From acc3606c01c9b0b66d810ffd582e5f3ddcdcc309 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Wed, 22 Jul 2026 09:51:32 +0100 Subject: [PATCH 1/2] launcher: add the A2065 Ethernet board to the I/O Ports tab The machine-configuration screen gains an Ethernet: section with an A2065 board picker (Not fitted / Isolated / Loopback / NAT), emitted as [a2065] net through the existing config pipeline so saved files match the hand-written schema. NAT is only offered when the userspace NAT is compiled in (the net-nat feature); without it the choice would fit a board that never connects. Selecting NAT draws a warning under the rows: inbound traffic follows the host clock, so input recordings and save-state replays are not byte-identical while it flows. The loopback backend echoes frames on the emulated clock and an isolated or absent NIC never sees traffic, so only NAT warns. Save states already handle a fitted board (the backend is a host resource rebuilt on load), so snapshotting stays enabled. net_config_name() joins parse_net_config() as its inverse for emitting the backend name from the launcher. --- docs/guide/configuration.md | 5 +- docs/guide/ui.md | 11 ++-- docs/zorro.md | 4 +- src/net/mod.rs | 10 ++++ src/video/launcher.rs | 109 ++++++++++++++++++++++++++++++++++-- src/video/ui.rs | 58 +++++++++++++++++++ 6 files changed, 186 insertions(+), 11 deletions(-) diff --git a/docs/guide/configuration.md b/docs/guide/configuration.md index c9e4999..800a449 100644 --- a/docs/guide/configuration.md +++ b/docs/guide/configuration.md @@ -901,8 +901,9 @@ net = "nat" # or "loopback"; "none" for an isolated NIC ``` Fits a Commodore A2065 Ethernet board (Am7990 LANCE) on the Zorro chain; -`--a2065-net BACKEND` is the matching per-run flag. `net` selects the host -network backend: +`--a2065-net BACKEND` is the matching per-run flag, and the launcher's +**I/O Ports** tab (Ethernet section) has the same picker. `net` selects the +host network backend: - `"nat"` -- userspace NAT: the guest gets outbound IPv4 internet through a virtual gateway with no host privileges or setup, identically on Linux, diff --git a/docs/guide/ui.md b/docs/guide/ui.md index 4326ff1..49cb467 100644 --- a/docs/guide/ui.md +++ b/docs/guide/ui.md @@ -248,10 +248,13 @@ The layout is: insert delay, CD32 NVRAM), *Input* (the controller device in each game port and the joystick input source), - *I/O Ports* (the serial and parallel ports under **Serial:** / **Parallel:** - headings: serial mode and MIDI endpoints; and the parallel device -- None, - Printer, or Sampler -- with, for the printer, its capture output file, or for - the sampler, its host audio input and input gain), + *I/O Ports* (the serial, parallel, and Ethernet ports under **Serial:** / + **Parallel:** / **Ethernet:** headings: serial mode and MIDI endpoints; the + parallel device -- None, Printer, or Sampler -- with, for the printer, its + capture output file, or for the sampler, its host audio input and input gain; + and the A2065 Ethernet board -- Not fitted, Isolated, Loopback, or NAT, with + a warning when NAT is selected, since its host-clocked traffic makes input + recordings and save-state replays non-reproducible while it flows), *Zorro* (extra autoconfig boards by metadata file, with a config panel for a WASM plugin board's declared options), and *A/V & Emu* (audio output device, channel mode, stereo diff --git a/docs/zorro.md b/docs/zorro.md index 1077a2f..ca0ef57 100644 --- a/docs/zorro.md +++ b/docs/zorro.md @@ -186,7 +186,9 @@ config: net = "nat" # host network backend; "loopback", or "none" for isolation ``` -(`--a2065-net BACKEND` is the matching per-run flag.) +(`--a2065-net BACKEND` is the matching per-run flag, and the launcher's +**I/O Ports** tab has the same picker under its **Ethernet:** heading, with a +warning shown when the non-deterministic NAT backend is selected.) Unlike the DMAC boards, the LANCE does not master the Amiga bus: its init block, descriptor rings, and packet buffers live in the board's own 32 KiB RAM diff --git a/src/net/mod.rs b/src/net/mod.rs index 2d1fdcf..57ca8ab 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -103,6 +103,16 @@ pub fn parse_net_config(s: &str) -> Option { } } +/// The canonical config-file spelling of a [`NetConfig`]: the inverse of +/// [`parse_net_config`], used when emitting a config from the launcher. +pub fn net_config_name(cfg: NetConfig) -> &'static str { + match cfg { + NetConfig::None => "none", + NetConfig::Loopback => "loopback", + NetConfig::Nat => "nat", + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/video/launcher.rs b/src/video/launcher.rs index c33c541..a8e96fe 100644 --- a/src/video/launcher.rs +++ b/src/video/launcher.rs @@ -28,6 +28,7 @@ use crate::config::{ RawConfig, RawDrive, RawFilesysMount, RawFloppyDrive, RawZorroBoard, RtgCard, ScsiController, SerialMode, WarpSpeed, }; +use crate::net::NetConfig; use crate::zorro::{ConfigOption, ConfigOptionKind, LoadedZorroBoard}; use anyhow::Result; use std::borrow::Cow; @@ -307,6 +308,9 @@ pub enum LauncherField { ParallelOutput, SamplerInput, SamplerGain, + /// The A2065 Ethernet board: absent, or fitted with a chosen host + /// backend (isolated / loopback / NAT). + Ethernet, /// Inert field for a non-interactive [`RowKind::SectionHeader`] row. SectionHeader, // A/V and emulation @@ -524,6 +528,7 @@ const PARALLEL_ROWS_SAMPLER: [Row; 3] = [ row(F::SamplerInput, "Audio input", Cycle), row(F::SamplerGain, "Input gain", Cycle), ]; +const ETHERNET_ROWS: [Row; 1] = [row(F::Ethernet, "A2065 board", Cycle)]; const AV_EMULATION_ROWS: [Row; 12] = [ row(F::AudioDevice, "Audio output", Cycle), row(F::AudioChannelMode, "Channel mode", Cycle), @@ -584,8 +589,9 @@ pub fn rows( } /// The I/O Ports tab: a `Serial:` section (only in a `midi` build, which is the -/// only build with serial rows) then a `Parallel:` section, each under a greyed -/// heading and each showing only the rows relevant to its selected device/mode. +/// only build with serial rows), a `Parallel:` section, then an `Ethernet:` +/// section, each under a greyed heading and each showing only the rows relevant +/// to its selected device/mode. fn io_ports_rows(serial_mode: SerialMode, parallel_device: ParallelDevice) -> Vec { let mut rows = Vec::new(); let serial = serial_rows(serial_mode); @@ -595,6 +601,8 @@ fn io_ports_rows(serial_mode: SerialMode, parallel_device: ParallelDevice) -> Ve } rows.push(section_header("Parallel:")); rows.extend_from_slice(parallel_rows(parallel_device)); + rows.push(section_header("Ethernet:")); + rows.extend_from_slice(ÐERNET_ROWS); rows } @@ -764,6 +772,14 @@ const SERIAL_MODES: [SerialMode; 6] = [ SerialMode::TcpConnect, SerialMode::Pty, ]; +// `None` = no A2065 fitted; `Some(NetConfig::None)` fits the board with an +// isolated NIC (the guest sees the hardware but no traffic ever arrives). +const ETHERNET_CHOICES: [Option; 4] = [ + None, + Some(NetConfig::None), + Some(NetConfig::Loopback), + Some(NetConfig::Nat), +]; /// Stereo-separation presets the picker steps through (percent), ascending so /// the right arrow steps up (wrapping 100 -> 0) and the left arrow steps down. @@ -879,6 +895,10 @@ pub struct MachineSetup { /// edited in the I/O Ports tab's Parallel section. sampler_input: Option, sampler_gain_db: f32, + /// The A2065 Ethernet board, edited in the I/O Ports tab's Ethernet + /// section: `None` = not fitted, `Some(backend)` fits the board with that + /// host backend (`NetConfig::None` = fitted but isolated). + a2065_net: Option, /// Input device names for the sampler picker: filled when the screen opens /// and re-read each time the field is cycled, so a reconnected device /// appears. @@ -1006,6 +1026,7 @@ impl MachineSetup { parallel_output: cfg.parallel.printer_output.clone(), sampler_input: cfg.parallel.sampler_input.clone(), sampler_gain_db: cfg.parallel.sampler_gain_db, + a2065_net: cfg.a2065_net, // Filled by refresh_sampler_inputs on open, like the audio devices. sampler_input_devices: Vec::new(), // Left empty here so config construction stays side-effect free; the @@ -1080,6 +1101,15 @@ impl MachineSetup { self.parallel_device } + /// Whether the selected Ethernet backend carries traffic on the host's + /// schedule rather than the emulated clock, breaking byte-identical + /// replay (the I/O Ports tab shows a warning). The loopback backend + /// echoes frames deterministically and an isolated or absent NIC never + /// sees traffic, so only NAT qualifies. + pub fn ethernet_breaks_determinism(&self) -> bool { + self.a2065_net == Some(NetConfig::Nat) + } + /// Re-read every host device list (MIDI endpoints + audio outputs + sampler /// inputs) for the pickers. Call after (re)building the setup -- e.g. loading /// a config or resetting to defaults -- so the pickers show what is connected @@ -1342,6 +1372,11 @@ impl MachineSetup { .then(|| ParallelDevice::Printer.label().to_string()), ParallelDevice::Sampler => Some(ParallelDevice::Sampler.label().to_string()), }; + // Ethernet: no profile fits an A2065 by default, so the board is + // emitted whenever it is on (absent key = not fitted). + raw.a2065.net = self + .a2065_net + .map(|n| crate::net::net_config_name(n).to_string()); // The Audio output picker is one of default / a named device / Disabled. // A named device sets output_device; Disabled sets output_enabled=false // (the resolved default is true, so it is omitted otherwise). @@ -1835,6 +1870,12 @@ impl MachineSetup { .clone() .unwrap_or_else(|| "Default".to_string()), F::SamplerGain => sampler_gain_label(self.sampler_gain_db), + F::Ethernet => match self.a2065_net { + None => "Not fitted".to_string(), + Some(NetConfig::None) => "Isolated".to_string(), + Some(NetConfig::Loopback) => "Loopback".to_string(), + Some(NetConfig::Nat) => "NAT".to_string(), + }, F::AudioDevice => self.audio_output.label().to_string(), F::AudioChannelMode => match self.audio_channel_mode { ChannelMode::Stereo => "Stereo".to_string(), @@ -2029,6 +2070,15 @@ impl MachineSetup { self.sampler_gain_db = cycle_floats(&SAMPLER_GAIN_STEPS, self.sampler_gain_db as f64, forward) as f32; } + F::Ethernet => { + // NAT is only on offer when the userspace NAT is compiled in; + // without it the choice would fit a board that never connects. + let choices: Vec> = ETHERNET_CHOICES + .into_iter() + .filter(|c| cfg!(feature = "net-nat") || *c != Some(NetConfig::Nat)) + .collect(); + self.a2065_net = cycle_slice(&choices, self.a2065_net, forward); + } F::AudioDevice => { // Re-read on each step so a device connected since the screen // opened appears; on-demand only, so no background polling. @@ -3060,19 +3110,70 @@ mod tests { #[cfg(feature = "midi")] #[test] - fn io_ports_tab_groups_serial_and_parallel_under_headers() { + fn io_ports_tab_groups_serial_parallel_and_ethernet_under_headers() { let r = rows(LauncherTab::IoPorts, ParallelDevice::None, SerialMode::Midi); let headers: Vec<_> = r .iter() .filter(|x| x.kind == RowKind::SectionHeader) .map(|x| x.label) .collect(); - assert_eq!(headers, ["Serial:", "Parallel:"]); + assert_eq!(headers, ["Serial:", "Parallel:", "Ethernet:"]); // Serial section: the Device / Mode selector, and (in MIDI) the endpoints. assert!(r.iter().any(|x| x.field == LauncherField::SerialMode)); assert!(r.iter().any(|x| x.field == LauncherField::MidiOut)); // Parallel section: the device selector. assert!(r.iter().any(|x| x.field == LauncherField::ParallelDevice)); + // Ethernet section: the A2065 board selector. + assert!(r.iter().any(|x| x.field == LauncherField::Ethernet)); + } + + #[test] + fn a2065_board_cycles_and_round_trips() { + let mut s = MachineSetup::default(); + assert_eq!(s.value_label(LauncherField::Ethernet), "Not fitted"); + assert!(s.to_raw().a2065.net.is_none()); + assert!(!s.ethernet_breaks_determinism()); + + s.cycle(LauncherField::Ethernet, true); + assert_eq!(s.value_label(LauncherField::Ethernet), "Isolated"); + s.cycle(LauncherField::Ethernet, true); + assert_eq!(s.value_label(LauncherField::Ethernet), "Loopback"); + // Loopback echoes frames on the emulated clock: no determinism warning. + assert!(!s.ethernet_breaks_determinism()); + + // The fitted board and its backend survive a save/load round trip. + let raw = s.to_raw(); + assert_eq!(raw.a2065.net.as_deref(), Some("loopback")); + let back = MachineSetup::from_raw(&raw).unwrap(); + assert_eq!(back.a2065_net, Some(NetConfig::Loopback)); + + #[cfg(feature = "net-nat")] + { + s.cycle(LauncherField::Ethernet, true); + assert_eq!(s.value_label(LauncherField::Ethernet), "NAT"); + // Only NAT carries traffic on the host's schedule. + assert!(s.ethernet_breaks_determinism()); + assert_eq!(s.to_raw().a2065.net.as_deref(), Some("nat")); + s.cycle(LauncherField::Ethernet, true); + } + #[cfg(not(feature = "net-nat"))] + { + // Without the userspace NAT the picker skips straight past it. + s.cycle(LauncherField::Ethernet, true); + } + assert_eq!(s.value_label(LauncherField::Ethernet), "Not fitted"); + } + + #[test] + fn an_isolated_a2065_round_trips_as_net_none() { + let mut s = MachineSetup::default(); + s.cycle(LauncherField::Ethernet, true); + let raw = s.to_raw(); + // Fitted-but-isolated is `net = "none"`; not fitted is an absent key. + assert_eq!(raw.a2065.net.as_deref(), Some("none")); + let back = MachineSetup::from_raw(&raw).unwrap(); + assert_eq!(back.a2065_net, Some(NetConfig::None)); + assert_eq!(back.value_label(LauncherField::Ethernet), "Isolated"); } #[test] diff --git a/src/video/ui.rs b/src/video/ui.rs index 72381db..3ea4f8f 100644 --- a/src/video/ui.rs +++ b/src/video/ui.rs @@ -4523,6 +4523,47 @@ fn draw_launcher( ); } } + // The NAT backend delivers inbound traffic on the host's schedule, not + // the emulated clock, so warn that runs stop being reproducible the + // moment packets flow (loopback and an isolated NIC stay deterministic). + if state.tab == LauncherTab::IoPorts && setup.ethernet_breaks_determinism() { + let note_top = launcher_row_y( + rect, + launcher::rows( + LauncherTab::IoPorts, + state.setup.parallel_device(), + state.setup.serial_mode(), + ) + .len() + + 1, + ); + draw_panel_text( + frame, + launcher_pane_x(rect), + note_top, + "Warning: NAT networking is non-deterministic.", + PANEL_TEXT_ACCENT, + 1, + scale, + ); + for (i, line) in [ + "Inbound traffic follows the host clock, so input recordings", + "and save-state replays are not byte-identical while it flows.", + ] + .iter() + .enumerate() + { + draw_panel_text( + frame, + launcher_pane_x(rect) + 8, + note_top + 16 + i * 14, + line, + PANEL_TEXT, + 1, + scale, + ); + } + } // Status / error line. if let Some(status) = &state.status { let color = if status.error { @@ -6698,6 +6739,23 @@ mod tests { draw(&mut frame, scale, &ui, None, None, false, false, labels()); save(&frame, "launcher-io-ports"); + // I/O Ports with the A2065 on the NAT backend, to check the + // non-determinism warning under the rows. + let mut frame = vec![0u8; w * h * 4]; + let mut state = LauncherState::new(launcher::MachineSetup::default()); + state.tab = LauncherTab::IoPorts; + // Not fitted -> Isolated -> Loopback -> NAT (without the net-nat + // feature this wraps back to Not fitted and no warning is shown). + for _ in 0..3 { + state.setup.cycle(LauncherField::Ethernet, true); + } + let ui = UiState { + menu_open: false, + panel: Some(Panel::Launcher(Box::new(state))), + }; + draw(&mut frame, scale, &ui, None, None, false, false, labels()); + save(&frame, "launcher-ethernet-warning"); + // I/O Ports with the printer selected and a long output path set, to // check the "Output file" value and the Browse/Clear placement. let mut frame = vec![0u8; w * h * 4]; From a26e3deadad81ea5c66f8236ec91b7decba17172 Mon Sep 17 00:00:00 2001 From: Andrew Hutchings Date: Wed, 22 Jul 2026 09:58:49 +0100 Subject: [PATCH 2/2] launcher: key the NAT choice and warning off actual NAT availability The NAT backend needs the net-nat feature and never comes up on wasm32 (make_backend's cfg gating), but the Ethernet picker filtered the choice on the feature alone and the determinism warning ignored the build entirely, so a wasm32 build could offer a NAT that never connects and a NAT-naming config could warn in a build where the NIC is actually left isolated (and the run deterministic). Export the condition as net::NAT_AVAILABLE next to make_backend and key the picker filter, the warning predicate, and the tests off it so the three sites cannot drift from what make_backend will do. --- src/net/mod.rs | 6 ++++++ src/video/launcher.rs | 33 ++++++++++++++++++++++----------- src/video/ui.rs | 4 ++-- 3 files changed, 30 insertions(+), 13 deletions(-) diff --git a/src/net/mod.rs b/src/net/mod.rs index 57ca8ab..9a0b5cc 100644 --- a/src/net/mod.rs +++ b/src/net/mod.rs @@ -61,6 +61,12 @@ pub enum NetConfig { Nat, } +/// Whether this build can bring up the userspace NAT backend: the same +/// condition [`make_backend`]'s `NetConfig::Nat` arms are compiled under +/// (the `net-nat` feature, and never on wasm32). Pickers and warnings key +/// off this so they track what `make_backend` will actually do. +pub const NAT_AVAILABLE: bool = cfg!(all(feature = "net-nat", not(target_arch = "wasm32"))); + /// Bring up the live backend a [`NetConfig`] names. `None` means the board has /// no host networking (its NIC still works, it just never sees traffic). pub fn make_backend(cfg: NetConfig) -> Option> { diff --git a/src/video/launcher.rs b/src/video/launcher.rs index a8e96fe..2aae399 100644 --- a/src/video/launcher.rs +++ b/src/video/launcher.rs @@ -1105,9 +1105,11 @@ impl MachineSetup { /// schedule rather than the emulated clock, breaking byte-identical /// replay (the I/O Ports tab shows a warning). The loopback backend /// echoes frames deterministically and an isolated or absent NIC never - /// sees traffic, so only NAT qualifies. + /// sees traffic, so only NAT qualifies -- and only in a build that can + /// actually bring the NAT up (a loaded config can still name it in one + /// that cannot, where `make_backend` leaves the NIC isolated). pub fn ethernet_breaks_determinism(&self) -> bool { - self.a2065_net == Some(NetConfig::Nat) + self.a2065_net == Some(NetConfig::Nat) && crate::net::NAT_AVAILABLE } /// Re-read every host device list (MIDI endpoints + audio outputs + sampler @@ -2071,11 +2073,11 @@ impl MachineSetup { cycle_floats(&SAMPLER_GAIN_STEPS, self.sampler_gain_db as f64, forward) as f32; } F::Ethernet => { - // NAT is only on offer when the userspace NAT is compiled in; - // without it the choice would fit a board that never connects. + // NAT is only on offer when this build can bring it up; + // otherwise the choice would fit a board that never connects. let choices: Vec> = ETHERNET_CHOICES .into_iter() - .filter(|c| cfg!(feature = "net-nat") || *c != Some(NetConfig::Nat)) + .filter(|c| crate::net::NAT_AVAILABLE || *c != Some(NetConfig::Nat)) .collect(); self.a2065_net = cycle_slice(&choices, self.a2065_net, forward); } @@ -3147,23 +3149,32 @@ mod tests { let back = MachineSetup::from_raw(&raw).unwrap(); assert_eq!(back.a2065_net, Some(NetConfig::Loopback)); - #[cfg(feature = "net-nat")] - { + if crate::net::NAT_AVAILABLE { s.cycle(LauncherField::Ethernet, true); assert_eq!(s.value_label(LauncherField::Ethernet), "NAT"); // Only NAT carries traffic on the host's schedule. assert!(s.ethernet_breaks_determinism()); assert_eq!(s.to_raw().a2065.net.as_deref(), Some("nat")); s.cycle(LauncherField::Ethernet, true); - } - #[cfg(not(feature = "net-nat"))] - { - // Without the userspace NAT the picker skips straight past it. + } else { + // Where the NAT cannot come up the picker skips straight past it. s.cycle(LauncherField::Ethernet, true); } assert_eq!(s.value_label(LauncherField::Ethernet), "Not fitted"); } + #[test] + fn a2065_nat_from_a_config_warns_only_where_the_nat_can_come_up() { + // A loaded config can name NAT even in a build whose picker skips it; + // there make_backend leaves the NIC isolated (deterministic), so the + // warning must track NAT_AVAILABLE, not just the selected backend. + let mut raw = RawConfig::default(); + raw.a2065.net = Some("nat".to_string()); + let s = MachineSetup::from_raw(&raw).unwrap(); + assert_eq!(s.value_label(LauncherField::Ethernet), "NAT"); + assert_eq!(s.ethernet_breaks_determinism(), crate::net::NAT_AVAILABLE); + } + #[test] fn an_isolated_a2065_round_trips_as_net_none() { let mut s = MachineSetup::default(); diff --git a/src/video/ui.rs b/src/video/ui.rs index 3ea4f8f..b67d364 100644 --- a/src/video/ui.rs +++ b/src/video/ui.rs @@ -6744,8 +6744,8 @@ mod tests { let mut frame = vec![0u8; w * h * 4]; let mut state = LauncherState::new(launcher::MachineSetup::default()); state.tab = LauncherTab::IoPorts; - // Not fitted -> Isolated -> Loopback -> NAT (without the net-nat - // feature this wraps back to Not fitted and no warning is shown). + // Not fitted -> Isolated -> Loopback -> NAT (where the NAT cannot + // come up this wraps back to Not fitted and no warning is shown). for _ in 0..3 { state.setup.cycle(LauncherField::Ethernet, true); }