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