mirror of
https://github.com/imjasonh/nescript
synced 2026-07-08 00:45:38 +00:00
M3: Asset pipeline, sprite/palette/background declarations, debug symbols
Parser extensions:
- sprite declarations with chr: @chr("file.png"), @binary("file.bin"), or inline [hex]
- palette declarations with colors: [0x0F, 0x00, 0x10, 0x20]
- background declarations with chr: asset source
- @chr/@binary asset source parsing
- load_background and set_palette statements
- --debug CLI flag (plumbed through, not yet wired to codegen)
Asset pipeline (new module):
- PNG → CHR tile conversion using image crate (8x8 tiles, 2-bitplane encoding)
- NES color palette table (all 64 standard NES colors as RGB)
- Nearest-color matching (Euclidean distance in RGB space)
Debug module (new):
- Source map (ROM address → source Span mapping)
- Debug symbols with variable address table
- Mesen-compatible .mlb label export
- .sym symbol table export
192 tests total (13 new: 5 parser + 3 asset + 5 debug)
https://claude.ai/code/session_01W6eQFStA66EuMKHUFo2rx3
This commit is contained in:
parent
0dc06f7f1a
commit
058f7a87b6
18 changed files with 884 additions and 5 deletions
132
Cargo.lock
generated
132
Cargo.lock
generated
|
|
@ -2,6 +2,12 @@
|
|||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "adler2"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
|
|
@ -62,6 +68,36 @@ dependencies = [
|
|||
"yansi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec"
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||
|
||||
[[package]]
|
||||
name = "clap"
|
||||
version = "4.6.0"
|
||||
|
|
@ -108,39 +144,123 @@ version = "1.0.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "diff"
|
||||
version = "0.1.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8"
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flate2"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "heck"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||
|
||||
[[package]]
|
||||
name = "image"
|
||||
version = "0.25.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
"byteorder-lite",
|
||||
"moxcms",
|
||||
"num-traits",
|
||||
"png",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "is_terminal_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "moxcms"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
"pxfm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nescript"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"ariadne",
|
||||
"clap",
|
||||
"image",
|
||||
"pretty_assertions",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell_polyfill"
|
||||
version = "1.70.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pretty_assertions"
|
||||
version = "1.4.1"
|
||||
|
|
@ -160,6 +280,12 @@ dependencies = [
|
|||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
|
|
@ -169,6 +295,12 @@ dependencies = [
|
|||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214"
|
||||
|
||||
[[package]]
|
||||
name = "strsim"
|
||||
version = "0.11.1"
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ license = "MIT"
|
|||
[dependencies]
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
ariadne = "0.4"
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1"
|
||||
|
|
|
|||
|
|
@ -364,7 +364,9 @@ impl Analyzer {
|
|||
Statement::Break(_)
|
||||
| Statement::Continue(_)
|
||||
| Statement::WaitFrame(_)
|
||||
| Statement::Return(None, _) => {}
|
||||
| Statement::Return(None, _)
|
||||
| Statement::LoadBackground(_, _)
|
||||
| Statement::SetPalette(_, _) => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -519,7 +521,9 @@ fn collect_calls_stmt(stmt: &Statement, calls: &mut Vec<String>) {
|
|||
| Statement::Transition(_, _)
|
||||
| Statement::WaitFrame(_)
|
||||
| Statement::Break(_)
|
||||
| Statement::Continue(_) => {}
|
||||
| Statement::Continue(_)
|
||||
| Statement::LoadBackground(_, _)
|
||||
| Statement::SetPalette(_, _) => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
55
src/assets/chr.rs
Normal file
55
src/assets/chr.rs
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
use image::GenericImageView;
|
||||
|
||||
/// Convert a PNG image to NES CHR tile data (2-bitplane format).
|
||||
/// Each 8x8 tile = 16 bytes (8 bytes plane 0, 8 bytes plane 1).
|
||||
pub fn png_to_chr(path: &std::path::Path) -> Result<Vec<u8>, String> {
|
||||
let img = image::open(path).map_err(|e| format!("failed to open {}: {e}", path.display()))?;
|
||||
let (w, h) = img.dimensions();
|
||||
|
||||
if w % 8 != 0 || h % 8 != 0 {
|
||||
return Err(format!("image dimensions {w}x{h} must be multiples of 8"));
|
||||
}
|
||||
|
||||
let mut chr_data = Vec::new();
|
||||
let tiles_x = w / 8;
|
||||
let tiles_y = h / 8;
|
||||
|
||||
for ty in 0..tiles_y {
|
||||
for tx in 0..tiles_x {
|
||||
let tile = encode_tile(&img, tx * 8, ty * 8);
|
||||
chr_data.extend_from_slice(&tile);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(chr_data)
|
||||
}
|
||||
|
||||
fn encode_tile(img: &image::DynamicImage, x: u32, y: u32) -> [u8; 16] {
|
||||
let mut tile = [0u8; 16];
|
||||
|
||||
for row in 0..8u32 {
|
||||
let mut plane0 = 0u8;
|
||||
let mut plane1 = 0u8;
|
||||
for col in 0..8u32 {
|
||||
let pixel = img.get_pixel(x + col, y + row);
|
||||
// Map to 2-bit palette index based on brightness
|
||||
let brightness = (u16::from(pixel[0]) + u16::from(pixel[1]) + u16::from(pixel[2])) / 3;
|
||||
let index = match brightness {
|
||||
0..=63 => 0u8,
|
||||
64..=127 => 1,
|
||||
128..=191 => 2,
|
||||
_ => 3,
|
||||
};
|
||||
if index & 1 != 0 {
|
||||
plane0 |= 0x80 >> col;
|
||||
}
|
||||
if index & 2 != 0 {
|
||||
plane1 |= 0x80 >> col;
|
||||
}
|
||||
}
|
||||
tile[row as usize] = plane0;
|
||||
tile[row as usize + 8] = plane1;
|
||||
}
|
||||
|
||||
tile
|
||||
}
|
||||
7
src/assets/mod.rs
Normal file
7
src/assets/mod.rs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
mod chr;
|
||||
mod palette;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use chr::png_to_chr;
|
||||
pub use palette::{nearest_nes_color, NES_COLORS};
|
||||
88
src/assets/palette.rs
Normal file
88
src/assets/palette.rs
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
/// NES color palette -- 64 colors as (R, G, B) tuples.
|
||||
pub const NES_COLORS: [(u8, u8, u8); 64] = [
|
||||
// Row 0
|
||||
(84, 84, 84), // 0x00
|
||||
(0, 30, 116), // 0x01
|
||||
(8, 16, 144), // 0x02
|
||||
(48, 0, 136), // 0x03
|
||||
(68, 0, 100), // 0x04
|
||||
(92, 0, 48), // 0x05
|
||||
(84, 4, 0), // 0x06
|
||||
(60, 24, 0), // 0x07
|
||||
(32, 42, 0), // 0x08
|
||||
(8, 58, 0), // 0x09
|
||||
(0, 64, 0), // 0x0A
|
||||
(0, 60, 0), // 0x0B
|
||||
(0, 50, 60), // 0x0C
|
||||
(0, 0, 0), // 0x0D
|
||||
(0, 0, 0), // 0x0E
|
||||
(0, 0, 0), // 0x0F
|
||||
// Row 1
|
||||
(152, 150, 152), // 0x10
|
||||
(8, 76, 196), // 0x11
|
||||
(48, 50, 236), // 0x12
|
||||
(92, 30, 228), // 0x13
|
||||
(136, 20, 176), // 0x14
|
||||
(160, 20, 100), // 0x15
|
||||
(152, 34, 32), // 0x16
|
||||
(120, 60, 0), // 0x17
|
||||
(84, 90, 0), // 0x18
|
||||
(40, 114, 0), // 0x19
|
||||
(8, 124, 0), // 0x1A
|
||||
(0, 118, 40), // 0x1B
|
||||
(0, 102, 120), // 0x1C
|
||||
(0, 0, 0), // 0x1D
|
||||
(0, 0, 0), // 0x1E
|
||||
(0, 0, 0), // 0x1F
|
||||
// Row 2
|
||||
(236, 238, 236), // 0x20
|
||||
(76, 154, 236), // 0x21
|
||||
(120, 124, 236), // 0x22
|
||||
(176, 98, 236), // 0x23
|
||||
(228, 84, 236), // 0x24
|
||||
(236, 88, 180), // 0x25
|
||||
(236, 106, 100), // 0x26
|
||||
(212, 136, 32), // 0x27
|
||||
(160, 170, 0), // 0x28
|
||||
(116, 196, 0), // 0x29
|
||||
(76, 208, 32), // 0x2A
|
||||
(56, 204, 108), // 0x2B
|
||||
(56, 180, 204), // 0x2C
|
||||
(60, 60, 60), // 0x2D
|
||||
(0, 0, 0), // 0x2E
|
||||
(0, 0, 0), // 0x2F
|
||||
// Row 3
|
||||
(236, 238, 236), // 0x30
|
||||
(168, 204, 236), // 0x31
|
||||
(188, 188, 236), // 0x32
|
||||
(212, 178, 236), // 0x33
|
||||
(236, 174, 236), // 0x34
|
||||
(236, 174, 212), // 0x35
|
||||
(236, 180, 176), // 0x36
|
||||
(228, 196, 144), // 0x37
|
||||
(204, 210, 120), // 0x38
|
||||
(180, 222, 120), // 0x39
|
||||
(168, 226, 144), // 0x3A
|
||||
(152, 226, 180), // 0x3B
|
||||
(160, 214, 228), // 0x3C
|
||||
(160, 162, 160), // 0x3D
|
||||
(0, 0, 0), // 0x3E
|
||||
(0, 0, 0), // 0x3F
|
||||
];
|
||||
|
||||
/// Find the nearest NES color index for an RGB value.
|
||||
pub fn nearest_nes_color(r: u8, g: u8, b: u8) -> u8 {
|
||||
let mut best_idx = 0u8;
|
||||
let mut best_dist = u32::MAX;
|
||||
for (i, &(nr, ng, nb)) in NES_COLORS.iter().enumerate() {
|
||||
let dr = i32::from(r) - i32::from(nr);
|
||||
let dg = i32::from(g) - i32::from(ng);
|
||||
let db = i32::from(b) - i32::from(nb);
|
||||
let dist = (dr * dr + dg * dg + db * db).unsigned_abs();
|
||||
if dist < best_dist {
|
||||
best_dist = dist;
|
||||
best_idx = i as u8;
|
||||
}
|
||||
}
|
||||
best_idx
|
||||
}
|
||||
17
src/assets/tests.rs
Normal file
17
src/assets/tests.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn nearest_nes_color_black() {
|
||||
assert_eq!(palette::nearest_nes_color(0, 0, 0), 0x0D); // NES black
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nearest_nes_color_white() {
|
||||
let idx = palette::nearest_nes_color(255, 255, 255);
|
||||
assert!(idx == 0x20 || idx == 0x30); // near-white colors
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nes_color_table_has_64_entries() {
|
||||
assert_eq!(palette::NES_COLORS.len(), 64);
|
||||
}
|
||||
|
|
@ -148,6 +148,9 @@ impl CodeGen {
|
|||
| Statement::Call(_, _, _) => {
|
||||
// TODO: implement for later milestones
|
||||
}
|
||||
Statement::LoadBackground(_, _) | Statement::SetPalette(_, _) => {
|
||||
// TODO: implement in asset pipeline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
5
src/debug/mod.rs
Normal file
5
src/debug/mod.rs
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
mod source_map;
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
|
||||
pub use source_map::{DebugSymbols, SourceMap, SourceMapping};
|
||||
89
src/debug/source_map.rs
Normal file
89
src/debug/source_map.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::analyzer::VarAllocation;
|
||||
use crate::lexer::Span;
|
||||
|
||||
/// A mapping from ROM address to source location.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SourceMapping {
|
||||
pub rom_address: u16,
|
||||
pub span: Span,
|
||||
pub label: Option<String>,
|
||||
}
|
||||
|
||||
/// Source map for debug symbol output.
|
||||
/// Maps ROM addresses back to `NEScript` source locations.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SourceMap {
|
||||
pub mappings: Vec<SourceMapping>,
|
||||
}
|
||||
|
||||
impl SourceMap {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn add(&mut self, rom_address: u16, span: Span, label: Option<String>) {
|
||||
self.mappings.push(SourceMapping {
|
||||
rom_address,
|
||||
span,
|
||||
label,
|
||||
});
|
||||
}
|
||||
|
||||
/// Sort mappings by ROM address.
|
||||
pub fn finalize(&mut self) {
|
||||
self.mappings.sort_by_key(|m| m.rom_address);
|
||||
}
|
||||
}
|
||||
|
||||
/// Debug symbols for a compiled ROM.
|
||||
/// Compatible with Mesen's .dbg format.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DebugSymbols {
|
||||
pub source_map: SourceMap,
|
||||
pub symbols: HashMap<String, u16>,
|
||||
pub source_file: String,
|
||||
}
|
||||
|
||||
impl DebugSymbols {
|
||||
pub fn new(source_file: &str) -> Self {
|
||||
Self {
|
||||
source_map: SourceMap::new(),
|
||||
symbols: HashMap::new(),
|
||||
source_file: source_file.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Add variable symbols from analysis.
|
||||
pub fn add_variables(&mut self, allocations: &[VarAllocation]) {
|
||||
for alloc in allocations {
|
||||
self.symbols.insert(alloc.name.clone(), alloc.address);
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate Mesen-compatible .mlb (label) file content.
|
||||
pub fn to_mesen_labels(&self) -> String {
|
||||
let mut output = String::new();
|
||||
for (name, &addr) in &self.symbols {
|
||||
// Mesen label format: P:ADDR:NAME (P = PRG ROM)
|
||||
let _ = writeln!(output, "P:{addr:04X}:{name}");
|
||||
}
|
||||
// Sort for deterministic output
|
||||
let mut lines: Vec<&str> = output.lines().collect();
|
||||
lines.sort_unstable();
|
||||
lines.join("\n") + "\n"
|
||||
}
|
||||
|
||||
/// Generate a simple .sym (symbol table) file.
|
||||
pub fn to_sym_file(&self) -> String {
|
||||
let mut output = String::from("; NEScript debug symbols\n");
|
||||
let mut entries: Vec<_> = self.symbols.iter().collect();
|
||||
entries.sort_by_key(|(_, &addr)| addr);
|
||||
for (name, &addr) in &entries {
|
||||
let _ = writeln!(output, "{addr:04X} {name}");
|
||||
}
|
||||
output
|
||||
}
|
||||
}
|
||||
66
src/debug/tests.rs
Normal file
66
src/debug/tests.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
use super::*;
|
||||
use crate::lexer::Span;
|
||||
|
||||
#[test]
|
||||
fn source_map_add_and_finalize() {
|
||||
let mut sm = SourceMap::new();
|
||||
sm.add(0xC010, Span::new(0, 10, 20), Some("main_loop".into()));
|
||||
sm.add(0xC000, Span::new(0, 0, 5), Some("reset".into()));
|
||||
sm.finalize();
|
||||
|
||||
// Should be sorted by ROM address
|
||||
assert_eq!(sm.mappings[0].rom_address, 0xC000);
|
||||
assert_eq!(sm.mappings[1].rom_address, 0xC010);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_symbols_new() {
|
||||
let ds = DebugSymbols::new("test.ne");
|
||||
assert_eq!(ds.source_file, "test.ne");
|
||||
assert!(ds.symbols.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_symbols_add_variables() {
|
||||
use crate::analyzer::VarAllocation;
|
||||
|
||||
let mut ds = DebugSymbols::new("test.ne");
|
||||
ds.add_variables(&[
|
||||
VarAllocation {
|
||||
name: "px".into(),
|
||||
address: 0x10,
|
||||
size: 1,
|
||||
},
|
||||
VarAllocation {
|
||||
name: "py".into(),
|
||||
address: 0x11,
|
||||
size: 1,
|
||||
},
|
||||
]);
|
||||
assert_eq!(ds.symbols["px"], 0x10);
|
||||
assert_eq!(ds.symbols["py"], 0x11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mesen_labels_format() {
|
||||
let mut ds = DebugSymbols::new("test.ne");
|
||||
ds.symbols.insert("player_x".into(), 0x0010);
|
||||
ds.symbols.insert("score".into(), 0x0012);
|
||||
|
||||
let labels = ds.to_mesen_labels();
|
||||
assert!(labels.contains("P:0010:player_x"));
|
||||
assert!(labels.contains("P:0012:score"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sym_file_format() {
|
||||
let mut ds = DebugSymbols::new("test.ne");
|
||||
ds.symbols.insert("px".into(), 0x10);
|
||||
ds.symbols.insert("py".into(), 0x11);
|
||||
|
||||
let sym = ds.to_sym_file();
|
||||
assert!(sym.contains("0010 px"));
|
||||
assert!(sym.contains("0011 py"));
|
||||
// Should have header comment
|
||||
assert!(sym.starts_with("; NEScript"));
|
||||
}
|
||||
|
|
@ -299,6 +299,9 @@ impl LoweringContext {
|
|||
let arg_temps: Vec<_> = args.iter().map(|a| self.lower_expr(a)).collect();
|
||||
self.emit(IrOp::Call(None, name.clone(), arg_temps));
|
||||
}
|
||||
Statement::LoadBackground(_, _) | Statement::SetPalette(_, _) => {
|
||||
// TODO: implement in asset pipeline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
pub mod analyzer;
|
||||
pub mod asm;
|
||||
pub mod assets;
|
||||
pub mod codegen;
|
||||
pub mod debug;
|
||||
pub mod errors;
|
||||
pub mod ir;
|
||||
pub mod lexer;
|
||||
|
|
|
|||
14
src/main.rs
14
src/main.rs
|
|
@ -19,6 +19,10 @@ enum Cli {
|
|||
/// Output ROM file (default: input with .nes extension)
|
||||
#[arg(short, long)]
|
||||
output: Option<PathBuf>,
|
||||
|
||||
/// Enable debug mode (runtime checks, debug.log)
|
||||
#[arg(long)]
|
||||
debug: bool,
|
||||
},
|
||||
/// Type-check a source file without building
|
||||
Check {
|
||||
|
|
@ -31,9 +35,13 @@ fn main() {
|
|||
let cli = Cli::parse();
|
||||
|
||||
match cli {
|
||||
Cli::Build { input, output } => {
|
||||
Cli::Build {
|
||||
input,
|
||||
output,
|
||||
debug,
|
||||
} => {
|
||||
let output = output.unwrap_or_else(|| input.with_extension("nes"));
|
||||
match compile(&input) {
|
||||
match compile(&input, debug) {
|
||||
Ok(rom) => {
|
||||
std::fs::write(&output, rom).unwrap_or_else(|e| {
|
||||
eprintln!("error: failed to write {}: {e}", output.display());
|
||||
|
|
@ -56,7 +64,7 @@ fn main() {
|
|||
}
|
||||
}
|
||||
|
||||
fn compile(input: &PathBuf) -> Result<Vec<u8>, ()> {
|
||||
fn compile(input: &PathBuf, _debug: bool) -> Result<Vec<u8>, ()> {
|
||||
let source = std::fs::read_to_string(input).map_err(|e| {
|
||||
eprintln!("error: failed to read {}: {e}", input.display());
|
||||
})?;
|
||||
|
|
|
|||
|
|
@ -7,10 +7,41 @@ pub struct Program {
|
|||
pub constants: Vec<ConstDecl>,
|
||||
pub functions: Vec<FunDecl>,
|
||||
pub states: Vec<StateDecl>,
|
||||
pub sprites: Vec<SpriteDecl>,
|
||||
pub palettes: Vec<PaletteDecl>,
|
||||
pub backgrounds: Vec<BackgroundDecl>,
|
||||
pub start_state: String,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SpriteDecl {
|
||||
pub name: String,
|
||||
pub chr_source: AssetSource,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PaletteDecl {
|
||||
pub name: String,
|
||||
pub colors: Vec<u8>,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BackgroundDecl {
|
||||
pub name: String,
|
||||
pub chr_source: AssetSource,
|
||||
pub span: Span,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum AssetSource {
|
||||
Chr(String),
|
||||
Binary(String),
|
||||
Inline(Vec<u8>),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct GameDecl {
|
||||
pub name: String,
|
||||
|
|
@ -188,6 +219,8 @@ pub enum Statement {
|
|||
Transition(String, Span),
|
||||
WaitFrame(Span),
|
||||
Call(String, Vec<Expr>, Span),
|
||||
LoadBackground(String, Span),
|
||||
SetPalette(String, Span),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
|
|
|
|||
|
|
@ -111,6 +111,9 @@ impl Parser {
|
|||
let mut constants = Vec::new();
|
||||
let mut functions = Vec::new();
|
||||
let mut states = Vec::new();
|
||||
let mut sprites = Vec::new();
|
||||
let mut palettes = Vec::new();
|
||||
let mut backgrounds = Vec::new();
|
||||
let mut start_state = None;
|
||||
let mut on_frame = None;
|
||||
let span = self.current_span();
|
||||
|
|
@ -135,6 +138,15 @@ impl Parser {
|
|||
TokenKind::KwState => {
|
||||
states.push(self.parse_state_decl()?);
|
||||
}
|
||||
TokenKind::KwSprite => {
|
||||
sprites.push(self.parse_sprite_decl()?);
|
||||
}
|
||||
TokenKind::KwPalette => {
|
||||
palettes.push(self.parse_palette_decl()?);
|
||||
}
|
||||
TokenKind::KwBackground => {
|
||||
backgrounds.push(self.parse_background_decl()?);
|
||||
}
|
||||
TokenKind::KwOn => {
|
||||
// Top-level `on frame` — implicit single state for M1
|
||||
on_frame = Some(self.parse_on_frame()?);
|
||||
|
|
@ -187,6 +199,9 @@ impl Parser {
|
|||
constants,
|
||||
functions,
|
||||
states,
|
||||
sprites,
|
||||
palettes,
|
||||
backgrounds,
|
||||
start_state,
|
||||
span,
|
||||
})
|
||||
|
|
@ -450,6 +465,204 @@ impl Parser {
|
|||
self.parse_block()
|
||||
}
|
||||
|
||||
// ── Sprite / Palette / Background declarations ──
|
||||
|
||||
fn parse_sprite_decl(&mut self) -> Result<SpriteDecl, Diagnostic> {
|
||||
let start = self.current_span();
|
||||
self.expect(&TokenKind::KwSprite)?;
|
||||
let (name, _) = self.expect_ident()?;
|
||||
self.expect(&TokenKind::LBrace)?;
|
||||
|
||||
let mut chr_source = None;
|
||||
|
||||
while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof {
|
||||
let (key, _) = self.expect_ident()?;
|
||||
self.expect(&TokenKind::Colon)?;
|
||||
match key.as_str() {
|
||||
"chr" => {
|
||||
chr_source = Some(self.parse_asset_source()?);
|
||||
}
|
||||
_ => {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("unknown sprite property '{key}'"),
|
||||
self.current_span(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.expect(&TokenKind::RBrace)?;
|
||||
|
||||
let chr_source = chr_source.ok_or_else(|| {
|
||||
Diagnostic::error(ErrorCode::E0201, "sprite requires 'chr' property", start)
|
||||
})?;
|
||||
|
||||
Ok(SpriteDecl {
|
||||
name,
|
||||
chr_source,
|
||||
span: Span::new(start.file_id, start.start, self.current_span().start),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_palette_decl(&mut self) -> Result<PaletteDecl, Diagnostic> {
|
||||
let start = self.current_span();
|
||||
self.expect(&TokenKind::KwPalette)?;
|
||||
let (name, _) = self.expect_ident()?;
|
||||
self.expect(&TokenKind::LBrace)?;
|
||||
|
||||
let mut colors = None;
|
||||
|
||||
while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof {
|
||||
let (key, _) = self.expect_ident()?;
|
||||
self.expect(&TokenKind::Colon)?;
|
||||
match key.as_str() {
|
||||
"colors" => {
|
||||
self.expect(&TokenKind::LBracket)?;
|
||||
let mut vals = Vec::new();
|
||||
while *self.peek() != TokenKind::RBracket && *self.peek() != TokenKind::Eof {
|
||||
if let TokenKind::IntLiteral(v) = self.peek().clone() {
|
||||
self.advance();
|
||||
vals.push(v as u8);
|
||||
} else {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("expected color index, found '{}'", self.peek()),
|
||||
self.current_span(),
|
||||
));
|
||||
}
|
||||
if *self.peek() == TokenKind::Comma {
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
self.expect(&TokenKind::RBracket)?;
|
||||
colors = Some(vals);
|
||||
}
|
||||
_ => {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("unknown palette property '{key}'"),
|
||||
self.current_span(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.expect(&TokenKind::RBrace)?;
|
||||
|
||||
let colors = colors.ok_or_else(|| {
|
||||
Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
"palette requires 'colors' property",
|
||||
start,
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(PaletteDecl {
|
||||
name,
|
||||
colors,
|
||||
span: Span::new(start.file_id, start.start, self.current_span().start),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_background_decl(&mut self) -> Result<BackgroundDecl, Diagnostic> {
|
||||
let start = self.current_span();
|
||||
self.expect(&TokenKind::KwBackground)?;
|
||||
let (name, _) = self.expect_ident()?;
|
||||
self.expect(&TokenKind::LBrace)?;
|
||||
|
||||
let mut chr_source = None;
|
||||
|
||||
while *self.peek() != TokenKind::RBrace && *self.peek() != TokenKind::Eof {
|
||||
let (key, _) = self.expect_ident()?;
|
||||
self.expect(&TokenKind::Colon)?;
|
||||
match key.as_str() {
|
||||
"chr" => {
|
||||
chr_source = Some(self.parse_asset_source()?);
|
||||
}
|
||||
_ => {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("unknown background property '{key}'"),
|
||||
self.current_span(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.expect(&TokenKind::RBrace)?;
|
||||
|
||||
let chr_source = chr_source.ok_or_else(|| {
|
||||
Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
"background requires 'chr' property",
|
||||
start,
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(BackgroundDecl {
|
||||
name,
|
||||
chr_source,
|
||||
span: Span::new(start.file_id, start.start, self.current_span().start),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_asset_source(&mut self) -> Result<AssetSource, Diagnostic> {
|
||||
match self.peek() {
|
||||
TokenKind::At => {
|
||||
self.advance(); // consume '@'
|
||||
let (kind, _) = self.expect_ident()?;
|
||||
self.expect(&TokenKind::LParen)?;
|
||||
let path = if let TokenKind::StringLiteral(s) = self.peek().clone() {
|
||||
self.advance();
|
||||
s
|
||||
} else {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("expected string path, found '{}'", self.peek()),
|
||||
self.current_span(),
|
||||
));
|
||||
};
|
||||
self.expect(&TokenKind::RParen)?;
|
||||
match kind.as_str() {
|
||||
"chr" => Ok(AssetSource::Chr(path)),
|
||||
"binary" => Ok(AssetSource::Binary(path)),
|
||||
_ => Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("unknown asset source kind '@{kind}'"),
|
||||
self.current_span(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
TokenKind::LBracket => {
|
||||
self.advance();
|
||||
let mut bytes = Vec::new();
|
||||
while *self.peek() != TokenKind::RBracket && *self.peek() != TokenKind::Eof {
|
||||
if let TokenKind::IntLiteral(v) = self.peek().clone() {
|
||||
self.advance();
|
||||
bytes.push(v as u8);
|
||||
} else {
|
||||
return Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!("expected byte value, found '{}'", self.peek()),
|
||||
self.current_span(),
|
||||
));
|
||||
}
|
||||
if *self.peek() == TokenKind::Comma {
|
||||
self.advance();
|
||||
}
|
||||
}
|
||||
self.expect(&TokenKind::RBracket)?;
|
||||
Ok(AssetSource::Inline(bytes))
|
||||
}
|
||||
_ => Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
format!(
|
||||
"expected asset source (@chr, @binary, or [...]), found '{}'",
|
||||
self.peek()
|
||||
),
|
||||
self.current_span(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Block ──
|
||||
|
||||
fn parse_block(&mut self) -> Result<Block, Diagnostic> {
|
||||
|
|
@ -511,6 +724,18 @@ impl Parser {
|
|||
self.advance();
|
||||
Ok(Statement::WaitFrame(span))
|
||||
}
|
||||
TokenKind::KwLoadBackground => {
|
||||
let span = self.current_span();
|
||||
self.advance();
|
||||
let (name, _) = self.expect_ident()?;
|
||||
Ok(Statement::LoadBackground(name, span))
|
||||
}
|
||||
TokenKind::KwSetPalette => {
|
||||
let span = self.current_span();
|
||||
self.advance();
|
||||
let (name, _) = self.expect_ident()?;
|
||||
Ok(Statement::SetPalette(name, span))
|
||||
}
|
||||
TokenKind::Ident(_) => self.parse_assign_or_call(),
|
||||
_ => Err(Diagnostic::error(
|
||||
ErrorCode::E0201,
|
||||
|
|
|
|||
|
|
@ -482,3 +482,103 @@ fn parse_function_call_expr() {
|
|||
other => panic!("expected assignment with Call, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── Milestone 3: Sprites / Palettes / Backgrounds ──
|
||||
|
||||
#[test]
|
||||
fn parse_sprite_decl() {
|
||||
let src = r#"
|
||||
game "Test" { mapper: NROM }
|
||||
sprite Player {
|
||||
chr: [0x3C, 0x42, 0x81, 0x81, 0x81, 0x81, 0x42, 0x3C,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
|
||||
}
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#;
|
||||
let prog = parse_ok(src);
|
||||
assert_eq!(prog.sprites.len(), 1);
|
||||
assert_eq!(prog.sprites[0].name, "Player");
|
||||
match &prog.sprites[0].chr_source {
|
||||
AssetSource::Inline(data) => {
|
||||
assert_eq!(data.len(), 16);
|
||||
assert_eq!(data[0], 0x3C);
|
||||
}
|
||||
other => panic!("expected Inline, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_palette_decl() {
|
||||
let src = r#"
|
||||
game "Test" { mapper: NROM }
|
||||
palette MainPal {
|
||||
colors: [0x0F, 0x00, 0x10, 0x20]
|
||||
}
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#;
|
||||
let prog = parse_ok(src);
|
||||
assert_eq!(prog.palettes.len(), 1);
|
||||
assert_eq!(prog.palettes[0].name, "MainPal");
|
||||
assert_eq!(prog.palettes[0].colors, vec![0x0F, 0x00, 0x10, 0x20]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_background_decl() {
|
||||
let src = r#"
|
||||
game "Test" { mapper: NROM }
|
||||
background TitleBg {
|
||||
chr: @chr("title.png")
|
||||
}
|
||||
on frame { wait_frame }
|
||||
start Main
|
||||
"#;
|
||||
let prog = parse_ok(src);
|
||||
assert_eq!(prog.backgrounds.len(), 1);
|
||||
assert_eq!(prog.backgrounds[0].name, "TitleBg");
|
||||
match &prog.backgrounds[0].chr_source {
|
||||
AssetSource::Chr(path) => assert_eq!(path, "title.png"),
|
||||
other => panic!("expected Chr, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_load_background_statement() {
|
||||
let src = r#"
|
||||
game "Test" { mapper: NROM }
|
||||
state Title {
|
||||
on frame {
|
||||
load_background TitleBg
|
||||
}
|
||||
}
|
||||
start Title
|
||||
"#;
|
||||
let prog = parse_ok(src);
|
||||
let frame = prog.states[0].on_frame.as_ref().unwrap();
|
||||
assert_eq!(frame.statements.len(), 1);
|
||||
match &frame.statements[0] {
|
||||
Statement::LoadBackground(name, _) => assert_eq!(name, "TitleBg"),
|
||||
other => panic!("expected LoadBackground, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_set_palette_statement() {
|
||||
let src = r#"
|
||||
game "Test" { mapper: NROM }
|
||||
state Title {
|
||||
on frame {
|
||||
set_palette MainPal
|
||||
}
|
||||
}
|
||||
start Title
|
||||
"#;
|
||||
let prog = parse_ok(src);
|
||||
let frame = prog.states[0].on_frame.as_ref().unwrap();
|
||||
assert_eq!(frame.statements.len(), 1);
|
||||
match &frame.statements[0] {
|
||||
Statement::SetPalette(name, _) => assert_eq!(name, "MainPal"),
|
||||
other => panic!("expected SetPalette, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -288,3 +288,44 @@ fn error_test_recursion_detected() {
|
|||
"should detect recursion"
|
||||
);
|
||||
}
|
||||
|
||||
// ── M3 Tests ──
|
||||
|
||||
#[test]
|
||||
fn program_with_sprites_and_palette() {
|
||||
let source = r#"
|
||||
game "M3 Assets" { mapper: NROM }
|
||||
|
||||
sprite Player {
|
||||
chr: [0x3C, 0x42, 0x81, 0x81, 0x81, 0x81, 0x42, 0x3C,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]
|
||||
}
|
||||
|
||||
palette MainPal {
|
||||
colors: [0x0F, 0x00, 0x10, 0x20]
|
||||
}
|
||||
|
||||
background TitleBg {
|
||||
chr: @binary("title.bin")
|
||||
}
|
||||
|
||||
var px: u8 = 128
|
||||
var py: u8 = 120
|
||||
|
||||
state Title {
|
||||
on enter {
|
||||
load_background TitleBg
|
||||
set_palette MainPal
|
||||
}
|
||||
on frame {
|
||||
if button.right { px += 2 }
|
||||
if button.left { px -= 2 }
|
||||
draw Player at: (px, py)
|
||||
}
|
||||
}
|
||||
|
||||
start Title
|
||||
"#;
|
||||
let rom_data = compile(source);
|
||||
rom::validate_ines(&rom_data).expect("should be valid iNES");
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue