diff --git a/CHANGELOG.md b/CHANGELOG.md index 84ac116d..affaa670 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,11 +31,12 @@ the version and stamps it with the date. `[hardware].dump_char_rom = false` turns the automatic read off. `--doctor` reports which ROM is in use and whether it verifies. - **A second book: the Programmer's Reference Guide** (`docs/reference/`), the - volume you open at the page you need rather than read in order. Six chapters: - the configuration language and its precedence rules, the catalogue of every - scene and overlay, the display pipeline from frame to VIC-II register, the - sound path in both directions, what actually lands in the Commodore's memory, - and every input and output that reaches the show from outside. Its appendices + volume you open at the page you need rather than read in order. Seven + chapters: the configuration language and its precedence rules, the catalogue + of every scene and overlay, the display pipeline from frame to VIC-II + register, the sound path in both directions, the link into the Commodore's + memory and what lands there, every input and output that reaches the show from + outside, and how to extend the program itself. Its appendices are *generated* from the code by `scripts/gen_reference_appendices.py`: every configuration section and field, every scene key, every overlay parameter, the overlay against display-mode matrix, every generator and effect, every @@ -155,6 +156,20 @@ the version and stamps it with the date. does, what is offered back at exit and what is not, and a warning that saving rewrites the whole configuration file from the settings in memory and keeps one `.bak` deep. +- **The reference guide's two vaguest chapter titles now say what is in them.** + "Inside the Machine" is *The Link and the Memory Map*, and "Everything + Outside" is *Inputs and Outputs* — which is what a reader scanning the + contents for MIDI, WLED or recording can actually find. The chapter numbers + are unchanged, so every cross-reference still lands where it did. +- **Extending c64cast is its own chapter** (7) rather than the tail of the + memory-map chapter. Writing a scene, an overlay, a generator or an effect is + contributor material, and it was sitting inside a user-facing chapter after a + write budget. It is appended rather than inserted, so chapters 1 to 6 keep + their numbers. +- ASID and the MIDI scene are no longer written out twice. Chapter 2's + catalogue entries state what a *configuration* needs — the keys, the extra, + the ports — and defer the mechanism to Chapter 4, which is the rule the + introduction sets and was the one place the book broke it. ## [0.1.0] - 2026-07-30 diff --git a/CLAUDE.md b/CLAUDE.md index 78d83a7d..d7371b97 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -84,7 +84,7 @@ directory of numbered Markdown plus a `book.toml` that [scripts/build_book.py](scripts/build_book.py) renders through the one shared [template](docs/shared/template.typ): [docs/guide/](docs/guide/README.md) (the User's Guide, read in order), [docs/reference/](docs/reference/README.md) (the -Programmer's Reference Guide — six chapters over nine appendices, of which A–H +Programmer's Reference Guide — seven chapters over nine appendices, of which A–H are generated by [scripts/gen_reference_appendices.py](scripts/gen_reference_appendices.py) and regenerated with `make reference-appendices`), and [docs/card/](docs/card/README.md) (the two-page Performance Card, whose live-target diff --git a/docs/reference/01-introduction.md b/docs/reference/01-introduction.md index 9345c77a..c35cbd5e 100644 --- a/docs/reference/01-introduction.md +++ b/docs/reference/01-introduction.md @@ -14,8 +14,12 @@ referred to from the other. Chapters 1 to 6 are prose: the rules of the configuration language, the vocabulary of scenes and overlays, the display pipeline from frame to VIC-II -register, the sound path, what actually lands in the Commodore's memory, and -everything that reaches c64cast from outside or leaves it. +register, the sound path, the link into the Commodore's memory and what lands +there, and everything that reaches c64cast from outside or leaves it. + +Chapter 7 is the exception, and is for a different reader: it is what you need +to add a scene, an overlay, a generator or an effect to c64cast itself. Nothing +in the first six chapters depends on it. Appendices A to H are not prose and are not written by hand. They are generated from the same definitions the program answers `--describe`, `--compat` and diff --git a/docs/reference/03-vocabulary.md b/docs/reference/03-vocabulary.md index 3a55fc52..f652cc5f 100644 --- a/docs/reference/03-vocabulary.md +++ b/docs/reference/03-vocabulary.md @@ -156,16 +156,14 @@ Plogue chipsynth C64, an Elektron with ASID-XP. On macOS enable the IAC driver in Audio MIDI Setup; on Linux `modprobe snd-virmidi`. This scene has no synthesiser knobs, because ASID carries the whole tune's -register state and c64cast is only relaying it. Multi-SID streams are honoured -on the Ultimate: extra chips are routed to their own addresses, preferring -physical sockets, and the scope subdivides each voice row into one window per -chip. `asid_multi_sid` and `asid_max_sids` gate and cap that; on a backend -without a configuration API the extra chips downmix to the primary SID. -`asid_buffered_player` chooses between a cycle-accurate Commodore-side ring -player and host-side coalescing — the ring player is what keeps multispeed -tunes' arpeggios and hard restarts intact, and `"auto"` selects it whenever -the backend has an REU. OPL-FM (`0x60`) is the one command that is recognised -and dropped. +register state and c64cast is only relaying it. What it has instead is three +keys about how that state is delivered. `asid_multi_sid` and `asid_max_sids` +gate and cap the routing of a multi-SID stream onto extra chips, which needs +a machine whose SID addresses c64cast can configure. `asid_buffered_player` +chooses between the two ways of playing what arrives, and its default `"auto"` +takes the accurate one wherever the machine can carry it. Chapter 4 has what +those two ways are, why one of them needs expansion memory, and what the +protocol does and does not carry. ### `blank` @@ -296,11 +294,11 @@ midi_filter_cutoff = 1024 Bitmap-only: `display` is ignored.* Each voice can hold its own waveform, and an entry may be a `+`-combination -for the chip's combined waveforms. In the default `shared` voice mode one MIDI -channel spreads across all three voices — held notes keep their voice, so a -pad survives while a melody cycles on top. With `multitimbral`, channels route -to fixed voices, each monophonic with last-note priority, and notes on -unmapped channels are ignored. +for the chip's combined waveforms. `midi_voice_mode` picks between the default +`shared`, where one channel spreads across all three voices, and +`multitimbral`, where `midi_voice_channels` pins a channel to each. Chapter 4 +has how voices are allocated and stolen under each mode, what pitch-bend and +velocity reach, and the controller map. > [!NOTE] > On a 6581 the waveform outputs share a bus and combine by AND, and any diff --git a/docs/reference/05-sound-and-music.md b/docs/reference/05-sound-and-music.md index 0146525c..4b2a840c 100644 --- a/docs/reference/05-sound-and-music.md +++ b/docs/reference/05-sound-and-music.md @@ -409,9 +409,9 @@ Two scenes drive the real SID from somewhere else entirely. Both need the ### ASID ASID packs SID register writes into MIDI system-exclusive messages. An ASID -*host* — DeepSID in a browser, SIDFactory II, Plogue chipsynth C64, an Elektron -with ASID-XP — sends the stream; the `asid` scene receives it and plays it on -the chip. It is a new input, not a fidelity change: the protocol carries only +*host* sends the stream and the `asid` scene receives it and plays it on the +chip; Chapter 2's entry for that scene names the hosts and how to open a port +for them. It is a new input, not a fidelity change: the protocol carries only what a SID can synthesise, never sampled audio. There are two ways to play what arrives, chosen by `asid_buffered_player`. @@ -466,7 +466,8 @@ that starts open means a lowpass patch is neutral until you sweep it. In the default `shared` voice mode one MIDI channel spreads across all three voices. With `multitimbral`, `midi_voice_channels` routes channels to fixed -voices, each monophonic, and notes on unmapped channels are ignored. +voices, each monophonic with last-note priority, and notes on unmapped channels +are ignored. ## Listening Back diff --git a/docs/reference/06-under-the-hood.md b/docs/reference/06-under-the-hood.md index fee42e67..b394069e 100644 --- a/docs/reference/06-under-the-hood.md +++ b/docs/reference/06-under-the-hood.md @@ -2,17 +2,16 @@ number: 5 --- -# Inside the Machine +# The Link and the Memory Map Everything in the previous two chapters ends with bytes arriving in a Commodore's memory. This chapter is about that arrival: how the bytes get there, where they land, how c64cast avoids sending the ones that have not -changed, and what a frame can afford. It closes with what you need to know to -add a scene, an overlay or a generator of your own. +changed, and what a frame can afford. None of it is required to use c64cast. It is required to reason about why a -scene is slow, why two features refuse to run together, and what your own code -may safely touch. +scene is slow, why two features refuse to run together, and what a picture +costs before you ask for it. ## Getting Bytes In @@ -314,116 +313,3 @@ In order of what they buy against what they cost: What not to give up: coalescing and the dirty cache are automatic, and any new code path that issues many small writes per frame is the first thing to look at. - -## Writing Your Own Scene - -A scene produces one frame of *content* per call. The playlist wraps it with -setup, overlays, pacing and teardown, so a scene never needs to know that -overlays exist. - -```python -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 if it can't sustain system rate - - def setup(self): - super().setup() - self.display_mode.setup(self.api) - - def process_frame(self, current_time: float) -> bool: - frame_bgr = self._produce_frame() - self.display_mode.render(self.api, _crop_to_aspect(frame_bgr)) - return True # False means finished - - def teardown(self): - super().teardown() -``` - -Then add a branch to the configuration loader's scene factory, and any fields -the scene takes to the scene dataclass, so they round-trip through TOML. - -Four things to honour: - -- **`audio` may be `None`.** It is `None` whenever audio is off, the scene sets - `audio = false`, or another system in an ensemble holds the audio slot. -- **Return `False` when finished,** or set `is_done`. The skip path sets it - externally; you may too. -- **Every byte goes through the region-cached write calls.** Opening your own - HTTP session bypasses both the shared connection's mutex and the dirty cache, - and the DMA service accepts one connection. -- **Invalidate the cache** if you change what a cached region means. A display - mode's setup does that for you. - -Set `target_fps` only when the scene genuinely cannot sustain the system rate. -The defaults already account for the link, and a scene that pins a low rate for -no reason simply looks worse. - -## Writing Your Own Overlay or Generator - -Both are small, and both are registered by a decorator rather than by editing a -table. - -### An Overlay - -Three methods, plus the class attributes that declare where it may run: - -```python -@register("blink") -class BlinkOverlay(Overlay): - REQUIRES_PETSCII = False # only touches $D020 - REQUIRES_AUDIO = False - - def setup(self, api, scene): ... - def process_frame(self, api, scene, t): ... - def teardown(self, api, scene): ... -``` - -| Attribute | Meaning | -|---|---| -| `REQUIRES_PETSCII` | It writes PETSCII codes to screen and colour RAM, so it needs a character mode | -| `COMPATIBLE_MODES` | An explicit whitelist, for an overlay that is not a clean fit for that split | -| `REQUIRES_AUDIO` | It cannot work at all without the audio streamer; refused at load when audio is off | -| `WANTS_AUDIO` | It uses the streamer when there is one and has a fallback; never refused | - -Appendix D's compatibility matrix is built from those, and they are checked when -the configuration loads rather than when the overlay would first draw. - -**An overlay that paints characters should not write them itself.** Setting -`PAINTS_INTO_BUFFERS = True` and implementing `compose(buffers, scene, t)` gets -its glyphs folded into the scene's own frame, so scene and overlays go out as -one upload. Writing screen memory from `process_frame` instead races the scene's -own write and flickers. A register write — a border colour, say — is the case -where `process_frame` is the right method. - -Two base classes cover most of what people write: one for single-line corner -text, which brings change detection with it, and one for a scrolling ticker. - -### A Generator or an Effect - -A generator renders 320×200 and returns it; an effect takes a frame and returns -a frame. Both declare their live-tunable parameters as one class attribute: - -```python -LIVE_PARAMS = {"speed": (0.1, 4.0), "scale": (0.5, 8.0)} -``` - -That line is the whole wiring. It puts the parameter in Appendix F, on a MIDI -knob, in the web console's effect rack, and under the WLED sliders, with nothing -else to register. A discrete choice rather than a number goes in `LIVE_CHOICES`, -as a tuple of the values it accepts. - -Only declare **independent single-numeric fields** there. A live write is one -attribute assignment, which is atomic; two fields that must change together are -not. - -Two behavioural rules matter more than the code. A generator should be -**deterministic in time** — the frame at a given moment the same frame however -you arrived at it — because that is what makes an offline render reproducible; -the two shipped exceptions carry real simulation state and say so. And a -reactive generator must **fall back to its time-driven behaviour** at rest, so a -silent scene is still the generator you asked for. - -`docs/extending.md` carries the working examples, the display-mode and -interstitial-background surfaces, and the testing patterns that keep the suite -hardware-free. diff --git a/docs/reference/07-inputs-and-outputs.md b/docs/reference/07-inputs-and-outputs.md index fcb2b40d..0c33a1b2 100644 --- a/docs/reference/07-inputs-and-outputs.md +++ b/docs/reference/07-inputs-and-outputs.md @@ -2,7 +2,7 @@ number: 6 --- -# Everything Outside +# Inputs and Outputs A running show is not a closed loop. Keys are pressed, cameras and microphones feed it, controllers and phones drive it, LED fixtures react to it, a recorder diff --git a/docs/reference/08-extending.md b/docs/reference/08-extending.md new file mode 100644 index 00000000..5de1b5ca --- /dev/null +++ b/docs/reference/08-extending.md @@ -0,0 +1,137 @@ +--- +number: 7 +--- + +# Extending c64cast + +Every other chapter in this book is for somebody using c64cast. This one is for +somebody changing it: adding a scene type, an overlay, a generator or an effect +to the program itself, rather than configuring the ones that ship with it. + +It is deliberately short. The surfaces are small — a scene is four methods, an +overlay three, a generator one — and what makes them small is that the playlist +handles setup order, overlay composition, pacing, fades and teardown, so none of +those pieces has to. What follows is the shape of each surface and the handful +of rules that are not obvious from it. + +`docs/architecture.md` and the notes it indexes are the other half of this. They +explain why each module is built the way it is, including the approaches that +were tried and abandoned — which is what you want before changing one, and not +what you want while writing your first scene. + +## Writing Your Own Scene + +A scene produces one frame of *content* per call. The playlist wraps it with +setup, overlays, pacing and teardown, so a scene never needs to know that +overlays exist. + +```python +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 if it can't sustain system rate + + def setup(self): + super().setup() + self.display_mode.setup(self.api) + + def process_frame(self, current_time: float) -> bool: + frame_bgr = self._produce_frame() + self.display_mode.render(self.api, _crop_to_aspect(frame_bgr)) + return True # False means finished + + def teardown(self): + super().teardown() +``` + +Then add a branch to the configuration loader's scene factory, and any fields +the scene takes to the scene dataclass, so they round-trip through TOML. + +Four things to honour: + +- **`audio` may be `None`.** It is `None` whenever audio is off, the scene sets + `audio = false`, or another system in an ensemble holds the audio slot. +- **Return `False` when finished,** or set `is_done`. The skip path sets it + externally; you may too. +- **Every byte goes through the region-cached write calls.** Opening your own + HTTP session bypasses both the shared connection's mutex and the dirty cache, + and the DMA service accepts one connection. +- **Invalidate the cache** if you change what a cached region means. A display + mode's setup does that for you. + +Set `target_fps` only when the scene genuinely cannot sustain the system rate. +The defaults already account for the link, and a scene that pins a low rate for +no reason simply looks worse. Chapter 5 is what those defaults are reasoning +about. + +## Writing Your Own Overlay or Generator + +Both are small, and both are registered by a decorator rather than by editing a +table. + +### An Overlay + +Three methods, plus the class attributes that declare where it may run: + +```python +@register("blink") +class BlinkOverlay(Overlay): + REQUIRES_PETSCII = False # only touches $D020 + REQUIRES_AUDIO = False + + def setup(self, api, scene): ... + def process_frame(self, api, scene, t): ... + def teardown(self, api, scene): ... +``` + +| Attribute | Meaning | +|---|---| +| `REQUIRES_PETSCII` | It writes PETSCII codes to screen and colour RAM, so it needs a character mode | +| `COMPATIBLE_MODES` | An explicit whitelist, for an overlay that is not a clean fit for that split | +| `REQUIRES_AUDIO` | It cannot work at all without the audio streamer; refused at load when audio is off | +| `WANTS_AUDIO` | It uses the streamer when there is one and has a fallback; never refused | + +Appendix D's compatibility matrix is built from those, and they are checked when +the configuration loads rather than when the overlay would first draw. + +**An overlay that paints characters should not write them itself.** Setting +`PAINTS_INTO_BUFFERS = True` and implementing `compose(buffers, scene, t)` gets +its glyphs folded into the scene's own frame, so scene and overlays go out as +one upload. Writing screen memory from `process_frame` instead races the scene's +own write and flickers. A register write — a border colour, say — is the case +where `process_frame` is the right method. + +Two base classes cover most of what people write: one for single-line corner +text, which brings change detection with it, and one for a scrolling ticker. + +### A Generator or an Effect + +A generator renders 320×200 and returns it; an effect takes a frame and returns +a frame. Both declare their live-tunable parameters as one class attribute: + +```python +LIVE_PARAMS = {"speed": (0.1, 4.0), "scale": (0.5, 8.0)} +``` + +That line is the whole wiring. It puts the parameter in Appendix F, on a MIDI +knob, in the web console's effect rack, and under the WLED sliders, with nothing +else to register. A discrete choice rather than a number goes in `LIVE_CHOICES`, +as a tuple of the values it accepts. + +Only declare **independent single-numeric fields** there. A live write is one +attribute assignment, which is atomic; two fields that must change together are +not. + +Two behavioural rules matter more than the code. A generator should be +**deterministic in time** — the frame at a given moment the same frame however +you arrived at it — because that is what makes an offline render reproducible; +the two shipped exceptions carry real simulation state and say so. And a +reactive generator must **fall back to its time-driven behaviour** at rest, so a +silent scene is still the generator you asked for. + +## Where the Working Code Is + +`docs/extending.md` carries the working examples, the display-mode and +interstitial-background surfaces, and the testing patterns that keep the suite +hardware-free. `CONTRIBUTING.md` has the development environment and the +conventions a change is expected to follow. diff --git a/docs/reference/README.md b/docs/reference/README.md index f02dd5f8..826028e5 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -4,7 +4,8 @@ The second volume. Where the [User's Guide](../guide/README.md) is a book you read in order, this is the one you open at the page you need: the rules of the configuration language, the vocabulary of scenes and overlays, the display and sound paths in full, what lands in the Commodore's memory, and exhaustive -tables of every field, key, parameter and flag. +tables of every field, key, parameter and flag. A closing chapter covers +extending the program rather than configuring it. Its structure is an homage to the *Commodore 64 Programmer's Reference Guide* — in particular that book's willingness to organise by subsystem rather than by