From 55408a980a7dc55d12ff02a8535425a1869f3009 Mon Sep 17 00:00:00 2001 From: alex Date: Wed, 9 Sep 2026 03:10:52 -0400 Subject: [PATCH] shim: aivisit, the fleet-assignment visit-order log (lane BU) Three function-entry detours, off unless `aivisit=on`: * StrategyAIAgent::AssignFleetsAndIssueOrders -- the bracket, recording (agent, task, pass, walked vector, its slots in index order); * StrategyAIAgent::ClaimShipsOfFleet -- the head loop, one row per element; * StrategyAIAgent::ReleaseShipsOfFleet -- the tail loop, one row per element. The two per-element targets have exactly ONE caller each in the whole image and that caller is the third target, so the module's traffic cannot come from anywhere else in the game and no return-address filter is needed. The element loop's own body is deliberately not patched: its first instruction is a branch target, which is the shape rule 19 exists for. Each element row carries the element address (the hypothesis under test), the fleet's id, its ship ids and its Location pointer. The ship ids are the join key to the command block's list 10; the join must not be by position because the walk can visit more elements than it emits commands for. Reads of game addresses are probed before every access and laundered through a register, so a wrong offset logs a zero instead of faulting inside a detour. Also: shim.cfg.bupin, which is shim.cfg.bppin plus exactly three non-comment lines, and is marked `# exhaustive` so tools/check_shim_configs.py enforces that every registered hook is named in it. --- CMakeLists.txt | 1 + include/generated/sots_addresses.h | 8 +- src/shim/hooks/ai_visit.cpp | 334 +++++++++++++++++++++++++++++ src/shim/hooks/ai_visit.h | 70 ++++++ src/shim/main.cpp | 10 + src/shim/shim.cfg.bupin | 118 ++++++++++ 6 files changed, 540 insertions(+), 1 deletion(-) create mode 100644 src/shim/hooks/ai_visit.cpp create mode 100644 src/shim/hooks/ai_visit.h create mode 100644 src/shim/shim.cfg.bupin diff --git a/CMakeLists.txt b/CMakeLists.txt index 93efd06..5e6a079 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -106,6 +106,7 @@ if(WIN32) src/shim/hooks/probe_entry.cpp src/shim/hooks/ai_orders.cpp src/shim/hooks/ai_rng.cpp + src/shim/hooks/ai_visit.cpp src/shim/hooks/watchpoints.cpp) # `minhook` is here for its include directory: lane H's probe_entry.cpp installs its own # detours (MH_CreateHook/MH_EnableHook) rather than handing descriptors back to main.cpp, diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index d827abc..4bc7698 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -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 @ 2ad6652, generated 2026-09-09 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ aa8d3fb, generated 2026-09-09 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -1325,6 +1325,12 @@ constexpr uint32_t ServerSpyManager_DetectionRoll_DrawSite = 0x00487c8a; constexpr uint32_t SpyCraft_ResetMission = 0x00438070; // data void*[7], the switch jump table SpeciesDef_InitTable 0x005453a0 dispatches through at 0x005453eb (`jmp [eax*4+0x545b60]`) to choose the `Species/%s/...` data-path prefix. READ AS BYTES, the seven entries are 0x005453f2 "Human", 0x005453f9 "Hiver", 0x00545400 "Tarkas", 0x00545407 "Liir", 0x0054541c "_NPC", 0x0054540e "Zuul", 0x00545415 "Morrigi" -- so the species enum is 0 Human, 1 HIVER, 2 TARKAS, 3 LIIR, 4 _NPC, 5 Zuul, 6 MORRIGI. NOTE the case bodies are NOT laid out in case order (index 4 jumps PAST index 5's body), so reading the disassembly top to bottom gives the wrong enum; only the table bytes settle it. SpeciesDef_Get 0x00545cc0 bounds-checks k <= 6, so there are exactly seven. This confirms lane AR's species 0 = Human / species 5 = Zuul from an independent direction and names 1, 2, 3, 4 and 6 for the first time [verified] constexpr uint32_t SpeciesDef_NameJumpTable = 0x00145b60; +// thiscall void __thiscall Game::StrategyAIAgent::ClaimShipsOfFleet(StrategyAIAgent* agent /*ecx*/, StarFleet* fleet /*stack*/) -- EXACTLY ONE CALLER in the whole image, 0x006c1735, the FIRST loop of AssignFleetsAndIssueOrders (0x006c1730-0x006c1740), which walks `param_4` once at function entry before any pass gate. Body: n = ([fleet+0xa8] - [fleet+0xa4]) (the ships vector, StarFleet_off_Ships), and for each StarShip* s in it, push_back(s ? [s+4] : 0) onto the agent's int vector at agent+0x2d8 -- a linear scan of that vector first (0x006a42e0) so an id already present is not added twice, then the MSVC push_back grow path (capacity check against 0x3ffffffe, 0x00483410 to reallocate). `[s+4]` is StarShip's id, the same word StarFleet_off_Id names on a fleet. BECAUSE IT HAS ONE CALLER AND IS CALLED ONCE PER ELEMENT AT THE HEAD OF THE WALK, a detour on it records the acquired-fleet vector in visit order with no return-address filter and no mid-function patch: it is the whole instrument of lane BU's stage-2 probe [verified] +constexpr uint32_t StrategyAIAgent_ClaimShipsOfFleet = 0x002a4290; +// thiscall void __thiscall Game::StrategyAIAgent::ReleaseShipsOfFleet(StrategyAIAgent* agent /*ecx*/, StarFleet* fleet /*stack*/) -- RET 4. EXACTLY ONE CALLER, 0x006c1765, the second loop of AssignFleetsAndIssueOrders (0x006c1760-0x006c1770), which is entered at 0x006c1753 AFTER the element loop has walked the vector to exhaustion (`jmp 0x6c1753` at 0x006c21c4). The exact inverse of ClaimShipsOfFleet: for each StarShip* s in the fleet's ships vector it finds [s+4] in the agent's int vector at agent+0x2d8, memmoves the tail down (import 0x009dd30c) and does `[agent+0x2dc] -= 4`. Same one-call-per-element shape over the SAME vector in the SAME order, so it is a free second witness of the visit order taken after the loop rather than before it [verified] +constexpr uint32_t StrategyAIAgent_ReleaseShipsOfFleet = 0x0029da10; +// offset std::vector claimed ship ids (_Myfirst @+0x2d8, _Mylast @+0x2dc, _Myend @+0x2e0). Read and written ONLY by the claim/release pair above, both of which are exclusive to AssignFleetsAndIssueOrders: the fleets acquired for a task have their ships' ids parked here for the duration of the assignment walk and removed when it is done. Enumerated from the push_back grow path at 0x006a4338-0x006a4356 (which reads +0x2e0 as _Myend via `mov ecx,[esi+8]` with esi = agent+0x2d8) and the erase path at 0x0069da63-0x0069da81 [verified] +constexpr uint32_t StrategyAIAgent_off_ClaimedShipIds = 0x000002d8; // thiscall void (Game::StrategyAIAgent::Streamable* this, Mars::Stream* s) // the body of every `Player..AIAgent` CD block. 36 wire items, NO conditionals: the only `if` the decompiler shows around `lnat` is an inlined std::vector destructor whose operator delete is marked noreturn, and both paths converge at 0x006c72e8. The agent object is *(this+4) [verified] constexpr uint32_t Game_StrategyAIAgent_Streamable_Write = 0x002c6f00; // thiscall bool (Game::StrategyAIAgent::Streamable* this, Mars::Stream* s) [verified] diff --git a/src/shim/hooks/ai_visit.cpp b/src/shim/hooks/ai_visit.cpp new file mode 100644 index 0000000..6fcc186 --- /dev/null +++ b/src/shim/hooks/ai_visit.cpp @@ -0,0 +1,334 @@ +#include "shim/hooks/ai_visit.h" + +#include +#include +#include +#include +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include + +#include "MinHook.h" +#endif + +#include "generated/sots_addresses.h" + +namespace shim::hooks { + +namespace { + +namespace A = sots::addr; + +bool g_enabled = false; +unsigned g_shipCap = 8; +#if defined(_WIN32) +char g_outPath[MAX_PATH] = {}; +#else +char g_outPath[260] = {}; +#endif +FILE* g_out = nullptr; +void (*g_log)(const char*) = nullptr; + +// Sequence numbers. The turn pipeline is single-threaded (lane Z), and a torn counter here would +// cost a row's label, not a crash -- and a wild number would itself be the finding. +std::uint32_t g_callSeq = 0; // one per AssignFleetsAndIssueOrders entry +std::uint32_t g_claimIdx = 0; // element index within the current head-loop burst +std::uint32_t g_relIdx = 0; // element index within the current tail-loop burst +std::uint32_t g_claimTotal = 0; +std::uint32_t g_relTotal = 0; + +void LogF(const char* fmt, ...) { + char buf[2048]; + va_list ap; + va_start(ap, fmt); + std::vsnprintf(buf, sizeof buf, fmt, ap); + va_end(ap); + if (g_out) { + std::fputs(buf, g_out); + std::fputc('\n', g_out); + std::fflush(g_out); + } else if (g_log) { + g_log(buf); + } +} + +// ---- guarded reads of game addresses ------------------------------------------------------------ +// +// Same discipline as `ai_orders.cpp`: probe before every read so a wrong offset produces a logged +// zero rather than a fault inside a detour, and launder the integer through a register so gcc +// cannot reason about it as an object and trip -Werror=array-bounds. + +inline bool Readable(std::uintptr_t p, std::size_t n) { +#if defined(_WIN32) + return p != 0 && !IsBadReadPtr(reinterpret_cast(p), n); +#else + (void)n; + return p != 0; +#endif +} + +template +inline volatile T* Opaque(std::uintptr_t p) { + auto* q = reinterpret_cast(p); +#if defined(__GNUC__) + asm volatile("" : "+r"(q)); +#endif + return q; +} + +inline std::uint32_t U32(std::uintptr_t p) { + return Readable(p, 4) ? *Opaque(p) : 0u; +} + +// StarFleet layout, from `ghidra/addresses.json` (lane B5 / lane P2): id at +0x04, owner at +0x58, +// Location* at +0xa0, ships `std::vector` with _Myfirst at +0xa4 and _Mylast at +0xa8. +// StarShip's id is at +0x04, the same word -- read by the game itself at 0x006a42d4 and 0x0069da41. +constexpr std::uint32_t kOffFleetId = 0x04; +constexpr std::uint32_t kOffFleetLoc = 0xa0; +constexpr std::uint32_t kOffShipsFirst = 0xa4; +constexpr std::uint32_t kOffShipsLast = 0xa8; +constexpr std::uint32_t kOffShipId = 0x04; + +// One element row. `kind` is "claim" (head loop) or "rel" (tail loop). +void EmitElement(const char* kind, std::uint32_t idx, std::uintptr_t agent, std::uintptr_t fleet) { + const std::uint32_t fid = U32(fleet + kOffFleetId); + const std::uint32_t loc = U32(fleet + kOffFleetLoc); + const std::uintptr_t first = U32(fleet + kOffShipsFirst); + const std::uintptr_t last = U32(fleet + kOffShipsLast); + const std::uint32_t nships = + (last >= first && first != 0) ? static_cast((last - first) / 4) : 0u; + + char ships[512]; + std::size_t w = 0; + ships[0] = '\0'; + const std::uint32_t shown = nships < g_shipCap ? nships : g_shipCap; + for (std::uint32_t i = 0; i < shown && w + 16 < sizeof ships; ++i) { + const std::uintptr_t sp = U32(first + i * 4); + const std::uint32_t sid = sp ? U32(sp + kOffShipId) : 0u; + w += static_cast( + std::snprintf(ships + w, sizeof ships - w, i ? " %u" : "%u", sid)); + } + LogF("aivisit %s call=%u idx=%u elem=0x%08x fid=%u nships=%u loc=0x%08x agent=0x%08x ships=[%s]%s", + kind, static_cast(g_callSeq), static_cast(idx), + static_cast(fleet), static_cast(fid), static_cast(nships), + static_cast(loc), static_cast(agent), ships, + shown < nships ? " (truncated)" : ""); +} + +} // namespace + +// ---- the three C entry points the stubs call ---------------------------------------------------- + +extern "C" void AiVisitCallHit(const std::uint32_t* args) { + if (!g_enabled) return; + ++g_callSeq; + g_claimIdx = 0; + g_relIdx = 0; + // __cdecl: agent, task, pass, fleets, targetA, targetB, flag. `fleets` is the walked vector. + const std::uintptr_t vec = args[3]; + const std::uintptr_t begin = U32(vec + 0); + const std::uintptr_t end = U32(vec + 4); + const std::uint32_t n = + (end >= begin && begin != 0) ? static_cast((end - begin) / 4) : 0u; + LogF("aivisit call=%u agent=0x%08x task=0x%08x pass=%d vec=0x%08x begin=0x%08x end=0x%08x n=%u " + "targetA=0x%08x targetB=0x%08x flag=%d", + static_cast(g_callSeq), static_cast(args[0]), + static_cast(args[1]), static_cast(args[2]), static_cast(vec), + static_cast(begin), static_cast(end), static_cast(n), + static_cast(args[4]), static_cast(args[5]), static_cast(args[6])); + // The vector's own storage, printed in index order. This is the same order the element loop + // will follow, and it is recorded independently of the two per-element detours so that a + // disagreement between them is visible instead of being resolved in favour of either. + if (n && n <= 64) { + char slots[1024]; + std::size_t w = 0; + slots[0] = '\0'; + for (std::uint32_t i = 0; i < n && w + 16 < sizeof slots; ++i) + w += static_cast(std::snprintf(slots + w, sizeof slots - w, + i ? " 0x%08x" : "0x%08x", + static_cast(U32(begin + i * 4)))); + LogF("aivisit slots call=%u [%s]", static_cast(g_callSeq), slots); + } +} + +extern "C" void AiVisitClaimHit(std::uint32_t fleet, std::uint32_t agent) { + if (!g_enabled) return; + ++g_claimTotal; + EmitElement("claim", g_claimIdx++, agent, fleet); +} + +extern "C" void AiVisitRelHit(std::uint32_t fleet, std::uint32_t agent) { + if (!g_enabled) return; + ++g_relTotal; + EmitElement("rel", g_relIdx++, agent, fleet); +} + +} // namespace shim::hooks + +// ---- the stubs ---------------------------------------------------------------------------------- +// +// Register-transparent, in the style lane H established and lane L4 reused: everything saved, the +// arguments read where the callee will read them, nothing written, then a tail jump to the +// trampoline so the original's own `ret` (or `ret 4`) stays in charge of stack cleanup. That makes +// the hook correct for any calling convention, which matters here because two of the three targets +// take `this` in ECX and their argument on the stack. +// +// After `pushfl` (4) + `pushal` (32) the return address is at esp+36 and the first stack argument +// at esp+40. `pushal` stores edi, esi, ebp, esp, ebx, edx, ecx, eax in that order from esp, so the +// caller's ECX is at esp+24. All loads happen BEFORE any push, so the offsets above are the ones +// that apply. + +#if defined(_WIN32) + +extern "C" void* g_trAiVisitCall; +void* g_trAiVisitCall = nullptr; +extern "C" void AiVisitStubCall(void); +asm(".text\n" + ".globl _AiVisitStubCall\n" + "_AiVisitStubCall:\n" + " pushfl\n" + " pushal\n" + " leal 40(%esp), %eax\n" // &args[0] -- the seven __cdecl arguments, unmodified + " pushl %eax\n" + " call _AiVisitCallHit\n" + " addl $4, %esp\n" + " popal\n" + " popfl\n" + " jmp *_g_trAiVisitCall\n"); + +extern "C" void* g_trAiVisitClaim; +void* g_trAiVisitClaim = nullptr; +extern "C" void AiVisitStubClaim(void); +asm(".text\n" + ".globl _AiVisitStubClaim\n" + "_AiVisitStubClaim:\n" + " pushfl\n" + " pushal\n" + " movl 40(%esp), %eax\n" // StarFleet* (the one stack argument) + " movl 24(%esp), %edx\n" // StrategyAIAgent* (the caller's ECX) + " pushl %edx\n" + " pushl %eax\n" + " call _AiVisitClaimHit\n" + " addl $8, %esp\n" + " popal\n" + " popfl\n" + " jmp *_g_trAiVisitClaim\n"); + +extern "C" void* g_trAiVisitRel; +void* g_trAiVisitRel = nullptr; +extern "C" void AiVisitStubRel(void); +asm(".text\n" + ".globl _AiVisitStubRel\n" + "_AiVisitStubRel:\n" + " pushfl\n" + " pushal\n" + " movl 40(%esp), %eax\n" + " movl 24(%esp), %edx\n" + " pushl %edx\n" + " pushl %eax\n" + " call _AiVisitRelHit\n" + " addl $8, %esp\n" + " popal\n" + " popfl\n" + " jmp *_g_trAiVisitRel\n"); + +#endif // _WIN32 + +namespace shim::hooks { + +bool ai_visit_config(const char* key, const char* value, std::string* err) { + (void)err; + if (std::strcmp(key, "aivisit") == 0) { + g_enabled = std::strcmp(value, "on") == 0 || std::strcmp(value, "1") == 0; + return true; + } + if (std::strcmp(key, "aivisit.out") == 0) { + std::snprintf(g_outPath, sizeof g_outPath, "%s", value); + return true; + } + if (std::strcmp(key, "aivisit.ships") == 0) { + char* end = nullptr; + const long n = std::strtol(value, &end, 10); + if (end == value || n < 0) { + if (err) *err = "expected a decimal count"; + return true; + } + g_shipCap = static_cast(n > 64 ? 64 : n); + return true; + } + return false; +} + +void install_ai_visit(std::uintptr_t exeBase, const char* gameDir, void (*log)(const char*)) { + g_log = log; + if (!g_enabled) return; +#if defined(_WIN32) + if (!g_outPath[0]) std::snprintf(g_outPath, sizeof g_outPath, "%s\\shim.aivisit.txt", gameDir); + g_out = std::fopen(g_outPath, "w"); + if (!g_out) LogF("aivisit: cannot open %s -- output goes to shim.log only", g_outPath); + if (g_log) { + char line[MAX_PATH + 64]; + std::snprintf(line, sizeof line, "aivisit: out=%s ships=%u", g_outPath, g_shipCap); + g_log(line); + } + + struct Target { + const char* name; + std::uint32_t rva; + void* stub; + void** tramp; + }; + const Target targets[] = { + {"StrategyAIAgent::AssignFleetsAndIssueOrders [bracket]", + A::StrategyAIAgent_AssignFleetsAndIssueOrders, + reinterpret_cast(&AiVisitStubCall), &g_trAiVisitCall}, + {"StrategyAIAgent::ClaimShipsOfFleet [head loop, one caller]", + A::StrategyAIAgent_ClaimShipsOfFleet, reinterpret_cast(&AiVisitStubClaim), + &g_trAiVisitClaim}, + {"StrategyAIAgent::ReleaseShipsOfFleet [tail loop, one caller]", + A::StrategyAIAgent_ReleaseShipsOfFleet, reinterpret_cast(&AiVisitStubRel), + &g_trAiVisitRel}, + }; + for (const Target& t : targets) { + void* addr = reinterpret_cast(exeBase + t.rva); + MH_STATUS s1 = MH_CreateHook(addr, t.stub, t.tramp); + MH_STATUS s2 = s1 == MH_OK ? MH_EnableHook(addr) : s1; + if (g_log) { + char line[512]; + std::snprintf(line, sizeof line, + "aivisit: %s rva=0x%08x va=%p create=%s enable=%s", t.name, t.rva, addr, + MH_StatusToString(s1), MH_StatusToString(s2)); + g_log(line); + } + if (s1 != MH_OK || s2 != MH_OK) { + if (g_log) + g_log("COVERAGE: an aivisit detour is NOT INSTALLED -- this run's visit order is " + "incomplete, not empty"); + } + } +#else + (void)exeBase; + (void)gameDir; +#endif +} + +void ai_visit_flush(void (*log)(const char*)) { + if (!g_enabled) return; + char line[256]; + std::snprintf(line, sizeof line, + "aivisit: calls=%u claim_rows=%u rel_rows=%u (claim and rel must agree per call)", + static_cast(g_callSeq), static_cast(g_claimTotal), + static_cast(g_relTotal)); + if (log) log(line); + if (g_out) { + std::fputs(line, g_out); + std::fputc('\n', g_out); + std::fflush(g_out); + std::fclose(g_out); + g_out = nullptr; + } +} + +} // namespace shim::hooks diff --git a/src/shim/hooks/ai_visit.h b/src/shim/hooks/ai_visit.h new file mode 100644 index 0000000..eac69a2 --- /dev/null +++ b/src/shim/hooks/ai_visit.h @@ -0,0 +1,70 @@ +// Lane BU -- the order in which the fleet-assignment pass visits the groups it assigns. +// +// WHY THIS HOOK EXISTS. +// +// With the three AI client seeds pinned to one natural run's observed values, two fresh processes +// on the campaign's richest AI turn still write different autosaves: 35 leaves of 61,147, and all +// 35 are one transposition -- two newly formed fleets exchange their contents. The ids themselves +// are not the variable (the client id counter hands out the same three ids in the same order in +// every process); what varies is the ORDER in which the assignment pass visits the ship groups. +// A per-client draw ledger then excluded RNG by measurement: zero foreign draws in any AI client's +// turn, and the per-process global generator drawn exactly three times in a whole process, all +// three to mint the client seeds. So the remaining candidates are an address-keyed container (the +// visit order is the heap order of the walked elements) or a comparator reading an uninitialised +// word (the order is a function of neither). This module separates them. +// +// WHAT IT HOOKS, AND WHY NOT THE LOOP ITSELF. +// +// The walk is the element loop of `StrategyAIAgent::AssignFleetsAndIssueOrders`: a cursor over a +// `std::vector`, advanced by four and compared against a `_Mylast` refetched every +// iteration, so the visit order IS the vector's index order. Patching the loop body would mean a +// mid-function detour whose first byte is a branch target, which is exactly the shape rule 19 was +// written for. It is unnecessary: the loop is bracketed by two other loops over the SAME vector, +// each of which calls exactly one function, once per element, in the same order -- +// +// * `ClaimShipsOfFleet` -- the head loop, walked once at function entry before any pass gate; +// * `ReleaseShipsOfFleet` -- the tail loop, walked after the element loop is exhausted. +// +// Both have EXACTLY ONE CALLER in the whole image, and that caller is the assignment function. So +// two ordinary function-entry detours record the vector in visit order with no return-address +// filter, no mid-function patch, and no traffic from anywhere else in the game. The tail loop is +// a free second witness: if the two orders agree, the vector was not permuted during the walk. +// +// A third detour brackets them on the assignment function's own entry, so each burst of element +// rows is attributed to (agent, task, pass) instead of being inferred from where the bursts fall. +// `pass` matters: the emitting body runs on pass 1 only, and the head loop runs on both passes. +// +// WHAT EACH ROW CARRIES, AND WHY. +// +// The element address is the hypothesis under test. The rest is the join key: this instrument has +// to be paired with the AI command block's list 10 (`{systemId, fleetId, ships[]}`), which records +// what each visited group actually RECEIVED, and the pairing must not be by position -- the walk +// can visit more elements than it emits commands for. So every row carries the fleet's own id and +// the ids of the ships it holds, and the join to list 10 is by ship-id set. +// +// WHAT THIS INSTRUMENT CANNOT DO, stated because it is the price of the design: +// +// * It records the order of a vector, not the identity of whatever filled it. If the order is +// address-ordered, the container that produced it is still one step away and lives in the +// gathering hub. +// * A tail-jumping stub never regains control, so nothing here reports a return value. +// * Element reads are of addresses the game handed us. Every one is probed before it is read, +// so a wrong offset logs a zero instead of faulting inside a detour (method rule 1). +#pragma once + +#include +#include + +namespace shim::hooks { + +// `aivisit=on|off`, `aivisit.out=`, `aivisit.ships=` (ship ids per row, default 8). +// Returns false if `key` is not ours. +bool ai_visit_config(const char* key, const char* value, std::string* err); + +// Installs the three detours. No-op unless `aivisit=on`. Call after MH_Initialize. +void install_ai_visit(std::uintptr_t exeBase, const char* gameDir, void (*log)(const char*)); + +// Writes the summary and closes the output file. Safe to call at shutdown. +void ai_visit_flush(void (*log)(const char*)); + +} // namespace shim::hooks diff --git a/src/shim/main.cpp b/src/shim/main.cpp index a4ba1d3..61f03f6 100644 --- a/src/shim/main.cpp +++ b/src/shim/main.cpp @@ -30,6 +30,7 @@ #include "shim/hooks/tech_effects.h" #include "shim/hooks/ai_orders.h" #include "shim/hooks/ai_rng.h" +#include "shim/hooks/ai_visit.h" #include "shim/hooks/watchpoints.h" #include "shim/trace/hook.h" #include "shim/trace/selftest.h" @@ -95,6 +96,9 @@ Config ReadConfig() { } else if (shim::hooks::ai_rng_config(p, val, &err)) { if (!err.empty()) Log("config: %s=%s rejected (%s)", p, val, err.c_str()); else Log("config: %s=%s", p, val); + } else if (shim::hooks::ai_visit_config(p, val, &err)) { + if (!err.empty()) Log("config: %s=%s rejected (%s)", p, val, err.c_str()); + else Log("config: %s=%s", p, val); } else if (shim::hooks::ai_orders_config(p, val, &err)) { if (!err.empty()) Log("config: %s=%s rejected (%s)", p, val, err.c_str()); else Log("config: %s=%s", p, val); @@ -329,6 +333,11 @@ void InstallHooks(shim::trace::Tracer& tracer) { // rather than hooking the seven entry points a second time (MinHook: one hook per target). // Its target is a client event handler, shared with nothing else in the shim. shim::hooks::install_ai_rng(exeBase, g_dir, &ShimLogLine); + // Lane BU: the fleet-assignment visit-order log. Off unless `aivisit=on`. Three detours, all + // of them function entries; two of the three targets have exactly ONE caller in the image and + // that caller is the third, so this module's traffic cannot come from anywhere else in the + // game. See ai_visit.h for why the loop body itself is not patched. + shim::hooks::install_ai_visit(exeBase, g_dir, &ShimLogLine); // 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. @@ -439,6 +448,7 @@ void Shim_Shutdown() { shim::hooks::watch_flush(&ShimLogLine); shim::hooks::ai_orders_flush(&ShimLogLine); shim::hooks::ai_rng_flush(&ShimLogLine); + shim::hooks::ai_visit_flush(&ShimLogLine); Log("%s", shim::fpu::summary().c_str()); shim::trace::Tracer& tracer = shim::trace::Tracer::instance(); if (tracer.is_open()) { diff --git a/src/shim/shim.cfg.bupin b/src/shim/shim.cfg.bupin new file mode 100644 index 0000000..1694c53 --- /dev/null +++ b/src/shim/shim.cfg.bupin @@ -0,0 +1,118 @@ +# Lane BU -- stage 2 of the fleet-visit-order chain: the container hook, runs 1 and 2. +# Derived from shim.cfg.bppin (via lane BR's shim.cfg.brprobe, which is bppin plus airng). +# exhaustive +# +# THE PROBE. `ad-turn27-two-raiders.sav` is the richest AI turn the campaign owns: thirteen ships +# complete into five new fleets, a design is created, a system is colonised. Two `hooks=off` +# processes on it differ in 94 leaves (lane AD, `raid-gate-multiplicity.md` section 5). Row 360 +# proved that PINNING the AI client seeds collapses three processes to one -- but only on +# `turn1-state`, an early-game turn. If the seed is the only per-process input on a RICH turn too, +# these two runs are byte-identical. If they are not, reading 3 of the resolution is alive, and +# C-exact ("nothing else per-process reaches the turn") is false on the turns that matter. +# +# THE MECHANISM IS THE CONSTRUCTOR-ARGUMENT OVERWRITE, NOT A RE-SEED. `aiseed=pin` replaces the +# fourth argument of `Game::StrategyApp::RunAI` -- the word the `StrategyClient` constructor hands +# to `RNG_Seed` -- before the callee reads it. It is NOT `airng.pin_seed`, which re-seeds the +# generator at bracket entry and is a declared perturbation; using that here would test a +# different thing and the resolution says so in as many words. +# +# THE PIN SET IS COMPLETE BY CONSTRUCTION. Every net id run L observed is listed explicitly, AND +# the wildcard is set, so a client with an id nobody has seen is still pinned instead of silently +# passing through unpinned. A partial pin set is worse than none: the run would look pinned and +# be half-natural. +# +# HOOK SET: identical to shim.cfg.bplog except the `aiseed=` line and the values. +hooks=trace +trace.path=C:\SOTS\shim.trace.jsonl +trace.flush=always +probes=off +watch=off +# LANE BU (2026-09-09), stage 2 of the visit-order chain. The ONLY change from shim.cfg.bppin +# is exactly three non-comment lines: `aivisit=on` plus its two settings. `airng` stays at +# bppin's own value (`off`): lane BR already took the per-client draw ledger on this state and +# it came back clean -- zero foreign words in every AI bracket, the global generator drawn three +# times in a whole process -- so RNG is excluded by measurement and re-installing that bracket +# here would be three more detours for no answer. +# +# `aivisit=on` installs THREE detours, all function entries: +# StrategyAIAgent::AssignFleetsAndIssueOrders -- the bracket, for (agent, task, pass, vector) +# StrategyAIAgent::ClaimShipsOfFleet -- the head loop, ONE caller in the image +# StrategyAIAgent::ReleaseShipsOfFleet -- the tail loop, ONE caller in the image +# The two per-element targets are called from nowhere else in the game, so no return-address +# filter is needed and no traffic reaches this log from outside the assignment walk. The loop +# body itself is deliberately NOT patched: its first instruction is a branch target. +# +# `aivisit.ships=8` bounds the per-row ship-id list. The list is the JOIN KEY to the command +# block's list 10 (`{systemId, fleetId, ships[]}`); the join must not be by position, because the +# walk can visit more elements than it emits commands for. +airng=off +aidesign=off +aivisit=on +aivisit.out=C:\SOTS\shim.aivisit.txt +aivisit.ships=8 + +# EVERY template hook, off by name. `Config::mode_for` falls through to `default_mode` for any +# hook NOT named here, and `default_mode` is `trace`, so an omission from this list silently +# leaves that hook INSTALLED. (`shim.cfg.cbpin` omits six of them -- see the finding.) +hook.Shim::SelfTest::Fill=off +hook.Mars::GlobalConsts::LoadFile=off +hook.Game::WeaponDictionary::Init=off +hook.Game::SectionDictionary::SectionDictionary=off +hook.Game::TechTree::ProcessResearch=off +hook.Game::ServerPlayer::OnTechResearched=off +hook.Game::ServerPlayer::ComputeBudget=off +hook.Game::ServerSystem::ProcessTurn=off +hook.Game::ServerSystem::GroupOutput=off +hook.Game::ServerSystem::ComputeTotalOutput=off +hook.Game::ServerPlayer::ProcessTurn=off +hook.Game::StrategyServer::MoveFleet=off +hook.Game::StrategyServer::ProcessFleetMovement=off +hook.Game::StrategyHost::Autosave=off +hook.Game::StrategyServer::ProcessTurn=off +hook.Game::StrategyServer::OnAllCombatDone_Tail=off +hook.Game::StrategyServer::ApplyEncounterResult=off +hook.Game::StrategyServer::NodeLineDecay=off +hook.Game::StrategyServer::ProcessNodeSpaceTravel=off +hook.Game::EncounterDetect::AssignContacts=off +hook.Game::EncounterDetect::ProcessTeamRecord=off +hook.Game::StrategyServer::BeginProcessTurn=off +hook.Game::SVSOSwarmQueen::OnTurnBegin=off +hook.Game::SVSOSwarmQueen::RegisterHives=off +hook.Game::SVSOSwarmQueen::TickHives=off +hook.Game::SVSOSlaversRefuel::UpdateDifficultyTier=off +hook.Mars::RNG::Seed=off +# The trace framework installs its own template hook on RunAI (lane L1's seed probe) and MinHook +# refuses a second detour on one target (`MH_ERROR_ALREADY_CREATED`). Off here so the ONE detour +# on RunAI is the aiseed module's. +hook.Game::StrategyApp::RunAI=off + +# The command-block dump. `aiorders=on` alone installs exactly one detour +# (StrategySim::ApplyTurnCommandBatch); `aiprobes=` would add more, and does not. +# `aiorders.deep=off`: the deep follower chases heap pointers whose VALUES are per-process, which +# would put per-process noise in the very log used to localise a per-process difference. +aiorders=on +aiorders.out=C:\SOTS\shim.aiorders.txt +aiorders.words=12 +aiorders.deep=off +aiprobes=off +airesearch=off + +aiseed=pin +# The values run L observed for itself on THIS save, on this guest, with this build, at +# 2026-09-09 01:20:53 local -- so the pinned runs reproduce a turn that actually happened +# rather than a synthetic one (lane CB's rule). +# +# RUN L CORRECTS THE BRIEF: the save has seven non-human players but the engine constructs only +# THREE AI clients. `RunAI` fired exactly three times, for net ids 32 (The Eternal Empire), 496 +# and 512 (both "Spengler", RebelAI). The four NPC factions -- 528 Alien Menace, 544 Peacekeeper +# Enforcer, 560 Von Neumann, 576 Independent Colony -- get no AI client, no RunAI call and no +# seed. A seven-id pin list would have been three pins and four dead entries. +# +# The wildcard is deliberately a value that appears nowhere else: if a fourth client ever shows +# up it is still pinned (no silent pass-through) AND its log line reads `used=0xdeadbeef`, so it +# can never be mistaken for a natural seed. +aiseed.values=32=156ebbbd,496=fe7b2826,512=0ed341d1,*=deadbeef +# +# With `aiseed=pin` and no values the module logs "PIN MODE WITH NO PINS -- every seed passes +# through unchanged, so this run is NOT pinned and must not be reported as one". Read that, and +# the three `aiseed call=... pinned=1` lines, in shim.log before trusting any run.