sots-re/findings/subsystems/output-term.md
alex 3c48d3a39e lane N: the population -> base-output term, read and live-verified
findings/subsystems/output-term.md is the whole reading: the call chain with
real function boundaries, the formula, the closed list of nine values the data
files supply (down from an unbounded fear), the advance prediction with its
falsification table, and the live result.

Corrects strategic-turn-internals.md 3.3 in place. That block had the SHAPE
wrong, not just the detail: a system's output is a sum of three terms, and the
function it named as the population base-output term is the over-harvest
resource demand.

Live on VM140, two builds, two species, both hooks in compare mode:
GroupOutput 13,105 calls / 0 divergences, ComputeTotalOutput 11,252 / 1 (one
ulp), 0 undeclared writes in 24,357 guarded calls. Thirteen distinct system
states, and every unexercised branch is listed rather than counted as covered.

tools/max_income_predict.py is the other half: it computes lane Y's
bankruptcy-limit oracle from colony state and reports 6 of 25 player-records
matching exactly, with the misses all AI-owned and the single-system ones short
by exactly the 1.1 difficulty income multiplier.
2026-09-08 12:11:52 -04:00

23 KiB
Raw Blame History

The population → base-output term (lane N, 2026-09-08)

The formula that blocks P01 P02 P03 P05 P06 in src/app. Read from the instruction stream, then hooked live on VM140.

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.


0. Correction to strategic-turn-internals.md §3.3

That section says:

total = round( BaseOutput × mods )        // 0x00750480:
        BaseOutput from pop (0x007483b0: (pbon+Pop)/… resource-availability factor,
                             capped by species +0x4c)

0x007483b0 is not the population→output term. It is the strip-mining / over-harvest resource demand, its P is Res (+0x68) and not Pop, and species +0x4c is added, not a cap — the cap is the available resource stock. (pbon+Pop) enters it only as a clamp01((pbon+Pop)×1e-5) scale factor, and only when the over-harvest rate SRoh > 0. The line is corrected in place below and in strategic-turn-internals.md.

The real population→output term is PopOutput(0) + PopOutput(1) + SlaveOutput() inside 0x00750480, and it is far simpler than the note implied: output points are linear in population, pop × typeMod × 1.8 / 500000.


1. The call chain

addr name conv / boundary note
0x00751bb0 ServerSystem::ComputeOutputFromRates(int out[12], OutputRates* rates) thiscall, ends 0x00751fa8 repairs ships in orbit — not compare-safe
0x00750480 ServerSystem::ComputeTotalOutput(double SRoh) thiscall ret 8, ends 0x007505a5 side-effect free — the compare target
0x00747d30 ServerSystem::StripMineFraction() thiscall, returns float, ends 0x00747ddd
0x007483b0 ServerSystem::OverHarvestDemand(double SRoh) thiscall ret 8, ends 0x007484c4 (Ghidra size 276 = correct here)
0x0074d8f0 ServerSystem::PopOutput(int groupType) thiscall ret 4, ends 0x0074da01
0x0074b880 ServerSystem::SlaveOutput() thiscall, ends 0x0074b901
0x0074b7a0 ServerSystem::GroupOutput(int groupType, int species, double count) thiscall ret 0x10, ends 0x0074b871 the per-capita term
0x00747ba0 ServerSystem::GroupPopulation(int groupType, int species) → int64 thiscall ret 8
0x00746910 ServerSystem::MoraleOutputMod(int species) → double thiscall ret 4
0x00535e00 PopTypeRow(int t) cdecl table at .bss 0x00b104e8, stride 0x30, 3 rows
0x00535ca0 InitPopTypeTable() — the table is built in code, see §3
0x00545cc0 SpeciesDef(int sp) cdecl table at .bss 0x00b10a00, stride 0x184, 7 rows
0x008e5680 signed_cbrt(double) cdecl x>=0 ? pow(x,1/3) : -pow(-x,1/3); exponent is the double 1/3 at 0x00a3a7c8

ComputeOutputFromRates is reached from ComputeOutput 0x00751fb0 (projected) and ComputeMaxIncome 0x007521c0 (max), which are what ComputeBudget 0x00863030 and UpdateBankruptcyLimits 0x00818600 call.


2. The formula

2.1 ComputeTotalOutput(SRoh) — 0x00750480

SRoh is the fifth float of the normalised OutputRates ({SRt, SRsc, SRtf, SRi, SRoh, SRs, int SRnr}, 0x1c), read at [ebp-0x4c] after NormaliseOutputRates. It is the over-harvest rate.

if (sys->PID == 0)  return 0.0                             // +0x100
if (sys->rbfl != 0) return 0.0                             // +0x1dc, any rebelling species

resAvail = sys->Res                                        // +0x68
if (PID && PID->[0x138]) resAvail += sys->MRes + sys->ARes2   // +0x70 + +0x6c   (the AMine flag)

resTerm  = (double)(sys->TRes + resAvail)                   // +0x74
           * (double)StripMineFraction()                    // float, narrowed
           * 0.9                                            // .rdata 0x00a05d48

baseTerm = OverHarvestDemand(SRoh) * (double)(float)SpeciesDef(PID->Species)->f50   // +0x50

popTerm  = PopOutput(0) + 0.0                               // .rdata 0x009e1e68 == 0.0
         + PopOutput(1)
         + SlaveOutput()

sum      = baseTerm + resTerm + popTerm                     // in that association:
                                                            //   ((y + x1 + (x0 + 0.0)) + (baseTerm + resTerm))

mod      = (AddictionPhase(sys, sp) >= 3) ? ADDICTION_OUTPUT_MOD : 1.0
           sp = PID ? (sys->indi ? sys->indi->[4] : PID->Species) : -1

sum *= PID->[0x124]      // p.OutMod
sum *= sys->OutMod       // +0x7c
sum *= PID->[0x224]      // the game-setup handicap output multiplier
sum *= PID->[0x128]      // p.RebOutMod
sum *= PID->[0x12c]      // p.ScOutMod
return mod * sum

Every multiply is 80-bit x87 with no intermediate store, so the whole tail is one long-double chain; only the two (double)(float) narrowings above are real roundings.

2.2 The population term — PopOutput 0x0074d8f0 and GroupOutput 0x0074b7a0

PopOutput(t):                                    // t = 0 imperial, 1 civilian
  sum = 0
  for sp in 0..6:
      n = GroupPopulation(t, sp)                                  // int64
      surplus = 0
      if (PID && sys->indi == 0 && t == 1 && sp == PID->Species):
          MaxPop(sys, PID, sp, &A, 0, 0)                          // 0x0074a6d0, out slot 4
          MaxPop(sys, PID, sp, 0, 0, &B)                          //             out slot 6
          surplus = (B > A) ? (B - A) : 0
      total = surplus + n
      if (total > 0) sum += GroupOutput(t, sp, (double)total)
  return sum

SlaveOutput():                                   // t = 2, no station factor, no morale
  sum = 0
  for sp in 0..6:
      s = SlaveCount(sp)                                          // 0x0074b610, int64
      if (s > 0) sum += max(0.0, POPTYPE[2].out * 1.8 * ((double)s / 500000.0))
  return sum

GroupOutput(t, sp, count):
  if (!(count > 0.0)) return 0.0
  q  = count / 500000.0                                           // .rdata 0x00a1fb00 = 500000.0
  sf = 1.0
  if (PID != 0 && t == 0):
      k = StationCount(sys, PID, 0)                               // 0x00815c10 -> 0x00815b10(.., 1)
      b = (STATION_BONUS_IMPERIAL_OUTPUT > 0.0) ? STATION_BONUS_IMPERIAL_OUTPUT : 0.0
      sf = 1.0 + (double)k * b
  mo = (t == 1) ? MoraleOutputMod(sp) : 1.0
  v  = (double)POPTYPE[t].out * (sf * 1.8) * mo * q               // 1.8 = .rdata 0x00a1faf8
  return max(v, 0.0)

GroupPopulation(t, sp) (0x00747ba0): t == 0 → (sp == (sys->indi ? sys->indi->[4] : PID->Species)) ? (sys->pbon + sys->Pop) : 0, sign-extended to int64. Otherwise → Population::Count(sys->pbon2 /*+0x1b4*/, t, sp) + Population::Count(sys->Pop2 /*+0x1a0*/, t, sp).

MoraleOutputMod(sp) (0x00746910): 1.0 if no owner, if sys->indi != 0, or if sys->cm.value[sp] == 0 (the Morale int[7] at +0x120). Else, with m = cm.value[sp]: m >= MORALE_INCREASE_OUTPUT → MORALE_INCREASE_OUTPUT_MOD if that is > 0, else 1.0; m <= MORALE_DECREASE_OUTPUT → MORALE_DECREASE_OUTPUT_MOD if that is > 0, else 1.0; otherwise 1.0.

So the per-capita output rate is typeMod × 1.8 / 500000 — for an imperial population with no stations that is exactly 3.6e-6 output points per head.

2.3 The two resource terms

StripMineFraction() 0x00747d30, returns float:

Fi = (double)float32(sys->ibon + sys->Infra)                  // +0x198 + +0x190, narrowed
V  = signed_cbrt((sys->pbon + sys->Pop) / 100.0) * 0.01       // 100.0 @0x009e20e8, 0.01 @0x00a1a448
R  = clamp01(V)
if (0.0001 + R >= 1.0) R = Fi                                 // 0.0001 @0x00a1fa18
return float32( (R < Fi) ? R : Fi )

OverHarvestDemand(SRoh) 0x007483b0, ret 8, returns double — the corrected §3.3 row:

resAvail = sys->Res; if (PID && PID->[0x138]) resAvail += sys->MRes + sys->ARes2
P = (double)resAvail
B = 0.0
if (SRoh > 0.0):
    S = sys->pbon + sys->Pop                                  // int add, may wrap
    R = clamp01((double)S * 1e-5)                             // 1e-5 @0x009faa28
    B = max(SRoh * P * R, 1.0)                                // floor of 1.0, not 0.0
D = PID ? (double)(int)SpeciesDef(PID->Species)->f4c : 0.0    // +0x4c, an int
return min(P, max(D + B, 0.0))

ComputeOutputFromRates calls it a second time for the resource ledger: need = ftol(OverHarvestDemand(SRoh)) + CivilianConsumption() (0x0074c6f0); out[2] = min(resAvail, max(need, 0)); out[1] = min(out[2], ftol(OverHarvestDemand(SRoh))) — which confirms B4's correction that out[1] is min(out[2], stripMineDemand) and names the demand.


3. Where each input comes from — and this is the useful part

InitPopTypeTable 0x00535ca0 builds the 3-row table in code, from x87 literals, with only three fields taken from GlobalConst slots. Read out of the initialiser (the function keeps six values on the x87 stack and rotates them with fxch, so the rows are not adjacent in the listing):

row +0 +4 +8 (max pop) +0xc +0x10 out +0x14 income +0x18 +0x1c +0x20
0 imperial 0 1.0 50 000 000 0 1.0 1.0 1.0 1.0 1.0
1 civilian 1 0.25 20 000 000 0 0.33 0.33 1.0 0.5 2.0
2 slaves 2 0.0 0 0 SLAVES_OUTPUT_MOD SLAVES_INCOME_MOD SLAVES_REPAIR_MOD 0.0 —

(the 50 000 000 confirms B4's "the 50,000,000 cap"; the GlobalConst slot pointers are 0x00ae2e88 → 0x00b0e9b0 out, 0x00ae2e84 → 0x00b0e9ac income, 0x00ae2e8c → 0x00b0e9b4 repair.)

So the whole population→output term is hard-coded in the executable for imperial and civilian population. The data-file dependency of §2 reduces to this closed list:

# value where needed when
1 STATION_BONUS_IMPERIAL_OUTPUT slot 0x00af08f4 → 0x00af08f0, in the file image as 0.1f any imperial pop with ≥1 station
2 MORALE_INCREASE_OUTPUT (int) slot 0x00aec784 civilian pop with non-zero morale
3 MORALE_INCREASE_OUTPUT_MOD (float) slot 0x00aec78c ditto
4 MORALE_DECREASE_OUTPUT (int) slot 0x00aec794 ditto
5 MORALE_DECREASE_OUTPUT_MOD (float) slot 0x00aec79c ditto
6 SLAVES_OUTPUT_MOD (float) 0x00b0e9b0 any slaves
7 ADDICTION_OUTPUT_MOD (float) slot 0x00aeca50 → 0x00aeca4c addiction phase ≥ 3
8 SpeciesDef[sp]->f4c (int) 0x00b10a00 + sp·0x184 + 0x4c always (the base resource demand)
9 SpeciesDef[sp]->f50 (float) +0x50 always

Everything else — 1.8, 500000, 0.9, 1.0, 0.33, 1e-5, 0.01, 100.0, 1/3, 0.0001 — is a .rdata literal, i.e. a fact about the algorithm, not about the shipped data.

Nine numbers. That is the whole tuning-table surface of this term.


4. PREDICTION — written before the run

Build output-<sha>, hook Game::ServerSystem::ComputeTotalOutput (0x00750480) in compare mode. Chosen over ComputeOutputFromRates deliberately: that one repairs ships in orbit and is not compare-safe. ComputeTotalOutput and every one of its callees were checked for stores to the game state and are read-only (0x0074a6d0's only writes are through its int64 out-parameters).

4.1 What I expect

  1. Call count. ComputeOutput/ComputeMaxIncome are called from the UI as well as the turn, so the count will be dominated by UI polling, exactly as ComputeBudget's 4,437 were. I expect hundreds to thousands of calls, and only a few tens of distinct system states — and I will report distinct states, not the call count (rule 15).
  2. Zero divergences on the returned double, compared as exact IEEE-754 bits.
  3. SRoh == 0.0 on every call from all 11 saves (all 28 systems in turn1-state.sav carry SRoh = 0.0), so the SRoh > 0 branch of OverHarvestDemand will be unexercised. I will flag it as a hypothesis (rule 6) rather than claim it.
  4. POPTYPE[0].out == 1.0f, POPTYPE[1].out == 0.33f, POPTYPE[1].maxpop == 20000000, POPTYPE[0].maxpop == 50000000 read live out of 0x00b104e8. If any of these differ from the initialiser I read, my reading of the fxch rotation is wrong and everything in §3 is suspect.
  5. STATION_BONUS_IMPERIAL_OUTPUT == 0.1f live (the file image value survives the data load).

4.2 Numbers, from turn1-state.sav, before the run

Gamma Cephei (Idx 4, human homeworld): Pop = pbon = 1e9, Pop2 = {type 1, species 0, 5e8}, Res 5000, MRes 155, ARes2 0, TRes 0, Infra = ibon = 1.0, OutMod 1.0, cm[0] = 75, SRoh 0, rbfl 0.

  • GroupPopulation(0, 0) = 2e9 → GroupOutput(0,0,2e9) = 1.0 × sf × 1.8 × (2e9/5e5) = 7200 × sf, sf = 1 + 0.1k for k stations.
  • GroupOutput(1, 0, 5e8 + surplus) = 0.33 × 1.8 × mo × ((5e8+surplus)/5e5) = 594 × mo plus the surplus term.
  • StripMineFraction: cbrt(2e9/100) × 0.01 = 2.7144… → clamps to 1.0; Fi = 2.0; min(1.0, 2.0) = 1.0. So resTerm = (0 + resAvail) × 1.0 × 0.9 = 4500 without the AMine flag, 4639.5 with it. This is a live discriminator for PID->[0x138].
  • baseTerm = min(resAvail, max(f4c, 0)) × f50 (the SRoh = 0 form).

So the returned total for Gamma Cephei should be (baseTerm + resTerm + 7200·sf + 594·mo) × p.OutMod × 1.0 × p.f224 × p.RebOutMod × p.ScOutMod, and with the four player multipliers at 1.0 and no stations it should sit in the low five figures.

The single strongest check: total must be linear in Pop. Koa'Vo has Pop = 1.2e8, pbon = 0 → imperial term 1.2e8/5e5 × 1.8 = 432, exactly 2000/7200 of Gamma Cephei's, with Res 3394 giving resTerm = 3054.6. Two colonies, one ratio, no fitted parameter.

4.3 Falsification — how this model could be wrong, and the symptom of each

way it could be wrong symptom in the run
[ecx+0x68] in 0x007483b0 is not Res — I matched the offsets to struct-recovery.md's memory column, which is 8 more than the wire column the AMine-gated +0x70 + +0x6c sum would not match MRes + ARes2; ours diverges on every call by a fixed additive amount, and the traced resAvail will not equal any save field
my fxch-rotation reading of InitPopTypeTable is off by one register POPTYPE[1].out reads live as 0.25 or 0.5 or 2.0 rather than 0.33; the civilian term is then wrong by a constant ratio and only civilian-bearing systems diverge
the max(v, 1.0) I read at 0x74844c–0x748451 is really min invisible here, because SRoh = 0 on every save — so this is explicitly unverified and stays a hypothesis
GroupOutput's station factor applies to civilians too (I read the t == 0 test at 0x74b7e0 as excluding them) systems with stations diverge; systems without do not — so a divergence that correlates with NumSnF > 0 names this
the MoraleOutputMod thresholds are inclusive the other way (> vs >=) a divergence only on systems whose cm[sp] sits exactly on a threshold; likely unexercised, so it will be reported as a hypothesis
the association of the additive chain differs (x87 is not associative) a divergence in the last 1–2 ulps only, on the largest colonies
p.f224 / RebOutMod / ScOutMod are not all 1.0 in these saves ours diverges by a clean multiplicative factor on every call of one player — which identifies the slot rather than refuting the model

A run with 0 divergences and only 2–3 distinct colony states would be a weak result and will be reported as one. The strong form is the turn1 → turn2 → turn3 and Zuul turn15 → turn23 progressions, where Pop, Res and Infra all move.



5. The live result

Two builds, two workloads, both on VM140. Hook set: Game::ServerSystem::GroupOutput and Game::ServerSystem::ComputeTotalOutput, both in compare mode, every other hook off. Traces verify/traces/output-*.jsonl.gz, reports verify/results/compare/output-*.md.

run build workload calls compared diverged undeclared writes
1 output-b48d860-20260908T1527Z ref-turn2 load + one End Turn (Human, turn 2 -> 3) 14,853 14,853 4,957 0
2 output2-b48d860-20260908T1553Z zuul-turn5 load + one End Turn (Zuul, turn 5 -> 6) 11,966 11,966 0 0
2 (cont.) same + ref-turn2 reloaded 24,357 24,357 1 0

Run 1's 4,957 divergences were every one of them exactly one ulp, and every one of them on the civilian population row. The cause was not the model: it was that a 32-bit x87 build of game::sim is allowed to leave an intermediate in a register at the register's own precision, while the original's x87 rounds every multiply to double (its control word is 0x127f). Run 2 carries sim::Narrow, which forces the round the original performs anyway, and the population term then matches bit for bit on 13,105 calls across two species.

5.1 Every prediction in §4.1, checked

predicted measured verdict
POPTYPE[0].out == 1.0f 1.0 HELD
POPTYPE[1].out == 0.33f 0.330000013 HELD
POPTYPE[0].maxpop == 50 000 000 50000000 HELD
POPTYPE[1].maxpop == 20 000 000 20000000 HELD
POPTYPE[2].maxpop == 0 0 HELD
STATION_BONUS_IMPERIAL_OUTPUT == 0.1f 0.100000001 HELD
SRoh == 0 on every call 0 on all 24,357 HELD (and the over-harvest branch is therefore unexercised -- see §5.3)
zero divergences on the returned double 0 of 13,105 on GroupOutput, 1 of 11,252 on ComputeTotalOutput HELD, with one residual
hundreds-to-thousands of calls, few distinct states 24,357 calls, 13 distinct system states HELD, and the coverage is thin exactly as forecast

So the reading of InitPopTypeTable's x87 rotation was right: the imperial and civilian output modifiers really are literals in the executable, and the shipped data changes neither.

Values the data files DO supply, read out of the running process (these were unknown before this run):

key value
STATION_BONUS_IMPERIAL_OUTPUT 0.1f
MORALE_INCREASE_OUTPUT / _MOD 85 / 1.5f
MORALE_DECREASE_OUTPUT / _MOD 20 / 0.5f
SLAVES_OUTPUT_MOD 3.0f
SpeciesDef +0x4c (base resource demand) 0 for Human and Tarkas, 10 for Zuul
SpeciesDef +0x50 (resource output factor) 10 for Human and Tarkas, 40 for Zuul

The last two are the reason a species table is not optional: they differ by species and they scale a whole term. Every colony in the corpus carries cm = 75, which sits strictly between 20 and 85, so the morale multiplier is 1.0 on every call the corpus makes -- both morale branches are unexercised and stay hypotheses.

5.2 The prediction that was wrong, and it was mine, not the model's

§4.2 said Gamma Cephei's strip-mine fraction would be min(1.0, 2.0) = 1.0 and its resource term 4500. It is 2.0 and 9000. The branch at 0x00747db4 is a substitution, not a clamp: once the population term reaches 1 - 1e-4 the infrastructure term replaces it outright rather than capping it, so a colony with a pending infrastructure bonus extracts at better than unity. The disassembly said so and I mis-evaluated my own reading of it by hand. The model in the code was right from the start; the arithmetic in the prediction was not. Caught by the very first host test, before any VM time was spent.

5.3 What this run did NOT cover -- read this before quoting the zero

24,357 calls sounds like a lot. It is 13 distinct system states and 12 distinct GroupOutput rows:

  • 5 colonies in total across both workloads (Gamma Cephei, Ke'Dolarra, Koa'Vo, Gallandro and three more Zuul systems), and 4,832 of run 1's calls are the UI polling one of them.
  • SRoh is 0 on every single call, so OverHarvestDemand's whole rate > 0 branch -- including the max(v, 1.0) floor, which is the one place I could most easily have read a min as a max -- is untested. It is a hypothesis, not a finding.
  • The station factor is untested. No system in either workload has a station, so stationFactor was 1.0 on all 13,105 rows and the hook's own ours assumed zero stations. A save with an imperial station would test it; none exists.
  • The slave term is untested. slaveGroupPop was 0 on every call, including the Zuul workload, so SLAVES_OUTPUT_MOD = 3.0 was read but never used.
  • The morale multiplier is untested (see above): every colony sits at 75.
  • The addiction multiplier is untested: no colony reached phase 3.
  • The civilian capacity surplus is untested: no colony was at its cap.
  • Zuul have no civilian population at all, so run 2 exercises only the imperial row -- which is why its 11,966 calls found zero divergences even before you account for the fix.

The honest summary is: the imperial and civilian population laws are verified hard, on two species and two workloads; every conditional hanging off them is unexercised and labelled.

5.4 The one residual

ComputeTotalOutput diverges on exactly one of 11,252 calls in run 2: Koa'Vo, the independent colony, 9248.450318530804 against 9248.450318530802. One ulp, in the five-multiplier tail (OutMod there is 2.18500018, the only non-trivial one in the corpus). It is immaterial to every consumer: the caller rounds this value half-to-even to an integer before splitting it across the output channels, and 9248.4503 rounds to 9248 either way. It is recorded rather than papered over, and it is not fixed.


6. What this unblocks, and what it does not

The brief's premise was that this term blocks P01 P02 P03 P05 P06. It is now verified -- and that turns out not to be enough to unblock them, for a reason worth stating plainly.

ComputeBudget's missing input is not a system's output; it is a system's money, which is ComputeMaxIncome(s) = max(ComputeOutputRates(s, maxMods)[3], 0). That runs the verified output total through a second chain -- TradePointsToMoney -- which has its own population law (typeIncomeModifier / 14000, no 1.8 factor, a different table column) and its own multipliers.

Lane Y's tools/max_income_oracle.py inverts the stored BnkEl to state the true Sum max(ComputeMaxIncome(s), 0) for 25 player-records across the corpus. tools/max_income_predict.py (new, this lane) computes the same number from colony state using the verified output term plus that income chain and compares. Result:

6 of 25 records MATCH exactly; 19 differ.

The six that match are every record whose owner is the human player or the independent colony -- including turn1/turn2/turn3-state for both -- and they match to the unit on a five-figure number, three of them on a moving workload. The nineteen that differ are all AI-owned, and the single-system ones differ by a clean factor of exactly 1.1 (246992 x 1.1 = 271691). That factor is not on the wire: it is DifficultyMods(owner)->+4, the AI trade/income difficulty multiplier that TradePointsToMoney applies and that formula-gaps.md Q3 already names as unresolved. The multi-system AI empires differ by more, so there is at least one further term there (candidates, in order: trade routes, stations, the suitability money cost on non-ideal planets).

So the blocker moves rather than clears: the population -> base-output term is closed, and what now stands between src/app and systemIncome is the income tail, not the output term. That is a smaller and much better-specified target, and the 25-record oracle makes it falsifiable without a VM.