Skip to content

The browser host ​

Prerequisite: Engine architecture.

The host is the code that is neither format knowledge (engine/src/df/) nor recovered engine behaviour (engine/src/runtime/), but the glue that puts the game on a canvas — the page, the navigation renderer, the movie player, and input. If the engine is the recovered TI.EXE, this layer is the recovered Windows 95 around it.

It lives in two places, and the seam between them is the subject of the next section. engine/src/web/ is the reusable half — it knows about canvases and key events but not about which game is running. A game's own src/ is the other half: the page, its DOM, and which disc it reads.

The engine's browser layer — engine/src/web/:

FileRole
host.tsGameHost — what it means to run the game: set activation, prefetch, cold boot, resuming a save
viewer.tsSetViewer — navigation state machine + renderer
screen-presenter.tsScreenPresenter — the one persistent framebuffer everything composites into, and the "is this picture already on the canvas?" check. Owned by the host, so it outlives the viewers
screen-gamma.tsthe palette power curve TI.EXE applies before anything reaches the screen
ring-cache.tsthe LRU of decoded turn/walk rings the viewer draws from
movie-player.tsMoviePlayer — modal MOV playback
puppet-view.tsconversation rendering (see Characters)
keys.tswhether a keypress belongs to whatever has focus or to the game — see Keys
save-browser.ts / save-store.tsthe saved-games UI and its IndexedDB store, parameterised on a SaveKind so each game gets its own database
window-bar.tsa game window's menu bar, attached to the frame that holds the canvas: the menus as a Win32 resource spells them, the Windows keys, fullscreen reveal
window-dialog.tsa game window's dialog box on the same frame: a Win32 dialog template drawn as laid out, modal over the picture, with the dialog manager's keys

The page around it — a game's own src/. Titanic's is the fuller one and is what this page's examples are drawn from; Dust's is smaller, because one volume and one edition need much less:

FileRole
taoot/src/main.tsthe page: DOM, the cold boot it starts, input, the rAF loop — nothing about the engine
taoot/src/files.tsFileStore — game files by lowercase basename, lazy dev-server fetching, six editions and two CDs
taoot/src/log-buffer.tsthe lines behind X, bounded — and the tail a bug report carries
engine/src/web/debug-panel.tswhat state the game is in, as a list that patches rather than redraws — kept in the engine, because every game's page shows one
chrome/src/bug-report.tsthe Report bug button: a prefilled GitHub issue, and the screen on the clipboard. Shared, because both games carry it
dust/src/main.ts / dust/src/files.tsthe same two jobs for Dust, against one disc

The split, and why it is where it is ​

GameHost owns the session, the open set and the lifecycle between them; the game's main.ts owns the browser. The host may not mention document, window or WebAudio — the page passes its side in as a file source, five UI notifications (log, hud, showStage, mapChanged, setsChanged) and an AudioSink.

The line is what makes the lifecycle testable: the test suite cannot import a module that touches document, so lifecycle code on the page side could only be tested through a stand-in. taoot/tests/auto/regression.ts builds the same GameHost over the on-disk gamefiles/ index and a recording sink, so activation and the cold boot are ordinary tests. Anything a test could contradict belongs below the line.

It is also what lets one host run two games. HostFiles is the whole contract for "where is the data", so Dust's small files.ts and Titanic's six-edition FileStore are interchangeable to everything above them — a second game needs a shell rather than a second engine.

One rule the host keeps breaking ​

TI.EXE issued screen and sound as fire-and-forget commands; a retained renderer and WebAudio give us state instead — a fade level that persists, a currentThemeName, an audio source that plays until stopped. Bugs of this shape are the host writing that state at the wrong moment, in one of four ways:

  1. A default applied before the script has spoken. A startTheme ahead of openset → setupsound blips the set-named bank before the deck theme; lifting the black at movie end comes before the script that played the movie has finished dressing the screen. Rule: the script gets the last word; the host fills in only what it leaves.
  2. A play with no owner. A movie's event sounds and a skipped puppetspeak line run on after the thing that started them is gone. Rule: whoever starts a sound ends it — by handle, since channels are shared with room ambience.
  3. State the engine believes but the host never delivered. Loops started before the AudioContext exists are dropped while currentThemeName says they are playing; a set-swap with no closeset behind it leaves the previous room's scheduled work running. Rule: if the engine records it as running, something must actually be running — or the record has to go.
  4. A once-per-session step run per room. house.shp's openshop is a LAYOUT pass (propxy("bag", 256, 324), the interface band), where putting the bag on the C73 bed is initprops' one-time job; re-firing it on every activation stops the bag being a world prop as soon as any later set opens, so leaving the cabin and coming back through the door loses it. boot() opens those shops once and never again. The method is session.ensureBooted() — no caller can know whether it is the first, so they state what they need and the session decides; an imperative name like load… invites the repeat. Rule: match the boot's cardinality, and say so in the name — a silent guard under an imperative name is a trap for the next caller.

Each is cheap to re-check when adding host code: does a script still have something to say after this line, and who ends what this line starts?

The page, and going straight in ​

Everything below is Titanic's shell, taoot/, which has four pages: the front page, /play/, /collection/, and the unlisted /speedrun/ workbench. Dust's shell has two — the game and its own /collection/ — and reaches the same GameHost a shorter way, because one disc and one edition need no manifest edition axis and no language chooser.

The game is its own page, /play/ (taoot/play/index.html); the front page (taoot/index.html) is welcome text and a Play button, and nothing else. As one document the welcome would have to be hidden the moment the boot had something to draw, leaving the sentence explaining that this is a re-implementation on screen only as long as the files take to load. Split, the front page can wait as long as the reader does, and the play page can assume the decision was already made a navigation ago.

The play page reads the gamefiles.json manifest, settles an edition, preloads everything the boot will want with a bar over the bytes, and then cold boots itself — the real boot(), logos into the main menu. (What "everything the boot will want" is comes from the game rather than from a list here — see the boot plan below.) There are no story-state or room shortcuts on the page: saves are reached through the in-game menu (the same modal opengame uses), any room through the editors, and the playthrough plays the game rather than jumping around inside it. What the page shows before the framebuffer is #booting: one paragraph — held invisible until the catalogue has translated it, so a German reader is not shown the English first — taken down when the stage appears, and left standing when no game files were served, which is what an install with nothing in it looks like.

Under the canvas, in this order: what you can press (Fullscreen, Report bug), how a swipe reads on a touch device, the keys, and — behind X, off until asked for — the scene/view readout and the script log.

Reporting a bug, and the one thing a URL cannot carry ​

Report bug opens github.com/dhobi/dreamrefactory/issues/new with title and body prefilled (bug-report.ts): the game, the room, the edition, the page URL, the browser, the window size and the last eight lines the engine logged — the three questions every report about this port otherwise has to be asked. The title names the game and then the room — [Dust] Bug in nite scene g15, [Timelapse] Bug in flat i0001.100.6 — because that is what makes an issue list readable when one repository takes reports about every game, and because [Dust] in the search box is then a filter. The game comes from the page's own registry entry (chrome/src/games.ts), so it has one spelling.

The picture cannot go that way. GitHub has no query parameter for an attachment, and the framebuffer is a 42 KB PNG against a URL that starts answering 414 around 8 KB — the only route an image has into a comment box is the clipboard or a drag. So the screen is written to the clipboard and the body says to paste it; a browser that refuses an image on the clipboard downloads the PNG instead and the line beside the button says which of the two happened.

Two orderings are load-bearing, and both are the same rule — a browser only does these things for a click. clipboard.write is called first and from the click's own task, because it refuses when the document is not focused and the new tab is about to take the focus away; window.open follows immediately, still in that task, because from a .then() it is a popup and a blocker may eat it. The ClipboardItem is handed the promise of the blob rather than the blob, which is what that form is for: the write is issued while the click is still the reason for it, with the PNG encoding still to come.

Nothing is sent anywhere. The button opens a form; what the player submits is whatever they can see in the box.

The boot plan: what a game says it needs ​

Two things have to be known before a DreamFactory game can start over HTTP: which files to have in hand so boot() never waits mid-sequence, and which room the boot ends up in. A hardcoded list of TAOOT filenames (bedsit1.set, logo.mov, gang.cst, house.shp, …) would be knowledge about one game in the layer that runs any of them: the 1996 demo shares four of those names and needs a fifth.

Both are read out of the game's own BOOTFILE (engine/src/runtime/bootplan.ts), because every one of those files is named as a string literal by the boot's own scripts — opencastfile("gang.cst"), openshopfile("house.shp"), opentrackfile("unilib.trk"), openstagefile("main.stg"), playmovie("logo.mov"), initall("bedsit1"). The scan walks calls out of boot() through the boot library, which is what catches a resource the entry point opens indirectly (the demo's boot() ends in menuscreen(), and that is what opens demo.stg), and it yields four things:

resourcesevery file the startup path names, in the order the boot reaches them — what GameHost.preload weighs and fetches behind the bar
caststhe cast files, held apart because a set change has to keep the story cast in hand while it swaps everything else out
landingSetthe room the day machine opens first, or null — which is not a degenerate case but the demo, whose boot() ends on a menu stage and whose advanceday lives in main.stg, opened three clicks later
volumesthe disc directories setpath mounts, in disc order (["titanic1", "titanic2"]), empty for a single-volume game

Two boundaries are worth knowing. The scan stops at the day machine (advanceday/advancetour): boot() ends with sendtostage(advanceday()), so a walk that followed it would run on into the entire story — the full game's advanceday names ocredits.mov, leave.mov and credits.mov, 85 MB of endgame a player five seconds into a launch must not wait for. That is also exactly where the host divides, since coldBoot runs boot() and then kicks the day advance itself. And fileexists is deliberately not a resource call though it names a file: the demo's boot() does fileexists("gstair2.set") as its "is the CD in the drive?" check, and fetching 9 MB of grand staircase to answer a question about presence would be an expensive no-op.

The plan is fetched and parsed once and shared; an edition switch is a page reload, so there is no live invalidation to get wrong. Three other places take their answer from it rather than from a list: loadServerSet (an entry point that may be reached without boot() ever running — a dev jump, a resumed save), session.ensureBooted's stand-in boot, which replays the plan's openings in the boot's own order and skips movies because playing the intro is not what a stand-in is for, and FileStore.setVolumes, so discOfUrl takes the disc labels from the game rather than from one title's CD names.

Hosting it as static files ​

Nothing on this site needs a server. Two dev middlewares look like it and neither is:

in devin a static deployment
the listing of gamefiles/served live from a directory walk, so a tree that changes needs no rebuildgamefiles.json, written into dist/ by the same walk at build time (tools/manifest.ts)
the bytesstreamed by a middleware, because Vite's transform 500s on extension-less paths like …/data/bootfilethe host serves them. Every consumer reads arrayBuffer(), so no Content-Type matters, and nothing asks for a Range

So a deployment is npm run build, then dist/ and gamefiles/ uploaded beside each other — the manifest names paths as served, gamefiles/en/… and bare lang.stg, and every consumer prefixes /. Where the game data is uploaded separately from the pages, or a host carries fewer editions than the machine that built them, regenerate the manifest where the tree actually lives:

cd /var/www/taoot
npx tsx …/tools/mkmanifest.ts . ./gamefiles .    # outDir, gamefilesDir, publicDir

Run it from inside the deployment, with those relative paths: the keys are the walked paths as written, so an absolute gamefilesDir writes absolute keys and nothing resolves. The third argument is where the authored DF files (lang.stg, the intro movie) are — public/ in a checkout, the deployment root here, because that is where public/ is served from.

The deploy workflow never uploads a manifest, for the same reason: the machine that builds the pages has no rip to describe.

Two things to know before uploading: paths keep their case (TITANIC1/data beside Titanic2/DATA, and object stores are case-sensitive), and an edition is 1.2–1.3 GB across ~670 files, of which ~260 MB is movies. A visitor pulls ~38 MB before the game starts — that is what the preload bar counts — and movies after that, on demand. The data never changes, so it takes a long Cache-Control; the manifest should not.

Saved games are IndexedDB and were never server-side; the editors export by download. There is no other moving part.

Audio needs a user gesture in a browser, so the WebAudioSink is created lazily (see Audio): the window arms ensureAudio() on the first pointerdown or keydown anywhere, so the boot runs silently until the player touches something and attach() restarts the theme already playing.

Loading a set lazy-loads it plus its siblings (.shp, .trk, .sfx, .11k); anything scripts ask for later (openshopfile("blkjack.shp")) is fetched on demand — a FileStore miss the dev server could satisfy kicks off a background fetch. Production builds don't bundle or serve game files.

The session's dialog hooks map to native alert/confirm/prompt; save and load route to the save browser. window.dbg exposes the viewer and session to the console (and to the Playwright test).

SetViewer: navigation and rendering ​

The viewer owns one parsed SET and is rebuilt on travel (sets are disposable, the session is not).

Frames. Turn/walk frames are decoded a ring at a time — one standpoint's turn circle, or one road — and held in an LRU with a 24 MB budget. Each carries its pixels, optional Z layer, and its per-frame camera pose, so world sprites stay correctly projected during motion, not just at standpoints.

The unit is the ring because the delta codec makes a frame a patch on the one before it: individual frames can't be decoded, chains can. A ring, though, needs nothing before it — its first frame repaints every pixel. Measured across the 20 largest sets, all 998 rings, each decoded from a fresh buffer and from a poisoned one: every frame byte-identical. So rings can be decoded on demand, in any order, with no priming.

A codec bug can masquerade as an ordering dependency. Run-mode-7 back-references shorter than their run must tile what they just wrote; a memmove instead poisons every later frame in the chain, showing in the flattest part of the picture — the night sky — as coloured horizontal dashes that grow as a walk goes on and vanish the moment you stand still. Under that corruption 38 rings look as if they need a predecessor and roads as if they need the standpoint they depart. The suite pins the real property (any ring, any buffer state, same bytes) as the canary.

Decoding everything at set-open is correct but costs 366 MB for the boat deck (1420 frames, half of it Z planes); decoded on demand, the same room holds 16 MB — where the player stands and where they can go. tick decodes one reachable ring per idle frame, so turning costs ~0.1 ms in play; the visible cost is a single ~70–90 ms frame when arriving somewhere whose ring nothing had reason to warm yet. Frames on screen or queued in an animation are held by reference, so an eviction can never blank them.

Motion. Turning walks the scene's ring to the next named view; walking takes the first available road and picks the arrival view nearest the travel direction (a road's endpoint view faces back along the road — see SET). Turn/walk animation paces at 50 ms per frame — one frame per service pass, which is TI.EXE's own frame period (framerate defaults to 3 ticks of 50/3 ms, 0x429643/0x43a940), checked against the original in DosBox; the rate is not a feel decision. A player may move their OWN moves off it — see the Movement setting — and a script's stay here whatever they choose. While animating, currentview() returns the pseudo-view "moving" — scripts genuinely poll for it. jumpTo tolerates the shipped data's one stale view name by falling back to the nearest view by rotation.

An arrival's own scripts may drive the camera, and they are trusted to. currentscene()/currentview() are unconditional setters in TI.EXE. The port arms them inside a user gesture's script chain and also around the walk-arrival closescene/openscene, scoped exactly as keydown's and press's are, because a walk's arrival lifecycle runs ticks after the gesture ended. The demo's grand staircase depends on it: it fakes three decks out of two set files, and landing in gstair2 runs changeset(theset); currentscene(thescene); currentview(theview) off savedeck — with the jumps dropped, every climb would land at the arriving set's default scene. A corpus audit finds exactly three openscene handlers that move the camera and no closescene that does.

Rendering asks one question per frame — screenOwner(): movie, puppet, held, faded, world. A movie is first, the same rule the input path keeps. A movie carries its own palette and its own pixels, so a fade the script left standing is not a layer over it — it is applied around the rectangle the clip paints (see MoviePlayer). The demo's Smethells briefing needs this: screentoblack("puppet", 15), puppetvisible(false), playmovie("penote.mov") with no blackscreen() between — and blackscreen() is the one thing that drops the held snapshot — so a fade drawn over the movie would leave the note playing, clickable, behind a black rectangle. In TI.EXE, screentoblack (id 12050 at 0x43e550) is a blocking ramp that dims steps times and returns, leaving nothing behind, so the black is simply what was last drawn and the next thing to draw the screen owns it. The shipped game has the same shape twice (the darkroom's photobox.mov, the wireless portrait).

held is that last sentence made into a state: nobody owns the screen between a movie ending and the next thing that draws. playmovie frees its buffers and restores nothing (0x448b00, exit path 0x44969e–0x4496c7) — the clip's final frame is still in the framebuffer and its palette is still installed. Handing the screen back to world on the frame the movie ends shows one fully-lit frame of the room before the script resumes a rAF later: measured at exactly one 16 ms frame of the un-bombed London flat between bedex.mov and ocredits.mov (#209). While held the renderer composites nothing at all, which is all the original does. It is also why the boot looks right: boot() plays playmode.mov and then loads the cast, four shops and a stage before advanceday reaches datebed.mov, with no screen statement anywhere in between — so the menu's last frame is what stays up through the load, in TI.EXE and here.

Within world, the path is: stage flat (with the set view composited in when visible, then world sprites, then props) → bare set. On top: the persistent drawstring text overlay (Courier New, colour 0 = black), the fade level, the optional hotspot overlay, and the deck-plan minimap. The clut/mixclut palette dims (the darkroom) rebuild the set/stage palettes through the same paths.

Every one of those palettes then goes through the display gamma on its way to the canvas — pow(c/255, 0.65) * 255 per channel, which is what TI.EXE puts between a palette and the screen (engine/src/web/screen-gamma.ts, and the codec doc). It is the last step, after any mixclut dim, because in TI.EXE it is the only route to the hardware palette. The set, prop and flat palettes, the puppet CLUT (art and subtitle ink alike) and each movie segment's own palette all pass through it; the Nightdive intro and the language chooser do not, since neither is the original showing a room.

Which keys the page keeps ​

The page's listener (taoot/src/main.ts) is the port's stand-in for TI.EXE's window proc, and the original translates every printable key through its VK table and hands it to the boot. So does this: past the page's own keys, a single character goes to viewer.keyDown — which is what makes the control panel's rebindable movement keys work at all (#14), and what lets a scene or flat bind a letter of its own (the deck map's deck letters, the panel's own key-capture).

Kept by the page rather than passed on:

F1–F9the display gamma, handled ahead of everything (below)
M / O / Xthe minimap, the hotspot overlay and the details pane
anything with Ctrl / Alt / MetaCtrl+R has to reload; the original's Ctrl marker only ever mattered to its movie key filter
a key typed into a page fieldfocusOwnsKey — typing stays typing

M/O/X are a deliberate deviation, and the one place it can bite: a player who rebinds a movement key to m will find the minimap takes it. Nothing else in the game binds those three.

The brightness controls ​

The original's are F1–F9: F1/F2 move all three exponents (the pair its manual names as Ctrl+F1/F2 — the code tests the virtual key alone, so Ctrl makes no difference), F3–F8 the channels one at a time, F9 resets. Each press is a factor of 1.05, and F1 is the brighten key because it divides the exponent.

Two things about where they sit:

  • They are handled first, and unconditionally. In TI.EXE the arms are in the window proc ahead of the one that hands ordinary keys to scripts, so they work over a playing movie and under a full-screen overlay stage. The port's listener (taoot/src/main.ts) matches that — the only thing that takes precedence is the page's own focus (focusOwnsKey), so typing in a field is still typing.
  • Phones get three presets — darker / original / brighter — in the play page's Picture options, because the original's answer here is keyboard-only. Their unit is the keypress: a preset is ±6 notches of the same 1.05, so it is the same control rather than a second setting that drifts, and the keys move the selection with them. Remembered under taoot.picture.brightness. Presets rather than a slider, because on a phone a 44 px target you can hit is worth more than granularity nobody wants.

The picture setting ​

The other control in the play page's Picture bar, and the one thing there that the original has no key for at all. Every standpoint ships twice — low-resolution in the right-turn ring, high-resolution in the left-turn one, paired by framePairID (set format) — and the original's landings are not uniform:

a move thatends onso it
turns rightits ring's low-res standpointlands soft and sharpens a beat later
turns leftthe hi-res standpointlands sharp
walks a roadan in-motion frame (all 722 registers in gamefiles/en)lands sharp, with no soft frame to see

That asymmetry is what original reproduces, and it is the default. The other three make every direction agree — sharp, transition (soft for one beat, then sharp), and soft, which keeps the low-res standpoint for the settled view as well and so leaves the whole room at low resolution. Remembered under taoot.picture.landing; a player who had ticked the old always land sharp box (taoot.picture.sharplanding) starts on sharp.

The mechanism is one rule: the animation's last frame is the one the settled view will be drawn from, because showView runs in the tick that draws it. So a soft frame is only ever seen when a sharp one follows it in the same animation, and the beat is exactly one animation interval. transition is the only setting that has to lengthen an animation — a walk has no landing standpoint of its own, so one is appended (SetViewer.standpointFrames).

None of the four touches the movement itself: in-motion frames are quarter-resolution in both rings and no sharp version of them was ever made.

The Movement setting ​

How long a frame of the player's own turn or walk is held. Four segments, and the numbers are not the port's invention — three of them are values the original's own framerate could be given (0x489efe, ticks of 50/3 ms between frames; see Timing):

settinga frame lastswhich is
slow100 msframerate(6)
original50 msframerate(3) — TI.EXE's shipped value, and the default
fast25 ms1.5 ticks: the one the original could not have asked for
instantnothing at allframerate(0), which the original documents as don't wait

The rate itself is not a matter of taste — original is the measured number (#205). What this adds is the choice: the request (#222) is from players who find 20 fps of low-res transition makes them motion-sick and want either a slower walk or, like Myst, no transition to watch. Remembered under taoot.move.speed.

Two things are deliberately outside it:

  • A script's move keeps the engine's rate, whatever the player has chosen (SetViewer.navigate passes FRAME_MS, the player's path passes SetViewer.playerPace). Scripts budget passes for the moves they ask for and then carry on without waiting — BEDSIT1's air raid gives a 7-frame road ten passes — so a slow player would break the air raid's timing (#40).
  • session.frameRate, the script-side framerate(), stays separate. Scripts write it (the fight stage asks for 5; the turbine drag loops drop it and put it back), and a preference a script can overwrite on the way past is not a preference.

instant needs more from the frame loop than a smaller number. Drawing at most one animation frame per tick is faithful — the original's throttle waits out the period and then draws exactly one, so a machine that cannot keep up stretches the move instead of dropping frames from it — but it puts a floor under the pace at whatever the host ticks: 50 ms headless, one display refresh in a browser. 0 would mean "a frame every rAF", which is neither instant nor the same speed on a 60 Hz and a 120 Hz panel; 25 would be 33 ms at 60 Hz. So below the engine step the tick advances by elapsed time and draws only the frame it lands on. At or above the step it draws one frame per tick and never skips, and instant falls out of it: the whole ring is spent on one tick, the settle runs inside that same tick, and the only picture that reaches the screen is the standpoint arrived at.

The Low memory box ​

The one setting on the page that the game decides rather than the port. BOOTFILE defines its own lowmemory() — shadowing the engine builtin of that name — as heapsize() < 6144000, and five script sites branch on it: half the themes are swapped for their shorter .11k twins and mission 4's boat deck loses its crowd. So the whole port-side implementation is one number, heapsize() answering 4 MB instead of 64 when GameSession.lowMemory is set. It is re-read per openset, so a change lands in the next room; remembered under taoot.sound.lowmemory.

The low-memory game has the full account — the five sites, what .11K actually is (not 11 kHz), why the two engine params it also zeroes are invisible here, and where it is worth listening.

The sound keys, and why the page has no sound control ​

wavevolume 0..9. Two things move it and they go through one setter (GameSession.setWaveVolume), because TI.EXE funnels it the same way: 0x4249b0 has twenty-one callers, wavevolume(n)'s own site plus the ten digit arms in each of its two key filters.

  • 0–9 while a character is speaking or a clip is playing, which is where those filters live — see skipping and repeating for why they are bound bare rather than as the original's Ctrl chords. They work over the logos and the menu too, which are movies played by the intro rather than by a viewer.
  • The game's own dial, opened from the lifebuoy in the interface band. CTL.STG c2 seeds it from the live value (propdeg("volume", wavevolume())) and HOUSE.SHP c3's mousedown is a while stilldown() drag loop writing wavevolume(propdeg(me)) on every frame, closing with a voicesound("volume") click.

The play page deliberately adds nothing here, and that is the opposite of the brightness decision above. The difference is whether the game already answers the question: brightness has no in-game control at all — the F-keys are the whole of it, and a phone cannot press them, so the page had to supply presets. Volume has a dial, and it is a drag, which a finger does as well as a mouse. A slider on the page would be a second control for a value the game already owns, drifting from the dial the moment a savegame or a script moved it.

What that leaves uncovered is real and is the original's own gap: with a clip or a conversation on screen the band is not reachable, so a player without a keyboard cannot change the volume until it ends. TI.EXE leaves a mouse-only player in exactly that position.

Four caches hold post-gamma bytes, and a live change has to reach all of them or the picture changes in one place and not the others: the set/prop palettes, the stage flat memo, the puppet's composited stance, and each movie segment's baked palette. They all watch one integer — screenGammaGeneration() — rather than subscribing, because viewers and puppet views come and go and a listener list would have to be unsubscribed correctly at every teardown. The viewer rebuilds on the next tick, and replacing the palette arrays is also what makes it visible: buildSignature refs both by identity, so a new array is a repaint.

A clut on the surface the screen is showing is its own reveal. In TI.EXE a fade is a palette ramp, so re-establishing the palette is what brings the picture back, and a script that dims into place need issue no blacktoscreen at all. transtoflat("redphoto.stg") is the case: screentoblack("current"), then mixclut("stage", "black", 0, 255, 245) and nothing after it — so without this rule Burns' darkroom would sit pitch black, red lamp and trays and all. It is the same shape as visualeffect's reveal, one function up. Scoped to the showing surface deliberately: CTL's exit runs clut("set") between a stage's screentoblack and its blacktoscreen, and lifting the black there would flash the room in early.

blackscreen() is a one-shot paint in the original and a retained level here, so a movie that ends over it would leave the screen black forever. The end of a movie therefore only arms a reveal; the black is lifted once no script dispatch is in flight — the script that played the movie almost always has more to say about the screen — and any blackscreen / clut / screentoblack / blacktoscreen it runs cancels the pending reveal instead. A flat whose intro movie is followed by no fade at all (the bomb's openstage) still gets its reveal, as soon as the script falls quiet. That same armed flag is what screenOwner reads to answer held above, and the two readings are one fact: until the script says what the screen should look like, the screen is still the movie's.

A stage swap arms the same flag rather than clearing the level, whenever the black it finds is one a script blanked on (fade.blanked — blackscreen or clut("black"), as against a screentoblack ramp, which it still lifts because the palette that ramp was against is the one being replaced). See the stage layer.

The fades block. screentoblack and blacktoscreen are a linear lerp between the named surface's palette and the black one, and the loop that runs it (0x435b90 / 0x435be0) busy-waits one 60 Hz tick per step on 0x41de90 with no message pump and no scheduler pass inside it. The interpreter is frozen for the whole ramp, so the statement after a fade cannot run until the fade is over. The game depends on it: gang.cst's prepuppet is screentoblack("current", 10), openpuppetfile, visualeffect(plain, 0), blacktoscreen("puppet", 10), and only then does runpuppet send the puppet its boot script — which is what speaks the first line. A non-blocking fade starts the line while the screen is still black and lets it ride the ramp up (#6). The port's fades awaitClock.sleep for steps script ticks, the same primitive and the same unit delay(n) uses.

And clut(name) installs a palette, right now: 0x43dfd0 resolves the name through the table the fades share and hands it to 0x4363e0 — the very call the last step of a blacktoscreen ramp makes. So clut is the un-ramped fade, and clut("black") is the un-ramped screentoblack. blackscreen() is not always beside it: transtoflat's rub.stg arm is playmovie("rub.mov") then clut("black") with no blackscreen anywhere, and that black is what the stage is revealed from two lines later. Where the pair does occur it is not redundant either: blackscreen clears the buffer and clut("black") makes every subsequent draw invisible, which is how the scripts hold a screen black across a swap that keeps repainting underneath.

The click priority chain ​

The host's part of a click is short, and everything modal comes first:

  1. a playing movie (it owns the screen and its clicks, even over a suspended conversation);
  2. a visible puppet (bevel hit-test on the answer band, and a repeat on the picture above it; a hidden puppet lets clicks through);
  3. lockevents — the scripts freeze the world while the game is doing something to you, and BOOTFILE's own mousedown exitcodes on it in exactly this position, after the puppet branch, so a conversation still answers while locked;
  4. a click made while a script is in flight is queued rather than dispatched (TI.EXE queues it too — the shipped premovie/playmovie/postmovie call no flushevents()), unless a poll loop is reading the button right now, in which case this press is its input and a second copy would replay into whatever comes next.

Then the dispatch itself, which the game does: BOOTFILE 0001's mousedown is hittest and a switch on result() over six cases into sendtoprop / sendtoactor / sendtobutton / sendtoscene / sendtopainting / sendtoflat. Where a title ships that handler it decides where a click goes; the port's transcription stays as the fallback for a title that does not, in the order hitTestAt answers in — the sprites (props front-to-back, opaque-pixel accurate, then actors with occlusion), then the SET where its image is drawn (a hotspot is a "painting", smallest region first; else the scene itself, by name), then the STAGE where that image is not (a named region is a "button", else the current flat). The argument a prop's mousedown receives is the packed click point, not the prop name.

What counts as "there is a stage" is GameSession.stageOpen — the question the game's own stagevisible asks (stageName != "none"), and both the hit test and the dispatch use it. "Does the stage have a main script?" is the wrong test, because a stage need not have one: TAOOT's inven1.stg does, the demo's inven.stg does not, because there the flat carries the handlers. Under that test a click on the demo's open-bag OK button answers ("", "none") instead of ("ok", "button"), the boot's mousedown switch has no case to take, and nothing is dispatched at all.

Hovering runs the same hit test and sends setcursor to whatever it answered, through the same six dispatch paths — so the cursor and the click cannot promise different things. The name comes back from the scripts, and the whole corpus emits five of them: touch (809 times), arrow (75), hand (36), watch (18) and fist (2), which main.ts maps to CSS cursors. What makes a takeable thing a hand is inven.shp's main and a person gang.cst's, both gated on realdist(target) < hotdist() (characters). The port does not choose a cursor of its own.

Keys ​

DF_KEY maps arrows to the engine's "leftarrow"/"rightarrow"/ "uparrow"/"downarrow" events — remember keys are script events, not direct movement. Space is forwarded too (the boot's keydown opens the door you're facing).

A press made while a move is on screen is queued, not dropped — one press per key stays pending however long the key is held, which is what walks a corridor instead of a room, and letting go leaves at most one more move to come (the queue's own policies are TI.EXE's, recovered in engine/src/runtime/input.ts). The gate is in SetViewer.keyDown and applies to every key, because that is where the original keeps it: its window proc posts and its main loop pops, both above any notion of which key it was, since the letter is only translated afterwards by BOOTFILE's keydown reading keynorth/keywest/keyeast — W/A/D by default and rebindable, so the engine cannot know which key means forward. A gate in the arrow-only path would throw W away during a walk while keeping ↑, so a burst of four Ws would walk one room and four ↑s two (#207). A playing movie and a suspended conversation stay ahead of the queue, because both own their keys outright rather than deferring them.

Escape is forwarded, not acted on. DF_KEY maps it to the character "." with the special-key marker set, which is exactly what TI.EXE's window proc does, and the decision belongs further down: SetViewer.keyDown gives a playing movie the key before anything else (the same precedence click() gives it), and MoviePlayer.key is what knows that a marked . means abort — see escaping a movie. Behind the movie sits a suspended conversation, which takes it for the same reason: in the original the puppet's own wait is the loop popping the event queue, so ESC reaches the line being spoken and not the scripts (skipping and repeating). With neither up, the key goes down the script chain and is ignored. This host has no "skip" verb of its own, deliberately — both the skips it forwards are the original's.

A phone has no Escape, so two taps in the same place within 320 ms are forwarded as one — keyDown(".", true), the identical route the key takes, so a live movie aborts, a spoken line is skipped, and anything else ignores it exactly as the original does. That gesture is also what makes a conversation playable on a phone at all, since a click does not skip a line. Only the second tap is swallowed: holding every tap back to see whether another follows would put 320 ms of lag on every press in the game, and during a clip — which is what this is for — the first tap reaches no region and does nothing. It sits with the other touch gestures (a swipe is an arrow press, a quick lift a tap, staying down a press for the drag loops) and after the same checks, so a drag or a walk swipe can never be read as half a double-tap. Verified by hand on a device and not covered by a test: Playwright's synthetic taps are separate round trips and land further apart in page time than the window allows.

A finger that goes down on a control is never a swipe. The classifier holds the mousedown back to tell a swipe (navigate) from a press (play) and resolves it by waiting — which is right for a tap and wrong for a drag, because a drag moves immediately: a real drag travels 48 px inside 220 ms and would be ruled a swipe, so no mousedown would arrive and the camera would walk instead. The drag that matters is the inventory's — INVEN.SHP's stdmouse carries a held item with propxy(handitem, pointx(arg), pointy(arg)) in a while stilldown() loop and drops it on whatever hittest finds, which is how the trunk key is used. So a finger that lands on a prop or a stage button takes the press at once and is never reclassified; a finger on a room surface goes through the classifier, because swiping the room is how a phone walks.

A phone has no arrow keys either, so a swipe presses the one it points at (swipeKey, pure, so the rule is testable without a device): leftwards turns left, rightwards turns right, away from you walks on, back towards you sends downarrow. Both axes can be flipped, independently, from a row of two checkboxes under the screen that only a touch pointer is shown — a turn has a second reading with as much of a claim (the finger pushes the scene, the panorama convention), and which one feels right is a matter of the hand rather than of the game. The answers live in localStorage under taoot.swipe.invertturn / taoot.swipe.invertwalk.

Down is a plain key event rather than a nav press, which is the keyboard's own asymmetry (ArrowDown goes to the script chain and nothing in the engine acts on it). Almost nothing reads downarrow; the exceptions are SMSTACK2 and SMSTACK3 views 43, 50, 54 and 56 — the false smokestack's ladder platforms, whose scene keydown is the only way down a level — and since the way out of the smokestack is at level 1, a player with no downarrow could climb the maze and never leave it. A soft-lock, not a missing convenience (#100). Binding it also makes "invert forward" swap a pair instead of moving walking onto a dead end.

Three host-only keys: M toggles the deck-plan minimap, O the hotspot overlay, X the details pane under the bars — the scene/view readout and the script log, which are off until asked for, because a running game should say nothing the player did not ask about. The pane is opened by a log line before the stage appears, though: a boot that never finishes has nothing else to say for itself, and the first showStage shuts it again. A full-screen overlay stage with a keydown target consumes all keys itself — the wireless telegraph key needs raw letters, which is also what keeps X out of the Zeitel machine's way.

The first showStage, and only that one. It fires on every set activation, so resetting the pane there would empty the log and shut it at every changeset — 28 rooms and at least 40 set changes over a full playthrough (#22). The page resets on the two things that really start a game from nothing (the cold boot, and quit()'s return to the menu), and the pane's open/shut answer is remembered in localStorage under taoot.details.open alongside the swipe and picture ones.

Where the pane lives. In the page, as the last column of the row that holds the screen — beside it when the window is at least 1480 px wide (1740 px on the speedrun page, which has the run sheet as a column of its own between them), and stacked under everything below that. Nothing moves the element and nothing is padded around it: the row wraps, so a window that is over the break but short of holding every column drops the pane to its own line rather than squeezing what is beside it (#249).

What the state list is. Every script global, rendered from snapshotState — the same function the playthrough goldens are recorded with, so what a reporter reads and what a golden compares are the same numbers. It is off until asked for on the play page (the state box, or ?debug=1 in the URL) — and on from the first paint on the speedrun workbench, whose <meta name="details-always"> says the pane is part of the page rather than something to summon: X cannot shut it there, and the state box starts checked. A route is read off the log and tuned against the state list, so a column that has to be called up every time is a column in the way of the thing it belongs to. It answers what just moved rather than the whole table: 93 globals at boot and 102 by the credits — 143 distinct names over the whole route, of which 108 ever move — but the median number that changes between two story beats is 5 and the most ever is 30. all gives the table, a filter searches it, and the six the game's own HELP button answers with (Mission, Phase, Letter, Necklace, and Maze/Level in the smokestack) stay on top of both — pinned out of the list's scroll, along with the filter box, so watching the props and the main states at once is possible at all (#178).

A filter takes more than one term, | or , apart: hrs|min|sec is the timer, and a timer is only worth watching whole (#126, #127) — any-of, since a name cannot contain two of them. It matches a row's type as well as its name, because a prop's row reads prop bag: prop answers with the props rather than only with saveprops, saveprops1 and saveprops2 — the three globals that encode them. A space is not a separator, so prop bag still means that one prop. Under a filter the owned props and actors join the list without all, for the same reason the unmoved globals do: the reader has named what they want.

The clock is excluded from "what just moved" by engine/src/runtime/masks.ts — the same predicate the trace comparisons drop. Without it the list would permanently show sec and clockcount and nothing else.

The list patches its rows rather than rebuilding them, because it polls (there is no "a global changed" event to listen for) and a rebuilt list would throw away every row it had, four times a second, for a screen that had not changed. Measured with a MutationObserver: a room standing still costs 0 mutations over 16 refresh ticks, one moved global costs 2 (its number and its highlight), and the only writes left under all are the pocketwatch's own.

What the input log is. The other half of the same stream (#178): the log has always said what the game DID and never what was done to it, so a report saying "I pressed forward and it went wrong" could not be checked against it. With the inputs box on, every press and click the game is given writes a line — what it was, what the engine's own hit test says was under it, and where the game stood once the gesture was answered:

[+01:14.8] [Space]         hotspot "door" → deckbd2.set — Scene35 / View102
[+01:15.9] [Forward]       → wireless.set — Scene10 / View14
[+01:18.2] [Click 214,180] region "ok" → wireless — wireless 1
[+01:20.0] [Esc]           — nothing changed

In the log rather than beside it, because the question is cause and effect and in one stream the answer is the ORDER — and because ⧉ Copy details and the Report bug button then carry the trail without a reporter having to know they should attach it. ?debug=1 turns it on with the rest, since that is the link somebody is sent when a report needs more than it carried.

A gesture that achieved nothing says so, which is most of the value: a press the engine threw away in the fade gap reads DROPPED — the engine was not accepting, one filed behind a camera move reads queued, and ESC at the Nightdive intro's ownership question — #171 exactly — reads IGNORED — the film carries no skip flag. "The key I pressed is not in the log at all" is indistinguishable from "the log is broken".

Keys the PAGE keeps (M, O, X, the gamma keys, a letter typed into the filter box) are never logged, and nothing had to be written to exclude them: the recorder wraps the four places a gesture reaches the engine, so what is logged is what the game was given. A gesture answered within 200 ms is one line; one the game is still working on gets its cause line at once — in the right place, above the engine lines it caused — and its effect on a second line when it lands, because a press at a two-minute film must not have its line land after the film's.

The lines themselves live in a bounded buffer (log-buffer.ts), not in the <pre>, so the pane does not grow without end. A whole game is 1141 lines / 40 923 bytes, so the 5000-line cap is not a budget for playing — it is a ceiling for a session that never ends, where movie click … arrives once per click inside an interactive movie. Past the cap the oldest tenth goes at once, so a repaint costs once per batch rather than once per line, and dropped counts what left so a follower (the browser gate's ENGINELOG) can still tell where it got to.

quit() goes back to the front door in place ​

The credits end playmovie("credits.mov"); quit(), so quit is called from inside the dispatch that played them, and a boot re-entered underneath it would be building sets while the old game was still talking. A page reload would throw the page away to reach something the page can show, and take a running browser test with it, so it is done in place, in two halves that are not timing guesses: onQuit returns immediately and continues on the next rendered frame through session.nextFrame — the engine's own yield, the same primitive script poll loops use — and GameSession.prepareRestart then awaits settle(), so nothing is torn down until the dispatch that asked for it has unwound. What it puts down is what would otherwise outlive the game: the scheduler (a finished game's loops firing at scenes the new one has not built), every audio channel (a theme is a loop and nothing else would stop it), a puppet, the fade the credits left pinned black, and ensureBooted's idempotence latch — a restart being precisely the case that has to run it twice. Game state is deliberately not reset here: BOOTFILE's own clock = "startdisk1" arm does that, and the data resetting itself beats this layer guessing at the same list.

MoviePlayer ​

Implements the MOV state machine from the format doc: the seven type codes, region waits, and a five-deep cross-movie call stack — one playmovie() promise is held across an entire chain, so the script resumes only when the last chained movie closes. Event sounds resolve from the movie's own chunk table first, then the open audio banks. Entering a header-named action frame latches the actionframe(n) flag scripts test afterwards.

A file is a chain of segments, and the player walks it. The per-picture state — frames, palette, region metadata, cues, the screen origin — is the current segment's, and enterSegment swaps the lot when a segment's type-1 exit leads to the next one; only the last segment's exit ends the film. Which is why a type-1 exit is not finish(): endSegment decides. The soundtrack follows the engine's own segment-reload rule (0x44956f) — a segment that brings a bed replaces the playing one, a segment that brings none inherits it, which is how one narration scores TOUR.MOV's twenty segments. MovSegment.cues are polled on every tick and outside the self-pacing gate, because a timed jump fires out of any wait, a modal region wait included; each fires once, on the segment's own clock.

Pacing is the film's. frameHoldMs — max(frame hold, segment floor), recovered from the demo build — decides every advance. chooseFrameInterval only decides whether a movie is self-paced at all (0 = a click-through close-up); a frame authored to wait for the spoken line holds until the movie's own event sounds are done. The soundtrack is still assembled the recovered way: unique chunks for a length, every chunk resampled up to the highest rate present, and 110% of the predicted runtime taken out of the authored order with a loop as the backstop, because the picture always runs a little long against a tick-quantised prediction.

A clip is a rectangle painted over the screen, not a screen of its own. It lands at the segment header's origin, and only the pixels it covers are written. 302 of TAOOT's 327 segments cover the whole 512×384 and make the distinction moot; the 25 that do not divide in two, and both halves need it. The in-room transitions — the lifts, the smokestack climbs — are 512×264 at (0,0), which is exactly the room-view region, and they play straight out of a keydown with nothing hiding the interface, so the band belongs under them rather than being cleared for the length of the ride. The demo's letterboxed cutscenes are 512×264 at (0,60), centred, and play behind a fade the script has already raised — so what belongs in their black bands is that fade, showing around the clip. A live clip is never dimmed by the persistent fade level (in TI.EXE the fade is the palette, and a movie carries its own) — the fade is applied around it instead.

Ending a movie puts down what it started. The bed is halted with the film, the same as TI.EXE's no-next-segment teardown (0x449d40). Event sounds are stopped only for a clip the player was driving (dismissed, or interactive at all): a cutscene's frame-entry sound is often a spoken line timed to ring out past the last frame, and cutting it is wrong — measured, 16 of the 52 region-less movies with audio fire a sound they leave no room for. And the frozen frame a fade-out was holding is voided, because a movie outranks a held fade in screenOwner: whatever a screentoblack snapshotted before the clip is not what should come back after it, and leaving it deadlocks the reveal the movie's end arms.

Back to the runtime index or the documentation home.

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