game/sim + app: the output -> money chain, the difficulty multiplier's source, T31

The second chain ComputeBudget needs. The output term (lane N) is a system's
OUTPUT; what the budget and the bankruptcy limits sum is its MONEY, which runs
that total through TradePointsToMoney. Read instruction by instruction, every
range disassembled to the next function start.

The income law is not the output law with a different constant: money per head
is typeIncomeMod / 14000 -- no 1.8, no 500000 -- and it truncates TWICE per
(group, species) row, once inside the per-row term and once after the morale and
addiction factors. Summing the species first and truncating once is wrong on any
colony with more than one species.

The multiplier that was "not on the wire" (formula-gaps.md Q3) resolves to a
three-row table the executable BUILDS IN CODE from .rdata float literals, exactly
like lane N's pop-type table: {int id; float ai[3]; float other[3]}, selected per
player by is-AI && !NPC. Every corpus save carries aidf == 1, whose AI income
column is 1.1f -- the x1.1 the BnkEl oracle measured. Its other two columns are a
fleet-maintenance divisor and a research multiplier, both of which already had a
home in BudgetInputs and no source.

Verified against the 25-record BnkEl oracle, which inverts the stored limit to
the true sum and therefore needs no VM: 6/25 before, 25/25 after. Falsified three
ways -- moving the AI flag off the AI players costs 11 records, moving it onto the
humans costs the other 11, and forcing one species' resource pair on all of them
costs every Zuul record.

app: T31 UpdateBankruptcyLimits now runs the whole roll-up and self-checks it
every run against the BnkEl the input save already carries -- 8 of 8 players on
turn1-state with --ai-player 1. It stays blocked on two things that are not the
formula: ServerPlayer+0xf9 (is this player AI?) is a game-setup input the save
does not carry, and BnkPr needs BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data
files. Committing it closes nothing on the reference pair -- the limits move
because the CIVILIAN POPULATION grows and that growth is not committed -- so
measured with --commit-blocked=T31 --ai-player 1: 0 closed, 0 REGRESSED, i.e.
209 -> 204 and 108 -> 103 unchanged.

P01 is NOT unblocked, and the roadmap's item 1 was wrong about that: ComputeBudget
takes its per-system money from ComputeOutput with the system's OWN rate sliders,
not from ComputeMaxIncome. Only its projected mode uses the max-income form. On
the turn path the repair pass runs and the unspent-industry and
unspent-terraforming cascades into the money channel are live, so the proof that
both are zero does not apply. The catalog text says so now.

Two things the 25/25 does NOT cover, and they are labelled in the code: the
suitability money cost is multiplied by zero on every corpus colony (all sit at
their species' ideal), and the slave, addiction, morale, station and
capacity-surplus branches are unexercised.
This commit is contained in:
alex 2026-09-08 13:49:12 -04:00
parent 54b0353067
commit 7c29700ab0
11 changed files with 591 additions and 43 deletions

View file

@ -43,6 +43,9 @@ int Usage() {
" id, comma-separated (e.g. T36)\n"
" --commit-blocked-except=IDS these blocked phases never commit\n"
" --commit-rng write the advanced generator state back\n"
" --ai-player N player N (by PlyrIdx) is AI-controlled; repeatable.\n"
" The flag is a game-setup input the save does not carry\n"
" and it selects the AI column of the difficulty table\n"
" --roundtrip re-serialise the UNTOUCHED save and check byte identity\n"
"\n"
"The two id lists exist so a blocked phase's own closed and regressed counts\n"
@ -101,6 +104,12 @@ int main(int argc, char** argv) {
} else if (a.rfind("--commit-blocked-except=", 0) == 0) {
opt.commitExcept = SplitIds(a.substr(std::strlen("--commit-blocked-except=")));
if (opt.commitExcept.empty()) return Usage();
} else if (a.rfind("--ai-player=", 0) == 0) {
opt.aiPlayers.push_back(std::atoi(a.c_str() + std::strlen("--ai-player=")));
} else if (a == "--ai-player") {
std::string v;
if (!next(v)) return Usage();
opt.aiPlayers.push_back(std::atoi(v.c_str()));
} else if (a == "--commit-rng") {
opt.commitRng = true;
} else if (a == "--roundtrip") {

View file

@ -127,8 +127,12 @@ constexpr PhaseDesc kStrategic[] = {
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. That needs the population -> base-output "
"term, which the colony model declares unresolved. Evaluated and reported, not committed"},
"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, 3, "P03", "RecordBudgetDerivedFields", PhaseStatus::Blocked,
@ -209,9 +213,17 @@ constexpr PhaseDesc kTail[] = {
{Driver::Tail, 29, "T29", "AbortInvisibleInterceptOrders", PhaseStatus::Stub, ""},
{Driver::Tail, 30, "T30", "RebuildCommunicationMasks", PhaseStatus::Stub, ""},
{Driver::Tail, 31, "T31", "UpdateBankruptcyLimits", PhaseStatus::Blocked,
"the limits formula is modelled in game::sim; its input is the sum of every owned "
"system's MAXIMUM money output, which needs the same unresolved population->output term "
"as P01. Evaluated and reported, not committed"},
"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, ""},

View file

@ -268,6 +268,211 @@ void RunSystemTurn(Sys& s, int playerCount, SystemTotals& t) {
(void)playerCount;
}
// ---------------------------------------------------------------------------------------
// T31 UpdateBankruptcyLimits -- the per-player maximum-income roll-up
// ---------------------------------------------------------------------------------------
//
// The input is `sum over owned, non-abandoned systems of max(ComputeMaxIncome(s), 0)`, and
// `ComputeMaxIncome` is the output total run through the money chain with the rate vector
// "all output to trade". Every term of that chain is now modelled in game::sim; what this
// function does is read its inputs off the wire.
//
// Two inputs of the chain are NOT on the wire and are named rather than guessed:
// * `ServerPlayer+0xf9`, the per-player "is AI" flag, which selects the AI column of the
// difficulty table (a x1.1 on the income at difficulty level 1). It is copied from the
// game-setup/network player record and never serialised. `TurnOptions::aiPlayers` is the
// operator's way to supply it; with nothing supplied every player takes the non-AI column
// and an AI empire's limit comes out 1/1.1 low.
// * the three game-setup handicap words (`ServerPlayer+0x224/+0x228/+0x22c`), also copied
// at game creation. `+0x224` multiplies the output total. Taken as 1.0 here, which is
// what the whole 11-save corpus measures.
// The tuning constants the chain can read -- the imperial station output bonus, the two
// morale thresholds, the addiction income modifier and the three slave-row columns -- are
// left at their unloaded zero. Every branch that reads one is UNEXERCISED in the corpus (no
// stations, no slaves, no addiction, and every colony's morale sits strictly between the two
// thresholds), so this is a hypothesis about coverage, not a claim that they do not matter.
struct MaxIncomeInputs {
std::vector<float> idealSuit; // the Sim block's ISsu array, indexed by species
double serverIncomeMod = 1.0; // the Sim block's `IncMod`
sim::TuningTable tuning; // unloaded: see above
};
// Sum of `PopC` over the (group, species) rows of one Population node.
std::int64_t PopCount(const mars::stream::shapes::Population& p, int group, int species) {
std::int64_t n = 0;
for (const auto& g : p.groups)
if (g.popT == group && g.popS == species) n += g.popC;
return n;
}
int MoraleOf(const mars::stream::shapes::Morale& m, int species) {
for (const auto& e : m.entries)
if (e.msp == species) return e.mv;
return 0;
}
bool AddictedTo(const std::vector<mars::stream::shapes::AdctEntry>& a, int species) {
for (const auto& e : a)
if (e.ads == species && e.adt != 0) return true;
return false;
}
// `max(ComputeMaxIncome(s), 0)` for one owned system.
int SystemMaxIncomeFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
const MaxIncomeInputs& ctx) {
// 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;
const auto species = static_cast<sim::Species>(owner.species);
const std::int64_t resAvail =
s.res + (owner.aMine ? static_cast<std::int64_t>(s.mRes) + s.aRes2 : 0);
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;
b.civilianPopulation = PopCount(s.pop2, 1, popSpecies) + PopCount(s.pbon2, 1, popSpecies);
b.civilianMorale = MoraleOf(s.cm, popSpecies);
b.independent = s.hindi;
b.transitResources = s.tRes;
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.speciesBaseDemand = sim::ConstantsOf(species).resourceDemand;
b.speciesResourceOutput = sim::ConstantsOf(species).resourceOutput;
sim::OutputModifiers m;
m.baseOutput = sim::SystemBaseOutput(b, ctx.tuning);
m.playerOutMod = owner.outMod;
m.systemOutMod = s.outMod;
m.rebOutMod = owner.rebOutMod;
m.scOutMod = owner.scOutMod;
m.techOutMod = 1.0; // ServerPlayer+0x224, not on the wire
total = sim::TotalSystemOutputRaw(m, ctx.tuning);
}
// --- the money chain (this lane's term) ---
sim::PopIncomeRow impRows[sim::kSpeciesCount] = {};
sim::PopIncomeRow civRows[sim::kSpeciesCount] = {};
sim::PopIncomeRow slvRows[sim::kSpeciesCount] = {};
for (int q = 0; q < sim::kSpeciesCount; ++q) {
// GroupPopulation(imperial) credits the whole colony to ONE species.
impRows[q].count = q == popSpecies ? imperial : 0;
civRows[q].count = PopCount(s.pop2, 1, q) + PopCount(s.pbon2, 1, q);
slvRows[q].count = PopCount(s.pop2, 2, q) + PopCount(s.pbon2, 2, q);
const int mor = MoraleOf(s.cm, q);
const bool add = AddictedTo(s.adct, q);
impRows[q].morale = civRows[q].morale = slvRows[q].morale = mor;
impRows[q].addicted = civRows[q].addicted = slvRows[q].addicted = add;
}
sim::SystemMoneyInputs mi;
mi.popIncomeImperial =
sim::PopulationIncome(sim::PopGroup::Imperial, impRows, true, s.hindi, ctx.tuning);
mi.popIncomeCivilian =
sim::PopulationIncome(sim::PopGroup::Civilian, civRows, true, s.hindi, ctx.tuning);
mi.slaveIncome =
sim::PopulationIncome(sim::PopGroup::Slaves, slvRows, true, s.hindi, ctx.tuning);
mi.speciesIncomeFactor = sim::ConstantsOf(species).incomeFactor;
mi.speciesCostFactor = sim::ConstantsOf(species).hazardCostFactor;
mi.playerIncMod = owner.incMod;
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);
}
void RunUpdateBankruptcyLimits(SaveGame& game, const TurnOptions& opt, PhaseRecord& rec) {
MaxIncomeInputs ctx;
ctx.serverIncomeMod = game.sim.incMod;
for (const auto& sp : game.sim.species) ctx.idealSuit.push_back(sp.issu);
// The system table, keyed by the handle id a player's `OwnId` list carries.
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;
};
int players = 0, dangling = 0, matches = 0, compared = 0, aiOwned = 0;
std::string firstMiss;
for (auto& pe : game.sim.players) {
Player& p = pe.player;
if (p.elim) continue;
++players;
const bool isAI = opt.IsAIPlayer(p.plyrIdx);
if (isAI) ++aiOwned;
int maxIncome = 0;
for (std::int32_t id : p.owners) {
const Sys* s = find(id);
if (!s) {
++dangling;
continue;
}
if (s->abdn) continue; // an abandoned colony is skipped, not counted as zero
maxIncome += SystemMaxIncomeFromWire(*s, p, isAI, ctx);
}
const sim::BankruptcyLimits lim =
sim::ComputeBankruptcyLimits(maxIncome, ctx.tuning);
// The limits the save already carries were computed by the ORIGINAL at the end of the
// previous turn from the same colony state, so comparing against them is a check of
// the whole income chain that needs no running game -- the same "testable on load"
// property the turn-record phase has.
++compared;
if (lim.eliminationFloor == p.bnkEl) {
++matches;
} else if (firstMiss.empty()) {
firstMiss = fmt("player %d: BnkEl ours %d, save %d (maxIncome %d)", p.plyrIdx,
lim.eliminationFloor, p.bnkEl, maxIncome);
}
++rec.invocations;
// BnkPr's factor is a data-file constant; with no tuning table its computed value is
// -0 for every player, which is a confidently wrong leaf rather than a missing one.
// It is therefore only offered when the table is loaded.
int would = lim.eliminationFloor != p.bnkEl ? 1 : 0;
const bool prModelled = opt.haveTuning;
if (prModelled && lim.protectionLimit != p.bnkPr) ++would;
if (opt.CommitBlocked("T31")) {
rec.leafWrites += would;
p.bnkEl = lim.eliminationFloor;
if (prModelled) p.bnkPr = lim.protectionLimit;
rec.committed = true;
} else {
rec.wouldWrite += would;
}
}
rec.notes.push_back(fmt("%d player(s); BnkEl reproduced for %d of %d from the input save's "
"own colony state",
players, matches, compared));
if (!firstMiss.empty()) rec.notes.push_back(firstMiss);
if (dangling) rec.notes.push_back(fmt("%d owned-system id(s) absent from the system table",
dangling));
if (aiOwned == 0)
rec.notes.push_back("no player was declared AI (--ai-player N); every player therefore "
"takes the non-AI difficulty column, which is 1/1.1 low on an AI "
"empire at difficulty level 1");
// BnkPr's factor is a data-file constant (BANKRUPTCY_PROTECTION_LIMIT_FACTOR), so with no
// tuning table loaded the protection limit is not modelled even though BnkEl is.
if (!opt.haveTuning)
rec.notes.push_back("BnkPr needs BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data "
"files, which is not loaded: only BnkEl is modelled here");
}
// ---------------------------------------------------------------------------------------
// The tail's last phase: the per-player turn record, and its own self-check
// ---------------------------------------------------------------------------------------
@ -693,6 +898,8 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
rec.leafWrites = 1;
rec.committed = true;
rec.notes.push_back(fmt("ModCount -> %d", game.sim.modCount));
} else if (tp[i].index == 31) {
RunUpdateBankruptcyLimits(game, opt, rec);
} else if (tp[i].index == 36) {
RunFinalizeTurnRecords(game, opt, rec, recordAudit, allianceMasks);
}
@ -728,8 +935,11 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
"turn measured (2 words), with no derived rule behind the count -- its bound is the "
"product of the contact and detector counts, so it is left unmodelled");
r.rngUnaccounted.push_back(
"the research-allocation draw is downstream of the budget, which is blocked on the "
"per-system money output (0 or 1 word)");
"two draws are downstream of the budget's research allocation -- ProcessResearch's "
"completion Chance and the tech-effect callback's own roll (0 or 1 word each). The "
"allocation needs ComputeBudget's per-system money, which is ComputeOutput with the "
"system's OWN rate sliders; the max-income form of that money is now modelled and "
"self-checked (see T31), but it is NOT the one this path takes");
if (r.rngLoaded && r.rngWords > 0)
r.rngUnaccounted.push_back(
"a successful raid roll may draw one further word to pick its target; no roll "

View file

@ -38,6 +38,17 @@ struct TurnOptions {
bool commitRng = false;
// Tuning constants were loaded, so formulas that read them may run.
bool haveTuning = false;
// Which players are AI-controlled, by `PlyrIdx`. `ServerPlayer+0xf9` is a game-setup
// input that the save format does not carry (see the T31 block in turn.cpp), and the
// difficulty table selects a different column for an AI player -- at the corpus'
// difficulty level that is a x1.1 on every AI-owned system's money. Empty means "assume
// nobody is AI", which is a statement about what we were told, not about the game.
std::vector<int> aiPlayers;
bool IsAIPlayer(int playerIndex) const {
for (int i : aiPlayers)
if (i == playerIndex) return true;
return false;
}
// The game's data root, when the operator supplied one. The standalone reads no game data
// of its own and needs none for any other phase; the tail's turn record is the one place
// where a save is not enough, because a design's hull size and its defence-platform flag

View file

@ -419,24 +419,73 @@ OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAt
return s;
}
double SuitabilityCostMod(double suitability, double idealSuitability, double suitTolerance,
bool rebelAI, bool owned) {
if (rebelAI) return 0.0;
if (!owned) return 20.0;
return std::min(std::fabs(idealSuitability - suitability), suitTolerance);
int GroupIncome(PopGroup group, std::int64_t count, const TuningTable& t) {
// `fld DWORD [row+0x14]` -- the income column is a float32 in the table.
const double mod = F32(PopTypeOf(group, t).incomeMod);
return Ftol(Narrow(mod * (static_cast<double>(count) / kIncomePopulationDivisor)));
}
int SystemMoneyIncome(const SystemMoneyInputs& in) {
// Whole blocks of five trade points, worth five money each.
double t = (in.tradePoints - std::fmod(in.tradePoints, 5.0)) * 5.0;
t += static_cast<double>(in.popIncomeImperial);
t += static_cast<double>(in.popIncomeCivilian);
t += static_cast<double>(in.slaveIncome);
t *= in.speciesIncomeFactor;
t *= in.playerIncMod;
t *= in.serverIncomeMod * in.difficultyIncomeMult;
const double cost = in.speciesCostFactor * in.suitCostMod * 10000.0 * 1.5;
return Ftol(t - cost);
double PopulationIncome(PopGroup group, const PopIncomeRow (&rows)[kSpeciesCount],
bool owned, bool independent, const TuningTable& t) {
double sum = 0.0;
for (int sp = 0; sp < kSpeciesCount; ++sp) {
const PopIncomeRow& r = rows[sp];
if (!(r.count > 0)) continue;
// Only the civilian row takes morale, and it takes it through the same helper the
// output term uses -- including the "no owner / independent / no record" bypass.
double morale = 1.0;
if (group == PopGroup::Civilian && owned && !independent) {
morale = MoraleOutputMultiplier(r.morale, t);
}
const double addiction = r.addicted ? t.ADDICTION_INCOME_MOD : 1.0;
const double base = static_cast<double>(GroupIncome(group, r.count, t));
// The SECOND truncation: per species, after both factors.
sum += static_cast<double>(Ftol(Narrow(Narrow(base * morale) * addiction)));
}
return sum;
}
double SuitabilityCostMod(double suitability, double idealSuitability, double suitTolerance,
bool rebelAI, bool owned, bool vonNeumann) {
if (vonNeumann) return 0.0; // the original's very first test
if (!owned) return 20.0; // ... and it logs a warning
if (rebelAI) return 0.0;
// All three operands are 4-byte floats in the original (`server->IdealSuit[sp]`,
// `sys->Suit`, `owner->SuitTol`); the subtraction and the compare are then done on the
// x87 with no store back, so nothing is narrowed here. The compare is `<=`: a distance
// exactly at the tolerance is charged as itself, not as the cap.
const double d = std::fabs(idealSuitability - suitability);
return d <= suitTolerance ? d : suitTolerance;
}
double SystemMoneyIncomeRaw(const SystemMoneyInputs& in) {
// Whole blocks of five trade points; the same literal is the modulus and the multiplier.
// The `+ 0.0` is a real instruction (an .rdata zero) and the association below is the
// original's -- x87 addition is not associative, so neither is reorderable.
const double blocks = (in.tradePoints - std::fmod(in.tradePoints, 5.0)) * 5.0;
double t = Narrow(Narrow(blocks + 0.0) + in.popIncomeImperial);
t = Narrow(in.popIncomeCivilian + t);
t = Narrow(in.slaveIncome + t);
// Every per-player multiplier is stored back through a 4-byte float before it is used.
t = Narrow(F32(in.speciesIncomeFactor) * t);
const double diff = F32(F32(in.difficultyIncomeMult) * F32(in.serverIncomeMod));
t = Narrow(diff * Narrow(F32(in.playerIncMod) * t));
const double cost =
Narrow(F32(in.speciesCostFactor) * Narrow(Narrow(in.suitCostMod * 10000.0) * 1.5));
return t - cost;
}
int SystemMoneyIncome(const SystemMoneyInputs& in) { return Ftol(SystemMoneyIncomeRaw(in)); }
int SystemMaxIncome(double totalOutput, const SystemMoneyInputs& in) {
SystemMoneyInputs m = in;
// The max-income rate vector is trade = 1 and every other channel 0, so the trade
// points are the rounded total and the two cascade channels contribute nothing.
m.tradePoints = RoundHalfEven(totalOutput);
const int money = SystemMoneyIncome(m);
return money > 0 ? money : 0;
}
BonusApplyResult ApplyPopulationBonus(std::int64_t& pop, std::int64_t capacity,

View file

@ -420,37 +420,106 @@ int ConstructionPoints(double constructionShare, int stations, const TuningTable
OutputSplit SplitLeftover(double leftover, const OutputRates& rates, bool suitAtIdeal,
bool infraFull);
// ---------------------------------------------------------------------------------------
// Money: the population income law and a system's money output
// ---------------------------------------------------------------------------------------
//
// The income chain is a SECOND chain off the same three-row population table, and it is not
// the output chain with a different constant: income per head is `typeIncomeMod / 14000`,
// with neither the 1.8 factor nor the 500000 divisor the output law uses, and it truncates
// TWICE per (group, species) row -- once inside the per-row term and once after the morale
// and addiction factors. Summing the species first and truncating once is wrong on any
// colony carrying more than one species or a non-unit morale/addiction factor.
// See sots-re findings/subsystems/income-term.md.
// Money contributed per head, before the type modifier. An .rdata literal.
constexpr double kIncomePopulationDivisor = 14000.0;
// One (group, count) row's money: `ftol( typeIncomeMod x (count / 14000) )`. The type
// modifier is a float32 in the table, so it is narrowed before the multiply.
// CONFIDENCE: high -- read instruction by instruction (the whole function is five
// instructions and a tail-jump into the float-to-int helper).
int GroupIncome(PopGroup group, std::int64_t count, const TuningTable& t);
struct PopIncomeRow {
std::int64_t count = 0; // heads of this species in this group
int morale = 0; // the system's Morale entry for this species
bool addicted = false; // the system's addiction entry for this species is non-zero
};
// The population income of one group type, summed over the seven species rows:
// for each species with count > 0:
// mo = civilian groups only: the morale multiplier (the SAME helper output uses)
// ad = addicted ? ADDICTION_INCOME_MOD : 1
// sum += (double) ftol( (double)GroupIncome(group, count) x mo x ad )
// Slave groups take neither morale nor the civilian capacity surplus.
// CONFIDENCE: high on the loop and both truncations; the civilian capacity-surplus term
// (which the original adds to the owner species' civilian count when the colony is at its
// cap) is the CALLER's business -- fold it into that row's `count`. No colony in the
// corpus is at its cap, so that term is UNEXERCISED either way.
double PopulationIncome(PopGroup group, const PopIncomeRow (&rows)[kSpeciesCount],
bool owned, bool independent, const TuningTable& t);
// Suitability term of the money cost: how far the planet is from the owner species'
// ideal, capped by the owner's SuitTol (so the tolerance techs also cap the cost).
// The rebel AI pays nothing; an unowned system is charged as if 20 away.
// The rebel AI pays nothing, and so does a system carrying a von Neumann machine
// (`vnh`, the very first test in the original); an unowned system is charged as if 20 away.
//
// `idealSuitability` is the SERVER's per-species baseline (`server->IdealSuit[species]`),
// not the owner's own `IdealSuit` field, and the species is the system's population species
// (`indi->indsp` on an independent colony) rather than the owner's. In the 11-save corpus
// the two IdealSuit sources agree for every player, so **which one the original reads is
// not falsifiable here** -- it is read from the instruction stream only.
// CONFIDENCE: high.
double SuitabilityCostMod(double suitability, double idealSuitability, double suitTolerance,
bool rebelAI, bool owned);
bool rebelAI, bool owned, bool vonNeumann = false);
struct SystemMoneyInputs {
double tradePoints = 0; // the system's trade-channel output this turn
int popIncomeImperial = 0; // income of the imperial population groups
int popIncomeCivilian = 0; // income of the civilian groups (incl. surplus term)
int slaveIncome = 0; // income of the slave groups
double popIncomeImperial = 0; // PopulationIncome(Imperial, ...)
double popIncomeCivilian = 0; // PopulationIncome(Civilian, ...), incl. surplus
double slaveIncome = 0; // PopulationIncome(Slaves, ...)
double speciesIncomeFactor = 1.0; // ConstantsOf(owner).incomeFactor (1 if unowned)
double playerIncMod = 1.0; // IncMod (1 if unowned)
double serverIncomeMod = 1.0; // game-option income modifier
double difficultyIncomeMult = 1.0; // AI difficulty trade/income multiplier
double serverIncomeMod = 1.0; // game-option income modifier (Sim tag `IncMod`)
double difficultyIncomeMult = 1.0; // DifficultyIncomeMod's table entry
double speciesCostFactor = 1.0; // ConstantsOf(owner).hazardCostFactor
double suitCostMod = 0; // from SuitabilityCostMod
};
// Money a system contributes this turn:
// t = (trade - fmod(trade, 5)) x 5 whole 5-point blocks of trade, five each
// t += imperial + civilian + slave income (the per-group income tables are inputs)
// t *= speciesIncomeFactor; t *= IncMod; t *= serverIncomeMod x difficultyIncomeMult
// cost = speciesCostFactor x suitCostMod x 10000 x 1.5
// money = ftol(t - cost)
// CONFIDENCE: high on the chain and constants; the per-group population income terms
// (and the addiction factor inside them) are inputs because their own tables are not
// modelled here.
// Money a system contributes this turn (`TradePointsToMoney`, returning the DOUBLE the
// original returns -- its caller is what truncates):
// t = slaves + (civilian + (((trade - fmod(trade, 5)) x 5 + 0) + imperial))
// t = f32(speciesIncomeFactor) x t
// t = f32(f32(difficultyIncomeMult) x f32(serverIncomeMod)) x (f32(IncMod) x t)
// cost = f32(speciesCostFactor) x (suitCostMod x 10000 x 1.5)
// return t - cost
// Whole five-point blocks of trade are worth FIVE money each after being multiplied by
// five -- i.e. 25 money per block of five points; the same literal is the modulus and the
// multiplier. Every per-player multiplier is narrowed to float32 before it is applied, and
// the additive association above is the original's, which x87 does not let us reorder.
// CONFIDENCE: high -- read instruction by instruction.
double SystemMoneyIncomeRaw(const SystemMoneyInputs& in);
// The truncated form its callers store. CONFIDENCE: high.
int SystemMoneyIncome(const SystemMoneyInputs& in);
// `ServerSystem::ComputeMaxIncome` -- what `ComputeBudget` and `UpdateBankruptcyLimits`
// actually sum. It runs the output total through `ComputeOutputFromRates` with the rate
// vector "all output to trade" and takes the money channel:
// trade = roundHalfEven(totalOutput) (rate 1.0, so the second rounding is a no-op)
// return max( ftol(SystemMoneyIncomeRaw(...)), 0 )
// The `max(.., 0)` is the original's `jg`: a negative-income colony contributes zero to the
// empire total rather than reducing it.
//
// The two cascade terms `ComputeOutputFromRates` can add to the money channel -- unspent
// industry and unspent terraforming points -- are provably ZERO under this rate vector:
// both are `0 - min(0, need)` with `need >= 0` (infra is clamped to 1, and the terraform
// point count takes fabs after its sign multiply). They are therefore not inputs here.
// CONFIDENCE: high; the science cascade being zero rests on the repair helper returning 0
// for zero science points, which is INFERRED rather than read.
int SystemMaxIncome(double totalOutput, const SystemMoneyInputs& in);
// ---------------------------------------------------------------------------------------
// System bonus and build queue
// ---------------------------------------------------------------------------------------

View file

@ -7,6 +7,23 @@
namespace sots::sim {
DifficultyMods DifficultyModsFor(int level, bool isAI, bool isNpc) {
// The six floats of each row, as the image holds them: {AI triple, non-AI triple}.
// Row 0 gives the break to the player; rows 1 and 2 give it to the AI.
struct Row { float ai[3]; float other[3]; };
static constexpr Row kTable[kDifficultyLevels] = {
{{1.0f, 1.0f, 1.0f}, {1.5f, 1.5f, 1.5f}}, // 0
{{3.0f, 1.1f, 1.5f}, {1.0f, 1.0f, 1.0f}}, // 1
{{1000000.0f, 1.7f, 2.0f}, {1.0f, 1.0f, 1.0f}}, // 2
};
// An out-of-range level keeps the all-ones default the original memcpy's in first.
if (level < 0 || level >= kDifficultyLevels) return DifficultyMods{};
const Row& r = kTable[level];
const float* m = (isAI && !isNpc) ? r.ai : r.other;
return DifficultyMods{static_cast<double>(m[0]), static_cast<double>(m[1]),
static_cast<double>(m[2])};
}
int SavingsInterest(int savings, bool ownsSystems) {
if (savings < 0 || !ownsSystems) return 0;
return Ftol(static_cast<double>(savings) * 0.01);

View file

@ -11,6 +11,33 @@
namespace sots::sim {
// ---------------------------------------------------------------------------------------
// Difficulty
// ---------------------------------------------------------------------------------------
//
// Three multipliers, selected per player from a three-row table that the executable builds
// **in code** from float literals -- there is no data-file key and no tuning-table entry for
// any of them. Each row carries two triples: one used for AI players and one for everybody
// else, so the same row makes the game easier for the human on level 0 and easier for the AI
// on levels 1 and 2. See sots-re findings/subsystems/income-term.md §3.
struct DifficultyMods {
double maintenanceDivisor = 1.0; // fleet upkeep is divided by ftol(this)
double incomeMult = 1.0; // a system's money income, and trade-route income
double researchMult = 1.0; // research points bought with money
};
// The number of rows the table holds. A level outside [0, kDifficultyLevels) selects the
// all-ones default rather than failing -- the original memcpy's that default in first and
// only overwrites it on a hit.
constexpr int kDifficultyLevels = 3;
// `level` is the player's `aidf` save field; `isAI` is the player's AI flag, which is
// **not on the wire** (it is copied from the game-setup/network player record), and an NPC
// player takes the non-AI triple whatever its AI flag says.
// CONFIDENCE: high -- table and selector both read instruction by instruction. The corpus
// carries level 1 on every player and exercises only the AI/non-AI split of that row.
DifficultyMods DifficultyModsFor(int level, bool isAI, bool isNpc);
// ---------------------------------------------------------------------------------------
// Budget
// ---------------------------------------------------------------------------------------

View file

@ -29,14 +29,24 @@ struct SpeciesConstants {
double incomeFactor = 1.0; // multiplies a system's money income (Zuul 1.1, Morrigi 0.8)
double hazardCostFactor = 1.0; // multiplies the suitability money cost (Zuul 0.7)
bool systemBonusEligible = true; // long-stability system bonus accrues (Zuul never)
// The species' base resource demand and resource-output factor -- the two fields the
// output term's harvest summand reads, off the SYSTEM OWNER's species rather than the
// colony's population species. Unlike the three above, these come from the DATA FILES;
// the values here are the ones measured live on VM140 for the three species the corpus
// exercises (Human, Tarkas, Zuul) and are 0/10 for the rest, which is UNVERIFIED for
// Hiver, Liir and Morrigi. Getting the Zuul pair wrong costs a Zuul colony exactly
// 400 output points -- 2000 money -- which is how it was found.
int resourceDemand = 0; // SpeciesDef +0x4c
double resourceOutput = 10.0; // SpeciesDef +0x50
};
// CONFIDENCE: high on the three fields (read with their constants and their consumers).
// CONFIDENCE: high on the first three fields (read with their constants and their
// consumers); the two resource fields are data-file values, measured not read.
constexpr SpeciesConstants ConstantsOf(Species s) {
switch (s) {
case Species::Zuul: return SpeciesConstants{1.1, 0.7, false};
case Species::Morrigi: return SpeciesConstants{0.8, 1.0, true};
default: return SpeciesConstants{1.0, 1.0, true};
case Species::Zuul: return SpeciesConstants{1.1, 0.7, false, 10, 40.0};
case Species::Morrigi: return SpeciesConstants{0.8, 1.0, true, 0, 10.0};
default: return SpeciesConstants{1.0, 1.0, true, 0, 10.0};
}
}

View file

@ -67,6 +67,11 @@ struct TuningTable {
double MORALE_DECREASE_OUTPUT = 0;
double MORALE_DECREASE_OUTPUT_MOD = 0;
double ADDICTION_OUTPUT_MOD = 0;
// The income counterpart, read through its own pointer slot (0x00aeca48 -> 0x00aeca44).
// It is applied per (group, species) row inside the population income term, not to the
// system total the way the output modifier is. Its file-image value is 0.9f, the same
// as ADDICTION_OUTPUT_MOD's, but they are two independent keys.
double ADDICTION_INCOME_MOD = 0;
// ---- movement (globals) ----
double STUTTER_SYSTEM_INFLUENCE_RADIUS = 0;

View file

@ -1,6 +1,9 @@
#include "game/sim/colony.h"
#include <cmath>
#include "check.h"
#include "game/sim/economy.h"
#include "game/sim/numeric.h"
using namespace sots::sim;
@ -22,6 +25,8 @@ static TuningTable tuning() {
t.STATION_BONUS_IMPERIAL_OUTPUT = 0.1;
t.STATION_BONUS_SHIPCON = 0.25;
t.ADDICTION_OUTPUT_MOD = 0.5;
t.ADDICTION_INCOME_MOD = 0.9;
t.SLAVES_INCOME_MOD = 3.0;
t.SYSTEMBONUS_MINTURNS = 10;
t.SYSTEMBONUS_POPBONUS = 0.1;
t.SYSTEMBONUS_POPBONUS_HOME = 0.2;
@ -461,6 +466,127 @@ static void test_system_money() {
CHECK_NEAR(ConstantsOf(Species::Human).incomeFactor, 1.0, 0.0);
CHECK(!ConstantsOf(Species::Zuul).systemBonusEligible);
CHECK(ConstantsOf(Species::Hiver).systemBonusEligible);
// A von Neumann machine at the system zeroes the cost before anything else is looked at.
CHECK_NEAR(SuitabilityCostMod(0.3, 0.5, 0.15, false, true, true), 0.0, 0.0);
CHECK_NEAR(SuitabilityCostMod(0.5, 0.5, 0.15, false, false, true), 0.0, 0.0);
}
static void test_population_income() {
TuningTable t = tuning();
// Income per head is typeIncomeMod / 14000 -- no 1.8, no 500000. An imperial billion
// is 1e9/14000 = 71428.57..., truncated.
CHECK_EQ(GroupIncome(PopGroup::Imperial, 1000000000, t), 71428);
CHECK_EQ(GroupIncome(PopGroup::Imperial, 13999, t), 0);
CHECK_EQ(GroupIncome(PopGroup::Imperial, 14000, t), 1);
// The civilian row's modifier is the float32 0.33, so half a billion civilians give
// ftol(0.33000001311302185 x 35714.2857...) = 11785.
CHECK_EQ(GroupIncome(PopGroup::Civilian, 500000000, t), 11785);
CHECK_EQ(GroupIncome(PopGroup::Slaves, 14000, t), 3); // SLAVES_INCOME_MOD = 3
PopIncomeRow rows[kSpeciesCount] = {};
rows[0].count = 1000000000;
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, rows, true, false, t), 71428.0, 0.0);
// Two species truncate SEPARATELY, so the sum is not the truncation of the sum.
PopIncomeRow two[kSpeciesCount] = {};
two[0].count = 20999; // -> 1
two[2].count = 20999; // -> 1
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, two, true, false, t), 2.0, 0.0);
CHECK_EQ(GroupIncome(PopGroup::Imperial, 41998, t), 2); // ... which happens to agree here
PopIncomeRow three[kSpeciesCount] = {};
three[0].count = 13999; // -> 0
three[2].count = 13999; // -> 0
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, three, true, false, t), 0.0, 0.0);
CHECK_EQ(GroupIncome(PopGroup::Imperial, 27998, t), 1); // ... and here it does NOT
// Morale applies to the civilian row only, and the product truncates again.
PopIncomeRow mor[kSpeciesCount] = {};
mor[0].count = 500000000;
mor[0].morale = 80; // >= MORALE_INCREASE_OUTPUT (75)
CHECK_NEAR(PopulationIncome(PopGroup::Civilian, mor, true, false, t),
std::floor(11785.0 * 1.1), 0.0);
// ... but not to the imperial row.
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, mor, true, false, t),
static_cast<double>(GroupIncome(PopGroup::Imperial, 500000000, t)), 0.0);
// ... and an independent colony bypasses morale entirely.
CHECK_NEAR(PopulationIncome(PopGroup::Civilian, mor, true, true, t), 11785.0, 0.0);
// Addiction multiplies every row, imperial included.
PopIncomeRow add[kSpeciesCount] = {};
add[0].count = 1000000000;
add[0].addicted = true;
CHECK_NEAR(PopulationIncome(PopGroup::Imperial, add, true, false, t),
std::floor(71428.0 * 0.9), 0.0);
}
static void test_max_income() {
// The whole chain, with the numbers a level-1 AI Zuul colony produces: the trade points
// are the rounded output total, blocks of five are worth five each after the x5, the
// species factor is 1.1 and the difficulty income modifier another 1.1.
SystemMoneyInputs m;
m.popIncomeImperial = 71428;
m.speciesIncomeFactor = ConstantsOf(Species::Zuul).incomeFactor;
m.speciesCostFactor = ConstantsOf(Species::Zuul).hazardCostFactor;
m.suitCostMod = 0.0; // a homeworld sits exactly at its ideal
m.difficultyIncomeMult = DifficultyModsFor(1, /*isAI=*/true, /*isNpc=*/false).incomeMult;
const int ai = SystemMaxIncome(12345.0, m);
m.difficultyIncomeMult = DifficultyModsFor(1, /*isAI=*/false, /*isNpc=*/false).incomeMult;
const int human = SystemMaxIncome(12345.0, m);
// The AI's advantage on this row is exactly the 1.1 in the difficulty table.
CHECK(ai > human);
CHECK_NEAR(static_cast<double>(ai) / static_cast<double>(human), 1.1, 1e-5);
// A colony whose money comes out negative contributes ZERO to the empire total rather
// than reducing it -- the `jg` at the end of ComputeMaxIncome.
SystemMoneyInputs bad;
bad.suitCostMod = 20.0; // 20 x 15000 of cost against no income
CHECK_EQ(SystemMoneyIncome(bad), -300000);
CHECK_EQ(SystemMaxIncome(0.0, bad), 0);
// The rate vector is trade = 1, so the trade points are the half-to-even rounded total.
SystemMoneyInputs r;
CHECK_EQ(SystemMaxIncome(20.5, r), SystemMoneyIncome([] {
SystemMoneyInputs x;
x.tradePoints = 20.0; // 20.5 ties to the even neighbour
return x;
}()));
CHECK_EQ(SystemMaxIncome(21.5, r), SystemMoneyIncome([] {
SystemMoneyInputs x;
x.tradePoints = 22.0;
return x;
}()));
}
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);
const DifficultyMods e_pl = DifficultyModsFor(0, false, false);
CHECK_NEAR(e_ai.maintenanceDivisor, 1.0, 0.0);
CHECK_NEAR(e_ai.incomeMult, 1.0, 0.0);
CHECK_NEAR(e_pl.maintenanceDivisor, 1.5, 0.0);
CHECK_NEAR(e_pl.incomeMult, 1.5, 0.0);
const DifficultyMods n_ai = DifficultyModsFor(1, true, false);
CHECK_NEAR(n_ai.maintenanceDivisor, 3.0, 0.0);
CHECK_NEAR(n_ai.incomeMult, 1.1, 1e-7);
CHECK_NEAR(n_ai.researchMult, 1.5, 0.0);
CHECK_NEAR(DifficultyModsFor(1, false, false).incomeMult, 1.0, 0.0);
const DifficultyMods h_ai = DifficultyModsFor(2, true, false);
CHECK_NEAR(h_ai.maintenanceDivisor, 1000000.0, 0.0);
CHECK_NEAR(h_ai.incomeMult, 1.7, 1e-7);
CHECK_NEAR(h_ai.researchMult, 2.0, 0.0);
// Hard maintenance really is "divided by a million", i.e. free.
CHECK_EQ(MaintenanceCost(999999, h_ai.maintenanceDivisor), 0);
// An NPC player takes the non-AI triple whatever its AI flag says ...
CHECK_NEAR(DifficultyModsFor(1, true, true).incomeMult, 1.0, 0.0);
// ... and an out-of-range level falls back to all ones rather than failing.
CHECK_NEAR(DifficultyModsFor(-1, true, false).incomeMult, 1.0, 0.0);
CHECK_NEAR(DifficultyModsFor(3, true, false).incomeMult, 1.0, 0.0);
CHECK_NEAR(DifficultyModsFor(3, true, false).maintenanceDivisor, 1.0, 0.0);
}
static void test_bonuses() {
@ -586,6 +712,9 @@ int main() {
test_slaves();
test_output();
test_system_money();
test_population_income();
test_max_income();
test_difficulty_table();
test_bonuses();
test_build_queue();
return simtest::finish("test_colony");