The second turn driver, 0x007d92a0, read byte for byte. Corrects turn-driver.md section 5: of the four subsystems it said live here, only bankruptcy does. - 36-phase map with strides enumerated (EncounterResults 0x178, Encounter 0x74, member 0x44) and the arity check that logs but does not return. - Phase 7 is encounters.clear(), not a filter: the erase pair is the same four-argument shape vector<Encounter>::operator= uses, and both arms converge three instructions later. - Bankruptcy: ProcessBankruptcy at phase 15, UpdateBankruptcyLimits at phase 31. Three corrections to formula-gaps Q1 - the divisor is the double -0.15000000596046448 not -0.15, the per-system income term is clamped at 0 before summing, and the 3.3 factor lives in .bss and is DB-loaded. - Turn results are FILLED here (phases 6, 11, 18 write S+0x2f4[PlyrIdx]) but rotated by ApplyEncounterResults and dispatched by SynchronizePlayer as event 0x25 afterwards. sizeof(SETurnResults) = 0x11c, enumerated five ways. - BuildTurnEvents is misnamed: it is the setup/load/rejoin resync push, gated on a pending descriptor, and references no EVENT_ string at all. - TurnEvents_Write and TurnEvents_Read are swapped in Ghidra (layouts.json is right). sizeof(TurnEvents) = 0x18, enumerated four ways. - The autosave: StrategyHost::Autosave 0x00895210, its four localized paths, the rotation that fires only on the post-turn call, the connection detach around the write, and why the payload carries nothing time-, name- or machine-derived. - Two RNG sources in the tail that nothing models: one NextFloat per expired node line, plus draws inside the combat resolver. Both run before the autosave. - S+0x8 advances twice per turn, not once. Repo-wide correction: the research-event roll costs one or two RNG words, not one. Fixed in unlock-cascade.md, addresses.json and lane-u.json; the captured compare artefacts under verify/results are left alone as run records.
382 lines
28 KiB
Markdown
382 lines
28 KiB
Markdown
# Formula gaps from the game/sim port — answers from the binary
|
||
|
||
Evidence: annotated disassembly/decompiles in `handoff/tech-decompiles/` (files named by address). `ftol` = `_ftol2`
|
||
(0x00925220, truncation). Field names per `struct-recovery.md`.
|
||
|
||
## Q1. Which bankruptcy limit carries the 3.3 factor; when is the start turn stamped?
|
||
|
||
`ServerPlayer::UpdateBankruptcyLimits` 0x00818600 (disasm 0x0081863e–0x0081869e):
|
||
```
|
||
maxIncome = Σ_owned systems ComputeOutputMax(s)[3] // FUN_007521c0, int
|
||
BnkEl = ftol(maxIncome / -0.15) // FILD; FLD -0.15 (0x00a2ec30); FDIVR → -6.667 × maxIncome
|
||
if (BnkEl < -2,000,000,000) BnkEl = -2,000,000,000
|
||
BnkPr = -ftol(maxIncome × BANKRUPTCY_PROTECTION_LIMIT_FACTOR) // FMUL [g_BANKRUPTCY_PROTECTION_LIMIT_FACTOR] (3.3)
|
||
if (BnkPr < BnkEl) BnkPr = BnkEl // i.e. BnkPr = max(-3.3·maxIncome, BnkEl)
|
||
```
|
||
So the **3.3 factor is on the protection limit `BnkPr`**; the elimination limit `BnkEl` is `maxIncome / −0.15`
|
||
(the debt at which 15 %/turn interest equals maximum income). `BankruptcyLevel` (0x0080db10): 2 if `Sav < BnkEl`, 1 if
|
||
`Sav < BnkPr`, else 0.
|
||
|
||
Stamping: `ProcessBankruptcy` 0x007c0a50 per non-eliminated non-NPC player: `old = (BnkWrn, BnkTrn)`; `level =
|
||
BankruptcyLevel()`; `SetBankruptcyState(level)` (0x0080e260) = `if (level != BnkWrn) { BnkWrn = level; BnkTrn = level ?
|
||
ModCount : -1 }`; then decisions use **`old`**: cost-cutting (0x00889500) if `level != 0 && old.BnkWrn != 0`; elimination
|
||
if `old.BnkWrn == 2 && ModCount − old.BnkTrn ≥ BANKRUPTCY_ELIMINATION_TURNS`. Net: the stamp is written on the turn the
|
||
level changes (any change, 1↔2 included, resets it), and actions start the following turn.
|
||
Call sequence: `OnAllCombatDone_Tail` → … → `ProcessBankruptcy` → (later in the tail) `UpdateBankruptcyLimits`, so the
|
||
limits used by a turn's bankruptcy check are the ones computed at the end of the previous turn (and on load).
|
||
|
||
### Q1 addendum — lane K, 2026-09-08 (instruction-verified)
|
||
|
||
The call sequence above is now read off the instruction stream, with addresses:
|
||
`ProcessBankruptcy` 0x007c0a50 is **phase 15** of `StrategyServer::OnAllCombatDone_Tail`, at 0x007d9731;
|
||
per-player `UpdateBankruptcyLimits` 0x00818600 is **phase 31**, at 0x007d9876, 0x145 bytes later.
|
||
Those two — and `LoadGame` 0x007ddc16 — are the only callers of `UpdateBankruptcyLimits` in the image.
|
||
See `findings/control-flow/combat-done-tail.md` §4.
|
||
|
||
Three corrections to the formula block above:
|
||
|
||
1. **The divisor is not `-0.15`.** The `.rdata` double at 0x00a2ec30 is `-0.15000000596046448`, i.e.
|
||
`(double)(float)-0.15f`. Writing `-0.15` in a reimplementation is one ulp out on large empires.
|
||
2. **The per-system term is clamped at zero before summing.** `FUN_007521c0`
|
||
(= `ServerSystem::ComputeMaxIncome`) ends `return max(ComputeOutputRates(sys)[3], 0)` — a `jg`, so a
|
||
negative-income colony contributes 0 rather than reducing `maxIncome`.
|
||
3. **3.3 is not a binary constant.** `0x00aedfdc` points to `0x00b23e28`, which lies past `.data`'s raw end
|
||
(0x00b07600) — it is `.bss`, zero-initialised and filled from the game data files at load. The only
|
||
hard-coded constants here are the `-0.15000000596046448` double and the int32 `-2000000000` at
|
||
0x00a2c638. Treat 3.3 as DB-sourced, not as a fact about the binary.
|
||
|
||
Also: there are exactly two clamps and both are one-sided lower bounds, so **a player with zero owned
|
||
systems has `BnkEl == BnkPr == 0`** and `BankruptcyLevel` reads 2 for any negative savings.
|
||
|
||
## Q2 (order as asked: 3). Suitability → carrying-capacity hazard curve
|
||
|
||
`HazardMod` 0x00747ae0: `clamp01(1 − |Suit − IdealSuit| / (SuitTol + 0.1))` (0x00a1a438 = 0.1). Linear, no exponent.
|
||
`SuitTol` starts at the species value and is raised by BIO_AtmoAd (+0.75) and BIO_GrvAdpt (+1.5) (tech-effects §1).
|
||
Skipped (=1.0) when species flag bit 7 (accommodate xenotech) or RebAI.
|
||
|
||
## Q3. Trade-points → money system-income tail
|
||
|
||
`ServerSystem::TradePointsToMoney(trade)` 0x007505b0 (disasm complete):
|
||
```
|
||
t = (trade − fmod(trade, 5.0)) × 5.0 // whole 5-point blocks, ×5
|
||
t += PopIncome(0) // 0x0074d760(sys, 0): Σ_species ftol(GroupIncome(0, pop_imperial(sp))) (0x00535e80 group-type table row 0)
|
||
t += PopIncome(1) // civilians (row 1); own species adds (cap − pop) surplus term when at cap
|
||
t += SlaveIncome() // 0x0074b700: Σ_species ftol(GroupIncome(2, slaves(sp))) (row 2, SLAVES_INCOME_MOD)
|
||
t *= SpeciesDef(owner).incomeFactor (+0x18: Zuul 1.1, Morrigi 0.8, else 1) [1 if unowned]
|
||
t *= owner.IncMod (+0x30c) [1 if unowned]
|
||
t *= srv.+0xbc × DifficultyMods(owner)->+4 // 0x0080f470: server income modifier × AI trade/income difficulty mult
|
||
cost = SpeciesDef(owner).costFactor (+0x24: Zuul 0.7) × CalcSuitMod × 10000 × 1.5
|
||
CalcSuitMod (0x007484d0) = min(|IdealSuit(species) − Suit|, owner.SuitTol) (0 if RebAI; 20 if no owner)
|
||
money = ftol(t − cost)
|
||
```
|
||
Note `SuitTol` therefore caps the hazard **cost** as well as extending the habitable range. `ADDICTION_INCOME_MOD` is
|
||
applied inside PopIncome (0x0074d760 → 0x00746910 morale/addiction factor) — not verified line by line (MEDIUM).
|
||
|
||
## Q4. `POPBONUS_INC` population increment
|
||
|
||
`ServerSystem::AccrueSystemBonus` 0x0074d4f0 (disasm 0x0074d53b–0x0074d5be):
|
||
```
|
||
if owner && IsStable && ModCount − TAcq > SYSTEMBONUS_MINTURNS && ntdev > SYSTEMBONUS_MINTURNS:
|
||
cap = MaxPop(imperial) // 0x0074ab20(0,0)
|
||
target = SystemBonusPopTarget(POPBONUS) // 0x0074b5a0: ftol(max(POPBONUS,0) × cap), 0 for species with SpeciesDef+0x5c == 0 (Zuul)
|
||
inc = ftol(POPBONUS_INC × cap) // FIMUL: 0.005 × cap
|
||
pbon += min(max(inc, 0), max(target − pbon, 0))
|
||
ibon += min(max(INFRABONUS_INC, 0), max(INFRABONUS − ibon, 0)) // INFRABONUS target 0 for Zuul
|
||
```
|
||
(`POPBONUS_HOME`/`INFRABONUS_HOME` are only used when the bonus is (re)initialised for a home system, 0x0074c680/
|
||
0x007477a0; the per-turn accrual always uses the non-HOME keys.) Applied next turn by `ApplyPopBonus` (`Pop += min(pbon,
|
||
cap − Pop)`).
|
||
|
||
## Q5. Expense-slider request term
|
||
|
||
`ComputeBudget` 0x00863030, loop 0x00863431–0x0086349f over `Nexp` entries `{xid, xmin, xmax, xper}` (16 B):
|
||
```
|
||
availPre = max(0, [6]−[14]−[13]−[11]−[9]−[7]+[3]+[1]−[12]−[8]−[10]+[5]+[4]+[2]) // net before expenses ([12] still 0)
|
||
for each entry:
|
||
xminC = max(xmin, 0)
|
||
xmaxC = clamp(xmax, 0, 2e9); if (xmaxC == 0) xmaxC = 2e9 // 0 = unlimited
|
||
room = xmaxC − xminC
|
||
req = ftol(xper × (float)availPre) − xminC // FLD [entry+8]; FMUL ST1 (float(availPre))
|
||
take = min(max(req, 0), room)
|
||
ΣXmin += xminC; ΣTake += take
|
||
[12] += ΣXmin + min(max(ΣTake, 0), availPre − ΣXmin)
|
||
```
|
||
So `xper` is a fraction of the pre-expense available income, the request is `xper × avail` minus the mandatory
|
||
minimum, clamped to `[0, xmax − xmin]`, and the total is capped by what is left after all minimums.
|
||
|
||
## Q6. Which running total the tech-income bonus and the savings aid read
|
||
|
||
Disasm 0x0086382b–0x00863899:
|
||
```
|
||
net1 = [5]−[9]−[10]−[11]−[12]−[13]−[14]−[8]−[7]+[3]+[4]+[2]+[1]+[6] // [6] = 0, [14] = 0 at this point
|
||
if (net1 > 0) [6] = max(0, ftol((p.+0x228 − 1.0) × net1)) // FLD [ESI+0x228]; FSUB 1.0; FIMUL net1
|
||
if (savAid != 0):
|
||
net2 = same sum, now including the new [6]
|
||
newSav = SatAdd(Sav, net2) // clamped ±2e9
|
||
[14] = min(max(newSav, 0), max(savAid, 0))
|
||
```
|
||
The bonus is a share of the **full net** (all income incl. interest and trade, minus maintenance, research money,
|
||
construction, expenses, research aid); savings aid is capped by **projected savings after this turn**, not by the turn
|
||
net. `p.+0x228` (and `+0x224` = output multiplier read by `ComputeOutputFromRates`, `+0x22c` = research multiplier)
|
||
are copied by 0x0077b620 from a per-player 0x50-byte setup record (`+0x48/+0x4c/+0x50`) at game creation/sync — the
|
||
game-setup handicap block, not a tech (MEDIUM).
|
||
|
||
## Q7. Node-line speed clamp at the influence radius
|
||
|
||
`NodeLine::Step` 0x00705510 + `BuildStutterSegments` 0x00705280: the travel line is intersected with every system's
|
||
sphere of radius `STUTTER_SYSTEM_INFLUENCE_RADIUS` (0x008a64f0 ray/sphere → `[t0,t1] × len`, entries dropped when
|
||
shorter than 0.01, sorted, overlaps merged at the midpoint). Per segment:
|
||
```
|
||
dist = DistPointToSegment(system.pos, segStart, segEnd) // 0x008e8eb0, t clamped to [0,1]
|
||
v = nodespeed × ((STUTTER_MAX_SPEED − STUTTER_MIN_SPEED) × (dist / RADIUS) + STUTTER_MIN_SPEED)
|
||
```
|
||
There is **no explicit clamp**: `dist ≤ RADIUS` holds by construction (the segment lies inside the sphere), so
|
||
`v ∈ [MIN, MAX] × nodespeed`; outside every sphere the remainder of the step moves at plain `nodespeed` (0x006fe3b0).
|
||
Also note the speed is per **segment** (closest approach of the whole chord), not re-evaluated per position.
|
||
|
||
## Q8. Does `DecayAllResearch` also hit the current target?
|
||
|
||
**No — corrected 2026-09-08 by the B3 live trace.** The loop reading is right: `TechTree::ProcessResearch`
|
||
0x005876c0, loop 0x00587c20–0x00587c90, for every node with `state == 2 && progress != 0`,
|
||
`progress = max(0, progress − ftol(Cost(node) × 0.05f))`. What was wrong is the assumption that the
|
||
funded node is in state 2. **The selected research target carries state 3**, so the equality test skips
|
||
it and the current tech keeps its whole gain; only *idle* partially-researched techs decay. Observed
|
||
directly in `b3-trace-golden.jsonl` / `b3-compare.jsonl`: the funded nodes (144, 142, 9) are state 3
|
||
before and after, and one traced tree's states are 164×0, 7×1, 23×2, 1×3, 22×4. Net gain of the
|
||
current tech per turn is therefore `spend`, not `spend − 5 %·cost`.
|
||
|
||
(State 3 is presumably "available and selected"; nothing in the traced runs had a state-2 node with
|
||
non-zero progress, so the decay branch itself is still unexercised behaviourally.)
|
||
|
||
## Extras resolved on the way
|
||
|
||
* `PERGATETRAFFIC_DRV_TpGate/GatAmp` readers: `OnTechResearched` ids 10018/10019 → `PrGtTrf = max(PrGtTrf, value)`
|
||
(storage 0x00b23e2c / 0x00b23e30).
|
||
* `TRKSTL_REGENERATION_MOD` path: 0x0079b980 → 0x0079b770 gated by `HasResearched(IND_TRKSTL)`.
|
||
* Sensor range (system): `SENSORMOD[species] × (hadvs ? ADVSENS_SENSORS_MOD : 1) × 4.0` (0x0080b730).
|
||
|
||
---
|
||
|
||
## B3 (2026-09-08) — `ProcessResearch` re-read instruction by instruction
|
||
|
||
Prompted by the old-vs-new milestone for `TechTree::ProcessResearch` (engine repo `docs/B3.md`).
|
||
Evidence: own `objdump -d` pass over `Sword of the Stars.exe` at 0x005876c0, 0x0057da00,
|
||
0x0047d830, 0x00426e00, 0x0049fdf0, 0x004271c0 and the single call site 0x008914a5. Everything
|
||
below is now in `ghidra/addresses.json` (status `verified`) and folded into
|
||
`strategic-turn-internals.md` §2.3 / §6, replacing what was there.
|
||
|
||
* **`ProcessResearch`'s second argument is the `Mars::RNG` object** (`StrategyServer+0x16c`),
|
||
which resolves the `?` in the prototype. The function re-bases it with `+4` before each draw.
|
||
The allocation vector's element is `{TechDef* target, int points}` (stride 8) and the node is
|
||
`tree->nodes[*(int*)target]`.
|
||
* **`NextFloat` divides by `2^32 − 1`, not `2^32`.** 0x009e61b0 holds
|
||
`0x3df0000000001000` = `1/4294967295`. The range is therefore closed at 1.0. Measured effect
|
||
of the old mapping: a different float32 for 0.78 % of words, and a different research
|
||
completion decision for about one draw in two billion — real, but not something a behavioural
|
||
compare can catch.
|
||
* **`NextInt` is inclusive and takes its bound by pointer.** Mask = smallest `2^k − 1` ≥ `*n`
|
||
(from `n`, not `n−1`); redraw while the masked word is `> *n`. So the result is uniform on
|
||
`[0, *n]`. Its status moves from `unverified` to `verified`.
|
||
* **Generator layout.** Object = `{vftable @+0, mt[624] @+4, uint32* next @+0x9c4, int left
|
||
@+0x9c8}` = 0x9cc bytes, but `Twist`/`NextFloat`/`NextInt` all receive `&mt` (object + 4), so
|
||
in *their* frame `next`/`left` are at `+0x9c0`/`+0x9c4`. The save blob (0x9c4 bytes) is
|
||
`mt[624]` then `left`, skipping `next` — which is fine because `next == &mt[624 − left]`.
|
||
* **Rounding.** `odds`, the roll, the Zuul minimum and the `progress/cost` ratio are each stored
|
||
to a 4-byte float before they are compared. The decay fraction (0x009e5060) and the
|
||
early-completion threshold (0x009e20c8) are widened *float* literals — `0.05000000074505806`
|
||
and `0.800000011920929` — not the exact decimals.
|
||
* **Smaller corrections.** `spend = min(points, hi − progress)` is a plain signed min with no
|
||
floor at 0; the 50/150 % bounds use a 32-bit multiply (it wraps near `INT_MAX`) and a
|
||
truncating divide by 100; the decay guard is `progress != 0`, not `> 0`; `Cost` takes the
|
||
**node**, not the def, and returns 0 (not 1) when `costRP <= 0`, when the tree has no owner,
|
||
or when the cost multiplier is <= 0.
|
||
* **`Cost` is read-only** (it only reads `costRP`, the def and the owner, then calls the
|
||
read-only multiplier helper 0x0080db50), so a compare harness may call it on a scratch tree.
|
||
* **Open.** The x87 precision-control field in force at run time is not decidable statically
|
||
here (`_controlfp` is imported and there are ~370 `fldcw` sites, mostly CRT; a D3D9 device
|
||
created without `FPU_PRESERVE` would leave 24-bit precision). It changes only the last bit of
|
||
a draw (0.094 % of words) and of the odds. The B3 shim records the control word with every
|
||
call, so the first trace settles it.
|
||
|
||
|
||
## B3 live verification (2026-09-08) — three more facts
|
||
|
||
From the `ProcessResearch` trace/compare/replace runs (engine repo `docs/B3.md`; artefacts
|
||
`/srv/re-lab/shim/traces/b3-*`):
|
||
|
||
* **x87 precision control is 53-bit.** `fnstcw` inside the hooked call returns `0x127f` (PC = 10b =
|
||
double, RC = nearest; bit 12 is the legacy infinity-control flag). At DLL init it is `0x027f`.
|
||
So the FPU is *not* left in single precision by the D3D9 device, and the `NextFloat` product
|
||
rounds to double before the caller narrows it to float32. The 24-bit contingency is moot.
|
||
* **`ServerPlayer::OnTechResearched` can consume an RNG draw.** Two techs completed during the
|
||
compare run; one cascade consumed no word and the other consumed exactly one more than the
|
||
research arithmetic accounts for. The draw is inside the owner's tech-effect callback, not in
|
||
`SetResearched` itself. Relevant to the B2 lane: at least one strategic effect rolls.
|
||
* **`EVENT_RESEARCH_OVERBUDGET` is raised in the same branch that sets `node.flag = 2`**, with
|
||
`EvDsc "Research Over Budget"`, `EvMsg "Research for <tech> has gone overbudget."`,
|
||
`EvImg "EVENT_RESEARCH_OVERBUDGET"`, `EvAct 1`, `EvPos {FLT_MAX,FLT_MAX,FLT_MAX}`, and it bumps
|
||
the player's `EvNxID`. A replace-mode run that sets only the flag differs from the oracle by
|
||
exactly this one event and nothing else in 40,300 save items.
|
||
**Corrected 2026-09-08 (lane E):** `EvPos` was recorded here as `{inf,inf,inf}`. It is
|
||
**`FLT_MAX` (0x7f7fffff), not infinity** — the `PlayerEvent` constructor at 0x0084ee30 copies
|
||
the `Vector3` global at 0x00af0dc8, whose bytes are `FF FF 7F 7F` x3, and the save carries
|
||
2139095039 in all three slots. Writing `+inf` (0x7f800000) would change the save bytes.
|
||
Full API: `findings/subsystems/events.md`.
|
||
* Tech-tree size in this game: **293 nodes** per player tree.
|
||
|
||
---
|
||
|
||
## B4 (2026-09-08) — colony turn + fleet movement re-read instruction by instruction
|
||
|
||
Prompted by the old-vs-new milestone for `ServerSystem::ProcessTurn` and
|
||
`MoveFleet`/`ProcessFleetMovement` (engine repo `docs/B4.md`). Evidence: own `objdump -d` pass
|
||
over `Sword of the Stars.exe`. Every entry below is now in `ghidra/addresses.json` (status
|
||
`verified`, 113 new entries) and the three target prototypes plus six helpers were written back
|
||
into the Ghidra project.
|
||
|
||
### Prototypes (all three were `unverified`; all three are now pinned)
|
||
|
||
* **`ServerSystem::ProcessTurn` 0x007598e0 takes NO arguments.** Plain `ret`, nothing reads
|
||
`[ebp+8]`. `turn-decompiles/*/007598e0.c` shows a second parameter `void* stream`; that is a
|
||
Ghidra guess and it is wrong. Hooking it as a one-argument function corrupts the stack.
|
||
* **`MoveFleet` 0x007d9ee0 is `bool __thiscall (StrategyServer*, StarFleet*, float dt)`,
|
||
`ret 8`.** `dt` is a 4-byte float (`fld DWORD`), pushed at every call site with
|
||
`push ecx; fstp DWORD PTR [esp]`. Returns AL, and the caller tests it.
|
||
* **`ProcessFleetMovement` 0x007da9a0 is `void __thiscall (StrategyServer*)`**, plain `ret`.
|
||
|
||
### Movement — where §4 was wrong
|
||
|
||
* **Q7 revisited.** `BuildStutterSegments` does **not** merge overlaps at the midpoint. When
|
||
`seg[i].end > seg[i+1].start` it sets *both* boundaries to
|
||
`float32(end_i + 0.5 x (end_i − start_{i+1}))` — the mirror of the midpoint about `end_i`,
|
||
pushed forward past both chords. Nothing is dropped and nothing is clipped back, so a chord
|
||
swallowed by its predecessor comes out inverted (`start > end`). The chord parameters are also
|
||
clamped to `[0, length]` before the drop test (the note omitted this; without it the
|
||
ray/sphere routine's `±FLT_MAX` sentinels poison the list), the drop threshold is
|
||
`fabs(start−end) <= 0.01f` (float32, inclusive), and the sort is a real `std::sort` on `start`
|
||
alone. There is genuinely no clamp on `dist/RADIUS`, as the note said — but after the merge
|
||
`dist <= RADIUS` is no longer guaranteed, so the ramp can exceed MAX.
|
||
* **`STUTTER_MIN_SPEED == STUTTER_MAX_SPEED == 0.33` in the shipped data** (radius 2), so the
|
||
ramp collapses to a constant 0.33x inside any sphere and 1.0x outside. The image bytes for all
|
||
three constants are zero (they live past `.data`'s file image), so a run that skips the config
|
||
load divides by zero.
|
||
* **`range = MinRange(fleet) + 0.05f`, not `− 0.05`.** 0x00a1d2c0 is `0x3d4ccccd` (sign bit
|
||
clear) and `MinRange` *adds* its argument. The out-of-range case zeroes the **range**, not the
|
||
step — `step` remains the divisor of the pass fraction. `move = min(min(range, step), distance)`
|
||
with **no floor at 0**. `MinRange` seeds with `FLT_MAX` (an empty fleet is unconstrained) and
|
||
skips no ship, so a range-exempt tanker still clamps the fleet.
|
||
* **The probabilistic jump (type 5) scatters, it does not stop part-way.** `v = float32(roll x
|
||
CstE)`; it arrives iff `!(v > CstT)` (equality arrives); on a miss the fleet is placed at
|
||
`dest + randomUnitVector x v`, which costs a **second** raw draw. 1 word on success, 2 on a
|
||
miss. `CstE` is `player+0x154`, `CstT` `player+0x158`. No fuel and no clamp on this path, and
|
||
the pass fraction is 1.0 either way.
|
||
* **`IsNodeWaypoint` (0x0056e720) is true for type 3 only**; the node-*line* case of the switch
|
||
is type 2. `IsGateTransitWaypoint` (0x0056e6e0) is true for 4 and 5. Only a type-3 waypoint
|
||
reports a partial pass fraction (`clamp01(distance/step)`); a blocked move reports
|
||
`clamp01(move/step)`; everything else reports 1.0.
|
||
* **The recursion threshold at 0x00a261f0 is an 8-byte double whose value is exactly
|
||
`(double)0.9999f`** = 0.9998999834060669, and the test is strict, so `fraction == threshold`
|
||
does not recurse. `dt' = float32((1 − fraction) x dt)`.
|
||
* **§4.2's bucketing is wrong.** The schedule is a **pursuit model**: classify every fleet whose
|
||
current waypoint targets another *fleet* by the owner-to-owner relation (0 = pursuer, else
|
||
follower); prey move 0.5, pursuers move 0.5 and a pursuer that arrives retires itself and its
|
||
prey, surviving prey take a second 0.5, everything unscheduled takes 1.0 (an uncaught pursuer
|
||
takes another 0.5), followers take 1.0. One of the six local containers is never inserted into
|
||
and its two guards are vacuous — it can be deleted from any reimplementation.
|
||
* **`FPogn2` at `fleet+0xec` is really `FPdpos`** (FlightPlan+0x28); `FPogn2` is at `+0xe0`.
|
||
The pass writes the current waypoint target's position there, with no waypoint-type and no
|
||
entity-kind check, and clears flag `0x2` on every fleet in the same loop. Flag `0x100` is
|
||
cleared for every fleet in a final loop.
|
||
* **Gate traffic** sums a **signed int16** at `fleet+0xc0` over fleets whose *front* waypoint is
|
||
a gate transit, into a fixed 32-int accumulator indexed by `player+0x28`, then **assigns** it
|
||
to `player+0x14c` indexed by the player's *position* in the server's vector — the two agree
|
||
only while `players[j]->index == j`. `ProcessFleetMovement` makes no RNG draw of its own.
|
||
|
||
### Colony — where §3 was wrong
|
||
|
||
* **The population growth curve has no `pop/cap` term.** §3.2's
|
||
`g = clamp01((1 − clamp01(pop/cap))^EXP)` is wrong: the capacity never enters the chain
|
||
(0x00748100 → 0x00537140 → 0x00536fb0). The base is a suitability term,
|
||
`1 − clamp01(min(|ideal − clamp(suit, 0, 20)|, SuitTol) / SuitTol)`, the exponent is clamped
|
||
into `[0.01f, 1000]` before a real `__CIpow`, and every modifier after it is gated on a strict
|
||
`> 0` and stored back to float32. `delta = trunc(pop x g)`, forced to 1 only when that
|
||
truncates to 0 with `g > 0`. A zero `SuitTol` makes the quotient 0/0 and the NaN wipes the
|
||
colony — a real hazard.
|
||
* **The 50,000,000 cap and the 100 floor live in the apply (0x0074b230), not in the growth.**
|
||
The over-cap shrink runs only when the colony was *already* over the cap and is computed from
|
||
the **old** population; a colony that merely grows past the cap lands exactly on it.
|
||
* **`AccrueSystemBonus`'s second gate is `ntdev` (+0x2c4), not `rbtn`** — §3.2 said `rbtn`; Q4
|
||
said `ntdev` and Q4 was right. Both bonus-apply helpers (0x00746780, 0x0074b510) **reset
|
||
`ntdev` to 0** on a colony that is not the owner's home system (`player+0x2c`), which is a real
|
||
feedback loop: a colony still absorbing a bonus never reaches the gate. `ApplyInfraBonus` snaps
|
||
`Infra` to **exactly 1.0f** when the pool covers the remainder; `ApplyPopBonus` drops the whole
|
||
pool on an unowned system; `pbon` (+0x194) is an **int32**.
|
||
* **The unowned infra decay constant at 0x009e9170 is `(double)0.02f`** = 0.019999999552965164,
|
||
not the decimal, and the floor is `Infra <= 0 → 0` (inclusive; NaN also collapses to 0).
|
||
* **§3.1's `0x007514f0` row is wrong.** The globals are not `DAT_00aeca78`/`7c`; the only global
|
||
read is **0x00aeca80, a hard-coded `float 0.05f`** with no GlobalConst key. The helper takes
|
||
four arguments — `(0, 1, indi->indsp, 0.05f)` then `(1, 0, indi->indsp, 0.05f)`.
|
||
* **§3.1's addiction row is misleading.** Player flag bit 5 (temperance) does not skip the block:
|
||
it takes the other branch and still raises morale event `0x1b` (−1). Bit 5 *clear* runs the
|
||
phase path: `0x1c` (+1) at onset, **nothing at all** in phase 2, `0x1d` (−2) at terminal. Both
|
||
phase comparisons are strict `>`. `0x00752a10` is the MoraleEvent **constructor**; the apply is
|
||
**0x00839d60**, and the delta travels in `ev->deltas[species]` (int[7] at ev+0x18), not as an
|
||
argument.
|
||
* **§3.1's call table omits `TRes = 0` (+0x74) and the `haltv[0..2] = false` clear (+0x78).**
|
||
* **RNG, and this is the load-bearing one for any oracle:** `ProcessTurn` itself, `ProcessPlague`,
|
||
`GrowCivilianPops` and `ProcessSlaves` are **all draw-free** (swept to call depth one).
|
||
**`ProcessRebellion` is the sole consumer in a colony turn**, and its count is data-dependent:
|
||
one `RandChance` per iteration of a 64-bit rebel counter (0x0074fbe0), a short-circuiting
|
||
per-species roll loop (0x00753c60), and one outcome roll (0x00756350), plus one 0.2f
|
||
continuation roll. `RNG::Chance` (0x008e6dd0) makes **no** draw for `p <= 0` or `p >= 1`.
|
||
The generator is reached as `*(RNG**)((char*)sys->owner + 0x168)` — Ghidra's
|
||
"StrategyServer+0x16c" is relative to the −4-adjusted base.
|
||
* **`ServerSystem::MaxPop` 0x0074ab20 is `(ServerPlayer* p, int flag)`, not `(species, group)`.**
|
||
Species comes from `p->Species` and the group type is hard-coded 0, which makes the
|
||
cross-species and INDSYS branches dead in that specialisation. The result is clamped to
|
||
`[0, INT32_MAX]`. **Every capacity is rounded down to a multiple of 10** by the shared helper
|
||
0x00535eb0, and `Size x 1e8` is an exact 64-bit *integer* product (the 1e8 is the immediate
|
||
0x05f5e100). The arcology bonus is 0 for slaves.
|
||
* **`ComputeOutputFromRates` corrections.** `NormaliseOutputRates` (0x00747390) is `__cdecl` with
|
||
a third *pinned channel* argument that every call site leaves null, selecting trade: only trade
|
||
is clamped to `[0,1]`, the other three are summed in float32 (trade excluded) and rescaled to
|
||
`1 − trade`, and the all-zero fallback is an equal split over **three** channels. The engine's
|
||
"round" (0x008e5660) is `fistp`/`fild` — **ties to even** — while `out[0]`, `out[3]` and the
|
||
construction points use the truncating `_ftol2`. `out[1]` is `min(out[2], stripMineDemand)`,
|
||
not a shortfall; `out[9]` is the amount **spent** on repairs. **Unspent terraforming points
|
||
cascade into the money channel** (an edge the notes missed entirely). The terraforming modifier
|
||
is inside the *point count* (0x00746890), the sign is `-1` only for `suit > ideal` strictly, and
|
||
the infra chain is `spend/500 x 0.01 x 1.65` in three 80-bit steps — not one `x3.3e-5`.
|
||
**The function repairs damaged ships in orbit** (0x00751590 with its estimate flag clear), so it
|
||
is not side-effect free and cannot be compared on a scratch system.
|
||
* **`BuildQueue::ProcessTurn` 0x00890d50** takes `points` by value and **returns the leftover**.
|
||
A money refusal **skips** that order and continues rather than stopping the pass; removal is a
|
||
separate sweep afterwards that unlinks every order with `conleft <= 0`.
|
||
* **Slaves.** The term order is `((|Δsuit| x BYHAZARD + DEATH_RATE) + SRs x BYOUTPUT) x mod`, every
|
||
step narrowed to float32; an unowned system returns **1.0**, not 0; the worst plague at the
|
||
system contributes an **additive** rate term; and `SLAVES_MIN/MAX_DEATHS` are each disabled by
|
||
**any** negative value, with the result clamped into `[0, adjustedSlaveCount]`.
|
||
* **`HazardMod`'s 0.1 (0x00a1a438) is a true double**, not `(double)0.1f`, and a zero band at the
|
||
ideal returns NaN (which the capacity's `max(v,0)` then turns into 0).
|
||
|
||
### B4 LIVE (2026-09-08) — what the game confirmed, and three offset traps
|
||
|
||
36 calls compared, 0 divergences. Confirmations:
|
||
* **`ProcessRebellion` really is the only RNG consumer in a colony turn.** Over 28 systems the
|
||
generator's `left` did not move and the `mt[624]` hash was identical before and after, on
|
||
every single record. The static sweep was right.
|
||
* `fpu_cw = 0x127f` (53-bit precision) on every colony record.
|
||
* `ServerSystem::ProcessTurn` really does take no arguments — hooked as a one-argument
|
||
`__thiscall` for 28 calls per turn across three full runs with no corruption.
|
||
* The movement step reproduced bit for bit on the one fleet that moved, and the pass schedule
|
||
matched `PlanFleetMovement`'s prediction exactly (no pursuits in this save).
|
||
|
||
**Offset traps, all three found live and all three now fixed in the contract:**
|
||
1. **The StrategyServer has two bases four bytes apart.** `ServerSystem+0x10` (`owner`) points
|
||
at a base four bytes ABOVE the one the class's own methods get in ECX — 0x007437f0 does
|
||
`owner - 4` before reading the generator. Every `StrategyServer_off_*` entry here is relative
|
||
to the RAW (owner) base; a hook whose `this` is the method base must add 4 first.
|
||
2. `StrategyServer_off_Fleets` is **0x60** relative to the raw base (Ghidra `+0x64`). The
|
||
earlier 0x64 made a hook enumerate the vector's spare capacity: `ProcessFleetMovement`
|
||
reported 1 fleet in a turn where `MoveFleet` was called 7 times.
|
||
3. `off_EntityHash` 0x80, `off_ArrivedSet` 0x200, `off_InMotionSet` 0x210 — same −4 correction.
|