c64cast

Extending c64cast

This guide covers the four pluggable surfaces — Overlays, Scenes, DisplayModes, and interstitial Backgrounds — plus the testing patterns that keep the suite hardware-free. For runtime behavior see architecture.md; for end-user config see the Programmer's Reference Guide.

Conventions#

Adding an Overlay#

Overlays are the most common extension point — drop a file into c64cast/overlays/, register it under a config name, implement three methods.

Minimal example#

# c64cast/overlays/blink.py
"""Blink the border color between two C64 colors at a configurable rate."""
from __future__ import annotations

from ..palette import C64_COLORS
from . import Overlay, register


@register("blink")
class BlinkOverlay(Overlay):
    REQUIRES_PETSCII = False          # bitmap-safe; only touches $D020
    REQUIRES_AUDIO = False

    def __init__(self, color_a: str = "black", color_b: str = "white",
                 hz: float = 1.0):
        self.color_a = C64_COLORS[color_a]
        self.color_b = C64_COLORS[color_b]
        self.hz = float(hz)
        self._last_state = None

    def setup(self, api, scene):
        self._last_state = None

    def process_frame(self, api, scene, t):
        state = int(t * 2 * self.hz) & 1
        if state == self._last_state:
            return                          # no traffic if nothing changed
        api.write_regs("D020", self.color_b if state else self.color_a)
        self._last_state = state

    def teardown(self, api, scene):
        api.write_regs("D020", C64_COLORS["black"])

Then register it in the loader's auto-import block (otherwise the @register decorator won't run and the loader can't find it):

# c64cast/overlays/__init__.py — inside _load_all()
from . import (
    ...,
    blink,        # <-- add this
    ...
)

Use it from config:

[[scenes.overlays]]
type = "blink"
color_a = "black"
color_b = "yellow"
hz = 2.0

Restriction flags#

These are validated by overlays.validate_for_scene (invoked from config._attach_overlays in config.py) at config-load time, not at the first frame.

Painting into screen / color RAM (compose)#

The blink example above pokes a VIC register directly from process_frame(), which is the right path for register writes. But an overlay that paints PETSCII screen codes to $0400 / $D800 should not use process_frame + write_region — it would race the scene's own screen write and flicker. Instead, set PAINTS_INTO_BUFFERS = True and implement compose():

def compose(self, buffers, scene, t):
    # buffers["screen"] and buffers["color"] are uint8 numpy arrays of
    # length 1000 (40×25 cells). Mutate them in place; the scene uploads
    # the composed result once per frame.
    buffers["screen"][row * 40 + col] = some_screen_code
    buffers["color"][row * 40 + col] = C64_COLORS["white"]

The Playlist skips process_frame() for PAINTS_INTO_BUFFERS overlays — the scene invokes compose() on each attached overlay during its render path, so scene + overlays produce a single composed frame that's pushed in one upload. compose() is only called when the scene's display mode supports it (PETSCII / blank). See corner_text.py and marquee.py for working examples. There is no region-ID allocation to do: buffer-painting overlays write into the shared screen/color arrays, not via per-overlay write_region slots.

Reusing shared bases#

Testing an overlay#

Tests use a FakeAPI (in tests/_fakes.py) and FakeAudio (in tests/test_overlays.py) — no hardware involved. Pattern:

def test_blink_emits_on_state_change(self):
    api = FakeAPI()
    overlay = build_overlay({"type": "blink", "hz": 1.0}, audio=None)
    overlay.setup(api, scene=None)
    overlay.process_frame(api, scene=None, t=0.0)
    self.assertEqual(api.regs["D020"], (C64_COLORS["black"],))
    overlay.process_frame(api, scene=None, t=0.51)
    self.assertEqual(api.regs["D020"], (C64_COLORS["white"],))

Hit at minimum: registry lookup (build_overlay returns the right class), restriction validation (validate_for_scene raises when appropriate), and one positive-path render.

Adding a Scene#

Scenes are bigger lifts than overlays — a Scene is responsible for producing one frame of content per process_frame() call (the overlays paint over it). Most users won't need to add scenes; if you do, subclass Scene from scenes.py.

Skeleton#

# c64cast/scenes.py (or your own module)
class MyScene(Scene):
    def __init__(self, api, audio, display_mode, name="My scene"):
        super().__init__(api, audio, display_mode, name)
        self.target_fps = 30.0   # only set if your scene can't sustain system rate

    def setup(self):
        super().setup()
        self.display_mode.setup(self.api)
        # Allocate any per-scene resources here (threads, hardware regs).

    def process_frame(self, current_time: float) -> bool:
        frame_bgr = self._produce_frame()    # whatever your source is
        cropped = _crop_to_aspect(frame_bgr)
        self.display_mode.render(self.api, cropped)
        # Don't run overlays — the Playlist does that for you.
        return True                          # False = scene is finished

    def teardown(self):
        super().teardown()
        # Stop threads, restore hardware state, etc.

Wire it into the config loader#

Open config.py and add a branch in scenes_from_config:

elif s.type == "my_scene":
    mode = _build_display_mode(s.display)
    scene = MyScene(api, audio, mode, s.name or "My scene")

Then add any custom config fields to SceneCfg (also in config.py) so they round-trip through TOML.

Things to honor#

The Playlist wraps your setup/process_frame/teardown calls with overlay calls; your scene doesn't need to know overlays exist.

Adding a DisplayMode#

Display modes are how a frame becomes VIC bytes. Adding one is rare; when needed, subclass DisplayMode from modes.py:

class MyDisplayMode(DisplayMode):
    name = "mymode"
    is_bitmapped = False     # True if you write $2000 instead of $0400/$D800

    def setup(self, api):
        super().setup(api)                # drops the delta cache for you
        # Put the VIC into the right mode: bank, $D011, $D016, $D018, etc.
        api.write_memory("d018", "...")

    def render(self, api, frame_bgr):
        # frame_bgr is a (H, W, 3) uint8 in OpenCV BGR order.
        # Quantize to whatever your mode needs, then push:
        api.write_region(0x0400, screen_bytes, region_id=REG_SCREEN)
        api.write_region(0xD800, color_bytes,  region_id=REG_COLOR)

Wire it into the loader's mode factory:

# config.py — _build_display_mode
if name == "mymode":
    return MyDisplayMode()

Performance tips (these are what makes the bundled modes hit 30+ fps):

Adding an Interstitial Background#

Backgrounds are the parallax decoration that plays between scenes (during the "UP NEXT: …" interstitial). Add one in backgrounds.py:

@register("mybg")                  # decorator sets cls.name + adds to REGISTRY
class MyBackground(Background):
    def _fill(self, chars, colors, t, rows, bg_color):
        # chars and colors are uint8 arrays of length 1000 (40×25), already
        # filled with SC_SPACE / bg_color. Only paint cells inside `rows`.
        for y in rows:
            for x in range(40):
                idx = y * 40 + x
                chars[idx]  = SC_FULL
                colors[idx] = C64_COLORS["light blue"]

The @register("mybg") decorator (mirroring the overlay pattern) sets the class name and adds it to REGISTRY for you — no manual dict edit.

Use it from the [interstitial] config:

[interstitial]
background = "mybg"             # or "random" to mix yours in

"random" rotation excludes "none" automatically; your new background will be one of the random picks.

Adding a CLI flag#

The pattern in cli.py is "argparse default=None, plus an entry in CLI_TO_CFG":

# cli.py — inside _parser()
g_audio.add_argument(
    "--my-knob",
    type=float, default=None,
    help="What it does (must be 0.0-1.0).",
)
# config.py — CLI_TO_CFG
"my_knob": ("audio", "my_knob"),

The merge function (merge_cli) skips fields where the CLI value is None, so absence is distinguishable from "user passed the default." Don't break that — never use a non-None argparse default for an override-able flag, or you'll permanently shadow the TOML value.

Adding a control-plane endpoint#

The FastAPI app in control_plane.py speaks to the Playlist via threading.Events. To add an action:

  1. Add an Event to Playlist.__init__ and a handler in the run loop.
  2. Register an endpoint:
   @app.post("/freeze")
   def freeze():
       playlist.freeze_event.set()
       return {"ok": True}
  1. If the action should also be triggerable from the C64 keyboard, extend keyboard.py (you'd need a new modifier-key edge or a chord).

Keep pause / resume / skip / reload semantics — they're the documented surface.

Testing patterns#

The suite (python -m unittest discover tests) runs in CI with no hardware. To keep it that way:

A good new test file looks like tests/test_overlays.py: one unittest.TestCase per surface, fakes at the top, three-to-six small test_* methods.

Where things live (cheat sheet)#

What you're addingWhere it goesWire-up
Overlayc64cast/overlays/yours.py@register("yours") + add to _load_all() in overlays/__init__.py
Scenec64cast/scenes.py (or new file)branch in config.scenes_from_config + optional SceneCfg fields
DisplayModec64cast/modes.pybranch in config._build_display_mode
Backgroundc64cast/backgrounds.py@register("yours") decorator
CLI flagc64cast/cli.pydefault=None + entry in config.CLI_TO_CFG
Control-plane endpointc64cast/control_plane.pynew event on Playlist + handler in the run loop
Testtests/test_*.pyFakeAPI (tests/_fakes.py) + FakeAudio (tests/test_overlays.py) reusable