diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f340ca8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Normalize line endings so format checks behave the same on every OS. +# Files are stored with LF and checked out with LF on all platforms. +* text=auto +*.zig text eol=lf +*.zon text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.asm text eol=lf +*.gb binary +*.png binary +*.jpg binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..b033501 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,57 @@ +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: Battery RAM save round-trip + run: | + zig build run-headless -Dsdl2=off -- fixtures/roms/sram.gb --save sram.sav + zig build run-headless -Dsdl2=off -- fixtures/roms/sram.gb --load-save sram.sav + - name: Build headless tools + run: zig build -Dsdl2=off + + 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..a578e88 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .zig-cache/ +.zig-global-cache/ zig-out/ *.o *.obj @@ -8,5 +9,6 @@ zig-out/ *.exe .DS_Store *.gb +!fixtures/roms/*.gb *.sav *.swp diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b4364d6 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,63 @@ +# Contributing + +Thank you for helping with Dot Matrix Deck. +This guide explains how to contribute cleanly. + +## Before you start + +Read the ROADMAP file. +It lists what the project delivers and what remains. +Pick an item from the "Next" list or open an issue first. + +## Build and test + +Build the headless tools. + +```text +zig build -Dsdl2=off +``` + +Run the whole test suite. + +```text +zig build test -Dsdl2=off +``` + +Run the format check. + +```text +zig fmt --check . +``` + +Fix formatting with `zig fmt .`. +The CI pipeline enforces both checks. + +## Make a change + +Follow these rules. + +- Keep every module small and focused. +- Match the style of the surrounding code. +- Do not add comments that restate the code. +- Add deterministic tests for new behavior. +- Keep the public API stable unless a test requires a change. + +## Regenerate fixtures + +Update the bundled ROMs only through their sources. + +```text +zig build fixtures -Dsdl2=off +``` + +Commit each `.asm` source and the generated `.gb` image together. +The round-trip tests check that they stay in sync. +Each ROM must print PASS over the serial port. +Keep the ROM small so the headless runner stays fast. + +## Open a pull request + +- Write a clear title and summary. +- Keep the change as small as possible. +- Confirm that tests, format, and builds pass. +- Confirm the windowed frontend still builds when SDL2 is present. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3eef3df --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dot Matrix Deck contributors + +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..b41f38f --- /dev/null +++ b/NOTICE @@ -0,0 +1,16 @@ +# Notice + +This project includes and links to the following third-party software. + +## SDL2 + +The windowed frontend links against Simple DirectMedia Layer (SDL2). +SDL2 is licensed under the zlib license. +The zlib license text is available at https://libsdl.org/license.php + +## Test ROM sources + +The bundled demo, DMA, and banking ROMs are written for this project. +The blargg cpu_instrs test suite is not bundled. +You add it yourself to run the optional suite. +Refer to the original suite license when you use it. diff --git a/README.md b/README.md index fc59c84..9be106c 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,227 @@ # 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. +Dot Matrix Deck is a Game Boy emulator written in Zig. +It models the SM83 CPU, the memory bus, the timers, and the pixel pipeline. +A software renderer shows the screen in an SDL2 window. +A headless mode runs public test ROMs for automated checks. + +## Why it exists + +This project is a workbench for learning the Game Boy hardware. +Every part of the machine is small and inspectable. +You can step through the CPU, read the trace, and watch frames render. + +The bundled demo ROM shows the core working end to end. +It draws a title, scrolls the background, and bounces a dot. +It prints PASS over the serial port. +A build step verifies that output in one command. + +## Architecture + +The core splits into small modules with one job each. + +- `src/cpu.zig` executes the SM83 instruction set. +- `src/bus.zig` routes memory between the devices. +- `src/timer.zig` models the game clock. +- `src/ppu.zig` renders background, sprites, and the window. +- `src/cartridge.zig` loads ROM-only, MBC1, MBC2, MBC3, and MBC5 images. + It banks memory and drives the MBC3 real-time clock. + Battery-backed carts keep RAM in a `.sav` file. +- `src/dma.zig` copies pages into OAM with the correct timing. +- `src/serial.zig` turns link-port bytes into test verdicts. +- `src/joypad.zig` reports button state through the FF00 register. +- `src/emulator.zig` ties the core together. +- `src/headless.zig` runs a ROM without a window. +- `src/main.zig` and `src/frontend.zig` form the SDL2 frontend. +- `tools/gbasm.zig` assembles SM83 test ROMs. + +The frontend logic stays free of SDL2. +Unit tests cover that logic in headless builds. + +The joypad uses the real register semantics. +The select bits are active-low. +A cleared bit selects a column. +The register returns the stored select bits when read. +The bus raises the joypad interrupt on a visible falling edge. -## 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. - -## Features +## Requirements -- 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 +You need Zig 0.16 or later. -## Requirements +The headless build needs no other tools. +The windowed frontend needs the SDL2 development library. +On Windows, the build searches common MSYS2 and vcpkg prefixes. +You can set `SDL2_DIR` to an SDL2 prefix. +You can also pass `-Dsdl2=` on the build line. -- Zig 0.16 or later -- SDL2 for the optional windowed frontend +## Setup -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. +Fetch the source. -## Build +```text +git clone https://github.com/DanielCuevas1208/dot-matrix-deck.git +cd dot-matrix-deck +``` -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 when SDL2 is installed. ```text zig build ``` +## Run the demo + +Run the bundled demo and check its verdict. + +```text +zig build run-demo -Dsdl2=off +``` + +Sample output: + +```text +Serial output: +DOT MATRIX DECK +PASS +Verdict: pass +``` + ## Run a ROM -Run a ROM without a window: +Run a ROM without a window. ```text 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. +Useful options are `--max-cycles N`, `--trace`, `--expect pass|fail|any`, `--load-save `, and `--save `. +The runner prints serial output and returns the verdict code. -Run a ROM in the SDL2 frontend: +Run a ROM in the SDL2 frontend. ```text zig build run -- path/to/rom.gb ``` +The built-in key map uses Z for A, X for B, Enter for Start, and Shift for Select. +Arrows move the d-pad. +P pauses, R resets, F fast-forwards, and ESC quits. +See "Key bindings" below to change the map. + +## Key bindings + +The key map is configurable. +Create a file named `dmd.keys.conf` in the working directory. +The frontend loads it at start. +The repository ships a `dmd.keys.conf` that matches the built-in map. + +Each line maps one logical key to one or more physical keys. +Each line overrides one logical key. +Omitted keys keep the built-in bindings. + +```text +a = z +select = left shift, right shift +``` + +Physical keys use SDL scancode names. +Examples are `z`, `return`, `up`, and `left shift`. +Lines that start with `#` are comments. +Blank lines are ignored. + +Pass `--keys ` to load another file. + +```text +zig build run -- path/to/rom.gb --keys my-keys.conf +``` + +## Save files + +Battery-backed cartridges keep their external RAM in a `.sav` file. +The windowed frontend loads the file next to the ROM at start. +It writes the file back when you quit. +The headless runner keeps the files explicit. + +```text +zig build run-headless -Dsdl2=off -- fixtures/roms/sram.gb --save /tmp/sram.sav +zig build run-headless -Dsdl2=off -- fixtures/roms/sram.gb --load-save /tmp/sram.sav +``` + +Sample output from the second run: + +```text +Loaded save data. +Serial output: +PASS +Verdict: pass +``` + +The save file stores the external RAM bytes only. +It matches the RAM size from the cartridge header. +The MBC3 real-time clock state is not persisted. + +## Generate fixtures + +Regenerate the bundled ROMs from their assembly sources. + +```text +zig build fixtures -Dsdl2=off +``` + +Each `.asm` file in `fixtures/asm` becomes a `.gb` image in `fixtures/roms`. +The round-trip tests keep those images in sync. +The suite ships seven ROMs: + +- `demo` renders the title and prints PASS. +- `dma` copies a page into OAM and checks the result. +- `banking` switches MBC1 banks and checks each marker. +- `mbc2` switches MBC2 banks and checks the internal RAM. +- `mbc3` switches MBC3 banks, RAM banks, and the real-time clock. +- `mbc5` switches MBC5 banks and RAM banks. +- `sram` enables battery-backed RAM and verifies the save file round-trip. + ## Test -Run the core and assembler tests: +Run the whole suite. ```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 suite covers the core, the assembler, and the bundled ROMs. +It also runs the blargg CPU suite when you add the ROMs. + +```text +zig build test-blargg -Dsdl2=off +``` + +Add the blargg `cpu_instrs` ROMs under `fixtures/blargg` first. ## 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/` holds the emulator core and frontend. +- `tools/` holds the assembler. +- `fixtures/` holds the bundled ROMs and their sources. +- `.github/workflows/` holds the CI pipeline. ## Limitations -Hardware coverage is incomplete. Timing accuracy, cartridge support, audio, -and frontend features will improve as the project grows. +Hardware coverage is incomplete. +Timing accuracy, cartridge support, audio, and save games will improve. +The core starts at the post-boot state. +It does not emulate the boot ROM. +The MBC3 clock advances with the emulated cycle count. +It does not follow the host clock. +Save files store external RAM only. +The MBC3 real-time clock state does not persist. ## License -No license file is published yet. Treat this repository as an experimental -project until a license is added. +This project is licensed under the MIT License. +See the LICENSE file for details. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..07251c8 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,41 @@ +# Roadmap + +This document tracks what the project delivers and what remains. + +## Release 0.2 + +Release 0.2 makes the emulator a usable workbench. It adds input, +DMA, cartridge banking, an assembler, save data, and bundled test ROMs. + +### Done + +- SM83 CPU with the full 256-opcode set and the CB prefix group. +- Memory bus with WRAM, HRAM, and hardware I/O registers. +- Timer with DIV, TIMA, TMA, and TAC. +- PPU with background maps, tile data, and sprite rendering. +- Cartridge support for ROM-only, MBC1, MBC2, MBC3, and MBC5 images. +- OAM DMA with cycle-accurate CPU blocking. +- Headless ROM runner with cycle caps, traces, and verdicts. +- Joypad device with active-low column selection and visible falling-edge interrupts. +- MBC3 real-time clock with halt, latch, and BCD seconds. +- Battery-backed RAM saves for the headless runner and the windowed frontend. +- SDL2 windowed frontend with a DMG-style shell and pause controls. +- Configurable key bindings for the windowed frontend via a `dmd.keys.conf` file. +- SM83 assembler with labels, data directives, and expressions. +- Bundled demo, DMA, banking, MBC2, MBC3, MBC5, and SRAM ROMs that report PASS. +- Deterministic unit tests for every core module. +- Continuous integration on Ubuntu and Windows. + +### Next + +- Cycle-accurate timing for the timer and the PPU STAT modes. +- Serial link emulation between two emulator instances. +- Audio processing unit (APU). +- A debugger overlay for pause and reset. + +## Known limits + +- The core starts at the post-boot state. Boot ROM emulation is out of scope. +- The PPU has no window line counter edge cases yet. +- The SDL2 frontend ships without audio. +- Save files store external RAM only. The MBC3 real-time clock state is not persisted. diff --git a/build.zig b/build.zig index 815819d..ccbc962 100644 --- a/build.zig +++ b/build.zig @@ -17,6 +17,18 @@ fn sdl2PrefixFound(b: *std.Build, prefix: []const u8) bool { return true; } +// Prefers the SDL2 DLL import library so the static MinGW archive is not +// pulled into a native build. MinGW and vcpkg ship different import libs. +fn sdl2ImportLib(b: *std.Build, prefix: []const u8) ?[]const u8 { + const candidates = [_][]const u8{ "libSDL2.dll.a", "SDL2.lib", "SDL2.dll.a" }; + for (candidates) |name| { + const path = b.pathJoin(&.{ prefix, "lib", name }); + b.build_root.handle.access(b.graph.io, path, .{}) catch continue; + return path; + } + return null; +} + pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); @@ -57,22 +69,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(.{ @@ -84,19 +96,32 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), }); - exe.root_module.linkSystemLibrary("SDL2", .{}); if (target.result.os.tag == .windows) { for (sdl_deps) |lib| exe.root_module.linkSystemLibrary(lib, .{}); } + var sdl2_linked = false; if (sdl_prefix) |prefix| { exe.root_module.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ prefix, "include" }) }); exe.root_module.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ prefix, "lib" }) }); + // Link the DLL import library directly. The generic search + // may otherwise select the static MinGW archive, which pulls + // MinGW-only CRT symbols into a native build. + if (target.result.os.tag == .windows) { + if (sdl2ImportLib(b, prefix)) |import_lib| { + exe.root_module.addObjectFile(.{ .cwd_relative = import_lib }); + sdl2_linked = true; + } + } 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"); + // Dest is relative to zig-out/bin, next to the frontend. + const install_dll = b.addInstallBinFile(.{ .cwd_relative = dll_path }, "SDL2.dll"); b.getInstallStep().dependOn(&install_dll.step); } } + if (!sdl2_linked) { + exe.root_module.linkSystemLibrary("SDL2", .{ .preferred_link_mode = .dynamic }); + } b.installArtifact(exe); const run_cmd = b.addRunArtifact(exe); @@ -112,7 +137,7 @@ pub fn build(b: *std.Build) void { const core_tests = b.addTest(.{ .root_module = b.createModule(.{ - .root_source_file = b.path("src/emulator.zig"), + .root_source_file = b.path("src/tests.zig"), .target = target, .optimize = optimize, }), @@ -131,6 +156,16 @@ pub fn build(b: *std.Build) void { const run_asm_tests = b.addRunArtifact(asm_tests); test_step.dependOn(&run_asm_tests.step); + const fixture_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("fixtures_tests.zig"), + .target = target, + .optimize = optimize, + }), + }); + const run_fixture_tests = b.addRunArtifact(fixture_tests); + test_step.dependOn(&run_fixture_tests.step); + const fixtures_step = b.step("fixtures", "Regenerate bundled test ROMs from their assembly sources"); { const run = b.addRunArtifact(gbasm); @@ -139,6 +174,13 @@ pub fn build(b: *std.Build) void { fixtures_step.dependOn(&run.step); } + const run_demo = b.addRunArtifact(headless); + run_demo.addFileArg(b.path("fixtures/roms/demo.gb")); + run_demo.addArg("--expect"); + run_demo.addArg("pass"); + const demo_step = b.step("run-demo", "Run the bundled demo ROM headlessly and check it reports PASS"); + demo_step.dependOn(&run_demo.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..307010d 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,10 +9,15 @@ "src", "tools", "fixtures", + "fixtures_tests.zig", + "ROADMAP.md", "README.md", + "CONTRIBUTING.md", "LICENSE", "NOTICE", ".github", ".gitignore", + ".gitattributes", + "dmd.keys.conf", }, } diff --git a/dmd.keys.conf b/dmd.keys.conf new file mode 100644 index 0000000..14a6c6c --- /dev/null +++ b/dmd.keys.conf @@ -0,0 +1,20 @@ +# Dot Matrix Deck key bindings +# +# Each line maps one logical key to one or more physical keys. +# Physical keys use SDL scancode names, for example: z, return, up. +# Lines that start with # are comments. Blank lines are ignored. +# The frontend loads this file from the working directory at start. + +# Logical keys: right, left, up, down, a, b, select, start, pause, reset, fast_forward + +a = z +b = x +select = left shift, right shift +start = return +right = right +left = left +up = up +down = down +pause = p +reset = r +fast_forward = f diff --git a/fixtures/asm/banking.asm b/fixtures/asm/banking.asm new file mode 100644 index 0000000..e73e9da --- /dev/null +++ b/fixtures/asm/banking.asm @@ -0,0 +1,102 @@ +; banking.asm - verifies MBC1 cartridge banking for the headless runner. +; +; The ROM switches ROM banks and checks a marker byte in each one. It +; prints PASS or FAIL over the serial port. Regenerate banking.gb with +; `zig build fixtures`. + + ORG $0000 + + ORG $0100 + NOP + JP init + + ORG $0134 + DB "MBC1 BANK TEST" ; cartridge title + ORG $0143 + DB $80 ; CGB flag + ORG $0144 + DB $00 ; licensee + ORG $0147 + DB $01 ; MBC1 + ORG $0148 + DB $01 ; 64KB ROM + ORG $0149 + DB $00 ; no external RAM + ORG $014C + DB $00 ; header checksum + ORG $014E + DB $00, $00 ; version + ORG $0150 + +init: + ; Bank 1 is the default. Check its marker. + LD A,($4000) + CP $11 + JR NZ,fail + + ; Switch to bank 2 and check its marker. + LD A,$02 + LD ($2000),A + LD A,($4000) + CP $22 + JR NZ,fail + + ; Switch to bank 3 and check its marker. + LD A,$03 + LD ($2000),A + LD A,($4000) + CP $33 + JR NZ,fail + + ; Switch back to bank 1 and check again. + LD A,$01 + LD ($2000),A + LD A,($4000) + CP $11 + JR NZ,fail + + ; The 0000-3FFF window stays pinned to bank 0. + LD A,($0000) + CP $00 + JR NZ,fail + + LD HL,msg_pass + CALL print_string + JR done + +fail: + LD HL,msg_fail + CALL print_string +done: + JR done + +; Prints a zero-terminated string pointed to by HL over the serial port. +print_string: + LD A,(HL+) + OR A + RET Z + CALL print_char + JR print_string + +print_char: + LDH ($01),A + LD A,$81 + LDH ($02),A + RET + +msg_pass: + DB "PASS", $0D, $0A, $00 + +msg_fail: + DB "FAIL", $0D, $0A, $00 + + ORG $4000 + DB $11 + + ORG $8000 + DB $22 + + ORG $C000 + DB $33 + + PAD $FFFF diff --git a/fixtures/asm/demo.asm b/fixtures/asm/demo.asm new file mode 100644 index 0000000..77bfe24 --- /dev/null +++ b/fixtures/asm/demo.asm @@ -0,0 +1,264 @@ +; demo.asm - a self-contained showcase ROM for dot-matrix-deck. +; +; The ROM turns on the LCD, draws the "DOT MATRIX / DECK" title with a +; dot-matrix font, scrolls the background, and bounces a bright dot. +; It prints "PASS" over the serial port after 64 frames so the headless +; runner can verify the core. Regenerate demo.gb with `zig build fixtures`. + + ORG $0000 + + ORG $0100 + NOP + JP init + + ORG $0134 + DB "DOT MATRIX DECK" ; cartridge title + ORG $0143 + DB $80 ; CGB flag + ORG $0144 + DB $00 ; licensee + ORG $0147 + DB $00 ; ROM only + ORG $0148 + DB $00 ; 32KB ROM + ORG $0149 + DB $00 ; no external RAM + ORG $014C + DB $33 ; header checksum + ORG $014E + DB $00, $00 ; version + ORG $0150 + +init: + ; Disable the LCD while writing video memory. + LD A,$00 + LDH ($40),A + + ; Copy the tile set into VRAM. Each tile is 8 data bytes followed by + ; eight zero bytes; the loop interleaves the hi bytes. + LD HL,tile_data + LD DE,$8000 + LD B,$78 ; 15 tiles * 8 bytes +copy_tiles: + LD A,(HL+) + LD (DE),A + INC DE + LD A,$00 + LD (DE),A + INC DE + DEC B + JR NZ,copy_tiles + + ; Fill the background map with the checker tile. Keep the fill value + ; in E because A is clobbered by the loop counter check. + LD HL,$9800 + LD E,$01 + LD BC,$0400 ; 32 * 32 cells +fill_map: + LD A,E + LD (HL),A + INC HL + DEC BC + LD A,B + OR C + JR NZ,fill_map + + ; Draw the two title lines from encoded text rows. + LD HL,text_row1 + CALL draw_text + LD HL,text_row2 + CALL draw_text + + ; Seed the bouncing dot and enable the LCD. + LD A,$0A + LD ($C001),A ; dot_x + LD ($C006),A ; prev_x + LD A,$09 + LD ($C002),A ; dot_y + LD ($C007),A ; prev_y + LD A,$01 + LD ($C003),A ; dot_dx + LD ($C004),A ; dot_dy + LD A,$91 + LDH ($40),A + + LD HL,msg_title + CALL print_string + +frame_loop: + ; Wait for the vertical blank to pace one step per frame. +wait_vblank: + LDH A,($44) + CP $90 + JR NZ,wait_vblank + + ; Scroll the background one pixel down. + LD A,($C000) + INC A + LD ($C000),A + LDH ($42),A + + ; Erase the previous dot position. + LD A,($C006) + LD E,A + LD A,($C007) + LD D,A + CALL map_addr + LD A,$01 + LD (HL),A + + ; Flip the X direction at the left and right edges. + LD A,($C001) + CP $13 + JR C,no_flip_x + LD A,$FF + LD ($C003),A + JR move_dot_x +no_flip_x: + LD A,($C001) + OR A + JR NZ,move_dot_x + LD A,$01 + LD ($C003),A +move_dot_x: + LD A,($C003) + LD B,A + LD A,($C001) + ADD A,B + LD ($C001),A + + ; Flip the Y direction at the top and bottom edges. + LD A,($C002) + CP $11 + JR C,no_flip_y + LD A,$FF + LD ($C004),A + JR move_dot_y +no_flip_y: + LD A,($C002) + OR A + JR NZ,move_dot_y + LD A,$01 + LD ($C004),A +move_dot_y: + LD A,($C004) + LD B,A + LD A,($C002) + ADD A,B + LD ($C002),A + + ; Draw the dot at its new position. +draw_dot: + LD A,($C002) + LD D,A + LD A,($C001) + LD E,A + CALL map_addr + LD A,$02 + LD (HL),A + + ; Remember the new position so the next frame can erase it. + LD A,($C001) + LD ($C006),A + LD A,($C002) + LD ($C007),A + + ; After 64 frames print the verdict once and keep animating. + LD A,($C005) + INC A + LD ($C005),A + CP $40 + JP NZ,frame_loop + LD A,($C008) + OR A + JP NZ,frame_loop + LD A,$01 + LD ($C008),A + LD HL,msg_pass + CALL print_string + JP frame_loop + +; Converts a map cell coordinate in (D=row, E=col) to an address in HL. +map_addr: + LD H,$00 + LD L,D + ADD HL,HL + ADD HL,HL + ADD HL,HL + ADD HL,HL + ADD HL,HL + LD A,E + ADD A,L + LD L,A + LD A,H + ADD A,$98 + LD H,A + RET + +; Draws an encoded text row. The row starts with row and column bytes, +; then tile indices, and ends with a zero byte. +draw_text: + LD A,(HL+) + LD D,A + LD A,(HL+) + LD E,A + PUSH HL + CALL map_addr + POP DE +draw_char_loop: + LD A,(DE) + OR A + JR Z,draw_done + LD (HL),A + INC HL + INC DE + JR draw_char_loop +draw_done: + RET + +; Prints a zero-terminated string pointed to by HL over the serial port. +print_string: + LD A,(HL+) + OR A + RET Z + CALL print_char + JR print_string + +print_char: + LDH ($01),A + LD A,$81 + LDH ($02),A + RET + +text_row1: + DB $03, $05, $03, $04, $05, $00, $06, $07, $05, $08, $09, $0A, $00 + +text_row2: + DB $06, $08, $03, $0B, $0C, $0D, $00 + +msg_title: + DB "DOT MATRIX DECK", $0D, $0A, $00 + +msg_pass: + DB "PASS", $0D, $0A, $00 + +; Tile 0 is blank, tile 1 is the checker, tile 2 is the dot, and tiles +; 3 through 14 are the dot-matrix font glyphs for the title. +tile_data: + DB $00, $00, $00, $00, $00, $00, $00, $00 ; tile 0 blank + DB $AA, $55, $AA, $55, $AA, $55, $AA, $55 ; tile 1 checker + DB $00, $00, $18, $3C, $3C, $18, $00, $00 ; tile 2 dot + DB $F0, $88, $88, $88, $88, $88, $F0, $00 ; tile 3 'D' + DB $70, $88, $88, $88, $88, $88, $70, $00 ; tile 4 'O' + DB $F8, $20, $20, $20, $20, $20, $20, $00 ; tile 5 'T' + DB $88, $D8, $A8, $A8, $88, $88, $88, $00 ; tile 6 'M' + DB $70, $88, $88, $F8, $88, $88, $88, $00 ; tile 7 'A' + DB $F0, $88, $88, $F0, $A0, $90, $88, $00 ; tile 8 'R' + DB $F8, $20, $20, $20, $20, $20, $F8, $00 ; tile 9 'I' + DB $88, $88, $50, $20, $50, $88, $88, $00 ; tile 10 'X' + DB $F8, $80, $80, $F0, $80, $80, $F8, $00 ; tile 11 'E' + DB $70, $88, $80, $80, $80, $88, $70, $00 ; tile 12 'C' + DB $88, $90, $A0, $C0, $A0, $90, $88, $00 ; tile 13 'K' + DB $00, $00, $00, $00, $00, $00, $00, $00 ; tile 14 space + + PAD $8000 diff --git a/fixtures/asm/dma.asm b/fixtures/asm/dma.asm new file mode 100644 index 0000000..330e62b --- /dev/null +++ b/fixtures/asm/dma.asm @@ -0,0 +1,101 @@ +; dma.asm - verifies OAM DMA for the headless runner. +; +; The ROM fills WRAM page C0 with a known pattern, starts a DMA transfer +; into OAM, and compares the result. It prints PASS or FAIL over the +; serial port. Regenerate dma.gb with `zig build fixtures`. + + ORG $0000 + + ORG $0100 + NOP + JP init + + ORG $0134 + DB "DMA TEST" ; cartridge title + ORG $0143 + DB $80 ; CGB flag + ORG $0144 + DB $00 ; licensee + ORG $0147 + DB $00 ; ROM only + ORG $0148 + DB $00 ; 32KB ROM + ORG $0149 + DB $00 ; no external RAM + ORG $014C + DB $0F ; header checksum + ORG $014E + DB $00, $00 ; version + ORG $0150 + +init: + ; Fill WRAM C000-C09F with the values 00..9F. + LD HL,$C000 + LD B,$A0 + XOR A +fill: + LD (HL),A + INC HL + INC A + DEC B + JR NZ,fill + + ; Start a DMA transfer from page C0 into OAM. + LD A,$C0 + LDH ($46),A + + ; Wait for the 160-cycle transfer to finish. + LD B,$40 +wait: + NOP + NOP + NOP + DEC B + JR NZ,wait + + ; Compare each OAM byte against the WRAM pattern. + LD HL,$FE00 + LD DE,$C000 + LD B,$A0 +verify: + LD A,(DE) + LD C,A + LD A,(HL) + CP C + JR NZ,fail + INC HL + INC DE + DEC B + JR NZ,verify + + LD HL,msg_pass + CALL print_string + JR done + +fail: + LD HL,msg_fail + CALL print_string +done: + JR done + +; Prints a zero-terminated string pointed to by HL over the serial port. +print_string: + LD A,(HL+) + OR A + RET Z + CALL print_char + JR print_string + +print_char: + LDH ($01),A + LD A,$81 + LDH ($02),A + RET + +msg_pass: + DB "PASS", $0D, $0A, $00 + +msg_fail: + DB "FAIL", $0D, $0A, $00 + + PAD $8000 diff --git a/fixtures/asm/mbc2.asm b/fixtures/asm/mbc2.asm new file mode 100644 index 0000000..58fbc88 --- /dev/null +++ b/fixtures/asm/mbc2.asm @@ -0,0 +1,114 @@ +; mbc2.asm - verifies MBC2 cartridge banking for the headless runner. +; +; The ROM switches ROM banks through the bit-8 register and checks the +; 4-bit internal RAM. It prints PASS or FAIL over the serial port. +; Regenerate mbc2.gb with `zig build fixtures`. + + ORG $0000 + + ORG $0100 + NOP + JP init + + ORG $0134 + DB "MBC2 BANK TEST" ; cartridge title + ORG $0143 + DB $80 ; CGB flag + ORG $0147 + DB $05 ; MBC2 + ORG $0148 + DB $01 ; 64KB ROM + ORG $0149 + DB $00 ; internal RAM + ORG $014C + DB $00 ; header checksum + ORG $014E + DB $00, $00 ; version + ORG $0150 + +init: + ; Bank 1 is the default. Check its marker. + LD A,($4000) + CP $11 + JR NZ,fail + + ; Switch to bank 2 through the bit-8 register and check its marker. + LD A,$02 + LD ($2100),A + LD A,($4000) + CP $22 + JR NZ,fail + + ; Switch to bank 3 and check its marker. + LD A,$03 + LD ($2100),A + LD A,($4000) + CP $33 + JR NZ,fail + + ; A bank of zero selects bank 1. + LD A,$00 + LD ($2100),A + LD A,($4000) + CP $11 + JR NZ,fail + + ; Enable the internal RAM and store a nibble. + LD A,$0A + LD ($0000),A + LD A,$05 + LD ($A000),A + LD A,($A000) + AND $0F + CP $05 + JR NZ,fail + + ; Disabling RAM drops further writes. + LD A,$00 + LD ($0000),A + LD A,$07 + LD ($A000),A + LD A,($A000) + CP $FF + JR NZ,fail + + LD HL,msg_pass + CALL print_string + JR done + +fail: + LD HL,msg_fail + CALL print_string +done: + JR done + +; Prints a zero-terminated string pointed to by HL over the serial port. +print_string: + LD A,(HL+) + OR A + RET Z + CALL print_char + JR print_string + +print_char: + LDH ($01),A + LD A,$81 + LDH ($02),A + RET + +msg_pass: + DB "PASS", $0D, $0A, $00 + +msg_fail: + DB "FAIL", $0D, $0A, $00 + + ORG $4000 + DB $11 + + ORG $8000 + DB $22 + + ORG $C000 + DB $33 + + PAD $FFFF diff --git a/fixtures/asm/mbc3.asm b/fixtures/asm/mbc3.asm new file mode 100644 index 0000000..a03b934 --- /dev/null +++ b/fixtures/asm/mbc3.asm @@ -0,0 +1,135 @@ +; mbc3.asm - verifies MBC3 cartridge banking and RTC for the headless runner. +; +; The ROM switches ROM banks, uses banked RAM, and checks the RTC registers. +; It prints PASS or FAIL over the serial port. Regenerate mbc3.gb with +; `zig build fixtures`. + + ORG $0000 + + ORG $0100 + NOP + JP init + + ORG $0134 + DB "MBC3 BANK TEST" ; cartridge title + ORG $0143 + DB $80 ; CGB flag + ORG $0147 + DB $0F ; MBC3 + ORG $0148 + DB $01 ; 64KB ROM + ORG $0149 + DB $03 ; 32KB RAM + ORG $014C + DB $00 ; header checksum + ORG $014E + DB $00, $00 ; version + ORG $0150 + +init: + ; Bank 1 is the default. Check its marker. + LD A,($4000) + CP $11 + JR NZ,fail + + ; Switch to bank 2 and check its marker. + LD A,$02 + LD ($2000),A + LD A,($4000) + CP $22 + JR NZ,fail + + ; Switch to bank 3 and check its marker. + LD A,$03 + LD ($2000),A + LD A,($4000) + CP $33 + JR NZ,fail + + ; Switch back to bank 1. + LD A,$01 + LD ($2000),A + LD A,($4000) + CP $11 + JR NZ,fail + + ; Enable external RAM and use bank 0. + LD A,$0A + LD ($0000),A + LD A,$42 + LD ($A000),A + LD A,($A000) + CP $42 + JR NZ,fail + + ; Switch to RAM bank 2 and store a different byte. + LD A,$02 + LD ($4000),A + LD A,$24 + LD ($A000),A + LD A,($A000) + CP $24 + JR NZ,fail + + ; Back on RAM bank 0 the first byte survives. + LD A,$00 + LD ($4000),A + LD A,($A000) + CP $42 + JR NZ,fail + + ; Select the RTC seconds register, halt the clock, and write a value. + LD A,$0C + LD ($4000),A + LD A,$40 + LD ($A000),A ; halt bit set + LD A,$08 + LD ($4000),A + LD A,$59 + LD ($A000),A + LD A,$08 + LD ($4000),A + LD A,($A000) + CP $59 + JR NZ,fail + + LD HL,msg_pass + CALL print_string + JR done + +fail: + LD HL,msg_fail + CALL print_string +done: + JR done + +; Prints a zero-terminated string pointed to by HL over the serial port. +print_string: + LD A,(HL+) + OR A + RET Z + CALL print_char + JR print_string + +print_char: + LDH ($01),A + LD A,$81 + LDH ($02),A + RET + +msg_pass: + DB "PASS", $0D, $0A, $00 + +msg_fail: + DB "FAIL", $0D, $0A, $00 + + ORG $4000 + DB $11 + + ORG $8000 + DB $22 + + ORG $C000 + DB $33 + + PAD $FFFF diff --git a/fixtures/asm/mbc5.asm b/fixtures/asm/mbc5.asm new file mode 100644 index 0000000..7813284 --- /dev/null +++ b/fixtures/asm/mbc5.asm @@ -0,0 +1,117 @@ +; mbc5.asm - verifies MBC5 cartridge banking for the headless runner. +; +; The ROM switches ROM banks through the low and high registers and checks +; banked RAM. It prints PASS or FAIL over the serial port. Regenerate +; mbc5.gb with `zig build fixtures`. + + ORG $0000 + + ORG $0100 + NOP + JP init + + ORG $0134 + DB "MBC5 BANK TEST" ; cartridge title + ORG $0143 + DB $80 ; CGB flag + ORG $0147 + DB $19 ; MBC5 + ORG $0148 + DB $01 ; 64KB ROM + ORG $0149 + DB $03 ; 32KB RAM + ORG $014C + DB $00 ; header checksum + ORG $014E + DB $00, $00 ; version + ORG $0150 + +init: + ; Bank 1 is the default. Check its marker. + LD A,($4000) + CP $11 + JR NZ,fail + + ; Switch to bank 2 through the low register. + LD A,$02 + LD ($2000),A + LD A,($4000) + CP $22 + JR NZ,fail + + ; Bank 0 is addressable, unlike MBC1. + LD A,$00 + LD ($2000),A + LD A,($4000) + CP $00 + JR NZ,fail + + ; Switch back to bank 1. + LD A,$01 + LD ($2000),A + LD A,($4000) + CP $11 + JR NZ,fail + + ; Enable external RAM and use RAM bank 0. + LD A,$0A + LD ($0000),A + LD A,$42 + LD ($A000),A + LD A,($A000) + CP $42 + JR NZ,fail + + ; Switch to RAM bank 2 and store a different byte. + LD A,$02 + LD ($4000),A + LD A,$24 + LD ($A000),A + LD A,($A000) + CP $24 + JR NZ,fail + + ; Back on RAM bank 0 the first byte survives. + LD A,$00 + LD ($4000),A + LD A,($A000) + CP $42 + JR NZ,fail + + LD HL,msg_pass + CALL print_string + JR done + +fail: + LD HL,msg_fail + CALL print_string +done: + JR done + +; Prints a zero-terminated string pointed to by HL over the serial port. +print_string: + LD A,(HL+) + OR A + RET Z + CALL print_char + JR print_string + +print_char: + LDH ($01),A + LD A,$81 + LDH ($02),A + RET + +msg_pass: + DB "PASS", $0D, $0A, $00 + +msg_fail: + DB "FAIL", $0D, $0A, $00 + + ORG $4000 + DB $11 + + ORG $8000 + DB $22 + + PAD $FFFF diff --git a/fixtures/asm/sram.asm b/fixtures/asm/sram.asm new file mode 100644 index 0000000..015ca36 --- /dev/null +++ b/fixtures/asm/sram.asm @@ -0,0 +1,97 @@ +; sram.asm - verifies battery-backed external RAM for the headless runner. +; +; The ROM enables MBC1 external RAM and stores a marker across the first +; RAM bank. On a fresh start it writes the marker and reads it back. When +; a save file is loaded the marker is already present, so the ROM checks +; that the whole saved page survived. It prints PASS over the serial port. +; Regenerate sram.gb with `zig build fixtures`. + + ORG $0000 + + ORG $0100 + NOP + JP init + + ORG $0134 + DB "SRAM TEST" ; cartridge title + ORG $0143 + DB $80 ; CGB flag + ORG $0144 + DB $00 ; licensee + ORG $0147 + DB $03 ; MBC1 + RAM + battery + ORG $0148 + DB $00 ; 32KB ROM + ORG $0149 + DB $03 ; 32KB RAM + ORG $014C + DB $00 ; header checksum + ORG $014E + DB $00, $00 ; version + ORG $0150 + +init: + LD A,$0A + LD ($0000),A ; enable RAM + LD A,($A000) + CP $5A + JR Z,check_loaded ; the marker is already saved + + ; Fresh RAM: write the marker across the bank and read it back. + LD A,$5A + LD ($A000),A + LD ($A001),A + LD ($AFFF),A + LD A,($A000) + CP $5A + JR NZ,fail + LD A,($A001) + CP $5A + JR NZ,fail + LD A,($AFFF) + CP $5A + JR NZ,fail + LD HL,msg_pass + CALL print_string + JR done + +check_loaded: + ; A save file was loaded. Confirm the whole saved page came back. + LD A,($A001) + CP $5A + JR NZ,fail + LD A,($AFFF) + CP $5A + JR NZ,fail + LD HL,msg_pass + CALL print_string + +done: + JR done + +fail: + LD HL,msg_fail + CALL print_string + JR done + +; Prints a zero-terminated string pointed to by HL over the serial port. +print_string: + LD A,(HL+) + OR A + RET Z + CALL print_char + JR print_string + +print_char: + LDH ($01),A + LD A,$81 + LDH ($02),A + RET + +msg_pass: + DB "PASS", $0D, $0A, $00 + +msg_fail: + DB "FAIL", $0D, $0A, $00 + + PAD $8000 diff --git a/fixtures/roms/banking.gb b/fixtures/roms/banking.gb new file mode 100644 index 0000000..99029a0 Binary files /dev/null and b/fixtures/roms/banking.gb differ diff --git a/fixtures/roms/demo.gb b/fixtures/roms/demo.gb new file mode 100644 index 0000000..e22ad95 Binary files /dev/null and b/fixtures/roms/demo.gb differ diff --git a/fixtures/roms/dma.gb b/fixtures/roms/dma.gb new file mode 100644 index 0000000..ef05e1b Binary files /dev/null and b/fixtures/roms/dma.gb differ diff --git a/fixtures/roms/mbc2.gb b/fixtures/roms/mbc2.gb new file mode 100644 index 0000000..08837e4 Binary files /dev/null and b/fixtures/roms/mbc2.gb differ diff --git a/fixtures/roms/mbc3.gb b/fixtures/roms/mbc3.gb new file mode 100644 index 0000000..270beb4 Binary files /dev/null and b/fixtures/roms/mbc3.gb differ diff --git a/fixtures/roms/mbc5.gb b/fixtures/roms/mbc5.gb new file mode 100644 index 0000000..b74d25d Binary files /dev/null and b/fixtures/roms/mbc5.gb differ diff --git a/fixtures/roms/sram.gb b/fixtures/roms/sram.gb new file mode 100644 index 0000000..cf51ee6 Binary files /dev/null and b/fixtures/roms/sram.gb differ diff --git a/fixtures_tests.zig b/fixtures_tests.zig new file mode 100644 index 0000000..ec7ed61 --- /dev/null +++ b/fixtures_tests.zig @@ -0,0 +1,124 @@ +const std = @import("std"); +const gbasm = @import("tools/gbasm.zig"); +const Emulator = @import("src/emulator.zig").Emulator; + +// Package-root shim for the fixture round-trip tests. +// +// The bundled ROMs must stay reproducible. Each test reassembles the +// source in fixtures/asm and compares the result to the committed image +// in fixtures/roms. `zig build fixtures` regenerates those images. + +const Fixture = struct { + name: []const u8, + source: []const u8, + image: []const u8, +}; + +const fixtures = [_]Fixture{ + .{ .name = "demo", .source = @embedFile("fixtures/asm/demo.asm"), .image = @embedFile("fixtures/roms/demo.gb") }, + .{ .name = "dma", .source = @embedFile("fixtures/asm/dma.asm"), .image = @embedFile("fixtures/roms/dma.gb") }, + .{ .name = "banking", .source = @embedFile("fixtures/asm/banking.asm"), .image = @embedFile("fixtures/roms/banking.gb") }, + .{ .name = "mbc2", .source = @embedFile("fixtures/asm/mbc2.asm"), .image = @embedFile("fixtures/roms/mbc2.gb") }, + .{ .name = "mbc3", .source = @embedFile("fixtures/asm/mbc3.asm"), .image = @embedFile("fixtures/roms/mbc3.gb") }, + .{ .name = "mbc5", .source = @embedFile("fixtures/asm/mbc5.asm"), .image = @embedFile("fixtures/roms/mbc5.gb") }, + .{ .name = "sram", .source = @embedFile("fixtures/asm/sram.asm"), .image = @embedFile("fixtures/roms/sram.gb") }, +}; + +test "fixture images round-trip byte for byte" { + for (fixtures) |fixture| { + var assembler = gbasm.Assembler.init(std.testing.allocator); + defer assembler.deinit(); + const image = try assembler.assemble(fixture.source); + defer std.testing.allocator.free(image); + try std.testing.expectEqualSlices(u8, fixture.image, image); + } +} + +test "demo ROM renders frames and reports PASS" { + const fixture = fixtures[0]; + var emulator = try Emulator.init(std.testing.allocator, fixture.image); + defer emulator.deinit(); + + var frame_index: u32 = 0; + while (frame_index < 4) : (frame_index += 1) emulator.runFrame(1 << 24); + var lit_pixels: usize = 0; + for (emulator.bus.ppu.frame) |pixel| { + if (pixel != 0xfff8f8f0) lit_pixels += 1; + } + try std.testing.expect(lit_pixels > 1000); + + while (frame_index < 80) : (frame_index += 1) emulator.runFrame(1 << 24); + try std.testing.expect(std.mem.indexOf(u8, emulator.serialOutput(), "PASS") != null); +} + +test "DMA ROM reports PASS" { + const fixture = fixtures[1]; + var emulator = try Emulator.init(std.testing.allocator, fixture.image); + defer emulator.deinit(); + var frame_index: u32 = 0; + while (frame_index < 40) : (frame_index += 1) emulator.runFrame(1 << 24); + try std.testing.expect(std.mem.indexOf(u8, emulator.serialOutput(), "PASS") != null); +} + +test "banking ROM reports PASS" { + const fixture = fixtures[2]; + var emulator = try Emulator.init(std.testing.allocator, fixture.image); + defer emulator.deinit(); + var frame_index: u32 = 0; + while (frame_index < 40) : (frame_index += 1) emulator.runFrame(1 << 24); + try std.testing.expect(std.mem.indexOf(u8, emulator.serialOutput(), "PASS") != null); +} + +test "MBC2 ROM reports PASS" { + const fixture = fixtures[3]; + var emulator = try Emulator.init(std.testing.allocator, fixture.image); + defer emulator.deinit(); + var frame_index: u32 = 0; + while (frame_index < 40) : (frame_index += 1) emulator.runFrame(1 << 24); + try std.testing.expect(std.mem.indexOf(u8, emulator.serialOutput(), "PASS") != null); +} + +test "MBC3 ROM reports PASS" { + const fixture = fixtures[4]; + var emulator = try Emulator.init(std.testing.allocator, fixture.image); + defer emulator.deinit(); + var frame_index: u32 = 0; + while (frame_index < 40) : (frame_index += 1) emulator.runFrame(1 << 24); + try std.testing.expect(std.mem.indexOf(u8, emulator.serialOutput(), "PASS") != null); +} + +test "MBC5 ROM reports PASS" { + const fixture = fixtures[5]; + var emulator = try Emulator.init(std.testing.allocator, fixture.image); + defer emulator.deinit(); + var frame_index: u32 = 0; + while (frame_index < 40) : (frame_index += 1) emulator.runFrame(1 << 24); + try std.testing.expect(std.mem.indexOf(u8, emulator.serialOutput(), "PASS") != null); +} + +test "SRAM ROM save data round-trips through a file" { + const fixture = fixtures[6]; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const save_path = "sram.sav"; + + // Run 1: fresh RAM writes the marker and reports PASS. + var first = try Emulator.init(std.testing.allocator, fixture.image); + defer first.deinit(); + var frame_index: u32 = 0; + while (frame_index < 40) : (frame_index += 1) first.runFrame(1 << 24); + try std.testing.expect(std.mem.indexOf(u8, first.serialOutput(), "PASS") != null); + first.bus.write(0x0000, 0x0a); + try std.testing.expectEqual(@as(u8, 0x5a), first.bus.read(0xa000)); + try first.saveSaveFile(std.testing.io, tmp.dir, save_path); + + // Run 2: the marker loads back before the ROM runs. + var second = try Emulator.init(std.testing.allocator, fixture.image); + defer second.deinit(); + try std.testing.expect(try second.loadSaveFile(std.testing.io, tmp.dir, save_path)); + second.bus.write(0x0000, 0x0a); + try std.testing.expectEqual(@as(u8, 0x5a), second.bus.read(0xa000)); + frame_index = 0; + while (frame_index < 40) : (frame_index += 1) second.runFrame(1 << 24); + try std.testing.expect(std.mem.indexOf(u8, second.serialOutput(), "PASS") != null); +} diff --git a/src/bus.zig b/src/bus.zig index ea87095..6d00233 100644 --- a/src/bus.zig +++ b/src/bus.zig @@ -1,15 +1,18 @@ const Cartridge = @import("cartridge.zig").Cartridge; +const Dma = @import("dma.zig").Dma; +const Joypad = @import("joypad.zig").Joypad; const Ppu = @import("ppu.zig").Ppu; const Timer = @import("timer.zig").Timer; pub const Bus = struct { allocator: @import("std").mem.Allocator, cartridge: Cartridge, + dma: Dma = .{}, + joypad: Joypad = .{}, ppu: Ppu = .{}, timer: Timer = .{}, wram: [0x2000]u8 = [_]u8{0} ** 0x2000, hram: [0x7f]u8 = [_]u8{0} ** 0x7f, - joypad: u8 = 0xcf, sb: u8 = 0, sc: u8 = 0, iflag: u8 = 0xe1, @@ -33,12 +36,14 @@ 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 => self.joypad.read(), 0xff01 => self.sb, 0xff02 => self.sc, 0xff0f => self.iflag | 0xe0, 0xff04...0xff07 => self.timer.read(address), - 0xff40...0xff4b => self.ppu.read(address), + 0xff40...0xff45 => self.ppu.read(address), + 0xff46 => self.dma.read(), + 0xff47...0xff4b => self.ppu.read(address), 0xffff => self.ie, else => 0xff, }; @@ -70,7 +75,11 @@ pub const Bus = struct { return; } switch (address) { - 0xff00 => self.joypad = (self.joypad & 0xcf) | (value & 0x30), + 0xff00 => { + const before = self.joypad.read() & 0x0f; + self.joypad.write(value); + self.raiseJoypadInterrupt(before); + }, 0xff01 => self.sb = value, 0xff02 => { self.sc = value; @@ -85,7 +94,9 @@ pub const Bus = struct { }, 0xff0f => self.iflag = value & 0x1f, 0xff04...0xff07 => self.timer.write(address, value), - 0xff40...0xff4b => self.ppu.write(address, value), + 0xff40...0xff45 => self.ppu.write(address, value), + 0xff46 => self.dma.start(value), + 0xff47...0xff4b => self.ppu.write(address, value), 0xffff => self.ie = value, else => {}, } @@ -94,6 +105,18 @@ pub const Bus = struct { pub fn tick(self: *Bus, cycles: u16) void { self.timer.tick(cycles, &self.iflag); self.ppu.tick(cycles, &self.iflag); + self.cartridge.tick(cycles); + } + + // Runs one DMA machine cycle. Copies one byte from the source page + // into OAM and advances the timer and PPU. Returns cycles consumed. + pub fn dmaStep(self: *Bus) u16 { + const source = self.dma.next() orelse return 0; + const index = self.dma.offset - 1; + self.ppu.oam[index] = self.read(source); + self.timer.tick(Dma.cycles_per_byte, &self.iflag); + self.ppu.tick(Dma.cycles_per_byte, &self.iflag); + return Dma.cycles_per_byte; } pub fn pendingInterrupts(self: *const Bus) u8 { @@ -104,7 +127,121 @@ pub const Bus = struct { self.iflag &= ~(@as(u8, 1) << bit); } + // A visible button press edge raises the joypad interrupt (IF bit 4). + pub fn setButton(self: *Bus, bit: Joypad.Button, pressed: bool) void { + const before = self.joypad.read() & 0x0f; + self.joypad.setButton(bit, pressed); + self.raiseJoypadInterrupt(before); + } + + // A visible direction press edge raises the joypad interrupt (IF bit 4). + pub fn setDirection(self: *Bus, bit: Joypad.Direction, pressed: bool) void { + const before = self.joypad.read() & 0x0f; + self.joypad.setDirection(bit, pressed); + self.raiseJoypadInterrupt(before); + } + + // The joypad interrupt is a falling edge on the visible low nibble. + // Presses in an unselected column do not change that nibble. + fn raiseJoypadInterrupt(self: *Bus, before: u8) void { + const after = self.joypad.read() & 0x0f; + if ((before & ~after) != 0) self.iflag |= 0x10; + } + pub fn serialOutput(self: *const Bus) []const u8 { return self.serial[0..self.serial_len]; } }; + +const testing = @import("std").testing; + +test "joypad reads the selected button column" { + const rom = [_]u8{0} ** 0x8000; + var bus = try Bus.init(testing.allocator, &rom); + defer bus.deinit(); + bus.write(0xff00, 0x10); // bit 5 low selects the buttons column. + try testing.expectEqual(@as(u8, 0x10), bus.read(0xff00) & 0x30); + bus.setButton(Joypad.Button.a, true); + try testing.expectEqual(@as(u8, 0x0e), bus.read(0xff00) & 0x0f); +} + +test "joypad interrupt follows a visible falling edge" { + const rom = [_]u8{0} ** 0x8000; + var bus = try Bus.init(testing.allocator, &rom); + defer bus.deinit(); + bus.write(0xff0f, 0x00); + bus.write(0xff00, 0x30); // No column is selected. + bus.setDirection(Joypad.Direction.right, true); + try testing.expectEqual(@as(u8, 0), bus.iflag & 0x10); + bus.write(0xff00, 0x20); // Selecting the held D-pad creates the edge. + try testing.expectEqual(@as(u8, 0x10), bus.iflag & 0x10); + bus.write(0xff0f, 0x00); + bus.setDirection(Joypad.Direction.right, true); // No state change. + try testing.expectEqual(@as(u8, 0), bus.iflag & 0x10); + bus.setDirection(Joypad.Direction.right, false); + bus.setDirection(Joypad.Direction.right, true); + try testing.expectEqual(@as(u8, 0x10), bus.iflag & 0x10); +} + +test "DMA transfers a WRAM page into OAM" { + const rom = [_]u8{0} ** 0x8000; + var bus = try Bus.init(testing.allocator, &rom); + defer bus.deinit(); + for (0..Dma.byte_count) |i| { + bus.write(0xc000 + @as(u16, @intCast(i)), @intCast(i & 0xff)); + } + bus.write(0xff46, 0xc0); + try testing.expectEqual(@as(u8, 0xc0), bus.read(0xff46)); + var steps: u32 = 0; + while (bus.dma.active()) : (steps += 1) { + try testing.expectEqual(@as(u16, 4), bus.dmaStep()); + } + try testing.expectEqual(@as(u32, 160), steps); + for (0..Dma.byte_count) |i| { + try testing.expectEqual(@as(u8, @intCast(i & 0xff)), bus.read(0xfe00 + @as(u16, @intCast(i)))); + } +} + +test "DMA register reads back the last source page" { + const rom = [_]u8{0} ** 0x8000; + var bus = try Bus.init(testing.allocator, &rom); + defer bus.deinit(); + bus.write(0xff46, 0x80); + try testing.expectEqual(@as(u8, 0x80), bus.read(0xff46)); + while (bus.dma.active()) _ = bus.dmaStep(); + try testing.expectEqual(@as(u8, 0x80), bus.read(0xff46)); +} + +test "bus ticks the MBC3 clock through FF registers" { + var image: [0x40000]u8 = [_]u8{0} ** 0x40000; + image[0x147] = 0x0f; // MBC3 + image[0x149] = 0x03; // 32KB RAM + var bus = try Bus.init(testing.allocator, &image); + defer bus.deinit(); + + bus.write(0x0000, 0x0a); // enable RAM + bus.write(0x4000, 0x0c); // RTC control register + bus.write(0xa000, 0x40); // halt the clock + bus.write(0x4000, 0x08); // seconds + bus.write(0xa000, 0x59); + bus.write(0x4000, 0x09); // minutes + bus.write(0xa000, 0x00); + bus.write(0x4000, 0x0a); // hours + bus.write(0xa000, 0x00); + bus.write(0x4000, 0x0c); // clear halt + bus.write(0xa000, 0x00); + + // One second of cycles advances the clock by one minute. + const chunks: u32 = 128; + const per_chunk: u16 = @intCast(Cartridge.cycles_per_second / chunks); + for (0..chunks) |_| bus.tick(per_chunk); + + bus.write(0x4000, 0x09); + bus.write(0x6000, 0x00); + bus.write(0x6000, 0x01); + try testing.expectEqual(@as(u8, 0x01), bus.read(0xa000)); + bus.write(0x4000, 0x08); + bus.write(0x6000, 0x00); + bus.write(0x6000, 0x01); + try testing.expectEqual(@as(u8, 0x00), bus.read(0xa000)); +} diff --git a/src/cartridge.zig b/src/cartridge.zig index 6fe8dae..5582a53 100644 --- a/src/cartridge.zig +++ b/src/cartridge.zig @@ -1,31 +1,43 @@ const std = @import("std"); +// Cartridge image loading and bank mapping. +// +// The emulator supports ROM-only carts and the MBC1, MBC2, MBC3, and MBC5 +// mappers. Each mapper maps a ROM window and an optional external RAM +// window. MBC3 also carries a real-time clock that advances with the CPU +// cycle count. + pub const Cartridge = struct { allocator: std.mem.Allocator, rom: []u8, ram: []u8, mapper: Mapper, + has_battery: bool = false, + ram_dirty: bool = false, rom_bank_low: u8 = 1, rom_bank_high: u8 = 0, + ram_bank: u8 = 0, ram_enabled: bool = false, banking_mode: u8 = 0, + rtc_regs: [5]u8 = [_]u8{0} ** 5, + rtc_total_seconds: u64 = 0, + rtc_cycle_accumulator: u64 = 0, + latch_state: u8 = 0, + latched_regs: [5]u8 = [_]u8{0} ** 5, + + pub const cycles_per_second: u64 = 4_194_304; - const Mapper = enum { rom_only, mbc1 }; + const Mapper = enum { rom_only, mbc1, mbc2, mbc3, mbc5 }; pub fn init(allocator: std.mem.Allocator, image: []const u8) !Cartridge { const rom = try allocator.alloc(u8, image.len); std.mem.copyForwards(u8, rom, image); - const mapper = if (image.len > 0x147 and (image[0x147] == 0x01 or image[0x147] == 0x02 or image[0x147] == 0x03)) Mapper.mbc1 else Mapper.rom_only; - const ram_size: usize = if (image.len > 0x149) switch (image[0x149]) { - 0x02 => 0x2000, - 0x03 => 0x8000, - 0x04 => 0x20000, - 0x05 => 0x10000, - else => 0, - } else 0; + const mapper = mapperFor(image); + const battery = batteryFor(image); + const ram_size: usize = if (mapper == .mbc2) 0x200 else externalRamSize(image); const ram = try allocator.alloc(u8, ram_size); @memset(ram, 0); - return .{ .allocator = allocator, .rom = rom, .ram = ram, .mapper = mapper }; + return .{ .allocator = allocator, .rom = rom, .ram = ram, .mapper = mapper, .has_battery = battery }; } pub fn deinit(self: *Cartridge) void { @@ -40,39 +52,676 @@ pub const Cartridge = struct { return self.romByte(bank * 0x4000 + raw); } if (raw < 0x8000) { - var bank: usize = self.rom_bank_low & 0x1f; - if (bank == 0) bank = 1; - if (self.mapper == .mbc1) bank |= @as(usize, self.rom_bank_high) << 5; - return self.romByte(bank * 0x4000 + raw - 0x4000); + return self.romByte(self.selectedRomBank() * 0x4000 + raw - 0x4000); } - if (raw >= 0xa000 and raw < 0xc000 and self.ram.len != 0 and self.ram_enabled) { - var index = raw - 0xa000; - if (self.mapper == .mbc1 and self.banking_mode == 1) index += @as(usize, self.rom_bank_high) * 0x2000; - return if (index < self.ram.len) self.ram[index] else 0xff; + if (raw >= 0xa000 and raw < 0xc000) { + return self.readExternal(raw); } return 0xff; } pub fn write(self: *Cartridge, address: u16, value: u8) void { if (self.mapper == .rom_only) return; - switch (address) { - 0x0000...0x1fff => self.ram_enabled = (value & 0x0f) == 0x0a, - 0x2000...0x3fff => { - self.rom_bank_low = value & 0x1f; - if (self.rom_bank_low == 0) self.rom_bank_low = 1; + const raw = @as(usize, address); + if (raw < 0x8000) { + self.writeBankRegisters(raw, value); + return; + } + if (raw >= 0xa000 and raw < 0xc000) { + self.writeExternal(raw, value); + } + } + + // Advances the MBC3 real-time clock with the given cycle count. + // Other mappers have no clock and do nothing. + pub fn tick(self: *Cartridge, cycles: u64) void { + if (self.mapper != .mbc3) return; + if ((self.rtc_regs[4] & 0x40) != 0) return; + self.rtc_cycle_accumulator += cycles; + const seconds = self.rtc_cycle_accumulator / cycles_per_second; + if (seconds == 0) return; + self.rtc_cycle_accumulator -= seconds * cycles_per_second; + self.rtc_total_seconds +%= seconds; + self.refreshRtcRegs(); + } + + // True when the cartridge carries battery-backed RAM. + pub fn hasBattery(self: *const Cartridge) bool { + return self.has_battery; + } + + // True when external RAM holds writes not yet written to disk. + pub fn ramDirty(self: *const Cartridge) bool { + return self.ram_dirty; + } + + // A battery-backed cartridge with unflushed RAM needs a save. + pub fn saveNeeded(self: *const Cartridge) bool { + return self.has_battery and self.ram_dirty; + } + + pub fn markRamClean(self: *Cartridge) void { + self.ram_dirty = false; + } + + // The external RAM bytes, stored verbatim in the .sav file. + pub fn saveData(self: *const Cartridge) []const u8 { + return self.ram; + } + + // Restores external RAM from a .sav file. The file size must match the + // cartridge RAM size exactly. + pub fn loadSaveData(self: *Cartridge, bytes: []const u8) !void { + if (bytes.len != self.ram.len) return error.SaveSizeMismatch; + @memcpy(self.ram, bytes); + self.markRamClean(); + } + + fn mapperFor(image: []const u8) Mapper { + if (image.len <= 0x147) return .rom_only; + return switch (image[0x147]) { + 0x01, 0x02, 0x03 => .mbc1, + 0x05, 0x06 => .mbc2, + 0x0f...0x13 => .mbc3, + 0x19...0x1e => .mbc5, + else => .rom_only, + }; + } + + // The cartridge type byte marks battery-backed carts. Save files only + // make sense for these images. + fn batteryFor(image: []const u8) bool { + if (image.len <= 0x147) return false; + return switch (image[0x147]) { + 0x03, 0x06, 0x0f, 0x10, 0x13, 0x1b, 0x1e => true, + else => false, + }; + } + + fn externalRamSize(image: []const u8) usize { + if (image.len <= 0x149) return 0; + return switch (image[0x149]) { + 0x02 => 0x2000, + 0x03 => 0x8000, + 0x04 => 0x20000, + 0x05 => 0x10000, + else => 0, + }; + } + + // Returns the bank selected for the 4000-7FFF ROM window. + fn selectedRomBank(self: *const Cartridge) usize { + var bank: usize = switch (self.mapper) { + .mbc1 => self.rom_bank_low & 0x1f, + .mbc2 => self.rom_bank_low & 0x0f, + .mbc3 => self.rom_bank_low & 0x7f, + .mbc5 => self.rom_bank_low, + .rom_only => 0, + }; + if (self.mapper == .mbc1) bank |= @as(usize, self.rom_bank_high) << 5; + if (self.mapper == .mbc5) bank |= @as(usize, self.rom_bank_high) << 8; + if (bank == 0 and self.mapper != .mbc5) bank = 1; + return bank; + } + + fn writeBankRegisters(self: *Cartridge, raw: usize, value: u8) void { + if (raw < 0x4000) { + // MBC2 folds both registers into 0000-3FFF. Address bit 8 + // selects the ROM bank register; clearing it selects the RAM + // enable register. + if (self.mapper == .mbc2) { + if ((raw & 0x100) == 0) { + self.ram_enabled = (value & 0x0f) == 0x0a; + } else { + self.rom_bank_low = value & 0x0f; + if (self.rom_bank_low == 0) self.rom_bank_low = 1; + } + return; + } + if (raw < 0x2000) { + self.ram_enabled = (value & 0x0f) == 0x0a; + return; + } + // MBC5 keeps the low eight bank bits at 2000-2FFF and the + // ninth bit at 3000-3FFF. + if (self.mapper == .mbc5) { + if (raw < 0x3000) { + self.rom_bank_low = value; + } else { + self.rom_bank_high = value & 1; + } + return; + } + // MBC1 uses a five-bit bank register; MBC3 uses seven bits. + self.rom_bank_low = if (self.mapper == .mbc3) value & 0x7f else value & 0x1f; + if (self.rom_bank_low == 0) self.rom_bank_low = 1; + return; + } + switch (raw) { + 0x4000...0x5fff => switch (self.mapper) { + .mbc1 => self.rom_bank_high = value & 0x03, + .mbc3, .mbc5 => self.ram_bank = value & 0x0f, + else => {}, }, - 0x4000...0x5fff => self.rom_bank_high = value & 0x03, - 0x6000...0x7fff => self.banking_mode = value & 1, - 0xa000...0xbfff => if (self.ram.len != 0 and self.ram_enabled) { - var index = @as(usize, address - 0xa000); - if (self.mapper == .mbc1 and self.banking_mode == 1) index += @as(usize, self.rom_bank_high) * 0x2000; - if (index < self.ram.len) self.ram[index] = value; + 0x6000...0x7fff => switch (self.mapper) { + .mbc1 => self.banking_mode = value & 1, + .mbc3 => self.latchRtc(value), + else => {}, }, else => {}, } } + fn readExternal(self: *Cartridge, raw: usize) u8 { + if (self.ram.len == 0 or !self.ram_enabled) return 0xff; + return switch (self.mapper) { + .mbc1 => blk: { + var index = raw - 0xa000; + if (self.banking_mode == 1) index += @as(usize, self.rom_bank_high) * 0x2000; + break :blk if (index < self.ram.len) self.ram[index] else 0xff; + }, + .mbc2 => blk: { + const index = self.mbc2RamIndex(raw); + break :blk 0xf0 | (self.ram[index] & 0x0f); + }, + .mbc3 => blk: { + if (self.ram_bank >= 8 and self.ram_bank <= 0x0c) break :blk self.readRtcRegister(); + const index = @as(usize, self.ram_bank & 0x03) * 0x2000 + (raw - 0xa000); + break :blk if (index < self.ram.len) self.ram[index] else 0xff; + }, + .mbc5 => blk: { + const index = @as(usize, self.ram_bank & 0x0f) * 0x2000 + (raw - 0xa000); + break :blk if (index < self.ram.len) self.ram[index] else 0xff; + }, + .rom_only => 0xff, + }; + } + + fn writeExternal(self: *Cartridge, raw: usize, value: u8) void { + if (self.ram.len == 0 or !self.ram_enabled) return; + switch (self.mapper) { + .mbc1 => { + var index = raw - 0xa000; + if (self.banking_mode == 1) index += @as(usize, self.rom_bank_high) * 0x2000; + if (index < self.ram.len) { + self.ram[index] = value; + self.ram_dirty = true; + } + }, + .mbc2 => { + const index = self.mbc2RamIndex(raw); + self.ram[index] = value & 0x0f; + self.ram_dirty = true; + }, + .mbc3 => { + if (self.ram_bank >= 8 and self.ram_bank <= 0x0c) { + self.writeRtcRegister(value); + return; + } + const index = @as(usize, self.ram_bank & 0x03) * 0x2000 + (raw - 0xa000); + if (index < self.ram.len) { + self.ram[index] = value; + self.ram_dirty = true; + } + }, + .mbc5 => { + const index = @as(usize, self.ram_bank & 0x0f) * 0x2000 + (raw - 0xa000); + if (index < self.ram.len) { + self.ram[index] = value; + self.ram_dirty = true; + } + }, + .rom_only => {}, + } + } + + // MBC2 maps its 512-nibble internal RAM through the lower 9 address + // bits, ignoring bit 0 of the byte address. + fn mbc2RamIndex(self: *const Cartridge, raw: usize) usize { + _ = self; + return ((raw - 0xa000) >> 1) & 0x1ff; + } + + fn readRtcRegister(self: *Cartridge) u8 { + if (self.latch_state == 1) return self.latched_regs[self.ram_bank - 8]; + return self.rtc_regs[self.ram_bank - 8]; + } + + fn writeRtcRegister(self: *Cartridge, value: u8) void { + // The control register is always writable. The time registers + // only accept writes while the clock is halted. + if (self.ram_bank == 0x0c) { + const was_halted = (self.rtc_regs[4] & 0x40) != 0; + self.rtc_regs[4] = value & 0xc0; + if (was_halted and (value & 0x40) == 0) self.recomputeSeconds(); + return; + } + if ((self.rtc_regs[4] & 0x40) != 0) { + self.rtc_regs[self.ram_bank - 8] = value; + self.recomputeSeconds(); + } + } + + // A write of 0 then 1 to 0x6000 latches the clock into the visible + // registers. A trailing 0 releases the latch. + fn latchRtc(self: *Cartridge, value: u8) void { + if (value == 1 and self.latch_state == 0) { + self.latched_regs = self.rtc_regs; + self.latch_state = 1; + } else if (value == 0) { + self.latch_state = 0; + } + } + + // Rebuilds the running second count from the halted registers. The + // clock resumes from this time when the halt bit clears. + fn recomputeSeconds(self: *Cartridge) void { + const days = (@as(u64, self.rtc_regs[4] >> 7) << 8) | (self.rtc_regs[3] & 0xff); + const hours = bcdToBinary(self.rtc_regs[2]); + const minutes = bcdToBinary(self.rtc_regs[1]); + const seconds = bcdToBinary(self.rtc_regs[0]); + self.rtc_total_seconds = @as(u64, days) * 86_400 + @as(u64, hours) * 3_600 + @as(u64, minutes) * 60 + seconds; + self.rtc_cycle_accumulator = 0; + } + + fn refreshRtcRegs(self: *Cartridge) void { + const days = self.rtc_total_seconds / 86_400; + const remainder = self.rtc_total_seconds % 86_400; + const hours = remainder / 3_600; + const minutes = (remainder % 3_600) / 60; + const seconds = remainder % 60; + self.rtc_regs[0] = binaryToBcd(seconds); + self.rtc_regs[1] = binaryToBcd(minutes); + self.rtc_regs[2] = binaryToBcd(hours); + const day_bits = days & 0x1ff; + self.rtc_regs[3] = (self.rtc_regs[3] & 0x80) | @as(u8, @intCast(day_bits & 0xff)); + if (days > 0x1ff) self.rtc_regs[3] |= 0x80; + self.rtc_regs[4] = (self.rtc_regs[4] & 0x40) | (@as(u8, @intCast((day_bits >> 8) & 1)) << 7); + } + + fn bcdToBinary(value: u8) u64 { + return @as(u64, (value >> 4) * 10 + (value & 0x0f)); + } + + fn binaryToBcd(value: u64) u8 { + return @as(u8, @intCast((value / 10) << 4 | (value % 10))); + } + fn romByte(self: *Cartridge, index: usize) u8 { return if (index < self.rom.len) self.rom[index] else 0xff; } }; + +const testing = @import("std").testing; + +// Builds an image with a marker byte at the start of every 16KB bank. +// The header claims the given mapper and RAM size. +fn bankedImage(allocator: std.mem.Allocator, size: usize, mapper_type: u8, ram_type: u8) ![]u8 { + const image = try allocator.alloc(u8, size); + @memset(image, 0); + image[0x147] = mapper_type; + image[0x149] = ram_type; + for (0..size / 0x4000) |bank| { + image[bank * 0x4000] = @intCast(bank + 1); + } + return image; +} + +fn mbc1Image(allocator: std.mem.Allocator) ![]u8 { + return bankedImage(allocator, 0x40000, 0x01, 0x03); +} + +fn mbc2Image(allocator: std.mem.Allocator) ![]u8 { + return bankedImage(allocator, 0x40000, 0x05, 0x00); +} + +fn mbc3Image(allocator: std.mem.Allocator) ![]u8 { + return bankedImage(allocator, 0x200000, 0x0f, 0x03); +} + +fn mbc5Image(allocator: std.mem.Allocator) ![]u8 { + return bankedImage(allocator, 0x200000, 0x19, 0x03); +} + +fn romOnlyImage(allocator: std.mem.Allocator) ![]u8 { + const image = try allocator.alloc(u8, 0x8000); + @memset(image, 0); + image[0x147] = 0x00; // ROM only + return image; +} + +test "ROM-only cartridge ignores mapper writes" { + const image = try romOnlyImage(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + cartridge.write(0x2000, 0x03); + cartridge.write(0x0000, 0x0a); + cartridge.write(0xa000, 0x42); + try testing.expectEqual(@as(u8, 0xff), cartridge.read(0xa000)); + try testing.expectEqual(@as(u8, 0x00), cartridge.read(0x4000)); +} + +test "MBC1 switches ROM banks through the low register" { + const image = try mbc1Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + // Bank 1 is the default for the 4000-7FFF window. + try testing.expectEqual(@as(u8, 2), cartridge.read(0x4000)); + + cartridge.write(0x2000, 0x05); + try testing.expectEqual(@as(u8, 6), cartridge.read(0x4000)); + + // Writing zero selects bank 1 instead of bank 0. + cartridge.write(0x2000, 0x00); + try testing.expectEqual(@as(u8, 2), cartridge.read(0x4000)); +} + +test "MBC1 keeps the low window pinned to bank 0" { + const image = try mbc1Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + cartridge.write(0x2000, 0x07); + try testing.expectEqual(@as(u8, 1), cartridge.read(0x0000)); +} + +test "MBC1 gates external RAM writes on the enable register" { + const image = try mbc1Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + // Disabled RAM ignores writes and reads open-bus. + cartridge.write(0xa000, 0x42); + try testing.expectEqual(@as(u8, 0xff), cartridge.read(0xa000)); + + cartridge.write(0x0000, 0x0a); + cartridge.write(0xa000, 0x42); + try testing.expectEqual(@as(u8, 0x42), cartridge.read(0xa000)); + + // Any other low nibble disables RAM again. + cartridge.write(0x0000, 0x00); + cartridge.write(0xa000, 0x43); + cartridge.write(0x0000, 0x0a); + try testing.expectEqual(@as(u8, 0x42), cartridge.read(0xa000)); +} + +test "MBC1 banking mode selects RAM banks" { + const image = try mbc1Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + cartridge.write(0x0000, 0x0a); + // Mode 1 uses the high ROM register to pick the RAM bank. + cartridge.write(0x6000, 0x01); + cartridge.write(0x4000, 0x02); + cartridge.write(0xa000, 0x11); + try testing.expectEqual(@as(u8, 0x11), cartridge.read(0xa000)); + + // Mode 0 pins RAM to bank 0 and ignores the high register. + cartridge.write(0x6000, 0x00); + cartridge.write(0x4000, 0x00); + cartridge.write(0xa000, 0x22); + try testing.expectEqual(@as(u8, 0x22), cartridge.read(0xa000)); + + // Back in mode 1, RAM bank 2 still holds its earlier byte. + cartridge.write(0x6000, 0x01); + cartridge.write(0x4000, 0x02); + try testing.expectEqual(@as(u8, 0x11), cartridge.read(0xa000)); + + // RAM bank 0 holds the mode-0 write. + cartridge.write(0x4000, 0x00); + try testing.expectEqual(@as(u8, 0x22), cartridge.read(0xa000)); +} + +test "MBC1 reads past the end of ROM as open bus" { + const image = try mbc1Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + // 16 banks fill the image; selecting bank 31 reads past the end. + cartridge.write(0x2000, 0x1f); + cartridge.write(0x4000, 0x03); + try testing.expectEqual(@as(u8, 0xff), cartridge.read(0x4000)); +} + +test "MBC2 selects ROM banks through the bit-8 address" { + const image = try mbc2Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + // Bank 1 is the default for the 4000-7FFF window. + try testing.expectEqual(@as(u8, 2), cartridge.read(0x4000)); + + // Address bit 8 set means a ROM bank write. + cartridge.write(0x2100, 0x03); + try testing.expectEqual(@as(u8, 4), cartridge.read(0x4000)); + + // A low bank value still maps to bank 1. + cartridge.write(0x2100, 0x00); + try testing.expectEqual(@as(u8, 2), cartridge.read(0x4000)); +} + +test "MBC2 gates its 4-bit RAM on the enable register" { + const image = try mbc2Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + // Disabled RAM ignores writes. + cartridge.write(0xa000, 0x0f); + try testing.expectEqual(@as(u8, 0xff), cartridge.read(0xa000)); + + cartridge.write(0x0000, 0x0a); + cartridge.write(0xa000, 0x0f); + // Only the low nibble is stored; the high nibble reads as 0xF. + try testing.expectEqual(@as(u8, 0x0f), cartridge.read(0xa000) & 0x0f); + + // Address bit 8 picks the upper 256-nibble block. + cartridge.write(0xa200, 0x0c); + try testing.expectEqual(@as(u8, 0x0c), cartridge.read(0xa200) & 0x0f); + + // The two blocks are independent. + try testing.expectEqual(@as(u8, 0x0f), cartridge.read(0xa000) & 0x0f); +} + +test "MBC3 switches ROM banks and RAM banks" { + const image = try mbc3Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + // Bank 1 is the default for the 4000-7FFF window. + try testing.expectEqual(@as(u8, 2), cartridge.read(0x4000)); + + cartridge.write(0x2000, 0x05); + try testing.expectEqual(@as(u8, 6), cartridge.read(0x4000)); + + // The seven-bit register reaches banks past the MBC1 range. + cartridge.write(0x2000, 0x40); + try testing.expectEqual(@as(u8, 0x41), cartridge.read(0x4000)); + + // RAM is gated and banked by the 0x4000 register. + cartridge.write(0x0000, 0x0a); + cartridge.write(0x4000, 0x00); + cartridge.write(0xa000, 0x11); + try testing.expectEqual(@as(u8, 0x11), cartridge.read(0xa000)); + + cartridge.write(0x4000, 0x02); + cartridge.write(0xa000, 0x22); + try testing.expectEqual(@as(u8, 0x22), cartridge.read(0xa000)); + + cartridge.write(0x4000, 0x00); + try testing.expectEqual(@as(u8, 0x11), cartridge.read(0xa000)); +} + +test "MBC3 RTC counts seconds in BCD and latches" { + const image = try mbc3Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + cartridge.write(0x0000, 0x0a); + // Select the control register and halt the clock before writing. + cartridge.write(0x4000, 0x0c); + cartridge.write(0xa000, 0x40); // halt bit set + cartridge.write(0x4000, 0x08); // seconds + cartridge.write(0xa000, 0x59); + cartridge.write(0x4000, 0x09); // minutes + cartridge.write(0xa000, 0x00); + cartridge.write(0x4000, 0x0a); // hours + cartridge.write(0xa000, 0x00); + cartridge.write(0x4000, 0x0c); // clear halt + cartridge.write(0xa000, 0x00); + + // One second later the minutes read one. + cartridge.tick(Cartridge.cycles_per_second); + cartridge.write(0x4000, 0x08); + cartridge.write(0x6000, 0x00); + cartridge.write(0x6000, 0x01); + try testing.expectEqual(@as(u8, 0x00), cartridge.read(0xa000)); + cartridge.write(0x4000, 0x09); + cartridge.write(0x6000, 0x00); + cartridge.write(0x6000, 0x01); + try testing.expectEqual(@as(u8, 0x01), cartridge.read(0xa000)); + + // Halting freezes the clock. + cartridge.write(0x4000, 0x0c); + cartridge.write(0xa000, 0x40); + cartridge.tick(Cartridge.cycles_per_second * 2); + cartridge.write(0x4000, 0x09); + cartridge.write(0x6000, 0x00); + cartridge.write(0x6000, 0x01); + try testing.expectEqual(@as(u8, 0x01), cartridge.read(0xa000)); +} + +test "MBC5 switches ROM banks through both registers" { + const image = try mbc5Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + // Bank 1 is the default for the 4000-7FFF window. + try testing.expectEqual(@as(u8, 2), cartridge.read(0x4000)); + + cartridge.write(0x2000, 0x05); + try testing.expectEqual(@as(u8, 6), cartridge.read(0x4000)); + + // Bank 0 is addressable, unlike MBC1. + cartridge.write(0x2000, 0x00); + try testing.expectEqual(@as(u8, 1), cartridge.read(0x4000)); + + // The 0x3000 register extends the bank to nine bits. Bank 0x20 fits + // the image; bank 0x120 does not, so it reads as open bus. + cartridge.write(0x2000, 0x20); + cartridge.write(0x3000, 0x00); + try testing.expectEqual(@as(u8, 0x21), cartridge.read(0x4000)); + cartridge.write(0x3000, 0x01); + try testing.expectEqual(@as(u8, 0xff), cartridge.read(0x4000)); +} + +test "MBC5 banks external RAM up to 16 banks" { + const image = try mbc5Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + cartridge.write(0x0000, 0x0a); + cartridge.write(0x4000, 0x03); + cartridge.write(0xa000, 0x42); + try testing.expectEqual(@as(u8, 0x42), cartridge.read(0xa000)); + + // RAM bank 0 stays fresh while bank 3 holds its byte. + cartridge.write(0x4000, 0x00); + try testing.expectEqual(@as(u8, 0x00), cartridge.read(0xa000)); + + cartridge.write(0x4000, 0x03); + try testing.expectEqual(@as(u8, 0x42), cartridge.read(0xa000)); +} + +test "battery flag matches the cartridge type byte" { + const cases = [_]struct { byte: u8, battery: bool }{ + .{ .byte = 0x00, .battery = false }, + .{ .byte = 0x01, .battery = false }, + .{ .byte = 0x03, .battery = true }, + .{ .byte = 0x06, .battery = true }, + .{ .byte = 0x0f, .battery = true }, + .{ .byte = 0x12, .battery = false }, + .{ .byte = 0x13, .battery = true }, + .{ .byte = 0x1b, .battery = true }, + .{ .byte = 0x1e, .battery = true }, + }; + for (cases) |case| { + var image: [0x8000]u8 = [_]u8{0} ** 0x8000; + image[0x147] = case.byte; + var cartridge = try Cartridge.init(testing.allocator, &image); + defer cartridge.deinit(); + try testing.expectEqual(case.battery, cartridge.hasBattery()); + } +} + +test "external RAM writes mark the save dirty" { + var image: [0x40000]u8 = [_]u8{0} ** 0x40000; + image[0x147] = 0x03; // MBC1 + RAM + battery + image[0x149] = 0x03; // 32KB external RAM + var cartridge = try Cartridge.init(testing.allocator, &image); + defer cartridge.deinit(); + + try testing.expect(!cartridge.saveNeeded()); + // RAM disabled: writes are dropped and stay clean. + cartridge.write(0xa000, 0x42); + try testing.expect(!cartridge.ramDirty()); + + cartridge.write(0x0000, 0x0a); + cartridge.write(0xa000, 0x42); + try testing.expect(cartridge.saveNeeded()); + + cartridge.markRamClean(); + try testing.expect(!cartridge.saveNeeded()); +} + +test "cart without battery never needs a save" { + const image = try romOnlyImage(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + cartridge.write(0xa000, 0x42); + try testing.expect(!cartridge.saveNeeded()); +} + +test "save data round-trips through loadSaveData" { + const image = try mbc1Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + cartridge.write(0x0000, 0x0a); + cartridge.write(0xa000, 0x11); + cartridge.write(0xa001, 0x22); + const saved = try testing.allocator.dupe(u8, cartridge.saveData()); + defer testing.allocator.free(saved); + + var reloaded = try Cartridge.init(testing.allocator, image); + defer reloaded.deinit(); + try reloaded.loadSaveData(saved); + reloaded.write(0x0000, 0x0a); + try testing.expectEqual(@as(u8, 0x11), reloaded.read(0xa000)); + try testing.expectEqual(@as(u8, 0x22), reloaded.read(0xa001)); +} + +test "save data rejects a size mismatch" { + const image = try mbc1Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + const short = [_]u8{0x00} ** 4; + try testing.expectError(error.SaveSizeMismatch, cartridge.loadSaveData(&short)); +} 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/dma.zig b/src/dma.zig new file mode 100644 index 0000000..43276fe --- /dev/null +++ b/src/dma.zig @@ -0,0 +1,73 @@ +// The Game Boy OAM DMA controller. +// +// Writing FF46 copies one page of memory into object attribute memory. +// The transfer copies 0xa0 bytes from page XX00 into FE00-FE9F. It runs +// for 640 T-cycles and blocks the CPU while it works. + +pub const Dma = struct { + source_page: u8 = 0, + offset: u16 = 0, + cycles_left: u16 = 0, + + pub const byte_count: u16 = 0xa0; + pub const cycles_per_byte: u16 = 4; + pub const transfer_cycles: u16 = byte_count * cycles_per_byte; + + pub fn start(self: *Dma, value: u8) void { + self.source_page = value; + self.offset = 0; + self.cycles_left = transfer_cycles; + } + + pub fn active(self: *const Dma) bool { + return self.cycles_left != 0; + } + + // Reading FF46 returns the last page written. + pub fn read(self: *const Dma) u8 { + return self.source_page; + } + + // Returns the source address of the byte for the next machine cycle + // and advances the transfer. Returns null when the transfer is done. + pub fn next(self: *Dma) ?u16 { + if (!self.active()) return null; + const address = @as(u16, self.source_page) << 8 | self.offset; + self.offset += 1; + self.cycles_left -= cycles_per_byte; + return address; + } +}; + +const std = @import("std"); + +test "a fresh controller is idle" { + var dma = Dma{}; + try std.testing.expect(!dma.active()); + try std.testing.expectEqual(@as(?u16, null), dma.next()); + try std.testing.expectEqual(@as(u8, 0), dma.read()); +} + +test "start begins a full transfer" { + var dma = Dma{}; + dma.start(0xc0); + try std.testing.expect(dma.active()); + try std.testing.expectEqual(@as(u8, 0xc0), dma.read()); + var count: usize = 0; + while (dma.next()) |address| { + try std.testing.expectEqual(@as(u16, 0xc000) + @as(u16, @intCast(count)), address); + count += 1; + } + try std.testing.expectEqual(@as(usize, 0xa0), count); + try std.testing.expect(!dma.active()); +} + +test "restarting replaces an in-flight transfer" { + var dma = Dma{}; + dma.start(0x80); + _ = dma.next(); + dma.start(0x40); + try std.testing.expectEqual(@as(u8, 0x40), dma.read()); + const first = dma.next(); + try std.testing.expectEqual(@as(u16, 0x4000), first); +} diff --git a/src/emulator.zig b/src/emulator.zig index 4639f75..dd39fa9 100644 --- a/src/emulator.zig +++ b/src/emulator.zig @@ -26,6 +26,11 @@ pub const Emulator = struct { } pub fn step(self: *Emulator) u16 { + if (self.bus.dma.active()) { + const cycles = self.bus.dmaStep(); + self.total_cycles += cycles; + return cycles; + } const cycles = self.cpu.step(&self.bus); self.total_cycles += cycles; self.total_instructions += 1; @@ -41,7 +46,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; } @@ -51,6 +56,43 @@ pub const Emulator = struct { return self.bus.serialOutput(); } + // True when the loaded cartridge keeps its RAM in a battery save file. + pub fn hasSaveData(self: *const Emulator) bool { + return self.bus.cartridge.hasBattery(); + } + + // A battery-backed cartridge with unflushed RAM needs a save. + pub fn saveNeeded(self: *const Emulator) bool { + return self.bus.cartridge.saveNeeded(); + } + + // Restores external RAM from a .sav file. Returns false when no save + // file exists at the path. + pub fn loadSaveFile(self: *Emulator, io: std.Io, dir: std.Io.Dir, path: []const u8) !bool { + const file = dir.openFile(io, path, .{}) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + defer file.close(io); + const size = try file.length(io); + const bytes = try self.allocator.alloc(u8, @intCast(size)); + defer self.allocator.free(bytes); + _ = try file.readPositionalAll(io, bytes, 0); + try self.bus.cartridge.loadSaveData(bytes); + return true; + } + + // Writes external RAM to a .sav file when the cartridge uses battery + // RAM and the RAM holds unflushed writes. No-op otherwise. + pub fn saveSaveFile(self: *Emulator, io: std.Io, dir: std.Io.Dir, path: []const u8) !void { + const cartridge = &self.bus.cartridge; + if (!cartridge.saveNeeded()) return; + const file = try dir.createFile(io, path, .{}); + defer file.close(io); + _ = try file.writePositionalAll(io, cartridge.saveData(), 0); + cartridge.markRamClean(); + } + pub fn cycleCount(self: *Emulator) u64 { return self.total_cycles; } @@ -59,6 +101,8 @@ pub const Emulator = struct { self.cpu = .{}; self.bus.ppu = .{}; self.bus.timer = .{}; + self.bus.joypad = .{}; + self.bus.dma = .{}; self.bus.serial_len = 0; self.total_cycles = 0; self.total_instructions = 0; @@ -89,3 +133,46 @@ test "page size sanity" { try std.testing.expectEqual(@as(usize, 160), ppu.ScreenWidth); try std.testing.expectEqual(@as(usize, 144), ppu.ScreenHeight); } + +test "save file round-trip persists external RAM" { + var image: [0x40000]u8 = [_]u8{0} ** 0x40000; + image[0x147] = 0x03; // MBC1 + RAM + battery + image[0x149] = 0x03; // 32KB external RAM + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const save_path = "save_test.sav"; + + var emulator = try Emulator.init(std.testing.allocator, &image); + defer emulator.deinit(); + try std.testing.expect(emulator.hasSaveData()); + + // No save file yet: load reports false and RAM stays clear. + try std.testing.expect(!try emulator.loadSaveFile(std.testing.io, tmp.dir, save_path)); + try std.testing.expect(!emulator.saveNeeded()); + + // A RAM write marks the save needed; saving flushes and clears it. + emulator.bus.write(0x0000, 0x0a); + emulator.bus.write(0xa000, 0x5a); + try std.testing.expect(emulator.saveNeeded()); + try emulator.saveSaveFile(std.testing.io, tmp.dir, save_path); + try std.testing.expect(!emulator.saveNeeded()); + + // A fresh emulator loads the saved byte back. + var reloaded = try Emulator.init(std.testing.allocator, &image); + defer reloaded.deinit(); + try std.testing.expect(try reloaded.loadSaveFile(std.testing.io, tmp.dir, save_path)); + reloaded.bus.write(0x0000, 0x0a); + try std.testing.expectEqual(@as(u8, 0x5a), reloaded.bus.read(0xa000)); +} + +test "save is skipped for cartridges without battery RAM" { + const rom = [_]u8{0} ** 0x8000; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var emulator = try Emulator.init(std.testing.allocator, &rom); + defer emulator.deinit(); + try std.testing.expect(!emulator.hasSaveData()); + try emulator.saveSaveFile(std.testing.io, tmp.dir, "noop.sav"); + try std.testing.expectError(error.FileNotFound, tmp.dir.openFile(std.testing.io, "noop.sav", .{})); +} diff --git a/src/frontend.zig b/src/frontend.zig new file mode 100644 index 0000000..9c19fcf --- /dev/null +++ b/src/frontend.zig @@ -0,0 +1,347 @@ +// Frontend presentation logic that does not depend on SDL2. +// +// The windowed frontend maps keyboard keys to the joypad, lays out the +// window, and shades the LCD palette. Keeping this logic free of SDL2 +// lets the unit tests run in headless builds. + +const std = @import("std"); +const Bus = @import("bus.zig").Bus; +const Joypad = @import("joypad.zig").Joypad; +const ppu = @import("ppu.zig"); + +pub const screen_scale = 3; +pub const bezel_padding = 16; + +pub const window_width = ppu.ScreenWidth * screen_scale + bezel_padding * 2; +pub const window_height = ppu.ScreenHeight * screen_scale + bezel_padding * 2; + +pub const screen_rect = struct { + pub const x: c_int = bezel_padding; + pub const y: c_int = bezel_padding; + pub const width: c_int = ppu.ScreenWidth * screen_scale; + pub const height: c_int = ppu.ScreenHeight * screen_scale; +}; + +// Logical keys. The SDL2 frontend translates scancodes into these. +pub const Key = enum { + right, + left, + up, + down, + a, + b, + select, + start, + pause, + reset, + fast_forward, +}; + +pub const KeyAction = enum { button, direction }; + +pub const Binding = struct { + key: Key, + action: KeyAction, + bit: u3, +}; + +pub const bindings = [_]Binding{ + .{ .key = .a, .action = .button, .bit = 0 }, + .{ .key = .b, .action = .button, .bit = 1 }, + .{ .key = .select, .action = .button, .bit = 2 }, + .{ .key = .start, .action = .button, .bit = 3 }, + .{ .key = .right, .action = .direction, .bit = 0 }, + .{ .key = .left, .action = .direction, .bit = 1 }, + .{ .key = .up, .action = .direction, .bit = 2 }, + .{ .key = .down, .action = .direction, .bit = 3 }, +}; + +pub const RunState = struct { + paused: bool = false, + fast_forward: bool = false, + running: bool = true, + + pub fn togglePause(self: *RunState) void { + self.paused = !self.paused; + } + + pub fn toggleFastForward(self: *RunState) void { + self.fast_forward = !self.fast_forward; + } +}; + +// Applies one key event to the bus. Returns false for control keys that +// the frontend must handle itself. +pub fn applyKey(bus: *Bus, key: Key, pressed: bool) bool { + if (key == .pause or key == .reset or key == .fast_forward) return false; + for (bindings) |binding| { + if (binding.key == key) { + switch (binding.action) { + .button => bus.setButton(@enumFromInt(binding.bit), pressed), + .direction => bus.setDirection(@enumFromInt(binding.bit), pressed), + } + return true; + } + } + return false; +} + +// A physical key bound to a logical key. The physical key is an SDL +// scancode name such as "z", "return", or "left shift". Keeping SDL out +// of this file lets the parser and defaults run in headless tests. +pub const KeyBinding = struct { + key: Key, + scancode_name: []const u8, +}; + +// The built-in bindings. A config file overrides only named logical keys. +pub fn defaultKeyMap() []const KeyBinding { + return &[_]KeyBinding{ + .{ .key = .a, .scancode_name = "z" }, + .{ .key = .b, .scancode_name = "x" }, + .{ .key = .select, .scancode_name = "left shift" }, + .{ .key = .select, .scancode_name = "right shift" }, + .{ .key = .start, .scancode_name = "return" }, + .{ .key = .right, .scancode_name = "right" }, + .{ .key = .left, .scancode_name = "left" }, + .{ .key = .up, .scancode_name = "up" }, + .{ .key = .down, .scancode_name = "down" }, + .{ .key = .pause, .scancode_name = "p" }, + .{ .key = .reset, .scancode_name = "r" }, + .{ .key = .fast_forward, .scancode_name = "f" }, + }; +} + +// Returns the logical key bound to a physical key, or null. +pub fn keyForScancodeName(map: []const KeyBinding, name: []const u8) ?Key { + for (map) |binding| { + if (std.mem.eql(u8, binding.scancode_name, name)) return binding.key; + } + return null; +} + +pub const KeyConfigError = error{ + ExpectedEquals, + UnknownKey, + DuplicateKey, + DuplicateScancode, + EmptyBinding, +}; + +// Parses a key config file into bindings. Each line maps a logical key to +// one or more comma-separated SDL scancode names. Blank lines and lines +// that start with '#' are ignored. The returned bindings point into the +// input text, so the caller must keep the text alive. +pub fn parseKeyMap(allocator: std.mem.Allocator, text: []const u8) ![]KeyBinding { + var entries: std.ArrayListUnmanaged(KeyBinding) = .empty; + errdefer entries.deinit(allocator); + var lines = std.mem.splitScalar(u8, text, '\n'); + while (lines.next()) |raw_line| { + const line = std.mem.trim(u8, raw_line, " \t\r"); + if (line.len == 0 or line[0] == '#') continue; + const equals = std.mem.indexOfScalar(u8, line, '=') orelse return error.ExpectedEquals; + const key = std.meta.stringToEnum(Key, std.mem.trim(u8, line[0..equals], " \t")) orelse + return error.UnknownKey; + for (entries.items) |existing| { + if (existing.key == key) return error.DuplicateKey; + } + var assigned = false; + var names = std.mem.splitScalar(u8, line[equals + 1 ..], ','); + while (names.next()) |raw_name| { + const name = std.mem.trim(u8, raw_name, " \t\r"); + if (name.len == 0) continue; + for (entries.items) |existing| { + if (std.mem.eql(u8, existing.scancode_name, name)) return error.DuplicateScancode; + } + try entries.append(allocator, .{ .key = key, .scancode_name = name }); + assigned = true; + } + if (!assigned) return error.EmptyBinding; + } + return entries.toOwnedSlice(allocator); +} + +// DMG-inspired presentation palette. The frame stores 0xAARRGGBB values. +pub const palette = struct { + pub const shell: u32 = 0xffd9d9b8; + pub const bezel: u32 = 0xff3a3a2e; + pub const screen_back: u32 = 0xff9ca89a; + pub const paused_bar: u32 = 0xff101010; +}; + +const testing = std.testing; + +fn testBus() !Bus { + const rom = [_]u8{0} ** 0x8000; + return try Bus.init(testing.allocator, &rom); +} + +test "bindings cover the eight joypad inputs" { + var seen_buttons: u8 = 0; + var seen_directions: u8 = 0; + for (bindings) |binding| { + switch (binding.action) { + .button => seen_buttons |= @as(u8, 1) << binding.bit, + .direction => seen_directions |= @as(u8, 1) << binding.bit, + } + } + try testing.expectEqual(@as(u8, 0x0f), seen_buttons); + try testing.expectEqual(@as(u8, 0x0f), seen_directions); +} + +test "applying a key presses the right joypad column" { + var bus = try testBus(); + defer bus.deinit(); + try testing.expect(applyKey(&bus, .a, true)); + try testing.expect(bus.joypad.isButtonPressed(Joypad.Button.a)); + try testing.expect(!bus.joypad.isDirectionPressed(Joypad.Direction.right)); + try testing.expect(applyKey(&bus, .up, true)); + try testing.expect(bus.joypad.isDirectionPressed(Joypad.Direction.up)); +} + +test "releasing a key restores the released state" { + var bus = try testBus(); + defer bus.deinit(); + try testing.expect(applyKey(&bus, .b, true)); + try testing.expect(applyKey(&bus, .b, false)); + try testing.expect(!bus.joypad.isButtonPressed(Joypad.Button.b)); +} + +test "control keys are not forwarded to the joypad" { + var bus = try testBus(); + defer bus.deinit(); + try testing.expect(!applyKey(&bus, .pause, true)); + try testing.expect(!applyKey(&bus, .reset, true)); + try testing.expect(!applyKey(&bus, .fast_forward, true)); + try testing.expectEqual(@as(u8, 0xff), bus.read(0xff00)); +} + +test "window layout keeps the screen centered" { + try testing.expectEqual(@as(usize, 512), window_width); + try testing.expectEqual(@as(usize, 464), window_height); + try testing.expectEqual(@as(usize, ppu.ScreenWidth * screen_scale), @as(usize, @intCast(screen_rect.width))); + try testing.expectEqual(@as(usize, ppu.ScreenHeight * screen_scale), @as(usize, @intCast(screen_rect.height))); +} + +test "run state toggles stay independent" { + var state = RunState{}; + state.togglePause(); + try testing.expect(state.paused); + state.togglePause(); + try testing.expect(!state.paused); + state.toggleFastForward(); + try testing.expect(state.fast_forward); + try testing.expect(state.running); +} + +test "default key map covers every logical key" { + var seen = [_]bool{false} ** @as(usize, @typeInfo(Key).@"enum".fields.len); + for (defaultKeyMap()) |binding| { + seen[@intFromEnum(binding.key)] = true; + } + for (seen) |present| try testing.expect(present); +} + +test "default key map keeps the arrow and shift defaults" { + const map = defaultKeyMap(); + try testing.expectEqual(@as(Key, .a), keyForScancodeName(map, "z").?); + try testing.expectEqual(@as(Key, .select), keyForScancodeName(map, "left shift").?); + try testing.expectEqual(@as(Key, .select), keyForScancodeName(map, "right shift").?); + try testing.expectEqual(@as(Key, .up), keyForScancodeName(map, "up").?); + try testing.expectEqual(@as(Key, .reset), keyForScancodeName(map, "r").?); + try testing.expectEqual(@as(?Key, null), keyForScancodeName(map, "k")); +} + +test "key config parses comments, blanks, and key lists" { + const text = + \\# custom bindings + \\a = k + \\select = left shift, right shift + \\start = space + \\right = w + \\ + \\left = a + ; + const parsed = try parseKeyMap(testing.allocator, text); + defer testing.allocator.free(parsed); + try testing.expectEqual(@as(usize, 6), parsed.len); + try testing.expectEqual(@as(Key, .a), keyForScancodeName(parsed, "k").?); + try testing.expectEqual(@as(Key, .select), keyForScancodeName(parsed, "right shift").?); + try testing.expectEqual(@as(Key, .start), keyForScancodeName(parsed, "space").?); + try testing.expectEqual(@as(Key, .right), keyForScancodeName(parsed, "w").?); + try testing.expectEqual(@as(Key, .left), keyForScancodeName(parsed, "a").?); + try testing.expectEqual(@as(?Key, null), keyForScancodeName(parsed, "z")); +} + +test "remapped key config drives the joypad" { + const text = "b = k\n"; + const parsed = try parseKeyMap(testing.allocator, text); + defer testing.allocator.free(parsed); + var bus = try testBus(); + defer bus.deinit(); + const key = keyForScancodeName(parsed, "k").?; + try testing.expect(applyKey(&bus, key, true)); + try testing.expect(bus.joypad.isButtonPressed(Joypad.Button.b)); +} + +test "key config rejects malformed lines" { + try testing.expectError(error.ExpectedEquals, parseKeyMap(testing.allocator, "a z\n")); + try testing.expectError(error.UnknownKey, parseKeyMap(testing.allocator, "turbo = z\n")); + try testing.expectError(error.DuplicateKey, parseKeyMap(testing.allocator, "a = z\na = x\n")); + try testing.expectError(error.DuplicateScancode, parseKeyMap(testing.allocator, "a = z\nb = z\n")); + try testing.expectError(error.EmptyBinding, parseKeyMap(testing.allocator, "a =\n")); +} + +test "key config rejects an empty file without error" { + const parsed = try parseKeyMap(testing.allocator, "# nothing\n\n"); + defer testing.allocator.free(parsed); + try testing.expectEqual(@as(usize, 0), parsed.len); +} + +// Combines partial overrides with the built-in map. The returned bindings +// point into the override text, so the caller must keep that text alive. +pub fn mergeKeyMap(allocator: std.mem.Allocator, overrides: []const KeyBinding) ![]KeyBinding { + var merged: std.ArrayListUnmanaged(KeyBinding) = .empty; + errdefer merged.deinit(allocator); + + for (defaultKeyMap()) |binding| { + var overridden = false; + for (overrides) |override| { + if (override.key == binding.key) { + overridden = true; + break; + } + } + if (!overridden) try merged.append(allocator, binding); + } + for (overrides) |override| try merged.append(allocator, override); + + for (merged.items, 0..) |binding, index| { + for (merged.items[index + 1 ..]) |other| { + if (std.mem.eql(u8, binding.scancode_name, other.scancode_name)) { + return error.DuplicateScancode; + } + } + } + return merged.toOwnedSlice(allocator); +} + +test "partial key config keeps unspecified defaults" { + const overrides = try parseKeyMap(testing.allocator, "a = k\nselect = space\n"); + defer testing.allocator.free(overrides); + const merged = try mergeKeyMap(testing.allocator, overrides); + defer testing.allocator.free(merged); + + try testing.expectEqual(@as(Key, .a), keyForScancodeName(merged, "k").?); + try testing.expectEqual(@as(?Key, null), keyForScancodeName(merged, "z")); + try testing.expectEqual(@as(Key, .b), keyForScancodeName(merged, "x").?); + try testing.expectEqual(@as(Key, .select), keyForScancodeName(merged, "space").?); + try testing.expectEqual(@as(?Key, null), keyForScancodeName(merged, "left shift")); + try testing.expectEqual(@as(Key, .start), keyForScancodeName(merged, "return").?); +} + +test "merged key config rejects a physical key collision" { + const overrides = try parseKeyMap(testing.allocator, "a = x\n"); + defer testing.allocator.free(overrides); + try testing.expectError(error.DuplicateScancode, mergeKeyMap(testing.allocator, overrides)); +} diff --git a/src/headless.zig b/src/headless.zig index 9ec1f1f..5acbdd2 100644 --- a/src/headless.zig +++ b/src/headless.zig @@ -12,6 +12,8 @@ const Options = struct { trace: bool = false, expect: ?serial.Verdict = null, timeout_ms: u64 = 120_000, + load_save: ?[]const u8 = null, + save: ?[]const u8 = null, }; fn usage(program: []const u8) void { @@ -19,10 +21,12 @@ fn usage(program: []const u8) void { \\Dot Matrix Deck - headless runner \\Usage: \\ {s} [--max-cycles N] [--trace] [--expect pass|fail|any] + \\ [--load-save ] [--save ] \\ {s} --blargg-suite \\ \\Runs a ROM without a window, prints its serial output, and sets the - \\exit code from the test verdict. + \\exit code from the test verdict. --load-save restores battery RAM + \\before the run; --save writes battery RAM to a file after the run. \\ , .{ program, program }); } @@ -84,6 +88,18 @@ fn runRom(io: std.Io, allocator: std.mem.Allocator, options: Options) !u8 { var emulator = try Emulator.init(allocator, rom); defer emulator.deinit(); + if (options.load_save) |path| { + if (!emulator.hasSaveData()) { + std.debug.print("Cartridge has no battery RAM; save file ignored.\n", .{}); + } else { + const loaded = emulator.loadSaveFile(io, std.Io.Dir.cwd(), path) catch |err| { + std.debug.print("Could not load save file {s}: {s}\n", .{ path, @errorName(err) }); + return 1; + }; + std.debug.print("{s}\n", .{if (loaded) "Loaded save data." else "No save file found; starting fresh."}); + } + } + var runner = Runner{ .emulator = &emulator, .options = options, @@ -103,6 +119,13 @@ fn runRom(io: std.Io, allocator: std.mem.Allocator, options: Options) !u8 { const wanted = options.expect orelse verdict; const ok = wanted == verdict; std.debug.print("Verdict: {s}\n", .{@tagName(verdict)}); + + if (options.save) |path| { + emulator.saveSaveFile(io, std.Io.Dir.cwd(), path) catch |err| { + std.debug.print("Could not write save file {s}: {s}\n", .{ path, @errorName(err) }); + return 1; + }; + } return if (ok) 0 else 1; } @@ -203,6 +226,20 @@ pub fn main(init: std.process.Init) !void { std.process.exit(2); } options.blargg_suite = argv[index]; + } else if (std.mem.eql(u8, arg, "--load-save")) { + index += 1; + if (index >= argv.len) { + std.debug.print("--load-save needs a path\n", .{}); + std.process.exit(2); + } + options.load_save = argv[index]; + } else if (std.mem.eql(u8, arg, "--save")) { + index += 1; + if (index >= argv.len) { + std.debug.print("--save needs a path\n", .{}); + std.process.exit(2); + } + options.save = argv[index]; } else if (options.rom_path == null) { options.rom_path = arg; } else { diff --git a/src/joypad.zig b/src/joypad.zig new file mode 100644 index 0000000..08ba4f2 --- /dev/null +++ b/src/joypad.zig @@ -0,0 +1,123 @@ +// The Game Boy joypad device. +// +// The register at 0xff00 reports key state through the low nibble. +// Bit 4 and bit 5 of the register select which column is active. +// A zero bit selects a column. A pressed key drives its bit low. +// When both columns are selected, the read is the AND of the two. + +const std = @import("std"); + +pub const Joypad = struct { + buttons: u8 = 0x0f, + dpad: u8 = 0x0f, + select: u8 = 0x30, + + // Low-nibble bit positions, active low. + pub const Button = enum(u3) { a = 0, b = 1, select = 2, start = 3 }; + + pub const Direction = enum(u3) { right = 0, left = 1, up = 2, down = 3 }; + + pub fn setButton(self: *Joypad, bit: Button, pressed: bool) void { + const mask: u8 = @as(u8, 1) << @intFromEnum(bit); + self.buttons = if (pressed) self.buttons & ~mask else self.buttons | mask; + } + + pub fn setDirection(self: *Joypad, bit: Direction, pressed: bool) void { + const mask: u8 = @as(u8, 1) << @intFromEnum(bit); + self.dpad = if (pressed) self.dpad & ~mask else self.dpad | mask; + } + + pub fn isButtonPressed(self: *const Joypad, bit: Button) bool { + return (self.buttons & (@as(u8, 1) << @intFromEnum(bit))) == 0; + } + + pub fn isDirectionPressed(self: *const Joypad, bit: Direction) bool { + return (self.dpad & (@as(u8, 1) << @intFromEnum(bit))) == 0; + } + + // Reading FF00. The stored select bits come back in bits 4 and 5. + // A cleared bit 4 activates the D-pad; a cleared bit 5 activates the + // buttons. Unselected columns report 1 (released). + pub fn read(self: *const Joypad) u8 { + var value: u8 = 0x0f; + if ((self.select & 0x10) == 0) value &= self.dpad; + if ((self.select & 0x20) == 0) value &= self.buttons; + return 0xc0 | (self.select & 0x30) | value; + } + + // Writing FF00. Only the two select bits are stored. + pub fn write(self: *Joypad, value: u8) void { + self.select = value & 0x30; + } +}; + +const testing = std.testing; + +test "default state reads as all released" { + const joypad = Joypad{}; + // No column is selected, so the whole register reads released. + try testing.expectEqual(@as(u8, 0xff), joypad.read()); +} + +test "reading returns the stored select bits" { + var joypad = Joypad{}; + joypad.write(0x20); + try testing.expectEqual(@as(u8, 0x20), joypad.read() & 0x30); + joypad.write(0x10); + try testing.expectEqual(@as(u8, 0x10), joypad.read() & 0x30); +} + +test "buttons column reflects pressed keys" { + var joypad = Joypad{}; + joypad.write(0x10); // bit 5 low selects the buttons column. + joypad.setButton(.a, true); + joypad.setButton(.start, true); + const value = joypad.read(); + // a (bit 0) and start (bit 3) are low; B and select stay high. + try testing.expectEqual(@as(u8, 0x06), value & 0x0f); +} + +test "dpad column reflects pressed directions" { + var joypad = Joypad{}; + joypad.write(0x20); // bit 4 low selects the D-pad column. + joypad.setDirection(.up, true); + joypad.setDirection(.down, true); + const value = joypad.read(); + // up (bit 2) and down (bit 3) are low; right and left stay high. + try testing.expectEqual(@as(u8, 0x03), value & 0x0f); +} + +test "unselected column reads released" { + var joypad = Joypad{}; + joypad.write(0x20); // only the D-pad column is selected. + joypad.setButton(.a, true); + // The button press hides in the unselected buttons column. + try testing.expectEqual(@as(u8, 0x0f), joypad.read() & 0x0f); +} + +test "both columns selected reads the AND of both" { + var joypad = Joypad{}; + joypad.write(0x00); // both columns are selected. + joypad.setButton(.a, true); + joypad.setDirection(.right, true); + // bit 0 is driven low by both columns. + try testing.expectEqual(@as(u8, 0x0e), joypad.read() & 0x0f); +} + +test "release restores the released state" { + var joypad = Joypad{}; + joypad.write(0x10); + joypad.setButton(.b, true); + joypad.setButton(.b, false); + try testing.expectEqual(@as(u8, 0x0f), joypad.read() & 0x0f); +} + +test "query helpers reflect active low storage" { + var joypad = Joypad{}; + joypad.setButton(.a, true); + joypad.setDirection(.left, true); + try testing.expect(joypad.isButtonPressed(.a)); + try testing.expect(!joypad.isButtonPressed(.b)); + try testing.expect(joypad.isDirectionPressed(.left)); + try testing.expect(!joypad.isDirectionPressed(.up)); +} diff --git a/src/main.zig b/src/main.zig index 643116a..8663a7c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,330 @@ const std = @import("std"); +const Emulator = @import("emulator.zig").Emulator; +const frontend = @import("frontend.zig"); +const ppu = @import("ppu.zig"); -pub fn main() !void { - std.debug.print("Dot Matrix Deck: windowed frontend is under construction.\n", .{}); +const c = @cImport({ + @cDefine("SDL_MAIN_HANDLED", "1"); + @cInclude("SDL2/SDL.h"); +}); + +const frame_cap: u64 = 1 << 24; +const frame_ms: u32 = 16; +const fast_forward_frames: u32 = 4; + +const default_key_config = "dmd.keys.conf"; + +const ScancodeKey = struct { + scancode: c.SDL_Scancode, + key: frontend.Key, +}; + +// Resolves scancode names from the key map into SDL scancodes. Each name +// comes from the SDL scancode vocabulary, for example "z" or "left shift". +fn resolveKeyMap(allocator: std.mem.Allocator, bindings: []const frontend.KeyBinding) ![]ScancodeKey { + var resolved: std.ArrayListUnmanaged(ScancodeKey) = .empty; + errdefer resolved.deinit(allocator); + for (bindings) |binding| { + const name = try allocator.dupeZ(u8, binding.scancode_name); + defer allocator.free(name); + const scancode = c.SDL_GetScancodeFromName(name.ptr); + if (scancode == c.SDL_SCANCODE_UNKNOWN) { + std.debug.print("Unknown key name in config: {s}\n", .{binding.scancode_name}); + return error.UnknownKeyName; + } + try resolved.append(allocator, .{ .scancode = scancode, .key = binding.key }); + } + return resolved.toOwnedSlice(allocator); +} + +fn keyFor(resolved: []const ScancodeKey, scancode: c.SDL_Scancode) ?frontend.Key { + for (resolved) |entry| { + if (entry.scancode == scancode) return entry.key; + } + return null; +} + +fn usage(program: []const u8) void { + std.debug.print( + \\Dot Matrix Deck - SDL2 windowed frontend + \\Usage: + \\ {s} [--keys ] + \\ + \\Keys: Z=A, X=B, Enter=Start, Shift=Select, arrows=d-pad. + \\P pause, R reset, F fast forward, ESC quit. + \\Battery RAM loads from and saves to a .sav file next to the ROM. + \\Key bindings load from dmd.keys.conf in the working directory. + \\Pass --keys to use another config file instead. + \\ + , .{program}); +} + +fn readFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const dir = std.Io.Dir.cwd(); + const file = try dir.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; +} + +// Reads the key config file. When the path is explicit and the file is +// missing, returns an error. When no path is given and the default file +// is missing, returns null so the built-in defaults apply. +fn loadKeyConfig(io: std.Io, allocator: std.mem.Allocator, keys_path: ?[]const u8) !?[]u8 { + const path = keys_path orelse default_key_config; + const dir = std.Io.Dir.cwd(); + if (dir.access(io, path, .{})) |_| { + return try readFile(io, allocator, path); + } else |_| { + if (keys_path != null) return error.MissingKeyConfig; + return null; + } +} + +// A battery save file sits next to the ROM with a .sav extension. +fn savePathFor(allocator: std.mem.Allocator, rom_path: []const u8) ![]u8 { + const extension = std.fs.path.extension(rom_path); + if (extension.len == 0) return std.fmt.allocPrint(allocator, "{s}.sav", .{rom_path}); + return std.fmt.allocPrint(allocator, "{s}.sav", .{rom_path[0 .. rom_path.len - extension.len]}); +} + +fn sdlError(context: []const u8) noreturn { + std.debug.print("{s}: {s}\n", .{ context, std.mem.span(c.SDL_GetError()) }); + std.process.exit(1); +} + +fn rect(x: c_int, y: c_int, width: c_int, height: c_int) c.SDL_Rect { + return .{ .x = x, .y = y, .w = width, .h = height }; +} + +const color = struct { + fn set(renderer: *c.SDL_Renderer, value: u32) void { + _ = c.SDL_SetRenderDrawColor(renderer, @truncate(value >> 16), @truncate(value >> 8), @truncate(value), 0xff); + } +}; + +fn drawPauseOverlay(renderer: *c.SDL_Renderer) void { + const bar_w: c_int = 6; + const bar_h: c_int = @intCast(frontend.screen_rect.height / 3); + const gap: c_int = 14; + const y = frontend.screen_rect.y + (frontend.screen_rect.height - bar_h) / 2; + const x1 = frontend.screen_rect.x + frontend.screen_rect.width / 2 - gap - bar_w; + const x2 = frontend.screen_rect.x + frontend.screen_rect.width / 2 + gap; + color.set(renderer, frontend.palette.paused_bar); + _ = c.SDL_RenderFillRect(renderer, &rect(x1, y, bar_w, bar_h)); + _ = c.SDL_RenderFillRect(renderer, &rect(x2, y, bar_w, bar_h)); +} + +// Draws faint lines between pixels to echo the DMG dot matrix. +fn drawPixelGrid(renderer: *c.SDL_Renderer) void { + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + _ = c.SDL_SetRenderDrawColor(renderer, 0x10, 0x14, 0x10, 0x26); + var index: usize = 1; + while (index < ppu.ScreenWidth) : (index += 1) { + const x = frontend.screen_rect.x + @as(c_int, @intCast(index * frontend.screen_scale)); + _ = c.SDL_RenderDrawLine(renderer, x, frontend.screen_rect.y, x, frontend.screen_rect.y + frontend.screen_rect.height - 1); + } + index = 1; + while (index < ppu.ScreenHeight) : (index += 1) { + const y = frontend.screen_rect.y + @as(c_int, @intCast(index * frontend.screen_scale)); + _ = c.SDL_RenderDrawLine(renderer, frontend.screen_rect.x, y, frontend.screen_rect.x + frontend.screen_rect.width - 1, y); + } + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_NONE); +} + +// Re-applies held keys after a reset so the joypad state stays in sync. +fn syncHeldKeys(emulator: *Emulator, resolved: []const ScancodeKey) void { + const keyboard = c.SDL_GetKeyboardState(null); + for (resolved) |entry| { + _ = frontend.applyKey(&emulator.bus, entry.key, false); + } + for (resolved) |entry| { + const down = keyboard[entry.scancode] != 0; + _ = frontend.applyKey(&emulator.bus, entry.key, down); + } +} + +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); + + var keys_path: ?[]const u8 = null; + var rom_path: ?[]const u8 = null; + var index: usize = 1; + while (index < args.items.len) : (index += 1) { + const arg = args.items[index]; + if (std.mem.eql(u8, arg, "--keys")) { + index += 1; + if (index >= args.items.len) { + std.debug.print("--keys needs a path\n", .{}); + std.process.exit(2); + } + keys_path = args.items[index]; + } else if (rom_path == null) { + rom_path = arg; + } else { + usage(args.items[0]); + std.process.exit(2); + } + } + + if (rom_path == null) { + usage(args.items[0]); + std.process.exit(2); + } + + const rom = try readFile(io, allocator, rom_path.?); + defer allocator.free(rom); + + const key_text = loadKeyConfig(io, allocator, keys_path) catch |err| { + std.debug.print("Could not read key config: {s}\n", .{@errorName(err)}); + std.process.exit(1); + }; + defer if (key_text) |text| allocator.free(text); + + var bindings: []const frontend.KeyBinding = frontend.defaultKeyMap(); + var bindings_owned = false; + if (key_text) |text| { + const overrides = frontend.parseKeyMap(allocator, text) catch |err| { + std.debug.print("Bad key config: {s}\n", .{@errorName(err)}); + std.process.exit(1); + }; + defer allocator.free(overrides); + bindings = frontend.mergeKeyMap(allocator, overrides) catch |err| { + std.debug.print("Bad key config: {s}\n", .{@errorName(err)}); + std.process.exit(1); + }; + bindings_owned = true; + } + defer if (bindings_owned) allocator.free(bindings); + + const resolved = resolveKeyMap(allocator, bindings) catch |err| { + std.debug.print("Bad key config: {s}\n", .{@errorName(err)}); + std.process.exit(1); + }; + defer allocator.free(resolved); + + if (c.SDL_Init(c.SDL_INIT_VIDEO) != 0) sdlError("SDL_Init failed"); + defer c.SDL_Quit(); + + const window = c.SDL_CreateWindow( + "Dot Matrix Deck", + c.SDL_WINDOWPOS_CENTERED, + c.SDL_WINDOWPOS_CENTERED, + @intCast(frontend.window_width), + @intCast(frontend.window_height), + c.SDL_WINDOW_SHOWN, + ) orelse sdlError("SDL_CreateWindow failed"); + defer c.SDL_DestroyWindow(window); + + const renderer = c.SDL_CreateRenderer(window, -1, c.SDL_RENDERER_ACCELERATED) orelse + sdlError("SDL_CreateRenderer failed"); + defer c.SDL_DestroyRenderer(renderer); + + const texture = c.SDL_CreateTexture( + renderer, + c.SDL_PIXELFORMAT_ARGB8888, + c.SDL_TEXTUREACCESS_STREAMING, + ppu.ScreenWidth, + ppu.ScreenHeight, + ) orelse sdlError("SDL_CreateTexture failed"); + defer c.SDL_DestroyTexture(texture); + + var emulator = try Emulator.init(allocator, rom); + defer emulator.deinit(); + + const save_path = try savePathFor(allocator, rom_path.?); + defer allocator.free(save_path); + var loaded = false; + if (emulator.loadSaveFile(io, std.Io.Dir.cwd(), save_path)) |ok| { + loaded = ok; + } else |err| { + std.debug.print("Could not load save file {s}: {s}\n", .{ save_path, @errorName(err) }); + } + if (emulator.hasSaveData()) { + std.debug.print("{s}\n", .{if (loaded) "Loaded save data." else "No save data found; starting fresh."}); + } + + var run_state = frontend.RunState{}; + var last_tick = c.SDL_GetTicks(); + + while (run_state.running) { + var event: c.SDL_Event = undefined; + while (c.SDL_PollEvent(&event) != 0) { + switch (event.type) { + c.SDL_QUIT => run_state.running = false, + c.SDL_KEYDOWN => { + if (event.key.repeat != 0) break; + const scancode = event.key.keysym.scancode; + if (scancode == c.SDL_SCANCODE_ESCAPE) { + run_state.running = false; + break; + } + const key = keyFor(resolved, scancode) orelse continue; + switch (key) { + .pause => run_state.togglePause(), + .fast_forward => run_state.toggleFastForward(), + .reset => { + emulator.reset(); + syncHeldKeys(&emulator, resolved); + }, + else => _ = frontend.applyKey(&emulator.bus, key, true), + } + }, + c.SDL_KEYUP => { + if (event.key.repeat != 0) break; + const key = keyFor(resolved, event.key.keysym.scancode) orelse continue; + _ = frontend.applyKey(&emulator.bus, key, false); + }, + else => {}, + } + } + + if (!run_state.paused) { + const frames: u32 = if (run_state.fast_forward) fast_forward_frames else 1; + var frame_index: u32 = 0; + while (frame_index < frames) : (frame_index += 1) { + emulator.runFrame(frame_cap); + } + } + + const frame = emulator.bus.ppu.frame; + _ = c.SDL_UpdateTexture(texture, null, &frame, @as(c_int, @intCast(ppu.ScreenWidth * @sizeOf(u32)))); + color.set(renderer, frontend.palette.shell); + _ = c.SDL_RenderClear(renderer); + color.set(renderer, frontend.palette.bezel); + _ = c.SDL_RenderFillRect(renderer, &rect( + frontend.screen_rect.x - 6, + frontend.screen_rect.y - 6, + frontend.screen_rect.width + 12, + frontend.screen_rect.height + 12, + )); + _ = c.SDL_RenderCopy(renderer, texture, null, &rect( + frontend.screen_rect.x, + frontend.screen_rect.y, + frontend.screen_rect.width, + frontend.screen_rect.height, + )); + drawPixelGrid(renderer); + if (run_state.paused) drawPauseOverlay(renderer); + c.SDL_RenderPresent(renderer); + + const now = c.SDL_GetTicks(); + const elapsed = now -% last_tick; + if (elapsed < frame_ms) c.SDL_Delay(frame_ms - elapsed); + last_tick = now; + } + + emulator.saveSaveFile(io, std.Io.Dir.cwd(), save_path) catch |err| { + std.debug.print("Could not write save file {s}: {s}\n", .{ save_path, @errorName(err) }); + }; } diff --git a/src/tests.zig b/src/tests.zig new file mode 100644 index 0000000..57a97f4 --- /dev/null +++ b/src/tests.zig @@ -0,0 +1,15 @@ +// Root module for the deterministic unit test suite. +// +// Importing a module pulls its test blocks into the test binary, so this +// root file exercises the whole emulator core and the frontend logic. + +test { + _ = @import("emulator.zig"); + _ = @import("bus.zig"); + _ = @import("cartridge.zig"); + _ = @import("dma.zig"); + _ = @import("joypad.zig"); + _ = @import("frontend.zig"); + _ = @import("serial.zig"); + _ = @import("disasm.zig"); +} diff --git a/tools/gbasm.zig b/tools/gbasm.zig index e81a221..a69c5cd 100644 --- a/tools/gbasm.zig +++ b/tools/gbasm.zig @@ -1,5 +1,880 @@ +// gbasm - a small SM83 assembler for Game Boy test ROMs. +// +// It reads a single .asm file or a whole directory of sources and +// writes raw .gb images. The assembler targets the full 256-opcode +// SM83 set plus the CB prefix group. +// +// Directives: +// ORG
set the output position, filling gaps with zero +// PAD
alias for ORG, used to size the final image +// DB emit bytes; items may be strings or numbers +// DW emit little-endian 16-bit values +// DS [,] emit bytes of +// +// Numbers accept $hex, 0xhex, %binary, or decimal. Labels are written +// as `name:` and may be forward-referenced. A `;` starts a comment. + const std = @import("std"); -pub fn main() !void { - std.debug.print("gbasm: assembler is under construction.\n", .{}); +pub const AssemblerError = error{ + Syntax, + UnknownMnemonic, + BadOperand, + BadOperandCount, + DuplicateLabel, + UnknownSymbol, + BranchOutOfRange, + AddressOverlap, + NumberOutOfRange, + BadRstVector, + TooManyOperands, + OutOfMemory, +}; + +const reg8_names = [_][]const u8{ "B", "C", "D", "E", "H", "L", "(HL)", "A" }; +const reg16_names = [_][]const u8{ "BC", "DE", "HL", "SP" }; +const cond_names = [_][]const u8{ "NZ", "Z", "NC", "C" }; + +pub const Assembler = struct { + allocator: std.mem.Allocator, + output: std.ArrayList(u8), + symbols: std.StringHashMap(u16), + address: u16 = 0, + emit: bool = false, + + pub fn init(allocator: std.mem.Allocator) Assembler { + return .{ + .allocator = allocator, + .output = std.ArrayList(u8).empty, + .symbols = std.StringHashMap(u16).init(allocator), + }; + } + + pub fn deinit(self: *Assembler) void { + self.output.deinit(self.allocator); + self.symbols.deinit(); + } + + // Runs a sizing pass, then an emit pass. Returns the ROM image. + pub fn assemble(self: *Assembler, source: []const u8) AssemblerError![]u8 { + try self.runPass(source); + self.emit = true; + try self.runPass(source); + return self.output.toOwnedSlice(self.allocator); + } + + fn runPass(self: *Assembler, source: []const u8) AssemblerError!void { + self.address = 0; + self.output.clearRetainingCapacity(); + + var lines = std.mem.splitScalar(u8, source, '\n'); + while (lines.next()) |raw_line| { + const comment = std.mem.indexOfScalar(u8, raw_line, ';'); + const line = std.mem.trim(u8, raw_line[0 .. comment orelse raw_line.len], " \t\r"); + if (line.len == 0) continue; + + var rest = line; + if (std.mem.indexOfScalar(u8, line, ':')) |colon| { + const candidate = line[0..colon]; + if (candidate.len != 0 and std.mem.indexOfAny(u8, candidate, " \t") == null) { + try self.defineLabel(candidate); + rest = std.mem.trim(u8, line[colon + 1 ..], " \t"); + if (rest.len == 0) continue; + } + } + + const mnemonic = nextToken(rest); + const operands = std.mem.trim(u8, rest[mnemonic.len..], " \t"); + try self.emitLine(mnemonic, operands); + } + } + + fn defineLabel(self: *Assembler, name: []const u8) AssemblerError!void { + if (self.emit) return; + const gop = try self.symbols.getOrPut(name); + if (gop.found_existing) return error.DuplicateLabel; + gop.value_ptr.* = self.address; + } + + fn emitLine(self: *Assembler, mnemonic: []const u8, operands: []const u8) AssemblerError!void { + if (eqlI(mnemonic, "ORG") or eqlI(mnemonic, "PAD")) { + const target = try self.eval(operands); + if (target < 0) return error.AddressOverlap; + const fill_to: u16 = @intCast(target); + if (fill_to < self.address) return error.AddressOverlap; + while (self.address < fill_to) try self.emitByte(0x00); + return; + } + if (eqlI(mnemonic, "DB")) return self.emitData(operands, false); + if (eqlI(mnemonic, "DW")) return self.emitData(operands, true); + if (eqlI(mnemonic, "DS")) return self.emitSpace(operands); + return self.encode(mnemonic, operands); + } + + fn emitData(self: *Assembler, operands: []const u8, wide: bool) AssemblerError!void { + var items: [64][]const u8 = undefined; + const count = try splitItems(operands, &items); + for (items[0..count]) |item| { + if (item.len >= 2 and item[0] == '"' and item[item.len - 1] == '"') { + const text = item[1 .. item.len - 1]; + if (wide) { + for (text) |byte| { + try self.emitByte(byte); + try self.emitByte(0); + } + } else { + for (text) |byte| try self.emitByte(byte); + } + continue; + } + const value = try self.eval(item); + if (wide) { + if (value < 0 or value > 0xffff) return error.NumberOutOfRange; + const word: u16 = @intCast(value); + try self.emitByte(@truncate(word)); + try self.emitByte(@truncate(word >> 8)); + } else { + if (value < -128 or value > 0xff) return error.NumberOutOfRange; + try self.emitByte(lowByte(value)); + } + } + } + + fn emitSpace(self: *Assembler, operands: []const u8) AssemblerError!void { + var items: [2][]const u8 = undefined; + const count = try splitItems(operands, &items); + if (count < 1 or count > 2) return error.BadOperandCount; + const length = try self.eval(items[0]); + if (length < 0 or length > 0xffff) return error.NumberOutOfRange; + const fill: u8 = if (count == 2) blk: { + const value = try self.eval(items[1]); + if (value < 0 or value > 0xff) return error.NumberOutOfRange; + break :blk @intCast(value); + } else 0; + var remaining: usize = @intCast(length); + while (remaining != 0) : (remaining -= 1) try self.emitByte(fill); + } + + fn emitByte(self: *Assembler, value: u8) AssemblerError!void { + if (self.emit) self.output.append(self.allocator, value) catch return error.OutOfMemory; + self.address +%= 1; + } + + // Parses a signed expression: numbers, labels, and + and - terms. + fn eval(self: *Assembler, text_in: []const u8) AssemblerError!i32 { + const text = std.mem.trim(u8, text_in, " \t"); + if (text.len == 0) return error.Syntax; + var total: i32 = 0; + var sign: i32 = 1; + var index: usize = 0; + while (index < text.len) { + const ch = text[index]; + if (ch == '+' or ch == '-') { + sign = if (ch == '+') 1 else -1; + index += 1; + continue; + } + const start = index; + while (index < text.len and text[index] != '+' and text[index] != '-') index += 1; + const term = text[start..index]; + total += sign * try self.evalTerm(term); + sign = 1; + } + return total; + } + + fn evalTerm(self: *Assembler, term: []const u8) AssemblerError!i32 { + if (term.len == 0) return error.Syntax; + const value = self.parseNumber(term) orelse blk: { + if (self.symbols.get(term)) |symbol_value| break :blk @as(i32, symbol_value); + if (!self.emit) break :blk 0; // forward reference during the sizing pass + return error.UnknownSymbol; + }; + return value; + } + + fn parseNumber(self: *Assembler, term: []const u8) ?i32 { + _ = self; + if (term.len >= 2 and term[0] == '$') return parseRadix(term[1..], 16) orelse return null; + if (term.len >= 3 and term[0] == '0' and (term[1] == 'x' or term[1] == 'X')) return parseRadix(term[2..], 16) orelse return null; + if (term.len >= 2 and term[0] == '%') return parseRadix(term[1..], 2) orelse return null; + if (term[0] >= '0' and term[0] <= '9') return parseRadix(term, 10) orelse return null; + return null; + } + + fn encode(self: *Assembler, mnemonic: []const u8, operands: []const u8) AssemblerError!void { + var parts: [4][]const u8 = undefined; + const count = try splitItems(operands, &parts); + + if (eqlI(mnemonic, "LD")) return self.encodeLd(parts[0..count], count); + if (eqlI(mnemonic, "LDH")) return self.encodeLdh(parts[0..count], count); + + const op: ?u3 = blk: { + const names = [_][]const u8{ "ADD", "ADC", "SUB", "SBC", "AND", "XOR", "OR", "CP" }; + for (names, 0..) |name, i| { + if (eqlI(mnemonic, name)) break :blk @as(u3, @intCast(i)); + } + break :blk null; + }; + + if (op) |alu_op| return self.encodeAlu(alu_op, parts[0..count], count); + + if (eqlI(mnemonic, "INC")) return self.encodeIncDec(parts[0..count], count, true); + if (eqlI(mnemonic, "DEC")) return self.encodeIncDec(parts[0..count], count, false); + if (eqlI(mnemonic, "JP")) return self.encodeJp(parts[0..count], count); + if (eqlI(mnemonic, "JR")) return self.encodeJr(parts[0..count], count); + if (eqlI(mnemonic, "CALL")) return self.encodeCall(parts[0..count], count); + if (eqlI(mnemonic, "RET")) return self.encodeRet(parts[0..count], count); + if (eqlI(mnemonic, "RETI")) { + if (count != 0) return error.BadOperandCount; + return self.emitByte(0xd9); + } + if (eqlI(mnemonic, "RST")) return self.encodeRst(parts[0..count], count); + if (eqlI(mnemonic, "PUSH") or eqlI(mnemonic, "POP")) return self.encodePushPop(mnemonic, parts[0..count], count); + if (eqlI(mnemonic, "RLC") or eqlI(mnemonic, "RRC") or + eqlI(mnemonic, "RL") or eqlI(mnemonic, "RR") or + eqlI(mnemonic, "SLA") or eqlI(mnemonic, "SRA") or + eqlI(mnemonic, "SWAP") or eqlI(mnemonic, "SRL")) return self.encodeShift(mnemonic, parts[0..count], count); + if (eqlI(mnemonic, "BIT") or eqlI(mnemonic, "RES") or eqlI(mnemonic, "SET")) return self.encodeBitOp(mnemonic, parts[0..count], count); + + const zero_arg = [_][]const u8{ "NOP", "HALT", "DI", "EI", "RLCA", "RLA", "RRCA", "RRA", "DAA", "CPL", "SCF", "CCF" }; + const zero_bytes = [_]u8{ 0x00, 0x76, 0xf3, 0xfb, 0x07, 0x17, 0x0f, 0x1f, 0x27, 0x2f, 0x37, 0x3f }; + for (zero_arg, 0..) |name, i| { + if (eqlI(mnemonic, name)) { + if (count != 0) return error.BadOperandCount; + return self.emitByte(zero_bytes[i]); + } + } + + if (eqlI(mnemonic, "STOP")) { + if (count != 0) return error.BadOperandCount; + try self.emitByte(0x10); + return self.emitByte(0x00); + } + + return error.UnknownMnemonic; + } + + fn encodeLd(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 2) return error.BadOperandCount; + const dst = parts[0]; + const src = parts[1]; + + if (std.mem.eql(u8, dst, "(C)") and std.mem.eql(u8, src, "A")) return self.emitByte(0xe2); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(C)")) return self.emitByte(0xf2); + if (std.mem.eql(u8, dst, "(BC)") and std.mem.eql(u8, src, "A")) return self.emitByte(0x02); + if (std.mem.eql(u8, dst, "(DE)") and std.mem.eql(u8, src, "A")) return self.emitByte(0x12); + if (std.mem.eql(u8, dst, "(HL)") and std.mem.eql(u8, src, "A")) return self.emitByte(0x77); + if (std.mem.eql(u8, dst, "(HL+)") and std.mem.eql(u8, src, "A")) return self.emitByte(0x22); + if (std.mem.eql(u8, dst, "(HL-)") and std.mem.eql(u8, src, "A")) return self.emitByte(0x32); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(BC)")) return self.emitByte(0x0a); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(DE)")) return self.emitByte(0x1a); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(HL)")) return self.emitByte(0x7e); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(HL+)")) return self.emitByte(0x2a); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(HL-)")) return self.emitByte(0x3a); + if (std.mem.eql(u8, dst, "SP") and std.mem.eql(u8, src, "HL")) return self.emitByte(0xf9); + + if (std.mem.eql(u8, dst, "HL") and startsWith(src, "SP+")) return self.encodeLdHlSpSigned(0xf8, src[3..]); + if (std.mem.eql(u8, dst, "HL") and startsWith(src, "SP-")) return self.encodeLdHlSpNegated(0xf8, src[3..]); + + if (isAddress(dst)) { + const address = try self.eval(dst[1 .. dst.len - 1]); + if (std.mem.eql(u8, src, "SP")) { + if (address < 0 or address > 0xffff) return error.NumberOutOfRange; + try self.emitByte(0x08); + try self.emitByte(@truncate(@as(u16, @intCast(address)))); + return self.emitByte(@truncate(@as(u16, @intCast(address)) >> 8)); + } + if (std.mem.eql(u8, src, "A")) { + if (address < 0 or address > 0xffff) return error.NumberOutOfRange; + try self.emitByte(0xea); + try self.emitByte(@truncate(@as(u16, @intCast(address)))); + return self.emitByte(@truncate(@as(u16, @intCast(address)) >> 8)); + } + } + if (std.mem.eql(u8, dst, "A") and isAddress(src)) { + const address = try self.eval(src[1 .. src.len - 1]); + if (address < 0 or address > 0xffff) return error.NumberOutOfRange; + try self.emitByte(0xfa); + try self.emitByte(@truncate(@as(u16, @intCast(address)))); + return self.emitByte(@truncate(@as(u16, @intCast(address)) >> 8)); + } + + if (reg16Index(dst)) |pair| { + const value = try self.eval(src); + if (value < 0 or value > 0xffff) return error.NumberOutOfRange; + try self.emitByte(0x01 | @as(u8, pair) << 4); + try self.emitByte(@truncate(@as(u16, @intCast(value)))); + return self.emitByte(@truncate(@as(u16, @intCast(value)) >> 8)); + } + + if (reg8Index(dst)) |dest_reg| { + if (reg8Index(src)) |source_reg| { + if (dest_reg == 6 and source_reg == 6) return error.BadOperand; + return self.emitByte(0x40 | @as(u8, dest_reg) << 3 | @as(u8, source_reg)); + } + const value = try self.eval(src); + try self.emitByte(0x06 | @as(u8, dest_reg) << 3); + return self.emitByte(lowByte(value)); + } + + return error.BadOperand; + } + + // HL <- SP + e, where e may be negative. + fn encodeLdHlSpSigned(self: *Assembler, opcode: u8, offset_text: []const u8) AssemblerError!void { + const offset = try self.eval(offset_text); + try self.emitByte(opcode); + return self.emitByte(lowByte(offset)); + } + + // HL <- SP - e. The sign is applied before encoding the low byte. + fn encodeLdHlSpNegated(self: *Assembler, opcode: u8, offset_text: []const u8) AssemblerError!void { + const offset = -(try self.eval(offset_text)); + try self.emitByte(opcode); + return self.emitByte(lowByte(offset)); + } + + fn encodeLdh(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 2) return error.BadOperandCount; + const dst = parts[0]; + const src = parts[1]; + if (std.mem.eql(u8, dst, "(C)") and std.mem.eql(u8, src, "A")) return self.emitByte(0xe2); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(C)")) return self.emitByte(0xf2); + if (isAddress(dst) and std.mem.eql(u8, src, "A")) { + const value = try self.eval(dst[1 .. dst.len - 1]); + try self.emitByte(0xe0); + return self.emitByte(lowByte(value)); + } + if (std.mem.eql(u8, dst, "A") and isAddress(src)) { + const value = try self.eval(src[1 .. src.len - 1]); + try self.emitByte(0xf0); + return self.emitByte(lowByte(value)); + } + return error.BadOperand; + } + + fn encodeAlu(self: *Assembler, operation: u3, parts: []const []const u8, count: usize) AssemblerError!void { + if (count == 2) { + const dst = parts[0]; + const src = parts[1]; + if (operation == 0 and std.mem.eql(u8, dst, "HL")) { + if (reg16Index(src)) |pair| return self.emitByte(0x09 | @as(u8, pair) << 4); + } + if (operation == 0 and std.mem.eql(u8, dst, "SP")) { + if (startsWith(src, "+")) return self.encodeLdHlSpSigned(0xe8, src[1..]); + if (startsWith(src, "-")) return self.encodeLdHlSpNegated(0xe8, src[1..]); + return self.encodeLdHlSpSigned(0xe8, src); + } + if (!std.mem.eql(u8, dst, "A")) return error.BadOperand; + if (reg8Index(src)) |source_reg| return self.emitByte(0x80 | @as(u8, operation) << 3 | source_reg); + const value = try self.eval(src); + try self.emitByte(0xc6 | @as(u8, operation) << 3); + return self.emitByte(lowByte(value)); + } + if (count == 1) { + if (reg8Index(parts[0])) |source_reg| return self.emitByte(0x80 | @as(u8, operation) << 3 | source_reg); + const value = try self.eval(parts[0]); + try self.emitByte(0xc6 | @as(u8, operation) << 3); + return self.emitByte(lowByte(value)); + } + return error.BadOperandCount; + } + + fn encodeIncDec(self: *Assembler, parts: []const []const u8, count: usize, increment: bool) AssemblerError!void { + if (count != 1) return error.BadOperandCount; + if (reg8Index(parts[0])) |reg| { + const base: u8 = if (increment) 0x04 else 0x05; + return self.emitByte(base | @as(u8, reg) << 3); + } + if (reg16Index(parts[0])) |pair| { + const base: u8 = if (increment) 0x03 else 0x0b; + return self.emitByte(base | @as(u8, pair) << 4); + } + return error.BadOperand; + } + + fn encodeJp(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count == 1) { + if (std.mem.eql(u8, parts[0], "(HL)")) return self.emitByte(0xe9); + const target = try self.eval(parts[0]); + return self.emitAddress(0xc3, target); + } + if (count == 2) { + if (condIndex(parts[0])) |condition| { + const target = try self.eval(parts[1]); + return self.emitAddress(0xc2 | @as(u8, condition) << 3, target); + } + } + return error.BadOperand; + } + + fn encodeJr(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count < 1 or count > 2) return error.BadOperandCount; + const base: u8 = if (count == 1) 0x18 else 0x20 | @as(u8, condIndex(parts[0]) orelse return error.BadOperand) << 3; + const target_text = if (count == 1) parts[0] else parts[1]; + const target = try self.eval(target_text); + const after = @as(i32, self.address) + 2; + const displacement = target - after; + if (self.emit and (displacement < -128 or displacement > 127)) return error.BranchOutOfRange; + try self.emitByte(base); + return self.emitByte(lowByte(displacement)); + } + + fn encodeCall(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count == 1) { + const target = try self.eval(parts[0]); + return self.emitAddress(0xcd, target); + } + if (count == 2) { + if (condIndex(parts[0])) |condition| { + const target = try self.eval(parts[1]); + return self.emitAddress(0xc4 | @as(u8, condition) << 3, target); + } + } + return error.BadOperand; + } + + fn encodeRet(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count == 0) return self.emitByte(0xc9); + if (count == 1) { + if (condIndex(parts[0])) |condition| return self.emitByte(0xc0 | @as(u8, condition) << 3); + } + return error.BadOperand; + } + + fn encodeRst(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 1) return error.BadOperandCount; + const vector = try self.eval(parts[0]); + if (vector < 0 or vector > 0x38 or (vector & 0x07) != 0) return error.BadRstVector; + return self.emitByte(0xc7 | @as(u8, @intCast(vector))); + } + + fn encodePushPop(self: *Assembler, mnemonic: []const u8, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 1) return error.BadOperandCount; + const names = [_][]const u8{ "BC", "DE", "HL", "AF" }; + for (names, 0..) |name, i| { + if (std.mem.eql(u8, parts[0], name)) { + const base: u8 = if (eqlI(mnemonic, "PUSH")) 0xc5 else 0xc1; + return self.emitByte(base | @as(u8, @intCast(i)) << 4); + } + } + return error.BadOperand; + } + + fn encodeShift(self: *Assembler, mnemonic: []const u8, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 1) return error.BadOperandCount; + const reg = reg8Index(parts[0]) orelse return error.BadOperand; + const names = [_][]const u8{ "RLC", "RRC", "RL", "RR", "SLA", "SRA", "SWAP", "SRL" }; + var operation: u8 = 0; + for (names, 0..) |name, i| { + if (eqlI(mnemonic, name)) { + operation = @intCast(i); + break; + } + } + try self.emitByte(0xcb); + return self.emitByte(operation << 3 | reg); + } + + fn encodeBitOp(self: *Assembler, mnemonic: []const u8, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 2) return error.BadOperandCount; + const bit_value = try self.eval(parts[0]); + if (bit_value < 0 or bit_value > 7) return error.NumberOutOfRange; + const reg = reg8Index(parts[1]) orelse return error.BadOperand; + const base: u8 = if (eqlI(mnemonic, "BIT")) 0x40 else if (eqlI(mnemonic, "RES")) 0x80 else 0xc0; + try self.emitByte(0xcb); + return self.emitByte(base | @as(u8, @intCast(bit_value)) << 3 | reg); + } + + fn emitAddress(self: *Assembler, opcode: u8, value: i32) AssemblerError!void { + if (value < 0 or value > 0xffff) return error.NumberOutOfRange; + const word: u16 = @intCast(value); + try self.emitByte(opcode); + try self.emitByte(@truncate(word)); + return self.emitByte(@truncate(word >> 8)); + } +}; + +fn reg8Index(text: []const u8) ?u3 { + for (reg8_names, 0..) |name, i| { + if (std.mem.eql(u8, text, name)) return @intCast(i); + } + return null; +} + +fn reg16Index(text: []const u8) ?u2 { + for (reg16_names, 0..) |name, i| { + if (std.mem.eql(u8, text, name)) return @intCast(i); + } + return null; +} + +fn condIndex(text: []const u8) ?u2 { + for (cond_names, 0..) |name, i| { + if (std.mem.eql(u8, text, name)) return @intCast(i); + } + return null; +} + +fn isAddress(text: []const u8) bool { + return text.len >= 3 and text[0] == '(' and text[text.len - 1] == ')' and + !std.mem.eql(u8, text, "(HL)") and !std.mem.eql(u8, text, "(HL+)") and + !std.mem.eql(u8, text, "(HL-)") and !std.mem.eql(u8, text, "(BC)") and + !std.mem.eql(u8, text, "(DE)") and !std.mem.eql(u8, text, "(C)"); +} + +fn startsWith(text: []const u8, prefix: []const u8) bool { + return std.mem.startsWith(u8, text, prefix); +} + +fn eqlI(a: []const u8, b: []const u8) bool { + return std.ascii.eqlIgnoreCase(a, b); +} + +// Low byte of a signed value in two's complement form. +fn lowByte(value: i32) u8 { + return @truncate(@as(u32, @bitCast(value))); +} + +fn nextToken(text: []const u8) []const u8 { + const end = std.mem.indexOfAny(u8, text, " \t") orelse text.len; + return text[0..end]; +} + +// Splits comma-separated items, keeping quoted strings intact. +fn splitItems(text: []const u8, items: [][]const u8) AssemblerError!usize { + var count: usize = 0; + var index: usize = 0; + while (index < text.len) { + while (index < text.len and (text[index] == ' ' or text[index] == '\t' or text[index] == ',')) index += 1; + if (index >= text.len) break; + const start = index; + var in_string = false; + while (index < text.len) : (index += 1) { + const ch = text[index]; + if (ch == '"') in_string = !in_string; + if (ch == ',' and !in_string) break; + } + if (count == items.len) return error.TooManyOperands; + items[count] = text[start..index]; + count += 1; + } + return count; +} + +fn parseRadix(digits: []const u8, base: u8) ?i32 { + var value: i64 = 0; + for (digits) |digit| { + const nibble: i64 = switch (digit) { + '0'...'9' => digit - '0', + 'a'...'f' => digit - 'a' + 10, + 'A'...'F' => digit - 'A' + 10, + else => return null, + }; + if (nibble >= base) return null; + value = value * base + nibble; + if (value > 0x7fffffff) return null; + } + return @intCast(value); +} + +const testing = std.testing; + +fn assembleBytes(source: []const u8) ![]u8 { + var assembler = Assembler.init(testing.allocator); + defer assembler.deinit(); + return assembler.assemble(source); +} + +test "assembles a minimal program" { + const bytes = try assembleBytes( + \\ ORG $0100 + \\ JP start + \\start: + \\ LD A,$12 + \\ HALT + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqualSlices(u8, &.{ 0xc3, 0x03, 0x01, 0x3e, 0x12, 0x76 }, bytes[0x0100..]); +} + +test "assembles register moves and arithmetic" { + const bytes = try assembleBytes( + \\ LD B,A + \\ LD C,(HL) + \\ ADD A,B + \\ ADC A,(HL) + \\ SUB B + \\ XOR A + \\ CP $40 + \\ INC (HL) + \\ DEC B + \\ INC HL + \\ ADD HL,DE + \\ ADD SP,-$06 + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqualSlices(u8, &.{ 0x47, 0x4e, 0x80, 0x8e, 0x90, 0xaf, 0xfe, 0x40, 0x34, 0x05, 0x23, 0x19, 0xe8, 0xfa }, bytes); +} + +test "assembles jumps, calls, and stack operations" { + const bytes = try assembleBytes( + \\ ORG $0200 + \\ JP target + \\ JR NZ,target + \\ CALL Z,target + \\ PUSH HL + \\ POP AF + \\ RET + \\ RETI + \\ RST $38 + \\ JP (HL) + \\target: + \\ NOP + \\ + ); + defer testing.allocator.free(bytes); + const expected = [_]u8{ + 0xc3, 0x0e, 0x02, + 0x20, 0x09, 0xcc, + 0x0e, 0x02, 0xe5, + 0xf1, 0xc9, 0xd9, + 0xff, 0xe9, 0x00, + }; + try testing.expectEqualSlices(u8, &expected, bytes[0x0200..]); +} + +test "assembles CB prefix group" { + const bytes = try assembleBytes( + \\ RLC A + \\ SRA (HL) + \\ BIT 3,L + \\ SET 7,(HL) + \\ RES 0,A + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqualSlices(u8, &.{ 0xcb, 0x07, 0xcb, 0x2e, 0xcb, 0x5d, 0xcb, 0xfe, 0xcb, 0x87 }, bytes); +} + +test "assembles ldh and memory forms" { + const bytes = try assembleBytes( + \\ LDH ($FF44),A + \\ LDH A,($FF40) + \\ LD (C),A + \\ LD A,(C) + \\ LD (BC),A + \\ LD A,(HL+) + \\ LD (HL-),A + \\ LD (MAP),SP + \\ LD HL,SP+$10 + \\ ORG $3000 + \\MAP: + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqualSlices(u8, &.{ 0xe0, 0x44, 0xf0, 0x40, 0xe2, 0xf2, 0x02, 0x2a, 0x32, 0x08, 0x00, 0x30, 0xf8, 0x10 }, bytes[0..14]); + try testing.expectEqual(@as(u16, 0x3000), bytes.len); +} + +test "assembles data directives and pads to size" { + const bytes = try assembleBytes( + \\ ORG $0000 + \\ DB "Passed", $0A, $00 + \\ DW $1234 + \\ DS 3,$FF + \\ PAD $0010 + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqual(@as(usize, 0x10), bytes.len); + try testing.expectEqualSlices(u8, "Passed", bytes[0..6]); + try testing.expectEqual(@as(u8, 0x0a), bytes[6]); + try testing.expectEqual(@as(u8, 0x00), bytes[7]); + try testing.expectEqualSlices(u8, &.{ 0x34, 0x12 }, bytes[8..10]); + try testing.expectEqualSlices(u8, &.{ 0xff, 0xff, 0xff }, bytes[10..13]); +} + +test "backward branch encodes the right displacement" { + const bytes = try assembleBytes( + \\ ORG $1000 + \\loop: + \\ JR loop + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqualSlices(u8, &.{ 0x18, 0xfe }, bytes[0x1000..]); +} + +test "forward branch over data" { + const bytes = try assembleBytes( + \\ ORG $0100 + \\ JR skip + \\ DB $00, $00, $00 + \\skip: + \\ NOP + \\ + ); + defer testing.allocator.free(bytes); + // JR is two bytes; skip is at 0105, so displacement is 0105-0102 = 3. + try testing.expectEqualSlices(u8, &.{ 0x18, 0x03 }, bytes[0x0100..0x0102]); + try testing.expectEqual(@as(u8, 0x00), bytes[0x0105]); +} + +test "rejects a branch out of range" { + var assembler = Assembler.init(testing.allocator); + defer assembler.deinit(); + const source = + \\ ORG $0000 + \\ JR far + \\ DS $100,$00 + \\far: + \\ + ; + try testing.expectError(AssemblerError.BranchOutOfRange, assembler.assemble(source)); +} + +test "rejects an unknown mnemonic" { + var assembler = Assembler.init(testing.allocator); + defer assembler.deinit(); + try testing.expectError(AssemblerError.UnknownMnemonic, assembler.assemble(" BOGUS\n")); +} + +test "rejects a duplicate label" { + var assembler = Assembler.init(testing.allocator); + defer assembler.deinit(); + try testing.expectError(AssemblerError.DuplicateLabel, assembler.assemble( + \\start: + \\start: + \\ + )); +} + +fn usage(program: []const u8) void { + std.debug.print( + \\gbasm - SM83 assembler for Game Boy test ROMs + \\Usage: + \\ {s} [output.gb] + \\ {s} + \\ + \\Assembles every .asm file in the directory mode and writes a + \\matching .gb image next to each source name. + \\ + , .{ program, program }); +} + +fn readFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const dir = std.Io.Dir.cwd(); + const file = try dir.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; +} + +fn isDirectory(io: std.Io, path: []const u8) bool { + const stat = std.Io.Dir.cwd().statFile(io, path, .{}) catch return false; + return stat.kind == .directory; +} + +fn assembleOne(io: std.Io, allocator: std.mem.Allocator, input_path: []const u8, output_path: []const u8) !void { + const source = try readFile(io, allocator, input_path); + defer allocator.free(source); + + var assembler = Assembler.init(allocator); + defer assembler.deinit(); + const image = assembler.assemble(source) catch |err| { + std.debug.print("{s}: error {s}\n", .{ input_path, @errorName(err) }); + std.process.exit(1); + }; + defer allocator.free(image); + + if (std.fs.path.dirname(output_path)) |parent| { + if (parent.len != 0) { + std.Io.Dir.cwd().createDirPath(io, parent) catch |err| { + std.debug.print("{s}: create error {s}\n", .{ parent, @errorName(err) }); + std.process.exit(1); + }; + } + } + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = output_path, .data = image }) catch |err| { + std.debug.print("{s}: write error {s}\n", .{ output_path, @errorName(err) }); + std.process.exit(1); + }; + std.debug.print("{s} -> {s} ({d} bytes)\n", .{ input_path, output_path, image.len }); +} + +fn assembleDirectory(io: std.Io, allocator: std.mem.Allocator, input_dir: []const u8, output_dir: []const u8) !void { + std.Io.Dir.cwd().createDirPath(io, output_dir) catch |err| { + std.debug.print("{s}: create error {s}\n", .{ output_dir, @errorName(err) }); + std.process.exit(1); + }; + + var dir = try std.Io.Dir.cwd().openDir(io, input_dir, .{ .iterate = true }); + defer dir.close(io); + var names: std.ArrayList([]const u8) = .empty; + defer names.deinit(allocator); + var iter = dir.iterate(); + while (try iter.next(io)) |entry| { + if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".asm")) { + 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); + + if (names.items.len == 0) { + std.debug.print("no .asm sources found in {s}\n", .{input_dir}); + std.process.exit(1); + } + + for (names.items) |name| { + defer allocator.free(name); + const input_path = try std.fs.path.join(allocator, &.{ input_dir, name }); + defer allocator.free(input_path); + const base = name[0 .. name.len - 4]; + const output_name = try std.fmt.allocPrint(allocator, "{s}.gb", .{base}); + defer allocator.free(output_name); + const output_path = try std.fs.path.join(allocator, &.{ output_dir, output_name }); + defer allocator.free(output_path); + try assembleOne(io, allocator, input_path, output_path); + } +} + +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); + + if (args.items.len < 2 or args.items.len > 3) { + usage(args.items[0]); + std.process.exit(2); + } + + if (isDirectory(io, args.items[1])) { + if (args.items.len != 3) { + usage(args.items[0]); + std.process.exit(2); + } + return assembleDirectory(io, allocator, args.items[1], args.items[2]); + } + + const input = args.items[1]; + const output = if (args.items.len == 3) args.items[2] else blk: { + break :blk try std.fmt.allocPrint(allocator, "{s}.gb", .{input[0 .. input.len - 4]}); + }; + defer if (args.items.len == 2) allocator.free(output); + return assembleOne(io, allocator, input, output); }