# SV — what updates `SvSctOb` during a turn Lane SV. Written **before** the build (rule 2). The falsification section is §5. The eight leaves of `/Sim/SvSctOb` that diverge on the reference pair belong to three of the twelve script objects the save carries. This lane asked what writes them, found the mechanism, and implements the part of it the standalone can reach. --- ## 1. The mechanism: one event bus, two steps per delivery `StrategyServer+0x1b4` holds the root script object (a `Game::SVSOSots`). Every site that notifies it does the **same two-step**: a generic handler taking the event id, then one event-specific virtual slot with no id: ``` script->vft[0x10](evt, arg); // generic: every object sees every event script->vft[](...); // specific: one slot per event id ``` The root's generic handler fans the same delivery out to **every child** script object through a shared dispatcher, which repeats both steps per child and carries a **33-entry jump table** mapping `evt` (0..0x20) to the specific slot. So `evt -> slot` is a real, exhaustive encoding in the image, not a guess: | evt | slot | evt | slot | evt | slot | |---|---|---|---|---|---| | 0x00 | +0x14 | 0x0b | +0x40 | 0x16 | +0x70 | | 0x01 | +0x18 | 0x0c | +0x44 | 0x17 | +0x74 | | 0x02 | +0x1c | 0x0d | +0x48 | 0x18 | +0x68 | | 0x03 | +0x20 | 0x0e | +0x4c | 0x19 | +0x7c | | 0x04 | +0x24 | 0x0f | +0x50 | 0x1a | +0x80 | | 0x05 | +0x28 | 0x10 | +0x54 | 0x1b | +0x84 | | 0x06 | +0x2c | 0x11 | +0x58 | 0x1c | +0x78 | | 0x07 | +0x30 | 0x12 | +0x5c | 0x1d | +0x88 | | 0x08 | +0x34 | 0x13 | +0x60 | 0x1e | +0x8c | | 0x09 | +0x38 | 0x14 | +0x64 | 0x1f | +0x90 | | 0x0a | +0x3c | 0x15 | +0x6c | 0x20 | +0x94 | The table is what proves the hand-written pairs in the two turn drivers are event deliveries and not ad-hoc calls: the tail's `vft[0x10](8,0); vft[0x34]()` is exactly `evt 8`, and `vft[0x10](0x14,0); vft[0x64]()` is exactly `evt 0x14`. ## 2. Where a turn delivers events Every site that reads `StrategyServer+0x1b4` and dispatches, with the id it sends: | driver | event | when | |---|---|---| | `BeginProcessTurn` | **0x13** | first thing in the turn, right after the frame counter | | `StrategyServer::ProcessTurn` | 6, then 0x1c | the spine | | `StrategyServer::MoveFleet` | 0xe | per fleet move | | `ApplyEncounterResult` | 7 | per encounter, in the tail | | `OnAllCombatDone_Tail` | **8**, then **0x14**, **0x15**, then **0x1c** | tail phases 8, 20 and one site lane K's phase map does not list | | `BuildTurnEvents` | 0x1a, ?, 0x1b | after the tail | | `SynchronizePlayer`, `LoadGame`, `ResumePlaying`, and eight others | 1..5, 0xd, 0x17, 0x18, 0x2 | not a turn | **Correction to lane K.** `combat-done-tail.md` lists three script-hook sites in the tail (phases 8 and 20). There is a **fourth**, after the maintenance/research recompute, and it sends **event 0x1c** — the same id `ProcessTurn` sends. Lane K's tier-4 note attributes 0x1c to `ProcessTurn` alone. Of the twelve classes our saves carry, only these override a slot a turn delivers: | class | evt 0x13 (turn begin) | evt 8 | evt 0x14 | evt 0x1c | evt 6 | evt 7 | |---|---|---|---|---|---|---| | VonNeumann (1) | yes | yes | — | yes | yes | yes | | Swarm (3) | yes | — | — | — | — | yes | | Derelict (4) / Monitor (5) / CrowRuins (17) | — | — | — | — | — | yes | | SlaversRefuel (9) | — | — | **yes** (via its generic handler) | — | — | yes | | SwarmQueen (10) | **yes** | — | **yes** | — | — | — | | Refugees (20) | **yes** | yes | — | — | yes | yes | | Traps / CrowDefenders / IndependentSystems | yes | yes | yes | — | yes | — | | GrandMenaceTrigger | yes | — | — | — | — | — | `evt 0x15` is overridden by **nobody** — the tail's second phase-20 pair is dead in every class our saves hold. ## 3. The three writers behind the eight leaves `EncObj[3]` is EncID 9 (SlaversRefuel), `EncObj[5]` is EncID 10 (SwarmQueen), `EncObj[6]` is EncID 20 (Refugees). ### 3a. `CDiff` (1 leaf) — SlaversRefuel, event 0x14, the tail The class overrides only the **generic** handler, which does nothing unless `evt == 0x14`. The body builds a **three-record table on the stack** and walks it against the server's frame counter: | threshold | payload | |---|---| | 1 | (1, 1) | | 50 | (2, 3) | | 100 | (2, 5) | It scans for the first record whose threshold is **greater** than the frame, and writes `index - 1` into `CDiff` — but only if that differs from what is there. Three consequences, all from the instruction stream and none of them guessable from the data: * frame ≤ 0 → the first record already exceeds it, index 0, `jle` exit: **no write**; * frame in 1..49 → `CDiff = 0`; frame in 50..99 → `CDiff = 1`; * **frame ≥ 100 → the scan runs off the end and there is no write at all**, so `CDiff` can never reach 2 through this path. That looks like an off-by-one in the original and is recorded as what the code does, not as what it presumably meant. Only when `CDiff` changes does the function continue into a per-system pass. That pass writes nothing this object serialises (`NAsg`, `NTD`, `NAD` are unchanged across the pair), so it is **not** modelled and is labelled below. ### 3b. `ini` + `didc`/`did` (3 leaves) — Refugees, event 0x13, turn begin ``` if (!ini) { ini = true; obj = ; if (obj) dids.push_back(obj.handleId); } ``` `ini` is a one-shot latch and it is the whole gate. The push-back is **not** modelled: the id it appends is `1712`, and the same turn's save also gains design `1712`, ship `1728` and fleet `1744` — three consecutive handle allocations, `NMnx` 106 → 109. That is the refugee-trader convoy being created from the data files, and nothing in the standalone allocates handles or instantiates a design template. So this lane commits the latch and names the rest. ### 3c. `Hives` (4 leaves) — SwarmQueen, event 0x13, turn begin The constructor is decisive about the two ids this class carries: it stores **3** in the `SVScriptObject` scenario tag and **10** in its own encounter id. So the queen operates on the **Swarm's** systems, not on its own. ``` for each system with system.EggScio == this.scenarioTag (== 3): if no hive already references it: hive.system = system hive.queen = 0 hive.nextQ = frame + LO + rand(HI - LO) <-- ONE MT DRAW PER NEW HIVE hives.push_back(hive) prune hives whose system.EggScio != 3 for each hive with queen == 0: if : ++hive.nextQ elif hive.nextQ <= frame: ``` `EggScio` is confirmed as the system's owning-scenario tag by the data and not only by the code: in `turn1-state.sav` exactly the two systems with `EggScio == 3` (336, 400) are the two the Swarm has infested and the two that get hives; `EggScio == 4` are the two systems the Derelict has fleets on; `EggScio == 5` is the Monitor's one system. `++nextQ` is the whole explanation of a number that looked impossible: `NextQ` reads 31/29 after turn 1 and 32/30 after turn 2. It is not re-rolled — it **slips forward by one every turn the spawn gates fail**, so a hive's queen date walks away from it until the gates open. ## 4. What is predicted Implemented in `src/game/sim/scriptobjects.{h,cpp}` and driven from three phases in `src/app`. **P1 — `CDiff`.** `/Sim/SvSctOb/EncObj[3]/CDiff` closes, `-1 -> 0`, on both reference pairs. This is the only one of the eight that is completely free of anything the standalone lacks. **P2 — `ini`.** `/Sim/SvSctOb/EncObj[6]/ini` closes, `False -> True`, on both pairs. `didc` and `did` do **not** close and do **not** regress: `didc` stays 0 against the oracle's 1 and `did` stays absent. **P3 — `Hives`.** `/Sim/SvSctOb/EncObj[5]/Hives/.` and `.[0]` (the count, 2) close. `.[1]` and `.[2]` do **not**, because `NextQ` needs a draw this lane cannot place. Two of four. **P4 — regressions: zero.** Nothing here writes a leaf that currently agrees. **P5 — the pair total.** 128 → 124, closed 4, regressed 0, on `turn1 -> turn2`. Pair 2 is `turn2 -> turn3`, where the latch and the tier are already set and the hives already exist, so **pair 2 moves by 0** — and that asymmetry is itself the check that these are one-shot rules and not per-turn ones. **P6 — an RNG claim, not measured here.** Lane Z measured a turn at 18–22 generator words, *all* inside `ProcessTurn`, residual outside the two drivers **exactly zero** — on turns 6 and 64, where the hives already existed. On the reference pair the hives are **created**, and creation draws once per hive from the strategic generator inside `BeginProcessTurn`, which is **outside both turn drivers and before either of them**. So the reference turn should cost lane Z's `ProcessTurn` total **plus at least two words**, and lane Z's "residual is exactly zero" is a statement about the turns it measured, not about a turn. ## 5. How this could be wrong, and the symptom of each way 1. **`CDiff` reads a different counter than the frame.** The handler reads the server's `+0xc`, which `BeginProcessTurn` increments while logging "Begin processing turn N", so it is the frame. If it were instead the modification counter, the tier for the reference pair would still be 0 (both are small), so **this corpus cannot separate them** — the 1..49 window swallows the difference. Symptom elsewhere: a save at frame ~50 with a very different ModCount would put `CDiff` on the wrong side of the boundary. 2. **The stack table is read with the wrong stride.** If the records were 2 dwords rather than 3, the thresholds would be 1/1/50 and the reference pair would come out `CDiff = 1`, not 0. The symptom is immediate and visible in the very leaf we are trying to close. 3. **`ini` is set somewhere else as well.** If some other handler also latches it, committing it at turn begin is right by accident. Symptom: none on this corpus. Stated as a risk. 4. **`ini` should not be set without the design.** If the original's latch were written only *after* a successful instantiation, then a standalone that cannot instantiate should leave it false, and closing it here is closing a leaf with the wrong reason. Read from the instruction stream: the store to the latch is the **second instruction of the guarded block**, before the lookup and unconditional on its result. So the latch is not conditional on the design. 5. **The hive set is keyed by something other than `EggScio`.** Symptom: the wrong count, or hives on systems 448/480/64. The count leaf would then regress rather than close. 6. **`NextQ` might be reachable after all.** If the two creation draws are the first draws of the turn and the standalone's generator is loaded from the save, a future lane that models the two config constants could reproduce them exactly. This lane does not claim they are unreachable, only that it has not placed them. 7. **The per-system pass after a `CDiff` change might write something.** It runs on the reference pair (the tier changes on turn 1). If it wrote a leaf, a regression would appear somewhere outside `SvSctOb` — which is exactly what P4 would catch. ## 5a. What actually happened Measured on CT111, `tools/standalone_report.py`. Closed and regressed are stated separately and never netted, and the two configurations are stated separately too. **Default (hive registration off).** | pair | before | after | closed | regressed | |---|---|---|---|---| | turn1 -> turn2 | 209 -> 128 | 209 -> **126** | 83 (was 81) | **0** | | turn2 -> turn3 | 108 -> 69 | 108 -> **67** | 41 (was 39) | **0** | **With `--commit-blocked=H03` (hive registration on).** | pair | before | after | closed | regressed | |---|---|---|---|---| | turn1 -> turn2 | 209 -> 128 | 209 -> **124** | 87 | **2** | | turn2 -> turn3 | 108 -> 69 | 108 -> **67** | 41 | **0** | Scoring the predictions. * **P1 held.** `CDiff` closed, `-1 -> 0`, on pair 1; pair 2 already carried 0 and the store is conditional, so it correctly did nothing there. * **P2 held**, including its negative half: `didc` and `did` neither closed nor regressed. * **P3 held**, and only under the flag: the count and the list-shape leaves close, the two `NextQ` leaves do not — and they show up as **regressions**, because the tool's baseline had those two positions on the "agrees" side while our tree had no hive there at all. That is the honest reading of a knowingly-wrong value and it is why the registration is opt-in. * **P4 held** in the default configuration and **failed as stated** under the flag: 2 regressions, both named above, both the same field. * **P5 was WRONG, and wrong in the direction that matters.** It said pair 2 would move by 0, because the latch and the tier are already set there and the hives already exist. Pair 2 moved by **2**: `Hives/.[1]/NextQ` and `.[2]/NextQ` closed, 31 -> 32 and 29 -> 30. The prediction forgot that the hives being present is exactly what lets the **slip** run, and the slip is a per-turn rule, not a one-shot. So the second pair is not the null control P5 called it — it is the only **exact** test of the rule this lane recovered, and it passes: two hives, two independent target turns, both landing on the oracle's value with no fitting and no draw. A rule that reproduces two numbers it was not built from is worth more than the pair-1 leaves it was aimed at. * **P6 is untested here.** It is a claim about the generator, not about leaves, and this lane did not instrument it. It is recorded so the next lane to touch the RNG ledger can falsify it cheaply: hook the turn-begin delivery on a save whose swarm hives do not yet exist. ## 6. What no save exercises (rule 6) * `evt 0x15` — no class our saves hold overrides it. The tail's second phase-20 pair is a no-op on every workload we can build from this corpus. * The queen **spawn** arm: no hive in any corpus save has a queen, so only the `++NextQ` slip arm has ever run. Workload needed: a swarm game run past the spawn gates. * `CDiff` tiers 1 and 2: needs a save at frame ≥ 50. The Zuul saves reach turn 23. * The Refugees `dids` push: needs design instantiation from the data files, not a different save. * Two script objects with a factory entry and no occurrence, and the whole alliance/contact family of events, remain unexercised — the corpus has no alliance and no two-empire contact.