Xenon 2

How it works · write-up

Removing Asyncify from the Hatari WASM build

xenondoc/remove-asyncify.md · 9 KB · updated 2026-09-22

Purpose

Native Hatari enters the M68K emulator and normally remains there until quit. In a browser that monopolizes the JavaScript main thread. The original WASM build used Emscripten Asyncify so emscripten_sleep() could unwind the WebAssembly stack, return to the browser, and reconstruct that stack later.

Asyncify transformed much of the hot CPU path and interacted badly with Hatari's frequent setjmp/longjmp-based TRY/ENDTRY fault handling. Profiles contained expensive WASM-to-JavaScript machinery such as invoke_ii, dynCall_ii, stackSave, and Asyncify rewind operations.

The WASM build now returns voluntarily at completed VBL boundaries. Only durable emulated-machine state survives between callbacks; no native C call stack is retained or reconstructed.

Implemented work

Browser-owned execution loop

main() performs normal initialization and calls M68000_Start(). Under Emscripten, that initializes/restores the machine without entering the permanent m68k_go() loop. main() schedules the first callback and returns:

browser loads Hatari
└─ main
   ├─ initialization and snapshot restore
   ├─ M68000_Start
   ├─ emscripten_async_call(Main_WasmRunSlice, 0 ms)
   └─ return to browser

Each callback runs one VBL-bounded CPU slice:

browser timer callback
└─ Main_WasmRunSlice
   ├─ Timing_GetHostDelayMs
   │  └─ if early: reschedule for remaining delay and return
   ├─ M68000_RunSlice
   │  ├─ clear host-yield request
   │  └─ m68k_go
   │     └─ m68k_run
   │        └─ m68k_run_2_000
   │           └─ execute instructions until a VBL requests a yield
   ├─ Timing_GetHostDelayMs
   ├─ emscripten_async_call(Main_WasmRunSlice, delay)
   └─ return to browser

m68k_go() preserves its one-time initialization state and hardboot value across slices. CPU, memory, device, and renderer state already resides in globals or durable Hatari structures.

VBL-driven yield

Timing_WaitOnVbl() no longer calls emscripten_sleep() in browser builds. It records the absolute deadline, advances the next VBL destination, sets a host-yield request, and returns. The request propagates outward at a safe instruction/fault boundary:

m68k_run_2_000
└─ emulated instruction
   └─ CycInt_CallActiveHandler
      └─ Video_InterruptHandler_VBL
         └─ Timing_WaitOnVbl
            ├─ HostVblDeadline = DestTicks
            ├─ DestTicks += frame duration
            ├─ M68000_RequestHostYield
            └─ return

m68k_run_2_000
├─ finish current instruction and TRY/ENDTRY boundary
├─ observe M68000_HostYieldRequested()
└─ return
   └─ m68k_run → m68k_go → M68000_RunSlice return
      └─ Main_WasmRunSlice schedules the next browser callback

The absolute deadline prevents cumulative drift. Timing_GetHostDelayMs() rounds remaining microseconds up to milliseconds. An early callback reschedules without entering the CPU; an elapsed deadline is cleared and the next slice runs.

Safe CPU-loop exit

The yield flag is checked in m68k_run_2_000() before the next instruction and again after ENDTRY, so no transient locals or half-completed setjmp/longjmp fault boundary must survive. m68k_go() checks the flag after m68k_run() and exits its outer loop, while retaining initialization until Hatari genuinely quits.

Sliced WASM execution currently accepts only m68k_run_2_000(). Selecting another CPU runner reports the unsupported configuration and quits instead of blocking the browser.

Asyncify removal and native longjmp

The WASM compile and link flags now use:

-fwasm-exceptions
-s SUPPORT_LONGJMP=wasm

The old -fno-exceptions and -s ASYNCIFY settings were removed. Hatari's fault handling therefore remains inside WebAssembly without Asyncify-transforming the CPU path.

Verification

A clean 170-step WASM build succeeded. Static inspection of hatari.js found:

Asyncify references: 0
doRewind references:  0
invoke_ii references: 0
invoke_vi references: 0
dynCall_ii references: 0

A browser smoke test restored /share/hatari/xenonplay.sav and advanced from frame 8 to frame 29 through separate timer callbacks, without a WASM trap, out-of-bounds access, or snapshot restore failure.

The representative profile is now:

Timer fired
└─ Main_WasmRunSlice
   └─ M68000_RunSlice
      └─ m68k_go
         └─ m68k_run_2_000
            ├─ emulated opcode / I/O access
            │  └─ IoMem_bget
            │     └─ Video_ScreenCounter_ReadByte
            └─ VBL interrupt
               └─ ScreenTraceList_UpdateWindows
                  └─ sprite-frame construction/rendering

The former doRewind/stackSave/invoke_ii path is gone. A measured callback was about 2.5-3 ms, leaving the browser main thread available between slices.

Still open

Update 2026-09-20: the per-second PERF counters, the browser-side A/B switches and the measured results of the loop under load (phone and desktop) are documented in WASM_PERFORMANCE.MD. That investigation found the loop itself sound -- the 8-10 ms callback lateness seen on a phone was the main thread saturated by hidden developer-view work, not timer clamping -- and changed WASM_PAUSED_POLL_MS from 50 to 16 ms so the paused options menu redraws at display rate. The items below are still accurate except where noted.

Additional CPU runners

Only m68k_run_2_000() has an instruction-boundary yield. Supporting 68020/68030/68040-compatible, prefetch, MMU, JIT, or other runners requires equivalent safe exits and an audit of state that must persist across callbacks.

Other blocking UI paths

SDL dialogs and other code may still use SDL_WaitEvent(), repeated SDL_Delay(), or their own blocking loops. Without Asyncify these cannot suspend a live WASM stack. Browser dialogs should use explicit state machines/event polling and return to the browser between steps.

Preloaded synchronous virtual-filesystem access is unaffected. Future genuinely asynchronous browser I/O needs callbacks/state machines or a separately evaluated JSPI integration; it cannot assume Asyncify semantics.

Diagnostic-output overhead

Sprite diagnostics still call iprintf, cross WASM→JS through _fd_write, and schedule HTML output. Forced layout was removed, but formatting, boundary crossings, and timer scheduling remain measurable.

Recommended follow-up:

  1. Compile out or runtime-disable verbose sprite-stream output outside diagnostics.
  2. Buffer HTML console text and allow at most one pending flush, once per animation frame or every 50-100 ms.
  3. Cap retained log lines so output storage cannot grow indefinitely.

ScreenTrace_LogRead() was deliberately left unchanged at the time. It has since been split: the instruction-fetch hooks that drive the sprite stream still run on every access, while the access-ring/address-map bookkeeping behind them is governed by --xenon-mem-trace (browser default: only while a developer view is visible). See WASM_PERFORMANCE.MD.

Video-counter read profiling

A short capture showed this path consuming about one third of one 3 ms callback:

m68k_run_2_000
└─ op_b010_0_ff
   └─ IoMem_bget
      └─ Video_ScreenCounter_ReadByte

This may be legitimate Xenon polling. Capture several seconds and inspect bottom-up/self-time totals first. If it stays dominant, investigate whether successive byte reads can safely share calculations without changing cycle-accurate register behavior.

Timing validation

Browser timers have millisecond granularity and may be throttled in background tabs. Validate:

  • long-run audio/video synchronization and deadline drift;
  • hide/show tab recovery;
  • fast-forward, pause/unpause, reset, and quit;
  • automatic frame skipping under sustained load;
  • browsers other than Chromium.

JSPI may eventually help selected asynchronous imports, but it is not needed by the VBL loop. The explicit VBL state boundary is predictable and easy to profile, so it should remain the default absent a concrete blocking browser API.

Relevant files

  • src/sdl/main_sdl.c: callback scheduling and top-level return.
  • src/m68000.c, src/includes/m68000.h: sliced entry and yield flag.
  • src/cpu/newcpu.c: instruction-boundary exits, runner guard, persistent m68k_go() state.
  • src/sdl/timing.c, src/includes/timing.h: VBL deadline and host delay.
  • CMakeLists.txt, src/CMakeLists.txt: native WASM exception/longjmp flags and removal of Asyncify.