diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..a0a7bb9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +* text=auto + +*.zig text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.yaml text eol=lf +*.asm text eol=lf + +*.gb binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..272d833 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test (ubuntu) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - name: Format check + run: zig fmt --check . + - name: Unit and integration tests + run: zig build test --summary all + - name: Regenerate bundled ROMs + run: zig build fixtures + - name: Bundled ROMs match their sources + run: git diff --exit-code -- fixtures + - name: CPU instruction suite (blargg) + run: zig build test-blargg + - name: Build headless tools + run: zig build -Dsdl2=off + + test-windows: + name: Test (windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - name: Format check + shell: pwsh + run: zig fmt --check . + - name: Unit and integration tests + shell: pwsh + run: zig build test --summary all + - name: Regenerate bundled ROMs + shell: pwsh + run: zig build fixtures + - name: Bundled ROMs match their sources + shell: pwsh + run: git diff --exit-code -- fixtures + - name: CPU instruction suite (blargg) + shell: pwsh + run: zig build test-blargg + - name: Build headless tools + shell: pwsh + run: zig build -Dsdl2=off + + windowed: + name: Windowed frontend (ubuntu) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - name: Install SDL2 + run: sudo apt-get update && sudo apt-get install -y libsdl2-dev + - name: Build windowed frontend + run: zig build diff --git a/.gitignore b/.gitignore index 51bafde..559f356 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,12 @@ zig-out/ *.dylib *.exe .DS_Store -*.gb *.sav *.swp +*.ppm + +# 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..8565ae3 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,47 @@ +# Contributing + +Thank you for your interest in Dot Matrix Deck. + +## Ground rules + +- Keep the emulator core free of SDL2 dependencies. +- Keep the headless build working without SDL2. +- Write deterministic tests for new behavior. +- Run `zig fmt` on every file you change. +- Keep the bundle ROMs reproducible. Change the assembler source, then + regenerate the ROMs with `zig build fixtures`. + +## How to build + +Requires Zig 0.16 or later. + +```text +zig build test -Dsdl2=off +``` + +Run the windowed frontend with SDL2 installed: + +```text +zig build +``` + +## How to add a test ROM + +1. Write an assembly source under `fixtures/asm`. +2. Generate the ROM with `zig build fixtures`. +3. Add integration checks in `src/integration.zig`. +4. Commit both the source and the generated ROM. + +## How to run the blargg suite + +Add the blargg cpu_instrs ROMs to `fixtures/blargg`, then run: + +```text +zig build test-blargg +``` + +## How to report an issue + +Open an issue on GitHub. +Include the ROM name, the expected result, and the actual result. +Include the headless command you used. 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..9bc4cf6 --- /dev/null +++ b/NOTICE @@ -0,0 +1,26 @@ +# NOTICE + +## Project + +Dot Matrix Deck is a Game Boy emulator written in Zig. +It is licensed under the MIT License. +See the LICENSE file for the full text. + +## Test ROMs + +The bundled blargg CPU test ROMs in fixtures/blargg come from the +Game Boy CPU Instruction Behavior Test by Shay Green (blargg). +The ROMs are distributed through the retrio/gb-test-roms archive at +https://github.com/retrio/gb-test-roms. + +The ROMs verify the behavior of the SM83 instruction set. +They run in the headless runner through the `zig build test-blargg` step. + +The bundled smoke ROM in fixtures/roms is assembled from +fixtures/asm/smoke.asm by the gbasm tool in this repository. + +## Third-party software + +The windowed frontend links against SDL2. +SDL2 is provided by the system package manager or a local prefix. +The emulator itself does not ship or redistribute SDL2 source code. diff --git a/README.md b/README.md index fc59c84..c0fb65f 100644 --- a/README.md +++ b/README.md @@ -1,41 +1,44 @@ # Dot Matrix Deck -Dot Matrix Deck is a Game Boy emulator workbench written in Zig. It models the -SM83 CPU, the memory bus, timers, serial output, and the pixel pipeline. A -headless runner makes emulator behavior easy to inspect in scripts and CI. +Dot Matrix Deck is a Game Boy emulator written in Zig. +It models the SM83 CPU, the memory bus, timers, serial output, and the pixel pipeline. +A software renderer shows the screen in an SDL2 window. +A headless mode runs test ROMs and reports pass or fail verdicts. -## Current status +## What you can do -This project is under active development. The headless core is the most useful -entry point today. The SDL2 windowed frontend is available when SDL2 is -installed, but it is still being built out. +Run a game in a window with keyboard controls. +Run a test ROM without a window and read its serial verdict. +Inspect a cartridge header with the `--info` flag. +Disassemble instructions with the `--trace` flag. +Verify the CPU against the blargg instruction suite. -## Features +## Value of this project -- SM83 CPU and instruction execution -- Memory bus with timer, serial, and pixel-processing components -- Headless ROM runner with cycle limits and serial verdicts -- Optional instruction trace output -- Small assembler tool for test ROM sources -- Deterministic unit tests for the emulator core and assembler +The core is small and readable. +Every subsystem has deterministic tests. +The bundled smoke ROM proves the full pipeline works. +The blargg suite proves the CPU is correct. +CI rebuilds the ROMs from source and checks them byte for byte. ## Requirements -- Zig 0.16 or later -- SDL2 for the optional windowed frontend +You need Zig 0.16 or later. +You need SDL2 for the windowed frontend only. +The headless build does not need SDL2. -The headless build does not require SDL2. On Windows, the build searches common -MSYS2 and vcpkg prefixes. You can also set `SDL2_DIR` or pass an SDL2 prefix. +On Windows, the build searches common MSYS2 and vcpkg prefixes. +You can set `SDL2_DIR` or pass an SDL2 prefix with `-Dsdl2`. ## Build -Build the headless runner and assembler: +Build the headless runner and the assembler: ```text zig build -Dsdl2=off ``` -Build with the windowed frontend when SDL2 is available: +Build the windowed frontend when SDL2 is available: ```text zig build @@ -49,8 +52,9 @@ Run a ROM without a window: zig build run-headless -- path/to/rom.gb ``` -Useful options are `--max-cycles N`, `--trace`, and `--expect pass|fail|any`. -The runner prints serial output and returns a status from the detected verdict. +Useful options are `--max-cycles N`, `--trace`, `--info`, and `--expect pass|fail|any`. +The runner prints the serial output. +The exit code follows the detected verdict. Run a ROM in the SDL2 frontend: @@ -58,32 +62,78 @@ Run a ROM in the SDL2 frontend: zig build run -- path/to/rom.gb ``` +Controls: Z is B, X is A, Enter is Start, Backspace is Select, Arrows are the D-pad, Esc quits. + ## Test -Run the core and assembler tests: +Run the core, assembler, and integration tests: ```text zig build test -Dsdl2=off ``` -The repository also contains build steps for generated fixtures and the Blargg -CPU instruction suite. Add the required fixture files before using those steps. +Run the blargg CPU instruction suite: + +```text +zig build test-blargg +``` + +Regenerate the bundled ROMs from their assembly sources: + +```text +zig build fixtures +``` ## Project layout - `src/cpu.zig` - SM83 CPU implementation -- `src/emulator.zig` - emulator composition and core tests - `src/bus.zig` - memory and device routing +- `src/ppu.zig` - pixel pipeline +- `src/timer.zig` - timer unit +- `src/cartridge.zig` - cartridge and header parsing +- `src/serial.zig` - serial output helpers +- `src/disasm.zig` - disassembler +- `src/emulator.zig` - emulator composition - `src/headless.zig` - command-line ROM runner - `src/main.zig` - SDL2 frontend -- `tools/gbasm.zig` - small assembler for fixture ROMs +- `src/integration.zig` - end-to-end ROM tests +- `tools/gbasm.zig` - SM83 assembler for test ROMs +- `fixtures/` - bundled test ROMs and their sources + +## Sample output + +Run the bundled smoke ROM: + +```text +$ zig build run-headless -- fixtures/roms/smoke.gb --max-cycles 5000000 +Serial output: +PASS +Verdict: pass +``` + +Inspect a cartridge header: + +```text +$ zig build run-headless -- fixtures/roms/smoke.gb --max-cycles 1000 --info +Title: SMOKE TEST +Mapper: ROM only +ROM: 32 KiB +RAM: 0 KiB +CGB: no +SGB: no +Header: valid checksum +Global: $0000 +``` ## Limitations -Hardware coverage is incomplete. Timing accuracy, cartridge support, audio, -and frontend features will improve as the project grows. +Timing accuracy is incomplete. +Cartridge support covers ROM-only and MBC1 mappers. +Audio is not implemented. +The windowed frontend is a basic player; it has no menu bar or save states. ## License -No license file is published yet. Treat this repository as an experimental -project until a license is added. +Dot Matrix Deck is licensed under the MIT License. +See the LICENSE file for details. +The NOTICE file lists the third-party test ROMs used by the test suite. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..9664514 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,75 @@ +# Roadmap + +This document tracks the work on Dot Matrix Deck. +It shows what is complete and what remains. + +## Milestone 1: CPU core + +Status: complete. + +- SM83 CPU with all 256 opcodes +- CB prefix instructions +- Interrupt handling with IME timing +- HALT and STOP behavior +- Stack and register pair operations +- Verified by the blargg CPU instruction suite + +## Milestone 2: Memory and timing + +Status: complete. + +- Full address map with WRAM, HRAM, and echoes +- Timer unit with DIV, TIMA, TMA, and TAC +- MBC1 mapper for banked ROM and RAM +- Serial link output for test verdicts +- Joypad input from the frontend + +## Milestone 3: Pixel pipeline + +Status: complete. + +- LCD register file +- Background rendering with scrolling +- Sprite rendering with priority and palettes +- VBlank and STAT interrupts +- Deterministic frame checksums + +## Milestone 4: Frontends + +Status: complete. + +- SDL2 windowed frontend +- Keyboard controls for the D-pad and buttons +- Headless runner with pass or fail verdicts +- Cartridge header display with the --info flag +- Instruction trace mode + +## Milestone 5: Test infrastructure + +Status: complete. + +- Bundled smoke ROM assembled from source +- Deterministic rebuild check for the smoke ROM +- Golden frame checksum tests +- blargg CPU instruction suite, 11 of 11 passing +- GitHub Actions CI on Linux and Windows +- Formatting enforced by zig fmt + +## Milestone 6: Audio and remaining hardware + +Status: planned. + +- APU and the four sound channels +- OAM DMA transfers +- Joypad interrupt +- Battery save to disk +- Additional mappers: MBC3 and MBC5 + +## Milestone 7: Accuracy and polish + +Status: planned. + +- Cycle-accurate STAT timing +- Boot ROM emulation +- Save state support +- Debugger integration diff --git a/build.zig b/build.zig index 815819d..a45bb8a 100644 --- a/build.zig +++ b/build.zig @@ -17,6 +17,11 @@ fn sdl2PrefixFound(b: *std.Build, prefix: []const u8) bool { return true; } +fn pathExists(b: *std.Build, path: []const u8) bool { + b.build_root.handle.access(b.graph.io, path, .{}) catch return false; + return true; +} + pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); @@ -57,22 +62,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,7 +89,19 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), }); - exe.root_module.linkSystemLibrary("SDL2", .{}); + if (sdl_prefix != null and target.result.os.tag == .windows) { + // Link against the DLL import library directly. The MSYS2 + // static archive references CRT symbols that Zig's runtime + // does not provide. + const import_lib = b.pathJoin(&.{ sdl_prefix.?, "lib", "libSDL2.dll.a" }); + if (pathExists(b, import_lib)) { + exe.root_module.addObjectFile(.{ .cwd_relative = import_lib }); + } else { + exe.root_module.linkSystemLibrary("SDL2", .{}); + } + } else { + exe.root_module.linkSystemLibrary("SDL2", .{}); + } if (target.result.os.tag == .windows) { for (sdl_deps) |lib| exe.root_module.linkSystemLibrary(lib, .{}); } @@ -92,7 +109,7 @@ pub fn build(b: *std.Build) void { 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)) { + if (pathExists(b, dll_path)) { const install_dll = b.addInstallBinFile(.{ .cwd_relative = dll_path }, "bin/SDL2.dll"); b.getInstallStep().dependOn(&install_dll.step); } @@ -131,6 +148,25 @@ pub fn build(b: *std.Build) void { const run_asm_tests = b.addRunArtifact(asm_tests); test_step.dependOn(&run_asm_tests.step); + const gbasm_module = b.createModule(.{ + .root_source_file = b.path("tools/gbasm.zig"), + .target = target, + .optimize = optimize, + }); + + const integration_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/integration.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "gbasm", .module = gbasm_module }, + }, + }), + }); + const run_integration_tests = b.addRunArtifact(integration_tests); + 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..4bb8f28 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .dot_matrix_deck, - .version = "0.1.0", + .version = "0.2.0", .minimum_zig_version = "0.16.0", .fingerprint = 0x3ad14c39cc8e69a1, .paths = .{ @@ -10,9 +10,12 @@ "tools", "fixtures", "README.md", + "ROADMAP.md", + "CONTRIBUTING.md", "LICENSE", "NOTICE", ".github", + ".gitattributes", ".gitignore", }, } 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 0000000..ad3e998 Binary files /dev/null and b/fixtures/blargg/01-special.gb differ diff --git a/fixtures/blargg/02-interrupts.gb b/fixtures/blargg/02-interrupts.gb new file mode 100644 index 0000000..2089594 Binary files /dev/null and b/fixtures/blargg/02-interrupts.gb differ diff --git a/fixtures/blargg/03-op sp,hl.gb b/fixtures/blargg/03-op sp,hl.gb new file mode 100644 index 0000000..50b3cc7 Binary files /dev/null and b/fixtures/blargg/03-op sp,hl.gb differ diff --git a/fixtures/blargg/04-op r,imm.gb b/fixtures/blargg/04-op r,imm.gb new file mode 100644 index 0000000..58ca7b8 Binary files /dev/null and b/fixtures/blargg/04-op r,imm.gb differ diff --git a/fixtures/blargg/05-op rp.gb b/fixtures/blargg/05-op rp.gb new file mode 100644 index 0000000..1c19d92 Binary files /dev/null and b/fixtures/blargg/05-op rp.gb differ diff --git a/fixtures/blargg/06-ld r,r.gb b/fixtures/blargg/06-ld r,r.gb new file mode 100644 index 0000000..d497bfd Binary files /dev/null and b/fixtures/blargg/06-ld r,r.gb differ diff --git a/fixtures/blargg/07-jr,jp,call,ret,rst.gb b/fixtures/blargg/07-jr,jp,call,ret,rst.gb new file mode 100644 index 0000000..5c8d20b Binary files /dev/null and b/fixtures/blargg/07-jr,jp,call,ret,rst.gb differ diff --git a/fixtures/blargg/08-misc instrs.gb b/fixtures/blargg/08-misc instrs.gb new file mode 100644 index 0000000..4da139b Binary files /dev/null and b/fixtures/blargg/08-misc instrs.gb differ diff --git a/fixtures/blargg/09-op r,r.gb b/fixtures/blargg/09-op r,r.gb new file mode 100644 index 0000000..e30e6ec Binary files /dev/null and b/fixtures/blargg/09-op r,r.gb differ diff --git a/fixtures/blargg/10-bit ops.gb b/fixtures/blargg/10-bit ops.gb new file mode 100644 index 0000000..8988458 Binary files /dev/null and b/fixtures/blargg/10-bit ops.gb differ diff --git a/fixtures/blargg/11-op a,(hl).gb b/fixtures/blargg/11-op a,(hl).gb new file mode 100644 index 0000000..0634b7f Binary files /dev/null and b/fixtures/blargg/11-op a,(hl).gb differ diff --git a/fixtures/roms/smoke.gb b/fixtures/roms/smoke.gb new file mode 100644 index 0000000..43d1cfe Binary files /dev/null and b/fixtures/roms/smoke.gb differ diff --git a/src/cartridge.zig b/src/cartridge.zig index 6fe8dae..221569e 100644 --- a/src/cartridge.zig +++ b/src/cartridge.zig @@ -76,3 +76,148 @@ pub const Cartridge = struct { return if (index < self.rom.len) self.rom[index] else 0xff; } }; + +// The cartridge header fields at $0134..$014F. The headless runner prints +// them with --info, and the windowed frontend uses the title for the +// window caption. +pub const Header = struct { + title: [16]u8, + mapper: []const u8, + rom_size_kib: u32, + ram_size_kib: u32, + cgb: bool, + sgb: bool, + header_checksum_ok: bool, + global_checksum: u16, +}; + +fn mapperName(code: u8) []const u8 { + return switch (code) { + 0x00 => "ROM only", + 0x01...0x03 => "MBC1", + 0x05, 0x06 => "MBC2", + 0x0f...0x13 => "MBC3", + 0x19...0x1e => "MBC5", + else => "Unknown", + }; +} + +fn romSizeKib(code: u8) u32 { + return switch (code) { + 0x00 => 32, + 0x01 => 64, + 0x02 => 128, + 0x03 => 256, + 0x04 => 512, + 0x05 => 1024, + 0x06 => 2048, + else => 0, + }; +} + +fn ramSizeKib(code: u8) u32 { + return switch (code) { + 0x01 => 2, + 0x02 => 8, + 0x03 => 32, + 0x04 => 128, + 0x05 => 64, + else => 0, + }; +} + +// Reads the 80-byte header at $0134. The header checksum is the sum of +// bytes $0134..$014D plus $19; the result must wrap to zero. +pub fn readHeader(image: []const u8) Header { + var title = [_]u8{0} ** 16; + if (image.len >= 0x0144) { + const raw = image[0x0134..0x0144]; + var index: usize = 0; + while (index < title.len) : (index += 1) { + const byte = raw[index]; + if (byte < 0x20 or byte == 0x7f) break; + title[index] = byte; + } + } + var checksum: u8 = 0x19; + if (image.len >= 0x014e) { + for (image[0x0134..0x014e]) |byte| checksum +%= byte; + } + const global = if (image.len >= 0x0150) @as(u16, image[0x014f]) << 8 | image[0x014e] else 0; + const mapper = if (image.len >= 0x0148) image[0x0147] else 0; + const cgb = if (image.len >= 0x0144) (image[0x0143] == 0x80 or image[0x0143] == 0xc0) else false; + const sgb = if (image.len >= 0x0147) image[0x0146] == 0x03 else false; + const rom_size = if (image.len >= 0x0149) image[0x0148] else 0; + const ram_size = if (image.len >= 0x014a) image[0x0149] else 0; + return .{ + .title = title, + .mapper = mapperName(mapper), + .rom_size_kib = romSizeKib(rom_size), + .ram_size_kib = ramSizeKib(ram_size), + .cgb = cgb, + .sgb = sgb, + .header_checksum_ok = checksum == 0, + .global_checksum = global, + }; +} + +// Formats the header as a readable block for --info. The result is a +// slice into `buffer`; keep the buffer alive while printing it. +pub fn formatHeader(header: Header, buffer: []u8) []const u8 { + const title = std.mem.sliceTo(&header.title, 0); + const cgb = if (header.cgb) "yes" else "no"; + const sgb = if (header.sgb) "yes" else "no"; + const checksum = if (header.header_checksum_ok) "valid" else "invalid"; + return std.fmt.bufPrint(buffer, "Title: {s}\nMapper: {s}\nROM: {d} KiB\nRAM: {d} KiB\nCGB: {s}\nSGB: {s}\nHeader: {s} checksum\nGlobal: ${X:0>4}\n", .{ + if (title.len == 0) "(none)" else title, + header.mapper, + header.rom_size_kib, + header.ram_size_kib, + cgb, + sgb, + checksum, + header.global_checksum, + }) catch buffer[0..0]; +} + +test "readHeader parses a known ROM image" { + var image: [0x8000]u8 = [_]u8{0} ** 0x8000; + image[0x0147] = 0x01; + image[0x0148] = 0x01; + image[0x0149] = 0x03; + image[0x0143] = 0x80; + image[0x0146] = 0x03; + _ = std.fmt.bufPrint(image[0x0134..0x0144], "TEST ROM", .{}) catch unreachable; + const header = readHeader(&image); + try std.testing.expectEqualStrings("TEST ROM", std.mem.sliceTo(&header.title, 0)); + try std.testing.expectEqualStrings("MBC1", header.mapper); + try std.testing.expectEqual(@as(u32, 64), header.rom_size_kib); + try std.testing.expectEqual(@as(u32, 32), header.ram_size_kib); + try std.testing.expect(header.cgb); + try std.testing.expect(header.sgb); +} + +test "readHeader verifies the header checksum" { + var image: [0x8000]u8 = [_]u8{0} ** 0x8000; + _ = std.fmt.bufPrint(image[0x0134..0x0144], "SMOKE", .{}) catch unreachable; + const before = readHeader(&image); + try std.testing.expect(!before.header_checksum_ok); + var checksum: u8 = 0x19; + for (image[0x0134..0x014d]) |byte| checksum +%= byte; + image[0x014d] = 0 -% checksum; + const after = readHeader(&image); + try std.testing.expect(after.header_checksum_ok); +} + +test "formatHeader prints a readable block" { + var image: [0x8000]u8 = [_]u8{0} ** 0x8000; + _ = std.fmt.bufPrint(image[0x0134..0x0144], "SMOKE", .{}) catch unreachable; + var checksum: u8 = 0x19; + for (image[0x0134..0x014d]) |byte| checksum +%= byte; + image[0x014d] = 0 -% checksum; + var buffer: [256]u8 = undefined; + const text = formatHeader(readHeader(&image), &buffer); + try std.testing.expect(std.mem.indexOf(u8, text, "Title: SMOKE") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "Mapper: ROM only") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "valid checksum") != null); +} diff --git a/src/cpu.zig b/src/cpu.zig index fd32347..2e504b7 100644 --- a/src/cpu.zig +++ b/src/cpu.zig @@ -273,9 +273,18 @@ pub const Cpu = struct { fn setPair(self: *Cpu, pair_index: u2, value: u16) void { switch (pair_index) { - 0 => { self.b = @truncate(value >> 8); self.c = @truncate(value); }, - 1 => { self.d = @truncate(value >> 8); self.e = @truncate(value); }, - 2 => { self.h = @truncate(value >> 8); self.l = @truncate(value); }, + 0 => { + self.b = @truncate(value >> 8); + self.c = @truncate(value); + }, + 1 => { + self.d = @truncate(value >> 8); + self.e = @truncate(value); + }, + 2 => { + self.h = @truncate(value >> 8); + self.l = @truncate(value); + }, else => self.sp = value, } } @@ -484,8 +493,13 @@ pub const Cpu = struct { } fn popPair(self: *Cpu, bus: *Bus, pair_index: u2) u16 { - self.setPair(pair_index, self.pop(bus)); - if (pair_index == 3) self.f &= 0xf0; + const value = self.pop(bus); + if (pair_index == 3) { + self.a = @truncate(value >> 8); + self.f = @as(u8, @truncate(value)) & 0xf0; + } else { + self.setPair(pair_index, value); + } return 12; } @@ -681,3 +695,61 @@ pub const Cpu = struct { return @as(u16, self.a) << 8 | self.f; } }; + +const std = @import("std"); + +test "POP AF restores A and F without touching SP" { + const allocator = std.testing.allocator; + var rom = [_]u8{0} ** 0x8000; + rom[0x0100] = 0xf1; + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + var cpu = Cpu{}; + + cpu.sp = 0xc000; + bus.write(0xc000, 0x5a); + bus.write(0xc001, 0xa3); + _ = cpu.step(&bus); + + try std.testing.expectEqual(@as(u8, 0xa3), cpu.a); + try std.testing.expectEqual(@as(u8, 0x50), cpu.f); + try std.testing.expectEqual(@as(u16, 0xc002), cpu.sp); + try std.testing.expectEqual(@as(u16, 0x0101), cpu.pc); +} + +test "PUSH AF preserves the register pair through the stack" { + const allocator = std.testing.allocator; + var rom = [_]u8{0} ** 0x8000; + rom[0x0100] = 0xf5; + rom[0x0101] = 0xf1; + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + var cpu = Cpu{}; + + cpu.sp = 0xdff0; + cpu.a = 0x4d; + cpu.f = 0x30; + _ = cpu.step(&bus); + _ = cpu.step(&bus); + + try std.testing.expectEqual(@as(u8, 0x4d), cpu.a); + try std.testing.expectEqual(@as(u8, 0x30), cpu.f); + try std.testing.expectEqual(@as(u16, 0xdff0), cpu.sp); +} + +test "POP AF masks the low nibble of F" { + const allocator = std.testing.allocator; + var rom = [_]u8{0} ** 0x8000; + rom[0x0100] = 0xf1; + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + var cpu = Cpu{}; + + cpu.sp = 0xc000; + bus.write(0xc000, 0x7b); + bus.write(0xc001, 0x7d); + _ = cpu.step(&bus); + + try std.testing.expectEqual(@as(u8, 0x7d), cpu.a); + try std.testing.expectEqual(@as(u8, 0x70), cpu.f); +} diff --git a/src/disasm.zig b/src/disasm.zig index 234bc7c..54f158e 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 stack_names = [_][]const u8{ "BC", "DE", "HL", "AF" }; fn r8(index: u8) []const u8 { return r8_names[index & 7]; @@ -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}", .{stack_names[index & 3]}); } fn putPUSH(self: *Writer, index: u8) void { - self.putFmt("PUSH {s}", .{r16_names[index & 3]}); + self.putFmt("PUSH {s}", .{stack_names[index & 3]}); } fn putJPC(self: *Writer, flag: []const u8, hi: u8, lo: u8) void { @@ -140,7 +141,7 @@ const Writer = struct { } fn putLDIMM8(self: *Writer, address: u16, reg: []const u8) void { - self.putFmt("LDH (${X:0>2}),{s}", .{address & 0xff, reg}); + self.putFmt("LDH (${X:0>2}),{s}", .{ address & 0xff, reg }); } fn putLDA16(self: *Writer, hi: u8, lo: u8) void { @@ -381,22 +382,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]; } 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..cc75790 100644 --- a/src/headless.zig +++ b/src/headless.zig @@ -1,5 +1,6 @@ const std = @import("std"); const Emulator = @import("emulator.zig").Emulator; +const cartridge = @import("cartridge.zig"); const serial = @import("serial.zig"); const disasm = @import("disasm.zig"); @@ -10,6 +11,7 @@ const Options = struct { blargg_suite: ?[]const u8 = null, max_cycles: u64 = default_max_cycles, trace: bool = false, + info: bool = false, expect: ?serial.Verdict = null, timeout_ms: u64 = 120_000, }; @@ -18,7 +20,7 @@ fn usage(program: []const u8) void { std.debug.print( \\Dot Matrix Deck - headless runner \\Usage: - \\ {s} [--max-cycles N] [--trace] [--expect pass|fail|any] + \\ {s} [--max-cycles N] [--trace] [--info] [--expect pass|fail|any] \\ {s} --blargg-suite \\ \\Runs a ROM without a window, prints its serial output, and sets the @@ -96,6 +98,11 @@ fn runRom(io: std.Io, allocator: std.mem.Allocator, options: Options) !u8 { const verdict = serial.verdictOf(output); const printable = serial.trim(serial.printable(output)); + if (options.info) { + var buffer: [256]u8 = undefined; + std.debug.print("{s}", .{cartridge.formatHeader(cartridge.readHeader(rom), &buffer)}); + } + if (printable.len != 0) { std.debug.print("Serial output:\n{s}\n", .{printable}); } @@ -180,6 +187,8 @@ pub fn main(init: std.process.Init) !void { std.debug.print("bad cycle count: {s}\n", .{argv[index]}); std.process.exit(2); }; + } else if (std.mem.eql(u8, arg, "--info")) { + options.info = true; } else if (std.mem.eql(u8, arg, "--expect")) { index += 1; if (index >= argv.len) { diff --git a/src/integration.zig b/src/integration.zig new file mode 100644 index 0000000..ebdc8e7 --- /dev/null +++ b/src/integration.zig @@ -0,0 +1,105 @@ +const std = @import("std"); +const Emulator = @import("emulator.zig").Emulator; +const cartridge = @import("cartridge.zig"); +const gbasm = @import("gbasm"); + +// End-to-end checks for the bundled smoke ROM. +// +// The ROM is assembled from its assembly source at test time and compared +// to the committed image. It is then run through the full emulator, and +// its serial verdict and frame checksum are checked against fixed golden +// values. The checksum is stable, so any change in the CPU, the bus, the +// PPU, or the assembler that alters observable output fails the suite. +// +// The fixtures are read from the repository root, which is the working +// directory for `zig build test`. + +const smoke_source_path = "fixtures/asm/smoke.asm"; +const smoke_rom_path = "fixtures/roms/smoke.gb"; + +fn readFixture(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const io = std.testing.io; + const dir = std.Io.Dir.cwd(); + const file = try dir.openFile(io, path, .{}); + defer file.close(io); + const size = try file.length(io); + const buffer = try allocator.alloc(u8, @intCast(size)); + _ = try file.readPositionalAll(io, buffer, 0); + return buffer; +} + +// FNV-1a over the raw frame bytes. +fn frameChecksum(frame: []const u32) u32 { + var hash: u32 = 0x811c9dc5; + for (frame) |pixel| { + const bytes = [4]u8{ + @truncate(pixel), + @truncate(pixel >> 8), + @truncate(pixel >> 16), + @truncate(pixel >> 24), + }; + for (bytes) |byte| { + hash ^= byte; + hash *%= 0x01000193; + } + } + return hash; +} + +test "gbasm rebuilds the committed smoke ROM byte for byte" { + const allocator = std.testing.allocator; + const source = try readFixture(allocator, smoke_source_path); + defer allocator.free(source); + const committed = try readFixture(allocator, smoke_rom_path); + defer allocator.free(committed); + + var assembler = try gbasm.assembleSource(allocator, source); + defer assembler.deinit(); + try std.testing.expect(assembler.errorText() == null); + try std.testing.expectEqualSlices(u8, committed, assembler.image); +} + +test "smoke ROM carries a valid cartridge header" { + const allocator = std.testing.allocator; + const rom = try readFixture(allocator, smoke_rom_path); + defer allocator.free(rom); + + const header = cartridge.readHeader(rom); + try std.testing.expect(header.header_checksum_ok); + try std.testing.expectEqualStrings("SMOKE TEST", std.mem.sliceTo(&header.title, 0)); + try std.testing.expectEqualStrings("ROM only", header.mapper); +} + +test "smoke ROM passes through the full pipeline" { + const allocator = std.testing.allocator; + const rom = try readFixture(allocator, smoke_rom_path); + defer allocator.free(rom); + + var emulator = try Emulator.init(allocator, rom); + defer emulator.deinit(); + emulator.runFrame(1_000_000); + emulator.runFrame(1_000_000); + emulator.runFrame(1_000_000); + try std.testing.expectEqualStrings("PASS\n", emulator.serialOutput()); +} + +test "smoke ROM frame checksum is deterministic" { + const allocator = std.testing.allocator; + const rom = try readFixture(allocator, smoke_rom_path); + defer allocator.free(rom); + + const runFrames = struct { + fn call(alloc: std.mem.Allocator, image: []const u8) !u32 { + var emulator = try Emulator.init(alloc, image); + defer emulator.deinit(); + var frame_index: usize = 0; + while (frame_index < 3) : (frame_index += 1) emulator.runFrame(1_000_000); + return frameChecksum(&emulator.bus.ppu.frame); + } + }.call; + + const first = try runFrames(allocator, rom); + const second = try runFrames(allocator, rom); + try std.testing.expectEqual(first, second); + try std.testing.expectEqual(@as(u32, 0x5CCE8DC5), first); +} diff --git a/src/main.zig b/src/main.zig index 643116a..bcaf3a0 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,222 @@ const std = @import("std"); -pub fn main() !void { - std.debug.print("Dot Matrix Deck: windowed frontend is under construction.\n", .{}); +const c = @cImport({ + @cDefine("SDL_MAIN_HANDLED", "1"); + @cInclude("SDL2/SDL.h"); +}); + +const Emulator = @import("emulator.zig").Emulator; +const cartridge = @import("cartridge.zig"); +const ppu = @import("ppu.zig"); + +// The windowed frontend. It opens an SDL2 window, blits the PPU frame +// through a streaming texture, and maps keyboard input to the joypad. +// The emulation core is shared with the headless runner; only input and +// presentation live here. + +const frame_cycles: u64 = 70_224; +const target_fps: f64 = 59.7275; + +const joypad_bits = struct { + const right: u8 = 1 << 0; + const left: u8 = 1 << 1; + const up: u8 = 1 << 2; + const down: u8 = 1 << 3; + const a: u8 = 1 << 4; + const b: u8 = 1 << 5; + const select: u8 = 1 << 6; + const start: u8 = 1 << 7; +}; + +fn usage(program: []const u8) void { + std.debug.print( + \\Dot Matrix Deck - windowed frontend + \\Usage: + \\ {s} + \\ + \\Controls: + \\ Z B button + \\ X A button + \\ Enter Start + \\ Backspace Select + \\ Arrows D-pad + \\ Esc Quit + \\ + , .{program}); +} + +fn readFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const dir = std.Io.Dir.cwd(); + const file = try dir.openFile(io, path, .{}); + defer file.close(io); + const size = try file.length(io); + const buffer = try allocator.alloc(u8, @intCast(size)); + _ = try file.readPositionalAll(io, buffer, 0); + return buffer; +} + +// Applies a key event to the joypad. Pressed bits are cleared, released +// bits are set, matching the low-active register on the Game Boy. +fn scancodeToButtons(scancode: c.SDL_Scancode, buttons: *u8, pressed: bool) bool { + const bit: ?u8 = switch (scancode) { + c.SDL_SCANCODE_Z => joypad_bits.b, + c.SDL_SCANCODE_X => joypad_bits.a, + c.SDL_SCANCODE_RETURN => joypad_bits.start, + c.SDL_SCANCODE_BACKSPACE => joypad_bits.select, + c.SDL_SCANCODE_RIGHT => joypad_bits.right, + c.SDL_SCANCODE_LEFT => joypad_bits.left, + c.SDL_SCANCODE_UP => joypad_bits.up, + c.SDL_SCANCODE_DOWN => joypad_bits.down, + else => null, + }; + if (bit) |mask| { + if (pressed) { + buttons.* &= ~mask; + } else { + buttons.* |= mask; + } + return true; + } + return false; +} + +const Frontend = struct { + emulator: *Emulator, + window: ?*c.SDL_Window, + renderer: ?*c.SDL_Renderer, + texture: ?*c.SDL_Texture, + + fn fail(message: []const u8) noreturn { + std.debug.print("{s}: {s}\n", .{ message, c.SDL_GetError() }); + std.process.exit(1); + } + + fn init(emulator: *Emulator, scale: u32) Frontend { + _ = c.SDL_SetMainReady(); + if (c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_EVENTS) != 0) fail("SDL_Init failed"); + _ = c.SDL_SetHint(c.SDL_HINT_RENDER_SCALE_QUALITY, "nearest"); + + const width: c_int = @intCast(@as(u32, @intCast(ppu.ScreenWidth)) * scale); + const height: c_int = @intCast(@as(u32, @intCast(ppu.ScreenHeight)) * scale); + + var title_buf: [32]u8 = undefined; + const title = std.fmt.bufPrint(&title_buf, "Dot Matrix Deck", .{}) catch "Dot Matrix Deck"; + + const window = c.SDL_CreateWindow( + title.ptr, + c.SDL_WINDOWPOS_UNDEFINED, + c.SDL_WINDOWPOS_UNDEFINED, + width, + height, + c.SDL_WINDOW_SHOWN, + ) orelse fail("SDL_CreateWindow failed"); + + const renderer = c.SDL_CreateRenderer( + window, + -1, + c.SDL_RENDERER_ACCELERATED | c.SDL_RENDERER_PRESENTVSYNC, + ) orelse c.SDL_CreateRenderer(window, -1, 0) orelse fail("SDL_CreateRenderer failed"); + + const texture = c.SDL_CreateTexture( + renderer, + c.SDL_PIXELFORMAT_ARGB8888, + c.SDL_TEXTUREACCESS_STREAMING, + @intCast(ppu.ScreenWidth), + @intCast(ppu.ScreenHeight), + ) orelse fail("SDL_CreateTexture failed"); + + return .{ .emulator = emulator, .window = window, .renderer = renderer, .texture = texture }; + } + + fn deinit(self: *Frontend) void { + if (self.texture) |texture| c.SDL_DestroyTexture(texture); + if (self.renderer) |renderer| c.SDL_DestroyRenderer(renderer); + if (self.window) |window| c.SDL_DestroyWindow(window); + c.SDL_Quit(); + } + + // Returns true when the loop should stop. + fn handleEvents(self: *Frontend) bool { + var event: c.SDL_Event = undefined; + while (c.SDL_PollEvent(&event) != 0) { + switch (event.type) { + c.SDL_QUIT => return true, + c.SDL_KEYDOWN => { + const handled = scancodeToButtons(event.key.keysym.scancode, &self.emulator.bus.joypad, true); + if (!handled and event.key.keysym.scancode == c.SDL_SCANCODE_ESCAPE) return true; + }, + c.SDL_KEYUP => _ = scancodeToButtons(event.key.keysym.scancode, &self.emulator.bus.joypad, false), + else => {}, + } + } + return false; + } + + fn presentFrame(self: *Frontend) void { + var pixels: ?*anyopaque = null; + var pitch: c_int = 0; + if (c.SDL_LockTexture(self.texture, null, &pixels, &pitch) == 0) { + const dst = @as([*]u8, @ptrCast(pixels.?)); + const src = std.mem.sliceAsBytes(self.emulator.bus.ppu.frame[0..]); + @memcpy(dst[0..src.len], src); + c.SDL_UnlockTexture(self.texture); + } + _ = c.SDL_RenderClear(self.renderer); + _ = c.SDL_RenderCopy(self.renderer, self.texture, null, null); + c.SDL_RenderPresent(self.renderer); + } + + fn run(self: *Frontend, io: std.Io) !void { + var last_frame = std.Io.Clock.Timestamp.now(io, .awake); + const frame_ns: i128 = @intFromFloat(@as(f64, std.time.ns_per_s) / target_fps); + + var running = true; + while (running) { + running = !self.handleEvents(); + if (!running) break; + + self.emulator.runFrame(frame_cycles * 2); + self.presentFrame(); + + const now = std.Io.Clock.Timestamp.now(io, .awake); + const elapsed = last_frame.durationTo(now).raw.nanoseconds; + const slack = frame_ns - elapsed; + if (slack > 0) { + const millis: u32 = @intCast(@divTrunc(slack, std.time.ns_per_ms)); + c.SDL_Delay(millis); + } + last_frame = now; + } + } +}; + +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; + + var it = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator); + defer it.deinit(); + + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(allocator); + while (it.next()) |arg| { + try args.append(allocator, arg); + } + const argv = args.items; + + if (argv.len < 2) { + usage(argv[0]); + std.process.exit(2); + } + + const rom = try readFile(io, allocator, argv[1]); + defer allocator.free(rom); + + var emulator = try Emulator.init(allocator, rom); + defer emulator.deinit(); + + var frontend = Frontend.init(&emulator, 3); + defer frontend.deinit(); + + try frontend.run(io); } diff --git a/tools/gbasm.zig b/tools/gbasm.zig index e81a221..628999c 100644 --- a/tools/gbasm.zig +++ b/tools/gbasm.zig @@ -1,5 +1,929 @@ const std = @import("std"); -pub fn main() !void { - std.debug.print("gbasm: assembler is under construction.\n", .{}); +// gbasm - an SM83 assembler for test ROM sources. +// +// It turns the small assembly dialect used by the bundled test ROMs into +// a Game Boy cartridge image. The build step "zig build fixtures" runs it +// over fixtures/assembler and writes the ROMs into fixtures/roms. The tests in +// src/integration.zig assemble the smoke ROM again and compare the output +// to the committed image byte for byte. +// +// Supported syntax: +// org $0100 set the next address; the gap fills with $00 +// db 1, 2, 3 emit bytes from numbers or double-quoted strings +// dw $1234 emit a 16-bit value, little endian +// ds $10 emit N zero bytes +// label: define a label at the current address +// instruction the SM83 base set plus the CB prefix +// +// Literals use $ for hex, % for binary, or plain decimal. An operand may +// also be a label, or a label plus or minus a constant. The image always +// has the size of a 32 KiB cartridge. SM83 instruction sizes never depend +// on operand values, so pass one sizes the program and pass two encodes +// it with full knowledge of the labels. + +const ImageSize: usize = 0x8000; + +const Parsed = struct { + mnemonic_buf: [8]u8, + mnemonic_len: usize, + operands: [3][]const u8, + count: usize, + + fn mnemonic(self: *const Parsed) []const u8 { + return self.mnemonic_buf[0..self.mnemonic_len]; + } + + fn operand(self: *const Parsed, index: usize) []const u8 { + return self.operands[index]; + } +}; + +fn parseLine(line: []const u8) Parsed { + var rest = std.mem.trimStart(u8, line, " \t"); + // Strip an inline comment: a semicolon outside a double-quoted string. + { + var in_quotes = false; + for (rest, 0..) |ch, i| { + if (ch == '"') in_quotes = !in_quotes; + if (ch == ';' and !in_quotes) { + rest = rest[0..i]; + rest = std.mem.trimEnd(u8, rest, " \t"); + break; + } + } + } + const word_end = std.mem.indexOfAny(u8, rest, " \t") orelse rest.len; + var mnemonic_buf: [8]u8 = undefined; + var mnemonic_len: usize = 0; + for (rest[0..word_end]) |ch| { + if (mnemonic_len < mnemonic_buf.len) { + mnemonic_buf[mnemonic_len] = std.ascii.toUpper(ch); + mnemonic_len += 1; + } + } + rest = std.mem.trimStart(u8, rest[word_end..], " \t"); + + var operands: [3][]const u8 = undefined; + var count: usize = 0; + var start: usize = 0; + var in_quotes = false; + var i: usize = 0; + while (i <= rest.len) : (i += 1) { + const at_end = i == rest.len; + const ch = if (at_end) 0 else rest[i]; + if (ch == '"') in_quotes = !in_quotes; + if ((at_end or ch == ',') and !in_quotes) { + const operand = std.mem.trim(u8, rest[start..i], " \t"); + if (operand.len != 0 and count < operands.len) { + operands[count] = operand; + count += 1; + } + start = i + 1; + } + } + return .{ .mnemonic_buf = mnemonic_buf, .mnemonic_len = mnemonic_len, .operands = operands, .count = count }; +} +pub const Assembler = struct { + allocator: std.mem.Allocator, + image: []u8, + symbols: std.StringHashMap(u16), + message_buffer: [160]u8 = undefined, + message_len: usize = 0, + + pub fn deinit(self: *Assembler) void { + var it = self.symbols.keyIterator(); + while (it.next()) |key| self.allocator.free(key.*); + self.symbols.deinit(); + self.allocator.free(self.image); + } + + pub fn errorText(self: *const Assembler) ?[]const u8 { + if (self.message_len == 0) return null; + return self.message_buffer[0..self.message_len]; + } + + fn fail(self: *Assembler, comptime format: []const u8, args: anytype) void { + if (self.message_len != 0) return; + const text = std.fmt.bufPrint(&self.message_buffer, format, args) catch "assembly error"; + self.message_len = @min(text.len, self.message_buffer.len); + } + fn failAddress(self: *Assembler, address: usize) usize { + self.fail("assembly error near ${X:0>4}", .{address}); + return address; + } + // Resolves an expression to a value. Numbers are $ hex, % binary, or + // decimal. Labels may be followed by "+const" or "-const". + fn evaluate(self: *Assembler, operand: []const u8) ?i32 { + const text = std.mem.trim(u8, operand, " \t"); + if (text.len == 0) return null; + var value: i64 = 0; + var cursor: usize = 0; + var sign: i64 = 1; + if (text[0] == '+') { + cursor = 1; + } else if (text[0] == '-') { + cursor = 1; + sign = -1; + } + const first = text[cursor..]; + const first_term = self.parseTerm(first) orelse return null; + value = sign * first_term; + cursor += termLength(first); + while (cursor < text.len) { + const op = text[cursor]; + if (op != '+' and op != '-') return null; + cursor += 1; + const rest = text[cursor..]; + const term = self.parseTerm(rest) orelse return null; + if (op == '-') value -= term else value += term; + cursor += termLength(rest); + } + if (value < -0x8000 or value > 0xffff) return null; + return @intCast(value); + } + + fn parseTerm(self: *Assembler, text: []const u8) ?i64 { + if (text.len == 0) return null; + const end = std.mem.indexOfAny(u8, text, "+-") orelse text.len; + const term = text[0..end]; + if (term.len == 0) return null; + if (term[0] == '$') return std.fmt.parseInt(i64, term[1..], 16) catch null; + if (term[0] == '%') return std.fmt.parseInt(i64, term[1..], 2) catch null; + if (std.ascii.isDigit(term[0])) return std.fmt.parseInt(i64, term, 10) catch null; + if (self.symbols.get(term)) |address| return address; + return null; + } + + fn termLength(text: []const u8) usize { + return std.mem.indexOfAny(u8, text, "+-") orelse text.len; + } +}; + +fn u8FromChar(ch: u8) ?u8 { + return switch (std.ascii.toUpper(ch)) { + 'B' => 0, + 'C' => 1, + 'D' => 2, + 'E' => 3, + 'H' => 4, + 'L' => 5, + 'A' => 7, + else => null, + }; +} + +fn r16Code(text: []const u8) ?u2 { + return if (std.ascii.eqlIgnoreCase(text, "BC")) 0 else if (std.ascii.eqlIgnoreCase(text, "DE")) 1 else if (std.ascii.eqlIgnoreCase(text, "HL")) 2 else if (std.ascii.eqlIgnoreCase(text, "SP")) 3 else null; +} + +fn stackPairCode(text: []const u8) ?u2 { + return if (std.ascii.eqlIgnoreCase(text, "BC")) 0 else if (std.ascii.eqlIgnoreCase(text, "DE")) 1 else if (std.ascii.eqlIgnoreCase(text, "HL")) 2 else if (std.ascii.eqlIgnoreCase(text, "AF")) 3 else null; +} + +fn condCode(text: []const u8) ?u2 { + return if (std.ascii.eqlIgnoreCase(text, "NZ")) 0 else if (std.ascii.eqlIgnoreCase(text, "Z")) 1 else if (std.ascii.eqlIgnoreCase(text, "NC")) 2 else if (std.ascii.eqlIgnoreCase(text, "C")) 3 else null; +} + +fn isShift(m: []const u8) bool { + return std.mem.eql(u8, m, "RLC") or std.mem.eql(u8, m, "RRC") or std.mem.eql(u8, m, "RL") or std.mem.eql(u8, m, "RR") or std.mem.eql(u8, m, "SLA") or std.mem.eql(u8, m, "SRA") or std.mem.eql(u8, m, "SWAP") or std.mem.eql(u8, m, "SRL"); +} + +fn shiftCode(m: []const u8) u8 { + return if (std.mem.eql(u8, m, "RLC")) 0 else if (std.mem.eql(u8, m, "RRC")) 1 else if (std.mem.eql(u8, m, "RL")) 2 else if (std.mem.eql(u8, m, "RR")) 3 else if (std.mem.eql(u8, m, "SLA")) 4 else if (std.mem.eql(u8, m, "SRA")) 5 else if (std.mem.eql(u8, m, "SWAP")) 6 else 7; +} + +fn isValidLabel(name: []const u8) bool { + if (name.len == 0) return false; + if (!std.ascii.isAlphabetic(name[0]) and name[0] != '_') return false; + for (name) |ch| { + if (!std.ascii.isAlphanumeric(ch) and ch != '_') return false; + } + return true; +} + +// Returns the number of bytes a line occupies, for the sizing pass. +fn lineSize(parsed: Parsed) usize { + const m = parsed.mnemonic(); + if (std.mem.eql(u8, m, "ORG")) return 0; + if (std.mem.eql(u8, m, "DS")) return 2; + if (std.mem.eql(u8, m, "DW")) return parsed.count * 2; + if (std.mem.eql(u8, m, "DB")) { + var size: usize = 0; + for (parsed.operands[0..parsed.count]) |operand| { + if (operand.len >= 2 and operand[0] == '"') { + size += operand.len - 2; + } else { + size += 1; + } + } + return size; + } + if (std.mem.eql(u8, m, "STOP")) return 2; + if (std.mem.eql(u8, m, "JR")) return 2; + if (std.mem.eql(u8, m, "JP")) { + // JP HL is a single-byte instruction. + if (parsed.count == 1 and std.ascii.eqlIgnoreCase(parsed.operand(0), "HL")) return 1; + return 3; + } + if (std.mem.eql(u8, m, "CALL")) return 3; + if (std.mem.eql(u8, m, "LD")) { + if (parsed.count != 2) return 0; + const dest = parsed.operand(0); + const src = parsed.operand(1); + // 16-bit register load. + if (r16Code(dest) != null) return 3; + // Single-byte forms: register to register and the fixed memory + // moves. + if (dest.len == 1 and src.len == 1) return 1; + if (dest.len == 1 and std.ascii.eqlIgnoreCase(src, "(HL)")) return 1; + if (std.ascii.eqlIgnoreCase(dest, "(HL)") and src.len == 1) return 1; + if (std.ascii.eqlIgnoreCase(dest, "A")) { + if (std.ascii.eqlIgnoreCase(src, "(BC)") or std.ascii.eqlIgnoreCase(src, "(DE)") or std.ascii.eqlIgnoreCase(src, "(HL+)") or std.ascii.eqlIgnoreCase(src, "(HL-)") or std.ascii.eqlIgnoreCase(src, "(C)")) return 1; + if (isMem16(src)) return 3; + } + if (std.ascii.eqlIgnoreCase(src, "A")) { + if (std.ascii.eqlIgnoreCase(dest, "(BC)") or std.ascii.eqlIgnoreCase(dest, "(DE)") or std.ascii.eqlIgnoreCase(dest, "(HL+)") or std.ascii.eqlIgnoreCase(dest, "(HL-)") or std.ascii.eqlIgnoreCase(dest, "(C)")) return 1; + if (isMem16(dest)) return 3; + } + if (std.ascii.eqlIgnoreCase(dest, "SP") and std.ascii.eqlIgnoreCase(src, "HL")) return 1; + if (std.ascii.eqlIgnoreCase(dest, "HL") and std.ascii.eqlIgnoreCase(src, "SP")) return 2; + return 2; + } + if (std.mem.eql(u8, m, "LDH")) return 2; + if (isShift(m) or std.mem.eql(u8, m, "BIT") or std.mem.eql(u8, m, "RES") or std.mem.eql(u8, m, "SET")) return 2; + if (std.mem.eql(u8, m, "ADD")) { + if (parsed.count == 2 and std.ascii.eqlIgnoreCase(parsed.operand(0), "SP")) return 2; + if (parsed.count == 2 and std.ascii.eqlIgnoreCase(parsed.operand(0), "HL")) return 1; + if (parsed.count == 1 and parsed.operand(0).len == 1) return 1; + if (parsed.count == 1 and std.ascii.eqlIgnoreCase(parsed.operand(0), "(HL)")) return 1; + if (parsed.count == 2 and std.ascii.eqlIgnoreCase(parsed.operand(0), "A")) { + const second = parsed.operand(1); + if (second.len == 1 or std.ascii.eqlIgnoreCase(second, "(HL)")) return 1; + } + return 2; + } + if (std.mem.eql(u8, m, "ADC") or std.mem.eql(u8, m, "SUB") or std.mem.eql(u8, m, "SBC") or std.mem.eql(u8, m, "AND") or std.mem.eql(u8, m, "XOR") or std.mem.eql(u8, m, "OR") or std.mem.eql(u8, m, "CP")) { + const operand = parsed.operand(parsed.count - 1); + if (operand.len == 1 or std.ascii.eqlIgnoreCase(operand, "(HL)")) return 1; + return 2; + } + return 1; +} + +fn isMem16(text: []const u8) bool { + if (text.len < 4) return false; + if (text[0] != '(' or text[text.len - 1] != ')') return false; + const body = text[1 .. text.len - 1]; + if (std.ascii.eqlIgnoreCase(body, "C")) return false; + if (std.ascii.eqlIgnoreCase(body, "BC") or std.ascii.eqlIgnoreCase(body, "DE") or std.ascii.eqlIgnoreCase(body, "HL")) return false; + return true; +} + +fn stripMem(text: []const u8) []const u8 { + if (text.len >= 2 and text[0] == '(' and text[text.len - 1] == ')') return text[1 .. text.len - 1]; + return text; +} + +fn assemble(allocator: std.mem.Allocator, source: []const u8) !Assembler { + var assembler = Assembler{ + .allocator = allocator, + .image = try allocator.alloc(u8, ImageSize), + .symbols = std.StringHashMap(u16).init(allocator), + }; + errdefer assembler.deinit(); + @memset(assembler.image, 0); + + var address: usize = 0; + + // Pass one: collect labels and compute every address. `ds` needs the + // count, so it is evaluated against the labels gathered so far. + var lines = std.mem.splitScalar(u8, source, '\n'); + while (lines.next()) |raw_line| { + const line = std.mem.trim(u8, raw_line, " \t\r"); + if (line.len == 0) continue; + if (line[0] == ';') continue; + var rest = line; + if (std.mem.indexOfScalar(u8, line, ':')) |colon| { + const name = std.mem.trim(u8, line[0..colon], " \t"); + if (isValidLabel(name)) { + if (assembler.symbols.contains(name)) { + assembler.fail("duplicate label: {s}", .{name}); + break; + } + try assembler.symbols.put(try allocator.dupe(u8, name), @intCast(address)); + rest = std.mem.trimStart(u8, line[colon + 1 ..], " \t"); + if (rest.len == 0) continue; + } + } + if (rest.len >= 2 and (std.mem.startsWith(u8, rest, "org"))) { + const arg = std.mem.trimStart(u8, rest["org".len..], " \t"); + const value = assembler.evaluate(arg) orelse { + assembler.fail("bad org target", .{}); + break; + }; + if (value < 0 or value >= ImageSize) { + assembler.fail("org out of range", .{}); + break; + } + if (@as(usize, @intCast(value)) < address) { + assembler.fail("org goes backwards", .{}); + break; + } + address = @intCast(value); + continue; + } + const parsed = parseLine(rest); + if (std.mem.eql(u8, parsed.mnemonic(), "DS")) { + const count = assembler.evaluate(parsed.operand(0)) orelse { + assembler.fail("bad ds count", .{}); + break; + }; + if (count < 0 or count > 0xffff) { + assembler.fail("ds count out of range", .{}); + break; + } + address += @as(usize, @intCast(count)); + continue; + } + address += lineSize(parsed); + if (address > ImageSize) { + assembler.fail("program past the end of the image", .{}); + break; + } + } + + // Pass two: encode every line with the labels resolved. + if (assembler.errorText() == null) { + address = 0; + lines = std.mem.splitScalar(u8, source, '\n'); + while (lines.next()) |raw_line| { + const line = std.mem.trim(u8, raw_line, " \t\r"); + if (line.len == 0) continue; + if (line[0] == ';') continue; + var rest = line; + if (std.mem.indexOfScalar(u8, line, ':')) |colon| { + const name = std.mem.trim(u8, line[0..colon], " \t"); + if (isValidLabel(name)) { + rest = std.mem.trimStart(u8, line[colon + 1 ..], " \t"); + if (rest.len == 0) continue; + } + } + address = encodeLine(&assembler, address, rest); + if (assembler.errorText() != null) break; + } + } + + return assembler; +} + +// Encodes a single line and returns the new address. +fn encodeLine(assembler: *Assembler, address: usize, line: []const u8) usize { + const parsed = parseLine(line); + const m = parsed.mnemonic(); + var cursor = address; + + if (std.mem.eql(u8, m, "ORG")) { + const value = assembler.evaluate(parsed.operand(0)) orelse return assembler.failAddress(0); + return @intCast(value); + } + if (std.mem.eql(u8, m, "DB")) { + for (parsed.operands[0..parsed.count]) |operand| { + if (operand.len >= 2 and operand[0] == '"') { + for (operand[1 .. operand.len - 1]) |byte| { + assembler.image[cursor] = byte; + cursor += 1; + } + } else { + const value = assembler.evaluate(operand) orelse return assembler.failAddress(cursor); + if (value < -0x80 or value > 0xff) return assembler.failAddress(cursor); + assembler.image[cursor] = @truncate(@as(u32, @bitCast(value))); + cursor += 1; + } + } + return cursor; + } + if (std.mem.eql(u8, m, "DW")) { + for (parsed.operands[0..parsed.count]) |operand| { + const value = assembler.evaluate(operand) orelse return assembler.failAddress(cursor); + if (value < 0 or value > 0xffff) return assembler.failAddress(cursor); + const as_word: u16 = @intCast(value); + assembler.image[cursor] = @truncate(as_word); + assembler.image[cursor + 1] = @truncate(as_word >> 8); + cursor += 2; + } + return cursor; + } + if (std.mem.eql(u8, m, "DS")) { + const value = assembler.evaluate(parsed.operand(0)) orelse return assembler.failAddress(cursor); + if (value < 0 or value > 0xffff) return assembler.failAddress(cursor); + cursor += @as(usize, @intCast(value)); + return cursor; + } + + // Instructions. + if (std.mem.eql(u8, m, "NOP")) { + return emit(assembler, cursor, 0x00); + } else if (std.mem.eql(u8, m, "HALT")) { + return emit(assembler, cursor, 0x76); + } else if (std.mem.eql(u8, m, "STOP")) { + cursor = emit(assembler, cursor, 0x10); + return emit(assembler, cursor, 0x00); + } else if (std.mem.eql(u8, m, "DI")) { + return emit(assembler, cursor, 0xf3); + } else if (std.mem.eql(u8, m, "EI")) { + return emit(assembler, cursor, 0xfb); + } else if (std.mem.eql(u8, m, "DAA")) { + return emit(assembler, cursor, 0x27); + } else if (std.mem.eql(u8, m, "CPL")) { + return emit(assembler, cursor, 0x2f); + } else if (std.mem.eql(u8, m, "SCF")) { + return emit(assembler, cursor, 0x37); + } else if (std.mem.eql(u8, m, "CCF")) { + return emit(assembler, cursor, 0x3f); + } else if (std.mem.eql(u8, m, "RETI")) { + return emit(assembler, cursor, 0xd9); + } else if (std.mem.eql(u8, m, "RET")) { + if (parsed.count == 0) return emit(assembler, cursor, 0xc9); + const cc = condCode(parsed.operand(0)) orelse return assembler.failAddress(cursor); + return emit(assembler, cursor, 0xc0 | @as(u8, cc) << 3); + } else if (std.mem.eql(u8, m, "JR")) { + var cc: u2 = 0; + var unconditional = false; + var operand: []const u8 = undefined; + if (parsed.count == 1) { + operand = parsed.operand(0); + unconditional = true; + } else if (parsed.count == 2) { + cc = condCode(parsed.operand(0)) orelse return assembler.failAddress(cursor); + operand = parsed.operand(1); + } else return assembler.failAddress(cursor); + const target = assembler.evaluate(operand) orelse return assembler.failAddress(cursor); + const offset = target - (@as(i32, @intCast(cursor)) + 2); + if (offset < -128 or offset > 127) return assembler.failAddress(cursor); + cursor = emit(assembler, cursor, if (unconditional) 0x18 else 0x20 | @as(u8, cc) << 3); + return emit(assembler, cursor, @bitCast(@as(i8, @intCast(offset)))); + } else if (std.mem.eql(u8, m, "JP")) { + if (parsed.count == 1 and std.ascii.eqlIgnoreCase(parsed.operand(0), "HL")) { + return emit(assembler, cursor, 0xe9); + } + var cc: u2 = 0; + var unconditional = false; + var operand: []const u8 = undefined; + if (parsed.count == 1) { + operand = parsed.operand(0); + unconditional = true; + } else if (parsed.count == 2) { + cc = condCode(parsed.operand(0)) orelse return assembler.failAddress(cursor); + operand = parsed.operand(1); + } else return assembler.failAddress(cursor); + const target = assembler.evaluate(operand) orelse return assembler.failAddress(cursor); + if (target < 0 or target > 0xffff) return assembler.failAddress(cursor); + const word: u16 = @intCast(target); + cursor = emit(assembler, cursor, if (unconditional) 0xc3 else 0xc2 | @as(u8, cc) << 3); + cursor = emit(assembler, cursor, @truncate(word)); + return emit(assembler, cursor, @truncate(word >> 8)); + } else if (std.mem.eql(u8, m, "CALL")) { + var cc: u2 = 0; + var unconditional = false; + var operand: []const u8 = undefined; + if (parsed.count == 1) { + operand = parsed.operand(0); + unconditional = true; + } else if (parsed.count == 2) { + cc = condCode(parsed.operand(0)) orelse return assembler.failAddress(cursor); + operand = parsed.operand(1); + } else return assembler.failAddress(cursor); + const target = assembler.evaluate(operand) orelse return assembler.failAddress(cursor); + if (target < 0 or target > 0xffff) return assembler.failAddress(cursor); + const word: u16 = @intCast(target); + cursor = emit(assembler, cursor, if (unconditional) 0xcd else 0xc4 | @as(u8, cc) << 3); + cursor = emit(assembler, cursor, @truncate(word)); + return emit(assembler, cursor, @truncate(word >> 8)); + } else if (std.mem.eql(u8, m, "RST")) { + const target = assembler.evaluate(parsed.operand(0)) orelse return assembler.failAddress(cursor); + if (target < 0 or target > 0x38 or (target & 7) != 0) return assembler.failAddress(cursor); + return emit(assembler, cursor, 0xc7 | @as(u8, @intCast(target))); + } else if (std.mem.eql(u8, m, "PUSH") or std.mem.eql(u8, m, "POP")) { + const base: u8 = if (std.mem.eql(u8, m, "PUSH")) 0xc5 else 0xc1; + const rr = stackPairCode(parsed.operand(0)) orelse return assembler.failAddress(cursor); + return emit(assembler, cursor, base | @as(u8, rr) << 4); + } else if (std.mem.eql(u8, m, "LD")) { + return encodeLd(assembler, cursor, parsed); + } else if (std.mem.eql(u8, m, "LDH")) { + return encodeLdh(assembler, cursor, parsed); + } else if (std.mem.eql(u8, m, "INC") or std.mem.eql(u8, m, "DEC")) { + const base_8: u8 = if (std.mem.eql(u8, m, "INC")) 0x04 else 0x05; + const base_16: u8 = if (std.mem.eql(u8, m, "INC")) 0x03 else 0x0b; + const operand = parsed.operand(0); + if (operand.len == 1) { + if (u8FromChar(operand[0])) |r| return emit(assembler, cursor, base_8 | r << 3); + return assembler.failAddress(cursor); + } + if (std.ascii.eqlIgnoreCase(operand, "(HL)")) return emit(assembler, cursor, base_8 | 6 << 3); + if (r16Code(operand)) |rr| return emit(assembler, cursor, base_16 | @as(u8, rr) << 4); + return assembler.failAddress(cursor); + } else if (std.mem.eql(u8, m, "ADD")) { + if (parsed.count == 2 and std.ascii.eqlIgnoreCase(parsed.operand(0), "HL")) { + const rr = r16Code(parsed.operand(1)) orelse return assembler.failAddress(cursor); + return emit(assembler, cursor, 0x09 | @as(u8, rr) << 4); + } + if (parsed.count == 2 and std.ascii.eqlIgnoreCase(parsed.operand(0), "SP")) { + const value = assembler.evaluate(parsed.operand(1)) orelse return assembler.failAddress(cursor); + if (value < -128 or value > 255) return assembler.failAddress(cursor); + cursor = emit(assembler, cursor, 0xe8); + return emit(assembler, cursor, @truncate(@as(u32, @bitCast(value)))); + } + if (parsed.count == 1 or (parsed.count == 2 and std.ascii.eqlIgnoreCase(parsed.operand(0), "A"))) { + return encodeAlu(assembler, cursor, 0, parsed); + } + return assembler.failAddress(cursor); + } else if (std.mem.eql(u8, m, "ADC")) { + return encodeAlu(assembler, cursor, 1, parsed); + } else if (std.mem.eql(u8, m, "SUB")) { + return encodeAlu(assembler, cursor, 2, parsed); + } else if (std.mem.eql(u8, m, "SBC")) { + return encodeAlu(assembler, cursor, 3, parsed); + } else if (std.mem.eql(u8, m, "AND")) { + return encodeAlu(assembler, cursor, 4, parsed); + } else if (std.mem.eql(u8, m, "XOR")) { + return encodeAlu(assembler, cursor, 5, parsed); + } else if (std.mem.eql(u8, m, "OR")) { + return encodeAlu(assembler, cursor, 6, parsed); + } else if (std.mem.eql(u8, m, "CP")) { + return encodeAlu(assembler, cursor, 7, parsed); + } else if (isShift(m)) { + const op = shiftCode(m); + const operand = parsed.operand(0); + var r: u8 = undefined; + if (operand.len == 1) { + r = u8FromChar(operand[0]) orelse return assembler.failAddress(cursor); + } else if (std.ascii.eqlIgnoreCase(operand, "(HL)")) { + r = 6; + } else return assembler.failAddress(cursor); + cursor = emit(assembler, cursor, 0xcb); + return emit(assembler, cursor, op << 3 | r); + } else if (std.mem.eql(u8, m, "BIT") or std.mem.eql(u8, m, "RES") or std.mem.eql(u8, m, "SET")) { + if (parsed.count != 2) return assembler.failAddress(cursor); + const bit = assembler.evaluate(parsed.operand(0)) orelse return assembler.failAddress(cursor); + if (bit < 0 or bit > 7) return assembler.failAddress(cursor); + const operand = parsed.operand(1); + var r: u8 = undefined; + if (operand.len == 1) { + r = u8FromChar(operand[0]) orelse return assembler.failAddress(cursor); + } else if (std.ascii.eqlIgnoreCase(operand, "(HL)")) { + r = 6; + } else return assembler.failAddress(cursor); + const base: u8 = if (std.mem.eql(u8, m, "BIT")) 0x40 else if (std.mem.eql(u8, m, "RES")) 0x80 else 0xc0; + cursor = emit(assembler, cursor, 0xcb); + return emit(assembler, cursor, base | @as(u8, @intCast(bit)) << 3 | r); + } + assembler.fail("unknown mnemonic: {s}", .{m}); + return cursor; +} + +fn emit(assembler: *Assembler, address: usize, byte: u8) usize { + if (address >= ImageSize) { + assembler.fail("output past the end of the image", .{}); + return address; + } + assembler.image[address] = byte; + return address + 1; +} + +fn encodeAlu(assembler: *Assembler, cursor: usize, op: u8, parsed: Parsed) usize { + const operand = parsed.operand(parsed.count - 1); + if (operand.len == 1) { + if (u8FromChar(operand[0])) |r| return emit(assembler, cursor, 0x80 | op << 3 | r); + return assembler.failAddress(cursor); + } + if (std.ascii.eqlIgnoreCase(operand, "(HL)")) return emit(assembler, cursor, 0x80 | op << 3 | 6); + const value = assembler.evaluate(operand) orelse return assembler.failAddress(cursor); + if (value < -0x80 or value > 0xff) return assembler.failAddress(cursor); + const next = emit(assembler, cursor, 0xc6 | op << 3); + return emit(assembler, next, @truncate(@as(u32, @bitCast(value)))); +} + +fn encodeLd(assembler: *Assembler, cursor: usize, parsed: Parsed) usize { + if (parsed.count != 2) return assembler.failAddress(cursor); + const dest = parsed.operand(0); + const src = parsed.operand(1); + + // LD A,(BC) / (DE) / (nn) / (HL) / (HL+) / (HL-) / (C) + if (std.ascii.eqlIgnoreCase(dest, "A") and (src.len > 2 and src[0] == '(')) { + if (std.ascii.eqlIgnoreCase(src, "(BC)")) return emit(assembler, cursor, 0x0a); + if (std.ascii.eqlIgnoreCase(src, "(DE)")) return emit(assembler, cursor, 0x1a); + if (std.ascii.eqlIgnoreCase(src, "(HL)")) return emit(assembler, cursor, 0x7e); + if (std.ascii.eqlIgnoreCase(src, "(HL+)")) return emit(assembler, cursor, 0x2a); + if (std.ascii.eqlIgnoreCase(src, "(HL-)")) return emit(assembler, cursor, 0x3a); + if (std.ascii.eqlIgnoreCase(src, "(C)")) return emit(assembler, cursor, 0xf2); + if (isMem16(src)) { + const value = assembler.evaluate(stripMem(src)) orelse return assembler.failAddress(cursor); + if (value < 0 or value > 0xffff) return assembler.failAddress(cursor); + const word: u16 = @intCast(value); + const b = emit(assembler, cursor, 0xfa); + const w = emit(assembler, b, @truncate(word)); + return emit(assembler, w, @truncate(word >> 8)); + } + return assembler.failAddress(cursor); + } + // LD (BC),A / (DE),A / (HL+),A / (HL-),A / (nn),A + if (src.len == 1 and u8FromChar(src[0]) == 7 and (dest.len > 2 and dest[0] == '(')) { + if (std.ascii.eqlIgnoreCase(dest, "(BC)")) return emit(assembler, cursor, 0x02); + if (std.ascii.eqlIgnoreCase(dest, "(DE)")) return emit(assembler, cursor, 0x12); + if (std.ascii.eqlIgnoreCase(dest, "(HL)")) return emit(assembler, cursor, 0x77); + if (std.ascii.eqlIgnoreCase(dest, "(HL+)")) return emit(assembler, cursor, 0x22); + if (std.ascii.eqlIgnoreCase(dest, "(HL-)")) return emit(assembler, cursor, 0x32); + if (isMem16(dest)) { + const value = assembler.evaluate(stripMem(dest)) orelse return assembler.failAddress(cursor); + if (value < 0 or value > 0xffff) return assembler.failAddress(cursor); + const word: u16 = @intCast(value); + const b = emit(assembler, cursor, 0xea); + const w = emit(assembler, b, @truncate(word)); + return emit(assembler, w, @truncate(word >> 8)); + } + return assembler.failAddress(cursor); + } + // LD r,r ; LD r,n ; LD r,(HL) + if (dest.len == 1) { + const d = u8FromChar(dest[0]) orelse return assembler.failAddress(cursor); + if (src.len == 1) { + const s = u8FromChar(src[0]) orelse return assembler.failAddress(cursor); + return emit(assembler, cursor, 0x40 | d << 3 | s); + } + if (std.ascii.eqlIgnoreCase(src, "(HL)")) return emit(assembler, cursor, 0x40 | d << 3 | 6); + const value = assembler.evaluate(src) orelse return assembler.failAddress(cursor); + if (value < -0x80 or value > 0xff) return assembler.failAddress(cursor); + const next = emit(assembler, cursor, 0x06 | d << 3); + return emit(assembler, next, @truncate(@as(u32, @bitCast(value)))); + } + // LD (HL),r ; LD (HL),n + if (std.ascii.eqlIgnoreCase(dest, "(HL)")) { + if (src.len == 1) { + const s = u8FromChar(src[0]) orelse return assembler.failAddress(cursor); + return emit(assembler, cursor, 0x70 | s); + } + const value = assembler.evaluate(src) orelse return assembler.failAddress(cursor); + if (value < -0x80 or value > 0xff) return assembler.failAddress(cursor); + const next = emit(assembler, cursor, 0x36); + return emit(assembler, next, @truncate(@as(u32, @bitCast(value)))); + } + // LD rr,nn + if (r16Code(dest)) |rr| { + const value = assembler.evaluate(src) orelse return assembler.failAddress(cursor); + if (value < 0 or value > 0xffff) return assembler.failAddress(cursor); + const word: u16 = @intCast(value); + const b = emit(assembler, cursor, 0x01 | @as(u8, rr) << 4); + const w = emit(assembler, b, @truncate(word)); + return emit(assembler, w, @truncate(word >> 8)); + } + // LD SP,HL ; LD HL,SP+e + if (std.ascii.eqlIgnoreCase(dest, "SP") and std.ascii.eqlIgnoreCase(src, "HL")) return emit(assembler, cursor, 0xf9); + if (std.ascii.eqlIgnoreCase(dest, "HL") and std.ascii.eqlIgnoreCase(src, "SP")) { + // LD HL,SP+e requires an explicit + or - offset. + const value = assembler.evaluate(src) orelse return assembler.failAddress(cursor); + if (value < -128 or value > 255) return assembler.failAddress(cursor); + const b = emit(assembler, cursor, 0xf8); + return emit(assembler, b, @truncate(@as(u32, @bitCast(value)))); + } + return assembler.failAddress(cursor); +} + +fn encodeLdh(assembler: *Assembler, cursor: usize, parsed: Parsed) usize { + if (parsed.count != 2) return assembler.failAddress(cursor); + const dest = parsed.operand(0); + const src = parsed.operand(1); + if (std.ascii.eqlIgnoreCase(dest, "A")) { + if (std.ascii.eqlIgnoreCase(src, "(C)")) return emit(assembler, cursor, 0xf2); + const value = assembler.evaluate(stripMem(src)) orelse return assembler.failAddress(cursor); + if (value < 0 or value > 0xff) return assembler.failAddress(cursor); + const byte: u8 = @intCast(value); + const b = emit(assembler, cursor, 0xf0); + return emit(assembler, b, byte); + } + if (std.ascii.eqlIgnoreCase(dest, "(C)") and src.len == 1 and u8FromChar(src[0]) == 7) return emit(assembler, cursor, 0xe2); + const value = assembler.evaluate(stripMem(dest)) orelse return assembler.failAddress(cursor); + if (value < 0 or value > 0xff) return assembler.failAddress(cursor); + const byte: u8 = @intCast(value); + const b = emit(assembler, cursor, 0xe0); + return emit(assembler, b, byte); +} + +// Public entry point used by the integration tests. +pub fn assembleSource(allocator: std.mem.Allocator, source: []const u8) !Assembler { + return assemble(allocator, source); +} + +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, path: []const u8, bytes: []const u8) !void { + 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 usage(program: []const u8) void { + std.debug.print( + \\gbasm - SM83 assembler for test ROMs + \\Usage: + \\ {s} + \\ + \\Assembles every .asm file in the input directory and writes the + \\matching .gb image into the output directory. + \\ + , .{program}); +} + +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; + + var it = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator); + defer it.deinit(); + + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(allocator); + while (it.next()) |arg| { + try args.append(allocator, arg); + } + const argv = args.items; + + if (argv.len < 3) { + usage(argv[0]); + std.process.exit(2); + } + + const input_dir = argv[1]; + const output_dir = argv[2]; + + var dir = try std.Io.Dir.cwd().openDir(io, input_dir, .{ .iterate = true }); + defer dir.close(io); + + var names: std.ArrayList([]const u8) = .empty; + defer names.deinit(allocator); + var iter = dir.iterate(); + while (try iter.next(io)) |entry| { + if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".asm")) { + try names.append(allocator, try allocator.dupe(u8, entry.name)); + } + } + std.mem.sort([]const u8, names.items, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.order(u8, a, b) == .lt; + } + }.lessThan); + + var failures: usize = 0; + for (names.items) |name| { + const base = name[0 .. name.len - 4]; + const output_name = try std.fmt.allocPrint(allocator, "{s}.gb", .{base}); + defer allocator.free(output_name); + const input_path = try std.fs.path.join(allocator, &.{ input_dir, name }); + defer allocator.free(input_path); + const output_path = try std.fs.path.join(allocator, &.{ output_dir, output_name }); + defer allocator.free(output_path); + + const source = readFile(io, allocator, input_path) catch |err| { + std.debug.print("{s}: read error {s}\n", .{ name, @errorName(err) }); + failures += 1; + continue; + }; + defer allocator.free(source); + + var assembler = assemble(allocator, source) catch |err| { + std.debug.print("{s}: error {s}\n", .{ name, @errorName(err) }); + failures += 1; + continue; + }; + defer assembler.deinit(); + if (assembler.errorText()) |message| { + std.debug.print("{s}: {s}\n", .{ name, message }); + failures += 1; + continue; + } + writeFile(io, output_path, assembler.image) catch |err| { + std.debug.print("{s}: write error {s}\n", .{ name, @errorName(err) }); + failures += 1; + continue; + }; + std.debug.print("{s}: {d} bytes\n", .{ name, assembler.image.len }); + allocator.free(name); + } + + std.process.exit(if (failures == 0) 0 else 1); +} + +test "assembler encodes the entry point and a jump" { + const allocator = std.testing.allocator; + const source = + \\ org $0100 + \\ nop + \\ jp start + \\start: + \\ ld a, $05 + \\ ret + ; + var assembler = try assembleSource(allocator, source); + defer assembler.deinit(); + try std.testing.expect(assembler.errorText() == null); + try std.testing.expectEqual(@as(u8, 0x00), assembler.image[0x0100]); + try std.testing.expectEqual(@as(u8, 0xc3), assembler.image[0x0101]); + try std.testing.expectEqual(@as(u8, 0x04), assembler.image[0x0102]); + try std.testing.expectEqual(@as(u8, 0x01), assembler.image[0x0103]); + try std.testing.expectEqual(@as(u8, 0x3e), assembler.image[0x0104]); + try std.testing.expectEqual(@as(u8, 0x05), assembler.image[0x0105]); +} + +test "assembler supports data directives" { + const allocator = std.testing.allocator; + const source = + \\ org $0000 + \\ db $12, $34 + \\ dw $abcd + \\ ds 2 + \\ db "HI", $00 + ; + var assembler = try assembleSource(allocator, source); + defer assembler.deinit(); + try std.testing.expect(assembler.errorText() == null); + try std.testing.expectEqual(@as(u8, 0x12), assembler.image[0]); + try std.testing.expectEqual(@as(u8, 0x34), assembler.image[1]); + try std.testing.expectEqual(@as(u8, 0xcd), assembler.image[2]); + try std.testing.expectEqual(@as(u8, 0xab), assembler.image[3]); + try std.testing.expectEqual(@as(u8, 0x00), assembler.image[4]); + try std.testing.expectEqual(@as(u8, 0x00), assembler.image[5]); + try std.testing.expectEqual(@as(u8, 'H'), assembler.image[6]); + try std.testing.expectEqual(@as(u8, 'I'), assembler.image[7]); +} + +test "assembler computes relative branches" { + const allocator = std.testing.allocator; + const source = + \\ org $0100 + \\ jr skip + \\ nop + \\skip: + \\ halt + ; + var assembler = try assembleSource(allocator, source); + defer assembler.deinit(); + try std.testing.expect(assembler.errorText() == null); + try std.testing.expectEqual(@as(u8, 0x18), assembler.image[0x0100]); + try std.testing.expectEqual(@as(u8, 0x01), assembler.image[0x0101]); +} + +test "assembler detects a duplicate label" { + const allocator = std.testing.allocator; + const source = + \\ org $0100 + \\dup: + \\ nop + \\dup: + \\ halt + ; + var assembler = try assembleSource(allocator, source); + defer assembler.deinit(); + try std.testing.expect(assembler.errorText() != null); +} + +test "assembler encodes the CB prefix" { + const allocator = std.testing.allocator; + const source = + \\ org $0100 + \\ sla a + \\ bit 1, a + \\ res 7, (hl) + \\ set 0, b + ; + var assembler = try assembleSource(allocator, source); + defer assembler.deinit(); + try std.testing.expect(assembler.errorText() == null); + try std.testing.expectEqual(@as(u8, 0xcb), assembler.image[0x0100]); + try std.testing.expectEqual(@as(u8, 0x27), assembler.image[0x0101]); + try std.testing.expectEqual(@as(u8, 0xcb), assembler.image[0x0102]); + try std.testing.expectEqual(@as(u8, 0x4f), assembler.image[0x0103]); + try std.testing.expectEqual(@as(u8, 0xcb), assembler.image[0x0104]); + try std.testing.expectEqual(@as(u8, 0xbe), assembler.image[0x0105]); + try std.testing.expectEqual(@as(u8, 0xcb), assembler.image[0x0106]); + try std.testing.expectEqual(@as(u8, 0xc0), assembler.image[0x0107]); +} + +test "assembler rejects a bad mnemonic" { + const allocator = std.testing.allocator; + const source = + \\ org $0100 + \\ frob a, $12 + ; + var assembler = try assembleSource(allocator, source); + defer assembler.deinit(); + try std.testing.expect(assembler.errorText() != null); }