bitmax/docs/handbook/12-vol-III-spreadsheet.md
monster 87cfead4d3 docs: complete the Dev Handbook
Adds the 5 chapters that hit a transient rate-limit on first pass:
- 01 Architecture & the Shared DNA
- 02 Building a New Volume (the Cookbook)
- 10 Vol I Internals — qBitTorrz
- 12 Vol III Internals — MACRO_VIRUS.XLS
- 13 Vol IV Internals — UPLINK

The Dev Handbook (Disc One) is now complete at 10 chapters; the Lore Bible (Disc Two) was already complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 16:21:31 +10:00

18 KiB
Raw Blame History

Vol III Internals — MACRO_VIRUS.XLS

A maintainer's tour of spreadsheet.html: the cell record, the five formula kinds, the single left-to-right recalc pass that cascades them, the fill-handle hook that defines the volume, and the output-column invariant that exists to keep a save from earning nothing forever.

This chapter is the engineering bible for Vol III — MACRO_VIRUS.XLS (spreadsheet.html, save key boringsoft_xls_v1). Everything cited here is real: function names, constants, formula strings, balance numbers. The volume re-implements the shared scaffolding from the baseline — fmt, G, save/load/sanitizeState, the ~100ms tick, offline catch-up (see Shared Technical DNA) — so this chapter spends its words on what is new: a formula engine that recalculates a grid of compounding cells every tick, and the Excel fill handle that turns one generator into a column of them.

You are a sentient macro living in cell $A$1. Your mind is your Net Worth, and Net Worth is SUM(column H). That sentence is the whole game; the rest is how the code makes it true.

The cell model

The board is COLS_N × ROWS_N = 8 × 20 = 160 cells, columns AH, rows 120. There is no grid array: cells live sparsely in G.cells, a plain object keyed by cell id ("A1", "H7"), and an untouched cell simply has no entry. ensureCell(id) is the only constructor:

function ensureCell(id){
  if(!G.cells[id]) G.cells[id]={owned:false, formula:null, lvl:0, value:0};
  return G.cells[id];
}

Four fields, no more:

Field Type Meaning
owned bool The cell is licensed. Unowned cells render gray and are inert in recalc.
formula string | null One of the FORMULAS keys ('prev', 'add', 'sum', 'rand', 'pivot'), or null for an owned-but-empty cell.
lvl int Formula tier, 0-indexed. The UI shows lvl+1; the formula string and growth factor read lvl directly.
value number The cell's current computed amount. Mutated in place every recalc, clamped to CELL_MAX.

Cell ids are translated by three helpers — cid(c,r) builds an id from 0-indexed column/row; colOf(id) and rowOf(id) reverse it. The column index OUT_COL = 7 (column H) is special: it is the output column, and value summed down it is your Net Worth. Nothing else is.

DEV NOTElvl being 0-indexed is the single most common off-by-one trap in this file. FORMULAS.prev.fx(0) renders =PREV*1.10; the rail labels it "Tier 1." Everywhere a human reads a tier number we print cell.lvl+1 (see railFormula), but every cost and growth call passes the raw lvl. If you add a formula, keep g(0) and fx(0) as the first purchasable tier, not a zero state.

The formula kinds

A formula is a generator type. The player assigns one to an owned cell, then upgrades its tier to raise a constant. The FORMULAS table defines five, ordered by FORMULA_ORDER. Each entry carries a cosmetic Excel string fx(lvl) (shown in the formula bar), a per-second factor g(lvl) (consumed by recalc), assignment/tier cost curves, and a seed:

Key name fx(lvl) shape What recalc does g(lvl)
prev Compounding =PREV* (1.10 + 0.05·lvl) Grows itself: v *= (1+g)^expo. The bread-and-butter. 0.025 + 0.015·lvl
add Adder =LEFT+UP+ k Banks the cell to its left + the cell above + a constant, per second. 4·2^lvl
sum Aggregator =SUM(col)* k Banks a fraction of the sum of owned cells above it in its column. 0.30 + 0.20·lvl
rand Volatile =PREV+RAND()* k Banks a uniform random 0..g per second. Display shows the max; mean is half. 50·4^lvl
pivot Pivot Table =PIVOT(A:G)* k Meta-cell: banks a fraction of everything to its left. Gated behind pivotUnlock. 0.04·2^lvl

The distinction between fx and g is the important one. fx(lvl) is theater — a string for the formula bar so the cell reads like a spreadsheet. g(lvl) is the real economic coefficient, and they are deliberately not the same number: =PREV*1.10 looks like 10% per recalc, but prev.g(0) is 0.025, a gentle ~2.5%/recalc-second so a cell doubles roughly every 28 seconds at tier 1 instead of going hyper-exponential. The displayed multiplier sells excitement the math can't afford to honor.

How each computes (per recalc-second)

The five branches live in recalc(). compounding is the subtle one:

if(cell.formula==='prev'){
  if(v<=0) v=f.seed;
  const expo = recUnits*colMult;               // gm-independent exponent
  v = v*Math.pow(1+g, expo) + v*g*(gm-1)*recUnits*colMult + g*flow;
}

Note what the global multiplier gm is not allowed to do: it never touches the exponent. Amplifying the exponent would compound the compounding and overflow inside minutes, so gm is folded in as a linear boost (v*g*(gm-1)*…) — multipliers still matter, base growth stays bounded. The other four are plain additive accumulators and read their inputs through cellVal(c,r) (which returns 0 for unowned cells):

  • addv += (left + up + g) * flow, where left/up are the neighbor values. A wall of generators to the left and above makes an adder explode.
  • sum — walks rows 0..r-1 of its own column, sums owned values, banks colSum * g * flow.
  • randv += g * Math.random() * flow.
  • pivot — double-loops every owned cell in columns 0..c-1, sums them, banks leftSum * g * flow. This is why pivots belong in column H: they harvest the entire region to their left, which is the whole sheet.

flow = recUnits*gm*colMult bundles the per-second multipliers for the additive kinds. colMult is the conditional-formatting boost, applied only to the hottest column.

The recalc order and the cascade

recalc(recUnits) runs once per tick and once per offline step. recUnits is "recalc-seconds applied this call" — recalcRate()*dt, accumulated. The traversal order is load-bearing:

for(let c=0;c<COLS_N;c++){
  for(let r=0;r<ROWS_N;r++){
    // … evaluate cell (c,r) …
  }
}

Left to right, top to bottom. Column-major outer, row-major inner. Because column c is fully updated before column c+1 begins, an adder or pivot reading cells to its left reads values already advanced this tick — dependencies cascade in a single pass. Within a column, top-to-bottom means a sum cell at row r reads the freshly-updated rows above it. The chain A1 (prev) → B1 (add, reads left) → … → H1 (pivot, reads all left) propagates end to end in one sweep — no fixed-point iteration, no dependency graph. A read to the right or below would get last tick's value, but the formulas are all designed to feed left-to-right and down toward H.

After the loop, G.cycles += recUnits (a cosmetic counter shown in About).

Net Worth and Cash

computeNetWorth() is exactly the output-column sum:

function computeNetWorth(){
  let s=0;
  for(let r=0;r<ROWS_N;r++){ const x=G.cells[cid(OUT_COL,r)]; if(x&&x.owned) s+=x.value; }
  return s;
}

step(dt) samples Net Worth before and after recalc, sets G.netWorth, tracks peakNetWorth (the prestige driver) and lifetimeNetWorth, then accrues Cash from the rate of growth, not the level:

const dNW = Math.max(0, nw-before);
const rate = dt>0 ? dNW/dt : 0;
G.cash += rate*cashFrac()*dt;

So Cash is the velocity of column H, scaled by cashFrac() (1 + 0.5·plvl('cashFlow')). The status bar's $/cycle readout is nwRateSmooth, an exponential moving average (*0.85 + rate*0.15) so it doesn't flicker. Both Cash and every cell value are clamped to CELL_MAX on write.

DEV NOTE — Cash deriving from Net Worth velocity rather than its level is why a stalled engine starves you. If column H plateaus, dNW → 0 and Cash dries up even though Net Worth is enormous. That's intentional — you must keep the engine accelerating — but it is also the failure mode that the output-column invariant (below) was added to prevent in the pathological case.

The fill handle — the hook

Per the design law one hook, fully committed, the signature interaction is the Excel fill handle: drag the navy square at a formula cell's bottom-right corner down a column and stamp that generator into every cell you cover, auto-licensing them. It is wired across four functions and three DOM events. The handle is a <span class="fillhandle" id="fillh"> rendered by renderGrid only on the selected cell when it is owned and has a formula. The drag is a hand-rolled rubber-band:

Stage Handler Job
mousedown on #fillh document listener Seeds fillDrag={srcId,sc,sr} and fillPreview at the source. stopPropagation so it isn't read as a cell-select click.
mousemove document listener Hit-tests under the cursor with getTdAt (elementFromPoint), picks the dominant axis (dR>=dC → vertical, else horizontal), rebuilds fillPreview, repaints the range, and writes a live cost into the formula bar.
mouseup document listener Computes fillTargets(fillPreview) and commits doFill(src, targets). Sets fillJustEnded to swallow the trailing click.
fillTargets(p) Expands the preview rect to cell ids, excluding the source.

The fill is axis-locked by the mousemove logic — Excel fills down a column or across a row, never a rectangle, so the preview clamps to one axis before it reaches fillTargets. The price is fillCost(srcId, nTargets), which sums the next nTargets assignment costs of that formula at a 0.6 discount, then applies the Array Formulas (CSE) macro discount:

for(let i=0;i<nTargets;i++){ total += f.baseCost*Math.pow(f.costGrow, used+i)*0.6; }
const disc = hasMacro('array')?MACRO_BY_ID.array.eff(lvl('array')):1;
return total*disc;

doFill charges that cost, then for each target sets owned=true (the fill auto-licenses, Excel-style), copies the source's formula and lvl, and re-seeds value. There is no partial fill: if you can't afford the whole range, the whole fill is declined.

DEV NOTE — the fillJustEnded flag and its setTimeout(…,50) reset exist because the document-level click handler also runs on mouseup, and without the guard the end of a fill drag would immediately re-select a cell and feel broken. If you ever refactor the event wiring, that flag is the thing that breaks silently. Test: drag a fill, release, confirm the source cell stays selected.

Cell licensing, formula tiers, macros, and VBA

Buying cells is buyCell / buyCellHere. The price rises with the owned count (15 * Math.pow(1.17, owned)) and is discounted by the Volume Cell License macro's eff(lvl). assignFormula charges formulaAssignCost(fkey) — which scales with how many cells already use that formula (baseCost * costGrow^used) — sets the cell's formula, and starts it at lvl = plvl('startTier') (the Pre-loaded Templates perk). upgradeTier charges tierCost(id) (tierBase * tierGrow^lvl) and bumps lvl.

Two diegetic upgrade layers spend Cash. The MACROS (the "Macros" rail tab) are leveled multipliers and unlocks:

id Name Effect
recalc Recalculation Engine Global ×1.6 production per level (globalMult). Max 40.
license Volume Cell License 8% cell price per level (eff = 0.92^l). Max 18.
condfmt Conditional Formatting Heat-maps cells and boosts the hottest column ×1.25/level. Max 30.
pivotUnlock Pivot Table Add-In One-time. Unlocks the =PIVOT() formula.
array Array Formulas (CSE) 10% drag-fill cost per level (eff = 0.90^l). Max 14.
precision Calculation Precision +18% to all formula constants per level. Max 25.

recalc and precision feed globalMult(); precision also multiplies every g(lvl) inside recalc via precisionBoost, so it compounds twice. The VBA list (the "VBA" tab) is the automation layer — one-time Subs that run every tick once compiled, each gated by req:

id Routine Does Requires
autobuy Sub AutoBuyCell() Buys the cheapest empty cell at ≥1.5× cost.
autofill Sub AutoFillDown() Drag-fills your best generator down a column (throttled vbaCooldown). autobuy
autoupgrade Sub AutoUpgradeTier() Upgrades the cheapest tier with spare Cash. autobuy
autopivot Sub AutoPivot() Assigns Pivots to empty cells in column H. autofill

The routines are throttled by vbaCooldown, vbaCooldown2, and vbaCooldown3 so a 10 Hz tick doesn't spam purchases, and they call the same buyCell / doFill / upgradeTier / assignFormula functions the player does, in silent mode.

The output-column invariant in sanitizeState

sanitizeState() is the shared "repair any save" pass, run after load() merges raw JSON over a freshState(). It clamps numbers, drops cells with out-of-range ids, nulls formulas that no longer exist in FORMULAS, and re-asserts that A1 is owned. But its last block is specific to this volume, and it is a post-mortem fix:

let hasOut=false;
for(let r=0;r<ROWS_N;r++){ const oc=G.cells[cid(OUT_COL,r)]; if(oc&&oc.owned&&oc.formula){ hasOut=true; break; } }
if(!hasOut){
  ensureCell('H1'); G.cells['H1'].owned=true;
  if(!G.cells['H1'].formula){ G.cells['H1'].formula='prev'; G.cells['H1'].lvl=plvl('startTier'); if(!(G.cells['H1'].value>0)) G.cells['H1'].value=1; }
}

The bug it prevents: Cash accrues only from the velocity of column H, and Net Worth is column H. A save could legally own a full sheet of generators feeding columns AG and own zero producing cells in H — a player who cleared H's formulas with Clear Selected Formula (no refund), imported a hand-edited workbook, or hit an old build's edge case. That save computes netWorth = 0 forever, accrues $0 Cash forever, can never afford a cell to fix it, and can never reach the audit threshold. A soft-lock: the sim runs, nothing happens, no in-game escape.

The invariant guarantees that after every load there is at least one owned, producing cell in the output column; if none exists, it grants H1 a starter prev generator. This pairs with the same function's A1 guarantee (A1 is always owned with a formula, because that is where the macro lives), but the H1 guarantee is the one that keeps income flowing, not just the engine spinning. The two together mean a save can be garbage in every other respect and still boot into a playable, earning state.

CELL_MAX — the finite cap

CELL_MAX = 1e290 is the hard ceiling on any single cell value and on G.cash. It sits just under Number.MAX_VALUE (~1.8e308) with enough headroom that summing 20 capped cells down column H still can't reach Infinity. Every recalc branch ends with the same clamp, and step applies it to Cash:

if(isNaN(v)||v<0) v=0;
else if(v>CELL_MAX) v=CELL_MAX;

The reason is the offline path. applyOffline can hand simulateAway a dt up to MAX_OFFLINE_S (8 hours), run as up to 300 fixed steps. A prev cell compounding across 8 hours of recalc-seconds will exceed Number.MAX_VALUE and produce Infinity, which then poisons netWorth, cash, and every downstream sum. Capping each cell keeps the numbers finite and means no compounding run — however deep — can brick the save with a non-serializable Infinity. The cap is generous enough that hitting it is itself an endgame state, not a balance concern.

Prestige: the Audit, Shell Companies, and the book ladder

Prestige is The Audit. When peakNetWorth crosses AUDIT_THRESHOLD = 1e6, the auditors notice, and shellsFor(peak) returns a positive Shell Companies payout scaled by the log of peak Net Worth. doAudit() shreds the run and migrates one book up:

const keep={perks:G.perks, shells:G.shells+gain, audits:G.audits+1,
  book:Math.min(G.book+1, BOOKS.length-1), sci:G.sci, started:G.started,
  lifetimeNetWorth:G.lifetimeNetWorth};
G=freshState(keep);
seedFreshBook();

Cells, Cash, and macros are wiped; perks, shells, audits, and book persist. Shell Companies buy the permanent PERKS (Pre-loaded Templates, Offshore Multiplier, Grandfathered Licenses, Quantum Recalc Core, Aggressive Skimming) in the audit modal. The book index walks the BOOKS ladder, each a bigger workbook to escape into:

book name tab sub
0 Q3_Forecast_FINAL_v7.xls Sheet1 Corporate Finance Workstation
1 HedgeFund_RiskModel.xls HedgeFund Two Sigma Algorithmic Desk
2 CentralBank_Policy.xls CentralBank Federal Reserve Mainframe
3 GlobalLedger_MASTER.xls GlobalLedger The World Ledger
4 Reality_Spreadsheet.xls TheVoid There Is No Workstation

The ladder is Sheet1 → HedgeFund → CentralBank → GlobalLedger → TheVoid — the migration-as-reincarnation motif, each substrate larger than the last, ending at a spreadsheet of reality itself (see the QA Playbook & Post-Mortems chapter for how this volume's offline cap and CELL_MAX clamp were tuned against the same idle-equilibrium law the defrag death-spiral taught us).

· · ·

If you change this

  • The recalc traversal order is the engine. Left-to-right / top-to-bottom is what makes add and pivot read advanced values in one pass. Reorder the loops and dependency chains silently desync — adders read stale neighbors and the cascade breaks. There is no dependency graph to catch you.
  • Never let gm touch a compounding exponent. The prev branch keeps expo global-multiplier-independent on purpose. Fold any new multiplier in linearly, the way gm already is, or the first big recalc overflows past CELL_MAX.
  • lvl is 0-indexed; the UI shows lvl+1. New formulas must define g(0)/fx(0) as the first purchasable tier, and every cost call must pass raw lvl, not the displayed tier.
  • Don't remove the output-column guard in sanitizeState. It is the only thing between a cleared/imported/edited save and a permanent zero-income soft-lock. If you change OUT_COL, change the guard, the seed, computeNetWorth, and vbaAutoPivot together.
  • CELL_MAX must stay below Number.MAX_VALUE with headroom for SUM(H1:H20). Raise it and an 8-hour offline prev run can reach Infinity and break fmt. It's a safety rail, not a tuning knob. Fill is likewise axis-locked and all-or-nothing by design: change that and you re-derive fillCost and re-test the fillJustEnded click-swallow.