22 KiB
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; RoundHalfEven is the engine's "round" -- fistp/fild, i.e. round
to nearest with ties to even, not away from zero (B4 correction); F32 narrows to float32
and back, which the colony and movement formulas do at every named store; 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 |
|
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 |
CarryingCapacity |
quantise(ftoi64((Size x 1e8) x (hazard x (groupMult x speciesFactor x crossSpecies)))) + arcology (1e8 imperial / 2e8 civilian, 0 for slaves); clamp to group max; x INDSYS_IMPERIAL_POPULATION_MOD and re-quantise for NPC owners. Size x 1e8 is an exact 64-bit integer product; quantise rounds down to a multiple of 10 above 10, so every capacity is a multiple of ten (B4) |
high |
PopulationGrowthFraction / PopulationGrowthDelta |
B4: there is no pop/cap term — the capacity never enters the growth chain. `base = 1 − clamp01(min( |
ideal − clamp(suit, 0, 20) |
ApplyImperialGrowth |
delta capped at 50,000,000 when non-negative; new <= cap → new; new > cap but pop <= cap → exactly cap (no shrink); both over cap → `max(pop − min( |
cap − pop |
InfrastructurePointsNeeded / InfrastructureGain / ApplyInfrastructureDelta |
ceil((1 − infra)/3.3e-5) with 3.3e-5 a true double; the gain is points/500 x 0.01 x 1.65 evaluated in that order on the x87, clamped at 0 and narrowed to float32 once — deliberately not folded into one x3.3e-5; the apply is a no-op at 1 and clamps there otherwise |
high |
DecayUnownedInfrastructure |
float32(infra − (double)0.02f), then result <= 0 → 0. The image holds the widened float literal 0.019999999552965164, not the decimal 0.02 |
high |
TerraformPointsNeeded / TerraformDelta / ApplyTerraformDelta |
`ceil( | float32(ideal − suit) |
SlaveDeathRate |
`(( | ideal − suit |
SlaveDeaths |
ftoi64(slaves x (rate + plagueRate)) — the worst plague at the system adds an additive rate term — then MIN/MAX_DEATHS, each disabled by any negative value, then clamped into [0, slaves] |
high |
NormaliseOutputRates |
B4: the trade slider is pinned. Terraform → 0 at the ideal (exact ==); infra → 0 when float32(ibon + infra) >= 1; any channel <= (double)1e-4f → 0; only trade is clamped to [0, 1]; the other three are summed in float32 and rescaled to (r/Σ) x (1 − trade); an exactly-zero sum seeds them with 1e-4f — an equal split over three channels, not four |
high — read step by step |
MoraleOutputMultiplier |
>= INCREASE_OUTPUT → x INCREASE_MOD; <= DECREASE_OUTPUT → x DECREASE_MOD |
high |
TotalSystemOutput |
roundHalfEven(base x morale x (1 + STATION_BONUS_IMPERIAL_OUTPUT x stations) x addiction x ScOutMod x RebOutMod x techOut x sys.OutMod x OutMod), kept as a double for the channel splits |
high on the chain and the rounding mode; the base-from-population term is an input (unresolved) |
SplitOutput |
roundHalfEven(total x rate) per channel, each kept as a double |
high |
ConstructionPoints |
trunc(cons x (1 + STATION_BONUS_SHIPCON x stations)) — truncating, not rounding |
high |
SplitLeftover |
unspent construction over trade/terraform/infra by their rates, or 1 / (suit != ideal) / (infra != 1) when the construction rate is exactly 1; each share is rounded half-to-even independently, so the three need not add back up to the leftover |
medium on the weights, high on the rounding |
SuitabilityCostMod |
`min( | ideal − suit |
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(cap − pop, bonus) with an unowned system dropping the whole pool and an at-or-over-cap colony short-circuiting; infra += min(bonus, float32(1 − infra)), snapping to exactly 1.0 when the pool covers the remainder; bonus reduced by the same. Either firing on a colony that is not the owner's home system resets ntdev to 0, which then costs it the system-bonus gate that same turn |
high |
AccrueSystemBonus |
gated on stable, owned > MINTURNS, and ntdev > MINTURNS (not the rebellion turn — B4 correction); 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: conleft > points → conleft −= points, points = 0, stop; else charge the money cost — a refusal skips that order and continues rather than stopping — then points −= conleft, conleft = 0, continue. The leftover is the return value, and removal is a separate sweep that unlinks every order at or below zero |
high |
| CountdownFor / SetCountdown / TickCountdowns | the system's Bats2 and rcex words carry a 4-bit countdown per player at bits [4i, 4i+4), with a companion 32-bit "someone is counting" mask. Each turn every counter ticks down by one and a player whose counter is already zero loses its mask bit; index >= 15 has no nibble and is skipped entirely, and the whole sweep is skipped when the counter word is zero | high |
| AddictionPhaseOf | adt[species] == 0 → none; else elapsed = turn − adt, elapsed > PHASE3_START → terminal, elapsed > PHASE2_START → established, else onset. Both comparisons strict | high |
| ProcessColonyTurn | the whole per-system pass, restricted to what ServerSystem::ProcessTurn writes itself: unowned infra decay → both bonus pools (either resets ntdev on a non-home colony) → ntdev ++/reset → AccrueSystemBonus → TRes = 0 → haltv cleared → both countdown sweeps → the addiction sweep, which raises morale event 0x1b (−1) under temperance, 0x1c (+1) at onset, 0x1d (−2) at terminal, and nothing at all in the established phase. Consumes no RNG; see docs/B4.md for the input boundary | high on every step; the callees are not modelled |
Movement (movement.h)
| function | formula | confidence |
|---|---|---|
PlanFleetMovement |
the turn's schedule is a pursuit model, not a departing/in-transit split: a fleet whose current waypoint targets another fleet is a pursuer when the two owners have no relation and a follower otherwise. Prey move dt 0.5; pursuers move dt 0.5 and a pursuer that arrives retires itself and its prey; surviving prey take a second 0.5; everything unscheduled takes 1.0 (an uncaught pursuer takes another 0.5); followers take 1.0 |
high — read pass by pass |
IsGateTransitWaypoint / IsNodeWaypoint |
type 4 or 5, and type 3 only, respectively. The node-line case of the movement switch is type 2, which IsNodeWaypoint does not accept |
high |
GateTrafficTotals |
per player, the sum of a signed int16 per fleet whose front waypoint is a gate transit; assigned, not accumulated, so it resets every turn | high |
StraightStep |
float32(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 |
SegmentSphereIntersect / BuildStutterSegments |
chord parameters scaled to world distance and clamped to [0, length]; a chord with ` |
start − end |
NodeLineStep |
walk the segments in ascending order at plain nodeSpeed between influence spheres and at the segment's own constant speed inside one, until the line ends or dt runs out; arrival is time < dt or ` |
along − length |
FleetMinShipRange / ResolveMoveStep |
range = float32(minShipRange + 0.05f) — the grace margin is added (B4 correction; the constant's sign bit is clear and the helper adds it). If distance > range and the un-biased minimum is exactly 0, the range is zeroed — not the step, which stays the divisor of the pass fraction. move = min(min(range, step), distance) in that order, with no floor at zero; arrival when move == distance, after which the position is copied from the destination verbatim. An empty fleet's minimum range is FLT_MAX, and no ship is excluded — a range-exempt tanker still clamps the fleet |
high |
AdvanceAlongDirection / ConsumeShipRange |
pos + unit(dest − pos) x move, each component narrowed to float32 after the multiply and before the add; max(0, float32(range − moved)) unless the ship carries the range-exempt flag, in which case it pays nothing |
high |
PassFraction / RemainingPassTime |
a full pass unless the waypoint is type 3, which reports clamp01(distance/step); a blocked move reports clamp01(move/step). Recursion iff fraction < 0.9998999834060669 strictly — an 8-byte constant whose value is exactly (double)0.9999f — with dt' = float32((1 − fraction) x dt) |
high |
RollProbabilisticJump |
v = float32(rand01() x CstE); arrives iff !(v > CstT) (equality arrives). On a miss the fleet is placed at dest + randomUnitVector x v — scattered around the destination by v, not advanced a fraction along the vector (B4 correction) — and the random direction costs a second draw, so a jump consumes 1 word on success and 2 on a miss. The pass fraction is 1.0 either way, so a type-5 waypoint never recurses. No fuel is charged and no distance clamp applies |
high on the arithmetic and the draw count; medium on the field identities |
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. B4 promoted five more to high by reading them instruction by instruction —
ApplyImperialGrowth's shrink, the movement pass schedule, the stutter overlap rule, the
node-line step and the probabilistic jump's arithmetic. Items still at medium:
TechCostMultiplier (which three species techs count), RollLabAccident (odds-from-boost
function), SplitLeftover's weights, TradeRouteGrossIncome multiplier truncation order,
the identity of the two probabilistic-jump player fields, 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, the morale-event application (ProcessColonyTurn reports the
addiction events it would raise, but not what the owner does with them), 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.
ComputeOutputFromRates was read in full for B4 and every correction is folded into the
table above, but the function itself is not side-effect free — it repairs damaged ships
in orbit — so it has no compare hook; see docs/B4.md. The unspent terraforming points
cascade into the money channel, an edge the split functions here do not yet carry.