From 4a5c218a35a08e02030d8661aab9ff8d631b0738 Mon Sep 17 00:00:00 2001 From: lane-l4 Date: Tue, 8 Sep 2026 18:01:19 -0400 Subject: [PATCH] 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. --- CMakeLists.txt | 1 + docs/L4-ai-orders.md | 81 +++++ include/generated/sots_addresses.h | 44 ++- src/shim/hooks/ai_orders.cpp | 490 +++++++++++++++++++++++++++++ src/shim/hooks/ai_orders.h | 66 ++++ src/shim/main.cpp | 11 + src/shim/shim.cfg.l4control | 39 +++ src/shim/shim.cfg.l4dump | 39 +++ src/shim/shim.cfg.l4off | 6 + src/shim/shim.cfg.l4probes | 39 +++ tests/game_ai/CMakeLists.txt | 9 + tests/game_ai/test_live_blocks.cpp | 248 +++++++++++++++ tests/game_ai/test_orders.cpp | 13 +- 13 files changed, 1081 insertions(+), 5 deletions(-) create mode 100644 docs/L4-ai-orders.md create mode 100644 src/shim/hooks/ai_orders.cpp create mode 100644 src/shim/hooks/ai_orders.h create mode 100644 src/shim/shim.cfg.l4control create mode 100644 src/shim/shim.cfg.l4dump create mode 100644 src/shim/shim.cfg.l4off create mode 100644 src/shim/shim.cfg.l4probes create mode 100644 tests/game_ai/test_live_blocks.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3c05fbc..6fb9ded 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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, diff --git a/docs/L4-ai-orders.md b/docs/L4-ai-orders.md new file mode 100644 index 0000000..4a1fa92 --- /dev/null +++ b/docs/L4-ai-orders.md @@ -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=`** — 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. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index b38e10d..767ba52 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 @ 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 @@ -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 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 -- {_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 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] diff --git a/src/shim/hooks/ai_orders.cpp b/src/shim/hooks/ai_orders.cpp new file mode 100644 index 0000000..c905ec9 --- /dev/null +++ b/src/shim/hooks/ai_orders.cpp @@ -0,0 +1,490 @@ +#include "shim/hooks/ai_orders.h" + +#include +#include +#include +#include + +#define WIN32_LEAN_AND_MEAN +#include + +#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(p), n); +} + +inline std::uint32_t U32(std::uintptr_t p) { + return Readable(p, 4) ? *reinterpret_cast(p) : 0u; +} +inline std::uint8_t U8(std::uintptr_t p) { + return Readable(p, 1) ? *reinterpret_cast(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(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(n) < kMaxEvents) { + Event& e = g_events[n]; + e.seq = static_cast(n); + e.probe = static_cast(index); + e.pass = static_cast(p); + e.run = static_cast(g_runSeq); + e.agent = static_cast(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(reinterpret_cast(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(&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(w); + if (Readable(p, 4)) { + const std::uint32_t v = *reinterpret_cast(p); + hp += std::snprintf(hex + hp, sizeof hex - hp, "%08x ", v); + ip += std::snprintf(ints + ip, sizeof ints - ip, "%d ", static_cast(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(node), static_cast(F32(val)), + static_cast(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(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(b), pid, gRate, + static_cast(F32(b + 0x08)), gTgt, static_cast(U32(b + 0x10)), gBoost, + static_cast(U32(b + 0x18)), static_cast(F32(b + 0x1c)), gG4, + static_cast(U8(b + 0x24)), static_cast(U32(b + 0x28)), gF3, + static_cast(F32(b + 0x30)), static_cast(F32(b + 0x34)), + static_cast(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(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(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(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(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(n) > kMaxEvents ? kMaxEvents : static_cast(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(e.pass), e.agent, static_cast(e.probe), nm); + } + g_eventsWritten = have; + if (static_cast(n) > kMaxEvents) + LogF("aievent OVERFLOW -- %ld hits taken, only %u recorded; counters below are complete, " + "the ordering is not", + n, static_cast(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(blocksv); + LogF("---- aibatch seq=%u blocks=0x%08x n=%d stride=0x%x ----", g_batchSeq, + static_cast(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(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(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(v) < kProbeCount ? static_cast(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(g_probeInstallCount), static_cast(kProbeCount)); + + void* target = + reinterpret_cast(exeBase + sots::addr::StrategySim_ApplyTurnCommandBatch); + MH_STATUS s1 = MH_CreateHook(target, reinterpret_cast(&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(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(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 diff --git a/src/shim/hooks/ai_orders.h b/src/shim/hooks/ai_orders.h new file mode 100644 index 0000000..54452c9 --- /dev/null +++ b/src/shim/hooks/ai_orders.h @@ -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 +#include +#include + +namespace shim::hooks { + +// `aiorders=on|off`, `aiprobes=off|all|N`, `aiorders.out=`. 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 diff --git a/src/shim/main.cpp b/src/shim/main.cpp index be0ac29..45d6475 100644 --- a/src/shim/main.cpp +++ b/src/shim/main.cpp @@ -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(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()) { diff --git a/src/shim/shim.cfg.l4control b/src/shim/shim.cfg.l4control new file mode 100644 index 0000000..ffa64a3 --- /dev/null +++ b/src/shim/shim.cfg.l4control @@ -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 diff --git a/src/shim/shim.cfg.l4dump b/src/shim/shim.cfg.l4dump new file mode 100644 index 0000000..e27c28e --- /dev/null +++ b/src/shim/shim.cfg.l4dump @@ -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 diff --git a/src/shim/shim.cfg.l4off b/src/shim/shim.cfg.l4off new file mode 100644 index 0000000..aea4df0 --- /dev/null +++ b/src/shim/shim.cfg.l4off @@ -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 diff --git a/src/shim/shim.cfg.l4probes b/src/shim/shim.cfg.l4probes new file mode 100644 index 0000000..768424a --- /dev/null +++ b/src/shim/shim.cfg.l4probes @@ -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 diff --git a/tests/game_ai/CMakeLists.txt b/tests/game_ai/CMakeLists.txt index 4d02a51..ad2cb34 100644 --- a/tests/game_ai/CMakeLists.txt +++ b/tests/game_ai/CMakeLists.txt @@ -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) diff --git a/tests/game_ai/test_live_blocks.cpp b/tests/game_ai/test_live_blocks.cpp new file mode 100644 index 0000000..95bcc29 --- /dev/null +++ b/tests/game_ai/test_live_blocks.cpp @@ -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 +#include +#include + +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 CaptureTurnOneToTwo() { + std::vector 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 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(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 CaptureTurnTwoToThree() { + std::vector 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 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(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 with = + turn == 0 ? CaptureTurnOneToTwo() : CaptureTurnTwoToThree(); + std::vector without = with; + without[1].unmodelled[static_cast(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 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; +} diff --git a/tests/game_ai/test_orders.cpp b/tests/game_ai/test_orders.cpp index 0d0f578..6923966 100644 --- a/tests/game_ai/test_orders.cpp +++ b/tests/game_ai/test_orders.cpp @@ -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());