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

compiler: audio driver, u16 arithmetic, multi-scanline, slot recycling

Five language features and optimizations from the planned-work backlog:

- **Minimal audio driver**: `play`/`start_music`/`stop_music` now generate
  APU pulse-1/pulse-2 writes from a builtin SFX/music name table, and
  the NMI handler gains a `JSR __audio_tick` splice (via the linker's
  `__audio_used` marker lookup) that ages an SFX countdown counter and
  mutes pulse 1 when the tone expires. Programs that never trigger
  audio pay zero ROM cost.

- **u16 arithmetic and comparisons**: new IR ops `LoadVarHi`, `StoreVarHi`,
  `Add16`, `Sub16`, and six `Cmp*16` variants. The lowering context
  tracks variable types via the analyzer's symbol table and routes
  expressions through the 8-bit or 16-bit path based on operand width.
  Add16 emits `CLC;ADC;ADC` with carry propagating naturally into the
  high byte; compares dispatch high-byte-first with a short-circuit
  low-byte fallback. Fixes a silent miscompile where `big += 1` on a
  u16 var only incremented the low byte.

- **Multi-scanline handlers per state**: `gen_scanline_irq` now
  dispatches on `(current_state, ZP_SCANLINE_STEP)` and reloads the
  MMC3 counter with the delta to the next scanline in the same state.
  `gen_scanline_reload` resets the step counter at the top of each
  NMI so a state with multiple handlers fires them in ascending line
  order. Previously only the first handler per state ever fired.

- **IR temp slot recycling**: `build_use_counts` pre-scans each
  function to count per-temp uses; `retire_op_sources` decrements
  the counts after each op and pushes dead slots back onto
  `free_slots` for later allocation. `bitwise_ops.ne` used to crash
  (debug) or miscompile (release) once it hit 128 concurrent temps;
  with recycling the same function now uses ~4 slots instead of 136.

- **INC/DEC peephole fold + improved dead-load elimination**:
  `fold_inc_dec` collapses `LDA addr; CLC; ADC #1; STA addr` into
  a single `INC addr` (and the SEC/SBC variant into `DEC addr`),
  saving 5 bytes and 5 cycles per increment. The fold is suppressed
  when the next instruction reads carry. `remove_dead_loads` now
  walks past INC/DEC/STX/STY (which don't touch A) to find the
  actual next A-use, catching more dead loads.

Tests: 331 unit + 39 integration (up from 313 + 37), including new
guards for audio, u16, multi-scanline, and slot recycling.

https://claude.ai/code/session_01A8qk3gw2jWSzdiXBZPZSFE
This commit is contained in:
Claude 2026-04-12 22:21:53 +00:00
parent 9ebf58f7db
commit 9a539ea068
No known key found for this signature in database
12 changed files with 2108 additions and 144 deletions

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,7 @@ pub fn optimize(instructions: &mut Vec<Instruction>) {
remove_redundant_loads(instructions);
fold_branch_over_jmp(instructions);
remove_jmp_to_next_label(instructions);
fold_inc_dec(instructions);
// Stop when no pass removed an instruction *and* the stream
// is unchanged. Copy propagation doesn't shrink the stream —
// it rewrites operands — so we need the content check too.
@ -28,6 +29,78 @@ pub fn optimize(instructions: &mut Vec<Instruction>) {
}
}
/// Fold `LDA addr; CLC; ADC #1; STA addr` into `INC addr`, and
/// `LDA addr; SEC; SBC #1; STA addr` into `DEC addr`. Both are
/// shorter (2 bytes vs 7) and faster (5 cycles vs 10) than the
/// ADC/SBC variants.
///
/// Safety: INC/DEC leave the carry flag alone, whereas the ADC
/// version clears it via CLC first and then consumes+produces a
/// new carry. The pattern we fold explicitly uses `CLC; ADC #1`
/// (so the incoming carry is discarded) and the STA commits the
/// result without reading flags, so anyone downstream relying on
/// the Z/N flags still gets the right flags from the INC/DEC —
/// both ops update N and Z from the new value just like ADC/SBC
/// would. Any downstream code reading the carry flag from the
/// original ADC/SBC would be depending on +1/-1 wrap arithmetic,
/// which the folded form can't preserve; we conservatively
/// require the pattern to be followed by an instruction that
/// isn't a carry-reading branch.
fn fold_inc_dec(instructions: &mut Vec<Instruction>) {
let mut out = Vec::with_capacity(instructions.len());
let mut idx = 0;
while idx < instructions.len() {
if idx + 3 < instructions.len() {
let lda = &instructions[idx];
let carry_op = &instructions[idx + 1];
let adc_or_sbc = &instructions[idx + 2];
let sta = &instructions[idx + 3];
// Must start with `LDA <addr>`.
let lda_addr = match (lda.opcode, &lda.mode) {
(Opcode::LDA, AddressingMode::ZeroPage(addr)) => {
Some(AddressingMode::ZeroPage(*addr))
}
(Opcode::LDA, AddressingMode::Absolute(addr)) => {
Some(AddressingMode::Absolute(*addr))
}
_ => None,
};
// STA of the same address.
let sta_matches = sta.opcode == Opcode::STA && Some(sta.mode.clone()) == lda_addr;
let is_clc_adc_1 = carry_op.opcode == Opcode::CLC
&& adc_or_sbc.opcode == Opcode::ADC
&& adc_or_sbc.mode == AddressingMode::Immediate(1);
let is_sec_sbc_1 = carry_op.opcode == Opcode::SEC
&& adc_or_sbc.opcode == Opcode::SBC
&& adc_or_sbc.mode == AddressingMode::Immediate(1);
// Only fold if the next instruction after the STA
// doesn't rely on the ADC's carry output. A BCC/BCS
// right after the pattern would break; anything else
// (including "no next instruction") is safe.
let next_is_carry_branch = instructions
.get(idx + 4)
.is_some_and(|n| matches!(n.opcode, Opcode::BCC | Opcode::BCS));
if let Some(addr) = lda_addr {
if sta_matches && !next_is_carry_branch {
if is_clc_adc_1 {
out.push(Instruction::new(Opcode::INC, addr));
idx += 4;
continue;
}
if is_sec_sbc_1 {
out.push(Instruction::new(Opcode::DEC, addr));
idx += 4;
continue;
}
}
}
}
out.push(instructions[idx].clone());
idx += 1;
}
*instructions = out;
}
/// Fold `Bxx label1; JMP label2; label1:` into `Byy label2`, where
/// `Byy` is the inversion of `Bxx`. This is emitted by the IR codegen
/// for every `if` statement without an else clause — the `BNE taken;
@ -170,26 +243,49 @@ fn remove_dead_loads(instructions: &mut Vec<Instruction>) {
if inst.opcode != Opcode::LDA {
continue;
}
// Find the next instruction that isn't a label definition.
// Walk forward looking for the next instruction that either
// reads A or overwrites it. If the first such instruction
// overwrites A without reading it, our LDA is dead.
//
// We can safely step across instructions that neither read
// nor write A — INC, DEC, STX, STY on memory, and label
// definitions — because they don't observe A and don't
// clobber it either. We must NOT step over any branch or
// jump: control flow at that point can reach code that
// reads A via a different path, and our local analysis
// can't see it.
let mut j = i + 1;
let mut dead = false;
while j < instructions.len() {
let next = &instructions[j];
// Label definitions are passive markers; skip over them.
// Labels are passive markers.
if next.opcode == Opcode::NOP && matches!(next.mode, AddressingMode::Label(_)) {
j += 1;
continue;
}
// Instructions that don't touch A — skip over them.
// INC/DEC on memory, STX/STY — all leave A alone.
if matches!(
next.opcode,
Opcode::INC | Opcode::DEC | Opcode::STX | Opcode::STY
) && !matches!(next.mode, AddressingMode::Accumulator)
{
j += 1;
continue;
}
// Instructions that overwrite A without reading it.
if matches!(
next.opcode,
Opcode::LDA | Opcode::PLA | Opcode::TXA | Opcode::TYA
) {
dead = true;
}
// Any other instruction — might read A (STA, ADC,
// SBC, AND, …) or transfer control (JMP, Bxx, JSR,
// RTS) — stop scanning and leave the LDA alone.
break;
}
if j >= instructions.len() {
continue;
}
let next = &instructions[j];
if matches!(
next.opcode,
Opcode::LDA | Opcode::PLA | Opcode::TXA | Opcode::TYA
) {
// A is about to be overwritten without being used.
if dead {
keep[i] = false;
}
}
@ -1031,4 +1127,69 @@ mod tests {
"both TAXes must be preceded by a fresh LDA #0: {insts:?}"
);
}
#[test]
fn folds_lda_clc_adc_sta_into_inc() {
// `LDA $10; CLC; ADC #1; STA $10` is a 4-instruction
// sequence (7 bytes, 10 cycles) that can be a single
// `INC $10` (2 bytes, 5 cycles). The peephole fold catches
// this for both zero-page and absolute addressing.
let mut insts = vec![
Instruction::new(LDA, AM::ZeroPage(0x10)),
Instruction::new(CLC, AM::Implied),
Instruction::new(ADC, AM::Immediate(1)),
Instruction::new(STA, AM::ZeroPage(0x10)),
Instruction::new(RTS, AM::Implied),
];
optimize(&mut insts);
let has_inc = insts
.iter()
.any(|i| i.opcode == INC && i.mode == AM::ZeroPage(0x10));
assert!(has_inc, "should fold to INC $10: {insts:?}");
// None of the original 4 instructions should survive.
assert!(!insts.iter().any(|i| i.opcode == CLC));
assert!(!insts.iter().any(|i| i.opcode == ADC));
}
#[test]
fn folds_lda_sec_sbc_sta_into_dec() {
let mut insts = vec![
Instruction::new(LDA, AM::Absolute(0x0300)),
Instruction::new(SEC, AM::Implied),
Instruction::new(SBC, AM::Immediate(1)),
Instruction::new(STA, AM::Absolute(0x0300)),
Instruction::new(RTS, AM::Implied),
];
optimize(&mut insts);
let has_dec = insts
.iter()
.any(|i| i.opcode == DEC && i.mode == AM::Absolute(0x0300));
assert!(has_dec, "should fold to DEC $0300: {insts:?}");
assert!(!insts.iter().any(|i| i.opcode == SEC));
assert!(!insts.iter().any(|i| i.opcode == SBC));
}
#[test]
fn inc_fold_preserves_when_followed_by_carry_branch() {
// If the next instruction after the STA is a carry-dependent
// branch (BCC/BCS), the ADC/SBC → INC/DEC fold would change
// observable semantics — INC doesn't touch carry. Preserve
// the original sequence in that case.
let mut insts = vec![
Instruction::new(LDA, AM::ZeroPage(0x10)),
Instruction::new(CLC, AM::Implied),
Instruction::new(ADC, AM::Immediate(1)),
Instruction::new(STA, AM::ZeroPage(0x10)),
Instruction::new(BCC, AM::LabelRelative("skip".into())),
Instruction::new(NOP, AM::Label("skip".into())),
Instruction::new(RTS, AM::Implied),
];
optimize(&mut insts);
// The ADC must still be present because the carry it
// produces is consumed by the BCC.
assert!(
insts.iter().any(|i| i.opcode == ADC),
"ADC must survive when followed by a carry-dependent branch: {insts:?}"
);
}
}

View file

@ -22,6 +22,10 @@ struct LoweringContext {
rom_data: Vec<IrRomBlock>,
var_map: HashMap<String, VarId>,
const_values: HashMap<String, u16>,
/// Type of each named variable (resolved from the analyzer's
/// symbol table). Used to decide between 8-bit and 16-bit IR
/// ops for identifier reads/writes and binary operations.
var_types: HashMap<String, NesType>,
next_var_id: u32,
next_temp: u32,
next_block: u32,
@ -35,6 +39,13 @@ struct LoweringContext {
// State metadata captured from the AST
state_names: Vec<String>,
start_state: String,
/// Map from a byte temp (used as the "low byte" of a wide
/// value) to the matching high byte temp. Temps not in the
/// map are plain 8-bit byte temps. Populated by
/// `lower_expr_wide` when it produces a u16 result; consumed
/// by binary-op, compare, and assignment lowering when they
/// need to decide between `Add`/`Add16`, etc.
wide_hi: HashMap<IrTemp, IrTemp>,
}
struct LoopContext {
@ -53,12 +64,23 @@ impl LoweringContext {
next_var_id += 1;
}
// Capture the type of each named variable from the
// analyzer's symbol table. This lets the lowering decide
// whether an identifier read should expand to a Byte or
// Word value — which in turn controls whether binary ops
// emit 8-bit or 16-bit IR.
let mut var_types = HashMap::new();
for (name, sym) in &analysis.symbols {
var_types.insert(name.clone(), sym.sym_type.clone());
}
Self {
functions: Vec::new(),
globals: Vec::new(),
rom_data: Vec::new(),
var_map,
const_values: HashMap::new(),
var_types,
next_var_id,
next_temp: 0,
next_block: 0,
@ -69,9 +91,17 @@ impl LoweringContext {
loop_stack: Vec::new(),
state_names: Vec::new(),
start_state: String::new(),
wide_hi: HashMap::new(),
}
}
/// Register a function parameter's type in the `var_types` map
/// so that identifier reads inside the function body know
/// whether to load as a byte or a word.
fn register_param_type(&mut self, name: &str, ty: &NesType) {
self.var_types.insert(name.to_string(), ty.clone());
}
fn fresh_temp(&mut self) -> IrTemp {
let t = IrTemp(self.next_temp);
self.next_temp += 1;
@ -254,6 +284,7 @@ impl LoweringContext {
name: param.name.clone(),
size: type_size(&param.param_type),
});
self.register_param_type(&param.name, &param.param_type);
}
let entry = self.fresh_label(&format!("fn_{}_entry", fun.name));
@ -362,6 +393,10 @@ impl LoweringContext {
size: type_size(&var.var_type),
});
}
// Seed the var_types map for local declarations so
// subsequent references lower with the right width.
self.var_types
.insert(var.name.clone(), var.var_type.clone());
if let Some(init) = &var.init {
// Struct literal initializers expand to per-field
// stores on the synthetic field variables.
@ -375,6 +410,12 @@ impl LoweringContext {
} else {
let val = self.lower_expr(init);
self.emit(IrOp::StoreVar(var_id, val));
// u16 var: write the high byte too, zero-
// extending narrow initializers.
if matches!(var.var_type, NesType::U16) {
let (_, hi) = self.widen(val);
self.emit(IrOp::StoreVarHi(var_id, hi));
}
}
}
}
@ -505,9 +546,14 @@ impl LoweringContext {
// adding a separate IrOp.
self.emit(IrOp::InlineAsm(format!("{RAW_ASM_PREFIX}{body}")));
}
Statement::Play(_, _) | Statement::StartMusic(_, _) | Statement::StopMusic(_) => {
// No audio driver yet — these parse but produce no
// IR.
Statement::Play(name, _) => {
self.emit(IrOp::PlaySfx(name.clone()));
}
Statement::StartMusic(name, _) => {
self.emit(IrOp::StartMusic(name.clone()));
}
Statement::StopMusic(_) => {
self.emit(IrOp::StopMusic);
}
}
}
@ -531,28 +577,82 @@ impl LoweringContext {
match lvalue {
LValue::Var(name) => {
let var_id = self.get_or_create_var(name);
// Is the destination a u16 variable? Wide vars need
// both bytes written on every assignment, otherwise
// the high byte silently stays stale.
let dest_is_u16 = matches!(self.var_types.get(name), Some(NesType::U16));
match op {
AssignOp::Assign => {
let val = self.lower_expr(expr);
self.emit(IrOp::StoreVar(var_id, val));
if dest_is_u16 {
// Narrow value: zero-extend.
let (_, val_hi) = self.widen(val);
self.emit(IrOp::StoreVarHi(var_id, val_hi));
}
}
_ => {
// Load current value. For u16, load both bytes
// and register as wide so binary-op lowering
// uses the 16-bit path.
let current = self.fresh_temp();
self.emit(IrOp::LoadVar(current, var_id));
if dest_is_u16 {
let current_hi = self.fresh_temp();
self.emit(IrOp::LoadVarHi(current_hi, var_id));
self.make_wide(current, current_hi);
}
let rhs = self.lower_expr(expr);
let result = self.fresh_temp();
let ir_op = match op {
AssignOp::PlusAssign => IrOp::Add(result, current, rhs),
AssignOp::MinusAssign => IrOp::Sub(result, current, rhs),
AssignOp::AmpAssign => IrOp::And(result, current, rhs),
AssignOp::PipeAssign => IrOp::Or(result, current, rhs),
AssignOp::CaretAssign => IrOp::Xor(result, current, rhs),
AssignOp::ShiftLeftAssign => IrOp::ShiftLeft(result, current, 1),
AssignOp::ShiftRightAssign => IrOp::ShiftRight(result, current, 1),
AssignOp::Assign => unreachable!(),
};
self.emit(ir_op);
self.emit(IrOp::StoreVar(var_id, result));
let wide = dest_is_u16 || self.is_wide(current) || self.is_wide(rhs);
if wide && matches!(op, AssignOp::PlusAssign | AssignOp::MinusAssign) {
let (a_lo, a_hi) = self.widen(current);
let (b_lo, b_hi) = self.widen(rhs);
let d_hi = self.fresh_temp();
match op {
AssignOp::PlusAssign => self.emit(IrOp::Add16 {
d_lo: result,
d_hi,
a_lo,
a_hi,
b_lo,
b_hi,
}),
AssignOp::MinusAssign => self.emit(IrOp::Sub16 {
d_lo: result,
d_hi,
a_lo,
a_hi,
b_lo,
b_hi,
}),
_ => unreachable!(),
}
self.make_wide(result, d_hi);
self.emit(IrOp::StoreVar(var_id, result));
if dest_is_u16 {
self.emit(IrOp::StoreVarHi(var_id, d_hi));
}
} else {
let ir_op = match op {
AssignOp::PlusAssign => IrOp::Add(result, current, rhs),
AssignOp::MinusAssign => IrOp::Sub(result, current, rhs),
AssignOp::AmpAssign => IrOp::And(result, current, rhs),
AssignOp::PipeAssign => IrOp::Or(result, current, rhs),
AssignOp::CaretAssign => IrOp::Xor(result, current, rhs),
AssignOp::ShiftLeftAssign => IrOp::ShiftLeft(result, current, 1),
AssignOp::ShiftRightAssign => IrOp::ShiftRight(result, current, 1),
AssignOp::Assign => unreachable!(),
};
self.emit(ir_op);
self.emit(IrOp::StoreVar(var_id, result));
if dest_is_u16 {
// High byte unchanged by 8-bit op; keep
// the previously-loaded high byte.
let (_, cur_hi) = self.widen(current);
self.emit(IrOp::StoreVarHi(var_id, cur_hi));
}
}
}
}
}
@ -775,11 +875,45 @@ impl LoweringContext {
self.start_block(&end_label);
}
/// Mark a temp as the low byte of a wide (u16) value, with the
/// given high-byte temp. Consumers that care about 16-bit
/// semantics look up the high byte in `wide_hi`; consumers that
/// only need a byte ignore the map entirely (implicit truncation).
fn make_wide(&mut self, lo: IrTemp, hi: IrTemp) {
self.wide_hi.insert(lo, hi);
}
/// True if `t` was produced as the low byte of a wide value.
fn is_wide(&self, t: IrTemp) -> bool {
self.wide_hi.contains_key(&t)
}
/// Return the high-byte temp for a wide value. If `t` is not
/// wide, zero-extend it: allocate a fresh temp, emit `LoadImm 0`,
/// and return the pair. Used before emitting a 16-bit IR op when
/// one operand is narrow and the other is wide.
fn widen(&mut self, t: IrTemp) -> (IrTemp, IrTemp) {
if let Some(&hi) = self.wide_hi.get(&t) {
return (t, hi);
}
let hi = self.fresh_temp();
self.emit(IrOp::LoadImm(hi, 0));
(t, hi)
}
fn lower_expr(&mut self, expr: &Expr) -> IrTemp {
match expr {
Expr::IntLiteral(v, _) => {
let t = self.fresh_temp();
self.emit(IrOp::LoadImm(t, *v as u8));
// For literals that don't fit in a byte, also emit
// the high byte and register the pair as wide so
// later assignment to a u16 var stores both halves.
if *v > 0xFF {
let hi = self.fresh_temp();
self.emit(IrOp::LoadImm(hi, (*v >> 8) as u8));
self.make_wide(t, hi);
}
t
}
Expr::BoolLiteral(v, _) => {
@ -797,6 +931,14 @@ impl LoweringContext {
let var_id = self.get_or_create_var(name);
let t = self.fresh_temp();
self.emit(IrOp::LoadVar(t, var_id));
// For u16 variables, also load the high byte and
// register the temp pair as wide so downstream ops
// can emit 16-bit IR when appropriate.
if matches!(self.var_types.get(name), Some(NesType::U16)) {
let hi = self.fresh_temp();
self.emit(IrOp::LoadVarHi(hi, var_id));
self.make_wide(t, hi);
}
t
}
Expr::ArrayIndex(name, index, _) => {
@ -896,8 +1038,114 @@ impl LoweringContext {
let l = self.lower_expr(left);
let r = self.lower_expr(right);
let wide = self.is_wide(l) || self.is_wide(r);
let t = self.fresh_temp();
// 16-bit path: either operand is a wide value. Promote the
// narrower operand via zero-extension and emit the 16-bit
// IR op. Only add/sub/cmp are wide-aware today — other
// bitwise ops and multiply fall through to their 8-bit
// variants, which truncate to the low byte. (Multi-byte
// bitwise / multiply could be added later; today they're
// rare enough in NES code to defer.)
if wide {
let (a_lo, a_hi) = self.widen(l);
let (b_lo, b_hi) = self.widen(r);
match op {
BinOp::Add => {
let d_hi = self.fresh_temp();
self.emit(IrOp::Add16 {
d_lo: t,
d_hi,
a_lo,
a_hi,
b_lo,
b_hi,
});
self.make_wide(t, d_hi);
return t;
}
BinOp::Sub => {
let d_hi = self.fresh_temp();
self.emit(IrOp::Sub16 {
d_lo: t,
d_hi,
a_lo,
a_hi,
b_lo,
b_hi,
});
self.make_wide(t, d_hi);
return t;
}
BinOp::Eq => {
self.emit(IrOp::CmpEq16 {
dest: t,
a_lo,
a_hi,
b_lo,
b_hi,
});
return t;
}
BinOp::NotEq => {
self.emit(IrOp::CmpNe16 {
dest: t,
a_lo,
a_hi,
b_lo,
b_hi,
});
return t;
}
BinOp::Lt => {
self.emit(IrOp::CmpLt16 {
dest: t,
a_lo,
a_hi,
b_lo,
b_hi,
});
return t;
}
BinOp::Gt => {
self.emit(IrOp::CmpGt16 {
dest: t,
a_lo,
a_hi,
b_lo,
b_hi,
});
return t;
}
BinOp::LtEq => {
self.emit(IrOp::CmpLtEq16 {
dest: t,
a_lo,
a_hi,
b_lo,
b_hi,
});
return t;
}
BinOp::GtEq => {
self.emit(IrOp::CmpGtEq16 {
dest: t,
a_lo,
a_hi,
b_lo,
b_hi,
});
return t;
}
// Other operators fall through to the 8-bit path
// below, truncating the wide operand to its low
// byte. This is intentional for bitwise/shift ops
// which are rarely used on u16 values in NES code.
_ => {}
}
}
match op {
BinOp::Add => self.emit(IrOp::Add(t, l, r)),
BinOp::Sub => self.emit(IrOp::Sub(t, l, r)),

View file

@ -155,6 +155,103 @@ pub enum IrOp {
/// `peek(addr)` — LDA from a fixed absolute address into a temp.
Peek(IrTemp, u16),
// 16-bit operations — emitted for u16-typed expressions and
// assignments. Each wide value is carried as a pair of 8-bit
// temps `(lo, hi)`, so the existing temp-slot allocator still
// works without modification.
/// Load the high byte of a u16 variable (var address + 1).
/// The existing `LoadVar` is repurposed as "load the low byte"
/// because it loads from the var's base address — which is the
/// low byte of a little-endian u16.
LoadVarHi(IrTemp, VarId),
/// Store the high byte of a u16 variable (var address + 1).
StoreVarHi(VarId, IrTemp),
/// 16-bit add: `(d_lo, d_hi) = (a_lo, a_hi) + (b_lo, b_hi)`.
/// Codegen emits `CLC; LDA a_lo; ADC b_lo; STA d_lo; LDA a_hi;
/// ADC b_hi; STA d_hi` — the ADC for the high byte propagates
/// the carry flag set by the low-byte addition.
Add16 {
d_lo: IrTemp,
d_hi: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
},
/// 16-bit subtract: `(d_lo, d_hi) = (a_lo, a_hi) - (b_lo, b_hi)`.
/// Uses SEC; SBC to propagate borrow through the high byte.
Sub16 {
d_lo: IrTemp,
d_hi: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
},
/// 16-bit equality comparison; `dest = (a == b) ? 1 : 0`.
/// Lowered as two CMPs with a short-circuit on the low byte.
CmpEq16 {
dest: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
},
/// 16-bit not-equal comparison.
CmpNe16 {
dest: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
},
/// 16-bit unsigned less-than. `dest = (a < b) ? 1 : 0`.
/// Codegen compares high bytes first; falls through to compare
/// low bytes only when the high bytes are equal.
CmpLt16 {
dest: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
},
/// 16-bit unsigned greater-than.
CmpGt16 {
dest: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
},
/// 16-bit unsigned less-or-equal.
CmpLtEq16 {
dest: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
},
/// 16-bit unsigned greater-or-equal.
CmpGtEq16 {
dest: IrTemp,
a_lo: IrTemp,
a_hi: IrTemp,
b_lo: IrTemp,
b_hi: IrTemp,
},
// Audio ops — map to the minimal APU driver emitted by the linker.
/// `play SfxName` — trigger a one-shot sound effect on pulse 1.
/// The sfx name is looked up in a builtin table; unrecognized names
/// play a generic beep.
PlaySfx(String),
/// `start_music TrackName` — play a sustained tone on pulse 2 until
/// `stop_music`. The track name is hashed into a tone parameter.
StartMusic(String),
/// `stop_music` — silence the music channel (pulse 2) and any
/// currently-playing SFX tail.
StopMusic,
// Source mapping
SourceLoc(Span),
}

View file

@ -116,6 +116,15 @@ impl Linker {
all_instructions.extend(runtime::gen_multiply());
all_instructions.extend(runtime::gen_divide());
// Audio driver body — linked in whenever user code touched
// audio. The driver needs to exist before `__nmi` (the NMI
// JSR target must be defined before the caller), so emit it
// alongside the math routines. No-cost elision when unused:
// the `has_label` check below skips the whole block.
if has_label(user_code, "__audio_used") {
all_instructions.extend(runtime::gen_audio_tick());
}
// NMI handler
all_instructions.push(Instruction::new(NOP, AM::Label("__nmi".into())));
// If user code emits an MMC3 reload hook, splice in a JSR
@ -129,6 +138,17 @@ impl Linker {
if has_label(user_code, "__ir_mmc3_reload") {
all_instructions.push(Instruction::new(JSR, AM::Label("__ir_mmc3_reload".into())));
}
// Audio tick: if the user program ever triggered an SFX or
// music (detected by the marker label `__audio_used` emitted
// by `IrCodeGen` when it sees any audio op), JSR into the
// driver's per-frame tick before the normal NMI body. The
// tick decrements the SFX counter and silences pulse 1 when
// it reaches zero. Programs that never play audio skip both
// the splice and the driver body entirely — no ROM cost.
let has_audio = has_label(user_code, "__audio_used");
if has_audio {
all_instructions.push(Instruction::new(JSR, AM::Label("__audio_tick".into())));
}
all_instructions.extend(runtime::gen_nmi());
// IRQ handler

View file

@ -130,6 +130,39 @@ fn link_with_sprites_spanning_multiple_tiles() {
assert_eq!(placed, sprite_bytes.as_slice());
}
#[test]
fn link_splices_audio_tick_when_user_marker_present() {
// When user code contains the `__audio_used` marker label (the
// IR codegen emits this whenever it sees a `play`/`start_music`/
// `stop_music` op), the linker must splice a `JSR __audio_tick`
// into the NMI handler prologue AND link in the audio driver
// body so the JSR target exists.
let linker = Linker::new(Mirroring::Horizontal);
let user_code = vec![
// Pretend user code with the marker the codegen would emit.
Instruction::new(NOP, AM::Label("__audio_used".into())),
Instruction::implied(NOP),
];
let rom_data = linker.link(&user_code);
// The ROM should be valid even with the splice — the driver
// body has to fit in bank 0 without overflowing.
let info = rom::validate_ines(&rom_data).unwrap();
assert_eq!(info.prg_banks, 1);
}
#[test]
fn link_omits_audio_tick_when_no_marker() {
// User code without the marker should not pay any ROM cost
// for the audio driver. We can't easily inspect bytes, but we
// can at least verify the ROM builds and has a normal shape.
let linker = Linker::new(Mirroring::Horizontal);
let user_code = vec![Instruction::implied(NOP)];
let rom_data = linker.link(&user_code);
let info = rom::validate_ines(&rom_data).unwrap();
assert_eq!(info.prg_banks, 1);
}
#[test]
fn palette_load_writes_to_ppu() {
let linker = Linker::new(Mirroring::Horizontal);

View file

@ -394,11 +394,79 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet<IrTemp>) {
IrOp::Poke(_, src) => {
used.insert(*src);
}
IrOp::ReadInput(_, _)
IrOp::StoreVarHi(_, src) => {
used.insert(*src);
}
IrOp::Add16 {
a_lo,
a_hi,
b_lo,
b_hi,
..
}
| IrOp::Sub16 {
a_lo,
a_hi,
b_lo,
b_hi,
..
}
| IrOp::CmpEq16 {
a_lo,
a_hi,
b_lo,
b_hi,
..
}
| IrOp::CmpNe16 {
a_lo,
a_hi,
b_lo,
b_hi,
..
}
| IrOp::CmpLt16 {
a_lo,
a_hi,
b_lo,
b_hi,
..
}
| IrOp::CmpGt16 {
a_lo,
a_hi,
b_lo,
b_hi,
..
}
| IrOp::CmpLtEq16 {
a_lo,
a_hi,
b_lo,
b_hi,
..
}
| IrOp::CmpGtEq16 {
a_lo,
a_hi,
b_lo,
b_hi,
..
} => {
used.insert(*a_lo);
used.insert(*a_hi);
used.insert(*b_lo);
used.insert(*b_hi);
}
IrOp::LoadVarHi(_, _)
| IrOp::ReadInput(_, _)
| IrOp::WaitFrame
| IrOp::Transition(_)
| IrOp::InlineAsm(_)
| IrOp::Peek(_, _)
| IrOp::PlaySfx(_)
| IrOp::StartMusic(_)
| IrOp::StopMusic
| IrOp::SourceLoc(_) => {}
}
}
@ -429,6 +497,7 @@ fn op_dest(op: &IrOp) -> Option<IrTemp> {
IrOp::ReadInput(d, _) => Some(*d),
IrOp::Peek(d, _) => Some(*d),
IrOp::StoreVar(_, _)
| IrOp::StoreVarHi(_, _)
| IrOp::ArrayStore(_, _, _)
| IrOp::DrawSprite { .. }
| IrOp::WaitFrame
@ -438,7 +507,25 @@ fn op_dest(op: &IrOp) -> Option<IrTemp> {
| IrOp::DebugAssert(_)
| IrOp::InlineAsm(_)
| IrOp::Poke(_, _)
| IrOp::PlaySfx(_)
| IrOp::StartMusic(_)
| IrOp::StopMusic
| IrOp::SourceLoc(_) => None,
// 16-bit ops have two destinations; the simple single-dest
// DCE below would incorrectly drop a 16-bit op whose low
// dest is unused even if its high dest is live. Returning
// `None` here preserves them unconditionally — they're
// rare enough that the lost DCE opportunity is a good
// trade for correctness.
IrOp::LoadVarHi(_, _)
| IrOp::Add16 { .. }
| IrOp::Sub16 { .. }
| IrOp::CmpEq16 { .. }
| IrOp::CmpNe16 { .. }
| IrOp::CmpLt16 { .. }
| IrOp::CmpGt16 { .. }
| IrOp::CmpLtEq16 { .. }
| IrOp::CmpGtEq16 { .. } => None,
}
}

View file

@ -25,6 +25,15 @@ pub const ZP_INPUT_P2: u8 = 0x08;
/// the oldest slot gets overwritten — the classic NES flicker
/// fallback.
pub const ZP_OAM_CURSOR: u8 = 0x09;
/// Pulse-1 SFX countdown frames. `play SfxName` sets this to the
/// SFX duration and writes the tone registers; the NMI audio tick
/// decrements it every frame, silencing pulse 1 when it reaches 0.
pub const ZP_SFX_COUNTER: u8 = 0x0A;
/// Pulse-2 music countdown frames. `start_music TrackName` sets
/// this to `$FF` (infinite sustain) and writes a pulse-2 tone;
/// `stop_music` zeros it and mutes pulse 2. `$FF` skips the NMI
/// decrement so music plays until explicitly stopped.
pub const ZP_MUSIC_COUNTER: u8 = 0x0B;
/// Generate the NES hardware initialization sequence.
/// This runs at RESET and sets up the hardware before user code.
@ -48,9 +57,29 @@ pub fn gen_init() -> Vec<Instruction> {
out.push(Instruction::new(STA, AM::Absolute(PPU_CTRL)));
out.push(Instruction::new(STA, AM::Absolute(PPU_MASK)));
// Disable DMC IRQs
// Disable DMC IRQs momentarily (will re-enable the square
// channels below so `play`/`start_music` can make sound).
out.push(Instruction::new(STA, AM::Absolute(APU_STATUS)));
// Enable pulse 1 and pulse 2 channels for the minimal audio
// driver. SFX runs on pulse 1, music on pulse 2. We leave
// triangle / noise / DMC disabled — the engine is deliberately
// simple and those channels would go unused anyway.
out.push(Instruction::new(LDA, AM::Immediate(0x03)));
out.push(Instruction::new(STA, AM::Absolute(APU_STATUS)));
// Pre-silence both channels: `$30` on the volume register sets
// constant-volume envelope with volume 0 and halts the length
// counter, which is the canonical "silent but armed" state.
out.push(Instruction::new(LDA, AM::Immediate(0x30)));
out.push(Instruction::new(STA, AM::Absolute(0x4000)));
out.push(Instruction::new(STA, AM::Absolute(0x4004)));
// Clear sweep units so the channel tone doesn't auto-slide.
out.push(Instruction::new(LDA, AM::Immediate(0x08)));
out.push(Instruction::new(STA, AM::Absolute(0x4001)));
out.push(Instruction::new(STA, AM::Absolute(0x4005)));
// Restore the zero we need for the subsequent RAM clear below.
out.push(Instruction::new(LDA, AM::Immediate(0x00)));
// Wait for first vblank
// vblankwait1:
out.push(Instruction::new(NOP, AM::Label("__vblankwait1".into())));
@ -227,6 +256,46 @@ pub fn gen_multiply() -> Vec<Instruction> {
out
}
/// Generate the per-NMI audio tick that ages the SFX counter and
/// silences pulse 1 when the counter hits zero. Music on pulse 2
/// uses the sentinel `$FF` for infinite sustain and is never
/// decremented here — `stop_music` handles mute explicitly.
///
/// The linker splices a `JSR __audio_tick` into the NMI handler
/// whenever user code contains any audio ops, so programs that
/// never call `play`/`start_music`/`stop_music` pay zero cost.
///
/// Contract:
/// - Input: `ZP_SFX_COUNTER` = remaining frames for pulse 1's tone
/// - Effect: decrements the counter; on 0→transition mutes $4000
/// - Clobbers: A (which the NMI handler restores via PLA)
pub fn gen_audio_tick() -> Vec<Instruction> {
let mut out = Vec::new();
out.push(Instruction::new(NOP, AM::Label("__audio_tick".into())));
// SFX counter check: if 0, nothing to do.
out.push(Instruction::new(LDA, AM::ZeroPage(ZP_SFX_COUNTER)));
out.push(Instruction::new(
BEQ,
AM::LabelRelative("__audio_tick_done".into()),
));
out.push(Instruction::new(DEC, AM::ZeroPage(ZP_SFX_COUNTER)));
// If still non-zero, leave the tone alone.
out.push(Instruction::new(
BNE,
AM::LabelRelative("__audio_tick_done".into()),
));
// Counter just hit 0: silence pulse 1 (volume envelope = mute).
out.push(Instruction::new(LDA, AM::Immediate(0x30)));
out.push(Instruction::new(STA, AM::Absolute(0x4000)));
out.push(Instruction::new(NOP, AM::Label("__audio_tick_done".into())));
out.push(Instruction::implied(RTS));
out
}
/// Generate 8 / 8 -> 8 software divide routine (restoring division).
///
/// Input: A = dividend, zero-page $02 = divisor