# game/sim — strategic-layer formula catalog Module: `src/game/sim/`. Pure functions over plain structs — no game state, no I/O. Every RNG-consuming roll takes an `IRandom&` (`rng.h`); the engine injects its MT19937-compatible server generator, tests inject a scripted sequence. Tuning constants come in through a `TuningTable` (`tuning.h`) whose fields are the data-file keys verbatim; nothing in the module hard-codes a shipped value. Numeric conventions (`numeric.h`): `Ftol`/`Ftoi64` truncate toward zero like the original float-to-int helper; `RoundToInt` rounds half away from zero; the treasury is a saturating 32-bit int clamped to +/-2,000,000,000. Build/test: `tests/game_sim/build_and_run.sh` (plain g++, `-Wall -Wextra -Werror`), or `-DSOTS_GAME_SIM_TESTS=ON` once `src/game/sim` is added to the root CMake. The real-data smoke test runs only when `SOTS_SAVES_JSON` points at a `save_reader.py --json` dump and asserts nothing (compare-mode is future work). Confidence legend — **high**: formula verified in the RE notes against the code path; **medium**: shape and inputs established, one term or the truncation order inferred; **low**: shape only, marked `CONFIDENCE: low — see sots-re open questions` in the header. ## Species (`species.h`) | item | formula / rule | confidence | |---|---|---| | enum order | `Human 0, Hiver 1, Tarkas 2, Liir 3, NPC 4, Zuul 5, Morrigi 6` — index of every per-species table | high | | `ConstantsOf` | engine-carried species constants: `incomeFactor` (Zuul 1.1, Morrigi 0.8, else 1), `hazardCostFactor` (Zuul 0.7, else 1), `systemBonusEligible` (Zuul false) | high on the three fields; the rest of the engine's species table is not modelled | ## Economy (`economy.h`) | function | formula | confidence | |---|---|---| | `SavingsInterest` | `Sav >= 0 && ownsSystems ? ftol(Sav x 0.01) : 0` | high | | `DebtInterest` | `Sav < 0 ? ftol(-Sav x 0.15) : 0` | high | | `MaintenanceCost` | `Maint / ftol(difficultyDivisor)` | high — matched live at divisors 1 and 3 | | `ExpenseTotal` | per entry `minC = max(min, 0)`, `maxC = clamp(max, 0, 2e9)` with 0 → 2e9, `req = ftol(fraction x float(availPre)) − minC`, `take = min(max(req, 0), maxC − minC)`; total `Σ minC + min(max(Σ take, 0), availPre − Σ minC)` | high — the request is the slider fraction of the pre-expense available income (single-precision product) minus the mandatory minimum | | `ResearchPointsFromMoney` | `ftol(difficulty x (money/50 x 1.15 x 0.5 x 0.85) x (ResMod + shrm + TRM) x techMult x srv.ResMod x ResScl)` ≈ money x 0.009775 x multipliers | high | | `ComputeBudget` | line items: +system income (positive part), +trade, +ship-carried population, +secondary manager, +savings interest, +tech bonus; −negative system income, −maintenance, −research kept, −debt interest, −construction, −expenses, −research aid, −savings aid. `avail = max(0, running)`; construction `= min(demand, avail)` for humans; `researchMoney = max(0, ftol((avail − construction) x ResRate))` (0 when projected); `totalRP = max(0, RP + TRA + TRP)`; `researchMoneyKept` (the money the turn actually spends) is charged **only when the player has a research target** — the slot and the research allocation are written in the same branch; aid: `given = x pct/100`; `bonus = net > 0 ? max(0, ftol((techIncomeMult − 1) x net)) : 0` where `net` is the full net so far (all income incl. interest and trade, minus maintenance, research money, construction, expenses, research aid); `savingsGiven = min(max(SatAdd(Sav, net + bonus), 0), max(aid, 0))` — capped by the projected treasury, not the turn net | high — old-vs-new on the live game (`docs/B1.md`): 4,437 compare calls, 0 divergences, and replace mode reproduced the End-Turn oracle. The research-target gate on `researchMoneyKept` was found by that run. Untested by the reference save: expense sliders, research/savings aid, debt interest, the tech income bonus. The secondary-manager slot is still only a name | | `TradeRoutesSupported` | `max(1, ceil(civ/REQ_CIV) + ceil(imp/REQ_IMP))` | high | | `TradeRouteGrossIncome` | age < `STARTUP_TURNS` → `STARTUP_INCOME`; else `MIN_INCOME + Σ_class min(n, capLeft) x PERFREIGHTER[class]` (CRQ, CR, DE; capLeft from `MAX_FREIGHTERS`) x `(1 + STATION_BONUS_TRADE_INCOME x stations)` x `ADDICTION_TRADE_MOD` if addicted | high on the sum; medium on truncation order of the multipliers | | `TradeRouteIncome` | owner `x OWNERS_SHARE` (clamped 0..1), partner `x (1 − share)`, `x` AI difficulty trade multiplier | high | | `ComputeBankruptcyLimits` | `eliminationFloor (BnkEl) = max(ftol(maxIncome / −0.15), −2e9)` — the debt whose 15 % interest equals the maximum income; `protectionLimit (BnkPr) = max(−ftol(PROTECTION_LIMIT_FACTOR x maxIncome), BnkEl)`. The limits a turn's check reads are the ones computed at the end of the previous turn (and on load) | high — the 3.3 factor is on the protection limit; both expressions read with their constants | | `BankruptcyLevel` | 2 if `Sav < BnkEl`, 1 if `Sav < BnkPr`, else 0 | high | | `BankruptcyStep` | state first: any level change (0↔1, 1↔2 alike) stamps `startTurn = level ? turn : −1`; decisions on the *old* state: `costCutting = level != 0 && old.level != 0`, `eliminate = old.level == 2 && turn − old.startTurn >= BANKRUPTCY_ELIMINATION_TURNS` — both actions begin the turn after the level is reached | high — stamp-on-transition and act-on-old-state read from the code | | `SaturatingAdd` | clamp to ±2e9 | high | ## Research (`research.h`) | function | formula | confidence | |---|---|---| | `EdgeAvailability` | per-species chance parsed as pct/100; unlisted species default 1.0; explicit 0 excludes | high | | `RollEdgeAvailable` | include iff `mode == Everything` or `(p > 0 && (mode == NoRoll || p >= 1 || rand01() <= p))`; one draw only when 0 < p < 1 in Normal mode | high | | `TechCostMultiplier` | `max(0.25, 1 − 0.25 x n)` | medium — which three species techs count is unresolved | | `TechCost` | `INT_MAX` stays; else `max(1, ftol(base x mult))` | high | | `ResearchSpendFloor` / `ResearchSpendCeiling` | `lo = cost x 50 / 100`, `hi = cost x 150 / 100` — a **32-bit** multiply (it wraps near `INT_MAX`) and a truncating divide; then `lo = max(lo, 0)`, `hi = max(lo, hi)` | high — read off the instruction sequence | | `ResearchCompletionOdds` | `(progress − lo) / hi` evaluated in double and **narrowed to float32**: the original stores it in a 4-byte float slot before comparing | high | | `ApplyResearchPoints` | `spend = min(points, hi − progress)` — a signed min with **no floor at zero**; `progress += spend`; below `hi`: odds as above, `roll = rand01()` (also narrowed to float32), Zuul keep the lower of two rolls, zero spend → odds 0 / roll 1; at `hi`: odds 1 / roll 0, no draw; complete iff `!(odds < roll)` compared as float32; crossing 100 % without completing → over-budget event (flag 2); completing below `(double)0.8f` of cost, where the ratio is itself a float32, → "completed early" (flag 0). Only the words the original function writes itself: the unlock cascade is `SetResearched`'s | high | | `DecayResearchProgress` | `max(0, progress − ftol(cost x (double)0.05f))` — the image holds the widened float literal `0.05000000074505806`, and there is no special case for a node whose cost is still `INT_MAX` | high | | `DecayAllResearch` | applies to every node in state **Available (2)** whose progress is **non-zero** (not "positive"), after the allocation pass. The *current* research target is **not** among them: the tree marks the selected target with state **3**, so the funded node keeps its full gain and only idle partially-researched techs decay (live trace, `docs/B3.md`) | high — confirmed against the loop and the live trace | | `ProcessResearchTurn` | the whole per-turn pass: every allocation entry in order, then the decay sweep. This is the shape the shim hooks; see `docs/B3.md` | high | | `RollLabAccident` | `randint(100) < odds`, where `randint` is uniform on `[0, 100]` **inclusive** (see `docs/mars-rng.md`) | medium — odds-from-boost function unresolved (caller supplies odds) | | `LabAccidentLossPercent` | `ceil(clamp01(rand01() x (max − min) + min) x 100)` | high | ## Colonies (`colony.h`) | function | formula | confidence | |---|---|---| | `SpeciesTechFlags` | per-species xenotech bits in family order: translation 1/2/3 (bits 0–2), incorporate 3, addict 4, temperance 5, subjugate 6, accommodate 7, proliferate 8; `FromBits` unpacks a flag word | high on the order | | `HazardModifier` | `clamp01(1 − |suit − ideal| / (SuitTol + 0.1))` — linear, no exponent; SuitTol is the species start value plus the adaptation techs (+0.75 atmospheric, +1.5 gravitational); the caller passes 1 when it holds the accommodate xenotech for the species or is the rebel AI | high — read with its 0.1 constant | | `CarryingCapacity` | `ftoi64(Size x 1e8 x groupMult x speciesFactor x crossSpecies x hazard) + arcology (1e8 imperial / 2e8 civilian)`; clamp to group max; `x INDSYS_IMPERIAL_POPULATION_MOD` for NPC owners; NPC species or uninhabitable → 0 | high | | `PopulationGrowthDelta` | `g = clamp01((1 − clamp01(pop/cap))^EXP)`; if g > 0: `x MOD x PopMod x hazard/species x groupMult (if > 0)`; `delta = ftoi64(pop x g)`, min 1 when g > 0, max 50,000,000; blockade → 0 | high | | `ApplyImperialGrowth` | over cap: shrink by `min(5e7, pop − cap)` but not below `min(pop, 100)`; else `min(cap, pop + delta)` | medium — shrink floor read from a terse note | | `InfrastructurePointsNeeded` / `InfrastructureGain` | `ceil((1 − infra)/3.3e-5)`; `points x (1/500) x 0.01 x 1.65 = points x 3.3e-5` (≈30,300 points for 0→1) | high | | `DecayUnownedInfrastructure` | `max(0, infra − 0.02)` | high | | `TerraformPointsNeeded` / `TerraformDelta` | `|ideal − suit| / (1.5 x 1.2 / 20000)`; `points x 1.5 x 1.2 x TerraMod x sign / 20000` toward the ideal | high | | `SlaveDeathRate` | `(SRs x BYOUTPUT + |ideal − suit| x BYHAZARD + DEATH_RATE) x ((translation1 ? 0.8 : 1) − 0.2 translation2 − 0.2 translation3)` | high | | `SlaveDeaths` | `clamp(ftoi64(slaves x rate), MIN_DEATHS, MAX_DEATHS)`, `MAX −1` = uncapped, never more than present | high | | `NormaliseOutputRates` | negatives → 0; terraform → 0 at ideal; infra → 0 when full; rescale to Σ 1, equal split when all zero | high (the tiny positive threshold is treated as 0) | | `MoraleOutputMultiplier` | `>= INCREASE_OUTPUT → x INCREASE_MOD`; `<= DECREASE_OUTPUT → x DECREASE_MOD` | high | | `TotalSystemOutput` | `round(base x morale x (1 + STATION_BONUS_IMPERIAL_OUTPUT x stations) x addiction x ScOutMod x RebOutMod x techOut x sys.OutMod x OutMod)` | high on the chain; the base-from-population term is an input (unresolved) | | `SplitOutput` | `round(total x rate)` per channel | high | | `ConstructionPoints` | `round(cons x (1 + STATION_BONUS_SHIPCON x stations))` | high | | `SplitLeftover` | unspent construction over trade/terraform/infra by their rates, or `1 / (suit != ideal) / (infra != 1)` when construction was the only slider | medium | | `SuitabilityCostMod` | `min(|ideal − suit|, SuitTol)`; 0 for the rebel AI; 20 when unowned — the tolerance techs cap the money cost as well as widening the habitable band | high | | `SystemMoneyIncome` | `t = (trade − fmod(trade, 5)) x 5` (whole five-point blocks, five money each); `t += imperial + civilian + slave population income`; `t *= speciesIncomeFactor`; `t *= IncMod`; `t *= serverIncomeMod x difficultyIncomeMult`; `cost = speciesCostFactor x suitCostMod x 10000 x 1.5`; `money = ftol(t − cost)` | high on the chain and constants; the three population-income terms are inputs (their group-income tables, and the addiction factor inside them, are not modelled) | | `ApplyPopulationBonus` / `ApplyInfrastructureBonus` | `pop += min(bonus, cap − pop)`; `infra += min(bonus, 1 − infra)`; bonus reduced by the same | high | | `AccrueSystemBonus` | gated on stable, owned > MINTURNS, no rebellion > MINTURNS; `target = eligible ? ftol(max(POPBONUS, 0) x cap) : 0`; `pbon += min(max(ftol(POPBONUS_INC x cap), 0), max(target − pbon, 0))`; `ibon += min(max(INFRABONUS_INC, 0), max((eligible ? INFRABONUS : 0) − ibon, 0))`; Zuul are never eligible. The `*_HOME` keys are only read when a home system's bonus is initialised (not modelled) | high — increment, target and gating read with constants | | `ProcessBuildQueue` | FIFO: `points < conleft → conleft −= points, stop`; else complete, `points −= conleft`, charge money cost, continue | high | ## Movement (`movement.h`) | function | formula | confidence | |---|---|---| | pass schedule | departing/in-transit sets: two `dt = 0.5` passes; everything else one `dt = 1.0` pass | medium — constants established, bucketing semantics not fully | | `StraightStep` | `speed x dt` | high | | `NodeLineSpeed` | `speed x ((STUTTER_MAX − STUTTER_MIN) x (dist / INFLUENCE_RADIUS) + STUTTER_MIN)` — no clamp; only evaluated for chords inside a sphere, so `dist <= radius` by construction | high | | `DistPointToSegment` | closest-point distance with the projection parameter clamped to [0, 1] | high | | `BuildStutterSegments` | the travel line is intersected with every system's influence sphere; each chord (clipped to the line) becomes a segment, chords shorter than 0.01 are dropped, segments are sorted by start and overlaps are split at the midpoint of the overlap; the speed factor of a segment is `NodeLineSpeed(1, closest approach of the whole chord)` — per segment, not per position | high on chord/clip/drop/sort and per-chord speed; medium on the overlap rule (midpoint split; a chord swallowed by an earlier one is dropped) | | `AdvanceAlongNodeLine` | piecewise integration: segment speed inside a sphere, plain `nodeSpeed` in the gaps, never past the line end | medium — written from the per-segment description | | `ResolveMoveStep` | `range = minShipRange − 0.05`; no range at all and `range < distance` → step 0 (stranded); `move = min(step, range, distance)` ≥ 0; arrival when `move == distance` | high | | `ConsumeShipRange` | `max(0, range − moved)` unless exempt | high | | `RemainingPassTime` | `fraction < 0.9999 ? (1 − fraction) x dt : 0` (multi-waypoint recursion) | high | | `RollProbabilisticJump` | `roll = rand01() x castEfficiency`; `roll > castThreshold` → stop at fraction `roll`; else arrive | medium — identity of the two player fields inferred | ## Low-confidence list (flagged in headers) None. The five formulas that were low (bankruptcy limits, bankruptcy stamping, hazard curve, money tail, system-bonus increment) are pinned; each header carries a one-line rationale. Items still at **medium**: `TechCostMultiplier` (which three species techs count), `RollLabAccident` (odds-from-boost function), `ApplyImperialGrowth` shrink floor, `SplitLeftover`, `TradeRouteGrossIncome` multiplier truncation order, the movement pass bucketing, the probabilistic-jump field identities, the stutter overlap rule and `AdvanceAlongNodeLine`, and the meaning of the budget's secondary-manager slot. Tech effects on the player's modifiers (`OutMod`, `PopMod`, `TerraMod`, `SuitTol`, ...) live in `game/effects` — see `game-effects.md`. Not modelled here (out of scope for pure formulas, or unresolved): base output from population, the per-group population income tables (inputs to `SystemMoneyIncome`), civilian seeding rules, morale event deltas, rebellion rolls, plague, the research-boost → accident-odds function, the fleet speed (`FPsp2`) derivation from engine `ftlspeed`/`nodespeed`, gate traffic capacity, the home-system bonus initialisation, and the game-setup handicap multipliers (output / income / research) that feed `techIncomeMult` and friends.