From 0c5b8aae6ef43f88acd50ba21837e44cac40b9e9 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 21:12:07 -0700 Subject: [PATCH] feat: extend dot matrix deck --- .github/workflows/ci.yml | 53 ++ .gitignore | 4 + LICENSE | 21 + NOTICE | 3 + README.md | 97 ++- build.zig | 92 ++- build.zig.zon | 3 +- docs/architecture.md | 60 ++ docs/roadmap.md | 37 + fixtures/asm/demo.gbasm | 137 ++++ fixtures/asm/smoke.gbasm | 27 + fixtures/roms/demo.gb | Bin 0 -> 509 bytes fixtures/roms/smoke.gb | Bin 0 -> 289 bytes src/bus.zig | 9 +- src/cpu.zig | 15 +- src/disasm.zig | 42 +- src/emulator.zig | 2 +- src/frontend.zig | 108 +++ src/joypad.zig | 58 ++ src/main.zig | 68 +- src/sdl_mingw_shim.zig | 19 + src/tests.zig | 182 +++++ tools/gbasm.zig | 1399 +++++++++++++++++++++++++++++++++++++- 23 files changed, 2357 insertions(+), 79 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 docs/architecture.md create mode 100644 docs/roadmap.md create mode 100644 fixtures/asm/demo.gbasm create mode 100644 fixtures/asm/smoke.gbasm create mode 100644 fixtures/roms/demo.gb create mode 100644 fixtures/roms/smoke.gb create mode 100644 src/frontend.zig create mode 100644 src/joypad.zig create mode 100644 src/sdl_mingw_shim.zig create mode 100644 src/tests.zig diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b2b1680 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - name: Format check + run: zig fmt --check . + - name: Unit and integration tests + run: zig build test -Dsdl2=off --summary all + - name: Regenerate bundled ROMs + run: zig build fixtures -Dsdl2=off + - name: Committed fixtures match regeneration + if: runner.os == 'Linux' + run: git diff --exit-code -- fixtures + - name: Demo ROM reports PASS + run: zig build run-demo -Dsdl2=off + - name: Build headless tools + run: zig build -Dsdl2=off --summary all + + windowed: + name: Windowed frontend (ubuntu) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - name: Install SDL2 + run: sudo apt-get update && sudo apt-get install -y libsdl2-dev + - name: Build windowed frontend + run: zig build --summary all diff --git a/.gitignore b/.gitignore index 51bafde..5a98c9e 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,7 @@ zig-out/ *.gb *.sav *.swp + +# The bundled demo and test ROMs are generated from fixtures/asm and +# committed so the demo works without running the build step first. +!fixtures/roms/*.gb diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a960b72 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 DanielCuevas1208 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..08aa5d1 --- /dev/null +++ b/NOTICE @@ -0,0 +1,3 @@ +This project links the SDL2 library at build time. SDL2 is a +third-party library. It is not part of this source tree. The SDL2 source +is available from the SDL website. This project does not bundle SDL2. diff --git a/README.md b/README.md index fc59c84..20c3441 100644 --- a/README.md +++ b/README.md @@ -1,46 +1,54 @@ # Dot Matrix Deck -Dot Matrix Deck is a Game Boy emulator workbench written in Zig. It models the -SM83 CPU, the memory bus, timers, serial output, and the pixel pipeline. A -headless runner makes emulator behavior easy to inspect in scripts and CI. - -## Current status - -This project is under active development. The headless core is the most useful -entry point today. The SDL2 windowed frontend is available when SDL2 is -installed, but it is still being built out. +Dot Matrix Deck is a Game Boy emulator. It is written in Zig. It +emulates the SM83 CPU, the memory bus, the timers, and the pixel +pipeline. A window shows the screen with SDL2. A headless mode runs +test ROMs without a window. ## Features -- SM83 CPU and instruction execution -- Memory bus with timer, serial, and pixel-processing components -- Headless ROM runner with cycle limits and serial verdicts -- Optional instruction trace output -- Small assembler tool for test ROM sources -- Deterministic unit tests for the emulator core and assembler +- SM83 CPU with the full base and CB instruction sets +- Memory bus with timer, serial, joypad, and pixel components +- Pixel pipeline with background, window, and sprites +- MBC1 and ROM-only cartridges +- Headless runner with serial verdicts +- SDL2 window with keyboard controls +- gbasm assembler for test ROMs +- Bundled demo ROM that draws and reports PASS +- Deterministic unit and end-to-end tests ## Requirements - Zig 0.16 or later -- SDL2 for the optional windowed frontend +- SDL2 for the windowed frontend -The headless build does not require SDL2. On Windows, the build searches common -MSYS2 and vcpkg prefixes. You can also set `SDL2_DIR` or pass an SDL2 prefix. +The headless build does not need SDL2. On Windows, the build searches +common MSYS2 and vcpkg prefixes. Set `SDL2_DIR` to use another prefix. ## Build -Build the headless runner and assembler: +Build the headless tools: ```text zig build -Dsdl2=off ``` -Build with the windowed frontend when SDL2 is available: +Build the windowed frontend: ```text zig build ``` +## Run the demo + +Run the demo ROM and check its verdict: + +```text +zig build run-demo -Dsdl2=off +``` + +The demo draws a pattern and reports PASS over the link port. + ## Run a ROM Run a ROM without a window: @@ -49,41 +57,58 @@ Run a ROM without a window: zig build run-headless -- path/to/rom.gb ``` -Useful options are `--max-cycles N`, `--trace`, and `--expect pass|fail|any`. -The runner prints serial output and returns a status from the detected verdict. +The runner prints the serial output. It sets the exit code from the +verdict. Useful options are `--max-cycles N`, `--trace`, and +`--expect pass|fail|any`. -Run a ROM in the SDL2 frontend: +Run a ROM in a window: ```text zig build run -- path/to/rom.gb ``` +## Controls + +Use the arrow keys to move. Use Z and X for the A and B buttons. Use +Enter and Backspace for Start and Select. Press Escape to quit. + ## Test -Run the core and assembler tests: +Run all tests: ```text zig build test -Dsdl2=off ``` -The repository also contains build steps for generated fixtures and the Blargg -CPU instruction suite. Add the required fixture files before using those steps. +The tests cover the CPU, the timer, the serial port, the disassembler, +the assembler, and the ROM fixtures. ## Project layout -- `src/cpu.zig` - SM83 CPU implementation -- `src/emulator.zig` - emulator composition and core tests -- `src/bus.zig` - memory and device routing -- `src/headless.zig` - command-line ROM runner -- `src/main.zig` - SDL2 frontend -- `tools/gbasm.zig` - small assembler for fixture ROMs +- `src/cpu.zig` - the SM83 CPU +- `src/bus.zig` - the memory bus +- `src/ppu.zig` - the pixel pipeline +- `src/timer.zig` - the timer +- `src/cartridge.zig` - cartridges and MBC1 +- `src/headless.zig` - the headless runner +- `src/frontend.zig` - the SDL2 window +- `src/joypad.zig` - the joypad +- `src/disasm.zig` - the disassembler +- `tools/gbasm.zig` - the assembler +- `fixtures/` - bundled test ROMs and sources +- `docs/` - roadmap and architecture notes + +## Test status + +The project runs 44 tests in CI. The CI checks formatting, builds, and +the demo verdict. It runs on Linux and Windows. ## Limitations -Hardware coverage is incomplete. Timing accuracy, cartridge support, audio, -and frontend features will improve as the project grows. +Timing accuracy is incomplete. Audio is not implemented. Only MBC1 +cartridges work. The windowed frontend is basic. See `docs/roadmap.md` +for the remaining work. ## License -No license file is published yet. Treat this repository as an experimental -project until a license is added. +MIT. See `LICENSE`. diff --git a/build.zig b/build.zig index 815819d..3af41c3 100644 --- a/build.zig +++ b/build.zig @@ -57,22 +57,22 @@ pub fn build(b: *std.Build) void { }; if (build_windowed) { - var sdl_prefix: ?[]const u8 = null; - if (sdl2_opt) |value| { - if (value.len != 0 and !std.mem.eql(u8, value, "off")) sdl_prefix = value; - } else { - if (b.graph.environ_map.get("SDL2_DIR")) |dir| { - if (dir.len != 0) sdl_prefix = dir; - } - if (sdl_prefix == null and target.result.os.tag == .windows) { + var sdl_prefix: ?[]const u8 = null; + if (sdl2_opt) |value| { + if (value.len != 0 and !std.mem.eql(u8, value, "off")) sdl_prefix = value; + } else { + if (b.graph.environ_map.get("SDL2_DIR")) |dir| { + if (dir.len != 0) sdl_prefix = dir; + } + if (sdl_prefix == null and target.result.os.tag == .windows) { for (sdl_prefix_candidates) |candidate| { if (sdl2PrefixFound(b, candidate)) { sdl_prefix = candidate; break; } } + } } - } if (sdl_prefix != null or target.result.os.tag != .windows) { const exe = b.addExecutable(.{ @@ -91,9 +91,20 @@ pub fn build(b: *std.Build) void { if (sdl_prefix) |prefix| { exe.root_module.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ prefix, "include" }) }); exe.root_module.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ prefix, "lib" }) }); + // On Windows prefer the DLL import library. The static + // mingw archive drags in CRT symbols Zig does not ship. + if (target.result.os.tag == .windows) { + exe.root_module.addObjectFile(.{ .cwd_relative = b.pathJoin(&.{ prefix, "lib", "libSDL2.dll.a" }) }); + const shim = b.addObject(.{ .name = "sdl_mingw_shim", .root_module = b.createModule(.{ + .root_source_file = b.path("src/sdl_mingw_shim.zig"), + .target = target, + .optimize = optimize, + }) }); + exe.root_module.addObject(shim); + } const dll_path = b.pathJoin(&.{ prefix, "bin", "SDL2.dll" }); if (sdl2PrefixFound(b, prefix)) { - const install_dll = b.addInstallBinFile(.{ .cwd_relative = dll_path }, "bin/SDL2.dll"); + const install_dll = b.addInstallBinFile(.{ .cwd_relative = dll_path }, "SDL2.dll"); b.getInstallStep().dependOn(&install_dll.step); } } @@ -118,8 +129,6 @@ pub fn build(b: *std.Build) void { }), }); const run_core_tests = b.addRunArtifact(core_tests); - const test_step = b.step("test", "Run deterministic unit tests and bundled ROM tests"); - test_step.dependOn(&run_core_tests.step); const asm_tests = b.addTest(.{ .root_module = b.createModule(.{ @@ -129,7 +138,56 @@ pub fn build(b: *std.Build) void { }), }); const run_asm_tests = b.addRunArtifact(asm_tests); + + const joypad_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/joypad.zig"), + .target = target, + .optimize = optimize, + }), + }); + const run_joypad_tests = b.addRunArtifact(joypad_tests); + + const serial_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/serial.zig"), + .target = target, + .optimize = optimize, + }), + }); + const run_serial_tests = b.addRunArtifact(serial_tests); + + const disasm_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/disasm.zig"), + .target = target, + .optimize = optimize, + }), + }); + const run_disasm_tests = b.addRunArtifact(disasm_tests); + + const gbasm_mod = b.createModule(.{ + .root_source_file = b.path("tools/gbasm.zig"), + .target = target, + .optimize = optimize, + }); + const end_to_end_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/tests.zig"), + .target = target, + .optimize = optimize, + .imports = &.{.{ .name = "gbasm", .module = gbasm_mod }}, + }), + }); + const run_end_to_end_tests = b.addRunArtifact(end_to_end_tests); + + const test_step = b.step("test", "Run deterministic unit tests and bundled ROM tests"); + test_step.dependOn(&run_core_tests.step); test_step.dependOn(&run_asm_tests.step); + test_step.dependOn(&run_joypad_tests.step); + test_step.dependOn(&run_serial_tests.step); + test_step.dependOn(&run_disasm_tests.step); + test_step.dependOn(&run_end_to_end_tests.step); const fixtures_step = b.step("fixtures", "Regenerate bundled test ROMs from their assembly sources"); { @@ -139,6 +197,16 @@ pub fn build(b: *std.Build) void { fixtures_step.dependOn(&run.step); } + const demo_step = b.step("run-demo", "Run the bundled demo ROM and check it reports PASS"); + { + const run = b.addRunArtifact(headless); + run.addArg("--expect"); + run.addArg("pass"); + run.addFileArg(b.path("fixtures/roms/demo.gb")); + demo_step.dependOn(&run.step); + } + demo_step.dependOn(fixtures_step); + const blargg_step = b.step("test-blargg", "Run the blargg cpu_instrs ROMs in headless mode and check their serial output"); { const run = b.addRunArtifact(headless); diff --git a/build.zig.zon b/build.zig.zon index 530eafc..c10efc8 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .dot_matrix_deck, - .version = "0.1.0", + .version = "0.2.0", .minimum_zig_version = "0.16.0", .fingerprint = 0x3ad14c39cc8e69a1, .paths = .{ @@ -9,6 +9,7 @@ "src", "tools", "fixtures", + "docs", "README.md", "LICENSE", "NOTICE", diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..60fcd78 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,60 @@ +# Architecture + +This page explains how the emulator is organized. It is a short guide, +not a full specification. + +## Layers + +The project has four layers. + +1. The core emulator in `src/`. +2. The headless runner in `src/headless.zig`. +3. The windowed frontend in `src/frontend.zig` and `src/main.zig`. +4. The assembler and fixtures in `tools/` and `fixtures/`. + +## The core + +The core lives in `src/emulator.zig`. It ties the CPU and the bus +together. The CPU executes instructions. Each instruction ticks the bus. + +The bus routes reads and writes. It connects the cartridge, the timer, +the serial port, the joypad, and the pixel pipeline. The bus lives in +`src/bus.zig`. + +The pixel pipeline renders 160 by 144 pixels. It produces one frame per +display refresh. The pipeline lives in `src/ppu.zig`. + +## The headless runner + +`src/headless.zig` runs a ROM without a window. It steps the core until +a serial verdict appears. It prints the serial output and sets the exit +code from the verdict. + +This runner powers the CI checks. It also runs the Blargg suites. + +## The windowed frontend + +`src/frontend.zig` opens an SDL2 window. Each loop iteration presents +one emulated frame. It reads the keyboard and writes the joypad. + +`src/main.zig` loads the ROM and runs the loop. It exits when the user +closes the window or presses Escape. + +## The assembler + +`tools/gbasm.zig` assembles SM83 source text into a ROM image. The +output is deterministic. The fixtures step uses it to build the bundled +ROMs. The tests use it to build ROMs in memory. + +## Data flow + +A frame flows like this. + +1. The frontend polls input. +2. The frontend writes the joypad. +3. The core steps the CPU. +4. The CPU ticks the bus. +5. The pixel pipeline fills the frame buffer. +6. The frontend shows the frame. + +The headless runner skips the input and display steps. diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..c355d72 --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,37 @@ +# Roadmap + +This file shows the current state of the project. It lists what is done +and what comes next. The list changes as the project grows. + +## Complete + +- SM83 CPU with the full base and CB instruction sets +- Memory bus with timer, serial, and pixel components +- Interrupt handling with EI delay and HALT wake-up +- ROM-only and MBC1 cartridges +- Pixel pipeline with background, window, and sprites +- Headless runner with cycle limits and serial verdicts +- gbasm assembler for test ROM sources +- Bundled demo ROM that draws and reports PASS +- SDL2 window that shows emulated frames +- Keyboard input for the joypad +- Deterministic unit and end-to-end tests +- CI pipeline for Linux and Windows + +## Next + +- Accurate pixel pipeline timing +- Accurate DIV, TIMA, and STAT behavior +- Boot ROM support +- Joypad poll with proper edge detection +- Save states and SRAM persistence +- MBC2, MBC3, and MBC5 cartridges +- Audio output +- Interactive debugger with breakpoints +- Blargg test suites in CI + +## Later + +- Game Boy Color support +- Link cable emulation +- Rewind and movie recording diff --git a/fixtures/asm/demo.gbasm b/fixtures/asm/demo.gbasm new file mode 100644 index 0000000..b056702 --- /dev/null +++ b/fixtures/asm/demo.gbasm @@ -0,0 +1,137 @@ +; Dot Matrix Deck demo ROM. +; +; Draws an animated dot-matrix pattern and reports PASS on the link +; port after about one second. The windowed frontend keeps running the +; scroll animation forever after the verdict is printed. +; +; Cartridge header is not validated by the emulator; the boot entry is +; enough. + + .org 0x0100 + nop + jp start + + .org 0x0150 + +start: + ld sp, 0xfffe + + ; Copy three tiles into VRAM at 0x8000. + ld hl, 0x8000 + ld de, tile_data + ld bc, 0x0030 +copy_tiles: + ld a, (de) + ld (hl), a + inc hl + inc de + dec bc + ld a, b + or c + jr nz, copy_tiles + + ; Fill the background map with an alternating tile pattern. + ld hl, 0x9800 + ld bc, 0x0400 + xor a +fill_map: + ld (hl), a + inc hl + xor 1 + dec bc + ld a, b + or c + jr nz, fill_map + + ; Place the smiley tile on a few map cells. + ld a, 2 + ld hl, 0x9844 + ld (hl), a + ld hl, 0x9845 + ld (hl), a + ld hl, 0x9865 + ld (hl), a + ld hl, 0x98a4 + ld (hl), a + + ; Scroll for sixty frames, then report the verdict. + ld b, 60 +run_frames: + call wait_vblank + call scroll + dec b + jr nz, run_frames + + ; Print PASS over the link port. + ld hl, pass_text +print_loop: + ld a, (hl) + or a + jr z, animate + call print_char + inc hl + jr print_loop + + ; Keep the animation alive for the windowed frontend. +animate: + call wait_vblank + call scroll + jr animate + +; Advances the vertical scroll one step per frame. +scroll: + ld a, (0xff42) + inc a + ld (0xff42), a + ret + +; Returns when a full frame boundary has passed. +wait_vblank: + ld a, (0xff44) + cp 144 + jr c, wait_vblank +wait_active: + ld a, (0xff44) + cp 144 + jr nc, wait_active + ret + +; Sends A over the link port. +print_char: + ld (0xff01), a + ld a, 0x81 + ld (0xff02), a + ret + +pass_text: + .ascii "PASS" + .db 10, 0 + +tile_data: + ; Tile 0: checkerboard. + .db 0xaa, 0xaa + .db 0x55, 0x55 + .db 0xaa, 0xaa + .db 0x55, 0x55 + .db 0xaa, 0xaa + .db 0x55, 0x55 + .db 0xaa, 0xaa + .db 0x55, 0x55 + ; Tile 1: solid shade 2. + .db 0x00, 0xff + .db 0x00, 0xff + .db 0x00, 0xff + .db 0x00, 0xff + .db 0x00, 0xff + .db 0x00, 0xff + .db 0x00, 0xff + .db 0x00, 0xff + ; Tile 2: smiley. + .db 0x3c, 0x3c + .db 0x42, 0x42 + .db 0xa5, 0xa5 + .db 0x81, 0x81 + .db 0x99, 0x99 + .db 0xbd, 0xbd + .db 0x81, 0x81 + .db 0x42, 0x42 diff --git a/fixtures/asm/smoke.gbasm b/fixtures/asm/smoke.gbasm new file mode 100644 index 0000000..9771fd3 --- /dev/null +++ b/fixtures/asm/smoke.gbasm @@ -0,0 +1,27 @@ +; Minimal boot test ROM. +; +; Prints PASS on the link port immediately and then idles. This is the +; cheapest end-to-end check that a ROM boots and reports a verdict. + + .org 0x0100 + nop + jp start + +start: + ld sp, 0xfffe + ld hl, pass_text +print_loop: + ld a, (hl) + or a + jr z, idle + ld (0xff01), a + ld a, 0x81 + ld (0xff02), a + inc hl + jr print_loop +idle: + jr idle + +pass_text: + .ascii "PASS" + .db 10, 0 diff --git a/fixtures/roms/demo.gb b/fixtures/roms/demo.gb new file mode 100644 index 0000000000000000000000000000000000000000..e3135d0a84ea0d606ef59d08159efc0cd4a45374 GIT binary patch literal 509 zcmZQz7~$Y>03$U+!0_LHMTQ2!vy6-e3{vIF!rT=b6}~Gn%wS|-SzoUFjuFhaV^VaP zQLg9;#Hm2MWJWog&Dr&gXO}aw0u>);tlO@^c6J}5vcxxtsKk$7PXBFQIsHHR%jN&S z2^K%Wl)=xFuNeQ^HNIl{e=@)^IGBrJ)vC}?WX$j%724Q1IW1k<*f?|M-o1^DPEG)A CzE5BP literal 0 HcmV?d00001 diff --git a/fixtures/roms/smoke.gb b/fixtures/roms/smoke.gb new file mode 100644 index 0000000000000000000000000000000000000000..b1033240f75cd6e84cd48eb6a193fe42b002a296 GIT binary patch literal 289 scmZQz7~$YB3!~w`|BBL#b=x(#Uorl-YkbA@Us>X##J>Q?;9xEW0NPv(vj6}9 literal 0 HcmV?d00001 diff --git a/src/bus.zig b/src/bus.zig index ea87095..6b63a9c 100644 --- a/src/bus.zig +++ b/src/bus.zig @@ -1,6 +1,7 @@ const Cartridge = @import("cartridge.zig").Cartridge; const Ppu = @import("ppu.zig").Ppu; const Timer = @import("timer.zig").Timer; +const joypad = @import("joypad.zig"); pub const Bus = struct { allocator: @import("std").mem.Allocator, @@ -33,7 +34,7 @@ pub const Bus = struct { if (address >= 0xfe00 and address < 0xfea0) return self.ppu.read(address); if (address >= 0xff80 and address < 0xffff) return self.hram[address - 0xff80]; return switch (address) { - 0xff00 => self.joypad, + 0xff00 => joypad.readRegister(self.joypad), 0xff01 => self.sb, 0xff02 => self.sc, 0xff0f => self.iflag | 0xe0, @@ -107,4 +108,10 @@ pub const Bus = struct { pub fn serialOutput(self: *const Bus) []const u8 { return self.serial[0..self.serial_len]; } + + // Reports the current held buttons to the joypad register. The + // select lines set by software are preserved. + pub fn setJoypadButtons(self: *Bus, held: u8) void { + self.joypad = (self.joypad & 0x30) | joypad.registerValue(held); + } }; diff --git a/src/cpu.zig b/src/cpu.zig index fd32347..7566dd6 100644 --- a/src/cpu.zig +++ b/src/cpu.zig @@ -273,9 +273,18 @@ pub const Cpu = struct { fn setPair(self: *Cpu, pair_index: u2, value: u16) void { switch (pair_index) { - 0 => { self.b = @truncate(value >> 8); self.c = @truncate(value); }, - 1 => { self.d = @truncate(value >> 8); self.e = @truncate(value); }, - 2 => { self.h = @truncate(value >> 8); self.l = @truncate(value); }, + 0 => { + self.b = @truncate(value >> 8); + self.c = @truncate(value); + }, + 1 => { + self.d = @truncate(value >> 8); + self.e = @truncate(value); + }, + 2 => { + self.h = @truncate(value >> 8); + self.l = @truncate(value); + }, else => self.sp = value, } } diff --git a/src/disasm.zig b/src/disasm.zig index 234bc7c..f68d9d9 100644 --- a/src/disasm.zig +++ b/src/disasm.zig @@ -140,7 +140,7 @@ const Writer = struct { } fn putLDIMM8(self: *Writer, address: u16, reg: []const u8) void { - self.putFmt("LDH (${X:0>2}),{s}", .{address & 0xff, reg}); + self.putFmt("LDH (${X:0>2}),{s}", .{ address & 0xff, reg }); } fn putLDA16(self: *Writer, hi: u8, lo: u8) void { @@ -381,22 +381,22 @@ fn instructionLen(opcode: u8) usize { fn cyclesOf(opcode: u8) u8 { const table = [_]u8{ - 4, 12, 8, 8, 4, 4, 8, 4, 20, 8, 8, 8, 4, 4, 8, 4, - 4, 12, 8, 8, 4, 4, 8, 4, 12, 8, 8, 8, 4, 4, 8, 4, - 8, 12, 8, 8, 4, 4, 8, 4, 12, 8, 8, 8, 4, 4, 8, 4, - 8, 12, 8, 8, 12, 12, 12, 4, 12, 8, 8, 8, 4, 4, 8, 4, - 4, 4, 4, 4, 4, 4, 8, 4, 4, 4, 4, 4, 4, 4, 8, 4, - 4, 4, 4, 4, 4, 4, 8, 4, 4, 4, 4, 4, 4, 4, 8, 4, - 8, 8, 8, 8, 8, 8, 4, 8, 4, 4, 4, 4, 4, 4, 8, 4, - 8, 8, 8, 8, 8, 8, 4, 8, 4, 4, 4, 4, 4, 4, 8, 4, - 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, - 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, - 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, - 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, - 12, 12, 8, 4, 16, 16, 8, 16, 20, 16, 16, 4, 16, 24, 8, 16, - 12, 12, 8, 4, 16, 16, 8, 16, 20, 16, 16, 4, 16, 24, 8, 16, - 12, 12, 8, 4, 16, 16, 20, 16, 8, 16, 16, 4, 16, 24, 8, 16, - 12, 12, 8, 4, 16, 16, 4, 16, 8, 16, 16, 4, 16, 24, 8, 16, + 4, 12, 8, 8, 4, 4, 8, 4, 20, 8, 8, 8, 4, 4, 8, 4, + 4, 12, 8, 8, 4, 4, 8, 4, 12, 8, 8, 8, 4, 4, 8, 4, + 8, 12, 8, 8, 4, 4, 8, 4, 12, 8, 8, 8, 4, 4, 8, 4, + 8, 12, 8, 8, 12, 12, 12, 4, 12, 8, 8, 8, 4, 4, 8, 4, + 4, 4, 4, 4, 4, 4, 8, 4, 4, 4, 4, 4, 4, 4, 8, 4, + 4, 4, 4, 4, 4, 4, 8, 4, 4, 4, 4, 4, 4, 4, 8, 4, + 8, 8, 8, 8, 8, 8, 4, 8, 4, 4, 4, 4, 4, 4, 8, 4, + 8, 8, 8, 8, 8, 8, 4, 8, 4, 4, 4, 4, 4, 4, 8, 4, + 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, + 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, + 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, + 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, + 12, 12, 8, 4, 16, 16, 8, 16, 20, 16, 16, 4, 16, 24, 8, 16, + 12, 12, 8, 4, 16, 16, 8, 16, 20, 16, 16, 4, 16, 24, 8, 16, + 12, 12, 8, 4, 16, 16, 20, 16, 8, 16, 16, 4, 16, 24, 8, 16, + 12, 12, 8, 4, 16, 16, 4, 16, 8, 16, 16, 4, 16, 24, 8, 16, }; return table[opcode]; } @@ -419,14 +419,14 @@ test "disassembler formats representative instructions" { const a = formatAt(&bus, 0x0100); try std.testing.expectEqual(@as(usize, 3), a.len); - try std.testing.expectEqualStrings("LD HL,$1234", std.mem.trimRight(u8, &a.text, "\x00")); + try std.testing.expectEqualStrings("LD HL,$1234", std.mem.trimEnd(u8, &a.text, "\x00")); const b = formatAt(&bus, 0x0103); - try std.testing.expectEqualStrings("LD A,$AA", std.mem.trimRight(u8, &b.text, "\x00")); + try std.testing.expectEqualStrings("LD A,$AA", std.mem.trimEnd(u8, &b.text, "\x00")); const c = formatAt(&bus, 0x0105); - try std.testing.expectEqualStrings("RLC C", std.mem.trimRight(u8, &c.text, "\x00")); + try std.testing.expectEqualStrings("RLC C", std.mem.trimEnd(u8, &c.text, "\x00")); const d = formatAt(&bus, 0x0107); - try std.testing.expectEqualStrings("JP $1000", std.mem.trimRight(u8, &d.text, "\x00")); + try std.testing.expectEqualStrings("JP $1000", std.mem.trimEnd(u8, &d.text, "\x00")); } diff --git a/src/emulator.zig b/src/emulator.zig index 4639f75..370d70d 100644 --- a/src/emulator.zig +++ b/src/emulator.zig @@ -41,7 +41,7 @@ pub const Emulator = struct { pub fn runFrame(self: *Emulator, cap: u64) void { var remaining = cap; while (!self.bus.ppu.takeFrame()) { - self.step(); + _ = self.step(); remaining -= 1; if (remaining == 0) return; } diff --git a/src/frontend.zig b/src/frontend.zig new file mode 100644 index 0000000..aef614e --- /dev/null +++ b/src/frontend.zig @@ -0,0 +1,108 @@ +//! SDL2 windowed frontend for dot-matrix-deck. +//! +//! The frontend owns the window, renderer, and texture. Each loop +//! iteration presents one emulated frame and reads the keyboard into a +//! held-buttons bitmask for the joypad. Everything SDL-specific lives +//! here so the emulator core stays portable. + +const std = @import("std"); +const joypad = @import("joypad.zig"); +const ppu = @import("ppu.zig"); + +const c = @cImport({ + @cInclude("SDL2/SDL.h"); +}); + +pub const Frontend = struct { + window: *c.SDL_Window, + renderer: *c.SDL_Renderer, + texture: *c.SDL_Texture, + + pub fn init(title: [:0]const u8) !Frontend { + if (c.SDL_Init(c.SDL_INIT_VIDEO) != 0) return error.SdlInitFailed; + errdefer c.SDL_Quit(); + + const window = c.SDL_CreateWindow( + title, + c.SDL_WINDOWPOS_CENTERED, + c.SDL_WINDOWPOS_CENTERED, + 480, + 432, + c.SDL_WINDOW_RESIZABLE, + ) orelse return error.SdlWindowFailed; + errdefer c.SDL_DestroyWindow(window); + + const renderer = c.SDL_CreateRenderer(window, -1, c.SDL_RENDERER_ACCELERATED) orelse + c.SDL_CreateRenderer(window, -1, c.SDL_RENDERER_SOFTWARE) orelse return error.SdlRendererFailed; + errdefer c.SDL_DestroyRenderer(renderer); + + const texture = c.SDL_CreateTexture( + renderer, + c.SDL_PIXELFORMAT_ARGB8888, + c.SDL_TEXTUREACCESS_STREAMING, + ppu.ScreenWidth, + ppu.ScreenHeight, + ) orelse return error.SdlTextureFailed; + + _ = c.SDL_SetHint(c.SDL_HINT_RENDER_SCALE_QUALITY, "nearest"); + _ = c.SDL_RenderSetLogicalSize(renderer, ppu.ScreenWidth, ppu.ScreenHeight); + _ = c.SDL_RenderSetIntegerScale(renderer, 1); + + return .{ .window = window, .renderer = renderer, .texture = texture }; + } + + pub fn deinit(self: *Frontend) void { + c.SDL_DestroyTexture(self.texture); + c.SDL_DestroyRenderer(self.renderer); + c.SDL_DestroyWindow(self.window); + c.SDL_Quit(); + } + + // Uploads one emulated frame and shows it scaled to the window. + pub fn present(self: *Frontend, frame: []const u32) void { + _ = c.SDL_UpdateTexture(self.texture, null, frame.ptr, @as(c_int, @intCast(ppu.ScreenWidth * @sizeOf(u32)))); + _ = c.SDL_RenderClear(self.renderer); + _ = c.SDL_RenderCopy(self.renderer, self.texture, null, null); + c.SDL_RenderPresent(self.renderer); + } + + // Throttles the loop to roughly 60 frames per second. + pub fn throttle(self: *Frontend) void { + _ = self; + c.SDL_Delay(16); + } + + // Polls the event queue. Returns false when the user closes the + // window or presses Escape. + pub fn poll(self: *Frontend) bool { + _ = self; + var running = true; + var event: c.SDL_Event = undefined; + while (c.SDL_PollEvent(&event) != 0) { + switch (event.type) { + c.SDL_QUIT => running = false, + c.SDL_KEYDOWN => { + if (event.key.keysym.sym == c.SDLK_ESCAPE) running = false; + }, + else => {}, + } + } + return running; + } + + // Reads the keyboard into a held-buttons bitmask for the joypad. + pub fn heldButtons(self: *Frontend) u8 { + _ = self; + const state = c.SDL_GetKeyboardState(null); + var held: u8 = 0; + if (state[@as(usize, @intCast(c.SDL_SCANCODE_LEFT))] != 0) held |= joypad.buttons.left; + if (state[@as(usize, @intCast(c.SDL_SCANCODE_RIGHT))] != 0) held |= joypad.buttons.right; + if (state[@as(usize, @intCast(c.SDL_SCANCODE_UP))] != 0) held |= joypad.buttons.up; + if (state[@as(usize, @intCast(c.SDL_SCANCODE_DOWN))] != 0) held |= joypad.buttons.down; + if (state[@as(usize, @intCast(c.SDL_SCANCODE_Z))] != 0) held |= joypad.buttons.action_a; + if (state[@as(usize, @intCast(c.SDL_SCANCODE_X))] != 0) held |= joypad.buttons.action_b; + if (state[@as(usize, @intCast(c.SDL_SCANCODE_RETURN))] != 0) held |= joypad.buttons.action_start; + if (state[@as(usize, @intCast(c.SDL_SCANCODE_BACKSPACE))] != 0) held |= joypad.buttons.action_select; + return held; + } +}; diff --git a/src/joypad.zig b/src/joypad.zig new file mode 100644 index 0000000..9e2c649 --- /dev/null +++ b/src/joypad.zig @@ -0,0 +1,58 @@ +//! Joypad handling for the dot-matrix-deck frontend. +//! +//! The Game Boy joypad register (FF00) reports two button sets on one +//! low nibble. Bit 4 selects the direction buttons. Bit 5 selects the +//! action buttons. The frontend writes the union of pressed buttons and +//! the bus masks them per the active select line. + +const std = @import("std"); + +pub const buttons = struct { + pub const right: u8 = 0x01; // also A + pub const left: u8 = 0x02; // also B + pub const up: u8 = 0x04; // also Select + pub const down: u8 = 0x08; // also Start + + pub const action_a: u8 = 0x01; + pub const action_b: u8 = 0x02; + pub const action_select: u8 = 0x04; + pub const action_start: u8 = 0x08; +}; + +// Combines a bitmask of held buttons into the joypad register. A set +// bit means the button is held; the register stores the inverse because +// a low bit reports a press. Bits 5-4 (the select lines) are left +// untouched so the caller can preserve what software wrote to FF00. +pub fn registerValue(held: u8) u8 { + return 0xc0 | ((~held) & 0x0f); +} + +// Masks the low nibble by the select lines. Bit 4 low selects the +// direction buttons; bit 5 low selects the action buttons. The result +// is what software sees when it reads FF00. +pub fn readRegister(register: u8) u8 { + const direction = if ((register & 0x10) == 0) register & 0x0f else 0x0f; + const action = if ((register & 0x20) == 0) register & 0x0f else 0x0f; + return 0xc0 | (register & 0x30) | (direction & action); +} + +test "register stores the inverse of held buttons" { + try std.testing.expectEqual(@as(u8, 0xce), registerValue(buttons.right)); + try std.testing.expectEqual(@as(u8, 0xcf), registerValue(0)); + try std.testing.expectEqual(@as(u8, 0xc0), registerValue(0x0f)); +} + +test "read masks by the active select line" { + const held = buttons.action_a | buttons.right; // 0x01 + // Software selects both lines and the right button is held. + const both_selected = registerValue(held); + try std.testing.expectEqual(@as(u8, 0xce), readRegister(both_selected)); + + // Software selects only the direction line (FF00 written with 0x20). + const direction_selected = (0x20 & 0x30) | registerValue(held); + try std.testing.expectEqual(@as(u8, 0xee), readRegister(direction_selected)); + + // Software selects only the action line (FF00 written with 0x10). + const action_selected = (0x10 & 0x30) | registerValue(held); + try std.testing.expectEqual(@as(u8, 0xde), readRegister(action_selected)); +} diff --git a/src/main.zig b/src/main.zig index 643116a..49195c0 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,69 @@ const std = @import("std"); +const Emulator = @import("emulator.zig").Emulator; +const Frontend = @import("frontend.zig").Frontend; -pub fn main() !void { - std.debug.print("Dot Matrix Deck: windowed frontend is under construction.\n", .{}); +fn usage(program: []const u8) void { + std.debug.print( + \\Dot Matrix Deck - windowed frontend + \\Usage: + \\ {s} + \\ + \\Runs a ROM in an SDL2 window. Press Escape or close the window + \\to quit. Arrow keys move; Z, X, Enter and Backspace map to A, + \\B, Start and Select. + \\ + , .{program}); +} + +fn readFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const file = try std.Io.Dir.cwd().openFile(io, path, .{}); + defer file.close(io); + const size = try file.length(io); + const buffer = try allocator.alloc(u8, @intCast(size)); + _ = try file.readPositionalAll(io, buffer, 0); + return buffer; +} + +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; + + var it = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator); + defer it.deinit(); + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(allocator); + while (it.next()) |arg| { + try args.append(allocator, arg); + } + const argv = args.items; + + if (argv.len < 2) { + usage(argv[0]); + std.process.exit(2); + } + + const rom = readFile(io, allocator, argv[1]) catch |err| { + std.debug.print("cannot read ROM: {s}\n", .{@errorName(err)}); + std.process.exit(1); + }; + defer allocator.free(rom); + + var emulator = try Emulator.init(allocator, rom); + defer emulator.deinit(); + + var frontend = Frontend.init("Dot Matrix Deck") catch |err| { + std.debug.print("cannot open SDL2 window: {s}\n", .{@errorName(err)}); + std.process.exit(1); + }; + defer frontend.deinit(); + + var frames: u64 = 0; + while (frontend.poll()) { + emulator.bus.setJoypadButtons(frontend.heldButtons()); + emulator.runFrame(200_000); + frontend.present(emulator.bus.ppu.frame[0..]); + frontend.throttle(); + frames += 1; + } + std.debug.print("Rendered {d} frames\n", .{frames}); } diff --git a/src/sdl_mingw_shim.zig b/src/sdl_mingw_shim.zig new file mode 100644 index 0000000..cf67d04 --- /dev/null +++ b/src/sdl_mingw_shim.zig @@ -0,0 +1,19 @@ +//! Windows-only shim for the msys2/mingw SDL2 dynamic library. +//! +//! The mingw-w64 SDL2.dll imports `ftello64` and `fseeko64` from the +//! Mingw C runtime. Zig links msvcrt.dll, which exports the 64-bit +//! variants as `_ftelli64` and `_fseeki64`. These exports satisfy the +//! DLL imports so the windowed frontend links and runs. + +const FILE = opaque {}; + +extern fn _ftelli64(stream: *FILE) c_longlong; +extern fn _fseeki64(stream: *FILE, offset: c_longlong, whence: c_int) c_int; + +export fn ftello64(stream: *FILE) callconv(.c) c_longlong { + return _ftelli64(stream); +} + +export fn fseeko64(stream: *FILE, offset: c_longlong, whence: c_int) callconv(.c) c_int { + return _fseeki64(stream, offset, whence); +} diff --git a/src/tests.zig b/src/tests.zig new file mode 100644 index 0000000..0b34d9a --- /dev/null +++ b/src/tests.zig @@ -0,0 +1,182 @@ +//! End-to-end tests for the dot-matrix-deck core. +//! +//! These tests assemble small ROMs with gbasm, boot them in the +//! emulator, and check the serial verdict. Each ROM exercises a slice +//! of the full pipeline: CPU, memory bus, timers, serial output, and +//! the pixel pipeline, with no display required. + +const std = @import("std"); +const gbasm = @import("gbasm"); +const Emulator = @import("emulator.zig").Emulator; +const serial = @import("serial.zig"); +const joypad = @import("joypad.zig"); +const ppu = @import("ppu.zig"); + +const boot_and_pass_source = + \\ .org 0x0100 + \\ nop + \\ jp start + \\start: + \\ ld sp, 0xfffe + \\ ld hl, pass_text + \\print_loop: + \\ ld a, (hl) + \\ or a + \\ jr z, idle + \\ ld (0xff01), a + \\ ld a, 0x81 + \\ ld (0xff02), a + \\ inc hl + \\ jr print_loop + \\idle: + \\ jr idle + \\pass_text: + \\ .ascii "PASS" + \\ .db 10, 0 +; + +const render_and_pass_source = + \\ .org 0x0100 + \\ nop + \\ jp start + \\start: + \\ ld sp, 0xfffe + \\ ; Draw a solid tile into VRAM. + \\ ld hl, 0x8000 + \\ ld b, 16 + \\tile_loop: + \\ ld (hl), 0xff + \\ inc hl + \\ dec b + \\ jr nz, tile_loop + \\ ; Wait a few frames so the pixel pipeline renders. + \\ ld b, 10 + \\frame_wait: + \\ call wait_vblank + \\ dec b + \\ jr nz, frame_wait + \\ ; Print PASS over the link port. + \\ ld hl, pass_text + \\print_loop: + \\ ld a, (hl) + \\ or a + \\ jr z, idle + \\ ld (0xff01), a + \\ ld a, 0x81 + \\ ld (0xff02), a + \\ inc hl + \\ jr print_loop + \\idle: + \\ jr idle + \\wait_vblank: + \\ ld a, (0xff44) + \\ cp 144 + \\ jr c, wait_vblank + \\wait_active: + \\ ld a, (0xff44) + \\ cp 144 + \\ jr nc, wait_active + \\ ret + \\pass_text: + \\ .ascii "PASS" + \\ .db 10, 0 +; + +const joypad_source = + \\ .org 0x0100 + \\ nop + \\ jp start + \\start: + \\ ld sp, 0xfffe + \\ xor a + \\ ld (0xff00), a + \\ ld a, (0xff00) + \\ and 0x0f + \\ ld (0xff01), a + \\ ld a, 0x81 + \\ ld (0xff02), a + \\idle: + \\ jr idle +; + +fn assemble(source: []const u8) ![]u8 { + var diagnostics = std.ArrayList(gbasm.Diagnostics).empty; + defer diagnostics.deinit(std.testing.allocator); + return gbasm.assemble(std.testing.allocator, source, &diagnostics) catch |err| { + for (diagnostics.items) |d| { + std.debug.print("assemble line {d}: {s}\n", .{ d.line, d.message }); + } + return err; + }; +} + +// Runs the ROM until a serial verdict settles. Returns the verdict. +fn runToVerdict(image: []const u8) !serial.Verdict { + var emulator = try Emulator.init(std.testing.allocator, image); + defer emulator.deinit(); + + var last_len: usize = 0; + var silent: u64 = 0; + var instructions: u64 = 0; + const limit: u64 = 20_000_000; + while (instructions < limit) : (instructions += 1) { + _ = emulator.step(); + const output = emulator.serialOutput(); + if (output.len != last_len) { + last_len = output.len; + silent = 0; + } else { + silent += 1; + } + const verdict = serial.verdictOf(output); + if (verdict != .pending and silent > 50_000) return verdict; + } + return .pending; +} + +test "boot ROM reports PASS" { + const image = try assemble(boot_and_pass_source); + defer std.testing.allocator.free(image); + try std.testing.expectEqual(serial.Verdict.pass, try runToVerdict(image)); +} + +test "rendered ROM reports PASS" { + const image = try assemble(render_and_pass_source); + defer std.testing.allocator.free(image); + try std.testing.expectEqual(serial.Verdict.pass, try runToVerdict(image)); +} + +test "pixel pipeline produces a frame with a lit tile" { + const image = try assemble(render_and_pass_source); + defer std.testing.allocator.free(image); + + var emulator = try Emulator.init(std.testing.allocator, image); + defer emulator.deinit(); + + // Advance past the boot and tile write, into the render loop. + emulator.runFrame(1_000_000); + var any_dark = false; + for (emulator.bus.ppu.frame) |pixel| { + if (pixel != 0xfff8f8f0) { + any_dark = true; + break; + } + } + try std.testing.expect(any_dark); + try std.testing.expectEqual(@as(usize, 160), ppu.ScreenWidth); + try std.testing.expectEqual(@as(usize, 144), ppu.ScreenHeight); +} + +test "joypad reads flow through the bus" { + const image = try assemble(joypad_source); + defer std.testing.allocator.free(image); + + var emulator = try Emulator.init(std.testing.allocator, image); + defer emulator.deinit(); + emulator.bus.setJoypadButtons(joypad.buttons.action_a | joypad.buttons.right); + + emulator.runInstructions(40); + const output = emulator.serialOutput(); + try std.testing.expect(output.len != 0); + try std.testing.expectEqual(@as(u8, 0x0e), output[0]); +} diff --git a/tools/gbasm.zig b/tools/gbasm.zig index e81a221..642a5cc 100644 --- a/tools/gbasm.zig +++ b/tools/gbasm.zig @@ -1,5 +1,1400 @@ +//! gbasm - a small assembler for the SM83. +//! +//! It turns line-oriented SM83 assembly into Game Boy ROM bytes. The +//! output is deterministic, so generated fixtures never change between +//! runs. The same code powers the CLI and the deterministic tests. + const std = @import("std"); -pub fn main() !void { - std.debug.print("gbasm: assembler is under construction.\n", .{}); +pub const Diagnostics = struct { + line: usize, + message: []const u8, +}; + +pub const AssemblyError = error{ + OutOfMemory, + AssemblyFailed, + IoFailed, +}; + +const Reg = enum(u8) { + b = 0, + c, + d, + e, + h, + l, + mhl, // (HL) + a, +}; + +const Pair = enum(u8) { + bc = 0, + de, + hl, + sp, +}; + +const Cond = enum(u8) { + nz = 0, + z, + nc, + c, +}; + +const TokenKind = enum { + ident, + number, + string, + comma, + colon, + plus, + minus, + lparen, + rparen, + eol, +}; + +const Token = struct { + kind: TokenKind, + text: []const u8, +}; + +const Line = struct { + number: usize, + tokens: []Token, +}; + +fn isIdentStart(ch: u8) bool { + return std.ascii.isAlphabetic(ch) or ch == '_' or ch == '.'; +} + +fn isIdentChar(ch: u8) bool { + return std.ascii.isAlphanumeric(ch) or ch == '_' or ch == '.'; +} + +fn isHex(ch: u8) bool { + return std.ascii.isHex(ch); +} + +// Splits the source into lines of tokens. Comments start with ';'. +// String literals support \\, \", \n, \t and \xNN escapes. +fn tokenize(allocator: std.mem.Allocator, source: []const u8, diagnostics: *std.ArrayList(Diagnostics)) AssemblyError![]Line { + var lines = std.ArrayList(Line).empty; + errdefer { + for (lines.items) |line| allocator.free(line.tokens); + lines.deinit(allocator); + } + + var it = std.mem.splitScalar(u8, source, '\n'); + var line_no: usize = 1; + while (it.next()) |raw| { + defer line_no += 1; + var tokens = std.ArrayList(Token).empty; + errdefer tokens.deinit(allocator); + + var i: usize = 0; + var ok = true; + while (i < raw.len) { + const ch = raw[i]; + if (ch == ';') break; + if (ch == ' ' or ch == '\t' or ch == '\r') { + i += 1; + continue; + } + if (isIdentStart(ch)) { + var end = i + 1; + while (end < raw.len and isIdentChar(raw[end])) end += 1; + try tokens.append(allocator, .{ .kind = .ident, .text = raw[i..end] }); + i = end; + } else if (std.ascii.isDigit(ch)) { + var end = i + 1; + while (end < raw.len and std.ascii.isAlphanumeric(raw[end])) end += 1; + try tokens.append(allocator, .{ .kind = .number, .text = raw[i..end] }); + i = end; + } else if (ch == '$' or ch == '0') { + // Handle $-prefixed hex and 0x/0b prefixes. + var end = i; + if (ch == '$') { + end = i + 1; + while (end < raw.len and isHex(raw[end])) end += 1; + try tokens.append(allocator, .{ .kind = .number, .text = raw[i..end] }); + } else { + end = i + 1; + if (end < raw.len and (raw[end] == 'x' or raw[end] == 'X' or raw[end] == 'b' or raw[end] == 'B')) { + end += 1; + while (end < raw.len and std.ascii.isAlphanumeric(raw[end])) end += 1; + try tokens.append(allocator, .{ .kind = .number, .text = raw[i..end] }); + } else { + while (end < raw.len and std.ascii.isDigit(raw[end])) end += 1; + try tokens.append(allocator, .{ .kind = .number, .text = raw[i..end] }); + } + } + i = end; + } else if (ch == '"') { + var text = std.ArrayList(u8).empty; + errdefer text.deinit(allocator); + i += 1; + var closed = false; + while (i < raw.len) { + const c = raw[i]; + if (c == '"') { + closed = true; + i += 1; + break; + } else if (c == '\\') { + i += 1; + if (i >= raw.len) break; + const esc = raw[i]; + switch (esc) { + 'n' => try text.append(allocator, '\n'), + 't' => try text.append(allocator, '\t'), + '\\' => try text.append(allocator, '\\'), + '"' => try text.append(allocator, '"'), + 'x' => { + if (i + 2 < raw.len and isHex(raw[i + 1]) and isHex(raw[i + 2])) { + try text.append(allocator, std.fmt.parseInt(u8, raw[i + 1 .. i + 3], 16) catch 0); + i += 2; + } else { + try text.append(allocator, 'x'); + } + }, + else => try text.append(allocator, esc), + } + i += 1; + } else { + try text.append(allocator, c); + i += 1; + } + } + if (!closed) { + try diagnostics.append(allocator, .{ .line = line_no, .message = "unterminated string" }); + ok = false; + break; + } + try tokens.append(allocator, .{ .kind = .string, .text = try text.toOwnedSlice(allocator) }); + } else { + const kind: TokenKind = switch (ch) { + ',' => .comma, + ':' => .colon, + '+' => .plus, + '-' => .minus, + '(' => .lparen, + ')' => .rparen, + else => { + try diagnostics.append(allocator, .{ .line = line_no, .message = "unexpected character" }); + ok = false; + break; + }, + }; + try tokens.append(allocator, .{ .kind = kind, .text = raw[i .. i + 1] }); + i += 1; + } + } + + try tokens.append(allocator, .{ .kind = .eol, .text = "" }); + try lines.append(allocator, .{ .number = line_no, .tokens = try tokens.toOwnedSlice(allocator) }); + if (!ok) return error.AssemblyFailed; + } + return lines.toOwnedSlice(allocator); +} + +fn freeLines(allocator: std.mem.Allocator, lines: []Line) void { + for (lines) |line| { + for (line.tokens) |token| { + if (token.kind == .string) allocator.free(token.text); + } + allocator.free(line.tokens); + } + allocator.free(lines); +} + +fn diag(diagnostics: *std.ArrayList(Diagnostics), allocator: std.mem.Allocator, line: usize, message: []const u8) AssemblyError { + diagnostics.append(allocator, .{ .line = line, .message = message }) catch return error.OutOfMemory; + return error.AssemblyFailed; +} + +const LabelTable = struct { + map: std.StringHashMap(u16), + allocator: std.mem.Allocator, + // In pass 1 expressions resolve to zero so forward labels do not + // need to be defined yet. Sizes stay correct because every + // instruction has a fixed length. + dummy: bool = false, + + fn init(allocator: std.mem.Allocator) LabelTable { + return .{ .map = std.StringHashMap(u16).init(allocator), .allocator = allocator }; + } + + fn deinit(self: *LabelTable) void { + var it = self.map.keyIterator(); + while (it.next()) |key| self.allocator.free(key.*); + self.map.deinit(); + } + + fn define(self: *LabelTable, name: []const u8, value: u16, line: usize, diagnostics: *std.ArrayList(Diagnostics)) AssemblyError!void { + if (self.map.get(name) != null) return diag(diagnostics, self.allocator, line, "duplicate label"); + const owned = try self.allocator.dupe(u8, name); + errdefer self.allocator.free(owned); + try self.map.put(owned, value); + } + + fn lookup(self: *const LabelTable, name: []const u8, line: usize, diagnostics: *std.ArrayList(Diagnostics)) AssemblyError!u16 { + if (self.dummy) return 0; + if (self.map.get(name)) |value| return value; + return diag(diagnostics, self.allocator, line, "unknown label"); + } +}; + +// Parses a number literal or a label name. +fn parseExprValue(tokens: []Token, i: *usize, labels: *const LabelTable, allocator: std.mem.Allocator, line: usize, diagnostics: *std.ArrayList(Diagnostics)) AssemblyError!u16 { + const token = tokens[i.*]; + switch (token.kind) { + .number => { + i.* += 1; + return parseNumber(token.text, allocator, line, diagnostics); + }, + .ident => { + i.* += 1; + return labels.lookup(token.text, line, diagnostics); + }, + else => return diag(diagnostics, allocator, line, "expected a value"), + } +} + +fn parseNumber(text: []const u8, allocator: std.mem.Allocator, line: usize, diagnostics: *std.ArrayList(Diagnostics)) AssemblyError!u16 { + if (text.len >= 2 and text[0] == '$') { + return std.fmt.parseInt(u16, text[1..], 16) catch return diag(diagnostics, allocator, line, "bad hex value"); + } + if (text.len >= 3 and (text[0] == '0' and (text[1] == 'x' or text[1] == 'X'))) { + return std.fmt.parseInt(u16, text[2..], 16) catch return diag(diagnostics, allocator, line, "bad hex value"); + } + if (text.len >= 3 and (text[0] == '0' and (text[1] == 'b' or text[1] == 'B'))) { + return std.fmt.parseInt(u16, text[2..], 2) catch return diag(diagnostics, allocator, line, "bad binary value"); + } + return std.fmt.parseInt(u16, text, 10) catch return diag(diagnostics, allocator, line, "bad number"); +} + +fn parseSignedValue(tokens: []Token, i: *usize, labels: *const LabelTable, allocator: std.mem.Allocator, line: usize, diagnostics: *std.ArrayList(Diagnostics)) AssemblyError!i16 { + var sign: i16 = 1; + if (i.* < tokens.len and tokens[i.*].kind == .minus) { + sign = -1; + i.* += 1; + } else if (i.* < tokens.len and tokens[i.*].kind == .plus) { + i.* += 1; + } + const value = try parseExprValue(tokens, i, labels, allocator, line, diagnostics); + return sign * @as(i16, @intCast(value)); +} + +fn parseReg(tokens: []Token, i: *usize) ?Reg { + if (tokens[i.*].kind != .ident) return null; + const name = tokens[i.*].text; + const reg: Reg = if (std.mem.eql(u8, name, "a")) .a else if (std.mem.eql(u8, name, "b")) .b else if (std.mem.eql(u8, name, "c")) .c else if (std.mem.eql(u8, name, "d")) .d else if (std.mem.eql(u8, name, "e")) .e else if (std.mem.eql(u8, name, "h")) .h else if (std.mem.eql(u8, name, "l")) .l else return null; + i.* += 1; + return reg; +} + +// Recognises "(reg)" where reg is bc, de, hl, (hl), c. +const ParenReg = enum { bc, de, hl_inc, hl_dec, hl, c, none }; + +fn parseParen(tokens: []Token, i: *usize) ParenReg { + if (tokens[i.*].kind != .lparen) return .none; + const save = i.*; + i.* += 1; + if (tokens[i.*].kind == .ident) { + const name = tokens[i.*].text; + if (std.mem.eql(u8, name, "bc") or std.mem.eql(u8, name, "BC")) { + i.* += 1; + } else if (std.mem.eql(u8, name, "de") or std.mem.eql(u8, name, "DE")) { + i.* += 1; + if (match(i, tokens, .rparen)) { + i.* += 1; + return .de; + } + i.* = save; + return .none; + } else if (std.mem.eql(u8, name, "hl") or std.mem.eql(u8, name, "HL")) { + i.* += 1; + if (match(i, tokens, .plus)) { + i.* += 1; + if (match(i, tokens, .rparen)) { + i.* += 1; + return .hl_inc; + } + i.* = save; + return .none; + } + if (match(i, tokens, .minus)) { + i.* += 1; + if (match(i, tokens, .rparen)) { + i.* += 1; + return .hl_dec; + } + i.* = save; + return .none; + } + if (match(i, tokens, .rparen)) { + i.* += 1; + return .hl; + } + i.* = save; + return .none; + } else if (std.mem.eql(u8, name, "c") or std.mem.eql(u8, name, "C")) { + i.* += 1; + if (match(i, tokens, .rparen)) { + i.* += 1; + return .c; + } + i.* = save; + return .none; + } + if (match(i, tokens, .rparen)) { + i.* += 1; + return .bc; + } + i.* = save; + return .none; + } + i.* = save; + return .none; +} + +fn match(i: *usize, tokens: []Token, kind: TokenKind) bool { + return i.* < tokens.len and tokens[i.*].kind == kind; +} + +fn isIdentAt(tokens: []Token, index: usize, name: []const u8) bool { + return index < tokens.len and tokens[index].kind == .ident and std.ascii.eqlIgnoreCase(tokens[index].text, name); +} + +fn matchIdent(tokens: []Token, i: *usize, name: []const u8) bool { + return tokens[i.*].kind == .ident and (std.mem.eql(u8, tokens[i.*].text, name) or std.ascii.eqlIgnoreCase(tokens[i.*].text, name)); +} + +fn opcode(value: u8, bytes: *std.ArrayList(u8), allocator: std.mem.Allocator) AssemblyError!void { + try bytes.append(allocator, value); +} + +fn encode8(value: u8, bytes: *std.ArrayList(u8), allocator: std.mem.Allocator) AssemblyError!void { + try bytes.append(allocator, value); +} + +fn encode16(value: u16, bytes: *std.ArrayList(u8), allocator: std.mem.Allocator) AssemblyError!void { + try bytes.append(allocator, @intCast(value & 0xff)); + try bytes.append(allocator, @intCast(value >> 8)); +} + +// Emits one line of assembly into `bytes`. Returns the number of bytes +// emitted so the caller can advance the program counter. When +// `resolve_labels` is false, expressions return 0; sizes stay correct +// because every instruction has a fixed length. +fn emitLine( + tokens: []Token, + line: usize, + address: u16, + labels: *LabelTable, + resolve_labels: bool, + bytes: *std.ArrayList(u8), + allocator: std.mem.Allocator, + diagnostics: *std.ArrayList(Diagnostics), +) AssemblyError!usize { + var i: usize = 0; + + // A label occupies the leading position and may share the line. + if (tokens[i].kind == .ident and tokens[i + 1].kind == .colon) { + if (!resolve_labels) try labels.define(tokens[i].text, address, line, diagnostics); + i += 2; + } + + const start = bytes.items.len; + if (tokens[i].kind == .eol) return 0; + if (tokens[i].kind == .ident) { + const name = tokens[i].text; + if (name.len != 0 and name[0] == '.') { + return emitDirective(tokens, line, address, labels, resolve_labels, bytes, allocator, diagnostics); + } + } + + const mnemonic = tokens[i].text; + if (tokens[i].kind != .ident) return diag(diagnostics, allocator, line, "expected a mnemonic"); + i += 1; + + try emitInstruction(tokens, &i, line, address, labels, resolve_labels, bytes, allocator, diagnostics, mnemonic); + if (tokens[i].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after instruction"); + return bytes.items.len - start; +} + +fn emitDirective( + tokens: []Token, + line: usize, + address: u16, + labels: *const LabelTable, + resolve_labels: bool, + bytes: *std.ArrayList(u8), + allocator: std.mem.Allocator, + diagnostics: *std.ArrayList(Diagnostics), +) AssemblyError!usize { + const name = tokens[0].text; + var i: usize = 1; + + if (std.mem.eql(u8, name, ".org")) { + const value = try parseExprValue(tokens, &i, labels, allocator, line, diagnostics); + if (tokens[i].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after .org"); + const target: usize = value; + if (target > 0x8000) return diag(diagnostics, allocator, line, ".org target is out of range"); + if (target < @as(usize, address)) return diag(diagnostics, allocator, line, ".org target overlaps emitted bytes"); + if (resolve_labels) { + while (bytes.items.len < target) try bytes.append(allocator, 0x00); + } + return target - @as(usize, address); + } + + if (std.mem.eql(u8, name, ".db")) { + var count: usize = 0; + while (true) { + const value = try parseExprValue(tokens, &i, labels, allocator, line, diagnostics); + if (value > 0xff) return diag(diagnostics, allocator, line, "byte value out of range"); + if (resolve_labels) try bytes.append(allocator, @intCast(value & 0xff)); + count += 1; + if (!match(&i, tokens, .comma)) break; + i += 1; + } + if (tokens[i].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after .db"); + return count; + } + + if (std.mem.eql(u8, name, ".dw")) { + var count: usize = 0; + while (true) { + const value = try parseExprValue(tokens, &i, labels, allocator, line, diagnostics); + if (resolve_labels) try encode16(value, bytes, allocator); + count += 2; + if (!match(&i, tokens, .comma)) break; + i += 1; + } + if (tokens[i].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after .dw"); + return count; + } + + if (std.mem.eql(u8, name, ".ascii")) { + var count: usize = 0; + while (true) { + if (tokens[i].kind != .string) return diag(diagnostics, allocator, line, "expected a string after .ascii"); + if (resolve_labels) { + for (tokens[i].text) |ch| try bytes.append(allocator, ch); + } + count += tokens[i].text.len; + i += 1; + if (!match(&i, tokens, .comma)) break; + i += 1; + } + if (tokens[i].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after .ascii"); + return count; + } + + return diag(diagnostics, allocator, line, "unknown directive"); +} + +const ALU = enum(u8) { + add = 0, + adc, + sub, + sbc, + and_op, + xor_op, + or_op, + cp, +}; + +fn parseAlu(name: []const u8) ?ALU { + if (std.ascii.eqlIgnoreCase(name, "ADD")) return .add; + if (std.ascii.eqlIgnoreCase(name, "ADC")) return .adc; + if (std.ascii.eqlIgnoreCase(name, "SUB")) return .sub; + if (std.ascii.eqlIgnoreCase(name, "SBC")) return .sbc; + if (std.ascii.eqlIgnoreCase(name, "AND")) return .and_op; + if (std.ascii.eqlIgnoreCase(name, "XOR")) return .xor_op; + if (std.ascii.eqlIgnoreCase(name, "OR")) return .or_op; + if (std.ascii.eqlIgnoreCase(name, "CP")) return .cp; + return null; +} + +fn parseCond(name: []const u8) ?Cond { + if (std.ascii.eqlIgnoreCase(name, "NZ")) return .nz; + if (std.ascii.eqlIgnoreCase(name, "Z")) return .z; + if (std.ascii.eqlIgnoreCase(name, "NC")) return .nc; + if (std.ascii.eqlIgnoreCase(name, "C")) return .c; + return null; +} + +fn emitInstruction( + tokens: []Token, + i: *usize, + line: usize, + address: u16, + labels: *const LabelTable, + resolve_labels: bool, + bytes: *std.ArrayList(u8), + allocator: std.mem.Allocator, + diagnostics: *std.ArrayList(Diagnostics), + mnemonic: []const u8, +) AssemblyError!void { + if (std.ascii.eqlIgnoreCase(mnemonic, "NOP")) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "NOP takes no operands"); + try opcode(0x00, bytes, allocator); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "HALT")) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "HALT takes no operands"); + try opcode(0x76, bytes, allocator); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "STOP")) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "STOP takes no operands"); + try opcode(0x10, bytes, allocator); + try opcode(0x00, bytes, allocator); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "DI")) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "DI takes no operands"); + try opcode(0xf3, bytes, allocator); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "EI")) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "EI takes no operands"); + try opcode(0xfb, bytes, allocator); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "DAA")) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "DAA takes no operands"); + try opcode(0x27, bytes, allocator); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "CPL")) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "CPL takes no operands"); + try opcode(0x2f, bytes, allocator); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "SCF")) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "SCF takes no operands"); + try opcode(0x37, bytes, allocator); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "CCF")) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "CCF takes no operands"); + try opcode(0x3f, bytes, allocator); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "RLCA")) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "RLCA takes no operands"); + try opcode(0x07, bytes, allocator); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "RLA")) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "RLA takes no operands"); + try opcode(0x17, bytes, allocator); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "RRCA")) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "RRCA takes no operands"); + try opcode(0x0f, bytes, allocator); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "RRA")) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "RRA takes no operands"); + try opcode(0x1f, bytes, allocator); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "RET")) { + const cond = if (tokens[i.*].kind == .ident) parseCond(tokens[i.*].text) else null; + if (cond) |c| { + i.* += 1; + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after RET"); + try opcode(@intFromEnum(c) * 8 + 0xc0, bytes, allocator); + } else { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after RET"); + try opcode(0xc9, bytes, allocator); + } + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "RETI")) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "RETI takes no operands"); + try opcode(0xd9, bytes, allocator); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "PUSH") or std.ascii.eqlIgnoreCase(mnemonic, "POP")) { + const pair = parsePushPopPair(tokens[i.*]) orelse return diag(diagnostics, allocator, line, "expected a register pair"); + if (tokens[i.* + 1].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after PUSH/POP"); + i.* += 1; + const base: u8 = if (std.ascii.eqlIgnoreCase(mnemonic, "PUSH")) 0xc5 else 0xc1; + try opcode(base | @as(u8, @intFromEnum(pair)) << 4, bytes, allocator); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "INC") or std.ascii.eqlIgnoreCase(mnemonic, "DEC")) { + const base: u8 = if (std.ascii.eqlIgnoreCase(mnemonic, "INC")) 0x04 else 0x05; + if (parseParen(tokens, i) == .hl) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after INC/DEC (HL)"); + try opcode(base | 0x30, bytes, allocator); + return; + } + if (parseParen(tokens, i) != .none) return diag(diagnostics, allocator, line, "unsupported indirect operand"); + if (parseReg(tokens, i)) |reg| { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after INC/DEC"); + try opcode(base | @as(u8, @intFromEnum(reg)) << 3, bytes, allocator); + return; + } + if (parsePair(tokens[i.*])) |pair| { + i.* += 1; + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after INC/DEC pair"); + const op: u8 = if (std.ascii.eqlIgnoreCase(mnemonic, "INC")) 0x03 else 0x0b; + try opcode(op | @as(u8, @intFromEnum(pair)) << 4, bytes, allocator); + return; + } + return diag(diagnostics, allocator, line, "expected a register, (HL), or register pair"); + } + if (parseAlu(mnemonic)) |alu| { + return emitAlu(tokens, i, line, labels, resolve_labels, bytes, allocator, diagnostics, alu); + } + if (std.ascii.eqlIgnoreCase(mnemonic, "JP")) { + // JP (HL) + if (parseParen(tokens, i) == .hl) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after JP (HL)"); + try opcode(0xe9, bytes, allocator); + return; + } + const cond = if (tokens[i.*].kind == .ident) parseCond(tokens[i.*].text) else null; + var base: u8 = 0xc3; + if (cond) |c| { + base = @intFromEnum(c) * 8 + 0xc2; + i.* += 1; + if (tokens[i.*].kind != .comma) return diag(diagnostics, allocator, line, "expected a comma after JP condition"); + i.* += 1; + } + const target = try parseExprValue(tokens, i, labels, allocator, line, diagnostics); + try opcode(base, bytes, allocator); + try encode16(target, bytes, allocator); + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after JP"); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "JR")) { + const cond = if (tokens[i.*].kind == .ident) parseCond(tokens[i.*].text) else null; + var base: u8 = 0x18; + if (cond) |c| { + base = @intFromEnum(c) * 8 + 0x20; + i.* += 1; + if (tokens[i.*].kind != .comma) return diag(diagnostics, allocator, line, "expected a comma after JR condition"); + i.* += 1; + } + const target = try parseExprValue(tokens, i, labels, allocator, line, diagnostics); + if (resolve_labels) { + const here = @as(u16, address +% 2); + const offset: i32 = @as(i32, target) - @as(i32, here); + if (offset < -128 or offset > 127) return diag(diagnostics, allocator, line, "JR offset out of range"); + try opcode(base, bytes, allocator); + try opcode(@as(u8, @bitCast(@as(i8, @intCast(offset)))), bytes, allocator); + } else { + try opcode(base, bytes, allocator); + try opcode(0x00, bytes, allocator); + } + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after JR"); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "CALL")) { + const cond = if (tokens[i.*].kind == .ident) parseCond(tokens[i.*].text) else null; + var base: u8 = 0xcd; + if (cond) |c| { + base = @intFromEnum(c) * 8 + 0xc4; + i.* += 1; + if (tokens[i.*].kind != .comma) return diag(diagnostics, allocator, line, "expected a comma after CALL condition"); + i.* += 1; + } + const target = try parseExprValue(tokens, i, labels, allocator, line, diagnostics); + try opcode(base, bytes, allocator); + try encode16(target, bytes, allocator); + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after CALL"); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "RST")) { + const value = try parseExprValue(tokens, i, labels, allocator, line, diagnostics); + if (value > 0x38 or (value & 7) != 0) return diag(diagnostics, allocator, line, "RST target must be 0x00-0x38 step 8"); + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after RST"); + try opcode(0xc7 + @as(u8, @intCast(value)), bytes, allocator); + return; + } + if (std.ascii.eqlIgnoreCase(mnemonic, "LD")) { + return emitLd(tokens, i, line, labels, resolve_labels, bytes, allocator, diagnostics); + } + if (std.ascii.eqlIgnoreCase(mnemonic, "LDH")) { + return emitLdh(tokens, i, line, labels, resolve_labels, bytes, allocator, diagnostics); + } + if (std.ascii.eqlIgnoreCase(mnemonic, "RLC") or std.ascii.eqlIgnoreCase(mnemonic, "RRC") or std.ascii.eqlIgnoreCase(mnemonic, "RL") or std.ascii.eqlIgnoreCase(mnemonic, "RR") or std.ascii.eqlIgnoreCase(mnemonic, "SLA") or std.ascii.eqlIgnoreCase(mnemonic, "SRA") or std.ascii.eqlIgnoreCase(mnemonic, "SWAP") or std.ascii.eqlIgnoreCase(mnemonic, "SRL")) { + return emitCbRotate(tokens, i, line, mnemonic, resolve_labels, bytes, allocator, diagnostics); + } + if (std.ascii.eqlIgnoreCase(mnemonic, "BIT") or std.ascii.eqlIgnoreCase(mnemonic, "RES") or std.ascii.eqlIgnoreCase(mnemonic, "SET")) { + return emitCbBit(tokens, i, line, mnemonic, labels, resolve_labels, bytes, allocator, diagnostics); + } + + return diag(diagnostics, allocator, line, "unknown mnemonic"); +} + +fn parsePair(token: Token) ?Pair { + if (token.kind != .ident) return null; + if (std.ascii.eqlIgnoreCase(token.text, "BC")) return .bc; + if (std.ascii.eqlIgnoreCase(token.text, "DE")) return .de; + if (std.ascii.eqlIgnoreCase(token.text, "HL")) return .hl; + if (std.ascii.eqlIgnoreCase(token.text, "SP")) return .sp; + return null; +} + +// PUSH/POP also accept AF, which shares the SP encoding. +fn parsePushPopPair(token: Token) ?Pair { + if (token.kind == .ident and std.ascii.eqlIgnoreCase(token.text, "AF")) return .sp; + return parsePair(token); +} + +fn parseExprZero(tokens: []Token, i: *usize, labels: *const LabelTable, line: usize, diagnostics: *std.ArrayList(Diagnostics)) AssemblyError!u16 { + _ = labels; + _ = line; + _ = diagnostics; + _ = tokens; + i.* += 1; + return 0; +} + +const ParenOperand = union(enum) { + c, + bc, + de, + hl_inc, + hl_dec, + hl, + ldh: u16, // (ff00+n) + abs: u16, // (nn) +}; + +// Parses a parenthesised operand. Register forms and the FF00+n form +// are recognised by name; anything else is an absolute 16-bit address. +fn parseParenOperand( + tokens: []Token, + i: *usize, + labels: *const LabelTable, + allocator: std.mem.Allocator, + line: usize, + diagnostics: *std.ArrayList(Diagnostics), +) AssemblyError!ParenOperand { + if (tokens[i.*].kind != .lparen) return diag(diagnostics, allocator, line, "expected '('"); + i.* += 1; + + if (tokens[i.*].kind == .ident) { + const name = tokens[i.*].text; + if (std.ascii.eqlIgnoreCase(name, "ff00")) { + i.* += 1; + const sign = if (match(i, tokens, .plus)) @as(i16, 1) else if (match(i, tokens, .minus)) @as(i16, -1) else return diag(diagnostics, allocator, line, "expected + or - after FF00"); + i.* += 1; + const offset = try parseExprValue(tokens, i, labels, allocator, line, diagnostics); + const total = 0xff00 +% @as(u16, @intCast(@mod(sign * @as(i16, @intCast(offset)), 0x100))); + if (!match(i, tokens, .rparen)) return diag(diagnostics, allocator, line, "expected ')'"); + i.* += 1; + return .{ .ldh = total & 0xff }; + } + if (std.ascii.eqlIgnoreCase(name, "bc")) { + i.* += 1; + if (!match(i, tokens, .rparen)) return diag(diagnostics, allocator, line, "expected ')'"); + i.* += 1; + return .bc; + } + if (std.ascii.eqlIgnoreCase(name, "de")) { + i.* += 1; + if (!match(i, tokens, .rparen)) return diag(diagnostics, allocator, line, "expected ')'"); + i.* += 1; + return .de; + } + if (std.ascii.eqlIgnoreCase(name, "c")) { + i.* += 1; + if (!match(i, tokens, .rparen)) return diag(diagnostics, allocator, line, "expected ')'"); + i.* += 1; + return .c; + } + if (std.ascii.eqlIgnoreCase(name, "hl")) { + i.* += 1; + if (match(i, tokens, .plus)) { + i.* += 1; + if (!match(i, tokens, .rparen)) return diag(diagnostics, allocator, line, "expected ')'"); + i.* += 1; + return .hl_inc; + } + if (match(i, tokens, .minus)) { + i.* += 1; + if (!match(i, tokens, .rparen)) return diag(diagnostics, allocator, line, "expected ')'"); + i.* += 1; + return .hl_dec; + } + if (!match(i, tokens, .rparen)) return diag(diagnostics, allocator, line, "expected ')'"); + i.* += 1; + return .hl; + } + } + + // Any other token is an absolute address. + const target = try parseExprValue(tokens, i, labels, allocator, line, diagnostics); + if (!match(i, tokens, .rparen)) return diag(diagnostics, allocator, line, "expected ')'"); + i.* += 1; + return .{ .abs = target }; +} + +fn emitAlu( + tokens: []Token, + i: *usize, + line: usize, + labels: *const LabelTable, + resolve_labels: bool, + bytes: *std.ArrayList(u8), + allocator: std.mem.Allocator, + diagnostics: *std.ArrayList(Diagnostics), + alu: ALU, +) AssemblyError!void { + _ = resolve_labels; + const base: u8 = @intFromEnum(alu) << 3; + + // 16-bit ADD forms: ADD HL,rr and ADD SP,o. + if (alu == .add) { + if (parsePair(tokens[i.*])) |left| { + if (left == .sp or left == .hl) { + i.* += 1; + if (tokens[i.*].kind != .comma) return diag(diagnostics, allocator, line, "expected a comma"); + i.* += 1; + if (left == .hl) { + const right = parsePair(tokens[i.*]) orelse return diag(diagnostics, allocator, line, "expected a register pair"); + i.* += 1; + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after ADD HL,rr"); + try opcode(0x09 | @as(u8, @intFromEnum(right)) << 4, bytes, allocator); + return; + } + const offset = try parseSignedValue(tokens, i, labels, allocator, line, diagnostics); + if (offset < -128 or offset > 127) return diag(diagnostics, allocator, line, "offset out of range"); + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after ADD SP,o"); + try opcode(0xe8, bytes, allocator); + try encode8(@as(u8, @bitCast(@as(i8, @intCast(offset)))), bytes, allocator); + return; + } + } + } + + // Allow the redundant "A," prefix, e.g. "ADD A,B". + if (matchIdent(tokens, i, "A") and tokens[i.* + 1].kind == .comma) { + i.* += 2; + } + if (parseReg(tokens, i)) |reg| { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after ALU"); + try opcode(0x80 | base | @as(u8, @intFromEnum(reg)), bytes, allocator); + return; + } + if (parseParen(tokens, i) == .hl) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after ALU"); + try opcode(0x80 | base | 6, bytes, allocator); + return; + } + const value = try parseExprValue(tokens, i, labels, allocator, line, diagnostics); + if (value > 0xff) return diag(diagnostics, allocator, line, "immediate out of range"); + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after ALU"); + try opcode(0xc6 | base, bytes, allocator); + try encode8(@intCast(value & 0xff), bytes, allocator); +} + +fn emitLd( + tokens: []Token, + i: *usize, + line: usize, + labels: *const LabelTable, + resolve_labels: bool, + bytes: *std.ArrayList(u8), + allocator: std.mem.Allocator, + diagnostics: *std.ArrayList(Diagnostics), +) AssemblyError!void { + _ = resolve_labels; + // LD (nn),SP. Only numeric addresses reach this probe; register + // operands like (C) or (HL) are handled below. + if (tokens[i.*].kind == .lparen and tokens[i.* + 1].kind == .number) { + const save = i.*; + i.* += 1; + const target = try parseExprValue(tokens, i, labels, allocator, line, diagnostics); + if (tokens[i.*].kind == .rparen and tokens[i.* + 1].kind == .comma and tokens[i.* + 2].kind == .ident and std.ascii.eqlIgnoreCase(tokens[i.* + 2].text, "SP")) { + i.* += 3; + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after LD (nn),SP"); + try opcode(0x08, bytes, allocator); + try encode16(target, bytes, allocator); + return; + } + i.* = save; + } + + // LD (operand),A for C, BC, DE, HL+, HL-, FF00+n or nn, plus the + // LD (HL),A / (HL),r / (HL),n forms. + if (tokens[i.*].kind == .lparen) { + const operand = try parseParenOperand(tokens, i, labels, allocator, line, diagnostics); + if (operand == .hl) { + if (tokens[i.*].kind != .comma) return diag(diagnostics, allocator, line, "expected a comma"); + i.* += 1; + if (parseReg(tokens, i)) |reg| { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after LD (HL),r"); + try opcode(0x70 | @as(u8, @intFromEnum(reg)), bytes, allocator); + return; + } + const value = try parseExprValue(tokens, i, labels, allocator, line, diagnostics); + if (value > 0xff) return diag(diagnostics, allocator, line, "immediate out of range"); + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after LD (HL),n"); + try opcode(0x36, bytes, allocator); + try encode8(@intCast(value & 0xff), bytes, allocator); + return; + } + if (tokens[i.*].kind != .comma) return diag(diagnostics, allocator, line, "expected a comma"); + i.* += 1; + if (!matchIdent(tokens, i, "A")) return diag(diagnostics, allocator, line, "LD to an indirect address only takes A"); + i.* += 1; + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after LD"); + switch (operand) { + .c => try opcode(0xe2, bytes, allocator), + .bc => try opcode(0x02, bytes, allocator), + .de => try opcode(0x12, bytes, allocator), + .hl_inc => try opcode(0x22, bytes, allocator), + .hl_dec => try opcode(0x32, bytes, allocator), + .hl => unreachable, + .ldh => |offset| { + try opcode(0xe0, bytes, allocator); + try encode8(@intCast(offset), bytes, allocator); + }, + .abs => |target| { + try opcode(0xea, bytes, allocator); + try encode16(target, bytes, allocator); + }, + } + return; + } + + // LD SP,HL + if (matchIdent(tokens, i, "SP") and tokens[i.* + 1].kind == .comma and isIdentAt(tokens, i.* + 2, "HL")) { + i.* += 3; + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after LD SP,HL"); + try opcode(0xf9, bytes, allocator); + return; + } + + // LD HL,SP+o + if (matchIdent(tokens, i, "HL") and tokens[i.* + 1].kind == .comma and isIdentAt(tokens, i.* + 2, "SP")) { + if (tokens[i.* + 3].kind == .eol) { + i.* += 1; + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after LD HL,SP"); + try opcode(0xf9, bytes, allocator); + return; + } + if (tokens[i.* + 3].kind == .plus or tokens[i.* + 3].kind == .minus) { + i.* += 3; + const offset = try parseSignedValue(tokens, i, labels, allocator, line, diagnostics); + if (offset < -128 or offset > 127) return diag(diagnostics, allocator, line, "offset out of range"); + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after LD HL,SP+o"); + try opcode(0xf8, bytes, allocator); + try encode8(@as(u8, @bitCast(@as(i8, @intCast(offset)))), bytes, allocator); + return; + } + return diag(diagnostics, allocator, line, "LD HL,SP expects an offset"); + } + + // LD rr,nn + if (parsePair(tokens[i.*])) |pair| { + i.* += 1; + if (tokens[i.*].kind != .comma) return diag(diagnostics, allocator, line, "expected a comma"); + i.* += 1; + const value = try parseExprValue(tokens, i, labels, allocator, line, diagnostics); + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after LD rr,nn"); + try opcode(0x01 | @as(u8, @intFromEnum(pair)) << 4, bytes, allocator); + try encode16(value, bytes, allocator); + return; + } + + // LD A,(operand) where operand is C, BC, DE, HL+, HL-, HL, FF00+n or nn. + if (matchIdent(tokens, i, "A") and tokens[i.* + 1].kind == .comma and tokens[i.* + 2].kind == .lparen) { + i.* += 2; + const operand = try parseParenOperand(tokens, i, labels, allocator, line, diagnostics); + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after LD A,(...)"); + switch (operand) { + .c => try opcode(0xf2, bytes, allocator), + .bc => try opcode(0x0a, bytes, allocator), + .de => try opcode(0x1a, bytes, allocator), + .hl_inc => try opcode(0x2a, bytes, allocator), + .hl_dec => try opcode(0x3a, bytes, allocator), + .hl => try opcode(0x7e, bytes, allocator), + .ldh => |offset| { + try opcode(0xf0, bytes, allocator); + try encode8(@intCast(offset), bytes, allocator); + }, + .abs => |target| { + try opcode(0xfa, bytes, allocator); + try encode16(target, bytes, allocator); + }, + } + return; + } + + // LD r,(HL) / LD r,r' / LD r,n + const dst = parseReg(tokens, i) orelse return diag(diagnostics, allocator, line, "expected a register"); + if (tokens[i.*].kind != .comma) return diag(diagnostics, allocator, line, "expected a comma"); + i.* += 1; + if (parseParen(tokens, i) == .hl) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after LD r,(HL)"); + try opcode(0x46 | @as(u8, @intFromEnum(dst)) << 3, bytes, allocator); + return; + } + if (parseReg(tokens, i)) |src| { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after LD r,r'"); + try opcode(0x40 | @as(u8, @intFromEnum(dst)) << 3 | @as(u8, @intFromEnum(src)), bytes, allocator); + return; + } + const value = try parseExprValue(tokens, i, labels, allocator, line, diagnostics); + if (value > 0xff) return diag(diagnostics, allocator, line, "immediate out of range"); + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens after LD r,n"); + try opcode(0x06 | @as(u8, @intFromEnum(dst)) << 3, bytes, allocator); + try encode8(@intCast(value & 0xff), bytes, allocator); +} + +fn emitLdh( + tokens: []Token, + i: *usize, + line: usize, + labels: *const LabelTable, + resolve_labels: bool, + bytes: *std.ArrayList(u8), + allocator: std.mem.Allocator, + diagnostics: *std.ArrayList(Diagnostics), +) AssemblyError!void { + _ = resolve_labels; + // LDH A,(n8) or LDH (n8),A + const read = matchIdent(tokens, i, "A") and tokens[i.* + 1].kind == .comma; + if (read) i.* += 2; + if (tokens[i.*].kind != .lparen) return diag(diagnostics, allocator, line, "LDH expects a (n8) operand"); + i.* += 1; + const value = try parseExprValue(tokens, i, labels, allocator, line, diagnostics); + if (value > 0xff) return diag(diagnostics, allocator, line, "LDH offset out of range"); + if (!match(i, tokens, .rparen)) return diag(diagnostics, allocator, line, "expected ')'"); + i.* += 1; + if (read) { + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens"); + try opcode(0xf0, bytes, allocator); + } else { + if (tokens[i.*].kind != .comma) return diag(diagnostics, allocator, line, "expected a comma"); + i.* += 1; + if (!matchIdent(tokens, i, "A")) return diag(diagnostics, allocator, line, "LDH (n8) only takes A"); + i.* += 1; + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens"); + try opcode(0xe0, bytes, allocator); + } + try encode8(@intCast(value & 0xff), bytes, allocator); +} + +fn emitCbRotate( + tokens: []Token, + i: *usize, + line: usize, + mnemonic: []const u8, + resolve_labels: bool, + bytes: *std.ArrayList(u8), + allocator: std.mem.Allocator, + diagnostics: *std.ArrayList(Diagnostics), +) AssemblyError!void { + _ = resolve_labels; + const base: u8 = if (std.ascii.eqlIgnoreCase(mnemonic, "RLC")) 0x00 else if (std.ascii.eqlIgnoreCase(mnemonic, "RRC")) 0x08 else if (std.ascii.eqlIgnoreCase(mnemonic, "RL")) 0x10 else if (std.ascii.eqlIgnoreCase(mnemonic, "RR")) 0x18 else if (std.ascii.eqlIgnoreCase(mnemonic, "SLA")) 0x20 else if (std.ascii.eqlIgnoreCase(mnemonic, "SRA")) 0x28 else if (std.ascii.eqlIgnoreCase(mnemonic, "SWAP")) 0x30 else 0x38; + const reg = parseReg(tokens, i) orelse blk: { + if (parseParen(tokens, i) == .hl) break :blk Reg.mhl; + return diag(diagnostics, allocator, line, "expected a register or (HL)"); + }; + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens"); + try opcode(0xcb, bytes, allocator); + try opcode(base | @as(u8, @intFromEnum(reg)), bytes, allocator); +} + +fn emitCbBit( + tokens: []Token, + i: *usize, + line: usize, + mnemonic: []const u8, + labels: *const LabelTable, + resolve_labels: bool, + bytes: *std.ArrayList(u8), + allocator: std.mem.Allocator, + diagnostics: *std.ArrayList(Diagnostics), +) AssemblyError!void { + _ = resolve_labels; + const bit = try parseExprValue(tokens, i, labels, allocator, line, diagnostics); + if (bit > 7) return diag(diagnostics, allocator, line, "bit index out of range"); + if (tokens[i.*].kind != .comma) return diag(diagnostics, allocator, line, "expected a comma after bit index"); + i.* += 1; + const reg = parseReg(tokens, i) orelse blk: { + if (parseParen(tokens, i) == .hl) break :blk Reg.mhl; + return diag(diagnostics, allocator, line, "expected a register or (HL)"); + }; + if (tokens[i.*].kind != .eol) return diag(diagnostics, allocator, line, "trailing tokens"); + const base: u8 = if (std.ascii.eqlIgnoreCase(mnemonic, "BIT")) 0x40 else if (std.ascii.eqlIgnoreCase(mnemonic, "RES")) 0x80 else 0xc0; + try opcode(0xcb, bytes, allocator); + try opcode(base | @as(u8, @intCast(bit)) << 3 | @as(u8, @intFromEnum(reg)), bytes, allocator); +} + +// Assemblies the source text into a ROM image. Callers should free the +// returned slice with the allocator. +pub fn assemble(allocator: std.mem.Allocator, source: []const u8, diagnostics: *std.ArrayList(Diagnostics)) AssemblyError![]u8 { + const lines = try tokenize(allocator, source, diagnostics); + defer freeLines(allocator, lines); + + var labels = LabelTable.init(allocator); + defer labels.deinit(); + + // Pass 1: assign addresses and collect labels. + labels.dummy = true; + var address: u16 = 0; + var scratch = std.ArrayList(u8).empty; + defer scratch.deinit(allocator); + for (lines) |line| { + scratch.clearRetainingCapacity(); + const size = emitLine(line.tokens, line.number, address, &labels, false, &scratch, allocator, diagnostics) catch |err| return err; + address +%= @intCast(size); + } + + // Pass 2: emit the final bytes. + labels.dummy = false; + var output = std.ArrayList(u8).empty; + errdefer output.deinit(allocator); + address = 0; + for (lines) |line| { + const size = emitLine(line.tokens, line.number, address, &labels, true, &output, allocator, diagnostics) catch |err| return err; + address +%= @intCast(size); + } + + return output.toOwnedSlice(allocator); +} + +fn usage(program: []const u8) void { + std.debug.print( + \\gbasm - SM83 assembler for dot-matrix-deck fixtures + \\Usage: + \\ {s} + \\ + \\Assembles every .gbasm file in the input directory and writes a + \\.gb ROM for each one into the output directory. + \\ + , .{program}); +} + +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; + + var it = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator); + defer it.deinit(); + + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(allocator); + while (it.next()) |arg| { + try args.append(allocator, arg); + } + const argv = args.items; + + if (argv.len < 3) { + usage(argv[0]); + std.process.exit(2); + } + + const input_dir = argv[1]; + const output_dir = argv[2]; + + var dir = std.Io.Dir.cwd().openDir(io, input_dir, .{ .iterate = true }) catch |err| { + std.debug.print("cannot open input directory: {s}\n", .{@errorName(err)}); + std.process.exit(1); + }; + defer dir.close(io); + + std.Io.Dir.cwd().createDirPath(io, output_dir) catch |err| { + std.debug.print("cannot create output directory: {s}\n", .{@errorName(err)}); + std.process.exit(1); + }; + + var names: std.ArrayList([]const u8) = .empty; + defer { + for (names.items) |name| allocator.free(name); + names.deinit(allocator); + } + var iter = dir.iterate(); + while (iter.next(io) catch |err| { + std.debug.print("cannot read input directory: {s}\n", .{@errorName(err)}); + std.process.exit(1); + }) |entry| { + if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".gbasm")) { + try names.append(allocator, try allocator.dupe(u8, entry.name)); + } + } + std.mem.sort([]const u8, names.items, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.order(u8, a, b) == .lt; + } + }.lessThan); + + for (names.items) |name| { + const stem = name[0 .. name.len - ".gbasm".len]; + const in_path = try std.fs.path.join(allocator, &.{ input_dir, name }); + defer allocator.free(in_path); + const out_path = try std.fs.path.join(allocator, &.{ output_dir, stem }); + const out_path2 = try std.fmt.allocPrint(allocator, "{s}.gb", .{out_path}); + defer allocator.free(out_path); + defer allocator.free(out_path2); + + const source = readFile(io, allocator, in_path) catch |err| { + std.debug.print("{s}: cannot read: {s}\n", .{ name, @errorName(err) }); + std.process.exit(1); + }; + defer allocator.free(source); + + var diagnostics = std.ArrayList(Diagnostics).empty; + defer diagnostics.deinit(allocator); + + const image = assemble(allocator, source, &diagnostics) catch |err| { + if (diagnostics.items.len != 0) { + for (diagnostics.items) |d| { + std.debug.print("{s}:{d}: {s}\n", .{ name, d.line, d.message }); + } + } else { + std.debug.print("{s}: {s}\n", .{ name, @errorName(err) }); + } + std.process.exit(1); + }; + defer allocator.free(image); + + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = out_path2, .data = image }) catch |err| { + std.debug.print("{s}: cannot write: {s}\n", .{ name, @errorName(err) }); + std.process.exit(1); + }; + std.debug.print("{s} -> {s} ({d} bytes)\n", .{ name, out_path2, image.len }); + } +} + +fn readFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const file = try std.Io.Dir.cwd().openFile(io, path, .{}); + defer file.close(io); + const size = try file.length(io); + const buffer = try allocator.alloc(u8, @intCast(size)); + _ = try file.readPositionalAll(io, buffer, 0); + return buffer; +} + +test "gbasm assembles core instructions to known bytes" { + const allocator = std.testing.allocator; + const source = + \\.org 0x100 + \\ nop + \\ ld a, 5 + \\ ld hl, 0x1234 + \\ jp 0x200 + \\ jr 0x106 + \\ ld (0xff01), a + \\ add a, 0x40 + \\ rlc c + \\ bit 3, (hl) + \\ ret + ; + var diagnostics = std.ArrayList(Diagnostics).empty; + defer diagnostics.deinit(allocator); + const image = try assemble(allocator, source, &diagnostics); + defer allocator.free(image); + + const expected = [_]u8{ 0x00, 0x3e, 0x05, 0x21, 0x34, 0x12, 0xc3, 0x00, 0x02, 0x18, 0xfb, 0xea, 0x01, 0xff, 0xc6, 0x40, 0xcb, 0x01, 0xcb, 0x5e, 0xc9 }; + try std.testing.expectEqualSlices(u8, &expected, image[0x100..]); +} + +test "gbasm resolves forward labels and conditional jumps" { + const allocator = std.testing.allocator; + const source = + \\ ld a, 0 + \\loop: + \\ inc a + \\ cp 0x10 + \\ jr nz, loop + \\ jp done + \\ ld b, 0xaa + \\done: + \\ ld c, a + ; + var diagnostics = std.ArrayList(Diagnostics).empty; + defer diagnostics.deinit(allocator); + const image = try assemble(allocator, source, &diagnostics); + defer allocator.free(image); + + // loop at offset 2; jr nz target loop (offset 2) from 7+2=9 -> -7. + const expected = [_]u8{ 0x3e, 0x00, 0x3c, 0xfe, 0x10, 0x20, 0xfb, 0xc3, 0x0c, 0x00, 0x06, 0xaa, 0x4f }; + try std.testing.expectEqualSlices(u8, &expected, image); +} + +test "gbasm assembles the demo ROM pattern" { + const allocator = std.testing.allocator; + const source = + \\.org 0x100 + \\ ld sp, 0xfffe + \\ ld a, 0x80 + \\ ld (0xff40), a + \\halted: + \\ jr halted + ; + var diagnostics = std.ArrayList(Diagnostics).empty; + defer diagnostics.deinit(allocator); + const image = try assemble(allocator, source, &diagnostics); + defer allocator.free(image); + + const expected = [_]u8{ 0x31, 0xfe, 0xff, 0x3e, 0x80, 0xea, 0x40, 0xff, 0x18, 0xfe }; + try std.testing.expectEqualSlices(u8, &expected, image[0x100..]); +} + +test "gbasm encodes 16-bit increment and decrement pairs" { + const allocator = std.testing.allocator; + const source = + \\ inc bc + \\ dec de + \\ inc hl + \\ dec sp + \\ inc b + \\ dec c + ; + var diagnostics = std.ArrayList(Diagnostics).empty; + defer diagnostics.deinit(allocator); + const image = try assemble(allocator, source, &diagnostics); + defer allocator.free(image); + const expected = [_]u8{ 0x03, 0x1b, 0x23, 0x3b, 0x04, 0x0d }; + try std.testing.expectEqualSlices(u8, &expected, image); +} + +test "gbasm reports errors with a line number" { + const allocator = std.testing.allocator; + const source = + \\ ld a, 1 + \\ ld b, 2 + \\ nop nope + ; + var diagnostics = std.ArrayList(Diagnostics).empty; + defer diagnostics.deinit(allocator); + try std.testing.expectError(error.AssemblyFailed, assemble(allocator, source, &diagnostics)); + try std.testing.expectEqual(@as(usize, 3), diagnostics.items[0].line); +} + +test "gbasm supports LDH, strings, and registers" { + const allocator = std.testing.allocator; + const source = + \\.org 0x150 + \\ ldh (0x01), a + \\ ldh a, (0x02) + \\ .ascii "PASS\n" + \\ .dw 0x1234 + \\ add hl, de + \\ pop af + ; + var diagnostics = std.ArrayList(Diagnostics).empty; + defer diagnostics.deinit(allocator); + const image = try assemble(allocator, source, &diagnostics); + defer allocator.free(image); + + const expected = [_]u8{ 0xe0, 0x01, 0xf0, 0x02, 'P', 'A', 'S', 'S', '\n', 0x34, 0x12, 0x19, 0xf1 }; + try std.testing.expectEqualSlices(u8, &expected, image[0x150..]); }