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.
33 KiB
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.mdrules 5, 6, 12, 21, 23, 24, 25, 27,objects/streams.json+include/generated/sots_stream_schema.h, anddumps/sots.exeread withdumps/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 intoverify/results/saves/.
0. Verdict
tools/gate.sh --freshis GREEN: 59/59 with the 43-save corpus, 0 corpus tests skipped, shim cross-build present.mars_stream_test_savegoes 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 throughsots_turn --roundtrip. TheSchemaProbeconformance 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/uscpair the writer was dropping (item 0a), and the two bodies the ratchet was actually complaining about — theSprjspecial-project frame and theFTPntsformation 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
ctestthroughtail, so it took tail's exit code: the baseline run below printsGATE ok host-ctestdirectly above2 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.
usncis the number of non-zero slots. For each non-zero slot the writer emits the slot's INDEX asuspand the slot's count asusc. Both go throughWriteInt, so both arei32on the wire even though the count is a byte in memory (rule 5). The record is 88 bytes:uint8 used[32], thenNmat +0x20 andNtgat +0x3c (twostd::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):SprjTcomes from the plain member atproject+0x3c—mov eax,[eax+0x3c]— not from a virtual call. -
Reader (
Game::ServerPlayer::Read, 0x00881aa7): readsSprjT, then passes it as the first argument to the factory at 0x008610a0, whose 4-entry jump table at 0x008611e8 isSprjTarm size vftable class 0 0x8610d6 0x54 0xa3185cGame::BackEngProject1 0x861119 0x54 0xa3180cGame::MonitorProject2 0x8611a1 0x4c 0x9fa438Game::JewelsProject3 0x861150 0x60 0xa31884Game::TechOfferProjectand 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.SprjToutside 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-extractis now set, sogame_design_censusruns 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_DIRwhen they skip), and every remaining skip is printed by name on its ownGATE 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
SprjT1, 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, sotest_save's round-trip breaks on the first save that carries one; and becauseSpecialProjectBodyonly 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.- The
usp/uscmodel 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 thatuspis an index. Cheap falsifier: generate enough special-project names in one game that a suffix repeats. FTPPosX/FTPPosY/FTPSqdare 0 in every observed point, so their disk type is the schema's claim and not the corpus's (§3.2).- 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-idsrefusing because the engine mints no client fleet ids) is untouched and still stands. smoke_real_savestill 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.- The engine's
Sprjdata 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,uspis the slot index anduscthe slot's use count, all 43 saves round-trip byte-identically, and theSchemaProbeconformance is 0 MISMATCH withProjectNamesat 6/6.backlog.md§3 Track 0, item 0b — mark DONE, and correct its scope: it was three bodies, not "the spy saves".SprjandFTPntswere the coverage debt;ap-*was only ever below the bar because ofFTPnts, and three of the sixFTPntssaves are not spy saves at all.backlog.md§3 Track 0 preamble — the gate's ownhost-cteststep was returningtail'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_censusnow runs.backlog.md§1,mars/streamrow — 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-idsrefusing) is untouched.- A new
mars/streamrow is NOT needed for theusppair — 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.