npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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 / fail

Merlin32 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
  1. The assembly harness calls AUnit_Init, exercises the function under test, records results with AUnit_CaptureRegs / AUnit_AppendValue / AUnit_AppendMem, then calls AUnit_WriteResults to flush an out.dat file via GS/OS.
  2. The JS test calls cpu65816(sharedConfig) to get a bound jsl/jsr caller, or runGeneratedTest({ harnessFile }) for hand-written harnesses.
  3. 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.exe

Both 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 once

Tests 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 the before group 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.s

Builder-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 binary

If 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 returned

Shorthand: 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 call

When 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/jsrA, 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_WRITE

If 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 Buffer

Registers 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 entry

Per-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' }); // Merlin32

ORCA/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 binary

How 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.s

Merlin32 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.s

put 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.
  • PHP immediately after JSL MyFunction captures P before any other instruction can alter it. PHK; PLB restores DBR=$02 without touching P on the stack.
  • allocMemory labels (ds N) are emitted in the data area after RTL, inside the segment, so their addresses are visible throughout the harness.
  • All artifacts are written to a single OS temp directory (mkdtemp prefix au) 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_CaptureRegs

AUnit_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:

  • rel appears once, at the top of the master source file. Do not put rel in included files.
  • Labels do not need entry declarations (there is only one segment).
  • Use put for source includes; use use for 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   #$00

3. 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   anop

To export a label as a linkable entry point:

MyFunction   entry
             rep   #$30
             rtl

4. 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)