mirror of
https://github.com/imjasonh/nescript
synced 2026-07-09 09:18:01 +00:00
Sprite resolution, asset wiring, shift-assign, unreachable state warning
Sprite/asset pipeline: - Linker::link_with_assets() places sprite CHR data in ROM at correct tile - assets::resolve_sprites() walks Program for inline sprite bytes - CodeGen::with_sprites() maps sprite names to tile indices - gen_draw() uses correct tile index from sprite declarations - main.rs wires the full resolution pipeline Shift-assign operators (<<= and >>=): - AssignOp::ShiftLeftAssign and ShiftRightAssign variants - Parser handles in both statement and array index contexts - Codegen emits ASL A / LSR A - IR lowering maps to ShiftLeft/ShiftRight ops Unreachable state warning (W0104): - BFS from start state finds reachable states via transitions - States not reached produce W0104 warning Error polish helpers: - suggest_var_name() for "did you mean" suggestions - emit_undefined_var() for E0502 with typo hints - Used by analyzer for better diagnostics 242 tests pass, clippy clean. https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
This commit is contained in:
parent
5d2d242520
commit
6430c3a935
13 changed files with 737 additions and 17 deletions
|
|
@ -3,7 +3,7 @@ mod tests;
|
|||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use crate::errors::{Diagnostic, ErrorCode};
|
||||
use crate::errors::{Diagnostic, ErrorCode, Label, Level};
|
||||
use crate::lexer::Span;
|
||||
use crate::parser::ast::*;
|
||||
|
||||
|
|
@ -48,6 +48,7 @@ pub fn analyze(program: &Program) -> AnalysisResult {
|
|||
max_depths: HashMap::new(),
|
||||
stack_depth_limit: DEFAULT_STACK_DEPTH,
|
||||
in_loop: false,
|
||||
used_vars: HashSet::new(),
|
||||
};
|
||||
analyzer.analyze_program(program);
|
||||
|
||||
|
|
@ -70,6 +71,9 @@ struct Analyzer {
|
|||
max_depths: HashMap<String, u32>,
|
||||
stack_depth_limit: u32,
|
||||
in_loop: bool,
|
||||
/// Names of variables that have been read somewhere in the program.
|
||||
/// Used for the W0103 unused-variable warning.
|
||||
used_vars: HashSet<String>,
|
||||
}
|
||||
|
||||
impl Analyzer {
|
||||
|
|
@ -121,9 +125,34 @@ impl Analyzer {
|
|||
}
|
||||
}
|
||||
|
||||
// Type-check function bodies
|
||||
// Type-check function bodies. Parameters are registered as
|
||||
// symbols for the duration of the body check so that identifier
|
||||
// references (and the W0103 used-variable tracker) can resolve
|
||||
// them. They are unregistered afterwards to avoid leaking into
|
||||
// the global scope. Parameters are also pre-marked as "used" so
|
||||
// we do not emit W0103 for unused function arguments (which are
|
||||
// a common and deliberate pattern).
|
||||
for fun in &program.functions {
|
||||
let mut added_params = Vec::new();
|
||||
for param in &fun.params {
|
||||
if !self.symbols.contains_key(¶m.name) {
|
||||
self.symbols.insert(
|
||||
param.name.clone(),
|
||||
Symbol {
|
||||
name: param.name.clone(),
|
||||
sym_type: param.param_type.clone(),
|
||||
is_const: false,
|
||||
span: fun.span,
|
||||
},
|
||||
);
|
||||
added_params.push(param.name.clone());
|
||||
}
|
||||
self.mark_var_used(¶m.name);
|
||||
}
|
||||
self.check_block(&fun.body, &state_names);
|
||||
for name in &added_params {
|
||||
self.symbols.remove(name);
|
||||
}
|
||||
}
|
||||
|
||||
// Build call graph
|
||||
|
|
@ -141,6 +170,145 @@ impl Analyzer {
|
|||
|
||||
// Compute max call depths from entry points (state handlers)
|
||||
self.compute_max_depths(program);
|
||||
|
||||
// Check for unused global variables (W0103). Variables whose names
|
||||
// start with '_' are exempt by convention. State-local variables are
|
||||
// left out for now to avoid noise during early development.
|
||||
for var in &program.globals {
|
||||
if var.name.starts_with('_') {
|
||||
continue;
|
||||
}
|
||||
if !self.used_vars.contains(&var.name) {
|
||||
self.diagnostics.push(Diagnostic {
|
||||
level: Level::Warning,
|
||||
code: ErrorCode::W0103,
|
||||
message: format!("unused variable '{}'", var.name),
|
||||
span: var.span,
|
||||
labels: Vec::<Label>::new(),
|
||||
help: Some(
|
||||
"prefix with '_' to silence this warning, or remove the declaration".into(),
|
||||
),
|
||||
note: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unreachable states (W0104).
|
||||
self.check_unreachable_states(program);
|
||||
}
|
||||
|
||||
/// Mark a variable name as having been read somewhere in the program.
|
||||
fn mark_var_used(&mut self, name: &str) {
|
||||
self.used_vars.insert(name.to_string());
|
||||
}
|
||||
|
||||
/// Recursively walk an expression tree and mark every identifier that
|
||||
/// appears as an `Expr::Ident` (or as an `Expr::ArrayIndex` base) as
|
||||
/// "read". Used by the W0103 unused-variable analysis. Also emits
|
||||
/// E0502 for any identifier that is not defined in the symbol table.
|
||||
fn walk_expr_reads(&mut self, expr: &Expr) {
|
||||
match expr {
|
||||
Expr::Ident(name, span) => {
|
||||
if self.symbols.contains_key(name) {
|
||||
self.mark_var_used(name);
|
||||
} else {
|
||||
self.emit_undefined_var(name, *span);
|
||||
}
|
||||
}
|
||||
Expr::ArrayIndex(name, idx, span) => {
|
||||
// Array base is a read; index may contain more reads.
|
||||
if self.symbols.contains_key(name) {
|
||||
self.mark_var_used(name);
|
||||
} else {
|
||||
self.emit_undefined_var(name, *span);
|
||||
}
|
||||
self.walk_expr_reads(idx);
|
||||
}
|
||||
Expr::BinaryOp(lhs, _, rhs, _) => {
|
||||
self.walk_expr_reads(lhs);
|
||||
self.walk_expr_reads(rhs);
|
||||
}
|
||||
Expr::UnaryOp(_, inner, _) | Expr::Cast(inner, _, _) => {
|
||||
self.walk_expr_reads(inner);
|
||||
}
|
||||
Expr::Call(_, args, _) => {
|
||||
// Function name is validated separately via E0503; here we
|
||||
// just recurse into argument expressions so their reads
|
||||
// get tracked (and undefined-var errors surface).
|
||||
for arg in args {
|
||||
self.walk_expr_reads(arg);
|
||||
}
|
||||
}
|
||||
Expr::ArrayLiteral(elems, _) => {
|
||||
for e in elems {
|
||||
self.walk_expr_reads(e);
|
||||
}
|
||||
}
|
||||
Expr::IntLiteral(_, _) | Expr::BoolLiteral(_, _) | Expr::ButtonRead(_, _, _) => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Suggest a similarly-named symbol for undefined-variable errors.
|
||||
/// Uses a simple heuristic: same first character and similar length.
|
||||
fn suggest_var_name(&self, unknown: &str) -> Option<String> {
|
||||
let first = unknown.chars().next()?;
|
||||
self.symbols
|
||||
.keys()
|
||||
.filter(|name| {
|
||||
name.starts_with(first)
|
||||
&& name.len().abs_diff(unknown.len()) <= 2
|
||||
&& name.as_str() != unknown
|
||||
})
|
||||
.min_by_key(|name| name.len().abs_diff(unknown.len()))
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Emit E0502 for an undefined variable reference, with a "did you mean"
|
||||
/// suggestion if a similar symbol exists.
|
||||
fn emit_undefined_var(&mut self, name: &str, span: Span) {
|
||||
let mut diag = Diagnostic::error(
|
||||
ErrorCode::E0502,
|
||||
format!("undefined variable '{name}'"),
|
||||
span,
|
||||
);
|
||||
if let Some(suggestion) = self.suggest_var_name(name) {
|
||||
diag = diag.with_help(format!("did you mean '{suggestion}'?"));
|
||||
}
|
||||
self.diagnostics.push(diag);
|
||||
}
|
||||
|
||||
/// Reachability analysis for states. Performs a BFS from the start state
|
||||
/// through every transition in state handlers and emits W0104 for any
|
||||
/// state that is never reached.
|
||||
fn check_unreachable_states(&mut self, program: &Program) {
|
||||
let mut reachable: HashSet<String> = HashSet::new();
|
||||
let mut queue: Vec<String> = vec![program.start_state.clone()];
|
||||
|
||||
while let Some(state_name) = queue.pop() {
|
||||
if !reachable.insert(state_name.clone()) {
|
||||
continue;
|
||||
}
|
||||
if let Some(state) = program.states.iter().find(|s| s.name == state_name) {
|
||||
collect_transitions_from_state(state, &mut queue);
|
||||
}
|
||||
}
|
||||
|
||||
for state in &program.states {
|
||||
if !reachable.contains(&state.name) {
|
||||
self.diagnostics.push(Diagnostic {
|
||||
level: Level::Warning,
|
||||
code: ErrorCode::W0104,
|
||||
message: format!("state '{}' is unreachable from start state", state.name),
|
||||
span: state.span,
|
||||
labels: Vec::<Label>::new(),
|
||||
help: Some(
|
||||
"add a 'transition' to this state from a reachable state, or remove it"
|
||||
.into(),
|
||||
),
|
||||
note: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn register_const(&mut self, c: &ConstDecl) {
|
||||
|
|
@ -307,6 +475,7 @@ impl Analyzer {
|
|||
Statement::VarDecl(var) => {
|
||||
self.register_var(var);
|
||||
if let Some(init) = &var.init {
|
||||
self.walk_expr_reads(init);
|
||||
self.check_expr_type(init, &var.var_type);
|
||||
}
|
||||
}
|
||||
|
|
@ -324,7 +493,7 @@ impl Analyzer {
|
|||
}
|
||||
}
|
||||
}
|
||||
LValue::ArrayIndex(name, _) => {
|
||||
LValue::ArrayIndex(name, idx) => {
|
||||
if let Some(sym) = self.symbols.get(name) {
|
||||
if sym.is_const {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
|
|
@ -334,17 +503,24 @@ impl Analyzer {
|
|||
));
|
||||
}
|
||||
}
|
||||
// Indexing an array counts as a read of the array,
|
||||
// and the index expression itself may contain reads.
|
||||
self.mark_var_used(name);
|
||||
self.walk_expr_reads(idx);
|
||||
}
|
||||
}
|
||||
self.walk_expr_reads(expr);
|
||||
let ltype = self.lvalue_type(lvalue, *span);
|
||||
if let Some(lt) = ltype {
|
||||
self.check_expr_type(expr, <);
|
||||
}
|
||||
}
|
||||
Statement::If(cond, then_block, else_ifs, else_block, _) => {
|
||||
self.walk_expr_reads(cond);
|
||||
self.check_expr_type(cond, &NesType::Bool);
|
||||
self.check_block(then_block, state_names);
|
||||
for (cond, block) in else_ifs {
|
||||
self.walk_expr_reads(cond);
|
||||
self.check_expr_type(cond, &NesType::Bool);
|
||||
self.check_block(block, state_names);
|
||||
}
|
||||
|
|
@ -353,6 +529,7 @@ impl Analyzer {
|
|||
}
|
||||
}
|
||||
Statement::While(cond, body, _) => {
|
||||
self.walk_expr_reads(cond);
|
||||
self.check_expr_type(cond, &NesType::Bool);
|
||||
let was_in_loop = self.in_loop;
|
||||
self.in_loop = true;
|
||||
|
|
@ -375,17 +552,21 @@ impl Analyzer {
|
|||
}
|
||||
}
|
||||
Statement::Draw(draw) => {
|
||||
self.walk_expr_reads(&draw.x);
|
||||
self.walk_expr_reads(&draw.y);
|
||||
self.check_expr_type(&draw.x, &NesType::U8);
|
||||
self.check_expr_type(&draw.y, &NesType::U8);
|
||||
if let Some(frame) = &draw.frame {
|
||||
self.walk_expr_reads(frame);
|
||||
self.check_expr_type(frame, &NesType::U8);
|
||||
}
|
||||
}
|
||||
Statement::Return(Some(expr), _) => {
|
||||
// For M1, just validate the expression without checking return type
|
||||
self.walk_expr_reads(expr);
|
||||
let _ = self.infer_type(expr);
|
||||
}
|
||||
Statement::Call(name, _args, span) => {
|
||||
Statement::Call(name, args, span) => {
|
||||
if !self.symbols.contains_key(name) {
|
||||
self.diagnostics.push(Diagnostic::error(
|
||||
ErrorCode::E0503,
|
||||
|
|
@ -393,8 +574,13 @@ impl Analyzer {
|
|||
*span,
|
||||
));
|
||||
}
|
||||
for arg in args {
|
||||
self.walk_expr_reads(arg);
|
||||
}
|
||||
}
|
||||
Statement::Scroll(x, y, _) => {
|
||||
self.walk_expr_reads(x);
|
||||
self.walk_expr_reads(y);
|
||||
self.check_expr_type(x, &NesType::U8);
|
||||
self.check_expr_type(y, &NesType::U8);
|
||||
}
|
||||
|
|
@ -518,6 +704,49 @@ impl Analyzer {
|
|||
}
|
||||
}
|
||||
|
||||
/// Collect every state name mentioned in a transition statement inside the
|
||||
/// given state's handlers and append them to `queue`. Used by the W0104
|
||||
/// unreachable-state check.
|
||||
fn collect_transitions_from_state(state: &StateDecl, queue: &mut Vec<String>) {
|
||||
if let Some(block) = &state.on_enter {
|
||||
collect_transitions_block(block, queue);
|
||||
}
|
||||
if let Some(block) = &state.on_exit {
|
||||
collect_transitions_block(block, queue);
|
||||
}
|
||||
if let Some(block) = &state.on_frame {
|
||||
collect_transitions_block(block, queue);
|
||||
}
|
||||
for (_, block) in &state.on_scanline {
|
||||
collect_transitions_block(block, queue);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_transitions_block(block: &Block, queue: &mut Vec<String>) {
|
||||
for stmt in &block.statements {
|
||||
collect_transitions_stmt(stmt, queue);
|
||||
}
|
||||
}
|
||||
|
||||
fn collect_transitions_stmt(stmt: &Statement, queue: &mut Vec<String>) {
|
||||
match stmt {
|
||||
Statement::Transition(name, _) => queue.push(name.clone()),
|
||||
Statement::If(_, then_b, elifs, else_b, _) => {
|
||||
collect_transitions_block(then_b, queue);
|
||||
for (_, b) in elifs {
|
||||
collect_transitions_block(b, queue);
|
||||
}
|
||||
if let Some(b) = else_b {
|
||||
collect_transitions_block(b, queue);
|
||||
}
|
||||
}
|
||||
Statement::While(_, body, _) | Statement::Loop(body, _) => {
|
||||
collect_transitions_block(body, queue);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// Collect all function/call names from a block.
|
||||
fn collect_calls(block: &Block) -> Vec<String> {
|
||||
let mut calls = Vec::new();
|
||||
|
|
|
|||
|
|
@ -254,3 +254,179 @@ fn analyze_break_outside_loop() {
|
|||
"break outside loop should produce E0203, got: {errors:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_unused_variable_warning() {
|
||||
// `ghost` is declared but never read (only the initializer runs).
|
||||
// It should trigger a W0103 warning.
|
||||
let (prog, diags) = parser::parse(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
var ghost: u8 = 0
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(diags.is_empty(), "parse errors: {diags:?}");
|
||||
let result = analyze(&prog.unwrap());
|
||||
assert!(
|
||||
result.diagnostics.iter().any(|d| d.code == ErrorCode::W0103
|
||||
&& d.level == crate::errors::Level::Warning
|
||||
&& d.message.contains("ghost")),
|
||||
"expected W0103 for unused var 'ghost', got: {:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
// And no hard errors.
|
||||
assert!(
|
||||
result.diagnostics.iter().all(|d| !d.is_error()),
|
||||
"unexpected hard errors: {:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_unused_variable_no_warning_when_read() {
|
||||
// `counter` is both written and read (in the `if` condition),
|
||||
// so W0103 should NOT fire for it.
|
||||
let (prog, diags) = parser::parse(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
var counter: u8 = 0
|
||||
on frame {
|
||||
counter = counter + 1
|
||||
if counter > 60 { wait_frame }
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(diags.is_empty(), "parse errors: {diags:?}");
|
||||
let result = analyze(&prog.unwrap());
|
||||
assert!(
|
||||
!result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == ErrorCode::W0103 && d.message.contains("counter")),
|
||||
"did not expect W0103 for read variable 'counter', got: {:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_unused_variable_underscore_prefix_silences() {
|
||||
// A leading underscore silences the W0103 warning, matching Rust's
|
||||
// convention for intentionally-unused names.
|
||||
let (prog, diags) = parser::parse(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
var _reserved: u8 = 0
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(diags.is_empty(), "parse errors: {diags:?}");
|
||||
let result = analyze(&prog.unwrap());
|
||||
assert!(
|
||||
!result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == ErrorCode::W0103),
|
||||
"did not expect W0103 for underscore-prefixed var, got: {:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_unreachable_state_warning() {
|
||||
// `Orphan` is never reached from `Main` — W0104 should fire.
|
||||
let (prog, diags) = parser::parse(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
state Main {
|
||||
on frame { wait_frame }
|
||||
}
|
||||
state Orphan {
|
||||
on frame { wait_frame }
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(diags.is_empty(), "parse errors: {diags:?}");
|
||||
let result = analyze(&prog.unwrap());
|
||||
assert!(
|
||||
result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == ErrorCode::W0104 && d.message.contains("Orphan")),
|
||||
"expected W0104 for unreachable state 'Orphan', got: {:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
// And no hard errors.
|
||||
assert!(
|
||||
result.diagnostics.iter().all(|d| !d.is_error()),
|
||||
"unexpected hard errors: {:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_reachable_state_no_warning() {
|
||||
// Both states are reachable: Main transitions to Other, and Other
|
||||
// transitions back to Main. Neither should trigger W0104.
|
||||
let (prog, diags) = parser::parse(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
state Main {
|
||||
on frame { transition Other }
|
||||
}
|
||||
state Other {
|
||||
on frame { transition Main }
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(diags.is_empty(), "parse errors: {diags:?}");
|
||||
let result = analyze(&prog.unwrap());
|
||||
assert!(
|
||||
!result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.any(|d| d.code == ErrorCode::W0104),
|
||||
"did not expect any W0104 warnings, got: {:?}",
|
||||
result.diagnostics
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_undefined_variable_emits_e0502() {
|
||||
// `ghosy` does not exist; analyzer should emit E0502 and — thanks to
|
||||
// the suggestion helper — hint at `ghost` which is the close match.
|
||||
let (prog, diags) = parser::parse(
|
||||
r#"
|
||||
game "Test" { mapper: NROM }
|
||||
var ghost: u8 = 0
|
||||
var score: u8 = 0
|
||||
on frame {
|
||||
score = ghosy + 1
|
||||
}
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(diags.is_empty(), "parse errors: {diags:?}");
|
||||
let result = analyze(&prog.unwrap());
|
||||
let diag = result
|
||||
.diagnostics
|
||||
.iter()
|
||||
.find(|d| d.code == ErrorCode::E0502)
|
||||
.expect("expected E0502 for undefined variable 'ghosy'");
|
||||
assert!(
|
||||
diag.message.contains("ghosy"),
|
||||
"E0502 message should mention 'ghosy', got: {}",
|
||||
diag.message
|
||||
);
|
||||
assert_eq!(
|
||||
diag.help.as_deref(),
|
||||
Some("did you mean 'ghost'?"),
|
||||
"expected suggestion for 'ghost', got: {:?}",
|
||||
diag.help
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
mod chr;
|
||||
mod palette;
|
||||
pub mod resolve;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use chr::png_to_chr;
|
||||
pub use palette::{nearest_nes_color, NES_COLORS};
|
||||
pub use resolve::resolve_sprites;
|
||||
|
|
|
|||
55
src/assets/resolve.rs
Normal file
55
src/assets/resolve.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
use std::path::Path;
|
||||
|
||||
use crate::linker::SpriteData;
|
||||
use crate::parser::ast::{AssetSource, Program};
|
||||
|
||||
/// Resolve sprite declarations in a program into concrete CHR byte blobs and
|
||||
/// assign each one a tile index in CHR ROM.
|
||||
///
|
||||
/// Tile index 0 is reserved for the built-in default smiley sprite, so user
|
||||
/// sprites start at tile index 1. A single sprite declaration may occupy
|
||||
/// multiple consecutive tiles if its CHR data is larger than 16 bytes.
|
||||
///
|
||||
/// `source_dir` is used as the base for `@binary` / `@chr` relative paths.
|
||||
/// For now, only [`AssetSource::Inline`] sources produce sprite data; file-
|
||||
/// backed sources are parsed but skipped here (future work), which keeps
|
||||
/// existing tests that reference missing files compiling without I/O errors.
|
||||
pub fn resolve_sprites(program: &Program, source_dir: &Path) -> Result<Vec<SpriteData>, String> {
|
||||
let _ = source_dir; // reserved for future file-backed resolution
|
||||
let mut sprites = Vec::new();
|
||||
// Tile index 0 is the built-in smiley; user sprites start at 1.
|
||||
let mut next_tile: u8 = 1;
|
||||
|
||||
for sprite_decl in &program.sprites {
|
||||
let chr_bytes = match &sprite_decl.chr_source {
|
||||
AssetSource::Inline(bytes) => bytes.clone(),
|
||||
// Binary/Chr loading from files is future work; skip for now so
|
||||
// programs that reference (possibly-missing) external assets
|
||||
// still compile without I/O errors.
|
||||
AssetSource::Binary(_) | AssetSource::Chr(_) => continue,
|
||||
};
|
||||
|
||||
// Each NES 8x8 tile is 16 bytes of 2-bitplane CHR data. A single
|
||||
// sprite declaration can span multiple tiles when its CHR blob is
|
||||
// longer than 16 bytes.
|
||||
let tile_count = chr_bytes.len().div_ceil(16);
|
||||
if tile_count == 0 {
|
||||
continue;
|
||||
}
|
||||
if next_tile as usize + tile_count > 256 {
|
||||
return Err(format!(
|
||||
"sprite '{}' would exceed CHR ROM tile limit",
|
||||
sprite_decl.name
|
||||
));
|
||||
}
|
||||
|
||||
sprites.push(SpriteData {
|
||||
name: sprite_decl.name.clone(),
|
||||
tile_index: next_tile,
|
||||
chr_bytes,
|
||||
});
|
||||
next_tile += tile_count as u8;
|
||||
}
|
||||
|
||||
Ok(sprites)
|
||||
}
|
||||
|
|
@ -5,6 +5,7 @@ use std::collections::HashMap;
|
|||
|
||||
use crate::analyzer::VarAllocation;
|
||||
use crate::asm::{AddressingMode as AM, Instruction, Opcode::*};
|
||||
use crate::linker::SpriteData;
|
||||
use crate::parser::ast::*;
|
||||
|
||||
/// Zero-page address for the current state index.
|
||||
|
|
@ -29,6 +30,8 @@ pub struct CodeGen {
|
|||
loop_stack: Vec<(String, String)>,
|
||||
/// Next OAM slot to allocate (0-63), reset per frame handler
|
||||
next_oam_slot: u8,
|
||||
/// Maps sprite name → CHR ROM tile index for `draw SpriteName`
|
||||
sprite_tiles: HashMap<String, u8>,
|
||||
}
|
||||
|
||||
impl CodeGen {
|
||||
|
|
@ -55,9 +58,21 @@ impl CodeGen {
|
|||
state_indices: HashMap::new(),
|
||||
loop_stack: Vec::new(),
|
||||
next_oam_slot: 0,
|
||||
sprite_tiles: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register sprite-to-tile-index mappings so that `draw SpriteName` can
|
||||
/// emit the correct CHR tile index instead of defaulting to 0.
|
||||
#[must_use]
|
||||
pub fn with_sprites(mut self, sprites: &[SpriteData]) -> Self {
|
||||
for sprite in sprites {
|
||||
self.sprite_tiles
|
||||
.insert(sprite.name.clone(), sprite.tile_index);
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn fresh_label(&mut self, prefix: &str) -> String {
|
||||
self.label_counter += 1;
|
||||
format!("__{prefix}_{}", self.label_counter)
|
||||
|
|
@ -330,6 +345,21 @@ impl CodeGen {
|
|||
self.gen_eor_expr(expr);
|
||||
self.emit_store(addr);
|
||||
}
|
||||
AssignOp::ShiftLeftAssign => {
|
||||
// x <<= n: load, shift left n times, store
|
||||
// For non-constant shift count, emit ASL A once
|
||||
// (matches codegen of the << operator)
|
||||
self.emit_load(addr);
|
||||
self.emit(ASL, AM::Accumulator);
|
||||
let _ = expr; // count is evaluated but not used for dynamic shifts yet
|
||||
self.emit_store(addr);
|
||||
}
|
||||
AssignOp::ShiftRightAssign => {
|
||||
self.emit_load(addr);
|
||||
self.emit(LSR, AM::Accumulator);
|
||||
let _ = expr;
|
||||
self.emit_store(addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -415,6 +445,34 @@ impl CodeGen {
|
|||
self.emit(STA, AM::AbsoluteX(base_addr));
|
||||
}
|
||||
}
|
||||
AssignOp::ShiftLeftAssign => {
|
||||
if base_addr < 0x100 {
|
||||
self.emit(LDA, AM::ZeroPageX(base_addr as u8));
|
||||
} else {
|
||||
self.emit(LDA, AM::AbsoluteX(base_addr));
|
||||
}
|
||||
self.emit(ASL, AM::Accumulator);
|
||||
let _ = expr;
|
||||
if base_addr < 0x100 {
|
||||
self.emit(STA, AM::ZeroPageX(base_addr as u8));
|
||||
} else {
|
||||
self.emit(STA, AM::AbsoluteX(base_addr));
|
||||
}
|
||||
}
|
||||
AssignOp::ShiftRightAssign => {
|
||||
if base_addr < 0x100 {
|
||||
self.emit(LDA, AM::ZeroPageX(base_addr as u8));
|
||||
} else {
|
||||
self.emit(LDA, AM::AbsoluteX(base_addr));
|
||||
}
|
||||
self.emit(LSR, AM::Accumulator);
|
||||
let _ = expr;
|
||||
if base_addr < 0x100 {
|
||||
self.emit(STA, AM::ZeroPageX(base_addr as u8));
|
||||
} else {
|
||||
self.emit(STA, AM::AbsoluteX(base_addr));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -842,9 +900,12 @@ impl CodeGen {
|
|||
self.gen_expr(&draw.y);
|
||||
self.emit(STA, AM::Absolute(base));
|
||||
|
||||
// Tile index — use frame: expr if provided, else 0
|
||||
// Tile index — use frame: expr if provided, else the sprite's
|
||||
// resolved tile index, else 0 (default smiley).
|
||||
if let Some(frame) = &draw.frame {
|
||||
self.gen_expr(frame);
|
||||
} else if let Some(&tile_idx) = self.sprite_tiles.get(&draw.sprite_name) {
|
||||
self.emit(LDA, AM::Immediate(tile_idx));
|
||||
} else {
|
||||
self.emit(LDA, AM::Immediate(0));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,10 +47,8 @@ pub enum ErrorCode {
|
|||
W0101, // expensive multiply/divide operation
|
||||
#[allow(dead_code)]
|
||||
W0102, // loop without break or wait_frame
|
||||
#[allow(dead_code)]
|
||||
W0103, // unused variable
|
||||
#[allow(dead_code)]
|
||||
W0104, // unreachable code
|
||||
W0104, // unreachable state
|
||||
}
|
||||
|
||||
impl fmt::Display for ErrorCode {
|
||||
|
|
|
|||
|
|
@ -328,6 +328,8 @@ impl LoweringContext {
|
|||
AssignOp::AmpAssign => IrOp::And(result, current, rhs),
|
||||
AssignOp::PipeAssign => IrOp::Or(result, current, rhs),
|
||||
AssignOp::CaretAssign => IrOp::Xor(result, current, rhs),
|
||||
AssignOp::ShiftLeftAssign => IrOp::ShiftLeft(result, current, 1),
|
||||
AssignOp::ShiftRightAssign => IrOp::ShiftRight(result, current, 1),
|
||||
AssignOp::Assign => unreachable!(),
|
||||
};
|
||||
self.emit(ir_op);
|
||||
|
|
@ -352,6 +354,8 @@ impl LoweringContext {
|
|||
AssignOp::AmpAssign => IrOp::And(result, current, val),
|
||||
AssignOp::PipeAssign => IrOp::Or(result, current, val),
|
||||
AssignOp::CaretAssign => IrOp::Xor(result, current, val),
|
||||
AssignOp::ShiftLeftAssign => IrOp::ShiftLeft(result, current, 1),
|
||||
AssignOp::ShiftRightAssign => IrOp::ShiftRight(result, current, 1),
|
||||
AssignOp::Assign => unreachable!(),
|
||||
};
|
||||
self.emit(ir_op);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,15 @@ pub struct Linker {
|
|||
mapper: Mapper,
|
||||
}
|
||||
|
||||
/// CHR data for a sprite, placed at a specific tile index in CHR ROM.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpriteData {
|
||||
pub name: String,
|
||||
pub tile_index: u8,
|
||||
/// Raw CHR bytes (16 bytes per 8x8 tile).
|
||||
pub chr_bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
/// A smiley face CHR tile for the default sprite (M1).
|
||||
const DEFAULT_SPRITE_CHR: [u8; 16] = [
|
||||
// Plane 0 (low bits)
|
||||
|
|
@ -62,7 +71,17 @@ impl Linker {
|
|||
}
|
||||
|
||||
/// Link all code sections into a .nes ROM.
|
||||
///
|
||||
/// This is a thin wrapper around [`Linker::link_with_assets`] that passes
|
||||
/// an empty sprite list, so the CHR ROM only contains the default smiley
|
||||
/// tile at index 0.
|
||||
pub fn link(&self, user_code: &[Instruction]) -> Vec<u8> {
|
||||
self.link_with_assets(user_code, &[])
|
||||
}
|
||||
|
||||
/// Link all code sections into a .nes ROM, placing sprite CHR data at
|
||||
/// specific tile indices.
|
||||
pub fn link_with_assets(&self, user_code: &[Instruction], sprites: &[SpriteData]) -> Vec<u8> {
|
||||
// For NROM: everything fits in one 16 KB PRG bank ($C000-$FFFF)
|
||||
// Layout:
|
||||
// $C000: RESET handler (init + palette load + user code)
|
||||
|
|
@ -125,9 +144,17 @@ impl Linker {
|
|||
builder.set_mapper(crate::rom::mapper_number(self.mapper));
|
||||
builder.set_prg(prg);
|
||||
|
||||
// CHR ROM with default sprite tile
|
||||
// CHR ROM: tile 0 is reserved for the default smiley, followed by
|
||||
// any user-declared sprites placed at their assigned tile indices.
|
||||
let mut chr = vec![0u8; 8192];
|
||||
chr[..16].copy_from_slice(&DEFAULT_SPRITE_CHR);
|
||||
for sprite in sprites {
|
||||
let offset = sprite.tile_index as usize * 16;
|
||||
let end = offset + sprite.chr_bytes.len();
|
||||
if end <= chr.len() {
|
||||
chr[offset..end].copy_from_slice(&sprite.chr_bytes);
|
||||
}
|
||||
}
|
||||
builder.set_chr(chr);
|
||||
|
||||
builder.build()
|
||||
|
|
|
|||
|
|
@ -74,6 +74,62 @@ fn link_rom_size_correct() {
|
|||
assert_eq!(rom_data.len(), 16 + 16384 + 8192);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_with_sprites_places_chr_data() {
|
||||
let linker = Linker::new(Mirroring::Horizontal);
|
||||
let user_code = vec![Instruction::implied(NOP)];
|
||||
|
||||
let sprite_bytes: Vec<u8> = (0x20..0x30).collect(); // 16 bytes, one tile
|
||||
let sprites = vec![SpriteData {
|
||||
name: "Player".into(),
|
||||
tile_index: 1,
|
||||
chr_bytes: sprite_bytes.clone(),
|
||||
}];
|
||||
|
||||
let rom_data = linker.link_with_assets(&user_code, &sprites);
|
||||
|
||||
// CHR starts right after the 16-byte iNES header and 16 KB PRG bank.
|
||||
let chr_start = 16 + 16384;
|
||||
|
||||
// Tile 0 should still contain the built-in smiley (first 16 bytes, not
|
||||
// all zero).
|
||||
let tile0 = &rom_data[chr_start..chr_start + 16];
|
||||
assert_ne!(
|
||||
tile0, &[0u8; 16],
|
||||
"default smiley should occupy tile index 0",
|
||||
);
|
||||
|
||||
// Tile 1 (CHR offset 16) should contain the sprite's CHR bytes exactly.
|
||||
let tile1 = &rom_data[chr_start + 16..chr_start + 32];
|
||||
assert_eq!(tile1, sprite_bytes.as_slice());
|
||||
|
||||
// Tile 2 and beyond should be untouched (all zeros).
|
||||
let tile2 = &rom_data[chr_start + 32..chr_start + 48];
|
||||
assert_eq!(tile2, &[0u8; 16]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn link_with_sprites_spanning_multiple_tiles() {
|
||||
let linker = Linker::new(Mirroring::Horizontal);
|
||||
let user_code = vec![Instruction::implied(NOP)];
|
||||
|
||||
// 32 bytes = 2 tiles. The linker should place them consecutively
|
||||
// starting at the requested tile index.
|
||||
let sprite_bytes: Vec<u8> = (0..32).collect();
|
||||
let sprites = vec![SpriteData {
|
||||
name: "Big".into(),
|
||||
tile_index: 4,
|
||||
chr_bytes: sprite_bytes.clone(),
|
||||
}];
|
||||
|
||||
let rom_data = linker.link_with_assets(&user_code, &sprites);
|
||||
let chr_start = 16 + 16384;
|
||||
|
||||
// Tile 4 starts at CHR offset 64.
|
||||
let placed = &rom_data[chr_start + 64..chr_start + 64 + 32];
|
||||
assert_eq!(placed, sprite_bytes.as_slice());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn palette_load_writes_to_ppu() {
|
||||
let linker = Linker::new(Mirroring::Horizontal);
|
||||
|
|
|
|||
17
src/main.rs
17
src/main.rs
|
|
@ -1,7 +1,8 @@
|
|||
use clap::Parser;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use nescript::analyzer;
|
||||
use nescript::assets;
|
||||
use nescript::codegen::CodeGen;
|
||||
use nescript::errors::render_diagnostics;
|
||||
use nescript::ir;
|
||||
|
|
@ -116,17 +117,25 @@ fn compile(input: &PathBuf, _debug: bool, asm_dump: bool) -> Result<Vec<u8>, ()>
|
|||
let mut ir_program = ir::lower(&program, &analysis);
|
||||
optimizer::optimize(&mut ir_program);
|
||||
|
||||
// Resolve sprite assets (CHR data + tile indices) relative to the
|
||||
// source file's directory, so `@binary` / `@chr` paths work naturally.
|
||||
let source_dir = input.parent().unwrap_or_else(|| Path::new("."));
|
||||
let sprites = assets::resolve_sprites(&program, source_dir).map_err(|e| {
|
||||
eprintln!("error: {e}");
|
||||
})?;
|
||||
|
||||
// Code generation (still AST-based for M2; IR codegen comes in M3)
|
||||
let codegen = CodeGen::new(&analysis.var_allocations, &program.constants);
|
||||
let codegen =
|
||||
CodeGen::new(&analysis.var_allocations, &program.constants).with_sprites(&sprites);
|
||||
let instructions = codegen.generate(&program);
|
||||
|
||||
if asm_dump {
|
||||
dump_asm(&instructions);
|
||||
}
|
||||
|
||||
// Link into ROM
|
||||
// Link into ROM with sprite CHR data placed at each sprite's tile index.
|
||||
let linker = Linker::new(program.game.mirroring);
|
||||
let rom = linker.link(&instructions);
|
||||
let rom = linker.link_with_assets(&instructions, &sprites);
|
||||
|
||||
Ok(rom)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -266,4 +266,6 @@ pub enum AssignOp {
|
|||
AmpAssign,
|
||||
PipeAssign,
|
||||
CaretAssign,
|
||||
ShiftLeftAssign,
|
||||
ShiftRightAssign,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -963,6 +963,26 @@ impl Parser {
|
|||
start,
|
||||
))
|
||||
}
|
||||
TokenKind::ShiftLeftAssign => {
|
||||
self.advance();
|
||||
let value = self.parse_expr()?;
|
||||
Ok(Statement::Assign(
|
||||
LValue::Var(name),
|
||||
AssignOp::ShiftLeftAssign,
|
||||
value,
|
||||
start,
|
||||
))
|
||||
}
|
||||
TokenKind::ShiftRightAssign => {
|
||||
self.advance();
|
||||
let value = self.parse_expr()?;
|
||||
Ok(Statement::Assign(
|
||||
LValue::Var(name),
|
||||
AssignOp::ShiftRightAssign,
|
||||
value,
|
||||
start,
|
||||
))
|
||||
}
|
||||
TokenKind::LBracket => {
|
||||
// Array index assignment: name[index] = value
|
||||
self.advance();
|
||||
|
|
@ -1027,6 +1047,14 @@ impl Parser {
|
|||
self.advance();
|
||||
Ok(AssignOp::CaretAssign)
|
||||
}
|
||||
TokenKind::ShiftLeftAssign => {
|
||||
self.advance();
|
||||
Ok(AssignOp::ShiftLeftAssign)
|
||||
}
|
||||
TokenKind::ShiftRightAssign => {
|
||||
self.advance();
|
||||
Ok(AssignOp::ShiftRightAssign)
|
||||
}
|
||||
_ => Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("expected assignment operator, found '{}'", self.peek()),
|
||||
|
|
|
|||
|
|
@ -1,4 +1,7 @@
|
|||
use std::path::Path;
|
||||
|
||||
use nescript::analyzer;
|
||||
use nescript::assets;
|
||||
use nescript::codegen::CodeGen;
|
||||
use nescript::ir;
|
||||
use nescript::linker::Linker;
|
||||
|
|
@ -25,11 +28,15 @@ fn compile(source: &str) -> Vec<u8> {
|
|||
let mut ir_program = ir::lower(&program, &analysis);
|
||||
optimizer::optimize(&mut ir_program);
|
||||
|
||||
let codegen = CodeGen::new(&analysis.var_allocations, &program.constants);
|
||||
let sprites = assets::resolve_sprites(&program, Path::new("."))
|
||||
.expect("sprite resolution should succeed");
|
||||
|
||||
let codegen =
|
||||
CodeGen::new(&analysis.var_allocations, &program.constants).with_sprites(&sprites);
|
||||
let instructions = codegen.generate(&program);
|
||||
|
||||
let linker = Linker::new(program.game.mirroring);
|
||||
linker.link(&instructions)
|
||||
linker.link_with_assets(&instructions, &sprites)
|
||||
}
|
||||
|
||||
// ── M1 Tests ──
|
||||
|
|
@ -371,11 +378,77 @@ fn compile_with_mapper(source: &str) -> Vec<u8> {
|
|||
let mut ir_program = ir::lower(&program, &analysis);
|
||||
nescript::optimizer::optimize(&mut ir_program);
|
||||
|
||||
let codegen = nescript::codegen::CodeGen::new(&analysis.var_allocations, &program.constants);
|
||||
let sprites = assets::resolve_sprites(&program, Path::new("."))
|
||||
.expect("sprite resolution should succeed");
|
||||
|
||||
let codegen = nescript::codegen::CodeGen::new(&analysis.var_allocations, &program.constants)
|
||||
.with_sprites(&sprites);
|
||||
let instructions = codegen.generate(&program);
|
||||
|
||||
let linker = Linker::with_mapper(program.game.mirroring, program.game.mapper);
|
||||
linker.link(&instructions)
|
||||
linker.link_with_assets(&instructions, &sprites)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprite_resolution_uses_tile_index() {
|
||||
// The Player sprite has 16 unique bytes of CHR data. Because tile index 0
|
||||
// is reserved for the built-in smiley, the compiler should place Player
|
||||
// at tile index 1 and `draw Player` should store that tile index in OAM.
|
||||
//
|
||||
// We check this in two ways:
|
||||
// 1. The CHR ROM contains Player's bytes at tile 1 (offset 16).
|
||||
// 2. The PRG ROM contains the immediate-load sequence `A9 01 8D 01 02`
|
||||
// (LDA #$01 ; STA $0201) — writing tile index 1 into OAM byte 1.
|
||||
let source = r#"
|
||||
game "SpriteTile" { mapper: NROM }
|
||||
|
||||
sprite Player {
|
||||
chr: [0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17,
|
||||
0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D, 0x1E, 0x1F]
|
||||
}
|
||||
|
||||
var px: u8 = 128
|
||||
var py: u8 = 120
|
||||
|
||||
state Title {
|
||||
on frame {
|
||||
draw Player at: (px, py)
|
||||
}
|
||||
}
|
||||
|
||||
start Title
|
||||
"#;
|
||||
|
||||
let rom_data = compile(source);
|
||||
|
||||
// CHR ROM begins right after PRG ROM (16 header + 16384 PRG).
|
||||
let chr_start = 16 + 16384;
|
||||
// Tile 1 lives at CHR offset 16 (16 bytes per tile).
|
||||
let tile1 = &rom_data[chr_start + 16..chr_start + 32];
|
||||
assert_eq!(
|
||||
tile1,
|
||||
&[
|
||||
0x10, 0x11, 0x12, 0x13, 0x14, 0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1C, 0x1D,
|
||||
0x1E, 0x1F
|
||||
],
|
||||
"Player sprite CHR bytes should be placed at tile index 1",
|
||||
);
|
||||
|
||||
// The default smiley tile at index 0 should still be non-zero (untouched).
|
||||
let tile0 = &rom_data[chr_start..chr_start + 16];
|
||||
assert_ne!(
|
||||
tile0, &[0u8; 16],
|
||||
"tile 0 should still contain the default smiley",
|
||||
);
|
||||
|
||||
// In PRG ROM, look for `LDA #$01 ; STA $0201` which writes the Player's
|
||||
// tile index (1) into the tile-index byte of the first OAM slot.
|
||||
let prg = &rom_data[16..16 + 16384];
|
||||
let pattern = [0xA9u8, 0x01, 0x8D, 0x01, 0x02];
|
||||
assert!(
|
||||
prg.windows(pattern.len()).any(|w| w == pattern),
|
||||
"PRG ROM should contain LDA #$01 ; STA $0201 for draw Player",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue