Remote optical ripping
June 27, 2026 · View on GitHub
Status: design / not yet implemented — tick the Progress tracker
as work lands. Author context: follow-on to the MiSTer optical work
(optical + remote are both compiled into rb-cli-mini and the desktop app
today).
Legend: [ ] todo · [~] in progress · [x] done & verified · [!] blocked /
needs decision · [-] dropped.
Motivation
The SuperStation One (and other MiSTer-family armv7 devices) can have a CD/DVD
drive attached, and rb-cli optical rip already runs on-device. But the
Cyclone V's ~800 MHz dual Cortex-A9 is a poor fit for the CPU-heavy part of
ripping: CHD compression (LZMA / zlib / FLAC over ~700 MB of sector data)
takes many minutes and pins the device.
The fix: let the MiSTer do only what must be local to the drive — issue
SCSI READ TOC / READ CD commands, run the read-retry loop, eject — and have
the desktop do all the encoding. Only raw sector bytes cross the LAN; no
compression happens on the device.
MiSTer (rb-cli serve) Desktop (rb app / rb-cli)
────────────────────── ─────────────────────────
cd-da-reader: READ TOC / READ CD RemoteCdReader (read_toc, read_data_sectors)
RetryConfig backoff loop (near drive) write local .bin + .cue / .iso
ship raw 2352-byte sectors ───────► CHD compress (libchdman) ← CPU-heavy, stays here
eject ◄───────
Goals / non-goals
Goals
- Drive a remote optical drive from the desktop GUI and the CLI.
- All encoding (ISO assembly, BIN/CUE assembly, CHD compression) on the desktop.
- A single unified device picker: local drives + every connected daemon's drives in one list/pulldown, each tagged with where it lives.
- CLI/GUI parity (
rb-cli optical rip --device rb://host:port/dev/sr0).
Non-goals (for the first cut)
- Multi-session / multiple simultaneous rips against one daemon (the drive and
cd-da-reader's handle are singular — see "One-session constraint"). - Subchannel/C2-pointer faithful "secure" ripping, GD-ROM, multisession.
- Audio-CD metadata lookup (MusicBrainz, etc.).
The clean seam (why this is small)
src/optical/rip.rs touches the physical drive through exactly three
cd-da-reader calls — CdReader::open, read_toc, read_data_sectors — plus
eject_disc. Everything else (rip_iso/rip_bin_cue write loops,
generate_cue_sheet, convert::to_chd) is already plain local code running in
the desktop worker thread. So we abstract those calls behind a trait and swap
the implementation; the encode half never moves.
// new: src/optical/source.rs
pub trait OpticalSource: Send {
fn read_toc(&self) -> Result<cd_da_reader::Toc>;
fn read_data_sectors(&self, lba: u32, count: u32,
mode: cd_da_reader::SectorReadMode) -> Result<Vec<u8>>;
fn eject(&self) -> Result<()>;
}
pub struct LocalCdReader { inner: cd_da_reader::CdReader, device_path: String, retry: RetryConfig }
pub struct RemoteCdReader { conn: Arc<Mutex<RemoteConnection>>, handle: u64 }
LocalCdReaderwraps today'scd_da_reader::CdReader(retry applied locally,eject= the existingeject_discshell-out).RemoteCdReaderproxies each method to the daemon (retry applied daemon-side,ejectproxied).
rip_iso / rip_bin_cue change only in that they take &dyn OpticalSource
instead of constructing CdReader directly. run_rip builds the right impl
from the (new) target. Note: read_data_sectors drops the &RetryConfig
arg from the trait — retry belongs next to the drive, so it is supplied at
open time (passed verbatim to a LocalCdReader, serialized to the daemon for
a RemoteCdReader).
RipConfig target
pub enum OpticalTarget {
Local(String), // "/dev/sr0"
Remote { conn: Arc<Mutex<RemoteConnection>>, device_path: String },
}
// RipConfig.device_path: PathBuf -> RipConfig.device: OpticalTarget
run_rip:
let src: Box<dyn OpticalSource> = match &config.device {
OpticalTarget::Local(p) => Box::new(LocalCdReader::open(p, &config.retry)?),
OpticalTarget::Remote{conn, device_path} => Box::new(RemoteCdReader::open(conn.clone(), device_path, &config.retry)?),
};
Daemon protocol additions (the "optical tier")
The wire protocol (src/remote/protocol.rs) is JSON control frames
([u32 LE len][JSON] via write_control/read_control) plus a bulk path
(Response::FileBegin{size} followed by a ChunkWriter stream of
[u32 n][n bytes]…[u32 0]). The block tier's ReadBlock already returns bytes
this way — the optical sector read reuses it verbatim.
New capability bit (advertised in Hello.capabilities only when the daemon
was built with the optical feature — which rb-cli-mini is):
pub const CAP_FAMILY_O: u16 = 1 << 2; // optical drive proxy
New Request variants (protocol.rs), dispatched in server.rs's
handle_conn match:
| Request | Response | Server action |
|---|---|---|
ListOpticalDrives | OpticalDrives { drives: Vec<WireOpticalDrive> } | cd_da_reader::CdReader::list_drives() |
OpenOptical { path, retry: WireRetryConfig } | OpticalOpened { handle } | CdReader::open(path); store in OpticalHandle |
ReadToc { handle } | Toc { toc: WireToc } | reader.read_toc() |
ReadOpticalSectors { handle, lba, count, mode } | FileBegin { size } + chunk stream | reader.read_data_sectors(lba,count,mode,&retry) |
EjectOptical { handle } | Ok | proxy of eject_disc against the device |
CloseOptical { handle } | Ok | drop the CdReader, free the global handle |
ReadOpticalSectors size = count * mode.sector_size() (2048 cooked /
2352 raw|audio), capped to the existing MAX_RANGE_READ (4 MiB) — the desktop
already requests SECTORS_PER_CHUNK = 128 sectors (~301 KB raw) per call.
Server handle store: add struct OpticalHandle { reader: cd_da_reader::CdReader, retry: RetryConfig, device_path: String } alongside the existing BlockHandle. Errors map to Response::Error{message} (the desktop re-wraps as an anyhow/rip error).
One-session constraint (important)
cd_da_reader::CdReader stores the drive fd in a process-global
static mut DRIVE_HANDLE, so a process can hold one open optical drive at
a time. The daemon therefore serializes optical sessions: a second OpenOptical
while one is live returns Error{"optical drive busy"}. For a one-drive MiSTer
this is the natural limit; document it rather than engineer around it.
Elevation
cd_da_reader::open uses O_RDWR (SCSI pass-through needs write access to the
device node). The daemon must run with permission to open /dev/sr0 — i.e. as
root on the MiSTer, the same elevated rb-daemon posture already used for
remote physical-disk backup.
Wire DTOs
cd-da-reader's public types have all-pub fields but no serde/Clone,
so mirror them in src/remote/protocol.rs with From conversions (no change to
cd-da-reader needed — we both encode from and reconstruct the real types):
#[derive(Serialize, Deserialize)] pub struct WireTrack { pub number:u8, pub start_lba:u32, pub start_msf:(u8,u8,u8), pub is_audio:bool }
#[derive(Serialize, Deserialize)] pub struct WireToc { pub first_track:u8, pub last_track:u8, pub tracks:Vec<WireTrack>, pub leadout_lba:u32 }
#[derive(Serialize, Deserialize)] pub enum WireSectorMode { Audio, DataCooked, DataRaw }
#[derive(Serialize, Deserialize)] pub struct WireRetryConfig { /* mirrors RetryConfig's 5 fields */ }
#[derive(Serialize, Deserialize)] pub struct WireOpticalDrive { pub device_path:String, pub display_name:String, pub has_audio_cd:bool }
// From<cd_da_reader::Toc> for WireToc, From<&WireToc> for cd_da_reader::Toc, etc.
Unified device picker (local + remote in one list)
A small testable core in src/model/optical_devices.rs:
pub enum DeviceLocation { Local, Remote { conn: Arc<Mutex<RemoteConnection>>, label: String } } // label = "host:port"
pub struct RipDevice { pub display_name: String, pub device_path: String, pub location: DeviceLocation }
pub fn list_rip_devices(remotes: &[Arc<Mutex<RemoteConnection>>]) -> Vec<RipDevice>;
// local: cd_da_reader::CdReader::list_drives() -> RipDevice{ Local }
// remote: for each conn whose Hello advertised CAP_FAMILY_O, send ListOpticalDrives
// -> RipDevice{ Remote{ conn, label } } (skip daemons without the optical capability)
RipDevice::into_target() yields the right OpticalTarget (Local(path) or
Remote{conn, device_path}) for RipConfig.
GUI changes (src/gui/optical_tab.rs)
- Replace the local-only drive combo (today
refresh_drives→opticaldiscs::drives::list_drives) with the unified list fromlist_rip_devices. Each entry labeled by location:- local:
TSSTcorp CD/DVDW SH-224 (/dev/sr0) - remote:
[mister.local:7341] HL-DT-ST GP65NB60 (/dev/sr0)
- local:
- Drop the
SourceMode::PhysicalDrivevs separate-remote split — a device is a device; the picker carries its location. (SourceMode::ImageFilestays for the convert/browse-an-image path.) - Add a small "Add remote daemon..." affordance that pops the existing
host:port connect dialog (reuse
RemoteConnection::connect_sharedand the dialog already used byRemoteBrowsePanel/ the Backup tab'sRemoteSourceState). Connected daemons are remembered for the session (and optionally persisted inconfig.json);Refreshre-queries local + all remotes. start_rip/start_rip_to_chdbuildRipConfig.device=selected_device.into_target(). Nothing in the encode path changes —rip_to_chd_workerstill rips to a local temp.bin/.cueand runsconvert::to_chdon the desktop.
The connect → list → pick → run worker-thread/Arc<Mutex<…>> pattern is copied
straight from BackupTab::RemoteSourceState.
CLI changes (src/cli/verbs/optical.rs)
rb-cli optical drives [--remote host:port]...— lists local drives, plus each--remotedaemon's drives (oneListOpticalDrivesper connection). Output stays<device-path> <display-name>, with remote rows prefixed[host:port].rb-cli optical rip --device <PATH-or-URL> ...—--deviceaccepts a local path (/dev/sr0) or anrb://host:port/dev/sr0URL. The singleCdReader::openseam parses the scheme:rb://→RemoteRef::parse+connect_shared→OpticalTarget::Remote; elseOpticalTarget::Local.- All output formats (
--format iso|bincue, and CHD viaoptical convertor a future--format chd) work identically — encoding is always local.
The CHD constraint (already handled)
CD-CHD creation is strictly path-based: libchdman_rs::cd::create_from_cue /
create_from_iso need a real on-disk .cue/.bin/.iso (MAME's parse_toc).
So the desktop materializes a local temp BIN+CUE, then compresses — which is
exactly what rip_to_chd_worker already does (rip → temp .cue/.bin →
to_chd → delete temps). The remote reader just feeds that same two-step flow;
no new wrinkle.
Data volumes / network
- Raw sector = 2352 B (BIN/CUE, all tracks); cooked ISO = 2048 B.
- Full ~74-min disc ≈ 333,000 sectors ≈ ~780 MB raw streamed MiSTer→desktop.
- Desktop requests
SECTORS_PER_CHUNK = 128sectors (~301 KB) perReadOpticalSectors; the daemon re-splits into ≤27-sectorREAD CDcommands internally (stays device-side). - Tradeoff: you ship ~780 MB raw over the LAN instead of a smaller compressed CHD — the right call when the alternative is pinning the armv7 CPU for minutes. On wired gigabit (~110 MB/s) the transfer is a few seconds of wall-clock per minute of audio; on 100 Mbit it is bounded by the link, not the device CPU.
Progress tracker
Update the marker as each item lands; keep the one-line note current (date +
commit when done, or the blocker for [!]). Phases are roughly sequential but
P1.1 (the local refactor) is independent and can land first on its own.
Phase 0 — prerequisites
-
optical+remoteboth compiled intorb-cli-miniand the desktop build, so the MiSTer can runrb-cli serveand drive the local optical stack. (done — the MiSTer optical PR.)
Phase 1 — streaming ISO / BIN-CUE over the wire (no encode change)
- P1.1 —
OpticalSourceseam (local-only refactor, no behavior change). (done 2026-06-27) Newsrc/optical/source.rswith the trait +LocalCdReader;rip_iso/rip_bin_cuetake&dyn OpticalSource;run_ripbuilds the source viaopen_optical_source;ejectmoved behind the trait.RipConfigkeepsdevice_pathfor now — theOpticalTargetswitch is deferred to P1.7 where the remote dispatch needs it (avoids aremote-feature-gated enum variant with no consumer yet). Verified: optical unit tests green, no behavior change. - P1.2 — wire DTOs. (done 2026-06-27)
WireToc/WireTrack/WireSectorMode/WireRetryConfig/WireOpticalDriveinprotocol.rs(always underremote);Fromconversions to/from thecd-da-readertypes gated behindoptical(in aoptical_convsubmodule). Serde round-trip + conversion unit tests green. - P1.3 — protocol surface. (done 2026-06-27)
CAP_FAMILY_O = 1 << 2; the six opticalRequestvariants (ListOpticalDrives/OpenOptical/ReadToc/ReadOpticalSectors/EjectOptical/CloseOptical) + theOpticalOpened/Toc/OpticalDrivesresponses (sector data reusesFileBegin+ chunk stream). - P1.4 — daemon handlers. (done 2026-06-27)
server.rsoptical_servermodule: a per-connectionOpticalStatethat wraps aLocalCdReader(reusing the P1.1 read/eject ops) + a process-globalAtomicBoolbusy guard (released on session drop / connection teardown, sincecd-da-readerholds a global handle); dispatch arms for all six verbs (not(optical)builds reply "built without the optical feature");HelloadvertisesCAP_FAMILY_Ounderoptical. Builds clean in optical / remote-only configs. Requires the elevated daemon (root) to open/dev/sr0O_RDWR. - P1.5 — client methods. (done 2026-06-27)
RemoteSession::{list_optical_drives, open_optical, read_toc, read_optical_sectors, eject_optical, close_optical}inclient.rs(return the Wire DTOs, so noopticalgate), delegated throughRemoteConnectioninconnection.rs. - P1.6 —
RemoteCdReader. (done 2026-06-27)source.rs(gatedremote) implementsOpticalSourceover anArc<Mutex<RemoteConnection>>; retry sent at open,Dropcallsclose_opticalto free the daemon's slot. - P1.7 — wire it up. (done 2026-06-27)
OpticalTarget(Local|Remote{conn, device_path},Remotearm#[cfg(feature = "remote")]) with a manualDebug(hides the conn) +resolve()that parses anrb://host:port/dev/sr0device arg into a remote connection;RipConfig.devicereplacesdevice_path;open_optical_sourcebranches Local/Remote. CLIoptical rip --device rb://…works (pulled the CLI URL-parse forward from P3.3); GUI/CLI local call sites buildOpticalTarget::Local. Builds clean in optical+remote / optical-only / default(GUI); 14 optical unit tests green. - [~] P1.8 — validate. (plumbing verified 2026-06-27)
tests/remote_optical.rs: loopback client↔daemon test green — theoptical-built daemon handlesListOpticalDrives(round-trips, doesn't disclaim the feature), andOpenOpticalof a bogus device errors cleanly and releases the process-global busy guard (a second open fails at open, not with "busy"). Remaining: the byte-identical rip-a-real-disc validation (desktop ↔ a daemon on a Linux box with an actual drive) needs hardware — user's SuperStation / a networked box.
Phase 2 — CHD compression on the desktop
- P2.1 — remote → CHD. (done 2026-06-27 with P3.2)
rip_to_chd_workertakes anOpticalTarget, so a remote rip → local temp.bin/.cue→convert::to_chdruns entirely on the desktop. No protocol change — the encode was always caller-side, so a remote source "just works". Reachable from the GUI (CHD output + a remote drive) and the CLI two-step (optical rip --device rb://… --format bincuethenoptical convert disc.cue disc.chd). - [~] P2.2 — validate. Needs hardware: confirm a remote-ripped CD-CHD opens
in
chdman info/ loads in MAME and the device CPU stays idle during compression (spot-checktop). User's device.
Phase 3 — unified device picker + CLI parity
- P3.1 — picker core. (done 2026-06-27)
src/model/optical_devices.rs:RipDevice/DeviceLocation(Local|Remote{conn,label},Remotegatedremote);list_local_rip_devices+append_remote_rip_devices(errors swallowed → offline/non-optical daemons add nothing, which also capability-gates the picker) +list_rip_devices;picker_label/cli_device_arg/into_targethelpers. Unit test (local label/arg/target) + loopback test (remote_rip_device_enumeration_over_loopback) green. - P3.2 — GUI. (done 2026-06-27, compile-validated; runtime UI pending a
user check)
optical_tabnow holds a unifiedrip_devices: Vec<RipDevice>+remote_daemons; the drive combo lists local + remote drives bypicker_label; an "Add remote daemon…" modal connects on a worker thread (ConnectStatus/poll_add_remote, non-freezing) and unlocks the Physical-drive mode. Rip dispatches viaRipDevice::to_target();start_rip_to_chd/rip_to_chd_workertake anOpticalTarget(encode still local). Remote drives are rip-only —get_browsable_pathreturnsNonefor them (disc-info/browse open the device locally). - P3.3 — CLI. (done 2026-06-27)
optical drives --remote host:port(repeatable) lists local + each daemon's drives via the picker core, printing a feedable<device-arg>(rb://host:port/dev/sr0for remote rows).optical rip --device rb://…landed in P1.7. - P3.4 — polish. (done 2026-06-27) Capability gating falls out of
append_remote_rip_devices(a daemon withoutopticalerrors onlist_optical_drives→ contributes no drives). Location-aware eject is automatic viaOpticalSource::eject(local shells out locally; remote sendsEjectOpticalto the daemon); the GUI eject checkbox gained a hover note. - P3.5 — MRU of daemon addresses. (done 2026-06-27, GUI-only)
UpdateConfig.recent_daemon_addrs(inconfig.json) +remember_daemon()(dedup, newest-first, capped at 8). On a successful connect the Optical tab records the address; the "Add remote daemon" dialog shows a "Recent:" quick-pick list (one click re-connects). Persists across sessions; unit-tested. Note: it's a pick list, not auto-reconnect — avoids blocking startup on an offline daemon.
Done criteria (cross-cutting)
- README +
docs/full_MiSTer_support_status.mdnote remote ripping. (done 2026-06-27) README MiSTer build list gained a "Remote ripping off-device" bullet; the support-status intro gained a "Remote optical ripping" capability line. - CLI/GUI parity. Both surfaces list local + remote drives and rip
iso/bincue/chd from either; remote addressed identically (
rb://…device arg vs the GUI's "Add remote daemon"). -
DISK_IMAGE_EXTS/ picker filters unaffected. Remote ripping adds no new container/file types (it reuses the optical formats), so nothing to update there.
When actively working a phase, mirror its open items into the session task list (
TaskCreate) for in-flight tracking; this doc stays the durable record.
File-by-file touch points
| File | Change |
|---|---|
src/optical/source.rs (new) | OpticalSource trait, LocalCdReader, RemoteCdReader |
src/optical/rip.rs | rip_iso/rip_bin_cue take &dyn OpticalSource; RipConfig.device: OpticalTarget; eject via the trait |
src/remote/protocol.rs | CAP_FAMILY_O; optical Request/Response variants; WireToc/WireTrack/WireSectorMode/WireRetryConfig/WireOpticalDrive + From impls |
src/remote/server.rs | OpticalHandle; dispatch arms for the optical tier; one-session guard; advertise CAP_FAMILY_O |
src/remote/client.rs | RemoteSession: list_optical_drives, open_optical, read_toc, read_optical_sectors, eject_optical, close_optical |
src/model/optical_devices.rs (new) | RipDevice, DeviceLocation, list_rip_devices |
src/gui/optical_tab.rs | unified picker; "Add remote daemon" dialog; OpticalTarget wiring (encode path unchanged) |
src/cli/verbs/optical.rs | optical drives --remote; optical rip --device rb://… scheme parse |
docs/full_MiSTer_support_status.md, README.md | note remote ripping once shipped |
Open questions / risks
- One-session serialization — acceptable for one drive; revisit only if a
daemon ever fronts multiple drives (would need per-drive handles, which
cd-da-reader's global handle blocks today). - Error fidelity —
CdReaderError/ScsiErrorcollapse to a string over the wire; the desktop loses the structured SCSI sense. Acceptable; log the message. - Connection reuse — the GUI should reuse the connection it listed drives
with for the rip (don't reconnect). The CLI's
rb://path connects fresh. - Eject semantics —
EjectOpticalejects the remote tray; make the GUI button label/location-aware so it's obvious which machine ejects. - Capability gating — only offer remote drives from daemons whose
HelloadvertisedCAP_FAMILY_O; older/non-optical daemons simply don't appear.
Reuse (what already exists)
RemoteConnection::connect_shared(src/remote/connection.rs) — shared, brokered session; the model forRemoteCdReader's transport.RemoteBlockReader(src/remote/block_reader.rs) — the structural template (handle lifecycle,Dropcloses the daemon handle).write_control/read_control+FileBegin/ChunkWriter/read_chunks(src/remote/protocol.rs) — framing for the TOC (JSON) and sectors (bulk).BackupTab::RemoteSourceState(src/gui/backup_tab.rs) — the connect → list → pick → worker-thread/poll GUI pattern.