341 lines
18 KiB
Markdown
341 lines
18 KiB
Markdown
# 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.
|