Skip to content

fix(sid): play vsync tunes at the tune's own frame rate - #277

Merged
kfox merged 1 commit into
mainfrom
sid-play-rate-timing
Aug 13, 2026
Merged

fix(sid): play vsync tunes at the tune's own frame rate#277
kfox merged 1 commit into
mainfrom
sid-play-rate-timing

Conversation

@kfox

@kfox kfox commented Aug 13, 2026

Copy link
Copy Markdown
Owner

The defect

The C64-side SID player chains PLAY onto the kernal's CIA #1 Timer A interrupt.
That interrupt is a wall-clock service — TI$, SCNKEY, cursor blink — not a
frame interrupt, so the KERNAL programs it to ≈60 Hz on both standards
($4025 PAL, $4295 NTSC). Nothing in the .sid path ever reprogrammed it.

A tune composed for PAL's 50.12 Hz therefore played at 60.0 — +19.7% tempo
on a PAL machine as much as on an NTSC one. That's roughly 80% of a full HVSC
(PAL + vsync); PAL CIA-timed tunes (~10%) self-time from their own INIT and drift
only by the clock ratio.

Tempo and pitch are two separate errors with two separate levers, and keeping
them apart is what shapes this change:

lever fixes costs
CIA #1 Timer A latch (sid_play_rate) tempo (+19.7%) nothing — software only
System Mode (sid_video_mode) pitch (+3.8%) an HDMI mode switch + capture re-lock

Hence the defaults: the tempo fix is on, the pitch fix is opt-in.

What's here

[ultimate64].sid_play_rate (default "auto") sets the latch to the tune's
own frame rate. "off" is exactly the previous behaviour — a real preference,
since plenty of people know these tunes at the speed an NTSC machine played
them — and an explicit number in Hz pins every vsync tune to one rate.

The latch is written after INIT, not before: the Ultimate kicks the player
via run_prg, which soft-resets the C64, and the KERNAL's reset path reloads
Timer A, so a pre-kick write is gone before the first PLAY. Two gates keep it off
self-timed tunes — the header's per-subtune speed flag must say vsync, and the
latch actually in place after INIT must not already look like a multispeed one.
That second gate is what protects tunes whose header lies.

[ultimate64].system defaults to "auto", read from the machine's live
System Mode. This one field feeds the CPU clock, the frame rate, the DAC NMI
latches and the PLAY rate, so a hand-set value that disagreed moved all of them
at once, silently. An explicit value still wins (it's how you describe a
TeensyROM-driven C64), warns on disagreement, and is an error-level --doctor
finding.

[ultimate64].sid_video_mode (default "off") retimes the machine via the
Ultimate 64's System Mode, correcting pitch. [ultimate64].hdmi_scan_resolution
(default "auto") raises SD to HD when it does — PAL timing at SD puts 576p50 on
the wire and not every capture device locks to that, while the same machine at
720p50 captures cleanly. "auto" only acts when c64cast itself retimed the
machine: clean up after our own change, leave a machine we didn't touch alone.

Two bugs found on the way

  • The kernal restore latch was the wrong standard's. The ASID ring player and
    the REU audio pump both wrote $4025 back at teardown while documenting it as
    the NTSC default. $4025 is PAL's; NTSC is $4295. The jiffy clock ran ~3.8%
    fast after either teardown until the next reset.
  • The latch sample burst was biased low. $DC04/$DC05 are write-only, so the
    latch is estimated as the max over a burst of reads of a free-running
    down-counter — which averages n/(n+1) of the true value with a fat tail. At
    n=8, a burst against a kernal 60 Hz jiffy measured 75.6 Hz on hardware.
    Raised to 16, which keeps that tail off the self-timed gate.

Note on the System Mode enum

The -50/-60 suffix selects the machine timing; the PAL/NTSC prefix selects
only the analog chroma encoding. So NTSC-50 is a PAL-timed machine and PAL-60
is an NTSC-timed one, and over HDMI the pairs are identical. SYSTEM_MODE_TIMING
therefore looks backwards on purpose, and says so.

On composite the prefix is the whole colour signal, so retiming costs more there
than over HDMI: the chroma encoding is preserved, but the field rate changes
underneath it and a single-standard set may not lock. Documented in caveats and
both books.

Verification

Full gate green: make check, make lint, make typecheck (mypy --strict +
pyright), make doctor, make site-check, make books. Schema and generated
appendices regenerated.

On hardware:

  • PAL vsync tune → PLAY retuned to 50.12 Hz (latch $4CC7), confirmed by the
    post-write sample reading ~50 Hz.
  • NTSC tune on a PAL-timed machine → 59.83 Hz.
  • PAL multispeed tune → untouched, self-timing at ~204 Hz.
  • sid_play_rate = "off" → no retune, previous behaviour intact.
  • System Mode switch PAL → PAL-60, C64 reset, restore to PAL; the retune then
    used the NTSC-clock latch ($42C6), confirming the profile re-folded after the
    switch.
  • SD → HD auto-raise and its restore.
  • --doctor reports both the agreeing and the disagreeing system cases.

The C64-side SID player chains PLAY onto the kernal's CIA #1 Timer A
interrupt, which the KERNAL runs at ~60 Hz on *both* standards -- it is a
wall-clock service (TI$, SCNKEY, cursor blink), not a frame interrupt.
Nothing in the .sid path ever reprogrammed it, so a tune composed for
PAL's 50.12 Hz ran at 60.0 on a PAL machine as much as an NTSC one:
+19.7% tempo, across roughly 80% of a full HVSC.

[ultimate64].sid_play_rate (default "auto") sets the latch to the tune's
own frame rate. "off" keeps the previous behaviour for anyone who knows
these tunes at NTSC speed; a number pins every vsync tune to one rate.

The latch is written after INIT, not before: the Ultimate kicks the
player via run_prg, which soft-resets the C64, and the KERNAL's reset
path reloads Timer A. Two gates keep it off self-timed tunes -- the
header's per-subtune speed flag must say vsync, and the latch actually in
place after INIT must not already look like a multispeed one. The sample
burst behind that second gate went 8 -> 16 reads: the max of a
free-running down-counter is biased low with a fat tail, and an 8-sample
burst against a kernal 60 Hz jiffy measured 75.6 Hz on hardware.

Also fixed: both the ASID ring player and the REU audio pump wrote $4025
back at teardown while documenting it as the NTSC default. $4025 is PAL's;
NTSC is $4295. Both now go through c64.kernal_cia1_latch(system).

Pitch is a separate error with a separate lever, so it gets separate
settings. sid_video_mode (default "off") retimes the machine itself via
the Ultimate 64's System Mode, correcting the 3.8% clock difference;
hdmi_scan_resolution (default "auto") raises SD to HD when it does, since
PAL timing at SD puts 576p50 on the wire and not every capture device
locks to that. Both live, volatile, restored at teardown.

[ultimate64].system now defaults to "auto" and is read from the machine's
live System Mode. One field feeds the CPU clock, frame rate, DAC NMI
latches and PLAY rate, and a hand-set value that disagreed moved all of
them at once, silently. An explicit value still wins, warns on
disagreement, and is an error-level --doctor finding.

Verified on hardware: PAL vsync tune retuned to 50.12 Hz (latch $4CC7),
NTSC tune to 59.83, a multispeed tune left self-timed at ~204 Hz, the
System Mode switch and both restores round-tripping, and the SD -> HD
raise and its restore.
@codecov

codecov Bot commented Aug 13, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 83.46774% with 41 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.08%. Comparing base (8859f39) to head (1c7add7).

Files with missing lines Patch % Lines
c64cast/hw/api.py 83.33% 7 Missing and 5 partials ⚠️
c64cast/hw/hw_provision.py 92.23% 5 Missing and 3 partials ⚠️
c64cast/app/doctor.py 41.66% 6 Missing and 1 partial ⚠️
c64cast/app/cli.py 0.00% 5 Missing ⚠️
c64cast/app/scene_factory.py 28.57% 3 Missing and 2 partials ⚠️
c64cast/hw/backend.py 72.72% 3 Missing ⚠️
c64cast/app/cli_commands.py 50.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #277      +/-   ##
==========================================
- Coverage   82.08%   82.08%   -0.01%     
==========================================
  Files         142      142              
  Lines       24788    25013     +225     
  Branches     3639     3675      +36     
==========================================
+ Hits        20347    20531     +184     
- Misses       3647     3677      +30     
- Partials      794      805      +11     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

@kfox
kfox merged commit d12a8b9 into main Aug 13, 2026
19 checks passed
@kfox
kfox deleted the sid-play-rate-timing branch August 13, 2026 03:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant