From 496925344df7ae5ae4103afbccdb3b3c72baba21 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 12 Apr 2026 17:40:34 +0000 Subject: [PATCH] Language: poke() and peek() hardware intrinsics Common PPU/APU/mapper access previously required either variable aliases or inline asm. Now two built-in intrinsics handle the single-register case directly: poke(0x2006, 0x3F) // STA \$3F, \$2006 poke(0x2006, 0x00) poke(0x2007, 0x0F) var status: u8 = peek(0x2002) - Analyzer: \`poke\` / \`peek\` are recognized as built-in intrinsics so they don't require a function declaration. Arity is still checked (E0203 on mismatch). - IR: new \`IrOp::Poke(u16, IrTemp)\` and \`IrOp::Peek(IrTemp, u16)\` variants carrying the compile-time constant address. - IR lowering: recognizes the \`poke\`/\`peek\` call names, evaluates the address as a const expression, and emits the intrinsic op. Falls back to a regular call if the address isn't a constant. - IR codegen: emits a single LDA/STA in ZP or absolute mode based on whether the address fits in a byte. - Optimizer: Poke has a source temp (liveness), Peek has a dest (new value); both pass through the existing passes. https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3 --- docs/language-guide.md | 84 +++++++++++++++------------------------ src/analyzer/mod.rs | 42 +++++++++++++++++++- src/codegen/ir_codegen.rs | 16 ++++++++ src/ir/lowering.rs | 25 +++++++++++- src/ir/mod.rs | 4 ++ src/optimizer/mod.rs | 6 +++ tests/integration_test.rs | 20 ++++++++++ 7 files changed, 142 insertions(+), 55 deletions(-) diff --git a/docs/language-guide.md b/docs/language-guide.md index 4173247..cda56fb 100644 --- a/docs/language-guide.md +++ b/docs/language-guide.md @@ -795,36 +795,56 @@ In debug mode, the compiler inserts: --- +## Hardware Intrinsics + +For the common case of reading or writing a single PPU/APU/mapper +register, NEScript provides two built-in intrinsics: + +``` +poke(0x2006, 0x3F) // write $3F to PPU address register +poke(0x2006, 0x00) // (second half of the address) +poke(0x2007, 0x0F) // write a palette byte to PPU data + +var status: u8 = peek(0x2002) // read PPU status register +``` + +The address argument to both is a compile-time constant. Zero-page +addresses compile to `STA $XX` / `LDA $XX`; anything larger compiles +to absolute addressing. + ## Inline Assembly -For performance-critical code, drop to 6502 assembly: - -### Bound Assembly +For more elaborate sequences, use `asm { ... }` blocks: ``` fun fast_shift(input: u8) -> u8 { + var result: u8 = 0 asm { - lda {input} - asl a - asl a - sta {return} + LDA {input} + ASL A + ASL A + STA {result} } + return result } ``` -`{variable_name}` resolves to the variable's memory address. `{return}` is the return value location. +Inside an `asm` block, `{name}` is replaced with the resolved +zero-page or absolute address of the variable `name`. Labels +defined with `name:` are local to the block. ### Raw Assembly ``` raw asm { - .org $C000 - nop - rti + LDA #$42 + STA $2007 } ``` -Raw blocks bypass all compiler management. Use with extreme caution. +`raw asm` skips variable substitution — `{name}` is passed through +verbatim. Useful for completely unmanaged snippets that don't +reference NEScript variables. --- @@ -905,46 +925,6 @@ error[E0402]: recursion is not allowed --- -## Compiler Commands - -### 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/analyzer/mod.rs b/src/analyzer/mod.rs index b238ba9..76ca108 100644 --- a/src/analyzer/mod.rs +++ b/src/analyzer/mod.rs @@ -991,7 +991,9 @@ impl Analyzer { // `return value` silently — the value is simply discarded. } Statement::Call(name, args, span) => { - if self.symbols.contains_key(name) { + if is_intrinsic(name) { + self.check_intrinsic_args(name, args, *span); + } else if self.symbols.contains_key(name) { self.check_call_signature(name, args, *span); } else { self.diagnostics.push(Diagnostic::error( @@ -1336,6 +1338,44 @@ fn is_small_constant(expr: &Expr) -> bool { matches!(expr, Expr::IntLiteral(_, _)) } +/// True if `name` is a built-in intrinsic function recognized by the +/// compiler. Intrinsics don't need a declaration and may have +/// special codegen (e.g. \`poke\` / \`peek\` write to raw addresses). +fn is_intrinsic(name: &str) -> bool { + matches!(name, "poke" | "peek") +} + +impl Analyzer { + /// Validate the arguments to a built-in intrinsic. Emits + /// diagnostics for mismatched arity or non-constant addresses. + fn check_intrinsic_args(&mut self, name: &str, args: &[Expr], span: Span) { + match name { + "poke" => { + if args.len() != 2 { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0203, + format!( + "`poke` takes exactly 2 arguments (addr, value), got {}", + args.len() + ), + span, + )); + } + } + "peek" => { + if args.len() != 1 { + self.diagnostics.push(Diagnostic::error( + ErrorCode::E0203, + format!("`peek` takes exactly 1 argument (addr), got {}", args.len()), + span, + )); + } + } + _ => {} + } + } +} + /// True if this statement unconditionally ends block execution — /// subsequent statements in the same block cannot be reached. fn stmt_is_terminator(stmt: &Statement) -> bool { diff --git a/src/codegen/ir_codegen.rs b/src/codegen/ir_codegen.rs index ef97238..d3ae8bb 100644 --- a/src/codegen/ir_codegen.rs +++ b/src/codegen/ir_codegen.rs @@ -639,6 +639,22 @@ impl<'a> IrCodeGen<'a> { } } } + IrOp::Poke(addr, src) => { + self.load_temp(*src); + if *addr < 0x100 { + self.emit(STA, AM::ZeroPage(*addr as u8)); + } else { + self.emit(STA, AM::Absolute(*addr)); + } + } + IrOp::Peek(dest, addr) => { + if *addr < 0x100 { + self.emit(LDA, AM::ZeroPage(*addr as u8)); + } else { + self.emit(LDA, AM::Absolute(*addr)); + } + self.store_temp(*dest); + } IrOp::SourceLoc(_) => { // No code for source location markers } diff --git a/src/ir/lowering.rs b/src/ir/lowering.rs index 30a6a27..e51616d 100644 --- a/src/ir/lowering.rs +++ b/src/ir/lowering.rs @@ -411,8 +411,20 @@ impl LoweringContext { self.emit(IrOp::WaitFrame); } Statement::Call(name, args, _) => { - let arg_temps: Vec<_> = args.iter().map(|a| self.lower_expr(a)).collect(); - self.emit(IrOp::Call(None, name.clone(), arg_temps)); + match name.as_str() { + // Built-in `poke(addr, value)` — write a byte to + // a compile-time-constant address. + "poke" if args.len() == 2 => { + if let Some(addr) = self.eval_const(&args[0]) { + let val = self.lower_expr(&args[1]); + self.emit(IrOp::Poke(addr, val)); + } + } + _ => { + let arg_temps: Vec<_> = args.iter().map(|a| self.lower_expr(a)).collect(); + self.emit(IrOp::Call(None, name.clone(), arg_temps)); + } + } } Statement::Scroll(x_expr, y_expr, _) => { let x = self.lower_expr(x_expr); @@ -768,6 +780,15 @@ impl LoweringContext { t } Expr::Call(name, args, _) => { + // Built-in `peek(addr)` reads a byte from a fixed + // absolute address at compile time. + if name == "peek" && args.len() == 1 { + if let Some(addr) = self.eval_const(&args[0]) { + let t = self.fresh_temp(); + self.emit(IrOp::Peek(t, addr)); + return t; + } + } let arg_temps: Vec<_> = args.iter().map(|a| self.lower_expr(a)).collect(); let t = self.fresh_temp(); self.emit(IrOp::Call(Some(t), name.clone(), arg_temps)); diff --git a/src/ir/mod.rs b/src/ir/mod.rs index d60f450..9b8b223 100644 --- a/src/ir/mod.rs +++ b/src/ir/mod.rs @@ -139,6 +139,10 @@ pub enum IrOp { DebugAssert(IrTemp), /// Raw 6502 assembly text; parsed and emitted by the codegen. InlineAsm(String), + /// `poke(addr, value)` — STA value to a fixed absolute address. + Poke(u16, IrTemp), + /// `peek(addr)` — LDA from a fixed absolute address into a temp. + Peek(IrTemp, u16), // Source mapping SourceLoc(Span), diff --git a/src/optimizer/mod.rs b/src/optimizer/mod.rs index 922e368..ed726e1 100644 --- a/src/optimizer/mod.rs +++ b/src/optimizer/mod.rs @@ -391,10 +391,14 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet) { IrOp::DebugAssert(cond) => { used.insert(*cond); } + IrOp::Poke(_, src) => { + used.insert(*src); + } IrOp::ReadInput(_, _) | IrOp::WaitFrame | IrOp::Transition(_) | IrOp::InlineAsm(_) + | IrOp::Peek(_, _) | IrOp::SourceLoc(_) => {} } } @@ -423,6 +427,7 @@ fn op_dest(op: &IrOp) -> Option { | IrOp::ArrayLoad(d, _, _) => Some(*d), IrOp::Call(dest, _, _) => *dest, IrOp::ReadInput(d, _) => Some(*d), + IrOp::Peek(d, _) => Some(*d), IrOp::StoreVar(_, _) | IrOp::ArrayStore(_, _, _) | IrOp::DrawSprite { .. } @@ -432,6 +437,7 @@ fn op_dest(op: &IrOp) -> Option { | IrOp::DebugLog(_) | IrOp::DebugAssert(_) | IrOp::InlineAsm(_) + | IrOp::Poke(_, _) | IrOp::SourceLoc(_) => None, } } diff --git a/tests/integration_test.rs b/tests/integration_test.rs index 4aa6621..90a7cec 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_poke_peek_intrinsics() { + let source = r#" + game "Hardware" { mapper: NROM } + var status: u8 = 0 + on frame { + // Write to PPU address / data registers directly. + poke(0x2006, 0x3F) + poke(0x2006, 0x00) + poke(0x2007, 0x0F) + // Read PPU status. + status = peek(0x2002) + wait_frame + } + start Main + "#; + let rom_data = compile(source); + rom::validate_ines(&rom_data).expect("should be valid iNES"); +} + #[test] fn program_with_raw_asm_block() { // `raw asm` bypasses `{var}` substitution so the body is passed