Compare commits
2 commits
dea127d4e1
...
45b16ef626
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45b16ef626 | ||
|
|
55408a980a |
7 changed files with 579 additions and 1 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 <cstdint>
|
||||
|
|
@ -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<int> 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.<id>.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]
|
||||
|
|
|
|||
|
|
@ -162,6 +162,45 @@ struct ColonizeOrder {
|
|||
int trailing = 0;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// A CONTRACT FOR WHOEVER BUILDS THE FLEET GATHER
|
||||
//
|
||||
// These take `fleetId` as a parameter: this layer emits orders and does not form fleets or mint
|
||||
// ids. The subsystem that does is not written yet, and when it is, it inherits a requirement that
|
||||
// was measured rather than guessed, so it is recorded here where it cannot be missed.
|
||||
//
|
||||
// In the original, two processes running the same turn from the same save with the AI's seeds
|
||||
// pinned produce different saves -- 35 leaves of 61,147, and all 35 are one transposition: two
|
||||
// fleets exchange contents. The command blocks differ in exactly three words. The id counter is
|
||||
// deterministic; the ids are minted in the same order every time. What varies is WHICH GROUP EACH
|
||||
// ID LANDS ON, because the pass that assigns them walks a container ordered by
|
||||
// `fleet->Location` -- a HEAP POINTER, with the fleet pointer breaking ties. Under LFH
|
||||
// randomisation two allocations of the same size land in either relative order, so the original's
|
||||
// whole observed outcome set on that turn is the two orderings of two heap pointers.
|
||||
//
|
||||
// Three consequences bind us:
|
||||
//
|
||||
// 1. **Order the groups by a key that is a function of the save, and mint in that order.**
|
||||
// `(systemId, then minimum ship id)` with a total tie-break. Any deterministic key would do:
|
||||
// the original's order is NOT ascending systemId and NOT ascending minimum ship id in any
|
||||
// process observed, so THERE IS NO ORIGINAL ORDER TO MATCH -- we are choosing one member of
|
||||
// its outcome set, not reproducing a hidden rule. Say so; do not let a later reader mistake
|
||||
// this for a recovered behaviour.
|
||||
// 2. **Impose it where the groups are gathered and the ids are minted, not at assignment.**
|
||||
// The fleets already carry their new ids when the assignment walk first touches them, so
|
||||
// canonicalising at assignment is one level too low and fixes nothing.
|
||||
// 3. **A byte-exact replay needs the original's order, and the block already carries it.**
|
||||
// List 10 records `{systemId, fleetId, ships[]}` per group in visit order, so every captured
|
||||
// run states its own order for free. An optional override that replays a captured order is
|
||||
// what turns "equal modulo a relabelling" into "equal" -- three words per new fleet, the same
|
||||
// cost class as pinning a seed.
|
||||
//
|
||||
// Evidence: sots-re `findings/control-flow/fleet-visit-order-mechanism.md` (the container and its
|
||||
// key, measured across three processes including a branch flip),
|
||||
// `findings/control-flow/pinned-seed-rich-turn.md` (the residue),
|
||||
// `findings/resolutions/2026-09-09-fleet-id-order-residue.md` (the ruling).
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
// List 8. The route is a counted vector of system ids, so a multi-hop order is longer on the wire
|
||||
// than a single-hop one -- but it is still ONE element and therefore ONE counter bump.
|
||||
struct FleetMoveOrder {
|
||||
|
|
|
|||
334
src/shim/hooks/ai_visit.cpp
Normal file
334
src/shim/hooks/ai_visit.cpp
Normal file
|
|
@ -0,0 +1,334 @@
|
|||
#include "shim/hooks/ai_visit.h"
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstddef>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#if defined(_WIN32)
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
|
||||
#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<void*>(p), n);
|
||||
#else
|
||||
(void)n;
|
||||
return p != 0;
|
||||
#endif
|
||||
}
|
||||
|
||||
template <typename T>
|
||||
inline volatile T* Opaque(std::uintptr_t p) {
|
||||
auto* q = reinterpret_cast<volatile T*>(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<std::uint32_t>(p) : 0u;
|
||||
}
|
||||
|
||||
// StarFleet layout, from `ghidra/addresses.json` (lane B5 / lane P2): id at +0x04, owner at +0x58,
|
||||
// Location* at +0xa0, ships `std::vector<StarShip*>` 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<std::uint32_t>((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::size_t>(
|
||||
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<unsigned>(g_callSeq), static_cast<unsigned>(idx),
|
||||
static_cast<unsigned>(fleet), static_cast<unsigned>(fid), static_cast<unsigned>(nships),
|
||||
static_cast<unsigned>(loc), static_cast<unsigned>(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<std::uint32_t>((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<unsigned>(g_callSeq), static_cast<unsigned>(args[0]),
|
||||
static_cast<unsigned>(args[1]), static_cast<int>(args[2]), static_cast<unsigned>(vec),
|
||||
static_cast<unsigned>(begin), static_cast<unsigned>(end), static_cast<unsigned>(n),
|
||||
static_cast<unsigned>(args[4]), static_cast<unsigned>(args[5]), static_cast<int>(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::size_t>(std::snprintf(slots + w, sizeof slots - w,
|
||||
i ? " 0x%08x" : "0x%08x",
|
||||
static_cast<unsigned>(U32(begin + i * 4))));
|
||||
LogF("aivisit slots call=%u [%s]", static_cast<unsigned>(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<unsigned>(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<void*>(&AiVisitStubCall), &g_trAiVisitCall},
|
||||
{"StrategyAIAgent::ClaimShipsOfFleet [head loop, one caller]",
|
||||
A::StrategyAIAgent_ClaimShipsOfFleet, reinterpret_cast<void*>(&AiVisitStubClaim),
|
||||
&g_trAiVisitClaim},
|
||||
{"StrategyAIAgent::ReleaseShipsOfFleet [tail loop, one caller]",
|
||||
A::StrategyAIAgent_ReleaseShipsOfFleet, reinterpret_cast<void*>(&AiVisitStubRel),
|
||||
&g_trAiVisitRel},
|
||||
};
|
||||
for (const Target& t : targets) {
|
||||
void* addr = reinterpret_cast<void*>(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<unsigned>(g_callSeq), static_cast<unsigned>(g_claimTotal),
|
||||
static_cast<unsigned>(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
|
||||
70
src/shim/hooks/ai_visit.h
Normal file
70
src/shim/hooks/ai_visit.h
Normal file
|
|
@ -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<StarFleet*>`, 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 <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace shim::hooks {
|
||||
|
||||
// `aivisit=on|off`, `aivisit.out=<path>`, `aivisit.ships=<n>` (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
|
||||
|
|
@ -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()) {
|
||||
|
|
|
|||
118
src/shim/shim.cfg.bupin
Normal file
118
src/shim/shim.cfg.bupin
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Reference in a new issue