From 76ddcc008a72b1dbb8d9bee227e02ae6549caa99 Mon Sep 17 00:00:00 2001 From: Matthew Stanley <1379tech@gmail.com> Date: Mon, 13 Jul 2026 21:40:11 -0700 Subject: [PATCH] test(spu): pin END-only voice mute semantics Document the overlap with kem0x PR #14 and explicitly record the game-specific and unrelated changes excluded from this branch. Co-authored-by: Kareem Olim --- .../upstream/kem0x-pr14-spu-end-mute.md | 38 +++++ runtime/tests/test_spu_end_without_repeat.c | 131 ++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 docs/internal/upstream/kem0x-pr14-spu-end-mute.md create mode 100644 runtime/tests/test_spu_end_without_repeat.c diff --git a/docs/internal/upstream/kem0x-pr14-spu-end-mute.md b/docs/internal/upstream/kem0x-pr14-spu-end-mute.md new file mode 100644 index 00000000..f93111ff --- /dev/null +++ b/docs/internal/upstream/kem0x-pr14-spu-end-mute.md @@ -0,0 +1,38 @@ +# kem0x PR #14: SPU END-without-REPEAT behavior + +Evaluated on 2026-07-13 against psxrecomp master `dde268dc0fb9daf8fe6529f4aebfe80995350334`. + +## Source and credit + +- Pull request: [mstan/psxrecomp#14](https://github.com/mstan/psxrecomp/pull/14) +- Evaluated commit: [`b2597df6d5905a0e183b5309a9b84720cd417e19`](https://github.com/mstan/psxrecomp/commit/b2597df6d5905a0e183b5309a9b84720cd417e19) +- Original author: Kareem Olim (`Kareem Olim `) + +The generic behavior identified in that commit is that an ADPCM block carrying +END without REPEAT must redirect decoding to the voice's repeat address, enter +Release, and immediately clear the envelope. This prevents a one-shot voice +from leaking or looping unrelated sample RAM after its terminator. + +Current master already implements the same behavior independently in commit +`462e2bf5fdb4d46b935322bf7f782fd424aea584`. Therefore this branch does not +replace that runtime code. It adds a focused regression test that pins the +shared behavior: END-only becomes silent and inactive at the boundary, resumes +decoding at the repeat address, and emits one stop event; END+REPEAT remains +audible and active. + +## Explicit exclusions + +The following material from the source commit was reviewed and intentionally +not imported: + +- `PEPSIMAN_SFX_RETRIGGER_GUARD` and its address-based burst/cooldown policy: + game-specific compensation for Pepsiman timing, not generic SPU behavior. +- The `PSX_WEB` reduction of `SPU_EVENT_CAP`: WebAssembly memory sizing is + unrelated to ADPCM END semantics and belongs in a separate change. +- `SpuVoice.start_addr`: introduced for the excluded retrigger policy and not + needed for the generic END transition. +- All other PR #14 changes: CD-ROM interrupts, CD-DA, raster timing, build + changes, and game-specific hooks require independent review and branches. + +No Pepsiman compile-time hook or game-specific playback heuristic is present in +this branch. diff --git a/runtime/tests/test_spu_end_without_repeat.c b/runtime/tests/test_spu_end_without_repeat.c new file mode 100644 index 00000000..e883276b --- /dev/null +++ b/runtime/tests/test_spu_end_without_repeat.c @@ -0,0 +1,131 @@ +/* + * Regression test for an ADPCM END block without REPEAT. + * + * The test includes spu.c so it can stage a voice exactly at the block + * boundary, without exposing emulator internals in the runtime API. + * + * Build from runtime/tests: + * gcc -std=c99 -I../include -o test_spu_end_without_repeat.exe \ + * test_spu_end_without_repeat.c + */ + +#include +#include +#include + +#include "../src/spu.c" + +/* Dependencies owned by the surrounding runtime are inert in this unit test. */ +uint64_t s_frame_count; + +uint64_t psx_get_cycle_count(void) { return 0; } + +void audio_trace_pcm(int tap, const int16_t *stereo, int frames) { + (void)tap; + (void)stereo; + (void)frames; +} + +void audio_trace_event(uint16_t kind, uint32_t a, uint32_t b) { + (void)kind; + (void)a; + (void)b; +} + +bool spu_shadow_enabled(void) { return false; } +void spu_shadow_reset(void) {} +void spu_shadow_process(int16_t *canon, int frames) { + (void)canon; + (void)frames; +} + +static int failures; + +#define CHECK(condition, message) \ + do { \ + if (!(condition)) { \ + fprintf(stderr, "FAIL: %s\n", message); \ + failures++; \ + } else { \ + printf("ok: %s\n", message); \ + } \ + } while (0) + +static void fill_block(uint32_t addr, uint8_t flags, uint8_t packed) { + spu_ram[addr] = 0x00; /* filter 0, shift 0 */ + spu_ram[addr + 1u] = flags; + memset(spu_ram + addr + 2u, packed, 14u); +} + +static SpuVoice *stage_end_boundary(uint8_t flags, uint32_t repeat_addr) { + SpuVoice *v = &voices[0]; + memset(v, 0, sizeof(*v)); + v->active = 1; + v->cur_addr = 0x310u; /* the block after the terminator */ + v->repeat_addr = repeat_addr; + v->sample_idx = SPU_BLOCK_SAMPLES; + v->env_level = 0x6000u; + v->adsr_phase = ADSR_SUSTAIN; + v->flags = flags; + spu_regs[2] = 0x1000u; /* one decoded sample per output sample */ + return v; +} + +static void test_end_without_repeat_is_silent(void) { + const uint32_t repeat_addr = 0x120u; + spu_init(); + + /* Distinguish the required repeat-address decode from an incorrect + * forward decode. Both blocks are deliberately non-silent: the envelope, + * rather than convenient zero sample data, must make the result silent. */ + fill_block(repeat_addr, 0x04u, 0x11u); + fill_block(0x310u, 0x00u, 0x77u); + SpuVoice *v = stage_end_boundary(0x01u, repeat_addr); + + int16_t sample = voice_next_sample(0); + + CHECK(sample == 0, "END without REPEAT emits silence at the boundary"); + CHECK(v->env_level == 0, "END without REPEAT forces the envelope to zero"); + CHECK(v->adsr_phase == ADSR_RELEASE, + "END without REPEAT enters Release"); + CHECK(!v->active, "zero-level Release deactivates the voice"); + CHECK(v->samples[0] == 4096, + "decoder resumes at the latched repeat address, not forward RAM"); + CHECK(spu_event_total() == 1, "END stop records exactly one event"); + + SpuEvent event; + CHECK(spu_event_get(&event, 1) == 1 && event.kind == SPU_EV_END_STOP, + "END stop event has the stop kind"); + CHECK(event.addr == repeat_addr, + "END stop event reports the redirected repeat address"); + + (void)voice_next_sample(0); + CHECK(spu_event_total() == 1, + "an inactive one-shot does not retrigger its END transition"); +} + +static void test_end_with_repeat_keeps_playing(void) { + const uint32_t repeat_addr = 0x180u; + spu_init(); + fill_block(repeat_addr, 0x00u, 0x11u); + SpuVoice *v = stage_end_boundary(0x03u, repeat_addr); + + int16_t sample = voice_next_sample(0); + + CHECK(sample != 0, "END with REPEAT remains audible"); + CHECK(v->env_level != 0, "END with REPEAT preserves the envelope"); + CHECK(v->adsr_phase == ADSR_SUSTAIN, + "END with REPEAT preserves the ADSR phase"); + CHECK(v->active, "END with REPEAT keeps the voice active"); + + SpuEvent event; + CHECK(spu_event_get(&event, 1) == 1 && event.kind == SPU_EV_END_LOOP, + "END with REPEAT records a loop event"); +} + +int main(void) { + test_end_without_repeat_is_silent(); + test_end_with_repeat_keeps_playing(); + printf(failures ? "FAILED (%d)\n" : "ALL PASS\n", failures); + return failures ? 1 : 0; +}