sots-re/findings/subsystems/income-term.md
alex 3b99e255c7 lane C3: ComputeOutput on the turn path -- the money ComputeBudget actually sums
`ServerSystem::ComputeOutput` 0x00751fb0 and the whole of `ComputeOutputFromRates`
0x00751bb0, read from the instruction stream to the next function start. This is the
function lane E1 correctly identified as the real blocker on P01/P02/P03/P05/P06 after
its own chain came out 25/25 on BnkEl and did not unblock them.

The shape, and the fact everything turns on: construction points the build queue and
the ship-repair pass do not spend are redistributed over trade / terraform /
infrastructure, and the TRADE share is added to the money channel. Unspent
infrastructure points cascade into the terraform pool and unspent terraform points
cascade into money -- two hops, not one. So a colony with an empty build queue earns
the same money whichever way its sliders point, which is why the turn path's money
sits within one trade point of the projected path on this corpus.

Eleven addresses, five of them dropped as agreeing duplicates. New: the repair pass
0x00751590 (the B1 double-run side effect, and its round robin is provably a `min`),
the construction-point helper 0x00746830, the queue-demand walker 0x008251e0, the
ideal-suitability resolver 0x00745d60, and two helpers with non-standard conventions
that would be silently wrong read as thiscall -- 0x0074c6f0 takes `this` in ESI and
0x007460b0 takes the system in EBX.

Corrections in place: `income-term.md`'s "leftover science points" are leftover SHIP
CONSTRUCTION points (there is no science channel in this function), its
science-cascade bullet moves from inferred to instruction-verified, and
`output-term.md`'s 0x00751fa8 boundary is padding -- the `ret 8` is at 0x00751fa0.

Measured, with the engine change (sots-engine wip/output2 d3ee453):

    turn1-state -> turn2-state    209 -> 157   closed 52  regressed 0   (was 51 / 0)
    turn2-state -> turn3-state    108 ->  86   closed 22  regressed 0   (was 21 / 0)

Closed 1 per pair, regressed 0. Every prediction in the doc's section 4 held; the one
that was off is named and explained. The strongest check is not the leaf count but
the eleven-save self-check in section 5.3, where every difference between the turn
path and the projected path decomposes to the unit into the build queue's points.
2026-09-08 14:50:50 -04:00

26 KiB
Raw Blame History

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] = the trade share of the leftover ship-construction points, non-zero only when the build queue and the repair pass left some over;
  • [ebp-0x18] = leftover terraforming points, itself fed by leftover industry points.

Correction (lane C3). This section originally called [ebp-0x28] "leftover science points". There is no science channel in this function: OutputRates+4 is the ship-construction slider SRsc, 0x00746830 turns it into construction points, and the leftover is what the build queue (0x008251e0) and the ship-repair pass (0x00751590) did not spend. Research is bought with money at the empire level, not produced per system. strategic-turn-internals.md §3.3 had this right. See output-turn-path.md.

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.)
  • Construction: out[7] = 0x00746830(sys, 0.0); with zero construction points nothing is left over and [ebp-0x28] keeps the fldz written at 0x00751d5f. Lane C3 upgraded this from inferred to instruction-verified: 0x00746830 with an argument of 0 returns ftol(k x (b x 0) + 0) = 0 on both its branches, so the guard really does fail.

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 by THIS lane (closed by lane C3)

Update, lane C3 (same day). Everything this section says is right, and the turn path is now read and modelled: see output-turn-path.md. The one thing it did not anticipate is that the leftover construction points come back to the trade channel, so on a corpus where every build queue is empty the two paths give the same money to within one trade point -- which makes the BnkEl oracle an indirect check on the turn path after all. P01/P02 are committed and closed one leaf on each reference pair.

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 leftover construction 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.