lane K: field offsets belong in the offset schema, and off_RNG was already right

gen_addresses.py distinguishes "addr" (VA -> RVA) from "offset" (a member
offset). Three lane-K entries used "addr" for offsets and generated negative
RVAs, which broke the engine build. Two are re-expressed as offsets; the third,
StrategyServer_off_RNGPtr_S_frame, is withdrawn - addresses.json already has
StrategyServer_off_RNG = 0x16c for the S frame alongside off_RNGPtr = 0x168 for
S+4, which is precisely lane T's rule. The second call site found this lane
confirms it rather than correcting it.
This commit is contained in:
alex 2026-09-08 08:48:25 -04:00
parent 117d641d74
commit bb0f990de9
3 changed files with 15 additions and 22 deletions

View file

@ -282,14 +282,17 @@ targeting it.
*Source: the `Chance(0.5f)` call site and its operand are instruction-verified; the surrounding record layout
and the downstream event names are from a ReVa sweep.*
### 3.1 The RNG pointer is `S+0x16c`
### 3.1 The RNG pointer is `S+0x16c`, and lane T's frame note holds
Two independent instruction reads agree: `ServerPlayer::ProcessTurn` 0x0089147f
(`eax=[esi+8]; eax-=4; rng=[eax+0x16c]`, and `ServerPlayer+0x8 == S+4`, so `eax == S`) and this call site
(`mov ecx,[esi+0x16c]`, `esi == S`). **`S+0x16c`**, i.e. `(S+4)+0x168`, which is exactly what
`addresses.json`'s `StrategyServer_off_RNGPtr = 0x168` says once the frame is applied. `off_RNG` is *not* an
exception to the `S+4` rule; lane T's §0 note that it is should be read as "the value there is entered at
`+4` by `NextFloat`", which is a different thing.
(`mov ecx,[esi+0x16c]`, `esi == S`). So the pointer is at **`S+0x16c`**.
`addresses.json` already carries both frames of the same field — `StrategyServer_off_RNG = 0x16c` (the `S`
frame) and `StrategyServer_off_RNGPtr = 0x168` (the `S+4` frame) — which is exactly lane T's §0 rule: every
`StrategyServer_off_*` is `S+4` **except `off_RNG`**. That is confirmed here from a second, independent call
site, not corrected. No new entry was added; a lane-K draft that duplicated `off_RNG` was withdrawn before
publication.
---

View file

@ -24,14 +24,6 @@
"status": "verified",
"source": "findings/control-flow/combat-done-tail.md §3 (lane K 2026-09-08)"
},
{
"name": "StrategyServer_off_RNGPtr_S_frame",
"addr": "0x0000016c",
"convention": "offset",
"prototype": "StrategyServer+0x16c in the S frame holds the Mars::RNG object pointer. Two independent instruction reads agree: ServerPlayer::ProcessTurn 0x0089147f (`eax=[esi+8]; eax-=4; rng=[eax+0x16c]`, and ServerPlayer+0x8 == S+4, so eax == S) and FUN_007ae010 0x007ae095 (`mov ecx,[esi+0x16c]` with esi == S). This equals (S+4)+0x168, i.e. exactly what StrategyServer_off_RNGPtr = 0x168 says once the S+4 frame is applied. off_RNG is NOT an exception to the S+4 rule -- turn-driver.md §0 should be read as saying that NextFloat enters the generator OBJECT at +4, which is a different thing",
"status": "verified",
"source": "findings/control-flow/combat-done-tail.md §3.1 (lane K 2026-09-08)"
},
{
"name": "StrategyHost_Autosave",
"addr": "0x00895210",
@ -90,19 +82,19 @@
},
{
"name": "StrategyServer_off_TurnResultsPending",
"addr": "0x000002f4",
"convention": "offset",
"prototype": "StrategyServer+0x2f4 (S frame) = std::vector<Game::SETurnResults> ACCUMULATOR, stride 0x11c, one record per player indexed by PlyrIdx. Written during the turn by (at least) ApplyEncounterResult 0x007d8f9e, FUN_007ae010 0x007ae286/0x007ae3ce, FUN_007a4ff0 0x007a516f, FUN_007a4700, FUN_007b9df0, ProcessAid (3 sites) and ApplyEncounterResults itself -- found by a whole-image scan for `imul r32,r32,0x11c` / `add r32,0x11c` at real instruction boundaries. ApplyEncounterResults' tail (0x007d4fa0-0x007d505f) destroys S+0x304, SWAPS the two vector headers so this turn's accumulation becomes the outbox, then resize(0)+resize(nPlayers) here for the next turn",
"status": "verified",
"source": "findings/control-flow/combat-done-tail.md §5A (lane K 2026-09-08)"
"source": "findings/control-flow/combat-done-tail.md §5A (lane K 2026-09-08)",
"offset": "0x2f4"
},
{
"name": "StrategyServer_off_TurnResultsOutbox",
"addr": "0x00000304",
"convention": "offset",
"prototype": "StrategyServer+0x304 (S frame) = std::vector<Game::SETurnResults> OUTBOX, stride 0x11c, filled by the swap in ApplyEncounterResults' tail. Read by SynchronizePlayer 0x007c865f: `if (size() == Players.size()) { r = base + i*0x11c; r->+0x20 = S->+0x1fc; OnEventCallback(netId, 0x25, r); r->+0x20 = 0; }`. Cleared by GenerateTurnEvents' first statement. SETurnResults is strategy-event id 0x25, unicast per player, and is NOT serialized -- its vtable 0x00a24b00 has no Read/Write pair and it appears in no save schema",
"status": "verified",
"source": "findings/control-flow/combat-done-tail.md §5A (lane K 2026-09-08)"
"source": "findings/control-flow/combat-done-tail.md §5A (lane K 2026-09-08)",
"offset": "0x304"
},
{
"name": "SETurnResults_ctor",

View file

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ bb1f8b4, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ 117d641, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once
#include <cstdint>
@ -1301,8 +1301,6 @@ constexpr uint32_t StrategyServer_OnAllCombatDone_Tail = 0x003d92a0;
constexpr uint32_t StrategyServer_OnAllCombatDone_Tail_ClearEncounters = 0x003d9690;
// site site in FUN_007ae010 (phase 11 of OnAllCombatDone_Tail): `mov ecx,[esi+0x16c]; fld dword [0x009e2ea0] /*0.5f*/; push ecx; fstp [esp]; call 0x008e6dd0` = Mars::RNG::Chance(0.5f) on the strategic generator at S+0x16c. Chance early-outs WITHOUT a draw at p<=0 and p>=1 but takes neither at 0.5f, so this is EXACTLY ONE NextFloat PER EXPIRED NODE LINE PER TURN. State-dependent draw count, in the combat-done tail, BEFORE the autosave. Every RNG account in the repo assumes the strategic generator advances only inside StrategyServer::ProcessTurn; it also advances here, and again inside the combat resolver FUN_007d5af0 (RNG_NextInt on the node-cannon path, RNG_Twist + RNG_NextInt on the salvage path) [verified]
constexpr uint32_t StrategyServer_OnAllCombatDone_Tail_NodeDecayRoll = 0x003ae095;
// offset StrategyServer+0x16c in the S frame holds the Mars::RNG object pointer. Two independent instruction reads agree: ServerPlayer::ProcessTurn 0x0089147f (`eax=[esi+8]; eax-=4; rng=[eax+0x16c]`, and ServerPlayer+0x8 == S+4, so eax == S) and FUN_007ae010 0x007ae095 (`mov ecx,[esi+0x16c]` with esi == S). This equals (S+4)+0x168, i.e. exactly what StrategyServer_off_RNGPtr = 0x168 says once the S+4 frame is applied. off_RNG is NOT an exception to the S+4 rule -- turn-driver.md §0 should be read as saying that NextFloat enters the generator OBJECT at +4, which is a different thing [verified]
constexpr uint32_t StrategyServer_off_RNGPtr_S_frame = 0x-03ffe94;
// thiscall void (StrategyHost* this /*the global at 0x00b29f98*/, std::string* outName, bool endTurn) // THE AUTOSAVE. Exactly two call sites: SendEndTurn 0x007839d7 with endTurn=1 -> (Autosave EndTurn).sav, the PRE-turn state; StrategyHost::OnMessage 0x00784e59 with endTurn=0 -> (Autosave).sav, the POST-turn state. Body: null-check this->+0x4 (the strat game) -> log "Can't autosave- Strat game doesn't exist."; build FOUR paths as _snprintf(buf,0x3ff,"%s/%s.%s", dir, name, ext) with dir=FUN_007a05a0(game) ("SavedGames") and ext=FUN_007a0620(game) ("sav") and the four localized names registered at 0x009bed00..0x009bed7f (SOTS_GAME_AUTOSAVE @0xaf092c, _AUTOSAVEBACKUP @0xaf0934, _ENDTURN_AUTOSAVE @0xaf093c, _ENDTURN_AUTOSAVEBACKUP @0xaf0944); if (!IsSinglePlayerHost()) remove both ENDTURN files; pick (cur,bak) by endTurn; mkdir(dir); ROTATE remove(bak)+rename(cur,bak) ONLY WHEN endTurn==0 (the flag byte at [ebp-0x14a1] is set to 1 and the je at 0x00895266 SKIPS the store of 0 when the arg is zero); gate on (this->flags & 4) && this->+0x4; DETACH each player's connection at pl->+0x12c via conn->vft[0x14] and reattach via conn->vft[0x18] after; call SaveGame_WriteFile(this->+0x4, curPath, 1, &agentNames) at 0x0089595d [verified]
constexpr uint32_t StrategyHost_Autosave = 0x00495210;
// cdecl bool () // whole 27-byte body: `g = *(void**)0x00b2d540; net = g->+0x148; return net != 0 && net->+0x4 == 0;`. Gates SendEndTurn's pre-turn autosave and the AI-agent sidecar branch inside StrategyHost::Autosave; its NEGATION gates the deletion of the ENDTURN autosave pair (so the pre-turn autosave is a single-player-only feature) [verified]
@ -1318,9 +1316,9 @@ constexpr uint32_t StrategyServer_GenerateTurnEvents = 0x003dc640;
// note MISNAMED. BuildTurnEvents 0x007db780 is NOT a per-turn turn-event builder: it is the FULL-STATE RESYNC PUSH for setup / load / rejoin. Its entire 3701-byte body is under `if (this->+0x12c != 0)`, and both that descriptor and bit 2 of +0x128 are set in exactly one place in the image -- FUN_007bd1b0 at 0x007bd204/0x007bd23a. It references NO EVENT_* string at all; its only string immediates are "vector<T> too long" and "StrategyServer: OnEvent() called, but no callback function specified." It sends SEResetMap (0x29), SEAddPlayer (0x01), SEInitTrade (0x2a), SETurnEvents (0x28), SESyncDesign (0x19) and calls SynchronizePlayer. It CALLS FUN_0081b390 (the previous-turn snapshot) at 0x007dbc7c to ESTABLISH the baseline and never diffs against it. findings/control-flow/turn-spine.md reads as if this were a per-turn diff step -- it is not [verified]
constexpr uint32_t StrategyServer_BuildTurnEvents_isResync = 0x003db780;
// offset StrategyServer+0x2f4 (S frame) = std::vector<Game::SETurnResults> ACCUMULATOR, stride 0x11c, one record per player indexed by PlyrIdx. Written during the turn by (at least) ApplyEncounterResult 0x007d8f9e, FUN_007ae010 0x007ae286/0x007ae3ce, FUN_007a4ff0 0x007a516f, FUN_007a4700, FUN_007b9df0, ProcessAid (3 sites) and ApplyEncounterResults itself -- found by a whole-image scan for `imul r32,r32,0x11c` / `add r32,0x11c` at real instruction boundaries. ApplyEncounterResults' tail (0x007d4fa0-0x007d505f) destroys S+0x304, SWAPS the two vector headers so this turn's accumulation becomes the outbox, then resize(0)+resize(nPlayers) here for the next turn [verified]
constexpr uint32_t StrategyServer_off_TurnResultsPending = 0x-03ffd0c;
constexpr uint32_t StrategyServer_off_TurnResultsPending = 0x000002f4;
// offset StrategyServer+0x304 (S frame) = std::vector<Game::SETurnResults> OUTBOX, stride 0x11c, filled by the swap in ApplyEncounterResults' tail. Read by SynchronizePlayer 0x007c865f: `if (size() == Players.size()) { r = base + i*0x11c; r->+0x20 = S->+0x1fc; OnEventCallback(netId, 0x25, r); r->+0x20 = 0; }`. Cleared by GenerateTurnEvents' first statement. SETurnResults is strategy-event id 0x25, unicast per player, and is NOT serialized -- its vtable 0x00a24b00 has no Read/Write pair and it appears in no save schema [verified]
constexpr uint32_t StrategyServer_off_TurnResultsOutbox = 0x-03ffcfc;
constexpr uint32_t StrategyServer_off_TurnResultsOutbox = 0x00000304;
// thiscall void (SETurnResults* this) // the DEFAULT CONSTRUCTOR of Game::SETurnResults (Ghidra calls it Create; it is not a factory). sizeof == 0x11c, enumerated five ways: the 0x11c stride and its reciprocal 0xe6c2b449/sar 8 in vector<SETurnResults>::resize 0x007cd2a0, the `add esi,0x11c` in _Ufill 0x007c5850, the accessor 0x00788cb0 (base[PlyrIdx*0x11c]), the operator new[] in 0x0078b0c0, and this ctor closing at +0x118 (the _Alval of a vector member at +0x10c). Layout: +0x00 vptr; +0x04 bool; +0x08 EMBEDDED Game::EventStorage::TurnEvents (vptr +0x08, int EvTurn +0x0c, vector<Event> +0x10/+0x14/+0x18, _Alval +0x1c); +0x20 int stamped by SynchronizePlayer from S+0x1fc and cleared after; +0x24 vector<ClientEncounterResults> (what ApplyEncounterResult publishes into); +0x34 byte with two bit-flags; strings at +0x38/+0x54/+0xa4/+0xc0/+0xdc; list at +0x70; vectors at +0x80/+0x90/+0xfc/+0x10c; two bools at +0xa0/+0xa1; int at +0xf8 [verified]
constexpr uint32_t SETurnResults_ctor = 0x003a7ae0;
// thiscall void (std::vector<SETurnResults>* this, int n) // MISNAMED as DispatchTurnResults: it dispatches nothing. std::vector<Game::SETurnResults>::resize(n) -- shrink to _Erase 0x007c5610, grow to _Reserve 0x007cb340 + _Ufill 0x007c5850. Likewise 0x007c5850 ("SendTurnResultsToPlayers") is _Ufill: per element default-construct a stack temp with 0x007a7ae0, copy-construct into the destination with 0x007c24d0, destroy the temp with 0x0079ac10, dest += 0x11c. The ONLY send of an SETurnResults in the image is SynchronizePlayer 0x007c86d1 (push 0x25) [verified]