afterglow / under the hood Complete source ↓

An emulator in one sitting

Run Starry Night 2.0 with Musashi

Start with an empty directory. Finish with the original After Dark 2.0 module executing as 68k machine code and drawing into an HTML canvas.

No after-dark-vm dependency — this is the machinery underneath

C11 + 2 EM_JS callsone language at the guest boundary
68EC020 → WasmMusashi compiled by Emscripten
20 Toolbox trapsonly what this module exercises
01 / THE SHAPE

What we are building

An After Dark module is not a picture or a movie. It is a Macintosh code resource with a small message-based ABI. After Dark prepares a parameter block, calls the module with messages such as Initialize, Blank, and DrawFrame, and supplies the Macintosh Toolbox services the code expects.

Our host does the same thing at the narrowest useful scale. Musashi handles CPU instructions; about 670 lines of intentionally plain C provide guest memory, the After Dark call frame, and just enough QuickDraw for this version of Starry Night.

ADgm 0resource fork
16 MiB RAMbig-endian guest
Musashi68EC020 CPU
A-line hostToolbox traps
RGBA640 × 480
<canvas>ImageData
The important boundary is the A-line host: it turns guest Toolbox calls into host-side behavior.
02 / THE STACK

Why C + Emscripten

Musashi is C, and its integration surface is six memory callbacks plus CPU control functions. Keeping the host in C makes registers, guest addresses, big-endian reads, and stack layouts explicit. Emscripten then compiles the complete CPU and host to WebAssembly.

C for the emulator

No binding layer around Musashi and no duplicated guest-memory model.

JavaScript only at presentation

Two EM_JS functions copy RGBA bytes to ImageData and update status text.

No SDL

The browser already has a canvas; avoiding a windowing library keeps the example direct.

No general Mac emulator

We construct only the low-memory globals and Toolbox behavior this one module needs.

Rust could provide stronger types, but wrapping a C CPU core and browser APIs would add build and FFI surface here. JavaScript could host a Wasm Musashi build, but every trap would cross the Wasm/JS boundary. For this tutorial, C is the shortest path from opcode to pixel.

03 / EMPTY DIRECTORY

Lay out the project

You need Git, Make, a C compiler, Python 3, and an activated Emscripten SDK.

terminal
mkdir -p starry-musashi/src
cd starry-musashi

SOURCE_BASE=https://morphing.cloud/afterglow/tutorial/sample
curl -fsSLO "$SOURCE_BASE/Makefile"
curl -fsSLO "$SOURCE_BASE/index.html"
curl -fsSLO "$SOURCE_BASE/m68kconf_tutorial.h"
curl -fsSLO "$SOURCE_BASE/musashi-a-line.patch"
curl -fsS "$SOURCE_BASE/src/mac.h"  -o src/mac.h
curl -fsS "$SOURCE_BASE/src/mac.c"  -o src/mac.c
curl -fsS "$SOURCE_BASE/src/main.c" -o src/main.c

# Copy your projected module resource fork.
cp "/path/to/projected/Starry Night" starry-night-2.rsrc
starry-musashi/ ├── Makefile ├── index.html ├── m68kconf_tutorial.h ├── musashi-a-line.patch ├── starry-night-2.rsrc ← you supply this └── src/ ├── mac.h ├── mac.c └── main.c vendor/musashi/ ← cloned by make starry.js + .wasm + .data ← generated by emcc
Bring your own module

The companion project does not include Starry Night. Copy your projected Starry Night 2.0 resource fork to starry-night-2.rsrc. “Projected” means it contains a standalone ADgm resource with ID 0. Projection from the After Dark 2.0 control panel is a separate preservation step.

04 / CODE RESOURCE

Find ADgm 0

A classic resource fork starts with offsets to its data and map sections. read_adgm() walks the type list, finds type ADgm, then copies resource ID 0 into an ordinary host buffer. We load those bytes at guest address $100000.

src/mac.c — the essential lookup
for (uint32_t i = 0; i < type_count; i++) {
    uint32_t type = types + 2 + i * 8;
    if (memcmp(fork + type, "ADgm", 4) != 0) continue;

    /* Find reference ID 0, then copy its data block. */
    ...
    memcpy(copy, fork + block + 4, size);
    return copy;
}

This loader is deliberately strict. It validates every offset before using it and reports a clear error if the fork is malformed or has no ADgm 0. A production importer also understands MacBinary, AppleDouble, HFS images, archives, and the built-in DRVR form used by the original control panel.

05 / MINIMAL MACINTOSH

Give Musashi a machine

Musashi does not allocate RAM or know what a Macintosh is. The host owns a 16 MiB byte array and exposes big-endian 8-, 16-, and 32-bit accessors through the callback names Musashi expects.

src/mac.c — the CPU boundary
unsigned int m68k_read_memory_8 (unsigned int a) { return r8(a); }
unsigned int m68k_read_memory_16(unsigned int a) { return r16(a); }
unsigned int m68k_read_memory_32(unsigned int a) { return r32(a); }
void m68k_write_memory_8 (unsigned int a, unsigned int v) { w8(a, v); }
void m68k_write_memory_16(unsigned int a, unsigned int v) { w16(a, v); }
void m68k_write_memory_32(unsigned int a, unsigned int v) { w32(a, v); }

The rest of the synthetic machine is small but significant:

  • a stack, code area, bump-allocated heap, and a sentinel return address;
  • classic low-memory globals for Ticks, ScrnBase, thePort, CurrentA5, and the random seed;
  • a minimal GrafPort, QuickDraw globals, screen BitMap, and regions;
  • a stable A5 world and a 44-byte After Dark GMParamBlock.
src/mac.c — start the CPU
m68k_init();
m68k_set_cpu_type(M68K_CPU_TYPE_68EC020);
m68k_pulse_reset();
06 / AFTER DARK ABI

Call the module as After Dark would

The module entry point uses a Pascal-style stack. Before every message, begin_message() resets SP, reserves an OSErr result, pushes the parameters, installs a sentinel return address, sets A5, and starts PC at the code resource.

src/mac.c — one module call
push16(0);             /* OSErr result slot */
push32(storage_var);   /* VAR storage */
push32(blank_rgn);
push16(message);
push32(param_block);
push32(SENTINEL);      /* guest RTS stops here */

m68k_set_reg(M68K_REG_A5, A5_WORLD);
m68k_set_reg(M68K_REG_PC, CODE_BASE);
MessageValueWhat the host does
Initialize0Let the module allocate and populate its storage handle.
Blank2Prepare the screen and reset animation state.
DrawFrame3Advance the original module once; repeat from the browser loop.

When the guest executes RTS, PC becomes SENTINEL. That is our clean return condition. It avoids inventing a second calling mechanism around Musashi.

07 / TOOLBOX HOST

Turn A-line opcodes into QuickDraw

Macintosh Toolbox calls compile to words in the $A000–$AFFF range. Upstream Musashi treats an A-line word as a CPU exception. The included four-line patch first offers it to Musashi’s existing illegal-instruction callback; m68kconf_tutorial.h connects that callback to mac_trap().

musashi-a-line.patch
+ /* Macintosh Toolbox calls use A-line opcodes. Let the host handle them. */
+ if (m68ki_illg_callback(REG_IR))
+     return;

The dispatcher reads parameters from the guest stack, performs the host-side operation, pops exactly those parameter bytes, and returns 1 to suppress the CPU exception. Unknown traps stop with the opcode and guest PC instead of silently corrupting state.

Memory

NewHandleClear, HLock, HUnlock

State

Random, foreground/background color, clip and pen state

Raster

EraseRgn, PaintRect, PaintOval, and Line

Geometry

MapPt maps module coordinates into the demo rectangle

This is the central lesson: CPU emulation is only one layer. The module becomes visible when a host supplies the OS contracts around it. Starry Night is a good first target because its projected code and narrow QuickDraw vocabulary let us see that boundary without first implementing an entire Macintosh.

08 / PRESENTATION

Copy RGBA pixels to the canvas

The QuickDraw shim writes directly into a 640 × 480 RGBA array. An Emscripten EM_JS function views the same bytes through HEAPU8 and publishes them with putImageData(). There is no renderer framework between the trap and the canvas.

src/main.c — the complete presentation bridge
EM_JS(void, present_canvas, (const uint8_t *pixels), {
    const canvas = document.getElementById("screen");
    const context = canvas.getContext("2d");
    const length = 640 * 480 * 4;
    if (!Module.starryImage)
        Module.starryImage = context.createImageData(640, 480);
    Module.starryImage.data.set(HEAPU8.subarray(pixels, pixels + length));
    context.putImageData(Module.starryImage, 0, 0);
});

Drawing traps end the current Musashi timeslice. The browser callback resumes up to eight such yields per animation refresh, presents if anything changed, and starts the next DrawFrame after the guest returns. This keeps Starry Night’s intra-frame erase/draw sequence responsive without blocking the browser event loop. See Emscripten’s main-loop API and C/JavaScript interop guide for the underlying mechanisms.

09 / BUILD

Compile and run

The Makefile clones a pinned Musashi revision, applies the A-line callback patch, generates its opcode table, and invokes emcc. The resource fork becomes a preloaded file in Emscripten’s virtual filesystem.

terminal
make
make serve

# Then visit http://localhost:8000

You should see a black 640 × 480 display accumulate Starry Night’s original stars. The status line reads “Starry Night 2.0 · original 68k code.”

Headless smoke test

The native target uses the same guest host without the canvas. It is the fastest way to distinguish an emulation error from browser presentation.

terminal
make native
./starry-native starry-night-2.rsrc 120

frames=120 lit_pixels=674
If it stops on an unknown trap

Confirm that your file is the same projected Starry Night 2.0 module. A different release or module may use more Toolbox calls. The error gives you the next opcode and PC to implement; that iterative loop is exactly how a broader module host grows.

10 / COMPANION PROJECT

Complete source

These are the exact files used for the native and WebAssembly validation of this tutorial. Expand them to read inline or download them into the tree above. The resource fork is intentionally absent.

Makefileclone, patch, build, servedownload
Loading…
index.htmlcanvas shelldownload
Loading…
m68kconf_tutorial.hMusashi configurationdownload
Loading…
musashi-a-line.patchroute Mac traps to the hostdownload
Loading…
src/mac.hsmall public APIdownload
Loading…
src/mac.cguest, ABI, Toolbox, rasterizerdownload
Loading…
src/main.ccanvas and native harnessdownload
Loading…
README.mdquick referencedownload
Loading…
11 / THE REAL ENGINE

What a general module emulator adds

This host is intentionally specific: it explains the mechanism, runs Starry Night, and fails loudly outside that contract. A practical After Dark engine keeps the same architecture while making every layer deeper.

More MacintoshResource, Memory, QuickDraw, Color QuickDraw, Sound, Gestalt, events, and time.
More module ABIsBuilt-in projection, framework DLL tables, controls, multiple monitors, and desktop drawing.
More fidelityIndexed pixels, transfer modes, regions, palettes, direct framebuffer writes, pacing, and compatibility evidence.

The conceptual loop does not change: execute guest code, intercept the contracts it expects, update host-visible state, and yield often enough to present it. Afterglow’s emulator is this experiment grown to support a much wider software ecosystem.