diff --git a/src/analyzer/mod.rs b/src/analyzer/mod.rs index 5c6b5fd..6c58a80 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -765,6 +765,11 @@ impl Analyzer { self.walk_expr_reads(cond); self.check_expr_type(cond, &NesType::Bool); } + Statement::InlineAsm(_, _) => { + // Inline assembly is treated as an opaque block. The + // codegen parses and validates the body; analysis has + // nothing to check. + } } } @@ -1001,7 +1006,8 @@ fn collect_calls_stmt(stmt: &Statement, calls: &mut Vec) { | Statement::Break(_) | Statement::Continue(_) | Statement::LoadBackground(_, _) - | Statement::SetPalette(_, _) => {} + | Statement::SetPalette(_, _) + | Statement::InlineAsm(_, _) => {} } } diff --git a/src/asm/inline_parser.rs b/src/asm/inline_parser.rs new file mode 100644 index 0000000..10bc73b --- /dev/null +++ b/src/asm/inline_parser.rs @@ -0,0 +1,381 @@ +//! Minimal 6502 assembly parser used by `asm { ... }` inline blocks. +//! +//! Supports the addressing modes we actually emit in codegen: +//! - Implied / Accumulator: `CLC`, `LSR A` +//! - Immediate: `LDA #$10`, `LDA #42`, `LDA #%00001111` +//! - Zero page (+ X/Y): `STA $02`, `LDA $10,X`, `LDX $20,Y` +//! - Absolute (+ X/Y): `STA $2000`, `LDA $0200,X`, `LDA $0100,Y` +//! - Indirect: `JMP ($FFFC)` +//! - Indirect (X/Y): `LDA ($10,X)`, `STA ($10),Y` +//! - Labels: `foo:` on a line by itself, `JMP foo`, `BNE loop` +//! +//! This is not a full 6502 assembler — it only accepts the subset +//! needed by hand-written inline blocks. Unknown mnemonics, unsupported +//! addressing modes, or syntax errors return a `String` message. + +use super::{AddressingMode, Instruction, Opcode}; + +/// Parse a block of inline assembly text into a list of `Instruction`s. +/// +/// Each line is either: +/// - blank / a `; comment` +/// - a label `name:` +/// - a mnemonic with an optional operand +/// +/// On error, returns the first problem with a line number. +pub fn parse_inline(body: &str) -> Result, String> { + let mut out = Vec::new(); + for (lineno, raw) in body.lines().enumerate() { + let line = strip_comment(raw).trim(); + if line.is_empty() { + continue; + } + // Label: `name:` + if let Some(name) = line.strip_suffix(':') { + let name = name.trim(); + if name.is_empty() || !is_valid_ident(name) { + return Err(format!("line {}: invalid label `{line}`", lineno + 1)); + } + out.push(Instruction::new( + Opcode::NOP, + AddressingMode::Label(name.to_string()), + )); + continue; + } + let (mnemonic, rest) = split_mnemonic(line); + let opcode = parse_opcode(mnemonic) + .ok_or_else(|| format!("line {}: unknown mnemonic `{mnemonic}`", lineno + 1))?; + let mode = + parse_operand(opcode, rest.trim()).map_err(|e| format!("line {}: {e}", lineno + 1))?; + out.push(Instruction::new(opcode, mode)); + } + Ok(out) +} + +fn strip_comment(line: &str) -> &str { + match line.find(';') { + Some(i) => &line[..i], + None => line, + } +} + +fn split_mnemonic(line: &str) -> (&str, &str) { + match line.find(|c: char| c.is_whitespace()) { + Some(i) => (&line[..i], &line[i..]), + None => (line, ""), + } +} + +fn is_valid_ident(s: &str) -> bool { + s.chars() + .next() + .is_some_and(|c| c == '_' || c.is_ascii_alphabetic()) + && s.chars().all(|c| c == '_' || c.is_ascii_alphanumeric()) +} + +fn parse_opcode(mnemonic: &str) -> Option { + let m = mnemonic.to_ascii_uppercase(); + Some(match m.as_str() { + "LDA" => Opcode::LDA, + "LDX" => Opcode::LDX, + "LDY" => Opcode::LDY, + "STA" => Opcode::STA, + "STX" => Opcode::STX, + "STY" => Opcode::STY, + "ADC" => Opcode::ADC, + "SBC" => Opcode::SBC, + "AND" => Opcode::AND, + "ORA" => Opcode::ORA, + "EOR" => Opcode::EOR, + "ASL" => Opcode::ASL, + "LSR" => Opcode::LSR, + "ROL" => Opcode::ROL, + "ROR" => Opcode::ROR, + "INC" => Opcode::INC, + "DEC" => Opcode::DEC, + "INX" => Opcode::INX, + "INY" => Opcode::INY, + "DEX" => Opcode::DEX, + "DEY" => Opcode::DEY, + "CMP" => Opcode::CMP, + "CPX" => Opcode::CPX, + "CPY" => Opcode::CPY, + "BIT" => Opcode::BIT, + "JMP" => Opcode::JMP, + "JSR" => Opcode::JSR, + "RTS" => Opcode::RTS, + "RTI" => Opcode::RTI, + "BEQ" => Opcode::BEQ, + "BNE" => Opcode::BNE, + "BCC" => Opcode::BCC, + "BCS" => Opcode::BCS, + "BMI" => Opcode::BMI, + "BPL" => Opcode::BPL, + "BVC" => Opcode::BVC, + "BVS" => Opcode::BVS, + "CLC" => Opcode::CLC, + "SEC" => Opcode::SEC, + "CLI" => Opcode::CLI, + "SEI" => Opcode::SEI, + "CLV" => Opcode::CLV, + "CLD" => Opcode::CLD, + "SED" => Opcode::SED, + "PHA" => Opcode::PHA, + "PLA" => Opcode::PLA, + "PHP" => Opcode::PHP, + "PLP" => Opcode::PLP, + "TAX" => Opcode::TAX, + "TAY" => Opcode::TAY, + "TXA" => Opcode::TXA, + "TYA" => Opcode::TYA, + "TSX" => Opcode::TSX, + "TXS" => Opcode::TXS, + "NOP" => Opcode::NOP, + "BRK" => Opcode::BRK, + _ => return None, + }) +} + +fn parse_operand(opcode: Opcode, operand: &str) -> Result { + // No operand → implied (or accumulator for some shifts) + if operand.is_empty() { + return Ok(AddressingMode::Implied); + } + // Explicit accumulator (e.g. `LSR A`) + if operand.eq_ignore_ascii_case("A") { + return Ok(AddressingMode::Accumulator); + } + // Immediate: `#...` + if let Some(rest) = operand.strip_prefix('#') { + let v = parse_u8(rest.trim())?; + return Ok(AddressingMode::Immediate(v)); + } + // Indirect: `(addr)`, `(addr,X)`, `(addr),Y` + if operand.starts_with('(') { + // `(addr),Y` — outer ,Y after the closing paren + if let Some(inner) = operand + .strip_suffix(",Y") + .or_else(|| operand.strip_suffix(",y")) + { + let inside = inner + .strip_prefix('(') + .and_then(|s| s.strip_suffix(')')) + .ok_or_else(|| format!("malformed indirect operand `{operand}`"))?; + let addr = parse_u8(inside.trim())?; + return Ok(AddressingMode::IndirectY(addr)); + } + // `(addr,X)` or `(addr)` — both end with `)` + if let Some(rest) = operand.strip_prefix('(').and_then(|s| s.strip_suffix(')')) { + if let Some(inside) = rest.strip_suffix(",X").or_else(|| rest.strip_suffix(",x")) { + let addr = parse_u8(inside.trim())?; + return Ok(AddressingMode::IndirectX(addr)); + } + let addr = parse_u16(rest.trim())?; + return Ok(AddressingMode::Indirect(addr)); + } + return Err(format!("malformed indirect operand `{operand}`")); + } + // `addr,X` / `addr,Y` + if let Some((base, reg)) = split_index(operand) { + let is_zp = looks_like_zero_page(base); + let (abs_mode, zp_mode) = match reg { + 'X' | 'x' => ( + AddressingMode::AbsoluteX as fn(u16) -> AddressingMode, + AddressingMode::ZeroPageX as fn(u8) -> AddressingMode, + ), + 'Y' | 'y' => ( + AddressingMode::AbsoluteY as fn(u16) -> AddressingMode, + AddressingMode::ZeroPageY as fn(u8) -> AddressingMode, + ), + _ => return Err(format!("unknown index register `{reg}`")), + }; + if is_zp { + let v = parse_u8(base)?; + return Ok(zp_mode(v)); + } + let v = parse_u16(base)?; + return Ok(abs_mode(v)); + } + // Branch instructions take a label by name. + if is_branch(opcode) && is_valid_ident(operand) { + return Ok(AddressingMode::LabelRelative(operand.to_string())); + } + // Plain label: JMP foo, JSR foo + if matches!(opcode, Opcode::JMP | Opcode::JSR) && is_valid_ident(operand) { + return Ok(AddressingMode::Label(operand.to_string())); + } + // Plain address: ZeroPage if it fits, else Absolute + if looks_like_zero_page(operand) { + let v = parse_u8(operand)?; + return Ok(AddressingMode::ZeroPage(v)); + } + let v = parse_u16(operand)?; + Ok(AddressingMode::Absolute(v)) +} + +fn split_index(operand: &str) -> Option<(&str, char)> { + let bytes = operand.as_bytes(); + if bytes.len() >= 2 && bytes[bytes.len() - 2] == b',' { + let reg = bytes[bytes.len() - 1] as char; + if matches!(reg, 'X' | 'x' | 'Y' | 'y') { + return Some((operand[..operand.len() - 2].trim_end(), reg)); + } + } + None +} + +/// True if the operand is a numeric literal that fits in 8 bits. +fn looks_like_zero_page(operand: &str) -> bool { + parse_u16(operand).is_ok_and(|v| v <= 0xFF) && parse_u8(operand).is_ok() +} + +fn parse_u8(s: &str) -> Result { + let v = parse_u16(s)?; + u8::try_from(v).map_err(|_| format!("value {v} out of u8 range")) +} + +fn parse_u16(s: &str) -> Result { + let s = s.trim(); + let (negative, s) = if let Some(rest) = s.strip_prefix('-') { + (true, rest) + } else { + (false, s) + }; + let v: u16 = if let Some(hex) = s.strip_prefix('$') { + u16::from_str_radix(hex, 16).map_err(|e| format!("bad hex `{s}`: {e}"))? + } else if let Some(bin) = s.strip_prefix('%') { + u16::from_str_radix(bin, 2).map_err(|e| format!("bad binary `{s}`: {e}"))? + } else { + s.parse().map_err(|e| format!("bad number `{s}`: {e}"))? + }; + Ok(if negative { v.wrapping_neg() } else { v }) +} + +fn is_branch(opcode: Opcode) -> bool { + matches!( + opcode, + Opcode::BEQ + | Opcode::BNE + | Opcode::BCC + | Opcode::BCS + | Opcode::BMI + | Opcode::BPL + | Opcode::BVC + | Opcode::BVS + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_lda_immediate_hex() { + let insts = parse_inline("LDA #$10").unwrap(); + assert_eq!(insts.len(), 1); + assert_eq!(insts[0].opcode, Opcode::LDA); + assert_eq!(insts[0].mode, AddressingMode::Immediate(0x10)); + } + + #[test] + fn parse_lda_immediate_decimal() { + let insts = parse_inline("LDA #42").unwrap(); + assert_eq!(insts[0].mode, AddressingMode::Immediate(42)); + } + + #[test] + fn parse_sta_zero_page() { + let insts = parse_inline("STA $10").unwrap(); + assert_eq!(insts[0].mode, AddressingMode::ZeroPage(0x10)); + } + + #[test] + fn parse_sta_absolute() { + let insts = parse_inline("STA $2007").unwrap(); + assert_eq!(insts[0].mode, AddressingMode::Absolute(0x2007)); + } + + #[test] + fn parse_lda_absolute_x() { + let insts = parse_inline("LDA $2000,X").unwrap(); + assert_eq!(insts[0].mode, AddressingMode::AbsoluteX(0x2000)); + } + + #[test] + fn parse_lda_zero_page_x() { + let insts = parse_inline("LDA $10,X").unwrap(); + assert_eq!(insts[0].mode, AddressingMode::ZeroPageX(0x10)); + } + + #[test] + fn parse_lda_indirect_y() { + let insts = parse_inline("LDA ($10),Y").unwrap(); + assert_eq!(insts[0].mode, AddressingMode::IndirectY(0x10)); + } + + #[test] + fn parse_jmp_indirect() { + let insts = parse_inline("JMP ($FFFC)").unwrap(); + assert_eq!(insts[0].mode, AddressingMode::Indirect(0xFFFC)); + } + + #[test] + fn parse_implied() { + let insts = parse_inline("CLC").unwrap(); + assert_eq!(insts[0].mode, AddressingMode::Implied); + } + + #[test] + fn parse_accumulator() { + let insts = parse_inline("LSR A").unwrap(); + assert_eq!(insts[0].mode, AddressingMode::Accumulator); + } + + #[test] + fn parse_label_and_branch() { + let insts = parse_inline( + r" + LDA #0 + loop: + INC $10 + BNE loop + RTS + ", + ) + .unwrap(); + // LDA, label, INC, BNE, RTS + assert_eq!(insts.len(), 5); + assert_eq!(insts[1].mode, AddressingMode::Label("loop".into())); + assert_eq!(insts[3].mode, AddressingMode::LabelRelative("loop".into())); + } + + #[test] + fn parse_jmp_label() { + let insts = parse_inline("JMP main").unwrap(); + assert_eq!(insts[0].mode, AddressingMode::Label("main".into())); + } + + #[test] + fn parse_comments_and_blanks() { + let insts = parse_inline( + r" + ; this is a comment + LDA #$00 ; inline comment + ", + ) + .unwrap(); + assert_eq!(insts.len(), 1); + } + + #[test] + fn parse_unknown_mnemonic_errors() { + let err = parse_inline("WTF $10").unwrap_err(); + assert!(err.contains("unknown mnemonic")); + } + + #[test] + fn parse_binary_immediate() { + let insts = parse_inline("LDA #%00001111").unwrap(); + assert_eq!(insts[0].mode, AddressingMode::Immediate(0x0F)); + } +} diff --git a/src/asm/mod.rs b/src/asm/mod.rs index 2f08f47..427693b 100644 --- a/src/asm/mod.rs +++ b/src/asm/mod.rs @@ -1,7 +1,9 @@ +mod inline_parser; mod opcodes; #[cfg(test)] mod tests; +pub use inline_parser::parse_inline; pub use opcodes::{AddressingMode, Instruction, Opcode}; use std::collections::HashMap; diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index 31c2fae..fc63230 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -488,6 +488,20 @@ impl<'a> IrCodeGen<'a> { self.emit_label(&pass_label); } } + 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 + // user-facing diagnostics cleanly. + match crate::asm::parse_inline(body) { + Ok(parsed) => self.instructions.extend(parsed), + Err(msg) => { + eprintln!("inline asm error: {msg}"); + self.emit(BRK, AM::Implied); + } + } + } IrOp::SourceLoc(_) => { // No code for source location markers } diff --git a/src/codegen/mod.rs b/src/codegen/mod.rs index a133b97..30df197 100644 --- a/src/codegen/mod.rs +++ b/src/codegen/mod.rs @@ -348,6 +348,13 @@ 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); + } + }, } } diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 54579a6..5fbee85 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -326,6 +326,9 @@ impl LoweringContext { let t = self.lower_expr(cond); self.emit(IrOp::DebugAssert(t)); } + Statement::InlineAsm(body, _) => { + self.emit(IrOp::InlineAsm(body.clone())); + } } } diff --git a/src/ir/mod.rs b/src/ir/mod.rs index f1c7295..16072aa 100644 --- a/src/ir/mod.rs +++ b/src/ir/mod.rs @@ -137,6 +137,8 @@ pub enum IrOp { /// Debug: runtime assertion — if `cond` is zero, halt with debug marker. /// Stripped in release mode by the codegen. DebugAssert(IrTemp), + /// Raw 6502 assembly text; parsed and emitted by the codegen. + InlineAsm(String), // Source mapping SourceLoc(Span), diff --git a/src/lexer/mod.rs b/src/lexer/mod.rs index 4287a75..1e75255 100644 --- a/src/lexer/mod.rs +++ b/src/lexer/mod.rs @@ -11,6 +11,10 @@ pub struct Lexer<'a> { pos: usize, file_id: u16, diagnostics: Vec, + /// When true, the next `{` that would normally be lexed as + /// `LBrace` triggers raw-text capture until the matching `}`. Set + /// right after emitting `KwAsm`. + asm_body_pending: bool, } impl<'a> Lexer<'a> { @@ -20,6 +24,7 @@ impl<'a> Lexer<'a> { pos: 0, file_id, diagnostics: Vec::new(), + asm_body_pending: false, } } @@ -88,6 +93,19 @@ impl<'a> Lexer<'a> { let start = self.pos; let ch = self.advance(); + // If we're right after `asm` and see `{`, consume the entire + // body until the matching `}` as a single `AsmBody` token. + if self.asm_body_pending && ch == b'{' { + self.asm_body_pending = false; + return Some(self.capture_asm_body(start)); + } + // Any non-`{` token clears the pending flag so we don't get + // confused by things like `asm ;` (which is a syntax error + // the parser will complain about). + if self.asm_body_pending { + self.asm_body_pending = false; + } + match ch { b'(' => Some(self.make_token(TokenKind::LParen, start)), b')' => Some(self.make_token(TokenKind::RParen, start)), @@ -440,7 +458,10 @@ impl<'a> Lexer<'a> { "load_background" => TokenKind::KwLoadBackground, "set_palette" => TokenKind::KwSetPalette, "scroll" => TokenKind::KwScroll, - "asm" => TokenKind::KwAsm, + "asm" => { + self.asm_body_pending = true; + TokenKind::KwAsm + } "raw" => TokenKind::KwRaw, "bank" => TokenKind::KwBank, "loop" => TokenKind::KwLoop, @@ -460,6 +481,33 @@ 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 `}`. + 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'}' { + self.pos += 1; + } + let body = String::from_utf8_lossy(&self.source[body_start..self.pos]).into_owned(); + if self.pos < self.source.len() { + // Consume the closing brace + self.pos += 1; + } else { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0101, + "unterminated `asm {` block", + self.span(start, self.pos), + )); + } + Token { + kind: TokenKind::AsmBody(body), + span: self.span(start, self.pos), + } + } +} + /// Convenience function for lexing a source string. pub fn lex(source: &str) -> (Vec, Vec) { Lexer::new(source, 0).lex() diff --git a/src/lexer/token.rs b/src/lexer/token.rs index 4ea73e6..7b91412 100644 --- a/src/lexer/token.rs +++ b/src/lexer/token.rs @@ -125,6 +125,11 @@ pub enum TokenKind { ShiftLeftAssign, ShiftRightAssign, + // Raw text from `asm { ... }` or `raw asm { ... }` blocks. + // The body is captured verbatim (including newlines) so the + // inline-asm parser can tokenize its own mnemonics. + AsmBody(String), + // Special Eof, } @@ -217,6 +222,7 @@ impl std::fmt::Display for TokenKind { Self::CaretAssign => write!(f, "^="), Self::ShiftLeftAssign => write!(f, "<<="), Self::ShiftRightAssign => write!(f, ">>="), + Self::AsmBody(_) => write!(f, ""), Self::Eof => write!(f, "EOF"), } } diff --git a/src/optimizer/mod.rs b/src/optimizer/mod.rs index d4de3a6..922e368 100644 --- a/src/optimizer/mod.rs +++ b/src/optimizer/mod.rs @@ -391,7 +391,11 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet) { IrOp::DebugAssert(cond) => { used.insert(*cond); } - IrOp::ReadInput(_, _) | IrOp::WaitFrame | IrOp::Transition(_) | IrOp::SourceLoc(_) => {} + IrOp::ReadInput(_, _) + | IrOp::WaitFrame + | IrOp::Transition(_) + | IrOp::InlineAsm(_) + | IrOp::SourceLoc(_) => {} } } @@ -427,6 +431,7 @@ fn op_dest(op: &IrOp) -> Option { | IrOp::Scroll(_, _) | IrOp::DebugLog(_) | IrOp::DebugAssert(_) + | IrOp::InlineAsm(_) | IrOp::SourceLoc(_) => None, } } diff --git a/src/parser/ast.rs b/src/parser/ast.rs index 8cde63d..282f9d4 100644 --- a/src/parser/ast.rs +++ b/src/parser/ast.rs @@ -245,6 +245,9 @@ pub enum Statement { /// debug.assert(cond) — runtime check, halts on failure. /// Stripped in release mode. DebugAssert(Expr, Span), + /// Inline 6502 assembly captured verbatim. The body is parsed by + /// the codegen stage using `asm::parse_inline`. + InlineAsm(String, Span), } impl Statement { @@ -266,7 +269,8 @@ impl Statement { | Self::SetPalette(_, s) | Self::Scroll(_, _, s) | Self::DebugLog(_, s) - | Self::DebugAssert(_, s) => *s, + | Self::DebugAssert(_, s) + | Self::InlineAsm(_, s) => *s, } } } diff --git a/src/parser/mod.rs b/src/parser/mod.rs index 765fa9a..29841d5 100644 --- a/src/parser/mod.rs +++ b/src/parser/mod.rs @@ -820,6 +820,22 @@ impl Parser { Ok(Statement::Scroll(x, y, span)) } TokenKind::KwDebug => self.parse_debug_statement(), + TokenKind::KwAsm => { + let span = self.current_span(); + self.advance(); // KwAsm + // The lexer emits an AsmBody token after `asm` when it + // sees the opening brace. Consume it here. + if let TokenKind::AsmBody(body) = self.peek().clone() { + self.advance(); + Ok(Statement::InlineAsm(body, span)) + } else { + Err(Diagnostic::error( + ErrorCode::E0201, + "expected `{` after `asm`", + self.current_span(), + )) + } + } TokenKind::Ident(_) => self.parse_assign_or_call(), _ => Err(Diagnostic::error( ErrorCode::E0201, diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 2f9c7a7..dd298a1 100644 --- a/tests/integration_test.rs +++ b/tests/integration_test.rs @@ -141,6 +141,27 @@ fn program_with_functions() { rom::validate_ines(&rom_data).expect("should be valid iNES"); } +#[test] +fn program_with_inline_asm() { + let source = r#" + game "Asm" { mapper: NROM } + var x: u8 = 0 + on frame { + asm { + LDA #$42 + STA $10 + INC $10 + LSR A + CLC + ADC #$01 + } + } + start Main + "#; + let rom_data = compile(source); + rom::validate_ines(&rom_data).expect("should be valid iNES"); +} + #[test] fn program_with_while_loop() { let source = r#"