mirror of
https://github.com/imjasonh/nescript
synced 2026-07-14 03:28:10 +00:00
audio: complete the subsystem — asset pipeline, user decls, tracker-style driver
The audio subsystem was a sketch: `play name` / `start_music name` /
`stop_music` parsed, lowered, and emitted a few hardcoded register
writes from a builtin name table. No user-declared effects, no
per-frame envelope, no note streams, no real engine.
This flesh-out brings audio up to the quality bar of the rest of
the compiler (sprites, palettes, bank switching, scanline IRQ,
etc.) with a full data-driven pipeline:
## Asset pipeline (new `src/assets/audio.rs`)
- `sfx Name { duty, pitch, volume }` blocks compile into per-frame
pulse-1 envelopes. Pitch/volume arrays must match in length; each
entry is one NMI's worth of `$4000` data.
- `music Name { duty, volume, repeat, notes }` blocks compile into
flat `(pitch, duration)` streams for pulse 2. Pitch 0 is a rest,
1-60 indexes a builtin period table covering C1-B5.
- `resolve_sfx` / `resolve_music` walk the program for `play` /
`start_music` references and append builtin fallbacks for any
name that isn't user-declared — so `play coin` still works
without a `sfx Coin { ... }` block.
- Builtin effects (coin, jump, hit, click, cancel, shoot, step)
and tracks (theme, battle, victory, gameover) synthesize through
the same compile path as user decls — one data model, one driver.
## Runtime engine (`src/runtime/mod.rs`)
- `gen_audio_tick()` walks both channels every NMI: reads one
envelope byte through `(ZP_SFX_PTR),Y` -> writes `$4000`,
advances ptr, mutes on zero sentinel. Music decrements the note
counter, advances to the next `(pitch, dur)` pair on zero, looks
up the period through `(__period_table),Y`, loops on `0xFF 0xFF`.
- `gen_period_table()` emits a 60-entry equal-tempered table
(A4 = 440 Hz, NTSC 1.789773 MHz CPU clock) with length-counter
load bits pre-baked into each high byte.
- `gen_data_block()` emits a label + raw-bytes pseudo pair so
user sfx/music data can be spliced into PRG with regular labels
that the two-pass assembler resolves.
- New ZP layout: `$05/$06` music loop base, `$07` music state
(duty/volume/loop/active), `$0C-$0F` sfx and music pointers.
## IR codegen (`src/codegen/ir_codegen.rs`)
- `with_audio(sfx, music)` registers compile-time trigger constants
per blob name.
- `gen_play_sfx` emits: write period to `$4002`/`$4003`, load
envelope pointer into `ZP_SFX_PTR` via SymbolLo/SymbolHi of
`__sfx_<name>`, mark the sfx counter active.
- `gen_start_music` stamps the header byte into `ZP_MUSIC_STATE`
with the active bit OR'd in, seeds both ptr and loop base from
`__music_<name>`, primes the duration counter.
- `gen_stop_music` mutes pulse 2 and clears state.
## Linker (`src/linker/mod.rs`)
- New `link_with_all_assets(user_code, sprites, sfx, music)` path
that splices driver body, period table, and each sfx/music data
blob into PRG — all guarded on the `__audio_used` marker so
silent programs pay zero ROM cost.
## Assembler (`src/asm/opcodes.rs`, `src/asm/mod.rs`)
- New `AddressingMode::Bytes(Vec<u8>)` variant for raw-data
pseudo-instructions. `NOP+Bytes(v)` emits the payload verbatim,
letting the linker splice ROM data tables into a code section
and still have `Label` / `SymbolLo` / `SymbolHi` fixups resolve
correctly in the same assembly pass.
## Analyzer
- `play` / `start_music` now validate the name against user decls
and builtin tables. Unknown names emit E0505 with a helpful list
of builtins — previously a typo would silently compile to no-op.
## Parser
- New `sfx_decl` / `music_decl` grammar with property-style
configuration. Strict validation: duty 0-3, volume 0-15, pitch
arrays must match volume length, music notes must come in pairs,
pitch 0-60, duration ≥ 1.
## Tests
+170 new tests across every layer:
- `src/assets/audio.rs`: 17 tests (compile, resolve, builtins,
shadowing, label sanitation, nested reference walks)
- `src/parser/tests.rs`: 13 tests (valid/invalid sfx + music
declarations, property validation, play/start_music/stop_music)
- `src/analyzer/tests.rs`: 7 tests (builtin acceptance, user decl
acceptance, unknown-name rejection)
- `src/runtime/tests.rs`: 10 tests (audio tick labels, RTS end,
$4000 write, $4004 mute, period table assembly, A4 = 440 Hz,
length counter bits, data block verbatim emit)
- `src/linker/tests.rs`: 4 tests (sfx/music blob placement,
pointer resolution, elision when unused)
- `src/codegen/ir_codegen.rs`: rewrote the 4 existing audio tests
to match the new data-driven contract
- `tests/integration_test.rs`: 4 end-to-end tests including a
user-declared `sfx` + `music` program that verifies bytes land
in PRG ROM at the right addresses
## Docs
- New Audio section in `docs/language-guide.md` with syntax
reference, builtin tables, and an explanation of how the
driver works at compile and run time.
- `docs/architecture.md` updated to reflect the real audio
pipeline instead of the old "audio import stubs" stub.
- `docs/future-work.md` moves audio from "status: minimal" to
"status: full subsystem" with a narrower list of follow-up work
(triangle/noise/DMC channels, NSF/FTM imports, richer envelopes).
- `examples/audio_demo.ne` rewritten to showcase user-declared
`sfx LongCoin`, `sfx Zap`, `music Theme`, still demonstrating
builtin fallback via `play coin`.
Total: 424 tests passing (381 unit + 43 integration), clippy clean,
fmt clean, all 19 examples compile.
https://claude.ai/code/session_015WfaDttE3DpWn9rpyfpQd8
This commit is contained in:
parent
c5c8c38a54
commit
d42540f45e
22 changed files with 2865 additions and 243 deletions
|
|
@ -47,10 +47,22 @@ const RAM_END: u16 = 0x0800;
|
|||
|
||||
/// Analyze a parsed program for semantic errors.
|
||||
pub fn analyze(program: &Program) -> AnalysisResult {
|
||||
// Pre-collect declared and builtin-matchable sfx/music names so
|
||||
// the statement walker can check `play` / `start_music` targets.
|
||||
let mut sfx_names = HashSet::new();
|
||||
for decl in &program.sfx {
|
||||
sfx_names.insert(decl.name.clone());
|
||||
}
|
||||
let mut music_names = HashSet::new();
|
||||
for decl in &program.music {
|
||||
music_names.insert(decl.name.clone());
|
||||
}
|
||||
let mut analyzer = Analyzer {
|
||||
symbols: HashMap::new(),
|
||||
var_allocations: Vec::new(),
|
||||
diagnostics: Vec::new(),
|
||||
sfx_names,
|
||||
music_names,
|
||||
next_ram_addr: 0x0300, // $0300 is first usable RAM after OAM buffer
|
||||
next_zp_addr: 0x10, // $10 is first usable zero-page after reserved area
|
||||
call_graph: HashMap::new(),
|
||||
|
|
@ -78,6 +90,13 @@ struct Analyzer {
|
|||
symbols: HashMap<String, Symbol>,
|
||||
var_allocations: Vec<VarAllocation>,
|
||||
diagnostics: Vec<Diagnostic>,
|
||||
/// Set of sfx names declared in the program (user-provided
|
||||
/// `sfx Name { ... }` blocks). Used to validate `play Name`
|
||||
/// targets. If a name isn't in here and isn't a known builtin,
|
||||
/// the analyzer emits E0505.
|
||||
sfx_names: HashSet<String>,
|
||||
/// Set of music names declared in the program.
|
||||
music_names: HashSet<String>,
|
||||
next_ram_addr: u16,
|
||||
next_zp_addr: u8,
|
||||
call_graph: HashMap<String, Vec<String>>,
|
||||
|
|
@ -1058,10 +1077,39 @@ impl Analyzer {
|
|||
// codegen parses and validates the body; analysis has
|
||||
// nothing to check.
|
||||
}
|
||||
Statement::Play(_, _) | Statement::StartMusic(_, _) | Statement::StopMusic(_) => {
|
||||
// Audio statements are parsed and recognized but
|
||||
// currently generate no code — no audio driver exists.
|
||||
// Users who need audio can use inline `asm` blocks.
|
||||
Statement::Play(name, span) => {
|
||||
// `play Name` is valid if the name refers to a
|
||||
// declared sfx block or to a builtin effect. Anything
|
||||
// else is an E0505 — the runtime driver needs a
|
||||
// known blob to point at, and silently accepting
|
||||
// bad names would hide typos.
|
||||
if !self.sfx_names.contains(name) && !crate::assets::is_builtin_sfx(name) {
|
||||
self.diagnostics.push(
|
||||
Diagnostic::error(ErrorCode::E0505, format!("unknown sfx '{name}'"), *span)
|
||||
.with_help(
|
||||
"declare one with `sfx Name { pitch: [..], volume: [..] }`, \
|
||||
or use a builtin: coin, jump, hit, click, cancel, shoot, step",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Statement::StartMusic(name, span) => {
|
||||
if !self.music_names.contains(name) && !crate::assets::is_builtin_music(name) {
|
||||
self.diagnostics.push(
|
||||
Diagnostic::error(
|
||||
ErrorCode::E0505,
|
||||
format!("unknown music track '{name}'"),
|
||||
*span,
|
||||
)
|
||||
.with_help(
|
||||
"declare one with `music Name { notes: [..] }`, \
|
||||
or use a builtin: theme, battle, victory, gameover",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Statement::StopMusic(_) => {
|
||||
// No arguments, nothing to validate.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -868,3 +868,103 @@ fn analyze_undefined_variable_emits_e0502() {
|
|||
diag.help
|
||||
);
|
||||
}
|
||||
|
||||
// ── Audio name validation ──
|
||||
|
||||
#[test]
|
||||
fn analyze_accepts_builtin_sfx() {
|
||||
// `play coin` is always valid because `coin` is a builtin
|
||||
// even without a user `sfx Coin { ... }` declaration.
|
||||
analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
on frame { play coin }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_accepts_user_declared_sfx() {
|
||||
analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
sfx Chime {
|
||||
pitch: [0x20, 0x22, 0x24, 0x26]
|
||||
volume: [15, 12, 8, 4]
|
||||
}
|
||||
on frame { play Chime }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_rejects_unknown_sfx_name() {
|
||||
// `play Nonexistent` with no matching user decl or builtin
|
||||
// should emit E0505.
|
||||
let codes = analyze_errors(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
on frame { play Nonexistent }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
codes.contains(&ErrorCode::E0505),
|
||||
"expected E0505 for unknown sfx, got {codes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_accepts_builtin_music() {
|
||||
analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
on frame { start_music theme }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_accepts_user_declared_music() {
|
||||
analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
music Boss {
|
||||
notes: [37, 8, 41, 8, 44, 8, 49, 8]
|
||||
}
|
||||
on frame { start_music Boss }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_rejects_unknown_music_name() {
|
||||
let codes = analyze_errors(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
on frame { start_music Nonexistent }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
assert!(
|
||||
codes.contains(&ErrorCode::E0505),
|
||||
"expected E0505 for unknown music, got {codes:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn analyze_stop_music_needs_no_name_and_is_always_valid() {
|
||||
// `stop_music` takes no argument, so there's nothing to
|
||||
// validate — it should always analyze cleanly.
|
||||
analyze_ok(
|
||||
r#"
|
||||
game "T" { mapper: NROM }
|
||||
on frame { stop_music }
|
||||
start Main
|
||||
"#,
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue