Philes you can read, a save, and MTU that costs you something

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 <noreply@anthropic.com>
This commit is contained in:
type-two 2026-08-02 15:46:18 +10:00
parent 696b0c65e8
commit 8030547e3c
14 changed files with 776 additions and 17 deletions

View File

@ -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 520 minutes the design doc describes. The genres, mechanics and set pieces are all in — the *duration* isn't.

View File

@ -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 @@
<div id="flash"></div>
<div id="fade"></div>
<div id="map"></div>
<div id="reader"></div>
<div id="dev"></div>
<div id="boot">
@ -135,6 +166,7 @@
<k>M</k> traceroute &nbsp; <k>ESC</k> release mouse &nbsp; <k>`</k> hop select<br><br>
headphones. every frequency in this is the real frequency.
</div>
<div id="resume" style="display:none"></div>
<div class="go">CLICK OR PRESS ANY KEY TO DIAL</div>
<div id="cd">▮ NO CARRIER</div>
</div>

View File

@ -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('<b>trunk seized</b> 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);
}

View File

@ -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);
}

View File

@ -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;

View File

@ -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));

View File

@ -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);

View File

@ -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('<b>0x90 0x90 0x90 0x90 0x90 0x90</b>', 4200); }
if (!S.sliding) { S.sliding = true; ctx.phile('nopsled'); ctx.say('<b>0x90 0x90 0x90 0x90 0x90 0x90</b>', 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);
}

View File

@ -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(`<b>shed</b> ${shed}`, 2200);
if (S.i === 0) ctx.phile('osi');
await ctx.sleep(700);
S.i = nxt;
S.spawn = S.body.pos.clone();

View File

@ -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');

View File

@ -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. <b>go and stand in it.</b>', 5000);
},

View File

@ -92,6 +92,7 @@ export default {
S.vec = VECTORS.indexOf(v);
A.blip(900, 0.04, 0.05);
ctx.say(`<b>${v.name}</b> ${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');

View File

@ -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(`<b>phile</b> ${title} <i>[${game.philes.size}/200]</i>`, 2600);
const p = phileLookup(id, title, body);
HUD.say(`<b>phile</b> ${p.title} <i>[${game.philes.size}/${PHILE_TOTAL}]</i> &nbsp; <k>P</k> 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 = `<b>fragmented</b> ${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() {
<span class="via">${via || ''}</span>
<span class="ms">${seen ? (l.ms || (12 + i * 17) + ' ms') : ''}</span></div>`;
}).join('');
const frags = fragments();
const cargo = game.cargo.length
? `<div class="cargo">carrying ${game.cargo.length} · MTU ${game.mtu} · ${frags > 1
? `<span class="bad">fragmenting into ${frags}, ${frags - 1} TTL every hop</span>`
: 'fits in one piece'}<br>` +
game.cargo.map((f, i) => `<span class="drop" data-c="${i}">${f} ✕</span>`).join(' ') + '</div>'
: '';
mapEl.innerHTML = `<div class="trace">
<div class="hdr">traceroute to root (0.0.0.0), 64 hops max, 1500 byte packets</div>
<div class="hdr">traceroute to root (0.0.0.0), 64 hops max, ${game.mtu} byte packets</div>
${rows}
${cargo}
<div class="ftr">TTL ${game.ttl} remaining · ${game.philes.size} philes · ${game.deaths} expiries
<br><span class="dim">click a reached hop to return · M to close</span></div></div>`;
<br><span class="dim">click a reached hop to return · P for philes · M to close</span></div></div>`;
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 = `<div class="rd"><div class="hdr">philes 0/${PHILE_TOTAL}</div>
<div class="empty">you have not picked anything up yet.<br><br>
they are lying around. nothing tells you where.</div>
<div class="ftr"><span class="dim">P to close</span></div></div>`;
return;
}
readerAt = Math.max(0, Math.min(phileLog.length - 1, readerAt));
const p = phileLog[readerAt];
const list = phileLog.map((q, i) =>
`<div class="it ${i === readerAt ? 'on' : ''}" data-i="${i}">${q.title}</div>`).join('');
readerEl.innerHTML = `<div class="rd">
<div class="hdr">philes ${phileLog.length}/${PHILE_TOTAL}</div>
<div class="cols">
<div class="list">${list}</div>
<div class="body"><h2>${p.title}</h2>${p.from ? `<div class="from">${p.from}</div>` : ''}<pre>${p.body || '(no text)'}</pre></div>
</div>
<div class="ftr"><span class="dim">click a title · P to close</span></div></div>`;
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 =
`<b>R</b> — RESUME &nbsp; 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

583
src/philes.js Normal file
View File

@ -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 19 ────────────────────────────────────────────────────────────
'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 1020 ──────────────────────────────────────────────────────────
'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 2134 ──────────────────────────────────────────────────────────
'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 3544, 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 4550, 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 5158, 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 5963, 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.