Compare commits

...

3 commits

Author SHA1 Message Date
alex
f6a914beee board: lane E2 census wired; ship construction identified as a new blocker by a falsified prediction 2026-09-08 13:28:52 -04:00
alex
55ea86f0d5 lane E3: the per-system visibility record read from the instruction stream, and the prediction before the build
NVE is a per-(system, player) map of {ETS = turn of sighting, Eid = encounter type
present}. Primary writer 0x00756300, called from tail phase 17 under the gate
(AFlags >> PlyrIdx) & 1 -- byte-decoded at 0x007cf7a7..0x007cf7ce, not from the
decompiler. Three further accessors named, including the intel-sharing copy that
keeps the older sighting stamp. operator[] has exactly three callers and none of
them erases, so the record is a memory, not a state.

Also names ltis's writer (driver phase 29, AFlags != 0 -> ltis = Frame), closing
half of board.md's 'TShn/ltis: NOTHING NAMES THEIR WRITER'. TShn's own gate is
still unnamed and demonstrably NOT AFlags -- Spica is the counterexample.

Resolves lane B5's flagged indirect edge: SetExploredBy's vft[0x1c] is
ServerSystem vtable slot 7 at 0x007480b0, and it writes only an unserialised
runtime mask.

Prediction committed before the engine change exists: 204 -> 158 (46 closed, 32
of them the brief's target) and 103 -> 87 (16 closed), 0 regressed, with a
falsification section naming the corpus save that separates AFlags from VFlags.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 13:26:46 -04:00
alex
fbe3c7bd86 lane E2: T36 measured with the ship census wired in -- 29 closed / 7 regressed, and the two things it is still blocked on
standalone_report.py regenerated from sots-engine wip/turnrecord. The reference pair does
not move by design: T36 does not commit by default, so 209 -> 204, closed 5 / regressed 0
and 108 -> 103, closed 5 / regressed 0 are unchanged. What moved in section 6 is the phase
tally, 14/44 modelled and 8 committed -> 15/44 and 9, from the A2 and D2 merges. verified
stays 0 on both tables.

t36-turnrecord.md carries the measurement the default run cannot show: with
--commit-blocked=T36 and a data root, the reference pair closes 29 leaves and regresses 7
(was 9). Two of the three census leaves close; the third is short by exactly one destroyer
on both pairs, because no phase we run builds a ship. The census model itself reproduces
480/480 archived census leaves over 11 saves with 0 mismatches.

T36 stays blocked: sav and inc on lane E1's budget, and one ship count on unmodelled
construction. A field-granular commit knob is not implementable -- the archived record is
one struct on the wire and has no hole to leave.

Coverage: 32 of 480 census leaves are nonzero anywhere in the corpus; cls1 and cls2's
platform count are unexercised, not verified.
2026-09-08 13:25:31 -04:00
6 changed files with 455 additions and 42 deletions

View file

@ -232,3 +232,6 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
| ship design: three sections, not five | objects | verified | high | 100% | 2026-09-08 | THREE CORRECTIONS FORCED. (1) THREE sections, not five - the campaign's "slots 3-4 reserved" was save_reader.py's trailing Rest("sections") sweeping Dtc/Dwgv into the section list and stock_designs.py decoding them as empty slots. **RULE 8 EXACTLY: reader and consumer agreed and were both wrong.** Two independent enumerations say three (straight-line writer; ctor eh_vector_constructor_iterator(this+0x24, 0x28, 3) closing at 0x9c = sizeof(ShipDesignDef)). (2) DWep/Dwgv are BOOLS, byte-indistinguishable at a 4-char tag - same class as odet. (3) Offset order != write order: array [+0x24,+0x4c,+0x74] = mission, command, engine; WIRE = command, mission, engine. Oracles fixed openly (rule 12): save_reader.py 49/49, stock_designs.json regenerated with the diff ONLY raw_slots 5->3 and dWep int->bool across all 127 designs, design rules still 32/32 and 127/127 |
| hull size and the 0x400 flag | objects | verified | high | 90% | 2026-09-08 | **0x400 = defence_platform**, read off the .shipsection parser's own bit setter at 0x005749b7 directly after the strcmp against the literal; the design's word is the OR across its <=3 sections. **NOT lane B5's 0x400** - that is a FLEET flag, on the wire as FtFlg (turn1-state fleet 0 has FtFlg 1024). Different word, different object. HULL SIZE = section_class via ParseShipClassName 0x0056e1c0, _stricmp against Destroyer/Cruiser/Dreadnought -> 0/1/2; absent or unrecognised -> **0 WITH A LOG LINE, NOT AN ERROR** (16 shipped sections have no section_class); ASSIGNMENT NOT MAX - last resolved section in MEMORY ORDER wins. Both live on ShipDesign, NEITHER ON THE WIRE, both rebuilt by UpdateDerivedStats. Independent corroboration: the same bit picks default hull health (500/3000/15000 without, 100/500/1000 with) - platforms, not ships. CENSUS VERIFIED: 11 saves, 503 designs, 480 leaves, 0 mismatched, computed TWICE independently (Python off save_reader, C++ off the engine's shapes reader - they DISAGREE about section count and still agree on all 480) |
| census coverage: 3 of 6 leaves UNEXERCISED | verify | backlog | — | 0% | 2026-09-08 | Rule 15 again, unprompted. Only 32 of 480 census leaves are NONZERO: cls0 shpt 18, cls0 satt 3, cls1 shpt 0, cls1 satt 0, cls2 shpt 11, cls2 satt 0. **Three of the six census leaves are UNEXERCISED, NOT VERIFIED** - no cruiser of any kind and no DN platform in the corpus. ONE SAVE WITH A CRUISER CLOSES TWO OF THEM. Also 0 ships with an unresolvable design, 0 unclassifiable designs, 0 designs where first- and last-resolved section disagree on hull size |
| T36 census wired; a NEW blocker found by a wrong prediction | verify | mapped | high | 100% | 2026-09-08 | Lane E2. Default run UNCHANGED at closed 5 / regressed 0 on both pairs, exactly as predicted (T36 does not commit by default). --commit-blocked regressions fell 9 -> **7** (pair 1) and 11 -> **9** (pair 2). PREDICTION WAS 6 AND 8, AND THE MISS IS THE FINDING: falsification hypothesis 1 fired - two census leaves closed (shpt[2]=5 dreadnoughts, satt[0]=10 platforms) but **shpt[0] did not, and the archived value is higher than ours by EXACTLY ONE DESTROYER ON BOTH PAIRS** (0 vs 1, then 1 vs 2). The self-check on the INPUT turn is exact, so this is NOT a classification error - it is a NEW NAMED BLOCKER: **no phase we run builds a ship** (S11's build-queue sub-pass is the declared input boundary). Census itself reproduced through the app's own path: 11 saves, 80 records, 1040 fields, 480 census leaves, 0 mismatches - and it visits slots in the ORIGINAL'S IN-MEMORY ORDER (mission, command, engine) rather than the wire's, so it is an independent route to lane D2's 480/480 |
| T36 remains blocked on TWO named things | verify | backlog | — | 0% | 2026-09-08 | Neither is lane E2's: (1) sav/inc x3 = the budget, lane E1's income chain; (2) shpt[0] x1 = unmodelled ship construction. **The briefed --commit-blocked-except=<fields> knob is NOT IMPLEMENTABLE and lane E2 did not fake one**: the archived record is ONE STRUCT ON THE WIRE, so sav/inc cannot be omitted while the other eleven fields are written - the format has no hole. It implemented the same convention at PHASE granularity instead (--commit-blocked=IDS / --commit-blocked-except=IDS), which is what isolated the T36 rows and cleanly separated S31's two Player/Status regressions out of the pair-2 number |
| standalone data root | engine | verified | high | 100% | 2026-09-08 | The standalone grew `--data DIR` / $SOTS_DATA_DIR. NO GAME DATA EMBEDDED; absence costs exactly the six census counters and SAYS SO. Note for integrators: with a data root set, `game_data_realdata` fails - and it FAILS IDENTICALLY ON UNMODIFIED MAIN, because the extracted sots.gob is a different data version than the counts that test pins. Not a regression; worth pinning the data version in that test |

View file

@ -0,0 +1,106 @@
# Prediction: the visibility phase, before it is built
Lane E3, 2026-09-08. Written and committed **before** the engine change exists (rule 2).
Model: `findings/subsystems/system-visibility-record.md`.
Baseline, measured on `sots-engine` main `0592104` this morning:
```
turn1-state -> turn2-state 204 leaf differences
turn2-state -> turn3-state 103 leaf differences
```
## The model, as three rules
1. **T17 `RecordObservation`.** For every player `p` and system `s` with
`s.AFlags & (1 << p.PlyrIdx)`: set `NVE[p] = { EPid = p's handle id, ETS = frame,
Eid = the encounter id at s }`. `frame` is the post-increment frame, i.e. the turn number
the autosave carries. Entries are ordered by `PlyrIdx` ascending (a `std::map`). Nothing is
ever erased.
2. **T21 `SetExploredBy`.** `s.EFlags |= s.AFlags`.
3. **S29 `ltis`.** If `s.AFlags != 0` then `s.ltis = frame`.
`Eid` is taken from an existing `NVE` entry when the system already has one (the field it
mirrors is fixed for the life of the game); otherwise from a fleet at the system with
`FtEnc != 0`; otherwise `-1`.
`AFlags` is **read from the save, not recomputed**. Justification, and it is checkable: neither
reference pair moves an `AFlags` leaf, so the loaded value is the value the phase would see.
## Predicted leaves closed
### `turn1-state -> turn2-state` (the 32-leaf target)
Eight systems — 64 Hyperion, 112 Gamma Cephei, 288 Ke'Dolarra, 304 Koa'Vo, 336 Kaa'Vaalu,
400 Markab, 448 Kea'Pono, 480 Ko'Rorkor — are exactly the systems with `AFlags != 0`.
| leaves | rule | count |
|---|---|---:|
| `Sys[*]/NVE` count `0 -> 1` | 1 | 8 |
| `Sys[*]/EPid` only-in-B | 1 | 8 |
| `Sys[*]/ETS` only-in-B | 1 | 8 |
| `Sys[*]/Eid` only-in-B | 1 | 8 |
| **the brief's target** | | **32** |
| `Sys[*]/EFlags` `0 -> {16,128,16,16,16,16}` on 64, 304, 336, 400, 448, 480 | 2 | 6 |
| `Sys[*]/ltis` `1 -> 2` on all eight | 3 | 8 |
| **total predicted closed** | | **46** |
Predicted `EPid` values, per system, in order: 528, 16, 32, 576, 528, 528, 528, 528.
Predicted `Eid` values: 5, -1, -1, -1, 3, 3, 4, 4. Predicted `ETS`: 2 everywhere.
Expected result: **204 -> 158, closed 46, regressed 0.**
### `turn2-state -> turn3-state`
The entries already exist, so only their stamps move.
| leaves | rule | count |
|---|---|---:|
| `Sys[*]/ETS` `2 -> 3` on the same eight | 1 | 8 |
| `Sys[*]/EFlags` | 2 | 0 (already equal to `AFlags`) |
| `Sys[*]/ltis` `2 -> 3` on the same eight | 3 | 8 |
| **total predicted closed** | | **16** |
Expected result: **103 -> 87, closed 16, regressed 0.**
`EPid` and `Eid` must be **re-emitted unchanged**; if either moves, the model has regressed a
leaf that agreed by construction, and that is a failure of rule 3, not a rounding detail.
## Falsification
Each of these is a way the model is wrong and the symptom that would show it.
* **The gate is `VFlags`, not `AFlags`.** On both reference pairs `VFlags == AFlags` on every
system, so the pairs cannot separate them. The discriminator already in the corpus is
`zuul-turn23-fleet23.sav` Bismol: `VFlags = 2`, `AFlags = 0`, `ETS` frozen at 22 while the
save is turn 23. A `VFlags` model refreshes it to 23. **Symptom:** a host test that replays
Bismol's row fails. That test is written.
* **The gate is `EFlags`.** Same corpus discriminator: Bismol has `EFlags = 2`.
* **`ETS` is the sighting turn, set once, not refreshed.** Then `turn2 -> turn3` closes 0 of
its 8, and the eight `ETS 2 -> 3` leaves stay. **Symptom:** pair 2 closes 8 instead of 16.
* **`Eid` is re-derived from the fleet every turn rather than parked on the system.** Invisible
on this corpus — no encounter fleet dies in it. **Symptom:** none available; that is why §8
of the model doc names the workload instead of claiming the rule.
* **The `Eid` test is `FtFlg & 0x10`, not `FtEnc != 0`.** Invisible on this corpus; the six
encounter fleets agree on both. **Symptom:** none available.
* **`EPid` is the player index, not the handle id.** Then all eight `EPid` leaves regress to
0..7 instead of closing. **Symptom:** 8 closed becomes 8 regressed, and the run's closed
count drops to 38 / 16.
* **The map is ordered by insertion, not by key.** Invisible: every system in the corpus has
exactly one entry. **Symptom:** none available; the ordering is asserted from the container
type, and §8 names the two-empire-contact workload that would exercise it.
* **`ltis` is not the frame but something that merely equals it here.** `ltis` is `1` at
turn 1 and `2` at turn 2 on the same systems, which is consistent with a great many
counters. The reading is a 41-byte function body; if it is wrong the 8 `ltis` leaves regress
rather than close.
* **The phase runs before the frame increment.** Then every stamp is one low and 24 leaves
regress across the two pairs.
## What the model deliberately does not write
* `NVO.TShn` (10 leaves per pair). Its gate is not `AFlags` — Spica has `AFlags == 0` and
`TShn` still moves. Formula not held; **evaluated and reported, not written** (lane S2's
rule).
* `rcex` (6 leaves per pair). Unexplained.
* `NVs` / `PlayerView`. Count 0 on both reference pairs; the `PlayerView` refresh loop is a
separate phase and no leaf of it diverges on either pair.

View file

@ -0,0 +1,160 @@
# The per-system visibility record: `NVE`, `ltis`, and the explored mask
Lane E3, 2026-09-08. Target: the 32 diverging `nve` leaves on the reference pair.
Status of each claim is marked **[V] instruction-verified**, **[D] decompiler-only**, or
**[H] hypothesis** (rule 6).
---
## 1. What the record is
`Sys.NVE` is a `std::map<int playerIdx, {int16 ETS, int32 Eid}>` at `ServerSystem+0x284/+0x288`.
It is **"what this player last saw at this system"**: the turn of the sighting and the
random-encounter type that was there. Its sibling `NVO` is the same idea for *ownership*
(`TShn` = turn, `OID` = owner as last seen), and `NVs`/`PlayerView` is the same idea for the
*colony's numbers*. Three maps, one concept, one key.
Node layout, reconciling `struct-recovery.md` §1.2 with the accessors below: MSVC `_Tree` nodes
put `_Myval` at `+0xc`, so the key (player index) is `node+0xc` and the **8-byte value is
`node+0x10`**:
| value offset | wire name | type | meaning |
|---|---|---|---|
| +0x0 | *(not serialised)* | int16 | the turn the record was last **touched** |
| +0x2 | `ETS` | int16 | the turn the **sighting** was made |
| +0x4 | `Eid` | int32 | the random-encounter type id present, `-1` for none |
The key is written to the wire as `EPid`, the player **handle id** (16, 32, 528 …), not the
index. That reconciles the isnil byte at `+0x19` with an 8-byte value. **[V]** (arithmetic
over the accessors in §2, each of which reads a named offset).
## 2. Every writer and reader of the map
| addr | what it is | body |
|---|---|---|
| `0x00756300` | **`ServerSystem::RecordObservation(p, encId)`** — the primary writer | `f = (int16)this->owner(+0x10)->Frame(+0x8); rec = NVE[p->PlyrIdx(+0x28)]; rec[0] = (f<<16)|f; rec[1] = encId;` **[D]** |
| `0x007536a0` | `ServerSystem::CopyObservationTo(p, srcRec)` — intel sharing | `rec = NVE[p->PlyrIdx]; rec.touched = (int16)Frame; rec.ETS = src.ETS; rec.Eid = src.Eid;` — the *sighting* stamp is copied, only the touch stamp is refreshed **[D]** |
| `0x00754d90` | `ShareObservation(systhis, from, to)` | `a = Find(from); b = Find(to); if (a && (!b || b->ETS < a->ETS)) CopyObservationTo(to, a);` — newer sighting wins **[D]** |
| `0x0074d360` | `ServerSystem::FindObservation(p)` | map find on `p->PlyrIdx`, returns `node+0x10` or null **[D]** |
| `0x0074f830` | `ServerSystem::LastSeenEncounterId(p)` | map find, returns `node+0x14` (`Eid`) or `-1` **[D]** |
| `0x00752730` | the map's `operator[]` | 3 callers only: the two writers above and `ServerSystem::Read` **[V]** (cross-reference query) |
**There is no eraser.** `operator[]` has exactly three callers and none of them removes an
entry, so once a player has an entry at a system it keeps it for the rest of the game. That is
what makes the record a *memory* rather than a *state*.
## 3. Where the primary writer is called from
Tail phase 17, `FUN_007cf560` (the "rebuild every `PlayerView`" phase). Disassembled at
`0x007cf7a7..0x007cf7ce` — **[V]**, byte-decoded, not from the decompiler:
```
mov edx,[esi+0x44] ; systems.begin
mov edi,[edx+ebx*4] ; edi = sys
mov eax,[ebp-0x14] ; player
push eax
mov ecx,edi ; this = sys
call 0x00743fb0 ; ServerSystem::IsVisibleTo(sys, player)
test al,al
jz skip
mov eax,[edi+0x184] ; the encounter type id parked on the system
mov ecx,[ebp-0x14] ; player
push eax ; arg: encId
push ecx ; arg: player
mov ecx,edi ; this = sys
call 0x00756300 ; RecordObservation
```
Outer loop is over **players**, inner over **systems**. The gate is one instruction:
* `FUN_00743fb0(sys, p)` = `(sys->AFlags(+0xd4) >> p->PlyrIdx(+0x28)) & 1` **[V]**.
`AFlags` — not `VFlags`, not `EFlags`. `combat-retreat-pipeline.md` §5 already had `AFlags` as
the *derived, non-sticky* union `FFlags | GFlags | isOwner`, recomputed on every fleet arrival
and departure. So the record is refreshed exactly while the player still has something at the
system, and freezes the moment the last fleet leaves and the player does not own it.
## 4. `Eid`'s source
`sys->+0x184` is a `StarSystem` member initialised to `-1` in the constructor
(`FUN_00752ea0`, `param_1[0x61] = 0xffffffff`) **[D]**, and set by
`FUN_007887c0(sys, encId)` — the encounter-placement routine, which refuses unless the system
is unowned, planetless and still `-1` **[D]**. **It is not on the wire.** It is fixed for the
life of the game, so the only observable it can be recovered from is the encounter fleet
sitting at the system, whose `FtEnc` carries the same id.
Across all 11 saves, `Eid == FtEnc` of the fleet at the system, for every one of the six
encounter fleets in the corpus, and `-1` everywhere else. The `Defenses` fleet at Koa'Vo has
`FtEnc == 0` and `FtFlg == 0x400`, and its system's `Eid` is `-1`; every real encounter fleet
has `FtEnc > 0` and `FtFlg & 0x10`. **Nothing in the corpus separates "`FtEnc > 0`" from
"`FtFlg & 0x10`" as the test, and nothing separates either from the true rule, which reads a
field that is not saved at all.** Labelled **[H]**; §8 names the workload.
## 5. `ltis` — a second writer, in the other driver
Driver phase 29, `FUN_00743ec0` (41 B, whole body) **[D]**:
```c
if (this->AFlags(+0xd4) != 0)
this->ltis(+0x2c8) = this->owner(+0x10)->Frame(+0x8);
```
`ltis` is the system-level twin of `ETS`: same clock, same gate, but the gate is
`AFlags != 0` for **any** player rather than for a particular one, and the value is a full
int rather than an int16. `board.md` records `TShn`/`ltis` as *"NOTHING NAMES THEIR WRITER"* —
this names `ltis`'s. `TShn` is still unnamed and is **not** the same rule (§7).
## 6. The explored mask, and the one thing `OnExploredChanged` does
Tail phase 21, `FUN_007a3c60`, per system × per player **[D]**:
```c
if (IsVisibleTo(sys, p) && !IsExploredBy(sys, p)) {
SetExploredBy(sys, p, true); // 0x007438b0: EFlags |= 1 << PlyrIdx
NodeGraphRegister(...); // 0x006e4620
PostEvent("EVENT_FLEET_EXPLORED", ...);
}
```
so **`EFlags |= AFlags`**, once per turn, in the tail.
`SetExploredBy`'s trailing `vft[0x1c](p, wasSet, on)` — the indirect edge `lane-b5.json` flagged
as unresolved — resolves through `Game::ServerSystem`'s primary vftable `0x00a2044c` slot 7 to
**`0x007480b0`** (`tools/vtable_map.py`). Its whole body **[D]**:
```c
if (!wasSet && on && server->Frame(+0x8) > 1) {
FUN_00747a20(p->PlyrIdx, 1);
if (p->PlyrIdx < 15) this->+0x2a4 |= 1 << p->PlyrIdx;
}
```
`+0x2a4` is past `NVs` and is not in the serialised table: a runtime "newly explored this turn"
mask, not save state. **The edge is resolved and it writes nothing that reaches the wire.**
Note the `Frame > 1` guard — turn 1 is special-cased throughout this subsystem.
## 7. What this does NOT explain
`NVO.TShn` moves on **more** systems than `NVE.ETS` does. On `turn1 -> turn2`, Spica has
`AFlags == 0`, no `NVE` entry and no `ltis` move — and its `TShn` still goes 1 -> 2. So `NVO`
has a looser gate than `AFlags`, and it is **not** `VFlags`, `EFlags` or `FFlags` either (all
zero at Spica). `TShn`'s writer remains unnamed. Conversely `zuul-turn23-fleet23.sav`'s Bismol
has `AFlags == 0` and freezes `TShn`, `ETS` **and** `ltis` at 22 together — so on that save the
two rules agree. Whatever `NVO`'s gate is, it admits Spica and excludes Bismol.
`rcex` (int64) moves `0 -> 65536` on the same six systems on `turn1 -> turn2` and back
`65536 -> 0` on `turn2 -> turn3`. `65536 = 1 << 16`, and Koa'Vo takes `1 << 28`. It is a
per-system word that toggles with the encounter/visibility cluster and it is **not** modelled
here.
## 8. Workloads that would settle the open items
| open item | workload |
|---|---|
| whether `Eid` is `sys->+0x184` (fixed at generation) or re-read from the fleet each turn | a save where an encounter fleet is **destroyed** at a system that stays visible: `+0x184` keeps the id, a fleet-derived rule drops to `-1` |
| whether the `Eid` source test is `FtEnc > 0` or `FtFlg & 0x10` | any save with a fleet carrying `FtEnc > 0` and `FtFlg & 0x10` clear, or the converse |
| `NVO.TShn`'s gate | a watchpoint on `ServerSystem+0x274`'s map during one turn — rule 18; Spica vs Bismol is already the discriminating pair |
| more than one player observing one system | every save in the corpus has **single-bit** `AFlags`. A two-empire contact save would exercise the map ordering (ascending `PlyrIdx`) and the `EPid` handle lookup for a second entry |
| `ShareObservation` (alliance intel) | no save has two players in an alliance; the newer-sighting-wins rule has never executed |
| `rcex` | unassigned |

View file

@ -1,8 +1,8 @@
# standalone vs the oracle
generated 2026-09-08T15:35:00Z binary /home/alex/sots-engine-wt-yield/build-host/src/app/sots_turn
generated 2026-09-08T17:23:54Z binary /home/alex/sots-engine-wt-turnrecord/build-host/src/app/sots_turn
phases: 14/44 of the two turn drivers modelled, 8 committed (implemented 2, partial 6, blocked 6, stub 30)
phases: 15/44 of the two turn drivers modelled, 9 committed (implemented 3, partial 6, blocked 6, stub 29)
3/37 of the post-combat tail modelled
## turn1-state.sav -> turn2-state.sav (real End Turn)

View file

@ -1,7 +1,7 @@
{
"schema": "sots-standalone-status/1",
"generated": "2026-09-08T15:35:00Z",
"binary": "/home/alex/sots-engine-wt-yield/build-host/src/app/sots_turn",
"generated": "2026-09-08T17:23:54Z",
"binary": "/home/alex/sots-engine-wt-turnrecord/build-host/src/app/sots_turn",
"reference": {
"input": "turn1-state.sav",
"oracle": "turn2-state.sav",
@ -30,12 +30,12 @@
"phases": {
"total": 44,
"verified": 0,
"implemented": 2,
"implemented": 3,
"partial": 6,
"blocked": 6,
"stub": 30,
"modelled": 14,
"committed": 8
"stub": 29,
"modelled": 15,
"committed": 9
},
"tailPhases": {
"total": 37,
@ -62,16 +62,17 @@
" 591376 inflated bytes, 0 error(s), 0 warning(s)",
" turn 1, frame 1, modCount 0, 8 player(s), 28 system(s), 6 fleet(s)",
"roundtrip (untouched): byte-identical (591376 bytes)",
"data: /tmp/claude-1000/-home-alex/ec8e34f8-af37-4ef2-a309-ed6a15293097/scratchpad/data -- 885 section(s) over 7 race(s), 46 load problem(s)",
"",
"phases",
" turn drivers (the milestone's denominator): 14 of 44 modelled, 8 committed",
" verified 0 implemented 2 partial 6 blocked 6 stub 30",
" turn drivers (the milestone's denominator): 15 of 44 modelled, 9 committed",
" verified 0 implemented 3 partial 6 blocked 6 stub 29",
" post-combat tail (written to the autosave, tracked separately): 3 of 37 modelled",
" verified 0 implemented 1 partial 0 blocked 2 stub 34",
"",
"this run",
" leaves written 7",
" leaves NOT written by a blocked phase 64",
" leaves NOT written by a blocked phase 120",
" generator words consumed 16 (state loaded, left untouched)",
" generator words NOT accounted (never netted off the above):",
" - encounter detection draws one unit value and one bounded integer per turn on every turn measured (2 words), with no derived rule behind the count -- its bound is the product of the contact and detector counts, so it is left unmodelled",
@ -80,8 +81,8 @@
" ! the generator advanced during this run but the save keeps its original state (--commit-rng to write it)",
" ! the modelled words are a LOWER BOUND on the turn's cost, so a committed generator is short by the unaccounted sites below and its drawn VALUES are not the game's",
"",
"wrote /tmp/tmp_cz5dsbf/post-turn1-state.sav (65145 bytes gzipped, 591376 inflated)",
"metric -> /tmp/tmp_cz5dsbf/metric-turn1-state.sav.json"
"wrote /tmp/tmpbodkynxg/post-turn1-state.sav (65145 bytes gzipped, 591376 inflated)",
"metric -> /tmp/tmpbodkynxg/metric-turn1-state.sav.json"
],
"coverage": {
"input": true,
@ -89,9 +90,9 @@
"ours": true
},
"roots": {
"input": "ce36f6e5a2e41ae1a90d5b7fefda8f8f",
"oracle": "b0e2448b83ad1da254d526f4f553ff8d",
"ours": "8836ea25910897ec7a9b7d69fcaf6c87"
"input": "64b836b966299d9b72fc02244ed31e31",
"oracle": "4bed514f853332b9fa35b388c3aa5558",
"ours": "1acc372b1d410b966ab544dffbe43cb5"
},
"baselineDiverging": 209,
"divergingAfterTurn": 204,
@ -151,16 +152,16 @@
"standalone": {
"schema": "sots-standalone-metric/1",
"input": "/home/alex/sots-re/verify/results/saves/turn1-state.sav",
"output": "/tmp/tmp_cz5dsbf/post-turn1-state.sav",
"output": "/tmp/tmpbodkynxg/post-turn1-state.sav",
"spine": {
"total": 44,
"verified": 0,
"implemented": 2,
"implemented": 3,
"partial": 6,
"blocked": 6,
"stub": 30,
"modelled": 14,
"committed": 8
"stub": 29,
"modelled": 15,
"committed": 9
},
"tail": {
"total": 37,
@ -174,7 +175,7 @@
},
"run": {
"leafWrites": 7,
"blockedLeafWrites": 64,
"blockedLeafWrites": 120,
"rngWords": 16,
"rngLoaded": true,
"rngCommitted": false,
@ -239,8 +240,8 @@
"driver": "StrategyServer::ProcessTurn",
"id": "S04",
"name": "RebuildAllianceMasks",
"status": "stub",
"ran": 0,
"status": "implemented",
"ran": 8,
"writes": 0,
"blockedWrites": 0,
"rng": 0
@ -1002,7 +1003,7 @@
"status": "blocked",
"ran": 8,
"writes": 0,
"blockedWrites": 48,
"blockedWrites": 104,
"rng": 0
},
{
@ -1028,16 +1029,17 @@
" 603360 inflated bytes, 0 error(s), 0 warning(s)",
" turn 2, frame 2, modCount 12, 8 player(s), 28 system(s), 7 fleet(s)",
"roundtrip (untouched): byte-identical (603360 bytes)",
"data: /tmp/claude-1000/-home-alex/ec8e34f8-af37-4ef2-a309-ed6a15293097/scratchpad/data -- 885 section(s) over 7 race(s), 46 load problem(s)",
"",
"phases",
" turn drivers (the milestone's denominator): 14 of 44 modelled, 8 committed",
" verified 0 implemented 2 partial 6 blocked 6 stub 30",
" turn drivers (the milestone's denominator): 15 of 44 modelled, 9 committed",
" verified 0 implemented 3 partial 6 blocked 6 stub 29",
" post-combat tail (written to the autosave, tracked separately): 3 of 37 modelled",
" verified 0 implemented 1 partial 0 blocked 2 stub 34",
"",
"this run",
" leaves written 7",
" leaves NOT written by a blocked phase 58",
" leaves NOT written by a blocked phase 114",
" generator words consumed 16 (state loaded, left untouched)",
" generator words NOT accounted (never netted off the above):",
" - encounter detection draws one unit value and one bounded integer per turn on every turn measured (2 words), with no derived rule behind the count -- its bound is the product of the contact and detector counts, so it is left unmodelled",
@ -1046,8 +1048,8 @@
" ! the generator advanced during this run but the save keeps its original state (--commit-rng to write it)",
" ! the modelled words are a LOWER BOUND on the turn's cost, so a committed generator is short by the unaccounted sites below and its drawn VALUES are not the game's",
"",
"wrote /tmp/tmp_cz5dsbf/post-turn2-state.sav (66883 bytes gzipped, 603360 inflated)",
"metric -> /tmp/tmp_cz5dsbf/metric-turn2-state.sav.json"
"wrote /tmp/tmpbodkynxg/post-turn2-state.sav (66883 bytes gzipped, 603360 inflated)",
"metric -> /tmp/tmpbodkynxg/metric-turn2-state.sav.json"
],
"coverage": {
"input": true,
@ -1055,9 +1057,9 @@
"ours": true
},
"roots": {
"input": "b0e2448b83ad1da254d526f4f553ff8d",
"oracle": "7ea50b486c2e9f9f1a8ec5b9646165a8",
"ours": "6523aa25786814380a26f48c046da22b"
"input": "4bed514f853332b9fa35b388c3aa5558",
"oracle": "e9c161e311f8ef8fad6f1aa1903dcf3f",
"ours": "22071ae1303f308a93b805e940b626b5"
},
"baselineDiverging": 108,
"divergingAfterTurn": 103,
@ -1117,16 +1119,16 @@
"standalone": {
"schema": "sots-standalone-metric/1",
"input": "/home/alex/sots-re/verify/results/saves/turn2-state.sav",
"output": "/tmp/tmp_cz5dsbf/post-turn2-state.sav",
"output": "/tmp/tmpbodkynxg/post-turn2-state.sav",
"spine": {
"total": 44,
"verified": 0,
"implemented": 2,
"implemented": 3,
"partial": 6,
"blocked": 6,
"stub": 30,
"modelled": 14,
"committed": 8
"stub": 29,
"modelled": 15,
"committed": 9
},
"tail": {
"total": 37,
@ -1140,7 +1142,7 @@
},
"run": {
"leafWrites": 7,
"blockedLeafWrites": 58,
"blockedLeafWrites": 114,
"rngWords": 16,
"rngLoaded": true,
"rngCommitted": false,
@ -1205,8 +1207,8 @@
"driver": "StrategyServer::ProcessTurn",
"id": "S04",
"name": "RebuildAllianceMasks",
"status": "stub",
"ran": 0,
"status": "implemented",
"ran": 8,
"writes": 0,
"blockedWrites": 0,
"rng": 0
@ -1968,7 +1970,7 @@
"status": "blocked",
"ran": 8,
"writes": 0,
"blockedWrites": 48,
"blockedWrites": 104,
"rng": 0
},
{

View file

@ -0,0 +1,142 @@
# T36 `FinalizeTurnRecords` — the ship census wired in, and what T36 is still blocked on
Lane E2, 2026-09-08. Engine branch `wip/turnrecord` off `sots-engine` main `0592104`.
Measured with `verify/state-checksum/state_checksum.py` leaves, the same currency as
`report.txt`. **`closed` and `regressed` are reported separately on every line; nothing here
is netted.**
`status.json` and `report.txt` in this directory were regenerated from the same binary.
## What changed in the engine
`ShipCensus` (lane D2) is wired into `BuildTurnRecord`. The classification walks every
player's designs — normal and legacy, one id space — resolves each design's sections against
the section catalog, and walks the global fleet list keyed by `Flt.PID` against each player's
**object id**, not its vector position. The record's modelled field count goes 7 → 13.
Two things the standalone did not have before:
* **a data root.** The census cannot come from a save: a design's hull size and its
defence-platform flag are recomputed from the section catalog whenever the design changes
and are never written down. `sots_turn --data DIR` (or `$SOTS_DATA_DIR`) supplies it. No
game data enters the repo, and without a root the six counters are reported unmodelled
rather than written as six zeros.
* **`--commit-blocked=IDS` / `--commit-blocked-except=IDS`.** `--commit-blocked` alone is
all-or-nothing across every blocked phase, which reports one closed count and one regressed
count for all of them together — the netting the campaign does not do. The two lists narrow
it to named phase ids, so a phase's own contribution can be measured. Every number below
that names T36 was measured with `--commit-blocked=T36`.
## Prediction, written before the code (rule 2)
> Default mode unchanged at 209 → 204, closed 5 / regressed 0 and 108 → 103, closed 5 /
> regressed 0. With `--commit-blocked`, the three census leaves close and the regressed list
> falls from 9 to 6 on pair 1 (`inc`×3 + `sav`×3, the budget) and from 11 to 8 on pair 2.
Falsification hypothesis 1 was: *the census is built from the fleet list as it stands, which
is pre-construction; if a real turn's census differs from the input turn's, the leaf does not
close and shows a third value.*
**That is what happened.** Two of the three closed; the third did not, and the symptom was
exactly the one written down.
## Measured — reference pair `turn1-state.sav` → `turn2-state.sav`
| run | closed | regressed |
|---|---:|---:|
| default (no commit flags) — before and after this lane | 5 | 0 |
| `--commit-blocked`, before this lane | 29 | 9 |
| `--commit-blocked`, after | 29 | **7** |
| `--commit-blocked=T36` alone, with a data root | 29 | 7 |
| `--commit-blocked=T36` alone, no data root | 29 | 9 |
Regressed after, named: `hist[0]/stats[1]/{sav,inc}`, `hist[1]/stats[1]/{sav,inc}`,
`hist[7]/stats[1]/{sav,inc}` — the budget, lane E1 — and `hist[1]/stats[1]/shpt[0]`, ours 0,
archived 1.
Closed by the census: `hist[4]/stats[1]/shpt[2]` (5 dreadnoughts) and
`hist[7]/stats[1]/satt[0]` (10 defence platforms).
## Measured — second pair `turn2-state.sav` → `turn3-state.sav`
| run | closed | regressed |
|---|---:|---:|
| default | 5 | 0 |
| `--commit-blocked`, before this lane | 13 | 11 |
| `--commit-blocked`, after | 13 | **9** |
| `--commit-blocked=T36` alone, with a data root | 13 | 7 |
| `--commit-blocked=T36` alone, no data root | 13 | 9 |
The two extra regressions under a full `--commit-blocked` are `Player/Status` on two players.
They are S31's, not T36's, and the new id filter separates them cleanly — which is the whole
reason the filter exists.
## The one census leaf that did not close is not a census error
`app_test_turn_record`, extended this lane, now compares the six counters against the record
the game itself archived, on every save: **11 saves, 80 player-records, 1040 fields, 480 of
them census leaves, 0 mismatches.** That is the same 480/480 lane D2 got, reproduced through
the app's own code path (which visits slots in the original's in-memory order rather than the
wire's) rather than through D2's test.
So the model reproduces the archived census exactly for the turn the save was written at. The
remaining leaf is `shpt[0]` for one player, and the archived value is higher than ours by
exactly one destroyer on **both** pairs (0 vs 1, then 1 vs 2). That player's build queue
completes one ship per turn, and no phase the standalone runs creates a ship — S11's
build-queue sub-pass is the declared input boundary. The leaf is short by the turn's
construction, not wrong about classification.
**This is a new named blocker, and it is not lane E1's and not mine.**
## T36's status: still `Blocked`, on two named things, neither of them in this phase
1. `sav` and `inc` for the new turn come from P01/P02, blocked on the per-system money
output — **lane E1**.
2. `shpt[0]` is short by the ships the turn builds — **ship construction, unmodelled**.
There is **no field-granular knob that could rescue this**, and the brief's
`--commit-blocked-except=<fields>` idea is not implementable as stated: the archived record
is one struct on the wire, so `sav` and `inc` cannot be left out while the other eleven fields
are written. The format has no hole. Committing is all-or-nothing at the record, so the
choice is 29 closed with 7 confidently-wrong leaves, or neither — and 7 wrong leaves is not
worth 29 that a later lane closes for free. What *is* implementable, and is implemented, is
the same convention at **phase** granularity, which is what produced the isolated rows above.
T36 flips the moment E1 lands, minus one leaf that waits on construction.
## Coverage, stated as loudly as the verdict (rules 6 and 15)
Only **32 of the 480** archived census leaves are nonzero anywhere in the 11-save corpus.
Per leaf (cls0 shpt/satt, cls1 shpt/satt, cls2 shpt/satt) = **18/3, 0/0, 11/0**.
* **cls1 (cruisers), ships and platforms both, and `satt` for cls2, have never been observed
nonzero.** Three of the six counters are unexercised hypotheses, not verified. A zero leaf
agrees for free. The corpus needs a cruiser and a dreadnought-hull platform
(roadmap §5 already lists both).
* The other four words of each class group — `shpl`, `shpk`, `satl`, `satk`, losses and kills
— are zero throughout the corpus, and this phase writes zeros with no model behind them.
Listed in the phase's own unmodelled list rather than passed off as agreement.
* Hull size is an **assignment** in slot order, so the visit order is load-bearing in
principle. The original's stats pass walks mission, command, engine; the wire writes
command, mission, engine. This code reproduces the memory order. **Design rule A6 makes
every shipped design class-homogeneous, so no save in the corpus can tell the two orders
apart** — the order is carried from the instruction stream, not confirmed by these bytes.
* `verified` stays **0**. Nothing here was compared against a running game; this lane holds no
VM. The comparison is against bytes the original wrote, which is the strongest check
available without one, and it is not the same thing.
## Gates
Run as separate commands, never chained (rule 13):
* `tools/clean_room_check.sh` — OK
* host `ctest`, no `SOTS_SAVES_DIR` — 45/45
* host `ctest`, `SOTS_SAVES_DIR` set — 45/45
* host `ctest`, `SOTS_SAVES_DIR` + `SOTS_DATA_DIR` set — 44/45. The one failure is
`game_data_realdata`, and it **fails identically on unmodified `main`** with the same data
root: that test pins section and bank counts against the campaign's canonical extraction and
my extraction of `sots.gob` is a different data version (9 pinned counts differ, e.g. a
`human/DEWAR` section the pin says should be absent). It is a data-root mismatch, not a
regression, and the census matched all 480 archived leaves against that same root anyway.
* **Shim cross-build NOT run**: no `i686-w64-mingw32-g++` on this host. No `src/shim` file was
touched, but per rule 13 the integrator should run the CT111 shim build before pushing.