1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 08:55:38 +00:00

IR codegen: scroll, debug.log, debug.assert

Adds Scroll / DebugLog / DebugAssert variants to IrOp, wires them into
the IR lowering, optimizer liveness tracking, and IR codegen. Scroll
emits two PPU $2005 writes; debug statements emit writes to $4800 when
IrCodeGen::with_debug(true) is set, stripped otherwise. These were the
last feature gaps versus the AST codegen path, so IR codegen is now a
full replacement.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
This commit is contained in:
Claude 2026-04-12 10:43:53 +00:00
parent 8a6441071e
commit d609b77cd7
No known key found for this signature in database
5 changed files with 162 additions and 6 deletions

View file

@ -34,6 +34,11 @@ const TEMP_BASE: u8 = 0x80;
const ZP_FRAME_FLAG: u8 = 0x00;
const ZP_CURRENT_STATE: u8 = 0x03;
/// Emulator debug output port. Writes to this address are logged by
/// Mesen / fceux when the debugger is attached. Used by `debug.log`
/// and `debug.assert` when compiled with `--debug`.
const DEBUG_PORT: u16 = 0x4800;
/// IR codegen that produces 6502 instructions from an `IrProgram`.
pub struct IrCodeGen<'a> {
instructions: Vec<Instruction>,
@ -54,6 +59,9 @@ pub struct IrCodeGen<'a> {
/// True while generating code inside a state frame handler.
/// When set, `Return` terminators emit `JMP __ir_main_loop` instead of `RTS`.
in_frame_handler: bool,
/// When true, emit code for `debug.log` / `debug.assert`.
/// When false, these ops are stripped entirely.
debug_mode: bool,
_allocations: &'a [VarAllocation],
}
@ -79,10 +87,19 @@ impl<'a> IrCodeGen<'a> {
function_names,
next_oam_slot: 0,
in_frame_handler: false,
debug_mode: false,
_allocations: allocations,
}
}
/// Enable debug-mode code generation. When set, `debug.log` and
/// `debug.assert` emit runtime code; otherwise they are stripped.
#[must_use]
pub fn with_debug(mut self, debug: bool) -> Self {
self.debug_mode = debug;
self
}
fn function_exists(&self, name: &str) -> bool {
self.function_names.contains(name)
}
@ -442,6 +459,35 @@ impl<'a> IrCodeGen<'a> {
self.emit(JMP, AM::Label("__ir_main_loop".into()));
}
}
IrOp::Scroll(x, y) => {
// PPU scroll register $2005 takes two writes: X then Y
self.load_temp(*x);
self.emit(STA, AM::Absolute(0x2005));
self.load_temp(*y);
self.emit(STA, AM::Absolute(0x2005));
}
IrOp::DebugLog(args) => {
if self.debug_mode {
for arg in args {
self.load_temp(*arg);
self.emit(STA, AM::Absolute(DEBUG_PORT));
}
}
// In release mode, stripped entirely
}
IrOp::DebugAssert(cond) => {
if self.debug_mode {
// Load cond; if nonzero (true) skip; else halt
self.load_temp(*cond);
let pass_label = format!("__ir_assert_pass_{}", self.instructions.len());
self.emit(BNE, AM::LabelRelative(pass_label.clone()));
// Assertion failed: write marker to debug port and BRK
self.emit(LDA, AM::Immediate(0xFF));
self.emit(STA, AM::Absolute(DEBUG_PORT));
self.emit(BRK, AM::Implied);
self.emit_label(&pass_label);
}
}
IrOp::SourceLoc(_) => {
// No code for source location markers
}
@ -728,4 +774,85 @@ mod more_tests {
.any(|i| i.opcode == STA && i.mode == AM::ZeroPage(0x03));
assert!(has_store_state, "transition should write to current_state");
}
#[test]
fn ir_codegen_scroll_writes_ppu_register() {
let insts = lower_and_gen(
r#"
game "T" { mapper: NROM }
var sx: u8 = 0
var sy: u8 = 0
on frame { scroll(sx, sy) }
start Main
"#,
);
// Both X and Y scroll values should be written to $2005
let scroll_writes = insts
.iter()
.filter(|i| i.opcode == STA && i.mode == AM::Absolute(0x2005))
.count();
assert_eq!(scroll_writes, 2, "scroll should emit two STA $2005 writes");
}
fn lower_and_gen_debug(source: &str) -> Vec<Instruction> {
let (prog, _) = parser::parse(source);
let prog = prog.unwrap();
let analysis = analyzer::analyze(&prog);
let ir_program = ir::lower(&prog, &analysis);
IrCodeGen::new(&analysis.var_allocations, &ir_program)
.with_debug(true)
.generate(&ir_program)
}
#[test]
fn ir_codegen_debug_log_emits_in_debug_mode() {
let insts = lower_and_gen_debug(
r#"
game "T" { mapper: NROM }
var x: u8 = 42
on frame { debug.log(x) }
start Main
"#,
);
// Should write to the debug port $4800
let writes_debug_port = insts
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4800));
assert!(writes_debug_port, "debug.log should write to $4800");
}
#[test]
fn ir_codegen_debug_log_stripped_in_release() {
let insts = lower_and_gen(
r#"
game "T" { mapper: NROM }
var x: u8 = 42
on frame { debug.log(x) }
start Main
"#,
);
// No debug port writes in release mode
let writes_debug_port = insts
.iter()
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4800));
assert!(
!writes_debug_port,
"debug.log should be stripped in release mode"
);
}
#[test]
fn ir_codegen_debug_assert_emits_in_debug_mode() {
let insts = lower_and_gen_debug(
r#"
game "T" { mapper: NROM }
var x: u8 = 42
on frame { debug.assert(x == 42) }
start Main
"#,
);
// Should emit a BRK for the fail path
let has_brk = insts.iter().any(|i| i.opcode == BRK);
assert!(has_brk, "debug.assert should emit BRK on failure path");
}
}

View file

@ -310,16 +310,21 @@ impl LoweringContext {
let arg_temps: Vec<_> = args.iter().map(|a| self.lower_expr(a)).collect();
self.emit(IrOp::Call(None, name.clone(), arg_temps));
}
Statement::Scroll(_, _, _) => {
// TODO: implement scroll hardware writes
Statement::Scroll(x_expr, y_expr, _) => {
let x = self.lower_expr(x_expr);
let y = self.lower_expr(y_expr);
self.emit(IrOp::Scroll(x, y));
}
Statement::LoadBackground(_, _) | Statement::SetPalette(_, _) => {
// TODO: implement in asset pipeline
}
Statement::DebugLog(_, _) | Statement::DebugAssert(_, _) => {
// Debug statements don't produce IR ops in release mode.
// In debug mode, the AST-based codegen handles them directly
// (IR codegen path for debug is a future enhancement).
Statement::DebugLog(args, _) => {
let temps: Vec<_> = args.iter().map(|a| self.lower_expr(a)).collect();
self.emit(IrOp::DebugLog(temps));
}
Statement::DebugAssert(cond, _) => {
let t = self.lower_expr(cond);
self.emit(IrOp::DebugAssert(t));
}
}
}

View file

@ -129,6 +129,14 @@ pub enum IrOp {
ReadInput(IrTemp, u8),
WaitFrame,
Transition(String),
/// Write PPU scroll registers (two writes to $2005: X then Y).
Scroll(IrTemp, IrTemp),
/// Debug: write a list of temps to the emulator debug port ($4800).
/// Stripped in release mode by the codegen.
DebugLog(Vec<IrTemp>),
/// Debug: runtime assertion — if `cond` is zero, halt with debug marker.
/// Stripped in release mode by the codegen.
DebugAssert(IrTemp),
// Source mapping
SourceLoc(Span),

View file

@ -140,6 +140,7 @@ fn compile(input: &PathBuf, debug: bool, asm_dump: bool, use_ir: bool) -> Result
use nescript::codegen::IrCodeGen;
IrCodeGen::new(&analysis.var_allocations, &ir_program)
.with_sprites(&sprites)
.with_debug(debug)
.generate(&ir_program)
} else {
CodeGen::new(&analysis.var_allocations, &program.constants)

View file

@ -379,6 +379,18 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet<IrTemp>) {
used.insert(*f);
}
}
IrOp::Scroll(x, y) => {
used.insert(*x);
used.insert(*y);
}
IrOp::DebugLog(args) => {
for a in args {
used.insert(*a);
}
}
IrOp::DebugAssert(cond) => {
used.insert(*cond);
}
IrOp::ReadInput(_, _) | IrOp::WaitFrame | IrOp::Transition(_) | IrOp::SourceLoc(_) => {}
}
}
@ -412,6 +424,9 @@ fn op_dest(op: &IrOp) -> Option<IrTemp> {
| IrOp::DrawSprite { .. }
| IrOp::WaitFrame
| IrOp::Transition(_)
| IrOp::Scroll(_, _)
| IrOp::DebugLog(_)
| IrOp::DebugAssert(_)
| IrOp::SourceLoc(_) => None,
}
}