From 8030547e3c819ecdadec6c9d219c44a801d99d9d Mon Sep 17 00:00:00 2001 From: type-two Date: Sun, 2 Aug 2026 15:46:18 +1000 Subject: [PATCH] Philes you can read, a save, and MTU that costs you something MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three holes closed: philes — 40 written, all in src/philes.js so the next 160 is a writing job and not a code job. P opens a reader: list on the left, the text on the right, attributed to whoever wrote it in whatever year they wrote it. Every level now drops at least one; previously you collected files you could never open. save — ESTABLISHED. Every hop writes level/ttl/hop/baud/philes/ cargo/flags to localStorage, and the boot screen offers R — RESUME. A 64-hop game with no save is not a 64-hop game. MTU — the HUD has been showing your MTU drop as you take files off the board while nothing happened. Now two files are free and after that you arrive in pieces, one TTL per extra fragment, every hop. You can put things down again from the traceroute map. Lore costs distance, which is the economy the whole design is built on. All 13 hops re-swept: clean. Co-Authored-By: Claude Opus 5 --- README.md | 18 +- index.html | 32 +++ src/levels/bluebox.js | 1 + src/levels/coldboot.js | 1 + src/levels/copper.js | 5 + src/levels/cuckoo.js | 2 +- src/levels/handshake.js | 6 +- src/levels/smash.js | 2 +- src/levels/stack.js | 2 + src/levels/timewait.js | 2 + src/levels/war.js | 1 + src/levels/worm.js | 1 + src/main.js | 137 +++++++++- src/philes.js | 583 ++++++++++++++++++++++++++++++++++++++++ 14 files changed, 776 insertions(+), 17 deletions(-) create mode 100644 src/philes.js diff --git a/README.md b/README.md index 0c187a6..2e55d2b 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,14 @@ hard and it makes every edit look like it did nothing. | `SPACE` | jump / hold 2600 Hz | | mouse | look (click to capture; dragging works too) | | `E` | use | -| `M` | traceroute — the level select, click any hop you've reached | +| `M` | traceroute — the level select, and where you drop cargo | +| `P` | philes — read what you've picked up | | `` ` `` | hop select (dev) | | `ESC` | release the mouse | +It saves. Every hop completes a handshake with the world and writes to +localStorage; the boot screen offers `R — RESUME` when there's something to resume. + ## What's here Thirteen playable hops, each a different genre, one renderer. @@ -64,6 +68,14 @@ this for real. [`src/kit.js`](src/kit.js). Seven wireframe shells, one per OSI layer, shed as you descend. +## MTU + +Files taken off the BBS are lore, and lore is paid for in distance. Two is free; +after that you don't fit down the next hop in one piece and every hop costs a TTL +per extra fragment. You can put things down again from the traceroute map. That's +the whole economy: everything worth reading costs you some of the distance you have +left. + ## Audio [`src/audio.js`](src/audio.js) — every tone is oscillators, nothing is sampled, and @@ -101,7 +113,7 @@ to zero in a hidden tab and that makes the game look broken when it's only aslee ## Skipped - **Multiplayer (`CHECKSUM`)** — the Journey-style anonymous pass-by. Needs a server; add when there's something to connect to. -- **MTU fragmentation** — BBS cargo lowers your MTU and it's on the HUD, but nothing splits you into two packets yet. Add when a level has a corridor narrow enough to need it. -- **The 200 philes** — the collectible framework is live (`ctx.phile`, `HS.philes`) with ~20 written. The rest is a writing job, not a code job. +- **Playing both fragments** — fragmenting costs TTL, it doesn't split you into two packets you alternate between. Add when a level has a corridor narrow enough to need it. +- **The 200 philes** — 40 written in [`src/philes.js`](src/philes.js), all readable with `P`. Adding the next 160 is a writing job, not a code job: put the text in that file, call `ctx.phile('id')` wherever it should be found. - **Trade Wars** — the second door game. LORD is real and playable; TW2002 is "closed for maintenance since 1994", which is both a joke and true. - Levels are one hop's worth of content each, not the full 5–20 minutes the design doc describes. The genres, mechanics and set pieces are all in — the *duration* isn't. diff --git a/index.html b/index.html index a4c2efa..0bf38fe 100644 --- a/index.html +++ b/index.html @@ -95,6 +95,36 @@ #boot .keys k{color:#5b8494} #cd{color:#ff4a5e;font-size:10px;letter-spacing:.3em;transition:opacity .2s} + .row .ms{color:#2a3f48;font-size:11px} + .cargo{margin-top:14px;font-size:11px;color:#5b8494;letter-spacing:.08em;line-height:2} + .cargo .bad{color:#ffb648} + .cargo .drop{color:#7fa8b8;border:1px solid #1e3a45;border-radius:3px; + padding:2px 7px;cursor:pointer;display:inline-block;margin:2px 3px 0 0} + .cargo .drop:hover{color:#ff4a5e;border-color:#5c1a22;background:#1a0a0d} + + /* ── the philes ────────────────────────────────────────────── */ + #reader{position:fixed;inset:0;z-index:12;display:none;align-items:center;justify-content:center; + background:#000000f2;backdrop-filter:blur(3px);} + .rd{width:min(1000px,94vw);height:min(76vh,720px);display:flex;flex-direction:column;font-size:13px} + .rd .hdr{color:#4d6b78;letter-spacing:.2em;margin-bottom:12px;text-transform:uppercase;font-size:11px} + .rd .ftr{color:#3d5560;margin-top:12px;font-size:11px;letter-spacing:.1em} + .rd .dim{color:#2a3f48} + .rd .empty{color:#4d6b78;line-height:2;padding:40px 0} + .rd .cols{display:flex;gap:26px;flex:1;min-height:0} + .rd .list{width:28ch;overflow-y:auto;border-right:1px solid #14252d;padding-right:14px;flex:none} + .rd .it{color:#5b8494;padding:4px 6px;cursor:pointer;border-radius:3px;line-height:1.4; + font-size:12px;letter-spacing:.02em} + .rd .it:hover{background:#0c1a20;color:#c9f4ff} + .rd .it.on{background:#12252d;color:#eafcff} + .rd .body{flex:1;overflow-y:auto;padding-right:8px} + .rd .body h2{margin:0 0 2px;font-size:17px;color:#eafcff;letter-spacing:.08em;font-weight:600} + .rd .body .from{color:#3d5560;font-size:11px;letter-spacing:.14em;margin-bottom:16px} + .rd .body pre{margin:0;white-space:pre-wrap;color:#a8ccd8;line-height:1.75; + font-family:var(--mono);font-size:13px} + + #resume{color:#ffb648;letter-spacing:.14em;font-size:11px;margin-top:6px} + #resume b{color:#ffe14a} + /* ── dark ink, for the levels that are white ───────────────── */ body.ink-dark #hud{color:#8a9aa6;text-shadow:0 1px 0 #fff} body.ink-dark #ttl{color:#14232c} @@ -125,6 +155,7 @@
+
@@ -135,6 +166,7 @@ M traceroute   ESC release mouse   ` hop select

headphones. every frequency in this is the real frequency.
+
CLICK OR PRESS ANY KEY TO DIAL
▮ NO CARRIER
diff --git a/src/levels/bluebox.js b/src/levels/bluebox.js index 30591cd..f926246 100644 --- a/src/levels/bluebox.js +++ b/src/levels/bluebox.js @@ -292,6 +292,7 @@ export default { S.hum.stop(0.3); A.relayClick(0.35, 0.5); ctx.link.glitch = 0; + ctx.phile('inband'); ctx.say('trunk seized the far end let go. the near end still thinks you are talking.', 5000); if (STAGES[S.stage] && STAGES[S.stage].id === 'seize') setTimeout(() => this.advance(ctx), 2400); } diff --git a/src/levels/coldboot.js b/src/levels/coldboot.js index 06f1528..37d1db1 100644 --- a/src/levels/coldboot.js +++ b/src/levels/coldboot.js @@ -161,6 +161,7 @@ export default { // walk far enough out and the ring notices you are missing if (!S.strayed && Math.hypot(body.pos.x, body.pos.z) < 30) { S.strayed = true; + ctx.phile('ring-buffer'); ctx.say('you have left your slot. this has never mattered before.', 3800); } diff --git a/src/levels/copper.js b/src/levels/copper.js index bd6ba1b..01b3d06 100644 --- a/src/levels/copper.js +++ b/src/levels/copper.js @@ -76,6 +76,7 @@ export default { w.rotation.x = Math.PI / 2; w.position.set(x, 0.4 + r * 0.45, z); scene.add(w); } } + S.coilAt = new THREE.Vector3(-4, 0, -52); const coilSign = panel('LOAD COILS · 88 mH · every 6000 ft', { color: '#c08040', size: 0.3 }); coilSign.position.set(-6.6, 3.4, -46); coilSign.rotation.y = Math.PI / 2; scene.add(coilSign); @@ -233,6 +234,10 @@ export default { S.banks.instanceMatrix.needsUpdate = true; } + if (!S.readCoil && Math.hypot(b.pos.x - S.coilAt.x, b.pos.z - S.coilAt.z) < 8) { + S.readCoil = true; ctx.phile('loadcoil'); + } + // the plaque if (Math.hypot(b.pos.x + 19.4, b.pos.z + 150) < 6 && !S.readPlaque) { S.readPlaque = true; diff --git a/src/levels/cuckoo.js b/src/levels/cuckoo.js index bd5f23a..a0fb45c 100644 --- a/src/levels/cuckoo.js +++ b/src/levels/cuckoo.js @@ -125,7 +125,7 @@ export default { term.writeln(''); await term.paint('|07 ' + d.body.replace(/\n/g, '\n ') + '\n', 900); S.read++; - ctx.phile('cuckoo-' + arg, d.title, d.body); + ctx.phile(d.bait ? 'sdinet' : 'cuckoo-' + arg, d.title, d.body); if (d.bait && !S.baited) { S.baited = true; S.lines = Math.max(1, Math.round(S.lines * 0.35)); diff --git a/src/levels/handshake.js b/src/levels/handshake.js index a97d0bd..fe2bdf0 100644 --- a/src/levels/handshake.js +++ b/src/levels/handshake.js @@ -211,7 +211,7 @@ const BOSS = { } } }, - win(ctx) { nextPhase(ctx, 'it is ringing.'); }, + win(ctx) { ctx.phile('dtmf'); nextPhase(ctx, 'it is ringing.'); }, exit() {}, }, @@ -272,7 +272,7 @@ const BOSS = { if (b.hits >= b.need) this.win(ctx); } }, - win(ctx) { S.b.tone.stop(); nextPhase(ctx, 'it has stopped asking politely.'); }, + win(ctx) { S.b.tone.stop(); ctx.phile('ansam'); nextPhase(ctx, 'it has stopped asking politely.'); }, exit() { S.b.tone?.stop(); }, }, @@ -289,6 +289,7 @@ const BOSS = { core.position.y = 2.6; S.arena.add(core); S.b.core = core; S.b.tag = sprite('PROBE', { color: '#ffd9a8', size: 1.6 }); S.b.tag.position.set(0, 8, -19); S.arena.add(S.b.tag); + ctx.phile('v8bis'); ctx.say('it is asking what you can do. it will only ask once.', 4200); }, update(dt, t, ctx) { @@ -360,6 +361,7 @@ const BOSS = { const proto = caps >= 5 ? '/V34' : caps >= 4 ? '/V32B' : caps >= 3 ? '/V32' : caps >= 2 ? '/V22B' : ''; const comp = caps >= 4 ? '/LAPM/V42BIS' : ''; await ctx.card(`CONNECT ${CAPS[caps].rate}`, `${CAPS[caps].tag}${arq}${proto}${comp}`, 3400); + ctx.phile('connect'); if (caps <= 1) ctx.say('you are going to see the rest of this in letters. honestly, it suits it.', 4600); else if (caps >= 6) ctx.say('everything. it agreed to everything. do not get used to it.', 4200); nextPhase(ctx, null); diff --git a/src/levels/smash.js b/src/levels/smash.js index d7aad73..4f1f531 100644 --- a/src/levels/smash.js +++ b/src/levels/smash.js @@ -227,7 +227,7 @@ export default { ctx.say('there is nothing down there for you yet. write the address first.', 4000); } if (b.grounded && b.ground && b.ground.kind === 'nop') { - if (!S.sliding) { S.sliding = true; ctx.say('0x90 0x90 0x90 0x90 0x90 0x90', 4200); } + if (!S.sliding) { S.sliding = true; ctx.phile('nopsled'); ctx.say('0x90 0x90 0x90 0x90 0x90 0x90', 4200); } b.vel.x = Math.max(b.vel.x, 9); // you do not walk a sled. you arrive on it. if (Math.random() < 0.25) A.blip(1400 + Math.random() * 200, 0.008, 0.02); } diff --git a/src/levels/stack.js b/src/levels/stack.js index 69f30a0..d183015 100644 --- a/src/levels/stack.js +++ b/src/levels/stack.js @@ -323,6 +323,7 @@ export default { } if (!F.done && b.pos.z < -22) { F.done = true; + ctx.phile('firewall'); (async () => { await ctx.card('FIREWALL', 'stateful firewalls trust the return leg', 2800); ctx.next(); @@ -340,6 +341,7 @@ export default { A.burst(660 + S.i * 90, 990 + S.i * 90, 0.28, 0.16); (async () => { if (shed) ctx.say(`shed ${shed}`, 2200); + if (S.i === 0) ctx.phile('osi'); await ctx.sleep(700); S.i = nxt; S.spawn = S.body.pos.clone(); diff --git a/src/levels/timewait.js b/src/levels/timewait.js index de3da4b..936b510 100644 --- a/src/levels/timewait.js +++ b/src/levels/timewait.js @@ -228,11 +228,13 @@ export default { ctx.say(line, 3600, d.name); near.spoken++; S.cool = 0.5; + if (near !== S.fin) ctx.phile('timewait'); A.blip(420 + Math.random() * 120, 0.02, 0.02); if (near === S.fin && near.spoken >= FIN_LINES.length && !S.finDone) { S.finDone = true; S.fin.walking = true; + ctx.phile('fin'); ctx.hint(''); setTimeout(() => { ctx.say('the door does open. it opens for people who are leaving.', 4200, 'FIN'); diff --git a/src/levels/war.js b/src/levels/war.js index 10f3daf..f25be23 100644 --- a/src/levels/war.js +++ b/src/levels/war.js @@ -225,6 +225,7 @@ export default { } S.ghost.visible = true; S.ghost.position.set(S.splitDir * (R * 0.55), 1.5, (Math.random() - 0.5) * R); + ctx.phile('irc'); ctx.say('*** netsplit. that half of the network is gone. go and stand in it.', 5000); }, diff --git a/src/levels/worm.js b/src/levels/worm.js index b69c182..153051c 100644 --- a/src/levels/worm.js +++ b/src/levels/worm.js @@ -92,6 +92,7 @@ export default { S.vec = VECTORS.indexOf(v); A.blip(900, 0.04, 0.05); ctx.say(`${v.name} ${v.desc}`, 3000); + if (v.name === 'fingerd') ctx.phile('fingerd'); } const V = VECTORS[S.vec]; S.vecTag.userData.setText(`${V.name} — 1 fingerd 2 sendmail 3 rsh`, '#63ffa2'); diff --git a/src/main.js b/src/main.js index 79237a7..7818928 100644 --- a/src/main.js +++ b/src/main.js @@ -6,6 +6,7 @@ import * as In from './input.js'; import * as HUD from './hud.js'; import { Link } from './link.js'; import { World } from './kit.js'; +import { lookup as phileLookup, TOTAL as PHILE_TOTAL } from './philes.js'; import coldboot from './levels/coldboot.js'; import stack from './levels/stack.js'; @@ -66,6 +67,15 @@ function refreshHud() { }); } +// How many pieces you arrive in. Two files is free; after that the network starts +// charging you for the privilege of being interesting. +export function fragments() { + game.mtu = Math.max(400, 1500 - game.cargo.length * 180); + if (game.cargo.length <= 2) return 1; + return 1 + Math.ceil((game.cargo.length - 2) / 3); +} +let fragNote = null; + // TTL only ever goes down. There is no item for this. export function spendTTL(n = 1, why = '') { game.ttl -= n; @@ -110,9 +120,10 @@ const ctx = { phile(id, title, body) { if (game.philes.has(id)) return false; game.philes.add(id); - HUD.say(`phile ${title} [${game.philes.size}/200]`, 2600); + const p = phileLookup(id, title, body); + HUD.say(`phile ${p.title} [${game.philes.size}/${PHILE_TOTAL}]   P to read`, 3000); A.blip(1800, 0.05, 0.05); setTimeout(() => A.blip(2400, 0.06, 0.05), 70); - phileLog.push({ id, title, body }); + phileLog.push(p); return true; }, }; @@ -157,6 +168,17 @@ export async function goto(id, { restart = false } = {}) { game.reached.add(id); if (!restart) game.hop = nextLevel.hop; game.ttl = Math.max(1, Math.min(game.ttl, 65 - game.hop)); + + // ── MTU. Carry too much and you do not fit down the next hop in one piece. + // Everything you took off the board is lore, and lore is paid for in distance. + if (!restart) { + const frags = fragments(); + if (frags > 1) { + game.ttl -= (frags - 1); + fragNote = `fragmented ${game.mtu} byte MTU · ${frags} pieces · −${frags - 1} TTL`; + if (game.ttl <= 0) { expire(); switching = false; return; } + } else fragNote = null; + } if (nextLevel.baud !== undefined && nextLevel.baud !== null) link.setBaud(nextLevel.baud); link.noTint(); if (nextLevel.tint) link.tint(nextLevel.tint[0], nextLevel.tint[1]); @@ -170,6 +192,9 @@ export async function goto(id, { restart = false } = {}) { const mounting = (async () => { try { await nextLevel.mount(ctx); } catch (e) { console.error('mount failed:', id, e); } })(); HUD.veil(0, 520); await mounting; + refreshHud(); + saveGame(); + if (fragNote) { HUD.say(fragNote, 3600); fragNote = null; } switching = false; } @@ -186,8 +211,9 @@ function frame(now) { export function step(dt) { game.time += dt; - if (In.hit('Escape')) { In.unlock(); } + if (In.hit('Escape')) { In.unlock(); if (readerOpen) toggleReader(); } if (In.hit('KeyM') && !In.hasTextCapture()) toggleMap(); + if (In.hit('KeyP') && !In.hasTextCapture()) toggleReader(); if (In.hit('Backquote')) toggleDev(); if (!game.paused && game.level && game.level.update) { @@ -226,19 +252,98 @@ function toggleMap() { ${via || ''} ${seen ? (l.ms || (12 + i * 17) + ' ms') : ''}`; }).join(''); + const frags = fragments(); + const cargo = game.cargo.length + ? `
carrying ${game.cargo.length} · MTU ${game.mtu} · ${frags > 1 + ? `fragmenting into ${frags}, −${frags - 1} TTL every hop` + : 'fits in one piece'}
` + + game.cargo.map((f, i) => `${f} ✕`).join(' ') + '
' + : ''; mapEl.innerHTML = `
-
traceroute to root (0.0.0.0), 64 hops max, 1500 byte packets
+
traceroute to root (0.0.0.0), 64 hops max, ${game.mtu} byte packets
${rows} + ${cargo}
TTL ${game.ttl} remaining · ${game.philes.size} philes · ${game.deaths} expiries -
click a reached hop to return · M to close
`; +
click a reached hop to return · P for philes · M to close`; mapEl.querySelectorAll('.row.seen').forEach(r => r.addEventListener('click', () => { const id = r.dataset.id; toggleMap(); goto(id); })); + // you can put something down. that is the whole trade. + mapEl.querySelectorAll('.drop').forEach(el => el.addEventListener('click', () => { + game.cargo.splice(+el.dataset.c, 1); + fragments(); refreshHud(); saveGame(); + toggleMap(); toggleMap(); + })); } } +// ────────────────────────────────────────────────────── the phile reader +// You spend the game collecting text files. There has to be somewhere to read them. + +const readerEl = document.getElementById('reader'); +let readerOpen = false, readerAt = 0; +function toggleReader() { + readerOpen = !readerOpen; + game.paused = readerOpen; + readerEl.style.display = readerOpen ? 'flex' : 'none'; + if (readerOpen) { In.unlock(); drawReader(); } +} +function drawReader() { + if (!phileLog.length) { + readerEl.innerHTML = `
philes 0/${PHILE_TOTAL}
+
you have not picked anything up yet.

+ they are lying around. nothing tells you where.
+
P to close
`; + return; + } + readerAt = Math.max(0, Math.min(phileLog.length - 1, readerAt)); + const p = phileLog[readerAt]; + const list = phileLog.map((q, i) => + `
${q.title}
`).join(''); + readerEl.innerHTML = `
+
philes ${phileLog.length}/${PHILE_TOTAL}
+
+
${list}
+

${p.title}

${p.from ? `
${p.from}
` : ''}
${p.body || '(no text)'}
+
+
click a title · P to close
`; + readerEl.querySelectorAll('.it').forEach(el => + el.addEventListener('click', () => { readerAt = +el.dataset.i; drawReader(); })); +} + +// ─────────────────────────────────────────────── ESTABLISHED — the save +// You do not save. You connect. Sitting still long enough to complete a handshake +// with the world is your checkpoint — which in practice means every hop, because +// arriving somewhere is the only moment the state is worth anything. + +const SAVE_KEY = 'handshake.save.v1'; +function saveGame() { + try { + localStorage.setItem(SAVE_KEY, JSON.stringify({ + level: game.level ? game.level.id : 'coldboot', + ttl: game.ttl, hop: game.hop, baud: link.index, + philes: [...game.philes], phileLog, + cargo: game.cargo, mtu: game.mtu, flags: game.flags, deaths: game.deaths, + })); + } catch (e) { /* private browsing, a full disk, somebody's kiosk. not worth dying over. */ } +} +export function loadSave() { + try { return JSON.parse(localStorage.getItem(SAVE_KEY) || 'null'); } catch (e) { return null; } +} +function applySave(s) { + game.ttl = s.ttl; game.hop = s.hop; + game.philes = new Set(s.philes || []); + phileLog.length = 0; (s.phileLog || []).forEach(p => phileLog.push(p)); + game.cargo = s.cargo || []; game.mtu = s.mtu ?? 1500; + game.flags = s.flags || Object.create(null); + game.deaths = s.deaths || 0; + link.setBaud(s.baud ?? 6); + ORDER.forEach(l => { if (l.hop <= s.hop) game.reached.add(l.id); }); +} +export function wipeSave() { try { localStorage.removeItem(SAVE_KEY); } catch (e) {} } + // ─────────────────────────────────────────────────────────────── dev const devEl = document.getElementById('dev'); @@ -258,18 +363,30 @@ function toggleDev() { // ─────────────────────────────────────────────────────────────── boot const bootEl = document.getElementById('boot'); -async function start() { +const save = loadSave(); +if (save) { + const l = ORDER.find(x => x.id === save.level); + document.getElementById('resume').innerHTML = + `R — RESUME   hop ${String(save.hop).padStart(2, '0')} · ${l ? l.title : save.level} · TTL ${save.ttl} · ${(save.philes || []).length} philes`; + document.getElementById('resume').style.display = 'block'; +} + +let started = false; +async function start(resume) { + if (started) return; + started = true; bootEl.style.display = 'none'; A.boot(); A.resume(); requestAnimationFrame(frame); - await goto('coldboot'); + if (resume && save) { applySave(save); await goto(save.level, { restart: true }); } + else { wipeSave(); await goto('coldboot'); } } -bootEl.addEventListener('click', start); +bootEl.addEventListener('click', () => start(false)); addEventListener('keydown', function once(e) { - if (bootEl.style.display === 'none') { removeEventListener('keydown', once); return; } + if (started) { removeEventListener('keydown', once); return; } if (e.key === 'Tab') return; removeEventListener('keydown', once); - start(); + start(save && e.key.toLowerCase() === 'r'); }); // carrier detect blinking on the boot screen, because of course diff --git a/src/philes.js b/src/philes.js new file mode 100644 index 0000000..6680b32 --- /dev/null +++ b/src/philes.js @@ -0,0 +1,583 @@ +// philes.js — the lore, and all of it in one place. +// +// Text files. The collectible. None of this is delivered by a cutscene and none of +// it is required. The design says the writing budget goes here, so it goes here: +// levels call ctx.phile('id') and the words live in this file, which means adding +// the next hundred is a writing job and not a code job. +// +// Voice rule: each one is written by somebody, in the year it happened, who did not +// know they were in a game. Nobody explains anything for the player's benefit. + +export const PHILES = { + + // ── hop 1–9 ──────────────────────────────────────────────────────────── + + 'ring-buffer': { + title: 'nic0: tx ring, 512 descriptors', + from: 'a comment in a driver, 1994', + body: `/* The ring is 512 entries because 512 is a nice number and because the + card's DMA engine wraps at a power of two. Head chases tail. Tail chases + head. Neither ever arrives. + + If you are reading this because something is stuck, check that you + actually wrote the tail pointer back. It is always the tail pointer. */`, + }, + + 'osi': { + title: 'the seven layers, as built', + from: 'ISO/IEC 7498-1, and what actually happened', + body: `the model has seven layers. + +the thing that won has four, does not match the model, was designed by +people who were not in the room, and shipped first. + +everybody still teaches the seven. everybody still runs the four. the gap +between those two facts is where roughly half of this is hiding.`, + }, + + 'firewall': { + title: 'stateful inspection', + from: 'a whitepaper, 1994', + body: `the innovation is that the firewall remembers. + +it does not just look at a packet and decide. it keeps a table of every +conversation it has allowed, and when something arrives it asks: is this +part of something we already started? + +which means the way in is not through the wall. the way in is to be +mistaken for the second half of a sentence the wall began.`, + }, + + 'timewait': { + title: 'why 2MSL', + from: 'RFC 793, September 1981', + body: `the connection stays in TIME-WAIT for twice the maximum segment lifetime. + +this is so that any duplicate segments still wandering the network have +expired before the same pair of addresses and ports could be reused, and +be delivered to a connection that has nothing to do with them. + +it is a waiting room for things that are already over, kept open in case +something arrives late. it is not for the connection. it is for the +segments that have not stopped travelling yet.`, + }, + + 'fin': { + title: 'the four-way close', + from: 'FIN, FIN-ACK, ACK, and the one before it', + body: `it takes three packets to open a connection and four to close one. + +opening is quicker than closing. everybody who has ever run a network has +noticed this and nobody has ever found it strange enough to write down.`, + }, + + // ── hop 10–20 ────────────────────────────────────────────────────────── + + 'netsplit': { + title: 'riding the split', + from: '#2600, EFnet, some time in 1998', + body: `when the servers lose each other the channel tears in half. your half +still exists. it just has almost nobody in it. + +so you take ops in a room with nobody in it, which nobody can stop you +doing, and you wait. when the servers find each other again they merge the +two channels, and the merge does not adjudicate. it just unions. + +you were opped. now you are opped in the real one. + +this was not a bug anybody could point at. it was two reasonable rules +having a child.`, + }, + + 'sub7': { + title: 'Back Orifice', + from: 'cDc, DEF CON 6, 1 August 1998', + body: `they shipped it as a remote administration tool, which is exactly what it +was, and named it after a Microsoft product, which is exactly what the +joke was. + +the argument was: this is not new, this is what the operating system +already lets anyone do, and we have simply made it easy enough that you +will finally have to care. + +they were right and it did not help anybody.`, + }, + + 'irc': { + title: 'how a channel is owned', + from: 'a text file with no author on it', + body: `nobody owns #anything. the server hands the first person in the room a +crown and then declines to have any further opinion. + +everything after that — the takeovers, the split riding, the bots, the +three years of war — is people improvising a system of government inside a +program that does not have one and was never going to get one.`, + }, + + 'canary': { + title: 'the stack canary', + from: 'StackGuard, 1997', + body: `you put a random word on the stack between the buffer and the return +address, and before returning you check it is still the word you put there. + +a linear overflow has to write through the canary to reach the return +address, so it dies loudly instead of quietly. + +it stops the overflow that walks. it does nothing about the one that knows +where it is going, and it does nothing at all if you can read the value +first — at which point it is not a guard, it is a password you have.`, + }, + + 'ret': { + title: 'the return address', + from: 'Smashing the Stack for Fun and Profit, Phrack 49, November 1996', + body: `you did not break in. + +the function was always going to return. it was always going to jump to +whatever address was sitting in that slot. you did not subvert anything — +you filled in a field, correctly, with a value of your choosing, and then +the machine did exactly what it was built to do, on time, without error. + +that is the part nobody expects: none of this is the computer going wrong. +all of it is the computer going right about the wrong thing.`, + }, + + 'nopsled': { + title: '0x90', + from: 'why the ramp', + body: `you do not know exactly where your code landed. stack addresses move. + +so you put a long run of do-nothing instructions in front of it and aim +somewhere in the middle. land anywhere on the ramp and you slide to the +bottom, and the bottom is where the work is. + +it is the single most forgiving thing in the whole field. everything else +demands you be exactly right. the sled says: near enough.`, + }, + + // ── hop 21–34 ────────────────────────────────────────────────────────── + + 'morris': { + title: 'the one in seven', + from: '2 November 1988', + body: `it asks a host whether it is already infected, so as not to waste it. + +but a host could lie. a host could say yes to make the worm go away. so — +to defeat that — it reinfects anyway, one time in seven, regardless of the +answer. + +that is the whole disaster. not the three exploits. not the copies. one +defensive line, written by somebody being careful, that turned a +self-limiting program into a machine for grinding a network into a stop. + +about six thousand hosts. roughly a tenth of everything there was.`, + }, + + 'fingerd': { + title: 'gets()', + from: 'the actual line', + body: ` gets(line); + +there is no length. there was never a length. the function reads until it +finds a newline and writes wherever you told it to start, and it has no +way at all of knowing how much room it has, because nobody passed it that. + +it was removed from the C standard in 2011. it had been the answer to +"how did they get in" for twenty-three years by then.`, + }, + + 'stoll': { + title: 'seventy-five cents', + from: 'Lawrence Berkeley Lab, August 1986', + body: `two accounting systems disagreed by seventy-five cents of connect time. + +everybody who had ever seen that rounded it off. one astronomer, who had +been moved onto computing because the telescope time ran out, decided to +find the nine seconds. + +it took ten months. he wired fifty printers to fifty lines in a basement +and slept next to a teletype with a pager taped to it. the answer was at +the other end of a satellite link and it was being paid in cocaine and +cash by people who wanted MILNET. + +nobody believed him for most of it. the FBI asked how much money had been +stolen and he said seventy-five cents and they hung up.`, + }, + + 'sdinet': { + title: 'the file that was not real', + from: 'the bait', + body: `he could not trace the line because the intruder never stayed on long +enough. + +so he wrote a fake project — a whole distribution list, memos, a +bureaucracy — full of the word the intruder kept searching for, and left +it where it would be found, and made it long. + +it was very boring on purpose. reading it took thirty minutes. thirty +minutes was enough.`, + }, + + // ── hop 35–44, the board ─────────────────────────────────────────────── + + 'board-login': { + title: 'THE LAST TRUNK — login screen', + from: 'ANSI, 1987', + body: `somebody sat and drew this with the eight block characters and sixteen +colours, one cell at a time, so that it would take exactly the right +amount of time to paint at 2400 baud and land its last line on the beat. + +they were not paid. about forty people ever saw it.`, + }, + + 'joy-ears': { + title: 'the sound of a tired switch', + from: 'JOYBUBBLES', + body: `you can hear when a crossbar is worn. it comes back a half tone flat and +it takes an extra beat to answer. + +i know six offices by ear and i have never seen any of them and i never +will and it does not matter one bit. + +people keep telling me i am missing something. i am not missing anything. +i am the only one actually listening.`, + }, + + 'nup-leak': { + title: 'NEW USER POLICY — READ', + from: 'TANDEM', + body: `we do not take new users off the nodelist any more. too much heat. + +if somebody vouched for you they gave you the word. type it at the ELITE +prompt. + +(and no, i will not tell you the word. i am not going to type it into a +public message base, am i.) + +— the entire security model of the scene, in four lines, defeated by the +person who designed it, in the same four lines.`, + }, + + 'ansi-bomb': { + title: 'the ANSI bomb', + from: 'ANSI.SYS, and what it would do for you', + body: `the terminal driver everybody loaded had a feature: an escape sequence +that redefines what a key produces. + +it was meant for macros. it is in the manual. + +so you put the sequence in a message, and the board sends the message to +the reader, and the reader's terminal driver reads it, and now their F1 +key types FORMAT C: and presses return. + +nobody exploited anything. it was a documented feature, working, on a +machine that had no concept of the message being from someone else.`, + }, + + 'crunch': { + title: 're: whistle', + from: 'CAP N CRUNCH', + body: `it is a toy. it came free in a box of cereal. it makes one note. + +the note happens to be two thousand six hundred cycles, which happens to +be the note the long distance network uses to tell itself a trunk is idle. + +nobody designed that coincidence. somebody just did not think anybody +would be listening. that is the whole of it. that is every single one of +these.`, + }, + + 'conscience': { + title: 'the conscience of a hacker', + from: 'Phrack 7, 8 January 1986', + body: `we explore, and you call us criminals. +we seek after knowledge, and you call us criminals. +we exist without skin colour, without nationality, without religious bias, +and you call us criminals. + +you build atomic bombs, you wage wars, you murder, cheat and lie to us and +try to make us believe it is for our own good, yet we are the criminals. + +yes, i am a criminal. my crime is that of curiosity. + +— written the day after he was arrested. he was seventeen.`, + }, + + 'sysop-chat': { + title: 'TANDEM broke into chat', + from: 'the sysop, watching', + body: `he can see what you type before you send it. + +not the message — the keystrokes. the board echoes your line to his screen +as you build it, because the board is a program running on his computer in +his house and there was never any layer in between. + +every sysop could do this. most of them watched. nobody thought to mention +it because there was nothing to mention: where else would it have gone.`, + }, + + 'lord': { + title: 'LORD — you killed a Small Thief', + from: 'Legend of the Red Dragon, v3.29', + body: `you get three forest fights a day, and then you have to hang up and come +back tomorrow, because there is one phone line and forty people and the +only fair way to share a world is to ration time in it. + +nobody has built a better multiplayer economy since. they have all built +faster ones.`, + }, + + 'fido': { + title: 'you posted to fidonet', + from: 'zone 1, net 129, node 22', + body: `at four in the morning your board will dial the next board up the tree and +hand over everything written today, and that board will dial the one above +it, and so on, all night, across the country, one long distance call at a +time, mostly to save money on the rates. + +your message will reach the far side of the world in about four days, +having been physically carried there by a chain of hobbyists' modems while +everyone involved was asleep. + +it worked for fifteen years. at its peak it had about forty thousand +nodes. nobody was in charge of it.`, + }, + + 'the-trunk': { + title: 'TANDEM — the trunk', + from: 'the elite sub-board', + body: `there is a way down out of the board and into the plant itself. + +the switch will hold the line open for a few seconds after you drop. that +is the hole. it has been there since 1971 and nobody closed it because +nobody wrote it down. + +hang up without typing goodbye.`, + }, + + // ── hop 45–50, the handshake ─────────────────────────────────────────── + + 'dtmf': { + title: 'the matrix', + from: 'Bell Labs, 1963', + body: ` 1209 1336 1477 1633 + 697 1 2 3 A + 770 4 5 6 B + 852 7 8 9 C + 941 * 0 # D + +a digit is not a tone. a digit is two tones, one from each set, chosen so +that no pair can be produced by the harmonics of any other pair, and so +that a human voice cannot accidentally make one. + +the fourth column exists. your phone does not have it. it was for the +military, it is called A B C D, and it is still there.`, + }, + + 'ansam': { + title: 'why it goes bong', + from: 'ITU-T V.8 / V.25', + body: `the answering modem sends 2100 Hz, and every 450 milliseconds it reverses +the phase. + +this is not decoration. the telephone network is full of echo cancellers, +which are extremely helpful for speech and completely ruinous for a modem +trying to run full duplex. the phase reversals are a specific, agreed +signal that means: turn yourselves off, we know what we are doing. + +the noise you remember is not the modem talking to the other modem. most +of it is the two of them talking to the network about the network.`, + }, + + 'v8bis': { + title: 'the scream', + from: 'capability negotiation', + body: `the sound in the middle — the one that goes up — is both ends listing what +they can do, at each other, at once. + +speeds, error correction, compression, whether the line will even carry it. +they try, they measure, they climb down until it works. + +which is why two identical modems on two different lines connect at two +different speeds, and why the number it lands on is not a specification. +it is a measurement of the copper between you.`, + }, + + 'connect': { + title: 'CONNECT 14400/ARQ/V32B/LAPM/V42BIS', + from: 'the line that told you what you got', + body: `it is the only benchmark result anybody ever cared about that arrived +unprompted, in plain english, at the exact moment it was decided, about a +negotiation you had no part in. + +nothing since has been this honest about what it actually managed.`, + }, + + // ── hop 51–58, the copper ────────────────────────────────────────────── + + 'strowger': { + title: 'Almon B. Strowger, undertaker', + from: 'Kansas City, 1891', + body: `he was losing business. people ringing for an undertaker were being put +through to his competitor. + +his competitor's wife was the local telephone operator. + +so he built a switch that connects calls without a person in the middle, +patented it, and removed her from the process entirely. it is called +step-by-step. it ran large parts of the world's telephone network for +about ninety years. + +the entire automatic telephone exchange exists because one man in Missouri +was furious about a specific woman.`, + }, + + 'loadcoil': { + title: '88 millihenries, every six thousand feet', + from: 'Pupin, 1900', + body: `a long copper pair eats the high frequencies and the voice arrives dull +and quiet. you fix it by adding inductance at intervals — lumps of iron +and wire, in a case, in the ground, every mile and change. + +it makes speech travel much further. it also puts a brick wall at about +3400 Hz, which is fine for a voice and fatal for a modem. + +the network was tuned, physically, in steel, for the human throat. every +single thing after that had to be smuggled through a hole shaped like a +person talking.`, + }, + + 'beige': { + title: 'the beige box', + from: 'a handset and a pair of jaws', + body: `it is a lineman's telephone. you clip it across the two wires at the box +on the side of somebody's house and you are on their line. + +there is no exploit here. there is no cleverness. the pair is right there, +it is not encrypted because it is not anything, it is two bits of copper +with a voice on them, and the only thing that was ever protecting it was +that it is on the side of a house and people can see you. + +it took no skill whatsoever. that is what makes it the worst one.`, + }, + + // ── hop 59–63, the blue box ──────────────────────────────────────────── + + 'inband': { + title: 'in-band signalling', + from: 'the original sin', + body: `the control channel and the voice channel are the same channel. + +the switches tell each other what to do by making noises down the line — +the same line your voice is on, because building a second network just for +the switches to gossip on would have cost a fortune, and in 1950 nobody +was going to be on the line who could make those noises on purpose. + +that assumption held for about twenty years.`, + }, + + 'tandem-loop': { + title: 'you rang the payphone next to you', + from: 'the set piece', + body: `you stack trunks. each one takes the last one's far end and hands you a +fresh one, and you keep going: this coast, that coast, London, Sydney, +back. + +and then you dial the payphone six feet behind you, and you walk over, and +you pick it up, and you hear yourself a quarter of a second late, having +gone all the way around the earth to travel two metres. + +every switch on that path believed you. not one of them asked.`, + }, + + 'ss7': { + title: 'the day 2600 stopped working', + from: 'Common Channel Signalling System No. 7', + body: `they moved the signalling off the voice path and onto its own network. + +that is all. no arrests, no patch, no announcement. the control channel +and the voice channel became two different things and one day the tone +just did not do anything any more, in one exchange, and then another, and +then everywhere. + +you can still make the sound. the sound is just a sound now. + +it is the only fix in this entire history that actually worked, and it +worked because it stopped being possible to say the thing, rather than +trying to stop people saying it.`, + }, + + // ── the climb ────────────────────────────────────────────────────────── + + 'spectre': { + title: 'the branch that did not happen', + from: '2018', + body: `the processor guesses which way a branch will go and starts work early. if +it guessed wrong it throws the work away and nobody is any the wiser. + +except the work touched memory, and touching memory leaves a line in the +cache warm, and you can time how fast something loads. + +so: get it to guess wrong, on purpose, in a direction it was never allowed +to go, and read the answer out of the temperature of the cache afterwards. + +the timeline was discarded. the timeline still left a mark. it is in every +fast processor made in twenty years, because it is not a bug in any of +them — it is what "fast" meant.`, + }, + + 'rowhammer': { + title: 'rowhammer', + from: '2014', + body: `DRAM cells are so close together now that repeatedly reading one row leaks +charge out of its neighbours. do it enough times, fast enough, before the +refresh comes round, and a bit in a row you were never given access to +changes value. + +you do not read it. you do not write it. you shout next to it. + +it is the point at which software stopped being able to pretend that +memory is an abstraction and had to admit it is a warehouse of tiny +buckets of electricity that are leaking into each other.`, + }, + + 'log4shell': { + title: '${jndi:ldap://}', + from: 'December 2021', + body: `the logging library, when asked to write a line to a file, would notice a +particular pattern in the text and go and fetch what it pointed at, over +the network, and run it. + +this was a feature. it had been a feature since 2013. + +so you put it in your username. or your user agent. or the name of your +iPhone. anywhere that anything, anywhere, might eventually write down what +you said. + +you did nothing. you held up a sign. it went and got it. it was very +helpful. that was the bug.`, + }, + + // ── hop 64 ───────────────────────────────────────────────────────────── + + 'root': { + title: 'root', + from: 'uid 0', + body: `the room was empty. + +root is not a person and it is not a place. it is a number, and the number +is zero, and everything else in the system agrees to defer to whatever is +wearing it. + +the reply was not empty.`, + }, +}; + +// levels can still pass their own title/body inline; this is the shared store +export function lookup(id, title, body) { + const p = PHILES[id]; + if (p) return { id, title: p.title, from: p.from || '', body: p.body }; + return { id, title: title || id, from: '', body: body || '' }; +} + +export const TOTAL = 200; // what the counter counts up to. we are not there yet.