c64cast

Caveats

Surprises, footguns, and design choices that look weird until you know why. Read this before you spend an evening debugging "it's almost working, but…". For end-user options see the Programmer's Reference Guide; for the architecture overview see architecture.md.

Audio is intentionally lo-fi (the 4-bit $D418 DAC)#

The SID DAC streaming path writes samples to the SID volume nibble at $D418 at 12 kHz by default ([audio] sample_rate). The classic path is 4-bit (0-15) — an objectively bad audio format, but the one a real C64 plays back. You can raise sample_rate, but it isn't the quality lever: the C64-side NMI period is derived from it (it programs the CIA #2 Timer A latch), so the pitch stays correct, and there's little headroom — rates past the ≈13.6 kHz NTSC handler ceiling are rejected at load (c64.nmi_rate_safety). The real depth knob is [audio] dac_curve, whose "auto" default lifts the U64's (deterministic emulated) SID to the Mahoney ≈6-7-bit $D418 technique; --calibrate-dac does the same for a physical SID. Only an uncalibrated physical/unknown chip stays on the classic 4-bit linear path.

There's no SID filter and no anti-aliasing, but shaping isn't bare: the [dsp] chain — compressor/limiter, a downward expander in place of a hard noise gate, pre-emphasis, mic AGC — is ON by default. [audio] noise_gate and mic_sensitivity are the pre-DSP shaping knobs; noise_gate only takes effect when [dsp] enabled = false. Hum and hiss are still part of the aesthetic.

The measurement records the SID's own output, so the calibration ring has to be the only thing making sound. On an Ultimate connection c64cast arranges that itself: it routes the socket being measured to $D400 and forces every other mixer source — the second socket, both UltiSID cores, the sampler channels — to OFF for the duration, restoring your settings afterwards.

Over any other link it cannot. Socket isolation and mixer control both need the Ultimate's config API, so calibrating over TeensyROM+ (or any future link without one) measures with whatever else you left audible mixed into the result — and on a machine with two physical SIDs, that includes a live second chip idling into the same output. Mute the other sources in the machine's own settings before you calibrate, and put them back afterwards.

This is worth doing on its own merits; it is not what caused the refused calibrations investigated on a two-SID rig — those turned out to be individual capture slots glitching, which are now discarded rather than averaged into the table. If a calibration is still refused, the message says what it saw and where it saved the capture.

High-fidelity video audio: the Ultimate Audio FPGA sampler (U64)#

On the Ultimate 64 the lo-fi DAC above is not the default for video playback. The U64 firmware exposes an "Ultimate Audio" FPGA PCM sampler at $DF20-$DFFF that plays 8/16-bit PCM (up to 48 kHz) directly out of REU SDRAM — the FPGA fetches and converts the samples itself, with zero SID / $D418 / NMI / CPU / turbo involvement. So it sidesteps every bus-halt and badline problem the 4-bit DAC fights — the constraints that hold the DAC path to ≈12.5 kHz and cap digi scenes at 20 fps — and it sounds like an actual sound card instead of a digi-player.

[audio].backend selects it: "auto" (default) uses the sampler on a capable U64 and falls back to the 4-bit DAC otherwise; "dac" forces the lo-fi DAC (the only path on the TeensyROM, which has no FPGA sampler); "sampler" forces it and warns + falls back to the DAC if it isn't available. sampler_sample_rate (default 44100) and sampler_bits (8 or 16, default 16) tune quality. Mic and webcam audio always use the 4-bit DAC.

Implementation (c64cast/audio/sampler.py): a streaming REU ring. Channel 0 is programmed as an A↔B loop over a region of REU; a host writer thread REUWRITEs decoded PCM ahead of a wall-clock-computed read head and wraps. The FPGA sample clock is crystal-exact, so the read position is computed (never read back) and the whole thing is open-loop and drift-free — no servo, no governor, no NMI. The sample rate is the FPGA's exact 6.25 MHz / divider, a constant <0.5 % offset from the nominal request (inaudible, and drift-free because A/V both ride the same clock). The ring lives in REU SDRAM, so a sampler run also provisions the REU (16 MB) — which makes overlay-free bitmap video resolve to the tear-free REU bank-swap path; the sampler installs no $0314 IRQ, so the two coexist with no contention.

Prerequisites on the U64 (auto-provisioned live + restored at teardown when missing, or set them yourself in F2): C64 and Cartridge Settings → Map Ultimate Audio $DF20-DFFF = Enabled, and Audio Mixer → Vol Sampler L / Vol Sampler R audible (0 dB, not OFF). c64cast --doctor reports the sampler's state.

Forced-DAC bitmap video plays ≈12% slow (tempo compensation)#

Force [audio].backend = "dac" on a bitmap display mode (hires / hires_edges / mhires) — or run the always-DAC TeensyROM+ — and video+audio play ≈12% slow at correct pitch. The default U64 video path (the off-bus sampler above) and the char modes (petscii/mcm) are unaffected.

Cause: video is slaved to the audio drain clock (AudioStreamer. position_secondsVideoScene._clock_s). In bitmap mode the audio worker shares the single socket-DMA link with heavy REU bank-swap bitmap writes; the host-DMA servo reads the ring pointer biased under that load and throttles the worker ≈12% (clock/wall ≈ 0.88 mhires vs ≈1.0 petscii, servo-tuning- independent). The $D418 output rate stays ≈ sample_rate (a pure 1000 Hz tone reads ≈993 Hz → pitch correct), so it's a pitch-preserving time stretch: the ring under-fills and the NMI re-reads/duplicates samples at the right per-sample rate. No host-side servo tuning fixes both speed and smoothness (servo on = smooth but slow; open-loop = correct tempo but skips; REU-pump = wobbly — all confirmed by ear).

Fix: because the stretch is pitch-preserving, pre-compress the content in the time domain by the inverse factor so it nets to real time. [audio]. dac_bitmap_tempo_hires / dac_bitmap_tempo_mhires (defaults 0.89 hires / 0.88 mhires, the measured U64-II NTSC speed fractions s) drive it: for the gated bitmap+DAC path, AVFileSource time-compresses the audio pitch-preserving by 1/s via an atempo filter graph and multiplies each video PTS by s. The existing drain-clock A/V sync (which reads ≈s) then lands both content streams at real time, in sync, pitch intact. clock/wall telemetry still reads ≈s by design (it gauges the drain rate; the compensation makes content real-time, not the drain clock). Set the field to 1.0 to disable. Other platforms (U64+PAL, U2P, TR+ PAL/NTSC) have different s — measure per platform with scripts/diags/mhires_tempo_clock_ab.py. This is orthogonal to the [audio].pitch_mult_* NMI-rate knobs (which correct pitch, not tempo). See the video.py tempo-compensation note in architecture.md.

A TeensyROM+ in an Ultimate needs Bus Operation Mode set to Writes#

A TeensyROM+ in the cartridge port of a C64 Ultimate or Ultimate 64 can play with a constant hiss under the audio until one setting is changed on the Ultimate side. It is not a c64cast knob, and no [audio] / [dsp] shaping substitutes for it:

F2 → Cartridge and ROM Settings → Bus Operation Mode = Writes

The choices are Quiet (the firmware default), Writes, Dynamic and Dyn. & Writes; the last also serves, since it includes writes. Back out with RUN/STOP and save when asked — this one has to persist in the machine's own settings, because c64cast cannot provision it live the way it does the REU and the sampler: on this rig the connection is tr:// to the TeensyROM+, and c64cast has no link to the Ultimate whose setting it is.

Worth setting whenever a TeensyROM+ shares a machine with an Ultimate, not only when audio is enabled — the always-DAC $D418 stream (see the tempo section above) is where the difference is loudest, not the only thing crossing that port. Verified on C64 Ultimate firmware 1.1.0.

Bus Operation Mode = Writes also blocks cartridge-port launches#

The same setting cuts the other way for a device that launches programs from the cartridge port. An Ultimate II+ sitting in an Ultimate 64's (or C64 Ultimate's) cartridge port cannot start a PRG via its run_prg REST runner: that launch path asserts RESET over the cartridge port, and under Bus Operation Mode = Writes the host machine does not honor it. The request succeeds, nothing errors, and the machine never restarts into the program.

So a cartridge-port U2+ is a config-read-only target for c64cast: the REST config surface and probes all answer, but SID/PRG playback never starts. Useful for checking configuration and connectivity; any playback behavior has to be verified with the U2+ in an ordinary C64, where its cartridge-port RESET works.

SID playback uses a C64-side player PRG, not runners:sidplay#

WaveformScene deliberately avoids the U64 firmware's POST /v1/runners:sidplay endpoint: firmware 3.14d hijacks the HDMI scaler with its own "ULTIMATE C-64 SID PLAYER" UI while that runner is active, which covers c64cast's oscilloscope visualization. There's no documented way to suppress the UI.

Instead, api.run_sid_player() DMAs the SID payload to its declared load address + a small hand-encoded 6502 player (plus a SHIFT-driven re-INIT stub), then POSTs a matching 10 SYS <player_base> BASIC stub via runners:run_prg. The player and stub are relocated per-tune by _choose_player_layout — the default location is $C300 (so the BASIC stub is SYS 49920), but a tune whose payload would overlap gets the bundle relocated to free RAM the tune doesn't touch (the waveform scene passes a footprint and picks the largest hole the tune never writes; the generic path places the bundle just past the payload), with the SYS argument rebuilt to match. The real 6510 sets the CPU port ($01) bank config around each call (see below), calls INIT once, installs an IRQ that calls PLAY then chains to kernal $EA31 (so keyboard scan at $028D + cursor-blink suppression survive), and then spins forever in a tight JMP *. The player intentionally never returns to BASIC: most SID INIT routines clobber zero-page locations BASIC depends on, so an RTS would land back in the interpreter with corrupted state and print ?SYNTAX ERROR on 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 U64's own player: it rests at $37, switches to the right bank around JSR init, restores $37, then switches again around JSR play and restores $37 before chaining to the kernal IRQ tail. _init_bank_for and _play_bank_for in api.py pick each value independently (init-bank from the load-end page, play-bank from the play-addr page):

Banking per call, rather than once, is what makes this work across the corpus. The two simpler schemes each break a real tune:

Nor can the choice be made once per tune offline: a tune with data under ROM and entry points in RAM needs both banks, and which one it needs is not decidable from the header. The re-INIT stub (SHIFT subtune cycling) carries the same per-call banking.

Known limitations:

WaveformScene's oscilloscope can't read SID register state back from the U64 — the FPGA SID is faithful to real hardware, so $D400-$D418 is write-only and reads return open-bus zeros. The Socket DMA protocol has no general-memory-read opcode either. So sid_host_emu.py runs the same SID file in parallel on a host-side py65 6502 emulator, trapping writes to $D400-$D418 into a 25-byte shadow that the render thread consumes. Audio still comes from the real SID on the U64; the host emulator's audio (if any — most SID PLAYs only write $D4xx) is discarded. The two run at the system video rate (60 NTSC / 50 PAL) with no drift correction — one tick of skew is invisible in an oscilloscope view. The PSID validation above is shared, so if run_sid_player refuses a tune, SidHostEmu refuses the same tune with the same error.

The player MC defaults to $C300 because audio_handlers.py owns $C000-$C2FF (NMI DAC at $C020, REU pump at $C100, REU mic tracker at $C200); the relocation picker refuses any layout that would overlap that region. WaveformScene.setup() calls audio.stop() before SID setup so the NMI handler is silent during playback, but the bytes remain installed for any later scene that re-arms audio.

Multi-SID (2SID/3SID) tunes: split scope, best-effort U64 audio#

A multi-SID PSID writes to extra SID chips at fixed $Dxxx addresses declared in its v3/v4 header (secondSIDAddress $7A, thirdSIDAddress $7B; each byte b → base $D000 | b<<4). WaveformScene auto-detects the count (sid_host_emu.detect_sid_addresses, with an _<N>SID.sid filename fallback when the header understates it) and shows a split scope — one side-by-side window per chip in each voice row. The single host SidHostEmu shadows every chip's register bank (the one 6502 already writes them all), so the display is always correct regardless of hardware.

Audio is best-effort on the U64 and unavailable elsewhere. The tune's writes only make sound where the U64 has a SID mapped to that exact address, so _apply_sid_hw_config maps the U64's UltiSID cores (and sockets) to the tune's own addresses before the player's INIT runs (asid_sidmap.plan_sid_map_for_addresses). The firmware exposes ≤2 sockets ($D400/$D420) + 2 UltiSID cores sharing one range split (1/2$40-aligned, 1/4$80-aligned; stride $20), so consecutive layouts ($D400/$D420/$D440) and two-page layouts ($D400+$D500) realize exactly; a scattered set needing three core windows ($D400+$DE00+$DF00) can't, and falls back to the canonical plan_sid_map layout (some chips silent — the scope stays correct). The prior config is snapshotted and restored on teardown (sid_hw_config.py). Backends without a SID config API (TeensyROM) skip this: every chip's scope still renders, and on a stock machine only $D400 is audible. A C64 with an internal dual-SID mod is the exception — an ARM2SID, SIDFX or DualSID answers at a second address (commonly $D420/$D500) in the machine's own hardware, so a multi-SID tune plays on both chips with no routing at all: the tune writes to both addresses and the chips are already there. Nothing on such a link can detect that, so [hardware].host_sid_chips declares it and the resolved-audio line reports per chip; undeclared, c64cast assumes the single-chip case. Single-SID tunes never touch the config (one window, byte-identical to before). Verified on U64-II hardware: Enchanted_Forest_3SID.sid → 3 windows, all 9 voices audible as each chip enters.

SID Player Autoconfig: chip model matching, not just addressing#

Multi-SID address routing (above) only guarantees a chip is audible somewhere — it says nothing about whether that chip sounds like what the tune expects. A .sid file's PSID header can separately declare which chip model (6581/8580) each voice was composed for (sid_host_emu.SidHeader.sid_models); without checking it, c64cast just plays the tune on whatever chip currently answers its address, silently wrong-sounding when that chip's model doesn't match. --sid-model / [ultimate64].sid_model ("auto" by default) ports the 1541ultimate firmware's own "SID Player Autoconfig" into c64cast's playback path: before run_sid_player, sid_autoconfig.apply_sid_autoconfig compares the header's per-chip requirement against what's actually socketed (sid_hw_config.detect_socket_models) and, if they don't match, swaps to the other physical socket if it has the right chip, else falls back to an UltiSID FPGA core set to a fixed representative filter curve ("6581" / "8580 Lo").

On an Ultimate II+ the same setting drives a much shorter path (emusid_mixer.apply_emusid_model): that machine has no sockets and no cores to fall back to, so each emulated SID snooping a tune chip is simply set to the requested model — Filter Curve and Combined Waveforms together, since a side split between the two emulates neither chip. Nothing is displaced and nothing can fail for want of matching hardware. The host C64's own SID is the exception, below.

Real-hardware limitation inherited from firmware: a genuinely fixed physical 6581 or 8580 chip cannot be reconfigured to the other model. Autoconfig can only route around a mismatched chip — swap which socket answers the address, or fall back to the emulated UltiSID core — it can never transmute the chip itself. A system with two physical sockets and both populated with the same model (e.g. two 6581s, as measured on the dev U64 used to build this feature) can never satisfy an 8580-tagged tune from a socket; it always falls back to UltiSID for that case, same as a single-socket or bare-UltiSID board would. --sid-model off disables header inspection entirely (matches firmware's CFG_PLAYER_AUTOCONFIG disabled state); an explicit --sid-model 6581/8580 forces that model for every chip in the tune, ignoring what the header says.

The same limitation bounds the U2+ path from the other side. Model matching there is total on the emulations and impossible on the host C64's own SID, which plays the tune in parallel on the machine's own audio output. So a mismatched tune on a U2+ resolves to matched emulations plus one unmatched physical chip, and the resolved-audio line reports both routes rather than declaring the tune matched.

Verifying U2+ emu-SID audio by ear requires the cartridge's own output. The two routes leave the setup on different cables: the emulated SIDs are audible only on the Ultimate II+'s green audio jack, while the C64's AV output (a 1702, or RF to a television) carries the machine's real, internal SID. A model change that is confirmed at the config level therefore sounds like nothing happened through the monitor — you are listening to the one chip no setting can touch. Observed during this feature's hardware verification: an 8580-tagged tune still sounded thin and scratchy on the 1702 after the emulation had been correctly set to 8580, and sounded as authored on headphones into the green jack. The U64 has no such split — it is the Commodore, and its own output carries the chips it configures.

The host chip's model can only ever be declared, never read. Nothing in the machine reports it, so [hardware].host_sid_model defaults to the NTSC=6581 / PAL=8580 convention and warns once per run that it is guessing, because every model verdict on that route rests on the guess. The convention is frequently wrong (NTSC machines carrying an 8580 are common). Setting the field to "6581"/"8580" states it as fact and silences the warning; "unknown" opts out of host-chip verdicts entirely. A machine with an internal dual-SID mod is declared per chip with [hardware].host_sid_chips instead, which supersedes the single-valued field and its guess.

The two outputs never mix inside the device, and each is complete. Internal SID audio is not routed through the expansion port, so the green jack carries only what the Ultimate itself generates (emulated stereo SID, Ultimate Audio sampler, tape, drive sounds — selectable in its config menu) and the AV output carries only the machine's own chips. They are two independent renderings of the same tune, not one signal reaching you twice. Pick the one you mean to listen to; there is nothing to balance between them.

The U2+'s snoop cannot tell an I/O write from a write to the RAM below it. The emulated SIDs take CPU writes off the cartridge port, which carries no signal distinguishing an access to $D400-$D7FF from one to the RAM underneath. A tune whose payload or working buffers live in that RAM therefore sprays clicks, pops and stray notes into the emulated SIDs — on the Ultimate's audio output only; the C64's own output is fed by real chips that decode properly. run_sid_player warns when a tune's payload lands there (a warning, never a refusal: the tune plays correctly and only one listening path is affected), but a tune that merely uses that RAM at run time cannot be detected from the header. This also inverts the usual fidelity argument on a dual-SID machine: for the second chip, the machine's own output is the more faithful path, not the emulated one.

ASID buffered ring player (cycle-accurate multispeed)#

AsidScene's default coalesced path folds incoming ASID register frames into per-chip shadows and flushes one $D400-$D418 block write per chip at ≤60 Hz (host socket DMA). That's fine for single-speed tunes but drops intermediate frames on multispeed content (0x31 up to 16×, or a small frame_delta_us pushes frames far faster than 60 Hz): arpeggios, fast vibrato, and gate-off→gate-on hard restarts get mangled, and every flush is a bus-halting, wall-clock-jittered burst.

The buffered path (asid_buffered_player, default auto) fixes this on the U64 by moving frame consumption onto the C64. The host serializes each frame into a fixed-size slot and REUWRITEs it (bus-clean) into a REU ring ahead of a computed read head; a 6502 player fired by CIA #1 Timer A at the ASID cadence pops one slot per tick and applies the writes honoring the 0x30 write-order + inter-write waits — no frames dropped, decoupled from host jitter. It is the open-loop producer-ahead-of-read-head pattern the FPGA sampler uses (the C64 crystal is exact, so no servo and no C64→host reads during playback — it obeys the "don't rapid-poll the U64 during capture" rule) with an IRQ ring consumer modeled on the REU audio pump. AsidScene runs no $D418 DAC, so the whole $C000 page and the REU are free for it. See asid_player.py.

U64 only. It needs a bus-clean reu_write (profile.supports_reu). auto engages it where an REU exists and stays coalesced otherwise; on forces it (warns + falls back on a no-REU backend); off always coalesces. TeensyROM / any no-REU backend keeps the coalesced path unchanged (and its display is never blanked). A buffered run folds the ASID ring into the REU auto-provisioner (hw_provision.wants_reu), so the REU is enabled + sized like the sampler's.

v1 limitations (documented, not over-engineered):

TeensyROM: pure-DMA $0314 vector-swap launch (no run_prg)#

The host-side orchestration above (parse / layout / build / divider auto-tune / subtune re-INIT) is backend-agnostic and shared via _SidPlayerMixin in api.py; only the kick — how control reaches the player — differs per backend, behind the abstract _launch_sid_player. The Ultimate POSTs the SYS stub to run_prg (a synchronous soft reset that preserves RAM, then RUNs). The TeensyROM has no synchronous run-PRG: LaunchFile resets the C64 and boots asynchronously, and its timing-sensitive fast-LOAD is corrupted by the badline-gated DMA reads of a concurrent $028D keyboard poll (and the scope's bitmap bring-up raced the still-completing boot). Working around the boot meant a fixed boot settle, a bus-silent launch lock, a $C000 trampoline + pre-uploaded SYS stub, and a verify-during-boot read — a pile of fragile boot-race hacks.

So the TR doesn't boot at all. After cycle-clean bring-up the C64 runs the IRQ-enabled BASIC clear-loop with the stock kernal IRQ chaining through $0314, so the player is started exactly like a subtune cue (cue_song_reinit): DMA the payload + player MC + re-INIT stub, then atomically DMA-swap $0314/$0315 to the re-INIT stub. The next kernal IRQ runs the stub once — JSR init (banking $01 per-call), restore $D418, install $0314 → the player's PLAY handler, JMP $EA31 — and every subsequent IRQ runs PLAY. The clear-loop the IRQ returns to keeps looping harmlessly underneath; the player MC's own SEI…JMP * entry is never used on this path, only its PLAY-handler tail. No reset, no boot, no fast-LOAD window to corrupt — the whole class of boot-race workarounds is deleted, and the display the caller set up survives the launch.

That last property is what makes the oscilloscope correct: a SID scene's job is to show the waveforms during playback, not just play a tune. So run_sid_player(defer_audio=True) loads the player silent (no $0314 swap yet); WaveformScene paints the hires scope (_setup_hires); then begin_sid_audio() fires the $0314 swap that actually starts INIT/PLAY — the scope is on screen before the first note. (On the Ultimate, run_prg re-inits VIC to text mode, so the bitmap is asserted after the player as it always was, and begin_sid_audio() is a no-op there — the gap is one frame.) The scene anchors its host-emu scope clock to sid_audio_start_time(), which each backend records at the instant audio actually started.

The vector-swap launch requires the IRQ-enabled idle, so it's gated on profile.supports_read (cycle-clean fw v0.7.2.5+ — ReadC64Mem and the cycle-clean DMA shipped together). On older firmware the spin-stub idle masks IRQs, so the swap would never fire — run_sid_player raises BackendCapabilityError rather than play silently. (The TR also has no REUWRITE opcode, so cli.py coerces any use_reu_pump / explicit use_reu_staged = true opt-in off on a no-REU backend, routing audio through the host-DMA NMI DAC and video through host-DMA; --doctor reports the same.)

Flicker blending flashes at 25/30 Hz, and cameras can't see it#

[color].flicker_tolerance makes the bitmap modes hold two screen pages and alternate them at the VIC field rate, so the eye fuses each cell's pair of hardware colors into a shade the machine cannot draw. It is "off" by default, and there are two reasons worth reading before turning it on.

In mhires only two of a cell's four colors can blend — the two the screen byte carries. The third lives in color RAM at $D800, which both fields read from the one copy, and the background is a single register; both stay real colors. It still pays better there than in hires, and on photographs rather than only on gradients, because four colors across a 4-pixel-wide cell leave spatial dither much less room to work with.

It is a flashing image. Each color is shown every other field, so a blended region alternates at 25 Hz on PAL and 30 Hz on NTSC — inside the frequency band recognized as a photosensitive-seizure risk (ITU-R BT.1702). What makes alternation hazardous is luminance modulation depth, which is exactly the same quantity that decides whether a pair reads as a color or as flicker. So [color].flicker_max_luma_delta is a safety control and nothing else: at the 0.075 default only ~21 of the 120 possible pairs clear it.

It warns rather than refuses. Past 0.10 it says pairs are starting to read as luminance flicker; past 0.12 it warns again, that being where modulation depth approaches the 20%-of-peak-white flash criterion the guidance is written around — and then proceeds either way. It clamped at 0.12 in an earlier build, which put a computed threshold above pairs a person had looked at and accepted; on the VIC-II rendering that withheld five of the eight pairs scored as fusing cleanly. Going past 0.12 is a deliberate decision to show a flashing image at a depth the guidance cautions against, so don't do it for anything an audience will see. NTSC's 30 Hz fuses noticeably better than PAL's 25 Hz.

A lower cap is not less flicker. Brightness distance turned out to be a poor predictor of whether a pair actually fuses — scored by eye over every admitted pair it correlates at r=+0.26, and the pair with the smallest possible separation visibly flickers while one near the top of the range reads as almost nothing. Nothing else computed from the two colors did better; a red-orange "warmth" axis, fitted to an earlier run, reached r=+0.32 and was removed when a blind sitting showed it excluding five of the eight steadiest pairs.

So the knob that buys steadiness is [color].flicker_tolerance, and it is not a metric at all — every pair the safety cap allows was scored by eye, blind, and the setting picks how far down that scale to go. "clean" takes only the pairs that fused, "subtle" adds mildly unsteady ones, and "visible" takes pairs that are meant to be seen flickering — an effect you chose, not a palette upgrade, and not for an audience. Pairs scored worse than that are recorded but offered by no setting: they reconstruct no better than "visible" does, so there is nothing to trade the flicker for.

Expect a smaller widened palette than the raw pair count suggests: of the 33 pairs the hard cap admits on an Ultimate 64, only 8 read as fused — 24 effective colors at "clean", and 21 of those at the default luma cap, since three of the eight need it raised toward 0.12. The scoring was done on an Ultimate 64, so pairs another host_palette brings under the cap but that sitting never judged are excluded rather than guessed at.

It does not survive being filmed. A capture card records individual fields, so it captures the flicker rather than the fusion — a 30 fps capture of a 50/60 Hz alternating display samples one page, or beats between them, and looks far worse than the plain palette would. This is not hypothetical: it is why the technique's best-known use, Dragon Breed's dragon, looks broken in 30 fps longplays and correct in 50 fps ones. If your output is a capture card, this feature costs you quality rather than adding it.

c64cast's own [preview] window and [recording] MP4 are the exception, and for once they are more faithful than a camera: they reconstruct from the outbound write stream rather than filming the screen, so they fuse the two pages arithmetically and show the blended color with no flicker at all. Use those to judge the result, not a capture of the HDMI output.

Because of the second point, this also can't be checked the usual way with the hw-visual-verify skill. Capture at 60 fps and average consecutive frame pairs offline, then compare against the preview's render.

Preview window fidelity + limits#

[preview] enabled = true opens a desktop window mirroring the C64. It is a reconstruction, not a capture: framebuffer.py shadows the memory writes c64cast sends and re-renders them host-side. What that costs you:

The host-DMA double_buffer page flip (and flicker_tolerance's own bank-swapping double buffer) writes both banks over the observable path, so the pixels ARE in the shadow — but the swap that decides which bank is live is a C64-side STA $DD00 inside the raster IRQ, which the host never issues and so never reaches add_write_listener either. render() follows the frame tracker's own pending-bank byte instead (see Framebuffer._vic_bank_base) — the same byte the IRQ itself reads to decide what to commit at the next safe vblank — rather than the (permanently stale) shadowed $DD00. That is exact whenever the real swap has already committed by the time something calls render(), which the raster gate bounds to at most one field of lag (see modes_irq.py).

Two mechanical constraints, both from cv2's HighGUI:

Closing the window is not a stop signal — playback continues headless, and the run stays stoppable: the join it falls through to polls, so Ctrl+C and SIGTERM still reach their handler (see session.join_bounded).

Char ROM substitution#

The C64 character ROM supplies every glyph c64cast draws as C64 text — the text overlays on bitmap modes, big_text, the on-C64 menu, the oscilloscope's labels, and the preview + recording renderers, which turn screen-code bytes back into 8×8 pixel cells.

c64cast reads it off your own machine on the first run and caches it at ~/.local/share/c64cast/roms/chargen.bin (see the reference guide, and --dump-char-rom / --install-char-rom). Until that has happened — or on a backend that can't run the dump stub — framebuffer.py falls back to a built-in 8×8 ASCII font: text stays readable, but it is not the C64 font, and PETSCII line-art and shaded blocks are approximations at best. That fallback is what "the scroller looks blocky/wrong" means in practice.

Set [preview] charset_path (or an overlay's charset_path) to force a specific file; leave it unset to use whatever c64cast resolved.

Ultimate 64 firmware version#

This project is developed against U64 firmware 3.x (3.14d/3.14e on the test hardware). Two transports are in play:

Older firmware may rename or omit endpoints; newer firmware sometimes tightens parameter validation. If a previously-working setup starts 500ing, run --skip-probe to bypass the reachability check and inspect the request bodies (-vv enables debug logging).

AudioStreamer shares the render path's Ultimate64API instance rather than opening its own. The U64 DMA service is single-connection only: a second concurrent TCP accept on port 64 succeeds, but its IDENTIFY round-trip never gets a reply, and the first connection continues to block subsequent ones for a few seconds after it closes. Sharing the API instance is safe because SocketDMAClient serializes every command on the wire via an internal lock, and the combined write rate (audio ≈8/sec + render ≈30-60/sec) sits well under the ≈200/sec DMA ceiling.

Writes go over Socket DMA, not REST#

Every memory write goes over the Ultimate DMA Service on TCP port 64 — a persistent socket protocol, opcode 0xFF06. There is no client write queue to tune: the TCP send buffer is the queue.

REST is used only for the operations that have no DMA equivalent (see the firmware section above): readmem, reset, run_prg, sidplay, the startup probe. Those are low-rate and one-shot.

Measured on U64 Elite II + firmware 3.x + wired LAN:

TransportPer-write latency (avg / p50 / p95)Sustained writes/sec
REST14.0 ms / 14.8 ms / 19.9 ms≈71/s
Socket DMA5.3 ms / 5.0 ms / 6.8 ms≈200/s

The persistent socket is what removes the per-write TCP handshake. The DMA service is single-connection only (socket_dma.cc accepts one connection at a time), so video and audio paths share a single Ultimate64API instance and let SocketDMAClient's per-command mutex serialize them on the wire. Ultimate64API.flush() is implemented as a trailing IDENTIFY round-trip — when it returns, every prior DMA command on the socket has been drained by the server.

Why REST can't carry writes#

Two firmware-level properties of the U64's REST server cap it at ≈50-70 writes/sec sustained, no matter how many client threads are thrown at it. Both measured against U64 Elite II on firmware 3.x over wired LAN in 2026-05; re-measure if a firmware release claims keep-alive or throughput improvements.

Under real workload (audio NMI firing, VIC raster IRQs, GIL pressure) the sequential floor rises from 14 ms to ≈20 ms per request, putting the sustainable rate at the lower end of that range. Parallelizing the writer cannot lift it either: a single-threaded server draining a FIFO turns extra workers into extra TCP setup, which is what the N=8 measurement above shows.

Design rules, which DMA raises the ceiling on but does not retire:

The reason count is the lever, measured: payload is free below ~2.4 KB. A write of 8 bytes and a write of 2 KB both cost ~5.2 ms, and only past that knee does cost start rising (~1.85 µs/byte). So splitting one write into two doubles its price no matter how few bytes each carries, while widening a write to cover a clean gap is free. That is the whole basis of write_region's chunking decision — see the cost model in the architecture notes, and re-measure with scripts/diags/link_cost_model.py. It is a per-link fact, not a general one: the TeensyROM+ charges ~0.29 ms per write and is otherwise all payload, so there bytes are the lever.

There is a sharper reason than throughput to keep writes off REST: a POST /v1/machine:writemem was observed to wedge the Ultimate's web server. Afterwards the unit still answered ping and the DMA socket on port 64 kept working normally, but every HTTP request was accepted and then dropped (curl exit 56) for 25+ minutes, until a power cycle. That takes readmem, reset and run_prg down with it — i.e. everything c64cast genuinely needs REST for. Socket DMA is not merely the faster write path; it is the one that doesn't put the REST surface at risk.

SID PLAY rides the kernal jiffy IRQ, which is ~60 Hz on both standards#

The C64-side SID player chains PLAY onto the kernal's CIA #1 Timer A IRQ. That interrupt is a wall-clock service (TI$, SCNKEY, cursor blink), not a frame interrupt, so the KERNAL programs it to ≈60 Hz on PAL as well as NTSC. A PAL vsync tune played through it therefore runs +19.7% fast — on a PAL machine too. Changing the Ultimate's System Mode does not fix this; it changes φ2, which is pitch, not tempo.

[ultimate64].sid_play_rate (default "auto") reprograms the latch to the tune's own frame rate. "off" restores the old behavior if you are used to hearing PAL tunes at NTSC speed, and an explicit number in Hz pins every vsync tune to one rate. CIA-timed (multispeed) tunes always self-time from their own INIT and are never overridden.

Full mechanism, including why the latch is written after INIT rather than before: docs/architecture/sid.md → "System timing".

Not every capture device survives 576p50#

PAL machine timing at the Ultimate's SD scan resolution puts 576p50 on the HDMI wire, and capture devices disagree about whether that is acceptable. Of two tested on the same machine and cable, one produced a torn/rolling picture in every PAL-timed mode and the other locked cleanly at ~25 fps delivered.

Raising HDMI Scan Resolution to HD (720p) or FullHD (1080p) fixed the failing device: what the capture card sees is the upscaler's output, not the C64's native timing. [ultimate64].hdmi_scan_resolution defaults to "auto", which raises SD to HD only when sid_video_mode retimed the machine — c64cast cleans up after its own change and leaves a machine it didn't retime alone. The four "PC" scan modes are passed through from the firmware but have not been tested under PAL timing.

If a video mode ever leaves you with no picture at all: hold C= and P (PAL) or C= and N (NTSC) at Ultimate boot to force System Mode back. Every video-output write c64cast makes is live + volatile, so a power cycle clears it regardless.

Composite output pays a different price than HDMI#

Retiming the machine changes the analog signal in a way the upscaler hides from HDMI. c64cast preserves the chroma encoding across a switch, so a set that decoded color before still can — but the field rate changes with the timing, and a single-standard analog display may simply not lock to it.

An NTSC-timed machine retimed to PAL emits NTSC-50: NTSC color at 50 Hz. A PAL-timed one retimed to NTSC emits PAL-60: PAL color at 60 Hz. PAL-60 is the friendlier of the two — most multi-standard sets and capture devices handle it — while NTSC-50 is rarer and more likely to come out monochrome or unstable. A pure NTSC set fed PAL-60 generally locks sync and loses color.

Audio is unaffected as a signal path; it leaves by the same connector either way. Its pitch changes, because φ2 did, which is the point.

If you run composite and picked one of the /L modes for its locked color subcarrier, note that a sid_video_mode run gives that up: every /L mode is a hybrid, and retiming a hybrid always lands on the other standard's plain mode, which has no locked form. Teardown restores it.

WaveformScene duration#

The U64's sidplay endpoint doesn't tell us when a tune ends, and the SID file itself doesn't carry song-length data. Ways to know how long to play a track:

  1. Set duration_s = N in the scene config (overrides any DB lookup).
  2. Configure [playlist] songlengths_file = "assets/sids/C64Music/DOCUMENTS/Songlengths.md5" and leave duration_s at the default. The HVSC SongLengths DB is keyed by an MD5 of the SID data payload (not the header) and covers most HVSC tunes — the file ships inside a full HVSC unpack at C64Music/DOCUMENTS/Songlengths.md5.
  3. Do nothing: if you unpacked HVSC under assets/sids/ (either the whole C64Music/ tree or just its contents), songlengths_file is auto-detected — see assets/sids/README.md. This is what quick playback (positional MEDIA args, no --config) relies on, since it has no [playlist] section to set the field in.

If none of these apply, the waveform scene defaults to 180 s — usually wrong for a specific tune.

WaveformScene defaults to half the video rate (DMA ceiling)#

WaveformScene renders 3 voice bitmap strips per frame, and because the trace moves every frame the per-region delta cache skips almost nothing, so each frame is ≈3 near-full strip uploads. At the full video rate (60 NTSC / 50 PAL) that is ≈170 writes/s — right at the ≈200/s DMA ceiling.

HW-verified 2026-06-09 on a real Ultimate-64: at ≈170 writes/s into a bank-2-relocated display (bitmap at $A000-$BFFF, the relocation target used when the SID payload overlaps bank 0's bitmap — see the bank 0↔2 relocation note) the U64 power-cycles itself mid-tune (reproduced with Times_of_Lore, a 2× multispeed Galway tune: clean for ≈50-90 s, then the DMA socket drops, the screen blacks out, and the machine physically powers off). A bank-0 tune at the same ≈170/s ran clean, and the same bank-2 tune at half rate (≈90 writes/s) played its full 7:40 length cleanly — so the trigger is the combination of high write rate and writes landing in the bank-2 region, and the host can only avoid it by lowering the write rate. A C64 powering itself off from legal memory writes is a U64 firmware/FPGA fault, not something the host causes through valid DMA.

Mitigation (shipped): WaveformScene.target_fps defaults to half the system video rate (30 NTSC / 25 PAL) instead of the full rate. An oscilloscope reads fine at half-rate, a half-integer divisor keeps the render an exact submultiple of the video standard (so the wallclock phase-lock stays clean), and it halves DMA to ≈90 writes/s with comfortable headroom. The host-emulator poll rate (_video_hz) is independent and stays at the full video rate so the scope still tracks every PLAY tick. An explicit target_fps in the CLI/TOML still overrides the default — but raising a bank-2 tune back toward 60 fps risks the power-off above.

Bitmap video/webcam scenes default lower when digitized audio streams#

The same DMA-ceiling reasoning applies to the frame-pushing scenes that can drive the 4-bit $D418 digitized-audio DAC — video, live webcam, and a generative scene with audio_source = "mic". A bitmap display (hires / hires_edges / mhires) re-uploads a full ≈9-10 KB frame every frame, and each DMA write halts the C64 bus for the duration of the transfer. When the digitized-audio DAC is also streaming (the audio worker writing the ring + the NMI consuming it), the two write streams compete for the bus and the picture tears at the full system rate.

Defaults (all overridable with an explicit target_fps):

These caps (config._frame_push_default_fps) are worth revisiting once the firmware no longer halts the CPU on DMA writes — see the U64 zero-halt DMA path notes.

LauncherScene runs a real program and only watches for input#

The launcher scene resets the U64, uploads a .prg (firmware /v1/runners:run_prg) or .crt cartridge (/v1/runners:run_crt) chosen by extension, and then hands the whole machine to it. From that point the program owns the VIC, SID, and CIAs — c64cast stops painting; the scene only polls for player input and times out. teardown() resets the machine so the next scene starts clean (mandatory for .crt, which run_crt leaves active). Consequences worth knowing:

> Unverified: whether the U64's DMA readmem of $DC00 returns the > live I/O register or RAM-under-I/O. The cia path depends on it. If a > hardware test shows it doesn't read the live register, switch the > default to kernal/auto.

> Known issue — intermittent TR launcher upload corruption (under > investigation). On the TeensyROM the keyboard poller's > ReadC64Mem $028D (and likely the launcher's own input poll) shares one > serial/TCP link with the launcher's reset() + PostFile. A poll read that > lands in the post-reset menu chatter (or reads a running program's state) > can desync the stream and leave stray bytes that make the next PostFile > drop a byte — the uploaded .prg then loads one byte short (BASIC autostart > stub lists garbage, ?SYNTAX ERROR). It's a race: intermittent, but when > it fires the symptom is consistent. The launcher works reliably > single-threaded (no concurrent poll) and on the Ultimate (no shared-link > poll), so reproducing it takes a TR+ under a live playlist — > nothing narrower will show it. Candidate fixes (not yet shipped): > make read_segment fully resync on any desync so no reader can poison a > later command; suspend the poller across the launcher's reset+upload; a > robust pre-upload drain. Needs a soak harness (hundreds of launch cycles) to > verify, since it can't be reproduced on demand.

WLED pixel sink (wled scene) needs an external sender#

The wled scene (WLED bridge Mode 2) turns the C64 into a virtual LED matrix that receives a realtime pixel stream. c64cast is the receiver — it does not generate the pixels. A sender must push frames to it: LedFx, xLights, Jinx!, Glediator, or another WLED device configured to sync. The WLED mobile app is a controller (it drives WLED devices) and cannot emit a pixel stream, so it can't feed this scene — use it for Mode 1 (the control surface), not Mode 2. With no sender running, the sink shows nothing until the first packet, then holds the last frame received.

WLED presets: cross-scene recall from the third-party app is best-effort#

WLED "presets" (bridge Mode 1) capture the current look and recall it in one tap. Recall is exact for a same-scene preset (the scene already live, params apply immediately). For a preset that targets a different scene, there's a timing gap: the scene jump isn't instant (the target scene tears down + sets up), so the stored sliders/palette/colors applied at recall time land on the outgoing scene and are lost when the new one comes up.

c64cast's own / control page handles this correctly — it re-fires the preset over its WebSocket once the target scene is live, so the params land on the new scene (this is why the / page uses /ws rather than polling). But the third-party WLED app just POSTs {ps:N} and can't orchestrate that wait, so a cross-scene recall from the app is best-effort: the scene jumps and power/ brightness restore, but the sliders/palette/colors may not stick. There is deliberately no server-side polling/daemon thread to paper over this (it was considered and dropped as the plan's flagged risk) — recall the preset from the / page for a perfect cross-scene restore, or drive same-scene presets from the app.

MIDI live-tune DJ transport: pad chords need note mappings, not MMC#

The Phase 3 loop_slot pad workflow (recall a saved loop on a plain press, save the current loop while Stop is held, clear a slot while Record is held — see the transport.py notes in architecture/control.md) relies on TransportSession seeing both a press AND a release for whichever button (Record or Stop) is being held down. MIDI Machine Control (MMC) has no release concept at all — a SysEx transport frame (F0 7F <dev> 06 <cmd> F7) always dispatches as a single momentary event (pressed=True), so an MMC-mapped Record/Stop button fires its own one-shot action correctly (arm a loop / close-pause-quit) but can never reliably chord with a pad: TransportSession._chord_active auto-expires a held flag after 5 seconds specifically so one MMC press can't wedge every later pad press as a false "clear" for the rest of the session — but that's a safety net, not a substitute for a real hold. Map Record and Stop as regular MIDI notes (the common case on a drum-pad-style controller anyway) if you want the save/clear pad chords; an MMC mapping still works fine for plain Record-arm / Stop-close-pause-quit presses and for the loop toggle / play-pause / RW / FF / jog actions that don't need a chord.

MIDI live-tune transport audio resync: splice latency + the mute-path tempo quirk#

Phase 4 makes a video's audio keep playing across every transport splice ([midi_control].loop_audio = "on", the default). A few properties are by design, not bugs:

Video-scene border is always restored to black ($00), not a configured value#

The Phase 3 record-armed red border (VideoScene._set_record_border) always restores to 0 rather than "whatever the border was before recording started." This is safe because every display mode VideoScene can use (hires, multi-hires, MCM) engages with a hardcoded $00 border and never rewrites $D020 again on its own (see modes.engage_bitmap_mode's docstring) — and [[scenes]].border (the config knob that lets a blank scene pick a border color) is explicitly scoped applies_to: ("blank",) and has no effect on a video scene. If a future display mode gives VideoScene a configurable non-black border, _set_record_border will need to capture and restore that value instead of assuming 0.

backgrounds.py constants are screen codes, not PETSCII#

PETSCII and the VIC screen-code encoding diverge above 0x40 — e.g. the @ character is PETSCII 0x40 but screen code 0x00. Anything that writes directly to $0400 (overlays, backgrounds) deals in screen codes, not PETSCII. The helper overlays.ascii_to_screen() does the conversion for ASCII text, which is the common case.

If you copy a constant out of a PETSCII reference table and notice it's painting the "wrong" character, that's the gap. Convert it.

C64_PALETTE_BGR is OpenCV BGR order#

palette.py stores the C64 palette as BGR (blue, green, red) tuples because OpenCV's frame format is BGR. If you ever extract a color from this table to display somewhere that expects RGB (matplotlib, PIL, a web page), swap channels first or you'll get yellow where you wanted blue.

Color shaping ([color]) is pre-quantization only in 3 of 4 modes#

The global [color] stage — a per-channel gain (channel_boost) plus hue-band corrections (hue_corrections) — runs before nearest-color quantization in MCM, MultiHires, and PETSCII, biasing the palette match toward C64-friendly hues. Hi-res mode skips it because its monochrome- per-cell pipeline is already binary.

channel_boost defaults to [1.3, 1.2, 1.0] (BGR): blue/green lift, red left neutral. Red is deliberately not cut: an A/B on real TRON frames showed a 0.9 red only raised perceptual (Lab) error and starved warm colors (yellow/red/purple), with no benefit to the blues the cut was meant to favor. Override per-config via [color].channel_boost.

This stage is orthogonal to palette_mode, which only chooses the VIC-II per-cell slot-allocation strategy. If you tune [color] and only some modes change, that's why (hires ignores it). [color].dither is a separate, independent pre-quantization stage that DOES apply to hires (as well as mcm and mhires) — see the section below.

[color].auto_fit — per-source adaptive fit (on by default)#

channel_boost and hue_corrections are the same nudge for every video. auto_fit (default true) is their per-source adaptive sibling: for video and slideshow scenes only, c64cast pre-scans the source (a quick downscaled decode → one luma histogram + mean saturation) and derives a contrast (levels) stretch plus a gentle saturation lift that expands the content to fill the C64 tonal + chroma range. The quantizer's target is always the fixed 16 colors and content that huddles in a corner of the gamut (dark, flat, or low-chroma — the common case for vintage videos) otherwise leaves most of the 16 colors unused and reads as muddy and monochromatic; the fit pushes it out so more of the palette gets used.

It is faithful — a luma stretch applied to all channels preserves hue (it expands what's there, it does not recolor — that's the deliberately-stylized follow-up, not this), and the saturation lift is floored at 1.0 (never desaturates). It is do-no-harm: black/white points come from the 1st/99th luma percentiles (outlier-robust), a minimum-span floor caps the contrast gain (≈8×) so a near-flat frame doesn't blow noise up to full contrast, and a well-exposed source resolves to an identity fit (no-op). auto_fit_strength (0..1) lerps the whole transform toward identity — auto_fit_strength = 0.0 is equivalent to off, handy for an A/B.

Where it runs: the scene computes one ColorFit per video (video) or per image (slideshow) and installs it on the display mode via set_color_fit; the mode applies it as the first step of compose/render, after the cheap downscale, so per-frame cost is two LUT passes. Webcam scenes never call set_color_fit (they can't pre-scan, and a per-frame fit would flicker), so the path is a no-op there — _color_fit stays None. See palette.ColorFitAccumulator / apply_color_fit and video.prescan_color_fit.

Floyd-Steinberg/Atkinson dither is a per-pixel Python loop — fine for stills, not for forced-on video#

[color].dither (default "auto") adds spatial dithering to mhires/mcm/hires before quantization. "ordered" (the Bayer 8×8 pattern) and "blue_noise" (a 64×64 void-and-cluster mask, dither.py) are both a single vectorized array op and hold realtime frame rates with no added shimmer — "auto" picks "blue_noise" for every motion scene (video/webcam/generative), since it has the same cost and stability as "ordered" but no visible grid/cross-hatch structure; "ordered" stays available as an explicit choice. "floyd-steinberg" and "atkinson" are a sequential per-pixel error-diffusion loop (dither.py): each pixel's quantization error is pushed onto its not-yet-visited neighbors, which is inherently non-vectorizable (every pixel's candidate distances depend on its predecessors' diffused error). "auto" only ever picks these for static scenes (slideshow — composed once per image, so the per-pixel cost is a non-issue), never for video/webcam/generative.

You can force dither = "floyd-steinberg" (or "atkinson") onto a motion scene explicitly, but two things follow: it's slower per composed frame (a Python loop over every cell's in-cell pixels, vectorized across cells but not across pixels-within-a-cell), and because each frame re-diffuses from scratch with no persisted state, the pattern is independent frame to frame — unlike the ordered family's fixed, position-deterministic offset, so it reads as shimmer on video even though any single frame looks great. This is a deliberate trade the config lets you make (e.g. a slow-motion or mostly-static video clip may look fine), not a bug — see the [color].dither note under modes/ in docs/architecture.md for the mechanism.

Scene.video_buffer.maxlen is "Optional[int]" to Pylance#

collections.deque(maxlen=8) has type deque[T], but deque.maxlen can be None at the type level (for the unbounded form). The webcam scene reads len(self.video_buffer) >= self.video_buffer.maxlen which Pylance flags as comparing an int to Optional[int]. The runtime is fine; the live scenes silence the warning with # type: ignore[operator]. Don't "fix" it by adding an if maxlen is not None guard — it's a Pylance limitation, not a real issue.

BASIC clear-and-loop is how the cursor stays hidden#

api.run_basic_clear_loop() POSTs 10 PRINT CHR$(147) : 20 GOTO 20 to the /v1/runners:run_prg endpoint at startup and on resume; a TeensyROM+ link launches the same program its own way and repairs it over DMA when the launcher leaves it unlinked at $0801. The PRINT CHR$(147) clears the screen + homes the cursor; the infinite GOTO 20 keeps BASIC out of the editor's direct-input mode, which is what keeps the kernal cursor-blink IRQ suppressed (the editor is what toggles $CC; while BASIC is busy looping, the blink never re-arms). Don't poke $CC and screen RAM directly from Python — the kernal cursor IRQ at $EA87 races the write and re-paints stale state. Let BASIC own it.

Licensing of SIDs, videos, and ROMs#

c64cast ships none of the following — you provide them:

"Why doesn't --list-devices show my webcam?"#

OpenCV on macOS sometimes fails to enumerate AVFoundation cameras without Privacy & Security → Camera permission granted for the terminal running the script. Grant the permission once, then --list-devices will show numbered entries.

On Linux, the device index corresponds to /dev/video<N> — a USB camera that re-enumerates may shift indices between reboots.

Optional-deps groups can silently degrade#

Installed without extras, the package imports fine but most features are disabled. Failure modes:

If something feels missing, check c64cast --doctor's EXTRAS section, then reinstall naming every extra you want at once — extras don't accumulate: uv tool install --force 'c64cast[all]'. See the User's Guide, "Installing c64cast".

Single-scene mode is automatic, not opt-in#

When the loaded playlist defines exactly one scene, the Playlist auto-enters single-scene mode: no interstitial, no CTRL-skip (it's silently dropped), and the one scene loops forever via teardown+setup.

Two surprises:

  1. [playlist] interleave_videos = true with a single-scene config and a populated videos directory does not insert videos — the loader logs an info line and short-circuits because inserting a video would promote the playlist to 2 scenes and silently defeat the mode.
  2. CTRL key presses (and HTTP POST /skip) are no-ops while running. C= pause/resume still works.

If you want videos or CTRL-skip back, define at least 2 scenes in your config.