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

213 lines
13 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `EncID`s (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.