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

21 KiB
Raw Permalink Blame History

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):

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, 14 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:

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 NOTEmem 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.4s). On each pass, a live proc first rolls for zombification, then — if it survived — wanders between R/S/D:

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:

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:

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():

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():

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 switches 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:

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:

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:

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:

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 NOTEfmt() 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.