L4: read the AI's command block out of the running game
New shim module src/shim/hooks/ai_orders.{h,cpp}: one register-transparent entry
stub on StrategySim::ApplyTurnCommandBatch dumps every submitted TurnCommands block
(six gates, 27 list lengths, element bytes) at the point where all of them are
complete in memory; sixteen entry probes, with RunTaskList's stub recording the
pass so every later hit is pass-attributed.
Two workloads on VM145, one End Turn each. The rule-19 control passed with all
seventeen detours installed: both autosaves byte-identical to the published oracle.
What the AI actually emits, and three things no reading had produced:
- a list-23 element on EVERY turn, the first element ever observed in the free
half of the cost table -- and both turns still cost the measured 12;
- the ids in AI commands are client-allocated and travel in the command (design
18, fleet 34; neither exists in the input save);
- pass 0 emits nothing, measured from element counts rather than inferred.
tests/game_ai/test_live_blocks.cpp rebuilds both captured blocks through the public
OrderClient API and asserts the list profile, element values, gate counts and
ModCount total: 44 checks. Kept separate from test_orders.cpp, which stays the
record of what static reading predicted.
Gates: clean_room_check OK, host ctest 55/55, CT111 shim cross-build exit 0.
This commit is contained in:
parent
45bf085981
commit
4a5c218a35
13 changed files with 1081 additions and 5 deletions
|
|
@ -104,6 +104,7 @@ if(WIN32)
|
|||
src/shim/hooks/tail_rng.cpp
|
||||
src/shim/hooks/draw_sites.cpp
|
||||
src/shim/hooks/probe_entry.cpp
|
||||
src/shim/hooks/ai_orders.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,
|
||||
|
|
|
|||
81
docs/L4-ai-orders.md
Normal file
81
docs/L4-ai-orders.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# L4 — the AI command block, live
|
||||
|
||||
Companion to `docs/L4-predictions.md` (written and committed **before** the module existed) and to
|
||||
`sots-re/findings/subsystems/ai-order-capture.md` (the full report, with the raw logs).
|
||||
|
||||
## What was added
|
||||
|
||||
`src/shim/hooks/ai_orders.{h,cpp}` — two instruments behind three config keys.
|
||||
|
||||
**`aiorders=on`** installs exactly one detour: a register-transparent entry stub on
|
||||
`Game::StrategySim::ApplyTurnCommandBatch`. That function takes `(blocks, n)` as stack arguments and
|
||||
multiplies `n` by `0x1b4` to make its end pointer, so at its entry every player's submitted
|
||||
`TurnCommands` block is complete, in memory, at a known stride. One hook there dumps the whole
|
||||
turn's command traffic: six gates, twenty-seven list lengths and 48 bytes per element, per block.
|
||||
|
||||
**`aiprobes=all`** adds sixteen entry counters in lane H's asm-stub style, in their own table so
|
||||
`probe_entry.cpp`'s set is untouched and `probes=` still means what it meant. Row 0 is
|
||||
`StrategyAIAgent::RunTaskList`, and its stub is hand-written: it reads that function's `pass` stack
|
||||
argument into a global before tail-jumping, so **every later probe hit is attributed to a pass**.
|
||||
That is the difference between counting entries and measuring the two-pass model.
|
||||
|
||||
**`aiorders.out=<path>`** — the dump goes to its own file as well as `shim.log`.
|
||||
|
||||
Both halves are separately switchable so they can be given separate rule-19 controls. In the end
|
||||
they did not need to be: the full seventeen-detour configuration reproduced the campaign's published
|
||||
End-Turn oracle byte for byte, on a guest (VM145) that had never been checked against it.
|
||||
|
||||
## What it found
|
||||
|
||||
Full account in the findings document. The three that change this repo:
|
||||
|
||||
1. **The AI submits a list-23 element every turn.** Lists 17–27 had never been populated by any
|
||||
workload, so the free half of `ListAdvancesModCount` was read from the instruction stream and
|
||||
nothing else. It is now exercised twice and both turns still cost the measured 12.
|
||||
2. **Ids in AI commands are client-allocated.** The build order names design `18` and the fleet
|
||||
order names fleet `34` — neither exists in the input save, and the *other* new design that turn
|
||||
(a monster faction's, made server-side) took `1712` from the save's master counter. Two id
|
||||
spaces; the small one travels in the command. `OrderClient` does not model id allocation and now
|
||||
has a named reason to.
|
||||
3. **Pass 0 emits nothing, measured from the output.** All three pass-1-gated exits were entered in
|
||||
both passes in equal numbers, and the block carries one copy of each element. `OrderClient`'s
|
||||
`EnterTaskPass` gate is confirmed.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/game_ai/test_live_blocks.cpp` — 44 checks, both captured blocks rebuilt through the public
|
||||
`OrderClient` API from the dumped values, asserting the list profile, the element values, the gate
|
||||
counts and the `ModCount` total for each turn, plus a two-sided check that the list-23 element is
|
||||
free while a list-16 element is not.
|
||||
|
||||
It is deliberately a **separate binary** from `test_orders.cpp`. That file is the record of what
|
||||
static reading predicted before any of this ran, and it stays that way; this one is the record of
|
||||
what the game did. The agreement between them is evidence only while the two stay independent.
|
||||
|
||||
Two placeholder ids in `test_orders.cpp` were corrected in place from the capture (the AI fleet
|
||||
order names fleet 34 with a hop to 272, not fleet 1744; the three research targets are techIds
|
||||
144/90/288). Rule 11: a wrong id in a test is how a wrong id spreads.
|
||||
|
||||
## Gates
|
||||
|
||||
Run as separate commands (rule 13).
|
||||
|
||||
* `tools/clean_room_check.sh` — **OK**
|
||||
* host `ctest --preset host` — **55/55**
|
||||
* CT111 shim cross-build (`/srv/re-lab/build/sots-engine-l4`, `DIST=/srv/re-lab/shim/dist-l4`) —
|
||||
**exit 0**, exports 66 names identical to the real `binkw32.dll`
|
||||
|
||||
The generated header was regenerated from `sots-re/ghidra/addresses.json` plus every
|
||||
`ghidra/addresses.d/*.json` fragment (1,209 entries). This lane's fragment is `lane-l4.json`, nine
|
||||
entries: eight `IAITask::Execute` bodies and the list-16 order method. Nine other entries in the
|
||||
regenerated header belong to concurrent lanes' fragments and came along with the merge, as the
|
||||
per-lane fragment mechanism intends.
|
||||
|
||||
## One thing that did not work, recorded because it costs a run
|
||||
|
||||
The turn-1 workload (`turn1-state.sav` + one End Turn) is **not reproducible**. Three runs produced
|
||||
three different post-turn autosaves, differing in exactly one field: the research target of the one
|
||||
AI player that owns nothing. Every other byte — ids, designs, fleets, `ModCount` — is identical.
|
||||
`determinism-oracle.md` verifies the oracle for the `ref-turn2` workload and only for that one; this
|
||||
lane is the first to run the turn-1 workload more than once, and it is now on the record that a
|
||||
byte comparison on that workload is a comparison against noise.
|
||||
|
|
@ -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 @ 2e935b8, generated 2026-09-08 by tools/gen_addresses.py
|
||||
// Source: sots-re ghidra/addresses.json @ 2d61848, 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>
|
||||
|
|
@ -1913,6 +1913,48 @@ constexpr uint32_t StrategyServer_FinalizeTurnRecords = 0x0038a0e0;
|
|||
constexpr uint32_t ServerSystem_ComputeMaxIncome = 0x003521c0;
|
||||
// note Game::SNMAllCombatDone RTTI vtable, four slots (0x0079e590, 0x0082a100, 0x0082a170, 0x0079e500 -- the middle pair are the network Read/Write). Layout by enumeration from the two stack constructors and from every offset OnAllCombatDone_Tail reads: `struct SNMAllCombatDone { void* vptr; std::vector<EncounterResults> results; }`, 0x10 bytes -- which is why the handler passes msg+4 and not msg. Three construction sites: RunCombatRound 0x007cc847 (stack), the combat server FUN_007cfd00+0x541 = 0x007d0241 (stack; sends it to every player whose +0x44 is 4 or 5, then sets combatServer->+0x60 = 9; NOTE Ghidra sizes FUN_007cfd00 at 384 B but its real body runs to the ret at 0x007d02b9), and the deserialization factory 0x008663b0 (operator new(0x14) -- 4 bytes larger than the enumerated size, UNEXPLAINED) [verified]
|
||||
constexpr uint32_t SNMAllCombatDone_layout = 0x00624758;
|
||||
// site call RNG_NextInt (0x004271c0) inside SVSOSwarmQueen_RegisterHives, ECX = GetGame()->RNG(+0x16c) + 4, bound = HI - LO passed BY POINTER at [ebp-0x14]; return address 0x00527714, then `add eax,edi` where edi = frame + LO. ONE STRATEGIC-GENERATOR WORD PER NEW HIVE, taken inside StrategyServer::BeginProcessTurn -- outside BOTH turn drivers and before either. Corrects svsctob-writers.md, which put the call at 0x0052770c (that is the `mov [ebp-0x14],esi` storing the bound) [verified]
|
||||
constexpr uint32_t SVSOSwarmQueen_RegisterHives_DrawSite = 0x0012770f;
|
||||
// site `inc DWORD PTR [esi+0x8]` -- the NextQ slip. FOUR gate-failure edges converge here: 0x005277e3 (frame <= *SwarmQueen_GateFrameFloor_ptr), 0x005277fe (queens count >= the per-turn allowance), 0x00527848 (age < *SwarmQueen_GateQueenAge_ptr), 0x0052784f (hive's system handle is 0). Consumes NO generator word on any of them; TickHives calls no RNG primitive anywhere in its body (0x00527770..0x005279a1 read whole) [verified]
|
||||
constexpr uint32_t SVSOSwarmQueen_TickHives_SlipSite = 0x0012785a;
|
||||
// data int* -- pointer to the LOW end of the new-hive queen-countdown offset. RegisterHives computes nextQ = frame + *this + RNG_NextInt(*SwarmQueen_HiveNextQOffsetHi_ptr - *this). Behind a pointer, so no .text or .data reference initialises it in a form a static reader can follow; the value is read live by the shim at RegisterHives entry [mapped]
|
||||
constexpr uint32_t SwarmQueen_HiveNextQOffsetLo_ptr = 0x006e0204;
|
||||
// data int* -- pointer to the HIGH end of the new-hive queen-countdown offset. The RNG_NextInt bound is (*this - *SwarmQueen_HiveNextQOffsetLo_ptr) and NextInt is INCLUSIVE of its bound, so the countdown lands in [frame+LO, frame+HI] [mapped]
|
||||
constexpr uint32_t SwarmQueen_HiveNextQOffsetHi_ptr = 0x006e0208;
|
||||
// data int* -- TickHives gate 1: `if (GetGame()->Frame(+0xc) <= *this) slip`. No queen can spawn at or below this frame [mapped]
|
||||
constexpr uint32_t SwarmQueen_GateFrameFloor_ptr = 0x006e0210;
|
||||
// data int* -- read in TickHives' SPAWN arm (0x005278d8), after a queen has been created: when *this <= 0 the vector at queen+0x30 is trimmed to 0 entries, otherwise to *this. Never exercised on any corpus save (no hive has ever had a queen) [mapped]
|
||||
constexpr uint32_t SwarmQueen_SpawnListCap_ptr = 0x006e0220;
|
||||
// data int* -- TickHives gate 3: an age in turns, compared against `GetGame()->Frame - resolvedQueen->+0xc`; below it the hive slips instead of spawning [mapped]
|
||||
constexpr uint32_t SwarmQueen_GateQueenAge_ptr = 0x006e0228;
|
||||
// field std::vector<HiveInfo> -- {_Myfirst 0x10, _Mylast 0x14, _Myend 0x18, _Alval 0x1c}, element stride 0x10: {void* vptr = 0x009f1a68, StarSystem* sys(+4), int nextQ(+8), Queen* queen(+0xc)}. The WIRE order is HiveID / QueenID / NextQ, which is NOT the member order [verified]
|
||||
constexpr uint32_t SVSOSwarmQueen_off_Hives = 0x00000010;
|
||||
// field std::vector<T> at +0x20..+0x24, element stride 0xc -- TickHives' second gate divides the byte span by 12. Empty on every corpus save [verified]
|
||||
constexpr uint32_t SVSOSwarmQueen_off_Queens = 0x00000020;
|
||||
// field int -- the difficulty tier, wire tag "CDiff". Set to -1 by the ctor 0x0051a820 and written ONLY by SVSOSlaversRefuel_UpdateDifficultyTier, which stores (scanIndex - 1) and only when it differs. Reachable values are 0 (frame 1..49) and 1 (frame 50..99) ONLY: frame <= 0 exits at index 0 and frame >= 100 walks off the end of the 3-entry threshold table, so BOTH edges store nothing and the tier can never reach 2 [verified]
|
||||
constexpr uint32_t SVSOSlaversRefuel_off_CDiff = 0x00000038;
|
||||
// thiscall void __thiscall Game::AITColonize::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. SHARED BODY: Game::AITColonizeGoal::Execute is the same address. 48 bytes; a forwarder to the parameterised worker 0x0068b280 called as worker(ecx = agent, this, pass, this->+0x8, &this->+0x20, &this->+0x10) with `edi = this->+0xc` passed as an IMPLICIT REGISTER ARGUMENT (whole-program-optimised custom convention; a reimplementation that ports only the stack arguments passes garbage). Reaches the colonize order (list 7) at depth 4 via 0x0068b280 -> 0x006930f0 -> 0x00578ff0 -> 0x00769640 [unverified]
|
||||
constexpr uint32_t AITColonize_Execute = 0x0028b400;
|
||||
// thiscall void __thiscall Game::AITEscortGateInvade::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. SHARED BODY: Game::AITEscortGateInvadeGoal::Execute is the same address. 80 bytes; forwards to 0x0068c5d0 as __fastcall(ecx = this->+0xc, edx = agent) plus 8 stack arguments. Reaches list 7 at depth 4 [unverified]
|
||||
constexpr uint32_t AITEscortGateInvade_Execute = 0x0028c7c0;
|
||||
// thiscall void __thiscall Game::AITInvade::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. SHARED BODY: Game::AITInvadeGoal::Execute is the same address. 160 bytes; forwards to 0x0068d460 with 11 arguments, then maintains this->+0x38 and this->+0x39. +0x39 is computed at 0x0068d80f-0x0068d82a as (0x006a6380(agent, this->+0xc) < 2 * 0x006a6260(agent, this->+0xc)) and is what IAITask slot 11 returns for this class. Reaches list 14 at depth 5 [unverified]
|
||||
constexpr uint32_t AITInvade_Execute = 0x0028d7a0;
|
||||
// thiscall void __thiscall Game::AITNodeBore::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Does its setup (0x00685810) and its finaliser (0x0068e090) only when pass == 0, and forwards `pass` to 0x0068a520 in both passes. Reaches list 14 at depth 5 via 0x0068a520 -> 0x006ceef0 -> 0x006c16c0 -> 0x006987e0 -> 0x007634d0. Zuul-flavoured task (the node-bore is a Zuul mechanic), so on a non-Zuul board it may be absent from the task list entirely [unverified]
|
||||
constexpr uint32_t AITNodeBore_Execute = 0x0028e590;
|
||||
// thiscall void __thiscall Game::AITRaid::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. THE ONE TASK WHOSE PASS-0 BEHAVIOUR IS NOT SETTLED: at 0x0068e89e it calls 0x006b76a0 and then at 0x0068e8b8 loops the list-16 order method 0x007635f0(client, fleetId, 1) over the returned fleets, and NEITHER the call nor the loop carries a `pass` guard of its own. AI3 §2.3's argument says the fleet vector at [ebp-0x28] is empty on pass 0 because it is downstream of the same hub, but [ebp-0x28] was never traced to closure. This address exists as an address so that a zero at 0x007635f0 can be read: entered-and-silent and never-entered are opposite answers (method rule 20) [unverified]
|
||||
constexpr uint32_t AITRaid_Execute = 0x0028e670;
|
||||
// thiscall void __thiscall Game::AITAdvanceIdleShips::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Whole body is pass-1 only: `if (pass != 1) return` at 0x0068f25a. Table priority 0, so it is always the LAST task in the descending-priority sweep -- the AI sweeps up whatever is still idle only after every other task has taken both its minimum and its desired force, which is what the two-tier quota model predicts [unverified]
|
||||
constexpr uint32_t AITAdvanceIdleShips_Execute = 0x0028f230;
|
||||
// thiscall void __thiscall Game::AITBuildDeepScanShips::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Reaches the BUILD order (list 3) at depth 6 via 0x006ce460 -> 0x006ce360 -> 0x006ce190 -> 0x006bd790 -> 0x006b3bc0 -> 0x00762fd0, the same chain as AITBuildPoliceShips [unverified]
|
||||
constexpr uint32_t AITBuildDeepScanShips_Execute = 0x002901a0;
|
||||
// thiscall void __thiscall Game::AITBuildPoliceShips::Execute(StrategyAIAgent* agent, int pass) -- RET 8, IAITask vtable slot 5. Reaches the BUILD order (list 3) at depth 6, identical chain to AITBuildDeepScanShips. One of the two candidate producers of the single list-3 element the reference AI emits on turn 1 [unverified]
|
||||
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]
|
||||
constexpr uint32_t ServerPlayer_off_Aidf = 0x00000368;
|
||||
// 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]
|
||||
|
|
|
|||
490
src/shim/hooks/ai_orders.cpp
Normal file
490
src/shim/hooks/ai_orders.cpp
Normal file
|
|
@ -0,0 +1,490 @@
|
|||
#include "shim/hooks/ai_orders.h"
|
||||
|
||||
#include <cstdarg>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
|
||||
#define WIN32_LEAN_AND_MEAN
|
||||
#include <windows.h>
|
||||
|
||||
#include "MinHook.h"
|
||||
|
||||
#include "generated/sots_addresses.h"
|
||||
|
||||
namespace shim::hooks {
|
||||
namespace {
|
||||
|
||||
// ---- configuration ---------------------------------------------------------------------------
|
||||
|
||||
bool g_enabled = false;
|
||||
std::size_t g_probeInstallCount = 0; // `aiprobes=`; default off, so `aiorders=on` alone is ONE detour
|
||||
char g_outPath[MAX_PATH] = {};
|
||||
FILE* g_out = nullptr;
|
||||
void (*g_log)(const char*) = nullptr;
|
||||
|
||||
void LogF(const char* fmt, ...) {
|
||||
char buf[1200];
|
||||
va_list ap;
|
||||
va_start(ap, fmt);
|
||||
std::vsnprintf(buf, sizeof buf, fmt, ap);
|
||||
va_end(ap);
|
||||
if (g_log) g_log(buf);
|
||||
if (g_out) {
|
||||
std::fputs(buf, g_out);
|
||||
std::fputc('\n', g_out);
|
||||
std::fflush(g_out);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- guarded reads ---------------------------------------------------------------------------
|
||||
//
|
||||
// Every read below is of a pointer the game handed us or of a heap node we reached by walking one.
|
||||
// A wrong offset must produce a logged zero, never a fault inside a detour, so nothing is read
|
||||
// without a probe first. Four bytes at a time: an element window may run past the end of a small
|
||||
// heap node, and probing per word bounds the damage to the word.
|
||||
|
||||
inline bool Readable(std::uintptr_t p, std::size_t n) {
|
||||
return p != 0 && !IsBadReadPtr(reinterpret_cast<void*>(p), n);
|
||||
}
|
||||
|
||||
inline std::uint32_t U32(std::uintptr_t p) {
|
||||
return Readable(p, 4) ? *reinterpret_cast<volatile std::uint32_t*>(p) : 0u;
|
||||
}
|
||||
inline std::uint8_t U8(std::uintptr_t p) {
|
||||
return Readable(p, 1) ? *reinterpret_cast<volatile std::uint8_t*>(p) : 0u;
|
||||
}
|
||||
inline float F32(std::uintptr_t p) {
|
||||
const std::uint32_t v = U32(p);
|
||||
float f;
|
||||
std::memcpy(&f, &v, 4);
|
||||
return f;
|
||||
}
|
||||
|
||||
// ---- the `TurnCommands` block ------------------------------------------------------------------
|
||||
//
|
||||
// Offsets from `Game::TurnCommands::Write` 0x00842540 as read by lane Q, and from the twenty-seven
|
||||
// per-list loops in `ApplyTurnCommandBatch` as read by lane AI4. Both are instruction-stream reads
|
||||
// of the same class from opposite ends -- the writer and the applier -- and they agree, which is
|
||||
// the only independent corroboration this layout has.
|
||||
constexpr std::uint32_t kBlockStride = 0x1b4;
|
||||
constexpr std::uint32_t kListBase = 0x70; // list 1's member
|
||||
constexpr std::uint32_t kListStride = 0x0c; // {_Myhead, _Mysize, _Alval}, allocator LAST
|
||||
constexpr int kListCount = 27;
|
||||
|
||||
// How much of each element to record. The largest element record lane Q read is list 5's
|
||||
// {i32, OutputRates frame}; 48 bytes covers every scalar-only record with room to spare and is
|
||||
// short enough that a heap node's tail is unlikely to matter. Words that do not probe readable are
|
||||
// printed as `????????` rather than as zero, so truncation is visible.
|
||||
constexpr int kElemWords = 12;
|
||||
|
||||
std::uint32_t g_batchSeq = 0;
|
||||
|
||||
// ---- the entry probes ---------------------------------------------------------------------------
|
||||
|
||||
constexpr std::size_t kMaxProbes = 16;
|
||||
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)
|
||||
bool g_installed[kMaxProbes];
|
||||
|
||||
// Set by the RunTaskList stub from that function's third __cdecl stack argument. It is STALE once
|
||||
// RunTaskList returns -- nothing can clear it from a tail-jumping stub -- so `pass` on a hit that
|
||||
// falls outside a task sweep is the last pass that ran, not a measurement. The event ring's
|
||||
// `run` column is what makes that readable: hits with the same `run` as the preceding RunTaskList
|
||||
// entry are inside that sweep.
|
||||
volatile LONG g_pass = -1;
|
||||
volatile LONG g_runSeq = 0;
|
||||
volatile LONG g_agent = 0;
|
||||
|
||||
struct Event {
|
||||
std::uint32_t seq;
|
||||
std::uint8_t probe;
|
||||
std::int8_t pass;
|
||||
std::uint32_t run;
|
||||
std::uint32_t agent;
|
||||
};
|
||||
constexpr std::size_t kMaxEvents = 4096;
|
||||
Event g_events[kMaxEvents];
|
||||
volatile LONG g_eventCount = 0;
|
||||
std::size_t g_eventsWritten = 0;
|
||||
|
||||
} // namespace
|
||||
|
||||
// ---- the hit sinks, called from the asm stubs ---------------------------------------------------
|
||||
|
||||
extern "C" void AiProbeHit(int index) {
|
||||
if (index < 0 || static_cast<std::size_t>(index) >= kMaxProbes) return;
|
||||
++g_calls[index];
|
||||
++g_total[index];
|
||||
const LONG p = g_pass;
|
||||
const int bucket = (p == 0) ? 0 : (p == 1) ? 1 : 2;
|
||||
++g_byPass[index][bucket];
|
||||
const LONG n = InterlockedIncrement(&g_eventCount) - 1;
|
||||
if (n >= 0 && static_cast<std::size_t>(n) < kMaxEvents) {
|
||||
Event& e = g_events[n];
|
||||
e.seq = static_cast<std::uint32_t>(n);
|
||||
e.probe = static_cast<std::uint8_t>(index);
|
||||
e.pass = static_cast<std::int8_t>(p);
|
||||
e.run = static_cast<std::uint32_t>(g_runSeq);
|
||||
e.agent = static_cast<std::uint32_t>(g_agent);
|
||||
}
|
||||
}
|
||||
|
||||
// The RunTaskList stub's sink. Records the pass BEFORE the function runs, so every probe hit
|
||||
// inside the sweep sees it.
|
||||
extern "C" void AiOnRunTaskList(void* agent, int pass) {
|
||||
g_pass = pass;
|
||||
g_agent = static_cast<LONG>(reinterpret_cast<std::uintptr_t>(agent));
|
||||
InterlockedIncrement(&g_runSeq);
|
||||
AiProbeHit(0);
|
||||
}
|
||||
|
||||
namespace {
|
||||
|
||||
// ---- the probe table ------------------------------------------------------------------------
|
||||
//
|
||||
// Order is the stub index, the report order and the `aiprobes=N` bisection order, so it is fixed.
|
||||
// Index 0 MUST be RunTaskList: it is both the pass recorder and the control. Every other row is
|
||||
// meaningless if row 0 reads zero, and the report says so rather than presenting a table of zeros.
|
||||
namespace A = sots::addr;
|
||||
|
||||
struct ProbeDef {
|
||||
const char* name;
|
||||
std::uint32_t rva;
|
||||
void* stub;
|
||||
void** trampoline;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
// One trampoline slot and one stub per probe. Written out rather than generated at runtime because
|
||||
// a runtime thunk needs an executable allocation and a relocation, and MinHook already owns that.
|
||||
#define AI_PROBE_STUB(i) \
|
||||
extern "C" void* g_aiTr##i; \
|
||||
void* g_aiTr##i = nullptr; \
|
||||
extern "C" void AiProbeStub##i(void); \
|
||||
asm(".text\n" \
|
||||
".globl _AiProbeStub" #i "\n" \
|
||||
"_AiProbeStub" #i ":\n" \
|
||||
" pushfl\n" \
|
||||
" pushal\n" \
|
||||
" pushl $" #i "\n" \
|
||||
" call _AiProbeHit\n" \
|
||||
" addl $4, %esp\n" \
|
||||
" popal\n" \
|
||||
" popfl\n" \
|
||||
" jmp *_g_aiTr" #i "\n")
|
||||
|
||||
// Index 0 is hand-written: it forwards RunTaskList's `agent` and `pass` stack arguments. After
|
||||
// `pushfl` (4) + `pushal` (32) the return address is at esp+36 and the four __cdecl arguments at
|
||||
// esp+40/44/48/52, so `pass` is esp+48 and `agent` is esp+40 -- and after the first push the
|
||||
// latter has moved to esp+44. Nothing is written; the arguments are read where the callee will
|
||||
// read them.
|
||||
extern "C" void* g_aiTr0;
|
||||
void* g_aiTr0 = nullptr;
|
||||
extern "C" void AiProbeStub0(void);
|
||||
asm(".text\n"
|
||||
".globl _AiProbeStub0\n"
|
||||
"_AiProbeStub0:\n"
|
||||
" pushfl\n"
|
||||
" pushal\n"
|
||||
" pushl 48(%esp)\n"
|
||||
" pushl 44(%esp)\n"
|
||||
" call _AiOnRunTaskList\n"
|
||||
" addl $8, %esp\n"
|
||||
" popal\n"
|
||||
" popfl\n"
|
||||
" jmp *_g_aiTr0\n");
|
||||
|
||||
AI_PROBE_STUB(1);
|
||||
AI_PROBE_STUB(2);
|
||||
AI_PROBE_STUB(3);
|
||||
AI_PROBE_STUB(4);
|
||||
AI_PROBE_STUB(5);
|
||||
AI_PROBE_STUB(6);
|
||||
AI_PROBE_STUB(7);
|
||||
AI_PROBE_STUB(8);
|
||||
AI_PROBE_STUB(9);
|
||||
AI_PROBE_STUB(10);
|
||||
AI_PROBE_STUB(11);
|
||||
AI_PROBE_STUB(12);
|
||||
AI_PROBE_STUB(13);
|
||||
AI_PROBE_STUB(14);
|
||||
AI_PROBE_STUB(15);
|
||||
#undef AI_PROBE_STUB
|
||||
|
||||
namespace {
|
||||
|
||||
#define AI_PROBE(name, rva, i) \
|
||||
ProbeDef { name, rva, reinterpret_cast<void*>(&AiProbeStub##i), &g_aiTr##i }
|
||||
|
||||
const ProbeDef kProbes[] = {
|
||||
// 0: the pass recorder AND the control. 3 AI players x 2 passes on the reference game.
|
||||
AI_PROBE("StrategyAIAgent::RunTaskList [control+pass]", A::StrategyAIAgent_RunTaskList, 0),
|
||||
// 1-2: the AITRaid question (AI3 section 2.4). A zero on 1 means nothing without 2.
|
||||
AI_PROBE("StrategyClient::OrderList16 [list 16 emit]", A::StrategyClient_OrderList16, 1),
|
||||
AI_PROBE("AITRaid::Execute", A::AITRaid_Execute, 2),
|
||||
// 3-8: the six shared bodies behind the nine tasks AI2 called planners and AI3 showed emit.
|
||||
AI_PROBE("AITColonize::Execute [+Goal]", A::AITColonize_Execute, 3),
|
||||
AI_PROBE("AITEscortGateInvade::Execute [+Goal]", A::AITEscortGateInvade_Execute, 4),
|
||||
AI_PROBE("AITInvade::Execute [+Goal]", A::AITInvade_Execute, 5),
|
||||
AI_PROBE("AITNodeBore::Execute", A::AITNodeBore_Execute, 6),
|
||||
AI_PROBE("AITBuildPoliceShips::Execute", A::AITBuildPoliceShips_Execute, 7),
|
||||
AI_PROBE("AITBuildDeepScanShips::Execute", A::AITBuildDeepScanShips_Execute, 8),
|
||||
// 9: priority 0, pass-1 body, always last in the sweep -- a second control on the task list.
|
||||
AI_PROBE("AITAdvanceIdleShips::Execute", A::AITAdvanceIdleShips_Execute, 9),
|
||||
// 10-12: the three pass-1-gated emission exits. Entered on BOTH passes if the gate is theirs.
|
||||
AI_PROBE("StrategyAIAgent::RequestBuildForTask [lists 3,1]",
|
||||
A::StrategyAIAgent_RequestBuildForTask, 10),
|
||||
AI_PROBE("StrategyAIAgent::AssignFleetsAndIssueOrders [lists 14,8,10]",
|
||||
A::StrategyAIAgent_AssignFleetsAndIssueOrders, 11),
|
||||
AI_PROBE("StrategyAIAgent::IssueRouteForFleets [list 14]",
|
||||
A::StrategyAIAgent_IssueRouteForFleets, 12),
|
||||
// 13-14: the hub and the claim test.
|
||||
AI_PROBE("StrategyAIAgent::AcquireFleetsForTask", A::StrategyAIAgent_AcquireFleetsForTask, 13),
|
||||
AI_PROBE("StrategyAIAgent::IsClaimedByAnotherTask [entry only]",
|
||||
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),
|
||||
};
|
||||
#undef AI_PROBE
|
||||
|
||||
constexpr std::size_t kProbeCount = sizeof kProbes / sizeof kProbes[0];
|
||||
static_assert(kProbeCount <= kMaxProbes, "add more AI_PROBE_STUB() slots");
|
||||
|
||||
// ---- the dump ---------------------------------------------------------------------------------
|
||||
|
||||
void DumpElements(int blk, int pid, int list, std::uintptr_t head) {
|
||||
// MSVC std::list node: {_Next, _Prev, _Myval}. begin() == _Myhead->_Next; the head is the nil
|
||||
// sentinel and terminates the walk.
|
||||
std::uintptr_t node = U32(head);
|
||||
int idx = 0;
|
||||
while (node && node != head && idx < 64) {
|
||||
const std::uintptr_t val = node + 8;
|
||||
char hex[kElemWords * 9 + 8] = {};
|
||||
char ints[kElemWords * 13 + 8] = {};
|
||||
int hp = 0, ip = 0;
|
||||
for (int w = 0; w < kElemWords; ++w) {
|
||||
const std::uintptr_t p = val + 4u * static_cast<unsigned>(w);
|
||||
if (Readable(p, 4)) {
|
||||
const std::uint32_t v = *reinterpret_cast<volatile std::uint32_t*>(p);
|
||||
hp += std::snprintf(hex + hp, sizeof hex - hp, "%08x ", v);
|
||||
ip += std::snprintf(ints + ip, sizeof ints - ip, "%d ", static_cast<int>(v));
|
||||
} else {
|
||||
hp += std::snprintf(hex + hp, sizeof hex - hp, "???????? ");
|
||||
ip += std::snprintf(ints + ip, sizeof ints - ip, "? ");
|
||||
}
|
||||
}
|
||||
// The first two words as floats as well: several element records lead with or contain a
|
||||
// rate/fraction, and reading 0x3f4ccccd as 1061997773 hides that.
|
||||
LogF("aielem blk=%d pid=%d list=%d idx=%d node=0x%08x f0=%g f1=%g ints=[ %s] hex=[ %s]",
|
||||
blk, pid, list, idx, static_cast<unsigned>(node), static_cast<double>(F32(val)),
|
||||
static_cast<double>(F32(val + 4)), ints, hex);
|
||||
node = U32(node);
|
||||
++idx;
|
||||
}
|
||||
if (idx >= 64) LogF("aielem blk=%d list=%d TRUNCATED at 64 elements", blk, list);
|
||||
}
|
||||
|
||||
void DumpBlock(int i, int n, std::uintptr_t b) {
|
||||
const int pid = static_cast<int>(U32(b + 0x04));
|
||||
const int gRate = U8(b + 0x0c), gTgt = U8(b + 0x14), gBoost = U8(b + 0x20);
|
||||
const int gG4 = U8(b + 0x2c), gF3 = U8(b + 0x3c), gCiv = U8(b + 0x6c);
|
||||
LogF("aiblk seq=%u blk=%d/%d at=0x%08x pid=%d "
|
||||
"rate=%d:%g target=%d:%d boost=%d:%d,%g g4=%d:%d,%d f3=%d:%g,%g,%g civ=%d",
|
||||
g_batchSeq, i, n, static_cast<unsigned>(b), pid, gRate,
|
||||
static_cast<double>(F32(b + 0x08)), gTgt, static_cast<int>(U32(b + 0x10)), gBoost,
|
||||
static_cast<int>(U32(b + 0x18)), static_cast<double>(F32(b + 0x1c)), gG4,
|
||||
static_cast<int>(U8(b + 0x24)), static_cast<int>(U32(b + 0x28)), gF3,
|
||||
static_cast<double>(F32(b + 0x30)), static_cast<double>(F32(b + 0x34)),
|
||||
static_cast<double>(F32(b + 0x38)), gCiv);
|
||||
|
||||
char sizes[27 * 5 + 16] = {};
|
||||
int sp = 0;
|
||||
int nonEmpty = 0;
|
||||
for (int L = 1; L <= kListCount; ++L) {
|
||||
const std::uintptr_t m = b + kListBase + kListStride * static_cast<unsigned>(L - 1);
|
||||
const std::uint32_t size = U32(m + 4);
|
||||
sp += std::snprintf(sizes + sp, sizeof sizes - sp, "%u ", size);
|
||||
if (size) ++nonEmpty;
|
||||
}
|
||||
LogF("ailists seq=%u blk=%d pid=%d nonEmpty=%d sizes(1..27)=[ %s]", g_batchSeq, i, pid,
|
||||
nonEmpty, sizes);
|
||||
|
||||
for (int L = 1; L <= kListCount; ++L) {
|
||||
const std::uintptr_t m = b + kListBase + kListStride * static_cast<unsigned>(L - 1);
|
||||
const std::uintptr_t head = U32(m);
|
||||
const std::uint32_t size = U32(m + 4);
|
||||
// Measure the list twice. A wrong container layout would otherwise print a confident zero
|
||||
// for all twenty-seven, which is exactly what an empty block looks like (method rule 1).
|
||||
int walked = 0;
|
||||
std::uintptr_t node = U32(head);
|
||||
while (node && node != head && walked < 4096) {
|
||||
++walked;
|
||||
node = U32(node);
|
||||
}
|
||||
if (static_cast<std::uint32_t>(walked) != size)
|
||||
LogF("ailist MISMATCH blk=%d pid=%d list=%d _Mysize=%u walked=%d head=0x%08x "
|
||||
"-- the list layout is wrong and every size on this block is unmeasured",
|
||||
i, pid, L, size, walked, static_cast<unsigned>(head));
|
||||
if (size) {
|
||||
LogF("ailist blk=%d pid=%d list=%d off=0x%03x size=%u", i, pid, L,
|
||||
kListBase + kListStride * (L - 1), size);
|
||||
DumpElements(i, pid, L, head);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void ai_orders_flush(void (*log_line)(const char*)) {
|
||||
if (log_line) g_log = log_line;
|
||||
const LONG n = g_eventCount;
|
||||
const std::size_t have =
|
||||
static_cast<std::size_t>(n) > kMaxEvents ? kMaxEvents : static_cast<std::size_t>(n);
|
||||
for (std::size_t i = g_eventsWritten; i < have; ++i) {
|
||||
const Event& e = g_events[i];
|
||||
const char* nm = (e.probe < kProbeCount) ? kProbes[e.probe].name : "?";
|
||||
LogF("aievent seq=%u run=%u pass=%d agent=0x%08x probe=%u %s", e.seq, e.run,
|
||||
static_cast<int>(e.pass), e.agent, static_cast<unsigned>(e.probe), nm);
|
||||
}
|
||||
g_eventsWritten = have;
|
||||
if (static_cast<std::size_t>(n) > kMaxEvents)
|
||||
LogF("aievent OVERFLOW -- %ld hits taken, only %u recorded; counters below are complete, "
|
||||
"the ordering is not",
|
||||
n, static_cast<unsigned>(kMaxEvents));
|
||||
}
|
||||
|
||||
// ---- the batch detour ---------------------------------------------------------------------------
|
||||
|
||||
extern "C" void* g_aiBatchOrig;
|
||||
void* g_aiBatchOrig = nullptr;
|
||||
extern "C" void AiBatchDetour();
|
||||
|
||||
extern "C" void AiOnBatch(void* blocksv, int n) {
|
||||
++g_batchSeq;
|
||||
const std::uintptr_t blocks = reinterpret_cast<std::uintptr_t>(blocksv);
|
||||
LogF("---- aibatch seq=%u blocks=0x%08x n=%d stride=0x%x ----", g_batchSeq,
|
||||
static_cast<unsigned>(blocks), n, kBlockStride);
|
||||
if (n < 0 || n > 64 || !Readable(blocks, kBlockStride)) {
|
||||
LogF("aibatch seq=%u UNREADABLE (n=%d) -- nothing dumped, and this is a failure of the "
|
||||
"instrument, not an empty turn",
|
||||
g_batchSeq, n);
|
||||
} else {
|
||||
for (int i = 0; i < n; ++i)
|
||||
DumpBlock(i, n, blocks + kBlockStride * static_cast<unsigned>(i));
|
||||
}
|
||||
|
||||
// The probe window. Counters are reported and reset here, so a row's `turn` column covers the
|
||||
// AI sweep that produced THIS batch: the AI runs on SEResumePlaying at the end of the previous
|
||||
// turn's processing (and on load), and the batch is applied at the start of the next one.
|
||||
ai_orders_flush(nullptr);
|
||||
if (g_probeInstallCount == 0) {
|
||||
LogF("aiprobe seq=%u none installed (aiprobes=off)", g_batchSeq);
|
||||
} else {
|
||||
for (std::size_t i = 0; i < kProbeCount; ++i) {
|
||||
if (!g_installed[i] && i < g_probeInstallCount)
|
||||
LogF("COVERAGE: aiprobe %s NOT INSTALLED -- its count is meaningless, not zero",
|
||||
kProbes[i].name);
|
||||
LogF("aiprobe seq=%u idx=%u installed=%d turn=%u total=%u pass0=%u pass1=%u other=%u %s",
|
||||
g_batchSeq, static_cast<unsigned>(i), g_installed[i] ? 1 : 0, g_calls[i],
|
||||
g_total[i], g_byPass[i][0], g_byPass[i][1], g_byPass[i][2], kProbes[i].name);
|
||||
}
|
||||
if (g_calls[0] == 0)
|
||||
LogF("aiprobe seq=%u CONTROL ZERO -- RunTaskList was not entered in this window, so "
|
||||
"every other row above is unmeasured rather than absent",
|
||||
g_batchSeq);
|
||||
}
|
||||
for (std::size_t i = 0; i < kProbeCount; ++i) {
|
||||
g_calls[i] = 0;
|
||||
g_byPass[i][0] = g_byPass[i][1] = g_byPass[i][2] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
asm(R"(
|
||||
.text
|
||||
.globl _AiBatchDetour
|
||||
_AiBatchDetour:
|
||||
pushfl
|
||||
pushal
|
||||
pushl 44(%esp)
|
||||
pushl 44(%esp)
|
||||
call _AiOnBatch
|
||||
addl $8, %esp
|
||||
popal
|
||||
popfl
|
||||
jmp *_g_aiBatchOrig
|
||||
)");
|
||||
|
||||
bool ai_orders_config(const char* key, const char* value, std::string* err) {
|
||||
if (std::strcmp(key, "aiorders") == 0) {
|
||||
if (std::strcmp(value, "on") == 0) g_enabled = true;
|
||||
else if (std::strcmp(value, "off") == 0) g_enabled = false;
|
||||
else if (err) *err = "expected on|off";
|
||||
return true;
|
||||
}
|
||||
if (std::strcmp(key, "aiorders.out") == 0) {
|
||||
std::snprintf(g_outPath, sizeof g_outPath, "%s", value);
|
||||
return true;
|
||||
}
|
||||
if (std::strcmp(key, "aiprobes") == 0) {
|
||||
if (std::strcmp(value, "off") == 0 || std::strcmp(value, "none") == 0) {
|
||||
g_probeInstallCount = 0;
|
||||
} else if (std::strcmp(value, "all") == 0 || std::strcmp(value, "on") == 0) {
|
||||
g_probeInstallCount = kProbeCount;
|
||||
} else {
|
||||
char* end = nullptr;
|
||||
const long v = std::strtol(value, &end, 10);
|
||||
if (end == value || v < 0) {
|
||||
if (err) *err = "expected off|all|N";
|
||||
return true;
|
||||
}
|
||||
g_probeInstallCount =
|
||||
static_cast<std::size_t>(v) < kProbeCount ? static_cast<std::size_t>(v) : kProbeCount;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool ai_orders_enabled() { return g_enabled; }
|
||||
|
||||
void install_ai_orders(std::uintptr_t exeBase, const char* gameDir, void (*log)(const char*)) {
|
||||
g_log = log;
|
||||
if (!g_enabled) {
|
||||
if (g_log) g_log("aiorders: disabled (aiorders=off)");
|
||||
return;
|
||||
}
|
||||
if (!g_outPath[0]) std::snprintf(g_outPath, sizeof g_outPath, "%s\\shim.aiorders.txt", gameDir);
|
||||
g_out = std::fopen(g_outPath, "w");
|
||||
if (!g_out) LogF("aiorders: cannot open %s -- output goes to shim.log only", g_outPath);
|
||||
LogF("aiorders: out=%s probes=%u of %u", g_outPath,
|
||||
static_cast<unsigned>(g_probeInstallCount), static_cast<unsigned>(kProbeCount));
|
||||
|
||||
void* target =
|
||||
reinterpret_cast<void*>(exeBase + sots::addr::StrategySim_ApplyTurnCommandBatch);
|
||||
MH_STATUS s1 = MH_CreateHook(target, reinterpret_cast<void*>(&AiBatchDetour), &g_aiBatchOrig);
|
||||
MH_STATUS s2 = s1 == MH_OK ? MH_EnableHook(target) : s1;
|
||||
LogF("aiorders: batch hook StrategySim::ApplyTurnCommandBatch rva=0x%08x va=%p create=%s "
|
||||
"enable=%s",
|
||||
sots::addr::StrategySim_ApplyTurnCommandBatch, target, MH_StatusToString(s1),
|
||||
MH_StatusToString(s2));
|
||||
if (s2 != MH_OK)
|
||||
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");
|
||||
|
||||
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);
|
||||
MH_STATUS p2 = p1 == MH_OK ? MH_EnableHook(t) : p1;
|
||||
g_installed[i] = (p2 == MH_OK);
|
||||
LogF("aiprobe: %u %s rva=0x%08x va=%p create=%s enable=%s", static_cast<unsigned>(i),
|
||||
kProbes[i].name, kProbes[i].rva, t, MH_StatusToString(p1), MH_StatusToString(p2));
|
||||
if (!g_installed[i])
|
||||
LogF("COVERAGE: aiprobe %s NOT INSTALLED -- its count is meaningless, not zero",
|
||||
kProbes[i].name);
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace shim::hooks
|
||||
66
src/shim/hooks/ai_orders.h
Normal file
66
src/shim/hooks/ai_orders.h
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
// Lane L4 -- what the AI actually submits, read out of the live game.
|
||||
//
|
||||
// WHY A BLOCK DUMP AND NOT MORE BUMP COUNTING.
|
||||
//
|
||||
// Every previous lane that looked at AI emission looked at `ModCount`: a counter that advances
|
||||
// once per applied command out of the paying half of the command table. That instrument answers
|
||||
// "how many commands" and cannot answer "which lists, how many elements, what values" -- and the
|
||||
// second question is the one a reimplementation has to answer, because our `game/ai` models the
|
||||
// shape of a command block and the cost of an element and *decides nothing*. Nobody has ever
|
||||
// looked at a real AI block.
|
||||
//
|
||||
// `Game::StrategySim::ApplyTurnCommandBatch` receives `(blocks, n)` as two stack arguments, and at
|
||||
// its entry every submitted player block is complete and in memory, laid out at a fixed 0x1b4
|
||||
// stride. One register-transparent entry stub therefore dumps the entire turn's command traffic in
|
||||
// one place: six flag-gated single commands and twenty-seven counted lists per player, with the
|
||||
// element bytes.
|
||||
//
|
||||
// TWO INSTRUMENTS, AND WHY THE SECOND ONE EXISTS.
|
||||
//
|
||||
// The block says what was emitted. It does not say *which task* emitted it, and it cannot
|
||||
// distinguish "this task never ran" from "this task ran and emitted nothing" -- opposite answers,
|
||||
// and the campaign has already paid once for reading a zero as the first when it was the second
|
||||
// (method rule 20). So the second instrument is a set of entry counters on the task bodies and the
|
||||
// three pass-gated emission sites, in the register-transparent asm-stub style lane H established.
|
||||
//
|
||||
// One of them is special. `StrategyAIAgent::RunTaskList` takes `pass` as its third `__cdecl` stack
|
||||
// argument, so its stub reads that argument and records it in a global before tail-jumping. Every
|
||||
// later probe hit is then attributed to the pass that was running, which is what turns "pass 0
|
||||
// emits nothing" from an inference about bump arithmetic into a measurement. The global is stale
|
||||
// once RunTaskList returns; the report says so rather than pretending otherwise, and the event ring
|
||||
// carries the RunTaskList sequence number so a reader can see exactly where each hit fell.
|
||||
//
|
||||
// WHAT NEITHER INSTRUMENT CAN DO, stated because it is the price of the design:
|
||||
//
|
||||
// * An entry counter cannot see a branch *inside* the function. `IsClaimedByAnotherTask` is
|
||||
// probed for its call count only; whether its steal branch fires is NOT settled here.
|
||||
// * A tail-jumping stub never regains control, so nothing here reports a return value or a cost.
|
||||
// * The dump reads element bytes, not element types. It emits a fixed window of raw bytes plus
|
||||
// the same bytes as ints; decoding to the element records happens offline, in the report, so a
|
||||
// wrong record shows up as a wrong value instead of being baked into the instrument.
|
||||
//
|
||||
// AND ONE SELF-CHECK, because a wrong container layout prints a confident zero (method rule 1):
|
||||
// every list is measured twice, by walking its node chain and by reading its `_Mysize`, and a
|
||||
// disagreement is logged as MISMATCH rather than being silently resolved in favour of either.
|
||||
#pragma once
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace shim::hooks {
|
||||
|
||||
// `aiorders=on|off`, `aiprobes=off|all|N`, `aiorders.out=<path>`. Returns false if `key` is not
|
||||
// ours. `aiprobes=N` installs the first N in the fixed table order, so the set is bisectable in one
|
||||
// build if the rule-19 control ever fails.
|
||||
bool ai_orders_config(const char* key, const char* value, std::string* err);
|
||||
|
||||
bool ai_orders_enabled();
|
||||
|
||||
// Installs the batch-dump detour and the entry probes. Call after MH_Initialize.
|
||||
void install_ai_orders(std::uintptr_t exe_base, const char* game_dir, void (*log_line)(const char*));
|
||||
|
||||
// Writes any event-ring entries not yet on disk. Safe to call from anywhere, including shutdown.
|
||||
void ai_orders_flush(void (*log_line)(const char*));
|
||||
|
||||
} // namespace shim::hooks
|
||||
|
|
@ -28,6 +28,7 @@
|
|||
#include "shim/hooks/probe_entry.h"
|
||||
#include "shim/hooks/tail_rng.h"
|
||||
#include "shim/hooks/tech_effects.h"
|
||||
#include "shim/hooks/ai_orders.h"
|
||||
#include "shim/hooks/watchpoints.h"
|
||||
#include "shim/trace/hook.h"
|
||||
#include "shim/trace/selftest.h"
|
||||
|
|
@ -90,6 +91,9 @@ Config ReadConfig() {
|
|||
if (shim::hooks::probe_config(p, val, &probe_n)) {
|
||||
Log("config: %s=%s -> %u lane-H entry probes", p, val,
|
||||
static_cast<unsigned>(probe_n));
|
||||
} 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);
|
||||
} else if (shim::hooks::watch_apply_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);
|
||||
|
|
@ -295,6 +299,12 @@ void InstallHooks(shim::trace::Tracer& tracer) {
|
|||
// Lane W2: hardware data-write watchpoints. One MinHook detour (the arming point); the
|
||||
// watchpoints themselves modify no code at all. Off unless `watch=on` (rule 19).
|
||||
shim::hooks::install_watchpoints(exeBase, g_dir, &ShimLogLine);
|
||||
// Lane L4: the AI command-block dump plus its entry probes. Off unless `aiorders=on`, and
|
||||
// `aiorders=on` alone installs exactly ONE detour -- `aiprobes=` adds the rest, so the two
|
||||
// halves of the instrument can be given separate rule-19 controls. Its batch target
|
||||
// (ApplyTurnCommandBatch) is a different function from the watchpoint module's arming point
|
||||
// (ApplyAllTurnCommands, its only caller), so the two never contend for a MinHook target.
|
||||
shim::hooks::install_ai_orders(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.
|
||||
|
|
@ -386,6 +396,7 @@ void Shim_Init(HMODULE self) {
|
|||
|
||||
void Shim_Shutdown() {
|
||||
shim::hooks::watch_flush(&ShimLogLine);
|
||||
shim::hooks::ai_orders_flush(&ShimLogLine);
|
||||
Log("%s", shim::fpu::summary().c_str());
|
||||
shim::trace::Tracer& tracer = shim::trace::Tracer::instance();
|
||||
if (tracer.is_open()) {
|
||||
|
|
|
|||
39
src/shim/shim.cfg.l4control
Normal file
39
src/shim/shim.cfg.l4control
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# 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=off
|
||||
aiprobes=off
|
||||
39
src/shim/shim.cfg.l4dump
Normal file
39
src/shim/shim.cfg.l4dump
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# 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=off
|
||||
6
src/shim/shim.cfg.l4off
Normal file
6
src/shim/shim.cfg.l4off
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Lane L4, the pure baseline: the shim DLL loads and forwards Bink, and installs NOTHING -- not the
|
||||
# M0 hook, not the template hooks, not the draw-site detours, not lane H's probes, not this lane's.
|
||||
# Its only job is to prove that VM145, which is a ZFS clone of the reference guest and has never
|
||||
# been checked for byte fidelity, reproduces the campaign's published autosave oracle. If it does
|
||||
# not, nothing measured on this guest can be compared with anything.
|
||||
hooks=off
|
||||
39
src/shim/shim.cfg.l4probes
Normal file
39
src/shim/shim.cfg.l4probes
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
# 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
|
||||
|
|
@ -28,3 +28,12 @@ target_link_libraries(game_ai_test_agent PRIVATE sots_game_ai)
|
|||
target_include_directories(game_ai_test_agent PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_compile_options(game_ai_test_agent PRIVATE -Wall -Wextra -pedantic)
|
||||
add_test(NAME game_ai_agent COMMAND game_ai_test_agent)
|
||||
|
||||
# The two AI command blocks read out of the RUNNING game (lane L4). Distinct from test_orders.cpp
|
||||
# on purpose: that file is what static reading predicted, this one is what the game did, and the
|
||||
# agreement between them is only evidence while the two stay independent.
|
||||
add_executable(game_ai_test_live_blocks test_live_blocks.cpp)
|
||||
target_link_libraries(game_ai_test_live_blocks PRIVATE sots_game_ai)
|
||||
target_include_directories(game_ai_test_live_blocks PRIVATE ${CMAKE_CURRENT_SOURCE_DIR})
|
||||
target_compile_options(game_ai_test_live_blocks PRIVATE -Wall -Wextra -pedantic)
|
||||
add_test(NAME game_ai_live_blocks COMMAND game_ai_test_live_blocks)
|
||||
|
|
|
|||
248
tests/game_ai/test_live_blocks.cpp
Normal file
248
tests/game_ai/test_live_blocks.cpp
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
// The two AI command blocks that were read out of the running game, element by element.
|
||||
//
|
||||
// Every number in this file was DUMPED, not derived. Lane L4 put a register-transparent entry stub
|
||||
// on the batch applier -- the point at which every player's submitted block is complete and in
|
||||
// memory at a fixed stride -- and printed the six gates, the twenty-seven list lengths and the
|
||||
// element bytes for all eight block slots, on two workloads, on a guest whose autosaves reproduced
|
||||
// the campaign's published oracle byte for byte with the instrument installed.
|
||||
//
|
||||
// WHY THIS FILE IS SEPARATE FROM test_orders.cpp. That file's expectations were written from the
|
||||
// instruction stream and the corpus saves before any of this code existed, and they must stay that
|
||||
// way: it is the record of what static reading predicted. This file is the record of what the game
|
||||
// did. Where they agree -- and on the arithmetic they agree exactly, twelve and twelve -- the
|
||||
// agreement means something precisely because the two were produced by different instruments and
|
||||
// neither was fitted to the other.
|
||||
//
|
||||
// The three things the live capture added that no amount of reading had produced:
|
||||
//
|
||||
// * A LIST-23 ELEMENT ON BOTH TURNS. Nothing in eleven corpus saves had ever populated a list in
|
||||
// the free half of the table, so the whole 17..27 row of the cost model was a hypothesis in
|
||||
// the rule-6 sense. It is now exercised: the AI submits one every turn, and the counter still
|
||||
// lands on the measured twelve, so the element really is free.
|
||||
// * THE IDS ARE CLIENT-ALLOCATED AND TRAVEL IN THE COMMAND. The build order names design 18 --
|
||||
// an id the server has not yet issued when the block is submitted -- and the fleet order names
|
||||
// fleet 34, an object that does not exist in the input save at all. A reimplementation cannot
|
||||
// assign these on apply; it has to allocate them where the original does or every id in the
|
||||
// resulting save is wrong.
|
||||
// * THE SAME SYSTEM IN THREE COMMANDS. Build, system rates and the population command all name
|
||||
// system 288, the AI's home. They are one decision expressed three times.
|
||||
#include "game/ai/orders.h"
|
||||
|
||||
#include <cstdio>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
using namespace sots::ai;
|
||||
|
||||
namespace {
|
||||
|
||||
int g_checks = 0;
|
||||
int g_fails = 0;
|
||||
|
||||
void check(bool ok, const std::string& what) {
|
||||
++g_checks;
|
||||
if (!ok) {
|
||||
++g_fails;
|
||||
std::printf("FAIL: %s\n", what.c_str());
|
||||
}
|
||||
}
|
||||
|
||||
// The AI's home system, named by three different commands in the same block.
|
||||
constexpr int kHomeSystem = 288;
|
||||
// The design the AI created on turn 1 and built from on both turns. Not a multiple of sixteen and
|
||||
// not from the save's master id counter -- the other new design that turn, a monster faction's,
|
||||
// took 1712 from that counter while this one took 18 from somewhere the save does not show.
|
||||
constexpr int kHonorLance = 18;
|
||||
// The fleet the turn-2 order names. It is NOT the fleet that exists in the input save (1744); it
|
||||
// is the one that exists in the OUTPUT save, so the client had already allocated it.
|
||||
constexpr int kBetaFleet = 34;
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Turn 1 -> 2, captured from `turn1-state.sav` + one End Turn
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
std::vector<TurnCommandBlock> CaptureTurnOneToTwo() {
|
||||
std::vector<TurnCommandBlock> blocks;
|
||||
|
||||
// pid 16, the human. Ordered nothing all turn and still submits a block, because the send
|
||||
// buffer sets the rate gate from live player state whatever the player did -- and the value it
|
||||
// carried was 0.25, the rate the human never touched.
|
||||
OrderClient human(16);
|
||||
human.EndTurn(0.25f);
|
||||
blocks.push_back(human.block());
|
||||
|
||||
// pid 32, the only AI with an empire.
|
||||
OrderClient ai(32);
|
||||
ai.SetResearchTarget(144);
|
||||
ai.OrderUnmodelled(CommandList::NewDesigns, 1); // list 1: the "Honor Lance" hull itself
|
||||
ai.OrderBuild(BuildOrder{1, kHonorLance, kHomeSystem, 0});
|
||||
ai.OrderSystemRates(SystemRatesOrder{kHomeSystem, 0, 1.0f, 0, 0, 0, 0, 0});
|
||||
ai.OrderUnmodelled(CommandList::PopulationCmds, 1); // list 23, free half
|
||||
ai.EndTurn(0.8f);
|
||||
blocks.push_back(ai.block());
|
||||
|
||||
// pid 496 and 512: the two AI players that own nothing. They still run a full two-pass task
|
||||
// sweep and they still pick a research target.
|
||||
OrderClient dormantA(496);
|
||||
dormantA.SetResearchTarget(90);
|
||||
dormantA.EndTurn(0.8f);
|
||||
blocks.push_back(dormantA.block());
|
||||
|
||||
OrderClient dormantB(512);
|
||||
// The one field in the whole turn that is not reproducible: three runs of this exact workload
|
||||
// produced three different targets for this player. 288 is what the instrumented run recorded.
|
||||
dormantB.SetResearchTarget(288);
|
||||
dormantB.EndTurn(0.8f);
|
||||
blocks.push_back(dormantB.block());
|
||||
|
||||
// The four monster factions occupy block slots and never touch them: player id zero, every
|
||||
// gate clear, all twenty-seven lists empty. They are not modelled as blocks here because a
|
||||
// block with no gate set is indistinguishable from an absent one for every purpose this
|
||||
// module has -- which is itself the finding.
|
||||
return blocks;
|
||||
}
|
||||
|
||||
void TestTurnOneToTwo() {
|
||||
const std::vector<TurnCommandBlock> blocks = CaptureTurnOneToTwo();
|
||||
const TurnCommandBlock& ai = blocks[1];
|
||||
|
||||
check(blocks.size() == 4, "four players submitted a block");
|
||||
check(ai.ElementCount(CommandList::NewDesigns) == 1, "1->2: one design command");
|
||||
check(ai.ElementCount(CommandList::Build) == 1, "1->2: one build order");
|
||||
check(ai.ElementCount(CommandList::SystemRates) == 1, "1->2: one system-rates command");
|
||||
check(ai.ElementCount(CommandList::PopulationCmds) == 1, "1->2: one population command");
|
||||
// The whole fleet-order group is absent on the turn the fleet is created.
|
||||
check(ai.ElementCount(CommandList::FleetMove) == 0, "1->2: no fleet move");
|
||||
check(ai.ElementCount(CommandList::FleetTask) == 0, "1->2: no fleet task");
|
||||
check(ai.ElementCount(CommandList::List10) == 0, "1->2: no list-10 command");
|
||||
// Nothing else at all: four lists out of twenty-seven.
|
||||
int nonEmpty = 0;
|
||||
for (int n = 1; n <= kCommandListCount; ++n)
|
||||
nonEmpty += ai.ElementCount(static_cast<CommandList>(n)) > 0 ? 1 : 0;
|
||||
check(nonEmpty == 4, "1->2: exactly four of the twenty-seven lists are non-empty");
|
||||
|
||||
check(ai.build[0].designId == kHonorLance, "the build order names the design the block creates");
|
||||
check(ai.build[0].systemId == kHomeSystem, "and builds it at the home system");
|
||||
check(ai.build[0].ordinal == 1, "the ordinal is 1 on the AI's first build");
|
||||
check(ai.systemRates[0].systemId == kHomeSystem, "system rates name the same system");
|
||||
|
||||
// Three targets, not one: every AI player picks one on the first turn, the human does not.
|
||||
int targets = 0, rates = 0;
|
||||
for (const auto& b : blocks) {
|
||||
targets += b.hasResearchTarget ? 1 : 0;
|
||||
rates += b.hasResearchRate ? 1 : 0;
|
||||
}
|
||||
check(targets == 3, "three research-target gates, one per AI player");
|
||||
check(rates == 4, "four research-rate gates, one per submitted block");
|
||||
check(!blocks[0].hasResearchTarget, "the human set no research target");
|
||||
|
||||
const ModCountCost cost = TurnModCountDelta(blocks);
|
||||
check(cost.exact, "1->2 cost is exact");
|
||||
check(cost.bumps == 12, "1->2 costs the measured 12");
|
||||
check(BlockModCountCost(blocks[2]).bumps == 2, "a dormant AI costs two: rate and target");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// Turn 2 -> 3, captured from `ref-turn2.sav` + one End Turn
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
std::vector<TurnCommandBlock> CaptureTurnTwoToThree() {
|
||||
std::vector<TurnCommandBlock> blocks;
|
||||
|
||||
OrderClient human(16);
|
||||
human.EndTurn(0.25f);
|
||||
blocks.push_back(human.block());
|
||||
|
||||
OrderClient ai(32);
|
||||
// No research target this turn: the gate is clear on all four blocks, and no player's saved
|
||||
// target changes across the turn.
|
||||
ai.OrderBuild(BuildOrder{2, kHonorLance, kHomeSystem, 0});
|
||||
ai.OrderSystemRates(SystemRatesOrder{kHomeSystem, 0, 1.0f, 0, 0, 0, 0, 0});
|
||||
ai.OrderUnmodelled(CommandList::List10, 1);
|
||||
ai.IssueAiFleetOrder(kBetaFleet, {272});
|
||||
ai.OrderUnmodelled(CommandList::PopulationCmds, 1);
|
||||
ai.EndTurn(0.8f);
|
||||
blocks.push_back(ai.block());
|
||||
|
||||
OrderClient dormantA(496);
|
||||
dormantA.EndTurn(0.8f);
|
||||
blocks.push_back(dormantA.block());
|
||||
|
||||
OrderClient dormantB(512);
|
||||
dormantB.EndTurn(0.8f);
|
||||
blocks.push_back(dormantB.block());
|
||||
return blocks;
|
||||
}
|
||||
|
||||
void TestTurnTwoToThree() {
|
||||
const std::vector<TurnCommandBlock> blocks = CaptureTurnTwoToThree();
|
||||
const TurnCommandBlock& ai = blocks[1];
|
||||
|
||||
check(ai.ElementCount(CommandList::Build) == 1, "2->3: one build order");
|
||||
check(ai.ElementCount(CommandList::SystemRates) == 1, "2->3: one system-rates command");
|
||||
check(ai.ElementCount(CommandList::FleetMove) == 1, "2->3: one fleet move");
|
||||
check(ai.ElementCount(CommandList::List10) == 1, "2->3: one list-10 command");
|
||||
check(ai.ElementCount(CommandList::FleetTask) == 2, "2->3: TWO fleet-task elements");
|
||||
check(ai.ElementCount(CommandList::PopulationCmds) == 1, "2->3: one population command");
|
||||
check(ai.ElementCount(CommandList::NewDesigns) == 0, "2->3: no new design -- it reuses design 18");
|
||||
int nonEmpty = 0;
|
||||
for (int n = 1; n <= kCommandListCount; ++n)
|
||||
nonEmpty += ai.ElementCount(static_cast<CommandList>(n)) > 0 ? 1 : 0;
|
||||
check(nonEmpty == 6, "2->3: exactly six of the twenty-seven lists are non-empty");
|
||||
|
||||
// The pair that AI2 predicted from the call site and that this capture read off the values:
|
||||
// one fleet, two elements, modes 0 and 1, and the same fleet id the route names.
|
||||
check(ai.fleetTasks.size() == 2, "two fleet-task elements");
|
||||
check(ai.fleetTasks[0].fleetId == kBetaFleet && ai.fleetTasks[1].fleetId == kBetaFleet,
|
||||
"both name the SAME fleet");
|
||||
check(ai.fleetTasks[0].mode == 0 && ai.fleetTasks[1].mode == 1, "modes 0 and 1, in that order");
|
||||
check(ai.fleetMoves[0].fleetId == kBetaFleet, "and the route names that fleet too");
|
||||
check(ai.fleetMoves[0].route.size() == 1, "the route is one hop");
|
||||
check(ai.build[0].ordinal == 2, "the ordinal is 2 on the AI's second build");
|
||||
|
||||
int targets = 0;
|
||||
for (const auto& b : blocks) targets += b.hasResearchTarget ? 1 : 0;
|
||||
check(targets == 0, "no research target is set on this turn by anyone");
|
||||
|
||||
const ModCountCost cost = TurnModCountDelta(blocks);
|
||||
check(cost.exact, "2->3 cost is exact");
|
||||
check(cost.bumps == 12, "2->3 costs the measured 12");
|
||||
check(BlockModCountCost(ai).bumps == 7, "seven of them are the AI's block");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// What the capture proves about the cost table itself
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
void TestFreeHalfExercisedLive() {
|
||||
// Both turns carry a list-23 element and both turns cost exactly twelve. Remove the element
|
||||
// and the total does not move: that is the free half of the table being exercised by a real
|
||||
// workload for the first time, from both directions.
|
||||
for (int turn = 0; turn < 2; ++turn) {
|
||||
std::vector<TurnCommandBlock> with =
|
||||
turn == 0 ? CaptureTurnOneToTwo() : CaptureTurnTwoToThree();
|
||||
std::vector<TurnCommandBlock> without = with;
|
||||
without[1].unmodelled[static_cast<int>(CommandList::PopulationCmds) - 1] = 0;
|
||||
check(with[1].ElementCount(CommandList::PopulationCmds) == 1, "the capture carries list 23");
|
||||
check(TurnModCountDelta(with).bumps == TurnModCountDelta(without).bumps,
|
||||
"removing the list-23 element changes nothing -- list 23 is free, measured");
|
||||
check(TurnModCountDelta(with).bumps == 12, "and the total is the measured 12 either way");
|
||||
}
|
||||
|
||||
// The counter-check, so the previous one is not vacuous: an element in the paying half does
|
||||
// move the total.
|
||||
std::vector<TurnCommandBlock> b = CaptureTurnTwoToThree();
|
||||
const int before = TurnModCountDelta(b).bumps;
|
||||
b[1].AddUnmodelled(CommandList::List16, 1);
|
||||
check(TurnModCountDelta(b).bumps == before + 1, "a list-16 element does cost one");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
TestTurnOneToTwo();
|
||||
TestTurnTwoToThree();
|
||||
TestFreeHalfExercisedLive();
|
||||
std::printf("%s: %d checks, %d failures\n", g_fails ? "FAILED" : "ok", g_checks, g_fails);
|
||||
return g_fails ? 1 : 0;
|
||||
}
|
||||
|
|
@ -211,7 +211,11 @@ void TestReferenceTurnTwoToThree() {
|
|||
ai.OrderSystemRates(SystemRatesOrder{});
|
||||
ai.OrderBuild(BuildOrder{});
|
||||
ai.OrderUnmodelled(CommandList::List10, 1);
|
||||
ai.IssueAiFleetOrder(1744, {288});
|
||||
// The ids here were placeholders when this case was written from the saves. The live capture
|
||||
// (tests/game_ai/test_live_blocks.cpp) read the real ones: the order names fleet 34 -- an
|
||||
// object the INPUT save does not contain -- and one hop to system 272. Corrected in place
|
||||
// rather than left as an illustration, because a wrong id in a test is how a wrong id spreads.
|
||||
ai.IssueAiFleetOrder(34, {272});
|
||||
ai.EndTurn(0.8f);
|
||||
blocks.push_back(ai.block());
|
||||
|
||||
|
|
@ -246,7 +250,7 @@ void TestReferenceTurnOneToTwo() {
|
|||
|
||||
OrderClient ai(32);
|
||||
ai.SetResearchRate(0.8f);
|
||||
ai.SetResearchTarget(1); // IND_Waldo
|
||||
ai.SetResearchTarget(144); // IND_Waldo -- tech id read live, not a placeholder
|
||||
ai.OrderUnmodelled(CommandList::NewDesigns, 1); // the new hull
|
||||
ai.OrderBuild(BuildOrder{}); // and the order to build it
|
||||
ai.OrderSystemRates(SystemRatesOrder{});
|
||||
|
|
@ -255,13 +259,14 @@ void TestReferenceTurnOneToTwo() {
|
|||
|
||||
OrderClient dormantA(496);
|
||||
dormantA.SetResearchRate(0.8f);
|
||||
dormantA.SetResearchTarget(2); // DRV_PlsFiss
|
||||
dormantA.SetResearchTarget(90); // DRV_PlsFiss, read live
|
||||
dormantA.EndTurn(0.8f);
|
||||
blocks.push_back(dormantA.block());
|
||||
|
||||
OrderClient dormantB(512);
|
||||
dormantB.SetResearchRate(0.8f);
|
||||
dormantB.SetResearchTarget(3); // BIO_GnMod
|
||||
dormantB.SetResearchTarget(288); // read live; and see test_live_blocks.cpp -- this
|
||||
// one player's target is NOT reproducible run to run
|
||||
dormantB.EndTurn(0.8f);
|
||||
blocks.push_back(dormantB.block());
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue