Closes lane K's #1 ranked gap: FUN_007d5af0 under phase 6 of OnAllCombatDone_Tail, the second RNG source in the strategic tail. RNG inventory (the deliverable that matters). The resolver draws nothing itself. Three sites in its subtree, each behind a function with exactly one caller: R1 RNG_NextInt @0x007bb69b (node cannon) 1 word w.p. 3/4, mean 4/3 R2 an INLINED RNG_NextFloat @0x007a84bd 1 word per back-eng candidate R3 RNG_NextInt @0x00852ec7 (project pick) >=1 word per successful roll R2 corrects combat-done-tail.md 3, which reported no NextFloat in the subtree: the draw is inlined, so the only call-graph edge it leaves is FUN_007a7f30 -> RNG_Twist, which reads as a bare Twist and is not one. An image-wide instruction-boundary scan for the MT tempering immediates finds 14 game functions with inlined draws; two of them, FUN_004f7670 (84 B) and FUN_007aa240, are reachable from StrategyServer::ProcessTurn and are candidate mechanism for part of lane Z's unexplained 18-20 words per turn. Also: the real body is 7641 B, not Ghidra's 7499 (which ends mid-instruction); only 8 non-stack stores and 1 indirect call in the whole function; 23 EVENT_* keys in the closure, five of them new to the campaign's combat picture (plague is decided inside combat); the resolver's subtree DOES write the SETurnResults accumulator at S+0x2f4[PlyrIdx]+0x90 via FUN_007baef0 -> FUN_007b9df0; and sizeof(Game::TacReport) = 0x94, enumerated twice. Two structural errors made and corrected before publication are recorded in 7.3 and 4 rather than quietly fixed. Gates run separately: clean-room check OK; host ctest 36/36.
124 lines
14 KiB
JSON
124 lines
14 KiB
JSON
{
|
|
"entries": [
|
|
{
|
|
"name": "CombatResolver_Run",
|
|
"addr": "0x007d5af0",
|
|
"convention": "thiscall",
|
|
"prototype": "void (CombatResolveContext* this) // PLAIN RET, no stack args. THE COMBAT RESOLVER under phase 6 of StrategyServer::OnAllCombatDone_Tail. Exactly one caller: StrategyServer::ApplyEncounterResult 0x007d8920 at 0x007d8d24, on the full-battle path only (res->+0x4 == 0). REAL BODY IS 0x007d5af0..0x007d78c8 = 7641 B; Ghidra's 7499 stops mid-instruction at 0x007d783b. SHAPE, read from the instruction stream: (1) prologue + 16 unconditional this-calls 0x007d5b1e..0x007d5c02; (2) ONE loop over enc->members, stride 0x44, 0x007d5c30..0x007d779d -- 7021 of the 7641 bytes, with four inner loops and no other outer control flow; (3) five more unconditional this-calls 0x007d77a3..0x007d77cd; (4) a victor block gated on ctx->+0xa34 != -1; (5) FUN_0079c740 and the epilogue. ONLY EIGHT NON-STACK STORES IN THE WHOLE BODY and exactly ONE indirect call (inside a _CxxThrowException path): it composes and posts per-player events and delegates every state mutation to callees. Strings it composes: EVENT_<TYPE>_FIGHT, EVENTSUM_<TYPE>, EVENTMSG_<...>, EVENT_TRADERAIDERS, EVENT_COMBAT_OBSERVED, EVENT_DEFEAT, EVENT_VICTORY, EVENT_ENGAGED, EVENT_STATION_KILLED, and the ENTITYVICTORY/ENTITYDEFEAT/UNRESOLVED outcome tokens. DRAWS NO RNG ITSELF -- see CombatResolve_NodeCannon and CombatResolve_SalvageBackEng",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/combat-resolver.md"
|
|
},
|
|
{
|
|
"name": "CombatResolveContext_Ctor",
|
|
"addr": "0x007b8460",
|
|
"convention": "thiscall",
|
|
"prototype": "CombatResolveContext* (CombatResolveContext* this, StrategyServer* S /*the S frame*/, Encounter* enc, Game::EncounterResults* res) // built as a ~0xea0-byte STACK local at [ebp-0xea0] in StrategyServer::ApplyEncounterResult, immediately before the resolver call. Field assignment read from the instruction stream: this->+0x00 = S; this->+0x04 = operator new(0x5c) then FUN_005a13f0 (a per-player lookup object); this->+0x08 = enc; this->+0x0c = res; this->+0x10 = 0; this->+0x14 = 0 (byte); this->+0x18 = an empty std::string; this->+0x38 = FUN_00536890. Further fields the resolver uses: +0x290/+0x330/+0x430 per-player int arrays indexed by PlyrIdx*4; +0x7b0 + PlyrIdx*0x10 a per-player vector; +0x9b0 + PlyrIdx*4 the posted-event pointer; +0xa34 the winner PlyrIdx (-1 = none); +0xe7c = FUN_00787690(enc), set by the resolver's first act",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/combat-resolver.md"
|
|
},
|
|
{
|
|
"name": "CombatResolve_NodeCannon",
|
|
"addr": "0x007bb530",
|
|
"convention": "thiscall",
|
|
"prototype": "void (CombatResolveContext* this) // RNG SITE 1 OF 3 in the combat resolver. Exactly one caller (the resolver, unconditionally, at 0x007d5be2), so it runs ONCE PER RESOLVED BATTLE. Body: return with NO DRAW if res(ctx+0xc)->+0xa8 == +0xac (the flung-entity vector is empty); else build a candidate destination list = every StarSystem in S->Systems (S+0x44/+0x48) with sys->+0xc5 == 0 and sys != enc->+0xc, sort it by distance from enc->+0x1c..+0x24 (FUN_00796590 with a 16-byte functor), TRUNCATE TO 3 (FUN_00459f70); if the list is non-empty draw EXACTLY ONE RNG_NextInt at 0x007bb69b with ecx = S->+0x16c + 4 and bound n = count-1 passed BY POINTER. NextInt is inclusive on [0,n], so a normal galaxy gives n = 2, mask = 3, and a y&3 == 3 draw is rejected: 1 MT word with p = 3/4, 2 with p = 3/16, mean 4/3 words. Then per flung entity HandleMap::Resolve(S+0x84, h) and FUN_007bb420 groups by (destination, obj->+0x10) into a 0x18-stride vector; the tail posts EVENT_NODECANNON_FLINGS and EVENT_NODECANNON_KILLS with no further draw",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/combat-resolver.md"
|
|
},
|
|
{
|
|
"name": "CombatResolve_SalvageBackEng",
|
|
"addr": "0x007a7f30",
|
|
"convention": "thiscall",
|
|
"prototype": "void (CombatResolveContext* this) // RNG SITES 2 AND 3 in the combat resolver. Exactly one caller (the resolver, unconditionally, at 0x007d77c8, AFTER the per-member loop), so it runs once per resolved battle -- but ITS OWN BODY IS A LOOP OVER THE ENCOUNTER MEMBERS, so the rolls are per combatant. WARNING: Ghidra sizes it 2670 B, ending at 0x007a89be, which CUTS OFF the outer back-edge at 0x007a89ab; the real body ends at 0x007a89cd and a dump that stops at Ghidra's size makes the whole outer loop read as straight-line code operating on members[0]. Structure: (1) 0x007a7f79..0x007a7f88 zero 32 slots of 0x10 at [ebp-0x310], a per-PlyrIdx 3-float salvage stat; (2) 0x007a7fc0..0x007a8071 per member, gate FUN_00787350(player), fill that player's slot via FUN_0078bcf0(ctx, player, slot), sticky flag [ebp-0x39d]; return with ZERO draws if the flag is clear (0x007a807e) or the member vector is empty (0x007a80a6); (3) OUTER LOOP 0x007a80ac..0x007a89ab over members, counter [ebp-0x3c4] -- per member: player = S->Players[m->+0x28], slot = [ebp-0x310] + PlyrIdx*0x10, skip with no draw if all three floats are 0.0 (0x007a8105), RESET the {void* def; float p} candidate vector [ebp-0x3d4]/[ebp-0x3d0]/[ebp-0x3cc] at 0x007a810d, rebuild it in the inner loop 0x007a8150..0x007a8406, then (4) ROLL LOOP 0x007a8452..0x007a8693 -- one INLINED Mars::RNG::NextFloat (see CombatResolve_SalvageBackEng_RollSite) per candidate whose def != 0, whose p > 0.0f, and for which FUN_0078f530(player, def, &out) is true; success is p >= roll (fcom + test ah,1, so equality succeeds); on success it composes \"SPRJ_BACKENG_\" + def->+0x20 and calls SpecialProject_UnlockRandomForPlayer, which draws one more word. No other RNG in the body: no direct call to RNG_NextInt/NextFloat/Chance and no second inlined draw anywhere in the 2782 real bytes",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/combat-resolver.md"
|
|
},
|
|
{
|
|
"name": "CombatResolve_SalvageBackEng_RollSite",
|
|
"addr": "0x007a84bd",
|
|
"convention": "site",
|
|
"prototype": "AN INLINED Mars::RNG::NextFloat, byte-for-byte the body of RNG_NextFloat 0x0047d830: esi = S->+0x16c; if (esi->+0x9c8 /*left*/ == 0) RNG_Twist(esi+4) at 0x007a84cf; y = *esi->+0x9c4 /*next*/, next += 4, left--; temper with 0xff3a58ad and 0xffffdf8c; fild with the +2^32 fixup at 0x009e61b8, multiply by the 1/(2^32-1) double at 0x009e61b0, store as float32. THE CAMPAIGN'S RNG SWEEPS CANNOT SEE THIS: the only call-graph edge it leaves is FUN_007a7f30 -> RNG_Twist, which reads as a bare Twist and is not one. An image-wide instruction-boundary scan for the two tempering immediates finds FOURTEEN game functions with inlined MT draws besides the four RNG primitives: 0x004b1f20 (x4), 0x004f7670, 0x00507ac0 (x12), 0x005232a0, 0x006ec720, 0x006f65f0, 0x006f7890, 0x0079f7d0, 0x007a7f30, 0x007aa240, 0x007c2fa0 (x4), 0x007c4140, 0x008cca30, 0x008e6e30 (x2). Of those, 0x004f7670 and 0x007aa240 are in the direct-call closure of StrategyServer::ProcessTurn and 0x007a7f30 is in the closure of OnAllCombatDone_Tail -- three strategic-turn RNG sources that no call-graph accounting has counted",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/combat-resolver.md"
|
|
},
|
|
{
|
|
"name": "SpecialProject_UnlockRandomForPlayer",
|
|
"addr": "0x007a0540",
|
|
"convention": "thiscall",
|
|
"prototype": "std::string* (StrategyServer* S /*ecx, the S frame*/, std::string* outName, std::string* keyPrefix, ServerPlayer* player) // RET 0xc. If player == 0 it returns an empty string with NO DRAW; otherwise it forwards to SpecialProject_PickRandomAvailable(S->+0x160, outName, keyPrefix, player->PlyrIdx(+0x28), S->+0x16c). Reached only from CombatResolve_SalvageBackEng on a successful back-engineering roll, with keyPrefix = \"SPRJ_BACKENG_\" + the destroyed design's tag",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/combat-resolver.md"
|
|
},
|
|
{
|
|
"name": "SpecialProject_PickRandomAvailable",
|
|
"addr": "0x00852d30",
|
|
"convention": "thiscall",
|
|
"prototype": "std::string* (void* projectMgr /*= StrategyServer+0x160*/, std::string* outName, std::string* keyPrefix, int plyrIdx, Mars::RNG* rng) // RET 0x10. RNG SITE 3 in the combat resolver's subtree. Builds a candidate vector<T*> via FUN_0059ec00 from keyPrefix; IF IT IS EMPTY it returns an empty string with NO DRAW; otherwise draws EXACTLY ONE RNG_NextInt at 0x00852ec7 (ecx = rng+4, bound n = count-1 by pointer, inclusive), marks the chosen element's per-player byte at elem[plyrIdx]++ and returns its name via FUN_008c97a0. Because the bound is inclusive and NextInt rejects on (y & mask) > n, the expected MT-word cost is 2^ceil(log2(count)) / count -- exactly 1 only when count is a power of two",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/combat-resolver.md"
|
|
},
|
|
{
|
|
"name": "CombatPlayerStats_TacReportAt",
|
|
"addr": "0x00456c40",
|
|
"convention": "thiscall",
|
|
"prototype": "Game::TacReport* (Game::CombatPlayerStats* this, int i) // RET 4. return (Game::TacReport*)(this->+0x04 + i * 0x94). Called by the combat resolver's inner loop B at 0x007d6f43",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/combat-resolver.md"
|
|
},
|
|
{
|
|
"name": "CombatPlayerStats_TacReportCount",
|
|
"addr": "0x00456c20",
|
|
"convention": "thiscall",
|
|
"prototype": "int (Game::CombatPlayerStats* this) // plain RET. return (this->+0x08 - this->+0x04) / 0x94, by the 0xdd67c8a7 add-back / sar 7 reciprocal. Called by the combat resolver at 0x007d6f2b and 0x007d702d",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/combat-resolver.md"
|
|
},
|
|
{
|
|
"name": "sizeof_Game_TacReport",
|
|
"convention": "layout",
|
|
"offset": "0x94",
|
|
"prototype": "sizeof(Game::TacReport) -- container stride, ENUMERATED TWICE and independently: the `imul eax,eax,0x94` in CombatPlayerStats_TacReportAt 0x00456c46, and the 0xdd67c8a7 add-back / sar 7 reciprocal divide in CombatPlayerStats_TacReportCount 0x00456c27. NOT sized by what the code touches. FLAG: objects/streams.json gives Game::TacReport 20 fields (two embedded Game::TacReportEvents of 0x20 each plus 18 scalars) which with a vptr accounts for at most 0x8c, so roughly 8 bytes are members the serializer never names -- carried, not named, in the sense of earned-rule 7. Not resolved here",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/combat-resolver.md"
|
|
},
|
|
{
|
|
"name": "Game_CombatPlayerStats_off_TacReports",
|
|
"convention": "layout",
|
|
"offset": "0x04",
|
|
"prototype": "Game::CombatPlayerStats+0x04/+0x08/+0x0c = std::vector<Game::TacReport> (stride 0x94), read off both accessors 0x00456c20 and 0x00456c40. sizeof(Game::CombatPlayerStats) is already verified at 0x24, and its stream schema writes RPBon/RPBonT/SavBonus/MaintHF BEFORE TacReports -- so this vector sits at +0x04, ahead of every scalar the serializer emits first. A live instance of lane Q's rule: OFFSET ORDER IS NOT WRITE ORDER; align against objects/streams.json, never against an offset-sorted view",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/combat-resolver.md"
|
|
},
|
|
{
|
|
"name": "CombatResolve_TurnResultsWriteSite",
|
|
"addr": "0x007ba140",
|
|
"convention": "site",
|
|
"prototype": "site in FUN_007b9df0, reached from the combat resolver as 0x007d5af0 -> FUN_007baef0 -> FUN_007b9df0 (depth 2), on the arm that also posts EVENT_INDSYS_SURRENDERS_COMBAT and writes the winner index. `mov ecx,[esi+0x28]; imul ecx,ecx,0x11c; mov edx,[ebp-0x14]; mov eax,[edx+0x2f4]; lea ecx,[ecx+eax*1+0x90]; call 0x007a6630` -- a push_back of a 0x20-byte record (vptr 0x00a23c54) into the SETurnResults ACCUMULATOR at StrategyServer+0x2f4, member +0x90, indexed by PlyrIdx*0x11c. COMBAT WRITES TURN RESULTS: lane K's combat-done-tail.md 5A attributes the phase-6 write to ApplyEncounterResult 0x007d8f9e at member +0x24; this is a SECOND member written from inside the resolver's subtree. FUN_007b9df0 has seven direct callers (0x007baef0, 0x007bd490, 0x007bd520, 0x007bd930, 0x007be870, 0x007d0580, StrategyServer::ProcessTurn), so it is not only a ProcessTurn-phase-1 function. Not serialized (SETurnResults has no Read/Write pair) but live in memory at autosave time and dispatched to the client as strategy-event 0x25",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/combat-resolver.md"
|
|
},
|
|
{
|
|
"name": "Game_EncounterResults_off_Participants",
|
|
"convention": "layout",
|
|
"offset": "0x98",
|
|
"prototype": "Game::EncounterResults+0x98/+0x9c = a 4-byte-stride vector of participant handle ids. The combat resolver's per-member loop linear-scans it for the member's ServerPlayer->+0x04 at 0x007d5c96 and SKIPS THE WHOLE MEMBER when absent -- so a combatant present in the Encounter but not in this vector contributes nothing to the resolver. LABELLED HYPOTHESIS on the element type: it is a 4-byte scalar and matches the schema's `carr<int>`, but the schema is in write order and no save observed here fixes the offset",
|
|
"status": "hypothesis",
|
|
"source": "findings/control-flow/combat-resolver.md"
|
|
},
|
|
{
|
|
"name": "Game_EncounterResults_off_PlayerStats",
|
|
"convention": "layout",
|
|
"offset": "0x18",
|
|
"prototype": "Game::EncounterResults+0x18/+0x1c = std::vector<Game::CombatPlayerStats>, stride 0x24 (verified sizeof), INDEXED BY THE ENCOUNTER MEMBER INDEX, not by PlyrIdx: the resolver computes base + i*0x24 with `lea edx,[eax+eax*8]; lea ebx,[eax+edx*4]` at 0x007d6f11 using the same i that drives the member loop, and skips the block when (+0x1c - +0x18)/0x24 <= i",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/combat-resolver.md"
|
|
},
|
|
{
|
|
"name": "Game_EncounterResults_off_NodeCannonFlung",
|
|
"convention": "layout",
|
|
"offset": "0xa8",
|
|
"prototype": "Game::EncounterResults+0xa8/+0xac = a 4-byte-stride vector of entity handles flung by a node cannon. Emptiness of THIS vector is the sole first gate on RNG site 1 of the combat resolver: CombatResolve_NodeCannon returns at 0x007bb575 with no draw when +0xa8 == +0xac. Each handle is resolved through the global HandleMap at GetGame()+0x84 (here reached as S+0x84)",
|
|
"status": "verified",
|
|
"source": "findings/control-flow/combat-resolver.md"
|
|
}
|
|
]
|
|
}
|