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>
244 lines
21 KiB
Markdown
244 lines
21 KiB
Markdown
# 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 <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 (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 <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`.
|