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

Inline asm: {var} placeholder substitution

Within \`asm { ... }\` blocks, \`{name}\` is replaced with the
resolved hex address of the variable at codegen time. The lexer's
asm-body capture now balances nested braces so it doesn't cut off
at the first \`{x}\`. Both IR and AST codegen paths preprocess the
body before passing to the inline parser:

    var counter: u8 = 0
    on frame {
        asm {
            LDA {counter}
            CLC
            ADC #\$01
            STA {counter}
        }
    }

Zero-page addresses become \`\$XX\`, absolute addresses become
\`\$XXXX\`. Unknown names pass through unchanged so the asm parser
can surface the "unknown mnemonic" / "unexpected token" error.

https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
This commit is contained in:
Claude 2026-04-12 17:30:21 +00:00
parent f95130346a
commit a283f87b79
No known key found for this signature in database
5 changed files with 176 additions and 16 deletions

View file

@ -909,6 +909,44 @@ error[E0402]: recursion is not allowed
### Build ### 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: Compile a `.ne` source file into a `.nes` ROM:
``` ```

View file

@ -62,7 +62,7 @@ pub struct IrCodeGen<'a> {
/// When true, emit code for `debug.log` / `debug.assert`. /// When true, emit code for `debug.log` / `debug.assert`.
/// When false, these ops are stripped entirely. /// When false, these ops are stripped entirely.
debug_mode: bool, debug_mode: bool,
_allocations: &'a [VarAllocation], allocations: &'a [VarAllocation],
} }
impl<'a> IrCodeGen<'a> { impl<'a> IrCodeGen<'a> {
@ -115,7 +115,7 @@ impl<'a> IrCodeGen<'a> {
next_oam_slot: 0, next_oam_slot: 0,
in_frame_handler: false, in_frame_handler: false,
debug_mode: false, debug_mode: false,
_allocations: allocations, allocations,
} }
} }
@ -616,12 +616,19 @@ impl<'a> IrCodeGen<'a> {
} }
} }
IrOp::InlineAsm(body) => { IrOp::InlineAsm(body) => {
// Parse the asm body with the shared inline parser and // Preprocess `{var}` substitutions, then parse with
// splice the resulting instructions directly into our // the shared inline parser and splice the resulting
// output stream. Parse errors are emitted as `BRK` so // instructions. Parse errors emit `BRK` so the ROM
// the ROM still links — codegen is too late to return // still links — codegen is too late to return
// user-facing diagnostics cleanly. // 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), Ok(parsed) => self.instructions.extend(parsed),
Err(msg) => { Err(msg) => {
eprintln!("inline asm error: {msg}"); eprintln!("inline asm error: {msg}");
@ -710,6 +717,52 @@ enum CmpKind {
GtEq, 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<F: Fn(&str) -> Option<u16>>(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 /// Scan the IR functions for all scanline handlers (named
/// `{state}_scanline_{line}`) and return them in IR function order. /// `{state}_scanline_{line}`) and return them in IR function order.
fn collect_scanline_handlers(ir: &IrProgram) -> Vec<(String, u8)> { fn collect_scanline_handlers(ir: &IrProgram) -> Vec<(String, u8)> {

View file

@ -107,6 +107,40 @@ impl CodeGen {
self 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 { fn fresh_label(&mut self, prefix: &str) -> String {
self.label_counter += 1; self.label_counter += 1;
format!("__{prefix}_{}", self.label_counter) format!("__{prefix}_{}", self.label_counter)
@ -369,13 +403,16 @@ impl CodeGen {
self.emit_label(&pass_label); self.emit_label(&pass_label);
} }
} }
Statement::InlineAsm(body, _) => match crate::asm::parse_inline(body) { Statement::InlineAsm(body, _) => {
Ok(parsed) => self.instructions.extend(parsed), let substituted = self.substitute_asm_vars(body);
Err(msg) => { match crate::asm::parse_inline(&substituted) {
eprintln!("inline asm error: {msg}"); Ok(parsed) => self.instructions.extend(parsed),
self.emit(BRK, AM::Implied); Err(msg) => {
eprintln!("inline asm error: {msg}");
self.emit(BRK, AM::Implied);
}
} }
}, }
Statement::Play(_, _) | Statement::StartMusic(_, _) | Statement::StopMusic(_) => { Statement::Play(_, _) | Statement::StartMusic(_, _) | Statement::StopMusic(_) => {
// Audio statements compile to no-ops for now. // Audio statements compile to no-ops for now.
} }

View file

@ -498,11 +498,23 @@ impl<'a> Lexer<'a> {
impl Lexer<'_> { impl Lexer<'_> {
/// Capture everything between the `{` we just consumed and the /// Capture everything between the `{` we just consumed and the
/// matching `}` as an `AsmBody` token. 6502 assembly has no /// matching `}` as an `AsmBody` token. Nested braces (e.g.
/// `{`/`}` in its syntax, so we can simply scan for the next `}`. /// `{variable}` substitution placeholders) are balanced by
/// depth counting, so the body isn't cut short.
fn capture_asm_body(&mut self, start: usize) -> Token { fn capture_asm_body(&mut self, start: usize) -> Token {
let body_start = self.pos; 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; self.pos += 1;
} }
let body = String::from_utf8_lossy(&self.source[body_start..self.pos]).into_owned(); let body = String::from_utf8_lossy(&self.source[body_start..self.pos]).into_owned();

View file

@ -322,6 +322,26 @@ fn program_with_enums() {
rom::validate_ines(&rom_data).expect("should be valid iNES"); 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] #[test]
fn program_with_inline_asm() { fn program_with_inline_asm() {
let source = r#" let source = r#"