1
0
Fork 0
mirror of https://github.com/imjasonh/nescript synced 2026-07-08 00:45:38 +00:00
nescript/src/ir/lowering.rs
Claude 7294ae3efa
analyzer/lowering: support nested struct fields and array struct fields
Struct field types beyond the v1 scalar set (`u8`, `i8`, `u16`,
`bool`) used to error out with `E0201: struct fields must be
u8/i8/u16/bool`. The size accumulator already handled them
correctly — what was missing was: (1) the analyzer side that
synthesizes per-leaf symbols and allocations for nested structs
plus a single array-typed symbol for array fields, (2) the
parser's chained-field-access path, and (3) the IR-lowering
recursion through nested struct literal initializers and array
literal field values.

The synthetic-variable model carries through unchanged: a
`var p: Player` where `Player { pos: Vec2, hp: u8, inv: u8[4] }`
and `Vec2 { x: u8, y: u8 }` produces flat allocations for
`p.pos.x`, `p.pos.y`, `p.hp`, and `p.inv`, plus an intermediate
`p.pos` Struct symbol so dotted-name lookups still resolve. Array
fields get a single allocation with the array type so the
existing `Expr::ArrayIndex` lowering path handles `p.inv[i]`
without changes. Array-of-structs is still rejected with E0201
because the synthetic model can't index per-element layouts
without further codegen work.

The parser change is the only structural move: `parse_primary`
and `parse_assign_or_call` now loop the dot chain into a single
joined identifier so `p.pos.x` becomes `FieldAccess("p.pos", "x")`
and `p.inv[0]` becomes `ArrayIndex("p.inv", 0)`. The downstream
analyzer and IR lowering use the same `format!("{name}.{field}")`
join they already used for one-level access — no plumbing
changes required.

Includes a new `examples/nested_structs.ne` that exercises both
features end-to-end with two `Hero` instances carrying nested
positions and inventory arrays. The reproducibility tripwire
ROM is committed alongside it and the emulator harness has a
matching pair of golden files.

https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
2026-04-15 02:19:49 +00:00

1459 lines
58 KiB
Rust

use std::collections::HashMap;
use super::*;
use crate::analyzer::AnalysisResult;
use crate::parser::ast::*;
/// Marker prefix the lowering prepends to the body of a `raw asm`
/// block, telling the codegen to skip `{var}` substitution. Uses
/// NUL characters so no normal source text can spoof it.
pub const RAW_ASM_PREFIX: &str = "\0RAW\0";
/// Lower a parsed & analyzed program into IR.
pub fn lower(program: &Program, analysis: &AnalysisResult) -> IrProgram {
let mut ctx = LoweringContext::new(analysis);
ctx.lower_program(program);
ctx.finish()
}
struct LoweringContext {
functions: Vec<IrFunction>,
globals: Vec<IrGlobal>,
rom_data: Vec<IrRomBlock>,
var_map: HashMap<String, VarId>,
const_values: HashMap<String, u16>,
/// Type of each named variable (resolved from the analyzer's
/// symbol table). Used to decide between 8-bit and 16-bit IR
/// ops for identifier reads/writes and binary operations.
var_types: HashMap<String, NesType>,
next_var_id: u32,
next_temp: u32,
next_block: u32,
// Current function being built
current_blocks: Vec<IrBasicBlock>,
current_ops: Vec<IrOp>,
current_label: String,
current_locals: Vec<IrLocal>,
// Loop context for break/continue
loop_stack: Vec<LoopContext>,
// State metadata captured from the AST
state_names: Vec<String>,
start_state: String,
/// Map from a byte temp (used as the "low byte" of a wide
/// value) to the matching high byte temp. Temps not in the
/// map are plain 8-bit byte temps. Populated by
/// `lower_expr_wide` when it produces a u16 result; consumed
/// by binary-op, compare, and assignment lowering when they
/// need to decide between `Add`/`Add16`, etc.
wide_hi: HashMap<IrTemp, IrTemp>,
}
struct LoopContext {
continue_label: String,
break_label: String,
}
impl LoweringContext {
fn new(analysis: &AnalysisResult) -> Self {
let mut var_map = HashMap::new();
let mut next_var_id = 0u32;
// Pre-register all allocated variables
for alloc in &analysis.var_allocations {
var_map.insert(alloc.name.clone(), VarId(next_var_id));
next_var_id += 1;
}
// Capture the type of each named variable from the
// analyzer's symbol table. This lets the lowering decide
// whether an identifier read should expand to a Byte or
// Word value — which in turn controls whether binary ops
// emit 8-bit or 16-bit IR.
let mut var_types = HashMap::new();
for (name, sym) in &analysis.symbols {
var_types.insert(name.clone(), sym.sym_type.clone());
}
Self {
functions: Vec::new(),
globals: Vec::new(),
rom_data: Vec::new(),
var_map,
const_values: HashMap::new(),
var_types,
next_var_id,
next_temp: 0,
next_block: 0,
current_blocks: Vec::new(),
current_ops: Vec::new(),
current_label: String::new(),
current_locals: Vec::new(),
loop_stack: Vec::new(),
state_names: Vec::new(),
start_state: String::new(),
wide_hi: HashMap::new(),
}
}
/// Register a function parameter's type in the `var_types` map
/// so that identifier reads inside the function body know
/// whether to load as a byte or a word.
fn register_param_type(&mut self, name: &str, ty: &NesType) {
self.var_types.insert(name.to_string(), ty.clone());
}
fn fresh_temp(&mut self) -> IrTemp {
let t = IrTemp(self.next_temp);
self.next_temp += 1;
t
}
fn fresh_label(&mut self, prefix: &str) -> String {
self.next_block += 1;
format!("{prefix}_{}", self.next_block)
}
fn get_or_create_var(&mut self, name: &str) -> VarId {
if let Some(&id) = self.var_map.get(name) {
id
} else {
let id = VarId(self.next_var_id);
self.next_var_id += 1;
self.var_map.insert(name.to_string(), id);
id
}
}
/// Recursively expand a struct-literal global initializer into
/// per-leaf-field `IrGlobal` entries. Handles three field-value
/// shapes:
///
/// - Scalar constant expressions (e.g. `x: 5`) → emit one
/// `IrGlobal` whose `init_value` is the folded constant.
/// - Nested struct literals (e.g. `pos: Vec2 { x: 1, y: 2 }`)
/// → recurse with `base_name = "outer.pos"`, expanding the
/// inner literal's fields under the dotted path.
/// - Array literals (e.g. `inv: [1, 2, 3, 4]`) → emit one
/// `IrGlobal` whose `init_array` carries the per-byte values.
///
/// Each leaf global's size is derived from the analyzer's
/// recorded field type so `u16` fields still claim two bytes.
fn expand_struct_literal_init(&mut self, base_name: &str, fields: &[(String, Expr)]) {
for (fname, fexpr) in fields {
let full = format!("{base_name}.{fname}");
let fvid = self.get_or_create_var(&full);
let field_type = self.var_types.get(&full).cloned();
match fexpr {
Expr::StructLiteral(_, inner_fields, _) => {
// Register the intermediate symbol with size 0 —
// its byte-allocation lives in the leaves, but
// the IR codegen still needs a global record so
// that name lookups don't fail.
self.globals.push(IrGlobal {
var_id: fvid,
name: full.clone(),
size: 0,
init_value: None,
init_array: Vec::new(),
});
self.expand_struct_literal_init(&full, inner_fields);
}
Expr::ArrayLiteral(elems, _) => {
let init_array: Vec<u8> = elems
.iter()
.filter_map(|e| self.eval_const(e).map(|v| v as u8))
.collect();
let size = type_size(field_type.as_ref().unwrap_or(&NesType::U8));
self.globals.push(IrGlobal {
var_id: fvid,
name: full,
size,
init_value: None,
init_array,
});
}
_ => {
let fval = self.eval_const(fexpr);
let size = match field_type {
Some(NesType::U16) => 2,
_ => 1,
};
self.globals.push(IrGlobal {
var_id: fvid,
name: full,
size,
init_value: fval,
init_array: Vec::new(),
});
}
}
}
}
/// Try to evaluate an expression at compile time, using the
/// already-registered constants as operands. Returns `None` if
/// the expression references something that isn't known at this
/// point (e.g. a runtime variable) or contains an operator we
/// don't constant-fold. The result is a u16 to keep the same
/// range as the AST integer literal type.
fn eval_const(&self, expr: &Expr) -> Option<u16> {
match expr {
Expr::IntLiteral(v, _) => Some(*v),
Expr::BoolLiteral(b, _) => Some(u16::from(*b)),
Expr::Ident(name, _) => self.const_values.get(name).copied(),
Expr::BinaryOp(lhs, op, rhs, _) => {
let l = self.eval_const(lhs)?;
let r = self.eval_const(rhs)?;
match op {
BinOp::Add => Some(l.wrapping_add(r)),
BinOp::Sub => Some(l.wrapping_sub(r)),
BinOp::Mul => Some(l.wrapping_mul(r)),
BinOp::Div if r != 0 => Some(l / r),
BinOp::Mod if r != 0 => Some(l % r),
BinOp::BitwiseAnd => Some(l & r),
BinOp::BitwiseOr => Some(l | r),
BinOp::BitwiseXor => Some(l ^ r),
BinOp::ShiftLeft => Some(l.wrapping_shl(u32::from(r))),
BinOp::ShiftRight => Some(l.wrapping_shr(u32::from(r))),
BinOp::Eq => Some(u16::from(l == r)),
BinOp::NotEq => Some(u16::from(l != r)),
BinOp::Lt => Some(u16::from(l < r)),
BinOp::Gt => Some(u16::from(l > r)),
BinOp::LtEq => Some(u16::from(l <= r)),
BinOp::GtEq => Some(u16::from(l >= r)),
_ => None,
}
}
Expr::UnaryOp(op, inner, _) => {
let v = self.eval_const(inner)?;
match op {
UnaryOp::Negate => Some(v.wrapping_neg()),
UnaryOp::BitNot => Some(!v),
UnaryOp::Not => Some(u16::from(v == 0)),
}
}
Expr::Cast(inner, _, _) => self.eval_const(inner),
_ => None,
}
}
fn emit(&mut self, op: IrOp) {
self.current_ops.push(op);
}
fn start_block(&mut self, label: &str) {
self.current_label = label.to_string();
self.current_ops = Vec::new();
}
fn end_block(&mut self, terminator: IrTerminator) {
self.current_blocks.push(IrBasicBlock {
label: self.current_label.clone(),
ops: std::mem::take(&mut self.current_ops),
terminator,
});
}
fn finish(self) -> IrProgram {
IrProgram {
functions: self.functions,
globals: self.globals,
rom_data: self.rom_data,
states: self.state_names,
start_state: self.start_state,
}
}
fn lower_program(&mut self, program: &Program) {
// Capture state metadata before lowering
self.state_names = program.states.iter().map(|s| s.name.clone()).collect();
program.start_state.clone_into(&mut self.start_state);
// Register enum variants first so constants that reference
// them (e.g. `const FIRST: u8 = VariantA`) can resolve.
for e in &program.enums {
for (i, (variant, _)) in e.variants.iter().enumerate() {
self.const_values.insert(variant.clone(), i as u16);
}
}
// Register constants with constant-evaluation. Each const
// may reference earlier constants.
for c in &program.constants {
if let Some(v) = self.eval_const(&c.value) {
self.const_values.insert(c.name.clone(), v);
}
}
// Lower globals. Initializers can be any constant expression.
// Struct-literal initializers are expanded into per-field
// globals so each field gets its own `init_value`; the parent
// struct itself is still registered (size=0) so any later IR
// op referencing it by name still resolves. Array-literal
// initializers are lowered into `init_array` on the parent
// global — the IR codegen's startup loop emits one LDA/STA
// per byte into the global's base address. Nested struct
// literals (`Player { pos: Vec2 { x: 1, y: 2 }, ... }`)
// and array-literal field values (`Hero { inv: [1,2,3,4] }`)
// are expanded recursively below.
for var in &program.globals {
let var_id = self.get_or_create_var(&var.name);
let init = var.init.as_ref().and_then(|e| self.eval_const(e));
let init_array = match &var.init {
Some(Expr::ArrayLiteral(elems, _)) => elems
.iter()
.filter_map(|e| self.eval_const(e).map(|v| v as u8))
.collect(),
_ => Vec::new(),
};
self.globals.push(IrGlobal {
var_id,
name: var.name.clone(),
size: type_size(&var.var_type),
init_value: init,
init_array,
});
if let Some(Expr::StructLiteral(_, fields, _)) = &var.init {
self.expand_struct_literal_init(&var.name, fields);
}
}
// Lower user functions
for fun in &program.functions {
self.lower_function(fun);
}
// Lower state handlers
for state in &program.states {
self.lower_state(state, state.name == program.start_state);
}
}
fn lower_function(&mut self, fun: &FunDecl) {
self.next_temp = 0;
self.current_blocks = Vec::new();
self.current_locals = Vec::new();
// Register parameters as locals
for param in &fun.params {
let var_id = self.get_or_create_var(&param.name);
self.current_locals.push(IrLocal {
var_id,
name: param.name.clone(),
size: type_size(&param.param_type),
});
self.register_param_type(&param.name, &param.param_type);
}
let entry = self.fresh_label(&format!("fn_{}_entry", fun.name));
self.start_block(&entry);
self.lower_block(&fun.body);
// Ensure the function ends with a return
if self.current_ops.is_empty()
|| !matches!(
self.current_blocks.last().map(|b| &b.terminator),
Some(IrTerminator::Return(_))
)
{
self.end_block(IrTerminator::Return(None));
}
self.functions.push(IrFunction {
name: fun.name.clone(),
blocks: std::mem::take(&mut self.current_blocks),
locals: std::mem::take(&mut self.current_locals),
param_count: fun.params.len(),
has_return: fun.return_type.is_some(),
bank: fun.bank.clone(),
source_span: fun.span,
});
}
fn lower_state(&mut self, state: &StateDecl, _is_start: bool) {
// Lower each event handler as a separate function
if let Some(on_enter) = &state.on_enter {
self.lower_handler(&format!("{}_enter", state.name), on_enter, state);
}
if let Some(on_exit) = &state.on_exit {
self.lower_handler(&format!("{}_exit", state.name), on_exit, state);
}
if let Some(on_frame) = &state.on_frame {
self.lower_handler(&format!("{}_frame", state.name), on_frame, state);
}
// Lower each scanline handler as a function named
// `{state}_scanline_{N}`. The IR codegen will generate the MMC3
// IRQ dispatch wrapper separately.
for (line, block) in &state.on_scanline {
let name = format!("{}_scanline_{line}", state.name);
self.lower_handler(&name, block, state);
}
}
fn lower_handler(&mut self, name: &str, block: &Block, state: &StateDecl) {
self.next_temp = 0;
self.current_blocks = Vec::new();
// Seed `current_locals` with the state's declared locals so any
// `VarDecl` inside the handler body — tracked by
// `lower_statement` via `current_locals` — is appended alongside
// them. Without this, handler-local variables (e.g. a `var i`
// inside a `while`) would get orphaned: their `VarId` would be
// created by `get_or_create_var`, but the `IrFunction`'s
// `locals` list (which the IR codegen uses to allocate RAM
// addresses) would never see them. The result would be a
// silent `LoadVar`/`StoreVar` emit-nothing bug that leaves the
// temp slots uninitialized at runtime.
self.current_locals = Vec::new();
for var in &state.locals {
let var_id = self.get_or_create_var(&var.name);
self.current_locals.push(IrLocal {
var_id,
name: var.name.clone(),
size: type_size(&var.var_type),
});
}
let entry = self.fresh_label(&format!("{name}_entry"));
self.start_block(&entry);
self.lower_block(block);
self.end_block(IrTerminator::Return(None));
self.functions.push(IrFunction {
name: name.to_string(),
blocks: std::mem::take(&mut self.current_blocks),
locals: std::mem::take(&mut self.current_locals),
param_count: 0,
has_return: false,
// State handlers always live in the fixed bank — the
// analyzer rejects state-handler nesting inside `bank`
// blocks because the NMI dispatcher and reset path JSR
// into them directly without going through a trampoline.
bank: None,
source_span: state.span,
});
}
fn lower_block(&mut self, block: &Block) {
for stmt in &block.statements {
self.lower_statement(stmt);
}
}
fn lower_statement(&mut self, stmt: &Statement) {
// Emit a source-location marker before every statement we
// lower. The codegen turns these into label-definition
// pseudo-ops (`__src_<file>_<byte>_<line>_<col>`), which
// the linker then reports back to the CLI so it can emit a
// source map. Release builds don't need the map, but we
// still leave the markers in — they lower to zero bytes in
// codegen, so there's no ROM cost.
self.emit(IrOp::SourceLoc(stmt.span()));
match stmt {
Statement::VarDecl(var) => {
let var_id = self.get_or_create_var(&var.name);
// Track every local declared inside the current
// function so the IR codegen can allocate backing
// storage (e.g. RAM) for it.
if !self.current_locals.iter().any(|l| l.var_id == var_id) {
self.current_locals.push(IrLocal {
var_id,
name: var.name.clone(),
size: type_size(&var.var_type),
});
}
// Seed the var_types map for local declarations so
// subsequent references lower with the right width.
self.var_types
.insert(var.name.clone(), var.var_type.clone());
if let Some(init) = &var.init {
// Struct literal initializers expand to per-field
// stores on the synthetic field variables.
if let Expr::StructLiteral(_, fields, _) = init {
for (fname, fexpr) in fields {
let full = format!("{}.{fname}", var.name);
let fvid = self.get_or_create_var(&full);
let val = self.lower_expr(fexpr);
self.emit(IrOp::StoreVar(fvid, val));
}
} else {
let val = self.lower_expr(init);
self.emit(IrOp::StoreVar(var_id, val));
// u16 var: write the high byte too, zero-
// extending narrow initializers.
if matches!(var.var_type, NesType::U16) {
let (_, hi) = self.widen(val);
self.emit(IrOp::StoreVarHi(var_id, hi));
}
}
}
}
Statement::Assign(lvalue, op, expr, _) => {
self.lower_assign(lvalue, *op, expr);
}
Statement::If(cond, then_block, else_ifs, else_block, _) => {
self.lower_if(cond, then_block, else_ifs, else_block.as_ref());
}
Statement::While(cond, body, _) => {
self.lower_while(cond, body);
}
Statement::Loop(body, _) => {
self.lower_loop(body);
}
Statement::For {
var,
start,
end,
body,
..
} => {
// Desugar `for var in start..end { body }` into:
// var = start
// while var < end { body; var = var + 1 }
let var_id = self.get_or_create_var(var);
// The loop variable is implicitly declared by the
// `for` statement — track it as a local so the IR
// codegen allocates backing storage. Without this
// the `StoreVar`/`LoadVar` ops for the counter are
// silently dropped by `IrCodeGen` (`var_addrs`
// has no entry), making the counter permanently 0
// and turning the loop into an infinite one. Same
// class of bug as handler-local `var` decls before
// the earlier fix.
if !self.current_locals.iter().any(|l| l.var_id == var_id) {
self.current_locals.push(IrLocal {
var_id,
name: var.clone(),
size: 1,
});
}
let start_temp = self.lower_expr(start);
self.emit(IrOp::StoreVar(var_id, start_temp));
// Precompute the end value once outside the loop
// header so subsequent iterations don't recompute it.
// (For a literal, the optimizer collapses this.)
self.lower_for_body(var_id, end, body);
}
Statement::Break(_) => {
if let Some(ctx) = self.loop_stack.last() {
let label = ctx.break_label.clone();
self.end_block(IrTerminator::Jump(label.clone()));
let cont = self.fresh_label("after_break");
self.start_block(&cont);
}
}
Statement::Continue(_) => {
if let Some(ctx) = self.loop_stack.last() {
let label = ctx.continue_label.clone();
self.end_block(IrTerminator::Jump(label.clone()));
let cont = self.fresh_label("after_continue");
self.start_block(&cont);
}
}
Statement::Return(value, _) => {
let temp = value.as_ref().map(|e| self.lower_expr(e));
self.end_block(IrTerminator::Return(temp));
let cont = self.fresh_label("after_return");
self.start_block(&cont);
}
Statement::Draw(draw) => {
let x = self.lower_expr(&draw.x);
let y = self.lower_expr(&draw.y);
let frame = draw.frame.as_ref().map(|e| self.lower_expr(e));
self.emit(IrOp::DrawSprite {
sprite_name: draw.sprite_name.clone(),
x,
y,
frame,
});
}
Statement::Transition(name, _) => {
self.emit(IrOp::Transition(name.clone()));
}
Statement::WaitFrame(_) => {
self.emit(IrOp::WaitFrame);
}
Statement::Call(name, args, _) => {
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);
let y = self.lower_expr(y_expr);
self.emit(IrOp::Scroll(x, y));
}
Statement::SetPalette(name, _) => {
self.emit(IrOp::SetPalette(name.clone()));
}
Statement::LoadBackground(name, _) => {
self.emit(IrOp::LoadBackground(name.clone()));
}
Statement::DebugLog(args, _) => {
let temps: Vec<_> = args.iter().map(|a| self.lower_expr(a)).collect();
self.emit(IrOp::DebugLog(temps));
}
Statement::DebugAssert(cond, _) => {
let t = self.lower_expr(cond);
self.emit(IrOp::DebugAssert(t));
}
Statement::InlineAsm(body, _) => {
self.emit(IrOp::InlineAsm(body.clone()));
}
Statement::RawAsm(body, _) => {
// Raw asm skips `{var}` substitution. We reuse the
// same IR op variant but mark the body with a magic
// prefix the codegen can detect — simpler than
// adding a separate IrOp.
self.emit(IrOp::InlineAsm(format!("{RAW_ASM_PREFIX}{body}")));
}
Statement::Play(name, _) => {
self.emit(IrOp::PlaySfx(name.clone()));
}
Statement::StartMusic(name, _) => {
self.emit(IrOp::StartMusic(name.clone()));
}
Statement::StopMusic(_) => {
self.emit(IrOp::StopMusic);
}
}
}
fn lower_assign(&mut self, lvalue: &LValue, op: AssignOp, expr: &Expr) {
// Special case: `var = StructLiteral { ... }` expands to
// per-field stores against the analyzer-synthesized field
// variables. This avoids needing struct values as IR temps.
if let (LValue::Var(name), AssignOp::Assign, Expr::StructLiteral(_, fields, _)) =
(lvalue, op, expr)
{
for (fname, fexpr) in fields {
let full = format!("{name}.{fname}");
let field_var = self.get_or_create_var(&full);
let val = self.lower_expr(fexpr);
self.emit(IrOp::StoreVar(field_var, val));
// u16 fields need the high byte written too — the
// `widen` helper yields a zero-extended high temp
// when the RHS is narrow.
if matches!(self.var_types.get(&full), Some(NesType::U16)) {
let (_, val_hi) = self.widen(val);
self.emit(IrOp::StoreVarHi(field_var, val_hi));
}
}
return;
}
match lvalue {
LValue::Var(name) => {
let var_id = self.get_or_create_var(name);
// Is the destination a u16 variable? Wide vars need
// both bytes written on every assignment, otherwise
// the high byte silently stays stale.
let dest_is_u16 = matches!(self.var_types.get(name), Some(NesType::U16));
match op {
AssignOp::Assign => {
let val = self.lower_expr(expr);
self.emit(IrOp::StoreVar(var_id, val));
if dest_is_u16 {
// Narrow value: zero-extend.
let (_, val_hi) = self.widen(val);
self.emit(IrOp::StoreVarHi(var_id, val_hi));
}
}
_ => {
// Load current value. For u16, load both bytes
// and register as wide so binary-op lowering
// uses the 16-bit path.
let current = self.fresh_temp();
self.emit(IrOp::LoadVar(current, var_id));
if dest_is_u16 {
let current_hi = self.fresh_temp();
self.emit(IrOp::LoadVarHi(current_hi, var_id));
self.make_wide(current, current_hi);
}
let rhs = self.lower_expr(expr);
let result = self.fresh_temp();
let wide = dest_is_u16 || self.is_wide(current) || self.is_wide(rhs);
if wide && matches!(op, AssignOp::PlusAssign | AssignOp::MinusAssign) {
let (a_lo, a_hi) = self.widen(current);
let (b_lo, b_hi) = self.widen(rhs);
let d_hi = self.fresh_temp();
match op {
AssignOp::PlusAssign => self.emit(IrOp::Add16 {
d_lo: result,
d_hi,
a_lo,
a_hi,
b_lo,
b_hi,
}),
AssignOp::MinusAssign => self.emit(IrOp::Sub16 {
d_lo: result,
d_hi,
a_lo,
a_hi,
b_lo,
b_hi,
}),
_ => unreachable!(),
}
self.make_wide(result, d_hi);
self.emit(IrOp::StoreVar(var_id, result));
if dest_is_u16 {
self.emit(IrOp::StoreVarHi(var_id, d_hi));
}
} else {
let ir_op = compound_assign_op(op, result, current, rhs, expr, self);
self.emit(ir_op);
self.emit(IrOp::StoreVar(var_id, result));
if dest_is_u16 {
// High byte unchanged by 8-bit op; keep
// the previously-loaded high byte.
let (_, cur_hi) = self.widen(current);
self.emit(IrOp::StoreVarHi(var_id, cur_hi));
}
}
}
}
}
LValue::ArrayIndex(name, index) => {
let var_id = self.get_or_create_var(name);
let idx = self.lower_expr(index);
let val = self.lower_expr(expr);
// For compound assignment on arrays, load first
if op == AssignOp::Assign {
self.emit(IrOp::ArrayStore(var_id, idx, val));
} else {
let current = self.fresh_temp();
self.emit(IrOp::ArrayLoad(current, var_id, idx));
let result = self.fresh_temp();
let ir_op = compound_assign_op(op, result, current, val, expr, self);
self.emit(ir_op);
self.emit(IrOp::ArrayStore(var_id, idx, result));
}
}
LValue::Field(name, field) => {
// The analyzer synthesizes a variable named
// `"struct.field"` for each struct field, so we can
// treat field assignment as a regular variable
// assignment to that synthetic name. u16 fields
// follow the same two-byte path as u16 globals.
let full_name = format!("{name}.{field}");
let var_id = self.get_or_create_var(&full_name);
let dest_is_u16 = matches!(self.var_types.get(&full_name), Some(NesType::U16));
match op {
AssignOp::Assign => {
let val = self.lower_expr(expr);
self.emit(IrOp::StoreVar(var_id, val));
if dest_is_u16 {
// Narrow value: zero-extend via widen
// (which returns the original hi temp if
// the value is already wide).
let (_, val_hi) = self.widen(val);
self.emit(IrOp::StoreVarHi(var_id, val_hi));
}
}
_ => {
let current = self.fresh_temp();
self.emit(IrOp::LoadVar(current, var_id));
if dest_is_u16 {
let current_hi = self.fresh_temp();
self.emit(IrOp::LoadVarHi(current_hi, var_id));
self.make_wide(current, current_hi);
}
let rhs = self.lower_expr(expr);
let result = self.fresh_temp();
let wide = dest_is_u16 || self.is_wide(current) || self.is_wide(rhs);
if wide && matches!(op, AssignOp::PlusAssign | AssignOp::MinusAssign) {
let (a_lo, a_hi) = self.widen(current);
let (b_lo, b_hi) = self.widen(rhs);
let d_hi = self.fresh_temp();
match op {
AssignOp::PlusAssign => self.emit(IrOp::Add16 {
d_lo: result,
d_hi,
a_lo,
a_hi,
b_lo,
b_hi,
}),
AssignOp::MinusAssign => self.emit(IrOp::Sub16 {
d_lo: result,
d_hi,
a_lo,
a_hi,
b_lo,
b_hi,
}),
_ => unreachable!(),
}
self.make_wide(result, d_hi);
self.emit(IrOp::StoreVar(var_id, result));
if dest_is_u16 {
self.emit(IrOp::StoreVarHi(var_id, d_hi));
}
} else {
let ir_op = compound_assign_op(op, result, current, rhs, expr, self);
self.emit(ir_op);
self.emit(IrOp::StoreVar(var_id, result));
if dest_is_u16 {
// High byte unchanged by 8-bit op;
// keep the previously-loaded high
// byte.
let (_, cur_hi) = self.widen(current);
self.emit(IrOp::StoreVarHi(var_id, cur_hi));
}
}
}
}
}
}
}
fn lower_if(
&mut self,
cond: &Expr,
then_block: &Block,
else_ifs: &[(Expr, Block)],
else_block: Option<&Block>,
) {
let end_label = self.fresh_label("if_end");
let cond_temp = self.lower_expr(cond);
let then_label = self.fresh_label("if_then");
let else_label = if else_ifs.is_empty() && else_block.is_none() {
end_label.clone()
} else {
self.fresh_label("if_else")
};
self.end_block(IrTerminator::Branch(
cond_temp,
then_label.clone(),
else_label.clone(),
));
// Then block
self.start_block(&then_label);
self.lower_block(then_block);
self.end_block(IrTerminator::Jump(end_label.clone()));
// Else-if chains
let mut current_else = else_label;
for (i, (elif_cond, elif_block)) in else_ifs.iter().enumerate() {
self.start_block(&current_else);
let cond_temp = self.lower_expr(elif_cond);
let elif_then = self.fresh_label("elif_then");
let elif_else = if i + 1 < else_ifs.len() || else_block.is_some() {
self.fresh_label("elif_else")
} else {
end_label.clone()
};
self.end_block(IrTerminator::Branch(
cond_temp,
elif_then.clone(),
elif_else.clone(),
));
self.start_block(&elif_then);
self.lower_block(elif_block);
self.end_block(IrTerminator::Jump(end_label.clone()));
current_else = elif_else;
}
// Else block
if let Some(block) = else_block {
self.start_block(&current_else);
self.lower_block(block);
self.end_block(IrTerminator::Jump(end_label.clone()));
}
self.start_block(&end_label);
}
fn lower_while(&mut self, cond: &Expr, body: &Block) {
let cond_label = self.fresh_label("while_cond");
let body_label = self.fresh_label("while_body");
let end_label = self.fresh_label("while_end");
self.end_block(IrTerminator::Jump(cond_label.clone()));
// Condition check
self.start_block(&cond_label);
let cond_temp = self.lower_expr(cond);
self.end_block(IrTerminator::Branch(
cond_temp,
body_label.clone(),
end_label.clone(),
));
// Body
self.loop_stack.push(LoopContext {
continue_label: cond_label,
break_label: end_label.clone(),
});
self.start_block(&body_label);
self.lower_block(body);
let cond_label = &self.loop_stack.last().unwrap().continue_label.clone();
self.end_block(IrTerminator::Jump(cond_label.clone()));
self.loop_stack.pop();
self.start_block(&end_label);
}
/// Lower the loop body for a `for var in start..end { body }`.
/// Assumes `var` has already been initialized to the start
/// value. Emits the condition `var < end` each iteration and
/// increments `var` at the continue edge.
fn lower_for_body(&mut self, var_id: VarId, end: &Expr, body: &Block) {
let cond_label = self.fresh_label("for_cond");
let body_label = self.fresh_label("for_body");
let end_label = self.fresh_label("for_end");
self.end_block(IrTerminator::Jump(cond_label.clone()));
// Condition: var < end
self.start_block(&cond_label);
let var_temp = self.fresh_temp();
self.emit(IrOp::LoadVar(var_temp, var_id));
let end_temp = self.lower_expr(end);
let cmp_temp = self.fresh_temp();
self.emit(IrOp::CmpLt(cmp_temp, var_temp, end_temp));
self.end_block(IrTerminator::Branch(
cmp_temp,
body_label.clone(),
end_label.clone(),
));
// Body + increment.
let step_label = self.fresh_label("for_step");
self.loop_stack.push(LoopContext {
continue_label: step_label.clone(),
break_label: end_label.clone(),
});
self.start_block(&body_label);
self.lower_block(body);
self.end_block(IrTerminator::Jump(step_label.clone()));
self.loop_stack.pop();
// Step: var = var + 1
self.start_block(&step_label);
let cur = self.fresh_temp();
self.emit(IrOp::LoadVar(cur, var_id));
let one = self.fresh_temp();
self.emit(IrOp::LoadImm(one, 1));
let next = self.fresh_temp();
self.emit(IrOp::Add(next, cur, one));
self.emit(IrOp::StoreVar(var_id, next));
self.end_block(IrTerminator::Jump(cond_label));
self.start_block(&end_label);
}
fn lower_loop(&mut self, body: &Block) {
let body_label = self.fresh_label("loop_body");
let end_label = self.fresh_label("loop_end");
self.end_block(IrTerminator::Jump(body_label.clone()));
self.loop_stack.push(LoopContext {
continue_label: body_label.clone(),
break_label: end_label.clone(),
});
self.start_block(&body_label);
self.lower_block(body);
self.end_block(IrTerminator::Jump(body_label));
self.loop_stack.pop();
self.start_block(&end_label);
}
/// Mark a temp as the low byte of a wide (u16) value, with the
/// given high-byte temp. Consumers that care about 16-bit
/// semantics look up the high byte in `wide_hi`; consumers that
/// only need a byte ignore the map entirely (implicit truncation).
fn make_wide(&mut self, lo: IrTemp, hi: IrTemp) {
self.wide_hi.insert(lo, hi);
}
/// True if `t` was produced as the low byte of a wide value.
fn is_wide(&self, t: IrTemp) -> bool {
self.wide_hi.contains_key(&t)
}
/// Return the high-byte temp for a wide value. If `t` is not
/// wide, zero-extend it: allocate a fresh temp, emit `LoadImm 0`,
/// and return the pair. Used before emitting a 16-bit IR op when
/// one operand is narrow and the other is wide.
fn widen(&mut self, t: IrTemp) -> (IrTemp, IrTemp) {
if let Some(&hi) = self.wide_hi.get(&t) {
return (t, hi);
}
let hi = self.fresh_temp();
self.emit(IrOp::LoadImm(hi, 0));
(t, hi)
}
fn lower_expr(&mut self, expr: &Expr) -> IrTemp {
match expr {
Expr::IntLiteral(v, _) => {
let t = self.fresh_temp();
self.emit(IrOp::LoadImm(t, *v as u8));
// For literals that don't fit in a byte, also emit
// the high byte and register the pair as wide so
// later assignment to a u16 var stores both halves.
if *v > 0xFF {
let hi = self.fresh_temp();
self.emit(IrOp::LoadImm(hi, (*v >> 8) as u8));
self.make_wide(t, hi);
}
t
}
Expr::BoolLiteral(v, _) => {
let t = self.fresh_temp();
self.emit(IrOp::LoadImm(t, u8::from(*v)));
t
}
Expr::Ident(name, _) => {
// Check constants first
if let Some(&val) = self.const_values.get(name) {
let t = self.fresh_temp();
self.emit(IrOp::LoadImm(t, val as u8));
return t;
}
let var_id = self.get_or_create_var(name);
let t = self.fresh_temp();
self.emit(IrOp::LoadVar(t, var_id));
// For u16 variables, also load the high byte and
// register the temp pair as wide so downstream ops
// can emit 16-bit IR when appropriate.
if matches!(self.var_types.get(name), Some(NesType::U16)) {
let hi = self.fresh_temp();
self.emit(IrOp::LoadVarHi(hi, var_id));
self.make_wide(t, hi);
}
t
}
Expr::ArrayIndex(name, index, _) => {
let var_id = self.get_or_create_var(name);
let idx = self.lower_expr(index);
let t = self.fresh_temp();
self.emit(IrOp::ArrayLoad(t, var_id, idx));
t
}
Expr::FieldAccess(name, field, _) => {
// Field access lowers to a plain load of the
// synthetic `"struct.field"` variable produced by the
// analyzer. u16 fields follow the same two-byte path
// as u16 globals — load the low byte via `LoadVar`
// and the high byte via `LoadVarHi`, then register
// the pair as wide.
let full_name = format!("{name}.{field}");
let var_id = self.get_or_create_var(&full_name);
let t = self.fresh_temp();
self.emit(IrOp::LoadVar(t, var_id));
if matches!(self.var_types.get(&full_name), Some(NesType::U16)) {
let hi = self.fresh_temp();
self.emit(IrOp::LoadVarHi(hi, var_id));
self.make_wide(t, hi);
}
t
}
Expr::BinaryOp(left, op, right, _) => self.lower_binop(left, *op, right),
Expr::UnaryOp(op, inner, _) => {
let val = self.lower_expr(inner);
let t = self.fresh_temp();
match op {
UnaryOp::Negate => self.emit(IrOp::Negate(t, val)),
UnaryOp::Not => {
// Logical not: compare with 0
let zero = self.fresh_temp();
self.emit(IrOp::LoadImm(zero, 0));
self.emit(IrOp::CmpEq(t, val, zero));
}
UnaryOp::BitNot => self.emit(IrOp::Complement(t, val)),
}
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));
t
}
Expr::ButtonRead(player, button, _) => {
// Button reads: read the input byte, mask with the button bit.
// Player 1 reads from $01, player 2 reads from $08.
let player_index = match player {
Some(Player::P2) => 1u8,
_ => 0u8,
};
let input = self.fresh_temp();
self.emit(IrOp::ReadInput(input, player_index));
let mask = button_mask(button);
let mask_temp = self.fresh_temp();
self.emit(IrOp::LoadImm(mask_temp, mask));
let t = self.fresh_temp();
self.emit(IrOp::And(t, input, mask_temp));
t
}
Expr::ArrayLiteral(_, _) => {
// Array literals are handled during initialization, not as general expressions
let t = self.fresh_temp();
self.emit(IrOp::LoadImm(t, 0));
t
}
Expr::StructLiteral(_, _, _) => {
// Struct literals are only supported as the right
// hand side of a plain assignment (see lower_assign).
// Falling through here means the literal was used in
// an expression context the lowering can't handle;
// emit zero so the build still produces a ROM.
let t = self.fresh_temp();
self.emit(IrOp::LoadImm(t, 0));
t
}
Expr::Cast(inner, _, _) => {
// For now, just evaluate the inner expression (truncation/extension is a no-op on 8-bit)
self.lower_expr(inner)
}
Expr::DebugCall(method, _args, _) => {
// The analyzer already validated the method name and
// argument count, so we can dispatch on the method
// name directly. Both currently-supported methods
// map to a Peek of a runtime address: the codegen
// strips the read out and substitutes a constant
// zero in release builds, so the builtin disappears
// from non-debug ROMs.
let t = self.fresh_temp();
let addr: u16 = match method.as_str() {
"frame_overrun_count" => 0x07FF,
"frame_overran" => 0x07FE,
// Should be unreachable post-analyzer, but emit
// a zero rather than panicking so a parser test
// that bypasses the analyzer still produces IR.
_ => {
self.emit(IrOp::LoadImm(t, 0));
return t;
}
};
self.emit(IrOp::Peek(t, addr));
t
}
}
}
fn lower_binop(&mut self, left: &Expr, op: BinOp, right: &Expr) -> IrTemp {
// Short-circuit for logical operators
match op {
BinOp::And => return self.lower_logical_and(left, right),
BinOp::Or => return self.lower_logical_or(left, right),
_ => {}
}
// Shift operators with a compile-time-constant RHS take a
// specialized path that bakes the count into the IR op. This
// also covers the common `x << 1` / `x >> 2` case where the
// RHS is a literal in the source.
if matches!(op, BinOp::ShiftLeft | BinOp::ShiftRight) {
if let Some(count) = self.eval_const(right) {
let l = self.lower_expr(left);
let t = self.fresh_temp();
// Shifting by ≥ 8 zeroes an 8-bit value; clamp so the
// codegen doesn't emit an absurd number of ASL/LSR.
let count = count.min(8) as u8;
let ir_op = if op == BinOp::ShiftLeft {
IrOp::ShiftLeft(t, l, count)
} else {
IrOp::ShiftRight(t, l, count)
};
self.emit(ir_op);
return t;
}
}
let l = self.lower_expr(left);
let r = self.lower_expr(right);
let wide = self.is_wide(l) || self.is_wide(r);
let t = self.fresh_temp();
// 16-bit path: either operand is a wide value. Promote the
// narrower operand via zero-extension and emit the 16-bit
// IR op. Only add/sub/cmp are wide-aware today — other
// bitwise ops and multiply fall through to their 8-bit
// variants, which truncate to the low byte. (Multi-byte
// bitwise / multiply could be added later; today they're
// rare enough in NES code to defer.)
if wide {
let (a_lo, a_hi) = self.widen(l);
let (b_lo, b_hi) = self.widen(r);
match op {
BinOp::Add => {
let d_hi = self.fresh_temp();
self.emit(IrOp::Add16 {
d_lo: t,
d_hi,
a_lo,
a_hi,
b_lo,
b_hi,
});
self.make_wide(t, d_hi);
return t;
}
BinOp::Sub => {
let d_hi = self.fresh_temp();
self.emit(IrOp::Sub16 {
d_lo: t,
d_hi,
a_lo,
a_hi,
b_lo,
b_hi,
});
self.make_wide(t, d_hi);
return t;
}
BinOp::Eq => {
self.emit(IrOp::CmpEq16 {
dest: t,
a_lo,
a_hi,
b_lo,
b_hi,
});
return t;
}
BinOp::NotEq => {
self.emit(IrOp::CmpNe16 {
dest: t,
a_lo,
a_hi,
b_lo,
b_hi,
});
return t;
}
BinOp::Lt => {
self.emit(IrOp::CmpLt16 {
dest: t,
a_lo,
a_hi,
b_lo,
b_hi,
});
return t;
}
BinOp::Gt => {
self.emit(IrOp::CmpGt16 {
dest: t,
a_lo,
a_hi,
b_lo,
b_hi,
});
return t;
}
BinOp::LtEq => {
self.emit(IrOp::CmpLtEq16 {
dest: t,
a_lo,
a_hi,
b_lo,
b_hi,
});
return t;
}
BinOp::GtEq => {
self.emit(IrOp::CmpGtEq16 {
dest: t,
a_lo,
a_hi,
b_lo,
b_hi,
});
return t;
}
// Other operators fall through to the 8-bit path
// below, truncating the wide operand to its low
// byte. This is intentional for bitwise/shift ops
// which are rarely used on u16 values in NES code.
_ => {}
}
}
match op {
BinOp::Add => self.emit(IrOp::Add(t, l, r)),
BinOp::Sub => self.emit(IrOp::Sub(t, l, r)),
BinOp::Mul => self.emit(IrOp::Mul(t, l, r)),
BinOp::BitwiseAnd => self.emit(IrOp::And(t, l, r)),
BinOp::BitwiseOr => self.emit(IrOp::Or(t, l, r)),
BinOp::BitwiseXor => self.emit(IrOp::Xor(t, l, r)),
BinOp::Eq => self.emit(IrOp::CmpEq(t, l, r)),
BinOp::NotEq => self.emit(IrOp::CmpNe(t, l, r)),
BinOp::Lt => self.emit(IrOp::CmpLt(t, l, r)),
BinOp::Gt => self.emit(IrOp::CmpGt(t, l, r)),
BinOp::LtEq => self.emit(IrOp::CmpLtEq(t, l, r)),
BinOp::GtEq => self.emit(IrOp::CmpGtEq(t, l, r)),
BinOp::ShiftLeft => self.emit(IrOp::ShiftLeftVar(t, l, r)),
BinOp::ShiftRight => self.emit(IrOp::ShiftRightVar(t, l, r)),
BinOp::Div => self.emit(IrOp::Div(t, l, r)),
BinOp::Mod => self.emit(IrOp::Mod(t, l, r)),
BinOp::And | BinOp::Or => unreachable!("handled above"),
}
t
}
/// Emit an IR "move" from `src` to `dest`: `dest = src | 0`.
/// Used to merge values from different control-flow paths.
fn emit_move(&mut self, dest: IrTemp, src: IrTemp) {
let zero = self.fresh_temp();
self.emit(IrOp::LoadImm(zero, 0));
self.emit(IrOp::Or(dest, src, zero));
}
fn lower_logical_and(&mut self, left: &Expr, right: &Expr) -> IrTemp {
let result = self.fresh_temp();
let right_label = self.fresh_label("and_right");
let end_label = self.fresh_label("and_end");
let false_label = self.fresh_label("and_false");
let l = self.lower_expr(left);
self.end_block(IrTerminator::Branch(
l,
right_label.clone(),
false_label.clone(),
));
// Right side (only evaluated if left is true)
self.start_block(&right_label);
let r = self.lower_expr(right);
self.emit_move(result, r);
self.end_block(IrTerminator::Jump(end_label.clone()));
// False path
self.start_block(&false_label);
self.emit(IrOp::LoadImm(result, 0));
self.end_block(IrTerminator::Jump(end_label.clone()));
// Merge
self.start_block(&end_label);
result
}
fn lower_logical_or(&mut self, left: &Expr, right: &Expr) -> IrTemp {
let result = self.fresh_temp();
let right_label = self.fresh_label("or_right");
let end_label = self.fresh_label("or_end");
let true_label = self.fresh_label("or_true");
let l = self.lower_expr(left);
self.end_block(IrTerminator::Branch(
l,
true_label.clone(),
right_label.clone(),
));
// True path (left was true)
self.start_block(&true_label);
self.emit(IrOp::LoadImm(result, 1));
self.end_block(IrTerminator::Jump(end_label.clone()));
// Right side
self.start_block(&right_label);
let r = self.lower_expr(right);
self.emit_move(result, r);
self.end_block(IrTerminator::Jump(end_label.clone()));
// Merge
self.start_block(&end_label);
result
}
}
fn type_size(t: &NesType) -> u16 {
match t {
NesType::U8 | NesType::I8 | NesType::Bool => 1,
NesType::U16 => 2,
NesType::Array(elem, count) => type_size(elem) * count,
// Struct sizes are resolved in the analyzer. IR lowering only
// sees struct types on `var` declarations, which are skipped
// below via the analyzer's synthetic field allocations.
NesType::Struct(_) => 0,
}
}
fn button_mask(button: &str) -> u8 {
match button {
"a" => 0x80,
"b" => 0x40,
"select" => 0x20,
"start" => 0x10,
"up" => 0x08,
"down" => 0x04,
"left" => 0x02,
"right" => 0x01,
_ => 0x00,
}
}
/// Build the IR op for a compound-assignment `lhs OP= rhs`. The
/// `rhs_expr` is consulted for shift counts so `x <<= 3` becomes
/// `ShiftLeft(result, current, 3)` rather than a runtime shift. All
/// other operators just map to their 3-address form over the already-
/// lowered temps.
fn compound_assign_op(
op: AssignOp,
result: IrTemp,
current: IrTemp,
rhs: IrTemp,
rhs_expr: &Expr,
ctx: &LoweringContext,
) -> IrOp {
match op {
AssignOp::PlusAssign => IrOp::Add(result, current, rhs),
AssignOp::MinusAssign => IrOp::Sub(result, current, rhs),
AssignOp::AmpAssign => IrOp::And(result, current, rhs),
AssignOp::PipeAssign => IrOp::Or(result, current, rhs),
AssignOp::CaretAssign => IrOp::Xor(result, current, rhs),
AssignOp::ShiftLeftAssign => {
if let Some(n) = ctx.eval_const(rhs_expr) {
IrOp::ShiftLeft(result, current, n.min(8) as u8)
} else {
IrOp::ShiftLeftVar(result, current, rhs)
}
}
AssignOp::ShiftRightAssign => {
if let Some(n) = ctx.eval_const(rhs_expr) {
IrOp::ShiftRight(result, current, n.min(8) as u8)
} else {
IrOp::ShiftRightVar(result, current, rhs)
}
}
AssignOp::Assign => unreachable!(),
}
}