Sega Master System Implementation Guide
Target System: Sega Master System / Game Gear
Prerequisite Reading: NEXT_EMULATOR_RECOMMENDATION.md
This guide provides practical implementation steps for adding Sega Master System emulation to Hemulator.
Quick Reference
Hardware Specifications
| Component | Specification |
|---|---|
| CPU | Zilog Z80A @ 3.58 MHz (NTSC) / 3.55 MHz (PAL) |
| RAM | 8 KB main RAM |
| VRAM | 16 KB video RAM |
| VDP | Sega 315-5124 (SMS 1), 315-5246 (SMS 2) |
| PSG | Texas Instruments SN76489 (Sega variant SN76496) |
| Resolution | 256×192 pixels @ 60Hz (NTSC) / 50Hz (PAL) |
| Colors | 64 colors (6-bit RGB), 32 simultaneous |
| Sprites | 64 total, 8 per scanline |
| ROM Sizes | Typically 8KB to 512KB, up to 1MB with mappers |
Implementation Order
Follow this order to minimize dependencies and enable incremental testing:
1. Complete Z80 CPU (crates/core/src/cpu_z80.rs)
Current State: Stub with registers and basic structure
Required: Full instruction set implementation
Steps:
-
Study existing CPU implementations:
- Reference:
crates/core/src/cpu_6502.rs(similar structure) - Reference:
crates/core/src/cpu_lr35902.rs(Z80 derivative)
- Reference:
-
Implement base instructions (non-prefixed opcodes):
- Load/Store:
LD r,r',LD r,n,LD r,(HL), etc. - Arithmetic:
ADD,ADC,SUB,SBC,INC,DEC - Logic:
AND,OR,XOR,CP - Bit operations:
BIT,SET,RES - Jumps:
JP,JR,CALL,RET - Stack:
PUSH,POP
- Load/Store:
-
Implement prefixed instructions:
0xCBprefix: Bit operations0xEDprefix: Extended instructions0xDDprefix: IX index register operations0xFDprefix: IY index register operations
-
Implement interrupt system:
- IM 0, IM 1, IM 2 modes
- NMI handling
- IFF1/IFF2 flags
- SMS primarily uses IM 1 (jump to $0038)
-
Add cycle counting:
- Each instruction has specific cycle count
- Essential for accurate emulation timing
Testing:
#[cfg(test)]
mod tests {
use super::*;
// Simple test memory for CPU testing
struct TestMemory {
ram: Vec<u8>,
}
impl TestMemory {
fn new() -> Self {
Self { ram: vec![0; 0x10000] }
}
}
impl MemoryZ80 for TestMemory {
fn read(&self, addr: u16) -> u8 {
self.ram[addr as usize]
}
fn write(&mut self, addr: u16, val: u8) {
self.ram[addr as usize] = val;
}
}
#[test]
fn test_z80_ld_r_n() {
let mut mem = TestMemory::new();
// Load 0x42 into register A
mem.ram[0x0000] = 0x3E; // LD A, n opcode
mem.ram[0x0001] = 0x42; // immediate value
let mut cpu = CpuZ80::new(mem);
cpu.step();
assert_eq!(cpu.a, 0x42);
}
}
References:
- Z80 User Manual - Zilog
- Z80 Instruction Set
- Internal:
docs/references/cpu_z80.md
2. Create SN76489 PSG (crates/core/src/apu/sn76489.rs)
Architecture: 3 square wave channels + 1 noise channel
Steps:
- Define PSG state:
// Note: PulseChannel and NoiseChannel are reusable components from
// crates/core/src/apu/ - see existing NES and Atari 2600 implementations
use emu_core::apu::{PulseChannel, NoiseChannel};
pub struct Sn76489Psg {
// Tone generators (3 channels) - reuses existing PulseChannel component
tone_channels: [PulseChannel; 3],
// Noise generator - reuses existing NoiseChannel component
noise_channel: NoiseChannel,
// Volume registers (4-bit, 0=max, 15=min/mute)
volumes: [u8; 4],
// Frequency registers (10-bit for tones)
frequencies: [u16; 3],
// Noise control
noise_control: u8,
// Sample rate
sample_rate: u32,
clock_rate: u32,
}
- Implement register writes:
pub fn write(&mut self, data: u8) {
if data & 0x80 != 0 {
// Latch/data byte
let channel = (data >> 5) & 0x03;
let is_volume = (data >> 4) & 0x01;
if is_volume != 0 {
self.volumes[channel as usize] = data & 0x0F;
} else {
// Frequency data (low 4 bits)
// ...
}
} else {
// Data byte (6 bits)
// ...
}
}
- Implement audio generation:
pub fn sample(&mut self) -> f32 {
let mut output = 0.0;
// Mix tone channels
for i in 0..3 {
output += self.tone_channels[i].sample()
* volume_to_amplitude(self.volumes[i]);
}
// Mix noise channel
output += self.noise_channel.sample()
* volume_to_amplitude(self.volumes[3]);
output / 4.0 // Average
}
- Implement AudioChip trait:
impl AudioChip for Sn76489Psg {
fn write_register(&mut self, addr: u16, val: u8) {
// SMS writes to PSG at I/O port 0x7F
if addr == 0x7F {
self.write(val);
}
}
fn sample(&mut self) -> f32 {
self.sample()
}
fn reset(&mut self) {
// Reset all channels
}
}
Testing:
#[test]
fn test_psg_tone_output() {
let mut psg = Sn76489Psg::new(44100, 3579545);
// Set channel 0 to 440 Hz (A note)
// Frequency = Clock / (32 * Register)
let register_value = 3579545 / (32 * 440);
psg.write(0x80 | 0x00); // Latch tone 0, data type
psg.write(register_value as u8); // Low 4 bits
psg.write((register_value >> 4) as u8); // High 6 bits
psg.write(0x90); // Latch tone 0, volume = 0 (max)
let sample = psg.sample();
assert!(sample != 0.0);
}
References:
3. Implement VDP (crates/systems/sms/src/vdp.rs)
Architecture: Tilemap-based graphics with sprite overlay
Steps:
- Define VDP state:
pub struct Vdp {
// Video RAM (16KB)
vram: [u8; 0x4000],
// Color RAM (32 bytes for palette)
cram: [u8; 0x20],
// VDP registers (11 registers)
registers: [u8; 11],
// Internal state
address_register: u16,
code_register: u8,
read_buffer: u8,
write_latch: bool,
// Rendering
frame_buffer: Vec<u32>,
// Interrupts
frame_interrupt: bool,
line_interrupt: bool,
line_counter: u8,
}
- Implement VDP control port (port 0xBF):
pub fn write_control(&mut self, data: u8) {
if !self.write_latch {
// First byte
self.address_register = (self.address_register & 0x3F00)
| data as u16;
self.write_latch = true;
} else {
// Second byte
self.address_register = (self.address_register & 0x00FF)
| ((data as u16 & 0x3F) << 8);
self.code_register = (data >> 6) & 0x03;
self.write_latch = false;
// Check if register write
if self.code_register == 0x02 {
let reg = data & 0x0F;
if reg < 11 {
self.registers[reg as usize] =
(self.address_register & 0xFF) as u8;
}
}
}
}
- Implement VDP data port (port 0xBE):
pub fn write_data(&mut self, data: u8) {
self.write_latch = false;
self.read_buffer = data;
match self.code_register {
0x03 => {
// CRAM write
self.cram[(self.address_register & 0x1F) as usize] = data;
}
_ => {
// VRAM write
self.vram[(self.address_register & 0x3FFF) as usize] = data;
}
}
self.address_register = self.address_register.wrapping_add(1);
}
pub fn read_data(&mut self) -> u8 {
self.write_latch = false;
let value = self.read_buffer;
self.read_buffer = self.vram[(self.address_register & 0x3FFF) as usize];
self.address_register = self.address_register.wrapping_add(1);
value
}
- Implement background rendering:
fn render_background(&mut self, line: u8) {
let name_table_addr = ((self.registers[2] as u16) & 0x0E) << 10;
// Calculate scroll offsets
let scroll_x = self.registers[8];
let scroll_y = if line >= 16 && (self.registers[0] & 0x40) != 0 {
0 // Vertical scroll lock for top 2 rows
} else {
self.registers[9]
};
let y = line.wrapping_add(scroll_y);
let tile_row = (y >> 3) as u16;
for x in 0..256 {
let adj_x = x.wrapping_sub(scroll_x);
let tile_col = (adj_x >> 3) as u16;
// Read name table entry
let name_addr = name_table_addr + (tile_row * 32 + tile_col) * 2;
let tile_data = self.vram[name_addr as usize] as u16
| ((self.vram[(name_addr + 1) as usize] as u16) << 8);
let tile_index = tile_data & 0x1FF;
let palette_bit = (tile_data >> 11) & 1;
let priority = (tile_data >> 12) & 1;
let h_flip = (tile_data >> 9) & 1;
let v_flip = (tile_data >> 10) & 1;
// Render tile pixel
// ...
}
}
- Implement sprite rendering:
fn render_sprites(&mut self, line: u8) {
let sprite_attr_table = ((self.registers[5] as u16) & 0x7E) << 7;
let sprite_size = if (self.registers[1] & 0x02) != 0 { 16 } else { 8 };
let mut sprites_on_line = 0;
for i in 0..64 {
let y = self.vram[(sprite_attr_table + i) as usize];
// Check if sprite is on this line
if y == 0xD0 { break; } // End marker
let y_pos = y.wrapping_add(1);
if line >= y_pos && line < y_pos + sprite_size {
sprites_on_line += 1;
if sprites_on_line > 8 {
// Sprite overflow flag
break;
}
// Render sprite
// ...
}
}
}
- Implement Renderer trait:
impl Renderer for Vdp {
fn get_frame(&self) -> &[u32] {
&self.frame_buffer
}
fn clear(&mut self) {
self.frame_buffer.fill(0);
}
fn reset(&mut self) {
self.vram.fill(0);
self.cram.fill(0);
self.registers.fill(0);
self.clear();
}
fn resize(&mut self, _width: u32, _height: u32) {
// SMS has fixed resolution
}
fn name(&self) -> &str {
"SMS VDP"
}
}
Testing:
#[test]
fn test_vdp_register_write() {
let mut vdp = Vdp::new();
// Write to register 0
vdp.write_control(0x00); // Low byte
vdp.write_control(0x80); // High byte (register write, reg 0)
assert_eq!(vdp.registers[0], 0x00);
}
References:
4. Build System Integration (crates/systems/sms/)
Steps:
- Create system structure:
// SmsMemory wraps the system state for Z80 memory access
pub struct SmsMemory {
rom: Vec<u8>,
ram: [u8; 0x2000], // 8KB
vdp: Rc<RefCell<Vdp>>,
psg: Rc<RefCell<Sn76489Psg>>,
// ... other shared state
}
pub struct SmsSystem {
cpu: CpuZ80<SmsMemory>,
vdp: Rc<RefCell<Vdp>>,
psg: Rc<RefCell<Sn76489Psg>>,
// I/O
io_control: u8,
// Timing
cycles: u64,
}
- Implement memory map:
// SmsMemory implements MemoryZ80 trait to provide CPU access to system resources
impl MemoryZ80 for SmsMemory {
fn read(&self, addr: u16) -> u8 {
match addr {
0x0000..=0xBFFF => {
// ROM (up to 48KB direct mapped)
self.rom.get(addr as usize).copied().unwrap_or(0xFF)
}
0xC000..=0xDFFF => {
// RAM (8KB mirrored)
self.ram[(addr & 0x1FFF) as usize]
}
0xE000..=0xFFFF => {
// RAM mirror
self.ram[(addr & 0x1FFF) as usize]
}
_ => 0xFF,
}
}
fn write(&mut self, addr: u16, val: u8) {
match addr {
0xC000..=0xFFFF => {
self.ram[(addr & 0x1FFF) as usize] = val;
}
_ => {}
}
}
fn io_read(&mut self, port: u8) -> u8 {
match port {
0x7E | 0x7F => {
// VDP vertical counter
self.vdp.read_vcounter()
}
0xBE => self.vdp.read_data(),
0xBF => self.vdp.read_status(),
0xDC | 0xDD => {
// Controller ports
self.read_controller(port)
}
_ => 0xFF,
}
}
fn io_write(&mut self, port: u8, val: u8) {
match port {
0x7E | 0x7F => self.psg.write(val),
0xBE => self.vdp.write_data(val),
0xBF => self.vdp.write_control(val),
0x3E => {
// Memory control (banking)
self.memory_control = val;
}
_ => {}
}
}
}
- Implement System trait:
impl System for SmsSystem {
fn reset(&mut self) {
self.cpu.reset();
self.vdp.reset();
self.psg.reset();
self.cycles = 0;
}
fn step_frame(&mut self) -> (Frame, Vec<f32>) {
let target_cycles = 59659; // ~3.58 MHz / 60 Hz
while self.cycles < target_cycles {
// Execute CPU instruction
let cpu_cycles = self.cpu.step() as u64;
self.cycles += cpu_cycles;
// Update VDP (3 pixels per CPU cycle)
for _ in 0..(cpu_cycles * 3) {
self.vdp.step();
}
// Generate audio samples
// ...
}
self.cycles -= target_cycles;
let frame = Frame::from_buffer(
self.vdp.get_frame(),
256,
192,
);
(frame, self.audio_buffer.drain(..).collect())
}
fn name(&self) -> &str {
"Sega Master System"
}
}
5. ROM Loading and Detection
ROM Format:
- Most ROMs are headerless raw binaries
- Some have 512-byte header (TMR SEGA format)
- Typical sizes: 8KB, 16KB, 32KB, 48KB, 64KB, 128KB, 256KB, 512KB
Detection:
pub fn detect_sms_rom(data: &[u8]) -> bool {
// Check for TMR SEGA header
if data.len() >= 512 + 0x7FF0 {
let header_offset = if data.len() >= 512 + 0x7FF0 + 16 {
512 // Has header
} else {
0 // Headerless
};
let sig_offset = header_offset + 0x7FF0;
if sig_offset + 16 <= data.len() {
let signature = &data[sig_offset..sig_offset + 8];
if signature == b"TMR SEGA" {
return true;
}
}
}
// Check size (common SMS ROM sizes)
matches!(data.len(),
8192 | 16384 | 32768 | 49152 | 65536 |
131072 | 262144 | 524288)
}
6. Banking Support
SMS uses simple paging for ROMs > 48KB:
impl SmsMemory {
fn update_banking(&mut self) {
// Paging registers at 0xFFFC, 0xFFFD, 0xFFFE
// Note: In production code, validate indices are within RAM bounds
let frame_0 = self.ram[0x1FFC] as usize; // Safe: 0x1FFC < 0x2000 (8KB RAM)
let frame_1 = self.ram[0x1FFD] as usize;
let frame_2 = self.ram[0x1FFE] as usize;
// Map 16KB banks (modulo ensures within valid range)
self.rom_bank_0 = frame_0 % self.num_banks;
self.rom_bank_1 = frame_1 % self.num_banks;
self.rom_bank_2 = frame_2 % self.num_banks;
}
}
Testing Strategy
1. Unit Tests
- CPU instruction tests (per opcode)
- PSG channel tests (frequency, volume)
- VDP register tests
2. Test ROM
Create minimal test ROM in test_roms/sms/:
; test.asm - SMS test ROM
.org $0000
di ; Disable interrupts
ld sp, $DFF0 ; Set stack pointer
; Initialize VDP
ld hl, vdp_init
ld b, 11
ld c, $BF
init_loop:
ld a, (hl)
out (c), a
inc hl
djnz init_loop
; Main loop
main_loop:
halt
jr main_loop
vdp_init:
.db $04, $80 ; Reg 0: mode control 1
.db $00, $81 ; Reg 1: mode control 2
; ...more registers
3. Integration Test
#[test]
fn smoke_test_sms() {
let rom = include_bytes!("../../test_roms/sms/test.sms");
let mut system = SmsSystem::new(rom.to_vec());
system.reset();
let (frame, _audio) = system.step_frame();
assert_eq!(frame.width, 256);
assert_eq!(frame.height, 192);
// Verify expected pixel pattern
}
Common Pitfalls
- Z80 Flags: Carefully implement all flag behaviors (especially Half-Carry)
- VDP Timing: Line interrupts occur at specific scanline positions
- PSG Noise: Sega variant uses 16-bit LFSR (not 15-bit like original)
- Banking: Some games expect specific initial bank configuration
- Controller Reading: Must handle both ports 0xDC and 0xDD
Game Gear Differences
Once SMS works, add Game Gear support:
pub struct GameGearSystem {
base: SmsSystem, // Reuse SMS implementation
gg_start_button: bool,
}
impl GameGearSystem {
fn convert_color(&self, sms_color: u8) -> u32 {
// Game Gear uses 12-bit color (4096 colors)
// vs SMS 6-bit (64 colors)
let r = (sms_color & 0x03) << 2;
let g = ((sms_color >> 2) & 0x03) << 2;
let b = ((sms_color >> 4) & 0x03) << 2;
let r32 = ((r << 4) | r) as u32;
let g32 = ((g << 4) | g) as u32;
let b32 = ((b << 4) | b) as u32;
(r32 << 16) | (g32 << 8) | b32
}
}
Resources
Essential Documentation
- SMS Power! Development Portal
- Charles MacDonald's SMS Documentation
- Rodrigo Copetti's SMS Architecture
Reference Emulators
- Gearsystem - Open source, good reference
- Emulicious - Excellent debugger
Test ROMs
- SMS Power! Homebrew
- Create your own minimal test ROMs
Next Steps: Once comfortable with this guide, start with Phase 1 (Z80 CPU) and work incrementally through each phase, testing thoroughly at each step.