bitmax/docs/handbook/01-architecture-and-the-dna.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

271 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Architecture & the Shared DNA
*The reference architecture every volume re-implements — documented from `index.html`, the canonical Vol I implementation (qBitTorrz 4.6.2).*
This is the scaffolding chapter. Five volumes ship five hooks, but they all stand on the same skeleton, established here in `index.html` and **re-implemented, never imported**, by **DEFRAG.EXE**, **MACRO_VIRUS.XLS**, **UPLINK**, and **INBOX ZERO**. If you understand the qBitTorrz shell — its number system, its `G` global, its save round-trip, its tick, its offline catch-up, its render strategy — you understand the other four before you open them. What follows is that skeleton, with the real function names, the real constants, and the reasons each choice exists.
## The single-file app-shell anatomy
Every volume is one self-contained `.html`: an inline `<style>`, an inline `<script>`, vanilla JS, zero external resources, no build step, double-click to run. `index.html` opens with `"use strict";` and four module constants that the rest of the file hangs off of:
```js
const SAVE_KEY = "qbittorrz_save_v1";
const TICK_MS = 100;
const MAX_OFFLINE_S = 8*3600; // cap offline progress at 8h
const SPD_SAMPLES = 160;
```
The script is laid out in fixed sections, top to bottom, separated by banner comments: number formatting, the data tables (`CATALOG`, `UPGRADES`, `PERKS`), the network-model constants, `STATE`, the derived getters, the game-logic tick, rendering, modals, save/load, event wiring, the main loop, and finally `boot()`. The DOM is declared once in the body — a menu bar, a toolbar, a body split with a sidebar and a center column, a status bar, plus three always-present singletons: `#modal-back`, `#ctxmenu`, and `#toasts`. JS never builds the chrome; it only fills `innerHTML` into the containers that already exist.
> **DEV NOTE** — The section order is load-bearing. Number formatting and the data tables come *before* `freshState()` because `defaultTrackers()` is called inside the state seed, and `fmt()` is referenced everywhere. Keep that ordering when you port the shell — moving `freshState()` above the tables it depends on is the classic "works until you reset" bug.
## The number system
Numbers in an idle game cross twenty orders of magnitude, so the formatter is foundational. `index.html` defines one core function, `fmt(n)`, and three thin wrappers — `fmtSpd`, `fmtInt`, and `fmtTime` — plus a `sciStr(n)` helper for the scientific tail.
`fmt()` walks an SI suffix table, dividing by 1000 each step, and falls back to exponential notation once it runs out of suffixes:
```js
const SI = ['B','kB','MB','GB','TB','PB','EB','ZB','YB','RB','QB'];
function fmt(n){
if(n===Infinity) return '∞';
if(!isFinite(n)||isNaN(n)) return '0 B';
if(n<0) n=0;
if(n<1000) return Math.floor(n)+' B';
if(G&&G.sci) return sciStr(n);
let i=0,x=n;
while(x>=1000 && i<SI.length-1){x/=1000;i++;}
if(i===SI.length-1 && x>=1000) return sciStr(n); // past QB -> scientific
return x.toFixed(x<100?2:1)+' '+SI[i];
}
```
Note the deliberate defenses: `Infinity` renders as `∞`, NaN/non-finite collapses to `0 B`, and negatives clamp to zero — so a garbage number can never reach the screen as `NaN B`. Precision is adaptive: two decimals under 100, one decimal otherwise. The `G&&G.sci` guard means `fmt()` is safe to call even before `G` is assigned.
| Range (bytes) | Suffix | Example output |
|---|---|---|
| `< 1000` | `B` (integer, no decimals) | `512 B` |
| `1e3 1e6` | `kB` | `5.20 kB` |
| `1e6 1e9` | `MB` | `1.30 MB` |
| `1e9 1e12` | `GB` | `4.20 GB` |
| `1e12 1e15` | `TB` | `1.60 TB` |
| `1e15 1e18` | `PB` | `1.30 PB` |
| `1e18 1e21` | `EB` | `5.00 EB` |
| `1e21 1e24` | `ZB` | — |
| `1e24 1e27` | `YB` | — |
| `1e27 1e30` | `RB` | — |
| `1e30 1e33` | `QB` (last suffix) | — |
| `≥ 1e33`, **or** `G.sci` on | scientific via `sciStr` | `1.23e34 B` |
`sciStr` is `(n/Math.pow(10,e)).toFixed(2)+'e'+e+' B'` with `e=Math.floor(Math.log10(n))` — a fixed two-significant-digit mantissa with the raw exponent. The `G.sci` toggle (View → Toggle Number Format, or the `toggle-sci` action) flips the whole UI into pure scientific even mid-SI-range, for players who would rather read `4.40e6 B` than `4.40 MB`. It is a single boolean on state, read inside `fmt()`, so one flag re-formats the entire app on the next render.
The wrappers are trivial and intentionally so. `fmtSpd(n)` is `fmt(n)+'/s'`. `fmtInt(n)` reuses `fmt` for large counts but strips the ` B` suffix (so seed counts read `1.20k`, not `1.20 kB`). `fmtTime(s)` formats seconds into `d/h/m/s` with two units shown, and — critically for an idle game — returns `∞` for anything over 99 days, so an un-seeded ETA never prints a meaningless nine-digit countdown.
## The `G` global and `freshState()`
There is exactly one mutable global: `G`, the entire save in a single object. It starts `null` and is populated only by `load()`. Everything the game knows — currencies, the torrent array, upgrade levels, perk levels, event timers, the IRC log, UI preferences — lives on `G`. Derived values are never stored; they are computed on demand by small pure getters (`downMult()`, `maxSlots()`, `lineSpeed()`, `creditsFor()`), so there is no cache to invalidate and no derived field to corrupt.
`freshState()` returns the canonical shape with every key seeded to a safe default:
```js
function freshState(){
return {
v:1,
data:0, totalUp:0, totalDown:0,
runUp:0, // upload accrued this prestige run (for credits)
credits:0, prestiges:0, lifetimeUp:0,
torrents:[], upgrades:{}, perks:{},
seq:1, sel:null, filter:'all', dtab:'general', sci:false,
seenCatalog:{},
trackerList:defaultTrackers(),
// ... event timers, irc:[], lastSave, started, sidebarW, detailH
};
}
```
Two distinctions matter for prestige later: `runUp` (uploaded *this run*, reset on migration) versus `totalUp` and `lifetimeUp` (which persist), and `upgrades`/`perks` as separate maps because upgrades are wiped on prestige and perks are not. Note that `freshState()` is also the schema authority for `load()`: every key that exists must appear here, or it won't survive a partial save.
## The save / load / sanitize round-trip
Persistence is three functions: `save()`, `load()`, and `sanitizeState()`. `save()` stamps `G.lastSave` and writes `JSON.stringify(G)` to `localStorage` under `SAVE_KEY`, wrapped in try/catch. On failure (private mode, quota) it toasts *once* — guarded by a module-level `saveFailed` flag — and returns `false`, so a storage-less browser degrades to a playable-but-unsaved session instead of throwing on every tick.
`load()` is the boot path and is paranoid by design:
```js
function load(){
let raw=null;
try{ raw=localStorage.getItem(SAVE_KEY); }catch(e){}
if(!raw){ G=freshState(); seedIrc(); return false; }
try{
const s=JSON.parse(raw);
if(!s||typeof s!=='object'||Array.isArray(s)) throw new Error('bad shape');
G=Object.assign(freshState(), s);
sanitizeState();
if(!G.irc.length) seedIrc();
return true;
}catch(e){ G=freshState(); seedIrc(); return false; }
}
```
The `Object.assign(freshState(), s)` is the migration mechanism: a save written by an older version that lacks a newer key still gets that key from `freshState()`, and any stray key from a tampered save is harmless because the getters only ever read the keys they know about. Anything that throws — bad JSON, an array at the top level, a `null` — falls through to a clean `freshState()`. A corrupt save costs your progress, never your boot.
`sanitizeState()` is the repair pass that runs *after* the merge, and it is the most defensively-written function in the file. It defines a local `num(v,d)` that accepts a value only if it is a finite, non-negative number and otherwise substitutes a default, then runs every scalar through it:
```js
const num=(v,d)=> (typeof v==='number'&&isFinite(v)&&v>=0)?v:d;
G.data=num(G.data,0); G.totalUp=num(G.totalUp,0); /* …all scalars… */
if(!G.upgrades||typeof G.upgrades!=='object'||Array.isArray(G.upgrades)) G.upgrades={};
if(!Array.isArray(G.trackerList)||!G.trackerList.length) G.trackerList=defaultTrackers();
if(!Array.isArray(G.torrents)) G.torrents=[];
G.torrents=G.torrents.filter(t=>t&&CAT_BY_ID[t.cid]);
```
The repairs, in order of paranoia:
| Hazard in the save | What `sanitizeState()` does |
|---|---|
| `data: NaN` or a negative / string scalar | `num()` substitutes the default — no NaN ever enters the economy |
| `upgrades` is `null`, an array, or missing | reset to `{}` so `G.upgrades[id]` can't crash |
| `trackerList` empty or not an array | restored to `defaultTrackers()` |
| a torrent referencing a deleted catalog id | filtered out (`CAT_BY_ID[t.cid]` is falsy) |
| `t.downloaded` exceeds the file size | clamped to `c.size` |
| duplicate / non-numeric `t.uid` | reassigned from `G.seq++` |
| `sel` points at a torrent that no longer exists | set to `null` |
| `lastSave` in the future (clock skew / edited save) | clamped to `Date.now()` — a future-dated save self-heals |
That last one matters more than it looks: offline catch-up trusts `lastSave`, so a future timestamp would otherwise yield a *negative* elapsed gap. Clamping it is what stops an edited save from breaking the welcome-back math.
> **DEV NOTE** — The pattern to copy is the three-layer funnel: `Object.assign` over `freshState()` guarantees every key *exists*; `num()` and the type guards in `sanitizeState()` guarantee every value is *sane*; the `CATALOG`/`CAT_BY_ID` cross-check guarantees every reference is *live*. Each volume's `sanitizeState()` differs only in which arrays it walks (clusters, cells, processes, mail) — the funnel shape is identical, and it is the single best defense against a community of save-editors.
## The tick loop
One `setInterval(tick, TICK_MS)` at 100 ms drives everything. The tick is delta-time based, not fixed-step, so it stays correct when the browser starves it:
```js
let lastTick=Date.now(), saveAccum=0;
function tick(){
const t=Date.now();
let dt=(t-lastTick)/1000; lastTick=t;
if(dt<0) dt=0; if(dt>5) dt=5; // cap big jumps (throttled tab)
step(dt);
// sample speed, render HUD/table/status…
saveAccum+=dt;
if(saveAccum>=10){ saveAccum=0; save(); }
}
```
The `dt` cap of 5 seconds is deliberate: a backgrounded or throttled tab can deliver a tick with several minutes of elapsed wall-clock, and feeding that straight into `step()` would credit a giant lump in one frame — and, worse, let download speeds overshoot. Capping `dt` keeps each *online* tick bounded; the real elapsed time is recovered separately by the offline/visibility path below, so nothing is lost, it's just credited through the more careful simulation. Autosave is an accumulator: `dt` sums into `saveAccum`, and every ~10 s of game time it flushes and resets. Plus `boot()` registers `window.addEventListener('beforeunload', save)` so closing the tab always writes one last time.
`tick()` also does the per-frame rendering — HUD, table, status bar every tick; the sidebar only when the 700 ms bucket rolls over (`Math.floor(t/700)!==Math.floor((t-TICK_MS)/700)`); the detail pane only if it's showing live data; and the open modal's affordance state via `updateStoreAfford()` / `updateAddAfford()`. The simulation (`step()`) and the presentation are cleanly separated — `step(dt)` mutates `G`, the render functions read it.
## Offline catch-up and the refocus handler
The idle contract is: it plays while you're gone and rewards your return. Two code paths honor it, and they share one simulator, `simulateAway(dt)`.
`simulateAway` is a coarse fixed-step replay: it chooses a step count scaled to `dt` and clamped to a sane band — `Math.min(240, Math.max(20, Math.ceil(dt/30)))` — and runs the download/seed economy that many times. The clamp is the point: enough sub-steps that a torrent which *finishes* mid-gap still spends the rest of the gap seeding (so you aren't cheated of completion income), but never so many that catching up eight hours hangs the boot.
`applyOffline()` runs at boot, after `load()`:
```js
function applyOffline(){
const dt=Math.min(MAX_OFFLINE_S, Math.max(0,(Date.now()-(G.lastSave||Date.now()))/1000));
if(dt<2) return;
const beforeData=G.data;
simulateAway(dt);
const earned=G.data-beforeData;
if(earned>0){
setTimeout(()=>toast('info','💤 Welcome back',
'While away ('+fmtTime(dt)+') your seeds earned <b>'+fmt(earned)+'</b> Data.'),400);
}
}
```
The gap is the difference between now and `lastSave`, clamped to `MAX_OFFLINE_S` (8 h) on the high end and `0` on the low — the latter being why `sanitizeState()` heals future-dated saves. The welcome-back toast is deferred 400 ms so it lands after the first render rather than fighting it.
The second path is the `visibilitychange` handler wired in `boot()`. Browsers throttle `setInterval` in background tabs, so the `dt` cap inside `tick()` would silently discard most of a long background stretch. On refocus, the handler measures the gap against `lastTick`, and if it exceeds 10 s, credits it through `simulateAway` and — crucially — resets `lastTick` so the very next `tick()` doesn't double-count the same span:
```js
document.addEventListener('visibilitychange',()=>{
if(document.visibilityState!=='visible') return;
const gap=(Date.now()-lastTick)/1000;
if(gap>10){ simulateAway(Math.min(MAX_OFFLINE_S,gap)); save(); renderAll(); }
lastTick=Date.now();
});
```
> **DEV NOTE** — There are two clocks, and conflating them is a real bug we want to avoid: `lastSave` (wall time, persisted, drives boot-time offline) and `lastTick` (live, in-memory, drives the visibility gap). The boot path reads `lastSave`; the refocus path reads `lastTick`. Reset `lastTick` after crediting the gap or the next online tick re-bills it. This is exactly the kind of double-credit that, in **DEFRAG.EXE**, would matter in the *opposite* direction — see the QA chapter's entropy death-spiral, where the lesson is that the away-sim must reach equilibrium, never runaway.
## The toast system
`toast(kind, title, body)` is the only notification primitive. It builds a `<div class="toast {kind}">`, appends it to the always-present `#toasts` container, and schedules its own removal — 6 s for `prestige`, 3.8 s for everything else — then caps the stack so only the five most recent remain (`while(wrap.children.length>5) wrap.firstChild.remove()`). `kind` is one of `''`, `info`, `warn`, `bad`, or `prestige`, each a left-border color via CSS. `title` and `body` are injected as HTML, which is what lets toasts carry `<b>` emphasis and the occasional inline link. Every player-facing event — a completed download, an unlocked tier, an ISP throttle, a migration — routes through this one function.
## The modal and event-delegation pattern
There is one modal host (`#modal-back` wrapping `#modal`) and one context-menu host (`#ctxmenu`). `openModal(html)` drops markup into `#modal` and shows the backdrop; `closeModal()` hides it and clears `modalKind`. A module-level `modalKind` string identifies which dialog is open, so the tick can live-update only the right one and the helpers `refreshStoreModal()` / `refreshPrestigeModal()` can no-op when their dialog isn't showing.
The wiring is **event delegation**, not per-element handlers. A single document-level `click` listener walks `e.target.closest(...)` for the relevant `data-*` attribute and dispatches:
```js
document.addEventListener('click',e=>{
hideCtx();
const actEl=e.target.closest('[data-act]');
// …filterEl, storeTabEl, catTabEl, dtabEl, rowEl…
if(actEl){ if(actEl.tagName==='A') e.preventDefault(); handleAct(actEl.dataset.act, actEl); return; }
if(rowEl){ selectTorrent(parseInt(rowEl.dataset.uid)); return; }
});
```
Every interactive element declares its intent in markup (`data-act="open-store"`, `data-cid`, `data-uid`), and `handleAct(act, el)` is one big switch over those verbs. Because the listener is on `document`, markup that `renderStoreModal()` regenerates wholesale on every purchase keeps working with zero re-binding — you can blow away and rebuild a modal's `innerHTML` and the buttons inside it still fire. `contextmenu`, `dblclick`, and `keydown` follow the same delegated shape. This is *the* reason the rebuild-heavy render strategy below is cheap to maintain.
## The render strategy: update-in-place vs. rebuild
`renderAll()` calls five renderers — `renderHud`, `renderSidebar`, `renderTable`, `renderDetail`, `renderStatus`. The split between *rebuild* and *update-in-place* is intentional and the policy is worth copying:
- **Update-in-place** for small, fixed sets of nodes that change every tick: `renderHud()` and `renderStatus()` set `.textContent` on a handful of known `id`s (`hud-data`, `sb-down`, `sb-ratio`, …). No DOM churn 10×/second.
- **Rebuild** for variable-length lists: `renderTable()`, `renderSidebar()`, and the modals assemble an HTML string and assign `innerHTML` once. The transfer table re-emits every visible row each frame. This is fine because the row count is small and event delegation means rebuilt rows need no handler re-attachment.
The one bespoke renderer is `drawSpeed()`, the Speed-tab graph: a `<canvas>` painted from two ring buffers, `spdDownHist` and `spdUpHist`, each `SPD_SAMPLES` (160) long. The tick `push`es the current totals and `shift`s the oldest every frame, so the canvas always shows the last ~16 s. The manifesto's note for the bigger grids in **DEFRAG.EXE** generalizes this: when the cell count gets large, canvas it — don't ask the DOM to hold 960 nodes.
## The save-key registry
Each volume owns a distinct `localStorage` key. They never collide, so all five can run side-by-side under **ENTROPY OS** with independent saves. This table is canon; do not reuse a key.
| Vol | App | `SAVE_KEY` constant |
|---|---|---|
| I | qBitTorrz | `qbittorrz_save_v1` |
| II | DEFRAG.EXE | `boringsoft_defrag_v1` |
| III | MACRO_VIRUS.XLS | `boringsoft_xls_v1` |
| IV | UPLINK | `boringsoft_uplink_v1` |
| V | INBOX ZERO | `boringsoft_inbox_v1` |
| — | ENTROPY OS (`desktop.html`) | *(none — stateless launcher)* |
The `_v1` suffix is the migration escape hatch: if a future save shape becomes genuinely incompatible with `Object.assign`-over-`freshState()` recovery, bump to `_v2` and the old key is simply ignored rather than mis-parsed.
## The prestige pattern
Prestige is the hard reset that grants a permanent currency and persistent perks, re-skinned per volume (migrate / compress / audit / ssh / ascend). In qBitTorrz it is **Client Migration** to a Seedbox Cluster, and the math lives in two functions. `creditsFor(runUp)` is the payout curve — deliberately sub-linear so each migration is worth less per byte than the last, which is what makes the *next* prestige a decision rather than a reflex:
```js
function creditsFor(runUp){
if(runUp<1e9) return 0; // gate: ~1e9 uploaded this run
return Math.floor(Math.pow(runUp/1e9, 0.42)); // sub-linear in run upload
}
```
`doPrestige()` performs the reset: it banks `gain` into `G.credits`, increments `G.prestiges`, then zeroes the *run* state while preserving the *meta* state. Specifically it wipes `data`, `runUp`, `torrents`, `upgrades`, `sel`, restores `trackerList` to `defaultTrackers()`, and clears every event timer — but keeps `credits`, `prestiges`, `perks`, `totalUp`, and `lifetimeUp`. The currency is spent in the prestige modal on `PERKS`, which carry across resets because they live in `G.perks`, never touched by `doPrestige()`. The two endgame perks — `darknet` and `singularity` — are `once:true` unlocks that gate the highest catalog tiers, so prestige is also the only door to the Exabyte-and-beyond content.
## The diegetic-upgrade pattern
The final law of the DNA: upgrades are bought through the app's own dialogs, never a cartoon shop. There is no "+1 click" button. In qBitTorrz the `UPGRADES` table is configuration — `Network Hardware`, `Cat6 Cable Upgrade`, `UPnP Port Forwarding`, `Automated RSS Filters`, `Quantum Encryption Protocol` — and each row's effect is *legible*: the store card computes and shows the before/after of the actual model term it touches. Buying `bandwidth` literally reads `Gigabit Fiber → 10G Fiber · line 4.40 MB/s → 9.68 MB/s`, because `upgradeCard()` renders the real `hwTierName()` and `lineSpeed()*u.mult`. Two kinds drive the curve: repeatable leveled upgrades (`kind:'mult-up'`, `'hardware'`, `'storage'`, etc., cost `base * growth^level`) and one-time `'unlock'`s (flat `base`, on/off). The player can always answer "where does my next number come from" by reading the dialog — which is Design Law 4, **legible numbers**, made concrete. Every sibling volume inherits this contract: the shop is the Options pane, the formula bar, the `apt install` list, the Rules editor — never a video-game menu.
· · ·
> **If you change the shared scaffolding**
>
> - **Bump `SAVE_KEY`'s `_v` suffix** if the new save shape can't survive `Object.assign` over `freshState()`. A silent shape change behind the same key mis-loads every existing save.
> - **Add every new state field to `freshState()` *and* `sanitizeState()`.** A field that exists in only one is either un-migratable (missing from `freshState`) or un-repairable (missing from `sanitize`).
> - **Keep two clocks straight.** `lastSave` drives `applyOffline()` at boot; `lastTick` drives the `visibilitychange` gap. Reset `lastTick` after crediting a gap, or the next tick double-bills it.
> - **Never widen the `dt` cap to "fix" idle.** The 5 s cap is the *online* safety valve; long gaps are the offline simulator's job. Loosening the cap reintroduces lump-credit and speed overshoot.
> - **Route new UI through event delegation.** Add a `data-act` verb and a `handleAct` case, not a fresh `addEventListener` — or rebuilt modals will quietly stop responding.
> - **Sub-linear prestige stays sub-linear.** `creditsFor`'s `0.42` exponent is what keeps migration a choice. Make the payout linear and the loop collapses into spam-prestige.