merge lane N: system output term (24,357 guarded calls, 0 undeclared writes); bankruptcy divisor fixed

This commit is contained in:
alex 2026-09-08 12:12:56 -04:00
commit b2bad30f6c
15 changed files with 1091 additions and 33 deletions

View file

@ -96,6 +96,7 @@ if(WIN32)
src/shim/hooks/tech_effects.cpp
src/shim/hooks/compute_budget.cpp
src/shim/hooks/colony_turn.cpp
src/shim/hooks/system_output.cpp
src/shim/hooks/fleet_movement.cpp
src/shim/hooks/player_turn.cpp
src/shim/hooks/tail_rng.cpp

104
docs/N-output-term.md Normal file
View file

@ -0,0 +1,104 @@
# N — the population → base-output term
The formula that a colony's whole economy hangs off, and the one `src/app` names as its
largest unmodelled input. It is now read from the instruction stream and compared against the
running game.
## What it is
Output points are **linear in population**:
```
outputPerHead = populationTypeOutputModifier x 1.8 / 500000
```
`1.8` and `500000` are literals in the executable, and so are the imperial (`1.0`) and civilian
(`0.33f`) type modifiers — the three-row population-type table is *built in code*, not loaded
from the data files. Only the slave row's modifier comes from the data. An imperial population
with no station therefore contributes exactly `3.6e-6` output points per head.
A system's total output is a **sum of three independent terms**, not one multiplicative chain:
```
base = overHarvestDemand x speciesResourceOutputFactor
+ (transitResources + availableResources) x stripMineFraction x 0.9
+ imperialOutput + civilianOutput + slaveOutput
total = addictionModifier x base
x playerOutMod x systemOutMod x setupOutputMult x rebOutMod x scOutMod
```
The station bonus multiplies **only** the imperial term; the morale modifier multiplies **only**
the civilian term. The previous model in this module applied both to the whole thing, which is
why `OutputModifiers` no longer carries `morale` or `stations` — they belong to
`GroupOutputInputs`, one population row at a time.
## API
`src/game/sim/colony.h`:
| function | what |
|---|---|
| `PopTypeOf(group, tuning)` | the population-type row: output/income modifiers and the row's population cap (imperial 50,000,000, civilian 20,000,000) |
| `GroupOutput(GroupOutputInputs, tuning)` | one (group, species) row's contribution — the per-capita law |
| `MoraleOutputMultiplier(morale, tuning)` | the civilian morale factor, with both of the original's guards |
| `StripMineFraction(StripMineInputs)` | resource extraction efficiency |
| `OverHarvestDemand(OverHarvestInputs)` | the strip-mining resource demand, which is also a summand of output |
| `SystemBaseOutput(BaseOutputInputs, tuning)` | the three terms summed in the original's association |
| `TotalSystemOutputRaw` / `TotalSystemOutput` | the multiplier tail, unrounded and rounded half-to-even |
## Verified against the running game
Two hooks in `src/shim/hooks/system_output.{h,cpp}`, both **compare** mode, config
`src/shim/shim.cfg.output`:
* `Game::ServerSystem::GroupOutput` — the per-capita law itself. **13,105 calls, 0 divergences**
across two species and two workloads.
* `Game::ServerSystem::ComputeTotalOutput` — the whole sum and multiplier tail. **11,252 calls,
1 divergence**, and that one is a single ulp on one system, in a value the caller rounds to an
integer before using — so it cannot move any number the game stores. It is recorded, not fixed.
Both functions were checked for stores to the game state before being chosen as compare
targets, and each declares a Guard region over the whole system object: **0 undeclared writes in
24,357 calls** turns that check from a claim into a measurement. The neighbouring
`ComputeOutputFromRates` is deliberately *not* hooked — it repairs damaged ships in orbit.
### Coverage — read this before quoting the zero
24,357 calls is **13 distinct system states**. Unexercised, and therefore hypotheses:
* the over-harvest branch (`SRoh` is 0 on every call in the corpus), including its `max(v, 1)`
floor;
* the station factor (no system in the corpus has a station);
* the slave term (no system in the corpus holds slaves);
* both morale branches (every colony sits at 75, strictly between the two thresholds);
* the addiction multiplier and the civilian capacity surplus.
## `sim::Narrow`
A 32-bit x87 build may leave an intermediate in a register at the register's own precision; the
original's x87 runs with its precision-control field at 53 bits and rounds every multiply to
double. The first live run made that visible as a one-ulp low result on **every** civilian row —
4,957 divergences that were all the same defect, and none of them the model's. `sim::Narrow`
forces the round the original performs anyway, and is a no-op on any SSE2 target.
## Correction shipped alongside
`ComputeBankruptcyLimits` used the decimal `-0.15` as the elimination-limit divisor. The image
holds `-0.15000000596046448`, a widened float literal. The two disagree for **every** maximum
income divisible by 3 — from `maxIncome = 3` upward — and for essentially every empire above
about 3,000,000, which is six of the twenty-five bankruptcy records recoverable from the save
corpus. Fixed, with `kBankruptcyInterestDivisor` named in the header and a test at the value
where it first bites.
## What this does and does not unblock
It does **not** unblock `P01`. `ComputeBudget`'s missing input is a system's *money*, which runs
this verified total through a second chain with its own population law (`incomeModifier / 14000`,
no `1.8`) and its own multipliers. Checked against the bankruptcy-limit oracle over the whole
save corpus, that composition reproduces **6 of 25 player-records exactly** — every record whose
owner is the human player or an independent colony — and the single-system misses are short by a
clean factor of 1.1, the AI trade/income difficulty multiplier, which is not on the wire.
So the blocker has moved from the output term to the income tail. `src/app` is unchanged:
5 leaves closed, 0 regressed, on both save pairs.

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 @ ab1c229, generated 2026-09-08 by tools/gen_addresses.py
// Source: sots-re ghidra/addresses.json @ 58e3d85, 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>

View file

@ -234,21 +234,147 @@ OutputRates NormaliseOutputRates(const OutputRates& raw, bool suitAtIdeal, bool
return r;
}
PopTypeConstants PopTypeOf(PopGroup g, const TuningTable& t) {
// The three rows are built in code from x87 literals; only the slave row reads the
// data files. `SLAVES_OUTPUT_MOD` / `SLAVES_INCOME_MOD` are not yet fields of
// TuningTable, so the slave row's modifiers arrive as zero until a loader supplies
// them -- which is the honest state, not a silent 1.0.
switch (g) {
case PopGroup::Imperial:
return PopTypeConstants{1.0, 1.0, 50000000};
case PopGroup::Civilian:
// 0.33 is a float literal in the image, so it widens to 0.33000001311302185.
return PopTypeConstants{F32(0.33), F32(0.33), 20000000};
case PopGroup::Slaves:
return PopTypeConstants{t.SLAVES_OUTPUT_MOD, t.SLAVES_INCOME_MOD, 0};
}
return PopTypeConstants{};
}
double MoraleOutputMultiplier(int morale, const TuningTable& t) {
if (morale >= t.MORALE_INCREASE_OUTPUT) return t.MORALE_INCREASE_OUTPUT_MOD;
if (morale <= t.MORALE_DECREASE_OUTPUT) return t.MORALE_DECREASE_OUTPUT_MOD;
// An entry of exactly zero means "no morale record for this species": the original
// returns 1 without consulting the thresholds.
if (morale == 0) return 1.0;
if (morale >= t.MORALE_INCREASE_OUTPUT) {
// The modifier is used only when strictly positive; otherwise the multiplier is 1.
return t.MORALE_INCREASE_OUTPUT_MOD > 0.0 ? t.MORALE_INCREASE_OUTPUT_MOD : 1.0;
}
if (morale <= t.MORALE_DECREASE_OUTPUT) {
return t.MORALE_DECREASE_OUTPUT_MOD > 0.0 ? t.MORALE_DECREASE_OUTPUT_MOD : 1.0;
}
return 1.0;
}
double TotalSystemOutput(const OutputModifiers& m, const TuningTable& t) {
double GroupOutput(const GroupOutputInputs& in, const TuningTable& t) {
const double count = static_cast<double>(in.count);
if (!(count > 0.0)) return 0.0;
const double q = count / kOutputPopulationDivisor;
double stationFactor = 1.0;
if (in.owned && in.group == PopGroup::Imperial) {
const double b =
t.STATION_BONUS_IMPERIAL_OUTPUT > 0.0 ? t.STATION_BONUS_IMPERIAL_OUTPUT : 0.0;
stationFactor = 1.0 + static_cast<double>(in.stations) * b;
}
double morale = 1.0;
if (in.group == PopGroup::Civilian && in.owned && !in.independent) {
morale = MoraleOutputMultiplier(in.morale, t);
}
// The original's association, and each step rounded to double the way its x87 does.
const double sf18 = Narrow(stationFactor * kOutputPopulationFactor);
const double a = Narrow(PopTypeOf(in.group, t).outputMod * sf18);
const double b = Narrow(a * morale);
const double v = Narrow(b * q);
return v > 0.0 ? v : 0.0;
}
float StripMineFraction(const StripMineInputs& in) {
const double fi = F32(static_cast<double>(in.infraBonus) + static_cast<double>(in.infra));
const double pop = static_cast<double>(in.population) / 100.0;
// The original's helper is an odd-symmetric cube root: pow(|x|, 1/3) with the sign
// carried through, using the double 1/3 rather than std::cbrt.
const double root = pop >= 0.0 ? std::pow(pop, 1.0 / 3.0) : -std::pow(-pop, 1.0 / 3.0);
double r = Clamp01(root * 0.01);
if (0.0001 + r >= 1.0) r = fi;
return static_cast<float>(r < fi ? r : fi);
}
double OverHarvestDemand(const OverHarvestInputs& in) {
const double avail = static_cast<double>(in.resourcesAvailable);
double b = 0.0;
if (in.overHarvestRate > 0.0) {
// The population sum is an int32 add in the original and the product is formed as
// rate x available x scale, in that order.
const double scale = Clamp01(static_cast<double>(in.population) * 1e-05);
const double v = in.overHarvestRate * avail * scale;
b = v > 1.0 ? v : 1.0;
}
const double base = in.owned ? static_cast<double>(in.speciesBaseDemand) : 0.0;
const double t = base + b;
const double lo = t > 0.0 ? t : 0.0;
return avail < lo ? avail : lo;
}
double SystemBaseOutput(const BaseOutputInputs& in, const TuningTable& t) {
OverHarvestInputs oh;
oh.overHarvestRate = in.overHarvestRate;
oh.resourcesAvailable = in.resourcesAvailable;
oh.population = in.imperialPopulation;
oh.speciesBaseDemand = in.speciesBaseDemand;
const double harvestTerm = Narrow(OverHarvestDemand(oh) * F32(in.speciesResourceOutput));
StripMineInputs sm;
sm.population = in.imperialPopulation;
sm.infra = in.infra;
sm.infraBonus = in.infraBonus;
const double resourceTerm =
Narrow(Narrow(static_cast<double>(in.transitResources + in.resourcesAvailable) *
static_cast<double>(StripMineFraction(sm))) *
0.9);
GroupOutputInputs g;
g.stations = in.stations;
g.independent = in.independent;
g.group = PopGroup::Imperial;
g.count = in.imperialPopulation;
g.morale = in.imperialMorale;
const double imperial = GroupOutput(g, t);
g.group = PopGroup::Civilian;
g.count = in.civilianPopulation;
g.morale = in.civilianMorale;
const double civilian = GroupOutput(g, t);
g.group = PopGroup::Slaves;
g.count = in.slavePopulation;
g.morale = 0;
const double slaves = GroupOutput(g, t);
// The original's association: ((slaves + (civilian + (imperial + 0.0))) + (harvest + resource)).
const double popTerm = Narrow(slaves + Narrow(civilian + Narrow(imperial + 0.0)));
return Narrow(popTerm + Narrow(harvestTerm + resourceTerm));
}
double TotalSystemOutputRaw(const OutputModifiers& m, const TuningTable& t) {
if (!m.owned || m.rebelling) return 0.0;
const double addiction = m.addictionPhase3 ? t.ADDICTION_OUTPUT_MOD : 1.0;
double v = m.baseOutput;
v *= MoraleOutputMultiplier(m.morale, t);
v *= 1.0 + t.STATION_BONUS_IMPERIAL_OUTPUT * m.stations;
if (m.addictionPhase3) v *= t.ADDICTION_OUTPUT_MOD;
v *= m.scOutMod * m.rebOutMod * m.techOutMod * m.systemOutMod * m.playerOutMod;
v = Narrow(v * m.playerOutMod);
v = Narrow(v * m.systemOutMod);
v = Narrow(v * m.techOutMod);
v = Narrow(v * m.rebOutMod);
v = Narrow(v * m.scOutMod);
return Narrow(addiction * v);
}
double TotalSystemOutput(const OutputModifiers& m, const TuningTable& t) {
// Rounded half-to-even and kept as a double: the channel splits multiply this value,
// and only the reported slot 0 truncates it to an int.
return RoundHalfEven(v);
return RoundHalfEven(TotalSystemOutputRaw(m, t));
}
OutputSplit SplitOutput(double total, const OutputRates& rates) {

View file

@ -263,30 +263,142 @@ constexpr double kOutputRateThreshold = 9.999999747378752e-05;
// argument's default and the float32 accumulation.
OutputRates NormaliseOutputRates(const OutputRates& raw, bool suitAtIdeal, bool infraFull);
struct OutputModifiers {
double baseOutput = 0; // population-derived base (its own formula is unresolved)
int morale = 0;
int stations = 0; // stations at the system
bool addictionPhase3 = false;
double scOutMod = 1.0; // ScOutMod
double rebOutMod = 1.0; // RebOutMod
double techOutMod = 1.0; // tech-effect output multiplier
double systemOutMod = 1.0; // sys.OutMod
double playerOutMod = 1.0; // OutMod
// ---------------------------------------------------------------------------------------
// Base output: the population, resource and over-harvest terms
//
// LANE N CORRECTION (2026-09-08). The previous model here treated the whole of a system's
// output as one multiplicative chain, `base x morale x stationFactor x ...`, with `baseOutput`
// an unresolved input. That is the wrong shape. The original sums **three independent terms**
// and applies morale and the station bonus to only some of them; the five player/system
// multipliers at the end are the only genuinely global factors.
//
// The population term itself is linear and is carried entirely by the executable: output
// points per head are `typeOutputMod x 1.8 / 500000`. See
// sots-re findings/subsystems/output-term.md.
// ---------------------------------------------------------------------------------------
// The per-population-type constants. The original builds this three-row table **in code**
// from x87 literals; only the slave row reads the data files. CONFIDENCE: high (read out of
// the initialiser, including its x87 register rotation).
struct PopTypeConstants {
double outputMod = 0; // multiplies the per-capita output rate
double incomeMod = 0; // multiplies the per-capita income rate
std::int64_t maxPopulation = 0; // the row's population cap
};
// Morale effect on output: above the increase threshold x INCREASE_MOD, at or below the
// decrease threshold x DECREASE_MOD, otherwise x1. CONFIDENCE: high.
// Slave-row values come from the data files, so they are taken from the tuning table.
PopTypeConstants PopTypeOf(PopGroup g, const TuningTable& t);
// Output points contributed per head, before the type modifier: `1.8 / 500000`. Both are
// .rdata literals, i.e. facts about the algorithm rather than about the shipped data.
constexpr double kOutputPopulationFactor = 1.7999999999999998; // the image's (double)1.8
constexpr double kOutputPopulationDivisor = 500000.0;
struct GroupOutputInputs {
PopGroup group = PopGroup::Imperial;
std::int64_t count = 0; // heads in this group (imperial: Pop + pbon)
int morale = 0; // the system's Morale int[7] entry for this species
int stations = 0; // stations at the system
bool owned = true; // the system has an owner
bool independent = false; // sys.indi != null: morale is bypassed
};
// One (group, species) row's contribution:
// count <= 0 -> 0
// q = count / 500000
// sf = 1 + stations x STATION_BONUS_IMPERIAL_OUTPUT, imperial groups of an owned system only
// (and only while the constant is > 0)
// mo = morale multiplier, civilian groups only
// max(0, typeOutputMod x (sf x 1.8) x mo x q)
// CONFIDENCE: high -- read instruction by instruction, including the association of the
// multiplies, which is not free in 80-bit x87.
double GroupOutput(const GroupOutputInputs& in, const TuningTable& t);
// Morale effect on output. A morale entry of exactly 0 means "no record", and the original
// returns 1 rather than consulting the thresholds; each modifier is also ignored unless it
// is strictly positive, which matters because an unloaded tuning table has them at 0.
// CONFIDENCE: high (both guards read from the branch).
double MoraleOutputMultiplier(int morale, const TuningTable& t);
// total = 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)
// B4 correction: the engine's "round" is `fistp`/`fild` -- round to nearest, ties to EVEN --
// and the result stays a double that the channel splits multiply; only the reported total
// slot truncates it. CONFIDENCE: high on the multiplier chain and the rounding mode; the
// base-output-from-population term is an input because its own formula is not resolved.
// Resource extraction efficiency: `min( clamp01(cbrt((Pop + pbon)/100) x 0.01), Infra + ibon )`,
// narrowed to float32 on the way in and on the way out. The `>= 1 - 1e-4` branch substitutes
// the infrastructure term outright. CONFIDENCE: high.
struct StripMineInputs {
std::int64_t population = 0; // Pop + pbon
float infra = 0; // Infra
float infraBonus = 0; // ibon
};
float StripMineFraction(const StripMineInputs& in);
// The over-harvest resource demand (0x007483b0). Also the term the resource ledger charges
// against the stock, and -- multiplied by the species' resource-output factor -- one of the
// three summands of a system's output.
// B = overHarvestRate > 0 ? max(rate x resourcesAvailable x clamp01((Pop+pbon) x 1e-5), 1) : 0
// return min(resourcesAvailable, max(speciesBaseDemand + B, 0))
// CONFIDENCE: high on the structure; the `max(.., 1)` floor is UNVERIFIED behaviourally
// because every save in the corpus carries SRoh = 0.
struct OverHarvestInputs {
double overHarvestRate = 0; // the normalised SRoh slider
std::int64_t resourcesAvailable = 0; // Res, plus MRes + ARes2 when the owner strip-mines
std::int64_t population = 0; // Pop + pbon
int speciesBaseDemand = 0; // SpeciesDef +0x4c, from the data files
bool owned = true;
};
double OverHarvestDemand(const OverHarvestInputs& in);
struct BaseOutputInputs {
// population
std::int64_t imperialPopulation = 0; // Pop + pbon, owner species only
std::int64_t civilianPopulation = 0; // Pop2 + pbon2 of the owner species, incl. surplus
std::int64_t slavePopulation = 0; // summed over species
int imperialMorale = 0; // unused: imperial output ignores morale
int civilianMorale = 0;
int stations = 0;
bool independent = false;
// resources
std::int64_t transitResources = 0; // TRes
std::int64_t resourcesAvailable = 0; // Res (+ MRes + ARes2 when strip-mining)
float infra = 0;
float infraBonus = 0;
// over-harvest
double overHarvestRate = 0; // SRoh
int speciesBaseDemand = 0; // SpeciesDef +0x4c
double speciesResourceOutput = 0; // SpeciesDef +0x50 (a float, widened)
};
// The sum of the three terms, before the global multipliers:
// overHarvestDemand x speciesResourceOutput
// + (TRes + resourcesAvailable) x stripMineFraction x 0.9
// + imperial + civilian + slave population output
// CONFIDENCE: high on the terms; the summation order below reproduces the original's,
// which matters because x87 addition is not associative.
double SystemBaseOutput(const BaseOutputInputs& in, const TuningTable& t);
struct OutputModifiers {
double baseOutput = 0; // SystemBaseOutput
bool addictionPhase3 = false;
double scOutMod = 1.0; // ScOutMod (player +0x12c)
double rebOutMod = 1.0; // RebOutMod (player +0x128)
double techOutMod = 1.0; // the game-setup output multiplier (player +0x224)
double systemOutMod = 1.0; // sys.OutMod (+0x7c)
double playerOutMod = 1.0; // OutMod (player +0x124)
bool owned = true; // no owner -> 0
bool rebelling = false; // rbfl != 0 -> 0
};
// total = roundHalfEven( addiction x base x OutMod x sys.OutMod x techOut x RebOutMod x ScOutMod )
// The five multipliers are applied in exactly that order in one uninterrupted 80-bit chain.
// B4 correction retained: the engine's "round" is `fistp`/`fild` -- round to nearest, ties to
// EVEN -- and the result stays a double that the channel splits multiply; only the reported
// total slot truncates it.
// CONFIDENCE: high. Note the return of the original is the *unrounded* double; the rounding
// is done by its caller before the channel split, and is folded in here because every caller
// in the game does it.
double TotalSystemOutput(const OutputModifiers& m, const TuningTable& t);
// The unrounded value the original returns, for a hook that compares its return bit for bit.
double TotalSystemOutputRaw(const OutputModifiers& m, const TuningTable& t);
struct OutputSplit {
double trade = 0;
double construction = 0;

View file

@ -162,7 +162,18 @@ BankruptcyLimits ComputeBankruptcyLimits(int maxIncome, const TuningTable& t) {
constexpr int kTreasuryLimit = 2000000000;
BankruptcyLimits l;
// Elimination: the debt whose 15 %/turn interest equals the maximum income.
l.eliminationFloor = std::max(Ftol(static_cast<double>(maxIncome) / -0.15), -kTreasuryLimit);
//
// The divisor is NOT the decimal -0.15. The image's .rdata double is
// -0.15000000596046448, i.e. (double)(float)-0.15f -- the compiler widened a float
// literal once, at compile time. Lane K read it; lane N is fixing it here because the
// difference is not the "one ulp on large empires" the old note claimed. The two
// constants give a DIFFERENT truncated result for every maxIncome divisible by 3,
// starting at maxIncome = 3 (-20 against -19), and the disagreement rate rises with
// empire size to 100 % above about 3,000,000. Six of the twenty-five bankruptcy records
// recoverable from the save corpus would come out wrong with the decimal.
l.eliminationFloor =
std::max(Ftol(static_cast<double>(maxIncome) / kBankruptcyInterestDivisor),
-kTreasuryLimit);
// Protection: the tuned factor times the maximum income, never below the floor.
l.protectionLimit = std::max(-Ftol(t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR * static_cast<double>(maxIncome)),
l.eliminationFloor);

View file

@ -160,13 +160,18 @@ int TradeRouteIncome(const TradeRouteState& route, bool asOwner, double difficul
// Bankruptcy
// ---------------------------------------------------------------------------------------
// The elimination limit's divisor, as the image holds it: a widened float literal, not the
// decimal -0.15. Writing -0.15 changes the truncated result for every maxIncome divisible
// by 3 and for essentially every empire above ~3,000,000 maximum income.
constexpr double kBankruptcyInterestDivisor = -0.15000000596046448;
struct BankruptcyLimits {
int eliminationFloor = 0; // BnkEl: below this the player is on the elimination clock
int protectionLimit = 0; // BnkPr: below this cost-cutting starts
};
// Limits from the sum of every owned system's maximum money output:
// eliminationFloor = max(ftol(maxIncome / -0.15), -2e9)
// eliminationFloor = max(ftol(maxIncome / kBankruptcyInterestDivisor), -2e9)
// (the debt at which 15 %/turn interest eats the whole maximum income)
// protectionLimit = max(-ftol(BANKRUPTCY_PROTECTION_LIMIT_FACTOR x maxIncome), eliminationFloor)
// The limits a turn's check uses are the ones computed at the end of the previous turn

View file

@ -47,6 +47,28 @@ inline double Clamp01(double v) { return v < 0 ? 0 : (v > 1 ? 1 : v); }
// field rounds to single precision; a formula that skips that step drifts.
inline double F32(double v) { return static_cast<double>(static_cast<float>(v)); }
// Force an intermediate back to IEEE double.
//
// The original runs its x87 with the precision-control field at 53 bits (measured: the
// control word inside a hooked call is 0x127f), so every one of its multiplies rounds to
// double. A 32-bit build of this module compiled for the x87 does NOT: the compiler is
// allowed to leave intermediates in a register at the register's own precision, and the
// result differs from the original in the last bit. Storing through a volatile forces the
// round that the original performs anyway.
//
// It is a no-op wherever doubles are already evaluated at their own precision (any SSE2
// target, so every 64-bit build and the host tests), which is why it costs nothing there.
// Measured on the live game: without it, the civilian population term of a colony's output
// came out one ulp low on every call; with it, the whole chain matches bit for bit.
inline double Narrow(double v) {
#if (defined(__i386__) || defined(_M_IX86)) && !defined(__SSE2_MATH__)
volatile double t = v;
return t;
#else
return v;
#endif
}
// A float32 literal as the image holds it. The compiler widened these decimals once, at
// compile time, so `0.02` in the disassembly is really 0.019999999552965164; using the exact
// decimal rounds differently at a truncation or comparison boundary.

View file

@ -43,6 +43,12 @@ struct TuningTable {
double SLAVES_DEATH_RATE_BYOUTPUT = 0;
std::int64_t SLAVES_MIN_DEATHS = 0;
std::int64_t SLAVES_MAX_DEATHS = -1; // -1 = no upper clamp
// The slave row of the per-population-type table. The imperial and civilian rows of
// that table are built from literals inside the executable; only these three are read
// from the data files, through the loader's pointer slots.
double SLAVES_OUTPUT_MOD = 0;
double SLAVES_INCOME_MOD = 0;
double SLAVES_REPAIR_MOD = 0;
// ---- system bonus (long-held stable colonies) ----
int SYSTEMBONUS_MINTURNS = 0;

View file

@ -0,0 +1,416 @@
#include "shim/hooks/system_output.h"
#include <cstdarg>
#include <cstdio>
#include <cstring>
#if defined(_WIN32)
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#endif
#include "game/sim/colony.h"
#include "game/sim/numeric.h"
#include "generated/sots_addresses.h"
namespace shim::hooks {
using trace::Tv;
namespace tv = trace::tv;
namespace A = sots::addr;
namespace {
constexpr std::size_t kSystemGuardSize = 0x2d8; // the whole ServerSystem object
constexpr std::size_t kPopGroupStride = 0x18; // {?, int type @+4, int species @+8, int64 @+0x10}
constexpr std::size_t kMaxPopGroups = 4096;
constexpr int kSpeciesSlots = 7;
struct Env {
std::uintptr_t exe_base = 0;
void (*log_line)(const char*) = nullptr;
};
Env g_env;
bool readable(const void* p, std::size_t n) {
if (!p) return false;
if (n == 0) return true;
#if defined(_WIN32)
const char* c = static_cast<const char*>(p);
const char* const end = c + n;
while (c < end) {
MEMORY_BASIC_INFORMATION mbi;
if (!VirtualQuery(c, &mbi, sizeof mbi)) return false;
if (mbi.State != MEM_COMMIT) return false;
if (mbi.Protect & (PAGE_NOACCESS | PAGE_GUARD)) return false;
const DWORD ok = PAGE_READONLY | PAGE_READWRITE | PAGE_WRITECOPY | PAGE_EXECUTE_READ |
PAGE_EXECUTE_READWRITE | PAGE_EXECUTE_WRITECOPY;
if (!(mbi.Protect & ok)) return false;
c = static_cast<const char*>(mbi.BaseAddress) + mbi.RegionSize;
}
return true;
#else
return true;
#endif
}
template <class T>
T peek(const void* base, std::size_t off) {
T v{};
std::memcpy(&v, static_cast<const char*>(base) + off, sizeof v);
return v;
}
void* ptr_at(const void* base, std::size_t off) { return peek<void*>(base, off); }
// A GlobalConst is reached through a pointer slot in .data holding the address of the storage
// word the data-file loader fills.
template <class T>
T global_const_via_slot(std::uintptr_t slot_rva, T fallback) {
if (!g_env.exe_base) return fallback;
void** slot = reinterpret_cast<void**>(g_env.exe_base + slot_rva);
if (!readable(slot, sizeof(void*))) return fallback;
void* storage = *slot;
if (!readable(storage, sizeof(T))) return fallback;
T v{};
std::memcpy(&v, storage, sizeof v);
return v;
}
// ... and a few are reached by their storage address directly.
template <class T>
T global_const_at(std::uintptr_t rva, T fallback) {
if (!g_env.exe_base) return fallback;
const void* p = reinterpret_cast<const void*>(g_env.exe_base + rva);
if (!readable(p, sizeof(T))) return fallback;
T v{};
std::memcpy(&v, p, sizeof v);
return v;
}
std::int64_t population_of(const void* pop, int groupType, int species) {
if (!readable(pop, 0xc)) return 0;
const char* begin = static_cast<const char*>(ptr_at(pop, 0x4));
const char* end = static_cast<const char*>(ptr_at(pop, 0x8));
if (!begin || !end || end < begin) return 0;
const std::size_t n = static_cast<std::size_t>(end - begin) / kPopGroupStride;
if (n > kMaxPopGroups || !readable(begin, n * kPopGroupStride)) return 0;
std::int64_t sum = 0;
for (std::size_t i = 0; i < n; ++i) {
const char* e = begin + i * kPopGroupStride;
if (peek<std::int32_t>(e, 0x4) != groupType) continue;
if (peek<std::int32_t>(e, 0x8) != species) continue;
sum += peek<std::int64_t>(e, 0x10);
}
return sum;
}
// ---- the tuning values these two formulas read out of the running process ----------------
//
// Every one of them is logged with every record, so a divergence can always be attributed to
// a value rather than to a guess about it.
struct OutputTuning {
float stationBonusImperialOutput = 0;
std::int32_t moraleIncreaseOutput = 0;
float moraleIncreaseOutputMod = 0;
std::int32_t moraleDecreaseOutput = 0;
float moraleDecreaseOutputMod = 0;
float slavesOutputMod = 0;
// The population-type table the executable builds in code -- read live so the run either
// confirms the initialiser reading or refutes it.
float popTypeOut[3] = {0, 0, 0};
std::int32_t popTypeMaxPop[3] = {0, 0, 0};
};
OutputTuning read_tuning() {
OutputTuning t;
t.stationBonusImperialOutput =
global_const_via_slot<float>(A::GlobalConst_slot_STATION_BONUS_IMPERIAL_OUTPUT, 0.0f);
t.moraleIncreaseOutput =
global_const_via_slot<std::int32_t>(A::GlobalConst_slot_MORALE_INCREASE_OUTPUT, 0);
t.moraleIncreaseOutputMod =
global_const_via_slot<float>(A::GlobalConst_slot_MORALE_INCREASE_OUTPUT_MOD, 0.0f);
t.moraleDecreaseOutput =
global_const_via_slot<std::int32_t>(A::GlobalConst_slot_MORALE_DECREASE_OUTPUT, 0);
t.moraleDecreaseOutputMod =
global_const_via_slot<float>(A::GlobalConst_slot_MORALE_DECREASE_OUTPUT_MOD, 0.0f);
t.slavesOutputMod = global_const_at<float>(A::GlobalConst_storage_SLAVES_OUTPUT_MOD, 0.0f);
for (int i = 0; i < 3; ++i) {
const std::uintptr_t row = A::PopTypeTable_base + std::uintptr_t(i) * 0x30;
t.popTypeOut[i] = global_const_at<float>(row + 0x10, 0.0f);
t.popTypeMaxPop[i] = global_const_at<std::int32_t>(row + 0x8, 0);
}
return t;
}
sots::sim::TuningTable to_tuning(const OutputTuning& t) {
sots::sim::TuningTable out;
out.STATION_BONUS_IMPERIAL_OUTPUT = t.stationBonusImperialOutput;
out.MORALE_INCREASE_OUTPUT = t.moraleIncreaseOutput;
out.MORALE_INCREASE_OUTPUT_MOD = t.moraleIncreaseOutputMod;
out.MORALE_DECREASE_OUTPUT = t.moraleDecreaseOutput;
out.MORALE_DECREASE_OUTPUT_MOD = t.moraleDecreaseOutputMod;
out.SLAVES_OUTPUT_MOD = t.slavesOutputMod;
return out;
}
Tv tuning_tv(const OutputTuning& t) {
Tv s = tv::struct_();
s.add("STATION_BONUS_IMPERIAL_OUTPUT", tv::f32(t.stationBonusImperialOutput));
s.add("MORALE_INCREASE_OUTPUT", tv::i32(t.moraleIncreaseOutput));
s.add("MORALE_INCREASE_OUTPUT_MOD", tv::f32(t.moraleIncreaseOutputMod));
s.add("MORALE_DECREASE_OUTPUT", tv::i32(t.moraleDecreaseOutput));
s.add("MORALE_DECREASE_OUTPUT_MOD", tv::f32(t.moraleDecreaseOutputMod));
s.add("SLAVES_OUTPUT_MOD", tv::f32(t.slavesOutputMod));
s.add("poptype0_out", tv::f32(t.popTypeOut[0]));
s.add("poptype1_out", tv::f32(t.popTypeOut[1]));
s.add("poptype2_out", tv::f32(t.popTypeOut[2]));
s.add("poptype0_maxpop", tv::i32(t.popTypeMaxPop[0]));
s.add("poptype1_maxpop", tv::i32(t.popTypeMaxPop[1]));
s.add("poptype2_maxpop", tv::i32(t.popTypeMaxPop[2]));
return s;
}
// ---- per-call state ----------------------------------------------------------------------
//
// Neither function nests and the strategic pass is single-threaded, so the snapshot taken in
// describe_args (before the original runs) reaches ours() through a static. Do not copy this
// into a re-entrant hook.
struct GroupState {
bool ok = false;
bool owned = false;
bool independent = false;
int morale = 0;
OutputTuning tuning;
};
GroupState g_group;
struct TotalState {
bool ok = false;
sots::sim::BaseOutputInputs in;
sots::sim::OutputModifiers mods;
OutputTuning tuning;
// diagnostics that never enter ours()
std::int32_t systemIndex = 0;
std::int32_t ownerSpecies = -1;
std::int64_t slaveGroupPop = 0;
std::int32_t addictionSlots = 0;
float speciesResourceOutput = 0;
std::int32_t speciesBaseDemand = 0;
};
TotalState g_total;
const void* species_def(int species) {
if (!g_env.exe_base || species < 0 || species > 6) return nullptr;
const void* p = reinterpret_cast<const void*>(A::SpeciesDefTable_base + g_env.exe_base +
std::uintptr_t(species) * 0x184);
return readable(p, 0x54) ? p : nullptr;
}
int effective_species(const void* sys, const void* owner) {
const void* indi = ptr_at(sys, A::ServerSystem_off_Indi);
if (indi && readable(indi, 8)) return peek<std::int32_t>(indi, 4);
if (owner && readable(owner, A::ServerPlayer_off_Species + 4))
return peek<std::int32_t>(owner, A::ServerPlayer_off_Species);
return -1;
}
// The Morale object at +0x11c is {vptr, int[7]}, so the per-species word is at +0x120 + 4*sp.
int morale_of(const void* sys, int species) {
if (species < 0 || species >= kSpeciesSlots) return 0;
const std::size_t off = A::ServerSystem_off_Morale + 4 + std::size_t(species) * 4;
if (!readable(sys, off + 4)) return 0;
return peek<std::int32_t>(sys, off);
}
} // namespace
void init_system_output(std::uintptr_t exe_base, void (*log_line)(const char* line)) {
g_env.exe_base = exe_base;
g_env.log_line = log_line;
}
// ---- GroupOutput --------------------------------------------------------------------------
void ServerSystemGroupOutputHook::describe_args(std::vector<Tv>& out, void* self,
std::int32_t groupType, std::int32_t species,
double count) {
GroupState st;
st.tuning = read_tuning();
if (readable(self, kSystemGuardSize)) {
const void* owner = ptr_at(self, A::ServerSystem_off_PID);
st.owned = owner != nullptr;
st.independent = ptr_at(self, A::ServerSystem_off_Indi) != nullptr;
st.morale = morale_of(self, species);
st.ok = true;
out.push_back(tv::i32(peek<std::int32_t>(self, A::ServerSystem_off_Idx)).named("sysIdx"));
} else {
out.push_back(tv::null().named("sysIdx"));
}
g_group = st;
out.push_back(tv::ptr(self).named("this"));
out.push_back(tv::i32(groupType).named("groupType"));
out.push_back(tv::i32(species).named("species"));
out.push_back(tv::f64(count).named("count"));
out.push_back(tv::i32(st.morale).named("morale"));
out.push_back(tv::boolean(st.owned).named("owned"));
out.push_back(tv::boolean(st.independent).named("independent"));
out.push_back(tuning_tv(st.tuning).named("tuning"));
}
Tv ServerSystemGroupOutputHook::describe_ret(double r) { return tv::f64(r); }
void ServerSystemGroupOutputHook::regions(std::vector<trace::Region>& out, void* self,
std::int32_t, std::int32_t, double) {
if (!readable(self, kSystemGuardSize)) return;
trace::Region g;
g.name = "guard:system";
g.ptr = self;
g.size = kSystemGuardSize;
g.kind = trace::Region::Kind::Guard;
out.push_back(g);
}
ServerSystemGroupOutputHook::Args ServerSystemGroupOutputHook::rebind(trace::Scratch&, void* self,
std::int32_t groupType,
std::int32_t species,
double count) {
return Args{self, groupType, species, count};
}
double ServerSystemGroupOutputHook::ours(void*, std::int32_t groupType, std::int32_t species,
double count) {
(void)species;
if (!g_group.ok) return 0.0;
if (groupType < 0 || groupType > 2) return 0.0;
sots::sim::GroupOutputInputs in;
in.group = static_cast<sots::sim::PopGroup>(groupType);
// The count arrives as a double that the original formed from an int64; recovering the
// integer keeps our arithmetic on the same path as the model's.
in.count = static_cast<std::int64_t>(count);
in.morale = g_group.morale;
in.stations = 0; // declared input boundary -- see coverage()
in.owned = g_group.owned;
in.independent = g_group.independent;
return sots::sim::GroupOutput(in, to_tuning(g_group.tuning));
}
// ---- ComputeTotalOutput -------------------------------------------------------------------
void ServerSystemComputeTotalOutputHook::describe_args(std::vector<Tv>& out, void* self,
double overHarvestRate) {
TotalState st;
st.tuning = read_tuning();
if (readable(self, kSystemGuardSize)) {
const void* owner = ptr_at(self, A::ServerSystem_off_PID);
const bool independent = ptr_at(self, A::ServerSystem_off_Indi) != nullptr;
const int sp = effective_species(self, owner);
st.systemIndex = peek<std::int32_t>(self, A::ServerSystem_off_Idx);
st.ownerSpecies = sp;
std::int64_t resAvail = peek<std::int32_t>(self, A::ServerSystem_off_Res);
bool stripMines = false;
if (owner && readable(owner, A::ServerPlayer_off_SetupOutputMult + 4)) {
stripMines = peek<std::uint8_t>(owner, A::ServerPlayer_off_AMine) != 0;
st.mods.playerOutMod = peek<float>(owner, A::ServerPlayer_off_OutMod);
st.mods.rebOutMod = peek<float>(owner, A::ServerPlayer_off_RebOutMod);
st.mods.scOutMod = peek<float>(owner, A::ServerPlayer_off_ScOutMod);
st.mods.techOutMod = peek<float>(owner, A::ServerPlayer_off_SetupOutputMult);
}
if (stripMines) {
resAvail += peek<std::int32_t>(self, A::ServerSystem_off_MRes);
resAvail += peek<std::int32_t>(self, A::ServerSystem_off_ARes2);
}
sots::sim::BaseOutputInputs& in = st.in;
in.imperialPopulation = std::int64_t(peek<std::int32_t>(self, A::ServerSystem_off_pbon)) +
std::int64_t(peek<std::int32_t>(self, A::ServerSystem_off_Pop));
// Group 0 is credited to the system's effective species only; every other species
// contributes nothing, which is why one int pair is the whole imperial term.
if (sp < 0) in.imperialPopulation = 0;
const void* pop2 = static_cast<const char*>(self) + A::ServerSystem_off_Pop2;
const void* pbon2 = static_cast<const char*>(self) + A::ServerSystem_off_pbon2;
for (int i = 0; i < kSpeciesSlots; ++i) {
st.slaveGroupPop += population_of(pop2, 2, i) + population_of(pbon2, 2, i);
}
if (sp >= 0 && sp < kSpeciesSlots) {
in.civilianPopulation = population_of(pop2, 1, sp) + population_of(pbon2, 1, sp);
in.civilianMorale = morale_of(self, sp);
}
in.slavePopulation = 0; // declared input boundary -- see coverage()
in.stations = 0; // declared input boundary
in.independent = independent;
in.transitResources = peek<std::int32_t>(self, A::ServerSystem_off_TRes);
in.resourcesAvailable = resAvail;
in.infra = peek<float>(self, A::ServerSystem_off_Infra);
in.infraBonus = peek<float>(self, A::ServerSystem_off_ibon);
in.overHarvestRate = overHarvestRate;
if (const void* def = species_def(sp)) {
st.speciesBaseDemand = peek<std::int32_t>(def, 0x4c);
st.speciesResourceOutput = peek<float>(def, 0x50);
}
in.speciesBaseDemand = st.speciesBaseDemand;
in.speciesResourceOutput = st.speciesResourceOutput;
st.mods.owned = owner != nullptr;
st.mods.rebelling = peek<std::int32_t>(self, A::ServerSystem_off_rbfl) != 0;
st.mods.systemOutMod = peek<float>(self, A::ServerSystem_off_OutMod);
st.mods.addictionPhase3 = false; // declared input boundary
st.ok = true;
}
g_total = st;
out.push_back(tv::ptr(self).named("this"));
out.push_back(tv::f64(overHarvestRate).named("SRoh"));
out.push_back(tv::i32(st.systemIndex).named("sysIdx"));
out.push_back(tv::i32(st.ownerSpecies).named("species"));
out.push_back(tv::i64(st.in.imperialPopulation).named("imperialPop"));
out.push_back(tv::i64(st.in.civilianPopulation).named("civilianPop"));
out.push_back(tv::i64(st.slaveGroupPop).named("slaveGroupPop"));
out.push_back(tv::i32(st.in.civilianMorale).named("civilianMorale"));
out.push_back(tv::i64(st.in.resourcesAvailable).named("resAvail"));
out.push_back(tv::i64(st.in.transitResources).named("TRes"));
out.push_back(tv::f32(st.in.infra).named("Infra"));
out.push_back(tv::f32(st.in.infraBonus).named("ibon"));
out.push_back(tv::i32(st.speciesBaseDemand).named("speciesBaseDemand"));
out.push_back(tv::f32(st.speciesResourceOutput).named("speciesResourceOutput"));
out.push_back(tv::f32(static_cast<float>(st.mods.playerOutMod)).named("OutMod"));
out.push_back(tv::f32(static_cast<float>(st.mods.systemOutMod)).named("sysOutMod"));
out.push_back(tv::f32(static_cast<float>(st.mods.techOutMod)).named("setupOutMod"));
out.push_back(tv::f32(static_cast<float>(st.mods.rebOutMod)).named("RebOutMod"));
out.push_back(tv::f32(static_cast<float>(st.mods.scOutMod)).named("ScOutMod"));
out.push_back(tv::boolean(st.mods.rebelling).named("rebelling"));
out.push_back(tv::boolean(st.in.independent).named("independent"));
out.push_back(tuning_tv(st.tuning).named("tuning"));
}
Tv ServerSystemComputeTotalOutputHook::describe_ret(double r) { return tv::f64(r); }
void ServerSystemComputeTotalOutputHook::regions(std::vector<trace::Region>& out, void* self,
double) {
if (!readable(self, kSystemGuardSize)) return;
trace::Region g;
g.name = "guard:system";
g.ptr = self;
g.size = kSystemGuardSize;
g.kind = trace::Region::Kind::Guard;
out.push_back(g);
}
ServerSystemComputeTotalOutputHook::Args ServerSystemComputeTotalOutputHook::rebind(
trace::Scratch&, void* self, double overHarvestRate) {
return Args{self, overHarvestRate};
}
double ServerSystemComputeTotalOutputHook::ours(void*, double) {
if (!g_total.ok) return 0.0;
const sots::sim::TuningTable t = to_tuning(g_total.tuning);
sots::sim::OutputModifiers m = g_total.mods;
m.baseOutput = sots::sim::SystemBaseOutput(g_total.in, t);
return sots::sim::TotalSystemOutputRaw(m, t);
}
} // namespace shim::hooks

View file

@ -0,0 +1,111 @@
// Hook descriptors for the two functions that turn a colony's population into output points
// (lane N).
//
// Game::ServerSystem::GroupOutput(this, groupType, species, count) -> double
// Game::ServerSystem::ComputeTotalOutput(this, overHarvestRate) -> double
//
// Both are **side-effect free**: they and every one of their callees were checked for stores
// to the game state before either was chosen as a compare target. That is the whole reason
// the neighbouring `ComputeOutputFromRates` is *not* hooked here -- that one repairs damaged
// ships in orbit, so running it twice would change the game.
//
// Neither declares a Result region: the thing being compared is the **return value**, which
// the harness diffs like any other output. Each declares one Guard over the whole
// ServerSystem, so a run that reports no undeclared writes is also evidence for the
// read-only claim above rather than an assumption about it.
//
// GroupOutput is the interesting one. It is the population -> output law itself, it is called
// up to 14 times per system per output pass, and its `count` arrives as an argument -- so the
// compare tests the law without needing to model the population lookup, the capacity surplus
// or the slave adjustment at all.
#pragma once
#include <cstdint>
#include <tuple>
#include <vector>
#include "shim/trace/hook.h"
namespace shim::hooks {
struct ServerSystemGroupOutputHook {
static constexpr const char* name = "Game::ServerSystem::GroupOutput";
static constexpr trace::CallConv conv = trace::CallConv::Thiscall;
using Ret = double;
using Args = std::tuple<void*, std::int32_t, std::int32_t, double>;
static void describe_args(std::vector<trace::Tv>& out, void* self, std::int32_t groupType,
std::int32_t species, double count);
static trace::Tv describe_ret(double r);
static void regions(std::vector<trace::Region>& out, void* self, std::int32_t groupType,
std::int32_t species, double count);
static Args rebind(trace::Scratch& s, void* self, std::int32_t groupType,
std::int32_t species, double count);
static double ours(void* self, std::int32_t groupType, std::int32_t species, double count);
static trace::HookPolicy policy() { return trace::HookPolicy{}; }
static void coverage(trace::Coverage& c) {
c.unmodelled("the station count that scales the imperial term",
trace::Risk::High,
"the original gets it from a helper that walks the system's fleets and "
"their ships through virtual calls and takes the system in EBX, which a "
"hook cannot call portably. `ours` therefore assumes ZERO stations. A "
"divergence on an imperial row is expected to be exactly the station "
"factor, and the record carries `count` and the return, so the factor is "
"MEASURED from the trace rather than fitted",
"declared input boundary; the trace makes it recoverable");
c.unmodelled("the slave row's output modifier, and every value the data files supply",
trace::Risk::Medium,
"SLAVES_OUTPUT_MOD, the two morale thresholds and their two modifiers, and "
"STATION_BONUS_IMPERIAL_OUTPUT are read out of the live process's globals "
"and logged with every record, so the record says which value drove it",
"logged as `tuning` on every record");
c.complete("the imperial and civilian output modifiers, the 1.8 factor and the 500000 "
"divisor are literals inside the executable, so nothing about them is "
"assumed from the data files");
}
};
struct ServerSystemComputeTotalOutputHook {
static constexpr const char* name = "Game::ServerSystem::ComputeTotalOutput";
static constexpr trace::CallConv conv = trace::CallConv::Thiscall;
using Ret = double;
using Args = std::tuple<void*, double>;
static void describe_args(std::vector<trace::Tv>& out, void* self, double overHarvestRate);
static trace::Tv describe_ret(double r);
static void regions(std::vector<trace::Region>& out, void* self, double overHarvestRate);
static Args rebind(trace::Scratch& s, void* self, double overHarvestRate);
static double ours(void* self, double overHarvestRate);
static trace::HookPolicy policy() { return trace::HookPolicy{}; }
static void coverage(trace::Coverage& c) {
c.unmodelled("the station count, as for GroupOutput",
trace::Risk::High,
"`ours` assumes zero stations, so a system with an imperial population and "
"a station diverges by the station factor",
"declared input boundary");
c.unmodelled("the slave population and its xenotech adjustment",
trace::Risk::High,
"the original's slave count is not a plain field: it runs the count through "
"a per-species xenotech factor. `ours` takes the slave term as ZERO, so any "
"system holding slaves diverges. The record logs the raw group-2 population "
"sums so a divergence can be attributed",
"declared input boundary");
c.unmodelled("the capacity surplus the civilian term adds for the owner's own species",
trace::Risk::Medium,
"the original calls the carrying-capacity helper twice with different "
"out-parameter slots and adds max(0, B - A) to the civilian count; `ours` "
"uses the raw civilian population. The surplus is zero except when the "
"colony is at its cap",
"declared input boundary");
c.unmodelled("the addiction phase",
trace::Risk::Low,
"`ours` assumes it is below 3, so ADDICTION_OUTPUT_MOD never applies; the "
"record logs the system's addiction table length so the case is visible");
}
};
// Process facts the hooks need (exe base for the globals, a line logger). Call once before
// installing.
void init_system_output(std::uintptr_t exe_base, void (*log_line)(const char* line));
} // namespace shim::hooks

View file

@ -18,6 +18,7 @@
#include "shim/fpu_force.h"
#include "shim/hooks/dictionaries.h"
#include "shim/hooks/colony_turn.h"
#include "shim/hooks/system_output.h"
#include "shim/hooks/player_turn.h"
#include "shim/hooks/compute_budget.h"
#include "shim/hooks/fleet_movement.h"
@ -165,6 +166,9 @@ using ProcessResearchHook = shim::trace::Hook<shim::hooks::TechTreeProcessResear
using OnTechResearchedHook = shim::trace::Hook<shim::hooks::ServerPlayerOnTechResearchedHook>;
using ComputeBudgetHook = shim::trace::Hook<shim::hooks::ComputeBudgetHook>;
using ColonyTurnHook = shim::trace::Hook<shim::hooks::ServerSystemProcessTurnHook>;
// Lane N: the population -> base-output term (docs/N-output-term.md).
using GroupOutputHook = shim::trace::Hook<shim::hooks::ServerSystemGroupOutputHook>;
using TotalOutputHook = shim::trace::Hook<shim::hooks::ServerSystemComputeTotalOutputHook>;
using PlayerTurnHook = shim::trace::Hook<shim::hooks::ServerPlayerProcessTurnHook>;
using MoveFleetHook = shim::trace::Hook<shim::hooks::StrategyServerMoveFleetHook>;
using FleetMovementHook = shim::trace::Hook<shim::hooks::StrategyServerProcessFleetMovementHook>;
@ -220,6 +224,11 @@ void InstallHooks(shim::trace::Tracer& tracer) {
// points. All three are verified thiscall prototypes with no stack-argument surprises.
shim::hooks::init_colony_turn(exeBase, &ShimLogLine);
InstallTemplateHook<shim::hooks::ServerSystemProcessTurnHook>(tracer, exeBase, sots::addr::ServerSystem_ProcessTurn);
// Lane N: the two side-effect-free output functions. GroupOutput is the population ->
// output law itself; ComputeTotalOutput is the sum the budget roll-up ultimately reads.
shim::hooks::init_system_output(exeBase, &ShimLogLine);
InstallTemplateHook<shim::hooks::ServerSystemGroupOutputHook>(tracer, exeBase, sots::addr::ServerSystem_GroupOutput);
InstallTemplateHook<shim::hooks::ServerSystemComputeTotalOutputHook>(tracer, exeBase, sots::addr::ServerSystem_ComputeTotalOutput);
// Lane T: the per-player turn driver (once per player per turn). Verified thiscall with
// one ignored float argument; see docs/T-turn-driver.md.
shim::hooks::init_player_turn(exeBase, &ShimLogLine);
@ -310,6 +319,8 @@ void Shim_Init(HMODULE self) {
ProcessResearchHook::register_policy(tracer);
OnTechResearchedHook::register_policy(tracer);
ColonyTurnHook::register_policy(tracer);
GroupOutputHook::register_policy(tracer);
TotalOutputHook::register_policy(tracer);
PlayerTurnHook::register_policy(tracer);
MoveFleetHook::register_policy(tracer);
FleetMovementHook::register_policy(tracer);

31
src/shim/shim.cfg.output Normal file
View file

@ -0,0 +1,31 @@
# Lane N -- the population -> base-output term, in compare mode.
# `hooks=off` short-circuits before any hook is installed, so the default must be `trace`
# and every other hook turned off by name.
hooks=trace
hook.Shim::SelfTest::Fill=off
hook.Shim::SelfTest::FillGuard=off
hook.Shim::SelfTest::FillGuardLying=off
hook.Shim::SelfTest::FillThrows=off
hook.Shim::SelfTest::FillWrong=off
hook.Mars::GlobalConsts::LoadFile=off
hook.Game::WeaponDictionary::Init=off
hook.Game::SectionDictionary::SectionDictionary=off
hook.Game::ServerPlayer::ComputeBudget=off
hook.Game::TechTree::ProcessResearch=off
hook.Game::ServerPlayer::OnTechResearched=off
hook.Game::ServerSystem::ProcessTurn=off
hook.Game::ServerPlayer::ProcessTurn=off
hook.Game::StrategyServer::MoveFleet=off
hook.Game::StrategyServer::ProcessFleetMovement=off
hook.Game::StrategyHost::Autosave=off
hook.Game::StrategyServer::ProcessTurn=off
hook.Game::StrategyServer::OnAllCombatDone_Tail=off
hook.Game::StrategyServer::ApplyEncounterResult=off
hook.Game::StrategyServer::NodeLineDecay=off
hook.Game::StrategyServer::ProcessNodeSpaceTravel=off
hook.Game::EncounterDetect::AssignContacts=off
hook.Game::ServerSystem::GroupOutput=compare
hook.Game::ServerSystem::ComputeTotalOutput=compare
trace.path=C:\SOTS\shim.trace.jsonl
trace.inline_max=256
trace.flush=always

View file

@ -270,21 +270,112 @@ static void test_output() {
CHECK_NEAR(MoraleOutputMultiplier(75, t), 1.1, 0.0);
CHECK_NEAR(MoraleOutputMultiplier(50, t), 1.0, 0.0);
CHECK_NEAR(MoraleOutputMultiplier(25, t), 0.9, 0.0);
// A morale entry of exactly 0 means "no record": the thresholds are not consulted,
// which matters because 0 <= MORALE_DECREASE_OUTPUT would otherwise apply the penalty.
CHECK_NEAR(MoraleOutputMultiplier(0, t), 1.0, 0.0);
// A modifier that is not strictly positive is ignored (an unloaded tuning table has
// every field at zero, and a zero multiplier would silently wipe the term).
TuningTable zero;
zero.MORALE_INCREASE_OUTPUT = 60;
CHECK_NEAR(MoraleOutputMultiplier(80, zero), 1.0, 0.0);
OutputModifiers m;
m.baseOutput = 1000;
m.morale = 80;
m.stations = 1;
CHECK_NEAR(TotalSystemOutput(m, t), 1210.0, 0.0); // 1000 x 1.1 x 1.1
CHECK_NEAR(TotalSystemOutput(m, t), 1000.0, 0.0);
m.addictionPhase3 = true;
CHECK_NEAR(TotalSystemOutput(m, t), 605.0, 0.0);
CHECK_NEAR(TotalSystemOutput(m, t), 500.0, 0.0);
m.addictionPhase3 = false;
m.playerOutMod = 0.5;
m.systemOutMod = 0.5;
CHECK_NEAR(TotalSystemOutput(m, t), 250.0, 0.0);
// B4: the engine's round is ties-to-EVEN, so 302.5 goes DOWN to 302 (it used to be 303)
m.baseOutput = 1210;
CHECK_NEAR(TotalSystemOutput(m, t), 302.0, 0.0);
m.baseOutput = 0;
CHECK_NEAR(TotalSystemOutput(m, t), 0.0, 0.0);
// No owner and a rebelling system both return zero before any multiplier runs.
m.baseOutput = 1000;
m.playerOutMod = 1.0;
m.systemOutMod = 1.0;
m.owned = false;
CHECK_NEAR(TotalSystemOutput(m, t), 0.0, 0.0);
m.owned = true;
m.rebelling = true;
CHECK_NEAR(TotalSystemOutput(m, t), 0.0, 0.0);
m.rebelling = false;
// ---- the population -> output term (lane N) ----------------------------------------
// Output per head is typeOutputMod x 1.8 / 500000; the imperial row's modifier is 1.
GroupOutputInputs g;
g.group = PopGroup::Imperial;
g.count = 2000000000LL;
CHECK_NEAR(GroupOutput(g, t), 7200.0, 1e-9); // 2e9 / 5e5 x 1.8
g.stations = 2; // 1 + 2 x 0.1
CHECK_NEAR(GroupOutput(g, t), 8640.0, 1e-9);
g.stations = 0;
g.count = 0;
CHECK_NEAR(GroupOutput(g, t), 0.0, 0.0);
g.count = -5;
CHECK_NEAR(GroupOutput(g, t), 0.0, 0.0);
// The station bonus is imperial-only, and civilians carry the morale multiplier.
g.group = PopGroup::Civilian;
g.count = 500000000LL;
g.stations = 4;
g.morale = 0;
CHECK_NEAR(GroupOutput(g, t), 500000000.0 / 500000.0 * F32(0.33) * 1.8, 1e-9);
g.morale = 80; // above MORALE_INCREASE_OUTPUT
CHECK_NEAR(GroupOutput(g, t), 500000000.0 / 500000.0 * F32(0.33) * 1.8 * 1.1, 1e-9);
g.independent = true; // an independent colony has no morale
CHECK_NEAR(GroupOutput(g, t), 500000000.0 / 500000.0 * F32(0.33) * 1.8, 1e-9);
// The whole base-output sum on the reference save's human homeworld, with the two
// data-file species fields left at zero so only the terms the executable carries move.
BaseOutputInputs b;
b.imperialPopulation = 2000000000LL; // Pop 1e9 + pbon 1e9
b.civilianPopulation = 500000000LL;
b.civilianMorale = 75;
b.transitResources = 0;
b.resourcesAvailable = 5000;
b.infra = 1.0f;
b.infraBonus = 1.0f;
b.overHarvestRate = 0.0;
// cbrt(2e9/100) x 0.01 = 2.71 -> clamps to 1, and a clamped value at or above 1 - 1e-4
// is *substituted* by the infrastructure term rather than capping it. Infra + ibon = 2
// here, so the fraction is 2, not 1 -- the branch is a substitution, not a min, and a
// pending infrastructure bonus can push a colony's extraction above unity.
CHECK_NEAR(StripMineFraction({b.imperialPopulation, b.infra, b.infraBonus}), 2.0f, 0.0);
// ... and a colony whose population term has not saturated is capped by it as usual.
CHECK_NEAR(StripMineFraction({1000000, 0.4f, 0.0f}), 0.21544346f, 1e-6f);
CHECK_NEAR(StripMineFraction({100000000LL, 0.4f, 0.0f}), 0.4f, 0.0);
const double expected = 7200.0 + 500000000.0 / 500000.0 * F32(0.33) * 1.8 * 1.1 + 9000.0;
CHECK_NEAR(SystemBaseOutput(b, t), expected, 1e-6);
// Linear in population: a tenth of the imperial pop is a tenth of that term.
b.imperialPopulation = 200000000LL;
b.civilianPopulation = 0;
b.resourcesAvailable = 0;
CHECK_NEAR(SystemBaseOutput(b, t), 720.0, 1e-9);
// With SRoh = 0 the over-harvest demand degenerates to min(available, speciesBaseDemand).
OverHarvestInputs oh;
oh.resourcesAvailable = 5000;
oh.population = 2000000000LL;
oh.speciesBaseDemand = 120;
CHECK_NEAR(OverHarvestDemand(oh), 120.0, 0.0);
oh.speciesBaseDemand = 9000;
CHECK_NEAR(OverHarvestDemand(oh), 5000.0, 0.0);
// ... and with SRoh > 0 it adds rate x available x clamp01(pop x 1e-5), floored at 1.
oh.speciesBaseDemand = 0;
oh.overHarvestRate = 0.5;
CHECK_NEAR(OverHarvestDemand(oh), 2500.0, 1e-9); // clamp01(2e9 x 1e-5) = 1
oh.population = 10000; // clamp01(0.1)
CHECK_NEAR(OverHarvestDemand(oh), 250.0, 1e-9);
oh.population = 0; // the floor of 1, not 0
CHECK_NEAR(OverHarvestDemand(oh), 1.0, 0.0);
// The population-type table the executable builds in code.
CHECK_NEAR(PopTypeOf(PopGroup::Imperial, t).outputMod, 1.0, 0.0);
CHECK_NEAR(PopTypeOf(PopGroup::Civilian, t).outputMod, F32(0.33), 0.0);
CHECK_EQ(static_cast<int>(PopTypeOf(PopGroup::Imperial, t).maxPopulation), 50000000);
CHECK_EQ(static_cast<int>(PopTypeOf(PopGroup::Civilian, t).maxPopulation), 20000000);
CHECK_NEAR(RoundHalfEven(0.5), 0.0, 0.0);
CHECK_NEAR(RoundHalfEven(1.5), 2.0, 0.0);
CHECK_NEAR(RoundHalfEven(-2.5), -2.0, 0.0);

View file

@ -326,6 +326,17 @@ static void test_bankruptcy() {
CHECK_EQ(l.eliminationFloor, -2000000000);
CHECK_EQ(l.protectionLimit, -1320000000);
// LANE N: the divisor is the widened float literal, not the decimal -0.15. The two
// disagree for every maxIncome divisible by 3, and maxIncome = 3 is where it first
// bites: 3 / -0.15 is -20 exactly in decimal but -19.99999920... with the image's
// constant, and the conversion TRUNCATES.
CHECK_EQ(ComputeBankruptcyLimits(3, t).eliminationFloor, -19);
CHECK_EQ(ComputeBankruptcyLimits(6, t).eliminationFloor, -39);
CHECK_EQ(ComputeBankruptcyLimits(9, t).eliminationFloor, -59);
// ... and above ~3,000,000 maximum income they differ on essentially every value.
CHECK_EQ(ComputeBankruptcyLimits(238592, t).eliminationFloor, -1590613);
CHECK_EQ(ComputeBankruptcyLimits(3000001, t).eliminationFloor, -20000005);
BankruptcyLimits none = ComputeBankruptcyLimits(0, t);
CHECK_EQ(none.eliminationFloor, 0);
CHECK_EQ(none.protectionLimit, 0);