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.