GenX‑DOSWIKI SearchPromptArticle↩ Site

sdltrs — Tandy TRS-80 Model III

The TRS-80 is the second GenX-DOS bundle compiled from source to WASM (after Emulator-atari800). It runs sdltrs (Mark Grebe / Jens Guenther) — the SDL2 TRS-80 emulator, BSD-2-Clause, still actively maintained.

The bundle was a Model I until August 2026. The Model III runs the Model I library, and it is the better machine to put in front of a visitor: one case instead of a monitor, an expansion interface and a drive stack cabled together, a 2.03 MHz Z80 instead of 1.77, lower-case as standard, and a keyboard with a numeric keypad. Same games, tidier machine. Changing it was a two-line edit to play.html — see The ROM for why no rebuild was needed.

Why sdltrs

The shortlist:

  1. lkesteloot/trs80 — a clean TypeScript/ESM emulator, easy to embed. It works, but renders the screen with a baked-in green-phosphor padding border (an overscan frame around the text) that fought the CRT bezel, and exposes no save-state. It was the starting point but got replaced.
  2. No libretro TRS-80 core ships in the EmulatorJS-flavoured set, so the EJS route was out (unlike most recent additions — see Emulator-VICE-family for that path).
  3. sdltrs — accurate, maintained, renders a plain 512×384 text area with no phosphor padding (so the canvas just fills the bezel cutout), and has a built-in save-state in C (trs_state_save / trs_state_load). Worth building.

Source: https://gitlab.com/jengun/sdltrs — the matching jengun GitHub repo 404s; GitLab is the live one.

The build

emscripten via emsdk (lives at ~/emsdk/), same toolchain as atari800. sdltrs is a flat src/*.c SDL2 program, so the recipe is a single emcc of all the sources — no autotools:

emcc $SRCS -O2 -DSDL2 -DROM_PATH='""' -sUSE_SDL=2 -sASYNCIFY \
  -sALLOW_MEMORY_GROWTH=1 -sINITIAL_MEMORY=32MB -sFORCE_FILESYSTEM=1 \
  -sMODULARIZE=1 -sEXPORT_NAME=createSDLTRS \
  -sEXPORTED_RUNTIME_METHODS="['FS','callMain','ccall','cwrap']" \
  -sEXPORTED_FUNCTIONS="['_main']" \
  --embed-file ../../level2.rom@/level2.rom -o ../out/sdltrs.js

$SRCS is every src/*.c including PasteManager.c — its X11 clipboard paths are #ifdef-guarded out under -DSDL2. Output: sdltrs.js (~190 KB) + sdltrs.wasm (~1.3 MB).

-sASYNCIFY and two yield patches

Like atari800, sdltrs runs a blocking continuous loop (z80_run(1), main() never returns), so -sASYNCIFY is non-optional. But ASYNCIFY only helps if the loop actually yields. sdltrs's throttle uses SDL_Delay, which busy-waits under emscripten and freezes the page. Two #ifdef __EMSCRIPTEN__ patches fix it:

With those, emscripten_sleep unwinds the stack each tick, the browser paints, and the createSDLTRS factory promise resolves.

-sMODULARIZE load order

The build emits a createSDLTRS(moduleOverrides) factory. sdltrs.js must load before the inline boot code that calls it — a load-order slip gives "createSDLTRS is not defined", silently swallowed inside the boot's async IIFE.

The ROM

play.html boots the Model III ROM (14 336 bytes, md5 0e4c4b1638a23fd26796816cf2f83961), which ships as systems/trs80/model3.rom. © Tandy / Microsoft; bundled for emulator-only use (recorded in ATTRIBUTION).

It is fetched at runtime, not embedded. The original build baked the Model I Level II ROM into the WASM at /level2.rom with --embed-file, which meant the machine was fixed at compile time — changing it would have meant a whole emsdk rebuild for the sake of a 14 KB file. Instead the page fetches the ROM alongside the game and writes both into MEMFS in preRun:

preRun: [m => {
  m.FS.writeFile('/game.cmd', cmdBytes);
  m.FS.writeFile('/model3.rom', romBytes);
}],

The WASM still has a /level2.rom embedded, but we zeroed its 12 KB in place rather than rebuild: nothing reads it, and the file keeps its size and offsets, so the engine is otherwise byte-for-byte the one we tested. If the bundle ever needs another TRS-80, it is now a change of two strings.

The play.html contract

createSDLTRS({
  canvas,
  arguments: ['-model', '3', '-romfile3', '/model3.rom', '-noled', '-borderwidth', '0', '-scale', '2', '/game.cmd'],
  preRun: [m => {
    m.FS.writeFile('/game.cmd', cmdBytes);
    m.FS.writeFile('/model3.rom', romBytes);
  }],
});

-model 3 picks the machine and -romfile3 names its ROM — note that each model has its own -romfileN switch, so -romfile1 would be silently ignored here. -noled -borderwidth 0 = a clean 512×384 (4:3) text area with no chrome (1024×768 at -scale 2). The .cmd is written to MEMFS and passed as a positional arg; sdltrs's trs_load_cmd runs it after reset and jumps straight into the program — no disk boot.

The canvas backing-store gotcha (cost an hour)

sdltrs uses the software video path (SDL_GetWindowSurface / SDL_UpdateWindowSurface = a raw putImageData into the canvas). Emscripten does not resize the canvas backing store on this path — it stays at the HTML default 300×150 — and width:100% CSS only sets the display size. So the 1024×768 surface lands in a 300×150 buffer and only the top-left corner draws. Fix: hard-set the backing store via HTML attributes — <canvas width="1024" height="768"> (= the scale-2 surface size); CSS then scales the display into the bezel cutout.

Why .cmd, not disk images — for the games

The lineup is 10 self-contained .cmd arcade games (Big Five Software, Adventure International, Cornsoft Group). Disk boot was investigated and dropped: the RetroStore disk_0.* images aren't bootable system disks (the games ship as command.CMD to run on a bare machine), and a bare machine with a non-bootable disk hangs on the black FDC boot-wait. The .cmd-as-positional-arg path sidesteps boot entirely.

The self-contained build is the thing to check

Eliminator was dropped in August 2026 and replaced with Space Castle, and the reason is worth recording because it will come up again.

Eliminator had been shipping dead — a black screen, on the Model I before the Model III swap and after it. The file itself was fine: 56 contiguous load blocks, a sensible entry point, every byte accounted for. Chasing it turned up three things:

So the rule for adding a title here: a .cmd that loads low, below where a DOS would be, is a bare-machine build and will work. One that loads high wants a DOS we do not provide, however clean its structure looks. Structural validity says nothing about it — the broken one parsed perfectly.

Sourcing note: RetroStore's RPC (retrostore.org/rpc?m=pubapplist → downloadapp?appId=…) yields a ZIP with the self-contained command.CMD plus a disk image; we keep the .cmd.

System disks, and the keyboard limit that forced them

Menu entry 11 is a System disks submenu offering two period disk operating systems. It exists because the obvious route does not work.

A Model III has BASIC in ROM, so a diskless machine ought to reach it directly. It does not: with the FDC present the boot ROM stops at Diskette?, which on real hardware is answered with BREAK. No keypress reaches the emulator at that prompt — not the browser's own keys, not synthetic KeyboardEvents, not the single-dispatch-at-document-with-a-hold recipe that drives the CLEAR softkey above. The same page takes keys perfectly once a .cmd is running; Space Castle starts on a plain digit.

The machine is not hung. Draw the canvas into a 2D canvas and checksum it a second apart and the cursor is still blinking. The likeliest reading is that this ASYNCIFY build unwinds on a blocking key read and never resumes, so code that polls the keyboard itself works and code that waits on a key does not.

Do not go hunting for the right key. It is not a key-mapping problem, and an afternoon was lost establishing that.

The consequence is that both DOSes boot and then stop at their own first prompt — TRSDOS at Enter Date, LDOS at Time ? — so the entry offers a boot screen rather than a command line. That is recorded rather than hidden.

Key Disk Format Notes
trsdos disks/trsdos13.dmk DMK, 256016 TRSDOS 1.3, dated Jul 1 1981. Radio Shack's own. No public distribution grant; same footing as the Model III ROM
ldos disks/ld3-531.dsk JV3, 193024 LDOS 5.3.1, MISOSYS. Freely redistributable provided the notice is retained — the ATTRIBUTION row is that retention

play.html now accepts a disk alongside rom in games.json: a disk is written to MEMFS and passed as -disk0, a rom is loaded positionally as before, and an entry may carry either. The submenu itself follows the ZX81 1K Chess pattern (see Customising-the-Filesystem).

CLEAR softkey

Most TRS-80 arcade games start with the CLEAR key, which has no obvious modern-keyboard equivalent. sdltrs maps CLEAR to PC Home (or Delete), so systems/_shared/genx-trs80-softkeys.js adds a CLEAR button that dispatches a synthetic Home keydown/keyup — emscripten SDL2 doesn't check isTrusted, so the synthetic key reaches the emulator. Esc is the TRS-80 BREAK key.

Three things about it changed in August 2026, all worth knowing if you write another soft key:

Save / load state

sdltrs has a working save-state in C (trs_state_save.c), but it isn't wired to the page's save/load menus yet — so the TRS-80 bundle ships without them for now. The hooks are there for a future pass.

Bundle layout

systems/trs80/
├── play.html
├── sdltrs.js
├── sdltrs.wasm          ← embedded /level2.rom zeroed (unused)
├── model3.rom           ← the ROM actually booted
├── games.json
├── controls.html
└── games/               ← 10 .cmd programs

The build tree is ephemeral (/tmp); only a rebuild needs the recipe above — re-clone, re-apply the two patches, run the emcc line.