SV: what writes SvSctOb during a turn -- the script-object event bus

Lane W recovered what the type is; this is what moves it. Nothing in the turn calls a
method on a child script object directly. A driver notifies the root with an integer event
id, the root fans the delivery out to every child, and each delivery is a generic handler
plus one event-specific vtable slot chosen from a 33-entry jump table at 0x007a6480. That
table is what proves the hand-written pairs lane K read in the tail are event deliveries
and not ad-hoc calls, and five of its rows are not in slot order.

Six deliveries in a turn, from five functions, with the ids they send. Three handlers
write the eight leaves that diverged: the slavers' difficulty tier (a three-record stack
table against the frame, and at frame >= 100 the scan runs off the end and stores nothing,
so the tier can never reach 2), the refugees' one-shot latch at turn begin, and the swarm
queen's hives -- registered on the systems whose EggScio equals the SWARM's scenario tag,
which the queen's constructor stores at +0x4 while its own encounter id sits at +0x8.

The hive target turn slips forward by one every turn the spawn gates fail. That single
`inc` is the whole explanation of a field that reads 31 after turn 1 and 32 after turn 2,
which no re-roll can produce on two hives at once.

Corrects combat-done-tail.md: the tail has a FOURTH script-hook site, at 0x007d9820, and
it sends event 0x1c -- the same id ProcessTurn sends, which lane K attributed to that
driver alone.

Two leaves stay blocked, and not on a workload: the refugees' design id needs handle
allocation and data-file instantiation, and the hive target turn needs one MT draw plus
two config constants behind pointers no reference initialises in a form this lane could
follow. Fitting them from a single two-hive observation would have been fitting, not
derivation, so it was not done.

An RNG claim for the next lane to falsify cheaply: lane Z's "residual outside the two turn
drivers is exactly zero" was measured on turns where the hives already existed. Hive
creation draws inside BeginProcessTurn, outside both drivers and before either.

13 addresses in ghidra/addresses.d/lane-sv.json; validated to a scratch path, merges to
1,204 with no duplicate name. A fourteenth was dropped: this lane reached 0x004271c0
independently and would have filed it as RNG_NextIntInclusive, but addresses.json already
carries it as RNG_NextInt with the same convention and four lanes depend on that name. The
merger only detects duplicate NAMES, so a second name for one address would have merged
silently and forked the vocabulary for the campaign's most-used RNG primitive.
This commit is contained in:
alex 2026-09-08 16:46:44 -04:00
parent d6469bd4c5
commit 2e935b8491
2 changed files with 428 additions and 0 deletions

View file

@ -0,0 +1,320 @@
# 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.
**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.

View file

@ -0,0 +1,108 @@
{
"entries": [
{
"name": "SVScriptObject_DispatchEvent",
"addr": "0x007a60d0",
"convention": "thiscall",
"prototype": "int (Game::SVScriptObject* this, int evt, void* arg) // the script-object event bus. Calls this->vft[0x10](evt, arg) -- the GENERIC handler every object sees -- then `cmp evt,0x20; ja done; jmp dword [evt*4 + SVScriptObject_EventSlotJumpTable]`, which dispatches to ONE event-specific vtable slot with the argument shape that event carries. Every hand-written `vft[0x10](id,0); vft[slot]()` pair in the two turn drivers is this same two-step done on the root object",
"status": "verified",
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
},
{
"name": "SVScriptObject_EventSlotJumpTable",
"addr": "0x007a6480",
"convention": "data",
"prototype": "void* [33] // evt (0..0x20) -> the vtable slot SVScriptObject_DispatchEvent calls. Slot byte offsets in evt order: 0x14 0x18 0x1c 0x20 0x24 0x28 0x2c 0x30 0x34 0x38 0x3c 0x40 0x44 0x48 0x4c 0x50 0x54 0x58 0x5c 0x60 0x64 0x6c 0x70 0x74 0x68 0x7c 0x80 0x84 0x78 0x88 0x8c 0x90 0x94. Note 0x15->+0x6c, 0x16->+0x70, 0x17->+0x74, 0x18->+0x68 and 0x1c->+0x78 are NOT in slot order",
"status": "verified",
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
},
{
"name": "SVSOSots_HandleEvent",
"addr": "0x005a7e40",
"convention": "thiscall",
"prototype": "int (Game::SVSOSots* this, int evt, void* arg) // Game::SVSOSots vftable 0x00A063C4 slot +0x10, the ONLY slot that class overrides. evt 3 or 0x1b re-runs the new-game seeder 0x005a7d70; evt 0x1a runs 0x005a37e0; then it fans the delivery out to every child in the pointer vector at this+0x1c..0x20 through SVScriptObject_DispatchEvent. The loop re-reads both bounds every iteration, so a callee may resize the child vector under it",
"status": "verified",
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
},
{
"name": "SVSOSlaversRefuel_HandleEvent",
"addr": "0x0051a800",
"convention": "thiscall",
"prototype": "int (Game::SVSOSlaversRefuel* this, int evt, void* arg) // generic handler; the class overrides no event-specific slot at all. Body is `if (evt == 0x14) SVSOSlaversRefuel_UpdateDifficultyTier(this)` and nothing else",
"status": "verified",
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
},
{
"name": "SVSOSlaversRefuel_UpdateDifficultyTier",
"addr": "0x00515820",
"convention": "thiscall",
"prototype": "void (Game::SVSOSlaversRefuel* this) // writes CDiff at this+0x38. Builds a 3x3-dword table on the stack -- thresholds 1 / 50 / 100 -- and scans for the FIRST threshold GREATER than StrategyServer+0xc (the frame), then stores index-1 if it differs from the stored value. Frame <= 0 exits at index 0; frame >= 100 runs off the end and stores NOTHING, so the tier can never reach 2. Only on a change does it continue into the per-system pass at 0x005158d4",
"status": "verified",
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
},
{
"name": "SVSORefugees_OnTurnBegin",
"addr": "0x00511260",
"convention": "thiscall",
"prototype": "void (Game::SVSORefugees* this) // vtable slot +0x60, event 0x13, sent from BeginProcessTurn. `if (!this->ini(+0x14)) { this->ini = 1; obj = <instantiate \"Mission\" / \"_Refugee_Trader\" from the data files>; if (obj) this->dids(+0x18).push_back(obj->handle(+0xa0)->id(+4)); }`. The store to the latch is unconditional on the lookup's result",
"status": "verified",
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
},
{
"name": "SVSORefugees_OnCombatDone",
"addr": "0x00511310",
"convention": "thiscall",
"prototype": "void (Game::SVSORefugees* this) // vtable slot +0x34, event 8, sent from OnAllCombatDone_Tail phase 8. Walks StrategyServer+0x44 (Systems) and drains the object vector at this+0x28..0x2c, destroying what it resolves. It does NOT write dids -- that is SVSORefugees_OnTurnBegin",
"status": "verified",
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
},
{
"name": "SVSOSwarmQueen_OnTurnBegin",
"addr": "0x00529930",
"convention": "thiscall",
"prototype": "void (Game::SVSOSwarmQueen* this) // vtable slot +0x60, event 0x13. Runs SVSOSwarmQueen_RegisterHives, then prunes hives whose system's EggScio (+0x184) no longer equals this->scenarioTag (+0x4), then SVSOSwarmQueen_TickHives and 0x00505100",
"status": "verified",
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
},
{
"name": "SVSOSwarmQueen_RegisterHives",
"addr": "0x00527630",
"convention": "thiscall",
"prototype": "void (Game::SVSOSwarmQueen* this) // for every system in StrategyServer+0x44 whose EggScio (+0x184) equals this->scenarioTag (+0x4, which the ctor sets to 3 -- the SWARM's tag, not the queen's own EncID 10), and that no hive already references, appends a HiveInfo {vptr 0x009f1a68, sys, nextQ, queen=0} to the vector at this+0x10..0x14, stride 0x10. nextQ = frame + *(int*)[0x00ae0204] + RNG_NextInt (0x004271c0, already in addresses.json; INCLUSIVE of its bound) over (*(int*)[0x00ae0208] - *(int*)[0x00ae0204]) -- ONE strategic-generator draw per new hive, taken inside BeginProcessTurn and therefore OUTSIDE both turn drivers",
"status": "verified",
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
},
{
"name": "SVSOSwarmQueen_TickHives",
"addr": "0x00527770",
"convention": "thiscall",
"prototype": "void (Game::SVSOSwarmQueen* this) // per hive with queen (+0xc) == 0: if any spawn gate fails, `inc [hive+8]` -- the target turn SLIPS FORWARD BY ONE, which is why NextQ reads 31 after turn 1 and 32 after turn 2. Otherwise, if nextQ <= frame, spawn a queen (0x0050dfc0 then 0x004fe810) and append to the Queens vector at this+0x20. Gates read config pointers at 0x00ae0210, 0x00ae0228 and 0x00ae0220",
"status": "verified",
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
},
{
"name": "SVSOSwarmQueen_OnTurnEnd",
"addr": "0x005275d0",
"convention": "thiscall",
"prototype": "void (Game::SVSOSwarmQueen* this) // vtable slot +0x64, event 0x14, sent from OnAllCombatDone_Tail phase 20. The same hive prune as the turn-begin handler, then 0x00521150. It does NOT register hives",
"status": "verified",
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
},
{
"name": "SVSOSwarmQueen_HiveInfo_Write",
"addr": "0x004fe730",
"convention": "thiscall",
"prototype": "void (Game::SVSOSwarmQueen::HiveInfo* this, Mars::IStream* s) // vftable 0x009f1a68 slot 2. Fixes the element layout: +0x4 -> HiveID (handle), +0x8 -> NextQ (int), +0xc -> QueenID (handle). The wire order is NOT the member order",
"status": "verified",
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
},
{
"name": "SVSOSwarmQueen_Ctor",
"addr": "0x0051ae20",
"convention": "cdecl",
"prototype": "Game::SVSOSwarmQueen* () // operator new(0x40); vftable 0x009f49e4; and the two ids that settle who the queen works for: [+0x4] = 3 (the scenario tag it selects systems by) and [+0x8] = 10 (its own EncID)",
"status": "verified",
"source": "findings/objects/svsctob-writers.md (lane SV 2026-09-08)"
}
]
}