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.

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/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.

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.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; only $D400 is audible. 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").

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.

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 (doctor._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.)

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:

Two mechanical constraints, both from cv2's HighGUI:

Closing the window is not a stop signal — playback continues headless.

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:

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.py 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. 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.