diff --git a/docs/language-guide.md b/docs/language-guide.md index ff30fbe..4173247 100644 --- a/docs/language-guide.md +++ b/docs/language-guide.md @@ -909,6 +909,44 @@ error[E0402]: recursion is not allowed ### Build +## Inline Assembly + +`asm { ... }` blocks contain raw 6502 assembly that the compiler +parses and splices directly into the output: + +``` +fun fast_add() -> u8 { + var x: u8 = 5 + var y: u8 = 3 + asm { + LDA {x} + CLC + ADC {y} + STA {x} + } + return x +} +``` + +Within an asm block, `{name}` is replaced with the resolved +zero-page or absolute address of the variable `name`. This lets +handwritten assembly reference NEScript variables without knowing +where the analyzer allocated them. + +Supported addressing modes (mirroring the generated codegen): + +- `LDA #$10` / `LDA #42` — immediate +- `LDA $10` / `STA $20,X` — zero-page (+ indexed) +- `LDA $2000` / `LDA $0200,Y` — absolute (+ indexed) +- `LDA ($10,X)` / `LDA ($10),Y` — indirect-X / indirect-Y +- `JMP ($FFFC)` — indirect +- `CLC`, `SEC`, `NOP`, ... — implied +- `LSR A` — accumulator +- Labels: `loop_start:` defines a label; `BNE loop_start` branches + to it. Labels are local to the surrounding asm block. + +## Command Line + Compile a `.ne` source file into a `.nes` ROM: ``` diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index 3f53294..202f1ab 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -62,7 +62,7 @@ pub struct IrCodeGen<'a> { /// When true, emit code for `debug.log` / `debug.assert`. /// When false, these ops are stripped entirely. debug_mode: bool, - _allocations: &'a [VarAllocation], + allocations: &'a [VarAllocation], } impl<'a> IrCodeGen<'a> { @@ -115,7 +115,7 @@ impl<'a> IrCodeGen<'a> { next_oam_slot: 0, in_frame_handler: false, debug_mode: false, - _allocations: allocations, + allocations, } } @@ -616,12 +616,19 @@ impl<'a> IrCodeGen<'a> { } } IrOp::InlineAsm(body) => { - // Parse the asm body with the shared inline parser and - // splice the resulting instructions directly into our - // output stream. Parse errors are emitted as `BRK` so - // the ROM still links — codegen is too late to return + // Preprocess `{var}` substitutions, then parse with + // the shared inline parser and splice the resulting + // instructions. Parse errors emit `BRK` so the ROM + // still links — codegen is too late to return // user-facing diagnostics cleanly. - match crate::asm::parse_inline(body) { + let substituted = substitute_asm_vars(body, |name| { + // Look up by allocated name. Prefer exact match. + self.allocations + .iter() + .find(|a| a.name == name) + .map(|a| a.address) + }); + match crate::asm::parse_inline(&substituted) { Ok(parsed) => self.instructions.extend(parsed), Err(msg) => { eprintln!("inline asm error: {msg}"); @@ -710,6 +717,52 @@ enum CmpKind { GtEq, } +/// Replace `{name}` tokens in an inline-asm body with the resolved +/// hex address from the given resolver. Unknown names and malformed +/// placeholders are passed through unchanged (the asm parser will +/// surface a clearer error than we could at this stage). +/// +/// Addresses that fit in a byte become zero-page syntax (`$XX`), +/// otherwise absolute (`$XXXX`). This lets users write +/// `lda {score}` and have it resolve to `lda $10` or similar. +fn substitute_asm_vars Option>(body: &str, resolver: F) -> String { + let mut out = String::with_capacity(body.len()); + let bytes = body.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'{' { + // Find the closing `}`. + if let Some(end) = bytes[i + 1..].iter().position(|&b| b == b'}') { + let name_start = i + 1; + let name_end = i + 1 + end; + let name = &body[name_start..name_end]; + // Only substitute bare identifiers. + let is_ident = !name.is_empty() + && name + .chars() + .next() + .is_some_and(|c| c == '_' || c.is_ascii_alphabetic()) + && name.chars().all(|c| c == '_' || c.is_ascii_alphanumeric()); + if is_ident { + if let Some(addr) = resolver(name) { + use std::fmt::Write; + if addr < 0x100 { + let _ = write!(out, "${addr:02X}"); + } else { + let _ = write!(out, "${addr:04X}"); + } + i = name_end + 1; + continue; + } + } + } + } + out.push(bytes[i] as char); + i += 1; + } + out +} + /// Scan the IR functions for all scanline handlers (named /// `{state}_scanline_{line}`) and return them in IR function order. fn collect_scanline_handlers(ir: &IrProgram) -> Vec<(String, u8)> { diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index fce8c98..6c9c342 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -107,6 +107,40 @@ impl CodeGen { self } + /// Replace `{name}` placeholders in an inline-asm body with the + /// resolved zero-page or absolute hex address. Unknown names + /// pass through unchanged so the asm parser can surface a clear + /// error. + fn substitute_asm_vars(&self, body: &str) -> String { + use std::fmt::Write; + let mut out = String::with_capacity(body.len()); + let bytes = body.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'{' { + if let Some(end) = bytes[i + 1..].iter().position(|&b| b == b'}') { + let name_start = i + 1; + let name_end = i + 1 + end; + let name = &body[name_start..name_end]; + if !name.is_empty() { + if let Some(&addr) = self.var_addrs.get(name) { + if addr < 0x100 { + let _ = write!(out, "${addr:02X}"); + } else { + let _ = write!(out, "${addr:04X}"); + } + i = name_end + 1; + continue; + } + } + } + } + out.push(bytes[i] as char); + i += 1; + } + out + } + fn fresh_label(&mut self, prefix: &str) -> String { self.label_counter += 1; format!("__{prefix}_{}", self.label_counter) @@ -369,13 +403,16 @@ impl CodeGen { self.emit_label(&pass_label); } } - Statement::InlineAsm(body, _) => match crate::asm::parse_inline(body) { - Ok(parsed) => self.instructions.extend(parsed), - Err(msg) => { - eprintln!("inline asm error: {msg}"); - self.emit(BRK, AM::Implied); + Statement::InlineAsm(body, _) => { + let substituted = self.substitute_asm_vars(body); + match crate::asm::parse_inline(&substituted) { + Ok(parsed) => self.instructions.extend(parsed), + Err(msg) => { + eprintln!("inline asm error: {msg}"); + self.emit(BRK, AM::Implied); + } } - }, + } Statement::Play(_, _) | Statement::StartMusic(_, _) | Statement::StopMusic(_) => { // Audio statements compile to no-ops for now. } diff --git a/src/lexer/mod.rs b/src/lexer/mod.rs index 38cb70b..1955db1 100644 --- a/src/lexer/mod.rs +++ b/src/lexer/mod.rs @@ -498,11 +498,23 @@ impl<'a> Lexer<'a> { impl Lexer<'_> { /// Capture everything between the `{` we just consumed and the - /// matching `}` as an `AsmBody` token. 6502 assembly has no - /// `{`/`}` in its syntax, so we can simply scan for the next `}`. + /// matching `}` as an `AsmBody` token. Nested braces (e.g. + /// `{variable}` substitution placeholders) are balanced by + /// depth counting, so the body isn't cut short. fn capture_asm_body(&mut self, start: usize) -> Token { let body_start = self.pos; - while self.pos < self.source.len() && self.source[self.pos] != b'}' { + let mut depth = 1u32; + while self.pos < self.source.len() && depth > 0 { + match self.source[self.pos] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + break; + } + } + _ => {} + } self.pos += 1; } let body = String::from_utf8_lossy(&self.source[body_start..self.pos]).into_owned(); diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 2b9afee..2e2d7b8 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -322,6 +322,26 @@ fn program_with_enums() { rom::validate_ines(&rom_data).expect("should be valid iNES"); } +#[test] +fn program_with_inline_asm_variable_substitution() { + let source = r#" + game "AsmVar" { mapper: NROM } + var counter: u8 = 0 + on frame { + asm { + LDA {counter} + CLC + ADC #$01 + STA {counter} + } + wait_frame + } + start Main + "#; + let rom_data = compile(source); + rom::validate_ines(&rom_data).expect("should be valid iNES"); +} + #[test] fn program_with_inline_asm() { let source = r#"