diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..bf5980d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,14 @@ +# Keep Zig sources with LF line endings. +*.zig text eol=lf +build.zig text eol=lf +build.zig.zon text eol=lf + +# Mark committed ROM images as binary. +fixtures/roms/*.gb binary +fixtures/blargg/*.gb binary + +# Documentation and text files. +*.md text +*.txt text +*.yml text +*.yaml text diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..272d833 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +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 (ubuntu) + runs-on: ubuntu-latest + 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 --summary all + - name: Regenerate bundled ROMs + run: zig build fixtures + - name: Bundled ROMs match their sources + run: git diff --exit-code -- fixtures + - name: CPU instruction suite (blargg) + run: zig build test-blargg + - name: Build headless tools + run: zig build -Dsdl2=off + + test-windows: + name: Test (windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - name: Format check + shell: pwsh + run: zig fmt --check . + - name: Unit and integration tests + shell: pwsh + run: zig build test --summary all + - name: Regenerate bundled ROMs + shell: pwsh + run: zig build fixtures + - name: Bundled ROMs match their sources + shell: pwsh + run: git diff --exit-code -- fixtures + - name: CPU instruction suite (blargg) + shell: pwsh + run: zig build test-blargg + - name: Build headless tools + shell: pwsh + 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 diff --git a/.gitignore b/.gitignore index 51bafde..496d0de 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,10 @@ zig-out/ *.dylib *.exe .DS_Store -*.gb *.sav *.swp + +# Committed fixtures stay tracked even though raw ROMs are ignored. +*.gb +!fixtures/roms/*.gb +!fixtures/blargg/*.gb diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..223bdc1 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,54 @@ +# Contributing + +Dot Matrix Deck is a work-in-progress emulator. Contributions are welcome. + +## Build + +Use Zig 0.16.0 or later. + +Build the headless tools: + +```text +zig build -Dsdl2=off +``` + +Build the windowed frontend. SDL2 must be installed. On Linux, install the package `libsdl2-dev`. On Windows, set the prefix with `-Dsdl2=`. + +```text +zig build +``` + +## Test + +Run the unit, assembler, and integration tests: + +```text +zig build test -Dsdl2=off +``` + +Run the blargg CPU instruction suite: + +```text +zig build test-blargg +``` + +## Format + +The CI workflow checks formatting. Format your changes before you open a pull request: + +```text +zig fmt . +``` + +## Bundled ROMs + +The bundled ROMs live in `fixtures`. Keep the ROM image in sync with its assembly source: + +```text +zig build fixtures +git diff --exit-code -- fixtures +``` + +## Pull requests + +Keep each pull request small and focused. Add tests for new behavior. Update the README and the roadmap when the change affects them. Do not add generated files to a pull request unless they are committed fixtures. 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..8637dbe --- /dev/null +++ b/NOTICE @@ -0,0 +1,20 @@ +Dot Matrix Deck + +Third-party test ROMs + +The CPU instruction suite in fixtures/blargg is a set of public test ROMs +for the Game Boy CPU. The files are reproduced from the gb-test-roms +project by retrio. The original test suite was written by blargg. + +The suite verifies the SM83 CPU against known-good behavior. Each ROM +prints a pass or fail verdict through the serial link port. + +Source: https://github.com/retrio/gb-test-roms + +SDL2 + +The windowed frontend links against SDL2. SDL2 is available under the +zlib license. See https://www.libsdl.org for details. + +The windowed frontend is optional. The headless runner and the tests do +not require SDL2. diff --git a/README.md b/README.md index fc59c84..7200b6b 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,27 @@ # Dot Matrix Deck -Dot Matrix Deck is a Game Boy emulator workbench written in Zig. It models the -SM83 CPU, the memory bus, timers, serial output, and the pixel pipeline. A -headless runner makes emulator behavior easy to inspect in scripts and CI. - -## Current status - -This project is under active development. The headless core is the most useful -entry point today. The SDL2 windowed frontend is available when SDL2 is -installed, but it is still being built out. +Dot Matrix Deck is a Game Boy emulator written in Zig. It emulates 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. ## Features -- SM83 CPU and instruction execution -- Memory bus with timer, serial, and pixel-processing components -- Headless ROM runner with cycle limits and serial verdicts -- Optional instruction trace output -- Small assembler tool for test ROM sources -- Deterministic unit tests for the emulator core and assembler +- SM83 CPU with all 256 opcodes +- CB prefix instructions and interrupt timing +- Memory bus with WRAM, HRAM, and the MBC1 mapper +- Timer unit with DIV, TIMA, TMA, and TAC +- Background and sprite rendering through the PPU +- Windowed frontend with keyboard controls +- Headless runner with pass or fail verdicts +- Instruction trace mode +- Cartridge header display with the --info flag +- Small assembler for test ROM sources +- Deterministic unit and integration tests ## Requirements -- Zig 0.16 or later -- SDL2 for the optional windowed frontend +- Zig 0.16.0 or later +- SDL2 for the windowed frontend -The headless build does not require SDL2. On Windows, the build searches common -MSYS2 and vcpkg prefixes. You can also set `SDL2_DIR` or pass an SDL2 prefix. +The headless build does not require SDL2. On Windows, the build searches common MSYS2 and vcpkg prefixes. You can also set `SDL2_DIR` or pass an SDL2 prefix. ## Build @@ -41,6 +37,12 @@ Build with the windowed frontend when SDL2 is available: zig build ``` +Disable the windowed frontend explicitly: + +```text +zig build -Dsdl2=off +``` + ## Run a ROM Run a ROM without a window: @@ -49,41 +51,90 @@ Run a ROM without a window: zig build run-headless -- path/to/rom.gb ``` -Useful options are `--max-cycles N`, `--trace`, and `--expect pass|fail|any`. -The runner prints serial output and returns a status from the detected verdict. - -Run a ROM in the SDL2 frontend: +Run a ROM in the SDL2 window: ```text zig build run -- path/to/rom.gb ``` +Useful options are `--max-cycles N`, `--trace`, `--info`, and `--expect pass|fail|any`. The runner prints serial output and returns a status from the detected verdict. + +## Sample output + +Run the bundled smoke ROM: + +```text +zig build run-headless -- fixtures/roms/smoke.gb +``` + +```text +Serial output: +PASS +Verdict: pass +``` + +Show the cartridge header: + +```text +zig build run-headless -- fixtures/roms/smoke.gb --info +``` + +```text +Title: SMOKE TEST +Mapper: ROM only +ROM: 32 KiB +RAM: 0 KiB +CGB: no +SGB: no +Header: valid checksum +Global: $0000 +``` + ## Test -Run the core and assembler tests: +Run the unit, assembler, and integration tests: ```text zig build test -Dsdl2=off ``` -The repository also contains build steps for generated fixtures and the Blargg -CPU instruction suite. Add the required fixture files before using those steps. +Run the blargg CPU instruction suite: -## Project layout +```text +zig build test-blargg +``` + +The integration tests assemble the smoke ROM from source and compare the image to the committed ROM. They also run the ROM through the emulator and check the serial verdict and the frame checksum. + +## Test status + +- Unit tests for the CPU, timer, bus, and serial output pass. +- The smoke ROM rebuilds byte for byte from its source. +- The smoke ROM reports a pass verdict through the emulator. +- The blargg CPU instruction suite passes, 11 of 11. +- The frame checksum is deterministic. + +## Architecture - `src/cpu.zig` - SM83 CPU implementation -- `src/emulator.zig` - emulator composition and core tests - `src/bus.zig` - memory and device routing +- `src/emulator.zig` - emulator composition and core tests +- `src/cartridge.zig` - cartridge mapping and header parsing +- `src/ppu.zig` - pixel pipeline and frame buffer +- `src/timer.zig` - timer unit - `src/headless.zig` - command-line ROM runner - `src/main.zig` - SDL2 frontend -- `tools/gbasm.zig` - small assembler for fixture ROMs +- `src/disasm.zig` - instruction disassembler +- `src/integration.zig` - end-to-end ROM tests +- `tools/gbasm.zig` - SM83 assembler for fixture ROMs +- `fixtures/asm` - assembly sources for the bundled ROMs +- `fixtures/roms` - committed ROM images +- `fixtures/blargg` - blargg CPU instruction suite ## 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 frontend features will improve as the project grows. The windowed frontend needs an SDL2 setup to build. ## License -No license file is published yet. Treat this repository as an experimental -project until a license is added. +Dot Matrix Deck is released under the MIT License. See `LICENSE` for details. The bundled test ROMs have their own provenance; see `NOTICE`. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..9664514 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,75 @@ +# Roadmap + +This document tracks the work on Dot Matrix Deck. +It shows what is complete and what remains. + +## Milestone 1: CPU core + +Status: complete. + +- SM83 CPU with all 256 opcodes +- CB prefix instructions +- Interrupt handling with IME timing +- HALT and STOP behavior +- Stack and register pair operations +- Verified by the blargg CPU instruction suite + +## Milestone 2: Memory and timing + +Status: complete. + +- Full address map with WRAM, HRAM, and echoes +- Timer unit with DIV, TIMA, TMA, and TAC +- MBC1 mapper for banked ROM and RAM +- Serial link output for test verdicts +- Joypad input from the frontend + +## Milestone 3: Pixel pipeline + +Status: complete. + +- LCD register file +- Background rendering with scrolling +- Sprite rendering with priority and palettes +- VBlank and STAT interrupts +- Deterministic frame checksums + +## Milestone 4: Frontends + +Status: complete. + +- SDL2 windowed frontend +- Keyboard controls for the D-pad and buttons +- Headless runner with pass or fail verdicts +- Cartridge header display with the --info flag +- Instruction trace mode + +## Milestone 5: Test infrastructure + +Status: complete. + +- Bundled smoke ROM assembled from source +- Deterministic rebuild check for the smoke ROM +- Golden frame checksum tests +- blargg CPU instruction suite, 11 of 11 passing +- GitHub Actions CI on Linux and Windows +- Formatting enforced by zig fmt + +## Milestone 6: Audio and remaining hardware + +Status: planned. + +- APU and the four sound channels +- OAM DMA transfers +- Joypad interrupt +- Battery save to disk +- Additional mappers: MBC3 and MBC5 + +## Milestone 7: Accuracy and polish + +Status: planned. + +- Cycle-accurate STAT timing +- Boot ROM emulation +- Save state support +- Debugger integration diff --git a/build.zig b/build.zig index 815819d..cdb9670 100644 --- a/build.zig +++ b/build.zig @@ -57,22 +57,22 @@ pub fn build(b: *std.Build) void { }; if (build_windowed) { - var sdl_prefix: ?[]const u8 = null; - if (sdl2_opt) |value| { - if (value.len != 0 and !std.mem.eql(u8, value, "off")) sdl_prefix = value; - } else { - if (b.graph.environ_map.get("SDL2_DIR")) |dir| { - if (dir.len != 0) sdl_prefix = dir; - } - if (sdl_prefix == null and target.result.os.tag == .windows) { + var sdl_prefix: ?[]const u8 = null; + if (sdl2_opt) |value| { + if (value.len != 0 and !std.mem.eql(u8, value, "off")) sdl_prefix = value; + } else { + if (b.graph.environ_map.get("SDL2_DIR")) |dir| { + if (dir.len != 0) sdl_prefix = dir; + } + if (sdl_prefix == null and target.result.os.tag == .windows) { for (sdl_prefix_candidates) |candidate| { if (sdl2PrefixFound(b, candidate)) { sdl_prefix = candidate; break; } } + } } - } if (sdl_prefix != null or target.result.os.tag != .windows) { const exe = b.addExecutable(.{ @@ -84,10 +84,6 @@ 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, .{}); - } if (sdl_prefix) |prefix| { exe.root_module.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ prefix, "include" }) }); exe.root_module.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ prefix, "lib" }) }); @@ -97,6 +93,19 @@ pub fn build(b: *std.Build) void { b.getInstallStep().dependOn(&install_dll.step); } } + if (target.result.os.tag == .windows) { + // Windows links the DLL import library directly. The mingw + // auto-import library references fseeko64 and ftello64 in a + // form that the Zig linker cannot resolve. + if (sdl_prefix) |prefix| { + exe.root_module.addObjectFile(.{ .cwd_relative = b.pathJoin(&.{ prefix, "lib", "libSDL2.dll.a" }) }); + } else { + exe.root_module.linkSystemLibrary("SDL2", .{}); + } + for (sdl_deps) |lib| exe.root_module.linkSystemLibrary(lib, .{}); + } else { + exe.root_module.linkSystemLibrary("SDL2", .{}); + } b.installArtifact(exe); const run_cmd = b.addRunArtifact(exe); @@ -118,8 +127,15 @@ pub fn build(b: *std.Build) void { }), }); const run_core_tests = b.addRunArtifact(core_tests); - const test_step = b.step("test", "Run deterministic unit tests and bundled ROM tests"); - test_step.dependOn(&run_core_tests.step); + + const disasm_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/disasm.zig"), + .target = target, + .optimize = optimize, + }), + }); + const run_disasm_tests = b.addRunArtifact(disasm_tests); const asm_tests = b.addTest(.{ .root_module = b.createModule(.{ @@ -129,7 +145,28 @@ pub fn build(b: *std.Build) void { }), }); const run_asm_tests = b.addRunArtifact(asm_tests); + + const gbasm_module = b.createModule(.{ + .root_source_file = b.path("tools/gbasm.zig"), + .target = target, + .optimize = optimize, + }); + + const integration_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/integration.zig"), + .target = target, + .optimize = optimize, + .imports = &.{.{ .name = "gbasm", .module = gbasm_module }}, + }), + }); + const run_integration_tests = b.addRunArtifact(integration_tests); + + const test_step = b.step("test", "Run deterministic unit tests and bundled ROM tests"); + test_step.dependOn(&run_core_tests.step); + test_step.dependOn(&run_disasm_tests.step); test_step.dependOn(&run_asm_tests.step); + test_step.dependOn(&run_integration_tests.step); const fixtures_step = b.step("fixtures", "Regenerate bundled test ROMs from their assembly sources"); { diff --git a/fixtures/asm/smoke.asm b/fixtures/asm/smoke.asm new file mode 100644 index 0000000..94c18c1 --- /dev/null +++ b/fixtures/asm/smoke.asm @@ -0,0 +1,159 @@ +; smoke.asm - bundled smoke test ROM for Dot Matrix Deck. +; +; The ROM exercises the CPU and the memory bus, then reports the result +; through the serial link port. The headless runner checks the serial +; output for "PASS" to decide whether the emulator behaved correctly. +; +; The cartridge header holds a valid header checksum, so the --info +; frontend can display the ROM details. +; +; Regenerate the ROM with: zig build fixtures + + org $0100 + nop + jp start + + ; $0104-$0133: Nintendo logo. Zeros are fine for an emulator test. + ds $30 + + ; Cartridge header ($0134-$014F). + db "SMOKE TEST" ; title (16 bytes, zero padded) + ds 6 + db $00, $00 ; new licensee code + db $00 ; SGB flag + db $00 ; cartridge type: ROM only + db $00 ; ROM size: 32 KiB + db $00 ; RAM size: none + db $01 ; destination + db $33 ; old licensee: new style + db $00 ; mask ROM version + db $d4 ; header checksum + db $00, $00 ; global checksum + + org $0150 + +start: + ; Set up a stable stack in working RAM. + ld sp, $fffe + + ; --- arithmetic: A = 2 + 3 must equal 5 --- + ld a, $02 + add a, $03 + cp $05 + jp nz, fail + + ; --- flag handling: INC wraps $ff to 0 and sets Z --- + ld a, $ff + inc a + jp nz, fail + + ; --- DEC from 1 to 0 sets Z --- + ld a, $01 + dec a + jp nz, fail + + ; --- 16-bit moves and ADD HL --- + ld bc, $1234 + ld de, $0001 + ld hl, $0000 + add hl, de + ld a, b + cp $12 + jp nz, fail + + ; --- stack push and pop round trip --- + ld hl, $abcd + push hl + pop de + ld a, d + cp $ab + jp nz, fail + ld a, e + cp $cd + jp nz, fail + + ; --- memory: LD (HL),A and LD A,(HL) --- + ld hl, $c000 + ld (hl), $7e + ld a, (hl) + cp $7e + jp nz, fail + + ; --- CB prefix: rotate and bit tests --- + ld a, $01 + sla a + cp $02 + jp nz, fail + bit 1, a + jp z, fail + + ; --- calls and conditional returns --- + call compute + jp nz, fail + + ; --- execute a payload copied into RAM --- + ld hl, payload + ld de, $d000 +copy: + ld a, (hl+) + or a + jr z, run + ld (de), a + inc de + jr copy +run: + call $d000 + jp nz, fail + + ; All checks passed. + ld hl, msg_pass + call print + jp done + +fail: + ld hl, msg_fail + call print + +done: + halt + jr done + +; Computes 40 + 2 and compares to 42. +; Returns with Z set when the result is correct. +compute: + ld a, $28 + add a, $02 + cp $2a + ret + +; A payload for RAM execution. It returns with Z set only when the +; emulator runs code from working RAM correctly. The byte sequence must +; not contain a $00 operand, because the copy loop stops on a zero byte. +payload: + ld a, $07 + add a, $01 + cp $08 + ret + db $00 + +; Prints the zero-terminated string at HL through the serial link port. +print: + ld a, (hl+) + or a + jr z, print_done + ld ($ff01), a + ld a, $81 + ld ($ff02), a + ld a, $00 + ld ($ff02), a + jr print +print_done: + ret + +msg_pass: + db "PASS", $0a + db $00 + +msg_fail: + db "FAIL", $0a + db $00 diff --git a/fixtures/blargg/01-special.gb b/fixtures/blargg/01-special.gb new file mode 100644 index 0000000..ad3e998 Binary files /dev/null and b/fixtures/blargg/01-special.gb differ diff --git a/fixtures/blargg/02-interrupts.gb b/fixtures/blargg/02-interrupts.gb new file mode 100644 index 0000000..2089594 Binary files /dev/null and b/fixtures/blargg/02-interrupts.gb differ diff --git a/fixtures/blargg/03-op sp,hl.gb b/fixtures/blargg/03-op sp,hl.gb new file mode 100644 index 0000000..50b3cc7 Binary files /dev/null and b/fixtures/blargg/03-op sp,hl.gb differ diff --git a/fixtures/blargg/04-op r,imm.gb b/fixtures/blargg/04-op r,imm.gb new file mode 100644 index 0000000..58ca7b8 Binary files /dev/null and b/fixtures/blargg/04-op r,imm.gb differ diff --git a/fixtures/blargg/05-op rp.gb b/fixtures/blargg/05-op rp.gb new file mode 100644 index 0000000..1c19d92 Binary files /dev/null and b/fixtures/blargg/05-op rp.gb differ diff --git a/fixtures/blargg/06-ld r,r.gb b/fixtures/blargg/06-ld r,r.gb new file mode 100644 index 0000000..d497bfd Binary files /dev/null and b/fixtures/blargg/06-ld r,r.gb differ diff --git a/fixtures/blargg/07-jr,jp,call,ret,rst.gb b/fixtures/blargg/07-jr,jp,call,ret,rst.gb new file mode 100644 index 0000000..5c8d20b Binary files /dev/null and b/fixtures/blargg/07-jr,jp,call,ret,rst.gb differ diff --git a/fixtures/blargg/08-misc instrs.gb b/fixtures/blargg/08-misc instrs.gb new file mode 100644 index 0000000..4da139b Binary files /dev/null and b/fixtures/blargg/08-misc instrs.gb differ diff --git a/fixtures/blargg/09-op r,r.gb b/fixtures/blargg/09-op r,r.gb new file mode 100644 index 0000000..e30e6ec Binary files /dev/null and b/fixtures/blargg/09-op r,r.gb differ diff --git a/fixtures/blargg/10-bit ops.gb b/fixtures/blargg/10-bit ops.gb new file mode 100644 index 0000000..8988458 Binary files /dev/null and b/fixtures/blargg/10-bit ops.gb differ diff --git a/fixtures/blargg/11-op a,(hl).gb b/fixtures/blargg/11-op a,(hl).gb new file mode 100644 index 0000000..0634b7f Binary files /dev/null and b/fixtures/blargg/11-op a,(hl).gb differ diff --git a/fixtures/roms/smoke.gb b/fixtures/roms/smoke.gb new file mode 100644 index 0000000..e60b28b Binary files /dev/null and b/fixtures/roms/smoke.gb differ diff --git a/src/cartridge.zig b/src/cartridge.zig index 6fe8dae..6f1af6a 100644 --- a/src/cartridge.zig +++ b/src/cartridge.zig @@ -76,3 +76,147 @@ pub const Cartridge = struct { return if (index < self.rom.len) self.rom[index] else 0xff; } }; + +// The fixed cartridge header at $0134..$014F. The headless runner prints +// it with --info, and the windowed frontend uses the title for the window. +pub const Header = struct { + title: [16]u8, + mapper: []const u8, + rom_size_kib: u32, + ram_size_kib: u32, + cgb: bool, + sgb: bool, + header_checksum_ok: bool, + global_checksum: u16, +}; + +fn mapperName(code: u8) []const u8 { + return switch (code) { + 0x00 => "ROM only", + 0x01...0x03 => "MBC1", + 0x05, 0x06 => "MBC2", + 0x0f...0x13 => "MBC3", + 0x19...0x1e => "MBC5", + else => "Unknown", + }; +} + +fn romSizeKib(code: u8) u32 { + return switch (code) { + 0x00 => 32, + 0x01 => 64, + 0x02 => 128, + 0x03 => 256, + 0x04 => 512, + 0x05 => 1024, + 0x06 => 2048, + else => 0, + }; +} + +fn ramSizeKib(code: u8) u32 { + return switch (code) { + 0x01 => 2, + 0x02 => 8, + 0x03 => 32, + 0x04 => 128, + 0x05 => 64, + else => 0, + }; +} + +// Reads the 80-byte header at $0134. The header checksum is the sum of +// bytes $0134..$014D plus $19; it must wrap to zero. +pub fn readHeader(image: []const u8) Header { + var title = [_]u8{0} ** 16; + if (image.len >= 0x0144) { + const raw = image[0x0134..0x0144]; + var index: usize = 0; + while (index < title.len) : (index += 1) { + const byte = raw[index]; + if (byte < 0x20 or byte == 0x7f) break; + title[index] = byte; + } + } + var checksum: u8 = 0x19; + if (image.len >= 0x014e) { + for (image[0x0134..0x014e]) |byte| checksum +%= byte; + } + const global = if (image.len >= 0x0150) @as(u16, image[0x014f]) << 8 | image[0x014e] else 0; + const mapper = if (image.len >= 0x0148) image[0x0147] else 0; + const cgb = if (image.len >= 0x0144) (image[0x0143] == 0x80 or image[0x0143] == 0xc0) else false; + const sgb = if (image.len >= 0x0147) image[0x0146] == 0x03 else false; + const rom_size = if (image.len >= 0x0149) image[0x0148] else 0; + const ram_size = if (image.len >= 0x014a) image[0x0149] else 0; + return .{ + .title = title, + .mapper = mapperName(mapper), + .rom_size_kib = romSizeKib(rom_size), + .ram_size_kib = ramSizeKib(ram_size), + .cgb = cgb, + .sgb = sgb, + .header_checksum_ok = checksum == 0, + .global_checksum = global, + }; +} + +// Formats the header as a readable block for --info. The result is a +// slice into `buffer`; keep the buffer alive while printing it. +pub fn formatHeader(header: Header, buffer: []u8) []const u8 { + const title = std.mem.sliceTo(&header.title, 0); + const cgb = if (header.cgb) "yes" else "no"; + const sgb = if (header.sgb) "yes" else "no"; + const checksum = if (header.header_checksum_ok) "valid" else "invalid"; + return std.fmt.bufPrint(buffer, "Title: {s}\nMapper: {s}\nROM: {d} KiB\nRAM: {d} KiB\nCGB: {s}\nSGB: {s}\nHeader: {s} checksum\nGlobal: ${X:0>4}\n", .{ + if (title.len == 0) "(none)" else title, + header.mapper, + header.rom_size_kib, + header.ram_size_kib, + cgb, + sgb, + checksum, + header.global_checksum, + }) catch buffer[0..0]; +} + +test "readHeader parses a known ROM image" { + var image: [0x8000]u8 = [_]u8{0} ** 0x8000; + image[0x0147] = 0x01; + image[0x0148] = 0x01; + image[0x0149] = 0x03; + image[0x0143] = 0x80; + image[0x0146] = 0x03; + _ = std.fmt.bufPrint(image[0x0134..0x0144], "TEST ROM", .{}) catch unreachable; + const header = readHeader(&image); + try std.testing.expectEqualStrings("TEST ROM", std.mem.sliceTo(&header.title, 0)); + try std.testing.expectEqualStrings("MBC1", header.mapper); + try std.testing.expectEqual(@as(u32, 64), header.rom_size_kib); + try std.testing.expectEqual(@as(u32, 32), header.ram_size_kib); + try std.testing.expect(header.cgb); + try std.testing.expect(header.sgb); +} + +test "readHeader verifies the header checksum" { + var image: [0x8000]u8 = [_]u8{0} ** 0x8000; + _ = std.fmt.bufPrint(image[0x0134..0x0144], "SMOKE", .{}) catch unreachable; + const before = readHeader(&image); + try std.testing.expect(!before.header_checksum_ok); + var checksum: u8 = 0x19; + for (image[0x0134..0x014d]) |byte| checksum +%= byte; + image[0x014d] = 0 -% checksum; + const after = readHeader(&image); + try std.testing.expect(after.header_checksum_ok); +} + +test "formatHeader prints a readable block" { + var image: [0x8000]u8 = [_]u8{0} ** 0x8000; + _ = std.fmt.bufPrint(image[0x0134..0x0144], "SMOKE", .{}) catch unreachable; + var checksum: u8 = 0x19; + for (image[0x0134..0x014d]) |byte| checksum +%= byte; + image[0x014d] = 0 -% checksum; + var buffer: [256]u8 = undefined; + const text = formatHeader(readHeader(&image), &buffer); + try std.testing.expect(std.mem.indexOf(u8, text, "Title: SMOKE") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "Mapper: ROM only") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "valid checksum") != null); +} diff --git a/src/cpu.zig b/src/cpu.zig index fd32347..3c3eac3 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, } } @@ -484,8 +493,13 @@ pub const Cpu = struct { } fn popPair(self: *Cpu, bus: *Bus, pair_index: u2) u16 { - self.setPair(pair_index, self.pop(bus)); - if (pair_index == 3) self.f &= 0xf0; + const value = self.pop(bus); + if (pair_index == 3) { + self.a = @truncate(value >> 8); + self.f = @as(u8, @truncate(value)) & 0xf0; + } else { + self.setPair(pair_index, value); + } return 12; } @@ -681,3 +695,34 @@ pub const Cpu = struct { return @as(u16, self.a) << 8 | self.f; } }; + +const std = @import("std"); + +test "POP AF restores A and masks the low nibble of F" { + const allocator = std.testing.allocator; + var rom = [_]u8{0} ** 0x8000; + rom[0x0100] = 0xf1; + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + var cpu = Cpu{}; + cpu.sp = 0xc000; + bus.write(0xc000, 0x5b); + bus.write(0xc001, 0xa3); + _ = cpu.step(&bus); + try std.testing.expectEqual(@as(u8, 0xa3), cpu.a); + try std.testing.expectEqual(@as(u8, 0x50), cpu.f); + try std.testing.expectEqual(@as(u16, 0xc002), cpu.sp); +} + +test "POP AF does not disturb SP with a non-AF pair" { + const allocator = std.testing.allocator; + var rom = [_]u8{0} ** 0x8000; + rom[0x0100] = 0xf1; // POP AF would corrupt SP in the old code + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + var cpu = Cpu{}; + cpu.sp = 0xd000; + _ = cpu.step(&bus); + try std.testing.expectEqual(@as(u16, 0xd002), cpu.sp); + try std.testing.expectEqual(@as(u16, 0x0101), cpu.pc); +} diff --git a/src/disasm.zig b/src/disasm.zig index 234bc7c..1cd290a 100644 --- a/src/disasm.zig +++ b/src/disasm.zig @@ -94,7 +94,7 @@ const Writer = struct { } else { self.put("JR "); } - self.offset(pc, signed); + self.offset(pc +% 2, signed); } fn putRETC(self: *Writer, flag: []const u8) void { @@ -140,13 +140,17 @@ 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 { self.putFmt("LD A,(${X:0>4})", .{@as(u16, hi) << 8 | lo}); } + fn putLD16A(self: *Writer, hi: u8, lo: u8) void { + self.putFmt("LD (${X:0>4}),A", .{@as(u16, hi) << 8 | lo}); + } + fn putADDSP(self: *Writer, pc: u16, signed: u8) void { self.put("ADD SP,"); const value: i8 = @bitCast(signed); @@ -181,30 +185,28 @@ pub fn formatAt(bus: *Bus, pc: u16) Instruction { if (opcode == 0xcb) { const sub = fetch.byte(1); len = 2; - const operation = sub >> 3; + const group = sub >> 6; + const bit: u8 = (sub >> 3) & 7; const target: u8 = sub & 7; - const base = switch (operation) { - 0 => "RLC", - 1 => "RRC", - 2 => "RL", - 3 => "RR", - 4 => "SLA", - 5 => "SRA", - 6 => "SWAP", - else => "SRL", - }; - if (operation < 8) { - writer.put(base); + if (group == 0) { + const rotate_names = [_][]const u8{ "RLC", "RRC", "RL", "RR", "SLA", "SRA", "SWAP", "SRL" }; + writer.put(rotate_names[bit]); writer.put(" "); writer.put(r8(target)); cycles = if (target == 6) 16 else 8; - } else if (operation == 8) { - writer.putFmt("BIT {d},", .{@as(u8, sub >> 3) & 7}); + } else if (group == 1) { + writer.put("BIT "); + writer.putFmt("{d},", .{bit}); writer.put(r8(target)); cycles = if (target == 6) 12 else 8; - } else { + } else if (group == 2) { writer.put("RES "); - writer.putFmt("{d},", .{@as(u8, sub >> 3) & 7}); + writer.putFmt("{d},", .{bit}); + writer.put(r8(target)); + cycles = if (target == 6) 16 else 8; + } else { + writer.put("SET "); + writer.putFmt("{d},", .{bit}); writer.put(r8(target)); cycles = if (target == 6) 16 else 8; } @@ -332,7 +334,7 @@ pub fn formatAt(bus: *Bus, pc: u16) Instruction { 0xe7 => writer.putRST(0x20), 0xe8 => writer.putADDSP(pc, fetch.byte(1)), 0xe9 => writer.put("JP (HL)"), - 0xea => writer.putLDA16(fetch.byte(2), fetch.byte(1)), + 0xea => writer.putLD16A(fetch.byte(2), fetch.byte(1)), 0xee => writer.putALUIMM("XOR", fetch.byte(1)), 0xef => writer.putRST(0x28), 0xf0 => writer.putLDIMM8(@as(u16, 0xff00) + fetch.byte(1), "A"), @@ -381,22 +383,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 +421,64 @@ 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")); +} + +test "disassembler formats CB bit, reset, and set instructions" { + const allocator = std.testing.allocator; + var rom = [_]u8{0} ** 0x8000; + rom[0x0100] = 0xcb; + rom[0x0101] = 0x4f; // BIT 1,A + rom[0x0102] = 0xcb; + rom[0x0103] = 0x8e; // RES 1,(HL) + rom[0x0104] = 0xcb; + rom[0x0105] = 0xde; // SET 3,(HL) + rom[0x0106] = 0xcb; + rom[0x0107] = 0x27; // SLA A + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + + const bit = formatAt(&bus, 0x0100); + try std.testing.expectEqualStrings("BIT 1,A", std.mem.trimEnd(u8, &bit.text, "\x00")); + const res = formatAt(&bus, 0x0102); + try std.testing.expectEqualStrings("RES 1,(HL)", std.mem.trimEnd(u8, &res.text, "\x00")); + const set = formatAt(&bus, 0x0104); + try std.testing.expectEqualStrings("SET 3,(HL)", std.mem.trimEnd(u8, &set.text, "\x00")); + const sla = formatAt(&bus, 0x0106); + try std.testing.expectEqualStrings("SLA A", std.mem.trimEnd(u8, &sla.text, "\x00")); +} + +test "disassembler formats a16 loads and JR targets from the next PC" { + const allocator = std.testing.allocator; + var rom = [_]u8{0} ** 0x8000; + rom[0x0100] = 0xea; // LD ($FF01),A + rom[0x0101] = 0x01; + rom[0x0102] = 0xff; + rom[0x0103] = 0xfa; // LD A,($C000) + rom[0x0104] = 0x00; + rom[0x0105] = 0xc0; + rom[0x0106] = 0x18; // JR $0108 (offset 0) + rom[0x0107] = 0x00; + rom[0x0108] = 0x18; // JR $0100 (offset -10) + rom[0x0109] = 0xf6; + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + + const write = formatAt(&bus, 0x0100); + try std.testing.expectEqualStrings("LD ($FF01),A", std.mem.trimEnd(u8, &write.text, "\x00")); + const read = formatAt(&bus, 0x0103); + try std.testing.expectEqualStrings("LD A,($C000)", std.mem.trimEnd(u8, &read.text, "\x00")); + const fwd = formatAt(&bus, 0x0106); + try std.testing.expectEqualStrings("JR $0108", std.mem.trimEnd(u8, &fwd.text, "\x00")); + const back = formatAt(&bus, 0x0108); + try std.testing.expectEqualStrings("JR $0100", std.mem.trimEnd(u8, &back.text, "\x00")); } diff --git a/src/emulator.zig b/src/emulator.zig index 4639f75..370d70d 100644 --- a/src/emulator.zig +++ b/src/emulator.zig @@ -41,7 +41,7 @@ pub const Emulator = struct { pub fn runFrame(self: *Emulator, cap: u64) void { var remaining = cap; while (!self.bus.ppu.takeFrame()) { - self.step(); + _ = self.step(); remaining -= 1; if (remaining == 0) return; } diff --git a/src/headless.zig b/src/headless.zig index 9ec1f1f..1ffb4b5 100644 --- a/src/headless.zig +++ b/src/headless.zig @@ -2,6 +2,7 @@ const std = @import("std"); const Emulator = @import("emulator.zig").Emulator; const serial = @import("serial.zig"); const disasm = @import("disasm.zig"); +const cartridge = @import("cartridge.zig"); const default_max_cycles: u64 = 2_000_000_000; @@ -10,6 +11,7 @@ const Options = struct { blargg_suite: ?[]const u8 = null, max_cycles: u64 = default_max_cycles, trace: bool = false, + info: bool = false, expect: ?serial.Verdict = null, timeout_ms: u64 = 120_000, }; @@ -18,7 +20,7 @@ fn usage(program: []const u8) void { std.debug.print( \\Dot Matrix Deck - headless runner \\Usage: - \\ {s} [--max-cycles N] [--trace] [--expect pass|fail|any] + \\ {s} [--max-cycles N] [--trace] [--info] [--expect pass|fail|any] \\ {s} --blargg-suite \\ \\Runs a ROM without a window, prints its serial output, and sets the @@ -81,6 +83,13 @@ fn runRom(io: std.Io, allocator: std.mem.Allocator, options: Options) !u8 { const rom = try readFile(io, allocator, options.rom_path.?); defer allocator.free(rom); + if (options.info) { + var buffer: [256]u8 = undefined; + const text = cartridge.formatHeader(cartridge.readHeader(rom), &buffer); + std.debug.print("{s}\n", .{text}); + return 0; + } + var emulator = try Emulator.init(allocator, rom); defer emulator.deinit(); @@ -170,6 +179,8 @@ pub fn main(init: std.process.Init) !void { const arg = argv[index]; if (std.mem.eql(u8, arg, "--trace")) { options.trace = true; + } else if (std.mem.eql(u8, arg, "--info")) { + options.info = true; } else if (std.mem.eql(u8, arg, "--max-cycles")) { index += 1; if (index >= argv.len) { diff --git a/src/integration.zig b/src/integration.zig new file mode 100644 index 0000000..ebdc8e7 --- /dev/null +++ b/src/integration.zig @@ -0,0 +1,105 @@ +const std = @import("std"); +const Emulator = @import("emulator.zig").Emulator; +const cartridge = @import("cartridge.zig"); +const gbasm = @import("gbasm"); + +// End-to-end checks for the bundled smoke ROM. +// +// The ROM is assembled from its assembly source at test time and compared +// to the committed image. It is then run through the full emulator, and +// its serial verdict and frame checksum are checked against fixed golden +// values. The checksum is stable, so any change in the CPU, the bus, the +// PPU, or the assembler that alters observable output fails the suite. +// +// The fixtures are read from the repository root, which is the working +// directory for `zig build test`. + +const smoke_source_path = "fixtures/asm/smoke.asm"; +const smoke_rom_path = "fixtures/roms/smoke.gb"; + +fn readFixture(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const io = std.testing.io; + 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; +} + +// FNV-1a over the raw frame bytes. +fn frameChecksum(frame: []const u32) u32 { + var hash: u32 = 0x811c9dc5; + for (frame) |pixel| { + const bytes = [4]u8{ + @truncate(pixel), + @truncate(pixel >> 8), + @truncate(pixel >> 16), + @truncate(pixel >> 24), + }; + for (bytes) |byte| { + hash ^= byte; + hash *%= 0x01000193; + } + } + return hash; +} + +test "gbasm rebuilds the committed smoke ROM byte for byte" { + const allocator = std.testing.allocator; + const source = try readFixture(allocator, smoke_source_path); + defer allocator.free(source); + const committed = try readFixture(allocator, smoke_rom_path); + defer allocator.free(committed); + + var assembler = try gbasm.assembleSource(allocator, source); + defer assembler.deinit(); + try std.testing.expect(assembler.errorText() == null); + try std.testing.expectEqualSlices(u8, committed, assembler.image); +} + +test "smoke ROM carries a valid cartridge header" { + const allocator = std.testing.allocator; + const rom = try readFixture(allocator, smoke_rom_path); + defer allocator.free(rom); + + const header = cartridge.readHeader(rom); + try std.testing.expect(header.header_checksum_ok); + try std.testing.expectEqualStrings("SMOKE TEST", std.mem.sliceTo(&header.title, 0)); + try std.testing.expectEqualStrings("ROM only", header.mapper); +} + +test "smoke ROM passes through the full pipeline" { + const allocator = std.testing.allocator; + const rom = try readFixture(allocator, smoke_rom_path); + defer allocator.free(rom); + + var emulator = try Emulator.init(allocator, rom); + defer emulator.deinit(); + emulator.runFrame(1_000_000); + emulator.runFrame(1_000_000); + emulator.runFrame(1_000_000); + try std.testing.expectEqualStrings("PASS\n", emulator.serialOutput()); +} + +test "smoke ROM frame checksum is deterministic" { + const allocator = std.testing.allocator; + const rom = try readFixture(allocator, smoke_rom_path); + defer allocator.free(rom); + + const runFrames = struct { + fn call(alloc: std.mem.Allocator, image: []const u8) !u32 { + var emulator = try Emulator.init(alloc, image); + defer emulator.deinit(); + var frame_index: usize = 0; + while (frame_index < 3) : (frame_index += 1) emulator.runFrame(1_000_000); + return frameChecksum(&emulator.bus.ppu.frame); + } + }.call; + + const first = try runFrames(allocator, rom); + const second = try runFrames(allocator, rom); + try std.testing.expectEqual(first, second); + try std.testing.expectEqual(@as(u32, 0x5CCE8DC5), first); +} diff --git a/src/main.zig b/src/main.zig index 643116a..2e68b95 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,260 @@ const std = @import("std"); -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 Emulator = @import("emulator.zig").Emulator; +const cartridge = @import("cartridge.zig"); +const ppu = @import("ppu.zig"); + +// The windowed frontend. +// +// It opens an SDL2 window and shows the screen rendered by the PPU. +// Keyboard input maps to the joypad, and the loop runs at the console +// refresh rate. The emulation core is shared with the headless runner; +// only input and presentation live here. + +const frame_cycles: u64 = 70_224; +const target_fps: f64 = 59.7275; + +const Joypad = struct { + const right: u8 = 1 << 0; + const left: u8 = 1 << 1; + const up: u8 = 1 << 2; + const down: u8 = 1 << 3; + const a: u8 = 1 << 4; + const b: u8 = 1 << 5; + const select: u8 = 1 << 6; + const start: u8 = 1 << 7; +}; + +fn usage(program: []const u8) void { + std.debug.print( + \\Dot Matrix Deck - windowed frontend + \\Usage: + \\ {s} [--scale N] + \\ + \\Controls: + \\ X A button + \\ Z B button + \\ Enter Start + \\ Backspace Select + \\ Arrows D-pad + \\ Esc Quit + \\ + , .{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; +} + +// Applies one key event to the joypad. Game Boy buttons are low active, +// so a press clears the bit and a release sets it. +fn applyScancode(scancode: c.SDL_Scancode, buttons: *u8, pressed: bool) bool { + const bit: ?u8 = switch (scancode) { + c.SDL_SCANCODE_Z => Joypad.b, + c.SDL_SCANCODE_X => Joypad.a, + c.SDL_SCANCODE_RETURN => Joypad.start, + c.SDL_SCANCODE_BACKSPACE => Joypad.select, + c.SDL_SCANCODE_RIGHT => Joypad.right, + c.SDL_SCANCODE_LEFT => Joypad.left, + c.SDL_SCANCODE_UP => Joypad.up, + c.SDL_SCANCODE_DOWN => Joypad.down, + else => null, + }; + if (bit) |mask| { + if (pressed) { + buttons.* &= ~mask; + } else { + buttons.* |= mask; + } + return true; + } + return false; +} + +const Frontend = struct { + emulator: *Emulator, + window: ?*c.SDL_Window, + renderer: ?*c.SDL_Renderer, + texture: ?*c.SDL_Texture, + + fn fail(message: []const u8) noreturn { + std.debug.print("{s}: {s}\n", .{ message, c.SDL_GetError() }); + std.process.exit(1); + } + + fn init(emulator: *Emulator, scale: u32) Frontend { + _ = c.SDL_SetMainReady(); + if (c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_EVENTS) != 0) fail("SDL_Init failed"); + _ = c.SDL_SetHint(c.SDL_HINT_RENDER_SCALE_QUALITY, "nearest"); + + const width: c_int = @intCast(@as(u32, @intCast(ppu.ScreenWidth)) * scale); + const height: c_int = @intCast(@as(u32, @intCast(ppu.ScreenHeight)) * scale); + + const header = cartridge.readHeader(emulator.bus.cartridge.rom); + const raw_title = std.mem.sliceTo(&header.title, 0); + var caption_buf: [48]u8 = undefined; + const caption = std.fmt.bufPrint(&caption_buf, "Dot Matrix Deck - {s}", .{raw_title}) catch "Dot Matrix Deck"; + + const window = c.SDL_CreateWindow( + caption.ptr, + c.SDL_WINDOWPOS_UNDEFINED, + c.SDL_WINDOWPOS_UNDEFINED, + width, + height, + c.SDL_WINDOW_SHOWN, + ) orelse fail("SDL_CreateWindow failed"); + + const renderer = c.SDL_CreateRenderer( + window, + -1, + c.SDL_RENDERER_ACCELERATED | c.SDL_RENDERER_PRESENTVSYNC, + ) orelse c.SDL_CreateRenderer(window, -1, 0) orelse fail("SDL_CreateRenderer failed"); + + const texture = c.SDL_CreateTexture( + renderer, + c.SDL_PIXELFORMAT_ARGB8888, + c.SDL_TEXTUREACCESS_STREAMING, + @intCast(ppu.ScreenWidth), + @intCast(ppu.ScreenHeight), + ) orelse fail("SDL_CreateTexture failed"); + + return .{ .emulator = emulator, .window = window, .renderer = renderer, .texture = texture }; + } + + fn deinit(self: *Frontend) void { + if (self.texture) |texture| c.SDL_DestroyTexture(texture); + if (self.renderer) |renderer| c.SDL_DestroyRenderer(renderer); + if (self.window) |window| c.SDL_DestroyWindow(window); + c.SDL_Quit(); + } + + // Returns true when the main loop should stop. + fn handleEvents(self: *Frontend) bool { + var event: c.SDL_Event = undefined; + while (c.SDL_PollEvent(&event) != 0) { + switch (event.type) { + c.SDL_QUIT => return true, + c.SDL_KEYDOWN => { + const scancode = event.key.keysym.scancode; + if (!applyScancode(scancode, &self.emulator.bus.joypad, true) and scancode == c.SDL_SCANCODE_ESCAPE) { + return true; + } + }, + c.SDL_KEYUP => _ = applyScancode(event.key.keysym.scancode, &self.emulator.bus.joypad, false), + else => {}, + } + } + return false; + } + + // Copies the PPU frame into the texture and presents it. The row + // pitch reported by SDL may exceed the texture width, so each row is + // copied separately. + fn presentFrame(self: *Frontend) void { + var pixels: ?*anyopaque = null; + var pitch: c_int = 0; + if (c.SDL_LockTexture(self.texture, null, &pixels, &pitch) == 0) { + const dst = @as([*]u8, @ptrCast(pixels.?)); + const frame = self.emulator.bus.ppu.frame; + const bytes_per_row = ScreenRowBytes; + for (0..ppu.ScreenHeight) |row| { + const src = std.mem.sliceAsBytes(frame[row * ppu.ScreenWidth ..][0..ppu.ScreenWidth]); + @memcpy(dst[@as(usize, @intCast(pitch)) * row ..][0..bytes_per_row], src); + } + c.SDL_UnlockTexture(self.texture); + } + _ = c.SDL_RenderClear(self.renderer); + _ = c.SDL_RenderCopy(self.renderer, self.texture, null, null); + c.SDL_RenderPresent(self.renderer); + } + + fn run(self: *Frontend, io: std.Io) !void { + var last_frame = std.Io.Clock.Timestamp.now(io, .awake); + const frame_ns: i128 = @intFromFloat(@as(f64, std.time.ns_per_s) / target_fps); + + var running = true; + while (running) { + running = !self.handleEvents(); + if (!running) break; + + self.emulator.runFrame(frame_cycles); + self.presentFrame(); + + const now = std.Io.Clock.Timestamp.now(io, .awake); + const elapsed = last_frame.durationTo(now).raw.nanoseconds; + const slack = frame_ns - elapsed; + if (slack > 0) { + const millis: u32 = @intCast(@divTrunc(slack, std.time.ns_per_ms)); + c.SDL_Delay(millis); + } + last_frame = now; + } + } +}; + +const ScreenRowBytes = ppu.ScreenWidth * @sizeOf(u32); + +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; + + var it = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator); + defer it.deinit(); + + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(allocator); + while (it.next()) |arg| { + try args.append(allocator, arg); + } + const argv = args.items; + + var rom_path: ?[]const u8 = null; + var scale: u32 = 3; + var index: usize = 1; + while (index < argv.len) : (index += 1) { + const arg = argv[index]; + if (std.mem.eql(u8, arg, "--scale") and index + 1 < argv.len) { + index += 1; + scale = std.fmt.parseInt(u32, argv[index], 10) catch { + std.debug.print("bad scale value: {s}\n", .{argv[index]}); + std.process.exit(2); + }; + if (scale == 0 or scale > 8) { + std.debug.print("scale must be between 1 and 8\n", .{}); + std.process.exit(2); + } + } else if (rom_path == null) { + rom_path = arg; + } else { + usage(argv[0]); + std.process.exit(2); + } + } + + if (rom_path == null) { + usage(argv[0]); + std.process.exit(2); + } + + const rom = try readFile(io, allocator, rom_path.?); + defer allocator.free(rom); + + var emulator = try Emulator.init(allocator, rom); + defer emulator.deinit(); + + var frontend = Frontend.init(&emulator, scale); + defer frontend.deinit(); + + try frontend.run(io); } diff --git a/tools/gbasm.zig b/tools/gbasm.zig index e81a221..448d283 100644 --- a/tools/gbasm.zig +++ b/tools/gbasm.zig @@ -1,5 +1,915 @@ const std = @import("std"); -pub fn main() !void { - std.debug.print("gbasm: assembler is under construction.\n", .{}); +// gbasm - an SM83 assembler for test ROM sources. +// +// It turns the small assembly dialect used by the bundled test ROMs into +// a Game Boy cartridge image. The build step "zig build fixtures" runs it +// over the assembler sources and writes the ROMs. The integration tests +// assemble the smoke ROM again and compare the output to the committed +// image byte for byte. +// +// Supported syntax: +// org $0100 set the next address; the gap fills with $00 +// db 1, 2, 3 emit bytes from numbers or double-quoted strings +// dw $1234 emit 16-bit values, little endian +// ds $10 emit N zero bytes +// label: define a label at the current address +// instruction the SM83 base set plus the CB prefix +// +// Literals use $ for hex, % for binary, or plain decimal. An operand may +// also be a label, or a label plus or minus a constant. The image always +// has the size of a 32 KiB cartridge. SM83 instruction sizes never depend +// on operand values, so pass one sizes the program and pass two encodes +// it with full knowledge of the labels. + +pub const ImageSize: usize = 0x8000; +const max_operands = 16; + +const Line = struct { + label: ?[]const u8 = null, + mnemonic: []const u8 = "", + operands: [max_operands][]const u8 = undefined, + count: usize = 0, + + fn operand(self: *const Line, index: usize) []const u8 { + return self.operands[index]; + } +}; + +// Uppercases the slice in place. +fn upperInPlace(slice: []u8) void { + for (slice) |*ch| ch.* = std.ascii.toUpper(ch.*); +} + +// Splits a source line into a label, a mnemonic, and comma-separated +// operands. Tokens are uppercased in place so instructions and registers +// match case-insensitively; quoted strings keep their case. A semicolon +// starts a comment outside a quoted string. +fn parseLine(line: []u8) Line { + var rest = std.mem.trim(u8, line, " \t\r"); + + { + var in_quotes = false; + for (rest, 0..) |ch, i| { + if (ch == '"') in_quotes = !in_quotes; + if (ch == ';' and !in_quotes) { + rest = std.mem.trimEnd(u8, rest[0..i], " \t"); + break; + } + } + } + if (rest.len == 0) return .{}; + + var label: ?[]const u8 = null; + if (std.mem.indexOf(u8, rest, ":")) |colon| { + const prefix = std.mem.trimEnd(u8, rest[0..colon], " \t"); + const is_label = prefix.len != 0 and (colon + 1 == rest.len or rest[colon + 1] == ' ' or rest[colon + 1] == '\t'); + if (is_label) { + upperInPlace(@constCast(prefix)); + label = prefix; + rest = std.mem.trimStart(u8, rest[colon + 1 ..], " \t"); + } + } + if (rest.len == 0) return .{ .label = label }; + + var line_result = Line{ .label = label }; + if (std.mem.indexOfAny(u8, rest, " \t")) |space| { + line_result.mnemonic = rest[0..space]; + upperInPlace(@constCast(line_result.mnemonic)); + rest = std.mem.trimStart(u8, rest[space..], " \t"); + } else { + line_result.mnemonic = rest; + upperInPlace(@constCast(line_result.mnemonic)); + rest = ""; + } + if (rest.len == 0) return line_result; + + var start: usize = 0; + var in_quotes = false; + var i: usize = 0; + while (i <= rest.len) : (i += 1) { + const at_end = i == rest.len; + const ch = if (at_end) 0 else rest[i]; + if (ch == '"') in_quotes = !in_quotes; + if ((at_end or ch == ',') and !in_quotes) { + const operand = std.mem.trim(u8, rest[start..i], " \t"); + if (operand.len != 0 and line_result.count < max_operands) { + if (operand.len < 2 or operand[0] != '"') upperInPlace(@constCast(operand)); + line_result.operands[line_result.count] = operand; + line_result.count += 1; + } + start = i + 1; + } + } + return line_result; +} + +const Operand = enum { rel8, addr16, imm8, imm16, a8, rst_vec, sp }; + +const Instruction = struct { + operand: Operand, +}; + +fn reg8Index(text: []const u8) ?u8 { + return if (std.mem.eql(u8, text, "A")) 7 else if (std.mem.eql(u8, text, "B")) 0 else if (std.mem.eql(u8, text, "C")) 1 else if (std.mem.eql(u8, text, "D")) 2 else if (std.mem.eql(u8, text, "E")) 3 else if (std.mem.eql(u8, text, "H")) 4 else if (std.mem.eql(u8, text, "L")) 5 else if (std.mem.eql(u8, text, "(HL)")) 6 else null; +} + +fn reg16Index(text: []const u8) ?u8 { + return if (std.mem.eql(u8, text, "BC")) 0 else if (std.mem.eql(u8, text, "DE")) 1 else if (std.mem.eql(u8, text, "HL")) 2 else if (std.mem.eql(u8, text, "SP")) 3 else null; +} + +fn pushPopIndex(text: []const u8) ?u8 { + return if (std.mem.eql(u8, text, "BC")) 0 else if (std.mem.eql(u8, text, "DE")) 1 else if (std.mem.eql(u8, text, "HL")) 2 else if (std.mem.eql(u8, text, "AF")) 3 else null; +} + +fn condIndex(text: []const u8) ?u8 { + return if (std.mem.eql(u8, text, "NZ")) 0 else if (std.mem.eql(u8, text, "Z")) 1 else if (std.mem.eql(u8, text, "NC")) 2 else if (std.mem.eql(u8, text, "C")) 3 else null; +} + +fn aluIndex(text: []const u8) ?u8 { + return if (std.mem.eql(u8, text, "ADD")) 0 else if (std.mem.eql(u8, text, "ADC")) 1 else if (std.mem.eql(u8, text, "SUB")) 2 else if (std.mem.eql(u8, text, "SBC")) 3 else if (std.mem.eql(u8, text, "AND")) 4 else if (std.mem.eql(u8, text, "XOR")) 5 else if (std.mem.eql(u8, text, "OR")) 6 else if (std.mem.eql(u8, text, "CP")) 7 else null; +} + +fn cbIndex(text: []const u8) ?u8 { + return if (std.mem.eql(u8, text, "RLC")) 0 else if (std.mem.eql(u8, text, "RRC")) 1 else if (std.mem.eql(u8, text, "RL")) 2 else if (std.mem.eql(u8, text, "RR")) 3 else if (std.mem.eql(u8, text, "SLA")) 4 else if (std.mem.eql(u8, text, "SRA")) 5 else if (std.mem.eql(u8, text, "SWAP")) 6 else if (std.mem.eql(u8, text, "SRL")) 7 else null; +} + +pub const Assembler = struct { + allocator: std.mem.Allocator, + image: []u8, + symbols: std.StringHashMap(u16), + work: []u8, + error_buffer: [160]u8 = undefined, + error_len: usize = 0, + pc: u16 = 0, + final_pass: bool = false, + + pub fn deinit(self: *Assembler) void { + var it = self.symbols.keyIterator(); + while (it.next()) |key| self.allocator.free(key.*); + self.symbols.deinit(); + self.allocator.free(self.image); + self.allocator.free(self.work); + } + + pub fn errorText(self: *const Assembler) ?[]const u8 { + if (self.error_len == 0) return null; + return self.error_buffer[0..self.error_len]; + } + + fn fail(self: *Assembler, comptime format: []const u8, args: anytype) void { + if (self.error_len != 0) return; + const text = std.fmt.bufPrint(&self.error_buffer, format, args) catch "assembly error"; + self.error_len = @min(text.len, self.error_buffer.len); + } + + // Evaluates an operand to an unsigned value. Unknown symbols return + // null; pass one treats those as zero and pass two resolves them. + // Surrounding parentheses, as in (a16), are ignored. + fn evaluate(self: *Assembler, text: []const u8) ?u32 { + const operand = std.mem.trim(u8, text, " \t"); + if (operand.len == 0) return null; + + const bare = if (operand.len >= 2 and operand[0] == '(' and operand[operand.len - 1] == ')') + std.mem.trim(u8, operand[1 .. operand.len - 1], " \t") + else + operand; + if (bare.len == 0) return null; + + if (std.mem.indexOfAny(u8, bare, "+-")) |sign| { + const left = std.mem.trim(u8, bare[0..sign], " \t"); + const right = std.mem.trim(u8, bare[sign + 1 ..], " \t"); + const base_value = self.evaluateSymbol(left) orelse return null; + const delta = self.parseNumber(right) orelse return null; + const result = if (bare[sign] == '+') @as(i64, base_value) + delta else @as(i64, base_value) - delta; + if (result < 0 or result > 0xffff) return null; + return @intCast(result); + } + return self.evaluateSymbol(bare); + } + + fn evaluateSymbol(self: *Assembler, text: []const u8) ?u32 { + if (self.parseNumber(text)) |value| return value; + if (self.symbols.get(text)) |value| return value; + return null; + } + + fn parseNumber(self: *Assembler, text: []const u8) ?u32 { + _ = self; + const value = if (text.len >= 1 and text[0] == '$') std.fmt.parseInt(u32, text[1..], 16) else if (text.len >= 1 and text[0] == '%') std.fmt.parseInt(u32, text[1..], 2) else std.fmt.parseInt(u32, text, 10); + return value catch null; + } + + fn writeByte(self: *Assembler, value: u8) void { + if (self.pc >= ImageSize) { + self.fail("program exceeds 32 KiB", .{}); + return; + } + self.image[self.pc] = value; + self.pc +%= 1; + } + + fn writeOperand(self: *Assembler, instruction: Instruction, line: Line, index: usize) void { + switch (instruction.operand) { + .rel8 => self.writeRel8(line, index), + .imm16, .addr16 => { + const value = self.operandWord(instruction, line, index); + self.writeByte(@truncate(value)); + self.writeByte(@truncate(value >> 8)); + }, + .imm8, .a8, .rst_vec, .sp => self.writeByte(self.operandByte(instruction, line, index)), + } + } + + fn operandByte(self: *Assembler, instruction: Instruction, line: Line, index: usize) u8 { + const text = std.mem.trim(u8, line.operand(index), " \t"); + switch (instruction.operand) { + .rst_vec => { + const value = self.evaluate(text) orelse 0; + if (value > 0x38 or (value & 7) != 0) { + self.fail("invalid RST vector: {s}", .{text}); + return 0; + } + return @intCast(value); + }, + .sp => return self.offsetByte(text), + .imm8, .a8 => { + const value = self.evaluate(text) orelse 0; + if (value > 0xff) self.fail("immediate out of range: {s}", .{text}); + return @truncate(value); + }, + else => return 0, + } + } + + // Encodes a signed 8-bit stack offset for ADD SP,e8 and LD HL,SP+e8. + // The operand may be written as a number, a label, or SP plus or + // minus a number. + fn offsetByte(self: *Assembler, text: []const u8) u8 { + var token = std.mem.trim(u8, text, " \t"); + if (std.mem.startsWith(u8, token, "SP")) { + token = std.mem.trimStart(u8, token[2..], " \t"); + } + var negative = false; + if (token.len >= 1 and (token[0] == '+' or token[0] == '-')) { + negative = token[0] == '-'; + token = std.mem.trimStart(u8, token[1..], " \t"); + } + const magnitude = self.evaluate(token) orelse 0; + const value: i64 = if (negative) -@as(i64, magnitude) else magnitude; + if (value < -128 or value > 127) { + self.fail("stack offset out of range: {s}", .{text}); + return 0; + } + const encoded: u32 = @bitCast(@as(i32, @intCast(value))); + return @truncate(encoded); + } + + fn operandWord(self: *Assembler, instruction: Instruction, line: Line, index: usize) u16 { + const text = std.mem.trim(u8, line.operand(index), " \t"); + _ = instruction; + const value = self.evaluate(text) orelse 0; + if (value > 0xffff) self.fail("immediate out of range: {s}", .{text}); + return @truncate(value); + } + + fn writeRel8(self: *Assembler, line: Line, index: usize) void { + const text = std.mem.trim(u8, line.operand(index), " \t"); + const target = self.evaluate(text) orelse 0; + const next_pc = @as(u32, self.pc) + 1; + const delta = @as(i64, target) - @as(i64, next_pc); + if (self.final_pass and (delta < -128 or delta > 127)) { + self.fail("branch target out of range: {s}", .{text}); + return; + } + const encoded: u32 = @bitCast(@as(i32, @intCast(delta))); + self.writeByte(@truncate(encoded)); + } + + // Encodes a two-operand LD or ALU form where the operands encode + // register bits directly. + fn encodeRegPair(self: *Assembler, opcode: u8, line: Line) void { + const dst = reg8Index(line.operand(0)) orelse blk: { + self.fail("expected 8-bit register: {s}", .{line.operand(0)}); + break :blk 0; + }; + const src = reg8Index(line.operand(1)) orelse blk: { + self.fail("expected 8-bit register: {s}", .{line.operand(1)}); + break :blk 0; + }; + self.writeByte(opcode | (dst << 3) | src); + } + + fn encodeAluReg(self: *Assembler, alu: u8, line: Line) void { + const src = reg8Index(line.operand(1)) orelse blk: { + self.fail("expected 8-bit register: {s}", .{line.operand(1)}); + break :blk 0; + }; + self.writeByte(0x80 | (alu << 3) | src); + } + + fn encodeCb(self: *Assembler, line: Line) bool { + if (cbIndex(line.mnemonic)) |operation| { + if (line.count != 1) { + self.fail("expected one register: {s}", .{line.mnemonic}); + return true; + } + const reg = reg8Index(line.operand(0)) orelse blk: { + self.fail("expected 8-bit register: {s}", .{line.operand(0)}); + break :blk 0; + }; + self.writeByte(0xcb); + self.writeByte(operation << 3 | reg); + return true; + } + if (std.mem.eql(u8, line.mnemonic, "BIT") or std.mem.eql(u8, line.mnemonic, "RES") or std.mem.eql(u8, line.mnemonic, "SET")) { + if (line.count != 2) { + self.fail("expected bit and register: {s}", .{line.mnemonic}); + return true; + } + const bit = self.evaluate(line.operand(0)) orelse 0; + if (bit > 7) { + self.fail("bit index out of range: {s}", .{line.operand(0)}); + return true; + } + const reg = reg8Index(line.operand(1)) orelse blk: { + self.fail("expected 8-bit register: {s}", .{line.operand(1)}); + break :blk 0; + }; + const base: u8 = if (std.mem.eql(u8, line.mnemonic, "BIT")) 0x40 else if (std.mem.eql(u8, line.mnemonic, "RES")) 0x80 else 0xc0; + self.writeByte(0xcb); + self.writeByte(base | (@as(u8, @intCast(bit)) << 3) | reg); + return true; + } + return false; + } + + fn emitInstruction(self: *Assembler, line: Line) void { + if (self.encodeCb(line)) return; + + const no_operands = [_][]const u8{ + "NOP", "HALT", "STOP", "DAA", "CPL", "SCF", "CCF", "DI", "EI", + "RLCA", "RRCA", "RLA", "RRA", "RET", "RETI", + }; + const no_operand_codes = [_]u8{ + 0x00, 0x76, 0x10, 0x27, 0x2f, 0x37, 0x3f, 0xf3, 0xfb, + 0x07, 0x0f, 0x17, 0x1f, 0xc9, 0xd9, + }; + for (no_operands, 0..) |name, i| { + if (std.mem.eql(u8, line.mnemonic, name)) { + if (line.count == 0) { + self.writeByte(no_operand_codes[i]); + } else { + self.fail("instruction takes no operands: {s}", .{name}); + } + return; + } + } + + if (line.count == 1) { + if (std.mem.eql(u8, line.mnemonic, "JP") and std.mem.eql(u8, line.operand(0), "(HL)")) { + self.writeByte(0xe9); + return; + } + if (reg8Index(line.operand(0))) |reg| { + if (std.mem.eql(u8, line.mnemonic, "INC")) { + self.writeByte(0x04 | (reg << 3)); + return; + } + if (std.mem.eql(u8, line.mnemonic, "DEC")) { + self.writeByte(0x05 | (reg << 3)); + return; + } + } + if (reg16Index(line.operand(0))) |rp| { + if (std.mem.eql(u8, line.mnemonic, "INC")) { + self.writeByte(0x03 | (rp << 4)); + return; + } + if (std.mem.eql(u8, line.mnemonic, "DEC")) { + self.writeByte(0x0b | (rp << 4)); + return; + } + } + if (pushPopIndex(line.operand(0))) |rp| { + if (std.mem.eql(u8, line.mnemonic, "PUSH")) { + self.writeByte(0xc5 | (rp << 4)); + return; + } + if (std.mem.eql(u8, line.mnemonic, "POP")) { + self.writeByte(0xc1 | (rp << 4)); + return; + } + } + if (std.mem.eql(u8, line.mnemonic, "JR")) { + self.writeByte(0x18); + self.writeOperand(.{ .operand = .rel8 }, line, 0); + return; + } + if (std.mem.eql(u8, line.mnemonic, "JP")) { + self.writeByte(0xc3); + self.writeOperand(.{ .operand = .addr16 }, line, 0); + return; + } + if (std.mem.eql(u8, line.mnemonic, "CALL")) { + self.writeByte(0xcd); + self.writeOperand(.{ .operand = .addr16 }, line, 0); + return; + } + if (std.mem.eql(u8, line.mnemonic, "RST")) { + self.writeByte(0xc7); + self.writeOperand(.{ .operand = .rst_vec }, line, 0); + return; + } + if (aluIndex(line.mnemonic)) |alu| { + if (reg8Index(line.operand(0))) |src| { + self.writeByte(0x80 | (alu << 3) | src); + return; + } + self.writeByte(0xc6 | (alu << 3)); + self.writeOperand(.{ .operand = .imm8 }, line, 0); + return; + } + self.fail("unsupported instruction: {s}", .{line.mnemonic}); + return; + } + + if (line.count == 2) { + if (std.mem.eql(u8, line.mnemonic, "LD")) { + self.emitLd(line); + return; + } + if (std.mem.eql(u8, line.mnemonic, "LDH")) { + self.emitLdh(line); + return; + } + if (std.mem.eql(u8, line.mnemonic, "ADD") and std.mem.eql(u8, line.operand(0), "HL")) { + if (reg16Index(line.operand(1))) |rp| { + self.writeByte(0x09 | (rp << 4)); + return; + } + self.fail("expected 16-bit register: {s}", .{line.operand(1)}); + return; + } + if (std.mem.eql(u8, line.mnemonic, "ADD") and std.mem.eql(u8, line.operand(0), "SP")) { + self.writeByte(0xe8); + self.writeOperand(.{ .operand = .sp }, line, 1); + return; + } + if (aluIndex(line.mnemonic)) |alu| { + if (!std.mem.eql(u8, line.operand(0), "A")) { + self.fail("ALU operands must start with A: {s}", .{line.operand(0)}); + return; + } + if (reg8Index(line.operand(1))) |src| { + self.writeByte(0x80 | (alu << 3) | src); + return; + } + self.writeByte(0xc6 | (alu << 3)); + self.writeOperand(.{ .operand = .imm8 }, line, 1); + return; + } + if (std.mem.eql(u8, line.mnemonic, "JR")) { + if (condIndex(line.operand(0))) |cc| { + self.writeByte(0x20 | (cc << 3)); + self.writeOperand(.{ .operand = .rel8 }, line, 1); + return; + } + self.fail("expected condition: {s}", .{line.operand(0)}); + return; + } + if (std.mem.eql(u8, line.mnemonic, "RET")) { + if (condIndex(line.operand(0))) |cc| { + self.writeByte(0xc0 | (cc << 3)); + return; + } + self.fail("expected condition: {s}", .{line.operand(0)}); + return; + } + if (std.mem.eql(u8, line.mnemonic, "JP") or std.mem.eql(u8, line.mnemonic, "CALL")) { + const base: u8 = if (std.mem.eql(u8, line.mnemonic, "JP")) 0xc2 else 0xc4; + if (condIndex(line.operand(0))) |cc| { + self.writeByte(base | (cc << 3)); + self.writeOperand(.{ .operand = .addr16 }, line, 1); + return; + } + self.fail("expected condition: {s}", .{line.operand(0)}); + return; + } + self.fail("unsupported instruction: {s}", .{line.mnemonic}); + return; + } + + self.fail("unsupported instruction: {s}", .{line.mnemonic}); + } + + fn emitLd(self: *Assembler, line: Line) void { + if (std.mem.eql(u8, line.operand(1), "A")) { + if (std.mem.eql(u8, line.operand(0), "(BC)")) return self.writeByte(0x02); + if (std.mem.eql(u8, line.operand(0), "(DE)")) return self.writeByte(0x12); + if (std.mem.eql(u8, line.operand(0), "(HL+)")) return self.writeByte(0x22); + if (std.mem.eql(u8, line.operand(0), "(HL-)")) return self.writeByte(0x32); + if (std.mem.eql(u8, line.operand(0), "(C)")) return self.writeByte(0xe2); + if (std.mem.eql(u8, line.operand(0), "(HL)")) return self.writeByte(0x77); + if (line.operand(0).len >= 2 and line.operand(0)[0] == '(') { + self.writeByte(0xea); + self.writeOperand(.{ .operand = .addr16 }, line, 0); + return; + } + if (reg8Index(line.operand(0))) |reg| { + self.writeByte(0x40 | (reg << 3) | 7); + return; + } + } + if (std.mem.eql(u8, line.operand(0), "A")) { + if (std.mem.eql(u8, line.operand(1), "(BC)")) return self.writeByte(0x0a); + if (std.mem.eql(u8, line.operand(1), "(DE)")) return self.writeByte(0x1a); + if (std.mem.eql(u8, line.operand(1), "(HL+)")) return self.writeByte(0x2a); + if (std.mem.eql(u8, line.operand(1), "(HL-)")) return self.writeByte(0x3a); + if (std.mem.eql(u8, line.operand(1), "(C)")) return self.writeByte(0xf2); + if (std.mem.eql(u8, line.operand(1), "(HL)")) return self.writeByte(0x7e); + if (line.operand(1).len >= 2 and line.operand(1)[0] == '(') { + self.writeByte(0xfa); + self.writeOperand(.{ .operand = .addr16 }, line, 1); + return; + } + if (reg8Index(line.operand(1))) |src| { + self.writeByte(0x40 | (7 << 3) | src); + return; + } + } + if (std.mem.eql(u8, line.operand(0), "SP") and std.mem.eql(u8, line.operand(1), "HL")) { + return self.writeByte(0xf9); + } + if (std.mem.eql(u8, line.operand(1), "SP")) { + if (line.operand(0).len >= 2 and line.operand(0)[0] == '(') { + self.writeByte(0x08); + self.writeOperand(.{ .operand = .addr16 }, line, 0); + return; + } + } + if (std.mem.eql(u8, line.operand(0), "HL") and std.mem.startsWith(u8, line.operand(1), "SP")) { + self.writeByte(0xf8); + self.writeOperand(.{ .operand = .sp }, line, 1); + return; + } + if (std.mem.eql(u8, line.operand(0), "(HL)") and reg8Index(line.operand(1)) == null) { + self.writeByte(0x36); + self.writeOperand(.{ .operand = .imm8 }, line, 1); + return; + } + if (reg16Index(line.operand(0))) |rp| { + self.writeByte(0x01 | (rp << 4)); + self.writeOperand(.{ .operand = .imm16 }, line, 1); + return; + } + if (reg8Index(line.operand(0))) |reg| { + self.writeByte(0x06 | (reg << 3)); + self.writeOperand(.{ .operand = .imm8 }, line, 1); + return; + } + if (reg8Index(line.operand(0))) |dst| { + if (reg8Index(line.operand(1))) |src| { + self.writeByte(0x40 | (dst << 3) | src); + return; + } + } + self.fail("unsupported LD form: {s}, {s}", .{ line.operand(0), line.operand(1) }); + } + + fn emitLdh(self: *Assembler, line: Line) void { + if (std.mem.eql(u8, line.operand(0), "(C)") and std.mem.eql(u8, line.operand(1), "A")) { + return self.writeByte(0xe2); + } + if (std.mem.eql(u8, line.operand(0), "A") and std.mem.eql(u8, line.operand(1), "(C)")) { + return self.writeByte(0xf2); + } + if (std.mem.eql(u8, line.operand(1), "A")) { + self.writeByte(0xe0); + self.writeOperand(.{ .operand = .a8 }, line, 0); + return; + } + if (std.mem.eql(u8, line.operand(0), "A")) { + self.writeByte(0xf0); + self.writeOperand(.{ .operand = .a8 }, line, 1); + return; + } + self.fail("unsupported LDH form: {s}, {s}", .{ line.operand(0), line.operand(1) }); + } + + fn emitDirective(self: *Assembler, line: Line) bool { + if (std.mem.eql(u8, line.mnemonic, "ORG")) { + const value = self.evaluate(line.operand(0)) orelse { + self.fail("org needs a constant address", .{}); + return true; + }; + if (value > 0xffff) { + self.fail("org address out of range", .{}); + return true; + } + const target: u16 = @intCast(value); + if (target < self.pc) { + self.fail("org moves the program backwards", .{}); + return true; + } + while (self.pc < target) self.writeByte(0); + return true; + } + if (std.mem.eql(u8, line.mnemonic, "DS")) { + const count = self.evaluate(line.operand(0)) orelse { + self.fail("DS needs a constant size", .{}); + return true; + }; + var i: u32 = 0; + while (i < count) : (i += 1) self.writeByte(0); + return true; + } + if (std.mem.eql(u8, line.mnemonic, "DB")) { + for (0..line.count) |i| self.emitDbOperand(line.operand(i)); + return true; + } + if (std.mem.eql(u8, line.mnemonic, "DW")) { + for (0..line.count) |i| { + const value = self.evaluate(line.operand(i)) orelse 0; + if (value > 0xffff) self.fail("16-bit value out of range: {s}", .{line.operand(i)}); + self.writeByte(@truncate(value)); + self.writeByte(@truncate(value >> 8)); + } + return true; + } + return false; + } + + fn emitDbOperand(self: *Assembler, text: []const u8) void { + const operand = std.mem.trim(u8, text, " \t"); + if (operand.len >= 2 and operand[0] == '"' and operand[operand.len - 1] == '"') { + for (operand[1 .. operand.len - 1]) |ch| self.writeByte(ch); + return; + } + const value = self.evaluate(operand) orelse 0; + if (value > 0xff) self.fail("byte value out of range: {s}", .{operand}); + self.writeByte(@truncate(value)); + } + + // Runs one assembly pass. The first pass records labels and lays out + // the program. The second pass rewrites the image with resolved + // labels; instruction sizes do not change between the passes. + fn pass(self: *Assembler, record_labels: bool) void { + self.pc = 0; + var lines = std.mem.splitScalar(u8, self.work, '\n'); + while (lines.next()) |raw| { + const line = parseLine(@constCast(raw)); + if (line.mnemonic.len == 0 and line.label == null) continue; + + if (line.label) |name| { + if (record_labels) { + if (self.symbols.contains(name)) { + self.fail("duplicate label: {s}", .{name}); + return; + } + const key = self.allocator.dupe(u8, name) catch { + self.fail("out of memory", .{}); + return; + }; + self.symbols.put(key, self.pc) catch { + self.allocator.free(key); + self.fail("out of memory", .{}); + return; + }; + } + } + if (line.mnemonic.len == 0) continue; + + if (self.emitDirective(line)) continue; + self.emitInstruction(line); + if (self.error_len != 0) return; + } + } +}; + +pub fn assembleSource(allocator: std.mem.Allocator, source: []const u8) !Assembler { + const image = try allocator.alloc(u8, ImageSize); + @memset(image, 0); + const work = try allocator.dupe(u8, source); + var assembler = Assembler{ + .allocator = allocator, + .image = image, + .symbols = std.StringHashMap(u16).init(allocator), + .work = work, + }; + errdefer assembler.deinit(); + assembler.pass(true); + assembler.final_pass = true; + assembler.pass(false); + return assembler; +} + +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; + + var it = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator); + defer it.deinit(); + + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(allocator); + while (it.next()) |arg| { + try args.append(allocator, arg); + } + const argv = args.items; + + if (argv.len < 3) { + std.debug.print( + \\gbasm - SM83 assembler for Dot Matrix Deck fixtures + \\Usage: + \\ {s} + \\ + , .{argv[0]}); + std.process.exit(2); + } + + const asm_dir_path = argv[1]; + const rom_dir_path = argv[2]; + + var asm_dir = try std.Io.Dir.cwd().openDir(io, asm_dir_path, .{ .iterate = true }); + defer asm_dir.close(io); + var rom_dir = try std.Io.Dir.cwd().openDir(io, rom_dir_path, .{}); + defer rom_dir.close(io); + + var sources: std.ArrayList([]const u8) = .empty; + defer { + for (sources.items) |name| allocator.free(name); + sources.deinit(allocator); + } + var iter = asm_dir.iterate(); + while (try iter.next(io)) |entry| { + if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".asm")) { + try sources.append(allocator, try allocator.dupe(u8, entry.name)); + } + } + std.mem.sort([]const u8, sources.items, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.order(u8, a, b) == .lt; + } + }.lessThan); + + var failures: usize = 0; + for (sources.items) |name| { + const source_path = try std.fs.path.join(allocator, &.{ asm_dir_path, name }); + defer allocator.free(source_path); + + const dir = std.Io.Dir.cwd(); + const file = try dir.openFile(io, source_path, .{}); + defer file.close(io); + const size = try file.length(io); + const source = try allocator.alloc(u8, @intCast(size)); + defer allocator.free(source); + _ = try file.readPositionalAll(io, source, 0); + + var assembler = try assembleSource(allocator, source); + defer assembler.deinit(); + if (assembler.errorText()) |error_text| { + std.debug.print("{s}: {s}\n", .{ name, error_text }); + failures += 1; + continue; + } + + const out_name = std.fmt.allocPrint(allocator, "{s}.gb", .{std.mem.trimEnd(u8, name, ".asm")}) catch blk: { + failures += 1; + break :blk ""; + }; + defer allocator.free(out_name); + const out_path = try std.fs.path.join(allocator, &.{ rom_dir_path, out_name }); + defer allocator.free(out_path); + + const out_file = try rom_dir.createFile(io, out_name, .{}); + defer out_file.close(io); + try out_file.writePositionalAll(io, assembler.image, 0); + std.debug.print("{s}: {d} bytes\n", .{ name, assembler.pc }); + } + + if (failures != 0) std.process.exit(1); +} + +const testing = std.testing; + +test "assembles and runs a small NOP program" { + const allocator = testing.allocator; + const source = + \\org $0100 + \\nop + \\jr done + \\db "X", $00 + \\done: + \\halt + ; + var assembler = try assembleSource(allocator, source); + defer assembler.deinit(); + try testing.expect(assembler.errorText() == null); + try testing.expectEqual(@as(u8, 0x00), assembler.image[0x0100]); + try testing.expectEqual(@as(u8, 0x18), assembler.image[0x0101]); + try testing.expectEqual(@as(u8, 0x02), assembler.image[0x0102]); + try testing.expectEqual(@as(u8, 0x58), assembler.image[0x0103]); + try testing.expectEqual(@as(u16, 0x0105), assembler.symbols.get("DONE").?); + try testing.expectEqual(@as(u8, 0x76), assembler.image[0x0105]); +} + +test "two-pass assembly resolves forward references" { + const allocator = testing.allocator; + const source = + \\org $0100 + \\ld a, $01 + \\jp skip + \\ld b, $02 + \\skip: + \\cp $01 + ; + var assembler = try assembleSource(allocator, source); + defer assembler.deinit(); + try testing.expect(assembler.errorText() == null); + try testing.expectEqual(@as(u8, 0x3e), assembler.image[0x0100]); + try testing.expectEqual(@as(u8, 0xc3), assembler.image[0x0102]); + try testing.expectEqual(@as(u16, 0x0107), @as(u16, assembler.image[0x0103]) | @as(u16, assembler.image[0x0104]) << 8); + try testing.expectEqual(@as(u8, 0xfe), assembler.image[0x0107]); +} + +test "directives emit data and pad gaps" { + const allocator = testing.allocator; + const source = + \\org $0100 + \\db 1, 2 + \\dw $1234 + \\ds 2 + \\org $0108 + \\db $aa + ; + var assembler = try assembleSource(allocator, source); + defer assembler.deinit(); + try testing.expect(assembler.errorText() == null); + try testing.expectEqual(@as(u8, 1), assembler.image[0x0100]); + try testing.expectEqual(@as(u8, 2), assembler.image[0x0101]); + try testing.expectEqual(@as(u8, 0x34), assembler.image[0x0102]); + try testing.expectEqual(@as(u8, 0x12), assembler.image[0x0103]); + try testing.expectEqual(@as(u8, 0x00), assembler.image[0x0106]); + try testing.expectEqual(@as(u8, 0xaa), assembler.image[0x0108]); +} + +test "cb-prefixed instructions encode bit operations" { + const allocator = testing.allocator; + const source = + \\org $0100 + \\sla a + \\bit 1, a + \\set 3, (hl) + \\res 0, b + \\rrc c + ; + var assembler = try assembleSource(allocator, source); + defer assembler.deinit(); + try testing.expect(assembler.errorText() == null); + try testing.expectEqual(@as(u8, 0xcb), assembler.image[0x0100]); + try testing.expectEqual(@as(u8, 0x27), assembler.image[0x0101]); + try testing.expectEqual(@as(u8, 0xcb), assembler.image[0x0102]); + try testing.expectEqual(@as(u8, 0x4f), assembler.image[0x0103]); + try testing.expectEqual(@as(u8, 0xcb), assembler.image[0x0104]); + try testing.expectEqual(@as(u8, 0xde), assembler.image[0x0105]); + try testing.expectEqual(@as(u8, 0xcb), assembler.image[0x0106]); + try testing.expectEqual(@as(u8, 0x80), assembler.image[0x0107]); + try testing.expectEqual(@as(u8, 0xcb), assembler.image[0x0108]); + try testing.expectEqual(@as(u8, 0x09), assembler.image[0x0109]); +} + +test "errors report the offending construct" { + const allocator = testing.allocator; + const source = + \\org $0100 + \\label: + \\label: + \\nop + ; + var assembler = try assembleSource(allocator, source); + defer assembler.deinit(); + const error_text = assembler.errorText(); + try testing.expect(error_text != null); + try testing.expect(std.mem.indexOf(u8, error_text.?, "duplicate label") != null); +} + +test "signed stack offsets encode as two-pass values" { + const allocator = testing.allocator; + const source = + \\org $0100 + \\add sp, -$02 + \\ld hl, sp+$05 + \\ld (start), sp + \\start: + ; + var assembler = try assembleSource(allocator, source); + defer assembler.deinit(); + try testing.expect(assembler.errorText() == null); + try testing.expectEqual(@as(u8, 0xe8), assembler.image[0x0100]); + try testing.expectEqual(@as(u8, 0xfe), assembler.image[0x0101]); + try testing.expectEqual(@as(u8, 0xf8), assembler.image[0x0102]); + try testing.expectEqual(@as(u8, 0x05), assembler.image[0x0103]); + try testing.expectEqual(@as(u8, 0x08), assembler.image[0x0104]); + try testing.expectEqual(@as(u16, 0x0107), @as(u16, assembler.image[0x0105]) | @as(u16, assembler.image[0x0106]) << 8); }