# What writes `SvSctOb` during a turn — the script-object event bus Lane SV, 2026-09-08. Program `sots` / "Sword of the Stars.exe", ImageBase 0x00400000, all addresses VAs. Host + static only; VM140 was held by lane W3 and the game was never run. Companion to `findings/objects/svsctob-variants.md` (lane W), which recovered **what the type is**. This one answers **what moves it**, and corrects one entry in `findings/control-flow/combat-done-tail.md`. --- ## 0. The answer in one paragraph `StrategyServer+0x1b4` holds the root script object. Nothing in the turn calls a method on a child script object directly. Everything goes through an **event bus**: a driver notifies the root with an integer event id, the root fans that same delivery out to **every** child, and each delivery is two steps — a **generic** handler that receives the id, and **one event-specific virtual slot** that does not. The `evt -> slot` map is a **33-entry dword jump table at 0x007a6480**, so "which class reacts to which event" is exhaustive and recovered, not inferred from what the saves happen to show. A turn sends six deliveries from five functions, and the eight diverging leaves of `/Sim/SvSctOb` are written by exactly three handlers across two of them. ## 1. The dispatcher, read as instructions `SVScriptObject_DispatchEvent` **0x007a60d0**, `__thiscall (this, int evt, void* arg)`, `ret 8`: ``` 007a60fd mov eax,[esi] ; esi = this 007a6102 mov edx,[eax+0x10] 007a6105 push edi ; arg 007a6106 push ebx ; evt 007a6107 call edx ; this->vft[0x10](evt, arg) -- the GENERIC handler 007a610c cmp ebx,0x20 007a610f ja 0x7a6469 ; -> done 007a6115 jmp dword ptr [ebx*4 + 0x7a6480] ``` Each arm loads a different slot and pushes a different argument shape out of `arg`. The full map, read out of the table and confirmed against the slot each arm loads: | 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 | Five ids are **not** in slot order (0x15, 0x16, 0x17, 0x18, 0x1c), which is exactly the kind of thing a reader who assumed `slot = 0x14 + 4*evt` would get wrong on the two ids the tail actually sends. The root's own handler, `SVSOSots_HandleEvent` **0x005a7e40** (vftable 0x00A063C4 slot +0x10, and the only slot `Game::SVSOSots` overrides at all): ``` evt == 3 || evt == 0x1b -> call 0x005a7d70 (lane W's new-game seeder) evt == 0x1a -> call 0x005a37e0 always: for (i = 0; i < (this->+0x20 - this->+0x1c)/4; ++i) SVScriptObject_DispatchEvent(this->children[i], evt, arg) ``` The loop re-reads both bounds each iteration, so a child may resize the child vector under it — the same shape as tail phase 6. **This is what proves the hand-written pairs in the turn drivers are event deliveries.** Lane K read tail phase 8 as `if (S->+0x1b4) { script->vft[0x10](8,0); script->vft[0x34](); }` and phase 20 as the same shape with `0x14`/`+0x64` and `0x15`/`+0x6c`. Those are precisely rows 8, 0x14 and 0x15 of the table above: the drivers open-code the two-step on the **root**, and the root's generic handler then does the full two-step per **child**. ## 2. Where a turn delivers, and what it sends Every site in the image that reads `StrategyServer+0x1b4` and dispatches, with the id it pushes. Found by scanning `.text` at instruction boundaries for `mov r32,[r32+0x1b4]` and filtering to the StrategyServer range, then reading the id off the `push` before the indirect call. | function | VA of the site | evt | in a turn? | |---|---|---|---| | `BeginProcessTurn` 0x007d98e0 | 0x007d9ab8 | **0x13** | **yes — the first delivery of the turn** | | `StrategyServer::ProcessTurn` 0x007dc6c0 | 0x007dcb7a | 6 | yes | | `StrategyServer::ProcessTurn` | 0x007dcb9f | 0x1c | yes | | `StrategyServer::MoveFleet` 0x007d9ee0 | 0x007d9faa | 0xe | per move | | `ApplyEncounterResult` 0x007d8920 | 0x007d8e5a | 7 | per encounter | | `OnAllCombatDone_Tail` 0x007d92a0 | 0x007d96bf | 8 | yes (phase 8) | | `OnAllCombatDone_Tail` | 0x007d9752 | 0x14 | yes (phase 20) | | `OnAllCombatDone_Tail` | 0x007d9772 | 0x15 | yes (phase 20) | | `OnAllCombatDone_Tail` | **0x007d9820** | **0x1c** | **yes — see §2.1** | | `BuildTurnEvents` 0x007db780 | 0x007db81f / 0x007dbd9d | 0x1a / 0x1b | after the tail | | `SynchronizePlayer`, `LoadGame`, `ResumePlaying`, `Write`, `Read` and eight others | — | 1..5, 0xd, 0x17, 0x18 | not a turn | ### 2.1 Correction to `combat-done-tail.md` Lane K's phase map lists **three** script-hook sites in the tail (phases 8 and 20) and its tier-4 note attributes event **0x1c** to `ProcessTurn`. There is a **fourth** site in the tail, at **0x007d9820**, immediately after the maintenance/research recompute and the call at 0x007d981b, and it sends **0x1c** as well: ``` 007d9820 mov esi,[ebx+0x1b4] 007d9829 test esi,esi 007d982b je 0x7d9843 007d982d mov eax,[esi] 007d982f mov edx,[eax+0x10] 007d9832 push 0x0 007d9834 push 0x1c 007d9838 call edx 007d983a mov eax,[esi] 007d983c mov edx,[eax+0x78] ; +0x78 is evt 0x1c's slot -- consistent with the table ``` So event 0x1c is sent **twice** in a turn, once from each driver. On our corpus only `Game::SVSOVonNeumann` overrides that slot (0x00527fd0), and it moves nothing that diverges. ## 3. Which classes react, by event Over the twelve classes our saves carry, comparing each vtable slot against the modal value across all 30 `SVScriptObject` subclasses (the base default). Only the four events a turn's *tail* and *begin* send are shown; the shared no-op is 0x0080c5a0. | class | evt 0x13 (begin) | evt 8 | evt 0x14 | evt 0x15 | evt 0x1c | |---|---|---|---|---|---| | VonNeumann (1) | 0x00521de0 | 0x00522100 | — | — | 0x00527fd0 | | Swarm (3) | 0x00504c90 | — | — | — | — | | Derelict (4) | — | — | — | — | — | | Monitor (5) | — | — | — | — | — | | **SlaversRefuel (9)** | — | — | **generic 0x0051a800** | — | — | | **SwarmQueen (10)** | **0x00529930** | — | **0x005275d0** | — | — | | CrowRuins (17) | — | — | — | — | — | | **Refugees (20)** | **0x00511260** | 0x00511310 | — | — | — | | Traps | 0x0051a0e0 | 0x0051a0e0 | 0x0051a020 | — | — | | CrowDefenders | 0x004f8d60 | 0x0052b2a0 | 0x005138a0 | — | — | | IndependentSystems | 0x00750c40 | 0x0075cb20 | — | — | — | | GrandMenaceTrigger | 0x0050dc80 | — | — | — | — | | SVSOSots (root) | — | — | — | — | — | **Event 0x15 is overridden by nobody.** The tail's second phase-20 pair is dead in every class our saves hold — read off the vtables, not inferred from the bytes. `SVSOSlaversRefuel` is the one class that reacts through the **generic** handler and overrides no event-specific slot at all: 0x0051a800 is `if (evt == 0x14) call 0x00515820`, seven instructions. ## 4. The three writers behind the eight diverging leaves `EncObj[3]` is EncID 9, `EncObj[5]` is EncID 10, `EncObj[6]` is EncID 20. ### 4.1 `CDiff` — `SVSOSlaversRefuel_UpdateDifficultyTier` 0x00515820, evt 0x14, tail phase 20 Builds a **3x3-dword table on the stack** and scans it against `GetGame()->+0xc`: | threshold | payload | |---|---| | 1 | (1, 1) | | 50 | (2, 3) | | 100 | (2, 5) | ``` 00515895 lea ecx,[ebp-0x34] 00515898 cmp [ecx],edx ; edx = frame 0051589a jg 0x5158c2 ; found 0051589c add eax,ebx ; ++i 0051589e add ecx,0xc ; next record 005158a1 cmp eax,0x3 005158a4 jl 0x515898 ...fall through to the epilogue: NO STORE 005158c2 test eax,eax 005158c4 jle 0x5158a6 ; index 0: NO STORE 005158c6 dec eax 005158cc cmp [edi+0x38],eax 005158cf je 0x5158a6 ; unchanged: NO STORE 005158d1 mov [edi+0x38],eax ; CDiff = index - 1 ``` Three consequences, none of them visible in the data: * frame ≤ 0 → no write; frame 1..49 → tier 0; frame 50..99 → tier 1; * **frame ≥ 100 → the scan runs off the end and there is no write at all**, so the tier can never reach 2 through this path. That reads as an off-by-one in the original; it is recorded as what the code does. * Only on a change does the function continue into the per-system pass at 0x005158d4. `CDiff` is `SVSOSlaversRefuel+0x38`, confirmed against `SVSOSlaversRefuel::Write` 0x004fdf80 (`lea eax,[esi+0x38]; push "CDiff"; call WriteInt`). ### 4.2 `ini` / `dids` — `SVSORefugees_OnTurnBegin` 0x00511260, evt 0x13, `BeginProcessTurn` ``` if (!this->ini(+0x14)) { this->ini = 1; // the SECOND instruction of the block obj = ; if (obj) this->dids(+0x18).push_back(obj->handle(+0xa0)->id(+4)); } ``` Offsets confirmed against `SVSORefugees::Write` 0x00509640: `didc` counts the vector at `+0x18..+0x1c`, `ini` is the bool at `+0x14`. The store to the latch is **unconditional on the lookup's result**, which matters for anyone modelling this without the data files: the latch is not conditional on the design. `Game::SVSORefugees` also overrides evt 8 (0x00511310), and that handler **drains** an object vector at `+0x28..+0x2c` rather than appending to `dids`. Reading the two the other way round is the obvious trap here and would put the writer in the tail instead of at turn begin. ### 4.3 `Hives` — `SVSOSwarmQueen_RegisterHives` 0x00527630 + `_TickHives` 0x00527770, evt 0x13 The constructor 0x0051ae20 settles who the queen works for: ``` 0051ae30 mov [eax],0x9f49e4 ; vftable 0051ae36 mov [eax+0x4],0x3 ; the SCENARIO TAG it selects systems by -- the SWARM's 0051ae3d mov [eax+0x8],0xa ; its own EncID, 10 ``` Registration walks `GetGame()->+0x44..+0x48` (`Systems`), matches `sys->+0x184` against the scenario tag, skips systems a hive already references, and appends a `HiveInfo`: ``` 005276e1 mov ecx,ds:0xae0204 005276e7 mov edx,ds:0xae0208 005276ed mov eax,[eax+0xc] ; frame 005276f0 mov edi,[ecx] ; LO 005276f2 mov esi,[edx] ; HI 005276f4 add edi,eax ; frame + LO 005276f6 add esi,eax 005276f8 call GetGame 005276fd mov eax,[eax+0x16c] ; the strategic RNG 0052770c call 0x4271c0 ; NextIntInclusive(HI - LO) <-- ONE DRAW PER NEW HIVE 00527717 add eax,edi ; nextQ = frame + LO + draw 0052771f call 0x523080 ; push_back ``` `HiveInfo` layout, from its `Write` 0x004fe730 (vftable 0x009f1a68): `+0x4` HiveID (handle), `+0x8` NextQ (int), `+0xc` QueenID (handle) — **the wire order is not the member order**. `sys->+0x184` is the save's **`EggScio`**, and the data says so as loudly as the code does. In `turn1-state.sav`, the only systems with `EggScio == 3` are 336 and 400 — exactly the two the swarm has infested (`SVSOSwarm.infest` sysid 336, 400) and exactly the two that get hives; `EggScio == 4` are 448 and 480, the two systems `SVSODerelict`'s `NAsg` names; `EggScio == 5` is 64, the Monitor's one system. Every other system is -1. **`++NextQ` is the whole explanation of a number that looked impossible.** `NextQ` reads 31/29 after turn 1 and 32/30 after turn 2, and re-rolling cannot produce a +1 on two hives at once. The tick: ``` 0052785a inc DWORD PTR [esi+0x8] ; every gate-failure path lands here ``` Per hive with `QueenID == 0`, if any spawn gate fails the target turn **slips forward by one**; only if the gates open and `NextQ <= frame` does a queen spawn. So the date walks away from the hive until the gates open. The gates read config pointers at 0x00ae0210, 0x00ae0228, 0x00ae0220. ## 5. What could not be closed, and precisely why | leaf | blocked on | |---|---| | `EncObj[6]/didc`, `.../did` | **design instantiation**, not a workload. The id appended is `1712`, and the same turn also allocates ship `1728` and fleet `1744` (`NMnx` 106 -> 109) — the refugee-trader convoy, three consecutive handles. A standalone that does not allocate handles cannot produce it, and no different save would help. | | `EncObj[5]/Hives/.[1]/NextQ`, `.[2]/NextQ` (on the creation turn) | **one MT draw and two data-file constants**. `frame + LO + NextIntInclusive(HI - LO)`, `LO` and `HI` behind pointers at 0x00ae0204/0x00ae0208 that no `.text` or `.data` reference initialises in a form this lane could follow. Fitting `LO` and `HI` from a single two-hive observation would have been fitting, not derivation (r1 - r2 = 2 is one constraint on two unknowns), so it was not done. | The `NextQ` **slip** is not blocked and is exact: on `turn2 -> turn3`, where the hives already exist, the modelled rule reproduces both target turns (31 -> 32, 29 -> 30) with no draw and no fitting. > **RUN AND CONFIRMED BY LANE L1, 2026-09-08** (`findings/control-flow/hive-creation-rng.md`). Every > claim in §4 and §5 below was put under an instrument on VM140 and held, with two corrections of > detail and one leaf closed: > > * **Hive creation draws, and lane Z's zero is the workload.** On the `turn1-state.sav` End Turn, > `RegisterHives` was entered once inside `BeginProcessTurn` and cost **exactly 2 words** for 2 new > hives; `BeginProcessTurn`'s total was 2 and the residual outside the two drivers was **2**, not 0. > On the next turn all of those are 0. The prediction in §5 was right. > * **The `NextQ` slip is exact, live.** `TickHives` cost 0 words on both turns; the hives left > `RegisterHives` at `NextQ` 30 / 28, the save carries 31 / 29, and the next turn's save carries > 32 / 30 — reproducing `turn2-state.sav` and `turn3-state.sav` from a draw plus an `inc`. > * **`LO` and `HI` are closed, by reading them live rather than fitting them**: `*(int*)0x00ae0204 = > **20**`, `*(int*)0x00ae0208 = **30**`, so `NextQ = frame + 20 + NextInt(10)` (inclusive). The > `TickHives` gates read 10 (a frame floor), 5 and 3 — so no queen can spawn before frame 11 on any > save, which is why the spawn arm has never run. > * **`CDiff`'s two invisible edges are confirmed** — the table scan was re-read independently and > `CDiff` can only ever hold 0 or 1 — and the "stored nothing" case was observed live and > distinguished from "did not run": the writer is entered every turn and stored only on frame 2. > * **One address corrected:** the draw call in §4.3 is at **0x0052770f** (return 0x00527714), not > 0x0052770c, which is the `mov [ebp-0x14],esi` that stores the bound. **An RNG claim this lane did not measure.** Lane Z measured a strategic turn at 18–22 generator words, *all* inside `ProcessTurn`, with the residual outside the two turn drivers **exactly zero** — on turns 6 and 64, where the swarm hives already existed. Hive creation draws **inside `BeginProcessTurn`**, which is outside both drivers and before either of them. So on a turn that creates hives the residual should be **at least two words**, and lane Z's zero is a statement about the turns it measured. This is cheap to falsify: 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 in any save we hold overrides it. * **The queen spawn arm.** No hive in the corpus has a queen, so only the slip arm has ever run. Workload: a swarm game run past the spawn gates. * **`CDiff` tiers 1 and 2.** Tier 1 needs frame ≥ 50; the Zuul saves reach turn 23. Tier 2 is unreachable at any frame, which is a property of the code and not of the corpus. * **The per-system pass after a `CDiff` change** (0x005158d4 onward). It runs on the reference pair, and it writes nothing this object serialises (`NAsg`, `NTD`, `NAD` are unchanged across both pairs). Whether it writes anything **elsewhere** is a labelled hypothesis; a regression outside `SvSctOb` would falsify it, and the measured run has none. * **The alliance and two-empire-contact events.** The corpus has neither, so the deliveries that carry them (`SynchronizePlayer` and the 1..5 family) are unreachable from any save we hold. ## 7. Engine `sots-engine` `wip/svsctob`: `src/game/sim/scriptobjects.{h,cpp}` (the rules, pure), `src/app/ script_phase.{h,cpp}` (the bridge), a new host phase **H03 ScriptHookTurnBegin** run right after the frame counter, and tail phase **T20** implemented. Prediction and falsification in `sots-engine/docs/SV-script-objects.md`, committed before the build. Measured on CT111, closed and regressed stated separately: | configuration | pair | result | closed | regressed | |---|---|---|---|---| | default | turn1 -> turn2 | 209 -> **126** (was 128) | 83 | 0 | | default | turn2 -> turn3 | 108 -> **67** (was 69) | 41 | 0 | | `--commit-blocked=H03` | turn1 -> turn2 | 209 -> **124** | 87 | **2** | | `--commit-blocked=H03` | turn2 -> turn3 | 108 -> **67** | 41 | 0 | Writing the hives closes the four leaves that say which systems have them and that they have no queens, and opens two carrying a `NextQ` known to be wrong. That trade is a flag, not a default. ## 8. Addresses `ghidra/addresses.d/lane-sv.json` — **13** entries (the dispatcher and its jump table, the root handler, the three writers plus the two handlers they are easily confused with, the `HiveInfo` writer, and the swarm-queen constructor). `gen_addresses.py` merges to 1,204 entries with no duplicate name. **A fourteenth entry was dropped, and the agreement recorded instead.** This lane reached the hive draw at 0x004271c0 independently and was about to file it as `RNG_NextIntInclusive`. `addresses.json` already carries it as **`RNG_NextInt`**, same address, same bound-by-pointer convention, same inclusive semantics — and lanes I, J, K and AI1 all depend on that name. The fragment merger only detects duplicate *names*, so a second name for the same address would have merged silently and quietly forked the campaign's vocabulary for its most-used RNG primitive. Dropped per the `addresses.d/README` rule; the existing entry is correct and the two readings agree.