# `SvSctOb`: the script-object tree, and the two factories that key it Lane W, 2026-09-08. Host/static only — VM140 was held by lane U and the game was never run. `Game::StrategyServer` writes one member tagged `SvSctOb`, typed `Mars::StreamableHelper` — a **polymorphic pointer**. In every save we hold it points at a `Game::SVSOSots`, whose serializer (`Write` 0x0059ddf0, `Read` 0x005a7a40, vftable 0x00A063C4) emits, in disk order: ``` ScnID int ScnObj StreamableHelper -- conditional; NULL, so absent, in all four saves numx int count x numx { xscn string -- a scenario NAME xsc StreamableHelper } NEncObjs int count x NEncObjs { EncID int -- an encounter ID EncObj StreamableHelper } ``` Both `xsc` and `EncObj` are polymorphic and **dispatched by the key item that immediately precedes them**. Neither map is anywhere on the wire: a reader that does not know them can only carry the bodies as opaque blobs, which is exactly what `sots-engine` was doing (147 items on turn1). Both maps were read out of the game. ## 1. `EncID` → class: a 23-entry jump table `Game::SVSOSots::Read` calls a get-or-create helper at **0x005a4450** (it scans the already loaded `(id, obj)` pair vector at `this+0x0C..0x10` and only on a miss calls the real factory). The factory is **0x0052bf00**: ``` 0052bf00 push ebp; mov ebp,esp; mov eax,[ebp+8] 0052bf06 dec eax 0052bf07 cmp eax,0x16 0052bf0a ja 0052bf5b ; -> xor eax,eax; ret (NULL) 0052bf0c jmp dword ptr [eax*4 + 0x0052bf60] ``` so the encoding is a **dword jump table at 0x0052bf60, 23 entries, indexed by `EncID - 1`**. Each thunk runs a ctor, and the class name comes from the `mov dword ptr [obj], ` store in that ctor — a demangled-RTTI name, not a guess. | EncID | ctor | class | in our saves | |---|---|---|---| | 1 | 0x0052bb90 → 0x0052b790 | `Game::SVSOVonNeumann` | yes | | 3 | 0x0051aba0 → 0x0051a870 | `Game::SVSOSwarm` | yes | | 4 | 0x0051a270 | `Game::SVSODerelict` | yes | | 5 | 0x0052ae10 | `Game::SVSOMonitor` | yes | | 7 | 0x0050e0f0 | `Game::SVSOSystemKiller` | no | | 8 | 0x0050de50 | `Game::SVSOPuppetMaster` | no | | 9 | 0x0051a820 | `Game::SVSOSlaversRefuel` | yes | | 10 | 0x0051ae20 | `Game::SVSOSwarmQueen` | yes | | 14 | 0x0052a030 → 0x00529080 | `Game::SVSOLocust` | no | | 17 | 0x00524190 | `Game::SVSOCrowRuins` | yes | | 20 | 0x005295a0 → 0x005293f0 | `Game::SVSORefugees` | yes | | 21 | 0x004f5210 → 0x004f5170 | `Game::SVSOOrtgay` | no | Ids 2, 6, 11–13, 15, 16, 18, 19, 22, 23 are dead slots that return NULL; so is anything outside 1..23 (the `ja`). **There is no named enum** — the ids are bare integers and the jump table is the only encoding of the map anywhere in the binary. The eight ids the saves carry were independently identified by tag-sequence match against the recovered serializers *before* the table was read, and the two agree exactly on all eight. ## 2. `xscn` → class: a four-way `_stricmp` chain `0x005a7050`, falling through to `"Error creating extra script %s.\n"`. Flat if-chain, no table, no registration list, and **exhaustive** — there are four scenario names in the whole game. | `xscn` | test at | ctor | class | |---|---|---|---| | `traps` | 0x005a7085 | 0x0052cbb0 → 0x0052c940 | `Game::SVSOTraps` | | `crowdefs` | 0x005a70a4 | 0x0052b3c0 | `Game::SVSOCrowDefenders` | | `indsys` | 0x005a70be | 0x0075b530 | `Game::SVSOIndependentSystems` | | `gmtrigger` | 0x005a70d8 | 0x004f5630 | `Game::SVSOGrandMenaceTrigger` | Corroborated by the new-game seeder at 0x005a7d70, which registers exactly `traps`, `crowdefs`, `indsys`, and — only when `ScnObj == NULL` — `gmtrigger`. That is the four-entry list our saves carry, in that order. ### `indsys` serializes nothing, and that is a fact, not an absence of evidence `Game::SVSOIndependentSystems` has no entry in `objects/layouts.json`, which on its own only says the recovery never found a serializer. Its vftable is **0x00A20314** (from the `C7 06 14 03 A2 00` store at 0x0075b585). Comparing slots against `Game::SVSOCrowDefenders`'s vftable at 0x009F5904 (slot0 dtor, slot1 `Read`, slot2 `Write` — both of which match `layouts.json`): ``` slot0 dtor = 0x0075afb0 slot1 Read = 0x005f8ac0 slot2 Write = 0x005f8ac0 ``` and 0x005f8ac0 is `C2 04 00` — a bare `ret 4` stub shared across the binary. So the class uses the inherited no-op for both directions and the `indsys` frame is **genuinely zero-length on disk**. Its 0x1c4-byte body (two 7-element arrays of 0x20-byte records) is runtime state rebuilt each session. The empty frame in the saves is the correct output, not a truncation. ## 3. Two loop-nesting questions the bytes could not answer Both counts involved are 0 in every save, so only the binary settles them. * **`Game::SVSOCrowDefenders::Write` (0x004f8c90) — the recovery is wrong about `dsys`.** Tag strings at .rdata 0x009f0270. Emission order: `sys`; `ndsys` count then a **loop writing `dsys`**; `ndes` count then a loop writing `des`; `drad`. `objects/layouts.json` records `dsys` as a plain member. It is a loop element. `des` as an `ndes` element was already right. * **`Game::SVSODerelict::Write` (0x004fc2b0) — the recovery is right.** `NDsn` count then a loop of `(DsnID, Dwght)`; `NAsg` count then a loop of `(Eflt, Esys)`. Two fields per iteration in each, confirmed by the 8-byte element strides. `objects/streams.json` still carries the `dsys` misclassification. It was left alone rather than special-cased into `serializers.py`'s loop heuristic: the conformance check aligns by tag and disk primitive, so the engine's corrected shape and the uncorrected table still agree item for item, and the correction is recorded here and in `shapes.h`. A future `serializers.py` improvement should pick it up. ## 4. A third trap resolved: `Read` accepts tags `Write` never emits `Game::SVSOSots::Read` (0x005a7a40) reads `NPCPlr` (int, first) and `hastraps` (bool) plus a following `traps` object. `Write` emits none of them. They are read-only backward compatibility for an older save format; a writer that omits them is correct, and ours does. Worth generalising: **a tag in `Read` with no counterpart in `Write` is not a hole in the recovery.** ## 5. What the engine does with it `sots-engine` `wip/wire` types the whole tree: `ScriptObjects` (= `Game::SVSOSots`), the four scenario bodies, and the eight encounter bodies the saves exercise. The key is applied in both directions — the reader `select()`s from the `xscn`/`EncID` it just read, the writer from the one it is about to write — so a body round-trips as whatever it came in as. The four ids with a factory entry but no occurrence in any save (**7 SystemKiller, 8 PuppetMaster, 14 Locust, 21 Ortgay**) are deliberately **not** modelled. Their serializers are recovered and shapes for them would probably be right, but nothing could check them; they fall to a generic `Node` and round-trip verbatim. Same for `ScnObj`, whose pointer is NULL in every save. `SvSctOb` went from 147 / 156 / 156 / 126 opaque items to **0 on all four saves**, with the byte-identical round trip preserved. All 18 new shapes bind clean against the generated wire schema: 0 mismatches, 0 wire-only, 0 shape-only. ## 6. Addresses `ghidra/addresses.d/lane-w.json` — 13 entries (both factories, the jump table, the get-or-create helper, the seeder, `SVSOSots` Read/Write/vftable, the `ret 4` stub, the `SVSOIndependentSystems` vftable, and the three `Write`s the nesting answers came from). `gen_addresses.py` merges to 640 entries with no duplicate name.