From 6b9ddd28e2efad27ed4b950bba86bb581e3b6d60 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Wed, 8 Jul 2026 08:46:14 -0500 Subject: [PATCH] Blank the display before the SID player's run_prg kick (Ultimate) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit run_prg soft-resets the C64, which has the same reset-latency window reset() already guards with blank_display() — without it, launching the SID player over a previous hires/mhires scene briefly flashes that scene's leftover bitmap RAM before the kernal reinitializes VIC and WaveformScene re-engages its own bitmap mode. Mirrors the guard reset() already uses for the identical class of glitch. --- c64cast/api.py | 9 +++++++++ docs/caveats.md | 13 +++++++++++++ tests/test_api.py | 19 +++++++++++++++---- 3 files changed, 37 insertions(+), 4 deletions(-) diff --git a/c64cast/api.py b/c64cast/api.py index 987db28..628dcbe 100644 --- a/c64cast/api.py +++ b/c64cast/api.py @@ -1325,12 +1325,21 @@ def _launch_sid_player( MC at $C300 survives) and RUNs the stub; BASIC's SYS jumps to the player MC, which installs the IRQ and spins forever (never re-entering BASIC). + Blanks the display first (same guard as `reset()`): run_prg's reset has + its own reset-latency window during which the VIC still holds the + OUTGOING scene's mode/bank, so without this a bitmap/hires scene (e.g. + another waveform or a video scene) flashes its leftover bitmap RAM until + the kernal reinitializes VIC and, in turn, `_setup_hires()` re-engages + the scope. This is the same class of glitch `reset()`'s pre-blank fixes; + `run_prg` here is a parallel reset path that needs the same guard. + `avoid` is unused here (no trampoline to place). `defer_audio` is ignored: run_prg is a synchronous reset+RUN that also re-inits VIC to text mode, so there's no loaded-but-silent window to hold — audio starts here. Returns True so `run_sid_player` runs the standard finalize (timestamp + divider). WaveformScene's `begin_sid_audio()` is then a no-op, and it (re)asserts the bitmap display *after* this call as it always has.""" + self.blank_display() self._write_sid_blobs(parsed, layout, mc, reinit) self.flush() basic_stub = _build_basic_sys_stub(layout.player_base) diff --git a/docs/caveats.md b/docs/caveats.md index 69c8068..810aa47 100644 --- a/docs/caveats.md +++ b/docs/caveats.md @@ -115,6 +115,19 @@ screen. The kernal IRQ keeps firing regardless, so PLAY runs at the system rate and `$028D` keeps updating for the keyboard poller. Audio still comes from the real SID chip. +**Pre-blank before the kick (Ultimate only).** `runners:run_prg` soft-resets +the C64, and like any reset it has a reset-latency window during which the +VIC still holds the *outgoing* scene's mode/bank/bitmap — so without a guard, +launching the SID player over a previous hires/mhires scene (another +waveform, or a video/generative bitmap scene) briefly flashes that scene's +leftover bitmap RAM before the kernal reinitializes VIC and `WaveformScene` +re-engages its own bitmap mode. `Ultimate64API._launch_sid_player` blanks the +display (`blank_display()`, DEN off) immediately before DMA'ing the SID +blobs, the same guard `reset()` uses for the same reason. The TeensyROM +backend never does this (see the vector-swap note below): its "kick" doesn't +reset the machine, and turning DEN off there would stall the cycle-clean DMA +gate. + ### Per-call memory banking (`$01`) The player banks the 6510 CPU port at `$0001` **per call**, matching the diff --git a/tests/test_api.py b/tests/test_api.py index ddb9488..9e4aef6 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -55,7 +55,7 @@ _PlayerLayout, parse_psid_for_player, ) -from c64cast.c64 import CPU, VECTORS +from c64cast.c64 import CPU, VECTORS, VIC from c64cast.socket_dma import SocketDMAError @@ -177,10 +177,21 @@ def setUp(self): self.api = Ultimate64API("http://example.invalid") # Stub the wire-level write + flush + REST POST so the test runs # against in-process state only. + # dma_writes tracks the SID-upload contract this class tests (payload + # / player MC / re-INIT stub); the pre-flight blank_display() write to + # $D011 (see _launch_sid_player) is recorded separately since it's + # orthogonal to that contract and would otherwise shift every + # index-based assertion below. self.dma_writes: list[tuple[int, bytes]] = [] - self.api._emit = lambda addr, payload: ( # type: ignore[method-assign] - self.dma_writes.append((addr, bytes(payload))) - ) + self.blank_writes: list[tuple[int, bytes]] = [] + + def _fake_emit(addr, payload): + if addr == VIC.D011_CONTROL_1: + self.blank_writes.append((addr, bytes(payload))) + else: + self.dma_writes.append((addr, bytes(payload))) + + self.api._emit = _fake_emit # type: ignore[method-assign] patch.object(self.api, "flush").start() self.posts: list[tuple[str, bytes]] = []