The desk as built

Version 1 of the create desk, read off the code as it stood on 2026-09-28: this site at 8e86ee7, nes ffa239e, nes-bus 707485a, 2a03 a7fa5f5, 2c02 c9fe9e8, ntsc-crt f91aecb and nes-bench 83f9175. Every claim here was read in a file, not remembered. Where a file's own comment disagreed with its code, the comment was fixed the same night and the last sections say what was found.

The desk is /nes/create: the console from the play page with every tool we have for looking inside a running game, each in a window of its own. This page is the map to keep open while the desk's ergonomics are redesigned. It says, for each window, what it reads, what it does, where what it makes goes, and which of the other pieces it leans on. Every drawing opens full screen from a press; the caption under each says what it shows. The reports' working words are explained in Words the reports use; the desk's own words are at the end of this page.

The short version

  • One machine, many views. Everything on /nes/play and /nes/create follows one module, playEngine.ts. It owns two workers: the console (the NES as WebAssembly) and the picture (the NTSC signal path, on the GPU when the browser offers one and its decode agrees, in WebAssembly otherwise). Every window subscribes to the engine's one snapshot and calls its verbs. No window talks to a worker directly.
  • A third worker, per job, for the flow tools. flowEngine.ts starts a worker that plays a recording back and feeds the replay's trace to the flow analyser, then throws the worker away.
  • Four places things are kept, and nothing else. The browser's own private file store holds recordings, their reports, one copy of each game, the moments, and the code blocks of a cartridge from the disk. The browser's local storage holds the desk's layout and the touch pad's placement. The shelf, behind a GitHub sign-in, holds cartridges, one battery save per cartridge, sprite revisions as patches, and the code blocks of a cartridge from there. Everything else, breakpoints, the history trace and the step-back marks, lives in memory and goes with the page.
  • No live handoff between Play and Create. Both pages import the same engine, but leaving a page detaches it: both workers stop and the state resets. What carries over is what was saved: the shelf's cartridge and its battery RAM, and the moments and recordings in the file store, which are keyed by the game's digest and so find the same game again.

Seven repositories, one page

%% How the seven repositories become the console in the browser: the Rust crates on the left, the scripts in this repository that build and pull from them in the middle, and the three workers and two stores of the running page on the right.
flowchart LR
  subgraph repos["Sibling repositories (Rust)"]
    bus["nes-bus<br/>contracts, boards,<br/>prg_offset, CartState"]
    c6502["6502<br/>v6502-micro, v6502-pins<br/>(rev 3805107)"]
    a03["2a03<br/>CPU rung + APU tables<br/>RungState"]
    c02["2c02<br/>PPU per-dot stepper<br/>Fast (postcard)"]
    ntsc["ntsc-crt<br/>signal path, decoders,<br/>ntsc-wasm Pipeline"]
    nes["nes<br/>nes-console, nes-wasm,<br/>record.rs, state.rs,<br/>examples/*.rs"]
    bench["nes-bench<br/>the Pi, bridge, pads,<br/>tools/, docs/"]
  end

  bus -->|"tag v0.1.8"| a03
  bus -->|"tag v0.1.8"| c02
  bus -->|"tag v0.1.8"| nes
  bus -->|"tag"| ntsc
  c6502 -->|"git rev"| a03
  c6502 -->|"git rev"| nes
  a03 -->|"sibling path"| nes
  c02 -->|"sibling path"| nes
  ntsc -->|"tag v0.2.18"| nes
  nes -->|"trace, pad-log, export-testrom"| bench
  bench -->|"scope captures .u8 + .toml"| ntsc

  subgraph site["This repository"]
    boardnes["scripts/board-nes.py<br/>--board, --wasm"]
    boardntsc["scripts/board-ntsc.py<br/>--board, --wasm"]
    bpw["scripts/build-playground-wasm.py<br/>slowppu, apuvoices, flow"]
    pull["web/scripts/pull-nesdocs.mjs"]
    flowsrc["wasm/flow<br/>FlowTool (MIT, no chip data)"]
    data["data/nes.json<br/>data/ntsc.json<br/>data/flow.json"]
    pubnes["web/public/nes/wasm/*<br/>(gitignored, NC-SA)"]
    pubntsc["web/public/ntsc/wasm/*<br/>(committed, MIT)"]
    pubflow["web/public/nes/flow/*"]
    docs["docs/nes/*.md<br/>/nes/bench/*, /lab/*"]
    api["api/carts.py<br/>/api/v1/me/carts"]
  end

  nes -->|"wasm-pack"| boardnes --> pubnes
  boardnes --> data
  ntsc -->|"wasm-pack"| boardntsc --> pubntsc
  boardntsc --> data
  flowsrc --> bpw --> pubflow
  bpw --> data
  bench -->|"markdown, SVG, PDF, HTML"| pull --> docs
  nes & bus & a03 & c02 & ntsc -->|"markdown"| pull

  subgraph browser["The browser"]
    engine["playEngine.ts<br/>+ flowEngine.ts"]
    cw["console.worker.mjs<br/>Nes, Pipeline (pacer)"]
    pw["picture.worker.mjs<br/>Pipeline or WebGPU"]
    fw["flow.worker.mjs<br/>NesReplay + FlowTool"]
    opfs[("the private file store<br/>flow/recordings, flow/roms,<br/>flow/moments, flow/blocks")]
    ls[("localStorage")]
  end

  pubnes --> cw
  pubnes --> fw
  pubntsc --> cw
  pubntsc --> pw
  pubflow --> fw
  engine <--> cw
  engine <--> pw
  engine <--> fw
  cw -->|"colour, emphasis, parity planes"| pw
  engine <--> opfs
  engine <--> ls
  engine <-->|"HTTP + tm_session cookie"| api
How the seven repositories become the console in the browser: the Rust crates on the left, the scripts in this repository that build and pull from them in the middle, and the three workers and two stores of the running page on the right.
How the seven repositories become the console in the browser: the Rust crates on the left, the scripts in this repository that build and pull from them in the middle, and the three workers and two stores of the running page on the right.

The console is a family of Rust crates. nes-bus holds the contracts they share: the dot frame, the pin frames, a cartridge with its boards. 2a03 is the CPU and sound chip, 2c02 the picture chip, both built from the transistor-level chips and checked against them. ntsc-crt is the signal between the console and the television. nes puts the chips on one board and, in nes-wasm, packs the whole console for a browser. nes-bench is the real console on the bench, its bridge and its pads, and the notebook you are reading. The crates reach each other by git tag or by sibling path; the site reaches them only through three scripts that build and record what they built, and one that pulls their documents.

Three things this drawing makes visible:

  • The console's bundle is not committed; the signal path's is. The console bundle in web/public/nes/wasm/ is derived from the die data and travels under its NonCommercial ShareAlike licence, so it is built on the host by board-nes.py and gitignored. The signal path's bundle embeds no chip data and is committed. The flow analyser in wasm/flow is ours, MIT, and reads only a trace.
  • The picture bundle is held to the console's pin. The console pins ntsc-crt by tag (v0.2.18 at the time of writing), and the site serves the signal path's bundle boarded at that tag (data/ntsc.json, bundle). Two library tests in the deploy's first stage hold it there: one refuses a served bundle whose tag is not the console's pin, the other holds the served files to the record's digests. Until 2026-09-29 the served bundle was six tags behind the pin and nothing said so.
  • The API knows cartridges, saves, revisions and code blocks, and nothing else. Moments, recordings and reports never reach the server. A code block does only for a cartridge from the shelf, and only to that shelf, where the game already is.

What each repository gives the page

fromtowhat, and in what form
nes-bus2a03, 2c02, nes, ntsc-crtRust types by git tag: DotFrame, pin frames, Cartridge with prg_offset and CartState
65022a03, nesv6502-micro and v6502-pins at one git revision, cross-checked by board-nes.py
2a03, 2c02nescrates by sibling path; die-data tables measured at build; saved states RungState and Fast
ntsc-crtnes, 2c02crates by tag
nesthe sitenes_wasm.js and its .wasm by board-nes.py --wasm (gitignored); the test ROMs bars, pad and pad-dmc; figures in data/nes.json; digests held by the build check
ntsc-crtthe sitentsc_wasm.js and its .wasm by board-ntsc.py --wasm (committed); data/ntsc.json
wasm/flow (here)the siteflow.js and flow_bg.wasm by build-playground-wasm.py; data/flow.json
nes examplesnes-benchtrace output (.pins, .stim, .events.json, .overlay) read by xray.py and dissect.py; pad-log and bench-script output for compare-logs.py
nes-benchntsc-crt, nesscope captures (.u8 with a .toml)
every siblingthe sitemarkdown through pull-nesdocs.mjs; from nes-bench also the SVG sheets, the fab files, the drawing packages, the lab photographs and the keydown page at /lab/pad-keydown

The desk itself

%% The create desk: its fourteen windows, six open from the start and eight in the tray, and the floor transport, all reading one engine; the flow engine beside it; the three workers under them; and the three places anything is kept.
flowchart TB
  subgraph desk["/nes/create, Desk.tsx (layout in localStorage tm.nes.create.desk)"]
    direction TB
    subgraph start["open from the start"]
      direction LR
      screen["Screen<br/>canvas + Gamepad when stacked"]
      cart["Cartridge<br/>file / ShelfPicker, play key,<br/>battery line, Moments"]
      code["Code<br/>disassembly lit by PC,<br/>breakpoints, blocks"]
      cpu["CPU"]
      mem["Memory<br/>watch a page"]
      rec["Record<br/>start here / from power,<br/>list, read, open, export"]
    end
    subgraph tray["in the tray until asked for"]
      direction LR
      pal["Palettes"]
      oam["OAM<br/>a tile opens Sprites"]
      nt["Nametables<br/>the two tables the chip holds,<br/>drawn with the game's tiles"]
      spr["Sprites<br/>CHR sheet, edit, apply,<br/>IPS, revisions, rename"]
      read["Readouts<br/>performance only"]
      flow["Flow<br/>the report's views,<br/>save trace, download report"]
      hist["History<br/>trace on/off, step back"]
      about["About"]
    end
  end

  transport["PlayTransport (floor)<br/>power, reset, play,<br/>steps: half, cycle, op, line, frame"]

  engine["playEngine.ts<br/>snapshot + verbs"]
  fe["flowEngine.ts<br/>list, analyze, open,<br/>import, export, remove"]

  screen & cart & code & cpu & mem & pal & oam & nt & spr & read & hist & transport --> engine
  rec --> engine
  rec --> fe
  flow --> fe
  mem -.->|"OAM tile: desk.show(sprites)"| spr
  fe -.->|"a report opens: desk.show(flow)"| flow

  cw["console worker"]
  pw["picture worker"]
  fw["flow worker (per job)"]
  engine <--> cw
  engine <--> pw
  fe --> fw

  opfs[("the private file store, flow/")]
  shelf[("the shelf API")]
  ls[("localStorage")]
  engine <-->|"moments, recordings"| opfs
  code <-->|"blocks, from the disk"| opfs
  fe <-->|"recordings, reports, game copies"| opfs
  engine <-->|"battery save"| shelf
  spr <-->|"revisions (IPS)"| shelf
  code <-->|"blocks"| shelf
  cart <-->|"list, fetch"| shelf
  screen <-->|"pad placement"| ls
The create desk: its fourteen windows, six open from the start and eight in the tray, and the floor transport, all reading one engine; the flow engine beside it; the three workers under them; and the three places anything is kept.
The create desk: its fourteen windows, six open from the start and eight in the tray, and the floor transport, all reading one engine; the flow engine beside it; the three workers under them; and the three places anything is kept.

How the desk works

A window is dragged by its bar, sized from its corner, brought forward by a press anywhere on it, filled to the desk by a double press on its bar, and closed from its bar. The tray above the desk opens a closed window again, and Tidy puts every window back where it started. A window's name is its content's own heading, read after mount, so a panel is named in one place and the tray follows it. The arrangement is kept in this browser under one key, tm.nes.create.desk, as pixels, unclamped, one record per window (lib/desk.ts):

type Win = { x: number; y: number; w: number; h: number; z: number; open: boolean; max: boolean };
// stored as { v: 2, wins: Record<string, Win> }, keyed by the window's id

The version is 2. Bumping it discards every stored arrangement, which is how Record came to be open on every desk on 2026-09-27; Tidy removes the key. A URL hash naming a window, #record say, opens and raises it, which is how the strip's Record link lands on the desk.

On a phone, or any viewport narrower than 64rem or shorter than 36rem, there is no desk: the same windows stand one under another with their own headings and the strip under the bar maps them, as the play page does. It is one tree either way, so crossing the breakpoint moves nothing. A window's content is never unmounted, and a closed window is hidden rather than removed, because the screen's canvas is what the console draws on and a new canvas would stop the console.

Where the windows start

open from the startin the tray until asked for
Screen (top left), Cartridge (under it), Code (the middle column), CPU and Memory (top right), Record (bottom right)Palettes, OAM, Nametables, Sprites, Readouts, Flow (which opens over the screen and the code when a report arrives), History, About

Record is open from the start so the way into the flow tools is on the desk rather than in the tray (owner's call, 2026-09-27).

Each window: what it consumes, produces and saves

The engine publishes one snapshot. A window reads the fields it needs and calls the engine's verbs. "Engine state" below means the snapshot's fields; the worker paths behind each verb are in the next section.

windowconsumesproduces (verbs)savesnotes
Screenthe picture worker's bitmaps, painted by the engine into the canvasattach, detach; the Gamepad's setTouchPad(bits); keys handled in the enginepad placement per orientation and haptics, localStorage tm.nes.pad*the Gamepad shows only when the desk stacks (a phone); an off-screen input holds focus so a phone hands over the arrows
Cartridgeloaded, loading, powered, running, battery, why; the game's SHA-256, for what is kept under it (moments, recordings, the disk's blocks from the file store, the shelf's blocks from the shelf), counted again whenever either says it changedload(file, cart?) from disk or the shelf; toggleRunbattery RAM to the shelf on a timer, on pause, on hide, and before another load; restored on loadcontains Moments; the line says what is loading until the console answers, and what is kept for the game once it has
Moments (inside Cartridge)the game's SHA-256, momentsKeptsaveMoment, loadMoment, deleteMomentthe file store, flow/moments/<sha>/<id>.bin with a .jsona moment is the whole console (nes-console state.rs) with its frame count; Load is disabled while recording
Codemachine.code, codeAt, cpu.pc, breakpoints, stoppedAt, cart, the game's SHA-256; disassembles with the 6502 site's own tabletoggleBreakpoint, clearBreakpoints; a captured block, its label and notebreakpoints: engine memory, sent with every tick; blocks: the shelf for a cartridge from there, the file store under the game's digest (flow/blocks/<sha>/<id>.json) for one from the disk, kept as captured and the words as the field is left either way; exported as markdown or JSON downloadsa block comes back with the cartridge from wherever it was kept; only a browser without the file store leaves a disk cartridge's blocks in the page, and the window says so
CPU, Memory, Palettes, OAM (one component, State.tsx)machine (published every 200 ms while running), palette (the measured colours)Memory: watch(page); OAM: a tile button hands its tile to Spritesnonefour windows, one reader
Nametablesthe nametable RAM and the pattern memory as the picture chip sees it, through the board's banks, asked of the worker (nametables) each time the machine is published while the window is on view; ppu.ctrl for the pattern table, palette RAM and the measured colours, rom for the header's mirroringnothingnonedraws the two tables as the chip holds them, with the tiles the chip sees at that instant, whatever the board banks
Spritesbase (the parsed iNES image), rom, patched, palette, machine.palette (live), cart; on a board that draws from CHR-RAM, the console's pattern memory (patternMemory), asked with each publish while the sheet is on viewApply (reloadWith(image)), Revert, download .ips or .patched.nes; Keep a revision, rename one, load one, delete oneedits: a Map in memory; revisions: the shelf, as IPS with a messagethe only window that changes bytes; on a CHR-RAM board it only shows them
Readoutsframes, undecoded, per-frame costs, path, agreement, fps, drift, underruns, batterynothingnoneread only
Recordplay: loaded, recording, powered, framesRun, recordingsKept; flow: list, busy, open, whystartRecording("here" or "power"), stopRecording; per recording: analyze (Read), open, exportOne (.nesrec), remove, cancel, giveRom, importOnerecordings in the file store; a copy every 5 s while recording and on hidethe way into the flow tools; open on the desk from the start
Flowflow.snapshot().open (the recording's meta and its parsed report)the view (overview, modes, routines, loops, tables, pad, vars, raw); saveTrace(from, to) as .trace; downloadReport as .flow.jsonnothing new; the report is already in the file storebrought forward when a report opens
Historyhistory, running, moves, backDepth, machine.code; while paused, reads the trace bytes and parses them (lib/history.ts)setHistory(on), stepBackthe trace and the back stack in engine memory (the newest mebibyte of trace; 64 marks)a step back restores a moment saved before each step and cuts the history to it
PlayTransport (the floor)powered, running, loadedsetPower, reset, toggleRun; on Create also step(half, cycle, op, line) and stepFramenonerate and seek are always disabled, with the reason in their title

A few of these deserve a sentence more than a cell.

Screen is the console attached to a canvas. The engine paints each bitmap the picture worker sends; the window itself only owns the canvas and, when the desk stacks, the touch pad. Keys are read by the engine at the document, so any window can have focus and the pad still works.

Cartridge is the way a game gets in: a file from disk, or a cartridge from the shelf. It also carries the battery line, which says whether the board has battery RAM and when it was last kept, and the Moments list under it. Battery RAM goes to the shelf on a timer while a cartridge with a battery runs, when the console pauses, when the page is hidden, and before another load; it comes back on load. A cartridge from disk keeps its battery only for the page.

Code is the bus from the program counter, disassembled forward and lit at the PC. Breakpoints live in the engine and travel with every tick, so the console stops on them without asking the page. A block is a run of the listing the reader selected, labelled and noted; it carries the cartridge's digest, the range, the bytes and the text, so it can leave as the encyclopedia's own shape or as JSON. For a cartridge from the shelf it is kept there beside the revisions, the bytes with it, and comes back when the cartridge is loaded from the shelf again; for a cartridge from the disk it is kept in the browser's file store beside the game's moments, under the game's digest, and comes back when the same file is loaded again.

Nametables is the picture chip's own two kilobytes of nametable RAM, drawn as the two tables the chip holds, with the game's tiles from the pattern table the control register names and the four background palettes. The board's mirroring decides which PPU addresses land on which table: the header says it for a board whose mirroring is soldered, and the window says so when the board switches it instead. The tiles are the pattern memory as the chip sees it at that instant: the console reads it through the board's banks as they stand and puts the board's state back after every byte, because one board's read has a side effect of its own (MMC2's latch trips on its trigger tiles). So a board that banks its picture ROM draws with the bank on the bus, not the file's first.

Sprites is the one window that changes bytes. An edit is a tile's sixteen new bytes against the base image. The set can go into the console as a patched image (the base stays the base, so the change survives a power cycle), leave as an IPS patch or the whole patched image, or, for a cartridge from the shelf, be kept there as a revision with a message. On a board that draws from CHR-RAM the file carries no tiles, so the sheet shows the console's pattern memory as the game has drawn it, following the machine, and changes nothing: there is no base to patch.

Record, Flow and History are the desk's own tools, and the next sections follow what they make.

The seam between the page and the machine

The page talks to the console worker through a request and answer bridge: {id, path, ...} in, {id, ok, answer} out. Every path, who calls it, and the WebAssembly behind it (nes-wasm's Nes):

pathcalled bywasmused by
loadload, power on, reloadWith, Record (from power)new Nes(bytes)Cartridge, Sprites, Record
tick (dt, pad, pad2, breakpoints)the looppacer.tick, run_frames, run_frames_until, frames_doneScreen, Code
staterefreshMachinecpu_state, ppu_state, palette, oam, peekCPU, Memory, Palettes, OAM, Code
watchwatchpeekMemory
step (kind)stepstep_half_cycles, step_instruction, step_scanlinethe transport (Create)
framestepFramerun_frames(1)the transport (Create)
reset, offreset, setPower(false)reset; record_stop if recordingthe transport
batterysaveNow, load, startRecordingbattery_ram, set_battery_ram, has_batteryCartridge, Record
save, restoresaveMoment, loadMoment, stepBacksave_state, load_stateMoments, History
record (here, soFar, on, off)startRecording, keepSoFar, stopRecording, detachrecord_start_here, record_so_far, record_start, record_stopRecord
history, historyRead, mark, historyCutsetHistory, readHistoryBytes, markBack, stepBackset_history, history, history_end, save_state, history_cutHistory
ciramnametables, patternMemoryciram, chrNametables, Sprites

One tick is in flight at a time, so the time each display callback reports is the true cost of the one before; the drift policy that decides how many frames a tick owes is the signal path's own Pipeline, used here as the pacer and pushing no frame, so the rule is the repository's and never restated. load builds a console from the bytes on one of the boards the console has, seven at the time of writing, and refuses anything else by name. The game never leaves the browser.

The picture worker takes frame (the colour, emphasis and parity planes of the newest frame) and answers a bitmap, which path drew it and the agreement figures; its palette path answers the 64 colours as this worker measured them, which Palettes and Sprites paint with. A frame the console produced while the picture was busy is replaced by the next and counted as undecoded, so the console never waits for the picture and the sound never stalls for it.

The flow worker takes analyze (the game, the battery RAM, the saved state, the input log and the program's length) and trace (the same with a frame range, capped at 320 pictures).

One saved state, three uses

save_state returns an 8-byte digest of the game and then the whole console: a magic (TMNESSTA), a version, then the CPU, the PPU, the board, the cartridge's registers and RAM, the timing and the sound. load_state refuses another game's state and refuses while recording. The same bytes are three things on the desk:

%% One saved state, three uses: the console's save_state bytes become a moment in the file store, the starting point of a recording made mid-game, and the mark before each step that a step back restores.
flowchart LR
  ss["Nes.save_state()<br/>the whole console"]
  ss --> m["a moment<br/>flow/moments/sha/id.bin<br/>+ json with frame, name"]
  ss --> h["record from here<br/>flow/recordings/id/state.bin<br/>NesReplay.from_state"]
  ss --> b["a mark before each step<br/>engine memory, 64 kept<br/>step back = restore + history_cut"]
One saved state, three uses: the console's save_state bytes become a moment in the file store, the starting point of a recording made mid-game, and the mark before each step that a step back restores.
One saved state, three uses: the console's save_state bytes become a moment in the file store, the starting point of a recording made mid-game, and the mark before each step that a step back restores.

A recording, end to end

%% A recording from the press of Record to the Flow window: the engine asks the console for its input log and keeps it in the file store, then the flow engine replays it in a worker of its own, checking every picture, and the report comes back to the desk.
sequenceDiagram
  participant R as Record window
  participant E as playEngine
  participant C as console worker (Nes)
  participant O as the file store, flow/
  participant F as flowEngine
  participant W as flow worker (NesReplay + FlowTool)
  participant V as Flow window

  R->>E: startRecording("here" or "power")
  E->>C: battery, then record {here} or {on}
  C-->>E: (state.bin when "here")
  loop every 5 s and on hide
    E->>C: record {soFar}
    C-->>E: log so far
    E->>O: recordings/id/{meta.json, inputs.bin, battery.bin, state.bin}, roms/sha.nes
  end
  R->>E: stopRecording
  E->>C: record {on:false}
  C-->>E: the log with END
  E->>O: the final recording (left: false)
  R->>F: analyze(id)
  F->>O: read rom, battery, state, log
  F->>W: analyze
  W->>W: NesReplay.run(30) per chunk, checking each picture's digest
  W->>W: FlowTool.feed(take_trace())
  W-->>F: progress {checked, frames}, then {report}
  F->>O: recordings/id/report.json
  F-->>V: open = meta + report, and the desk shows Flow
A recording from the press of Record to the Flow window: the engine asks the console for its input log and keeps it in the file store, then the flow engine replays it in a worker of its own, checking every picture, and the report comes back to the desk.
A recording from the press of Record to the Flow window: the engine asks the console for its input log and keeps it in the file store, then the flow engine replays it in a worker of its own, checking every picture, and the report comes back to the desk.

The formats that cross here are the console's, defined once in the nes repository's record.rs and mirrored in the browser:

  • The input log, 16 bytes per event (a pad change, a reset, a frame's digest, the end), applied when the master clock matches. The log is the whole recording, because the console is deterministic from power-on: the same bytes and the same presses give the same frames, and the digests prove it on replay.
  • The trace, 8 bytes per record (a CPU cycle with its program offset, the registers after each opcode fetch, an input, a frame), which the flow analyser reads and History parses (lib/history.ts).
  • The report, JSON from FlowTool.report(): sites keyed by program offset, routines by kind, loops, variables, dispatch tables, modes, what followed the pad, and a per-frame timeline. flowEngine.ts carries the matching TypeScript type.
  • The export, .nesrec: a magic, then length-prefixed meta, battery, log and state. It never includes the game. A recording whose game is missing asks for it (Record's "give ROM", from disk or the shelf) and checks its digest.

A recording made from where the game stands starts from the whole console saved at that instant, kept beside the log as state.bin; the replay loads that state first (NesReplay.from_state) and then applies the log.

The shelf

%% The cartridge shelf: five callers in the browser, one API behind a GitHub sign-in, and the files and rows it keeps on the server.
flowchart LR
  subgraph browser
    picker["ShelfPicker<br/>listShelf, fetchCart (SHA checked)"]
    eng["playEngine<br/>getSave, putSave"]
    spr["Sprites<br/>listRevisions, addRevision,<br/>fetchRevision, patchRevision,<br/>deleteRevision"]
    code["Code<br/>listBlocks, addBlock,<br/>patchBlock, deleteBlock"]
    mgr["/nes/shelf Manager<br/>addCart, addRaw, patchCart,<br/>deleteCart, deleteSave"]
  end
  api["api/carts.py<br/>/api/v1/me/carts<br/>require_user (GitHub OAuth, tm_session)"]
  disk[("$STATE/carts/user/<br/>sha.nes  sha.sav  sha.rN.ips<br/>0600 in 0700")]
  db[("SQLite carts, cart_revisions,<br/>cart_blocks (the bytes in the row)")]
  picker & eng & spr & code & mgr <--> api
  api <--> disk
  api <--> db
The cartridge shelf: five callers in the browser, one API behind a GitHub sign-in, and the files and rows it keeps on the server.
The cartridge shelf: five callers in the browser, one API behind a GitHub sign-in, and the files and rows it keeps on the server.

The limits are the API's, read from its constants and its environment: a name of 80 characters and a note of 240; a battery save of 32 KiB; 16 revisions per cartridge; 64 code blocks per cartridge, each 4 KiB at most; a cartridge of 4 MiB plus its header and trainer; and 32 places per account, which an admin resizes and zero closes. A revision is an IPS patch against the base image; the patched image is built on request and never stored. A block is its range, the bytes that were on the bus and two strings; its listing is disassembled from the bytes wherever it is shown and never stored. The window event tm:shelf-changed refreshes every picker after a write.

What is kept where, and what survives

wherewhatkeyed bysurvives a reloadsurvives leaving the pageanother browser, another machine
the browser's private file storerecordings and their reports, one copy of each game, moments, the code blocks of a cartridge from the diskrecording id; the game's SHA-256yesyesno: this browser only, and clearing site data removes it
localStoragethe desk's layout, the touch pad's placement and hapticsfixed keysyesyesno
the shelf, signed in with GitHubcartridges, one battery save per cartridge, sprite revisions as IPS, code blocksthe account and the cartridge idyesyesyes, signed in
memorybreakpoints, the history trace, the 64 step-back marksnothingnonono

Two consequences for anyone redesigning the desk. A game's moments, recordings and (from the disk) code blocks are already indexed by its digest, so the cartridge line says what is kept for a game the moment it loads, on either page, and again at every change (Held.tsx, since 2026-09-29). And since 2026-09-29 everything written on the desk is kept somewhere: code blocks live in two places, the shelf for a cartridge from there and the file store for one from the disk, and a block never crosses between them, because each is asked for by the cartridge it was read on.

Leaving a page

%% What happens on leaving Play or Create: the engine detaches, both workers stop and the state resets; only what was saved to the shelf or the file store is there for the next page.
flowchart LR
  play["/nes/play"] -->|"leave: detach()"| off["both workers stop<br/>state = INITIAL<br/>last file and cart cleared"]
  create["/nes/create"] -->|"leave: detach()"| off
  off -.->|"nothing carries in memory"| next["the next page's attach()"]
  shelf[("the shelf:<br/>cartridge, battery RAM")] --> next
  opfs[("the file store:<br/>moments, recordings,<br/>keyed by the game's digest")] --> next
What happens on leaving Play or Create: the engine detaches, both workers stop and the state resets; only what was saved to the shelf or the file store is there for the next page.
What happens on leaving Play or Create: the engine detaches, both workers stop and the state resets; only what was saved to the shelf or the file store is there for the next page.

Detach stops the recording if one is running (record_stop), stops both workers and resets the engine to its first state, clearing the last file and cartridge. It announces nothing, because the only followers are the sections leaving with the page, and the next attach starts from the fresh snapshot. Record, Flow and History exist only on Create, while Play's engine carries their state and never exercises it.

What crosses each boundary

fromtowhat, and how
console workerpicture workerthe colour, emphasis and parity planes; a bitmap back
console workerthe file storethe input log, the saved state, battery RAM
flow workerthe file storethe report JSON
the pagethe APIHTTP with the session cookie: the game's bytes, a .sav, an IPS, a code block (its range, its bytes and its words)
the enginelocalStoragethe desk's layout; the pad's placement
the deskdownloadsa block as markdown or JSON; a recording as .nesrec; a trace; a report; an IPS or a patched image

Nothing crosses that is not in this table. In particular no key and no trace reaches the server; a code block does only for a cartridge from the shelf, and only to that shelf; and the game's bytes go up only to the reader's own shelf.

What the survey found behind the code

Reading every window against its own comments turned up eleven places where the words were behind the code. None was a behaviour; each was a comment or a line of copy written against an earlier desk. All eleven were corrected on 2026-09-28, and the list stays here as the record of what a survey of a system's own words catches:

  1. The create page's about text said a moment could not yet be saved on its own and that breakpoints were not there yet. Both were on the desk.
  2. The transport's seek title said nothing could be rewound; History steps back.
  3. The Sprites header said palette RAM could not be read from this bundle; the component reads it.
  4. The console worker's header listed three of the sixteen paths it answers, and said a recording could start only at power-on; one can start mid-game. Its ciram path had no caller (it has one since 2026-09-29: the Nametables window).
  5. The picture worker's header omitted its palette path.
  6. The file store's header omitted state.bin and the moments.
  7. The Record header said "from power-on" only.
  8. shelf.patchRevision had no caller (it has one since 2026-09-29: Rename, in Sprites).
  9. detach() resets the engine's state without telling its followers, which is harmless for the reason given above; it now says so.
  10. lib/nes-shelves.ts is the notebook's document shelves, not the cartridge shelf; both files now say which is which.
  11. Record, Flow and History exist only on Create while Play's engine carries their state; the engine's header now says so.

What the survey found missing, and what arrived

The survey of 2026-09-28 found four things not on the desk. All four arrived on 2026-09-29, and the rest of this document describes the desk with them on it:

  • Code blocks are kept. For a cartridge from the shelf they go to the shelf beside the revisions, as the window itself said they should, and come back with the cartridge. Blocks of a cartridge from the disk go to the browser's file store beside the game's moments, under its digest, and come back when the same file is loaded again. Only a browser without that store leaves them in the page, and the window says so.
  • The nametables have a window. Nametables asks the console worker's ciram path, which had no caller, and draws the two tables the chip holds.
  • A revision can be re-described. Rename, in Sprites, calls shelf.patchRevision, which no window had called.
  • The picture bundle is at the console's pin, boarded at the tag the console names, and a library test in the deploy refuses a served bundle at any other tag.

What this means for the ergonomics work

Facts the redesign can lean on, because they are structural rather than cosmetic:

  • Every window is a view of one snapshot. Moving, merging or splitting windows costs nothing in the engine; a new window is a new reader of fields that are already published. The desk's only per-window state is its rectangle.
  • The desk's own persistence is one key. The layout is {v, wins} in pixels under tm.nes.create.desk; a version bump discards it and Tidy removes it. A URL hash opens and raises a window.
  • Nothing is waiting for a home. The three things the survey found waiting (code blocks in React state, the ciram export with no view, patchRevision with no caller) each have one since 2026-09-29: the shelf, the Nametables window, Rename in Sprites.
  • Three things are already keyed by the game's digest and so follow a game from Play to Create and back: moments, recordings, and the code blocks of a cartridge from the disk. The cartridge line counts them the moment a game loads.
  • The bytes only change in Sprites. Every other window reads. An "edited" state for the whole desk is Sprites' edit Map plus the engine's patched flag, nothing more.

The desk's words

  • A moment: the whole console saved at one instant, with its frame count and a name. Kept in the file store under the game's digest.
  • A recording: the input log of one run, from power-on or from a moment, with the digest of every picture. The log is the run, because the console is deterministic.
  • A report: what the flow analyser read off a recording's replay: routines, loops, tables, modes, what followed the pad.
  • The trace: the console's own record of each CPU cycle, instruction, input and frame, 8 bytes each. History reads the newest of it; the flow analyser reads all of a replay's.
  • Battery RAM: the cartridge's own save memory, on boards that have one. Kept on the shelf, one per cartridge.
  • A revision: an edit to a cartridge's tiles, kept on the shelf as an IPS patch with a message. The base image is never changed.
  • A block: a run of the code listing the reader selected and described, with the bytes that were on the bus. Kept on the shelf for a cartridge from there; its listing is read from its bytes again wherever it is shown.
  • A nametable: one screen of tile numbers and their palettes, as the picture chip holds it. The chip's RAM holds two; the board's mirroring says which PPU addresses land on which.
  • A board: the cartridge's circuit, which decides how the console reads it. The console has seven and refuses the rest by name.
  • The picture worker: the signal path from the console's dots to a bitmap, on the GPU or in WebAssembly, on its own thread.
  • The file store: the browser's Origin Private File System, where a web page keeps files of its own. Never sent anywhere.

Written in this repository, not pulled: the desk is ours. The rest of this notebook is pulled from the sibling repositories at build time.