12 KiB
mars::text — flat text readers
src/mars/text/ reimplements the three simple text formats the game reads
next to the brace-block catalogs: flat KEY value tuning tables (and their
positional-row cousins), the numbered id manifests, and the #///-commented
CSVs. Library target: mars_text (static, src/mars/text/CMakeLists.txt).
Include as mars/text/<header>.h. C++17, no dependencies, no exceptions cross
the API; every reader is total and reports oddities as Problems.
The behaviour spec for the KEY value tables is the original engine's
GlobalConsts loader as recovered in the RE repo
(findings/subsystems/loader-prototypes.md, §M1 with the tokenizer of §M3);
the RE repo's Python readers (sots-re/verify/parsers/flat_kv.py,
manifest.py) implement the same rules and serve as the oracle this module
is checked against file by file (below).
API
namespace mars::text {
// result.h
struct Problem { enum class Kind {...}; Kind kind; int line; long long id; std::string message; };
template <class T> struct Result { T value; std::vector<Problem> problems; bool ok() const; };
// value.h — lazily typed tokens
enum class ValueKind { Int, Float, Bool, Bareword, String /*quoted*/ };
struct Token { std::string text; bool quoted; ValueKind kind() const;
std::optional<int64_t> as_int() const; std::optional<double> as_float() const;
std::optional<bool> as_bool() const; };
struct Color { double r, g, b, a; int components; };
std::optional<Color> parse_color(std::string_view "r g b [a]");
// flat_kv.h
struct KvEntry { int line; std::string key /*original spelling*/; Token value; };
class FlatKV { entries(); find(key) /*case-insensitive, FIRST wins*/; has(key);
get_int/get_float/get_bool/get_string/get_color(key); duplicates(); };
struct Row { int line; std::vector<Token> tokens; }; using Rows = std::vector<Row>;
Result<FlatKV> parse_flat_kv(std::string_view); // engine loader loop (mars/parse/script.h)
Result<Rows> parse_rows(std::string_view); // line-based
std::string_view strip_comment(std::string_view line); std::vector<Token> split_tokens(std::string_view line);
// manifest.h
struct ManifestEntry { int line; int id; std::string name; };
class Manifest { entries(); deleted(); name_of(id); id_of(name) /*case-insensitive*/; is_deleted(id); };
Result<Manifest> parse_manifest(std::string_view);
// csv.h
struct CsvRow { int line; std::vector<std::string> cells; };
struct Csv { std::optional<std::vector<std::string>> header; std::vector<CsvRow> rows; };
Result<Csv> parse_csv(std::string_view); // comments dropped, cells stripped
Result<std::vector<CsvRow>> split_csv_records(std::string_view); // raw RFC-4180 split
}
Input is the file's raw bytes (std::string_view). The files are cp1252 with
mixed CRLF/LF; bytes ≥ 0x80 are passed through untouched and are never
whitespace or case-folded.
Behaviour
Flat KEY value tables (parse_flat_kv)
This is the engine's GlobalConsts::LoadFile loop, verbatim in shape. The
file is not read by lines: it is stepped through with the same pull
tokenizer the brace-block catalogs use (mars/parse/script.h,
Script::next()), and each step is one of
| step | what the loader does |
|---|---|
KEY value |
one token each. value is consumed by the key's registered parser; a colour must therefore be quoted ("48 29 2"), and LIST 1 2 three is the two pairs LIST=1, 2=three |
NAME { |
the block is skipped to its matching } (Problem::Kind::SkippedBlock) |
} |
ignored |
Tokenizer rules (script.h): whitespace is space/tab/CR/LF only; a bareword
runs to whitespace, so braces glued to a word are part of it; ", ' and
backtick open a quoted token that ends at the same character, no escapes,
quotes stripped, an unterminated quote runs to end of input
(UnterminatedQuote); a token whose text starts with // is a comment to
end of line (so 3//x is one word, "0 0 0"// junk is the value 0 0 0
followed by a comment); tokens are capped at 1023 bytes.
Keys are matched case-insensitively and the first occurrence wins:
the loader erases a key from its expected-set once consumed, so a later
duplicate is logged "multiply defined" and ignored. find() returns the first
entry; every pair stays in entries() in file order; the later line is
reported as DuplicateKey; duplicates() lists repeated (case-folded) keys
with their line numbers. Unknown keys are the caller's business (the engine
ignores them); a registered key absent from the file keeps its default.
End of input: the loader stops at the first step that does not complete.
A final KEY value whose value token touches the end of the file — no
trailing newline or space — is therefore dropped (DroppedTrailingPair),
as is a final key with no value. Two shipped files lose a key this way:
Data/Strategy/StrategyVars.txt (CIVILIAN_BURDEN_RATIO 0.5) and
Data/encounters.txt (HERALD_SPEECH_MAX_INVERVAL 45) — the game runs on
those keys' compiled-in defaults.
Typing is lazy: Token::kind() classifies a bareword exactly like the
reference coerce(): [+-]?\d+ → Int, [+-]?(\d+\.\d*|\.\d+|\d+)([eE][+-]?\d+)?
→ Float (.5, -.8, 1., 7e+8), true/false any case → Bool, else
Bareword (0x10, inf, nan, 1,5 are barewords). A quoted token is always
a String — "8" never becomes a number. as_float() also accepts Int shapes;
as_int() refuses values that do not fit in 64 bits. (The engine types by the
key's registered parser — sscanf %d/%f — not by the text; see the RE
notes. Token keeps the text so a consumer can apply the registered parser.)
Colours are quoted "r g b" / "r g b a" strings (tabs inside are fine, as in
globals.txt); get_color / parse_color require 3 or 4 numeric components.
KvEntry::line counts \n only (the engine has no notion of lines; the
number is for diagnostics).
Row tables (parse_rows)
Line-based (these files are read by other engine code whose reader has not
been recovered; the behaviour is the reference reader's). Per line: strip a
trailing // comment (quote-aware: // inside "..." is text, and an
unclosed " disables comment stripping for the rest of the line), trim, skip
if empty, then tokenise: "..." is one token (may be empty, may contain
spaces/tabs///; no escapes), otherwise a run of non-whitespace is one
bareword; a " that has no closing quote, or sits mid-word, is an ordinary
character. Whitespace here is the C set (space, \t, \r, \n, \v, \f).
UnbalancedQuote (odd number of " on a line) is reported but the line is
still parsed. None occur in shipped data.
Id manifests (parse_manifest)
Per trimmed line: blank → skip; a // DELETED - <n> marker anywhere on the
line (word case-insensitive, whitespace optional) → tombstone in deleted();
other //... → comment; <digits> <non-whitespace> and nothing else → entry.
Anything else is Problem::Kind::Unrecognised — including an entry followed
by a trailing comment, matching the reference regex ^\s*(\d+)\s+(\S+)\s*$.
A repeated id is DuplicateId (the later entry is kept and wins lookups); an
id that is both a tombstone and an entry is DeletedAndAssigned.
id_of() folds case (DEWar.SHIPSECTION ↔ dewar.shipsection).
CSV (parse_csv, split_csv_records)
split_csv_records reproduces the reference (Python csv, excel dialect,
non-strict) byte for byte on the shipped files:
,delimits;\r\n,\nand a lone\reach end a record; a blank line is an empty record[]; a trailing,yields a trailing empty cell; the last record needs no terminator.- A cell is quoted only if
"is its first byte. Inside quotes""is a literal"and newline bytes are kept verbatim (Strings.csvhas one such cell). Text after the closing quote is appended as-is ("tail"junk→tailjunk). - A
"anywhere else is an ordinary byte:EVENTMSG_DERELICT_UNRESOLVED"keeps its quote, and"Effects/Blastoid1.effect"(Asteroids.csv, quote after leading spaces) keeps both quotes. - End of input inside a quoted cell →
UnterminatedQuotedCell, cell kept.
parse_csv then drops comment rows (first cell, after leading whitespace,
starts with # or //), empty rows and rows whose cells are all blank
(,,), and strips every remaining cell. The header is the first # row with
more than one cell that appears before any data row, with # and any </>
trimmed from each cell: # <tech>,<human-pri> → tech, human-pri;
"# species","event" → species, event; #Key+A955,String,Size,Notes →
Key+A955, .... A # row with a single cell is a plain comment and does not
end the search.
Oracle results
tests/mars_text/build_and_run.sh compiles with g++ -std=c++17 -Wall -Wextra -Werror, runs the unit tests (201 checks, hand-written samples for every
rule above, including a dedicated engine-step suite for the kv loader), the
real-data facts test, and — when SOTS_DATA_DIR is set — dumps every file
mars_text owns through both dump_json (C++) and oracle/dump.py (the
Python readers) and compares them structurally, type-aware (compare.py).
Run 2026-09-07 (engine-parity rules) against the extracted sots.gob +
sots_local_en.gob tree:
| kind | files | agree |
|---|---|---|
kv (Data/**/*.txt) |
20 | 20 |
rows (_turrets.txt, _defaultweapons.txt, damfx*.txt, playercolors.txt, BadgeTable.txt, AvatarTable.txt, WeaponIconPlacements.txt) |
8 | 8 |
manifest (_weapons.txt, 7 × _shipsections.txt) |
8 | 8 |
csv (AI tables, scenarios, RealSpace.csv, SpriteTable.csv, Strings.csv, SpeechEvents.csv, asteroids, music, sound_ui) |
28 | 28 |
| total | 64 | 64 (100%) |
Compared per file: the full key → value map (case-insensitive keys under
their first spelling, first wins, typed values), duplicate-key report, every
row's typed tokens, manifest entries / tombstones / problems (kind, line, id),
CSV header and every stripped cell. Real-data facts also asserted:
globals.txt 364 keys, no duplicates, MARS_DEFAULT_COLOR = (255,177,39);
StrategyVars.txt 96 keys with CIVILIAN_BURDEN_RATIO dropped and
encounters.txt with HERALD_SPEECH_MAX_INVERVAL dropped (one
DroppedTrailingPair each); _turrets.txt 42 × 8 tokens; _weapons.txt 123
ids, deleted 36/58/59; Human _shipsections.txt 145 ids with
dewar.shipsection → 98; Strings.csv 5,722 raw records → 5,200 data rows →
5,196 distinct keys, exactly one multi-line raw cell; aitechpri.csv 0 rows
with a 7-name schema.
What changed when the kv reader moved from the earlier line-based, last-wins reading to the engine's rules: no shipped kv file contains a duplicate key (exact or case-folded), so first-wins flips no value; the only observable difference is the two dropped trailing pairs above.
Deliberate divergences from the reference
None affect shipped data (verified above); listed so nobody chases them:
- Row/manifest/CSV whitespace is ASCII-only. The Python readers operate on
decoded
str, so they would also treat cp12520xA0(NBSP) and bytes0x1C–0x1Fas whitespace / line breaks. The relevant files contain no such bytes. (The kv reader and the PythonScriptboth use the engine's exact four-character set.) - Ints are 64-bit; the reference has unbounded ints. An overflowing bareword
classifies as
Intbutas_int()returns nullopt. - The reference
csvmodule rejects a NUL byte; this reader treats it as data. Problemmessages are our own wording; the oracle comparison matches on kind / line / id, not text.
Open questions
- The
SkipBlockused forNAME {inside a kv file is assumed to count raw{/}tokens (Script::skip_block); no shipped kv file contains a block, so this cannot be observed from data. - The engine's own tolerance is unknown for a manifest entry with a trailing comment; we copy the reference (absent from shipped data).