From 9f8d75d9cb34c7bc050f66bee6ccd100f4c2d6a8 Mon Sep 17 00:00:00 2001 From: lane-b6 Date: Tue, 8 Sep 2026 14:33:16 -0400 Subject: [PATCH 1/3] docs: B6 prediction before the build -- the build queue closes nothing on either reference pair Written before any code (earned rule 2). Both reference saves carry three BQ frames with zero orders, every hbq false and the only TurnCommands_v5 block empty, so the pass has nothing to advance; the archived destroyer is created inside the turn by the AI. Includes the falsification list and names the oracle pair the campaign already owns. --- docs/B6-ship-construction.md | 71 ++++++++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 docs/B6-ship-construction.md diff --git a/docs/B6-ship-construction.md b/docs/B6-ship-construction.md new file mode 100644 index 0000000..28f1932 --- /dev/null +++ b/docs/B6-ship-construction.md @@ -0,0 +1,71 @@ +# B6 — ship construction: the build queue, and where the missing destroyer actually comes from + +Lane B6. Branch `wip/build` off `main` `0f1c007`. + +This file was written and committed **before** the code (earned rule 2). Section 1 is the +prediction as first written; section 2 is the falsification list; sections 3 onward are filled +in after the measurement and correct section 1 in place where it was wrong. + +--- + +## 1. Prediction, written before the build + +The brief's target is `hist[*]/stats[*]/shpt[0]` on both reference pairs — the archived ship +census is one destroyer higher than ours, and lane E2 concluded that no phase we run builds a +ship. That conclusion is right. The inference drawn from it — that `S11`'s build-queue +sub-pass is what closes it — is **wrong**, and this lane predicts the pass closes **nothing**: + +> **Both reference pairs: closed 0, regressed 0.** +> +> `turn1-state.sav` and `turn2-state.sav` each carry exactly **three** `BQ` frames (one per +> owned system: players 16, 32 and 576), and **every one of them holds zero orders**. Every +> `hbq` in both files is `false`, so there is no ship-borne `BQ2` queue either. The only +> `TurnCommands_v5` block belongs to player 16 and is the byte-identical empty one. So at the +> moment the standalone loads either save, **the whole game contains no build order at all**, +> and a faithful build-queue pass has nothing to advance. +> +> The destroyer the archive counts is created *inside* the turn: on the reference pair the +> owner is player 32 ("Fane Lao"), an AI, and the same turn shows `NumDes` 5 → 6 (a design the +> AI designed during the turn), `FNG/FNGNum` 0 → 1 (a fleet named), `Maint` 0 → 500, +> `ShipRecs/srb[0]` 0 → 1 and a new `ShipRecs` design record, with `NMnx` 106 → 109 — three +> ids issued in the order design(107), ship(108), fleet(109). The order that fed the queue was +> issued by the AI and consumed in the same turn. +> +> **`shpt[0]` is blocked on AI order generation (`game/ai`, Rung B), not on S11.** This lane +> moves the blocker; it does not close the leaf. + +What this lane does deliver, and what it is worth: + +* the build-queue advance read byte for byte from `Game::BuildQueue::ProcessTurn` + (`0x00890d50`, real end `0x00891240` — Ghidra says 1230 bytes, the body runs 1264), which + **independently confirms** lane B4's `sim::ProcessBuildQueue` and finds **one divergence** + in it (§4); +* the `ShipRecords` update the pass performs, whose per-class counter has **exactly one + writer in the whole image**; +* the pass wired into the standalone's S11 as its own reported sub-pass; +* a real oracle for the pass that the campaign already owns and had not noticed: + `zuul-turn16-noderoute.sav` → `zuul-turn17-rollpending.sav` is a genuine consecutive-turn + pair (frames 16 and 17) in which **six orders complete and one is partially advanced** + across two systems (§5). + +## 2. How this prediction could be wrong, and the symptom of each way + +1. **A build order exists somewhere I did not look.** The reader types unknown regions as + `raw`; a queue hidden inside one would not show up in a tag scan. *Symptom:* the standalone + reports a non-zero order count on a reference pair, and `closed` is not 0. +2. **The order is created before the spine rather than during it.** If the AI's orders were + applied at load time by something the standalone also runs, the queue would be non-empty by + S11 and the pass would build. *Symptom:* same as (1). (The measured `PvSav` says otherwise: + Fane Lao's savings are 50,000 in the input file and the previous-turn snapshot in the output + file is 38,100, so 11,900 left the treasury *between the save and phase 0* — the queue-time + deduction of a build order the file does not contain.) +3. **The FIFO + stop-at-the-first-short-order model is wrong.** *Symptom:* on the + `zuul-turn16 → zuul-turn17` pair there is **no** single non-negative integer point total per + system that turns the observed before-queue into the observed after-queue. This is a real + test: a per-order point budget, a non-FIFO order, or a "skip and continue" rule instead of + "stop" each fail it. +4. **The `points <= 0` gate on the removal sweep is wrong.** Unobservable in this corpus + (§4) and therefore carried as a labelled hypothesis, not a result. +5. **`shpt` is not the fleet walk.** If the census leaf were fed by `ShipRecs` rather than by + walking fleets, the fix would be different. Lane E2 already falsifies this: the fleet walk + reproduces 480/480 archived census leaves. From 0fcbb69ba5e11592587c784f4cc132f0b77570b6 Mon Sep 17 00:00:00 2001 From: lane-b6 Date: Tue, 8 Sep 2026 14:39:20 -0400 Subject: [PATCH 2/3] game/sim + app: ship construction -- the build-queue completion bookkeeping, and S11's sub-pass wired in game/sim/construction.{h,cpp}: Game::ShipRecords (four per-hull-class arrays plus the per-design vector, sized by ENUMERATION against the wire, not by what the code touches) and the completion bookkeeping BuildQueue::ProcessTurn performs -- the per-class built counter, whose indexed increment has EXACTLY ONE writer in the whole image, and the find-or-append per-design record keyed by the design's object id. RunSystemConstruction wraps the point pass and keeps each completion's design id, which the point pass alone does not report. game/sim/colony: corrected from the instruction stream -- with points <= 0 the entry test branches to the epilogue, so the REMOVAL SWEEP IS SKIPPED TOO. Carried as a labelled hypothesis: no corpus save can reach the state that shows it. app/construction_phase.{h,cpp}: S11's build-queue sub-pass, reported on its own line because what blocks it is not what blocks the rest of the colony turn. It is blocked on the per-system output term for points; it is NOT what the archived ship census waits on. tests/game_sim/test_construction.cpp: 65 checks, including a corpus oracle the campaign already owned and had not noticed -- zuul-turn16-noderoute -> zuul-turn17-rollpending is a real consecutive-turn pair in which six orders complete and one is partially advanced. The test SOLVES for the point total rather than assuming it, so a non-FIFO order, a per-order budget or skip-instead-of-stop each falsify it. --- src/app/CMakeLists.txt | 1 + src/app/construction_phase.cpp | 96 +++++++++++ src/app/construction_phase.h | 58 +++++++ src/app/phase_catalog.cpp | 8 +- src/app/turn.cpp | 9 + src/game/sim/CMakeLists.txt | 3 +- src/game/sim/colony.cpp | 9 +- src/game/sim/colony.h | 7 + src/game/sim/construction.cpp | 66 ++++++++ src/game/sim/construction.h | 109 ++++++++++++ tests/game_sim/CMakeLists.txt | 2 +- tests/game_sim/test_construction.cpp | 244 +++++++++++++++++++++++++++ 12 files changed, 607 insertions(+), 5 deletions(-) create mode 100644 src/app/construction_phase.cpp create mode 100644 src/app/construction_phase.h create mode 100644 src/game/sim/construction.cpp create mode 100644 src/game/sim/construction.h create mode 100644 tests/game_sim/test_construction.cpp diff --git a/src/app/CMakeLists.txt b/src/app/CMakeLists.txt index 02824cf..6fcd4da 100644 --- a/src/app/CMakeLists.txt +++ b/src/app/CMakeLists.txt @@ -8,6 +8,7 @@ add_library(sots_app STATIC phase_catalog.cpp trade_raid.cpp turn_record.cpp + construction_phase.cpp visibility_phase.cpp turn.cpp report.cpp) diff --git a/src/app/construction_phase.cpp b/src/app/construction_phase.cpp new file mode 100644 index 0000000..477adaf --- /dev/null +++ b/src/app/construction_phase.cpp @@ -0,0 +1,96 @@ +#include "app/construction_phase.h" + +#include +#include +#include + +#include "game/sim/construction.h" + +namespace sots::app { + +namespace { + +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); +} + +} // namespace + +ConstructionPhaseResult RunBuildQueues(mars::stream::shapes::SaveGame& game) { + ConstructionPhaseResult r; + + // The owner of a queue is the system's `PID`, which is the player's OBJECT id, not its + // position in the player vector. The two are different numbers and confusing them is a + // trap this codebase has already paid for once. + std::map playerIndex; + for (std::size_t i = 0; i < game.sim.players.size(); ++i) + playerIndex[game.sim.players[i].playerID] = i; + + int shipBorne = 0; + int ordersByOwner = 0; + int unownedQueues = 0; + + for (auto& e : game.sim.systems) { + if (!e.sys.bq.has_value()) continue; + ++r.queuesVisited; + auto& q = *e.sys.bq; + r.ordersPending += static_cast(q.orders.size()); + for (const auto& o : q.orders) r.pointsDemanded += o.conleft; + if (!q.orders.empty()) { + if (playerIndex.count(e.sys.pid)) + ++ordersByOwner; + else + ++unownedQueues; + } + } + + // The ship-borne queues. Every one of them is gated behind the ship's `hbq` flag, and + // the count is reported because an order hiding in one would falsify the "no orders on + // the reference pair" reading, which is the load-bearing claim of this phase. + for (const auto& fe : game.sim.fleets) + for (const auto& se : fe.flt.ships) + if (se.ship.hbq) shipBorne += static_cast(se.ship.bq2.orders.size()); + + r.blockedOnPoints = r.ordersPending > 0; + + if (r.queuesVisited == 0) { + r.notes.push_back("no system carries a build queue: a queue is written only for a " + "system with an owner"); + return r; + } + + r.notes.push_back(fmt("%d system build queue(s), %d pending order(s) demanding %d " + "construction point(s); %d order(s) in ship-borne queues", + r.queuesVisited, r.ordersPending, r.pointsDemanded, shipBorne)); + + if (r.ordersPending == 0 && shipBorne == 0) { + r.notes.push_back("NOTHING TO BUILD. The pass is faithful and idle: with no order " + "anywhere in the game it cannot create a ship, so it closes no " + "leaf here. On the reference pairs the archived ship census is " + "still one destroyer higher than ours, and the order behind that " + "destroyer is created inside the turn by the AI -- that leaf is " + "blocked on AI order generation, not on this phase"); + return r; + } + + // Points would come from the system's output vector; that term is the roadmap's item 1 + // and is not this lane's. The pass is therefore driven with nothing and reports the + // demand, so the shape of the gap is visible in the run log. + r.notes.push_back(fmt("BLOCKED: construction points come from the per-system output " + "term (out[7] scaled by the shipyard bonus, then out[8] = min(out" + "[7], demand)), which is unmodelled. %d order(s) would be offered " + "points this turn", r.ordersPending)); + if (unownedQueues) + r.notes.push_back(fmt("%d queue(s) hold orders but their system's owner id resolves " + "to no player in the save -- reported, not skipped silently", + unownedQueues)); + (void)ordersByOwner; + return r; +} + +} // namespace sots::app diff --git a/src/app/construction_phase.h b/src/app/construction_phase.h new file mode 100644 index 0000000..510f9bd --- /dev/null +++ b/src/app/construction_phase.h @@ -0,0 +1,58 @@ +// S11's build-queue sub-pass, wired to the save shapes. +// +// WHERE IT SITS +// ------------- +// `StrategyServer::ProcessTurn` phase 11 walks the systems; each system's own turn runs the +// build queue between the plague pass and the population growth. The engine's S11 already +// runs the parts of the colony turn that need neither the tuning table nor a carrying +// capacity; this is the build-queue part, kept in its own file and reported as its own line +// so its contribution is never folded into S11's other writes. +// +// WHAT IT IS BLOCKED ON, and what it is NOT blocked on +// ---------------------------------------------------- +// Two different things, and the campaign had them confused: +// +// * the POINTS. `BuildQueue::ProcessTurn` takes construction points by value. They come +// from the system's output vector -- `out[7]` scaled by the shipyard station bonus, then +// `out[8] = min(out[7], queue demand)` -- which is the per-system output term (roadmap +// item 1). Until that lands this phase has no points to spend and it says so with the +// demand named, rather than inventing a number. +// +// * the ORDERS. On BOTH reference pairs there are none. `turn1-state.sav` and +// `turn2-state.sav` each carry three build queues, one per owned system, and all three +// are empty; every `hbq` is false, so no ship-borne queue exists either; and the only +// `TurnCommands_v5` block is the human's, which is the byte-identical empty one. The +// destroyer the archived turn record counts is built from an order the AI creates +// *during* the turn. So the census leaf `shpt[0]` is blocked on AI order generation, not +// on this phase, and this phase closes nothing on either reference pair by design. +// +// Five of the eleven corpus saves DO carry orders (4, 7, 2, 2 and 1 of them), so the pass is +// exercised the moment the points arrive; the run log reports what it would do on each. +#pragma once + +#include +#include + +#include "mars/stream/shapes.h" + +namespace sots::app { + +struct ConstructionPhaseResult { + int queuesVisited = 0; // systems that carry a build queue at all + int ordersPending = 0; // orders sitting in those queues + int pointsDemanded = 0; // sum of `conleft` over every pending order + int shipsBuilt = 0; // completions this run actually performed + int leafWrites = 0; // save leaves changed (0 while the points are blocked) + int wouldWrite = 0; // leaves a points-fed pass would change + bool blockedOnPoints = false; + std::vector notes; +}; + +// Run the build-queue sub-pass over every system that owns a queue. +// +// `points` is not available from the save, so the pass is driven with zero points and +// reports the demand. Nothing is committed: an order that advanced with no ship behind it +// would leave the save in a state the game never produces, which is worse than not running. +ConstructionPhaseResult RunBuildQueues(mars::stream::shapes::SaveGame& game); + +} // namespace sots::app diff --git a/src/app/phase_catalog.cpp b/src/app/phase_catalog.cpp index 5b83b06..1921c51 100644 --- a/src/app/phase_catalog.cpp +++ b/src/app/phase_catalog.cpp @@ -79,8 +79,12 @@ constexpr PhaseDesc kStrategic[] = { "upkeep of population carried aboard colony/slaver hulls in transit"}, {Driver::Strategic, 11, "S11", "SystemTurn", PhaseStatus::Partial, "runs game::sim ProcessColonyTurn per system and commits the parts that need neither the " - "tuning table nor a carrying capacity; plague, growth, resources, slaves, rebellion and " - "the build queue are the sub-passes the model already declares as its input boundary"}, + "tuning table nor a carrying capacity; plague, growth, resources, slaves and rebellion " + "are the sub-passes the model still declares as its input boundary. The BUILD QUEUE is " + "modelled and runs (it is the only writer of the per-class built counter in the whole " + "image) but has no points to spend: they come from the per-system output term. It is " + "NOT what the missing destroyer waits on -- no build order exists anywhere in either " + "reference save, so that order is created inside the turn by the AI"}, {Driver::Strategic, 12, "S12", "TradeSliderFinalisation", PhaseStatus::Stub, "re-normalises the per-system output rates"}, {Driver::Strategic, 13, "S13", "PlayerTurn", PhaseStatus::Partial, diff --git a/src/app/turn.cpp b/src/app/turn.cpp index 8cef6eb..63514d5 100644 --- a/src/app/turn.cpp +++ b/src/app/turn.cpp @@ -8,6 +8,7 @@ #include #include "app/alliance.h" +#include "app/construction_phase.h" #include "app/trade_raid.h" #include "app/turn_record.h" #include "app/visibility_phase.h" @@ -785,6 +786,14 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) { rec.notes.push_back(fmt("%d system(s) left their countdown words alone: the " "companion active-player mask is not identified", st.skippedCountdown)); + // The build-queue sub-pass reports on its own line rather than folding its + // numbers into S11's, because what blocks it is not what blocks the rest of + // the colony turn. See app/construction_phase.h. + { + const ConstructionPhaseResult b = RunBuildQueues(game); + rec.wouldWrite += b.wouldWrite; + for (const auto& n : b.notes) rec.notes.push_back("build queue: " + n); + } break; } case 13: { // S13 PlayerTurn -- the nested driver diff --git a/src/game/sim/CMakeLists.txt b/src/game/sim/CMakeLists.txt index c234d55..df5b5bd 100644 --- a/src/game/sim/CMakeLists.txt +++ b/src/game/sim/CMakeLists.txt @@ -4,6 +4,7 @@ # sources with plain g++ in the meantime. add_library(sots_game_sim STATIC economy.cpp + construction.cpp research.cpp colony.cpp movement.cpp @@ -18,7 +19,7 @@ endif() option(SOTS_GAME_SIM_TESTS "Build the game/sim unit tests" OFF) if(SOTS_GAME_SIM_TESTS) enable_testing() - set(_sim_tests economy research colony movement techgraph visibility) + set(_sim_tests economy research colony movement techgraph visibility construction) foreach(_t IN LISTS _sim_tests) add_executable(game_sim_test_${_t} ${CMAKE_CURRENT_SOURCE_DIR}/../../../tests/game_sim/test_${_t}.cpp) target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim) diff --git a/src/game/sim/colony.cpp b/src/game/sim/colony.cpp index e917df1..19e4e30 100644 --- a/src/game/sim/colony.cpp +++ b/src/game/sim/colony.cpp @@ -541,7 +541,14 @@ void AccrueSystemBonus(const SystemBonusInputs& in, std::int64_t& popBonus, doub BuildQueueResult ProcessBuildQueue(std::vector& queue, int points) { BuildQueueResult r; - if (points > 0) { + if (points <= 0) { + // The whole body is skipped, REMOVAL SWEEP INCLUDED: the entry test branches + // straight to the epilogue, which returns the points untouched. Corrected by lane + // B6 from the instruction stream; see the header for why no save can show it. + r.pointsLeft = points; + return r; + } + { for (BuildOrder& o : queue) { if (o.constructionLeft > points) { o.constructionLeft -= points; diff --git a/src/game/sim/colony.h b/src/game/sim/colony.h index cdc36de..623dec4 100644 --- a/src/game/sim/colony.h +++ b/src/game/sim/colony.h @@ -599,6 +599,13 @@ struct BuildQueueResult { // including ones that were already at zero before this turn. More than one order can // complete in a turn. CONFIDENCE: high -- B4 corrected the money-refusal path (continue, not // stop), the removal sweep's predicate, and that the leftover is the return value. +// +// The `points <= 0` case skips **everything**, the removal sweep included: the entry test +// branches to the epilogue, which returns the argument. Corrected by lane B6 from the +// instruction stream, and carried as a LABELLED HYPOTHESIS rather than a result because no +// save can exercise it: an order can only reach `conleft <= 0` inside this pass, and this +// pass erases it before returning, so a queue never *starts* a turn with one -- unless a +// design with zero construction cost is ever queued, which no corpus save has done. BuildQueueResult ProcessBuildQueue(std::vector& queue, int points); // --------------------------------------------------------------------------------------- diff --git a/src/game/sim/construction.cpp b/src/game/sim/construction.cpp new file mode 100644 index 0000000..39d4b61 --- /dev/null +++ b/src/game/sim/construction.cpp @@ -0,0 +1,66 @@ +#include "game/sim/construction.h" + +#include +#include + +namespace sots::sim { + +ShipDesignRecord& FindOrAppendDesignRecord(ShipRecords& r, int designKey, int hullClass) { + for (ShipDesignRecord& d : r.designs) + if (d.designKey == designKey) return d; + ShipDesignRecord fresh; + fresh.designKey = designKey; + fresh.hullClass = hullClass; + // The original zeroes the remaining three words at the append site; the default member + // initialisers already do that, and they are spelled out here so the append's shape is + // visible next to the read of it. + fresh.built = 0; + fresh.lost = 0; + fresh.inService = 0; + r.designs.push_back(fresh); + return r.designs.back(); +} + +void RecordShipBuilt(ShipRecords& r, int designKey, int hullClass) { + if (hullClass >= 0 && hullClass < kHullClassCount) ++r.built[hullClass]; + ++FindOrAppendDesignRecord(r, designKey, hullClass).built; +} + +SystemConstructionResult RunSystemConstruction(std::vector& queue, int points) { + SystemConstructionResult out; + out.pointsIn = points; + out.ordersBefore = static_cast(queue.size()); + + // The design an order names is lost once the order is unlinked, so it is captured here. + // Order ids are unique within a queue in every save observed; a duplicate would make the + // last one win, which is why the map is built before the pass rather than after it. + std::map designOfOrder; + for (const BuildOrder& o : queue) designOfOrder[o.orderId] = o.designId; + + // Whether any order will absorb the remaining points and stop the pass. Recomputed from + // the queue rather than inferred from the result, so it is reported even when the stop + // happens on the first order. + const BuildQueueResult r = ProcessBuildQueue(queue, points); + + out.pointsLeft = r.pointsLeft; + out.pointsSpent = out.pointsIn - r.pointsLeft; + out.moneyCharged = r.moneyCharged; + out.ordersAfter = static_cast(queue.size()); + out.ordersRemoved = out.ordersBefore - out.ordersAfter; + out.sweepRan = points > 0; + // Points went in, some were spent, and none came back out: the pass stopped inside an + // order rather than running off the end of the queue. + out.advancedPartially = points > 0 && r.pointsLeft == 0 && !queue.empty(); + + out.completed.reserve(r.completedOrderIds.size()); + for (int id : r.completedOrderIds) { + Completion c; + c.orderId = id; + const auto it = designOfOrder.find(id); + c.designId = it == designOfOrder.end() ? 0 : it->second; + out.completed.push_back(c); + } + return out; +} + +} // namespace sots::sim diff --git a/src/game/sim/construction.h b/src/game/sim/construction.h new file mode 100644 index 0000000..e79a3b4 --- /dev/null +++ b/src/game/sim/construction.h @@ -0,0 +1,109 @@ +// game::sim -- ship construction: what a completed build order writes. +// +// The point-consuming half of the pass lives in colony.h as `ProcessBuildQueue`, because +// that is where the colony turn's other point channels live. This file holds the half that +// runs *per completed order*: the player's `ShipRecords`, which is the only thing a +// completion writes that the save can see without also creating the ship. +// +// WHERE THIS RUNS +// --------------- +// `StrategyServer::ProcessTurn` phase 11 -> `ServerSystem::ProcessTurn` -> +// `ServerSystem::ProcessBuildQueue` -> `BuildQueue::ProcessTurn`. A second caller exists +// (the ship-borne queue reached from the ship-action dispatcher, i.e. a construction ship +// building a station), and it reuses the same function. +// +// WHAT A COMPLETION WRITES, in the order the original writes it +// ------------------------------------------------------------- +// 1. the ship is created and attached, and the new ships of the pass are collected into a +// vector that is handed to the fleet-forming step after the loop; +// 2. `ShipRecords.built[hullClass]` is incremented -- indexed by the design's cached hull +// class ordinal, stride 4; +// 3. the per-design record whose key equals the design's object id is found, appended if +// absent, and its own `built` field is incremented; +// 4. a build-completed event is pushed onto the system's event list; +// 5. `points -= conleft`, `conleft = 0`. +// +// Step 2 has EXACTLY ONE writer in the whole executable -- an image-wide scan for the +// indexed increment at that displacement returns one site, inside this function. So a ship +// that reaches the wire with the per-class counter bumped came through this pass and no +// other. (Losses, kills and in-service are three further parallel arrays with the same +// stride; nothing increments them here, and nothing in the save corpus is ever non-zero for +// losses or kills, so they carry no model.) +// +// The record layout is settled by ENUMERATION, not by what this function touches: the wire +// writes `srnc` groups of {srb, srl, srk, sri} followed by `srbd` records of +// {srd, src, srb, srl, sri}, and the class array's base plus four arrays of three ints lands +// exactly on the per-design vector's first word. Three classes, four arrays, then the +// vector. +#pragma once + +#include +#include + +#include "game/sim/colony.h" + +namespace sots::sim { + +// Destroyer / cruiser / dreadnought. The wire's `srnc` is 3 in every save in the corpus. +constexpr int kHullClassCount = 3; + +// One element of the second counted section (`srbd`). `src` is the design's hull class and +// is written once, when the record is appended; a later completion of the same design only +// touches `built`. +struct ShipDesignRecord { + int designKey = 0; // srd -- the design's OBJECT id, not its index + int hullClass = 0; // src + int built = 0; // srb + int lost = 0; // srl + int inService = 0; // sri +}; + +// Game::ShipRecords, held inline in the player. +struct ShipRecords { + int built[kHullClassCount] = {}; // srb + int lost[kHullClassCount] = {}; // srl + int killed[kHullClassCount] = {}; // srk + int inService[kHullClassCount] = {}; // sri + std::vector designs; +}; + +// Linear search for `designKey` over the per-design vector, appending a fresh record when +// there is no hit. The search is a plain forward scan and the append is a push_back, so the +// vector's order is first-seen and is load-bearing for the wire. +// CONFIDENCE: high -- read instruction by instruction, including the append's field order. +ShipDesignRecord& FindOrAppendDesignRecord(ShipRecords& r, int designKey, int hullClass); + +// One completed hull: bump the class counter and the design record's own counter. +// A hull class outside [0, kHullClassCount) leaves the class array alone -- the original +// indexes it unchecked, so an out-of-range class is a corrupt design, not a policy. +// CONFIDENCE: high on both increments; the guard is ours. +void RecordShipBuilt(ShipRecords& r, int designKey, int hullClass); + +// --------------------------------------------------------------------------------------- +// One system's construction pass +// --------------------------------------------------------------------------------------- + +struct Completion { + int orderId = 0; + int designId = 0; +}; + +struct SystemConstructionResult { + std::vector completed; + int pointsIn = 0; + int pointsLeft = 0; // the original's return value + int pointsSpent = 0; // pointsIn - pointsLeft + int moneyCharged = 0; + int ordersBefore = 0; + int ordersAfter = 0; + int ordersRemoved = 0; // completed here, plus any that were already at or below zero + bool advancedPartially = false; // an order absorbed everything and stopped the pass + bool sweepRan = false; // false when points <= 0: the whole body is skipped +}; + +// FIFO consumption with the design id of every completion kept, which the queue pass alone +// does not report. `queue` is modified in place exactly as the original modifies the list. +// CONFIDENCE: high -- see colony.h's ProcessBuildQueue for the rules and their evidence. +SystemConstructionResult RunSystemConstruction(std::vector& queue, int points); + +} // namespace sots::sim diff --git a/tests/game_sim/CMakeLists.txt b/tests/game_sim/CMakeLists.txt index 123a95e..559703c 100644 --- a/tests/game_sim/CMakeLists.txt +++ b/tests/game_sim/CMakeLists.txt @@ -1,5 +1,5 @@ # game/sim tests: four hand-computed suites + a real-save smoke test (skips unless SOTS_SAVES_JSON). -foreach(_t economy research colony movement techgraph visibility) +foreach(_t economy research colony movement techgraph visibility construction) add_executable(game_sim_test_${_t} test_${_t}.cpp) target_link_libraries(game_sim_test_${_t} PRIVATE sots_game_sim) target_include_directories(game_sim_test_${_t} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/tests/game_sim/test_construction.cpp b/tests/game_sim/test_construction.cpp new file mode 100644 index 0000000..2cc29de --- /dev/null +++ b/tests/game_sim/test_construction.cpp @@ -0,0 +1,244 @@ +// Ship construction: the completion bookkeeping, and the two rules of the pass that the +// corpus can and cannot show. +// +// The corpus fixtures at the bottom are NOT invented. They are the build queues of +// `zuul-turn16-noderoute.sav` and the queues the same game carries one turn later in +// `zuul-turn17-rollpending.sav` (frames 16 and 17, a genuine consecutive-turn pair). The +// test does not assume the point totals: it SOLVES for them, and the solve is the +// falsification -- a non-FIFO order, a per-order point budget, or a "skip and continue" +// rule instead of "stop at the first order that cannot finish" each make the solve fail. +#include "game/sim/construction.h" + +#include "check.h" + +using namespace sots::sim; + +// --------------------------------------------------------------------------------------- +// The records a completion writes +// --------------------------------------------------------------------------------------- + +static void test_records() { + ShipRecords r; + RecordShipBuilt(r, 608, 0); + CHECK_EQ(r.built[0], 1); + CHECK_EQ(r.designs.size(), std::size_t{1}); + CHECK_EQ(r.designs[0].designKey, 608); + CHECK_EQ(r.designs[0].hullClass, 0); + CHECK_EQ(r.designs[0].built, 1); + + // A second hull of the same design finds the record rather than appending one. + RecordShipBuilt(r, 608, 0); + CHECK_EQ(r.designs.size(), std::size_t{1}); + CHECK_EQ(r.designs[0].built, 2); + CHECK_EQ(r.built[0], 2); + + // A different design appends, and the vector's order is first-seen. + RecordShipBuilt(r, 576, 0); + RecordShipBuilt(r, 1136, 2); + CHECK_EQ(r.designs.size(), std::size_t{3}); + CHECK_EQ(r.designs[1].designKey, 576); + CHECK_EQ(r.designs[2].designKey, 1136); + CHECK_EQ(r.designs[2].hullClass, 2); + CHECK_EQ(r.built[0], 3); + CHECK_EQ(r.built[2], 1); + // Nothing here touches losses, kills or in-service. + CHECK_EQ(r.lost[0], 0); + CHECK_EQ(r.killed[0], 0); + CHECK_EQ(r.inService[0], 0); + + // An out-of-range hull class leaves the class array alone but still gets its own record; + // the original indexes the array unchecked, so this guard is ours and is stated as such. + ShipRecords g; + RecordShipBuilt(g, 7, 9); + CHECK_EQ(g.built[0], 0); + CHECK_EQ(g.designs.size(), std::size_t{1}); + CHECK_EQ(g.designs[0].built, 1); +} + +// --------------------------------------------------------------------------------------- +// The pass +// --------------------------------------------------------------------------------------- + +static void test_pass_reports_designs() { + // {designId, orderId, con, conleft, money, moneyAvailable} + std::vector q = {{608, 3, 1980, 1753, 0, true}, + {576, 4, 1860, 1860, 0, true}, + {576, 5, 1860, 1860, 0, true}}; + const SystemConstructionResult r = RunSystemConstruction(q, 4182); + CHECK_EQ(r.completed.size(), std::size_t{2}); + CHECK_EQ(r.completed[0].orderId, 3); + CHECK_EQ(r.completed[0].designId, 608); + CHECK_EQ(r.completed[1].orderId, 4); + CHECK_EQ(r.completed[1].designId, 576); + CHECK_EQ(r.pointsSpent, 4182); + CHECK_EQ(r.pointsLeft, 0); + CHECK(r.advancedPartially); + CHECK_EQ(r.ordersRemoved, 2); + CHECK_EQ(q.size(), std::size_t{1}); + CHECK_EQ(q[0].orderId, 5); + CHECK_EQ(q[0].constructionLeft, 1291); +} + +static void test_points_gate_skips_the_sweep() { + // An order already at zero. This state cannot arise in the corpus -- the pass that + // zeroes an order also erases it -- so the rule is a labelled hypothesis about a + // zero-construction-cost design, and the test pins the behaviour, not a measurement. + std::vector q = {{608, 3, 0, 0, 0, true}}; + SystemConstructionResult r = RunSystemConstruction(q, 0); + CHECK_EQ(q.size(), std::size_t{1}); // points <= 0: the whole body is skipped + CHECK_EQ(r.pointsLeft, 0); + CHECK(!r.sweepRan); + + r = RunSystemConstruction(q, -5); + CHECK_EQ(q.size(), std::size_t{1}); + CHECK_EQ(r.pointsLeft, -5); + + r = RunSystemConstruction(q, 1); // one point is enough to run the sweep + CHECK(q.empty()); + CHECK(r.sweepRan); + CHECK_EQ(r.ordersRemoved, 1); +} + +static void test_money_refusal_skips_not_stops() { + std::vector q = {{608, 1, 100, 100, 500, false}, + {576, 2, 100, 100, 500, true}}; + const SystemConstructionResult r = RunSystemConstruction(q, 300); + CHECK_EQ(r.completed.size(), std::size_t{1}); + CHECK_EQ(r.completed[0].orderId, 2); + CHECK_EQ(r.moneyCharged, 500); + // The refused order keeps its points and survives the sweep; the pass did not stop at it. + CHECK_EQ(q.size(), std::size_t{1}); + CHECK_EQ(q[0].orderId, 1); + CHECK_EQ(r.pointsLeft, 200); + CHECK(!r.advancedPartially); +} + +static void test_running_off_the_end() { + std::vector q = {{608, 1, 100, 100, 0, true}}; + const SystemConstructionResult r = RunSystemConstruction(q, 900); + CHECK_EQ(r.pointsLeft, 800); + CHECK(!r.advancedPartially); + CHECK(q.empty()); +} + +// --------------------------------------------------------------------------------------- +// The corpus oracle: zuul-turn16-noderoute.sav -> zuul-turn17-rollpending.sav +// --------------------------------------------------------------------------------------- +// +// System 80 (owner 32, an AI) and system 384 (owner 16) each hold a queue at frame 16 and a +// different queue at frame 17. The AI appended one order (58) during the turn, so its +// before-state is the frame-16 queue with that order pushed on the end -- the only fitted +// element in this fixture, and it is fitted from the frame-17 file's own `con`/`ordID`, not +// from the model. +// +// The test searches every point total in a wide range and asserts that the set of totals +// that reproduce the observed after-state is non-empty and is a contiguous run of ONE value +// per system (the transition is exact, not a band), then checks the completions against the +// per-design `srb` deltas the two files carry. + +struct Fixture { + const char* what; + std::vector before; + std::vector after; + std::vector expectedCompletedDesigns; +}; + +static int solve_points(const Fixture& f, int* solutions) { + int found = -1; + *solutions = 0; + for (int p = 0; p <= 200000; ++p) { + std::vector q = f.before; + const SystemConstructionResult r = RunSystemConstruction(q, p); + if (q.size() != f.after.size()) continue; + bool same = true; + for (std::size_t i = 0; i < q.size(); ++i) + if (q[i].orderId != f.after[i].orderId || + q[i].constructionLeft != f.after[i].constructionLeft) + same = false; + if (!same) continue; + std::vector designs; + for (const Completion& c : r.completed) designs.push_back(c.designId); + if (designs != f.expectedCompletedDesigns) continue; + ++*solutions; + if (found < 0) found = p; + } + return found; +} + +static void test_corpus_pair() { + // System 384, owner 16. Frame 16: three orders. Frame 17: one, advanced by 569. + Fixture human{"sys 384 / player 16", + {{608, 3, 1980, 1753, 0, true}, + {576, 4, 1860, 1860, 0, true}, + {576, 5, 1860, 1860, 0, true}}, + {{576, 5, 1860, 1291, 0, true}}, + {608, 576}}; + // System 80, owner 32. Frame 16: four orders of design 114. Frame 17: order 58 only, + // which the AI appended during the turn (con 6974) and which was advanced by 3156. + Fixture ai{"sys 80 / player 32", + {{114, 54, 1889, 959, 0, true}, + {114, 55, 1889, 1889, 0, true}, + {114, 56, 1889, 1889, 0, true}, + {114, 57, 1889, 1889, 0, true}, + {816, 58, 6974, 6974, 0, true}}, + {{816, 58, 6974, 3818, 0, true}}, + {114, 114, 114, 114}}; + + for (const Fixture* f : {&human, &ai}) { + int solutions = 0; + const int p = solve_points(*f, &solutions); + simtest::report(p >= 0, "a point total reproduces the observed transition", __FILE__, + __LINE__, std::string(f->what)); + simtest::report(solutions == 1, "the point total is unique", __FILE__, __LINE__, + std::string(f->what) + " solutions=" + std::to_string(solutions)); + } + // The two totals the solve finds, stated so a change to the model is visible as a number. + int n = 0; + CHECK_EQ(solve_points(human, &n), 4182); + CHECK_EQ(solve_points(ai, &n), 9782); + + // The per-design `srb` deltas the two saves carry, reproduced by feeding the solved + // totals through the records model. Player 16 (index 0 on the wire): design 608 goes + // 2 -> 3 and a record for 576 appears with 1. Player 32 (index 1): design 114 goes + // 18 -> 22, and the class-0 counters go 2 -> 4 and 53 -> 57. + { + ShipRecords r; + r.built[0] = 2; + r.designs.push_back({656, 0, 0, 0, 2}); + r.designs.push_back({608, 0, 2, 0, 2}); + std::vector q = human.before; + for (const Completion& c : RunSystemConstruction(q, 4182).completed) + RecordShipBuilt(r, c.designId, 0); + CHECK_EQ(r.built[0], 4); + CHECK_EQ(r.designs.size(), std::size_t{3}); + CHECK_EQ(r.designs[1].built, 3); // 608 + CHECK_EQ(r.designs[2].designKey, 576); // appended, in first-seen order + CHECK_EQ(r.designs[2].built, 1); + } + { + ShipRecords r; + r.built[0] = 53; + r.designs.push_back({816, 0, 6, 0, 8}); + r.designs.push_back({18, 0, 23, 0, 23}); + r.designs.push_back({34, 0, 4, 0, 4}); + r.designs.push_back({114, 0, 18, 0, 18}); + r.designs.push_back({130, 0, 2, 0, 2}); + std::vector q = ai.before; + for (const Completion& c : RunSystemConstruction(q, 9782).completed) + RecordShipBuilt(r, c.designId, 0); + CHECK_EQ(r.built[0], 57); + CHECK_EQ(r.designs.size(), std::size_t{5}); // nothing appended + CHECK_EQ(r.designs[3].built, 22); // 114 + CHECK_EQ(r.designs[0].built, 6); // 816 did not complete + } +} + +int main() { + test_records(); + test_pass_reports_designs(); + test_points_gate_skips_the_sweep(); + test_money_refusal_skips_not_stops(); + test_running_off_the_end(); + test_corpus_pair(); + return simtest::finish("game_sim_construction"); +} From db3909bcd49ab155625f1f17ae9d9c9223a834cf Mon Sep 17 00:00:00 2001 From: lane-b6 Date: Tue, 8 Sep 2026 14:54:21 -0400 Subject: [PATCH 3/3] docs: B6 measured -- prediction held, closed 0 regressed 0 on both reference pairs 209 -> 158 (closed 51, regressed 0) and 108 -> 87 (closed 21, regressed 0), identical to the pre-lane baseline, measured on CT111. Falsification hypotheses 1 and 2 refuted by measurement (three empty BQ frames per reference save, every hbq false, the one command block empty); hypothesis 3 refuted by the corpus oracle; hypothesis 4 stands as a labelled hypothesis. construction.h also carries the ship/fleet birth shape as read but NOT implemented, with the reason stated: the newborn hull copies four cached stat words the engine does not yet compute. --- docs/B6-ship-construction.md | 97 ++++++++++++++++++++++++++++++++++-- src/game/sim/construction.h | 37 ++++++++++++++ 2 files changed, 131 insertions(+), 3 deletions(-) diff --git a/docs/B6-ship-construction.md b/docs/B6-ship-construction.md index 28f1932..e672d0d 100644 --- a/docs/B6-ship-construction.md +++ b/docs/B6-ship-construction.md @@ -39,14 +39,14 @@ What this lane does deliver, and what it is worth: * the build-queue advance read byte for byte from `Game::BuildQueue::ProcessTurn` (`0x00890d50`, real end `0x00891240` — Ghidra says 1230 bytes, the body runs 1264), which **independently confirms** lane B4's `sim::ProcessBuildQueue` and finds **one divergence** - in it (§4); + in it (§4, "What changed in the engine"); * the `ShipRecords` update the pass performs, whose per-class counter has **exactly one writer in the whole image**; * the pass wired into the standalone's S11 as its own reported sub-pass; * a real oracle for the pass that the campaign already owns and had not noticed: `zuul-turn16-noderoute.sav` → `zuul-turn17-rollpending.sav` is a genuine consecutive-turn pair (frames 16 and 17) in which **six orders complete and one is partially advanced** - across two systems (§5). + across two systems (§3, "The corpus oracle"). ## 2. How this prediction could be wrong, and the symptom of each way @@ -65,7 +65,98 @@ What this lane does deliver, and what it is worth: test: a per-order point budget, a non-FIFO order, or a "skip and continue" rule instead of "stop" each fail it. 4. **The `points <= 0` gate on the removal sweep is wrong.** Unobservable in this corpus - (§4) and therefore carried as a labelled hypothesis, not a result. + and therefore carried as a labelled hypothesis, not a result. 5. **`shpt` is not the fleet walk.** If the census leaf were fed by `ShipRecs` rather than by walking fleets, the fix would be different. Lane E2 already falsifies this: the fleet walk reproduces 480/480 archived census leaves. + +--- + +## 3. Measured + +Built on CT111 (`/srv/re-lab/build/sots-engine-b6`), report run there with +`tools/standalone_report.py --binary …`. Host gates run separately, never `&&`-chained. + +| pair | baseline | after | **closed** | **regressed** | +|---|---:|---:|---:|---:| +| `turn1-state.sav` → `turn2-state.sav` | 209 | 158 | **51** | **0** | +| `turn2-state.sav` → `turn3-state.sav` | 108 | 87 | **21** | **0** | + +Identical to the pre-lane baseline, which is the prediction holding: **this lane closed 0 and +regressed 0.** `regressedPaths` is empty on both pairs. + +The prediction's own numbers, checked one by one: + +* three `BQ` frames per reference save (systems owned by players 16, 32 and 576), **0 orders in + all three**, on both sides of both pairs — the standalone now prints this on its own line; +* every `hbq` in `turn1/2/3-state.sav` is `false` (15, 16 and 17 of them), so **0 ship-borne + orders**; +* the four `CDT` ids are `Player.00000016.TurnCommands_v5` plus three `AIAgent` blobs, and the + one command block is the 36-node empty one. + +Falsification hypotheses 1 and 2 are therefore both refuted by measurement rather than by +argument, and hypothesis 3 is refuted by the corpus oracle below. Hypothesis 4 stands as a +labelled hypothesis; hypothesis 5 was already refuted by lane E2. + +### What the pass does on the saves that are not the reference pair + +`sots_turn --phases` now prints a `build queue:` line under S11 on every save: + +| save | queues | pending orders | demand (points) | +|---|---:|---:|---:| +| `turn1/2/3-state.sav` | 3 | 0 | 0 | +| `human-turn2-orders.sav` | 20 | 0 | 0 | +| `zuul-turn5-species5.sav` | 4 | 4 | 7,120 | +| `zuul-turn16-noderoute.sav` | 4 | 7 | 12,099 | +| `zuul-turn17-orders2.sav` | 4 | 2 | 5,109 | +| `zuul-turn23-fleet23.sav` | 4 | 1 | 370 | + +Five of eleven saves exercise the pass the moment points exist. Nothing is committed: an order +that advanced with no ship behind it leaves a state the game never produces. + +### The corpus oracle, which the campaign already owned + +`zuul-turn16-noderoute.sav` (frame 16) → `zuul-turn17-rollpending.sav` (frame 17) is a real +consecutive-turn pair. Two systems, six completions, one partial advance: + +| system | owner | before (`desID`:`conleft`) | after | solved points | +|---|---|---|---|---:| +| 384 | 16 | 608:1753, 576:1860, 576:1860 | 576:**1291** | **4182** | +| 80 | 32 | 114:959, 114:1889 ×3 (+816:6974 appended in-turn) | 816:**3818** | **9782** | + +`test_construction.cpp` does not assume those totals — it **searches** 0..200,000 and asserts a +solution exists and is **unique**. A per-order budget, a non-FIFO drain, or skip-instead-of-stop +each make the search come back empty. The completions then reproduce the `ShipRecs` deltas the +same two files carry (class-0 `srb` 2→4 and 53→57; design 608 2→3; a new record for 576; +design 114 18→22; design 816 unchanged because its order did not finish). + +## 4. What changed in the engine + +* **`game/sim/construction.{h,cpp}`** — `ShipRecords` and the completion bookkeeping. The + per-class `built` counter's indexed increment has **exactly one writer in the whole image** + (an image-wide byte scan for the form at that displacement returns one site, inside this + pass), so a hull on the wire with that counter bumped came through the build queue and + nothing else — the encounter spawner that also creates ships touches no `ShipRecords`. + `RunSystemConstruction` wraps the point pass and keeps each completion's design id. +* **`game/sim/colony.{h,cpp}`** — one corrected rule: with `points <= 0` the original skips the + **removal sweep** as well, because the entry test branches to the epilogue. Carried as a + labelled hypothesis; no corpus save can reach the state that shows it, and the workload that + would is named in the header. +* **`app/construction_phase.{h,cpp}`** — S11's build-queue sub-pass, reported on its own line. +* **`app/phase_catalog.cpp`** — S11's blurb no longer calls the build queue the input boundary + for the census leaf. + +## 5. What this lane did NOT do, said plainly + +* **It did not close `shpt[0]`, and no build-queue work can.** The blocker moves to `game/ai`. +* **It did not model the ship or the fleet at birth in code.** The chain is read end to end and + written up in the RE repo (`findings/subsystems/ship-construction.md` §4): `StarShip` is 0xb0 + bytes, its id comes from the object-id allocator and lands in `+0x04`, `FltID` is born NULL; + the hull joins its system's **cached home fleet** (`ServerSystem+0x238`) and a fleet is created + only when that slot is empty; every fleet born that way gets `FtFlg |= 0x400` — which + **corrects lane B5**, that bit is not a retreat marker. It is not implemented because the + newborn hull's stats are copied from design words the engine does not yet compute, and because + no save exercises the path with an oracle behind it. +* **The money charge has never fired.** No design in the corpus costs money, so the slot-9 + refusal path is read from the bytes and never observed. +* **`srl`, `srk`, `sri` carry no model.** Losses and kills are zero in all eleven saves. diff --git a/src/game/sim/construction.h b/src/game/sim/construction.h index e79a3b4..0f44201 100644 --- a/src/game/sim/construction.h +++ b/src/game/sim/construction.h @@ -106,4 +106,41 @@ struct SystemConstructionResult { // CONFIDENCE: high -- see colony.h's ProcessBuildQueue for the rules and their evidence. SystemConstructionResult RunSystemConstruction(std::vector& queue, int points); +// --------------------------------------------------------------------------------------- +// The hull and the fleet at birth -- READ, NOT YET IMPLEMENTED +// --------------------------------------------------------------------------------------- +// +// This is written down here rather than coded because the newborn hull copies four cached +// stat words out of its design, and those words are recomputed by the design's own stats +// pass, which this engine models only far enough to get a hull class. Coding it now would be +// writing fields we cannot compute. The whole chain is read instruction by instruction in +// the RE repo; the shape, for the lane that gets the design stats: +// +// THE SHIP. 176 bytes. The object id comes from the network-node id allocator -- +// `(counter << 4) | (node & 0xF)`, per-node counters, PRE-incremented, never issuing 0 -- +// and is written into the object by the id-map insert, not by the constructor. At birth: +// the owner is the queue's owner and the design is the order's design; range, health, +// construction capacity, refuel capacity and repair capacity are copied from the design's +// cached stat words; the plague and one other word are -1 and everything else is zero. +// Two fields matter to a reimplementation: +// * the FLEET LINK IS NULL at birth and is set by the fleet-join step, not here; +// * the TURN-BUILT stamp is the sim's frame counter, i.e. the number of the turn being +// produced -- MEASURED: the six hulls the zuul frame-16 -> frame-17 pair adds all +// carry the NEW turn number, which is independent evidence that the frame counter is +// incremented before the spine runs. +// +// THE FLEET. 288 bytes. A system caches ONE home fleet; every hull built there joins it, +// and a fleet is created only when that cache is empty. So a second turn of building at +// the same system creates no fleet. A created fleet takes an id from the same allocator, +// is born at the system's position, and always carries flag 0x400 -- which is NOT a +// retreat marker (that reading is corrected) and is NOT the design flag of the same +// numeral that game/design/hull.h warns about. The build path adds 0x20 on top. There is +// NO scalar ship count: the wire's count is the length of the fleet's ship vector. +// A fleet created with no name override asks the player's name generator, which is what +// bumps the generator's counter -- one bump per generated name. +// +// Ships are also created OUTSIDE this pass, by the trade manager's encounter spawner. That +// path touches no ShipRecords at all, which is what makes the per-class built counter a +// clean discriminator between player-built hulls and spawned ones. + } // namespace sots::sim