sots-engine/docs/mars-stream.md
alex 7f04bcc88b lane WS: pay the ratchet debt -- type the five new bodies, do not move the bar
The corpus went 11 -> 19 saves and named coverage fell to 97.6% against a 99.99
bar. Rule 27 says type the content. Typed, all from the recovered wire schema
plus the records the new trade/spy saves finally carry:

  Game::CombatPlayerReport  the <rest:prep> tail -- ncls run of six per ship
                            class, nsec run of two per section, ndam, srep
  Game::CombatShipReport    srep elements (caps2 is an i64; dami/damt f32)
  Game::TacReport           TRnc is a COUNT and TRships/TRsats/TRshipsL are one
                            loop body, INTERLEAVED on the wire -- not three
                            trailing runs. Two lanes could not settle this
                            because TRnc was 0 in every save until tonight.
  Game::TacReportEvents     TRby / TRto
  Game::FleetLayout         Lay: a FieldTemplate frame then a count and its ids
  Game::TradeRoute          rt -- a container write with NO count word, read as
                            an uncounted run keyed on the tag
  Game::ServerTradeSector::FreighterWarning   fwarn elements
  Game::SpyCraft            spy elements
  Game::WeaponGroups        Dwg, and Game::GunBankSelection under it

Corrections found on the way, both silent until a shape was bound to the table:

  * Game::CombatReport: auto and cdst are BOOLs, dur/cdt/cdi are FLOATS. All
    five were ints here. Four are 0/1 everywhere so the bytes never moved; cdt
    is not, and was being read as 1070805848 instead of 1.598.
  * Game::CombatWeaponReport: the damage quartet is flat and dami/damt are
    floats. The old shape reached them through obj_flex, modelling a nested
    `dams` frame the binary does not write. That also made CoverageArchive
    charge one phantom typed item per weapon report -- exactly 8/13/21/32 on the
    four affected saves -- so the pre-fix coverage figures were slightly
    optimistic as well as too low.

FTPnts stays carried: it is the one item the recovery itself marks unresolved,
its count is 0 in every save, and element framing is a property of the helper --
the SysMem / mts / nalat trap. The workload that settles it is a save with a
stored fleet tactical formation.

Named coverage, per save, before -> after:
  human-turn5-traderoutes   99.0003% -> 99.9951%
  human-turn8-traderoutes   98.6349% -> 99.9955%
  human-turn11-spytechs     97.6382% -> 99.9959%
  human-turn15-spyprogram   96.9412% -> 99.9964%
  the other 16 saves        unchanged, 99.9940-99.9953%
  corpus                    99.5021% -> 99.9949%

All 20 saves are above the ratchet; the only opaque items left anywhere are the
two of the RNG blob. 14 new shapes bound to the wire schema, every one a full
match: 100 shapes, 966 items, 0 MISMATCH. ctest 58/58 over all 20 saves, both
round trips byte-identical, 0 errors and 0 warnings.
2026-09-08 20:14:18 -04:00

7.9 KiB
Raw Blame History

mars::stream — Streamable serialization (save files)

src/mars/stream/ reimplements the engine's self-describing "Streamable" stream as used by .sav files: a generic walker that recovers the item tree from any stream, a writer that emits the same framing, and typed shapes for the confirmed top-level structures. The format facts come from the RE repo's confirmed description (verify/save-reader/SAVE_FORMAT.md); the reference Python reader is the oracle the C++ is tested against.

The format in one screen

container   one gzip member; everything below is the inflated stream, little-endian
item        [int32 len][name bytes][value][NUL pad]      pad brings the WHOLE item to 4 bytes
                                                         ("joint" padding; a NULL name is written as ".")
scalars     int32 | float32 | bool (1 byte) | int64 | string = [int32 len][cp1252 bytes]
            (an empty string is 4 zero bytes — byte-identical to int 0; no type bytes anywhere)
frame       [len][name][pad]  BE EF BE EF  ...items...  10 41 10 41      (0x41104110 = ~0xBEEFBEEF)
arrays      VectorHelper<T>: a frame holding "." count + n × "." elements (frames or scalars)
            inline arrays:   a named count followed by n × element in the same frame
Vector3     a frame of 3 × "." float
root        Summary → CreateParams → Sim → CDT → n × CD
RNG         Sim.RNG { "." raw[2503] }: MT19937 mt[624] + left (0x9c4) + 3 pad bytes (see mars-rng.md)

Modules

file contents
bytes.h LE read/write helpers, pad4, the two markers
node.h Node{name, tagged, kind, raw, hinted, offset, size, children}, Issue, Stats
gzip.h/.cpp gunzip, inflate_container (passthrough for already-inflated data), gzip — via vendored miniz
reader.h/.cpp Walker — the generic tokenizer; read_tree()
writer.h/.cpp Writer — scalar/frame/raw emitters with joint padding; Writer::node() re-emits a tree
dump.h/.cpp dump_tree() text form (identical to the reference --dump), Python float repr, JSON quoting
schema.h Desc/Hint/Registry — the type hints the walker consults
archive.h ReadArchive, WriteArchive, SchemaBuilder — drive a shape's io() field list
shapes.h the typed shapes (Summary, CreateParams/MapP, Sim, Player, Sys, Fleet, Ship, …)
save.h/.cpp read_save_file/bytes() → SaveDocument{inflated, tree, game, issues, stats}, write_save(), write_tree(), save_registry()
savedump_main.cpp sots_savedump SAVE [--dump] [--strict] [--roundtrip] [--rewrite OUT]

Walker (generic reader)

The walker needs no schema. Frames are hard synchronisation points; between markers each item's scalar layout is chosen by

  1. a hint — positional (Desc::prefix, from the shape describing the frame) or by tag (Desc::by_name, then the global tag → kind catalog), or
  2. guessing: try [word, bool, string, int64] and keep the first layout after which another plausible item, a marker or EOF follows (two items of lookahead; string values only text-checked while guessing, since a known string may hold any cp1252 byte); a bare 4-byte word is int if |i| <= 100000, float if finite with 1e-6 <= |f| < 1e12, else int.

Nothing readable is lost: when no layout fits, the bytes up to the next marker or plausible tag become a raw node (a warning), a small unnamed payload before an END is a raw node (info), and the walk resumes. Every node keeps its exact value bytes and offset, so write_tree(read_tree(x)) == x for any input the walker accepts, damaged or not. These rules are the reference reader's; the test suite checks the two implementations tokenise the three real saves identically (every line of the dump, including the guessed/hinted marks).

Hints are not hand-maintained: save_registry() runs every shape's io() under SchemaBuilder, which records prefixes, by-name tables and the global catalog (a tag claimed with two different kinds is dropped from the catalog; optional legacy tags register at lowest priority; "." is never a hint key because it carries ints, floats and frames alike).

Shapes and archives

A shape is a struct with kStreamName and a template<class Ar> void io(Ar&) listing its fields in disk order:

struct Summary {
    static constexpr const char* kStreamName = "Summary";
    std::string gameName; int32_t turn = 0; ...
    template <class Ar> void io(Ar& ar) {
        ar.str(A("GameName"), gameName);      // A(): confirmed on-disk tag, matched by name
        ar.i32(A("Turn"), turn);
        ar.carr(A("Players"), players);       // VectorHelper: "." count + "." frames
        ...
        ar.rest(extra);                       // anything the shape does not describe, kept generic
    }
};

R("idx") fields are positional (the game wrote a NULL name — "." on disk — or the reference name differs from the disk spelling, e.g. faiDes → FAIDes, ontF → otnF, nextId → nextid); the second argument of R is the tag to write. Conditionals are plain ar.when(vnh, ...), optional legacy tags ar.opt_i32(A("ARes"), aRes), uncounted lists ar.repeat("stats", ...), inline arrays ar.narr(A("NumSys"), systems, elem). Bodies still kept opaque are Node members and re-emit verbatim. That list has shrunk to two kinds: the RNG blob, which is deliberately opaque, and containers no save has ever filled — nodePaths, Ojvs, usnc, FTPnts, the four SpyReport sub-lists, the deflay/rtgt pairs, and the polymorphic keys with no factory entry (SvSctOb variants, CD block suffixes). Those are hypotheses under rule 6, not modelling debt: the element framing is a property of the writer's helper and no record has ever shown one. On every save in the corpus the only opaque items left are the RNG blob's two.

ReadArchive mirrors the reference applier: a confirmed tag that is missing is an error, unexpected items before it a warning, a positional item read under another tag an info; kinds are coerced with the same width/zero rules (4 zero bytes are the empty string). The same io() drives WriteArchive, so a shape loaded from a real save writes back byte-identically — the whole file, typed shapes plus retained generic bodies.

Tests (tests/mars_stream/build_and_run.sh, plain g++)

  • test_stream.cpp — hand-built byte fixtures: primitive encodings and joint padding (bytes written out by hand and compared with the Writer), framing/nesting/tagless/empty frames, "." arrays, resync (two fixtures cross-checked with the reference reader), cp1252 values, hints from the registry, the raw RNG frame, typed Summary/Ship/Fleet write → read → write, gzip, Python float repr and JSON quoting.
  • test_rng.cpp — MT19937 reference vectors and state (de)serialization.
  • test_save.cpp — real saves when SOTS_SAVES_DIR is set (skips otherwise, saves never enter the repo): 0 errors/warnings/resyncs/hint failures, only the 2503-byte RNG blob raw, typed values consistent (Summary ↔ Sim ↔ CreateParams counts, names, 7 species), RNG state reachable from seed(RSeed), tree and typed round trips byte-identical; with SOTS_SAVE_READER set, oracle/compare.py diffs the C++ dump against the reference --dump (exact and canonical agreement, summary values).

Result on the owner's 20-save corpus (SotS 1.8): 100% exact line agreement, both round trips identical, summaries agree. Named coverage is 99.994–99.996% per save; the residual is the two items of the RNG blob and nothing else.

Not done / open

  • Only joint padding is implemented (the split convention never matched a real file).
  • Element shapes for the never-filled containers listed above — each needs a save that puts something in it before the framing can be typed rather than guessed.
  • The float-vs-int guess for unhinted words inside opaque bodies is a heuristic; the typed layer never depends on it (it coerces from the raw bytes).