sots-re/ghidra/addresses.d/as.json
alex 4a212e25b4 AS: the spy detection roll fires -- 1 word, gated on a usable asteroid belt; spies2 closed
Mars::RNG::Chance at 0x00887c8a, in the BODY of ServerSpyManager::vslot13 (tail
phase 23 call 9), measured at 1 call / 1 word / no_draw_calls=0 / strategic=true
on VM144 at probes=8, with OnAllCombatDone_Tail going 363 -> 364 against 0 on
every turn any lane has measured. The entry probe on SpyManager::Slot13RngCallee
read 0 on the same turn -- that zero was never about the subtree.

Predicate, decoded to save fields: spy.deat != 0 && spy.sdet == -1 &&
sys(deat).ARes2 > 0 && (sys(deat).TerrFl & 1). Corpus count 0 of 22; the failed
conjunct is deat != 0, not the belt -- all 22 corpus saves already had one.

Also: ServerSystem::AddSpy 0x007514c0 writes deat and pushes the spy id into
ServerSystem+0x1cc in the same two instructions, so spies2 is the per-system
DEPLOYED-spy vector; measured non-empty for the first time in the corpus.
And 0x0078c97f, a strategic NextFloat in the SHIPACTION_DEPLOYSPY handler, is in
no existing inventory -- decoded, not measured.
2026-09-09 00:10:49 -04:00

61 lines
7.1 KiB
JSON

{
"_note": "Lane AS (2026-09-09). The spy-deployment and detection-roll chain, read from the instruction stream of dumps/sots.exe (disassembled to the NEXT FUNCTION START, rule 17 -- Ghidra's reported ends are not used anywhere below). Every entry marked `verified` was read instruction by instruction and its operand offsets cross-checked against objects/layouts.md for Game::ServerSystem; entries marked `mapped` were reached but not read. Two of these -- ServerSystem_AddSpy and ServerSystem_RemoveSpy -- also settle the campaign's long-standing `spies2` rule-6 flag: they are the only writers of both SpyCraft+0x10 (`deat`) and ServerSystem+0x1cc (`spies2`), and they write them together. Evidence: findings/subsystems/spy-detection-roll.md. NOT minted here because another fragment already has them: ServerSystem_GetOwner 0x007437e0 (lane B5) and ServerSystem_IsIndependent 0x00743fa0 (lane AG), both of which this chain calls.",
"entries": [
{
"name": "ServerSystem_AddSpy",
"addr": "0x007514c0",
"convention": "__thiscall",
"prototype": "void (Game::ServerSystem* this, Game::SpyCraft* spy) /* 48 B, complete: `if (!spy) return; spy->deat(+0x10) = this ? this->[+4] : 0; push_back(&this->spies2(+0x1cc), &spy->sid(+0x4))` via lane AI3's 0x0059f1a0. THE ONLY WRITER THAT EVER MAKES `deat` NON-ZERO AT RUNTIME (the other stores to SpyCraft+0x10 are the zero-init in CreateSpyCraft, the deserializer, and three field copies). Exactly one caller, 0x008874d4 inside ServerSpyManager_DeploySpy, and no vtable slot. THIS SETTLES `spies2`: ServerSystem+0x1cc is the per-system vector of DEPLOYED spy ids, written in the same two instructions as `deat`, which is why it is 0 in every save whose only spy is docked to its tender */",
"status": "verified",
"source": "findings/subsystems/spy-detection-roll.md (lane AS 2026-09-09)"
},
{
"name": "ServerSystem_RemoveSpy",
"addr": "0x0074f550",
"convention": "__thiscall",
"prototype": "void (Game::ServerSystem* this, Game::SpyCraft* spy) /* 48 B, complete: `if (!spy) return; spy->deat(+0x10) = 0; erase(&this->spies2(+0x1cc), &spy->sid(+0x4))` via 0x0059ec00. The exact mirror of ServerSystem_AddSpy; the two together are the whole life cycle of `deat` and of `spies2` */",
"status": "verified",
"source": "findings/subsystems/spy-detection-roll.md (lane AS 2026-09-09)"
},
{
"name": "ServerSpyManager_DeploySpy",
"addr": "0x00887410",
"convention": "__thiscall",
"prototype": "bool (Game::ServerSpyManager* this, int spyHandle, float cbh) /* Game::ServerSpyManager vftable 0x00a3073c SLOT 7; zero direct call sites -- reached only from the SHIPACTION_DEPLOYSPY handler 0x0078c930 through [[dispatcher+0x15c]+0x1c]. Read at the field writes: spy->Reset() (0x00838070), then sdo(+0x3c) := 0.0f, sdet(+0x40) := -1, cbh(+0x18) := arg, tdep(+0x14) := server->[+0xc] (the current turn), atto(+0xc) := 0 and carrierShip->[+0xa8] := 0 (detach), then ServerSystem_AddSpy(system, spy) at 0x008874d4, then a player message built from system->Name (+0xa8, capacity at +0xbc -- which is what proves the receiver is a ServerSystem). CONSEQUENCE FOR THE TAIL: a spy is deployed with sdet == -1 and sdo == 0, so ServerSpyManager_vslot13's detection branch is entered on the very next OnAllCombatDone_Tail */",
"status": "verified",
"source": "findings/subsystems/spy-detection-roll.md (lane AS 2026-09-09)"
},
{
"name": "ServerSystem_BeltUsableFlags",
"addr": "0x00743f80",
"convention": "__thiscall",
"prototype": "int (Game::ServerSystem* this) /* 16 B, complete: `eax = this->TerrFl(+0x19c); if (this->ARes2(+0x6c) <= 0) eax &= ~1; return eax`. BIT 0 OF THE RETURN IS \"this system still has a usable asteroid belt\" -- TerrFl bit 0 masked by the belt's remaining resources. Corroborated from the save side without a debugger: ARes2 > 0 and TerrFl & 1 agree on all 616 system records of the 22-save corpus and on all 28 of the state this lane built, where TerrFl also takes the values 2 and 3, so bit 1 is a second, independent terrain feature. Called at 0x00887c71 (the detection roll's gate) and at 0x0083d15d inside the shared placement validator 0x0083ce50, where a clear bit 0 sets refusal bit 0x1000000 */",
"status": "verified",
"source": "findings/subsystems/spy-detection-roll.md (lane AS 2026-09-09)"
},
{
"name": "SpyCraft_AccumulateDetectionOdds",
"addr": "0x0081f570",
"convention": "__stdcall",
"prototype": "void (Game::SpyCraft* spy, Game::ServerSystem* sys, Game::ServerPlayer* spyOwner) /* 512 B, `ret 0xc`. Writes spy->sdo (+0x3c) and is called unconditionally at 0x00887c66, immediately before the detection roll's gate. EARLY OUT: if sys->PID == 0 (unowned) it stores 0.0f and returns, so a spy at an unowned system rolls at p = 0 and costs no word. Otherwise it walks the system's fleet vector accumulating three counters and computes, with every literal read as the four bytes in the image (rule 23): p = 0.01f + (cA>0 ? 0.01f : 0) + (cB>0 ? 0.02f : 0) + 0.001*cC, then p *= (spyOwner->[+0x5c]==5 ? 0.75f : spyOwner->[+0x5c]==6 ? 0.5f : 1.0f), then p *= 0.7, then sdo = min(sdo + p, 1.0) -- IT ACCUMULATES onto the old value and is CLAMPED AT 1.0. So a quiet foreign target gives sdo = 0.007 on the first turn and ~0.007/turn after, and a spy left in place long enough reaches p >= 1, at which point RNG_Chance costs 0 words and always succeeds */",
"status": "verified",
"source": "findings/subsystems/spy-detection-roll.md (lane AS 2026-09-09)"
},
{
"name": "ServerSpyManager_DetectionRoll_DrawSite",
"addr": "0x00887c8a",
"convention": "site",
"prototype": "/* THE SPY DETECTION ROLL. A plain E8 rel32 `call Mars_RNG_Chance 0x008e6dd0` in the BODY of ServerSpyManager_vslot13 0x008877b0 -- NOT an inlined draw (the campaign carried `inline` for a day; the draw is a normal entry-point call, it simply sits in the caller rather than in SpyManager_Slot13RngCallee 0x008408e0, which is why an entry probe on that callee reads zero either way). Generator: `mov ecx,[server+0x16c]` at 0x00887c80 -- the STRATEGIC generator. Probability: `fld [spy+0x3c]` -- sdo. Return address for lane Z's draw_sites ledger: 0x00887c8f. Gates, in order: spy.deat != 0, spy.sdet == -1, and ServerSystem_BeltUsableFlags(sys(deat)) & 1. WHEN THAT LAST BIT IS CLEAR THE ROLL IS SKIPPED AND THE SPY IS DETECTED UNCONDITIONALLY (`je 0x887c97`, which stores sdet := turn) -- a zero here is therefore not always a negative */",
"status": "verified",
"source": "findings/subsystems/spy-detection-roll.md (lane AS 2026-09-09)"
},
{
"name": "SpyCraft_ResetMission",
"addr": "0x00838070",
"convention": "__thiscall",
"prototype": "void (Game::SpyCraft* this) /* Called by ServerSpyManager_DeploySpy at 0x0088749e and by ServerSpyManager_vslot14 at 0x0088dc11 when the target system changes hands. NOT READ: only its position in those two chains and the fields its callers write immediately afterwards (sdo := 0, sdet := -1) are established here */",
"status": "mapped",
"source": "findings/subsystems/spy-detection-roll.md (lane AS 2026-09-09)"
}
]
}