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
This commit is contained in:
alex 2026-09-08 14:50:18 -04:00
parent 0f1c007f00
commit d3ee45364b
5 changed files with 635 additions and 39 deletions

View file

@ -128,26 +128,36 @@ constexpr PhaseDesc kStrategic[] = {
// ServerPlayer::ProcessTurn -- 12 phases, 1..12
// ---------------------------------------------------------------------------------------
constexpr PhaseDesc kPlayer[] = {
{Driver::Player, 1, "P01", "ComputeBudget", PhaseStatus::Blocked,
"the formula is verified (0 divergences over 4,284 live calls) but one input is not "
"modelled: the money output of each owned system. NOT the same function T31 sums -- the "
"turn path takes ComputeOutput with the system's OWN rate sliders, so its money channel "
"carries the repair pass (which is not side-effect free) and the unspent-industry and "
"unspent-terraforming cascades, none of which are zero once the other channels are "
"funded. Only ComputeBudget's PROJECTED mode uses the max-income form that is now "
"modelled. Evaluated and reported, not committed"},
{Driver::Player, 2, "P02", "ApplyNetToSavings", PhaseStatus::Blocked,
"saturating add of the budget net into savings; blocked behind P01's missing input"},
{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) but its "
"allocation comes from P01's budget, so it cannot be driven yet"},
"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 P01 and P05"},
"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"},

View file

@ -96,16 +96,37 @@ struct PlayerPhaseTotals {
int fired[13] = {}; // how many players the phase actually did something for
};
// What the caller has to hand the player driver for P01: the per-system money of every
// owned, non-abandoned system, on the TURN path, and whether the driver may trust it.
struct PlayerBudgetFeed {
std::vector<int> systemIncome;
bool isAI = false;
sim::DifficultyMods difficulty;
bool held = true; // false when a system the player owns could not be found
// The self-check: the same systems run through the PROJECTED path, which is the number
// the save's own `BnkEl` states and lane E1 scored 25/25 against. The two paths are not
// the same function and need not agree -- but on a corpus where every colony is at its
// ideal suitability with full infrastructure and an empty build queue, the whole
// construction channel returns to trade and they should agree to within the trade-point
// rounding. A large delta here is the model failing, and it is visible without a VM.
int projectedIncome = 0;
int turnIncome = 0;
};
void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng,
PlayerPhaseTotals& t) {
// --- P01 ComputeBudget -- blocked on the per-system money output -----------------
// The formula is here and is verified; what is missing is `systemIncome`. We build the
// inputs we do hold so the shape of the gap is visible, then stop.
PlayerPhaseTotals& t, const PlayerBudgetFeed& feed) {
// --- P01 ComputeBudget ------------------------------------------------------------
// The per-system money is `ComputeOutput(s).out[3]`, NOT `ComputeMaxIncome(s)`: the
// turn path runs the system's own sliders, so the build queue, the ship-repair pass and
// the infrastructure -> terraform -> money cascade are all live. See
// sots-re findings/subsystems/output-turn-path.md.
{
sim::BudgetInputs in;
in.savings = p.sav;
in.ownsSystems = !p.owners.empty();
in.maintenance = p.maint;
in.maintenanceDivisor = feed.difficulty.maintenanceDivisor;
in.researchDifficultyMult = feed.difficulty.researchMult;
in.isAI = p.npc;
in.researchRate = p.resRate;
in.resMod = p.resMod;
@ -122,14 +143,19 @@ void RunPlayerDriver(Player& p, const TurnOptions& opt, CountingRandom* rng,
s.fraction = e.xper;
in.expenses.push_back(s);
}
// in.systemIncome stays empty: unmodelled input.
in.systemIncome = feed.systemIncome;
const sim::Budget b = sim::ComputeBudget(in, /*projected=*/false);
const int wouldBe = sim::SaturatingAdd(p.sav, b.net);
++t.fired[1];
if (wouldBe != p.sav) ++t.wouldWrite[2];
if (opt.CommitBlocked("P02")) {
p.sav = wouldBe;
++t.writes[2];
// P02 is the write. It is committed by default now that the money channel is
// modelled; a player whose owned systems could not all be resolved is still
// evaluate-and-report.
if (feed.held || opt.CommitBlocked("P02")) {
if (wouldBe != p.sav) {
p.sav = wouldBe;
++t.writes[2];
}
}
}
@ -319,12 +345,23 @@ bool AddictedTo(const std::vector<mars::stream::shapes::AdctEntry>& a, int speci
return false;
}
// `max(ComputeMaxIncome(s), 0)` for one owned system.
int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
const MaxIncomeInputs& ctx) {
// The two terms both money paths share: the output total and everything in the money chain
// except the trade points. `ComputeMaxIncome` and the turn's `ComputeOutput` differ only in
// how they arrive at those trade points.
struct SystemIncomeTerms {
double totalOutputRaw = 0;
sim::SystemMoneyInputs money;
int popSpecies = 0;
double idealSuitability = 0; // the SERVER's per-species baseline (the money cost's ideal)
};
SystemIncomeTerms SystemIncomeTermsFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
const MaxIncomeInputs& ctx, double overHarvestRate) {
SystemIncomeTerms out;
// The system's population is credited to the independent race's species when the colony
// has one, otherwise to the owner's. `hindi` is the gate; `indi` is written either way.
const int popSpecies = s.hindi ? s.indi.indsp : owner.species;
out.popSpecies = popSpecies;
const auto species = static_cast<sim::Species>(owner.species);
const std::int64_t resAvail =
@ -332,7 +369,6 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
const std::int64_t imperial = static_cast<std::int64_t>(s.pop) + s.pbon;
// --- the output total (lane N's term) ---
double total = 0.0;
if (s.rbfl == 0) {
sim::BaseOutputInputs b;
b.imperialPopulation = imperial;
@ -343,7 +379,7 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
b.resourcesAvailable = resAvail;
b.infra = s.infra;
b.infraBonus = s.ibon;
b.overHarvestRate = 0.0; // the max-income rate vector puts nothing on over-harvest
b.overHarvestRate = overHarvestRate;
b.speciesBaseDemand = sim::ConstantsOf(species).resourceDemand;
b.speciesResourceOutput = sim::ConstantsOf(species).resourceOutput;
sim::OutputModifiers m;
@ -353,10 +389,10 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
m.rebOutMod = owner.rebOutMod;
m.scOutMod = owner.scOutMod;
m.techOutMod = 1.0; // ServerPlayer+0x224, not on the wire
total = sim::TotalSystemOutputRaw(m, ctx.tuning);
out.totalOutputRaw = sim::TotalSystemOutputRaw(m, ctx.tuning);
}
// --- the money chain (this lane's term) ---
// --- the money chain (lane E1's term) ---
sim::PopIncomeRow impRows[sim::kSpeciesCount] = {};
sim::PopIncomeRow civRows[sim::kSpeciesCount] = {};
sim::PopIncomeRow slvRows[sim::kSpeciesCount] = {};
@ -371,7 +407,7 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
impRows[q].addicted = civRows[q].addicted = slvRows[q].addicted = add;
}
sim::SystemMoneyInputs mi;
sim::SystemMoneyInputs& mi = out.money;
mi.popIncomeImperial =
sim::PopulationIncome(sim::PopGroup::Imperial, impRows, true, s.hindi, ctx.tuning);
mi.popIncomeCivilian =
@ -384,12 +420,113 @@ int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
mi.serverIncomeMod = ctx.serverIncomeMod;
mi.difficultyIncomeMult =
sim::DifficultyModsFor(owner.aidf, ownerIsAI, owner.npc).incomeMult;
const double ideal = popSpecies >= 0 && popSpecies < static_cast<int>(ctx.idealSuit.size())
? ctx.idealSuit[static_cast<std::size_t>(popSpecies)]
: owner.idealSuit;
mi.suitCostMod =
sim::SuitabilityCostMod(s.suit, ideal, owner.suitTol, owner.rebAI, true, s.vnh);
return sim::SystemMaxIncome(total, mi);
out.idealSuitability = popSpecies >= 0 && popSpecies < static_cast<int>(ctx.idealSuit.size())
? ctx.idealSuit[static_cast<std::size_t>(popSpecies)]
: owner.idealSuit;
mi.suitCostMod = sim::SuitabilityCostMod(s.suit, out.idealSuitability, owner.suitTol,
owner.rebAI, true, s.vnh);
return out;
}
// `ComputeOutput(s).out[3]` -- the money a system contributes to the TURN's budget, as
// opposed to the projected maximum T31 sums. Not clamped: `ComputeBudget` splits a negative
// system into its expense column itself.
//
// One input of the nine this needs is not on the wire and is taken as zero here: the repair
// demand of the owner's damaged ships in orbit, which would need `Ship::RepairCost` over the
// fleets at the system. Every point it would consume is a point that does NOT come back to
// the money channel, so a colony with a damaged fleet reads HIGH.
int SystemTurnMoneyFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
const MaxIncomeInputs& ctx) {
const SystemIncomeTerms terms =
SystemIncomeTermsFromWire(s, owner, ownerIsAI, ctx, s.rts.sroh);
sim::IdealSuitabilityInputs isi;
isi.owned = true;
isi.systemSuitability = s.suit;
isi.ownerIdealSuitability = owner.idealSuit;
isi.independent = s.hindi;
isi.serverIdealSuitability = terms.idealSuitability;
isi.systemOverride = s.dsu;
const double ideal = sim::IdealSuitability(isi);
sim::SystemOutputInputs in;
in.rates.trade = s.rts.srt;
in.rates.construction = s.rts.srsc;
in.rates.terraform = s.rts.srtf;
in.rates.infra = s.rts.sri;
// The normaliser's two suppressions, with the predicates the original uses: an EXACT
// equality for suitability and `float32(Infra + ibon) >= 1` for infrastructure.
in.suitAtIdeal = static_cast<double>(s.suit) == ideal;
in.infraFull = sim::F32(static_cast<double>(s.ibon) + s.infra) >= 1.0;
// The leftover split's own infrastructure test reads the RAW Infra against 1.
in.infraExactlyOne = static_cast<double>(s.infra) == 1.0;
in.infra = s.infra;
in.totalOutputRaw = terms.totalOutputRaw;
in.shipyardStations = 0; // StationCount(sys, owner, 1): no corpus system has a station
in.buildQueueDemand = 0;
if (s.bq)
for (const auto& o : s.bq->orders) in.buildQueueDemand += o.conleft;
in.repairDemand = 0; // see the note above
in.terraformPointsNeeded = sim::TerraformPointsNeeded(s.suit, ideal, owner.terraMod);
in.terraformDown = ideal < static_cast<double>(s.suit);
in.terraformMod = owner.terraMod;
in.money = terms.money;
return sim::ComputeSystemOutput(in, ctx.tuning).money;
}
// `max(ComputeMaxIncome(s), 0)` for one owned system.
int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
const MaxIncomeInputs& ctx) {
// The max-income rate vector puts nothing on over-harvest, and the two cascade channels
// are provably zero under it, so the trade points are simply the rounded output total.
const SystemIncomeTerms terms = SystemIncomeTermsFromWire(s, owner, ownerIsAI, ctx, 0.0);
return sim::SystemMaxIncome(terms.totalOutputRaw, terms.money);
}
// One `PlayerBudgetFeed` per player, in save order. Built once, from the colony state as it
// stands when the player driver runs -- which is AFTER the per-system turn (the strategic
// driver runs the system turn at phase 11 and the player driver at phase 13), so anything
// `S11` fails to commit is missing from these numbers as well.
std::vector<PlayerBudgetFeed> BuildBudgetFeeds(const SaveGame& game, const TurnOptions& opt) {
MaxIncomeInputs ctx;
ctx.serverIncomeMod = game.sim.incMod;
for (const auto& sp : game.sim.species) ctx.idealSuit.push_back(sp.issu);
std::vector<const Sys*> byId;
std::vector<std::int32_t> ids;
for (const auto& e : game.sim.systems) {
ids.push_back(e.sysID);
byId.push_back(&e.sys);
}
const auto find = [&](std::int32_t id) -> const Sys* {
for (std::size_t i = 0; i < ids.size(); ++i)
if (ids[i] == id) return byId[i];
return nullptr;
};
std::vector<PlayerBudgetFeed> feeds;
feeds.reserve(game.sim.players.size());
for (const auto& pe : game.sim.players) {
const Player& p = pe.player;
PlayerBudgetFeed f;
f.isAI = opt.IsAIPlayer(p.plyrIdx);
f.difficulty = sim::DifficultyModsFor(p.aidf, f.isAI, p.npc);
for (std::int32_t id : p.owners) {
const Sys* s = find(id);
if (!s) {
f.held = false; // a dangling owner id: the sum is incomplete, say so
continue;
}
if (s->abdn) continue; // an abandoned colony is skipped, not counted as zero
const int money = SystemTurnMoneyFromWire(*s, p, f.isAI, ctx);
if (money != 0) f.systemIncome.push_back(money);
f.turnIncome += money;
f.projectedIncome += SystemMaxIncomeFromWire(*s, p, f.isAI, ctx);
}
feeds.push_back(std::move(f));
}
return feeds;
}
void RunUpdateBankruptcyLimits(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec) {
@ -788,8 +925,32 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
break;
}
case 13: { // S13 PlayerTurn -- the nested driver
for (auto& e : game.sim.players)
RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt);
const std::vector<PlayerBudgetFeed> feeds = BuildBudgetFeeds(game, opt);
std::size_t fi = 0;
int fed = 0, agree = 0, worst = 0;
for (auto& e : game.sim.players) {
const PlayerBudgetFeed& f = feeds[fi++];
if (!f.systemIncome.empty()) ++fed;
else {
RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt, f);
continue;
}
const int d = f.turnIncome - f.projectedIncome;
if (d == 0) ++agree;
if (d > worst || -d > worst) worst = d < 0 ? -d : d;
RunPlayerDriver(e.player, opt, r.rngLoaded ? &rng : nullptr, pt, f);
}
rec.notes.push_back(fmt(
"%d player(s) had a non-empty per-system money roll-up on the TURN path "
"(ComputeOutput, not ComputeMaxIncome); the ship-repair demand of damaged "
"ships in orbit is taken as 0 and S11's civilian growth is not committed, "
"so a colony that grew this turn is priced from its pre-growth population",
fed));
rec.notes.push_back(fmt(
"turn path vs projected path on the same colony state: %d of %d landed "
"players agree exactly, worst |delta| %d money (the projected sum is what "
"the save's own BnkEl states, so this is a check without a VM)",
agree, fed, worst));
rec.invocations = static_cast<int>(game.sim.players.size());
for (int k = 1; k <= 12; ++k) {
rec.leafWrites += pt.writes[k];

View file

@ -389,7 +389,12 @@ OutputSplit SplitOutput(double total, const OutputRates& rates) {
int ConstructionPoints(double constructionShare, int stations, const TuningTable& t) {
// Truncating, not rounding -- this slot goes through the float-to-int helper.
return Ftol(constructionShare * (1.0 + t.STATION_BONUS_SHIPCON * stations));
// C3 correction, from the instruction stream of 0x00746830: the bonus is ignored unless
// it is STRICTLY positive (the same unloaded-table guard the output term carries), and
// the association is `k x (b x cons) + cons`, not `cons x (1 + b x k)`. Both differences
// are invisible while no system has a shipyard station, which is the whole corpus.
const double b = t.STATION_BONUS_SHIPCON > 0.0 ? t.STATION_BONUS_SHIPCON : 0.0;
return Ftol(static_cast<double>(stations) * (b * constructionShare) + constructionShare);
}
OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAtIdeal,
@ -406,7 +411,9 @@ OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAt
wf = rates.terraform;
wi = rates.infra;
}
const double sum = wt + wf + wi;
// C3 correction: the original accumulates `wi + (wf + wt)` on the x87 stack, in that
// association. Reordering it is not free in floating point.
const double sum = wi + (wf + wt);
OutputSplit s;
if (sum <= 0 || leftover <= 0) {
s.trade = std::max(0.0, leftover);
@ -488,6 +495,87 @@ int SystemMaxIncome(double totalOutput, const SystemMoneyInputs& in) {
return money > 0 ? money : 0;
}
double IdealSuitability(const IdealSuitabilityInputs& in) {
if (!in.owned) return in.systemSuitability;
double v = in.ownerIdealSuitability;
if (in.independent) v = in.serverIdealSuitability;
// An `!=` against the sentinel, so a NaN override would also win. Nothing in the corpus
// exercises either side of that.
if (in.systemOverride != kIdealSuitabilityNoOverride) v = in.systemOverride;
return v;
}
RepairPassResult RepairShipsInOrbit(int points, int repairDemand) {
RepairPassResult r;
if (points <= 0 || repairDemand <= 0) {
r.left = points;
return r;
}
r.spent = points < repairDemand ? points : repairDemand;
r.left = points - r.spent;
return r;
}
SystemOutput ComputeSystemOutput(const SystemOutputInputs& in, const TuningTable& t) {
SystemOutput o;
const OutputRates r = NormaliseOutputRates(in.rates, in.suitAtIdeal, in.infraFull);
o.normalisedRates = r;
// One rounding of the total, then one rounding per channel off that same value.
const double total = RoundHalfEven(in.totalOutputRaw);
o.totalOutput = Ftol(total);
const OutputSplit split = SplitOutput(total, r);
o.tradePoints = split.trade;
// --- construction: the queue first, then the repair pass -----------------------------
o.construction = ConstructionPoints(split.construction, in.shipyardStations, t);
o.constructionToQueue =
in.buildQueueDemand < o.construction ? in.buildQueueDemand : o.construction;
int rem = o.construction - o.constructionToQueue;
if (rem < 0) rem = 0;
if (rem > 0) {
const RepairPassResult rep = RepairShipsInOrbit(rem, in.repairDemand);
o.constructionToRepair = rep.spent;
rem = rep.left;
}
// --- the leftover redistribution -----------------------------------------------------
// `SplitLeftover`'s `infraFull` argument is the RAW `Infra == 1` test, not the
// `Infra + ibon >= 1` one the normaliser used.
const OutputSplit left =
rem > 0 ? SplitLeftover(static_cast<double>(rem), r, in.suitAtIdeal, in.infraExactlyOne)
: OutputSplit{};
o.leftoverToTrade = left.trade;
// --- infrastructure ------------------------------------------------------------------
const double infraNeed = std::ceil((1.0 - in.infra) / 3.3e-5);
const double poolInfra = left.infra + split.infra;
const double spendInfra = poolInfra < infraNeed ? poolInfra : infraNeed;
double leftInfra = poolInfra - spendInfra;
if (!(leftInfra > 0.0)) leftInfra = 0.0;
// Three separate 80-bit steps, not one x3.3e-5.
const double infraGain = spendInfra / 500.0 * 0.01 * 1.65;
o.infraDelta = F32(infraGain > 0.0 ? infraGain : 0.0);
// --- terraforming: the infrastructure leftover lands in THIS pool ---------------------
const double terraNeed = std::ceil(in.terraformPointsNeeded);
const double poolTerra = (left.terraform + split.terraform) + leftInfra;
const double spendTerra = poolTerra < terraNeed ? poolTerra : terraNeed;
double leftTerra = poolTerra - spendTerra;
if (!(leftTerra > 0.0)) leftTerra = 0.0;
o.leftoverToMoney = leftTerra;
// The same helper the colony pass uses; its sign test is `suit > ideal`, which is the
// original's `IdealSuitability() < Suit` with the operands swapped.
o.suitabilityDelta = TerraformDelta(spendTerra, in.terraformMod,
in.terraformDown ? 1.0 : 0.0, 0.0);
// --- money ----------------------------------------------------------------------------
SystemMoneyInputs m = in.money;
m.tradePoints = (o.leftoverToTrade + o.tradePoints) + leftTerra;
o.money = SystemMoneyIncome(m);
return o;
}
BonusApplyResult ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity,
std::int64_t& pendingBonus, bool owned, bool homeSystem) {
BonusApplyResult r;

View file

@ -520,6 +520,126 @@ int SystemMoneyIncome(const SystemMoneyInputs& in);
// for zero science points, which is INFERRED rather than read.
int SystemMaxIncome(double totalOutput, const SystemMoneyInputs& in);
// ---------------------------------------------------------------------------------------
// The turn path: `ServerSystem::ComputeOutput`
// ---------------------------------------------------------------------------------------
//
// `ComputeBudget` has two modes and they take a system's money from DIFFERENT functions.
// Projected mode calls `ComputeMaxIncome` (SystemMaxIncome above). The turn calls
// `ComputeOutput`, which runs `ComputeOutputFromRates` with the system's OWN stored sliders,
// so the construction, terraform and infrastructure channels are funded and three edges that
// are provably dead under the max-income vector are live:
//
// * the build queue and the ship-repair pass consume construction points;
// * whatever they leave over is redistributed across trade / terraform / infrastructure
// and the TRADE share is added to the money channel;
// * unspent infrastructure points cascade into the terraform pool, and unspent terraform
// points cascade into the money channel -- two hops, not one.
//
// See sots-re findings/subsystems/output-turn-path.md. Note that the field the earlier
// income-term note called "science" is the SHIP CONSTRUCTION slider; there is no science
// channel in this function (research is bought with money at the empire level).
// `ServerSystem::IdealSuitability` (0x00745d60) -- the suitability the terraform channel
// aims at, and the `==` the rate normaliser tests against.
// unowned -> the system's own suitability (so it is "at its ideal")
// independent colony -> the SERVER's per-species baseline for `indi->indsp`
// otherwise -> the OWNER's own `IdealSuit` field
// and a system-level `dsu` override wins over all three when it is not the sentinel.
// CONFIDENCE: high on the branch order. The sentinel is FLT_MAX: that is INFERRED from the
// corpus (every system carries exactly FLT_MAX there) rather than read out of the data files.
constexpr double kIdealSuitabilityNoOverride = 3.4028234663852886e+38; // FLT_MAX
struct IdealSuitabilityInputs {
bool owned = true;
double systemSuitability = 0; // sys.Suit
double ownerIdealSuitability = 0; // owner's IdealSuit field
bool independent = false; // sys.hindi
double serverIdealSuitability = 0; // server->IdealSuit[indi.indsp], independent only
double systemOverride = kIdealSuitabilityNoOverride; // sys.dsu
};
double IdealSuitability(const IdealSuitabilityInputs& in);
// C3 note on `TerraformPointsNeeded` above (0x00746890): the original does NOT round -- the
// `ceil` belongs to `ComputeOutputFromRates`, which applies it to the returned double. Our
// version folds the `ceil` in, which is harmless because `ceil` is idempotent and every
// caller applies it, but the boundary is worth stating. The sign multiply inside the divisor
// is cancelled by a `fabs`, so the result is always >= 0, and a zero `TerraMod` yields +inf,
// which makes the terraform channel absorb its whole pool with nothing cascading to money.
// That branch is UNEXERCISED -- no corpus player carries TerraMod 0.
// `ServerSystem::RepairShipsInOrbit` (0x00751590) -- **the side effect** that makes
// `ComputeOutputFromRates` unsafe to call for its value. It hands each damaged ship of the
// owner's fleets at the system a share of the construction points left over after the build
// queue, round-robin, and returns what is left.
//
// The round robin is EQUIVALENT to `points - min(points, demand)` and this is a proof rather
// than an observation: the per-pass share is `max(points / shipCount, 1)`, so every ship with
// a positive remaining cost takes at least one point per pass, and the loop's only early exit
// requires every remaining cost to be zero. So it ends either with the points exhausted or
// with the demand met. CONFIDENCE: high; the equivalence is pinned by a test.
struct RepairPassResult {
int spent = 0;
int left = 0;
};
RepairPassResult RepairShipsInOrbit(int points, int repairDemand);
struct SystemOutputInputs {
// --- the rate vector, exactly as the system stores it (NOT normalised) ---
OutputRates rates;
// The two suppressions the normaliser applies. `suitAtIdeal` is an exact `==` against
// IdealSuitability(); `infraFull` is `float32(Infra + ibon) >= 1`.
bool suitAtIdeal = false;
bool infraFull = false;
// The leftover-weight test reads the RAW `Infra` against 1.0 and does NOT add the pending
// bonus, so it is a different predicate from `infraFull` and is carried separately.
bool infraExactlyOne = false;
// --- the output total, unrounded (lane N's TotalSystemOutputRaw) ---
double totalOutputRaw = 0;
// --- construction ---
int shipyardStations = 0; // StationCount(sys, owner, 1)
int buildQueueDemand = 0; // sum of `conleft` over the system's build queue
// Sum of `Ship::RepairCost` over the owner's damaged ships in orbit. NOT modelled from
// the wire anywhere yet; a caller that cannot compute it must leave it 0 and say so.
int repairDemand = 0;
// --- infrastructure ---
double infra = 0; // sys.Infra, for `ceil((1 - Infra) / 3.3e-5)`
// --- terraforming ---
double terraformPointsNeeded = 0; // TerraformPointsNeeded(...)
bool terraformDown = false; // IdealSuitability() < sys.Suit
double terraformMod = 1.0; // owner's TerraMod
// --- money: every field except `tradePoints`, which this function computes ---
SystemMoneyInputs money;
};
struct SystemOutput {
int totalOutput = 0; // out[0], truncated
int money = 0; // out[3] <- the ONLY slot ComputeBudget reads
int construction = 0; // out[7]
int constructionToQueue = 0; // out[8]
int constructionToRepair = 0; // out[9]
double infraDelta = 0; // out[10], a float32
double suitabilityDelta = 0; // out[11], a float32
// Reported so a caller can see which edges actually carried anything.
double tradePoints = 0; // round(total x SRt)
double leftoverToTrade = 0; // the construction leftover's trade share
double leftoverToMoney = 0; // the terraform leftover that reached the money channel
OutputRates normalisedRates;
};
// `ServerSystem::ComputeOutput` restricted to the channels the campaign has models for.
// out[1], out[2], out[4], out[5] and out[6] -- the resource ledger, the trade-route income
// pair and the repair demand -- are NOT produced here: none of them feeds `out[3]`, they have
// their own inputs, and inventing them would be coverage theatre.
// CONFIDENCE: high on the channel algebra and the rounding sites (every one read off the
// instruction stream). The repair spend is only as good as `repairDemand`.
SystemOutput ComputeSystemOutput(const SystemOutputInputs& in, const TuningTable& t);
// ---------------------------------------------------------------------------------------
// System bonus and build queue
// ---------------------------------------------------------------------------------------

View file

@ -559,6 +559,219 @@ static void test_max_income() {
}()));
}
// ---------------------------------------------------------------------------------------
// The turn path: ComputeOutput
// ---------------------------------------------------------------------------------------
static void test_ideal_suitability() {
IdealSuitabilityInputs in;
in.owned = false;
in.systemSuitability = 7.5;
in.ownerIdealSuitability = 11.0;
// An unowned system reports its OWN suitability, which is what makes it "at its ideal"
// and suppresses the terraform channel.
CHECK_NEAR(IdealSuitability(in), 7.5, 0.0);
in.owned = true;
CHECK_NEAR(IdealSuitability(in), 11.0, 0.0);
in.independent = true;
in.serverIdealSuitability = 9.25;
CHECK_NEAR(IdealSuitability(in), 9.25, 0.0);
// The per-system override beats both, and the sentinel is FLT_MAX.
in.systemOverride = 3.0;
CHECK_NEAR(IdealSuitability(in), 3.0, 0.0);
in.systemOverride = kIdealSuitabilityNoOverride;
CHECK_NEAR(IdealSuitability(in), 9.25, 0.0);
}
static void test_terraform_points() {
// A planet at its ideal needs nothing, whichever direction it would move.
CHECK_NEAR(TerraformPointsNeeded(10.0, 10.0, 1.0), 0.0, 0.0);
// The rate is TerraMod x 1.8f / 20000, and the sign cancels: the count is the same
// whether the planet is above or below the ideal.
const double up = TerraformPointsNeeded(9.0, 10.0, 1.0);
const double down = TerraformPointsNeeded(11.0, 10.0, 1.0);
CHECK_NEAR(up, down, 0.0);
// Our helper folds in the `ceil` that ComputeOutputFromRates applies to the original's
// return value, so the expected numbers are the ceilings.
CHECK_NEAR(up, std::ceil(1.0 / (1.8000000715255737 / 20000.0)), 0.0); // 11112
// A bigger TerraMod needs proportionally fewer points.
CHECK_NEAR(TerraformPointsNeeded(9.0, 10.0, 3.7),
std::ceil(1.0 / (3.7 * 1.8000000715255737 / 20000.0)), 0.0); // 3004
}
static void test_repair_pass() {
// The round robin's outcome, as a min. Every case the loop can reach:
RepairPassResult r = RepairShipsInOrbit(100, 0); // nothing damaged
CHECK_EQ(r.spent, 0);
CHECK_EQ(r.left, 100);
r = RepairShipsInOrbit(100, 40); // points win
CHECK_EQ(r.spent, 40);
CHECK_EQ(r.left, 60);
r = RepairShipsInOrbit(40, 100); // demand wins; nothing cascades
CHECK_EQ(r.spent, 40);
CHECK_EQ(r.left, 0);
r = RepairShipsInOrbit(0, 100); // the early return
CHECK_EQ(r.spent, 0);
CHECK_EQ(r.left, 0);
}
// A helper matching the corpus's shape: at the ideal, infrastructure exactly 1, no station,
// so the terraform and infrastructure channels are suppressed and both needs are zero.
static SystemOutputInputs CorpusColony(double trade, double construction, double total) {
SystemOutputInputs in;
in.rates.trade = trade;
in.rates.construction = construction;
in.suitAtIdeal = true;
in.infraFull = true;
in.infraExactlyOne = true;
in.infra = 1.0;
in.totalOutputRaw = total;
in.terraformPointsNeeded = 0.0;
return in;
}
static void test_turn_path_output() {
const TuningTable t; // unloaded: no station bonus, which the corpus never exercises
// 1. The claim the whole lane turns on: with an empty build queue and nothing to repair,
// every construction point comes back to the money channel, so a colony that puts
// everything into ship construction earns exactly as much as one that puts everything
// into trade.
{
const SystemOutput allTrade = ComputeSystemOutput(CorpusColony(1.0, 0.0, 4000.0), t);
const SystemOutput allCons = ComputeSystemOutput(CorpusColony(0.0, 1.0, 4000.0), t);
const SystemOutput half = ComputeSystemOutput(CorpusColony(0.5, 0.5, 4000.0), t);
CHECK_EQ(allCons.money, allTrade.money);
CHECK_EQ(half.money, allTrade.money);
// and the leftover really is what carries it on the construction colony
CHECK_NEAR(allCons.tradePoints, 0.0, 0.0);
CHECK_NEAR(allCons.leftoverToTrade, 4000.0, 0.0);
CHECK_EQ(allCons.construction, 4000);
}
// 2. ... which makes it equal to the PROJECTED path, up to the trade-point rounding.
// An even total agrees exactly; an odd one can differ by one trade point because
// `2 x round(T/2)` is not `T`.
{
SystemMoneyInputs m;
const SystemOutput even = ComputeSystemOutput(CorpusColony(0.5, 0.5, 4000.0), t);
CHECK_EQ(even.money, SystemMaxIncome(4000.0, m));
const SystemOutput odd = ComputeSystemOutput(CorpusColony(0.5, 0.5, 4001.0), t);
// 4001 x 0.5 = 2000.5, ties to even -> 2000 twice, so 4000 trade points, not 4001.
CHECK_NEAR(odd.tradePoints + odd.leftoverToTrade, 4000.0, 0.0);
}
// 3. The build queue eats construction points BEFORE the leftover is redistributed, so a
// funded queue is a direct loss of money.
{
SystemOutputInputs in = CorpusColony(0.0, 1.0, 4000.0);
in.buildQueueDemand = 1500;
const SystemOutput o = ComputeSystemOutput(in, t);
CHECK_EQ(o.constructionToQueue, 1500);
CHECK_NEAR(o.leftoverToTrade, 2500.0, 0.0);
// a queue larger than the output takes all of it and leaves nothing
in.buildQueueDemand = 999999;
const SystemOutput starved = ComputeSystemOutput(in, t);
CHECK_EQ(starved.constructionToQueue, 4000);
CHECK_NEAR(starved.leftoverToTrade, 0.0, 0.0);
CHECK_EQ(starved.money, 0);
}
// 4. The repair pass takes its share after the queue and before the redistribution.
{
SystemOutputInputs in = CorpusColony(0.0, 1.0, 4000.0);
in.buildQueueDemand = 1000;
in.repairDemand = 700;
const SystemOutput o = ComputeSystemOutput(in, t);
CHECK_EQ(o.constructionToQueue, 1000);
CHECK_EQ(o.constructionToRepair, 700);
CHECK_NEAR(o.leftoverToTrade, 2300.0, 0.0);
}
// 5. The two cascades, which the max-income path proves ARE zero and this one does not.
// A colony below full infrastructure with a funded infra channel spends what it needs
// and passes the rest to terraforming; terraforming passes ITS rest to money.
{
SystemOutputInputs in;
in.rates.trade = 0.0;
in.rates.construction = 0.0;
in.rates.infra = 1.0;
in.suitAtIdeal = true; // so the terraform channel is suppressed and needs 0
in.infraFull = false;
in.infraExactlyOne = false;
in.infra = 1.0 - 3.3e-5 * 100.0; // exactly 100 points short of full
in.totalOutputRaw = 4000.0;
in.terraformPointsNeeded = 0.0;
const SystemOutput o = ComputeSystemOutput(in, t);
// 100 points close the infrastructure gap, the other 3900 fall through terraforming
// (which needs nothing) into the money channel.
CHECK_NEAR(o.leftoverToMoney, 3900.0, 1e-6);
CHECK(o.infraDelta > 0.0);
SystemMoneyInputs m;
m.tradePoints = 3900.0;
CHECK_EQ(o.money, SystemMoneyIncome(m));
}
// 6. A terraforming colony consumes what it needs and cascades the rest, and the sign of
// the suitability delta follows the direction of travel.
{
SystemOutputInputs in;
in.rates.terraform = 1.0;
in.suitAtIdeal = false;
in.infraFull = true;
in.infraExactlyOne = true;
in.infra = 1.0;
in.totalOutputRaw = 4000.0;
in.terraformPointsNeeded = 250.0;
in.terraformMod = 1.0;
const SystemOutput up = ComputeSystemOutput(in, t);
CHECK_NEAR(up.leftoverToMoney, 3750.0, 1e-6);
CHECK(up.suitabilityDelta > 0.0);
in.terraformDown = true;
const SystemOutput down = ComputeSystemOutput(in, t);
CHECK_NEAR(down.suitabilityDelta, -up.suitabilityDelta, 0.0);
// The point count and the point value use the same rate, so spending exactly the
// needed points closes exactly the gap it was computed from.
const double gap = 250.0 * (1.5 * kTerraform12 * 1.0) / 20000.0;
CHECK_NEAR(up.suitabilityDelta, static_cast<double>(static_cast<float>(gap)), 0.0);
}
// 7. The `SRsc == 1` leftover branch really is a different rule: with construction at
// exactly 1 the weights become 1 / (suit off ideal) / (infra below 1) rather than the
// sliders, so a colony that is off its ideal sends HALF its leftover to terraforming
// instead of all of it to trade.
{
SystemOutputInputs in;
in.rates.construction = 1.0;
in.suitAtIdeal = false; // terraform weight 1
in.infraFull = true; // infra suppressed by the normaliser ...
in.infraExactlyOne = true; // ... and weight 0 in the leftover split
in.infra = 1.0;
in.totalOutputRaw = 4000.0;
in.terraformPointsNeeded = 0.0; // nothing to spend it on, so it cascades to money
const SystemOutput o = ComputeSystemOutput(in, t);
CHECK_NEAR(o.normalisedRates.construction, 1.0, 0.0);
CHECK_NEAR(o.leftoverToTrade, 2000.0, 0.0);
CHECK_NEAR(o.leftoverToMoney, 2000.0, 0.0);
// Both halves reach the money channel here, so the total is the same as if it had
// all gone to trade -- the split matters only when terraforming has work to do.
SystemMoneyInputs m;
m.tradePoints = 4000.0;
CHECK_EQ(o.money, SystemMoneyIncome(m));
}
// 8. An unfunded channel produces nothing, and a total of zero produces no money.
{
const SystemOutput o = ComputeSystemOutput(CorpusColony(0.5, 0.5, 0.0), t);
CHECK_EQ(o.totalOutput, 0);
CHECK_EQ(o.construction, 0);
CHECK_EQ(o.money, 0);
}
}
static void test_difficulty_table() {
// Level 0 gives the break to the human; levels 1 and 2 give it to the AI.
const DifficultyMods e_ai = DifficultyModsFor(0, true, false);
@ -714,6 +927,10 @@ int main() {
test_system_money();
test_population_income();
test_max_income();
test_ideal_suitability();
test_terraform_points();
test_repair_pass();
test_turn_path_output();
test_difficulty_table();
test_bonuses();
test_build_queue();