mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 17:06:04 +00:00
W0109 (shipped last commit) catches the 8-sprites-per-scanline
hardware limit at compile time for static layouts, but the
dynamic case — enemy formations, projectile clusters, animated
NPCs where coordinates come from variables — was still silent.
This change adds two layers of defense on top of W0109:
Layer 2: `cycle_sprites` runtime flicker intrinsic
New keyword statement that rotates the OAM DMA start offset
one slot per call. When called once per `on frame`, the PPU's
sprite evaluation picks up a different subset of the 12+
overlapping sprites each frame, so the permanent-dropout
failure mode becomes visible flicker — the classic NES
technique used by Gradius, Battletoads, and every shmup.
Implementation:
- Lexer keyword `KwCycleSprites` and parser production.
- AST `Statement::CycleSprites(Span)`.
- `IrOp::CycleSprites` lowered by the IR pass.
- Codegen emits `LDA $07EF / CLC / ADC #4 / STA $07EF` with
natural u8 wrap, plus a one-shot `__sprite_cycle_used`
marker label the first time it fires.
- Linker detects the marker and switches `gen_nmi` to the
cycling variant, which reads the rotating offset from
`$07EF` into OAM_ADDR before the DMA instead of writing
a literal 0. Programs that don't call `cycle_sprites`
skip the marker and get byte-identical ROM output.
Layer 3: debug-mode sprite overflow telemetry
Mirrors the frame-overrun pair (`debug.frame_overrun_count` /
`debug.frame_overran`). In debug builds the NMI handler reads
`$2002` at the top of vblank, masks bit 5 (the PPU's sprite
overflow flag), and if set bumps a cumulative counter at
`$07FD` plus a sticky bit at `$07FC`. The sticky bit clears
on every `wait_frame`.
New debug builtins:
- `debug.sprite_overflow_count()` → u8 peek of $07FD
- `debug.sprite_overflow()` → u8 peek of $07FC (sticky bit)
The hardware flag has well-known quirks but is correct for
the overwhelming majority of cases and costs ~15 cycles per
frame to sample. Release builds emit no overflow-check code
at all, so the four bytes at `$07EF` / `$07FC`-`$07FD` stay
free for user allocation.
Related changes:
- `gen_nmi` now takes an `NmiOptions` struct. Four bool
parameters tripped clippy's `fn_params_excessive_bools`.
- CLI `build` now renders analyzer warnings on a successful
build. Previously warnings were silently dropped unless
the user also ran `nescript check`, which made W0109
effectively invisible to CI and local dev alike. Existing
pre-existing W0103 / W0106 warnings on `coin_cavern`,
`mmc3_per_state_split`, `sprites_and_palettes` surface
too — not regressions, just now visible.
New example: `examples/sprite_flicker_demo.ne`
Draws 12 sprites into a 4-pixel band, W0109 fires at compile
time with nine labels pointing at the offenders, and a
`cycle_sprites` call at the end of `on frame` turns the
hardware dropout into flicker. The committed emulator golden
captures one frame of the cycling pattern (deterministic).
Tests:
- `runtime::tests::nmi_debug_mode_samples_sprite_overflow`
- `runtime::tests::nmi_sprite_cycle_variant_reads_rotating_offset`
- `ir_codegen::*::debug_sprite_overflow_count_loads_07fd`
- `ir_codegen::*::debug_sprite_overflow_flag_loads_07fc`
- `ir_codegen::*::wait_frame_clears_sprite_overflow_sticky_in_debug_mode`
- `ir_codegen::*::wait_frame_release_does_not_touch_sprite_overflow_sticky`
- `ir_codegen::*::cycle_sprites_emits_marker_and_add4`
- `ir_codegen::*::cycle_sprites_marker_dedup_across_multiple_calls`
- `ir_codegen::*::program_without_cycle_sprites_emits_no_marker`
- `analyzer::*::accepts_debug_sprite_overflow_builtins`
- `analyzer::*::rejects_unknown_debug_method_lists_all_four_known_names`
- `analyzer::*::accepts_cycle_sprites_statement`
Docs: `examples/war/COMPILER_BUGS.md` §4 now describes all three
layers (W0109, `cycle_sprites`, debug telemetry) with reasoning
for when each applies. `README.md` and `examples/README.md` add
the new example to their tables.
All 32 emulator goldens still match — the cycling is opt-in
and programs that don't call `cycle_sprites` or enable debug
mode are byte-identical to the pre-change output.
https://claude.ai/code/session_0143dTgh3UeRrtfHgQwzcv5z
1901 lines
79 KiB
Rust
1901 lines
79 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>,
|
||
/// Current local scope prefix — mirrors the analyzer's field
|
||
/// of the same name. While lowering a function or handler
|
||
/// body this is `Some("<func_name>")` (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<String>,
|
||
/// 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<String, CapturedInline>,
|
||
/// 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<HashMap<String, IrTemp>>,
|
||
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>,
|
||
/// 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<String, MetaspriteInfo>,
|
||
}
|
||
|
||
/// 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 <expr> }`. 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<Statement>),
|
||
}
|
||
|
||
/// 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<Param>,
|
||
body: InlineBody,
|
||
}
|
||
|
||
#[derive(Debug, Clone)]
|
||
struct MetaspriteInfo {
|
||
sprite_name: String,
|
||
dx: Vec<u8>,
|
||
dy: Vec<u8>,
|
||
frame: Vec<u8>,
|
||
}
|
||
|
||
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 <expr> }`. 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<IrTemp> {
|
||
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<IrTemp> = 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<IrTemp> = 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<IrTemp> {
|
||
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<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);
|
||
|
||
// 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<String, u8> = 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<u8> =
|
||
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_<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) => {
|
||
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!(),
|
||
}
|
||
}
|