Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions docs/guide/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 7 additions & 4 deletions docs/guide/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion docs/zorro.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions src/net/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Box<dyn NetBackend>> {
Expand Down Expand Up @@ -103,6 +109,16 @@ pub fn parse_net_config(s: &str) -> Option<NetConfig> {
}
}

/// 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::*;
Expand Down
120 changes: 116 additions & 4 deletions src/video/launcher.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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<Row> {
let mut rows = Vec::new();
let serial = serial_rows(serial_mode);
Expand All @@ -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(&ETHERNET_ROWS);
rows
}

Expand Down Expand Up @@ -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<NetConfig>; 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.
Expand Down Expand Up @@ -879,6 +895,10 @@ pub struct MachineSetup {
/// edited in the I/O Ports tab's Parallel section.
sampler_input: Option<String>,
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<NetConfig>,
/// 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -1080,6 +1101,17 @@ 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 -- 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) && crate::net::NAT_AVAILABLE
}

/// 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
Expand Down Expand Up @@ -1342,6 +1374,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).
Expand Down Expand Up @@ -1835,6 +1872,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(),
Expand Down Expand Up @@ -2029,6 +2072,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 this build can bring it up;
// otherwise the choice would fit a board that never connects.
let choices: Vec<Option<NetConfig>> = ETHERNET_CHOICES
.into_iter()
.filter(|c| crate::net::NAT_AVAILABLE || *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.
Expand Down Expand Up @@ -3060,19 +3112,79 @@ 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));

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);
} 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();
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]
Expand Down
58 changes: 58 additions & 0 deletions src/video/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 (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);
}
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];
Expand Down
Loading