sots-re/findings/objects/wire-schema-closeout.md
lane-w 2308b6edb9 lane W: SvSctOb variant factories; fix the four save_reader.py defects openly
Part 1 (notes side): findings/objects/svsctob-variants.md records the two maps
that are nowhere on the wire and were read out of the game -- EncID -> class
from a 23-entry dword jump table at 0x0052bf60 indexed by EncID-1 (0x0052bf00),
and xscn -> class from an exhaustive four-way _stricmp chain at 0x005a7050.
Twelve live EncIDs, four scenario names, and "indsys" =
Game::SVSOIndependentSystems whose Read and Write are both the shared `ret 4`
stub at 0x005f8ac0, so its empty frame is correct output rather than a
truncation. Also: SVSOSots::Read accepts NPCPlr and hastraps, which Write never
emits -- read-only backward compatibility, not a hole in the recovery. And a
correction to the recovery itself: SVSOCrowDefenders writes `dsys` INSIDE the
ndsys loop; layouts.json calls it a plain member and no save can settle it
because both counts are 0 everywhere. 13 addresses in ghidra/addresses.d/
lane-w.json; gen_addresses.py merges to 640 with no duplicate name.

Part 2: lane G found four defects in both readers and deliberately did not
patch the oracle mid-campaign. Fixed now, with tests, and byte-neutral.
  1. Game::SystemParams field 1 is a string, not an int (empty string == four
     zero bytes == int 0, so it round-tripped by luck).
  2. ObservedTech/ObservedWeapon odet is a bool, not an int (byte-safe only
     because a 4-char tag makes both items 12 bytes; 3 chars would not).
  3. SpeciesRatios nv is a count, not a field.
  4. ShipRecords srbd is a count, not a field -- and this one is behaviourally
     confirmed, not inferred: srbd takes 0, 1, 3 and 4 across the players and
     every non-zero count is followed by exactly srbd x 5 scalars.
Note that 3 and 4 were an ABSENCE in save_reader.py, not an error: ShipRecs and
civr were both A(..., "any"), so the fix had to add the shapes rather than
retype a field.

Byte-neutrality: every item's inflated offset is unchanged on all four saves
(38,933 / 39,843 / 40,300 / 35,771 offsets, sequences identical), so no item
boundary moved. state_checksum.py still reports coverage: PROVED on all four
with the same rebuilt byte counts. The /CreateParams and /Sim/players digests do
change, because they hash typed VALUES and two fixes change what a value is --
and the value-byte deltas balance exactly: odet items x 3 plus p1 items x 4.
--strict exit 0 on all four saves; tests 36 -> 48.

findings/objects/wire-schema-closeout.md carries the whole account, including
the proof that CD/TurnCommands_v5 cannot be typed without a save that has
issued orders.
2026-09-08 07:05:09 -04:00

13 KiB
Raw Blame History

Closing the opaque sections, and correcting the Python oracle

Lane W, 2026-09-08. Host/static only — VM140 was held by lane U and the game was never run. Continues findings/objects/wire-schema-channel.md (lane G).

Headline: named coverage 97.1 % → 98.0 %

The honest metric is CoverageArchive's split of items a field names from items a generic Node merely carries — not round-trip success, which ar.any bodies achieve trivially by copying bytes nobody understands. The byte-identical round trip held throughout.

save before after still opaque after
turn1-state 97.1 % 98.0 % CD 744, RNG 2, Attrib 2
turn2-state 97.2 % 98.0 % CD 744, RNG 2, Attrib 2
turn3-state 97.2 % 98.0 % CD 744, RNG 2, Attrib 2
zuul-turn5-species5 97.6 % 98.4 % CD 517, RNG 2, Attrib 2

Conformance: 74 shapes bound, 769 items matched, 0 MISMATCH (was 56 / 657 / 0). The ratchet in test_save.cpp moved 95.0 → 97.5. ctest 34/34; tools/clean_room_check.sh OK; test_save still skips cleanly with SOTS_SAVES_DIR unset.

CD is now the only remaining region of any size, and §4 says why it cannot be closed here.

1. SvSctOb — closed, 147 items → 0

Its own note: findings/objects/svsctob-variants.md. Summary: the body is Game::SVSOSots, which writes two variant lists dispatched by a preceding key (xscn for xsc, EncID for EncObj). Neither map is on the wire; both were read out of the game — the ids from a 23-entry dword jump table at 0x0052bf60 indexed by EncID - 1, the names from an exhaustive four-way _stricmp chain at 0x005a7050. Eighteen shapes bound, all clean. The four factory ids no save exercises (7 SystemKiller, 8 PuppetMaster, 14 Locust, 21 Ortgay) are deliberately left carried, not typed.

2. DOpts — closed, 94 items → 0. Certain, from the helper's own decorated name.

Game::ShipDesignDef::Section's DOpts is ?$VectorHelper@VString@Mars@@@Mars — a Mars::VectorHelper<Mars::String>. The element type is not inferred from the bytes; it is in the mangled helper type the writer instantiated. The engine's read_elem/write_elem grew a std::string branch (the one thing lane G listed as missing) and carr<std::string> now works.

The same branch closed Game::SVSOVonNeumann::trev, also VectorHelper<Mars::String>.

Note the empty-string trap this had to survive: an empty string is four zero bytes, so an empty DOpts element is byte-identical to the int 0. The new unit test writes {"OPT_Armour", "", "OPT_Shield"} specifically to exercise it.

3. spies2 — closed, 56 items → 0, but read this before believing the element layout

Game::ServerSystem's spies2 is ?$VectorHelper@H@Mars — Mars::VectorHelper<int>. So it is a framed array of NULL-named ints, and that is a certainty about the type, from the same decorated-name evidence as DOpts, not a guess from the bytes.

What is not exercised is any element. The count is 0 in all 28 systems of all four saves, so the 56 items closed here are 28 × (the frame + its . count) and no spies2 element value has ever been observed on disk. If a future save carries spies, the shape says they are plain ints; that claim rests entirely on VectorHelper<int> and would be falsified by the first non-empty one. Labelling it: the type is certain, the behaviour is a hypothesis.

Game::SVSOSwarmQueen::SysMem and Game::SVSOVonNeumann::mts are in the same position — ?$VectorHelper@U?$StreamableEnum@I@Mars@@@Mars, i.e. VectorHelper<StreamableEnum<uint>>, typed as int arrays, count 0 everywhere.

4. CD — NOT closed, and here is the evidence that it cannot be, without a workload

CD is two different problems wearing one tag. CDT lists the ids in order and one CD frame follows per id:

  • Player.<id>.TurnCommands_v5 → Game::TurnCommands. One block, 35 items on turn1.
  • Player.<id>.AIAgent → Game::StrategyAIAgent::Streamable. Three blocks, ~236 items each, and these are the bulk of the 744.

4a. TurnCommands: the recovered sequence provably does not align to a no-orders save

Game::TurnCommands is recovered complete at 44 items, every tag ".". The turn1 block has 35. Lane G called it a no-orders snapshot. That is right, and it is now stronger than a description — the two sequences cannot be aligned at all, not even as a subsequence:

  • Positionally they diverge at item 4: the save's item 4 is 8 bytes, which for a 1-character tag can only be a bool (4 + 1 + 1 → 8; an int is 4 + 1 + 4 → 12). The recovery says i32. Item 16 is a plain 12-byte int in the save where the recovery says a frame.
  • As a subsequence it fails on counting. The save is int, bool, float, bool, bool, bool, bool, bool then 27 consecutive ints. After the recovery's item 15 there are only 22 i32 slots (indices 17–20, 22–27, 29–31, 33–40, 42); the rest are frames, a narr, and bools. 27 > 22, so no order-preserving assignment exists.

So the writer takes branches that a no-orders turn does not, and with every tag "." there is no name to bind against. TurnCommands needs a save with issued orders — which needs the VM. Anything typed from here would be a guess dressed as a layout, so nothing was typed.

4b. AIAgent: statically tractable, deliberately not attempted

Game::StrategyAIAgent::Streamable is recovered at 36 items with named tags (AIAttr, AITurnPris, AISit, AIPlyHat, prs2, dsh, AIDNG, AISys, CmbR, apr, …), so unlike TurnCommands it could be bound by name. Two reasons it was left: the recovery is incomplete (8 unresolved items) and roughly ten nested sub-objects would each need their own shape and their own conformance binding. It is the single largest remaining block and a clean, self-contained job for a following lane — but it is AI cache state, not orders, so it is not on the reimplementation critical path the way 4a is.

4c. RNG (2) and Attrib (2)

RNG is correctly opaque — the raw MT19937 state block — and was left alone, as instructed. Attrib is StreamableHelper<Game::AttribMap> and is an empty frame in every save; there is nothing to type.

5. The four defects in save_reader.py, fixed openly

Lane G found four real defects in both readers, fixed the engine, and deliberately left the Python oracle alone rather than quietly editing it mid-campaign. That judgement was right; this is the follow-through. All four are now fixed in verify/save-reader/save_reader.py, each with a test, and all four are byte-neutral on all four saves.

# defect fix
1 Game::SystemParams field 1 is a string, not an int Planet's p1 retyped
2 ObservedTech/ObservedWeapon odet is a bool, not an int Owep/Otch retyped
3 Game::SpeciesRatios nv is a count new SpeciesRatios/CivilianRatios shapes
4 Game::ShipRecords srbd is a count new ShipRecords shape

A wrinkle worth recording: for 3 and 4 the reader did not have the wrong layout, it had no layout — ShipRecs and civr were both A(..., "any"). So the "defect present in both readers" was, on the Python side, an absence rather than an error. Fixing it meant adding the typed shapes with the count semantics spelled out, which is a bigger change than a retype and is why the byte-neutrality evidence below matters more than usual.

The byte-neutrality evidence

Every item's inflated offset is unchanged. Dumping all four saves before and after and comparing the offset column:

turn1-state:          offset sequence IDENTICAL (38,933 items)
turn2-state:          offset sequence IDENTICAL (39,843 items)
turn3-state:          offset sequence IDENTICAL (40,300 items)
zuul-turn5-species5:  offset sequence IDENTICAL (35,771 items)

so no item boundary moved; only type labels changed. state_checksum.py still reports coverage: PROVED on all four (591,376 / 603,360 / 609,080 / 532,752 bytes rebuilt == inflated, unchanged), with 0 error, 0 warn.

The subsystem digests for /CreateParams and /Sim/players do change, and that is the point — they are digests of typed values, and two of the fixes change what a value is. The value-byte totals account for the change exactly:

save Δ value bytes odet items × 3 (int 4 B → bool 1 B) p1 items × 4 (int 4 B → "" 0 B)
turn1 −292 60 × 3 = 180 28 × 4 = 112
turn2 −295 61 × 3 = 183 28 × 4 = 112
turn3 −295 61 × 3 = 183 28 × 4 = 112
zuul −238 42 × 3 = 126 28 × 4 = 112

Every row balances to the byte. Nothing else in the digest moved.

--strict exits 0 on all four saves. Tests went 36 → 48, all passing.

One finding that fell out of the fix

srbd is not merely inferred from the recovery — the saves exercise it. It takes the values 0, 1, 3 and 4 across the players of the four saves, and every non-zero count is followed by exactly srbd × 5 scalars (turn3 player 4: srbd == 4, then 20 items in four srd/src/srb/srl/sri groups). Read as a field, those trailing scalars have no explanation at all; the new test asserts exactly that, and the field reading raises. So defect 4 is behaviourally confirmed, not a hypothesis. Defect 3 is confirmed the same way but more weakly: nv is only ever 0 or 1 in these saves, so the count reading is confirmed for one pair and the multi-pair case is synthetic.

The tests

test_save_reader.py::WireSchemaDefectsTest, 12 tests. Each defect gets the case the real saves happen not to contain (a named system, a bool-valued odet, two species pairs, a non-empty design-record section), plus a test showing why it stayed invisible — an empty string and the int 0 are the same four bytes; a bool item and an int item are the same 12 bytes only because odet is 4 characters long, and the same test shows a 3-character tag would not have been forgiving.

One nuance the tests made explicit and that belongs on the record: in save_reader.py the generic walker types "." items from its own kind catalog, not from the schema passed in. On the real saves the catalog said int for p1 and the old schema said int, so the two agreed with each other and were both wrong — the failure mode there is a silent agreement, not the framing desync the schema-driven engine would have suffered.

6. Artifacts

  • sots-engine wip/wire: src/mars/stream/shapes.h (18 new SVSO shapes, DOpts → vector<string>, spies2 → vector<int32_t>, SvSctOb → opt_obj<ScriptObjects>), src/mars/stream/archive.h (std::string element branch in read_elem / write_elem / SchemaBuilder::carr), tests/mars_stream/test_wire_schema.cpp (+18 bindings), tests/mars_stream/test_stream.cpp (+2 unit tests, no saves needed), tests/mars_stream/test_save.cpp (ratchet 95.0 → 97.5).
  • sots-re: verify/save-reader/save_reader.py + test_save_reader.py, ghidra/addresses.d/lane-w.json (13 entries), findings/objects/svsctob-variants.md, this note.
  • include/generated/sots_stream_schema.h was not regenerated: tools/streams.py and tools/gen_stream_schema.py were re-run and the output is byte-identical to the committed header apart from the provenance line, because nothing on the notes side changed the recovery.
  • ghidra/generated/sots_addresses.h was not regenerated either. Lane W's addresses are in the fragment; gen_addresses.py merges to 640 entries with no duplicate name (checked against a scratch path). Regenerating in place would have swept lane U's in-flight lane-u.json into this lane's branch, and the engine consumes none of these addresses.

7. Open items

  1. A save with issued turn commands remains the blocker for TurnCommands_v5 (§4a), and it is now a proven blocker rather than a suspected one.
  2. Game::StrategyAIAgent::Streamable (§4b) — the largest remaining block, bindable by name, ~10 nested shapes, no VM needed.
  3. The four unexercised EncIDs (7, 8, 14, 21) and ScnObj stay carried until a save or a scenario game produces one.
  4. serializers.py should reclassify Game::SVSOCrowDefenders's dsys as a loop element of ndsys (see svsctob-variants.md §3); objects/streams.json still carries the old reading and the conformance check does not notice, because it aligns by tag and disk primitive.
  5. spies2, SysMem, mts, trev, Ojvs, aid, Attrib, comms.nmsg, AIEnf.Nas, spymgr.nspy and spy2.rtc/evc/ttc are 0 in every save. Their containers are typed; their element behaviour is not verified by anything.