The milestone needs a second input. The AI is a client, not part of the sim: it decides once,
on one machine, and its decisions reach the server as commands. A save carries the board and
half the input, which is why our turn wrote ModCount 14 where the original writes 24 -- the
missing ten ARE the turn's command stream.
* `game/ai/apply_order` -- the thirty-step schedule the original drains a batch in: twenty-
seven per-LIST steps (every player's elements of one list before the next list starts) with
three per-PLAYER gate loops spliced in at step 10, 29 and 30. Neither list order nor member
order, and both facts are asserted so a port that sorted cannot pass.
* `game/ai/command_capture` -- a `.tcb` recorded turn: gates, list lengths, elements in wire
order, per-client seeds, and `?` for a field the instrument could not read. An element count
that disagrees with its declaration is REJECTED, because a counter quietly one short is
indistinguishable from a turn that issued one fewer command.
* `app/command_replay` -- applies it before the drivers, where the End-Turn dispatcher does.
Every command is CHARGED; only the ones whose subsystem we hold are APPLIED; the rest are
declined with the named gap, or marked incomplete when the capture itself lacks the payload.
* `--turn-commands`, `--replay-count-only`, `--replay-recorded-names`, `--ai-seed`.
Measured on a fresh build directory, canonical pair turn2-state -> turn3-state:
108 -> 62, closed 46, regressed 0 (was 108 -> 63, closed 45) -- /Sim/ModCount now reads the
original's 24, decomposed as 2 drivers + 4 research-rate gates + build + rates + list 10 +
two list-14 + fleet move, with the list-23 population element free.
turn1-state replayed against the SAME run's autosave closes 7 (ModCount and all six research
leaves); against the historical turn2-state it closes 6 and leaves player 512's research pick
diverging -- which is correct, because that recording is from a process that picked differently.
One prediction was falsified and it paid for itself: the first run regressed two leaves because
the rates element's MEMORY field order is not its wire order. The converter no longer claims a
mapping it cannot support.
Two new addresses (the second and third gate-loop heads) via ghidra/addresses.d/lane-rb.json;
header regenerated, never hand-resolved.
280 lines
12 KiB
C++
280 lines
12 KiB
C++
// The recorded-turn capture format, and the two real turns that exist in it.
|
|
//
|
|
// The two captures below are transcriptions of live dumps taken at the original's own
|
|
// command-application routine on two consecutive End Turns of the reference game. They are here
|
|
// as VALUES, the way an address is a value: they are what the game submitted, and nothing in
|
|
// this engine produced them.
|
|
//
|
|
// What the checks are for, in order of what they would catch:
|
|
//
|
|
// * THE COUNT. Each capture must cost exactly the ten command bumps the reference turns were
|
|
// measured at, and they must be two DIFFERENT tens -- one is four rate gates plus a build,
|
|
// rates, a fleet group and a population command; the other is four rate gates plus three
|
|
// research targets, a design, a build and rates. Ten twice is not a constant. It is the
|
|
// single most valuable thing in this file, because the counter is the one leaf of the save
|
|
// that cannot be reached from a save at all.
|
|
// * THE FOUR EMPTY SLOTS. The batch is sized to the player count, so the non-playing factions
|
|
// occupy slots with a player id of zero, every gate clear and every list empty. They must
|
|
// cost nothing. A parser that read their uninitialised gate payloads as SET gates would come
|
|
// out four bumps high and look plausible.
|
|
// * THE LOST ELEMENT. A capture whose declared list length disagrees with the elements it
|
|
// carries must be REJECTED, not silently short. A counter quietly one under is
|
|
// indistinguishable from a turn that issued one fewer command.
|
|
// * `?` IS NOT ZERO. An element the instrument could not fully read must come out incomplete,
|
|
// so the replayer counts it and declines to apply it. A parser that defaulted the unknown
|
|
// fields to zero would produce a confident wrong route and a confident wrong build order.
|
|
#include "game/ai/command_capture.h"
|
|
|
|
#include <cstdio>
|
|
#include <string>
|
|
|
|
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::fprintf(stderr, "FAIL: %s\n", what.c_str());
|
|
}
|
|
}
|
|
|
|
// The reference turn: turn 2 -> turn 3 of the recorded game. Four clients submit; one of them
|
|
// has orders. The rates element's slider values are `?` on purpose -- the dump reads the frame
|
|
// in MEMORY order and the memory order of that frame is not its wire order, a fact this campaign
|
|
// learned by writing the one non-zero slider into the wrong member and regressing two leaves.
|
|
const char* kTurn2to3 = R"(tcb 1
|
|
meta source l4-turn2to3-aiorders.txt
|
|
meta batch seq=2 n=8
|
|
block 0 16
|
|
gate 0 rate 0.25
|
|
block 1 32
|
|
gate 1 rate 0.8
|
|
list 1 3 1
|
|
elem 1 3 0 i2 i18 i288 i0
|
|
list 1 5 1
|
|
elem 1 5 0 i288 ? ? ? ? ? ? ?
|
|
list 1 8 1
|
|
elem 1 8 0 i34 v1
|
|
list 1 10 1
|
|
elem 1 10 0 i288 i34 v1
|
|
list 1 14 2
|
|
elem 1 14 0 i34 i0 b1
|
|
elem 1 14 1 i34 i1 b1
|
|
list 1 23 1
|
|
elem 1 23 0 i288 ?
|
|
block 2 496
|
|
gate 2 rate 0.8
|
|
block 3 512
|
|
gate 3 rate 0.8
|
|
block 4 0
|
|
block 5 0
|
|
block 6 0
|
|
block 7 0
|
|
)";
|
|
|
|
// The turn before it. A different ten: three research targets and a new ship design, and NOT one
|
|
// element in the fleet group.
|
|
const char* kTurn1to2 = R"(tcb 1
|
|
meta source l4-turn1to2-aiorders.txt
|
|
seed client0 0x11223344
|
|
block 0 16
|
|
gate 0 rate 0.25
|
|
block 1 32
|
|
gate 1 rate 0.8
|
|
gate 1 target 144 name IND_Waldo
|
|
list 1 1 1
|
|
elem 1 1 0 ?
|
|
list 1 3 1
|
|
elem 1 3 0 i1 i18 i288 i0
|
|
list 1 5 1
|
|
elem 1 5 0 i288 ? ? ? ? ? ? ?
|
|
list 1 23 1
|
|
elem 1 23 0 i288 ?
|
|
block 2 496
|
|
gate 2 rate 0.8
|
|
gate 2 target 90 name DRV_PlsFiss
|
|
block 3 512
|
|
gate 3 rate 0.8
|
|
gate 3 target 288 name XNC_TrnsMorr2
|
|
block 4 0
|
|
block 5 0
|
|
block 6 0
|
|
block 7 0
|
|
)";
|
|
|
|
Capture Parse(const char* text, const std::string& what) {
|
|
Capture c;
|
|
CaptureDiagnostics d;
|
|
const bool ok = ParseCapture(text, c, d);
|
|
check(ok, what + " parses");
|
|
for (const auto& e : d.errors) std::fprintf(stderr, " parse error: %s\n", e.c_str());
|
|
return c;
|
|
}
|
|
|
|
ModCountCost CostOf(const Capture& c) {
|
|
std::vector<TurnCommandBlock> blocks;
|
|
for (const auto& b : c.blocks) blocks.push_back(ToTurnCommandBlock(b));
|
|
return TurnModCountDelta(blocks);
|
|
}
|
|
|
|
void TestReferenceTurnTwoToThree() {
|
|
const Capture c = Parse(kTurn2to3, "turn 2->3");
|
|
check(c.blocks.size() == 8, "the batch is sized to the player count, not the submitter count");
|
|
int submitting = 0;
|
|
for (const auto& b : c.blocks) submitting += b.playerId != 0 ? 1 : 0;
|
|
check(submitting == 4, "four clients submit");
|
|
|
|
const CapturedBlock& ai = c.blocks[1];
|
|
check(ai.playerId == 32, "block 1 is the AI empire");
|
|
check(ai.List(3).size() == 1 && ai.List(5).size() == 1 && ai.List(8).size() == 1 &&
|
|
ai.List(10).size() == 1 && ai.List(14).size() == 2 && ai.List(23).size() == 1,
|
|
"lists 3, 5, 8, 10, 14x2 and 23");
|
|
for (int n = 1; n <= kCommandListCount; ++n) {
|
|
const bool expected = n == 3 || n == 5 || n == 8 || n == 10 || n == 14 || n == 23;
|
|
check(ai.List(n).empty() != expected, "list " + std::to_string(n) + " emptiness");
|
|
}
|
|
|
|
// The build order, in WIRE order: ordinal, design, system, trailing. The ordinal is the
|
|
// running build-queue index and it is 2 on this turn and 1 on the one before, which is the
|
|
// cross-check that the descending memory order was undone the right way round.
|
|
const TurnCommandBlock t = ToTurnCommandBlock(ai);
|
|
check(t.build.size() == 1 && t.build[0].ordinal == 2 && t.build[0].designId == 18 &&
|
|
t.build[0].systemId == 288,
|
|
"build order {ordinal 2, design 18, system 288}");
|
|
// The AI's fleet order is TWO list-14 elements against one fleet, modes 0 then 1. That is
|
|
// the prediction the whole cost model turned on, and it is here as element values.
|
|
check(t.fleetTasks.size() == 2 && t.fleetTasks[0].fleetId == 34 &&
|
|
t.fleetTasks[0].mode == 0 && t.fleetTasks[1].fleetId == 34 &&
|
|
t.fleetTasks[1].mode == 1,
|
|
"one fleet, modes 0 and 1");
|
|
// The route's length is known and its hops are not, and the element must say so.
|
|
check(t.fleetMoves.size() == 1 && t.fleetMoves[0].fleetId == 34 &&
|
|
t.fleetMoves[0].route.empty(),
|
|
"the fleet move names fleet 34 with an unread route");
|
|
check(!ai.List(8)[0].complete, "an unread route makes the element incomplete");
|
|
check(!ai.List(5)[0].complete, "an unread rates frame makes the element incomplete");
|
|
check(ai.List(3)[0].complete, "the build order is fully read");
|
|
|
|
const ModCountCost cost = CostOf(c);
|
|
check(cost.exact, "the cost is exact -- no gate with an unlocated applier is set");
|
|
check(cost.bumps == 12, "the reference turn costs 12: two drivers plus ten commands");
|
|
check(cost.bumps - kTurnDriverBumps == 10, "ten command bumps");
|
|
}
|
|
|
|
void TestTurnOneToTwo() {
|
|
const Capture c = Parse(kTurn1to2, "turn 1->2");
|
|
int targets = 0;
|
|
for (const auto& b : c.blocks) targets += b.GateSet(PrologueGate::ResearchTarget) ? 1 : 0;
|
|
check(targets == 3, "three AI clients set a research target; the human does not");
|
|
check(c.blocks[1].researchTarget == 144 && c.blocks[1].researchTargetName == "IND_Waldo",
|
|
"the target gate carries an id AND the name the instrument saw it resolve to");
|
|
check(c.blocks[0].researchTargetName.empty(), "the human's target gate is clear");
|
|
check(c.blocks[1].List(1).size() == 1, "a new ship design");
|
|
check(c.blocks[1].List(8).empty() && c.blocks[1].List(10).empty() &&
|
|
c.blocks[1].List(14).empty(),
|
|
"and NOT one element of the fleet group");
|
|
check(c.seeds.size() == 1 && c.Seed("client0") && c.Seed("client0")->seed == 0x11223344u,
|
|
"an AI client's construction seed is carried");
|
|
check(c.Seed("nobody") == nullptr, "an unknown client has no seed");
|
|
|
|
const ModCountCost cost = CostOf(c);
|
|
check(cost.bumps == 12, "this turn also costs 12");
|
|
// Same total, different composition. That is the point of having both.
|
|
const Capture ref = Parse(kTurn2to3, "turn 2->3 (again)");
|
|
check(CostOf(ref).bumps == cost.bumps, "the two turns agree on the total");
|
|
check(c.blocks[1].List(3).size() == ref.blocks[1].List(3).size(), "both build once");
|
|
check(!ref.blocks[1].GateSet(PrologueGate::ResearchTarget) &&
|
|
c.blocks[1].GateSet(PrologueGate::ResearchTarget),
|
|
"and disagree on every other term: three targets here, none there");
|
|
check(ref.blocks[1].List(14).size() == 2 && c.blocks[1].List(14).empty(),
|
|
"a fleet group there, none here");
|
|
}
|
|
|
|
void TestEmptySlotsCostNothing() {
|
|
const Capture c = Parse(kTurn2to3, "turn 2->3 (slots)");
|
|
for (std::size_t i = 4; i < c.blocks.size(); ++i) {
|
|
const TurnCommandBlock t = ToTurnCommandBlock(c.blocks[i]);
|
|
check(BlockModCountCost(t).bumps == 0, "an unwritten batch slot costs nothing");
|
|
}
|
|
// A block whose only content is the always-set rate gate still costs one. Four of this
|
|
// turn's ten are exactly that, and one of the four is the human's.
|
|
const TurnCommandBlock human = ToTurnCommandBlock(c.blocks[0]);
|
|
check(BlockModCountCost(human).bumps == 1, "a player who ordered nothing still costs one");
|
|
}
|
|
|
|
void TestRejections() {
|
|
struct Case {
|
|
const char* text;
|
|
const char* what;
|
|
};
|
|
const Case bad[] = {
|
|
{"block 0 16\n", "no magic line"},
|
|
{"tcb 2\nblock 0 16\n", "a version this reader does not speak"},
|
|
{"tcb 1\nblock 1 16\nblock 0 32\n", "blocks out of order"},
|
|
{"tcb 1\nblock 0 16\nblock 0 32\n", "a block declared twice"},
|
|
{"tcb 1\nblock 0 16\nlist 0 3 2\nelem 0 3 0 i1 i2 i3 i4\n", "a lost element"},
|
|
{"tcb 1\nblock 0 16\nlist 0 3 0\nelem 0 3 0 i1\n", "an element too many"},
|
|
{"tcb 1\nblock 0 16\nelem 0 3 0 i1\n", "an element with no declared count"},
|
|
{"tcb 1\nblock 0 16\nlist 0 28 1\nelem 0 28 0 i1\n", "a list number out of range"},
|
|
{"tcb 1\nblock 0 16\nlist 0 3 1\nelem 0 3 0 q9\n", "an unreadable field"},
|
|
{"tcb 1\nblock 0 16\nlist 0 8 1\nelem 0 8 0 i1 v2:1\n", "a vector shorter than it claims"},
|
|
{"tcb 1\nblock 0 16\ngate 0 rate\n", "a gate with no payload"},
|
|
{"tcb 1\nlist 0 3 0\n", "a record for a block that was never opened"},
|
|
{"tcb 1\nblock 0 16\nseed a 1\nseed a 2\n", "a client seeded twice"},
|
|
};
|
|
for (const auto& b : bad) {
|
|
Capture c;
|
|
CaptureDiagnostics d;
|
|
const bool ok = ParseCapture(b.text, c, d);
|
|
check(!ok && !d.errors.empty(), std::string("rejected: ") + b.what);
|
|
}
|
|
// And the civilian-ratios gate is accepted but must WARN, because its applier has never been
|
|
// located: the counter for such a turn is a lower bound and silence would hide that.
|
|
Capture c;
|
|
CaptureDiagnostics d;
|
|
check(ParseCapture("tcb 1\nblock 0 16\ngate 0 civilian\n", c, d), "the civilian gate parses");
|
|
check(!d.warnings.empty(), "and warns that the cost is a lower bound");
|
|
const TurnCommandBlock t = ToTurnCommandBlock(c.blocks[0]);
|
|
check(!BlockModCountCost(t).exact, "and makes the block's cost inexact");
|
|
}
|
|
|
|
void TestFieldKinds() {
|
|
Capture c;
|
|
CaptureDiagnostics d;
|
|
check(ParseCapture("tcb 1\nblock 0 16\nlist 0 9 1\nelem 0 9 0 i-7 f0.5 b1 s:Alpha v3:1,2,3 ?\n",
|
|
c, d),
|
|
"every field kind parses");
|
|
const CapturedElement& e = c.blocks[0].List(9)[0];
|
|
check(e.fields.size() == 6, "six fields");
|
|
check(e.fields[0].kind == CaptureField::Kind::Int && e.fields[0].i == -7, "a negative int");
|
|
check(e.fields[1].kind == CaptureField::Kind::Float && e.fields[1].f == 0.5f, "a float");
|
|
check(e.fields[2].kind == CaptureField::Kind::Bool && e.fields[2].b, "a bool");
|
|
check(e.fields[3].kind == CaptureField::Kind::Str && e.fields[3].s == "Alpha", "a string");
|
|
check(e.fields[4].vecValues && e.fields[4].vec.size() == 3, "a vector with values");
|
|
check(!e.complete, "one `?` makes the whole element incomplete");
|
|
// A vector whose length is known and whose contents are not is ALSO incomplete -- that is the
|
|
// route case, and it is the difference between counting a command and applying it.
|
|
Capture c2;
|
|
CaptureDiagnostics d2;
|
|
ParseCapture("tcb 1\nblock 0 16\nlist 0 8 1\nelem 0 8 0 i34 v1\n", c2, d2);
|
|
const CapturedElement& route = c2.blocks[0].List(8)[0];
|
|
check(route.fields[1].vecCount == 1 && !route.fields[1].vecValues, "a length without values");
|
|
check(!route.complete, "and that makes the element incomplete");
|
|
}
|
|
|
|
} // namespace
|
|
|
|
int main() {
|
|
TestReferenceTurnTwoToThree();
|
|
TestTurnOneToTwo();
|
|
TestEmptySlotsCostNothing();
|
|
TestRejections();
|
|
TestFieldKinds();
|
|
std::printf("game_ai/command_capture: %d checks, %d failures\n", g_checks, g_fails);
|
|
return g_fails == 0 ? 0 : 1;
|
|
}
|