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.