sots-re/findings/subsystems/writer-byte-exact.md
alex 1df4ffb463 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.
2026-09-09 10:10:40 -04:00

33 KiB
Raw Permalink Blame History

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::strings 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

struct UsedName {          // one non-zero slot
    int32_t slot = 0;      // usp
    int32_t count = 0;     // usc
    template <class Ar> void io(Ar& ar) { ar.i32(A("usp"), slot); ar.i32(A("usc"), count); }
};
struct ProjectName {
    std::vector<UsedName> used;
    std::string nm, ntg;
    template <class Ar> 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:

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

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 -rfs 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

# 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.