diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index 767ba52..7ed643c 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 @ 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 @@ -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* 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] diff --git a/src/shim/hooks/ai_orders.cpp b/src/shim/hooks/ai_orders.cpp index c905ec9..68ae3ae 100644 --- a/src/shim/hooks/ai_orders.cpp +++ b/src/shim/hooks/ai_orders.cpp @@ -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(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(U8(p + i)); + out[i] = (c >= 32 && static_cast(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(w) + 4, out, cap); + if (out[0]) return; + ReadStdString(static_cast(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(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(a), static_cast(client), + static_cast(player), player ? U32(player + 0x294) : 0, + player ? static_cast(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(candWord0), n0, + candWord1, static_cast(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(U8(namePtr + i)); + if (!c) break; + buf[i] = (c >= 32 && static_cast(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(&AiSelectDetour), &g_aiSelectOrig}, + {"StrategyAIAgent::TryResearchCandidate", sots::addr::StrategyAIAgent_TryResearchCandidate, + reinterpret_cast(&AiCandDetour), &g_aiCandOrig}, + {"cl_SetResearchTarget", sots::addr::cl_SetResearchTarget, + reinterpret_cast(&AiSetTargetDetour), &g_aiSetTargetOrig}, + }; + for (const auto& r : kResearch) { + void* t = reinterpret_cast(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(exeBase + kProbes[i].rva); MH_STATUS p1 = MH_CreateHook(t, kProbes[i].stub, kProbes[i].trampoline); diff --git a/src/shim/shim.cfg.l4control b/src/shim/shim.cfg.l4control index ffa64a3..6b0a4ae 100644 --- a/src/shim/shim.cfg.l4control +++ b/src/shim/shim.cfg.l4control @@ -37,3 +37,4 @@ watch=off aiorders.out=C:\SOTS\shim.aiorders.txt aiorders=off aiprobes=off +airesearch=off diff --git a/src/shim/shim.cfg.l4dump b/src/shim/shim.cfg.l4dump index e27c28e..e3b6df4 100644 --- a/src/shim/shim.cfg.l4dump +++ b/src/shim/shim.cfg.l4dump @@ -37,3 +37,4 @@ watch=off aiorders.out=C:\SOTS\shim.aiorders.txt aiorders=on aiprobes=off +airesearch=off diff --git a/src/shim/shim.cfg.l4probes b/src/shim/shim.cfg.l4probes index 768424a..2a428b1 100644 --- a/src/shim/shim.cfg.l4probes +++ b/src/shim/shim.cfg.l4probes @@ -37,3 +37,4 @@ watch=off aiorders.out=C:\SOTS\shim.aiorders.txt aiorders=on aiprobes=all +airesearch=off diff --git a/src/shim/shim.cfg.l4research b/src/shim/shim.cfg.l4research new file mode 100644 index 0000000..ae67ffb --- /dev/null +++ b/src/shim/shim.cfg.l4research @@ -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