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

z80-testing-library

v0.7.0

Published

Headless Z80 unit testing library with pluggable platform adapters

Readme

z80-testing-library

Headless unit testing library for Z80 programs. Loads your ROM and symbols into a Z80 emulator, runs individual functions, and lets you assert on registers, memory, and peripheral state.

Built with a ports & adapters architecture — the core is platform-agnostic, and platform-specific behavior (MSX, ColecoVision, ZX Spectrum, etc.) is provided through pluggable adapters.

Install

npm install z80-testing-library

Quick Start (MSX)

Build your MSX project with SDCC (producing a .rom and .noi file), then write tests with any test runner (vitest, jest, mocha, etc.):

import { readFileSync } from 'fs'
import { createMsxTestbed, SdccSymbols, ffi } from 'z80-testing-library'

const symbols = SdccSymbols.fromFiles('path/to/game.noi', 'path/to/build')
const { machine: m, vdp, keyboard } = createMsxTestbed({
  rom: new Uint8Array(readFileSync('path/to/game.rom')),
})

// Define a function binding — signature declared once, fully typed
const myFunctionSchema = ffi.fn(symbols.get('my_function'), ['u8'], 'u8')
const myFunction = myFunctionSchema.bind(m)
expect(myFunction(42)).toBe(expectedResult)

// Or use ffi.call for one-off calls without pre-declaring the signature
const result = ffi.call(m, symbols.get('my_function'), { args: [42], ret: 'u8' })
expect(result.value).toBe(expectedResult)

// Or use the low-level API for full register control
m.regs.a = 42
m.runFrom(symbols.get('my_function'))
expect(m.regs.a).toBe(expectedResult)

Quick Start (ZX Spectrum)

Build your program with z88dk (zcc +zx -clib=sdcc_iy -m -create-app …) — you get hello.tap and hello.map. Load both into the Spectrum testbed:

import { readFileSync } from 'fs'
import {
  createSpectrumTestbed, extractCodeFromTap, Z88dkSymbols,
  sdcccall0, ffi,
} from 'z80-testing-library'

const { code, loadAddress } = extractCodeFromTap(new Uint8Array(readFileSync('hello.tap')))
const symbols = Z88dkSymbols.fromFile('hello.map')
const { machine: m } = createSpectrumTestbed({ regions: [[loadAddress, code]] })

// z88dk's sdcc_iy clib uses __sdcccall(0): all args on stack, return in L/HL.
const clampAdd = ffi.fn(symbols.get('clamp_add'), ['u8', 'u8'], 'u8', {
  cc: sdcccall0(),
}).bind(m)
expect(clampAdd(200, 100)).toBe(255)

Quick Start (Bare Z80)

For platform-independent Z80 testing (no MSX assumptions):

import { Z80TestMachine } from 'z80-testing-library'

const m = new Z80TestMachine({
  regions: [[0x100, myCode]],   // load code at address 0x100
})
m.runFrom(0x100)
expect(m.regs.a).toBe(42)

Architecture

src/
  core/
    types.ts              Port interfaces: MemoryMap, Hardware, Symbols
    machine.ts            Z80TestMachine — platform-agnostic Z80 execution core
  calling-convention/
    types.ts              CallingConvention interface and argument types
    sdcccall0.ts          SDCC __sdcccall(0) convention implementation
    sdcccall1.ts          SDCC __sdcccall(1) convention implementation
  devices/
    tms9918.ts            TMS9918 VDP capture (MSX, ColecoVision, SG-1000)
  symbols/
    sdcc.ts               SDCC .noi/.lst symbol parsing + SdccSymbols
    z88dk.ts              z88dk .map symbol parsing + Z88dkSymbols
  adapters/
    msx/                  MSX adapter: memory map, BIOS hooks, factory
    spectrum/             ZX Spectrum adapter: memory map, .tap loader
  ffi.ts                  Foreign function interface (fn + call + var)
  utils.ts                Helpers: pushStackArg, signed8

How It Works

User Tests (driving adapter)
        │
  ┌─────▼──────────────────────────────┐
  │     Z80TestMachine  (core)         │
  │  execution · memory · registers    │
  ├────────────────────────────────────┤
  │     Secondary Ports (interfaces)   │
  │  MemoryMap · Hardware              │
  └──┬──────────┬──────────────────────┘
     │          │
  ┌──▼───┐  ┌────▼────┐  ┌──▼───┐  ┌────────────┐
  │ MSX  │  │ Spectrum│  │ Bare │  │ future ... │
  └──────┘  └─────────┘  └──────┘  └────────────┘

  Symbols (SdccSymbols, etc.) are managed independently
  and passed to ffi.fn / ffi.call for name → address resolution.
  1. Z80TestMachine is the core — it runs Z80 code, manages memory and registers, and delegates hardware I/O to injected ports.
  2. Adapters (like createMsxTestbed()) compose the core with platform-specific MemoryMap and Hardware implementations. Symbols are managed separately.
  3. Devices (like Tms9918) are reusable peripheral emulations shared across adapters.
  4. A HALT instruction at address 0x0000 acts as a sentinel — runFrom() pushes 0x0000 as the return address, so when the function executes RET, the CPU halts.
  5. A configurable cycle limit (default 100,000 T-states) prevents infinite loops.

API Reference

createMsxTestbed()

Factory function that creates an MSX testing environment with VDP capture, keyboard simulation, and BIOS hooks.

import { readFileSync } from 'fs'
import { createMsxTestbed } from 'z80-testing-library'

const { machine, vdp, keyboard } = createMsxTestbed({
  rom: new Uint8Array(readFileSync('game.rom')),
  romLoadAddress: 0x4000,        // default: 0x4000 (slot 1)
  stackPointer: 0xF380,          // default: 0xF380
  extraStubs: [0x1234],          // additional addresses to stub with RET
})

Returns { machine, vdp, keyboard }:

  • machineZ80TestMachine instance with MSX memory map and hardware
  • vdpTms9918 instance for VDP/VRAM inspection
  • keyboardUint8Array(16) for keyboard matrix simulation

Symbols are managed separately — see SdccSymbols.

createSpectrumTestbed()

Factory for a ZX Spectrum 48K testing environment: 16K ROM write-protected at 0x0000-0x3FFF, RAM above 0x4000, stack at 0xFF58, default ORG 0x8000 (z88dk's +zx default).

import { readFileSync } from 'fs'
import {
  createSpectrumTestbed, extractCodeFromTap, Z88dkSymbols,
} from 'z80-testing-library'

const { code, loadAddress } = extractCodeFromTap(
  new Uint8Array(readFileSync('hello.tap')),
)
const symbols = Z88dkSymbols.fromFile('hello.map')

const { machine } = createSpectrumTestbed({
  regions: [[loadAddress, code]], // load the extracted .tap CODE block
  stackPointer: 0xFF58,           // default
  hooks: new Map(),               // install PC hooks for ROM routines
  extraStubs: [0x10A8],           // or stub addresses with RET
})

Returns { machine } — no VDP/keyboard wrappers yet. The ULA is not modelled; install PC hooks for ROM routines (RST 0x10, CHAN_OPEN, etc.) if your code depends on them.

Pair with Z88dkSymbols for name resolution and sdcccall0() as the calling convention for z88dk's -clib=sdcc_iy output.

extractCodeFromTap()

Parses a ZX Spectrum .tap tape image and returns the first CODE block (type 3) plus its advertised load address.

import { extractCodeFromTap } from 'z80-testing-library'
import { readFileSync } from 'fs'

const { code, loadAddress } = extractCodeFromTap(
  new Uint8Array(readFileSync('hello.tap')),
)
// → code: Uint8Array of the binary, loadAddress: 0x8000 (for +zx defaults)

Accepts Uint8Array, not a path — callers load the file themselves so the library stays browser-compatible. Throws if the tape has no CODE block (BASIC-only tape, bad file, etc.). For a multi-code-block tape, only the first CODE block is returned.

Z80TestMachine

The platform-agnostic core. Used directly for bare Z80 testing, or via an adapter for platform-specific testing.

import { Z80TestMachine } from 'z80-testing-library'

const m = new Z80TestMachine({
  memoryMap: myMemoryMap,        // MemoryMap port (write protection, defaults)
  hardware: myHardware,          // Hardware port (I/O, hooks, stubs)
  rom: myRomData,                // ROM as Uint8Array
  romLoadAddress: 0x4000,        // where to load ROM
  regions: [[0x100, myCode]],    // additional memory regions
  stackPointer: 0xF000,          // initial stack pointer
  hooks: new Map([[0x100, (m) => { ... }]]),  // user PC-triggered hooks
  stubs: [0x0050],               // additional stub addresses
})

All config fields are optional — with no arguments you get a flat 64KB all-writable Z80.

Execution

| Method | Description | |---|---| | runFrom(addr, cycleLimit?) | Run from a raw address. Returns T-states consumed. | | elapsedTStates | T-states from the last runFrom call. |

Registers

Access the Z80 register set via m.regs:

m.regs.a    // 8-bit registers: a, b, c, d, e, h, l, f
m.regs.bc   // 16-bit pairs: af, bc, de, hl
m.regs.sp   // stack pointer
m.regs.pc   // program counter
m.regs.ix   // index registers: ix, iy

Memory

| Method | Description | |---|---| | readByte(addr) | Read a byte from memory. | | writeByte(addr, val) | Write a byte to memory. | | readWord(addr) | Read a little-endian 16-bit word. | | writeWord(addr, val) | Write a little-endian 16-bit word. | | writeBlock(addr, data) | Write a Uint8Array or number[] to memory. |

Port Interfaces

Implement these to add support for a new Z80 platform:

import type { MemoryMap, Hardware } from 'z80-testing-library'

// Memory layout — defines address space characteristics
const myMemoryMap: MemoryMap = {
  defaultRomLoadAddress: 0x4000,
  defaultStackPointer: 0xF380,
  isWritable: (addr) => addr >= 0xC000,
}

// Hardware — I/O ports and OS/BIOS interception
const myHardware: Hardware = {
  readPort: (port) => 0xFF,
  writePort: (port, value) => {},
  hooks: new Map(),    // PC-triggered hooks for BIOS/OS calls
  stubs: [0x0041],     // addresses to patch with RET
}

Symbols Interface

The Symbols interface is independent of the machine — implement it for custom symbol sources:

import type { Symbols } from 'z80-testing-library'

const mySymbols: Symbols = {
  query: (name) => symbolTable.get(name),
  get: (name) => {
    const addr = symbolTable.get(name)
    if (addr === undefined) throw new Error(`Unknown symbol: ${name}`)
    return addr
  },
  has: (name) => symbolTable.has(name),
}

Tms9918 (VDP)

TMS9918 video display processor capture. Used by MSX, ColecoVision, SG-1000, and other systems with this VDP.

import { Tms9918, TMS9918_LAYOUT } from 'z80-testing-library'

const vdp = new Tms9918()

vdp.readVram(addr)                 // read from 16KB VRAM buffer
vdp.writeVram(addr, value)         // write to VRAM buffer
vdp.fillVram(addr, value, len)     // fill VRAM region
vdp.readSatEntry(index)            // read sprite attribute: { y, x, pattern, color }
vdp.readPntTile(col, row)          // read Pattern Name Table tile
vdp.getRegisterWrites()            // recorded VDP register writes
vdp.getVramWrites()                // recorded data port writes
vdp.clear()                        // clear recorded writes (keep VRAM)
vdp.clearAll()                     // clear everything including VRAM

SdccSymbols

Symbol resolution for programs compiled with SDCC. Parses .noi content for exported symbols and .lst content for static (non-exported) symbols. See Appendix: How SDCC Symbol Resolution Works for details on why both file types are needed.

import { SdccSymbols } from 'z80-testing-library'

// From file paths (convenience — reads .noi, .lk, and .lst files automatically)
const symbols = SdccSymbols.fromFiles('build/game.noi', './build')

// From content strings (no I/O — useful for testing or non-filesystem sources)
const ordered = SdccSymbols.parseLk(lkContent, {
  main: mainLstContent,
  physics: physicsLstContent,
  render: renderLstContent,
})
const symbols = new SdccSymbols(noiContent, ordered)

symbols.get('game_loop')           // → 0x400B (exported function, throws if missing)
symbols.query('game_loop')         // → 0x400B (returns undefined if missing)
symbols.query('clamp_position')    // → 0x4018 (static function, from .lst)
symbols.has('game_loop')           // → true

Z88dkSymbols

Symbol resolution for programs compiled with z88dk. z88dk emits its own linker map format (not SDCC's .noi/.lst), so SdccSymbols does not apply — use this instead.

Pass -m to zcc so the map file is written:

zcc +zx -clib=sdcc_iy -m -o hello -create-app hello.c    # produces hello.map

Map lines look like _name = $HEX ; addr, public, .... The parser strips the SDCC leading underscore for ergonomic lookups and skips non-address lines (constants from .inc files).

import { Z88dkSymbols } from 'z80-testing-library'

// From a file path
const symbols = Z88dkSymbols.fromFile('build/hello.map')

// From a content string (no I/O — useful for tests or non-filesystem sources)
const symbols2 = new Z88dkSymbols(mapContent)

symbols.get('clamp_add')       // → 0x9363 (throws if missing)
symbols.query('clamp_add')     // → 0x9363 (undefined if missing)
symbols.has('clamp_add')       // → true

ffi.fn()

Declarative function binding. Define the signature once, bind to a machine, and get a fully typed callable. This is the recommended way to test compiled functions.

import { ffi, SdccSymbols } from 'z80-testing-library'

const symbols = SdccSymbols.fromFiles('build/game.noi', './build')

// Define schemas — reusable, no machine dependency
const paddleHeightSchema = ffi.fn(symbols.get('paddle_height'), ['u8'], 'u8')
const setRectSchema = ffi.fn(symbols.get('set_rect'), ['u8', 'u8', 'u8', 'u8'], 'void')
const getEntitySchema = ffi.fn(symbols.get('get_entity'), ['u16'], 'u16')

// Bind to a machine — typically in beforeEach or describe scope
const paddleHeight = paddleHeightSchema.bind(m)
const setRect = setRectSchema.bind(m)

// Call with values — TypeScript enforces correct argument count and types
expect(paddleHeight(0)).toBe(16)
expect(paddleHeight(1)).toBe(24)
setRect(5, 3, 3, 4)

// Inline binding when reuse isn't needed
const rng = ffi.fn(symbols.get('next_rng'), [], 'u8').bind(m)
expect(rng()).toBeGreaterThan(0)

// Or skip the bind step entirely for one-shot use
expect(ffi.fn(symbols.get('next_rng'), [], 'u8').call(m)).toBeGreaterThan(0)

Argument types: Declared as an array of 'u8' and 'u16' strings. At the call site, all values are plain numbers — the signature tells the calling convention how to place each one.

Return type: 'void' functions return undefined. 'u8' and 'u16' functions return number.

Schema methods:

| Method | Purpose | |---|---| | schema.bind(m) | Returns a reusable BoundFunction that closes over the machine | | schema.call(m, ...args) | One-shot invoke — returns the value only | | schema.callDetailed(m, ...args) | One-shot invoke — returns { value, tStates } |

bound.detailed(): On a bound function, use .detailed() to get cycle counts:

const result = paddleHeight.detailed(0)  // → { value: 16, tStates: 234 }

Sharing schemas across test files:

// test/helpers/game-functions.ts
const symbols = SdccSymbols.fromFiles('build/game.noi', './build')
export const paddleHeightSchema = ffi.fn(symbols.get('paddle_height'), ['u8'], 'u8')
export const setRectSchema = ffi.fn(symbols.get('set_rect'), ['u8', 'u8', 'u8', 'u8'], 'void')

// test/paddle.test.ts
import { paddleHeightSchema } from './helpers/game-functions'
const paddleHeight = paddleHeightSchema.bind(m)
expect(paddleHeight(0)).toBe(16)

Options

| Option | Default | Description | |---|---|---| | cc | sdcccall1() | Calling convention to use | | cycleLimit | 100000 | Maximum T-states before aborting |

ffi.call()

Imperative helper for one-off function calls. Useful when you don't need to pre-declare a signature.

import { ffi } from 'z80-testing-library'

// uint8_t add_offset(uint8_t val)
const result = ffi.call(m, symbols.get('add_offset'), { args: [10], ret: 'u8' })
expect(result.value).toBe(15)

// void set_rect(uint8_t col, uint8_t row, uint8_t w, uint8_t h)
ffi.call(m, symbols.get('set_rect'), { args: [5, 3, 3, 4] })

// uint16_t get_address(uint16_t *ptr)
const r = ffi.call(m, symbols.get('get_address'), {
  args: [{ type: 'u16', value: 0xC100 }],
  ret: 'u16',
})

Arguments: Bare numbers are treated as u8. For 16-bit values, use { type: 'u16', value }.

Result object: { value, tStates } — the return value and the number of T-states consumed.

Options

| Option | Default | Description | |---|---|---| | args | [] | Function arguments | | ret | 'void' | Return type: 'void', 'u8', 'u16' | | cc | sdcccall1() | Calling convention to use | | cycleLimit | 100000 | Maximum T-states before aborting |

Custom Calling Conventions

Both ffi.fn and ffi.call accept a custom calling convention via the cc option. Implement the CallingConvention interface to support a different compiler:

import type { CallingConvention } from 'z80-testing-library'

const myConvention: CallingConvention = {
  placeArgs(m, args) {
    // Place arguments into registers and/or stack
    // e.g. Hitech-C: all args on stack, right-to-left
  },
  readReturn(m, ret) {
    // Read return value from registers
    // e.g. Hitech-C: u8 from L, u16 from HL
    return ret === 'u8' ? m.regs.l : m.regs.hl
  },
}

// With ffi.fn
const myFuncSchema = ffi.fn(symbols.get('my_func'), ['u8', 'u8'], 'u8', { cc: myConvention })

// With ffi.call
ffi.call(m, symbols.get('my_func'), { args: [1, 2], ret: 'u8', cc: myConvention })

ffi.var()

Typed global variable binding. Declares the type once, bind to a machine, and get a typed get/set accessor. Useful for reading and writing SDCC global and static variables without manually choosing readByte/readWord.

import { ffi, SdccSymbols } from 'z80-testing-library'

const symbols = SdccSymbols.fromFiles('build/game.noi', './build')

// Define schemas — reusable, no machine dependency
const scoreSchema    = ffi.var(symbols.get('score'), 'u8')
const highScoreSchema = ffi.var(symbols.get('high_score'), 'u16')
const velocitySchema = ffi.var(symbols.get('velocity'), 'i8')

// Bind to a machine — typically in beforeEach or describe scope
const score     = scoreSchema.bind(m)
const highScore = highScoreSchema.bind(m)
const velocity  = velocitySchema.bind(m)

// Read and write
score.set(42)
expect(score.get()).toBe(42)

velocity.set(-5)
expect(velocity.get()).toBe(-5)  // signed

// Inline binding when reuse isn't needed
const lives = ffi.var(symbols.get('lives'), 'u8').bind(m)
lives.set(3)

// Or skip the bind step for one-shot reads/writes
scoreSchema.set(m, 100)
expect(scoreSchema.get(m)).toBe(100)

Supported types:

| Type | Size | Range | Description | |---|---|---|---| | 'u8' | 1 byte | 0–255 | Unsigned byte | | 'i8' | 1 byte | -128–127 | Signed byte (two's complement) | | 'u16' | 2 bytes | 0–65535 | Unsigned word, little-endian | | 'i16' | 2 bytes | -32768–32767 | Signed word, little-endian |

Schema methods:

| Method | Purpose | |---|---| | schema.bind(m) | Returns a reusable BoundVariable (.get(), .set(v), .addr) | | schema.get(m) | One-shot read against a machine | | schema.set(m, v) | One-shot write against a machine | | schema.addr | The memory address (no machine needed) |

addr property: Both the schema and each bound variable expose .addr for cases where you need the raw address.

Sharing schemas across test files:

// test/helpers/game-vars.ts
const symbols = SdccSymbols.fromFiles('build/game.noi', './build')
export const scoreSchema    = ffi.var(symbols.get('score'), 'u8')
export const highScoreSchema = ffi.var(symbols.get('high_score'), 'u16')

// test/score.test.ts
import { scoreSchema } from './helpers/game-vars'
const score = scoreSchema.bind(m)
score.set(0)

Utility Functions

import { pushStackArg, signed8 } from 'z80-testing-library'

// Push a byte onto the stack (for manual argument placement)
pushStackArg(m, 0x42)

// Convert unsigned byte to signed int8
signed8(0xFE)  // → -2
signed8(127)   // → 127

Constants

import { MSX_BIOS, TMS9918_LAYOUT } from 'z80-testing-library'

MSX_BIOS.SNSMAT   // 0x0141
MSX_BIOS.WRTVDP   // 0x0047
// ... DISSCR, ENASCR, WRTVRM, RDVRM, FILVRM, LDIRVM, CHGCLR, INIGRP

TMS9918_LAYOUT.SAT     // 0x1B00 (Sprite Attribute Table)
TMS9918_LAYOUT.PNT     // 0x1800 (Pattern Name Table)
TMS9918_LAYOUT.PGT     // 0x0000 (Pattern Generator Table)
TMS9918_LAYOUT.CT      // 0x2000 (Color Table)
TMS9918_LAYOUT.SPT     // 0x3800 (Sprite Pattern Table)

Calling Conventions

The library ships with two SDCC calling conventions. ffi.fn and ffi.call handle the details automatically — you just need to pick the right one.

Built-in conventions

| Convention | Import | Args | Return u8 | Return u16 | |---|---|---|---|---| | sdcccall1() | default | 1st u8 → A, 1st u16 → DE, rest → stack (R→L) | A | DE | | sdcccall0() | { sdcccall0 } | All → stack (R→L) | L | HL |

sdcccall1() — SDCC default

The default convention for SDCC 4.x targeting Z80. Used automatically by ffi.fn and ffi.call unless overridden.

import { ffi } from 'z80-testing-library'

// No cc option needed — sdcccall1 is the default
const addOffset = ffi.fn(symbols.get('add_offset'), ['u8'], 'u8').bind(m)
const updateEntity = ffi.fn(symbols.get('update_entity'), ['u16', 'u8'], 'void').bind(m)
const setRect = ffi.fn(symbols.get('set_rect'), ['u8', 'u8', 'u8', 'u8'], 'void').bind(m)

addOffset(10)
updateEntity(entityAddr, 0x01)
setRect(5, 3, 3, 4)

Register assignment (left-to-right): first uint8_t → A, first uint16_t/pointer → DE. Remaining arguments go to the stack in reverse order.

Tip: For functions with 2+ args where the first is a pointer, check the generated assembly to confirm where SDCC places the second argument. It may be in A or on the stack.

sdcccall0() — BIOS wrappers

All parameters on the stack. Used by SDCC for BIOS wrapper functions and older code using __sdcccall(0).

import { ffi, sdcccall0 } from 'z80-testing-library'

const cc = sdcccall0()
const biosRead = ffi.fn(symbols.get('bios_read_sector'), ['u8', 'u16'], 'u8', { cc }).bind(m)
expect(biosRead(0x01, 0x1800)).toBe(expectedResult)

Custom conventions

To support a different compiler (Hitech-C, z88dk, etc.), implement the CallingConvention interface:

import type { CallingConvention } from 'z80-testing-library'

const hitechC: CallingConvention = {
  placeArgs(m, args) {
    // Hitech-C: all args on stack, right-to-left
  },
  readReturn(m, ret) {
    // Hitech-C: u8 from L, u16 from HL
    return ret === 'u8' ? m.regs.l : m.regs.hl
  },
}

const myFunc = ffi.fn(symbols.get('my_func'), ['u8', 'u8'], 'u8', { cc: hitechC }).bind(m)

Low-level register/stack management

When using runFrom directly, you manage calling conventions manually:

import { pushStackArg } from 'z80-testing-library'

// sdcccall(1): uint8_t add_offset(uint8_t val)
m.regs.a = 10
m.runFrom(symbols.get('add_offset'))
const result = m.regs.a

// sdcccall(1): void set_rect(uint8_t col, uint8_t row, uint8_t w, uint8_t h)
// col in A, row/w/h on stack (push in reverse order)
m.regs.a = 5
pushStackArg(m, 4) // h (pushed first = higher on stack)
pushStackArg(m, 3) // w
pushStackArg(m, 3) // row (pushed last = lower on stack, read first)
m.runFrom(symbols.get('set_rect'))

Testing Patterns

Testing a Pure Function

// Using ffi.fn (recommended)
const dirToIndex = ffi.fn(symbols.get('dir_to_index'), ['u8'], 'u8').bind(m)

it('converts direction to index', () => {
  expect(dirToIndex(0x04)).toBe(0) // DIR_DOWN
})

// Using the low-level API
it('converts direction to index', () => {
  m.regs.a = 0x04 // DIR_DOWN
  m.runFrom(symbols.get('dir_to_index'))
  expect(m.regs.a).toBe(0)
})

Testing a Function That Reads/Writes Globals

const nextRng = ffi.fn(symbols.get('next_rng'), [], 'u8').bind(m)
const rng     = ffi.var(symbols.get('rng'), 'u8').bind(m)

it('advances the RNG state', () => {
  rng.set(42)
  const expected = (42 * 109 + 31) & 0xFF
  expect(nextRng()).toBe(expected)
  expect(rng.get()).toBe(expected)
})

Testing with Struct Pointers

Define read/write helpers for your game's struct layouts:

const OBJ_SIZE = 15
const processKnockback = ffi.fn(symbols.get('process_knockback'), ['u16'], 'void').bind(m)

function writeObj(m: Z80TestMachine, index: number, fields: Partial<{
  active: number, x: number, y: number, dir: number, hp: number
}>) {
  const base = symbols.get('objs') + index * OBJ_SIZE
  m.writeByte(base + 0, fields.active ?? 1)
  m.writeByte(base + 2, fields.x ?? 0x80)
  m.writeByte(base + 3, fields.y ?? 0x80)
  m.writeByte(base + 4, fields.dir ?? 0x04)
  m.writeByte(base + 11, fields.hp ?? 3)
}

it('processes knockback', () => {
  writeObj(m, 0, { active: 1, x: 0x80, y: 0x80 })
  processKnockback(symbols.get('objs'))
  expect(m.readByte(symbols.get('objs') + 2)).not.toBe(0x80) // x changed
})

Testing Keyboard Input (MSX)

// MSX keyboard row 8 contains arrow keys (active-low):
// bit 7=Right, 6=Down, 5=Up, 4=Left, 0=Space

it('reads right arrow press', () => {
  keyboard[8] = 0xFF & ~(1 << 7) // Right pressed
  m.runFrom(symbols.get('read_input'))
  expect(m.readByte(symbols.get('inputDir'))).toBe(0x01) // DIR_RIGHT
})

Testing VDP/Sprite Output (MSX)

it('updates sprite position in SAT', () => {
  writeObj(m, 0, { x: 0x80, y: 0x60 })
  m.runFrom(symbols.get('update_sprites'))

  const sprite = vdp.readSatEntry(0)
  expect(sprite.x).toBe(0x80)
  expect(sprite.y).toBe(0x60 - 1) // VDP Y is off by 1
})

Testing Tile Map Operations (MSX)

it('places wall tiles', () => {
  const addr = symbols.get('tileMap')
  for (let i = 0; i < 22 * 32; i++) m.writeByte(addr + i, 0x26)

  m.regs.a = 0  // room_id
  m.runFrom(symbols.get('build_room_tilemap'))

  expect(m.readByte(addr + 0 * 32 + 15)).toBe(0x90) // TILE_WALL
})

MSX Memory Map

The MSX adapter enforces the standard MSX memory layout:

| Range | Contents | Writable | |---|---|---| | 0x0000-0x3FFF | BIOS / system area (stubs) | Yes | | 0x4000-0xBFFF | ROM (your cartridge) | No | | 0xC000-0xFFFF | RAM (variables, stack) | Yes |

TMS9918 VRAM Layout (Graphics II / SCREEN 2)

| Region | Address | Size | Description | |---|---|---|---| | PGT | 0x0000 | 6144 | Pattern Generator Table | | PNT | 0x1800 | 768 | Pattern Name Table | | SAT | 0x1B00 | 128 | Sprite Attribute Table (32 sprites x 4 bytes) | | CT | 0x2000 | 6144 | Color Table | | SPT | 0x3800 | 2048 | Sprite Pattern Table |

Adding a New Platform

To support a new Z80 computer, implement the two port interfaces and write a factory function. The built-in Spectrum adapter (src/adapters/spectrum/) is the canonical minimal example — a memory map with nothing but write protection, a hardware port with no I/O modelling, and a thin factory:

// src/adapters/amstradcpc/index.ts (hypothetical)
import { Z80TestMachine } from 'z80-testing-library'
import type { MemoryMap, Hardware } from 'z80-testing-library'

const cpcMemoryMap: MemoryMap = {
  defaultRomLoadAddress: 0x0100,
  defaultStackPointer: 0xC000,
  isWritable: (addr) => addr < 0xC000,  // 16K ROM at 0xC000-0xFFFF
}

const cpcHardware: Hardware = {
  readPort: (port) => 0xFF,   // stub — implement gate array etc. if needed
  writePort: () => {},
  hooks: new Map(),
  stubs: [],
}

export function createCpcTestbed(config) {
  return {
    machine: new Z80TestMachine({
      memoryMap: cpcMemoryMap,
      hardware: cpcHardware,
      ...config,
    }),
  }
}

See the MSX adapter for how to wire in a device (VDP) and hook real BIOS entry points when your target needs more than a stub.

Tips and Gotchas

  1. RAM is uninitialized. The machine starts with zeros in RAM. Always initialize the globals your function depends on.

  2. BIOS calls are partially emulated (MSX). SNSMAT, WRTVRM, LDIRVM, and FILVRM have functional hooks. Other BIOS calls are stubbed with RET (no-op). The function's logic still executes — only the actual hardware I/O is skipped.

  3. Signed bytes. When reading Z80 memory values that represent int8_t, use signed8():

    import { signed8 } from 'z80-testing-library'
    const offset = signed8(m.readByte(addr))  // -128..127
  4. ROM is read-only (MSX). Writes to 0x4000-0xBFFF are silently dropped.

  5. Stack location (MSX). SP starts at 0xF380. Keep object data away from the stack region (0xF000-0xF380) to avoid corruption in deeply nested calls.

  6. Cycle limits. Default is 100,000 T-states. Pass a higher limit for complex functions:

    m.runFrom(symbols.get('build_room_tilemap'), 500_000)
  7. No file I/O in the core. The Z80TestMachine and SdccSymbols constructor accept raw data (Uint8Array, content strings), not file paths. Use SdccSymbols.fromFiles() or readFileSync for disk loading, or provide data directly for browser-compatible usage.

Appendix: How SDCC Symbol Resolution Works

When you compile a C program with SDCC, the toolchain produces several artifact files. Understanding which ones matter — and why — helps explain how this library resolves function and variable addresses for testing.

Exported vs. static symbols

In C, symbols (functions and variables) are exported by default — the linker can see them across files. Marking a symbol static makes it file-local: invisible to the linker, callable only within its own source file.

void game_loop(void) { ... }           // exported — visible to linker
static void clamp_position(void) { ... } // static — file-local, invisible to linker

uint8_t score = 0;                      // exported variable
static uint8_t collision_count = 0;     // static variable

Normally you'd only test exported functions — they define the public interface of each module. But on a retro platform with tight ROM constraints, functions tend to be large and deeply intertwined. Testing through exported entry points alone may require complex setup or provide limited observability. Being able to call static helpers directly gives you a more practical way to isolate behavior:

m.runFrom(symbols.get('clamp_position'))  // call a static function directly
m.readByte(symbols.get('collision_count'))  // read a static variable

SDCC build artifacts

source.c  →  sdcc -c  →  source.rel, source.lst, ...
                              ↓
              sdcc -o  →  game.ihx, game.noi, game.lk, game.map
                              ↓
           sdobjcopy   →  game.rom

| File | Produced by | Contains | |---|---|---| | .noi | Linker | Exported symbol addresses (absolute, final) and segment bases | | .lst | Compiler | All labels (exported and static) with local offsets per area | | .lk | Linker | Link order of .rel files | | .map | Linker | Human-readable summary (exported symbols, area layout, link order) | | .rom | objcopy | The binary ROM image loaded into the emulator |

Why we need each file

.noi provides the ground truth for exported symbols — their final absolute addresses after linking. This is the primary symbol source.

.lst files are needed for static symbols. Each .lst file contains labels with local offsets within their .area section. By finding an exported label that appears in both the .lst and .noi, the library computes the area's base address and resolves static labels relative to it.

.lk provides the link order of source files. This matters because the linker concatenates each file's contribution to shared areas (like _DATA or _INITIALIZED) in this order. When a static symbol has no exported anchor in its area, the library uses the cumulative sizes of prior files to compute the correct base address.

How resolution works

  1. Exported symbols: looked up directly from .noi content (name → absolute address).

  2. Static symbols with an exported anchor: if the same .lst file has an exported label in the same area, the library computes base = noi_address - local_offset and resolves the static label relative to that base.

  3. Static symbols without an anchor: the library accumulates per-file area sizes (from .ds directives in .lst files) across all files in link order, then computes base = segment_start + cumulative_offset_of_prior_files.

Using fromFiles vs. the constructor

SdccSymbols.fromFiles(noiPath, lstDir) handles everything automatically — reads the .noi, parses the .lk for link order, and loads .lst files in the correct sequence.

For manual control (or non-filesystem sources), use parseLk to establish the correct order, then pass content strings to the constructor:

const ordered = SdccSymbols.parseLk(lkContent, {
  main: mainLstContent,
  physics: physicsLstContent,
})
const provider = new SdccSymbols(noiContent, ordered)

The OrderedLstContents branded type returned by parseLk ensures the constructor receives correctly ordered content — passing a plain string[] is a type error.

License

MIT