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, globals: Vec, rom_data: Vec, var_map: HashMap, const_values: HashMap, /// 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, /// Current local scope prefix — mirrors the analyzer's field /// of the same name. While lowering a function or handler /// body this is `Some("")` (or `Some("State__frame")`, /// etc), and `get_or_create_var` prepends /// `"__local__{prefix}__"` to any bare identifier lookup so /// function-local vars resolve to the scoped entry the /// analyzer registered for them. `None` outside of any body. current_scope_prefix: Option, /// Captured inline function bodies. Populated by /// `capture_inline_bodies` before any lowering runs. Each /// entry is keyed by function name and holds the parameter /// list plus the shape of the body (see [`InlineBody`]). /// Call sites targeting a name in this map expand inline: /// each argument is lowered to a temp, the temps are /// registered as substitutions for the parameter names, /// and the body is lowered into the caller's current block /// in place of a `Call` op. See `try_inline_call_expr` / /// `try_inline_call_stmt` below and `COMPILER_BUGS.md` §5. inline_bodies: HashMap, /// Substitution stack for nested inline expansions. The top /// frame is the active substitution map — `Expr::Ident(name)` /// lookups check it first and, if the name is present, use /// the stored IR temp directly without emitting any load op. /// Nested inlines push a fresh frame on entry and pop it on /// exit so an inline body calling another inline sees the /// inner function's parameter substitutions, not its /// caller's. inline_subs_stack: Vec>, next_var_id: u32, next_temp: u32, next_block: u32, // Current function being built current_blocks: Vec, current_ops: Vec, current_label: String, current_locals: Vec, // Loop context for break/continue loop_stack: Vec, // State metadata captured from the AST state_names: Vec, 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, /// Captured metasprite declarations keyed by name. When a /// `Statement::Draw` names a metasprite (rather than a flat /// sprite), the lowering expands it inline into one /// [`IrOp::DrawSprite`] per tile, with x/y offsets folded into /// the per-tile coordinates and the metasprite's `frame:` /// entry used as the literal frame index. Storing the lookup /// here keeps the per-statement lowering simple and avoids /// having to thread the program through every helper. metasprites: HashMap, } /// A captured `inline fun` body that the lowerer can splice in /// at each call site. Two flavours are recognised: /// /// - **Expression**: the function body is exactly /// `{ return }`. The return expression can be lowered /// into either a statement context (result discarded) or an /// expression context (result used). /// - **Void**: the function has no return type and its body is /// a sequence of plain statements (no `return`, no loops, no /// conditionals). The statements can only be spliced into /// statement contexts. This is the shape of helpers like /// `set_phase(p) { phase = p; phase_timer = 0 }`. /// /// Anything more exotic (early returns inside `if`, loops, /// nested blocks, recursive inlines, etc.) is not captured and /// compiles as a regular `JSR` call, with no warning since /// declining to inline is always a correct fallback. #[derive(Debug, Clone)] enum InlineBody { Expression(Expr), Void(Vec), } /// Captured inline function metadata: parameter list plus the /// shape of the body. See `InlineBody` and /// `LoweringContext::inline_bodies`. #[derive(Debug, Clone)] struct CapturedInline { params: Vec, body: InlineBody, } #[derive(Debug, Clone)] struct MetaspriteInfo { sprite_name: String, dx: Vec, dy: Vec, frame: Vec, } 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, current_scope_prefix: None, inline_bodies: HashMap::new(), inline_subs_stack: Vec::new(), 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(), metasprites: HashMap::new(), } } 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) } /// Resolve a user-written identifier to the scoped key used by /// the symbol table. Mirrors `Analyzer::resolve_key`: tries the /// current function/handler's qualified key first, falls back /// to the bare key for globals / consts / enum variants / /// state-level vars / function names. fn scoped_key(&self, name: &str) -> String { if let Some(prefix) = &self.current_scope_prefix { let qualified = format!("__local__{prefix}__{name}"); if self.var_map.contains_key(&qualified) || self.var_types.contains_key(&qualified) { return qualified; } } name.to_string() } fn get_or_create_var(&mut self, name: &str) -> VarId { let key = self.scoped_key(name); if let Some(&id) = self.var_map.get(&key) { return id; } let id = VarId(self.next_var_id); self.next_var_id += 1; self.var_map.insert(key, id); id } /// Walk the program and capture every `inline fun` whose /// body matches one of the shapes the lowerer can splice /// in at call sites. Two shapes are recognised: /// /// 1. **Single-return-expression**: the function has a /// declared return type and its body is exactly /// `{ return }`. Lowered as `InlineBody::Expression` /// — usable in both expression and statement contexts. /// 2. **Void multi-statement**: the function has no return /// type and its body is a sequence of plain statements /// (assigns, calls, draws — no control flow, no /// `return`). Lowered as `InlineBody::Void` — usable /// only in statement contexts. /// /// Anything else (conditional early returns, loops, /// block-nested `if`s, etc.) is silently declined and the /// function compiles as a regular `JSR` call. Users who /// want their `inline fun` inlined can check the /// `--asm-dump` output; declining is always correct. fn capture_inline_bodies(&mut self, program: &Program) { for fun in &program.functions { if !fun.is_inline { continue; } // Single-return-expression shape. if fun.return_type.is_some() && fun.body.statements.len() == 1 && matches!(fun.body.statements[0], Statement::Return(Some(_), _)) { if let Statement::Return(Some(expr), _) = &fun.body.statements[0] { self.inline_bodies.insert( fun.name.clone(), CapturedInline { params: fun.params.clone(), body: InlineBody::Expression(expr.clone()), }, ); continue; } } // Void multi-statement shape: no return type, and // every body statement must be a shape we know how // to splice. Only assigns, statement-context calls, // draws, scroll, set_palette, and load_background // are accepted — anything with nested control flow // is too complex to inline without a full CFG // clone. if fun.return_type.is_none() && !fun.body.statements.is_empty() && fun.body.statements.iter().all(is_splicable_void_stmt) { self.inline_bodies.insert( fun.name.clone(), CapturedInline { params: fun.params.clone(), body: InlineBody::Void(fun.body.statements.clone()), }, ); } } } /// Inline a call to `name` in expression context and /// return the result temp. Returns `None` if the target /// isn't in `inline_bodies` or is a void-body inline that /// can't produce a value. fn try_inline_call_expr(&mut self, name: &str, args: &[Expr]) -> Option { let captured = self.inline_bodies.get(name).cloned()?; let InlineBody::Expression(return_expr) = &captured.body else { return None; }; if captured.params.len() != args.len() { return None; } let arg_temps: Vec = args.iter().map(|a| self.lower_expr(a)).collect(); let mut frame = HashMap::new(); for (param, temp) in captured.params.iter().zip(arg_temps.iter()) { frame.insert(param.name.clone(), *temp); } self.inline_subs_stack.push(frame); let result = self.lower_expr(return_expr); self.inline_subs_stack.pop(); Some(result) } /// Inline a call to `name` in statement context. Returns /// `true` on success (i.e. the body was spliced into /// `current_ops`), `false` if the target isn't in /// `inline_bodies`. /// /// A single-return-expression inline used in statement /// context lowers the return expression and discards the /// result — the side effects of argument evaluation still /// happen, which is what a regular `Statement::Call` would /// do. fn try_inline_call_stmt(&mut self, name: &str, args: &[Expr]) -> bool { let Some(captured) = self.inline_bodies.get(name).cloned() else { return false; }; if captured.params.len() != args.len() { return false; } let arg_temps: Vec = args.iter().map(|a| self.lower_expr(a)).collect(); let mut frame = HashMap::new(); for (param, temp) in captured.params.iter().zip(arg_temps.iter()) { frame.insert(param.name.clone(), *temp); } self.inline_subs_stack.push(frame); match &captured.body { InlineBody::Expression(expr) => { // Evaluate the expression for its side effects; // discard the result temp. let _ = self.lower_expr(expr); } InlineBody::Void(stmts) => { for stmt in stmts { self.lower_statement(stmt); } } } self.inline_subs_stack.pop(); true } /// Look up `name` in the active inline substitution frame, /// if any. Returns the IR temp previously computed for that /// parameter (during `try_inline_call_*`'s argument /// lowering). The top of the stack wins so nested inlines /// see their own frame. fn lookup_inline_sub(&self, name: &str) -> Option { self.inline_subs_stack.last()?.get(name).copied() } /// 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 = 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 { 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); // Capture metasprite declarations so the per-statement // Draw lowering can expand `draw Hero` into one // DrawSprite op per tile. The `frame:` array in a // metasprite is interpreted *relative to the underlying // sprite's base tile* — i.e. `frame: [0, 1, 2, 3]` on a // 16×16 sprite means "the four tiles this sprite owns". // Since the IR codegen's DrawSprite op takes an *absolute* // tile index whenever `frame` is set, we need to resolve // the per-sprite base tile here and rewrite the array // before storing it. // // Tile assignment mirrors `assets::resolve_sprites`: tile // index 0 is reserved for the runtime's default smiley, // user sprites start at 1, and each sprite consumes // `chr_bytes.len() / 16` tiles (rounded up). Sprites with // an external `@chr(...)` / `@binary(...)` source whose // bytes aren't available at parse time fall back to a // single-tile assumption — that's a regression for those // exotic sources but keeps the in-tree examples working. let mut sprite_base: HashMap = HashMap::new(); let mut next_tile: u8 = 1; for sprite in &program.sprites { sprite_base.insert(sprite.name.clone(), next_tile); let tile_count = match &sprite.chr_source { crate::parser::ast::AssetSource::Inline(bytes) => { (bytes.len().div_ceil(16)).max(1) as u8 } _ => 1, }; next_tile = next_tile.saturating_add(tile_count); } for ms in &program.metasprites { let base = sprite_base.get(&ms.sprite_name).copied().unwrap_or(0); let resolved_frames: Vec = ms.frame.iter().map(|&f| base.saturating_add(f)).collect(); self.metasprites.insert( ms.name.clone(), MetaspriteInfo { sprite_name: ms.sprite_name.clone(), dx: ms.dx.clone(), dy: ms.dy.clone(), frame: resolved_frames, }, ); } // 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); } } // Capture `inline fun` bodies that qualify for real // inlining. A function qualifies when it's marked // `inline`, has a declared return type, and its body // consists of exactly one `Statement::Return(Some(expr))`. // Call sites targeting one of these functions will be // expanded in-place in `lower_expr` / `lower_statement` // instead of emitting a `Call` op — the caller's body // gets the return expression spliced in with the // function's parameters substituted for argument temps. // // Functions marked `inline` but with more complex bodies // (multi-statement, void, loops, conditionals) compile // as regular calls with a W0109 "inline declined" // warning emitted by the analyzer. This catches users // who write `inline fun` expecting the keyword to be // enforced. self.capture_inline_bodies(program); // 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; // Clear the wide-temp tracking map. `wide_hi` records "this // low temp has its high byte at this other temp" entries // produced by `make_wide`; without clearing it, the entries // from previous functions leak into the next function and // get matched against fresh temp IDs (since next_temp resets // to 0). That manifests as `is_wide(t)` spuriously returning // true and, worse, `widen(t)` returning a stale `hi` temp ID // that collides with a later `fresh_temp()` allocation — // producing 16-bit IR ops where the destination temp is // *also* one of the source temps. See COMPILER_BUGS.md §6. self.wide_hi.clear(); self.current_blocks = Vec::new(); self.current_locals = Vec::new(); // Enter the function's local scope so all bare identifier // lookups inside the body resolve against the analyzer's // `__local__{function_name}__{name}` entries. self.current_scope_prefix = Some(fun.name.clone()); // Register parameters as locals. They're looked up via // their bare name (which `get_or_create_var` now qualifies // via `scoped_key`), so two different functions can each // have a parameter named `x` without the VarIds colliding. for param in &fun.params { let var_id = self.get_or_create_var(¶m.name); self.current_locals.push(IrLocal { var_id, name: param.name.clone(), size: type_size(¶m.param_type), }); // Register the param type under the scoped key so // `lower_expr` can decide 8-bit vs 16-bit loads. let key = format!("__local__{}__{}", fun.name, param.name); self.var_types.insert(key, param.param_type.clone()); } 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, }); self.current_scope_prefix = None; } fn lower_state(&mut self, state: &StateDecl, _is_start: bool) { // Lower each event handler as a separate function. Each // handler uses a distinct scope prefix so a `var i` in // `Title::on frame` and one in `Playing::on frame` get // different VarIds. if let Some(on_enter) = &state.on_enter { self.lower_handler( &format!("{}_enter", state.name), &format!("{}__enter", state.name), on_enter, state, ); } if let Some(on_exit) = &state.on_exit { self.lower_handler( &format!("{}_exit", state.name), &format!("{}__exit", state.name), on_exit, state, ); } if let Some(on_frame) = &state.on_frame { self.lower_handler( &format!("{}_frame", state.name), &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); let scope = format!("{}__scanline_{line}", state.name); self.lower_handler(&name, &scope, block, state); } } fn lower_handler(&mut self, name: &str, scope_prefix: &str, block: &Block, state: &StateDecl) { self.next_temp = 0; // Same per-function reset as `lower_function`. See the // commentary there and COMPILER_BUGS.md §6 for why this is // critical — without it, state-handler bodies pick up wide // temp pairs left over from the previous function and emit // catastrophically wrong 16-bit IR ops. self.wide_hi.clear(); self.current_blocks = Vec::new(); self.current_scope_prefix = Some(scope_prefix.to_string()); // 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. // // State-level locals (declared at `state Foo { var i: u8 }` // outside any handler) live in the GLOBAL scope so every // handler in the state can read/write them across frames. // `get_or_create_var` would try the scoped key first — // which isn't registered for state-locals — then fall back // to the bare key, which IS registered. 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, }); self.current_scope_prefix = None; } 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____`), 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) => { if let Some(meta) = self.metasprites.get(&draw.sprite_name).cloned() { // Metasprite expansion: for each tile in the // declaration, emit one DrawSprite with x/y // offset by (dx[i], dy[i]) and frame = frame[i]. // The IR codegen sees N independent draws so // the runtime OAM-cursor path picks them up // exactly like a hand-written sequence of // `draw` statements. // // The user's `frame:` argument is ignored when // drawing a metasprite — the per-tile frame // index comes from the declaration. The // analyzer doesn't currently flag this; future // work could warn on it. let base_x = self.lower_expr(&draw.x); let base_y = self.lower_expr(&draw.y); for ((dx_off, dy_off), tile) in meta.dx.iter().zip(&meta.dy).zip(&meta.frame) { let off_x = self.fresh_temp(); self.emit(IrOp::LoadImm(off_x, *dx_off)); let x_sum = self.fresh_temp(); self.emit(IrOp::Add(x_sum, base_x, off_x)); let off_y = self.fresh_temp(); self.emit(IrOp::LoadImm(off_y, *dy_off)); let y_sum = self.fresh_temp(); self.emit(IrOp::Add(y_sum, base_y, off_y)); let tile_imm = self.fresh_temp(); self.emit(IrOp::LoadImm(tile_imm, *tile)); self.emit(IrOp::DrawSprite { sprite_name: meta.sprite_name.clone(), x: x_sum, y: y_sum, frame: Some(tile_imm), }); } return; } 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::CycleSprites(_) => { self.emit(IrOp::CycleSprites); } 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)); } } _ => { // Inline expansion at statement context // splices either the return expression // (discarding its result) or the body // statements directly into `current_ops`. if self.try_inline_call_stmt(name, args) { return; } 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(¤t_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(¤t_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, _) => { // When we're inside an inline expansion and this // name is a parameter of the function currently // being inlined, return the pre-computed argument // temp directly instead of emitting a load op. // That's how substitution actually happens: the // body expression references the parameter, we // short-circuit the lookup to the temp the caller // already evaluated. if let Some(temp) = self.lookup_inline_sub(name) { return temp; } // 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; } } // `inline fun` bodies captured by // `capture_inline_bodies` expand in-place here: // no JSR, no parameter transport, no prologue. // The return value is whatever temp the body // expression lowered to. if let Some(t) = self.try_inline_call_expr(name, args) { 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. All 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, "sprite_overflow_count" => 0x07FD, "sprite_overflow" => 0x07FC, // 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 } } /// True if `stmt` is simple enough for the inliner to splice /// into a caller without a CFG rewrite. Accepted shapes: plain /// assignments, statement-context calls, draws, scroll/set /// palette / load background, `wait_frame`, inline asm, and the /// `debug.log` / `debug.assert` builtins. Rejected: any shape with /// control flow (if/while/loop/for/match/return/break/continue /// /transition) because those would require cloning basic /// blocks and renumbering labels per call site, which is /// more than the simple substitution machinery can handle. fn is_splicable_void_stmt(stmt: &Statement) -> bool { matches!( stmt, Statement::Assign(..) | Statement::Call(..) | Statement::Draw(..) | Statement::Scroll(..) | Statement::SetPalette(..) | Statement::LoadBackground(..) | Statement::WaitFrame(..) | Statement::CycleSprites(..) | Statement::Play(..) | Statement::StartMusic(..) | Statement::StopMusic(..) | Statement::InlineAsm(..) | Statement::RawAsm(..) | Statement::DebugLog(..) | Statement::DebugAssert(..) ) } 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!(), } }