From 1e6474b31c576967cc2dbcf1050d8a3558fd1473 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 18:33:17 -0400 Subject: [PATCH 1/3] RB: predictions for the recorded-command replay, before the module exists The headline is one number: our turn writes ModCount 14 where the oracle writes 24, and the missing ten are the turn's command stream. P1 decomposes the ten and names what each near miss would mean; P5 predicts the secondary pair closes five leaves and must NOT close the sixth, because the recording is from a run that diverged there. --- docs/RB-predictions.md | 182 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 182 insertions(+) create mode 100644 docs/RB-predictions.md diff --git a/docs/RB-predictions.md b/docs/RB-predictions.md new file mode 100644 index 0000000..c0034e8 --- /dev/null +++ b/docs/RB-predictions.md @@ -0,0 +1,182 @@ +# Rung B — predictions, written before the replay module exists + +Lane RB, 2026-09-08. Committed **before** `src/game/ai/command_capture.*`, +`src/game/ai/apply_order.*` or `src/app/command_replay.*` were written, and before any +`--turn-commands` run. Rule 2. + +Baseline taken on a **fresh** `build-host` in this worktree (rule 24), `main` at `4f25f1e`, +ctest 54/54, header regenerated from `~/sots-re/ghidra` (1,217 entries, no diff): + +``` +turn2-state.sav -> turn3-state.sav : baseline 108, after our turn 63, closed 45, regressed 0 + our /Sim/ModCount = 14 oracle = 24 input = 12 +``` + +The whole lane is aimed at that one arithmetic gap: our turn contributes the **two driver +bumps** and nothing else, because a command stream is the only other writer and a save does +not carry the AI's. + +--- + +## The inputs this lane replays + +Lane L4's live dumps at `StrategySim::ApplyTurnCommandBatch`, already in the RE repo: +`verify/results/shim/aiorders/l4-turn{1to2,2to3}-aiorders.txt`. Each holds two batches; the +**second** (`seq=2`, `n=8`) is the End-Turn submission. The first (`seq=1`, `n=1`) is the +load-time batch and is deliberately **excluded** — our standalone loads from disk and never +runs it, and the save's on-disk `ModCount` is the value before it. + +--- + +## P1 — `ModCount` lands on exactly 24 on the canonical pair + +Replaying the `turn2to3` block set against `turn2-state.sav` makes `/Sim/ModCount` read +**24**, closing that leaf. The decomposition, item by item, from AI4's cost table: + +| term | count | +|---|---:| +| turn drivers (`S00`, `T00`) — already ours | 2 | +| research-rate gate, blocks 0–3 (pids 16, 32, 496, 512) | 4 | +| list 5 system rates ×1 | 1 | +| list 3 build ×1 | 1 | +| list 10 ×1 | 1 | +| list 14 fleet task ×2 | 2 | +| list 8 fleet move ×1 | 1 | +| list 23 population ×1 — **free half** | 0 | +| **total** | **24** | + +**Falsified if** the run reports anything but 24, and each near miss names its own defect: + +* **25** — list 23 charged; the 17..27 free half is wrong and AI4 §1 must be reopened. +* **23** — the AI's fleet order counted as the interface's single list-14 element; AI2's P1 + and the `(fleetId, mode)` dedup key are wrong. +* **28** — the four uninitialised monster slots' garbage rate payloads read as *set* gates. + This is the parser failing, not the model: those slots carry `pid == 0` with the gate bit + clear and a garbage float behind it. +* **22** — the human's block (pid 16, every list empty) charged 0 instead of 1. The + research-rate gate is set unconditionally by the send-buffer build; a block that ordered + nothing still costs one. This is the boundary case rule 23 asks for and it carries four of + the ten. + +## P2 — the apply order is this permutation, and it is neither list order nor offset order + +The batch is a flat run of 27 per-list loops with three per-player gate loops spliced in: + +``` +lists 6 11 20 19 17 18 5 23 24 +gate loop A { group5(+0x3c, free), target(+0x14, bump), rate(+0x0c, bump) } +lists 1 4 3 21 2 22 9 10 12 13 14 15 16 7 8 25 27 26 +gate loop B { boost(+0x20, bump) } +gate loop C { group4(+0x2c, bump) } +``` + +Each list step is `for each player block: for each element: apply` — so **every player's +list-6 elements are applied before any player's list-11 element**. Order is per list, not +per player. + +**How I will verify it, three ways, and what each cannot show.** + +1. **Address monotonicity of the six inlined bump sites.** `ApplyTurnCommandBatch` inlines + six appliers and each writes `ModCount` in place: `0x0088fe0a` (gate A target), + `0x008902fe` (list 12), `0x008903b9` (list 13), `0x0089046c` (list 14), `0x008905c8` + (list 7), `0x008907bc` (gate B boost). Their positions in the permutation above are + 10th, 18th, 19th, 20th, 23rd and 28th. Adding the three gate-loop heads (A `0x0088fdb0`, + B `0x008907b1`, C `0x0089080a`) gives a nine-point chain that must be strictly increasing + in both address and position. **This is a check on 9 of 30 positions and no more** — the + other 21 lists' handlers are out-of-line and this lane has no record of their call-site + addresses inside the batch, so their relative order rests on AI4's direct read of the + `add edi, imm` sequence and is inherited, not re-derived. + *Falsified if* any of those nine is out of order. +2. **The permutation is a bijection of 1..27**, asserted at compile time. Catches a + transcription slip, catches nothing about the order being right. +3. **Neither sorted.** Asserted: the list sequence is not ascending, and the member-offset + sequence implied by it is not ascending either. A naive implementation that loops + `for (list = 1..27)` or walks the block's members in memory order cannot pass this — which + is the only reason the test earns its place, since on every workload the corpus holds the + *outcome* is order-independent (no two commands in either capture touch the same object + through a modelled handler). + +**Stated plainly: apply order is unfalsifiable on this workload.** Both captures put every +non-empty list on one player and every command on one system, so any permutation produces the +same save and the same count. The order is implemented because a later workload will need it, +and it is tested against the instruction stream rather than against an outcome. + +## P3 — a replay closes `ModCount` and nothing else, and regresses nothing + +Predicted canonical-pair result: **closed 46 (45 + `/Sim/ModCount`), regressed 0, remaining 62.** + +Of the ten commands in the `turn2to3` block set, only three have a handler this lane can +write, and all three are **no-ops on this workload**: + +* the research-rate gate — `ServerPlayer.ResRate` already reads 0.25/0.8 in `turn2-state`; +* list 5 system rates — Ke'Dolarra's rates already read `{0, 1.0, 0, 0, 0, 0, 0}`; +* the research-target gate — not set on any block this turn. + +The other seven need subsystems we do not have (P4). So the replay's entire contribution to +the byte-match distance is the counter. + +**Falsified if:** any further leaf closes — which would mean one of the seven is more +modellable than I claimed, or that a "no-op" write is not one; or any leaf regresses — which +would mean a modelled handler writes the wrong field, and is the failure this prediction +exists to catch. + +## P4 — the no-op control + +Running `--turn-commands` with the three modelled handlers enabled must change **zero** leaves +relative to the same run with them suppressed. That is the control on "these are no-ops here": +a rate written to the wrong player, or rates written into the wrong system's frame, shows up +as a regression against a run that wrote nothing, even though both agree with the oracle by +luck. Rule 1 — the outcome agreeing is not evidence the write went to the right place. + +## P5 — the secondary pair, where the stream actually does something + +`turn1-state.sav` carries `ModCount 0`; the oracle `turn2-state.sav` carries 12. The +`turn1to2` capture holds 4 rate gates, **3 research-target gates** (ids 144, 90, 288 on +players 32, 496, 512), one list-1 design, one list-3 build, one list-5 rates and one list-23 +population. + +**Prediction:** `ModCount` 0 → **12** (2 + 4 + 3 + 1 + 1 + 1 + 0), and — if and only if the +capture carries the resolved tech *names* — the replay closes `ResRate` on players 32, 496 and +512 and `ResTNm` on 32 (`IND_Waldo`) and 496 (`DRV_PlsFiss`). + +**And it must NOT close `ResTNm` on player 512.** The capture records target id **288**, which +that run resolved to `XNC_TrnsMorr2`; `turn2-state.sav` holds `BIO_GnMod`. The recording is +from a *different process* than the oracle, and 512's pick is the one leaf lanes L4 and L5 +both showed is not reproducible. So the recorded stream and the reference save genuinely +disagree there, and a replay that closed it would mean I had fitted something. + +*This is the sharpest prediction in the set*, because it is the one where the replay's output +is determined by the recording and the recording is known to be from a divergent run. If 512 +closes, something is copying the oracle rather than the capture. + +*Falsified if:* `ModCount` ≠ 12; or `ResRate` fails to close on all three; or 512's `ResTNm` +closes; or 32/496 fail to close while the capture does carry names. + +## P6 — the wire carries an id, the save carries a name, and we cannot bridge them + +The research-target gate's payload is an `int techId` (`block+0x10`). `ResTNm` on the wire is +a string. Process Turn phase 18 resolves the chosen object's `std::string` at `+0x4` and +passes a `char*` to `cl_SetResearchTarget`, so the resolution happens **client-side, off the +command**, and the applier receives an id whose mapping to a name this campaign has not read. +`144`, `90` and `288` are not `index * 16` and are not indices into anything we hold. + +**Prediction:** the target-gate handler cannot be modelled from the id alone, and the capture +format must therefore be able to carry the *observed* name alongside the id. When it does, the +leaf is closed **by recorded payload, not by model**, and the report must say so in a separate +column — otherwise a future reader will mistake a transcription for a reimplementation. + +*Falsified if:* someone finds the id→name map, at which point this becomes a modelled handler +and the column empties. + +--- + +## What this lane is not predicting + +* **Nothing about `Summary.Checksum`.** Its inputs are unread; it moves whenever anything else + does. +* **Nothing about the RNG frame.** No command in either capture draws. +* **Nothing about the load-time batch.** `seq=1` is excluded by construction (above), and + whether the original charges `ModCount` for it is untested — a save loaded and immediately + re-saved would settle it in one run and this lane does not do it. +* **Nothing about ordering effects between two commands on the same object.** No workload has + one. From 98257b9e8260855e973d7caa1d73ac6352e05098 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 18:57:46 -0400 Subject: [PATCH 2/3] RB: sots_turn --turn-commands replays a recorded command stream, and ModCount closes 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. --- include/generated/sots_addresses.h | 6 +- src/app/CMakeLists.txt | 3 +- src/app/command_replay.cpp | 323 ++++++++++++++ src/app/command_replay.h | 87 ++++ src/app/main.cpp | 110 ++++- src/app/report.cpp | 41 ++ src/app/report.h | 5 + src/app/turn.cpp | 11 + src/app/turn.h | 14 + src/game/ai/CMakeLists.txt | 2 + src/game/ai/apply_order.cpp | 84 ++++ src/game/ai/apply_order.h | 94 +++++ src/game/ai/command_capture.cpp | 554 +++++++++++++++++++++++++ src/game/ai/command_capture.h | 160 +++++++ tests/app/CMakeLists.txt | 7 + tests/app/test_command_replay.cpp | 233 +++++++++++ tests/game_ai/CMakeLists.txt | 15 + tests/game_ai/test_apply_order.cpp | 158 +++++++ tests/game_ai/test_command_capture.cpp | 280 +++++++++++++ 19 files changed, 2184 insertions(+), 3 deletions(-) create mode 100644 src/app/command_replay.cpp create mode 100644 src/app/command_replay.h create mode 100644 src/game/ai/apply_order.cpp create mode 100644 src/game/ai/apply_order.h create mode 100644 src/game/ai/command_capture.cpp create mode 100644 src/game/ai/command_capture.h create mode 100644 tests/app/test_command_replay.cpp create mode 100644 tests/game_ai/test_apply_order.cpp create mode 100644 tests/game_ai/test_command_capture.cpp diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index d7b7281..a85c619 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 @ 940aeca, generated 2026-09-08 by tools/gen_addresses.py +// Source: sots-re ghidra/addresses.json @ af67d31, generated 2026-09-08 by tools/gen_addresses.py // Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated). #pragma once #include @@ -2101,6 +2101,10 @@ constexpr uint32_t TurnCommands_WriteBuildOrderList = 0x00422870; constexpr uint32_t TurnCommands_WriteSystemRatesList = 0x0042e3d0; // cdecl void __cdecl (Mars::IStream* s, std::list* orders) -- writer for TurnCommands member +0xb8, the COLONIZE list. Per element two WriteInts (node+0x8 then node+0xc): {shipId, w}. Observed once, in zuul-turn17-orders2.sav: three ships 2512/2544/2624, each with w = 1 [verified] constexpr uint32_t TurnCommands_WriteColonizeList = 0x00422960; +// label the SECOND of the three per-player gate loops inside StrategySim::ApplyTurnCommandBatch. `esi = block+0x20`; it tests the research-boost gate at +0x20 and applies its {spend, fraction} payload through 0x00820560, bumping ModCount inline at 0x008907bc. It runs AFTER all twenty-seven list loops, not with the other gates -- the six prologue gates are split across three loops at three points in the routine, which is why a port that applies them together as a prologue gets the order wrong [verified] +constexpr uint32_t StrategySim_ApplyTurnCommandBatch_GateLoopB = 0x004907b1; +// label the THIRD and last per-player gate loop inside StrategySim::ApplyTurnCommandBatch. `esi = block+0x24`; it tests the group-4 gate at +0x2c and applies its {bool, int} payload through 0x00821b90. It is the final step of the whole batch. Together with the loop-A head at 0x0088fdb0 and the loop-B head at 0x008907b1, and the six inlined ModCount bump sites, this gives nine positions of the thirty-step apply schedule an address-monotonicity check -- the only part of the sequence that can be re-derived rather than inherited from the read of the `add edi, imm` chain [verified] +constexpr uint32_t StrategySim_ApplyTurnCommandBatch_GateLoopC = 0x0049080a; // thiscall int (Game::SVScriptObject* this, int evt, void* arg) // the script-object event bus. Calls this->vft[0x10](evt, arg) -- the GENERIC handler every object sees -- then `cmp evt,0x20; ja done; jmp dword [evt*4 + SVScriptObject_EventSlotJumpTable]`, which dispatches to ONE event-specific vtable slot with the argument shape that event carries. Every hand-written `vft[0x10](id,0); vft[slot]()` pair in the two turn drivers is this same two-step done on the root object [verified] constexpr uint32_t SVScriptObject_DispatchEvent = 0x003a60d0; // data void* [33] // evt (0..0x20) -> the vtable slot SVScriptObject_DispatchEvent calls. Slot byte offsets in evt order: 0x14 0x18 0x1c 0x20 0x24 0x28 0x2c 0x30 0x34 0x38 0x3c 0x40 0x44 0x48 0x4c 0x50 0x54 0x58 0x5c 0x60 0x64 0x6c 0x70 0x74 0x68 0x7c 0x80 0x84 0x78 0x88 0x8c 0x90 0x94. Note 0x15->+0x6c, 0x16->+0x70, 0x17->+0x74, 0x18->+0x68 and 0x1c->+0x78 are NOT in slot order [verified] diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index d26057c..029b97b 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -14,6 +14,7 @@ add_library(sots_app STATIC growth_phase.cpp script_phase.cpp visibility_phase.cpp + command_replay.cpp turn.cpp report.cpp) target_include_directories(sots_app PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..) @@ -21,7 +22,7 @@ target_include_directories(sots_app PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/..) # needs a design's hull size and defence-platform flag, which are recomputed from the section # catalog and are nowhere on the wire. No game data is embedded; the root is supplied at run # time and its absence costs exactly six fields. -target_link_libraries(sots_app PUBLIC mars_stream mars_rng sots_game_sim sots_game_events game_design) +target_link_libraries(sots_app PUBLIC mars_stream mars_rng sots_game_sim sots_game_events sots_game_ai game_design) target_compile_features(sots_app PUBLIC cxx_std_17) if(NOT MSVC) target_compile_options(sots_app PRIVATE -Wall -Wextra -Werror) diff --git a/src/app/command_replay.cpp b/src/app/command_replay.cpp new file mode 100644 index 0000000..2385c3e --- /dev/null +++ b/src/app/command_replay.cpp @@ -0,0 +1,323 @@ +#include "app/command_replay.h" + +#include +#include + +#include "game/ai/apply_order.h" + +namespace sots::app { +namespace { + +using mars::stream::shapes::Player; +using mars::stream::shapes::SaveGame; +using mars::stream::shapes::Sys; + +std::string Fmt(const char* f, ...) { + char buf[512]; + va_list ap; + va_start(ap, f); + std::vsnprintf(buf, sizeof buf, f, ap); + va_end(ap); + return std::string(buf); +} + +Player* FindPlayer(SaveGame& game, int playerId) { + for (auto& e : game.sim.players) + if (e.playerID == playerId) return &e.player; + return nullptr; +} + +Sys* FindSystem(SaveGame& game, int sysId) { + for (auto& e : game.sim.systems) + if (e.sysID == sysId) return &e.sys; + return nullptr; +} + +// Why each list's command cannot be written here. Every one of these is a named subsystem, not a +// shrug: the point of declining is that the gap is a work item. +// +// Lists nobody has ever seen populated get the rule-6 answer, which is the honest one -- their +// element record is read from the writer's instruction stream and has never been exercised, so +// even the fields are a hypothesis and an applier built on them would be guessing twice. +const char* DeclineReason(int list) { + switch (list) { + case 1: + return "a new ship design, carried as an object with its own client-allocated id; " + "this engine has design rules but no design-registration path, and the id " + "allocator the client uses is unread"; + case 3: + return "ship construction: no phase in this engine builds a ship, so the build " + "queue's ordinal, the maintenance charge, the savings debit and the hull's " + "id allocation all have no model"; + case 7: + return "colonisation from a named colony ship: the colony formulas exist but the " + "ship-to-planet resolution the command relies on does not"; + case 8: + return "the order names a fleet the input save does not contain -- the client " + "allocates the fleet object AND its id before it submits, and that allocator " + "is unread, so applying a route would move a fleet that does not exist"; + case 10: + return "an unnamed command; its three words fit 'assign these ships to this fleet at " + "this system' and that reading has never been tested"; + case 14: + return "a fleet task keyed on (fleet, mode); the two modes' effects are unread, and " + "the fleet it names is the client-allocated one from list 8"; + case 23: + return "a population command whose body is a Population object behind a vftable that " + "the capture window does not follow"; + default: + return "no workload has ever populated this list, so its element record is read from " + "the writer's instruction stream alone; an applier built on it would be a " + "hypothesis about a hypothesis"; + } +} + +const char* GateName(sots::ai::PrologueGate g) { + switch (g) { + case sots::ai::PrologueGate::ResearchRate: + return "research rate"; + case sots::ai::PrologueGate::ResearchTarget: + return "research target"; + case sots::ai::PrologueGate::ResearchBoost: + return "research boost"; + case sots::ai::PrologueGate::Group4: + return "group 4"; + case sots::ai::PrologueGate::Group5: + return "group 5 (three floats)"; + case sots::ai::PrologueGate::CivilianRatios: + return "civilian ratios"; + } + return "?"; +} + +} // namespace + +ReplayReport ReplayTurnCommands(SaveGame& game, const sots::ai::Capture& cap, + const ReplayOptions& opt) { + using namespace sots::ai; + + ReplayReport r; + r.ran = true; + r.blocks = static_cast(cap.blocks.size()); + + for (const auto& b : cap.blocks) { + bool any = b.playerId != 0; + for (int n = 1; n <= kCommandListCount && !any; ++n) any = !b.List(n).empty(); + for (int g = 0; g < kPrologueGateCount && !any; ++g) + any = b.GateSet(static_cast(g)); + // A gate the schedule does not cover. Exactly one exists -- the civilian-ratios command, + // for which no applier has been found anywhere in the command-application path -- and + // its absence from the walk is deliberate: a schedule that included it would be claiming + // a cost of zero for something whose cost is unknown. It is caught here instead, so the + // count is reported as a LOWER BOUND rather than silently under-charged. + for (int g = 0; g < kPrologueGateCount; ++g) { + const auto gate = static_cast(g); + if (b.GateSet(gate) && GateModCountCost(gate) == GateCost::Unknown) { + r.bumpsExact = false; + r.warnings.push_back( + Fmt("block %d sets the %s gate, which has no located applier: the count " + "below is a lower bound and this command is neither applied nor costed", + b.index, GateName(gate))); + } + } + if (b.playerId != 0) ++r.submittingBlocks; + else if (any) + r.warnings.push_back(Fmt( + "block %d carries commands but no player id; the batch is sized to the player " + "count and the slots no client wrote hold an id of zero, so this block is " + "either a capture defect or a case nothing has seen", + b.index)); + } + + auto record = [&](int step, const std::string& what, const CapturedBlock& b, int bumps, + ReplayDisposition d, const std::string& reason, int leaves) { + ReplayCommand c; + c.step = step; + c.what = what; + c.blockIndex = b.index; + c.playerId = b.playerId; + c.bumps = bumps; + c.disposition = d; + c.reason = reason; + c.leafWrites = leaves; + r.log.push_back(std::move(c)); + ++r.commands; + r.bumps += bumps; + r.leafWrites += leaves; + switch (d) { + case ReplayDisposition::Applied: + ++r.applied; + break; + case ReplayDisposition::Transcribed: + ++r.transcribed; + break; + case ReplayDisposition::Declined: + ++r.declined; + break; + case ReplayDisposition::Incomplete: + ++r.incomplete; + break; + } + }; + + // The walk. Steps in the original's order; within a LIST step every block is visited before + // the next step begins, and within a GATE step every block is visited once and its gates are + // tested in the step's own order. + for (int s = 0; s < kApplyStepCount; ++s) { + const ApplyStep& step = kApplySchedule[s]; + + if (step.kind == ApplyStepKind::List) { + for (const auto& b : cap.blocks) { + const auto& elems = b.List(step.list); + if (elems.empty()) continue; + for (std::size_t i = 0; i < elems.size(); ++i) { + const CapturedElement& e = elems[i]; + const int bumps = step.bumps ? 1 : 0; + const std::string what = + Fmt("%s [%zu]", step.label, i); + + // The one list whose applier this engine holds outright: a system's + // planetary-budget sliders are a plain frame on the system and the command + // carries every field of it. + if (step.list == 5) { + int sysId = 0; + float v[6] = {0, 0, 0, 0, 0, 0}; + int noRate = 0; + bool ok = e.fields.size() >= 8 && + e.fields[0].kind == CaptureField::Kind::Int; + if (ok) sysId = e.fields[0].i; + for (int k = 0; k < 6 && ok; ++k) { + const CaptureField& f = e.fields[static_cast(k + 1)]; + if (f.kind == CaptureField::Kind::Float) v[k] = f.f; + else if (f.kind == CaptureField::Kind::Int) v[k] = static_cast(f.i); + else ok = false; + } + if (ok && e.fields[7].kind == CaptureField::Kind::Int) noRate = e.fields[7].i; + else ok = false; + if (!ok) { + record(s, what, b, bumps, ReplayDisposition::Incomplete, + "the capture does not carry all eight fields of the rates " + "frame", 0); + continue; + } + Sys* sys = FindSystem(game, sysId); + if (!sys) { + record(s, what, b, bumps, ReplayDisposition::Declined, + Fmt("the save holds no system %d", sysId), 0); + continue; + } + int leaves = 0; + if (!opt.countOnly) { + float* dst[6] = {&sys->rts.srs, &sys->rts.srt, &sys->rts.srsc, + &sys->rts.srtf, &sys->rts.sri, &sys->rts.sroh}; + for (int k = 0; k < 6; ++k) { + if (*dst[k] != v[k]) ++leaves; + *dst[k] = v[k]; + } + if (sys->rts.srnr != noRate) ++leaves; + sys->rts.srnr = noRate; + } + record(s, what, b, bumps, ReplayDisposition::Applied, + Fmt("system %d planetary budget", sysId), leaves); + continue; + } + + if (!e.complete) { + record(s, what, b, bumps, ReplayDisposition::Incomplete, + "the capture recorded this element's presence but not all of its " + "payload; the command is counted and deliberately not applied", 0); + continue; + } + record(s, what, b, bumps, ReplayDisposition::Declined, + DeclineReason(step.list), 0); + } + } + continue; + } + + // A gate step. + for (const auto& b : cap.blocks) { + for (int g = 0; g < step.gateCount; ++g) { + const PrologueGate gate = step.gates[static_cast(g)]; + if (!b.GateSet(gate)) continue; + const std::string what = Fmt("%s / %s", step.label, GateName(gate)); + int bumps = 0; + switch (GateModCountCost(gate)) { + case GateCost::OneBump: + bumps = 1; + break; + case GateCost::Free: + break; + case GateCost::Unknown: + r.bumpsExact = false; + break; + } + + if (gate == PrologueGate::ResearchRate) { + Player* p = FindPlayer(game, b.playerId); + if (!p) { + record(s, what, b, bumps, ReplayDisposition::Declined, + Fmt("the save holds no player %d", b.playerId), 0); + continue; + } + int leaves = 0; + if (!opt.countOnly) { + if (p->resRate != b.researchRate) ++leaves; + p->resRate = b.researchRate; + } + record(s, what, b, bumps, ReplayDisposition::Applied, + "the empire research/savings slider; the applier is a small handler " + "and only this field write is modelled from it", leaves); + continue; + } + + if (gate == PrologueGate::ResearchTarget) { + if (b.researchTargetName.empty() || !opt.useRecordedNames) { + record(s, what, b, bumps, ReplayDisposition::Declined, + b.researchTargetName.empty() + ? "the command carries an integer tech id and the save carries " + "a tech NAME; the client resolves one to the other off the " + "command and that map is unread" + : "the capture carries a resolved name but --replay-recorded-" + "names was not given", + 0); + continue; + } + Player* p = FindPlayer(game, b.playerId); + if (!p) { + record(s, what, b, bumps, ReplayDisposition::Declined, + Fmt("the save holds no player %d", b.playerId), 0); + continue; + } + int leaves = 0; + if (!opt.countOnly) { + if (p->resTNm != b.researchTargetName) ++leaves; + p->resTNm = b.researchTargetName; + } + record(s, what, b, bumps, ReplayDisposition::Transcribed, + Fmt("tech id %d recorded as '%s' by the instrument, not resolved here", + b.researchTarget, b.researchTargetName.c_str()), + leaves); + continue; + } + + const char* reason = + gate == PrologueGate::ResearchBoost + ? "the boost spends savings to advance research; both halves of that " + "transaction are unmodelled" + : gate == PrologueGate::Group5 + ? "three floats written to three player fields whose save leaves are not " + "identified; the gate is species-restricted and no save carries it" + : gate == PrologueGate::CivilianRatios + ? "no applier for this gate has been located in the command-application " + "path at all, so its cost is unknown rather than zero" + : "an unnamed bool-and-int command with no read semantics"; + record(s, what, b, bumps, ReplayDisposition::Declined, reason, 0); + } + } + } + + return r; +} + +} // namespace sots::app diff --git a/src/app/command_replay.h b/src/app/command_replay.h new file mode 100644 index 0000000..d0eaef1 --- /dev/null +++ b/src/app/command_replay.h @@ -0,0 +1,87 @@ +// Replaying a recorded turn's command stream against a save. +// +// WHY THIS EXISTS. The milestone is "load a save, run a turn, write the original's autosave". +// A save cannot support that on its own, and the reason is structural rather than a gap in our +// reading: the AI is a client, not part of the simulation. It decides once, on one machine, and +// its decisions travel to the server as commands exactly as a human's do. A peer -- and this +// engine is a peer -- reproduces the simulation's response to a command; it never reproduces the +// decision. Three runs of the original from one save have produced three different autosaves for +// exactly that reason. +// +// So the honest byte-match is: given the board AND the turn's commands, produce that turn's +// autosave. This module is the second input. +// +// WHAT IT DOES AND DOES NOT DO. It walks the recorded blocks in the order the original's +// command-application routine walks them -- twenty-seven per-list steps with three per-player +// gate steps spliced in, which is neither list order nor member order -- and for each command it +// does exactly one of three things: +// +// * APPLIES it, where we hold the subsystem the command acts on; +// * TRANSCRIBES it, where the capture carries an observed payload we cannot compute (the +// research target's tech NAME is the only case: the wire carries an id and the id->name map +// is unread). Reported in its own column, because a transcription is not a reimplementation; +// * DECLINES it, naming the subsystem it would need. Declining still counts the command's cost. +// +// The modification counter is charged for every command in all three cases, because the counter +// is a property of the stream and not of our ability to model what the command did. That is the +// whole point: `ModCount` is the one leaf of the save that has been unreachable from a save all +// campaign, and it becomes reachable the moment the stream is an input. +#pragma once + +#include +#include + +#include "game/ai/command_capture.h" +#include "mars/stream/shapes.h" + +namespace sots::app { + +enum class ReplayDisposition { + Applied, // we hold the subsystem; the save was written + Transcribed, // written from a payload the instrument observed, not from a model + Declined, // counted, not written; `reason` names what it would need + Incomplete, // counted, not written; the capture itself does not carry the payload +}; + +struct ReplayCommand { + int step = 0; // index into the apply schedule, so the order is in the report + std::string what; // "list 3 build orders", "gate loop A / research rate" + int blockIndex = 0; + int playerId = 0; + int bumps = 0; // this command's contribution to the modification counter + ReplayDisposition disposition = ReplayDisposition::Declined; + std::string reason; // for Declined/Incomplete: the named gap + int leafWrites = 0; // save leaves this command actually changed +}; + +struct ReplayOptions { + // Count the stream and report, but write nothing. The control for "the handlers we do model + // are no-ops on this workload": a run with writing on and a run with it off must produce the + // same save, and if they do not, a handler is writing somewhere it should not. + bool countOnly = false; + // Apply the research target from a name the capture recorded rather than declining. Off by + // default: it is a transcription and an operator should have to ask for it. + bool useRecordedNames = false; +}; + +struct ReplayReport { + bool ran = false; + int blocks = 0; + int submittingBlocks = 0; // blocks a client actually filled in (playerId != 0) + int commands = 0; + int bumps = 0; // total charged to the modification counter + bool bumpsExact = true; // false when a gate with no located applier was set + int leafWrites = 0; + int applied = 0, transcribed = 0, declined = 0, incomplete = 0; + std::vector log; + std::vector warnings; +}; + +// Apply a recorded command stream to a loaded save, in the original's own order. +// +// This runs BEFORE the turn drivers, which is where the original runs it: the End-Turn dispatcher +// calls the command flush, then the strategic turn, then the post-combat tail, in that order. +ReplayReport ReplayTurnCommands(mars::stream::shapes::SaveGame& game, const sots::ai::Capture& cap, + const ReplayOptions& opt); + +} // namespace sots::app diff --git a/src/app/main.cpp b/src/app/main.cpp index c9c5e45..67d0dd3 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -19,6 +19,7 @@ #include "app/report.h" #include "app/turn.h" +#include "game/ai/command_capture.h" #include "game/data/catalog.h" #include "mars/stream/gzip.h" #include "mars/stream/save.h" @@ -43,6 +44,22 @@ int Usage() { " id, comma-separated (e.g. T36)\n" " --commit-blocked-except=IDS these blocked phases never commit\n" " --commit-rng write the advanced generator state back\n" + " --turn-commands FILE replay a recorded turn's command blocks (a .tcb\n" + " capture) against the save before the drivers run. This\n" + " is the second half of a turn's input: the AI is a client\n" + " and its decisions reach the sim as commands, so a save\n" + " alone cannot reproduce a turn that had any\n" + " --replay-count-only read and cost the stream, write nothing from it\n" + " --replay-recorded-names let a command whose payload we cannot compute be\n" + " written from a value the instrument observed (the\n" + " research target's tech name). Reported separately: a\n" + " transcription is not a reimplementation\n" + " --ai-seed NETID=HEX[,NETID=HEX...] an AI client's construction seed,\n" + " overriding or supplying what the capture carries. Each\n" + " AI client seeds one generator with one 32-bit word, so a\n" + " decision is a function of (board, seed) -- NOTHING HERE\n" + " CONSUMES ONE YET; the flag exists so a capture taken now\n" + " is still the right file when game/ai lands\n" " --ai-player N player N (by PlyrIdx) is AI-controlled; repeatable.\n" " The flag is a game-setup input the save does not carry\n" " and it selects the AI column of the difficulty table\n" @@ -70,10 +87,40 @@ std::vector SplitIds(const std::string& s) { return out; } +// "a=1,b=0x2f" -> two seeds. Rejects anything it does not fully understand rather than +// silently dropping a pair: a seed that quietly did not arrive would make a future AI run +// diverge with no visible cause. +bool ParseSeedList(const std::string& s, std::vector& out) { + if (s.empty()) return false; + std::string cur; + for (std::size_t i = 0; i <= s.size(); ++i) { + if (i != s.size() && s[i] != ',') { + cur += s[i]; + continue; + } + const std::size_t eq = cur.find('='); + if (eq == std::string::npos || eq == 0 || eq + 1 == cur.size()) return false; + char* end = nullptr; + // `unsigned long long`, not `unsigned long`: on a 32-bit target the latter IS 32 bits, + // so the range test below would be tautologically false and out-of-range input would be + // accepted silently (and -Wextra would reject the comparison outright). + const unsigned long long v = std::strtoull(cur.c_str() + eq + 1, &end, 0); + if (!end || *end || v > 0xffffffffULL) return false; + sots::ai::AiSeed seed; + seed.netId = cur.substr(0, eq); + seed.seed = static_cast(v); + out.push_back(std::move(seed)); + cur.clear(); + } + return !out.empty(); +} + } // namespace int main(int argc, char** argv) { - std::string in, out, metric, dataDir; + std::string in, out, metric, dataDir, commands; + std::vector seedOverrides; + sots::ai::Capture capture; bool phases = false, verbose = false, roundtrip = false; sots::app::TurnOptions opt; if (const char* env = std::getenv("SOTS_DATA_DIR")) dataDir = env; @@ -110,6 +157,22 @@ int main(int argc, char** argv) { std::string v; if (!next(v)) return Usage(); opt.aiPlayers.push_back(std::atoi(v.c_str())); + } else if (a == "--turn-commands") { + if (!next(commands)) return Usage(); + } else if (a.rfind("--turn-commands=", 0) == 0) { + commands = a.substr(std::strlen("--turn-commands=")); + if (commands.empty()) return Usage(); + } else if (a.rfind("--ai-seed=", 0) == 0 || a == "--ai-seed") { + std::string v = a == "--ai-seed" ? std::string() : a.substr(std::strlen("--ai-seed=")); + if (a == "--ai-seed" && !next(v)) return Usage(); + if (!ParseSeedList(v, seedOverrides)) { + std::fprintf(stderr, "--ai-seed wants NETID=VALUE pairs, comma-separated\n"); + return Usage(); + } + } else if (a == "--replay-count-only") { + opt.replayOptions.countOnly = true; + } else if (a == "--replay-recorded-names") { + opt.replayOptions.useRecordedNames = true; } else if (a == "--commit-rng") { opt.commitRng = true; } else if (a == "--roundtrip") { @@ -190,9 +253,54 @@ int main(int argc, char** argv) { "stays unmodelled\n"); } + // The turn's other input. A save carries the board; this carries what the players decided. + if (!commands.empty()) { + std::ifstream f(commands, std::ios::binary); + if (!f) { + std::fprintf(stderr, "cannot read %s\n", commands.c_str()); + return 2; + } + const std::string text((std::istreambuf_iterator(f)), + std::istreambuf_iterator()); + sots::ai::CaptureDiagnostics diag; + const bool ok = sots::ai::ParseCapture(text, capture, diag); + for (const auto& w : diag.warnings) std::printf("turn-commands: ! %s\n", w.c_str()); + if (!ok) { + for (const auto& e : diag.errors) + std::fprintf(stderr, "turn-commands: %s\n", e.c_str()); + std::fprintf(stderr, + "refusing to replay a capture that did not parse; a lost element is a " + "counter that is quietly short by one\n"); + return 2; + } + opt.turnCommands = &capture; + std::printf("turn-commands: %s -- %zu block(s), %zu seed(s)\n", commands.c_str(), + capture.blocks.size(), capture.seeds.size()); + for (const auto& kv : capture.meta) + std::printf(" %s: %s\n", kv.first.c_str(), kv.second.c_str()); + } + // Seeds given on the command line win over the ones in the file, and a seed for a client the + // file does not mention is added. Nothing consumes them yet, so this is bookkeeping with a + // stated purpose rather than a feature. + for (const auto& s : seedOverrides) { + bool replaced = false; + for (auto& have : capture.seeds) + if (have.netId == s.netId) { + have.seed = s.seed; + replaced = true; + } + if (!replaced) capture.seeds.push_back(s); + } + if (!seedOverrides.empty()) + std::printf("ai seeds: %zu supplied on the command line; %zu held in total. NOTHING " + "CONSUMES THEM YET -- game/ai does not exist, so this run's output does not " + "depend on them.\n", + seedOverrides.size(), capture.seeds.size()); + const sots::app::TurnResult r = sots::app::RunStrategicTurn(doc.game, opt); if (phases) sots::app::PrintPhaseLog(stdout, r, verbose); + sots::app::PrintCommandReplay(stdout, r); sots::app::PrintSummary(stdout, r); if (!out.empty()) { diff --git a/src/app/report.cpp b/src/app/report.cpp index d1cb062..89ebd0b 100644 --- a/src/app/report.cpp +++ b/src/app/report.cpp @@ -85,6 +85,37 @@ void PrintSummary(std::FILE* out, const TurnResult& r) { for (const auto& w : r.warnings) std::fprintf(out, " ! %s\n", w.c_str()); } +void PrintCommandReplay(std::FILE* out, const TurnResult& r) { + const ReplayReport& c = r.commandReplay; + if (!c.ran) { + std::fprintf(out, + "\nturn commands: none supplied (--turn-commands FILE). The modification " + "counter can only reach the two driver bumps without a stream; the rest of\n" + " its per-turn delta is the commands, and a save does not " + "carry them.\n"); + return; + } + std::fprintf(out, "\nturn commands: %d block(s), %d submitting, %d command(s)\n", c.blocks, + c.submittingBlocks, c.commands); + std::fprintf(out, " ModCount bumps charged %d%s\n", c.bumps, + c.bumpsExact ? "" : " (LOWER BOUND -- see the warning below)"); + std::fprintf(out, " applied %d transcribed %d declined %d incomplete %d\n", c.applied, + c.transcribed, c.declined, c.incomplete); + std::fprintf(out, " leaves written by commands %d\n", c.leafWrites); + std::fprintf(out, "\n step blk player cost disposition command\n"); + for (const auto& e : c.log) { + const char* d = e.disposition == ReplayDisposition::Applied ? "applied" + : e.disposition == ReplayDisposition::Transcribed ? "transcribed" + : e.disposition == ReplayDisposition::Incomplete ? "incomplete" + : "declined"; + std::fprintf(out, " %4d %3d %6d %4d %-12s %s\n", e.step, e.blockIndex, e.playerId, + e.bumps, d, e.what.c_str()); + if (!e.reason.empty()) std::fprintf(out, " %s\n", + e.reason.c_str()); + } + for (const auto& w : c.warnings) std::fprintf(out, " ! %s\n", w.c_str()); +} + bool WriteMetricJson(const std::string& path, const TurnResult& r, const std::string& inputName, const std::string& outputName) { std::ofstream f(path); @@ -106,6 +137,16 @@ bool WriteMetricJson(const std::string& path, const TurnResult& r, const std::st for (std::size_t i = 0; i < r.rngUnaccounted.size(); ++i) f << (i ? ", " : "") << '"' << JsonEscape(r.rngUnaccounted[i]) << '"'; f << "]},\n"; + // The command stream's own numbers, kept apart from the run's: a bump charged for a command + // we declined to apply is a real bump and a fake model, and netting them would hide that. + const ReplayReport& c = r.commandReplay; + f << " \"turnCommands\": {\"supplied\": " << (c.ran ? "true" : "false") + << ", \"blocks\": " << c.blocks << ", \"submittingBlocks\": " << c.submittingBlocks + << ", \"commands\": " << c.commands << ", \"modCountBumps\": " << c.bumps + << ", \"modCountExact\": " << (c.bumpsExact ? "true" : "false") + << ", \"applied\": " << c.applied << ", \"transcribed\": " << c.transcribed + << ", \"declined\": " << c.declined << ", \"incomplete\": " << c.incomplete + << ", \"leafWrites\": " << c.leafWrites << "},\n"; f << " \"phases\": [\n"; bool first = true; for (const auto& rec : r.records) { diff --git a/src/app/report.h b/src/app/report.h index 8adb351..c55574f 100644 --- a/src/app/report.h +++ b/src/app/report.h @@ -15,6 +15,11 @@ void PrintPhaseLog(std::FILE* out, const TurnResult& r, bool verbose); // A one-screen summary: how many phases of each driver we hold, and how much this run moved. void PrintSummary(std::FILE* out, const TurnResult& r); +// The recorded command stream, command by command, in the order the original applies them, +// with each one's disposition and -- for the ones we decline -- the named subsystem it needs. +// That list of reasons IS the remaining work, so it is printed in full rather than summarised. +void PrintCommandReplay(std::FILE* out, const TurnResult& r); + // The completion metric, as JSON, for the campaign dashboard. Written to `path`; the // divergence numbers are filled in by the comparison tool that runs the checksum diff, so // this file carries only what the standalone itself knows. diff --git a/src/app/turn.cpp b/src/app/turn.cpp index 031fdd5..1bae886 100644 --- a/src/app/turn.cpp +++ b/src/app/turn.cpp @@ -954,6 +954,17 @@ void ApplySaveWriterInvariants(SaveGame& game, TurnResult& r) { TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { TurnResult r; + // The recorded command stream, applied first because that is where the original applies it: + // the End-Turn dispatcher drains every submitted block, THEN runs the strategic turn, THEN + // runs the post-combat tail. Every leaf a command writes is therefore already in place when + // the first phase reads the board. + if (opt.turnCommands) { + r.commandReplay = ReplayTurnCommands(game, *opt.turnCommands, opt.replayOptions); + game.sim.modCount += r.commandReplay.bumps; + r.leafWrites += r.commandReplay.leafWrites; + for (const auto& w : r.commandReplay.warnings) r.warnings.push_back(w); + } + // Taken before any phase runs: the turn-record model is checked against the record the // INPUT save already carries for its own turn. None of the fields it reads is written by // a phase below, but the check is taken first so that stays true by construction. diff --git a/src/app/turn.h b/src/app/turn.h index 2ec459f..69bc7df 100644 --- a/src/app/turn.h +++ b/src/app/turn.h @@ -9,6 +9,7 @@ #include #include +#include "app/command_replay.h" #include "app/phase_catalog.h" #include "game/data/catalog.h" #include "mars/rng/mt19937.h" @@ -55,6 +56,14 @@ struct TurnOptions { // are recomputed from the section catalog and never written down. Null means the ship // census is not modelled, and the phase says so rather than writing six zeros. const ::game::data::Catalog* catalog = nullptr; + // The turn's recorded command stream, when the operator supplied one. This is the second + // half of a turn's input and the save does not carry it: the AI is a client, it decides + // once on one machine, and its decisions reach the server as commands. Null means "no + // commands were issued this turn", which is a claim about what we were told and not about + // the game -- and it is why the modification counter reads two instead of the original's + // twelve on a turn nobody has given us the stream for. + const sots::ai::Capture* turnCommands = nullptr; + ReplayOptions replayOptions; }; // One line of the run log. @@ -80,6 +89,11 @@ struct TurnResult { // two different facts, and folding them into one hides which is which. std::vector rngUnaccounted; std::vector warnings; + // What the recorded command stream did, when one was supplied. `ran` is false otherwise, + // which is not the same as "an empty stream": a turn with no commands still costs one + // counter bump per submitting player, so an absent capture and an empty one are different + // numbers and the report keeps them apart. + ReplayReport commandReplay; }; // Run one strategic turn in place. `game` is mutated; the caller re-serialises it. diff --git a/src/game/ai/CMakeLists.txt b/src/game/ai/CMakeLists.txt index 43c81ed..6f5eba1 100644 --- a/src/game/ai/CMakeLists.txt +++ b/src/game/ai/CMakeLists.txt @@ -5,6 +5,8 @@ add_library(sots_game_ai STATIC tasks.cpp turn_order.cpp orders.cpp + apply_order.cpp + command_capture.cpp agent.cpp) target_include_directories(sots_game_ai PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/../..) target_compile_features(sots_game_ai PUBLIC cxx_std_17) diff --git a/src/game/ai/apply_order.cpp b/src/game/ai/apply_order.cpp new file mode 100644 index 0000000..087e4e7 --- /dev/null +++ b/src/game/ai/apply_order.cpp @@ -0,0 +1,84 @@ +#include "game/ai/apply_order.h" + +namespace sots::ai { +namespace { + +constexpr ApplyStep L(int list, int off, const char* label) { + return ApplyStep{ApplyStepKind::List, list, off, list >= 1 && list <= kLastCountedList, 0, + {PrologueGate::ResearchRate, PrologueGate::ResearchRate, + PrologueGate::ResearchRate}, + label}; +} + +constexpr ApplyStep G1(PrologueGate a, const char* label) { + return ApplyStep{ApplyStepKind::GateLoop, 0, 0, false, 1, {a, a, a}, label}; +} + +constexpr ApplyStep G3(PrologueGate a, PrologueGate b, PrologueGate c, const char* label) { + return ApplyStep{ApplyStepKind::GateLoop, 0, 0, false, 3, {a, b, c}, label}; +} + +} // namespace + +// The order, step for step. Member offsets are the literals the batch adds to its walking +// register; they are here as identification, not as layout anyone should depend on. +const ApplyStep kApplySchedule[kApplyStepCount] = { + L(6, 0xac, "list 6 player notes"), + L(11, 0xe8, "list 11"), + L(20, 0x154, "list 20"), + L(19, 0x148, "list 19"), + L(17, 0x130, "list 17"), + L(18, 0x13c, "list 18"), + L(5, 0xa0, "list 5 system rates"), + L(23, 0x178, "list 23 population"), + L(24, 0x184, "list 24"), + // Gate loop A. Three gates, one pass over the blocks, spliced between list 24 and list 1. + // The three-float gate is free; the target and the rate each cost one. + G3(PrologueGate::Group5, PrologueGate::ResearchTarget, PrologueGate::ResearchRate, + "gate loop A (group5, research target, research rate)"), + L(1, 0x70, "list 1 new designs"), + L(4, 0x94, "list 4"), + L(3, 0x88, "list 3 build orders"), + L(21, 0x160, "list 21"), + L(2, 0x7c, "list 2"), + L(22, 0x16c, "list 22 weapon groups"), + L(9, 0xd0, "list 9"), + L(10, 0xdc, "list 10"), + L(12, 0xf4, "list 12 fleet layouts"), + L(13, 0x100, "list 13"), + L(14, 0x10c, "list 14 fleet tasks"), + L(15, 0x118, "list 15"), + L(16, 0x124, "list 16"), + L(7, 0xb8, "list 7 colonize"), + L(8, 0xc4, "list 8 fleet moves"), + L(25, 0x190, "list 25 defence layouts"), + L(27, 0x1a8, "list 27"), + L(26, 0x19c, "list 26 raid targets"), + // Gate loops B and C, at the very end and in that order. + G1(PrologueGate::ResearchBoost, "gate loop B (research boost)"), + G1(PrologueGate::Group4, "gate loop C (group 4)"), +}; + +// The nine anchored positions. Six are the appliers the batch inlines -- each writes the +// modification counter in place, which is why a watchpoint run recovered their addresses -- and +// three are the gate-loop heads. +const ScheduleAnchor kScheduleAnchors[kScheduleAnchorCount] = { + {9, 0x0088fdb0, "gate loop A head"}, + {9, 0x0088fe0a, "gate loop A, research target (inlined bump)"}, + {18, 0x008902fe, "list 12 (inlined bump)"}, + {19, 0x008903b9, "list 13 (inlined bump)"}, + {20, 0x0089046c, "list 14 (inlined bump)"}, + {23, 0x008905c8, "list 7 (inlined bump)"}, + {28, 0x008907b1, "gate loop B head"}, + {28, 0x008907bc, "gate loop B, research boost (inlined bump)"}, + {29, 0x0089080a, "gate loop C head"}, +}; + +int StepIndexOfList(int list) { + for (int i = 0; i < kApplyStepCount; ++i) + if (kApplySchedule[i].kind == ApplyStepKind::List && kApplySchedule[i].list == list) + return i; + return -1; +} + +} // namespace sots::ai diff --git a/src/game/ai/apply_order.h b/src/game/ai/apply_order.h new file mode 100644 index 0000000..5ba1f31 --- /dev/null +++ b/src/game/ai/apply_order.h @@ -0,0 +1,94 @@ +// The order the server applies a turn's command blocks in -- and it is not the order they are +// written in. +// +// A turn's submitted blocks are drained by one flat, hand-written routine. It is NOT "for each +// player, for each list, for each element". It is thirty steps in a fixed sequence: +// +// * twenty-seven per-LIST steps, each of which loops over EVERY player's block and applies +// every element of that one list before the next step begins; +// * three per-PLAYER steps that test the flag-gated single commands, spliced into the middle +// and the end of that run rather than sitting together at the front. +// +// Three consequences, and each of them is a bug waiting for a workload that exercises it: +// +// * ONE PLAYER'S LIST-6 COMMANDS ARE APPLIED BEFORE ANOTHER PLAYER'S LIST-11 COMMANDS. A port +// that iterates players outermost gets the same answer only while no two players command the +// same object in the same turn. +// * THE LIST SEQUENCE IS NOT 1..27 AND IT IS NOT MEMBER ORDER EITHER. It starts at list 6, and +// the member offsets it walks are not ascending. Both facts are asserted below, because they +// are the only cheap way to catch an implementation that quietly sorted. +// * THE RESEARCH TARGET AND RATE ARE APPLIED IN THE MIDDLE, after list 24 and before list 1; +// the research boost and the fourth gate are applied at the very END, after list 26. The six +// gates are not one group. +// +// Cost, restated here because it is a property of the step and not of the command: applying an +// element of lists 1..16 advances the save's modification counter, applying an element of lists +// 17..27 does not, and four of the six gates advance it while one is free and one has no located +// applier at all. +// +// Pure: a static table plus a walker. No state, no I/O, no draws. +// CONFIDENCE: the sequence is read from the original's instruction stream (the batch writes each +// list's member offset literally into an `add edi, imm` and fetches the player id at the matching +// negative displacement, so each step names its list unambiguously). NINE of the thirty positions +// have an independent address check -- the six appliers inlined into the batch and the three gate +// loop heads, whose addresses must increase in step order; that check is a test. The other +// twenty-one rest on the read alone. And no workload the campaign holds can tell any permutation +// from any other, because every non-empty list in both captures belongs to one player and every +// command names one system: the order is implemented for the workload that will need it, not +// because an outcome demanded it. +#pragma once + +#include + +#include "game/ai/orders.h" + +namespace sots::ai { + +// A step is either "apply this list, across all blocks" or "test these gates, per block". +enum class ApplyStepKind { List, GateLoop }; + +// The gates a single gate-loop step tests, in the order it tests them. Three is the widest. +constexpr int kMaxGatesPerStep = 3; + +struct ApplyStep { + ApplyStepKind kind; + // List steps only. 1..27. + int list; + // List steps only: the block member offset the batch walks for this list. Carried because it + // is the evidence that identifies the step in the original, and because asserting that this + // sequence is not ascending is what stops a reimplementation from walking the block in + // memory order and calling it the same thing. + int memberOffset; + // List steps only: whether applying one element advances the modification counter. + bool bumps; + // Gate steps only. + int gateCount; + PrologueGate gates[kMaxGatesPerStep]; + // A short label for a report line. + const char* label; +}; + +// Twenty-seven list steps plus three gate loops. +constexpr int kApplyStepCount = 30; + +// The schedule, in the order the original runs it. +extern const ApplyStep kApplySchedule[kApplyStepCount]; + +// Where a list sits in the schedule (0-based step index), or -1 for a list number out of range. +// Exists so a caller can talk about "list 14 runs at step 20" without searching. +int StepIndexOfList(int list); + +// The nine positions that have an independent address check: the six appliers the batch inlines +// (each of which writes the counter in place) and the three gate-loop heads. Both arrays are in +// SCHEDULE order, so `kAnchorAddress` must be strictly increasing and `kAnchorStep` must be too. +// A test asserts exactly that; it is the only part of the sequence this module can re-derive +// rather than inherit. +struct ScheduleAnchor { + int step; // index into kApplySchedule + unsigned address; // absolute VA in the original's image + const char* what; +}; +constexpr int kScheduleAnchorCount = 9; +extern const ScheduleAnchor kScheduleAnchors[kScheduleAnchorCount]; + +} // namespace sots::ai diff --git a/src/game/ai/command_capture.cpp b/src/game/ai/command_capture.cpp new file mode 100644 index 0000000..f6f6f73 --- /dev/null +++ b/src/game/ai/command_capture.cpp @@ -0,0 +1,554 @@ +#include "game/ai/command_capture.h" + +#include +#include + +namespace sots::ai { +namespace { + +const std::vector& EmptyList() { + static const std::vector empty; + return empty; +} + +std::string Fmt(const char* f, int a) { + char buf[128]; + std::snprintf(buf, sizeof buf, f, a); + return std::string(buf); +} + +std::string Fmt2(const char* f, int a, int b) { + char buf[160]; + std::snprintf(buf, sizeof buf, f, a, b); + return std::string(buf); +} + +// --- tokenising --------------------------------------------------------------------------- + +std::vector Tokens(std::string_view line) { + std::vector out; + std::size_t i = 0; + while (i < line.size()) { + while (i < line.size() && (line[i] == ' ' || line[i] == '\t' || line[i] == '\r')) ++i; + if (i >= line.size() || line[i] == '#') break; + const std::size_t start = i; + while (i < line.size() && line[i] != ' ' && line[i] != '\t' && line[i] != '\r') ++i; + out.emplace_back(line.substr(start, i - start)); + } + return out; +} + +bool ParseInt(const std::string& s, int& out) { + if (s.empty()) return false; + char* end = nullptr; + const long v = std::strtol(s.c_str(), &end, 0); + if (!end || *end) return false; + out = static_cast(v); + return true; +} + +bool ParseFloat(const std::string& s, float& out) { + if (s.empty()) return false; + char* end = nullptr; + const double v = std::strtod(s.c_str(), &end); + if (!end || *end) return false; + out = static_cast(v); + return true; +} + +// One element field token. See the header for the grammar. +bool ParseField(const std::string& t, CaptureField& f) { + if (t == "?") { + f.kind = CaptureField::Kind::Unknown; + return true; + } + if (t.size() < 2) return false; + const char tag = t[0]; + const std::string body = t.substr(1); + switch (tag) { + case 'i': + f.kind = CaptureField::Kind::Int; + return ParseInt(body, f.i); + case 'f': + f.kind = CaptureField::Kind::Float; + return ParseFloat(body, f.f); + case 'b': + if (body != "0" && body != "1") return false; + f.kind = CaptureField::Kind::Bool; + f.b = body == "1"; + return true; + case 's': + if (body.empty() || body[0] != ':') return false; + f.kind = CaptureField::Kind::Str; + f.s = body.substr(1); + return true; + case 'v': { + f.kind = CaptureField::Kind::Vec; + const std::size_t colon = body.find(':'); + const std::string count = colon == std::string::npos ? body : body.substr(0, colon); + if (!ParseInt(count, f.vecCount) || f.vecCount < 0) return false; + if (colon == std::string::npos) { + f.vecValues = false; + return true; + } + f.vecValues = true; + std::string cur; + const std::string vals = body.substr(colon + 1); + for (std::size_t i = 0; i <= vals.size(); ++i) { + if (i == vals.size() || vals[i] == ',') { + int v = 0; + if (!ParseInt(cur, v)) return false; + f.vec.push_back(v); + cur.clear(); + } else { + cur += vals[i]; + } + } + return static_cast(f.vec.size()) == f.vecCount; + } + default: + return false; + } +} + +bool FieldKnown(const CaptureField& f) { + if (f.kind == CaptureField::Kind::Unknown) return false; + if (f.kind == CaptureField::Kind::Vec) return f.vecValues; + return true; +} + +// Field accessors that decline rather than guess. A capture whose field is a `?` must not +// silently become a zero. +bool FieldInt(const CapturedElement& e, std::size_t i, int& out) { + if (i >= e.fields.size() || e.fields[i].kind != CaptureField::Kind::Int) return false; + out = e.fields[i].i; + return true; +} + +bool FieldFloat(const CapturedElement& e, std::size_t i, float& out) { + if (i >= e.fields.size()) return false; + if (e.fields[i].kind == CaptureField::Kind::Float) { + out = e.fields[i].f; + return true; + } + // An integral rate written without a decimal point is still a rate. + if (e.fields[i].kind == CaptureField::Kind::Int) { + out = static_cast(e.fields[i].i); + return true; + } + return false; +} + +bool FieldBool(const CapturedElement& e, std::size_t i, bool& out) { + if (i >= e.fields.size()) return false; + if (e.fields[i].kind == CaptureField::Kind::Bool) { + out = e.fields[i].b; + return true; + } + if (e.fields[i].kind == CaptureField::Kind::Int) { + out = e.fields[i].i != 0; + return true; + } + return false; +} + +} // namespace + +// --- CapturedBlock --------------------------------------------------------------------------- + +bool CapturedBlock::GateSet(PrologueGate gate) const { + switch (gate) { + case PrologueGate::ResearchRate: + return hasResearchRate; + case PrologueGate::ResearchTarget: + return hasResearchTarget; + case PrologueGate::ResearchBoost: + return hasResearchBoost; + case PrologueGate::Group4: + return hasGroup4; + case PrologueGate::Group5: + return hasGroup5; + case PrologueGate::CivilianRatios: + return hasCivilianRatios; + } + return false; +} + +const std::vector& CapturedBlock::List(int listNo) const { + if (listNo < 1 || listNo > kCommandListCount) return EmptyList(); + return lists[static_cast(listNo - 1)]; +} + +const std::string* Capture::Meta(std::string_view key) const { + for (const auto& kv : meta) + if (kv.first == key) return &kv.second; + return nullptr; +} + +const AiSeed* Capture::Seed(std::string_view netId) const { + for (const auto& s : seeds) + if (s.netId == netId) return &s; + return nullptr; +} + +// --- the parser -------------------------------------------------------------------------------- + +bool ParseCapture(std::string_view text, Capture& out, CaptureDiagnostics& diag) { + out = Capture{}; + // Declared list lengths, so the element records can be checked against them rather than + // trusted. Indexed [blockIndex][list - 1]; -1 means "no `list` record seen". + std::vector> declared; + + auto blockAt = [&](int idx) -> CapturedBlock* { + for (auto& b : out.blocks) + if (b.index == idx) return &b; + return nullptr; + }; + + bool sawMagic = false; + int lineNo = 0; + std::size_t pos = 0; + while (pos <= text.size()) { + const std::size_t nl = text.find('\n', pos); + const std::string_view line = + text.substr(pos, nl == std::string_view::npos ? std::string_view::npos : nl - pos); + pos = nl == std::string_view::npos ? text.size() + 1 : nl + 1; + ++lineNo; + + const std::vector t = Tokens(line); + if (t.empty()) continue; + const std::string& kw = t[0]; + auto err = [&](const std::string& m) { + diag.errors.push_back(Fmt("line %d: ", lineNo) + m); + }; + + if (!sawMagic) { + if (kw != "tcb" || t.size() != 2 || !ParseInt(t[1], out.version)) { + err("expected the magic line `tcb 1` before any record"); + return false; + } + if (out.version != 1) { + err(Fmt("capture version %d is not understood; this reader speaks version 1", + out.version)); + return false; + } + sawMagic = true; + continue; + } + + if (kw == "tcb") { + err("a second magic line"); + continue; + } + + if (kw == "meta") { + if (t.size() < 2) { + err("meta needs a key"); + continue; + } + std::string value; + for (std::size_t i = 2; i < t.size(); ++i) { + if (i > 2) value += ' '; + value += t[i]; + } + out.meta.emplace_back(t[1], value); + continue; + } + + if (kw == "seed") { + if (t.size() != 3) { + err("seed needs NETID and a 32-bit value"); + continue; + } + char* end = nullptr; + // `unsigned long long`: `unsigned long` is 32 bits on a 32-bit target, which would + // make the range test below tautologically false. + const unsigned long long v = std::strtoull(t[2].c_str(), &end, 0); + if (!end || *end || v > 0xffffffffULL) { + err("seed value `" + t[2] + "` is not a 32-bit word"); + continue; + } + if (out.Seed(t[1])) { + err("client `" + t[1] + "` is seeded twice"); + continue; + } + AiSeed s; + s.netId = t[1]; + s.seed = static_cast(v); + out.seeds.push_back(std::move(s)); + continue; + } + + if (kw == "block") { + int idx = 0, pid = 0; + if (t.size() != 3 || !ParseInt(t[1], idx) || !ParseInt(t[2], pid)) { + err("block needs IDX and PLAYERID"); + continue; + } + if (blockAt(idx)) { + err(Fmt("block %d declared twice", idx)); + continue; + } + if (!out.blocks.empty() && idx <= out.blocks.back().index) { + err(Fmt("block %d is out of order; blocks must ascend", idx)); + continue; + } + CapturedBlock b; + b.index = idx; + b.playerId = pid; + out.blocks.push_back(std::move(b)); + std::array d; + d.fill(-1); + declared.push_back(d); + continue; + } + + // Every remaining record names a block first. + int idx = 0; + if (t.size() < 2 || !ParseInt(t[1], idx)) { + err("`" + kw + "` needs a block index"); + continue; + } + CapturedBlock* b = blockAt(idx); + if (!b) { + err(Fmt("block %d has not been declared", idx)); + continue; + } + const std::size_t slot = static_cast(b - out.blocks.data()); + + if (kw == "gate") { + if (t.size() < 3) { + err("gate needs a name"); + continue; + } + const std::string& g = t[2]; + if (g == "rate") { + if (t.size() != 4 || !ParseFloat(t[3], b->researchRate)) { + err("gate rate needs one float"); + continue; + } + b->hasResearchRate = true; + } else if (g == "target") { + if (t.size() != 4 && !(t.size() == 6 && t[4] == "name")) { + err("gate target needs an id, optionally followed by `name NAME`"); + continue; + } + if (!ParseInt(t[3], b->researchTarget)) { + err("gate target id is not an integer"); + continue; + } + if (t.size() == 6) b->researchTargetName = t[5]; + b->hasResearchTarget = true; + } else if (g == "boost") { + if (t.size() != 5 || !ParseInt(t[3], b->researchBoostSpend) || + !ParseFloat(t[4], b->researchBoostFraction)) { + err("gate boost needs an int and a float"); + continue; + } + b->hasResearchBoost = true; + } else if (g == "group4") { + int flag = 0; + if (t.size() != 5 || !ParseInt(t[3], flag) || !ParseInt(t[4], b->group4Value)) { + err("gate group4 needs a bool and an int"); + continue; + } + b->group4Flag = flag != 0; + b->hasGroup4 = true; + } else if (g == "group5") { + if (t.size() != 6 || !ParseFloat(t[3], b->group5a) || + !ParseFloat(t[4], b->group5b) || !ParseFloat(t[5], b->group5c)) { + err("gate group5 needs three floats"); + continue; + } + b->hasGroup5 = true; + } else if (g == "civilian") { + b->hasCivilianRatios = true; + diag.warnings.push_back(Fmt( + "block %d sets the civilian-ratios gate: no applier for it has been located " + "in the command-application path, so this turn's counter is a LOWER BOUND", + idx)); + } else { + err("unknown gate `" + g + "`"); + } + continue; + } + + if (kw == "list") { + int listNo = 0, count = 0; + if (t.size() != 4 || !ParseInt(t[2], listNo) || !ParseInt(t[3], count)) { + err("list needs LISTNO and COUNT"); + continue; + } + if (listNo < 1 || listNo > kCommandListCount) { + err(Fmt("list %d is out of range 1..27", listNo)); + continue; + } + if (count < 0) { + err("a list count cannot be negative"); + continue; + } + declared[slot][static_cast(listNo - 1)] = count; + continue; + } + + if (kw == "elem") { + int listNo = 0, elemIdx = 0; + if (t.size() < 4 || !ParseInt(t[2], listNo) || !ParseInt(t[3], elemIdx)) { + err("elem needs LISTNO and ELEMIDX"); + continue; + } + if (listNo < 1 || listNo > kCommandListCount) { + err(Fmt("list %d is out of range 1..27", listNo)); + continue; + } + auto& list = b->lists[static_cast(listNo - 1)]; + if (elemIdx != static_cast(list.size())) { + err(Fmt2("list %d element %d is out of order", listNo, elemIdx)); + continue; + } + CapturedElement e; + bool bad = false; + for (std::size_t i = 4; i < t.size(); ++i) { + CaptureField f; + if (!ParseField(t[i], f)) { + err("unreadable field `" + t[i] + "`"); + bad = true; + break; + } + if (!FieldKnown(f)) e.complete = false; + e.fields.push_back(std::move(f)); + } + if (bad) continue; + if (e.fields.empty()) { + err(Fmt("list %d element has no fields", listNo)); + continue; + } + list.push_back(std::move(e)); + continue; + } + + err("unknown record `" + kw + "`"); + } + + if (!sawMagic) { + diag.errors.push_back("the capture is empty or has no `tcb 1` magic line"); + return false; + } + + // The coverage guard. A declared count that does not match the elements present is an + // error: a capture that lost an element would otherwise produce a counter quietly short by + // one, and nothing downstream could tell that from a turn that really issued one fewer. + for (std::size_t s = 0; s < out.blocks.size(); ++s) { + for (int n = 1; n <= kCommandListCount; ++n) { + const int want = declared[s][static_cast(n - 1)]; + const int have = static_cast(out.blocks[s].List(n).size()); + if (want < 0) { + if (have > 0) + diag.errors.push_back( + Fmt2("block %d list %d has elements with no `list` record declaring how " + "many there should be", + out.blocks[s].index, n)); + continue; + } + if (want != have) + diag.errors.push_back( + Fmt2("block %d list %d declares ", out.blocks[s].index, n) + + Fmt("%d element(s) and carries ", want) + Fmt("%d", have)); + } + } + + return diag.ok(); +} + +// --- the typed view ---------------------------------------------------------------------------- + +TurnCommandBlock ToTurnCommandBlock(const CapturedBlock& block) { + TurnCommandBlock out; + out.playerId = block.playerId; + out.hasResearchRate = block.hasResearchRate; + out.researchRate = block.researchRate; + out.hasResearchTarget = block.hasResearchTarget; + out.researchTarget = block.researchTarget; + out.hasResearchBoost = block.hasResearchBoost; + out.researchBoostSpend = block.researchBoostSpend; + out.researchBoostFraction = block.researchBoostFraction; + out.hasGroup4 = block.hasGroup4; + out.group4Flag = block.group4Flag; + out.group4Value = block.group4Value; + out.hasGroup5 = block.hasGroup5; + out.group5a = block.group5a; + out.group5b = block.group5b; + out.group5c = block.group5c; + out.hasCivilianRatios = block.hasCivilianRatios; + + for (int n = 1; n <= kCommandListCount; ++n) { + const auto& src = block.List(n); + if (src.empty()) continue; + const auto list = static_cast(n); + switch (list) { + case CommandList::Build: + for (const auto& e : src) { + BuildOrder o; + FieldInt(e, 0, o.ordinal); + FieldInt(e, 1, o.designId); + FieldInt(e, 2, o.systemId); + FieldInt(e, 3, o.trailing); + out.build.push_back(o); + } + break; + case CommandList::SystemRates: + for (const auto& e : src) { + SystemRatesOrder o; + FieldInt(e, 0, o.systemId); + FieldFloat(e, 1, o.ship); + FieldFloat(e, 2, o.terraform); + FieldFloat(e, 3, o.sciences); + FieldFloat(e, 4, o.trade); + FieldFloat(e, 5, o.infrastructure); + FieldFloat(e, 6, o.overharvest); + FieldInt(e, 7, o.noRate); + out.systemRates.push_back(o); + } + break; + case CommandList::Colonize: + for (const auto& e : src) { + ColonizeOrder o; + FieldInt(e, 0, o.shipId); + FieldInt(e, 1, o.trailing); + out.colonize.push_back(o); + } + break; + case CommandList::FleetMove: + for (const auto& e : src) { + FleetMoveOrder o; + FieldInt(e, 0, o.fleetId); + // The route is a counted vector. When the instrument recorded only its + // length the hops stay empty -- the element still exists and still costs, + // and nothing downstream may pretend to know where the fleet was sent. + if (e.fields.size() > 1 && e.fields[1].kind == CaptureField::Kind::Vec && + e.fields[1].vecValues) + o.route = e.fields[1].vec; + out.fleetMoves.push_back(std::move(o)); + } + break; + case CommandList::FleetTask: + for (const auto& e : src) { + FleetTaskOrder o; + FieldInt(e, 0, o.fleetId); + FieldInt(e, 1, o.mode); + FieldBool(e, 2, o.flag); + // Appended directly rather than through the order API's (fleet, mode) dedup: + // this is a RECORDING of what the original submitted, and a capture that + // holds two elements holds two, whatever a re-derivation would have made. + out.fleetTasks.push_back(o); + } + break; + default: + out.AddUnmodelled(list, static_cast(src.size())); + break; + } + } + return out; +} + +} // namespace sots::ai diff --git a/src/game/ai/command_capture.h b/src/game/ai/command_capture.h new file mode 100644 index 0000000..2af51b2 --- /dev/null +++ b/src/game/ai/command_capture.h @@ -0,0 +1,160 @@ +// A recorded turn's command blocks, on disk. +// +// The decision layer of this game is not part of the simulation. Every player's orders -- the +// AI's included -- are built on one machine and shipped to the server as a block of commands; +// peers reproduce the sim's response to a command, never the decision that produced it. A save +// therefore does not carry enough to re-run a turn: it carries the board, and the board is only +// half the input. +// +// This is the other half, in a form an instrumented run can write and this engine can read. +// +// FORMAT (".tcb"). Line-oriented, whitespace-separated, `#` to end of line is a comment, blank +// lines ignored. The first non-comment line must be the magic. Records: +// +// tcb 1 magic and version +// meta KEY VALUE... provenance; carried into the report, never interpreted +// seed NETID HEX one AI client's generator seed (see below) +// block IDX PLAYERID opens block IDX (the batch slot, 0-based, ascending) +// gate IDX rate FLOAT +// gate IDX target INT [name NAME] the wire carries an id; NAME is the resolved tech name +// IF the instrument saw it -- see below +// gate IDX boost INT FLOAT +// gate IDX group4 BOOL INT +// gate IDX group5 FLOAT FLOAT FLOAT +// gate IDX civilian +// list IDX LISTNO COUNT declares a list's length; lists with no record are empty +// elem IDX LISTNO ELEMIDX FIELD... one element, fields in WIRE order +// +// A field is one token: `iN` an int, `fN` a float, `b0`/`b1` a bool, `s:TEXT` a string with no +// spaces, `vN` a counted vector of N values the instrument did not read, `vN:a,b,c` one it did, +// and `?` a scalar it did not read. Elements are matched against their declared count and a +// mismatch is an error, not a warning: a capture that lost an element silently would produce a +// modification counter that is quietly one short, which is the exact failure this whole file +// exists to make impossible. +// +// TWO DESIGN POINTS THAT ARE NOT CONVENIENCES. +// +// * `?` AND `vN` ARE LOAD-BEARING. A dump that reads a fixed window of each element cannot +// follow a pointer, so a route or a nested object arrives as "one element, contents unknown". +// That is a different fact from "no element" and from "an element of zeros": the command +// still costs its bump, and its EFFECT still cannot be applied. Recording the ignorance is +// what lets the counter be right while the state is honestly left alone. +// * `name` ON THE TARGET GATE IS A RECORDING, NOT A MODEL. The wire carries an integer tech +// id; the save carries a tech name; the resolution happens on the client, off the command, +// and this campaign has not read the map. When a capture supplies the name, the replay can +// write the leaf -- but it is transcribing what the instrument saw, not computing it, and +// the report keeps that in its own column so nobody later mistakes one for the other. +// * `seed` IS THE OTHER HALF OF THE DECISION LAYER, AND IT IS CARRIED HERE ON PURPOSE. +// The AI client's choices are not non-deterministic: each client's generator is seeded once +// at construction with a single 32-bit word taken from a per-process global, and the +// generator itself is one this engine already reproduces bit for bit. So a decision is a +// function of (board, seed), and a capture that records the seeds is a complete turn record +// -- the commands can be re-derived from it rather than replayed from it. Nothing in this +// engine consumes a seed yet; the field exists now so that a capture taken today is still +// the right file when it does, rather than a stream with the reproducing half missing. +// A reference save whose seeds were never logged is not reproducible by ANY process, the +// original included, and its commands can only ever be replayed. +// +// Pure: parsing only. No state outside the returned Capture, no I/O (the caller supplies text). +// CONFIDENCE: the block's shape is settled -- 8 prologue items and 27 always-written lists, read +// off the writer's instruction stream and cross-checked against saves that carry issued orders. +// The ELEMENT records of the five lists a workload has ever populated are observed; the other +// twenty-two are typed from the writer alone and this parser deliberately does not type them at +// all -- it carries their fields as written and lets the count be the thing it is sure of. +#pragma once + +#include +#include +#include +#include + +#include "game/ai/orders.h" + +namespace sots::ai { + +// One field of one element, as the instrument recorded it. +struct CaptureField { + enum class Kind { Int, Float, Bool, Str, Vec, Unknown }; + Kind kind = Kind::Unknown; + int i = 0; + float f = 0; + bool b = false; + std::string s; + std::vector vec; + int vecCount = 0; // declared length, which is known even when the values are not + bool vecValues = false; // whether `vec` holds them +}; + +// One element of one list. +struct CapturedElement { + std::vector fields; + // True when every field's value is known. False means the command is present and counted but + // its payload cannot be applied. + bool complete = true; +}; + +// One player's submitted block. +struct CapturedBlock { + int index = 0; // the batch slot + int playerId = 0; // 0 marks a slot no client wrote -- the batch is sized to the player + // count and the non-playing factions occupy slots with nothing in them + bool hasResearchRate = false; + float researchRate = 0; + bool hasResearchTarget = false; + int researchTarget = 0; + std::string researchTargetName; // empty unless the instrument resolved it + bool hasResearchBoost = false; + int researchBoostSpend = 0; + float researchBoostFraction = 0; + bool hasGroup4 = false; + bool group4Flag = false; + int group4Value = 0; + bool hasGroup5 = false; + float group5a = 0, group5b = 0, group5c = 0; + bool hasCivilianRatios = false; + + // Indexed by list number - 1. + std::array, kCommandListCount> lists; + + bool GateSet(PrologueGate gate) const; + const std::vector& List(int listNo) const; +}; + +// One AI client's construction seed. Keyed by the client's network identity rather than by the +// save's player id, because that is the identity the instrument can read at the moment the seed +// is drawn -- the client exists before it is bound to a player. +struct AiSeed { + std::string netId; + unsigned seed = 0; +}; + +struct Capture { + int version = 0; + std::vector> meta; + // Empty means the seeds were not recorded. That is a fact about the capture, not about the + // game, and it is the difference between a turn that can be re-derived and one that can only + // be replayed. + std::vector seeds; + std::vector blocks; + const std::string* Meta(std::string_view key) const; + const AiSeed* Seed(std::string_view netId) const; +}; + +struct CaptureDiagnostics { + std::vector errors; + std::vector warnings; + bool ok() const { return errors.empty(); } +}; + +// Parse a capture. Returns false, with every problem named, when the text is not a valid one; +// a partly-parsed Capture is still returned so a caller can report what it did understand. +bool ParseCapture(std::string_view text, Capture& out, CaptureDiagnostics& diag); + +// The typed, cost-bearing view of a captured block: the same object the order API produces, so +// the modification counter is computed by the module that was verified against the original +// rather than by a second implementation here. Lists this engine has a record for are converted +// element by element where the fields allow it; every other list contributes its COUNT, which is +// all the counter needs and all the capture is sure of. +TurnCommandBlock ToTurnCommandBlock(const CapturedBlock& block); + +} // namespace sots::ai diff --git a/tests/app/CMakeLists.txt b/tests/app/CMakeLists.txt index 775ed25..c189a3a 100644 --- a/tests/app/CMakeLists.txt +++ b/tests/app/CMakeLists.txt @@ -46,3 +46,10 @@ foreach(_t app_test_catalog app_test_turn app_test_trade_raid app_test_alliance target_include_directories(${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_compile_options(${_t} PRIVATE -Wall -Wextra -Wpedantic) endforeach() + +# Replaying a recorded command stream: the count is charged for every command, a command we +# cannot apply is counted and declined rather than guessed, and the count-only control proves +# the modelled handlers write where they claim to. Builds its own board, so it always runs. +add_executable(app_test_command_replay test_command_replay.cpp) +target_link_libraries(app_test_command_replay PRIVATE sots_app) +add_test(NAME app_command_replay COMMAND app_test_command_replay) diff --git a/tests/app/test_command_replay.cpp b/tests/app/test_command_replay.cpp new file mode 100644 index 0000000..b483b6a --- /dev/null +++ b/tests/app/test_command_replay.cpp @@ -0,0 +1,233 @@ +// Replaying a recorded command stream against a save. +// +// Every check here runs on a save built in this file, so the test always runs and no .sav enters +// the repo. What it pins: +// +// 1. THE COUNT IS CHARGED FOR EVERY COMMAND, whether or not we can apply it. That is the +// point of the whole module: the modification counter is a property of the turn's command +// stream, not of our ability to model what the commands did, and it is the one leaf of the +// save that a save alone can never supply. +// 2. A COMMAND WE CANNOT APPLY IS COUNTED AND DECLINED, WITH A NAMED REASON -- never applied +// with a guessed payload, and never dropped. +// 3. AN INCOMPLETE ELEMENT IS NOT A ZERO ELEMENT. A route whose hops the instrument could not +// read must leave the fleet alone. +// 4. THE COUNT-ONLY CONTROL. With writing suppressed the state is untouched and the count is +// unchanged. That is what separates "our handlers happen to agree with the oracle" from +// "our handlers wrote to the right place", which an outcome cannot tell apart when the +// command re-issues a value the save already holds -- and on the reference turn EVERY +// modelled handler does exactly that. +#include +#include + +#include "app/command_replay.h" +#include "game/ai/command_capture.h" + +static int failures = 0; +#define CHECK(c) \ + do { \ + if (!(c)) { \ + std::printf("FAIL %s:%d %s\n", __FILE__, __LINE__, #c); \ + ++failures; \ + } \ + } while (0) + +using mars::stream::shapes::SaveGame; + +namespace { + +// Two players and one system, which is all any command below names. +SaveGame Board() { + SaveGame g; + g.sim.modCount = 100; + mars::stream::shapes::PlayerEntry a, b; + a.playerID = 16; + a.player.resRate = 0.25f; + b.playerID = 32; + b.player.resRate = 0.25f; + g.sim.players = {a, b}; + mars::stream::shapes::SysEntry s; + s.sysID = 288; + s.sys.rts.srsc = 1.0f; + g.sim.systems = {s}; + return g; +} + +sots::ai::Capture Parse(const char* text) { + sots::ai::Capture c; + sots::ai::CaptureDiagnostics d; + if (!sots::ai::ParseCapture(text, c, d)) { + for (const auto& e : d.errors) std::printf(" parse error: %s\n", e.c_str()); + ++failures; + } + return c; +} + +// The reference turn's shape: four submitting blocks, one with orders, four empty slots. +const char* kStream = R"(tcb 1 +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 0 +block 3 0 +)"; + +void TestTheStreamIsCountedInFull() { + SaveGame g = Board(); + const sots::ai::Capture c = Parse(kStream); + sots::app::ReplayOptions opt; + const sots::app::ReplayReport r = sots::app::ReplayTurnCommands(g, c, opt); + + // Two rate gates + build + rates + list 8 + list 10 + two list-14 = 8. List 23 is in the + // free half and costs nothing, which is the half no save has ever exercised. + CHECK(r.bumps == 8); + CHECK(r.bumpsExact); + CHECK(r.commands == 9); // the eight paying ones plus the free list-23 element + CHECK(r.blocks == 4); + CHECK(r.submittingBlocks == 2); + // Every command has a disposition and the four sum to the total. + CHECK(r.applied + r.transcribed + r.declined + r.incomplete == r.commands); + // Nothing may be silently dropped: every logged command carries a `what`, and every command + // we did not apply carries a reason. + for (const auto& e : r.log) { + CHECK(!e.what.empty()); + if (e.disposition != sots::app::ReplayDisposition::Applied) CHECK(!e.reason.empty()); + } +} + +void TestDeclinedCommandsChangeNothing() { + SaveGame g = Board(); + const sots::ai::Capture c = Parse(kStream); + sots::app::ReplayOptions opt; + sots::app::ReplayTurnCommands(g, c, opt); + // The build order names design 18 and system 288; the fleet commands name fleet 34, which + // this board does not contain. None of it may have leaked into the save. + CHECK(g.sim.fleets.empty()); + CHECK(g.sim.systems.size() == 1); + CHECK(g.sim.systems[0].sys.rts.srsc == 1.0f); // the rates frame is unread, so untouched + CHECK(g.sim.systems[0].sys.rts.srt == 0.0f); + // The one thing that IS modelled did run: the AI's rate reached its player and only its + // player. + CHECK(g.sim.players[1].player.resRate == 0.8f); + CHECK(g.sim.players[0].player.resRate == 0.25f); +} + +void TestCountOnlyIsTheControl() { + const sots::ai::Capture c = Parse(kStream); + SaveGame wrote = Board(), counted = Board(); + sots::app::ReplayOptions on, off; + off.countOnly = true; + const sots::app::ReplayReport a = sots::app::ReplayTurnCommands(wrote, c, on); + const sots::app::ReplayReport b = sots::app::ReplayTurnCommands(counted, c, off); + CHECK(a.bumps == b.bumps); // suppressing the writes may not change the count + CHECK(b.leafWrites == 0); + CHECK(a.leafWrites == 1); // the AI's rate, 0.25 -> 0.8 + CHECK(counted.sim.players[1].player.resRate == 0.25f); + CHECK(wrote.sim.players[1].player.resRate == 0.8f); +} + +void TestAnEmptyBlockStillCostsOne() { + SaveGame g = Board(); + const sots::ai::Capture c = Parse("tcb 1\nblock 0 16\ngate 0 rate 0.25\n"); + sots::app::ReplayOptions opt; + const sots::app::ReplayReport r = sots::app::ReplayTurnCommands(g, c, opt); + // The send-buffer build sets the rate gate unconditionally, so a player who ordered nothing + // at all still costs one -- and on the reference turn four of the ten bumps are exactly this. + CHECK(r.bumps == 1); + CHECK(r.commands == 1); + CHECK(r.leafWrites == 0); // 0.25 was already the value +} + +void TestAGateWithNoApplierMakesTheCountInexact() { + SaveGame g = Board(); + const sots::ai::Capture c = Parse("tcb 1\nblock 0 16\ngate 0 civilian\n"); + sots::app::ReplayOptions opt; + const sots::app::ReplayReport r = sots::app::ReplayTurnCommands(g, c, opt); + CHECK(!r.bumpsExact); + CHECK(r.bumps == 0); // a lower bound, and the report says so rather than guessing + CHECK(!r.warnings.empty()); +} + +void TestTheTargetGateIsATranscription() { + const char* stream = "tcb 1\nblock 0 32\ngate 0 target 144 name IND_Waldo\n"; + // Without the flag the command is counted and declined: the wire carries an id, the save + // carries a name, and the map between them is unread. + { + SaveGame g = Board(); + const sots::ai::Capture c = Parse(stream); + sots::app::ReplayOptions opt; + const sots::app::ReplayReport r = sots::app::ReplayTurnCommands(g, c, opt); + CHECK(r.bumps == 1); + CHECK(r.declined == 1 && r.transcribed == 0); + CHECK(g.sim.players[1].player.resTNm.empty()); + } + // With it, the leaf is written -- and reported in its own column, because writing a value an + // instrument observed is not the same as computing one. + { + SaveGame g = Board(); + const sots::ai::Capture c = Parse(stream); + sots::app::ReplayOptions opt; + opt.useRecordedNames = true; + const sots::app::ReplayReport r = sots::app::ReplayTurnCommands(g, c, opt); + CHECK(r.bumps == 1); + CHECK(r.transcribed == 1 && r.applied == 0); + CHECK(g.sim.players[1].player.resTNm == "IND_Waldo"); + } + // A capture with no recorded name declines even when the flag is given. + { + SaveGame g = Board(); + const sots::ai::Capture c = Parse("tcb 1\nblock 0 32\ngate 0 target 144\n"); + sots::app::ReplayOptions opt; + opt.useRecordedNames = true; + const sots::app::ReplayReport r = sots::app::ReplayTurnCommands(g, c, opt); + CHECK(r.declined == 1 && r.transcribed == 0); + CHECK(g.sim.players[1].player.resTNm.empty()); + } +} + +void TestCommandsAreAppliedInTheOriginalsOrder() { + SaveGame g = Board(); + const sots::ai::Capture c = Parse(kStream); + sots::app::ReplayOptions opt; + const sots::app::ReplayReport r = sots::app::ReplayTurnCommands(g, c, opt); + // The log is in schedule order, and the schedule is not list order: list 5 and list 23 come + // before the gates, and the gates come before list 3, list 10, list 14 and list 8. A port + // that applied lists 1..27 in order would produce a log whose step numbers were sorted by + // list number instead. + int prev = -1; + for (const auto& e : r.log) { + CHECK(e.step >= prev); + prev = e.step; + } + CHECK(r.log.front().what.rfind("list 5", 0) == 0); + CHECK(r.log.back().what.rfind("list 8", 0) == 0); + // A player id reaches every logged command, so a multi-player turn can be attributed. + for (const auto& e : r.log) CHECK(e.playerId == 16 || e.playerId == 32); +} + +} // namespace + +int main() { + TestTheStreamIsCountedInFull(); + TestDeclinedCommandsChangeNothing(); + TestCountOnlyIsTheControl(); + TestAnEmptyBlockStillCostsOne(); + TestAGateWithNoApplierMakesTheCountInexact(); + TestTheTargetGateIsATranscription(); + TestCommandsAreAppliedInTheOriginalsOrder(); + std::printf("app_test_command_replay: %s\n", failures ? "FAILURES" : "ok"); + return failures ? 1 : 0; +} diff --git a/tests/game_ai/CMakeLists.txt b/tests/game_ai/CMakeLists.txt index 4d02a51..e9e2d87 100644 --- a/tests/game_ai/CMakeLists.txt +++ b/tests/game_ai/CMakeLists.txt @@ -28,3 +28,18 @@ 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 order the server applies a turn's commands in: twenty-seven per-list steps with three +# per-player gate loops spliced in, which is neither list order nor member order. +add_executable(game_ai_test_apply_order test_apply_order.cpp) +target_link_libraries(game_ai_test_apply_order PRIVATE sots_game_ai) +target_include_directories(game_ai_test_apply_order PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_compile_options(game_ai_test_apply_order PRIVATE -Wall -Wextra -pedantic) +add_test(NAME game_ai_apply_order COMMAND game_ai_test_apply_order) + +# The recorded-turn capture format and the two real turns that exist in it. +add_executable(game_ai_test_command_capture test_command_capture.cpp) +target_link_libraries(game_ai_test_command_capture PRIVATE sots_game_ai) +target_include_directories(game_ai_test_command_capture PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) +target_compile_options(game_ai_test_command_capture PRIVATE -Wall -Wextra -pedantic) +add_test(NAME game_ai_command_capture COMMAND game_ai_test_command_capture) diff --git a/tests/game_ai/test_apply_order.cpp b/tests/game_ai/test_apply_order.cpp new file mode 100644 index 0000000..cefe526 --- /dev/null +++ b/tests/game_ai/test_apply_order.cpp @@ -0,0 +1,158 @@ +// The order the server applies a turn's commands in. +// +// This test cannot be written against an outcome, and saying so is half of its value. Both +// captures the campaign holds put every non-empty list on ONE player and every command on ONE +// system, so any permutation of the thirty steps produces the same save and the same counter. +// The order is implemented because a later workload will need it, and the checks below are +// therefore checks against the ORIGINAL'S INSTRUCTION STREAM, not against a result: +// +// * the six appliers the batch inlines each write the counter in place, so a watchpoint run +// recovered their addresses; those addresses must increase in the same order as the steps +// they belong to. That is nine of thirty positions independently pinned, and the test says +// nine rather than pretending to thirty; +// * the permutation must be a bijection of 1..27 and the gates must appear exactly once each, +// which catches a transcription slip and nothing else; +// * NEITHER the list sequence NOR the member-offset sequence may be ascending. A port that +// loops `for (list = 1..27)`, or walks the block's members in memory order, is the obvious +// wrong implementation and is the one thing an outcome-free test CAN rule out. +#include "game/ai/apply_order.h" + +#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::fprintf(stderr, "FAIL: %s\n", what.c_str()); + } +} + +// Every list exactly once, every gate exactly once, and nothing else in the schedule. +void TestSchedulePartitionsTheBlock() { + int listSeen[kCommandListCount + 1] = {}; + int gateSeen[kPrologueGateCount] = {}; + int listSteps = 0, gateSteps = 0; + for (int i = 0; i < kApplyStepCount; ++i) { + const ApplyStep& s = kApplySchedule[i]; + if (s.kind == ApplyStepKind::List) { + ++listSteps; + check(s.list >= 1 && s.list <= kCommandListCount, "list number in range"); + if (s.list >= 1 && s.list <= kCommandListCount) ++listSeen[s.list]; + } else { + ++gateSteps; + check(s.gateCount >= 1 && s.gateCount <= kMaxGatesPerStep, "gate count in range"); + for (int g = 0; g < s.gateCount; ++g) ++gateSeen[static_cast(s.gates[g])]; + } + } + check(listSteps == kCommandListCount, "twenty-seven list steps"); + check(gateSteps == 3, "three gate loops"); + for (int n = 1; n <= kCommandListCount; ++n) + check(listSeen[n] == 1, "list " + std::to_string(n) + " appears exactly once"); + // Five of the six gates have a located applier in this routine; the civilian-ratios gate has + // none at all, and its ABSENCE from the schedule is the honest representation of that. A + // schedule that quietly included it would be claiming a cost of zero for something whose cost + // is unknown. + for (int g = 0; g < kPrologueGateCount; ++g) { + const bool located = static_cast(g) != PrologueGate::CivilianRatios; + check(gateSeen[g] == (located ? 1 : 0), + "gate " + std::to_string(g) + (located ? " applied once" : " has no applier here")); + } +} + +// The two orderings a wrong implementation would produce. +void TestScheduleIsNeitherSortedOrder() { + bool listAscending = true, offsetAscending = true; + int prevList = 0, prevOffset = 0; + for (int i = 0; i < kApplyStepCount; ++i) { + const ApplyStep& s = kApplySchedule[i]; + if (s.kind != ApplyStepKind::List) continue; + if (s.list < prevList) listAscending = false; + if (s.memberOffset < prevOffset) offsetAscending = false; + prevList = s.list; + prevOffset = s.memberOffset; + } + check(!listAscending, "the list sequence is NOT 1..27"); + check(!offsetAscending, "the member-offset sequence is NOT ascending either"); + // The two anchors a reader can check by eye against the published table. + check(kApplySchedule[0].kind == ApplyStepKind::List && kApplySchedule[0].list == 6, + "the batch starts at list 6"); + check(kApplySchedule[kApplyStepCount - 1].kind == ApplyStepKind::GateLoop, + "the batch ends on a gate loop"); + check(StepIndexOfList(14) == 20, "list 14 is the twenty-first step"); + check(StepIndexOfList(8) == 24, "list 8 is the twenty-fifth step"); + check(StepIndexOfList(-1) == -1 && StepIndexOfList(28) == -1, "a bad list number has no step"); +} + +// The one part of the sequence this module can re-derive rather than inherit. +void TestInlinedBumpAddressesIncreaseWithTheSchedule() { + unsigned prevAddr = 0; + int prevStep = -1; + for (int i = 0; i < kScheduleAnchorCount; ++i) { + const ScheduleAnchor& a = kScheduleAnchors[i]; + check(a.address > prevAddr, std::string("address increases at ") + a.what); + check(a.step >= prevStep, std::string("step does not go backwards at ") + a.what); + check(a.step >= 0 && a.step < kApplyStepCount, std::string("step in range at ") + a.what); + prevAddr = a.address; + prevStep = a.step; + } + // Every anchored step must be the kind the anchor claims: the four inlined LIST appliers sit + // on list steps and the gate-loop anchors sit on gate steps. This is what would catch an + // anchor that had been renumbered against a reordered schedule. + check(kApplySchedule[18].kind == ApplyStepKind::List && kApplySchedule[18].list == 12, + "anchor 0x008902fe is list 12"); + check(kApplySchedule[19].kind == ApplyStepKind::List && kApplySchedule[19].list == 13, + "anchor 0x008903b9 is list 13"); + check(kApplySchedule[20].kind == ApplyStepKind::List && kApplySchedule[20].list == 14, + "anchor 0x0089046c is list 14"); + check(kApplySchedule[23].kind == ApplyStepKind::List && kApplySchedule[23].list == 7, + "anchor 0x008905c8 is list 7"); + check(kApplySchedule[9].kind == ApplyStepKind::GateLoop, "anchor 0x0088fdb0 is gate loop A"); + check(kApplySchedule[28].kind == ApplyStepKind::GateLoop, "anchor 0x008907b1 is gate loop B"); + check(kApplySchedule[29].kind == ApplyStepKind::GateLoop, "anchor 0x0089080a is gate loop C"); +} + +// The gates are not one group, and the two that sit in the middle are the two research ones. +void TestGatesAreSplitAcrossTheRun() { + const ApplyStep& a = kApplySchedule[9]; + check(a.kind == ApplyStepKind::GateLoop && a.gateCount == 3, "gate loop A tests three gates"); + check(a.gates[0] == PrologueGate::Group5 && a.gates[1] == PrologueGate::ResearchTarget && + a.gates[2] == PrologueGate::ResearchRate, + "gate loop A order: group5, target, rate"); + check(kApplySchedule[28].gates[0] == PrologueGate::ResearchBoost, "gate loop B is the boost"); + check(kApplySchedule[29].gates[0] == PrologueGate::Group4, "gate loop C is group 4"); + // Nine list steps run before any gate is tested, and eighteen run after. + int before = 0; + for (int i = 0; i < 9; ++i) before += kApplySchedule[i].kind == ApplyStepKind::List ? 1 : 0; + check(before == 9, "nine lists are applied before the first gate is even read"); +} + +// The cost half of the schedule, restated from the step rather than from the list number, so a +// step whose `bumps` flag disagreed with the 1..16 rule would be caught here. +void TestStepCostMatchesTheListCostRule() { + for (int i = 0; i < kApplyStepCount; ++i) { + const ApplyStep& s = kApplySchedule[i]; + if (s.kind != ApplyStepKind::List) continue; + check(s.bumps == ListAdvancesModCount(static_cast(s.list)), + "step cost agrees with the list cost rule for list " + std::to_string(s.list)); + } +} + +} // namespace + +int main() { + TestSchedulePartitionsTheBlock(); + TestScheduleIsNeitherSortedOrder(); + TestInlinedBumpAddressesIncreaseWithTheSchedule(); + TestGatesAreSplitAcrossTheRun(); + TestStepCostMatchesTheListCostRule(); + std::printf("game_ai/apply_order: %d checks, %d failures\n", g_checks, g_fails); + return g_fails == 0 ? 0 : 1; +} diff --git a/tests/game_ai/test_command_capture.cpp b/tests/game_ai/test_command_capture.cpp new file mode 100644 index 0000000..179aee9 --- /dev/null +++ b/tests/game_ai/test_command_capture.cpp @@ -0,0 +1,280 @@ +// 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 +#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::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 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; +} From f438d2f9a7b2d04034d4c9e55b57752aa2e08a66 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 19:05:29 -0400 Subject: [PATCH 3/3] RB: refuse to replay a capture silently against the wrong board A capture belongs to one save. Replaying one turn's commands against another turn's board is not an error the arithmetic can see -- the blocks name player ids that exist in both, so the counter is charged happily and the number is confidently wrong. The capture now records which save it was taken on and the CLI says so when they disagree, and says so too when the capture does not record it at all. --- src/app/main.cpp | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/app/main.cpp b/src/app/main.cpp index 67d0dd3..92fda89 100644 --- a/src/app/main.cpp +++ b/src/app/main.cpp @@ -278,6 +278,24 @@ int main(int argc, char** argv) { capture.blocks.size(), capture.seeds.size()); for (const auto& kv : capture.meta) std::printf(" %s: %s\n", kv.first.c_str(), kv.second.c_str()); + // A CAPTURE BELONGS TO ONE SAVE. Replaying a turn's commands against a different board + // is not an error the arithmetic can see -- the blocks name player ids that exist in + // both, so it charges the counter happily and produces a confident wrong number. When + // the capture records which save it was taken on, check it. + if (const std::string* bound = capture.Meta("input")) { + const std::string want = bound->substr(0, bound->find(' ')); + std::string have = in; + const std::size_t slash = have.find_last_of("/\\"); + if (slash != std::string::npos) have = have.substr(slash + 1); + if (!want.empty() && want != have) + std::printf("turn-commands: ! this capture was taken on '%s' and is being " + "replayed against '%s'. A capture belongs to ONE board; the counter " + "will be charged either way and the number will be wrong.\n", + want.c_str(), have.c_str()); + } else { + std::printf("turn-commands: ! this capture does not record which save it was taken " + "on, so nothing here can tell whether it belongs to this one\n"); + } } // Seeds given on the command line win over the ones in the file, and a seed for a client the // file does not mention is added. Nothing consumes them yet, so this is bookkeeping with a