From 1df4ffb463c99bd8102e2f487e9863461d12e7bf Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 9 Sep 2026 10:10:40 -0400 Subject: [PATCH] lane DW: the writer is byte-exact on all 43 saves; make the gate honest Track 0 items 0a and 0b. The finding has the full account; the two things that matter for anyone reading the gate afterwards: gate.sh was taking TAIL's exit code from `ctest ... | tail -15`, so it printed "GATE ok host-ctest" directly above "2 tests failed out of 59". The same masking was on the host build and on the shim cross-build. All three now capture the real status and trim the output afterwards. The corpus-skip check counted every "unset, skipped" line, two of which are not corpus tests, so it could never reach zero. It is now scoped to the tests gated on the corpus, SOTS_DATA_DIR is set so game_design_census actually runs against it (43 saves, 2728 designs, 0 mismatched -- a test that had been skipping), and every remaining skip is printed by name so none can be invisible again. --- findings/subsystems/writer-byte-exact.md | 501 +++++++++++++++++++++++ tools/gate.sh | 25 +- 2 files changed, 519 insertions(+), 7 deletions(-) create mode 100644 findings/subsystems/writer-byte-exact.md 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