From 359241d906b6f32a3b5c49bf299548cc80b0f4ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 16:22:54 +0000 Subject: [PATCH] Codegen: MMC3 on_scanline IRQ dispatch (minimal) Wires \`on scanline(N)\` handlers through IR lowering and codegen: - IR lowering: each scanline handler becomes a regular IR function named \`{state}_scanline_{N}\` - IR codegen: when any scanline handler exists, emits MMC3 IRQ setup at program start (\$C000 latch, \$C001 reload, \$E001 enable, CLI) and a \`__irq_user\` handler that saves registers, acknowledges via \$E000, JSRs the scanline handler, restores registers, and RTIs - Linker: vector table prefers \`__irq_user\` over the default \`__irq\` stub when both exist Scope of this first pass is intentionally minimal: supports ONE scanline handler per program (the first one found in IR function order). Per-state dispatch and multi-scanline reload will come later. https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3 --- src/codegen/ir_codegen.rs | 91 +++++++++++++++++++++++++++++++++++++++ src/ir/lowering.rs | 8 ++++ src/linker/mod.rs | 11 ++++- tests/integration_test.rs | 15 +++++++ 4 files changed, 123 insertions(+), 2 deletions(-) diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index fc63230..0c91371 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -185,6 +185,22 @@ impl<'a> IrCodeGen<'a> { } } + // 2b. If the program has any `on scanline` handlers, set up + // the MMC3 IRQ counter. We pick the first scanline handler's + // line count as the latch value. Only supports a single- + // scanline-per-program setup for now. + let scanline_info = find_first_scanline_handler(ir); + if let Some((_state_name, line)) = &scanline_info { + // Write (line-1) to $C000 (scanline latch), any value to + // $C001 (reload counter), any value to $E001 (enable IRQ). + self.emit(LDA, AM::Immediate(line.saturating_sub(1))); + self.emit(STA, AM::Absolute(0xC000)); + self.emit(STA, AM::Absolute(0xC001)); + self.emit(STA, AM::Absolute(0xE001)); + // Enable interrupts (CLI) so the IRQ can fire. + self.emit(CLI, AM::Implied); + } + // 3. Main dispatch loop let main_loop = "__ir_main_loop".to_string(); self.emit_label(&main_loop); @@ -218,6 +234,36 @@ impl<'a> IrCodeGen<'a> { self.gen_function(func); } + // 5. If we have scanline handlers, emit an IRQ handler that + // saves registers, ACKs the MMC3 IRQ, JSRs into the first + // scanline handler, restores registers, and RTIs. The linker + // picks up `__irq_user` and uses it as the IRQ vector instead + // of the default stub. + if let Some((state_name, line)) = scanline_info { + self.emit_label("__irq_user"); + // Save registers onto the stack. + self.emit(PHA, AM::Implied); + self.emit(TXA, AM::Implied); + self.emit(PHA, AM::Implied); + self.emit(TYA, AM::Implied); + self.emit(PHA, AM::Implied); + // Acknowledge and reload the MMC3 IRQ counter. + self.emit(STA, AM::Absolute(0xE000)); // disable / ack + self.emit(STA, AM::Absolute(0xE001)); // re-enable + // JSR into the scanline handler. We don't dispatch by + // state yet — the first scanline handler found is used + // unconditionally. + let handler = format!("{state_name}_scanline_{line}"); + self.emit(JSR, AM::Label(format!("__ir_fn_{handler}"))); + // Restore registers and return from interrupt. + self.emit(PLA, AM::Implied); + self.emit(TAY, AM::Implied); + self.emit(PLA, AM::Implied); + self.emit(TAX, AM::Implied); + self.emit(PLA, AM::Implied); + self.emit(RTI, AM::Implied); + } + self.instructions } @@ -583,6 +629,22 @@ enum CmpKind { GtEq, } +/// Scan the IR functions for the first scanline handler (named +/// `{state}_scanline_{line}`) and return the state name and line. +fn find_first_scanline_handler(ir: &IrProgram) -> Option<(String, u8)> { + for func in &ir.functions { + // Match the pattern `_scanline_` by splitting on + // the last `_scanline_`. This keeps it simple and avoids + // re-threading scanline info through lowering. + if let Some((state_name, tail)) = func.name.rsplit_once("_scanline_") { + if let Ok(line) = tail.parse::() { + return Some((state_name.to_string(), line)); + } + } + } + None +} + #[cfg(test)] mod tests { use super::*; @@ -768,6 +830,35 @@ mod more_tests { assert!(writes_slot1, "second draw should use OAM slot 1"); } + #[test] + fn ir_codegen_scanline_emits_mmc3_setup_and_irq_user() { + let insts = lower_and_gen( + r#" + game "T" { mapper: MMC3 } + state Main { + on frame { wait_frame } + on scanline(100) { wait_frame } + } + start Main + "#, + ); + // MMC3 latch write (LDA #99; STA $C000) + let has_latch = insts + .iter() + .any(|i| i.opcode == STA && i.mode == AM::Absolute(0xC000)); + assert!(has_latch, "should write to MMC3 latch $C000"); + // __irq_user label should be emitted + let has_irq_user = insts + .iter() + .any(|i| matches!(&i.mode, AM::Label(l) if l == "__irq_user")); + assert!(has_irq_user, "should emit __irq_user label"); + // The scanline handler function should exist + let has_handler = insts + .iter() + .any(|i| matches!(&i.mode, AM::Label(l) if l == "__ir_fn_Main_scanline_100")); + assert!(has_handler, "should emit scanline handler function"); + } + #[test] fn ir_codegen_transition_writes_state_index() { let insts = lower_and_gen( diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 75effe9..2fb39b7 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -215,6 +215,14 @@ impl LoweringContext { if let Some(on_frame) = &state.on_frame { self.lower_handler(&format!("{}_frame", state.name), on_frame, state); } + + // Lower each scanline handler as a function named + // `{state}_scanline_{N}`. The IR codegen will generate the MMC3 + // IRQ dispatch wrapper separately. + for (line, block) in &state.on_scanline { + let name = format!("{}_scanline_{line}", state.name); + self.lower_handler(&name, block, state); + } } fn lower_handler(&mut self, name: &str, block: &Block, state: &StateDecl) { diff --git a/src/linker/mod.rs b/src/linker/mod.rs index 58b76a2..cec59ff 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -130,10 +130,17 @@ impl Linker { } prg.resize(vector_offset, 0xFF); - // Write vector table + // Write vector table. IR codegen emits a richer IRQ handler + // under `__irq_user` when the program has scanline handlers; + // prefer that over the generic RTI stub at `__irq`. let nmi_addr = result.labels.get("__nmi").copied().unwrap_or(0xC000); let reset_addr = result.labels.get("__reset").copied().unwrap_or(0xC000); - let irq_addr = result.labels.get("__irq").copied().unwrap_or(0xC000); + let irq_addr = result + .labels + .get("__irq_user") + .or_else(|| result.labels.get("__irq")) + .copied() + .unwrap_or(0xC000); prg.extend_from_slice(&nmi_addr.to_le_bytes()); prg.extend_from_slice(&reset_addr.to_le_bytes()); diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 612c272..5f7d253 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -141,6 +141,21 @@ fn program_with_functions() { rom::validate_ines(&rom_data).expect("should be valid iNES"); } +#[test] +fn program_with_on_scanline_mmc3() { + let source = r#" + game "Scanline" { mapper: MMC3 } + var sx: u8 = 0 + state Main { + on frame { wait_frame } + on scanline(120) { scroll(sx, 0) } + } + start Main + "#; + let rom_data = compile(source); + rom::validate_ines(&rom_data).expect("should be valid iNES"); +} + #[test] fn program_with_structs() { let source = r#"