Compare commits
No commits in common. "3f014a42eb1e44fa7364fdb6172ab6af9e27aa94" and "08da5a1a06a99e92d0ff3e6205a2c26ba4b21864" have entirely different histories.
3f014a42eb
...
08da5a1a06
7 changed files with 80 additions and 890 deletions
|
|
@ -44,7 +44,8 @@ turn, after the autosave**. `Summary.Checksum` is fed by `ModCount`. So, two run
|
||||||
|
|
||||||
| # | engine deliverable | module | RE input (done unless noted) | closes |
|
| # | engine deliverable | module | RE input (done unless noted) | closes |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 1 | ~~income chain~~ **DONE, and it did not unblock the phases** — `TradePointsToMoney` + difficulty ×1.1 landed at **25/25** on the `BnkEl` oracle (lane E1). But `ComputeBudget`'s **turn path** calls `ComputeOutput` with the system's own `Rts` sliders, **not** `ComputeMaxIncome` — a strictly larger function where the repair pass and the unspent-industry/terraform cascades are live. **The new item 1 is `ComputeOutput` on the turn path.** | `game/sim` | lane E1; `findings/subsystems/income-term.md` | P01/P02/P03/P05/P06, ~82 leaves, 2 RNG words |
|
| 1 | **income chain**: `TradePointsToMoney` (`incomeModifier/14000`) + AI difficulty ×1.1 | `game/sim` | lane N/Y; 25-record `BnkEl` oracle, 6/25 match now; **multiplier not on the wire — one Ghidra read** | P01/P02/P03/P05/P06/T31, ~82 of 204 leaves, 2 RNG words |
|
||||||
|
| 2 | **wire `ShipCensus` + alliance mask into `BuildTurnRecord`**, commit T36 | `app` | lanes D2/A2, 480+560 fields 0 mismatch | +24 turnstats leaves, clean |
|
||||||
| 3 | **encounter-detection draws** in the generator model | `game/sim` + `app` | lane I bound; **lane H measuring now** | last 2 RNG words |
|
| 3 | **encounter-detection draws** in the generator model | `game/sim` + `app` | lane I bound; **lane H measuring now** | last 2 RNG words |
|
||||||
| 4 | **`nve` visibility record** | `game/sim` | one mechanism ×8, unread | 32 leaves |
|
| 4 | **`nve` visibility record** | `game/sim` | one mechanism ×8, unread | 32 leaves |
|
||||||
| 5 | **post events into the save's turn bucket** (P11 + tail event phases) | `app` + `game/events` | model exists; wiring only | events subtree |
|
| 5 | **post events into the save's turn bucket** (P11 + tail event phases) | `app` + `game/events` | model exists; wiring only | events subtree |
|
||||||
|
|
|
||||||
|
|
@ -245,8 +245,3 @@ Status flow: `backlog → in-progress → mapped → verified` (or `blocked`).
|
||||||
| THE GATE IS AFlags - and the near-miss is the finding | verify | verified | high | 100% | 2026-09-08 | NVE is a std::map<playerIdx, {int16 ETS, int32 Eid}> - what this player LAST SAW at this system: turn of sighting + the encounter type that was there. Writer 0x00756300 (ETS = server->Frame), called from tail phase 17 under one gate, BYTE-DECODED at 0x007cf7a7..0x007cf7ce, not taken from the decompiler. **THE GATE IS `AFlags`** - the DERIVED, NON-STICKY union FFlags|GFlags|isOwner - NOT VFlags, NOT EFlags. That is load-bearing: **all three masks agree on nearly every system of every save, so a model on the wrong one LOOKS RIGHT**. zuul-turn23-fleet23.sav's Bismol separates them, and the engine reproduces the freeze on the save itself (ETS and ltis stay at 22 while every other system moves to 24) - with a host test that asserts BOTH the freeze AND what the wrong gate would have produced |
|
| THE GATE IS AFlags - and the near-miss is the finding | verify | verified | high | 100% | 2026-09-08 | NVE is a std::map<playerIdx, {int16 ETS, int32 Eid}> - what this player LAST SAW at this system: turn of sighting + the encounter type that was there. Writer 0x00756300 (ETS = server->Frame), called from tail phase 17 under one gate, BYTE-DECODED at 0x007cf7a7..0x007cf7ce, not taken from the decompiler. **THE GATE IS `AFlags`** - the DERIVED, NON-STICKY union FFlags|GFlags|isOwner - NOT VFlags, NOT EFlags. That is load-bearing: **all three masks agree on nearly every system of every save, so a model on the wrong one LOOKS RIGHT**. zuul-turn23-fleet23.sav's Bismol separates them, and the engine reproduces the freeze on the save itself (ETS and ltis stay at 22 while every other system moves to 24) - with a host test that asserts BOTH the freeze AND what the wrong gate would have produced |
|
||||||
| ltis writer NAMED (was an unnamed offset) | objects | verified | high | 90% | 2026-09-08 | Lane E3 names a writer this board recorded as unnamed: ltis's writer is 0x00743ec0, `if AFlags != 0: ltis = Frame` (decompiler-level, not byte-decoded). Also EFlags |= AFlags in tail phase 21. AND lane B5's flagged indirect edge is RESOLVED: SetExploredBy's vft[0x1c] is ServerSystem vtable slot 7 = 0x007480b0, and it writes only an UNSERIALISED runtime mask |
|
| ltis writer NAMED (was an unnamed offset) | objects | verified | high | 90% | 2026-09-08 | Lane E3 names a writer this board recorded as unnamed: ltis's writer is 0x00743ec0, `if AFlags != 0: ltis = Frame` (decompiler-level, not byte-decoded). Also EFlags |= AFlags in tail phase 21. AND lane B5's flagged indirect edge is RESOLVED: SetExploredBy's vft[0x1c] is ServerSystem vtable slot 7 = 0x007480b0, and it writes only an UNSERIALISED runtime mask |
|
||||||
| visibility: what remains and the workloads | verify | backlog | — | 0% | 2026-09-08 | (1) **Eid's source is a HYPOTHESIS** - the original reads StarSystem+0x184, set once at map generation and NOT ON THE WIRE; the engine recovers it from the encounter fleet's FtEnc and agrees on all six encounter fleets in the corpus. NEEDS: a save where an encounter fleet is DESTROYED at a system that stays visible. (2) **NVO.TShn** (10 leaves/pair) EVALUATED AND REPORTED, NOT WRITTEN - its gate is demonstrably NOT AFlags (Spica has AFlags==0 and TShn moves anyway); needs a watchpoint on the +0x274 map, with Spica vs Bismol as the discriminating pair. (3) **rcex** (6 leaves/pair) toggles 0 -> 1<<16 -> 0 with this cluster; UNEXPLAINED, UNASSIGNED. (4) NEVER EXECUTED BY ANY SAVE: the map's multi-entry ordering (every corpus system has a single-bit AFlags) and the alliance intel-sharing rule 0x00754d90 (no save has an alliance) - needs a two-empire-contact save |
|
| visibility: what remains and the workloads | verify | backlog | — | 0% | 2026-09-08 | (1) **Eid's source is a HYPOTHESIS** - the original reads StarSystem+0x184, set once at map generation and NOT ON THE WIRE; the engine recovers it from the encounter fleet's FtEnc and agrees on all six encounter fleets in the corpus. NEEDS: a save where an encounter fleet is DESTROYED at a system that stays visible. (2) **NVO.TShn** (10 leaves/pair) EVALUATED AND REPORTED, NOT WRITTEN - its gate is demonstrably NOT AFlags (Spica has AFlags==0 and TShn moves anyway); needs a watchpoint on the +0x274 map, with Spica vs Bismol as the discriminating pair. (3) **rcex** (6 leaves/pair) toggles 0 -> 1<<16 -> 0 with this cluster; UNEXPLAINED, UNASSIGNED. (4) NEVER EXECUTED BY ANY SAVE: the map's multi-entry ordering (every corpus system has a single-bit AFlags) and the alliance intel-sharing rule 0x00754d90 (no save has an alliance) - needs a two-empire-contact save |
|
||||||
| income chain 25/25 - and the roadmap's item 1 was WRONG | subsystems | verified | high | 90% | 2026-09-08 | Lane E1 took the BnkEl oracle from **6/25 to 25/25**. THE MULTIPLIER RESOLVES FULLY: StrategyServer::IncomeDifficultyMod 0x0080f470 returns f32(f32(triple[1]) x f32(server->IncMod)) where IncMod is the Sim block's own tag, ON THE WIRE; DifficultyMods::Select 0x0059b490 returns the AI triple iff p->[0xf9] && !p->NPC; ServerPlayer+0x36c is an UNNAMED UNSAVED pointer filled by ServerPlayer::Read via LoadDifficultyRow. **THE TABLE IS BUILT IN CODE** (BuildDifficultyTable 0x005a3870) from .rdata float literals - no data file, no GlobalConst key, the same shape lane N found for the pop-type table; id 1 (which EVERY corpus save carries) is AI {3.0, 1.1, 1.5}. Q3 WAS WRONG about the third column: it is a RESEARCH multiplier, not a trade one, and the first is a fleet-maintenance DIVISOR. **BUT P01/P02/P03/P05/P06 ARE NOT UNBLOCKED AND MY ROADMAP ITEM 1 WAS WRONG**: ComputeBudget at 0x008631fd BRANCHES - projected mode uses ComputeMaxIncome, THE TURN PATH USES ComputeOutput 0x00751fb0 WITH THE SYSTEM'S OWN Rts SLIDERS, where the repair pass runs and the unspent-industry/terraform cascades into the money channel are live. A strictly larger function, and nothing in the corpus states its answer. The RNG reason narrowed from "a system's money is unmodelled" to "the PROJECTED-RATE money is" |
|
|
||||||
| E1's own prediction was wrong, and recorded as wrong | verify | verified | high | 100% | 2026-09-08 | Lane E1 predicted the 12 remaining Zuul misses were the suitability cost. THEY WERE NOT - every corpus colony sits EXACTLY at its species' ideal, so CalcSuitMod is 0 and that whole term is UNEXERCISED. The misses were in the OUTPUT half: SpeciesDef +0x4c/+0x50 are PER SPECIES (0/10 Human & Tarkas, 10/40 Zuul) and the predictor carried them as one global pair; 400 output points = 2000 money per Zuul colony, and the observed 4400 and 5566 deltas fall out TO THE UNIT. FALSIFICATION ACTUALLY RUN: model 25/25; "nobody is AI" 14/25 (the 11 AI records break); "both real players AI" 14/25 (the 11 human records break, each by x1.1); human species pair forced on Zuul 0/2 |
|
|
||||||
| T31 unblocked in substance, still listed blocked | verify | mapped | high | 90% | 2026-09-08 | closed 0 / regressed 0 - it self-checks every run against the save's own BnkEl (8/8, 7/7 ... on ALL 11 saves) and closes nothing because the limits move between turn1 and turn2 from CIVILIAN POPULATION GROWTH, which is not committed, so our value equals the input's. Still blocked on two named things, NEITHER THE FORMULA: ServerPlayer+0xf9 (is-AI) is a game-setup input not on the wire (--ai-player N supplies it), and BnkPr needs BANKRUPTCY_PROTECTION_LIMIT_FACTOR from the data files - committing it from a zero constant REGRESSED a leaf in the first measurement, so it is now gated on haveTuning |
|
|
||||||
| NEW WIRE FACT: ISsp/ISsu is server->IdealSuit[] | objects | verified | high | 100% | 2026-09-08 | Lane E1: the Sim block's ISsp/ISsu IS server->IdealSuit[] (0x0080f4b0, raw base +0xf8), randomised per game by the map generator, cross-checking against every ServerPlayer.IdealSuit in all 11 saves. **So the suitability cost's ideal needs no data file.** struct-recovery.md 5 lists the tags without naming them |
|
|
||||||
| income chain coverage - read before quoting 25/25 | verify | backlog | — | 0% | 2026-09-08 | Lane E1's own caveat list: UNTESTED are the suitability cost (x0 everywhere - every corpus colony sits at its species' ideal), slaves, addiction, morale, stations, the civilian capacity surplus, difficulty levels 0 and 2, and the maintenance/research columns of all three difficulty rows. `aidf` is 1 on all 25 records |
|
|
||||||
|
|
|
||||||
|
|
@ -72,30 +72,6 @@ money = ftol(t − cost)
|
||||||
Note `SuitTol` therefore caps the hazard **cost** as well as extending the habitable range. `ADDICTION_INCOME_MOD` is
|
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).
|
applied inside PopIncome (0x0074d760 → 0x00746910 morale/addiction factor) — not verified line by line (MEDIUM).
|
||||||
|
|
||||||
### Q3 addendum — lane E1, 2026-09-08 (instruction-verified, and the block above is now superseded)
|
|
||||||
|
|
||||||
The whole chain is read instruction by instruction in `findings/subsystems/income-term.md`, which
|
|
||||||
replaces the MEDIUM-confidence sketch above. Five corrections:
|
|
||||||
|
|
||||||
1. **`TradePointsToMoney` returns a double, not an int.** The `ftol` is in the caller
|
|
||||||
(`ComputeOutputFromRates`, `out[3] = _ftol2(...)`), not here.
|
|
||||||
2. **The block term is `(trade − fmod(trade,5)) × 5`** — the literal at 0x009e2398 is used twice,
|
|
||||||
as the modulus *and* as the multiplier, so a whole five-point block is worth **25** money.
|
|
||||||
3. **`PopIncome` truncates twice per (group, species) row** — once inside `GroupIncome`
|
|
||||||
(`ftol(typeIncomeMod × count/14000)`) and again after the morale and addiction factors. Summing
|
|
||||||
the species and truncating once is wrong on any multi-species colony.
|
|
||||||
4. **`DifficultyMods(owner)->+4` resolves.** `ServerPlayer+0x36c` is an unnamed, unsaved pointer to
|
|
||||||
a 0x1c record `{int id; float ai[3]; float other[3]}` filled by `LoadDifficultyRow` 0x005a3990
|
|
||||||
from a three-row table **built in code** by 0x005a3870 (`BuildDifficultyTable`) — no data-file
|
|
||||||
key. The selector 0x0059b490 takes the AI triple iff `p->[0xf9] && !p->NPC`. Every corpus save
|
|
||||||
carries `aidf = 1`, whose AI income column is **1.1f** — the multiplier the `BnkEl` oracle
|
|
||||||
measured. The record's other two columns are a fleet-maintenance **divisor** and a **research**
|
|
||||||
multiplier, not a trade multiplier as this note said.
|
|
||||||
5. **`CalcSuitMod`'s ideal is the SERVER's per-species array**, `server->IdealSuit[species]`
|
|
||||||
(0x0080f4b0, raw base +0xf8), which is on the wire as the Sim block's `ISsp`/`ISsu` pairs — not
|
|
||||||
the owner's `IdealSuit` field. The species is the system's population species (`indi->indsp` on
|
|
||||||
an independent colony), and a `vnh` system pays no cost at all.
|
|
||||||
|
|
||||||
## Q4. `POPBONUS_INC` population increment
|
## Q4. `POPBONUS_INC` population increment
|
||||||
|
|
||||||
`ServerSystem::AccrueSystemBonus` 0x0074d4f0 (disasm 0x0074d53b–0x0074d5be):
|
`ServerSystem::AccrueSystemBonus` 0x0074d4f0 (disasm 0x0074d53b–0x0074d5be):
|
||||||
|
|
|
||||||
|
|
@ -1,428 +0,0 @@
|
||||||
# The output → money term (lane E1, 2026-09-08)
|
|
||||||
|
|
||||||
`ServerSystem::ComputeMaxIncome` and its `TradePointsToMoney` tail — the second chain
|
|
||||||
`ComputeBudget` needs, and the one `output-term.md` §6 named as the reason the verified output
|
|
||||||
total did **not** unblock `P01 P02 P03 P05 P06`.
|
|
||||||
|
|
||||||
Evidence: own `objdump -d` pass over `Sword of the Stars.exe`, every range disassembled **to the
|
|
||||||
next function start** and the real boundary found (rule 17). Field names per
|
|
||||||
`findings/objects/struct-recovery.md`. Everything below marked *instruction-verified* is read off
|
|
||||||
the instruction stream; everything marked *inferred* is not.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. The call chain
|
|
||||||
|
|
||||||
| addr | name | conv / boundary | note |
|
|
||||||
|---|---|---|---|
|
|
||||||
| 0x007521c0 | `ServerSystem::ComputeMaxIncome()` → int | thiscall, ends 0x0075223d | Ghidra size 126 = correct |
|
|
||||||
| 0x00751bb0 | `ServerSystem::ComputeOutputFromRates(int out[12], OutputRates*)` | thiscall `ret 8`, ends 0x00751fa0 | **repairs ships** — not compare-safe |
|
|
||||||
| 0x007505b0 | `ServerSystem::TradePointsToMoney(double trade)` → double | thiscall `ret 8`, ends 0x007506c8 | Ghidra size 281 = correct |
|
|
||||||
| 0x0074d760 | `ServerSystem::PopIncome(int groupType)` → double | thiscall `ret 4`, ends 0x0074d8e1 | |
|
|
||||||
| 0x0074b700 | `ServerSystem::SlaveIncome()` → double | thiscall, ends 0x0074b793 | |
|
|
||||||
| 0x00535e80 | `GroupIncome(int groupType, int64 count)` → int | **cdecl**, tail-jumps `_ftol2` | the per-capita income term |
|
|
||||||
| 0x00535e00 | `PopTypeRow(int t)` | cdecl | same 3-row table as the output term |
|
|
||||||
| 0x007484d0 | `ServerSystem::CalcSuitMod(int species)` → double | thiscall `ret 4`, ends 0x00748560 | the suitability money **cost** |
|
|
||||||
| 0x0080f470 | `StrategyServer::IncomeDifficultyMod(ServerPlayer*)` → float | thiscall `ret 4`, ends 0x0080f49f | **the missing ×1.1** |
|
|
||||||
| 0x0059b490 | `DifficultyMods::Select(ServerPlayer*)` → float* | thiscall `ret 4`, ends 0x0059b4b3 | picks the AI or the non-AI triple |
|
|
||||||
| 0x005a3990 | `LoadDifficultyRow(int level, DiffRec* out)` | cdecl | fills the 0x1c record |
|
|
||||||
| 0x005a3870 | `BuildDifficultyTable(vector<DiffRec>* out)` | thiscall | **the table is built in code** — §3 |
|
|
||||||
| 0x0080dd10 | `ServerPlayer::GetIncMod()` → float | thiscall | `player+0x30c` |
|
|
||||||
| 0x0080dd20 | `ServerPlayer::GetSpeciesCostFactor()` → float | thiscall | `SpeciesDef(Species)->+0x24` |
|
|
||||||
| 0x0080f4b0 | `StrategyServer::IdealSuit(int species)` → float | thiscall `ret 4` | `server+0xf8 + species*4` |
|
|
||||||
| 0x00746890 | `ServerSystem::TerraformPointsNeeded()` → double | thiscall | always ≥ 0 — §2.4 |
|
|
||||||
| 0x009dd1e4 | `ceil` (MSVCR100) | import | resolved from the IAT |
|
|
||||||
| 0x00925086 | `_CIfmod` (MSVCR100) | import thunk | |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. The formula
|
|
||||||
|
|
||||||
### 2.1 `ComputeMaxIncome()` — 0x007521c0 (instruction-verified)
|
|
||||||
|
|
||||||
```
|
|
||||||
rates = { SRt = 1.0, SRsc = SRtf = SRi = SRoh = SRs = 0.0, SRnr = 0 }
|
|
||||||
NormaliseOutputRates(&rates, this, 0) // 0x00747390, cdecl
|
|
||||||
int out[12] = {0}
|
|
||||||
ComputeOutputFromRates(out, &rates) // 0x00751bb0
|
|
||||||
return (out[3] > 0) ? out[3] : 0 // a `jg`, so a negative colony contributes 0
|
|
||||||
```
|
|
||||||
|
|
||||||
`NormaliseOutputRates` clamps only trade to `[0,1]` and rescales the other three to `1 − trade`;
|
|
||||||
with trade already 1.0 the other three stay 0 and `SRoh` is untouched. So the max-income rate
|
|
||||||
vector really is "all output to the trade channel, no over-harvest".
|
|
||||||
|
|
||||||
### 2.2 `out[3]` inside `ComputeOutputFromRates` — the money channel (instruction-verified)
|
|
||||||
|
|
||||||
The last four instructions of 0x00751bb0 are
|
|
||||||
|
|
||||||
```
|
|
||||||
out[3] = _ftol2( TradePointsToMoney( [ebp-0x28] + [ebp-0x40] + [ebp-0x18] ) )
|
|
||||||
```
|
|
||||||
|
|
||||||
with
|
|
||||||
|
|
||||||
* `[ebp-0x40]` = `round(round(ComputeTotalOutput(SRoh)) × SRt)` — the **trade points**;
|
|
||||||
* `[ebp-0x28]` = leftover **science** points, non-zero only when the repair block ran;
|
|
||||||
* `[ebp-0x18]` = leftover **terraforming** points, itself fed by leftover **industry** points.
|
|
||||||
|
|
||||||
`round` is 0x008e5660 (`fistp`/`fild`, ties to even); the outer conversion is the truncating
|
|
||||||
`_ftol2`. Under the max-income rate vector all three of science, terraform and industry points are
|
|
||||||
`round(T × 0) = 0`, and §2.4 shows both cascade sources are then zero, so
|
|
||||||
|
|
||||||
> **`out[3] = trunc( TradePointsToMoney( round_half_even( ComputeTotalOutput(0) ) ) )`.**
|
|
||||||
|
|
||||||
### 2.3 `TradePointsToMoney(trade)` — 0x007505b0 (instruction-verified)
|
|
||||||
|
|
||||||
```
|
|
||||||
t = PopIncome(0) // 0x0074d760(this, 0)
|
|
||||||
t = ((trade − fmod(trade, 5.0)) × 5.0 + 0.0) + t // 5.0 @0x009e2398, 0.0 @0x009e1e68
|
|
||||||
t = PopIncome(1) + t
|
|
||||||
t = SlaveIncome() + t // 0x0074b700
|
|
||||||
|
|
||||||
owner = sys->PID // +0x100
|
|
||||||
t = float32( owner ? SpeciesDef(owner->Species)->f18 : 1.0 ) × t
|
|
||||||
t = IncomeDifficultyMod(sys->server, owner) // 0x0080f470, a float32
|
|
||||||
× ( float32(owner ? owner->IncMod /*+0x30c*/ : 1.0) × t )
|
|
||||||
|
|
||||||
sp = owner ? (sys->indi ? sys->indi->[4] : owner->Species) : -1 // +0x1c8
|
|
||||||
cost = float32( owner ? SpeciesDef(owner->Species)->f24 : 1.0 )
|
|
||||||
× ( CalcSuitMod(sp) × 10000.0 × 1.5 ) // 10000.0 @0x009e9398, 1.5 @0x009e90b8
|
|
||||||
return t − cost // a DOUBLE; the caller truncates
|
|
||||||
```
|
|
||||||
|
|
||||||
Three corrections to `formula-gaps.md` Q3, which had this block as MEDIUM confidence:
|
|
||||||
|
|
||||||
1. **The function returns a double, not an int.** The `ftol` is in `ComputeOutputFromRates`, not here.
|
|
||||||
2. **`t` is `(trade − fmod(trade,5)) × 5`, not `× 5` of the block count** — i.e. whole 5-point blocks
|
|
||||||
are worth **25** money each, not 5. The literal at 0x009e2398 is used twice, once as the modulus
|
|
||||||
and once as the multiplier.
|
|
||||||
3. **Every per-player multiplier is narrowed to float32 before use** (`fstp DWORD [ebp+0xc]` /
|
|
||||||
`fld DWORD [ebp+0xc]`, three times), and the additive chain associates
|
|
||||||
`Slaves + (Pop1 + ((blocks + 0.0) + Pop0))`. x87 is not associative; this matters at the ulp.
|
|
||||||
|
|
||||||
`sys->server` is `ServerSystem+0x10`, the **raw** StrategyServer base (B4 trap 1).
|
|
||||||
|
|
||||||
### 2.4 The two cascade terms are zero under max-income (instruction-verified)
|
|
||||||
|
|
||||||
* Industry: `infraNeed = ceil((1.0 − sys->Infra) / 3.3e-5)`. `Infra` is clamped to `≤ 1.0` by
|
|
||||||
`ApplyInfraBonus`, so `infraNeed ≥ 0`, so `leftoverIndustry = 0 − min(0, infraNeed) = 0`.
|
|
||||||
* Terraform: `TerraformPointsNeeded` (0x00746890) ends `|Δsuit| / |rate × sign / 20000|` — the
|
|
||||||
`fabs` at 0x00746906 is **after** the sign multiply, so the sign cancels and the result is
|
|
||||||
always `≥ 0`. `leftoverTerraform = 0 − min(0, ceil(that)) = 0`.
|
|
||||||
(This corrects the implication in `output-term.md`'s "unspent terraforming points cascade into
|
|
||||||
the money channel": they do cascade, but only when the terraform channel is actually funded.)
|
|
||||||
* Science: `out[7] = 0x00746830(sys, 0.0)`; with zero science points the repair block's guard
|
|
||||||
`esi > 0` fails and `[ebp-0x28]` keeps the `fldz` written at 0x00751d5f. *Inferred* — that
|
|
||||||
0x00746830 returns 0 for an argument of 0 is read from its shape, not proven here.
|
|
||||||
|
|
||||||
### 2.5 `PopIncome(t)` — 0x0074d760 (instruction-verified)
|
|
||||||
|
|
||||||
```
|
|
||||||
sum = 0.0
|
|
||||||
for sp in 0..6:
|
|
||||||
n = GroupPopulation(t, sp) // 0x00747ba0, int64
|
|
||||||
surplus = 0
|
|
||||||
if (owner && sys->indi == 0 && t == 1 && sp == owner->Species):
|
|
||||||
MaxPop(sys, owner, sp, &A, 0, 0) // 0x0074a6d0, out slot 4
|
|
||||||
MaxPop(sys, owner, sp, 0, 0, &B) // out slot 6
|
|
||||||
surplus = (B > A) ? (B − A) : 0
|
|
||||||
total = surplus + n
|
|
||||||
if (total > 0):
|
|
||||||
mo = (t == 1) ? MoraleOutputMod(sp) : 1.0 // 0x00746910, the SAME helper as output
|
|
||||||
ad = sys->addiction[sp] ? ADDICTION_INCOME_MOD : 1.0 // int[7] @+0x1e4; slot 0x00aeca48
|
|
||||||
r = GroupIncome(t, total) // an int
|
|
||||||
sum += (double) trunc( (double)r × mo × ad )
|
|
||||||
return sum
|
|
||||||
|
|
||||||
SlaveIncome(): // t = 2, no morale, no surplus
|
|
||||||
same loop with n = SlaveCount(sp) (0x0074b610) and no morale factor
|
|
||||||
|
|
||||||
GroupIncome(t, count): // 0x00535e80, cdecl
|
|
||||||
return _ftol2( float32(POPTYPE[t].income) × ((double)count / 14000.0) )
|
|
||||||
```
|
|
||||||
|
|
||||||
**So the per-capita income rate is `typeIncomeMod / 14000` — no 1.8 and no 500000.** For imperial
|
|
||||||
population that is `1/14000` money per head; the output law over the same head is
|
|
||||||
`1.8/500000` points. Two different laws off two adjacent columns of the same three-row table.
|
|
||||||
|
|
||||||
Note the **double truncation**: `GroupIncome` truncates, and then the morale/addiction product is
|
|
||||||
truncated again, per species, before it is summed. Summing first and truncating once is wrong on
|
|
||||||
any colony with more than one species or a non-unit morale/addiction factor.
|
|
||||||
|
|
||||||
### 2.6 `CalcSuitMod(species)` — 0x007484d0 (instruction-verified)
|
|
||||||
|
|
||||||
```
|
|
||||||
if (sys->vnh /*+0xc6*/) return 0.0
|
|
||||||
if (sys->PID == 0) return 20.0 // 0x009e2c08 − 0.0, and it logs a warning
|
|
||||||
if (owner->RebAI /*+0xfc*/) return 0.0
|
|
||||||
d = | IdealSuit(species) − sys->Suit /*+0x64*/ |
|
|
||||||
return (d <= owner->SuitTol /*+0xb4*/) ? d : owner->SuitTol
|
|
||||||
```
|
|
||||||
|
|
||||||
`IdealSuit` is `server->float[0xf8 + species*4]`, the **species baseline**, not the player's own
|
|
||||||
`IdealSuit` field. In all 11 corpus saves the two agree for every player of that species, which is
|
|
||||||
what makes the oracle self-contained (§5).
|
|
||||||
|
|
||||||
So the money cost of a badly-suited colony is `speciesCostFactor × min(|Δsuit|, SuitTol) × 15000`,
|
|
||||||
and `SuitTol` therefore caps the cost as well as extending the habitable range.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. The difficulty multiplier — where it comes from (instruction-verified)
|
|
||||||
|
|
||||||
`formula-gaps.md` Q3 named `DifficultyMods(owner)->+4` and left it there. It resolves to a
|
|
||||||
**three-row table built in code from `.rdata` float literals**, exactly as lane N found for the
|
|
||||||
pop-type table. There is no data-file dependency and no `GlobalConst` key.
|
|
||||||
|
|
||||||
### 3.1 Selection
|
|
||||||
|
|
||||||
```
|
|
||||||
StrategyServer::IncomeDifficultyMod(ServerPlayer* p): // 0x0080f470
|
|
||||||
m = float32(server->IncMod) // raw base +0xbc == save tag `IncMod`
|
|
||||||
if (p) m = float32( float32(Select(p->diffMods /*+0x36c*/, p)[1]) × m )
|
|
||||||
return m // float32
|
|
||||||
|
|
||||||
DifficultyMods::Select(rec, p): // 0x0059b490
|
|
||||||
return (p && p->isAI /*+0xf9*/ && !p->NPC /*+0xfb*/) ? &rec->f[0] : &rec->f[3]
|
|
||||||
```
|
|
||||||
|
|
||||||
`ServerPlayer+0x36c` is an **unnamed, unsaved pointer** sitting between `aidf` (+0x368) and `civr`
|
|
||||||
(+0x370) — it is not in `struct-recovery.md` §2 because the serializer never touches it.
|
|
||||||
`ServerPlayer::Read` (0x008804d0) sets it at 0x00880fa3:
|
|
||||||
|
|
||||||
```
|
|
||||||
if (0 <= aidf && aidf < 3) { LoadDifficultyRow(aidf, p->diffMods); p->aidf = aidf; }
|
|
||||||
```
|
|
||||||
|
|
||||||
`p->isAI` at `+0xf9` is **not on the wire**. It is copied from the setup/network player record at
|
|
||||||
`+0xd` by 0x0077b620, alongside `PvMA`(+0x18c), `+0xfa` and `Elim`(+0xf8). 0x0080d7a0 reads it as
|
|
||||||
`NPC || RebAI ? 0 : (isAI ? 2 : 1)`, which is what fixes its polarity. This is a **game-setup
|
|
||||||
input the save does not carry**; §5 states how the engine takes it.
|
|
||||||
|
|
||||||
### 3.2 The record and the table
|
|
||||||
|
|
||||||
`LoadDifficultyRow(level, out)` (0x005a3990) writes the default `{1, 1,1,1, 1,1,1}` first, then
|
|
||||||
linear-searches the table for `id == level` and copies its six floats. So an out-of-range level
|
|
||||||
yields all-ones, not a crash.
|
|
||||||
|
|
||||||
```
|
|
||||||
struct DifficultyMods { // 0x1c bytes, stride confirmed by the 0x92492493 magic divide
|
|
||||||
int id; // +0x00
|
|
||||||
float aiMaintDivisor; // +0x04 ComputeBudget: Maint /= ftol(this)
|
|
||||||
float aiIncomeMod; // +0x08 TradePointsToMoney, and trade-route income
|
|
||||||
float aiResearchMod; // +0x0c research points bought with money
|
|
||||||
float plMaintDivisor; // +0x10
|
|
||||||
float plIncomeMod; // +0x14
|
|
||||||
float plResearchMod; // +0x18
|
|
||||||
};
|
|
||||||
```
|
|
||||||
|
|
||||||
The three consumers, all reading through `Select`'s returned triple pointer:
|
|
||||||
|
|
||||||
| triple offset | reader | what it scales |
|
|
||||||
|---|---|---|
|
|
||||||
| `+0` | `ComputeBudget` 0x0086338b | `Maint = Maint / ftol(m)` -- a fleet-upkeep **divisor** |
|
|
||||||
| `+4` | `IncomeDifficultyMod` 0x0080f470, and the trade manager at 0x00833938 | a system's money income, and a trade route's |
|
|
||||||
| `+8` | `ResearchPointsFromMoney` 0x0080e229, inlined again in `ComputeBudget` at 0x00863618 | research points bought with money |
|
|
||||||
|
|
||||||
`BuildDifficultyTable` (0x005a3870) push_backs three rows:
|
|
||||||
|
|
||||||
| id | AI: maint / | AI: **income x** | AI: research x | | else: maint / | income x | research x |
|
|
||||||
|---|---|---|---|---|---|---|---|
|
|
||||||
| 0 | 1.0 | 1.0 | 1.0 | | 1.5 @0x00a1b000 | 1.5 | 1.5 |
|
|
||||||
| 1 | 3.0 @0x00a0451c | **1.1 @0x009f957c** | 1.5 @0x00a1b000 | | 1.0 | 1.0 | 1.0 |
|
|
||||||
| 2 | 1e6 @0x009ebd7c | **1.7 @0x009f9580** | 2.0 @0x00a04518 | | 1.0 | 1.0 | 1.0 |
|
|
||||||
|
|
||||||
Read as: on **easy** (id 0) the *player* gets the break -- 1.5x income, 1.5x research and
|
|
||||||
2/3 maintenance -- and the AI is unmodified; on **normal** (id 1) and **hard** (id 2) the *AI*
|
|
||||||
gets the break, and on hard its fleet maintenance is divided by a million, i.e. free.
|
|
||||||
|
|
||||||
**Every player record in all 11 corpus saves carries `aidf = 1`**, so the live multiplier for an
|
|
||||||
AI-owned system in this corpus is exactly `1.1f = 1.100000023841858` — which is the ×1.1 the
|
|
||||||
`BnkEl` oracle measured and `formula-gaps.md` Q3 could not name.
|
|
||||||
|
|
||||||
The other two columns are named here because they come off the same record and the same selector,
|
|
||||||
and both already have a home in `game::sim::BudgetInputs` (`maintenanceDivisor`,
|
|
||||||
`researchDifficultyMult`) that had no source until now.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. PREDICTION — written before the run
|
|
||||||
|
|
||||||
The oracle is `tools/max_income_oracle.py`, which inverts the stored `BnkEl` to
|
|
||||||
`Σ max(ComputeMaxIncome(s), 0)` over each player's owned systems: 25 player-records over 11 saves.
|
|
||||||
Lane N's predictor scores **6/25** — every human-owned or independent record, no AI-owned one.
|
|
||||||
|
|
||||||
### 4.1 What I expect
|
|
||||||
|
|
||||||
1. Adding **only** the difficulty multiplier (`1.1` on AI-owned systems) does **not** reach 25/25.
|
|
||||||
The single-system AI empires close; the multi-system ones do not, because they miss the
|
|
||||||
suitability cost (§2.6), which is zero on a homeworld and non-zero on every other colony.
|
|
||||||
2. Adding the suitability cost, the species income/cost factors, the per-species truncation of
|
|
||||||
`PopIncome`, and the civilian species loop closes the rest: **25/25**.
|
|
||||||
3. `SpeciesDef +0x18` (income factor) and `+0x24` (cost factor) are data-file values. Q3 quotes
|
|
||||||
Zuul 1.1 / Morrigi 0.8 for the first and Zuul 0.7 for the second. If those are right the Zuul
|
|
||||||
saves close with them and miss without them.
|
|
||||||
4. `rbfl` (rebelling) zeroes `ComputeTotalOutput` but **not** the money chain — the population
|
|
||||||
income and the suitability cost still apply. Lane N's predictor returns 0 for such a system;
|
|
||||||
that is wrong, and no corpus system is rebelling, so it is untested either way.
|
|
||||||
|
|
||||||
### 4.2 Falsification — how this could be wrong, and the symptom of each
|
|
||||||
|
|
||||||
| way it could be wrong | symptom |
|
|
||||||
|---|---|
|
|
||||||
| `p->[0xf9]` is not "is AI" but something else | the ×1.1 lands on the wrong records; the human records that match today would break |
|
|
||||||
| the blocks term is `×5` not `(t − t mod 5) × 5` | every record off by a multiple of the block count |
|
|
||||||
| `POPTYPE[1].income` is not `0.33f` | civilian-bearing colonies off by a clean ratio; Zuul (no civilians) unaffected |
|
|
||||||
| the per-species truncation is really one truncation of the sum | off by at most 6 per colony, and only where two species or a non-unit factor coexist |
|
|
||||||
| `IdealSuit` is the player's field, not the server's species array | invisible in this corpus, where they are equal — so this stays **unverified** |
|
|
||||||
| `SpeciesDef +0x24` for species 4 (the NPC species) is not 1.0 | only the NPC-owned records miss |
|
|
||||||
| the science-cascade term is not zero | every record short by the same non-zero amount |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. Result
|
|
||||||
|
|
||||||
**The oracle goes from 6/25 to 25/25.** No VM time was spent: `BnkEl` is an inversion of the
|
|
||||||
number under test, so the corpus states the answer for every player-record it holds.
|
|
||||||
|
|
||||||
```
|
|
||||||
tools/max_income_oracle.py --json oracle.json
|
|
||||||
tools/max_income_predict.py verify/results/saves/*.sav --oracle oracle.json
|
|
||||||
-> 25 match, 0 differ, 0 with no oracle record
|
|
||||||
```
|
|
||||||
|
|
||||||
### 5.1 Every prediction in §4.1, checked
|
|
||||||
|
|
||||||
| predicted | outcome | verdict |
|
|
||||||
|---|---|---|
|
|
||||||
| the difficulty multiplier alone does not reach 25/25 | 13/25 with it; the twelve Zuul records still missed | HELD |
|
|
||||||
| the remaining terms close the rest | 25/25 | HELD, but **for a different reason than predicted** -- see §5.2 |
|
|
||||||
| the Zuul `SpeciesDef +0x18`/`+0x24` values matter | 1.1 / 0.7 give 25/25 | HELD (weakly: the cost factor is multiplied by a zero cost on every corpus colony, so **only the income factor is actually tested**) |
|
|
||||||
| `rbfl` does not zero the money chain | no corpus system is rebelling | UNEXERCISED, and now labelled as such in the code |
|
|
||||||
| `IdealSuit` is the server's species array, not the player's field | it is the server's, and it is **on the wire** -- see §5.3 | HELD, and no longer unfalsifiable |
|
|
||||||
|
|
||||||
### 5.2 The prediction that was wrong
|
|
||||||
|
|
||||||
§4.1 said the twelve Zuul misses were the suitability cost. They were not: every Zuul colony in the
|
|
||||||
corpus sits exactly at its species' ideal suitability, so `CalcSuitMod` is 0 and the cost term is
|
|
||||||
0 on all of them. The suitability cost is **completely unexercised by this corpus** and stays a
|
|
||||||
hypothesis, exactly like the morale and station branches of the output term.
|
|
||||||
|
|
||||||
What the Zuul records were actually missing was in the *output* half, not the income half:
|
|
||||||
`SpeciesDef +0x4c` (base resource demand) and `+0x50` (resource output factor) are **per species**
|
|
||||||
and are read off the system **owner's** species. Lane N measured them live -- 0/10 for Human and
|
|
||||||
Tarkas, 10/40 for Zuul -- but `max_income_predict.py` carried them as one global pair defaulting to
|
|
||||||
the Human values. A Zuul colony's harvest term is `min(resAvail, 10) x 40 = 400` output points that
|
|
||||||
a 0/10 pair scores as **zero**; 400 points is 80 whole five-point blocks worth 5 money each after
|
|
||||||
the x5, i.e. exactly 2000 money per colony, and the observed shortfalls were 4400 (two colonies x
|
|
||||||
2000 x the 1.1 species income factor) and 5566 (the same, with a 1.15 `OutMod` and the AI's 1.1).
|
|
||||||
Both deltas fall out to the unit.
|
|
||||||
|
|
||||||
The lesson is the campaign's own rule 8 in a new dress: a per-species table taken as a scalar
|
|
||||||
agrees with the oracle on the species it was measured from and disagrees on every other, and there
|
|
||||||
is no symptom until a second species appears in the corpus.
|
|
||||||
|
|
||||||
### 5.3 A new wire fact: `ISsp`/`ISsu` is `server->IdealSuit[]`
|
|
||||||
|
|
||||||
The Sim block's `ISsp`/`ISsu` pairs -- seven of them, in species-index order -- are exactly the
|
|
||||||
float[7] that `StrategyServer::IdealSuit` (0x0080f4b0) indexes at `raw base + 0xf8`. The array is
|
|
||||||
randomised per game by the map generator (`turn1-state` has Human 11.106, `human-turn2` has Human
|
|
||||||
10.220, `zuul-turn15` has Human 7.502), and every `ServerPlayer`'s own `IdealSuit` field carries the
|
|
||||||
same value for its own species in all 11 saves. So the suitability cost's ideal is fully held on
|
|
||||||
the wire and needs no data file. `struct-recovery.md` §5 lists the two tags without saying what
|
|
||||||
they are; this names them.
|
|
||||||
|
|
||||||
### 5.4 Falsification actually run
|
|
||||||
|
|
||||||
| run | score | what it shows |
|
|
||||||
|---|---|---|
|
|
||||||
| `--ai-rule non-npc-not-first` (the model) | **25/25** | |
|
|
||||||
| `--ai-rule none` (nobody is AI) | 14/25 | the eleven AI-owned records break -- the x1.1 is load-bearing |
|
|
||||||
| `--ai-rule all-non-npc` (both real players are AI) | 14/25 | the eleven human-owned records break, each by exactly x1.1 -- so the multiplier has to land on **precisely** the AI set, not merely somewhere |
|
|
||||||
| `--base-demand 0 --res-output 10` on a Zuul save | 0/2 | the per-species resource pair is load-bearing, by the 4400 computed in §5.2 |
|
|
||||||
|
|
||||||
### 5.5 What this run did NOT cover -- read this before quoting the 25/25
|
|
||||||
|
|
||||||
* **The suitability cost is untested.** Every colony in the corpus is at its species' ideal, so
|
|
||||||
`CalcSuitMod` returns 0 everywhere and `SpeciesDef +0x24` is multiplied by zero. The `<=`
|
|
||||||
boundary, the `SuitTol` cap, the `vnh` early-out and the unowned-system 20.0 are all unexercised.
|
|
||||||
* **The slave income term is untested**: no colony carries slaves.
|
|
||||||
* **The addiction income modifier is untested**: `nadct` is 0 on every system.
|
|
||||||
* **The morale multiplier is untested**: every colony sits at 75, strictly between the thresholds.
|
|
||||||
* **The civilian capacity surplus is untested**: no colony is at its cap.
|
|
||||||
* **Only difficulty level 1 is exercised.** Levels 0 and 2, and the maintenance and research
|
|
||||||
columns of all three rows, are read from the initialiser and never run.
|
|
||||||
* **`aidf` is 1 on all 25 records**, so the level lookup itself is a constant here.
|
|
||||||
* **The AI flag is not on the wire**, so 25/25 pins *an* assignment of it (player 0 human, player 1
|
|
||||||
AI, NPCs neither), not the flag's provenance. §5.4's second falsification run is what makes that
|
|
||||||
assignment non-trivial rather than a free parameter.
|
|
||||||
* Two of the 25 records (the human-turn2/turn3 pair) come from a game whose Sim-level `IncMod` is
|
|
||||||
1.48 rather than 1.0, which is the only exercise the server income modifier gets.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. What this unblocks, and what it does not
|
|
||||||
|
|
||||||
### 6.1 T31 `UpdateBankruptcyLimits` -- unblocked in substance
|
|
||||||
|
|
||||||
`UpdateBankruptcyLimits` sums exactly `max(ComputeMaxIncome(s), 0)` over owned, non-abandoned
|
|
||||||
systems, which is the number the oracle validates. It is now wired into `sots_turn` and self-checks
|
|
||||||
every run against the `BnkEl` the input save already carries -- 8 of 8 players on `turn1-state`.
|
|
||||||
|
|
||||||
It is still listed **blocked**, for two reasons that are not the formula:
|
|
||||||
|
|
||||||
1. `ServerPlayer+0xf9` (is this player AI?) is a game-setup input the save does not carry;
|
|
||||||
`--ai-player N` supplies it, and without it an AI empire's limit comes out 1/1.1 low.
|
|
||||||
2. `BnkPr` needs `BANKRUPTCY_PROTECTION_LIMIT_FACTOR` from the data files, so it is only offered
|
|
||||||
with a tuning table loaded. Committing a value computed from an unloaded (zero) constant
|
|
||||||
regressed one leaf in the first measurement and was removed.
|
|
||||||
|
|
||||||
Committing it closes **nothing** on the reference pair, and that is a fact about the rest of the
|
|
||||||
engine rather than about this chain: the limits move between `turn1` and `turn2` because the
|
|
||||||
**civilian population grows**, and that growth is not committed, so our limit equals the input
|
|
||||||
save's. Measured with `--commit-blocked=T31 --ai-player 1`: **0 closed, 0 regressed** (209 -> 204
|
|
||||||
and 108 -> 103, unchanged from the baseline). The same numbers hold with every other blocked phase
|
|
||||||
committed alongside it.
|
|
||||||
|
|
||||||
### 6.2 P01 `ComputeBudget` -- NOT unblocked, and the roadmap's item 1 was wrong about this
|
|
||||||
|
|
||||||
`ComputeBudget` has two modes and they take their per-system money from **different functions**
|
|
||||||
(0x008631fd, the `[ebp+0xc]` test):
|
|
||||||
|
|
||||||
```
|
|
||||||
if (projected) money = ComputeMaxIncome(s) // 0x007521c0 -- what this lane closed
|
|
||||||
else { OutputRates r; ComputeOutput(s, &r); money = r.out[3] } // 0x00751fb0
|
|
||||||
```
|
|
||||||
|
|
||||||
`ComputeOutput` (0x00751fb0) passes the system's **own** `Rts` sliders, not a max-mods vector. On
|
|
||||||
that path the science, construction and terraform channels are funded, which means:
|
|
||||||
|
|
||||||
* the repair pass inside `ComputeOutputFromRates` runs (it is **not** side-effect free -- it repairs
|
|
||||||
ships in orbit), so its leftover science points can be non-zero;
|
|
||||||
* the unspent-industry and unspent-terraforming cascades into the money channel are live, and §2.4's
|
|
||||||
proof that both are zero **does not apply**;
|
|
||||||
* the build queue's consumption of construction points is in the same call.
|
|
||||||
|
|
||||||
So the turn's real per-system money is a strictly larger problem than the one the `BnkEl` oracle can
|
|
||||||
falsify, and nothing in this corpus states its answer. P01/P02/P03/P05/P06 stay blocked, and the
|
|
||||||
next lane on them should target `ComputeOutputFromRates`'s full channel split rather than this
|
|
||||||
chain. The roadmap's item 1 claimed this chain unblocks them; it does not.
|
|
||||||
|
|
||||||
### 6.3 The two research RNG words
|
|
||||||
|
|
||||||
Both are downstream of the budget's research allocation:
|
|
||||||
|
|
||||||
1. `ProcessResearch`'s `RNG::Chance(odds)` on the funded node, where `odds` is built from the
|
|
||||||
research **points** the allocation bought (0 or 1 word -- `Chance` draws nothing for `p <= 0`
|
|
||||||
or `p >= 1`);
|
|
||||||
2. the draw inside `ServerPlayer::OnTechResearched`'s effect callback, which fires only when a
|
|
||||||
tech actually completes and therefore only when the allocation was large enough (0 or 1 word,
|
|
||||||
measured live in the B3 compare run).
|
|
||||||
|
|
||||||
Those points come from `ComputeBudget`'s research money, which is on the P01 path of §6.2 -- not
|
|
||||||
this one. **The generator model still cannot include them.** What has changed is the reason: it is
|
|
||||||
no longer "the money output of a system is unmodelled" but "the *projected-rate* money output is",
|
|
||||||
which is a narrower and differently-shaped gap, and it is now the only thing between the ledger and
|
|
||||||
those two words.
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
{
|
|
||||||
"_note": "Lane E1, the output -> money chain. Six addresses this lane read are already held elsewhere and AGREE, so they are dropped from this fragment rather than duplicated (rule 14): ServerSystem_ComputeMaxIncome 0x007521c0 (lane-k.json), ServerSystem_ComputeOutputFromRates 0x00751bb0 and ServerSystem_TradePointsToMoney 0x007505b0 and ServerSystem_CalcSuitMod 0x007484d0 (addresses.json), GroupIncome 0x00535e80 and PopTypeRow 0x00535e00 (lane-n.json). Every one of those six was re-read from the instruction stream this lane and the address, convention and boundary matched what was already recorded.",
|
|
||||||
"entries": [
|
|
||||||
{
|
|
||||||
"name": "ServerSystem_PopIncome",
|
|
||||||
"addr": "0x0074d760",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "double (ServerSystem* sys, int groupType) // `ret 4`, real end 0x0074d8e1. The income analogue of PopOutput 0x0074d8f0, and NOT the same law: it sums, over species 0..6, `(double)ftol( (double)GroupIncome(groupType, count) x moraleMod x addictionMod )` -- so the value truncates TWICE per (group, species) row, once inside GroupIncome and once after both factors. moraleMod is 0x00746910 (the same helper the output term uses) and applies to groupType 1 only; addictionMod is the float behind slot 0x00aeca48 when the system's int[7] addiction table at +0x1e4 has a non-zero entry for that species. For groupType 1 and the owner's own species on a non-independent system the count first gains the capacity surplus from two calls to 0x0074a6d0 (out slot 4, then out slot 6), max(0, B - A)",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/subsystems/income-term.md (lane E1 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ServerSystem_SlaveIncome",
|
|
||||||
"addr": "0x0074b700",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "double (ServerSystem* sys) // plain `ret`, real end 0x0074b793. groupType 2 of the same loop as PopIncome, over SlaveCount(species) (0x0074b610): no morale factor and no capacity surplus, but the addiction factor still applies. Unexercised: slave counts are 0 on every call in the corpus",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/subsystems/income-term.md (lane E1 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ServerSystem_SlaveCount",
|
|
||||||
"addr": "0x0074b610",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "int64 (ServerSystem* sys, int species) // the slave-group population of one species; the group-2 counterpart of GroupPopulation 0x00747ba0",
|
|
||||||
"status": "unverified",
|
|
||||||
"source": "findings/subsystems/income-term.md (lane E1 2026-09-08) -- reached from SlaveIncome, body not read"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ServerSystem_ComputeOutput",
|
|
||||||
"addr": "0x00751fb0",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "void (ServerSystem* sys, int out[12]) // zeroes `out`, returns immediately when the caller's pointer is null or the system has no owner (+0x100), else calls ComputeOutputFromRates(out, &sys->Rts /*+0x88*/) -- the system's OWN rate sliders, not a max-mods vector. THE DISTINCTION THAT MATTERS: ComputeBudget's real (non-projected) per-system money is this function's out[3], while its projected mode and UpdateBankruptcyLimits use ComputeMaxIncome 0x007521c0. They are different numbers: this path funds the science, construction and terraform channels, so the repair pass runs and the unspent-industry and unspent-terraforming cascades into the money channel are live",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/subsystems/income-term.md (lane E1 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "StrategyServer_IncomeDifficultyMod",
|
|
||||||
"addr": "0x0080f470",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "float (StrategyServer* srv, ServerPlayer* p) // `ret 4`, real end 0x0080f49f. Returns float32( float32(DifficultyMods_Select(p->diffMods /*+0x36c*/, p)[1]) x float32(srv->IncMod /*raw base +0xbc, the Sim block's `IncMod` tag*/) ), or just the server modifier when p is null. Every step is stored back through a 4-byte float. `ecx` here is the RAW StrategyServer base (ServerSystem+0x10), four bytes above the base the class's own methods get. THIS IS THE MISSING x1.1: at the difficulty level every corpus save carries (aidf == 1) the AI column of the table is 1.1f",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/subsystems/income-term.md (lane E1 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "DifficultyMods_Select",
|
|
||||||
"addr": "0x0059b490",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "float* (DifficultyMods* rec, ServerPlayer* p) // `ret 4`, real end 0x0059b4b3. Returns &rec->f[0] (the AI triple, at +0x04) when p is non-null AND p->[0xf9] (is-AI) is set AND p->NPC (+0xfb) is clear; otherwise &rec->f[3] (the non-AI triple, at +0x10). The three consumers read offset +0 (fleet maintenance divisor, ComputeBudget 0x0086338b), +4 (system and trade-route money, 0x0080f470 and 0x00833938) and +8 (research points bought with money, 0x0080e229 and 0x00863618)",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/subsystems/income-term.md (lane E1 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "LoadDifficultyRow",
|
|
||||||
"addr": "0x005a3990",
|
|
||||||
"convention": "cdecl",
|
|
||||||
"prototype": "void (int level, DifficultyMods* out) // real end 0x005a3a53 (Ghidra size 193 stops 2 bytes short). memcpy's the default {id 1, 1.0f x6} into `out` FIRST, then builds the table with BuildDifficultyTable 0x005a3870 and linear-searches it for id == level (stride 0x1c, from the 0x92492493 magic divide), copying the six floats on a hit. An out-of-range level therefore yields all ones rather than failing. Called from ServerPlayer::Read 0x008804d0 at 0x00880fa3, gated on 0 <= aidf < 3, which is also where ServerPlayer+0x368 (`aidf`) is stored",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/subsystems/income-term.md (lane E1 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "BuildDifficultyTable",
|
|
||||||
"addr": "0x005a3870",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "vector<DifficultyMods>* (vector<DifficultyMods>* out) // real end 0x005a3989. THE TABLE IS BUILT IN CODE, from .rdata float literals -- no data-file key, no GlobalConst slot, same shape as lane N's pop-type table. Three rows of {int id; float ai[3]; float other[3]} (0x1c): id 0 = ai {1,1,1} / other {1.5,1.5,1.5} (0x00a1b000); id 1 = ai {3.0 (0x00a0451c), 1.1 (0x009f957c), 1.5} / other {1,1,1}; id 2 = ai {1e6 (0x009ebd7c), 1.7 (0x009f9580), 2.0 (0x00a04518)} / other {1,1,1}. Read as: level 0 gives the break to the human player, levels 1 and 2 give it to the AI, and on level 2 the AI's fleet maintenance is divided by a million",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/subsystems/income-term.md (lane E1 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ServerPlayer_GetIncMod",
|
|
||||||
"addr": "0x0080dd10",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "float (ServerPlayer* p) // seven bytes: `fld DWORD [ecx+0x30c]; ret`. The save's per-player `IncMod`",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/subsystems/income-term.md (lane E1 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ServerPlayer_GetSpeciesCostFactor",
|
|
||||||
"addr": "0x0080dd20",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "float (ServerPlayer* p) // `SpeciesDef(p->Species /*+0x5c*/)->+0x24`, the multiplier on the suitability MONEY cost (Zuul 0.7). A data-file value",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/subsystems/income-term.md (lane E1 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "StrategyServer_IdealSuit",
|
|
||||||
"addr": "0x0080f4b0",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "float (StrategyServer* srv, int species) // `ret 4`; one instruction of work: `fld DWORD [ecx + species*4 + 0xf8]`. The per-species ideal-suitability array on the RAW server base. IT IS ON THE WIRE: the Sim block's `ISsp`/`ISsu` pairs are this float[7] in species-index order, and the array is randomised per game by the map generator -- verified against every ServerPlayer's own `IdealSuit` field in all 11 corpus saves. CalcSuitMod reads THIS, not the player's field",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/subsystems/income-term.md (lane E1 2026-09-08)"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "ServerSystem_TerraformPointsNeeded",
|
|
||||||
"addr": "0x00746890",
|
|
||||||
"convention": "thiscall",
|
|
||||||
"prototype": "double (ServerSystem* sys) // real end 0x0074690d. Returns 0 with no owner, else float32(|IdealSuit(species) - sys->Suit|) / |owner->TerraMod (+0x134) x [0x00a1f928] / 20000|. The sign term (-1.0 when the planet's suitability is STRICTLY above the ideal) is multiplied in BEFORE the fabs at 0x00746906 and therefore cancels: the result is always >= 0. That is why the unspent-terraform cascade into the money channel is provably zero under the max-income rate vector",
|
|
||||||
"status": "verified",
|
|
||||||
"source": "findings/subsystems/income-term.md (lane E1 2026-09-08)"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
@ -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 @ bebdee1, generated 2026-09-08 by tools/gen_addresses.py
|
// Source: sots-re ghidra/addresses.json @ 728348b, 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>
|
||||||
|
|
@ -1319,52 +1319,6 @@ constexpr uint32_t StrategyNetworkClient_off_Server = 0x00000054;
|
||||||
constexpr uint32_t StrategyServer_AbandonChaosCheck = 0x003b9df0;
|
constexpr uint32_t StrategyServer_AbandonChaosCheck = 0x003b9df0;
|
||||||
// thiscall void (StrategySim* this /* S+4 */, ...) // the fleet-move command handler; logs "StrategySim: Fleet not found.", "StrategySim: Waypoint %d(id) not found." and "StrategySim: (see above) cannot move fleet %d(id)." Bumps ModCount at 0x008657aa. THE ONLY ModCount WRITER OTHER THAN THE ABANDON CHECK THAT IS DIRECT-CALL REACHABLE FROM EITHER TURN DRIVER (from OnAllCombatDone_Tail's 1369-function closure; not from ProcessTurn's 1382) [verified]
|
// thiscall void (StrategySim* this /* S+4 */, ...) // the fleet-move command handler; logs "StrategySim: Fleet not found.", "StrategySim: Waypoint %d(id) not found." and "StrategySim: (see above) cannot move fleet %d(id)." Bumps ModCount at 0x008657aa. THE ONLY ModCount WRITER OTHER THAN THE ABANDON CHECK THAT IS DIRECT-CALL REACHABLE FROM EITHER TURN DRIVER (from OnAllCombatDone_Tail's 1369-function closure; not from ProcessTurn's 1382) [verified]
|
||||||
constexpr uint32_t StrategySim_MoveFleetCommand = 0x00465780;
|
constexpr uint32_t StrategySim_MoveFleetCommand = 0x00465780;
|
||||||
// thiscall void __thiscall Game::StrategyApp::RunAI(int playerNetId, const char* aiCustomDataName, unsigned char aiPersonality, unsigned int rngSeed) -- RET 0x10, four stack args. The ONE-SHOT AI construction path, reached only from StrategyNetworkClient::OnMessage 0x00784640+0x96e, case SNMRunAI (net msg id 0x3d). Resolves the player through the handle registry at server+0x84; refuses on a human (`p->IsAI(+0xf9) == 0` -> "RunAI: Cannot create a StrategyClient/AI for a human player."); logs "RunAI: Creating AI client for %s using %08x for random seed."; operator_new(0x708) + StrategyClient ctor 0x00782ed0 WITH rngSeed; StrategyServer::InitGame; StrategyClient::CreateAI 0x007653c0(aiPersonality) which builds the Game::StrategyAIAgent into StrategyClient+0x12c ("RunAI: Failed to create AI for %s (player %d)."); if a save-game blob was supplied, "Loading AI custom data from save game..." through agent vt[5]/vt[6]; finally RaiseAIPrepareTurn 0x00815f20. NOT a per-turn entry point [verified]
|
|
||||||
constexpr uint32_t StrategyApp_RunAI = 0x004706f0;
|
|
||||||
// cdecl void (Game::StrategyServer* srv) with EBX = Game::StrategyAIAgent* (register-passed; both call sites set EBX before the call). Builds a stack Game::SEAIPrepareTurn (vftable 0x00a23c00) and invokes agent->vt[1](9, &ev) -- i.e. StrategyAIAgent::OnEvent with client event type 9. Also fires the SVScriptObject hooks at srv+0x1b4 with ids 9 and 0xa. Exactly two call sites: RunAI 0x008706f0+0x26c and StrategyApp::CreateGame 0x00888e80+0x3f5 -- both are game/AI construction, so SEAIPrepareTurn is NOT raised once per turn despite the name [verified]
|
|
||||||
constexpr uint32_t StrategyApp_RaiseAIPrepareTurn = 0x00415f20;
|
|
||||||
// thiscall void __thiscall Game::StrategyAIAgent::OnEvent(int clientEventType, Game::StrategyEvent** ev) -- RET 8. IStrategyAIAgent vtable slot 1 (vftable 0x00a1b244). Pushes a log scope on this->+0x10, then calls StrategyAIContext::OnStrategyEvent 0x006c2b90 with ECX = this->+0x94 (the context) and args (type, ev, &thunk 0x006d0ab0, this). The thunk forwards to StrategyAIAgent::OnAIPacket 0x006cf8a0. Every StrategyClient event handler in 0x00773xxx-0x00777xxx forwards through this slot when StrategyClient+0x12c is non-null [verified]
|
|
||||||
constexpr uint32_t StrategyAIAgent_OnEvent = 0x002d0ad0;
|
|
||||||
// thiscall void __thiscall Game::StrategyAIContext::OnStrategyEvent(int clientEventType, Game::StrategyEvent** ev, void (*cb)(void*, void*), Game::StrategyAIAgent* agent) -- RET 0x10. Registers {cb, agent, seq} on the pending-callback deque at this+0x58 (ring deque, buf@+0x5c cap@+0x60 head@+0x64 size@+0x68, 0x0c-byte nodes, push helper 0x0069e470 under a critical section). Then `switch (type - 6)` over 0..0x20 through the byte index table at 0x006c33a4 and jump table at 0x006c3360 (17 distinct cases), updating the AI world model and emitting INTERNAL AI packets {int code; ...} through StrategyAIContext::Broadcast 0x006b3840. Client event 9 (SEAIPrepareTurn) emits codes 1 then 2; client event 0x26 (SEResumePlaying) emits code 3. Tail: if the pending deque size is 1 it drains a second, separate queue at this+0x38 via 0x006a8690 [verified]
|
|
||||||
constexpr uint32_t StrategyAIContext_OnStrategyEvent = 0x002c2b90;
|
|
||||||
// thiscall void __thiscall Game::StrategyAIContext::Broadcast(const AIPacket* pkt) -- walks the listener red-black tree at this+0xc (std::set/map nodes; `_Isnil` at node+0x15) and calls listener->vt[3](pkt) on each -- that is the Game::AIObject event slot, implemented by AIPlayer (0x00723ed0), AISystem (0x006b3ae0), AIFleet (0x006b3970), AIBuildOrder and StrategyAIAgent (0x0069de50). Then, if the pending-callback deque at this+0x68 is non-empty, iterates it (0x0069e510 / 0x006a4ee0) and delivers the same packet to the queued {cb, this} pairs -- the hop that reaches StrategyAIAgent::OnAIPacket 0x006cf8a0. 19 call sites, all inside OnStrategyEvent 0x006c2b90 and 0x006c29c0 [mapped]
|
|
||||||
constexpr uint32_t StrategyAIContext_Broadcast = 0x002b3840;
|
|
||||||
// thiscall void __thiscall Game::StrategyAIAgent::OnAIPacket(const AIPacket* pkt) -- 2008 bytes. `eax = pkt->code - 2; if (eax > 0xf) return; jmp [eax*4 + 0x006d0078]` -- a 16-entry jump table over internal packet codes 2..17. Code 2 = the PREPARE TURN body (0x006cf958, logs "====== AI Prepare Turn (%s) ======"); code 3 = the PROCESS TURN body (0x006cfabf, logs "====== AI Process Turn (%s) ======", ~30 phases, ends by calling cl_EndTurn 0x00579310). Codes 4/6/7/8/10/11/13 fall through to the no-op at 0x006d0058. this->+0x10 = the owning StrategyClient, this->+0x14 = the ClientPlayer (name std::string at +0x40), this->+0x94 = the StrategyAIContext. Reached only through the thunk at 0x006d0ab0 [verified]
|
|
||||||
constexpr uint32_t StrategyAIAgent_OnAIPacket = 0x002cf8a0;
|
|
||||||
// thiscall void __thiscall Game::StrategyClient::OnResumePlaying(Game::StrategyEvent** ev) -- the case-0x26 handler of StrategyClient::RaiseEvent 0x00783ee0 (jump table 0x00784200, 0x2c entries). At +0x9d: `if (this->+0x12c) agent->vt[1](0x26, ev)` -- THE per-turn AI trigger. SEResumePlaying is broadcast by StrategyServer::ResumePlaying 0x007ddc90 at the very start of a new turn, after OnAllCombatDone_Tail has written the pre-turn autosave [verified]
|
|
||||||
constexpr uint32_t StrategyClient_OnResumePlaying = 0x00377480;
|
|
||||||
// offset Game::StrategyAIAgent* -- non-null only on an AI client. Every StrategyClient event handler tests it before forwarding the event to the agent; SendEndTurn 0x00783980 reads agent->vt[8]() through it to fetch the AIEncounterFlags it appends to SNMEndTurn; cl_EndTurn 0x00579310 refuses to end the turn unless it is non-null [verified]
|
|
||||||
constexpr uint32_t StrategyClient_off_AIAgent = 0x0000012c;
|
|
||||||
// offset Mars::RNG* -- a PER-CLIENT generator, operator_new(0x9cc) + RNG_Seed(ctorArg) in the StrategyClient constructor 0x00782ed0+0x143..+0x181. For an AI client the seed is RunAI's 4th argument. THE ONLY GENERATOR THE STRATEGIC AI DRAWS FROM: all six direct NextInt sites in the AI module reach it, and so do the two façade helpers cl_Chance 0x00578cf0 and cl_RandRange 0x005798e0. It is distinct from the strategic generator at StrategyServer+0x16c and is NOT serialised anywhere in the save [verified]
|
|
||||||
constexpr uint32_t StrategyClient_off_RNG = 0x00000134;
|
|
||||||
// offset Game::TurnCommands -- the ACCUMULATING order queue. Every StrategyClient order method in 0x00762ca0..0x00763f60 does `lea ecx,[this+0x160]` and appends to one of its 27 lists (or sets one of its six gates). At End Turn, StrategyClient::BuildTurnCommands 0x00783780 does TurnCommands::operator=(this->+0x4d8, this->+0x160) and then overwrites the player id, research rate and fleet-move list from live state [verified]
|
|
||||||
constexpr uint32_t StrategyClient_off_PendingTurnCommands = 0x00000160;
|
|
||||||
// offset Game::TurnCommands -- the SEND buffer, the object that becomes the `Player.<id>.TurnCommands_v5` block on the wire. Written only by StrategyClient::BuildTurnCommands 0x00783780 (from +0x160) and read by SendEndTurn 0x00783980+0x96, which copies it into the SNMEndTurn message (vftable 0x00a229e0) with TurnCommands::operator= 0x007832b0 [verified]
|
|
||||||
constexpr uint32_t StrategyClient_off_SendTurnCommands = 0x000004d8;
|
|
||||||
// thiscall void __thiscall Game::StrategyClient::BuildTurnCommands(Game::TurnCommands* dst) -- called once from EndTurn 0x00783be0+0xee with dst = &this->+0x4d8. TurnCommands::Clear 0x00893f00(dst); TurnCommands::operator= 0x007832b0(dst, &this->+0x160); dst->playerId(+4) = this->+0x150->+4; TurnCommands::SetResearchRate 0x0080f2d0(dst, this->+0x150->+0xbc) -- which is why EVERY save's TurnCommands block has the research-rate gate set and the other five clear; then, if this->+0x6d0, walks the pending fleet-move vector at this->+0x6d4/+0x6d8 (stride 8) into the move list [verified]
|
|
||||||
constexpr uint32_t StrategyClient_BuildTurnCommands = 0x00383780;
|
|
||||||
// thiscall Game::TurnCommands& __thiscall Game::TurnCommands::operator=(const TurnCommands& src) -- member-by-member copy of the six gates and their payloads, then the 27 lists. 11 call sites, including SendEndTurn, StrategyServer::OnPlayerEndTurn 0x007d9af0+0x68 (the host storing an arriving block), BuildTurnEvents and LoadGame [verified]
|
|
||||||
constexpr uint32_t TurnCommands_Assign = 0x003832b0;
|
|
||||||
// cdecl void () -- the AI's turn-submission façade. `c = g_StrategyClients[g_CurrentClientIndex]; if (c && c->AIAgent(+0x12c) && !c->bTurnEnded(+0x15c)) StrategyClient::EndTurn(c, true);` -- note the +0x12c test: this entry point works ONLY for an AI client. Called from the AI Process Turn body at 0x006cfcd9. The other caller of StrategyClient::EndTurn is the human UI at 0x005e4f80+0x5f [verified]
|
|
||||||
constexpr uint32_t cl_EndTurn = 0x00179310;
|
|
||||||
// cdecl bool (float p) -- `c = g_StrategyClients[g_CurrentClientIndex]; return c ? RNG_Chance(c->RNG(+0x134), p) : false;`. One of the AI's two randomness façades; used by the AI Process Turn body at 0x006cfc24 for the surrender roll that follows the "Survival Outlook: Dead in %i turns (%5.2f%% chance to surrender this turn)" log line [verified]
|
|
||||||
constexpr uint32_t cl_Chance = 0x00178cf0;
|
|
||||||
// cdecl int (int lo, int hi) -- `c = g_StrategyClients[g_CurrentClientIndex]; if (!c) return 0; n = hi - lo; return lo + RNG_NextInt(&c->RNG(+0x134)->mt, &n);`. NOTE RNG_NextInt is INCLUSIVE of its bound (addresses.json), so the range is [lo, hi] inclusive. Eight AI-module call sites [verified]
|
|
||||||
constexpr uint32_t cl_RandRange = 0x001798e0;
|
|
||||||
// offset Game::StrategyClient* g_StrategyClients[] -- the client table the whole 0x00578cf0..0x005793xx façade family indexes with g_CurrentClientIndex (0x00ae4808). 40 functions reference it. The AI runs as the current client: everything it does goes through this indirection, which is how one process hosts the human client and N AI clients over the same API [mapped]
|
|
||||||
constexpr uint32_t g_StrategyClients = 0x006e47e4;
|
|
||||||
// offset int -- index into g_StrategyClients (0x00ae47e4). Selects which client the cl_* façade acts on. Not instrumented; who sets it, and when relative to the AI's turn, is open [mapped]
|
|
||||||
constexpr uint32_t g_CurrentClientIndex = 0x006e4808;
|
|
||||||
// offset Mars::RNG -- a STATIC generator in .data, 0x9cc bytes. Its only static initialiser (0x009dc6e0) writes the Mars::IStreamable vftable 0x009e22bc, NOT the Mars::RNG vftable 0x009e9aec that RNG_Seed installs: none of the six RNG_Seed call sites in the image targets it, so its mt[624] is the zero-initialised BSS array and `left` is 0. An all-zero MT19937 state is a fixed point of the twist, so EVERY draw from it returns 0. Five consumers: SNMRunAI (the AI client seed, OnMessage+0x955), RunCombatRound 0x007cbe80+0x60f, 0x007c2fa0+0xc84, 0x0079ea90+0x73 (an RNG_Chance) and 0x005b9f00+0xc0 [verified]
|
|
||||||
constexpr uint32_t g_GlobalRNG = 0x006f6e58;
|
|
||||||
// thiscall void __thiscall -- loads Data/Strategy/AI/aitechmode.csv, aitechpri.csv and aitechgrp.csv through the Mars::ICSVRowParser subclasses Game::AIUserTechModeRowParser (vftable 0x00a1ae8c), AIUserTechPriRowParser (0x00a1ae7c) and AIUserTechGrpRowParser (0x00a1ae6c). Diagnostics: "%s, %i: tech %s does not exist.", "%s, %i: priority %i (%s) is out of range 0..255.", "%s, %i: bad research mode character: %s", "%s, %i: bad group: %s". Tech group ids are the AITG_* strings emitted by 0x006920a0 (ARMOR BALWEAP BEAM BIOWEAP NRGWEAP SHIELDS TORPS WARHEAD) [mapped]
|
|
||||||
constexpr uint32_t AIRulesDB_LoadTechTables = 0x002c7980;
|
|
||||||
// thiscall void __thiscall -- loads data/strategy/ai/stock_design_names.csv, stock_diplomacy_messages.csv and stock_player_names.csv through Game::AIPersonaDB::StockDesignNameRowParser (vftable 0x00a1b034), StockDiplomacyMessageRowParser (0x00a1b024) and StockPlayerNameRowParser (0x00a1b014). Diagnostics key on species and on a diplomatic event id; the id vocabulary is the 53 AIDIP_* strings returned by 0x00690960 [mapped]
|
|
||||||
constexpr uint32_t AIPersonaDB_LoadStockTables = 0x002c6250;
|
|
||||||
// thiscall void __thiscall -- loads data/strategy/ai/affinity_weapon.csv and affinity_section.csv ("ToAISectionRule: %s ship section not found: %s"). These are the ship-design affinity weights consumed by the design composer 0x006ad700 ("AIComposeShipBlueprint: SectionBlueprint::MAX_OPTIONS", "While AI for %s was designing a ship: Did not find any weapon to match %s, %s, bNoTrackingWeapons=%i."). Section-class rule selection is 0x0069cc30 / 0x0069cdb0 ("AISelectSectionClassRules: maxout", "AISelectSectionClassRulesMergeFallback: maxout"); the generic selector is 0x00695140 ("AIRulesDB::SelectRules_T: maxout") [mapped]
|
|
||||||
constexpr uint32_t AIRulesDB_LoadAffinityTables = 0x002c63c0;
|
|
||||||
// thiscall void __thiscall -- loads Data/Strategy/AI/weapon_replacements.csv through Game::StrategyAIContext::WeaponReplacementsRowParser (vftable 0x00a1a62c). Consumed by 0x00694f80 ("StrategyAIContext::GetWeaponReplacement: maxReplacements (%i)") [mapped]
|
|
||||||
constexpr uint32_t StrategyAIContext_LoadWeaponReplacements = 0x002b4dc0;
|
|
||||||
// thiscall void (CombatResolveContext* this) // THE POST-BATTLE RETREAT PIPELINE. Exactly one caller: CombatResolver_Run 0x007d5af0, unconditionally, at 0x007d5be2. Real body 0x007d5a00..0x007d5abb; the only jcc in it is the operator-new null test whose false arm is a _CxxThrowException. It builds a ~0x2c-byte RetreatContext stack local from the resolver's ctx (rc->+0x00 = ctx->+0x00 = S; rc->+0x04 = ctx->+0x08 = enc; rc->+0x08 = ctx->+0x0c = res; a std::map<int,ServerSystem*> at rc->+0x0c with an operator_new(0x18) head node at rc->+0x10 and _Mysize rc->+0x14; a std::vector<RetreatGroup*> at rc->+0x1c/+0x20/+0x24) and runs SIX unconditional this-calls in a straight line: FUN_0079bb90 (per-player destinations), FUN_0079bcd0 (build groups), FUN_007b0320 (whole vs partial), FUN_00790790 (split partial fleets), FUN_007d5650 (execute; EVENT_FLEET_RETREATED_VIA_TELEPORT), FUN_007a7cd0 (destructor). CORRECTS combat-resolver.md's characterisation of this as 'the per-phase combat pipeline': it is ONE subsystem, retreat, not six combat phases. DRAW-FREE: a 327-function closure (E8 calls plus E9 tail-call thunks) contains zero calls to the four RNG primitives and zero inlined MT tempering immediates [verified]
|
// thiscall void (CombatResolveContext* this) // THE POST-BATTLE RETREAT PIPELINE. Exactly one caller: CombatResolver_Run 0x007d5af0, unconditionally, at 0x007d5be2. Real body 0x007d5a00..0x007d5abb; the only jcc in it is the operator-new null test whose false arm is a _CxxThrowException. It builds a ~0x2c-byte RetreatContext stack local from the resolver's ctx (rc->+0x00 = ctx->+0x00 = S; rc->+0x04 = ctx->+0x08 = enc; rc->+0x08 = ctx->+0x0c = res; a std::map<int,ServerSystem*> at rc->+0x0c with an operator_new(0x18) head node at rc->+0x10 and _Mysize rc->+0x14; a std::vector<RetreatGroup*> at rc->+0x1c/+0x20/+0x24) and runs SIX unconditional this-calls in a straight line: FUN_0079bb90 (per-player destinations), FUN_0079bcd0 (build groups), FUN_007b0320 (whole vs partial), FUN_00790790 (split partial fleets), FUN_007d5650 (execute; EVENT_FLEET_RETREATED_VIA_TELEPORT), FUN_007a7cd0 (destructor). CORRECTS combat-resolver.md's characterisation of this as 'the per-phase combat pipeline': it is ONE subsystem, retreat, not six combat phases. DRAW-FREE: a 327-function closure (E8 calls plus E9 tail-call thunks) contains zero calls to the four RNG primitives and zero inlined MT tempering immediates [verified]
|
||||||
constexpr uint32_t CombatResolve_Retreat = 0x003d5a00;
|
constexpr uint32_t CombatResolve_Retreat = 0x003d5a00;
|
||||||
// thiscall void (RetreatContext* this) // RETREAT PHASE 1. One loop over enc->members (stride 0x44, magic 0x78787879 / sar 5). Per member: FUN_00787210(&enc->+0x1c, enc->+0x0c, member->+0x00 /*ServerPlayer*/, &r1, &r2, &r3), then this->dest[player->PlyrIdx(+0x28)] = the FIRST NON-NULL of (r1, r2, r3) via std::map<int,T*>::operator[] 0x0076bce0. So the per-player retreat destination is: nearest system you own, else nearest system with no hostile presence, else nearest system at all [verified]
|
// thiscall void (RetreatContext* this) // RETREAT PHASE 1. One loop over enc->members (stride 0x44, magic 0x78787879 / sar 5). Per member: FUN_00787210(&enc->+0x1c, enc->+0x0c, member->+0x00 /*ServerPlayer*/, &r1, &r2, &r3), then this->dest[player->PlyrIdx(+0x28)] = the FIRST NON-NULL of (r1, r2, r3) via std::map<int,T*>::operator[] 0x0076bce0. So the per-player retreat destination is: nearest system you own, else nearest system with no hostile presence, else nearest system at all [verified]
|
||||||
|
|
@ -1469,50 +1423,6 @@ constexpr uint32_t ShipDesign_off_Dtc = 0x00000134;
|
||||||
constexpr uint32_t ShipSectionDef_off_SectionClass = 0x00000260;
|
constexpr uint32_t ShipSectionDef_off_SectionClass = 0x00000260;
|
||||||
// offset unsigned int // low dword of the section's 64-bit role-flag word (high dword at +0x29c), one bit per boolean role key in the .shipsection file [verified]
|
// offset unsigned int // low dword of the section's 64-bit role-flag word (high dword at +0x29c), one bit per boolean role key in the .shipsection file [verified]
|
||||||
constexpr uint32_t ShipSectionDef_off_RoleFlagsLow = 0x00000298;
|
constexpr uint32_t ShipSectionDef_off_RoleFlagsLow = 0x00000298;
|
||||||
// thiscall double (ServerSystem* sys, int groupType) // `ret 4`, real end 0x0074d8e1. The income analogue of PopOutput 0x0074d8f0, and NOT the same law: it sums, over species 0..6, `(double)ftol( (double)GroupIncome(groupType, count) x moraleMod x addictionMod )` -- so the value truncates TWICE per (group, species) row, once inside GroupIncome and once after both factors. moraleMod is 0x00746910 (the same helper the output term uses) and applies to groupType 1 only; addictionMod is the float behind slot 0x00aeca48 when the system's int[7] addiction table at +0x1e4 has a non-zero entry for that species. For groupType 1 and the owner's own species on a non-independent system the count first gains the capacity surplus from two calls to 0x0074a6d0 (out slot 4, then out slot 6), max(0, B - A) [verified]
|
|
||||||
constexpr uint32_t ServerSystem_PopIncome = 0x0034d760;
|
|
||||||
// thiscall double (ServerSystem* sys) // plain `ret`, real end 0x0074b793. groupType 2 of the same loop as PopIncome, over SlaveCount(species) (0x0074b610): no morale factor and no capacity surplus, but the addiction factor still applies. Unexercised: slave counts are 0 on every call in the corpus [verified]
|
|
||||||
constexpr uint32_t ServerSystem_SlaveIncome = 0x0034b700;
|
|
||||||
// thiscall int64 (ServerSystem* sys, int species) // the slave-group population of one species; the group-2 counterpart of GroupPopulation 0x00747ba0 [unverified]
|
|
||||||
constexpr uint32_t ServerSystem_SlaveCount = 0x0034b610;
|
|
||||||
// thiscall void (ServerSystem* sys, int out[12]) // zeroes `out`, returns immediately when the caller's pointer is null or the system has no owner (+0x100), else calls ComputeOutputFromRates(out, &sys->Rts /*+0x88*/) -- the system's OWN rate sliders, not a max-mods vector. THE DISTINCTION THAT MATTERS: ComputeBudget's real (non-projected) per-system money is this function's out[3], while its projected mode and UpdateBankruptcyLimits use ComputeMaxIncome 0x007521c0. They are different numbers: this path funds the science, construction and terraform channels, so the repair pass runs and the unspent-industry and unspent-terraforming cascades into the money channel are live [verified]
|
|
||||||
constexpr uint32_t ServerSystem_ComputeOutput = 0x00351fb0;
|
|
||||||
// thiscall float (StrategyServer* srv, ServerPlayer* p) // `ret 4`, real end 0x0080f49f. Returns float32( float32(DifficultyMods_Select(p->diffMods /*+0x36c*/, p)[1]) x float32(srv->IncMod /*raw base +0xbc, the Sim block's `IncMod` tag*/) ), or just the server modifier when p is null. Every step is stored back through a 4-byte float. `ecx` here is the RAW StrategyServer base (ServerSystem+0x10), four bytes above the base the class's own methods get. THIS IS THE MISSING x1.1: at the difficulty level every corpus save carries (aidf == 1) the AI column of the table is 1.1f [verified]
|
|
||||||
constexpr uint32_t StrategyServer_IncomeDifficultyMod = 0x0040f470;
|
|
||||||
// thiscall float* (DifficultyMods* rec, ServerPlayer* p) // `ret 4`, real end 0x0059b4b3. Returns &rec->f[0] (the AI triple, at +0x04) when p is non-null AND p->[0xf9] (is-AI) is set AND p->NPC (+0xfb) is clear; otherwise &rec->f[3] (the non-AI triple, at +0x10). The three consumers read offset +0 (fleet maintenance divisor, ComputeBudget 0x0086338b), +4 (system and trade-route money, 0x0080f470 and 0x00833938) and +8 (research points bought with money, 0x0080e229 and 0x00863618) [verified]
|
|
||||||
constexpr uint32_t DifficultyMods_Select = 0x0019b490;
|
|
||||||
// cdecl void (int level, DifficultyMods* out) // real end 0x005a3a53 (Ghidra size 193 stops 2 bytes short). memcpy's the default {id 1, 1.0f x6} into `out` FIRST, then builds the table with BuildDifficultyTable 0x005a3870 and linear-searches it for id == level (stride 0x1c, from the 0x92492493 magic divide), copying the six floats on a hit. An out-of-range level therefore yields all ones rather than failing. Called from ServerPlayer::Read 0x008804d0 at 0x00880fa3, gated on 0 <= aidf < 3, which is also where ServerPlayer+0x368 (`aidf`) is stored [verified]
|
|
||||||
constexpr uint32_t LoadDifficultyRow = 0x001a3990;
|
|
||||||
// thiscall vector<DifficultyMods>* (vector<DifficultyMods>* out) // real end 0x005a3989. THE TABLE IS BUILT IN CODE, from .rdata float literals -- no data-file key, no GlobalConst slot, same shape as lane N's pop-type table. Three rows of {int id; float ai[3]; float other[3]} (0x1c): id 0 = ai {1,1,1} / other {1.5,1.5,1.5} (0x00a1b000); id 1 = ai {3.0 (0x00a0451c), 1.1 (0x009f957c), 1.5} / other {1,1,1}; id 2 = ai {1e6 (0x009ebd7c), 1.7 (0x009f9580), 2.0 (0x00a04518)} / other {1,1,1}. Read as: level 0 gives the break to the human player, levels 1 and 2 give it to the AI, and on level 2 the AI's fleet maintenance is divided by a million [verified]
|
|
||||||
constexpr uint32_t BuildDifficultyTable = 0x001a3870;
|
|
||||||
// thiscall float (ServerPlayer* p) // seven bytes: `fld DWORD [ecx+0x30c]; ret`. The save's per-player `IncMod` [verified]
|
|
||||||
constexpr uint32_t ServerPlayer_GetIncMod = 0x0040dd10;
|
|
||||||
// thiscall float (ServerPlayer* p) // `SpeciesDef(p->Species /*+0x5c*/)->+0x24`, the multiplier on the suitability MONEY cost (Zuul 0.7). A data-file value [verified]
|
|
||||||
constexpr uint32_t ServerPlayer_GetSpeciesCostFactor = 0x0040dd20;
|
|
||||||
// thiscall float (StrategyServer* srv, int species) // `ret 4`; one instruction of work: `fld DWORD [ecx + species*4 + 0xf8]`. The per-species ideal-suitability array on the RAW server base. IT IS ON THE WIRE: the Sim block's `ISsp`/`ISsu` pairs are this float[7] in species-index order, and the array is randomised per game by the map generator -- verified against every ServerPlayer's own `IdealSuit` field in all 11 corpus saves. CalcSuitMod reads THIS, not the player's field [verified]
|
|
||||||
constexpr uint32_t StrategyServer_IdealSuit = 0x0040f4b0;
|
|
||||||
// thiscall double (ServerSystem* sys) // real end 0x0074690d. Returns 0 with no owner, else float32(|IdealSuit(species) - sys->Suit|) / |owner->TerraMod (+0x134) x [0x00a1f928] / 20000|. The sign term (-1.0 when the planet's suitability is STRICTLY above the ideal) is multiplied in BEFORE the fabs at 0x00746906 and therefore cancels: the result is always >= 0. That is why the unspent-terraform cascade into the money channel is provably zero under the max-income rate vector [verified]
|
|
||||||
constexpr uint32_t ServerSystem_TerraformPointsNeeded = 0x00346890;
|
|
||||||
// thiscall void (ServerSystem* this, ServerPlayer* p, int encounterId) // 75 B, ret 8. THE NVE WRITER. if (!p) return; s = (int16)this->owner(+0x10)->Frame(+0x8); rec = NVE_map_at(&this->NVE(+0x284), &p->PlyrIdx(+0x28)); rec[0] = (s<<16)|s; rec[1] = encounterId. The map value is 8 bytes at node+0x10: an UNSERIALISED int16 touch stamp at +0, the saved ETS int16 at +2, the saved Eid int32 at +4 -- so ETS and the touch stamp are both set to the frame here, and only the writer at 0x007536a0 makes them differ. Sole caller is the tail's PlayerView-rebuild phase 0x007cf560, under the gate (AFlags >> PlyrIdx) & 1 [mapped]
|
|
||||||
constexpr uint32_t ServerSystem_RecordObservation = 0x00356300;
|
|
||||||
// thiscall void (ServerSystem* this, ServerPlayer* p, NveValue* src) // 70 B, ret 8. Intel sharing. rec = NVE_map_at(&this->NVE, &p->PlyrIdx(+0x28)); rec[0] = (hi16(src[0]) << 16) | (int16)this->owner(+0x10)->Frame(+0x8); rec[1] = src[1]. i.e. the receiver gets the DONOR's sighting turn (ETS) and encounter id unchanged, and only the unserialised touch stamp becomes the current frame. Never executed by any save in the corpus: no save has two players in an alliance [mapped]
|
|
||||||
constexpr uint32_t ServerSystem_CopyObservationTo = 0x003536a0;
|
|
||||||
// cdecl bool (ServerSystem* sys, ServerPlayer* from, ServerPlayer* to) // 96 B. Null-guards all three and from != to; a = FindObservation(from); b = FindObservation(to); if (a && (!b || b->ETS(+2) < a->ETS(+2))) { CopyObservationTo(to, a); return true; } return false. NEWER SIGHTING WINS, compared as a signed int16 [mapped]
|
|
||||||
constexpr uint32_t ServerSystem_ShareObservation = 0x00354d90;
|
|
||||||
// thiscall NveValue* (ServerSystem* this, ServerPlayer* p) // 58 B, ret 4. Map find on p->PlyrIdx(+0x28); returns node+0x10 (the 8-byte value) or null when the search ended at this->NVE head (+0x284) [mapped]
|
|
||||||
constexpr uint32_t ServerSystem_FindObservation = 0x0034d360;
|
|
||||||
// thiscall int (ServerSystem* this, ServerPlayer* p) // 64 B, ret 4. Same map find; returns node+0x14 (Eid) or -1. The extra `!= -0x10` guard is the null-node case reached through node+0x10 [mapped]
|
|
||||||
constexpr uint32_t ServerSystem_LastSeenEncounterId = 0x0034f830;
|
|
||||||
// thiscall bool (ServerSystem* this, ServerPlayer* p) // 34 B, ret 4. return ((1 << (p->PlyrIdx(+0x28) & 0x1f)) & this->AFlags(+0xd4)) != 0. THE gate on the observation record, on the explored sweep and on the PlayerView rebuild -- AFlags, the DERIVED non-sticky union, not VFlags. 19 callers [verified]
|
|
||||||
constexpr uint32_t ServerSystem_IsVisibleTo = 0x00343fb0;
|
|
||||||
// fastcall void (ServerSystem* this) // 41 B, WHOLE BODY. if (this->AFlags(+0xd4) != 0) this->ltis(+0x2c8) = this->owner(+0x10)->Frame(+0x8). This is `ltis`'s writer, which board.md recorded as unnamed; it is driver phase 29 (0x007dcbd6, per system). TShn's writer is still unnamed and is demonstrably a DIFFERENT rule: Spica in turn1-state has AFlags == 0 and its TShn moves anyway [mapped]
|
|
||||||
constexpr uint32_t ServerSystem_UpdateLastObservedTurn = 0x00343ec0;
|
|
||||||
// thiscall void (ServerSystem* this, ServerPlayer* p, bool wasSet, bool on) // 71 B, WHOLE BODY. Game::ServerSystem primary vftable 0x00a2044c SLOT 7 -- this RESOLVES the indirect edge lane B5 flagged in SetExploredBy's tail (vft[0x1c]). if (!wasSet && on && this->owner(+0x10)->Frame(+0x8) > 1) { FUN_00747a20(p->PlyrIdx(+0x28), 1); if (p->PlyrIdx < 0xf) this->+0x2a4 |= 1 << (PlyrIdx & 0x1f); } -- +0x2a4 sits past NVs and is NOT in the serialised table, so this edge writes no save state. Note the Frame > 1 guard: turn 1 is special-cased [mapped]
|
|
||||||
constexpr uint32_t ServerSystem_OnExploredChanged = 0x003480b0;
|
|
||||||
// cdecl bool (StarSystem* sys, int encounterId) // 129 B. Refuses unless the system is unowned (FUN_007437e0 == 0), has no planets, sys->+0x184 == -1 and two further tests pass; then sys->+0x184 = encounterId and ORs a mask from the encounter def into sys->+0x19c. sys->+0x184 is the field ServerSystem_RecordObservation copies into Eid. It is constructed to -1 (StarSystem ctor 0x00752ea0, member index 0x61) and IS NOT ON THE WIRE, so a reimplementation has to recover it from the encounter fleet's FtEnc -- which agrees on all six encounter fleets in the corpus and which no save can separate from the real field [mapped]
|
|
||||||
constexpr uint32_t StarSystem_PlaceEncounter = 0x003887c0;
|
|
||||||
// fastcall void (StrategyServer* S) // 948 B, tail phase 17. Four passes: (1) walk the per-(system, player) view tree at S+0x228 and drop entries whose player can no longer see the system, then clear the tree and reset S+0x22c = 0; (2) per player x per system, if IsVisibleTo, FUN_0075f550; (3) per player x per system, if IsVisibleTo, ServerSystem_RecordObservation(sys, player, sys->+0x184) -- byte-decoded at 0x007cf7a7..0x007cf7ce, this is the ONLY caller of the NVE writer outside intel sharing; (4) per system x per player, if IsExploredBy and a colony exists, build a PlayerView via 0x00755ab0/0x007561d0 and apply it. Draw-free [mapped]
|
|
||||||
constexpr uint32_t StrategyServer_RebuildPlayerViews = 0x003cf560;
|
|
||||||
// cdecl void (const char* gamename) /* GameSpy SDK gsAvailable. sprintf("%s.available.gamespy.com"), inet_addr/gethostbyname, UDP socket, sendto port 27900 (htons 0x6cfc) with '\x09\0\0\0\0' + gamename + NUL, len = strlen(gamename)+6. Overridable hostname buffer at 0x00b085b0. Leaves socket = -1 on DNS failure. */ [verified]
|
// cdecl void (const char* gamename) /* GameSpy SDK gsAvailable. sprintf("%s.available.gamespy.com"), inet_addr/gethostbyname, UDP socket, sendto port 27900 (htons 0x6cfc) with '\x09\0\0\0\0' + gamename + NUL, len = strlen(gamename)+6. Overridable hostname buffer at 0x00b085b0. Leaves socket = -1 on DNS failure. */ [verified]
|
||||||
constexpr uint32_t GameSpy_GSIStartAvailableCheck = 0x0000a060;
|
constexpr uint32_t GameSpy_GSIStartAvailableCheck = 0x0000a060;
|
||||||
// cdecl int (void) /* returns 0=waiting 1=available 2=unavailable 3=temporarily-unavailable. Socket==-1 (start failed) => returns 1. Retries once after 2000 ms then returns 1. */ [verified]
|
// cdecl int (void) /* returns 0=waiting 1=available 2=unavailable 3=temporarily-unavailable. Socket==-1 (start failed) => returns 1. Retries once after 2000 ms then returns 1. */ [verified]
|
||||||
|
|
|
||||||
|
|
@ -9,32 +9,22 @@ which is the per-system money output that blocks `ComputeBudget` and four other
|
||||||
This script goes the other way: it computes the same number from the colony state and
|
This script goes the other way: it computes the same number from the colony state and
|
||||||
compares, so the formula is falsified per player-record rather than per lane.
|
compares, so the formula is falsified per player-record rather than per lane.
|
||||||
|
|
||||||
The chain it implements (lane N `findings/subsystems/output-term.md` for the output half,
|
The chain it implements (lane N, findings/subsystems/output-term.md, live-verified on VM140):
|
||||||
lane E1 `findings/subsystems/income-term.md` for the money half, both read off the
|
|
||||||
instruction stream):
|
|
||||||
|
|
||||||
total = ComputeTotalOutput(SRoh = 0) # max mods -> trade rate 1, rest 0
|
total = ComputeTotalOutput(SRoh = 0) # max mods -> trade rate 1, rest 0
|
||||||
|
= ( overHarvestDemand x speciesResourceOutput
|
||||||
|
+ (TRes + resAvail) x stripMineFraction x 0.9
|
||||||
|
+ populationOutput )
|
||||||
|
x OutMod x sysOutMod x setupOutMod x RebOutMod x ScOutMod
|
||||||
trade = roundHalfEven(total)
|
trade = roundHalfEven(total)
|
||||||
money = trunc( TradePointsToMoney(trade) )
|
money = ftol( TradePointsToMoney(trade) )
|
||||||
maxIncome_s = max(money, 0)
|
|
||||||
|
|
||||||
with
|
Population output per head is `typeOutputMod x 1.8 / 500000`; population INCOME per head is
|
||||||
|
`typeIncomeModifier / 14000`, with no 1.8 -- two different laws off the same table.
|
||||||
|
|
||||||
TradePointsToMoney(trade) =
|
Everything the executable carries is hard-coded here as such. The two per-species fields the
|
||||||
diffMod * ( f32(IncMod) * ( f32(speciesIncomeFactor)
|
data files supply (SpeciesDef +0x4c base resource demand, +0x50 resource output factor) are
|
||||||
* ( Slaves + (PopIncome(1) + (((trade - trade mod 5) * 5 + 0) + PopIncome(0))) ) ) )
|
options, defaulting to the values measured live on VM140 for the species this corpus contains.
|
||||||
- f32(speciesCostFactor) * ( CalcSuitMod * 10000 * 1.5 )
|
|
||||||
|
|
||||||
diffMod = f32( f32(DifficultyMods(owner)[1]) * f32(server.IncMod) )
|
|
||||||
|
|
||||||
Population output per head is `typeOutputMod * 1.8 / 500000`; population INCOME per head is
|
|
||||||
`typeIncomeModifier / 14000`, with no 1.8 -- two different laws off two adjacent columns of the
|
|
||||||
same three-row table, and the income one truncates twice (once inside GroupIncome, once after
|
|
||||||
the morale/addiction product) PER SPECIES.
|
|
||||||
|
|
||||||
The difficulty table is built in code from .rdata float literals (0x005a3870); every corpus save
|
|
||||||
carries `aidf == 1`, whose AI income modifier is 1.1f. Which players count as AI is NOT on the
|
|
||||||
wire -- see --ai-rule.
|
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
import json
|
||||||
|
|
@ -49,37 +39,14 @@ import save_reader as sr # noqa: E402
|
||||||
# ---- constants the executable carries itself ------------------------------------------------
|
# ---- constants the executable carries itself ------------------------------------------------
|
||||||
OUT_FACTOR = struct.unpack("<d", bytes.fromhex("ccccccccccccfc3f"))[0] # 1.8, as the image holds it
|
OUT_FACTOR = struct.unpack("<d", bytes.fromhex("ccccccccccccfc3f"))[0] # 1.8, as the image holds it
|
||||||
OUT_DIVISOR = 500000.0
|
OUT_DIVISOR = 500000.0
|
||||||
INCOME_DIVISOR = 14000.0 # 0x009f8d60
|
INCOME_DIVISOR = 14000.0
|
||||||
RES_FACTOR = struct.unpack("<d", bytes.fromhex("cdccccccccccec3f"))[0] # 0.9
|
RES_FACTOR = struct.unpack("<d", bytes.fromhex("cdccccccccccec3f"))[0] # 0.9
|
||||||
BLOCK = 5.0 # 0x009e2398, used as BOTH the modulus and the multiplier
|
|
||||||
SUIT_COST_A = 10000.0 # 0x009e9398
|
|
||||||
SUIT_COST_B = 1.5 # 0x009e90b8
|
|
||||||
UNOWNED_SUIT_MOD = 20.0 # 0x009e2c08
|
|
||||||
POPTYPE_OUT = {0: 1.0, 1: struct.unpack("<f", struct.pack("<f", 0.33))[0]}
|
POPTYPE_OUT = {0: 1.0, 1: struct.unpack("<f", struct.pack("<f", 0.33))[0]}
|
||||||
POPTYPE_INC = {0: 1.0, 1: struct.unpack("<f", struct.pack("<f", 0.33))[0], 2: None}
|
POPTYPE_INC = {0: 1.0, 1: struct.unpack("<f", struct.pack("<f", 0.33))[0]}
|
||||||
|
|
||||||
# The difficulty table, built in code by 0x005a3870 from .rdata float literals.
|
# ---- values the data files supply, measured live on VM140 (2026-09-08) ----------------------
|
||||||
# row -> (aiMaintDiv, aiIncome, aiTrade, plMaintDiv, plIncome, plTrade)
|
SPECIES_BASE_DEMAND = 0 # SpeciesDef +0x4c
|
||||||
DIFFICULTY_TABLE = {
|
SPECIES_RES_OUTPUT = 10.0 # SpeciesDef +0x50
|
||||||
0: (1.0, 1.0, 1.0, 1.5, 1.5, 1.5),
|
|
||||||
1: (3.0, 1.100000023841858, 1.5, 1.0, 1.0, 1.0),
|
|
||||||
2: (1000000.0, 1.7000000476837158, 2.0, 1.0, 1.0, 1.0),
|
|
||||||
}
|
|
||||||
DIFFICULTY_DEFAULT = (1.0, 1.0, 1.0, 1.0, 1.0, 1.0) # LoadDifficultyRow's memcpy'd default
|
|
||||||
|
|
||||||
# ---- values the data files supply, measured live on VM140 (2026-09-08, lane N) ---------------
|
|
||||||
# SpeciesDef +0x4c (base resource demand) and +0x50 (resource output factor) are PER SPECIES and
|
|
||||||
# are read off the SYSTEM OWNER's species, not the system's population species. Taking them as
|
|
||||||
# one global pair is what cost lane N's predictor every Zuul record: a Zuul colony's harvest term
|
|
||||||
# is min(resAvail, 10) * 40 = 400 output points that a 0/10 pair scores as zero, and 400 points
|
|
||||||
# is 400/5 whole blocks worth 5 money each, i.e. exactly the 2000-per-colony shortfall observed.
|
|
||||||
SPECIES_BASE_DEMAND = {5: 10} # SpeciesDef +0x4c; 0 for Human and Tarkas
|
|
||||||
SPECIES_RES_OUTPUT = {5: 40.0} # SpeciesDef +0x50; 10 for Human and Tarkas
|
|
||||||
SPECIES_BASE_DEMAND_DEFAULT = 0
|
|
||||||
SPECIES_RES_OUTPUT_DEFAULT = 10.0
|
|
||||||
# SpeciesDef +0x18 income factor / +0x24 cost factor, per formula-gaps.md Q3 (NOT measured live).
|
|
||||||
SPECIES_INCOME_FACTOR = {5: 1.1, 6: 0.8}
|
|
||||||
SPECIES_COST_FACTOR = {5: 0.7}
|
|
||||||
|
|
||||||
|
|
||||||
def f32(v):
|
def f32(v):
|
||||||
|
|
@ -87,7 +54,6 @@ def f32(v):
|
||||||
|
|
||||||
|
|
||||||
def ftol(v):
|
def ftol(v):
|
||||||
"""_ftol2 -- truncation toward zero."""
|
|
||||||
return math.trunc(v)
|
return math.trunc(v)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -137,15 +103,14 @@ def group_output(group, count, morale, stations, tuning, owned, independent):
|
||||||
b = tuning["STATION_BONUS_IMPERIAL_OUTPUT"]
|
b = tuning["STATION_BONUS_IMPERIAL_OUTPUT"]
|
||||||
sf = 1.0 + stations * (b if b > 0 else 0.0)
|
sf = 1.0 + stations * (b if b > 0 else 0.0)
|
||||||
mo = 1.0
|
mo = 1.0
|
||||||
if group == 1:
|
if group == 1 and owned and not independent:
|
||||||
mo = morale_output_mod(morale, tuning, owned, independent)
|
mo = morale_output_mod(morale, tuning)
|
||||||
v = POPTYPE_OUT[group] * (sf * OUT_FACTOR) * mo * q
|
v = POPTYPE_OUT[group] * (sf * OUT_FACTOR) * mo * q
|
||||||
return v if v > 0 else 0.0
|
return v if v > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
def morale_output_mod(m, tuning, owned, independent):
|
def morale_output_mod(m, tuning):
|
||||||
"""0x00746910 -- 1.0 with no owner, on an independent system, or with cm == 0."""
|
if m == 0:
|
||||||
if not owned or independent or m == 0:
|
|
||||||
return 1.0
|
return 1.0
|
||||||
if m >= tuning["MORALE_INCREASE_OUTPUT"]:
|
if m >= tuning["MORALE_INCREASE_OUTPUT"]:
|
||||||
x = tuning["MORALE_INCREASE_OUTPUT_MOD"]
|
x = tuning["MORALE_INCREASE_OUTPUT_MOD"]
|
||||||
|
|
@ -164,8 +129,6 @@ LIVE_TUNING = {
|
||||||
"MORALE_DECREASE_OUTPUT": 20,
|
"MORALE_DECREASE_OUTPUT": 20,
|
||||||
"MORALE_DECREASE_OUTPUT_MOD": f32(0.5),
|
"MORALE_DECREASE_OUTPUT_MOD": f32(0.5),
|
||||||
"SLAVES_OUTPUT_MOD": f32(3.0),
|
"SLAVES_OUTPUT_MOD": f32(3.0),
|
||||||
"SLAVES_INCOME_MOD": f32(3.0),
|
|
||||||
"ADDICTION_INCOME_MOD": f32(0.9),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -206,43 +169,32 @@ def population(node, name, group, species):
|
||||||
return total
|
return total
|
||||||
|
|
||||||
|
|
||||||
def sparse_table(node, name, key_tag, val_tag):
|
def morale_table(node):
|
||||||
"""`cm`/`nadct` on the wire: a count then (index, value) pairs."""
|
"""`cm` on the wire is a count then (species, value) pairs."""
|
||||||
|
cm = sub(node, "cm")
|
||||||
out = {}
|
out = {}
|
||||||
holder = sub(node, name) if name else node
|
if cm is None:
|
||||||
if holder is None:
|
|
||||||
return out
|
return out
|
||||||
pending = None
|
pending = None
|
||||||
for c in kids(holder):
|
for c in kids(cm):
|
||||||
if c.name == key_tag:
|
if c.name == "msp":
|
||||||
pending = c.value
|
pending = c.value
|
||||||
elif c.name == val_tag and pending is not None:
|
elif c.name == "mv" and pending is not None:
|
||||||
out[pending] = c.value
|
out[pending] = c.value
|
||||||
pending = None
|
pending = None
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def morale_table(node):
|
|
||||||
return sparse_table(node, "cm", "msp", "mv")
|
|
||||||
|
|
||||||
|
|
||||||
def addiction_table(node):
|
|
||||||
"""The int[7] at ServerSystem+0x1e4: `nadct` then sparse (`ads`,`adt`) pairs."""
|
|
||||||
return sparse_table(node, None, "ads", "adt")
|
|
||||||
|
|
||||||
|
|
||||||
def read_state(path):
|
def read_state(path):
|
||||||
r = sr.read_save(path)
|
r = sr.read_save(path)
|
||||||
systems, players, sim = [], [], None
|
systems, players = [], []
|
||||||
for _, n in walk(r.tree):
|
for _, n in walk(r.tree):
|
||||||
if n.name == "Sim" and sim is None:
|
|
||||||
sim = n
|
|
||||||
names = {c.name for c in kids(n)}
|
names = {c.name for c in kids(n)}
|
||||||
if {"Pop", "Rts", "Infra", "pbon"} <= names:
|
if {"Pop", "Rts", "Infra", "pbon"} <= names:
|
||||||
systems.append(n)
|
systems.append(n)
|
||||||
elif {"OutMod", "IncMod", "ScOutMod", "BnkEl", "PlyrIdx"} <= names:
|
elif {"OutMod", "IncMod", "ScOutMod", "BnkEl", "PlyrIdx"} <= names:
|
||||||
players.append(n)
|
players.append(n)
|
||||||
return systems, players, sim
|
return systems, players
|
||||||
|
|
||||||
|
|
||||||
def system_species(node, owner_species):
|
def system_species(node, owner_species):
|
||||||
|
|
@ -266,123 +218,7 @@ def is_independent(node):
|
||||||
return bool(field(node, "hindi"))
|
return bool(field(node, "hindi"))
|
||||||
|
|
||||||
|
|
||||||
# ---- the income chain ------------------------------------------------------------------------
|
def predict(save, base_demand, res_output, verbose=False):
|
||||||
def group_income(t, count, tuning):
|
|
||||||
"""0x00535e80: _ftol2( f32(POPTYPE[t].income) * (count / 14000) )."""
|
|
||||||
inc = POPTYPE_INC[t]
|
|
||||||
if inc is None:
|
|
||||||
inc = tuning["SLAVES_INCOME_MOD"]
|
|
||||||
return ftol(f32(inc) * (float(count) / INCOME_DIVISOR))
|
|
||||||
|
|
||||||
|
|
||||||
def pop_income(t, counts, morale, addicted, tuning, owned, independent):
|
|
||||||
"""0x0074d760 / 0x0074b700. `counts` is species -> population for this group type."""
|
|
||||||
total = 0.0
|
|
||||||
for sp in range(7):
|
|
||||||
n = counts.get(sp, 0)
|
|
||||||
if not n > 0:
|
|
||||||
continue
|
|
||||||
mo = morale_output_mod(morale.get(sp, 0), tuning, owned, independent) if t == 1 else 1.0
|
|
||||||
ad = tuning["ADDICTION_INCOME_MOD"] if addicted.get(sp) else 1.0
|
|
||||||
r = group_income(t, n, tuning)
|
|
||||||
total += float(ftol(float(r) * mo * ad))
|
|
||||||
return total
|
|
||||||
|
|
||||||
|
|
||||||
def calc_suit_mod(node, p, species, ideal_suit):
|
|
||||||
"""0x007484d0."""
|
|
||||||
if field(node, "vnh"):
|
|
||||||
return 0.0
|
|
||||||
if not field(node, "PID"):
|
|
||||||
return UNOWNED_SUIT_MOD
|
|
||||||
if field(p, "RebAI"):
|
|
||||||
return 0.0
|
|
||||||
ideal = ideal_suit.get(species)
|
|
||||||
if ideal is None:
|
|
||||||
raise KeyError("no IdealSuit known for species %r" % (species,))
|
|
||||||
tol = f32(field(p, "SuitTol") or 0.0)
|
|
||||||
d = abs(f32(ideal) - f32(field(node, "Suit") or 0.0))
|
|
||||||
return d if d <= tol else tol
|
|
||||||
|
|
||||||
|
|
||||||
def difficulty_income_mod(p, is_ai, server_inc_mod):
|
|
||||||
"""0x0080f470 + 0x0059b490 + 0x005a3990: float32 throughout."""
|
|
||||||
m = f32(server_inc_mod)
|
|
||||||
if p is None:
|
|
||||||
return m
|
|
||||||
level = field(p, "aidf")
|
|
||||||
row = DIFFICULTY_TABLE.get(level, DIFFICULTY_DEFAULT)
|
|
||||||
# Select(): the AI triple is f[0..2], the non-AI triple f[3..5]; ->+4 is index 1 of the triple.
|
|
||||||
mods = row[0:3] if (is_ai and not field(p, "NPC")) else row[3:6]
|
|
||||||
return f32(f32(mods[1]) * m)
|
|
||||||
|
|
||||||
|
|
||||||
def system_income(s, p, base_demand, res_output, ideal_suit, server_inc_mod, is_ai, tuning):
|
|
||||||
owner_species = field(p, "Species")
|
|
||||||
if base_demand is None:
|
|
||||||
base_demand = SPECIES_BASE_DEMAND.get(owner_species, SPECIES_BASE_DEMAND_DEFAULT)
|
|
||||||
if res_output is None:
|
|
||||||
res_output = SPECIES_RES_OUTPUT.get(owner_species, SPECIES_RES_OUTPUT_DEFAULT)
|
|
||||||
sp = system_species(s, owner_species)
|
|
||||||
strip = bool(field(p, "AMine"))
|
|
||||||
independent = is_independent(s)
|
|
||||||
|
|
||||||
res = field(s, "Res") or 0
|
|
||||||
avail = res + ((field(s, "MRes") or 0) + (field(s, "ARes2") or 0) if strip else 0)
|
|
||||||
pop = (field(s, "Pop") or 0) + (field(s, "pbon") or 0)
|
|
||||||
morale = morale_table(s)
|
|
||||||
addicted = addiction_table(s)
|
|
||||||
|
|
||||||
# ---- the output half (lane N, live-verified) --------------------------------------------
|
|
||||||
if field(s, "rbfl"):
|
|
||||||
total = 0.0
|
|
||||||
else:
|
|
||||||
civ_own = population(s, "Pop2", 1, sp) + population(s, "pbon2", 1, sp)
|
|
||||||
harvest = over_harvest_demand(0.0, avail, pop, base_demand) * f32(res_output)
|
|
||||||
resource = (float((field(s, "TRes") or 0) + avail)
|
|
||||||
* float(strip_mine_fraction(pop, field(s, "Infra") or 0.0,
|
|
||||||
field(s, "ibon") or 0.0))
|
|
||||||
* RES_FACTOR)
|
|
||||||
imperial = group_output(0, pop, 0, 0, tuning, True, independent)
|
|
||||||
civilian = group_output(1, civ_own, morale.get(sp, 0), 0, tuning, True, independent)
|
|
||||||
total = (civilian + (imperial + 0.0)) + (harvest + resource)
|
|
||||||
total *= f32(field(p, "OutMod") or 1.0)
|
|
||||||
total *= f32(field(s, "OutMod") or 1.0)
|
|
||||||
total *= f32(field(p, "RebOutMod") or 1.0)
|
|
||||||
total *= f32(field(p, "ScOutMod") or 1.0)
|
|
||||||
|
|
||||||
# ---- the money half (lane E1) -------------------------------------------------------------
|
|
||||||
trade = round_half_even(total)
|
|
||||||
|
|
||||||
imperial_counts = {sp: pop} if pop > 0 else {}
|
|
||||||
civ_counts = {}
|
|
||||||
for q in range(7):
|
|
||||||
c = population(s, "Pop2", 1, q) + population(s, "pbon2", 1, q)
|
|
||||||
if c:
|
|
||||||
civ_counts[q] = c
|
|
||||||
|
|
||||||
t = pop_income(0, imperial_counts, morale, addicted, tuning, True, independent)
|
|
||||||
t = ((trade - math.fmod(trade, BLOCK)) * BLOCK + 0.0) + t
|
|
||||||
t = pop_income(1, civ_counts, morale, addicted, tuning, True, independent) + t
|
|
||||||
t = 0.0 + t # SlaveIncome(): no slaves in this corpus
|
|
||||||
|
|
||||||
t = f32(SPECIES_INCOME_FACTOR.get(owner_species, 1.0)) * t
|
|
||||||
t = difficulty_income_mod(p, is_ai, server_inc_mod) * (f32(field(p, "IncMod") or 1.0) * t)
|
|
||||||
|
|
||||||
cost = f32(SPECIES_COST_FACTOR.get(owner_species, 1.0)) * (
|
|
||||||
calc_suit_mod(s, p, sp, ideal_suit) * SUIT_COST_A * SUIT_COST_B)
|
|
||||||
return ftol(t - cost)
|
|
||||||
|
|
||||||
|
|
||||||
AI_RULES = {
|
|
||||||
# `ServerPlayer+0xf9` is a setup input, not a save field (income-term.md §3.1).
|
|
||||||
"non-npc-not-first": lambda p, i: not field(p, "NPC") and (field(p, "PlyrIdx") or 0) != 0,
|
|
||||||
"none": lambda p, i: False,
|
|
||||||
"all-non-npc": lambda p, i: not field(p, "NPC"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def predict(save, base_demand, res_output, ai_rule, verbose=False):
|
|
||||||
"""Return {BnkEl: (predicted maxIncome, per-system detail)}.
|
"""Return {BnkEl: (predicted maxIncome, per-system detail)}.
|
||||||
|
|
||||||
The join key is `BnkEl` rather than any id: a system stores its owner as a HANDLE id
|
The join key is `BnkEl` rather than any id: a system stores its owner as a HANDLE id
|
||||||
|
|
@ -390,30 +226,7 @@ def predict(save, base_demand, res_output, ai_rule, verbose=False):
|
||||||
schemes. The oracle inverts the same `BnkEl` the player node carries, so keying on it
|
schemes. The oracle inverts the same `BnkEl` the player node carries, so keying on it
|
||||||
needs no id mapping at all and cannot silently pair the wrong two records.
|
needs no id mapping at all and cannot silently pair the wrong two records.
|
||||||
"""
|
"""
|
||||||
systems, players, sim = read_state(save)
|
systems, players = read_state(save)
|
||||||
server_inc_mod = field(sim, "IncMod", 1.0) if sim is not None else 1.0
|
|
||||||
|
|
||||||
# `server->IdealSuit[species]` IS on the wire: the Sim block's `ISsp`/`ISsu` pairs are that
|
|
||||||
# float[7], in species-index order, and they are randomised per game by the map generator.
|
|
||||||
# Each ServerPlayer's own `IdealSuit` field carries the same value for its species, so the
|
|
||||||
# two are cross-checked here rather than one being trusted blindly (rule 8: two checks that
|
|
||||||
# share a hidden assumption are one check -- these two do not share a source).
|
|
||||||
ideal_suit = {}
|
|
||||||
if sim is not None:
|
|
||||||
idx = 0
|
|
||||||
for c in kids(sim):
|
|
||||||
if c.name == "ISsu":
|
|
||||||
ideal_suit[idx] = c.value
|
|
||||||
idx += 1
|
|
||||||
for p in players:
|
|
||||||
s, v = field(p, "Species"), field(p, "IdealSuit")
|
|
||||||
if s is None or v is None:
|
|
||||||
continue
|
|
||||||
if s in ideal_suit and ideal_suit[s] != v:
|
|
||||||
print(" WARNING: species %d ISsu %r disagrees with a player's IdealSuit %r"
|
|
||||||
% (s, ideal_suit[s], v), file=sys.stderr)
|
|
||||||
ideal_suit.setdefault(s, v)
|
|
||||||
|
|
||||||
# A player's handle id is not a named field, but every system names its owner's, so the
|
# A player's handle id is not a named field, but every system names its owner's, so the
|
||||||
# set of distinct non-zero `PID` values is the set of owning players -- in the same order
|
# set of distinct non-zero `PID` values is the set of owning players -- in the same order
|
||||||
# the player records appear. Pair them by position among the players that own anything.
|
# the player records appear. Pair them by position among the players that own anything.
|
||||||
|
|
@ -431,39 +244,69 @@ def predict(save, base_demand, res_output, ai_rule, verbose=False):
|
||||||
handle_of[id(p)] = h
|
handle_of[id(p)] = h
|
||||||
|
|
||||||
out = {}
|
out = {}
|
||||||
for i, p in enumerate(players):
|
for p in players:
|
||||||
h = handle_of.get(id(p))
|
h = handle_of.get(id(p))
|
||||||
is_ai = AI_RULES[ai_rule](p, i)
|
|
||||||
total = 0
|
total = 0
|
||||||
detail = []
|
detail = []
|
||||||
if h is not None:
|
if h is not None:
|
||||||
for s in systems:
|
for s in systems:
|
||||||
if field(s, "PID") != h:
|
if field(s, "PID") != h:
|
||||||
continue
|
continue
|
||||||
m = system_income(s, p, base_demand, res_output, ideal_suit,
|
m = system_income(s, p, base_demand, res_output)
|
||||||
server_inc_mod, is_ai, LIVE_TUNING)
|
|
||||||
detail.append((field(s, "Idx"), field(s, "Name"), m))
|
detail.append((field(s, "Idx"), field(s, "Name"), m))
|
||||||
total += max(m, 0)
|
total += max(m, 0)
|
||||||
out[field(p, "BnkEl")] = (total, detail)
|
out[field(p, "BnkEl")] = (total, detail)
|
||||||
if verbose and detail:
|
if verbose and detail:
|
||||||
print(" player handle %s sp=%s ai=%s npc=%s (%d system(s))"
|
print(" player handle %s (%d system(s))" % (h, len(detail)))
|
||||||
% (h, field(p, "Species"), is_ai, field(p, "NPC"), len(detail)))
|
|
||||||
for sid, nm, m in detail:
|
for sid, nm, m in detail:
|
||||||
print(" sys %-4s %-16s money=%d" % (sid, nm, m))
|
print(" sys %-4s %-16s money=%d" % (sid, nm, m))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def system_income(s, p, base_demand, res_output):
|
||||||
|
rts = sub(s, "Rts")
|
||||||
|
owner_species = field(p, "Species")
|
||||||
|
sp = system_species(s, owner_species)
|
||||||
|
strip = bool(field(p, "AMine"))
|
||||||
|
|
||||||
|
res = field(s, "Res") or 0
|
||||||
|
avail = res + ((field(s, "MRes") or 0) + (field(s, "ARes2") or 0) if strip else 0)
|
||||||
|
pop = (field(s, "Pop") or 0) + (field(s, "pbon") or 0)
|
||||||
|
civ = population(s, "Pop2", 1, sp) + population(s, "pbon2", 1, sp)
|
||||||
|
morale = morale_table(s).get(sp, 0)
|
||||||
|
independent = is_independent(s)
|
||||||
|
|
||||||
|
harvest = over_harvest_demand(0.0, avail, pop, base_demand) * f32(res_output)
|
||||||
|
resource = (float((field(s, "TRes") or 0) + avail)
|
||||||
|
* float(strip_mine_fraction(pop, field(s, "Infra") or 0.0, field(s, "ibon") or 0.0))
|
||||||
|
* RES_FACTOR)
|
||||||
|
imperial = group_output(0, pop, 0, 0, LIVE_TUNING, True, independent)
|
||||||
|
civilian = group_output(1, civ, morale, 0, LIVE_TUNING, True, independent)
|
||||||
|
base = (civilian + (imperial + 0.0)) + (harvest + resource)
|
||||||
|
|
||||||
|
if field(s, "rbfl"):
|
||||||
|
return 0
|
||||||
|
total = base
|
||||||
|
total *= f32(field(p, "OutMod") or 1.0)
|
||||||
|
total *= f32(field(s, "OutMod") or 1.0)
|
||||||
|
total *= f32(field(p, "RebOutMod") or 1.0)
|
||||||
|
total *= f32(field(p, "ScOutMod") or 1.0)
|
||||||
|
|
||||||
|
trade = round_half_even(total)
|
||||||
|
t = (trade - math.fmod(trade, 5.0)) * 5.0
|
||||||
|
t += ftol(POPTYPE_INC[0] * (float(pop) / INCOME_DIVISOR))
|
||||||
|
t += ftol(POPTYPE_INC[1] * (float(civ) / INCOME_DIVISOR))
|
||||||
|
t *= f32(field(p, "IncMod") or 1.0)
|
||||||
|
return ftol(t)
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
ap = argparse.ArgumentParser(description=__doc__,
|
ap = argparse.ArgumentParser(description=__doc__,
|
||||||
formatter_class=argparse.RawDescriptionHelpFormatter)
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
ap.add_argument("saves", nargs="*")
|
ap.add_argument("saves", nargs="*")
|
||||||
ap.add_argument("--oracle", help="JSON from max_income_oracle.py --json")
|
ap.add_argument("--oracle", help="JSON from max_income_oracle.py --json")
|
||||||
ap.add_argument("--base-demand", type=int, default=None,
|
ap.add_argument("--base-demand", type=int, default=SPECIES_BASE_DEMAND)
|
||||||
help="override SpeciesDef +0x4c for every species")
|
ap.add_argument("--res-output", type=float, default=SPECIES_RES_OUTPUT)
|
||||||
ap.add_argument("--res-output", type=float, default=None,
|
|
||||||
help="override SpeciesDef +0x50 for every species")
|
|
||||||
ap.add_argument("--ai-rule", choices=sorted(AI_RULES), default="non-npc-not-first",
|
|
||||||
help="which players count as AI (ServerPlayer+0xf9 is not on the wire)")
|
|
||||||
ap.add_argument("-v", "--verbose", action="store_true")
|
ap.add_argument("-v", "--verbose", action="store_true")
|
||||||
a = ap.parse_args()
|
a = ap.parse_args()
|
||||||
|
|
||||||
|
|
@ -471,21 +314,15 @@ def main():
|
||||||
if a.oracle:
|
if a.oracle:
|
||||||
with open(a.oracle) as fh:
|
with open(a.oracle) as fh:
|
||||||
raw = json.load(fh)
|
raw = json.load(fh)
|
||||||
if isinstance(raw, dict) and raw and isinstance(next(iter(raw.values())), list):
|
|
||||||
for name, rows in raw.items():
|
|
||||||
for row in rows:
|
|
||||||
oracle[(os.path.basename(name), row.get("BnkEl"))] = row.get("maxIncome")
|
|
||||||
else:
|
|
||||||
for row in (raw if isinstance(raw, list) else raw.get("records", [])):
|
for row in (raw if isinstance(raw, list) else raw.get("records", [])):
|
||||||
oracle[(os.path.basename(row.get("save", "")), row.get("BnkEl"))] = \
|
oracle[(os.path.basename(row.get("save", "")), row.get("BnkEl"))] = row.get("maxIncome")
|
||||||
row.get("maxIncome")
|
|
||||||
|
|
||||||
hits = misses = unknown = 0
|
hits = misses = unknown = 0
|
||||||
for save in a.saves:
|
for save in a.saves:
|
||||||
name = os.path.basename(save)
|
name = os.path.basename(save)
|
||||||
print("==", name)
|
print("==", name)
|
||||||
for bnkel, (total, detail) in sorted(predict(save, a.base_demand, a.res_output,
|
for bnkel, (total, detail) in sorted(predict(save, a.base_demand, a.res_output,
|
||||||
a.ai_rule, a.verbose).items()):
|
a.verbose).items()):
|
||||||
if not detail:
|
if not detail:
|
||||||
continue
|
continue
|
||||||
want = oracle.get((name, bnkel))
|
want = oracle.get((name, bnkel))
|
||||||
|
|
@ -497,7 +334,7 @@ def main():
|
||||||
print(" BnkEl=%-12s predicted=%-12d MATCH" % (bnkel, total))
|
print(" BnkEl=%-12s predicted=%-12d MATCH" % (bnkel, total))
|
||||||
else:
|
else:
|
||||||
misses += 1
|
misses += 1
|
||||||
print(" BnkEl=%-12s predicted=%-12d oracle=%-12d delta=%+d (%.6f x)"
|
print(" BnkEl=%-12s predicted=%-12d oracle=%-12d delta=%+d (%.4f x)"
|
||||||
% (bnkel, total, want, total - want,
|
% (bnkel, total, want, total - want,
|
||||||
(total / want) if want else float("nan")))
|
(total / want) if want else float("nan")))
|
||||||
print("\n%d match, %d differ, %d with no oracle record" % (hits, misses, unknown))
|
print("\n%d match, %d differ, %d with no oracle record" % (hits, misses, unknown))
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue