JS99'er
JS99'er, by Rasmus Moustgaard, emulates the TI-99/4A entirely in the browser, including a number of peripherals and near-complete support for the F18A video upgrade. Now built with Angular and TypeScript, it loads cartridges and disk images and runs in any modern browser.
Runs on: Web browser
JS99'er Online Emulator
Play JS99'er using JavaScript directly in your browser.
Controls
Configurations
| Configuration | Emulator | Machine | OS | Legal | |
|---|---|---|---|---|---|
| TI-99/4A · TI BASIC | JS99'er | TI-99/4A | grey | Open ⛶ |
Machines emulated
Chips
Notes
Embedding
js99er is an Angular/TypeScript application, not a single script. We bundle a small boot entry with Vite (library mode, formats:['es']) into js99er-boot.js, load it as <script type="module">, and drive the machine ourselves rather than using js99er's Angular UI — so the loop can be paused and single-stepped, which the debugger needs.
Boot. The entry constructs just the TI994A console (the same class the app's ConsoleFactoryService builds), passing a plain Settings object and stubs for the Angular services it does not need. It then owns a loop built on the console's own frame() method:
import { TI994A } from "./emulator/classes/ti994a";
const ti = new TI994A(document, canvas, [], settings, dbStub, wasm, onBreakpoint);
ti.reset(false);
ti.getKeyboard().start(); // attach js99er's own key listeners
(function loop(){ ti.frame(); requestAnimationFrame(loop); })();
The machine is plain objects. Everything the debugger needs is reachable off the console — no wasm heap to reach into for the CPU state:
| Member | Kind | What it does |
|---|---|---|
ti.frame(skipBp) | method | Run one video frame: draws every scanline and steps the TMS9900 in lockstep. The frame-step primitive. |
cpu.run(1, true) | method | Run one CPU instruction (skipping the breakpoint check). The single-instruction step. |
cpu.getPc() / getWp() | methods | The Program Counter and Workspace Pointer, readable and writable (setPc/setWp). |
cpu.st | field | The 16-bit status register (L> A> EQ C OV OP X + interrupt mask). |
cpu.setBreakpoints([...]) | method | Give js99er's CPU a list of Breakpoint objects (INSTRUCTION or CPU_MEMORY_WRITE); run() stops on them. |
memory.getWord(a) | method | Side-effect-free read of the 16-bit big-endian CPU bus — what the hex/disassembly views use. |
memory.writeWord(a, w, cpu) | method | Bus write — poke memory live. |
vdp.getRAM() | method | The 16 KB VDP VRAM (a view into the render WASM's memory). |
keyboard.simulateKeyDown/Up(keyCode) | methods | Drive the TI key matrix from a JS keyCode — used by the on-screen keyboard. |
Because the CPU, bus and RAM are ordinary JavaScript, the debugger single-steps with cpu.run(1), reads WP/PC/ST straight off the CPU, and implements breakpoints and watchpoints through js99er's own setBreakpoints — no changes to the emulator core.
Debugger integration
Wiring js99er into the shared in-browser debugger needed two pieces of custom work, plus a set of techniques for reaching into the live machine.
1 · A boot shim, because js99er is an Angular app. Its machine core is framework-agnostic TypeScript, but it is normally wired together by Angular services and driven by an internal requestAnimationFrame loop that cannot be paused or single-stepped from outside — which the debugger needs. So we wrote a small boot entry that constructs the TI994A directly, replaces the three @Injectable services it touches (WASM, database, dialog) with plain build shims, and drives it from a loop we control:
// our loop, not js99er's — so pause / step / breakpoints work
function stepInsn(n){ while(n-- > 0) cpu.run(1, true); }
function loop(){ if(!running) return; ti.frame(bps.size === 0); if(running) requestAnimationFrame(loop); }
2 · Bundled as an ES module, not an IIFE. js99er's modules import each other and pull in a small AssemblyScript WASM module for video. Building with Vite in library mode with formats:['es'] keeps live module bindings and lets the boot instantiate the WASM (an imported 21-page memory that holds VRAM + the framebuffer, exactly as js99er's WasmService does). Because a module is deferred, the boot publishes window.EMU_BOOT asynchronously, so the debugger plug-in polls for it.
Techniques for deeper access. The plug-in reaches into the running machine through js99er's own surfaces:
- No fixed registers. The TMS9900 has no on-chip general-purpose registers — its sixteen "workspace" registers live in RAM at the address in WP. We expose WP, PC and ST (plus the status flags) as the CPU registers, and surface R0–R15 as a live view of the words at
WP+2n. - Side-effect-free reads. The hex and disassembly views scrub memory with
memory.getWord()(not the bus read that runs I/O), so auto-polling the view is safe. The bus is 16-bit big-endian, so a byte view picks the high byte at even addresses. - Native breakpoints. Execution breakpoints and memory-write watchpoints are handed to js99er's CPU as
Breakpointobjects viasetBreakpoints; itsrun()stops on them and calls ouronBreakpoint, which pauses the loop. - Single instruction step.
cpu.run(1, true)executes exactly one instruction.
Everything the debugger shows — WP/PC/ST and the workspace registers, the CPU bus and VDP VRAM in hex/disassembly, follow-PC, single-step, breakpoints and watchpoints — is built from these, using the shared tms9900 disassembler and with no changes to js99er itself.
Architecture
js99er is a full TI-99/4A by Rasmus Moustgaard. Each hardware block is its own class hanging off the TI994A console:
TMS9900— the 16-bit CPU interpreter. Big-endian, with the workspace-register model (WP points at sixteen registers in RAM) and the BLWP/RTWP context-switch mechanism.Memory— the console address map: system ROM, the 256-byte scratchpad, the 32K expansion RAM, GROMs and cartridge space, plus peripheral (DSR) cards.TMS9918A— the VDP video chip; its 16 KB VRAM and the RGBA framebuffer both live in a small AssemblyScript WASM module that rasterises each scanline. (js99er can also emulate the F18A and V9938.)TMS9919(SN76489) sound,TMS5200speech,CRU,Keyboard, disk controllers and RAM disks.System— the console ROM and GROM, embedded as byte arrays (the TI operating system and TI BASIC GPL).
Each frame() draws the visible scanlines and runs the CPU (and, on an F18A, its GPU) in lockstep. Because every component is an ordinary object, the whole machine state is inspectable — which is what the debugger reads each refresh.
Sound
The TI-99/4A's sound is the TMS9919 (a Texas Instruments SN76489): three square-wave tone channels and a noise channel. js99er emulates the chip faithfully, but its output is normally drained by an Angular SoundService that owned a Web Audio context — and that service is one of the pieces we replaced with a build shim when bundling the boot entry. So in this build the chip runs but nothing reads its samples: the machine is genuinely silent, and the only AudioContext present is the unrelated one used for cassette (tape) I/O.
We give it sound by draining the chip ourselves into the site's shared Web Audio sink (/debugger/src/audio.js, window.EmuAudio). Two steps, both in js99er-debug.js. First the PSG driver is re-initialised at the sink's own sample rate so pitch is exact — the chip's clock divisor is derived from that rate, so pushing the right sample count alone would not be enough. Then we wrap the console's per-frame method so every video frame we pull exactly one frame of chip output and push it to the sink, inside the same step the run-loop already drives (no extra timer):
const psg = EMU_BOOT.ti.getPSG();
psg.setSampleRate(EmuAudio.sampleRate); // chip at sink rate => correct pitch
const n = Math.round(EmuAudio.sampleRate / 60); // stereo frames per video frame
const mono = new Int8Array(n), stereo = new Int16Array(n * 2);
const frame = EMU_BOOT.ti.frame.bind(EMU_BOOT.ti);
EMU_BOOT.ti.frame = function (skip) {
const out = frame(skip);
psg.update(mono, n); // one frame of 8-bit signed mono
for (let i = 0; i < n; i++) {
const v = mono[i] << 8; // 8-bit signed -> 16-bit signed
stereo[i * 2] = v; stereo[i * 2 + 1] = v; // mono duplicated to L, R
}
EmuAudio.push(stereo); // ignored while muted
return out;
};
The Sound/Mute button in the shell reads EMU_BOOT.transport.isMuted / setMute, which we delegate straight to EmuAudio. It starts muted, as browsers require, and unmutes from the real click; the shared sink resumes its AudioContext on that gesture.
The machine boots to the master title screen and stays there. Sound plays whenever the software programs the chip: press any key then 1 to reach TI BASIC, and a CALL SOUND comes straight out of the SN76489.