Engineering notes

ToonTalk on WebAssembly

Moving a 2004 Win32/DirectX C++ application into the browser without rewriting it: where the seam was cut, which failure modes dominate, and the bugs worth the retelling.
The running app · the author's account of the project

~210kLines of C++ kept
67Translation units
37Replacement headers
14Shim implementations
117Functional JS overrides
235Zero-stubs
287Commits
87 hActive build time

The seam

The engine is unmodified in substance: it is the original C++, compiled with Emscripten, with port-specific compensations confined to #ifdef __EMSCRIPTEN__ blocks. What was rewritten is the platform beneath it — headers that declare the Win32 and DirectX surface the engine expects, and implementations that satisfy those calls with browser primitives.

Two rules made the work tractable. First, the original source is the specification; any behavioural difference is a port bug, which removes essentially all design debate. Second, compensations are gated and commented at the point of divergence, so the diff against the original stays legible.

SubsystemOriginalReplacement
Raster surfaces, blits, flipDirectDraw 7COM-shaped objects over a 2D canvas
Text, brushes, paletteGDIBitmap-font rasteriser + nearest-colour palette mapping
Effects and narrationDirectSound, winmmWeb Audio, per-buffer gain nodes
Mouse deltasDirectInputPointer events; Pointer Lock for relative mode
Saved worlds and robotsMSXMLHand-written DOM with COM reference semantics
Exact arithmeticGMP (Win32 .lib)mini-gmp / mini-mpq compiled in
Archives (.dmo, .tt)dunzip DLLzlib via Emscripten
Files and configurationWin32 file API, .iniEmscripten FS over a preloaded bundle; synthesised .ini

Assets are preloaded into a package fetched at start-up; the recorded demos, at roughly 36 MB, are pulled over HTTP on demand instead. Both use fetch, which is why the build cannot be opened from a file:// URL — the scheme has an opaque origin and fetch refuses it, and instantiateStreaming additionally wants an application/wasm content type that file:// cannot supply.

Failure mode one — stubs that report success

The link runs with -sERROR_ON_UNDEFINED_SYMBOLS=0. This is not optional: the engine imports hundreds of Win32 entry points, most of which are irrelevant in a browser, and demanding an implementation for each would stall the port indefinitely. The build therefore generates a zero-returning JavaScript stub for every import not implemented in the shim — currently 235 of them, against 117 hand-written functional overrides, and the ratio shifts a little further toward the overrides with every month of testing.

The cost is that the compiler's most valuable guarantee is switched off, and it is replaced by two much worse ones. A stub that is called through a vtable traps at runtime as null function — noisy, but at least it stops. A stub that is called directly returns 0, and 0 is the success code for most of Win32, S_OK for COM, and Ok for GDI+. It does not fail. It reports that it succeeded.

The general shape

Every bug in this class presents as “the logic is wrong” and resolves as “the logic is fine, it was handed a confident zero.” Three of the nine cases below are this bug wearing different clothes, and each one cost days before the pattern was recognised.

Failure mode two — assumptions the original was entitled to make

The second family comes from the target, not the shim. The engine was compiled by 32-bit MSVC for Windows, and it is correct on that machine. wasm changes the widths and the concurrency model underneath it, and the code has no reason to expect that.

  • Type widths. time_t is 4 bytes on the compiler that wrote the recordings and 8 in wasm; wchar_t is 2 bytes on Windows and 4 here. Anything that reads a recorded file with sizeof silently mis-parses it.
  • Integer range. Intermediate products that never overflowed at the original's coordinate scale do overflow here, and the original's overflow guards were written against the cases that actually occurred.
  • Blocking. Sleep() suspends a thread. A browser main loop cannot block at all, so the port records a wake deadline and skips iterations — which preserves pacing but changes what happens within a cycle.
  • Installer state. Directory search paths, registered file extensions and palette settings were written by the installer, not the program. On a fresh Emscripten filesystem none of it exists, and the engine has no fallback because on Windows it never needed one.

Case studies

Commit
150d640
Subsystem
DirectDraw shim
Symptom
Helicopter descends upward; parked craft sits on grass at the top of the screen

Top-down versus bottom-up surface memory

The canvas present read the surface bottom-up while the DirectDraw-path blits wrote top-down, so every sprite placed through blt_to_back_surface — which pre-flips its y-up marks into top-down rects, exactly as real DDraw expects — rendered mirrored about the horizontal midline.

What made it expensive was that GDI content compensated with a flip of its own, which hid the mismatch. Backgrounds looked right; sprites did not. The fix was to standardise every shim on top-down memory rather than to correct any individual blit, which retired a whole family of positional bugs at once.

Commit
a9baf9e
Subsystem
Exact arithmetic
Symptom
Pong ball never re-served after leaving the screen; “.” did not stop the game

Fifty-four stubs where GMP used to be

ToonTalk's exact-arithmetic layer is GNU GMP, reached through mpq_* / mpz_* DLL exports. The Win32 .lib binaries cannot link into wasm, so all 54 imports had become return-0 stubs.

Integer pads appeared to work throughout, because small values ride a separate LongNumberValue path. Only the rational path — fractional sensor values, big numbers, exact comparisons — was returning garbage, and Pong's serve trigger is precisely that path: the ball's X sensor pushes a RationalNumberValue. Replaced with mini-gmp and mini-mpq compiled directly into the build.

Commit
3b4fabb
Subsystem
Sprite registration
Symptom
Helicopter climbs off the top of the city and out over the ocean; controls dead

Signed overflow in a per-frame compensation

The port's registration compensation in Sprite::update_display feeds offset_delta * y_scale — millions of units at high camera scale — into shrink_height_from_640x480_screen_size, whose h * 480 product overflows 32 bits beyond about 4.4 million. The original's overflow guard only catches positive h.

The wrapped result flipped sign, so each rotor-frame swap ratcheted the craft roughly 28,000 units upward. Console telemetry pinned it exactly: yo dropped 14,264 while dy gained 13,698. The end state — all water, no helicopter, unresponsive input — had been reported several times before as separate bugs.

Commit
f111ac9
Subsystem
Log replay
Symptom
Demo city snapshots never load; stream fails at tellg = -1

sizeof(time_t) in a recorded file

read_use_profile read three time_t fields at sizeof(time_t) — 4 bytes on the 32-bit MSVC that wrote the recordings, 8 in wasm. Twelve bytes over-consumed shifted the entire log preface, so load_city_from_log met a failed stream instead of the file-name marker.

Reads and writes now use explicit 4-byte time values under Emscripten, preserving compatibility with recordings made by the original. With that, every segment's marker reads at tellg = 752 and the shipped demo corpus replays end to end.

Commit
9acf60f
Subsystem
Text layout
Symptom
A long text pad renders as nothing but question marks

Two-byte stride over one-byte characters

The wide-text clipping routine advanced two bytes per character, correct where wchar_t is 2 bytes wide, wrong in wasm where it is 4 and wrong again for the on-disk representation. Every read landed between characters and produced the fallback glyph.

Related, and fixed alongside: XML blobs inside recorded logs are stored as 2-byte wide characters regardless of the host's wchar_t, so they are now read explicitly at that width rather than at the native one.

Commit
a2d77b8
Subsystem
MSXML shim
Symptom
Tab killed mid-demo: Uncaught RuntimeError: null function

COM lifetime in a hand-written DOM

The shim's document owned its nodes outright but got the reference model wrong, releasing storage while the engine still held pointers into it. The next virtual call went through a freed vtable and, with undefined symbols permitted, surfaced as an indirect call to index 0.

The first fix was also wrong — it tested rc > 1, but the engine releases the mint reference after appending, so a legitimately held node sits at exactly 1 and was declared unreferenced. The correct model counts client references only: a fresh node starts at 0, every factory and cloneNode hands out an AddRef, and teardown is deferred while any node is still held. Headless replay then ran to frame 13,243, against a hard trap at 7,520 before.

Commit
7ea3d4f
Subsystem
GDI shim
Symptom
Number and text pad faces render as black rectangles

Luminance is not a palette lookup

Number::display paints a non-blank pad's face as a solid GDI rectangle rather than sprite art, using a true RGB COLORREF from tt_colors. The shim was mapping every COLORREF to a palette index by luminance — a shortcut valid only against the grayscale development palette, and against the real one it lands on arbitrary entries. The pad's warm-white face mapped to black.

The shim now holds the live palette, fed from the DirectDraw shim's SetEntries, and maps by nearest colour with a fast path for explicit palette indices.

Commit
1664392
Subsystem
Image loading
Symptom
Time-travel buttons invisible for weeks

A stub that measured every file at zero by zero

UserImage::compute_dimensions measures a file with load_image_dimensions, which is GDI+. There is no GDI+ in wasm: the stubbed Bitmap constructor does nothing, GetLastStatus() returns 0 — which is Ok — and GetWidth()/GetHeight() return 0. So the call “succeeded” with 0×0 and set file_read, which skipped the DibReadBitmapInfoFromFileName measurement immediately below it. set_dimensions then clamped both axes to 1.

Every image loaded from a real file was therefore one city unit across. The buttons were laid out, blitted and dirtied every frame at 1×1 — present the entire time, sub-pixel. Two further causes sat behind it: no FileSearchPath in the synthesised .ini, so bare-name lookups failed name resolution outright (the retail installer writes that entry); and the pointing-cursor artwork had never been staged, which is why the frozen screen appeared not to repaint at all.

Commit
32d9f62
Subsystem
Asset conversion
Symptom
Buttons in wrong colours; subtitle text visible through their bodies

Transparency is palette index 0

The retail button art ships as GIF, which the port cannot decode, so it is converted to BMP ahead of time. The first conversion preserved each GIF's own palette — but the engine runs 8-bit against ToonTalk's palette and treats index 0 as transparent, which in the retail art is black.

In the GIF palette index 0 is the crimson button body. So the body became the transparency hole and every other colour was resolved against the wrong table. The conversion now remaps each pixel to the nearest colour in ToonTalk's palette, sends the GIF's transparency index to 0, and keeps opaque pixels off index 0 so a dark pixel can never become a hole.

Method

A shim boundary this wide defeats reasoning from first principles, because any given call may be real, may be a stub, and looks identical either way at the call site. What worked was cheap, permanent instrumentation and a refusal to theorise ahead of measurement.

  • Bounded probes in the engine. printf lines tagged [tt], capped at a few prints or fired every N frames, left in place through the testing phase. The page rings them so nothing is lost to a late console hook.
  • A heartbeat rather than a screenshot. A still ToonTalk scene is pixel-identical to a stopped one. The loop prints iteration, frame, pause and time-travel state, so liveness is read, not inferred.
  • A headless harness. Node driving the same build under a wall-clock cap, for crash bisection without a browser in the loop.
  • Canvas measurement over eyeballing. Row-scanning the rendered canvas to assert geometry — that the subtitle band occupies rows 460–523 and the buttons 529–593 — turns “they overlap” into a check.
Three diagnostic failures, recorded

Comparing unlike measurements. The harness prints pumped loop iterations; the engine prints frame numbers. Reading the first as the second made a run look past a crash point that it had not reached, and nearly produced a false “fixed” report.

Inferring liveness from pixels. Twice a still scene was called frozen. The hidden browser pane additionally throttles to about one iteration per second, which makes a healthy engine look dead.

A sound measurement with a wrong conclusion. “Moving the mouse while frozen changes zero pixels” was correct and reproducible. “Therefore the paused screen never repaints, and this is the same root cause as a second unrelated bug” was neither. It was committed and reported before being retracted one commit later. The pause path repaints fine; it was returning early on a null cursor sprite.

Deliberate divergences

Kept short on purpose, and each one flagged at the point of change.

  • Marty speaks via speechSynthesis, with an English male voice and raised pitch, and an off switch on the query string. This is a change of engine, not of approach: Marty was synthesised in the original too, through Microsoft Agent, so the port substitutes one speech synthesiser for another rather than inventing a voice where there was none. He reaches speech through two speak() overloads, and hooking only the wide-string one left him silent outside demos.
  • Subtitles step aside for the time-travel controls. The original pins the subtitle band to the screen bottom and the buttons to min_y, and paints the buttons last, so they overdraw it; that is tolerable natively only because the buttons auto-hide. The port measures what the interface currently covers from the sprites' live positions and lifts the band clear.
  • GIF art is pre-converted to BMP and .gif lookups are redirected, since only the classic DIB decoder is functional.
  • Absolute mouse mode by default — a browser is an absolute pointing device — except during demo replay, where recordings were made in relative mode and are interpreted through whatever mode is current.

Since this page was written

Every item that stood open when these notes were first drafted has been closed. The work that closed them is worth recording, because it kept landing on the same two failure modes described above rather than on anything new.

Theme
Stubs that report success
Count
Four more

The pattern held

CreateDirectory did nothing, so time travel recorded into folders that were never created and produced not one byte. dzip — every zip WRITE in the program — returned ZE_OK without writing, so no archive could exist. GDI+ measured every image file at 0×0 and reported success, so all file-loaded art was laid out one city unit across. And setAttribute silently dropped any VARIANT that was not a string, so every numeric XML attribute the port had ever written came out empty — saved cities and notebooks included.

Each presented as "the logic is wrong" and resolved as "the logic is fine, it was handed a confident zero". That is now five instances counting the GMP one above, which is enough to call it the defining hazard of this port rather than a run of bad luck.

Theme
Assumptions the original was entitled to
New instances
Installer state, case-insensitivity

Things Windows did for free

The time-travel buttons and the emulated cursor are asked for by bare name, and only resolve because the retail installer writes a FileSearchPath entry. The port synthesises its own ini and had no such entry, so every one of them failed name resolution. Reaching the end of a demo QUIT rather than handing over the controls, because ExitWhenDemoEnds — another installer-written switch — defaults on.

And file names: one demo's narration script asks for us\s01.wav where the archive stores US/s01.wav. Windows does not care; the browser's filesystem does, so that demo played with subtitles and no voice. Lookups now fall back to a case-insensitive match, which is what the engine was written against.

Area
Text
Effect
Four symptoms, one cause

A real font engine

The port had none. In-game text came from three embedded 1-bit bitmaps point-sampled to whatever size was requested, drawn on a fixed-width cell — so edges were jagged, proportional text rendered monospace, and there was no descent allowance. That single gap accounted for the coarse subtitles, the clipped descenders, a letter not fitting inside a character's button, and digits that shrank badly. Text is now rasterised by the browser's own font engine at the exact requested size, with GDI's lfWidth stretching reproduced so the engine's fit-to-pad arithmetic sees unchanged numbers, and coverage blended against the palette.

Area
Sound
Effect
Self-inflicted

A compensation that overreached

An earlier fix made stop_sound silence the whole mixer, to catch a looping effect the engine had lost track of. But stop_sound(narration_too) takes an argument, and the compensation ran before the check — so all seven callers that ask for "effects only", including the landing, cut the demo narration off mid-sentence. The lesson is narrow and repeatable: a compensation must respect the contract of the function it is compensating inside.

Also closed along the way: free-play time travel records and can be saved as a .dmo; a picked demo keeps its own name so its narration script resolves; recorded logs are byte-compatible with the reader again; and the volume control reaches both the effects and Marty.

Failure mode three — the original's own accidents are part of the specification

The first two failure modes were about the port being wrong. August contributed a third: the port being right where the original was wrong, which under this project's rule is also a port bug — and much harder to suspect.

Symptom
Stacked fractions overlap; the bar strikes through
Found by
Instrumenting TextOut, not by reading layout code

lfHeight is pixels, and has been misread since the nineties

The engine's set_font multiplies its pixel cell by −72/96, believing it converts to points. LOGFONT::lfHeight is in logical units — pixels under MM_TEXT — so on real Windows every font came out three-quarters of the layout cell, for the program's entire life. Invisibly: the engine measures the text it gets (digit_height_to_character_height comes from a live get_extent_size of the digit “0”) and tunes its layout from the measurement, so twenty years of margins and fudge factors quietly encoded the mistake.

The shim performed the conversion correctly, drawing 4/3-size glyphs. Single lines self-corrected through the measure-and-fit loops; the stacked fraction, whose line pitch comes from the same measurements, could not. A temporary print in TextOut settled it in one run: 36-pixel cells drawn on a 22-pixel pitch. Taking |lfHeight| as pixels made the same scene draw 27-pixel cells on a 30-pixel pitch, and the self-measuring layout snapped back to the original's own proportions everywhere at once.

Two companion fixes in the same family: glyph ink anchors at GDI's baseline geometry (ascent 0.9 of the cell, matching Arial's real tables) rather than centred in the cell — which is what places the fraction bar, an underscore, in the descent zone under the numerator where number.cpp draws it — and the raster buffer now carries descent room below the cell, because GDI happily inks below the line and the engine's layout depends on it.

Area
Time travel
Severity
The port's one data-loss bug

The archive that overwrote itself

Not in the original's code: in the port's own tt_finish_time_travel_archive(), added so the page can offer a session as a download. It called close_log_and_open_next() unconditionally — and after the user has jumped back in time, “the next” segment already exists on disk. A tester's segment went from 13,181 bytes to 5,389, restamped with the previous segment's clock. The guard is one comparison — only advance when current == youngest — and the verification was byte-level: nine segments recorded, a jump back to seven, a save, and every log and city snapshot checked against the size the console reported when it was written.

Same lesson as the sound case above, sharpened: a compensation must respect the contract of the machinery it sits inside, and the port's additions are the least-tested code in the building.

Area
Input
Shape
One invariant, five bugs

The mouse mode must be derived, never assigned

The engine has a relative mouse mode (deltas, original full-screen behaviour) and an absolute one (pen and tablet support, which the browser resembles). The port's recurring mistake was setting the mode to a constant at transition points — end of a demo, exit from time travel, taking over recording — when the only correct value is whatever the pointer-lock state actually is. Every variant produced the same signature: a hand that jams against an edge or snaps to the wrong place. The eventual fix checks lock-versus-mode on every mouse move, two integers compared, which closed the class rather than the instance. Two adjacent traps: a key release swallowed by a text box strands the key down engine-side (Alt-Tab's blur handler was “fixing” this for one tester), and JavaScript-initiated pointer-lock exits must be marked, or the shell's Escape-forwarding helpfully delivers an Escape nobody pressed.

Area
Harness
Cause
An abort that poisoned the runtime

The headless harness died and nobody noticed

Adding browser-storage persistence (IDBFS) silently killed the node test harness: under node there is no indexedDB, the mount's first sync aborts the Emscripten runtime, and an aborted runtime fails every later call while the harness pumps a dead module. The boot log even said persist: loaded, because the JavaScript side caught the error and carried on. Guarding the mount on indexedDB existing revived the harness — which mattered the same day, because the fraction fix above was verified with its framebuffer dumps.

The month also delivered the port's first enhancement, kept deliberately outside the restoration: an optional page where Marty is backed by a language model (Claude, OpenAI, Gemini, or Chrome's built-in Nano). The engineering worth noting: the enhanced page is generated from the same source as the faithful one, so they cannot drift; Marty's knowledge is compiled from the shipped 264-page manual plus the engine's own help strings, placed in a prompt-cached system block so each question costs a fraction of a cent; and a gated engine export built on the same describe() machinery as his speech balloons tells the model where the player is and what is in their hand — so “what's in my pocket?” is answered from ground truth, not inference. Author-confirmed rulings live in a small file that outranks the manual, which is how wrong answers get corrected permanently.

Failure mode four — the port's own compensations, outliving their reason

The three modes above are all about the original's code. This one is about the port's. A compensation is written to work around a defect; the defect is later fixed; the compensation stays, and is now itself the divergence. It is harder to find than any of the others, because the suspicious-looking code is code the port added on purpose, with a comment explaining why.

The prescription that came out of it: when behaviour diverges in a region the port has touched, git log -S its own #ifdef __EMSCRIPTEN__ blocks and re-justify each one against the original before suspecting the engine.

Commit
4800db6
Subsystem
Number display
Symptom
(3/2)100 drawn with a stretched, flush-left denominator; (3/2)1000 with full-size digits spilling past a too-narrow face

A safety net that became the bug

An earlier commit had capped a free number pad's width at seventeen tiles and forced font-fitting at the cap — a guard added while containment was broken, in the same commit that fixed containment. Retail photographs settled what the original actually does: it lets the face grow to the digits' full natural width, wider than the screen if need be, with full-size digits and the shorter line centred. The cap was the divergence, and it was also what armed the fisheye display that produced the garbled glyphs.

The reproduction mattered as much as the fix. A pad built fresh with the same value looked perfect; the fault only appeared when the pad had first displayed 3/2 and was then given a large value, because it sized itself from the stale small-text font. Replaying the user's construction sequence — take the notebook's Fraction page, drop a typed 100^ on it — was what made it reproducible at all.

Since the fractions

The work after that point was less about the engine and more about what surrounds it: putting a 2005 research study back in front of a learner, letting people take their work out of the tab, and cleaning up after the port's own diagnostics.

Commits
bc5c961 · 3d34e4b
Subsystem
Object loading; activity page
Finding
A .tt file is a zip of data.xml — the same pasted-object XML the clipboard already decodes

Twenty-year-old student programs still load

The WebLabs cardinality study (Kahn, Sendova, Sacristán & Noss, 2011) had children aged 9–13 building infinite sequences in ToonTalk. Its worksheets survive in the author's own files; 464 of the students' programs survive in the Wayback Machine, under weblabs.org.uk/wlplone/Members/…, and still download intact.

Loading them needed no new engine code. sprite_from_file_name — what the native build calls when a .tt file is double-clicked — parses them directly. Seven 2005 programs were tried: the Match Maker, the Diagonal team, the duplicate removers, a Bulgarian student's multiple-sequence box. All loaded and rendered, boxes with live nests and trained robots inside.

Handing one to a running world is the same path: the sprite goes to tt_add_sprite_when_on_floor, which the floor's own react() picks up a frame later. That is what the activity page's load buttons do, so nothing restarts and the learner keeps what they have built.

Commit
80356f9
Subsystem
Saving
Trap
A save that reported success and wrote nothing

Out of the tab, onto a disk

The engine has always written both kinds of file — the paused dialog's save what is in the hand (case 7) and save everything (case 4). Natively they land in My Documents, which a browser tab has no equivalent of, so the port runs those same paths into its own filesystem and hands the bytes to the browser as a download. Reading one back is the loader above.

The trap was in the city path. City::compute_full_file_name takes a name that already ends in .cty verbatim, and otherwise resolves it against the user's folder — so an absolute path became <user dir>//toontalk/save/…, wrote nothing, and still returned TRUE. It now checks file_exists before claiming success: 10,181 bytes for a city, 662 for a held robot, and the robot's file reloads.

Commits
c39ff81 · 6a114b6 · ec2f7f5
Subsystem
Diagnostics
Symptom
52,000 console lines in one session; “everything slowed down”

The probes become the problem

Debugging probes added for one bug outlive it. Two of them printed per robot-match attempt — fine for a Pong demo, catastrophic in Resort Infinity where robots match every frame, at two console lines each. One had a multi-line robot name in it, so the printf split across console lines and the second half slipped past a filter that keyed on the line's prefix. Another was called holefail, and hole mismatches are routine — the resort's machinery probes an incomplete solution box every cycle — so the word “fail” in its own name held the quiet filter open.

Console output is now off by default and available behind ?log=1, the filter sits on console.log as well as on printf because half the probes bypass the latter, and probes that report expected conditions no longer say FAIL. The remaining bulk of that 52,000-line log was Chrome's own [Violation] notices, which only exist while DevTools is open.

Commits
230efa2 · 2ae22fd
Subsystem
Activity page
Symptom
A worksheet's “printed page” 404s; another's is hidden although it exists

A fact written twice will drift

Whether a sheet had a printable PDF was deduced from which group it was listed in, in two places that then disagreed. Moving one sheet between groups made it claim a PDF that was never built; fixing the first copy of the rule left the second still serving the 404. The rule is now generated — the build script lists the PDF folder and writes out what it finds.

It is the same shape as the port bugs catalogued above: one quantity computed in two places that must agree. The port has been producing them at about the rate it finds them in the original.

The work, measured

The whole port was carried out in a single continuous Claude Code session, which logs every request. The figures below are counted from that log and from git, not estimated. “Active time” sums the gaps between logged events with any gap capped at five minutes, so overnight pauses and the author's testing breaks do not inflate it; build waits do count.

MeasureThe port (5 July – 12 Aug)Whole session (from 22 June)
Active working time87 hours104 hours
Days worked3241
Commits287, on 30 distinct days
Lines added / removed after the source import+101,978 / −29,775
Messages from the author450525
Model requests19,13023,850
Output tokens (everything written)18.7 M27.6 M
Fresh input tokens0.49 M1.35 M
Cache writes344 M418 M
Cache reads9.66 B11.26 B
Tool calls10,36612,693
— of which shell commands4,6635,328
— file edits / reads1,474 / 9022,105 / 1,447

The ratio worth noticing is between the two input rows. Fresh input — text the model had never seen — is half a million tokens. Cache reads are nine and a half billion. Over 19,130 requests the accumulated conversation is re-read on each one, so 96% of all tokens processed were re-reads of context already paid for once. A single session spanning seven weeks is only affordable because of that.

The other ratio: 450 messages from the author against 10,366 tool calls — roughly twenty-three actions per instruction. Most of those actions are measurement rather than editing. Shell commands outnumber file edits three to one, which is the shape of the method described above: probe, measure, then change one thing.

Open

ItemCurrent understanding
Stale first frame after a time-travel jumpThe frame shown at a checkpoint can be half-drawn until play resumes. A one-cycle "fix" replayed log events and moved the user off the checkpoint; reverted. City::display_region deferring work is the live hypothesis.
Giant bird after time travelSeen once: a bird drawn at many times her size after a replay ended (birdgo rf=0). Not reproduced since.
Runt segment after browsing the pastResuming recording leaves next_log unchanged, so the next checkpoint is cut short. The arithmetic may be faithful to the original — in which case it is a question, not a bug.
Fullscreen subtitles coarseBetter since the real font engine and the descent-room fix, but the fullscreen upscale still shows.
Room-interior fidelityFloor stud noise and a dark pad against the original's look.
Guest pad truncated in flightIn Resort Infinity the box a bird carries draws with its text cut off. Same family as the earlier text-fit bugs; the carried-sprite scale and the text layout disagree.
Time-travel exit sets a fixed mouse modeLeaving time travel assigns a constant instead of deriving the mode from the pointer-lock state — the same class of error as the demo-replay mode bug, which was fixed by deriving rather than assigning.
Colours snapped to 256 entriesThe port reports an 8-bit display because the artwork is 8-bit. Sprite art is therefore exact, but anything drawn as a true RGB colorref is snapped, where the original at 32-bit is not. Deferred deliberately.