From f1ffb7ea2e641e66ba72d03f875551680c9bd4e9 Mon Sep 17 00:00:00 2001 From: Kelly Fox Date: Mon, 3 Aug 2026 17:52:35 -0500 Subject: [PATCH] docs(books): mark the live parameters, give the card its column back MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The introduction's Notation section promised that a parameter marked *live* could be moved during a show. Nothing was marked anywhere. Two different things wear that word, so they are marked apart. Appendices A and B now mark a field *live-tunable* and name the target a knob reaches it by; the join is an explicit alias map rather than a bare-name match, because [color].dither is mode.dither_method (a name match would miss it, and would falsely mark [audio].dither, which is the DAC's noise shaping). A field whose apply metadata is "live" — the metadata the on-C64 menu builds its panel from, read but never printed until now — is marked *menu-live*. palette_mode carries both, which is why the wording has to distinguish them. Notation says which is which; the menu section points at the mark. Appendix E prints a generator's or effect's parameters as the target string a cc_map takes (source.speed, not speed), so a line can be copied into a mapping unchanged and matches Appendix F verbatim. The card's live-target page gets *Declared by* back. The data was already in the LiveTargetDoc the builder read and discarded; raw owners will not fit a 3.5in column (source.speed has fourteen), so it compresses to the sole owner or a count in the group's noun. Without it a performer has no way to know that a knob on source.ring_freq is dead unless moire2 is on screen. Still two pages. --- CHANGELOG.md | 13 ++ docs/card/01-controls.md | 3 + docs/card/02-live-targets.md | 68 +++---- docs/reference/01-introduction.md | 11 +- docs/reference/07-inputs-and-outputs.md | 5 + docs/reference/20-appendix-a-configuration.md | 14 +- docs/reference/21-appendix-b-scene-types.md | 44 ++--- .../24-appendix-e-generators-effects.md | 58 +++--- scripts/gen_reference_appendices.py | 173 +++++++++++++++--- tests/test_reference_appendices.py | 88 +++++++++ 10 files changed, 353 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9960a596..fe7e35a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -177,6 +177,19 @@ the version and stamps it with the date. `c64cast[all]` rather than one extra at a time. The chapters have always named an extra where a feature needs one; nothing collected them. The glossary moves to Appendix J. +- **The books keep the promise their notation section makes.** A setting that + can move while a show is running now says so where it is defined: Appendices A + and B mark a field *live-tunable* and name the target a knob reaches it by + (`[color].dither` is `mode.dither_method`, which is exactly the pairing a + reader could not guess), and mark it *menu-live* when the on-C64 menu carries + it as a knob. Appendix E writes each generator's and effect's parameters the + way a `cc_map` has to spell them — `source.speed`, not `speed` — so a line can + be copied straight into a mapping. +- **The performance card's live-target list says who declares each target.** A + knob mapped to `source.ring_freq` does nothing unless `moire2` is the + generator on screen, and the column that says so was the one the card dropped + for space. It is back, compressed to fit: the sole owner where there is one, + otherwise a count — `14 generators`. Still two pages. ### Fixed diff --git a/docs/card/01-controls.md b/docs/card/01-controls.md index a86a3d10..5700ca22 100644 --- a/docs/card/01-controls.md +++ b/docs/card/01-controls.md @@ -92,6 +92,9 @@ over a fixed set of values it selects across the set, and a pad steps through it. A target the running scene does not have is a silent no-op, so a whole knob bank can stay mapped across a mixed playlist. +*Declared by*, overleaf, is who has a target — a name when only one does, else +a count. `source.ring_freq` says `moire2`: dead until `moire2` is on screen. + | Holder | Reaches | |---|---| | `source` | The generative scene's generator | diff --git a/docs/card/02-live-targets.md b/docs/card/02-live-targets.md index b252f2a4..102e4396 100644 --- a/docs/card/02-live-targets.md +++ b/docs/card/02-live-targets.md @@ -8,46 +8,46 @@ generated: true ## Color pipeline -| Target | Range | -|---|---| -| `mode.auto_fit_strength` | 0 – 1 | -| `mode.color_match` | 2 values | -| `mode.dither_strength` | 0 – 2 | -| `mode.dither_method` | 5 values | -| `mode.palette_mode` | 4 values | -| `mode.motion_smoothing` | 0 – 1 | -| `mode.cell_strategy` | 4 values | +| Target | Range | Declared by | +|---|---|---| +| `mode.auto_fit_strength` | 0 – 1 | 3 modes | +| `mode.color_match` | 2 values | 4 modes | +| `mode.dither_strength` | 0 – 2 | 3 modes | +| `mode.dither_method` | 5 values | 3 modes | +| `mode.palette_mode` | 4 values | 2 modes | +| `mode.motion_smoothing` | 0 – 1 | `mhires` | +| `mode.cell_strategy` | 4 values | `mhires` | ## Effect -| Target | Range | -|---|---| -| `effect.decay` | 0 – 0.96 | -| `effect.intensity` | 0 – 2.5 | -| `effect.duty` | 0.05 – 1 | -| `effect.rate` | 1 – 16 | -| `effect.mix` | 0 – 1 | -| `effect.axis` | 3 values | -| `effect.levels` | 2 – 32 | +| Target | Range | Declared by | +|---|---|---| +| `effect.decay` | 0 – 0.96 | `trails` | +| `effect.intensity` | 0 – 2.5 | 3 effects | +| `effect.duty` | 0.05 – 1 | `strobe` | +| `effect.rate` | 1 – 16 | `strobe` | +| `effect.mix` | 0 – 1 | `invert` | +| `effect.axis` | 3 values | `mirror` | +| `effect.levels` | 2 – 32 | `posterize` | ## Generator -| Target | Range | -|---|---| -| `source.speed` | 0 – 2 | -| `source.scale` | 0.1 – 4 | -| `source.scroll_speed` | 0 – 4 | -| `source.intensity` | 0.2 – 2 | -| `source.zoom_speed` | 0.02 – 1 | -| `source.cycle_speed` | 0 – 2 | -| `source.ring_freq` | 10 – 80 | -| `source.drift_speed` | 0 – 2 | -| `source.pulse_speed` | 0 – 3 | -| `source.a` | -2 – 2 | -| `source.grow_speed` | 0 – 4 | +| Target | Range | Declared by | +|---|---|---| +| `source.speed` | 0 – 2 | 14 generators | +| `source.scale` | 0.1 – 4 | 11 generators | +| `source.scroll_speed` | 0 – 4 | `fire` | +| `source.intensity` | 0.2 – 2 | `fire` | +| `source.zoom_speed` | 0.02 – 1 | `mandelbrot` | +| `source.cycle_speed` | 0 – 2 | `mandelbrot` | +| `source.ring_freq` | 10 – 80 | `moire2` | +| `source.drift_speed` | 0 – 2 | 3 generators | +| `source.pulse_speed` | 0 – 3 | `halo` | +| `source.a` | -2 – 2 | `hopalong` | +| `source.grow_speed` | 0 – 4 | `rorschach` | ## Scope -| Target | Range | -|---|---| -| `scene.gain` | 0.25 – 3 | +| Target | Range | Declared by | +|---|---|---| +| `scene.gain` | 0.25 – 3 | `voice_scope` | diff --git a/docs/reference/01-introduction.md b/docs/reference/01-introduction.md index 63bb69dd..084a5413 100644 --- a/docs/reference/01-introduction.md +++ b/docs/reference/01-introduction.md @@ -68,8 +68,15 @@ number does not. Where a key takes one of a fixed set of values, the set is listed in full; where it takes a free string, the shape of that string is given by example. -A parameter marked *live* can be moved while a show is running, by a MIDI -knob, a pad, or the web console. Appendix F lists every one of them. +Two marks say that a setting can change while a show is running, and they are +different powers. A parameter marked *live-tunable* can be moved by a MIDI +knob, a pad or the web console; the mark names the `holder.name` target string +that reaches it, and Appendix F lists every one of them. A key marked +*menu-live* is one the on-C64 menu offers as a knob, applying the change to the +running scene; a key without the mark is not in the panel at all, because +changing it would mean rebuilding the scene. A setting can carry both: a +scene's `palette_mode` is on a MIDI knob as `mode.palette_mode` and is in the +menu. A command-line flag is written as it is typed, with its leading dashes, as `--config`. Flags are introduced beside the behaviour they change rather than diff --git a/docs/reference/07-inputs-and-outputs.md b/docs/reference/07-inputs-and-outputs.md index 0c33a1b2..a3b5ebe1 100644 --- a/docs/reference/07-inputs-and-outputs.md +++ b/docs/reference/07-inputs-and-outputs.md @@ -55,6 +55,11 @@ cursor keys drive the menu instead. On exit with unsaved changes, into the configuration you launched from; `false` applies them to the running scene and never persists, which is what a convention stand wants. +Which knobs the panel offers is a property of the key rather than of the menu: +the ones Appendices A and B mark *menu-live* are the ones a running scene can +take a change to in place. Everything else would need the scene rebuilt, so it +is not in the panel. + It needs a backend that can read memory — the Ultimate, or a cycle-clean TeensyROM+ — because SPACE is not a modifier and has to be read out of the kernal's keyboard buffer. Text-valued parameters are shown read-only. diff --git a/docs/reference/20-appendix-a-configuration.md b/docs/reference/20-appendix-a-configuration.md index 168e65c3..adf55547 100644 --- a/docs/reference/20-appendix-a-configuration.md +++ b/docs/reference/20-appendix-a-configuration.md @@ -8,7 +8,7 @@ generated: true *Generated from the code by `scripts/gen_reference_appendices.py`. Edits here are overwritten; run `make reference-appendices`.* -Every section of a configuration file: 19 sections and 149 fields, with the type each takes and the value it holds when you say nothing. `c64cast --describe section:NAME` prints any one of these at the terminal. +Every section of a configuration file: 19 sections and 149 fields, with the type each takes and the value it holds when you say nothing. A field a knob can move mid-show says so, and names the target Appendix F lists it under. `c64cast --describe section:NAME` prints any one of these at the terminal. ## `[hardware]` @@ -191,14 +191,14 @@ Global pre-quantize color shaping for mcm/mhires/petscii: static channel boost + | **`hue_corrections`**
`list[dict[str, Any]]`
`[]` | List of [[color.hue_corrections]] bands applied before quantize (keys: hue_lo_deg, hue_hi_deg, sat_thresh, val_thresh, sat_mult, val_mult, hue_target_deg, name). Empty = built-in purple rescue only. | | **`hue_corrections_replace_defaults`**
`bool`
`False` | If true, user hue_corrections REPLACE the built-in defaults instead of extending them. | | **`auto_fit`**
`bool`
`True` | Per-source adaptive color fit for video + slideshow scenes: pre-scan the source and stretch its contrast + saturation to fill the C64 gamut (faithful — hue preserved). Ignored by webcam scenes (can't pre-scan). | -| **`auto_fit_strength`**
`float`
`1.0` | Strength of the auto_fit transform, 0..1 (1 = full, 0 = off). Lerps the derived stretch toward identity. | +| **`auto_fit_strength`**
`float`
`1.0` | Strength of the auto_fit transform, 0..1 (1 = full, 0 = off). Lerps the derived stretch toward identity. *Live-tunable* while a show runs, as `mode.auto_fit_strength` — Appendix F. | | **`force_palette`**
`bool`
`False` | EXTREME forced-palette remap (mcm/mhires): k-means the source into N clusters and map each to a DISTINCT C64 color so all N colors are used. Pre-scanned for video + slideshow; adapts live (rolling, warm-start + hysteresis) for webcam/wled/generative. Deliberate false-color (NOT faithful) — off by default; also reachable via the SHIFT cycle's 'percell+forced' stop once enabled. Tip: `--suggest-palette FILE` ranks a good force_palette_colors set. | | **`force_palette_colors`**
`int \| list[int \| str]`
`16` | How force_palette allocates C64 colors: either an int count of distinct colors to spread the source across (2..16), OR an explicit list of colors to whitelist — each a color name (fuzzy + case-insensitive, e.g. "light blue", "lgrn", "blk") or an index 0..15. A list's length sets the color count. | -| **`dither`**
`str`
`'auto'` | Spatial dither applied before nearest-palette quantization on mhires/mcm/hires. 'auto' picks the best method that's actually useful for the scene: floyd-steinberg (highest quality) for static scenes (slideshow), blue_noise (vectorized, temporally stable — no added shimmer, and no Bayer grid structure) for motion scenes (video/webcam/generative). Any value can be forced on any scene; floyd-steinberg/atkinson are a Python-level per-pixel loop and can shimmer frame-to-frame on motion; 'ordered' (Bayer) is the older motion default and still available if the cross-hatch pattern is wanted (see docs/caveats.md). Choices: `auto`, `none`, `ordered`, `blue_noise`, `floyd-steinberg`, `atkinson`. | -| **`dither_strength`**
`float`
`0.5` | Dither strength, roughly 0..2.0. For 'ordered'/'blue_noise' it scales the threshold spread (same scale for both, so switching between them doesn't need a strength retune); for floyd-steinberg/atkinson it scales how much of each pixel's quantization error is diffused to its neighbors (1.0 = the textbook kernel weights). | -| **`color_match`**
`str`
`'auto'` | Color space for the nearest-palette match on the quantizing modes (mcm/mhires/hires/petscii). 'perceptual' measures nearest-color in CIE-Lab (perceptually uniform — picks the color the eye calls closest, e.g. a warm gray → orange/brown, not muddy gray). 'rgb' is the classic brightness-weighted BGR metric. Both keep the channel_boost + gray-penalty shaping; only the distance space differs. 'auto' (default) picks perceptual on every quantizing mode (a no-op on hires edges / blank, which pick no colors). Choices: `auto`, `rgb`, `perceptual`. | -| **`cell_strategy`**
`str`
`'auto'` | How mhires percell mode fills each 4×8 cell's 3 per-cell color slots from the colors present in that cell. 'frequency' = the 3 most-common (temporally stable). 'luminance' = darkest/median/brightest (preserves a cell's full tonal span). 'contrast' = the two luma extremes plus the color farthest from both. 'error-min' = the trio minimizing the cell's reconstruction error (best quality, costlier). 'auto' (default) uses error-min for static scenes (slideshow — composed once) and frequency for motion scenes (video/webcam/generative, where frequency's stability avoids per-frame slot churn). Only affects mhires with palette_mode=percell. Choices: `auto`, `frequency`, `luminance`, `contrast`, `error-min`. | -| **`motion_smoothing`**
`float`
`0.25` | Temporal smoothing for mhires percell mode, 0..1. The percell path smooths its per-cell color choices over time (an EMA over color counts plus per-pixel/per-cell decision hysteresis) to suppress frame-to-frame flicker on noisy video. That smoothing trades motion-tracking for stability, so on a hard shot cut an outline from the previous shot lingers as an after-image for a moment. 1.0 (full smoothing) is the most stable but ghostiest; 0.0 tracks the source exactly (no after-image) but can flicker on grainy content. The default 0.25 was picked by hardware A/B as the best ghost/flicker balance. Lower it if after-images still bother you, raise it if motion shimmers. No effect on other modes or palette_modes. | +| **`dither`**
`str`
`'auto'` | Spatial dither applied before nearest-palette quantization on mhires/mcm/hires. 'auto' picks the best method that's actually useful for the scene: floyd-steinberg (highest quality) for static scenes (slideshow), blue_noise (vectorized, temporally stable — no added shimmer, and no Bayer grid structure) for motion scenes (video/webcam/generative). Any value can be forced on any scene; floyd-steinberg/atkinson are a Python-level per-pixel loop and can shimmer frame-to-frame on motion; 'ordered' (Bayer) is the older motion default and still available if the cross-hatch pattern is wanted (see docs/caveats.md). Choices: `auto`, `none`, `ordered`, `blue_noise`, `floyd-steinberg`, `atkinson`. *Live-tunable* while a show runs, as `mode.dither_method` — Appendix F. | +| **`dither_strength`**
`float`
`0.5` | Dither strength, roughly 0..2.0. For 'ordered'/'blue_noise' it scales the threshold spread (same scale for both, so switching between them doesn't need a strength retune); for floyd-steinberg/atkinson it scales how much of each pixel's quantization error is diffused to its neighbors (1.0 = the textbook kernel weights). *Live-tunable* while a show runs, as `mode.dither_strength` — Appendix F. | +| **`color_match`**
`str`
`'auto'` | Color space for the nearest-palette match on the quantizing modes (mcm/mhires/hires/petscii). 'perceptual' measures nearest-color in CIE-Lab (perceptually uniform — picks the color the eye calls closest, e.g. a warm gray → orange/brown, not muddy gray). 'rgb' is the classic brightness-weighted BGR metric. Both keep the channel_boost + gray-penalty shaping; only the distance space differs. 'auto' (default) picks perceptual on every quantizing mode (a no-op on hires edges / blank, which pick no colors). Choices: `auto`, `rgb`, `perceptual`. *Live-tunable* while a show runs, as `mode.color_match` — Appendix F. | +| **`cell_strategy`**
`str`
`'auto'` | How mhires percell mode fills each 4×8 cell's 3 per-cell color slots from the colors present in that cell. 'frequency' = the 3 most-common (temporally stable). 'luminance' = darkest/median/brightest (preserves a cell's full tonal span). 'contrast' = the two luma extremes plus the color farthest from both. 'error-min' = the trio minimizing the cell's reconstruction error (best quality, costlier). 'auto' (default) uses error-min for static scenes (slideshow — composed once) and frequency for motion scenes (video/webcam/generative, where frequency's stability avoids per-frame slot churn). Only affects mhires with palette_mode=percell. Choices: `auto`, `frequency`, `luminance`, `contrast`, `error-min`. *Live-tunable* while a show runs, as `mode.cell_strategy` — Appendix F. | +| **`motion_smoothing`**
`float`
`0.25` | Temporal smoothing for mhires percell mode, 0..1. The percell path smooths its per-cell color choices over time (an EMA over color counts plus per-pixel/per-cell decision hysteresis) to suppress frame-to-frame flicker on noisy video. That smoothing trades motion-tracking for stability, so on a hard shot cut an outline from the previous shot lingers as an after-image for a moment. 1.0 (full smoothing) is the most stable but ghostiest; 0.0 tracks the source exactly (no after-image) but can flicker on grainy content. The default 0.25 was picked by hardware A/B as the best ghost/flicker balance. Lower it if after-images still bother you, raise it if motion shimmers. No effect on other modes or palette_modes. *Live-tunable* while a show runs, as `mode.motion_smoothing` — Appendix F. | ## `[dsp]` diff --git a/docs/reference/21-appendix-b-scene-types.md b/docs/reference/21-appendix-b-scene-types.md index a3190a49..92ddcd35 100644 --- a/docs/reference/21-appendix-b-scene-types.md +++ b/docs/reference/21-appendix-b-scene-types.md @@ -8,7 +8,7 @@ generated: true *Generated from the code by `scripts/gen_reference_appendices.py`. Edits here are overwritten; run `make reference-appendices`.* -The 10 kinds of scene a `[[scenes]]` block can be, and the keys each one reads. `c64cast --describe scene:NAME` prints any one of these at the terminal. +The 10 kinds of scene a `[[scenes]]` block can be, and the keys each one reads. A key marked *live-tunable* can be moved by a knob mid-show; one marked *menu-live* is one the on-C64 menu can change without rebuilding the scene. `c64cast --describe scene:NAME` prints any one of these at the terminal. ## Keys Every Scene Takes @@ -17,9 +17,9 @@ These apply whatever the scene's `type` is. The per-type sections below list onl | Key | Description | |---|---| -| **`type`**
`str`
`'webcam'` | Scene kind. | +| **`type`**
`str`
`'webcam'` | Scene kind. Choices: `webcam`, `blank`, `video`, `waveform`, `midi`, `asid`, `slideshow`, `launcher`, `generative`, `wled`. | | **`name`**
`str \| None`
`None` | Display name (shown in interstitials/logs; ensemble match key). | -| **`target_fps`**
`float \| None`
`None` | Per-scene frame-rate cap; unset = playlist default (60/50). Bitmap (hires/mhires) video/webcam/generative scenes default lower to stay under the DMA bus-halt ceiling: 20 fps while streaming digitized audio, else half rate (30/25). Generative and webcam scenes take that 20 fps cap in CHAR modes too whenever audio is on the 4-bit DAC — they repaint every tick (no dedup), so the frame writes contend with the audio ring for the DMA socket. Off-bus Ultimate Audio sampler playback keeps the high default. Waveform/midi/asid default to half rate too. | +| **`target_fps`**
`float \| None`
`None` | Per-scene frame-rate cap; unset = playlist default (60/50). Bitmap (hires/mhires) video/webcam/generative scenes default lower to stay under the DMA bus-halt ceiling: 20 fps while streaming digitized audio, else half rate (30/25). Generative and webcam scenes take that 20 fps cap in CHAR modes too whenever audio is on the 4-bit DAC — they repaint every tick (no dedup), so the frame writes contend with the audio ring for the DMA socket. Off-bus Ultimate Audio sampler playback keeps the high default. Waveform/midi/asid default to half rate too. *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | | **`overlays`**
`list[dict[str, Any]]`
`[]` | List of overlay tables ([[scenes.overlays]]); see `--list-overlays`. | | **`orchestrate`**
`bool`
`False` | Ensemble: make this system the conductor and broadcast this scene to all others (requires name; ignored single-system). | | **`follower_only`**
`bool`
`False` | Ensemble: exclude from normal rotation; used only as a broadcast follower override (requires name; excludes orchestrate). | @@ -34,15 +34,15 @@ Display modes: `hires_edges`, `hires`, `mhires`, `mcm`, `petscii`, `blank`. | Key | Description | |---|---| | **`display`**
`str \| None`
`None` | VIC-II display mode. Unset resolves per scene type: 'mhires' for video (richest bitmap mode, suits arbitrary film/photo content) and 'hires_edges' for webcam/blank/slideshow/generative (tuned for live Canny-edge stylization). waveform and midi are bitmap-only (both ignore this); slideshow also accepts 'random'. generative renders a frame so any quantizing mode works (not 'blank'/'random'). Choices: `hires_edges`, `hires`, `petscii`, `mcm`, `mhires`, `blank`, `random`. | -| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). | +| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | | **`audio`**
`bool \| None`
`None` | Per-scene audio override. Unset follows [audio].enabled; false mutes this scene only. | | **`effect`**
`str \| None`
`None` | Pixel effect applied to the frame before quantization (unset = none). Works on any frame-bearing scene. 'trails' echoes moving content; 'pulse' beat-punches the zoom; 'rgb_shift' slews the color channels apart on a transient. pulse/rgb_shift only visibly react on a music-reactive scene (generative + audio_source = 'sid'); elsewhere they're inert (no feature stream to react to). Choices: `trails`, `pulse`, `rgb_shift`, `blur`, `strobe`, `invert`, `mirror`, `posterize`. | | **`effects`**
`list[str]`
`[]` | Ordered pixel-effect chain applied before quantization, e.g. effects = ["trails", "rgb_shift", "strobe"]. Each is one of the `effect` choices; layers apply in order and are individually tunable (map a CC to fx0./fx1.…) and bypass-toggleable live (fx_toggle). Mutually exclusive with the single `effect` field. Empty = none. Choices: `trails`, `pulse`, `rgb_shift`, `blur`, `strobe`, `invert`, `mirror`, `posterize`. | | **`mod_source`**
`str`
`'audio'` | What drives this scene's reactive effect layers: 'audio' (the SID feature stream — needs a music-reactive scene, i.e. generative + audio_source = 'sid'), 'clock' (the [performance] beat grid, so effects lock to MIDI/tap tempo on any scene — the way to tempo-lock a 'strobe'), or 'off' (never react — layers use their static baseline). Applies to every effect layer on the scene. Choices: `audio`, `clock`, `off`. | | **`pre_emphasis`**
`float \| None`
`None` | Per-scene HF pre-emphasis (0 = off, ~0.3-0.7 typical; brightens speech). Unset = global [dsp].pre_emphasis / source-aware default. Needs [dsp].enabled + scene audio. | -| **`palette_mode`**
`str`
`'percell'` | VIC-II slot-allocation strategy for mcm/mhires display (ignored by other modes): percell (default), cheap, vivid, grayscale. Color shaping (channel boost + hue corrections, e.g. the purple rescue) is the global [color] section, applied to every mode. Choices: `percell`, `cheap`, `vivid`, `grayscale`. | +| **`palette_mode`**
`str`
`'percell'` | VIC-II slot-allocation strategy for mcm/mhires display (ignored by other modes): percell (default), cheap, vivid, grayscale. Color shaping (channel boost + hue corrections, e.g. the purple rescue) is the global [color] section, applied to every mode. Choices: `percell`, `cheap`, `vivid`, `grayscale`. *Live-tunable* while a show runs, as `mode.palette_mode` — Appendix F. *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | | **`text_double_height`**
`bool`
`False` | On mhires, render text overlays (clock/marquee/…) at double height — 16px / 2 cell rows — for across-the-room legibility. Text is always double-WIDE on mhires (8x8 glyph spans 2 of the 4px cells); this toggle adds the vertical stretch. Ignored on other display modes. | -| **`style`**
`str`
`'default'` | PETSCII glyph/color style (only when display = 'petscii'); 'random' picks one at setup. Choices: `default`, `halftone`, `random_glyph`, `letter_rain`, `neon`, `inverse_pop`, `hatch`, `color_only`, `random`. | +| **`style`**
`str`
`'default'` | PETSCII glyph/color style (only when display = 'petscii'); 'random' picks one at setup. Choices: `default`, `halftone`, `random_glyph`, `letter_rain`, `neon`, `inverse_pop`, `hatch`, `color_only`, `random`. *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | ## `blank` @@ -54,7 +54,7 @@ Display modes: `blank`, `hires_edges`. | Key | Description | |---|---| | **`display`**
`str \| None`
`None` | VIC-II display mode. Unset resolves per scene type: 'mhires' for video (richest bitmap mode, suits arbitrary film/photo content) and 'hires_edges' for webcam/blank/slideshow/generative (tuned for live Canny-edge stylization). waveform and midi are bitmap-only (both ignore this); slideshow also accepts 'random'. generative renders a frame so any quantizing mode works (not 'blank'/'random'). Choices: `hires_edges`, `hires`, `petscii`, `mcm`, `mhires`, `blank`, `random`. | -| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). | +| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | | **`audio`**
`bool \| None`
`None` | Per-scene audio override. Unset follows [audio].enabled; false mutes this scene only. | | **`pre_emphasis`**
`float \| None`
`None` | Per-scene HF pre-emphasis (0 = off, ~0.3-0.7 typical; brightens speech). Unset = global [dsp].pre_emphasis / source-aware default. Needs [dsp].enabled + scene audio. | | **`border`**
`int \| str`
`0` | Border color (blank scenes): a C64 color name (fuzzy + case-insensitive, e.g. "light blue") or a palette index 0..15. | @@ -77,9 +77,9 @@ Display modes: `mhires`, `hires_edges`, `hires`, `mcm`, `petscii`, `blank`. | **`effects`**
`list[str]`
`[]` | Ordered pixel-effect chain applied before quantization, e.g. effects = ["trails", "rgb_shift", "strobe"]. Each is one of the `effect` choices; layers apply in order and are individually tunable (map a CC to fx0./fx1.…) and bypass-toggleable live (fx_toggle). Mutually exclusive with the single `effect` field. Empty = none. Choices: `trails`, `pulse`, `rgb_shift`, `blur`, `strobe`, `invert`, `mirror`, `posterize`. | | **`mod_source`**
`str`
`'audio'` | What drives this scene's reactive effect layers: 'audio' (the SID feature stream — needs a music-reactive scene, i.e. generative + audio_source = 'sid'), 'clock' (the [performance] beat grid, so effects lock to MIDI/tap tempo on any scene — the way to tempo-lock a 'strobe'), or 'off' (never react — layers use their static baseline). Applies to every effect layer on the scene. Choices: `audio`, `clock`, `off`. | | **`pre_emphasis`**
`float \| None`
`None` | Per-scene HF pre-emphasis (0 = off, ~0.3-0.7 typical; brightens speech). Unset = global [dsp].pre_emphasis / source-aware default. Needs [dsp].enabled + scene audio. | -| **`palette_mode`**
`str`
`'percell'` | VIC-II slot-allocation strategy for mcm/mhires display (ignored by other modes): percell (default), cheap, vivid, grayscale. Color shaping (channel boost + hue corrections, e.g. the purple rescue) is the global [color] section, applied to every mode. Choices: `percell`, `cheap`, `vivid`, `grayscale`. | +| **`palette_mode`**
`str`
`'percell'` | VIC-II slot-allocation strategy for mcm/mhires display (ignored by other modes): percell (default), cheap, vivid, grayscale. Color shaping (channel boost + hue corrections, e.g. the purple rescue) is the global [color] section, applied to every mode. Choices: `percell`, `cheap`, `vivid`, `grayscale`. *Live-tunable* while a show runs, as `mode.palette_mode` — Appendix F. *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | | **`text_double_height`**
`bool`
`False` | On mhires, render text overlays (clock/marquee/…) at double height — 16px / 2 cell rows — for across-the-room legibility. Text is always double-WIDE on mhires (8x8 glyph spans 2 of the 4px cells); this toggle adds the vertical stretch. Ignored on other display modes. | -| **`style`**
`str`
`'default'` | PETSCII glyph/color style (only when display = 'petscii'); 'random' picks one at setup. Choices: `default`, `halftone`, `random_glyph`, `letter_rain`, `neon`, `inverse_pop`, `hatch`, `color_only`, `random`. | +| **`style`**
`str`
`'default'` | PETSCII glyph/color style (only when display = 'petscii'); 'random' picks one at setup. Choices: `default`, `halftone`, `random_glyph`, `letter_rain`, `neon`, `inverse_pop`, `hatch`, `color_only`, `random`. *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | ## `waveform` @@ -88,7 +88,7 @@ Display modes: `mhires`, `hires_edges`, `hires`, `mcm`, `petscii`, `blank`. | Key | Description | |---|---| -| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). | +| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | | **`file`**
`str \| None`
`None` | Asset spec (comma-separated paths/dirs/globs). Videos for video, .sid for waveform, images for slideshow, .prg/.crt for launcher, .sid for generative when audio_source = sid. | | **`song`**
`int`
`0` | SID subtune index to play (0 = the SID's default; 1-based otherwise). For generative scenes, only with audio_source = sid. | | **`color_mode`**
`str`
`'per_voice'` | Oscilloscope coloring: fixed per voice, or by current waveform type. Choices: `per_voice`, `per_waveform`. | @@ -106,7 +106,7 @@ Live MIDI input → SID synth + 3-voice oscilloscope (bitmap-only). | Key | Description | |---|---| -| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). | +| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | | **`color_mode`**
`str`
`'per_voice'` | Oscilloscope coloring: fixed per voice, or by current waveform type. Choices: `per_voice`, `per_waveform`. | | **`voice_colors`**
`list[str]`
`[]` | Per-voice trace colors (C64 color names) for color_mode=per_voice. | | **`waveform_colors`**
`dict[str, str]`
`{}` | Per-waveform-type colors (e.g. pulse=cyan) for color_mode=per_waveform. | @@ -134,7 +134,7 @@ Play an incoming ASID MIDI stream on the real SID + 3-voice oscilloscope (bitmap | Key | Description | |---|---| -| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). | +| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | | **`color_mode`**
`str`
`'per_voice'` | Oscilloscope coloring: fixed per voice, or by current waveform type. Choices: `per_voice`, `per_waveform`. | | **`voice_colors`**
`list[str]`
`[]` | Per-voice trace colors (C64 color names) for color_mode=per_voice. | | **`waveform_colors`**
`dict[str, str]`
`{}` | Per-waveform-type colors (e.g. pulse=cyan) for color_mode=per_waveform. | @@ -157,16 +157,16 @@ Display modes: `mhires`, `hires`, `hires_edges`, `mcm`, `petscii`, `random`. | Key | Description | |---|---| | **`display`**
`str \| None`
`None` | VIC-II display mode. Unset resolves per scene type: 'mhires' for video (richest bitmap mode, suits arbitrary film/photo content) and 'hires_edges' for webcam/blank/slideshow/generative (tuned for live Canny-edge stylization). waveform and midi are bitmap-only (both ignore this); slideshow also accepts 'random'. generative renders a frame so any quantizing mode works (not 'blank'/'random'). Choices: `hires_edges`, `hires`, `petscii`, `mcm`, `mhires`, `blank`, `random`. | -| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). | +| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | | **`file`**
`str \| None`
`None` | Asset spec (comma-separated paths/dirs/globs). Videos for video, .sid for waveform, images for slideshow, .prg/.crt for launcher, .sid for generative when audio_source = sid. | | **`image_duration_s`**
`float`
`5.0` | Per-image dwell time before advancing (total runtime is duration_s). | | **`aspect_mode`**
`str`
`'crop'` | How each image is fit to the C64 4:2.5 aspect: 'crop' (center-crop to fill — the default, edges lost), 'fit' (letterbox/pillarbox so the whole image shows, padded black), or 'stretch' (distort to fill, no padding or cropping). Choices: `crop`, `fit`, `stretch`. | | **`effect`**
`str \| None`
`None` | Pixel effect applied to the frame before quantization (unset = none). Works on any frame-bearing scene. 'trails' echoes moving content; 'pulse' beat-punches the zoom; 'rgb_shift' slews the color channels apart on a transient. pulse/rgb_shift only visibly react on a music-reactive scene (generative + audio_source = 'sid'); elsewhere they're inert (no feature stream to react to). Choices: `trails`, `pulse`, `rgb_shift`, `blur`, `strobe`, `invert`, `mirror`, `posterize`. | | **`effects`**
`list[str]`
`[]` | Ordered pixel-effect chain applied before quantization, e.g. effects = ["trails", "rgb_shift", "strobe"]. Each is one of the `effect` choices; layers apply in order and are individually tunable (map a CC to fx0./fx1.…) and bypass-toggleable live (fx_toggle). Mutually exclusive with the single `effect` field. Empty = none. Choices: `trails`, `pulse`, `rgb_shift`, `blur`, `strobe`, `invert`, `mirror`, `posterize`. | | **`mod_source`**
`str`
`'audio'` | What drives this scene's reactive effect layers: 'audio' (the SID feature stream — needs a music-reactive scene, i.e. generative + audio_source = 'sid'), 'clock' (the [performance] beat grid, so effects lock to MIDI/tap tempo on any scene — the way to tempo-lock a 'strobe'), or 'off' (never react — layers use their static baseline). Applies to every effect layer on the scene. Choices: `audio`, `clock`, `off`. | -| **`palette_mode`**
`str`
`'percell'` | VIC-II slot-allocation strategy for mcm/mhires display (ignored by other modes): percell (default), cheap, vivid, grayscale. Color shaping (channel boost + hue corrections, e.g. the purple rescue) is the global [color] section, applied to every mode. Choices: `percell`, `cheap`, `vivid`, `grayscale`. | +| **`palette_mode`**
`str`
`'percell'` | VIC-II slot-allocation strategy for mcm/mhires display (ignored by other modes): percell (default), cheap, vivid, grayscale. Color shaping (channel boost + hue corrections, e.g. the purple rescue) is the global [color] section, applied to every mode. Choices: `percell`, `cheap`, `vivid`, `grayscale`. *Live-tunable* while a show runs, as `mode.palette_mode` — Appendix F. *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | | **`text_double_height`**
`bool`
`False` | On mhires, render text overlays (clock/marquee/…) at double height — 16px / 2 cell rows — for across-the-room legibility. Text is always double-WIDE on mhires (8x8 glyph spans 2 of the 4px cells); this toggle adds the vertical stretch. Ignored on other display modes. | -| **`style`**
`str`
`'default'` | PETSCII glyph/color style (only when display = 'petscii'); 'random' picks one at setup. Choices: `default`, `halftone`, `random_glyph`, `letter_rain`, `neon`, `inverse_pop`, `hatch`, `color_only`, `random`. | +| **`style`**
`str`
`'default'` | PETSCII glyph/color style (only when display = 'petscii'); 'random' picks one at setup. Choices: `default`, `halftone`, `random_glyph`, `letter_rain`, `neon`, `inverse_pop`, `hatch`, `color_only`, `random`. *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | ## `launcher` @@ -175,7 +175,7 @@ Launch a native C64 program (.prg/.crt) and hand the machine over; idle timeout | Key | Description | |---|---| -| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). | +| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | | **`file`**
`str \| None`
`None` | Asset spec (comma-separated paths/dirs/globs). Videos for video, .sid for waveform, images for slideshow, .prg/.crt for launcher, .sid for generative when audio_source = sid. | | **`input_source`**
`str`
`'cia'` | What counts as player input to reset the idle timeout: 'cia' (joystick bits at $DC00/$DC01), 'kernal' ($00C5/$00C6, only live while the kernal IRQ runs), 'auto' (both), or 'none' (pure timer, for demos). Never counts C=/SHIFT/CTRL. Choices: `cia`, `kernal`, `auto`, `none`. | | **`max_duration_s`**
`float \| None`
`None` | Hard ceiling in seconds — advance regardless of input. Unset = no cap (a continuously-played game runs forever). | @@ -193,7 +193,7 @@ Display modes: `mhires`, `hires`, `hires_edges`, `mcm`, `petscii`. | Key | Description | |---|---| | **`display`**
`str \| None`
`None` | VIC-II display mode. Unset resolves per scene type: 'mhires' for video (richest bitmap mode, suits arbitrary film/photo content) and 'hires_edges' for webcam/blank/slideshow/generative (tuned for live Canny-edge stylization). waveform and midi are bitmap-only (both ignore this); slideshow also accepts 'random'. generative renders a frame so any quantizing mode works (not 'blank'/'random'). Choices: `hires_edges`, `hires`, `petscii`, `mcm`, `mhires`, `blank`, `random`. | -| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). | +| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | | **`file`**
`str \| None`
`None` | Asset spec (comma-separated paths/dirs/globs). Videos for video, .sid for waveform, images for slideshow, .prg/.crt for launcher, .sid for generative when audio_source = sid. | | **`audio`**
`bool \| None`
`None` | Per-scene audio override. Unset follows [audio].enabled; false mutes this scene only. | | **`source`**
`str`
`'plasma'` | Generative video source to render (generative scenes only). Choices: `plasma`, `tunnel`, `fire`, `mandelbrot`, `moire2`, `halo`, `epicycle`, `hopalong`, `rorschach`, `hiphotic`, `metaballs`, `rotozoomer`, `lissajous`, `dna`, `drift`, `colored_bursts`, `dotswarm`, `game_of_life`, `soap`, `fireworks`. | @@ -204,9 +204,9 @@ Display modes: `mhires`, `hires`, `hires_edges`, `mcm`, `petscii`. | **`mod_source`**
`str`
`'audio'` | What drives this scene's reactive effect layers: 'audio' (the SID feature stream — needs a music-reactive scene, i.e. generative + audio_source = 'sid'), 'clock' (the [performance] beat grid, so effects lock to MIDI/tap tempo on any scene — the way to tempo-lock a 'strobe'), or 'off' (never react — layers use their static baseline). Applies to every effect layer on the scene. Choices: `audio`, `clock`, `off`. | | **`pre_emphasis`**
`float \| None`
`None` | Per-scene HF pre-emphasis (0 = off, ~0.3-0.7 typical; brightens speech). Unset = global [dsp].pre_emphasis / source-aware default. Needs [dsp].enabled + scene audio. | | **`song`**
`int`
`0` | SID subtune index to play (0 = the SID's default; 1-based otherwise). For generative scenes, only with audio_source = sid. | -| **`palette_mode`**
`str`
`'percell'` | VIC-II slot-allocation strategy for mcm/mhires display (ignored by other modes): percell (default), cheap, vivid, grayscale. Color shaping (channel boost + hue corrections, e.g. the purple rescue) is the global [color] section, applied to every mode. Choices: `percell`, `cheap`, `vivid`, `grayscale`. | +| **`palette_mode`**
`str`
`'percell'` | VIC-II slot-allocation strategy for mcm/mhires display (ignored by other modes): percell (default), cheap, vivid, grayscale. Color shaping (channel boost + hue corrections, e.g. the purple rescue) is the global [color] section, applied to every mode. Choices: `percell`, `cheap`, `vivid`, `grayscale`. *Live-tunable* while a show runs, as `mode.palette_mode` — Appendix F. *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | | **`text_double_height`**
`bool`
`False` | On mhires, render text overlays (clock/marquee/…) at double height — 16px / 2 cell rows — for across-the-room legibility. Text is always double-WIDE on mhires (8x8 glyph spans 2 of the 4px cells); this toggle adds the vertical stretch. Ignored on other display modes. | -| **`style`**
`str`
`'default'` | PETSCII glyph/color style (only when display = 'petscii'); 'random' picks one at setup. Choices: `default`, `halftone`, `random_glyph`, `letter_rain`, `neon`, `inverse_pop`, `hatch`, `color_only`, `random`. | +| **`style`**
`str`
`'default'` | PETSCII glyph/color style (only when display = 'petscii'); 'random' picks one at setup. Choices: `default`, `halftone`, `random_glyph`, `letter_rain`, `neon`, `inverse_pop`, `hatch`, `color_only`, `random`. *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | ## `wled` @@ -218,12 +218,12 @@ Display modes: `mhires`, `hires`, `hires_edges`, `mcm`, `petscii`. | Key | Description | |---|---| | **`display`**
`str \| None`
`None` | VIC-II display mode. Unset resolves per scene type: 'mhires' for video (richest bitmap mode, suits arbitrary film/photo content) and 'hires_edges' for webcam/blank/slideshow/generative (tuned for live Canny-edge stylization). waveform and midi are bitmap-only (both ignore this); slideshow also accepts 'random'. generative renders a frame so any quantizing mode works (not 'blank'/'random'). Choices: `hires_edges`, `hires`, `petscii`, `mcm`, `mhires`, `blank`, `random`. | -| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). | +| **`duration_s`**
`float \| None`
`None` | Seconds before auto-advance; 0 = run forever. Unset = scene-type default (webcam/blank run forever when they're the only scene, else 30s; waveform = song length or 30s; slideshow/generative = 30s). Video scenes reject this (they run until the file ends). For launcher this is the idle timeout (reset by player input). *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | | **`sink_width`**
`int`
`320` | WLED sink: virtual LED-matrix width in pixels a sender streams to (wled scenes only). Must match the sender's configured matrix; the display mode downscales it to the C64. Default 320. | | **`sink_height`**
`int`
`200` | WLED sink: virtual LED-matrix height in pixels a sender streams to (wled scenes only). Must match the sender's configured matrix; the display mode downscales it to the C64. Default 200. | | **`effect`**
`str \| None`
`None` | Pixel effect applied to the frame before quantization (unset = none). Works on any frame-bearing scene. 'trails' echoes moving content; 'pulse' beat-punches the zoom; 'rgb_shift' slews the color channels apart on a transient. pulse/rgb_shift only visibly react on a music-reactive scene (generative + audio_source = 'sid'); elsewhere they're inert (no feature stream to react to). Choices: `trails`, `pulse`, `rgb_shift`, `blur`, `strobe`, `invert`, `mirror`, `posterize`. | | **`effects`**
`list[str]`
`[]` | Ordered pixel-effect chain applied before quantization, e.g. effects = ["trails", "rgb_shift", "strobe"]. Each is one of the `effect` choices; layers apply in order and are individually tunable (map a CC to fx0./fx1.…) and bypass-toggleable live (fx_toggle). Mutually exclusive with the single `effect` field. Empty = none. Choices: `trails`, `pulse`, `rgb_shift`, `blur`, `strobe`, `invert`, `mirror`, `posterize`. | | **`mod_source`**
`str`
`'audio'` | What drives this scene's reactive effect layers: 'audio' (the SID feature stream — needs a music-reactive scene, i.e. generative + audio_source = 'sid'), 'clock' (the [performance] beat grid, so effects lock to MIDI/tap tempo on any scene — the way to tempo-lock a 'strobe'), or 'off' (never react — layers use their static baseline). Applies to every effect layer on the scene. Choices: `audio`, `clock`, `off`. | -| **`palette_mode`**
`str`
`'percell'` | VIC-II slot-allocation strategy for mcm/mhires display (ignored by other modes): percell (default), cheap, vivid, grayscale. Color shaping (channel boost + hue corrections, e.g. the purple rescue) is the global [color] section, applied to every mode. Choices: `percell`, `cheap`, `vivid`, `grayscale`. | +| **`palette_mode`**
`str`
`'percell'` | VIC-II slot-allocation strategy for mcm/mhires display (ignored by other modes): percell (default), cheap, vivid, grayscale. Color shaping (channel boost + hue corrections, e.g. the purple rescue) is the global [color] section, applied to every mode. Choices: `percell`, `cheap`, `vivid`, `grayscale`. *Live-tunable* while a show runs, as `mode.palette_mode` — Appendix F. *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | | **`text_double_height`**
`bool`
`False` | On mhires, render text overlays (clock/marquee/…) at double height — 16px / 2 cell rows — for across-the-room legibility. Text is always double-WIDE on mhires (8x8 glyph spans 2 of the 4px cells); this toggle adds the vertical stretch. Ignored on other display modes. | -| **`style`**
`str`
`'default'` | PETSCII glyph/color style (only when display = 'petscii'); 'random' picks one at setup. Choices: `default`, `halftone`, `random_glyph`, `letter_rain`, `neon`, `inverse_pop`, `hatch`, `color_only`, `random`. | +| **`style`**
`str`
`'default'` | PETSCII glyph/color style (only when display = 'petscii'); 'random' picks one at setup. Choices: `default`, `halftone`, `random_glyph`, `letter_rain`, `neon`, `inverse_pop`, `hatch`, `color_only`, `random`. *Menu-live*: the on-C64 menu offers this knob, applied to the running scene. | diff --git a/docs/reference/24-appendix-e-generators-effects.md b/docs/reference/24-appendix-e-generators-effects.md index 95f64c1d..21ee5eca 100644 --- a/docs/reference/24-appendix-e-generators-effects.md +++ b/docs/reference/24-appendix-e-generators-effects.md @@ -8,7 +8,7 @@ generated: true *Generated from the code by `scripts/gen_reference_appendices.py`. Edits here are overwritten; run `make reference-appendices`.* -The 20 procedural sources a `generative` scene can draw from, and the 8 effects that can be layered over any scene. Each entry lists what a knob can reach while the show is running under its name; the targets themselves are Appendix F. +The 20 procedural sources a `generative` scene can draw from, and the 8 effects that can be layered over any scene. Each entry lists what a knob can reach while the show is running under its name, spelled as the `target` a `param` mapping takes — so a line here can be copied into a `cc_map` unchanged. Appendix F is the same targets the other way round: one row each, with every generator or effect that declares it. ## Generators @@ -17,26 +17,26 @@ Set one as a `generative` scene's `source`. Every generator renders at 320×200 | Generator | Description | |---|---| -| **`plasma`**
`speed` 0–2
`scale` 0.1–4 | Classic sine-sum plasma whose hue cycles over time. | -| **`tunnel`**
`speed` 0–2
`scale` 0.25–4 | Infinite-zoom tunnel: hue is driven by per-pixel depth (1/radius) and angle, scrolled over time. | -| **`fire`**
`scroll_speed` 0–4
`intensity` 0.2–2 | Rising fire: an upward-scrolling turbulence texture masked by a bottom-hot vertical gradient and colour-mapped black→red→yellow→white (`cv2.COLORMAP_HOT` — a near-perfect match for the C64 palette). | -| **`mandelbrot`**
`zoom_speed` 0.02–1
`cycle_speed` 0–2 | Escape-time Mandelbrot zoom. | -| **`moire2`**
`ring_freq` 10–80
`drift_speed` 0–2 | Two concentric-ring distance fields whose centers drift apart and together, summed into a classic moiré interference pattern (each field is `sin(distance-to-center * freq)`; xscreensaver's moire2.c gets the same beat pattern by XOR-compositing two arc bitmaps — this is the closed-form equivalent: a distance field instead of drawn arcs). | -| **`halo`**
`drift_speed` 0–2
`pulse_speed` 0–3 | Several soft-edged halos drifting on independent circular orbits, additively blended (bright where they overlap, no clear — matching xscreensaver's halo.c un-erased canvas). | -| **`epicycle`**
`speed` 0–2 | Fourier epicycles: a chain of circles, each spinning around the tip of the previous, whose combined tip traces `sum_i r_i * exp(j*(w_i t + phi_i))` — a chain of rotations composes to the same vector sum regardless of framing, so this sums phasors directly rather than nesting rotations. | -| **`hopalong`**
`a` -2–2
`drift_speed` 0–1 | Hopalong chaotic point-map attractor, iterated for many parallel starting points at once (numpy-vectorized across the batch — each *step* is still sequential, the map depends on the previous point) into a density accumulator, colour-mapped by (log-scaled) density. | -| **`rorschach`**
`grow_speed` 0–4 | Mirrored-symmetric ink-blot: a precomputed 2D random walk (fixed seed → deterministic) cumulative-summed from Gaussian steps, progressively revealed as `t` advances and reflected across the vertical center line — xscreensaver's rorschach.c animates the same way (draw a few more walk points each frame); this stays a pure function of `t` by redrawing however much of the (fixed) walk is "revealed" by `t` from scratch each frame, rather than accumulating pixels frame to frame. | -| **`hiphotic`**
`speed` 0.1–8
`scale` 0.1–4 | WLED "Hiphotic" port: nested trig interference (`sin(cos(x...) + sin(y...) + a)`), reimplemented in continuous float instead of WLED's 8-bit sin8/cos8 lookup tables. | -| **`metaballs`**
`speed` 0.05–5 | WLED "Metaballs" port: 3 moving "ball" centers blended into a classic inverse-distance metaball field. | -| **`rotozoomer`**
`speed` 0–4
`scale` 0.2–4 | WLED "Rotozoomer" port: a static XOR bit-pattern texture (`(x*4) ^ (y*4)`, precomputed + colorized once) sampled through a rotating/zooming affine transform. | -| **`lissajous`**
`speed` 0–4
`scale` 0.2–6 | WLED "Lissajous" port: a classic XY curve (`x = sin(theta*freq_x + phase)`, `y = cos(theta*2 + phase)`) sampled at a fixed number of points along its parametrization. | -| **`dna`**
`speed` 0–3
`scale` 0.3–4 | WLED "DNA" port: two sine strands sweeping the full frame width, phase-shifted by half a cycle (`pi`, matching WLED's `i*4` vs `i*4+128` offset) so they wind around a shared center line like a double helix; color cycles per column + time. | -| **`drift`**
`speed` 0–3
`scale` 0.3–2 | WLED "Drift" port: a rotating spiral trail — for radii `i` stepping outward from center, a point at angle `t*(maxDim-i)` traces a full spiral arm every frame. | -| **`colored_bursts`**
`speed` 0–3
`scale` 0.3–3 | WLED "Colored Bursts" port: several lines burst from one common, slowly-orbiting point out to per-line endpoints that trace their own faster orbits — WLED's shared start point has no per-line phase offset, while the per-line `i*24`/`i*48+64` phase spread on the *other* endpoint is what fans the lines out into a burst. | -| **`dotswarm`**
`speed` 0–3
`scale` 0.2–2 | A WLED "beatsin dot swarm" port covering the shared shape of several kin effects — Black Hole, Frizzles, Sindots, Squared Swirl, Drift Rose — which all boil down to the same primitive: a handful of points, each independently orbiting via a bounded sine (`beatsin8` in WLED) at its own frequency, color-cycled and blended. | -| **`game_of_life`**
`speed` 0.1–4 | WLED "Game Of Life" port: Conway's Game of Life on a coarse grid (chunky upscaled cells — reads great after C64 quantization, especially on PETSCII), with WLED's signature parent-color inheritance (a newly-born cell's hue is the mean of its live parents' hues). | -| **`soap`**
`speed` 0–3
`scale` 0.2–3 | WLED "Soap" port: a persistent color buffer smeared/advected each tick by a slowly-rotating noise-driven flow field — the classic swirling soap-film look. | -| **`fireworks`**
`speed` 0–3
`scale` 0.3–3 | WLED "Fireworks" port — the flagship of WLED's shared particle-system engine, which also drives Volcano/Ballpit/Waterfall/Impact/Attractor/ Galaxy as different emitter/gravity presets on the same primitive; only the fireworks preset is ported here. | +| **`plasma`**
`source.speed` 0–2
`source.scale` 0.1–4 | Classic sine-sum plasma whose hue cycles over time. | +| **`tunnel`**
`source.speed` 0–2
`source.scale` 0.25–4 | Infinite-zoom tunnel: hue is driven by per-pixel depth (1/radius) and angle, scrolled over time. | +| **`fire`**
`source.scroll_speed` 0–4
`source.intensity` 0.2–2 | Rising fire: an upward-scrolling turbulence texture masked by a bottom-hot vertical gradient and colour-mapped black→red→yellow→white (`cv2.COLORMAP_HOT` — a near-perfect match for the C64 palette). | +| **`mandelbrot`**
`source.zoom_speed` 0.02–1
`source.cycle_speed` 0–2 | Escape-time Mandelbrot zoom. | +| **`moire2`**
`source.ring_freq` 10–80
`source.drift_speed` 0–2 | Two concentric-ring distance fields whose centers drift apart and together, summed into a classic moiré interference pattern (each field is `sin(distance-to-center * freq)`; xscreensaver's moire2.c gets the same beat pattern by XOR-compositing two arc bitmaps — this is the closed-form equivalent: a distance field instead of drawn arcs). | +| **`halo`**
`source.drift_speed` 0–2
`source.pulse_speed` 0–3 | Several soft-edged halos drifting on independent circular orbits, additively blended (bright where they overlap, no clear — matching xscreensaver's halo.c un-erased canvas). | +| **`epicycle`**
`source.speed` 0–2 | Fourier epicycles: a chain of circles, each spinning around the tip of the previous, whose combined tip traces `sum_i r_i * exp(j*(w_i t + phi_i))` — a chain of rotations composes to the same vector sum regardless of framing, so this sums phasors directly rather than nesting rotations. | +| **`hopalong`**
`source.a` -2–2
`source.drift_speed` 0–1 | Hopalong chaotic point-map attractor, iterated for many parallel starting points at once (numpy-vectorized across the batch — each *step* is still sequential, the map depends on the previous point) into a density accumulator, colour-mapped by (log-scaled) density. | +| **`rorschach`**
`source.grow_speed` 0–4 | Mirrored-symmetric ink-blot: a precomputed 2D random walk (fixed seed → deterministic) cumulative-summed from Gaussian steps, progressively revealed as `t` advances and reflected across the vertical center line — xscreensaver's rorschach.c animates the same way (draw a few more walk points each frame); this stays a pure function of `t` by redrawing however much of the (fixed) walk is "revealed" by `t` from scratch each frame, rather than accumulating pixels frame to frame. | +| **`hiphotic`**
`source.speed` 0.1–8
`source.scale` 0.1–4 | WLED "Hiphotic" port: nested trig interference (`sin(cos(x...) + sin(y...) + a)`), reimplemented in continuous float instead of WLED's 8-bit sin8/cos8 lookup tables. | +| **`metaballs`**
`source.speed` 0.05–5 | WLED "Metaballs" port: 3 moving "ball" centers blended into a classic inverse-distance metaball field. | +| **`rotozoomer`**
`source.speed` 0–4
`source.scale` 0.2–4 | WLED "Rotozoomer" port: a static XOR bit-pattern texture (`(x*4) ^ (y*4)`, precomputed + colorized once) sampled through a rotating/zooming affine transform. | +| **`lissajous`**
`source.speed` 0–4
`source.scale` 0.2–6 | WLED "Lissajous" port: a classic XY curve (`x = sin(theta*freq_x + phase)`, `y = cos(theta*2 + phase)`) sampled at a fixed number of points along its parametrization. | +| **`dna`**
`source.speed` 0–3
`source.scale` 0.3–4 | WLED "DNA" port: two sine strands sweeping the full frame width, phase-shifted by half a cycle (`pi`, matching WLED's `i*4` vs `i*4+128` offset) so they wind around a shared center line like a double helix; color cycles per column + time. | +| **`drift`**
`source.speed` 0–3
`source.scale` 0.3–2 | WLED "Drift" port: a rotating spiral trail — for radii `i` stepping outward from center, a point at angle `t*(maxDim-i)` traces a full spiral arm every frame. | +| **`colored_bursts`**
`source.speed` 0–3
`source.scale` 0.3–3 | WLED "Colored Bursts" port: several lines burst from one common, slowly-orbiting point out to per-line endpoints that trace their own faster orbits — WLED's shared start point has no per-line phase offset, while the per-line `i*24`/`i*48+64` phase spread on the *other* endpoint is what fans the lines out into a burst. | +| **`dotswarm`**
`source.speed` 0–3
`source.scale` 0.2–2 | A WLED "beatsin dot swarm" port covering the shared shape of several kin effects — Black Hole, Frizzles, Sindots, Squared Swirl, Drift Rose — which all boil down to the same primitive: a handful of points, each independently orbiting via a bounded sine (`beatsin8` in WLED) at its own frequency, color-cycled and blended. | +| **`game_of_life`**
`source.speed` 0.1–4 | WLED "Game Of Life" port: Conway's Game of Life on a coarse grid (chunky upscaled cells — reads great after C64 quantization, especially on PETSCII), with WLED's signature parent-color inheritance (a newly-born cell's hue is the mean of its live parents' hues). | +| **`soap`**
`source.speed` 0–3
`source.scale` 0.2–3 | WLED "Soap" port: a persistent color buffer smeared/advected each tick by a slowly-rotating noise-driven flow field — the classic swirling soap-film look. | +| **`fireworks`**
`source.speed` 0–3
`source.scale` 0.3–3 | WLED "Fireworks" port — the flagship of WLED's shared particle-system engine, which also drives Volcano/Ballpit/Waterfall/Impact/Attractor/ Galaxy as different emitter/gravity presets on the same primitive; only the fireworks preset is ported here. | ## Effects @@ -45,11 +45,11 @@ Named by a scene's `effect`, or chained in order with `effects`. An effect trans | Effect | Description | |---|---| -| **`trails`**
`decay` 0–0.96 | Feedback / echo trails: each frame is max-blended with a decayed copy of the previous output, so moving content leaves a fading comet tail. | -| **`pulse`**
`intensity` 0–2.5 | Beat-punch zoom: a transient punches the frame scale up (zoom-in toward center), relaxing back as `onset` decays; sustained loudness adds a gentle steady zoom. | -| **`rgb_shift`**
`intensity` 0–2.5 | Chromatic split: a transient slews the red and blue channels apart horizontally (opposite directions), an RGB-shift glitch shudder that snaps on the beat and relaxes as `onset` decays; loudness adds a steady split. | -| **`blur`**
`intensity` 0–8 | Gaussian blur (`cv2.GaussianBlur`) — the first blur primitive in the codebase, added as an enabler for future dot/trail-family generator ports (WLED leans on `SEGMENT.blur` throughout its 2D effects). | -| **`strobe`**
`duty` 0.05–1
`rate` 1–16 | Tempo-locked strobe: blanks the frame to black for part of every beat, so the picture flashes on the grid. | -| **`invert`**
`mix` 0–1 | Photo-negative: blends the frame toward its color inverse (`255 - px`). | -| **`mirror`**
`axis` (3 values) | Symmetry fold: reflects one half of the frame onto the other, the classic VJ kaleidoscope-lite look. | -| **`posterize`**
`levels` 2–32 | Level crush: quantizes each channel to `levels` steps, flattening the image into hard poster bands (a look that also pre-simplifies the frame for the C64 palette reduction downstream). | +| **`trails`**
`effect.decay` 0–0.96 | Feedback / echo trails: each frame is max-blended with a decayed copy of the previous output, so moving content leaves a fading comet tail. | +| **`pulse`**
`effect.intensity` 0–2.5 | Beat-punch zoom: a transient punches the frame scale up (zoom-in toward center), relaxing back as `onset` decays; sustained loudness adds a gentle steady zoom. | +| **`rgb_shift`**
`effect.intensity` 0–2.5 | Chromatic split: a transient slews the red and blue channels apart horizontally (opposite directions), an RGB-shift glitch shudder that snaps on the beat and relaxes as `onset` decays; loudness adds a steady split. | +| **`blur`**
`effect.intensity` 0–8 | Gaussian blur (`cv2.GaussianBlur`) — the first blur primitive in the codebase, added as an enabler for future dot/trail-family generator ports (WLED leans on `SEGMENT.blur` throughout its 2D effects). | +| **`strobe`**
`effect.duty` 0.05–1
`effect.rate` 1–16 | Tempo-locked strobe: blanks the frame to black for part of every beat, so the picture flashes on the grid. | +| **`invert`**
`effect.mix` 0–1 | Photo-negative: blends the frame toward its color inverse (`255 - px`). | +| **`mirror`**
`effect.axis` (3 values) | Symmetry fold: reflects one half of the frame onto the other, the classic VJ kaleidoscope-lite look. | +| **`posterize`**
`effect.levels` 2–32 | Level crush: quantizes each channel to `levels` steps, flattening the image into hard poster bands (a look that also pre-simplifies the frame for the C64 palette reduction downstream). | diff --git a/scripts/gen_reference_appendices.py b/scripts/gen_reference_appendices.py index 94c4cf58..b41e6ec7 100644 --- a/scripts/gen_reference_appendices.py +++ b/scripts/gen_reference_appendices.py @@ -154,6 +154,63 @@ def table(headers: Sequence[str], rows: Iterable[Sequence[str]]) -> list[str]: return out + [""] +# --------------------------------------------------------------------------- +# The two senses of "live" +# --------------------------------------------------------------------------- +# +# Two different powers wear the same word. Appendix F's targets move under a +# MIDI knob, a pad or the web console mid-show; `apply="live"` is the metadata +# the on-C64 menu builds its panel from (`overlays/menu.py`). One mark for both +# would read as one power, so they are worded apart here and the introduction's +# Notation section says which is which. + +# Configuration field -> the live-tune target that moves it. Keyed by +# `(section, field)`, where the section `scenes` is a `[[scenes]]` key. +# +# Written out rather than matched on the bare name: `[color].dither` is +# `mode.dither_method`, so a name match would miss it -- and it would mark +# `[audio].dither`, which is a 4-bit DAC's noise shaping and has nothing to do +# with the display pipeline. tests/test_reference_appendices.py resolves both +# sides of every entry. +_LIVE_TUNABLE: dict[tuple[str, str], str] = { + ("color", "auto_fit_strength"): "mode.auto_fit_strength", + ("color", "dither"): "mode.dither_method", + ("color", "dither_strength"): "mode.dither_strength", + ("color", "color_match"): "mode.color_match", + ("color", "cell_strategy"): "mode.cell_strategy", + ("color", "motion_smoothing"): "mode.motion_smoothing", + ("scenes", "palette_mode"): "mode.palette_mode", +} + + +def marks(section: str, fd: introspect.FieldDoc) -> str: + """The *live-tunable* and *menu-live* marks a field earns, if any. + + Appended to the description rather than stacked into the identity column: + the identity is what a thing is called, and a mark is something it can do. + """ + bits = [] + target = _LIVE_TUNABLE.get((section, fd.name)) + if target: + bits.append(f"*Live-tunable* while a show runs, as {code(target)} — Appendix F.") + if fd.apply == "live": + bits.append("*Menu-live*: the on-C64 menu offers this knob, applied to the running scene.") + return " ".join(bits) + + +def describe(section: str, fd: introspect.FieldDoc) -> str: + """A field's description cell: its help, its choices, then its marks.""" + parts = [] + if fd.help: + parts.append(cell(fd.help)) + if fd.choices: + parts.append("Choices: " + ", ".join(code(c) for c in fd.choices) + ".") + mark = marks(section, fd) + if mark: + parts.append(mark) + return " ".join(parts) + + def identity(*lines: str) -> str: """The left column of a fields table: what the thing is called, stacked. @@ -223,19 +280,21 @@ def appendix_config() -> list[str]: "Configuration Sections", f"Every section of a configuration file: {len(sections)} sections and {total} " "fields, with the type each takes and the value it holds when you say nothing. " - "`c64cast --describe section:NAME` prints any one of these at the terminal.", + "A field a knob can move mid-show says so, and names the target Appendix F " + "lists it under. `c64cast --describe section:NAME` prints any one of these at " + "the terminal.", ) for section in sections: out += [f"## `[{section.name}]`", ""] if section.help: out += [prose(section.help), ""] - rows = [] - for fd in section.fields: - desc = cell(fd.help) if fd.help else "" - if fd.choices: - choices = ", ".join(code(c) for c in fd.choices) - desc = f"{desc} Choices: {choices}." if desc else f"Choices: {choices}." - rows.append([identity(code(fd.name), code(fd.type), fmt_default(fd.default)), desc]) + rows = [ + [ + identity(code(fd.name), code(fd.type), fmt_default(fd.default)), + describe(section.name, fd), + ] + for fd in section.fields + ] out += fields_table("Field", rows) return out @@ -259,8 +318,9 @@ def appendix_scenes() -> list[str]: "B", "Scene Types", f"The {len(types)} kinds of scene a `[[scenes]]` block can be, and the keys each " - "one reads. `c64cast --describe scene:NAME` prints any one of these at the " - "terminal.", + "one reads. A key marked *live-tunable* can be moved by a knob mid-show; one " + "marked *menu-live* is one the on-C64 menu can change without rebuilding the " + "scene. `c64cast --describe scene:NAME` prints any one of these at the terminal.", ) out += ["## Keys Every Scene Takes", ""] out += [ @@ -273,7 +333,10 @@ def appendix_scenes() -> list[str]: out += fields_table( "Key", [ - [identity(code(fd.name), code(fd.type), fmt_default(fd.default)), cell(fd.help)] + [ + identity(code(fd.name), code(fd.type), fmt_default(fd.default)), + describe("scenes", fd), + ] for fd in common ], ) @@ -288,15 +351,14 @@ def appendix_scenes() -> list[str]: if sd.displays: modes = ", ".join(code(d) for d in sd.displays) out += [prose(f"Display modes: {modes}."), ""] - rows = [] - for fd in sd.fields: - if fd.name in common_names: - continue - desc = cell(fd.help) if fd.help else "" - if fd.choices: - choices = ", ".join(code(c) for c in fd.choices) - desc = f"{desc} Choices: {choices}." if desc else f"Choices: {choices}." - rows.append([identity(code(fd.name), code(fd.type), fmt_default(fd.default)), desc]) + rows = [ + [ + identity(code(fd.name), code(fd.type), fmt_default(fd.default)), + describe("scenes", fd), + ] + for fd in sd.fields + if fd.name not in common_names + ] if rows: out += fields_table("Key", rows) else: @@ -398,17 +460,24 @@ def appendix_compat() -> list[str]: # --------------------------------------------------------------------------- -def _live_params(cls: type) -> list[str]: +def _live_params(holder: str, cls: type) -> list[str]: """What a knob can reach on this generator or effect, one per line. A line each rather than one run of commas: they share the identity column with the name, and a generator with four of them would otherwise set as a paragraph of mono in a column narrower than the paragraph. + + Each is written with its holder — `source.speed`, not `speed` — because + that is the string a `cc_map` entry has to carry, and printing the bare + name made the reader translate a table into Appendix F's spelling to use + it. """ params: dict[str, tuple[float, float]] = getattr(cls, "LIVE_PARAMS", {}) or {} choices: dict[str, tuple[str, ...]] = getattr(cls, "LIVE_CHOICES", {}) or {} - bits = [f"{code(name)} {lo:g}–{hi:g}" for name, (lo, hi) in params.items()] - bits += [f"{code(name)} ({len(values)} values)" for name, values in choices.items()] + bits = [f"{code(f'{holder}.{name}')} {lo:g}–{hi:g}" for name, (lo, hi) in params.items()] + bits += [ + f"{code(f'{holder}.{name}')} ({len(values)} values)" for name, values in choices.items() + ] return bits @@ -418,8 +487,11 @@ def appendix_generators() -> list[str]: "Generators and Effects", f"The {len(generators.REGISTRY)} procedural sources a `generative` scene can " f"draw from, and the {len(effects.REGISTRY)} effects that can be layered over " - "any scene. Each entry lists what a knob can reach while the show is " - "running under its name; the targets themselves are Appendix F.", + "any scene. Each entry lists what a knob can reach while the show is running " + "under its name, spelled as the `target` a `param` mapping takes — so a line " + "here can be copied into a `cc_map` unchanged. Appendix F is the same targets " + "the other way round: one row each, with every generator or effect that " + "declares it.", ) out += ["## Generators", ""] out += [ @@ -433,7 +505,7 @@ def appendix_generators() -> list[str]: "Generator", [ [ - identity(code(name), *_live_params(cls)), + identity(code(name), *_live_params("source", cls)), cell(first_sentence(cls.__doc__ or "")), ] for name, cls in generators.REGISTRY.items() @@ -452,7 +524,7 @@ def appendix_generators() -> list[str]: "Effect", [ [ - identity(code(name), *_live_params(cls)), + identity(code(name), *_live_params("effect", cls)), cell(first_sentence(cls.__doc__ or "")), ] for name, cls in effects.REGISTRY.items() @@ -620,11 +692,51 @@ def appendix_extras() -> list[str]: # --------------------------------------------------------------------------- +# The noun a holder's declarers are counted in, for :func:`declared_by`. +_OWNER_NOUNS: dict[str, str] = { + "mode": "modes", + "effect": "effects", + "source": "generators", + "scene": "scenes", +} + + +def declared_by(target: introspect.LiveTargetDoc, totals: dict[str, int]) -> str: + """Who owns a live target, short enough for the card's column. + + Appendix F prints the owners; a 3.5in column cannot — `source.speed` has + fourteen of them. But the fact itself is what a performer needs at the + console, because a target the running scene does not declare is a silent + no-op: a knob on `source.ring_freq` moves nothing at all unless `moire2` + is the generator on screen. + + So the sole owner when there is one, and otherwise a count in the group's + own noun — `14 generators` — which is what the question actually is at that + point: is this knob worth a hand, or is it for one specific look. + """ + if len(target.owners) == 1: + return code(target.owners[0]) + if len(target.owners) == totals.get(target.holder): + return "all" + return f"{len(target.owners)} {_OWNER_NOUNS[target.holder]}" + + +def _holder_totals() -> dict[str, int]: + """How many classes each holder has in total, so :func:`declared_by` can + say `all` rather than a count that happens to equal the registry.""" + totals: dict[str, int] = {} + for entry in introspect._iter_live_holders(): + holder = entry[0] + totals[holder] = totals.get(holder, 0) + 1 + return totals + + def card_live_targets() -> list[str]: """The card's most drift-prone page, generated in the same pass as Appendix F. Deliberately not the same table: a card is read at arm's length in a dark - room, so it carries the target and its range and nothing else. + room, so it carries the target, its range and — compressed by + :func:`declared_by` — who declares it. The provenance line is likewise shorter than an appendix's. `generated: true` is what the drift check and a human editor go by; the visible line is @@ -632,6 +744,7 @@ def card_live_targets() -> list[str]: what the list *is* than how it was made. """ targets = introspect.live_targets() + totals = _holder_totals() out = [ "---", "generated: true", @@ -651,8 +764,8 @@ def card_live_targets() -> list[str]: if t.kind == "scalar" and t.lo is not None and t.hi is not None else f"{len(t.choices)} values" ) - rows.append([code(t.target), span]) - out += table(["Target", "Range"], rows) + rows.append([code(t.target), span, declared_by(t, totals)]) + out += table(["Target", "Range", "Declared by"], rows) return out diff --git a/tests/test_reference_appendices.py b/tests/test_reference_appendices.py index bc97d302..1208ff62 100644 --- a/tests/test_reference_appendices.py +++ b/tests/test_reference_appendices.py @@ -23,10 +23,13 @@ import importlib.util import os +import re import sys import unittest from pathlib import Path +from c64cast import introspect + _REPO_ROOT = Path(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) @@ -99,6 +102,91 @@ def test_the_reference_book_builds(self): self.assertIn("#show: guide.with(", bb.build(gen.REFERENCE_DIR)) +class LiveMarkTest(unittest.TestCase): + """The two *live* marks Appendices A and B carry. + + The alias map is hand-written, so both of its ends can rot independently: + a renamed config field leaves the mark on nothing, and a retired live + target leaves it pointing at a row Appendix F no longer has. + """ + + def test_every_alias_names_a_real_config_field(self): + by_section = {s.name: {fd.name for fd in s.fields} for s in introspect.config_sections()} + by_section["scenes"] = {fd.name for fd in introspect._scene_field_docs()} + for section, field in gen._LIVE_TUNABLE: + with self.subTest(field=f"{section}.{field}"): + self.assertIn(section, by_section) + self.assertIn(field, by_section[section]) + + def test_every_alias_names_a_real_live_target(self): + targets = {t.target for t in introspect.live_targets()} + for (section, field), target in gen._LIVE_TUNABLE.items(): + with self.subTest(field=f"{section}.{field}"): + self.assertIn(target, targets) + + def test_the_marks_reach_the_committed_appendices(self): + # [color].dither is the one that does not join by name -- it is + # mode.dither_method -- so it is the one worth asserting lands. + text = (gen.REFERENCE_DIR / "20-appendix-a-configuration.md").read_text(encoding="utf-8") + self.assertIn("`mode.dither_method`", text) + # palette_mode carries both marks, which is why they are worded apart. + scenes = (gen.REFERENCE_DIR / "21-appendix-b-scene-types.md").read_text(encoding="utf-8") + self.assertIn("*Live-tunable*", scenes) + self.assertIn("*Menu-live*", scenes) + + def test_a_bare_name_match_would_have_marked_the_wrong_dither(self): + # [audio].dither is the 4-bit DAC's noise shaping and has nothing to do + # with the display pipeline; it must stay unmarked. + audio = next(s for s in introspect.config_sections() if s.name == "audio") + fd = next(f for f in audio.fields if f.name == "dither") + self.assertEqual(gen.marks("audio", fd), "") + + +class DeclaredByTest(unittest.TestCase): + """The card's compression of Appendix F's owner list.""" + + def _target(self, owners, holder="source"): + return introspect.LiveTargetDoc( + target=f"{holder}.x", + holder=holder, + group="Generator", + kind="scalar", + owners=tuple(owners), + ) + + def test_a_sole_owner_is_named(self): + self.assertEqual(gen.declared_by(self._target(["moire2"]), {"source": 20}), "`moire2`") + + def test_several_owners_are_counted_in_the_groups_noun(self): + owners = [f"g{i}" for i in range(14)] + self.assertEqual(gen.declared_by(self._target(owners), {"source": 20}), "14 generators") + + def test_every_owner_is_all(self): + owners = [f"g{i}" for i in range(20)] + self.assertEqual(gen.declared_by(self._target(owners), {"source": 20}), "all") + + def test_every_holder_has_a_noun(self): + # A new live-tune holder with no noun would raise mid-generation. + for holder in gen._holder_totals(): + self.assertIn(holder, gen._OWNER_NOUNS) + + +class LiveParamSpellingTest(unittest.TestCase): + """Appendix E writes a knob the way a `cc_map` has to spell it.""" + + def test_a_generator_param_carries_its_holder(self): + targets = {t.target for t in introspect.live_targets()} + text = (gen.REFERENCE_DIR / "24-appendix-e-generators-effects.md").read_text( + encoding="utf-8" + ) + self.assertIn("`source.speed`", text) + self.assertIn("`effect.decay`", text) + # Whatever Appendix E prints has to be a row Appendix F has. + for name in re.findall(r"`((?:source|effect)\.[a-z_]+)`", text): + with self.subTest(target=name): + self.assertIn(name, targets) + + class TextEscapingTest(unittest.TestCase): """The helpers that make terminal help text safe as Markdown."""