sots-engine/tests/game_ai/test_live_blocks.cpp
lane-l4 4a5c218a35 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.
2026-09-08 18:01:19 -04:00

248 lines
12 KiB
C++

// 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;
}