iigs-unit
v0.0.3
Published
65816 assembly unit-testing framework
Downloads
232
Readme
iigs-unit — Unit Testing for Apple IIgs Assembly Code
iigs-unit is a lightweight unit-testing framework for 65816 assembly code running on the Apple IIgs. Tests are executed under GoldenGate (iix.exe) and assertions are made in JavaScriptunit test runner of your choice. Both ORCA/M and Merlin32 assembly syntax are supported.
How It Works
ORCA/M path (assembler: 'orca') (default):
Main.s ── iix assemble ──▶ Main.ROOT + Main.A (OMF object files)
── iix link ──────▶ Main.aunit (OMF executable)
── iix run ───────▶ out.dat (AUNT binary packet)
── assertions ────▶ pass / failMerlin32 path (assembler: 'merlin32'):
Main.s ── merlin32 ────▶ Main.aunit (OMF executable, assemble+link in one step)
── iix run ─────▶ out.dat (AUNT binary packet)
── assertions ──▶ pass / fail- The assembly harness calls
AUnit_Init, exercises the function under test, records results withAUnit_CaptureRegs/AUnit_AppendValue/AUnit_AppendMem, then callsAUnit_WriteResultsto flush anout.datfile via GS/OS. - The JS test calls
cpu65816(sharedConfig)to get a boundjsl/jsrcaller, orrunGeneratedTest({ harnessFile })for hand-written harnesses. - Assertions check the returned result object.
Environment Setup
iigs-unit requires two environment variables pointing to the tool executables:
| Variable | Description |
|----------|-------------|
| IIX_EXE | Full path to iix.exe (GoldenGate emulator) |
| MERLIN32_EXE | Full path to Merlin32.exe (only required for Merlin32 tests) |
The recommended way to set these is a .env file in the project root, which can loaded automatically via dotenv:
IIX_EXE=C:\Program Files (x86)\GoldenGate\iix.exe
MERLIN32_EXE=C:\path\to\Merlin32.exeBoth variables must be set to the executable path directly — no directory fallback is performed. The runner throws at startup if IIX_EXE is unset, and throws if MERLIN32_EXE is unset when a Merlin32 test runs.
Running the Built-In Tests
npm run test # run onceTests live in test/**/*.test.mjs and are discovered automatically by Vitest.
The AUNT Data Packet (out.dat)
AUnit_WriteResults writes a binary packet to out.dat in the current GS/OS prefix (the directory containing the executable). The format is:
File Header (8 bytes)
| Offset | Size | Value | Description |
|--------|------|-------|-------------|
| 0..3 | 4 | AUNT | Magic bytes (ASCII) |
| 4 | 1 | 1 | Format version |
| 5 | 1 | 0 | Status: 0 = success, non-zero = harness error code |
| 6..7 | 2 | N | Record count (little-endian 16-bit) |
Records
Each record immediately follows the header (or the previous record):
tag(1) payloadLen(2-LE16) payload[payloadLen]Three record types are defined:
R — Register Snapshot (payload = 16 bytes)
Captures the CPU register state as it existed when the function under test returned. The caller must execute PHP immediately after the tested function's RTL, before any other instruction that could alter registers.
| Field | Size | Description |
|-------|------|-------------|
| A | 2 | Accumulator |
| X | 2 | X index |
| Y | 2 | Y index |
| P | 2 | Processor status (low byte = real P, high byte = 0) |
| DP | 2 | Direct page register |
| SP | 2 | Stack pointer (as it was before the PHP) |
| DBR | 2 | Data bank register (low byte = real DBR, high byte = 0) |
| K | 2 | Program bank register (low byte = real K, high byte = 0) |
All fields are 16-bit little-endian. The low byte of P, DBR, and K holds the actual 8-bit register value.
M — Memory Snapshot (payload = 5 + N bytes)
Captures a contiguous range of memory.
| Field | Size | Description | |--------|------|-------------| | bank | 1 | Source bank byte | | addrLo | 2 | Source address low word (little-endian) | | length | 2 | Byte count (little-endian) | | data | N | Raw bytes |
V — Named 16-bit Value (payload = 1 + nameLen + 2 bytes)
Associates a name string with a 16-bit value for convenient assertion in JS.
| Field | Size | Description | |---------|---------|-------------| | nameLen | 1 | Length of name string in bytes | | name | nameLen | Name bytes (not null-terminated, not length-prefixed) | | value | 2 | 16-bit value (little-endian) |
cpu65816 — Primary API for Generated Tests
cpu65816 is the recommended entry point for parametric tests. It captures shared configuration once and returns jsl, jsr, and sequence caller functions. Each jsl/jsr call generates a complete test harness in an OS temp directory, assembles it, runs it, and returns a result object. sequence builds a multi-step harness from a fluent chain of calls (see Sequences).
import { describe, test, expect } from 'vitest';
import { join } from 'node:path';
import { cpu65816 } from 'iigs-unit';
const SRC = join(process.env.SRC_ROOT, 'misc/App.Msg.s');
describe('ByteToString', () => {
const { jsr } = cpu65816({
includes: [SRC],
assembler: 'merlin32',
});
test('converts 0x00 to "00"', async () => {
const r = await jsr('ByteToString', {
A: 0,
Y: 'buf',
allocMemory: [{ label: 'buf', length: 2 }],
});
expect(r.memory.buf.toString()).toBe('00');
});
test('converts 0xAB to "AB"', async () => {
const r = await jsr('ByteToString', {
A: 0xAB,
Y: 'buf',
allocMemory: [{ label: 'buf', length: 2 }],
});
expect(r.memory.buf.toString()).toBe('AB');
});
});cpu65816 shared config
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| assembler | string | 'orca' | 'orca' or 'merlin32'. |
| includes | Array | [] | Source files to include. Each entry is a string (absolute path) or { src: string, placement?, order? } (path with explicit positioning). |
| inline | Array | [] | Inline assembly snippets. Each entry is a string (literal source) or { src: string, placement?, order? }. |
| mocks | object | {} | Mock stubs for external functions called by the function under test. Keys are assembly label names; values are mock configs (see Mocking). |
| keepArtifacts | boolean | false | Keep the temp directory after the run (logs its path). |
| trace | boolean | false | Pass --trace-gsos to iix. |
Per-call config (jsl / jsr)
Per-call config also accepts a mocks key that is merged on top of the builder-level mocks (per-call entries override by name):
const r = await jsl('MyFunc', {
mocks: { 'ExtFunc': { returns: { A: 0x00FF } } }, // overrides shared mock
...
});Full per-call config reference:
const r = await jsl('MyFunc', {
// --- register inputs ---
A: 0x1234, // 16-bit accumulator
X: 0xABCD, // 16-bit X
Y: 'myBuf', // string → load address-of-label (ldy #myBuf)
DP: 0x0200, // direct page
SP: 0x01FF, // stack pointer
DBR: 0x02, // data bank register (8-bit)
P: 0x30, // processor status (8-bit)
// --- per-call mock overrides (merged on top of builder-level mocks) ---
mocks: { 'ExtFunc': { returns: { A: 0x00FF } } },
// --- per-call inline assembly (merged with builder-level inline) ---
inline: [`
MockHelper start
rtl
end
`],
// --- existing memory labels to write before the call ---
memory: [
{ label: 'mydata', data: [0x00, 0x08, 0x88] },
{ label: 'mydata', offset: 4, data: Buffer.from([0xFF]) },
],
// --- memory to snapshot after the call ---
captureMemory: [
{ label: 'mydata', length: 8 }, // → raw Buffer
{ label: 'counter', as: 'word' }, // → number
{ label: 'table', as: 'byte', count: 4 }, // → number[]
{ as: 'shr' }, // → raw 32KB Buffer in r.memory
{ as: 'shr', png: true }, // → raw Buffer + PNG in r.images
{ as: 'shr', png: './screen.png' }, // → also writes PNG file to disk
],
// --- allocate labeled storage in the harness (auto-captured) ---
allocMemory: [
{ label: 'outBuf', length: 16 }, // → raw Buffer (supports .toString())
{ label: 'result', as: 'word' }, // → number
],
});Registers. Only registers whose value is explicitly provided are initialised; unspecified registers retain whatever state AUnit_Init leaves them in. A string value is treated as an assembly label — the harness emits lda #label (loads the 16-bit address of the label).
memory. Each entry copies bytes into the named address via MVN before the call. data may be a Buffer, a number[] of byte values, or a value built with the mem helper (see below).
captureMemory. Each entry snapshots a memory region after the call. Provide either length (raw Buffer returned) or as + optional count (typed JS number or number[] returned). Captured results are in r.memory keyed by label.
The special type as: 'shr' captures the full 32 KB Apple IIgs Super Hi-Res screen buffer starting at $E1/2000 (or a custom address). The raw buffer is always returned in r.memory. Add png: true to also decode the buffer to a PNG and make it available in r.images under the same key. Pass a file path string instead of true to additionally write the PNG to disk.
| as value | Returned in r.memory[key] | r.images[key] |
|---|---|---|
| 'shr' | raw 32 KB Buffer | not set |
| 'shr' + png: true | raw 32 KB Buffer | PNG Buffer |
| 'shr' + png: './out.png' | raw 32 KB Buffer | PNG Buffer (file also written) |
The key in both r.memory and r.images is the label if one is supplied, or the 24-bit address as a lowercase hex string (e.g. 'e12000') if only address is used or the default SHR address is implied.
allocMemory. Declares a ds N label in the generated harness (zeroed storage), which the function under test can write to. Each entry is automatically appended to the capture list, so results appear in r.memory keyed by label alongside any captureMemory entries. The same as/count vs length rules apply.
inline (shared and per-call). Supplies assembly source code as a string directly in the test file rather than on disk. Useful when the function under test needs a small helper, a mock callback, or a lookup table that is too complex for memory but too trivial to warrant a separate .s file.
Snippets are combined with any object-form entries in includes and sorted into a single ordered include list before assembly. Each entry is:
string— literal assembly source, placed at the head of thebeforegroup by default.{ src, placement?, order? }— literal assembly source with explicit control:
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| src | string | required | Literal assembly source code. |
| placement | 'before' | 'after' | 'before' | Whether the snippet is placed before or after the includes file list. |
| order | number | — | Sort key within the placement group. Omitting it puts 'before' snippets at the head and 'after' snippets at the tail; explicit values interleave them with any ordered file entries. |
Entries in includes follow the same object form, except src is an absolute file path rather than literal source. This lets you give a real .s file an explicit placement or order alongside inline snippets.
Final include order (for both assemblers):
[ 'before' entries, sorted by order (unordered → head) ]
[ 'after' entries, sorted by order (unordered → tail) ]
aunit.s / aunit.merlin.s
io.s / io.merlin.sBuilder-level inline items are merged with per-call inline items before sorting; items from the builder appear first within each placement group when order values are equal.
ORCA/M snippets must contain complete segment(s) with start/end wrappers, since each copy'd file is assembled as a separate OMF segment:
const { jsl } = cpu65816({
inline: [`
MockCallback start
lda #$0001 ; return value
rtl
end
`],
});Merlin32 snippets are joined into the enclosing segment via put and must not include segment markers (rel, start, end):
const { jsl } = cpu65816({
inline: [`
MockCallback
lda #$0001
rtl
`],
assembler: 'merlin32',
});cpu65816 result object
const r = await jsl('MyFunc', { A: 0x10 });
r.A // number — accumulator after return
r.X // number — X after return
r.Y // number — Y after return
r.P // number — processor status (low byte)
r.DP // number — direct page register
r.SP // number — stack pointer
r.DBR // number — data bank register (low byte)
r.K // number — program bank register (low byte)
r.memory // object — { label: value } for captureMemory + allocMemory
// value is Buffer when no 'as', number when as+count===1,
// number[] when as+count>1, raw 32KB Buffer when as:'shr'
r.images // object — { label: PngBuffer } for every 'shr' spec that included
// png: true or png: '<path>'. Only present when at least one PNG
// was generated; omitted entirely otherwise.
r.values // object — { name: value } from V records written by the assembly
r.mocks // object — { label: CallRecord[] } for every mock declared with record: true
// Each CallRecord has { A, X, Y, P, DP, SP, DBR, K } — the register values
// as they were at the moment the mock was entered.
// Always present (empty object {} when no recording mocks were declared).
r.raw // Buffer — the complete out.dat binaryIf the harness exits with a non-zero status byte, jsl/jsr throws an AssemblyError. Check r.ok is not available on the cpu65816 result — use the thrown error instead.
mem — Typed Memory Fixture Builder
iigs-unit/mem provides assembler-directive-style helpers for building memory fixtures with correct endianness:
import { mem } from 'iigs-unit/mem';
const r = await jsl('MyFunc', {
memory: [{
label: 'palette',
data: Buffer.concat([
mem.db(0x01), // 1 byte
mem.dw(0x0888), // 16-bit little-endian
mem.dl(0x7E0000), // 24-bit little-endian (bank pointer)
mem.asc('hello'), // ASCII string
]),
}],
});| Method | Width | Endian | Notes |
|--------|-------|--------|-------|
| mem.db(v, ...) | 8-bit | — | One byte per value |
| mem.dw(v, ...) | 16-bit | LE | |
| mem.dl(v, ...) | 24-bit | LE | 65816 bank pointer format |
| mem.dd(v, ...) | 32-bit | LE | |
| mem.asc(str) | — | ASCII | No length prefix |
| mem.asciiz(str) | — | ASCII | NUL-terminated |
All methods accept a single value, variadic values, or an array, and return a Buffer.
Mocking External Functions
When the function under test calls external subroutines (GS/OS toolbox calls, library functions, or other modules), those symbols must be resolved at link time. The mocks config replaces them with generated stubs that return fixed values and, optionally, record every call's incoming register state.
Mocks are declared in the mocks field of either the cpu65816 builder config (shared across all calls in the describe block) or in the per-call config (overrides the shared value for that one invocation).
Basic stub — return a fixed value
const { jsl } = cpu65816({
includes: [SRC],
assembler: 'merlin32',
mocks: {
'ExtFunc': { returns: { A: 0x0042 } },
},
});
const r = await jsl('MyFunc');
expect(r.A).toBe(0x0042); // whatever ExtFunc would have returnedShorthand: a plain number is equivalent to { returns: { A: N } }:
mocks: { 'ExtFunc': 0x0042 }Mock config reference
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| returns.A | number | — | Value loaded into A before the stub returns. |
| returns.X | number | — | Value loaded into X before the stub returns. |
| returns.Y | number | — | Value loaded into Y before the stub returns. |
| returns.carry | boolean | — | true sets the carry flag; false clears it. Omit to leave the flag unchanged (only relevant when record is false; recording stubs restore the caller's P via plp). |
| record | boolean | false | Capture incoming registers on every invocation. Results appear in r.mocks[label]. |
| callType | 'jsl' | 'jsr' | 'jsl' | How the function under test calls the mock. Must match the actual call instruction in your assembly code. |
Unspecified returns fields are passed through from the caller unchanged (for non-recording stubs). For recording stubs, A and X are explicitly restored if not overridden, because AUnit_RecordCall clobbers those registers.
Carry flag example
mocks: {
'ReadByte': { returns: { A: 0x0041, carry: false } }, // success
'TryOpen': { returns: { A: 0x0001, carry: true } }, // error path
}Recording calls
Add record: true to capture the register state at each invocation. Results appear in r.mocks as an array of { A, X, Y, P, DP, SP, DBR, K } objects, one per call, in order.
const { jsl } = cpu65816({
includes: [SRC],
mocks: { 'ExtFunc': { returns: { A: 0x0007 }, record: true } },
});
const r = await jsl('MockTwice'); // MockTwice calls ExtFunc twice
expect(r.mocks.ExtFunc).toHaveLength(2);
expect(r.mocks.ExtFunc[0].A).toBe(0x0000); // first call: A was 0
expect(r.mocks.ExtFunc[1].A).toBe(0x0001); // second call: A was 1
expect(r.A).toBe(0x0007); // return value from the last callWhen record: false (the default), r.mocks is an empty object {}.
Per-call mock override
The shared mock is used for every jsl/jsr call in the block. A per-call mocks entry replaces it for that one invocation only:
const { jsl } = cpu65816({
includes: [SRC],
mocks: { 'ExtFunc': { returns: { A: 0x0001 } } },
});
const r1 = await jsl('MyFunc'); // A = 0x0001
const r2 = await jsl('MyFunc', {
mocks: { 'ExtFunc': { returns: { A: 0x00FF } } }, // A = 0x00FF
});Multiple mocks
Any number of functions can be mocked simultaneously. Each key in the mocks object generates one stub:
mocks: {
'OpenFile': { returns: { A: 0x0000 } }, // success
'ReadBlock': { returns: { A: 0x0080 }, record: true },
'CloseFile': { returns: { A: 0x0000 } },
}ORCA/M vs Merlin32
The stub code is generated in the correct syntax for the selected assembler automatically. ORCA/M stubs are generated as standalone start/end segments placed in the after group (so they never shadow real definitions in your includes files). Merlin32 stubs are placed inline in the same segment.
No changes are needed in your assembly source files — mocks are pure test infrastructure injected at harness generation time.
Sequences — Multi-Step Harnesses
jsl and jsr each generate, assemble, and run a complete harness for one function call. That covers most tests, but some correctness properties only emerge across a chain of calls: a write followed by a read, a state machine stepped through several transitions, a hardware register bank that must be initialised in order.
Before sequences, the only option for multi-call tests was a hand-written .s harness passed to runGeneratedTest({ harnessFile }). Sequences eliminate that overhead — the entire test stays in JavaScript, and mocks, allocMemory, and captureMemory all work identically to single-call mode.
Quickstart
cpu65816() returns a sequence function alongside jsl and jsr:
import { describe, test, expect } from 'vitest';
import { join } from 'node:path';
import { cpu65816 } from 'iigs-unit';
const SRC = join(process.env.SRC_ROOT, 'ppu/ppu_regs.s');
describe('PPU write-then-read', () => {
const { sequence } = cpu65816({ includes: [SRC], assembler: 'merlin32' });
test('two PPUDATA_WRITEs are visible on PPUDATA_READ', async () => {
const r = await sequence({
allocMemory: [{ label: 'readBuf', length: 2 }],
})
.jsl('PPUADDR_WRITE', { A: 0x20, mx: 3 }) // latch high byte of address
.jsl('PPUADDR_WRITE', { A: 0x00 }) // latch low byte
.jsl('PPUDATA_WRITE', { A: 0x42 }) // write $42
.jsl('PPUADDR_WRITE', { A: 0x20 }) // reset address
.jsl('PPUADDR_WRITE', { A: 0x00 })
.jsl('PPUDATA_READ')
.inline(' sta readBuf+0') // save first result
.jsl('PPUDATA_READ')
.inline(' sta readBuf+1') // save second result
.run();
expect(r.memory.readBuf.readUInt8(0)).toBe(0x42);
expect(r.memory.readBuf.readUInt8(1)).toBe(0x00);
});
});All steps share a single assembled harness and a single GoldenGate process. In-memory state written by one step — hardware registers, shared variables, anything in the address space — is visible to every subsequent step, exactly as it would be at runtime.
sequence() config
sequence(seqConfig?) is called on the cpu65816 object. All fields are optional:
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| includes | Array | [] | Additional source files for this sequence, appended after the shared cpu65816 includes. |
| mocks | object | {} | Mock overrides for this sequence, merged on top of the shared cpu65816 mocks. Applied to the entire harness — no per-step override is possible. |
| allocMemory | Array | [] | Labeled storage (ds N) placed in the data area after RTL and auto-captured into r.memory. Same shape as per-call allocMemory. |
Builder methods
.jsl(label, stepConfig?) / .jsr(label, stepConfig?)
Appends a function call step. stepConfig accepts the same register fields as single-call jsl/jsr — A, X, Y, DP, DBR, SP, P — plus one new field:
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| mx | 0 | 1 | 2 | 3 | inherited | Processor mode to be active when the call executes. Bit 1 = M flag (0 = 16-bit A, 1 = 8-bit A); bit 0 = X flag (0 = 16-bit X/Y, 1 = 8-bit X/Y). The builder emits the minimal sep/rep pair to reach the requested mode. |
Register setup (when any register value is specified) always runs in 16-bit mode so that immediate sizes are predictable, then the mx transition is applied before the call:
rep #$30 ; widen for safe register loads
lda #$0042 ; 16-bit immediate
sep #$30 ; switch to 8-bit before the call (mx: 3)
jsl PPUDATA_WRITEIf mx is not specified on a step, the mode set by the previous step carries forward. The sequence starts in 16-bit mode (mx = 0).
.inline(src, opts?)
Emits src verbatim into the execution stream at the current position in the chain. Use it to save a return value, mask bits, or perform any one-liner that does not warrant a separate function call:
.jsl('ReadByte')
.inline(' sta resultBuf,x')
.inline(' inx')src may be a multiline string — each \n-separated line is emitted as its own source line.
opts.mx (optional) declares the mode the snippet exits in. Set it when the snippet itself contains a sep/rep instruction, so the builder knows the correct mode for any mx transition it needs to emit before the next step.
.captureMemory(spec)
Appends a memory capture that runs after all steps complete. Accepts the same spec shape as single-call captureMemory (label or address, length or as+count, png for SHR captures). All .captureMemory() calls are collected and emitted in order, after the final register capture and before AUnit_WriteResults.
Multiple captures can be chained:
sequence()
.jsl('Render')
.captureMemory({ as: 'shr', png: true }) // full screen
.captureMemory({ label: 'spriteTable', length: 32 }) // sprite data
.run();.run()
Finalises the builder, generates the harness, assembles it, runs it, and returns a Promise that resolves to the same result shape as jsl/jsr:
r.A // accumulator after the last step
r.X
r.Y
r.P
// ... other registers ...
r.memory // { label: value } for captureMemory + allocMemory
r.mocks // { label: CallRecord[] } for recording mocks (record: true)
r.values // { name: value } from AUnit_AppendValue V records
r.raw // complete out.dat BufferRegisters reflect the state at the end of the sequence (after the last .jsl(), .jsr(), or .inline() step). There is no per-step register history; save intermediate values using .inline() and allocMemory labels if they are needed.
run() throws AssemblyError on assembly or harness failure, matching single-call behaviour.
mx mode in detail
The mx value encodes the 65816 M and X processor status bits directly, matching Merlin32's mx %NN directive:
| mx | Merlin32 | A width | X/Y width |
|------|----------|---------|-----------|
| 0 | %00 | 16-bit | 16-bit |
| 1 | %01 | 16-bit | 8-bit |
| 2 | %10 | 8-bit | 16-bit |
| 3 | %11 | 8-bit | 8-bit |
The builder emits only the bits that change. Going from mx: 3 to mx: 0 emits rep #$30; going from mx: 0 to mx: 2 (8-bit A, 16-bit X) emits sep #$20. A step with no mx key inherits the mode from the preceding step, so you only need to specify mx once at the start of a run of same-mode calls:
sequence()
.jsl('Init', { mx: 3 }) // sep #$30 emitted here
.jsl('WriteControl', { A: 0x80 }) // mode still 3; no sep/rep
.jsl('WriteControl', { A: 0x00 }) // mode still 3; no sep/rep
.jsl('ReadStatus', { mx: 0 }) // rep #$30 emitted here
.run();Sequences with mocks
Mocks declared on cpu65816() are automatically available in sequences. A sequence-level mocks entry (in sequence({ mocks: {...} })) overrides the shared value for that function across the entire harness:
const { sequence } = cpu65816({
includes: [SRC],
mocks: { 'GetTick': { returns: { A: 0x0000 } } },
});
// override GetTick for this one test
const r = await sequence({
mocks: { 'GetTick': { returns: { A: 0x0100 } } },
})
.jsl('WaitForVBlank')
.jsl('ReadStatus')
.run();Recording mocks (record: true) accumulate call history across all steps in the sequence. r.mocks.GetTick is a flat array of every invocation in call order:
const r = await sequence({
mocks: { 'GetTick': { returns: { A: 0x0001 }, record: true } },
})
.jsl('PollTwice') // calls GetTick twice
.run();
expect(r.mocks.GetTick).toHaveLength(2);
expect(r.mocks.GetTick[0].A).toBe(0x0000); // A on first entry
expect(r.mocks.GetTick[1].A).toBe(0x0000); // A on second entryPer-step mock overrides (different return values per call within a sequence) are not supported — mock stubs are static assembly generated once for the whole harness. Use an .inline() snippet with explicit dispatch logic if per-invocation varying returns are required.
allocMemory in sequences
allocMemory declared in sequence() generates ds N labels in the harness data section (after RTL, outside the execution path) and auto-captures them into r.memory:
const r = await sequence({
allocMemory: [
{ label: 'out0', as: 'byte' },
{ label: 'out1', as: 'byte' },
],
})
.jsl('ReadByte')
.inline(' sep #$20')
.inline(' sta out0', { mx: 3 })
.jsl('ReadByte')
.inline(' sta out1', { mx: 3 })
.run();
expect(r.memory.out0).toBe(0x42);
expect(r.memory.out1).toBe(0x43);Labels declared in allocMemory are also visible to .inline() snippets throughout the sequence, since they are part of the same segment.
ORCA/M vs Merlin32
Sequences work with both assemblers. The generated harness syntax differs (copy/start/end for ORCA/M, put/rel for Merlin32), but the JS API is identical. Select the assembler via the shared cpu65816 config:
const { sequence } = cpu65816({ includes: [SRC], assembler: 'orca' }); // ORCA/M
const { sequence } = cpu65816({ includes: [SRC], assembler: 'merlin32' }); // Merlin32ORCA/M inline snippets that define labeled storage must use start/end wrappers and cannot be used inside a sequence step directly — use allocMemory or a file in includes instead. Merlin32 inline snippets share the enclosing segment and can reference any label in scope.
runGeneratedTest — Lower-level API
cpu65816 is built on top of runGeneratedTest, which you can call directly when you need the raw result object (with result.registers, result.memory[], etc.) rather than the flattened cpu65816 shape.
Auto-generated harness
import { runGeneratedTest } from 'iigs-unit';
const result = await runGeneratedTest({
assembler: 'merlin32',
call: 'MyFunction',
includes: ['/absolute/path/to/MyFunction.s'],
registers: { A: 0x0016 },
captureMemory: [{ label: 'output', length: 4 }],
});
expect(result.ok).toBe(true);
expect(result.registers.A).toBe(0x0F00);
expect(result.memory[0].data).toEqual(Buffer.from([...]));Custom harness file
When you need full control over the harness — to inject hardware setup, call multiple functions, or use unconventional calling conventions — supply a harnessFile instead of call. The library appends the necessary copy/put directives for aunit.s and io.s automatically; your file does not need to reference them.
import { runGeneratedTest } from 'iigs-unit';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const result = await runGeneratedTest({
harnessFile: join(__dirname, 'Main.s'),
});
expect(result.ok).toBe(true);
expect(result.registers.A).toBe(0xABCD);If your harness file calls functions defined in separate .s files, list them in includes — they are appended before the lib files:
const result = await runGeneratedTest({
harnessFile: join(__dirname, 'Main.s'),
includes: [join(__dirname, 'helpers.s')],
assembler: 'merlin32',
});Config reference
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| assembler | string | 'orca' | 'orca' or 'merlin32'. |
| call | string | — | Assembly label of the function under test. Required unless harnessFile is set. |
| harnessFile | string | — | Absolute path to a custom hand-written harness .s file. Mutually exclusive with call. The library appends lib includes automatically. |
| includes | Array | [] | Source files to include. Each entry is a string (absolute path) or { src: string, placement?, order? } (path with explicit positioning). |
| inline | Array | [] | Inline assembly snippets merged into the source list alongside includes. Each entry is a string (literal source) or { src: string, placement?, order? }. See the inline section above for syntax requirements per assembler. |
| callMode | string | 'jsl' | 'jsl' or 'jsr'. Ignored when harnessFile is set. |
| registers | object | {} | Initial register values. Ignored when harnessFile is set. |
| memory | object[] | [] | Regions to pre-populate before the call. Ignored when harnessFile is set. |
| captureMemory | object[] | [] | Regions to snapshot after the call. Ignored when harnessFile is set. |
| allocMemory | object[] | [] | Labels to allocate (ds N) in the harness. Ignored when harnessFile is set. |
runGeneratedTest result object
result.ok // boolean — true if status byte is 0
result.status // number — raw status byte
result.registers // object — { A, X, Y, P, DP, SP, DBR, K }
result.memory // array — [{ bank, address, data: Buffer }, ...]
result.values // object — { name: value } from V records
result.raw // Buffer — complete out.dat binaryHow the generated harness works
ORCA/M skeleton
* Auto-generated AUnit harness — do not edit
org $020000
Main start
clc
xce ; native mode
rep #$30 ; 16-bit A and X/Y
longa on
longi on
phk
plb ; DBR = program bank ($02)
jsl AUnit_Init
* --- register setup ---
lda #$0016 ; (only registers with explicit values appear)
ldy #myBuf ; string value → address of label
* --- call ---
jsl MyFunction
* --- capture registers ---
php
phk
plb
rep #$30
longa on
longi on
jsl AUnit_CaptureRegs
* --- capture memory 0: output (4 bytes) ---
lda #output
ldx #4
jsl AUnit_AppendMem
jsl AUnit_WriteResults
rtl
myBuf ds 16 ; allocMemory label
end
copy C:/absolute/path/to/MyFunction.s ; includes / inline 'before' entries
* copy <inline 'after' entries would appear here>
copy C:/absolute/path/to/aunit/src/lib/aunit.s
copy C:/absolute/path/to/aunit/src/lib/io.sMerlin32 skeleton
* Auto-generated AUnit harness — do not edit
rel ; relocatable OMF segment
mx %00 ; 16-bit A and X/Y
Main
clc
xce ; native mode
rep #$30
phk
plb ; DBR = program bank ($02)
jsl AUnit_Init
* --- register setup ---
lda #$0016
ldy #myBuf
* --- call ---
jsl MyFunction
* --- capture registers ---
php
phk
plb
rep #$30
jsl AUnit_CaptureRegs
* --- capture memory 0: output (4 bytes) ---
lda #output
ldx #4
jsl AUnit_AppendMem
jsl AUnit_WriteResults
rtl
myBuf ds 16 ; allocMemory label
put ../../path/to/MyFunction.s ; includes / inline 'before' entries
* put <inline 'after' entries would appear here>
put ../../path/to/aunit/src/lib/aunit.merlin.s
put ../../path/to/aunit/src/lib/io.merlin.sput paths are forward-slash relative paths from the temp directory. Absolute Windows paths cannot be used because Merlin32 expects ProDOS-style paths, which treats drive letters as plain directory names. See docs/adr-003-merlin32-path-handling.md and docs/adr-004-include-path-normalization.md for the full rationale.
Common points (both assemblers)
- Only registers that have explicit values in the config are initialised; the rest are untouched.
PHPimmediately afterJSL MyFunctioncaptures P before any other instruction can alter it.PHK; PLBrestores DBR=$02 without touching P on the stack.allocMemorylabels (ds N) are emitted in the data area afterRTL, inside the segment, so their addresses are visible throughout the harness.- All artifacts are written to a single OS temp directory (
mkdtempprefixau) and deleted on completion.
Writing a Custom ORCA/M Harness
Use runGeneratedTest({ harnessFile }) when you need full control over the harness. Write only the harness logic — do not include the AUnit library files; the runner appends them automatically.
*--------------------------------------------------------------
* tests/my_subsystem/my_test/Main.s
*--------------------------------------------------------------
org $020000 ; GoldenGate loads OMF at bank $02
Main start
clc
xce ; switch to 65816 native mode
rep #$30 ; 16-bit A and X/Y
longa on
longi on
phk
plb ; DBR = program bank ($02)
jsl AUnit_Init ; stamp header, reset write index
* Set up inputs and call the function under test.
lda #$1234 ; <-- YOUR inputs
jsl MyFunction ; <-- YOUR function under test
* Capture registers IMMEDIATELY after return.
php
rep #$30
longa on
longi on
jsl AUnit_CaptureRegs ; appends 'R' record; restores A/X/Y
* Optionally record named values.
ldx #ResultName ; address of name string (in program bank)
ldy #ResultNameLen ; length in bytes
jsl AUnit_AppendValue ; appends 'V' record; A = value to record
* Optionally snapshot a memory range.
* lda #MyBuffer ; low word of source address (program bank)
* ldx #BufferSize ; byte count
* jsl AUnit_AppendMem ; appends 'M' record
jsl AUnit_WriteResults ; write out.dat
rtl
ResultName dc c'myResult'
ResultNameLen equ *-ResultName
end
* No copy directives needed — the runner appends aunit.s and io.s.
* If MyFunction lives in a separate file, pass it via config.includes instead.Rules for the ORCA/M Harness
PHP placement. Capture P immediately after the tested function's RTL, with no intervening instructions:
jsl MyFunction ; function returns here
php ; save P — MUST be the very next instruction
rep #$30 ; expand to 16-bit before JSL
longa on
longi on
jsl AUnit_CaptureRegsAUnit_AppendValue calling convention (16-bit mode, DBR = program bank):
- A = the 16-bit value to record
- X = address of name string
- Y = name length in bytes
AUnit_AppendMem calling convention (16-bit mode, DBR = program bank):
- A = low word of source address (data must be in the program bank, $02)
- X = byte count
AUnit_Fail — call instead of AUnit_WriteResults when the harness detects a setup error. Pass the error code in A (1–255).
Writing a Custom Merlin32 Harness
A Merlin32 custom harness follows the same pattern — write only the harness logic and omit the AUnit library put directives. The runner appends them automatically.
*--------------------------------------------------------------
* tests/my_subsystem/my_test/Main.s (Merlin32 master source)
*--------------------------------------------------------------
rel ; relocatable OMF segment
mx %00 ; 16-bit A and X/Y
Main
clc
xce ; 65816 native mode
rep #$30
phk
plb ; DBR = program bank ($02)
jsl AUnit_Init
lda #$1234 ; <-- YOUR inputs
jsl MyFunction ; <-- YOUR function
php
rep #$30
jsl AUnit_CaptureRegs
jsl AUnit_WriteResults
rtl
* No put directives needed — the runner appends aunit.merlin.s and io.merlin.s.
* If MyFunction lives in a separate file, pass it via config.includes instead.Key structural rules:
relappears once, at the top of the master source file. Do not putrelin included files.- Labels do not need
entrydeclarations (there is only one segment). - Use
putfor source includes; useusefor Merlin32 macro files (.Macs.s).
Merlin32 Assembler Notes
1. No start / end / entry wrappers
Files included via put are raw code with no segment markers. entry and extern are for multi-segment programs; a single-segment test harness never needs them.
2. Bare labels are valid
MyBranchTarget ; valid — no anop needed
lda #$003. Data directives differ from ORCA/M
| ORCA/M | Merlin32 | Notes |
|--------|----------|-------|
| dc h'AABBCC' | hex AABBCC | raw hex bytes |
| dc i2'$1234' | dw $1234 | 16-bit little-endian word |
| dc i4'label' | adrl label | 4-byte little-endian address |
| dc c'text' | asc 'text' | ASCII string, no length prefix |
| ds N | ds N | N bytes of zeroed storage |
4. GS/OS inline calls use dw + adrl
jsl $E100A8 ; GS/OS dispatcher
dw $2010 ; function code (2 bytes)
adrl myParamBlock ; parameter block pointer (4 bytes)5. ProDOS path constraints (generated harnesses only)
Merlin32 expected to use ProDOS-style paths, which does not recognise Windows drive letters as absolute path roots. The runner works around this by using forward-slash relative paths from the temp directory for all put directives. Custom harnesses are unaffected — the lib put directives are injected by the runner. See docs/adr-003-merlin32-path-handling.md for details.
ORCA/M Assembler Gotchas
1. GoldenGate loads OMF at bank $02, not $03
Use phk; plb (not a hardcoded bank constant) to set DBR at harness startup.
2. Full-line comments require * in column 1
ORCA/M only allows ; for end-of-line comments. Use * as the first character for standalone comment lines.
3. Every labeled line must have an operation
Use anop for label-only lines:
MyLabel anopTo export a label as a linkable entry point:
MyFunction entry
rep #$30
rtl4. GoldenGate exits with 0xFFFFFFFE on normal program end
When a program terminates by RTLing into GoldenGate's launcher, iix exits with code 4294967294 (0xFFFFFFFE). The runner treats this as a successful run and checks for out.dat.
Directory Layout
iigs-unit/
src/
lib/
aunit.s ORCA/M: AUnit runtime (Init, CaptureRegs, AppendMem,
AppendValue, WriteResults, Fail)
io.s ORCA/M: GS/OS loaddata / savedata helpers
aunit.merlin.s Merlin32: AUnit runtime (same API, Merlin32 syntax)
io.merlin.s Merlin32: GS/OS loaddata / savedata helpers
runner.mjs Node.js: assemble → run → parse out.dat
Exports: runGeneratedTest, cpu65816, AssemblyError
parser.mjs Parse AUNT binary packet into JS result object
mem.mjs Typed memory fixture builder (db, dw, dl, dd, asc, asciiz)
shr2png.mjs Convert 32 KB SHR screen dump to PNG (optional helper)
docs/
adr-001-cpu65816-builder-pattern.md
adr-002-mem-typed-buffer-builder.md
adr-003-merlin32-path-handling.md
adr-004-include-path-normalization.md
adr-005-mocking.md
adr-006-sequences.md
test/
orca/ ORCA/M tests (generated + custom harness + mock + sequence tests)
merlin32/ Merlin32 tests (generated + custom harness + mock + sequence tests)