From 1f10204e8b404c7c12c80c0268e8e264bd0ed061 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 15:18:20 -0700 Subject: [PATCH] feat: extend dot matrix deck --- .github/workflows/ci.yml | 59 +++ .gitignore | 1 + README.md | 138 ++++-- ROADMAP.md | 35 ++ build.zig | 66 ++- build.zig.zon | 2 + fixtures/asm/demo.asm | 264 ++++++++++++ fixtures/roms/demo.gb | Bin 0 -> 32768 bytes fixtures_tests.zig | 36 ++ src/bus.zig | 44 +- src/cpu.zig | 15 +- src/disasm.zig | 42 +- src/emulator.zig | 2 +- src/frontend.zig | 163 ++++++++ src/joypad.zig | 114 +++++ src/main.zig | 235 ++++++++++- src/tests.zig | 13 + tools/gbasm.zig | 879 ++++++++++++++++++++++++++++++++++++++- 18 files changed, 2020 insertions(+), 88 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 ROADMAP.md create mode 100644 fixtures/asm/demo.asm create mode 100644 fixtures/roms/demo.gb create mode 100644 fixtures_tests.zig create mode 100644 src/frontend.zig create mode 100644 src/joypad.zig create mode 100644 src/tests.zig diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..81194a0 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,59 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - name: Format check + shell: ${{ matrix.os == 'windows-latest' && 'pwsh' || 'bash' }} + run: zig fmt --check . + - name: Unit and integration tests + shell: ${{ matrix.os == 'windows-latest' && 'pwsh' || 'bash' }} + run: zig build test -Dsdl2=off --summary all + - name: Regenerate bundled ROMs + shell: ${{ matrix.os == 'windows-latest' && 'pwsh' || 'bash' }} + run: zig build fixtures -Dsdl2=off + - name: Committed fixtures match regeneration + shell: bash + if: ${{ matrix.os == 'ubuntu-latest' }} + run: git diff --exit-code -- fixtures + - name: Demo ROM reports PASS + shell: ${{ matrix.os == 'windows-latest' && 'pwsh' || 'bash' }} + run: zig build run-demo -Dsdl2=off + - name: Build headless tools + shell: ${{ matrix.os == 'windows-latest' && 'pwsh' || 'bash' }} + run: zig build -Dsdl2=off + + windowed: + name: Windowed frontend (ubuntu) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - name: Install SDL2 + run: sudo apt-get update && sudo apt-get install -y libsdl2-dev + - name: Build windowed frontend + run: zig build --summary all diff --git a/.gitignore b/.gitignore index 51bafde..f73f965 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,6 @@ zig-out/ *.exe .DS_Store *.gb +!fixtures/roms/*.gb *.sav *.swp diff --git a/README.md b/README.md index fc59c84..bff07c1 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,139 @@ # Dot Matrix Deck -Dot Matrix Deck is a Game Boy emulator workbench written in Zig. It models the -SM83 CPU, the memory bus, timers, serial output, and the pixel pipeline. A -headless runner makes emulator behavior easy to inspect in scripts and CI. - -## Current status - -This project is under active development. The headless core is the most useful -entry point today. The SDL2 windowed frontend is available when SDL2 is -installed, but it is still being built out. +Dot Matrix Deck is a Game Boy emulator written in Zig. +It emulates the SM83 CPU, the memory bus, the timers, and the pixel pipeline. +The SDL2 frontend shows the screen in a window with a DMG-style shell. +A headless runner verifies the core with test ROMs. ## Features -- SM83 CPU and instruction execution -- Memory bus with timer, serial, and pixel-processing components -- Headless ROM runner with cycle limits and serial verdicts -- Optional instruction trace output -- Small assembler tool for test ROM sources -- Deterministic unit tests for the emulator core and assembler +- Full SM83 instruction set with the CB prefix group. +- Timer, joypad, and pixel-processing devices. +- SDL2 windowed frontend with a dot-matrix presentation. +- Headless ROM runner with cycle caps and serial verdicts. +- Small SM83 assembler for test ROM sources. +- Bundled demo ROM that draws a title and prints PASS. +- Deterministic unit tests for every core module. ## Requirements -- Zig 0.16 or later -- SDL2 for the optional windowed frontend +- Zig 0.16 or later. +- SDL2 for the windowed frontend. -The headless build does not require SDL2. On Windows, the build searches common -MSYS2 and vcpkg prefixes. You can also set `SDL2_DIR` or pass an SDL2 prefix. +The headless tools do not need SDL2. +On Windows, the build searches MSYS2 and vcpkg prefixes. +Set `SDL2_DIR` to choose a prefix. ## Build -Build the headless runner and assembler: +Build the headless tools: ```text zig build -Dsdl2=off ``` -Build with the windowed frontend when SDL2 is available: +Build the windowed frontend: ```text zig build ``` -## Run a ROM +## Test -Run a ROM without a window: +Run all tests: ```text -zig build run-headless -- path/to/rom.gb +zig build test -Dsdl2=off ``` -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. +Regenerate the bundled ROMs: -Run a ROM in the SDL2 frontend: +```text +zig build fixtures -Dsdl2=off +``` + +## Run + +Run the bundled demo headlessly: ```text -zig build run -- path/to/rom.gb +zig build run-demo -Dsdl2=off ``` -## Test +Run the demo in a window: -Run the core and assembler tests: +```text +zig build run -- fixtures/roms/demo.gb +``` + +Run any ROM headlessly: ```text -zig build test -Dsdl2=off +zig build run-headless -- path/to/rom.gb ``` -The repository also contains build steps for generated fixtures and the Blargg -CPU instruction suite. Add the required fixture files before using those steps. +Useful options are `--max-cycles N`, `--trace`, and `--expect pass|fail|any`. + +## Sample output + +This is the output of `zig build run-demo -Dsdl2=off`: -## Project layout +```text +Serial output: +DOT MATRIX DECK +PASS +Verdict: pass +``` -- `src/cpu.zig` - SM83 CPU implementation -- `src/emulator.zig` - emulator composition and core tests -- `src/bus.zig` - memory and device routing -- `src/headless.zig` - command-line ROM runner -- `src/main.zig` - SDL2 frontend -- `tools/gbasm.zig` - small assembler for fixture ROMs +## Window controls + +| Key | Action | +| --- | --- | +| Arrow keys | D-pad | +| Z | A button | +| X | B button | +| Enter | Start | +| Shift | Select | +| P | Pause | +| R | Reset | +| F | Fast forward | +| ESC | Quit | + +## Architecture + +`src/emulator.zig` drives the run loop. +`src/bus.zig` routes reads and writes to the devices. +`src/cpu.zig` executes the SM83 instruction set. +`src/joypad.zig`, `src/timer.zig`, and `src/ppu.zig` model the hardware. +`src/serial.zig` decodes pass and fail verdicts. +`src/disasm.zig` formats instructions for the trace mode. +`src/frontend.zig` and `src/main.zig` form the SDL2 frontend. +`tools/gbasm.zig` assembles SM83 source into ROM images. + +The frontend and the headless runner share the same core. +The core does not depend on SDL2. +All tests run without SDL2. ## Limitations -Hardware coverage is incomplete. Timing accuracy, cartridge support, audio, -and frontend features will improve as the project grows. +Hardware coverage is incomplete. +Timing accuracy is not cycle-perfect. +Cartridge support covers ROM-only and MBC1 images. +Audio is not implemented. +The frontend does not handle save files yet. + +## Test status + +The suite runs 68 checks. +It covers the CPU, timer, PPU, joypad, serial verdicts, and the assembler. +A round-trip test rebuilds the demo ROM and compares it byte for byte. +Continuous integration runs the suite on Ubuntu and Windows. + +## Roadmap + +See [ROADMAP.md](ROADMAP.md) for what is done and what comes next. ## License -No license file is published yet. Treat this repository as an experimental -project until a license is added. +No license is published yet. +Treat this project as experimental. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..75519ac --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,35 @@ +# Roadmap + +This document tracks what the project delivers and what remains. +It is the plan for the next releases. + +## Done + +- SM83 CPU with the full 256-opcode set and the CB prefix group. +- Memory bus with WRAM, HRAM, and hardware I/O registers. +- Timer with DIV, TIMA, TMA, and TAC. +- PPU with background maps, tile data, and sprite rendering. +- Joypad device with column selection and interrupts. +- Cartridge support for ROM-only and MBC1 images. +- Headless ROM runner with cycle caps, traces, and verdicts. +- SDL2 windowed frontend with a DMG-style shell. +- SM83 assembler with labels, data directives, and expressions. +- Bundled demo ROM that draws a dot-matrix title and prints PASS. +- Deterministic unit tests for every core module. +- Continuous integration on Ubuntu and Windows. + +## Next + +- Cycle-accurate timing for the timer and the PPU STAT modes. +- MBC2, MBC3, and MBC5 cartridge support. +- Serial link emulation between two emulator instances. +- Audio processing unit (APU). +- Save game files and battery-backed RAM. +- Configurable key bindings for the windowed frontend. +- A pause and reset debugger overlay. + +## Known limits + +- The core starts at the post-boot state. Boot ROM emulation is out of scope. +- The PPU has no window line counter edge cases yet. +- The SDL2 frontend ships without audio. diff --git a/build.zig b/build.zig index 815819d..ccbc962 100644 --- a/build.zig +++ b/build.zig @@ -17,6 +17,18 @@ fn sdl2PrefixFound(b: *std.Build, prefix: []const u8) bool { return true; } +// Prefers the SDL2 DLL import library so the static MinGW archive is not +// pulled into a native build. MinGW and vcpkg ship different import libs. +fn sdl2ImportLib(b: *std.Build, prefix: []const u8) ?[]const u8 { + const candidates = [_][]const u8{ "libSDL2.dll.a", "SDL2.lib", "SDL2.dll.a" }; + for (candidates) |name| { + const path = b.pathJoin(&.{ prefix, "lib", name }); + b.build_root.handle.access(b.graph.io, path, .{}) catch continue; + return path; + } + return null; +} + pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); @@ -57,22 +69,22 @@ pub fn build(b: *std.Build) void { }; if (build_windowed) { - var sdl_prefix: ?[]const u8 = null; - if (sdl2_opt) |value| { - if (value.len != 0 and !std.mem.eql(u8, value, "off")) sdl_prefix = value; - } else { - if (b.graph.environ_map.get("SDL2_DIR")) |dir| { - if (dir.len != 0) sdl_prefix = dir; - } - if (sdl_prefix == null and target.result.os.tag == .windows) { + var sdl_prefix: ?[]const u8 = null; + if (sdl2_opt) |value| { + if (value.len != 0 and !std.mem.eql(u8, value, "off")) sdl_prefix = value; + } else { + if (b.graph.environ_map.get("SDL2_DIR")) |dir| { + if (dir.len != 0) sdl_prefix = dir; + } + if (sdl_prefix == null and target.result.os.tag == .windows) { for (sdl_prefix_candidates) |candidate| { if (sdl2PrefixFound(b, candidate)) { sdl_prefix = candidate; break; } } + } } - } if (sdl_prefix != null or target.result.os.tag != .windows) { const exe = b.addExecutable(.{ @@ -84,19 +96,32 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), }); - exe.root_module.linkSystemLibrary("SDL2", .{}); if (target.result.os.tag == .windows) { for (sdl_deps) |lib| exe.root_module.linkSystemLibrary(lib, .{}); } + var sdl2_linked = false; if (sdl_prefix) |prefix| { exe.root_module.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ prefix, "include" }) }); exe.root_module.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ prefix, "lib" }) }); + // Link the DLL import library directly. The generic search + // may otherwise select the static MinGW archive, which pulls + // MinGW-only CRT symbols into a native build. + if (target.result.os.tag == .windows) { + if (sdl2ImportLib(b, prefix)) |import_lib| { + exe.root_module.addObjectFile(.{ .cwd_relative = import_lib }); + sdl2_linked = true; + } + } const dll_path = b.pathJoin(&.{ prefix, "bin", "SDL2.dll" }); if (sdl2PrefixFound(b, prefix)) { - const install_dll = b.addInstallBinFile(.{ .cwd_relative = dll_path }, "bin/SDL2.dll"); + // Dest is relative to zig-out/bin, next to the frontend. + const install_dll = b.addInstallBinFile(.{ .cwd_relative = dll_path }, "SDL2.dll"); b.getInstallStep().dependOn(&install_dll.step); } } + if (!sdl2_linked) { + exe.root_module.linkSystemLibrary("SDL2", .{ .preferred_link_mode = .dynamic }); + } b.installArtifact(exe); const run_cmd = b.addRunArtifact(exe); @@ -112,7 +137,7 @@ pub fn build(b: *std.Build) void { const core_tests = b.addTest(.{ .root_module = b.createModule(.{ - .root_source_file = b.path("src/emulator.zig"), + .root_source_file = b.path("src/tests.zig"), .target = target, .optimize = optimize, }), @@ -131,6 +156,16 @@ pub fn build(b: *std.Build) void { const run_asm_tests = b.addRunArtifact(asm_tests); test_step.dependOn(&run_asm_tests.step); + const fixture_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("fixtures_tests.zig"), + .target = target, + .optimize = optimize, + }), + }); + const run_fixture_tests = b.addRunArtifact(fixture_tests); + test_step.dependOn(&run_fixture_tests.step); + const fixtures_step = b.step("fixtures", "Regenerate bundled test ROMs from their assembly sources"); { const run = b.addRunArtifact(gbasm); @@ -139,6 +174,13 @@ pub fn build(b: *std.Build) void { fixtures_step.dependOn(&run.step); } + const run_demo = b.addRunArtifact(headless); + run_demo.addFileArg(b.path("fixtures/roms/demo.gb")); + run_demo.addArg("--expect"); + run_demo.addArg("pass"); + const demo_step = b.step("run-demo", "Run the bundled demo ROM headlessly and check it reports PASS"); + demo_step.dependOn(&run_demo.step); + const blargg_step = b.step("test-blargg", "Run the blargg cpu_instrs ROMs in headless mode and check their serial output"); { const run = b.addRunArtifact(headless); diff --git a/build.zig.zon b/build.zig.zon index 530eafc..97d2237 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -9,7 +9,9 @@ "src", "tools", "fixtures", + "fixtures_tests.zig", "README.md", + "ROADMAP.md", "LICENSE", "NOTICE", ".github", diff --git a/fixtures/asm/demo.asm b/fixtures/asm/demo.asm new file mode 100644 index 0000000..77bfe24 --- /dev/null +++ b/fixtures/asm/demo.asm @@ -0,0 +1,264 @@ +; demo.asm - a self-contained showcase ROM for dot-matrix-deck. +; +; The ROM turns on the LCD, draws the "DOT MATRIX / DECK" title with a +; dot-matrix font, scrolls the background, and bounces a bright dot. +; It prints "PASS" over the serial port after 64 frames so the headless +; runner can verify the core. Regenerate demo.gb with `zig build fixtures`. + + ORG $0000 + + ORG $0100 + NOP + JP init + + ORG $0134 + DB "DOT MATRIX DECK" ; cartridge title + ORG $0143 + DB $80 ; CGB flag + ORG $0144 + DB $00 ; licensee + ORG $0147 + DB $00 ; ROM only + ORG $0148 + DB $00 ; 32KB ROM + ORG $0149 + DB $00 ; no external RAM + ORG $014C + DB $33 ; header checksum + ORG $014E + DB $00, $00 ; version + ORG $0150 + +init: + ; Disable the LCD while writing video memory. + LD A,$00 + LDH ($40),A + + ; Copy the tile set into VRAM. Each tile is 8 data bytes followed by + ; eight zero bytes; the loop interleaves the hi bytes. + LD HL,tile_data + LD DE,$8000 + LD B,$78 ; 15 tiles * 8 bytes +copy_tiles: + LD A,(HL+) + LD (DE),A + INC DE + LD A,$00 + LD (DE),A + INC DE + DEC B + JR NZ,copy_tiles + + ; Fill the background map with the checker tile. Keep the fill value + ; in E because A is clobbered by the loop counter check. + LD HL,$9800 + LD E,$01 + LD BC,$0400 ; 32 * 32 cells +fill_map: + LD A,E + LD (HL),A + INC HL + DEC BC + LD A,B + OR C + JR NZ,fill_map + + ; Draw the two title lines from encoded text rows. + LD HL,text_row1 + CALL draw_text + LD HL,text_row2 + CALL draw_text + + ; Seed the bouncing dot and enable the LCD. + LD A,$0A + LD ($C001),A ; dot_x + LD ($C006),A ; prev_x + LD A,$09 + LD ($C002),A ; dot_y + LD ($C007),A ; prev_y + LD A,$01 + LD ($C003),A ; dot_dx + LD ($C004),A ; dot_dy + LD A,$91 + LDH ($40),A + + LD HL,msg_title + CALL print_string + +frame_loop: + ; Wait for the vertical blank to pace one step per frame. +wait_vblank: + LDH A,($44) + CP $90 + JR NZ,wait_vblank + + ; Scroll the background one pixel down. + LD A,($C000) + INC A + LD ($C000),A + LDH ($42),A + + ; Erase the previous dot position. + LD A,($C006) + LD E,A + LD A,($C007) + LD D,A + CALL map_addr + LD A,$01 + LD (HL),A + + ; Flip the X direction at the left and right edges. + LD A,($C001) + CP $13 + JR C,no_flip_x + LD A,$FF + LD ($C003),A + JR move_dot_x +no_flip_x: + LD A,($C001) + OR A + JR NZ,move_dot_x + LD A,$01 + LD ($C003),A +move_dot_x: + LD A,($C003) + LD B,A + LD A,($C001) + ADD A,B + LD ($C001),A + + ; Flip the Y direction at the top and bottom edges. + LD A,($C002) + CP $11 + JR C,no_flip_y + LD A,$FF + LD ($C004),A + JR move_dot_y +no_flip_y: + LD A,($C002) + OR A + JR NZ,move_dot_y + LD A,$01 + LD ($C004),A +move_dot_y: + LD A,($C004) + LD B,A + LD A,($C002) + ADD A,B + LD ($C002),A + + ; Draw the dot at its new position. +draw_dot: + LD A,($C002) + LD D,A + LD A,($C001) + LD E,A + CALL map_addr + LD A,$02 + LD (HL),A + + ; Remember the new position so the next frame can erase it. + LD A,($C001) + LD ($C006),A + LD A,($C002) + LD ($C007),A + + ; After 64 frames print the verdict once and keep animating. + LD A,($C005) + INC A + LD ($C005),A + CP $40 + JP NZ,frame_loop + LD A,($C008) + OR A + JP NZ,frame_loop + LD A,$01 + LD ($C008),A + LD HL,msg_pass + CALL print_string + JP frame_loop + +; Converts a map cell coordinate in (D=row, E=col) to an address in HL. +map_addr: + LD H,$00 + LD L,D + ADD HL,HL + ADD HL,HL + ADD HL,HL + ADD HL,HL + ADD HL,HL + LD A,E + ADD A,L + LD L,A + LD A,H + ADD A,$98 + LD H,A + RET + +; Draws an encoded text row. The row starts with row and column bytes, +; then tile indices, and ends with a zero byte. +draw_text: + LD A,(HL+) + LD D,A + LD A,(HL+) + LD E,A + PUSH HL + CALL map_addr + POP DE +draw_char_loop: + LD A,(DE) + OR A + JR Z,draw_done + LD (HL),A + INC HL + INC DE + JR draw_char_loop +draw_done: + RET + +; Prints a zero-terminated string pointed to by HL over the serial port. +print_string: + LD A,(HL+) + OR A + RET Z + CALL print_char + JR print_string + +print_char: + LDH ($01),A + LD A,$81 + LDH ($02),A + RET + +text_row1: + DB $03, $05, $03, $04, $05, $00, $06, $07, $05, $08, $09, $0A, $00 + +text_row2: + DB $06, $08, $03, $0B, $0C, $0D, $00 + +msg_title: + DB "DOT MATRIX DECK", $0D, $0A, $00 + +msg_pass: + DB "PASS", $0D, $0A, $00 + +; Tile 0 is blank, tile 1 is the checker, tile 2 is the dot, and tiles +; 3 through 14 are the dot-matrix font glyphs for the title. +tile_data: + DB $00, $00, $00, $00, $00, $00, $00, $00 ; tile 0 blank + DB $AA, $55, $AA, $55, $AA, $55, $AA, $55 ; tile 1 checker + DB $00, $00, $18, $3C, $3C, $18, $00, $00 ; tile 2 dot + DB $F0, $88, $88, $88, $88, $88, $F0, $00 ; tile 3 'D' + DB $70, $88, $88, $88, $88, $88, $70, $00 ; tile 4 'O' + DB $F8, $20, $20, $20, $20, $20, $20, $00 ; tile 5 'T' + DB $88, $D8, $A8, $A8, $88, $88, $88, $00 ; tile 6 'M' + DB $70, $88, $88, $F8, $88, $88, $88, $00 ; tile 7 'A' + DB $F0, $88, $88, $F0, $A0, $90, $88, $00 ; tile 8 'R' + DB $F8, $20, $20, $20, $20, $20, $F8, $00 ; tile 9 'I' + DB $88, $88, $50, $20, $50, $88, $88, $00 ; tile 10 'X' + DB $F8, $80, $80, $F0, $80, $80, $F8, $00 ; tile 11 'E' + DB $70, $88, $80, $80, $80, $88, $70, $00 ; tile 12 'C' + DB $88, $90, $A0, $C0, $A0, $90, $88, $00 ; tile 13 'K' + DB $00, $00, $00, $00, $00, $00, $00, $00 ; tile 14 space + + PAD $8000 diff --git a/fixtures/roms/demo.gb b/fixtures/roms/demo.gb new file mode 100644 index 0000000000000000000000000000000000000000..e22ad9555b6c08a0ed0e28b259f1a4746916c940 GIT binary patch literal 32768 zcmeIuF=!J}9LMqhy}L` ti;2;+YqFG%W!qI9(M8setam!e6X(m&s!-)uoQ`|Z@ z6zovkoU%SFh#4}tIc9OWDU%*IME@_gi<@)5|ATw?-u>SHJw76ryw1Ab{|a;-_o7ET zy(bTzMV)(hA0`+3Z|O%Y^X=vbvmr_8ptV_#WwRbeKbvxN&GRHU9BtJOK1F%+g_-qC z^R;`&)y%V5$;NA$v02&1Ugp~@u<<+X^w!Mw%%Za#M@1pFor#^_DN1%wlx=_3F|ju) zJiDylF2}!hSEHu0NffSH6u!N$(?q)$#x6HjnZRYtc_y$$pc$hX<1&4X4_sH{y6Tm5 zO)pf0dT?l$+n-OoqGBgH=m8bmeD5xIcH&)^=Qmv)zS)2IbC|zw9mJzlN;e^ ztKRrEZB4$;_Dv(7d-1Eenfjq0gi 1000); + + while (frame_index < 80) : (frame_index += 1) emulator.runFrame(1 << 24); + try std.testing.expect(std.mem.indexOf(u8, emulator.serialOutput(), "PASS") != null); +} diff --git a/src/bus.zig b/src/bus.zig index ea87095..8494b35 100644 --- a/src/bus.zig +++ b/src/bus.zig @@ -1,15 +1,16 @@ const Cartridge = @import("cartridge.zig").Cartridge; +const Joypad = @import("joypad.zig").Joypad; const Ppu = @import("ppu.zig").Ppu; const Timer = @import("timer.zig").Timer; pub const Bus = struct { allocator: @import("std").mem.Allocator, cartridge: Cartridge, + joypad: Joypad = .{}, ppu: Ppu = .{}, timer: Timer = .{}, wram: [0x2000]u8 = [_]u8{0} ** 0x2000, hram: [0x7f]u8 = [_]u8{0} ** 0x7f, - joypad: u8 = 0xcf, sb: u8 = 0, sc: u8 = 0, iflag: u8 = 0xe1, @@ -33,7 +34,7 @@ pub const Bus = struct { if (address >= 0xfe00 and address < 0xfea0) return self.ppu.read(address); if (address >= 0xff80 and address < 0xffff) return self.hram[address - 0xff80]; return switch (address) { - 0xff00 => self.joypad, + 0xff00 => self.joypad.read(), 0xff01 => self.sb, 0xff02 => self.sc, 0xff0f => self.iflag | 0xe0, @@ -70,7 +71,7 @@ pub const Bus = struct { return; } switch (address) { - 0xff00 => self.joypad = (self.joypad & 0xcf) | (value & 0x30), + 0xff00 => self.joypad.write(value), 0xff01 => self.sb = value, 0xff02 => { self.sc = value; @@ -104,7 +105,44 @@ pub const Bus = struct { self.iflag &= ~(@as(u8, 1) << bit); } + // A button press edge raises the joypad interrupt (IF bit 4). + pub fn setButton(self: *Bus, bit: Joypad.Button, pressed: bool) void { + const was_pressed = self.joypad.isButtonPressed(bit); + self.joypad.setButton(bit, pressed); + if (pressed and !was_pressed) self.iflag |= 0x10; + } + + // A direction press edge raises the joypad interrupt (IF bit 4). + pub fn setDirection(self: *Bus, bit: Joypad.Direction, pressed: bool) void { + const was_pressed = self.joypad.isDirectionPressed(bit); + self.joypad.setDirection(bit, pressed); + if (pressed and !was_pressed) self.iflag |= 0x10; + } + pub fn serialOutput(self: *const Bus) []const u8 { return self.serial[0..self.serial_len]; } }; + +test "joypad reads the selected button column" { + const allocator = @import("std").testing.allocator; + const rom = [_]u8{0} ** 0x8000; + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + bus.write(0xff00, 0x20); + bus.setButton(@import("joypad.zig").Joypad.Button.a, true); + try @import("std").testing.expectEqual(@as(u8, 0x0e), bus.read(0xff00) & 0x0f); +} + +test "joypad press edge raises the joypad interrupt" { + const allocator = @import("std").testing.allocator; + const rom = [_]u8{0} ** 0x8000; + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + bus.write(0xff00, 0x10); + bus.setDirection(@import("joypad.zig").Joypad.Direction.right, true); + try @import("std").testing.expectEqual(@as(u8, 0x10), bus.iflag & 0x10); + bus.setDirection(@import("joypad.zig").Joypad.Direction.right, false); + bus.setDirection(@import("joypad.zig").Joypad.Direction.right, true); + try @import("std").testing.expectEqual(@as(u8, 0x10), bus.iflag & 0x10); +} diff --git a/src/cpu.zig b/src/cpu.zig index fd32347..7566dd6 100644 --- a/src/cpu.zig +++ b/src/cpu.zig @@ -273,9 +273,18 @@ pub const Cpu = struct { fn setPair(self: *Cpu, pair_index: u2, value: u16) void { switch (pair_index) { - 0 => { self.b = @truncate(value >> 8); self.c = @truncate(value); }, - 1 => { self.d = @truncate(value >> 8); self.e = @truncate(value); }, - 2 => { self.h = @truncate(value >> 8); self.l = @truncate(value); }, + 0 => { + self.b = @truncate(value >> 8); + self.c = @truncate(value); + }, + 1 => { + self.d = @truncate(value >> 8); + self.e = @truncate(value); + }, + 2 => { + self.h = @truncate(value >> 8); + self.l = @truncate(value); + }, else => self.sp = value, } } diff --git a/src/disasm.zig b/src/disasm.zig index 234bc7c..f68d9d9 100644 --- a/src/disasm.zig +++ b/src/disasm.zig @@ -140,7 +140,7 @@ const Writer = struct { } fn putLDIMM8(self: *Writer, address: u16, reg: []const u8) void { - self.putFmt("LDH (${X:0>2}),{s}", .{address & 0xff, reg}); + self.putFmt("LDH (${X:0>2}),{s}", .{ address & 0xff, reg }); } fn putLDA16(self: *Writer, hi: u8, lo: u8) void { @@ -381,22 +381,22 @@ fn instructionLen(opcode: u8) usize { fn cyclesOf(opcode: u8) u8 { const table = [_]u8{ - 4, 12, 8, 8, 4, 4, 8, 4, 20, 8, 8, 8, 4, 4, 8, 4, - 4, 12, 8, 8, 4, 4, 8, 4, 12, 8, 8, 8, 4, 4, 8, 4, - 8, 12, 8, 8, 4, 4, 8, 4, 12, 8, 8, 8, 4, 4, 8, 4, - 8, 12, 8, 8, 12, 12, 12, 4, 12, 8, 8, 8, 4, 4, 8, 4, - 4, 4, 4, 4, 4, 4, 8, 4, 4, 4, 4, 4, 4, 4, 8, 4, - 4, 4, 4, 4, 4, 4, 8, 4, 4, 4, 4, 4, 4, 4, 8, 4, - 8, 8, 8, 8, 8, 8, 4, 8, 4, 4, 4, 4, 4, 4, 8, 4, - 8, 8, 8, 8, 8, 8, 4, 8, 4, 4, 4, 4, 4, 4, 8, 4, - 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, - 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, - 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, - 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, - 12, 12, 8, 4, 16, 16, 8, 16, 20, 16, 16, 4, 16, 24, 8, 16, - 12, 12, 8, 4, 16, 16, 8, 16, 20, 16, 16, 4, 16, 24, 8, 16, - 12, 12, 8, 4, 16, 16, 20, 16, 8, 16, 16, 4, 16, 24, 8, 16, - 12, 12, 8, 4, 16, 16, 4, 16, 8, 16, 16, 4, 16, 24, 8, 16, + 4, 12, 8, 8, 4, 4, 8, 4, 20, 8, 8, 8, 4, 4, 8, 4, + 4, 12, 8, 8, 4, 4, 8, 4, 12, 8, 8, 8, 4, 4, 8, 4, + 8, 12, 8, 8, 4, 4, 8, 4, 12, 8, 8, 8, 4, 4, 8, 4, + 8, 12, 8, 8, 12, 12, 12, 4, 12, 8, 8, 8, 4, 4, 8, 4, + 4, 4, 4, 4, 4, 4, 8, 4, 4, 4, 4, 4, 4, 4, 8, 4, + 4, 4, 4, 4, 4, 4, 8, 4, 4, 4, 4, 4, 4, 4, 8, 4, + 8, 8, 8, 8, 8, 8, 4, 8, 4, 4, 4, 4, 4, 4, 8, 4, + 8, 8, 8, 8, 8, 8, 4, 8, 4, 4, 4, 4, 4, 4, 8, 4, + 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, + 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, + 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, + 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, + 12, 12, 8, 4, 16, 16, 8, 16, 20, 16, 16, 4, 16, 24, 8, 16, + 12, 12, 8, 4, 16, 16, 8, 16, 20, 16, 16, 4, 16, 24, 8, 16, + 12, 12, 8, 4, 16, 16, 20, 16, 8, 16, 16, 4, 16, 24, 8, 16, + 12, 12, 8, 4, 16, 16, 4, 16, 8, 16, 16, 4, 16, 24, 8, 16, }; return table[opcode]; } @@ -419,14 +419,14 @@ test "disassembler formats representative instructions" { const a = formatAt(&bus, 0x0100); try std.testing.expectEqual(@as(usize, 3), a.len); - try std.testing.expectEqualStrings("LD HL,$1234", std.mem.trimRight(u8, &a.text, "\x00")); + try std.testing.expectEqualStrings("LD HL,$1234", std.mem.trimEnd(u8, &a.text, "\x00")); const b = formatAt(&bus, 0x0103); - try std.testing.expectEqualStrings("LD A,$AA", std.mem.trimRight(u8, &b.text, "\x00")); + try std.testing.expectEqualStrings("LD A,$AA", std.mem.trimEnd(u8, &b.text, "\x00")); const c = formatAt(&bus, 0x0105); - try std.testing.expectEqualStrings("RLC C", std.mem.trimRight(u8, &c.text, "\x00")); + try std.testing.expectEqualStrings("RLC C", std.mem.trimEnd(u8, &c.text, "\x00")); const d = formatAt(&bus, 0x0107); - try std.testing.expectEqualStrings("JP $1000", std.mem.trimRight(u8, &d.text, "\x00")); + try std.testing.expectEqualStrings("JP $1000", std.mem.trimEnd(u8, &d.text, "\x00")); } diff --git a/src/emulator.zig b/src/emulator.zig index 4639f75..370d70d 100644 --- a/src/emulator.zig +++ b/src/emulator.zig @@ -41,7 +41,7 @@ pub const Emulator = struct { pub fn runFrame(self: *Emulator, cap: u64) void { var remaining = cap; while (!self.bus.ppu.takeFrame()) { - self.step(); + _ = self.step(); remaining -= 1; if (remaining == 0) return; } diff --git a/src/frontend.zig b/src/frontend.zig new file mode 100644 index 0000000..f664cdb --- /dev/null +++ b/src/frontend.zig @@ -0,0 +1,163 @@ +// Frontend presentation logic that does not depend on SDL2. +// +// The windowed frontend maps keyboard keys to the joypad, lays out the +// window, and shades the LCD palette. Keeping this logic free of SDL2 +// lets the unit tests run in headless builds. + +const std = @import("std"); +const Bus = @import("bus.zig").Bus; +const Joypad = @import("joypad.zig").Joypad; +const ppu = @import("ppu.zig"); + +pub const screen_scale = 3; +pub const bezel_padding = 16; + +pub const window_width = ppu.ScreenWidth * screen_scale + bezel_padding * 2; +pub const window_height = ppu.ScreenHeight * screen_scale + bezel_padding * 2; + +pub const screen_rect = struct { + pub const x: c_int = bezel_padding; + pub const y: c_int = bezel_padding; + pub const width: c_int = ppu.ScreenWidth * screen_scale; + pub const height: c_int = ppu.ScreenHeight * screen_scale; +}; + +// Logical keys. The SDL2 frontend translates scancodes into these. +pub const Key = enum { + right, + left, + up, + down, + a, + b, + select, + start, + pause, + reset, + fast_forward, +}; + +pub const key_count = @typeInfo(Key).@"enum".fields.len; + +pub const KeyAction = enum { button, direction }; + +pub const Binding = struct { + key: Key, + action: KeyAction, + bit: u3, +}; + +pub const bindings = [_]Binding{ + .{ .key = .a, .action = .button, .bit = 0 }, + .{ .key = .b, .action = .button, .bit = 1 }, + .{ .key = .select, .action = .button, .bit = 2 }, + .{ .key = .start, .action = .button, .bit = 3 }, + .{ .key = .right, .action = .direction, .bit = 0 }, + .{ .key = .left, .action = .direction, .bit = 1 }, + .{ .key = .up, .action = .direction, .bit = 2 }, + .{ .key = .down, .action = .direction, .bit = 3 }, +}; + +pub const RunState = struct { + paused: bool = false, + fast_forward: bool = false, + running: bool = true, + + pub fn togglePause(self: *RunState) void { + self.paused = !self.paused; + } + + pub fn toggleFastForward(self: *RunState) void { + self.fast_forward = !self.fast_forward; + } +}; + +// Applies one key event to the bus. Returns false for control keys that +// the frontend must handle itself. +pub fn applyKey(bus: *Bus, key: Key, pressed: bool) bool { + if (key == .pause or key == .reset or key == .fast_forward) return false; + for (bindings) |binding| { + if (binding.key == key) { + switch (binding.action) { + .button => bus.setButton(@enumFromInt(binding.bit), pressed), + .direction => bus.setDirection(@enumFromInt(binding.bit), pressed), + } + return true; + } + } + return false; +} + +// DMG-inspired presentation palette. The frame stores 0xAARRGGBB values. +pub const palette = struct { + pub const shell: u32 = 0xffd9d9b8; + pub const bezel: u32 = 0xff3a3a2e; + pub const screen_back: u32 = 0xff9ca89a; + pub const paused_bar: u32 = 0xff101010; +}; + +const testing = std.testing; + +fn testBus() !Bus { + const allocator = testing.allocator; + const rom = [_]u8{0} ** 0x8000; + return try Bus.init(allocator, &rom); +} + +test "bindings cover the eight joypad inputs" { + var seen_buttons: u8 = 0; + var seen_directions: u8 = 0; + for (bindings) |binding| { + switch (binding.action) { + .button => seen_buttons |= @as(u8, 1) << binding.bit, + .direction => seen_directions |= @as(u8, 1) << binding.bit, + } + } + try testing.expectEqual(@as(u8, 0x0f), seen_buttons); + try testing.expectEqual(@as(u8, 0x0f), seen_directions); +} + +test "applying a key presses the right joypad column" { + var bus = try testBus(); + defer bus.deinit(); + try testing.expect(applyKey(&bus, .a, true)); + try testing.expect(bus.joypad.isButtonPressed(Joypad.Button.a)); + try testing.expect(!bus.joypad.isDirectionPressed(Joypad.Direction.right)); + try testing.expect(applyKey(&bus, .up, true)); + try testing.expect(bus.joypad.isDirectionPressed(Joypad.Direction.up)); +} + +test "releasing a key restores the released state" { + var bus = try testBus(); + defer bus.deinit(); + try testing.expect(applyKey(&bus, .b, true)); + try testing.expect(applyKey(&bus, .b, false)); + try testing.expect(!bus.joypad.isButtonPressed(Joypad.Button.b)); +} + +test "control keys are not forwarded to the joypad" { + var bus = try testBus(); + defer bus.deinit(); + try testing.expect(!applyKey(&bus, .pause, true)); + try testing.expect(!applyKey(&bus, .reset, true)); + try testing.expect(!applyKey(&bus, .fast_forward, true)); + try testing.expectEqual(@as(u8, 0xcf), bus.read(0xff00)); +} + +test "window layout keeps the screen centered" { + try testing.expectEqual(@as(usize, 512), window_width); + try testing.expectEqual(@as(usize, 464), window_height); + try testing.expectEqual(@as(usize, ppu.ScreenWidth * screen_scale), @as(usize, @intCast(screen_rect.width))); + try testing.expectEqual(@as(usize, ppu.ScreenHeight * screen_scale), @as(usize, @intCast(screen_rect.height))); +} + +test "run state toggles stay independent" { + var state = RunState{}; + state.togglePause(); + try testing.expect(state.paused); + state.togglePause(); + try testing.expect(!state.paused); + state.toggleFastForward(); + try testing.expect(state.fast_forward); + try testing.expect(state.running); +} diff --git a/src/joypad.zig b/src/joypad.zig new file mode 100644 index 0000000..f709ee1 --- /dev/null +++ b/src/joypad.zig @@ -0,0 +1,114 @@ +// The Game Boy joypad device. +// +// The register at 0xff00 reports key state through the low nibble. +// Bit 4 and bit 5 of the register select which column is active. +// A zero bit selects a column. A pressed key drives its bit low. +// When both columns are selected, the read is the AND of the two. + +const std = @import("std"); + +pub const Joypad = struct { + buttons: u8 = 0x0f, + dpad: u8 = 0x0f, + select: u8 = 0x30, + + // Low-nibble bit positions, active low. + pub const Button = enum(u3) { a = 0, b = 1, select = 2, start = 3 }; + + pub const Direction = enum(u3) { right = 0, left = 1, up = 2, down = 3 }; + + // A press edge raises the joypad interrupt. The bus owns the flag. + pub fn setButton(self: *Joypad, bit: Button, pressed: bool) void { + const mask: u8 = @as(u8, 1) << @intFromEnum(bit); + self.buttons = if (pressed) self.buttons & ~mask else self.buttons | mask; + } + + pub fn setDirection(self: *Joypad, bit: Direction, pressed: bool) void { + const mask: u8 = @as(u8, 1) << @intFromEnum(bit); + self.dpad = if (pressed) self.dpad & ~mask else self.dpad | mask; + } + + pub fn isButtonPressed(self: *const Joypad, bit: Button) bool { + return (self.buttons & (@as(u8, 1) << @intFromEnum(bit))) == 0; + } + + pub fn isDirectionPressed(self: *const Joypad, bit: Direction) bool { + return (self.dpad & (@as(u8, 1) << @intFromEnum(bit))) == 0; + } + + // Reading FF00. Unselected columns report 1 (released). + // A set bit 4 activates the D-pad; a set bit 5 activates the buttons. + pub fn read(self: *const Joypad) u8 { + var value: u8 = 0x0f; + if ((self.select & 0x10) != 0) value &= self.dpad; + if ((self.select & 0x20) != 0) value &= self.buttons; + return 0xc0 | value; + } + + // Writing FF00. Only the two select bits are stored. + pub fn write(self: *Joypad, value: u8) void { + self.select = value & 0x30; + } +}; + +const testing = std.testing; + +test "default state reads as all released" { + const joypad = Joypad{}; + try testing.expectEqual(@as(u8, 0xcf), joypad.read()); +} + +test "buttons column reflects pressed keys" { + var joypad = Joypad{}; + joypad.write(0x20); // select buttons + joypad.setButton(.a, true); + joypad.setButton(.start, true); + const value = joypad.read(); + // a (bit0) and start (bit3) are low; B and select stay high. + try testing.expectEqual(@as(u8, 0x06), value & 0x0f); +} + +test "dpad column reflects pressed directions" { + var joypad = Joypad{}; + joypad.write(0x10); // select dpad + joypad.setDirection(.up, true); + joypad.setDirection(.down, true); + const value = joypad.read(); + // up (bit2) and down (bit3) are low; right and left stay high. + try testing.expectEqual(@as(u8, 0x03), value & 0x0f); +} + +test "unselected column reads released" { + var joypad = Joypad{}; + joypad.setButton(.a, true); + joypad.setDirection(.right, true); + joypad.write(0x00); // neither column selected + try testing.expectEqual(@as(u8, 0xcf), joypad.read()); +} + +test "both columns selected reads the AND of both" { + var joypad = Joypad{}; + joypad.write(0x30); // both columns selected + joypad.setButton(.a, true); + joypad.setDirection(.right, true); + // bit0 is driven low by both columns. + try testing.expectEqual(@as(u8, 0x0e), joypad.read() & 0x0f); +} + +test "release restores the released state" { + var joypad = Joypad{}; + joypad.write(0x10); + joypad.setButton(.b, true); + joypad.setButton(.b, false); + try testing.expectEqual(@as(u8, 0xcf), joypad.read()); +} + +test "query helpers reflect active low storage" { + var joypad = Joypad{}; + joypad.setButton(.a, true); + joypad.setDirection(.left, true); + try testing.expect(joypad.isButtonPressed(.a)); + try testing.expect(!joypad.isButtonPressed(.b)); + try testing.expect(joypad.isDirectionPressed(.left)); + try testing.expect(!joypad.isDirectionPressed(.up)); +} diff --git a/src/main.zig b/src/main.zig index 643116a..39dd0c1 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,236 @@ const std = @import("std"); +const Emulator = @import("emulator.zig").Emulator; +const frontend = @import("frontend.zig"); +const ppu = @import("ppu.zig"); -pub fn main() !void { - std.debug.print("Dot Matrix Deck: windowed frontend is under construction.\n", .{}); +const c = @cImport({ + @cDefine("SDL_MAIN_HANDLED", "1"); + @cInclude("SDL2/SDL.h"); +}); + +const frame_cap: u64 = 1 << 24; +const frame_ms: u32 = 16; +const fast_forward_frames: u32 = 4; + +const ScancodeKey = struct { + scancode: c.SDL_Scancode, + key: frontend.Key, +}; + +const scancode_map = [_]ScancodeKey{ + .{ .scancode = c.SDL_SCANCODE_RIGHT, .key = .right }, + .{ .scancode = c.SDL_SCANCODE_LEFT, .key = .left }, + .{ .scancode = c.SDL_SCANCODE_UP, .key = .up }, + .{ .scancode = c.SDL_SCANCODE_DOWN, .key = .down }, + .{ .scancode = c.SDL_SCANCODE_Z, .key = .a }, + .{ .scancode = c.SDL_SCANCODE_X, .key = .b }, + .{ .scancode = c.SDL_SCANCODE_RSHIFT, .key = .select }, + .{ .scancode = c.SDL_SCANCODE_LSHIFT, .key = .select }, + .{ .scancode = c.SDL_SCANCODE_RETURN, .key = .start }, + .{ .scancode = c.SDL_SCANCODE_P, .key = .pause }, + .{ .scancode = c.SDL_SCANCODE_R, .key = .reset }, + .{ .scancode = c.SDL_SCANCODE_F, .key = .fast_forward }, +}; + +fn keyFor(scancode: c.SDL_Scancode) ?frontend.Key { + for (scancode_map) |mapping| { + if (mapping.scancode == scancode) return mapping.key; + } + return null; +} + +fn usage(program: []const u8) void { + std.debug.print( + \\Dot Matrix Deck - SDL2 windowed frontend + \\Usage: + \\ {s} + \\ + \\Keys: Z=A, X=B, Enter=Start, Shift=Select, arrows=d-pad. + \\P pause, R reset, F fast forward, 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; +} + +fn sdlError(context: []const u8) noreturn { + std.debug.print("{s}: {s}\n", .{ context, std.mem.span(c.SDL_GetError()) }); + std.process.exit(1); +} + +fn rect(x: c_int, y: c_int, width: c_int, height: c_int) c.SDL_Rect { + return .{ .x = x, .y = y, .w = width, .h = height }; +} + +const color = struct { + fn set(renderer: *c.SDL_Renderer, value: u32) void { + _ = c.SDL_SetRenderDrawColor(renderer, @truncate(value >> 16), @truncate(value >> 8), @truncate(value), 0xff); + } +}; + +fn drawPauseOverlay(renderer: *c.SDL_Renderer) void { + const bar_w: c_int = 6; + const bar_h: c_int = @intCast(frontend.screen_rect.height / 3); + const gap: c_int = 14; + const y = frontend.screen_rect.y + (frontend.screen_rect.height - bar_h) / 2; + const x1 = frontend.screen_rect.x + frontend.screen_rect.width / 2 - gap - bar_w; + const x2 = frontend.screen_rect.x + frontend.screen_rect.width / 2 + gap; + color.set(renderer, frontend.palette.paused_bar); + _ = c.SDL_RenderFillRect(renderer, &rect(x1, y, bar_w, bar_h)); + _ = c.SDL_RenderFillRect(renderer, &rect(x2, y, bar_w, bar_h)); +} + +fn drawPixelGrid(renderer: *c.SDL_Renderer) void { + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + _ = c.SDL_SetRenderDrawColor(renderer, 0x10, 0x14, 0x10, 0x26); + var index: usize = 1; + while (index < ppu.ScreenWidth) : (index += 1) { + const x = frontend.screen_rect.x + @as(c_int, @intCast(index * frontend.screen_scale)); + _ = c.SDL_RenderDrawLine(renderer, x, frontend.screen_rect.y, x, frontend.screen_rect.y + frontend.screen_rect.height - 1); + } + index = 1; + while (index < ppu.ScreenHeight) : (index += 1) { + const y = frontend.screen_rect.y + @as(c_int, @intCast(index * frontend.screen_scale)); + _ = c.SDL_RenderDrawLine(renderer, frontend.screen_rect.x, y, frontend.screen_rect.x + frontend.screen_rect.width - 1, y); + } + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_NONE); +} + +// Re-applies held keys after a reset so the joypad state stays in sync. +fn syncHeldKeys(emulator: *Emulator) void { + const keyboard = c.SDL_GetKeyboardState(null); + for (scancode_map) |mapping| { + _ = frontend.applyKey(&emulator.bus, mapping.key, false); + } + for (scancode_map) |mapping| { + const down = keyboard[mapping.scancode] != 0; + _ = frontend.applyKey(&emulator.bus, mapping.key, down); + } +} + +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; + + var it = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator); + defer it.deinit(); + + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(allocator); + while (it.next()) |arg| try args.append(allocator, arg); + + if (args.items.len != 2) { + usage(args.items[0]); + std.process.exit(2); + } + + const rom = try readFile(io, allocator, args.items[1]); + defer allocator.free(rom); + + if (c.SDL_Init(c.SDL_INIT_VIDEO) != 0) sdlError("SDL_Init failed"); + defer c.SDL_Quit(); + + const window = c.SDL_CreateWindow( + "Dot Matrix Deck", + c.SDL_WINDOWPOS_CENTERED, + c.SDL_WINDOWPOS_CENTERED, + @intCast(frontend.window_width), + @intCast(frontend.window_height), + c.SDL_WINDOW_SHOWN, + ) orelse sdlError("SDL_CreateWindow failed"); + defer c.SDL_DestroyWindow(window); + + const renderer = c.SDL_CreateRenderer(window, -1, c.SDL_RENDERER_ACCELERATED) orelse + sdlError("SDL_CreateRenderer failed"); + defer c.SDL_DestroyRenderer(renderer); + + const texture = c.SDL_CreateTexture( + renderer, + c.SDL_PIXELFORMAT_ARGB8888, + c.SDL_TEXTUREACCESS_STREAMING, + ppu.ScreenWidth, + ppu.ScreenHeight, + ) orelse sdlError("SDL_CreateTexture failed"); + defer c.SDL_DestroyTexture(texture); + + var emulator = try Emulator.init(allocator, rom); + defer emulator.deinit(); + + var run_state = frontend.RunState{}; + var last_tick = c.SDL_GetTicks(); + + while (run_state.running) { + var event: c.SDL_Event = undefined; + while (c.SDL_PollEvent(&event) != 0) { + switch (event.type) { + c.SDL_QUIT => run_state.running = false, + c.SDL_KEYDOWN => { + if (event.key.repeat != 0) break; + const scancode = event.key.keysym.scancode; + if (scancode == c.SDL_SCANCODE_ESCAPE) { + run_state.running = false; + break; + } + const key = keyFor(scancode) orelse continue; + switch (key) { + .pause => run_state.togglePause(), + .fast_forward => run_state.toggleFastForward(), + .reset => { + emulator.reset(); + syncHeldKeys(&emulator); + }, + else => _ = frontend.applyKey(&emulator.bus, key, true), + } + }, + c.SDL_KEYUP => { + if (event.key.repeat != 0) break; + const key = keyFor(event.key.keysym.scancode) orelse continue; + _ = frontend.applyKey(&emulator.bus, key, false); + }, + else => {}, + } + } + + if (!run_state.paused) { + const frames: u32 = if (run_state.fast_forward) fast_forward_frames else 1; + var frame_index: u32 = 0; + while (frame_index < frames) : (frame_index += 1) { + emulator.runFrame(frame_cap); + } + } + + const frame = emulator.bus.ppu.frame; + _ = c.SDL_UpdateTexture(texture, null, &frame, @as(c_int, @intCast(ppu.ScreenWidth * @sizeOf(u32)))); + color.set(renderer, frontend.palette.shell); + _ = c.SDL_RenderClear(renderer); + color.set(renderer, frontend.palette.bezel); + _ = c.SDL_RenderFillRect(renderer, &rect( + frontend.screen_rect.x - 6, + frontend.screen_rect.y - 6, + frontend.screen_rect.width + 12, + frontend.screen_rect.height + 12, + )); + _ = c.SDL_RenderCopy(renderer, texture, null, &rect( + frontend.screen_rect.x, + frontend.screen_rect.y, + frontend.screen_rect.width, + frontend.screen_rect.height, + )); + drawPixelGrid(renderer); + if (run_state.paused) drawPauseOverlay(renderer); + c.SDL_RenderPresent(renderer); + + const now = c.SDL_GetTicks(); + const elapsed = now -% last_tick; + if (elapsed < frame_ms) c.SDL_Delay(frame_ms - elapsed); + last_tick = now; + } } diff --git a/src/tests.zig b/src/tests.zig new file mode 100644 index 0000000..edcda47 --- /dev/null +++ b/src/tests.zig @@ -0,0 +1,13 @@ +// Root module for the deterministic unit test suite. +// +// Importing a module pulls its test blocks into the test binary, so this +// root file exercises the whole emulator core and the frontend logic. + +test { + _ = @import("emulator.zig"); + _ = @import("bus.zig"); + _ = @import("joypad.zig"); + _ = @import("frontend.zig"); + _ = @import("serial.zig"); + _ = @import("disasm.zig"); +} diff --git a/tools/gbasm.zig b/tools/gbasm.zig index e81a221..5ccf402 100644 --- a/tools/gbasm.zig +++ b/tools/gbasm.zig @@ -1,5 +1,880 @@ +// gbasm - a small SM83 assembler for Game Boy test ROMs. +// +// It reads a single .asm file or a whole directory of sources and +// writes raw .gb images. The assembler targets the instruction set +// implemented by the emulator core, which is the complete 256-opcode +// SM83 set plus the CB prefix group. +// +// Directives: +// ORG
set the output position, filling gaps with zero +// PAD
alias for ORG, used to size the final image +// DB emit bytes; items may be strings or numbers +// DW emit little-endian 16-bit values +// DS [,] emit bytes of +// +// Numbers accept $hex, 0xhex, %binary, or decimal. Labels are written +// as `name:` and may be forward-referenced. A `;` starts a comment. + const std = @import("std"); -pub fn main() !void { - std.debug.print("gbasm: assembler is under construction.\n", .{}); +pub const AssemblerError = error{ + Syntax, + UnknownMnemonic, + BadOperand, + BadOperandCount, + DuplicateLabel, + UnknownSymbol, + BranchOutOfRange, + AddressOverlap, + NumberOutOfRange, + BadRstVector, + TooManyOperands, + OutOfMemory, +}; + +const reg8_names = [_][]const u8{ "B", "C", "D", "E", "H", "L", "(HL)", "A" }; +const reg16_names = [_][]const u8{ "BC", "DE", "HL", "SP" }; +const cond_names = [_][]const u8{ "NZ", "Z", "NC", "C" }; + +pub const Assembler = struct { + allocator: std.mem.Allocator, + output: std.ArrayList(u8), + symbols: std.StringHashMap(u16), + address: u16 = 0, + emit: bool = false, + + pub fn init(allocator: std.mem.Allocator) Assembler { + return .{ + .allocator = allocator, + .output = std.ArrayList(u8).empty, + .symbols = std.StringHashMap(u16).init(allocator), + }; + } + + pub fn deinit(self: *Assembler) void { + self.output.deinit(self.allocator); + self.symbols.deinit(); + } + + pub fn assemble(self: *Assembler, source: []const u8) AssemblerError![]u8 { + try self.runPass(source); + self.emit = true; + try self.runPass(source); + return self.output.toOwnedSlice(self.allocator); + } + + fn runPass(self: *Assembler, source: []const u8) AssemblerError!void { + self.address = 0; + self.output.clearRetainingCapacity(); + + var lines = std.mem.splitScalar(u8, source, '\n'); + while (lines.next()) |raw_line| { + const comment = std.mem.indexOfScalar(u8, raw_line, ';'); + const line = std.mem.trim(u8, raw_line[0 .. comment orelse raw_line.len], " \t\r"); + if (line.len == 0) continue; + + var rest = line; + if (std.mem.indexOfScalar(u8, line, ':')) |colon| { + const candidate = line[0..colon]; + if (candidate.len != 0 and std.mem.indexOfAny(u8, candidate, " \t") == null) { + try self.defineLabel(candidate); + rest = std.mem.trim(u8, line[colon + 1 ..], " \t"); + if (rest.len == 0) continue; + } + } + + const mnemonic = nextToken(rest); + const operands = std.mem.trim(u8, rest[mnemonic.len..], " \t"); + try self.emitLine(mnemonic, operands); + } + } + + fn defineLabel(self: *Assembler, name: []const u8) AssemblerError!void { + if (self.emit) return; + const gop = try self.symbols.getOrPut(name); + if (gop.found_existing) return error.DuplicateLabel; + gop.value_ptr.* = self.address; + } + + fn emitLine(self: *Assembler, mnemonic_in: []const u8, operands: []const u8) AssemblerError!void { + const mnemonic = mnemonic_in; + + if (eqlI(mnemonic, "ORG") or eqlI(mnemonic, "PAD")) { + const target = try self.eval(operands); + if (target < 0) return error.AddressOverlap; + const fill_to: u16 = @intCast(target); + if (fill_to < self.address) return error.AddressOverlap; + while (self.address < fill_to) try self.emitByte(0x00); + return; + } + if (eqlI(mnemonic, "DB")) return self.emitData(operands, false); + if (eqlI(mnemonic, "DW")) return self.emitData(operands, true); + if (eqlI(mnemonic, "DS")) return self.emitSpace(operands); + return self.encode(mnemonic, operands); + } + + fn emitData(self: *Assembler, operands: []const u8, wide: bool) AssemblerError!void { + var items: [64][]const u8 = undefined; + const count = try splitItems(operands, &items); + for (items[0..count]) |item| { + if (item.len >= 2 and item[0] == '"' and item[item.len - 1] == '"') { + const text = item[1 .. item.len - 1]; + if (wide) { + for (text) |byte| { + try self.emitByte(byte); + try self.emitByte(0); + } + } else { + for (text) |byte| try self.emitByte(byte); + } + continue; + } + const value = try self.eval(item); + if (wide) { + if (value < 0 or value > 0xffff) return error.NumberOutOfRange; + const word: u16 = @intCast(value); + try self.emitByte(@truncate(word)); + try self.emitByte(@truncate(word >> 8)); + } else { + if (value < -128 or value > 0xff) return error.NumberOutOfRange; + try self.emitByte(lowByte(value)); + } + } + } + + fn emitSpace(self: *Assembler, operands: []const u8) AssemblerError!void { + var items: [2][]const u8 = undefined; + const count = try splitItems(operands, &items); + if (count < 1 or count > 2) return error.BadOperandCount; + const length = try self.eval(items[0]); + if (length < 0 or length > 0xffff) return error.NumberOutOfRange; + const fill: u8 = if (count == 2) blk: { + const value = try self.eval(items[1]); + if (value < 0 or value > 0xff) return error.NumberOutOfRange; + break :blk @intCast(value); + } else 0; + var remaining: usize = @intCast(length); + while (remaining != 0) : (remaining -= 1) try self.emitByte(fill); + } + + fn emitByte(self: *Assembler, value: u8) AssemblerError!void { + if (self.emit) self.output.append(self.allocator, value) catch return error.OutOfMemory; + self.address +%= 1; + } + + // Parses a signed expression: numbers, labels, and + and - terms. + fn eval(self: *Assembler, text_in: []const u8) AssemblerError!i32 { + const text = std.mem.trim(u8, text_in, " \t"); + if (text.len == 0) return error.Syntax; + var total: i32 = 0; + var sign: i32 = 1; + var index: usize = 0; + while (index < text.len) { + const ch = text[index]; + if (ch == '+' or ch == '-') { + sign = if (ch == '+') 1 else -1; + index += 1; + continue; + } + const start = index; + while (index < text.len and text[index] != '+' and text[index] != '-') index += 1; + const term = text[start..index]; + total += sign * try self.evalTerm(term); + sign = 1; + } + return total; + } + + fn evalTerm(self: *Assembler, term: []const u8) AssemblerError!i32 { + if (term.len == 0) return error.Syntax; + const value = self.parseNumber(term) orelse blk: { + if (self.symbols.get(term)) |symbol_value| break :blk @as(i32, symbol_value); + if (!self.emit) break :blk 0; // forward reference during the sizing pass + return error.UnknownSymbol; + }; + return value; + } + + fn parseNumber(self: *Assembler, term: []const u8) ?i32 { + _ = self; + if (term.len >= 2 and term[0] == '$') return parseRadix(term[1..], 16) orelse return null; + if (term.len >= 3 and term[0] == '0' and (term[1] == 'x' or term[1] == 'X')) return parseRadix(term[2..], 16) orelse return null; + if (term.len >= 2 and term[0] == '%') return parseRadix(term[1..], 2) orelse return null; + if (term[0] >= '0' and term[0] <= '9') return parseRadix(term, 10) orelse return null; + return null; + } + + fn encode(self: *Assembler, mnemonic: []const u8, operands: []const u8) AssemblerError!void { + var parts: [4][]const u8 = undefined; + const count = try splitItems(operands, &parts); + + if (eqlI(mnemonic, "LD")) return self.encodeLd(parts[0..count], count); + if (eqlI(mnemonic, "LDH")) return self.encodeLdh(parts[0..count], count); + + const op: ?u3 = blk: { + const names = [_][]const u8{ "ADD", "ADC", "SUB", "SBC", "AND", "XOR", "OR", "CP" }; + for (names, 0..) |name, i| { + if (eqlI(mnemonic, name)) break :blk @as(u3, @intCast(i)); + } + break :blk null; + }; + + if (op) |alu_op| return self.encodeAlu(alu_op, parts[0..count], count); + + if (eqlI(mnemonic, "INC")) return self.encodeIncDec(parts[0..count], count, true); + if (eqlI(mnemonic, "DEC")) return self.encodeIncDec(parts[0..count], count, false); + if (eqlI(mnemonic, "JP")) return self.encodeJp(parts[0..count], count); + if (eqlI(mnemonic, "JR")) return self.encodeJr(parts[0..count], count); + if (eqlI(mnemonic, "CALL")) return self.encodeCall(parts[0..count], count); + if (eqlI(mnemonic, "RET")) return self.encodeRet(parts[0..count], count); + if (eqlI(mnemonic, "RETI")) { + if (count != 0) return error.BadOperandCount; + return self.emitByte(0xd9); + } + if (eqlI(mnemonic, "RST")) return self.encodeRst(parts[0..count], count); + if (eqlI(mnemonic, "PUSH") or eqlI(mnemonic, "POP")) return self.encodePushPop(mnemonic, parts[0..count], count); + if (eqlI(mnemonic, "RLC") or eqlI(mnemonic, "RRC") or + eqlI(mnemonic, "RL") or eqlI(mnemonic, "RR") or + eqlI(mnemonic, "SLA") or eqlI(mnemonic, "SRA") or + eqlI(mnemonic, "SWAP") or eqlI(mnemonic, "SRL")) return self.encodeShift(mnemonic, parts[0..count], count); + if (eqlI(mnemonic, "BIT") or eqlI(mnemonic, "RES") or eqlI(mnemonic, "SET")) return self.encodeBitOp(mnemonic, parts[0..count], count); + + const zero_arg = [_][]const u8{ "NOP", "HALT", "DI", "EI", "RLCA", "RLA", "RRCA", "RRA", "DAA", "CPL", "SCF", "CCF" }; + const zero_bytes = [_]u8{ 0x00, 0x76, 0xf3, 0xfb, 0x07, 0x17, 0x0f, 0x1f, 0x27, 0x2f, 0x37, 0x3f }; + for (zero_arg, 0..) |name, i| { + if (eqlI(mnemonic, name)) { + if (count != 0) return error.BadOperandCount; + return self.emitByte(zero_bytes[i]); + } + } + + if (eqlI(mnemonic, "STOP")) { + if (count != 0) return error.BadOperandCount; + try self.emitByte(0x10); + return self.emitByte(0x00); + } + + return error.UnknownMnemonic; + } + + fn encodeLd(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 2) return error.BadOperandCount; + const dst = parts[0]; + const src = parts[1]; + + if (std.mem.eql(u8, dst, "(C)") and std.mem.eql(u8, src, "A")) return self.emitByte(0xe2); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(C)")) return self.emitByte(0xf2); + if (std.mem.eql(u8, dst, "(BC)") and std.mem.eql(u8, src, "A")) return self.emitByte(0x02); + if (std.mem.eql(u8, dst, "(DE)") and std.mem.eql(u8, src, "A")) return self.emitByte(0x12); + if (std.mem.eql(u8, dst, "(HL)") and std.mem.eql(u8, src, "A")) return self.emitByte(0x77); + if (std.mem.eql(u8, dst, "(HL+)") and std.mem.eql(u8, src, "A")) return self.emitByte(0x22); + if (std.mem.eql(u8, dst, "(HL-)") and std.mem.eql(u8, src, "A")) return self.emitByte(0x32); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(BC)")) return self.emitByte(0x0a); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(DE)")) return self.emitByte(0x1a); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(HL)")) return self.emitByte(0x7e); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(HL+)")) return self.emitByte(0x2a); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(HL-)")) return self.emitByte(0x3a); + if (std.mem.eql(u8, dst, "SP") and std.mem.eql(u8, src, "HL")) return self.emitByte(0xf9); + + if (std.mem.eql(u8, dst, "HL") and startsWith(src, "SP+")) return self.encodeLdHlSp(0xf8, src[3..]); + if (std.mem.eql(u8, dst, "HL") and startsWith(src, "SP-")) return self.encodeLdHlSpSigned(0xf8, src[3..]); + + if (isAddress(dst)) { + const address = try self.eval(dst[1 .. dst.len - 1]); + if (std.mem.eql(u8, src, "SP")) { + if (address < 0 or address > 0xffff) return error.NumberOutOfRange; + try self.emitByte(0x08); + try self.emitByte(@truncate(@as(u16, @intCast(address)))); + return self.emitByte(@truncate(@as(u16, @intCast(address)) >> 8)); + } + if (std.mem.eql(u8, src, "A")) { + if (address < 0 or address > 0xffff) return error.NumberOutOfRange; + try self.emitByte(0xea); + try self.emitByte(@truncate(@as(u16, @intCast(address)))); + return self.emitByte(@truncate(@as(u16, @intCast(address)) >> 8)); + } + } + if (std.mem.eql(u8, dst, "A") and isAddress(src)) { + const address = try self.eval(src[1 .. src.len - 1]); + if (address < 0 or address > 0xffff) return error.NumberOutOfRange; + try self.emitByte(0xfa); + try self.emitByte(@truncate(@as(u16, @intCast(address)))); + return self.emitByte(@truncate(@as(u16, @intCast(address)) >> 8)); + } + + if (reg16Index(dst)) |pair| { + const value = try self.eval(src); + if (value < 0 or value > 0xffff) return error.NumberOutOfRange; + try self.emitByte(0x01 | @as(u8, pair) << 4); + try self.emitByte(@truncate(@as(u16, @intCast(value)))); + return self.emitByte(@truncate(@as(u16, @intCast(value)) >> 8)); + } + + if (reg8Index(dst)) |dest_reg| { + if (reg8Index(src)) |source_reg| { + if (dest_reg == 6 and source_reg == 6) return error.BadOperand; + return self.emitByte(0x40 | @as(u8, dest_reg) << 3 | @as(u8, source_reg)); + } + const value = try self.eval(src); + try self.emitByte(0x06 | @as(u8, dest_reg) << 3); + return self.emitByte(lowByte(value)); + } + + return error.BadOperand; + } + + fn encodeLdHlSp(self: *Assembler, opcode: u8, offset_text: []const u8) AssemblerError!void { + const offset = try self.eval(offset_text); + try self.emitByte(opcode); + return self.emitByte(lowByte(offset)); + } + + fn encodeLdHlSpSigned(self: *Assembler, opcode: u8, offset_text: []const u8) AssemblerError!void { + const offset = -(try self.eval(offset_text)); + try self.emitByte(opcode); + return self.emitByte(lowByte(offset)); + } + + fn encodeLdh(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 2) return error.BadOperandCount; + const dst = parts[0]; + const src = parts[1]; + if (std.mem.eql(u8, dst, "(C)") and std.mem.eql(u8, src, "A")) return self.emitByte(0xe2); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(C)")) return self.emitByte(0xf2); + if (isAddress(dst) and std.mem.eql(u8, src, "A")) { + const value = try self.eval(dst[1 .. dst.len - 1]); + try self.emitByte(0xe0); + return self.emitByte(lowByte(value)); + } + if (std.mem.eql(u8, dst, "A") and isAddress(src)) { + const value = try self.eval(src[1 .. src.len - 1]); + try self.emitByte(0xf0); + return self.emitByte(lowByte(value)); + } + return error.BadOperand; + } + + fn encodeAlu(self: *Assembler, operation: u3, parts: []const []const u8, count: usize) AssemblerError!void { + if (count == 2) { + const dst = parts[0]; + const src = parts[1]; + if (operation == 0 and std.mem.eql(u8, dst, "HL")) { + if (reg16Index(src)) |pair| return self.emitByte(0x09 | @as(u8, pair) << 4); + } + if (operation == 0 and std.mem.eql(u8, dst, "SP")) { + if (startsWith(src, "+")) return self.encodeLdHlSp(0xe8, src[1..]); + if (startsWith(src, "-")) return self.encodeLdHlSpSigned(0xe8, src[1..]); + return self.encodeLdHlSp(0xe8, src); + } + if (!std.mem.eql(u8, dst, "A")) return error.BadOperand; + if (reg8Index(src)) |source_reg| return self.emitByte(0x80 | @as(u8, operation) << 3 | source_reg); + const value = try self.eval(src); + try self.emitByte(0xc6 | @as(u8, operation) << 3); + return self.emitByte(lowByte(value)); + } + if (count == 1) { + if (reg8Index(parts[0])) |source_reg| return self.emitByte(0x80 | @as(u8, operation) << 3 | source_reg); + const value = try self.eval(parts[0]); + try self.emitByte(0xc6 | @as(u8, operation) << 3); + return self.emitByte(lowByte(value)); + } + return error.BadOperandCount; + } + + fn encodeIncDec(self: *Assembler, parts: []const []const u8, count: usize, increment: bool) AssemblerError!void { + if (count != 1) return error.BadOperandCount; + if (reg8Index(parts[0])) |reg| { + const base: u8 = if (increment) 0x04 else 0x05; + return self.emitByte(base | @as(u8, reg) << 3); + } + if (reg16Index(parts[0])) |pair| { + const base: u8 = if (increment) 0x03 else 0x0b; + return self.emitByte(base | @as(u8, pair) << 4); + } + return error.BadOperand; + } + + fn encodeJp(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count == 1) { + if (std.mem.eql(u8, parts[0], "(HL)")) return self.emitByte(0xe9); + const target = try self.eval(parts[0]); + return self.emitAddress(0xc3, target); + } + if (count == 2) { + if (condIndex(parts[0])) |condition| { + const target = try self.eval(parts[1]); + return self.emitAddress(0xc2 | @as(u8, condition) << 3, target); + } + } + return error.BadOperand; + } + + fn encodeJr(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count < 1 or count > 2) return error.BadOperandCount; + const base: u8 = if (count == 1) 0x18 else 0x20 | @as(u8, condIndex(parts[0]) orelse return error.BadOperand) << 3; + const target_text = if (count == 1) parts[0] else parts[1]; + const target = try self.eval(target_text); + const after = @as(i32, self.address) + 2; + const displacement = target - after; + if (self.emit and (displacement < -128 or displacement > 127)) return error.BranchOutOfRange; + try self.emitByte(base); + return self.emitByte(lowByte(displacement)); + } + + fn encodeCall(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count == 1) { + const target = try self.eval(parts[0]); + return self.emitAddress(0xcd, target); + } + if (count == 2) { + if (condIndex(parts[0])) |condition| { + const target = try self.eval(parts[1]); + return self.emitAddress(0xc4 | @as(u8, condition) << 3, target); + } + } + return error.BadOperand; + } + + fn encodeRet(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count == 0) return self.emitByte(0xc9); + if (count == 1) { + if (condIndex(parts[0])) |condition| return self.emitByte(0xc0 | @as(u8, condition) << 3); + } + return error.BadOperand; + } + + fn encodeRst(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 1) return error.BadOperandCount; + const vector = try self.eval(parts[0]); + if (vector < 0 or vector > 0x38 or (vector & 0x07) != 0) return error.BadRstVector; + return self.emitByte(0xc7 | @as(u8, @intCast(vector))); + } + + fn encodePushPop(self: *Assembler, mnemonic: []const u8, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 1) return error.BadOperandCount; + const names = [_][]const u8{ "BC", "DE", "HL", "AF" }; + for (names, 0..) |name, i| { + if (std.mem.eql(u8, parts[0], name)) { + const base: u8 = if (eqlI(mnemonic, "PUSH")) 0xc5 else 0xc1; + return self.emitByte(base | @as(u8, @intCast(i)) << 4); + } + } + return error.BadOperand; + } + + fn encodeShift(self: *Assembler, mnemonic: []const u8, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 1) return error.BadOperandCount; + const reg = reg8Index(parts[0]) orelse return error.BadOperand; + const names = [_][]const u8{ "RLC", "RRC", "RL", "RR", "SLA", "SRA", "SWAP", "SRL" }; + var operation: u8 = 0; + for (names, 0..) |name, i| { + if (eqlI(mnemonic, name)) { + operation = @intCast(i); + break; + } + } + try self.emitByte(0xcb); + return self.emitByte(operation << 3 | reg); + } + + fn encodeBitOp(self: *Assembler, mnemonic: []const u8, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 2) return error.BadOperandCount; + const bit_value = try self.eval(parts[0]); + if (bit_value < 0 or bit_value > 7) return error.NumberOutOfRange; + const reg = reg8Index(parts[1]) orelse return error.BadOperand; + const base: u8 = if (eqlI(mnemonic, "BIT")) 0x40 else if (eqlI(mnemonic, "RES")) 0x80 else 0xc0; + try self.emitByte(0xcb); + return self.emitByte(base | @as(u8, @intCast(bit_value)) << 3 | reg); + } + + fn emitAddress(self: *Assembler, opcode: u8, value: i32) AssemblerError!void { + if (value < 0 or value > 0xffff) return error.NumberOutOfRange; + const word: u16 = @intCast(value); + try self.emitByte(opcode); + try self.emitByte(@truncate(word)); + return self.emitByte(@truncate(word >> 8)); + } +}; + +fn reg8Index(text: []const u8) ?u3 { + for (reg8_names, 0..) |name, i| { + if (std.mem.eql(u8, text, name)) return @intCast(i); + } + return null; +} + +fn reg16Index(text: []const u8) ?u2 { + for (reg16_names, 0..) |name, i| { + if (std.mem.eql(u8, text, name)) return @intCast(i); + } + return null; +} + +fn condIndex(text: []const u8) ?u2 { + for (cond_names, 0..) |name, i| { + if (std.mem.eql(u8, text, name)) return @intCast(i); + } + return null; +} + +fn isAddress(text: []const u8) bool { + return text.len >= 3 and text[0] == '(' and text[text.len - 1] == ')' and + !std.mem.eql(u8, text, "(HL)") and !std.mem.eql(u8, text, "(HL+)") and + !std.mem.eql(u8, text, "(HL-)") and !std.mem.eql(u8, text, "(BC)") and + !std.mem.eql(u8, text, "(DE)") and !std.mem.eql(u8, text, "(C)"); +} + +fn startsWith(text: []const u8, prefix: []const u8) bool { + return std.mem.startsWith(u8, text, prefix); +} + +fn eqlI(a: []const u8, b: []const u8) bool { + return std.ascii.eqlIgnoreCase(a, b); +} + +// Low byte of a signed value in two's complement form. +fn lowByte(value: i32) u8 { + return @truncate(@as(u32, @bitCast(value))); +} + +fn nextToken(text: []const u8) []const u8 { + const end = std.mem.indexOfAny(u8, text, " \t") orelse text.len; + return text[0..end]; +} + +// Splits comma-separated items, keeping quoted strings intact. +fn splitItems(text: []const u8, items: [][]const u8) AssemblerError!usize { + var count: usize = 0; + var index: usize = 0; + while (index < text.len) { + while (index < text.len and (text[index] == ' ' or text[index] == '\t' or text[index] == ',')) index += 1; + if (index >= text.len) break; + const start = index; + var in_string = false; + while (index < text.len) : (index += 1) { + const ch = text[index]; + if (ch == '"') in_string = !in_string; + if (ch == ',' and !in_string) break; + } + if (count == items.len) return error.TooManyOperands; + items[count] = text[start..index]; + count += 1; + } + return count; +} + +fn parseRadix(digits: []const u8, base: u8) ?i32 { + var value: i64 = 0; + for (digits) |digit| { + const nibble: i64 = switch (digit) { + '0'...'9' => digit - '0', + 'a'...'f' => digit - 'a' + 10, + 'A'...'F' => digit - 'A' + 10, + else => return null, + }; + if (nibble >= base) return null; + value = value * base + nibble; + if (value > 0x7fffffff) return null; + } + return @intCast(value); +} + +const testing = std.testing; + +fn assembleBytes(source: []const u8) ![]u8 { + var assembler = Assembler.init(testing.allocator); + defer assembler.deinit(); + return assembler.assemble(source); +} + +test "assembles a minimal program" { + const bytes = try assembleBytes( + \\ ORG $0100 + \\ JP start + \\start: + \\ LD A,$12 + \\ HALT + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqualSlices(u8, &.{ 0xc3, 0x03, 0x01, 0x3e, 0x12, 0x76 }, bytes[0x0100..]); +} + +test "assembles register moves and arithmetic" { + const bytes = try assembleBytes( + \\ LD B,A + \\ LD C,(HL) + \\ ADD A,B + \\ ADC A,(HL) + \\ SUB B + \\ XOR A + \\ CP $40 + \\ INC (HL) + \\ DEC B + \\ INC HL + \\ ADD HL,DE + \\ ADD SP,-$06 + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqualSlices(u8, &.{ 0x47, 0x4e, 0x80, 0x8e, 0x90, 0xaf, 0xfe, 0x40, 0x34, 0x05, 0x23, 0x19, 0xe8, 0xfa }, bytes); +} + +test "assembles jumps, calls, and stack operations" { + const bytes = try assembleBytes( + \\ ORG $0200 + \\ JP target + \\ JR NZ,target + \\ CALL Z,target + \\ PUSH HL + \\ POP AF + \\ RET + \\ RETI + \\ RST $38 + \\ JP (HL) + \\target: + \\ NOP + \\ + ); + defer testing.allocator.free(bytes); + const expected = [_]u8{ + 0xc3, 0x0e, 0x02, + 0x20, 0x09, 0xcc, + 0x0e, 0x02, 0xe5, + 0xf1, 0xc9, 0xd9, + 0xff, 0xe9, 0x00, + }; + try testing.expectEqualSlices(u8, &expected, bytes[0x0200..]); +} + +test "assembles CB prefix group" { + const bytes = try assembleBytes( + \\ RLC A + \\ SRA (HL) + \\ BIT 3,L + \\ SET 7,(HL) + \\ RES 0,A + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqualSlices(u8, &.{ 0xcb, 0x07, 0xcb, 0x2e, 0xcb, 0x5d, 0xcb, 0xfe, 0xcb, 0x87 }, bytes); +} + +test "assembles ldh and memory forms" { + const bytes = try assembleBytes( + \\ LDH ($FF44),A + \\ LDH A,($FF40) + \\ LD (C),A + \\ LD A,(C) + \\ LD (BC),A + \\ LD A,(HL+) + \\ LD (HL-),A + \\ LD (MAP),SP + \\ LD HL,SP+$10 + \\ ORG $3000 + \\MAP: + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqualSlices(u8, &.{ 0xe0, 0x44, 0xf0, 0x40, 0xe2, 0xf2, 0x02, 0x2a, 0x32, 0x08, 0x00, 0x30, 0xf8, 0x10 }, bytes[0..14]); + try testing.expectEqual(@as(u16, 0x3000), bytes.len); +} + +test "assembles data directives and pads to size" { + const bytes = try assembleBytes( + \\ ORG $0000 + \\ DB "Passed", $0A, $00 + \\ DW $1234 + \\ DS 3,$FF + \\ PAD $0010 + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqual(@as(usize, 0x10), bytes.len); + try testing.expectEqualSlices(u8, "Passed", bytes[0..6]); + try testing.expectEqual(@as(u8, 0x0a), bytes[6]); + try testing.expectEqual(@as(u8, 0x00), bytes[7]); + try testing.expectEqualSlices(u8, &.{ 0x34, 0x12 }, bytes[8..10]); + try testing.expectEqualSlices(u8, &.{ 0xff, 0xff, 0xff }, bytes[10..13]); +} + +test "backward branch encodes the right displacement" { + const bytes = try assembleBytes( + \\ ORG $1000 + \\loop: + \\ JR loop + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqualSlices(u8, &.{ 0x18, 0xfe }, bytes[0x1000..]); +} + +test "forward branch over data" { + const bytes = try assembleBytes( + \\ ORG $0100 + \\ JR skip + \\ DB $00, $00, $00 + \\skip: + \\ NOP + \\ + ); + defer testing.allocator.free(bytes); + // JR is two bytes; skip is at 0105, so displacement is 0105-0102 = 3. + try testing.expectEqualSlices(u8, &.{ 0x18, 0x03 }, bytes[0x0100..0x0102]); + try testing.expectEqual(@as(u8, 0x00), bytes[0x0105]); +} + +test "rejects a branch out of range" { + var assembler = Assembler.init(testing.allocator); + defer assembler.deinit(); + const source = + \\ ORG $0000 + \\ JR far + \\ DS $100,$00 + \\far: + \\ + ; + try testing.expectError(AssemblerError.BranchOutOfRange, assembler.assemble(source)); +} + +test "rejects an unknown mnemonic" { + var assembler = Assembler.init(testing.allocator); + defer assembler.deinit(); + try testing.expectError(AssemblerError.UnknownMnemonic, assembler.assemble(" BOGUS\n")); +} + +test "rejects a duplicate label" { + var assembler = Assembler.init(testing.allocator); + defer assembler.deinit(); + try testing.expectError(AssemblerError.DuplicateLabel, assembler.assemble( + \\start: + \\start: + \\ + )); +} + +fn usage(program: []const u8) void { + std.debug.print( + \\gbasm - SM83 assembler for Game Boy test ROMs + \\Usage: + \\ {s} [output.gb] + \\ {s} + \\ + \\Assembles every .asm file in the directory mode and writes a + \\matching .gb image next to each source name. + \\ + , .{ program, program }); +} + +fn readFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const dir = std.Io.Dir.cwd(); + const file = try dir.openFile(io, path, .{}); + defer file.close(io); + const size = try file.length(io); + const buffer = try allocator.alloc(u8, @intCast(size)); + _ = try file.readPositionalAll(io, buffer, 0); + return buffer; +} + +fn isDirectory(io: std.Io, path: []const u8) bool { + const stat = std.Io.Dir.cwd().statFile(io, path, .{}) catch return false; + return stat.kind == .directory; +} + +fn assembleOne(io: std.Io, allocator: std.mem.Allocator, input_path: []const u8, output_path: []const u8) !void { + const source = try readFile(io, allocator, input_path); + defer allocator.free(source); + + var assembler = Assembler.init(allocator); + defer assembler.deinit(); + const image = assembler.assemble(source) catch |err| { + std.debug.print("{s}: error {s}\n", .{ input_path, @errorName(err) }); + std.process.exit(1); + }; + defer allocator.free(image); + + if (std.fs.path.dirname(output_path)) |parent| { + if (parent.len != 0) { + std.Io.Dir.cwd().createDirPath(io, parent) catch |err| { + std.debug.print("{s}: create error {s}\n", .{ parent, @errorName(err) }); + std.process.exit(1); + }; + } + } + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = output_path, .data = image }) catch |err| { + std.debug.print("{s}: write error {s}\n", .{ output_path, @errorName(err) }); + std.process.exit(1); + }; + std.debug.print("{s} -> {s} ({d} bytes)\n", .{ input_path, output_path, image.len }); +} + +fn assembleDirectory(io: std.Io, allocator: std.mem.Allocator, input_dir: []const u8, output_dir: []const u8) !void { + std.Io.Dir.cwd().createDirPath(io, output_dir) catch |err| { + std.debug.print("{s}: create error {s}\n", .{ output_dir, @errorName(err) }); + std.process.exit(1); + }; + + var dir = try std.Io.Dir.cwd().openDir(io, input_dir, .{ .iterate = true }); + defer dir.close(io); + var names: std.ArrayList([]const u8) = .empty; + defer names.deinit(allocator); + var iter = dir.iterate(); + while (try iter.next(io)) |entry| { + if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".asm")) { + try names.append(allocator, try allocator.dupe(u8, entry.name)); + } + } + std.mem.sort([]const u8, names.items, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.order(u8, a, b) == .lt; + } + }.lessThan); + + if (names.items.len == 0) { + std.debug.print("no .asm sources found in {s}\n", .{input_dir}); + std.process.exit(1); + } + + for (names.items) |name| { + defer allocator.free(name); + const input_path = try std.fs.path.join(allocator, &.{ input_dir, name }); + defer allocator.free(input_path); + const base = name[0 .. name.len - 4]; + const output_name = try std.fmt.allocPrint(allocator, "{s}.gb", .{base}); + defer allocator.free(output_name); + const output_path = try std.fs.path.join(allocator, &.{ output_dir, output_name }); + defer allocator.free(output_path); + try assembleOne(io, allocator, input_path, output_path); + } +} + +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; + + var it = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator); + defer it.deinit(); + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(allocator); + while (it.next()) |arg| try args.append(allocator, arg); + + if (args.items.len < 2 or args.items.len > 3) { + usage(args.items[0]); + std.process.exit(2); + } + + if (isDirectory(io, args.items[1])) { + if (args.items.len != 3) { + usage(args.items[0]); + std.process.exit(2); + } + return assembleDirectory(io, allocator, args.items[1], args.items[2]); + } + + const input = args.items[1]; + const output = if (args.items.len == 3) args.items[2] else blk: { + break :blk try std.fmt.allocPrint(allocator, "{s}.gb", .{input[0 .. input.len - 4]}); + }; + defer if (args.items.len == 2) allocator.free(output); + return assembleOne(io, allocator, input, output); }