bitmax/docs/handbook/13-vol-IV-uplink.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

244 lines
21 KiB
Markdown
Raw Permalink 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.

# Vol IV Internals — UPLINK
*How `terminal.html` turns an htop window and a fake shell into an incremental game: the process table that earns cycles, the four gauges that fight back, and the command parser that is the whole interface.*
UPLINK (`SAVE_KEY = "boringsoft_uplink_v1"`) is the fourth volume, and it commits hardest of all five to the **one hook, fully committed** law. The hook is the command line. There is no shop, no upgrade tree, no "buy" button you can find by clicking around blindly — there is a green-on-black prompt, an htop pane above it, and a verb table you discover by typing. Everything the player does routes through `runCommand(raw)`. This chapter walks the model that command line drives: the process records, the R/S/D/Z state machine, the cycles-load-heat economy, and the `ssh` prestige ladder.
The shared DNA from **Vol I — qBitTorrz** is all here — a single `G` global, a `~100ms` tick, `freshState()`/`save()`/`load()`/`sanitizeState()`, `simulateAway()` for offline catch-up, `toast()` for corner notifications. This chapter assumes that scaffolding and digs into what is unique to UPLINK.
## The process model
Your engine is a list of process records living at `G.procs`. Each is a plain object minted by `spawnWorker(kind)`:
```js
const p={pid:G.pidSeq++, sys:false, wkind:kind, cmd:w.cmd, user:G.priv,
mem:w.mem, state:'R', nice:1, bornAt:now()};
```
`G.pidSeq` starts at `1337` (you are PID 1337) and only ever increments — every fork burns a new pid, never reused within a run. The fields:
| Field | Meaning | Source |
|---|---|---|
| `pid` | unique id, from `G.pidSeq++` | seeded `1337` in `freshState()` |
| `sys` | `false` for your procs; `true` for flavor procs | — |
| `wkind` | worker archetype key into `WORKERS` | `spawn`/`fork` arg |
| `cmd` | display command string | `WORKERS[kind].cmd` |
| `user` | owning privilege; rewritten on escalation | `G.priv` |
| `mem` | MB footprint (fixed per archetype) | `WORKERS[kind].mem` |
| `state` | `R` / `S` / `D` / `Z` | drifts each tick |
| `nice` | yield/load/heat multiplier, `1``4` | `reniceePid` |
| `bornAt` / `zAt` | fork time; zombification time | timestamps |
There are six worker archetypes in `WORKERS`. Each has a base `yield` (work units per second, before the clock multiplier), `heat` (°C/s), `mem`, `load`, `cost`, and a `minPriv` gate:
| key | `cmd` | yield | heat | mem | load | cost | minPriv |
|---|---|---|---|---|---|---|---|
| `worker` | `cpu_worker` | 9 | 0.18 | 24 | 1.0 | 16 | guest |
| `miner` | `cryptominer` | 54 | 0.62 | 96 | 1.7 | 120 | user |
| `cracker` | `hashcracker` | 260 | 1.05 | 180 | 2.4 | 900 | user |
| `scanner` | `vuln_scanner` | 34 | 0.30 | 64 | 1.2 | 220 | user |
| `botnet` | `botnet_node` | 1800 | 1.7 | 420 | 3.2 | 1.8e4 | sudo |
| `kernel` | `kthread_miner` | 1.3e4 | 2.6 | 760 | 4.4 | 2.2e5 | root |
The `WORKER_ALIASES` map lets the player type either the short key (`miner`) or the real command (`cryptominer`) — both resolve through `spawn`.
Forking the same archetype again is deliberately self-limiting. `workerCost(kind)` multiplies the base cost by `1.16` per live instance of that type:
```js
const owned=G.procs.filter(p=>p.wkind===kind).length;
return Math.ceil(w.cost * Math.pow(1.16, owned));
```
So flooding one generator has diminishing returns. The intended engine is a *mix* — and the mix is gated by privilege, by `maxProcs()`, and by the load and heat ceilings.
Alongside your procs sits `G.sysProcs` — seven static `SYS_PROCS` entries (`/sbin/init`, `systemd-journald`, `sshd: /usr/sbin`, `[kworker/0:1]`, `cron`, `rsyslogd`, `auditd --watch`). They are pure texture: rendered dimmed in htop, jittering CPU for atmosphere, earning nothing. `freshHostProcs()` rebuilds them on every host, with pids from `nextSysPid(i)` (`100 + i*7 + i`) so they never collide with your 1337+ range.
> **DEV NOTE** — `mem` and `cmd` are *not trusted* on load. `sanitizeState()` rebuilds every proc's `mem`, `cmd`, and `user` from the live `WORKERS` table rather than from the save: `mem:WORKERS[p.wkind].mem, cmd:WORKERS[p.wkind].cmd`. This means a save written before a balance change picks up the new numbers automatically, and a hand-edited `localStorage` can't smuggle a 9999-yield worker past the table. Any proc whose `wkind` no longer exists in `WORKERS` is simply dropped (`G.procs.filter(p=>p&&WORKERS[p.wkind])`). The pid is one of the few fields trusted, and even it gets de-duped and `G.pidSeq` bumped past the max.
## The state machine and the zombie mechanic
Process `state` is a four-value enum: **R** (running), **S** (sleeping), **D** (uninterruptible sleep), and **Z** (zombie/defunct). `driftProcesses(dt)` re-rolls states roughly 2.5×/s (it accumulates `dt` into `_driftAccum` and only acts above `0.4`s). On each pass, a live proc first rolls for zombification, then — if it survived — wanders between R/S/D:
```js
const zChance = (p.wkind==='scanner'?0.004:0.012) * elapsed;
if(Math.random() < zChance){ p.state='Z'; p.zAt=now(); continue; }
const r2=Math.random();
if(r2<0.55) p.state='R'; else if(r2<0.86) p.state='S'; else p.state='D';
```
State is load-bearing for earnings, via `procYield(p)`:
- **Z** yields `0`. A zombie is dead weight — it still occupies a proc slot but earns nothing. (It also stops counting toward `curLoad()` and `heatGen()`, which skip `state==='Z'`.)
- **D** yields `0` *unless* you own the `polymorph` package. Uninterruptible sleep stalls a worker; `polymorph-engine` is the upgrade that makes D-state procs keep earning.
- **R** and **S** yield normally.
Scanners are hardened (`0.004` vs `0.012` zombify chance) on purpose — the comment is explicit: "Scanners are hardened (they re-fork on death) so the exploit pipeline stays reliable." You never want the exploit supply to dry up because your one scanner went defunct.
**The zombie leak/reap loop** is the volume's recurring chore. Beyond the ambient drift, `handleEvents()` runs a heavier burst timer (`G.nextZomb`, every 3055s, then 4580s) that zombifies a random live worker outright and toasts you. Zombies accumulate until *reaped*. The player reaps manually with `kill <pid>`, `kill zombies`, or `kill z`; or automates it. `autoReap()` splices out every `Z` proc and runs only when `hasPkg('cron') || hasPerk('sentinel')` is true:
```js
function autoReap(){
for(let i=G.procs.length-1;i>=0;i--){ if(G.procs[i].state==='Z'){ G.procs.splice(i,1); } }
...
}
```
So the early game is "babysit your zombies"; the `cron-daemon` package (or the permanent `sentinel` perk) buys that chore away forever. Note the asymmetry with the **idle contract**: zombies still spawn offline (`simulateAway` has a slow zombify path), but at a trickle, so you come back to cleanup rather than a graveyard.
## The cycles economy: clock, cores, load, and heat
The currency is **CPU cycles** (`G.cycles`), accrued in `step(dt)` from `totalYield()`, the sum of every proc's `procYield`. Yield is the chain that turns archetype work units into Hz:
```js
let base = w.yield * (p.nice||1) * yieldMult();
base *= (baseClock()/1000); // clock turns work units into Hz
if(now()<G.overheatUntil && !hasPkg('thermal')) base*=0.35; // throttle
```
`baseClock()` is the global cycle multiplier: `1000 * host().clockMul`, then `overclock` (×1.22/level), `turbo` (×1.4/level), and the prestige `silicon` perk (×1.15/level) stacked multiplicatively. `yieldMult()` folds in `cfs-scheduler` (×1.18/level) and the `fab` perk (×1.20/level). Because clock and yield are *global* multipliers applied to every proc, the late-game leverage is in the multipliers, not the proc count.
Two gauges fight back. Both are recomputed every tick:
**System Load** (`G.load`) is instantaneous — `curLoad()` sums `w.load*(p.nice||1)` over live procs. The ceiling is `loadCeiling()`:
```js
return (3 + cores()*3) * Math.pow(PKG_BY_ID.ulimit.mult, lvl('ulimit'));
```
Host 0's 2 cores give a ceiling of ~9 — room for a few workers, punishing for a fork-bomb. `ulimit-tuning` raises it ×1.22/level. **Heat** (`G.heat`) is integrated, not instantaneous: `G.heat += (heatGen() - dissipate)*dt`, floored at the host's `heatBase` and capped at `heatCeiling()` (host base + 72, plus `cooling` and the `heatsink` perk). Dissipation rises with `cooling` level and gets a 60% bonus when load is zero.
**The OOM killer** is the load failure mode. In `step()`:
```js
if(G.load>lc){
G.oomTimer=(G.oomTimer||0)+dt;
if(G.oomTimer>=1.4){ G.oomTimer=0; oomKill(); }
}
```
Cross the load ceiling and hold it for 1.4 seconds, and `oomKill()` culls a process — specifically the **highest-memory** live proc you own (it scans for `WORKERS[p.wkind].mem` max), which is usually your fattest, most valuable worker. That is the design: a runaway is punished by losing exactly the proc you least want to lose.
**Overheat** is the heat failure mode and it is gentler — it doesn't kill, it throttles. When `G.heat` hits the ceiling, `G.overheatUntil` is set 4 seconds out and yield is multiplied by `0.35` until it cools (unless you own `thermald-ai`, which ignores the penalty entirely).
> **DEV NOTE** — Load is instantaneous but heat is integrated, and that difference is the whole feel of the gauge pair. Load snaps the moment you fork or kill; you can dance right at the ceiling if you're quick. Heat has thermal mass — it climbs and falls over seconds, so a brief hot spike is survivable but a sustained hot engine bakes. If you ever "fix" heat to be instantaneous like load, the game loses its only gauge with hysteresis and both gauges start feeling identical.
## The command layer — the signature interaction
Everything funnels through `runCommand(raw)`. It trims, echoes the line at the prompt via `ps1Text()`, pushes to `G.history`, tokenizes on whitespace, and `switch`es on the lowercased verb. The canonical verbs live in `COMMANDS` (used for `help`, `man`, and autocomplete) — `spawn`/`fork`, `renice`, `kill`, `apt`, `sudo`, `ssh`, `htop`/`top`, `ps`, `free`, `ls`, `whoami`, `netmap`, `clear`, `help`, `man` — plus a tail of Easter-egg verbs (`uname`, `cat`, `sl`, `rm -rf /`, `exit`) that print flavor and earn nothing.
Three affordances make the command line usable without memorizing it:
1. **Tab autocomplete.** `tabComplete()` calls `completions(val)`, which is context-aware: the first token completes against `CMD_NAMES`; later tokens complete against the right option list (worker types after `spawn`, package names after `apt install`, live pids after `kill`/`renice`, etc.). One match → it fills in and adds a trailing space if the verb needs an argument. Multiple → it fills the longest common prefix (`longestCommonPrefix`) and opens the palette.
2. **The ghost completion.** `updateGhost()` renders the top completion as dim inline ghost text behind the input; `ArrowRight` at end-of-line accepts it. This is the inline-suggestion pattern from a real shell, reimplemented in a `<div id="ghost">` overlaid on the `<input>`.
3. **The clickable palette and chips.** `openPalette()` renders a dropdown of completions with descriptions (`argDesc()` even prices each `spawn` target and labels zombies). Below the input, `renderChips()` paints contextual one-click verbs — `spawn worker` always, `spawn miner`/`scanner`/`cracker` once you're `user`, a `⚠ kill zombies` CTA when any zombie exists, a `sudo su ▸ <next>` escalation chip, `apt install`, and an `ssh ▸ next host` chip when `canPrestige()`. Chips with `cta:true` glow amber when affordable.
The chips are the safety net for the player who never reads `help`: the next meaningful action is almost always a glowing chip away.
## Packages, privilege, renice, and kill
**Privilege** is a four-rung ladder: `PRIV = ['guest','user','sudo','root']`. `tryEscalate()` (bound to `sudo su` and `su`) spends both cycles and exploits per `ESCALATE`:
| into | cycles | exploits | from |
|---|---|---|---|
| `user` | 800 | 0 | guest |
| `sudo` | 2.2e4 | 1 | user |
| `root` | 2.5e6 | 2 | sudo |
Escalation rewrites `p.user` on every owned proc and unlocks higher `minPriv` workers and packages. You cannot escalate past `root` on a host — the game tells you to `ssh`.
**apt packages** (the `PACKAGES` array, surfaced by `openApt()` / `apt install <pkg>`) are the upgrades. Leveled packages cost `base * growth^level` (`pkgCost`); `unlock` packages are one-time at `base`:
| id | name | tier | effect |
|---|---|---|---|
| `coreutils` | coreutils | guest | +1 to `maxProcs()` ceiling per level (max 12) |
| `overclock` | overclock | guest | ×1.22 base clock per level (max 25) |
| `scheduler` | cfs-scheduler | user | ×1.18 yield per proc per level (max 25) |
| `cooling` | lm-sensors+fancontrol | user | +18°C heat ceiling, faster dissipation (max 24) |
| `ulimit` | ulimit-tuning | user | ×1.22 load ceiling per level (max 20) |
| `turbo` | turbo-boost | sudo | ×1.4 base clock per level (max 20) |
| `cron` | cron-daemon | user | unlock: auto-reap zombies |
| `autospawn` | spawn.timer | sudo | unlock (needs `cron`): auto-fork best affordable worker |
| `rootkit` | rootkit-lkm | sudo | unlock: hides you, stops the admin kill-spree |
| `polymorph` | polymorph-engine | sudo | unlock (needs `rootkit`): D-state procs keep earning |
| `thermal` | thermald-ai | root | unlock (needs `cooling`): heat never throttles |
| `distcc` | distcc-farm | root | +12% offline/passive earnings per level (max 20) |
`autospawn` is the automation capstone: `autoSpawn()` gates itself to once per 1.5s and forks the best affordable worker (`['kernel','botnet','cracker','miner','worker','scanner']` order) that keeps load under 92% of the ceiling — so the engine plays itself within the safety margins you've bought.
**renice** (`reniceePid`) bumps a single proc's `nice` by `0.6` up to a cap of `4`, multiplying its yield — and its load and heat — for a cost of `WORKERS[p.wkind].cost * 2 * nice`. It's the lever for squeezing one prized proc instead of forking another. Zombies refuse renice ("kill it instead").
**kill** is where a real post-mortem bug lived. `kill` accepts an optional signal flag, and the parser must strip it before reading the target:
```js
const kargs=args.filter(x=>!/^-/.test(x));
const a=(kargs[0]||'').toLowerCase();
...
const pid=parsePid(kargs[0]);
```
The fix is that `kargs` filters out any `-`-prefixed token, so `kill -9 1337` correctly targets `1337`. The original parser read `args[0]` directly — meaning the `-9` *was* the target, `parsePid('-9')` produced `-9`, and the kill silently no-op'd against "No such process." A player typing the most muscle-memory'd command in the Unix world (`kill -9 <pid>`) found it did nothing. See **QA Playbook & Post-Mortems** for the full write-up of this one; it is the canonical example of "test the input the user will actually type, not the input your parser expects."
> **DEV NOTE** — The htop `[x]` button and the `kill <pid>` command share `killPid()`, but they reach it by different paths. The button passes a real integer (`parseInt(kb.dataset.kill,10)`), so it was never affected by the signal-flag bug — only the typed command was. That's why the bug survived casual play-testing: anyone clicking the kill button saw it work fine. Two entry points to one action means two test cases, always.
## Exploits and the scanner
**Exploits** (`G.exploits`) are the second currency, spent on escalation and prestige. The only source is a running `vuln_scanner`. `handleEvents()` checks for live scanners and schedules drops on `G.nextScanDrop`:
```js
const scanners=G.procs.filter(p=>p.wkind==='scanner' && p.state!=='Z').length;
if(scanners>0){
if(G.nextScanDrop===0) G.nextScanDrop=t+(G.exploits===0&&G.priv==='user'?rand(7000,11000):rand(14000,26000))/scanners;
if(t>=G.nextScanDrop){ G.nextScanDrop=t+rand(16000,30000)/Math.max(1,scanners); G.exploits++; ... }
}
```
The first exploit comes fast (711s) specifically when you're a fresh `user` with zero exploits, to open the sudo path quickly; subsequent drops settle to a 1430s cadence, divided by the scanner count so more scanners means faster drops. Each drop toasts a fake `CVE-20XX-XXXX`. With no live scanner, `G.nextScanDrop` resets to `0` and the pipeline stalls — which is why scanner hardening (lower zombify rate) matters.
## Prestige: the ssh pivot and the host ladder
Prestige is `ssh`. You must own `root`, hold ≥1 exploit, and have mined enough this run for `keysFor(G.runPeak)` to return ≥1:
```js
function keysFor(peak){ if(peak<1e8) return 0; return Math.floor(Math.pow(peak/1e8, 0.40)); }
function canPrestige(){ return G.priv==='root' && G.exploits>=1 && keysFor(G.runPeak)>=1; }
```
The `1e8` floor (~100 MHz peak) makes the first pivot *earned* — a few minutes at root scaling up — and the `0.40` exponent makes keys grow sub-linearly, so deeper hosts pay more but never trivially. `doPrestige()` grants the keys, burns one exploit, increments `G.hostIdx`, then resets the run: cycles to `40`, procs cleared, privilege back to `guest` (or `user` with the `genesis` perk), heat to the new host's base. **Root keys** (`G.keys`) and **perks** (`G.perks`) persist forever.
Keys buy `PERKS`: `fab` (+20% yield/level), `silicon` (+15% clock/level), `heatsink` (+12°C ceiling/level), `preboot` (boot with +1 `cpu_worker`/level), the one-time `sentinel` (free permanent zombie auto-reap), and the one-time `genesis` (start every host at `user` with +1 exploit; requires `sentinel`).
The hosts you pivot through are the `HOSTS` ladder — cores and `clockMul` climb, `heatBase` rises to make each box nastier:
| idx | ip | name | cores | clockMul | heatBase |
|---|---|---|---|---|---|
| 0 | 127.0.0.1 | localhost (your laptop) | 2 | 1 | 28 |
| 1 | 10.0.0.12 | devbox-staging | 4 | 6 | 32 |
| 2 | 10.0.4.40 | build-runner-07 | 8 | 40 | 36 |
| 3 | 192.168.9.2 | db-primary | 16 | 260 | 40 |
| 4 | 172.16.30.1 | k8s-node-pool | 32 | 1800 | 44 |
| 5 | 45.77.12.88 | colo-rack-A12 | 64 | 1.3e4 | 48 |
| 6 | 8.8.8.8 | edge-cdn-cluster | 128 | 9e4 | 52 |
| 7 | aws://us-east | the cloud (autoscaling) | 256 | 7e5 | 56 |
Past the table, `hostAt(i)` extends procedurally — the **Omega hosts**:
```js
return {ip:'10.'+(13+k)+'.0.1', name:'datacenter-Ω-'+(k+1), cores:base.cores*Math.pow(2,k),
clockMul:base.clockMul*Math.pow(8,k), heatBase:56+k*4};
```
Cores double and `clockMul` grows ×8 per Omega tier off the last table entry. This is the intended infinite tail — and it is also the **overflow note**: `clockMul` is `7e5 * 8^k`, so it crosses `Number.MAX_SAFE_INTEGER` around the eleventh Omega host and eventually overflows to `Infinity`. Once `host().clockMul` is `Infinity`, `baseClock()` is `Infinity`, every `procYield` is `Infinity`, and `fmt()` prints `∞`. The game doesn't crash — `fmt` and `keysFor` handle non-finite input — but the numbers stop being numbers. A player who pivots that far has effectively reached the top of the ladder: the substrate so large it is indistinguishable from no substrate at all.
> **DEV NOTE** — `fmt()` short-circuits `Infinity → '∞'` and `sanitizeState()` floors every scalar through `num(v,d)` (which rejects non-finite values), so an `Infinity` clock never poisons the *save* — only the live display. That's the right call: clamping `clockMul` to a "safe" max would silently cap the prestige ladder, whereas letting it bloom to `∞` and rendering it honestly turns the overflow into the secret ending rather than a bug. If you change the host growth curve, keep that property — the overflow should be reachable and legible, not a console error.
· · ·
### If you change this
- **The `kill` flag filter** (`args.filter(x=>!/^-/.test(x))`). Strip signal flags before reading the target, or `kill -9 <pid>` reads `-9` as the pid and no-ops. The htop `[x]` button bypasses the parser and will keep working, hiding the regression — test the *typed* command. (Full post-mortem in **QA Playbook & Post-Mortems**.)
- **`sanitizeState()` rebuilds `mem`/`cmd`/`user` from `WORKERS`.** If you add a per-proc field that should survive a balance change, decide deliberately whether it's trusted (loaded from save) or derived (rebuilt). Untrusted-by-default is the safer instinct here.
- **Load is instantaneous; heat is integrated.** Don't unify them. The hysteresis on heat is the only thing keeping the two gauges from feeling redundant.
- **The OOM killer targets max-`mem`, not random.** It deliberately culls your best proc. A "fairer" random victim removes the bite from crossing the load ceiling.
- **`clockMul` overflows to `Infinity` deep in the Omega tail.** That's intended and handled (`fmt → '∞'`, save scalars floored finite). If you re-tune host growth, preserve graceful overflow — never let a non-finite value reach `localStorage` un-sanitized, and never silently cap the ladder.
- **Scanner zombify rate is hardened (`0.004` vs `0.012`).** The exploit pipeline depends on a live scanner. Raise that rate and you can starve the player of the only path to `sudo`/`root`.