Files
Joe MattielloandClaude Opus 4.6 d1720d4c9f fix(dac): resample I2S output when SCLK changes at runtime (#163)
* fix(dac): handle SCLK rate changes for correct audio pitch

Replace fixed 48 kHz sample-and-hold with a proper resampler that
adapts to runtime SCLK changes. Games like Skyhammer and Iron Soldier 2
change the I2S serial clock mid-frame for pitch effects, causing audio
clipping when the output buffer assumes a constant rate.

The fix captures DSP-produced samples (LTXD/RTXD writes) into a ring
buffer at the hardware I2S rate, then linearly interpolates to 48 kHz
in DSPSampleCallback. When SCLK changes, DACUpdateSCLKRate() recomputes
the ratio using the JTRM formula: rate = sysclk / (64 * (SCLK + 1)).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(dac): increase ring buffer and upper clamp for extreme SCLK values

SCLK=0 gives divider=64, yielding ~415kHz I2S rate (ratio ~8.66 to
48kHz host). The previous 4.0 upper clamp rejected this valid setting,
and the 4096-sample ring buffer was too small for ~6900 samples/frame
at that rate.

- Raise upper clamp from 4.0 to 16.0 (covers all valid SCLK 0-255)
- Increase I2S_RING_SIZE from 4096 to 8192

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(dac): address review feedback on resampler edge cases

- Increase I2S ring buffer to 16384 (covers PAL SCLK=0 at ~8311 samples/frame)
- Preserve fractional interpolation phase across frame boundaries (prevents clicks)
- Lower SCLK ratio floor from 0.1 to 0.01 (allows valid SCLK=255 at ~1624 Hz)

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(dac): fix frame-boundary interpolation and rename misleading counter

- Seed ring[0] with previous frame's last sample so interpolation has a
  valid left endpoint across frame boundaries (eliminates periodic click)
- Rename i2sReadCount → i2sWriteCount (it counts writes, not reads)
- Track last sample pair for frame-boundary seeding

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(dac): seed ring from registers, remove i2sLast fields

- Seed ring[0..1] with current *ltxd/*rtxd at frame start instead of
  tracking separate i2sLastL/R. This maintains "hold current DAC value"
  behavior and provides two valid interpolation endpoints.
- Guard path uses i2sWriteCount < 2 (reachable only after mid-frame reset).
- Removes i2sLastL/R (no extra savestate fields needed).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(dac): use canonical clock constants, fix byte-write SCLK path

- Use RISC_CLOCK_RATE_NTSC/PAL from jaguar.h instead of hardcoded literals
  (stays in sync if clock values are corrected elsewhere)
- Fix DACWriteByte: forward to SCLK+2 (not SCLK+0) so byte writes via
  MOVE.B correctly trigger DACUpdateSCLKRate()

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* refactor(clock): add SYSTEM_CLOCK_RATE macro, DRY clock selection

Add SYSTEM_CLOCK_RATE and M68K_CLOCK_RATE macros to jaguar.h that
encapsulate the NTSC/PAL ternary. Replaces duplicated patterns in
dac.c and jerry.c.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* fix(dac): make DACUpdateSCLKRate static + handle SMODE byte writes

DACUpdateSCLKRate is only used within dac.c — remove from public
header and make it static with a forward declaration.

Add SMODE+3 byte-write forwarding in DACWriteByte so games using
MOVE.B to toggle SMODE bits correctly update the serial mode
register (same pattern as the existing SCLK byte path).

Co-Authored-By: Claude Opus 4.6 <[email protected]>

* test(pit): accept SYSTEM_CLOCK_RATE as valid RISC clock reference

The PIT clock rate regression guard greps function bodies for literal
RISC_CLOCK_RATE_* strings. Since JERRYGetPIT1/2Frequency now uses
the SYSTEM_CLOCK_RATE macro (which expands to the RISC clock ternary),
add it to the allowlist so the test correctly recognizes it as using
the full system clock.

Co-Authored-By: Claude Opus 4.6 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
2026-05-05 00:30:34 -04:00

189 lines
5.3 KiB
C

/*
* test_pit_clock_rate.c -- regression guard for the recurring PIT clock
* rate bug.
*
* Per JTRM (docs/jtrm-clocks-timing.md): "The PIT clock is the FULL
* system clock (26.59 MHz), NOT half. ... If you see system_clock / 2
* in PIT calculations, it is a bug."
*
* This bug has been re-introduced multiple times with the rationale
* that "Doom plays correctly at half rate" -- but actually halving the
* PIT rate breaks Doom and Rayman music timing.
*
* This test is a textual guard that fails the build if anyone schedules
* JERRYPIT1/2 or TOMPIT callbacks using the M68K clock constants
* (M68K_CYCLE_IN_USEC / M68K_CYCLE_PAL_IN_USEC) instead of the RISC
* constants (RISC_CYCLE_IN_USEC / RISC_CYCLE_PAL_IN_USEC), or if the
* frequency helpers use M68K_CLOCK_RATE_NTSC/PAL.
*
* If a real cycle-accurate emulation reason ever surfaces to change
* this, update the test and the JTRM doc together -- don't just edit
* the source.
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int g_failures = 0;
static char *load_file(const char *path)
{
FILE *f = fopen(path, "rb");
long sz;
char *buf;
size_t got;
if (!f)
{
fprintf(stderr, "FATAL: cannot open %s\n", path);
exit(2);
}
fseek(f, 0, SEEK_END);
sz = ftell(f);
fseek(f, 0, SEEK_SET);
buf = (char *)malloc((size_t)sz + 1);
got = fread(buf, 1, (size_t)sz, f);
buf[got] = 0;
fclose(f);
return buf;
}
/* Returns pointer to the start of `func_name`'s body in `src`, or NULL.
* Does the dumb thing: finds "func_name(", then walks to the next '{'.
* Returns the matching '}' end via *end_out.
*
* This is good enough for our two callers (well-formed C, no nested
* function definitions, single open-brace function bodies).
*/
static char *find_func_body(const char *src, const char *func_name, char **end_out)
{
const char *p;
const char *open;
int depth;
size_t needle_len;
char needle[128];
snprintf(needle, sizeof(needle), "%s(", func_name);
needle_len = strlen(needle);
p = src;
while ((p = strstr(p, needle)) != NULL)
{
/* Walk forward to first '{' that opens a body. Bail if we hit ';'
* before '{' (that's a forward declaration, not a definition). */
const char *q = p + needle_len;
while (*q && *q != '{' && *q != ';')
q++;
if (*q != '{')
{
p = q;
continue;
}
open = q;
depth = 0;
while (*q)
{
if (*q == '{')
depth++;
else if (*q == '}')
{
depth--;
if (depth == 0)
{
*end_out = (char *)q;
return (char *)open;
}
}
q++;
}
return NULL; /* unbalanced */
}
return NULL;
}
static void check_func_uses_risc(const char *path, const char *src, const char *func)
{
char *end = NULL;
char *body = find_func_body(src, func, &end);
size_t len;
char *snippet;
int has_risc, has_m68k;
if (!body || !end)
{
fprintf(stderr, "FAIL: %s: cannot locate function %s\n", path, func);
g_failures++;
return;
}
len = (size_t)(end - body);
snippet = (char *)malloc(len + 1);
memcpy(snippet, body, len);
snippet[len] = 0;
has_risc = (strstr(snippet, "RISC_CYCLE_IN_USEC") != NULL)
|| (strstr(snippet, "RISC_CYCLE_PAL_IN_USEC") != NULL)
|| (strstr(snippet, "RISC_CLOCK_RATE_NTSC") != NULL)
|| (strstr(snippet, "RISC_CLOCK_RATE_PAL") != NULL)
|| (strstr(snippet, "SYSTEM_CLOCK_RATE") != NULL);
has_m68k = (strstr(snippet, "M68K_CYCLE_IN_USEC") != NULL)
|| (strstr(snippet, "M68K_CYCLE_PAL_IN_USEC") != NULL)
|| (strstr(snippet, "M68K_CLOCK_RATE_NTSC") != NULL)
|| (strstr(snippet, "M68K_CLOCK_RATE_PAL") != NULL);
if (has_m68k)
{
fprintf(stderr,
"FAIL: %s: function %s references M68K_*_IN_USEC / "
"M68K_CLOCK_RATE_*\n"
" PIT clock must be RISC (full system clock) per JTRM.\n"
" See docs/jtrm-clocks-timing.md.\n",
path, func);
g_failures++;
}
if (!has_risc)
{
fprintf(stderr,
"FAIL: %s: function %s does not reference any RISC_*_IN_USEC "
"constant.\n"
" Expected the PIT scheduling to use RISC_CYCLE_IN_USEC / "
"RISC_CYCLE_PAL_IN_USEC.\n",
path, func);
g_failures++;
}
free(snippet);
}
int main(int argc, char **argv)
{
const char *jerry_path = "src/jerry/jerry.c";
const char *tom_path = "src/tom/tom.c";
char *jerry_src;
char *tom_src;
if (argc > 1)
jerry_path = argv[1];
if (argc > 2)
tom_path = argv[2];
jerry_src = load_file(jerry_path);
tom_src = load_file(tom_path);
check_func_uses_risc(jerry_path, jerry_src, "JERRYResetPIT1");
check_func_uses_risc(jerry_path, jerry_src, "JERRYResetPIT2");
check_func_uses_risc(jerry_path, jerry_src, "JERRYGetPIT1Frequency");
check_func_uses_risc(jerry_path, jerry_src, "JERRYGetPIT2Frequency");
check_func_uses_risc(tom_path, tom_src, "TOMResetPIT");
free(jerry_src);
free(tom_src);
if (g_failures)
{
fprintf(stderr, "FAIL: %d PIT clock rate violation(s) detected.\n",
g_failures);
return 1;
}
printf("PASS: PIT scheduling functions use the RISC (full system clock) "
"rate.\n");
return 0;
}