From d14b6976e24c133a2fc2a74e96d08d12a17f54da Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 03:38:02 -0700 Subject: [PATCH] feat: extend dot matrix deck --- .gitattributes | 8 + .github/workflows/ci.yml | 69 ++ .gitignore | 7 +- CONTRIBUTING.md | 70 ++ LICENSE | 21 + NOTICE | 15 + README.md | 214 +++++ ROADMAP.md | 76 ++ build.zig | 49 +- build.zig.zon | 3 + fixtures/asm/smoke.asm | 157 ++++ fixtures/blargg/01-special.gb | Bin 0 -> 32768 bytes fixtures/blargg/02-interrupts.gb | Bin 0 -> 32768 bytes fixtures/blargg/03-op sp,hl.gb | Bin 0 -> 32768 bytes fixtures/blargg/04-op r,imm.gb | Bin 0 -> 32768 bytes fixtures/blargg/05-op rp.gb | Bin 0 -> 32768 bytes fixtures/blargg/06-ld r,r.gb | Bin 0 -> 32768 bytes fixtures/blargg/07-jr,jp,call,ret,rst.gb | Bin 0 -> 32768 bytes fixtures/blargg/08-misc instrs.gb | Bin 0 -> 32768 bytes fixtures/blargg/09-op r,r.gb | Bin 0 -> 32768 bytes fixtures/blargg/10-bit ops.gb | Bin 0 -> 32768 bytes fixtures/blargg/11-op a,(hl).gb | Bin 0 -> 32768 bytes fixtures/roms/smoke.gb | Bin 0 -> 509 bytes src/bus.zig | 65 +- src/cartridge.zig | 143 +++ src/cpu.zig | 118 ++- src/disasm.zig | 98 +- src/emulator.zig | 2 +- src/headless.zig | 99 +- src/integration.zig | 101 ++ src/main.zig | 195 +++- src/ppu.zig | 22 + src/sdl2.zig | 98 ++ tools/gbasm.zig | 1096 +++++++++++++++++++++- 34 files changed, 2673 insertions(+), 53 deletions(-) create mode 100644 .gitattributes create mode 100644 .github/workflows/ci.yml create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 ROADMAP.md create mode 100644 fixtures/asm/smoke.asm create mode 100644 fixtures/blargg/01-special.gb create mode 100644 fixtures/blargg/02-interrupts.gb create mode 100644 fixtures/blargg/03-op sp,hl.gb create mode 100644 fixtures/blargg/04-op r,imm.gb create mode 100644 fixtures/blargg/05-op rp.gb create mode 100644 fixtures/blargg/06-ld r,r.gb create mode 100644 fixtures/blargg/07-jr,jp,call,ret,rst.gb create mode 100644 fixtures/blargg/08-misc instrs.gb create mode 100644 fixtures/blargg/09-op r,r.gb create mode 100644 fixtures/blargg/10-bit ops.gb create mode 100644 fixtures/blargg/11-op a,(hl).gb create mode 100644 fixtures/roms/smoke.gb create mode 100644 src/integration.zig create mode 100644 src/sdl2.zig diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..010ff23 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Normalise line endings so that regenerated fixtures diff cleanly on +# every platform. Binary images must never be touched by line-ending rules. +* text=auto eol=lf + +*.gb binary +*.ppm binary +*.png binary +*.sav binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0a05dff --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,69 @@ +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@v1 + 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@v1 + 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 + + windowed: + name: Windowed frontend (ubuntu) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: mlugg/setup-zig@v1 + 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..759d3b5 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,11 @@ zig-out/ *.dylib *.exe .DS_Store -*.gb *.sav *.swp + +# Generated ROM images are ignored by default, but the bundled test +# fixtures are committed so that CI can run the ROM suites. +*.gb +!fixtures/roms/*.gb +!fixtures/blargg/*.gb diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..af1f5aa --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,70 @@ +# Contributing + +Thank you for your interest in Dot Matrix Deck. + +## Setup + +Install Zig 0.16.0 or newer. +Install SDL2 for the windowed frontend. + +## Build and test + +Run the test suite. + +``` +zig build test +``` + +Run the blargg CPU instruction suite. + +``` +zig build test-blargg +``` + +Build the headless tools. + +``` +zig build -Dsdl2=off +``` + +Format the code. + +``` +zig fmt . +``` + +## Code style + +Follow the existing style in the repository. +Run `zig fmt --check .` before you finish. +Keep each module small and single-purpose. +Add a test for every new behaviour. + +## Adding tests + +Use the bundled smoke ROM for end-to-end checks. +Regenerate its binary after you change its assembly source. + +``` +zig build fixtures +``` + +Verify the committed ROM still matches its source. + +``` +git diff --exit-code -- fixtures +``` + +Update the golden frame checksum when rendering changes. +Run the headless runner to read the new value. + +``` +zig build run-headless -- fixtures/roms/smoke.gb --dump-frame out.ppm +``` + +## Submitting changes + +Create a pull request against the main branch. +Make the change small and focused. +Add a clear description of what changed. +Confirm all CI checks pass before you request a review. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..50b893b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Daniel Cuevas + +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..09771b4 --- /dev/null +++ b/NOTICE @@ -0,0 +1,15 @@ +# Notice + +This file lists third-party content distributed with Dot Matrix Deck. + +## blargg CPU instruction tests + +The directory `fixtures/blargg` contains the `cpu_instrs` test ROMs. +Shay Green wrote these ROMs. +The ROMs verify the instruction set of the CPU. +They are used as read-only test fixtures. +See the blargg test collection at: +https://github.com/retrio/gb-test-roms + +The ROMs are included for test purposes only. +Dot Matrix Deck does not claim ownership of these files. diff --git a/README.md b/README.md index 5b102d7..85d2073 100644 --- a/README.md +++ b/README.md @@ -1 +1,215 @@ # Dot Matrix Deck + +``` + ____ __ __ ____ + | _ \ | \/ | | _ \ + | | | | | |\/| | | | | | + | |_| | | | | | | |_| | + |____/ |_| |_| |____/ +``` + +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 draws the screen in an SDL2 window. +A headless mode runs test ROMs without a window. +The headless mode prints a pass or fail verdict from the serial link output. + +## What it provides + +- SM83 CPU with the full instruction set and interrupts +- Memory bus with ROM-only and MBC1 cartridges +- Timer unit with DIV, TIMA, TMA, and TAC registers +- Pixel pipeline for background, window, and sprites +- Serial link output for automated test verdicts +- SDL2 windowed frontend with keyboard controls +- Headless runner with frame dump and checksums +- A bundled SM83 assembler named gbasm + +The bundled test ROM runs the CPU and the memory bus. +It reports "PASS" only when every check succeeds. +The blargg CPU instruction suite runs in the headless mode. +All 11 test ROMs in the suite pass. + +## Architecture + +The code is split into small modules. +Each module owns one part of the machine. +The frontends share one core; only presentation differs. + +``` + rom.gb --> Cartridge --> Bus --> Cpu + | + v (cycles) + Timer and Ppu + | + v + Frame buffer --> window or PPM dump +``` + +| Module | Responsibility | +| --- | --- | +| `cpu.zig` | SM83 core, instructions, flags, interrupts | +| `bus.zig` | Memory map, joypad, serial link, interrupts | +| `cartridge.zig` | ROM image and MBC1 mapper | +| `timer.zig` | DIV, TIMA, TMA, and TAC | +| `ppu.zig` | LCD registers and pixel rendering | +| `serial.zig` | Verdict extraction from serial output | +| `disasm.zig` | Instruction disassembly for trace mode | +| `headless.zig` | Headless runner entry point | +| `main.zig` | SDL2 windowed frontend | +| `sdl2.zig` | Hand-written SDL2 bindings | +| `tools/gbasm.zig` | Two-pass assembler for test ROMs | + +## Requirements + +- Zig 0.16.0 or newer +- SDL2 for the windowed frontend only + +The headless runner does not need SDL2. +On Windows, pass `-Dsdl2=off` when SDL2 is absent. + +## Build + +Build the headless runner and gbasm. + +``` +zig build -Dsdl2=off +``` + +Build the windowed frontend. + +``` +zig build +``` + +## Run + +Run a ROM in the SDL2 window. + +``` +zig build run -- path/to/rom.gb +``` + +Keys: arrows move the D-pad. Z is A, X is B. +Enter is Start, Backspace is Select, and Esc quits. + +Run a ROM without a window. + +``` +zig build run-headless -- path/to/rom.gb +``` + +Show the cartridge header. + +``` +zig build run-headless -- path/to/rom.gb --info +``` + +Run the bundled smoke ROM. + +``` +zig build run-headless -- fixtures/roms/smoke.gb +``` + +Print a pixel checksum for a fixed number of frames. + +``` +zig build run-headless -- fixtures/roms/smoke.gb --dump-frame out.ppm --frames 3 +``` + +## Sample output + +Cartridge header of the bundled smoke ROM. + +``` +Title: SMOKE TEST +Mapper: ROM only +ROM: 32 KiB +RAM: 0 KiB +CGB: no +SGB: no +Header: valid checksum +Global: $0000 +``` + +Headless run of the bundled smoke ROM. + +``` +Serial output: +PASS + +Verdict: pass +``` + +Deterministic frame checksum after three frames. + +``` +Frame checksum: 5CCE8DC5 +``` + +Headless run of the blargg CPU suite. + +``` +11/11 ROMs passed +``` + +## Test + +Run the unit and integration tests. + +``` +zig build test +``` + +Run the blargg CPU instruction suite. + +``` +zig build test-blargg +``` + +Regenerate the bundled ROMs from their assembly sources. + +``` +zig build fixtures +``` + +Check code formatting. + +``` +zig fmt --check . +``` + +The test suite is deterministic. +The smoke ROM must rebuild byte for byte from its source. +The rendered frame must produce a fixed checksum. +CI runs every check on Linux and Windows. + +## Test status + +- `zig build test`: 52 tests pass +- `zig build test-blargg`: 11 of 11 ROMs pass +- Formatting: clean + +## Limitations + +- The APU is not implemented, so there is no sound +- OAM DMA is not implemented +- Only ROM-only and MBC1 cartridges are supported +- The joypad interrupt is not implemented +- Battery save is not supported +- The boot ROM is not emulated +- Registers start in the state left by the boot ROM + +## Roadmap + +See [ROADMAP.md](ROADMAP.md) for the completed and planned work. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution guide. + +## License + +The project is licensed under the MIT License. +See [LICENSE](LICENSE) for details. +Third-party test ROMs are listed in [NOTICE](NOTICE). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..8a2a55b --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,76 @@ +# 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 behaviour +- 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, window, and sprite rendering +- Sprite 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 +- Frame dump to PPM with checksums +- 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..d59fafd 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,16 +84,26 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), }); - exe.root_module.linkSystemLibrary("SDL2", .{}); if (target.result.os.tag == .windows) { + // Link the SDL2.dll import library when an SDL2 prefix is + // known. The msys2 static archive expects the mingw CRT, + // which Zig's UCRT build cannot satisfy. The DLL keeps the + // windowed frontend self-contained next to the executable. + 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", .{}); } if (sdl_prefix) |prefix| { exe.root_module.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ prefix, "include" }) }); exe.root_module.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ prefix, "lib" }) }); const dll_path = b.pathJoin(&.{ prefix, "bin", "SDL2.dll" }); if (sdl2PrefixFound(b, prefix)) { - const install_dll = b.addInstallBinFile(.{ .cwd_relative = dll_path }, "bin/SDL2.dll"); + const install_dll = b.addInstallBinFile(.{ .cwd_relative = dll_path }, "SDL2.dll"); b.getInstallStep().dependOn(&install_dll.step); } } @@ -131,6 +141,23 @@ pub fn build(b: *std.Build) void { const run_asm_tests = b.addRunArtifact(asm_tests); test_step.dependOn(&run_asm_tests.step); + 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 = b.createModule(.{ + .root_source_file = b.path("tools/gbasm.zig"), + .target = target, + .optimize = optimize, + }) }, + }, + }), + }); + const run_integration_tests = b.addRunArtifact(integration_tests); + test_step.dependOn(&run_integration_tests.step); + const fixtures_step = b.step("fixtures", "Regenerate bundled test ROMs from their assembly sources"); { const run = b.addRunArtifact(gbasm); diff --git a/build.zig.zon b/build.zig.zon index 530eafc..0888578 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -10,9 +10,12 @@ "tools", "fixtures", "README.md", + "ROADMAP.md", + "CONTRIBUTING.md", "LICENSE", "NOTICE", ".github", ".gitignore", + ".gitattributes", }, } diff --git a/fixtures/asm/smoke.asm b/fixtures/asm/smoke.asm new file mode 100644 index 0000000..8084454 --- /dev/null +++ b/fixtures/asm/smoke.asm @@ -0,0 +1,157 @@ +; smoke.asm - a bundled smoke test for the Dot Matrix Deck emulator. +; +; 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 ; entry point + 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 $00 ; destination: Japan + db $33 ; old licensee: new style + db $00 ; mask ROM version + db $d5 ; header checksum + db $00, $00 ; global checksum + +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/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 / 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 0000000000000000000000000000000000000000..ad3e9984f967b77b7ffdf768842ce3c04517d059 GIT binary patch literal 32768 zcmeI)+iw(A7y$5Zx80$Z?a~4dM`&TnrIv<M@$w=uCK3`2FB)GYyojYQGo9TnBOD?LkTIcgH`+#5Y&z&DBJTQ~SxVyN5AgC$ z=FIFj=klHNn@isc@IT+|W#Mz*8^+l>SX?y#V-STpsA*XJ+UpRze71LXdiska6BGOP z^v157oxXhJ{fY6rv_FZBU>pjr8QZ`6Q{6v4+|~eQtiEk&qx|dgI{D|M4Q4ZR*bx55 z&zvSm0wh2JBtQZrKmsH{0wh2JBtQZrKmsH{0wh2JBtQZrKmsH{0wh2JBtQZrKmsH{ z0wh2JBtQZrKmsH{0wh2JBtQZrKmsH{0wh2JBtQZrKmsH{0wh2J|1AMqp1c%MZmd?| zT}pr&#L%`X4^^m7ti5y zCSPXpCX27N_$kxz`37sD5B~JBzQxO?<@iZ9;}5Zde}GN<2iX^Xie2=F*?B*k#AZVl zoAR>{Ji@N}+0O0k3g2b=XYS>9S>EiDQf)k(Ta;7mkn3kz)X(CD#j(BP7oM_Wwfre7 zr{H98CDNwq!^(}(t|W#-PGQDhySA-Z?G6_sxIhWlGQ*#*f!Q%Qf?3pUPEsm&skr9=F1Tw-yRWvJlB@(WNuEg@IFD_~FOp-tK+7 zU+NjGD10`ls%j}bId0i!C)tmcR#n)lzi*&tp%n`)Gz#i#s~_!@cRaZ9p+}+6Ty1Oc z?H{UZB0IWv$Xm8TAp}dao_~I5c76gAR7Yhm=pT_fw`_qz1HeV^4j-Kg!+2OOdk1s% zPs`th^miIwev$uT*>76e(5hdnk|$cF`OeZ?vTr%OK64%1+4{_J8z0DHb~7A`0sjWi zZ^X)Y)|MAnB6YFR)vgtrT)s2Io@|D9rRx0~)I1g%H=eOAhGQp*We23>8 z$9HdJb_6FQ-oDnR8NYd@+g4g6-{zI%)>^(b<9na9-`%^vP#K7iWpKiH#8I*{*PGm2 z4X#0xn^(Bf@;f8)4Q(Z^u7K|yX2oXM;?isR2R!GZXx!J*vSCBRO1EjnnuD0*TH*Yy z4-69v)`;Tp19W)FTnCQ>a7>kNDq@ye<;q_>vnn+`= zw4Rbgtm=A7v`>p>ih}J8v5T`NMdKo`*l(T{5Lpx^=8EZY$KRr7B@eC_^FQ0GYrt@T zv|&KXFvO16bzR4ClL^Bx5=qRjr*s|X6`Xa@biEf-0@h+T15`16rh+6k?1z2&u`e!F zY-z~Nl`iFSIWPv(Y3wNGi<=tc!v;Y-n7Aj1F{Q|_ig6H8Y$6YCK|VuO8Q@r#N^>}# zp^B-CdNGBH3+7uDbXx@-V)arOl0lq=`gkID>plRKOkKGds*;A|2fVm`5YoC>j~3*` z*hpjCsHBix^ug>A`q7 zpooPB#^S-l`zQor7>hLGzru)a;szr?N<6CpK;=5l2XAul{KObjM86DisA72iicAJRtiVPC5ef0$kIf%WY58UCBiFHDI~ zGb`PCbAn;$KmsH{0wh2JBtQZrKmsH{0wh2JBtQZrKmsH{0wh2JBtQZrKmsH{0wh2J tBtQZrKmsH{0wh2JBtQZrKmsH{0wh2JBtQZrKmsH{0wh2JB=Bz&_#K5wPf-8> literal 0 HcmV?d00001 diff --git a/fixtures/blargg/02-interrupts.gb b/fixtures/blargg/02-interrupts.gb new file mode 100644 index 0000000000000000000000000000000000000000..20895940ad31ca0229cb4ff8bb7c7ef79ca8db10 GIT binary patch literal 32768 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 literal 0 HcmV?d00001 diff --git a/fixtures/blargg/04-op r,imm.gb b/fixtures/blargg/04-op r,imm.gb new file mode 100644 index 0000000000000000000000000000000000000000..58ca7b8ac3c2b16efc9c8e401e790671c00022a2 GIT binary patch literal 32768 zcmeI4ZEO?g9l-x~k|Pbqu|w1yAuneVtPr4hD}zWgk1{}@y`p91iI-s1C}m)=&Ci}PS^)m|6_7tDiNZ`%vs1o!oe z{kL!2xc1Vyb332yci*{q~E)78){-D{jD zeGra=>Vuv*I6wB8Q-VZ*2oM1xKm>>Y5g-CYfCvx)B0vO)01+SpM1Tko0U|&IhyW2F z0z`la5CI}U1c(3;AOb{y2oM1xKm>>Y5g-CYfCvx)B0vO)01+SpM1Tko0U|&IhyW2F z0z`la5P|t3>m;#d?jCJ;y z(NpT*EU`-esoHf)x?9lnDOH~z(_3PCdrUv8YL>nv_Rt1z3kfTxm(`eNg~hCOK+ISN z#T(Wk@tPG8-?WCrD^?1k^xKxD-l*yzZjSq=?kNHK+9yU_V%t^wK0^d!2$)W zC96Ld6P;zq=)J0jxn}io^-Is}-MeEuWEjMs8!uKH!OogBoj1jLKDU0Lf5*d_8e^a+ z7(1hG%Jj_o)`E(%V#KuN$4O6#bXy?OM>2rSBUr#FwTmp7AScq2w?mH!p0%GrK}Nw%0)h zkvx>7cI2|&euXTWYc*Ih*JfzAs*0^xdYiG+J6sl^>G-cVwl^OteOR31&o=GqtPeOXaO2HrJ}~XrpNYEHs`Z3v(ffb)Do!P z+_fG^n0+B z5|)BxFz2h%{Rg@U+jqG#k@o{&g|{*V?UW?oZv3NIRXH*&=H{Lau8qe>PJ04Q^gX@R zU)#nvetKcR^RN4UTbS?sYkNEPbnNcfzf5`~-m4DehkrHTzm^|4-eC^sNAWt6AH(Zt z6i*cWujBfb^ZKjfm2Cr%@!d;w<)$aQa(A&8C$o51+?!-uax;^-Snf~aYSfn|^^@bB z3o0fK$P}k1^_yxbEeXZD{4O(8S}L85l#9$_8(ElkdIBLL52QCyMV)>fbq^iM0=vqSBu;_VBs}j(Shq;o*Gw z@Zp+M-x&S=YewCKwf@_yyY6kMDgJiFvDoHKU4Obf)p$0ycGVk4JAV=A{mHY*l}~-S z_D9V>aDQ%k@6{uz>&dF$uk$4wt@oEBRy6qNu722&r(+5wdJ4g-;aBZTj|*pCX83~lE@ZY zjASj=z$9OjAvAufMw(#@vAPWI=!!CGOuS&OEogXTwip9`|B@x%g+@!$qC*&CI&br zlwdii;FKsv(P5Li#x2(j$dNKZ2`(*|q zI*2L?L==Vp@VP9@=r~cUaTKDX_?o>Y5g-CYfCvx)B0vO)01+SpM1Tko0U|&IhyW2F0{;a9e+RrWAo2hJ literal 0 HcmV?d00001 diff --git a/fixtures/blargg/05-op rp.gb b/fixtures/blargg/05-op rp.gb new file mode 100644 index 0000000000000000000000000000000000000000..1c19d922f728bd11dcd8b289813fd0409f438157 GIT binary patch literal 32768 zcmeI4Uu;v?8Nfd~$t48iI3#12{2@0ucp<>J9fC!gIm##kRUOS<)=E`rZ#B5I!%6~Z zV2I;7$;7&8539O|@lu8+wVJfSkhbwpo33MQb7w9pT|pPENv{WHnnfeK)m?)L_P%o+ z*w$`&m^7){z9XM|&UeoD=X}3=&bfIC!2fwkG`p{UpsH7v!jhVUFaaJ|3iVB$uYM1_ z?_C+ZGc|Sd_{EERzd7i=du8gq<0mej-^e~eEQYen8B;yq_{7|2Ufj_Hn%CF6tX;a_ zvQ+vwoB*v0`V-*(%&(jlNCb!g5g-CYfCvx)B0vO)01+SpM1Tko0U|&IhyW2F0z`la z5CI}U1c(3;AOb{y2oM1xKm>>Y5g-CYfCvx)B0vO)01+SpM1Tko0U|&IhyW2F0z`la z5CI}U1c(3;_}?OskS1@`$#b1DTy?#D8T7N4ZT7xVqZPMEe|3b4u}ov{R!54u9Wi{` zD3|n4bI?U#6)R?qS>x7(ZYQtjx+W^y*FUxX>Gh`q^_jlRicD*#?0X?R^TCcIGv@}5 z&P+TrG9#^)ZU-u>4y@b1Za8}_UerhPZZndvF~wS@i*A<4yG-3KH(YvFGh8Mvfl2di zgVSb>wDuQUtTEowde2Jtin=kQ8O?E{BX0QP#sy8cjWzLyHrOpD?YL3V;<_CcbM_H2 zYabP->=E&%9TBhFqvAC?8Ai1uaWQQtw>~L;XeYPr6z>|(YIf$!#>RN@PGh;D&z)M7 zk`pe|PKq8oi33Z#yU)M7EADMDcEwXNE(S9ZHq^c*&y5X)Q9Pn&bGF~#o3AxT^L3b@ zfVt$1eR0uUfvmAp({ZgiV^aJ2iw6(x*$r6+iG7o$S~J*Fx25Nn*vRL$4Gr#jI8$d1 zl>~Ed^lZ;!v_uH_MD?=kDmSzXu?==%4C^@7i48pQNA(uIzC!Gxw5%|8L0VcEKQGl6 zCeBGUi2=A{nHwHAD)fk-`bM}qFcZ%`zOHMB6$uxLq_cp~U0<@m`aAY6_fMY8{+@er{j?R>Vg;<#RyQkR&|_B1r|{{?0ihnvd>FQg&7wOc(IAmY21y*@|1CA zvS&rr#0A;X?6h%9D`zC3bdUG4qvhq&g-AIi{W@_Xp0gKPL3VVZ+3-S>M;4kqy3pjv zLX+42b5nf@KpVhXfEs{i06)NDfE5610K8zVO?iF%Vev-+A9~lW)bswQ{W)y$5MEC{ z<=^h-1I1`IflwQhcn~39mp5Dpj!)d=(Tc^Wwjo)lP2y z>F+*wZtaQJQ+ zn+6Z#QR7y|?KCbJpXeo-D|MKuddz`IU9G0j@})XyjHQFkg)StwDu>hz_Ev3K3Q1KO9#S1^tFYs;Y*<7+;Af3a-m(6<~@oh#>)I z(M*LPU%nUwffuwxn_;xYRC$R)YNmW6l}dqnC>ljWzFsUrCmt#|@}baXN5+smevtb? zh`jJPSe$rjFen20x>24%e`=5~ozL?nRJmZhm5$j;=;zrBZU{Sa7<_%9)T8$RP}UTA zUJMEh{X2Luf8eAQo)2^4qO3+yZkHp7o!j6z6Oakw!uTTeVFY}SNFU2E!VB^I`y4Td z`gb`1Y^(a*AJ4xkM-;~&I^;6vfimBC_#Qa{FUmX&|F=urjK5$65aD~(0Z<*s_25f( z_K#N-Wp3w$M=ryn=)q;7p7P-v)vb$t0OM+k|Ft1+T0b-_9T;xle}34Oi;Y=so7MmQ zTb4MMHl9x#8#8!}17i8nxpAD(g9s1-B0vO)01+SpM1Tko0U|&IhyW2F0z`la5CI}U z1c(3;AOb{y2oM1xKm>>Y5g-CYfCvx)B0vO)01+SpM1Tko0U|&IhyW2F0z`la5CJ0a HUn1}~7IqB+ literal 0 HcmV?d00001 diff --git a/fixtures/blargg/06-ld r,r.gb b/fixtures/blargg/06-ld r,r.gb new file mode 100644 index 0000000000000000000000000000000000000000..d497bfd1275361bc847fa94dc87b43729f180b5d GIT binary patch literal 32768 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5c8d20bb3a1a0458f29afe40b8d3ca125b13a391 GIT binary patch literal 32768 zcmeI4Z){W76~K?3#H7JEf3-bALS7(vAxefRTZy#StF%K!)0(C02P#!X7!@8ZwUQ7J zIK=jxWa_$U8m%_9>pqlK>)0P{FeYsjvJZ9~+j%pOkq+1ck>t4$P%WC-pf?f&_Rf6{ ztm~$Hm^2Ah=jq*h-?{hvx##!pz0aR4DPUgG|EVo_e~9Zywe`%$vV3|OEG^m#!(fMH zP+HmW+IPWm`E>WS$;pdvkBsbkvDpE*sC2oM1xKm>>Y5g-CYfCvx)B0vO)01+SpM1Tko z0U|&IhyW2F0z`la5CI}U1c(3;AOb{y2oM1xKm>>Y5g-CYfCvx)B0vO)01+SpM1Tko z0U|&IhyW2F0z`la5CJ0a*&+}TM=zAP?l!pKjOFY&C@01ZHmw$gGLMV@FonEY6=LTK zJsHLDsTCnVmlpmn;FB~I0a#uS>Vx`_KCBqg^D~XZ`KQ{x(YB@SsIxTDo>-o!ZE#g= z^Ui*}xqtTku7k6~&kf9qYs9P0{HngzJ*~aTiEu{or>vSURip{UOcAUslCo%ukt-W} zKBQVSoB|UU+A_0Es`1cAhESybBGht1oX;rgY)GvRtMy@Zbyz(WQVex%_`VH3W}-$| z&4=EBKfCbo6OZ@BIbc6m|kSCTWv z>eVf&V$Gi_!31rX%Z&O;Sg__Hsdk1Goa>A_8hU2?-n~0_K$1b^mCknMDrtPi4d?x^_=AlH#E}0bw+v+%Mg~~bv*G0uk(1#T%yy~wxOiK+wLU+c0*!&b1WP%VJQgPlV#(`k z*a5w%@lZnjVMq;x)$WAa6*5?n+7M#qx>5N`QGYXL=7OFKb^mB4awK8!8NVCKJ8ei%3$6GwMT!qd3D`D z?%Sf_MAw!Cw%DDAvv5$72syrM-4D?E4Qlgaa*<&5c{L!-~5(Q-7fidltK>J_G+ zyk5R5$`SNMor)O!a&*yfEiv*W|R}64R_;Tg`&cdVK4{x ztZ{8TK2pjT;aJymTb-p1eB&opxGMgA-*5Bsoqus}Q(x1brUR|w2a(QDPdfP99_PjM z;L#>Mm>$Ab%7I&tYFLiec z7t6gVT#f3pG4=Rp^YVg;1(MmhDfLPymk@2)dH$62=bjZ$`EoJw50S&+8Dp^-B>jtx zdKVk)Uu^K;VuJ&V4Nm;;rix&Za7{N`B$q{Mw@9P@Si~Ruvp@EMKX%C<8}r9b`(vs< zcFrHW?2m;c7?(hi;H(6i1Q#WU1N;TxZvfK(*8%e<*9WI7?@XPK%uPwRO0ARYd#BYyV_EAYuZzq&VD|XU+Go}SW(|`) zS*-%_yt(_W)Rt|JHpeHvCYRg}mY@2;6Cb|!leV_+_x-$j|1WpsUiuS%e&)@l zFDzD~mX&vkyvLhu=}o!UMI)@JSK{W`GBy;)Z_K`UBMdn}9l`c{jy$%kCba@j&s3ep z=2G>Vx^`T6p%H#;E5-$Xx2Z&&whBCVUS{9I0{^{~h?C5c>cYbpgP^~zyi(ac zZgWR%rM1<+TD1y2g-b;II;!j9>O)i*~v$IY%vy(6O>!_R5(S`a70Kd8<1 zGe+YIttGcad?b?-o6FSY<3{F>ZC~y_fSa3JsawWz!dRo1=uEa=i1(J^^Hp;Bv=ag}TS;+I!@$fZc94^&E9*SLcAg#qfCJGq)_n!F)0P?rt{&0UZQn8GN$LZ|rxw z-RRfrk!9KA#rSTY+l})wTQ@MbyBk9Sj-r_i5}&>xfsJ2ihc>-vi>dM}0I}KJg;*>G z^8P>o4f%ZWlFWFhV9NVFkC`%th_mC(Vnw^_k!%3i2w_rxB&2Y_72?OG5e8$8;11Q(Ie2AUf zU_Y}V6U2q_1!%_z_#TlyUcCs>Y5g-CYfCvx)B0vO)01+SpM1Tko0U|&Ih`?u;z&`;#5pP@o literal 0 HcmV?d00001 diff --git a/fixtures/blargg/08-misc instrs.gb b/fixtures/blargg/08-misc instrs.gb new file mode 100644 index 0000000000000000000000000000000000000000..4da139be35c0f82db12136c2b65b2571122a026b GIT binary patch literal 32768 zcmeI4Uu+b|8Ng@H_Hr@D*=M`WVr;y|=432fjs{X!Jw{H7f~wSYRjD9F&@2ipg%dXINMc{<|Q~$l+srDqezLWHc%c~{`Fzk=R13KEP^(mMZ)%8z*IdE>khPyeZXzM z*#p{=$`5^tns2o`GvCbjXTIOg%-+)x@)f`0)voiO$?~}+WN}F!873aGgt&bTul|B~ zub%6^F*$kV=-IQoUg-ATJU4mu=(}f6KS3WM76X}c`edp0k+CnmxXDKpZ)Iy$op`5a ziTHUqPLxK{5ht!M{mgNKNB{{S0VIF~kN^@u0!RP}AOR$R1dsp{Kmter2_OL^fCP{L z5Ghhl-ykD0) zby^=+{%#2+`bSFZNpU`>>9dMnt?Kov-lXbh6wT6CsSj+RnM+uzZY!#0g@qZbUr1XA zg-Pp>aK(xUm#hKdqLm1PTE8lcTZyNi6>eFHjh(_x{W---eM^5r&E2Rdl()N*i;{sj zXIKfrV8Ej?p3HMM3+je$%VB*;T9 zGy03F;Ic_t?^HCH>x@3C{BV0;-_9K*O-cO4(R`^9Y$@B&a!sgUV;g(AcRm;?GkWs8 zu`7DAUE)Jg%Ki6)rwWl-0pu}7RPX#q$Z%Tht(W9!q zIi+_g7A?^m6zZ%Sovjr8_hU{j;K|bWkLKd9q%1b$_my0MGej>-?3wMmLpyhrlQbZC zAc;-DWux;7X)3eapvg>wp~0%kFNf3{j9tE%&6C<=f7sMicc}1l(Ha1y2^!)A@+C$A+yz1ACO z{nV4%+cK|e$4&nR({HXcn`sV$zHHWf0iTxW;@x0Y31NM74%|s8U%*A*1N~Q+KF4-w zH6smLRTlj|YnF+IswKPuCr}=mT|CM9-*2g~Xa&loXE)i+Y)7gK5L@k=2ZL*Bc*W*38^gYi1sv{L~B_7I&uTQ0DFwES7syup0GsN&UoV%hI9= zN7DJZDgByKNQr!Yp0&~gg>~YYNFgcyG5)SPV=Xj;^uR)+;e`hK7aBac(BPqk1~2~a zrdoD{m?PvCA)gYGAF(qdwtK|pnmAt**Vx3ZZQ?9K5`wqJJ;x ztz?DCiYycwEo6?^^eT9vXEJZtv^o=;rdu+Hr|GXV(P=uJ8JOm)65PmU-=BZgn4QYG z$>iGTbbM~g?V4QMJFQnF@~%n!^|5@3Qy#Hdd&DWpAR{QKuYmN;27#=O%Z} zYn}Gj-hSz$^*bM}KhFQ;sSDwL^|*X=-IDa%M|&>xY>PL1@Y-nZH@DUJ)v(fbjeC8p zV&hvY_I!JL;&(s3Grjsp32ynFlJJQZKD)7SM6^^*PmMcg$k?87_?B!PZzMy2;RC2& z^V(3;n#?jdB{Ovfoy*i4x^F_*+DLxMm%_rn+gv8ju~Xxxd?lPOyXj$g$bWyz#7W9! zy5Nw7AmFdd)_j{McqzfVYpZ{|Vg-0o%S8AJ>W`1p5wipQe3xD~H^tXaI9)!oBc~mQ z&t~jK3?{=qoGtY;R^u|GwXjHhELRX);Ilbl<^I&RzWV@dVrHe&379aH;3YAWuQ!rz z$bpKDwFY1F%`)*WT?W~u;ixDogU01o&8Kr3oW=D6ZcC=_yjE`$mpxCs%c6yzfUkwZd2O1@0$o~&!{k-tMc9*?8vtpmF{FX?TEy1kbW zIcNuE7!rgiI4oX)fCP=`eTfiBA}-zo4m(It?%prUK_W1FAqp}Zh?1yB@bJ*@sSAdp z5GyK0cqRuWDZ=!lOf3)qeVN^?ro5+}#bx*XIzU*IfWYPo;o*ihvn zRdz*5a<*_enM@LSe>4h)Y`$;>op_+&$ooT2Ix>W0@q^3{Kx7w-1D6v|4h97RzAhJL z!JiytQ)lCB3PmmuZ@FW(oOH14c^3&ga+p-MhmsH910e-P3fvciJO%#_UdW#?(h|#u zI&ndkqafD>B7mLQKtJVy3E+bG0%?Z`@E(CaT)hA<#PV-<#31P3WB{Np>N9^V|Dqg` z9Dm@D$&3fcY~jIsWCXk*voP!rotPPW!2lq_)~W-b*bno8m+Y(`uOI}Nof96o^a_Fp zrUiP^t#^t`7utmAE#vHWOrX8(rQLlmh`YP{ezvc#oc+A9OpXqkE{nSV^{tGX&4kQ@LIC1xrOriQJM0eU4D2= zFwKvo|N5DW2?_#&fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_` z0)l`bAP5Kof`A|(2nYg#fFK|U2m*qDARq_`0)l`bAP5Kof`A|(2nYg#fFK|U2m*qD zARq_`0)l`b@V`aCFSi{{wOuH-q1*IPC)9nNhVnZt*&CfI|0yBFwOp@qc<4%0HQZXZ zw}DIQKXXteC~>_}Z_=Cf7S#wG=`CrA&#L@Z<-E$B#2qy*I;pCu{qNq}m;t!jGsu4>+xWKEKf;0t1 zzSyQG+K|@a{evMTYumimeeyt5)y{ggkv^@+r8x___Vm!ry4G) z*H|HijK`$o#!Bg!;g*gV4N{L0aFN;ypVVOlX51`&ZUkn|kxpw1yau~YyT%vo%ZjB} zC$$f2xA{#$BOqlM0lF~4a^L>Lclj*o+FibO8%>5XF;7f+&UT@x#zo=^HPmaAmRiFp z!G>@uC1|ExdbJ0AQc@ftZH`x^x%O&p-dpdfufP93gcSH6Y#U4o+AC70R-BTu_}I+4 zCHH?hk{YZVGzS-X_EiigOM*aWQjeLsxuFqhoM=Rvs5Vn=naC5rR8P_Ei!`>%xsj#= za%QA?znm6nc~4IE*Lby{&%fVirEEvIcFTWO&rOQ|E{vc8eHhg&s&T62N+Z>{K2CX5 zFDseVt-avYJU(qww^rjdlw_^gt0dNq##f5=#=%4`Nfa{w|Jv5Q&6_> z#Ae;Ixc2`0(h(vgUrKT*ahaL8h01TjV}eRYxHzcNsu~E&K1E`-Lw4mrSB z)|DTNH0>yY>QH~D&v z%grFvaJf;}wxvZ zOTf#(2H+>aE5IgTGq45tDex+=75Evj4R{^c4(tGa4!i-p3G4z=LBD6g6+jU%4yXjC z0x6)swqO)c2$TU606Q=Y7!70r#Q>wj1eOGr43+|x3N{QZ4Qx1AI@k!X46sbFEU;{_ z9I#xlkzk|1t^gYiCV}OF<%1P~6@nFk6@$rOC17L0O2Nv&#(`Z4Rsl91%nEiD*aWaj zu!&$cut{K5VAp_M3-(p8>%gkPt_S-X*c323*i^6^V1c&2!xEvBTGH7utCvoJ-hg$s zwZ=N%y6^};g`$31z*&Kiue2f7BkQ3=b6ABbE4%pd#z_29$36G`)B_!F^nCxJ=5J=1 zt%1RH8(#5OZFa%8X|cDbZOh!u8#;$Ktb4xOX`A=rIn$Oq)_v!ec^5W6HKTu}wa=YA z?IuUy^?e_{_1Wgf3Tn=eedikU)V&?{tw&0IA1q&^Z&=@IS$?dqdD}^+=BoMqGlwgc z)py?BF(5D8|N19gcX$4F^^af8T;*7E;HoR9uWs3$K7HfS^=tF?jch;iVN3V-H{sf0 zcSl0`5B%uBliN1WGXHG(K+D&2KPyP?Jij8m0kzvR?`HF-7u=k&CoOpD#$llsuB|Us zrHR!8JyU+Z;KTO* z${ibDKC$(9$({|9H~qS^>g$#Zy>tDyE#G=(yS~x2t?0c!x{p3GM7-u;g{s6%33HzkAD`{Da=zC->a+t>@C$?i@X7?yCihmj#pmFn8^$ zD<|LE_hfF*=T+>W(ozRtR!?TgB-SI&2ze*e&-ypmamwWDnA;gcKBRb9QKzxC}u zeeF=%eQVkl-tOGB=E^~GDhEi@}12~rKY|a^K!deN6xZ;u;Bb{0n?cC z$*$cM=E%(0v$Ek+wb!~5pT~nYbv5(?ax6`?MuatuiWHKpTK?VNmH07X`J` zUDBN;SZ_|DclL#{RJosj5FapS)2DN>@;Fue>zXPbS4`m=+CNYb@>dgAb0#sfBVbO; zAGv+(Sn}jcm1%>geXc6c>C?#1jmjtbY=1UOT*}d>Mb%xjBW&zzq{;9-VMS4|QBn}J z#)iqmqcOQ6UHiDph`w9-)g_P8_la9+d5k7ZXYvy09V`mAr%?`yT#_F&kE%+QFDM0+ zU5LJdL&~IabhSPss?s*2C|FlsK7M>oL9i%)>`IE!AL_ZZ?Ui_egxcEL#7?+2R!avU zDGs~YEx}nU*&OXZK;h)V>N@8XWMt4gii)L>>BQow1C+nqEZNgi@IRBSc} zbvzd(z~MlWIfEQdgWb905vS7*iQ7valKFrKo(w6&OxH6C?X@0?<#D*pT(&zLZm#d) zYBn3`JNdy|#hg*i|B@P7=Iq*20a66s+pz98Y zgZ#Q`ola-1i{d-n4hPLE(K?_w97`x9Xq3#Hu=D9lc9?k~JF;0ywv;L_9<-m09c^!K zhw~AShYb0Asn`?oNFgCVQad>zQ%D})&ixQXUU(cTiFi)CT>|+!8aqq=oOV8SKF+5w z#D(IGNtlhnG@iXV39f|fLUwg+`=$E;#JmpMMbU0n$bSMabx{sWIg=8Lv|Er9f@f%D4+3q{}=q9B}PcoFWzwQA2tzFs+UE1PqdXCGb*rf+2 z;YFMT0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF r1OY)n5D)|e0YN|z5CjAPK|l}?1Ox#=KoAfF1OY)n5D*0ZO#*)bMsZX? literal 0 HcmV?d00001 diff --git a/fixtures/blargg/10-bit ops.gb b/fixtures/blargg/10-bit ops.gb new file mode 100644 index 0000000000000000000000000000000000000000..8988458ea0eff185d7f3bd78ff85bb7717edccfa GIT binary patch literal 32768 zcmeI4dvp_38oi~z4V`7uv(jXK=+9GPz|xr1ELesUV0gf$D8JyZD~1HQ(r%0 z>KxFJP~yPgDOtxSn`lru^G zR}QiOQb?^-m#I~1wXC_nYE7>WjUGI9@DqbS$cn~1?2Achrua$rwhIM|+jbQ#X{&y+ zv`ri!p2-Sj%o{X!P>J`5Ga#4y^qw+bghz;!WI->vd^(S;1!MM2a40$tr67sd`Gk<5>&2e_WqZUP{;J(# zw7+_n7~v{%C?2P4w=;*T?QZ{2yd<~Z9Xja)j|A#%)Z(axsAWl&)I#b2X_1+ro;=ew zeOe573CRsf>`z=qg_v)^|quv(Dl; zO$__#klm!7WHjZRip9CvSDf;8HS0b#OC6wQN&yP`qMGSB54TnP(5v zrmqj5d>3pldU9e`bQ;_Em~4vw$9+G^&UXH>`I+-F=VmS%B!1y4bQJq5w-sj{^Dp}# zQ?2w@(S4b}n(kE<^o^o?*PuMJPubkiwQT^r=~vx3zRM?ad{?M-oM@#li>oK3WZ$(D zv{*V%&}vlL_bVSZWcTed(Sf()@(Ja%BZ#7*;|d#j%Y*IWp0eP6affSzvsJrQ2Hx^p z&Dw7@x%gI-OKvq;daKDJ|GBA*`G6w;M*`jtI12CqzyiRZ13m~i2Jj)khXD%#ivS-1 zd=&68z+V794mb|*Nx<=dPXSHZf>z=?p904;!%0jB^?1)K&r9dHKVvw$-JX8}G3 zXak%LSPWPKI2Ujp;C#RZfX@RiqFn~K7;p(-DPS33Ip9*jO2B1+Re;riF97}u@YjGZ z0lo~l9Pl@QD*%5B_zK`kz*T^&0oMS&3iuk}8-Qy8YXE--_$J^wz~2LI0Ne=p7U0`} z?*MKB`~%>-fLj2!0@eb)2lzhVHo)zGe+2vx@FT#F0Y3rU0aypP3vf5!9>99QPXRvz z+zaRcbOJU2x&Ze9x&ikC9sra94+0`!6QBZk2=FlA5x_41zXWUs{0i`EKo8(Cz;6J* z1w0Pe0(cTo1@r=*0z3_P2Cxw|g&n1$rg&VBpGYWpOhG2GW(Zp$cs}C*x=*rfWds_@Urwv%HW_IMi)@#+|H9cl-F?Ro;^JG%g_jh+11`X?dEv0+X@QrcfT0JSVZA*70 zZdX}{Z-^nd4ju10PJbWo**XnnlKb?B1pn9UCWQj{G67=ll90FO&~z_{saH9;#LEer4muUR#Pv z*XsKWZ~kq!@tZ=U58fVKYuArCG|#(b++#0a?mRj+-$w$+odVlfsz_LJ8}Glb-SyS1#Vrxs}c4Ovkm@H z!Ih%sl$Fo@;T@O`RkS7MP`}(5qFze$CDVb&m*SBw`%*p1aTK0OhqZ=C z`i;7l86{q32Y_b{@pM#}DJ`W2|Gq_uEt1YxMCT<6LjH`LJ zKyQd8hxuT(&RJ-+nn7UpLIufeq5>*ng;)bUk4-V#DkxTkrOd!&v&B-z^edQ}$wc~A z_F=tbh|Obh+2?w10v5$2uv{rTeQ3-qyW&v26o1wniv$8WsIXe0%xYyHdTy~;$gkaI zwOVaZvn|-nL{CgX35M7W|qES1_Qguj%-TEmP%z;1vIt=k2W?of^}g< z1sSq@=`x4okwRErX!~hcrjRVYnfW1z>|$}~3dggW%>t0Gqro=vXEn3b**r_3iwni; z7dGn$`K)?_9_(S+4)J-m#+&Z}An34|u8U@aME=8gseWLjEvz0X9G7Hk1<5I@i-maso^$YjQYWVZ0= zJu(7Dl35t`zuA}>d%*;tjIGr$fUf715545@`Y{TEiP?q26PFS}h^4eh&vJ2n*V3gi zfYN@D{Yi4SY4DVp3&i4*`7!LT6iwAh%T&E4MgQ%{>v|8BYE&j1qK%v-1aIz~up55k z1ULasfD_;ZH~~(86W|0m0ZxDu-~>1UPJk2O1ULasfD_;ZH~~(86W|0m0ZxDu-~>1U lPJk2O1ULasfD_;ZH~~(86W|0m0ZxDu-~>1UPT)T!@K+QR9VGw& literal 0 HcmV?d00001 diff --git a/fixtures/blargg/11-op a,(hl).gb b/fixtures/blargg/11-op a,(hl).gb new file mode 100644 index 0000000000000000000000000000000000000000..0634b7fdd19bd62c16860ab1bad05760d6ac5965 GIT binary patch literal 32768 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 HpAz^7^EI)n literal 0 HcmV?d00001 diff --git a/fixtures/roms/smoke.gb b/fixtures/roms/smoke.gb new file mode 100644 index 0000000000000000000000000000000000000000..1060e82f5657ab59851a5dccad5af2973f3a585d GIT binary patch literal 509 zcmZQz7~$Y>03)d(5bW#k?Wz#s8XSV6)0p8Z1B2ne|8`8rnE$aJI?8DG-v&f7+5$;N z6CpuH21N!2$%=nMK)&MH)lV;0{aX!WR{uK-5@0xBR#*404#>1)JgxqZ3CK9@f9fdX z*>j8_>8p%_3>UPvYp@6jOZ+&?Z~-Ltk@4(Z#>1x>6~BWRWfFhwG>$R-(>iI#evI)S m$4LgDa{gD0|Lq!IG5xn=01^^!PX;&!2XisFIePkVF#rIX_D-t+ literal 0 HcmV?d00001 diff --git a/src/bus.zig b/src/bus.zig index ea87095..a605d61 100644 --- a/src/bus.zig +++ b/src/bus.zig @@ -9,7 +9,9 @@ pub const Bus = struct { timer: Timer = .{}, wram: [0x2000]u8 = [_]u8{0} ** 0x2000, hram: [0x7f]u8 = [_]u8{0} ** 0x7f, - joypad: u8 = 0xcf, + joypad: u8 = 0x30, + dpad: u8 = 0x0f, + buttons: u8 = 0x0f, sb: u8 = 0, sc: u8 = 0, iflag: u8 = 0xe1, @@ -33,7 +35,11 @@ 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 => blk: { + const select = self.joypad & 0x30; + const low: u8 = (if ((select & 0x10) == 0) self.dpad else 0x0f) & (if ((select & 0x20) == 0) self.buttons else 0x0f); + break :blk 0xc0 | select | low; + }, 0xff01 => self.sb, 0xff02 => self.sc, 0xff0f => self.iflag | 0xe0, @@ -70,7 +76,7 @@ pub const Bus = struct { return; } switch (address) { - 0xff00 => self.joypad = (self.joypad & 0xcf) | (value & 0x30), + 0xff00 => self.joypad = 0xc0 | (value & 0x30), 0xff01 => self.sb = value, 0xff02 => { self.sc = value; @@ -107,4 +113,57 @@ pub const Bus = struct { pub fn serialOutput(self: *const Bus) []const u8 { return self.serial[0..self.serial_len]; } + + // Sets the joypad state. A cleared bit (0) means the button is pressed. + // dpad bits: 0 right, 1 left, 2 up, 3 down. + // button bits: 0 A, 1 B, 2 select, 3 start. + pub fn setJoypad(self: *Bus, dpad: u8, buttons: u8) void { + self.dpad = dpad & 0x0f; + self.buttons = buttons & 0x0f; + } }; + +const std = @import("std"); +const testing = std.testing; + +test "joypad returns D-pad when selected" { + const allocator = testing.allocator; + const rom = [_]u8{0} ** 0x8000; + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + bus.setJoypad(0x0b, 0x0f); + bus.write(0xff00, 0x20); + try testing.expectEqual(@as(u8, 0xeb), bus.read(0xff00)); +} + +test "joypad returns buttons when selected" { + const allocator = testing.allocator; + const rom = [_]u8{0} ** 0x8000; + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + bus.setJoypad(0x0f, 0x07); + bus.write(0xff00, 0x10); + try testing.expectEqual(@as(u8, 0xd7), bus.read(0xff00)); +} + +test "joypad returns all released when nothing selected" { + const allocator = testing.allocator; + const rom = [_]u8{0} ** 0x8000; + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + bus.setJoypad(0x00, 0x00); + bus.write(0xff00, 0x30); + try testing.expectEqual(@as(u8, 0xff), bus.read(0xff00)); +} + +test "serial captures bytes written with transfer bit set" { + const allocator = testing.allocator; + const rom = [_]u8{0} ** 0x8000; + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + bus.write(0xff01, 'A'); + bus.write(0xff02, 0x81); + bus.write(0xff01, 'B'); + bus.write(0xff02, 0x81); + try testing.expectEqualStrings("AB", bus.serialOutput()); +} diff --git a/src/cartridge.zig b/src/cartridge.zig index 6fe8dae..eabef06 100644 --- a/src/cartridge.zig +++ b/src/cartridge.zig @@ -76,3 +76,146 @@ pub const Cartridge = struct { return if (index < self.rom.len) self.rom[index] else 0xff; } }; + +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 cartridge header at $0134. The header checksum is the +// sum of bytes $0134..$014D plus $19, which must wrap to zero on hardware. +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 for the headless --info flag. Returns a slice into +// `buffer`; callers keep the buffer alive while they print the result. +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; // MBC1 + image[0x0148] = 0x01; // 64 KiB + image[0x0149] = 0x03; // 32 KiB RAM + image[0x0143] = 0x80; // CGB + image[0x0146] = 0x03; // SGB + _ = 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); + try std.testing.expect(std.mem.indexOf(u8, text, "Global: $0000") != null); +} diff --git a/src/cpu.zig b/src/cpu.zig index fd32347..b466bec 100644 --- a/src/cpu.zig +++ b/src/cpu.zig @@ -155,7 +155,7 @@ pub const Cpu = struct { 0xee => self.xorImmediate(bus), 0xef => self.rst(bus, 0x28), 0xf0 => self.highRead(bus), - 0xf1 => self.popPair(bus, 3), + 0xf1 => self.popAf(bus), 0xf2 => self.highReadC(bus), 0xf3 => self.disableInterrupts(), 0xf5 => self.pushPair(bus, self.af()), @@ -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, } } @@ -485,7 +494,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; + return 12; + } + + fn popAf(self: *Cpu, bus: *Bus) u16 { + const value = self.pop(bus); + self.a = @truncate(value >> 8); + self.f = @as(u8, @truncate(value)) & 0xf0; return 12; } @@ -681,3 +696,96 @@ pub const Cpu = struct { return @as(u16, self.a) << 8 | self.f; } }; + +const std = @import("std"); + +const TestCpu = struct { + cpu: Cpu, + bus: @import("bus.zig").Bus, + + fn init(allocator: std.mem.Allocator, bytes: []const u8) !TestCpu { + var rom: [0x8000]u8 = [_]u8{0} ** 0x8000; + const start = 0x0100; + @memcpy(rom[start .. start + bytes.len], bytes); + const bus = try @import("bus.zig").Bus.init(allocator, &rom); + return .{ .cpu = .{}, .bus = bus }; + } + + fn deinit(self: *TestCpu) void { + self.bus.deinit(); + } + + fn stepN(self: *TestCpu, count: usize) void { + var i: usize = 0; + while (i < count) : (i += 1) _ = self.cpu.step(&self.bus); + } +}; + +test "POP AF restores A and F and does not touch SP" { + const allocator = std.testing.allocator; + var tc = try TestCpu.init(allocator, &.{ + 0x31, 0x00, 0xdf, // LD SP,$DF00 + 0x01, 0x34, 0x12, // LD BC,$1234 + 0xc5, // PUSH BC + 0xf1, // POP AF + }); + defer tc.deinit(); + tc.stepN(4); + try std.testing.expectEqual(@as(u8, 0x12), tc.cpu.a); + try std.testing.expectEqual(@as(u8, 0x30), tc.cpu.f); + try std.testing.expectEqual(@as(u16, 0xdf00), tc.cpu.sp); +} + +test "PUSH AF and POP AF round trip A and F" { + const allocator = std.testing.allocator; + var tc = try TestCpu.init(allocator, &.{ + 0x31, 0x00, 0xdf, // LD SP,$DF00 + 0x3e, 0xab, // LD A,$AB + 0xf5, // PUSH AF + 0x3e, 0x00, // LD A,$00 + 0xf1, // POP AF + }); + defer tc.deinit(); + tc.stepN(5); + try std.testing.expectEqual(@as(u8, 0xab), tc.cpu.a); + try std.testing.expectEqual(@as(u16, 0xdf00), tc.cpu.sp); +} + +test "SP pair operations for LD, INC, and DEC SP" { + const allocator = std.testing.allocator; + var tc = try TestCpu.init(allocator, &.{ + 0x31, 0x34, 0x12, // LD SP,$1234 + 0x33, // INC SP + 0x3b, // DEC SP + 0x3b, // DEC SP + }); + defer tc.deinit(); + tc.stepN(4); + try std.testing.expectEqual(@as(u16, 0x1233), tc.cpu.sp); +} + +test "ADD HL,SP computes half and full carry flags" { + const allocator = std.testing.allocator; + var tc = try TestCpu.init(allocator, &.{ + 0x31, 0xf0, 0x0f, // LD SP,$0FF0 + 0x21, 0x10, 0x00, // LD HL,$0010 + 0x39, // ADD HL,SP + }); + defer tc.deinit(); + tc.stepN(3); + try std.testing.expectEqual(@as(u16, 0x1000), tc.cpu.hl()); + try std.testing.expectEqual(@as(u8, 0x20), tc.cpu.f & 0x30); +} + +test "DAA adds decimal correction to A" { + const allocator = std.testing.allocator; + var tc = try TestCpu.init(allocator, &.{ + 0xaf, // XOR A (clears carry) + 0x3e, 0x0a, // LD A,$0A + 0x27, // DAA + }); + defer tc.deinit(); + tc.stepN(3); + try std.testing.expectEqual(@as(u8, 0x10), tc.cpu.a); + try std.testing.expectEqual(@as(u8, 0x00), tc.cpu.f & 0x10); +} diff --git a/src/disasm.zig b/src/disasm.zig index 234bc7c..29a139e 100644 --- a/src/disasm.zig +++ b/src/disasm.zig @@ -13,6 +13,7 @@ pub const Instruction = struct { const r8_names = [_][]const u8{ "B", "C", "D", "E", "H", "L", "(HL)", "A" }; const r16_names = [_][]const u8{ "BC", "DE", "HL", "SP" }; +const r16_stack_names = [_][]const u8{ "BC", "DE", "HL", "AF" }; fn r8(index: u8) []const u8 { return r8_names[index & 7]; @@ -94,7 +95,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 { @@ -102,11 +103,11 @@ const Writer = struct { } fn putPOP(self: *Writer, index: u8) void { - self.putFmt("POP {s}", .{r16_names[index & 3]}); + self.putFmt("POP {s}", .{r16_stack_names[index & 3]}); } fn putPUSH(self: *Writer, index: u8) void { - self.putFmt("PUSH {s}", .{r16_names[index & 3]}); + self.putFmt("PUSH {s}", .{r16_stack_names[index & 3]}); } fn putJPC(self: *Writer, flag: []const u8, hi: u8, lo: u8) void { @@ -140,13 +141,21 @@ 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 putLDIMM8A(self: *Writer, address: u16) void { + self.putFmt("LDH A,(${X:0>2})", .{address & 0xff}); + } + fn putADDSP(self: *Writer, pc: u16, signed: u8) void { self.put("ADD SP,"); const value: i8 = @bitCast(signed); @@ -332,10 +341,10 @@ 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"), + 0xf0 => writer.putLDIMM8A(@as(u16, 0xff00) + fetch.byte(1)), 0xf1 => writer.putPOP(3), 0xf2 => writer.putLD8("A", "(C)"), 0xf3 => writer.put("DI"), @@ -381,22 +390,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]; } @@ -430,3 +439,48 @@ test "disassembler formats representative instructions" { const d = formatAt(&bus, 0x0107); try std.testing.expectEqualStrings("JP $1000", std.mem.trimRight(u8, &d.text, "\x00")); } + +test "disassembler computes JR targets from the next instruction" { + const allocator = std.testing.allocator; + var rom = [_]u8{0} ** 0x8000; + rom[0x0100] = 0x18; // JR +2 + rom[0x0101] = 0x02; + rom[0x0102] = 0x20; // JR NZ,-4 (back to 0x0100) + rom[0x0103] = 0xfc; + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + + const a = formatAt(&bus, 0x0100); + try std.testing.expectEqualStrings("JR $0104", std.mem.trimRight(u8, &a.text, "\x00")); + + const b = formatAt(&bus, 0x0102); + try std.testing.expectEqualStrings("JR NZ,$0100", std.mem.trimRight(u8, &b.text, "\x00")); +} + +test "disassembler names PUSH AF, POP AF, and absolute loads" { + const allocator = std.testing.allocator; + var rom = [_]u8{0} ** 0x8000; + rom[0x0100] = 0xf5; // PUSH AF + rom[0x0101] = 0xf1; // POP AF + rom[0x0102] = 0xea; // LD ($1234),A + rom[0x0103] = 0x34; + rom[0x0104] = 0x12; + rom[0x0105] = 0xf0; // LDH A,($56) + rom[0x0106] = 0x56; + rom[0x0107] = 0xfa; // LD A,($5678) + rom[0x0108] = 0x78; + rom[0x0109] = 0x56; + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + + const a = formatAt(&bus, 0x0100); + try std.testing.expectEqualStrings("PUSH AF", std.mem.trimRight(u8, &a.text, "\x00")); + const b = formatAt(&bus, 0x0101); + try std.testing.expectEqualStrings("POP AF", std.mem.trimRight(u8, &b.text, "\x00")); + const c = formatAt(&bus, 0x0102); + try std.testing.expectEqualStrings("LD ($1234),A", std.mem.trimRight(u8, &c.text, "\x00")); + const d = formatAt(&bus, 0x0105); + try std.testing.expectEqualStrings("LDH A,($56)", std.mem.trimRight(u8, &d.text, "\x00")); + const e = formatAt(&bus, 0x0107); + try std.testing.expectEqualStrings("LD A,($5678)", std.mem.trimRight(u8, &e.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..f76009b 100644 --- a/src/headless.zig +++ b/src/headless.zig @@ -2,29 +2,77 @@ const std = @import("std"); const Emulator = @import("emulator.zig").Emulator; const serial = @import("serial.zig"); const disasm = @import("disasm.zig"); +const ppu = @import("ppu.zig"); +const cartridge = @import("cartridge.zig"); const default_max_cycles: u64 = 2_000_000_000; +const Expect = enum { pass, fail, any }; + const Options = struct { rom_path: ?[]const u8 = null, blargg_suite: ?[]const u8 = null, max_cycles: u64 = default_max_cycles, trace: bool = false, - expect: ?serial.Verdict = null, + expect: ?Expect = null, timeout_ms: u64 = 120_000, + frame_dump: ?[]const u8 = null, + frame_count: u64 = 1, + info: bool = false, }; +// FNV-1a over the raw frame bytes. Used to prove that the pixel pipeline +// is deterministic across builds and platforms. +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; +} + +fn writePpm(io: std.Io, path: []const u8, frame: []const u32) !void { + const dir = std.Io.Dir.cwd(); + const file = try dir.createFile(io, path, .{ .truncate = true }); + defer file.close(io); + + const header = try std.fmt.allocPrint(std.heap.page_allocator, "P6\n{d} {d}\n255\n", .{ ppu.ScreenWidth, ppu.ScreenHeight }); + defer std.heap.page_allocator.free(header); + try file.writePositionalAll(io, header, 0); + + var pixels: [ppu.ScreenWidth * ppu.ScreenHeight * 3]u8 = undefined; + for (frame, 0..) |pixel, index| { + pixels[index * 3] = @truncate(pixel >> 16); + pixels[index * 3 + 1] = @truncate(pixel >> 8); + pixels[index * 3 + 2] = @truncate(pixel); + } + try file.writePositionalAll(io, &pixels, header.len); +} + 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} --dump-frame [--frames N] + \\ {s} --info \\ {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. --dump-frame writes the rendered + \\pixel buffer to a PPM file and prints a deterministic checksum. + \\--info prints the cartridge header and exits. \\ - , .{ program, program }); + , .{ program, program, program, program }); } fn readFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]u8 { @@ -81,9 +129,26 @@ 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: [512]u8 = undefined; + std.debug.print("{s}", .{cartridge.formatHeader(cartridge.readHeader(rom), &buffer)}); + return 0; + } + var emulator = try Emulator.init(allocator, rom); defer emulator.deinit(); + if (options.frame_dump) |path| { + var frame_index: u64 = 0; + while (frame_index < options.frame_count) : (frame_index += 1) { + emulator.runFrame(1_000_000); + } + const frame = &emulator.bus.ppu.frame; + try writePpm(io, path, frame); + std.debug.print("Frame checksum: {X:0>8}\n", .{frameChecksum(frame)}); + return 0; + } + var runner = Runner{ .emulator = &emulator, .options = options, @@ -100,8 +165,11 @@ fn runRom(io: std.Io, allocator: std.mem.Allocator, options: Options) !u8 { std.debug.print("Serial output:\n{s}\n", .{printable}); } - const wanted = options.expect orelse verdict; - const ok = wanted == verdict; + const ok: bool = if (options.expect) |expected| switch (expected) { + Expect.pass => verdict == serial.Verdict.pass, + Expect.fail => verdict == serial.Verdict.fail, + Expect.any => true, + } else verdict == serial.Verdict.pass; std.debug.print("Verdict: {s}\n", .{@tagName(verdict)}); return if (ok) 0 else 1; } @@ -191,7 +259,7 @@ pub fn main(init: std.process.Init) !void { } else if (std.mem.eql(u8, argv[index], "fail")) { options.expect = .fail; } else if (std.mem.eql(u8, argv[index], "any")) { - options.expect = .pass; + options.expect = .any; } else { std.debug.print("bad --expect value: {s}\n", .{argv[index]}); std.process.exit(2); @@ -203,6 +271,25 @@ pub fn main(init: std.process.Init) !void { std.process.exit(2); } options.blargg_suite = argv[index]; + } else if (std.mem.eql(u8, arg, "--dump-frame")) { + index += 1; + if (index >= argv.len) { + std.debug.print("--dump-frame needs a path\n", .{}); + std.process.exit(2); + } + options.frame_dump = argv[index]; + } else if (std.mem.eql(u8, arg, "--frames")) { + index += 1; + if (index >= argv.len) { + std.debug.print("--frames needs a count\n", .{}); + std.process.exit(2); + } + options.frame_count = std.fmt.parseInt(u64, argv[index], 10) catch { + std.debug.print("bad frame count: {s}\n", .{argv[index]}); + std.process.exit(2); + }; + } else if (std.mem.eql(u8, arg, "--info")) { + options.info = true; } else if (options.rom_path == null) { options.rom_path = arg; } else { diff --git a/src/integration.zig b/src/integration.zig new file mode 100644 index 0000000..76b47d7 --- /dev/null +++ b/src/integration.zig @@ -0,0 +1,101 @@ +const std = @import("std"); +const Emulator = @import("emulator.zig").Emulator; +const cartridge = @import("cartridge.zig"); +const gbasm = @import("gbasm"); + +// Deterministic 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, then run through the full pipeline. Every expected value +// below is fixed, so a 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. It matches the hash the headless runner +// prints for --dump-frame. +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); + + const rom = try gbasm.assemble(allocator, source); + defer allocator.free(rom); + try std.testing.expectEqualSlices(u8, committed, rom); +} + +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..f4fc550 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,196 @@ const std = @import("std"); +const Emulator = @import("emulator.zig").Emulator; +const ppu = @import("ppu.zig"); +const sdl = @import("sdl2.zig"); -pub fn main() !void { - std.debug.print("Dot Matrix Deck: windowed frontend is under construction.\n", .{}); +// The windowed frontend. It opens an SDL2 window, renders the PPU frame, +// and maps keyboard input to the Game Boy joypad. The emulation core is +// shared with the headless runner; only presentation and input live here. + +const ScreenWidth = 160; +const ScreenHeight = 144; +const WindowScale = 3; +const FrameMs: u32 = 1000 / 60; + +const Gamepad = struct { + dpad: u8 = 0x0f, + buttons: u8 = 0x0f, + + fn keyDown(self: *Gamepad, sym: c_int, running: *bool) void { + const mask = switch (sym) { + sdl.KeyCode.right => @as(u8, 0x01), + sdl.KeyCode.left => @as(u8, 0x02), + sdl.KeyCode.up => @as(u8, 0x04), + sdl.KeyCode.down => @as(u8, 0x08), + else => 0, + }; + if (mask != 0) { + self.dpad &= ~mask; + return; + } + const button_mask = switch (sym) { + sdl.KeyCode.z => @as(u8, 0x01), // A + sdl.KeyCode.x => @as(u8, 0x02), // B + sdl.KeyCode.backspace => @as(u8, 0x04), // Select + sdl.KeyCode.enter => @as(u8, 0x08), // Start + else => 0, + }; + if (button_mask != 0) { + self.buttons &= ~button_mask; + return; + } + if (sym == sdl.KeyCode.escape) running.* = false; + } + + fn keyUp(self: *Gamepad, sym: c_int) void { + const mask = switch (sym) { + sdl.KeyCode.right => @as(u8, 0x01), + sdl.KeyCode.left => @as(u8, 0x02), + sdl.KeyCode.up => @as(u8, 0x04), + sdl.KeyCode.down => @as(u8, 0x08), + else => 0, + }; + if (mask != 0) { + self.dpad |= mask; + return; + } + const button_mask = switch (sym) { + sdl.KeyCode.z => @as(u8, 0x01), + sdl.KeyCode.x => @as(u8, 0x02), + sdl.KeyCode.backspace => @as(u8, 0x04), + sdl.KeyCode.enter => @as(u8, 0x08), + else => 0, + }; + if (button_mask != 0) self.buttons |= button_mask; + } +}; + +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 usage(program: []const u8) void { + std.debug.print( + \\Dot Matrix Deck - windowed frontend + \\Usage: {s} + \\ + \\Arrow keys move the D-pad. Z is A, X is B. + \\Enter is Start, Backspace is Select. Esc quits. + \\ + , .{program}); +} + +fn fail(comptime fmt: []const u8, args: anytype) noreturn { + std.debug.print(fmt ++ "\n", args); + std.process.exit(1); +} + +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; + + const program = if (argv.len != 0) argv[0] else "dot-matrix-deck"; + if (argv.len < 2 or argv.len > 2) { + usage(program); + std.process.exit(2); + } + const rom_path = argv[1]; + + const rom = readFile(io, allocator, rom_path) catch |err| { + fail("could not read {s}: {s}", .{ rom_path, @errorName(err) }); + }; + defer allocator.free(rom); + + var emulator = Emulator.init(allocator, rom) catch |err| { + fail("could not start emulator: {s}", .{@errorName(err)}); + }; + defer emulator.deinit(); + + sdl.SDL_SetMainReady(); + if (sdl.SDL_Init(sdl.InitFlags.video) != 0) { + fail("SDL_Init failed: {s}", .{sdl.SDL_GetError()}); + } + defer sdl.SDL_Quit(); + + const window = sdl.SDL_CreateWindow( + "Dot Matrix Deck", + 0x1fff0000, // SDL_WINDOWPOS_UNDEFINED + 0x1fff0000, // SDL_WINDOWPOS_UNDEFINED + ScreenWidth * WindowScale, + ScreenHeight * WindowScale, + sdl.WindowFlags.shown | sdl.WindowFlags.resizable, + ) orelse { + fail("SDL_CreateWindow failed: {s}", .{sdl.SDL_GetError()}); + }; + defer sdl.SDL_DestroyWindow(window); + + const renderer = sdl.SDL_CreateRenderer( + window, + -1, + sdl.RendererFlags.accelerated | sdl.RendererFlags.present_vsync, + ) orelse { + fail("SDL_CreateRenderer failed: {s}", .{sdl.SDL_GetError()}); + }; + defer sdl.SDL_DestroyRenderer(renderer); + + _ = sdl.SDL_RenderSetLogicalSize(renderer, ScreenWidth, ScreenHeight); + + const texture = sdl.SDL_CreateTexture( + renderer, + sdl.PixelFormat.bgra8888, + sdl.TextureAccess.streaming, + ScreenWidth, + ScreenHeight, + ) orelse { + fail("SDL_CreateTexture failed: {s}", .{sdl.SDL_GetError()}); + }; + defer sdl.SDL_DestroyTexture(texture); + + var gamepad = Gamepad{}; + var running = true; + var last_frame = sdl.SDL_GetTicks(); + + while (running) { + var event: sdl.Event = undefined; + while (sdl.SDL_PollEvent(&event) != 0) { + switch (event.event_type) { + sdl.EventType.quit => running = false, + sdl.EventType.key_down => gamepad.keyDown(event.key.keysym.sym, &running), + sdl.EventType.key_up => gamepad.keyUp(event.key.keysym.sym), + else => {}, + } + } + + emulator.bus.setJoypad(gamepad.dpad, gamepad.buttons); + emulator.runFrame(1_000_000); + + _ = sdl.SDL_UpdateTexture( + texture, + null, + @ptrCast(&emulator.bus.ppu.frame), + ScreenWidth * @sizeOf(u32), + ); + _ = sdl.SDL_RenderClear(renderer); + _ = sdl.SDL_RenderCopy(renderer, texture, null, null); + sdl.SDL_RenderPresent(renderer); + + const now = sdl.SDL_GetTicks(); + const elapsed = now -% last_frame; + if (elapsed < FrameMs) sdl.SDL_Delay(FrameMs - elapsed); + last_frame = now; + } } diff --git a/src/ppu.zig b/src/ppu.zig index c22a198..00864af 100644 --- a/src/ppu.zig +++ b/src/ppu.zig @@ -174,3 +174,25 @@ pub const Ppu = struct { } } }; + +const std = @import("std"); +const testing = std.testing; + +test "PPU raises VBlank once per frame at 70224 cycles" { + var ppu = Ppu{}; + var iflag: u8 = 0; + var line: usize = 0; + while (line < 143) : (line += 1) ppu.tick(456, &iflag); + try testing.expectEqual(false, ppu.takeFrame()); + ppu.tick(456, &iflag); + try testing.expectEqual(true, ppu.takeFrame()); + try testing.expectEqual(@as(u8, 0x01), iflag & 0x01); +} + +test "PPU line counter wraps to 0 after line 153" { + var ppu = Ppu{}; + var iflag: u8 = 0; + var line: usize = 0; + while (line < 154) : (line += 1) ppu.tick(456, &iflag); + try testing.expectEqual(@as(u8, 0), ppu.ly); +} diff --git a/src/sdl2.zig b/src/sdl2.zig new file mode 100644 index 0000000..4360102 --- /dev/null +++ b/src/sdl2.zig @@ -0,0 +1,98 @@ +// Minimal SDL2 bindings for the windowed frontend. +// +// The bindings declare the subset of the SDL2 C API that the frontend +// needs. They are hand-written against the stable SDL2 ABI. The build +// links the system SDL2 library, so a C header translator is not needed. + +pub const InitFlags = struct { + pub const video: u32 = 0x00000020; +}; + +pub const WindowFlags = struct { + pub const shown: u32 = 0x00000004; + pub const resizable: u32 = 0x00000020; +}; + +pub const RendererFlags = struct { + pub const accelerated: u32 = 0x00000002; + pub const present_vsync: u32 = 0x00000004; +}; + +pub const TextureAccess = struct { + pub const streaming: c_int = 1; +}; + +pub const PixelFormat = struct { + pub const bgra8888: u32 = 0x16862004; +}; + +pub const EventType = struct { + pub const quit: u32 = 0x100; + pub const key_down: u32 = 0x300; + pub const key_up: u32 = 0x301; +}; + +pub const KeyCode = struct { + pub const right: c_int = 0x4000004f; + pub const left: c_int = 0x40000050; + pub const down: c_int = 0x40000051; + pub const up: c_int = 0x40000052; + pub const enter: c_int = 0x40000028; + pub const escape: c_int = 0x40000029; + pub const backspace: c_int = 0x4000002a; + pub const z: c_int = 0x7a; + pub const x: c_int = 0x78; +}; + +pub const Keysym = extern struct { + scancode: c_int, + sym: c_int, + mod: u16, + unused: u32, +}; + +pub const KeyboardEvent = extern struct { + event_type: u32, + timestamp: u32, + window_id: u32, + state: u8, + repeat: u8, + padding: [2]u8, + keysym: Keysym, +}; + +pub const Event = extern union { + event_type: u32, + key: KeyboardEvent, + padding: [56]u8, +}; + +pub const Window = opaque {}; +pub const Renderer = opaque {}; +pub const Texture = opaque {}; +pub const Rect = extern struct { + x: c_int, + y: c_int, + w: c_int, + h: c_int, +}; + +pub extern "c" fn SDL_SetMainReady() void; +pub extern "c" fn SDL_Init(flags: u32) c_int; +pub extern "c" fn SDL_Quit() void; +pub extern "c" fn SDL_GetError() [*:0]const u8; +pub extern "c" fn SDL_GetTicks() u32; +pub extern "c" fn SDL_Delay(ms: u32) void; +pub extern "c" fn SDL_CreateWindow(title: [*:0]const u8, x: c_int, y: c_int, w: c_int, h: c_int, flags: u32) ?*Window; +pub extern "c" fn SDL_SetWindowTitle(window: ?*Window, title: [*:0]const u8) void; +pub extern "c" fn SDL_CreateRenderer(window: ?*Window, index: c_int, flags: u32) ?*Renderer; +pub extern "c" fn SDL_RenderSetLogicalSize(renderer: ?*Renderer, w: c_int, h: c_int) c_int; +pub extern "c" fn SDL_CreateTexture(renderer: ?*Renderer, format: u32, access: c_int, w: c_int, h: c_int) ?*Texture; +pub extern "c" fn SDL_UpdateTexture(texture: ?*Texture, rect: ?*const Rect, pixels: *const anyopaque, pitch: c_int) c_int; +pub extern "c" fn SDL_RenderClear(renderer: ?*Renderer) c_int; +pub extern "c" fn SDL_RenderCopy(renderer: ?*Renderer, texture: ?*Texture, src: ?*const Rect, dst: ?*const Rect) c_int; +pub extern "c" fn SDL_RenderPresent(renderer: ?*Renderer) void; +pub extern "c" fn SDL_PollEvent(event: *Event) c_int; +pub extern "c" fn SDL_DestroyTexture(texture: ?*Texture) void; +pub extern "c" fn SDL_DestroyRenderer(renderer: ?*Renderer) void; +pub extern "c" fn SDL_DestroyWindow(window: ?*Window) void; diff --git a/tools/gbasm.zig b/tools/gbasm.zig index e81a221..9bf42bd 100644 --- a/tools/gbasm.zig +++ b/tools/gbasm.zig @@ -1,5 +1,1097 @@ const std = @import("std"); -pub fn main() !void { - std.debug.print("gbasm: assembler is under construction.\n", .{}); +// gbasm - a two-pass assembler for the SM83. +// +// It reads assembly source and writes a Game Boy ROM image. The project +// uses it to regenerate the bundled test ROMs from their assembly sources, +// so the automated test suite never depends on downloaded binaries. +// +// Directives: org, db, dw, ds. +// Numbers: $FF, 0xFF, %101, 0b101, decimal, 'c'. +// A label is a name followed by ':'. An immediate is a number, a label, or +// a label with a +/- constant suffix. Mnemonics and registers are matched +// case-insensitively. + +const MaxOperands = 3; + +const DbItem = union(enum) { + byte: Expr, + string: []const u8, +}; + +const Expr = union(enum) { + number: i32, + label: []const u8, +}; + +const Instr = struct { + mnemonic: []const u8, + operands: [MaxOperands]?[]const u8 = .{ null, null, null }, + count: u8 = 0, +}; + +const Kind = union(enum) { + org: u16, + db: []const DbItem, + dw: []const Expr, + ds: u16, + instr: Instr, +}; + +const Statement = struct { + address: u16 = 0, + label: ?[]const u8 = null, + kind: Kind, +}; + +pub const Error = error{ + InvalidMnemonic, + InvalidOperand, + MissingOperand, + ExtraOperand, + UndefinedLabel, + DuplicateLabel, + BadNumber, + Range, + OutOfMemory, +}; + +const SymbolMap = std.StringHashMapUnmanaged(u16); + +// Operand shapes used for sizing. An immediate may be a number or a label. +const Shape = enum { reg8, reg16, imm8, imm16, rel8 }; + +const Assembler = struct { + allocator: std.mem.Allocator, + statements: std.ArrayListUnmanaged(Statement) = .empty, + symbols: SymbolMap = .empty, + + fn deinit(self: *Assembler) void { + const a = self.allocator; + for (self.statements.items) |*stmt| switch (stmt.kind) { + .db => |items| a.free(items), + .dw => |items| a.free(items), + else => {}, + }; + self.statements.deinit(a); + self.symbols.deinit(a); + } + + fn trim(s: []const u8) []const u8 { + return std.mem.trim(u8, s, " \t\r\n"); + } + + // Strips a comment, respecting single and double quotes. + fn stripComment(line: []const u8) []const u8 { + var in_single = false; + var in_double = false; + for (line, 0..) |c, i| { + switch (c) { + '\'' => in_single = !in_single, + '"' => in_double = !in_double, + ';' => if (!in_single and !in_double) return line[0..i], + else => {}, + } + } + return line; + } + + fn eqIgnoreCase(a: []const u8, b: []const u8) bool { + if (a.len != b.len) return false; + for (a, b) |x, y| { + if (std.ascii.toLower(x) != std.ascii.toLower(y)) return false; + } + return true; + } + + fn isLabelChar(c: u8) bool { + return std.ascii.isAlphanumeric(c) or c == '_' or c == '.'; + } + + fn labelName(token: []const u8) ?[]const u8 { + if (token.len == 0 or !isLabelChar(token[0])) return null; + var end: usize = 1; + while (end < token.len and isLabelChar(token[end])) : (end += 1) {} + return token[0..end]; + } + + fn parseNumber(self: *Assembler, token: []const u8) Error!i32 { + _ = self; + if (token.len == 0) return error.BadNumber; + if (token[0] == '\'') { + if (token.len != 3 or token[2] != '\'') return error.BadNumber; + return token[1]; + } + var base: u8 = 10; + var digits = token; + if (token[0] == '$') { + base = 16; + digits = token[1..]; + } else if (std.mem.startsWith(u8, token, "0x")) { + base = 16; + digits = token[2..]; + } else if (token[0] == '%') { + base = 2; + digits = token[1..]; + } else if (std.mem.startsWith(u8, token, "0b")) { + base = 2; + digits = token[2..]; + } + if (digits.len == 0) return error.BadNumber; + var value: i64 = 0; + for (digits) |c| { + const d: i32 = switch (c) { + '0'...'9' => c - '0', + 'a'...'f' => c - 'a' + 10, + 'A'...'F' => c - 'A' + 10, + else => return error.BadNumber, + }; + if (d >= base) return error.BadNumber; + value = value * base + d; + } + if (value > std.math.maxInt(i32)) return error.BadNumber; + return @intCast(value); + } + + fn parseExpr(self: *Assembler, token: []const u8) Error!Expr { + if (self.parseNumber(token)) |n| { + return .{ .number = n }; + } else |err| { + if (err != error.BadNumber) return err; + } + // Label with optional +/- constant suffix. The offset is only + // validated; plain labels encode their base address. + if (labelName(token)) |name| { + const rest = token[name.len..]; + if (rest.len == 0) return .{ .label = name }; + if (rest.len >= 2 and (rest[0] == '+' or rest[0] == '-')) { + _ = try self.parseNumber(rest[1..]); + return .{ .label = name }; + } + } + return error.InvalidOperand; + } + + fn exprValue(self: *Assembler, expr: Expr) Error!u16 { + return switch (expr) { + .number => |n| blk: { + if (n < 0 or n > 0xffff) return error.Range; + break :blk @intCast(n); + }, + .label => |name| self.symbols.get(name) orelse error.UndefinedLabel, + }; + } + + fn exprByte(self: *Assembler, expr: Expr) Error!u8 { + const v = try self.exprValue(expr); + if (v > 0xff) return error.Range; + return @intCast(v); + } + + // ---- operand classification ----------------------------------------- + + fn reg8Index(token: []const u8) ?u8 { + if (token.len == 1) { + for ("bcdeh", 0..) |c, i| { + if (std.ascii.toLower(token[0]) == c) return @intCast(i); + } + if (std.ascii.toLower(token[0]) == 'a') return 7; + if (std.ascii.toLower(token[0]) == 'l') return 5; + } + if (token.len == 4 and eqIgnoreCase(token, "(hl)")) return 6; + return null; + } + + fn reg16Index(token: []const u8) ?u2 { + const names = [_][]const u8{ "bc", "de", "hl", "sp" }; + for (names, 0..) |name, i| { + if (eqIgnoreCase(token, name)) return @intCast(i); + } + return null; + } + + fn condIndex(token: []const u8) ?u2 { + const names = [_][]const u8{ "nz", "z", "nc", "c" }; + for (names, 0..) |name, i| { + if (eqIgnoreCase(token, name)) return @intCast(i); + } + return null; + } + + // Stack pair index for PUSH/POP: BC=0, DE=1, HL=2, AF=3. + fn stack16Index(token: []const u8) ?u2 { + if (eqIgnoreCase(token, "af")) return 3; + return reg16Index(token); + } + + fn isParenthesized(token: []const u8) bool { + return token.len >= 2 and token[0] == '(' and token[token.len - 1] == ')'; + } + + fn parenInner(token: []const u8) []const u8 { + return token[1 .. token.len - 1]; + } + + // Classifies an operand token into an encoding shape. + fn classify(self: *Assembler, token: []const u8) Shape { + _ = self; + if (reg8Index(token) != null) return .reg8; + if (reg16Index(token) != null) return .reg16; + if (isParenthesized(token)) return .imm16; + return .imm8; // numbers and labels + } + + // ---- parsing -------------------------------------------------------- + + fn splitMnemonic(line: []const u8) struct { name: []const u8, rest: []const u8 } { + const trimmed = trim(line); + var end: usize = 0; + while (end < trimmed.len and (std.ascii.isAlphanumeric(trimmed[end]) or trimmed[end] == '.')) : (end += 1) {} + return .{ .name = trimmed[0..end], .rest = trim(trimmed[end..]) }; + } + + fn splitOperands(self: *Assembler, allocator: std.mem.Allocator, rest: []const u8) Error![]const []const u8 { + _ = self; + var result: std.ArrayListUnmanaged([]const u8) = .empty; + errdefer result.deinit(allocator); + var start: usize = 0; + var i: usize = 0; + var in_single = false; + var in_double = false; + while (i < rest.len) : (i += 1) { + switch (rest[i]) { + '\'' => in_single = !in_single, + '"' => in_double = !in_double, + ',' => if (!in_single and !in_double) { + try result.append(allocator, trim(rest[start..i])); + start = i + 1; + }, + else => {}, + } + } + if (rest.len != 0) try result.append(allocator, trim(rest[start..])); + return result.toOwnedSlice(allocator); + } + + fn parseLine(self: *Assembler, allocator: std.mem.Allocator, line: []const u8) Error!?Statement { + const trimmed = trim(stripComment(line)); + if (trimmed.len == 0) return null; + + var label: ?[]const u8 = null; + var body = trimmed; + + if (std.mem.indexOfScalar(u8, trimmed, ':')) |colon| { + const head = trim(trimmed[0..colon]); + if (head.len != 0 and labelName(head) != null and eqIgnoreCase(head, labelName(head).?)) { + label = head; + body = trim(trimmed[colon + 1 ..]); + if (body.len == 0) { + // A bare label carries no bytes and does not move the + // program counter. + return .{ .label = label, .kind = .{ .ds = 0 } }; + } + } + } + + const mn = splitMnemonic(body); + const operands = try self.splitOperands(allocator, mn.rest); + defer allocator.free(operands); + var count: usize = 0; + while (count < operands.len and operands[count].len != 0) : (count += 1) {} + + if (eqIgnoreCase(mn.name, "org")) { + if (count != 1) return error.InvalidOperand; + const value = try self.exprValue(try self.parseExpr(operands[0])); + return .{ .label = label, .kind = .{ .org = value } }; + } + if (eqIgnoreCase(mn.name, "db")) { + if (count == 0) return error.InvalidOperand; + const items = try self.parseDbItems(allocator, operands[0..count]); + return .{ .label = label, .kind = .{ .db = items } }; + } + if (eqIgnoreCase(mn.name, "dw")) { + if (count == 0) return error.InvalidOperand; + const items = try self.parseDwItems(allocator, operands[0..count]); + return .{ .label = label, .kind = .{ .dw = items } }; + } + if (eqIgnoreCase(mn.name, "ds")) { + if (count != 1) return error.InvalidOperand; + const value = try self.exprValue(try self.parseExpr(operands[0])); + return .{ .label = label, .kind = .{ .ds = value } }; + } + + if (count > MaxOperands) return error.ExtraOperand; + var instr = Instr{ .mnemonic = mn.name }; + for (operands[0..count], 0..) |op, i| instr.operands[i] = op; + instr.count = @intCast(count); + return .{ .label = label, .kind = .{ .instr = instr } }; + } + + fn parseDbItems(self: *Assembler, allocator: std.mem.Allocator, items: []const []const u8) Error![]const DbItem { + var result: std.ArrayListUnmanaged(DbItem) = .empty; + errdefer result.deinit(allocator); + for (items) |item| { + if (item.len >= 2 and item[0] == '"' and item[item.len - 1] == '"') { + try result.append(allocator, .{ .string = item[1 .. item.len - 1] }); + } else { + try result.append(allocator, .{ .byte = try self.parseExpr(item) }); + } + } + return result.toOwnedSlice(allocator); + } + + fn parseDwItems(self: *Assembler, allocator: std.mem.Allocator, items: []const []const u8) Error![]const Expr { + var result: std.ArrayListUnmanaged(Expr) = .empty; + errdefer result.deinit(allocator); + for (items) |item| try result.append(allocator, try self.parseExpr(item)); + return result.toOwnedSlice(allocator); + } + + // ---- sizing --------------------------------------------------------- + + fn statementSize(self: *Assembler, stmt: Statement) Error!u16 { + return switch (stmt.kind) { + .org => 0, + .ds => |n| n, + .db => |items| blk: { + var total: usize = 0; + for (items) |item| switch (item) { + .byte => total += 1, + .string => |s| total += s.len, + }; + break :blk @intCast(total); + }, + .dw => |items| @intCast(items.len * 2), + .instr => |instr| self.instructionSize(instr), + }; + } + + fn operand(ops: *const [MaxOperands]?[]const u8, i: u8) ?[]const u8 { + return if (i < ops.len) ops[i] else null; + } + + // Sizes are fixed by instruction shape, never by the value of a label. + fn instructionSize(self: *Assembler, instr: Instr) Error!u16 { + const m = instr.mnemonic; + const ops = &instr.operands; + const op0 = operand(ops, 0); + const op1 = operand(ops, 1); + + const no_operand = [_][]const u8{ "nop", "halt", "stop", "di", "ei", "reti", "rlca", "rrca", "rla", "rra", "daa", "cpl", "scf", "ccf" }; + for (no_operand) |name| { + if (eqIgnoreCase(m, name)) return 1; + } + + if (eqIgnoreCase(m, "ld")) return self.ldSize(op0, op1); + if (eqIgnoreCase(m, "ldh") or eqIgnoreCase(m, "lda")) return 2; + if (eqIgnoreCase(m, "push") or eqIgnoreCase(m, "pop")) return 1; + if (eqIgnoreCase(m, "inc") or eqIgnoreCase(m, "dec")) return 1; + if (eqIgnoreCase(m, "jr")) return 2; + if (eqIgnoreCase(m, "jp")) { + if (op0 != null and isParenthesized(op0.?)) return 1; // JP (HL) + return 3; + } + if (eqIgnoreCase(m, "call")) return 3; + if (eqIgnoreCase(m, "ret")) return 1; + if (eqIgnoreCase(m, "rst")) return 1; + + const alu_names = [_][]const u8{ "add", "adc", "sub", "sbc", "and", "xor", "or", "cp" }; + for (alu_names) |name| { + if (eqIgnoreCase(m, name)) { + if (op0 == null) return error.MissingOperand; + if (eqIgnoreCase(m, "add")) { + // ADD HL,r16 (1) / ADD SP,e8 (2) / ADD A,r8 (1) / ADD A,d8 (2) + if (op1 != null) { + if (eqIgnoreCase(op0.?, "hl")) return 1; + if (eqIgnoreCase(op0.?, "sp")) return 2; + return if (reg8Index(op1.?) != null) @as(u16, 1) else 2; + } + return if (reg8Index(op0.?) != null) @as(u16, 1) else 2; + } + // ADC/SUB/SBC/AND/XOR/OR/CP + if (op1 != null) return if (reg8Index(op1.?) != null) @as(u16, 1) else 2; + return if (reg8Index(op0.?) != null) @as(u16, 1) else 2; + } + } + + const cb_names = [_][]const u8{ "rlc", "rrc", "rl", "rr", "sla", "sra", "swap", "srl", "bit", "res", "set" }; + for (cb_names) |name| { + if (eqIgnoreCase(m, name)) return 2; + } + + return error.InvalidMnemonic; + } + + fn ldSize(self: *Assembler, op0: ?[]const u8, op1: ?[]const u8) Error!u16 { + _ = self; + if (op0 == null or op1 == null) return error.MissingOperand; + + // LD r16,d16 + if (reg16Index(op0.?) != null) return 3; + + // LD SP,HL / LD HL,SP+e8 + if (reg16Index(op1.?) != null) { + if (eqIgnoreCase(op0.?, "hl") and eqIgnoreCase(op1.?, "sp")) return 2; + return 1; + } + + // LD (r16mem),A / LD A,(r16mem) / LD (HL+),A etc: 1 byte each. + if (isParenthesized(op0.?) or isParenthesized(op1.?)) { + const inner = if (isParenthesized(op0.?)) parenInner(op0.?) else parenInner(op1.?); + if (eqIgnoreCase(inner, "c")) return 1; + if (eqIgnoreCase(inner, "bc") or eqIgnoreCase(inner, "de")) return 1; + if (eqIgnoreCase(inner, "hl") or eqIgnoreCase(inner, "hl+") or eqIgnoreCase(inner, "hl-")) { + // LD (HL),A / LD A,(HL) / LDI/LDD are one byte; LD (HL),d8 is two. + if (isParenthesized(op0.?) and reg8Index(op1.?) == null) return 2; + return 1; + } + return 3; // LD A,(a16) / LD (a16),A / LD (a16),SP + } + + // LD r8,r8 / LD r8,d8 + if (reg8Index(op0.?) != null and reg8Index(op1.?) != null) return 1; + return 2; + } + + // ---- pass 1 --------------------------------------------------------- + + fn pass1(self: *Assembler) Error!void { + var address: u16 = 0; + for (self.statements.items) |*stmt| { + stmt.address = address; + switch (stmt.kind) { + .org => |target| { + if (stmt.label) |label| { + if (self.symbols.contains(label)) return error.DuplicateLabel; + try self.symbols.put(self.allocator, label, target); + } + address = target; + }, + else => { + if (stmt.label) |label| { + if (self.symbols.contains(label)) return error.DuplicateLabel; + try self.symbols.put(self.allocator, label, address); + } + address +%= try self.statementSize(stmt.*); + }, + } + } + } + + fn emit(self: *Assembler, out: *std.ArrayListUnmanaged(u8), stmt: Statement) Error!void { + switch (stmt.kind) { + .org => |target| { + // Pad the image with zeros up to the target address. + var cursor = out.items.len; + while (cursor < target) : (cursor += 1) try out.append(self.allocator, 0); + }, + .ds => |n| { + var i: u16 = 0; + while (i < n) : (i += 1) try out.append(self.allocator, 0); + }, + .db => |items| for (items) |item| switch (item) { + .byte => |expr| try out.append(self.allocator, try self.exprByte(expr)), + .string => |s| for (s) |c| try out.append(self.allocator, c), + }, + .dw => |items| for (items) |expr| { + const v = try self.exprValue(expr); + try out.append(self.allocator, @truncate(v)); + try out.append(self.allocator, @truncate(v >> 8)); + }, + .instr => |instr| try self.emitInstruction(out, instr, stmt.address), + } + } + + fn byte(self: *Assembler, out: *std.ArrayListUnmanaged(u8), value: u8) Error!void { + try out.append(self.allocator, value); + } + + fn emitByte(self: *Assembler, out: *std.ArrayListUnmanaged(u8), token: []const u8) Error!void { + try out.append(self.allocator, try self.exprByte(try self.parseExpr(token))); + } + + fn emitWord(self: *Assembler, out: *std.ArrayListUnmanaged(u8), token: []const u8) Error!void { + const v = try self.exprValue(try self.parseExpr(token)); + try out.append(self.allocator, @truncate(v)); + try out.append(self.allocator, @truncate(v >> 8)); + } + + fn emitRel8(self: *Assembler, out: *std.ArrayListUnmanaged(u8), token: []const u8, address: u16, instr_len: u16) Error!void { + const target = try self.exprValue(try self.parseExpr(token)); + const offset = @as(i32, target) - @as(i32, address +% instr_len); + if (offset < -128 or offset > 127) return error.Range; + try out.append(self.allocator, @bitCast(@as(i8, @intCast(offset)))); + } + + // Emits a signed 8-bit immediate. Used by ADD SP,e8 and LD HL,SP+e8, + // where the operand is an offset from SP, not a branch target. + fn emitSigned8(self: *Assembler, out: *std.ArrayListUnmanaged(u8), token: []const u8) Error!void { + const value = try self.exprValue(try self.parseExpr(token)); + if (value > 0x7f and value < 0x100) { + // Accept an unsigned encoding of a negative offset. + try out.append(self.allocator, @truncate(value)); + return; + } + if (value > 0xff) return error.Range; + try out.append(self.allocator, @truncate(value)); + } + + fn emitInstruction(self: *Assembler, out: *std.ArrayListUnmanaged(u8), instr: Instr, address: u16) Error!void { + const m = instr.mnemonic; + const ops = &instr.operands; + const op0 = operand(ops, 0); + const op1 = operand(ops, 1); + + const single_table = [_]struct { name: []const u8, op: u8 }{ + .{ .name = "nop", .op = 0x00 }, .{ .name = "halt", .op = 0x76 }, + .{ .name = "stop", .op = 0x10 }, .{ .name = "di", .op = 0xf3 }, + .{ .name = "ei", .op = 0xfb }, .{ .name = "reti", .op = 0xd9 }, + .{ .name = "rlca", .op = 0x07 }, .{ .name = "rrca", .op = 0x0f }, + .{ .name = "rla", .op = 0x17 }, .{ .name = "rra", .op = 0x1f }, + .{ .name = "daa", .op = 0x27 }, .{ .name = "cpl", .op = 0x2f }, + .{ .name = "scf", .op = 0x37 }, .{ .name = "ccf", .op = 0x3f }, + }; + for (single_table) |entry| { + if (eqIgnoreCase(m, entry.name)) { + try self.byte(out, entry.op); + return; + } + } + + if (eqIgnoreCase(m, "ld")) return self.emitLd(out, op0, op1); + if (eqIgnoreCase(m, "ldh") or eqIgnoreCase(m, "lda")) return self.emitLdh(out, op0, op1); + + if (eqIgnoreCase(m, "push")) { + try self.byte(out, @intCast(0xc5 + @as(u8, stack16Index(op0.?).?) * 16)); + return; + } + if (eqIgnoreCase(m, "pop")) { + try self.byte(out, @intCast(0xc1 + @as(u8, stack16Index(op0.?).?) * 16)); + return; + } + + if (eqIgnoreCase(m, "inc")) { + if (reg16Index(op0.?)) |p| { + try self.byte(out, @intCast(0x03 + @as(u8, p) * 16)); + } else { + try self.byte(out, @intCast(0x04 + reg8Index(op0.?).? * 8)); + } + return; + } + if (eqIgnoreCase(m, "dec")) { + if (reg16Index(op0.?)) |p| { + try self.byte(out, @intCast(0x0b + @as(u8, p) * 16)); + } else { + try self.byte(out, @intCast(0x05 + reg8Index(op0.?).? * 8)); + } + return; + } + + if (eqIgnoreCase(m, "jr")) { + const cc = if (op0 != null and condIndex(op0.?) != null) condIndex(op0.?).? else null; + const target = if (cc != null) op1.? else op0.?; + try self.byte(out, if (cc) |c| @as(u8, 0x20) + @as(u8, c) * 8 else 0x18); + return self.emitRel8(out, target, address, 2); + } + + if (eqIgnoreCase(m, "jp")) { + if (op0 != null and isParenthesized(op0.?)) { + try self.byte(out, 0xe9); + return; + } + const cc = if (op0 != null and condIndex(op0.?) != null) condIndex(op0.?).? else null; + const target = if (cc != null) op1.? else op0.?; + try self.byte(out, if (cc) |c| @as(u8, 0xc2) + @as(u8, c) * 8 else 0xc3); + return self.emitWord(out, target); + } + + if (eqIgnoreCase(m, "call")) { + const cc = if (op0 != null and condIndex(op0.?) != null) condIndex(op0.?).? else null; + const target = if (cc != null) op1.? else op0.?; + try self.byte(out, if (cc) |c| @as(u8, 0xc4) + @as(u8, c) * 8 else 0xcd); + return self.emitWord(out, target); + } + + if (eqIgnoreCase(m, "ret")) { + const cc = if (op0 != null and condIndex(op0.?) != null) condIndex(op0.?).? else null; + try self.byte(out, if (cc) |c| @as(u8, 0xc0) + @as(u8, c) * 8 else 0xc9); + return; + } + + if (eqIgnoreCase(m, "rst")) { + const value = try self.exprValue(try self.parseExpr(op0.?)); + if (value > 0x38 or value & 7 != 0) return error.Range; + try self.byte(out, @intCast(0xc7 + value)); + return; + } + + const alu_names = [_][]const u8{ "add", "adc", "sub", "sbc", "and", "xor", "or", "cp" }; + const alu_imm = [_]u8{ 0xc6, 0xce, 0xd6, 0xde, 0xe6, 0xee, 0xf6, 0xfe }; + for (alu_names, 0..) |name, i| { + if (eqIgnoreCase(m, name)) { + if (eqIgnoreCase(m, "add")) { + if (op1 != null and eqIgnoreCase(op0.?, "hl")) { + try self.byte(out, @intCast(0x09 + @as(u8, reg16Index(op1.?).?) * 16)); + return; + } + if (op1 != null and eqIgnoreCase(op0.?, "sp")) { + try self.byte(out, 0xe8); + return self.emitSigned8(out, op1.?); + } + if (op1 != null and reg8Index(op1.?) != null) { + try self.byte(out, @intCast(0x80 + i * 8 + reg8Index(op1.?).?)); + return; + } + if (op1 != null) { + try self.byte(out, alu_imm[i]); + return self.emitByte(out, op1.?); + } + if (reg8Index(op0.?) != null) { + try self.byte(out, @intCast(0x80 + i * 8 + reg8Index(op0.?).?)); + return; + } + try self.byte(out, alu_imm[i]); + return self.emitByte(out, op0.?); + } + // ADC/SUB/SBC/AND/XOR/OR/CP + if (op1 != null and reg8Index(op1.?) != null) { + try self.byte(out, @intCast(0x80 + i * 8 + reg8Index(op1.?).?)); + return; + } + if (op1 != null) { + try self.byte(out, alu_imm[i]); + return self.emitByte(out, op1.?); + } + if (reg8Index(op0.?) != null) { + try self.byte(out, @intCast(0x80 + i * 8 + reg8Index(op0.?).?)); + return; + } + try self.byte(out, alu_imm[i]); + return self.emitByte(out, op0.?); + } + } + + const cb_shift = [_][]const u8{ "rlc", "rrc", "rl", "rr", "sla", "sra", "swap", "srl" }; + for (cb_shift, 0..) |name, i| { + if (eqIgnoreCase(m, name)) { + try self.byte(out, 0xcb); + try self.byte(out, @intCast(i * 8 + reg8Index(op0.?).?)); + return; + } + } + const cb_bit = [_][]const u8{ "bit", "res", "set" }; + for (cb_bit, 0..) |name, i| { + if (eqIgnoreCase(m, name)) { + const bit = try self.exprByte(try self.parseExpr(op0.?)); + if (bit > 7) return error.Range; + try self.byte(out, 0xcb); + try self.byte(out, @intCast(0x40 + i * 0x40 + bit * 8 + reg8Index(op1.?).?)); + return; + } + } + + return error.InvalidMnemonic; + } + + fn emitLd(self: *Assembler, out: *std.ArrayListUnmanaged(u8), op0: ?[]const u8, op1: ?[]const u8) Error!void { + const a = op0.?; + const b = op1.?; + + // LD SP,HL + if (eqIgnoreCase(a, "sp") and eqIgnoreCase(b, "hl")) { + try self.byte(out, 0xf9); + return; + } + // LD HL,SP+e8 (offset given as sp+/-N) + if (eqIgnoreCase(a, "hl")) { + if (std.mem.startsWith(u8, b, "SP+") or std.mem.startsWith(u8, b, "sp+")) { + try self.byte(out, 0xf8); + return self.emitSigned8(out, b[3..]); + } + if (std.mem.startsWith(u8, b, "SP-") or std.mem.startsWith(u8, b, "sp-")) { + const value = try self.exprValue(try self.parseExpr(b[3..])); + const negated = @as(i64, -@as(i64, value)); + if (negated < -128 or negated > 127) return error.Range; + try self.byte(out, 0xf8); + try self.byte(out, @bitCast(@as(i8, @intCast(negated)))); + return; + } + } + // LD r16,d16 (BC/DE/HL/SP) + if (reg16Index(a) != null) { + try self.byte(out, @intCast(0x01 + @as(u8, reg16Index(a).?) * 16)); + return self.emitWord(out, b); + } + + // LD (r16mem),A and LD (HL+),A / (HL-),A and LD (a16),A and LD (a16),SP + if (isParenthesized(a)) { + const inner = parenInner(a); + if (eqIgnoreCase(inner, "c")) { + try self.byte(out, 0xe2); + return; + } + if (eqIgnoreCase(inner, "hl")) { + // LD (HL),r8 or LD (HL),d8 + if (reg8Index(b)) |s| { + try self.byte(out, @intCast(0x40 + 6 * 8 + s)); + } else { + try self.byte(out, 0x36); + return self.emitByte(out, b); + } + return; + } + if (eqIgnoreCase(inner, "hl+")) { + try self.byte(out, 0x22); + return; + } + if (eqIgnoreCase(inner, "hl-")) { + try self.byte(out, 0x32); + return; + } + if (reg16Index(inner)) |p| { + try self.byte(out, @intCast(0x02 + @as(u8, p) * 16)); + return; + } + if (eqIgnoreCase(b, "sp")) { + try self.byte(out, 0x08); + return self.emitWord(out, inner); + } + try self.byte(out, 0xea); + return self.emitWord(out, inner); + } + + // LD A,(r16mem) and LD A,(HL+)/(HL-) and LD A,(a16) + if (isParenthesized(b)) { + const inner = parenInner(b); + if (eqIgnoreCase(inner, "c")) { + try self.byte(out, 0xf2); + return; + } + if (eqIgnoreCase(inner, "hl")) { + try self.byte(out, 0x7e); + return; + } + if (eqIgnoreCase(inner, "hl+")) { + try self.byte(out, 0x2a); + return; + } + if (eqIgnoreCase(inner, "hl-")) { + try self.byte(out, 0x3a); + return; + } + if (reg16Index(inner)) |p| { + try self.byte(out, @intCast(0x0a + @as(u8, p) * 16)); + return; + } + try self.byte(out, 0xfa); + return self.emitWord(out, inner); + } + + // LD r8,r8 / LD r8,d8 + const d = reg8Index(a).?; + if (reg8Index(b)) |s| { + try self.byte(out, @intCast(0x40 + d * 8 + s)); + return; + } + try self.byte(out, @intCast(0x06 + d * 8)); + return self.emitByte(out, b); + } + + fn emitLdh(self: *Assembler, out: *std.ArrayListUnmanaged(u8), op0: ?[]const u8, op1: ?[]const u8) Error!void { + const a = op0.?; + const b = op1.?; + // LDH A,(n) + if (eqIgnoreCase(a, "a") and isParenthesized(b)) { + try self.byte(out, 0xf0); + return self.emitByte(out, parenInner(b)); + } + // LDH (n),A + if (isParenthesized(a) and eqIgnoreCase(b, "a")) { + try self.byte(out, 0xe0); + return self.emitByte(out, parenInner(a)); + } + return error.InvalidOperand; + } +}; + +fn splitLines(allocator: std.mem.Allocator, source: []const u8) !*std.ArrayListUnmanaged([]const u8) { + const lines = try allocator.create(std.ArrayListUnmanaged([]const u8)); + errdefer allocator.destroy(lines); + lines.* = .empty; + var it = std.mem.splitScalar(u8, source, '\n'); + while (it.next()) |line| try lines.append(allocator, line); + return lines; +} + +// Assembles a source file into a complete ROM image. +pub fn assemble(allocator: std.mem.Allocator, source: []const u8) ![]u8 { + var assembler = Assembler{ .allocator = allocator }; + defer assembler.deinit(); + + const lines = try splitLines(allocator, source); + defer { + lines.deinit(allocator); + allocator.destroy(lines); + } + + for (lines.items) |line| { + if (try assembler.parseLine(allocator, line)) |stmt| { + try assembler.statements.append(allocator, stmt); + } + } + + try assembler.pass1(); + + var out: std.ArrayListUnmanaged(u8) = .empty; + errdefer out.deinit(allocator); + for (assembler.statements.items) |stmt| try assembler.emit(&out, stmt); + return out.toOwnedSlice(allocator); +} + +// ---- file pipeline ------------------------------------------------------- + +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 writeFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8, bytes: []const u8) !void { + _ = allocator; + const dir = std.Io.Dir.cwd(); + const file = try dir.createFile(io, path, .{ .truncate = true }); + defer file.close(io); + try file.writePositionalAll(io, bytes, 0); +} + +fn assembleDir(io: std.Io, allocator: std.mem.Allocator, in_dir: []const u8, out_dir: []const u8) !u8 { + var dir = try std.Io.Dir.cwd().openDir(io, in_dir, .{ .iterate = true }); + defer dir.close(io); + std.Io.Dir.cwd().createDir(io, out_dir, std.Io.File.Permissions.default_dir) catch {}; + + var failures: usize = 0; + var total: usize = 0; + + var names: std.ArrayListUnmanaged([]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)); + } + } + + for (names.items) |name| { + defer allocator.free(name); + total += 1; + const in_path = try std.fs.path.join(allocator, &.{ in_dir, name }); + defer allocator.free(in_path); + const stem = name[0 .. name.len - 4]; + const out_name = try std.fmt.allocPrint(allocator, "{s}.gb", .{stem}); + defer allocator.free(out_name); + const out_path = try std.fs.path.join(allocator, &.{ out_dir, out_name }); + defer allocator.free(out_path); + + const source = readFile(io, allocator, in_path) catch |err| { + std.debug.print("{s}: read error {s}\n", .{ name, @errorName(err) }); + failures += 1; + continue; + }; + defer allocator.free(source); + + const rom = assemble(allocator, source) catch |err| { + std.debug.print("{s}: assembly error {s}\n", .{ name, @errorName(err) }); + failures += 1; + continue; + }; + defer allocator.free(rom); + + writeFile(io, allocator, out_path, rom) catch |err| { + std.debug.print("{s}: write error {s}\n", .{ name, @errorName(err) }); + failures += 1; + continue; + }; + std.debug.print("{s}: {d} bytes\n", .{ name, rom.len }); + } + + std.debug.print("\n{d}/{d} sources assembled\n", .{ total - failures, total }); + return if (failures == 0) 0 else 1; +} + +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.ArrayListUnmanaged([]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 + \\Usage: + \\ {s} + \\ + \\Assembles every .asm file in into a .gb ROM image in + \\. + \\ + , .{if (argv.len != 0) argv[0] else "gbasm"}); + std.process.exit(2); + } + + const code = assembleDir(io, allocator, argv[1], argv[2]) catch |err| { + std.debug.print("gbasm: {s}\n", .{@errorName(err)}); + std.process.exit(1); + }; + std.process.exit(code); +} + +const testing = std.testing; + +test "assemble a minimal ROM with org, labels, and branches" { + const source = + \\ org $0100 + \\start: + \\ ld a,$12 + \\ add a,$24 + \\ cp $36 + \\ jr nz,fail + \\ ld bc,$1234 + \\ push bc + \\ pop de + \\ ld a,d + \\ cp $12 + \\ jr nz,fail + \\pass: + \\ halt + \\ jr pass + \\fail: + \\ jr fail + ; + const rom = try assemble(testing.allocator, source); + defer testing.allocator.free(rom); + try testing.expect(rom.len >= 0x0100 + 20); + try testing.expectEqual(@as(u8, 0x3e), rom[0x0100]); // LD A,$12 + try testing.expectEqual(@as(u8, 0x12), rom[0x0101]); + try testing.expectEqual(@as(u8, 0xc6), rom[0x0102]); // ADD A,$24 + try testing.expectEqual(@as(u8, 0x24), rom[0x0103]); + try testing.expectEqual(@as(u8, 0xfe), rom[0x0104]); // CP $36 + try testing.expectEqual(@as(u8, 0x36), rom[0x0105]); +} + +test "assemble loads, stack ops, and relative branches to labels" { + const source = + \\ org $0100 + \\ ld sp,$fffe + \\ ld hl,msg + \\loop: + \\ ld a,(hl+) + \\ or a + \\ jr z,done + \\ ld ($ff01),a + \\ jr loop + \\done: + \\ halt + \\msg: + \\ db "PASS",$0a + ; + const rom = try assemble(testing.allocator, source); + defer testing.allocator.free(rom); + try testing.expectEqual(@as(u8, 0x31), rom[0x0100]); // LD SP,d16 + try testing.expectEqual(@as(u8, 0xfe), rom[0x0101]); + try testing.expectEqual(@as(u8, 0xff), rom[0x0102]); + try testing.expectEqual(@as(u8, 0x21), rom[0x0103]); // LD HL,d16 + try testing.expectEqual(@as(u8, 0x2a), rom[0x0106]); // LD A,(HL+) + try testing.expectEqual(@as(u8, 0xb7), rom[0x0107]); // OR A + try testing.expectEqual(@as(u8, 0x28), rom[0x0108]); // JR Z,e8 + try testing.expectEqual(@as(u8, 0xea), rom[0x010a]); // LD (a16),A + try testing.expectEqual(@as(u8, 'P'), rom[0x0110]); + try testing.expectEqual(@as(u8, 'A'), rom[0x0111]); + try testing.expectEqual(@as(u8, 'S'), rom[0x0112]); + try testing.expectEqual(@as(u8, 'S'), rom[0x0113]); + try testing.expectEqual(@as(u8, 0x0a), rom[0x0114]); +} + +test "assemble a full instruction set sample" { + const source = + \\ org $0100 + \\ nop + \\ ld a,b + \\ ld b,c + \\ inc a + \\ dec hl + \\ add hl,bc + \\ add a,$01 + \\ adc a,c + \\ sub d + \\ sbc a,$10 + \\ and e + \\ xor a + \\ or l + \\ cp $80 + \\ rlca + \\ rra + \\ daa + \\ cpl + \\ scf + \\ ccf + \\ push af + \\ pop bc + \\ jp $0200 + \\ jp nz,$0300 + \\ call $0400 + \\ ret + \\ ret z + \\ reti + \\ rst $38 + \\ di + \\ ei + \\ halt + \\ rlc b + \\ bit 3,(hl) + \\ set 7,a + \\ res 0,e + ; + const rom = try assemble(testing.allocator, source); + defer testing.allocator.free(rom); + const cases = [_]struct { addr: usize, byte: u8 }{ + .{ .addr = 0x0100, .byte = 0x00 }, .{ .addr = 0x0101, .byte = 0x78 }, + .{ .addr = 0x0102, .byte = 0x41 }, .{ .addr = 0x0103, .byte = 0x3c }, + .{ .addr = 0x0104, .byte = 0x2b }, .{ .addr = 0x0105, .byte = 0x09 }, + .{ .addr = 0x0106, .byte = 0xc6 }, .{ .addr = 0x0108, .byte = 0x89 }, + .{ .addr = 0x0109, .byte = 0x92 }, .{ .addr = 0x010a, .byte = 0xde }, + .{ .addr = 0x010c, .byte = 0xa3 }, .{ .addr = 0x010d, .byte = 0xaf }, + .{ .addr = 0x010e, .byte = 0xb5 }, .{ .addr = 0x010f, .byte = 0xfe }, + .{ .addr = 0x0111, .byte = 0x07 }, .{ .addr = 0x0112, .byte = 0x1f }, + .{ .addr = 0x0113, .byte = 0x27 }, .{ .addr = 0x0114, .byte = 0x2f }, + .{ .addr = 0x0115, .byte = 0x37 }, .{ .addr = 0x0116, .byte = 0x3f }, + .{ .addr = 0x0117, .byte = 0xf5 }, .{ .addr = 0x0118, .byte = 0xc1 }, + .{ .addr = 0x0119, .byte = 0xc3 }, .{ .addr = 0x011c, .byte = 0xc2 }, + .{ .addr = 0x011f, .byte = 0xcd }, .{ .addr = 0x0122, .byte = 0xc9 }, + .{ .addr = 0x0123, .byte = 0xc8 }, .{ .addr = 0x0124, .byte = 0xd9 }, + .{ .addr = 0x0125, .byte = 0xff }, .{ .addr = 0x0126, .byte = 0xf3 }, + .{ .addr = 0x0127, .byte = 0xfb }, .{ .addr = 0x0128, .byte = 0x76 }, + .{ .addr = 0x0129, .byte = 0xcb }, .{ .addr = 0x012a, .byte = 0x00 }, + .{ .addr = 0x012b, .byte = 0xcb }, .{ .addr = 0x012c, .byte = 0x5e }, + .{ .addr = 0x012d, .byte = 0xcb }, .{ .addr = 0x012e, .byte = 0xff }, + .{ .addr = 0x012f, .byte = 0xcb }, .{ .addr = 0x0130, .byte = 0x83 }, + }; + for (cases) |case| try testing.expectEqual(case.byte, rom[case.addr]); +} + +test "rejects an undefined label" { + const source = + \\ org $0100 + \\ jp nowhere + ; + const rom = assemble(testing.allocator, source); + try testing.expectError(error.UndefinedLabel, rom); }