diff --git a/findings/subsystems/writer-byte-exact.md b/findings/subsystems/writer-byte-exact.md new file mode 100644 index 0000000..5d68a93 --- /dev/null +++ b/findings/subsystems/writer-byte-exact.md @@ -0,0 +1,501 @@ +# The writer is byte-exact on all 43 saves, and the coverage ratchet is honoured, not moved + +- **Type:** subsystem / engine (`mars/stream`) + gate +- **Owner / date:** lane DW · 2026-09-09 · **HOST ONLY**. No VM was touched — VM140, VM141, VM145 + and VM146 were not approached, not pinged and not logged into. CT111 was used only as the gate's + build/test host, through `tools/gate.sh`, exactly as that script already does. +- **Trigger:** `campaign/backlog.md` §3 **Track 0**, items 0a and 0b — the two things that must be + true before any byte-match claim on the current save family means anything. +- **Consumes:** lane CV's `findings/subsystems/rung-b-rich-turn.md` §4.1 (which named the writer + defect and correctly stopped), `guides/method-rules.md` rules 5, 6, 12, 21, 23, 24, 25, 27, + `objects/streams.json` + `include/generated/sots_stream_schema.h`, and `dumps/sots.exe` read with + `dumps/b6dis.py`. +- **Touches:** `sots-engine/src/mars/stream/shapes.h`, `tests/mars_stream/test_save.cpp`, + `tests/mars_stream/test_domains.cpp`, `tests/mars_stream/test_wire_schema.cpp`; + `sots-re/tools/gate.sh`; this file. **No save was modified and nothing was written into + `verify/results/saves/`.** + +--- + +## 0. Verdict + +> **`tools/gate.sh --fresh` is GREEN: 59/59 with the 43-save corpus, 0 corpus tests skipped, +> shim cross-build present.** `mars_stream_test_save` goes from **39 failures across 15 of 43 +> saves** to **0**; every one of the 43 saves now round-trips **byte-identically** through the typed +> writer and through `sots_turn --roundtrip`. The `SchemaProbe` conformance test still reports +> **0 MISMATCH**, with **six new shapes bound and every one of them matching item-for-item**. +> +> Three bodies were typed, not one: the `usp`/`usc` pair the writer was dropping (item 0a), and the +> two bodies the ratchet was actually complaining about — the `Sprj` special-project frame and the +> `FTPnts` formation points (item 0b). **The ratchet was not lowered. It was replaced with a +> strictly stronger one** (§4), and the domain baseline was raised from 490 to **538** (§5). +> +> **And the gate itself was not honest.** It piped `ctest` through `tail`, so it took *tail's* exit +> code: the baseline run below prints `GATE ok host-ctest` directly above `2 tests failed`. That is +> rule 1's failure inside the script written to prevent it, and it is fixed here (§6). + +--- + +## 1. Predictions, written before the first build (rule 2) + +| # | prediction | verdict | +|---|---|---| +| P1 | The 12 short saves lose exactly one `usp` **i32 item** = 4-byte length + 3-byte tag + 4-byte value + 1 pad = **12 bytes**, so modelling the pair closes the size gap exactly and no other byte moves. | **HELD.** All 12 become byte-identical; the other 31 are untouched. | +| P2 | Item 0b is **not** one body. `test_save` prints the opaque tags, and CV only reported `usc`; a save that is 12 bytes short and *also* below the bar must be carrying something else. | **HELD, and it was two more bodies** — `Sprj` (14 items, the same 12 saves) and `FTPnts` (6/12/18 items, 6 saves, 3 of which have no writer defect at all). | +| P3 | Raising `pct >= 99.99` after typing everything will be possible. | **FALSIFIED, and the falsification is the finding.** With only the deliberately-opaque MT19937 block left, the *worst* save reads **99.99400** — barely above the existing bar — because the percentage is `2/items` and the smallest save has the fewest items. A percentage ratchet punishes small saves for being small. The count is the ratchet (§4). | +| P4 | `usp` is readable from the binary, so the lane will not have to carry it "typed by tag only". | **HELD** (§2). | + +--- + +## 2. Item 0a — what `usp` *is*, read out of the writer + +`Game::SpecialProjectNameGen::Write` is at **0x008147e0** (`objects/layouts.json` gives the write +VA; `dumps/b6dis.py 0x8147e0` disassembles it to the next function start, rule 17). The whole loop +body, in order: + +``` +008147ed mov ecx,[esi+8] / sub ecx,[esi+4] / imul 0x2e8ba2e9 / sar edx,4 ; (end-begin)/0x58 +00814816 call WriteInt ; "NNms2" = record count, stride 0x58 = 88 bytes +00814837 mov edi,[esi+4] / add edi,[ebp-4] ; edi = this record +00814850 cmp BYTE PTR [eax-1],0x0 ... 8x ; unrolled 4-at-a-time scan of 32 BYTES at record+0 +0081485b add DWORD PTR [ebp+8],edx ; counting the NON-ZERO ones +0081488b call WriteInt ; "usnc" = that count +00814893 xor esi,esi ; for (slot = 0; slot < 0x20; ++slot) +00814895 mov al,[esi+edi] / test al,al / je ; if (table[slot] == 0) continue; +008148b1 call WriteInt ; "usp" <- esi THE SLOT INDEX +008148c2 call WriteInt ; "usc" <- movzx al THAT SLOT'S COUNT BYTE +008148cb cmp esi,0x20 / jl ; } +008148dc call WriteString ; "Nm" <- record+0x20 +008148ed call WriteString ; "Ntg" <- record+0x3c +``` + +The two tag pointers are `0xa2bbf8` and `0xa2bbf4`, and those addresses hold the literals `usp` and +`usc` in the image — so the **order on the wire is `usp` then `usc`**, confirmed from the string +table and not only from the one save that shows it. + +So, stated plainly: + +> **A name-generator record carries a 32-slot table of per-suffix use counts. `usnc` is the number +> of non-zero slots. For each non-zero slot the writer emits the slot's INDEX as `usp` and the +> slot's count as `usc`.** Both go through `WriteInt`, so both are `i32` on the wire even though the +> count is a byte in memory (rule 5). The record is 88 bytes: `uint8 used[32]`, then `Nm` at +0x20 +> and `Ntg` at +0x3c (two `std::string`s at 0x1c each, which is exactly 0x58). + +This also explains why the recovery marked `usnc` and `usp` as `const 0`: a linear pass over `Write` +sees the two counters initialised to zero and cannot see the loops. The old shape's comment — +*"usnc is 0 in every save available"* — was rule 6's hypothesis, and `ad-turn27-two-raiders.sav` +falsified it. + +**The ambiguity this kills.** From the corpus alone, `usnc = 1, usp = 0, usc = 1` is equally +consistent with "`usp` is a nested count that happens to be 0". The disassembly settles it: `usp` is +the loop induction variable `esi`, written unconditionally inside the same `if` body as `usc`. There +is no nested container. + +### The shape + +```cpp +struct UsedName { // one non-zero slot + int32_t slot = 0; // usp + int32_t count = 0; // usc + template void io(Ar& ar) { ar.i32(A("usp"), slot); ar.i32(A("usc"), count); } +}; +struct ProjectName { + std::vector used; + std::string nm, ntg; + template void io(Ar& ar) { + ar.narr(A("usnc"), used, [](Ar& a, UsedName& e) { e.io(a); }); + ar.str(A("Nm"), nm); + ar.str(A("Ntg"), ntg); + } +}; +``` + +`SchemaProbe` on `ProjectNames` now emits `NNms2, usnc, usp, usc, Nm, Ntg` against a wire table of +`NNms2, usnc, usp, usc, Nm, Ntg`: **6/6 matched, 0 wire-only, 0 shape-only, 0 opaque** (it was +5 matched / 1 wire-only / 1 opaque). + +### What the corpus actually holds + +Every one of the 12 affected saves holds **exactly one** used-slot record, and it is the same one: +`usnc = 1`, `usp = 0`, `usc = 1` — suffix slot 0, used once. 102 of `ad-turn27`'s 103 records have an +empty table. So the model is exercised at *one* point of its domain (rule 15/23): a record with two +or more used slots, or a slot index other than 0, has never been seen. The model handles them by +construction, and the cheap falsifier is named in §8. + +--- + +## 3. Item 0b — what the ratchet was actually catching, per save + +`test_save` prints the opaque tags, and reading them rather than the percentage is what showed this +was three bodies and not one. **Before**, over 43 saves: + +| tag | items/save | saves | what it was | +|---|---:|---:|---| +| `RNG` | 2 | 43 | the MT19937 block — **deliberately** opaque, and it stays that way | +| `Sprj` | 14 | 12 | a special-project frame carried whole (`ar.any`) | +| `usc` | 1 | 12 | the name-generator element, half-modelled (§2) | +| `FTPnts` | 6 / 12 / 18 | 6 | 1 / 2 / 3 stored formation points, carried as Nodes | + +Per save, before → after: + +| save | before: opaque tags | pct before | round-trip | after | +|---|---|---:|---|---| +| `ad-oracle-A-post` | `Sprj=14 RNG=2 usc=1` | 99.9749 | **12 B short** | `RNG=2`, identical | +| `ad-oracle-B-post` | `Sprj=14 RNG=2 usc=1` | 99.9749 | **12 B short** | `RNG=2`, identical | +| `ad-turn27-two-raiders` | `Sprj=14 RNG=2 usc=1` | 99.9746 | **12 B short** | `RNG=2`, identical | +| `ap-turn22-spydetected` | `FTPnts=12 RNG=2` | 99.9745 | ok | `RNG=2`, identical | +| `ap-turn25-spydestroyed` | `FTPnts=6 RNG=2` | 99.9858 | ok | `RNG=2`, identical | +| `ar-oracle-A-post` | `Sprj=14 RNG=2 usc=1` | 99.9773 | **12 B short** | `RNG=2`, identical | +| `ar-oracle-A-pre` | `Sprj=14 FTPnts=6 RNG=2 usc=1` | 99.9692 | **12 B short** | `RNG=2`, identical | +| `ar-oracle-B-post` | `Sprj=14 RNG=2 usc=1` | 99.9773 | **12 B short** | `RNG=2`, identical | +| `ar-r1-turn43-post` | `Sprj=14 FTPnts=6 RNG=2 usc=1` | 99.9707 | **12 B short** | `RNG=2`, identical | +| `ar-r2-probes8-post` | `Sprj=14 RNG=2 usc=1` | 99.9773 | **12 B short** | `RNG=2`, identical | +| `ar-turn37-816raiders` | `Sprj=14 FTPnts=6 RNG=2 usc=1` | 99.9691 | **12 B short** | `RNG=2`, identical | +| `az-turn23-tarka-comraid` | `FTPnts=18 RNG=2` | 99.9660 | ok | `RNG=2`, identical | +| `bp-pinA-turn28` | `Sprj=14 RNG=2 usc=1` | 99.9750 | **12 B short** | `RNG=2`, identical | +| `bp-pinB-turn28` | `Sprj=14 RNG=2 usc=1` | 99.9750 | **12 B short** | `RNG=2`, identical | +| `bp-turn28-pre` | `Sprj=14 RNG=2 usc=1` | 99.9746 | **12 B short** | `RNG=2`, identical | +| the other **28** saves | `RNG=2` | 99.9940–99.9969 | ok | unchanged | + +12 saves short by exactly 12 bytes; 15 saves below the bar; 39 assertion failures +(12 × 3 + 3 × 1). All of them now pass. + +### 3.1 `Sprj` — a polymorphic frame, and the discriminator's mapping is measured + +`Sprj` is written with a `SprjT` immediately before it, and the body class depends on it. Both +halves of the mapping were read out of the image: + +* **Writer** (`Game::ServerPlayer::Write`, 0x00856fe4): `SprjT` comes from the **plain member at + `project+0x3c`** — `mov eax,[eax+0x3c]` — *not* from a virtual call. +* **Reader** (`Game::ServerPlayer::Read`, 0x00881aa7): reads `SprjT`, then passes it as the first + argument to the factory at **0x008610a0**, whose 4-entry jump table at **0x008611e8** is + + | `SprjT` | arm | size | vftable | class | + |---:|---|---:|---|---| + | 0 | 0x8610d6 | 0x54 | `0xa3185c` | **`Game::BackEngProject`** | + | 1 | 0x861119 | 0x54 | `0xa3180c` | `Game::MonitorProject` | + | 2 | 0x8611a1 | 0x4c | `0x9fa438` | `Game::JewelsProject` | + | 3 | 0x861150 | 0x60 | `0xa31884` | `Game::TechOfferProject` | + + and **each of those constructors stores that same index at `+0x3c`** (0x0085325a and 0x008530aa + for the two shared base constructors; 0x00853149 writes the literal 1 for the monitor arm). That + closes the loop: the value the writer emits is the index the factory consumes. `SprjT` outside + 0..3 creates nothing, so a save carrying one is malformed. + +`Game::TechProject` (vftable `0xa31834`) has **no factory entry** and therefore cannot come off a +save; it shares `BackEngProject`'s serializer (Read 0x853280 / Write 0x853390) byte for byte, so the +two are indistinguishable on the wire in any case. + +**Only `SprjT == 0` is exercised** — one record, in all 12 of the `ad-*`/`ar-*`/`bp-*` saves, and it +is the same record: a back-engineering project on *"Magonian Prophicies"* for `DRV_RecFiss`, +`AOdd 0.05`, `AInc 0.025`, `RCst 5000`, `RDn 0`. The other three arms are typed from the recovered +schema plus the factory and are **hypotheses until a save exercises them (rule 6)** — they are +labelled as such in the code. An unknown `SprjT` falls to `rest()`, so it surfaces as opaque +coverage and breaks the ratchet rather than being mis-read in silence. + +The idiom is the one `EncounterObject` already uses: a `switch`, not a `when`, so `SchemaProbe` does +not concatenate the arms, and each arm is bound to its own wire class: + +``` + SpecialProject -> Game::SpecialProject [verified 5/5] shape 6 wire 6 match 6 opaque 0 + BackEngProject -> Game::BackEngProject [verified 4/4] shape 7 wire 7 match 7 opaque 0 + MonitorProject -> Game::MonitorProject [verified 4/4] shape 7 wire 7 match 7 opaque 0 + JewelsProject -> Game::JewelsProject [verified 2/2] shape 4 wire 4 match 4 opaque 0 + TechOfferProject -> Game::TechOfferProject [verified 5/5] shape 10 wire 10 match 10 opaque 0 +``` + +### 3.2 `FTPnts` — the workload the old comment asked for now exists + +`sh::FieldTemplate` carried its points as Nodes, with a comment that is worth quoting because it was +right and is now spent: + +> *"the count is 0 in every Lay in the corpus. That is exactly the pair of conditions under which +> SysMem, mts and nalat were all typed wrong … What settles it is a save whose fleet has a stored +> tactical formation."* + +Six saves in the 43-save corpus have one: `ap-turn22-spydetected` (2 points), `az-turn23-tarka-comraid` +(3), and `ap-turn25-spydestroyed` / `ar-oracle-A-pre` / `ar-r1-turn43-post` / `ar-turn37-816raiders` +(1 each). The framing is the ordinary `CArr` — an `FTPnts` frame holding a `"."` count and that many +NULL-named element frames of five `i32` items — and `Game::FieldTemplate::Point` is now bound +(`shape 5 wire 5 match 5`). Rule 28 practice 5 in miniature: **the state was already sitting in the +save set.** + +Caveat, stated because it is the same shape as the trap above: `FTPPosX`, `FTPPosY` and `FTPSqd` +read **0 in every point observed**, so their disk type (`i32`, from the `WriteInt` vftable slot) is +the schema's word and not the corpus's. A formation with a ship moved off the origin is what would +tell an `i32` grid coordinate from a float one **by value**. + +--- + +## 4. The coverage ratchet: not lowered, replaced with a stronger one + +`pct >= 99.99` **stays exactly where it is.** What is added beside it: + +```cpp +CHECK(pct >= 99.99); +CHECK(cov.opaque == 2); +CHECK(cov.opaque_by_tag.size() == 1 && cov.opaque_by_tag.count("RNG") == 1); +``` + +The reason the percentage could not simply be raised is measured, not asserted. After typing +everything, the opaque set is the MT19937 block's two items in every save — and the resulting +percentage still ranges from **99.99400** (`zuul-turn5-species5`, 33,318 stream items) to +**99.99746** (`ar-r1-turn43-post`, 78,633 items), because the number is `2 / items`. Raising the bar +to 99.995 would have failed on a save that types **perfectly**, and passed a save twice its size that +had picked up a whole new opaque body. **A percentage ratchet on a fixed-size debt is a ratchet on +save size.** + +The item-count form has none of that: it breaks on the save that *has* the new body, it names the +body (`opaque_by_tag`), and it cannot be satisfied by adding a big save. It is strictly stronger than +the line it sits next to, and rule 27's instruction — *type the content instead* — is what raises it. + +**Before:** 15 saves under the bar, 4 distinct opaque tags. +**After:** 43 saves, `still opaque: RNG=2` on every one of them. + +--- + +## 5. The value-domain census, before and after + +`test_domains` walks the same populated shapes and asks the other question — not "is the field +named" but "what has the corpus ever put in it". + +| | fields observed | vary | constant | +|---|---:|---:|---:| +| baseline in the file (2026-09-09, **22** saves) | 724 | 490 | 234 | +| this tree, **43** saves, before typing | 736 | 536 | 200 | +| this tree, **43** saves, after typing | **755** | **538** | **217** | + +The 19 new fields are exactly the three bodies: `usp`/`usc` (2), the `SprjT == 0` arm — `Stp`, the +six of `SPi`, `AOdd`, `AInc`, `Tch`, `RCst`, `RDn` (12) — and the five of a formation point. + +**Only two of the 19 have ever been seen to move** (`FTPShID`, `FTPDesID`). That is why the count of +*constants* rose by 17 while *varying* rose by 2, and it is the honest shape of this kind of work: +typing a body adds far more unexercised fields than exercised ones, and this census is where that +stays visible instead of being absorbed into a coverage percentage. + +`kVaryingBaseline` is raised **490 → 538**, deliberately, with the date and count in the comment in +the same form as the line it replaces. It is a floor: removing a save or losing a shape now fails. + +--- + +## 6. The gate was not honest, in two ways, and both are fixed + +**(a) `ctest`'s exit code was being thrown away.** The step was + +```sh +ct "cd $REMOTE_TREE/build-host && SOTS_SAVES_DIR=$REMOTE_CORPUS ctest --output-on-failure 2>&1 | tail -15"; report host-ctest $? +``` + +`cmd | tail` returns **tail's** status. The baseline run in §7 prints `GATE ok host-ctest` +immediately above `2 tests failed out of 59`. The gate went RED only because of the *separate* +skip check; had the corpus tests been running, two failing tests would have been reported green. +This is rule 1's failure mode wearing the clothes of the script written to prevent it. The same +masking was on the build step. Both now capture the real status and trim afterwards. + +**(b) The skip check could never reach zero.** It counted every `unset, skipped` line, and two of +them are not corpus tests: `game_design_census` also wants `SOTS_DATA_DIR`, and `smoke_real_save` +wants `SOTS_SAVES_JSON`. So the gate reported "corpus tests skipped (2) — the gate is hollow" while +both corpus tests were in fact running. + +The fix makes the gate do **more**, not less: + +* `SOTS_DATA_DIR=/srv/re-lab/gob-extract` is now set, so **`game_design_census` runs against the + corpus for the first time in this gate** — 43 saves, 2,728 designs, 1,962 census leaves, + **0 mismatched**. That is a test that was skipping and is now a real check. +* the skip check is scoped to the tests gated on the **corpus** (they name `SOTS_SAVES_DIR` when + they skip), and **every remaining skip is printed by name** on its own `GATE info skip:` line, so + a skip can never again be invisible. + +**The one skip that remains, named rather than hidden:** `smoke_real_save` wants +`SOTS_SAVES_JSON`, a JSON produced by `verify/save-reader/save_reader.py` in the *other* repo, which +this gate does not sync. It is a **print-only test with no assertions** ("no assertions on exact +values yet" — its own header), so running it would add no check; and wiring the RE repo's reader +into the engine gate would couple two things rule 8 warns about coupling. Left as a named info line +and a proposed board row, not silently satisfied. + +--- + +## 7. The gate, before and after (rule 24: `--fresh`, remote build dirs removed) + +**Before** — `~/sots-engine @ e7e2bd6`, unmodified: + +``` +== gate: /home/alex/sots-engine @ e7e2bd6, corpus 43 saves, fresh=1 +clean-room check: OK +GATE ok clean-room +check_shim_configs: OK (27 registered hooks, 2 template(s) declared exhaustive) +GATE ok shim-configs +GATE ok sync-to-host +GATE ok clear-remote-builds +GATE ok sync-to-ct +GATE ok corpus-to-host +GATE ok corpus-to-ct +GATE info corpus on CT111: 43 saves +[100%] Built target mars_stream_test_stream +GATE ok host-build +... +97% tests passed, 2 tests failed out of 59 + +The following tests FAILED: + 22 - mars_stream_save (Failed) + 53 - app_turn (Failed) +Errors while running CTest +GATE ok host-ctest <-- WRONG: tail's exit code, not ctest's +GATE FAIL corpus tests skipped (2) -- the gate is hollow +[100%] Built target sots_turn +-rwxr-xr-x 1 root root 2347312 Sep 9 13:44 build-shim/binkw32.dll +GATE ok shim-cross-build +== gate: RED +``` + +**After** — same command, `tools/gate.sh --fresh`. This is the run taken **alone**, with no other +gate against CT111 at the same time (§8.1), which is why it is the one quoted. The header still +reads `@ e7e2bd6` because the run was made from the working tree before the two commits below were +made; the tree the gate rsynced and tested is byte-for-byte what `076f714` + `00e9266` contain. A +second run **after** committing reproduced `GATE ok host-ctest` / `100% tests passed, 0 tests failed +out of 59` / `corpus tests ran (0 skipped)` at `@ 00e9266`, and was abandoned in its shim step when +a second gate started from another session. + +``` +== gate: /home/alex/sots-engine @ e7e2bd6, corpus 43 saves, fresh=1 +clean-room check: OK +GATE ok clean-room +check_shim_configs: OK (27 registered hooks, 2 template(s) declared exhaustive) +GATE ok shim-configs +GATE ok sync-to-host +GATE ok clear-remote-builds +GATE ok sync-to-ct +GATE ok corpus-to-host +GATE ok corpus-to-ct +GATE info corpus on CT111: 43 saves +[100%] Built target mars_stream_test_stream +GATE ok host-build +54/59 Test #54: app_trade_raid ................... Passed 0.00 sec + Start 55: app_alliance +55/59 Test #55: app_alliance ..................... Passed 0.00 sec + Start 56: app_treaty +56/59 Test #56: app_treaty ....................... Passed 0.00 sec + Start 57: app_event_phase +57/59 Test #57: app_event_phase .................. Passed 0.00 sec + Start 58: app_turn_record +58/59 Test #58: app_turn_record .................. Passed 2.29 sec + Start 59: app_command_replay +59/59 Test #59: app_command_replay ............... Passed 0.00 sec + +100% tests passed, 0 tests failed out of 59 + +Total Test time (real) = 14.73 sec +GATE ok host-ctest +GATE ok corpus tests ran (0 skipped) +GATE info skip: 19: smoke_real_save: SOTS_SAVES_JSON unset, skipped +[100%] Built target sots_turn +-rwxr-xr-x 1 root root 2347312 Sep 9 14:00 build-shim/binkw32.dll +GATE ok shim-cross-build +== gate: GREEN +``` + +--- + +## 8. What this lane did NOT establish + +1. **`SprjT` 1, 2 and 3 are unexercised.** Their shapes come from the recovered schema and the + factory, not from any save (rule 6). Nothing in the corpus has ever put a monitor, jewels or + tech-offer project on the wire, and until one does, those three arms are hypotheses. Two things + limit the damage if one of them is wrong, and neither is a substitute for a save: an arm that + mis-parses will not re-emit the same bytes, so `test_save`'s round-trip breaks on the first save + that carries one; and because `SpecialProjectBody` only ever describes the arm it selects, the + three unexercised arms' tags are **not** in the walker's global kind catalog, so they cannot + mistype anything else in the file. +2. **The `usp`/`usc` model is exercised at one point of its domain.** Every affected save holds the + same single record (`usnc = 1, usp = 0, usc = 1`). A record with two or more used slots would + test the loop; a slot index other than 0 would test that `usp` is an index. Cheap falsifier: + generate enough special-project names in one game that a suffix repeats. +3. **`FTPPosX`/`FTPPosY`/`FTPSqd` are 0 in every observed point**, so their disk type is the + schema's claim and not the corpus's (§3.2). +4. **Nothing about a turn.** No phase was implemented, no leaf count was measured, and lane CV's + 1092 was not re-run. What is now true is only that outcome 1 is no longer blocked *by the + writer* on this save family — CV's other blocker (§4.2, `--relabel-new-ids` refusing because the + engine mints no client fleet ids) is untouched and still stands. +5. **`smoke_real_save` still skips** (§6), and Track 0 item **0c** (the list-5 slider permutation, + which needs one UI run) was not attempted — it is a VM item and this is a host-only lane. +6. **The engine's `Sprj` data is carried, not used.** Typing it means the codec understands it; no + engine phase reads a special project. + +### 8.1 One incident worth recording: two gates, one remote build tree + +While this lane's post-commit gate run was in its shim step, a second `tools/gate.sh --fresh` +started from another session (the integrator, validating the `wip/cr` merge that landed on `main` on +top of this lane's two commits), and by the time that one ended there were **four** concurrent gate +runs. Every one of them rsyncs into the *same* `/srv/re-lab/build/sots-engine` and `rm -rf`s its +build directories. No result taken under that is trustworthy, so **the contended runs were discarded +and §7 quotes the run that was taken alone** — with the effect on the header noted there. The +post-commit tree was instead re-verified locally: a fresh out-of-tree build of `main` at `19f49dc` +(this lane's two commits plus the `wip/cr` merge) gives **59/59** with `SOTS_SAVES_DIR` set. This is rule 24's shape one level up — shared mutable state plus +concurrency, with no lock — and rule 21's, for a directory that has no worktree equivalent. The +cheap fix, if it recurs, is a lock file around the remote tree (or a per-caller remote directory); +noted here rather than implemented, because it is the integrator's script and this lane had no +mandate to change how it is scheduled. + +--- + +## 9. Reproducing every number here + +```sh +# 1. build in a FRESH directory, out of tree (rules 21, 24) +cmake -S ~/sots-engine -B /tmp/dw-build -DCMAKE_BUILD_TYPE=Release +cmake --build /tmp/dw-build -j 12 + +# 2. the corpus tests +SOTS_SAVES_DIR=~/sots-re/verify/results/saves /tmp/dw-build/tests/mars_stream/mars_stream_test_save + # test_save: ok (43 save(s), 0 failures); every save "still opaque: RNG=2" +SOTS_SAVES_DIR=~/sots-re/verify/results/saves /tmp/dw-build/tests/mars_stream/mars_stream_test_domains + # 755 field(s) observed: 538 vary, 217 constant +/tmp/dw-build/tests/mars_stream/mars_stream_test_wire_schema + # totals: 106 shapes bound, 1006 items matched, 0 MISMATCH, 30 wire-only, 16 opaque + +# 3. the app path, on every save +for s in ~/sots-re/verify/results/saves/*.sav; do + /tmp/dw-build/src/app/sots_turn "$s" --roundtrip | grep 'roundtrip (untouched)' +done # 43 x "byte-identical" + +# 4. the binary facts +cd ~/sots-re +uv run python3 dumps/b6dis.py 0x8147e0 # the name-gen writer, usp then usc +uv run python3 dumps/b6dis.py 0x8610a0 # the SprjT factory; table at 0x8611e8 +uv run python3 dumps/b6dis.py 0x856fc0 0x857040 # SprjT written from project+0x3c + +# 5. the gate +bash tools/gate.sh --fresh # == gate: GREEN +``` + +--- + +## Proposed board rows + +New row: + +``` +| THE TYPED WRITER IS BYTE-EXACT ON ALL 43 SAVES; THE RATCHET WAS HONOURED, NOT MOVED; AND THE GATE ITSELF WAS THROWING AWAY CTEST'S EXIT CODE | engine + gate | verified | high | 100% | 2026-09-09 | Lane DW, HOST ONLY (no VM; 140/141/145/146 untouched; CT111 only as the gate's build host). Track 0 items 0a and 0b, both closed. **`tools/gate.sh --fresh` is GREEN: 59/59 with the 43-save corpus, 0 corpus tests skipped, shim cross-build present.** `mars_stream_test_save` goes 39 failures across 15 of 43 saves -> 0; all 43 round-trip BYTE-IDENTICALLY through the typed writer and through `sots_turn --roundtrip`; `SchemaProbe` still reports 0 MISMATCH with SIX new shapes bound, every one matching item-for-item. **(0a) `usp` IS NOW READ, NOT GUESSED.** From `Game::SpecialProjectNameGen::Write` (0x008147e0): each 88-byte name record opens with a 32-BYTE TABLE OF PER-SUFFIX USE COUNTS; the unrolled loop at 0x814850 counts the NON-ZERO slots and writes that as `usnc`; the loop at 0x814895 then walks slots 0..31 and for each non-zero one writes the SLOT INDEX as `usp` (0x8148b1) and the slot's COUNT BYTE, movzx-widened, as `usc` (0x8148c2) -- both through WriteInt, so both i32 on the wire (rule 5); `Nm` at record+0x20 and `Ntg` at +0x3c follow. Tag literals at 0xa2bbf8/0xa2bbf4 confirm the ORDER independently of the one save that shows it. This kills the ambiguity the corpus alone cannot: `usnc=1,usp=0,usc=1` also fits "usp is a nested count", and the disassembly refutes that -- `usp` is the induction variable, written unconditionally inside `usc`'s own if-body. 12 saves were 12 bytes short = exactly one i32 item (4 len + 3 tag + 4 value + 1 pad). **(0b) THE RATCHET WAS CATCHING THREE BODIES, NOT ONE -- CV NAMED ONLY `usc`.** Reading the per-save opaque tags rather than the percentage shows `Sprj=14` (12 saves), `usc=1` (12), `FTPnts=6/12/18` (6 saves, THREE OF WHICH HAVE NO WRITER DEFECT AT ALL), `RNG=2` (43, deliberate). All three typed. **`Sprj` IS A POLYMORPHIC FRAME AND ITS DISCRIMINATOR MAPPING IS NOW MEASURED IN BOTH DIRECTIONS:** ServerPlayer::Write (0x856fe4) emits `SprjT` from the PLAIN MEMBER at project+0x3c, not a vfunc; ServerPlayer::Read (0x881aa7) feeds it to the factory at 0x8610a0 whose 4-entry table (0x8611e8) is 0 -> BackEngProject (vft 0xa3185c), 1 -> MonitorProject (0xa3180c), 2 -> JewelsProject (0x9fa438), 3 -> TechOfferProject (0xa31884) -- and EVERY ONE OF THOSE CONSTRUCTORS STORES ITS OWN INDEX BACK AT +0x3c, closing the loop. `Game::TechProject` (0xa31834) has NO factory entry, cannot come off a save, and shares BackEngProject's serializer byte for byte anyway. ONLY SprjT 0 IS EXERCISED (one back-engineering project on "Magonian Prophicies"/`DRV_RecFiss`, identical in all 12 saves); arms 1/2/3 are typed from schema+factory and LABELLED AS HYPOTHESES (rule 6), with an unknown SprjT falling to `rest()` so it breaks the ratchet instead of being mis-read. **`FTPnts`: the workload the old comment asked for was ALREADY IN THE CORPUS** (rule 28 practice 5) -- six saves carry stored tactical formations (`ap-turn22` 2 points, `az-turn23` 3, four others 1 each), the framing is the ordinary CArr, and `Game::FieldTemplate::Point` is now bound 5/5. Caveat kept: FTPPosX/FTPPosY/FTPSqd read 0 in EVERY observed point, so their i32 disk type is the schema's word and not the corpus's. **THE RATCHET WAS NOT LOWERED AND COULD NOT USEFULLY BE RAISED -- THAT IS A RESULT.** With only the MT19937 block left opaque, `pct` still ranges 99.99400 (`zuul-turn5-species5`, 33,318 stream items) to 99.99746 (`ar-r1-turn43-post`, 78,633 items), because the number is 2/items: raising the bar to 99.995 would FAIL A SAVE THAT TYPES PERFECTLY and pass a save twice its size carrying a whole new opaque body. A percentage ratchet on a fixed-size debt is a ratchet on SAVE SIZE. So `pct >= 99.99` stays and a STRICTLY STRONGER line is added beside it: `cov.opaque == 2` and the opaque tag set is exactly {RNG}. It breaks on the save that has the new body, and it names the body. DOMAIN CENSUS (43 saves): 736 fields / 536 vary / 200 constant BEFORE -> **755 / 538 / 217** AFTER; the 19 new fields are the three bodies, and **only two of them (`FTPShID`, `FTPDesID`) have ever been seen to move** -- typing a body honestly adds far more unexercised fields than exercised ones. `kVaryingBaseline` raised 490 -> **538** deliberately, with date and count. **AND THE GATE WAS NOT HONEST, IN TWO WAYS.** (a) `ctest ... | tail -15` returns TAIL's exit code, so the baseline run prints `GATE ok host-ctest` DIRECTLY ABOVE `2 tests failed out of 59` -- rule 1's failure inside the script written to prevent it; the same masking was on the build step; both now capture the real status and trim after. (b) the skip check counted every `unset, skipped` line, including two tests that are not corpus tests, so it could never reach zero; it is now scoped to the corpus-gated tests AND every remaining skip is printed by name on its own `GATE info skip:` line. The fix makes the gate do MORE: `SOTS_DATA_DIR=/srv/re-lab/gob-extract` is now set, so **`game_design_census` runs against the corpus for the first time in this gate -- 43 saves, 2,728 designs, 1,962 census leaves, 0 mismatched**, a test that was silently skipping and is now a real check. One skip remains and is NAMED, not hidden: `smoke_real_save` wants `SOTS_SAVES_JSON` from the RE repo's `save_reader.py`, and it is a PRINT-ONLY TEST WITH NO ASSERTIONS, so satisfying it would add no check and would couple the engine gate to the reader rule 8 warns about. Artefacts: `findings/subsystems/writer-byte-exact.md`; engine commits `076f714` (the three bodies) and `00e9266` (the ratchets) on `main`; `tools/gate.sh`. | +``` + +Edits to existing rows: + +- **`backlog.md` §3 Track 0, item 0a** — mark **DONE**: the pair is modelled, `usp` is the slot + index and `usc` the slot's use count, all 43 saves round-trip byte-identically, and the + `SchemaProbe` conformance is 0 MISMATCH with `ProjectNames` at 6/6. +- **`backlog.md` §3 Track 0, item 0b** — mark **DONE**, and correct its scope: it was **three** + bodies, not "the spy saves". `Sprj` and `FTPnts` were the coverage debt; `ap-*` was only ever + below the bar because of `FTPnts`, and three of the six `FTPnts` saves are not spy saves at all. +- **`backlog.md` §3 Track 0 preamble** — the gate's own `host-ctest` step was returning `tail`'s + exit code and would have reported two failing tests as green; the corpus-skip check counted + non-corpus skips and could never reach zero. Both fixed; `game_design_census` now runs. +- **`backlog.md` §1, `mars/stream` row** — the verification column should read + **"100% named coverage, byte-identical round-trip, 43 saves"** (it says 11), and the module's + opaque debt is now exactly the MT19937 block in every save. +- **`rung-b-rich-turn.md` §4.1** — the writer defect it named is fixed; its §5 rank-15 row + (`sprjs/usp`, 1 leaf) and one of its six regressions are closed. The other blocker (§4.2, + `--relabel-new-ids` refusing) is untouched. +- **A new `mars/stream` row is NOT needed for the `usp` pair** — CV's proposed row already + describes the defect; it should be marked SUPERSEDED by the row above rather than added. +- **`verify/results/saves/certified-pairs.md`** — no new row. This lane certifies no pair. +- **Row 62 (guest ledger)** — unchanged by this lane; it took no guest. diff --git a/tools/gate.sh b/tools/gate.sh index adff927..aed03b2 100755 --- a/tools/gate.sh +++ b/tools/gate.sh @@ -20,6 +20,7 @@ RE="${RE:-$HOME/sots-re}" CT=111 REMOTE_TREE=/srv/re-lab/build/sots-engine REMOTE_CORPUS=/srv/re-lab/saves-corpus +REMOTE_DATA=/srv/re-lab/gob-extract # the extracted asset tree; game_design_census needs it FRESH=0 [ "${1:-}" = "--fresh" ] && FRESH=1 @@ -49,15 +50,25 @@ ssh spicy "pct exec $CT -- mkdir -p $REMOTE_CORPUS && tar -C /tmp/sots-corpus -c NCORPUS=$(ct "ls $REMOTE_CORPUS | grep -c .sav") echo "GATE info corpus on CT111: $NCORPUS saves" -# 5. host build + tests, WITH the corpus -ct "cd $REMOTE_TREE && cmake -S . -B build-host -DCMAKE_BUILD_TYPE=Release >/dev/null 2>&1 && cmake --build build-host -j8 2>&1 | tail -1"; report host-build $? -ct "cd $REMOTE_TREE/build-host && SOTS_SAVES_DIR=$REMOTE_CORPUS ctest --output-on-failure 2>&1 | tail -15"; report host-ctest $? -# and prove the corpus tests RAN rather than skipped -SKIPPED=$(ct "cd $REMOTE_TREE/build-host && SOTS_SAVES_DIR=$REMOTE_CORPUS ctest -V 2>&1 | grep -c 'unset, skipped'") +# 5. host build + tests, WITH the corpus AND the extracted data tree +# +# `cmd | tail -1` returns TAIL's status, not the command's -- so for a day this +# script printed "GATE ok host-ctest" over a run with two FAILING tests, which is +# rule 1's failure wearing this script's own clothes. Every step below captures +# the real exit code first and only then trims the output. +ct "cd $REMOTE_TREE && cmake -S . -B build-host -DCMAKE_BUILD_TYPE=Release >/tmp/gate-cfg.log 2>&1 && cmake --build build-host -j8 >/tmp/gate-build.log 2>&1; rc=\$?; tail -1 /tmp/gate-build.log; exit \$rc"; report host-build $? +ct "cd $REMOTE_TREE/build-host && SOTS_SAVES_DIR=$REMOTE_CORPUS SOTS_DATA_DIR=$REMOTE_DATA ctest --output-on-failure >/tmp/gate-ctest.log 2>&1; rc=\$?; tail -15 /tmp/gate-ctest.log; exit \$rc"; report host-ctest $? +# and prove the corpus tests RAN rather than skipped. The check is scoped to the +# tests gated on the CORPUS (they name SOTS_SAVES_DIR when they skip); every other +# skip is listed below it by name, because a skip nobody reads is how this started. +SKIPPED=$(ct "cd $REMOTE_TREE/build-host && SOTS_SAVES_DIR=$REMOTE_CORPUS SOTS_DATA_DIR=$REMOTE_DATA ctest -V 2>&1 | grep -c SOTS_SAVES_DIR") if [ "${SKIPPED:-1}" -ne 0 ]; then echo "GATE FAIL corpus tests skipped ($SKIPPED) -- the gate is hollow"; status=1; else echo "GATE ok corpus tests ran (0 skipped)"; fi +ct "cd $REMOTE_TREE/build-host && SOTS_SAVES_DIR=$REMOTE_CORPUS SOTS_DATA_DIR=$REMOTE_DATA ctest -V 2>&1 | grep skipped" | sed 's/^/GATE info skip: /' -# 6. shim cross-build -ct "cd $REMOTE_TREE && cmake -S . -B build-shim -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain-mingw-i686.cmake -DCMAKE_BUILD_TYPE=Release >/dev/null 2>&1 && cmake --build build-shim -j8 2>&1 | tail -1 && ls -la build-shim/binkw32.dll"; report shim-cross-build $? +# 6. shim cross-build -- same exit-code discipline as step 5. The `ls` at the end +# is a second, independent check (the artefact exists), not the build's verdict: +# without --fresh a DLL from an earlier run would satisfy it on its own. +ct "cd $REMOTE_TREE && cmake -S . -B build-shim -DCMAKE_TOOLCHAIN_FILE=cmake/toolchain-mingw-i686.cmake -DCMAKE_BUILD_TYPE=Release >/tmp/gate-shim-cfg.log 2>&1; rc=\$?; cmake --build build-shim -j8 >/tmp/gate-shim.log 2>&1 || rc=1; tail -1 /tmp/gate-shim.log; ls -la build-shim/binkw32.dll || rc=1; exit \$rc"; report shim-cross-build $? echo "== gate: $([ $status -eq 0 ] && echo GREEN || echo RED)" exit $status