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:
parent
9ebf58f7db
commit
9a539ea068
12 changed files with 2108 additions and 144 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(¶m.param_type),
|
||||
});
|
||||
self.register_param_type(¶m.name, ¶m.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)),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue