merge lane N: system output term (24,357 guarded calls, 0 undeclared writes); bankruptcy divisor fixed
This commit is contained in:
commit
b2bad30f6c
15 changed files with 1091 additions and 33 deletions
|
|
@ -96,6 +96,7 @@ if(WIN32)
|
||||||
src/shim/hooks/tech_effects.cpp
|
src/shim/hooks/tech_effects.cpp
|
||||||
src/shim/hooks/compute_budget.cpp
|
src/shim/hooks/compute_budget.cpp
|
||||||
src/shim/hooks/colony_turn.cpp
|
src/shim/hooks/colony_turn.cpp
|
||||||
|
src/shim/hooks/system_output.cpp
|
||||||
src/shim/hooks/fleet_movement.cpp
|
src/shim/hooks/fleet_movement.cpp
|
||||||
src/shim/hooks/player_turn.cpp
|
src/shim/hooks/player_turn.cpp
|
||||||
src/shim/hooks/tail_rng.cpp
|
src/shim/hooks/tail_rng.cpp
|
||||||
|
|
|
||||||
104
docs/N-output-term.md
Normal file
104
docs/N-output-term.md
Normal 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.
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// GENERATED — do not edit. Facts about Sword of the Stars.exe (GOG 1.8.1).
|
// 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).
|
// Runtime address = (uintptr_t)GetModuleHandle(NULL) + RVA (the exe is ASLR-relocated).
|
||||||
#pragma once
|
#pragma once
|
||||||
#include <cstdint>
|
#include <cstdint>
|
||||||
|
|
|
||||||
|
|
@ -234,21 +234,147 @@ OutputRates NormaliseOutputRates(const OutputRates& raw, bool suitAtIdeal, bool
|
||||||
return r;
|
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) {
|
double MoraleOutputMultiplier(int morale, const TuningTable& t) {
|
||||||
if (morale >= t.MORALE_INCREASE_OUTPUT) return t.MORALE_INCREASE_OUTPUT_MOD;
|
// An entry of exactly zero means "no morale record for this species": the original
|
||||||
if (morale <= t.MORALE_DECREASE_OUTPUT) return t.MORALE_DECREASE_OUTPUT_MOD;
|
// 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;
|
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;
|
double v = m.baseOutput;
|
||||||
v *= MoraleOutputMultiplier(m.morale, t);
|
v = Narrow(v * m.playerOutMod);
|
||||||
v *= 1.0 + t.STATION_BONUS_IMPERIAL_OUTPUT * m.stations;
|
v = Narrow(v * m.systemOutMod);
|
||||||
if (m.addictionPhase3) v *= t.ADDICTION_OUTPUT_MOD;
|
v = Narrow(v * m.techOutMod);
|
||||||
v *= m.scOutMod * m.rebOutMod * m.techOutMod * m.systemOutMod * m.playerOutMod;
|
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,
|
// 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.
|
// 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) {
|
OutputSplit SplitOutput(double total, const OutputRates& rates) {
|
||||||
|
|
|
||||||
|
|
@ -263,30 +263,142 @@ constexpr double kOutputRateThreshold = 9.999999747378752e-05;
|
||||||
// argument's default and the float32 accumulation.
|
// argument's default and the float32 accumulation.
|
||||||
OutputRates NormaliseOutputRates(const OutputRates& raw, bool suitAtIdeal, bool infraFull);
|
OutputRates NormaliseOutputRates(const OutputRates& raw, bool suitAtIdeal, bool infraFull);
|
||||||
|
|
||||||
struct OutputModifiers {
|
// ---------------------------------------------------------------------------------------
|
||||||
double baseOutput = 0; // population-derived base (its own formula is unresolved)
|
// Base output: the population, resource and over-harvest terms
|
||||||
int morale = 0;
|
//
|
||||||
int stations = 0; // stations at the system
|
// LANE N CORRECTION (2026-09-08). The previous model here treated the whole of a system's
|
||||||
bool addictionPhase3 = false;
|
// output as one multiplicative chain, `base x morale x stationFactor x ...`, with `baseOutput`
|
||||||
double scOutMod = 1.0; // ScOutMod
|
// an unresolved input. That is the wrong shape. The original sums **three independent terms**
|
||||||
double rebOutMod = 1.0; // RebOutMod
|
// and applies morale and the station bonus to only some of them; the five player/system
|
||||||
double techOutMod = 1.0; // tech-effect output multiplier
|
// multipliers at the end are the only genuinely global factors.
|
||||||
double systemOutMod = 1.0; // sys.OutMod
|
//
|
||||||
double playerOutMod = 1.0; // OutMod
|
// 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
|
// Slave-row values come from the data files, so they are taken from the tuning table.
|
||||||
// decrease threshold x DECREASE_MOD, otherwise x1. CONFIDENCE: high.
|
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);
|
double MoraleOutputMultiplier(int morale, const TuningTable& t);
|
||||||
|
|
||||||
// total = roundHalfEven(base x morale x (1 + STATION_BONUS_IMPERIAL_OUTPUT x stations)
|
// Resource extraction efficiency: `min( clamp01(cbrt((Pop + pbon)/100) x 0.01), Infra + ibon )`,
|
||||||
// x addiction x ScOutMod x RebOutMod x techOut x sys.OutMod x OutMod)
|
// narrowed to float32 on the way in and on the way out. The `>= 1 - 1e-4` branch substitutes
|
||||||
// B4 correction: the engine's "round" is `fistp`/`fild` -- round to nearest, ties to EVEN --
|
// the infrastructure term outright. CONFIDENCE: high.
|
||||||
// and the result stays a double that the channel splits multiply; only the reported total
|
struct StripMineInputs {
|
||||||
// slot truncates it. CONFIDENCE: high on the multiplier chain and the rounding mode; the
|
std::int64_t population = 0; // Pop + pbon
|
||||||
// base-output-from-population term is an input because its own formula is not resolved.
|
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);
|
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 {
|
struct OutputSplit {
|
||||||
double trade = 0;
|
double trade = 0;
|
||||||
double construction = 0;
|
double construction = 0;
|
||||||
|
|
|
||||||
|
|
@ -162,7 +162,18 @@ BankruptcyLimits ComputeBankruptcyLimits(int maxIncome, const TuningTable& t) {
|
||||||
constexpr int kTreasuryLimit = 2000000000;
|
constexpr int kTreasuryLimit = 2000000000;
|
||||||
BankruptcyLimits l;
|
BankruptcyLimits l;
|
||||||
// Elimination: the debt whose 15 %/turn interest equals the maximum income.
|
// 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.
|
// 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.protectionLimit = std::max(-Ftol(t.BANKRUPTCY_PROTECTION_LIMIT_FACTOR * static_cast<double>(maxIncome)),
|
||||||
l.eliminationFloor);
|
l.eliminationFloor);
|
||||||
|
|
|
||||||
|
|
@ -160,13 +160,18 @@ int TradeRouteIncome(const TradeRouteState& route, bool asOwner, double difficul
|
||||||
// Bankruptcy
|
// 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 {
|
struct BankruptcyLimits {
|
||||||
int eliminationFloor = 0; // BnkEl: below this the player is on the elimination clock
|
int eliminationFloor = 0; // BnkEl: below this the player is on the elimination clock
|
||||||
int protectionLimit = 0; // BnkPr: below this cost-cutting starts
|
int protectionLimit = 0; // BnkPr: below this cost-cutting starts
|
||||||
};
|
};
|
||||||
|
|
||||||
// Limits from the sum of every owned system's maximum money output:
|
// 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)
|
// (the debt at which 15 %/turn interest eats the whole maximum income)
|
||||||
// protectionLimit = max(-ftol(BANKRUPTCY_PROTECTION_LIMIT_FACTOR x maxIncome), eliminationFloor)
|
// 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
|
// The limits a turn's check uses are the ones computed at the end of the previous turn
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// 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)); }
|
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
|
// 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
|
// compile time, so `0.02` in the disassembly is really 0.019999999552965164; using the exact
|
||||||
// decimal rounds differently at a truncation or comparison boundary.
|
// decimal rounds differently at a truncation or comparison boundary.
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,12 @@ struct TuningTable {
|
||||||
double SLAVES_DEATH_RATE_BYOUTPUT = 0;
|
double SLAVES_DEATH_RATE_BYOUTPUT = 0;
|
||||||
std::int64_t SLAVES_MIN_DEATHS = 0;
|
std::int64_t SLAVES_MIN_DEATHS = 0;
|
||||||
std::int64_t SLAVES_MAX_DEATHS = -1; // -1 = no upper clamp
|
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) ----
|
// ---- system bonus (long-held stable colonies) ----
|
||||||
int SYSTEMBONUS_MINTURNS = 0;
|
int SYSTEMBONUS_MINTURNS = 0;
|
||||||
|
|
|
||||||
416
src/shim/hooks/system_output.cpp
Normal file
416
src/shim/hooks/system_output.cpp
Normal 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
|
||||||
111
src/shim/hooks/system_output.h
Normal file
111
src/shim/hooks/system_output.h
Normal 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
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
#include "shim/fpu_force.h"
|
#include "shim/fpu_force.h"
|
||||||
#include "shim/hooks/dictionaries.h"
|
#include "shim/hooks/dictionaries.h"
|
||||||
#include "shim/hooks/colony_turn.h"
|
#include "shim/hooks/colony_turn.h"
|
||||||
|
#include "shim/hooks/system_output.h"
|
||||||
#include "shim/hooks/player_turn.h"
|
#include "shim/hooks/player_turn.h"
|
||||||
#include "shim/hooks/compute_budget.h"
|
#include "shim/hooks/compute_budget.h"
|
||||||
#include "shim/hooks/fleet_movement.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 OnTechResearchedHook = shim::trace::Hook<shim::hooks::ServerPlayerOnTechResearchedHook>;
|
||||||
using ComputeBudgetHook = shim::trace::Hook<shim::hooks::ComputeBudgetHook>;
|
using ComputeBudgetHook = shim::trace::Hook<shim::hooks::ComputeBudgetHook>;
|
||||||
using ColonyTurnHook = shim::trace::Hook<shim::hooks::ServerSystemProcessTurnHook>;
|
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 PlayerTurnHook = shim::trace::Hook<shim::hooks::ServerPlayerProcessTurnHook>;
|
||||||
using MoveFleetHook = shim::trace::Hook<shim::hooks::StrategyServerMoveFleetHook>;
|
using MoveFleetHook = shim::trace::Hook<shim::hooks::StrategyServerMoveFleetHook>;
|
||||||
using FleetMovementHook = shim::trace::Hook<shim::hooks::StrategyServerProcessFleetMovementHook>;
|
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.
|
// points. All three are verified thiscall prototypes with no stack-argument surprises.
|
||||||
shim::hooks::init_colony_turn(exeBase, &ShimLogLine);
|
shim::hooks::init_colony_turn(exeBase, &ShimLogLine);
|
||||||
InstallTemplateHook<shim::hooks::ServerSystemProcessTurnHook>(tracer, exeBase, sots::addr::ServerSystem_ProcessTurn);
|
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
|
// 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.
|
// one ignored float argument; see docs/T-turn-driver.md.
|
||||||
shim::hooks::init_player_turn(exeBase, &ShimLogLine);
|
shim::hooks::init_player_turn(exeBase, &ShimLogLine);
|
||||||
|
|
@ -310,6 +319,8 @@ void Shim_Init(HMODULE self) {
|
||||||
ProcessResearchHook::register_policy(tracer);
|
ProcessResearchHook::register_policy(tracer);
|
||||||
OnTechResearchedHook::register_policy(tracer);
|
OnTechResearchedHook::register_policy(tracer);
|
||||||
ColonyTurnHook::register_policy(tracer);
|
ColonyTurnHook::register_policy(tracer);
|
||||||
|
GroupOutputHook::register_policy(tracer);
|
||||||
|
TotalOutputHook::register_policy(tracer);
|
||||||
PlayerTurnHook::register_policy(tracer);
|
PlayerTurnHook::register_policy(tracer);
|
||||||
MoveFleetHook::register_policy(tracer);
|
MoveFleetHook::register_policy(tracer);
|
||||||
FleetMovementHook::register_policy(tracer);
|
FleetMovementHook::register_policy(tracer);
|
||||||
|
|
|
||||||
31
src/shim/shim.cfg.output
Normal file
31
src/shim/shim.cfg.output
Normal 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
|
||||||
|
|
@ -270,21 +270,112 @@ static void test_output() {
|
||||||
CHECK_NEAR(MoraleOutputMultiplier(75, t), 1.1, 0.0);
|
CHECK_NEAR(MoraleOutputMultiplier(75, t), 1.1, 0.0);
|
||||||
CHECK_NEAR(MoraleOutputMultiplier(50, t), 1.0, 0.0);
|
CHECK_NEAR(MoraleOutputMultiplier(50, t), 1.0, 0.0);
|
||||||
CHECK_NEAR(MoraleOutputMultiplier(25, t), 0.9, 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;
|
OutputModifiers m;
|
||||||
m.baseOutput = 1000;
|
m.baseOutput = 1000;
|
||||||
m.morale = 80;
|
CHECK_NEAR(TotalSystemOutput(m, t), 1000.0, 0.0);
|
||||||
m.stations = 1;
|
|
||||||
CHECK_NEAR(TotalSystemOutput(m, t), 1210.0, 0.0); // 1000 x 1.1 x 1.1
|
|
||||||
m.addictionPhase3 = true;
|
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.addictionPhase3 = false;
|
||||||
m.playerOutMod = 0.5;
|
m.playerOutMod = 0.5;
|
||||||
m.systemOutMod = 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)
|
// 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);
|
CHECK_NEAR(TotalSystemOutput(m, t), 302.0, 0.0);
|
||||||
m.baseOutput = 0;
|
m.baseOutput = 0;
|
||||||
CHECK_NEAR(TotalSystemOutput(m, t), 0.0, 0.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(0.5), 0.0, 0.0);
|
||||||
CHECK_NEAR(RoundHalfEven(1.5), 2.0, 0.0);
|
CHECK_NEAR(RoundHalfEven(1.5), 2.0, 0.0);
|
||||||
CHECK_NEAR(RoundHalfEven(-2.5), -2.0, 0.0);
|
CHECK_NEAR(RoundHalfEven(-2.5), -2.0, 0.0);
|
||||||
|
|
|
||||||
|
|
@ -326,6 +326,17 @@ static void test_bankruptcy() {
|
||||||
CHECK_EQ(l.eliminationFloor, -2000000000);
|
CHECK_EQ(l.eliminationFloor, -2000000000);
|
||||||
CHECK_EQ(l.protectionLimit, -1320000000);
|
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);
|
BankruptcyLimits none = ComputeBankruptcyLimits(0, t);
|
||||||
CHECK_EQ(none.eliminationFloor, 0);
|
CHECK_EQ(none.eliminationFloor, 0);
|
||||||
CHECK_EQ(none.protectionLimit, 0);
|
CHECK_EQ(none.protectionLimit, 0);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue