Compare commits

..

No commits in common. "aabd8a3506028c9292b4c7d82204e22605672be3" and "5d01c7c7de4a9f2a3e2c045d4392a77d3a2aecbf" have entirely different histories.

14 changed files with 36 additions and 820 deletions

View file

@ -1,82 +0,0 @@
# Civilian population growth, and a savings-interest correction (lane G3)
The colony sub-pass that runs between the build queue and the resource ledger inside each
system's turn, plus the per-ship repair-cost term that was the last unmodelled input of the
output turn path, plus a one-money defect in `ComputeBudget` that landing the first of those
exposed.
## What decides the value
The whole system's civilian delta is clamped to **20,000,000** per turn. That number is an
int64 column of the three-row population-type table, which the executable builds from its own
literals; it is not a data-file value and it is not a carrying capacity. On both reference
pairs the uncapped delta is 150,000,005 and the capacity headroom is 500,000,000, so the clamp
is what produces the answer and neither the growth curve nor the capacity chain can change it
without being wrong by more than an order of magnitude.
That is why the pass commits with **no tuning table loaded**: the growth fraction it computes
under an unloaded table (0.25) and under the shipped one (0.30000001192092896) both land on the
same committed value, and so would anything in `[0.04, 1.0]`.
Ranked by consequence, the float that actually decides the value is neither of those. It is the
**rescale**, `trunc(applied x (clamped / total))`. The relative error of the quotient reaches
`2^-53` and the product's absolute error can exceed half an ulp of 20,000,000, so a
single-species colony landing exactly on the cap is a measurement, not a theorem. It does, at
both 53-bit and x87 64-bit precision, on both pairs; `tests/game_sim/test_colony.cpp` pins it.
One ulp the other way costs a whole person and moves the human's savings.
## The capacity, and how it is handled without the data files
`MaxPopGeneric` multiplies `Size x 1e8` by a per-species, per-group factor that lives in the
data files and is nowhere on the wire. Rather than assume it, the phase runs the pass **twice**:
once with the modelled capacity and once with the capacity discarded in favour of the system's
own `dcs` limit, which *is* on the wire, and commits only when the two agree. It also reports
the threshold the factor would have to fall below before a committed value moved (0.260 on the
reference pair), against a lower bound of 0.27 that the observed growth itself establishes.
For the imperial group the corpus pins the factor exactly, with no data files at all: Gamma
Cephei's pending population bonus of 1e9 never drains, and the bonus apply returns exactly when
`Pop >= MaxPop`, so `MaxPop <= 1e9`; and its `Pop` never shrinks, and the imperial apply shrinks
exactly when `pop > cap`, so `MaxPop >= 1e9`. Two behaviours, one capacity, no assumption.
**Imperial growth is deliberately not committed.** It is a no-op on this corpus and committing
it would need a capacity the corpus can bound from below but not from above; that trades a
regression risk for nothing.
## The interest literals
Landing growth left the human's savings **one money high** on the first reference pair and exact
on the second. The residual was not growth. `ComputeBudget` multiplies a treasury by widened
**float** literals — `(double)0.01f` and `(double)0.15f` — and then truncates, so a treasury of
exactly 50,000 earns 499, not 500. This module used the exact decimals. Corrected, with the
constants named in `game/sim/economy.h`; sixteen hand-computed test expectations moved by one.
Worth recording why it survived: `ComputeBudget` has been compared live against the original for
**4,437 calls with 0 divergences**. That run presented only 20 distinct states and none of them
sat on a rounding boundary. A green behavioural compare is not coverage.
## Ship repair cost
`ShipRepairCost` is `max(0, buildTarget - (buildProgress + (allowance ? designAllowance : 0)))`
— plain 32-bit integers. The ship's `ConCap` word is the *progress*, not a capacity: the repair
apply adds points to it and clamps it to the design's build target.
The demand is still taken as 0, because the two design fields are cached stats the save does not
carry. What changed is that the zero is now **evidenced**: the phase reports the candidate set,
and the independent colony keeps a ten-ship fleet in orbit over a colony whose savings close
exactly with the demand at zero — which cannot happen if any of those hulls carried a cost.
## Result
Reference pair `turn1 -> turn2`: **81 leaves closed, 0 regressed** (was 78/0). Pair
`turn2 -> turn3`: **39 closed, 0 regressed** (was 36/0). The three new leaves per pair are the
two colonies' civilian population and the human's savings. With `--commit-blocked=T31
--ai-player 1` the counts are **83/0** and **41/0**; the extra two are the human's and the AI's
bankruptcy elimination limits, which that phase could not close before because they move with
the population.
Every gate ran as a separate command: clean-room OK, host `ctest` 49/49, and the CT111 shim
cross-build (exit 0) — required here because `game/sim` is compiled into the shim.
The reverse-engineering evidence, the falsification table and the honest list of what no corpus
save exercises are in the notes repo, `findings/subsystems/population-growth.md`.

View file

@ -1,5 +1,5 @@
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
// Source: sots-re ghidra/addresses.json @ d4f9404, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ b77a611, generated 2026-09-08 by tools/gen_addresses.py
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
#pragma once
#include <cstdint>
@ -1657,8 +1657,6 @@ constexpr uint32_t ServerSystem_OnExploredChanged = 0x003480b0;
constexpr uint32_t StarSystem_PlaceEncounter = 0x003887c0;
// fastcall void (StrategyServer* S) // 948 B, tail phase 17. Four passes: (1) walk the per-(system, player) view tree at S+0x228 and drop entries whose player can no longer see the system, then clear the tree and reset S+0x22c = 0; (2) per player x per system, if IsVisibleTo, FUN_0075f550; (3) per player x per system, if IsVisibleTo, ServerSystem_RecordObservation(sys, player, sys->+0x184) -- byte-decoded at 0x007cf7a7..0x007cf7ce, this is the ONLY caller of the NVE writer outside intel sharing; (4) per system x per player, if IsExploredBy and a colony exists, build a PlayerView via 0x00755ab0/0x007561d0 and apply it. Draw-free [mapped]
constexpr uint32_t StrategyServer_RebuildPlayerViews = 0x003cf560;
// none the three-test gate that guards EVENT_NO_RESEARCH; the post itself is ServerPlayer_ProcessTurn_PostEventNoResearch 0x0089168c. Read byte for byte (ReVa read-memory 0x00891600+160, decoded by hand): cmp [esi+0x294],ebx / jne past the whole block -> test 1, ResT == NULL. Then a 12-byte stack vector is zeroed at [ebp-0x28] and TechTree::CollectResearchedTechs 0x00584e50 is called as ecx = this->TechTree(+0xf4), push 1 (sort), push 0x7fffffff (maxTurn), push [[esi+8]+8] (the SERVER TURN -- the same word the post below hands PostEvent as its turn argument), push &out; then cmp [ebp-0x28],[ebp-0x24] / jne past -> test 2, THE LIST CAME BACK EMPTY, i.e. NO TECH WAS RESEARCHED ON THIS TURN OR LATER. Then ecx = this->TechTree again, call TechTree::FindFirstAvailableTech 0x0057da90, test eax,eax / setne al / cmp al,bl / je past -> test 3, at least one node is in state 2. CORRECTION to the note on ServerPlayer_ProcessTurn_PostEventNoResearch, which reads 0x00584e50 as a ListAvailableTechs and the gate as 'no tech is available': it is TechTree::CollectResearchedTechs (lane T's reading, confirmed here from the call site's argument order) and the gate is about what was RESEARCHED, not about what is available -- test 3 is the availability half. The turn argument is passed as CollectResearchedTechs' minTurn, so the range is [turn, INT_MAX] over node->turnResearched(+0x24) [mapped]
constexpr uint32_t ServerPlayer_ProcessTurn_NoResearchGate = 0x0049162a;
// cdecl void (const char* gamename) /* GameSpy SDK gsAvailable. sprintf("%s.available.gamespy.com"), inet_addr/gethostbyname, UDP socket, sendto port 27900 (htons 0x6cfc) with '\x09\0\0\0\0' + gamename + NUL, len = strlen(gamename)+6. Overridable hostname buffer at 0x00b085b0. Leaves socket = -1 on DNS failure. */ [verified]
constexpr uint32_t GameSpy_GSIStartAvailableCheck = 0x0000a060;
// cdecl int (void) /* returns 0=waiting 1=available 2=unavailable 3=temporarily-unavailable. Socket==-1 (start failed) => returns 1. Retries once after 2000 ms then returns 1. */ [verified]
@ -1745,40 +1743,6 @@ constexpr uint32_t g_LanScanPortRange = 0x00713c70;
constexpr uint32_t g_GameSpyAvailableHostOverride = 0x007085b0;
// data const char* /* when non-NULL, replaces "<gamename>.ms<N>.gamespy.com" in SBServerListConnect */ [mapped]
constexpr uint32_t g_GameSpyMasterHostOverride = 0x00709440;
// thiscall void (ServerSystem* sys) // plain `ret`, REAL END 0x00754b59 -- the body is a loop over group types 0,1,2 whose back edge is at 0x00754b2e (`inc esi; cmp esi,3; jl 0x7543e9`) and lies outside every decompiler `if`, so the function reads as straight-line code if you stop at the first `ret`. Returns immediately when the system has no owner. Per group type: skip when Population::TotalOfType(Pop2, t) <= 0, then skip unless t == 1 -- so only CIVILIANS grow here. Per species: delta = PopGrowthDelta(1, sp); cur = Count(Pop2,1,sp) + Count(pbon2,1,sp); cap = MaxPopGeneric(1, sp, PID, NULL); soft = CivilianSettleLimit(sp); limit = soft < cap ? soft : cap (and soft < cap with cur+delta > soft raises a per-species settle flag); applied = min(delta, limit - cur); haltv[1] with applied > 0 zeroes both. The SYSTEM TOTAL is then clamped into [-50,000,000 (a literal here), POPTYPE[1]+0x08 = 20,000,000] and, when the clamp bit, every entry of the clamp's own sign is rescaled by trunc(applied x (clamped / total)) with NO renormalisation. Write-back is Population::SetCount(Pop2, 1, sp, Count(Pop2,1,sp) + applied) -- note the headroom counted pbon2 and the write does not. THE 20,000,000 CLAMP IS WHAT DECIDES THE VALUE on both reference pairs: the uncapped delta is 7.5x it and the capacity headroom 25x it [verified]
constexpr uint32_t ServerSystem_GrowCivilianPops_G3 = 0x00354220;
// thiscall int64 (ServerSystem* sys, int species) // `ret 4`, ends 0x0074aa2d. Returns 0 without an owner. Otherwise min( MaxPopGeneric(1, sp, PID, &StrategyServer::IdealSuit(server, sp)) , Population::Count(sys->dcs /*+0x104*/, 1, sp) ). The first term is the capacity the colony would have if the planet were perfectly suited; the second is the system's own `dcs` Population, which is on the wire. On Gamma Cephei `dcs` = 1e9 and it is the binding limit, not either capacity [verified]
constexpr uint32_t ServerSystem_CivilianSettleLimit = 0x0034a9a0;
// thiscall int64 (ServerSystem* sys, int groupType, int species, ServerPlayer* p, float* suitOverride) // `ret 0x10`, REAL END 0x0074a6cd -- 0x0074a6d0 is a DIFFERENT function (lane N and E1 both cite 0x0074a6d0 for the capacity-surplus pair; that is the next one along, not this). Returns 0 when species == 4; when the OWNER species' SpeciesDef+0x168[groupType] is not > 0 (which is why Zuul have no civilians); when groupType != 0 and the owner is RebAI; when groupType == 1 and bit 3 of ServerPlayer+0x348[species] is clear; and when groupType == 2 and 0x0082bdf0(p, species) is false. Otherwise ftoi64( (int64)Size x 1e8 x [ hazard x (POPTYPE[t]+0x20 x SpeciesDef(sp)+0x168[t] x (crossSpecies ? SpeciesDef(sp)+0x174[t] : 1)) ] ) via 0x00535eb0, plus the arcology flat bonus 0x0080dd30(p, t), then clamped to the int64 at POPTYPE[t]+0x28 -- see PopTypeTableStaticInit, that clamp is always a no-op -- and finally, for groupType 0 ONLY, scaled by INDSYS_IMPERIAL_POPULATION_MOD when the owner species is 4 [verified]
constexpr uint32_t ServerSystem_MaxPopGeneric_G3 = 0x0034a4a0;
// thiscall float (SpeciesDef* d, int groupType) // `ret 4`, 15 bytes. Returns d->float[0x168 + groupType*4]. The per-species, per-population-group capacity factor -- a DATA FILE value. Its imperial entry for Human is pinned at exactly 1.0 by the corpus (Gamma Cephei's pbon never drains and its Pop never shrinks, which bracket the imperial capacity at Size x 1e8); the civilian entry is bounded below at 0.27 by the observed growth and is otherwise unmeasured [verified]
constexpr uint32_t SpeciesDef_GroupCapacityFactor = 0x0013bb00;
// thiscall float (SpeciesDef* d, int groupType) // `ret 4`, 15 bytes. Returns d->float[0x174 + groupType*4]; applied by MaxPopGeneric only when the system owner's species differs from the population species and is not 4 [verified]
constexpr uint32_t SpeciesDef_GroupCrossSpeciesMod = 0x0013bb20;
// thiscall bool (SpeciesDef* d, int groupType) // `ret 4`. Returns `0.0 < d->float[0x168 + groupType*4]` -- the SAME array SpeciesDef_GroupCapacityFactor reads, so 'this species has this population group' and 'its capacity factor is positive' are one fact. MaxPopGeneric asks it about the system OWNER's species, not the population's [verified]
constexpr uint32_t SpeciesDef_HasPopulationGroup = 0x0013bb40;
// cdecl float (ServerPlayer* p, int species, float suit) // ends 0x00536f8b. Returns 20.0 and logs when p is null; 0.0 when p->RebAI (+0xfc) -- the 'accommodated' case; otherwise min( float32(|float32(StrategyServer::IdealSuit(p->server /*+0x8*/, species) - clamp(suit, 0, 20))|) , p->SuitTol /*+0xb4*/ ). The ideal is the SERVER's per-species baseline (the save's ISsu array), NOT the player's own IdealSuit field [verified]
constexpr uint32_t PopGrowthSuitabilityDistance = 0x00136eb0;
// cdecl int64 (ServerPlayer* p, int groupType, int species, float suit, float factor, int64 pop) // `add esp,0x1c`, 7 dwords of arguments, ends 0x005371ca. Returns 0 unless pop > 0; g = PopGrowthFraction(p, groupType, species, suit, factor); returns 0 when g == 0 (an fucomp against zero, so a NaN g also proceeds); d = ftoi64(g x pop); returns d, or exactly 1 when d == 0 and g is strictly positive, or 0 when d == 0 and g is negative [verified]
constexpr uint32_t PopGrowthDeltaHelper = 0x00137140;
// cdecl void () // the CRT static initialiser for the population-type table at 0x00b104e8, and THE ONLY WRITER of the group-ceiling field. It sets every row's +0x28 to 0xffffffff and +0x2c to 0x7fffffff -- the int64 INT64_MAX -- along with +0x00/+0x04/+0x08/+0x0c to -1/1.0/-1/-1 and the float columns to 1.0. InitPopTypeTable 0x00535ca0 runs later and overwrites +0x00 through +0x20 but NEVER touches +0x24/+0x28/+0x2c. So MaxPopGeneric's group-ceiling clamp is present, always enabled, and always a no-op; a reader who opens only InitPopTypeTable sees zero there and would cap every carrying capacity in the game at nothing [verified]
constexpr uint32_t PopTypeTableStaticInit = 0x005abe20;
// thiscall int (StarShip* sh, bool useAllowance) // `ret 4`, REAL END 0x008151b7 (0x008151ba..0x008151bf is int3). max(0, design->+0xcc - (sh->ConCap /*+0x68*/ + (useAllowance ? design->+0xd0 : 0))). No floating point, no clamp but the floor at zero. Its only caller, RepairShipsInOrbit, always passes 1. UNEXERCISED on the corpus, and that is a measurement rather than an absence: the independent colony's fleet sits over Koa'Vo on both reference pairs and that player's Sav closes exactly with the demand taken as zero [verified]
constexpr uint32_t Ship_RepairCost_G3 = 0x00415180;
// thiscall void (StarShip* sh, int points) // `ret 4`, ends 0x00815221. Does NOTHING unless the ship's cached role word (+0x18, not on the wire) carries bit 0x400000 -- a different bit from the one RepairShipsInOrbit's candidate filter tests, so a ship can be charged points that never reach it. Otherwise sh->ConCap += max(points, 0) and then ConCap = min(max(ConCap, 0), design->+0xcc). THIS IS WHAT NAMES THE TWO FIELDS: ConCap is the construction invested in the hull so far -- not a per-turn capacity, despite the save-format name -- and design+0xcc is its ceiling [verified]
constexpr uint32_t Ship_ApplyRepair_G3 = 0x004151c0;
// cdecl-through-register int (ServerPlayer* owner) /* the system arrives in EBX */ // ends 0x0074615a. CORRECTION to output-turn-path.md, which names this SystemRepairDemandForOwner: it is NOT a repair function. It walks the fleets at the system through the system's own vtable slots 2 and 3, skips a fleet whose owner is not the argument or whose +0x78 byte is clear, and sums 0x0081f8c0 over each ship -- and 0x0081f8c0 gates on the design's CARRIED-POPULATION bit (design->+0xb8 & 0x04000000) and computes GroupIncome over the ship's Population at ship+0x9c. So ComputeOutput's out[6] is the income of population carried in slaver/colony hulls in orbit, which is a slot the engine's BudgetInputs already has, and it feeds no save leaf [verified]
constexpr uint32_t ServerSystem_ShipCarriedPopIncome = 0x003460b0;
// thiscall int (StarShip* sh) // returns 0 unless design->+0xb8 & 0x04000000 (the carried-population bit lane B6 named); otherwise sums GroupIncome over the ship's `hsp` Population at ship+0x9c for group types 0 and 1. Body read to the second GroupIncome call only [unverified]
constexpr uint32_t ShipCarriedPopIncomePerShip = 0x0041f8c0;
// data double 0.009999999776482582 = (double)0.01f -- the savings-interest multiplier ComputeBudget uses at 0x008631ce, then truncated by _ftol2. It is a WIDENED FLOAT, not the exact decimal, and the difference is not cosmetic: a treasury of exactly 50,000 earns 499, not 500. That one money was the whole residual on the human's Sav after civilian growth landed [verified]
constexpr uint32_t g_SavingsInterestRate = 0x005e31c0;
// data double 0.15000000596046448 = (double)0.15f -- the debt-interest multiplier ComputeBudget uses at 0x008631b0. The same constant lane E1 already carries, negated, as the bankruptcy-elimination divisor [verified]
constexpr uint32_t g_DebtInterestRate = 0x005ed188;
// data float 1.5 -- the per-call growth factor PopGrowthDelta 0x00748100 substitutes for 1.0 when the system's GFlags (+0xdc) carries the owner's PlyrIdx bit. It is a BONUS, not a penalty. UNEXERCISED: GFlags is 0 for the growing player on every corpus system [verified]
constexpr uint32_t g_FlaggedSystemGrowthFactor = 0x0061b000;
// data float 0.25 -- POPTYPE[1]+0x04, the civilian growth column, read out of InitPopTypeTable's six-register fxch rotation. Independently re-derived this lane; lane N's whole table is reproduced (imperial +0x04 = 1.0 from 0x009e2ea0's neighbours, civilian +0x10/+0x14 = 0.33f from 0x009f8d48, civilian +0x1c = 0.5, civilian +0x20 = 2.0 from 0x009e5840) [verified]
constexpr uint32_t g_PopTypeCivilianGrowthMod = 0x005e5ac0;
// thiscall int (void* this, int, int) // the ONE callee through which Game::ServerSpyManager vftable 0x00a3073c slot 13 (0x008877b0) reaches the strategic generator. Exactly ONE incoming reference in the image: an unconditional call at 0x00887af2 inside that slot. Real body 0x008408e0..0x00840a59, 370 bytes. Contains three of lane V2's eight draw sites -- Chance 0x00840929, NextInt 0x008409c7, Chance 0x00840a3c -- all loading the generator as [reg+0x16c]. Named for the verified relationship (which slot reaches it, and that it draws), NOT for any semantics: its body was not read. Lane H hooks it with a register-transparent entry counter so that 'the spy path never fired' can be split into 'slot 13 was not entered' and 'slot 13 was entered and gated above this call' [mapped]
constexpr uint32_t SpyManager_Slot13RngCallee = 0x004408e0;
// thiscall uint (void* fleet, char) // first of the two callees through which Game::ServerTradeManagerImpl vftable 0x00a31b74 slot 13 (0x0088ef80) reaches the strategic generator. Exactly ONE incoming reference: an unconditional call at 0x0088f036. Real body 0x00820ca0..0x00820e53, 436 bytes, containing lane V2's NextFloat site 0x00820e18. Its own body loops the vector at arg+0xa4/+0xa8 testing per-element flags, and its boolean result is what gates the call to TradeManager_Slot13RngCalleeB one instruction later. THE CALLER'S LOOP IS OVER THE FLEETS VECTOR at GetServer()+0x64/+0x68, not over a trade-route list -- which is why 'the tail draws nothing because we have no trade routes' does not explain this slot [mapped]

View file

@ -11,7 +11,6 @@ add_library(sots_app STATIC
turn_record.cpp
construction_phase.cpp
event_phase.cpp
growth_phase.cpp
visibility_phase.cpp
turn.cpp
report.cpp)

View file

@ -1,275 +0,0 @@
#include "app/growth_phase.h"
#include <algorithm>
#include <cstdarg>
#include <cstdio>
#include <map>
#include "game/sim/colony.h"
#include "game/sim/numeric.h"
#include "game/sim/species.h"
namespace sots::app {
namespace {
using mars::stream::shapes::Player;
using mars::stream::shapes::Population;
using mars::stream::shapes::SaveGame;
using mars::stream::shapes::Sys;
std::string fmt(const char* f, ...) {
char buf[640];
va_list ap;
va_start(ap, f);
std::vsnprintf(buf, sizeof buf, f, ap);
va_end(ap);
return std::string(buf);
}
std::int64_t CountOf(const 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;
}
bool HaltedFor(const Sys& s, int group) {
for (const auto& h : s.halt)
if (h.haltt == group) return h.haltv;
return false;
}
// `Population::SetCount(1, sp, n)`: the group row is rewritten in place, and a species that
// has no row is left alone (the corpus never seeds one -- see the seeding note in the finding).
bool SetCivilianCount(Population& p, int species, std::int64_t n) {
for (auto& g : p.groups) {
if (g.popT == 1 && g.popS == species) {
if (g.popC == n) return false;
g.popC = n;
return true;
}
}
return false;
}
} // namespace
GrowthPhaseResult RunCivilianGrowth(SaveGame& game, const sim::TuningTable& tuning,
bool haveTuning) {
GrowthPhaseResult r;
// The system's `PID` is the owner's OBJECT id, not its index in the player vector.
std::map<std::int32_t, const Player*> owners;
for (const auto& e : game.sim.players) owners[e.playerID] = &e.player;
std::vector<float> idealSuit;
for (const auto& sp : game.sim.species) idealSuit.push_back(sp.issu);
int noOwner = 0, noCivilians = 0, heldBack = 0;
std::int64_t worstMarginNumer = 0; // smallest (limit - current) seen, over the step cap
bool anyMargin = false;
double smallestCapacityFactorThatStillWorks = 0.0;
bool anyFactorBound = false;
for (auto& e : game.sim.systems) {
Sys& s = e.sys;
auto it = owners.find(s.pid);
if (s.pid == 0 || it == owners.end()) {
// `GrowCivilianPops` returns immediately when the system has no owner.
for (const auto& g : s.pop2.groups)
if (g.popT == 1 && g.popC > 0) { ++noOwner; break; }
continue;
}
const Player& p = *it->second;
bool anyCivilian = false;
for (const auto& g : s.pop2.groups)
if (g.popT == 1 && g.popC > 0) anyCivilian = true;
if (!anyCivilian) { ++noCivilians; continue; }
++r.systemsVisited;
const bool halted = HaltedFor(s, 1);
const auto ownerSpecies = static_cast<sim::Species>(p.species);
const bool ownerHasCivilians = ownerSpecies != sim::Species::Zuul; // see below
sim::CivilianGrowthRow modelled[sim::kSpeciesCount] = {};
sim::CivilianGrowthRow wireOnly[sim::kSpeciesCount] = {};
std::int64_t before[sim::kSpeciesCount] = {};
for (int sp = 0; sp < sim::kSpeciesCount; ++sp) {
const std::int64_t pop2 = CountOf(s.pop2, 1, sp);
const std::int64_t bonus = CountOf(s.pbon2, 1, sp);
before[sp] = pop2;
// --- the curve -------------------------------------------------------------
sim::GrowthInputs gi;
gi.pop = pop2 + bonus; // GroupPopulation(1, sp) = Pop2 + pbon2
gi.blockaded = halted;
gi.suitability = s.suit;
// The distance helper reads the SERVER's per-species baseline, not the owner's
// own `IdealSuit` field. In this corpus they agree; the difference is read, not
// measured.
gi.idealSuitability = sp < static_cast<int>(idealSuit.size())
? idealSuit[static_cast<std::size_t>(sp)]
: p.idealSuit;
gi.suitTolerance = p.suitTol;
gi.accommodated = p.rebAI;
gi.playerPopMod = p.popMod;
// The system's `GFlags` carries a per-player bit that swaps the curve's per-call
// factor from 1 to 1.5. UNEXERCISED: zero for the growing player everywhere.
gi.extraFactor =
(p.plyrIdx >= 0 && p.plyrIdx < 32 &&
(s.gFlags & (1 << p.plyrIdx)) != 0)
? sim::kFlaggedSystemGrowthFactor
: 1.0;
gi.groupGrowthMult = sim::kCivilianGrowthMult;
const std::int64_t delta = sim::PopulationGrowthDelta(gi, tuning);
// --- the capacity ----------------------------------------------------------
sim::CapacityInputs ci;
ci.planetSize = s.size;
ci.species = static_cast<sim::Species>(sp);
// `MaxPopGeneric` gates on the OWNER species carrying this population group at
// all (`SpeciesDef+0x168[1] > 0`), which is how Zuul end up with no civilians.
ci.speciesCanLive = ownerHasCivilians;
ci.group = sim::PopGroup::Civilian;
ci.groupCapacityMult = sim::kCivilianCapacityMult;
ci.speciesGrowthFactor = sim::ConstantsOf(static_cast<sim::Species>(sp)).growthFactor;
ci.ownerIsDifferentSpecies =
p.species != 4 && p.species != sp;
ci.crossSpeciesMod = 1.0; // SpeciesDef+0x174[1]; not on the wire, never applied here
ci.hazardMod = p.rebAI ? 1.0
: sim::HazardModifier(s.suit, gi.idealSuitability, p.suitTol);
ci.arcologyTech = p.harcc;
ci.groupMaxEnabled = true;
ci.groupMax = sim::kGroupPopulationCeiling;
ci.ownerIsNpc = false; // the INDSYS multiplier is imperial-only (group 0)
const std::int64_t capacity = sim::CarryingCapacity(ci, tuning);
// `CivilianSettleLimit` = min(capacity at the species' IDEAL suitability, dcs).
sim::CapacityInputs ideal = ci;
ideal.hazardMod = 1.0; // the override makes suit == ideal, so the hazard is 1
const std::int64_t idealCapacity = sim::CarryingCapacity(ideal, tuning);
const std::int64_t dcs = CountOf(s.dcs, 1, sp);
modelled[sp].delta = delta;
modelled[sp].current = pop2 + bonus;
modelled[sp].capacity = capacity;
modelled[sp].settleLimit = std::min(idealCapacity, dcs);
// The same pass with the modelled capacity discarded: `dcs` alone is a wire-known
// upper bound on the true limit, so agreement between the two means the capacity
// chain -- the one input that is not on the wire -- did not decide anything.
wireOnly[sp] = modelled[sp];
wireOnly[sp].capacity = INT64_MAX;
wireOnly[sp].settleLimit = dcs;
const std::int64_t limit =
modelled[sp].settleLimit < capacity ? modelled[sp].settleLimit : capacity;
const std::int64_t headroom = limit - modelled[sp].current;
// The margin is only meaningful for a species the step cap actually held back: a
// species stopped dead by its settle limit has a headroom of zero by definition
// and reporting it as the margin would say nothing about the capacity chain.
if (delta > 0 && headroom >= sim::kCivilianGrowthStepCap) {
if (!anyMargin || headroom < worstMarginNumer) {
worstMarginNumer = headroom;
anyMargin = true;
}
// The capacity factor would have to fall below this before the committed value
// moved: the capacity is linear in it, and what is needed is
// `current + min(delta, cap) >= current + committed`.
if (s.size > 0) {
const double need =
static_cast<double>(modelled[sp].current +
std::min<std::int64_t>(delta,
sim::kCivilianGrowthStepCap)) /
(static_cast<double>(s.size) * 1e8 * sim::kCivilianCapacityMult);
if (!anyFactorBound || need > smallestCapacityFactorThatStillWorks) {
smallestCapacityFactorThatStillWorks = need;
anyFactorBound = true;
}
}
}
}
const sim::CivilianGrowthResult a = sim::GrowCivilianPopulations(modelled, halted);
const sim::CivilianGrowthResult b = sim::GrowCivilianPopulations(wireOnly, halted);
bool agree = true;
for (int sp = 0; sp < sim::kSpeciesCount; ++sp)
if (a.applied[sp] != b.applied[sp]) agree = false;
int moved = 0;
for (int sp = 0; sp < sim::kSpeciesCount; ++sp)
if (a.applied[sp] != 0) ++moved;
for (int sp = 0; sp < sim::kSpeciesCount; ++sp)
if (a.hitLimit[sp]) ++r.hitSettleLimit;
if (a.stepCapBound) ++r.stepCapBound;
if (!agree) {
++heldBack;
r.wouldWrite += moved;
r.notes.push_back(fmt(
"%s: NOT committed -- the capacity model changes the answer (%lld vs %lld with "
"the wire's dcs limit alone), so the per-species capacity factor is a real input "
"here and is evaluated, not written",
s.name.c_str(), static_cast<long long>(a.committedTotal),
static_cast<long long>(b.committedTotal)));
continue;
}
for (int sp = 0; sp < sim::kSpeciesCount; ++sp) {
if (a.applied[sp] == 0) continue;
if (SetCivilianCount(s.pop2, sp, before[sp] + a.applied[sp])) ++r.leafWrites;
}
if (moved) ++r.systemsGrown;
}
if (r.systemsVisited == 0) {
r.notes.push_back("no owned system carries a civilian population; the pass is faithful "
"and idle");
return r;
}
r.notes.push_back(fmt("%d owned system(s) with civilians, %d grew, %d leaf write(s); "
"%d system(s) skipped with no civilians and %d unowned",
r.systemsVisited, r.systemsGrown, r.leafWrites, noCivilians, noOwner));
r.notes.push_back(fmt(
"%d system(s) had the value decided by the 20,000,000 per-turn step cap "
"(POPTYPE[1]+0x08, a literal in the executable) rather than by the growth curve or any "
"capacity", r.stepCapBound));
if (anyMargin)
r.notes.push_back(fmt(
"smallest capacity headroom on any growing species: %lld, against a step cap of "
"%lld -- a %.1fx margin, so the capacity chain would have to be wrong by more than "
"that before a committed value moved",
static_cast<long long>(worstMarginNumer),
static_cast<long long>(sim::kCivilianGrowthStepCap),
static_cast<double>(worstMarginNumer) /
static_cast<double>(sim::kCivilianGrowthStepCap)));
if (anyFactorBound)
r.notes.push_back(fmt(
"the per-species civilian capacity factor (SpeciesDef+0x168[1], a DATA FILE value "
"taken as 1.0) would have to fall below %.3f before any committed value changed",
smallestCapacityFactorThatStillWorks));
r.notes.push_back(
haveTuning
? "tuning table loaded: POPULATION_GROWTH_MOD and _EXP are the file's"
: "NO tuning table: POPULATION_GROWTH_MOD and _EXP read 0, the MOD multiply is "
"skipped by its strict >0 gate and the exponent clamps to (double)0.01f. Every "
"corpus colony sits EXACTLY at its species' ideal suitability, so the curve's "
"base is 1.0 and the exponent cannot matter; and the uncapped delta is 7.5x the "
"step cap, so no growth fraction in [0.04, 1.0] changes a committed value");
if (heldBack)
r.notes.push_back(fmt("%d system(s) held back and reported rather than written",
heldBack));
if (r.hitSettleLimit)
r.notes.push_back(fmt(
"%d species stopped by their settle limit rather than by their own curve; the "
"original raises a morale event for each and that half is NOT modelled, so a "
"morale leaf may stay open because of it", r.hitSettleLimit));
return r;
}
} // namespace sots::app

View file

@ -1,52 +0,0 @@
// S11's civilian population growth sub-pass, wired to the save shapes.
//
// WHERE IT SITS
// -------------
// `StrategyServer::ProcessTurn` phase 11 walks the systems; each system's own turn runs, in
// order, the plague pass, the build queue, imperial growth and then *civilian* growth. That
// last pass is `ServerSystem::GrowCivilianPops`, and it is the one modelled here. It runs
// before the player driver at phase 13, so the money `ComputeBudget` sums is priced from the
// POST-growth colony -- which is why one uncommitted population leaf costs the human's `Sav`,
// `PvSav`, `BnkEl` and `BnkPr` as well.
//
// WHAT DECIDES THE VALUE, AND WHAT DOES NOT
// -----------------------------------------
// The whole system's civilian delta is clamped to `POPTYPE[1] +0x08` = 20,000,000 -- an int64
// literal inside the executable, not a data-file value and not a carrying capacity. On both
// reference pairs the uncapped delta is 150,000,005 and the capacity headroom is 500,000,000,
// so the clamp is what decides the answer and neither the growth curve nor the capacity chain
// can change it without being wrong by an order of magnitude. The pass therefore commits
// without a tuning table, and reports the margin by which each unmodelled input would have to
// be wrong before it mattered.
//
// The one input that is genuinely not on the wire is the per-species, per-group capacity
// factor (`SpeciesDef+0x168[group]`). It is handled by running the pass twice -- once with the
// modelled capacity and once with the capacity discarded in favour of the system's own `dcs`
// limit, which IS on the wire -- and committing only when the two agree.
#pragma once
#include <string>
#include <vector>
#include "game/sim/tuning.h"
#include "mars/stream/shapes.h"
namespace sots::app {
struct GrowthPhaseResult {
int systemsVisited = 0; // owned systems with a civilian population
int systemsGrown = 0; // systems whose civilian count actually moved
int leafWrites = 0;
int wouldWrite = 0; // systems held back because the capacity model mattered
int stepCapBound = 0; // systems where the 20,000,000 clamp decided the value
int hitSettleLimit = 0; // species stopped by the settle limit (the morale-event half)
std::vector<std::string> notes;
};
// Run civilian growth over every owned system. `tuning` may be default-constructed (nothing
// loaded); the run log says which of the three tuning states it was given and whether that
// state could have changed any committed value.
GrowthPhaseResult RunCivilianGrowth(mars::stream::shapes::SaveGame& game,
const sim::TuningTable& tuning, bool haveTuning);
} // namespace sots::app

View file

@ -85,17 +85,8 @@ constexpr PhaseDesc kStrategic[] = {
"upkeep of population carried aboard colony/slaver hulls in transit"},
{Driver::Strategic, 11, "S11", "SystemTurn", PhaseStatus::Partial,
"runs game::sim ProcessColonyTurn per system and commits the parts that need neither the "
"tuning table nor a carrying capacity. CIVILIAN GROWTH now runs and commits: the whole "
"system's civilian delta is clamped to POPTYPE[1]+0x08 = 20,000,000, an int64 literal in "
"the executable, and on both reference pairs that clamp -- not the growth curve and not "
"any capacity -- is what decides the value (the uncapped delta is 7.5x it and the "
"capacity headroom 25x it). The one input that is genuinely off the wire, the per-species "
"civilian capacity factor, is handled by running the pass twice, once with the modelled "
"capacity and once with the system's own wire-known dcs limit, and committing only when "
"the two agree. Plague, resources, slaves and rebellion remain the model's input "
"boundary, as does IMPERIAL growth -- it is a no-op on this corpus (the homeworld sits "
"exactly at its cap) and committing it would need a capacity the corpus cannot bound "
"from above. The BUILD QUEUE is "
"tuning table nor a carrying capacity; plague, growth, resources, slaves and rebellion "
"are the sub-passes the model still declares as its input boundary. The BUILD QUEUE is "
"modelled and runs (it is the only writer of the per-class built counter in the whole "
"image) but has no points to spend: they come from the per-system output term. It is "
"NOT what the missing destroyer waits on -- no build order exists anywhere in either "
@ -152,19 +143,16 @@ constexpr PhaseDesc kPlayer[] = {
"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: the repair demand of ships in orbit is taken as 0 "
"(its two design fields are cached stats, nowhere on the wire; the phase now reports the "
"candidate set that evidences the zero). S11's civilian growth IS committed as of G3, so "
"a colony that grew this turn is priced from its post-growth population. The phase "
"self-checks every run by "
"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 the human and for "
"the independent colony on BOTH reference pairs now that S11 commits civilian growth "
"and the two interest rates are the widened float literals the image holds. Still "
"wrong for an AI whose research rate and target are set by its own orders during the "
"turn (Rung B)"},
"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"},
@ -276,10 +264,10 @@ constexpr PhaseDesc kTail[] = {
"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. It used to close NOTHING because the limits move with the "
"CIVILIAN population and that growth was not committed; now that S11 commits it, measured "
"with --commit-blocked=T31 --ai-player 1: 2 closed, 0 regressed on EACH reference pair "
"-- BnkEl for the human and for the AI. BnkPr still needs the tuning factor"},
"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

@ -10,7 +10,6 @@
#include "app/alliance.h"
#include "app/construction_phase.h"
#include "app/event_phase.h"
#include "app/growth_phase.h"
#include "app/trade_raid.h"
#include "app/treaty.h"
#include "app/turn_record.h"
@ -121,14 +120,6 @@ struct PlayerBudgetFeed {
// rounding. A large delta here is the model failing, and it is visible without a VM.
int projectedIncome = 0;
int turnIncome = 0;
// The ninth input of the output turn path: the repair demand of the owner's ships in
// orbit, `Sum Ship::RepairCost` over `RepairShipsInOrbit`'s candidate set. The per-ship
// arithmetic is now read (sim::ShipRepairCost) but its two design fields are cached stats
// that are nowhere on the wire, so the demand is still taken as 0. What IS on the wire is
// the candidate set, and counting it turns a silent zero into an evidenced one: a colony
// with no ship in orbit cannot have a repair demand at all.
int repairCandidateSystems = 0; // owned colonies with at least one fleet in orbit
int repairCandidateShips = 0; // ships in those fleets
};
// What P11 needs beyond the player itself: the turn to post into (the frame AFTER H00's
@ -512,10 +503,7 @@ int SystemTurnMoneyFromWire(const Sys& s, const Player& owner, bool ownerIsAI,
in.buildQueueDemand = 0;
if (s.bq)
for (const auto& o : s.bq->orders) in.buildQueueDemand += o.conleft;
// The ninth input. `sim::ShipRepairCost` now models the per-ship term, but its two design
// fields (the build target and the allowance) are cached design stats that the save does
// not carry, so the demand stays 0 and S13 reports the candidate set that proves the zero.
in.repairDemand = 0;
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;
@ -571,13 +559,6 @@ std::vector<PlayerBudgetFeed> BuildBudgetFeeds(const SaveGame& game, const TurnO
if (money != 0) f.systemIncome.push_back(money);
f.turnIncome += money;
f.projectedIncome += SystemMaxIncomeFromWire(*s, p, f.isAI, ctx);
if (!s->fleets.empty()) {
++f.repairCandidateSystems;
for (std::int32_t fid : s->fleets)
for (const auto& fe : game.sim.fleets)
if (fe.fltID == fid)
f.repairCandidateShips += static_cast<int>(fe.flt.ships.size());
}
}
feeds.push_back(std::move(f));
}
@ -1015,17 +996,6 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
rec.wouldWrite += b.wouldWrite;
for (const auto& n : b.notes) rec.notes.push_back("build queue: " + n);
}
// Civilian growth runs after the build queue in the original's colony turn,
// and before the player driver at phase 13 -- so what it writes here is what
// ComputeBudget prices the colony from.
{
sim::TuningTable tuning;
const GrowthPhaseResult g = RunCivilianGrowth(game, tuning, opt.haveTuning);
rec.leafWrites += g.leafWrites;
rec.wouldWrite += g.wouldWrite;
if (g.leafWrites > 0) rec.committed = true;
for (const auto& n : g.notes) rec.notes.push_back("civilian growth: " + n);
}
break;
}
case 13: { // S13 PlayerTurn -- the nested driver
@ -1054,26 +1024,10 @@ TurnResult RunStrategicTurn(SaveGame& game, const TurnOptions& opt) {
}
rec.notes.push_back(fmt(
"%d player(s) had a non-empty per-system money roll-up on the TURN path "
"(ComputeOutput, not ComputeMaxIncome); civilian growth is now committed "
"by S11, so a colony that grew this turn is priced from its POST-growth "
"population",
"(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));
{
int repSys = 0, repShips = 0;
for (const auto& f2 : feeds) {
repSys += f2.repairCandidateSystems;
repShips += f2.repairCandidateShips;
}
rec.notes.push_back(fmt(
"ship-repair demand taken as 0: %d owned colony(ies) carry a fleet in "
"orbit at all, %d ship(s) between them -- the only input of the nine "
"still unmodelled, and its two design fields are cached stats that are "
"nowhere on the wire. Zero is EVIDENCED rather than assumed on this "
"corpus: the independent colony's savings close exactly on both "
"reference pairs with a fleet parked over Koa'Vo, which cannot happen "
"if any of those hulls had a positive repair cost",
repSys, repShips));
}
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 "

View file

@ -105,53 +105,6 @@ std::int64_t ApplyImperialGrowth(std::int64_t pop, std::int64_t capacity, std::i
return result < 0 ? 0 : result;
}
CivilianGrowthResult GrowCivilianPopulations(const CivilianGrowthRow* rows, bool growthHalted) {
CivilianGrowthResult r;
for (int sp = 0; sp < kSpeciesCount; ++sp) {
const CivilianGrowthRow& w = rows[sp];
std::int64_t limit;
bool hit = false;
if (w.settleLimit < w.capacity) {
limit = w.settleLimit;
// The flag is raised on the pre-clamp intent, not on what is finally applied.
if (w.current + w.delta > w.settleLimit) hit = true;
} else {
limit = w.capacity;
}
const std::int64_t headroom = limit - w.current;
std::int64_t a = w.delta < headroom ? w.delta : headroom;
if (growthHalted && a > 0) {
a = 0;
hit = false;
}
r.applied[sp] = a;
r.hitLimit[sp] = hit;
r.rawTotal += a;
}
std::int64_t clamped = r.rawTotal;
if (clamped < kCivilianDeclineFloor) clamped = kCivilianDeclineFloor;
if (clamped > kCivilianGrowthStepCap) clamped = kCivilianGrowthStepCap;
r.committedTotal = clamped;
if (clamped == r.rawTotal) return r;
r.stepCapBound = true;
// The rescale quotient and its product are the floats that decide the answer: the
// relative error of `clamped / total` is up to 2^-53 and the product's absolute error can
// exceed half an ulp of the cap, so it is NOT a theorem that a single-species colony
// lands exactly on the cap. It does on both reference pairs and a test pins it.
const double scale = static_cast<double>(clamped) / static_cast<double>(r.rawTotal);
const bool scaleGains = r.rawTotal > kCivilianGrowthStepCap;
const bool scaleLosses = r.rawTotal < kCivilianDeclineFloor;
for (int sp = 0; sp < kSpeciesCount; ++sp) {
if (scaleGains && !(r.applied[sp] > 0)) continue;
if (!scaleGains && scaleLosses && !(r.applied[sp] < 0)) continue;
r.applied[sp] = Ftoi64(Narrow(static_cast<double>(r.applied[sp]) * scale));
r.hitLimit[sp] = false;
}
return r;
}
double InfrastructurePointsNeeded(double infra) {
// A real ceil() on the double quotient; the result stays a double and is compared
// against the (also double) point pool, so nothing is truncated on the way.
@ -552,16 +505,6 @@ double IdealSuitability(const IdealSuitabilityInputs& in) {
return v;
}
int ShipRepairCost(int designBuildTarget, int shipBuildProgress, int designAllowance,
bool useAllowance) {
// The original loads both design fields unconditionally and adds the allowance to the
// ship's progress only under the flag; the subtraction is plain 32-bit integer.
int have = shipBuildProgress;
if (useAllowance) have += designAllowance;
const int cost = designBuildTarget - have;
return cost > 0 ? cost : 0;
}
RepairPassResult RepairShipsInOrbit(int points, int repairDemand) {
RepairPassResult r;
if (points <= 0 || repairDemand <= 0) {

View file

@ -147,86 +147,6 @@ constexpr std::int64_t kMaxPopulationStep = 50000000;
// it. CONFIDENCE: high -- read branch by branch.
std::int64_t ApplyImperialGrowth(std::int64_t pop, std::int64_t capacity, std::int64_t delta);
// ---------------------------------------------------------------------------------------
// The per-population-type table's own columns (G3)
// ---------------------------------------------------------------------------------------
//
// The three-row table is built inside the executable and only the three slave modifiers come
// from the data files, so every constant below is a fact about the program, not about the
// shipped content. `+0x08` is an int64 and it is NOT a population ceiling: it is the largest
// step one turn may take, which is how both growth passes use it. `+0x28/+0x2c` -- the group
// ceiling -- is written only by the CRT static initialiser, to INT64_MAX, so the clamp that
// reads it is always a no-op; a reader who opens only the obvious initialiser sees zero there
// and would cap every colony at nothing. CONFIDENCE: high, read twice from the bytes and
// four of the values confirmed live by lane N.
constexpr double kImperialGrowthMult = 1.0; // POPTYPE[0] +0x04
constexpr double kCivilianGrowthMult = 0.25; // POPTYPE[1] +0x04
constexpr double kSlaveGrowthMult = 0.0; // POPTYPE[2] +0x04
constexpr double kImperialCapacityMult = 1.0; // POPTYPE[0] +0x20
constexpr double kCivilianCapacityMult = 2.0; // POPTYPE[1] +0x20
constexpr std::int64_t kImperialGrowthStepCap = 50000000; // POPTYPE[0] +0x08
constexpr std::int64_t kCivilianGrowthStepCap = 20000000; // POPTYPE[1] +0x08
constexpr std::int64_t kGroupPopulationCeiling = INT64_MAX; // POPTYPE[t] +0x28
// The floor the civilian pass puts on a system-wide DECLINE. It is a literal inside
// `GrowCivilianPops` (`0xffffffff_fd050f80`), not the imperial row's step cap, even though
// the two numbers happen to be equal.
constexpr std::int64_t kCivilianDeclineFloor = -50000000;
// The growth curve's per-call factor when the system's `GFlags` carries the owner's player
// bit: the curve is multiplied by 1.5 instead of 1. It is a BONUS, not a penalty.
// UNEXERCISED: `GFlags` is 0 for the growing player on every corpus system. HYPOTHESIS.
constexpr double kFlaggedSystemGrowthFactor = 1.5;
// ---------------------------------------------------------------------------------------
// Civilian growth -- `ServerSystem::GrowCivilianPops`
// ---------------------------------------------------------------------------------------
//
// The original loops group types 0,1,2 and does work for **1** only (the imperial pass is
// inlined in `ServerSystem::ProcessTurn`, slaves are `ProcessSlaves`); the loop's back edge
// sits outside every decompiler `if`, which is why the body reads as straight-line code if
// you stop at the first `ret`.
struct CivilianGrowthRow {
// `PopulationGrowthDelta` for (group 1, this species).
std::int64_t delta = 0;
// `Population::Count(Pop2, 1, sp) + Population::Count(pbon2, 1, sp)`. The PENDING civilian
// bonus pool counts towards the headroom even though the write-back adds to `Pop2` alone,
// so a colony with a pending pool grows less than its `Pop2` headroom would allow.
// UNEXERCISED: `pbon2` is empty on every corpus system. HYPOTHESIS on which of the two
// sides carries the pool.
std::int64_t current = 0;
// `MaxPopGeneric(1, sp, owner, NULL)` -- the capacity at the planet's real suitability.
std::int64_t capacity = 0;
// `CivilianSettleLimit(sp)` = min(capacity at the species' IDEAL suitability, the count
// the system's `dcs` Population holds for (1, sp)).
std::int64_t settleLimit = 0;
};
struct CivilianGrowthResult {
std::int64_t applied[kSpeciesCount] = {};
// Set when the species was stopped by its settle limit rather than by its own curve.
// The original turns this into a morale event; that half is not modelled here.
bool hitLimit[kSpeciesCount] = {};
std::int64_t rawTotal = 0; // before the system-wide clamp
std::int64_t committedTotal = 0; // after it
bool stepCapBound = false; // the clamp moved the total, so the rescale ran
};
// One civilian growth pass over a system.
//
// per species: limit = settleLimit < capacity ? settleLimit : capacity
// applied = min(delta, limit - current) (so a limit BELOW the current
// population is a shrink)
// halted && applied > 0 -> applied = 0
// system-wide: total clamped into [kCivilianDeclineFloor, kCivilianGrowthStepCap]; when the
// clamp bit, every entry of the same sign as the clamp is rescaled by
// `trunc(applied x (clamped / total))` and the original does NOT renormalise,
// so the shares need not add back up to the clamp.
// CONFIDENCE: high -- read instruction by instruction. The single-species case is the only
// one the corpus exercises, and there the rescale is exact.
CivilianGrowthResult GrowCivilianPopulations(const CivilianGrowthRow* rows, bool growthHalted);
// ---------------------------------------------------------------------------------------
// Infrastructure and terraforming
// ---------------------------------------------------------------------------------------
@ -664,32 +584,6 @@ struct RepairPassResult {
};
RepairPassResult RepairShipsInOrbit(int points, int repairDemand);
// `Ship::RepairCost` 0x00815180 -- the per-ship term the pass above sums, and the ninth and
// last input of the output turn path. It is a two-field integer subtraction with a floor at
// zero and no floating point at all:
//
// cost = max(0, designBuildTarget - (shipBuildProgress + (allowance ? designAllowance : 0)))
//
// `shipBuildProgress` is the ship's `ConCap` word (+0x68). Despite the save-format name it is
// NOT a per-turn capacity: `Ship::ApplyRepair` 0x008151c0 does `ConCap += max(points, 0)` and
// then clamps `ConCap` into `[0, designBuildTarget]`, so it is the construction invested in
// the hull so far and `designBuildTarget` (design +0xcc) is its ceiling. `designAllowance`
// (design +0xd0) is subtracted only when the caller asks for it, and the only caller --
// `RepairShipsInOrbit` -- always does. CONFIDENCE: high on the arithmetic; the two design
// fields are read but NOT identified, so a caller supplies them.
//
// UNEXERCISED. No corpus save has ever produced a non-zero value, and that is a MEASUREMENT
// rather than an absence: the independent colony's fleet sits in orbit over Koa'Vo on both
// reference pairs and that player's `Sav` closes exactly with the demand taken as zero, which
// it could not do if any of those ships had a positive cost. What no save exercises is a
// hull with `ConCap < designBuildTarget`, so every branch below the floor is a HYPOTHESIS.
//
// Note `Ship::ApplyRepair` silently does nothing unless the ship's cached role word (+0x18,
// not on the wire) carries bit 0x400000, while the pass's candidate filter tests a different
// bit -- so a ship can be charged points that never reach it. Read, not explained; flagged.
int ShipRepairCost(int designBuildTarget, int shipBuildProgress, int designAllowance,
bool useAllowance);
struct SystemOutputInputs {
// --- the rate vector, exactly as the system stores it (NOT normalised) ---
OutputRates rates;

View file

@ -26,12 +26,12 @@ DifficultyMods DifficultyModsFor(int level, bool isAI, bool isNpc) {
int SavingsInterest(int savings, bool ownsSystems) {
if (savings < 0 || !ownsSystems) return 0;
return Ftol(static_cast<double>(savings) * kSavingsInterestRate);
return Ftol(static_cast<double>(savings) * 0.01);
}
int DebtInterest(int savings) {
if (savings >= 0) return 0;
return Ftol(-static_cast<double>(savings) * kDebtInterestRate);
return Ftol(-static_cast<double>(savings) * 0.15);
}
int MaintenanceCost(int maintenance, double difficultyDivisor) {

View file

@ -110,17 +110,6 @@ struct Budget {
int net = 0; // change in savings this turn
};
// The two interest rates `ComputeBudget` multiplies by are **widened float literals** in the
// image, not the exact decimals: 0x009e31c0 holds (double)0.01f = 0.009999999776482582 and
// 0x009ed188 holds (double)0.15f = 0.15000000596046448 (the same constant lane E1 already
// carries, negated, as `kBankruptcyInterestDivisor`). Both are then truncated by `_ftol2`, so
// the difference from the exact decimal is not cosmetic: a treasury of exactly 50,000 earns
// 499, not 500. G3 correction -- the module used exact decimals, and the live `ComputeBudget`
// compare (4,437 calls, 0 divergences) did not catch it because only 20 distinct states were
// ever presented and none of them sat on a boundary.
constexpr double kSavingsInterestRate = 0.009999999776482582; // (double)0.01f
constexpr double kDebtInterestRate = 0.15000000596046448; // (double)0.15f
// Savings interest: 1 % of a non-negative treasury, only for players who own systems.
// CONFIDENCE: high.
int SavingsInterest(int savings, bool ownsSystems);

View file

@ -38,14 +38,6 @@ struct SpeciesConstants {
// 400 output points -- 2000 money -- which is how it was found.
int resourceDemand = 0; // SpeciesDef +0x4c
double resourceOutput = 10.0; // SpeciesDef +0x50
// The per-species multiplier on every carrying capacity (the helper at 0x0053bb00 off the
// same SpeciesDef). Also a DATA FILE value. 1.0 for Human is MEASURED, not assumed, and
// not from the data files either: Gamma Cephei's imperial `pbon` of 1e9 never drains --
// and the bonus apply returns exactly when `Pop >= MaxPop` -- while its `Pop` of 1e9 never
// shrinks, and the imperial apply shrinks exactly when `pop > cap`. The two behaviours
// bracket the capacity at exactly `Size x 1e8 x 1.0`, which forces this factor to 1.0 for
// a Human colony at its ideal suitability. UNVERIFIED for every other species.
double growthFactor = 1.0; // SpeciesDef, read by MaxPopGeneric
};
// CONFIDENCE: high on the first three fields (read with their constants and their

View file

@ -918,99 +918,6 @@ static void test_build_queue() {
CHECK_EQ(zero[0].constructionLeft, 100);
}
// ---------------------------------------------------------------------------------------
// G3: civilian growth, the step cap and the rescale
// ---------------------------------------------------------------------------------------
static void test_civilian_growth() {
// The two reference pairs, from the wire. Gamma Cephei: 500,000,000 civilians of one
// species, a `dcs` settle limit of 1,000,000,000 and a modelled capacity of 2,000,000,000.
// The growth fraction is float32(1.2f x 1.0 x 1.0 x 0.25) = 0.30000001192092896, so the
// uncapped delta is 150,000,005 -- 7.5x the step cap.
CivilianGrowthRow rows[kSpeciesCount] = {};
rows[0].delta = 150000005;
rows[0].current = 500000000;
rows[0].capacity = 2000000000;
rows[0].settleLimit = 1000000000;
CivilianGrowthResult r = GrowCivilianPopulations(rows, false);
CHECK_EQ(r.rawTotal, 150000005);
CHECK_EQ(r.committedTotal, 20000000);
CHECK(r.stepCapBound);
// THE float that decides the value: trunc(150000005 x (20000000 / 150000005)). The
// product's error can exceed half an ulp of 20,000,000, so this is a measurement, not a
// theorem, and one ulp low would cost a whole person and move the human's savings.
CHECK_EQ(r.applied[0], 20000000);
CHECK(!r.hitLimit[0]);
// Pair 2: the colony starts 20,000,000 higher and takes the same step again. This is the
// discriminator that proves POPTYPE[1]+0x08 is a per-turn STEP cap and not a population
// ceiling -- a ceiling of 20,000,000 would make the colony collapse instead.
rows[0].delta = 156000006;
rows[0].current = 520000000;
r = GrowCivilianPopulations(rows, false);
CHECK_EQ(r.applied[0], 20000000);
// Koa'Vo: the settle limit equals the current population exactly, so the headroom is zero
// and nothing grows -- and because the total is then zero the clamp does not bite, so the
// settle-limit flag survives to raise a morale event.
CivilianGrowthRow tight[kSpeciesCount] = {};
tight[2].delta = 241500000;
tight[2].current = 500000000;
tight[2].capacity = 1000000000;
tight[2].settleLimit = 500000000;
r = GrowCivilianPopulations(tight, false);
CHECK_EQ(r.applied[2], 0);
CHECK_EQ(r.committedTotal, 0);
CHECK(!r.stepCapBound);
CHECK(r.hitLimit[2]);
// A halted colony: the flag is cleared with the growth.
r = GrowCivilianPopulations(rows, true);
CHECK_EQ(r.applied[0], 0);
CHECK(!r.hitLimit[0]);
// A limit BELOW the current population is a shrink, floored by the decline clamp at
// -50,000,000 and rescaled the same way.
CivilianGrowthRow over[kSpeciesCount] = {};
over[0].delta = 0;
over[0].current = 900000000;
over[0].capacity = 2000000000;
over[0].settleLimit = 100000000;
r = GrowCivilianPopulations(over, false);
CHECK_EQ(r.rawTotal, -800000000);
CHECK_EQ(r.committedTotal, -50000000);
CHECK_EQ(r.applied[0], -50000000);
// Two species over the cap: the rescale is proportional and TRUNCATING, and the original
// does not renormalise, so the shares need not add back up to the cap.
CivilianGrowthRow two[kSpeciesCount] = {};
two[0].delta = 30000000; two[0].current = 0; two[0].capacity = INT64_MAX;
two[0].settleLimit = INT64_MAX;
two[1].delta = 30000001; two[1].current = 0; two[1].capacity = INT64_MAX;
two[1].settleLimit = INT64_MAX;
r = GrowCivilianPopulations(two, false);
CHECK_EQ(r.rawTotal, 60000001);
CHECK_EQ(r.committedTotal, 20000000);
CHECK(r.applied[0] + r.applied[1] <= 20000000);
CHECK(r.applied[0] > 0);
CHECK(r.applied[1] > 0);
// The step caps are the population-type table's own int64 column.
CHECK_EQ(kCivilianGrowthStepCap, 20000000);
CHECK_EQ(kImperialGrowthStepCap, 50000000);
CHECK_EQ(kCivilianDeclineFloor, -50000000);
}
static void test_ship_repair_cost() {
// max(0, target - (progress + allowance)), plain 32-bit integers, floored at zero.
CHECK_EQ(ShipRepairCost(1000, 400, 100, true), 500);
CHECK_EQ(ShipRepairCost(1000, 400, 100, false), 600);
CHECK_EQ(ShipRepairCost(1000, 1000, 0, false), 0);
CHECK_EQ(ShipRepairCost(1000, 1200, 0, false), 0); // the floor, not a negative
CHECK_EQ(ShipRepairCost(1000, 950, 100, true), 0); // the allowance can cross the floor
}
int main() {
test_capacity();
test_growth();
@ -1027,7 +934,5 @@ int main() {
test_difficulty_table();
test_bonuses();
test_build_queue();
test_civilian_growth();
test_ship_repair_cost();
return simtest::finish("test_colony");
}

View file

@ -5,11 +5,8 @@
using namespace sots::sim;
// The two interest rates are WIDENED FLOAT literals in the image, so a round treasury earns
// one less than the exact decimal would give. Every expectation below that moved by exactly 1
// was hand-computed from the exact decimal and is corrected here (G3).
static void test_interest() {
CHECK_EQ(SavingsInterest(1000, true), 9); // 1000 x (double)0.01f = 9.99999977 -> 9
CHECK_EQ(SavingsInterest(1000, true), 10);
CHECK_EQ(SavingsInterest(199, true), 1); // 1.99 truncates
CHECK_EQ(SavingsInterest(1000, false), 0); // no systems, no interest
CHECK_EQ(SavingsInterest(-500, true), 0);
@ -84,22 +81,22 @@ static BudgetInputs base_inputs() {
static void test_budget_hand_case() {
Budget b = ComputeBudget(base_inputs(), false);
CHECK_EQ(b.savingsInterest, 99);
CHECK_EQ(b.savingsInterest, 100);
CHECK_EQ(b.debtInterest, 0);
CHECK_EQ(b.systemIncomePositive, 8000);
CHECK_EQ(b.systemIncomeNegative, 200);
CHECK_EQ(b.maintenance, 2000);
CHECK_EQ(b.expenses, 0);
// 8000 + 1000 + 100 - 200 - 2000 = 6900
CHECK_EQ(b.available, 6899);
CHECK_EQ(b.available, 6900);
CHECK_EQ(b.construction, 500);
// (6900 - 500) x 0.5 = 3200
CHECK_EQ(b.researchMoney, 3199);
CHECK_EQ(b.researchMoney, 3200);
// 3200/50 = 64; x1.15 = 73.6; x0.5 = 36.8; x0.85 = 31.28 -> 31
CHECK_EQ(b.researchPoints, 31);
CHECK_EQ(b.totalResearchPoints, 31);
CHECK(b.hasResearchAllocation);
CHECK_EQ(b.researchMoneyKept, 3199);
CHECK_EQ(b.researchMoneyKept, 3200);
CHECK_EQ(b.bonusIncome, 0);
CHECK_EQ(b.savingsGiven, 0);
// 6900 - 500 - 3200
@ -113,20 +110,20 @@ static void test_budget_without_research_target() {
BudgetInputs in = base_inputs();
in.hasResearchTarget = false;
Budget b = ComputeBudget(in, false);
CHECK_EQ(b.researchMoney, 3199);
CHECK_EQ(b.researchMoney, 3200);
CHECK_EQ(b.researchPoints, 31);
CHECK_EQ(b.totalResearchPoints, 31);
CHECK(!b.hasResearchAllocation);
CHECK_EQ(b.researchMoneyKept, 0);
// The research money stays in the treasury: 6900 - 500 construction.
CHECK_EQ(b.net, 6399);
CHECK_EQ(b.net, 6400);
}
static void test_budget_projected() {
Budget b = ComputeBudget(base_inputs(), true);
CHECK_EQ(b.researchMoney, 0);
CHECK_EQ(b.researchPoints, 0);
CHECK_EQ(b.net, 6399);
CHECK_EQ(b.net, 6400);
}
static void test_budget_debt() {
@ -150,8 +147,8 @@ static void test_budget_aid_and_bonus() {
in.tra = 4;
in.trp = 5;
Budget b = ComputeBudget(in, false);
CHECK_EQ(b.researchMoney, 3199);
CHECK_EQ(b.researchMoneyGiven, 1599);
CHECK_EQ(b.researchMoney, 3200);
CHECK_EQ(b.researchMoneyGiven, 1600);
CHECK_EQ(b.researchMoneyKept, 1600);
// 31 + 4 + 5 = 40; given 20; kept 20
CHECK_EQ(b.researchPointsGiven, 20);
@ -163,7 +160,7 @@ static void test_budget_aid_and_bonus() {
in.aidResearchPercent = 250; // clamps to 100
in.aidSavings = 0;
b = ComputeBudget(in, false);
CHECK_EQ(b.researchMoneyGiven, 3199);
CHECK_EQ(b.researchMoneyGiven, 3200);
CHECK_EQ(b.totalResearchPoints, 0);
in = base_inputs();
@ -191,7 +188,7 @@ static void test_budget_aid_and_bonus() {
b = ComputeBudget(in, false);
CHECK_EQ(b.available, 0);
CHECK_EQ(b.bonusIncome, 0);
CHECK_EQ(b.net, -11101);
CHECK_EQ(b.net, -11100);
// savings aid is capped by the projected treasury, not by the turn net
in = base_inputs();
@ -228,7 +225,7 @@ static void test_budget_edges() {
in.isAI = true;
Budget b = ComputeBudget(in, false);
CHECK_EQ(b.construction, 0); // AI path spends construction elsewhere
CHECK_EQ(b.researchMoney, 3449); // 6900 x 0.5
CHECK_EQ(b.researchMoney, 3450); // 6900 x 0.5
in = base_inputs();
in.systemIncome = {};
@ -238,12 +235,12 @@ static void test_budget_edges() {
CHECK_EQ(b.available, 0); // floored at zero
CHECK_EQ(b.construction, 0);
CHECK_EQ(b.researchMoney, 0);
CHECK_EQ(b.net, 99 - 5000); // interest minus maintenance
CHECK_EQ(b.net, 100 - 5000); // interest minus maintenance
in = base_inputs();
in.constructionDemand = 100000;
b = ComputeBudget(in, false);
CHECK_EQ(b.construction, 6899); // capped at available
CHECK_EQ(b.construction, 6900); // capped at available
CHECK_EQ(b.researchMoney, 0);
in = base_inputs();
@ -251,7 +248,7 @@ static void test_budget_edges() {
b = ComputeBudget(in, false);
// pre-expense avail 6900: request 3450 - 1000 = 2450, room 1000 -> 2000 total
CHECK_EQ(b.expenses, 2000);
CHECK_EQ(b.available, 4899);
CHECK_EQ(b.available, 4900);
}
static void test_trade() {