mirror of
https://github.com/imjasonh/nescript
synced 2026-07-20 21:10:04 +00:00
audio: per-frame pitch envelopes for pulse SFX
Pulse-channel sfx with a multi-byte `pitch:` array used to silently ignore everything past the first byte — the runtime audio tick latched the period at trigger time and never updated it. Programs that wanted a frequency sweep had no way to express it. The compiler now compiles a per-frame pitch envelope blob alongside the existing volume envelope when `decl.pitch` has more than one distinct value. The blob is padded (or truncated) to the volume envelope's length and ends in a zero sentinel so the runtime walker stops both pointers on the same NMI. Sfx with a single scalar pitch (or an array where every byte is the same) keep their historical "no pitch blob, latch once" path and emit byte-identical ROM bytes. The runtime gains two new pieces, both gated on a new `__sfx_pitch_used` codegen marker so programs without varying-pitch sfx pay zero bytes: 1. `gen_audio_tick` emits a per-frame pitch update block inside the SFX tick: read a byte through `(AUDIO_SFX_PITCH_PTR),Y`, write it to `$4002` (pulse-1 period low), advance the pointer. The block bails on a zero high-byte pointer so a single program can mix scalar-pitch and varying-pitch sfx without one clobbering the other. 2. `emit_play_pulse` seeds `AUDIO_SFX_PITCH_PTR_LO/HI` with the pitch-blob label for varying-pitch sfx and zeros it for scalar-pitch sfx. The per-call branch is skipped entirely when the program has no varying-pitch sfx anywhere. The new `examples/sfx_pitch_envelope.ne` exercises the path with a 16-frame siren sweep. Triangle and noise per-frame pitch are deferred — they share the same data shape but the runtime ticks for those channels still write only their volume registers, see docs/future-work.md for the gap. https://claude.ai/code/session_01KEczoNUX3WmcFLfq6iAQxB
This commit is contained in:
parent
db3a4adc57
commit
9878b7d87d
12 changed files with 449 additions and 31 deletions
|
|
@ -90,6 +90,7 @@ start Main
|
|||
| [`inline_asm_demo.ne`](examples/inline_asm_demo.ne) | Inline asm with `{var}` substitution, `poke`/`peek` |
|
||||
| [`audio_demo.ne`](examples/audio_demo.ne) | Audio subsystem: user `sfx`/`music` blocks, builtin effects, `play`/`start_music`/`stop_music` |
|
||||
| [`noise_triangle_sfx.ne`](examples/noise_triangle_sfx.ne) | Noise and triangle channel sfx via `channel: noise` / `channel: triangle` on `sfx` blocks |
|
||||
| [`sfx_pitch_envelope.ne`](examples/sfx_pitch_envelope.ne) | Per-frame pulse `pitch:` arrays — the audio tick walks the pitch envelope in lockstep with the volume envelope and writes `$4002` on every NMI for a frequency-sweeping siren tone |
|
||||
| [`platformer.ne`](examples/platformer.ne) | **End-to-end side-scroller** — custom CHR tileset, full background nametable, metasprite player with gravity/jump physics, wrap-around scrolling, stomp-or-die enemy collisions, live stomp-count HUD, pickup coins, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness demonstrates the full gameplay loop (stomp, stomp, die, retry) inside six seconds |
|
||||
|
||||
## Compiler Commands
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ Open any `.nes` file in an NES emulator ([Mesen](https://www.mesen.ca/), [FCEUX]
|
|||
| `palette_and_background.ne` | palette, background, set_palette, load_background | Reset-time initial load plus vblank-safe runtime swaps |
|
||||
| `friendly_assets.ne` | named colours, grouped palette, pixel art, tilemap+legend, palette_map, scalar sfx pitch, note-name music | Exercises every "friendlier" asset syntax at once — the `palette` uses `bg0..sp3` + a shared `universal:`, the sprite is authored as ASCII pixel art, the background uses a `legend { ... } + map:` tilemap with a `palette_map:` for attributes, the sfx uses a scalar `pitch:` + `envelope:` alias, and the music uses note names (`C4, E4 40, rest 10`) with a `tempo:` default. |
|
||||
| `noise_triangle_sfx.ne` | `channel: noise`, `channel: triangle` on `sfx` blocks | Demonstrates the noise and triangle sfx channels. Declares one noise burst and one triangle bass note, plays each on a timer so the emulator harness captures both the pixel output and the APU state. |
|
||||
| `sfx_pitch_envelope.ne` | varying-pitch pulse SFX | A 16-frame frequency sweep written as a per-frame `pitch:` array on a Pulse-1 sfx. The compiler emits a separate `__sfx_pitch_<name>` blob and gates the audio tick's pitch update path on the `__sfx_pitch_used` marker, so programs that stick to the scalar `pitch:` form still get byte-identical ROM output. |
|
||||
| `nested_structs.ne` | nested struct fields, array struct fields, chained literals | Two `Hero` instances each carry a `Vec2` position and a `u8[4]` inventory. Exercises `hero.pos.x` chained access, `hero.inv[i]` array-field access, and chained struct-literal initializers (`Hero { pos: Vec2 { x: ..., y: ... }, inv: [...] }`). |
|
||||
| `platformer.ne` | **every subsystem** | End-to-end side-scrolling demo: custom CHR tileset, full 32×30 nametable with per-region attribute palettes, 2×2 metasprite hero with gravity/jump physics, wrap-around horizontal scrolling, stomp-or-die enemy collisions with a live stomp-count HUD, coin pickups, user-declared SFX + music, and a Title → Playing → GameOver state machine with a proximity-based autopilot so the headless harness cycles through stomp, stomp, die, and retry inside six seconds. Regenerate the tile art with `cargo run --bin gen_platformer_tiles`. |
|
||||
|
||||
|
|
|
|||
42
examples/sfx_pitch_envelope.ne
Normal file
42
examples/sfx_pitch_envelope.ne
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
// Per-frame pitch envelope on a pulse SFX. The user authors the
|
||||
// `pitch:` array as one byte per frame (matching the per-frame
|
||||
// `volume:` array) and the runtime audio tick walks both pointers
|
||||
// in lockstep, writing pitch to `$4002` and volume to `$4000` on
|
||||
// every NMI. The result is a frequency-sweeping "siren" tone — the
|
||||
// classic latch-once pulse SFX driver couldn't model this at all.
|
||||
//
|
||||
// Per-frame pitch is opt-in: if the `pitch:` array has a single
|
||||
// value (or repeats one byte) the compiler emits the byte-identical
|
||||
// pre-pitch-envelope sequence and no extra blob, so existing
|
||||
// programs that just want a static pitch keep working unchanged.
|
||||
// See `runtime/gen_audio_tick` for the gated extension.
|
||||
//
|
||||
// Build: cargo run -- build examples/sfx_pitch_envelope.ne
|
||||
|
||||
game "SFX Pitch Envelope" {
|
||||
mapper: NROM
|
||||
}
|
||||
|
||||
// 16-frame pitch sweep from $40 down to $20 paired with a slow
|
||||
// volume ramp. Both arrays are the same length so the runtime's
|
||||
// lockstep walker handles the simplest possible case.
|
||||
sfx Siren {
|
||||
duty: 2
|
||||
pitch: [0x40, 0x3D, 0x3A, 0x37, 0x34, 0x31, 0x2E, 0x2B, 0x28, 0x26, 0x24, 0x22, 0x21, 0x20, 0x20, 0x20]
|
||||
volume: [15, 14, 13, 12, 11, 10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0]
|
||||
}
|
||||
|
||||
var tick: u8 = 0
|
||||
|
||||
on frame {
|
||||
// Re-trigger the sfx every 60 frames so the siren restarts
|
||||
// whenever the previous pass mutes itself, giving the
|
||||
// emulator harness a stable pattern to capture.
|
||||
tick += 1
|
||||
if tick == 60 {
|
||||
tick = 0
|
||||
play Siren
|
||||
}
|
||||
}
|
||||
|
||||
start Main
|
||||
BIN
examples/sfx_pitch_envelope.nes
Normal file
BIN
examples/sfx_pitch_envelope.nes
Normal file
Binary file not shown.
|
|
@ -79,6 +79,20 @@ pub struct SfxData {
|
|||
/// (`0x00` for pulse/noise, `0x80` for triangle). Linked into
|
||||
/// PRG ROM as a labelled data block.
|
||||
pub envelope: Vec<u8>,
|
||||
/// Per-frame pitch bytes. Empty when the sfx has a single
|
||||
/// scalar pitch (the existing latch-once behaviour) or when
|
||||
/// the channel doesn't currently support per-frame pitch
|
||||
/// updates (today only Pulse 1 does — triangle and noise
|
||||
/// share the same data shape but the runtime path hasn't been
|
||||
/// extended yet, see `docs/future-work.md`). When non-empty
|
||||
/// the audio tick writes one byte per NMI to the channel's
|
||||
/// period-lo register, in lockstep with the volume envelope.
|
||||
/// Length doesn't have to match `envelope` — the runtime
|
||||
/// re-reads the same byte each frame, so a shorter pitch
|
||||
/// stream simply latches its last value. The pitch stream
|
||||
/// has its own zero-byte sentinel matching the volume
|
||||
/// envelope's length so the lockstep walk terminates cleanly.
|
||||
pub pitch_envelope: Vec<u8>,
|
||||
/// APU channel this sfx drives.
|
||||
pub channel: Channel,
|
||||
}
|
||||
|
|
@ -104,6 +118,27 @@ impl SfxData {
|
|||
pub fn label(&self) -> String {
|
||||
format!("__sfx_{}", sanitize_label(&self.name))
|
||||
}
|
||||
|
||||
/// ROM label for the optional per-frame pitch envelope blob.
|
||||
/// Only meaningful when [`SfxData::pitch_envelope`] is
|
||||
/// non-empty; the codegen / linker uses it to splice the blob
|
||||
/// into PRG and to set up the pitch-walk pointer at trigger
|
||||
/// time. The label format mirrors the volume envelope label
|
||||
/// to keep `--symbols` output uniform.
|
||||
#[must_use]
|
||||
pub fn pitch_label(&self) -> String {
|
||||
format!("__sfx_pitch_{}", sanitize_label(&self.name))
|
||||
}
|
||||
|
||||
/// True iff this sfx carries a per-frame pitch envelope. The
|
||||
/// runtime audio tick has a slightly different (and slightly
|
||||
/// larger) code path for sfx with pitch envelopes, so the
|
||||
/// codegen gates emission of that path on whether *any* sfx
|
||||
/// in the program has one.
|
||||
#[must_use]
|
||||
pub fn has_pitch_envelope(&self) -> bool {
|
||||
!self.pitch_envelope.is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
impl MusicData {
|
||||
|
|
@ -334,15 +369,54 @@ fn compile_pulse_sfx(decl: &SfxDecl) -> SfxData {
|
|||
// Zero sentinel — the audio tick sees this, mutes the channel,
|
||||
// and clears the sfx counter so subsequent NMIs don't keep walking.
|
||||
envelope.push(0x00);
|
||||
// Per-frame pitch envelope — populated when the user provides
|
||||
// more than one distinct pitch byte. A single scalar (or a
|
||||
// multi-element array where every byte is the same) keeps
|
||||
// the historical "latch period at trigger and never touch
|
||||
// it again" behaviour and emits no pitch blob, so existing
|
||||
// sfx ROMs are byte-identical. The pitch envelope is padded
|
||||
// (or truncated) to match the volume envelope length so the
|
||||
// runtime can walk both pointers in lockstep without a
|
||||
// separate length check; the trailing zero sentinel is added
|
||||
// last so the pitch blob's last byte aligns with the volume
|
||||
// sentinel and the runtime stops both walks at the same NMI.
|
||||
let pitch_envelope = build_pulse_pitch_envelope(&decl.pitch, decl.volume.len());
|
||||
SfxData {
|
||||
name: decl.name.clone(),
|
||||
period_lo,
|
||||
period_hi,
|
||||
envelope,
|
||||
pitch_envelope,
|
||||
channel: decl.channel,
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the pulse-channel pitch envelope from a user-declared
|
||||
/// `pitch:` array. Returns an empty vector when the array
|
||||
/// describes a single static pitch (length ≤ 1, or all bytes the
|
||||
/// same), in which case the runtime keeps its existing latch-once
|
||||
/// behaviour and no pitch blob is emitted at all. Otherwise the
|
||||
/// returned vector has exactly `volume_frames + 1` bytes — the
|
||||
/// extra byte is a zero sentinel that lines up with the volume
|
||||
/// envelope's mute byte so the runtime tick sees both end markers
|
||||
/// on the same NMI. Pitches shorter than `volume_frames` repeat
|
||||
/// their last value; longer pitches truncate.
|
||||
fn build_pulse_pitch_envelope(pitch: &[u8], volume_frames: usize) -> Vec<u8> {
|
||||
if pitch.len() <= 1 {
|
||||
return Vec::new();
|
||||
}
|
||||
if pitch.iter().all(|&p| p == pitch[0]) {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::with_capacity(volume_frames + 1);
|
||||
let last = *pitch.last().unwrap_or(&0);
|
||||
for i in 0..volume_frames {
|
||||
out.push(pitch.get(i).copied().unwrap_or(last));
|
||||
}
|
||||
out.push(0x00);
|
||||
out
|
||||
}
|
||||
|
||||
/// Triangle envelope: each byte is a linear-counter reload value
|
||||
/// written back to `$4008`. Nonzero means "continue holding the
|
||||
/// note"; the sentinel `0x80` (linear counter = 0 with control bit
|
||||
|
|
@ -380,6 +454,10 @@ fn compile_triangle_sfx(decl: &SfxDecl) -> SfxData {
|
|||
period_lo,
|
||||
period_hi,
|
||||
envelope,
|
||||
// Triangle per-frame pitch isn't wired up yet (the runtime
|
||||
// tick's triangle block writes only $4008, not $400A); see
|
||||
// docs/future-work.md for the gap.
|
||||
pitch_envelope: Vec::new(),
|
||||
channel: decl.channel,
|
||||
}
|
||||
}
|
||||
|
|
@ -416,6 +494,10 @@ fn compile_noise_sfx(decl: &SfxDecl) -> SfxData {
|
|||
period_lo,
|
||||
period_hi,
|
||||
envelope,
|
||||
// Noise per-frame pitch (period-index sweeping) isn't
|
||||
// wired up yet — the existing runtime tick only updates
|
||||
// `$400C` per frame. See docs/future-work.md.
|
||||
pitch_envelope: Vec::new(),
|
||||
channel: decl.channel,
|
||||
}
|
||||
}
|
||||
|
|
@ -1089,6 +1171,85 @@ mod tests {
|
|||
assert_eq!(note_name_to_index("CoolName"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_pulse_pitch_envelope_scalar_returns_empty() {
|
||||
// A single pitch byte (the historical scalar `pitch:` form)
|
||||
// keeps the latch-once driver path. The runtime never
|
||||
// reads the pitch envelope blob, and we don't want to
|
||||
// emit one — the empty vec signals that to the linker.
|
||||
assert!(build_pulse_pitch_envelope(&[0x40], 8).is_empty());
|
||||
// Same for "the user wrote an array but every element is
|
||||
// the same byte" — semantically identical to scalar pitch.
|
||||
assert!(build_pulse_pitch_envelope(&[0x40, 0x40, 0x40], 8).is_empty());
|
||||
// And the degenerate empty case.
|
||||
assert!(build_pulse_pitch_envelope(&[], 8).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_pulse_pitch_envelope_varying_pads_to_volume_length() {
|
||||
// The runtime walks pitch and volume in lockstep, so the
|
||||
// pitch blob is sized to match the volume envelope's
|
||||
// length plus a trailing zero sentinel.
|
||||
let env = build_pulse_pitch_envelope(&[0x40, 0x30, 0x20], 5);
|
||||
assert_eq!(
|
||||
env,
|
||||
vec![0x40, 0x30, 0x20, 0x20, 0x20, 0x00],
|
||||
"pitches shorter than volume frames should latch their last value"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_pulse_pitch_envelope_truncates_when_longer_than_volume() {
|
||||
// A pitch array longer than the volume envelope is
|
||||
// truncated — the runtime stops walking when the volume
|
||||
// envelope hits its zero sentinel anyway.
|
||||
let env = build_pulse_pitch_envelope(&[0x10, 0x20, 0x30, 0x40, 0x50, 0x60], 3);
|
||||
assert_eq!(env, vec![0x10, 0x20, 0x30, 0x00]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_pulse_sfx_with_varying_pitch_populates_envelope() {
|
||||
// End-to-end: a pulse sfx with a varying `pitch:` array
|
||||
// should produce a non-empty `pitch_envelope` whose length
|
||||
// matches the volume envelope (excluding the sentinel),
|
||||
// and whose label is the canonical `__sfx_pitch_<name>`.
|
||||
let decl = SfxDecl {
|
||||
name: "Siren".to_string(),
|
||||
duty: 2,
|
||||
pitch: vec![0x40, 0x30, 0x20],
|
||||
volume: vec![15, 10, 5],
|
||||
channel: Channel::Pulse1,
|
||||
span: crate::lexer::Span::dummy(),
|
||||
};
|
||||
let sfx = compile_pulse_sfx(&decl);
|
||||
assert!(sfx.has_pitch_envelope());
|
||||
// Three volume frames + sentinel.
|
||||
assert_eq!(sfx.envelope.len(), 4);
|
||||
assert_eq!(sfx.pitch_envelope.len(), 4);
|
||||
assert_eq!(sfx.pitch_envelope[0], 0x40);
|
||||
assert_eq!(sfx.pitch_envelope[3], 0x00); // sentinel
|
||||
assert_eq!(sfx.pitch_label(), "__sfx_pitch_Siren");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compile_pulse_sfx_with_scalar_pitch_omits_envelope() {
|
||||
// The historical scalar form should still produce no
|
||||
// pitch envelope — gating the runtime extension on
|
||||
// emptiness keeps existing scalar-pitch ROMs byte-
|
||||
// identical.
|
||||
let decl = SfxDecl {
|
||||
name: "Coin".to_string(),
|
||||
duty: 2,
|
||||
pitch: vec![0x50],
|
||||
volume: vec![15, 10, 5],
|
||||
channel: Channel::Pulse1,
|
||||
span: crate::lexer::Span::dummy(),
|
||||
};
|
||||
let sfx = compile_pulse_sfx(&decl);
|
||||
assert!(!sfx.has_pitch_envelope());
|
||||
assert!(sfx.pitch_envelope.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn walk_for_play_finds_nested_references() {
|
||||
// `play` inside an `if` inside a `while` should still be
|
||||
|
|
|
|||
|
|
@ -30,12 +30,12 @@ use crate::assets::{MusicData, SfxData};
|
|||
use crate::ir::{IrBasicBlock, IrFunction, IrOp, IrProgram, IrTemp, IrTerminator, VarId};
|
||||
use crate::parser::ast::Channel;
|
||||
use crate::runtime::{
|
||||
AUDIO_NOISE_COUNTER, AUDIO_NOISE_PTR_HI, AUDIO_NOISE_PTR_LO, AUDIO_TRIANGLE_COUNTER,
|
||||
AUDIO_TRIANGLE_PTR_HI, AUDIO_TRIANGLE_PTR_LO, ZP_MUSIC_BASE_HI, ZP_MUSIC_BASE_LO,
|
||||
ZP_MUSIC_COUNTER, ZP_MUSIC_PTR_HI, ZP_MUSIC_PTR_LO, ZP_MUSIC_STATE, ZP_OAM_CURSOR,
|
||||
ZP_PENDING_BG_ATTRS_HI, ZP_PENDING_BG_ATTRS_LO, ZP_PENDING_BG_TILES_HI, ZP_PENDING_BG_TILES_LO,
|
||||
ZP_PENDING_PALETTE_HI, ZP_PENDING_PALETTE_LO, ZP_PPU_UPDATE_FLAGS, ZP_SFX_COUNTER,
|
||||
ZP_SFX_PTR_HI, ZP_SFX_PTR_LO,
|
||||
AUDIO_NOISE_COUNTER, AUDIO_NOISE_PTR_HI, AUDIO_NOISE_PTR_LO, AUDIO_SFX_PITCH_PTR_HI,
|
||||
AUDIO_SFX_PITCH_PTR_LO, AUDIO_TRIANGLE_COUNTER, AUDIO_TRIANGLE_PTR_HI, AUDIO_TRIANGLE_PTR_LO,
|
||||
ZP_MUSIC_BASE_HI, ZP_MUSIC_BASE_LO, ZP_MUSIC_COUNTER, ZP_MUSIC_PTR_HI, ZP_MUSIC_PTR_LO,
|
||||
ZP_MUSIC_STATE, ZP_OAM_CURSOR, ZP_PENDING_BG_ATTRS_HI, ZP_PENDING_BG_ATTRS_LO,
|
||||
ZP_PENDING_BG_TILES_HI, ZP_PENDING_BG_TILES_LO, ZP_PENDING_PALETTE_HI, ZP_PENDING_PALETTE_LO,
|
||||
ZP_PPU_UPDATE_FLAGS, ZP_SFX_COUNTER, ZP_SFX_PTR_HI, ZP_SFX_PTR_LO,
|
||||
};
|
||||
|
||||
/// Base zero-page address for IR temp slots.
|
||||
|
|
@ -82,15 +82,26 @@ pub struct IrCodeGen<'a> {
|
|||
use_counts: HashMap<IrTemp, u32>,
|
||||
/// Sprite name to tile index mapping.
|
||||
sprite_tiles: HashMap<String, u8>,
|
||||
/// Sfx name to `(period_lo, period_hi, envelope_label, channel)`.
|
||||
/// Populated by `with_audio` from the resolved [`SfxData`] list.
|
||||
/// Per-sfx codegen metadata captured by [`Self::with_audio`].
|
||||
/// Tuple layout:
|
||||
/// - trigger period low / high (channel-specific — see below);
|
||||
/// - the volume-envelope blob's PRG label;
|
||||
/// - the APU channel the sfx drives;
|
||||
/// - an optional per-frame pitch envelope blob label
|
||||
/// (`Some(label)` when the sfx has a varying-pitch envelope,
|
||||
/// `None` for the scalar-pitch fast path that doesn't touch
|
||||
/// the runtime pitch tick).
|
||||
///
|
||||
/// `play Name` consults this to pick:
|
||||
/// - the right trigger register pair (pulse 1 = $4002/$4003,
|
||||
/// triangle = $400A/$400B, noise = $400E/$400F);
|
||||
/// - the right per-channel envelope pointer slot (pulse 1 uses
|
||||
/// zero-page `ZP_SFX_PTR`, triangle/noise live in main RAM
|
||||
/// at the `AUDIO_*_PTR_*` addresses).
|
||||
sfx_info: HashMap<String, (u8, u8, String, Channel)>,
|
||||
/// at the `AUDIO_*_PTR_*` addresses);
|
||||
/// - whether to seed [`AUDIO_SFX_PITCH_PTR_LO`] with the
|
||||
/// pitch-envelope label or with zero (the runtime "no pitch
|
||||
/// update" sentinel).
|
||||
sfx_info: HashMap<String, (u8, u8, String, Channel, Option<String>)>,
|
||||
/// Music name to `(header_byte, stream_label)`. Populated by
|
||||
/// `with_audio`. `start_music Name` stamps `header | 0x02` into
|
||||
/// `ZP_MUSIC_STATE` and loads the pointer from `stream_label`.
|
||||
|
|
@ -118,6 +129,14 @@ pub struct IrCodeGen<'a> {
|
|||
/// Same as `noise_used`, but for triangle sfx. Drives the
|
||||
/// `__triangle_used` marker label.
|
||||
triangle_used: bool,
|
||||
/// True when at least one sfx in the program declares a
|
||||
/// varying-pitch envelope. Drives the `__sfx_pitch_used`
|
||||
/// marker label, which the linker reads to decide whether to
|
||||
/// splice the per-frame pitch update path into the audio
|
||||
/// tick. Programs without varying-pitch sfx leave this
|
||||
/// `false` and emit byte-identical ROM bytes for the audio
|
||||
/// subsystem.
|
||||
sfx_pitch_used: bool,
|
||||
/// Set to true the first time we emit any PPU update op
|
||||
/// (`set_palette` / `load_background`). The linker uses the
|
||||
/// resulting `__ppu_update_used` marker label to decide whether
|
||||
|
|
@ -252,6 +271,7 @@ impl<'a> IrCodeGen<'a> {
|
|||
use_counts: HashMap::new(),
|
||||
sprite_tiles: HashMap::new(),
|
||||
sfx_info: HashMap::new(),
|
||||
sfx_pitch_used: false,
|
||||
music_info: HashMap::new(),
|
||||
state_indices: HashMap::new(),
|
||||
function_names,
|
||||
|
|
@ -351,9 +371,15 @@ impl<'a> IrCodeGen<'a> {
|
|||
#[must_use]
|
||||
pub fn with_audio(mut self, sfx: &[SfxData], music: &[MusicData]) -> Self {
|
||||
for s in sfx {
|
||||
let pitch_label = if s.has_pitch_envelope() {
|
||||
self.sfx_pitch_used = true;
|
||||
Some(s.pitch_label())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
self.sfx_info.insert(
|
||||
s.name.clone(),
|
||||
(s.period_lo, s.period_hi, s.label(), s.channel),
|
||||
(s.period_lo, s.period_hi, s.label(), s.channel, pitch_label),
|
||||
);
|
||||
}
|
||||
for m in music {
|
||||
|
|
@ -549,6 +575,18 @@ impl<'a> IrCodeGen<'a> {
|
|||
self.emit_label("__debug_mode");
|
||||
}
|
||||
|
||||
// `__sfx_pitch_used` follows the same marker-label pattern
|
||||
// as `__audio_used`. It tells the linker that at least one
|
||||
// sfx in the program declared a per-frame `pitch:` array
|
||||
// and so the audio tick needs the extra pitch-update
|
||||
// block. Programs without a varying-pitch sfx never set
|
||||
// `sfx_pitch_used` and the linker emits the smaller pre-
|
||||
// pitch-envelope tick path, so existing example ROMs are
|
||||
// byte-identical.
|
||||
if self.sfx_pitch_used {
|
||||
self.emit_label("__sfx_pitch_used");
|
||||
}
|
||||
|
||||
// 1. Variable initializers
|
||||
//
|
||||
// Scalars write a single byte from `init_value`. Array
|
||||
|
|
@ -1383,7 +1421,9 @@ impl<'a> IrCodeGen<'a> {
|
|||
/// diagnostic for the unknown name.
|
||||
fn gen_play_sfx(&mut self, name: &str) {
|
||||
self.emit_audio_marker();
|
||||
let Some((period_lo, period_hi, label, channel)) = self.sfx_info.get(name).cloned() else {
|
||||
let Some((period_lo, period_hi, label, channel, pitch_label)) =
|
||||
self.sfx_info.get(name).cloned()
|
||||
else {
|
||||
// Unknown name. The analyzer warns on this; emit a no-op
|
||||
// sequence so the rest of the code still assembles. The
|
||||
// unknown branch is easy to spot in `--asm-dump`: it
|
||||
|
|
@ -1392,17 +1432,31 @@ impl<'a> IrCodeGen<'a> {
|
|||
return;
|
||||
};
|
||||
match channel {
|
||||
Channel::Pulse1 | Channel::Pulse2 => self.emit_play_pulse(period_lo, period_hi, &label),
|
||||
Channel::Pulse1 | Channel::Pulse2 => {
|
||||
self.emit_play_pulse(period_lo, period_hi, &label, pitch_label.as_deref());
|
||||
}
|
||||
Channel::Triangle => self.emit_play_triangle(period_lo, period_hi, &label),
|
||||
Channel::Noise => self.emit_play_noise(period_lo, period_hi, &label),
|
||||
}
|
||||
}
|
||||
|
||||
/// Original pulse-1 `play` sequence — unchanged from before the
|
||||
/// channel feature. Kept as its own helper so the channel
|
||||
/// dispatch above reads cleanly and the byte layout is trivial
|
||||
/// to eyeball against the old code.
|
||||
fn emit_play_pulse(&mut self, period_lo: u8, period_hi: u8, label: &str) {
|
||||
/// Pulse `play` sequence. The byte layout is unchanged from
|
||||
/// the pre-pitch-envelope codegen unless either (a) the sfx
|
||||
/// being played has its own pitch envelope or (b) the program
|
||||
/// has at least one varying-pitch sfx anywhere — in which
|
||||
/// case the sequence also seeds (or zeros) the runtime's
|
||||
/// per-frame pitch pointer at
|
||||
/// [`AUDIO_SFX_PITCH_PTR_LO`] / `_HI`. The runtime tick
|
||||
/// treats a zero high byte as "no pitch envelope on the
|
||||
/// currently-playing sfx" so a single program can mix scalar
|
||||
/// and varying-pitch sfx without one clobbering the other.
|
||||
fn emit_play_pulse(
|
||||
&mut self,
|
||||
period_lo: u8,
|
||||
period_hi: u8,
|
||||
label: &str,
|
||||
pitch_label: Option<&str>,
|
||||
) {
|
||||
// $4000: we don't write a volume envelope here. The first
|
||||
// envelope byte is consumed by the next NMI audio tick. We
|
||||
// only need to set up the trigger (period + length).
|
||||
|
|
@ -1421,6 +1475,29 @@ impl<'a> IrCodeGen<'a> {
|
|||
self.emit(STA, AM::ZeroPage(ZP_SFX_PTR_LO));
|
||||
self.emit(LDA, AM::SymbolHi(label.to_string()));
|
||||
self.emit(STA, AM::ZeroPage(ZP_SFX_PTR_HI));
|
||||
// Optional pitch envelope pointer setup. We only emit any
|
||||
// store at all when the *program* has at least one
|
||||
// varying-pitch sfx (`self.sfx_pitch_used`) — that's the
|
||||
// gate that keeps existing scalar-pitch programs
|
||||
// byte-identical. Within such a program every play
|
||||
// sequence still runs through this branch so a scalar
|
||||
// sfx that follows a varying-pitch one zeros the pointer
|
||||
// and the runtime tick safely skips the pitch update.
|
||||
if self.sfx_pitch_used {
|
||||
if let Some(pl) = pitch_label {
|
||||
self.emit(LDA, AM::SymbolLo(pl.to_string()));
|
||||
self.emit(STA, AM::Absolute(AUDIO_SFX_PITCH_PTR_LO));
|
||||
self.emit(LDA, AM::SymbolHi(pl.to_string()));
|
||||
self.emit(STA, AM::Absolute(AUDIO_SFX_PITCH_PTR_HI));
|
||||
} else {
|
||||
// Scalar-pitch sfx in a mixed program: clear the
|
||||
// pointer so the tick's high-byte sentinel check
|
||||
// bails out before reading the (now-stale) blob.
|
||||
self.emit(LDA, AM::Immediate(0));
|
||||
self.emit(STA, AM::Absolute(AUDIO_SFX_PITCH_PTR_LO));
|
||||
self.emit(STA, AM::Absolute(AUDIO_SFX_PITCH_PTR_HI));
|
||||
}
|
||||
}
|
||||
// Mark sfx as active. The audio tick checks this and bails
|
||||
// on zero. We use `$FF` (any nonzero value works) as a flag;
|
||||
// the tick zeros it when it hits the envelope sentinel.
|
||||
|
|
|
|||
|
|
@ -489,8 +489,13 @@ impl Linker {
|
|||
let has_audio = has_label(user_code, "__audio_used");
|
||||
let has_noise = has_label(user_code, "__noise_used");
|
||||
let has_triangle = has_label(user_code, "__triangle_used");
|
||||
let has_sfx_pitch = has_label(user_code, "__sfx_pitch_used");
|
||||
if has_audio {
|
||||
all_instructions.extend(runtime::gen_audio_tick(has_noise, has_triangle));
|
||||
all_instructions.extend(runtime::gen_audio_tick(
|
||||
has_noise,
|
||||
has_triangle,
|
||||
has_sfx_pitch,
|
||||
));
|
||||
all_instructions.extend(runtime::gen_period_table());
|
||||
// Emit one data block per sfx blob: a label followed by
|
||||
// the envelope bytes. `play Name` codegen emits a
|
||||
|
|
@ -500,6 +505,18 @@ impl Linker {
|
|||
&blob.label(),
|
||||
blob.envelope.clone(),
|
||||
));
|
||||
// Optional pitch envelope blob. Only emitted for
|
||||
// sfx the compiler decided actually need per-frame
|
||||
// pitch updates — the pitch_envelope is empty for
|
||||
// single-pitch sfx and the `gen_data_block` call
|
||||
// is skipped, keeping ROM bytes identical to the
|
||||
// pre-pitch-envelope behaviour.
|
||||
if blob.has_pitch_envelope() {
|
||||
all_instructions.extend(runtime::gen_data_block(
|
||||
&blob.pitch_label(),
|
||||
blob.pitch_envelope.clone(),
|
||||
));
|
||||
}
|
||||
}
|
||||
// Same for music: label + note stream.
|
||||
for blob in music {
|
||||
|
|
|
|||
|
|
@ -187,6 +187,7 @@ fn link_with_audio_data_places_sfx_blobs_in_prg() {
|
|||
period_lo: 0x50,
|
||||
period_hi: 0x08,
|
||||
envelope: vec![0xBF, 0xB8, 0xB4, 0xB0, 0x00],
|
||||
pitch_envelope: Vec::new(),
|
||||
channel: Channel::Pulse1,
|
||||
}];
|
||||
let rom = linker.link_with_all_assets(&user_code, &[], &sfx, &[]);
|
||||
|
|
@ -239,6 +240,7 @@ fn link_with_audio_resolves_sfx_pointer_references() {
|
|||
period_lo: 0x50,
|
||||
period_hi: 0x08,
|
||||
envelope: vec![0xDE, 0xAD, 0xBE, 0xEF, 0x00],
|
||||
pitch_envelope: Vec::new(),
|
||||
channel: Channel::Pulse1,
|
||||
}];
|
||||
let rom = linker.link_with_all_assets(&user_code, &[], &sfx, &[]);
|
||||
|
|
@ -282,6 +284,7 @@ fn link_without_audio_marker_does_not_emit_period_table() {
|
|||
period_lo: 0,
|
||||
period_hi: 0,
|
||||
envelope: vec![0xAA, 0xBB, 0x00],
|
||||
pitch_envelope: Vec::new(),
|
||||
channel: Channel::Pulse1,
|
||||
}],
|
||||
&[],
|
||||
|
|
|
|||
|
|
@ -131,6 +131,16 @@ pub const AUDIO_NOISE_COUNTER: u16 = 0x07F2;
|
|||
pub const AUDIO_TRIANGLE_PTR_LO: u16 = 0x07F3;
|
||||
pub const AUDIO_TRIANGLE_PTR_HI: u16 = 0x07F4;
|
||||
pub const AUDIO_TRIANGLE_COUNTER: u16 = 0x07F5;
|
||||
/// Pulse-1 sfx per-frame pitch envelope pointer. Only populated
|
||||
/// (and only read by the audio tick) in programs that declare at
|
||||
/// least one sfx with a varying-pitch `pitch:` array; programs
|
||||
/// that stick to scalar `pitch:` keep their byte-for-byte
|
||||
/// pre-pitch-envelope ROM output. The tick treats a zero
|
||||
/// high-byte as "no pitch update for the currently-playing sfx",
|
||||
/// which lets a single program mix sfx with and without pitch
|
||||
/// envelopes.
|
||||
pub const AUDIO_SFX_PITCH_PTR_LO: u16 = 0x07F6;
|
||||
pub const AUDIO_SFX_PITCH_PTR_HI: u16 = 0x07F7;
|
||||
|
||||
/// Generate the NES hardware initialization sequence.
|
||||
/// This runs at RESET and sets up the hardware before user code.
|
||||
|
|
@ -778,7 +788,11 @@ pub fn gen_multiply() -> Vec<Instruction> {
|
|||
/// instruction stream as before. The linker decides whether to
|
||||
/// enable each by scanning for the `__noise_used` and
|
||||
/// `__triangle_used` marker labels emitted by the IR codegen.
|
||||
pub fn gen_audio_tick(has_noise: bool, has_triangle: bool) -> Vec<Instruction> {
|
||||
pub fn gen_audio_tick(
|
||||
has_noise: bool,
|
||||
has_triangle: bool,
|
||||
has_sfx_pitch: bool,
|
||||
) -> Vec<Instruction> {
|
||||
let mut out = Vec::new();
|
||||
|
||||
out.push(Instruction::new(NOP, AM::Label("__audio_tick".into())));
|
||||
|
|
@ -818,6 +832,61 @@ pub fn gen_audio_tick(has_noise: bool, has_triangle: bool) -> Vec<Instruction> {
|
|||
NOP,
|
||||
AM::Label("__audio_sfx_ptr_ok".into()),
|
||||
));
|
||||
// Optional per-frame pitch update. Only emitted in programs
|
||||
// that declare at least one varying-pitch sfx, gated on the
|
||||
// `__sfx_pitch_used` codegen marker. The block reads a byte
|
||||
// through (AUDIO_SFX_PITCH_PTR),Y, writes it to `$4002`
|
||||
// (pulse-1 period low), then advances the pointer in
|
||||
// lockstep with the volume envelope above. A zero high byte
|
||||
// in the pointer is treated as "no pitch envelope on the
|
||||
// currently-playing sfx" so a program can mix scalar-pitch
|
||||
// and varying-pitch sfx without the latter clobbering the
|
||||
// former when it isn't playing.
|
||||
//
|
||||
// The pointer lives in main RAM (no zero-page slot pressure)
|
||||
// and is copied into ZP scratch $02/$03 for the indirect
|
||||
// read because the 6502 has no `(abs),Y` mode — the same
|
||||
// technique used by `gen_noise_tick` / `gen_triangle_tick`.
|
||||
if has_sfx_pitch {
|
||||
// Bail out if the high byte is zero — sentinel for "the
|
||||
// currently-playing sfx has no pitch envelope".
|
||||
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_SFX_PITCH_PTR_HI)));
|
||||
out.push(Instruction::new(
|
||||
BEQ,
|
||||
AM::LabelRelative("__audio_sfx_pitch_done".into()),
|
||||
));
|
||||
// Stash main-RAM pointer in ZP scratch $02/$03.
|
||||
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_SFX_PITCH_PTR_LO)));
|
||||
out.push(Instruction::new(STA, AM::ZeroPage(0x02)));
|
||||
out.push(Instruction::new(LDA, AM::Absolute(AUDIO_SFX_PITCH_PTR_HI)));
|
||||
out.push(Instruction::new(STA, AM::ZeroPage(0x03)));
|
||||
out.push(Instruction::new(LDY, AM::Immediate(0)));
|
||||
out.push(Instruction::new(LDA, AM::IndirectY(0x02)));
|
||||
// Zero pitch byte? Treat it as the matching mute sentinel
|
||||
// (volume envelope's zero will already mute on the next
|
||||
// tick) and skip the period write so we don't yank pulse-1
|
||||
// to a 0 period for one frame before muting.
|
||||
out.push(Instruction::new(
|
||||
BEQ,
|
||||
AM::LabelRelative("__audio_sfx_pitch_advance".into()),
|
||||
));
|
||||
out.push(Instruction::new(STA, AM::Absolute(0x4002)));
|
||||
out.push(Instruction::new(
|
||||
NOP,
|
||||
AM::Label("__audio_sfx_pitch_advance".into()),
|
||||
));
|
||||
// Advance the main-RAM pointer 1 byte.
|
||||
out.push(Instruction::new(INC, AM::Absolute(AUDIO_SFX_PITCH_PTR_LO)));
|
||||
out.push(Instruction::new(
|
||||
BNE,
|
||||
AM::LabelRelative("__audio_sfx_pitch_done".into()),
|
||||
));
|
||||
out.push(Instruction::new(INC, AM::Absolute(AUDIO_SFX_PITCH_PTR_HI)));
|
||||
out.push(Instruction::new(
|
||||
NOP,
|
||||
AM::Label("__audio_sfx_pitch_done".into()),
|
||||
));
|
||||
}
|
||||
out.push(Instruction::new(NOP, AM::Label("__audio_sfx_done".into())));
|
||||
|
||||
// ── Music tick ──
|
||||
|
|
|
|||
|
|
@ -195,7 +195,7 @@ fn multiply_routine_assembles() {
|
|||
|
||||
#[test]
|
||||
fn audio_tick_defines_required_labels() {
|
||||
let tick = gen_audio_tick(false, false);
|
||||
let tick = gen_audio_tick(false, false, false);
|
||||
// The IR codegen JSRs into `__audio_tick`; that's the entry.
|
||||
let has_entry = tick
|
||||
.iter()
|
||||
|
|
@ -215,7 +215,7 @@ fn audio_tick_defines_required_labels() {
|
|||
|
||||
#[test]
|
||||
fn audio_tick_ends_with_rts() {
|
||||
let tick = gen_audio_tick(false, false);
|
||||
let tick = gen_audio_tick(false, false, false);
|
||||
assert_eq!(
|
||||
tick.last().unwrap().opcode,
|
||||
RTS,
|
||||
|
|
@ -228,7 +228,7 @@ fn audio_tick_reads_sfx_envelope_via_indirect_y() {
|
|||
// The sfx branch walks the envelope via (ZP_SFX_PTR_LO),Y with
|
||||
// Y=0 — each NMI reads one byte through the pointer and writes
|
||||
// it to $4000. Verify the indirect-indexed load is present.
|
||||
let tick = gen_audio_tick(false, false);
|
||||
let tick = gen_audio_tick(false, false, false);
|
||||
let has_load = tick
|
||||
.iter()
|
||||
.any(|i| i.opcode == LDA && i.mode == AM::IndirectY(ZP_SFX_PTR_LO));
|
||||
|
|
@ -241,7 +241,7 @@ fn audio_tick_reads_sfx_envelope_via_indirect_y() {
|
|||
#[test]
|
||||
fn audio_tick_writes_pulse1_envelope_register() {
|
||||
// After reading the envelope byte the tick writes it to $4000.
|
||||
let tick = gen_audio_tick(false, false);
|
||||
let tick = gen_audio_tick(false, false, false);
|
||||
let has_store = tick
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4000));
|
||||
|
|
@ -254,7 +254,7 @@ fn audio_tick_noise_block_writes_400c_when_enabled() {
|
|||
// envelope byte and writes it to the APU noise volume register
|
||||
// at $400C. Verify both the marker label and the STA $400C are
|
||||
// present.
|
||||
let tick = gen_audio_tick(true, false);
|
||||
let tick = gen_audio_tick(true, false, false);
|
||||
let has_label = tick
|
||||
.iter()
|
||||
.any(|i| matches!(&i.mode, AM::Label(n) if n == "__audio_noise_tick"));
|
||||
|
|
@ -270,7 +270,7 @@ fn audio_tick_noise_block_absent_when_disabled() {
|
|||
// The pulse-only path must not emit any noise label, so
|
||||
// programs that never declare a noise sfx get byte-identical
|
||||
// code to the pre-feature version.
|
||||
let tick = gen_audio_tick(false, false);
|
||||
let tick = gen_audio_tick(false, false, false);
|
||||
let has_label = tick
|
||||
.iter()
|
||||
.any(|i| matches!(&i.mode, AM::Label(n) if n == "__audio_noise_tick"));
|
||||
|
|
@ -289,7 +289,7 @@ fn audio_tick_noise_block_absent_when_disabled() {
|
|||
|
||||
#[test]
|
||||
fn audio_tick_triangle_block_writes_4008_when_enabled() {
|
||||
let tick = gen_audio_tick(false, true);
|
||||
let tick = gen_audio_tick(false, true, false);
|
||||
let has_label = tick
|
||||
.iter()
|
||||
.any(|i| matches!(&i.mode, AM::Label(n) if n == "__audio_triangle_tick"));
|
||||
|
|
@ -305,7 +305,7 @@ fn audio_tick_triangle_block_writes_4008_when_enabled() {
|
|||
|
||||
#[test]
|
||||
fn audio_tick_triangle_block_absent_when_disabled() {
|
||||
let tick = gen_audio_tick(false, false);
|
||||
let tick = gen_audio_tick(false, false, false);
|
||||
let has_label = tick
|
||||
.iter()
|
||||
.any(|i| matches!(&i.mode, AM::Label(n) if n == "__audio_triangle_tick"));
|
||||
|
|
@ -316,12 +316,58 @@ fn audio_tick_triangle_block_absent_when_disabled() {
|
|||
assert!(!has_4008);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_tick_sfx_pitch_block_writes_4002_when_enabled() {
|
||||
// With `has_sfx_pitch = true` the tick gains a per-frame pitch
|
||||
// update path that writes to `$4002` (pulse-1 period low) on
|
||||
// every NMI. The path also needs to read the pitch envelope
|
||||
// pointer from main RAM, so we check both the absolute-RAM
|
||||
// load and the period write.
|
||||
let tick = gen_audio_tick(false, false, true);
|
||||
let writes_4002 = tick
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4002));
|
||||
assert!(
|
||||
writes_4002,
|
||||
"sfx pitch tick path should write to $4002 (pulse-1 period low)"
|
||||
);
|
||||
let reads_pitch_ptr = tick
|
||||
.iter()
|
||||
.any(|i| i.opcode == LDA && i.mode == AM::Absolute(AUDIO_SFX_PITCH_PTR_LO));
|
||||
assert!(
|
||||
reads_pitch_ptr,
|
||||
"sfx pitch tick path should LDA from AUDIO_SFX_PITCH_PTR_LO"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_tick_sfx_pitch_block_absent_when_disabled() {
|
||||
// Programs without varying-pitch sfx must not pay any bytes
|
||||
// for the pitch update path, otherwise existing audio_demo /
|
||||
// friendly_assets goldens would byte-shift.
|
||||
let tick = gen_audio_tick(false, false, false);
|
||||
let writes_4002 = tick
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4002));
|
||||
assert!(
|
||||
!writes_4002,
|
||||
"sfx pitch tick path must not appear when flag is off"
|
||||
);
|
||||
let reads_pitch_ptr = tick
|
||||
.iter()
|
||||
.any(|i| i.opcode == LDA && i.mode == AM::Absolute(AUDIO_SFX_PITCH_PTR_LO));
|
||||
assert!(
|
||||
!reads_pitch_ptr,
|
||||
"AUDIO_SFX_PITCH_PTR_LO must not be touched when flag is off"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn audio_tick_both_channels_assemble() {
|
||||
// With both new channels enabled, the tick + period table must
|
||||
// still fit its internal branches (±127 bytes) and assemble
|
||||
// successfully.
|
||||
let mut combined = gen_audio_tick(true, true);
|
||||
let mut combined = gen_audio_tick(true, true, false);
|
||||
combined.extend(gen_period_table());
|
||||
let result = asm::assemble(&combined, 0xC000);
|
||||
assert!(!result.bytes.is_empty());
|
||||
|
|
@ -335,7 +381,7 @@ fn audio_tick_mutes_pulse2_on_non_looping_end_of_track() {
|
|||
// tick writes $30 to $4004 and clears ZP_MUSIC_STATE. We verify
|
||||
// the mute path exists by checking both writes exist somewhere
|
||||
// in the tick body.
|
||||
let tick = gen_audio_tick(false, false);
|
||||
let tick = gen_audio_tick(false, false, false);
|
||||
let has_mute = tick
|
||||
.iter()
|
||||
.any(|i| i.opcode == STA && i.mode == AM::Absolute(0x4004));
|
||||
|
|
@ -356,7 +402,7 @@ fn audio_tick_assembles_without_error() {
|
|||
// uses label-relative branches internally which need to fit
|
||||
// within ±127 bytes — if the body grows past that the branches
|
||||
// will panic at assemble time.
|
||||
let mut combined = gen_audio_tick(false, false);
|
||||
let mut combined = gen_audio_tick(false, false, false);
|
||||
combined.extend(gen_period_table());
|
||||
let result = asm::assemble(&combined, 0xC000);
|
||||
assert!(
|
||||
|
|
|
|||
1
tests/emulator/goldens/sfx_pitch_envelope.audio.hash
Normal file
1
tests/emulator/goldens/sfx_pitch_envelope.audio.hash
Normal file
|
|
@ -0,0 +1 @@
|
|||
61bd9e17 132084
|
||||
BIN
tests/emulator/goldens/sfx_pitch_envelope.png
Normal file
BIN
tests/emulator/goldens/sfx_pitch_envelope.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 711 B |
Loading…
Add table
Add a link
Reference in a new issue