Z: per-call-site draw ledger -- seven entry points, keyed by return address
The boundary ledger says which phase spends a turn's words; this says which call site. Each entry point is detoured with its verified prototype and records __builtin_return_address(0) plus the word cost from left before/after -- two 4-byte reads, no record per draw, since NextFloat alone has 109 call sites. Each draw is tagged with WHICH generator it came from. That is not a detail: the first build counted every Mars::RNG instance in the process and reported 44 words against a bracket of 18. The StrategyClient's generator at +0x134 draws 8 times a turn and must not be in the strategic total. Also hooks EncounterDetect::AssignContacts, lane I's one inlined-draw site in ProcessTurn's closure -- invisible to both a call-graph sweep and to the entry- point detours, so only a boundary hook can see it. It did not fire on this workload, which is consistent with the site sums reconciling exactly.
This commit is contained in:
parent
2f4fcfcb21
commit
2a9b97dcee
8 changed files with 576 additions and 2 deletions
|
|
@ -96,7 +96,8 @@ if(WIN32)
|
||||||
src/shim/hooks/colony_turn.cpp
|
src/shim/hooks/colony_turn.cpp
|
||||||
src/shim/hooks/fleet_movement.cpp
|
src/shim/hooks/fleet_movement.cpp
|
||||||
src/shim/hooks/player_turn.cpp
|
src/shim/hooks/player_turn.cpp
|
||||||
src/shim/hooks/tail_rng.cpp)
|
src/shim/hooks/tail_rng.cpp
|
||||||
|
src/shim/hooks/draw_sites.cpp)
|
||||||
target_link_libraries(shim_hooks PUBLIC shim_trace sots_addresses sots_game_config sots_game_sim
|
target_link_libraries(shim_hooks PUBLIC shim_trace sots_addresses sots_game_config sots_game_sim
|
||||||
sots_game_effects mars_rng shim_budget shim_techfx
|
sots_game_effects mars_rng shim_budget shim_techfx
|
||||||
shim_colony shim_movement shim_events
|
shim_colony shim_movement shim_events
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
|
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
|
||||||
// Source: sots-re ghidra/addresses.json @ 729cfc0, generated 2026-09-08 by tools/gen_addresses.py
|
// Source: sots-re ghidra/addresses.json @ 9fa1ee2, generated 2026-09-08 by tools/gen_addresses.py
|
||||||
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
@ -1295,6 +1295,68 @@ constexpr uint32_t Mars_VectorHelper_AIPlayerRequestStamp_Write = 0x0029b310;
|
||||||
constexpr uint32_t Mars_StreamableHelper_AIPlayerRequestStamp_vftable = 0x0061a730;
|
constexpr uint32_t Mars_StreamableHelper_AIPlayerRequestStamp_vftable = 0x0061a730;
|
||||||
// thiscall void (Mars::StreamableHelper<Game::AIPlayerRequestStamp>* this, Mars::Stream* s) // two named ints: `pid` at +0 and `trn` at +4. Game::AIPlayerRequestStamp is a POD with no RTTI class of its own, reached only through this specialised helper, so tools/serializers.py reports 'no serializer' for it and it has no entry in the generated wire table [verified]
|
// thiscall void (Mars::StreamableHelper<Game::AIPlayerRequestStamp>* this, Mars::Stream* s) // two named ints: `pid` at +0 and `trn` at +4. Game::AIPlayerRequestStamp is a POD with no RTTI class of its own, reached only through this specialised helper, so tools/serializers.py reports 'no serializer' for it and it has no entry in the generated wire table [verified]
|
||||||
constexpr uint32_t Game_AIPlayerRequestStamp_Write = 0x00295400;
|
constexpr uint32_t Game_AIPlayerRequestStamp_Write = 0x00295400;
|
||||||
|
// thiscall uint32_t (Mars::RNG* this /*ecx = THE OBJECT, not &mt*/) // plain RET, no stack args. THE FOURTH DRAW ENTRY POINT. Whole 84-byte body read from the instruction stream: `cmp [ecx+0x9c8],0; push esi; lea esi,[ecx+4]; jne skip; mov ecx,esi; call RNG_Twist; skip: eax=[esi+0x9c0]; dec [esi+0x9c4]; ecx=*eax; eax+=4; [esi+0x9c0]=eax;` then the standard Mars temper (shr 11 / and 0xff3a58ad shl 7 / and 0xffffdf8c shl 15 / shr 18) and `ret`. EXACTLY ONE MT WORD, UNCONDITIONAL -- no rejection loop, no early-out, no branch except the lazy twist. Contrast RNG_NextFloat and RNG_NextInt, which are entered with ECX = &mt = obj+4; this one takes the object and does the +4 itself. Body ends 0x004f76c3 (Ghidra's 84 is correct here), then 12 int3 to 0x004f76d0. 11 callers image-wide; in StrategyServer::ProcessTurn's direct-call closure at DEPTH 4 via ProcessFleetMovement 0x007da9a0 -> MoveFleet 0x007d9ee0 -> ProbabilisticJump 0x007b6700 @0x007b67e7 [verified]
|
||||||
|
constexpr uint32_t Mars_RNG_NextUInt = 0x000f7670;
|
||||||
|
// thiscall float (Mars::RNG* this /*ecx = THE OBJECT*/, float lo, float hi) // RET 8. FIFTH DRAW ENTRY POINT, in no previous lane's primitive set. `add ecx,4; call RNG_NextFloat` then `lo + (float)((hi-lo) * unit)`, with the product STORED TO A FLOAT before the add and the sum stored to a float again -- two roundings, both must be reproduced. EXACTLY ONE MT WORD. In StrategyServer::ProcessTurn's closure at depth 3 via ServerPlayer::ProcessTurn -> 0x00889dc0 (call sites 0x0088a1bd, 0x0088a20f) [verified]
|
||||||
|
constexpr uint32_t Mars_RNG_FloatRange = 0x0007d8a0;
|
||||||
|
// cdecl int (Mars::RNG* rng /*STACK arg, the object*/, int lo, int hi) // plain RET. SIXTH DRAW ENTRY POINT. h = hi - lo; half = h/2 truncated toward zero (the `cdq; sub eax,edx; sar 1` idiom, so negative h rounds toward zero not down); returns lo + NextInt(half) + NextInt(h - half), both NextInt calls entered with ECX = rng+4 and both taking the bound BY POINTER. TRIANGULAR, not uniform. AT LEAST TWO MT WORDS -- each NextInt carries its own rejection loop. Not reachable from any turn driver by a direct call [verified]
|
||||||
|
constexpr uint32_t Mars_RNG_IntRangeBell = 0x004e6d80;
|
||||||
|
// cdecl int (Mars::RNG* rng /*STACK arg, the object*/, int lo, int hi, int mode) // plain RET. SEVENTH DRAW ENTRY POINT and the only one whose cost is UNBOUNDED. Box-Muller with rejection; BOTH draws are INLINED (temper chains at 0x008e6eb8 and 0x008e6f34), so no call-graph RNG sweep sees them and the only edges left are two bare RNG_Twist calls. half = (hi-lo)/2; mean = 2.1 * ((mode-lo)/half - 1). Per attempt: z = sqrt(-2 * ln(1 - (y1 + 0.5) * 2^-32)) * cos(2*pi * y2 * 2^-32) + mean; REJECT and redraw while z > 2.1 or z < -2.1 (back-edges 0x008e6f8d and 0x008e6fa0 -> 0x008e6e7f). Result = lo + ftol(((z + 2.1) / 4.2) * (hi - lo)). TWO MT WORDS PER ATTEMPT. NOTE THE DIVISOR: this path scales by 2^-32 (0x00a3b6f0), NOT the 1/(2^32-1) at 0x009e61b0 that RNG_NextFloat uses -- the two are different constants in the same image. Three callers (0x00786200, 0x00786230, 0x00798040); not reachable from any turn driver by a direct call [verified]
|
||||||
|
constexpr uint32_t Mars_RNG_GaussianRange = 0x004e6e30;
|
||||||
|
// thiscall void (EncounterDetectCtx* this, std::vector<std::vector<void*>>* outBuckets, std::vector<void*>* detectors, std::vector<void*>* contacts) // RET 0xc. THE ONLY GAME FUNCTION WITH AN INLINED MT DRAW IN StrategyServer::ProcessTurn's CLOSURE (depth 4). this = {+0x00 StrategyServer* S, +0x04 TechDef* id 0x2729, +0x08 TechDef* id 0x2728}. For each contact (outer loop over `contacts`, index ebx) it walks `detectors` (inner loop, index edi) and draws ONE inlined NextFloat from S->rng (S+0x16c) PER (contact, detector) TRIAL, BEFORE the accept test. thresh = 0.25f (0x00a23a6c) if TechTree_HasTechComplete(detector->+0xf4, this->+0x8) or (detector->+0xf4, this->+0x4), else 0.0f; r = (float)unit; ACCEPT iff thresh >= r, and on accept the contact is pushed into outBuckets[detector] and the outer loop moves on. A detector with neither tech BURNS A WORD AND CAN NEVER ACCEPT (0.0f >= r only when the word is 0). WORDS PER CALL = sum over contacts of min(trials-to-first-accept, |detectors|); all-teched mean = |contacts| * 4 * (1 - 0.75^|detectors|), none-teched = |contacts| * |detectors| exactly. A vector<int> 'tried' bitset at [ebp-0x38] (|contacts| words, bit = detector index) and a vector<bool> 'assigned' at [ebp-0x4c] are NEVER cleared, so the repeat-until-no-progress outer loop at 0x007aa583 cannot redraw a pair and always terminates after at most two passes. RULE 17: Ghidra reports 944 bytes (end 0x007aa5f0, mid-instruction); the real body ends at 0x007aa5f9 -- the std::vector length_error throw stub at 0x007aa5ee/0x007aa5f3 is outside the reported range [verified]
|
||||||
|
constexpr uint32_t EncounterDetect_AssignContacts = 0x003aa240;
|
||||||
|
// site site in EncounterDetect_AssignContacts: the INLINED Mars::RNG::NextFloat. `mov esi,[edx+0x16c]; cmp [esi+0x9c8],0; jne +8; lea ecx,[esi+4]; call RNG_Twist` -- the Twist call is the LAZY TWIST INSIDE NextFloat, not a bare Twist. The temper runs 0x007aa3e1..0x007aa407, the fild/+2^32/*1-over-(2^32-1) 0x007aa40c..0x007aa41d. THIS IS THE SITE RULE 16 WAS WRITTEN FOR: the only call-graph edge it leaves is EncounterDetect_AssignContacts -> RNG_Twist [verified]
|
||||||
|
constexpr uint32_t EncounterDetect_AssignContacts_Draw = 0x003aa3b6;
|
||||||
|
// site site in EncounterDetect_AssignContacts: `fcompp; fnstsw ax; test ah,5; jp 0x007aa45a`. st0 = thresh, st1 = r. ah&5 is 0 when thresh > r, 0 when thresh == r, 1 when thresh < r, 5 when unordered; PF is even for 0 and 5, so the jp is TAKEN (ACCEPT) iff thresh >= r or unordered, and falls through to the next-detector path iff thresh < r. EQUALITY ACCEPTS. Derive the branch from the ISA, not from the mnemonic (rule 10) [verified]
|
||||||
|
constexpr uint32_t EncounterDetect_AssignContacts_AcceptTest = 0x003aa435;
|
||||||
|
// site site in EncounterDetect_AssignContacts: `cmp BYTE [ebp-0xd],0; je 0x007aa2d0` -- the repeat-until-no-progress back-edge that rule 17 exists to make you look for. [ebp-0xd] is set to 1 at 0x007aa2da at the top of each pass and cleared at 0x007aa360 by any (contact, detector) pair that is evaluated. It is NOT an unbounded loop: the 'tried' bitset it consults is never reset, so the second pass evaluates nothing, clears nothing, and the flag stays 1. At most two passes, and no pair is ever drawn for twice [verified]
|
||||||
|
constexpr uint32_t EncounterDetect_AssignContacts_OuterBackEdge = 0x003aa583;
|
||||||
|
// site the REAL end of EncounterDetect_AssignContacts. Ghidra reports sizeInBytes 944, i.e. an end of 0x007aa5f0, which falls inside the 5-byte `push 0x9e1f90` at 0x007aa5ee. The body's last instruction is the `call ds:0x9dd150` (std::vector length_error throw) at 0x007aa5f3, ending 0x007aa5f9; int3 padding runs to the next function start 0x007aa600. Real size 953. Rule 17 [verified]
|
||||||
|
constexpr uint32_t EncounterDetect_AssignContacts_RealEnd = 0x003aa5f9;
|
||||||
|
// thiscall void (EncounterDetectCtx* this, TeamRecord* rec /*the 0x74-byte record StrategyServer::DetectEncounters builds*/) // RET 4. THE ONLY CALLER OF EncounterDetect_AssignContacts. Gate at 0x007ca671: 0x007892d0(rec) is true iff some entry of rec->(+0x28..+0x2c) (stride 0x44) has entry[0]->+0xfc != 0; false -> whole function is a no-op and NO WORD IS DRAWN. Then `detectors` = 0x007949b0(rec) (entries whose object has +0xfc == 0 AND +0xfb == 0) and `contacts` = 0x00791460(rec) (entries whose object has +0xfc != 0); either empty -> return, still no draw. Otherwise buckets = vector<vector<void*>>(|detectors|) via 0x007b77c0, then the draw call at 0x007ca73a [verified]
|
||||||
|
constexpr uint32_t EncounterDetect_ProcessTeamRecord = 0x003ca640;
|
||||||
|
// thiscall EncounterDetectCtx* (EncounterDetectCtx* this, StrategyServer* S, std::vector<TeamRecord>* records) // RET 8, returns this in EAX. Constructs the 12-byte context {S, TechDef*(id 0x2729), TechDef*(id 0x2728)} -- the tech lookup is 0x0057d610(g_0x00b2d540->+0x110, id) -- then calls EncounterDetect_ProcessTeamRecord once per 0x74-byte record (the 0x8d3dcb09 / sar 6 divide-by-0x74 idiom). Called from StrategyServer::DetectEncounters 0x007d7f70 at 0x007d8470, i.e. inside the LAST phase of StrategyServer::ProcessTurn. This is the ONLY path by which a game-code inlined MT draw is reachable from ProcessTurn [verified]
|
||||||
|
constexpr uint32_t EncounterDetect_Run = 0x003cb080;
|
||||||
|
// thiscall bool (TechTree* this, TechDef* def) // RET 4. `if (!def) return false; node = this->+0x10[def->+0x00]; return node != NULL && node->+0x14 == 4;` -- state 4 is 'researched'. 28 callers image-wide. EncounterDetect_AssignContacts uses it to choose between the 0.25f detection threshold and 0.0f [verified]
|
||||||
|
constexpr uint32_t TechTree_HasTechComplete = 0x0017d7e0;
|
||||||
|
// site site in ProbabilisticJump: the SECOND draw, `mov ecx,[edx+0x16c]; call Mars_RNG_NextUInt`. Note ECX is the RNG OBJECT here while the NextFloat at 0x007b677c three dozen bytes earlier is entered at rng+4 -- two conventions on the same generator in one function. It is reached only when the arrival test at 0x007b67ae..0x007b67b5 FAILS (i.e. fleet->+0x58->+0x158 < float(NextFloat() * fleet->+0x58->+0x154)); on the arriving branch the function copies the destination position and never draws again. ONE EXTRA MT WORD. The word is handed to 0x008a6fa0, which returns a pointer to three floats used to scatter the fleet's position around the destination [verified]
|
||||||
|
constexpr uint32_t ProbabilisticJump_NextUIntDraw = 0x003b67e7;
|
||||||
|
// site NOT AN RNG SITE, recorded so nobody re-derives it. An image-wide scan for the tempering immediates reports 0x008cca30 as containing 0xffffdf8c, but the four bytes at 0x008cca90 are the rel32 displacement of `call 0x008caa20` (e8 8c df ff ff), not an `and r32,imm32`. FUN_008cca30 has no temper chain: no 0xff3a58ad, no `shr r32,0xb`, no shl 7/15/18. It appears in combat-resolver.md §0.1's list of 14 inlined-draw functions and must be struck from it [verified]
|
||||||
|
constexpr uint32_t InlinedDrawScan_FalsePositive_008cca30 = 0x004cca8f;
|
||||||
|
// constant 0xff3a58ad -- the first tempering mask AS THE IMAGE SPELLS IT, `y ^= (y & 0xff3a58ad) << 7`. This is the TEXTBOOK MT19937 tempering with the mask applied BEFORE the shift rather than after: (y & 0xff3a58ad) << 7 == (y << 7) & 0x9d2c5680, because the mask bits above bit 24 shift past bit 31 and are dont-cares (0x9d2c5680 >> 7 == 0x013a58ad == 0xff3a58ad & 0x01ffffff). Verified over 200,000 random words. Mars::RNG is stock MT19937 -- our mars::rng model is NOT wrong -- but a scan for the textbook constants finds NOTHING in this image, which is why an inlined-draw sweep must scan for THIS value at real instruction boundaries (rule 16). 33 occurrences inside decoded instructions image-wide, all genuine `and r32,imm32` in a temper chain [verified]
|
||||||
|
constexpr uint32_t MT_TemperMask1 = 0xff3a58ad;
|
||||||
|
// constant 0xffffdf8c -- the second tempering mask as the image spells it, `y ^= (y & 0xffffdf8c) << 15`, equal to the textbook `(y << 15) & 0xefc60000` (0xefc60000 >> 15 == 0x0001df8c == 0xffffdf8c & 0x0001ffff). Image-wide there are 34 occurrences of these bytes inside decoded instructions; 33 are genuine and ONE (0x008cca90) is the rel32 displacement of a call. Always require BOTH masks plus a preceding `shr r32,0xb` before calling a hit a draw [verified]
|
||||||
|
constexpr uint32_t MT_TemperMask2 = 0xffffdf8c;
|
||||||
|
// thiscall 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 [verified]
|
||||||
|
constexpr uint32_t CombatResolver_Run = 0x003d5af0;
|
||||||
|
// thiscall 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 [verified]
|
||||||
|
constexpr uint32_t CombatResolveContext_Ctor = 0x003b8460;
|
||||||
|
// thiscall 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 [verified]
|
||||||
|
constexpr uint32_t CombatResolve_NodeCannon = 0x003bb530;
|
||||||
|
// thiscall 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 [verified]
|
||||||
|
constexpr uint32_t CombatResolve_SalvageBackEng = 0x003a7f30;
|
||||||
|
// site 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 [verified]
|
||||||
|
constexpr uint32_t CombatResolve_SalvageBackEng_RollSite = 0x003a84bd;
|
||||||
|
// thiscall 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 [verified]
|
||||||
|
constexpr uint32_t SpecialProject_UnlockRandomForPlayer = 0x003a0540;
|
||||||
|
// thiscall 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 [verified]
|
||||||
|
constexpr uint32_t SpecialProject_PickRandomAvailable = 0x00452d30;
|
||||||
|
// thiscall 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 [verified]
|
||||||
|
constexpr uint32_t CombatPlayerStats_TacReportAt = 0x00056c40;
|
||||||
|
// thiscall 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 [verified]
|
||||||
|
constexpr uint32_t CombatPlayerStats_TacReportCount = 0x00056c20;
|
||||||
|
// layout 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 [verified]
|
||||||
|
constexpr uint32_t sizeof_Game_TacReport = 0x00000094;
|
||||||
|
// layout 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 [verified]
|
||||||
|
constexpr uint32_t Game_CombatPlayerStats_off_TacReports = 0x00000004;
|
||||||
|
// site 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 [verified]
|
||||||
|
constexpr uint32_t CombatResolve_TurnResultsWriteSite = 0x003ba140;
|
||||||
|
// layout 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 [hypothesis]
|
||||||
|
constexpr uint32_t Game_EncounterResults_off_Participants = 0x00000098;
|
||||||
|
// layout 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 [verified]
|
||||||
|
constexpr uint32_t Game_EncounterResults_off_PlayerStats = 0x00000018;
|
||||||
|
// layout 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) [verified]
|
||||||
|
constexpr uint32_t Game_EncounterResults_off_NodeCannonFlung = 0x000000a8;
|
||||||
// thiscall void (StrategyServer* this /*base S*/, std::vector<EncounterResults>* results) // RET 4. THE SECOND TURN DRIVER. Reached from exactly one caller: StrategyHost::OnMessage 0x00784640 at 0x00784d07, on the SNMAllCombatDone message, with this = host->+0x54 and results = msg+4. 36 phases, whole 1587-byte body read from the instruction stream. Base is S (NOT S+4): Players at [S+0x54/0x58], ServerTradeManager at [S+0x158], SVScriptObject at [S+0x1b4], encounters at [S+0x1e8]. STRAIGHT-LINE past 0x007d96bf -- every jcc from there on is a per-player loop bound or one of three null tests on S+0x1b4. Order: ++S->+0x8 / arity check / first contact over all ordered combatant pairs / sighting announce / battle tally / diplomacy stats / ApplyEncounterResult per encounter + resupply / encounters.clear() / script(8) / AIRebellion(1) / ProcessNodeSpaceTravel / node-line decay (RNG) / colony-loss drain / two morale passes / ProcessBankruptcy / colonizer resolve / PlayerView rebuild / warnings / infra-terra drain / script(0x14,0x15) / survey+stats / FUN_0078a7c0 / eight ServerTradeManager vtable calls / upkeep / sensors / script(0x1c) / view refresh / node-line sightings / intercept aborts / comm masks / UpdateBankruptcyLimits per player / incoming warnings / two more vtable calls / observed designs / player reports / turn records [verified]
|
// thiscall void (StrategyServer* this /*base S*/, std::vector<EncounterResults>* results) // RET 4. THE SECOND TURN DRIVER. Reached from exactly one caller: StrategyHost::OnMessage 0x00784640 at 0x00784d07, on the SNMAllCombatDone message, with this = host->+0x54 and results = msg+4. 36 phases, whole 1587-byte body read from the instruction stream. Base is S (NOT S+4): Players at [S+0x54/0x58], ServerTradeManager at [S+0x158], SVScriptObject at [S+0x1b4], encounters at [S+0x1e8]. STRAIGHT-LINE past 0x007d96bf -- every jcc from there on is a per-player loop bound or one of three null tests on S+0x1b4. Order: ++S->+0x8 / arity check / first contact over all ordered combatant pairs / sighting announce / battle tally / diplomacy stats / ApplyEncounterResult per encounter + resupply / encounters.clear() / script(8) / AIRebellion(1) / ProcessNodeSpaceTravel / node-line decay (RNG) / colony-loss drain / two morale passes / ProcessBankruptcy / colonizer resolve / PlayerView rebuild / warnings / infra-terra drain / script(0x14,0x15) / survey+stats / FUN_0078a7c0 / eight ServerTradeManager vtable calls / upkeep / sensors / script(0x1c) / view refresh / node-line sightings / intercept aborts / comm masks / UpdateBankruptcyLimits per player / incoming warnings / two more vtable calls / observed designs / player reports / turn records [verified]
|
||||||
constexpr uint32_t StrategyServer_OnAllCombatDone_Tail = 0x003d92a0;
|
constexpr uint32_t StrategyServer_OnAllCombatDone_Tail = 0x003d92a0;
|
||||||
// site site, phase 7: S->encounters.clear(). The bytes are `if (_Myfirst != _Mylast) { newEnd = FUN_007c5780(_Mylast,_Mylast,_Myfirst,c); FUN_00679c80(newEnd,_Mylast,&vec+0xc,c); _Mylast = newEnd; }`, MSVC's vector::erase(begin,end). FUN_007c5780 is std::_Uninit_move over 0x74-byte Encounters and is handed the EMPTY range [_Mylast,_Mylast), so it copies nothing and returns _Myfirst; FUN_00679c80 is std::_Destroy_range. THE IDENTICAL FOUR-ARGUMENT SHAPE appears at 0x007cd147/0x007cd15b inside FUN_007cd100 (vector<Encounter>::operator= taking the empty-source path), which is what identifies it. NO PREDICATE, NO FILTER: every encounter is erased. The `if` is the empty-vector guard erase always carries and both arms converge at 0x007d96bf [verified]
|
// site site, phase 7: S->encounters.clear(). The bytes are `if (_Myfirst != _Mylast) { newEnd = FUN_007c5780(_Mylast,_Mylast,_Myfirst,c); FUN_00679c80(newEnd,_Mylast,&vec+0xc,c); _Mylast = newEnd; }`, MSVC's vector::erase(begin,end). FUN_007c5780 is std::_Uninit_move over 0x74-byte Encounters and is handed the EMPTY range [_Mylast,_Mylast), so it copies nothing and returns _Myfirst; FUN_00679c80 is std::_Destroy_range. THE IDENTICAL FOUR-ARGUMENT SHAPE appears at 0x007cd147/0x007cd15b inside FUN_007cd100 (vector<Encounter>::operator= taking the empty-source path), which is what identifies it. NO PREDICATE, NO FILTER: every encounter is erased. The `if` is the empty-vector guard erase always carries and both arms converge at 0x007d96bf [verified]
|
||||||
|
|
@ -1539,5 +1601,13 @@ constexpr uint32_t ServerNodeGraph_FindPathById = 0x002e23d0;
|
||||||
constexpr uint32_t StarFleet_HasFlagShips = 0x00303500;
|
constexpr uint32_t StarFleet_HasFlagShips = 0x00303500;
|
||||||
// cdecl int (StarFleet* fleet, int npid) // 234 B. Returns 3 exactly when the fleet's FRONT waypoint (the deque at fleet+0xc4, element +0x10) names this npid -- i.e. the fleet is currently riding this line; 0/1/2/4 otherwise. Node-line decay drops a rolled line when any 0x20000-flagged fleet returns 3 for it [verified]
|
// cdecl int (StarFleet* fleet, int npid) // 234 B. Returns 3 exactly when the fleet's FRONT waypoint (the deque at fleet+0xc4, element +0x10) names this npid -- i.e. the fleet is currently riding this line; 0/1/2/4 otherwise. Node-line decay drops a rolled line when any 0x20000-flagged fleet returns 3 for it [verified]
|
||||||
constexpr uint32_t StarFleet_PathRelation = 0x0038c360;
|
constexpr uint32_t StarFleet_PathRelation = 0x0038c360;
|
||||||
|
// note NAME CORRECTION, from StrategyServer::Write's own wire tags. At 0x0079fb2f `lea edx,[edi+0x08]; push "ModCount"` and at 0x0079fb40 `lea eax,[edi+0x0c]; push "Frame"`, with edi = S (the same edi that indexes the players vector at +0x54). So in the S frame **S+0x8 is ModCount and S+0xc is Frame**, i.e. in the stored (S+4) frame +0x4 is ModCount and +0x8 is Frame. `StrategyServer_off_ModCount = 0x8` therefore carries the WRONG NAME: that word is Frame, the turn number. The word it names is the one lane T recorded as StrategyServer_off_PhaseCounter = 0x4 and lane K called 'never named' -- it has a name, and it is ModCount. CONFIRMED FROM THE SAVES, which is an independent instrument: Frame reads 1/2/3 on turn1/2/3-state, 16 on zuul-turn16, 23 on zuul-turn23, while ModCount reads 0/12/24/241/412. And CONFIRMED LIVE: lane Z measured S+0x8 advancing 12, 14, 12 per turn on the early Human game (the saves say +12/turn) and 16, 21, 44 on the Zuul one (the saves say ~24/turn average). A modification counter is exactly what those numbers look like, and it explains why only 2 of the 12-44 increments come from the two turn drivers. Integrator: reconcile StrategyServer_off_ModCount / StrategyServer_off_PhaseCounter rather than adding a third name [verified]
|
||||||
|
constexpr uint32_t StrategyServer_wire_ModCount_vs_Frame = 0x0039fb2f;
|
||||||
|
// thiscall void (void* rawBase /* = S+4 */) // the StrategyServer base-class ctor, called from StrategyServer::StrategyServer 0x007d78d0 at 0x007d7905 as `lea ecx,[esi+0x4]`. It zero-initialises FOUR CONSECUTIVE std::vectors as three-word triples with the fourth word skipped: raw +0x40/+0x44/+0x48, +0x50/+0x54/+0x58, +0x60/+0x64/+0x68, +0x70/+0x74/+0x78, then `lea ecx,[esi+0x80]` for the entity hash. That is the campaign's `{_Myfirst,_Mylast,_Myend,_Alval}` = 0x10 allocator-last shape (method rule 5) enumerated four times in a row, and it independently pins StrategyServer_off_Players = 0x50 and _off_Fleets = 0x60 in the raw frame WITHOUT any frame arithmetic -- the ctor is entered with ecx = S+4, so the players triple is literally {S+0x54, S+0x58, S+0x5c}. This is the enumeration that closes the 0x60-vs-0x64 question the campaign paid for once [verified]
|
||||||
|
constexpr uint32_t StrategyServer_ctor_VectorBlock = 0x0045b120;
|
||||||
|
// thiscall ServerPlayer* (StrategyServer* this /*S frame*/) // five sibling accessors at 0x00788de0, 0x00788e10, 0x00788e40, 0x00788e70, 0x00788ea0, one per NPC pseudo-player index word at S+0x1b8/0x1bc/0x1c0/0x1c4/0x1c8 (the five words the ctor sets to -1 at 0x007d79fe..0x007d7a16, and the save's NPCm/NPCo/NPCi/NPCv/NPCa). Each is `idx = this->+0x1b8; if (idx < 0) return 0; first = [this+0x54]; last = [this+0x58]; if (idx >= (last-first)>>2) return 0; return first[idx];` -- a bounds check against the players vector's size followed by an index off _Myfirst, which is a third independent confirmation that S+0x54/S+0x58 are _Myfirst/_Mylast. THE PLAYER VECTOR IS NOT THE LOBBY'S PLAYER LIST: it is #empires + one rebel-AI per distinct empire species + 4 NPC pseudo-players (Alien Menace, Peacekeeper Enforcer, Von Neumann, Independent Colony, all Species 4). Hence NumPlrs 8 on the Human saves (two species) and 7 on the Zuul ones (one species), against a lobby that says '2 Players' in both -- Summary.Players counts EMPIRE SLOTS and is also right [verified]
|
||||||
|
constexpr uint32_t StrategyServer_NPCPlayerAccessors = 0x00388de0;
|
||||||
|
// offset sizeof(Game::ServerPlayer) = 0x3e0, from the two `push 0x3e0` + operator new sites that precede the ctor call: 0x007865b3 (the bare factory reached through the class-registry word at 0x00a26078) and 0x0078a2f5 (the save loader, which also sets +0x8 = S+4 and inserts into the entity hash at S+0x84). The ctor itself is 0x008803d0 -- NOTE that findings/control-flow/turn-driver.md §3 cites 0x00880474 as 'the ServerPlayer constructor', which is an address INSIDE it; the instruction there is `mov WORD [esi+0x3b4],0x100`, a 16-bit store, so it sets ResErrRoll(+0x3b4) = 0 and cta(+0x3b5) = 1, not '+0x3b4 = 1' as that note reads [verified]
|
||||||
|
constexpr uint32_t sizeof_Game_ServerPlayer = 0x000003e0;
|
||||||
|
|
||||||
} // namespace sots::addr
|
} // namespace sots::addr
|
||||||
|
|
|
||||||
246
src/shim/hooks/draw_sites.cpp
Normal file
246
src/shim/hooks/draw_sites.cpp
Normal file
|
|
@ -0,0 +1,246 @@
|
||||||
|
#include "shim/hooks/draw_sites.h"
|
||||||
|
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
#include "generated/sots_addresses.h"
|
||||||
|
#include "shim/trace/platform.h"
|
||||||
|
|
||||||
|
namespace shim::hooks {
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
|
||||||
|
namespace A = sots::addr;
|
||||||
|
|
||||||
|
constexpr std::size_t kMaxSites = 96;
|
||||||
|
constexpr std::int32_t kBlock = 624;
|
||||||
|
|
||||||
|
std::uintptr_t g_exe_base = 0;
|
||||||
|
Mutex g_mu;
|
||||||
|
DrawSiteRow g_rows[kMaxSites];
|
||||||
|
std::size_t g_nrows = 0;
|
||||||
|
std::uint32_t g_total_words = 0;
|
||||||
|
std::uint32_t g_total_calls = 0;
|
||||||
|
std::uint32_t g_other_words = 0;
|
||||||
|
std::uint32_t g_other_calls = 0;
|
||||||
|
std::uint32_t g_overflow = 0;
|
||||||
|
const void* g_strategic = nullptr;
|
||||||
|
|
||||||
|
// `left` lives at RNG+0x9c8 (the object base). Every entry point below normalises its argument to
|
||||||
|
// that base before reading it, because the image uses BOTH conventions: the inner primitives take
|
||||||
|
// `&mt` = obj+4, the outer helpers take the object.
|
||||||
|
std::int32_t left_of(const void* obj) {
|
||||||
|
if (!obj) return -1;
|
||||||
|
std::int32_t v = 0;
|
||||||
|
std::memcpy(&v, static_cast<const char*>(obj) + A::RNG_off_Left, 4);
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Words consumed between two `left` readings of the same generator. See the header for the
|
||||||
|
// single-twist assumption.
|
||||||
|
std::uint32_t words_between(std::int32_t before, std::int32_t after) {
|
||||||
|
if (before < 0 || after < 0) return 0;
|
||||||
|
if (after <= before) return static_cast<std::uint32_t>(before - after);
|
||||||
|
return static_cast<std::uint32_t>(before + kBlock - after);
|
||||||
|
}
|
||||||
|
|
||||||
|
void record(DrawEntry e, const void* ret, const void* obj, std::uint32_t words) {
|
||||||
|
const std::uint32_t rva =
|
||||||
|
static_cast<std::uint32_t>(reinterpret_cast<std::uintptr_t>(ret) - g_exe_base);
|
||||||
|
LockGuard g(g_mu);
|
||||||
|
const bool strategic = g_strategic != nullptr && obj == g_strategic;
|
||||||
|
if (strategic) {
|
||||||
|
g_total_words += words;
|
||||||
|
++g_total_calls;
|
||||||
|
} else {
|
||||||
|
g_other_words += words;
|
||||||
|
++g_other_calls;
|
||||||
|
}
|
||||||
|
for (std::size_t i = 0; i < g_nrows; ++i) {
|
||||||
|
if (g_rows[i].ret_rva == rva && g_rows[i].entry == e && g_rows[i].strategic == strategic) {
|
||||||
|
++g_rows[i].calls;
|
||||||
|
g_rows[i].words += words;
|
||||||
|
if (words == 0) ++g_rows[i].zero_calls;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (g_nrows >= kMaxSites) {
|
||||||
|
++g_overflow;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DrawSiteRow& r = g_rows[g_nrows++];
|
||||||
|
r.ret_rva = rva;
|
||||||
|
r.entry = e;
|
||||||
|
r.calls = 1;
|
||||||
|
r.words = words;
|
||||||
|
r.zero_calls = words == 0 ? 1 : 0;
|
||||||
|
r.strategic = strategic;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- trampolines -----------------------------------------------------------------------------
|
||||||
|
|
||||||
|
using NextFloatFn = float(SHIM_THISCALL*)(void*);
|
||||||
|
using NextIntFn = std::uint32_t(SHIM_THISCALL*)(void*, std::uint32_t*);
|
||||||
|
using ChanceFn = bool(SHIM_THISCALL*)(void*, float);
|
||||||
|
using NextUIntFn = std::uint32_t(SHIM_THISCALL*)(void*);
|
||||||
|
using FloatRangeFn = float(SHIM_THISCALL*)(void*, float, float);
|
||||||
|
using IntRangeBellFn = int(SHIM_CDECL*)(void*, int, int);
|
||||||
|
using GaussianRangeFn = int(SHIM_CDECL*)(void*, int, int, int);
|
||||||
|
|
||||||
|
void* g_tr_next_float = nullptr;
|
||||||
|
void* g_tr_next_int = nullptr;
|
||||||
|
void* g_tr_chance = nullptr;
|
||||||
|
void* g_tr_next_uint = nullptr;
|
||||||
|
void* g_tr_float_range = nullptr;
|
||||||
|
void* g_tr_int_range_bell = nullptr;
|
||||||
|
void* g_tr_gaussian_range = nullptr;
|
||||||
|
|
||||||
|
// The inner primitives are entered with ECX = &mt = object + 4; the outer helpers take the object.
|
||||||
|
const void* obj_from_mt(void* mt) { return mt ? static_cast<char*>(mt) - A::RNG_off_State : nullptr; }
|
||||||
|
|
||||||
|
// ---- detours ---------------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Each one is the direct target of the game's `call`, so __builtin_return_address(0) is the game
|
||||||
|
// instruction after that call -- the call site we are attributing to.
|
||||||
|
|
||||||
|
float SHIM_THISCALL DetourNextFloat(void* mt) {
|
||||||
|
const void* ret = __builtin_return_address(0);
|
||||||
|
const void* obj = obj_from_mt(mt);
|
||||||
|
const std::int32_t before = left_of(obj);
|
||||||
|
const float r = reinterpret_cast<NextFloatFn>(g_tr_next_float)(mt);
|
||||||
|
record(DrawEntry::NextFloat, ret, obj, words_between(before, left_of(obj)));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint32_t SHIM_THISCALL DetourNextInt(void* mt, std::uint32_t* bound) {
|
||||||
|
const void* ret = __builtin_return_address(0);
|
||||||
|
const void* obj = obj_from_mt(mt);
|
||||||
|
const std::int32_t before = left_of(obj);
|
||||||
|
const std::uint32_t r = reinterpret_cast<NextIntFn>(g_tr_next_int)(mt, bound);
|
||||||
|
record(DrawEntry::NextInt, ret, obj, words_between(before, left_of(obj)));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool SHIM_THISCALL DetourChance(void* obj, float p) {
|
||||||
|
const void* ret = __builtin_return_address(0);
|
||||||
|
const std::int32_t before = left_of(obj);
|
||||||
|
const bool r = reinterpret_cast<ChanceFn>(g_tr_chance)(obj, p);
|
||||||
|
record(DrawEntry::Chance, ret, obj, words_between(before, left_of(obj)));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint32_t SHIM_THISCALL DetourNextUInt(void* obj) {
|
||||||
|
const void* ret = __builtin_return_address(0);
|
||||||
|
const std::int32_t before = left_of(obj);
|
||||||
|
const std::uint32_t r = reinterpret_cast<NextUIntFn>(g_tr_next_uint)(obj);
|
||||||
|
record(DrawEntry::NextUInt, ret, obj, words_between(before, left_of(obj)));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
float SHIM_THISCALL DetourFloatRange(void* obj, float lo, float hi) {
|
||||||
|
const void* ret = __builtin_return_address(0);
|
||||||
|
const std::int32_t before = left_of(obj);
|
||||||
|
const float r = reinterpret_cast<FloatRangeFn>(g_tr_float_range)(obj, lo, hi);
|
||||||
|
record(DrawEntry::FloatRange, ret, obj, words_between(before, left_of(obj)));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
int SHIM_CDECL DetourIntRangeBell(void* obj, int lo, int hi) {
|
||||||
|
const void* ret = __builtin_return_address(0);
|
||||||
|
const std::int32_t before = left_of(obj);
|
||||||
|
const int r = reinterpret_cast<IntRangeBellFn>(g_tr_int_range_bell)(obj, lo, hi);
|
||||||
|
record(DrawEntry::IntRangeBell, ret, obj, words_between(before, left_of(obj)));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
int SHIM_CDECL DetourGaussianRange(void* obj, int lo, int hi, int mode) {
|
||||||
|
const void* ret = __builtin_return_address(0);
|
||||||
|
const std::int32_t before = left_of(obj);
|
||||||
|
const int r = reinterpret_cast<GaussianRangeFn>(g_tr_gaussian_range)(obj, lo, hi, mode);
|
||||||
|
record(DrawEntry::GaussianRange, ret, obj, words_between(before, left_of(obj)));
|
||||||
|
return r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// `IntRangeBell` and `GaussianRange` call `NextInt` / draw inline on the SAME generator, so a naive
|
||||||
|
// reading would count their words twice -- once against the helper's own site and once against the
|
||||||
|
// inner `NextInt` site. `IntRangeBell`'s two inner calls are hooked too, and their return addresses
|
||||||
|
// land inside 0x008e6d80, so they appear as their own rows and are recognisable as such. The
|
||||||
|
// reconciliation in the report subtracts any row whose ret_rva falls inside another entry point's
|
||||||
|
// body; nothing in this workload has ever hit either helper, so it has never mattered.
|
||||||
|
|
||||||
|
const DrawSiteHook kHooks[] = {
|
||||||
|
{"Mars::RNG::NextFloat", A::RNG_NextFloat, reinterpret_cast<void*>(&DetourNextFloat), &g_tr_next_float},
|
||||||
|
{"Mars::RNG::NextInt", A::RNG_NextInt, reinterpret_cast<void*>(&DetourNextInt), &g_tr_next_int},
|
||||||
|
{"Mars::RNG::Chance", A::RNG_Chance, reinterpret_cast<void*>(&DetourChance), &g_tr_chance},
|
||||||
|
{"Mars::RNG::NextUInt", A::Mars_RNG_NextUInt, reinterpret_cast<void*>(&DetourNextUInt), &g_tr_next_uint},
|
||||||
|
{"Mars::RNG::FloatRange", A::Mars_RNG_FloatRange, reinterpret_cast<void*>(&DetourFloatRange), &g_tr_float_range},
|
||||||
|
{"Mars::RNG::IntRangeBell", A::Mars_RNG_IntRangeBell, reinterpret_cast<void*>(&DetourIntRangeBell), &g_tr_int_range_bell},
|
||||||
|
{"Mars::RNG::GaussianRange", A::Mars_RNG_GaussianRange, reinterpret_cast<void*>(&DetourGaussianRange), &g_tr_gaussian_range},
|
||||||
|
};
|
||||||
|
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
const char* draw_entry_name(DrawEntry e) {
|
||||||
|
switch (e) {
|
||||||
|
case DrawEntry::NextFloat: return "NextFloat";
|
||||||
|
case DrawEntry::NextInt: return "NextInt";
|
||||||
|
case DrawEntry::Chance: return "Chance";
|
||||||
|
case DrawEntry::NextUInt: return "NextUInt";
|
||||||
|
case DrawEntry::FloatRange: return "FloatRange";
|
||||||
|
case DrawEntry::IntRangeBell: return "IntRangeBell";
|
||||||
|
case DrawEntry::GaussianRange: return "GaussianRange";
|
||||||
|
case DrawEntry::Count: break;
|
||||||
|
}
|
||||||
|
return "?";
|
||||||
|
}
|
||||||
|
|
||||||
|
const DrawSiteHook* draw_site_hooks(std::size_t* count) {
|
||||||
|
if (count) *count = sizeof kHooks / sizeof kHooks[0];
|
||||||
|
return kHooks;
|
||||||
|
}
|
||||||
|
|
||||||
|
void init_draw_sites(std::uintptr_t exe_base) { g_exe_base = exe_base; }
|
||||||
|
|
||||||
|
void draw_sites_set_generator(const void* strategic_rng) {
|
||||||
|
LockGuard g(g_mu);
|
||||||
|
g_strategic = strategic_rng;
|
||||||
|
}
|
||||||
|
|
||||||
|
void draw_sites_reset() {
|
||||||
|
LockGuard g(g_mu);
|
||||||
|
g_nrows = 0;
|
||||||
|
g_total_words = 0;
|
||||||
|
g_total_calls = 0;
|
||||||
|
g_other_words = 0;
|
||||||
|
g_other_calls = 0;
|
||||||
|
g_overflow = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::size_t draw_sites_snapshot(DrawSiteRow* out, std::size_t max) {
|
||||||
|
LockGuard g(g_mu);
|
||||||
|
const std::size_t n = g_nrows < max ? g_nrows : max;
|
||||||
|
for (std::size_t i = 0; i < n; ++i) out[i] = g_rows[i];
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::uint32_t draw_sites_total_words() {
|
||||||
|
LockGuard g(g_mu);
|
||||||
|
return g_total_words;
|
||||||
|
}
|
||||||
|
std::uint32_t draw_sites_total_calls() {
|
||||||
|
LockGuard g(g_mu);
|
||||||
|
return g_total_calls;
|
||||||
|
}
|
||||||
|
std::uint32_t draw_sites_other_words() {
|
||||||
|
LockGuard g(g_mu);
|
||||||
|
return g_other_words;
|
||||||
|
}
|
||||||
|
std::uint32_t draw_sites_other_calls() {
|
||||||
|
LockGuard g(g_mu);
|
||||||
|
return g_other_calls;
|
||||||
|
}
|
||||||
|
std::uint32_t draw_sites_overflow() {
|
||||||
|
LockGuard g(g_mu);
|
||||||
|
return g_overflow;
|
||||||
|
}
|
||||||
|
|
||||||
|
} // namespace shim::hooks
|
||||||
96
src/shim/hooks/draw_sites.h
Normal file
96
src/shim/hooks/draw_sites.h
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
// Per-call-site attribution of every strategic RNG word, by return address.
|
||||||
|
//
|
||||||
|
// The boundary ledger (rng_ledger.h + tail_rng.h) answers "how many words did this turn spend, and
|
||||||
|
// in which phase". It cannot answer "which call site spent them", because it deliberately never
|
||||||
|
// looks at calls. This does the other half: it detours the generator's **entry points** and records
|
||||||
|
// `__builtin_return_address(0)` — the instruction after the game's own `call` — together with the
|
||||||
|
// word cost of that one call, taken from `left` before and after.
|
||||||
|
//
|
||||||
|
// WHY THIS IS ATTRIBUTION AND NOT DISCOVERY. Lane I closed the search space
|
||||||
|
// (`sots-re/findings/control-flow/inlined-draws.md`): **seven** entry points, and eleven game
|
||||||
|
// functions carrying inlined draws over 28 sites, with recall proved by a brute byte scan finding
|
||||||
|
// zero orphans. Of those, `StrategyServer::ProcessTurn`'s direct-call closure contains exactly 22
|
||||||
|
// draw sites — 21 calls to an entry point plus one inlined site. Twenty-one of the twenty-two are
|
||||||
|
// therefore visible here by construction; the twenty-second (`EncounterDetect_AssignContacts`
|
||||||
|
// 0x007aa240) is covered by its own boundary hook in tail_rng.h, and the one inlined site under
|
||||||
|
// the combat resolver is covered by the `ApplyEncounterResult` boundary hook.
|
||||||
|
//
|
||||||
|
// **The check that makes this a fact rather than a model:** the per-site words must sum to the
|
||||||
|
// bracket total the boundary ledger measured independently. A shortfall is an unattributed word,
|
||||||
|
// and an unattributed word is evidence about the one thing lane I could not settle — indirect-call
|
||||||
|
// reachability.
|
||||||
|
//
|
||||||
|
// COST. Two 4-byte reads per draw, no hashing, no allocation, no record written. The table is a
|
||||||
|
// small fixed array scanned linearly; a turn touches a handful of entries.
|
||||||
|
//
|
||||||
|
// THE ONE ASSUMPTION, stated because it is the only place this can be wrong: a single call is taken
|
||||||
|
// to cross at most one block boundary, so `left_after > left_before` means exactly one twist. That
|
||||||
|
// holds for every entry point whose cost is bounded (all but two) and for a rejection loop unless
|
||||||
|
// it spends 624+ words in one call, which would be a 1-in-2^624 event for `NextInt`. `GaussianRange`
|
||||||
|
// is unbounded in attempts and is the one place the assumption could genuinely break; it is
|
||||||
|
// reachable from no turn driver by a direct call, and if it ever fires the boundary ledger's total
|
||||||
|
// will disagree with the site sum and say so.
|
||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <cstddef>
|
||||||
|
#include <cstdint>
|
||||||
|
|
||||||
|
namespace shim::hooks {
|
||||||
|
|
||||||
|
// The seven entry points, in the order lane I lists them.
|
||||||
|
enum class DrawEntry : std::uint8_t {
|
||||||
|
NextFloat, // 0x0047d830 thiscall(&mt) 1 word
|
||||||
|
NextInt, // 0x004271c0 thiscall(&mt, uint* bound) 1 + rejections
|
||||||
|
Chance, // 0x008e6dd0 thiscall(obj, float p) 0 or 1
|
||||||
|
NextUInt, // 0x004f7670 thiscall(obj) 1
|
||||||
|
FloatRange, // 0x0047d8a0 thiscall(obj, float, float) 1
|
||||||
|
IntRangeBell, // 0x008e6d80 cdecl(obj, int, int) >= 2
|
||||||
|
GaussianRange, // 0x008e6e30 cdecl(obj, int, int, int) 2 per attempt, unbounded
|
||||||
|
Count
|
||||||
|
};
|
||||||
|
|
||||||
|
const char* draw_entry_name(DrawEntry e);
|
||||||
|
|
||||||
|
struct DrawSiteRow {
|
||||||
|
std::uint32_t ret_rva = 0; // the game instruction after the call
|
||||||
|
DrawEntry entry = DrawEntry::Count;
|
||||||
|
std::uint32_t calls = 0;
|
||||||
|
std::uint32_t words = 0;
|
||||||
|
std::uint32_t zero_calls = 0; // calls that consumed nothing (only Chance's two early-outs)
|
||||||
|
// Whether the draw came from the STRATEGIC generator at StrategyServer+0x16c. The first run of
|
||||||
|
// this instrument recorded 44 words against a bracket of 18 because it counted every generator
|
||||||
|
// in the process; a site that draws from both appears as two rows.
|
||||||
|
bool strategic = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
// What main.cpp needs to install the detours with MinHook.
|
||||||
|
struct DrawSiteHook {
|
||||||
|
const char* name;
|
||||||
|
std::uint32_t rva;
|
||||||
|
void* detour;
|
||||||
|
void** trampoline;
|
||||||
|
};
|
||||||
|
const DrawSiteHook* draw_site_hooks(std::size_t* count);
|
||||||
|
|
||||||
|
// Call once before installing: the exe base, so return addresses are recorded as RVAs.
|
||||||
|
void init_draw_sites(std::uintptr_t exe_base);
|
||||||
|
|
||||||
|
// Told by the boundary hooks whenever they resolve StrategyServer+0x16c, so each recorded draw can
|
||||||
|
// say whether it came from the strategic generator or from another instance (the StrategyClient's
|
||||||
|
// at +0x134, the tactical CombatSim's at +0x108, or a map-generation temporary). Without this the
|
||||||
|
// per-site total cannot be reconciled against the bracket, because the bracket watches one object
|
||||||
|
// and the detours see them all.
|
||||||
|
void draw_sites_set_generator(const void* strategic_rng);
|
||||||
|
|
||||||
|
// Accumulator control. `reset` is called at the pre-turn autosave and `snapshot` at the post-turn
|
||||||
|
// one, so a snapshot covers exactly the interval the boundary ledger's bracket covers.
|
||||||
|
void draw_sites_reset();
|
||||||
|
std::size_t draw_sites_snapshot(DrawSiteRow* out, std::size_t max);
|
||||||
|
std::uint32_t draw_sites_total_words(); // strategic generator only
|
||||||
|
std::uint32_t draw_sites_total_calls(); // strategic generator only
|
||||||
|
std::uint32_t draw_sites_other_words(); // every other generator, summed
|
||||||
|
std::uint32_t draw_sites_other_calls();
|
||||||
|
// Sites seen since reset that did not fit in the table (a non-zero value invalidates the sum).
|
||||||
|
std::uint32_t draw_sites_overflow();
|
||||||
|
|
||||||
|
} // namespace shim::hooks
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
|
|
||||||
#include "generated/sots_addresses.h"
|
#include "generated/sots_addresses.h"
|
||||||
#include "mars/rng/mt19937.h"
|
#include "mars/rng/mt19937.h"
|
||||||
|
#include "shim/hooks/draw_sites.h"
|
||||||
#include "shim/hooks/rng_ledger.h"
|
#include "shim/hooks/rng_ledger.h"
|
||||||
|
|
||||||
namespace shim::hooks {
|
namespace shim::hooks {
|
||||||
|
|
@ -147,6 +148,9 @@ RngEntry observe_entry(void* self) {
|
||||||
if (!e.rng) return e;
|
if (!e.rng) return e;
|
||||||
e.pos = RngLedger::instance().observe_object(e.rng, kRngSize);
|
e.pos = RngLedger::instance().observe_object(e.rng, kRngSize);
|
||||||
e.have = true;
|
e.have = true;
|
||||||
|
// Tell the per-call-site detours which generator is the strategic one, so their rows can be
|
||||||
|
// split. Without it they count every RNG instance in the process and cannot reconcile.
|
||||||
|
draw_sites_set_generator(e.rng);
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -393,6 +397,33 @@ void StrategyHostAutosaveHook::describe_args(std::vector<Tv>& out, void* self, v
|
||||||
out.push_back(tv::ptr(from_this).named("host_plus_0x54"));
|
out.push_back(tv::ptr(from_this).named("host_plus_0x54"));
|
||||||
out.push_back(tv::boolean(from_this != nullptr && from_this == g_server).named("server_agrees"));
|
out.push_back(tv::boolean(from_this != nullptr && from_this == g_server).named("server_agrees"));
|
||||||
push_rng_args(out, g_autosave.entry);
|
push_rng_args(out, g_autosave.entry);
|
||||||
|
|
||||||
|
// The per-call-site ledger covers exactly the bracket: reset at the pre-turn marker, emitted at
|
||||||
|
// the post-turn one. Its total must equal the bracket total the region ledger measures
|
||||||
|
// independently; a shortfall is an unattributed word, which is the whole point of collecting it.
|
||||||
|
if (end_turn) {
|
||||||
|
draw_sites_reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DrawSiteRow rows[64];
|
||||||
|
const std::size_t n = draw_sites_snapshot(rows, sizeof rows / sizeof rows[0]);
|
||||||
|
std::vector<Tv> sites;
|
||||||
|
for (std::size_t i = 0; i < n; ++i) {
|
||||||
|
Tv one = tv::struct_();
|
||||||
|
one.add("entry", tv::str(draw_entry_name(rows[i].entry)));
|
||||||
|
one.add("ret_rva", tv::u32(rows[i].ret_rva));
|
||||||
|
one.add("calls", tv::u32(rows[i].calls));
|
||||||
|
one.add("words", tv::u32(rows[i].words));
|
||||||
|
one.add("no_draw_calls", tv::u32(rows[i].zero_calls));
|
||||||
|
one.add("strategic", tv::boolean(rows[i].strategic));
|
||||||
|
sites.push_back(std::move(one));
|
||||||
|
}
|
||||||
|
out.push_back(tv::list(std::move(sites)).named("draw_sites"));
|
||||||
|
out.push_back(tv::u32(draw_sites_total_words()).named("draw_site_words"));
|
||||||
|
out.push_back(tv::u32(draw_sites_total_calls()).named("draw_site_calls"));
|
||||||
|
out.push_back(tv::u32(draw_sites_other_words()).named("draw_site_words_other_rng"));
|
||||||
|
out.push_back(tv::u32(draw_sites_other_calls()).named("draw_site_calls_other_rng"));
|
||||||
|
out.push_back(tv::u32(draw_sites_overflow()).named("draw_site_overflow"));
|
||||||
}
|
}
|
||||||
|
|
||||||
Tv StrategyHostAutosaveHook::describe_ret(void* r) { return tv::ptr(r); }
|
Tv StrategyHostAutosaveHook::describe_ret(void* r) { return tv::ptr(r); }
|
||||||
|
|
@ -670,6 +701,84 @@ void NodeLineDecayHook::coverage(trace::Coverage& c) {
|
||||||
"");
|
"");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- Game::EncounterDetect::AssignContacts -------------------------------------------------------
|
||||||
|
|
||||||
|
namespace {
|
||||||
|
struct AssignState {
|
||||||
|
RngEntry entry;
|
||||||
|
std::int32_t detectors = -1;
|
||||||
|
std::int32_t contacts = -1;
|
||||||
|
};
|
||||||
|
AssignState g_assign;
|
||||||
|
|
||||||
|
std::int32_t ptr_vector_size(void* v) {
|
||||||
|
if (!readable(v, 8)) return -1;
|
||||||
|
const char* first = static_cast<const char*>(ptr_at(v, 0));
|
||||||
|
const char* last = static_cast<const char*>(ptr_at(v, 4));
|
||||||
|
if (!first || !last || last < first) return -1;
|
||||||
|
return static_cast<std::int32_t>((last - first) / 4);
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
|
void EncounterDetectAssignContactsHook::describe_args(std::vector<Tv>& out, void* self,
|
||||||
|
void* buckets, void* det, void* con) {
|
||||||
|
// ctx = {StrategyServer* S, TechDef*, TechDef*}; the generator is the server's, as always.
|
||||||
|
void* server = readable(self, 4) ? ptr_at(self, 0) : nullptr;
|
||||||
|
g_assign.entry = observe_entry(server);
|
||||||
|
g_assign.detectors = ptr_vector_size(det);
|
||||||
|
g_assign.contacts = ptr_vector_size(con);
|
||||||
|
|
||||||
|
out.push_back(tv::ptr(self).named("ctx"));
|
||||||
|
out.push_back(tv::ptr(server).named("server"));
|
||||||
|
out.push_back(tv::ptr(buckets).named("out_buckets"));
|
||||||
|
out.push_back(tv::i32(g_assign.detectors).named("detectors"));
|
||||||
|
out.push_back(tv::i32(g_assign.contacts).named("contacts"));
|
||||||
|
// Lane I's worst case: one inlined NextFloat per (contact, detector) trial, drawn BEFORE the
|
||||||
|
// accept test, so an unteched detector (threshold 0.0f) still costs the word. Recorded as a
|
||||||
|
// bound, not a prediction -- the accept short-circuits the inner loop, so the measured cost
|
||||||
|
// should be at most this.
|
||||||
|
const std::int32_t bound = (g_assign.detectors > 0 && g_assign.contacts > 0)
|
||||||
|
? g_assign.detectors * g_assign.contacts
|
||||||
|
: -1;
|
||||||
|
out.push_back(tv::i32(bound).named("max_trials"));
|
||||||
|
push_rng_args(out, g_assign.entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EncounterDetectAssignContactsHook::regions(std::vector<trace::Region>& out, void*, void*,
|
||||||
|
void*, void*) {
|
||||||
|
push_rng_region(out, g_assign.entry.rng);
|
||||||
|
}
|
||||||
|
|
||||||
|
EncounterDetectAssignContactsHook::Args EncounterDetectAssignContactsHook::rebind(
|
||||||
|
trace::Scratch&, void* self, void* buckets, void* det, void* con) {
|
||||||
|
return Args(self, buckets, det, con);
|
||||||
|
}
|
||||||
|
|
||||||
|
void EncounterDetectAssignContactsHook::ours(void* self, void* buckets, void* det, void* con) {
|
||||||
|
using H = trace::Hook<EncounterDetectAssignContactsHook>;
|
||||||
|
if (H::mode == trace::Mode::Replace) {
|
||||||
|
refuse_replace("EncounterDetect::AssignContacts");
|
||||||
|
if (H::original) H::original(self, buckets, det, con);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void EncounterDetectAssignContactsHook::coverage(trace::Coverage& c) {
|
||||||
|
tail_rng_common_coverage(c);
|
||||||
|
c.unmodelled("the contact-to-detector assignment itself, and the two-pass outer loop",
|
||||||
|
trace::Risk::Medium,
|
||||||
|
"this hook exists because the draw here is INLINED and therefore invisible to "
|
||||||
|
"every call-graph sweep and to the entry-point detours -- it is the one site in "
|
||||||
|
"ProcessTurn's closure that neither instrument can see. It measures the word "
|
||||||
|
"cost of the whole call and models nothing",
|
||||||
|
"region:rng; arg:detectors/contacts/max_trials bound the expected count");
|
||||||
|
c.unmodelled("the per-trial threshold is 0.25f or 0.0f depending on two tech lookups, and the "
|
||||||
|
"accept test short-circuits the inner loop",
|
||||||
|
trace::Risk::Low,
|
||||||
|
"so the measured cost is between |contacts| and |contacts| x |detectors| and the "
|
||||||
|
"exact number depends on tech state this hook does not read",
|
||||||
|
"arg:max_trials is the upper bound only");
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Game::StrategyServer::ProcessNodeSpaceTravel ------------------------------------------------
|
// ---- Game::StrategyServer::ProcessNodeSpaceTravel ------------------------------------------------
|
||||||
|
|
||||||
void ProcessNodeSpaceTravelHook::describe_args(std::vector<Tv>& out, void* self) {
|
void ProcessNodeSpaceTravelHook::describe_args(std::vector<Tv>& out, void* self) {
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,32 @@ struct NodeLineDecayHook {
|
||||||
static void coverage(trace::Coverage& c);
|
static void coverage(trace::Coverage& c);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// `EncounterDetect_AssignContacts` 0x007aa240, `ret 0xc` (lane I). **The only game function with an
|
||||||
|
// inlined MT draw in `StrategyServer::ProcessTurn`'s closure** — one `NextFloat` per (contact,
|
||||||
|
// detector) trial, drawn BEFORE the accept test, so it draws even when the threshold is 0.0f and
|
||||||
|
// nothing is ever assigned. It leaves no call-graph edge except a bare `RNG_Twist`, which is the
|
||||||
|
// site method rule 16 was written for. Reached only through `EncounterDetect_Run` 0x007cb080 from
|
||||||
|
// `StrategyServer::DetectEncounters`, the last phase of `ProcessTurn`.
|
||||||
|
//
|
||||||
|
// `|detectors|` and `|contacts|` are recorded as arguments so lane I's expected cost
|
||||||
|
// (`|contacts| x |detectors|` trials at worst) becomes checkable against the measured word count
|
||||||
|
// rather than asserted.
|
||||||
|
struct EncounterDetectAssignContactsHook {
|
||||||
|
static constexpr const char* name = "Game::EncounterDetect::AssignContacts";
|
||||||
|
static constexpr trace::CallConv conv = trace::CallConv::Thiscall;
|
||||||
|
using Ret = void;
|
||||||
|
using Args = std::tuple<void*, void*, void*, void*>; // ctx, outBuckets, detectors, contacts
|
||||||
|
|
||||||
|
static void describe_args(std::vector<trace::Tv>& out, void* self, void* buckets, void* det,
|
||||||
|
void* con);
|
||||||
|
static void regions(std::vector<trace::Region>& out, void* self, void* buckets, void* det,
|
||||||
|
void* con);
|
||||||
|
static Args rebind(trace::Scratch& s, void* self, void* buckets, void* det, void* con);
|
||||||
|
static void ours(void* self, void* buckets, void* det, void* con);
|
||||||
|
static trace::HookPolicy policy() { return trace::HookPolicy{}; }
|
||||||
|
static void coverage(trace::Coverage& c);
|
||||||
|
};
|
||||||
|
|
||||||
// `ProcessNodeSpaceTravel` 0x007a0e20, run TWICE a turn (ProcessTurn phase 7 and tail phase 10)
|
// `ProcessNodeSpaceTravel` 0x007a0e20, run TWICE a turn (ProcessTurn phase 7 and tail phase 10)
|
||||||
// and never swept for draws by any lane.
|
// and never swept for draws by any lane.
|
||||||
struct ProcessNodeSpaceTravelHook {
|
struct ProcessNodeSpaceTravelHook {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@
|
||||||
#include "shim/hooks/fleet_movement.h"
|
#include "shim/hooks/fleet_movement.h"
|
||||||
#include "shim/hooks/global_consts.h"
|
#include "shim/hooks/global_consts.h"
|
||||||
#include "shim/hooks/research.h"
|
#include "shim/hooks/research.h"
|
||||||
|
#include "shim/hooks/draw_sites.h"
|
||||||
#include "shim/hooks/tail_rng.h"
|
#include "shim/hooks/tail_rng.h"
|
||||||
#include "shim/hooks/tech_effects.h"
|
#include "shim/hooks/tech_effects.h"
|
||||||
#include "shim/trace/hook.h"
|
#include "shim/trace/hook.h"
|
||||||
|
|
@ -174,6 +175,7 @@ using CombatDoneTailHook = shim::trace::Hook<shim::hooks::OnAllCombatDoneTailHoo
|
||||||
using ApplyEncounterHook = shim::trace::Hook<shim::hooks::ApplyEncounterResultHook>;
|
using ApplyEncounterHook = shim::trace::Hook<shim::hooks::ApplyEncounterResultHook>;
|
||||||
using NodeDecayHook = shim::trace::Hook<shim::hooks::NodeLineDecayHook>;
|
using NodeDecayHook = shim::trace::Hook<shim::hooks::NodeLineDecayHook>;
|
||||||
using NodeSpaceHook = shim::trace::Hook<shim::hooks::ProcessNodeSpaceTravelHook>;
|
using NodeSpaceHook = shim::trace::Hook<shim::hooks::ProcessNodeSpaceTravelHook>;
|
||||||
|
using AssignContactsHook = shim::trace::Hook<shim::hooks::EncounterDetectAssignContactsHook>;
|
||||||
|
|
||||||
void InstallHooks(shim::trace::Tracer& tracer) {
|
void InstallHooks(shim::trace::Tracer& tracer) {
|
||||||
const uintptr_t exeBase = reinterpret_cast<uintptr_t>(GetModuleHandleA(nullptr));
|
const uintptr_t exeBase = reinterpret_cast<uintptr_t>(GetModuleHandleA(nullptr));
|
||||||
|
|
@ -238,6 +240,28 @@ void InstallHooks(shim::trace::Tracer& tracer) {
|
||||||
InstallTemplateHook<shim::hooks::ApplyEncounterResultHook>(tracer, exeBase, sots::addr::StrategyServer_ApplyEncounterResult);
|
InstallTemplateHook<shim::hooks::ApplyEncounterResultHook>(tracer, exeBase, sots::addr::StrategyServer_ApplyEncounterResult);
|
||||||
InstallTemplateHook<shim::hooks::NodeLineDecayHook>(tracer, exeBase, sots::addr::StrategyServer_NodeLineDecay);
|
InstallTemplateHook<shim::hooks::NodeLineDecayHook>(tracer, exeBase, sots::addr::StrategyServer_NodeLineDecay);
|
||||||
InstallTemplateHook<shim::hooks::ProcessNodeSpaceTravelHook>(tracer, exeBase, sots::addr::StrategyServer_ProcessNodeSpaceTravel);
|
InstallTemplateHook<shim::hooks::ProcessNodeSpaceTravelHook>(tracer, exeBase, sots::addr::StrategyServer_ProcessNodeSpaceTravel);
|
||||||
|
// Lane I's one inlined-draw site inside ProcessTurn's closure. It leaves no call-graph edge, so
|
||||||
|
// neither a sweep nor the entry-point detours below can see it; only a boundary hook can.
|
||||||
|
InstallTemplateHook<shim::hooks::EncounterDetectAssignContactsHook>(tracer, exeBase, sots::addr::EncounterDetect_AssignContacts);
|
||||||
|
|
||||||
|
// Per-call-site attribution: detour the SEVEN generator entry points and record
|
||||||
|
// __builtin_return_address(0) with the word cost of each call. These are NOT template hooks --
|
||||||
|
// they write no record and take no snapshot, just two 4-byte reads of `left` -- because
|
||||||
|
// NextFloat alone has 109 call sites and a record per draw would drown the log. The table is
|
||||||
|
// emitted once per turn on the post-turn autosave, where it can be reconciled against the
|
||||||
|
// bracket total the region ledger measured independently.
|
||||||
|
shim::hooks::init_draw_sites(exeBase);
|
||||||
|
{
|
||||||
|
std::size_t nsites = 0;
|
||||||
|
const shim::hooks::DrawSiteHook* sites = shim::hooks::draw_site_hooks(&nsites);
|
||||||
|
for (std::size_t i = 0; i < nsites; ++i) {
|
||||||
|
void* t = reinterpret_cast<void*>(exeBase + sites[i].rva);
|
||||||
|
MH_STATUS s1 = MH_CreateHook(t, sites[i].detour, sites[i].trampoline);
|
||||||
|
MH_STATUS s2 = s1 == MH_OK ? MH_EnableHook(t) : s1;
|
||||||
|
Log("drawsite: %s rva=0x%08x -> va=%p create=%s enable=%s", sites[i].name, sites[i].rva,
|
||||||
|
t, MH_StatusToString(s1), MH_StatusToString(s2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Lane F: x87 control-word forcing at the turn gate + the per-tick change sampler.
|
// Lane F: x87 control-word forcing at the turn gate + the per-tick change sampler.
|
||||||
// Installed last so it is nowhere near the template hooks it is meant to measure.
|
// Installed last so it is nowhere near the template hooks it is meant to measure.
|
||||||
|
|
@ -295,6 +319,7 @@ void Shim_Init(HMODULE self) {
|
||||||
ApplyEncounterHook::register_policy(tracer);
|
ApplyEncounterHook::register_policy(tracer);
|
||||||
NodeDecayHook::register_policy(tracer);
|
NodeDecayHook::register_policy(tracer);
|
||||||
NodeSpaceHook::register_policy(tracer);
|
NodeSpaceHook::register_policy(tracer);
|
||||||
|
AssignContactsHook::register_policy(tracer);
|
||||||
// A hook that never stated what it does not check is a defect, not a detail: say so in
|
// A hook that never stated what it does not check is a defect, not a detail: say so in
|
||||||
// shim.log as well as in the trace's meta line (docs/harness-audit.md).
|
// shim.log as well as in the trace's meta line (docs/harness-audit.md).
|
||||||
for (const std::string& h : tracer.unstated_hooks())
|
for (const std::string& h : tracer.unstated_hooks())
|
||||||
|
|
|
||||||
|
|
@ -38,3 +38,4 @@ fpu.sample_ticks=off
|
||||||
trace.inline_max=64
|
trace.inline_max=64
|
||||||
trace.path=C:\SOTS\shim.trace.jsonl
|
trace.path=C:\SOTS\shim.trace.jsonl
|
||||||
trace.flush=always
|
trace.flush=always
|
||||||
|
hook.Game::EncounterDetect::AssignContacts=trace
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue