sots-re/findings/objects/svsctob-writers.md

18 KiB
Raw Blame History

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 = <lookup "Mission" / "_Refugee_Trader" in the data files>;
    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.