L4: research-selection capture -- candidate stream in arrival order, and the fallback probes that separate a tie from a rotation
This commit is contained in:
parent
ec8d841dba
commit
ca0de05e07
6 changed files with 284 additions and 5 deletions
|
|
@ -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 @ 2d61848, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Source: sots-re ghidra/addresses.json @ d1710e9, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
|
|
@ -1951,10 +1951,26 @@ constexpr uint32_t AITBuildDeepScanShips_Execute = 0x002901a0;
|
|||
constexpr uint32_t AITBuildPoliceShips_Execute = 0x00290380;
|
||||
// thiscall bool __thiscall Game::StrategyClient::OrderList16(int objectId, bool flag) -- RET 8. THE LIST-16 PRODUCER. Opens with `cmp byte [this+0x15c], 0` -- the End-Turn latch StrategyClient::EndTurn 0x00783be0 sets at 0x00783c59 before BuildTurnCommands runs -- so like every other order method it refuses once the turn has been submitted. Element record for list 16 is {i32, bool} (lane Q), matching the (objectId, flag) pair; list 16 is in the PAYING half of the ModCount table (bump at 0x00821e23 in its applier 0x00821e20). Reached from AITRaid::Execute at depth 1. The method is named for the list it fills, NOT for what the flag means: no save has ever carried a list-16 element and nothing has been observed [unverified]
|
||||
constexpr uint32_t StrategyClient_OrderList16 = 0x003635f0;
|
||||
// offset DifficultyMods rec -- the per-player difficulty record, 0x1c bytes: {int id; float ai[3]; float other[3]}. THE RECORD IS PER-PLAYER AND REACHABLE FROM A ServerPlayer, which is the correction this lane makes: src/shim/hooks/compute_budget.h's coverage note says the difficulty row is 'not reachable from a ServerPlayer, so the two relevant entries are fitted constants measured from the B1 trace rather than snapshotted inputs'. It is reachable. DifficultyMods_Select 0x0059b490 is called as `Select(p->+0x36c, p)` by every one of the three consumers (ComputeBudget 0x0086338b, StrategyServer_IncomeDifficultyMod 0x0080f470, 0x0080e229), and LoadDifficultyRow 0x005a3990 fills it from ServerPlayer::Read 0x008804d0 at 0x00880fa3 gated on 0 <= aidf < 3 [verified]
|
||||
constexpr uint32_t ServerPlayer_off_DiffMods = 0x0000036c;
|
||||
// offset int aidf -- the difficulty level (0..2) stored by ServerPlayer::Read 0x008804d0 at 0x00880fa3, the selector LoadDifficultyRow 0x005a3990 uses to pick the row it copies into ServerPlayer+0x36c. It sits immediately below the DifficultyMods record it selects [verified]
|
||||
// thiscall void* __thiscall Game::StrategyAIAgent::SelectResearchTarget(void** outSlot) -- RET 4, the THIRD and last research producer tried by Process Turn phase 18 (0x006caf70), reached only when 0x006a84f0 and 0x006c27c0 both answer null. Body: (1) `if (agent->+0x10->+0x150 /*ClientPlayer*/ ->+0x294 != 0) return 0` -- a player that already has a target picks nothing; (2) build a std::vector of 0x0c-stride candidates with 0x006c2490(agent, &vec); (3) walk it FRONT TO BACK calling 0x006c8580(ecx = outSlot, edx = cand[1], stack: agent, cand[0]) and return the FIRST non-null answer; (4) if none answered, fall through to the three-arm rotation seeded by 0x006b36e0(agent) & 7 over the .data tables at 0x00a1a544 / 0x00a1a564. THERE IS NO SORT AND NO SCORE IN THE WALK: the vector's arrival order IS the priority, which is what makes a per-process ordering difference change the outcome [unverified]
|
||||
constexpr uint32_t StrategyAIAgent_SelectResearchTarget = 0x002c8890;
|
||||
// cdecl void __cdecl Game::StrategyAIAgent::BuildResearchCandidates(StrategyAIAgent* agent, std::vector<Candidate>* out) -- constructs a working object on the stack (0x006bcca0(agent)), emits the candidate vector into `out` (0x006bc500), destroys it (0x006bcef0). Element stride 0x0c. The emitter is a nested walk over groups with NO std::sort at that level, so the output order is the walk order [unverified]
|
||||
constexpr uint32_t StrategyAIAgent_BuildResearchCandidates = 0x002c2490;
|
||||
// fastcall void* __fastcall Game::StrategyAIAgent::TryResearchCandidate(void** outSlot /*ecx*/, int candWord1 /*edx*/, StrategyAIAgent* agent, int candWord0) -- called once per candidate in arrival order by SelectResearchTarget; the FIRST call that returns non-null ends the walk and that return value is the chosen tech object (its +0x4 is the std::string the order method is given). An entry probe on this address therefore records the whole candidate stream in the order the selector sees it [unverified]
|
||||
constexpr uint32_t StrategyAIAgent_TryResearchCandidate = 0x002c8580;
|
||||
// cdecl void* __cdecl (StrategyAIAgent* agent) -- the FIRST research producer tried by phase 18. Body unread; probed for reachability only, because a non-null answer here means the candidate walk never runs [unverified]
|
||||
constexpr uint32_t StrategyAIAgent_ResearchProducerA = 0x002a84f0;
|
||||
// thiscall void* __thiscall (StrategyAIAgent* this) -- the SECOND research producer tried by phase 18, entered with `mov eax,esi; call` (the receiver is in EAX at the call site, so the real convention is not confirmed). Body unread; probed for reachability only [unverified]
|
||||
constexpr uint32_t StrategyAIAgent_ResearchProducerB = 0x002c27c0;
|
||||
// thiscall int __thiscall (StrategyAIAgent* this) -- SelectResearchTarget's fallback index source, masked with 0x80000007 and used to index the eight-entry .data tables at 0x00a1a544 (2 0 2 0 2 1 2 0) and 0x00a1a564 (0 2 0 2 1 0 2 0). It is NOT an RNG draw: it reads ClientPlayer+0xf4 and calls 0x0080da80. A hit here means the research pick came from the three-arm rotation, not from the candidate list [unverified]
|
||||
constexpr uint32_t StrategyAIAgent_ResearchFallbackIndex = 0x002b36e0;
|
||||
// fastcall void* __fastcall (void** outSlot /*ecx*/, StrategyAIAgent* agent) -- one arm of SelectResearchTarget's three-arm fallback rotation; the arm index is (i + table[seed]) % 3 for i = 0,1,2 and the first non-null answer wins. Its outcome space is therefore at most three [unverified]
|
||||
constexpr uint32_t StrategyAIAgent_ResearchFallbackArm = 0x002c8670;
|
||||
// offset int aidf -- the difficulty level (0..2) stored by ServerPlayer::Read 0x008804d0 at 0x00880fa3, the selector LoadDifficultyRow 0x005a3990 uses to pick the row it copies for this player. VERIFIED LIVE on VM146: read at ComputeBudget entry on every one of the 8 players of turn1-state across 3,895 calls, it is 1 on all eight, which is the difficulty level the whole corpus carries and the level whose AI column holds the 1.1x income modifier [verified]
|
||||
constexpr uint32_t ServerPlayer_off_Aidf = 0x00000368;
|
||||
// offset DifficultyMods* -- a POINTER to the per-player difficulty record, NOT the record inline. This entry exists to correct a lane L5 mistake and to save the next lane the run. addresses.json already records DifficultyMods_Select 0x0059b490 being called as Select(p->diffMods /*+0x36c*/, p), and lane L5 read the 0x1c bytes AT +0x36c expecting {int id; float ai[3]; float other[3]}. They are not that: the first dword is 0x0da0ec20..0x0da13360 on the eight players of turn1-state -- eight distinct heap addresses within 18 KB of each other and of the ServerPlayer allocations themselves (the player object was at 0x0da08600) -- and the floats behind it decode as denormal garbage (1.7e-38, 1.4e-43). So the field is a pointer and the record is one dereference further on. HYPOTHESIS, not verified: this run did not follow the pointer, so it is consistent with +0x36c being a pointer to the DifficultyMods record and equally consistent with its being some other per-player sub-object pointer. What IS established is the thing the reading was for: the difficulty row is reachable from a ServerPlayer, so compute_budget.h's old coverage note ('not reachable from a ServerPlayer, so the two relevant entries are fitted constants') is wrong as written. The fitted pair {3.0, 1.5} / {1.0, 1.0} remains UNVERIFIED against the running game; the next run should dereference this field behind a readability guard and compare the selected triple against those constants [hypothesis]
|
||||
constexpr uint32_t ServerPlayer_off_DiffModsPtr = 0x0000036c;
|
||||
// data double 0x3FEB333340000000 = 0.85000002384185791 = (double)0.85f -- the research-yield factor, multiplied in by ComputeBudget at 0x00863601 as a QWORD operand. THE WIDTH IS THE FINDING, and what makes it a rule-23 case rather than a curiosity is its two NEIGHBOURS in the same product: 1.15 at 0x00a1a4b8 and 0.5 at 0x009e20a0 are stored as EXACT doubles, while this one is a widened float. Three literals, one expression, two widths -- so a C++ port that writes all three as decimals is wrong in exactly one place. The exact-decimal product (money/50 x 1.15 x 0.5 x 0.85) is integral when researchMoney is a multiple of 40,000, and the image's larger constant lands one ABOVE it there: at 40,000 the game gives 391 research points and the decimal gives 390. NOT VISIBLE ON THE CORPUS, and that is now measured rather than argued: the live compare run presented 9 distinct researchMoney values across 3,895 calls and NONE of them is a multiple of 40,000. Read live out of the running process as well, so nothing patched or relocated it [verified]
|
||||
constexpr uint32_t g_ResearchYieldFactor = 0x0062d818;
|
||||
// thiscall double (ServerSystem* sys, double overHarvestRate) // `ret 8`, real end 0x007505a5. Returns 0 when the system has no owner (+0x100) or rbfl (+0x1dc) is non-zero. Otherwise the SUM of three terms -- the over-harvest demand x SpeciesDef+0x50, (TRes + available resources) x StripMineFraction x 0.9, and the population output of groups 0, 1 and 2 -- multiplied in one uninterrupted 80-bit chain by player OutMod, sys.OutMod, player +0x224, RebOutMod, ScOutMod, and finally by ADDICTION_OUTPUT_MOD when the addiction phase is >= 3. SIDE-EFFECT FREE: it and all seven callees were checked for stores to the game state (0x0074a6d0's only writes are through its int64 out-parameters), which is why this and not ComputeOutputFromRates is the compare target -- that one repairs ships in orbit [verified]
|
||||
constexpr uint32_t ServerSystem_ComputeTotalOutput = 0x00350480;
|
||||
// thiscall double (ServerSystem* sys, int groupType, int species, double count) // `ret 0x10`, real end 0x0074b871. THE population -> output term: returns 0 for count <= 0, else max(0, POPTYPE[groupType].outputMod x (stationFactor x 1.8) x moraleMod x (count / 500000)). stationFactor is 1 + stations x STATION_BONUS_IMPERIAL_OUTPUT and applies to groupType 0 of an owned system only (and only while that constant is > 0); moraleMod applies to groupType 1 only. So output points per head are typeOutputMod x 1.8 / 500000 -- exactly 3.6e-6 for an imperial population with no stations [verified]
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ namespace {
|
|||
|
||||
bool g_enabled = false;
|
||||
std::size_t g_probeInstallCount = 0; // `aiprobes=`; default off, so `aiorders=on` alone is ONE detour
|
||||
bool g_research = false; // `airesearch=`; the three research-selection dump hooks
|
||||
char g_outPath[MAX_PATH] = {};
|
||||
FILE* g_out = nullptr;
|
||||
void (*g_log)(const char*) = nullptr;
|
||||
|
|
@ -82,7 +83,7 @@ std::uint32_t g_batchSeq = 0;
|
|||
|
||||
// ---- the entry probes ---------------------------------------------------------------------------
|
||||
|
||||
constexpr std::size_t kMaxProbes = 16;
|
||||
constexpr std::size_t kMaxProbes = 24;
|
||||
volatile std::uint32_t g_calls[kMaxProbes]; // since the last batch dump
|
||||
volatile std::uint32_t g_total[kMaxProbes]; // since process start
|
||||
volatile std::uint32_t g_byPass[kMaxProbes][3]; // pass 0, pass 1, anything else (incl. stale)
|
||||
|
|
@ -212,6 +213,10 @@ AI_PROBE_STUB(12);
|
|||
AI_PROBE_STUB(13);
|
||||
AI_PROBE_STUB(14);
|
||||
AI_PROBE_STUB(15);
|
||||
AI_PROBE_STUB(16);
|
||||
AI_PROBE_STUB(17);
|
||||
AI_PROBE_STUB(18);
|
||||
AI_PROBE_STUB(19);
|
||||
#undef AI_PROBE_STUB
|
||||
|
||||
namespace {
|
||||
|
|
@ -247,6 +252,16 @@ const ProbeDef kProbes[] = {
|
|||
A::StrategyAIAgent_IsClaimedByAnotherTask, 14),
|
||||
// 15: one per submitting block -- the block count seen from the client side.
|
||||
AI_PROBE("StrategyClient::BuildTurnCommands [control]", A::StrategyClient_BuildTurnCommands, 15),
|
||||
// 16-17: the two research producers phase 18 tries BEFORE the candidate walk. If either of
|
||||
// these answers, the walk never runs and the candidate set is not where the answer comes from.
|
||||
AI_PROBE("AIResearch::ProducerA", A::StrategyAIAgent_ResearchProducerA, 16),
|
||||
AI_PROBE("AIResearch::ProducerB", A::StrategyAIAgent_ResearchProducerB, 17),
|
||||
// 18-19: the FALLBACK, reached only when the candidate walk accepts nothing. Its index source
|
||||
// reads player state rather than the generator, and it rotates over three arms -- so a hit
|
||||
// here means the outcome space is <= 3 by construction and is NOT a tie in a candidate list.
|
||||
// This pair is the discriminator between the two mechanisms, and it is why they are probed.
|
||||
AI_PROBE("AIResearch::FallbackIndex", A::StrategyAIAgent_ResearchFallbackIndex, 18),
|
||||
AI_PROBE("AIResearch::FallbackArm", A::StrategyAIAgent_ResearchFallbackArm, 19),
|
||||
};
|
||||
#undef AI_PROBE
|
||||
|
||||
|
|
@ -402,6 +417,176 @@ extern "C" void AiOnBatch(void* blocksv, int n) {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// ---- the research-selection capture (lane L4 addendum) -------------------------------------------
|
||||
//
|
||||
// WHY THIS IS A DUMPER AND NOT THREE MORE COUNTERS.
|
||||
//
|
||||
// Exactly one AI decision in the reference game is not reproducible run to run: one shadow empire's
|
||||
// research target (lane L5). If that pick is a TIE broken by something per-process, the original's
|
||||
// possible outcomes form a small enumerable set, and a deterministic reimplementation can pick
|
||||
// canonically and claim membership of that set -- which is a stronger claim than "behaviourally
|
||||
// equivalent" and keeps a byte match reachable whenever the tiebreaks agree. Naming the set needs
|
||||
// the candidate list, in the order the selector sees it. A counter cannot give that.
|
||||
//
|
||||
// `SelectResearchTarget 0x006c8890` walks a vector of 0x0c-stride candidates FRONT TO BACK and
|
||||
// takes the first one `TryResearchCandidate 0x006c8580` accepts. There is no sort and no score in
|
||||
// the walk, so the vector's order IS the priority. One entry stub per candidate therefore records
|
||||
// the whole stream in arrival order; the LAST call before the walk ends is the accepted one.
|
||||
//
|
||||
// Three stubs, all register-transparent:
|
||||
// * SelectResearchTarget -- the per-player delimiter, and it prints the current target word so a
|
||||
// player that returns immediately is distinguishable from one that walks an empty list;
|
||||
// * TryResearchCandidate -- one line per candidate, with both candidate words and whatever a
|
||||
// std::string at +4 of either resolves to, which is how a tech gets a name here;
|
||||
// * cl_SetResearchTarget -- the outcome, which is the only place the chosen NAME is in a
|
||||
// register (phase 18 resolves the short-string union and pushes the char*).
|
||||
//
|
||||
// What it cannot do: a tail-jumping stub never sees a return value, so "the last candidate tried"
|
||||
// is the accepted one only when the walk actually accepted something -- and the fallback probes
|
||||
// (rows 18/19) are what say whether it did. Read the three together.
|
||||
|
||||
namespace {
|
||||
std::uint32_t g_researchSeq = 0;
|
||||
int g_candIdx = 0;
|
||||
|
||||
// MSVC std::string (0x1c): union _Bx at +0, _Mysize +0x10, _Myres +0x14; short strings live in the
|
||||
// union. Prints nothing rather than guessing when the shape does not validate.
|
||||
void ReadStdString(std::uintptr_t s, char* out, std::size_t cap) {
|
||||
out[0] = '\0';
|
||||
if (!Readable(s, 0x18)) return;
|
||||
const std::uint32_t len = U32(s + 0x10);
|
||||
const std::uint32_t res = U32(s + 0x14);
|
||||
if (len == 0 || len > 0x80 || res < len) return;
|
||||
const std::uintptr_t p = (res < 16) ? s : static_cast<std::uintptr_t>(U32(s));
|
||||
if (!Readable(p, len)) return;
|
||||
std::size_t n = len < cap - 1 ? len : cap - 1;
|
||||
for (std::size_t i = 0; i < n; ++i) {
|
||||
const char c = static_cast<char>(U8(p + i));
|
||||
out[i] = (c >= 32 && static_cast<unsigned char>(c) < 127) ? c : '?';
|
||||
}
|
||||
out[n] = '\0';
|
||||
}
|
||||
|
||||
// A candidate word is either a small integer or a pointer to an object whose +4 is the tech's name
|
||||
// string. Try the string; fall back to printing the word.
|
||||
void DescribeWord(std::uint32_t w, char* out, std::size_t cap) {
|
||||
out[0] = '\0';
|
||||
if (w > 0x10000) {
|
||||
ReadStdString(static_cast<std::uintptr_t>(w) + 4, out, cap);
|
||||
if (out[0]) return;
|
||||
ReadStdString(static_cast<std::uintptr_t>(w), out, cap);
|
||||
if (out[0]) return;
|
||||
}
|
||||
std::snprintf(out, cap, "-");
|
||||
}
|
||||
} // namespace
|
||||
|
||||
extern "C" void* g_aiSelectOrig;
|
||||
void* g_aiSelectOrig = nullptr;
|
||||
extern "C" void AiSelectDetour();
|
||||
|
||||
extern "C" void AiOnSelectResearch(void* agent) {
|
||||
++g_researchSeq;
|
||||
g_candIdx = 0;
|
||||
const std::uintptr_t a = reinterpret_cast<std::uintptr_t>(agent);
|
||||
// agent->+0x10 is the StrategyClient, client->+0x150 the ClientPlayer, player->+0x294 the
|
||||
// current research target. Read defensively: a wrong offset must print a zero, not fault.
|
||||
const std::uintptr_t client = U32(a + 0x10);
|
||||
const std::uintptr_t player = client ? U32(client + 0x150) : 0;
|
||||
LogF("---- airesearch sel=%u agent=0x%08x client=0x%08x player=0x%08x curTarget=0x%08x "
|
||||
"species=%d ----",
|
||||
g_researchSeq, static_cast<unsigned>(a), static_cast<unsigned>(client),
|
||||
static_cast<unsigned>(player), player ? U32(player + 0x294) : 0,
|
||||
player ? static_cast<int>(U32(player + 0x5c)) : -1);
|
||||
}
|
||||
|
||||
extern "C" void* g_aiCandOrig;
|
||||
void* g_aiCandOrig = nullptr;
|
||||
extern "C" void AiCandDetour();
|
||||
|
||||
extern "C" void AiOnResearchCandidate(std::uint32_t outSlot, std::uint32_t candWord1,
|
||||
std::uint32_t agent, std::uint32_t candWord0) {
|
||||
char n0[64], n1[64];
|
||||
DescribeWord(candWord0, n0, sizeof n0);
|
||||
DescribeWord(candWord1, n1, sizeof n1);
|
||||
LogF("aicand sel=%u idx=%d agent=0x%08x slot=0x%08x w0=0x%08x(%d) '%s' w1=0x%08x(%d) '%s'",
|
||||
g_researchSeq, g_candIdx, agent, outSlot, candWord0, static_cast<int>(candWord0), n0,
|
||||
candWord1, static_cast<int>(candWord1), n1);
|
||||
++g_candIdx;
|
||||
}
|
||||
|
||||
extern "C" void* g_aiSetTargetOrig;
|
||||
void* g_aiSetTargetOrig = nullptr;
|
||||
extern "C" void AiSetTargetDetour();
|
||||
|
||||
extern "C" void AiOnSetResearchTarget(std::uint32_t namePtr) {
|
||||
char buf[96];
|
||||
buf[0] = '\0';
|
||||
if (Readable(namePtr, 1)) {
|
||||
std::size_t i = 0;
|
||||
for (; i < sizeof buf - 1; ++i) {
|
||||
if (!Readable(namePtr + i, 1)) break;
|
||||
const char c = static_cast<char>(U8(namePtr + i));
|
||||
if (!c) break;
|
||||
buf[i] = (c >= 32 && static_cast<unsigned char>(c) < 127) ? c : '?';
|
||||
}
|
||||
buf[i] = '\0';
|
||||
}
|
||||
LogF("airesult sel=%u candidatesTried=%d chose='%s' (ptr=0x%08x)", g_researchSeq, g_candIdx,
|
||||
buf, namePtr);
|
||||
}
|
||||
|
||||
// SelectResearchTarget is __thiscall with one stack argument: push ECX.
|
||||
asm(R"(
|
||||
.text
|
||||
.globl _AiSelectDetour
|
||||
_AiSelectDetour:
|
||||
pushfl
|
||||
pushal
|
||||
pushl %ecx
|
||||
call _AiOnSelectResearch
|
||||
addl $4, %esp
|
||||
popal
|
||||
popfl
|
||||
jmp *_g_aiSelectOrig
|
||||
)");
|
||||
|
||||
// TryResearchCandidate: ECX = the out slot, EDX = candidate word 1, and two stack arguments
|
||||
// (agent, candidate word 0). After pushfl+pushal the return address is at esp+36 and those two are
|
||||
// at esp+40 and esp+44; each push shifts the rest by four, so the reads walk backwards.
|
||||
asm(R"(
|
||||
.text
|
||||
.globl _AiCandDetour
|
||||
_AiCandDetour:
|
||||
pushfl
|
||||
pushal
|
||||
pushl 44(%esp)
|
||||
pushl 44(%esp)
|
||||
pushl %edx
|
||||
pushl %ecx
|
||||
call _AiOnResearchCandidate
|
||||
addl $16, %esp
|
||||
popal
|
||||
popfl
|
||||
jmp *_g_aiCandOrig
|
||||
)");
|
||||
|
||||
// cl_SetResearchTarget is __cdecl with one stack argument, the tech NAME.
|
||||
asm(R"(
|
||||
.text
|
||||
.globl _AiSetTargetDetour
|
||||
_AiSetTargetDetour:
|
||||
pushfl
|
||||
pushal
|
||||
pushl 40(%esp)
|
||||
call _AiOnSetResearchTarget
|
||||
addl $4, %esp
|
||||
popal
|
||||
popfl
|
||||
jmp *_g_aiSetTargetOrig
|
||||
)");
|
||||
|
||||
asm(R"(
|
||||
.text
|
||||
.globl _AiBatchDetour
|
||||
|
|
@ -428,6 +613,12 @@ bool ai_orders_config(const char* key, const char* value, std::string* err) {
|
|||
std::snprintf(g_outPath, sizeof g_outPath, "%s", value);
|
||||
return true;
|
||||
}
|
||||
if (std::strcmp(key, "airesearch") == 0) {
|
||||
if (std::strcmp(value, "on") == 0) g_research = true;
|
||||
else if (std::strcmp(value, "off") == 0) g_research = false;
|
||||
else if (err) *err = "expected on|off";
|
||||
return true;
|
||||
}
|
||||
if (std::strcmp(key, "aiprobes") == 0) {
|
||||
if (std::strcmp(value, "off") == 0 || std::strcmp(value, "none") == 0) {
|
||||
g_probeInstallCount = 0;
|
||||
|
|
@ -474,6 +665,35 @@ void install_ai_orders(std::uintptr_t exeBase, const char* gameDir, void (*log)(
|
|||
LogF("COVERAGE: aiorders batch hook NOT INSTALLED -- no block will be dumped, and an empty "
|
||||
"report means the instrument failed, not that the AI emitted nothing");
|
||||
|
||||
if (g_research) {
|
||||
const struct {
|
||||
const char* name;
|
||||
std::uint32_t rva;
|
||||
void* detour;
|
||||
void** tramp;
|
||||
} kResearch[3] = {
|
||||
{"StrategyAIAgent::SelectResearchTarget", sots::addr::StrategyAIAgent_SelectResearchTarget,
|
||||
reinterpret_cast<void*>(&AiSelectDetour), &g_aiSelectOrig},
|
||||
{"StrategyAIAgent::TryResearchCandidate", sots::addr::StrategyAIAgent_TryResearchCandidate,
|
||||
reinterpret_cast<void*>(&AiCandDetour), &g_aiCandOrig},
|
||||
{"cl_SetResearchTarget", sots::addr::cl_SetResearchTarget,
|
||||
reinterpret_cast<void*>(&AiSetTargetDetour), &g_aiSetTargetOrig},
|
||||
};
|
||||
for (const auto& r : kResearch) {
|
||||
void* t = reinterpret_cast<void*>(exeBase + r.rva);
|
||||
MH_STATUS r1 = MH_CreateHook(t, r.detour, r.tramp);
|
||||
MH_STATUS r2 = r1 == MH_OK ? MH_EnableHook(t) : r1;
|
||||
LogF("airesearch: %s rva=0x%08x va=%p create=%s enable=%s", r.name, r.rva, t,
|
||||
MH_StatusToString(r1), MH_StatusToString(r2));
|
||||
if (r2 != MH_OK)
|
||||
LogF("COVERAGE: airesearch hook %s NOT INSTALLED -- a silent capture below means "
|
||||
"the instrument failed, not that the selector did nothing",
|
||||
r.name);
|
||||
}
|
||||
} else {
|
||||
LogF("airesearch: disabled (airesearch=off)");
|
||||
}
|
||||
|
||||
for (std::size_t i = 0; i < g_probeInstallCount; ++i) {
|
||||
void* t = reinterpret_cast<void*>(exeBase + kProbes[i].rva);
|
||||
MH_STATUS p1 = MH_CreateHook(t, kProbes[i].stub, kProbes[i].trampoline);
|
||||
|
|
|
|||
|
|
@ -37,3 +37,4 @@ watch=off
|
|||
aiorders.out=C:\SOTS\shim.aiorders.txt
|
||||
aiorders=off
|
||||
aiprobes=off
|
||||
airesearch=off
|
||||
|
|
|
|||
|
|
@ -37,3 +37,4 @@ watch=off
|
|||
aiorders.out=C:\SOTS\shim.aiorders.txt
|
||||
aiorders=on
|
||||
aiprobes=off
|
||||
airesearch=off
|
||||
|
|
|
|||
|
|
@ -37,3 +37,4 @@ watch=off
|
|||
aiorders.out=C:\SOTS\shim.aiorders.txt
|
||||
aiorders=on
|
||||
aiprobes=all
|
||||
airesearch=off
|
||||
|
|
|
|||
40
src/shim/shim.cfg.l4research
Normal file
40
src/shim/shim.cfg.l4research
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# Lane L4. THE THREE CONFIGS BELOW DIFFER FROM EACH OTHER IN AT MOST TWO KEYS, ON PURPOSE.
|
||||
#
|
||||
# `l4control` installs nothing but the same template-hook set as `l4dump`/`l4probes` (all off),
|
||||
# `l4dump` adds ONE detour (`aiorders=on`), `l4probes` adds sixteen entry probes on top
|
||||
# (`aiprobes=all`). Rule 19 is run against the campaign's published autosave oracle, and the
|
||||
# pairwise single-key differences are what localise a failure to one half of the instrument.
|
||||
#
|
||||
# Lane H's own entry probes are explicitly `probes=off` here: that set is the one measured to have
|
||||
# changed an autosave by 4 bytes, and leaving it on the default (which is ALL of them) would make
|
||||
# every byte comparison below meaningless.
|
||||
hooks=trace
|
||||
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::ComputeBudget=off
|
||||
hook.Game::ServerPlayer::OnTechResearched=off
|
||||
hook.Game::ServerPlayer::ProcessTurn=off
|
||||
hook.Game::ServerSystem::ProcessTurn=off
|
||||
hook.Game::ServerSystem::GroupOutput=off
|
||||
hook.Game::ServerSystem::ComputeTotalOutput=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
|
||||
trace.path=C:\SOTS\shim.trace.jsonl
|
||||
trace.flush=always
|
||||
probes=off
|
||||
watch=off
|
||||
aiorders.out=C:\SOTS\shim.aiorders.txt
|
||||
aiorders=on
|
||||
aiprobes=all
|
||||
airesearch=on
|
||||
Loading…
Add table
Reference in a new issue