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.
248 lines
12 KiB
C++
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;
|
|
}
|