sots-engine/docs/N-output-term.md
alex 0ebc222f45 lane N: the population -> base-output term, live-verified
Reads the whole colony output chain off the instruction stream (every range
disassembled to the next function start) and compares two of its functions
against the running game.

The population -> output law is linear and is carried by the executable:
output points per head are typeOutputModifier x 1.8 / 500000, and the
three-row population-type table is built in code rather than loaded, so the
imperial (1.0) and civilian (0.33f) modifiers are facts about the binary.

A system's total output is a SUM of three terms, not one multiplicative
chain. The station bonus scales only the imperial term and morale only the
civilian one, so OutputModifiers no longer carries either; they belong to
GroupOutputInputs. The function previously described as the base-output term
is the over-harvest RESOURCE demand, and it is corrected in place.

Live on VM140, both hooks in compare mode over two species and two workloads:
GroupOutput 13,105 calls / 0 divergences; ComputeTotalOutput 11,252 calls /
1 divergence of one ulp, in a value its caller rounds to an integer. Both
functions declare a whole-object Guard: 0 undeclared writes in 24,357 calls,
which is what makes the side-effect-free claim a measurement.

sim::Narrow forces the double rounding a 32-bit x87 build otherwise skips;
without it every civilian row came out one ulp low.

Also fixes ComputeBankruptcyLimits' elimination divisor, which was the
decimal -0.15 rather than the image's widened float -0.15000000596046448.
The two disagree for every maximum income divisible by 3 and for essentially
every empire above ~3,000,000.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ARBgSooAfokKUy6wKUKEyZ
2026-09-08 12:11:36 -04:00

5.4 KiB

N — the population → base-output term

The formula that a colony's whole economy hangs off, and the one src/app names as its largest unmodelled input. It is now read from the instruction stream and compared against the running game.

What it is

Output points are linear in population:

outputPerHead = populationTypeOutputModifier x 1.8 / 500000

1.8 and 500000 are literals in the executable, and so are the imperial (1.0) and civilian (0.33f) type modifiers — the three-row population-type table is built in code, not loaded from the data files. Only the slave row's modifier comes from the data. An imperial population with no station therefore contributes exactly 3.6e-6 output points per head.

A system's total output is a sum of three independent terms, not one multiplicative chain:

base = overHarvestDemand x speciesResourceOutputFactor
     + (transitResources + availableResources) x stripMineFraction x 0.9
     + imperialOutput + civilianOutput + slaveOutput

total = addictionModifier x base
      x playerOutMod x systemOutMod x setupOutputMult x rebOutMod x scOutMod

The station bonus multiplies only the imperial term; the morale modifier multiplies only the civilian term. The previous model in this module applied both to the whole thing, which is why OutputModifiers no longer carries morale or stations — they belong to GroupOutputInputs, one population row at a time.

API

src/game/sim/colony.h:

function what
PopTypeOf(group, tuning) the population-type row: output/income modifiers and the row's population cap (imperial 50,000,000, civilian 20,000,000)
GroupOutput(GroupOutputInputs, tuning) one (group, species) row's contribution — the per-capita law
MoraleOutputMultiplier(morale, tuning) the civilian morale factor, with both of the original's guards
StripMineFraction(StripMineInputs) resource extraction efficiency
OverHarvestDemand(OverHarvestInputs) the strip-mining resource demand, which is also a summand of output
SystemBaseOutput(BaseOutputInputs, tuning) the three terms summed in the original's association
TotalSystemOutputRaw / TotalSystemOutput the multiplier tail, unrounded and rounded half-to-even

Verified against the running game

Two hooks in src/shim/hooks/system_output.{h,cpp}, both compare mode, config src/shim/shim.cfg.output:

  • Game::ServerSystem::GroupOutput — the per-capita law itself. 13,105 calls, 0 divergences across two species and two workloads.
  • Game::ServerSystem::ComputeTotalOutput — the whole sum and multiplier tail. 11,252 calls, 1 divergence, and that one is a single ulp on one system, in a value the caller rounds to an integer before using — so it cannot move any number the game stores. It is recorded, not fixed.

Both functions were checked for stores to the game state before being chosen as compare targets, and each declares a Guard region over the whole system object: 0 undeclared writes in 24,357 calls turns that check from a claim into a measurement. The neighbouring ComputeOutputFromRates is deliberately not hooked — it repairs damaged ships in orbit.

Coverage — read this before quoting the zero

24,357 calls is 13 distinct system states. Unexercised, and therefore hypotheses:

  • the over-harvest branch (SRoh is 0 on every call in the corpus), including its max(v, 1) floor;
  • the station factor (no system in the corpus has a station);
  • the slave term (no system in the corpus holds slaves);
  • both morale branches (every colony sits at 75, strictly between the two thresholds);
  • the addiction multiplier and the civilian capacity surplus.

sim::Narrow

A 32-bit x87 build may leave an intermediate in a register at the register's own precision; the original's x87 runs with its precision-control field at 53 bits and rounds every multiply to double. The first live run made that visible as a one-ulp low result on every civilian row — 4,957 divergences that were all the same defect, and none of them the model's. sim::Narrow forces the round the original performs anyway, and is a no-op on any SSE2 target.

Correction shipped alongside

ComputeBankruptcyLimits used the decimal -0.15 as the elimination-limit divisor. The image holds -0.15000000596046448, a widened float literal. The two disagree for every maximum income divisible by 3 — from maxIncome = 3 upward — and for essentially every empire above about 3,000,000, which is six of the twenty-five bankruptcy records recoverable from the save corpus. Fixed, with kBankruptcyInterestDivisor named in the header and a test at the value where it first bites.

What this does and does not unblock

It does not unblock P01. ComputeBudget's missing input is a system's money, which runs this verified total through a second chain with its own population law (incomeModifier / 14000, no 1.8) and its own multipliers. Checked against the bankruptcy-limit oracle over the whole save corpus, that composition reproduces 6 of 25 player-records exactly — every record whose owner is the human player or an independent colony — and the single-system misses are short by a clean factor of 1.1, the AI trade/income difficulty multiplier, which is not on the wire.

So the blocker has moved from the output term to the income tail. src/app is unchanged: 5 leaves closed, 0 regressed, on both save pairs.