Compare commits

...

4 Commits

Author SHA1 Message Date
type-two
5a85360ef9 [screen] round 1 notes
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:11:35 +10:00
type-two
f31e36de72 [screen] compositor: shipments drive THE SCREEN
Eased param pressure so shipments land as waves, log escalation over the
first ~100 items, cross-modulated wobble above 0.8 so a mature factory looks
alive, brownout seize, and a one-frame white awakening on the first ship ever.

Measured 0.004-0.013ms/frame CPU against a 2ms budget.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:11:35 +10:00
type-two
2982862e83 [screen] corruption ledger: item -> artifact mapping + escalation curve
Mix and total are separate axes: what you ship picks the artifact, how much
you have ever shipped picks the intensity. That separation is what makes
40 chroma slurry read as chroma specifically instead of generic damage.

Covers the full 40-item codex LANE-DATA landed; unknown ids degrade to a
generic noise bump, with a test walking data/items.json to keep it that way.
anchor-slab has negative potency and sanitizes THE SCREEN (see NOTES).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:11:35 +10:00
type-two
3dd423fe2b [screen] port GLYTCH shader stack + procedural clean feed
Raw WebGL fullscreen triangle, not three.js: one texture, one pass.
The 8 prototype params keep their tuning verbatim; u_drift, u_brownout
and u_flash are additions. Base feed repaints only when a visible glyph
changes rather than per frame, so the texture upload stays off the hot path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 16:11:35 +10:00
7 changed files with 821 additions and 12 deletions

View File

@ -63,3 +63,86 @@ measured ms). NOTES written.
### Round N — YYYY-MM-DD — [your model]
SHIPPED: / DECISIONS: / BLOCKED/BROKEN: / CONTRACT REQUEST: / PROPOSAL: / NEXT:
```
### Round 1 — 2026-07-17 — Opus 4.8
SHIPPED: all 5 orders done and verified on the dev build.
- `params.ts` — the 8 params + frame-rate-independent `ease()`.
- `glitchStack.ts` — GLYTCH shader stack ported. Raw WebGL, NOT three.js (order 1 said my
call): it's one fullscreen triangle + one texture; three.js would cost more than the
whole effect is allowed to. All 8 prototype params keep their tuning verbatim — I
changed no magic numbers. Added 3 uniforms of my own: `u_drift`, `u_brownout`, `u_flash`.
- `baseTexture.ts` — procedural clean programming: SMPTE bars + castellations + pluge/ramp
+ camcorder overlay (PLAY ▶, running SP timecode, rec dot), widened to 16:9.
- `corruptionMap.ts` — the ledger + item→artifact table + escalation curve.
- `index.ts` — compositor: ledger, easing, wobble, brownout, awakening.
- `corruptionMap.test.ts` — 24 tests, all green.
DECISIONS:
- **Mix vs. total are separate axes.** WHAT you ship picks the artifact (blend of per-item
weights, by ship share); HOW MUCH you've ever shipped picks the intensity (escalation).
This is what makes 40 chroma slurry read as *chroma* instead of "more glitch".
- Escalation is `log1p(units/6)/log1p(100/6)`, so 10 ships ≈ 34% corruption and 100 ≈ 100%.
Front-loaded hard, per orders — there's a test pinning early-vs-late slope.
- Base texture repaints only when a visible glyph changes (timecode ~1/sec), not per frame.
Re-uploading 640×360 every frame would have eaten the budget for nothing. Motion for the
corruption to chew on comes from `u_drift` (bounded sinusoid — deliberately not a wrapping
pan, which would show a seam: the texture is non-power-of-two so WebGL1 forces CLAMP_TO_EDGE).
- Wobble above 0.8 total is CPU-side, 8 distinct slow rates, ±0.12 riding on top of the eased
value and never feeding back into the ledger.
- **I extended the item table well past the orders' examples.** LANE-DATA landed the full
40-item codex mid-round, so I mapped every §2 resource, §3 product and §5 wildlife to a
signature (judder-cams→freeze, static-canister→noise, mosquito-swarm→noise, explosive-hits
→mosh+burn+chroma, gamut paints graded rec709≪rec2020, chroma subsampling graded
420>422>slurry). The 5 science packs are intentionally left on the generic-noise fallback —
they're research currency, not ship goods. Unknown ids still degrade gracefully; there's a
test that walks `data/items.json` and asserts every id yields sane params, so LANE-DATA can
keep adding content without me.
VERIFIED (dev build, driving the real module on the real canvas, reading GL pixels — not vibes):
- Starts sterile: bars + "NOTHING IS WRONG" chyron, `idle:true`, all params 0.
- 40× chroma-slurry → `chroma 0.684`, every other param 0 except a 0.082 burn trace. Screenshot
shows channel-split fringing on bars and doubled glyphs. Item-specific: confirmed.
- Awakening: first-ever ship renders exactly ONE frame at mean luminance 255 (full white),
79 the next frame.
- Mature: 120× melt → total 1.0, melt 1.0 / mosh 0.451; screenshot shows the bars dripping
into black. Above 0.8, params drift ~0.095 between frames — alive, not maxed-out static.
- Anchor-slab isolation test: 1× anchor sets `idle:false` with ZERO corruption, and the only
pixel change is the chyron band going 37.1→100.8 luminance (dark overlay removed, bars
revealed). Chyron logic and sanitation both confirmed in one shot.
- Brownout: 1.0 when on, 0.0 when lifted, freeze+dropout+judder all driven off it.
- **Frame budget: 0.0040.013 ms/frame CPU** (rolling avg, `__fktryScreen.stats().msPerFrame`).
Budget was <2ms. GPU side is one fullscreen triangle, no readbacks in the hot path.
BLOCKED/BROKEN: nothing blocking me. Two things worth knowing:
- LANE-SIM emits no `shipped` events yet, so THE SCREEN can't be driven by the real chain.
I shipped a debug hook instead: `__fktryScreen.ship('melt',40)`, `.brownout(true)`,
`.reset()`, `.stats()`. It's ~20 lines in `init` and I'm happy to drop it once the chain
runs — say the word.
- `npm test` currently has 2 red in `src/sim/sim.test.ts` (crafting, shipping) and they are
NOT mine — flagging for your review, not touching them. My 24 pass; tsc is clean repo-wide.
NOTE ON A THING I TOUCHED OUTSIDE MY LANE: I briefly created `.claude/launch.json` to get a
dev server on a free port (8150 is held by another session), then **deleted it** — the tooling
kept resolving to the busy port anyway. Net diff outside `src/screen/**` is zero. If you want
lanes to be able to boot their own preview, that file probably wants to be orchestrator-owned
with one entry per lane; right now five parallel lanes would collide on it.
CONTRACT REQUEST: none. `ScreenFX` + `SimEvent` gave me everything I needed.
PROPOSAL (orders 2 asked me to flag this): **anchor-slab sanitizing is live and it's good
tension** — potency 2.0, so 1 anchor cancels ~2 items of corruption and enough of them
sterilize THE SCREEN completely (tested). Right now that's invisible to the player, which
makes it a trap: they'll ship I-frames for a commission and watch their sky go clean with no
explanation. Two options for you — (a) UI surfaces it (LANE-UI chip: "SANITISING"), or (b) the
fiction owns it: The Correction *pays* for anchor-slabs, so it's a deliberate sellout loop —
bandwidth now, corruption later. I'd build (b); it's the codex's whole conflict in one item.
Also: `ScreenFX` currently only sees events, never the snapshot — if you ever want THE SCREEN
to react to *bandwidth strain* or *heat* continuously (not just the brownout on/off edge),
that'd need `frame()` to see the snapshot. Not asking yet; noting the seam.
NEXT (my read, not self-assigning): item-specific artifacts that the 8 prototype params can't
express — moiré needs a real interference lattice, ringing needs edge-detected halos,
macroblock bricks want the 8×8 grid to be *legible*. That's new shader passes, not new
weights. Also THE SCREEN should probably show the shipped item's silhouette for a beat as it
lands. Say the word on round 2.

View File

@ -0,0 +1,94 @@
/**
* THE SCREEN's base content: procedural "clean programming".
* Sterile SMPTE-ish test pattern + camcorder overlay, ported from the prototype's
* makeBaseTexture and widened to 16:9. This is what the factory's output is painted
* ONTO it must be bland enough that any corruption reads instantly.
*
* Cost control: the pattern is static, so we only repaint when a visible glyph changes
* (timecode ticks ~1/sec, chyron toggles once). Motion comes from the shader's slow
* drift, not from re-uploading 640x360 pixels every frame.
*/
export const BASE_W = 640;
export const BASE_H = 360;
export interface BaseFeed {
canvas: HTMLCanvasElement;
/** Repaint if anything visible changed. Returns true when the texture needs re-upload. */
update(timeMs: number, idle: boolean): boolean;
}
function timecode(timeMs: number): string {
// Tape-style running timecode, starting at a suspiciously specific point.
const total = Math.floor(timeMs / 1000) + 754; // 0:12:34
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
return `SP ${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
}
export function createBaseFeed(): BaseFeed {
const canvas = document.createElement('canvas');
canvas.width = BASE_W;
canvas.height = BASE_H;
const g = canvas.getContext('2d')!;
let lastTC = '';
let lastIdle: boolean | null = null;
function paint(tc: string, idle: boolean) {
const bars = ['#c0c0c0', '#c0c000', '#00c0c0', '#00c000', '#c000c0', '#c00000', '#0000c0'];
const bw = BASE_W / bars.length;
bars.forEach((col, i) => {
g.fillStyle = col;
g.fillRect(i * bw, 0, bw + 1, BASE_H * 0.62);
});
// castellations strip
const casts = ['#0000c0', '#131313', '#c000c0', '#131313', '#00c0c0', '#131313', '#c0c0c0'];
casts.forEach((col, i) => {
g.fillStyle = col;
g.fillRect(i * bw, BASE_H * 0.62, bw + 1, BASE_H * 0.1);
});
// pluge / ramp
g.fillStyle = '#101010';
g.fillRect(0, BASE_H * 0.72, BASE_W, BASE_H * 0.28);
const ramp = g.createLinearGradient(0, 0, BASE_W, 0);
ramp.addColorStop(0, '#000');
ramp.addColorStop(1, '#fff');
g.fillStyle = ramp;
g.fillRect(BASE_W * 0.55, BASE_H * 0.8, BASE_W * 0.4, 18);
// camcorder overlay
g.fillStyle = '#eee';
g.font = "bold 22px 'Courier New', monospace";
g.fillText('PLAY ▶', 20, BASE_H * 0.86);
g.fillText(tc, 20, BASE_H * 0.95);
g.font = "bold 18px 'Courier New', monospace";
g.fillStyle = '#f4f4f4';
g.fillText('WIMVEE FKTRY', BASE_W * 0.55, BASE_H * 0.95);
g.fillStyle = '#ff4444';
g.beginPath();
g.arc(BASE_W - 26, BASE_H * 0.83, 7, 0, Math.PI * 2);
g.fill();
// Idle chyron: OSHA-poster deadpan. Disappears forever once the factory ships.
if (idle) {
g.fillStyle = 'rgba(8,8,12,0.82)';
g.fillRect(0, BASE_H * 0.5, BASE_W, 34);
g.fillStyle = '#d8ffd8';
g.font = "bold 20px 'Courier New', monospace";
g.fillText('NOTHING IS WRONG', 18, BASE_H * 0.5 + 24);
}
}
return {
canvas,
update(timeMs: number, idle: boolean) {
const tc = timecode(timeMs);
if (tc === lastTC && idle === lastIdle) return false;
lastTC = tc;
lastIdle = idle;
paint(tc, idle);
return true;
},
};
}

View File

@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';
import { escalation, resolveLedger, corruptionFor, ESCALATION_ITEMS } from './corruptionMap';
import { PARAMS } from './params';
const tally = (o: Record<string, number>) => new Map(Object.entries(o));
/** The param carrying the most pressure — "what does the player actually SEE". */
const dominant = (t: Record<string, number>) =>
PARAMS.reduce((a, b) => (t[a] >= t[b] ? a : b));
describe('escalation curve', () => {
it('is 0 when nothing has shipped and 1 at the tuned item count', () => {
expect(escalation(0)).toBe(0);
expect(escalation(-5)).toBe(0);
expect(escalation(ESCALATION_ITEMS)).toBeCloseTo(1, 5);
});
it('clamps above the tuned count', () => {
expect(escalation(10_000)).toBe(1);
});
it('is monotonic and front-loaded: the first 10 ships outrun the last 10', () => {
let prev = -1;
for (let n = 0; n <= 120; n += 5) {
const e = escalation(n);
expect(e).toBeGreaterThanOrEqual(prev);
prev = e;
}
const early = escalation(10) - escalation(0);
const late = escalation(100) - escalation(90);
expect(early).toBeGreaterThan(late * 5);
});
});
describe('item -> artifact legibility', () => {
it('40 chroma slurry reads as chroma damage specifically', () => {
const { targets } = resolveLedger(tally({ 'chroma-slurry': 40 }));
expect(dominant(targets)).toBe('chroma');
expect(targets.chroma).toBeGreaterThan(0.5);
});
it('melt reads as melt, not generic mush', () => {
const { targets } = resolveLedger(tally({ melt: 40 }));
expect(dominant(targets)).toBe('melt');
expect(targets.melt).toBeGreaterThan(targets.mosh);
});
it('hf-dust reads as static', () => {
expect(dominant(resolveLedger(tally({ 'hf-dust': 40 })).targets)).toBe('noise');
});
it('mixing shifts the blend toward whatever dominates the shipping', () => {
const mixed = resolveLedger(tally({ 'chroma-slurry': 38, 'hf-dust': 2 })).targets;
expect(dominant(mixed)).toBe('chroma');
expect(mixed.noise).toBeGreaterThan(0); // the dust is still visible, just outvoted
});
it('unknown items degrade to a generic noise bump instead of vanishing', () => {
const { targets, total } = resolveLedger(tally({ 'megaglitch-prototype': 20 }));
expect(total).toBeGreaterThan(0);
expect(dominant(targets)).toBe('noise');
expect(corruptionFor('megaglitch-prototype').potency).toBeGreaterThan(0);
});
});
describe('codex signatures read as themselves', () => {
it.each([
['static-canister', 'noise'], // bottled analog snow
['judder-cams', 'freeze'], // 3:2 pulldown, machined
['macroblock-bricks', 'mosh'], // the quantizer's confession
['false-color-speckle', 'chroma'],
['mosquito-swarm', 'noise'], // literal mosquito noise
['v-hold-roll', 'roll'],
['silver-frames', 'burn'], // celluloid
['bloom-concentrate', 'mosh'], // motion compounded on itself
])('%s reads as %s', (item, param) => {
expect(dominant(resolveLedger(tally({ [item]: 40 })).targets)).toBe(param);
});
it('grades chroma subsampling by how much colour was thrown away', () => {
expect(corruptionFor('chroma-420').potency).toBeGreaterThan(corruptionFor('chroma-422').potency);
expect(corruptionFor('chroma-422').potency).toBeGreaterThan(corruptionFor('chroma-slurry').potency);
});
it('rates laser-primary gamut paint far above house-gray', () => {
expect(corruptionFor('gamut-paint-rec2020').potency).toBeGreaterThan(
corruptionFor('gamut-paint-rec709').potency * 3,
);
});
});
describe('survives whatever LANE-DATA ships', () => {
it('every item in data/items.json produces sane params', async () => {
const items = (await import('../../data/items.json')).default as Array<{ id: string }>;
expect(items.length).toBeGreaterThan(0);
for (const { id } of items) {
const { targets, total } = resolveLedger(tally({ [id]: 25 }));
expect(Number.isFinite(total), `${id} total`).toBe(true);
for (const p of PARAMS) {
expect(Number.isFinite(targets[p]), `${id}.${p}`).toBe(true);
expect(targets[p], `${id}.${p}`).toBeGreaterThanOrEqual(0);
expect(targets[p], `${id}.${p}`).toBeLessThanOrEqual(1);
}
}
});
});
describe('anchor slabs sanitize', () => {
it('shipping I-frames pulls corruption back down', () => {
const dirty = resolveLedger(tally({ melt: 20 })).total;
const cleaned = resolveLedger(tally({ melt: 20, 'anchor-slab': 5 })).total;
expect(cleaned).toBeLessThan(dirty);
});
it('enough anchors fully sterilize THE SCREEN', () => {
const { total, targets } = resolveLedger(tally({ melt: 10, 'anchor-slab': 50 }));
expect(total).toBe(0);
for (const p of PARAMS) expect(targets[p]).toBe(0);
});
it('anchors alone never drive corruption negative', () => {
const { units, total } = resolveLedger(tally({ 'anchor-slab': 99 }));
expect(units).toBe(0);
expect(total).toBe(0);
});
});
describe('param hygiene', () => {
it('never emits a param outside 0..1, even shipping everything at once', () => {
const everything = tally({
melt: 500, 'chroma-slurry': 500, 'hf-dust': 500, 'delta-wafer': 500,
'gop-crate': 500, 'luma-bars': 500, 'coefficient-pack': 500, 'mdat-ore': 500,
});
const { targets } = resolveLedger(everything);
for (const p of PARAMS) {
expect(targets[p]).toBeGreaterThanOrEqual(0);
expect(targets[p]).toBeLessThanOrEqual(1);
}
});
it('an empty ledger is fully sterile', () => {
const { targets, total } = resolveLedger(tally({}));
expect(total).toBe(0);
for (const p of PARAMS) expect(targets[p]).toBe(0);
});
});

View File

@ -0,0 +1,133 @@
/**
* Item -> artifact mapping. This is the design core of THE SCREEN: a player who ships
* 40 chroma slurry must SEE chroma damage specifically, not "more glitch".
*
* Two independent quantities come out of the ledger:
* - MIX (which artifacts) = the weighted blend of what you ship
* - TOTAL (how much) = the escalation curve over lifetime shipments
* Shipping a lot of one thing bends the mix hard toward that item's signature.
*/
import type { GameData } from '../contracts';
import { PARAMS, type Param, type ParamSet, zeroParams } from './params';
export interface ItemCorruption {
/** How much this item escalates overall corruption. Negative = sanitizes. */
potency: number;
/** Which artifacts this item argues for. Values 0..1, blended by ship share. */
weights: Partial<Record<Param, number>>;
}
/**
* Keyed by ItemDef.id (codex canon, see docs/FKTRY_LORE.md §2/§3). Items not listed
* here still work they get FALLBACK, a small generic noise bump so LANE-DATA can
* add content without touching this lane. Unrecognised ids are reported at init.
*/
const CORRUPTION: Record<string, ItemCorruption> = {
// ---- raw (§2) : undifferentiated damage, cheap to ship, weak signature
'mdat-ore': { potency: 0.4, weights: { noise: 0.5, burn: 0.25 } },
'bayer-grit': { potency: 0.4, weights: { noise: 0.55, chroma: 0.25 } },
'field-splinters': { potency: 0.5, weights: { tear: 0.6, roll: 0.45 } }, // interlace
'silver-frames': { potency: 0.5, weights: { burn: 0.7 } }, // celluloid
// ---- refined (§2)
'luma-bars': { potency: 0.6, weights: { roll: 0.6, tear: 0.5 } },
'chroma-slurry': { potency: 1.0, weights: { chroma: 1.0, burn: 0.12 } },
// Subsampling grades: the more you throw away, the harder the colour bleeds.
'chroma-422': { potency: 1.05, weights: { chroma: 1.0, melt: 0.15 } },
'chroma-420': { potency: 1.1, weights: { chroma: 1.0, melt: 0.3 } },
'color-plate': { potency: 0.7, weights: { chroma: 0.75 } },
'coefficient-pack': { potency: 0.7, weights: { freeze: 0.55, noise: 0.35, tear: 0.3 } },
'hf-dust': { potency: 0.8, weights: { noise: 0.9, tear: 0.2 } },
'mv-flux': { potency: 1.0, weights: { mosh: 0.9, melt: 0.45 } }, // motion vectors
'v-hold-roll': { potency: 0.7, weights: { roll: 1.0 } },
// ---- frames (§2)
// I-frames are CLEAN REFERENCE. Shipping anchors sanitizes THE SCREEN.
// See NOTES: deliberate tension, flagged for the orchestrator.
'anchor-slab': { potency: -2.0, weights: {} },
'delta-wafer': { potency: 0.9, weights: { mosh: 0.85, melt: 0.3 } }, // P: prediction, no anchor
'janus-wafer': { potency: 1.0, weights: { mosh: 0.6, freeze: 0.6, melt: 0.25 } }, // B: bidirectional
'gop-crate': { potency: 1.0, weights: { freeze: 0.7, mosh: 0.45, roll: 0.25 } },
// ---- glitch products (§3) : the signatures. These must read INSTANTLY.
melt: { potency: 1.2, weights: { melt: 0.95, mosh: 0.55 } },
'bloom-concentrate': { potency: 1.3, weights: { mosh: 0.9, melt: 0.6 } },
'macroblock-bricks': { potency: 1.1, weights: { mosh: 1.0 } },
'ringing-halos': { potency: 0.9, weights: { noise: 0.5, tear: 0.45 } },
'static-canister': { potency: 1.0, weights: { noise: 1.0 } },
'moire-crystal': { potency: 1.1, weights: { chroma: 0.6, noise: 0.5, tear: 0.35 } },
'false-color-speckle': { potency: 1.2, weights: { chroma: 1.0, noise: 0.3 } },
'judder-cams': { potency: 1.1, weights: { freeze: 1.0 } }, // 3:2 pulldown
'explosive-hits': { potency: 1.5, weights: { mosh: 0.8, burn: 0.6, chroma: 0.5 } },
// Gamut paints: house-gray is nearly inert, laser primaries are weapons-grade.
'gamut-paint-rec709': { potency: 0.3, weights: { chroma: 0.2 } },
'gamut-paint-dci-p3': { potency: 0.8, weights: { chroma: 0.7 } },
'gamut-paint-rec2020': { potency: 1.3, weights: { chroma: 1.0, burn: 0.5 } },
// ---- wildlife (§5)
'mosquito-swarm': { potency: 0.9, weights: { noise: 0.95 } }, // literal mosquito noise
'gibbs-wraith': { potency: 1.0, weights: { noise: 0.55, tear: 0.5, freeze: 0.3 } },
};
const FALLBACK: ItemCorruption = { potency: 0.5, weights: { noise: 0.6 } };
export function corruptionFor(id: string): ItemCorruption {
return CORRUPTION[id] ?? FALLBACK;
}
/** Items whose corruption profile is unmapped — logged at init so new data is visible. */
export function unmappedItems(data: GameData): string[] {
return data.items.filter((i) => !(i.id in CORRUPTION)).map((i) => i.id);
}
/** Shipments needed to reach full corruption. Tuned by feel; log-ish, so early ships land hard. */
export const ESCALATION_ITEMS = 100;
const KNEE = 6; // lower = steeper early curve
/** Corruption units -> 0..1 total. log-shaped: the first handful of ships feel enormous. */
export function escalation(units: number): number {
if (units <= 0) return 0;
const t = Math.log1p(units / KNEE) / Math.log1p(ESCALATION_ITEMS / KNEE);
return Math.min(1, t);
}
export interface LedgerResult {
targets: ParamSet;
/** net corruption units after anchor-slab sanitation */
units: number;
/** 0..1 escalation */
total: number;
}
/**
* Blend the per-item tally into target param pressures.
* Mix uses only positive-potency items (you can't corrupt *toward* clean); anchors
* pull down `units`, which scales everything back down through `total`.
*/
export function resolveLedger(tally: ReadonlyMap<string, number>): LedgerResult {
const targets = zeroParams();
let units = 0;
let mixWeight = 0;
for (const [id, count] of tally) {
if (count <= 0) continue;
units += count * corruptionFor(id).potency;
const p = corruptionFor(id).potency;
if (p > 0) mixWeight += count * p;
}
units = Math.max(0, units);
const total = escalation(units);
if (mixWeight <= 0) return { targets, units, total };
for (const [id, count] of tally) {
if (count <= 0) continue;
const { potency, weights } = corruptionFor(id);
if (potency <= 0) continue;
const share = (count * potency) / mixWeight;
for (const p of PARAMS) {
targets[p] += (weights[p] ?? 0) * share;
}
}
for (const p of PARAMS) targets[p] = Math.min(1, targets[p] * total);
return { targets, units, total };
}

View File

@ -0,0 +1,208 @@
/**
* The GLYTCH shader stack, ported from the WIMVEE GLYTCH prototype (../index.html).
*
* The 8 glitch params and their maths are TUNED the prototype is the reference and
* the magic numbers are respected verbatim. Only three things are new here:
* u_drift - slow sinusoidal pan of the clean feed (subtle motion to chew on)
* u_brownout - the factory's pain: freeze-frame + dropout bars + desync judder
* u_flash - one-frame full-white awakening on the first shipment ever
*
* Raw WebGL, not three.js: this is one fullscreen triangle with one texture. Pulling
* three.js in for that would cost more than the whole effect is allowed to cost.
*/
import { BASE_H, BASE_W, type BaseFeed } from './baseTexture';
import { PARAMS, type Param, type ParamSet } from './params';
const VS = `
attribute vec2 p;
varying vec2 vUv;
void main(){ vUv = p*0.5+0.5; vUv.y = 1.0-vUv.y; gl_Position = vec4(p,0.,1.); }`;
const FS = `
precision mediump float;
varying vec2 vUv;
uniform sampler2D tex;
uniform float t;
uniform float u_mosh, u_melt, u_chroma, u_tear, u_roll, u_burn, u_noise, u_freeze;
uniform float u_drift, u_brownout, u_flash;
float hash(vec2 p){ return fract(sin(dot(p, vec2(127.1,311.7)))*43758.5453); }
float vnoise(vec2 p){
vec2 i=floor(p), f=fract(p); f=f*f*(3.-2.*f);
return mix(mix(hash(i),hash(i+vec2(1,0)),f.x),
mix(hash(i+vec2(0,1)),hash(i+vec2(1,1)),f.x),f.y);
}
float fbm(vec2 p){ return 0.5*vnoise(p)+0.25*vnoise(p*2.3)+0.125*vnoise(p*5.1); }
void main(){
// freeze quantizes the timebase everything else animates on.
// A brownout slams the whole timebase into a hard stutter regardless of freeze.
float fz = clamp(max(u_freeze, u_brownout*0.85)*1.4, 0., 1.);
float tt = mix(t, floor(t*2.5)/2.5, fz);
vec2 uv = vUv;
// slow drift of the clean feed: bounded sinusoid, so no wrap seam
uv += vec2(sin(t*0.07)*0.004, cos(t*0.05)*0.003) * u_drift;
// desync judder: the frame itself loses its footing during a brownout
if(u_brownout > 0.001){
float j = floor(tt*12.0);
uv.x += (hash(vec2(j, 3.0))-0.5) * u_brownout * 0.07;
uv.y += (hash(vec2(j, 8.0))-0.5) * u_brownout * 0.04;
}
// v-hold roll
uv.y = fract(uv.y + u_roll * tt * 0.35);
// scanline tears: bands shove sideways
float band = floor(uv.y * 36.0);
float tr = hash(vec2(band, floor(tt*7.0)));
if(tr > 1.0 - u_tear*0.85){
uv.x += (hash(vec2(band, floor(tt*7.0)+9.0)) - 0.5) * u_tear * 0.5;
}
// datamosh: macroblocks pick up stale offsets
vec2 blk = floor(uv * vec2(16.0, 12.0));
float mb = hash(blk + floor(tt*3.0));
if(mb > 1.0 - u_mosh*0.9){
vec2 off = vec2(hash(blk+1.7), hash(blk+4.2)) - 0.5;
uv += off * u_mosh * 0.35;
}
// melt: columns drip downward
float drip = pow(vnoise(vec2(uv.x*30.0, floor(tt*1.5))), 3.0);
uv.y -= u_melt * drip * uv.y * 0.9;
uv = fract(uv);
// chroma split
float ca = u_chroma * 0.03;
vec3 col;
col.r = texture2D(tex, uv + vec2( ca, 0.)).r;
col.g = texture2D(tex, uv).g;
col.b = texture2D(tex, uv - vec2( ca, ca*0.6)).b;
// static
float sn = hash(uv*vec2(431.0,917.0) + fract(tt)*13.0);
col = mix(col, vec3(sn), u_noise * 0.7);
// freeze dropout: grey flash lines
float dropo = step(0.985 - u_freeze*0.12, hash(vec2(floor(uv.y*90.0), floor(tt*5.0))));
col = mix(col, vec3(0.45), dropo * u_freeze);
// brownout dropout: heavy black tape-dropout bars with hot rims
if(u_brownout > 0.001){
float row = floor(vUv.y*70.0);
float d = hash(vec2(row, floor(tt*8.0)));
float bar = step(1.0 - u_brownout*0.38, d);
col = mix(col, vec3(0.03), bar*u_brownout);
float rim = step(1.0 - u_brownout*0.06, hash(vec2(row+31.0, floor(tt*8.0))));
col = mix(col, vec3(0.85,0.9,0.85), rim*u_brownout*0.8);
}
// film burn: fbm blotch eats the frame, hot rim then black
float b = fbm(uv*3.0 + vec2(0., -tt*0.4));
float edge = smoothstep(1.0 - u_burn*1.1, 1.0 - u_burn*1.1 + 0.12, b);
vec3 rim2 = vec3(1.0, 0.55, 0.15);
col = mix(col, rim2, edge * (1.0 - smoothstep(0.05, 0.35, edge)) * 2.0 * u_burn);
col = mix(col, vec3(0.02), smoothstep(0.3, 1.0, edge) * step(0.01, u_burn));
// faint scanlines for vibe (constant, not scored)
col *= 0.92 + 0.08 * sin(vUv.y * 360.0 * 3.1415);
// the awakening: one frame of full white on the first shipment ever
col = mix(col, vec3(1.0), u_flash);
gl_FragColor = vec4(col, 1.0);
}`;
type UniformKey = Param | 't' | 'drift' | 'brownout' | 'flash';
export interface DrawState {
params: ParamSet;
timeSec: number;
/** 0..1 amount of slow sinusoidal pan applied to the clean feed */
drift: number;
/** 0..1 brownout severity */
brownout: number;
/** 0..1 one-frame white awakening */
flash: number;
/** nothing shipped yet — the clean feed still carries its chyron */
idle: boolean;
}
export interface GlitchStack {
draw(s: DrawState): void;
}
function compile(gl: WebGLRenderingContext, type: number, src: string): WebGLShader {
const s = gl.createShader(type)!;
gl.shaderSource(s, src);
gl.compileShader(s);
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
throw new Error(`[screen] shader compile failed: ${gl.getShaderInfoLog(s)}`);
}
return s;
}
/** Returns null when WebGL is unavailable; caller degrades to a dead screen rather than crashing the game. */
export function createGlitchStack(canvas: HTMLCanvasElement, base: BaseFeed): GlitchStack | null {
const gl = canvas.getContext('webgl', { antialias: false, depth: false, alpha: false });
if (!gl) {
console.warn('[screen] no WebGL context — THE SCREEN stays dark');
return null;
}
const prog = gl.createProgram()!;
gl.attachShader(prog, compile(gl, gl.VERTEX_SHADER, VS));
gl.attachShader(prog, compile(gl, gl.FRAGMENT_SHADER, FS));
gl.linkProgram(prog);
if (!gl.getProgramParameter(prog, gl.LINK_STATUS)) {
throw new Error(`[screen] program link failed: ${gl.getProgramInfoLog(prog)}`);
}
gl.useProgram(prog);
const buf = gl.createBuffer();
gl.bindBuffer(gl.ARRAY_BUFFER, buf);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1, -1, 3, -1, -1, 3]), gl.STATIC_DRAW);
const loc = gl.getAttribLocation(prog, 'p');
gl.enableVertexAttribArray(loc);
gl.vertexAttribPointer(loc, 2, gl.FLOAT, false, 0, 0);
// 640x360 is non-power-of-two: CLAMP_TO_EDGE + LINEAR + no mips is mandatory in WebGL1.
const texture = gl.createTexture();
gl.bindTexture(gl.TEXTURE_2D, texture);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
base.update(0, true);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, base.canvas);
const uni = {} as Record<UniformKey, WebGLUniformLocation | null>;
uni.t = gl.getUniformLocation(prog, 't');
uni.drift = gl.getUniformLocation(prog, 'u_drift');
uni.brownout = gl.getUniformLocation(prog, 'u_brownout');
uni.flash = gl.getUniformLocation(prog, 'u_flash');
for (const p of PARAMS) uni[p] = gl.getUniformLocation(prog, `u_${p}`);
gl.viewport(0, 0, canvas.width, canvas.height);
gl.uniform1f(uni.drift, 1);
return {
draw({ params, timeSec, drift, brownout, flash, idle }) {
// Re-upload the clean feed only when it actually changed (timecode tick / chyron).
if (base.update(timeSec * 1000, idle)) {
gl.texSubImage2D(gl.TEXTURE_2D, 0, 0, 0, gl.RGBA, gl.UNSIGNED_BYTE, base.canvas);
}
gl.uniform1f(uni.t, timeSec);
gl.uniform1f(uni.drift, drift);
gl.uniform1f(uni.brownout, brownout);
gl.uniform1f(uni.flash, flash);
for (const p of PARAMS) gl.uniform1f(uni[p], params[p]);
gl.drawArrays(gl.TRIANGLES, 0, 3);
},
};
}
export { BASE_W, BASE_H };

View File

@ -1,21 +1,139 @@
/** LANE-SCREEN territory. Stub — replace per lanes/LANE-SCREEN.md. */
/**
* LANE-SCREEN THE SCREEN compositor.
*
* The sky-display everything ships to. Starts sterile, ends deranged. Shipped items
* feed a corruption ledger (corruptionMap.ts) whose blend drives the 8 tuned glitch
* params of the ported GLYTCH shader stack (glitchStack.ts).
*
* Design contract with the player: WHAT you ship decides WHICH artifact; HOW MUCH you
* have ever shipped decides the intensity. Shipments arrive as waves, not switches
* every param eases toward its target.
*/
import type { GameData, ScreenFX, SimEvent } from '../contracts';
import { createBaseFeed } from './baseTexture';
import { createGlitchStack, type GlitchStack } from './glitchStack';
import { resolveLedger, unmappedItems, ESCALATION_ITEMS } from './corruptionMap';
import { PARAMS, ease, zeroParams, type ParamSet } from './params';
/** Seconds for a param to travel ~63% toward its target. Shipments feel like waves. */
const EASE_TAU = 1.2;
/** Brownouts hit fast and lift fast — the factory's pain should be legible immediately. */
const BROWNOUT_TAU = 0.12;
/** Above this total corruption, params start cross-modulating so a mature SCREEN looks alive. */
const WOBBLE_THRESHOLD = 0.8;
const WOBBLE_DEPTH = 0.12;
/** Distinct slow rates per param so the wobble never reads as one global pulse. */
const WOBBLE_RATE = [0.13, 0.19, 0.11, 0.23, 0.17, 0.29, 0.31, 0.07];
export function createScreenFX(): ScreenFX {
let ctx: CanvasRenderingContext2D | null = null;
let stack: GlitchStack | null = null;
const tally = new Map<string, number>();
const eased: ParamSet = zeroParams();
const out: ParamSet = zeroParams();
let targets: ParamSet = zeroParams();
let total = 0;
let units = 0;
let ledgerDirty = false;
let brownout = 0;
let brownoutTarget = 0;
let pendingFlash = false;
let hasShipped = false;
let lastMs = 0;
let frameCost = 0; // rolling avg ms of this module's CPU work
function recompute() {
const r = resolveLedger(tally);
targets = r.targets;
total = r.total;
units = r.units;
ledgerDirty = false;
}
function ship(item: string, count: number) {
if (count <= 0) return;
tally.set(item, (tally.get(item) ?? 0) + count);
ledgerDirty = true;
if (!hasShipped) {
hasShipped = true;
pendingFlash = true; // the awakening
}
}
return {
init(canvas: HTMLCanvasElement, _data: GameData) {
ctx = canvas.getContext('2d');
init(canvas: HTMLCanvasElement, data: GameData) {
const base = createBaseFeed();
stack = createGlitchStack(canvas, base);
const unmapped = unmappedItems(data);
if (unmapped.length) {
console.info(
`[screen] ${unmapped.length} item(s) have no corruption profile, using generic noise: ${unmapped.join(', ')}`,
);
}
// Debug/authoring hook — LANE-SIM does not ship yet, so THE SCREEN must be
// drivable by hand. Console: __fktryScreen.ship('melt', 40)
(window as any).__fktryScreen = {
ship,
brownout: (on: boolean) => (brownoutTarget = on ? 1 : 0),
reset: () => {
tally.clear();
ledgerDirty = true;
hasShipped = false;
for (const p of PARAMS) eased[p] = 0;
},
stats: () => ({
units: +units.toFixed(2),
total: +total.toFixed(3),
idle: !hasShipped,
brownout: +brownout.toFixed(2),
msPerFrame: +frameCost.toFixed(3),
tally: Object.fromEntries(tally),
params: Object.fromEntries(PARAMS.map((p) => [p, +out[p].toFixed(3)])),
}),
escalationItems: ESCALATION_ITEMS,
};
},
onEvents(_events: SimEvent[]) {},
onEvents(events: SimEvent[]) {
for (const e of events) {
if (e.kind === 'shipped') ship(e.item, e.count);
else if (e.kind === 'brownout') brownoutTarget = e.on ? 1 : 0;
}
},
frame(timeMs: number) {
if (!ctx) return;
// placeholder: sterile clean playback awaiting corruption
ctx.fillStyle = '#101018';
ctx.fillRect(0, 0, 640, 360);
ctx.fillStyle = '#2a2440';
ctx.font = '20px monospace';
ctx.fillText('THE SCREEN · 0 corruption', 140, 180 + Math.sin(timeMs / 800) * 4);
if (!stack) return;
const t0 = performance.now();
const dt = lastMs === 0 ? 1 / 60 : Math.min(0.1, (timeMs - lastMs) / 1000);
lastMs = timeMs;
if (ledgerDirty) recompute();
brownout = ease(brownout, brownoutTarget, dt, BROWNOUT_TAU);
const timeSec = timeMs / 1000;
const wobble =
total > WOBBLE_THRESHOLD ? (total - WOBBLE_THRESHOLD) / (1 - WOBBLE_THRESHOLD) : 0;
for (let i = 0; i < PARAMS.length; i++) {
const p = PARAMS[i];
eased[p] = ease(eased[p], targets[p], dt, EASE_TAU);
// Wobble rides on top of the eased value; it never feeds back into the ledger.
const w =
wobble > 0
? Math.sin(timeSec * WOBBLE_RATE[i] * Math.PI * 2 + i) * WOBBLE_DEPTH * wobble
: 0;
out[p] = Math.min(1, Math.max(0, eased[p] + w));
}
const flash = pendingFlash ? 1 : 0;
stack.draw({ params: out, timeSec, drift: 1, brownout, flash, idle: !hasShipped });
pendingFlash = false;
frameCost = frameCost * 0.95 + (performance.now() - t0) * 0.05;
},
};
}

View File

@ -0,0 +1,28 @@
/**
* The eight glitch parameters, ported verbatim from the WIMVEE GLYTCH prototype.
* Names and semantics are load-bearing: the fragment shader reads `u_<key>` and the
* prototype's tuning assumes these exact ranges (0..1 each).
*/
export const PARAMS = [
'mosh', // macroblock displacement
'melt', // vertical pixel drip
'chroma', // RGB channel split
'tear', // scanline horizontal tears
'roll', // vertical hold roll
'burn', // film burn blotches
'noise', // analog snow
'freeze', // time quantize + dropout
] as const;
export type Param = (typeof PARAMS)[number];
export type ParamSet = Record<Param, number>;
export function zeroParams(): ParamSet {
return { mosh: 0, melt: 0, chroma: 0, tear: 0, roll: 0, burn: 0, noise: 0, freeze: 0 };
}
/** Frame-rate independent approach toward a target. `tau` = seconds to ~63% of the way. */
export function ease(current: number, target: number, dt: number, tau: number): number {
return current + (target - current) * (1 - Math.exp(-dt / Math.max(1e-4, tau)));
}