From 5e409cfa0569f724ec2af95f326387ddbf31d4f9 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 12:44:00 -0400 Subject: [PATCH] lane A2: S04, the alliance mask -- 80 player-records, 560 fields, 0 mismatches The spine's fourth phase, read byte-for-byte and implemented: almem[i] = (1 << i) | (ALid != -1 ? AL : 0) with i the player's POSITION IN THE PLAYER VECTOR, not its index field. Both inputs are on the wire and so is the output, through the turn-record archive, so the phase is checkable against bytes the original wrote: app_turn_record: 11 saves, 80 player-records, 560 fields, 0 mismatches (was 480 fields over six fields; almem is the seventh) The eight zero masks of the corpus's earliest archived turn are PREDICTED, not excluded: the archiving phase also runs on load, and the load path does not run the spine. BuildTurnRecord takes spineRan and models it, so all 80 records are compared. Three parts of the rule the corpus cannot separate -- the bit index, the OR, and the ALid guard -- are pinned in app_alliance with the separating inputs no save provides, and app_turn_record prints that it could not separate them. Divergence, closed and regressed reported separately: turn1->turn2 default 209 -> 204 closed 5, regressed 0 turn1->turn2 --commit-blocked 209 -> 189 closed 29, regressed 9 (was 17) turn2->turn3 default 108 -> 103 closed 5, regressed 0 turn2->turn3 --commit-blocked 108 -> 106 closed 13, regressed 11 (was 19) T36 stays blocked: nine leaves would still be wrong (inc x3, sav x3 behind the budget; three census leaves behind the design catalogue). It now closes all 24 turnstats leaves on the reference pair, so it becomes a clean +24 once those two land. Prediction and falsification committed first in 49ae628. Gates run separately: clean-room OK; host ctest 43/43. No src/shim touched. --- docs/A2-alliance-and-modcount.md | 70 ++++++++++++++++++++ docs/S-standalone.md | 12 ++++ src/app/CMakeLists.txt | 1 + src/app/alliance.cpp | 25 +++++++ src/app/alliance.h | 47 +++++++++++++ src/app/phase_catalog.cpp | 18 +++-- src/app/turn.cpp | 53 ++++++++++++--- src/app/turn_record.cpp | 17 ++++- src/app/turn_record.h | 16 ++++- tests/app/CMakeLists.txt | 9 ++- tests/app/test_alliance.cpp | 110 +++++++++++++++++++++++++++++++ tests/app/test_turn_record.cpp | 31 ++++++++- 12 files changed, 387 insertions(+), 22 deletions(-) create mode 100644 src/app/alliance.cpp create mode 100644 src/app/alliance.h create mode 100644 tests/app/test_alliance.cpp diff --git a/docs/A2-alliance-and-modcount.md b/docs/A2-alliance-and-modcount.md index 523869d..3c6e803 100644 --- a/docs/A2-alliance-and-modcount.md +++ b/docs/A2-alliance-and-modcount.md @@ -103,3 +103,73 @@ The counter-evidence I already hold is that `human-turn2→turn3` is 28 and `zuu 16 on boards whose system and player counts are identical (28 systems, 7 players) — so it is not a function of the board size. If someone finds a closed-form rule over the pre-turn save that fits all eleven saves, this section is wrong. + +--- + +# 4. Result + +Everything above this line was committed (`49ae628`) before a line of the implementation existed. + +## 4.1 The divergence delta — `closed` and `regressed`, never netted + +`tools/standalone_report.py`, both real End-Turn pairs, on `wip/alliance`: + +| pair | run | baseline | after | **closed** | **regressed** | +|---|---|---:|---:|---:|---:| +| `turn1 → turn2` | default | 209 | **204** | **5** | **0** | +| `turn1 → turn2` | `--commit-blocked` | 209 | 189 | **29** | **9** *(was 17)* | +| `turn2 → turn3` | default | 108 | **103** | **5** | **0** | +| `turn2 → turn3` | `--commit-blocked` | 108 | 106 | **13** | **11** *(was 19)* | + +**The default run is unchanged at 5 closed / 0 regressed, and that is the honest headline.** `S04` +writes no save leaf of its own: the alliance mask reaches the wire only through `T36`, and `T36` +is still blocked. + +What moved is `T36`'s cost of committing. The 8 `almem` regressions are **gone**, and the +regression set on the reference pair is now exactly: + +``` +inc x3 sav x3 <- the budget: blocked behind the per-system money output +shpt[0] shpt[2] satt[0] <- the ship census: needs each design's hull size and 0x400 class flag +``` + +Nine leaves, from two named blockers, both owned by other lanes in flight. On the reference pair +`T36` now closes **all 24** `/Sim/turnstats` leaves. **When the budget and the design catalogue +land, `T36` is a clean +24 with nothing left over** — the same conclusion lane Y reached, with the +alliance third of it now paid for. + +One thing lane Y wrote is no longer true and is corrected here: *"which way the net falls depends +on the save"*. It does not any more — `--commit-blocked` is net positive on **both** pairs (+20 and ++2, against +12 and −9 before). `T36` nevertheless **stays blocked**, because `regressed > 0` means +nine leaves would be confidently wrong, and that is the whole rule the status exists to enforce. + +## 4.2 The alliance rule — measured + +`almem == (1 << vectorIndex) | (ALid != -1 ? AL : 0)`, against bytes the game wrote: +**11 saves, 80 player-records, 560 fields compared, 0 mismatches** (`app_turn_record`, up from 480 +fields). 14 records carry a live alliance id, so falsifier **F7** did not bite — the alliance term +is exercised. + +**F5 fired, and it was the model, not a miss.** All eight `almem` values of `turn1-state.sav` are +zero where the rule says otherwise, because the archiving phase also runs on load and the load path +does not run the spine. The engine states that as a prediction (`spineRan`), so all 80 records +compare rather than 72. + +**F1, F2 and F3 are all unfalsifiable on this corpus and the run says so every time.** Every player +in every save has `PlyrIdx == vectorIndex`; every observed alliance mask already contains its own +member's bit; and `AL == 0` exactly when `ALid == -1`. `app_alliance` pins all three with the +separating inputs no save provides. + +## 4.3 `ModCount` — the prediction stands, and the mechanism is named + +§3's reading is confirmed by the instruction stream and sharpened: the counter is +`Game::StrategySim + 0x4`, and the per-turn delta is **2 + one per command applied out of every +player's `TurnCommands` block**. The flush is `StrategyServer::ApplyAllTurnCommands`, called from +the same message handler that calls both turn drivers, and it walks a `vector` whose +0x1b4 stride independently confirms the block layout the campaign already recovered. + +So `S00` + `T00`'s two bumps really are all the standalone can produce, and the remaining 10–42 are +downstream of the AI's order generation. `ModCount` is **not** the next leaf to chase. The +watchpoint that would settle the residual in one turn is specified in +`sots-re/findings/control-flow/alliance-mask-and-modcount.md` §3, with its own written prediction +(exactly 12 hits on the reference workload) and four falsifiers. diff --git a/docs/S-standalone.md b/docs/S-standalone.md index 8dc5956..77eef6a 100644 --- a/docs/S-standalone.md +++ b/docs/S-standalone.md @@ -78,6 +78,11 @@ Nothing is `verified`. That is deliberate: in this table `verified` means "compa the live game", and lane S2 holds no VM. `app_test_catalog` asserts `verified == 0` so the claim cannot drift upward by accident. +> **Updated by lane A2, 2026-09-08.** `S04 RebuildAllianceMasks` is now `implemented` +> (15 of 44 modelled, 9 committed), and `T36`'s modelled field count went from six to seven. +> The tables below are lane S2's originals and are one lane behind; `docs/A2-alliance-and-modcount.md` +> §4 carries the current numbers and the measured `--commit-blocked` delta. + ### 3.1 Current state | driver | phases | modelled | committed | @@ -103,6 +108,7 @@ claim cannot drift upward by accident. | `P09 AccumulateTimedResearchBonuses` | the timed research-bonus vector, iterated **last → first**, which is load-bearing because float addition is not associative | | `P10 ConsumeResearchRollPending` | the strict `0.5f < progress/cost` test and the in-branch flag clear | | `T00 IncrementModCount` | the tail's own bump of the same counter | +| `S04 RebuildAllianceMasks` | the per-player shared-vision mask (lane A2); writes no leaf of its own — it reaches the wire through `T36` | **Evaluated and reported, not committed:** `P01` `P02` `P03` `P05` `P06` `P11` `S31` `T31`. @@ -186,6 +192,12 @@ In the order they must be solved, not in order of size. 3. **The post-combat tail.** 37 phases, none implemented, and it is the driver the autosave is written from. `turnstats`, the bankruptcy limits, the observed-design records and the player reports all live there. +3a. **`ModCount`.** Named and enumerated by lane A2: it is a *command* counter, bumped once on + entry to each of 26 `StrategySim` command-application sites plus once by each turn driver. Its + per-turn delta is the number of commands applied out of every player's queued command block, + which is downstream of the AI's order generation. `S00` + `T00` are all this standalone can + produce, and the leaf cannot close before the AI does. See `docs/A2-alliance-and-modcount.md`. + 4. **`Summary.Checksum`.** Its algorithm is unknown. It is one leaf, and it is the *last* leaf: whatever it hashes, it cannot be right until everything it hashes is right. 5. **The `Player.Status` writer.** The phase writes 1, the file carries 4, a load resets to 0. diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 741d372..2defd12 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -4,6 +4,7 @@ # The turn logic is a library so the test can drive it in process; `sots_turn` is the CLI. add_library(sots_app STATIC + alliance.cpp phase_catalog.cpp trade_raid.cpp turn_record.cpp diff --git a/src/app/alliance.cpp b/src/app/alliance.cpp new file mode 100644 index 0000000..e0443ec --- /dev/null +++ b/src/app/alliance.cpp @@ -0,0 +1,25 @@ +#include "app/alliance.h" + +namespace sots::app { + +std::int32_t AllianceMask(const mars::stream::shapes::Player& p, std::size_t vectorIndex) { + // The original clears the word, ORs in the self bit, and then -- only when the player + // carries an alliance id -- ORs in the alliance's own member mask. It is three separate + // stores to the same word, and the middle one is what makes the result an OR rather than + // an assignment. + const std::uint32_t self = 1u << (static_cast(vectorIndex) & 31u); + std::uint32_t mask = self; + if (p.alliances.alid != kNoAlliance) mask |= static_cast(p.alliances.al); + return static_cast(mask); +} + +std::vector RebuildAllianceMasks( + const std::vector& players) { + std::vector out; + out.reserve(players.size()); + for (std::size_t i = 0; i < players.size(); ++i) + out.push_back(AllianceMask(players[i].player, i)); + return out; +} + +} // namespace sots::app diff --git a/src/app/alliance.h b/src/app/alliance.h new file mode 100644 index 0000000..5e72af6 --- /dev/null +++ b/src/app/alliance.h @@ -0,0 +1,47 @@ +// The alliance / shared-vision mask -- the spine's fourth phase. +// +// Early in every strategic turn, before anything else in the turn can read it, the driver +// walks the server's player vector and rebuilds one word on each player's turn record from +// scratch. The word is a bitmask of "whose vision this player shares this turn": the player's +// own bit, plus every member of the player's alliance when it is in one. +// +// Two things about it are worth stating in the header because both are places a +// reimplementation goes quietly wrong. +// +// 1. The bit is the player's POSITION IN THE PLAYER VECTOR, not the player's own index +// field. Every other per-player index in the tail -- the turn-results array, the battle +// tally, the archive key -- uses the index field, and this one does not. The shift count +// in the original is the loop induction variable; the index field is never loaded in this +// phase. +// 2. The mask is rebuilt, not accumulated. The word is cleared first, so nothing survives +// from the previous turn. +// +// The word is not itself a save leaf. It reaches the wire only because the last phase of the +// post-combat tail copies the whole turn record into the per-turn statistics archive, which +// IS on the wire -- so this phase is checkable against bytes the original wrote. +#pragma once + +#include +#include +#include + +#include "mars/stream/shapes.h" + +namespace sots::app { + +// The value the original uses for "this player is in no alliance". +constexpr std::int32_t kNoAlliance = -1; + +// One player's mask. `vectorIndex` is the player's position in the server's player vector. +// +// Above 31 players the original's shift wraps -- x86 masks a variable shift count to five +// bits -- so bit (index % 32) would be set. No save in the corpus has more than eight +// players and that path is untested; it is reproduced rather than "fixed" because the point +// of this file is to be the original, not to be correct. +std::int32_t AllianceMask(const mars::stream::shapes::Player& p, std::size_t vectorIndex); + +// The whole phase: every player's mask, in vector order. +std::vector RebuildAllianceMasks( + const std::vector& players); + +} // namespace sots::app diff --git a/src/app/phase_catalog.cpp b/src/app/phase_catalog.cpp index 479c2d1..55e21dd 100644 --- a/src/app/phase_catalog.cpp +++ b/src/app/phase_catalog.cpp @@ -58,8 +58,12 @@ constexpr PhaseDesc kStrategic[] = { {Driver::Strategic, 2, "S02", "TradeManagerTurn", PhaseStatus::Stub, "ServerTradeManager::ProcessTurn -- feeds the trade slot of the budget"}, {Driver::Strategic, 3, "S03", "RegisterTradeSystems", PhaseStatus::Stub, ""}, - {Driver::Strategic, 4, "S04", "RebuildAllianceMasks", PhaseStatus::Stub, - "per-player shared-vision / alliance mask, rebuilt from scratch each turn"}, + {Driver::Strategic, 4, "S04", "RebuildAllianceMasks", PhaseStatus::Implemented, + "per-player shared-vision / alliance mask, rebuilt from scratch each turn: the player's " + "own bit -- its POSITION IN THE PLAYER VECTOR, not its index field -- OR the alliance's " + "member mask when the player carries an alliance id. The word is not a leaf of its own; " + "it reaches the wire through the tail's turn-record archive, and it agrees with the " + "archived bytes on 72 of 80 player-records with the other 8 predicted"}, {Driver::Strategic, 5, "S05", "BuildShipActionTypeSets", PhaseStatus::Stub, "builds the two action-type id sets the dispatcher runs over"}, {Driver::Strategic, 6, "S06", "ShipActionsExceptType2", PhaseStatus::Stub, @@ -213,12 +217,12 @@ constexpr PhaseDesc kTail[] = { {Driver::Tail, 34, "T34", "RecordObservedDesigns", PhaseStatus::Stub, ""}, {Driver::Tail, 35, "T35", "RebuildPlayerReports", PhaseStatus::Stub, ""}, {Driver::Tail, 36, "T36", "FinalizeTurnRecords", PhaseStatus::Blocked, - "fills every player's turn record and archives it by turn; must stay last. Six of its " + "fills every player's turn record and archives it by turn; must stay last. Seven of its " "fields are recoverable from the wire and are reproduced -- turn, colony count, savings, " - "the savings delta, the completed-tech count and the summed population -- and the model is " - "self-checked every run against the record the input save already carries for its own " - "turn. Not committed: five further fields of the same record are unmodelled, and savings " - "for the NEW turn comes from a blocked phase"}, + "the savings delta, the completed-tech count, the summed population and the alliance mask " + "phase S04 rebuilt -- and the model is self-checked every run against the record the input " + "save already carries for its own turn. Not committed: four further fields of the same " + "record are unmodelled, and savings for the NEW turn comes from a blocked phase"}, }; PhaseTally Tally(const PhaseDesc* p, std::size_t n) { diff --git a/src/app/turn.cpp b/src/app/turn.cpp index 9a02f9e..dbacbc2 100644 --- a/src/app/turn.cpp +++ b/src/app/turn.cpp @@ -7,6 +7,7 @@ #include #include +#include "app/alliance.h" #include "app/trade_raid.h" #include "app/turn_record.h" #include "game/sim/colony.h" @@ -284,8 +285,12 @@ TurnRecordAudit AuditTurnRecordsAgainstSave(const SaveGame& game) { continue; } int dangling = 0; + // The archiving phase runs both at the end of a turn and on load, and only the + // end-of-turn path has a spine behind it. The earliest turn the archive carries is + // the one the load path wrote, so its alliance mask is predicted to be zero. + const bool spineRan = inputTurn > EarliestArchivedTurn(hist); const TurnRecord built = BuildTurnRecord(game.sim.players[i].player, game.sim.systems, - inputTurn, &dangling); + inputTurn, i, spineRan, &dangling); a.dangling += dangling; const TurnRecordDiff d = CompareTurnRecord(built, *stored); ++a.playersChecked; @@ -299,7 +304,8 @@ TurnRecordAudit AuditTurnRecordsAgainstSave(const SaveGame& game) { } void RunFinalizeTurnRecords(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec, - const TurnRecordAudit& audit) { + const TurnRecordAudit& audit, + const std::vector& allianceMasks) { rec.invocations = static_cast(game.sim.players.size()); // Six fields per player would be written, plus a new archive element per player. Nothing // is committed by default: five further fields of the same element are unmodelled, and @@ -316,10 +322,15 @@ void RunFinalizeTurnRecords(SaveGame& game, const TurnOptions& opt, PhaseRecord& ++archived; continue; } - const TurnRecord built = - BuildTurnRecord(game.sim.players[i].player, game.sim.systems, game.sim.frame); + // The record being archived is this turn's, and this turn ran the spine, so the + // alliance mask is the one phase S04 rebuilt rather than a zero from the load path. + const TurnRecord built = BuildTurnRecord(game.sim.players[i].player, game.sim.systems, + game.sim.frame, i, /*spineRan=*/true); mars::stream::shapes::PlayerTurnStats s; s.trn = built.turn; + // The mask the spine's phase 4 rebuilt earlier in this same turn, not a value + // recomputed here: the dependency between the two phases is real and is expressed. + s.almem = i < allianceMasks.size() ? allianceMasks[i] : built.allianceMask; s.pop = built.population; s.col = built.colonies; s.sav = built.savings; @@ -334,11 +345,11 @@ void RunFinalizeTurnRecords(SaveGame& game, const TurnOptions& opt, PhaseRecord& } hist.stats.push_back(s); ++archived; - rec.leafWrites += 6; + rec.leafWrites += 7; } rec.committed = rec.leafWrites > 0; - if (!opt.commitBlocked) rec.wouldWrite = archived * 6; - rec.notes.push_back(fmt("%s %d record(s) for turn %d; 6 modelled field(s) each", + if (!opt.commitBlocked) rec.wouldWrite = archived * 7; + rec.notes.push_back(fmt("%s %d record(s) for turn %d; 7 modelled field(s) each", opt.commitBlocked ? "ARCHIVED" : "would archive", archived, game.sim.frame)); if (audit.playersChecked) @@ -436,6 +447,9 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { PlayerPhaseTotals pt; SystemTotals st; bool playerDriverRan = false; + // Filled by S04 and consumed by the tail's archiving phase. Empty until S04 runs, which + // is what makes the ordering between the two visible rather than assumed. + std::vector allianceMasks; for (std::size_t i = 0; i < ns; ++i) { PhaseRecord rec; @@ -453,6 +467,29 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { "modelled, so the leaf will not match yet"); break; } + case 4: { // S04 RebuildAllianceMasks + // Rebuilt from scratch every turn, before anything in the turn can read it. + // The word is not a save leaf of its own: it reaches the wire only through + // the tail's archiving phase, so this phase commits nothing here and the + // count of leaves it will cause to move is reported by that phase. + allianceMasks = RebuildAllianceMasks(game.sim.players); + int allied = 0; + for (const auto& e : game.sim.players) + if (e.player.alliances.alid != kNoAlliance) ++allied; + rec.invocations = static_cast(allianceMasks.size()); + rec.committed = true; + rec.notes.push_back(fmt("%d mask(s) rebuilt; %d player(s) carry an alliance id", + rec.invocations, allied)); + rec.notes.push_back("the mask is not a leaf of its own -- it reaches the wire " + "only through the turn-record archive, so the leaves it " + "moves are counted by the tail's last phase"); + if (allied == 0) + rec.notes.push_back("NO player is in an alliance in this save, so this run " + "exercises the self bit only and the alliance term is " + "an instruction-stream reading with no evidence behind " + "it here"); + break; + } case 11: { // S11 SystemTurn for (auto& e : game.sim.systems) RunSystemTurn(e.sys, static_cast(game.sim.players.size()), st); @@ -584,7 +621,7 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { rec.committed = true; rec.notes.push_back(fmt("ModCount -> %d", game.sim.modCount)); } else if (tp[i].index == 36) { - RunFinalizeTurnRecords(game, opt, rec, recordAudit); + RunFinalizeTurnRecords(game, opt, rec, recordAudit, allianceMasks); } r.records.push_back(rec); } diff --git a/src/app/turn_record.cpp b/src/app/turn_record.cpp index 0021451..b21e008 100644 --- a/src/app/turn_record.cpp +++ b/src/app/turn_record.cpp @@ -1,12 +1,14 @@ #include "app/turn_record.h" #include +#include + +#include "app/alliance.h" namespace sots::app { namespace { constexpr const char* kUnmodelled[] = { - "alliance/vision mask -- rebuilt by the alliance-mask phase of the spine, which is a stub", "trade income -- a budget-derived field, blocked behind the per-system money output", "battles fought -- written by the tail's battle-tally phase, which is a stub", "systems acquired / lost this turn -- the two counted lists are never non-empty in the " @@ -23,9 +25,12 @@ const char* const* TurnRecord::Unmodelled(std::size_t& count) { TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p, const std::vector& systems, - std::int32_t frame, int* danglingOwnedSystems) { + std::int32_t frame, std::size_t vectorIndex, bool spineRan, + int* danglingOwnedSystems) { TurnRecord r; r.turn = frame; + // Written by the spine's fourth phase, which the load path does not run. + r.allianceMask = spineRan ? AllianceMask(p, vectorIndex) : 0; r.savings = p.sav; // The turn's income is the change in savings across the turn, not a budget line: the // previous-turn savings word is on the wire and is stamped before this turn's savings are @@ -57,6 +62,13 @@ TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p, return r; } +std::int32_t EarliestArchivedTurn(const mars::stream::shapes::PlayerTurnHistory& hist) { + std::int32_t earliest = std::numeric_limits::max(); + for (const auto& s : hist.stats) + if (s.trn < earliest) earliest = s.trn; + return earliest; +} + const mars::stream::shapes::PlayerTurnStats* FindArchivedRecord( const mars::stream::shapes::PlayerTurnHistory& hist, std::int32_t turn) { for (const auto& s : hist.stats) @@ -81,6 +93,7 @@ TurnRecordDiff CompareTurnRecord(const TurnRecord& built, cmp("sav", built.savings, stored.sav); cmp("inc", built.income, stored.inc); cmp("tch", built.completedTech, stored.tch); + cmp("almem", built.allianceMask, stored.almem); return d; } diff --git a/src/app/turn_record.h b/src/app/turn_record.h index 66c3927..e33e5d3 100644 --- a/src/app/turn_record.h +++ b/src/app/turn_record.h @@ -27,6 +27,7 @@ struct TurnRecord { std::int32_t savings = 0; std::int32_t income = 0; // savings minus previous-turn savings std::int32_t completedTech = 0; // tech-tree entries in the completed state + std::int32_t allianceMask = 0; // the shared-vision mask the spine's phase 4 rebuilds // Fields the archive element also carries that this model does NOT fill, kept as a // published list rather than as silence. Each is blocked on something named. @@ -39,13 +40,26 @@ constexpr std::int32_t kTechStateCompleted = 4; // Build one player's record from the simulation state. `systemsById` is the save's system // table indexed the way the player's owned-system ids index it. +// +// `vectorIndex` is the player's position in the player vector -- the alliance mask's bit +// index, and NOT the player's own index field. See app/alliance.h. +// +// `spineRan` says whether the turn whose record this is actually ran the strategic spine. +// The archiving phase runs in two places -- at the end of a turn, and on load -- and the +// alliance mask is written only by the spine. So a record archived by the load path carries +// a zero mask, and that is a positive prediction of this model, not an exclusion: the +// earliest turn every save carries has `almem == 0` on every player, in all eleven saves. TurnRecord BuildTurnRecord(const mars::stream::shapes::Player& p, const std::vector& systems, - std::int32_t frame, + std::int32_t frame, std::size_t vectorIndex, bool spineRan, // set when an owned-system id is not present in the table, which // would silently drop a term from the population sum int* danglingOwnedSystems = nullptr); +// The earliest turn the archive carries for a player. The record for that turn was written +// by the new-game / load path, not by a turn. +std::int32_t EarliestArchivedTurn(const mars::stream::shapes::PlayerTurnHistory& hist); + // What the archive element for `turn` holds, if the save carries one for that turn. const mars::stream::shapes::PlayerTurnStats* FindArchivedRecord( const mars::stream::shapes::PlayerTurnHistory& hist, std::int32_t turn); diff --git a/tests/app/CMakeLists.txt b/tests/app/CMakeLists.txt index eb8245a..89e921e 100644 --- a/tests/app/CMakeLists.txt +++ b/tests/app/CMakeLists.txt @@ -16,12 +16,19 @@ add_executable(app_test_trade_raid test_trade_raid.cpp) target_link_libraries(app_test_trade_raid PRIVATE sots_app) add_test(NAME app_trade_raid COMMAND app_test_trade_raid) +# The alliance mask, as a rule. Pins the three things the corpus cannot separate -- the bit +# index, the OR, and the guard -- so it always runs. +add_executable(app_test_alliance test_alliance.cpp) +target_link_libraries(app_test_alliance PRIVATE sots_app) +add_test(NAME app_alliance COMMAND app_test_alliance) + # The turn-record model against the record the game itself archived; needs the owner's saves. add_executable(app_test_turn_record test_turn_record.cpp) target_link_libraries(app_test_turn_record PRIVATE sots_app) add_test(NAME app_turn_record COMMAND app_test_turn_record) -foreach(_t app_test_catalog app_test_turn app_test_trade_raid app_test_turn_record) +foreach(_t app_test_catalog app_test_turn app_test_trade_raid app_test_alliance + app_test_turn_record) target_include_directories(${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) target_compile_options(${_t} PRIVATE -Wall -Wextra -Wpedantic) endforeach() diff --git a/tests/app/test_alliance.cpp b/tests/app/test_alliance.cpp new file mode 100644 index 0000000..08498e4 --- /dev/null +++ b/tests/app/test_alliance.cpp @@ -0,0 +1,110 @@ +// The alliance / shared-vision mask, pinned as a rule rather than as a table of observations. +// +// The corpus agrees with this rule on 72 of 80 player-records and predicts the other 8 (the +// zero masks of every save's earliest archived turn, which the load path wrote without a +// spine). That comparison lives in app_test_turn_record, where the stored bytes are. What is +// pinned HERE is the part the corpus cannot separate: +// +// * the bit is the player's position in the vector, not its own index field. Every save in +// the corpus has the two equal, so no comparison against stored bytes can tell them +// apart -- only the instruction stream can, and this test holds the reading. +// * the mask is an OR of the self bit with the alliance's member mask. In the corpus every +// observed alliance mask already contains its member's own bit, so `self | AL` and `AL` +// are indistinguishable there. +// * the guard is on the alliance id, not on the member mask being non-zero. In the corpus +// the two always agree, because a player with no alliance id also has a zero mask. +// +// Three cases that no save exercises, each held here so that a later save which does +// exercise one has something to disagree with. +#include + +#include "app/alliance.h" +#include "mars/stream/shapes.h" + +static int failures = 0; +#define CHECK_EQ(a, b) \ + do { \ + const long long va = (long long)(a), vb = (long long)(b); \ + if (va != vb) { \ + std::printf("FAIL %s:%d %s == %s (%lld != %lld)\n", __FILE__, \ + __LINE__, #a, #b, va, vb); \ + ++failures; \ + } \ + } while (0) + +using mars::stream::shapes::Player; +using mars::stream::shapes::PlayerEntry; + +static Player MakePlayer(int plyrIdx, int alid, int al) { + Player p; + p.plyrIdx = plyrIdx; + p.alliances.alid = alid; + p.alliances.al = al; + return p; +} + +int main() { + // 1. No alliance: the self bit alone, at the VECTOR index. + for (std::size_t i = 0; i < 8; ++i) { + const Player p = MakePlayer(static_cast(i), sots::app::kNoAlliance, 0); + CHECK_EQ(sots::app::AllianceMask(p, i), 1 << i); + } + + // 2. The index field disagrees with the vector position. The original shifts by the loop + // variable and never loads the index field, so the vector position wins. No save in the + // corpus has these two apart; this is the instruction-stream reading, pinned. + { + const Player p = MakePlayer(/*plyrIdx=*/7, sots::app::kNoAlliance, 0); + CHECK_EQ(sots::app::AllianceMask(p, 2), 1 << 2); + } + + // 3. In an alliance whose member mask does NOT already contain the player's own bit. + // Every alliance in the corpus does contain it, so `self | AL` and `AL` are the same + // number there. Here they are not, and the OR is what is being held. + { + const Player p = MakePlayer(/*plyrIdx=*/1, /*alid=*/0, /*al=*/0x0c); // bits 2 and 3 + CHECK_EQ(sots::app::AllianceMask(p, 1), 0x0e); // 1|2|3 + } + + // 4. The guard is the alliance id, not the member mask. A stale member mask with no + // alliance id contributes nothing. Unobserved in the corpus, where the two never differ. + { + const Player p = MakePlayer(/*plyrIdx=*/1, sots::app::kNoAlliance, /*al=*/0x0c); + CHECK_EQ(sots::app::AllianceMask(p, 1), 0x02); + } + + // 5. An alliance id of zero is a real alliance. -1 is the only "none", and a check + // written as `if (alid)` would drop every player of alliance 0 -- which is the only + // alliance id that appears anywhere in the corpus. + { + const Player p = MakePlayer(/*plyrIdx=*/2, /*alid=*/0, /*al=*/0x0c); + CHECK_EQ(sots::app::AllianceMask(p, 2), 0x0c); + } + + // 6. The whole phase, in vector order, and the shape the corpus actually shows: two + // allied players carrying the same member mask, the rest alone. + { + std::vector players(8); + for (std::size_t i = 0; i < players.size(); ++i) + players[i].player = MakePlayer(static_cast(i), sots::app::kNoAlliance, 0); + players[2].player = MakePlayer(2, 0, 0x0c); + players[3].player = MakePlayer(3, 0, 0x0c); + const std::vector m = sots::app::RebuildAllianceMasks(players); + CHECK_EQ(m.size(), players.size()); + const std::int32_t want[8] = {1, 2, 0x0c, 0x0c, 0x10, 0x20, 0x40, 0x80}; + for (std::size_t i = 0; i < 8; ++i) CHECK_EQ(m[i], want[i]); + } + + // 7. The shift wraps above 31, because x86 masks a variable shift count to five bits. + // Untested against any game -- no save has more than eight players -- and reproduced + // rather than corrected, so that a reimplementation running a 32-player game diverges the + // same way the original does instead of diverging differently. + { + const Player p = MakePlayer(0, sots::app::kNoAlliance, 0); + CHECK_EQ(sots::app::AllianceMask(p, 32), 1); + CHECK_EQ(sots::app::AllianceMask(p, 33), 2); + } + + std::printf("app_test_alliance: %d failure(s)\n", failures); + return failures ? 1 : 0; +} diff --git a/tests/app/test_turn_record.cpp b/tests/app/test_turn_record.cpp index 8e5ea4d..cf5ef57 100644 --- a/tests/app/test_turn_record.cpp +++ b/tests/app/test_turn_record.cpp @@ -11,6 +11,7 @@ // // Reads $SOTS_SAVES_DIR at run time and skips cleanly when it is unset. No .sav enters this // repo. +#include #include #include #include @@ -18,6 +19,7 @@ #include +#include "app/alliance.h" #include "app/turn_record.h" #include "mars/stream/save.h" @@ -54,6 +56,10 @@ int main() { } int files = 0, players = 0, fields = 0, dangling = 0, noArchive = 0; + // Reported rather than assumed: how much of the alliance rule the corpus actually + // exercises. A green run over records that are all `alid == -1` would test the self bit + // and nothing else, and would look identical to a green run that tested everything. + int alliedRecords = 0, loadWrittenRecords = 0, indexDiffers = 0; for (const std::string& path : saves) { mars::stream::SaveDocument doc; try { @@ -78,12 +84,21 @@ int main() { continue; } int dang = 0; - const sots::app::TurnRecord built = - sots::app::BuildTurnRecord(sim.players[i].player, sim.systems, sim.frame, &dang); + // The archiving phase runs at the end of a turn AND on load, and only the + // end-of-turn path has a spine behind it to have written the alliance mask. So + // the earliest turn the archive carries is predicted to have a zero mask -- a + // positive claim the corpus checks on 8 records, not a field skipped. + const bool spineRan = + sim.frame > sots::app::EarliestArchivedTurn(sim.turnstats.players[i].hist); + const sots::app::TurnRecord built = sots::app::BuildTurnRecord( + sim.players[i].player, sim.systems, sim.frame, i, spineRan, &dang); dangling += dang; // An owned-system id the save's table does not carry would drop a term from the // population sum without any other symptom, so it is a failure, not a note. CHECK(dang == 0); + if (sim.players[i].player.alliances.alid != sots::app::kNoAlliance) ++alliedRecords; + if (!spineRan) ++loadWrittenRecords; + if (sim.players[i].player.plyrIdx != static_cast(i)) ++indexDiffers; const sots::app::TurnRecordDiff diff = sots::app::CompareTurnRecord(built, *stored); ++filePlayers; fileFields += diff.compared; @@ -103,11 +118,21 @@ int main() { // failure mode this campaign has paid for twice. CHECK(files > 0); CHECK(players > 0); - CHECK(fields == players * 6); + CHECK(fields == players * 7); std::printf("app_test_turn_record: %d save(s), %d player-record(s), %d field(s) compared, " "%d player(s) with no archive element, %d dangling owned-system id(s), " "%d failure(s)\n", files, players, fields, noArchive, dangling, failures); + // Coverage of the alliance rule, stated as loudly as the verdict (earned rule 15). + std::printf(" alliance mask: %d of %d record(s) carry an alliance id; %d were written by " + "the load path and are predicted to be zero; %d record(s) have a vector " + "position that differs from the player index field\n", + alliedRecords, players, loadWrittenRecords, indexDiffers); + if (indexDiffers == 0) + std::printf(" NOT SEPARATED by this corpus: every player's vector position equals its " + "index field, so no comparison here can tell `1 << position` from " + "`1 << index`. The instruction stream is what settles it; app_alliance " + "holds that reading.\n"); return failures ? 1 : 0; }