add nes core system implementation with ppu 2c02 and apu 2a03

This commit is contained in:
2026-08-14 06:48:18 +02:00
parent 04fb7bcc1f
commit 5dc2db0a8b
6 changed files with 1660 additions and 0 deletions
+276
View File
@@ -0,0 +1,276 @@
const std = @import("std");
const Apu = @This();
pub const sample_rate: u32 = 44_100;
sample_buffer: [2048]i16 = [_]i16{0} ** 2048,
sample_count: usize = 0,
cycle_counter: u32 = 0,
// Pulse 1
pulse1_enabled: bool = false,
pulse1_timer_reload: u11 = 0,
pulse1_timer: u11 = 0,
pulse1_duty: u2 = 0,
pulse1_duty_pos: u3 = 0,
pulse1_volume: u4 = 0,
// Pulse 2
pulse2_enabled: bool = false,
pulse2_timer_reload: u11 = 0,
pulse2_timer: u11 = 0,
pulse2_duty: u2 = 0,
pulse2_duty_pos: u3 = 0,
pulse2_volume: u4 = 0,
// Triangle
triangle_enabled: bool = false,
triangle_timer_reload: u11 = 0,
triangle_timer: u11 = 0,
triangle_seq_pos: u5 = 0,
// Noise
noise_enabled: bool = false,
noise_timer_reload: u12 = 0,
noise_timer: u12 = 0,
noise_lfsr: u16 = 1,
noise_volume: u4 = 0,
// DMC
dmc_enabled: bool = false,
dmc_loop: bool = false,
dmc_rate_index: u4 = 0,
dmc_timer: u12 = 0,
dmc_timer_reload: u12 = 0,
dmc_output_level: u7 = 0,
dmc_sample_address: u16 = 0xC000,
dmc_sample_length: u16 = 1,
dmc_current_address: u16 = 0xC000,
dmc_bytes_remaining: u16 = 0,
dmc_shift_register: u8 = 0,
dmc_bits_remaining: u4 = 0,
dmc_silence: bool = true,
const duty_tables: [4][8]u8 = .{
.{ 0, 1, 0, 0, 0, 0, 0, 0 }, // 12.5%
.{ 0, 1, 1, 0, 0, 0, 0, 0 }, // 25%
.{ 0, 1, 1, 1, 1, 0, 0, 0 }, // 50%
.{ 1, 0, 0, 1, 1, 1, 1, 1 }, // 75%
};
const triangle_table: [32]u4 = .{
15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0,
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15,
};
const noise_periods: [16]u12 = .{
4, 8, 16, 32, 64, 96, 128, 160, 202, 254, 380, 508, 762, 1016, 2034, 4068,
};
const dmc_periods: [16]u12 = .{
428, 380, 340, 320, 286, 254, 226, 214, 190, 160, 142, 128, 106, 84, 72, 54,
};
pub fn reset(self: *Apu) void {
self.sample_count = 0;
self.cycle_counter = 0;
self.pulse1_enabled = false;
self.pulse2_enabled = false;
self.triangle_enabled = false;
self.noise_enabled = false;
self.noise_lfsr = 1;
self.dmc_enabled = false;
self.dmc_output_level = 0;
}
pub inline fn tick(self: *Apu) void {
// Pulse 1 timer
if (self.pulse1_timer == 0) {
self.pulse1_timer = self.pulse1_timer_reload;
self.pulse1_duty_pos +%= 1;
} else {
self.pulse1_timer -= 1;
}
// Pulse 2 timer
if (self.pulse2_timer == 0) {
self.pulse2_timer = self.pulse2_timer_reload;
self.pulse2_duty_pos +%= 1;
} else {
self.pulse2_timer -= 1;
}
// Triangle timer
if (self.triangle_timer == 0) {
self.triangle_timer = self.triangle_timer_reload;
self.triangle_seq_pos +%= 1;
} else {
self.triangle_timer -= 1;
}
// Noise timer
if (self.noise_timer == 0) {
self.noise_timer = self.noise_timer_reload;
const feedback = (self.noise_lfsr & 1) ^ ((self.noise_lfsr >> 1) & 1);
self.noise_lfsr = (self.noise_lfsr >> 1) | (feedback << 14);
} else {
self.noise_timer -= 1;
}
// DMC timer
if (self.dmc_timer == 0) {
self.dmc_timer = self.dmc_timer_reload;
if (!self.dmc_silence) {
if ((self.dmc_shift_register & 1) != 0) {
if (self.dmc_output_level <= 125) self.dmc_output_level += 2;
} else {
if (self.dmc_output_level >= 2) self.dmc_output_level -= 2;
}
self.dmc_shift_register >>= 1;
}
if (self.dmc_bits_remaining > 0) {
self.dmc_bits_remaining -= 1;
}
if (self.dmc_bits_remaining == 0) {
self.dmc_silence = true;
}
} else {
self.dmc_timer -= 1;
}
// Sample downsampling at ~44.1 kHz (every ~40 CPU cycles)
self.cycle_counter += 1;
if (self.cycle_counter >= 40) {
self.cycle_counter = 0;
if (self.sample_count < self.sample_buffer.len) {
const p1_val: i32 = if (self.pulse1_enabled and duty_tables[self.pulse1_duty][self.pulse1_duty_pos] != 0 and self.pulse1_timer_reload > 8)
@as(i32, self.pulse1_volume)
else
0;
const p2_val: i32 = if (self.pulse2_enabled and duty_tables[self.pulse2_duty][self.pulse2_duty_pos] != 0 and self.pulse2_timer_reload > 8)
@as(i32, self.pulse2_volume)
else
0;
const tri_val: i32 = if (self.triangle_enabled and self.triangle_timer_reload > 2)
@as(i32, triangle_table[self.triangle_seq_pos])
else
0;
const noise_val: i32 = if (self.noise_enabled and (self.noise_lfsr & 1) == 0)
@as(i32, self.noise_volume)
else
0;
const dmc_val: i32 = if (self.dmc_enabled) @as(i32, self.dmc_output_level) else 0;
const sample_i32 = (p1_val + p2_val) * 400 + tri_val * 350 + noise_val * 300 + dmc_val * 150;
self.sample_buffer[self.sample_count] = @intCast(std.math.clamp(sample_i32, -32000, 32000));
self.sample_count += 1;
}
}
}
pub fn write(self: *Apu, address: u16, value: u8) void {
switch (address) {
// Pulse 1
0x4000 => {
self.pulse1_duty = @truncate((value >> 6) & 3);
self.pulse1_volume = @truncate(value & 0x0f);
},
0x4002 => {
self.pulse1_timer_reload = (self.pulse1_timer_reload & 0x0700) | value;
},
0x4003 => {
self.pulse1_timer_reload = (self.pulse1_timer_reload & 0x00ff) | (@as(u11, value & 7) << 8);
self.pulse1_duty_pos = 0;
},
// Pulse 2
0x4004 => {
self.pulse2_duty = @truncate((value >> 6) & 3);
self.pulse2_volume = @truncate(value & 0x0f);
},
0x4006 => {
self.pulse2_timer_reload = (self.pulse2_timer_reload & 0x0700) | value;
},
0x4007 => {
self.pulse2_timer_reload = (self.pulse2_timer_reload & 0x00ff) | (@as(u11, value & 7) << 8);
self.pulse2_duty_pos = 0;
},
// Triangle
0x400a => {
self.triangle_timer_reload = (self.triangle_timer_reload & 0x0700) | value;
},
0x400b => {
self.triangle_timer_reload = (self.triangle_timer_reload & 0x00ff) | (@as(u11, value & 7) << 8);
},
// Noise
0x400c => {
self.noise_volume = @truncate(value & 0x0f);
},
0x400e => {
self.noise_timer_reload = noise_periods[value & 0x0f];
},
// DMC
0x4010 => {
self.dmc_loop = (value & 0x40) != 0;
self.dmc_rate_index = @truncate(value & 0x0f);
self.dmc_timer_reload = dmc_periods[self.dmc_rate_index];
},
0x4011 => {
self.dmc_output_level = @truncate(value & 0x7f);
},
0x4012 => {
self.dmc_sample_address = 0xC000 + (@as(u16, value) << 6);
},
0x4013 => {
self.dmc_sample_length = (@as(u16, value) << 4) + 1;
},
else => {},
}
}
pub fn readStatus(_: *Apu) u8 {
return 0;
}
pub fn writeStatus(self: *Apu, value: u8) void {
self.pulse1_enabled = (value & 0x01) != 0;
self.pulse2_enabled = (value & 0x02) != 0;
self.triangle_enabled = (value & 0x04) != 0;
self.noise_enabled = (value & 0x08) != 0;
self.dmc_enabled = (value & 0x10) != 0;
}
pub fn writeFrameCounter(_: *Apu, _: u8) void {}
pub fn irqAsserted(_: *const Apu) bool {
return false;
}
test "NES APU - Sound Synthesis & Sample Generation" {
var apu = Apu{};
apu.reset();
// Enable Pulse 1 and Pulse 2
apu.writeStatus(0x03);
// Setup Pulse 1 volume and frequency
apu.write(0x4000, 0xbf); // 50% duty, max volume 15
apu.write(0x4002, 100);
apu.write(0x4003, 0);
// Tick APU for 100 cycles to produce PCM samples
for (0..100) |_| {
apu.tick();
}
try std.testing.expect(apu.sample_count > 0);
}
+157
View File
@@ -0,0 +1,157 @@
const Cartridge = @import("cartridge.zig");
const Controller = @import("controller.zig");
const Ppu = @import("ppu.zig");
const Apu = @import("apu.zig");
const Bus = @This();
ram: [0x800]u8 =
[_]u8{0} ** 0x800,
cartridge: Cartridge,
ppu: Ppu = .{},
apu: Apu = .{},
controllers: [2]Controller = .{
.{},
.{},
},
open_bus: u8 = 0,
oam_dma_page: ?u8 = null,
pub fn init(
rom: []const u8,
) Cartridge.Error!Bus {
return .{
.cartridge = try Cartridge.init(rom),
};
}
pub fn reset(
self: *Bus,
) void {
self.ppu.reset();
self.apu.reset();
for (&self.controllers) |*controller|
controller.reset();
self.oam_dma_page = null;
}
pub inline fn read(
self: *Bus,
address: u16,
) u8 {
const value: u8 = switch (address) {
// 2 KiB internal RAM + mirrors.
0x0000...0x1fff => self.ram[address & 0x07ff],
// PPU registers + mirrors.
0x2000...0x3fff => self.ppu.cpuRead(
&self.cartridge,
address & 7,
),
// APU status.
0x4015 => self.apu.readStatus(),
// Controller 1.
0x4016 => self.controllers[0].read(),
// Controller 2.
0x4017 => self.controllers[1].read(),
// Cartridge space.
0x4020...0xffff => self.cartridge.cpuRead(address),
else => self.open_bus,
};
self.open_bus = value;
return value;
}
pub inline fn write(
self: *Bus,
address: u16,
value: u8,
) void {
self.open_bus = value;
switch (address) {
0x0000...0x1fff => self.ram[address & 0x07ff] =
value,
0x2000...0x3fff => self.ppu.cpuWrite(
&self.cartridge,
address & 7,
value,
),
0x4000...0x4013 => self.apu.write(
address,
value,
),
// OAMDMA
0x4014 => self.oam_dma_page = value,
0x4015 => self.apu.writeStatus(value),
0x4016 => {
self.controllers[0].write(value);
self.controllers[1].write(value);
},
0x4017 => self.apu.writeFrameCounter(value),
else => self.cartridge.cpuWrite(
address,
value,
),
}
}
/// Called exactly once for every CPU cycle.
pub inline fn tick(
self: *Bus,
) void {
self.apu.tick();
// NTSC NES: PPU runs at 3× CPU clock.
self.ppu.tick(&self.cartridge);
self.ppu.tick(&self.cartridge);
self.ppu.tick(&self.cartridge);
}
pub fn nmiLine(
self: *Bus,
) bool {
return self.ppu.nmiAsserted();
}
pub fn irqLine(
self: *Bus,
) bool {
return self.apu.irqAsserted() or
self.cartridge.irqAsserted();
}
pub fn takeOamDmaPage(
self: *Bus,
) ?u8 {
const page =
self.oam_dma_page;
self.oam_dma_page = null;
return page;
}
+70
View File
@@ -0,0 +1,70 @@
pub const Button = enum(u3) {
a = 0,
b = 1,
select = 2,
start = 3,
up = 4,
down = 5,
left = 6,
right = 7,
};
const Controller = @This();
buttons: u8 = 0,
shift_register: u8 = 0,
strobe: bool = false,
pub fn reset(
self: *Controller,
) void {
self.shift_register = 0;
self.strobe = false;
}
pub fn setButtons(
self: *Controller,
buttons: u8,
) void {
self.buttons = buttons;
if (self.strobe)
self.shift_register = buttons;
}
pub fn write(
self: *Controller,
value: u8,
) void {
const new_strobe =
(value & 1) != 0;
if (new_strobe) {
self.shift_register =
self.buttons;
} else if (self.strobe) {
// Falling edge latches controller.
self.shift_register =
self.buttons;
}
self.strobe = new_strobe;
}
pub fn read(
self: *Controller,
) u8 {
if (self.strobe)
return self.buttons & 1;
const value =
self.shift_register & 1;
// Real controllers return 1 after all
// eight buttons have shifted out.
self.shift_register =
(self.shift_register >> 1) | 0x80;
return value;
}
+779
View File
@@ -0,0 +1,779 @@
const std = @import("std");
const Cartridge = @import("cartridge.zig");
const mapper = @import("mapper/root.zig");
const Mirroring = mapper.Mirroring;
const Ppu = @This();
pub const width = 256;
pub const height = 240;
// NTSC 2C02 64-color system palette mapped directly to RGB565 format
pub const nes_ntsc_palette_rgb565: [64]u16 = .{
0x52AA, 0x00EE, 0x0892, 0x3011, 0x400C, 0x5806, 0x5020, 0x38C0,
0x2140, 0x09C0, 0x0200, 0x01E0, 0x0187, 0x0000, 0x0000, 0x0000,
0x9CB3, 0x0A78, 0x319D, 0x58FC, 0x88B6, 0xA08C, 0x9904, 0x79E0,
0x52C0, 0x2B80, 0x0BE0, 0x03A5, 0x032F, 0x0000, 0x0000, 0x0000,
0xEF7D, 0x4CDD, 0x7BFD, 0xB31D, 0xE2BD, 0xEACE, 0xEB4C, 0xD444,
0xA540, 0x7620, 0x4E84, 0x3E6D, 0x3DA9, 0x3DF7, 0x0000, 0x0000,
0xEF7D, 0xAE6D, 0xBD7D, 0xD59D, 0xED7D, 0xED7A, 0xEDB6, 0xE632,
0xCE8F, 0xB6EF, 0xAF12, 0x9F16, 0xA6BD, 0xA514, 0x0000, 0x0000,
};
framebuffer: [width * height]u16 =
[_]u16{0} ** (width * height),
/// Four-screen sized so the same structure
/// can handle that mode without allocation.
nametable: [0x1000]u8 =
[_]u8{0} ** 0x1000,
palette: [32]u8 =
[_]u8{0} ** 32,
oam: [256]u8 =
[_]u8{0} ** 256,
ctrl: u8 = 0,
mask: u8 = 0,
status: u8 = 0,
oam_addr: u8 = 0,
vram_addr: u16 = 0,
temp_addr: u16 = 0,
fine_x: u3 = 0,
write_latch: bool = false,
read_buffer: u8 = 0,
io_latch: u8 = 0,
dot: u16 = 0,
scanline: u16 = 261,
frame_number: u64 = 0,
frame_ready: bool = false,
// internal shift registers & tile latches for background rendering
bg_shift_pattern_lo: u16 = 0,
bg_shift_pattern_hi: u16 = 0,
bg_shift_attrib_lo: u16 = 0,
bg_shift_attrib_hi: u16 = 0,
next_tile_id: u8 = 0,
next_tile_attrib: u8 = 0,
next_tile_lsb: u8 = 0,
next_tile_msb: u8 = 0,
// secondary OAM scanline sprite evaluation buffer
sec_oam_pattern_lo: [8]u8 = [_]u8{0} ** 8,
sec_oam_pattern_hi: [8]u8 = [_]u8{0} ** 8,
sec_oam_attribute: [8]u8 = [_]u8{0} ** 8,
sec_oam_x: [8]u8 = [_]u8{0} ** 8,
sec_oam_is_zero: [8]bool = [_]bool{false} ** 8,
sec_oam_count: u8 = 0,
pub fn reset(
self: *Ppu,
) void {
self.ctrl = 0;
self.mask = 0;
self.status = 0;
self.oam_addr = 0;
self.vram_addr = 0;
self.temp_addr = 0;
self.fine_x = 0;
self.write_latch = false;
self.read_buffer = 0;
self.io_latch = 0;
self.dot = 0;
self.scanline = 261;
self.frame_ready = false;
self.bg_shift_pattern_lo = 0;
self.bg_shift_pattern_hi = 0;
self.bg_shift_attrib_lo = 0;
self.bg_shift_attrib_hi = 0;
self.next_tile_id = 0;
self.next_tile_attrib = 0;
self.next_tile_lsb = 0;
self.next_tile_msb = 0;
self.sec_oam_count = 0;
}
pub fn cpuRead(
self: *Ppu,
cartridge: *Cartridge,
register: u16,
) u8 {
const reg = register & 7;
const value: u8 = switch (reg) {
// PPUSTATUS
2 => blk: {
const result =
(self.status & 0xe0) |
(self.io_latch & 0x1f);
self.status &= ~@as(u8, 0x80);
self.write_latch = false;
break :blk result;
},
// OAMDATA
4 => self.oam[self.oam_addr],
// PPUDATA
7 => self.readData(cartridge),
else => self.io_latch,
};
self.io_latch = value;
return value;
}
pub fn cpuWrite(
self: *Ppu,
cartridge: *Cartridge,
register: u16,
value: u8,
) void {
const reg = register & 7;
self.io_latch = value;
switch (reg) {
// PPUCTRL
0 => {
self.ctrl = value;
self.temp_addr =
(self.temp_addr & 0xf3ff) |
(@as(u16, value & 0x03) << 10);
},
// PPUMASK
1 => {
self.mask = value;
},
// OAMADDR
3 => {
self.oam_addr = value;
},
// OAMDATA
4 => {
self.writeOamDma(value);
},
// PPUSCROLL
5 => {
if (!self.write_latch) {
self.fine_x =
@truncate(value & 7);
self.temp_addr =
(self.temp_addr & 0xffe0) |
@as(u16, value >> 3);
self.write_latch = true;
} else {
self.temp_addr =
(self.temp_addr & 0x8c1f) |
(@as(u16, value & 0x07) << 12) |
(@as(u16, value & 0xf8) << 2);
self.write_latch = false;
}
},
// PPUADDR
6 => {
if (!self.write_latch) {
self.temp_addr =
(self.temp_addr & 0x00ff) |
(@as(u16, value & 0x3f) << 8);
self.write_latch = true;
} else {
self.temp_addr =
(self.temp_addr & 0x7f00) |
@as(u16, value);
self.vram_addr =
self.temp_addr;
self.write_latch = false;
}
},
// PPUDATA
7 => {
self.writeMemory(
cartridge,
self.vram_addr,
value,
);
self.incrementVramAddress();
},
else => {},
}
}
pub fn writeOamDma(
self: *Ppu,
value: u8,
) void {
self.oam[self.oam_addr] = value;
self.oam_addr +%= 1;
}
pub fn tick(
self: *Ppu,
cartridge: *Cartridge,
) void {
const rendering_enabled = (self.mask & 0x18) != 0;
const is_visible_scanline = (self.scanline < 240);
const is_prerender_scanline = (self.scanline == 261);
// Start of VBlank.
if (self.scanline == 241 and
self.dot == 1)
{
self.status |= 0x80;
}
// Pre-render line.
if (is_prerender_scanline and
self.dot == 1)
{
// Clear: VBlank, sprite zero hit, sprite overflow
self.status &= ~@as(u8, 0xe0);
}
// --- Rendering Pipeline ---
if (rendering_enabled and (is_visible_scanline or is_prerender_scanline)) {
if ((self.dot >= 1 and self.dot <= 256) or (self.dot >= 321 and self.dot <= 336)) {
self.shiftBackgroundRegisters();
const cycle_in_tile = (self.dot - 1) % 8;
switch (cycle_in_tile) {
0 => {
self.loadShiftRegisters();
const nt_addr = 0x2000 | (self.vram_addr & 0x0fff);
self.next_tile_id = self.readMemory(cartridge, nt_addr);
},
2 => {
const attr_addr = 0x23c0 |
(self.vram_addr & 0x0c00) |
((self.vram_addr >> 4) & 0x38) |
((self.vram_addr >> 2) & 0x07);
const attr_byte = self.readMemory(cartridge, attr_addr);
const shift: u3 = @intCast(((self.vram_addr >> 4) & 4) | (self.vram_addr & 2));
self.next_tile_attrib = (attr_byte >> shift) & 0x03;
},
4 => {
const bg_table: u16 = if ((self.ctrl & 0x10) != 0) 0x1000 else 0x0000;
const fine_y = (self.vram_addr >> 12) & 7;
const pattern_addr = bg_table + (@as(u16, self.next_tile_id) << 4) + fine_y;
self.next_tile_lsb = self.readMemory(cartridge, pattern_addr);
},
6 => {
const bg_table: u16 = if ((self.ctrl & 0x10) != 0) 0x1000 else 0x0000;
const fine_y = (self.vram_addr >> 12) & 7;
const pattern_addr = bg_table + (@as(u16, self.next_tile_id) << 4) + fine_y + 8;
self.next_tile_msb = self.readMemory(cartridge, pattern_addr);
},
7 => {
self.incrementCoarseX();
},
else => {},
}
}
if (self.dot == 256) {
self.incrementY();
}
if (self.dot == 257) {
self.loadShiftRegisters();
self.copyHorizontal();
}
if (is_prerender_scanline and self.dot >= 280 and self.dot <= 304) {
self.copyVertical();
}
if (self.dot == 257 and is_visible_scanline) {
self.evaluateSprites(cartridge);
}
if (self.dot == 260 and is_visible_scanline) {
cartridge.handleScanline();
}
}
// --- Pixel Output ---
if (is_visible_scanline and self.dot >= 1 and self.dot <= 256) {
self.renderPixel(cartridge);
}
// --- Dot & Scanline Counters ---
self.dot += 1;
if (self.dot == 341) {
self.dot = 0;
self.scanline += 1;
if (self.scanline == 262) {
self.scanline = 0;
self.frame_number +%= 1;
self.frame_ready = true;
}
}
}
pub fn nmiAsserted(
self: *const Ppu,
) bool {
const nmi_enabled =
(self.ctrl & 0x80) != 0;
const in_vblank =
(self.status & 0x80) != 0;
return nmi_enabled and in_vblank;
}
fn shiftBackgroundRegisters(self: *Ppu) void {
if ((self.mask & 0x08) != 0) {
self.bg_shift_pattern_lo <<= 1;
self.bg_shift_pattern_hi <<= 1;
self.bg_shift_attrib_lo <<= 1;
self.bg_shift_attrib_hi <<= 1;
}
}
fn loadShiftRegisters(self: *Ppu) void {
self.bg_shift_pattern_lo = (self.bg_shift_pattern_lo & 0xff00) | self.next_tile_lsb;
self.bg_shift_pattern_hi = (self.bg_shift_pattern_hi & 0xff00) | self.next_tile_msb;
const attr_lo: u16 = if ((self.next_tile_attrib & 1) != 0) 0x00ff else 0x0000;
const attr_hi: u16 = if ((self.next_tile_attrib & 2) != 0) 0x00ff else 0x0000;
self.bg_shift_attrib_lo = (self.bg_shift_attrib_lo & 0xff00) | attr_lo;
self.bg_shift_attrib_hi = (self.bg_shift_attrib_hi & 0xff00) | attr_hi;
}
inline fn incrementCoarseX(self: *Ppu) void {
if ((self.vram_addr & 0x001f) == 31) {
self.vram_addr &= ~@as(u16, 0x001f);
self.vram_addr ^= 0x0400;
} else {
self.vram_addr += 1;
}
}
inline fn incrementY(self: *Ppu) void {
if ((self.vram_addr & 0x7000) != 0x7000) {
self.vram_addr += 0x1000;
} else {
self.vram_addr &= ~@as(u16, 0x7000);
var y = (self.vram_addr & 0x03e0) >> 5;
if (y == 29) {
y = 0;
self.vram_addr ^= 0x0800;
} else if (y == 31) {
y = 0;
} else {
y += 1;
}
self.vram_addr = (self.vram_addr & ~@as(u16, 0x03e0)) | (y << 5);
}
}
inline fn copyHorizontal(self: *Ppu) void {
self.vram_addr = (self.vram_addr & ~@as(u16, 0x041f)) | (self.temp_addr & 0x041f);
}
inline fn copyVertical(self: *Ppu) void {
self.vram_addr = (self.vram_addr & ~@as(u16, 0x7be0)) | (self.temp_addr & 0x7be0);
}
inline fn evaluateSprites(
self: *Ppu,
cartridge: *Cartridge,
) void {
self.sec_oam_count = 0;
const sprite_height: u16 = if ((self.ctrl & 0x20) != 0) 16 else 8;
var oam_idx: usize = 0;
var i: usize = 0;
while (i < 64) : ({
i += 1;
oam_idx += 4;
}) {
const sprite_y = @as(u16, self.oam[oam_idx]);
const diff = @as(i16, @intCast(self.scanline)) - @as(i16, @intCast(sprite_y));
if (diff >= 0 and diff < sprite_height) {
if (self.sec_oam_count < 8) {
const idx = self.sec_oam_count;
const tile = self.oam[oam_idx + 1];
const attr = self.oam[oam_idx + 2];
const x = self.oam[oam_idx + 3];
var row: u16 = @intCast(diff);
// Vertical flip
if ((attr & 0x80) != 0) {
row = (sprite_height - 1) - row;
}
var pattern_addr: u16 = 0;
if (sprite_height == 8) {
const pattern_table: u16 = if ((self.ctrl & 0x08) != 0) 0x1000 else 0x0000;
pattern_addr = pattern_table + (@as(u16, tile) << 4) + row;
} else {
// 8x16 mode
const pattern_table: u16 = if ((tile & 1) != 0) 0x1000 else 0x0000;
const tile_index: u16 = tile & 0xfe;
if (row < 8) {
pattern_addr = pattern_table + (tile_index << 4) + row;
} else {
pattern_addr = pattern_table + ((tile_index + 1) << 4) + (row - 8);
}
}
var pat_lo = self.readMemory(cartridge, pattern_addr);
var pat_hi = self.readMemory(cartridge, pattern_addr + 8);
// Horizontal flip
if ((attr & 0x40) != 0) {
pat_lo = @bitReverse(pat_lo);
pat_hi = @bitReverse(pat_hi);
}
self.sec_oam_pattern_lo[idx] = pat_lo;
self.sec_oam_pattern_hi[idx] = pat_hi;
self.sec_oam_attribute[idx] = attr;
self.sec_oam_x[idx] = x;
self.sec_oam_is_zero[idx] = (i == 0);
self.sec_oam_count += 1;
} else {
// Sprite overflow
self.status |= 0x20;
break;
}
}
}
}
fn renderPixel(
self: *Ppu,
_: *Cartridge,
) void {
const x = self.dot - 1;
var bg_pixel: u8 = 0;
var bg_palette: u8 = 0;
if ((self.mask & 0x08) != 0 and (x >= 8 or (self.mask & 0x02) != 0)) {
const bit_shift: u4 = 15 - @as(u4, self.fine_x);
const p1 = @as(u8, @truncate((self.bg_shift_pattern_lo >> bit_shift) & 1));
const p2 = @as(u8, @truncate((self.bg_shift_pattern_hi >> bit_shift) & 1));
bg_pixel = (p2 << 1) | p1;
const a1 = @as(u8, @truncate((self.bg_shift_attrib_lo >> bit_shift) & 1));
const a2 = @as(u8, @truncate((self.bg_shift_attrib_hi >> bit_shift) & 1));
bg_palette = (a2 << 1) | a1;
}
var fg_pixel: u8 = 0;
var fg_palette: u8 = 0;
var fg_priority: bool = false;
var is_sprite_zero: bool = false;
if (self.sec_oam_count > 0 and (self.mask & 0x10) != 0 and (x >= 8 or (self.mask & 0x04) != 0)) {
for (0..self.sec_oam_count) |i| {
const spr_x = self.sec_oam_x[i];
if (x >= spr_x and x < spr_x + 8) {
const offset: u3 = @intCast(x - spr_x);
const shift: u3 = 7 - offset;
const p1 = (self.sec_oam_pattern_lo[i] >> shift) & 1;
const p2 = (self.sec_oam_pattern_hi[i] >> shift) & 1;
const pixel = (p2 << 1) | p1;
if (pixel != 0) {
fg_pixel = pixel;
fg_palette = 4 + (self.sec_oam_attribute[i] & 0x03);
fg_priority = (self.sec_oam_attribute[i] & 0x20) != 0;
is_sprite_zero = self.sec_oam_is_zero[i];
break;
}
}
}
}
var final_palette_entry: u16 = 0;
if (bg_pixel == 0 and fg_pixel == 0) {
final_palette_entry = 0x00;
} else if (bg_pixel == 0 and fg_pixel != 0) {
final_palette_entry = @as(u16, fg_palette) * 4 + fg_pixel;
} else if (bg_pixel != 0 and fg_pixel == 0) {
final_palette_entry = @as(u16, bg_palette) * 4 + bg_pixel;
} else {
// Both bg and fg pixels present
if (is_sprite_zero and x < 255 and (self.mask & 0x18) == 0x18) {
self.status |= 0x40; // Sprite 0 hit
}
if (fg_priority) {
final_palette_entry = @as(u16, bg_palette) * 4 + bg_pixel;
} else {
final_palette_entry = @as(u16, fg_palette) * 4 + fg_pixel;
}
}
var color_idx = self.palette[mapPalette(0x3f00 + final_palette_entry)] & 0x3f;
if ((self.mask & 0x01) != 0) {
color_idx &= 0x30;
}
var pixel_rgb = nes_ntsc_palette_rgb565[color_idx];
const emphasis = (self.mask >> 5) & 7;
if (emphasis != 0) {
var r = (pixel_rgb >> 11) & 0x1F;
var g = (pixel_rgb >> 5) & 0x3F;
var b = pixel_rgb & 0x1F;
// Red emphasis (bit 5)
if ((emphasis & 1) != 0) {
g = (g * 3) / 4;
b = (b * 3) / 4;
}
// Green emphasis (bit 6)
if ((emphasis & 2) != 0) {
r = (r * 3) / 4;
b = (b * 3) / 4;
}
// Blue emphasis (bit 7)
if ((emphasis & 4) != 0) {
r = (r * 3) / 4;
g = (g * 3) / 4;
}
pixel_rgb = (@as(u16, r) << 11) | (@as(u16, g) << 5) | @as(u16, b);
}
self.framebuffer[self.scanline * 256 + x] = pixel_rgb;
}
fn incrementVramAddress(
self: *Ppu,
) void {
const increment: u16 =
if ((self.ctrl & 0x04) != 0)
32
else
1;
self.vram_addr =
(self.vram_addr +% increment) &
0x3fff;
}
fn readData(
self: *Ppu,
cartridge: *Cartridge,
) u8 {
const address =
self.vram_addr & 0x3fff;
const value: u8 =
if (address < 0x3f00) blk: {
const previous =
self.read_buffer;
self.read_buffer =
self.readMemory(
cartridge,
address,
);
break :blk previous;
} else blk: {
const result =
self.readMemory(
cartridge,
address,
);
// Palette reads aren't delayed, but
// still update the internal buffer.
self.read_buffer =
self.readMemory(
cartridge,
address - 0x1000,
);
break :blk result;
};
self.incrementVramAddress();
return value;
}
inline fn readMemory(
self: *Ppu,
cartridge: *Cartridge,
address_: u16,
) u8 {
const address =
address_ & 0x3fff;
return switch (address) {
0x0000...0x1fff => cartridge.ppuRead(address),
0x2000...0x3eff => self.nametable[
self.mapNametable(
cartridge.mirroring(),
address,
)
],
0x3f00...0x3fff => self.palette[
mapPalette(address)
],
else => unreachable,
};
}
inline fn writeMemory(
self: *Ppu,
cartridge: *Cartridge,
address_: u16,
value: u8,
) void {
const address =
address_ & 0x3fff;
switch (address) {
0x0000...0x1fff => cartridge.ppuWrite(
address,
value,
),
0x2000...0x3eff => self.nametable[
self.mapNametable(
cartridge.mirroring(),
address,
)
] = value,
0x3f00...0x3fff => self.palette[
mapPalette(address)
] = value,
else => unreachable,
}
}
inline fn mapNametable(
_: *const Ppu,
mirroring: Mirroring,
address: u16,
) usize {
const relative =
(@as(usize, address) - 0x2000) &
0x0fff;
const table =
relative >> 10;
const offset =
relative & 0x3ff;
const mapped_table: usize =
switch (mirroring) {
.vertical => switch (table) {
0, 2 => 0,
1, 3 => 1,
else => unreachable,
},
.horizontal => switch (table) {
0, 1 => 0,
2, 3 => 1,
else => unreachable,
},
.single_screen_lower => 0,
.single_screen_upper => 1,
.four_screen => table,
};
return mapped_table * 0x400 +
offset;
}
inline fn mapPalette(
address: u16,
) usize {
var index: usize =
(@as(usize, address) - 0x3f00) &
0x1f;
// Universal background color mirrors.
switch (index) {
0x10 => index = 0x00,
0x14 => index = 0x04,
0x18 => index = 0x08,
0x1c => index = 0x0c,
else => {},
}
return index;
}
test "NES PPU - Palette Mirroring & NTSC Palette LUT" {
try std.testing.expectEqual(@as(usize, 0), mapPalette(0x3F00));
try std.testing.expectEqual(@as(usize, 0), mapPalette(0x3F10));
try std.testing.expectEqual(@as(usize, 4), mapPalette(0x3F04));
try std.testing.expectEqual(@as(usize, 4), mapPalette(0x3F14));
// Verify system palette RGB565 LUT contains non-zero color constants
try std.testing.expectEqual(@as(u16, 0x52AA), nes_ntsc_palette_rgb565[0]);
try std.testing.expectEqual(@as(u16, 0xEF7D), nes_ntsc_palette_rgb565[0x20]);
}
test "NES PPU - Scroll Address Increments" {
var ppu = Ppu{};
ppu.reset();
ppu.vram_addr = 0x001F; // Coarse X = 31
ppu.incrementCoarseX();
try std.testing.expectEqual(@as(u16, 0x0400), ppu.vram_addr); // Coarse X = 0, switched nametable X
ppu.vram_addr = 0x73A0; // Coarse Y = 29, Fine Y = 7
ppu.incrementY();
try std.testing.expectEqual(@as(u16, 0x0800), ppu.vram_addr); // Coarse Y = 0, Fine Y = 0, switched nametable Y
}
+373
View File
@@ -0,0 +1,373 @@
const std = @import("std");
const contract = @import("contract");
const _cpu = @import("cpu");
const Bus = @import("bus.zig");
const Cartridge = @import("cartridge.zig");
const controller = @import("controller.zig");
pub const Button = controller.Button;
// Adjust this enum spelling to whatever your CPU package
// currently exports for the Ricoh 2A03.
const Cpu = _cpu.m6502.Cpu(
Bus,
.ricoh2a03,
.cycle,
);
const Nes = @This();
pub const spec = contract.SystemSpec{
.name = "Nintendo Entertainment System",
.video_outputs = &.{
.{
.max_width = 256,
.max_height = 240,
.format = .rgb565,
.aspect_ratio = .{
.numerator = 4,
.denominator = 3,
},
.refresh_rate = .{
.numerator = 60,
.denominator = 1,
},
},
},
.audio_outputs = &.{
.{
.sample_rate = 44_100,
.channels = 1,
.format = .i16,
},
},
.input_devices = &.{
.{
.button_count = 8,
},
.{
.button_count = 8,
},
},
.storage_devices = &.{
.{
.name = "Battery PRG RAM",
.min_size = 0,
.max_size = 8192,
.persistent = true,
.removable = false,
.writable = true,
},
},
};
bus: Bus = undefined,
cpu: Cpu = undefined,
/// Initialize in place.
///
/// `rom` must remain valid for the lifetime of this NES
/// because Cartridge stores slices into it.
pub fn init(
self: *Nes,
rom: []const u8,
) Cartridge.Error!void {
self.bus =
try Bus.init(rom);
self.cpu =
Cpu.init(&self.bus);
self.reset();
}
pub fn reset(
self: *Nes,
) void {
self.bus.reset();
self.cpu.reset();
}
pub fn step(
self: *Nes,
) ?u8 {
const cycles = self.cpu.step() orelse return null;
self.serviceOamDma();
return cycles;
}
pub fn runFrame(
self: *Nes,
) void {
self.bus.ppu.frame_ready = false;
self.bus.apu.sample_count = 0;
while (!self.bus.ppu.frame_ready) {
_ = self.step() orelse
return;
}
}
pub const conformance = @import("conformance.zig");
test {
_ = conformance;
}
comptime {
contract.validateSystem(Nes);
}
pub fn videoFrame(
self: *const Nes,
output: usize,
) contract.VideoFrame {
std.debug.assert(output == 0);
return .{
.data = std.mem.sliceAsBytes(
self.bus.ppu.framebuffer[0..],
),
.width = 256,
.height = 240,
.pitch = 256 * @sizeOf(u16),
.format = .rgb565,
.frame_number = self.bus.ppu.frame_number,
};
}
pub fn audioBuffer(
self: *const Nes,
output: usize,
) contract.AudioBuffer {
std.debug.assert(output == 0);
const samples = self.bus.apu.sample_buffer[0..self.bus.apu.sample_count];
return .{
.data = std.mem.sliceAsBytes(samples),
.frames = self.bus.apu.sample_count,
.sample_rate = 44_100,
.channels = 1,
.format = .i16,
};
}
pub fn setInput(
self: *Nes,
device: usize,
input: contract.DeviceInput,
) void {
std.debug.assert(device < 2);
self.bus.controllers[device]
.setButtons(
@truncate(input.buttons),
);
}
pub fn storageView(
self: *const Nes,
slot: usize,
) contract.StorageView {
std.debug.assert(slot == 0);
return .{
.data = &self.bus.cartridge.prg_ram,
.generation = 1,
};
}
pub fn loadStorage(
self: *Nes,
slot: usize,
data: []const u8,
) contract.StorageLoadError!void {
if (slot != 0) return error.InvalidSlot;
if (data.len > self.bus.cartridge.prg_ram.len) return error.InvalidSize;
@memcpy(self.bus.cartridge.prg_ram[0..data.len], data);
}
fn serviceOamDma(
self: *Nes,
) void {
const page =
self.bus.takeOamDmaPage() orelse return;
const odd_cycle =
(self.cpu.cycles & 1) != 0;
// Mandatory DMA halt cycle.
self.cpu.tick();
// Alignment cycle when necessary.
if (odd_cycle)
self.cpu.tick();
const base =
@as(u16, page) << 8;
for (0..256) |i| {
// DMA read cycle.
const value =
self.cpu.readCycle(
base |
@as(u16, @intCast(i)),
);
// DMA write cycle.
self.bus.ppu.writeOamDma(value);
self.cpu.tick();
}
}
pub fn saveState(
self: *const Nes,
buffer: []u8,
) error{BufferTooSmall}!usize {
var offset: usize = 0;
if (buffer.len < 8) return error.BufferTooSmall;
@memcpy(buffer[offset..][0..8], "6SOZNES1");
offset += 8;
// CPU
if (offset + 15 > buffer.len) return error.BufferTooSmall;
std.mem.writeInt(u16, buffer[offset..][0..2], self.cpu.registers.pc, .little); offset += 2;
buffer[offset] = self.cpu.registers.a; offset += 1;
buffer[offset] = self.cpu.registers.x; offset += 1;
buffer[offset] = self.cpu.registers.y; offset += 1;
buffer[offset] = self.cpu.registers.sp; offset += 1;
buffer[offset] = @bitCast(self.cpu.registers.status); offset += 1;
std.mem.writeInt(u64, buffer[offset..][0..8], self.cpu.cycles, .little); offset += 8;
// Bus RAM & PRG RAM
if (offset + self.bus.ram.len + self.bus.cartridge.prg_ram.len > buffer.len) return error.BufferTooSmall;
@memcpy(buffer[offset..][0..self.bus.ram.len], &self.bus.ram); offset += self.bus.ram.len;
@memcpy(buffer[offset..][0..self.bus.cartridge.prg_ram.len], &self.bus.cartridge.prg_ram); offset += self.bus.cartridge.prg_ram.len;
if (self.bus.cartridge.chr_is_ram) {
if (offset + self.bus.cartridge.chr_ram.len > buffer.len) return error.BufferTooSmall;
@memcpy(buffer[offset..][0..self.bus.cartridge.chr_ram.len], &self.bus.cartridge.chr_ram); offset += self.bus.cartridge.chr_ram.len;
}
// PPU State
const ppu_size = 1 + 1 + 1 + 1 + 2 + 2 + 1 + 1 + 2 + 2 + 4096 + 32 + 256;
if (offset + ppu_size > buffer.len) return error.BufferTooSmall;
buffer[offset] = self.bus.ppu.ctrl; offset += 1;
buffer[offset] = self.bus.ppu.mask; offset += 1;
buffer[offset] = self.bus.ppu.status; offset += 1;
buffer[offset] = self.bus.ppu.oam_addr; offset += 1;
std.mem.writeInt(u16, buffer[offset..][0..2], self.bus.ppu.vram_addr, .little); offset += 2;
std.mem.writeInt(u16, buffer[offset..][0..2], self.bus.ppu.temp_addr, .little); offset += 2;
buffer[offset] = self.bus.ppu.fine_x; offset += 1;
buffer[offset] = if (self.bus.ppu.write_latch) 1 else 0; offset += 1;
std.mem.writeInt(u16, buffer[offset..][0..2], self.bus.ppu.dot, .little); offset += 2;
std.mem.writeInt(u16, buffer[offset..][0..2], self.bus.ppu.scanline, .little); offset += 2;
@memcpy(buffer[offset..][0..4096], &self.bus.ppu.nametable); offset += 4096;
@memcpy(buffer[offset..][0..32], &self.bus.ppu.palette); offset += 32;
@memcpy(buffer[offset..][0..256], &self.bus.ppu.oam); offset += 256;
return offset;
}
pub fn loadState(
self: *Nes,
buffer: []const u8,
) error{ InvalidState, BufferTooShort }!void {
var offset: usize = 0;
if (buffer.len < 8) return error.BufferTooShort;
if (!std.mem.eql(u8, buffer[0..8], "6SOZNES1")) return error.InvalidState;
offset += 8;
// CPU
if (offset + 15 > buffer.len) return error.BufferTooShort;
self.cpu.registers.pc = std.mem.readInt(u16, buffer[offset..][0..2], .little); offset += 2;
self.cpu.registers.a = buffer[offset]; offset += 1;
self.cpu.registers.x = buffer[offset]; offset += 1;
self.cpu.registers.y = buffer[offset]; offset += 1;
self.cpu.registers.sp = buffer[offset]; offset += 1;
self.cpu.registers.status = @bitCast(buffer[offset]); offset += 1;
self.cpu.cycles = std.mem.readInt(u64, buffer[offset..][0..8], .little); offset += 8;
// Bus RAM & PRG RAM
if (offset + self.bus.ram.len + self.bus.cartridge.prg_ram.len > buffer.len) return error.BufferTooShort;
@memcpy(&self.bus.ram, buffer[offset..][0..self.bus.ram.len]); offset += self.bus.ram.len;
@memcpy(&self.bus.cartridge.prg_ram, buffer[offset..][0..self.bus.cartridge.prg_ram.len]); offset += self.bus.cartridge.prg_ram.len;
if (self.bus.cartridge.chr_is_ram) {
if (offset + self.bus.cartridge.chr_ram.len > buffer.len) return error.BufferTooShort;
@memcpy(&self.bus.cartridge.chr_ram, buffer[offset..][0..self.bus.cartridge.chr_ram.len]); offset += self.bus.cartridge.chr_ram.len;
}
// PPU State
const ppu_size = 1 + 1 + 1 + 1 + 2 + 2 + 1 + 1 + 2 + 2 + 4096 + 32 + 256;
if (offset + ppu_size > buffer.len) return error.BufferTooShort;
self.bus.ppu.ctrl = buffer[offset]; offset += 1;
self.bus.ppu.mask = buffer[offset]; offset += 1;
self.bus.ppu.status = buffer[offset]; offset += 1;
self.bus.ppu.oam_addr = buffer[offset]; offset += 1;
self.bus.ppu.vram_addr = std.mem.readInt(u16, buffer[offset..][0..2], .little); offset += 2;
self.bus.ppu.temp_addr = std.mem.readInt(u16, buffer[offset..][0..2], .little); offset += 2;
self.bus.ppu.fine_x = @truncate(buffer[offset]); offset += 1;
self.bus.ppu.write_latch = buffer[offset] != 0; offset += 1;
self.bus.ppu.dot = std.mem.readInt(u16, buffer[offset..][0..2], .little); offset += 2;
self.bus.ppu.scanline = std.mem.readInt(u16, buffer[offset..][0..2], .little); offset += 2;
@memcpy(&self.bus.ppu.nametable, buffer[offset..][0..4096]); offset += 4096;
@memcpy(&self.bus.ppu.palette, buffer[offset..][0..32]); offset += 32;
@memcpy(&self.bus.ppu.oam, buffer[offset..][0..256]); offset += 256;
}
test "NES System - Save State Serialization Roundtrip" {
// Construct dummy iNES NROM header + 16K PRG + 8K CHR
var rom: [16 + 0x4000 + 0x2000]u8 = [_]u8{0} ** (16 + 0x4000 + 0x2000);
@memcpy(rom[0..4], "NES\x1a");
rom[4] = 1; // 16K PRG
rom[5] = 1; // 8K CHR
var nes: Nes = undefined;
try nes.init(&rom);
// Mutate state
nes.cpu.registers.pc = 0x1234;
nes.cpu.registers.a = 0x42;
nes.bus.ram[0x05] = 0xAA;
nes.bus.ppu.vram_addr = 0x2050;
var save_buf: [32768]u8 = undefined;
const bytes_written = try nes.saveState(&save_buf);
// Reset nes
nes.reset();
try std.testing.expect(nes.cpu.registers.pc != 0x1234);
// Load state
try nes.loadState(save_buf[0..bytes_written]);
try std.testing.expectEqual(@as(u16, 0x1234), nes.cpu.registers.pc);
try std.testing.expectEqual(@as(u8, 0x42), nes.cpu.registers.a);
try std.testing.expectEqual(@as(u8, 0xAA), nes.bus.ram[0x05]);
try std.testing.expectEqual(@as(u16, 0x2050), nes.bus.ppu.vram_addr);
}
comptime {
contract.validateSystem(Nes);
}
+5
View File
@@ -0,0 +1,5 @@
pub const nes = @import("nes/root.zig");
test {
_ = nes;
}