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>
21 KiB
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, 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:
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 —
memandcmdare not trusted on load.sanitizeState()rebuilds every proc'smem,cmd, anduserfrom the liveWORKERStable 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-editedlocalStoragecan't smuggle a 9999-yield worker past the table. Any proc whosewkindno longer exists inWORKERSis 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 andG.pidSeqbumped 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 towardcurLoad()andheatGen(), which skipstate==='Z'.) - D yields
0unless you own thepolymorphpackage. Uninterruptible sleep stalls a worker;polymorph-engineis 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 30–55s, then 45–80s) 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:
- Tab autocomplete.
tabComplete()callscompletions(val), which is context-aware: the first token completes againstCMD_NAMES; later tokens complete against the right option list (worker types afterspawn, package names afterapt install, live pids afterkill/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. - The ghost completion.
updateGhost()renders the top completion as dim inline ghost text behind the input;ArrowRightat 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>. - The clickable palette and chips.
openPalette()renders a dropdown of completions with descriptions (argDesc()even prices eachspawntarget and labels zombies). Below the input,renderChips()paints contextual one-click verbs —spawn workeralways,spawn miner/scanner/crackeronce you'reuser, a⚠ kill zombiesCTA when any zombie exists, asudo su ▸ <next>escalation chip,apt install, and anssh ▸ next hostchip whencanPrestige(). Chips withcta:trueglow 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 thekill <pid>command sharekillPid(), 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 (7–11s) specifically when you're a fresh user with zero exploits, to open the sudo path quickly; subsequent drops settle to a 14–30s 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 NOTE —
fmt()short-circuitsInfinity → '∞'andsanitizeState()floors every scalar throughnum(v,d)(which rejects non-finite values), so anInfinityclock never poisons the save — only the live display. That's the right call: clampingclockMulto 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
killflag filter (args.filter(x=>!/^-/.test(x))). Strip signal flags before reading the target, orkill -9 <pid>reads-9as 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()rebuildsmem/cmd/userfromWORKERS. 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. clockMuloverflows toInfinitydeep 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 reachlocalStorageun-sanitized, and never silently cap the ladder.- Scanner zombify rate is hardened (
0.004vs0.012). The exploit pipeline depends on a live scanner. Raise that rate and you can starve the player of the only path tosudo/root.