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:
parent
f95130346a
commit
a283f87b79
5 changed files with 176 additions and 16 deletions
|
|
@ -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:
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
|
||||||
|
|
@ -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)> {
|
||||||
|
|
|
||||||
|
|
@ -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, _) => {
|
||||||
|
let substituted = self.substitute_asm_vars(body);
|
||||||
|
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}");
|
||||||
self.emit(BRK, AM::Implied);
|
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.
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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#"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue