diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..010ff23 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +# Normalise line endings so that regenerated fixtures diff cleanly on +# every platform. Binary images must never be touched by line-ending rules. +* text=auto eol=lf + +*.gb binary +*.ppm binary +*.png binary +*.sav binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..272d833 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,75 @@ +name: ci + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Test (ubuntu) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - name: Format check + run: zig fmt --check . + - name: Unit and integration tests + run: zig build test --summary all + - name: Regenerate bundled ROMs + run: zig build fixtures + - name: Bundled ROMs match their sources + run: git diff --exit-code -- fixtures + - name: CPU instruction suite (blargg) + run: zig build test-blargg + - name: Build headless tools + run: zig build -Dsdl2=off + + test-windows: + name: Test (windows) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - name: Format check + shell: pwsh + run: zig fmt --check . + - name: Unit and integration tests + shell: pwsh + run: zig build test --summary all + - name: Regenerate bundled ROMs + shell: pwsh + run: zig build fixtures + - name: Bundled ROMs match their sources + shell: pwsh + run: git diff --exit-code -- fixtures + - name: CPU instruction suite (blargg) + shell: pwsh + run: zig build test-blargg + - name: Build headless tools + shell: pwsh + run: zig build -Dsdl2=off + + windowed: + name: Windowed frontend (ubuntu) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: mlugg/setup-zig@v2 + with: + version: 0.16.0 + - name: Install SDL2 + run: sudo apt-get update && sudo apt-get install -y libsdl2-dev + - name: Build windowed frontend + run: zig build diff --git a/.gitignore b/.gitignore index 51bafde..559f356 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,12 @@ zig-out/ *.dylib *.exe .DS_Store -*.gb *.sav *.swp +*.ppm + +# Generated ROM images are ignored by default, but the bundled test +# fixtures are committed so that CI can run the ROM suites. +*.gb +!fixtures/roms/*.gb +!fixtures/blargg/*.gb diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..af1f5aa --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,70 @@ +# Contributing + +Thank you for your interest in Dot Matrix Deck. + +## Setup + +Install Zig 0.16.0 or newer. +Install SDL2 for the windowed frontend. + +## Build and test + +Run the test suite. + +``` +zig build test +``` + +Run the blargg CPU instruction suite. + +``` +zig build test-blargg +``` + +Build the headless tools. + +``` +zig build -Dsdl2=off +``` + +Format the code. + +``` +zig fmt . +``` + +## Code style + +Follow the existing style in the repository. +Run `zig fmt --check .` before you finish. +Keep each module small and single-purpose. +Add a test for every new behaviour. + +## Adding tests + +Use the bundled smoke ROM for end-to-end checks. +Regenerate its binary after you change its assembly source. + +``` +zig build fixtures +``` + +Verify the committed ROM still matches its source. + +``` +git diff --exit-code -- fixtures +``` + +Update the golden frame checksum when rendering changes. +Run the headless runner to read the new value. + +``` +zig build run-headless -- fixtures/roms/smoke.gb --dump-frame out.ppm +``` + +## Submitting changes + +Create a pull request against the main branch. +Make the change small and focused. +Add a clear description of what changed. +Confirm all CI checks pass before you request a review. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..50b893b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Daniel Cuevas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..09771b4 --- /dev/null +++ b/NOTICE @@ -0,0 +1,15 @@ +# Notice + +This file lists third-party content distributed with Dot Matrix Deck. + +## blargg CPU instruction tests + +The directory `fixtures/blargg` contains the `cpu_instrs` test ROMs. +Shay Green wrote these ROMs. +The ROMs verify the instruction set of the CPU. +They are used as read-only test fixtures. +See the blargg test collection at: +https://github.com/retrio/gb-test-roms + +The ROMs are included for test purposes only. +Dot Matrix Deck does not claim ownership of these files. diff --git a/README.md b/README.md index 5b102d7..6ba5f2f 100644 --- a/README.md +++ b/README.md @@ -1 +1,214 @@ # Dot Matrix Deck + +``` + ____ __ __ ____ + | _ \ | \/ | | _ \ + | | | | | |\/| | | | | | + | |_| | | | | | | |_| | + |____/ |_| |_| |____/ +``` + +Dot Matrix Deck is a Game Boy emulator written in Zig. +It emulates the SM83 CPU, the memory bus, the timers, and the pixel pipeline. +A software renderer draws the screen in an SDL2 window. +A headless mode runs test ROMs without a window. +The headless mode prints a pass or fail verdict from the serial link output. + +## What it provides + +- SM83 CPU with the full instruction set and interrupts +- Memory bus with ROM-only and MBC1 cartridges +- Timer unit with DIV, TIMA, TMA, and TAC registers +- Pixel pipeline for background, window, and sprites +- Serial link output for automated test verdicts +- SDL2 windowed frontend with keyboard controls +- Headless runner with frame dump and checksums +- A bundled SM83 assembler named gbasm + +The bundled test ROM runs the CPU and the memory bus. +It reports "PASS" only when every check succeeds. +The blargg CPU instruction suite runs in the headless mode. +All 11 test ROMs in the suite pass. + +## Architecture + +The code is split into small modules. +Each module owns one part of the machine. +The frontends share one core; only presentation differs. + +``` + rom.gb --> Cartridge --> Bus --> Cpu + | + v (cycles) + Timer and Ppu + | + v + Frame buffer --> window or PPM dump +``` + +| Module | Responsibility | +| --- | --- | +| `cpu.zig` | SM83 core, instructions, flags, interrupts | +| `bus.zig` | Memory map, joypad, serial link, interrupts | +| `cartridge.zig` | ROM image, MBC1 mapper, header parser | +| `timer.zig` | DIV, TIMA, TMA, and TAC | +| `ppu.zig` | LCD registers and pixel rendering | +| `serial.zig` | Verdict extraction from serial output | +| `disasm.zig` | Instruction disassembly for trace mode | +| `headless.zig` | Headless runner entry point | +| `main.zig` | SDL2 windowed frontend | +| `tools/gbasm.zig` | Two-pass assembler for test ROMs | + +## Requirements + +- Zig 0.16.0 or newer +- SDL2 for the windowed frontend only + +The headless runner does not need SDL2. +On Windows, pass `-Dsdl2=off` when SDL2 is absent. + +## Build + +Build the headless runner and gbasm. + +``` +zig build -Dsdl2=off +``` + +Build the windowed frontend. + +``` +zig build +``` + +## Run + +Run a ROM in the SDL2 window. + +``` +zig build run -- path/to/rom.gb +``` + +Keys: arrows move the D-pad. Z is A, X is B. +Enter is Start, Backspace is Select, and Esc quits. + +Run a ROM without a window. + +``` +zig build run-headless -- path/to/rom.gb +``` + +Show the cartridge header. + +``` +zig build run-headless -- path/to/rom.gb --info +``` + +Run the bundled smoke ROM. + +``` +zig build run-headless -- fixtures/roms/smoke.gb +``` + +Print a pixel checksum for a fixed number of frames. + +``` +zig build run-headless -- fixtures/roms/smoke.gb --dump-frame out.ppm --frames 3 +``` + +## Sample output + +Cartridge header of the bundled smoke ROM. + +``` +Title: SMOKE TEST +Mapper: ROM only +ROM: 32 KiB +RAM: 0 KiB +CGB: no +SGB: no +Header: valid checksum +Global: $0000 +``` + +Headless run of the bundled smoke ROM. + +``` +Serial output: +PASS + +Verdict: pass +``` + +Deterministic frame checksum after three frames. + +``` +Frame checksum: 5CCE8DC5 +``` + +Headless run of the blargg CPU suite. + +``` +11/11 ROMs passed +``` + +## Test + +Run the unit and integration tests. + +``` +zig build test +``` + +Run the blargg CPU instruction suite. + +``` +zig build test-blargg +``` + +Regenerate the bundled ROMs from their assembly sources. + +``` +zig build fixtures +``` + +Check code formatting. + +``` +zig fmt --check . +``` + +The test suite is deterministic. +The smoke ROM must rebuild byte for byte from its source. +The rendered frame must produce a fixed checksum. +CI runs every check on Linux and Windows. + +## Test status + +- `zig build test`: 42 tests pass +- `zig build test-blargg`: 11 of 11 ROMs pass +- Formatting: clean + +## Limitations + +- The APU is not implemented, so there is no sound +- OAM DMA is not implemented +- Only ROM-only and MBC1 cartridges are supported +- The joypad interrupt is not implemented +- Battery save is not supported +- The boot ROM is not emulated +- Registers start in the state left by the boot ROM + +## Roadmap + +See [ROADMAP.md](ROADMAP.md) for the completed and planned work. + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for the contribution guide. + +## License + +The project is licensed under the MIT License. +See [LICENSE](LICENSE) for details. +Third-party test ROMs are listed in [NOTICE](NOTICE). diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..8a2a55b --- /dev/null +++ b/ROADMAP.md @@ -0,0 +1,76 @@ +# Roadmap + +This document tracks the work on Dot Matrix Deck. +It shows what is complete and what remains. + +## Milestone 1: CPU core + +Status: complete. + +- SM83 CPU with all 256 opcodes +- CB prefix instructions +- Interrupt handling with IME timing +- HALT and STOP behaviour +- Stack and register pair operations +- Verified by the blargg CPU instruction suite + +## Milestone 2: Memory and timing + +Status: complete. + +- Full address map with WRAM, HRAM, and echoes +- Timer unit with DIV, TIMA, TMA, and TAC +- MBC1 mapper for banked ROM and RAM +- Serial link output for test verdicts +- Joypad input from the frontend + +## Milestone 3: Pixel pipeline + +Status: complete. + +- LCD register file +- Background, window, and sprite rendering +- Sprite priority and palettes +- VBlank and STAT interrupts +- Deterministic frame checksums + +## Milestone 4: Frontends + +Status: complete. + +- SDL2 windowed frontend +- Keyboard controls for the D-pad and buttons +- Headless runner with pass or fail verdicts +- Frame dump to PPM with checksums +- Cartridge header display with the --info flag +- Instruction trace mode + +## Milestone 5: Test infrastructure + +Status: complete. + +- Bundled smoke ROM assembled from source +- Deterministic rebuild check for the smoke ROM +- Golden frame checksum tests +- blargg CPU instruction suite, 11 of 11 passing +- GitHub Actions CI on Linux and Windows +- Formatting enforced by zig fmt + +## Milestone 6: Audio and remaining hardware + +Status: planned. + +- APU and the four sound channels +- OAM DMA transfers +- Joypad interrupt +- Battery save to disk +- Additional mappers: MBC3 and MBC5 + +## Milestone 7: Accuracy and polish + +Status: planned. + +- Cycle-accurate STAT timing +- Boot ROM emulation +- Save state support +- Debugger integration diff --git a/build.zig b/build.zig index 815819d..f1894fa 100644 --- a/build.zig +++ b/build.zig @@ -17,6 +17,11 @@ fn sdl2PrefixFound(b: *std.Build, prefix: []const u8) bool { return true; } +fn pathExists(b: *std.Build, path: []const u8) bool { + b.build_root.handle.access(b.graph.io, path, .{}) catch return false; + return true; +} + pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); @@ -57,22 +62,22 @@ pub fn build(b: *std.Build) void { }; if (build_windowed) { - var sdl_prefix: ?[]const u8 = null; - if (sdl2_opt) |value| { - if (value.len != 0 and !std.mem.eql(u8, value, "off")) sdl_prefix = value; - } else { - if (b.graph.environ_map.get("SDL2_DIR")) |dir| { - if (dir.len != 0) sdl_prefix = dir; - } - if (sdl_prefix == null and target.result.os.tag == .windows) { + var sdl_prefix: ?[]const u8 = null; + if (sdl2_opt) |value| { + if (value.len != 0 and !std.mem.eql(u8, value, "off")) sdl_prefix = value; + } else { + if (b.graph.environ_map.get("SDL2_DIR")) |dir| { + if (dir.len != 0) sdl_prefix = dir; + } + if (sdl_prefix == null and target.result.os.tag == .windows) { for (sdl_prefix_candidates) |candidate| { if (sdl2PrefixFound(b, candidate)) { sdl_prefix = candidate; break; } } + } } - } if (sdl_prefix != null or target.result.os.tag != .windows) { const exe = b.addExecutable(.{ @@ -84,7 +89,18 @@ pub fn build(b: *std.Build) void { .link_libc = true, }), }); - exe.root_module.linkSystemLibrary("SDL2", .{}); + if (sdl_prefix != null and target.result.os.tag == .windows) { + // Link against the MSYS2 DLL directly. The static archive + // references CRT symbols that Zig's runtime does not provide. + const dll_path = b.pathJoin(&.{ sdl_prefix.?, "bin", "SDL2.dll" }); + if (pathExists(b, dll_path)) { + exe.root_module.addObjectFile(.{ .cwd_relative = dll_path }); + } else { + exe.root_module.linkSystemLibrary("SDL2", .{}); + } + } else { + exe.root_module.linkSystemLibrary("SDL2", .{}); + } if (target.result.os.tag == .windows) { for (sdl_deps) |lib| exe.root_module.linkSystemLibrary(lib, .{}); } @@ -92,7 +108,7 @@ pub fn build(b: *std.Build) void { exe.root_module.addIncludePath(.{ .cwd_relative = b.pathJoin(&.{ prefix, "include" }) }); exe.root_module.addLibraryPath(.{ .cwd_relative = b.pathJoin(&.{ prefix, "lib" }) }); const dll_path = b.pathJoin(&.{ prefix, "bin", "SDL2.dll" }); - if (sdl2PrefixFound(b, prefix)) { + if (pathExists(b, dll_path)) { const install_dll = b.addInstallBinFile(.{ .cwd_relative = dll_path }, "bin/SDL2.dll"); b.getInstallStep().dependOn(&install_dll.step); } @@ -131,6 +147,23 @@ pub fn build(b: *std.Build) void { const run_asm_tests = b.addRunArtifact(asm_tests); test_step.dependOn(&run_asm_tests.step); + const integration_tests = b.addTest(.{ + .root_module = b.createModule(.{ + .root_source_file = b.path("src/integration.zig"), + .target = target, + .optimize = optimize, + .imports = &.{ + .{ .name = "gbasm", .module = b.createModule(.{ + .root_source_file = b.path("tools/gbasm.zig"), + .target = target, + .optimize = optimize, + }) }, + }, + }), + }); + const run_integration_tests = b.addRunArtifact(integration_tests); + test_step.dependOn(&run_integration_tests.step); + const fixtures_step = b.step("fixtures", "Regenerate bundled test ROMs from their assembly sources"); { const run = b.addRunArtifact(gbasm); diff --git a/build.zig.zon b/build.zig.zon index 530eafc..0888578 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -10,9 +10,12 @@ "tools", "fixtures", "README.md", + "ROADMAP.md", + "CONTRIBUTING.md", "LICENSE", "NOTICE", ".github", ".gitignore", + ".gitattributes", }, } diff --git a/fixtures/asm/smoke.asm b/fixtures/asm/smoke.asm new file mode 100644 index 0000000..8084454 --- /dev/null +++ b/fixtures/asm/smoke.asm @@ -0,0 +1,157 @@ +; smoke.asm - a bundled smoke test for the Dot Matrix Deck emulator. +; +; The ROM exercises the CPU and the memory bus, then reports the result +; through the serial link port. The headless runner checks the serial +; output for "PASS" to decide whether the emulator behaved correctly. +; +; The cartridge header holds a valid header checksum, so the --info +; frontend can display the ROM details. +; +; Regenerate the ROM with: zig build fixtures + + org $0100 + nop ; entry point + jp start + + ; $0104-$0133: Nintendo logo. Zeros are fine for an emulator test. + ds $30 + + ; Cartridge header ($0134-$014F). + db "SMOKE TEST" ; title (16 bytes, zero padded) + ds 6 + db $00, $00 ; new licensee code + db $00 ; SGB flag + db $00 ; cartridge type: ROM only + db $00 ; ROM size: 32 KiB + db $00 ; RAM size: none + db $00 ; destination: Japan + db $33 ; old licensee: new style + db $00 ; mask ROM version + db $d5 ; header checksum + db $00, $00 ; global checksum + +start: + ; Set up a stable stack in working RAM. + ld sp, $fffe + + ; --- arithmetic: A = 2 + 3 must equal 5 --- + ld a, $02 + add a, $03 + cp $05 + jp nz, fail + + ; --- flag handling: INC wraps $ff to 0 and sets Z --- + ld a, $ff + inc a + jp nz, fail + + ; --- DEC from 1 to 0 sets Z --- + ld a, $01 + dec a + jp nz, fail + + ; --- 16-bit moves and ADD HL --- + ld bc, $1234 + ld de, $0001 + ld hl, $0000 + add hl, de + ld a, b + cp $12 + jp nz, fail + + ; --- stack push/pop round trip --- + ld hl, $abcd + push hl + pop de + ld a, d + cp $ab + jp nz, fail + ld a, e + cp $cd + jp nz, fail + + ; --- memory: LD (HL),A / LD A,(HL) --- + ld hl, $c000 + ld (hl), $7e + ld a, (hl) + cp $7e + jp nz, fail + + ; --- CB prefix: rotate and bit tests --- + ld a, $01 + sla a + cp $02 + jp nz, fail + bit 1, a + jp z, fail + + ; --- calls and conditional returns --- + call compute + jp nz, fail + + ; --- execute a payload copied into RAM --- + ld hl, payload + ld de, $d000 +copy: + ld a, (hl+) + or a + jr z, run + ld (de), a + inc de + jr copy +run: + call $d000 + jp nz, fail + + ; All checks passed. + ld hl, msg_pass + call print + jp done + +fail: + ld hl, msg_fail + call print + +done: + halt + jr done + +; Computes 40 + 2 and compares to 42. +; Returns with Z set when the result is correct. +compute: + ld a, $28 + add a, $02 + cp $2a + ret + +; A payload for RAM execution. It returns with Z set only when the +; emulator runs code from working RAM correctly. The byte sequence must +; not contain a $00 operand, because the copy loop stops on a zero byte. +payload: + ld a, $07 + add a, $01 + cp $08 + ret + db $00 + +; Prints the zero-terminated string at HL through the serial link port. +print: + ld a, (hl+) + or a + jr z, print_done + ld ($ff01), a + ld a, $81 + ld ($ff02), a + ld a, $00 + ld ($ff02), a + jr print +print_done: + ret + +msg_pass: + db "PASS", $0a + db $00 + +msg_fail: + db "FAIL", $0a + db $00 diff --git a/fixtures/blargg/01-special.gb b/fixtures/blargg/01-special.gb new file mode 100644 index 0000000..ad3e998 Binary files /dev/null and b/fixtures/blargg/01-special.gb differ diff --git a/fixtures/blargg/02-interrupts.gb b/fixtures/blargg/02-interrupts.gb new file mode 100644 index 0000000..2089594 Binary files /dev/null and b/fixtures/blargg/02-interrupts.gb differ diff --git a/fixtures/blargg/03-op sp,hl.gb b/fixtures/blargg/03-op sp,hl.gb new file mode 100644 index 0000000..50b3cc7 Binary files /dev/null and b/fixtures/blargg/03-op sp,hl.gb differ diff --git a/fixtures/blargg/04-op r,imm.gb b/fixtures/blargg/04-op r,imm.gb new file mode 100644 index 0000000..58ca7b8 Binary files /dev/null and b/fixtures/blargg/04-op r,imm.gb differ diff --git a/fixtures/blargg/05-op rp.gb b/fixtures/blargg/05-op rp.gb new file mode 100644 index 0000000..1c19d92 Binary files /dev/null and b/fixtures/blargg/05-op rp.gb differ diff --git a/fixtures/blargg/06-ld r,r.gb b/fixtures/blargg/06-ld r,r.gb new file mode 100644 index 0000000..d497bfd Binary files /dev/null and b/fixtures/blargg/06-ld r,r.gb differ diff --git a/fixtures/blargg/07-jr,jp,call,ret,rst.gb b/fixtures/blargg/07-jr,jp,call,ret,rst.gb new file mode 100644 index 0000000..5c8d20b Binary files /dev/null and b/fixtures/blargg/07-jr,jp,call,ret,rst.gb differ diff --git a/fixtures/blargg/08-misc instrs.gb b/fixtures/blargg/08-misc instrs.gb new file mode 100644 index 0000000..4da139b Binary files /dev/null and b/fixtures/blargg/08-misc instrs.gb differ diff --git a/fixtures/blargg/09-op r,r.gb b/fixtures/blargg/09-op r,r.gb new file mode 100644 index 0000000..e30e6ec Binary files /dev/null and b/fixtures/blargg/09-op r,r.gb differ diff --git a/fixtures/blargg/10-bit ops.gb b/fixtures/blargg/10-bit ops.gb new file mode 100644 index 0000000..8988458 Binary files /dev/null and b/fixtures/blargg/10-bit ops.gb differ diff --git a/fixtures/blargg/11-op a,(hl).gb b/fixtures/blargg/11-op a,(hl).gb new file mode 100644 index 0000000..0634b7f Binary files /dev/null and b/fixtures/blargg/11-op a,(hl).gb differ diff --git a/fixtures/roms/smoke.gb b/fixtures/roms/smoke.gb new file mode 100644 index 0000000..43d1cfe Binary files /dev/null and b/fixtures/roms/smoke.gb differ diff --git a/src/cartridge.zig b/src/cartridge.zig index 6fe8dae..eabef06 100644 --- a/src/cartridge.zig +++ b/src/cartridge.zig @@ -76,3 +76,146 @@ pub const Cartridge = struct { return if (index < self.rom.len) self.rom[index] else 0xff; } }; + +pub const Header = struct { + title: [16]u8, + mapper: []const u8, + rom_size_kib: u32, + ram_size_kib: u32, + cgb: bool, + sgb: bool, + header_checksum_ok: bool, + global_checksum: u16, +}; + +fn mapperName(code: u8) []const u8 { + return switch (code) { + 0x00 => "ROM only", + 0x01...0x03 => "MBC1", + 0x05, 0x06 => "MBC2", + 0x0f...0x13 => "MBC3", + 0x19...0x1e => "MBC5", + else => "Unknown", + }; +} + +fn romSizeKib(code: u8) u32 { + return switch (code) { + 0x00 => 32, + 0x01 => 64, + 0x02 => 128, + 0x03 => 256, + 0x04 => 512, + 0x05 => 1024, + 0x06 => 2048, + else => 0, + }; +} + +fn ramSizeKib(code: u8) u32 { + return switch (code) { + 0x01 => 2, + 0x02 => 8, + 0x03 => 32, + 0x04 => 128, + 0x05 => 64, + else => 0, + }; +} + +// Reads the 80-byte cartridge header at $0134. The header checksum is the +// sum of bytes $0134..$014D plus $19, which must wrap to zero on hardware. +pub fn readHeader(image: []const u8) Header { + var title = [_]u8{0} ** 16; + if (image.len >= 0x0144) { + const raw = image[0x0134..0x0144]; + var index: usize = 0; + while (index < title.len) : (index += 1) { + const byte = raw[index]; + if (byte < 0x20 or byte == 0x7f) break; + title[index] = byte; + } + } + var checksum: u8 = 0x19; + if (image.len >= 0x014e) { + for (image[0x0134..0x014e]) |byte| checksum +%= byte; + } + const global = if (image.len >= 0x0150) @as(u16, image[0x014f]) << 8 | image[0x014e] else 0; + const mapper = if (image.len >= 0x0148) image[0x0147] else 0; + const cgb = if (image.len >= 0x0144) (image[0x0143] == 0x80 or image[0x0143] == 0xc0) else false; + const sgb = if (image.len >= 0x0147) image[0x0146] == 0x03 else false; + const rom_size = if (image.len >= 0x0149) image[0x0148] else 0; + const ram_size = if (image.len >= 0x014a) image[0x0149] else 0; + return .{ + .title = title, + .mapper = mapperName(mapper), + .rom_size_kib = romSizeKib(rom_size), + .ram_size_kib = ramSizeKib(ram_size), + .cgb = cgb, + .sgb = sgb, + .header_checksum_ok = checksum == 0, + .global_checksum = global, + }; +} + +// Formats the header for the headless --info flag. Returns a slice into +// `buffer`; callers keep the buffer alive while they print the result. +pub fn formatHeader(header: Header, buffer: []u8) []const u8 { + const title = std.mem.sliceTo(&header.title, 0); + const cgb = if (header.cgb) "yes" else "no"; + const sgb = if (header.sgb) "yes" else "no"; + const checksum = if (header.header_checksum_ok) "valid" else "invalid"; + return std.fmt.bufPrint(buffer, "Title: {s}\nMapper: {s}\nROM: {d} KiB\nRAM: {d} KiB\nCGB: {s}\nSGB: {s}\nHeader: {s} checksum\nGlobal: ${X:0>4}\n", .{ + if (title.len == 0) "(none)" else title, + header.mapper, + header.rom_size_kib, + header.ram_size_kib, + cgb, + sgb, + checksum, + header.global_checksum, + }) catch buffer[0..0]; +} + +test "readHeader parses a known ROM image" { + var image: [0x8000]u8 = [_]u8{0} ** 0x8000; + image[0x0147] = 0x01; // MBC1 + image[0x0148] = 0x01; // 64 KiB + image[0x0149] = 0x03; // 32 KiB RAM + image[0x0143] = 0x80; // CGB + image[0x0146] = 0x03; // SGB + _ = std.fmt.bufPrint(image[0x0134..0x0144], "TEST ROM", .{}) catch unreachable; + const header = readHeader(&image); + try std.testing.expectEqualStrings("TEST ROM", std.mem.sliceTo(&header.title, 0)); + try std.testing.expectEqualStrings("MBC1", header.mapper); + try std.testing.expectEqual(@as(u32, 64), header.rom_size_kib); + try std.testing.expectEqual(@as(u32, 32), header.ram_size_kib); + try std.testing.expect(header.cgb); + try std.testing.expect(header.sgb); +} + +test "readHeader verifies the header checksum" { + var image: [0x8000]u8 = [_]u8{0} ** 0x8000; + _ = std.fmt.bufPrint(image[0x0134..0x0144], "SMOKE", .{}) catch unreachable; + const before = readHeader(&image); + try std.testing.expect(!before.header_checksum_ok); + var checksum: u8 = 0x19; + for (image[0x0134..0x014d]) |byte| checksum +%= byte; + image[0x014d] = 0 -% checksum; + const after = readHeader(&image); + try std.testing.expect(after.header_checksum_ok); +} + +test "formatHeader prints a readable block" { + var image: [0x8000]u8 = [_]u8{0} ** 0x8000; + _ = std.fmt.bufPrint(image[0x0134..0x0144], "SMOKE", .{}) catch unreachable; + var checksum: u8 = 0x19; + for (image[0x0134..0x014d]) |byte| checksum +%= byte; + image[0x014d] = 0 -% checksum; + var buffer: [256]u8 = undefined; + const text = formatHeader(readHeader(&image), &buffer); + try std.testing.expect(std.mem.indexOf(u8, text, "Title: SMOKE") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "Mapper: ROM only") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "valid checksum") != null); + try std.testing.expect(std.mem.indexOf(u8, text, "Global: $0000") != null); +} diff --git a/src/cpu.zig b/src/cpu.zig index fd32347..d48c75f 100644 --- a/src/cpu.zig +++ b/src/cpu.zig @@ -273,9 +273,18 @@ pub const Cpu = struct { fn setPair(self: *Cpu, pair_index: u2, value: u16) void { switch (pair_index) { - 0 => { self.b = @truncate(value >> 8); self.c = @truncate(value); }, - 1 => { self.d = @truncate(value >> 8); self.e = @truncate(value); }, - 2 => { self.h = @truncate(value >> 8); self.l = @truncate(value); }, + 0 => { + self.b = @truncate(value >> 8); + self.c = @truncate(value); + }, + 1 => { + self.d = @truncate(value >> 8); + self.e = @truncate(value); + }, + 2 => { + self.h = @truncate(value >> 8); + self.l = @truncate(value); + }, else => self.sp = value, } } @@ -484,8 +493,13 @@ pub const Cpu = struct { } fn popPair(self: *Cpu, bus: *Bus, pair_index: u2) u16 { - self.setPair(pair_index, self.pop(bus)); - if (pair_index == 3) self.f &= 0xf0; + const value = self.pop(bus); + if (pair_index == 3) { + self.a = @truncate(value >> 8); + self.f = @as(u8, @truncate(value)) & 0xf0; + } else { + self.setPair(pair_index, value); + } return 12; } @@ -681,3 +695,46 @@ pub const Cpu = struct { return @as(u16, self.a) << 8 | self.f; } }; + +const std = @import("std"); + +test "POP AF restores A and F without changing SP" { + const allocator = std.testing.allocator; + var rom = [_]u8{0} ** 0x8000; + rom[0x0100] = 0xf1; + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + var cpu = Cpu{}; + + cpu.sp = 0xc000; + cpu.a = 0; + cpu.f = 0; + bus.write(0xc000, 0x5a); + bus.write(0xc001, 0xa3); + _ = cpu.step(&bus); + + try std.testing.expectEqual(@as(u8, 0xa3), cpu.a); + try std.testing.expectEqual(@as(u8, 0x50), cpu.f); + try std.testing.expectEqual(@as(u16, 0xc002), cpu.sp); + try std.testing.expectEqual(@as(u16, 0x0101), cpu.pc); +} + +test "PUSH AF preserves the register pair through the stack" { + const allocator = std.testing.allocator; + var rom = [_]u8{0} ** 0x8000; + rom[0x0100] = 0xf5; + rom[0x0101] = 0xf1; + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + var cpu = Cpu{}; + + cpu.sp = 0xdff0; + cpu.a = 0x4d; + cpu.f = 0x30; + _ = cpu.step(&bus); + _ = cpu.step(&bus); + + try std.testing.expectEqual(@as(u8, 0x4d), cpu.a); + try std.testing.expectEqual(@as(u8, 0x30), cpu.f); + try std.testing.expectEqual(@as(u16, 0xdff0), cpu.sp); +} diff --git a/src/disasm.zig b/src/disasm.zig index 234bc7c..6d10afc 100644 --- a/src/disasm.zig +++ b/src/disasm.zig @@ -13,6 +13,7 @@ pub const Instruction = struct { const r8_names = [_][]const u8{ "B", "C", "D", "E", "H", "L", "(HL)", "A" }; const r16_names = [_][]const u8{ "BC", "DE", "HL", "SP" }; +const stack_pair_names = [_][]const u8{ "BC", "DE", "HL", "AF" }; fn r8(index: u8) []const u8 { return r8_names[index & 7]; @@ -102,11 +103,11 @@ const Writer = struct { } fn putPOP(self: *Writer, index: u8) void { - self.putFmt("POP {s}", .{r16_names[index & 3]}); + self.putFmt("POP {s}", .{stack_pair_names[index & 3]}); } fn putPUSH(self: *Writer, index: u8) void { - self.putFmt("PUSH {s}", .{r16_names[index & 3]}); + self.putFmt("PUSH {s}", .{stack_pair_names[index & 3]}); } fn putJPC(self: *Writer, flag: []const u8, hi: u8, lo: u8) void { @@ -140,7 +141,7 @@ const Writer = struct { } fn putLDIMM8(self: *Writer, address: u16, reg: []const u8) void { - self.putFmt("LDH (${X:0>2}),{s}", .{address & 0xff, reg}); + self.putFmt("LDH (${X:0>2}),{s}", .{ address & 0xff, reg }); } fn putLDA16(self: *Writer, hi: u8, lo: u8) void { @@ -381,22 +382,22 @@ fn instructionLen(opcode: u8) usize { fn cyclesOf(opcode: u8) u8 { const table = [_]u8{ - 4, 12, 8, 8, 4, 4, 8, 4, 20, 8, 8, 8, 4, 4, 8, 4, - 4, 12, 8, 8, 4, 4, 8, 4, 12, 8, 8, 8, 4, 4, 8, 4, - 8, 12, 8, 8, 4, 4, 8, 4, 12, 8, 8, 8, 4, 4, 8, 4, - 8, 12, 8, 8, 12, 12, 12, 4, 12, 8, 8, 8, 4, 4, 8, 4, - 4, 4, 4, 4, 4, 4, 8, 4, 4, 4, 4, 4, 4, 4, 8, 4, - 4, 4, 4, 4, 4, 4, 8, 4, 4, 4, 4, 4, 4, 4, 8, 4, - 8, 8, 8, 8, 8, 8, 4, 8, 4, 4, 4, 4, 4, 4, 8, 4, - 8, 8, 8, 8, 8, 8, 4, 8, 4, 4, 4, 4, 4, 4, 8, 4, - 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, - 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, - 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, - 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, - 12, 12, 8, 4, 16, 16, 8, 16, 20, 16, 16, 4, 16, 24, 8, 16, - 12, 12, 8, 4, 16, 16, 8, 16, 20, 16, 16, 4, 16, 24, 8, 16, - 12, 12, 8, 4, 16, 16, 20, 16, 8, 16, 16, 4, 16, 24, 8, 16, - 12, 12, 8, 4, 16, 16, 4, 16, 8, 16, 16, 4, 16, 24, 8, 16, + 4, 12, 8, 8, 4, 4, 8, 4, 20, 8, 8, 8, 4, 4, 8, 4, + 4, 12, 8, 8, 4, 4, 8, 4, 12, 8, 8, 8, 4, 4, 8, 4, + 8, 12, 8, 8, 4, 4, 8, 4, 12, 8, 8, 8, 4, 4, 8, 4, + 8, 12, 8, 8, 12, 12, 12, 4, 12, 8, 8, 8, 4, 4, 8, 4, + 4, 4, 4, 4, 4, 4, 8, 4, 4, 4, 4, 4, 4, 4, 8, 4, + 4, 4, 4, 4, 4, 4, 8, 4, 4, 4, 4, 4, 4, 4, 8, 4, + 8, 8, 8, 8, 8, 8, 4, 8, 4, 4, 4, 4, 4, 4, 8, 4, + 8, 8, 8, 8, 8, 8, 4, 8, 4, 4, 4, 4, 4, 4, 8, 4, + 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, + 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, + 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, + 8, 12, 12, 16, 12, 16, 8, 16, 8, 16, 12, 16, 12, 24, 8, 16, + 12, 12, 8, 4, 16, 16, 8, 16, 20, 16, 16, 4, 16, 24, 8, 16, + 12, 12, 8, 4, 16, 16, 8, 16, 20, 16, 16, 4, 16, 24, 8, 16, + 12, 12, 8, 4, 16, 16, 20, 16, 8, 16, 16, 4, 16, 24, 8, 16, + 12, 12, 8, 4, 16, 16, 4, 16, 8, 16, 16, 4, 16, 24, 8, 16, }; return table[opcode]; } @@ -419,14 +420,29 @@ test "disassembler formats representative instructions" { const a = formatAt(&bus, 0x0100); try std.testing.expectEqual(@as(usize, 3), a.len); - try std.testing.expectEqualStrings("LD HL,$1234", std.mem.trimRight(u8, &a.text, "\x00")); + try std.testing.expectEqualStrings("LD HL,$1234", std.mem.trimEnd(u8, &a.text, "\x00")); const b = formatAt(&bus, 0x0103); - try std.testing.expectEqualStrings("LD A,$AA", std.mem.trimRight(u8, &b.text, "\x00")); + try std.testing.expectEqualStrings("LD A,$AA", std.mem.trimEnd(u8, &b.text, "\x00")); const c = formatAt(&bus, 0x0105); - try std.testing.expectEqualStrings("RLC C", std.mem.trimRight(u8, &c.text, "\x00")); + try std.testing.expectEqualStrings("RLC C", std.mem.trimEnd(u8, &c.text, "\x00")); const d = formatAt(&bus, 0x0107); - try std.testing.expectEqualStrings("JP $1000", std.mem.trimRight(u8, &d.text, "\x00")); + try std.testing.expectEqualStrings("JP $1000", std.mem.trimEnd(u8, &d.text, "\x00")); +} + +test "disassembler labels PUSH and POP AF correctly" { + const allocator = std.testing.allocator; + var rom = [_]u8{0} ** 0x8000; + rom[0x0100] = 0xf5; + rom[0x0101] = 0xf1; + var bus = try Bus.init(allocator, &rom); + defer bus.deinit(); + + const push = formatAt(&bus, 0x0100); + try std.testing.expectEqualStrings("PUSH AF", std.mem.trimEnd(u8, &push.text, "\x00")); + + const pop = formatAt(&bus, 0x0101); + try std.testing.expectEqualStrings("POP AF", std.mem.trimEnd(u8, &pop.text, "\x00")); } diff --git a/src/emulator.zig b/src/emulator.zig index 4639f75..370d70d 100644 --- a/src/emulator.zig +++ b/src/emulator.zig @@ -41,7 +41,7 @@ pub const Emulator = struct { pub fn runFrame(self: *Emulator, cap: u64) void { var remaining = cap; while (!self.bus.ppu.takeFrame()) { - self.step(); + _ = self.step(); remaining -= 1; if (remaining == 0) return; } diff --git a/src/headless.zig b/src/headless.zig index 9ec1f1f..9a8ae52 100644 --- a/src/headless.zig +++ b/src/headless.zig @@ -2,29 +2,77 @@ const std = @import("std"); const Emulator = @import("emulator.zig").Emulator; const serial = @import("serial.zig"); const disasm = @import("disasm.zig"); +const ppu = @import("ppu.zig"); +const cartridge = @import("cartridge.zig"); const default_max_cycles: u64 = 2_000_000_000; +const Expect = enum { pass, fail, any }; + const Options = struct { rom_path: ?[]const u8 = null, blargg_suite: ?[]const u8 = null, max_cycles: u64 = default_max_cycles, trace: bool = false, - expect: ?serial.Verdict = null, + expect: ?Expect = null, timeout_ms: u64 = 120_000, + frame_dump: ?[]const u8 = null, + frame_count: u64 = 1, + info: bool = false, }; +// FNV-1a over the raw frame bytes. The value proves that the pixel +// pipeline is deterministic across builds and platforms. +fn frameChecksum(frame: []const u32) u32 { + var hash: u32 = 0x811c9dc5; + for (frame) |pixel| { + const bytes = [4]u8{ + @truncate(pixel), + @truncate(pixel >> 8), + @truncate(pixel >> 16), + @truncate(pixel >> 24), + }; + for (bytes) |byte| { + hash ^= byte; + hash *%= 0x01000193; + } + } + return hash; +} + +fn writePpm(io: std.Io, path: []const u8, frame: []const u32) !void { + const dir = std.Io.Dir.cwd(); + const file = try dir.createFile(io, path, .{ .truncate = true }); + defer file.close(io); + + const header = try std.fmt.allocPrint(std.heap.page_allocator, "P6\n{d} {d}\n255\n", .{ ppu.ScreenWidth, ppu.ScreenHeight }); + defer std.heap.page_allocator.free(header); + try file.writePositionalAll(io, header, 0); + + var pixels: [ppu.ScreenWidth * ppu.ScreenHeight * 3]u8 = undefined; + for (frame, 0..) |pixel, index| { + pixels[index * 3] = @truncate(pixel >> 16); + pixels[index * 3 + 1] = @truncate(pixel >> 8); + pixels[index * 3 + 2] = @truncate(pixel); + } + try file.writePositionalAll(io, &pixels, header.len); +} + fn usage(program: []const u8) void { std.debug.print( \\Dot Matrix Deck - headless runner \\Usage: \\ {s} [--max-cycles N] [--trace] [--expect pass|fail|any] + \\ {s} --dump-frame [--frames N] + \\ {s} --info \\ {s} --blargg-suite \\ \\Runs a ROM without a window, prints its serial output, and sets the - \\exit code from the test verdict. + \\exit code from the test verdict. --dump-frame writes the rendered + \\pixel buffer to a PPM file and prints a deterministic checksum. + \\--info prints the cartridge header and exits. \\ - , .{ program, program }); + , .{ program, program, program, program }); } fn readFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]u8 { @@ -81,9 +129,26 @@ fn runRom(io: std.Io, allocator: std.mem.Allocator, options: Options) !u8 { const rom = try readFile(io, allocator, options.rom_path.?); defer allocator.free(rom); + if (options.info) { + var buffer: [512]u8 = undefined; + std.debug.print("{s}", .{cartridge.formatHeader(cartridge.readHeader(rom), &buffer)}); + return 0; + } + var emulator = try Emulator.init(allocator, rom); defer emulator.deinit(); + if (options.frame_dump) |path| { + var frame_index: u64 = 0; + while (frame_index < options.frame_count) : (frame_index += 1) { + emulator.runFrame(1_000_000); + } + const frame = &emulator.bus.ppu.frame; + try writePpm(io, path, frame); + std.debug.print("Frame checksum: {X:0>8}\n", .{frameChecksum(frame)}); + return 0; + } + var runner = Runner{ .emulator = &emulator, .options = options, @@ -100,8 +165,11 @@ fn runRom(io: std.Io, allocator: std.mem.Allocator, options: Options) !u8 { std.debug.print("Serial output:\n{s}\n", .{printable}); } - const wanted = options.expect orelse verdict; - const ok = wanted == verdict; + const ok: bool = if (options.expect) |expected| switch (expected) { + Expect.pass => verdict == serial.Verdict.pass, + Expect.fail => verdict == serial.Verdict.fail, + Expect.any => true, + } else verdict == serial.Verdict.pass; std.debug.print("Verdict: {s}\n", .{@tagName(verdict)}); return if (ok) 0 else 1; } @@ -191,7 +259,7 @@ pub fn main(init: std.process.Init) !void { } else if (std.mem.eql(u8, argv[index], "fail")) { options.expect = .fail; } else if (std.mem.eql(u8, argv[index], "any")) { - options.expect = .pass; + options.expect = .any; } else { std.debug.print("bad --expect value: {s}\n", .{argv[index]}); std.process.exit(2); @@ -203,6 +271,25 @@ pub fn main(init: std.process.Init) !void { std.process.exit(2); } options.blargg_suite = argv[index]; + } else if (std.mem.eql(u8, arg, "--dump-frame")) { + index += 1; + if (index >= argv.len) { + std.debug.print("--dump-frame needs a path\n", .{}); + std.process.exit(2); + } + options.frame_dump = argv[index]; + } else if (std.mem.eql(u8, arg, "--frames")) { + index += 1; + if (index >= argv.len) { + std.debug.print("--frames needs a count\n", .{}); + std.process.exit(2); + } + options.frame_count = std.fmt.parseInt(u64, argv[index], 10) catch { + std.debug.print("bad frame count: {s}\n", .{argv[index]}); + std.process.exit(2); + }; + } else if (std.mem.eql(u8, arg, "--info")) { + options.info = true; } else if (options.rom_path == null) { options.rom_path = arg; } else { diff --git a/src/integration.zig b/src/integration.zig new file mode 100644 index 0000000..b701d8d --- /dev/null +++ b/src/integration.zig @@ -0,0 +1,102 @@ +const std = @import("std"); +const Emulator = @import("emulator.zig").Emulator; +const cartridge = @import("cartridge.zig"); +const gbasm = @import("gbasm"); + +// Deterministic end-to-end checks for the bundled smoke ROM. The ROM is +// assembled from its assembly source at test time and compared to the +// committed image, then run through the full pipeline. Every expected value +// below is fixed, so a change in the CPU, the bus, the PPU, or the assembler +// that alters observable output fails the suite. The fixtures are read from +// the repository root, which is the working directory for `zig build test`. + +const smoke_source_path = "fixtures/asm/smoke.asm"; +const smoke_rom_path = "fixtures/roms/smoke.gb"; + +fn readFixture(allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const io = std.testing.io; + const dir = std.Io.Dir.cwd(); + const file = try dir.openFile(io, path, .{}); + defer file.close(io); + const size = try file.length(io); + const buffer = try allocator.alloc(u8, @intCast(size)); + _ = try file.readPositionalAll(io, buffer, 0); + return buffer; +} + +// FNV-1a over the raw frame bytes. It matches the hash the headless runner +// prints for --dump-frame. +fn frameChecksum(frame: []const u32) u32 { + var hash: u32 = 0x811c9dc5; + for (frame) |pixel| { + const bytes = [4]u8{ + @truncate(pixel), + @truncate(pixel >> 8), + @truncate(pixel >> 16), + @truncate(pixel >> 24), + }; + for (bytes) |byte| { + hash ^= byte; + hash *%= 0x01000193; + } + } + return hash; +} + +test "gbasm rebuilds the committed smoke ROM byte for byte" { + const allocator = std.testing.allocator; + const source = try readFixture(allocator, smoke_source_path); + defer allocator.free(source); + const committed = try readFixture(allocator, smoke_rom_path); + defer allocator.free(committed); + + var assembler = try gbasm.assembleSource(allocator, source); + defer assembler.deinit(); + try std.testing.expect(assembler.failure == null); + try std.testing.expectEqualSlices(u8, committed, assembler.rom[0..]); +} + +test "smoke ROM carries a valid cartridge header" { + const allocator = std.testing.allocator; + const rom = try readFixture(allocator, smoke_rom_path); + defer allocator.free(rom); + + const header = cartridge.readHeader(rom); + try std.testing.expect(header.header_checksum_ok); + try std.testing.expectEqualStrings("SMOKE TEST", std.mem.sliceTo(&header.title, 0)); + try std.testing.expectEqualStrings("ROM only", header.mapper); +} + +test "smoke ROM passes through the full pipeline" { + const allocator = std.testing.allocator; + const rom = try readFixture(allocator, smoke_rom_path); + defer allocator.free(rom); + + var emulator = try Emulator.init(allocator, rom); + defer emulator.deinit(); + emulator.runFrame(1_000_000); + emulator.runFrame(1_000_000); + emulator.runFrame(1_000_000); + try std.testing.expectEqualStrings("PASS\n", emulator.serialOutput()); +} + +test "smoke ROM frame checksum is deterministic" { + const allocator = std.testing.allocator; + const rom = try readFixture(allocator, smoke_rom_path); + defer allocator.free(rom); + + const runFrames = struct { + fn call(alloc: std.mem.Allocator, image: []const u8) !u32 { + var emulator = try Emulator.init(alloc, image); + defer emulator.deinit(); + var frame_index: usize = 0; + while (frame_index < 3) : (frame_index += 1) emulator.runFrame(1_000_000); + return frameChecksum(&emulator.bus.ppu.frame); + } + }.call; + + const first = try runFrames(allocator, rom); + const second = try runFrames(allocator, rom); + try std.testing.expectEqual(first, second); + try std.testing.expectEqual(@as(u32, 0x5CCE8DC5), first); +} diff --git a/src/main.zig b/src/main.zig index 643116a..538bb48 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,5 +1,210 @@ const std = @import("std"); -pub fn main() !void { - std.debug.print("Dot Matrix Deck: windowed frontend is under construction.\n", .{}); +const c = @cImport({ + @cDefine("SDL_MAIN_HANDLED", "1"); + @cInclude("SDL2/SDL.h"); +}); + +const Emulator = @import("emulator.zig").Emulator; +const ppu = @import("ppu.zig"); + +const frame_cycles: u64 = 70_224; +const target_fps: f64 = 59.7275; + +const joypad_bits = struct { + const right: u8 = 1 << 0; + const left: u8 = 1 << 1; + const up: u8 = 1 << 2; + const down: u8 = 1 << 3; + const a: u8 = 1 << 4; + const b: u8 = 1 << 5; + const select: u8 = 1 << 6; + const start: u8 = 1 << 7; +}; + +fn usage(program: []const u8) void { + std.debug.print( + \\Dot Matrix Deck - windowed frontend + \\Usage: + \\ {s} + \\ + \\Controls: + \\ Z B button + \\ X A button + \\ Enter Start + \\ Backspace Select + \\ Arrows D-pad + \\ Esc Quit + \\ + , .{program}); +} + +fn readFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const dir = std.Io.Dir.cwd(); + const file = try dir.openFile(io, path, .{}); + defer file.close(io); + const size = try file.length(io); + const buffer = try allocator.alloc(u8, @intCast(size)); + _ = try file.readPositionalAll(io, buffer, 0); + return buffer; +} + +fn scancodeToButtons(scancode: c.SDL_Scancode, buttons: *u8, pressed: bool) bool { + const bit: ?u8 = switch (scancode) { + c.SDL_SCANCODE_Z => joypad_bits.b, + c.SDL_SCANCODE_X => joypad_bits.a, + c.SDL_SCANCODE_RETURN => joypad_bits.start, + c.SDL_SCANCODE_BACKSPACE => joypad_bits.select, + c.SDL_SCANCODE_RIGHT => joypad_bits.right, + c.SDL_SCANCODE_LEFT => joypad_bits.left, + c.SDL_SCANCODE_UP => joypad_bits.up, + c.SDL_SCANCODE_DOWN => joypad_bits.down, + else => null, + }; + if (bit) |mask| { + if (pressed) { + buttons.* &= ~mask; + } else { + buttons.* |= mask; + } + return true; + } + return false; +} + +const Frontend = struct { + emulator: *Emulator, + window: ?*c.SDL_Window, + renderer: ?*c.SDL_Renderer, + texture: ?*c.SDL_Texture, + + fn fail(message: []const u8) noreturn { + std.debug.print("{s}: {s}\n", .{ message, c.SDL_GetError() }); + std.process.exit(1); + } + + fn init(emulator: *Emulator, scale: u32) Frontend { + _ = c.SDL_SetMainReady(); + if (c.SDL_Init(c.SDL_INIT_VIDEO | c.SDL_INIT_EVENTS) != 0) fail("SDL_Init failed"); + _ = c.SDL_SetHint(c.SDL_HINT_RENDER_SCALE_QUALITY, "nearest"); + + const width: c_int = @intCast(@as(u32, @intCast(ppu.ScreenWidth)) * scale); + const height: c_int = @intCast(@as(u32, @intCast(ppu.ScreenHeight)) * scale); + + const window = c.SDL_CreateWindow( + "Dot Matrix Deck", + c.SDL_WINDOWPOS_UNDEFINED, + c.SDL_WINDOWPOS_UNDEFINED, + width, + height, + c.SDL_WINDOW_SHOWN, + ) orelse fail("SDL_CreateWindow failed"); + + const renderer = c.SDL_CreateRenderer( + window, + -1, + c.SDL_RENDERER_ACCELERATED | c.SDL_RENDERER_PRESENTVSYNC, + ) orelse c.SDL_CreateRenderer(window, -1, 0) orelse fail("SDL_CreateRenderer failed"); + + const texture = c.SDL_CreateTexture( + renderer, + c.SDL_PIXELFORMAT_ARGB8888, + c.SDL_TEXTUREACCESS_STREAMING, + @intCast(ppu.ScreenWidth), + @intCast(ppu.ScreenHeight), + ) orelse fail("SDL_CreateTexture failed"); + + return .{ .emulator = emulator, .window = window, .renderer = renderer, .texture = texture }; + } + + fn deinit(self: *Frontend) void { + if (self.texture) |texture| c.SDL_DestroyTexture(texture); + if (self.renderer) |renderer| c.SDL_DestroyRenderer(renderer); + if (self.window) |window| c.SDL_DestroyWindow(window); + c.SDL_Quit(); + } + + fn handleEvents(self: *Frontend) bool { + var event: c.SDL_Event = undefined; + while (c.SDL_PollEvent(&event) != 0) { + switch (event.type) { + c.SDL_QUIT => return true, + c.SDL_KEYDOWN => { + const handled = scancodeToButtons(event.key.keysym.scancode, &self.emulator.bus.joypad, true); + if (!handled and event.key.keysym.scancode == c.SDL_SCANCODE_ESCAPE) return true; + }, + c.SDL_KEYUP => _ = scancodeToButtons(event.key.keysym.scancode, &self.emulator.bus.joypad, false), + else => {}, + } + } + return false; + } + + fn presentFrame(self: *Frontend) void { + var pixels: ?*anyopaque = null; + var pitch: c_int = 0; + if (c.SDL_LockTexture(self.texture, null, &pixels, &pitch) == 0) { + const dst = @as([*]u8, @ptrCast(pixels.?)); + const src = std.mem.sliceAsBytes(self.emulator.bus.ppu.frame[0..]); + @memcpy(dst[0..src.len], src); + c.SDL_UnlockTexture(self.texture); + } + _ = c.SDL_RenderClear(self.renderer); + _ = c.SDL_RenderCopy(self.renderer, self.texture, null, null); + c.SDL_RenderPresent(self.renderer); + } + + fn run(self: *Frontend, io: std.Io) !void { + var last_frame = std.Io.Clock.Timestamp.now(io, .awake); + const frame_ns: i128 = @intFromFloat(@as(f64, std.time.ns_per_s) / target_fps); + + var running = true; + while (running) { + running = !self.handleEvents(); + if (!running) break; + + self.emulator.runFrame(frame_cycles * 2); + self.presentFrame(); + + const now = std.Io.Clock.Timestamp.now(io, .awake); + const elapsed = last_frame.durationTo(now).raw.nanoseconds; + const slack = frame_ns - elapsed; + if (slack > 0) { + const millis: u32 = @intCast(@divTrunc(slack, std.time.ns_per_ms)); + c.SDL_Delay(millis); + } + last_frame = now; + } + } +}; + +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; + + var it = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator); + defer it.deinit(); + + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(allocator); + while (it.next()) |arg| { + try args.append(allocator, arg); + } + const argv = args.items; + + if (argv.len < 2) { + usage(argv[0]); + std.process.exit(2); + } + + const rom = try readFile(io, allocator, argv[1]); + defer allocator.free(rom); + + var emulator = try Emulator.init(allocator, rom); + defer emulator.deinit(); + + var frontend = Frontend.init(&emulator, 3); + defer frontend.deinit(); + + try frontend.run(io); } diff --git a/src/ppu.zig b/src/ppu.zig index c22a198..00864af 100644 --- a/src/ppu.zig +++ b/src/ppu.zig @@ -174,3 +174,25 @@ pub const Ppu = struct { } } }; + +const std = @import("std"); +const testing = std.testing; + +test "PPU raises VBlank once per frame at 70224 cycles" { + var ppu = Ppu{}; + var iflag: u8 = 0; + var line: usize = 0; + while (line < 143) : (line += 1) ppu.tick(456, &iflag); + try testing.expectEqual(false, ppu.takeFrame()); + ppu.tick(456, &iflag); + try testing.expectEqual(true, ppu.takeFrame()); + try testing.expectEqual(@as(u8, 0x01), iflag & 0x01); +} + +test "PPU line counter wraps to 0 after line 153" { + var ppu = Ppu{}; + var iflag: u8 = 0; + var line: usize = 0; + while (line < 154) : (line += 1) ppu.tick(456, &iflag); + try testing.expectEqual(@as(u8, 0), ppu.ly); +} diff --git a/tools/gbasm.zig b/tools/gbasm.zig index e81a221..13b92c1 100644 --- a/tools/gbasm.zig +++ b/tools/gbasm.zig @@ -1,5 +1,1312 @@ +// gbasm - a two-pass SM83 assembler for the Dot Matrix Deck test ROMs. +// +// Usage: +// gbasm +// +// Each file ending in .asm in is assembled into a 32 KiB ROM +// named /.gb. The emulator starts executing at +// $0100, so every source must begin with `org $0100`. +// +// The assembler supports labels, EQU, DB, DW, DS, and the full SM83 +// instruction set. Expressions accept $hex, 0xhex, decimal, %binary, +// labels, and `+` / `-` with parentheses. + const std = @import("std"); -pub fn main() !void { - std.debug.print("gbasm: assembler is under construction.\n", .{}); +const rom_size = 0x8000; +const entry_point = 0x0100; + +const OperandKind = enum { + reg8, + reg16, + af, + hl, // bare HL (ADD HL,r16, LD SP,HL, JP (HL)) + sp, + ind_bc, + ind_de, + ind_hl, + ind_c, + ind_a16, + hl_inc, + hl_dec, + imm8, + imm16, + cond, + rst, + sp_plus, // SP+e form for ADD SP,e / LD HL,SP+e +}; + +const Operand = struct { + kind: OperandKind, + text: []const u8, + inner: []const u8 = "", // body of an indirect operand, e.g. "$ff44" + + fn isNone(op: Operand) bool { + return op.text.len == 0; + } + + fn match(op: Operand, comptime name: []const u8) bool { + return std.ascii.eqlIgnoreCase(op.text, name); + } +}; + +const Statement = struct { + line_no: usize, + label: ?[]const u8 = null, + op: []const u8 = "", + owns_op: bool = false, + operands: [3]Operand = .{ .{ .kind = .imm8, .text = "" }, .{ .kind = .imm8, .text = "" }, .{ .kind = .imm8, .text = "" } }, + operand_count: usize = 0, + + fn operand(self: *const Statement, index: usize) Operand { + if (index < self.operand_count) return self.operands[index]; + return .{ .kind = .imm8, .text = "" }; + } +}; + +const LabelMap = std.StringHashMap(u32); + +const ExprError = error{ InvalidExpression, UnknownLabel }; + +const Assembler = struct { + allocator: std.mem.Allocator, + rom: [rom_size]u8 = [_]u8{0} ** rom_size, + labels: LabelMap, + cursor: u32 = 0, + failure: ?Failure = null, + + const Failure = struct { + message: []const u8, + line_no: usize, + }; + + fn init(allocator: std.mem.Allocator) Assembler { + return .{ .allocator = allocator, .labels = LabelMap.init(allocator) }; + } + + pub fn deinit(self: *Assembler) void { + var it = self.labels.keyIterator(); + while (it.next()) |key| self.allocator.free(key.*); + self.labels.deinit(); + } + + fn fail(self: *Assembler, message: []const u8, line_no: usize) void { + if (self.failure == null) { + self.failure = .{ .message = message, .line_no = line_no }; + } + } + + fn registerLabel(self: *Assembler, name: []const u8, value: u32, line_no: usize) void { + const stored = self.allocator.dupe(u8, name) catch return; + if (self.labels.fetchPut(stored, value) catch return) |_| { + self.fail("duplicate label", line_no); + self.allocator.free(stored); + } + } +}; + +fn isIdentChar(ch: u8) bool { + return std.ascii.isAlphanumeric(ch) or ch == '_' or ch == '.' or ch == '?' or ch == '@'; +} + +fn splitComment(line: []const u8) []const u8 { + const index = std.mem.indexOfScalar(u8, line, ';') orelse return line; + return line[0..index]; +} + +fn trimLine(line: []const u8) []const u8 { + return std.mem.trim(u8, line, " \t\r"); +} + +fn parseNumber(token: []const u8) ?u32 { + if (token.len == 0) return null; + if (token[0] == '$') return std.fmt.parseInt(u32, token[1..], 16) catch null; + if (std.mem.startsWith(u8, token, "0x")) return std.fmt.parseInt(u32, token[2..], 16) catch null; + if (token[0] == '%') return std.fmt.parseInt(u32, token[1..], 2) catch null; + if (std.mem.startsWith(u8, token, "0b")) return std.fmt.parseInt(u32, token[2..], 2) catch null; + return std.fmt.parseInt(u32, token, 10) catch null; +} + +fn parseTerm(text: []const u8, pos: *usize, labels: *const LabelMap) ExprError!i32 { + const token = std.mem.trimStart(u8, text[pos.*..], " \t"); + pos.* += text.len - pos.* - token.len; + const rest = text[pos.*..]; + if (rest.len == 0) return error.InvalidExpression; + + if (rest[0] == '+' or rest[0] == '-') { + const sign: i32 = if (rest[0] == '-') -1 else 1; + pos.* += 1; + return sign * try parseTerm(text, pos, labels); + } + + if (rest[0] == '(') { + pos.* += 1; + const value = try parseSum(text, pos, labels); + const after = std.mem.trimStart(u8, text[pos.*..], " \t"); + if (after.len == 0 or after[0] != ')') return error.InvalidExpression; + pos.* += after.len - (after.len - 1); + return value; + } + + if (rest[0] == '$' or rest[0] == '%' or std.ascii.isDigit(rest[0])) { + var end = pos.*; + while (end < text.len and (isIdentChar(text[end]) or text[end] == '$')) : (end += 1) {} + const token_text = text[pos.*..end]; + pos.* = end; + const value = parseNumber(token_text) orelse return error.InvalidExpression; + return @intCast(value); + } + + var end = pos.*; + while (end < text.len and isIdentChar(text[end])) : (end += 1) {} + if (end == pos.*) return error.InvalidExpression; + const token_text = text[pos.*..end]; + pos.* = end; + + const value: u32 = labels.get(token_text) orelse return error.UnknownLabel; + return @intCast(value); +} + +fn parseSum(text: []const u8, pos: *usize, labels: *const LabelMap) ExprError!i32 { + var value = try parseTerm(text, pos, labels); + while (pos.* < text.len) { + const rest = text[pos.*..]; + if (rest[0] == '+') { + pos.* += 1; + value += try parseTerm(text, pos, labels); + } else if (rest[0] == '-') { + pos.* += 1; + value -= try parseTerm(text, pos, labels); + } else { + break; + } + } + return value; +} + +fn evalExpression(text: []const u8, labels: *const LabelMap) !i32 { + var pos: usize = 0; + const value = try parseSum(text, &pos, labels); + const rest = std.mem.trimStart(u8, text[pos..], " \t"); + if (rest.len != 0) return error.InvalidExpression; + return value; +} + +fn operandValue(op: Operand, labels: *const LabelMap, assembler: *Assembler, line_no: usize) u32 { + const expr = if (op.inner.len != 0) op.inner else op.text; + const value = evalExpression(expr, labels) catch { + assembler.fail("invalid operand expression", line_no); + return 0; + }; + if (value < 0 or value > 0xffff) { + assembler.fail("operand value out of range", line_no); + return 0; + } + return @intCast(value); +} + +fn signedByteValue(text: []const u8, labels: *const LabelMap, assembler: *Assembler, line_no: usize) u8 { + const value = evalExpression(text, labels) catch { + assembler.fail("invalid signed offset", line_no); + return 0; + }; + if (value < -128 or value > 127) { + assembler.fail("signed offset out of range", line_no); + return 0; + } + return @bitCast(@as(i8, @intCast(value))); +} + +fn spOffset(op: Operand, labels: *const LabelMap, assembler: *Assembler, line_no: usize) u8 { + if (op.text.len < 3) { + assembler.fail("SP offset must be written as SP+e", line_no); + return 0; + } + const value = evalExpression(op.text[2..], labels) catch { + assembler.fail("invalid SP offset", line_no); + return 0; + }; + if (value < -128 or value > 127) { + assembler.fail("SP offset out of range", line_no); + return 0; + } + return @bitCast(@as(i8, @intCast(value))); +} + +fn splitOperands(statement: []const u8, out: *[3][]const u8) usize { + var count: usize = 0; + var start: usize = 0; + var index: usize = 0; + while (index <= statement.len) : (index += 1) { + const at_end = index == statement.len; + const ch = if (at_end) ',' else statement[index]; + if (ch == ',' or at_end) { + if (count < out.len) { + const part = std.mem.trim(u8, statement[start..index], " \t"); + if (part.len != 0) { + out[count] = part; + count += 1; + } + } + start = index + 1; + } + } + return count; +} + +fn classifyOperand(text: []const u8) Operand { + const lowered = text; + if (text.len == 0) return .{ .kind = .imm8, .text = text }; + + if (std.mem.startsWith(u8, lowered, "sp+") or std.mem.startsWith(u8, lowered, "sp-")) { + return .{ .kind = .sp_plus, .text = text }; + } + + if (text[0] == '(') { + const inner_close = std.mem.indexOfScalar(u8, text, ')') orelse return .{ .kind = .imm8, .text = text }; + const body = std.mem.trim(u8, text[1..inner_close], " \t"); + if (body.len == 0) return .{ .kind = .imm8, .text = text }; + if (body.len == 1 and (body[0] == 'c' or body[0] == 'C')) return .{ .kind = .ind_c, .text = text }; + if (std.ascii.eqlIgnoreCase(body, "bc")) return .{ .kind = .ind_bc, .text = text }; + if (std.ascii.eqlIgnoreCase(body, "de")) return .{ .kind = .ind_de, .text = text }; + if (std.ascii.eqlIgnoreCase(body, "hl")) return .{ .kind = .ind_hl, .text = text }; + if (std.ascii.eqlIgnoreCase(body, "hl+") or std.ascii.eqlIgnoreCase(body, "hli")) return .{ .kind = .hl_inc, .text = text }; + if (std.ascii.eqlIgnoreCase(body, "hl-") or std.ascii.eqlIgnoreCase(body, "hld")) return .{ .kind = .hl_dec, .text = text }; + return .{ .kind = .ind_a16, .text = text, .inner = body }; + } + + if (std.ascii.eqlIgnoreCase(lowered, "a")) return .{ .kind = .reg8, .text = text }; + if (std.ascii.eqlIgnoreCase(lowered, "b")) return .{ .kind = .reg8, .text = text }; + if (std.ascii.eqlIgnoreCase(lowered, "c")) return .{ .kind = .reg8, .text = text }; + if (std.ascii.eqlIgnoreCase(lowered, "d")) return .{ .kind = .reg8, .text = text }; + if (std.ascii.eqlIgnoreCase(lowered, "e")) return .{ .kind = .reg8, .text = text }; + if (std.ascii.eqlIgnoreCase(lowered, "h")) return .{ .kind = .reg8, .text = text }; + if (std.ascii.eqlIgnoreCase(lowered, "l")) return .{ .kind = .reg8, .text = text }; + if (std.ascii.eqlIgnoreCase(lowered, "bc")) return .{ .kind = .reg16, .text = text }; + if (std.ascii.eqlIgnoreCase(lowered, "de")) return .{ .kind = .reg16, .text = text }; + if (std.ascii.eqlIgnoreCase(lowered, "hl")) return .{ .kind = .hl, .text = text }; + if (std.ascii.eqlIgnoreCase(lowered, "sp")) return .{ .kind = .sp, .text = text }; + if (std.ascii.eqlIgnoreCase(lowered, "af")) return .{ .kind = .af, .text = text }; + if (std.ascii.eqlIgnoreCase(lowered, "nz")) return .{ .kind = .cond, .text = text }; + if (std.ascii.eqlIgnoreCase(lowered, "z")) return .{ .kind = .cond, .text = text }; + if (std.ascii.eqlIgnoreCase(lowered, "nc")) return .{ .kind = .cond, .text = text }; + return .{ .kind = .imm8, .text = text }; +} + +fn parseStatement(line: []const u8, line_no: usize, allocator: std.mem.Allocator) !Statement { + var statement = Statement{ .line_no = line_no }; + var text = trimLine(splitComment(line)); + + if (text.len == 0) return statement; + + const colon = std.mem.indexOfScalar(u8, text, ':'); + if (colon) |index| { + const before = std.mem.trim(u8, text[0..index], " \t"); + if (before.len != 0 and (isIdentChar(before[0]) or before[0] == '.')) { + statement.label = try allocator.dupe(u8, before); + text = trimLine(text[index + 1 ..]); + } + } + + if (text.len == 0) return statement; + + var end: usize = 0; + while (end < text.len and !std.ascii.isWhitespace(text[end])) : (end += 1) {} + statement.op = try allocator.dupe(u8, text[0..end]); + statement.owns_op = true; + const rest = trimLine(text[end..]); + + if (rest.len != 0) { + if (isEquForm(statement.op, rest)) { + const after_equ = std.mem.trimStart(u8, rest["equ".len..], " \t"); + statement.label = statement.op; + statement.op = try allocator.dupe(u8, "equ"); + const duped = try allocator.dupe(u8, after_equ); + statement.operands[0] = classifyOperand(duped); + statement.operand_count = 1; + return statement; + } + + var parts: [3][]const u8 = undefined; + statement.operand_count = splitOperands(rest, &parts); + var operand_index: usize = 0; + while (operand_index < statement.operand_count) : (operand_index += 1) { + const duped = try allocator.dupe(u8, parts[operand_index]); + statement.operands[operand_index] = classifyOperand(duped); + } + } + return statement; +} + +fn isIdentifier(text: []const u8) bool { + if (text.len == 0) return false; + return isIdentChar(text[0]) and !std.ascii.isDigit(text[0]); +} + +fn isEquForm(op: []const u8, rest: []const u8) bool { + if (op.len == 0 or !isIdentifier(op)) return false; + if (rest.len < "equ".len) return false; + const prefix = rest[0.."equ".len]; + if (!std.ascii.eqlIgnoreCase(prefix, "equ")) return false; + if (rest.len > "equ".len and !std.ascii.isWhitespace(rest["equ".len])) return false; + return true; +} + +fn r8code(op: Operand) ?u8 { + return switch (op.kind) { + .reg8 => switch (op.text[0]) { + 'b', 'B' => 0, + 'c', 'C' => 1, + 'd', 'D' => 2, + 'e', 'E' => 3, + 'h', 'H' => 4, + 'l', 'L' => 5, + 'a', 'A' => 7, + else => null, + }, + .ind_hl => 6, + else => null, + }; +} + +fn r16code(op: Operand) ?u8 { + return switch (op.kind) { + .reg16 => switch (op.text[0]) { + 'b', 'B' => 0, + 'd', 'D' => 1, + 'h', 'H' => 2, + 's', 'S' => 3, + else => null, + }, + .hl => 2, + .sp => 3, + else => null, + }; +} + +fn isReg16Dest(op: Operand) bool { + return switch (op.kind) { + .reg16, .hl, .sp => true, + else => false, + }; +} + +fn condCode(op: Operand) ?u8 { + if (op.kind != .cond) return null; + if (op.match("nz")) return 0; + if (op.match("z")) return 1; + if (op.match("nc")) return 2; + if (op.match("c")) return 3; + return null; +} + +fn emit8(assembler: *Assembler, value: u8, line_no: usize) void { + if (assembler.failure != null) return; + if (assembler.cursor >= rom_size) { + assembler.fail("ROM overflow", line_no); + return; + } + assembler.rom[assembler.cursor] = value; + assembler.cursor += 1; +} + +fn emit16(assembler: *Assembler, value: u16, line_no: usize) void { + emit8(assembler, @truncate(value), line_no); + emit8(assembler, @truncate(value >> 8), line_no); +} + +fn emitCb(assembler: *Assembler, opcode: u8, r: u8, line_no: usize) void { + emit8(assembler, 0xcb, line_no); + emit8(assembler, opcode | r, line_no); +} + +fn rstValue(op: Operand, assembler: *Assembler, line_no: usize) u8 { + const value = operandValue(op, &assembler.labels, assembler, line_no); + return switch (value) { + 0x00, 0x08, 0x10, 0x18, 0x20, 0x28, 0x30, 0x38 => @intCast(value), + else => { + assembler.fail("RST requires a value of $00, $08, ... $38", line_no); + return 0; + }, + }; +} + +fn assembleInstruction(assembler: *Assembler, statement: *const Statement) void { + const op = statement.op; + const line_no = statement.line_no; + const labels = &assembler.labels; + + if (assembler.failure != null) return; + + if (std.mem.eql(u8, op, "nop")) { + emit8(assembler, 0x00, line_no); + } else if (std.mem.eql(u8, op, "halt")) { + emit8(assembler, 0x76, line_no); + } else if (std.mem.eql(u8, op, "stop")) { + emit8(assembler, 0x10, line_no); + emit8(assembler, 0x00, line_no); + } else if (std.mem.eql(u8, op, "di")) { + emit8(assembler, 0xf3, line_no); + } else if (std.mem.eql(u8, op, "ei")) { + emit8(assembler, 0xfb, line_no); + } else if (std.mem.eql(u8, op, "daa")) { + emit8(assembler, 0x27, line_no); + } else if (std.mem.eql(u8, op, "cpl")) { + emit8(assembler, 0x2f, line_no); + } else if (std.mem.eql(u8, op, "scf")) { + emit8(assembler, 0x37, line_no); + } else if (std.mem.eql(u8, op, "ccf")) { + emit8(assembler, 0x3f, line_no); + } else if (std.mem.eql(u8, op, "rlca")) { + emit8(assembler, 0x07, line_no); + } else if (std.mem.eql(u8, op, "rrca")) { + emit8(assembler, 0x0f, line_no); + } else if (std.mem.eql(u8, op, "rla")) { + emit8(assembler, 0x17, line_no); + } else if (std.mem.eql(u8, op, "rra")) { + emit8(assembler, 0x1f, line_no); + } else if (std.mem.eql(u8, op, "ld")) { + assembleLd(assembler, statement); + } else if (std.mem.eql(u8, op, "ldh")) { + assembleLdh(assembler, statement); + } else if (std.mem.eql(u8, op, "ldi")) { + assembleLdi(assembler, statement); + } else if (std.mem.eql(u8, op, "ldd")) { + assembleLdd(assembler, statement); + } else if (std.mem.eql(u8, op, "inc")) { + const a = statement.operand(0); + if (r8code(a)) |r| { + emit8(assembler, if (r == 6) 0x34 else 0x04 | (r << 3), line_no); + } else if (r16code(a)) |r| { + emit8(assembler, 0x03 | (r << 4), line_no); + } else { + assembler.fail("INC requires a register", line_no); + } + } else if (std.mem.eql(u8, op, "dec")) { + const a = statement.operand(0); + if (r8code(a)) |r| { + emit8(assembler, if (r == 6) 0x35 else 0x05 | (r << 3), line_no); + } else if (r16code(a)) |r| { + emit8(assembler, 0x0b | (r << 4), line_no); + } else { + assembler.fail("DEC requires a register", line_no); + } + } else if (std.mem.eql(u8, op, "add")) { + assembleAdd(assembler, statement); + } else if (std.mem.eql(u8, op, "adc") or std.mem.eql(u8, op, "sub") or std.mem.eql(u8, op, "sbc") or std.mem.eql(u8, op, "and") or std.mem.eql(u8, op, "xor") or std.mem.eql(u8, op, "or") or std.mem.eql(u8, op, "cp")) { + const alu_base: u8 = if (std.mem.eql(u8, op, "adc")) 0x88 else if (std.mem.eql(u8, op, "sub")) 0x90 else if (std.mem.eql(u8, op, "sbc")) 0x98 else if (std.mem.eql(u8, op, "and")) 0xa0 else if (std.mem.eql(u8, op, "xor")) 0xa8 else if (std.mem.eql(u8, op, "or")) 0xb0 else 0xb8; + const a = statement.operand(0); + const b = statement.operand(1); + const target = if (a.kind == .reg8 and a.match("a") and !b.isNone()) b else a; + if (r8code(target)) |r| { + emit8(assembler, alu_base | r, line_no); + } else if (target.kind == .imm8) { + emit8(assembler, alu_base + 0x46, line_no); + emit8(assembler, @truncate(operandValue(target, labels, assembler, line_no)), line_no); + } else { + assembler.fail("invalid ALU operand", line_no); + } + } else if (std.mem.eql(u8, op, "jr")) { + assembleJr(assembler, statement); + } else if (std.mem.eql(u8, op, "jp")) { + assembleJp(assembler, statement); + } else if (std.mem.eql(u8, op, "call")) { + assembleCall(assembler, statement); + } else if (std.mem.eql(u8, op, "ret")) { + const a = statement.operand(0); + if (a.isNone()) { + emit8(assembler, 0xc9, line_no); + } else if (condCode(a)) |cc| { + emit8(assembler, 0xc0 | (cc << 3), line_no); + } else { + assembler.fail("invalid RET operand", line_no); + } + } else if (std.mem.eql(u8, op, "reti")) { + emit8(assembler, 0xd9, line_no); + } else if (std.mem.eql(u8, op, "push")) { + const a = statement.operand(0); + if (a.kind == .af) { + emit8(assembler, 0xf5, line_no); + } else if (r16code(a)) |r| { + emit8(assembler, 0xc5 | (r << 4), line_no); + } else { + assembler.fail("PUSH requires BC, DE, HL, or AF", line_no); + } + } else if (std.mem.eql(u8, op, "pop")) { + const a = statement.operand(0); + if (a.kind == .af) { + emit8(assembler, 0xf1, line_no); + } else if (r16code(a)) |r| { + emit8(assembler, 0xc1 | (r << 4), line_no); + } else { + assembler.fail("POP requires BC, DE, HL, or AF", line_no); + } + } else if (std.mem.eql(u8, op, "rst")) { + const value = rstValue(statement.operand(0), assembler, line_no); + emit8(assembler, 0xc7 | value, line_no); + } else if (std.mem.eql(u8, op, "rlc") or std.mem.eql(u8, op, "rrc") or std.mem.eql(u8, op, "rl") or std.mem.eql(u8, op, "rr") or std.mem.eql(u8, op, "sla") or std.mem.eql(u8, op, "sra") or std.mem.eql(u8, op, "swap") or std.mem.eql(u8, op, "srl")) { + const base: u8 = if (std.mem.eql(u8, op, "rrc")) 0x08 else if (std.mem.eql(u8, op, "rl")) 0x10 else if (std.mem.eql(u8, op, "rr")) 0x18 else if (std.mem.eql(u8, op, "sla")) 0x20 else if (std.mem.eql(u8, op, "sra")) 0x28 else if (std.mem.eql(u8, op, "swap")) 0x30 else if (std.mem.eql(u8, op, "srl")) 0x38 else 0x00; + if (r8code(statement.operand(0))) |r| { + emitCb(assembler, base, r, line_no); + } else { + assembler.fail("invalid shift/rotate operand", line_no); + } + } else if (std.mem.eql(u8, op, "bit") or std.mem.eql(u8, op, "res") or std.mem.eql(u8, op, "set")) { + const cb_base: u8 = if (std.mem.eql(u8, op, "res")) 0x80 else if (std.mem.eql(u8, op, "set")) 0xc0 else 0x40; + const bit_op = statement.operand(0); + const reg_op = statement.operand(1); + if (r8code(reg_op)) |r| { + const bit_value = operandValue(bit_op, labels, assembler, line_no); + if (bit_value > 7) { + assembler.fail("bit number must be 0-7", line_no); + return; + } + emitCb(assembler, cb_base | (@as(u8, @intCast(bit_value)) << 3), r, line_no); + } else { + assembler.fail("invalid bit operand", line_no); + } + } else { + assembler.fail("unknown mnemonic", line_no); + } +} + +fn assembleLd(assembler: *Assembler, statement: *const Statement) void { + const a = statement.operand(0); + const b = statement.operand(1); + const line_no = statement.line_no; + const labels = &assembler.labels; + + if (a.isNone() or b.isNone()) { + assembler.fail("LD requires two operands", line_no); + return; + } + + const dst_r8 = r8code(a); + const src_r8 = r8code(b); + + if (dst_r8 != null and src_r8 != null) { + if (dst_r8.? == 6 and src_r8.? == 6) { + assembler.fail("LD (HL),(HL) is not a valid instruction", line_no); + return; + } + emit8(assembler, 0x40 | (dst_r8.? << 3) | src_r8.?, line_no); + return; + } + + if (dst_r8) |r| { + if (b.kind == .imm8) { + emit8(assembler, if (r == 6) 0x36 else 0x06 | (r << 3), line_no); + emit8(assembler, @truncate(operandValue(b, labels, assembler, line_no)), line_no); + return; + } + if (r == 7 and b.kind == .ind_a16) { + emit8(assembler, 0xfa, line_no); + emit16(assembler, @truncate(operandValue(b, labels, assembler, line_no)), line_no); + return; + } + } + + if (src_r8) |r| { + if (r == 7 and a.kind == .ind_a16) { + emit8(assembler, 0xea, line_no); + emit16(assembler, @truncate(operandValue(a, labels, assembler, line_no)), line_no); + return; + } + } + + switch (a.kind) { + .ind_bc => { + emit8(assembler, 0x02, line_no); + return; + }, + .ind_de => { + emit8(assembler, 0x12, line_no); + return; + }, + .hl_inc => { + emit8(assembler, 0x22, line_no); + return; + }, + .hl_dec => { + emit8(assembler, 0x32, line_no); + return; + }, + .ind_c => { + emit8(assembler, 0xe2, line_no); + return; + }, + else => {}, + } + + switch (b.kind) { + .ind_bc => { + emit8(assembler, 0x0a, line_no); + return; + }, + .ind_de => { + emit8(assembler, 0x1a, line_no); + return; + }, + .hl_inc => { + emit8(assembler, 0x2a, line_no); + return; + }, + .hl_dec => { + emit8(assembler, 0x3a, line_no); + return; + }, + .ind_c => { + emit8(assembler, 0xf2, line_no); + return; + }, + else => {}, + } + + if (a.kind == .hl and b.kind == .sp_plus) { + emit8(assembler, 0xf8, line_no); // LD HL,SP+e + emit8(assembler, spOffset(b, labels, assembler, line_no), line_no); + return; + } + + if (a.kind == .sp and b.kind == .hl) { + emit8(assembler, 0xf9, line_no); // LD SP,HL + return; + } + + if (isReg16Dest(a) and b.kind == .imm8) { + const r16 = r16code(a) orelse unreachable; + emit8(assembler, 0x01 | (r16 << 4), line_no); + emit16(assembler, @truncate(operandValue(b, labels, assembler, line_no)), line_no); + return; + } + + if (a.kind == .ind_a16) { + emit8(assembler, 0x08, line_no); // LD (a16),SP + emit16(assembler, @truncate(operandValue(a, labels, assembler, line_no)), line_no); + return; + } + + if (dst_r8) |r| { + _ = r; + } + + if (b.kind == .ind_a16) { + emit8(assembler, 0xfa, line_no); // LD A,(a16) + emit16(assembler, @truncate(operandValue(b, labels, assembler, line_no)), line_no); + return; + } + + assembler.fail("invalid LD form", line_no); +} + +fn assembleLdh(assembler: *Assembler, statement: *const Statement) void { + const a = statement.operand(0); + const b = statement.operand(1); + const line_no = statement.line_no; + const labels = &assembler.labels; + + const a_reg = r8code(a); + const b_reg = r8code(b); + + if (b_reg != null and b_reg.? == 7 and (a.kind == .imm8 or a.kind == .ind_a16)) { + emit8(assembler, 0xe0, line_no); // LDH (a8),A + emit8(assembler, @truncate(operandValue(a, labels, assembler, line_no)), line_no); + return; + } + if (a_reg != null and a_reg.? == 7 and (b.kind == .imm8 or b.kind == .ind_a16)) { + emit8(assembler, 0xf0, line_no); // LDH A,(a8) + emit8(assembler, @truncate(operandValue(b, labels, assembler, line_no)), line_no); + return; + } + assembler.fail("invalid LDH form", line_no); +} + +fn assembleLdi(assembler: *Assembler, statement: *const Statement) void { + const a = statement.operand(0); + const b = statement.operand(1); + const line_no = statement.line_no; + if (a.kind == .ind_hl and b.match("a")) { + emit8(assembler, 0x22, line_no); // LDI (HL),A + return; + } + if (a.match("a") and b.kind == .ind_hl) { + emit8(assembler, 0x2a, line_no); // LDI A,(HL) + return; + } + assembler.fail("invalid LDI form", line_no); +} + +fn assembleLdd(assembler: *Assembler, statement: *const Statement) void { + const a = statement.operand(0); + const b = statement.operand(1); + const line_no = statement.line_no; + if (a.kind == .ind_hl and b.match("a")) { + emit8(assembler, 0x32, line_no); // LDD (HL),A + return; + } + if (a.match("a") and b.kind == .ind_hl) { + emit8(assembler, 0x3a, line_no); // LDD A,(HL) + return; + } + assembler.fail("invalid LDD form", line_no); +} + +fn assembleAdd(assembler: *Assembler, statement: *const Statement) void { + const a = statement.operand(0); + const b = statement.operand(1); + const line_no = statement.line_no; + const labels = &assembler.labels; + + if (a.kind == .hl) { + if (r16code(b)) |r| { + emit8(assembler, 0x09 | (r << 4), line_no); // ADD HL,r16 + return; + } + assembler.fail("ADD HL requires a 16-bit register", line_no); + return; + } + if (a.kind == .sp) { + if (b.kind == .sp_plus or b.kind == .imm8) { + emit8(assembler, 0xe8, line_no); // ADD SP,e + const offset: u8 = if (b.kind == .sp_plus) spOffset(b, labels, assembler, line_no) else signedByteValue(b.text, labels, assembler, line_no); + emit8(assembler, offset, line_no); + return; + } + assembler.fail("ADD SP requires an immediate offset", line_no); + return; + } + const target = if (a.kind == .reg8 and a.match("a") and !b.isNone()) b else a; + if (r8code(target)) |r| { + emit8(assembler, 0x80 | r, line_no); + return; + } + if (target.kind == .imm8) { + emit8(assembler, 0xc6, line_no); + emit8(assembler, @truncate(operandValue(target, labels, assembler, line_no)), line_no); + return; + } + assembler.fail("invalid ADD form", line_no); +} + +fn assembleJr(assembler: *Assembler, statement: *const Statement) void { + const a = statement.operand(0); + const b = statement.operand(1); + const line_no = statement.line_no; + const labels = &assembler.labels; + + var cc: ?u8 = null; + var target = a; + if (condCode(a)) |code| { + cc = code; + target = b; + } + if (target.isNone()) { + assembler.fail("JR requires a target", line_no); + return; + } + if (cc) |code| { + emit8(assembler, 0x20 | (code << 3), line_no); + } else { + emit8(assembler, 0x18, line_no); + } + + const absolute = operandValue(target, labels, assembler, line_no); + const from: u32 = assembler.cursor + 1; + const offset: i32 = @as(i32, @intCast(absolute)) - @as(i32, @intCast(from)); + if (offset < -128 or offset > 127) { + assembler.fail("JR target out of range", line_no); + return; + } + emit8(assembler, @bitCast(@as(i8, @intCast(offset))), line_no); +} + +fn assembleJp(assembler: *Assembler, statement: *const Statement) void { + const a = statement.operand(0); + const b = statement.operand(1); + const line_no = statement.line_no; + const labels = &assembler.labels; + + if (a.kind == .hl or a.kind == .ind_hl) { + emit8(assembler, 0xe9, line_no); // JP (HL) + return; + } + if (condCode(a)) |cc| { + emit8(assembler, 0xc2 | (cc << 3), line_no); + emit16(assembler, @truncate(operandValue(b, labels, assembler, line_no)), line_no); + return; + } + if (b.isNone()) { + emit8(assembler, 0xc3, line_no); + emit16(assembler, @truncate(operandValue(a, labels, assembler, line_no)), line_no); + return; + } + assembler.fail("invalid JP form", line_no); +} + +fn assembleCall(assembler: *Assembler, statement: *const Statement) void { + const a = statement.operand(0); + const b = statement.operand(1); + const line_no = statement.line_no; + const labels = &assembler.labels; + + if (condCode(a)) |cc| { + emit8(assembler, 0xc4 | (cc << 3), line_no); + emit16(assembler, @truncate(operandValue(b, labels, assembler, line_no)), line_no); + return; + } + if (b.isNone()) { + emit8(assembler, 0xcd, line_no); + emit16(assembler, @truncate(operandValue(a, labels, assembler, line_no)), line_no); + return; + } + assembler.fail("invalid CALL form", line_no); +} + +fn assembleData(assembler: *Assembler, statement: *const Statement, op: []const u8) void { + const line_no = statement.line_no; + const labels = &assembler.labels; + + if (std.mem.eql(u8, op, "org")) { + const value = operandValue(statement.operand(0), labels, assembler, line_no); + if (value > rom_size) { + assembler.fail("ORG out of range", line_no); + return; + } + assembler.cursor = value; + return; + } + if (std.mem.eql(u8, op, "equ")) { + _ = operandValue(statement.operand(0), labels, assembler, line_no); + return; + } + if (std.mem.eql(u8, op, "db")) { + for (statement.operands[0..statement.operand_count]) |operand| { + const text = operand.text; + if (text.len >= 2 and text[0] == '"' and text[text.len - 1] == '"') { + for (text[1 .. text.len - 1]) |ch| emit8(assembler, ch, line_no); + } else { + emit8(assembler, @truncate(operandValue(operand, labels, assembler, line_no)), line_no); + } + } + return; + } + if (std.mem.eql(u8, op, "dw")) { + for (statement.operands[0..statement.operand_count]) |operand| { + emit16(assembler, @truncate(operandValue(operand, labels, assembler, line_no)), line_no); + } + return; + } + if (std.mem.eql(u8, op, "ds")) { + const count = operandValue(statement.operand(0), labels, assembler, line_no); + var index: u32 = 0; + while (index < count) : (index += 1) emit8(assembler, 0, line_no); + return; + } +} + +fn instructionSize(statement: *const Statement) u32 { + const op = statement.op; + const a = statement.operand(0); + const b = statement.operand(1); + + if (std.mem.eql(u8, op, "stop")) return 2; + if (std.mem.eql(u8, op, "ld")) { + if (b.kind == .imm8 and isReg16Dest(a)) return 3; + if (b.kind == .imm8) return 2; + if (b.kind == .sp_plus) return 2; + if (a.kind == .ind_a16) return 3; + if (b.kind == .ind_a16) return 3; + return 1; + } + if (std.mem.eql(u8, op, "ldh")) return 2; + if (std.mem.eql(u8, op, "ldi") or std.mem.eql(u8, op, "ldd")) return 1; + if (std.mem.eql(u8, op, "add")) { + if (a.kind == .hl) return 1; + if (a.kind == .sp) return 2; + if (b.kind == .imm8) return 2; + return 1; + } + if (std.mem.eql(u8, op, "adc") or std.mem.eql(u8, op, "sub") or std.mem.eql(u8, op, "sbc") or std.mem.eql(u8, op, "and") or std.mem.eql(u8, op, "xor") or std.mem.eql(u8, op, "or") or std.mem.eql(u8, op, "cp")) { + const target = if (a.kind == .reg8 and a.match("a") and !b.isNone()) b else a; + if (target.kind == .imm8) return 2; + return 1; + } + if (std.mem.eql(u8, op, "jr")) return 2; + if (std.mem.eql(u8, op, "jp")) { + if (a.kind == .hl or a.kind == .ind_hl) return 1; + return 3; + } + if (std.mem.eql(u8, op, "call")) return 3; + if (std.mem.eql(u8, op, "rlc") or std.mem.eql(u8, op, "rrc") or std.mem.eql(u8, op, "rl") or std.mem.eql(u8, op, "rr") or std.mem.eql(u8, op, "sla") or std.mem.eql(u8, op, "sra") or std.mem.eql(u8, op, "swap") or std.mem.eql(u8, op, "srl") or std.mem.eql(u8, op, "bit") or std.mem.eql(u8, op, "res") or std.mem.eql(u8, op, "set")) return 2; + return 1; +} + +pub fn assembleSource(allocator: std.mem.Allocator, source: []const u8) !Assembler { + var assembler = Assembler.init(allocator); + errdefer assembler.deinit(); + + var statements: std.ArrayListUnmanaged(Statement) = .empty; + defer { + for (statements.items) |st| { + if (st.label) |name| allocator.free(name); + if (st.owns_op) allocator.free(st.op); + for (st.operands[0..st.operand_count]) |op| { + if (op.text.len != 0) allocator.free(op.text); + } + } + statements.deinit(allocator); + } + + var line_no: usize = 1; + var lines = std.mem.splitScalar(u8, source, '\n'); + while (lines.next()) |raw_line| { + const statement = parseStatement(raw_line, line_no, allocator) catch return error.OutOfMemory; + statements.append(allocator, statement) catch return error.OutOfMemory; + line_no += 1; + } + + if (assembler.failure != null) return error.AssembleFailed; + + var pass1 = Assembler.init(allocator); + defer pass1.deinit(); + + for (statements.items) |*statement| { + if (pass1.failure != null) break; + const op = statement.op; + if (op.len == 0) { + if (statement.label) |name| pass1.registerLabel(name, pass1.cursor, statement.line_no); + continue; + } + if (std.mem.eql(u8, op, "org")) { + pass1.cursor = operandValue(statement.operand(0), &pass1.labels, &pass1, statement.line_no); + continue; + } + if (std.mem.eql(u8, op, "equ")) { + if (statement.label) |name| { + const value = operandValue(statement.operand(0), &pass1.labels, &pass1, statement.line_no); + pass1.registerLabel(name, value, statement.line_no); + } + continue; + } + if (statement.label) |name| { + pass1.registerLabel(name, pass1.cursor, statement.line_no); + } + if (std.mem.eql(u8, op, "db")) { + for (statement.operands[0..statement.operand_count]) |operand| { + const text = operand.text; + if (text.len >= 2 and text[0] == '"' and text[text.len - 1] == '"') { + pass1.cursor += @intCast(text.len - 2); + } else { + pass1.cursor += 1; + } + } + continue; + } + if (std.mem.eql(u8, op, "dw")) { + pass1.cursor += @as(u32, @intCast(2 * statement.operand_count)); + continue; + } + if (std.mem.eql(u8, op, "ds")) { + pass1.cursor += operandValue(statement.operand(0), &pass1.labels, &pass1, statement.line_no); + continue; + } + pass1.cursor += instructionSize(statement); + } + + if (pass1.failure != null) { + assembler.failure = pass1.failure; + return error.AssembleFailed; + } + + assembler.labels = pass1.labels; + pass1.labels = LabelMap.init(allocator); + + for (statements.items) |statement| { + if (assembler.failure != null) break; + const op = statement.op; + if (op.len == 0) continue; + if (std.mem.eql(u8, op, "org") or std.mem.eql(u8, op, "equ") or std.mem.eql(u8, op, "db") or std.mem.eql(u8, op, "dw") or std.mem.eql(u8, op, "ds")) { + assembleData(&assembler, &statement, op); + } else { + assembleInstruction(&assembler, &statement); + } + } + + return assembler; +} + +fn readFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![]u8 { + const dir = std.Io.Dir.cwd(); + const file = try dir.openFile(io, path, .{}); + defer file.close(io); + const size = try file.length(io); + const buffer = try allocator.alloc(u8, @intCast(size)); + _ = try file.readPositionalAll(io, buffer, 0); + return buffer; +} + +fn writeFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8, bytes: []const u8) !void { + _ = allocator; + const dir = std.Io.Dir.cwd(); + const file = try dir.createFile(io, path, .{}); + defer file.close(io); + try file.writeStreamingAll(io, bytes); +} + +pub fn main(init: std.process.Init) !void { + const allocator = init.gpa; + const io = init.io; + + var it = try std.process.Args.Iterator.initAllocator(init.minimal.args, allocator); + defer it.deinit(); + + var args: std.ArrayList([]const u8) = .empty; + defer args.deinit(allocator); + while (it.next()) |arg| { + try args.append(allocator, arg); + } + const argv = args.items; + + if (argv.len < 3) { + std.debug.print("Usage: {s} \n", .{argv[0]}); + std.process.exit(2); + } + + var asm_dir = try std.Io.Dir.cwd().openDir(io, argv[1], .{ .iterate = true }); + defer asm_dir.close(io); + + var names: std.ArrayList([]const u8) = .empty; + defer names.deinit(allocator); + var iter = asm_dir.iterate(); + while (try iter.next(io)) |entry| { + if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".asm")) { + try names.append(allocator, try allocator.dupe(u8, entry.name)); + } + } + std.mem.sort([]const u8, names.items, {}, struct { + fn lessThan(_: void, lhs: []const u8, rhs: []const u8) bool { + return std.mem.order(u8, lhs, rhs) == .lt; + } + }.lessThan); + + var failures: usize = 0; + for (names.items) |name| { + const path = try std.fs.path.join(allocator, &.{ argv[1], name }); + defer allocator.free(path); + + const source = readFile(io, allocator, path) catch |err| { + std.debug.print("{s}: cannot read: {s}\n", .{ name, @errorName(err) }); + failures += 1; + allocator.free(name); + continue; + }; + defer allocator.free(source); + + var result = assembleSource(allocator, source) catch |err| { + std.debug.print("{s}: assembly failed: {s}\n", .{ name, @errorName(err) }); + failures += 1; + allocator.free(name); + continue; + }; + defer result.deinit(); + + if (result.failure) |failure| { + std.debug.print("{s}:{d}: error: {s}\n", .{ name, failure.line_no, failure.message }); + failures += 1; + allocator.free(name); + continue; + } + + const output_name = try std.fmt.allocPrint(allocator, "{s}.gb", .{name[0 .. name.len - 4]}); + defer allocator.free(output_name); + const output_path = try std.fs.path.join(allocator, &.{ argv[2], output_name }); + defer allocator.free(output_path); + + writeFile(io, allocator, output_path, result.rom[0..]) catch |err| { + std.debug.print("{s}: cannot write: {s}\n", .{ output_name, @errorName(err) }); + failures += 1; + allocator.free(name); + continue; + }; + allocator.free(name); + } + + if (failures != 0) std.process.exit(1); +} + +test "assembles a minimal ROM and encodes core instructions" { + const source = + \\org $0100 + \\ ld a,$3c + \\ ld b,a + \\ add a,b + \\ jp done + \\done: + \\ halt + ; + var assembler = try assembleSource(std.testing.allocator, source); + defer assembler.deinit(); + try std.testing.expect(assembler.failure == null); + try std.testing.expectEqual(@as(u8, 0x3e), assembler.rom[0x0100]); + try std.testing.expectEqual(@as(u8, 0x3c), assembler.rom[0x0101]); + try std.testing.expectEqual(@as(u8, 0x47), assembler.rom[0x0102]); + try std.testing.expectEqual(@as(u8, 0x80), assembler.rom[0x0103]); + try std.testing.expectEqual(@as(u8, 0xc3), assembler.rom[0x0104]); + try std.testing.expectEqual(@as(u8, 0x07), assembler.rom[0x0105]); + try std.testing.expectEqual(@as(u8, 0x01), assembler.rom[0x0106]); + try std.testing.expectEqual(@as(u8, 0x76), assembler.rom[0x0107]); +} + +test "encodes 16-bit, stack, and relative jump instructions" { + const source = + \\org $0100 + \\ ld sp,$dff0 + \\ push hl + \\ pop af + \\ call routine + \\ jr loop + \\loop: + \\ jr loop + \\routine: + \\ ret + ; + var assembler = try assembleSource(std.testing.allocator, source); + defer assembler.deinit(); + try std.testing.expect(assembler.failure == null); + try std.testing.expectEqual(@as(u8, 0x31), assembler.rom[0x0100]); + try std.testing.expectEqual(@as(u8, 0xf0), assembler.rom[0x0101]); + try std.testing.expectEqual(@as(u8, 0xdf), assembler.rom[0x0102]); + try std.testing.expectEqual(@as(u8, 0xe5), assembler.rom[0x0103]); + try std.testing.expectEqual(@as(u8, 0xf1), assembler.rom[0x0104]); + try std.testing.expectEqual(@as(u8, 0xcd), assembler.rom[0x0105]); + try std.testing.expectEqual(@as(u8, 0x0c), assembler.rom[0x0106]); + try std.testing.expectEqual(@as(u8, 0x01), assembler.rom[0x0107]); + try std.testing.expectEqual(@as(u8, 0x18), assembler.rom[0x0108]); + try std.testing.expectEqual(@as(u8, 0x00), assembler.rom[0x0109]); + try std.testing.expectEqual(@as(u8, 0x18), assembler.rom[0x010a]); + try std.testing.expectEqual(@as(u8, 0xfe), assembler.rom[0x010b]); + try std.testing.expectEqual(@as(u8, 0xc9), assembler.rom[0x010c]); +} + +test "encodes CB-prefixed instructions" { + const source = + \\org $0100 + \\ sla b + \\ bit 3,(hl) + \\ set 7,a + \\ rlc c + ; + var assembler = try assembleSource(std.testing.allocator, source); + defer assembler.deinit(); + try std.testing.expect(assembler.failure == null); + try std.testing.expectEqual(@as(u8, 0xcb), assembler.rom[0x0100]); + try std.testing.expectEqual(@as(u8, 0x20), assembler.rom[0x0101]); + try std.testing.expectEqual(@as(u8, 0xcb), assembler.rom[0x0102]); + try std.testing.expectEqual(@as(u8, 0x5e), assembler.rom[0x0103]); + try std.testing.expectEqual(@as(u8, 0xcb), assembler.rom[0x0104]); + try std.testing.expectEqual(@as(u8, 0xff), assembler.rom[0x0105]); + try std.testing.expectEqual(@as(u8, 0xcb), assembler.rom[0x0106]); + try std.testing.expectEqual(@as(u8, 0x01), assembler.rom[0x0107]); +} + +test "encodes ALU immediate and register instructions" { + const source = + \\org $0100 + \\ adc a,$12 + \\ sub $13 + \\ sbc $14 + \\ and $15 + \\ xor $16 + \\ or $17 + \\ cp $18 + \\ adc a,b + \\ or (hl) + ; + var assembler = try assembleSource(std.testing.allocator, source); + defer assembler.deinit(); + try std.testing.expect(assembler.failure == null); + try std.testing.expectEqual(@as(u8, 0xce), assembler.rom[0x0100]); + try std.testing.expectEqual(@as(u8, 0x12), assembler.rom[0x0101]); + try std.testing.expectEqual(@as(u8, 0xd6), assembler.rom[0x0102]); + try std.testing.expectEqual(@as(u8, 0x13), assembler.rom[0x0103]); + try std.testing.expectEqual(@as(u8, 0xde), assembler.rom[0x0104]); + try std.testing.expectEqual(@as(u8, 0x14), assembler.rom[0x0105]); + try std.testing.expectEqual(@as(u8, 0xe6), assembler.rom[0x0106]); + try std.testing.expectEqual(@as(u8, 0x15), assembler.rom[0x0107]); + try std.testing.expectEqual(@as(u8, 0xee), assembler.rom[0x0108]); + try std.testing.expectEqual(@as(u8, 0x16), assembler.rom[0x0109]); + try std.testing.expectEqual(@as(u8, 0xf6), assembler.rom[0x010a]); + try std.testing.expectEqual(@as(u8, 0x17), assembler.rom[0x010b]); + try std.testing.expectEqual(@as(u8, 0xfe), assembler.rom[0x010c]); + try std.testing.expectEqual(@as(u8, 0x18), assembler.rom[0x010d]); + try std.testing.expectEqual(@as(u8, 0x88), assembler.rom[0x010e]); + try std.testing.expectEqual(@as(u8, 0xb6), assembler.rom[0x010f]); +} + +test "resolves forward label references in expressions" { + const source = + \\org $0100 + \\ jp target + \\ ds $20 + \\target: + \\ nop + ; + var assembler = try assembleSource(std.testing.allocator, source); + defer assembler.deinit(); + try std.testing.expect(assembler.failure == null); + try std.testing.expectEqual(@as(u8, 0xc3), assembler.rom[0x0100]); + try std.testing.expectEqual(@as(u8, 0x23), assembler.rom[0x0101]); + try std.testing.expectEqual(@as(u8, 0x01), assembler.rom[0x0102]); +} + +test "assembles high-memory and SP-offset instructions" { + const source = + \\org $0100 + \\ ldh ($ff44),a + \\ ldh a,($ff00+$44) + \\ add sp,-$08 + \\ ld hl,sp+$10 + \\ ld (hl+),a + ; + var assembler = try assembleSource(std.testing.allocator, source); + defer assembler.deinit(); + try std.testing.expect(assembler.failure == null); + try std.testing.expectEqual(@as(u8, 0xe0), assembler.rom[0x0100]); + try std.testing.expectEqual(@as(u8, 0x44), assembler.rom[0x0101]); + try std.testing.expectEqual(@as(u8, 0xf0), assembler.rom[0x0102]); + try std.testing.expectEqual(@as(u8, 0x44), assembler.rom[0x0103]); + try std.testing.expectEqual(@as(u8, 0xe8), assembler.rom[0x0104]); + try std.testing.expectEqual(@as(u8, 0xf8), assembler.rom[0x0105]); + try std.testing.expectEqual(@as(u8, 0xf8), assembler.rom[0x0106]); + try std.testing.expectEqual(@as(u8, 0x10), assembler.rom[0x0107]); + try std.testing.expectEqual(@as(u8, 0x22), assembler.rom[0x0108]); +} + +test "emits data directives and equ constants" { + const source = + \\start equ $0100 + \\org start + \\ db "Hi", $0a, $0d + \\ dw start + \\ ds $04 + ; + var assembler = try assembleSource(std.testing.allocator, source); + defer assembler.deinit(); + try std.testing.expect(assembler.failure == null); + try std.testing.expectEqual(@as(u8, 'H'), assembler.rom[0x0100]); + try std.testing.expectEqual(@as(u8, 'i'), assembler.rom[0x0101]); + try std.testing.expectEqual(@as(u8, 0x0a), assembler.rom[0x0102]); + try std.testing.expectEqual(@as(u8, 0x0d), assembler.rom[0x0103]); + try std.testing.expectEqual(@as(u8, 0x00), assembler.rom[0x0104]); + try std.testing.expectEqual(@as(u8, 0x01), assembler.rom[0x0105]); +} + +test "reports unknown mnemonic as a failure" { + const source = + \\org $0100 + \\ frobnicate a + ; + var assembler = try assembleSource(std.testing.allocator, source); + defer assembler.deinit(); + try std.testing.expect(assembler.failure != null); }