Skip to content

Tests ​

Prerequisite: Engine architecture.

This page is the inventory: which suite checks what, and how to run it. For why the port is believed correct — what the playthrough is for, what it has caught, and what the comparison deliberately ignores — see How we know it's right.

Where the suites are ​

Each package tests itself, and npm test collects them all: the root vitest.config.ts names no game, only the glob */vitest.config.ts, and each package's own config includes its tests/*.ts (Titanic's tests/auto/*.ts), so a package joins the gate by having one. Most of it reads the original game files (a local gamefiles/ copy), which is where the port's correctness claims bottom out; the rest builds its own fixtures and travels anywhere (Continuous integration is the split).

PackageDirectoryWhat it covers
taoot/taoot/tests/auto/the engine driven against Titanic's disc — nearly all the behavioural coverage, because that is the game the engine was recovered from
dust/dust/tests/Dust's own: its movies, its .rtd saves, its v1 movie playback, and its saloon games
timelapse/timelapse/tests/Timelapse's own: the mouse cursors out of its engine build, the half of its interface that says where the player may go. Its machine suites, the whole game played headless world by world, are timelapse/tests/machine/ and run with npm test -w timelapse (Timelapse)
engine/engine/tests/what needs no game at all: the write-path scaffolding, the focus rule for keys, a screen with no room on it, what the parser tolerates around a handler, the opcodes Timelapse needs, touch gestures, a stage that moves on its own, and the geometry of a sliding turn
site/site/tests/the shared layer: the layering rule, the chrome cascade, the six locale catalogues, who the front page credits, the docs nav, the deploy lanes and the web-app icons
redjack/, lunicus/, jumpraven/<game>/tests/the page's half of each game — its file store, saves, dialogs and input; the games themselves are played by their machine suites, below
skullcracker/skullcracker/tests/see Skull Cracker

Titanic has two further categories that are not in npm test, each with its own budget:

CategoryDirectoryCommandWhat it is
playthroughtaoot/tests/playthrough/npm run test:machinethe game played from the boot to the ending, asserting a recorded state trace — the 27 segments of the route and a few property tests, ~75 s
browsertaoot/tests/browser/npm run test:browser -w taootthe same route through real mouse and keyboard events against a live dev server, diffed against the same trace — ~24 min, because it costs what the game costs

A playthrough segment covers minutes of game time and the route grows as it reaches further into the story; keeping it out of npm test keeps the gate fast. Only harness.ts sits above the three — a real GameHost over the on-disk game files, which the automatic suites and the playthrough both boot. What newHost does to make a run reproducible — the injectable audio sink and the two seeded random streams (session.seedRandom) — is on the verification page; read it before diagnosing a flaky failure.

Titanic's automatic suite — taoot/tests/auto/ ​

SuiteVerifies
regression.tsend-to-end behaviour on real data: hotspot alignment and cursors, the Space door-opener, road-arrival facing, cross-set travel with global persistence, the staircases (the grand staircase's deck flips; the 2nd class staircase's 90°-per-press landings, its deck treadmill, and the floor where the treadmill hands over to the real flight down to F deck), the input queue (including that the movement LETTERS queue like the arrows — a four-press burst of W walks two rooms, not one, #207) and lockevents, doors and prop state, movies (spotmovie, the menu zoom, actionframe), the darkroom CLUT, the reserved white a room view may still draw through although a set fills only the lower half of the CLUT (#351), the deck map, inventory pickup, world-space props, the timing model (loops/crickets/walks/delay), actors (placement, walking, facing, endwalk patrols), puppets (the Smethells conversation, per-puppet frame caches, and a handler swallowed by an unclosed switch — #177), the per-view openscene subtlety, and every scripted mini-game — wireless, trunk gramophone, Enigma, boiler, bomb, turbine, blackjack, fencing, fight, fusebox. Plus the crowd's names (#199): lounge1c's openset builds up to fourteen extras, each named and placed out of its own star by findword's character mode; the check reads the room's log, where a space-splitting read leaves fourteen starxyz: no star "ex..cen" lines and five extras instead of fourteen
savegame.tsbyte-exact round-trip of every shipped .ti, patch-writes that parse back, story-state decoding, loadGame (travel, hallside recovery, foreign-file rejection), inventory possession restore, snapshotSave after load and after collecting an item, and what the load dialog does to the game behind it (#162) — driven through the control panel's own brass lever, so the cancel arm is CTL.STG's and not the test's: black while the modal is up, the panel back untouched on cancel or on an unreadable file, the loaded room drawn rather than left under the black it was rebuilt behind, and the world frozen throughout. The crowd (#186): the open-cast-file list decodes to exactly two shapes across the corpus, and every shipped save is loaded in sequence through one session (so a load must also leave the previous game's cast behind), asserting that each actor record with a set and visible resolves to a live actor afterwards. restoreActors logs a dropped character rather than failing, so checking that the load returned true is not enough — 344 missing extras across 39 saves pass that check. The walks table: a census of every live slot in the corpus (16 across 12 saves — 12 turns, one straight line, three authored routes) and both resumes end to end. Save 17 catches Daisy mid-stride on the Grand Staircase; the test rebuilds her saved position from the record's origin, deltas, distance and progress before loading it, so a misread offset fails as arithmetic rather than as a character in the wrong place. The three routes do the same from their waypoint payloads, then must arrive at the route's LAST point rather than cut the corner to it. An actor steps through its play script whether a walk is running or not, so a dropped walk leaves a walk pose treadmilling (reported against the #181 branch). The open audio banks (#199): a save lists every bank it had open and only one of them is playing, so a loader that opens only the theme's bank restores loops and crickets with no bank under them. Every shipped save with a cricket table is loaded and every restored cricket must resolve its sound — 49 of the 50 records fail with the theme's bank alone — and the reported room is played: load into lounge1c mid-sinking, run the clock past the restored playcrickets loop, and the sound channel must fire with no sound not found behind it. The globals a load leaves behind (#340): a save's variable records are the game's whole variable list, not a patch, so a load drops what the file does not name — pinned on the reported stamp (jonesframe set as the boat deck leaves it, the load rewinding the frame counter under it, and the Gorse-Joneses' gate open again afterwards) and over the whole corpus, where every shipped save is loaded and nothing its file does not name may survive. The script a load abandons (the other half of #340): the ending is one straight-line script, so a checkpoint loaded during it must abandon that script, not merely release the film it is parked in and let it run on into the next segment — and into if mission = "good" with the checkpoint's mission in the global. Driven with a stand-in for the browser's MoviePlayer (playmovie blocks, abandon resolves it), the load must leave the ending released, silent and scriptBusy clear, with the file's own room standing
save-original.tsa save the original game wrote, loaded by the port (#179) — the one save suite that runs without gamefiles/, because its whole fixture is the 47 KB taoot/tests/data/M4P0FCL.ti a player attached to that issue. The 109 shipped saves share one build of TI.EXE at one load address. This one was made in another DosBox, so the DFValue vtable its variable nodes carry is 0x87c4596f, not the corpus's 0x00431e0f; a reader that finds the node grid by matching that pattern decodes zero globals and a load applies an empty map — the right room with the previous game's mission, phase and everything else (the format note). The suite pins the premise (the fixture's vtable differs from ours, so a replaced fixture cannot pass quietly), the decode (mission 4, phase 0, handitem="shawl"), the reported sequence end to end (loading it over a mission-2 game through a real loadGame, asserting the three globals the reporter could see from inside the room), and the write half — a global added to a foreign base must read back, which it does not if the writer stamps our constant into someone else's grid
re_builtins.tsthe TI.EXE-recovered builtins in isolation: calcvectx/calcvecty round-trips, non-negative calcmod, path slots, the inert debugger family, dialog hooks, count/index enumeration, roadahead, soundvol/soundpan, currentsound
interp.tsruns winner() straight out of BLKJACK.STG against a full player/dealer totals matrix — the interpreter's original acceptance test
speedrun-aim.tswhere the speedrun drivers AIM, and whether the page reads it back as the pixel meant (#277). Both drivers name a gesture in CANVAS pixels and the page turns a client coordinate back into one with canvasCoords's Math.floor, so an aim is correct only if it survives that floor. The coordinate that arrives is a WHOLE number (measured: the browser truncates rather than rounds), so a half-pixel aim, origin + (v + 0.5) * scale, works while a canvas pixel is two client pixels wide and fails at one: the half pixel of centring plus a fractional rect.top lands in the pixel before the one asked for. It shows as dial(slider, 7) settling on 6, because the coal lever is the one control with no tolerance (calcswitchdeg clamps the cursor to 245..345 and divides by 5, so one pixel is one whole setting); every other gesture aims at a hotspot many pixels wide. The suite is the arithmetic alone, exhaustively: every canvas row at ten presentation widths and six fractional rect origins must read back as itself at a 1:1 scale or better, the half-pixel aim must miss, and miss LOW BY ONE wherever that is pinnable, every aim must be a whole client pixel (so how the browser delivers it cannot matter), and all twenty-one coal-lever stops must be reachable
speedrun-loads.tsthe load remover — a run's clock stops while the game is coming over a link (#251). Every room is a fetch, so the same sheet over a cold cache and a warm one is minutes apart with no gesture changed, and a wall clock measures the link as much as the route. Three seams, none of them a browser: the fetcher reports the wire to as many watchers as ask, LoadClock turns those edges into a total, and the run loop subtracts it from every leg. The arithmetic is pinned with time as a variable: a cache hit and a localhost fetch are disk reads and cost the route nothing (#369), six downloads in the air are ONE wait and not six (summing their durations would remove more than the run spent), the fetch still in the air counts (otherwise the clock ticks through a 37 MB film and then jumps backwards), a cache hit is not a load, a fetch spanning two actions is charged to each in part and to the pair once, and time + load equals the wall clock exactly
input-log.tsthe input log — what the player pressed, beside what the game said about it (#178). Tested as WORDING, with no browser and no engine: the module asks the page four questions (the clock, the location, the gate, and a hit test) and decides what to say. Most of the file covers gestures that did NOTHING, since a line claiming otherwise sends a reader looking for the bug elsewhere: a press the engine threw away must not read as one that worked, a press filed behind a camera move must say so, and the Nightdive intro's ESC (#171) must not read "nothing changed" when it skipped a film. Also: a log switched off costs the engine no hit test, and a slow gesture's cause line lands before the engine lines it caused
nav.tsthe navigator's judgement, tested where the route cannot reach it: hunt must answer on what a click moved, not on where it landed (why). A green playthrough makes no dud click, so the test needs a room where a click is guaranteed to do nothing: one standpoint sliced out of a real set with its turn rings and roads emptied, and a stub driver that lands every click and changes the world only when told to
text.tsthe character set the text is stored in (Languages): the three code pages decoded and re-encoded, byte-clamping that never splits a Shift-JIS character, and the per-language table re-derived from the shipped puppet files by an independent sniff over their dialogue, so a wrong entry fails instead of mojibaking. Languages not installed are skipped, and checking nothing fails. Plus wrapText: Latin at spaces, Japanese between characters, and never a line that starts with closing punctuation
audio-rates.tsmixed sample rates inside one file, and the joins that must survive them: resampleTo changes the sample count and not the duration (and keeps the waveform, compared against a tone generated natively at the target rate), a synthesized mixed-rate bank whose theme lasts as long as its chunks do, and the same assertion over every shipped tree's real banks — which fails on every mixed-rate tree if the resample is removed
sound-channels.ts`currentsound(1
shp-play-order.ts + cst-play-order.tsthe play script both sprite formats carry — the table that says what is shown when, and for how long, over the pictures a state or pose stores. shp: FUSE.SHP's fusedoor stores its swing twice, closed→open both times, so only the script says closing runs backwards; a reader that takes only as many entries as there are frames sees the repeats, judges the table "not a permutation", and plays the opening animation for the closing one. cst (#181): every walk in the game draws ten pictures under a twenty-step script, so a stride takes a second; cycling the pictures one per pass gets the character to the right place at the right time with the feet going twice as fast. It pins the file (every walk, every edition), the runtime (twenty passes, two per picture, and a still actor that never changes picture in a hundred), and the regrouping the script requires: a pose's pictures group by the step number each record carries, not eight at a time, because stok1 stores nine views of every picture and life1 seventeen — which is also why the drawn view is picked by its depicted ANGLE and not by a slot
pup-editor.ts + trk-editor.ts + set-editor.ts + shp-editor.ts + stg-editor.ts + cst-editor.ts + mov-editor.tsthe write half the browser editors are built on, over a SYNTHESIZED puppet, audio bank, set, shop, stage, cast and movie — built by the library's own writers (engine/src/df/*-build.ts, see the write path), so these run without gamefiles/ and check the editors' edits against files the write path produced. Structure-preserving read → write, byte-identical write → read → write, the SHP/v41/frame codecs round-tripping (the frame encoder is also self-contained — shown by decoding into a deliberately poisoned buffer — and carries a Z layer over), and each edit (subtitle text, frame art, track name, chunk identifier, loop order, set/scene/view/road names, default start, hotspot identifier and rectangle, actor mark, shop/prop/state names, frame degree and anchor, flat name, region name and rectangle, member and pose names, sprite anchor, frame name, frame and region action codes and names, region rectangle, action-frame slots, the ESC flag) touching exactly its own field — in the bytes and in the parsed structure the editor draws from. The shop suite pins the one case where those two can disagree: a state whose play-order table reverses its frames, where an edit made through the reordered view must land on the record that frame came from. The movie suite pins a NEGATIVE result: it hand-builds a frame that holds the picture before it (row mode 10 — a mode our own encoder never emits) and shows that replacing the frame under it changes what it decodes to, which is why the movie editor offers no art replacement. It also builds a chain of segments: each segment reads its own header, frames and action-frame slots, and an edit aimed at a later segment lands there and leaves container 0 byte-identical, which a patch that hardcodes container 0 fails
mov-format.tsthe MOV facts recovered from the demo build's movie loop, against the shipped corpus rather than a synthesized file — they are claims about what the authors wrote, which a file we wrote ourselves cannot contradict. A movie is a chain of segments and a segment can carry cues (tour.mov: 20 segments, and the one cue record in any shipped tree, which is what leaves its authored ship's-logo loop), the letterboxed films name their own screen origin (512×264 at (0,60)), and leave.mov is the whole sinking montage rather than the 70 frames container 0 shows. Each skips when the tree it needs is not installed
script-encode.ts + lang-chooser.ts + files-lang.tsthe authoring path and the language axis, all three without gamefiles/. script-encode: the opcode table is 1:1, tokens → bytes → tokens is the identity over every token kind, the string pool is shared and offsets are segment-relative, what cannot be encoded throws, and assembled source parses into the handlers it declared (including a handler named after a command). lang-chooser: the generated lang.stg read back (two flats, a region per language, art decoding at 512×384, non-overlapping on-screen rectangles, a script behind every button), then the screen played through a real GameSession with no set and no BOOTFILE — a click runs the button's own compiled mousedown, the number keys run the flat's own keydown, a language with no data behind it is refused by click and undone by key, and closing hands the stage back. files-lang: which URL a basename resolves to, per language and per disc, with a fake manifest and a fake fetch — the active language across both discs, then the neutral tree; a language switch dropping that language's cached bytes but not the neutral ones; and the shipped saves picked from one tree
smokestack.tsthe false smokestack's four mazes, and what each one looks like. mazenumber = random(4) is drawn once at the door and everything after it is the disc's own table, so this pins all of it: blocks for every (maze, level) against setupblocks() verbatim, a floor that looks the same however you arrived at it, a shut gap that refuses the walk and an open one that takes it. Then both halves of #339. The bug: a checkpoint loaded out of the stack left the last climb's crates behind, so a floor wore two mazes at once — reproduced over all sixteen (before, after) pairs, and failing on the load itself without the fix. The not-bug: the runners' identification rules (blocked at once is maze 4, crates across the shaft is maze 3, nothing is 1 or 2) hold for ONE of smstack1's four ladders; the suite pins both the rules from Scene37 and the four-by-four grid from the other three that disagrees with them, where maze 1 reads as maze 3. What it SEES is counted in pixels that reach the screen through the SET's own Z image, because a crate at the far side of the ring is in the draw list but behind a wall — the difference between "boxes in the distance" and nothing at all
stack-plan.tsthe speedrun's way up the false smokestack (src/speedrun/nav/smokestack.ts), planned over setupblocks()'s table with no rip: every maze climbs from scene37 in 18 moves, maze 4 has no way up from scene39 and no other entry is dead, and every plan is a chain a player could walk — each move from where the last landed, walks round the ring by the scene's own exit at the same level, climbs by a ladder scene one level up, the last into smstack3
bedsit-room.tsthe bedsit's room as geometry (bedsit/src/bedsit-room.ts) and its matrices (bedsit-optics.ts), in node: every part's arrays the size its count says, a unit normal on every triangle with any area — two of the window wall's had none, from a quad that was a triangle, which the room's flip-towards-the-eye shader shows as a lighting fault — each chart drawn once with its texels inside it, the shell inside its walls, the lamp and the door as pieces; the ceiling's pitch, the windows and the pendant's tables; each of the builder's primitives (box, sphere, tube, sweep, bend, spun, lathe, disc, a placed and a material piece, pinned picture corners) facing out; and the projection, lookAlong straight down, and view agreeing with it about which way a bearing faces
captions-editor.tsthe caption editor (src/captions-editor.ts, taoot/captions/) in node, with made-up banks: a row per clip, film sound and track line; ▶ playing a clip as a WAV inside the click and a track line from its start to its end; why a clip will not play (not in the bank, bank still loading, no track, the browser refusing); Enter on to the next line and playing it, Shift back, Ctrl again; an edit kept as a draft, flagged, given a speaker and counted against the repository's file, a draft's track timings giving way to the repository's; Save, Copy, an issue with only the changed lines or — too long for a link — the file saved and the issue asking for it; Reset only when confirmed; an edition with no file starting from the English clips, and one that speaks another's recordings saying whose file captions it
devmode-page.tsdeveloper mode's page (src/devmode-page.ts) against a stand-in window.dbg: the flag raised once the game is up and put back when a load takes it down, but not when the page's own switch turned it off; the menu bar running TI.EXE's commands through menuselect, Debug On/Off turning the flag off by the script's case and on by the page's; the greyed commands and why; one menu down at a time; the accelerators, and not the greyed ones or another modifier; the option/command latches, a real held key, setloc, the map overlay's switch; and the console's answers and history
collection.tsthe collection page (src/collection.ts) and its booklet (src/booklet.ts): each face's scan per edition, a default edition shown but not written down and a pick written, the face buttons and arrow keys, a drag snapping to the face it stopped SHOWING — tipped over from the back it is the top, not the back — one disc or two, and the booklet only for the edition whose booklet survives: spreads and their readout, a turn landing early when the next click comes, no leaf for a reader who asked for less motion
mov-play.tsthe movie player over a shipped film — the state machine, not the container. mov-format.ts proves camelsee.mov says what it says; this proves the port acts on it. A flag that is parsed and then ignored passes every format assertion (#172: the gym's horses stood still)
nightdive.tsthe intro film and its question, played the way a player plays it — over the bytes mknightdive.ts generates, through a real GameSession and the engine's own MoviePlayer, with no gamefiles/ and no BOOTFILE. The intro runs before the boot, so it must not need anything a language tree carries, and the answer must come back through the engine's own actionframe() rather than a hook the page installed
reproducible.tssource rules rather than behaviour: the two ways a run could stop being reproducible — an unseeded random, a read of wall time — banned at the source. Asserting the behaviour directly costs two full 27-segment runs and a byte comparison of 27 goldens
debug-panel.tsthe state list behind X, against a real recorded beat rather than made-up variables, because what the panel must handle is the SIZE of the real table (102 globals at the credits, 113 at its fullest) with only a handful moving between beats. The "what just moved" default needs masks.ts. The filter is pinned against the same beat (#178): several terms `
log-buffer.tsthe pane's rolling log stays bounded, the lines a bug report carries are the newest ones, and a full repaint is rare rather than per-line
sets.tsevery .SET of every edition on disk and the demo, both discs' copies, read without a game tick: each walk starts and ends at a view its set has, every picture a walk or a turn shows is there, every script reads (an object or scene with none keeps eight zero bytes, 617 of them an edition), every file a script names by a literal is in the edition, and every changeset lands on a set, scene and view that exist. One does not, and the game ships it so: gstair3's changeset ("gstair3", "scene65", "view79"), whose View79 is Scene10's — the engine lands on View69 by the facing
page.tsthe play page itself (src/main.ts), in node: the real play/index.html parsed by linkedom, with a stand-in GameHost, file store, intro, language chooser, saves and site chrome. The boot's order — the edition by ?edition=, a remembered one, a page's pinned one or the chooser; the volumes; the intro and the preload; then coldBoot — and the intro's "no" leaving for GOG; the speedrun, tour and developer pages as the same file with their meta tags (pinned edition, no film, the pane always up or never, the tour on boot and on quit()); the pane behind X and what it remembers, ?debug=1, the details copied or saved as a file; the dialogs, save, load and the save template by disc; the picture, brightness, movement, low-memory and every-line rows; keys through the script chain, an overlay stage taking every key, the gamma keys moving the presets; clicks, drags, the input log's "where", and a finger's swipe, prop tap and double-tap ESC, the intro's included
play-rules.tsthe page's rules that need no page (src/play-rules.ts): a pointer floored onto the 512x384 framebuffer, TI.EXE's five key names and the overlay's letters, the ESC/Ctrl marker, F1-F9 by channel, the brightness presets counted in F1 presses and named back from a gamma, the stored picture mode and the old sharp checkbox, the save dialog's name, and the input log's "where" and its gate in KEY_SAFE's order
pages.tsthe three small pages: the front page opening the speedrun once the mark's animation has ended however it ends (src/home.ts); the workbench handed this game's actions, its sheet and a warm-up list of one edition plus the neutral files (src/speedrun/page.ts); the tour page stepping through a refused door on a forward key and saying what a door click opened, or that it leads nowhere (src/freeroam-page.ts)
cache-warmup.tsthe warmer that pulls an edition through the browser cache before a timed run. What matters about a 1.2 GB download — does it hold the bytes, does one missing file take the other 663 with it, does Stop stop — is invisible in a real run, so the fetch is a fake and the clock is a variable
cursors.tsthe mouse cursors out of ti.exe — eleven CURS.* resources, byte-identical across the demo and every shipped edition, covering all five names the corpus asks for (touch 809, arrow 75, hand 36, watch 18, fist 2). Three of the other six have no CSS keyword with their meaning, which is half of why the art is carried rather than mapped; the other half is that it is the 1996 artwork. godown is checked by its SHAPE, because Timelapse's build redraws that one and a swapped table would otherwise go unnoticed. A name this build has no art for still hides the pointer: hidecursor() answers none, and only Timelapse ships a CURS.NONE
konami.tsthe front page's one hidden door, tested on false starts rather than the happy path: a cursor-based matcher that resets on a wrong key passes hand-testing and still locks out a player who presses an extra ↑
ui-languages.tsthe chrome's six languages and this game's six editions are the same six — a coincidence, not a constraint, so the test fails naming which side moved rather than offering a language chooser a page nobody wrote
devmode.ts + map-overlay.tsdeveloper mode's menu bar against the RT_MENU resource it is generated from: every command names a case BOOTFILE's menuselect has, or is a hole the page knows about; and the deck-map overlay's frame loop — shown only on the map stage, rebuilt when the plan turns, not every frame
freeroam.tsfree roam: the tour is still a mode in the corpus and still shuts the doors the page opens, which editions have a tour, and that the doorway table is read from the corpus
files-cache.tswhat the browser file store keeps: movies past the byte budget evicted oldest first and refetched, the movie just asked for never the one evicted, and nothing that cannot be fetched again ever dropped
save-seed.tsthe shipped saves seeded into the save browser once per language, a language switch replacing them while keeping the player's own, and no seed marker when nothing was stored
prop-views-go-round.tsa prop's view keeps playing while the prop stands in it, as TI.EXE's prop pass (0x418bb0) does every displayed frame: the trunk gramophone's crank turns while the record plays (#472) and the deck map keeps rolling open until a script changes its view; Dust's views still play once and hold
bedsit-settings.ts + bedsit-xr.tsthe bedsit page's settings store (a change reaches every listener, a non-change reaches none) and its WebXR pose maths against a faked headset — scale, handedness and the direction of a forward step
speedrun-controls.ts + speedrun-hands.ts + speedrun-stand.ts + speedrun-move.ts + speedrun-say.tsthe speedrun verbs one rule at a time: dial, wireless and mapJump; take, use, closeUp, intro and mission; stand planning over the room in memory; move(u,r,u,o) expanding into the actions it names (#250); and then: leave / then: stop out of a conversation (#265)
speedrun-headless.tsthe sheet's opening played twice in node under the sheet clock, ending on the same pass both times — a run is a function of the sheet (#509)

The suite boots real GameSessions and drives a virtual clock — no DOM, no wall time, no audio output (the NullAudioSink records what would have played, and reports every non-looping play as done the instant it starts, which keeps it deterministic; a suite that needs sounds to take time brings its own sink, as sound-channels.ts does). Prefer extending regression.ts over throwaway tests.

Dust's suites — dust/tests/ ​

Dust reads a different disc, so its suites live with it. The ones that open it skip rather than fail without gamefiles/, which lets the runner treat Dust's rip as optional; the ones that stub what they read need no rip at all.

SuiteVerifies
movies.tsDreamFactory 1 movies: the engine-true record layout (80-byte records at 0x8c2, DF.EXE 0x40484d), the sound and chain fields, and the adapter that plays them through v4's machinery. Every field is checked against the whole shipped disc rather than one file, because a reading of the frame table can fit most of the data and still be wrong about which field is which
movie-effects.tsa v1 frame's transition, record +0x06: every entry of DF.EXE's table at 0x405c9c copies what an emulator trace of its routine copies, step for step; the fades' clamps and the black a fade-out leaves; the codes the shipped films use; that the dirty rect at +0x28 is exact; that the voice wait is +0x1a bit 0 and nothing else; and the player fading DOG1.MOV in and wiping PAPER1.MOV's page up, on a clock in node
mov-play.tsthe player over a Dust film, on a clock and with its own audio sink. It covers what the file above cannot (#278): record +0x1a bit 0 — "hold this frame until the sound it started has finished" — must be acted on, not just parsed into flags2; otherwise the dog that stops you leaving town growls once instead of twice
salgames.tsthe saloon's blackjack and poker, by running SALGAMES.FLT's own scripts. Both depend on two primitives. result() after indextoprop names the FILE the prop came from — if only hittest sets it, the round-clearing loop hides nothing and a second hand is dealt on top of the first one's cards and its WINNER banner. And variable(name) resolves LOCALS before globals — poker counts faces into thirteen locals and reads them back by computed name, so a globals-only lookup answers 0 for all of them and every hand at the showdown scores as its high card, four aces included
saves.tsthe claim the Dust save support rests on: a .rtd is the SAME container a .ti is, so the shared framing reader and writer take Dust's files unchanged — and reproduce every byte of every save that came off a real DOS installation. Plus the store's discovery of them, and the claim the collection is one continuous session ordered by its frame counter
cursors.tsthe mouse cursors out of DF.EXE; DreamFactory 1's set is not v4's: nine, no godown/goup at all, and a CURS.TOUCH drawn differently from Titanic's (the two builds share eight of the nine byte for byte and disagree about the pointing hand). Dust's scripts ask for seven of them 285 times — touch 205, arrow 40, watch 34, sight 3, and one each of gostrait/goright/goleft — and the shell must call hover for any of them to appear, including the crosshairs and the direction arrows that are the only sign a doorway can be walked through
scenegrid.tsthe town as a GRID — rowcoltoscene and scenebuild, two DreamFactory 1 commands whose ids belong to something else in DreamFactory 4. The two engines' tables (read out of both binaries by taoot/tools/exetable.ts) disagree on twenty ids and Dust calls eight of them; most are renames — a "ball" is v4's cricket, a "dir" its view — but these two are deferred-call forms in v4, and answering 0 breaks extra.cst's five bounty hunters: their isbuild asks rowcoltoscene(y, x) for the scene on a cell and scenebuild(name) whether it is built on, and with both answering 0 nothing is ever "none", nothing is ever built, and all 225 cells of the town read as open street. The build flag is scene record +12, pinned by the fact that on 28 of the disc's 29 sets "the flag is set" is EXACTLY "no transition touches this cell" — TOWN's 173 against the 52 its 526 moves reach
loads.tsDust's fetches reach the load remover — the same clock Titanic's speedrun subtracts (#251), kept in the engine (engine/src/web/load-clock.ts) so the two ports time themselves the same way. Dust's part is the wire underneath it: each fetch announced once, by URL, to every watcher that asks, and closed even when it throws — a period the clock never sees end reads waiting for the rest of the page's life. The flag worth its own test is waited: only the fetches the game is STOPPED for may be removed (#369). Titanic's store gets that from having two fetch paths; Dust's provide starts the same one flight an awaiting caller would, so whether anybody is waiting depends on who got there first. Needs no rip: the clock's now and its cache verdicts are injected and the fetch is a stub, because a localhost fetch is classed local and removed from nothing, so a dev server could not answer this either way
sets.tsevery set on the disc read without a game tick — the rest of each room, which no playthrough rung stands on: every move's frames there, every place a move arrives at a place with a move out of it, every standpoint with exactly one standing picture, the default standpoint walkable, every script readable (the authoring tool's empty scene scripts, eight zero bytes, are none), and every set, film, sound, cast or shop a script names by a literal on the disc or inside a sound bank (themevol ("saloonsep.snd") is an entry of every SALOON*.SND). Three things are not, and are listed with the reason: target.set's keydown sends one view to groc.set (the disc's store is store.set); UNDER/MINE.SET carries no hi-res stills at all, so the page stands on each move's last frame there; and hub.set alone stores a second copy of a move's coordinates that disagrees with the first
page.tsthe play page itself (src/main.ts), in node: the real index.html parsed by linkedom, with a stand-in GameHost, file store, saved-games dialog and bug report, and fetch answered off the disc. The head phase (the panel and APOTH drawn and walked by the set walker before the engine exists — skipped without the disc); the boot's order — the plan, the prefetch with town.set and intro3.mov, the Enter button, play, then coldBoot — and its account (globals, stage, shops, casts, misses, viewer); what it says with no set, a first room the disc does not serve, no viewer, a boot that throws or a page that does; autostart and details-always; the trace logged once per change; the game's save and load answered through the dialog, named by the room; window.dbg's trace, load clock and loadSave; keys by the names the boot maps with isrepeat from a held key, b for the log, every key held by an open saves dialog; a mouse press, drag and release with shift as it was held; and a finger: a double tap skipping a film with no room open, a prop tapped, a swipe walking
walker.tsthe set walker's rules (src/walker.ts), on a made-up set and then on the disc: the two turns in register order and the walk, the hi-res still before a move's last frame, the walker's keys and its one-line account; APOTH's frames all view-sized and its turns coming round in four; the mine stood on arrival frames
input.tswhich key is whose (src/input.ts): the arrows by the boot's names, W/A/D passed through for its own mapping, Escape as the movie player's ., a held key said as held, b for the log, and shortcuts, named keys and a text field's keys left alone; a pointer floored onto 512x384; the save dialog's default name
meter.tsthe loading pages' network meter (chrome/src/net-meter.ts), Dust's and Timelapse's: no rate before the window settles, old samples forgotten but the total kept, the fraction and the time left once the remainder is known
speedrun-page.tsthe workbench page's wiring: Dust's own key space, actions and published route, and a state pane that waits for a game
speedrun-verbs.tsDust's own speedrun verbs against a fake that evaluates the same window.dbg expressions the page does: every verb reachable by name, the repository's route parsing against Dust's vocabulary, and a verb's grammar checked when the sheet is read
save-seed.tsthe shipped saves reaching the browser (seedDustSaves) and the base a first save is patched into (loadDustTemplate): a deleted save stays deleted, a new one still arrives
banks.tsthe v1 .snd banks, which have no loop-order container: every bank's one-shot and loop counts, and the theme a script asks for being the bed at the end of the bank
setmain.tswhich container a v1 set calls its main script — the field, not header 0x1c, which is a constant; undertak.set names container 2
lifecycle.tsa jump made from inside openscene does not re-enter it, which froze the morning after sleeping in the hotel
conversation.ts + patrol.tsan actor keeps where it was going through a turn and a conversation (#289), and walks its authored route along the streets rather than straight through the buildings (#394)
props.ts + targets.tsa prop assigned to a set is scenery seated when that set opens, not a screen overlay (#290); the shooting range's 2D-actor targets placed, drawn and hit (#292)
checkers-script.tsCHECKERS.PRP's own win () run against the disc: losing to Bolivar ends the game instead of stopping with his gloat half-said
machine/credits.ts + machine/demo.tsmachine suites (npm run test:machine -w dust): the cow menu's credits (NEW.FLT/0028) scrolling all eight pages of CREDITS.PRP over successive backdrops, wrapping, and a click putting the panel and the interrupted theme back; and the cow skull's long demo (NEW.FLT/0032), its films and puppet scenes in their two interleaved orders until a held button ends it — skipped until DreamFactory 1's setwidth () answers

Timelapse's own — timelapse/tests/ ​

SuiteVerifies
cursors.tsthe mouse cursors, which carry navigation: 11,031 of Timelapse's 13,200 cursor(...) calls are godown and goup — "you can back up from here", "you can step forward here" — over regions that look like nothing at all, so the arrow under the hand is the only sign a picture has an exit. cursor(name) names a CURS.<NAME> cursor resource inside the engine's own executable (tools/dumpcursors.ts has the composition, at tl.exe 0x421060), and the set is per BUILD: four of Timelapse's seventeen are not in Titanic's ti.exe, and of the thirteen both carry, eleven are byte-identical while GODOWN and GOUP differ — Titanic's are plain arrows, Timelapse's stand on a foot. Two are checked by RENDERING them, because a flipped plane, a swapped palette or a mask read as colour all pass a dimensions test and fail a picture: CURS.ARROW against its 32 rows with the hotspot on its tip, and goup against its plinth. Also: only three states are ever drawn (Windows' fourth, invert-the-screen, has no CSS and the generator refuses a table that uses it); 2× is exact nearest neighbour rather than the browser's blur; the lookup folds case the way Win32 resource names do (the discs spell two of them HyperLink and None); with no canvas to draw a PNG on, every name still answers a keyword; and a picture shown at a FRACTION gets a cursor that size rather than the integer below it — Titanic shows a 512-wide picture at 1024 CSS pixels, a clean 2×, but a narrower window is 1.4×, where an integer-only cursor stays 32 px, 30% too small against the art it sits on
page.tsthe play page itself (src/main.ts), in node: the real index.html parsed by linkedom, with a stand-in GameHost, file store and bug report. The loader's order — the plan, the prefetch weighed in bytes, the version tags, the Enter button, and only then coldBoot — and what it says when the manifest is empty, the BOOTFILE is missing, the prefetch is short or unsized, the boot throws or the page itself does; the location readout drawn from the game's globals and its stage's table, asked once per position; keys, clicks, the SPACE button, the cursor asked for after a click, on a move and on a resize; ?slowturn=, the console's tl.jump/tl.eval, the network spinner's delay; and on a touchscreen, a swipe that walks while an edge region under a resting thumb does not
input.tswhich key is whose (src/input.ts): the arrows by the names the BOOTFILE's router answers to, Escape as the movie player's special key, b kept for the log, and the browser's shortcuts and a focused control's keys left alone; a pointer on the game's 640x480 whatever size the picture is shown at; the four edge regions a finger is not given
location.tsthe readout (src/location.ts): each verb of a getframeaction word in words, read caselessly as the game's switch does; the bug report's line led by the flat; HTML escaped

RedJack's own — redjack/tests/ ​

The page's half of RedJack; the game itself is played by its machine suites, redjack/tests/machine/ (RedJack).

SuiteVerifies
files.tsthe file store on the page: three discs as one index by lowercase basename, bootfile served as this rip's bootfile.boot, death.move followed to the disc the game says it is on, and one fetch per name
saves.tswhich files the shared save dialog takes as RedJack's, and the port's seven day saves put into its list once — a day the player deleted does not come back
page.tsthe play page itself (src/main.ts), in node: the real index.html parsed by linkedom, with a stand-in GameHost, file store, saved-games dialog and bug report. The loader's order — the BOOTFILE, the plan, the first room fetched once, the Enter button, and only then coldBoot — and what it says with no rip, no BOOTFILE, a boot that throws or a page that does; the readout and the cursor each frame (none when hidden, the arrow in a conversation); the game's save and load answered through the dialog, named by the room; keys by v5's names, the arrows' releases, the space bar held as well as pressed, every key held by an open saves dialog; the right button pressed as button 2 with the browser's menu kept off; and a finger: no walk on a tap on the bare room, a prop tapped, a slow drag looking round, two fingers zooming
input.tswhich key is whose (src/input.ts): the arrows by the short names the BOOTFILE's router remaps, Escape as the film player's special key, b for the log, the browser's shortcuts and a focused control's keys left alone, and only the arrows coming up; a pointer on the game's 640x480; the readout's line, room or stage; the save dialog's default name
room-touch.tsa finger in a room (src/room-touch.ts) against a stand-in camera, with the clock injected: a tap on the bare room swallowed, a slow drag turning the camera under the finger and kept, the pitch held to the scroll's own 60° up and 50° down, a quick drag read as the flick and the camera put back, a control, a walk or another owner of the screen let by; two fingers the right button held at their middle until they lift or are cancelled, and no zoom away from a node

Lunicus's own — lunicus/tests/machine/ ​

Machine suites, not in npm test: npm test -w lunicus runs each in a process of its own (vitest, lunicus/vitest.machine.config.ts). A suite stands the game machine (lunicus/src/game/) up on the rip read from disk and steps its 1/60 s ticks as fast as the CPU goes, waiting on the game's state and never on a duration. harness.ts walks the base and the city by the maze's own transitions (a breadth-first search over them, pressed as arrow keys), clicks a crew member's figure where the camera draws it, and answers a talk's menus. Every gesture goes through lunicus/src/input.ts, the door the page's own mouse and keyboard listeners use, and RECORD=<path> on day6.ts keeps them, with the tick each came before, the files read and a checkpoint at each ok.

tests/browser/playthrough.ts (npm run test:browser -w lunicus, with the dev server up) replays such a run in Chromium: it plays the machine route first to record it, opens the page with ?drive&seed= (the page ticks only when told, on the run's seed), fetches every file the run read, and hands in each gesture as a real Playwright mouse or key event at its tick. The machine is deterministic, so the replay is the same run; each checkpoint compares the page's level, progress and score with the run's and the first that differs fails naming the tick. HEADED=1 PACE=4 shows it in a window at four times game speed; the screenshots land in out/lunicus/playthrough/. To watch the same run in any browser on the network, open the page with ?replay=/replays/playthrough.json&seed=1994&pace=4: it plays the last recording on its own clock, with the sound on. [ and ] halve and double the pace.

tests/browser/saves.ts (npm run test:browser:saves -w lunicus) checks the page's saved games in drive mode: the Load button lists the port's day saves (gamefiles/save/, written by SAVES=gamefiles/save npm test -w lunicus -- day6) and opens Day 4 onto its lower floor; the panel's save button brings up the dialog and keeps a named file; and that file opens back into the same game.

The suites beside them in npm test pin what turns the page's own input into an answer the machine suites hand the game ready-made: page.ts (the page itself, src/main.ts, in node: its clock, its status line in every place the game can be, which gesture reaches which door of Input, the Load button, ?drive and ?replay), dialogs.ts (the High Score and Keys boxes, answered as LUNICUS.EXE's procedures answered them), menu-bar.ts (what the bar shows and greys, and its picks), sco.ts (LUNICUS.SCO's key table at its edges, and a file of the wrong size met with the defaults) and save-seed.ts (the port's day saves put into the browser once, a deleted one staying deleted).

SuiteVerifies
menu.tsthe menu bar's commands through the page's door: the bar up on the title and not over the intro; Settings ▸ Expert, and a new game opening with it; Sound levels and Theme; Help ▸ About over the title, Esc out of its credits, and the title given back pixel for pixel; then in a game File ▸ Save as the save button, Help ▸ Help as the help button, the title's commands ignored, and File ▸ Exit back to the title with no death counted
scores.tslunicus.sco, the key table and the high scores: the rip's file is the EXE's defaults; the title's plate drawn with no name asked for; Settings ▸ Keys showing the table, OK binding new keys (the arrows kept) and keeping the file, and the base turning by the new key and not the old; in a game Cancel keeping the table and the defaults put back; a score past the seventh place asking for a name as the title opens again and taking the first place, and Cancel putting nothing in
films.tsa film frame that waits for its sound (flag bit 0): the intro's lecture, whose last slide is held until the narration ends, and not a tick past it
saveload.tssaved games through the game's own path: day one to its bed, the panel's save button on day two's first view writing a .LUN that holds the game as it stood; the briefing moving the game on and File ▸ Open putting it back — level, progress, the HUD, and no suit or gun; day two played through from the opened game; then, from a new machine's title, a building's fifth floor opened straight into the city and played to its node
day6.tsthe whole game from the intro: after day five, the hive with four drones — its cabinets fill again from an elevator ride — until its ENEMIES bar is empty, its elevator down to the final chamber and the bar full again, that bar emptied, and the queen: finalin, trans, her talk walked to its end, and final.move's credits clicked away once they loop. DRAW=1 plays it with every picture decoded
day5.tsdays one to five from the intro: day five's briefing, the suit and the gun taken down to the lower floor (the guards allow it today), the lower elevator's bottom button to the engine rooms with a full HUD and three drones, the bot clearing both rooms — their elevator's down button trades one room for the other until the ENEMIES bar is empty — and tohive.move down to the hive
day4.tsdays one to four from the intro: day four's city with two drones and the pulse gun, whose bullets bounce off the walls, and bed into day five
day3.tsdays one to three from the intro: day three's city is day two's route (days/city-day.ts) with a drone added — a flying bomb that comes a cell at a time at the player and goes off on reaching it — which the bot shoots down when it can; then bed into day four
day2.tsdays one and two from the intro: day two's briefing and the .2 talks, the suit and the gun, the transporter onto a Los Angeles building's fifth floor, the city played by city-bot.ts — it aims what it sees, turns to what lines up with it, takes the elevator down floor by floor and empties cabinets when its ammo runs low, and brings the node down once the ENEMIES bar is empty — then cityrise.move back to the station at progress 4, the lockers, and bed into day three
day1.tsday one played from the intro to the bed: the title, a new game waking in bed at (4,6), the bed refusing before the briefing, the rooms of both floors and their films or HUD lines, a talk with each of the six crew members from the right talk file for the day's progress (.1 before the briefing, .3 after), the briefing moving the progress to 4, the elevators, the suit and gun lockers, the guard's line at the transporter, and sleep opening day two's lower floor. DRAW=1 decodes every picture as the page does

Jump Raven's own — jumpraven/tests/machine/ ​

Machine suites in Lunicus's shape: npm test -w jumpraven runs each in a process of its own (jumpraven/vitest.machine.config.ts), the game machine (jumpraven/src/game/) stood up on the rip and stepped as fast as the CPU goes. Every gesture goes through jumpraven/src/game/input.ts. See Jump Raven's machine suites.

SuiteVerifies
opening.ts, back.tsa run with nothing to open, the intro to the high scores; and the way back from a flight — the drop, the briefings, the damage screen and the debrief
flight.ts, combat.tsthe city's moves, FLY and HOVER; the enemies firing at a craft sitting still, and a jeep shot until it blows up and is paid for
day1.ts … day3.tseach day's flight to its end, flown by the copilot, with PODS one pod from empty and the boss weak
wholegame.tsthe intro, three days and the copilot's ending, chained, with the game set up
fairgame.tsthe whole game won by src/player.ts with nothing set, on seed 7
menu.ts, saves.ts, films.tsthe menu bar and the HUD's buttons; the .RVN save written, read back and opened before the run and on the high scores screen; a film's voice cutting the one before it
screens.ts, weapons.tswith the drawing on (draws: true): the screens between flights, each picture and number where RAVEN.EXE put it, checked against the window's palette indexes; and each of the six weapons fired from the view

jumpraven/tests/dialogs.ts is in npm test: what each of RAVEN.EXE's dialog procedures hands back for its buttons — the high score's name, Quit, Pause — which the machine suites stand in for with callbacks. So is jumpraven/tests/page.ts: the page itself, src/main.ts, in node — its clock, its status line in every place the game can be, which gesture reaches which door of Input, the Load button and the menu bar's Open, Memory and Exit, and ?autoplay.

The engine's own — engine/tests/ ​

None of these need a game. Where one also checks a rip, that half skips (loudly) when the rip is not there.

SuiteVerifies
df0-formats.tsLunicus's files, DreamFactory 0: hand-built bytes for each reader — a frame whose fourth run kind copies from the compressed STREAM (so a copy-the-row-above reading gets different pixels), the one-bit figure codec, a headerless sound, a puppet track, a maze whose third transition borrows the first one's film — and then the rip, where every reader is strict and nothing is left over: every sprite, figure and backdrop row lands on its width, every bank entry ends on its last byte, every non-empty puppet track has one keyframe per two blocks of the voice line before it, and a maze's owned films tile its frame containers end to end
df-build.tsthe shared write-path scaffolding the seven builders rest on: a container's index is handed back as it is allocated, a reserved container stays writable after later ones exist (a header names what follows it), a gap reads back as a gap, the field writers agree with the readers — doubles big-endian, everything else little — and the palette block puts each channel value in the high byte where paletteToRGBA looks for it
keys.tswhich keys belong to a focused control rather than to the game. The play page's shortcuts are letters and it listens on window, so without this rule a text field and the game hear the same keystrokes — filtering the state list for mission would toggle the minimap on the M and the hotspot overlay on the O
window-bar.tsthe game window's menu bar: a Win32 label's text, underlined letter and accelerator hint; the bar on the frame above the canvas; a click selecting, a disabled item not, check marks moving; Alt+letter, Alt alone, the arrows over the disabled items, Enter and Esc as Windows has them; no key reaching the game while a menu is open; the accelerators working with the bar hidden; and no keys at all while the page says so
window-dialog.tsthe game window's dialog box: a template's controls where it put them, in dialog units scaled by the frame; a button a command to the procedure, which closes the box itself; Enter the default button and Esc IDCANCEL; and the page told a dialog is up until it closes
screen-director.tsa screen with no room on it — the case the class exists for. It authors a 640×480 stage with buildStgBytes and never builds a SetViewer: the compositor answers "flat", a fade runs to its end with nothing but the director to step it, a click walks the whole priority chain down to a flat's region, hittest answers for the stage and never for a scene, a key is offered and refused, and a FILM is fetched before it is looked for — the provider answers only for what has already arrived, as a browser's does. Timelapse has no preload list at all: its boot() ends in enterworld ("I") and builds every name by concatenation, so readBootPlan finds no literal to fetch ahead and every film arrives on the miss that wants it. The right-hand column at x=639 carries the geometry: it is past Titanic's 512, so a screen pinned to Titanic's size cannot pass it. The cursor is re-asked when the SCREEN's owner changes, not only when the mouse moves: a shell hears only pointer movement, but lockevents freezing the world answers watch whatever is under the pointer, and in the original that hourglass appears the instant the lock goes up (its idle loop sets it every pass) — also under a still hand, such as a player waiting for a character to walk over. Cheap by construction: a four-part gate (the film on screen, a shown puppet, the events lock, the flat and set) compared once a frame, so the setcursor chain runs on a flip and not sixty times a second
parser-deadtokens.tswhat the parser tolerates around a handler, and what it still refuses. The 1996 compiler left fragments of earlier compiles at a container's top level — after the last endcode, and in front of the first — which the original never sees because it dispatches by name and never walks past the handler it wants. This port walks the whole container, so a throw there would cost the caller every handler in the file: Timelapse's BOOTFILE ends endcode ( ), which blocks boot, and Dust's BOLIVAR.PUP has three handlers behind an abandoned compile. Also exitcode ()/passcode () with an empty argument list, and a case closing an if whose endif the compiler never wrote. The refusals are pinned too: a container declaring no handler is still an error (these discs carry 31 one-token containers sniffScript cannot tell from a script, Titanic's 273), and exitcode (1) stays one
timelapse-opcodes.tsthe six opcodes a THIRD rip needs that an engine recovered from two lacked — plugin (42 calls), pluginfx (4), prophide (2), freemem, sysmem, sendtobootfx. Left to onUnknown, four of them give a silent wrong answer rather than a missing feature. The big one is plugin("xray"): a light dragged over a dark flat reveals a SECOND flat through the light's own shape; the test authors a 640×480 stage and a diamond-shaped stencil so the corners of the mask frame — inside its bounds, outside its shape — must stay unrevealed. Also: a moved aperture is a repaint and not a matching signature, and sendtobootfx addresses the boot rather than the stage (a decoy handler of the same name sits on the flat). Three cover the CAMERA, the one plugin with a store behind it: a shot is 320x240 taken from where the viewfinder was aimed and drawn at (160, 120), both numbers tz.dll's; the save may refuse ONLY for an id already used, because docamera reads any other refusal as "roll another" inside a while true; the album hydrates from its store, and a store that will not open costs persistence and nothing else. Opening a stage file lifts the fade the old stage's palette was under, which makes the album visible — it is the one panel flat whose openflatx never calls blacktoscreen. The memory report deliberately reports a SMALL machine, because minMemory picks between two turn handlers and only one is built from opcodes this port has. Two are about NAMES: sendtoboot addresses the whole boot and not just its first container — Timelapse's endinterface/begininterface/docamera are the library's, so otherwise the panel's own buttons dead-end and the camera does nothing — and names FOLD CASE the way the language does, which the journal's pickup needs (gJournalTaken = 1 against every reader's gjournaltaken, and Playsound against the library's PlaySound). hidecursor/showcursor are a COUNTER, not a flag, as the original's are (tl.exe 0x4087b0 is ShowCursor(FALSE) plus a decrement of its own tally at 0x45b418): the game hides the pointer in the three places where it draws its own — the bow being drawn, the camera's viewfinder bevel, the endgame — so two nested hides need two shows, the hover chain is not asked while it is hidden, and the endgame's unmatched hide is undone by a restart, because the game is over and nothing else will. Last, sendtopost addresses the BOOT's containers, not the STAGE: all 110 of Timelapse's calls name one of seven handlers defined in the BOOTFILE library and nowhere else in the corpus — 58 stage-to-stage moves, and the four views that approach the cave's lantern, whose entire mousedown is sendtopost (jumptoframe (873))
touch.tsa finger on the glass, and the four things it can mean. The recogniser is engine/src/web/touch.ts, shared by Titanic's, Dust's and Timelapse's pages; the speedrun driver deliberately steers around it with pointerType: "mouse". A gesture must start with no room open: Dust's films play on the director with no set open, while on Titanic the boot opens a set before the logos roll and the one film with no viewer, the Nightdive intro's, is not the director's film and has its own line in the page. What is under test is the ambiguity: a finger going down begins a tap AND a swipe, so the mousedown is withheld until the gesture declares itself, and every bug this can have is a wrong declaration — a click delivered on a swipe (the camera turns and the thing under your finger opens), a swipe eaten by a drag loop, a press handed over and never released on pointercancel. Also: a diagonal decides nothing rather than guessing the nearer axis; a tap clicks where the finger LANDED rather than where it lifted; a third quick tap is a fresh first and not another escape; a finger on a prop or a button is pressed at once so a while stilldown() drag is never ruled a swipe; a FIRST tap at clock zero is a click — the previous-tap time is a sentinel as well as a time, so 0 must not mean both, or a tap inside the first 320 ms of a page's life sends the escape (the other tests start their clock at 1000 and cannot see this); and the recogniser tracks ONE pointer: a second finger's move and release are not the first one's, which is what owns() answers. No DOM: the hooks are the seam and the clock is injected
flat-anim.tsa stage that moves on its own. Timelapse animates by walking a RUN OF FLATS — flatstartanim(2, 54, "FlatAnimDone()", 8) — 433 times across its discs, and never in the other two games. Three requirements, none of them about animation: makeLoop keeps the callback NAME's case (Titanic's 67 loop callbacks are all lowercase; Timelapse names 9 of 34 in CamelCase and Dust one, SOUNDFXS, Chinatown's ambient loop); a flat loop can reach a handler the BOOT owns (a flat with no script resolves to its stage, so the chain is never empty, only unable to answer); and flats are not each decoded into a fresh FrameBuffer, because the cels are DELTAS — image.ts warns about exactly that at the top. Includes a hand-assembled delta frame (row mode 10, "keep this row from the previous image") and the claim that protects the other two games: a keyframe decodes the same whatever preceded it. A fourth, found in play: gotoflat sends a flat's openflat/closeflat along the chain rather than straight at its own script, so Timelapse's BOOTFILE defaults run and baseflat follows every move within a stage (otherwise the sea shows one view's water while the player stands at another). A loop that comes due mid-DRAG: fireDueLoops counts a timer loop down whether or not a script is in flight and then declines to fire it, so a loop armed inside a while stilldown() handler would sit at zero until the player let go. Timelapse's match is the worked example — striking it plays the sound and arms makeloop("prop", "Matches", "MatchBurn()", 30 / 6), and the flame must start before the button comes up. forceupdate() is the drag's own yield and in the original it IS a service pass, so the pump fires a due timer loop as well as the per-frame ones — without counting it down, which keeps the pace the engine's rather than the display's. And a JUMP into the middle of a variant chain: a flat named i{region}.{frame}.{n} — three components — is a delta over the variant before it, .1 over the base and .2 over .1, so seeding from the flat on screen is right for an animation (walked in order) and wrong when a script jumps. Timelapse jumps on purpose: the lantern's instruction sheet is gotoflat("i0001.605.1") with the matches on the table and gotoflat("i0001.605.2") once they are taken, both from the table; .2 decoded over the table changes 4,771 of its pixels and leaves 302,429, so the table shows instead of the instructions
turn-effect.ts`visualeffect(turnleft
visual-effects.tsvisualeffect's reveals as each exe draws them, pass for pass: TI.EXE's wipes, barn doors, irises, scrolls, venetian and turnup/turndown (which tl.exe and RedJack.exe share), most of which no shipped script asks for; DF.EXE's own plan for Dust (fixed passes in sixteenths, twelfths or slats, steps only the hold, no turns or nodraw); and RedJack.exe's photodissolve blend. Synthetic pictures whose pixels say where they came from, so a scroll (the arriving picture moves) is told from a wipe (it is uncovered in place) and a push from both; strips of extent / steps + 1 (or / (2 * steps) for the two-sided ones), two passes on the first frame, and nodraw as the dispatcher's bare ret that leaves the black alone
df5-script.ts + df5-room.ts + df5-loop-table.ts + maze-render.tsDreamFactory 5: the opcodes and segment kind v5 added to a script, the room's angle arithmetic and click projector (and, with the rip, every RedJack room read and every sphere agreeing with the films that leave it), a film's loop table read from 0x226 so its last record is kept, and a node's sphere and a road's film drawn on rooms built in the test
df5-shop-sound.tsa v5 view's own sound: the u32 at 0x0c names a SOUN container of IMA ADPCM, measured from Villains' Revenge (#477) and built from nothing here, since that game is not carried
savegame-v5.ts + savegame-builtins.tsRedJack's .save as bytes — writer, reader and the two checks RedJack.exe's loader makes first — and savegame/opengame at their edges, including RedJack.exe's two refusals
set-any.ts + container-refs.tsa SET opened by the version container 0 states, never by its name or game; and every container index a header names checked against what it points at (#325)
v0-screen.tsthe DreamFactory 0 game window's drawing primitives, which Lunicus's and Jump Raven's machine suites run with draws off
mov-film.tsa film laid out on a clock for the movie editor's video export (#435): holds, loops closed at their jump back, and one event sound at a time
fade-after-movie.tsa fade after a film takes down the film's last frame, not the room behind it — the lift gate that shut again for a moment (#380)
checkers.tsBolivar's checkers opponent, pluginfx("checkmove", …): the script's own rules as referee, and golden/checkers-dll.json, CHECKERS.DLL's answers run under an emulator, matched move for move
audio-mute.ts + audio-sinks.tsthe listener's mute kept apart from the game's own wavevolume/themevol, and the two browser sinks and the bank library on paths the machine suites (which play into NullAudioSink) never take
speedrun-clock.ts + load-clock-hidden.tsthe speedrun clock: game time that counts ticks, stands still while the engine waits on a file, and stops while the tab is hidden (#375)
speedrun/tests/condition.ts + speedrun/tests/sheet-header.ts + reply-ids.tsthe sheet grammar's joined conditions (a or b, a and b, !a); a contributed sheet's header, title on line 1 and the rest optional; and the answer ids a conversation can show for say([…]) (#377)
fullscreen.ts + stretch.ts + tylerhartman.tsthe Fullscreen button on a browser with no requestFullscreen (an iPhone), the stretched fullscreen's remembered box and pointer maths, and TH mode's layout on a 16:9 display
page-url.tspageUrl resolves a path against the page and refuses anything that leaves the site

The shared layer — site/tests/ ​

SuiteVerifies
layering.tsthe one structural rule the layout rests on: a shared package may not import a game. engine imports nothing, site imports engine, and the two games import both and never each other. It is a test rather than a convention because the violation compiles perfectly
cascade.tsthe bundler does not get a vote on what anything looks like. Every page links the shared chrome and then writes its own <style> after it; in a build Vite re-inserts the <link> at the END of <head>, so two declarations of equal specificity swap places. Dust's page is a grid and the chrome sets display: flex on body — the pair this file exists for
front-doors.tsthe front page credits the right studio for each game. CyberFlix wrote the engine and made two of the three; Timelapse is GTE Interactive Media's. The credit is a field on the registry AND a badge in the markup — two copies of one fact — and this holds them together, including that the badge's visible text is the attribute the test reads, so the two cannot agree while a reader sees something else
locales.tsthe catalogue must say exactly what the markup says. The pages carry their English inline (so an English reader needs no JavaScript and git diff shows the sentence that changed) and locales/en.ts carries the same English for translators — two copies of a string, kept in step by this file failing when one moves
docs-nav.tsevery documentation page is reachable from the hand-written nav in docs/.vitepress/config.ts, and every nav link goes to a page — add a page to the nav in the same change
deploy-lanes.ts + nightly-suites.tsevery game has a deploy lane and every lane names a game, across the four places deploy.yml says it; and the nightly browser.yml runs what was meant (CI)
manifest-keys.tsa manifest's keys are paths as served, whichever directory it was written from
app-icons.ts + landscape.tsthe home-screen web-app manifests and their icons (npm run mkappicons), and every game's page going fullscreen when a phone is turned on its side

Running the playthrough ​

npm run test:machine -w taoot         # the 27 segments and the speedrun suites, headless
npx vitest run --config taoot/vitest.machine.config.ts -t "playthrough 13"   # one segment
TAOOT_RECORD=1 npx vitest run --config taoot/vitest.machine.config.ts        # re-record the goldens

Checkpoints are written to out/checkpoints/ as .ti files, one per segment, and are what a filtered or fresh run resumes from when there is no live game to continue (why a run has two modes). The routes are segments.ts, one function per segment; the segment-by-segment table of what each crosses is the route.

Dust's playthrough — the other kind of evidence ​

npm run test:machine -w dust                                            # the whole route
npm run test:machine -w dust -- -t "D2A_001 → D2A_002"                    # one rung

Titanic's playthrough asserts a recorded trace: the game as this project last saw it. Dust's asserts something this project cannot forge. gamefiles/save/ holds the 61 .rtd files shipped on the disc, and all but one of them are a single continuous session played by CyberFlix on DF.EXE in 1995 (the golden thread). A rung is the play between two consecutive saves, and the runner loads the first, plays, and checks the live game against the second — the room, the standpoint, the view, and the globals that rung is about. A pass says the port arrived where the original engine arrived.

Rungs are independent, which Titanic's segments are not. Both ends come off the disc, so a rung can be written, run and debugged on its own, in any order, and by more than one person at once. Hence the layout — route.ts is the vocabulary every rung is written in (walkTo, openDoor, clickActor, answer, talkOut, excuseUs), and each helper's doc comment records what goes wrong without it; rungs/ is one rung per file.

What a rung claims is a judgement, and each exclusion is written down beside the segment with its reason. D2A_003 → D2A_004 cannot claim the shooting range's four hit counts, because the shipped save kept four names of a five-name dumpglobal while the port discards the list. Across all 55 saves this shows that a global's presence in an .rtd is not proof it was live: a dumped name keeps its record until something is allocated over it, and those four go on the next save that allocates anything.

Running the browser suite ​

It drives Titanic's page, which is its own dev server on 5175 — so npm run dev (the front door, 5173) is not the one to start:

npm run dev -w taoot                         # in another terminal — Titanic on 5175
npm run test:browser -w taoot                      # the demo, menu-movie, then the whole route, ~39 min
npm run test:browser:playthrough -w taoot          # just the route
npm run test:browser:endgame -w taoot              # just the ending, ~3 min, off a checkpoint
npm run test:browser:lang -w taoot                 # the language chooser, with real clicks
npm run test:browser:workbench -w taoot            # the speedrun workbench's panel, and Play
npm run test:browser:workbench -w dust             # the same, on Dust's disc
npm run test:browser:demo -w taoot                 # the 1996 demo's menu, ~30 s (skips with no demo rip)
npm run test:browser:m0 -w taoot                   # segment 1
npm run test:browser:m1 -w taoot                   # segments 2–6
npm run test:browser:m2 -w taoot                   # segments 7–16
SEGMENTS=13 npm run test:browser:seg -w taoot      # one segment
npm run test:browser:minigames -w taoot            # the minigames booted on their own, with no ship behind them
npm run test:browser:devmode -w taoot              # developer mode's menu bar and overlay
npm run test:browser:freeroam -w taoot             # free roam's opened doors
npm run test:browser:transitions -w taoot          # nothing paints the world while a transition waits for bytes
npm run test:browser:vr -w taoot                   # the bedsit's VR vignette
npm run test:browser:board -w taoot                # the bedsit's board
npm run test:browser -w dust                       # Dust: a load's standpoint, the shooting range, the walk back

APP_URL overrides where it looks; the driver treats it as the game's root and appends play/, and it defaults to http://localhost:5175/ — this game's own dev server. CI sets it explicitly, because browser.yml runs the server on 5199 rather than 5175 to stay off the port a person's own npm run dev -w taoot would want.

Two browser checks run against the build rather than a dev server, because the bugs they cover only appear there:

npm run build -w dust && npm run test:built -w dust    # Dust's layout, as built
npm run test:built -w site                          # the site, served from a subdirectory

The second builds the site itself and serves dist/site under a /dreamrefactory/ prefix, which neither npm run dev nor vite preview can show: both serve at a host's root, where a URL that is wrong for a subdirectory still works. The typical case is a URL assembled in TypeScript at runtime, which Vite never sees and so never rebases — the games menu asking danielhobi.ch for /mark-taoot.png. Anything the page requests that does not answer 200 fails it, gamefiles.json excepted: the editors probe every game for one to find out which rips a deployment carries.

Run it on its own — it waits on real frames, so anything else using the machine changes what the game does rather than merely slowing it down. No second browser, no vitest, no docs build alongside it.

Watching it play ​

npm run dev -w taoot
npm run watch:playthrough -w taoot                 # a real window, slowed down, stays open
npm run watch:mission0 -w taoot                    # the boot, the flat, the bomb, the crossing
npm run watch:mission1 -w taoot                    # skip the boot, watch all of mission 1
npm run watch:mission2 -w taoot                    # ...and all of mission 2
npm run watch:m1p0 -w taoot                        # just the segment that starts at phase 0
npm run watch:m1p1 -w taoot                        # ...phase 1
npm run watch:m1p2 -w taoot                        # ...phase 2
npm run watch:m1p3 -w taoot                        # ...phase 3
npm run watch:m1p4 -w taoot                        # ...and mission 1's debrief
npm run watch:m2p0 -w taoot                        # ...and the start of mission 2
HEADED=1 SLOWMO=400 KEEPOPEN=0 npx tsx taoot/tests/browser/playthrough.ts

The per-segment scripts are named for the checkpoint they resume from, so the script and the savegame it loads have the same name — watch:m1p2 reads out/checkpoints/m1p2.ti, which is also what the error names if you haven't recorded one. watch:mission1 is the whole mission and grows a segment at a time.

SEGMENTS picks which stretches to play, and picking a subset changes how it starts: a segment whose predecessor isn't in the run has no live game to continue, so it loads the same .ti checkpoint its headless twin starts from (out/checkpoints, written by npm run test:machine). That lets you watch mission 1 without sitting through the crossing — and it is also why a filtered run's trace comparison may stand down, since a golden speaks for one mode. The segment's own assertions still run either way.

Standalone scripts (run with tsx) ​

These have top-level side effects, so they're outside the Vitest run:

npx tsx taoot/tests/browser/playthrough.ts                # the playthrough route, in a real browser
npx tsx taoot/tests/browser/endgame.ts                    # the ending only, resumed from m4anti.ti
npx tsx taoot/tests/browser/menu-movie.ts                 # screenshots + state dump of the menu movie flow
npx tsx taoot/tests/browser/lang-chooser.ts               # pick a language in a real browser, then check what the boot reads
npx tsx taoot/tests/browser/repaint.ts                    # does the renderer ever skip a frame it should have drawn?
npx tsx taoot/tests/browser/transition-hold.ts            # does anything paint the world while a transition waits for bytes? (#308)
npx tsx taoot/tests/browser/workbench.ts                  # does /speedrun/ assemble, and does Play run a sheet?
npx tsx dust/tests/browser/workbench.ts                   # the same, for Dust's workbench
npx tsx dust/tests/browser/built-layout.ts                # Dust's page as BUILT, not as served
npx tsx dust/tests/browser/load-standpoint.ts             # where a load puts you on a game that has just booted
npx tsx dust/tests/browser/shooting-range.ts             # are the range's targets actually on the canvas?
npx tsx tools/parse.ts                                    # parse the whole script corpus, report coverage
npx tsx taoot/tools/navdump.ts taoot/gamefiles/en/titanic2/DATA/b59.set out/   # navigation dump (PNG per step)

Three files under taoot/tests/browser/ are machinery rather than runs: driver.ts (the page, the real mouse and keyboard, the state mirror), story.ts (a Story over that driver, so a segment written once plays in both hosts) and proptrace.ts (every write to a prop's owner, formatted by the engine so a browser log and a headless log diff cleanly).

The speedrun ​

Not a test — a route written as a sheet and driven against the clock, which is why it has its own directory and its own runner rather than a place in the suite above.

npm run dev -w taoot
npm run speedrun -w taoot                          # run the sheet, print the splits
npm run speedrun:watch -w taoot                    # a real window, same run
npm run speedrun:lint -w taoot                     # parse the sheet and say nothing else
npm run speedrun -w taoot -- --from="m4p0 cabin"   # enter at one of its save points
SHEET=taoot/tests/speedrun/any.sheet npm run speedrun -w taoot

The same sheet language runs in the browser, on the unlisted /speedrun/ workbench, over one sheet parser and one action table. What is where: speedrun/src/ holds the half that names no game — the parser, the in-page driver, the aim sweep and the panel's own modules — taoot/src/speedrun/ holds Titanic's verbs and its routes' navigation, and taoot/tests/speedrun/ the Playwright driver that is the clock of record.

Dust has the same workbench at dust/speedrun/, off the same modules. The engine's verbs are written against a DreamFactory session and Dust is one, so most of a route needs nothing more; what its disc asks that Titanic's does not — goto a grid cell, give, offer, meet, talkout, takeinhand, doorat, loadsave, its saloon's blackjack, and menuSave/menuLoad through its own panel — is DUST_ACTIONS in dust/src/speedrun/actions.ts, laid over the core table so a name there wins. What each game supplies is five facts about itself: its key namespace, its action table, and optionally a Warm list, what that button should say, and a sheet to copy (Workbench in speedrun/src/workbench.ts).

The clock: in-game time ​

A run is measured by in-game time: how far the game's own clock moved (#508). The same sheet shows the same in-game time on a slow machine as on a fast one, and over a slow link as over a fast one. Four things in the engine make it so:

  • A late pass is not made up, as in TI.EXE (timing): one frame per main-loop pass.
  • Game time stands still while the engine waits on a file it needs now — a room, its casts, a film, a script's file, a save (GameSession.whileLoading). The original read its disc synchronously. This is on for every page.
  • Speedrun time (nominalTime) moves game time by exactly one tick (1/60 s) per tick, in whole milliseconds, so every third tick is a 50 ms pass. A tick and not a pass, because the tick is TI.EXE's unit: a script that lowers framerate (the turbine's valves, the fencing) runs faster than 20 passes a second, a film holds its own ticks, and a fade ramps one step a tick (#542). Five rules keep a tick-stepped run the same on every machine and in either host: a sleep, a clip's length and a film's cue count whole ticks, never milliseconds; a script's poll goes on after the next tick, not the next screen refresh (GameSession.frameWait); the scheduler's pass and the frame counter fall on tick NUMBERS (every third tick, every framerateth), not on a rhythm counted from wherever it last started; a frame that runs no tick takes queued input and moves nothing (ScreenDirector.untickedFrame), so no film or fade starts its clock on a frame one host draws and the other does not; and the first frame after the game stood still runs no tick, so input made while it stood still lands on the tick it was made on. A runner's hammer looks only once the game has booted and no file is loading (READY_TO_LOOK). travel/hunt/stand's planner runs on the wall clock and voids the time. So delay, fades, films and animation cost ticks rather than milliseconds, and voicedone/sounddone/currentsound end a clip by its own length (TimedAudio) rather than the sound card's.
  • A sheet's clock (sheetClock): the game runs only while the runner waits on it. A hold hands its condition to the session, which stops the game on the very pass it comes true; a pause between presses is a number of passes. The runner's own round trips and thinking cost nothing.

Both runners play on a sheet's clock: the CLI runner always, the workbench while a sheet runs (by hand, the page is the game again). Measured on the stack-to-mission-4 leg, seed 20: 8239–8242 frames at full speed, with the CPU throttled 4×, over a throttled link and with both, while the wall time ran from 25.5 s to 40 s. Timed on the wall clock instead, the same leg gained about a hundred frames on the slow CPU and about four hundred over the slow link.

A person's run is timed with Time me on the workbench: speedrun time on, a sheet's clock off. The stopwatch counts the game's passes while it is busy and the player's real time while it waits for them (GameSession.countRun), so the player's own waiting is theirs at full speed even on a machine too slow for twenty passes a second, and loads the engine waited on are out of both.

A sheet's time is what a frame-perfect player could do: every input on the first pass it could take effect, pressed within that 50 ms by anticipation rather than reaction. It is the floor for its route, and a person's run is on the same scale. Inputs are taken one per screen refresh, for both.

The report leads with in-game; time beside it is the wall clock with downloads taken out (the load remover, #251), and its load column is what came out of it, present only when there was something to remove. time + load is the wall clock, exactly. On the workbench the stopwatch shows in-game time with the wall time small beside it, and says LOADING while the link is busy. On a slow machine --patience=N gives every line N times its budget; a budget only decides when a run is called stuck.

A standing watch (watchFor) is asked by the session too, on every pass: on the pass its condition rises the game stops, the interrupted line stands still, and only the watch's own action moves the game until it is done. Polled on the runner's beat instead, the ESC for a sinking film landed some passes in, and how many was the machine's.

Without a browser ​

npm run speedrun -w taoot -- --headless --from="m3p0 smokestack" --seed=20

--headless plays the sheet in node against the same host the playthrough suites drive, with no drawing, as fast as the CPU goes (#509). It drives the game with the workbench's own driver over a stand-in window, so every gesture is the same code, and it gives the browser's numbers: the sheet from a cold boot, seed 20, came out the same to the millisecond of in-game time on every one of 361 lines in both runners (2:45 of it, in 17 s headless against 3:36 in the browser). Load points are shared with the browser runner (out/speedrun/). What it cannot run is what the workbench cannot: travel, hunt and stand. Its budgets count its own frames, not the wall.

On the workbench, Calculate does the same in a Web Worker beside the page (taoot/src/speedrun/calc-worker.ts): it plays the open sheet from a cold boot, headless and at full CPU speed, with a bar for how far it has got, and ends in one of two answers — the sheet's in-game time and frames, or the line it stopped at, how far in, and a button that puts the editor on that line. The game on screen is not touched; the files come through the browser's cache, and the sheet's checkpoints go with it for its load() lines. The dice are live, as on the workbench, so a sheet that depends on them can come out differently on the next press.

Two rules make the runners agree, and both are the sheet clock's, not the headless runner's:

  • The runner looks at the game only once the frame's work has settled. A browser settles every promise between one animation-frame callback and the next; the headless runner gives each callback a task of its own for the same reason.
  • The runner does not look at the game while it waits on a file (GameSession.loadingFiles): a pause is not over and a hold does not close until the load has landed. A runner that saw a film still downloading as no film let a pass run the moment it arrived, before pressing ESC at it.

A seeded run ​

reset(seed: 360) as a sheet's first line boots the game with its script dice pinned, so the sheet is the same course every time — on the workbench, in Calculate and from the command line (--seed=N does the same from outside). It is a boot, not a setting: the original took its seed once, at start-up, from the clock (TI.EXE 0x435180 -> 0x4357c0, timeGetTime() * 3 / 50), and loading a save never re-seeds it. One seed at the start is fair for a TAS-style run, and a seeded time is a category of its own, never ranked against an unseeded one. A seed is best only for the sheet it was found for: an edit before a die changes every draw after it.

The repository's sheet uses 360. Of seeds 1–1000, twelve set the bedsit bomb off before the card trick's last line is over, the earliest the flat allows, and of those 360 plays the whole sheet fastest: 6:24.9 in-game, against 6:30.4 for 20.

Saving and loading on the clock ​

Two pairs of verbs, for two different jobs (#523):

save(m1p2)       load(m1p2)        # load points, instant: for working on a route
menuSave(m1p2)   menuLoad(m1p2)    # the game's own menu, on the clock: for a run

save() and load() restore a game on the spot, which is what makes them useful while a route is being written, and it is also why a time that used load() (or Dust's loadSave()) is not a time. Such a run still finishes, but its headline says NOT A VALID TIME and names the line. Verbs say so with voidsTime (speedrun/src/action.ts).

menuSave() and menuLoad() go the way a player has to: Titanic's life preserver, the control panel's save or open lever and the OK lever, with the fades both ways; Dust's horn, the panel's barn-door wipe and its button presses. Only the game's Save As and Open dialogs are answered at once, and those froze the game in the original too. Measured on the shipped saves, a menuSave costs 2.1 s of game time in Titanic and 1.65 s in Dust, a menuLoad 0.9 s and 0.6 s. The levers' animations draw on the script dice, so a seeded sheet is dealt differently once one is added. menuLoad() loads only what menuSave() wrote in the same game: a point save() wrote for free would skip the half of the cost the save should have paid. blackjack saves and loads the same way. Each game names its menu in src/speedrun/menu.ts; tests/speedrun/menu-saves.ts in both games is the machine suite.

Contributed sheets ​

Sheets come in by pull request as plain sheet files in taoot/speedrun/sheets/: line 1 is # title: …, and author and notes may follow in the comments before the first action (speedrun/src/sheet-header.ts). The workbench lists them under Contributed sheets… (the workbench's Vite plugin, speedrun/vite.ts, publishes the folder as speedrun/sheets.json), in place of the "Copy the full run" button.

taoot/tests/speedrun/sheets.ts, one of Titanic's machine suites, plays each to its last line headless on the sheet clock and fails at the line one stops on. A seeded sheet is one run; an unseeded one is played on seeds 1, 2 and 3 and must finish on each. The times go to out/speedrun/sheets.md and the CI job summary.

A coin on arrival ​

Some of the ship is decided by a die. The Gorse-Joneses, the only people who tell Frank about Burns besides a failed debrief with Penny, stop you on arrival in recept1c, gstair2 or gstair3 on BOOTFILE jonesok's random (100) < 50, after a map jump or a lift ride alike (#474). A route that needs them goes again until they come, and says so in one line:

mapJump(gstair3, deck: c, until: talking, max: 20)
say([101])

until: on a ride is checked after each arrival, and max: (20 by default) turns a run of bad luck into an error instead of a run that never ends. A map jump to the landing you are standing on draws again, because the original does: MAP.STG's red area only stores the target, changeset closes the room before opening one, and TI.EXE's opensetfile (0x43cac6 → 0x407590) compares no names. The lift cannot do that, because its attendant never offers the stop you are at (ELEV1.PUP calcbevels), so lift(deck: c, until: talking) alternates and ends wherever they spoke; a plain lift(deck: c) on the next line takes you to C, or does nothing if you are there. until: talking means anyone, not the Joneses, so nothing is refused up front; when max: runs out on a ride into those rooms where jonesok could never say yes (the tour, phase 0 of mission 1, mission 2 outside phase 2, or once they have told Frank about Burns), the error says which.

Blackjack for the boat pass ​

In mission 4 Buick plays Frank for his boat pass, and the Rubaiyat or the real necklace is the stake. A 100% route needs the pass, a 0% route may need to lose the stake, and either is one line (#487):

blackjack(win, bet: rubaiyat)
blackjack(lose, bet: realneck)

It starts where the table is clickable. It looks only at what a player at the table sees: its own cards after one press of SHOW, and Buick's face-up card, the fourth one dealt. It never reads dealerdowncard, dealertotal or the deck. The game settles the bet before Buick's play-again plaque (BLKJACK2.PUP playerwin/buickwin/draw), so a draw is played again at the table. A hand that went the wrong way is undone with a save the verb makes before walking up, and loads without re-seeding the dice, as TI.EXE's own load does; re-seeded, every retry would be dealt the same cards. It saves and loads the way a player has to: the life preserver, the control panel's lever, the fades both ways (#523). Only the game's Save As and Open dialogs are answered at once, and those froze the game in the original too, as with menuSave()/menuLoad() (see above). max: (20 hands by default) turns bad luck into an error.

taoot/tests/speedrun/blackjack.ts, a machine suite, plays both from the shipped save "In the Smoking Room" (en/save/ENDGAME1) on seeds that between them win, lose, draw and load; taoot/tests/auto/speedrun-blackjack.ts pins the hit-or-stay table, which Dust's table plays too (speedrun/src/blackjack.ts).

Blackjack for cash, in Dust ​

Dust's money comes from Jan's table in the saloon, and a route that needs some of it says how much (#490):

blackjack(800)

It starts where the table is clickable, with some cash, and plays until the cash in hand is at least that much. The table has no limit (JAN.PUP drawbetbevel adds $1, $5 or $10 a press while the cash lasts), so each hand bets what is still missing, min(cash, target - cash): as many hands as going all in, fewer presses, no overshoot beyond a blackjack's 3:2. What it looks at is Titanic's verb's: its own cards after one press of SEECARD, and the dealer's face-up card, the fourth one dealt. A draw is bet again at the table. The verb saves before the first sitting and again after every hand that won, so a loss costs one hand: it gets up (or is sent away broke), loads without re-seeding, and sits down to a new shuffle, because initgame reshuffles at every sitting. Saving and loading go through the panel behind the horn, its barn-door wipes and button presses included, as Titanic's go through the control panel. max: (100 hands by default) turns bad luck into an error. The verb ends standing in the saloon, up from the table.

dust/tests/speedrun/blackjack.ts, a machine suite under Dust's own headless driver (dust/tests/speedrun/headless.ts), plays it from the disc's save D1E_002 ($400, facing the table) on seeds that between them go broke, lose part of a bet, draw, are paid for a blackjack and load.

A route is a line; the sinking is not ​

watchFor(<condition>, <action>) is a standing rule — whenever this becomes true, do that — for the part of this game that cannot be written as a line (#255). During mission 4 the phase advances on a mix of real time, how far the player has walked and how many conversations they have had, so sink1.mov arrives at a moment no sheet can name: it interrupts whatever command is running, blocks input, and the line waiting on the world times out. The only linear alternative is an esc() after every movement, which is unreadable and still wrong, because the film does not follow any particular move.

watchFor(movie == sink1.mov, skipMovie(until: quiet, budget: 60000))
watchFor(movie == sink1.mov, off)

The condition is the ordinary condition language, the one wait takes, and the action is an ordinary sheet line — parsed as one, so it takes every verb rather than an allow-list.

It is polled alongside the running step, not between steps, because the step that needs rescuing is the one already waiting; the runner starts a watchdog beside each step, one round trip per tick however many watches there are, and nothing at all when none is registered. And it is edge-triggered — a watch fires when its condition goes false→true and not again until it has gone false in between — so a film playing for two hundred frames is one firing, not two hundred.

Safe to gesture from, because a step that is waiting is only polling. A watch that fires while a step is mid-gesture is the one hazard, and it is the sheet author's to avoid: a watch is for recovery, not for playing the game.

With the three sinking films watched, the route runs cold boot to credits.

Both halves aim through one piece of arithmetic, clientPointFor in src/speedrun/driver.ts: a gesture is named in canvas pixels, the page reads it back with Math.floor, and the coordinate in between is a whole number of client pixels. Getting that wrong costs one pixel, which is nothing at a hotspot and everything at the coal lever (#277). Measured against a real page at nine widths, a half-pixel aim missed five of the lever's twenty-one stops at 640px and twenty at 512px; clientPointFor reaches all twenty-one at every width from 400px up. Anything below a 1:1 scale is lossy by construction — several canvas pixels share one client pixel — so clientAxis returns the nearest whole coordinate rather than a fraction that will not arrive, and setLever aims at the MIDDLE of a setting's band to spend the tolerance the control already has.

They also pace a held drag through one constant, HELD_YIELDS. A dragged control is a script spinning in while stilldown() { … forceupdate() } and both of those bump realYieldSeq, so one turn of that loop is two bumps — and the dial steps once per turn. Waiting four bumps between moves turns every dial at half the rate the game can be turned at (#293). Two is not a proof that the move was consumed, and does not need to be: the gesture is closed-loop, so a move the loop missed costs one more move and nothing else, and limiter reads only the SIGN of the bearing change, which two accumulated moves do not change. Measured over the plant's six dials from one seed, so openstage deals the same scatter each time: 5.3 s of dialling at four bumps, 4.7 s at three, 3.7 s at two, 3.5 s at one. One buys nothing over two and guarantees no turn at all. The floor under both is the game's own loop rate, which is the floor a hand has too.

browser/transition-hold.ts is the browser half of a rule the headless suite can only pin from the inside: a load must not hand the screen back to the world while a transition is waiting for it. It samples every ScreenDirector.render — flagged with whether a composite actually happened — and reports any composite that put the lit room on the canvas inside three windows: the boot between the menu film and the date caption (CPU throttled 4x, because nothing there is a fetch on a warm page), the first open of the map (map.stg held back by STALL_MS, default 1200), and the painting crate — transtoflat("cargo.stg"), whose reveal is cratep.mov and not a fade, so the window runs past the swap and all the way to the clip. Each leg drives the game's own transtoflat through session.track, because scriptBusy counts tracked dispatches and nothing else: an untracked drive looks idle from its first await, and every hold that waits for the script to fall quiet is lifted a frame after it goes up. It prints which build the PAGE is running, read out of the loaded function — a dev server that has been up across an edit can serve the browser a stale transform while handing curl a fresh one, so both arms of a before/after would measure the same code.

browser/lang-chooser.ts needs two or more language directories under gamefiles/ and skips (exit 0, with the reason) when the install has fewer: it clears the remembered choice, waits for lang.stg to come up before the landing screen, clicks a language's region with a real mouse, and then asserts what only a browser can check — that every gamefiles/ request from the choice onwards comes from the chosen tree. The other browser suites pass ?edition= (from TAOOT_LANG, default en) so the chooser never blocks them.

browser/demo.ts is the third that asserts, and covers a class of page bug invisible to everything else here. The 1996 demo opens no set, ever — open.mov, then demo.stg, a menu that is a stage flat with four portholes on it — so host.viewer is null for the life of the page. Every other edition has a room from the first second, so input gates and chrome conditioned on one existing go unnoticed there (#299: the demo had no keys, no clicks and no bug button). The headless suite cannot see it, because the gates are the page's and not the engine's: auto/regression.ts boots the demo to its menu and passes either way.

Its ESC check is timed rather than tested for effect, which is the shape worth copying: open.mov ends by itself after 31.4 s, so "the film stopped" is not evidence that a key did anything. The claim is that the menu arrives within ten seconds of the press — 0.2 s in practice, against 31.4 s with the key dropped.

The remaining two browser scripts differ. browser/playthrough.ts asserts — it diffs a live page against the golden trace. browser/menu-movie.ts asserts nothing: it drives the Scene51/View65 menu flow through window.dbg and prints state plus screenshots, for when a movie misbehaves and you need to see it. parse.ts and navdump.ts inspect the corpus rather than the port, so they live in tools/; parse.ts is the source of the "100% of the corpus parses" claim.

Back to the reference index.

Docs licensed GPL-3.0 (the decoder is ported from DFET). Game data © CyberFlix — not included.