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 usage.md; for the architecture overview see architecture.md.
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 that replaces the
hard noise gate, pre-emphasis, mic AGC — is ON by default. [audio] noise_gate and mic_sensitivity are the legacy shaping knobs; noise_gate
only takes effect when [dsp] enabled = false. Hum and hiss are still part
of the aesthetic.
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 (it kept the DAC capped at ≈16 kHz and
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.
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_seconds → VideoScene._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.
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.
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):
$37(default + resting) — BASIC ROM + KERNAL ROM + I/O all mapped. Used for ordinary tunes, including ones that deliberately read BASIC ROM as a data table (e.g. Galway's Comic Bakery), and is the environment tunes assume between calls.$36— BASIC ROM banked out (LORAM=0), KERNAL + I/O kept. Used for the call (init and/or play) whose entry point lives under BASIC ROM ($A000-$BFFF). Without it,JSR initwould execute BASIC ROM bytes instead of the tune — e.g. Matt Gray's Hyperion 2 (load/init$AE2A) lands on the ROM's SYNTAX-error stub at$AF08and prints?SYNTAX ERROR IN 10(theSYSline), with no music. KERNAL stays mapped so the$EA31IRQ chain still works.
This replaces two earlier dead ends: an unconditional $36 (broke
Comic Bakery and Last Ninja 2), and a per-tune-but-permanent $36
(set once, never restored — crashed tunes like Election ≈24 s in because
they assume the $37 resting environment between PLAY calls, and the
$36/$37 choice for "data under ROM, entry points in RAM" tunes proved
undecidable offline). Banking per call sidesteps both. The re-INIT stub
(SHIFT subtune cycling) carries the same per-call banking.
Known limitations:
- PSID only. RSIDs expect their own raster IRQ and don't cooperate with kernal chaining — they're refused with a clear error.
load_addrmust be ≥$0820. The BASIC SYS stub occupies$0801-$0811; SIDs that load any lower are refused (the threshold is rounded up to$0820for safety margin).play_addrmust be non-zero. Tunes whose INIT installs their own IRQ instead of exposing a PLAY entrypoint are refused.- No code/data under KERNAL ROM (
$E000-$FFFF). The player keeps KERNAL mapped to chain its$EA31IRQ tail, so it can't bank KERNAL out to expose RAM there — such tunes are refused. (Tunes under BASIC ROM at$A000-$BFFFare supported; see "Per-tune memory banking" above.) - PAL/NTSC speed flag is ignored. The kernal's default CIA #1 Timer
A latch is left alone, so PAL tunes on an NTSC kernal play ≈20% fast
(and vice versa). A future enhancement could reprogram the timer
based on the SID's
speed_flags.
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.
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.
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.
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):
- Frame-fit ceiling. The handler's per-frame cost (per-op overhead +
0x30waits, summed across all chips) must fit the frame period. Realistic content fits — it's how the tune runs natively — but a pathological 8-SID × 16× frame can overrun and queue ticks, an inherent limit like the NMI DAC cycle budget. - Coarse cycle delay. The on-C64 busy-wait approximates each
0x30wait_cycleswithin a few cycles (≈DELAY_CYCLES_PER_UNITper unit) — far better than dropped/instant, a refinement target if it ever matters audibly. - Frame grouping assumes chip 0 (
0x4E) is written every frame in multi-SID (the emit boundary); single-SID is unaffected. Costs one frame of constant latency. - Real-time cadence, open-loop lead. The ASID host feeds frames in real time
at exactly the consume cadence, so the ring's write-ahead lead can never grow
past the startup prebuffer (unlike the FPGA sampler, whose demuxer races ahead
of real time). The prebuffer is therefore seeded to the full lead for maximum
jitter cushion; a genuine producer stall pads a hold (the SID holds its
last state — graceful, no echo). Two consequences learned on hardware: the
player arms lazily — the read-head clock +
$0314swap wait for the prebuffer, or the read head runs away before the host starts streaming and every real frame lands in an already-consumed slot; and a0x31speed message is honored before arming, or the player would arm at the wrong (initial video-rate) cadence and decimate the tune to that rate. HW-verified on the U64-II: at 2× multispeed the buffered audio modulates at exactly 2× the coalesced path's rate (spectral-flux measured).
The host-side orchestration above (parse / layout / build / divider
auto-tune / subtune re-INIT) is backend-agnostic and shared via
_SidPlayerBackend 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] 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:
- Only the modes c64cast draws — standard text, MCM text, hires bitmap, multicolor bitmap. Sprites, raster splits, and anything a running C64 program draws for itself aren't modelled.
- Launcher scenes show nothing. A
.prg/.crtrenders on the C64 with no host-side pixel writes at all, so the window (and the recording) stay blank. - REU-staged bitmap scenes show black. With
[video].use_reu_stagedactive (the default on a REU-enabled U64 for hires/mhires), frames go out viaREUWRITEand a C64-side IRQ DMAs them into the off-screen VIC bank. Those writes never reachadd_write_listener, so the shadow never sees the pixels.[video].use_reu_staged = falseroutes them back through host DMA and the preview fills in — verified on hardware. The same blind spot applies to the host-DMAdouble_bufferpage flip, for a second reason:render()reads the bitmap from a fixed$2000/$0400, so it doesn't model$DD00bank selection at all. Teaching the shadow about banks (and notifying it from the REU path) is what a fully faithful preview would need. - Text needs a real CHARGEN dump — see below.
- It is not visual verification. Proving what the VIC actually put on
HDMI needs a capture device (the
hw-visual-verifyskill), not this.
Two mechanical constraints, both from cv2's HighGUI:
- The window lives on the main thread. Desktop windows may only be created
and serviced from the process's main thread — a hard Cocoa requirement on
macOS, where an off-thread
namedWindowraises "Unknown C++ exception from OpenCV code" out of the first call. Playlists all run on worker threads, socli._pump_previews_until_donepumps the window from the parked main thread. (This is why the window is not a background thread like the recorder: the pre-cv2 pygame implementation was threaded, and therefore never actually worked on macOS.) - A headless opencv build has no GUI.
namedWindowthen throws; the window logs an error, disables itself, and the session runs on.
Closing the window is not a stop signal — playback continues headless.
[preview] charset_path points at the C64 character ROM
(characters.901225-01.bin, 4 KB). The preview window and the
recording path both use it to render screen-code bytes back to actual
8×8 pixel cells.
If the file is missing, framebuffer.py falls back to a built-in
8×8 ASCII font — text is still readable but PETSCII line-art,
shaded blocks, and inverse video look wrong (they render as the
corresponding ASCII control codes' glyphs, which is to say "garbage").
For an accurate preview, drop a real CHARGEN dump in
assets/roms/characters.901225-01.bin; see
assets/roms/README.md for sources.
This project is developed against U64 firmware 3.x (3.14d/3.14e on the test hardware). Two transports are in play:
- Socket DMA (TCP port 64) carries every memory write — opcode
0xFF06 DMAWRITE. Must be enabled in U64 settings (F2 → Network Settings → Ultimate DMA Service → Enabled) beforec64castwill start; the CLI prints an actionable error otherwise. - REST carries the operations that have no DMA equivalent:
GET /v1/machine:readmem— keyboard poller, waveform scene, U64-ping overlayPUT /v1/machine:reset— between scenesPOST /v1/runners:run_prg— BASIC clear-and-loop at startup/resume, plus theSYS 49920stub that kicks the SID playerGET /— startup reachability probe
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.
The U64's REST server caps sustained writes at ≈50-70/sec because of a
combination of Connection: close (every PUT pays a fresh TCP
handshake) and server-side request serialization — see the historical
section below for the full measurements. The fix landed in 2026-05: the
project now sends every memory write over the Ultimate DMA Service
on TCP port 64 (a persistent socket protocol; opcode 0xFF06).
Measured impact on U64 Elite II + firmware 3.x + wired LAN:
| Transport | Per-write latency (avg / p50 / p95) | Sustained writes/sec |
|---|---|---|
| HTTP (was) | 14.0 ms / 14.8 ms / 19.9 ms | ≈71/s |
| Socket DMA (is) | 5.3 ms / 5.0 ms / 6.8 ms | ≈200/s |
The persistent socket eliminates 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.
REST is still used for the operations that have no DMA equivalent (see
the firmware section above): readmem, reset, run_prg, sidplay,
the startup probe. These are low-rate and one-shot, so the HTTP wall
doesn't apply to them.
The CLI no longer exposes --async-writes / --queue-depth — they
were HTTP-pipeline knobs and have no meaning under DMA. The TCP send
buffer is the queue.
Resolved 2026-05 by the Socket DMA migration above; this section is kept as background on why we moved off REST for writes.
The U64's REST server has two firmware-level properties that together cap how fast we can push state to it. Both were measured against U64 Elite II on firmware 3.x over wired LAN in 2026-05; re-measure if a new firmware claims throughput improvements.
- Every response includes
Connection: close. The server refuses HTTP keep-alive, even when explicitly asked via a request header. This meansrequests.Sessioncannot pool connections — every PUT / POST pays a fresh TCP handshake. The measured floor is ≈14 ms per request sequential (p50 14.8 ms, max 20 ms over 50 samples), of which most is TCP setup, not actual write processing. - The server serializes concurrent requests internally. Running
N=8 parallel writers over the same
requests.Sessionproduces effective throughput of 65 writes/s — slightly worse than the sequential rate of 71/s, because the requests just queue at the server with extra TCP-setup overhead. Per-request latency under concurrency scales linearly with worker count (8 workers → ≈115 ms per request), which is the signature of a single-threaded server draining a FIFO.
The practical ceiling is therefore ≈50-70 writes/sec sustained regardless of how many client threads we throw at it. 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.
Implications for design:
- Parallelizing the async write worker won't help. This was
considered as a follow-up to the profiling work and ruled out — the
experiment is in the
Connection: closemeasurement above. The single-worker FIFO in api.py is the right architecture for this server. - Reducing write count is the only real lever.
write_regscoalesces N contiguous register writes into one PUT; use it liberally.write_regionskips writes when the buffer is unchanged vs. the per-region cache; this is why static char-mode scenes are cheap (most frames write nothing). Any new code path that issues N small writes per frame should be a yellow flag. - A scene targeting >30 fps with ≥2 writes/frame will saturate the
pipe. big_text at 50 fps with 1 write/frame is the canonical
example — it produces exactly 50 writes/s, which is the wall, so
every frame's enqueue blocks on the previous frame's HTTP. The
classic mitigation is to cap such a scene at
target_fps = 30(or redesign so per-frame state lives in U64 RAM and is consumed by a raster IRQ, so Python only writes when something macro-level changes). - To re-measure HTTP (e.g. to check whether a new firmware has
added keep-alive), temporarily revert the writes back to
requests.putand run--profile. Today the profile summary line isu64 dma latency: n=N avg=… p50=… p95=… max=… msand reports the DMA path, not REST.
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:
- Set
duration_s = Nin the scene config (overrides any DB lookup). - Configure
[playlist] songlengths_file = "assets/sids/C64Music/DOCUMENTS/Songlengths.md5"and leaveduration_sat 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 atC64Music/DOCUMENTS/Songlengths.md5. - Do nothing: if you unpacked HVSC under
assets/sids/(either the wholeC64Music/tree or just its contents),songlengths_fileis auto-detected — seeassets/sids/README.md. This is what quick playback (positionalMEDIAargs, 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 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.
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):
- Bitmap + digitized audio → 20 fps (both NTSC and PAL). The aggressive cap that keeps the combined DMA load under the bus-halt ceiling.
- Always-fresh source + digitized audio → 20 fps in any mode, char modes
included.
generativeand livewebcamrender a new frame every tick, so unlikevideothey have nothing to dedup: a char mode still rewrites the whole screen (plus colour RAM onmcm) every frame, over the same DMA socket the audio ring uses. Anmcmgenerative scene with DAC audio is a noisy mess at 60 fps and clean at 20 (HW 2026-07-25). Sampler audio is off-bus and does not trigger this. - Bitmap, no digitized audio → half rate (30 NTSC / 25 PAL). A muted
bitmap video / no-mic bitmap webcam still pushes full frames, so it gets the
same half-rate treatment as
WaveformScene. - Bitmap video on the Ultimate Audio sampler → full system rate
(60 NTSC / 50 PAL). The FPGA PCM sampler plays straight from REU with zero
bus involvement and forces the bus-clean REU-staged (bank-swap) video path,
so neither bus-halt cap above applies. The full-rate value is a ceiling:
because
VideoSceneonly re-pushes a frame when the source yields a new one (dedup), the effective push rate equals the source video's fps — a 24 fps clip pushes 24/s, a 60 fps clip 60/s — i.e. source-rate playback, capped at the VIC refresh. HW-verified on .64 (no added shimmer on real ≤30 fps content; audio clean at a genuine 60/s push). - Char modes (petscii / blank) → unchanged. A 1 KB screen that the per-region delta cache mostly skips is cheap, so these keep the playlist system default (60 NTSC / 50 PAL).
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.
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:
-
duration_sis an idle timeout, not a runtime. It resets on player input, so an actively-played game stays up and an untouched demo runs the full window.min_duration_sis a floor; the optionalmax_duration_sis a hard ceiling that advances regardless of input (unset = no cap, so a game played non-stop never advances on its own). -
Input detection deliberately excludes the modifier keys c64cast already scans (Commodore / SHIFT / CTRL at
$028D) — those drive pause/skip/style and must not count as "player active."input_sourcepicks what does count:cia($DC00/$DC01 joystick bits, default),kernal($00C5 last-key + $00C6 buffer length),auto(both), ornone. -
Idle detection is best-effort. A program that runs its own keyboard-matrix scan drives
$DC00as an output, sociareads can race that scan — producing false "activity" (the scene won't advance untilmax_duration_s) or missing a keypress.kernalmode is clean but only works while the program leaves the kernal IRQ intact (BASIC games, kernal-friendly demos); it's blind once a program installs its own IRQ — which most action games and demos do. There is no universally-reliable "any input" signal under an arbitrary program; joystick games onciaare the most dependable case.Unverified: whether the U64's DMA
readmemof$DC00returns the live I/O register or RAM-under-I/O. Theciapath depends on it. If a hardware test shows it doesn't read the live register, switch the default tokernal/auto. -
Single program only. No disk (
.d64/.d81), tape (.t64/.tap), or multi-disk games — there's no mounting or disk-swap. Cartridge-type support depends on the firmware; PAL/NTSC is not switched; the local preview/recording windows show nothing (no host-side pixel writes). -
bypass_audio_locklets several players hear their own games. In ensemble mode at most one system normally holds the exclusive audio slot, so an audio-bearing scene on another system is skipped while it's taken (the audio lock inc64cast/ensemble.py). That's wrong for interactive launchers — two people at two machines both want to play and hear their own game. Settingbypass_audio_lock = trueon a launcher makes it neither claim nor wait on the slot, so multiple launchers run concurrently, each driving its own SID. No effect on a single-system run. -
TR launch/upload errors surface the firmware's reason. When the TR NAKs a command,
teensyrom_dma._expect_acknow captures the trailing text the firmware emits and puts it in the raised error —TRBusyErroron a"Busy!"reply (a program is running / the menu handler isn't active), and the literal text ("Not enough room","File already exists.", …) appended otherwise — instead of a bare token code. This is what makes the failure below diagnosable.Known issue — intermittent TR launcher upload corruption (under investigation, pre-existing). 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'sreset()+ 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.prgthen 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), which is why it was never caught — the TR launcher had not been HW-exercised under the live playlist. Candidate fixes (not yet shipped): makeread_segmentfully 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.
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.
- The matrix size is a real config knob.
sink_width/sink_height(default 320×200) must match the pixel layout the sender is configured for — a WLED sender streams a fixed pixel count, and the sink assembles exactly that. A mismatch renders garbage or a partial frame (the assembler clips rather than erroring). The display mode then downscales whatever size you pick to the C64 grid, so a small matrix (e.g. 64×48) is perfectly fine and much lighter on the wire — 320×200 RGB is 192 KB/frame, which is a lot of UDP for 20 fps. - Both standard ports are bound at once (DDP 4048 + WLED realtime 21324). If another process already holds one, the scene logs the bind error and self-aborts so the playlist advances — it does not crash the run.
- E1.31/sACN is deliberately not implemented (a documented follow-up). Multi-universe reassembly (170 px/universe) is a lot of protocol surface for a format LedFx and xLights can both emit as DDP instead, so DDP + WLED-realtime cover the ecosystem for far less code.
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.
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.
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:
- Splice click. There is no crossfade in v1. Both the DAC and sampler splice boundaries are DC-silence (NEUTRAL), so any click at a seek/loop wrap is only the content discontinuity (the new position's waveform not matching the old), not a NEUTRAL step. An obvious v2 improvement is a ≈5 ms host-side fade-in on the first post-flush chunk.
- DAC splices cut over after the constant ring latency. On the 4-bit
$D418DAC path ([audio].backend = "dac"),flush()does not stomp the ring at a seek/loop wrap — the servo-held ≈4096-byte gap (≈0.3-0.5 s) is the accepted constant output latency, so the not-yet-heard approach to the splice point finishes playing while fresh audio lands behind it. The audio therefore cuts over a constant ≈0.3-0.5 s after the video jumps, identical every splice — no silence hole, no mid-phrase chop, but not frame-tight either. - Sampler splices play ≤
FLUSH_GUARD_S(0.15 s) past the splice point. The samplerflush()leaves a small guard between the computed read head and the first rewritten byte (covers open-loop read-head jitter + REUWRITE latency), so pre-splice content plays at most ≈0.15 s past the cut. This is also where the open-loop consumed-estimate drift (≈0.16 s per 10 min worst case) is absorbed; a very-late-scene splice could in principle show an artifact if that drift ever exceeds the guard. loop_audio = "mute"is the permanent fallback. It reproduces the Phase-2 escape valve bit-for-bit (audio mutes for the rest of the scene's run the moment transport is touched, and the clock switches to a self-owned wall anchor). Use it if the resync splices ever misbehave on unusual hardware/firmware.- Mute-path DAC+bitmap tempo quirk (unchanged). On the
"mute"path over the DAC+bitmaptempo_scale(≈0.88) content, the wall-clock anchor runs at 1× while the video PTS timeline is scaled — so after transport is touched the video plays ≈1.14× (audio is muted, so only the picture is affected). This is pre-existing Phase-2 behavior, out of scope for Phase 4 ("mute reproduces today bit-for-bit"), and does not occur on the default"on"path, whose audio-anchored clock stays in the scaled domain.
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.
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.
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.
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. The historical default cut red to 0.9, but an A/B on real
TRON frames showed that only raised perceptual (Lab) error and starved warm
colors (yellow/red/purple) with no benefit to the blues it was meant to
favor — so red is now neutral. 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.
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.
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).
Earlier code tried to write $CC and screen RAM directly from Python,
but the kernal cursor IRQ at $EA87 races the write and re-paints stale
state. Don't go back to poking $CC directly — let BASIC own it.
c64cast ships none of the following — you provide them:
- SID files — HVSC is the canonical archive. The HVSC license permits free non-commercial use; commercial use requires per-tune permission from each composer. Don't blindly include HVSC tunes in a Twitch VOD if you ever plan to monetize.
- Videos (the
videoscene type) — your problem. If you're playing a Coca-Cola ad at VCFSW for nostalgia, fair use is probably defensible; on a recorded stream the publisher may disagree. - CHARGEN ROM (
characters.901225-01.bin) — Commodore copyright. Distributed widely with C64 emulators under various legal gray-area arrangements; the project's stance is "you have a real C64, dump it from yours." The built-in ASCII fallback exists so the codebase can run without it at all.
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.
If you pip install -e . (no extras), the package imports fine but
most features are disabled. Failure modes:
[audio] enabled = truewithout[mic]extra →AudioStreamerraisesImportErrorat scene setup.type = "video"without[video]extra → loader logs "Found N video files but PyAV is not installed; skipping videos" and the playlist runs without videos.[control]enabled without[control]extra → control plane silently disabled with a warning.type = "midi"without[midi]extra → scene setup raisesRuntimeError("MidiScene requires mido + python-rtmidi").type = "obs_status"overlay without[obs]extra → loader rejects the overlay with a clearRuntimeErrorat config load.
If something feels missing, double-check pip install -e .[all].
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:
[playlist] interleave_videos = truewith 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.- 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.