mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 08:55:38 +00:00
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
This commit is contained in:
parent
a45944e0f9
commit
496925344d
7 changed files with 142 additions and 55 deletions
|
|
@ -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
|
## Inline Assembly
|
||||||
|
|
||||||
For performance-critical code, drop to 6502 assembly:
|
For more elaborate sequences, use `asm { ... }` blocks:
|
||||||
|
|
||||||
### Bound Assembly
|
|
||||||
|
|
||||||
```
|
```
|
||||||
fun fast_shift(input: u8) -> u8 {
|
fun fast_shift(input: u8) -> u8 {
|
||||||
|
var result: u8 = 0
|
||||||
asm {
|
asm {
|
||||||
lda {input}
|
LDA {input}
|
||||||
asl a
|
ASL A
|
||||||
asl a
|
ASL A
|
||||||
sta {return}
|
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 Assembly
|
||||||
|
|
||||||
```
|
```
|
||||||
raw asm {
|
raw asm {
|
||||||
.org $C000
|
LDA #$42
|
||||||
nop
|
STA $2007
|
||||||
rti
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
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
|
## Command Line
|
||||||
|
|
||||||
Compile a `.ne` source file into a `.nes` ROM:
|
Compile a `.ne` source file into a `.nes` ROM:
|
||||||
|
|
|
||||||
|
|
@ -991,7 +991,9 @@ impl Analyzer {
|
||||||
// `return value` silently — the value is simply discarded.
|
// `return value` silently — the value is simply discarded.
|
||||||
}
|
}
|
||||||
Statement::Call(name, args, span) => {
|
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);
|
self.check_call_signature(name, args, *span);
|
||||||
} else {
|
} else {
|
||||||
self.diagnostics.push(Diagnostic::error(
|
self.diagnostics.push(Diagnostic::error(
|
||||||
|
|
@ -1336,6 +1338,44 @@ fn is_small_constant(expr: &Expr) -> bool {
|
||||||
matches!(expr, Expr::IntLiteral(_, _))
|
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 —
|
/// True if this statement unconditionally ends block execution —
|
||||||
/// subsequent statements in the same block cannot be reached.
|
/// subsequent statements in the same block cannot be reached.
|
||||||
fn stmt_is_terminator(stmt: &Statement) -> bool {
|
fn stmt_is_terminator(stmt: &Statement) -> bool {
|
||||||
|
|
|
||||||
|
|
@ -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(_) => {
|
IrOp::SourceLoc(_) => {
|
||||||
// No code for source location markers
|
// No code for source location markers
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -411,8 +411,20 @@ impl LoweringContext {
|
||||||
self.emit(IrOp::WaitFrame);
|
self.emit(IrOp::WaitFrame);
|
||||||
}
|
}
|
||||||
Statement::Call(name, args, _) => {
|
Statement::Call(name, args, _) => {
|
||||||
let arg_temps: Vec<_> = args.iter().map(|a| self.lower_expr(a)).collect();
|
match name.as_str() {
|
||||||
self.emit(IrOp::Call(None, name.clone(), arg_temps));
|
// 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, _) => {
|
Statement::Scroll(x_expr, y_expr, _) => {
|
||||||
let x = self.lower_expr(x_expr);
|
let x = self.lower_expr(x_expr);
|
||||||
|
|
@ -768,6 +780,15 @@ impl LoweringContext {
|
||||||
t
|
t
|
||||||
}
|
}
|
||||||
Expr::Call(name, args, _) => {
|
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 arg_temps: Vec<_> = args.iter().map(|a| self.lower_expr(a)).collect();
|
||||||
let t = self.fresh_temp();
|
let t = self.fresh_temp();
|
||||||
self.emit(IrOp::Call(Some(t), name.clone(), arg_temps));
|
self.emit(IrOp::Call(Some(t), name.clone(), arg_temps));
|
||||||
|
|
|
||||||
|
|
@ -139,6 +139,10 @@ pub enum IrOp {
|
||||||
DebugAssert(IrTemp),
|
DebugAssert(IrTemp),
|
||||||
/// Raw 6502 assembly text; parsed and emitted by the codegen.
|
/// Raw 6502 assembly text; parsed and emitted by the codegen.
|
||||||
InlineAsm(String),
|
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
|
// Source mapping
|
||||||
SourceLoc(Span),
|
SourceLoc(Span),
|
||||||
|
|
|
||||||
|
|
@ -391,10 +391,14 @@ fn collect_source_temps(op: &IrOp, used: &mut HashSet<IrTemp>) {
|
||||||
IrOp::DebugAssert(cond) => {
|
IrOp::DebugAssert(cond) => {
|
||||||
used.insert(*cond);
|
used.insert(*cond);
|
||||||
}
|
}
|
||||||
|
IrOp::Poke(_, src) => {
|
||||||
|
used.insert(*src);
|
||||||
|
}
|
||||||
IrOp::ReadInput(_, _)
|
IrOp::ReadInput(_, _)
|
||||||
| IrOp::WaitFrame
|
| IrOp::WaitFrame
|
||||||
| IrOp::Transition(_)
|
| IrOp::Transition(_)
|
||||||
| IrOp::InlineAsm(_)
|
| IrOp::InlineAsm(_)
|
||||||
|
| IrOp::Peek(_, _)
|
||||||
| IrOp::SourceLoc(_) => {}
|
| IrOp::SourceLoc(_) => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -423,6 +427,7 @@ fn op_dest(op: &IrOp) -> Option<IrTemp> {
|
||||||
| IrOp::ArrayLoad(d, _, _) => Some(*d),
|
| IrOp::ArrayLoad(d, _, _) => Some(*d),
|
||||||
IrOp::Call(dest, _, _) => *dest,
|
IrOp::Call(dest, _, _) => *dest,
|
||||||
IrOp::ReadInput(d, _) => Some(*d),
|
IrOp::ReadInput(d, _) => Some(*d),
|
||||||
|
IrOp::Peek(d, _) => Some(*d),
|
||||||
IrOp::StoreVar(_, _)
|
IrOp::StoreVar(_, _)
|
||||||
| IrOp::ArrayStore(_, _, _)
|
| IrOp::ArrayStore(_, _, _)
|
||||||
| IrOp::DrawSprite { .. }
|
| IrOp::DrawSprite { .. }
|
||||||
|
|
@ -432,6 +437,7 @@ fn op_dest(op: &IrOp) -> Option<IrTemp> {
|
||||||
| IrOp::DebugLog(_)
|
| IrOp::DebugLog(_)
|
||||||
| IrOp::DebugAssert(_)
|
| IrOp::DebugAssert(_)
|
||||||
| IrOp::InlineAsm(_)
|
| IrOp::InlineAsm(_)
|
||||||
|
| IrOp::Poke(_, _)
|
||||||
| IrOp::SourceLoc(_) => None,
|
| IrOp::SourceLoc(_) => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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_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]
|
#[test]
|
||||||
fn program_with_raw_asm_block() {
|
fn program_with_raw_asm_block() {
|
||||||
// `raw asm` bypasses `{var}` substitution so the body is passed
|
// `raw asm` bypasses `{var}` substitution so the body is passed
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue