# 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 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 `, `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()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 `
` overlaid on the ``.
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 ▸ ` 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 `) 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 `) 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 ` 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 (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:
```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 ` 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`.