You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
Discoverability — the class declaration states intent (class Keyboard(GuestDevice)),
instead of a reader having to evaluate option scopes at runtime.
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.
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.
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.
Separates the host/presentation side of the emulator from the content side (#38).
Streams vs channels
session.zx); reconstructed on load.settings.json), never to the content description.The
SoundDevice(sample-stream producer) vsSDLSound(channel) split was the first instance of this;headless=Trueis "the content-only set".Consequences
settings.jsonis the host-side state descriptor, dual tosession.zx: the channel set, the streams→channels mapping, and host options (bindings, levels, geometry, "WAV on"). Users mix host configs × content states freely.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 scopealone. 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/DataRecordevent 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:
class Keyboard(GuestDevice)),instead of a reader having to evaluate option scopes at runtime.
Device; the role bases add only the differing default scope and thecapture/restore hook.
isinstance(d, GuestDevice)(recreate) vsHostDevice(carry over), robustwhere a parallel bookkeeping list could desync.
GuestDevice, the carry-overpath only
HostDevice; mypy + a runtime assert catch a miscategoriseddevice, 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 (aHostDevice's options defaultto host scope) but are kept distinct.
Open question the split forces: the sinks/recorders (
PlaybackRecordertoday; 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 designtime. 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)
Emulatoris not aDispatcher(the currentEmulator(Dispatcher)inheritance is the leftover to undo). Two responsibilities are split:
Emulator— persistent. Owns the device set and exposes it (not asecret: iterable /
find_device, for tools and tests), holds the run loop,lifecycle, orchestration, and an internal action queue (below).
Dispatcher— anotify-only capability (aProtocol), transient:created over the current device set at the point dispatch is needed and
discarded, never stored. A device receives it in
on_eventand can donothing 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 mainone, but
reset/load/generate_key_strokesdispatch too.Owner events (
LoadFile/SaveSnapshot/ToggleTapePause) are unaffected bythe lifetime change — two independent axes were conflated:
Emulator,which exists whether the dispatcher is persistent or transient, Emulator or
separate object. Owner events keep flowing through
notify.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 isdeferred: the
Emulatorqueues 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
ScreenWindowalreadyuses (queue SDL input into
self.__events, drain at the end of_on_run_quantum), lifted to the container. "Queue what you cannot safely donow, 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:
Spectrum,Keyboard,Beeper,TapePlayer,PlaybackPlayer,PlaybackRecorder.ScreenWindow(owns no emulation state —pulls pixels via
GetFramePixels),GlobalSettingsManager.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 asource_ratethe incomingSoundPulsesalready carries (rate).ScreenWindow(frame_size)bakes model-dependent dimensions into thewindow/texture.
Work breakdown
A thin spine first (container/framework — the per-device work has
nothing to build against without it):
GuestDevice/HostDevicerole base classes (see "Role base classes"above), so each device declares its role and the container can partition by
type;
Emulator(Dispatcher), makeDispatchera transientnotify-onlyProtocol, move device ownership/exposure toEmulator, migrate the testharnesses off bare
Dispatcher([...]), and route owner-event actionsthrough an
Emulatorqueue drained between quanta;Emulator(two groups; event flow stillcrosses both);
Sessionrecord ([(type, name, state)]) + a sharedGetState/RestoreStateevent;session.zxbecomes aSession(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):
ScreenWindowTapePlayerPlaybackPlayer.rzx-as-slice caseSoundDevicemodelctor param, size budget from stream rate)Spectrumto_snapshot()via the state eventKeyboard,BeeperDemand-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.