diff --git a/CMakeLists.txt b/CMakeLists.txt index 927ac9a..3f925b7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -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 diff --git a/docs/N-output-term.md b/docs/N-output-term.md new file mode 100644 index 0000000..0b85144 --- /dev/null +++ b/docs/N-output-term.md @@ -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. diff --git a/include/generated/sots_addresses.h b/include/generated/sots_addresses.h index 780dc0d..b776e8f 100644 --- a/include/generated/sots_addresses.h +++ b/include/generated/sots_addresses.h @@ -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 diff --git a/src/game/sim/colony.cpp b/src/game/sim/colony.cpp index 9048e06..1eb0599 100644 --- a/src/game/sim/colony.cpp +++ b/src/game/sim/colony.cpp @@ -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(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(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(in.infraBonus) + static_cast(in.infra)); + const double pop = static_cast(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(r < fi ? r : fi); +} + +double OverHarvestDemand(const OverHarvestInputs& in) { + const double avail = static_cast(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(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(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(in.transitResources + in.resourcesAvailable) * + static_cast(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) { diff --git a/src/game/sim/colony.h b/src/game/sim/colony.h index 8575f5f..46904f2 100644 --- a/src/game/sim/colony.h +++ b/src/game/sim/colony.h @@ -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; diff --git a/src/game/sim/economy.cpp b/src/game/sim/economy.cpp index 99f1891..4c791f4 100644 --- a/src/game/sim/economy.cpp +++ b/src/game/sim/economy.cpp @@ -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(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(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(maxIncome)), l.eliminationFloor); diff --git a/src/game/sim/economy.h b/src/game/sim/economy.h index 85ae86c..e0c0899 100644 --- a/src/game/sim/economy.h +++ b/src/game/sim/economy.h @@ -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 diff --git a/src/game/sim/numeric.h b/src/game/sim/numeric.h index 6e292b3..6b4aa71 100644 --- a/src/game/sim/numeric.h +++ b/src/game/sim/numeric.h @@ -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(static_cast(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. diff --git a/src/game/sim/tuning.h b/src/game/sim/tuning.h index e286b50..a1265ca 100644 --- a/src/game/sim/tuning.h +++ b/src/game/sim/tuning.h @@ -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; diff --git a/src/shim/hooks/system_output.cpp b/src/shim/hooks/system_output.cpp new file mode 100644 index 0000000..ab57e1f --- /dev/null +++ b/src/shim/hooks/system_output.cpp @@ -0,0 +1,416 @@ +#include "shim/hooks/system_output.h" + +#include +#include +#include + +#if defined(_WIN32) +#define WIN32_LEAN_AND_MEAN +#include +#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(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(mbi.BaseAddress) + mbi.RegionSize; + } + return true; +#else + return true; +#endif +} + +template +T peek(const void* base, std::size_t off) { + T v{}; + std::memcpy(&v, static_cast(base) + off, sizeof v); + return v; +} +void* ptr_at(const void* base, std::size_t off) { return peek(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 +T global_const_via_slot(std::uintptr_t slot_rva, T fallback) { + if (!g_env.exe_base) return fallback; + void** slot = reinterpret_cast(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 +T global_const_at(std::uintptr_t rva, T fallback) { + if (!g_env.exe_base) return fallback; + const void* p = reinterpret_cast(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(ptr_at(pop, 0x4)); + const char* end = static_cast(ptr_at(pop, 0x8)); + if (!begin || !end || end < begin) return 0; + const std::size_t n = static_cast(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(e, 0x4) != groupType) continue; + if (peek(e, 0x8) != species) continue; + sum += peek(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(A::GlobalConst_slot_STATION_BONUS_IMPERIAL_OUTPUT, 0.0f); + t.moraleIncreaseOutput = + global_const_via_slot(A::GlobalConst_slot_MORALE_INCREASE_OUTPUT, 0); + t.moraleIncreaseOutputMod = + global_const_via_slot(A::GlobalConst_slot_MORALE_INCREASE_OUTPUT_MOD, 0.0f); + t.moraleDecreaseOutput = + global_const_via_slot(A::GlobalConst_slot_MORALE_DECREASE_OUTPUT, 0); + t.moraleDecreaseOutputMod = + global_const_via_slot(A::GlobalConst_slot_MORALE_DECREASE_OUTPUT_MOD, 0.0f); + t.slavesOutputMod = global_const_at(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(row + 0x10, 0.0f); + t.popTypeMaxPop[i] = global_const_at(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(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(indi, 4); + if (owner && readable(owner, A::ServerPlayer_off_Species + 4)) + return peek(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(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& 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(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& 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(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(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& 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(self, A::ServerSystem_off_Idx); + st.ownerSpecies = sp; + + std::int64_t resAvail = peek(self, A::ServerSystem_off_Res); + bool stripMines = false; + if (owner && readable(owner, A::ServerPlayer_off_SetupOutputMult + 4)) { + stripMines = peek(owner, A::ServerPlayer_off_AMine) != 0; + st.mods.playerOutMod = peek(owner, A::ServerPlayer_off_OutMod); + st.mods.rebOutMod = peek(owner, A::ServerPlayer_off_RebOutMod); + st.mods.scOutMod = peek(owner, A::ServerPlayer_off_ScOutMod); + st.mods.techOutMod = peek(owner, A::ServerPlayer_off_SetupOutputMult); + } + if (stripMines) { + resAvail += peek(self, A::ServerSystem_off_MRes); + resAvail += peek(self, A::ServerSystem_off_ARes2); + } + + sots::sim::BaseOutputInputs& in = st.in; + in.imperialPopulation = std::int64_t(peek(self, A::ServerSystem_off_pbon)) + + std::int64_t(peek(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(self) + A::ServerSystem_off_Pop2; + const void* pbon2 = static_cast(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(self, A::ServerSystem_off_TRes); + in.resourcesAvailable = resAvail; + in.infra = peek(self, A::ServerSystem_off_Infra); + in.infraBonus = peek(self, A::ServerSystem_off_ibon); + in.overHarvestRate = overHarvestRate; + + if (const void* def = species_def(sp)) { + st.speciesBaseDemand = peek(def, 0x4c); + st.speciesResourceOutput = peek(def, 0x50); + } + in.speciesBaseDemand = st.speciesBaseDemand; + in.speciesResourceOutput = st.speciesResourceOutput; + + st.mods.owned = owner != nullptr; + st.mods.rebelling = peek(self, A::ServerSystem_off_rbfl) != 0; + st.mods.systemOutMod = peek(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(st.mods.playerOutMod)).named("OutMod")); + out.push_back(tv::f32(static_cast(st.mods.systemOutMod)).named("sysOutMod")); + out.push_back(tv::f32(static_cast(st.mods.techOutMod)).named("setupOutMod")); + out.push_back(tv::f32(static_cast(st.mods.rebOutMod)).named("RebOutMod")); + out.push_back(tv::f32(static_cast(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& 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 diff --git a/src/shim/hooks/system_output.h b/src/shim/hooks/system_output.h new file mode 100644 index 0000000..2e3242a --- /dev/null +++ b/src/shim/hooks/system_output.h @@ -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 +#include +#include + +#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; + + static void describe_args(std::vector& 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& 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; + + static void describe_args(std::vector& out, void* self, double overHarvestRate); + static trace::Tv describe_ret(double r); + static void regions(std::vector& 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 diff --git a/src/shim/main.cpp b/src/shim/main.cpp index bcf0bbd..617a525 100644 --- a/src/shim/main.cpp +++ b/src/shim/main.cpp @@ -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; using ComputeBudgetHook = shim::trace::Hook; using ColonyTurnHook = shim::trace::Hook; +// Lane N: the population -> base-output term (docs/N-output-term.md). +using GroupOutputHook = shim::trace::Hook; +using TotalOutputHook = shim::trace::Hook; using PlayerTurnHook = shim::trace::Hook; using MoveFleetHook = shim::trace::Hook; using FleetMovementHook = shim::trace::Hook; @@ -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(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(tracer, exeBase, sots::addr::ServerSystem_GroupOutput); + InstallTemplateHook(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); diff --git a/src/shim/shim.cfg.output b/src/shim/shim.cfg.output new file mode 100644 index 0000000..8477624 --- /dev/null +++ b/src/shim/shim.cfg.output @@ -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 diff --git a/tests/game_sim/test_colony.cpp b/tests/game_sim/test_colony.cpp index 1a4ed05..b37771d 100644 --- a/tests/game_sim/test_colony.cpp +++ b/tests/game_sim/test_colony.cpp @@ -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(PopTypeOf(PopGroup::Imperial, t).maxPopulation), 50000000); + CHECK_EQ(static_cast(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); diff --git a/tests/game_sim/test_economy.cpp b/tests/game_sim/test_economy.cpp index 08ccade..65c8227 100644 --- a/tests/game_sim/test_economy.cpp +++ b/tests/game_sim/test_economy.cpp @@ -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);