Skip to content

Host-side architecture: streams and channels #40

Description

@kosarev

Separates the host/presentation side of the emulator from the content side (#38).

Streams vs channels

  • Content devices are signal generators radiating streams (data): video, sound, tape-out. Their state is content-scoped and travels in the machine/session description (session.zx); reconstructed on load.
  • Host devices are channels: host ports that present or consume a stream (a GUI window, SDL sound out, a WAV writer, a video recorder). Stateless re content (pure host resources), but may hold host-scoped state (window geometry, file-panel path) persisted to host prefs (settings.json), never to the content description.
  • Channels subscribe to streams; the streams→channels mapping is the host configuration (which channel shows/plays which stream, and how — resampling, with/without colour attributes, …).

The SoundDevice (sample-stream producer) vs SDLSound (channel) split was the first instance of this; headless=True is "the content-only set".

Consequences

  • A second window is not a device-set change — it is a host-side channel creation; the streams already radiate, the user just points the new channel at one (main/shadow screen, debugger, tape UI).
  • settings.json is the host-side state descriptor, dual to session.zx: the channel set, the streams→channels mapping, and host options (bindings, levels, geometry, "WAV on"). Users mix host configs × content states freely.
  • On load the Emulator reconstructs the content (stream generators) and reconciles only the streams→channels mapping; channels persist host-side, and a channel reconciles its own side — the crisp form of "carry over the durable devices".

Relation

This is the host-side counterpart to #38 (the content-side reconstructable device set). Mostly future / demand-driven. Design notes live in CLAUDE.md.

Role base classes (decided 2026-06-26)

The content/host distinction is carried by role base classes — a single
hierarchy Device → {GuestDevice, HostDevice} — not by per-option scope
alone. The earlier "one uniform mechanism, not a parallel class hierarchy"
framing was a conflation and is corrected here: "no parallel hierarchy" only
means do not fork the mechanism (one on_event/Dispatcher/DataRecord
event system routes every device identically). Splitting the role at the
leaf type is not a second mechanism, so it is fine.

The justification is lifecycle: a guest device is reconstructed from its
content state slice on load, a host device is carried over and never
reconstructed from content. That reconstruct-vs-persist fact is a whole-device
property (you cannot recreate half a device), so host-vs-guest is a total,
device-level partition
— exactly what a type should carry. The bases are not
empty markers; each holds a differing contract:

  • GuestDevice: reconstructed on load; state/options default to content scope;
    produces/restores a content state slice.
  • HostDevice: never reconstructed from content; carried over across loads;
    state/options default to host scope (persisted to settings.json).

This buys, concretely:

  1. Discoverability — the class declaration states intent (class Keyboard(GuestDevice)),
    instead of a reader having to evaluate option scopes at runtime.
  2. Unified options — the generic option machinery stays in the common
    Device; the role bases add only the differing default scope and the
    capture/restore hook.
  3. Reload partition — on load the Emulator partitions the live set with
    isinstance(d, GuestDevice) (recreate) vs HostDevice (carry over), robust
    where a parallel bookkeeping list could desync.
  4. Enforcement — the rebuild path accepts only GuestDevice, the carry-over
    path only HostDevice; mypy + a runtime assert catch a miscategorised
    device, which scope-data alone cannot.

Reconciliation with "scope resolved at the option level": these are different
axes. Lifecycle role (recreate vs persist) is per-device and total → the
base class. Option scope (where one setting is persisted) stays per-option
and finer → SettingScope. They usually align (a HostDevice's options default
to host scope) but are kept distinct.

Open question the split forces: the sinks/recorders (PlaybackRecorder
today; future WAV/video recorders) are ambiguous — a recorder consumes the
emulated output stream (channel-like → host) yet its product is a content
artefact (a UnifiedPlayback). Assigning a base forces this call at design
time. Orthogonal axes to keep clear: active/inactive (the model flag) stays
a fluid flag, not a type; backend-abstraction bases nest underneath
(Device → HostDevice → SoundDevice → SDLSound).

Dispatcher lifetime and owner events (decided 2026-06-26)

Emulator is not a Dispatcher (the current Emulator(Dispatcher)
inheritance is the leftover to undo). Two responsibilities are split:

  • Emulator — persistent. Owns the device set and exposes it (not a
    secret: iterable / find_device, for tools and tests), holds the run loop,
    lifecycle, orchestration, and an internal action queue (below).
  • Dispatcher — a notify-only capability (a Protocol), transient:
    created over the current device set at the point dispatch is needed and
    discarded, never stored. A device receives it in on_event and can do
    nothing but notify — it cannot enumerate peers or reach the container.
    That closes the leak at the type level (today a device could for d in dispatcher).

Why transient, not just an interface tweak: it is what keeps dispatch correct
across reconstruct-on-load. The guest set is rebuilt on load, so a
persistent dispatcher would iterate a stale list; a dispatcher created per
operation always dispatches over the current set. This also finishes the
already-made decision that the dispatcher is "threaded through the run, never
stored on Python". Lifetime is per dispatch operation — run() is the main
one, but reset/load/generate_key_strokes dispatch too.

Owner events (LoadFile/SaveSnapshot/ToggleTapePause) are unaffected by
the lifetime change — two independent axes were conflated:

  • Catching depends only on an interception hook tied to the Emulator,
    which exists whether the dispatcher is persistent or transient, Emulator or
    separate object. Owner events keep flowing through notify.
  • Acting is the only real concern, and only because acting may rebuild
    the device set
    (Host-side architecture: streams and channels #40). Doing that inline — a device raised the event from
    inside on_event — would mutate a set mid-iteration. So the action is
    deferred: the Emulator queues it and drains at a boundary between quanta,
    where a rebuild is safe; the next iteration's transient dispatcher is then
    built over the new set.

This is not a new mechanism — it is exactly the pattern ScreenWindow already
uses (queue SDL input into self.__events, drain at the end of
_on_run_quantum), lifted to the container. "Queue what you cannot safely do
now, drain later."

Current state (grounded in the code, 2026-06-26)

The content/host line is already clean — no device is half-and-half in
its logic or persisted state. Audited:

  • Already pure content: Spectrum, Keyboard, Beeper, TapePlayer,
    PlaybackPlayer, PlaybackRecorder.
  • Already pure host channel: ScreenWindow (owns no emulation state —
    pulls pixels via GetFramePixels), GlobalSettingsManager.
  • The one real impurity is the same in two host channels: each is
    parameterised at construction by a content fact instead of reading it
    from the stream it consumes, so it is silently pinned to one model's
    geometry and cannot survive a content swap:
    • SoundDevice(model) recomputes a source_rate the incoming
      SoundPulses already carries (rate).
    • ScreenWindow(frame_size) bakes model-dependent dimensions into the
      window/texture.

Work breakdown

A thin spine first (container/framework — the per-device work has
nothing to build against without it):

  • the GuestDevice/HostDevice role base classes (see "Role base classes"
    above), so each device declares its role and the container can partition by
    type;
  • the dispatcher split (see "Dispatcher lifetime and owner events"): drop
    Emulator(Dispatcher), make Dispatcher a transient notify-only
    Protocol, move device ownership/exposure to Emulator, migrate the test
    harnesses off bare Dispatcher([...]), and route owner-event actions
    through an Emulator queue drained between quanta;
  • content/host construction split in Emulator (two groups; event flow still
    crosses both);
  • a Session record ([(type, name, state)]) + a shared
    GetState/RestoreState event; session.zx becomes a Session
    (captures tape + playback + core, not core-only).

Then per-device conformance (only the devices that need work; bundling
purification + state slice where they are the same edit):

Device Work Size
ScreenWindow purify (dimensions from stream + dynamic texture resize); host geometry state real
TapePlayer produce/restore state slice (loaded tape + position) real
PlaybackPlayer state slice (playback + position) — the .rzx-as-slice case real
SoundDevice purify (drop model ctor param, size budget from stream rate) small
Spectrum expose existing to_snapshot() via the state event small
Keyboard, Beeper already pure, no persisted content state none

Demand-driven: spin out a real sub-ticket only when actually starting one;
reconstruct-on-load carrying over host channels stays the deferred body of
this issue.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions