sots-engine/src/app/phase_catalog.cpp
alex d3ee45364b game/sim + app: ComputeOutput on the turn path, and P01/P02 committed
`ComputeBudget` takes a system's money from two different functions. Projected mode
calls `ComputeMaxIncome`, which lane E1 closed 25/25 against the BnkEl oracle. The
TURN calls `ComputeOutput` with the system's own rate sliders, where the build queue,
the ship-repair pass and the infrastructure -> terraform -> money cascade are all
live and E1's proof that the cascades are zero does not apply.

Read from the instruction stream, both ranges disassembled to the next function start:

* `sim::ComputeSystemOutput` -- the channel algebra of `ComputeOutputFromRates`, with
  every rounding site (round-half-even per channel, truncating for the construction
  and money slots) and the association of every x87 sum as the original has them.
* `sim::IdealSuitability` -- the owner's own field, the server's species baseline for
  an independent colony, and the per-system `dsu` override.
* `sim::RepairShipsInOrbit` -- the round robin, which is provably equivalent to
  `points - min(points, demand)`: the per-pass share is at least 1, so the only early
  exit needs every remaining cost to be zero.
* two corrections to `ConstructionPoints` and `SplitLeftover`: the station bonus is
  ignored unless strictly positive and its association is `k x (b x cons) + cons`, and
  the leftover weights sum as `wi + (wf + wt)`.

The load-bearing fact: the leftover construction points come back to the TRADE
channel, so a colony with an empty build queue earns the same money whichever way its
sliders point. The engine now runs both paths on every load and reports the
difference; on the 11-save corpus every delta decomposes to the unit into the build
queue's points priced through the money chain.

P01/P02 move from blocked to partial and are committed:

    turn1-state -> turn2-state    209 -> 157   closed 52  regressed 0   (was 51 / 0)
    turn2-state -> turn3-state    108 ->  86   closed 22  regressed 0   (was 21 / 0)

One leaf per pair, and it is the easy one: the independent colony, whose population
does not grow and whose orders the turn does not change. The human's savings are
still short by the civilian growth `S11` does not commit, and the AI's by its own
orders. The ship-repair demand is taken as 0 because `Ship::RepairCost` is unread.

sots-re: findings/subsystems/output-turn-path.md, ghidra/addresses.d/lane-c3.json
2026-09-08 14:50:18 -04:00

325 lines
20 KiB
C++

#include "app/phase_catalog.h"
namespace sots::app {
const char* DriverName(Driver d) {
switch (d) {
case Driver::Host: return "host turn sequence (outside the two turn drivers)";
case Driver::Strategic: return "StrategyServer::ProcessTurn";
case Driver::Player: return "ServerPlayer::ProcessTurn";
case Driver::Tail: return "StrategyServer::OnAllCombatDone_Tail";
}
return "?";
}
const char* StatusName(PhaseStatus s) {
switch (s) {
case PhaseStatus::Verified: return "verified";
case PhaseStatus::Implemented: return "implemented";
case PhaseStatus::Partial: return "partial";
case PhaseStatus::Blocked: return "blocked";
case PhaseStatus::Stub: return "stub";
}
return "?";
}
char StatusGlyph(PhaseStatus s) {
switch (s) {
case PhaseStatus::Verified: return 'V';
case PhaseStatus::Implemented: return 'I';
case PhaseStatus::Partial: return 'P';
case PhaseStatus::Blocked: return 'B';
case PhaseStatus::Stub: return '.';
}
return '?';
}
// ---------------------------------------------------------------------------------------
// StrategyServer::ProcessTurn -- 32 phases, 0..31
// ---------------------------------------------------------------------------------------
namespace {
// The host steps around the drivers. Two turn counters live here, not in either driver.
constexpr PhaseDesc kHost[] = {
{Driver::Host, 0, "H00", "BeginProcessTurn", PhaseStatus::Implemented,
"advances the frame counter, which is the turn number the whole game displays; runs "
"before the spine and is where the 'Begin processing turn N' line comes from"},
{Driver::Host, 1, "H01", "SaveWriterInvariants", PhaseStatus::Implemented,
"the summary's turn number is the simulation's frame counter -- an identity that holds "
"across every save in the corpus and belongs to the writer, not to a turn phase"},
};
constexpr PhaseDesc kStrategic[] = {
{Driver::Strategic, 0, "S00", "SnapshotPreviousTurn", PhaseStatus::Partial,
"bumps the modification counter; the previous-turn shadow-word snapshot is not modelled "
"(the shadow words are not all identified on the wire)"},
{Driver::Strategic, 1, "S01", "SystemPrePassMoraleAndAbandon", PhaseStatus::Stub,
"per-system morale event + the abandon/chaos check below the minimum chaos population"},
{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::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,
"the ship-action dispatcher over every action type but type 2 (colonise, build, "
"terraform, mine, scrap)"},
{Driver::Strategic, 7, "S07", "NodeSpaceTravel", PhaseStatus::Stub, ""},
{Driver::Strategic, 8, "S08", "FleetMovement", PhaseStatus::Stub,
"game::sim movement primitives exist and are mechanism-verified, but the fleet/waypoint "
"adapter over the save's flight plans is not written"},
{Driver::Strategic, 9, "S09", "ShipActionsType2", PhaseStatus::Stub,
"the action type that needs the fleet to have arrived first"},
{Driver::Strategic, 10, "S10", "ShipUpkeep", PhaseStatus::Stub,
"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"},
{Driver::Strategic, 12, "S12", "TradeSliderFinalisation", PhaseStatus::Stub,
"re-normalises the per-system output rates"},
{Driver::Strategic, 13, "S13", "PlayerTurn", PhaseStatus::Partial,
"runs the 12-phase player driver once per player, in save order"},
{Driver::Strategic, 14, "S14", "ProcessMissions", PhaseStatus::Stub, ""},
{Driver::Strategic, 15, "S15", "ProcessStations", PhaseStatus::Stub, ""},
{Driver::Strategic, 16, "S16", "ProcessDefenceSats", PhaseStatus::Stub, ""},
{Driver::Strategic, 17, "S17", "ShipStatCacheRefresh", PhaseStatus::Stub,
"re-syncs cached per-ship stat words from the design record"},
{Driver::Strategic, 18, "S18", "ShipActionsForceValidate", PhaseStatus::Stub,
"the dispatcher a third time over all action types with the force flag: validate and "
"cancel whatever is left"},
{Driver::Strategic, 19, "S19", "ProcessAid", PhaseStatus::Stub,
"writes savings and research points of OTHER players; an input to the budget"},
{Driver::Strategic, 20, "S20", "ProcessSpecialProjectsServer", PhaseStatus::Stub, ""},
{Driver::Strategic, 21, "S21", "ProcessSurrenders", PhaseStatus::Stub, ""},
{Driver::Strategic, 22, "S22", "AdvanceAIRebellion", PhaseStatus::Stub,
"steps an in-progress AI rebellion; the rebellion object is opaque on the wire"},
{Driver::Strategic, 23, "S23", "ScriptHookTurnStart", PhaseStatus::Stub,
"scripted-scenario callback pair; dead in a normal game but not proven so"},
{Driver::Strategic, 24, "S24", "SensorUpdate", PhaseStatus::Stub,
"packs 2-bit per-player visibility into every system and fleet"},
{Driver::Strategic, 25, "S25", "ScriptHookPostSensor", PhaseStatus::Stub, ""},
{Driver::Strategic, 26, "S26", "RefreshPlayerViews", PhaseStatus::Stub, ""},
{Driver::Strategic, 27, "S27", "RecomputePlayerReports", PhaseStatus::Stub, ""},
{Driver::Strategic, 28, "S28", "GrantMetSpeciesTechs", PhaseStatus::Stub,
"the 'you have met this race, its racial tech appears in your tree' rule -- small, pure, "
"draw-free, and the cheapest unimplemented phase in this table"},
{Driver::Strategic, 29, "S29", "SystemObservedStamp", PhaseStatus::Implemented,
"the system's own last-observed turn, written wherever some player can currently see "
"the system and left alone everywhere else. Whole function modelled; the gate is the "
"active-presence mask, which falls when the last fleet leaves"},
{Driver::Strategic, 30, "S30", "BuildTeamPartition", PhaseStatus::Stub, ""},
{Driver::Strategic, 31, "S31", "EncounterDetectionAndStatusRestore", PhaseStatus::Partial,
"trade-raid generation runs first here and IS modelled: two chances per player, one word "
"each, neither site inside a back edge, so the count is a bound -- it is the turn's "
"dominant generator cost and it is committed under --commit-rng. Detection proper is not "
"modelled and spends two further words. The player-status restore that follows it IS "
"modelled -- it writes 1 -- but the value the file carries is 4, so a further writer "
"between this phase and the autosave is unaccounted. Committing the 1 turned two agreeing "
"leaves into disagreeing ones on the turn2->turn3 pair, so it is evaluated and reported"},
};
// ---------------------------------------------------------------------------------------
// ServerPlayer::ProcessTurn -- 12 phases, 1..12
// ---------------------------------------------------------------------------------------
constexpr PhaseDesc kPlayer[] = {
{Driver::Player, 1, "P01", "ComputeBudget", PhaseStatus::Partial,
"the formula is verified (0 divergences over 4,284 live calls) and the per-system money "
"input is now modelled on the TURN path -- ComputeOutput with the system's own rate "
"sliders, so the build queue, the ship-repair pass and the infrastructure -> terraform "
"-> money cascade are all live, none of which is the max-income form T31 sums. What is "
"still missing is upstream, not here: S11's civilian growth is not committed, so a "
"colony that grew this turn is priced from its pre-growth population, and the repair "
"demand of damaged ships in orbit is taken as 0. The phase self-checks every run by "
"running the same colonies through the projected path, which the save's own BnkEl "
"states"},
{Driver::Player, 2, "P02", "ApplyNetToSavings", PhaseStatus::Partial,
"saturating add of the budget net into savings, committed. Exact for a player whose "
"colonies did not grow and whose own orders the turn does not change (the independent "
"colony, on both reference pairs); short by the growth for the human, and wrong for an "
"AI whose research rate and target are set by its own orders during the turn (Rung B)"},
{Driver::Player, 3, "P03", "RecordBudgetDerivedFields", PhaseStatus::Blocked,
"trade income, savings-given-away and research-points-given-away land on the turn record "
"and on two player words that are not identified on the wire"},
{Driver::Player, 4, "P04", "ProcessSpecialProjectsSpend", PhaseStatus::Stub,
"special-project spend; the project bodies are opaque on the wire"},
{Driver::Player, 5, "P05", "ProcessResearch", PhaseStatus::Blocked,
"the research slice is verified end to end (35 live calls, 0 divergences) and P01 now "
"supplies the allocation, but the blocker has MOVED rather than cleared: the only "
"corpus player that reaches this phase with a research target is the AI, and its "
"research rate and target are set by its own orders during the same turn, so the "
"allocation fed in would be wrong. Evaluated and reported, not committed, until AI "
"order generation exists"},
{Driver::Player, 6, "P06", "ResearchRefund", PhaseStatus::Blocked,
"unspent research points converted back to money at the turn's own rate; needs P05, "
"which is now blocked on the AI's orders rather than on the budget"},
{Driver::Player, 7, "P07", "ClearTimedResearchAccumulators", PhaseStatus::Partial,
"zeroes the three timed-research accumulators that are on the wire; two further words the "
"phase also zeroes are not identified"},
{Driver::Player, 8, "P08", "DecayRebellionOutputModifier", PhaseStatus::Implemented,
"rebel AI only: the output modifier walks down by 0.04f per turn, clamped to [1, 2]"},
{Driver::Player, 9, "P09", "AccumulateTimedResearchBonuses", PhaseStatus::Implemented,
"the timed research-bonus vector, iterated from the LAST element down to index 0 -- the "
"descending order is load-bearing because float addition is not associative"},
{Driver::Player, 10, "P10", "ConsumeResearchRollPending", PhaseStatus::Partial,
"the flag/threshold test and the flag clear are implemented and committed; the draw it "
"fires is counted into the RNG ledger but the generator state is only written back under "
"--commit-rng, because the turn's other draws are not yet attributed"},
{Driver::Player, 11, "P11", "PostNoResearchEvent", PhaseStatus::Blocked,
"the condition is implemented and reported; posting needs the localised event text table "
"and the event-id sequence, neither of which the standalone has"},
{Driver::Player, 12, "P12", "PruneRaidTargets", PhaseStatus::Stub,
"20-turn ageing of the raid-target list; the records are opaque on the wire"},
};
// ---------------------------------------------------------------------------------------
// StrategyServer::OnAllCombatDone_Tail -- 37 phases, 0..36.
//
// This is the driver the autosave is written from: everything below runs AFTER combat and
// BEFORE the file hits disk. Nothing here is implemented. It is listed in full because a
// reimplementation that reproduces the spine exactly and stops will still diverge -- two of
// these phases draw from the same generator.
// ---------------------------------------------------------------------------------------
constexpr PhaseDesc kTail[] = {
{Driver::Tail, 0, "T00", "IncrementModCount", PhaseStatus::Implemented,
"the same modification counter the spine's phase 0 bumps"},
{Driver::Tail, 1, "T01", "ValidateEncounterResultArity", PhaseStatus::Stub, ""},
{Driver::Tail, 2, "T02", "ResolveFirstContact", PhaseStatus::Stub, ""},
{Driver::Tail, 3, "T03", "AnnounceEncounterSightings", PhaseStatus::Stub, ""},
{Driver::Tail, 4, "T04", "TallyBattlesFought", PhaseStatus::Stub, ""},
{Driver::Tail, 5, "T05", "UpdateDiplomacyStatsFromCombat", PhaseStatus::Stub, ""},
{Driver::Tail, 6, "T06", "ApplyEncounterResults", PhaseStatus::Stub,
"DRAWS RNG -- node-cannon and salvage paths; the word count is combat-dependent"},
{Driver::Tail, 7, "T07", "ClearEncounters", PhaseStatus::Stub, ""},
{Driver::Tail, 8, "T08", "ScriptHookCombatDone", PhaseStatus::Stub, ""},
{Driver::Tail, 9, "T09", "AdvanceAIRebellionPostCombat", PhaseStatus::Stub, ""},
{Driver::Tail, 10, "T10", "NodeSpaceTravelSecondPass", PhaseStatus::Stub,
"node-space travel runs a SECOND time this turn"},
{Driver::Tail, 11, "T11", "DecayNodeLines", PhaseStatus::Stub,
"DRAWS RNG -- exactly one unit draw per expired node line per turn"},
{Driver::Tail, 12, "T12", "DrainColonyLossQueue", PhaseStatus::Stub, ""},
{Driver::Tail, 13, "T13", "UpdateTreasuryMorale", PhaseStatus::Stub, ""},
{Driver::Tail, 14, "T14", "UpdateForeignFleetMorale", PhaseStatus::Stub, ""},
{Driver::Tail, 15, "T15", "ProcessBankruptcy", PhaseStatus::Stub,
"the decision function is modelled in game::sim; the per-player state it reads is not "
"assembled here"},
{Driver::Tail, 16, "T16", "ResolveArrivedColonizers", PhaseStatus::Stub, ""},
{Driver::Tail, 17, "T17", "RebuildPlayerViewTree", PhaseStatus::Partial,
"the per-(system, player) observation record IS modelled and committed -- who saw "
"the system, on what turn, and what encounter was there. The colony-numbers view "
"the same phase rebuilds beside it is NOT: its list is empty on both reference "
"pairs, so nothing here has evidence to build it against"},
{Driver::Tail, 18, "T18", "PostFleetWarnings", PhaseStatus::Stub, ""},
{Driver::Tail, 19, "T19", "DrainInfraTerraformQueue", PhaseStatus::Stub, ""},
{Driver::Tail, 20, "T20", "ScriptHooksTurnEnd", PhaseStatus::Stub, ""},
{Driver::Tail, 21, "T21", "UpdateSurveyAndSystemStats", PhaseStatus::Partial,
"the explored sweep IS modelled and committed: every player who can currently see a "
"system has now surveyed it. The event this owes per newly-surveyed pair is not "
"posted, and the derived per-system defence figure the same phase computes is not "
"modelled"},
{Driver::Tail, 22, "T22", "TradeSliderFinalisationSecondPass", PhaseStatus::Stub, ""},
{Driver::Tail, 23, "T23", "TradeManagerEndOfTurnHooks", PhaseStatus::Stub,
"eight vtable calls, wholly unidentified"},
{Driver::Tail, 24, "T24", "RecomputeMaintenanceAndResearchBonus", PhaseStatus::Stub,
"recomputes per-player ship maintenance and research bonus and rebuilds the ship records"},
{Driver::Tail, 25, "T25", "SensorUpdateSecondPass", PhaseStatus::Stub, ""},
{Driver::Tail, 26, "T26", "ScriptHookPostSensorTail", PhaseStatus::Stub, ""},
{Driver::Tail, 27, "T27", "RefreshPlayerViewsSecondPass", PhaseStatus::Stub, ""},
{Driver::Tail, 28, "T28", "UpdateNodeLineSightingMasks", PhaseStatus::Stub, ""},
{Driver::Tail, 29, "T29", "AbortInvisibleInterceptOrders", PhaseStatus::Stub, ""},
{Driver::Tail, 30, "T30", "RebuildCommunicationMasks", PhaseStatus::Stub, ""},
{Driver::Tail, 31, "T31", "UpdateBankruptcyLimits", PhaseStatus::Blocked,
"the whole chain is now modelled: sum over owned, non-abandoned systems of "
"max(ComputeMaxIncome, 0), and the phase self-checks it every run against the BnkEl the "
"input save already carries -- 8 of 8 players on turn1-state with the AI flag supplied. "
"Two things keep it blocked and neither is the formula. First, `ServerPlayer+0xf9` (is "
"this player AI?) is a game-setup input the save does not carry, and it selects a "
"difficulty column worth x1.1 on an AI empire; --ai-player N supplies it. Second, BnkPr "
"needs BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data files, so it is offered only "
"with a tuning table loaded. Committing it closes NOTHING on the reference pair: the "
"limits move between turn1 and turn2 because the CIVILIAN population grows, and that "
"growth is itself not committed, so our value equals the input save's. Measured with "
"--commit-blocked=T31 --ai-player 1: 0 closed, 0 regressed"},
{Driver::Tail, 32, "T32", "PostIncomingFleetWarnings", PhaseStatus::Stub, ""},
{Driver::Tail, 33, "T33", "ShipManagerEndOfTurnHooks", PhaseStatus::Stub, ""},
{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. Thirteen of "
"its fields are modelled and self-checked every run against the record the input save "
"already carries for its own turn: seven from the wire -- turn, colony count, savings, "
"the savings delta, the completed-tech count, the summed population and the alliance mask "
"phase S04 rebuilt -- and, when a data root is given, the six per-hull-class ship counts, "
"which need each design's hull size and defence-platform flag from the section catalog "
"because neither is on the wire. Only 32 of the 480 census leaves the corpus archives are "
"nonzero, so cls1 (cruisers) and cls2's platform count are UNEXERCISED, not verified. "
"Still not committed, and now blocked on exactly two named things, neither of them here: "
"savings and the income derived from it come from P01/P02, which are blocked on the "
"per-system money output; and a ship the turn BUILDS never enters our fleet list, so the "
"census carries the pre-construction count. Measured with --commit-blocked=T36 on "
"turn1->turn2: 29 leaves closed, 7 regressed -- sav x3, inc x3 and one shpt[0] short by "
"exactly the one destroyer that turn completes. The archived record is one struct on the "
"wire, so those words cannot be omitted while the rest is written: committing is "
"all-or-nothing at the record"},
};
PhaseTally Tally(const PhaseDesc* p, std::size_t n) {
PhaseTally t;
t.total = static_cast<int>(n);
for (std::size_t i = 0; i < n; ++i) {
switch (p[i].status) {
case PhaseStatus::Verified: ++t.verified; break;
case PhaseStatus::Implemented: ++t.implemented; break;
case PhaseStatus::Partial: ++t.partial; break;
case PhaseStatus::Blocked: ++t.blocked; break;
case PhaseStatus::Stub: ++t.stub; break;
}
}
return t;
}
} // namespace
const PhaseDesc* HostPhases(std::size_t& count) {
count = sizeof(kHost) / sizeof(kHost[0]);
return kHost;
}
const PhaseDesc* StrategicPhases(std::size_t& count) {
count = sizeof(kStrategic) / sizeof(kStrategic[0]);
return kStrategic;
}
const PhaseDesc* PlayerPhases(std::size_t& count) {
count = sizeof(kPlayer) / sizeof(kPlayer[0]);
return kPlayer;
}
const PhaseDesc* TailPhases(std::size_t& count) {
count = sizeof(kTail) / sizeof(kTail[0]);
return kTail;
}
PhaseTally TallySpine() {
std::size_t ns = 0, np = 0;
const PhaseDesc* s = StrategicPhases(ns);
const PhaseDesc* p = PlayerPhases(np);
PhaseTally a = Tally(s, ns), b = Tally(p, np);
PhaseTally t;
t.total = a.total + b.total;
t.verified = a.verified + b.verified;
t.implemented = a.implemented + b.implemented;
t.partial = a.partial + b.partial;
t.blocked = a.blocked + b.blocked;
t.stub = a.stub + b.stub;
return t;
}
PhaseTally TallyTail() {
std::size_t n = 0;
const PhaseDesc* p = TailPhases(n);
return Tally(p, n);
}
} // namespace sots::app