From 2cfec88e9e6427125f203eb9b4ac0aab1a00d7ca Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 19:30:13 -0700 Subject: [PATCH 1/6] feat: extend dot matrix deck --- .github/workflows/ci.yml | 53 +++ .gitignore | 1 + CONTRIBUTING.md | 61 +++ LICENSE | 21 + NOTICE | 16 + README.md | 137 ++++-- ROADMAP.md | 39 ++ build.zig | 66 ++- build.zig.zon | 5 +- 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 | 3 +- src/frontend.zig | 160 +++++++ src/joypad.zig | 113 +++++ src/main.zig | 236 ++++++++++- src/tests.zig | 13 + tools/gbasm.zig | 879 ++++++++++++++++++++++++++++++++++++++- 21 files changed, 2122 insertions(+), 82 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 NOTICE create mode 100644 ROADMAP.md create mode 100644 fixtures/asm/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..2b45c84 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,53 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - name: Format check + run: zig fmt --check . + - name: Unit and integration tests + run: zig build test -Dsdl2=off --summary all + - name: Regenerate bundled ROMs + run: zig build fixtures -Dsdl2=off + - name: Committed fixtures match regeneration + if: ${{ runner.os == 'Linux' }} + run: git diff --exit-code -- fixtures + - name: Demo ROM reports PASS + run: zig build run-demo -Dsdl2=off + - name: Build headless tools + run: zig build -Dsdl2=off + + windowed: + name: Windowed frontend (ubuntu) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - name: Install SDL2 + run: sudo apt-get update && sudo apt-get install -y libsdl2-dev + - name: Build windowed frontend + run: zig build --summary all diff --git a/.gitignore b/.gitignore index 51bafde..f73f965 100644 --- a/.gitignore +++ b/.gitignore @@ -8,5 +8,6 @@ zig-out/ *.exe .DS_Store *.gb +!fixtures/roms/*.gb *.sav *.swp diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..dd60945 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contributing + +Thank you for helping with Dot Matrix Deck. +This guide explains how to contribute cleanly. + +## Before you start + +Read the ROADMAP file. +It lists what the project delivers and what remains. +Pick an item from the "Next" list or open an issue first. + +## Build and test + +Build the headless tools. + +```text +zig build -Dsdl2=off +``` + +Run the whole test suite. + +```text +zig build test -Dsdl2=off +``` + +Run the format check. + +```text +zig fmt --check . +``` + +Fix formatting with `zig fmt .`. +The CI pipeline enforces both checks. + +## Make a change + +Follow these rules. + +- Keep every module small and focused. +- Match the style of the surrounding code. +- Do not add comments that restate the code. +- Add deterministic tests for new behavior. +- Keep the public API stable unless a test requires a change. + +## Regenerate fixtures + +Update the demo ROM only through its source. + +```text +zig build fixtures -Dsdl2=off +``` + +Commit the `.asm` source and the generated `.gb` image together. +The round-trip test checks that they stay in sync. + +## Open a pull request + +- Write a clear title and summary. +- Keep the change as small as possible. +- Confirm that tests, format, and builds pass. +- Confirm the windowed frontend still builds when SDL2 is present. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..3eef3df --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Dot Matrix Deck contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..32c9e7d --- /dev/null +++ b/NOTICE @@ -0,0 +1,16 @@ +# Notice + +This project includes and links to the following third-party software. + +## SDL2 + +The windowed frontend links against Simple DirectMedia Layer (SDL2). +SDL2 is licensed under the zlib license. +The zlib license text is available at https://libsdl.org/license.php + +## Test ROM sources + +The bundled demo ROM is written for this project. +The blargg cpu_instrs test suite is not bundled. +You add it yourself to run the optional suite. +Refer to the original suite license when you use it. diff --git a/README.md b/README.md index fc59c84..09646cd 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,152 @@ # Dot Matrix Deck -Dot Matrix Deck is a Game Boy emulator workbench written in Zig. It models the -SM83 CPU, the memory bus, timers, serial output, and the pixel pipeline. A -headless runner makes emulator behavior easy to inspect in scripts and CI. +Dot Matrix Deck is a Game Boy emulator written in Zig. +It models the SM83 CPU, the memory bus, the timers, and the pixel pipeline. +A software renderer shows the screen in an SDL2 window. +A headless mode runs public test ROMs for automated checks. -## Current status +## Why it exists -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. +This project is a workbench for learning the Game Boy hardware. +Every part of the machine is small and inspectable. +You can step through the CPU, read the trace, and watch frames render. -## Features +The bundled demo ROM shows the core working end to end. +It draws a title, scrolls the background, and bounces a dot. +It prints PASS over the serial port. +A build step verifies that output in one command. -- 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 +## Architecture + +The core splits into small modules with one job each. + +- `src/cpu.zig` executes the SM83 instruction set. +- `src/bus.zig` routes memory between the devices. +- `src/timer.zig` models the game clock. +- `src/ppu.zig` renders background, sprites, and the window. +- `src/cartridge.zig` loads ROM-only and MBC1 images. +- `src/serial.zig` turns link-port bytes into test verdicts. +- `src/joypad.zig` reports button state through the FF00 register. +- `src/emulator.zig` ties the core together. +- `src/headless.zig` runs a ROM without a window. +- `src/main.zig` and `src/frontend.zig` form the SDL2 frontend. +- `tools/gbasm.zig` assembles SM83 test ROMs. + +The frontend logic stays free of SDL2. +Unit tests cover that logic in headless builds. ## Requirements -- Zig 0.16 or later -- SDL2 for the optional windowed frontend +You need Zig 0.16 or later. + +The headless build needs no other tools. +The windowed frontend needs the SDL2 development library. +On Windows, the build searches common MSYS2 and vcpkg prefixes. +You can set `SDL2_DIR` to an SDL2 prefix. +You can also pass `-Dsdl2=` on the build line. -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. +## Setup -## Build +Fetch the source. + +```text +git clone https://github.com/DanielCuevas1208/dot-matrix-deck.git +cd dot-matrix-deck +``` -Build the headless runner and assembler: +Build the headless tools. ```text zig build -Dsdl2=off ``` -Build with the windowed frontend when SDL2 is available: +Build the windowed frontend when SDL2 is installed. ```text zig build ``` +## Run the demo + +Run the bundled demo and check its verdict. + +```text +zig build run-demo -Dsdl2=off +``` + +Sample output: + +```text +Serial output: +DOT MATRIX DECK +PASS +Verdict: pass +``` + ## Run a ROM -Run a ROM without a window: +Run a ROM without a window. ```text zig build run-headless -- path/to/rom.gb ``` Useful options are `--max-cycles N`, `--trace`, and `--expect pass|fail|any`. -The runner prints serial output and returns a status from the detected verdict. +The runner prints serial output and returns the verdict code. -Run a ROM in the SDL2 frontend: +Run a ROM in the SDL2 frontend. ```text zig build run -- path/to/rom.gb ``` +Keys: Z is A, X is B, Enter is Start, Shift is Select. +Arrows move the d-pad. +P pauses, R resets, F fast-forwards, ESC quits. + +## Generate fixtures + +Regenerate the bundled ROMs from their assembly sources. + +```text +zig build fixtures -Dsdl2=off +``` + +Each `.asm` file in `fixtures/asm` becomes a `.gb` image in `fixtures/roms`. +The round-trip test keeps those images in sync. + ## Test -Run the core and assembler tests: +Run the whole suite. ```text zig build test -Dsdl2=off ``` -The repository also contains build steps for generated fixtures and the Blargg -CPU instruction suite. Add the required fixture files before using those steps. +The suite covers the core, the assembler, and the bundled ROMs. +It also runs the blargg CPU suite when you add the ROMs. + +```text +zig build test-blargg -Dsdl2=off +``` + +Add the blargg `cpu_instrs` ROMs under `fixtures/blargg` first. ## Project layout -- `src/cpu.zig` - SM83 CPU implementation -- `src/emulator.zig` - emulator composition and core tests -- `src/bus.zig` - memory and device routing -- `src/headless.zig` - command-line ROM runner -- `src/main.zig` - SDL2 frontend -- `tools/gbasm.zig` - small assembler for fixture ROMs +- `src/` holds the emulator core and frontend. +- `tools/` holds the assembler. +- `fixtures/` holds the demo ROM and its source. +- `.github/workflows/` holds the CI pipeline. ## Limitations -Hardware coverage is incomplete. Timing accuracy, cartridge support, audio, -and frontend features will improve as the project grows. +Hardware coverage is incomplete. +Timing accuracy, cartridge support, audio, and save games will improve. +The core starts at the post-boot state. +It does not emulate the boot ROM. ## License -No license file is published yet. Treat this repository as an experimental -project until a license is added. +This project is licensed under the MIT License. +See the LICENSE file for details. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..71f8a92 --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,39 @@ +# Roadmap + +This document tracks what the project delivers and what remains. + +## Release 0.2 + +Release 0.2 makes the emulator a usable workbench. It adds input, +a windowed frontend, an assembler, and a bundled demo ROM. + +### Done + +- SM83 CPU with the full 256-opcode set and the CB prefix group. +- Memory bus with WRAM, HRAM, and hardware I/O registers. +- Timer with DIV, TIMA, TMA, and TAC. +- PPU with background maps, tile data, and sprite rendering. +- Cartridge support for ROM-only and MBC1 images. +- Headless ROM runner with cycle caps, traces, and verdicts. +- Joypad device with column selection and press-edge interrupts. +- SDL2 windowed frontend with a DMG-style shell and pause controls. +- SM83 assembler with labels, data directives, and expressions. +- Bundled demo ROM that draws a 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 debugger overlay for pause and reset. + +## Known limits + +- The core starts at the post-boot state. Boot ROM emulation is out of scope. +- The PPU has no window line counter edge cases yet. +- The SDL2 frontend ships without audio. 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..88094fe 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = .dot_matrix_deck, - .version = "0.1.0", + .version = "0.2.0", .minimum_zig_version = "0.16.0", .fingerprint = 0x3ad14c39cc8e69a1, .paths = .{ @@ -9,7 +9,10 @@ "src", "tools", "fixtures", + "fixtures_tests.zig", + "ROADMAP.md", "README.md", + "CONTRIBUTING.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..4371b09 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]; } }; + +const testing = @import("std").testing; + +test "joypad reads the selected button column" { + const rom = [_]u8{0} ** 0x8000; + var bus = try Bus.init(testing.allocator, &rom); + defer bus.deinit(); + bus.write(0xff00, 0x20); + bus.setButton(Joypad.Button.a, true); + try testing.expectEqual(@as(u8, 0x0e), bus.read(0xff00) & 0x0f); +} + +test "joypad press edge raises the joypad interrupt" { + const rom = [_]u8{0} ** 0x8000; + var bus = try Bus.init(testing.allocator, &rom); + defer bus.deinit(); + bus.write(0xff00, 0x10); + bus.setDirection(Joypad.Direction.right, true); + try testing.expectEqual(@as(u8, 0x10), bus.iflag & 0x10); + bus.setDirection(Joypad.Direction.right, false); + bus.setDirection(Joypad.Direction.right, true); + try 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..19a204e 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; } @@ -59,6 +59,7 @@ pub const Emulator = struct { self.cpu = .{}; self.bus.ppu = .{}; self.bus.timer = .{}; + self.bus.joypad = .{}; self.bus.serial_len = 0; self.total_cycles = 0; self.total_instructions = 0; diff --git a/src/frontend.zig b/src/frontend.zig new file mode 100644 index 0000000..4f54340 --- /dev/null +++ b/src/frontend.zig @@ -0,0 +1,160 @@ +// Frontend presentation logic that does not depend on SDL2. +// +// The windowed frontend maps keyboard keys to the joypad, lays out the +// window, and shades the LCD palette. Keeping this logic free of SDL2 +// lets the unit tests run in headless builds. + +const std = @import("std"); +const Bus = @import("bus.zig").Bus; +const Joypad = @import("joypad.zig").Joypad; +const ppu = @import("ppu.zig"); + +pub const screen_scale = 3; +pub const bezel_padding = 16; + +pub const window_width = ppu.ScreenWidth * screen_scale + bezel_padding * 2; +pub const window_height = ppu.ScreenHeight * screen_scale + bezel_padding * 2; + +pub const screen_rect = struct { + pub const x: c_int = bezel_padding; + pub const y: c_int = bezel_padding; + pub const width: c_int = ppu.ScreenWidth * screen_scale; + pub const height: c_int = ppu.ScreenHeight * screen_scale; +}; + +// Logical keys. The SDL2 frontend translates scancodes into these. +pub const Key = enum { + right, + left, + up, + down, + a, + b, + select, + start, + pause, + reset, + fast_forward, +}; + +pub const KeyAction = enum { button, direction }; + +pub const Binding = struct { + key: Key, + action: KeyAction, + bit: u3, +}; + +pub const bindings = [_]Binding{ + .{ .key = .a, .action = .button, .bit = 0 }, + .{ .key = .b, .action = .button, .bit = 1 }, + .{ .key = .select, .action = .button, .bit = 2 }, + .{ .key = .start, .action = .button, .bit = 3 }, + .{ .key = .right, .action = .direction, .bit = 0 }, + .{ .key = .left, .action = .direction, .bit = 1 }, + .{ .key = .up, .action = .direction, .bit = 2 }, + .{ .key = .down, .action = .direction, .bit = 3 }, +}; + +pub const RunState = struct { + paused: bool = false, + fast_forward: bool = false, + running: bool = true, + + pub fn togglePause(self: *RunState) void { + self.paused = !self.paused; + } + + pub fn toggleFastForward(self: *RunState) void { + self.fast_forward = !self.fast_forward; + } +}; + +// Applies one key event to the bus. Returns false for control keys that +// the frontend must handle itself. +pub fn applyKey(bus: *Bus, key: Key, pressed: bool) bool { + if (key == .pause or key == .reset or key == .fast_forward) return false; + for (bindings) |binding| { + if (binding.key == key) { + switch (binding.action) { + .button => bus.setButton(@enumFromInt(binding.bit), pressed), + .direction => bus.setDirection(@enumFromInt(binding.bit), pressed), + } + return true; + } + } + return false; +} + +// DMG-inspired presentation palette. The frame stores 0xAARRGGBB values. +pub const palette = struct { + pub const shell: u32 = 0xffd9d9b8; + pub const bezel: u32 = 0xff3a3a2e; + pub const screen_back: u32 = 0xff9ca89a; + pub const paused_bar: u32 = 0xff101010; +}; + +const testing = std.testing; + +fn testBus() !Bus { + const rom = [_]u8{0} ** 0x8000; + return try Bus.init(testing.allocator, &rom); +} + +test "bindings cover the eight joypad inputs" { + var seen_buttons: u8 = 0; + var seen_directions: u8 = 0; + for (bindings) |binding| { + switch (binding.action) { + .button => seen_buttons |= @as(u8, 1) << binding.bit, + .direction => seen_directions |= @as(u8, 1) << binding.bit, + } + } + try testing.expectEqual(@as(u8, 0x0f), seen_buttons); + try testing.expectEqual(@as(u8, 0x0f), seen_directions); +} + +test "applying a key presses the right joypad column" { + var bus = try testBus(); + defer bus.deinit(); + try testing.expect(applyKey(&bus, .a, true)); + try testing.expect(bus.joypad.isButtonPressed(Joypad.Button.a)); + try testing.expect(!bus.joypad.isDirectionPressed(Joypad.Direction.right)); + try testing.expect(applyKey(&bus, .up, true)); + try testing.expect(bus.joypad.isDirectionPressed(Joypad.Direction.up)); +} + +test "releasing a key restores the released state" { + var bus = try testBus(); + defer bus.deinit(); + try testing.expect(applyKey(&bus, .b, true)); + try testing.expect(applyKey(&bus, .b, false)); + try testing.expect(!bus.joypad.isButtonPressed(Joypad.Button.b)); +} + +test "control keys are not forwarded to the joypad" { + var bus = try testBus(); + defer bus.deinit(); + try testing.expect(!applyKey(&bus, .pause, true)); + try testing.expect(!applyKey(&bus, .reset, true)); + try testing.expect(!applyKey(&bus, .fast_forward, true)); + try testing.expectEqual(@as(u8, 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..9fb1538 --- /dev/null +++ b/src/joypad.zig @@ -0,0 +1,113 @@ +// The Game Boy joypad device. +// +// The register at 0xff00 reports key state through the low nibble. +// Bit 4 and bit 5 of the register select which column is active. +// A zero bit selects a column. A pressed key drives its bit low. +// When both columns are selected, the read is the AND of the two. + +const std = @import("std"); + +pub const Joypad = struct { + buttons: u8 = 0x0f, + dpad: u8 = 0x0f, + select: u8 = 0x30, + + // Low-nibble bit positions, active low. + pub const Button = enum(u3) { a = 0, b = 1, select = 2, start = 3 }; + + pub const Direction = enum(u3) { right = 0, left = 1, up = 2, down = 3 }; + + pub fn setButton(self: *Joypad, bit: Button, pressed: bool) void { + const mask: u8 = @as(u8, 1) << @intFromEnum(bit); + self.buttons = if (pressed) self.buttons & ~mask else self.buttons | mask; + } + + pub fn setDirection(self: *Joypad, bit: Direction, pressed: bool) void { + const mask: u8 = @as(u8, 1) << @intFromEnum(bit); + self.dpad = if (pressed) self.dpad & ~mask else self.dpad | mask; + } + + pub fn isButtonPressed(self: *const Joypad, bit: Button) bool { + return (self.buttons & (@as(u8, 1) << @intFromEnum(bit))) == 0; + } + + pub fn isDirectionPressed(self: *const Joypad, bit: Direction) bool { + return (self.dpad & (@as(u8, 1) << @intFromEnum(bit))) == 0; + } + + // Reading FF00. 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); + joypad.setButton(.a, true); + joypad.setButton(.start, true); + const value = joypad.read(); + // a (bit 0) and start (bit 3) are low; B and select stay high. + try testing.expectEqual(@as(u8, 0x06), value & 0x0f); +} + +test "dpad column reflects pressed directions" { + var joypad = Joypad{}; + joypad.write(0x10); + joypad.setDirection(.up, true); + joypad.setDirection(.down, true); + const value = joypad.read(); + // up (bit 2) and down (bit 3) are low; right and left stay high. + try testing.expectEqual(@as(u8, 0x03), value & 0x0f); +} + +test "unselected column reads released" { + var joypad = Joypad{}; + joypad.setButton(.a, true); + joypad.setDirection(.right, true); + joypad.write(0x00); + try testing.expectEqual(@as(u8, 0xcf), joypad.read()); +} + +test "both columns selected reads the AND of both" { + var joypad = Joypad{}; + joypad.write(0x30); + joypad.setButton(.a, true); + joypad.setDirection(.right, true); + // bit 0 is driven low by both columns. + try testing.expectEqual(@as(u8, 0x0e), joypad.read() & 0x0f); +} + +test "release restores the released state" { + var joypad = Joypad{}; + joypad.write(0x10); + joypad.setButton(.b, true); + joypad.setButton(.b, false); + try testing.expectEqual(@as(u8, 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..e8ef019 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,237 @@ 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)); +} + +// Draws faint lines between pixels to echo the DMG dot matrix. +fn drawPixelGrid(renderer: *c.SDL_Renderer) void { + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_BLEND); + _ = c.SDL_SetRenderDrawColor(renderer, 0x10, 0x14, 0x10, 0x26); + var index: usize = 1; + while (index < ppu.ScreenWidth) : (index += 1) { + const x = frontend.screen_rect.x + @as(c_int, @intCast(index * frontend.screen_scale)); + _ = c.SDL_RenderDrawLine(renderer, x, frontend.screen_rect.y, x, frontend.screen_rect.y + frontend.screen_rect.height - 1); + } + index = 1; + while (index < ppu.ScreenHeight) : (index += 1) { + const y = frontend.screen_rect.y + @as(c_int, @intCast(index * frontend.screen_scale)); + _ = c.SDL_RenderDrawLine(renderer, frontend.screen_rect.x, y, frontend.screen_rect.x + frontend.screen_rect.width - 1, y); + } + _ = c.SDL_SetRenderDrawBlendMode(renderer, c.SDL_BLENDMODE_NONE); +} + +// Re-applies held keys after a reset so the joypad state stays in sync. +fn syncHeldKeys(emulator: *Emulator) 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..a69c5cd 100644 --- a/tools/gbasm.zig +++ b/tools/gbasm.zig @@ -1,5 +1,880 @@ +// gbasm - a small SM83 assembler for Game Boy test ROMs. +// +// It reads a single .asm file or a whole directory of sources and +// writes raw .gb images. The assembler targets the full 256-opcode +// SM83 set plus the CB prefix group. +// +// Directives: +// ORG
set the output position, filling gaps with zero +// PAD
alias for ORG, used to size the final image +// DB emit bytes; items may be strings or numbers +// DW emit little-endian 16-bit values +// DS [,] emit bytes of +// +// Numbers accept $hex, 0xhex, %binary, or decimal. Labels are written +// as `name:` and may be forward-referenced. A `;` starts a comment. + const std = @import("std"); -pub fn main() !void { - std.debug.print("gbasm: assembler is under construction.\n", .{}); +pub const AssemblerError = error{ + Syntax, + UnknownMnemonic, + BadOperand, + BadOperandCount, + DuplicateLabel, + UnknownSymbol, + BranchOutOfRange, + AddressOverlap, + NumberOutOfRange, + BadRstVector, + TooManyOperands, + OutOfMemory, +}; + +const reg8_names = [_][]const u8{ "B", "C", "D", "E", "H", "L", "(HL)", "A" }; +const reg16_names = [_][]const u8{ "BC", "DE", "HL", "SP" }; +const cond_names = [_][]const u8{ "NZ", "Z", "NC", "C" }; + +pub const Assembler = struct { + allocator: std.mem.Allocator, + output: std.ArrayList(u8), + symbols: std.StringHashMap(u16), + address: u16 = 0, + emit: bool = false, + + pub fn init(allocator: std.mem.Allocator) Assembler { + return .{ + .allocator = allocator, + .output = std.ArrayList(u8).empty, + .symbols = std.StringHashMap(u16).init(allocator), + }; + } + + pub fn deinit(self: *Assembler) void { + self.output.deinit(self.allocator); + self.symbols.deinit(); + } + + // Runs a sizing pass, then an emit pass. Returns the ROM image. + pub fn assemble(self: *Assembler, source: []const u8) AssemblerError![]u8 { + try self.runPass(source); + self.emit = true; + try self.runPass(source); + return self.output.toOwnedSlice(self.allocator); + } + + fn runPass(self: *Assembler, source: []const u8) AssemblerError!void { + self.address = 0; + self.output.clearRetainingCapacity(); + + var lines = std.mem.splitScalar(u8, source, '\n'); + while (lines.next()) |raw_line| { + const comment = std.mem.indexOfScalar(u8, raw_line, ';'); + const line = std.mem.trim(u8, raw_line[0 .. comment orelse raw_line.len], " \t\r"); + if (line.len == 0) continue; + + var rest = line; + if (std.mem.indexOfScalar(u8, line, ':')) |colon| { + const candidate = line[0..colon]; + if (candidate.len != 0 and std.mem.indexOfAny(u8, candidate, " \t") == null) { + try self.defineLabel(candidate); + rest = std.mem.trim(u8, line[colon + 1 ..], " \t"); + if (rest.len == 0) continue; + } + } + + const mnemonic = nextToken(rest); + const operands = std.mem.trim(u8, rest[mnemonic.len..], " \t"); + try self.emitLine(mnemonic, operands); + } + } + + fn defineLabel(self: *Assembler, name: []const u8) AssemblerError!void { + if (self.emit) return; + const gop = try self.symbols.getOrPut(name); + if (gop.found_existing) return error.DuplicateLabel; + gop.value_ptr.* = self.address; + } + + fn emitLine(self: *Assembler, mnemonic: []const u8, operands: []const u8) AssemblerError!void { + if (eqlI(mnemonic, "ORG") or eqlI(mnemonic, "PAD")) { + const target = try self.eval(operands); + if (target < 0) return error.AddressOverlap; + const fill_to: u16 = @intCast(target); + if (fill_to < self.address) return error.AddressOverlap; + while (self.address < fill_to) try self.emitByte(0x00); + return; + } + if (eqlI(mnemonic, "DB")) return self.emitData(operands, false); + if (eqlI(mnemonic, "DW")) return self.emitData(operands, true); + if (eqlI(mnemonic, "DS")) return self.emitSpace(operands); + return self.encode(mnemonic, operands); + } + + fn emitData(self: *Assembler, operands: []const u8, wide: bool) AssemblerError!void { + var items: [64][]const u8 = undefined; + const count = try splitItems(operands, &items); + for (items[0..count]) |item| { + if (item.len >= 2 and item[0] == '"' and item[item.len - 1] == '"') { + const text = item[1 .. item.len - 1]; + if (wide) { + for (text) |byte| { + try self.emitByte(byte); + try self.emitByte(0); + } + } else { + for (text) |byte| try self.emitByte(byte); + } + continue; + } + const value = try self.eval(item); + if (wide) { + if (value < 0 or value > 0xffff) return error.NumberOutOfRange; + const word: u16 = @intCast(value); + try self.emitByte(@truncate(word)); + try self.emitByte(@truncate(word >> 8)); + } else { + if (value < -128 or value > 0xff) return error.NumberOutOfRange; + try self.emitByte(lowByte(value)); + } + } + } + + fn emitSpace(self: *Assembler, operands: []const u8) AssemblerError!void { + var items: [2][]const u8 = undefined; + const count = try splitItems(operands, &items); + if (count < 1 or count > 2) return error.BadOperandCount; + const length = try self.eval(items[0]); + if (length < 0 or length > 0xffff) return error.NumberOutOfRange; + const fill: u8 = if (count == 2) blk: { + const value = try self.eval(items[1]); + if (value < 0 or value > 0xff) return error.NumberOutOfRange; + break :blk @intCast(value); + } else 0; + var remaining: usize = @intCast(length); + while (remaining != 0) : (remaining -= 1) try self.emitByte(fill); + } + + fn emitByte(self: *Assembler, value: u8) AssemblerError!void { + if (self.emit) self.output.append(self.allocator, value) catch return error.OutOfMemory; + self.address +%= 1; + } + + // Parses a signed expression: numbers, labels, and + and - terms. + fn eval(self: *Assembler, text_in: []const u8) AssemblerError!i32 { + const text = std.mem.trim(u8, text_in, " \t"); + if (text.len == 0) return error.Syntax; + var total: i32 = 0; + var sign: i32 = 1; + var index: usize = 0; + while (index < text.len) { + const ch = text[index]; + if (ch == '+' or ch == '-') { + sign = if (ch == '+') 1 else -1; + index += 1; + continue; + } + const start = index; + while (index < text.len and text[index] != '+' and text[index] != '-') index += 1; + const term = text[start..index]; + total += sign * try self.evalTerm(term); + sign = 1; + } + return total; + } + + fn evalTerm(self: *Assembler, term: []const u8) AssemblerError!i32 { + if (term.len == 0) return error.Syntax; + const value = self.parseNumber(term) orelse blk: { + if (self.symbols.get(term)) |symbol_value| break :blk @as(i32, symbol_value); + if (!self.emit) break :blk 0; // forward reference during the sizing pass + return error.UnknownSymbol; + }; + return value; + } + + fn parseNumber(self: *Assembler, term: []const u8) ?i32 { + _ = self; + if (term.len >= 2 and term[0] == '$') return parseRadix(term[1..], 16) orelse return null; + if (term.len >= 3 and term[0] == '0' and (term[1] == 'x' or term[1] == 'X')) return parseRadix(term[2..], 16) orelse return null; + if (term.len >= 2 and term[0] == '%') return parseRadix(term[1..], 2) orelse return null; + if (term[0] >= '0' and term[0] <= '9') return parseRadix(term, 10) orelse return null; + return null; + } + + fn encode(self: *Assembler, mnemonic: []const u8, operands: []const u8) AssemblerError!void { + var parts: [4][]const u8 = undefined; + const count = try splitItems(operands, &parts); + + if (eqlI(mnemonic, "LD")) return self.encodeLd(parts[0..count], count); + if (eqlI(mnemonic, "LDH")) return self.encodeLdh(parts[0..count], count); + + const op: ?u3 = blk: { + const names = [_][]const u8{ "ADD", "ADC", "SUB", "SBC", "AND", "XOR", "OR", "CP" }; + for (names, 0..) |name, i| { + if (eqlI(mnemonic, name)) break :blk @as(u3, @intCast(i)); + } + break :blk null; + }; + + if (op) |alu_op| return self.encodeAlu(alu_op, parts[0..count], count); + + if (eqlI(mnemonic, "INC")) return self.encodeIncDec(parts[0..count], count, true); + if (eqlI(mnemonic, "DEC")) return self.encodeIncDec(parts[0..count], count, false); + if (eqlI(mnemonic, "JP")) return self.encodeJp(parts[0..count], count); + if (eqlI(mnemonic, "JR")) return self.encodeJr(parts[0..count], count); + if (eqlI(mnemonic, "CALL")) return self.encodeCall(parts[0..count], count); + if (eqlI(mnemonic, "RET")) return self.encodeRet(parts[0..count], count); + if (eqlI(mnemonic, "RETI")) { + if (count != 0) return error.BadOperandCount; + return self.emitByte(0xd9); + } + if (eqlI(mnemonic, "RST")) return self.encodeRst(parts[0..count], count); + if (eqlI(mnemonic, "PUSH") or eqlI(mnemonic, "POP")) return self.encodePushPop(mnemonic, parts[0..count], count); + if (eqlI(mnemonic, "RLC") or eqlI(mnemonic, "RRC") or + eqlI(mnemonic, "RL") or eqlI(mnemonic, "RR") or + eqlI(mnemonic, "SLA") or eqlI(mnemonic, "SRA") or + eqlI(mnemonic, "SWAP") or eqlI(mnemonic, "SRL")) return self.encodeShift(mnemonic, parts[0..count], count); + if (eqlI(mnemonic, "BIT") or eqlI(mnemonic, "RES") or eqlI(mnemonic, "SET")) return self.encodeBitOp(mnemonic, parts[0..count], count); + + const zero_arg = [_][]const u8{ "NOP", "HALT", "DI", "EI", "RLCA", "RLA", "RRCA", "RRA", "DAA", "CPL", "SCF", "CCF" }; + const zero_bytes = [_]u8{ 0x00, 0x76, 0xf3, 0xfb, 0x07, 0x17, 0x0f, 0x1f, 0x27, 0x2f, 0x37, 0x3f }; + for (zero_arg, 0..) |name, i| { + if (eqlI(mnemonic, name)) { + if (count != 0) return error.BadOperandCount; + return self.emitByte(zero_bytes[i]); + } + } + + if (eqlI(mnemonic, "STOP")) { + if (count != 0) return error.BadOperandCount; + try self.emitByte(0x10); + return self.emitByte(0x00); + } + + return error.UnknownMnemonic; + } + + fn encodeLd(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 2) return error.BadOperandCount; + const dst = parts[0]; + const src = parts[1]; + + if (std.mem.eql(u8, dst, "(C)") and std.mem.eql(u8, src, "A")) return self.emitByte(0xe2); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(C)")) return self.emitByte(0xf2); + if (std.mem.eql(u8, dst, "(BC)") and std.mem.eql(u8, src, "A")) return self.emitByte(0x02); + if (std.mem.eql(u8, dst, "(DE)") and std.mem.eql(u8, src, "A")) return self.emitByte(0x12); + if (std.mem.eql(u8, dst, "(HL)") and std.mem.eql(u8, src, "A")) return self.emitByte(0x77); + if (std.mem.eql(u8, dst, "(HL+)") and std.mem.eql(u8, src, "A")) return self.emitByte(0x22); + if (std.mem.eql(u8, dst, "(HL-)") and std.mem.eql(u8, src, "A")) return self.emitByte(0x32); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(BC)")) return self.emitByte(0x0a); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(DE)")) return self.emitByte(0x1a); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(HL)")) return self.emitByte(0x7e); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(HL+)")) return self.emitByte(0x2a); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(HL-)")) return self.emitByte(0x3a); + if (std.mem.eql(u8, dst, "SP") and std.mem.eql(u8, src, "HL")) return self.emitByte(0xf9); + + if (std.mem.eql(u8, dst, "HL") and startsWith(src, "SP+")) return self.encodeLdHlSpSigned(0xf8, src[3..]); + if (std.mem.eql(u8, dst, "HL") and startsWith(src, "SP-")) return self.encodeLdHlSpNegated(0xf8, src[3..]); + + if (isAddress(dst)) { + const address = try self.eval(dst[1 .. dst.len - 1]); + if (std.mem.eql(u8, src, "SP")) { + if (address < 0 or address > 0xffff) return error.NumberOutOfRange; + try self.emitByte(0x08); + try self.emitByte(@truncate(@as(u16, @intCast(address)))); + return self.emitByte(@truncate(@as(u16, @intCast(address)) >> 8)); + } + if (std.mem.eql(u8, src, "A")) { + if (address < 0 or address > 0xffff) return error.NumberOutOfRange; + try self.emitByte(0xea); + try self.emitByte(@truncate(@as(u16, @intCast(address)))); + return self.emitByte(@truncate(@as(u16, @intCast(address)) >> 8)); + } + } + if (std.mem.eql(u8, dst, "A") and isAddress(src)) { + const address = try self.eval(src[1 .. src.len - 1]); + if (address < 0 or address > 0xffff) return error.NumberOutOfRange; + try self.emitByte(0xfa); + try self.emitByte(@truncate(@as(u16, @intCast(address)))); + return self.emitByte(@truncate(@as(u16, @intCast(address)) >> 8)); + } + + if (reg16Index(dst)) |pair| { + const value = try self.eval(src); + if (value < 0 or value > 0xffff) return error.NumberOutOfRange; + try self.emitByte(0x01 | @as(u8, pair) << 4); + try self.emitByte(@truncate(@as(u16, @intCast(value)))); + return self.emitByte(@truncate(@as(u16, @intCast(value)) >> 8)); + } + + if (reg8Index(dst)) |dest_reg| { + if (reg8Index(src)) |source_reg| { + if (dest_reg == 6 and source_reg == 6) return error.BadOperand; + return self.emitByte(0x40 | @as(u8, dest_reg) << 3 | @as(u8, source_reg)); + } + const value = try self.eval(src); + try self.emitByte(0x06 | @as(u8, dest_reg) << 3); + return self.emitByte(lowByte(value)); + } + + return error.BadOperand; + } + + // HL <- SP + e, where e may be negative. + fn encodeLdHlSpSigned(self: *Assembler, opcode: u8, offset_text: []const u8) AssemblerError!void { + const offset = try self.eval(offset_text); + try self.emitByte(opcode); + return self.emitByte(lowByte(offset)); + } + + // HL <- SP - e. The sign is applied before encoding the low byte. + fn encodeLdHlSpNegated(self: *Assembler, opcode: u8, offset_text: []const u8) AssemblerError!void { + const offset = -(try self.eval(offset_text)); + try self.emitByte(opcode); + return self.emitByte(lowByte(offset)); + } + + fn encodeLdh(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 2) return error.BadOperandCount; + const dst = parts[0]; + const src = parts[1]; + if (std.mem.eql(u8, dst, "(C)") and std.mem.eql(u8, src, "A")) return self.emitByte(0xe2); + if (std.mem.eql(u8, dst, "A") and std.mem.eql(u8, src, "(C)")) return self.emitByte(0xf2); + if (isAddress(dst) and std.mem.eql(u8, src, "A")) { + const value = try self.eval(dst[1 .. dst.len - 1]); + try self.emitByte(0xe0); + return self.emitByte(lowByte(value)); + } + if (std.mem.eql(u8, dst, "A") and isAddress(src)) { + const value = try self.eval(src[1 .. src.len - 1]); + try self.emitByte(0xf0); + return self.emitByte(lowByte(value)); + } + return error.BadOperand; + } + + fn encodeAlu(self: *Assembler, operation: u3, parts: []const []const u8, count: usize) AssemblerError!void { + if (count == 2) { + const dst = parts[0]; + const src = parts[1]; + if (operation == 0 and std.mem.eql(u8, dst, "HL")) { + if (reg16Index(src)) |pair| return self.emitByte(0x09 | @as(u8, pair) << 4); + } + if (operation == 0 and std.mem.eql(u8, dst, "SP")) { + if (startsWith(src, "+")) return self.encodeLdHlSpSigned(0xe8, src[1..]); + if (startsWith(src, "-")) return self.encodeLdHlSpNegated(0xe8, src[1..]); + return self.encodeLdHlSpSigned(0xe8, src); + } + if (!std.mem.eql(u8, dst, "A")) return error.BadOperand; + if (reg8Index(src)) |source_reg| return self.emitByte(0x80 | @as(u8, operation) << 3 | source_reg); + const value = try self.eval(src); + try self.emitByte(0xc6 | @as(u8, operation) << 3); + return self.emitByte(lowByte(value)); + } + if (count == 1) { + if (reg8Index(parts[0])) |source_reg| return self.emitByte(0x80 | @as(u8, operation) << 3 | source_reg); + const value = try self.eval(parts[0]); + try self.emitByte(0xc6 | @as(u8, operation) << 3); + return self.emitByte(lowByte(value)); + } + return error.BadOperandCount; + } + + fn encodeIncDec(self: *Assembler, parts: []const []const u8, count: usize, increment: bool) AssemblerError!void { + if (count != 1) return error.BadOperandCount; + if (reg8Index(parts[0])) |reg| { + const base: u8 = if (increment) 0x04 else 0x05; + return self.emitByte(base | @as(u8, reg) << 3); + } + if (reg16Index(parts[0])) |pair| { + const base: u8 = if (increment) 0x03 else 0x0b; + return self.emitByte(base | @as(u8, pair) << 4); + } + return error.BadOperand; + } + + fn encodeJp(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count == 1) { + if (std.mem.eql(u8, parts[0], "(HL)")) return self.emitByte(0xe9); + const target = try self.eval(parts[0]); + return self.emitAddress(0xc3, target); + } + if (count == 2) { + if (condIndex(parts[0])) |condition| { + const target = try self.eval(parts[1]); + return self.emitAddress(0xc2 | @as(u8, condition) << 3, target); + } + } + return error.BadOperand; + } + + fn encodeJr(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count < 1 or count > 2) return error.BadOperandCount; + const base: u8 = if (count == 1) 0x18 else 0x20 | @as(u8, condIndex(parts[0]) orelse return error.BadOperand) << 3; + const target_text = if (count == 1) parts[0] else parts[1]; + const target = try self.eval(target_text); + const after = @as(i32, self.address) + 2; + const displacement = target - after; + if (self.emit and (displacement < -128 or displacement > 127)) return error.BranchOutOfRange; + try self.emitByte(base); + return self.emitByte(lowByte(displacement)); + } + + fn encodeCall(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count == 1) { + const target = try self.eval(parts[0]); + return self.emitAddress(0xcd, target); + } + if (count == 2) { + if (condIndex(parts[0])) |condition| { + const target = try self.eval(parts[1]); + return self.emitAddress(0xc4 | @as(u8, condition) << 3, target); + } + } + return error.BadOperand; + } + + fn encodeRet(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count == 0) return self.emitByte(0xc9); + if (count == 1) { + if (condIndex(parts[0])) |condition| return self.emitByte(0xc0 | @as(u8, condition) << 3); + } + return error.BadOperand; + } + + fn encodeRst(self: *Assembler, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 1) return error.BadOperandCount; + const vector = try self.eval(parts[0]); + if (vector < 0 or vector > 0x38 or (vector & 0x07) != 0) return error.BadRstVector; + return self.emitByte(0xc7 | @as(u8, @intCast(vector))); + } + + fn encodePushPop(self: *Assembler, mnemonic: []const u8, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 1) return error.BadOperandCount; + const names = [_][]const u8{ "BC", "DE", "HL", "AF" }; + for (names, 0..) |name, i| { + if (std.mem.eql(u8, parts[0], name)) { + const base: u8 = if (eqlI(mnemonic, "PUSH")) 0xc5 else 0xc1; + return self.emitByte(base | @as(u8, @intCast(i)) << 4); + } + } + return error.BadOperand; + } + + fn encodeShift(self: *Assembler, mnemonic: []const u8, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 1) return error.BadOperandCount; + const reg = reg8Index(parts[0]) orelse return error.BadOperand; + const names = [_][]const u8{ "RLC", "RRC", "RL", "RR", "SLA", "SRA", "SWAP", "SRL" }; + var operation: u8 = 0; + for (names, 0..) |name, i| { + if (eqlI(mnemonic, name)) { + operation = @intCast(i); + break; + } + } + try self.emitByte(0xcb); + return self.emitByte(operation << 3 | reg); + } + + fn encodeBitOp(self: *Assembler, mnemonic: []const u8, parts: []const []const u8, count: usize) AssemblerError!void { + if (count != 2) return error.BadOperandCount; + const bit_value = try self.eval(parts[0]); + if (bit_value < 0 or bit_value > 7) return error.NumberOutOfRange; + const reg = reg8Index(parts[1]) orelse return error.BadOperand; + const base: u8 = if (eqlI(mnemonic, "BIT")) 0x40 else if (eqlI(mnemonic, "RES")) 0x80 else 0xc0; + try self.emitByte(0xcb); + return self.emitByte(base | @as(u8, @intCast(bit_value)) << 3 | reg); + } + + fn emitAddress(self: *Assembler, opcode: u8, value: i32) AssemblerError!void { + if (value < 0 or value > 0xffff) return error.NumberOutOfRange; + const word: u16 = @intCast(value); + try self.emitByte(opcode); + try self.emitByte(@truncate(word)); + return self.emitByte(@truncate(word >> 8)); + } +}; + +fn reg8Index(text: []const u8) ?u3 { + for (reg8_names, 0..) |name, i| { + if (std.mem.eql(u8, text, name)) return @intCast(i); + } + return null; +} + +fn reg16Index(text: []const u8) ?u2 { + for (reg16_names, 0..) |name, i| { + if (std.mem.eql(u8, text, name)) return @intCast(i); + } + return null; +} + +fn condIndex(text: []const u8) ?u2 { + for (cond_names, 0..) |name, i| { + if (std.mem.eql(u8, text, name)) return @intCast(i); + } + return null; +} + +fn isAddress(text: []const u8) bool { + return text.len >= 3 and text[0] == '(' and text[text.len - 1] == ')' and + !std.mem.eql(u8, text, "(HL)") and !std.mem.eql(u8, text, "(HL+)") and + !std.mem.eql(u8, text, "(HL-)") and !std.mem.eql(u8, text, "(BC)") and + !std.mem.eql(u8, text, "(DE)") and !std.mem.eql(u8, text, "(C)"); +} + +fn startsWith(text: []const u8, prefix: []const u8) bool { + return std.mem.startsWith(u8, text, prefix); +} + +fn eqlI(a: []const u8, b: []const u8) bool { + return std.ascii.eqlIgnoreCase(a, b); +} + +// Low byte of a signed value in two's complement form. +fn lowByte(value: i32) u8 { + return @truncate(@as(u32, @bitCast(value))); +} + +fn nextToken(text: []const u8) []const u8 { + const end = std.mem.indexOfAny(u8, text, " \t") orelse text.len; + return text[0..end]; +} + +// Splits comma-separated items, keeping quoted strings intact. +fn splitItems(text: []const u8, items: [][]const u8) AssemblerError!usize { + var count: usize = 0; + var index: usize = 0; + while (index < text.len) { + while (index < text.len and (text[index] == ' ' or text[index] == '\t' or text[index] == ',')) index += 1; + if (index >= text.len) break; + const start = index; + var in_string = false; + while (index < text.len) : (index += 1) { + const ch = text[index]; + if (ch == '"') in_string = !in_string; + if (ch == ',' and !in_string) break; + } + if (count == items.len) return error.TooManyOperands; + items[count] = text[start..index]; + count += 1; + } + return count; +} + +fn parseRadix(digits: []const u8, base: u8) ?i32 { + var value: i64 = 0; + for (digits) |digit| { + const nibble: i64 = switch (digit) { + '0'...'9' => digit - '0', + 'a'...'f' => digit - 'a' + 10, + 'A'...'F' => digit - 'A' + 10, + else => return null, + }; + if (nibble >= base) return null; + value = value * base + nibble; + if (value > 0x7fffffff) return null; + } + return @intCast(value); +} + +const testing = std.testing; + +fn assembleBytes(source: []const u8) ![]u8 { + var assembler = Assembler.init(testing.allocator); + defer assembler.deinit(); + return assembler.assemble(source); +} + +test "assembles a minimal program" { + const bytes = try assembleBytes( + \\ ORG $0100 + \\ JP start + \\start: + \\ LD A,$12 + \\ HALT + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqualSlices(u8, &.{ 0xc3, 0x03, 0x01, 0x3e, 0x12, 0x76 }, bytes[0x0100..]); +} + +test "assembles register moves and arithmetic" { + const bytes = try assembleBytes( + \\ LD B,A + \\ LD C,(HL) + \\ ADD A,B + \\ ADC A,(HL) + \\ SUB B + \\ XOR A + \\ CP $40 + \\ INC (HL) + \\ DEC B + \\ INC HL + \\ ADD HL,DE + \\ ADD SP,-$06 + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqualSlices(u8, &.{ 0x47, 0x4e, 0x80, 0x8e, 0x90, 0xaf, 0xfe, 0x40, 0x34, 0x05, 0x23, 0x19, 0xe8, 0xfa }, bytes); +} + +test "assembles jumps, calls, and stack operations" { + const bytes = try assembleBytes( + \\ ORG $0200 + \\ JP target + \\ JR NZ,target + \\ CALL Z,target + \\ PUSH HL + \\ POP AF + \\ RET + \\ RETI + \\ RST $38 + \\ JP (HL) + \\target: + \\ NOP + \\ + ); + defer testing.allocator.free(bytes); + const expected = [_]u8{ + 0xc3, 0x0e, 0x02, + 0x20, 0x09, 0xcc, + 0x0e, 0x02, 0xe5, + 0xf1, 0xc9, 0xd9, + 0xff, 0xe9, 0x00, + }; + try testing.expectEqualSlices(u8, &expected, bytes[0x0200..]); +} + +test "assembles CB prefix group" { + const bytes = try assembleBytes( + \\ RLC A + \\ SRA (HL) + \\ BIT 3,L + \\ SET 7,(HL) + \\ RES 0,A + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqualSlices(u8, &.{ 0xcb, 0x07, 0xcb, 0x2e, 0xcb, 0x5d, 0xcb, 0xfe, 0xcb, 0x87 }, bytes); +} + +test "assembles ldh and memory forms" { + const bytes = try assembleBytes( + \\ LDH ($FF44),A + \\ LDH A,($FF40) + \\ LD (C),A + \\ LD A,(C) + \\ LD (BC),A + \\ LD A,(HL+) + \\ LD (HL-),A + \\ LD (MAP),SP + \\ LD HL,SP+$10 + \\ ORG $3000 + \\MAP: + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqualSlices(u8, &.{ 0xe0, 0x44, 0xf0, 0x40, 0xe2, 0xf2, 0x02, 0x2a, 0x32, 0x08, 0x00, 0x30, 0xf8, 0x10 }, bytes[0..14]); + try testing.expectEqual(@as(u16, 0x3000), bytes.len); +} + +test "assembles data directives and pads to size" { + const bytes = try assembleBytes( + \\ ORG $0000 + \\ DB "Passed", $0A, $00 + \\ DW $1234 + \\ DS 3,$FF + \\ PAD $0010 + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqual(@as(usize, 0x10), bytes.len); + try testing.expectEqualSlices(u8, "Passed", bytes[0..6]); + try testing.expectEqual(@as(u8, 0x0a), bytes[6]); + try testing.expectEqual(@as(u8, 0x00), bytes[7]); + try testing.expectEqualSlices(u8, &.{ 0x34, 0x12 }, bytes[8..10]); + try testing.expectEqualSlices(u8, &.{ 0xff, 0xff, 0xff }, bytes[10..13]); +} + +test "backward branch encodes the right displacement" { + const bytes = try assembleBytes( + \\ ORG $1000 + \\loop: + \\ JR loop + \\ + ); + defer testing.allocator.free(bytes); + try testing.expectEqualSlices(u8, &.{ 0x18, 0xfe }, bytes[0x1000..]); +} + +test "forward branch over data" { + const bytes = try assembleBytes( + \\ ORG $0100 + \\ JR skip + \\ DB $00, $00, $00 + \\skip: + \\ NOP + \\ + ); + defer testing.allocator.free(bytes); + // JR is two bytes; skip is at 0105, so displacement is 0105-0102 = 3. + try testing.expectEqualSlices(u8, &.{ 0x18, 0x03 }, bytes[0x0100..0x0102]); + try testing.expectEqual(@as(u8, 0x00), bytes[0x0105]); +} + +test "rejects a branch out of range" { + var assembler = Assembler.init(testing.allocator); + defer assembler.deinit(); + const source = + \\ ORG $0000 + \\ JR far + \\ DS $100,$00 + \\far: + \\ + ; + try testing.expectError(AssemblerError.BranchOutOfRange, assembler.assemble(source)); +} + +test "rejects an unknown mnemonic" { + var assembler = Assembler.init(testing.allocator); + defer assembler.deinit(); + try testing.expectError(AssemblerError.UnknownMnemonic, assembler.assemble(" BOGUS\n")); +} + +test "rejects a duplicate label" { + var assembler = Assembler.init(testing.allocator); + defer assembler.deinit(); + try testing.expectError(AssemblerError.DuplicateLabel, assembler.assemble( + \\start: + \\start: + \\ + )); +} + +fn usage(program: []const u8) void { + std.debug.print( + \\gbasm - SM83 assembler for Game Boy test ROMs + \\Usage: + \\ {s} [output.gb] + \\ {s} + \\ + \\Assembles every .asm file in the directory mode and writes a + \\matching .gb image next to each source name. + \\ + , .{ program, program }); +} + +fn readFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const dir = std.Io.Dir.cwd(); + const file = try dir.openFile(io, path, .{}); + defer file.close(io); + const size = try file.length(io); + const buffer = try allocator.alloc(u8, @intCast(size)); + _ = try file.readPositionalAll(io, buffer, 0); + return buffer; +} + +fn isDirectory(io: std.Io, path: []const u8) bool { + const stat = std.Io.Dir.cwd().statFile(io, path, .{}) catch return false; + return stat.kind == .directory; +} + +fn assembleOne(io: std.Io, allocator: std.mem.Allocator, input_path: []const u8, output_path: []const u8) !void { + const source = try readFile(io, allocator, input_path); + defer allocator.free(source); + + var assembler = Assembler.init(allocator); + defer assembler.deinit(); + const image = assembler.assemble(source) catch |err| { + std.debug.print("{s}: error {s}\n", .{ input_path, @errorName(err) }); + std.process.exit(1); + }; + defer allocator.free(image); + + if (std.fs.path.dirname(output_path)) |parent| { + if (parent.len != 0) { + std.Io.Dir.cwd().createDirPath(io, parent) catch |err| { + std.debug.print("{s}: create error {s}\n", .{ parent, @errorName(err) }); + std.process.exit(1); + }; + } + } + std.Io.Dir.cwd().writeFile(io, .{ .sub_path = output_path, .data = image }) catch |err| { + std.debug.print("{s}: write error {s}\n", .{ output_path, @errorName(err) }); + std.process.exit(1); + }; + std.debug.print("{s} -> {s} ({d} bytes)\n", .{ input_path, output_path, image.len }); +} + +fn assembleDirectory(io: std.Io, allocator: std.mem.Allocator, input_dir: []const u8, output_dir: []const u8) !void { + std.Io.Dir.cwd().createDirPath(io, output_dir) catch |err| { + std.debug.print("{s}: create error {s}\n", .{ output_dir, @errorName(err) }); + std.process.exit(1); + }; + + var dir = try std.Io.Dir.cwd().openDir(io, input_dir, .{ .iterate = true }); + defer dir.close(io); + var names: std.ArrayList([]const u8) = .empty; + defer names.deinit(allocator); + var iter = dir.iterate(); + while (try iter.next(io)) |entry| { + if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".asm")) { + try names.append(allocator, try allocator.dupe(u8, entry.name)); + } + } + std.mem.sort([]const u8, names.items, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.order(u8, a, b) == .lt; + } + }.lessThan); + + if (names.items.len == 0) { + std.debug.print("no .asm sources found in {s}\n", .{input_dir}); + std.process.exit(1); + } + + for (names.items) |name| { + defer allocator.free(name); + const input_path = try std.fs.path.join(allocator, &.{ input_dir, name }); + defer allocator.free(input_path); + const base = name[0 .. name.len - 4]; + const output_name = try std.fmt.allocPrint(allocator, "{s}.gb", .{base}); + defer allocator.free(output_name); + const output_path = try std.fs.path.join(allocator, &.{ output_dir, output_name }); + defer allocator.free(output_path); + try assembleOne(io, allocator, input_path, output_path); + } +} + +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; + + var it = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator); + defer it.deinit(); + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(allocator); + while (it.next()) |arg| try args.append(allocator, arg); + + if (args.items.len < 2 or args.items.len > 3) { + usage(args.items[0]); + std.process.exit(2); + } + + if (isDirectory(io, args.items[1])) { + if (args.items.len != 3) { + usage(args.items[0]); + std.process.exit(2); + } + return assembleDirectory(io, allocator, args.items[1], args.items[2]); + } + + const input = args.items[1]; + const output = if (args.items.len == 3) args.items[2] else blk: { + break :blk try std.fmt.allocPrint(allocator, "{s}.gb", .{input[0 .. input.len - 4]}); + }; + defer if (args.items.len == 2) allocator.free(output); + return assembleOne(io, allocator, input, output); } From 72509a37b15b5b726ce819cd51f790287302261f Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Mon, 3 Aug 2026 23:40:36 -0700 Subject: [PATCH 2/6] feat: extend dot matrix deck --- .gitattributes | 11 ++++ CONTRIBUTING.md | 8 ++- NOTICE | 2 +- README.md | 17 +++++- ROADMAP.md | 9 +-- build.zig.zon | 1 + fixtures/asm/banking.asm | 102 ++++++++++++++++++++++++++++++++ fixtures/asm/dma.asm | 101 +++++++++++++++++++++++++++++++ fixtures/roms/banking.gb | Bin 0 -> 65535 bytes fixtures/roms/dma.gb | Bin 0 -> 32768 bytes fixtures_tests.zig | 60 ++++++++++++++----- src/bus.zig | 54 +++++++++++++++-- src/cartridge.zig | 124 +++++++++++++++++++++++++++++++++++++++ src/dma.zig | 73 +++++++++++++++++++++++ src/emulator.zig | 6 ++ src/frontend.zig | 2 +- src/joypad.zig | 36 ++++++++---- src/tests.zig | 2 + 18 files changed, 564 insertions(+), 44 deletions(-) create mode 100644 .gitattributes create mode 100644 fixtures/asm/banking.asm create mode 100644 fixtures/asm/dma.asm create mode 100644 fixtures/roms/banking.gb create mode 100644 fixtures/roms/dma.gb create mode 100644 src/dma.zig diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..f340ca8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,11 @@ +# Normalize line endings so format checks behave the same on every OS. +# Files are stored with LF and checked out with LF on all platforms. +* text=auto +*.zig text eol=lf +*.zon text eol=lf +*.md text eol=lf +*.yml text eol=lf +*.asm text eol=lf +*.gb binary +*.png binary +*.jpg binary diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index dd60945..b4364d6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,14 +44,16 @@ Follow these rules. ## Regenerate fixtures -Update the demo ROM only through its source. +Update the bundled ROMs only through their sources. ```text zig build fixtures -Dsdl2=off ``` -Commit the `.asm` source and the generated `.gb` image together. -The round-trip test checks that they stay in sync. +Commit each `.asm` source and the generated `.gb` image together. +The round-trip tests check that they stay in sync. +Each ROM must print PASS over the serial port. +Keep the ROM small so the headless runner stays fast. ## Open a pull request diff --git a/NOTICE b/NOTICE index 32c9e7d..b41f38f 100644 --- a/NOTICE +++ b/NOTICE @@ -10,7 +10,7 @@ The zlib license text is available at https://libsdl.org/license.php ## Test ROM sources -The bundled demo ROM is written for this project. +The bundled demo, DMA, and banking ROMs are written for this project. The blargg cpu_instrs test suite is not bundled. You add it yourself to run the optional suite. Refer to the original suite license when you use it. diff --git a/README.md b/README.md index 09646cd..72f7b8f 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ The core splits into small modules with one job each. - `src/bus.zig` routes memory between the devices. - `src/timer.zig` models the game clock. - `src/ppu.zig` renders background, sprites, and the window. -- `src/cartridge.zig` loads ROM-only and MBC1 images. +- `src/cartridge.zig` loads ROM-only and MBC1 images and banks their memory. +- `src/dma.zig` copies pages into OAM with the correct timing. - `src/serial.zig` turns link-port bytes into test verdicts. - `src/joypad.zig` reports button state through the FF00 register. - `src/emulator.zig` ties the core together. @@ -35,6 +36,11 @@ The core splits into small modules with one job each. The frontend logic stays free of SDL2. Unit tests cover that logic in headless builds. +The joypad uses the real register semantics. +The select bits are active-low. +A cleared bit selects a column. +The register returns the stored select bits when read. + ## Requirements You need Zig 0.16 or later. @@ -113,7 +119,12 @@ zig build fixtures -Dsdl2=off ``` Each `.asm` file in `fixtures/asm` becomes a `.gb` image in `fixtures/roms`. -The round-trip test keeps those images in sync. +The round-trip tests keep those images in sync. +The suite ships three ROMs: + +- `demo` renders the title and prints PASS. +- `dma` copies a page into OAM and checks the result. +- `banking` switches MBC1 banks and checks each marker. ## Test @@ -136,7 +147,7 @@ Add the blargg `cpu_instrs` ROMs under `fixtures/blargg` first. - `src/` holds the emulator core and frontend. - `tools/` holds the assembler. -- `fixtures/` holds the demo ROM and its source. +- `fixtures/` holds the bundled ROMs and their sources. - `.github/workflows/` holds the CI pipeline. ## Limitations diff --git a/ROADMAP.md b/ROADMAP.md index 71f8a92..60ba974 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,7 +5,7 @@ This document tracks what the project delivers and what remains. ## Release 0.2 Release 0.2 makes the emulator a usable workbench. It adds input, -a windowed frontend, an assembler, and a bundled demo ROM. +DMA, cartridge banking, an assembler, and bundled test ROMs. ### Done @@ -13,12 +13,13 @@ a windowed frontend, an assembler, and a bundled demo ROM. - Memory bus with WRAM, HRAM, and hardware I/O registers. - Timer with DIV, TIMA, TMA, and TAC. - PPU with background maps, tile data, and sprite rendering. -- Cartridge support for ROM-only and MBC1 images. +- Cartridge support for ROM-only and MBC1 images with bank switching. +- OAM DMA with cycle-accurate CPU blocking. - Headless ROM runner with cycle caps, traces, and verdicts. -- Joypad device with column selection and press-edge interrupts. +- Joypad device with active-low column selection and press-edge interrupts. - SDL2 windowed frontend with a DMG-style shell and pause controls. - SM83 assembler with labels, data directives, and expressions. -- Bundled demo ROM that draws a title and prints PASS. +- Bundled demo, DMA, and banking ROMs that report PASS. - Deterministic unit tests for every core module. - Continuous integration on Ubuntu and Windows. diff --git a/build.zig.zon b/build.zig.zon index 88094fe..4c30835 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -17,5 +17,6 @@ "NOTICE", ".github", ".gitignore", + ".gitattributes", }, } diff --git a/fixtures/asm/banking.asm b/fixtures/asm/banking.asm new file mode 100644 index 0000000..e73e9da --- /dev/null +++ b/fixtures/asm/banking.asm @@ -0,0 +1,102 @@ +; banking.asm - verifies MBC1 cartridge banking for the headless runner. +; +; The ROM switches ROM banks and checks a marker byte in each one. It +; prints PASS or FAIL over the serial port. Regenerate banking.gb with +; `zig build fixtures`. + + ORG $0000 + + ORG $0100 + NOP + JP init + + ORG $0134 + DB "MBC1 BANK TEST" ; cartridge title + ORG $0143 + DB $80 ; CGB flag + ORG $0144 + DB $00 ; licensee + ORG $0147 + DB $01 ; MBC1 + ORG $0148 + DB $01 ; 64KB ROM + ORG $0149 + DB $00 ; no external RAM + ORG $014C + DB $00 ; header checksum + ORG $014E + DB $00, $00 ; version + ORG $0150 + +init: + ; Bank 1 is the default. Check its marker. + LD A,($4000) + CP $11 + JR NZ,fail + + ; Switch to bank 2 and check its marker. + LD A,$02 + LD ($2000),A + LD A,($4000) + CP $22 + JR NZ,fail + + ; Switch to bank 3 and check its marker. + LD A,$03 + LD ($2000),A + LD A,($4000) + CP $33 + JR NZ,fail + + ; Switch back to bank 1 and check again. + LD A,$01 + LD ($2000),A + LD A,($4000) + CP $11 + JR NZ,fail + + ; The 0000-3FFF window stays pinned to bank 0. + LD A,($0000) + CP $00 + JR NZ,fail + + LD HL,msg_pass + CALL print_string + JR done + +fail: + LD HL,msg_fail + CALL print_string +done: + JR done + +; Prints a zero-terminated string pointed to by HL over the serial port. +print_string: + LD A,(HL+) + OR A + RET Z + CALL print_char + JR print_string + +print_char: + LDH ($01),A + LD A,$81 + LDH ($02),A + RET + +msg_pass: + DB "PASS", $0D, $0A, $00 + +msg_fail: + DB "FAIL", $0D, $0A, $00 + + ORG $4000 + DB $11 + + ORG $8000 + DB $22 + + ORG $C000 + DB $33 + + PAD $FFFF diff --git a/fixtures/asm/dma.asm b/fixtures/asm/dma.asm new file mode 100644 index 0000000..330e62b --- /dev/null +++ b/fixtures/asm/dma.asm @@ -0,0 +1,101 @@ +; dma.asm - verifies OAM DMA for the headless runner. +; +; The ROM fills WRAM page C0 with a known pattern, starts a DMA transfer +; into OAM, and compares the result. It prints PASS or FAIL over the +; serial port. Regenerate dma.gb with `zig build fixtures`. + + ORG $0000 + + ORG $0100 + NOP + JP init + + ORG $0134 + DB "DMA TEST" ; cartridge title + ORG $0143 + DB $80 ; CGB flag + ORG $0144 + DB $00 ; licensee + ORG $0147 + DB $00 ; ROM only + ORG $0148 + DB $00 ; 32KB ROM + ORG $0149 + DB $00 ; no external RAM + ORG $014C + DB $0F ; header checksum + ORG $014E + DB $00, $00 ; version + ORG $0150 + +init: + ; Fill WRAM C000-C09F with the values 00..9F. + LD HL,$C000 + LD B,$A0 + XOR A +fill: + LD (HL),A + INC HL + INC A + DEC B + JR NZ,fill + + ; Start a DMA transfer from page C0 into OAM. + LD A,$C0 + LDH ($46),A + + ; Wait for the 160-cycle transfer to finish. + LD B,$40 +wait: + NOP + NOP + NOP + DEC B + JR NZ,wait + + ; Compare each OAM byte against the WRAM pattern. + LD HL,$FE00 + LD DE,$C000 + LD B,$A0 +verify: + LD A,(DE) + LD C,A + LD A,(HL) + CP C + JR NZ,fail + INC HL + INC DE + DEC B + JR NZ,verify + + LD HL,msg_pass + CALL print_string + JR done + +fail: + LD HL,msg_fail + CALL print_string +done: + JR done + +; Prints a zero-terminated string pointed to by HL over the serial port. +print_string: + LD A,(HL+) + OR A + RET Z + CALL print_char + JR print_string + +print_char: + LDH ($01),A + LD A,$81 + LDH ($02),A + RET + +msg_pass: + DB "PASS", $0D, $0A, $00 + +msg_fail: + DB "FAIL", $0D, $0A, $00 + + PAD $8000 diff --git a/fixtures/roms/banking.gb b/fixtures/roms/banking.gb new file mode 100644 index 0000000000000000000000000000000000000000..99029a07d3503222ee2a82d40137656baee1e93b GIT binary patch literal 65535 zcmeIuJxT*n6b8`yB8Uhg(* self.sc, 0xff0f => self.iflag | 0xe0, 0xff04...0xff07 => self.timer.read(address), - 0xff40...0xff4b => self.ppu.read(address), + 0xff40...0xff45 => self.ppu.read(address), + 0xff46 => self.dma.read(), + 0xff47...0xff4b => self.ppu.read(address), 0xffff => self.ie, else => 0xff, }; @@ -86,7 +90,9 @@ pub const Bus = struct { }, 0xff0f => self.iflag = value & 0x1f, 0xff04...0xff07 => self.timer.write(address, value), - 0xff40...0xff4b => self.ppu.write(address, value), + 0xff40...0xff45 => self.ppu.write(address, value), + 0xff46 => self.dma.start(value), + 0xff47...0xff4b => self.ppu.write(address, value), 0xffff => self.ie = value, else => {}, } @@ -97,6 +103,17 @@ pub const Bus = struct { self.ppu.tick(cycles, &self.iflag); } + // Runs one DMA machine cycle. Copies one byte from the source page + // into OAM and advances the timer and PPU. Returns cycles consumed. + pub fn dmaStep(self: *Bus) u16 { + const source = self.dma.next() orelse return 0; + const index = self.dma.offset - 1; + self.ppu.oam[index] = self.read(source); + self.timer.tick(Dma.cycles_per_byte, &self.iflag); + self.ppu.tick(Dma.cycles_per_byte, &self.iflag); + return Dma.cycles_per_byte; + } + pub fn pendingInterrupts(self: *const Bus) u8 { return self.iflag & self.ie & 0x1f; } @@ -130,7 +147,7 @@ test "joypad reads the selected button column" { const rom = [_]u8{0} ** 0x8000; var bus = try Bus.init(testing.allocator, &rom); defer bus.deinit(); - bus.write(0xff00, 0x20); + bus.write(0xff00, 0x10); // bit 5 low selects the buttons column. bus.setButton(Joypad.Button.a, true); try testing.expectEqual(@as(u8, 0x0e), bus.read(0xff00) & 0x0f); } @@ -139,10 +156,39 @@ test "joypad press edge raises the joypad interrupt" { const rom = [_]u8{0} ** 0x8000; var bus = try Bus.init(testing.allocator, &rom); defer bus.deinit(); - bus.write(0xff00, 0x10); + bus.write(0xff00, 0x20); // bit 4 low selects the D-pad column. bus.setDirection(Joypad.Direction.right, true); try testing.expectEqual(@as(u8, 0x10), bus.iflag & 0x10); bus.setDirection(Joypad.Direction.right, false); bus.setDirection(Joypad.Direction.right, true); try testing.expectEqual(@as(u8, 0x10), bus.iflag & 0x10); } + +test "DMA transfers a WRAM page into OAM" { + const rom = [_]u8{0} ** 0x8000; + var bus = try Bus.init(testing.allocator, &rom); + defer bus.deinit(); + for (0..Dma.byte_count) |i| { + bus.write(0xc000 + @as(u16, @intCast(i)), @intCast(i & 0xff)); + } + bus.write(0xff46, 0xc0); + try testing.expectEqual(@as(u8, 0xc0), bus.read(0xff46)); + var steps: u32 = 0; + while (bus.dma.active()) : (steps += 1) { + try testing.expectEqual(@as(u16, 4), bus.dmaStep()); + } + try testing.expectEqual(@as(u32, 160), steps); + for (0..Dma.byte_count) |i| { + try testing.expectEqual(@as(u8, @intCast(i & 0xff)), bus.read(0xfe00 + @as(u16, @intCast(i)))); + } +} + +test "DMA register reads back the last source page" { + const rom = [_]u8{0} ** 0x8000; + var bus = try Bus.init(testing.allocator, &rom); + defer bus.deinit(); + bus.write(0xff46, 0x80); + try testing.expectEqual(@as(u8, 0x80), bus.read(0xff46)); + while (bus.dma.active()) _ = bus.dmaStep(); + try testing.expectEqual(@as(u8, 0x80), bus.read(0xff46)); +} diff --git a/src/cartridge.zig b/src/cartridge.zig index 6fe8dae..779207e 100644 --- a/src/cartridge.zig +++ b/src/cartridge.zig @@ -76,3 +76,127 @@ pub const Cartridge = struct { return if (index < self.rom.len) self.rom[index] else 0xff; } }; + +const testing = @import("std").testing; + +// Builds a 256KB MBC1 image with a distinct marker byte at the start of +// every 16KB bank. The header claims MBC1 and 32KB of RAM. +fn mbc1Image(allocator: std.mem.Allocator) ![]u8 { + const image = try allocator.alloc(u8, 0x40000); + @memset(image, 0); + image[0x147] = 0x01; // MBC1 + image[0x149] = 0x03; // 32KB RAM + for (0..16) |bank| { + image[bank * 0x4000] = @intCast(bank + 1); + } + return image; +} + +fn romOnlyImage(allocator: std.mem.Allocator) ![]u8 { + const image = try allocator.alloc(u8, 0x8000); + @memset(image, 0); + image[0x147] = 0x00; // ROM only + return image; +} + +test "ROM-only cartridge ignores mapper writes" { + const image = try romOnlyImage(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + cartridge.write(0x2000, 0x03); + cartridge.write(0x0000, 0x0a); + cartridge.write(0xa000, 0x42); + try testing.expectEqual(@as(u8, 0xff), cartridge.read(0xa000)); + try testing.expectEqual(@as(u8, 0x00), cartridge.read(0x4000)); +} + +test "MBC1 switches ROM banks through the low register" { + const image = try mbc1Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + // Bank 1 is the default for the 4000-7FFF window. + try testing.expectEqual(@as(u8, 2), cartridge.read(0x4000)); + + cartridge.write(0x2000, 0x05); + try testing.expectEqual(@as(u8, 6), cartridge.read(0x4000)); + + // Writing zero selects bank 1 instead of bank 0. + cartridge.write(0x2000, 0x00); + try testing.expectEqual(@as(u8, 2), cartridge.read(0x4000)); +} + +test "MBC1 keeps the low window pinned to bank 0" { + const image = try mbc1Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + cartridge.write(0x2000, 0x07); + try testing.expectEqual(@as(u8, 1), cartridge.read(0x0000)); +} + +test "MBC1 gates external RAM writes on the enable register" { + const image = try mbc1Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + // Disabled RAM ignores writes and reads open-bus. + cartridge.write(0xa000, 0x42); + try testing.expectEqual(@as(u8, 0xff), cartridge.read(0xa000)); + + cartridge.write(0x0000, 0x0a); + cartridge.write(0xa000, 0x42); + try testing.expectEqual(@as(u8, 0x42), cartridge.read(0xa000)); + + // Any other low nibble disables RAM again. + cartridge.write(0x0000, 0x00); + cartridge.write(0xa000, 0x43); + cartridge.write(0x0000, 0x0a); + try testing.expectEqual(@as(u8, 0x42), cartridge.read(0xa000)); +} + +test "MBC1 banking mode selects RAM banks" { + const image = try mbc1Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + cartridge.write(0x0000, 0x0a); + // Mode 1 uses the high ROM register to pick the RAM bank. + cartridge.write(0x6000, 0x01); + cartridge.write(0x4000, 0x02); + cartridge.write(0xa000, 0x11); + try testing.expectEqual(@as(u8, 0x11), cartridge.read(0xa000)); + + // Mode 0 pins RAM to bank 0 and ignores the high register. + cartridge.write(0x6000, 0x00); + cartridge.write(0x4000, 0x00); + cartridge.write(0xa000, 0x22); + try testing.expectEqual(@as(u8, 0x22), cartridge.read(0xa000)); + + // Back in mode 1, RAM bank 2 still holds its earlier byte. + cartridge.write(0x6000, 0x01); + cartridge.write(0x4000, 0x02); + try testing.expectEqual(@as(u8, 0x11), cartridge.read(0xa000)); + + // RAM bank 0 holds the mode-0 write. + cartridge.write(0x4000, 0x00); + try testing.expectEqual(@as(u8, 0x22), cartridge.read(0xa000)); +} + +test "MBC1 reads past the end of ROM as open bus" { + const image = try mbc1Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + // 16 banks fill the image; selecting bank 31 reads past the end. + cartridge.write(0x2000, 0x1f); + cartridge.write(0x4000, 0x03); + try testing.expectEqual(@as(u8, 0xff), cartridge.read(0x4000)); +} diff --git a/src/dma.zig b/src/dma.zig new file mode 100644 index 0000000..43276fe --- /dev/null +++ b/src/dma.zig @@ -0,0 +1,73 @@ +// The Game Boy OAM DMA controller. +// +// Writing FF46 copies one page of memory into object attribute memory. +// The transfer copies 0xa0 bytes from page XX00 into FE00-FE9F. It runs +// for 640 T-cycles and blocks the CPU while it works. + +pub const Dma = struct { + source_page: u8 = 0, + offset: u16 = 0, + cycles_left: u16 = 0, + + pub const byte_count: u16 = 0xa0; + pub const cycles_per_byte: u16 = 4; + pub const transfer_cycles: u16 = byte_count * cycles_per_byte; + + pub fn start(self: *Dma, value: u8) void { + self.source_page = value; + self.offset = 0; + self.cycles_left = transfer_cycles; + } + + pub fn active(self: *const Dma) bool { + return self.cycles_left != 0; + } + + // Reading FF46 returns the last page written. + pub fn read(self: *const Dma) u8 { + return self.source_page; + } + + // Returns the source address of the byte for the next machine cycle + // and advances the transfer. Returns null when the transfer is done. + pub fn next(self: *Dma) ?u16 { + if (!self.active()) return null; + const address = @as(u16, self.source_page) << 8 | self.offset; + self.offset += 1; + self.cycles_left -= cycles_per_byte; + return address; + } +}; + +const std = @import("std"); + +test "a fresh controller is idle" { + var dma = Dma{}; + try std.testing.expect(!dma.active()); + try std.testing.expectEqual(@as(?u16, null), dma.next()); + try std.testing.expectEqual(@as(u8, 0), dma.read()); +} + +test "start begins a full transfer" { + var dma = Dma{}; + dma.start(0xc0); + try std.testing.expect(dma.active()); + try std.testing.expectEqual(@as(u8, 0xc0), dma.read()); + var count: usize = 0; + while (dma.next()) |address| { + try std.testing.expectEqual(@as(u16, 0xc000) + @as(u16, @intCast(count)), address); + count += 1; + } + try std.testing.expectEqual(@as(usize, 0xa0), count); + try std.testing.expect(!dma.active()); +} + +test "restarting replaces an in-flight transfer" { + var dma = Dma{}; + dma.start(0x80); + _ = dma.next(); + dma.start(0x40); + try std.testing.expectEqual(@as(u8, 0x40), dma.read()); + const first = dma.next(); + try std.testing.expectEqual(@as(u16, 0x4000), first); +} diff --git a/src/emulator.zig b/src/emulator.zig index 19a204e..55c0de0 100644 --- a/src/emulator.zig +++ b/src/emulator.zig @@ -26,6 +26,11 @@ pub const Emulator = struct { } pub fn step(self: *Emulator) u16 { + if (self.bus.dma.active()) { + const cycles = self.bus.dmaStep(); + self.total_cycles += cycles; + return cycles; + } const cycles = self.cpu.step(&self.bus); self.total_cycles += cycles; self.total_instructions += 1; @@ -60,6 +65,7 @@ pub const Emulator = struct { self.bus.ppu = .{}; self.bus.timer = .{}; self.bus.joypad = .{}; + self.bus.dma = .{}; self.bus.serial_len = 0; self.total_cycles = 0; self.total_instructions = 0; diff --git a/src/frontend.zig b/src/frontend.zig index 4f54340..77bb03a 100644 --- a/src/frontend.zig +++ b/src/frontend.zig @@ -138,7 +138,7 @@ test "control keys are not forwarded to the joypad" { 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)); + try testing.expectEqual(@as(u8, 0xff), bus.read(0xff00)); } test "window layout keeps the screen centered" { diff --git a/src/joypad.zig b/src/joypad.zig index 9fb1538..08ba4f2 100644 --- a/src/joypad.zig +++ b/src/joypad.zig @@ -35,13 +35,14 @@ pub const Joypad = struct { 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. + // Reading FF00. The stored select bits come back in bits 4 and 5. + // A cleared bit 4 activates the D-pad; a cleared bit 5 activates the + // buttons. Unselected columns report 1 (released). pub fn read(self: *const Joypad) u8 { var value: u8 = 0x0f; - if ((self.select & 0x10) != 0) value &= self.dpad; - if ((self.select & 0x20) != 0) value &= self.buttons; - return 0xc0 | value; + if ((self.select & 0x10) == 0) value &= self.dpad; + if ((self.select & 0x20) == 0) value &= self.buttons; + return 0xc0 | (self.select & 0x30) | value; } // Writing FF00. Only the two select bits are stored. @@ -54,12 +55,21 @@ const testing = std.testing; test "default state reads as all released" { const joypad = Joypad{}; - try testing.expectEqual(@as(u8, 0xcf), joypad.read()); + // No column is selected, so the whole register reads released. + try testing.expectEqual(@as(u8, 0xff), joypad.read()); } -test "buttons column reflects pressed keys" { +test "reading returns the stored select bits" { var joypad = Joypad{}; joypad.write(0x20); + try testing.expectEqual(@as(u8, 0x20), joypad.read() & 0x30); + joypad.write(0x10); + try testing.expectEqual(@as(u8, 0x10), joypad.read() & 0x30); +} + +test "buttons column reflects pressed keys" { + var joypad = Joypad{}; + joypad.write(0x10); // bit 5 low selects the buttons column. joypad.setButton(.a, true); joypad.setButton(.start, true); const value = joypad.read(); @@ -69,7 +79,7 @@ test "buttons column reflects pressed keys" { test "dpad column reflects pressed directions" { var joypad = Joypad{}; - joypad.write(0x10); + joypad.write(0x20); // bit 4 low selects the D-pad column. joypad.setDirection(.up, true); joypad.setDirection(.down, true); const value = joypad.read(); @@ -79,15 +89,15 @@ test "dpad column reflects pressed directions" { test "unselected column reads released" { var joypad = Joypad{}; + joypad.write(0x20); // only the D-pad column is selected. joypad.setButton(.a, true); - joypad.setDirection(.right, true); - joypad.write(0x00); - try testing.expectEqual(@as(u8, 0xcf), joypad.read()); + // The button press hides in the unselected buttons column. + try testing.expectEqual(@as(u8, 0x0f), joypad.read() & 0x0f); } test "both columns selected reads the AND of both" { var joypad = Joypad{}; - joypad.write(0x30); + joypad.write(0x00); // both columns are selected. joypad.setButton(.a, true); joypad.setDirection(.right, true); // bit 0 is driven low by both columns. @@ -99,7 +109,7 @@ test "release restores the released state" { joypad.write(0x10); joypad.setButton(.b, true); joypad.setButton(.b, false); - try testing.expectEqual(@as(u8, 0xcf), joypad.read()); + try testing.expectEqual(@as(u8, 0x0f), joypad.read() & 0x0f); } test "query helpers reflect active low storage" { diff --git a/src/tests.zig b/src/tests.zig index edcda47..57a97f4 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -6,6 +6,8 @@ test { _ = @import("emulator.zig"); _ = @import("bus.zig"); + _ = @import("cartridge.zig"); + _ = @import("dma.zig"); _ = @import("joypad.zig"); _ = @import("frontend.zig"); _ = @import("serial.zig"); From fc6c7324d9d8314bc8e2cdd8a2f2b77b896e1218 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Tue, 4 Aug 2026 00:34:57 -0700 Subject: [PATCH 3/6] feat: extend dot matrix deck --- README.md | 9 +- ROADMAP.md | 6 +- fixtures/asm/mbc2.asm | 114 +++++++++++ fixtures/asm/mbc3.asm | 135 +++++++++++++ fixtures/asm/mbc5.asm | 117 +++++++++++ fixtures/roms/mbc2.gb | Bin 0 -> 65535 bytes fixtures/roms/mbc3.gb | Bin 0 -> 65535 bytes fixtures/roms/mbc5.gb | Bin 0 -> 65535 bytes fixtures_tests.zig | 30 +++ src/bus.zig | 35 ++++ src/cartridge.zig | 461 ++++++++++++++++++++++++++++++++++++++---- 11 files changed, 867 insertions(+), 40 deletions(-) create mode 100644 fixtures/asm/mbc2.asm create mode 100644 fixtures/asm/mbc3.asm create mode 100644 fixtures/asm/mbc5.asm create mode 100644 fixtures/roms/mbc2.gb create mode 100644 fixtures/roms/mbc3.gb create mode 100644 fixtures/roms/mbc5.gb diff --git a/README.md b/README.md index 72f7b8f..365632e 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The core splits into small modules with one job each. - `src/bus.zig` routes memory between the devices. - `src/timer.zig` models the game clock. - `src/ppu.zig` renders background, sprites, and the window. -- `src/cartridge.zig` loads ROM-only and MBC1 images and banks their memory. +- `src/cartridge.zig` loads ROM-only, MBC1, MBC2, MBC3, and MBC5 images. It banks their memory and drives the MBC3 real-time clock. - `src/dma.zig` copies pages into OAM with the correct timing. - `src/serial.zig` turns link-port bytes into test verdicts. - `src/joypad.zig` reports button state through the FF00 register. @@ -120,11 +120,14 @@ zig build fixtures -Dsdl2=off Each `.asm` file in `fixtures/asm` becomes a `.gb` image in `fixtures/roms`. The round-trip tests keep those images in sync. -The suite ships three ROMs: +The suite ships six ROMs: - `demo` renders the title and prints PASS. - `dma` copies a page into OAM and checks the result. - `banking` switches MBC1 banks and checks each marker. +- `mbc2` switches MBC2 banks and checks the internal RAM. +- `mbc3` switches MBC3 banks, RAM banks, and the real-time clock. +- `mbc5` switches MBC5 banks and RAM banks. ## Test @@ -156,6 +159,8 @@ Hardware coverage is incomplete. Timing accuracy, cartridge support, audio, and save games will improve. The core starts at the post-boot state. It does not emulate the boot ROM. +The MBC3 clock advances with the emulated cycle count. +It does not follow the host clock. ## License diff --git a/ROADMAP.md b/ROADMAP.md index 60ba974..06dfccc 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -13,20 +13,20 @@ DMA, cartridge banking, an assembler, and bundled test ROMs. - Memory bus with WRAM, HRAM, and hardware I/O registers. - Timer with DIV, TIMA, TMA, and TAC. - PPU with background maps, tile data, and sprite rendering. -- Cartridge support for ROM-only and MBC1 images with bank switching. +- Cartridge support for ROM-only, MBC1, MBC2, MBC3, and MBC5 images. - OAM DMA with cycle-accurate CPU blocking. - Headless ROM runner with cycle caps, traces, and verdicts. - Joypad device with active-low column selection and press-edge interrupts. +- MBC3 real-time clock with halt, latch, and BCD seconds. - SDL2 windowed frontend with a DMG-style shell and pause controls. - SM83 assembler with labels, data directives, and expressions. -- Bundled demo, DMA, and banking ROMs that report PASS. +- Bundled demo, DMA, banking, MBC2, MBC3, and MBC5 ROMs that report PASS. - Deterministic unit tests for every core module. - Continuous integration on Ubuntu and Windows. ### Next - Cycle-accurate timing for the timer and the PPU STAT modes. -- MBC2, MBC3, and MBC5 cartridge support. - Serial link emulation between two emulator instances. - Audio processing unit (APU). - Save game files and battery-backed RAM. diff --git a/fixtures/asm/mbc2.asm b/fixtures/asm/mbc2.asm new file mode 100644 index 0000000..58fbc88 --- /dev/null +++ b/fixtures/asm/mbc2.asm @@ -0,0 +1,114 @@ +; mbc2.asm - verifies MBC2 cartridge banking for the headless runner. +; +; The ROM switches ROM banks through the bit-8 register and checks the +; 4-bit internal RAM. It prints PASS or FAIL over the serial port. +; Regenerate mbc2.gb with `zig build fixtures`. + + ORG $0000 + + ORG $0100 + NOP + JP init + + ORG $0134 + DB "MBC2 BANK TEST" ; cartridge title + ORG $0143 + DB $80 ; CGB flag + ORG $0147 + DB $05 ; MBC2 + ORG $0148 + DB $01 ; 64KB ROM + ORG $0149 + DB $00 ; internal RAM + ORG $014C + DB $00 ; header checksum + ORG $014E + DB $00, $00 ; version + ORG $0150 + +init: + ; Bank 1 is the default. Check its marker. + LD A,($4000) + CP $11 + JR NZ,fail + + ; Switch to bank 2 through the bit-8 register and check its marker. + LD A,$02 + LD ($2100),A + LD A,($4000) + CP $22 + JR NZ,fail + + ; Switch to bank 3 and check its marker. + LD A,$03 + LD ($2100),A + LD A,($4000) + CP $33 + JR NZ,fail + + ; A bank of zero selects bank 1. + LD A,$00 + LD ($2100),A + LD A,($4000) + CP $11 + JR NZ,fail + + ; Enable the internal RAM and store a nibble. + LD A,$0A + LD ($0000),A + LD A,$05 + LD ($A000),A + LD A,($A000) + AND $0F + CP $05 + JR NZ,fail + + ; Disabling RAM drops further writes. + LD A,$00 + LD ($0000),A + LD A,$07 + LD ($A000),A + LD A,($A000) + CP $FF + JR NZ,fail + + LD HL,msg_pass + CALL print_string + JR done + +fail: + LD HL,msg_fail + CALL print_string +done: + JR done + +; Prints a zero-terminated string pointed to by HL over the serial port. +print_string: + LD A,(HL+) + OR A + RET Z + CALL print_char + JR print_string + +print_char: + LDH ($01),A + LD A,$81 + LDH ($02),A + RET + +msg_pass: + DB "PASS", $0D, $0A, $00 + +msg_fail: + DB "FAIL", $0D, $0A, $00 + + ORG $4000 + DB $11 + + ORG $8000 + DB $22 + + ORG $C000 + DB $33 + + PAD $FFFF diff --git a/fixtures/asm/mbc3.asm b/fixtures/asm/mbc3.asm new file mode 100644 index 0000000..a03b934 --- /dev/null +++ b/fixtures/asm/mbc3.asm @@ -0,0 +1,135 @@ +; mbc3.asm - verifies MBC3 cartridge banking and RTC for the headless runner. +; +; The ROM switches ROM banks, uses banked RAM, and checks the RTC registers. +; It prints PASS or FAIL over the serial port. Regenerate mbc3.gb with +; `zig build fixtures`. + + ORG $0000 + + ORG $0100 + NOP + JP init + + ORG $0134 + DB "MBC3 BANK TEST" ; cartridge title + ORG $0143 + DB $80 ; CGB flag + ORG $0147 + DB $0F ; MBC3 + ORG $0148 + DB $01 ; 64KB ROM + ORG $0149 + DB $03 ; 32KB RAM + ORG $014C + DB $00 ; header checksum + ORG $014E + DB $00, $00 ; version + ORG $0150 + +init: + ; Bank 1 is the default. Check its marker. + LD A,($4000) + CP $11 + JR NZ,fail + + ; Switch to bank 2 and check its marker. + LD A,$02 + LD ($2000),A + LD A,($4000) + CP $22 + JR NZ,fail + + ; Switch to bank 3 and check its marker. + LD A,$03 + LD ($2000),A + LD A,($4000) + CP $33 + JR NZ,fail + + ; Switch back to bank 1. + LD A,$01 + LD ($2000),A + LD A,($4000) + CP $11 + JR NZ,fail + + ; Enable external RAM and use bank 0. + LD A,$0A + LD ($0000),A + LD A,$42 + LD ($A000),A + LD A,($A000) + CP $42 + JR NZ,fail + + ; Switch to RAM bank 2 and store a different byte. + LD A,$02 + LD ($4000),A + LD A,$24 + LD ($A000),A + LD A,($A000) + CP $24 + JR NZ,fail + + ; Back on RAM bank 0 the first byte survives. + LD A,$00 + LD ($4000),A + LD A,($A000) + CP $42 + JR NZ,fail + + ; Select the RTC seconds register, halt the clock, and write a value. + LD A,$0C + LD ($4000),A + LD A,$40 + LD ($A000),A ; halt bit set + LD A,$08 + LD ($4000),A + LD A,$59 + LD ($A000),A + LD A,$08 + LD ($4000),A + LD A,($A000) + CP $59 + JR NZ,fail + + LD HL,msg_pass + CALL print_string + JR done + +fail: + LD HL,msg_fail + CALL print_string +done: + JR done + +; Prints a zero-terminated string pointed to by HL over the serial port. +print_string: + LD A,(HL+) + OR A + RET Z + CALL print_char + JR print_string + +print_char: + LDH ($01),A + LD A,$81 + LDH ($02),A + RET + +msg_pass: + DB "PASS", $0D, $0A, $00 + +msg_fail: + DB "FAIL", $0D, $0A, $00 + + ORG $4000 + DB $11 + + ORG $8000 + DB $22 + + ORG $C000 + DB $33 + + PAD $FFFF diff --git a/fixtures/asm/mbc5.asm b/fixtures/asm/mbc5.asm new file mode 100644 index 0000000..7813284 --- /dev/null +++ b/fixtures/asm/mbc5.asm @@ -0,0 +1,117 @@ +; mbc5.asm - verifies MBC5 cartridge banking for the headless runner. +; +; The ROM switches ROM banks through the low and high registers and checks +; banked RAM. It prints PASS or FAIL over the serial port. Regenerate +; mbc5.gb with `zig build fixtures`. + + ORG $0000 + + ORG $0100 + NOP + JP init + + ORG $0134 + DB "MBC5 BANK TEST" ; cartridge title + ORG $0143 + DB $80 ; CGB flag + ORG $0147 + DB $19 ; MBC5 + ORG $0148 + DB $01 ; 64KB ROM + ORG $0149 + DB $03 ; 32KB RAM + ORG $014C + DB $00 ; header checksum + ORG $014E + DB $00, $00 ; version + ORG $0150 + +init: + ; Bank 1 is the default. Check its marker. + LD A,($4000) + CP $11 + JR NZ,fail + + ; Switch to bank 2 through the low register. + LD A,$02 + LD ($2000),A + LD A,($4000) + CP $22 + JR NZ,fail + + ; Bank 0 is addressable, unlike MBC1. + LD A,$00 + LD ($2000),A + LD A,($4000) + CP $00 + JR NZ,fail + + ; Switch back to bank 1. + LD A,$01 + LD ($2000),A + LD A,($4000) + CP $11 + JR NZ,fail + + ; Enable external RAM and use RAM bank 0. + LD A,$0A + LD ($0000),A + LD A,$42 + LD ($A000),A + LD A,($A000) + CP $42 + JR NZ,fail + + ; Switch to RAM bank 2 and store a different byte. + LD A,$02 + LD ($4000),A + LD A,$24 + LD ($A000),A + LD A,($A000) + CP $24 + JR NZ,fail + + ; Back on RAM bank 0 the first byte survives. + LD A,$00 + LD ($4000),A + LD A,($A000) + CP $42 + JR NZ,fail + + LD HL,msg_pass + CALL print_string + JR done + +fail: + LD HL,msg_fail + CALL print_string +done: + JR done + +; Prints a zero-terminated string pointed to by HL over the serial port. +print_string: + LD A,(HL+) + OR A + RET Z + CALL print_char + JR print_string + +print_char: + LDH ($01),A + LD A,$81 + LDH ($02),A + RET + +msg_pass: + DB "PASS", $0D, $0A, $00 + +msg_fail: + DB "FAIL", $0D, $0A, $00 + + ORG $4000 + DB $11 + + ORG $8000 + DB $22 + + PAD $FFFF diff --git a/fixtures/roms/mbc2.gb b/fixtures/roms/mbc2.gb new file mode 100644 index 0000000000000000000000000000000000000000..08837e49d6c36270d2640d27b36e662b3f576b2f GIT binary patch literal 65535 zcmeIuJxT*X6bJCxNK^tM+r@5@Qmjm4HJJDT;s*=Kf%GQ1fn_=y5wyr1Qd#T?B*ki? z@c@>){$BGQGtBURzYzX;Jr~(u1#U-Y$8j{gyN;8K@g&@@-nV~0S%%Xpk40*gp|_s< z@jPv9=0QA3VKe9PD0RvZQo9V(WthJ2SM7MPz7A>U=cxLOyS=Au{+M;QdoSO;I-I@E zXIb}Sk)?-4>#Z1$$9tV{IlQ`AHAR2`0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N z0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0`(Qh>lYn}0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UsIS1FzR@KoK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ LfB*pkO%eD4aMv-< literal 0 HcmV?d00001 diff --git a/fixtures/roms/mbc3.gb b/fixtures/roms/mbc3.gb new file mode 100644 index 0000000000000000000000000000000000000000..270beb4fe7fde47c110953091bd7fb2782b5a21b GIT binary patch literal 65535 zcmeIuKS~2Z6bInh1SAAS(xse83bBp#kgP-y{}4zJ(s}?7V5K+7Ej)mY7CC@j>?Dn? zqzLYscmTVFeACR2dA#AhFN7UGFQe?g0@wLjFXn@r%QzX1C*iT^Kg{ZzUsj>NZpDXG zTZUNXqj;a{+uVzHDcj~&yiLtz2q|BN*(%J|c^sBa{nXt)bmM6X#lxm}oc7CNzbK|g znWlfKtWRU3^O?=xv-V!+>-S!tJipFAviA2POHYg1+h{NzA2h@H;Nq&d90CLg5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBly zK!5-N0t5&UsG2~lY6F>v009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+ z009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF?4ZEW4(@Da0t5&UAV7cs0RjXF z5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyP&I*G)dn&T0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF j5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U*gb(C?g>KH literal 0 HcmV?d00001 diff --git a/fixtures/roms/mbc5.gb b/fixtures/roms/mbc5.gb new file mode 100644 index 0000000000000000000000000000000000000000..b74d25d8bcfe7e1d330413f0fcdbf6b485fd34b9 GIT binary patch literal 65535 zcmeIuF-k*05CG8K2qFQCfJN*!AvX3}F8N;+L=AzT%G2mgatpB(n^**|kUA%j6v2G+ zFOV|vO*6wRv&_5@{&{?khlY93GzRIco*Zr;PkOUBe~;bc-uGW#U%t)e zAF=zhi1~HV`WhEib=nSh#r@+Z2?7KN5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U zAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&U*f)XBzAbED0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ zfB*pk1PBlyaDW2+1H8#e1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk z1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs z0RjXF5FkK+009C72oNAZfB*pk1PBlyK!5-N0t5&UAV7cs0RjXF5FkK+009C72oNAZ HU`K&pAwe}` literal 0 HcmV?d00001 diff --git a/fixtures_tests.zig b/fixtures_tests.zig index d5fe96b..9195b4c 100644 --- a/fixtures_tests.zig +++ b/fixtures_tests.zig @@ -18,6 +18,9 @@ const fixtures = [_]Fixture{ .{ .name = "demo", .source = @embedFile("fixtures/asm/demo.asm"), .image = @embedFile("fixtures/roms/demo.gb") }, .{ .name = "dma", .source = @embedFile("fixtures/asm/dma.asm"), .image = @embedFile("fixtures/roms/dma.gb") }, .{ .name = "banking", .source = @embedFile("fixtures/asm/banking.asm"), .image = @embedFile("fixtures/roms/banking.gb") }, + .{ .name = "mbc2", .source = @embedFile("fixtures/asm/mbc2.asm"), .image = @embedFile("fixtures/roms/mbc2.gb") }, + .{ .name = "mbc3", .source = @embedFile("fixtures/asm/mbc3.asm"), .image = @embedFile("fixtures/roms/mbc3.gb") }, + .{ .name = "mbc5", .source = @embedFile("fixtures/asm/mbc5.asm"), .image = @embedFile("fixtures/roms/mbc5.gb") }, }; test "fixture images round-trip byte for byte" { @@ -64,3 +67,30 @@ test "banking ROM reports PASS" { while (frame_index < 40) : (frame_index += 1) emulator.runFrame(1 << 24); try std.testing.expect(std.mem.indexOf(u8, emulator.serialOutput(), "PASS") != null); } + +test "MBC2 ROM reports PASS" { + const fixture = fixtures[3]; + var emulator = try Emulator.init(std.testing.allocator, fixture.image); + defer emulator.deinit(); + var frame_index: u32 = 0; + while (frame_index < 40) : (frame_index += 1) emulator.runFrame(1 << 24); + try std.testing.expect(std.mem.indexOf(u8, emulator.serialOutput(), "PASS") != null); +} + +test "MBC3 ROM reports PASS" { + const fixture = fixtures[4]; + var emulator = try Emulator.init(std.testing.allocator, fixture.image); + defer emulator.deinit(); + var frame_index: u32 = 0; + while (frame_index < 40) : (frame_index += 1) emulator.runFrame(1 << 24); + try std.testing.expect(std.mem.indexOf(u8, emulator.serialOutput(), "PASS") != null); +} + +test "MBC5 ROM reports PASS" { + const fixture = fixtures[5]; + var emulator = try Emulator.init(std.testing.allocator, fixture.image); + defer emulator.deinit(); + var frame_index: u32 = 0; + while (frame_index < 40) : (frame_index += 1) emulator.runFrame(1 << 24); + try std.testing.expect(std.mem.indexOf(u8, emulator.serialOutput(), "PASS") != null); +} diff --git a/src/bus.zig b/src/bus.zig index 7bda134..9ea09b5 100644 --- a/src/bus.zig +++ b/src/bus.zig @@ -101,6 +101,7 @@ pub const Bus = struct { pub fn tick(self: *Bus, cycles: u16) void { self.timer.tick(cycles, &self.iflag); self.ppu.tick(cycles, &self.iflag); + self.cartridge.tick(cycles); } // Runs one DMA machine cycle. Copies one byte from the source page @@ -192,3 +193,37 @@ test "DMA register reads back the last source page" { while (bus.dma.active()) _ = bus.dmaStep(); try testing.expectEqual(@as(u8, 0x80), bus.read(0xff46)); } + +test "bus ticks the MBC3 clock through FF registers" { + var image: [0x40000]u8 = [_]u8{0} ** 0x40000; + image[0x147] = 0x0f; // MBC3 + image[0x149] = 0x03; // 32KB RAM + var bus = try Bus.init(testing.allocator, &image); + defer bus.deinit(); + + bus.write(0x0000, 0x0a); // enable RAM + bus.write(0x4000, 0x0c); // RTC control register + bus.write(0xa000, 0x40); // halt the clock + bus.write(0x4000, 0x08); // seconds + bus.write(0xa000, 0x59); + bus.write(0x4000, 0x09); // minutes + bus.write(0xa000, 0x00); + bus.write(0x4000, 0x0a); // hours + bus.write(0xa000, 0x00); + bus.write(0x4000, 0x0c); // clear halt + bus.write(0xa000, 0x00); + + // One second of cycles advances the clock by one minute. + const chunks: u32 = 128; + const per_chunk: u16 = @intCast(Cartridge.cycles_per_second / chunks); + for (0..chunks) |_| bus.tick(per_chunk); + + bus.write(0x4000, 0x09); + bus.write(0x6000, 0x00); + bus.write(0x6000, 0x01); + try testing.expectEqual(@as(u8, 0x01), bus.read(0xa000)); + bus.write(0x4000, 0x08); + bus.write(0x6000, 0x00); + bus.write(0x6000, 0x01); + try testing.expectEqual(@as(u8, 0x00), bus.read(0xa000)); +} diff --git a/src/cartridge.zig b/src/cartridge.zig index 779207e..d441e31 100644 --- a/src/cartridge.zig +++ b/src/cartridge.zig @@ -1,5 +1,12 @@ const std = @import("std"); +// Cartridge image loading and bank mapping. +// +// The emulator supports ROM-only carts and the MBC1, MBC2, MBC3, and MBC5 +// mappers. Each mapper maps a ROM window and an optional external RAM +// window. MBC3 also carries a real-time clock that advances with the CPU +// cycle count. + pub const Cartridge = struct { allocator: std.mem.Allocator, rom: []u8, @@ -7,22 +14,24 @@ pub const Cartridge = struct { mapper: Mapper, rom_bank_low: u8 = 1, rom_bank_high: u8 = 0, + ram_bank: u8 = 0, ram_enabled: bool = false, banking_mode: u8 = 0, + rtc_regs: [5]u8 = [_]u8{0} ** 5, + rtc_total_seconds: u64 = 0, + rtc_cycle_accumulator: u64 = 0, + latch_state: u8 = 0, + latched_regs: [5]u8 = [_]u8{0} ** 5, + + pub const cycles_per_second: u64 = 4_194_304; - const Mapper = enum { rom_only, mbc1 }; + const Mapper = enum { rom_only, mbc1, mbc2, mbc3, mbc5 }; pub fn init(allocator: std.mem.Allocator, image: []const u8) !Cartridge { const rom = try allocator.alloc(u8, image.len); std.mem.copyForwards(u8, rom, image); - const mapper = if (image.len > 0x147 and (image[0x147] == 0x01 or image[0x147] == 0x02 or image[0x147] == 0x03)) Mapper.mbc1 else Mapper.rom_only; - const ram_size: usize = if (image.len > 0x149) switch (image[0x149]) { - 0x02 => 0x2000, - 0x03 => 0x8000, - 0x04 => 0x20000, - 0x05 => 0x10000, - else => 0, - } else 0; + const mapper = mapperFor(image); + const ram_size: usize = if (mapper == .mbc2) 0x200 else externalRamSize(image); const ram = try allocator.alloc(u8, ram_size); @memset(ram, 0); return .{ .allocator = allocator, .rom = rom, .ram = ram, .mapper = mapper }; @@ -40,38 +49,249 @@ pub const Cartridge = struct { return self.romByte(bank * 0x4000 + raw); } if (raw < 0x8000) { - var bank: usize = self.rom_bank_low & 0x1f; - if (bank == 0) bank = 1; - if (self.mapper == .mbc1) bank |= @as(usize, self.rom_bank_high) << 5; - return self.romByte(bank * 0x4000 + raw - 0x4000); + return self.romByte(self.selectedRomBank() * 0x4000 + raw - 0x4000); } - if (raw >= 0xa000 and raw < 0xc000 and self.ram.len != 0 and self.ram_enabled) { - var index = raw - 0xa000; - if (self.mapper == .mbc1 and self.banking_mode == 1) index += @as(usize, self.rom_bank_high) * 0x2000; - return if (index < self.ram.len) self.ram[index] else 0xff; + if (raw >= 0xa000 and raw < 0xc000) { + return self.readExternal(raw); } return 0xff; } pub fn write(self: *Cartridge, address: u16, value: u8) void { if (self.mapper == .rom_only) return; - switch (address) { - 0x0000...0x1fff => self.ram_enabled = (value & 0x0f) == 0x0a, - 0x2000...0x3fff => { - self.rom_bank_low = value & 0x1f; - if (self.rom_bank_low == 0) self.rom_bank_low = 1; + const raw = @as(usize, address); + if (raw < 0x8000) { + self.writeBankRegisters(raw, value); + return; + } + if (raw >= 0xa000 and raw < 0xc000) { + self.writeExternal(raw, value); + } + } + + // Advances the MBC3 real-time clock with the given cycle count. + // Other mappers have no clock and do nothing. + pub fn tick(self: *Cartridge, cycles: u64) void { + if (self.mapper != .mbc3) return; + if ((self.rtc_regs[4] & 0x40) != 0) return; + self.rtc_cycle_accumulator += cycles; + const seconds = self.rtc_cycle_accumulator / cycles_per_second; + if (seconds == 0) return; + self.rtc_cycle_accumulator -= seconds * cycles_per_second; + self.rtc_total_seconds +%= seconds; + self.refreshRtcRegs(); + } + + fn mapperFor(image: []const u8) Mapper { + if (image.len <= 0x147) return .rom_only; + return switch (image[0x147]) { + 0x01, 0x02, 0x03 => .mbc1, + 0x05, 0x06 => .mbc2, + 0x0f...0x13 => .mbc3, + 0x19...0x1e => .mbc5, + else => .rom_only, + }; + } + + fn externalRamSize(image: []const u8) usize { + if (image.len <= 0x149) return 0; + return switch (image[0x149]) { + 0x02 => 0x2000, + 0x03 => 0x8000, + 0x04 => 0x20000, + 0x05 => 0x10000, + else => 0, + }; + } + + // Returns the bank selected for the 4000-7FFF ROM window. + fn selectedRomBank(self: *const Cartridge) usize { + var bank: usize = switch (self.mapper) { + .mbc1 => self.rom_bank_low & 0x1f, + .mbc2 => self.rom_bank_low & 0x0f, + .mbc3 => self.rom_bank_low & 0x7f, + .mbc5 => self.rom_bank_low, + .rom_only => 0, + }; + if (self.mapper == .mbc1) bank |= @as(usize, self.rom_bank_high) << 5; + if (self.mapper == .mbc5) bank |= @as(usize, self.rom_bank_high) << 8; + if (bank == 0 and self.mapper != .mbc5) bank = 1; + return bank; + } + + fn writeBankRegisters(self: *Cartridge, raw: usize, value: u8) void { + if (raw < 0x4000) { + // MBC2 folds both registers into 0000-3FFF. Address bit 8 + // selects the ROM bank register; clearing it selects the RAM + // enable register. + if (self.mapper == .mbc2) { + if ((raw & 0x100) == 0) { + self.ram_enabled = (value & 0x0f) == 0x0a; + } else { + self.rom_bank_low = value & 0x0f; + if (self.rom_bank_low == 0) self.rom_bank_low = 1; + } + return; + } + if (raw < 0x2000) { + self.ram_enabled = (value & 0x0f) == 0x0a; + return; + } + // MBC5 keeps the low eight bank bits at 2000-2FFF and the + // ninth bit at 3000-3FFF. + if (self.mapper == .mbc5) { + if (raw < 0x3000) { + self.rom_bank_low = value; + } else { + self.rom_bank_high = value & 1; + } + return; + } + // MBC1 uses a five-bit bank register; MBC3 uses seven bits. + self.rom_bank_low = if (self.mapper == .mbc3) value & 0x7f else value & 0x1f; + if (self.rom_bank_low == 0) self.rom_bank_low = 1; + return; + } + switch (raw) { + 0x4000...0x5fff => switch (self.mapper) { + .mbc1 => self.rom_bank_high = value & 0x03, + .mbc3, .mbc5 => self.ram_bank = value & 0x0f, + else => {}, }, - 0x4000...0x5fff => self.rom_bank_high = value & 0x03, - 0x6000...0x7fff => self.banking_mode = value & 1, - 0xa000...0xbfff => if (self.ram.len != 0 and self.ram_enabled) { - var index = @as(usize, address - 0xa000); - if (self.mapper == .mbc1 and self.banking_mode == 1) index += @as(usize, self.rom_bank_high) * 0x2000; - if (index < self.ram.len) self.ram[index] = value; + 0x6000...0x7fff => switch (self.mapper) { + .mbc1 => self.banking_mode = value & 1, + .mbc3 => self.latchRtc(value), + else => {}, }, else => {}, } } + fn readExternal(self: *Cartridge, raw: usize) u8 { + if (self.ram.len == 0 or !self.ram_enabled) return 0xff; + return switch (self.mapper) { + .mbc1 => blk: { + var index = raw - 0xa000; + if (self.banking_mode == 1) index += @as(usize, self.rom_bank_high) * 0x2000; + break :blk if (index < self.ram.len) self.ram[index] else 0xff; + }, + .mbc2 => blk: { + const index = self.mbc2RamIndex(raw); + break :blk 0xf0 | (self.ram[index] & 0x0f); + }, + .mbc3 => blk: { + if (self.ram_bank >= 8 and self.ram_bank <= 0x0c) break :blk self.readRtcRegister(); + const index = @as(usize, self.ram_bank & 0x03) * 0x2000 + (raw - 0xa000); + break :blk if (index < self.ram.len) self.ram[index] else 0xff; + }, + .mbc5 => blk: { + const index = @as(usize, self.ram_bank & 0x0f) * 0x2000 + (raw - 0xa000); + break :blk if (index < self.ram.len) self.ram[index] else 0xff; + }, + .rom_only => 0xff, + }; + } + + fn writeExternal(self: *Cartridge, raw: usize, value: u8) void { + if (self.ram.len == 0 or !self.ram_enabled) return; + switch (self.mapper) { + .mbc1 => { + var index = raw - 0xa000; + if (self.banking_mode == 1) index += @as(usize, self.rom_bank_high) * 0x2000; + if (index < self.ram.len) self.ram[index] = value; + }, + .mbc2 => { + const index = self.mbc2RamIndex(raw); + self.ram[index] = value & 0x0f; + }, + .mbc3 => { + if (self.ram_bank >= 8 and self.ram_bank <= 0x0c) { + self.writeRtcRegister(value); + return; + } + const index = @as(usize, self.ram_bank & 0x03) * 0x2000 + (raw - 0xa000); + if (index < self.ram.len) self.ram[index] = value; + }, + .mbc5 => { + const index = @as(usize, self.ram_bank & 0x0f) * 0x2000 + (raw - 0xa000); + if (index < self.ram.len) self.ram[index] = value; + }, + .rom_only => {}, + } + } + + // MBC2 maps its 512-nibble internal RAM through the lower 9 address + // bits, ignoring bit 0 of the byte address. + fn mbc2RamIndex(self: *const Cartridge, raw: usize) usize { + _ = self; + return ((raw - 0xa000) >> 1) & 0x1ff; + } + + fn readRtcRegister(self: *Cartridge) u8 { + if (self.latch_state == 1) return self.latched_regs[self.ram_bank - 8]; + return self.rtc_regs[self.ram_bank - 8]; + } + + fn writeRtcRegister(self: *Cartridge, value: u8) void { + // The control register is always writable. The time registers + // only accept writes while the clock is halted. + if (self.ram_bank == 0x0c) { + const was_halted = (self.rtc_regs[4] & 0x40) != 0; + self.rtc_regs[4] = value & 0xc0; + if (was_halted and (value & 0x40) == 0) self.recomputeSeconds(); + return; + } + if ((self.rtc_regs[4] & 0x40) != 0) { + self.rtc_regs[self.ram_bank - 8] = value; + self.recomputeSeconds(); + } + } + + // A write of 0 then 1 to 0x6000 latches the clock into the visible + // registers. A trailing 0 releases the latch. + fn latchRtc(self: *Cartridge, value: u8) void { + if (value == 1 and self.latch_state == 0) { + self.latched_regs = self.rtc_regs; + self.latch_state = 1; + } else if (value == 0) { + self.latch_state = 0; + } + } + + // Rebuilds the running second count from the halted registers. The + // clock resumes from this time when the halt bit clears. + fn recomputeSeconds(self: *Cartridge) void { + const days = (@as(u64, self.rtc_regs[4] >> 7) << 8) | (self.rtc_regs[3] & 0xff); + const hours = bcdToBinary(self.rtc_regs[2]); + const minutes = bcdToBinary(self.rtc_regs[1]); + const seconds = bcdToBinary(self.rtc_regs[0]); + self.rtc_total_seconds = @as(u64, days) * 86_400 + @as(u64, hours) * 3_600 + @as(u64, minutes) * 60 + seconds; + self.rtc_cycle_accumulator = 0; + } + + fn refreshRtcRegs(self: *Cartridge) void { + const days = self.rtc_total_seconds / 86_400; + const remainder = self.rtc_total_seconds % 86_400; + const hours = remainder / 3_600; + const minutes = (remainder % 3_600) / 60; + const seconds = remainder % 60; + self.rtc_regs[0] = binaryToBcd(seconds); + self.rtc_regs[1] = binaryToBcd(minutes); + self.rtc_regs[2] = binaryToBcd(hours); + const day_bits = days & 0x1ff; + self.rtc_regs[3] = (self.rtc_regs[3] & 0x80) | @as(u8, @intCast(day_bits & 0xff)); + if (days > 0x1ff) self.rtc_regs[3] |= 0x80; + self.rtc_regs[4] = (self.rtc_regs[4] & 0x40) | (@as(u8, @intCast((day_bits >> 8) & 1)) << 7); + } + + fn bcdToBinary(value: u8) u64 { + return @as(u64, (value >> 4) * 10 + (value & 0x0f)); + } + + fn binaryToBcd(value: u64) u8 { + return @as(u8, @intCast((value / 10) << 4 | (value % 10))); + } + fn romByte(self: *Cartridge, index: usize) u8 { return if (index < self.rom.len) self.rom[index] else 0xff; } @@ -79,19 +299,35 @@ pub const Cartridge = struct { const testing = @import("std").testing; -// Builds a 256KB MBC1 image with a distinct marker byte at the start of -// every 16KB bank. The header claims MBC1 and 32KB of RAM. -fn mbc1Image(allocator: std.mem.Allocator) ![]u8 { - const image = try allocator.alloc(u8, 0x40000); +// Builds an image with a marker byte at the start of every 16KB bank. +// The header claims the given mapper and RAM size. +fn bankedImage(allocator: std.mem.Allocator, size: usize, mapper_type: u8, ram_type: u8) ![]u8 { + const image = try allocator.alloc(u8, size); @memset(image, 0); - image[0x147] = 0x01; // MBC1 - image[0x149] = 0x03; // 32KB RAM - for (0..16) |bank| { + image[0x147] = mapper_type; + image[0x149] = ram_type; + for (0..size / 0x4000) |bank| { image[bank * 0x4000] = @intCast(bank + 1); } return image; } +fn mbc1Image(allocator: std.mem.Allocator) ![]u8 { + return bankedImage(allocator, 0x40000, 0x01, 0x03); +} + +fn mbc2Image(allocator: std.mem.Allocator) ![]u8 { + return bankedImage(allocator, 0x40000, 0x05, 0x00); +} + +fn mbc3Image(allocator: std.mem.Allocator) ![]u8 { + return bankedImage(allocator, 0x200000, 0x0f, 0x03); +} + +fn mbc5Image(allocator: std.mem.Allocator) ![]u8 { + return bankedImage(allocator, 0x200000, 0x19, 0x03); +} + fn romOnlyImage(allocator: std.mem.Allocator) ![]u8 { const image = try allocator.alloc(u8, 0x8000); @memset(image, 0); @@ -200,3 +436,158 @@ test "MBC1 reads past the end of ROM as open bus" { cartridge.write(0x4000, 0x03); try testing.expectEqual(@as(u8, 0xff), cartridge.read(0x4000)); } + +test "MBC2 selects ROM banks through the bit-8 address" { + const image = try mbc2Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + // Bank 1 is the default for the 4000-7FFF window. + try testing.expectEqual(@as(u8, 2), cartridge.read(0x4000)); + + // Address bit 8 set means a ROM bank write. + cartridge.write(0x2100, 0x03); + try testing.expectEqual(@as(u8, 4), cartridge.read(0x4000)); + + // A low bank value still maps to bank 1. + cartridge.write(0x2100, 0x00); + try testing.expectEqual(@as(u8, 2), cartridge.read(0x4000)); +} + +test "MBC2 gates its 4-bit RAM on the enable register" { + const image = try mbc2Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + // Disabled RAM ignores writes. + cartridge.write(0xa000, 0x0f); + try testing.expectEqual(@as(u8, 0xff), cartridge.read(0xa000)); + + cartridge.write(0x0000, 0x0a); + cartridge.write(0xa000, 0x0f); + // Only the low nibble is stored; the high nibble reads as 0xF. + try testing.expectEqual(@as(u8, 0x0f), cartridge.read(0xa000) & 0x0f); + + // Address bit 8 picks the upper 256-nibble block. + cartridge.write(0xa200, 0x0c); + try testing.expectEqual(@as(u8, 0x0c), cartridge.read(0xa200) & 0x0f); + + // The two blocks are independent. + try testing.expectEqual(@as(u8, 0x0f), cartridge.read(0xa000) & 0x0f); +} + +test "MBC3 switches ROM banks and RAM banks" { + const image = try mbc3Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + // Bank 1 is the default for the 4000-7FFF window. + try testing.expectEqual(@as(u8, 2), cartridge.read(0x4000)); + + cartridge.write(0x2000, 0x05); + try testing.expectEqual(@as(u8, 6), cartridge.read(0x4000)); + + // The seven-bit register reaches banks past the MBC1 range. + cartridge.write(0x2000, 0x40); + try testing.expectEqual(@as(u8, 0x41), cartridge.read(0x4000)); + + // RAM is gated and banked by the 0x4000 register. + cartridge.write(0x0000, 0x0a); + cartridge.write(0x4000, 0x00); + cartridge.write(0xa000, 0x11); + try testing.expectEqual(@as(u8, 0x11), cartridge.read(0xa000)); + + cartridge.write(0x4000, 0x02); + cartridge.write(0xa000, 0x22); + try testing.expectEqual(@as(u8, 0x22), cartridge.read(0xa000)); + + cartridge.write(0x4000, 0x00); + try testing.expectEqual(@as(u8, 0x11), cartridge.read(0xa000)); +} + +test "MBC3 RTC counts seconds in BCD and latches" { + const image = try mbc3Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + cartridge.write(0x0000, 0x0a); + // Select the control register and halt the clock before writing. + cartridge.write(0x4000, 0x0c); + cartridge.write(0xa000, 0x40); // halt bit set + cartridge.write(0x4000, 0x08); // seconds + cartridge.write(0xa000, 0x59); + cartridge.write(0x4000, 0x09); // minutes + cartridge.write(0xa000, 0x00); + cartridge.write(0x4000, 0x0a); // hours + cartridge.write(0xa000, 0x00); + cartridge.write(0x4000, 0x0c); // clear halt + cartridge.write(0xa000, 0x00); + + // One second later the minutes read one. + cartridge.tick(Cartridge.cycles_per_second); + cartridge.write(0x4000, 0x08); + cartridge.write(0x6000, 0x00); + cartridge.write(0x6000, 0x01); + try testing.expectEqual(@as(u8, 0x00), cartridge.read(0xa000)); + cartridge.write(0x4000, 0x09); + cartridge.write(0x6000, 0x00); + cartridge.write(0x6000, 0x01); + try testing.expectEqual(@as(u8, 0x01), cartridge.read(0xa000)); + + // Halting freezes the clock. + cartridge.write(0x4000, 0x0c); + cartridge.write(0xa000, 0x40); + cartridge.tick(Cartridge.cycles_per_second * 2); + cartridge.write(0x4000, 0x09); + cartridge.write(0x6000, 0x00); + cartridge.write(0x6000, 0x01); + try testing.expectEqual(@as(u8, 0x01), cartridge.read(0xa000)); +} + +test "MBC5 switches ROM banks through both registers" { + const image = try mbc5Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + // Bank 1 is the default for the 4000-7FFF window. + try testing.expectEqual(@as(u8, 2), cartridge.read(0x4000)); + + cartridge.write(0x2000, 0x05); + try testing.expectEqual(@as(u8, 6), cartridge.read(0x4000)); + + // Bank 0 is addressable, unlike MBC1. + cartridge.write(0x2000, 0x00); + try testing.expectEqual(@as(u8, 1), cartridge.read(0x4000)); + + // The 0x3000 register extends the bank to nine bits. Bank 0x20 fits + // the image; bank 0x120 does not, so it reads as open bus. + cartridge.write(0x2000, 0x20); + cartridge.write(0x3000, 0x00); + try testing.expectEqual(@as(u8, 0x21), cartridge.read(0x4000)); + cartridge.write(0x3000, 0x01); + try testing.expectEqual(@as(u8, 0xff), cartridge.read(0x4000)); +} + +test "MBC5 banks external RAM up to 16 banks" { + const image = try mbc5Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + cartridge.write(0x0000, 0x0a); + cartridge.write(0x4000, 0x03); + cartridge.write(0xa000, 0x42); + try testing.expectEqual(@as(u8, 0x42), cartridge.read(0xa000)); + + // RAM bank 0 stays fresh while bank 3 holds its byte. + cartridge.write(0x4000, 0x00); + try testing.expectEqual(@as(u8, 0x00), cartridge.read(0xa000)); + + cartridge.write(0x4000, 0x03); + try testing.expectEqual(@as(u8, 0x42), cartridge.read(0xa000)); +} From 41c6882fb6a7be6a208d50316a76f443dccac79e Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Tue, 4 Aug 2026 01:01:37 -0700 Subject: [PATCH 4/6] feat: extend dot matrix deck --- .github/workflows/ci.yml | 4 ++ README.md | 34 +++++++++- ROADMAP.md | 7 +- fixtures/asm/sram.asm | 97 ++++++++++++++++++++++++++ fixtures/roms/sram.gb | Bin 0 -> 32768 bytes fixtures_tests.zig | 28 ++++++++ src/cartridge.zig | 142 +++++++++++++++++++++++++++++++++++++-- src/emulator.zig | 80 ++++++++++++++++++++++ src/headless.zig | 39 ++++++++++- src/main.zig | 24 +++++++ 10 files changed, 444 insertions(+), 11 deletions(-) create mode 100644 fixtures/asm/sram.asm create mode 100644 fixtures/roms/sram.gb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b45c84..b033501 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,6 +36,10 @@ jobs: run: git diff --exit-code -- fixtures - name: Demo ROM reports PASS run: zig build run-demo -Dsdl2=off + - name: Battery RAM save round-trip + run: | + zig build run-headless -Dsdl2=off -- fixtures/roms/sram.gb --save sram.sav + zig build run-headless -Dsdl2=off -- fixtures/roms/sram.gb --load-save sram.sav - name: Build headless tools run: zig build -Dsdl2=off diff --git a/README.md b/README.md index 365632e..cb9b886 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The core splits into small modules with one job each. - `src/bus.zig` routes memory between the devices. - `src/timer.zig` models the game clock. - `src/ppu.zig` renders background, sprites, and the window. -- `src/cartridge.zig` loads ROM-only, MBC1, MBC2, MBC3, and MBC5 images. It banks their memory and drives the MBC3 real-time clock. +- `src/cartridge.zig` loads ROM-only, MBC1, MBC2, MBC3, and MBC5 images. It banks their memory and drives the MBC3 real-time clock. Battery-backed carts keep their RAM in a `.sav` file. - `src/dma.zig` copies pages into OAM with the correct timing. - `src/serial.zig` turns link-port bytes into test verdicts. - `src/joypad.zig` reports button state through the FF00 register. @@ -97,7 +97,7 @@ 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`. +Useful options are `--max-cycles N`, `--trace`, `--expect pass|fail|any`, `--load-save `, and `--save `. The runner prints serial output and returns the verdict code. Run a ROM in the SDL2 frontend. @@ -110,6 +110,31 @@ Keys: Z is A, X is B, Enter is Start, Shift is Select. Arrows move the d-pad. P pauses, R resets, F fast-forwards, ESC quits. +## Save files + +Battery-backed cartridges keep their external RAM in a `.sav` file. +The windowed frontend loads the file next to the ROM at start. +It writes the file back when you quit. +The headless runner keeps the files explicit. + +```text +zig build run-headless -Dsdl2=off -- fixtures/roms/sram.gb --save /tmp/sram.sav +zig build run-headless -Dsdl2=off -- fixtures/roms/sram.gb --load-save /tmp/sram.sav +``` + +Sample output from the second run: + +```text +Loaded save data. +Serial output: +PASS +Verdict: pass +``` + +The save file stores the external RAM bytes only. +It matches the RAM size from the cartridge header. +The MBC3 real-time clock state is not persisted. + ## Generate fixtures Regenerate the bundled ROMs from their assembly sources. @@ -120,7 +145,7 @@ zig build fixtures -Dsdl2=off Each `.asm` file in `fixtures/asm` becomes a `.gb` image in `fixtures/roms`. The round-trip tests keep those images in sync. -The suite ships six ROMs: +The suite ships seven ROMs: - `demo` renders the title and prints PASS. - `dma` copies a page into OAM and checks the result. @@ -128,6 +153,7 @@ The suite ships six ROMs: - `mbc2` switches MBC2 banks and checks the internal RAM. - `mbc3` switches MBC3 banks, RAM banks, and the real-time clock. - `mbc5` switches MBC5 banks and RAM banks. +- `sram` enables battery-backed RAM and verifies the save file round-trip. ## Test @@ -161,6 +187,8 @@ The core starts at the post-boot state. It does not emulate the boot ROM. The MBC3 clock advances with the emulated cycle count. It does not follow the host clock. +Save files store external RAM only. +The MBC3 real-time clock state does not persist. ## License diff --git a/ROADMAP.md b/ROADMAP.md index 06dfccc..7536108 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -5,7 +5,7 @@ This document tracks what the project delivers and what remains. ## Release 0.2 Release 0.2 makes the emulator a usable workbench. It adds input, -DMA, cartridge banking, an assembler, and bundled test ROMs. +DMA, cartridge banking, an assembler, save data, and bundled test ROMs. ### Done @@ -18,9 +18,10 @@ DMA, cartridge banking, an assembler, and bundled test ROMs. - Headless ROM runner with cycle caps, traces, and verdicts. - Joypad device with active-low column selection and press-edge interrupts. - MBC3 real-time clock with halt, latch, and BCD seconds. +- Battery-backed RAM saves for the headless runner and the windowed frontend. - SDL2 windowed frontend with a DMG-style shell and pause controls. - SM83 assembler with labels, data directives, and expressions. -- Bundled demo, DMA, banking, MBC2, MBC3, and MBC5 ROMs that report PASS. +- Bundled demo, DMA, banking, MBC2, MBC3, MBC5, and SRAM ROMs that report PASS. - Deterministic unit tests for every core module. - Continuous integration on Ubuntu and Windows. @@ -29,7 +30,6 @@ DMA, cartridge banking, an assembler, and bundled test ROMs. - Cycle-accurate timing for the timer and the PPU STAT modes. - 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 debugger overlay for pause and reset. @@ -38,3 +38,4 @@ DMA, cartridge banking, an assembler, and bundled test ROMs. - The core starts at the post-boot state. Boot ROM emulation is out of scope. - The PPU has no window line counter edge cases yet. - The SDL2 frontend ships without audio. +- Save files store external RAM only. The MBC3 real-time clock state is not persisted. diff --git a/fixtures/asm/sram.asm b/fixtures/asm/sram.asm new file mode 100644 index 0000000..015ca36 --- /dev/null +++ b/fixtures/asm/sram.asm @@ -0,0 +1,97 @@ +; sram.asm - verifies battery-backed external RAM for the headless runner. +; +; The ROM enables MBC1 external RAM and stores a marker across the first +; RAM bank. On a fresh start it writes the marker and reads it back. When +; a save file is loaded the marker is already present, so the ROM checks +; that the whole saved page survived. It prints PASS over the serial port. +; Regenerate sram.gb with `zig build fixtures`. + + ORG $0000 + + ORG $0100 + NOP + JP init + + ORG $0134 + DB "SRAM TEST" ; cartridge title + ORG $0143 + DB $80 ; CGB flag + ORG $0144 + DB $00 ; licensee + ORG $0147 + DB $03 ; MBC1 + RAM + battery + ORG $0148 + DB $00 ; 32KB ROM + ORG $0149 + DB $03 ; 32KB RAM + ORG $014C + DB $00 ; header checksum + ORG $014E + DB $00, $00 ; version + ORG $0150 + +init: + LD A,$0A + LD ($0000),A ; enable RAM + LD A,($A000) + CP $5A + JR Z,check_loaded ; the marker is already saved + + ; Fresh RAM: write the marker across the bank and read it back. + LD A,$5A + LD ($A000),A + LD ($A001),A + LD ($AFFF),A + LD A,($A000) + CP $5A + JR NZ,fail + LD A,($A001) + CP $5A + JR NZ,fail + LD A,($AFFF) + CP $5A + JR NZ,fail + LD HL,msg_pass + CALL print_string + JR done + +check_loaded: + ; A save file was loaded. Confirm the whole saved page came back. + LD A,($A001) + CP $5A + JR NZ,fail + LD A,($AFFF) + CP $5A + JR NZ,fail + LD HL,msg_pass + CALL print_string + +done: + JR done + +fail: + LD HL,msg_fail + CALL print_string + JR done + +; Prints a zero-terminated string pointed to by HL over the serial port. +print_string: + LD A,(HL+) + OR A + RET Z + CALL print_char + JR print_string + +print_char: + LDH ($01),A + LD A,$81 + LDH ($02),A + RET + +msg_pass: + DB "PASS", $0D, $0A, $00 + +msg_fail: + DB "FAIL", $0D, $0A, $00 + + PAD $8000 diff --git a/fixtures/roms/sram.gb b/fixtures/roms/sram.gb new file mode 100644 index 0000000000000000000000000000000000000000..cf51ee643b22ce5a12bff9c2384939bafc4769c2 GIT binary patch literal 32768 zcmeIuF=_%q7zN;&*aRV@v9Jnj5s;8GENqd0kbq>ZJWX1d@X0xYq)_r_W)l08U-mgPwLUo*v$N9VtRUNDP zv}~8`v5D!B-8ZLYs=LuD?iO)4=+@hAz5ZK|qp$XQcvx?CpK true, + else => false, + }; + } + fn externalRamSize(image: []const u8) usize { if (image.len <= 0x149) return 0; return switch (image[0x149]) { @@ -198,11 +243,15 @@ pub const Cartridge = struct { .mbc1 => { var index = raw - 0xa000; if (self.banking_mode == 1) index += @as(usize, self.rom_bank_high) * 0x2000; - if (index < self.ram.len) self.ram[index] = value; + if (index < self.ram.len) { + self.ram[index] = value; + self.ram_dirty = true; + } }, .mbc2 => { const index = self.mbc2RamIndex(raw); self.ram[index] = value & 0x0f; + self.ram_dirty = true; }, .mbc3 => { if (self.ram_bank >= 8 and self.ram_bank <= 0x0c) { @@ -210,11 +259,17 @@ pub const Cartridge = struct { return; } const index = @as(usize, self.ram_bank & 0x03) * 0x2000 + (raw - 0xa000); - if (index < self.ram.len) self.ram[index] = value; + if (index < self.ram.len) { + self.ram[index] = value; + self.ram_dirty = true; + } }, .mbc5 => { const index = @as(usize, self.ram_bank & 0x0f) * 0x2000 + (raw - 0xa000); - if (index < self.ram.len) self.ram[index] = value; + if (index < self.ram.len) { + self.ram[index] = value; + self.ram_dirty = true; + } }, .rom_only => {}, } @@ -591,3 +646,82 @@ test "MBC5 banks external RAM up to 16 banks" { cartridge.write(0x4000, 0x03); try testing.expectEqual(@as(u8, 0x42), cartridge.read(0xa000)); } + +test "battery flag matches the cartridge type byte" { + const cases = [_]struct { byte: u8, battery: bool }{ + .{ .byte = 0x00, .battery = false }, + .{ .byte = 0x01, .battery = false }, + .{ .byte = 0x03, .battery = true }, + .{ .byte = 0x06, .battery = true }, + .{ .byte = 0x0f, .battery = true }, + .{ .byte = 0x12, .battery = false }, + .{ .byte = 0x13, .battery = true }, + .{ .byte = 0x1b, .battery = true }, + .{ .byte = 0x1e, .battery = true }, + }; + for (cases) |case| { + var image: [0x8000]u8 = [_]u8{0} ** 0x8000; + image[0x147] = case.byte; + var cartridge = try Cartridge.init(testing.allocator, &image); + defer cartridge.deinit(); + try testing.expectEqual(case.battery, cartridge.hasBattery()); + } +} + +test "external RAM writes mark the save dirty" { + var image: [0x40000]u8 = [_]u8{0} ** 0x40000; + image[0x147] = 0x03; // MBC1 + RAM + battery + image[0x149] = 0x03; // 32KB external RAM + var cartridge = try Cartridge.init(testing.allocator, &image); + defer cartridge.deinit(); + + try testing.expect(!cartridge.saveNeeded()); + // RAM disabled: writes are dropped and stay clean. + cartridge.write(0xa000, 0x42); + try testing.expect(!cartridge.ramDirty()); + + cartridge.write(0x0000, 0x0a); + cartridge.write(0xa000, 0x42); + try testing.expect(cartridge.saveNeeded()); + + cartridge.markRamClean(); + try testing.expect(!cartridge.saveNeeded()); +} + +test "cart without battery never needs a save" { + const image = try romOnlyImage(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + cartridge.write(0xa000, 0x42); + try testing.expect(!cartridge.saveNeeded()); +} + +test "save data round-trips through loadSaveData" { + const image = try mbc1Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + + cartridge.write(0x0000, 0x0a); + cartridge.write(0xa000, 0x11); + cartridge.write(0xa001, 0x22); + const saved = try testing.allocator.dupe(u8, cartridge.saveData()); + defer testing.allocator.free(saved); + + var reloaded = try Cartridge.init(testing.allocator, image); + defer reloaded.deinit(); + try reloaded.loadSaveData(saved); + reloaded.write(0x0000, 0x0a); + try testing.expectEqual(@as(u8, 0x11), reloaded.read(0xa000)); + try testing.expectEqual(@as(u8, 0x22), reloaded.read(0xa001)); +} + +test "save data rejects a size mismatch" { + const image = try mbc1Image(testing.allocator); + defer testing.allocator.free(image); + var cartridge = try Cartridge.init(testing.allocator, image); + defer cartridge.deinit(); + const short = [_]u8{0x00} ** 4; + try testing.expectError(error.SaveSizeMismatch, cartridge.loadSaveData(&short)); +} diff --git a/src/emulator.zig b/src/emulator.zig index 55c0de0..dd39fa9 100644 --- a/src/emulator.zig +++ b/src/emulator.zig @@ -56,6 +56,43 @@ pub const Emulator = struct { return self.bus.serialOutput(); } + // True when the loaded cartridge keeps its RAM in a battery save file. + pub fn hasSaveData(self: *const Emulator) bool { + return self.bus.cartridge.hasBattery(); + } + + // A battery-backed cartridge with unflushed RAM needs a save. + pub fn saveNeeded(self: *const Emulator) bool { + return self.bus.cartridge.saveNeeded(); + } + + // Restores external RAM from a .sav file. Returns false when no save + // file exists at the path. + pub fn loadSaveFile(self: *Emulator, io: std.Io, dir: std.Io.Dir, path: []const u8) !bool { + const file = dir.openFile(io, path, .{}) catch |err| switch (err) { + error.FileNotFound => return false, + else => return err, + }; + defer file.close(io); + const size = try file.length(io); + const bytes = try self.allocator.alloc(u8, @intCast(size)); + defer self.allocator.free(bytes); + _ = try file.readPositionalAll(io, bytes, 0); + try self.bus.cartridge.loadSaveData(bytes); + return true; + } + + // Writes external RAM to a .sav file when the cartridge uses battery + // RAM and the RAM holds unflushed writes. No-op otherwise. + pub fn saveSaveFile(self: *Emulator, io: std.Io, dir: std.Io.Dir, path: []const u8) !void { + const cartridge = &self.bus.cartridge; + if (!cartridge.saveNeeded()) return; + const file = try dir.createFile(io, path, .{}); + defer file.close(io); + _ = try file.writePositionalAll(io, cartridge.saveData(), 0); + cartridge.markRamClean(); + } + pub fn cycleCount(self: *Emulator) u64 { return self.total_cycles; } @@ -96,3 +133,46 @@ test "page size sanity" { try std.testing.expectEqual(@as(usize, 160), ppu.ScreenWidth); try std.testing.expectEqual(@as(usize, 144), ppu.ScreenHeight); } + +test "save file round-trip persists external RAM" { + var image: [0x40000]u8 = [_]u8{0} ** 0x40000; + image[0x147] = 0x03; // MBC1 + RAM + battery + image[0x149] = 0x03; // 32KB external RAM + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + const save_path = "save_test.sav"; + + var emulator = try Emulator.init(std.testing.allocator, &image); + defer emulator.deinit(); + try std.testing.expect(emulator.hasSaveData()); + + // No save file yet: load reports false and RAM stays clear. + try std.testing.expect(!try emulator.loadSaveFile(std.testing.io, tmp.dir, save_path)); + try std.testing.expect(!emulator.saveNeeded()); + + // A RAM write marks the save needed; saving flushes and clears it. + emulator.bus.write(0x0000, 0x0a); + emulator.bus.write(0xa000, 0x5a); + try std.testing.expect(emulator.saveNeeded()); + try emulator.saveSaveFile(std.testing.io, tmp.dir, save_path); + try std.testing.expect(!emulator.saveNeeded()); + + // A fresh emulator loads the saved byte back. + var reloaded = try Emulator.init(std.testing.allocator, &image); + defer reloaded.deinit(); + try std.testing.expect(try reloaded.loadSaveFile(std.testing.io, tmp.dir, save_path)); + reloaded.bus.write(0x0000, 0x0a); + try std.testing.expectEqual(@as(u8, 0x5a), reloaded.bus.read(0xa000)); +} + +test "save is skipped for cartridges without battery RAM" { + const rom = [_]u8{0} ** 0x8000; + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + var emulator = try Emulator.init(std.testing.allocator, &rom); + defer emulator.deinit(); + try std.testing.expect(!emulator.hasSaveData()); + try emulator.saveSaveFile(std.testing.io, tmp.dir, "noop.sav"); + try std.testing.expectError(error.FileNotFound, tmp.dir.openFile(std.testing.io, "noop.sav", .{})); +} diff --git a/src/headless.zig b/src/headless.zig index 9ec1f1f..5acbdd2 100644 --- a/src/headless.zig +++ b/src/headless.zig @@ -12,6 +12,8 @@ const Options = struct { trace: bool = false, expect: ?serial.Verdict = null, timeout_ms: u64 = 120_000, + load_save: ?[]const u8 = null, + save: ?[]const u8 = null, }; fn usage(program: []const u8) void { @@ -19,10 +21,12 @@ fn usage(program: []const u8) void { \\Dot Matrix Deck - headless runner \\Usage: \\ {s} [--max-cycles N] [--trace] [--expect pass|fail|any] + \\ [--load-save ] [--save ] \\ {s} --blargg-suite \\ \\Runs a ROM without a window, prints its serial output, and sets the - \\exit code from the test verdict. + \\exit code from the test verdict. --load-save restores battery RAM + \\before the run; --save writes battery RAM to a file after the run. \\ , .{ program, program }); } @@ -84,6 +88,18 @@ fn runRom(io: std.Io, allocator: std.mem.Allocator, options: Options) !u8 { var emulator = try Emulator.init(allocator, rom); defer emulator.deinit(); + if (options.load_save) |path| { + if (!emulator.hasSaveData()) { + std.debug.print("Cartridge has no battery RAM; save file ignored.\n", .{}); + } else { + const loaded = emulator.loadSaveFile(io, std.Io.Dir.cwd(), path) catch |err| { + std.debug.print("Could not load save file {s}: {s}\n", .{ path, @errorName(err) }); + return 1; + }; + std.debug.print("{s}\n", .{if (loaded) "Loaded save data." else "No save file found; starting fresh."}); + } + } + var runner = Runner{ .emulator = &emulator, .options = options, @@ -103,6 +119,13 @@ fn runRom(io: std.Io, allocator: std.mem.Allocator, options: Options) !u8 { const wanted = options.expect orelse verdict; const ok = wanted == verdict; std.debug.print("Verdict: {s}\n", .{@tagName(verdict)}); + + if (options.save) |path| { + emulator.saveSaveFile(io, std.Io.Dir.cwd(), path) catch |err| { + std.debug.print("Could not write save file {s}: {s}\n", .{ path, @errorName(err) }); + return 1; + }; + } return if (ok) 0 else 1; } @@ -203,6 +226,20 @@ pub fn main(init: std.process.Init) !void { std.process.exit(2); } options.blargg_suite = argv[index]; + } else if (std.mem.eql(u8, arg, "--load-save")) { + index += 1; + if (index >= argv.len) { + std.debug.print("--load-save needs a path\n", .{}); + std.process.exit(2); + } + options.load_save = argv[index]; + } else if (std.mem.eql(u8, arg, "--save")) { + index += 1; + if (index >= argv.len) { + std.debug.print("--save needs a path\n", .{}); + std.process.exit(2); + } + options.save = argv[index]; } else if (options.rom_path == null) { options.rom_path = arg; } else { diff --git a/src/main.zig b/src/main.zig index e8ef019..ca4b178 100644 --- a/src/main.zig +++ b/src/main.zig @@ -47,6 +47,7 @@ fn usage(program: []const u8) void { \\ \\Keys: Z=A, X=B, Enter=Start, Shift=Select, arrows=d-pad. \\P pause, R reset, F fast forward, ESC quit. + \\Battery RAM loads from and saves to a .sav file next to the ROM. \\ , .{program}); } @@ -61,6 +62,13 @@ fn readFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]u8 { return buffer; } +// A battery save file sits next to the ROM with a .sav extension. +fn savePathFor(allocator: std.mem.Allocator, rom_path: []const u8) ![]u8 { + const extension = std.fs.path.extension(rom_path); + if (extension.len == 0) return std.fmt.allocPrint(allocator, "{s}.sav", .{rom_path}); + return std.fmt.allocPrint(allocator, "{s}.sav", .{rom_path[0 .. rom_path.len - extension.len]}); +} + fn sdlError(context: []const u8) noreturn { std.debug.print("{s}: {s}\n", .{ context, std.mem.span(c.SDL_GetError()) }); std.process.exit(1); @@ -165,6 +173,18 @@ pub fn main(init: std.process.Init) !void { var emulator = try Emulator.init(allocator, rom); defer emulator.deinit(); + const save_path = try savePathFor(allocator, args.items[1]); + defer allocator.free(save_path); + var loaded = false; + if (emulator.loadSaveFile(io, std.Io.Dir.cwd(), save_path)) |ok| { + loaded = ok; + } else |err| { + std.debug.print("Could not load save file {s}: {s}\n", .{ save_path, @errorName(err) }); + } + if (emulator.hasSaveData()) { + std.debug.print("{s}\n", .{if (loaded) "Loaded save data." else "No save data found; starting fresh."}); + } + var run_state = frontend.RunState{}; var last_tick = c.SDL_GetTicks(); @@ -234,4 +254,8 @@ pub fn main(init: std.process.Init) !void { if (elapsed < frame_ms) c.SDL_Delay(frame_ms - elapsed); last_tick = now; } + + emulator.saveSaveFile(io, std.Io.Dir.cwd(), save_path) catch |err| { + std.debug.print("Could not write save file {s}: {s}\n", .{ save_path, @errorName(err) }); + }; } From 859c40cb63937e99e0a5bf27c2cb9f3149a5b2d9 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Tue, 4 Aug 2026 02:58:07 -0700 Subject: [PATCH 5/6] feat: extend dot matrix deck --- README.md | 36 ++++++++- ROADMAP.md | 2 +- build.zig.zon | 1 + dmd.keys.conf | 20 +++++ src/frontend.zig | 187 +++++++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 129 ++++++++++++++++++++++++-------- 6 files changed, 341 insertions(+), 34 deletions(-) create mode 100644 dmd.keys.conf diff --git a/README.md b/README.md index cb9b886..9421f65 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,9 @@ The core splits into small modules with one job each. - `src/bus.zig` routes memory between the devices. - `src/timer.zig` models the game clock. - `src/ppu.zig` renders background, sprites, and the window. -- `src/cartridge.zig` loads ROM-only, MBC1, MBC2, MBC3, and MBC5 images. It banks their memory and drives the MBC3 real-time clock. Battery-backed carts keep their RAM in a `.sav` file. +- `src/cartridge.zig` loads ROM-only, MBC1, MBC2, MBC3, and MBC5 images. + It banks memory and drives the MBC3 real-time clock. + Battery-backed carts keep RAM in a `.sav` file. - `src/dma.zig` copies pages into OAM with the correct timing. - `src/serial.zig` turns link-port bytes into test verdicts. - `src/joypad.zig` reports button state through the FF00 register. @@ -106,9 +108,37 @@ Run a ROM in the SDL2 frontend. zig build run -- path/to/rom.gb ``` -Keys: Z is A, X is B, Enter is Start, Shift is Select. +The built-in key map uses Z for A, X for B, Enter for Start, and Shift for Select. Arrows move the d-pad. -P pauses, R resets, F fast-forwards, ESC quits. +P pauses, R resets, F fast-forwards, and ESC quits. +See "Key bindings" below to change the map. + +## Key bindings + +The key map is configurable. +Create a file named `dmd.keys.conf` in the working directory. +The frontend loads it at start. +The repository ships a `dmd.keys.conf` that matches the built-in map. + +Each line maps one logical key to one or more physical keys. +Each line overrides one logical key. +Omitted keys keep the built-in bindings. + +```text +a = z +select = left shift, right shift +``` + +Physical keys use SDL scancode names. +Examples are `z`, `return`, `up`, and `left shift`. +Lines that start with `#` are comments. +Blank lines are ignored. + +Pass `--keys ` to load another file. + +```text +zig build run -- path/to/rom.gb --keys my-keys.conf +``` ## Save files diff --git a/ROADMAP.md b/ROADMAP.md index 7536108..7d2ce55 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -20,6 +20,7 @@ DMA, cartridge banking, an assembler, save data, and bundled test ROMs. - MBC3 real-time clock with halt, latch, and BCD seconds. - Battery-backed RAM saves for the headless runner and the windowed frontend. - SDL2 windowed frontend with a DMG-style shell and pause controls. +- Configurable key bindings for the windowed frontend via a `dmd.keys.conf` file. - SM83 assembler with labels, data directives, and expressions. - Bundled demo, DMA, banking, MBC2, MBC3, MBC5, and SRAM ROMs that report PASS. - Deterministic unit tests for every core module. @@ -30,7 +31,6 @@ DMA, cartridge banking, an assembler, save data, and bundled test ROMs. - Cycle-accurate timing for the timer and the PPU STAT modes. - Serial link emulation between two emulator instances. - Audio processing unit (APU). -- Configurable key bindings for the windowed frontend. - A debugger overlay for pause and reset. ## Known limits diff --git a/build.zig.zon b/build.zig.zon index 4c30835..307010d 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -18,5 +18,6 @@ ".github", ".gitignore", ".gitattributes", + "dmd.keys.conf", }, } diff --git a/dmd.keys.conf b/dmd.keys.conf new file mode 100644 index 0000000..14a6c6c --- /dev/null +++ b/dmd.keys.conf @@ -0,0 +1,20 @@ +# Dot Matrix Deck key bindings +# +# Each line maps one logical key to one or more physical keys. +# Physical keys use SDL scancode names, for example: z, return, up. +# Lines that start with # are comments. Blank lines are ignored. +# The frontend loads this file from the working directory at start. + +# Logical keys: right, left, up, down, a, b, select, start, pause, reset, fast_forward + +a = z +b = x +select = left shift, right shift +start = return +right = right +left = left +up = up +down = down +pause = p +reset = r +fast_forward = f diff --git a/src/frontend.zig b/src/frontend.zig index 77bb03a..9c19fcf 100644 --- a/src/frontend.zig +++ b/src/frontend.zig @@ -86,6 +86,81 @@ pub fn applyKey(bus: *Bus, key: Key, pressed: bool) bool { return false; } +// A physical key bound to a logical key. The physical key is an SDL +// scancode name such as "z", "return", or "left shift". Keeping SDL out +// of this file lets the parser and defaults run in headless tests. +pub const KeyBinding = struct { + key: Key, + scancode_name: []const u8, +}; + +// The built-in bindings. A config file overrides only named logical keys. +pub fn defaultKeyMap() []const KeyBinding { + return &[_]KeyBinding{ + .{ .key = .a, .scancode_name = "z" }, + .{ .key = .b, .scancode_name = "x" }, + .{ .key = .select, .scancode_name = "left shift" }, + .{ .key = .select, .scancode_name = "right shift" }, + .{ .key = .start, .scancode_name = "return" }, + .{ .key = .right, .scancode_name = "right" }, + .{ .key = .left, .scancode_name = "left" }, + .{ .key = .up, .scancode_name = "up" }, + .{ .key = .down, .scancode_name = "down" }, + .{ .key = .pause, .scancode_name = "p" }, + .{ .key = .reset, .scancode_name = "r" }, + .{ .key = .fast_forward, .scancode_name = "f" }, + }; +} + +// Returns the logical key bound to a physical key, or null. +pub fn keyForScancodeName(map: []const KeyBinding, name: []const u8) ?Key { + for (map) |binding| { + if (std.mem.eql(u8, binding.scancode_name, name)) return binding.key; + } + return null; +} + +pub const KeyConfigError = error{ + ExpectedEquals, + UnknownKey, + DuplicateKey, + DuplicateScancode, + EmptyBinding, +}; + +// Parses a key config file into bindings. Each line maps a logical key to +// one or more comma-separated SDL scancode names. Blank lines and lines +// that start with '#' are ignored. The returned bindings point into the +// input text, so the caller must keep the text alive. +pub fn parseKeyMap(allocator: std.mem.Allocator, text: []const u8) ![]KeyBinding { + var entries: std.ArrayListUnmanaged(KeyBinding) = .empty; + errdefer entries.deinit(allocator); + var lines = std.mem.splitScalar(u8, text, '\n'); + while (lines.next()) |raw_line| { + const line = std.mem.trim(u8, raw_line, " \t\r"); + if (line.len == 0 or line[0] == '#') continue; + const equals = std.mem.indexOfScalar(u8, line, '=') orelse return error.ExpectedEquals; + const key = std.meta.stringToEnum(Key, std.mem.trim(u8, line[0..equals], " \t")) orelse + return error.UnknownKey; + for (entries.items) |existing| { + if (existing.key == key) return error.DuplicateKey; + } + var assigned = false; + var names = std.mem.splitScalar(u8, line[equals + 1 ..], ','); + while (names.next()) |raw_name| { + const name = std.mem.trim(u8, raw_name, " \t\r"); + if (name.len == 0) continue; + for (entries.items) |existing| { + if (std.mem.eql(u8, existing.scancode_name, name)) return error.DuplicateScancode; + } + try entries.append(allocator, .{ .key = key, .scancode_name = name }); + assigned = true; + } + if (!assigned) return error.EmptyBinding; + } + return entries.toOwnedSlice(allocator); +} + // DMG-inspired presentation palette. The frame stores 0xAARRGGBB values. pub const palette = struct { pub const shell: u32 = 0xffd9d9b8; @@ -158,3 +233,115 @@ test "run state toggles stay independent" { try testing.expect(state.fast_forward); try testing.expect(state.running); } + +test "default key map covers every logical key" { + var seen = [_]bool{false} ** @as(usize, @typeInfo(Key).@"enum".fields.len); + for (defaultKeyMap()) |binding| { + seen[@intFromEnum(binding.key)] = true; + } + for (seen) |present| try testing.expect(present); +} + +test "default key map keeps the arrow and shift defaults" { + const map = defaultKeyMap(); + try testing.expectEqual(@as(Key, .a), keyForScancodeName(map, "z").?); + try testing.expectEqual(@as(Key, .select), keyForScancodeName(map, "left shift").?); + try testing.expectEqual(@as(Key, .select), keyForScancodeName(map, "right shift").?); + try testing.expectEqual(@as(Key, .up), keyForScancodeName(map, "up").?); + try testing.expectEqual(@as(Key, .reset), keyForScancodeName(map, "r").?); + try testing.expectEqual(@as(?Key, null), keyForScancodeName(map, "k")); +} + +test "key config parses comments, blanks, and key lists" { + const text = + \\# custom bindings + \\a = k + \\select = left shift, right shift + \\start = space + \\right = w + \\ + \\left = a + ; + const parsed = try parseKeyMap(testing.allocator, text); + defer testing.allocator.free(parsed); + try testing.expectEqual(@as(usize, 6), parsed.len); + try testing.expectEqual(@as(Key, .a), keyForScancodeName(parsed, "k").?); + try testing.expectEqual(@as(Key, .select), keyForScancodeName(parsed, "right shift").?); + try testing.expectEqual(@as(Key, .start), keyForScancodeName(parsed, "space").?); + try testing.expectEqual(@as(Key, .right), keyForScancodeName(parsed, "w").?); + try testing.expectEqual(@as(Key, .left), keyForScancodeName(parsed, "a").?); + try testing.expectEqual(@as(?Key, null), keyForScancodeName(parsed, "z")); +} + +test "remapped key config drives the joypad" { + const text = "b = k\n"; + const parsed = try parseKeyMap(testing.allocator, text); + defer testing.allocator.free(parsed); + var bus = try testBus(); + defer bus.deinit(); + const key = keyForScancodeName(parsed, "k").?; + try testing.expect(applyKey(&bus, key, true)); + try testing.expect(bus.joypad.isButtonPressed(Joypad.Button.b)); +} + +test "key config rejects malformed lines" { + try testing.expectError(error.ExpectedEquals, parseKeyMap(testing.allocator, "a z\n")); + try testing.expectError(error.UnknownKey, parseKeyMap(testing.allocator, "turbo = z\n")); + try testing.expectError(error.DuplicateKey, parseKeyMap(testing.allocator, "a = z\na = x\n")); + try testing.expectError(error.DuplicateScancode, parseKeyMap(testing.allocator, "a = z\nb = z\n")); + try testing.expectError(error.EmptyBinding, parseKeyMap(testing.allocator, "a =\n")); +} + +test "key config rejects an empty file without error" { + const parsed = try parseKeyMap(testing.allocator, "# nothing\n\n"); + defer testing.allocator.free(parsed); + try testing.expectEqual(@as(usize, 0), parsed.len); +} + +// Combines partial overrides with the built-in map. The returned bindings +// point into the override text, so the caller must keep that text alive. +pub fn mergeKeyMap(allocator: std.mem.Allocator, overrides: []const KeyBinding) ![]KeyBinding { + var merged: std.ArrayListUnmanaged(KeyBinding) = .empty; + errdefer merged.deinit(allocator); + + for (defaultKeyMap()) |binding| { + var overridden = false; + for (overrides) |override| { + if (override.key == binding.key) { + overridden = true; + break; + } + } + if (!overridden) try merged.append(allocator, binding); + } + for (overrides) |override| try merged.append(allocator, override); + + for (merged.items, 0..) |binding, index| { + for (merged.items[index + 1 ..]) |other| { + if (std.mem.eql(u8, binding.scancode_name, other.scancode_name)) { + return error.DuplicateScancode; + } + } + } + return merged.toOwnedSlice(allocator); +} + +test "partial key config keeps unspecified defaults" { + const overrides = try parseKeyMap(testing.allocator, "a = k\nselect = space\n"); + defer testing.allocator.free(overrides); + const merged = try mergeKeyMap(testing.allocator, overrides); + defer testing.allocator.free(merged); + + try testing.expectEqual(@as(Key, .a), keyForScancodeName(merged, "k").?); + try testing.expectEqual(@as(?Key, null), keyForScancodeName(merged, "z")); + try testing.expectEqual(@as(Key, .b), keyForScancodeName(merged, "x").?); + try testing.expectEqual(@as(Key, .select), keyForScancodeName(merged, "space").?); + try testing.expectEqual(@as(?Key, null), keyForScancodeName(merged, "left shift")); + try testing.expectEqual(@as(Key, .start), keyForScancodeName(merged, "return").?); +} + +test "merged key config rejects a physical key collision" { + const overrides = try parseKeyMap(testing.allocator, "a = x\n"); + defer testing.allocator.free(overrides); + try testing.expectError(error.DuplicateScancode, mergeKeyMap(testing.allocator, overrides)); +} diff --git a/src/main.zig b/src/main.zig index ca4b178..8663a7c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -12,29 +12,34 @@ const frame_cap: u64 = 1 << 24; const frame_ms: u32 = 16; const fast_forward_frames: u32 = 4; +const default_key_config = "dmd.keys.conf"; + const ScancodeKey = struct { scancode: c.SDL_Scancode, key: frontend.Key, }; -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 }, -}; +// Resolves scancode names from the key map into SDL scancodes. Each name +// comes from the SDL scancode vocabulary, for example "z" or "left shift". +fn resolveKeyMap(allocator: std.mem.Allocator, bindings: []const frontend.KeyBinding) ![]ScancodeKey { + var resolved: std.ArrayListUnmanaged(ScancodeKey) = .empty; + errdefer resolved.deinit(allocator); + for (bindings) |binding| { + const name = try allocator.dupeZ(u8, binding.scancode_name); + defer allocator.free(name); + const scancode = c.SDL_GetScancodeFromName(name.ptr); + if (scancode == c.SDL_SCANCODE_UNKNOWN) { + std.debug.print("Unknown key name in config: {s}\n", .{binding.scancode_name}); + return error.UnknownKeyName; + } + try resolved.append(allocator, .{ .scancode = scancode, .key = binding.key }); + } + return resolved.toOwnedSlice(allocator); +} -fn keyFor(scancode: c.SDL_Scancode) ?frontend.Key { - for (scancode_map) |mapping| { - if (mapping.scancode == scancode) return mapping.key; +fn keyFor(resolved: []const ScancodeKey, scancode: c.SDL_Scancode) ?frontend.Key { + for (resolved) |entry| { + if (entry.scancode == scancode) return entry.key; } return null; } @@ -43,11 +48,13 @@ fn usage(program: []const u8) void { std.debug.print( \\Dot Matrix Deck - SDL2 windowed frontend \\Usage: - \\ {s} + \\ {s} [--keys ] \\ \\Keys: Z=A, X=B, Enter=Start, Shift=Select, arrows=d-pad. \\P pause, R reset, F fast forward, ESC quit. \\Battery RAM loads from and saves to a .sav file next to the ROM. + \\Key bindings load from dmd.keys.conf in the working directory. + \\Pass --keys to use another config file instead. \\ , .{program}); } @@ -62,6 +69,20 @@ fn readFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]u8 { return buffer; } +// Reads the key config file. When the path is explicit and the file is +// missing, returns an error. When no path is given and the default file +// is missing, returns null so the built-in defaults apply. +fn loadKeyConfig(io: std.Io, allocator: std.mem.Allocator, keys_path: ?[]const u8) !?[]u8 { + const path = keys_path orelse default_key_config; + const dir = std.Io.Dir.cwd(); + if (dir.access(io, path, .{})) |_| { + return try readFile(io, allocator, path); + } else |_| { + if (keys_path != null) return error.MissingKeyConfig; + return null; + } +} + // A battery save file sits next to the ROM with a .sav extension. fn savePathFor(allocator: std.mem.Allocator, rom_path: []const u8) ![]u8 { const extension = std.fs.path.extension(rom_path); @@ -114,14 +135,14 @@ fn drawPixelGrid(renderer: *c.SDL_Renderer) void { } // Re-applies held keys after a reset so the joypad state stays in sync. -fn syncHeldKeys(emulator: *Emulator) void { +fn syncHeldKeys(emulator: *Emulator, resolved: []const ScancodeKey) void { const keyboard = c.SDL_GetKeyboardState(null); - for (scancode_map) |mapping| { - _ = frontend.applyKey(&emulator.bus, mapping.key, false); + for (resolved) |entry| { + _ = frontend.applyKey(&emulator.bus, entry.key, false); } - for (scancode_map) |mapping| { - const down = keyboard[mapping.scancode] != 0; - _ = frontend.applyKey(&emulator.bus, mapping.key, down); + for (resolved) |entry| { + const down = keyboard[entry.scancode] != 0; + _ = frontend.applyKey(&emulator.bus, entry.key, down); } } @@ -136,14 +157,62 @@ pub fn main(init: std.process.Init) !void { defer args.deinit(allocator); while (it.next()) |arg| try args.append(allocator, arg); - if (args.items.len != 2) { + var keys_path: ?[]const u8 = null; + var rom_path: ?[]const u8 = null; + var index: usize = 1; + while (index < args.items.len) : (index += 1) { + const arg = args.items[index]; + if (std.mem.eql(u8, arg, "--keys")) { + index += 1; + if (index >= args.items.len) { + std.debug.print("--keys needs a path\n", .{}); + std.process.exit(2); + } + keys_path = args.items[index]; + } else if (rom_path == null) { + rom_path = arg; + } else { + usage(args.items[0]); + std.process.exit(2); + } + } + + if (rom_path == null) { usage(args.items[0]); std.process.exit(2); } - const rom = try readFile(io, allocator, args.items[1]); + const rom = try readFile(io, allocator, rom_path.?); defer allocator.free(rom); + const key_text = loadKeyConfig(io, allocator, keys_path) catch |err| { + std.debug.print("Could not read key config: {s}\n", .{@errorName(err)}); + std.process.exit(1); + }; + defer if (key_text) |text| allocator.free(text); + + var bindings: []const frontend.KeyBinding = frontend.defaultKeyMap(); + var bindings_owned = false; + if (key_text) |text| { + const overrides = frontend.parseKeyMap(allocator, text) catch |err| { + std.debug.print("Bad key config: {s}\n", .{@errorName(err)}); + std.process.exit(1); + }; + defer allocator.free(overrides); + bindings = frontend.mergeKeyMap(allocator, overrides) catch |err| { + std.debug.print("Bad key config: {s}\n", .{@errorName(err)}); + std.process.exit(1); + }; + bindings_owned = true; + } + defer if (bindings_owned) allocator.free(bindings); + + const resolved = resolveKeyMap(allocator, bindings) catch |err| { + std.debug.print("Bad key config: {s}\n", .{@errorName(err)}); + std.process.exit(1); + }; + defer allocator.free(resolved); + if (c.SDL_Init(c.SDL_INIT_VIDEO) != 0) sdlError("SDL_Init failed"); defer c.SDL_Quit(); @@ -173,7 +242,7 @@ pub fn main(init: std.process.Init) !void { var emulator = try Emulator.init(allocator, rom); defer emulator.deinit(); - const save_path = try savePathFor(allocator, args.items[1]); + const save_path = try savePathFor(allocator, rom_path.?); defer allocator.free(save_path); var loaded = false; if (emulator.loadSaveFile(io, std.Io.Dir.cwd(), save_path)) |ok| { @@ -200,20 +269,20 @@ pub fn main(init: std.process.Init) !void { run_state.running = false; break; } - const key = keyFor(scancode) orelse continue; + const key = keyFor(resolved, scancode) orelse continue; switch (key) { .pause => run_state.togglePause(), .fast_forward => run_state.toggleFastForward(), .reset => { emulator.reset(); - syncHeldKeys(&emulator); + syncHeldKeys(&emulator, resolved); }, 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; + const key = keyFor(resolved, event.key.keysym.scancode) orelse continue; _ = frontend.applyKey(&emulator.bus, key, false); }, else => {}, From 0b67805b74baa4b8cbb08e0b3be76bb2bffdbdb9 Mon Sep 17 00:00:00 2001 From: DanieCuevas <43822444+DanielCuevas1208@users.noreply.github.com> Date: Tue, 4 Aug 2026 04:06:25 -0700 Subject: [PATCH 6/6] feat: extend dot matrix deck --- .gitignore | 1 + README.md | 1 + ROADMAP.md | 2 +- src/bus.zig | 36 +++++++++++++++++++++++++++--------- 4 files changed, 30 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index f73f965..a578e88 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .zig-cache/ +.zig-global-cache/ zig-out/ *.o *.obj diff --git a/README.md b/README.md index 9421f65..9be106c 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ The joypad uses the real register semantics. The select bits are active-low. A cleared bit selects a column. The register returns the stored select bits when read. +The bus raises the joypad interrupt on a visible falling edge. ## Requirements diff --git a/ROADMAP.md b/ROADMAP.md index 7d2ce55..07251c8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -16,7 +16,7 @@ DMA, cartridge banking, an assembler, save data, and bundled test ROMs. - Cartridge support for ROM-only, MBC1, MBC2, MBC3, and MBC5 images. - OAM DMA with cycle-accurate CPU blocking. - Headless ROM runner with cycle caps, traces, and verdicts. -- Joypad device with active-low column selection and press-edge interrupts. +- Joypad device with active-low column selection and visible falling-edge interrupts. - MBC3 real-time clock with halt, latch, and BCD seconds. - Battery-backed RAM saves for the headless runner and the windowed frontend. - SDL2 windowed frontend with a DMG-style shell and pause controls. diff --git a/src/bus.zig b/src/bus.zig index 9ea09b5..6d00233 100644 --- a/src/bus.zig +++ b/src/bus.zig @@ -75,7 +75,11 @@ pub const Bus = struct { return; } switch (address) { - 0xff00 => self.joypad.write(value), + 0xff00 => { + const before = self.joypad.read() & 0x0f; + self.joypad.write(value); + self.raiseJoypadInterrupt(before); + }, 0xff01 => self.sb = value, 0xff02 => { self.sc = value; @@ -123,18 +127,25 @@ pub const Bus = struct { self.iflag &= ~(@as(u8, 1) << bit); } - // A button press edge raises the joypad interrupt (IF bit 4). + // A visible 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); + const before = self.joypad.read() & 0x0f; self.joypad.setButton(bit, pressed); - if (pressed and !was_pressed) self.iflag |= 0x10; + self.raiseJoypadInterrupt(before); } - // A direction press edge raises the joypad interrupt (IF bit 4). + // A visible 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); + const before = self.joypad.read() & 0x0f; self.joypad.setDirection(bit, pressed); - if (pressed and !was_pressed) self.iflag |= 0x10; + self.raiseJoypadInterrupt(before); + } + + // The joypad interrupt is a falling edge on the visible low nibble. + // Presses in an unselected column do not change that nibble. + fn raiseJoypadInterrupt(self: *Bus, before: u8) void { + const after = self.joypad.read() & 0x0f; + if ((before & ~after) != 0) self.iflag |= 0x10; } pub fn serialOutput(self: *const Bus) []const u8 { @@ -149,17 +160,24 @@ test "joypad reads the selected button column" { var bus = try Bus.init(testing.allocator, &rom); defer bus.deinit(); bus.write(0xff00, 0x10); // bit 5 low selects the buttons column. + try testing.expectEqual(@as(u8, 0x10), bus.read(0xff00) & 0x30); bus.setButton(Joypad.Button.a, true); try testing.expectEqual(@as(u8, 0x0e), bus.read(0xff00) & 0x0f); } -test "joypad press edge raises the joypad interrupt" { +test "joypad interrupt follows a visible falling edge" { const rom = [_]u8{0} ** 0x8000; var bus = try Bus.init(testing.allocator, &rom); defer bus.deinit(); - bus.write(0xff00, 0x20); // bit 4 low selects the D-pad column. + bus.write(0xff0f, 0x00); + bus.write(0xff00, 0x30); // No column is selected. bus.setDirection(Joypad.Direction.right, true); + try testing.expectEqual(@as(u8, 0), bus.iflag & 0x10); + bus.write(0xff00, 0x20); // Selecting the held D-pad creates the edge. try testing.expectEqual(@as(u8, 0x10), bus.iflag & 0x10); + bus.write(0xff0f, 0x00); + bus.setDirection(Joypad.Direction.right, true); // No state change. + try testing.expectEqual(@as(u8, 0), bus.iflag & 0x10); bus.setDirection(Joypad.Direction.right, false); bus.setDirection(Joypad.Direction.right, true); try testing.expectEqual(@as(u8, 0x10), bus.iflag & 0x10);