diff --git a/Cargo.lock b/Cargo.lock index 27c00d7..2419f4b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -791,6 +791,20 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "sleipnir-daemon" +version = "0.1.0" +dependencies = [ + "anyhow", + "cultcache-rs", + "cultmesh-rs", + "cultnet-rs", + "odin-core", + "rmp-serde", + "serde_json", + "vigem-client", +] + [[package]] name = "socket2" version = "0.5.10" @@ -878,6 +892,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "vigem-client" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b857e6f99efe1e1eb1e4dfb035de8ae7ec8ec56bd1928edcbd7c6e4427634d52" +dependencies = [ + "winapi", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" diff --git a/Cargo.toml b/Cargo.toml index a6c8188..12add68 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/muninn-daemon", "crates/muninn-move-tracker", "crates/muninn-psmoveapi-tracker", + "crates/sleipnir-daemon", ] resolver = "3" @@ -10,3 +11,11 @@ resolver = "3" edition = "2024" license = "MIT" publish = false + +# One pin per shared upstream. Muninn and Sleipnir resolve the same CultLib and +# odin-core by construction; a bump is one edit here. +[workspace.dependencies] +cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "c2a9a6e526390808ef195759056fda0b9a55d8a3", package = "cultcache-rs" } +cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "c2a9a6e526390808ef195759056fda0b9a55d8a3", package = "cultmesh-rs" } +cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "c2a9a6e526390808ef195759056fda0b9a55d8a3", package = "cultnet-rs" } +odin-core = { git = "https://github.com/GameCult/Odin.git", rev = "3e96c6c85ca8b89b1bc7b01c1b7744f43111be99" } diff --git a/crates/muninn-daemon/Cargo.toml b/crates/muninn-daemon/Cargo.toml index ac34344..5b495ee 100644 --- a/crates/muninn-daemon/Cargo.toml +++ b/crates/muninn-daemon/Cargo.toml @@ -15,13 +15,13 @@ path = "src/main.rs" [dependencies] anyhow = "1" -cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "c2a9a6e526390808ef195759056fda0b9a55d8a3", package = "cultcache-rs" } -cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "c2a9a6e526390808ef195759056fda0b9a55d8a3", package = "cultmesh-rs" } -cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "c2a9a6e526390808ef195759056fda0b9a55d8a3", package = "cultnet-rs" } +cultcache-rs = { workspace = true } +cultmesh-rs = { workspace = true } +cultnet-rs = { workspace = true } libc = "0.2" muninn-move-tracker = { path = "../muninn-move-tracker" } muninn-psmoveapi-tracker = { path = "../muninn-psmoveapi-tracker", optional = true } -odin-core = { git = "https://github.com/GameCult/Odin.git", rev = "3e96c6c85ca8b89b1bc7b01c1b7744f43111be99" } +odin-core = { workspace = true } rmp-serde = "1.3" serde = { version = "1", features = ["derive"] } serde_bytes = "0.11" diff --git a/crates/sleipnir-daemon/Cargo.toml b/crates/sleipnir-daemon/Cargo.toml new file mode 100644 index 0000000..b10f048 --- /dev/null +++ b/crates/sleipnir-daemon/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "sleipnir-daemon" +version = "0.1.0" +edition.workspace = true +license.workspace = true +publish.workspace = true + +[[bin]] +name = "sleipnir" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +cultmesh-rs = { workspace = true } +cultnet-rs = { workspace = true } +cultcache-rs = { workspace = true } +odin-core = { workspace = true } +rmp-serde = "1.3" +serde_json = "1" + +[target.'cfg(windows)'.dependencies] +vigem-client = "0.1.4" diff --git a/crates/sleipnir-daemon/src/main.rs b/crates/sleipnir-daemon/src/main.rs new file mode 100644 index 0000000..1be0193 --- /dev/null +++ b/crates/sleipnir-daemon/src/main.rs @@ -0,0 +1,3660 @@ +use anyhow::{Context, Result, anyhow}; +use cultmesh_rs::{ + CultMesh, CultMeshNodeOptions, CultMeshRudpDocumentPublishOptions, CultMeshRudpSnapshotOptions, +}; +use cultnet_rs::{ + CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, CultNetRudpSocketMode, + CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, CultNetWireContract, + encode_cultnet_message_to_vec, +}; +use odin_core::{ + EVE_PROVIDER_ADVERTISEMENT_SCHEMA, EveProviderAdvertisementRecord, EveSurfaceStateRecord, + IdunnDaemonHealthRecord, MUNINN_HID_CONTROLLER_STATE_SCHEMA, MuninnHidControllerStateRecord, + OdinDocuments, OdinEndpointQuery, SleipnirInputMappingRecord, discover_provider_endpoints, +}; +use std::collections::HashMap; +use std::env; +use std::fs; +use std::net::{SocketAddr, ToSocketAddrs, UdpSocket}; +use std::path::PathBuf; +use std::thread; +use std::time::{Duration, Instant}; + +const SLEIPNIR_HID_RUDP_CONNECTION_ID: u32 = 0x6d75_0005; +const SLEIPNIR_COMMAND_ROUTE: &str = "cultmesh://odin/command/sleipnir/input_mapping"; +const SLEIPNIR_INPUT_MAPPING_SCHEMA: &str = "sleipnir.input_mapping.v1"; +const IDUNN_HEALTH_RUDP_CONNECTION_ID: u32 = 0x1d0d_0001; +const CULTNET_RUDP_PROTOCOL_ID: &str = "cultnet.transport.rudp.v0"; +const INPUT_STALE_NEUTRAL_AFTER: Duration = Duration::from_millis(1_500); +const INPUT_STREAM_RECONNECT_AFTER: Duration = Duration::from_secs(3); +const DEFAULT_STICK_DEADZONE: f32 = 0.12; +const HID_RUDP_MAX_FRAGMENT_BYTES: u32 = 1_200; + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Options { + store_path: PathBuf, + discovery_store_path: Option, + mapping_path: Option, + device_filter: Option, + host_id: String, + interval_ms: u64, + odin_cultmesh_uri: Option, + rudp_bind: Option, + command_route: String, + idunn_rudp_health: Option, + once: bool, + dry_run: bool, + trace: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct IdunnRudpHealthOptions { + endpoint: SocketAddr, + daemon_id: String, + health_contract: String, +} + +struct ActiveRudpStream { + transport: CultNetRudpSocketTransportConnection, + target: Option, + last_connect_attempt_at: Option, + connected_logged: bool, + last_frame_at: Option, + last_stale_log_at: Option, + last_subscription: Option, + last_subscription_at: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct HidControllerRudpSubscription { + device_filter: Option, + stream_id: Option, +} + +#[derive(Clone, Debug)] +struct SleipnirRuntimeState { + provider_id: String, + selected_muninn_endpoint: Option, + selected_device_filter: Option, + presentation: String, + virtual_backend: String, + stream_state: String, + last_device_id: Option, + last_device_kind: Option, + last_sequence: Option, + last_frame_age_ms: Option, + last_input_latency: Option, + ignored_stream_frames: u64, + available_devices: Vec, + axis_map: HashMap, + button_map: HashMap, + pending_learn: Option, + updated_at: String, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct SleipnirInputLatencySnapshot { + device_id: String, + sequence: u64, + source_to_arrival_ms: Option, + arrival_to_buffer_ms: u128, + buffer_to_axis_ms: u128, + axis_to_hid_ms: u128, + source_to_hid_ms: Option, + total_observed_ms: u128, + axis_summary: Vec, + emitted: bool, +} + +#[derive(Clone, Debug, PartialEq)] +struct SleipnirDesiredMapping { + enabled: bool, + device_filter: Option, + stream_id: Option, + presentation: String, + axis_map: HashMap, + button_map: HashMap, + pending_learn: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct LearnRequest { + target: String, + binding_kind: String, + requested_at: Option, +} + +#[derive(Clone, Debug, PartialEq)] +struct AxisBinding { + source: usize, + invert: bool, + scale: f32, + deadzone: f32, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct AvailableHidDevice { + device_id: String, + device_kind: String, + source_path: String, + stream_id: String, + host_id: String, + endpoint: String, +} + +#[derive(Clone, Debug, PartialEq)] +struct VirtualPadState { + buttons: Vec, + left_x: i16, + left_y: i16, + left_trigger: u8, + right_trigger: u8, +} + +#[derive(Clone, Debug)] +struct TimedHidRecord { + record: MuninnHidControllerStateRecord, + timing: HidRecordTiming, +} + +#[derive(Clone, Debug)] +struct HidRecordTiming { + frame_received_at: Instant, + frame_received_unix_ns: i64, + buffer_ready_at: Instant, +} + +impl Default for VirtualPadState { + fn default() -> Self { + Self { + buttons: Vec::new(), + left_x: 0, + left_y: 0, + left_trigger: 0, + right_trigger: 0, + } + } +} + +trait VirtualPadBackend { + fn update(&mut self, state: &VirtualPadState) -> Result<()>; +} + +struct LoggingBackend; + +impl VirtualPadBackend for LoggingBackend { + fn update(&mut self, state: &VirtualPadState) -> Result<()> { + println!( + "Sleipnir dry-run virtual pad lx={} ly={} lt={} rt={} buttons=[{}]", + state.left_x, + state.left_y, + state.left_trigger, + state.right_trigger, + state.buttons.join(",") + ); + Ok(()) + } +} + +#[cfg(windows)] +struct VigemXboxBackend { + target: vigem_client::Xbox360Wired, +} + +#[cfg(windows)] +impl VigemXboxBackend { + fn connect() -> Result { + let client = vigem_client::Client::connect().context("connecting to ViGEmBus")?; + let mut target = + vigem_client::Xbox360Wired::new(client, vigem_client::TargetId::XBOX360_WIRED); + target + .plugin() + .context("plugging in virtual Xbox 360 controller")?; + target + .wait_ready() + .context("waiting for virtual Xbox 360 controller readiness")?; + Ok(Self { target }) + } +} + +#[cfg(windows)] +impl VirtualPadBackend for VigemXboxBackend { + fn update(&mut self, state: &VirtualPadState) -> Result<()> { + let mut raw_buttons = 0u16; + for button in &state.buttons { + raw_buttons |= xbutton_mask(button); + } + let gamepad = vigem_client::XGamepad { + buttons: vigem_client::XButtons::from(raw_buttons), + left_trigger: state.left_trigger, + right_trigger: state.right_trigger, + thumb_lx: state.left_x, + thumb_ly: state.left_y, + thumb_rx: 0, + thumb_ry: 0, + ..Default::default() + }; + self.target + .update(&gamepad) + .context("updating virtual Xbox 360 controller") + } +} + +#[cfg(windows)] +fn xbutton_mask(button: &str) -> u16 { + match button { + "up" => vigem_client::XButtons::UP, + "down" => vigem_client::XButtons::DOWN, + "left" => vigem_client::XButtons::LEFT, + "right" => vigem_client::XButtons::RIGHT, + "start" => vigem_client::XButtons::START, + "select" | "back" => vigem_client::XButtons::BACK, + "l3" => vigem_client::XButtons::LTHUMB, + "r3" => vigem_client::XButtons::RTHUMB, + "l1" => vigem_client::XButtons::LB, + "r1" => vigem_client::XButtons::RB, + "ps" | "guide" => vigem_client::XButtons::GUIDE, + "cross" | "a" => vigem_client::XButtons::A, + "circle" | "o" | "b" => vigem_client::XButtons::B, + "square" | "x" => vigem_client::XButtons::X, + "triangle" | "y" => vigem_client::XButtons::Y, + _ => 0, + } +} + +fn main() -> Result<()> { + let options = Options::parse(env::args().skip(1))?; + let mut node = CultMesh::create_node( + &options.store_path, + OdinDocuments, + CultMeshNodeOptions { + runtime_id: "sleipnir-daemon".to_string(), + pull_on_start: true, + }, + ) + .with_context(|| format!("opening CultMesh store {}", options.store_path.display()))?; + let mut discovery_node = + if let Some(discovery_store_path) = options.discovery_store_path.as_ref() { + Some( + CultMesh::create_node( + discovery_store_path, + OdinDocuments, + CultMeshNodeOptions { + runtime_id: "sleipnir-discovery".to_string(), + pull_on_start: true, + }, + ) + .with_context(|| { + format!( + "opening Sleipnir discovery CultMesh store {}", + discovery_store_path.display() + ) + })?, + ) + } else { + None + }; + pull_odin_catalog_snapshot(discovery_node.as_mut().unwrap_or(&mut node), &options); + + let mut desired_mapping = + read_desired_mapping_from_sources(&mut node, discovery_node.as_mut(), &options); + let mut backend: Box = if options.dry_run { + Box::new(LoggingBackend) + } else { + create_backend()? + }; + let mut current_device_filter = effective_device_filter(&options, &desired_mapping); + let discovered_endpoint = if desired_mapping.enabled { + effective_muninn_hid_endpoint( + discovery_node.as_ref().unwrap_or(&node), + current_device_filter.as_deref(), + desired_mapping.stream_id.as_deref(), + options.trace, + ) + } else { + None + }; + if options.trace { + if !desired_mapping.enabled { + eprintln!("Sleipnir input consumption is disabled; publishing control surface only"); + } else { + match discovered_endpoint.as_deref() { + Some(endpoint) => eprintln!("Sleipnir selected Muninn HID endpoint {endpoint}"), + None => eprintln!("Sleipnir found no Odin-discovered Muninn HID endpoint; waiting"), + } + } + } + let mut rudp_stream = + create_rudp_stream(options.rudp_bind, discovered_endpoint, options.trace)?; + let mut last_rudp_discovery_attempt_at = Instant::now(); + let initial_state = { + let discovery_ref = discovery_node.as_ref().unwrap_or(&node); + SleipnirRuntimeState::from_runtime( + &options, + current_device_filter.as_deref(), + discovery_ref, + rudp_stream.as_ref(), + None, + 0, + &desired_mapping, + None, + ) + }; + publish_sleipnir_runtime_surface(&mut node, &options, &initial_state, true)?; + let mut last_idunn_health_attempt_at = Instant::now() - Duration::from_secs(60); + publish_idunn_health_if_configured(&options, &initial_state, &mut last_idunn_health_attempt_at); + + let mut last_sequence_by_device = HashMap::::new(); + let mut ignored_stream_frames = 0u64; + let mut last_surface_publish_at = Instant::now() - Duration::from_secs(60); + let mut last_odin_catalog_pull_at = Instant::now() - Duration::from_secs(60); + let mut recent_applied_record: Option = None; + let mut recent_input_latency: Option = None; + let mut output_is_neutral = true; + let mut last_output_frame_at: Option = None; + let mut last_output_source_timestamp_ns: Option = None; + loop { + let input_stream_needs_discovery = rudp_stream + .as_ref() + .is_none_or(|stream| stream.target.is_none()); + if input_stream_needs_discovery + && last_odin_catalog_pull_at.elapsed() >= Duration::from_secs(2) + { + pull_odin_catalog_snapshot(discovery_node.as_mut().unwrap_or(&mut node), &options); + last_odin_catalog_pull_at = Instant::now(); + } + let next_desired_mapping = + read_desired_mapping_from_sources(&mut node, discovery_node.as_mut(), &options); + if next_desired_mapping != desired_mapping { + desired_mapping = next_desired_mapping; + current_device_filter = effective_device_filter(&options, &desired_mapping); + let rediscovered_endpoint = if desired_mapping.enabled { + effective_muninn_hid_endpoint( + discovery_node.as_ref().unwrap_or(&node), + current_device_filter.as_deref(), + desired_mapping.stream_id.as_deref(), + options.trace, + ) + } else { + None + }; + if options.trace { + eprintln!( + "Sleipnir mapping changed device={:?} discovered_muninn_endpoint={:?}", + current_device_filter, rediscovered_endpoint + ); + } + backend.update(&VirtualPadState::default())?; + output_is_neutral = true; + last_output_frame_at = Some(Instant::now()); + last_output_source_timestamp_ns = None; + drop(rudp_stream.take()); + rudp_stream = + create_rudp_stream(options.rudp_bind, rediscovered_endpoint, options.trace)?; + last_sequence_by_device.clear(); + } + let mut last_applied_record: Option = None; + if let Some(stream) = rudp_stream.as_mut() { + if ensure_rudp_connected(stream, options.trace)? { + if !output_is_neutral { + backend.update(&VirtualPadState::default())?; + output_is_neutral = true; + last_output_frame_at = Some(Instant::now()); + last_output_source_timestamp_ns = None; + if options.trace { + eprintln!( + "Sleipnir neutralized virtual pad before recreating stale Muninn HID RUDP connection for filter={:?}", + current_device_filter + ); + } + } + let rediscovered_endpoint = if desired_mapping.enabled { + effective_muninn_hid_endpoint( + discovery_node.as_ref().unwrap_or(&node), + current_device_filter.as_deref(), + desired_mapping.stream_id.as_deref(), + options.trace, + ) + } else { + None + }; + drop(rudp_stream.take()); + rudp_stream = + create_rudp_stream(options.rudp_bind, rediscovered_endpoint, options.trace)?; + last_rudp_discovery_attempt_at = Instant::now(); + } else { + send_hid_subscription_if_due( + stream, + current_device_filter.clone(), + desired_mapping.stream_id.clone(), + options.trace, + )?; + let records = receive_rudp_records(stream, options.trace)?; + for timed_record in records { + let record = timed_record.record; + let timing = timed_record.timing; + if record_matches_filter(&record, current_device_filter.as_deref()) { + stream.last_frame_at = Some(Instant::now()); + stream.last_stale_log_at = None; + if desired_mapping.pending_learn.is_some() { + if let Some(updated_mapping) = + apply_pending_learn(&mut node, &options, &desired_mapping, &record)? + { + desired_mapping = updated_mapping; + } + } + let previous = last_sequence_by_device + .insert(record.device_id.clone(), record.sequence) + .unwrap_or_default(); + let has_new_sequence = record.sequence != previous || options.once; + let has_new_source_timestamp = last_output_source_timestamp_ns + .is_none_or(|last| record.source_timestamp_ns > last); + if has_new_sequence && has_new_source_timestamp { + let state = map_record_to_virtual_pad(&record, &desired_mapping); + let axis_classified_at = Instant::now(); + let output_frame_at = Instant::now(); + if options.trace { + let source_age_ms = unix_nanos_i64() + .saturating_sub(record.source_timestamp_ns) + / 1_000_000; + eprintln!( + "Sleipnir applying fast HID frame device={} seq={} source_age_ms={} lx={} ly={} lt={} rt={} buttons=[{}]", + record.device_id, + record.sequence, + source_age_ms, + state.left_x, + state.left_y, + state.left_trigger, + state.right_trigger, + state.buttons.join(",") + ); + } + match backend.update(&state) { + Ok(()) => { + let hid_emitted_at = Instant::now(); + recent_input_latency = Some(input_latency_snapshot( + &record, + &timing, + axis_classified_at, + hid_emitted_at, + &desired_mapping, + true, + )); + output_is_neutral = state == VirtualPadState::default(); + last_output_frame_at = Some(output_frame_at); + last_output_source_timestamp_ns = + Some(record.source_timestamp_ns); + last_applied_record = Some(record); + } + Err(error) => { + eprintln!( + "Sleipnir virtual pad update failed for device={} seq={}: {error:#}", + record.device_id, record.sequence + ); + recent_input_latency = Some(input_latency_snapshot( + &record, + &timing, + axis_classified_at, + Instant::now(), + &desired_mapping, + false, + )); + last_applied_record = Some(record); + } + } + } + } else if options.trace { + ignored_stream_frames = ignored_stream_frames.saturating_add(1); + if ignored_stream_frames <= 5 || ignored_stream_frames % 120 == 0 { + eprintln!( + "Sleipnir ignored fast HID frame device={} kind={} stream={} filter={:?}", + record.device_id, + record.device_kind, + record.stream_id, + options.device_filter + ); + } + } + } + if desired_mapping.enabled + && !output_is_neutral + && last_output_frame_at + .is_none_or(|last_frame| last_frame.elapsed() >= INPUT_STALE_NEUTRAL_AFTER) + { + let stale_for = last_output_frame_at.map(|last_frame| last_frame.elapsed()); + backend.update(&VirtualPadState::default())?; + output_is_neutral = true; + last_output_frame_at = Some(Instant::now()); + if options.trace { + eprintln!( + "Sleipnir neutralized virtual pad after {:?} without a new selected input sequence for filter={:?}", + stale_for, current_device_filter + ); + } + } + } + } else if last_rudp_discovery_attempt_at.elapsed() >= Duration::from_secs(2) { + let rediscovered_endpoint = if desired_mapping.enabled { + effective_muninn_hid_endpoint( + discovery_node.as_ref().unwrap_or(&node), + current_device_filter.as_deref(), + desired_mapping.stream_id.as_deref(), + options.trace, + ) + } else { + None + }; + if rediscovered_endpoint.is_some() { + drop(rudp_stream.take()); + rudp_stream = + create_rudp_stream(options.rudp_bind, rediscovered_endpoint, options.trace)?; + let runtime_state = { + let discovery_ref = discovery_node.as_ref().unwrap_or(&node); + SleipnirRuntimeState::from_runtime( + &options, + current_device_filter.as_deref(), + discovery_ref, + rudp_stream.as_ref(), + recent_applied_record.as_ref(), + ignored_stream_frames, + &desired_mapping, + recent_input_latency.as_ref(), + ) + }; + publish_sleipnir_runtime_surface(&mut node, &options, &runtime_state, true)?; + publish_idunn_health_if_configured( + &options, + &runtime_state, + &mut last_idunn_health_attempt_at, + ); + } else if options.trace && desired_mapping.enabled { + eprintln!("Sleipnir still has no Odin-discovered Muninn HID endpoint"); + } + last_rudp_discovery_attempt_at = Instant::now(); + } + let surface_age = last_surface_publish_at.elapsed(); + let has_new_applied_record = last_applied_record.is_some(); + let missing_input_target = rudp_stream + .as_ref() + .is_none_or(|stream| stream.target.is_none()); + if let Some(record) = last_applied_record.as_ref() { + recent_applied_record = Some(record.clone()); + } + if (has_new_applied_record && surface_age >= Duration::from_secs(1)) + || (missing_input_target && surface_age >= Duration::from_secs(5)) + { + let runtime_state = { + let discovery_ref = discovery_node.as_ref().unwrap_or(&node); + SleipnirRuntimeState::from_runtime( + &options, + current_device_filter.as_deref(), + discovery_ref, + rudp_stream.as_ref(), + recent_applied_record.as_ref(), + ignored_stream_frames, + &desired_mapping, + recent_input_latency.as_ref(), + ) + }; + publish_sleipnir_runtime_surface( + &mut node, + &options, + &runtime_state, + has_new_applied_record, + )?; + publish_idunn_health_if_configured( + &options, + &runtime_state, + &mut last_idunn_health_attempt_at, + ); + last_surface_publish_at = Instant::now(); + } + if options.once { + break; + } + thread::sleep(Duration::from_millis(options.interval_ms.max(1))); + } + Ok(()) +} + +impl SleipnirRuntimeState { + fn from_runtime( + options: &Options, + selected_device_filter: Option<&str>, + discovery_node: &cultmesh_rs::CultMeshNode, + stream: Option<&ActiveRudpStream>, + last_record: Option<&MuninnHidControllerStateRecord>, + ignored_stream_frames: u64, + desired_mapping: &SleipnirDesiredMapping, + last_input_latency: Option<&SleipnirInputLatencySnapshot>, + ) -> Self { + let has_recent_frame = stream + .and_then(|stream| stream.last_frame_at) + .is_some_and(|last_frame| last_frame.elapsed() < Duration::from_secs(3)); + let stream_state = match stream { + _ if !desired_mapping.enabled => "idle", + Some(_) if has_recent_frame => "connected", + Some(stream) if stream.transport.connected() => "connected", + Some(stream) if stream.target.is_some() => "connecting", + Some(_) => "listening", + None => "discovering", + } + .to_string(); + let selected_muninn_endpoint = stream + .and_then(|stream| stream.target) + .map(|target| target.to_string()); + let mut available_devices = discover_available_hid_devices(discovery_node); + if let (Some(record), Some(endpoint)) = (last_record, selected_muninn_endpoint.as_deref()) { + upsert_available_hid_device( + &mut available_devices, + AvailableHidDevice { + device_id: record.device_id.clone(), + device_kind: record.device_kind.clone(), + source_path: record.source_path.clone(), + stream_id: record.stream_id.clone(), + host_id: record.host_id.clone(), + endpoint: endpoint.to_string(), + }, + ); + } + Self { + provider_id: sleipnir_provider_id(options), + selected_muninn_endpoint, + selected_device_filter: selected_device_filter.map(ToString::to_string), + presentation: default_presentation(), + virtual_backend: if options.dry_run { + "logging.dry-run" + } else if cfg!(windows) { + "vigem.xbox360" + } else { + "unavailable" + } + .to_string(), + stream_state, + last_device_id: last_record.map(|record| record.device_id.clone()), + last_device_kind: last_record.map(|record| record.device_kind.clone()), + last_sequence: last_record.map(|record| record.sequence), + last_frame_age_ms: stream + .and_then(|stream| stream.last_frame_at) + .map(|last_frame| last_frame.elapsed().as_millis()), + last_input_latency: last_input_latency.cloned(), + ignored_stream_frames, + available_devices, + axis_map: desired_mapping.axis_map.clone(), + button_map: desired_mapping.button_map.clone(), + pending_learn: desired_mapping.pending_learn.clone(), + updated_at: timestamp(), + } + } +} + +fn publish_sleipnir_runtime_surface( + node: &mut cultmesh_rs::CultMeshNode, + options: &Options, + state: &SleipnirRuntimeState, + publish_remote: bool, +) -> Result<()> { + let mut advertisement = EveProviderAdvertisementRecord { + value: serde_json::json!({ + "schema": "gamecult.eve.provider_advertisement.v1", + "providerId": state.provider_id, + "daemonId": sleipnir_daemon_id(options), + "title": sleipnir_provider_title(options), + "description": "Sleipnir mirrors Muninn-discovered input streams into a local virtual HID backend.", + "canonicalService": "asgard.sleipnir", + "locatedService": format!("asgard.{}.sleipnir", options.host_id), + "verseId": format!("{}.local", options.host_id), + "cultMeshAddress": format!("asgard.{}.sleipnir/input-mirror", options.host_id), + "status": state.stream_state, + "mode": "daemon-live", + "updatedAt": state.updated_at, + "stateStore": options.store_path.display().to_string(), + "surfaceId": state.provider_id, + "capabilities": [ + "muninn.input.discovery", + "cultmesh.input.stream.consumer", + "virtual-hid.xinput" + ], + "endpoints": [ + { + "transport": "cultcache-store", + "address": options.store_path.display().to_string() + } + ], + "routes": [ + { + "transport": "cultcache-store", + "address": options.store_path.display().to_string() + } + ] + }), + }; + if let Some(routes) = advertisement + .value + .get_mut("routes") + .and_then(|value| value.as_array_mut()) + { + routes.push(serde_json::json!({ + "transport": "cultnet.transport.rudp.v0", + "address": options.command_route, + "resolver": "odin-cultmesh", + "schema": SLEIPNIR_INPUT_MAPPING_SCHEMA, + "role": "sleipnir.input_mapping.command", + "channel": "schema" + })); + } + let surface = EveSurfaceStateRecord { + provider_id: state.provider_id.clone(), + title: sleipnir_provider_title(options), + version: unix_millis_i64(), + updated_at: state.updated_at.clone(), + surface: sleipnir_surface_document(state), + }; + node.put(&state.provider_id, &advertisement)?; + node.put(&state.provider_id, &surface)?; + if publish_remote && let Some(target) = resolve_odin_cultmesh_uri(options) { + if let Err(error) = node.publish_document_to_rudp_catalog( + &state.provider_id, + &advertisement, + CultMeshRudpDocumentPublishOptions { + target, + runtime_id: sleipnir_daemon_id(options), + source_role: Some("sleipnir.input-mirror-provider".to_string()), + tags: vec!["sleipnir".to_string(), "odin-verse-discovery".to_string()], + ..CultMeshRudpDocumentPublishOptions::default() + }, + ) { + if options.trace { + eprintln!("Sleipnir could not publish provider advertisement to Odin: {error:#}"); + } + } + if let Err(error) = node.publish_document_to_rudp_catalog( + &state.provider_id, + &surface, + CultMeshRudpDocumentPublishOptions { + target, + runtime_id: sleipnir_daemon_id(options), + source_role: Some("sleipnir.input-mirror-surface".to_string()), + tags: vec!["sleipnir".to_string(), "eve-surface".to_string()], + flush_timeout: Duration::from_millis(300), + resend_delay_ms: 15, + ..CultMeshRudpDocumentPublishOptions::default() + }, + ) { + if options.trace { + eprintln!("Sleipnir could not publish surface state to Odin: {error:#}"); + } + } + } + Ok(()) +} + +fn publish_idunn_health_if_configured( + options: &Options, + state: &SleipnirRuntimeState, + last_attempt_at: &mut Instant, +) { + let Some(idunn) = options.idunn_rudp_health.as_ref() else { + return; + }; + if last_attempt_at.elapsed() < Duration::from_secs(5) { + return; + } + *last_attempt_at = Instant::now(); + let health_state = if state.stream_state == "connected" || state.stream_state == "idle" { + "active" + } else { + "degraded" + }; + let detail = format!( + "Sleipnir {}: endpoint={} device={} sequence={} age_ms={}", + state.stream_state, + state + .selected_muninn_endpoint + .as_deref() + .unwrap_or("discovering"), + state + .selected_device_filter + .as_deref() + .unwrap_or("unselected"), + state + .last_sequence + .map(|sequence| sequence.to_string()) + .unwrap_or_else(|| "none".to_string()), + state + .last_frame_age_ms + .map(|age| age.to_string()) + .unwrap_or_else(|| "none".to_string()) + ); + if let Err(error) = publish_idunn_rudp_health(idunn, health_state, &detail, &state.updated_at) { + if options.trace { + eprintln!( + "Sleipnir could not publish Idunn RUDP health for {} at {}: {error:#}", + idunn.daemon_id, state.updated_at + ); + } + } +} + +fn publish_idunn_rudp_health( + options: &IdunnRudpHealthOptions, + state: &str, + detail: &str, + observed_at: &str, +) -> Result<()> { + let health = IdunnDaemonHealthRecord { + daemon_id: options.daemon_id.clone(), + state: state.to_string(), + detail: detail.to_string(), + observed_at: observed_at.to_string(), + health_contract: options.health_contract.clone(), + publication_source: "daemon-published".to_string(), + transport: CULTNET_RUDP_PROTOCOL_ID.to_string(), + }; + let message = CultNetMessage::DocumentPutRaw { + message_id: format!( + "sleipnir-health:{}:{}", + options.daemon_id, + observed_at.replace(':', "-") + ), + document: CultNetRawDocumentRecord { + schema_id: "idunn.daemon_health".to_string(), + record_key: options.daemon_id.clone(), + stored_at: observed_at.to_string(), + payload_encoding: CultNetRawPayloadEncoding::Messagepack, + payload: rmp_serde::to_vec(&health).context("encoding Idunn daemon health")?, + source_runtime_id: Some("sleipnir-daemon".to_string()), + source_agent_id: None, + source_role: Some("daemon-health-publisher".to_string()), + tags: Some(vec![CULTNET_RUDP_PROTOCOL_ID.to_string()]), + }, + }; + let bind_address = if options.endpoint.is_ipv4() { + "0.0.0.0:0" + } else { + "[::]:0" + }; + let socket = UdpSocket::bind(bind_address) + .with_context(|| format!("binding Sleipnir Idunn RUDP sender at {bind_address}"))?; + socket.set_read_timeout(Some(Duration::from_millis(100)))?; + let mut transport = + CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions::client( + "sleipnir-daemon", + socket, + options.endpoint, + IDUNN_HEALTH_RUDP_CONNECTION_ID, + ))?; + transport.connect(Vec::new())?; + let deadline = Instant::now() + Duration::from_millis(300); + while !transport.connected() { + let _ = transport.receive_once()?; + transport.poll_resends()?; + if Instant::now() >= deadline { + return Err(anyhow!( + "timed out connecting Sleipnir RUDP sender to {}", + options.endpoint + )); + } + } + let payload = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0) + .context("encoding Idunn health CultNet message")?; + transport + .send("schema", payload) + .with_context(|| format!("sending Sleipnir Idunn health to {}", options.endpoint))?; + Ok(()) +} + +fn resolve_odin_cultmesh_uri(options: &Options) -> Option { + let uri = options.odin_cultmesh_uri.as_deref()?.trim(); + if uri.is_empty() { + return None; + } + match CultMesh::resolve_rudp_endpoint(uri) { + Ok(target) => Some(target), + Err(error) => { + if options.trace { + eprintln!("Sleipnir could not resolve Odin CultMesh URI {uri}: {error:#}"); + } + None + } + } +} + +fn pull_odin_catalog_snapshot(node: &mut cultmesh_rs::CultMeshNode, options: &Options) { + let Some(target) = resolve_odin_cultmesh_uri(options) else { + return; + }; + let hid_result = node.pull_rudp_catalog_snapshot(CultMeshRudpSnapshotOptions { + target, + runtime_id: format!("sleipnir-{}-catalog-client", options.host_id), + schema_ids: Some(vec![ + MUNINN_HID_CONTROLLER_STATE_SCHEMA.to_string(), + SLEIPNIR_INPUT_MAPPING_SCHEMA.to_string(), + ]), + connect_timeout: Duration::from_millis(150), + response_timeout: Duration::from_millis(150), + resend_delay_ms: 15, + ..CultMeshRudpSnapshotOptions::default() + }); + match hid_result { + Ok(count) if options.trace => { + eprintln!("Sleipnir pulled {count} Odin HID/mapping discovery records from {target}"); + } + Ok(_) => {} + Err(error) if options.trace => { + eprintln!("Sleipnir Odin HID/mapping snapshot pull failed from {target}: {error:#}"); + } + Err(_) => return, + } + + let provider_keys = muninn_provider_keys_for_discovered_hid_hosts(node); + if provider_keys.is_empty() { + return; + } + let provider_result = node.pull_rudp_catalog_snapshot(CultMeshRudpSnapshotOptions { + target, + runtime_id: format!("sleipnir-{}-provider-catalog-client", options.host_id), + schema_ids: Some(vec![EVE_PROVIDER_ADVERTISEMENT_SCHEMA.to_string()]), + record_keys: Some(provider_keys), + connect_timeout: Duration::from_millis(150), + response_timeout: Duration::from_millis(150), + resend_delay_ms: 15, + ..CultMeshRudpSnapshotOptions::default() + }); + match provider_result { + Ok(count) if options.trace => { + eprintln!("Sleipnir pulled {count} Odin Muninn provider records from {target}"); + } + Ok(_) => {} + Err(error) if options.trace => { + eprintln!( + "Sleipnir Odin Muninn provider snapshot pull failed from {target}: {error:#}" + ); + } + Err(_) => {} + } +} + +fn muninn_provider_keys_for_discovered_hid_hosts(node: &cultmesh_rs::CultMeshNode) -> Vec { + let mut keys = node + .cache() + .get_all::() + .ok() + .into_iter() + .flatten() + .map(|record| format!("muninn.telemetry.{}", record.host_id)) + .collect::>(); + for envelope in node.cache().snapshot() { + if envelope.schema_id.as_deref() != Some(MUNINN_HID_CONTROLLER_STATE_SCHEMA) + && envelope.r#type != "muninn.hid_controller_state" + { + continue; + } + let Ok(value) = rmp_serde::from_slice::(&envelope.payload) else { + continue; + }; + if let Some(host_id) = hid_state_host_id_from_value(&value) { + keys.push(format!("muninn.telemetry.{host_id}")); + } + } + keys.sort(); + keys.dedup(); + keys +} + +fn hid_state_host_id_from_value(value: &serde_json::Value) -> Option { + let payload = value + .as_array() + .and_then(|items| { + (items.len() == 1) + .then(|| items.first()) + .flatten() + .filter(|item| item.is_object() || item.is_array()) + }) + .unwrap_or(value); + if let Some(items) = payload.as_array() { + return items.get(1)?.as_str().map(ToString::to_string); + } + string_field(payload, &["host_id", "hostId"]) +} + +fn sleipnir_surface_document(state: &SleipnirRuntimeState) -> serde_json::Value { + let preset_cards = remap_preset_cards(state); + let binding_map = xbox_binding_map(state); + let device_cards = state + .available_devices + .iter() + .map(|device| { + let selected = state.selected_device_filter.as_deref() == Some(device.device_id.as_str()) + || state.selected_device_filter.as_deref() == Some(device.device_kind.as_str()) + || state.selected_device_filter.as_deref() == Some(device.stream_id.as_str()); + serde_json::json!({ + "id": format!("{}.device.{}", state.provider_id, stable_id(&device.device_id)), + "kind": "card", + "props": { + "title": format!("{} {}", if selected { "selected" } else { "available" }, device.device_id), + "commandId": "sleipnir.map-hid-device", + "action": { + "type": "sleipnir.map-hid-device", + "providerId": state.provider_id, + "deviceFilter": device.device_id, + "streamId": device.stream_id + } + }, + "children": [ + text_element(format!("{}.device.{}.kind", state.provider_id, stable_id(&device.device_id)), format!("kind: {}", device.device_kind)), + text_element(format!("{}.device.{}.host", state.provider_id, stable_id(&device.device_id)), format!("host: {}", device.host_id)), + text_element(format!("{}.device.{}.endpoint", state.provider_id, stable_id(&device.device_id)), format!("endpoint: {}", device.endpoint)), + text_element(format!("{}.device.{}.path", state.provider_id, stable_id(&device.device_id)), format!("source: {}", device.source_path)) + ] + }) + }) + .collect::>(); + let mut runtime_children = vec![ + serde_json::json!({ + "id": format!("{}.disable", state.provider_id), + "kind": "card", + "props": { + "title": "Stop consuming input", + "commandId": "sleipnir.disable-input", + "action": { + "type": "sleipnir.disable-input", + "providerId": state.provider_id + } + }, + "children": [] + }), + text_element( + format!("{}.state", state.provider_id), + format!("state: {}", state.stream_state), + ), + text_element( + format!("{}.backend", state.provider_id), + format!("virtual backend: {}", state.virtual_backend), + ), + text_element( + format!("{}.presentation", state.provider_id), + format!("presentation: {}", state.presentation), + ), + text_element( + format!("{}.muninn", state.provider_id), + format!( + "muninn endpoint: {}", + state + .selected_muninn_endpoint + .as_deref() + .unwrap_or("discovering") + ), + ), + text_element( + format!("{}.filter", state.provider_id), + format!( + "device filter: {}", + state.selected_device_filter.as_deref().unwrap_or("any") + ), + ), + text_element( + format!("{}.updated", state.provider_id), + format!("updated: {}", state.updated_at), + ), + ]; + runtime_children.push(serde_json::json!({ + "id": format!("{}.devices-inline", state.provider_id), + "kind": "pane", + "props": { "title": "Available HID Devices", "density": "compact" }, + "children": if device_cards.is_empty() { + vec![text_element( + format!("{}.devices.empty", state.provider_id), + "no Muninn HID devices discovered".to_string(), + )] + } else { + device_cards + } + })); + serde_json::json!({ + "schema": "gamecult.eve.surface.v1", + "id": format!("{}.surface", state.provider_id), + "title": "Sleipnir Input Mirror", + "root": { + "id": format!("{}.root", state.provider_id), + "kind": "dashboard", + "props": { + "title": "Sleipnir Input Mirror", + "summary": format!("{} via {}", state.stream_state, state.virtual_backend) + }, + "children": [ + { + "id": format!("{}.runtime", state.provider_id), + "kind": "pane", + "props": { "title": "Runtime" }, + "children": runtime_children + }, + { + "id": format!("{}.input", state.provider_id), + "kind": "pane", + "props": { "title": "Input" }, + "children": [ + text_element(format!("{}.device", state.provider_id), format!("device: {}", state.last_device_id.as_deref().unwrap_or("none"))), + text_element(format!("{}.kind", state.provider_id), format!("kind: {}", state.last_device_kind.as_deref().unwrap_or("unknown"))), + text_element(format!("{}.sequence", state.provider_id), format!("sequence: {}", state.last_sequence.map(|value| value.to_string()).unwrap_or_else(|| "none".to_string()))), + text_element(format!("{}.age", state.provider_id), format!("last frame age ms: {}", state.last_frame_age_ms.map(|value| value.to_string()).unwrap_or_else(|| "none".to_string()))), + text_element(format!("{}.ignored", state.provider_id), format!("ignored stream frames: {}", state.ignored_stream_frames)) + ] + }, + { + "id": format!("{}.latency", state.provider_id), + "kind": "pane", + "props": { "title": "Input Latency" }, + "children": input_latency_elements(state) + }, + { + "id": format!("{}.axis", state.provider_id), + "kind": "pane", + "props": { "title": "Axis Classification" }, + "children": [ + text_element(format!("{}.axis.summary", state.provider_id), format!("mapped axes: {}", state.last_input_latency.as_ref().map(|trace| trace.axis_summary.join(", ")).filter(|text| !text.is_empty()).unwrap_or_else(|| "none".to_string()))) + ] + }, + { + "id": format!("{}.remap", state.provider_id), + "kind": "pane", + "props": { "title": "Remapping Presets" }, + "children": preset_cards + }, + { + "id": format!("{}.binding-map-pane", state.provider_id), + "kind": "pane", + "props": { "title": "Xbox Controller Mapping", "span": "wide" }, + "children": [binding_map] + } + ] + }, + "assets": [] + }) +} + +fn input_latency_elements(state: &SleipnirRuntimeState) -> Vec { + let Some(trace) = state.last_input_latency.as_ref() else { + return vec![text_element( + format!("{}.latency.empty", state.provider_id), + "no emitted input trace yet".to_string(), + )]; + }; + vec![ + text_element( + format!("{}.latency.identity", state.provider_id), + format!( + "device: {} seq={} emitted={}", + trace.device_id, trace.sequence, trace.emitted + ), + ), + text_element( + format!("{}.latency.source-arrival", state.provider_id), + format!( + "signal arrival: {}", + trace + .source_to_arrival_ms + .map(|value| format!("source to arrival {} ms", value)) + .unwrap_or_else(|| "source timestamp unavailable".to_string()) + ), + ), + text_element( + format!("{}.latency.buffer", state.provider_id), + format!("arrival to buffer: {} ms", trace.arrival_to_buffer_ms), + ), + text_element( + format!("{}.latency.axis", state.provider_id), + format!( + "buffer to axis classification: {} ms", + trace.buffer_to_axis_ms + ), + ), + text_element( + format!("{}.latency.hid", state.provider_id), + format!("axis to HID emission: {} ms", trace.axis_to_hid_ms), + ), + text_element( + format!("{}.latency.total", state.provider_id), + format!( + "observed mirror total: {} ms{}", + trace.total_observed_ms, + trace + .source_to_hid_ms + .map(|value| format!(" / source to HID {} ms", value)) + .unwrap_or_default() + ), + ), + ] +} + +fn remap_preset_cards(state: &SleipnirRuntimeState) -> Vec { + vec![ + remap_preset_card( + state, + "nav-to-xbox", + "Nav to Xbox", + "xbox360", + serde_json::json!({ + "leftX": {"source": 0}, + "leftY": {"source": 1, "invert": true}, + "leftTrigger": {"source": 2}, + "rightTrigger": {"source": 5} + }), + serde_json::json!({ + "cross": "a", + "circle": "b", + "square": "x", + "triangle": "y", + "select": "back", + "ps": "guide" + }), + ), + remap_preset_card( + state, + "invert-y", + "Invert Y", + "xbox360", + serde_json::json!({ + "leftX": {"source": 0}, + "leftY": {"source": 1, "invert": true}, + "leftTrigger": {"source": 2}, + "rightTrigger": {"source": 5} + }), + serde_json::json!({}), + ), + ] +} + +fn remap_preset_card( + state: &SleipnirRuntimeState, + preset_id: &str, + title: &str, + presentation: &str, + axis_map: serde_json::Value, + button_map: serde_json::Value, +) -> serde_json::Value { + serde_json::json!({ + "id": format!("{}.remap.{}", state.provider_id, preset_id), + "kind": "card", + "props": { + "title": title, + "commandId": "sleipnir.apply-remap-preset", + "action": { + "type": "sleipnir.apply-remap-preset", + "providerId": state.provider_id, + "deviceFilter": state.selected_device_filter, + "presentation": presentation, + "axisMap": axis_map, + "buttonMap": button_map + } + }, + "children": [ + text_element(format!("{}.remap.{}.detail", state.provider_id, preset_id), format!("presentation: {presentation}")) + ] + }) +} + +fn xbox_binding_map(state: &SleipnirRuntimeState) -> serde_json::Value { + let targets = [ + ( + "leftX", "axis", "Left X", "axis 0", "16%", "34%", "265", "224", + ), + ( + "leftY", "axis", "Left Y", "axis 1", "16%", "49%", "265", "224", + ), + ( + "leftTrigger", + "axis", + "LT", + "axis 2", + "20%", + "12%", + "253", + "68", + ), + ( + "rightTrigger", + "axis", + "RT", + "axis 5", + "80%", + "12%", + "527", + "68", + ), + ("a", "button", "A", "unmapped", "88%", "72%", "568", "244"), + ("b", "button", "B", "unmapped", "92%", "58%", "608", "204"), + ("x", "button", "X", "unmapped", "80%", "58%", "528", "204"), + ("y", "button", "Y", "unmapped", "84%", "44%", "568", "164"), + ("lb", "button", "LB", "unmapped", "22%", "22%", "253", "108"), + ("rb", "button", "RB", "unmapped", "78%", "22%", "527", "108"), + ( + "back", "button", "Back", "unmapped", "40%", "43%", "363", "197", + ), + ( + "start", "button", "Start", "unmapped", "60%", "43%", "419", "197", + ), + ( + "guide", "button", "Guide", "unmapped", "50%", "36%", "391", "224", + ), + ("l3", "button", "L3", "unmapped", "30%", "76%", "265", "224"), + ("r3", "button", "R3", "unmapped", "70%", "76%", "474", "256"), + ( + "up", "button", "D-Up", "unmapped", "12%", "64%", "245", "224", + ), + ( + "down", "button", "D-Down", "unmapped", "12%", "78%", "245", "306", + ), + ( + "left", "button", "D-Left", "unmapped", "12%", "71%", "204", "267", + ), + ( + "right", "button", "D-Right", "unmapped", "26%", "71%", "286", "267", + ), + ]; + let children = targets + .into_iter() + .map( + |(target, kind, label, fallback, x, y, anchor_x, anchor_y)| { + binding_target( + state, + target, + kind, + label, + binding_summary(state, target, kind, fallback), + x, + y, + anchor_x, + anchor_y, + ) + }, + ) + .collect::>(); + serde_json::json!({ + "id": format!("{}.binding-map", state.provider_id), + "kind": "input.binding-map", + "props": { + "label": "Xbox controller remapping" + }, + "children": children + }) +} + +fn binding_target( + state: &SleipnirRuntimeState, + target: &str, + binding_kind: &str, + label: &str, + current: String, + x: &str, + y: &str, + anchor_x: &str, + anchor_y: &str, +) -> serde_json::Value { + let listening = state + .pending_learn + .as_ref() + .is_some_and(|learn| learn.target == target && learn.binding_kind == binding_kind); + serde_json::json!({ + "id": format!("{}.binding.{}", state.provider_id, stable_id(target)), + "kind": "binding.target", + "props": { + "target": target, + "bindingKind": binding_kind, + "label": label, + "current": if listening { "listening..." } else { current.as_str() }, + "x": x, + "y": y, + "anchorX": anchor_x, + "anchorY": anchor_y, + "documentId": format!("cultmesh://sleipnir/{}/binding/{target}", state.provider_id), + "slotId": format!("sleipnir.binding.{target}"), + "schemaId": "gamecult.eve.surface.v1", + "presentationKind": "modal.binding", + "deviceFilter": state.selected_device_filter, + "presentation": "xbox360" + }, + "embeddedDocuments": [{ + "slotId": format!("sleipnir.binding.{target}"), + "documentId": format!("cultmesh://sleipnir/{}/binding/{target}", state.provider_id), + "schemaId": "gamecult.eve.surface.v1", + "presentationKind": "modal.binding" + }] + }) +} + +fn binding_summary( + state: &SleipnirRuntimeState, + target: &str, + binding_kind: &str, + fallback: &str, +) -> String { + if binding_kind == "axis" { + return axis_binding_label(state.axis_map.get(target)); + } + let sources = state + .button_map + .iter() + .filter_map(|(source, mapped)| (mapped == target).then_some(source.as_str())) + .collect::>(); + if sources.is_empty() { + fallback.to_string() + } else { + sources.join(", ") + } +} + +fn axis_binding_label(binding: Option<&AxisBinding>) -> String { + match binding { + Some(binding) if binding.invert => format!("axis {} inverted", binding.source), + Some(binding) => format!("axis {}", binding.source), + None => "unmapped".to_string(), + } +} + +fn text_element(id: String, text: String) -> serde_json::Value { + serde_json::json!({ + "id": id, + "kind": "text", + "props": { "text": text }, + "children": [] + }) +} + +fn read_desired_mapping( + node: &mut cultmesh_rs::CultMeshNode, + options: &Options, +) -> SleipnirDesiredMapping { + read_desired_mapping_from_sources(node, None, options) +} + +fn read_desired_mapping_from_sources( + local_node: &mut cultmesh_rs::CultMeshNode, + discovery_node: Option<&mut cultmesh_rs::CultMeshNode>, + options: &Options, +) -> SleipnirDesiredMapping { + let local_record = read_desired_mapping_record(local_node, options); + let discovery_record = + discovery_node.and_then(|node| read_desired_mapping_record(node, options)); + if let Some(record) = newest_sleipnir_mapping_record(local_record, discovery_record) { + return desired_mapping_from_record(record); + } + options + .mapping_path + .as_ref() + .and_then(|path| read_legacy_mapping_file(path)) + .unwrap_or_else(default_desired_mapping) +} + +fn read_desired_mapping_record( + node: &mut cultmesh_rs::CultMeshNode, + options: &Options, +) -> Option { + let _ = node.pull_all_backing_stores(); + node.get::(&sleipnir_provider_id(options)) + .ok() + .flatten() +} + +fn newest_sleipnir_mapping_record( + local: Option, + discovery: Option, +) -> Option { + match (local, discovery) { + (Some(local), Some(discovery)) if discovery.updated_at > local.updated_at => { + Some(discovery) + } + (Some(local), Some(_)) => Some(local), + (Some(local), None) => Some(local), + (None, Some(discovery)) => Some(discovery), + (None, None) => None, + } +} + +fn default_desired_mapping() -> SleipnirDesiredMapping { + SleipnirDesiredMapping { + enabled: false, + device_filter: None, + stream_id: None, + presentation: default_presentation(), + axis_map: default_axis_map(), + button_map: default_button_map(), + pending_learn: None, + } +} + +fn desired_mapping_from_record(record: SleipnirInputMappingRecord) -> SleipnirDesiredMapping { + SleipnirDesiredMapping { + enabled: record.enabled, + device_filter: Some(record.device_filter) + .filter(|value| !value.trim().is_empty()) + .map(|value| value.to_string()), + stream_id: Some(record.stream_id) + .filter(|value| !value.trim().is_empty()) + .map(|value| value.to_string()), + presentation: normalize_presentation(&record.presentation), + axis_map: parse_axis_map(Some(&record.axis_map)), + button_map: parse_button_map(Some(&record.button_map)), + pending_learn: parse_learn_request(Some(&record.pending_learn)), + } +} + +fn read_legacy_mapping_file(path: &PathBuf) -> Option { + let text = fs::read_to_string(path).ok()?; + let value = + serde_json::from_str::(text.trim_start_matches('\u{feff}')).ok()?; + Some(SleipnirDesiredMapping { + enabled: false, + device_filter: value + .get("deviceFilter") + .or_else(|| value.get("device_filter")) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .map(ToString::to_string), + stream_id: value + .get("streamId") + .or_else(|| value.get("stream_id")) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .map(ToString::to_string), + presentation: normalize_presentation( + value + .get("presentation") + .or_else(|| value.get("presentationProfile")) + .or_else(|| value.get("presentation_profile")) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("xbox360"), + ), + axis_map: parse_axis_map(value.get("axisMap").or_else(|| value.get("axis_map"))), + button_map: parse_button_map(value.get("buttonMap").or_else(|| value.get("button_map"))), + pending_learn: parse_learn_request( + value + .get("pendingLearn") + .or_else(|| value.get("pending_learn")) + .or_else(|| value.get("learn")), + ), + }) +} + +fn parse_learn_request(value: Option<&serde_json::Value>) -> Option { + let map = value?.as_object()?; + let target = map + .get("target") + .or_else(|| map.get("buttonTarget")) + .or_else(|| map.get("axisTarget")) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty())? + .to_string(); + let binding_kind = map + .get("bindingKind") + .or_else(|| map.get("kind")) + .and_then(|value| value.as_str()) + .filter(|value| !value.trim().is_empty()) + .unwrap_or("button") + .to_string(); + Some(LearnRequest { + target, + binding_kind, + requested_at: map + .get("requestedAt") + .or_else(|| map.get("requested_at")) + .and_then(|value| value.as_str()) + .map(ToString::to_string), + }) +} + +fn default_presentation() -> String { + "xbox360".to_string() +} + +fn normalize_presentation(_presentation: &str) -> String { + default_presentation() +} + +fn default_axis_map() -> HashMap { + HashMap::from([ + ("leftX".to_string(), AxisBinding::stick_source(0)), + ( + "leftY".to_string(), + AxisBinding { + source: 1, + invert: true, + scale: 1.0, + deadzone: DEFAULT_STICK_DEADZONE, + }, + ), + ("leftTrigger".to_string(), AxisBinding::source(2)), + ("rightTrigger".to_string(), AxisBinding::source(5)), + ]) +} + +fn default_button_map() -> HashMap { + HashMap::from([ + ("trigger".to_string(), "none".to_string()), + ("l2".to_string(), "none".to_string()), + ]) +} + +impl AxisBinding { + fn source(source: usize) -> Self { + Self { + source, + invert: false, + scale: 1.0, + deadzone: 0.0, + } + } + + fn stick_source(source: usize) -> Self { + Self { + source, + invert: false, + scale: 1.0, + deadzone: DEFAULT_STICK_DEADZONE, + } + } +} + +fn parse_axis_map(value: Option<&serde_json::Value>) -> HashMap { + let mut axis_map = default_axis_map(); + let Some(map) = value.and_then(|value| value.as_object()) else { + return axis_map; + }; + for (target, binding) in map { + if let Some(binding) = parse_axis_binding(binding) { + axis_map.insert(target.clone(), binding); + } + } + axis_map +} + +fn parse_axis_binding(value: &serde_json::Value) -> Option { + if let Some(source) = value.as_u64() { + return Some(AxisBinding::source(source as usize)); + } + let map = value.as_object()?; + let source = map + .get("source") + .or_else(|| map.get("axis")) + .and_then(|value| value.as_u64())? as usize; + Some(AxisBinding { + source, + invert: map + .get("invert") + .and_then(|value| value.as_bool()) + .unwrap_or(false), + scale: map + .get("scale") + .and_then(|value| value.as_f64()) + .unwrap_or(1.0) as f32, + deadzone: map + .get("deadzone") + .and_then(|value| value.as_f64()) + .unwrap_or(0.0) as f32, + }) +} + +fn parse_button_map(value: Option<&serde_json::Value>) -> HashMap { + value + .and_then(|value| value.as_object()) + .map(|map| { + map.iter() + .filter_map(|(source, target)| { + target + .as_str() + .filter(|target| !target.trim().is_empty()) + .map(|target| (source.clone(), target.to_string())) + }) + .collect() + }) + .unwrap_or_else(default_button_map) +} + +fn apply_pending_learn( + node: &mut cultmesh_rs::CultMeshNode, + options: &Options, + mapping: &SleipnirDesiredMapping, + record: &MuninnHidControllerStateRecord, +) -> Result> { + let Some(learn) = mapping.pending_learn.as_ref() else { + return Ok(None); + }; + let mut updated = mapping.clone(); + let learned = if learn.binding_kind == "axis" { + if let Some(source) = most_active_axis(&record.axes) { + updated.axis_map.insert( + learn.target.clone(), + AxisBinding { + source, + invert: learn.target == "leftY" || learn.target == "rightY", + scale: 1.0, + deadzone: 0.0, + }, + ); + true + } else { + false + } + } else if let Some(source) = record.buttons.first() { + updated + .button_map + .insert(source.clone(), learn.target.clone()); + true + } else { + false + }; + if !learned { + return Ok(None); + } + updated.pending_learn = None; + updated.enabled = true; + write_desired_mapping(node, options, &updated, "sleipnir.auto-bind")?; + Ok(Some(updated)) +} + +fn most_active_axis(axes: &[f32]) -> Option { + axes.iter() + .enumerate() + .filter(|(_, value)| value.abs() >= 0.45) + .max_by(|(_, left), (_, right)| { + left.abs() + .partial_cmp(&right.abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }) + .map(|(index, _)| index) +} + +fn write_desired_mapping( + node: &mut cultmesh_rs::CultMeshNode, + options: &Options, + mapping: &SleipnirDesiredMapping, + source: &str, +) -> Result<()> { + let provider_id = sleipnir_provider_id(options); + let record = SleipnirInputMappingRecord { + provider_id: provider_id.clone(), + enabled: mapping.enabled, + device_filter: mapping.device_filter.clone().unwrap_or_default(), + stream_id: mapping.stream_id.clone().unwrap_or_default(), + presentation: mapping.presentation.clone(), + axis_map: axis_map_json(&mapping.axis_map), + button_map: serde_json::Value::Object( + mapping + .button_map + .iter() + .map(|(source, target)| (source.clone(), serde_json::Value::String(target.clone()))) + .collect(), + ), + pending_learn: mapping + .pending_learn + .as_ref() + .map(|learn| { + serde_json::json!({ + "target": learn.target, + "bindingKind": learn.binding_kind, + "requestedAt": learn.requested_at + }) + }) + .unwrap_or(serde_json::Value::Null), + updated_at: timestamp(), + source: source.to_string(), + }; + node.put(&provider_id, &record)?; + Ok(()) +} + +fn axis_map_json(axis_map: &HashMap) -> serde_json::Value { + serde_json::Value::Object( + axis_map + .iter() + .map(|(target, binding)| { + ( + target.clone(), + serde_json::json!({ + "source": binding.source, + "invert": binding.invert, + "scale": binding.scale, + "deadzone": binding.deadzone + }), + ) + }) + .collect(), + ) +} + +fn effective_muninn_hid_endpoint( + node: &cultmesh_rs::CultMeshNode, + device_filter: Option<&str>, + stream_id: Option<&str>, + trace: bool, +) -> Option { + discover_muninn_hid_endpoint(node, device_filter, stream_id, trace) +} + +fn effective_device_filter( + options: &Options, + desired_mapping: &SleipnirDesiredMapping, +) -> Option { + desired_mapping + .device_filter + .clone() + .or_else(|| options.device_filter.clone()) +} + +fn discover_available_hid_devices(node: &cultmesh_rs::CultMeshNode) -> Vec { + let mut devices = Vec::new(); + for record in node + .cache() + .get_all::() + .ok() + .into_iter() + .flatten() + { + upsert_available_hid_device( + &mut devices, + available_hid_device_from_record( + node, + &record, + discover_muninn_hid_endpoint( + node, + Some(record.device_id.as_str()), + Some(record.stream_id.as_str()), + false, + ), + ), + ); + } + for provider in node + .cache() + .get_all::() + .ok() + .into_iter() + .flatten() + { + for device in available_hid_devices_from_value(&provider.value) { + upsert_available_hid_device(&mut devices, device); + } + } + for envelope in node.cache().snapshot() { + if envelope.schema_id.as_deref() == Some(MUNINN_HID_CONTROLLER_STATE_SCHEMA) + || envelope.r#type == "muninn.hid_controller_state" + { + if let Ok(value) = rmp_serde::from_slice::(&envelope.payload) { + if let Some(device) = available_hid_device_from_hid_value(node, &value) { + upsert_available_hid_device(&mut devices, device); + } + } + continue; + } + if envelope.schema_id.as_deref() == Some(EVE_PROVIDER_ADVERTISEMENT_SCHEMA) + || envelope.r#type == "gamecult.eve.provider_advertisement" + { + if let Ok(value) = rmp_serde::from_slice::(&envelope.payload) { + for device in available_hid_devices_from_value(&value) { + upsert_available_hid_device(&mut devices, device); + } + } + continue; + } + } + devices.retain(|device| endpoint_looks_like_socket(&device.endpoint)); + devices.sort_by(|left, right| { + (&left.host_id, &left.device_id, &left.endpoint).cmp(&( + &right.host_id, + &right.device_id, + &right.endpoint, + )) + }); + devices + .dedup_by(|left, right| left.host_id == right.host_id && left.device_id == right.device_id); + devices +} + +fn available_hid_device_from_hid_value( + node: &cultmesh_rs::CultMeshNode, + value: &serde_json::Value, +) -> Option { + let payload = value + .as_array() + .and_then(|items| { + (items.len() == 1) + .then(|| items.first()) + .flatten() + .filter(|item| item.is_object() || item.is_array()) + }) + .unwrap_or(value); + let (stream_id, host_id, device_id, device_kind, source_path) = + if let Some(items) = payload.as_array() { + ( + items.first()?.as_str()?.to_string(), + items.get(1)?.as_str()?.to_string(), + items.get(2)?.as_str()?.to_string(), + items.get(3)?.as_str()?.to_string(), + items + .get(10) + .and_then(|item| item.as_str()) + .unwrap_or("unknown") + .to_string(), + ) + } else { + ( + string_field(payload, &["stream_id", "streamId"])?, + string_field(payload, &["host_id", "hostId"])?, + string_field(payload, &["device_id", "deviceId"])?, + string_field(payload, &["device_kind", "deviceKind"])?, + string_field(payload, &["source_path", "sourcePath"]) + .unwrap_or_else(|| "unknown".to_string()), + ) + }; + let endpoint = discover_muninn_hid_endpoint( + node, + Some(device_id.as_str()), + Some(stream_id.as_str()), + false, + ) + .unwrap_or_else(|| "discovering".to_string()); + Some(AvailableHidDevice { + device_id, + device_kind, + source_path, + stream_id, + host_id, + endpoint, + }) +} + +fn string_field(value: &serde_json::Value, names: &[&str]) -> Option { + names + .iter() + .find_map(|name| value.get(*name).and_then(|field| field.as_str())) + .map(ToString::to_string) +} + +fn available_hid_device_from_record( + node: &cultmesh_rs::CultMeshNode, + record: &MuninnHidControllerStateRecord, + endpoint: Option, +) -> AvailableHidDevice { + AvailableHidDevice { + device_id: record.device_id.clone(), + device_kind: record.device_kind.clone(), + source_path: record.source_path.clone(), + stream_id: record.stream_id.clone(), + host_id: record.host_id.clone(), + endpoint: endpoint.unwrap_or_else(|| { + discover_muninn_hid_endpoint( + node, + Some(record.device_id.as_str()), + Some(record.stream_id.as_str()), + false, + ) + .unwrap_or_else(|| "discovering".to_string()) + }), + } +} + +fn upsert_available_hid_device(devices: &mut Vec, device: AvailableHidDevice) { + if let Some(existing) = devices.iter_mut().find(|existing| { + existing.host_id == device.host_id && existing.device_id == device.device_id + }) { + if should_replace_available_hid_device(existing, &device) { + *existing = device; + } + } else { + devices.push(device); + } + devices.sort_by(|left, right| { + (&left.host_id, &left.device_id, &left.endpoint).cmp(&( + &right.host_id, + &right.device_id, + &right.endpoint, + )) + }); +} + +fn should_replace_available_hid_device( + existing: &AvailableHidDevice, + candidate: &AvailableHidDevice, +) -> bool { + let existing_score = available_hid_device_discovery_score(existing); + let candidate_score = available_hid_device_discovery_score(candidate); + candidate_score >= existing_score +} + +fn available_hid_device_discovery_score(device: &AvailableHidDevice) -> u8 { + let mut score = 0; + if device.endpoint != "discovering" { + score += 4; + } + if device.stream_id.contains(&device.device_id) { + score += 2; + } + if !device.source_path.trim().is_empty() && device.source_path != "unknown" { + score += 1; + } + score +} + +fn available_hid_devices_from_value(value: &serde_json::Value) -> Vec { + let mut devices = Vec::new(); + let Some(streams) = value + .get("inputStreams") + .or_else(|| value.get("input_streams")) + .and_then(|streams| streams.as_array()) + else { + return devices; + }; + for stream in streams { + let endpoint = stream + .get("address") + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + let stream_id = stream + .get("streamId") + .or_else(|| stream.get("stream_id")) + .and_then(|value| value.as_str()) + .unwrap_or("muninn:hid") + .to_string(); + if !is_muninn_fast_hid_stream(stream, &stream_id, &endpoint) { + continue; + } + let host_id = value + .get("verseId") + .or_else(|| value.get("verse_id")) + .and_then(|value| value.as_str()) + .unwrap_or("") + .trim_end_matches(".local") + .to_string(); + if let Some(stream_devices) = stream.get("devices").and_then(|value| value.as_array()) { + for device in stream_devices { + let device_id = device + .get("deviceId") + .or_else(|| device.get("device_id")) + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string(); + if device_id.is_empty() { + continue; + } + devices.push(AvailableHidDevice { + device_id, + device_kind: device + .get("deviceKind") + .or_else(|| device.get("device_kind")) + .and_then(|value| value.as_str()) + .unwrap_or("hid") + .to_string(), + source_path: device + .get("sourcePath") + .or_else(|| device.get("source_path")) + .and_then(|value| value.as_str()) + .unwrap_or("") + .to_string(), + stream_id: stream_id.clone(), + host_id: host_id.clone(), + endpoint: endpoint.clone(), + }); + } + } + } + devices +} + +fn is_muninn_fast_hid_stream(stream: &serde_json::Value, stream_id: &str, endpoint: &str) -> bool { + if stream.get("schema").and_then(|value| value.as_str()) + != Some("muninn.hid_controller_state.v1") + { + return false; + } + if !stream_id.contains(":hid-controller-state") || stream_id.contains("move-evidence") { + return false; + } + if !endpoint_looks_like_socket(endpoint) { + return false; + } + stream + .get("transport") + .and_then(|value| value.as_str()) + .is_some_and(|transport| transport.contains("rudp")) +} + +fn endpoint_looks_like_socket(endpoint: &str) -> bool { + let Some((host, port)) = endpoint.rsplit_once(':') else { + return false; + }; + !host.trim().is_empty() + && port + .parse::() + .is_ok_and(|parsed_port| parsed_port != 0) +} + +fn stable_id(value: &str) -> String { + let normalized = value + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) + .collect::() + .trim_matches('-') + .to_ascii_lowercase(); + if normalized.is_empty() { + "device".to_string() + } else { + normalized + } +} + +fn sleipnir_provider_id(options: &Options) -> String { + format!("sleipnir.input-mirror.{}", options.host_id) +} + +fn sleipnir_daemon_id(options: &Options) -> String { + format!("{}-sleipnir", options.host_id) +} + +fn sleipnir_provider_title(options: &Options) -> String { + format!("Sleipnir {} Input Mirror", title_case(&options.host_id)) +} + +fn timestamp() -> String { + use std::time::{SystemTime, UNIX_EPOCH}; + match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(duration) => format!("unix-{}", duration.as_secs()), + Err(_) => "unix-0".to_string(), + } +} + +fn unix_millis_i64() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)) + .unwrap_or_default() +} + +fn unix_nanos_i64() -> i64 { + use std::time::{SystemTime, UNIX_EPOCH}; + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|duration| i64::try_from(duration.as_nanos()).unwrap_or(i64::MAX)) + .unwrap_or_default() +} + +fn title_case(value: &str) -> String { + let mut chars = value.chars(); + match chars.next() { + Some(first) => first.to_uppercase().collect::() + chars.as_str(), + None => String::new(), + } +} + +fn create_rudp_stream( + bind: Option, + connect_endpoint: Option, + trace: bool, +) -> Result> { + if let Some(endpoint) = connect_endpoint { + let target = resolve_socket_addr(&endpoint) + .with_context(|| format!("resolving Muninn HID RUDP endpoint {endpoint}"))?; + let bind_address = bind.unwrap_or_else(|| { + if target.is_ipv4() { + SocketAddr::from(([0, 0, 0, 0], 0)) + } else { + SocketAddr::from(([0, 0, 0, 0, 0, 0, 0, 0], 0)) + } + }); + let socket = UdpSocket::bind(bind_address) + .with_context(|| format!("binding Sleipnir RUDP HID client at {bind_address}"))?; + socket + .set_read_timeout(Some(Duration::from_millis(1))) + .context("setting Sleipnir RUDP HID client timeout")?; + if trace { + eprintln!( + "Sleipnir connecting to Muninn HID RUDP at {target} connection_id={SLEIPNIR_HID_RUDP_CONNECTION_ID:08x}" + ); + } + let mut transport = + CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { + runtime_id: "sleipnir-hid-rudp".to_string(), + socket, + mode: CultNetRudpSocketMode::Client, + remote_addr: Some(target), + connection_id: SLEIPNIR_HID_RUDP_CONNECTION_ID, + initial_sequence: 1, + resend_delay_ms: 5, + transport_id: Some("sleipnir-hid-rudp".to_string()), + max_payload_bytes: None, + max_fragment_bytes: Some(HID_RUDP_MAX_FRAGMENT_BYTES), + max_pending_reliable_packets: Some(256), + media_reliable_expire_after_ms: Some(25), + media_delivery: None, + reconnect_policy: None, + })?; + transport.connect(Vec::new())?; + return Ok(Some(ActiveRudpStream { + transport, + target: Some(target), + last_connect_attempt_at: Some(Instant::now()), + connected_logged: false, + last_frame_at: Some(Instant::now()), + last_stale_log_at: None, + last_subscription: None, + last_subscription_at: None, + })); + } + let Some(bind) = bind else { + return Ok(None); + }; + let socket = UdpSocket::bind(bind) + .with_context(|| format!("binding Sleipnir RUDP HID stream at {bind}"))?; + socket + .set_read_timeout(Some(Duration::from_millis(1))) + .with_context(|| format!("setting Sleipnir RUDP HID stream {bind} timeout"))?; + if trace { + eprintln!( + "Sleipnir listening for fast HID RUDP on {bind} connection_id={SLEIPNIR_HID_RUDP_CONNECTION_ID:08x}" + ); + } + Ok(Some(ActiveRudpStream { + transport: CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { + runtime_id: "sleipnir-hid-rudp".to_string(), + socket, + mode: CultNetRudpSocketMode::Server, + remote_addr: None, + connection_id: SLEIPNIR_HID_RUDP_CONNECTION_ID, + initial_sequence: 1, + resend_delay_ms: 5, + transport_id: Some("sleipnir-hid-rudp".to_string()), + max_payload_bytes: None, + max_fragment_bytes: Some(HID_RUDP_MAX_FRAGMENT_BYTES), + max_pending_reliable_packets: Some(256), + media_reliable_expire_after_ms: Some(25), + media_delivery: None, + reconnect_policy: None, + })?, + target: None, + last_connect_attempt_at: None, + connected_logged: false, + last_frame_at: None, + last_stale_log_at: None, + last_subscription: None, + last_subscription_at: None, + })) +} + +fn send_hid_subscription_if_due( + stream: &mut ActiveRudpStream, + device_filter: Option, + stream_id: Option, + trace: bool, +) -> Result<()> { + if !stream.transport.connected() { + return Ok(()); + } + let subscription = HidControllerRudpSubscription { + device_filter: device_filter.filter(|filter| !filter.trim().is_empty()), + stream_id: stream_id.filter(|stream_id| !stream_id.trim().is_empty()), + }; + let changed = stream.last_subscription.as_ref() != Some(&subscription); + let due = stream + .last_subscription_at + .is_none_or(|sent_at| sent_at.elapsed() >= Duration::from_secs(1)); + if !changed && !due { + return Ok(()); + } + let payload = serde_json::to_vec(&serde_json::json!({ + "deviceFilter": subscription.device_filter, + "streamId": subscription.stream_id, + })) + .context("encoding Sleipnir HID RUDP subscription")?; + stream + .transport + .send("hid.subscribe", payload) + .context("sending Sleipnir HID RUDP subscription")?; + if trace || changed { + eprintln!( + "Sleipnir subscribed to Muninn HID device_filter={:?} stream_id={:?}", + subscription.device_filter, subscription.stream_id + ); + } + stream.last_subscription = Some(subscription); + stream.last_subscription_at = Some(Instant::now()); + Ok(()) +} + +fn resolve_socket_addr(endpoint: &str) -> Result { + endpoint + .to_socket_addrs() + .with_context(|| format!("resolving {endpoint}"))? + .next() + .ok_or_else(|| anyhow!("no socket addresses resolved for {endpoint}")) +} + +fn ensure_rudp_connected(stream: &mut ActiveRudpStream, trace: bool) -> Result { + let Some(target) = stream.target else { + return Ok(false); + }; + if stream.transport.connected() { + if !stream.connected_logged && trace { + eprintln!("Sleipnir connected to Muninn HID RUDP at {target}"); + } + stream.connected_logged = true; + if stream + .last_frame_at + .is_some_and(|last_frame| last_frame.elapsed() >= INPUT_STREAM_RECONNECT_AFTER) + { + let should_log = stream + .last_stale_log_at + .is_none_or(|logged| logged.elapsed() >= INPUT_STREAM_RECONNECT_AFTER); + if should_log && trace { + eprintln!( + "Sleipnir Muninn HID RUDP is connected to {target} but has received no frames for {:?}; recreating connection", + stream.last_frame_at.map(|last_frame| last_frame.elapsed()) + ); + } + stream.last_stale_log_at = Some(Instant::now()); + stream.connected_logged = false; + return Ok(true); + } else { + return Ok(false); + } + } + let should_attempt = stream + .last_connect_attempt_at + .is_none_or(|attempt| attempt.elapsed() >= Duration::from_millis(250)); + if should_attempt { + stream.last_connect_attempt_at = Some(Instant::now()); + stream.transport.connect(Vec::new())?; + } + Ok(false) +} + +fn discover_muninn_hid_endpoint( + node: &cultmesh_rs::CultMeshNode, + device_filter: Option<&str>, + stream_id: Option<&str>, + trace: bool, +) -> Option { + if let Some(endpoint) = discover_available_hid_endpoint(node, device_filter, stream_id) { + return Some(endpoint); + } + let host_hint = stream_host_hint(stream_id); + let endpoints = discover_provider_endpoints( + node, + OdinEndpointQuery { + schema: Some(MUNINN_HID_CONTROLLER_STATE_SCHEMA), + transport_contains: Some("rudp"), + host_hint: host_hint.as_deref(), + device_filter, + }, + ); + if trace && endpoints.is_empty() { + eprintln!( + "Sleipnir found no Muninn HID endpoints for host_hint={:?} device_filter={:?}", + host_hint, device_filter + ); + } + endpoints + .into_iter() + .next() + .map(|endpoint| endpoint.address) +} + +fn discover_available_hid_endpoint( + node: &cultmesh_rs::CultMeshNode, + device_filter: Option<&str>, + stream_id: Option<&str>, +) -> Option { + for provider in node + .cache() + .get_all::() + .ok() + .into_iter() + .flatten() + { + if let Some(endpoint) = + matching_provider_advertised_hid_endpoint(&provider.value, device_filter, stream_id) + { + return Some(endpoint); + } + } + for envelope in node.cache().snapshot() { + if envelope.schema_id.as_deref() != Some(EVE_PROVIDER_ADVERTISEMENT_SCHEMA) + && envelope.r#type != "gamecult.eve.provider_advertisement" + { + continue; + } + let Ok(value) = rmp_serde::from_slice::(&envelope.payload) else { + continue; + }; + if let Some(endpoint) = + matching_provider_advertised_hid_endpoint(&value, device_filter, stream_id) + { + return Some(endpoint); + } + } + None +} + +fn matching_provider_advertised_hid_endpoint( + value: &serde_json::Value, + device_filter: Option<&str>, + stream_id: Option<&str>, +) -> Option { + let host_hint = stream_host_hint(stream_id); + let filter = device_filter + .map(str::trim) + .filter(|value| !value.is_empty()); + available_hid_devices_from_value(value) + .into_iter() + .filter(|device| { + host_hint + .as_deref() + .is_none_or(|host| device.host_id.eq_ignore_ascii_case(host)) + }) + .find(|device| { + stream_id.is_none_or(|stream_id| device.stream_id == stream_id) + || filter.is_some_and(|filter| { + device.device_id == filter + || device.device_kind == filter + || device.stream_id == filter + || device.source_path.contains(filter) + }) + }) + .map(|device| device.endpoint) +} + +fn stream_host_hint(stream_id: Option<&str>) -> Option { + let stream_id = stream_id?; + let parts = stream_id + .split(':') + .filter(|part| !part.trim().is_empty()) + .collect::>(); + let host = match parts.as_slice() { + ["muninn", host, ..] => *host, + [host, ..] => *host, + _ => return None, + }; + let normalized = host.trim().trim_end_matches(".local").to_ascii_lowercase(); + (!normalized.is_empty()).then_some(normalized) +} + +fn receive_rudp_records(stream: &mut ActiveRudpStream, trace: bool) -> Result> { + let mut records = Vec::new(); + let mut empty_polls = 0usize; + for _ in 0..64 { + match stream.transport.receive_once() { + Ok(Some(frame)) if frame.channel_id == "latest" || frame.channel_id == "hid" => { + empty_polls = 0; + let frame_received_at = Instant::now(); + let frame_received_unix_ns = unix_nanos_i64(); + if trace { + eprintln!( + "Sleipnir received fast HID frame channel={} bytes={}", + frame.channel_id, + frame.payload.len() + ); + } + records.push(TimedHidRecord { + record: serde_json::from_slice(&frame.payload) + .context("decoding Sleipnir RUDP HID frame")?, + timing: HidRecordTiming { + frame_received_at, + frame_received_unix_ns, + buffer_ready_at: frame_received_at, + }, + }); + } + Ok(Some(frame)) => { + empty_polls = 0; + if trace { + eprintln!( + "Sleipnir ignored RUDP channel={} bytes={}", + frame.channel_id, + frame.payload.len() + ); + } + } + Ok(None) => { + empty_polls += 1; + if empty_polls >= 4 { + break; + } + } + Err(error) => { + if trace { + eprintln!("Sleipnir RUDP receive/control warning: {error:#}"); + } + break; + } + } + } + if let Err(error) = stream.transport.poll_resends() { + if trace { + eprintln!("Sleipnir RUDP resend warning: {error:#}"); + } + } + let buffer_ready_at = Instant::now(); + let mut records = coalesce_latest_timed_hid_records_by_device(records); + for record in &mut records { + record.timing.buffer_ready_at = buffer_ready_at; + } + Ok(records) +} + +fn coalesce_latest_timed_hid_records_by_device( + records: Vec, +) -> Vec { + let mut latest_by_device = HashMap::::new(); + for (index, record) in records.into_iter().enumerate() { + latest_by_device + .entry(record.record.device_id.clone()) + .and_modify(|(latest_index, latest_record)| { + if record.record.source_timestamp_ns > latest_record.record.source_timestamp_ns + || (record.record.source_timestamp_ns + == latest_record.record.source_timestamp_ns + && record.record.sequence > latest_record.record.sequence) + { + *latest_index = index; + *latest_record = record.clone(); + } + }) + .or_insert((index, record)); + } + let mut latest = latest_by_device.into_values().collect::>(); + latest.sort_by_key(|(index, _)| *index); + latest.into_iter().map(|(_, record)| record).collect() +} + +fn coalesce_latest_hid_records_by_device( + records: Vec, +) -> Vec { + let mut latest_by_device = HashMap::::new(); + for (index, record) in records.into_iter().enumerate() { + latest_by_device + .entry(record.device_id.clone()) + .and_modify(|(latest_index, latest_record)| { + if record.source_timestamp_ns > latest_record.source_timestamp_ns + || (record.source_timestamp_ns == latest_record.source_timestamp_ns + && record.sequence > latest_record.sequence) + { + *latest_index = index; + *latest_record = record.clone(); + } + }) + .or_insert((index, record)); + } + let mut latest = latest_by_device.into_values().collect::>(); + latest.sort_by_key(|(index, _)| *index); + latest.into_iter().map(|(_, record)| record).collect() +} + +#[cfg(windows)] +fn create_backend() -> Result> { + Ok(Box::new(VigemXboxBackend::connect()?)) +} + +#[cfg(not(windows))] +fn create_backend() -> Result> { + Err(anyhow!( + "Sleipnir's ViGEm Xbox virtual controller backend is currently implemented on Windows; use --dry-run on this platform" + )) +} + +fn record_matches_filter( + record: &MuninnHidControllerStateRecord, + device_filter: Option<&str>, +) -> bool { + device_filter.is_none_or(|filter| { + record.device_id == filter + || record.stream_id == filter + || record.source_path.contains(filter) + || record.device_kind == filter + }) +} + +fn input_latency_snapshot( + record: &MuninnHidControllerStateRecord, + timing: &HidRecordTiming, + axis_classified_at: Instant, + hid_emitted_at: Instant, + mapping: &SleipnirDesiredMapping, + emitted: bool, +) -> SleipnirInputLatencySnapshot { + let source_to_arrival_ms = (record.source_timestamp_ns > 0).then(|| { + timing + .frame_received_unix_ns + .saturating_sub(record.source_timestamp_ns) + / 1_000_000 + }); + let source_to_hid_ms = (record.source_timestamp_ns > 0) + .then(|| unix_nanos_i64().saturating_sub(record.source_timestamp_ns) / 1_000_000); + SleipnirInputLatencySnapshot { + device_id: record.device_id.clone(), + sequence: record.sequence, + source_to_arrival_ms, + arrival_to_buffer_ms: timing + .buffer_ready_at + .saturating_duration_since(timing.frame_received_at) + .as_millis(), + buffer_to_axis_ms: axis_classified_at + .saturating_duration_since(timing.buffer_ready_at) + .as_millis(), + axis_to_hid_ms: hid_emitted_at + .saturating_duration_since(axis_classified_at) + .as_millis(), + source_to_hid_ms, + total_observed_ms: hid_emitted_at + .saturating_duration_since(timing.frame_received_at) + .as_millis(), + axis_summary: axis_classification_summary(record, mapping), + emitted, + } +} + +fn axis_classification_summary( + record: &MuninnHidControllerStateRecord, + mapping: &SleipnirDesiredMapping, +) -> Vec { + let mut axes = mapping + .axis_map + .iter() + .map(|(target, binding)| { + let value = record.axes.get(binding.source).copied(); + format!( + "{}<-axis{}{}{}", + target, + binding.source, + if binding.invert { " inverted" } else { "" }, + value + .map(|value| format!(" value={:.3}", value)) + .unwrap_or_else(|| " missing".to_string()) + ) + }) + .collect::>(); + axes.sort(); + axes +} + +fn map_record_to_virtual_pad( + record: &MuninnHidControllerStateRecord, + mapping: &SleipnirDesiredMapping, +) -> VirtualPadState { + let left_x = signed_axis_to_i16(mapped_axis(record, mapping, "leftX", 0.0)); + let left_y = signed_axis_to_i16(mapped_axis(record, mapping, "leftY", 0.0)); + let mut buttons = mapped_buttons(record, mapping); + let left_trigger = mapped_trigger(record, mapping, "leftTrigger", 2); + let right_trigger = mapped_trigger(record, mapping, "rightTrigger", 5); + if buttons.iter().any(|button| button == "l2") && left_trigger == 0 { + buttons.push("left-trigger-button".to_string()); + } + VirtualPadState { + buttons, + left_x, + left_y, + left_trigger, + right_trigger, + } +} + +fn mapped_axis( + record: &MuninnHidControllerStateRecord, + mapping: &SleipnirDesiredMapping, + target: &str, + default: f32, +) -> f32 { + let Some(binding) = mapping.axis_map.get(target) else { + return default; + }; + let mut value = record.axes.get(binding.source).copied().unwrap_or(default); + if value.abs() < binding.deadzone { + value = 0.0; + } + if binding.invert { + value = -value; + } + (value * binding.scale).clamp(-1.0, 1.0) +} + +fn mapped_trigger( + record: &MuninnHidControllerStateRecord, + mapping: &SleipnirDesiredMapping, + target: &str, + fallback_source: usize, +) -> u8 { + if mapping.axis_map.contains_key(target) { + if mapping + .axis_map + .get(target) + .is_some_and(|binding| record.axes.get(binding.source).is_none()) + { + return 0; + } + trigger_from_axis( + Some(mapped_axis(record, mapping, target, 0.0)), + record.device_kind.as_str(), + ) + } else { + trigger_from_axis( + record.axes.get(fallback_source).copied(), + record.device_kind.as_str(), + ) + } +} + +fn mapped_buttons( + record: &MuninnHidControllerStateRecord, + mapping: &SleipnirDesiredMapping, +) -> Vec { + let mut buttons = Vec::new(); + for button in &record.buttons { + let mapped = mapping + .button_map + .get(button) + .map(String::as_str) + .unwrap_or(button.as_str()); + if mapped.eq_ignore_ascii_case("none") || mapped.trim().is_empty() { + continue; + } + if !buttons.iter().any(|existing| existing == mapped) { + buttons.push(mapped.to_string()); + } + } + buttons +} + +fn signed_axis_to_i16(value: f32) -> i16 { + let clamped = value.clamp(-1.0, 1.0); + if clamped < 0.0 { + (clamped * 32768.0).round() as i16 + } else { + (clamped * 32767.0).round() as i16 + } +} + +fn trigger_from_axis(value: Option, device_kind: &str) -> u8 { + let Some(value) = value else { + return 0; + }; + if device_kind == "xinput" || device_kind == "xinput-controller" { + return (value.clamp(0.0, 1.0) * 255.0).round() as u8; + } + (((value.clamp(-1.0, 1.0) + 1.0) * 0.5) * 255.0).round() as u8 +} + +impl Options { + fn parse(args: impl Iterator) -> Result { + let mut options = Options { + store_path: PathBuf::from("C:/Meta/Odin/state/starfire.muninn.telemetry.cc"), + discovery_store_path: None, + mapping_path: None, + device_filter: None, + host_id: "starfire".to_string(), + interval_ms: 8, + odin_cultmesh_uri: Some("cultmesh://odin/rendezvous/provider-catalog".to_string()), + rudp_bind: None, + command_route: SLEIPNIR_COMMAND_ROUTE.to_string(), + idunn_rudp_health: None, + once: false, + dry_run: false, + trace: env::var("SLEIPNIR_TRACE") + .ok() + .is_some_and(|value| value != "0" && !value.eq_ignore_ascii_case("false")), + }; + let mut idunn_rudp_health_endpoint = None; + let mut idunn_daemon_id = None; + let mut idunn_health_contract = None; + let mut args = args.peekable(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--store" => options.store_path = PathBuf::from(next_value(&mut args, "--store")?), + "--discovery-store" => { + options.discovery_store_path = + Some(PathBuf::from(next_value(&mut args, "--discovery-store")?)) + } + "--mapping" | "--mapping-path" => { + options.mapping_path = Some(PathBuf::from(next_value(&mut args, &arg)?)); + } + "--device" => options.device_filter = Some(next_value(&mut args, "--device")?), + "--host" => options.host_id = next_value(&mut args, "--host")?, + "--interval-ms" => { + options.interval_ms = next_value(&mut args, "--interval-ms")? + .parse() + .context("--interval-ms must be an integer")? + } + "--rudp-bind" | "--udp-bind" => { + options.rudp_bind = Some( + next_value(&mut args, &arg)? + .parse() + .context("--rudp-bind must be a socket address")?, + ) + } + "--command-rudp-bind" => { + let _ = next_value(&mut args, "--command-rudp-bind")?; + return Err(anyhow!( + "--command-rudp-bind has been removed; Sleipnir reads input mapping commands from Odin/CultMesh discovery" + )); + } + "--command-route" => { + options.command_route = next_value(&mut args, "--command-route")? + } + "--command-rudp-advertise" => { + return Err(anyhow!( + "--command-rudp-advertise has been removed; use --command-route with a cultmesh:// Odin command route" + )); + } + "--odin-cultmesh-rudp" => { + let _ = next_value(&mut args, "--odin-cultmesh-rudp")?; + return Err(anyhow!( + "--odin-cultmesh-rudp has been removed; use --odin-cultmesh-uri cultmesh://odin/rendezvous/provider-catalog and let CultMesh resolve Odin's transport" + )); + } + "--odin-cultmesh-uri" => { + options.odin_cultmesh_uri = Some(next_value(&mut args, "--odin-cultmesh-uri")?) + } + "--idunn-rudp-health" => { + idunn_rudp_health_endpoint = Some( + next_value(&mut args, "--idunn-rudp-health")? + .parse() + .context("--idunn-rudp-health must be a socket address")?, + ) + } + "--idunn-daemon" => { + idunn_daemon_id = Some(next_value(&mut args, "--idunn-daemon")?) + } + "--idunn-health-contract" => { + idunn_health_contract = Some(next_value(&mut args, "--idunn-health-contract")?) + } + "--once" => options.once = true, + "--dry-run" => options.dry_run = true, + "--trace" => options.trace = true, + "--help" | "-h" => { + println!("{}", usage()); + std::process::exit(0); + } + other => return Err(anyhow!("unrecognized argument {other}\n{}", usage())), + } + } + options.idunn_rudp_health = match ( + idunn_rudp_health_endpoint, + idunn_daemon_id, + idunn_health_contract, + ) { + (None, None, None) => None, + (Some(endpoint), Some(daemon_id), Some(health_contract)) => { + Some(IdunnRudpHealthOptions { + endpoint, + daemon_id, + health_contract, + }) + } + _ => { + return Err(anyhow!( + "--idunn-rudp-health, --idunn-daemon, and --idunn-health-contract must be provided together" + )); + } + }; + Ok(options) + } +} + +fn next_value(args: &mut impl Iterator, flag: &str) -> Result { + args.next() + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| anyhow!("{flag} requires a value")) +} + +fn usage() -> &'static str { + "usage: sleipnir [--store PATH] [--discovery-store PATH] [--mapping-path LEGACY_PATH] [--host HOST] [--device DEVICE_OR_KIND] [--odin-cultmesh-uri cultmesh://...] [--rudp-bind ADDR] [--command-route cultmesh://...] [--idunn-rudp-health ADDR --idunn-daemon ID --idunn-health-contract CONTRACT] [--interval-ms N] [--trace] [--dry-run] [--once]" +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::fs; + + fn record(axes: Vec, buttons: Vec<&str>, sequence: u64) -> MuninnHidControllerStateRecord { + MuninnHidControllerStateRecord { + stream_id: "nightwing:nav:hid-controller-state".to_string(), + host_id: "nightwing".to_string(), + device_id: "nav".to_string(), + device_kind: "ps3-navigation".to_string(), + sequence, + source_timestamp_ns: 0, + axes, + buttons: buttons.into_iter().map(String::from).collect(), + battery01: f32::NAN, + observed_at: "unix:0".to_string(), + source_path: "/dev/input/js0".to_string(), + } + } + + fn default_mapping() -> SleipnirDesiredMapping { + SleipnirDesiredMapping { + enabled: false, + device_filter: None, + stream_id: None, + presentation: default_presentation(), + axis_map: default_axis_map(), + button_map: default_button_map(), + pending_learn: None, + } + } + + fn test_node(name: &str) -> (cultmesh_rs::CultMeshNode, PathBuf) { + let store_path = + std::env::temp_dir().join(format!("sleipnir-{name}-{}.cc", unix_millis_i64())); + let node = CultMesh::create_node( + &store_path, + OdinDocuments, + CultMeshNodeOptions { + runtime_id: format!("sleipnir-test-{name}"), + pull_on_start: true, + }, + ) + .unwrap(); + (node, store_path) + } + + #[test] + fn coalesces_latest_hid_records_per_device() { + let mut old_nav = record(vec![1.0, 0.0, -1.0], Vec::new(), 1); + old_nav.device_id = "nav".to_string(); + old_nav.source_timestamp_ns = 1_000; + let mut xbox = record(vec![0.25, 0.0, -1.0], Vec::new(), 7); + xbox.device_id = "xbox".to_string(); + xbox.source_timestamp_ns = 1_500; + let mut latest_nav = record(vec![0.0, 0.0, -1.0], Vec::new(), 2); + latest_nav.device_id = "nav".to_string(); + latest_nav.source_timestamp_ns = 2_000; + + let records = coalesce_latest_hid_records_by_device(vec![old_nav, xbox, latest_nav]); + + assert_eq!(records.len(), 2); + assert_eq!(records[0].device_id, "xbox"); + assert_eq!(records[0].sequence, 7); + assert_eq!(records[1].device_id, "nav"); + assert_eq!(records[1].sequence, 2); + assert_eq!(records[1].axes[0], 0.0); + } + + #[test] + fn coalesces_by_source_timestamp_not_arrival_order() { + let mut newer_nav = record(vec![0.0, 1.0, -1.0], Vec::new(), 10); + newer_nav.device_id = "nav".to_string(); + newer_nav.source_timestamp_ns = 2_000; + let mut older_nav = record(vec![-1.0, 0.0, -1.0], Vec::new(), 9); + older_nav.device_id = "nav".to_string(); + older_nav.source_timestamp_ns = 1_000; + + let records = coalesce_latest_hid_records_by_device(vec![newer_nav, older_nav]); + + assert_eq!(records.len(), 1); + assert_eq!(records[0].sequence, 10); + assert_eq!(records[0].axes[1], 1.0); + } + + #[test] + fn maps_nav_stick_and_buttons_to_xinput_shape() { + let state = map_record_to_virtual_pad( + &record( + vec![1.0, -1.0, -1.0], + vec!["cross", "circle", "l1", "l3", "up"], + 7, + ), + &default_mapping(), + ); + + assert_eq!(state.left_x, 32767); + assert_eq!(state.left_y, 32767); + assert_eq!(state.left_trigger, 0); + assert_eq!(state.right_trigger, 0); + assert!(state.buttons.contains(&"cross".to_string())); + assert!(state.buttons.contains(&"circle".to_string())); + assert!(state.buttons.contains(&"l1".to_string())); + assert!(state.buttons.contains(&"l3".to_string())); + assert!(state.buttons.contains(&"up".to_string())); + } + + #[test] + fn remaps_buttons_and_axes_from_mapping() { + let mut mapping = default_mapping(); + mapping + .button_map + .insert("cross".to_string(), "b".to_string()); + mapping.axis_map.insert( + "leftX".to_string(), + AxisBinding { + source: 1, + invert: false, + scale: 0.5, + deadzone: 0.0, + }, + ); + + let state = + map_record_to_virtual_pad(&record(vec![1.0, 1.0, -1.0], vec!["cross"], 7), &mapping); + + assert_eq!(state.left_x, 16384); + assert!(state.buttons.contains(&"b".to_string())); + assert!(!state.buttons.contains(&"cross".to_string())); + } + + #[test] + fn default_mapping_deadzones_small_stick_noise() { + let state = map_record_to_virtual_pad( + &record(vec![0.05, -0.04, -1.0], Vec::new(), 7), + &default_mapping(), + ); + + assert_eq!(state.left_x, 0); + assert_eq!(state.left_y, 0); + assert_eq!(state.left_trigger, 0); + assert_eq!(state.right_trigger, 0); + assert!(state.buttons.is_empty()); + } + + #[test] + fn pending_learn_maps_next_active_input() { + let mut mapping = default_mapping(); + mapping.pending_learn = Some(LearnRequest { + target: "a".to_string(), + binding_kind: "button".to_string(), + requested_at: None, + }); + let (mut node, store_path) = test_node("learn"); + let options = Options::parse( + ["--store", store_path.to_str().unwrap(), "--dry-run"] + .into_iter() + .map(String::from), + ) + .unwrap(); + let updated = apply_pending_learn( + &mut node, + &options, + &mapping, + &record(vec![0.0, 0.0], vec!["cross"], 2), + ) + .unwrap() + .unwrap(); + assert_eq!( + updated.button_map.get("cross").map(String::as_str), + Some("a") + ); + assert!(updated.pending_learn.is_none()); + let persisted = read_desired_mapping(&mut node, &options); + assert_eq!( + persisted.button_map.get("cross").map(String::as_str), + Some("a") + ); + let _ = fs::remove_file(store_path); + } + + #[test] + fn runtime_state_prefers_recent_frames_over_handshake_churn() { + let (node, store_path) = test_node("runtime-fresh-frame"); + let options = Options::parse( + ["--store", store_path.to_str().unwrap(), "--dry-run"] + .into_iter() + .map(String::from), + ) + .unwrap(); + let mut mapping = default_mapping(); + mapping.enabled = true; + let stream = create_rudp_stream(None, Some("127.0.0.1:9".to_string()), false).unwrap(); + + let state = SleipnirRuntimeState::from_runtime( + &options, + Some("nav"), + &node, + stream.as_ref(), + None, + 0, + &mapping, + None, + ); + + assert_eq!(state.stream_state, "connected"); + let _ = fs::remove_file(store_path); + } + + #[test] + fn surface_exposes_input_latency_and_axis_classification() { + let mapping = default_mapping(); + let state = SleipnirRuntimeState { + provider_id: "sleipnir.input-mirror.test".to_string(), + selected_muninn_endpoint: Some("127.0.0.1:17888".to_string()), + selected_device_filter: Some("nav".to_string()), + presentation: "xbox360".to_string(), + virtual_backend: "logging.dry-run".to_string(), + stream_state: "connected".to_string(), + last_device_id: Some("nav".to_string()), + last_device_kind: Some("ps3-navigation".to_string()), + last_sequence: Some(42), + last_frame_age_ms: Some(3), + last_input_latency: Some(SleipnirInputLatencySnapshot { + device_id: "nav".to_string(), + sequence: 42, + source_to_arrival_ms: Some(2), + arrival_to_buffer_ms: 3, + buffer_to_axis_ms: 1, + axis_to_hid_ms: 1, + source_to_hid_ms: Some(7), + total_observed_ms: 5, + axis_summary: vec!["leftX<-axis0 value=0.500".to_string()], + emitted: true, + }), + ignored_stream_frames: 0, + available_devices: Vec::new(), + axis_map: mapping.axis_map, + button_map: mapping.button_map, + pending_learn: None, + updated_at: "unix-1".to_string(), + }; + + let surface = serde_json::to_string(&sleipnir_surface_document(&state)).unwrap(); + + assert!(surface.contains("Input Latency")); + assert!(surface.contains("arrival to buffer: 3 ms")); + assert!(surface.contains("buffer to axis classification: 1 ms")); + assert!(surface.contains("axis to HID emission: 1 ms")); + assert!(surface.contains("mapped axes: leftX<-axis0 value=0.500")); + } + + #[test] + fn available_hid_devices_ignore_move_evidence_streams() { + let provider = json!({ + "verseId": "starfire.local", + "inputStreams": [ + { + "streamId": "muninn:nightwing:move-evidence", + "schema": "muninn.hid_controller_state.v1", + "transport": "cultnet.transport.rudp.v0", + "address": "198.51.100.75:17888", + "devices": [ + { + "deviceId": "nav-ghost", + "deviceKind": "ps3-navigation", + "sourcePath": "/dev/input/js0" + } + ] + }, + { + "streamId": "muninn:starfire:hid-controller-state", + "schema": "muninn.hid_controller_state.v1", + "transport": "cultnet.transport.rudp.v0", + "address": "198.51.100.66:17888", + "devices": [ + { + "deviceId": "nav-windows-psnav-0", + "deviceKind": "ps3-navigation", + "sourcePath": "windows-psmove://nav" + } + ] + } + ] + }); + + let devices = available_hid_devices_from_value(&provider); + + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].device_id, "nav-windows-psnav-0"); + assert_eq!(devices[0].host_id, "starfire"); + assert_eq!(devices[0].endpoint, "198.51.100.66:17888"); + } + + #[test] + fn discovers_hid_endpoint_from_odin_provider_routes() { + let (mut node, store_path) = test_node("hid-endpoint-routes"); + node.put( + "muninn.telemetry.starfire", + &EveProviderAdvertisementRecord { + value: json!({ + "providerId": "muninn.telemetry.starfire", + "endpoints": [ + { + "address": "C:\\Meta\\Odin\\state\\starfire.muninn.telemetry.cc", + "transport": "cultcache-store" + }, + { + "address": "198.51.100.66:17888", + "channel": "latest", + "connectionId": 1836384261, + "role": "muninn.hid_controller_state", + "schema": "muninn.hid_controller_state.v1", + "transport": "cultnet.transport.rudp.v0" + } + ], + "routes": [ + { + "address": "C:\\Meta\\Odin\\state\\starfire.muninn.telemetry.cc", + "transport": "cultcache-store" + } + ] + }), + }, + ) + .unwrap(); + + assert_eq!( + discover_muninn_hid_endpoint( + &node, + Some("nav-windows-psnav-0"), + Some("starfire:nav-windows-psnav-0:hid-controller-state"), + false, + ) + .as_deref(), + Some("198.51.100.66:17888") + ); + let _ = fs::remove_file(store_path); + } + + #[test] + fn discovers_available_hid_devices_from_typed_muninn_state_records() { + let (mut node, store_path) = test_node("hid-record-discovery"); + node.put( + "muninn.telemetry.starfire", + &EveProviderAdvertisementRecord { + value: json!({ + "providerId": "muninn.telemetry.starfire", + "endpoints": [{ + "address": "198.51.100.66:17888", + "schema": "muninn.hid_controller_state.v1", + "transport": "cultnet.transport.rudp.v0" + }] + }), + }, + ) + .unwrap(); + node.put( + "starfire:nav-windows-psnav-0:hid-controller-state", + &MuninnHidControllerStateRecord { + stream_id: "starfire:nav-windows-psnav-0:hid-controller-state".to_string(), + host_id: "starfire".to_string(), + device_id: "nav-windows-psnav-0".to_string(), + device_kind: "ps3-navigation".to_string(), + sequence: 1, + source_timestamp_ns: 0, + axes: vec![0.0, 0.0, 0.0], + buttons: Vec::new(), + battery01: f32::NAN, + observed_at: "unix:0".to_string(), + source_path: "windows-psmove://nav".to_string(), + }, + ) + .unwrap(); + + let devices = discover_available_hid_devices(&node); + + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].device_id, "nav-windows-psnav-0"); + assert_eq!(devices[0].host_id, "starfire"); + assert_eq!( + devices[0].stream_id, + "starfire:nav-windows-psnav-0:hid-controller-state" + ); + assert_eq!(devices[0].endpoint, "198.51.100.66:17888"); + let _ = fs::remove_file(store_path); + } + + #[test] + fn normalizes_tuple_shaped_hid_state_payloads_for_discovery() { + let (mut node, store_path) = test_node("hid-tuple-discovery"); + node.put( + "muninn.telemetry.raven", + &EveProviderAdvertisementRecord { + value: json!({ + "providerId": "muninn.telemetry.raven", + "endpoints": [{ + "address": "198.51.100.84:17888", + "schema": "muninn.hid_controller_state.v1", + "transport": "cultnet.transport.rudp.v0" + }] + }), + }, + ) + .unwrap(); + let device = available_hid_device_from_hid_value( + &node, + &json!([ + "raven:xbox-raven:hid-controller-state", + "raven", + "xbox-raven", + "xbox-controller", + 42, + 0, + [], + [], + null, + "unix:0", + "xinput://raven/xbox" + ]), + ) + .unwrap(); + + assert_eq!(device.device_id, "xbox-raven"); + assert_eq!(device.host_id, "raven"); + assert_eq!(device.endpoint, "198.51.100.84:17888"); + let _ = fs::remove_file(store_path); + } + + #[test] + fn available_hid_device_upsert_deduplicates_endpoint_variants() { + let mut devices = Vec::new(); + upsert_available_hid_device( + &mut devices, + AvailableHidDevice { + device_id: "nav-windows-psnav-0".to_string(), + device_kind: "ps3-navigation".to_string(), + source_path: "windows-psmove://nav".to_string(), + stream_id: "starfire:nav-windows-psnav-0:hid-controller-state".to_string(), + host_id: "starfire".to_string(), + endpoint: "discovering".to_string(), + }, + ); + upsert_available_hid_device( + &mut devices, + AvailableHidDevice { + device_id: "nav-windows-psnav-0".to_string(), + device_kind: "ps3-navigation".to_string(), + source_path: "windows-psmove://nav".to_string(), + stream_id: "starfire:nav-windows-psnav-0:hid-controller-state".to_string(), + host_id: "starfire".to_string(), + endpoint: "198.51.100.66:17888".to_string(), + }, + ); + upsert_available_hid_device( + &mut devices, + AvailableHidDevice { + device_id: "nav-windows-psnav-0".to_string(), + device_kind: "ps3-navigation".to_string(), + source_path: "windows-psmove://nav".to_string(), + stream_id: "muninn:starfire:hid-controller-state".to_string(), + host_id: "starfire".to_string(), + endpoint: "198.51.100.66:17888".to_string(), + }, + ); + + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].endpoint, "198.51.100.66:17888"); + assert_eq!( + devices[0].stream_id, + "starfire:nav-windows-psnav-0:hid-controller-state" + ); + } + + #[test] + fn endpoint_discovery_does_not_borrow_other_hosts_from_surfaces() { + let (mut node, store_path) = test_node("hid-no-surface-leak"); + node.put( + "sleipnir.input-mirror.raven", + &EveSurfaceStateRecord { + provider_id: "sleipnir.input-mirror.raven".to_string(), + title: "Sleipnir Raven".to_string(), + version: 1, + updated_at: "unix:0".to_string(), + surface: json!({ + "id": "sleipnir.input-mirror.raven.surface", + "root": { + "kind": "dashboard", + "children": [{ + "kind": "text", + "props": { + "text": "raven sees nightwing endpoint 198.51.100.75:17888" + } + }] + } + }), + }, + ) + .unwrap(); + + assert_eq!( + discover_muninn_hid_endpoint( + &node, + Some("xbox-raven"), + Some("raven:xbox-raven:hid-controller-state"), + false, + ), + None + ); + let _ = fs::remove_file(store_path); + } + + #[test] + fn stream_id_host_hint_filters_provider_routes() { + let (mut node, store_path) = test_node("hid-host-hint"); + node.put( + "muninn.telemetry.nightwing", + &EveProviderAdvertisementRecord { + value: json!({ + "providerId": "muninn.telemetry.nightwing", + "verseId": "nightwing.local", + "endpoints": [{ + "address": "198.51.100.75:17888", + "schema": "muninn.hid_controller_state.v1", + "transport": "cultnet.transport.rudp.v0" + }] + }), + }, + ) + .unwrap(); + node.put( + "muninn.telemetry.starfire", + &EveProviderAdvertisementRecord { + value: json!({ + "providerId": "muninn.telemetry.starfire", + "verseId": "starfire.local", + "endpoints": [{ + "address": "198.51.100.66:17888", + "schema": "muninn.hid_controller_state.v1", + "transport": "cultnet.transport.rudp.v0" + }] + }), + }, + ) + .unwrap(); + let hint = stream_host_hint(Some("starfire:nav-windows-psnav-0:hid-controller-state")); + + assert_eq!(hint.as_deref(), Some("starfire")); + assert_eq!( + discover_muninn_hid_endpoint( + &node, + Some("nav-windows-psnav-0"), + Some("starfire:nav-windows-psnav-0:hid-controller-state"), + false, + ) + .as_deref(), + Some("198.51.100.66:17888") + ); + let _ = fs::remove_file(store_path); + } + + #[test] + fn selected_provider_device_resolves_input_stream_endpoint() { + let (mut node, store_path) = test_node("hid-provider-device-endpoint"); + node.put( + "muninn.telemetry.starfire", + &EveProviderAdvertisementRecord { + value: json!({ + "providerId": "muninn.telemetry.starfire", + "verseId": "starfire.local", + "inputStreams": [{ + "address": "198.51.100.66:17888", + "channel": "latest", + "connectionId": 1836384261, + "schema": "muninn.hid_controller_state.v1", + "streamId": "muninn:starfire:hid-controller-state", + "transport": "cultnet.transport.rudp.v0", + "devices": [{ + "deviceId": "nav-windows-psnav-0", + "deviceKind": "ps3-navigation", + "sourcePath": "windows-psmove://nav" + }] + }] + }), + }, + ) + .unwrap(); + + assert_eq!( + discover_muninn_hid_endpoint( + &node, + Some("nav-windows-psnav-0"), + Some("muninn:starfire:hid-controller-state"), + false, + ) + .as_deref(), + Some("198.51.100.66:17888") + ); + let _ = fs::remove_file(store_path); + } + + #[test] + fn desired_mapping_ignores_transport_endpoint_shortcuts() { + let (mut node, store_path) = test_node("mapping"); + let options = Options::parse( + ["--store", store_path.to_str().unwrap()] + .into_iter() + .map(String::from), + ) + .unwrap(); + node.put( + &sleipnir_provider_id(&options), + &SleipnirInputMappingRecord { + provider_id: sleipnir_provider_id(&options), + enabled: true, + device_filter: "nav-windows-psnav-0".to_string(), + stream_id: String::new(), + presentation: "xbox360".to_string(), + axis_map: serde_json::Value::Null, + button_map: serde_json::Value::Null, + pending_learn: json!({ + "muninnRudp": "198.51.100.66:17888" + }), + updated_at: timestamp(), + source: "test".to_string(), + }, + ) + .unwrap(); + + let mapping = read_desired_mapping(&mut node, &options); + + assert_eq!( + mapping.device_filter.as_deref(), + Some("nav-windows-psnav-0") + ); + assert_eq!(mapping.stream_id, None); + let _ = fs::remove_file(store_path); + } + + #[test] + fn desired_mapping_reads_newest_odin_discovery_mapping() { + let (mut local_node, local_store_path) = test_node("mapping-local"); + let (mut discovery_node, discovery_store_path) = test_node("mapping-discovery"); + let options = Options::parse( + ["--store", local_store_path.to_str().unwrap()] + .into_iter() + .map(String::from), + ) + .unwrap(); + let provider_id = sleipnir_provider_id(&options); + local_node + .put( + &provider_id, + &SleipnirInputMappingRecord { + provider_id: provider_id.clone(), + enabled: true, + device_filter: "local-old".to_string(), + stream_id: String::new(), + presentation: "xbox360".to_string(), + axis_map: serde_json::Value::Null, + button_map: serde_json::Value::Null, + pending_learn: serde_json::Value::Null, + updated_at: "2026-07-04T00:00:00.000Z".to_string(), + source: "local".to_string(), + }, + ) + .unwrap(); + discovery_node + .put( + &provider_id, + &SleipnirInputMappingRecord { + provider_id: provider_id.clone(), + enabled: true, + device_filter: "odin-new".to_string(), + stream_id: String::new(), + presentation: "xbox360".to_string(), + axis_map: serde_json::Value::Null, + button_map: serde_json::Value::Null, + pending_learn: serde_json::Value::Null, + updated_at: "2026-07-04T00:00:01.000Z".to_string(), + source: "odin".to_string(), + }, + ) + .unwrap(); + + let mapping = + read_desired_mapping_from_sources(&mut local_node, Some(&mut discovery_node), &options); + + assert_eq!(mapping.device_filter.as_deref(), Some("odin-new")); + let _ = fs::remove_file(local_store_path); + let _ = fs::remove_file(discovery_store_path); + } + + #[test] + fn rejects_removed_command_rudp_bind() { + let error = Options::parse( + ["--command-rudp-bind", "127.0.0.1:17889"] + .into_iter() + .map(String::from), + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("--command-rudp-bind has been removed")); + } + + #[test] + fn accepts_odin_cultmesh_uri() { + let options = Options::parse( + [ + "--odin-cultmesh-uri", + "cultmesh://odin/rendezvous/provider-catalog", + ] + .into_iter() + .map(String::from), + ) + .unwrap(); + + assert_eq!( + options.odin_cultmesh_uri.as_deref(), + Some("cultmesh://odin/rendezvous/provider-catalog") + ); + } + + #[test] + fn rejects_removed_odin_cultmesh_rudp() { + let error = Options::parse( + ["--odin-cultmesh-rudp", "127.0.0.1:17871"] + .into_iter() + .map(String::from), + ) + .unwrap_err() + .to_string(); + + assert!(error.contains("--odin-cultmesh-rudp has been removed")); + } + + #[cfg(windows)] + #[test] + fn xbox_face_button_names_stay_distinct() { + assert_eq!(xbutton_mask("a"), vigem_client::XButtons::A); + assert_eq!(xbutton_mask("b"), vigem_client::XButtons::B); + assert_eq!(xbutton_mask("x"), vigem_client::XButtons::X); + assert_eq!(xbutton_mask("y"), vigem_client::XButtons::Y); + assert_eq!(xbutton_mask("cross"), vigem_client::XButtons::A); + assert_eq!(xbutton_mask("square"), vigem_client::XButtons::X); + } +} diff --git a/deployment/idunn/raven-muninn.toml b/deployment/idunn/raven-muninn.toml index 28c903e..c440494 100644 --- a/deployment/idunn/raven-muninn.toml +++ b/deployment/idunn/raven-muninn.toml @@ -94,3 +94,14 @@ contract = "muninn.runtime-health.v1" capability = "muninn.media-producer" schema = "gamecult.media_stream_advertisement.v1" compatibility = "v1" + +# raven-muninn publishes to Odin over CultMesh RUDP (--odin-cultmesh-uri), and +# it has no route, so it declares the Odin dependency: Idunn admits it as Ready +# on Odin's correlation. A recipe that declares neither a route nor this is +# refused at admission. +[[dependencies]] +kind = "shared-infrastructure" +capability = "odin.verse-rendezvous" +schema = "odin.verse-topology.v1" +compatibility = "v1" +startup = "before-promotion" diff --git a/docs/realtime-robustness-cut.md b/docs/realtime-robustness-cut.md index a18d713..24816fb 100644 --- a/docs/realtime-robustness-cut.md +++ b/docs/realtime-robustness-cut.md @@ -11,10 +11,10 @@ adaptation, and a loss-tolerant HID edge for remote play. | | | |---|---| -| Map state | Imagination pass 2. Q1-Q8 were ruled on 2026-09-30 (below). Pass 2 maps Cut 6 in full (Q6 = a) and the HID cuts H0-H5 (Q1 = remote play). Two new forks are open: Q9 (Sleipnir's home) and Q10 (HID connection direction). | +| Map state | Imagination pass 3. Q1-Q10 are ruled (below). Pass 2 mapped Cut 6 in full (Q6 = a); pass 3 re-maps the HID cuts H0-H5 to Q9 (Sleipnir is a crate in Muninn's workspace) and Q10 (Muninn advertises an input-stream capability; the consumer requests it and dials). Three new forks are open: Q11 (who owns the input-stream records), Q12 (Move evidence rides the HID listener), Q13 (whether Raven's Sleipnir is live, which gates the H2/H4 merge). | | In Hands | Cuts 0-1 on `hands/rr-cut0-1` (`2c120c9`, `f2a5db9`, `a189363`). This pass does not touch those sections. | -| Ready for Hands | Cut 2 (the latency default is settled by Q7); Cut 3 (Q2-Q4, Q8 settled); H1 (after H0); Cut 6a (after Cut 2) | -| Blocked | Cut 4 (on Cut 3's tag); Cut 5 (on the pin gate); Cut 6b (on Cut 2 and Cut 5); Cut 6c (on Idunn B3 and `route/b3-declare` merging, and on FFmpeg provisioning on Raven); H3-H5 (on Q9, Q10) | +| Ready for Hands | Cut 2 (the latency default is settled by Q7); Cut 3 (Q2-Q4, Q8 settled); H1 (after H0, and after Q11); H3 (any time; behaviour-neutral); Cut 6a (after Cut 2) | +| Blocked | Cut 4 (on Cut 3's tag); Cut 5 (on the pin gate); Cut 6b (on Cut 2 and Cut 5); Cut 6c (on Idunn B3 and `route/b3-declare` merging, and on FFmpeg provisioning on Raven); H2 (on Q12); the H2+H4 merge to `main` (on Q13 and H5 readiness, because `raven-muninn` deploys `main`'s head); H5 (on Idunn B3) | | Heads read | Muninn `origin/main` `eb332b0` (code identical to `cee7b9c`), `route/b3-declare` `2260853`; CultLib `069ecc3`; Ratatoskr `488b5b1`; Odin `main` `379b826`; donor Odin `80adfd3`; Odin tag `attic/claude-cultlib-pin-c2a9a6e` `3e96c6c`; Idunn `771138a`; gamecult-ops working tree (binding `idunn/yggdrasil/bindings/raven-muninn.toml.in`, `scripts/idunn/idunn-deployment-targets.ps1`) | The CultLib media plane is byte-identical between Muninn's pin `c2a9a6e` and @@ -65,8 +65,19 @@ the ruled design. The earlier options survive only in git (`e70db69`). viewer-dials recommendation. Operator: "Ratatoskr is asking Muninn to open a video stream, Sleipnir asks Muninn to open an input stream. Both should be Muninn capabilities which it chooses to broadcast via CultMesh." So Muninn publishes an input-stream capability through CultMesh beside its media capability, - and Sleipnir requests it the way Ratatoskr requests video. The HID cuts H0-H5 need re-mapping to this - shape before Hands. + and Sleipnir requests it the way Ratatoskr requests video. Both consumers dial the producing Muninn. + Pass 3 re-mapped H0-H5 to this shape. + +**Rulings, 2026-09-30 (operator), pass 3 forks:** +- **Q11 (a): the input-stream records live in `muninn-contracts` as `muninn.input_stream_*`.** The input track + needs no CultLib change. Promote them to CultLib if a second producer appears. +- **Q12 (a): Mimir's Move proof is in use.** Move evidence becomes a channel on the input-stream session, and a + small Mimir cut switches over. H2 waits for that Mimir cut. +- **Q13 (c): no live Sleipnir, so accept the gap.** H2 and H4 may merge independently. The operator said + (2026-09-30): "If Sleipnir is running, feel free to take it down; we're certainly not using it". + - Checked read-only on Raven the same day: no Sleipnir process is running. + - Only an on-demand scheduled task, `\Codex-Sleipnir-LAN-Proof`, remains. It last ran on 2026-07-17. + - Raven runs `muninn.exe` alone. ## Target: ends and invariants @@ -130,16 +141,22 @@ Invariants. Each one must be falsifiable by a test or a probe. acknowledged. No reliable channel carries input, so there is no retransmit debt and no head-of-line blocking. 12. **Input memory is bounded on both ends.** - - The sender keeps at most 256 unacknowledged edges per source, and - rotates the epoch on overflow (donor rule, `D main.rs:5846-5854`). - - A frame carries at most 32. + - The sender keeps at most 256 unacknowledged edges **per (subscriber + session, device) window**, and rotates that window's epoch on overflow + (donor rule, `D main.rs:5846-5854`). One lossy subscriber never forces + another to rebaseline. + - A frame carries at most `MAX_EDGES_PER_FRAME` (32, or less if H1's + worst-case encode test says 32 does not fit one datagram). - The receiver buffers nothing beyond the window it was sent. - Stale epochs are fenced by monotonic comparison, not by a growing set (the donor's `retired_epochs: HashSet`, `DS main.rs:210`, grows forever). -13. **Input latency is bounded.** A queued edge is held visible for 24 ms - (`DS main.rs:32-35`, one 60 Hz game sample plus margin) **unless** it is - older than the input budget (100 ms default). Then it is actuated without - the hold. A tap is compressed, never dropped. +13. **Input latency is bounded, and the bound is owned by the request.** A + queued edge is held visible for 24 ms (`DS main.rs:32-35`, one 60 Hz game + sample plus margin) **unless** it is older than `input_budget_ms`. Then it + is actuated without the hold. A tap is compressed, never dropped. The + budget is Q7's shape applied to input: the advertisement carries the + default (100 ms), the request carries the value, and both ends derive from + the request. 14. **No JSON on a live wire.** Muninn↔Sleipnir today speaks JSON both ways (B14), and the donor did too (`D main.rs:6117,6141`, `DS main.rs:693-699`). Every HID and encoder-control message becomes a typed CultCache record in @@ -330,14 +347,20 @@ contract (`a250ec6` onward), and the consumer-dials-producer inversion. - **Sleipnir keeps only the latest record per device** (`S :2543-2590`), so a tap that survived capture dies again at the sink. -**B15. Muninn has two HID transport paths.** -- An ingress server that Sleipnir dials: `--hid-controller-rudp-bind`, - `M :6144-6356`. This is the one Raven runs (recipe `raven-muninn.toml:62-65`, - advertised `10.77.0.4:17887`). -- An outbound client that dials a target: `--hid-controller-rudp-target`, - `M :6717-6860`, `:10891-10895`. -- Both speak JSON on `latest`. Two paths for one signal is the split Q10 - settles. +**B15. Muninn has two HID transport paths, and so does Sleipnir.** +`M` here is Muninn `main` `c3520d1`; HID lines moved +7 to +9 since `eb332b0`. +- Muninn listens: `--hid-controller-rudp-bind`, `M :6151-6340`. This is the one + Raven runs (recipe `raven-muninn.toml:62-65`, advertised `10.77.0.4:17887`), + and Starfire's legacy script can run it too + (`restart-starfire-muninn.ps1:14-15, 74-79`). +- Muninn dials: `--hid-controller-rudp-target`, `M :6724-6863`, parsed at + `:10899-10904`. +- Sleipnir mirrors both: it dials a discovered endpoint, or listens on + `--rudp-bind` (`S :2191-2281`, server branch `:2244-2281`). +- **Q10 settles it:** the consumer dials the producer's advertised endpoint, + exactly as Ratatoskr dials Muninn for video. The surviving direction is + "Muninn listens, Sleipnir dials". Muninn's outbound client and Sleipnir's + listening mode are the losing paths; H2 and H4 delete them. **B16. Sleipnir is welded into Odin's pin.** - `S` is a member of Odin's workspace. It takes `odin-core` by path @@ -376,6 +399,68 @@ libraries. `deployment/idunn/raven-muninn.toml`. Cut 6c edits the same file and must build on it after it merges. +**B20. Move evidence rides the HID listener, and Mimir consumes it.** +- `serve` gets the Move-evidence sender back from + `start_hid_controller_rudp_ingress` (`M :429-437`), and the ingress thread + sends it on channel `move-evidence` over the HID connection id + (`M :6271-6283`). +- It sends only while an HID subscription is selected (`M :6258-6269`), so a + consumer that wants Move evidence must speak the JSON `hid.subscribe`. +- Mimir does exactly that: `MimirOdinMoveProofEvidenceRingProvider.cs:223` + (Mimir `d88f2e6`) sends `{"streamId": ...}` on `hid.subscribe`. It discovers + the endpoint from the JSON `inputStreams` in Muninn's Eve provider + advertisement (`M :3713-3722`), channel `move-evidence`. +- The local ring is switched on by the absence of the HID bind + (`local_ring_enabled: options.hid_controller_rudp_bind.is_none()`, + `M :4238`). +- So deleting the JSON subscription (H2) silently stops Move evidence reaching + Mimir. The pass-2 map did not know this second consumer existed. Q12. + +**B21. HID discovery is JSON scraped out of Eve advertisements.** +- Muninn writes its HID endpoint and devices as JSON into three places in + `publish_runtime_boundary_records`: the transport profile's `input_streams` + (`M :3674-3699`, key at `:3856`), the provider advertisement's + `inputStreams` (`M :3701-3727`, key at `:3885`) and its `endpoints` + (`M :3729-3750`), in two casings. +- Sleipnir recovers it by pulling `muninn.hid_controller_state.v1` records to + learn host ids (`S :978-1003`), then pulling those hosts' provider + advertisements (`S :950-975`), then parsing JSON in either casing and in + tuple shape (`S :1845-2131`, `:2370-2478`), with Odin's generic + `discover_provider_endpoints` as a fallback (`S :2380-2398`). +- The video capability has none of this: one typed advertisement, one typed + request (`R catalog.rs:64-145`, `M :971-1166`). That typed path is what Q10 + orders input to mirror. +- The Odin-facing `publish_hid_controller_state_to_odin` (`M :6865-6875`, a + store `put` keyed by stream id) has **one reader**: Sleipnir's discovery. + Nothing else in Odin, Mimir, Ratatoskr, StreamPixels, Eve, VoidBot, Heimdall + or Idunn names the schema (`git grep`, 2026-09-30). + +**B22. Two readers own the same HID devices.** +- The serve tick reads every source to build receipts + (`publish_move_controller_states`, `M :4976-5134`). +- The ingress thread reads the same sources again at its own tick + (`read_hid_controller_state_for_stream`, `M :6377-6528`). +- The collision is fenced, not removed: on Windows the tick skips PS Move + sources whenever the HID bind is set (`M :5081-5086`), because the report + reader thread would otherwise have two consumers. +- Neither reader samples fast enough for a tap. The ingress loop sleeps 8 ms + (`M :6338`), and Sleipnir's default interval is 8 ms (`S :2791`). The + pass-2 map's "2 ms at the current poll (`HID_CONTROLLER_RUDP_POLL_INTERVAL`)" + is the **donor's** constant (`D main.rs:5879`). It does not exist at `c3520d1`. + +**B23. A producer restart orphans every running request.** +- `sync_media_stream_requests` admits only requests in state `pending` + (`M :1004`). The "already answered" memory is process-local + (`answered_requests`, `M :463`). +- So after a Muninn restart, every request it had answered `running` stays + `running` on Odin and is never admitted again. The consumer must notice and + publish a fresh request. +- Whether Ratatoskr re-requests is **unverified** (the redial lives in + `R receiver.rs`, and the request lives in the OBS plugin's C). +- H2 does not copy this. An input request is admitted as **demand**, by + `action`, and its `state` is only the producer's answer. The same change for + media belongs to Muninn's media owner and is recorded, not made, here. + --- ## Donor ledger: port, rewrite, drop @@ -392,7 +477,7 @@ on it after it merges. | `MuninnVideoBitrateController` (`D :2439-2512`) | **Rewrite** (Cut 6b) | Its ceiling halving assumes 50% FEC overhead, and its doc and code disagree. The rewrite keys the ceiling on the ruled parity rate and aggregates across hub receivers, which the donor predates. | | `native/muninn-video-encoder/main.cpp` (265 lines) | **Port** the capture and encode loop (Cut 6a). **Rewrite** its shell. | Kept: the lavfi → decode → encode loop, NVENC options, `pict_type = I` and `AVCodecContext` bitrate mutation. Deleted: `main`, `parse_options`, the `std::getline` text command thread, `fail()`/`exit`, and `fwrite` to stdout. They become a C ABI (`muninn_encoder_run` with poll and write callbacks) under a Rust `main` that decodes typed command records (invariant 15). | | `CMakeLists.txt`, `scripts/build-muninn-video-encoder.ps1` | **Drop** | `build.rs` with the `cc` crate compiles the C++. The Idunn runner may run only `cargo` (B18). The script's FFmpeg path no longer exists. | -| `capture_button_edges`, `ActiveHidControllerRudpSource` edge fields, overflow epoch rotation (`D main.rs:5732-5856`) | **Rewrite** into `muninn-contracts::hid::EdgeWindow` (H1). Capture calls it on the sampling thread (H2). | The rotation and bound rules are right. The donor ran them at transport-tick time, which loses sub-tick taps (B14), cloned `epoch` needlessly, and sent JSON. | +| `capture_button_edges`, `ActiveHidControllerRudpSource` edge fields, overflow epoch rotation (`D main.rs:5732-5856`) | **Rewrite** as two pieces in `muninn-contracts::hid` (H1): `EdgeDetector` (diffs samples; runs on the capture thread, H2) and `EdgeWindow` (per subscriber session and device; owns epoch, pending bound and acks; runs on the input transport tick, H2). | The rotation and bound rules are right. The donor ran detection at transport-tick time, which loses sub-tick taps (B14); it kept one window per source, which cannot serve two subscribers; it cloned `epoch` needlessly; and it sent JSON. | | Donor's `hid.edge` (reliable) + `hid.edge.assist` (unreliable resend) channels and the `hid.subscribe`/`hid.edge.ack` JSON | **Drop** | Two mechanisms for one job, plus four transport name arms (invariant 9). Replaced by one frame on the profile-declared `latest` channel that carries state and the unacknowledged window (invariant 11). | | `HidSemanticCursor` (`DS main.rs:204-256`) | **Port** into `muninn-contracts::hid::EdgeCursor` (H1), with the `retired_epochs` set **replaced** by a monotonic epoch fence | The ordering, dedupe and gap bound (256) are right. The unbounded set is not (invariant 12). | | `INPUT_EDGE_MINIMUM_VISIBILITY` 24 ms and the actuation queue (`DS main.rs:32-35, 655-700`) | **Port** into Sleipnir (H4), with the budget override added | Invariant 13. The donor queue has no latency ceiling. | @@ -461,11 +546,13 @@ What it does **not** own: | Keyframe request | `requested_keyframe` on feedback | Counted per receiver. It is an edge: at most one IDR per 500 ms cooldown per stream. | Receiver asserts. Muninn's gate decides (6b). | | Encoder command `muninn.video_encoder_command.v1` | `(encoder process, command_sequence)`. The sequence is monotonic from 1 per spawned encoder process. | Lives only on the stdin pipe. Never stored. It dies with the process; a respawn restarts the sequence at 1. | Muninn's `EncoderControl` writes. The encoder applies at the next frame. **Effect is observed from the bitstream**, not acknowledged. | | Live encode bitrate | Observed bytes per second over a 1 s window | Moves within `[floor, ceiling]` from the request's `video_bitrate_kbps` and the parity rate | Muninn's bitrate controller (6b) | -| HID epoch | `(device_id, epoch: u64)`. The epoch is the sender's monotonic start time in ns, and `+1` on overflow rotation. | New per Muninn process start and per overflow. The receiver fences any epoch below its current one. | Muninn (sender). Sleipnir only fences. | -| HID state sequence | `(device_id, epoch, state_sequence)` | Monotonic per epoch. Each frame supersedes the last (the `latest` channel is sequenced). | Muninn. | -| HID edge | `(device_id, epoch, edge_sequence)`, contiguous from 1 per epoch | Pending until acknowledged. Resent in every frame while pending, at most 32 per frame and 256 held. Overflow rotates the epoch and drops the history. | Muninn creates at sample time. Sleipnir applies in order and acknowledges cumulatively. | -| HID edge ack `muninn.hid_edge_ack.v1` | `(device_id, epoch, applied_through)` | Cumulative and idempotent. Resent each Sleipnir tick while nonzero. | Sleipnir. | -| HID subscription `muninn.hid_subscription.v1` | `(subscriber_id)` | Resent every 1 s (today's cadence, `S :2295-2302`). The newest wins. | Sleipnir. | +| Input-stream advertisement `muninn.input_stream_advertisement.v1` (Q11) | `stream_id` (`muninn..input`) | Republished on the same cadence and at the same site as the media advertisement. State `available` / `streaming` / `unavailable`. | Muninn (producer). | +| Input-stream request `muninn.input_stream_request.v1` (Q11) | `input_stream_request_key(stream_id, receiver_id)` | The consumer publishes `start` or `stop`. The producer admits it **as demand**, by `action`, not by `state` (B23), and writes its answer (`running` / `stopped` / `failed` with `detail`) onto the same key. A new request supersedes the old one under the key. | **Consumer** asks (Sleipnir); producer answers. | +| Input session | The hub session whose connect payload is the request's `receiver_id` (the Ratatoskr rule, `R receiver.rs:40-42`) | Lives while the consumer is attached. A session with no admitted request is served nothing. | Consumer dials; Muninn's input hub admits. | +| HID epoch | `(session, device_id, epoch: u64)`. The epoch is the window's monotonic creation time in ns, and `+1` on overflow rotation. | New per window (session attach, device change, process start) and per overflow. The receiver fences any epoch below its current one. | Muninn's `EdgeWindow`. Sleipnir only fences. | +| HID state sequence | `(session, device_id, epoch, state_sequence)` | Monotonic per epoch. Each frame supersedes the last (the `latest` channel is sequenced per session). | Muninn. | +| HID edge | `(session, device_id, epoch, edge_sequence)`, contiguous from 1 per epoch | Detected once, at sample time, on the capture thread. Pending per window until acknowledged. Resent in every frame while pending, at most `MAX_EDGES_PER_FRAME` per frame and 256 held. Overflow rotates that window's epoch and drops its history. | Muninn detects (capture) and delivers (window). Sleipnir applies in order and acknowledges cumulatively. | +| HID edge ack `muninn.hid_edge_ack.v1` | `(device_id, epoch, applied_through)` on the consumer's session | Cumulative and idempotent. Resent each Sleipnir tick while nonzero. | Sleipnir. | --- @@ -1159,30 +1246,75 @@ is a human-used workstation (teardown-map L268-271). --- -## HID edge for remote play: cuts H0-H5 (Q1 ruled) +## HID edge for remote play: cuts H0-H5 (Q1, Q9, Q10 ruled) **What it is for** (Q1, 2026-09-30): a viewer's controller drives a game on another host, over LAN or mesh, so quick taps must survive loss. -- The **source** is Muninn on the viewer's host, capturing the pad. -- The **sink** is Sleipnir on the game host, which drives ViGEm. -- Today that sink is `raven-sleipnir`, Raven being the game host (B16). +- The **producer** is Muninn on the viewer's host, capturing the pad. +- The **consumer** is Sleipnir on the game host, which drives ViGEm. +- Today the consumer is `raven-sleipnir`, Raven being the game host (B16). -**Ownership.** +**Anchors in this section.** +- `M` = Muninn `main` `c3520d1`. +- `S` = Sleipnir at Odin `main` `379b826`, `crates/sleipnir-daemon/src/main.rs`. + H3 moves that file verbatim, so after H3 the same line numbers hold at + `Muninn/crates/sleipnir-daemon/src/main.rs` until H4 edits it. +- `R` = Ratatoskr `488b5b1`. +- `C` = CultLib `c2a9a6e`, Muninn's pin. +- `Mi` = Mimir `d88f2e6`. +- `Ops` = gamecult-ops `8554e60`. + +### The capability mechanism, verified: video today, input mirrored + +Q10 orders input to be requested the way Ratatoskr requests video. This is +that request path, read at the SHAs above. + +| Step | Video today | Input after H2/H4 | +|---|---|---| +| Producer advertises through CultMesh | Muninn builds `gamecult.media_stream_advertisement.v1` (`M :1122-1166`). It stores it and publishes it to Odin's catalog under its `stream_id`, with role `media-stream-producer` (`M :4146-4166`). | Muninn builds `muninn.input_stream_advertisement.v1` in `input_stream.rs`, and publishes it at the same site with role `input-stream-producer`. | +| Advertised endpoint | `media_endpoint` is explicit (`--media-rudp-advertise`), or derived, **never guessed** (`advertised_media_endpoint`, `M :1173-1196`). `media_connection_id` is `MUNINN_MEDIA_RUDP_CONNECTION_ID`. | `input_endpoint` comes from the same function, parameterised (`advertised_endpoint(explicit, bind)`). `input_connection_id` is `0x6d75_0005`, today's HID id (`M :74`, `S :23`). | +| Recipe declares the capability | `[[provides]] capability = "muninn.media-producer"` (`raven-muninn.toml:93-96`) | `[[provides]] capability = "muninn.input-producer"`, schema `muninn.input_stream_advertisement.v1` | +| Consumer discovers | Ratatoskr pulls the advertisement schema from Odin (`R catalog.rs:64-91`) | Sleipnir pulls the input advertisement schema and its own mapping schema: one pull, typed, with no JSON scraping (B21). | +| Consumer asks | Ratatoskr publishes a request under `media_stream_request_key(stream, receiver)`, role `media-stream-consumer` (`R catalog.rs:95-122`). The request names the sources, and the latency budget defaults from the advertisement (`R :151-179`). | Sleipnir publishes `muninn.input_stream_request.v1` under `input_stream_request_key(stream, receiver)`, role `input-stream-consumer`. It names `device_id`, and `input_budget_ms` defaults from the advertisement. | +| Producer admits and answers | Muninn pulls request documents from Odin each tick (`M :971-1034`), turns them into `MuninnCaptureStreamCommandRecord`s, and writes the answer onto the request key (`M :1038-1117`). | The **same pull** takes both request schemas in one snapshot. Input requests go to the input organ as demand (B23), with **no** detour through a command record, and are answered on the same key. | +| Who dials | The consumer dials the advertised endpoint (`R receiver.rs:4-7`). Its connect payload is its `receiver_id` (`R receiver.rs:40-42`). Muninn listens on a `CultNetRudpServerHub` (`M :3192-3213`). | The consumer (Sleipnir, on the game host) dials the viewer's Muninn. Muninn listens on a `CultNetRudpServerHub` and serves a session only the device its request names. | + +**One rule for both streams:** the consumer dials the Muninn that produces the +stream, and that Muninn listens. For video, the producing Muninn is on the game +host. For input, it is on the viewer's machine, because the controller is +there. Every host whose Muninn produces a stream admits inbound UDP on that +stream's port. Raven already does this for video. H5 adds the same rule on each +viewer that produces input. + +**Two deliberate differences from the video path**, each protecting a named +invariant: +- **Admission as demand, not as `state == pending`** (B23). A restarted + producer resumes serving every live consumer without a re-request. The video + path should adopt this too, under its own owner. +- **No command-record detour.** `MuninnCaptureStreamCommandRecord` exists for + the media child's lifecycle and for legacy readers (teardown-map, item 2 of + "Next unblocked work"). Input has neither, so it does not grow a second + command vocabulary. + +### Ownership | Organ | Owns | Does not own | |---|---|---| -| Muninn (viewer host) | Sampling; edge detection at sample time; the epoch; the state sequence; the unacknowledged-edge window and its bound; frame emission; applying acks to its window; answering subscriptions | Mapping, actuation, or deciding that an edge was applied | -| `muninn-contracts` (Muninn repo, new lib, H0) | The HID wire records; their validation and CultNet envelope; the **protocol state machines**, `EdgeWindow` (sender) and `EdgeCursor` (receiver), as pure code | Sockets, threads, devices | -| Sleipnir (game host) | Subscription; the cursor instance; mapping; **the single pad-commit primitive** (invariant 10); the visibility hold and budget (invariant 13); acks; neutralising on staleness | Edge creation, epochs, capture | -| CultLib | The `latest` channel: Unreliable and Sequenced, profile-declared (`C rudp.rs:2189-2197, 2384-2390`) | Anything HID-named (invariant 9). **No CultLib change.** | +| Muninn capture thread, one per source (H2) | The **only** device reader (B22); sampling (XInput 1 ms, Windows HID every report, joystick events as they arrive); `EdgeDetector`; the latest-state slot; the per-source edge queue | Sessions, windows, epochs, frames | +| Muninn input organ, `crates/muninn-daemon/src/input_stream.rs` (H2) | The advertisement; admission of input requests as demand, and their answers; the input hub and its sessions; one `EdgeWindow` per (session, device); frame emission on `latest`; applying acks | Device reads; mapping; deciding that an edge was applied | +| `muninn-contracts` (H0, H1) | The input records and their key and validation; the HID wire records and their CultNet envelope; `EdgeDetector`, `EdgeWindow` and `EdgeCursor` as pure code | Sockets, threads, devices, Odin | +| Sleipnir (`crates/sleipnir-daemon`, Muninn workspace, H3/H4) | Choosing the device from typed advertisements plus its mapping record; publishing the request; dialling; the `EdgeCursor`; **the single `commit_pad` primitive** (invariant 10); the hold and the request-owned budget (invariant 13); acks; neutralising on staleness | Edge creation, epochs, capture, discovery JSON | +| CultLib | The `latest` channel: Unreliable and Sequenced, fixed by name (`C rudp.rs:2189-2192, 2384-2390`); `CultNetRudpServerHub` with per-session send (`C rudp.rs:1384, 1464, 1475`) | Anything HID-named (invariant 9). **No CultLib change** (under Q11's recommendation). | +| Odin | Catalog storage for the advertisement and request documents. It is schema-agnostic: `odin-daemon` names no media schema either (`git grep`, Odin `379b826`). | Anything input-specific. **No Odin change.** | **Why one frame on `latest`, and not the donor's reliable `hid.edge` channel.** -- A frame carries the current state **and** every unacknowledged edge, up to 32. +- A frame carries the current state **and** every unacknowledged edge of that + session's window, up to `MAX_EDGES_PER_FRAME`. - Each frame therefore supersedes the last, which is exactly what a sequenced channel delivers: an older frame arriving late is dropped, and nothing is lost by that. -- Loss costs nothing but the next tick, 2 ms at the current poll - (`HID_CONTROLLER_RUDP_POLL_INTERVAL`). +- Loss costs one transport tick. H2 sets that tick to 2 ms. Today it is 8 ms + (B22). - There is no retransmit timer, no reliable window to stall, and no second "assist" path. - The ruling's "profile-owned channels" is met by using a channel the profile @@ -1190,42 +1322,83 @@ another host, over LAN or mesh, so quick taps must survive loss. **How Odin `main`'s pin stays untouched while StreamPixels ships.** - Nothing in H0-H5 commits to Odin `main`. -- Sleipnir's changes happen in Sleipnir's new home (Q9). Its odin-core comes - from the `pins/` tag (Q2 mechanism). `3e96c6c` already pins CultLib - `c2a9a6e`, Muninn's pin, so no new Odin commit is needed until Cut 3. -- From H3 on, Odin `main`'s `crates/sleipnir-daemon` is a **forbidden writer**: - no commits there. -- After StreamPixels ships, **one** Odin commit removes the crate from Odin's +- Sleipnir moves into Muninn's workspace (Q9) at Muninn's pins: CultLib + `c2a9a6e`, and odin-core from Odin `3e96c6c`. That is the commit Odin made + for exactly this ("CultLib pin a8aedda -> c2a9a6e so a consumer can build + odin-core without a path override"). It already bumps `sleipnir-daemon`'s + own manifest to `c2a9a6e`, and its odin-core API equals Odin `main`'s: + `git diff 3e96c6c 379b826 -- crates/odin-core` touches only `Cargo.toml` and + two examples. +- From H3 on, Odin `main`'s `crates/sleipnir-daemon` is a **forbidden writer**. +- After StreamPixels ships, **one** Odin commit deletes the crate from Odin's workspace. It is a deletion, with no pin change and no other edit. -- Until then, two copies of the source exist and only one is built and - deployed. The Muninn extraction lived through this interval too - (teardown-map L628-629), and it is recorded as debt with its deletion line. +- Until then two copies of the source exist, and only Muninn's is built and + deployed from H5 on. Recorded as debt, with its deletion line. ### H0: shape of Muninn's named cut 1 (a constraint, not a new cut) Named cut 1 moves Muninn's 16 records out of odin-core. For H, it must place -them in a **library** crate, `crates/muninn-contracts` (`crate-type = rlib`), -not in `muninn-daemon`'s binary. +them in a **library** crate in Muninn's workspace, `crates/muninn-contracts` +(`crate-type = rlib`), not in `muninn-daemon`'s binary. - **Owner:** Muninn. -- **Live consumers:** `muninn-daemon`; Sleipnir, which imports - `MuninnHidControllerStateRecord` today (`S :12`); `muninn-video-encoder` - (6a). -- **Protected invariant:** each Muninn wire record has one definition across - repositories. +- **Live consumers:** + - `muninn-daemon`; + - `sleipnir-daemon`, a workspace member from H3. It imports + `MuninnHidControllerStateRecord` today (`S :12`), until H4. + - `muninn-video-encoder` (6a). +- **Protected invariant:** each Muninn wire record has one definition, and + every crate that links it resolves one `cultcache-rs`. With Sleipnir in the + workspace that is structural: one `Cargo.lock`, a path dependency, and no + git pin to drift. The pass-2 guard test for a cross-repo pin is **not** + written. - **Why the existing owner cannot serve:** a binary crate cannot be a - dependency. + dependency, and two binaries (`muninn`, `sleipnir`) share these records. - **What it replaces:** Muninn records defined in Odin's odin-core. -Its dependencies are `cultcache-rs`, `cultnet-rs`, `serde` and `anyhow`, at -Muninn's CultLib pin. +Its dependencies are `cultcache-rs`, `cultnet-rs`, `serde` and `anyhow`, taken +from `[workspace.dependencies]` (H3 hoists the pins there). -### H1: HID wire records and protocol state machines (`muninn-contracts`) +**Ordering against H3.** Neither waits for the other. If H3 lands first, +Sleipnir keeps importing the HID record from odin-core. Named cut 1 then +switches both importers in one commit, which is the practical gain of Q9. -**Adds (types and rules; bodies are Hands'):** +### H1: input-stream records, HID wire records and protocol state machines (`muninn-contracts`) + +Location per Q11 (recommended): `crates/muninn-contracts/src/input_stream.rs` +(the capability records) and `crates/muninn-contracts/src/hid.rs` (the wire +records and state machines). If Q11 rules CultLib, the same types go to +`CultLib packages/cultnet-rs/src/input_stream_contracts.rs` under `gamecult.*` +ids, and H1 waits for Cut 3's CultLib tag and the pin gate. + +**Adds, the capability pair** (types and rules; bodies are Hands'). It is +modelled field-for-field on `C media_stream_contracts.rs:71-183`. +- `muninn.input_stream_advertisement.v1`: + - `stream_id`, `producer_id`, `label`; + - `state`, one of `available`, `streaming`, `unavailable`; + - `device_ids`, `device_labels`, `device_kinds`: parallel, equal length; + - `default_input_budget_ms`: the default, 100; + - `input_endpoint`, `input_connection_id`; + - `updated_at`. +- `muninn.input_stream_request.v1`: + - `request_id`, `stream_id`, `producer_id`, `receiver_id`; + - `action`, one of `start` or `stop`; + - `state`, one of `pending`, `running`, `stopped`, `failed`; + - `device_id`, `input_budget_ms`, `detail`, `updated_at`. +- `input_stream_request_key(stream_id, receiver_id)`, with the same shape as + `media_stream_request_key` (`C media_stream_contracts.rs:180`). +- `validate_input_stream_advertisement` and `validate_input_stream_request`. + They mirror `C :184-290`. Validation refuses: + - unequal device arrays; + - `start` without a `device_id`; + - an `input_budget_ms` of 0 on a `start`, because the consumer fills in the + advertised default, as Ratatoskr does (`R catalog.rs:174`); + - an unknown action or state. + +**Adds, the wire:** - `muninn.hid_input_frame.v1`: - `device_id`, `stream_id`; - - `epoch: u64`, which is never 0; + - `epoch: u64`, never 0; - `state_sequence: u64`; - `source_timestamp_ns: i64`, telemetry only; - `axes: Vec`; @@ -1233,26 +1406,33 @@ Muninn's CultLib pin. frame's window; - `edge_first_sequence: u64`; - `edge_buttons: Vec`, `edge_pressed: Vec`, - `edge_captured_ns: Vec`: parallel arrays, equal length, at most 32, - contiguous from `edge_first_sequence`. + `edge_captured_ns: Vec`: parallel arrays, equal length, at most + `MAX_EDGES_PER_FRAME`, contiguous from `edge_first_sequence`. - Hands may use a nested record list instead of parallel arrays if the `cultcache-rs` derive supports it at the pin. The rule, not the spelling, is the contract. - `muninn.hid_edge_ack.v1`: `device_id`, `epoch`, `applied_through: u64`. -- `muninn.hid_subscription.v1`: `subscriber_id`, `device_filter`, `stream_id`. - This replaces the JSON object at `S :2303-2306`. -- `MuninnHidWireRecord { Frame, Ack, Subscription }`, with - `encode`/`decode` through the CultNet message envelope. This is the same - pattern as CultLib's `encode_media_wire_record` - (`C media_stream_wire.rs:134-190`), with validation on both encode and decode. -- `EdgeWindow` (sender): - - `capture(buttons_now, captured_ns)` diffs against the last sample, then - appends edges: releases first, then presses (the donor order); - - `frame_edges() -> ≤32 oldest pending`; +- `MuninnHidWireRecord { Frame, Ack }` with `encode`/`decode` through the + CultNet message envelope. This is the pattern of CultLib's + `encode_media_wire_record` (`C media_stream_wire.rs:134-190`), with + validation on both encode and decode. +- **Deleted from the design:** pass 2's `muninn.hid_subscription.v1`. Device + selection is the request's `device_id`, and the subscriber's identity is the + session's connect payload. There is no in-session subscription message. + +**Adds, the state machines:** +- `EdgeDetector` (runs on the capture thread): + - `sample(buttons_now, captured_ns) -> impl Iterator` diffs against the + previous sample: releases first, then presses (the donor order); + - edges carry no sequence and no epoch. Those belong to a window. +- `EdgeWindow` (one per session and device, on the input tick): + - `new(epoch_ns)`; + - `push(edge)` appends, with a sequence contiguous from 1; + - `frame_edges() -> ≤ MAX_EDGES_PER_FRAME oldest pending`; - `ack(epoch, applied_through)`; - - overflow past 256 pending rotates the epoch (`+1`), sets `state_sequence` - to 0, clears pending, and reports the rotation to the caller, which counts - it. + - `rebaseline()`. Past 256 pending it sets the epoch to `+1`, + `state_sequence` to 0, clears pending, and reports the rotation to the + caller, which counts it. - `EdgeCursor` (receiver): - `accept_frame(epoch, state_sequence, first_seq, edges) -> Admission`; - an epoch below the current one: fenced (a monotonic comparison, with no @@ -1264,197 +1444,515 @@ Muninn's CultLib pin. - the same epoch: return the edges with sequence above `applied_through`, in order. Duplicates are dropped by sequence, and a gap past 256 is refused. -**Rules that must die under their own mutation** (a property-style test drives -`EdgeWindow` → frames → a seeded drop, reorder or duplicate → `EdgeCursor`): -- Every edge captured is delivered **exactly once, in capture order**, under - every Cut 3b profile. Use the **same seed tables** as the media harness, so - that one impairment vocabulary serves both. +**Rules that must die under their own mutation.** A property-style test drives +`EdgeDetector` → two `EdgeWindow`s (two subscribers, independent drop seeds) → +frames → a seeded drop, reorder or duplicate → one `EdgeCursor` per +subscriber. +- Every edge captured is delivered **exactly once, in capture order, to each + subscriber**, under every Cut 3b profile. Use the **same seed tables** as the + media harness, so one impairment vocabulary serves both. +- One subscriber at `burst-8` never rotates the other subscriber's epoch. - A press and release captured in one sampling interval arrive as two edges, press then release. -- The window in a frame is never above 32. Pending is never above 256. At 257, - the epoch rotates, and the next frame's epoch is exactly one more. +- The window in a frame never exceeds `MAX_EDGES_PER_FRAME`. Pending is never + above 256. At 257 the epoch rotates, and the next frame's epoch is exactly + one more. - An ack for a stale epoch changes nothing. An ack for `n` trims exactly through `n`. - The cursor rejects a frame from any epoch below its current one, including `current − 1`. Kill the off-by-one comparison mutant. -- A frame at the maximum window, with the longest button names and 16 axes, - encodes to ≤ 1,200 bytes (`HID_CONTROLLER_RUDP_MAX_FRAGMENT_BYTES`), so it - never fragments (invariant 6's rule, applied to input). -- The frame round-trips, and a frame with unequal parallel arrays or a - non-contiguous window is refused on decode. +- **Fit.** A frame with a full window, the longest button names, 16 axes and + a full held set encodes to ≤ 1,200 bytes (`HID_CONTROLLER_RUDP_MAX_FRAGMENT_BYTES`, + `M :6140`), so it never fragments (invariant 6's rule, applied to input). + - A rough count puts 32 edges with 16-character names at about 1,270 bytes. + **The test sets the constant.** Start at 32, and lower it until the worst + case fits. + - Never raise the fragment limit to make it fit. +- The frame round-trips. A frame with unequal parallel arrays, or a + non-contiguous window, is refused on decode. +- The request and advertisement round-trip. Each refusal listed above has a + test. **Verification:** `cargo test -p muninn-contracts` and `cargo mutants --in-diff` -on Yggdrasil. This crate is where the protocol is proven; H2 and H4 only wire -it. +on Yggdrasil, one job. -**Ledger:** about +450 (records, wire, two state machines and the property -test). It retires the donor's ~350 HID lines, which never land. +**Build budget:** one library crate that already exists from named cut 1. +It gains no dependencies and no new target. -### H2: Muninn sends frames (Muninn repo) +**Ledger:** about +550 (the capability pair ~100, the wire and three state +machines ~250, tests ~200). It retires the donor's ~350 HID lines, which never +land. -**Deletes first:** -- The JSON subscription: `HidControllerRudpSubscription` (`M main.rs:6137-6142`) - and `serde_json::from_slice` (`:6216-6217`). -- The JSON state send (`:6298-6300`, `:6835-6836`). -- **The losing HID path, per Q10.** Either the ingress server (`:6144-6356`, - `--hid-controller-rudp-bind`) or the outbound client (`:6717-6860`, - `--hid-controller-rudp-target`, `:10891-10895`), with its options, its - advertisement fields (`:3619-3740`), and its tests. -- The per-tick read (`:6286`) as the place where edges exist. +### H2: Muninn serves input streams (Muninn repo) -**Adds:** -- A capture thread per source, ported from `fb4f331`: - - XInput sampled every 1 ms (`D main.rs:5881`); - - Windows HID via the existing report reader, every report; - - joystick events as they arrive. - - Each sample goes to `EdgeWindow::capture` **on the capture thread**, and - the latest state goes into a slot the transport reads. - - Edges are never computed at transport time (B14). -- A transport tick at 2 ms: - - for each subscribed source, send one `hid_input_frame` on `latest`; - - apply received `hid_edge_ack`; - - take `hid_subscription`. - - A source with nothing new still sends at the 50 ms heartbeat - (`HID_CONTROLLER_RUDP_HEARTBEAT_AFTER`). -- `muninn.hid_controller_state.v1` stays exactly what it is: the Odin-published - **observation** for device discovery (`M :6858`). It is not a wire frame. +Depends on H1 and Q12. It is developed on the same Hands branch as H4 and +merged with it (see Sequencing). -**Authority map:** -- **Owner:** the `EdgeWindow` per source, on the capture thread. -- **Inputs:** device samples. -- **Outputs:** frames. +**Authority map.** +- **Owner:** + - edges: `EdgeDetector` on each source's capture thread; + - delivery: the input organ in `input_stream.rs`, one `EdgeWindow` per + (session, device). +- **Inputs:** + - device samples, capture only; + - admitted input requests, from the serve tick's single Odin pull; + - hub sessions and their connect payloads; + - `hid_edge_ack` frames. +- **Outputs:** + - `hid_input_frame` per session on `latest`; + - the advertisement; + - answers on request keys; + - counters (frames, rotations, acks, sessions). +- **Derived state:** + - `muninn.hid_controller_state.v1` receipts become **observation-only**, + projected from the capture slot. They are no longer read from the device, + and they are no longer a discovery source. + - The Eve provider advertisement lists no input streams. - **Forbidden writers:** - the transport tick deciding edges; - - wall-clock time deciding freshness (it is telemetry only; donor doctrine, + - the serve tick reading a device that a capture thread owns; + - wall-clock time deciding freshness (telemetry only; donor doctrine, `D docs/realtime-delivery-authority.md`); - - any JSON. -- **Shared paths:** live capture, reconnect (the window survives a - reconnection; the epoch survives until overflow or restart) and tests all go - through `EdgeWindow`. + - JSON anywhere on this path; + - an in-session subscription message. +- **Shared paths:** live capture, reconnect, restart (demand re-admission) and + tests all go through `EdgeDetector` → `EdgeWindow`. A reattaching session + gets a new window, a new epoch, and a baseline. + +**Deletes first** (`M`): +1. **The losing path per Q10: Muninn's outbound client.** + - `ActiveHidControllerStream` (`:6077-6086`); + - `create_hid_controller_stream` (`:6724-6763`); + - `publish_hid_controller_state_to_stream` (`:6765-6778`) and `_inner` + (`:6780-6863`); + - in `serve`: `let mut hid_controller_stream` (`:418`) and its argument + (`:506`); + - the `hid_controller_stream` parameter of `publish_move_controller_states` + (`:4982`), and its three calls (`:5026-5029`, `:5073-5076`, + `:5112-5115`); + - the option field (`:229`), its default (`:10628`) and its parse arm + `--hid-controller-rudp-target` | `--hid-controller-udp-target` + (`:10899-10904`). +2. **The JSON listener body.** Its direction survives; its body does not. + - `HidControllerRudpSubscription` (`:6143-6149`); + - `start_hid_controller_rudp_ingress` (`:6151-6177`); + - `run_hid_controller_rudp_ingress` (`:6179-6340`); + - `hid_controller_source_matches_subscription` (`:6342-6360`); + - `hid_controller_record_matches_subscription` (`:6362-6375`); + - `sanitize_hid_controller_record` (`:6710-6722`); + - the `serve` wiring at `:429-437`, per Q12. +3. **The per-tick reader as the place where HID state is made.** + - `read_hid_controller_state_for_stream` (`:6377-6528`), + `emit_hid_controller_record_if_due` (`:6530-6554`), + `hid_controller_stream_heartbeat_due` (`:6556-6560`) and + `hid_controller_axes_changed` (`:6562-6570`). + - The device-read bodies inside them move to the capture thread; they are + not rewritten. The emit-if-due throttling dies, because the window + decides what to send. + - `ActiveHidControllerRudpSource`, `active_hid_controller_rudp_source` and + `sync_hid_controller_rudp_sources` (`:6088-6129`) become the capture-set + reconciler. The hot-plug test `rudp_sources_reconcile_hotplugged_physical_identity` + (`:14620-14636`) is kept and retargeted. +4. **The double-read fence** (`:5081-5086`), because the capture thread is + now the only reader (B22). +5. **The JSON discovery surface** (B21). + - `hid_controller_endpoint` (`:3626-3631`); + - the device JSON (`:3653-3672`); + - `transport_input_streams` and `provider_input_streams` (`:3673-3727`); + - the provider `endpoints` pushes (`:3729-3750`); + - the `"input_streams"` key (`:3856`) and the `"inputStreams"` key + (`:3885`). + - The test `runtime_boundary_advertises_hid_and_move_evidence_streams` + (`:14072-14172`) is rewritten against the typed advertisement. +6. **The discovery write** `publish_hid_controller_state_to_odin` + (`:6865-6875`) and its three calls (`:5030`, `:5077`, `:5116`). Its only + reader is Sleipnir's discovery, which H4 deletes (B21). Receipts through + `put_hid_controller_state_receipt` (`:5970`) stay. +7. **Rename, not keep beside.** The options `hid_controller_rudp_bind` and + `_advertise` (`:230-231`, parse `:10906-10916`) become + `--input-rudp-bind` and `--input-rudp-advertise`. The old spellings are + refused with a message naming the new ones (the house pattern, + `M :10890-10894`). `local_ring_enabled` (`:4238`) follows Q12. + +**Adds:** +- `crates/muninn-daemon/src/input_stream.rs`, so `main.rs` shrinks. It holds: + - `input_stream_advertisement(options, live_sources)`, published at `:4146` + beside the media advertisement, with role `input-stream-producer` and tag + `muninn.input-stream-advertisement`; + - `InputDemand`: the admitted requests, keyed by `receiver_id`. It is + written by the serve tick and read by the input thread (a latest-wins + `Arc>`); + - `run_input_hub`: a `CultNetRudpServerHub` on `--input-rudp-bind`, open for + the life of `serve` when configured (`muninn_input_rudp_hub_options`, on + the model of `M :3232-3241`). On a 2 ms tick it: + - drains each source's edge queue into the windows of every session that + requested that device; + - sends one frame per (session, device) on `latest`, with a 50 ms + heartbeat when a window holds nothing new + (`HID_CONTROLLER_RUDP_HEARTBEAT_AFTER`, `:6138`); + - applies acks; + - drops sessions after timeout, as the media hub does (`M :3090-3096`). +- **Request intake: one owner for both capabilities.** + - `sync_media_stream_requests` (`M :971-1034`) becomes + `sync_stream_requests`. It makes **one** `pull_rudp_catalog_snapshot` with + `schema_ids = [media request, input request]`. + - Media requests continue exactly as today. + - Input requests addressed to `options.host_id` are admitted by `action` + (B23). An unknown `device_id` is answered `failed` with a typed `detail`. + A `stop`, or a newer request under the same key, withdraws the demand. + - `answer_media_stream_request` (`M :1069-1117`) gets an input twin with + the same keying and dedupe (`answered`). A generic helper is not + introduced for two call sites. +- `advertised_media_endpoint` (`M :1173-1196`) is parameterised as + `advertised_endpoint(explicit, bind, command_advertise)`. Media and input + call the one function, so input inherits "explicit or derived, never + guessed". +- `MuninnDocuments` (`M :10503-10542`) registers the two input records. +- **A capture thread per source**, ported from `fb4f331`: + - XInput sampled every 1 ms (`D main.rs:5881`); + - Windows HID through the existing report reader + (`start_windows_hid_report_reader_if_supported`, `M :8813`), every report; + - joystick events as they arrive. + - Each sample goes through `EdgeDetector` **on the capture thread**. The + edges go to a bounded per-source queue (256; overflow rebaselines every + window on that device), and the latest state goes to a slot. + - The serve tick's HID receipts (`M :5016-5025`, `:5064-5072`, + `:5102-5111`) read the slot, not the device. +- `raven-muninn.toml`: + - the argument pair `:62-65` becomes `--input-rudp-bind 0.0.0.0:17887` and + `--input-rudp-advertise` (binding `input_rudp_advertise`); + - a second `[[provides]]` block: `muninn.input-producer`, + `muninn.input_stream_advertisement.v1`, `v1`. + - The binding rename lands in gamecult-ops with H5. + - This edits the file `route/b3-declare` also edits, in a different block. + Rebase onto it if it has merged. **Rules:** - A tap shorter than one transport tick, from a scripted fake XInput source, becomes two edges in the next frame. -- The frame stream from a fake source through a drop relay matches H1's - property on the wire. Assert on captured datagrams. -- `rg serde_json crates/muninn-daemon/src` finds no HID or subscription use. +- Two sessions requesting one device each receive every edge once. Dropping + all frames to one session for 600 ms rotates only that session's epoch. +- A session whose `receiver_id` has no admitted request receives no frame. + So does a session whose request names another device. +- After a simulated restart, with fresh `InputDemand` and an Odin fixture + still holding the request at `running`, the session is served again with no + new request (B23). +- The frame stream from a fake source through a seeded drop relay matches + H1's property on the wire. Assert on captured datagrams. +- The serve tick never opens a device that a capture thread owns: a dev-only + per-device open counter reads 1. +- `rg serde_json crates/muninn-daemon/src/input_stream.rs` is empty, and no HID + path in `main.rs` calls `serde_json`. +- The typed advertisement round-trips through a local CultMesh node fixture, + as `R tests/catalog.rs:162-180` does for media. -**Verification:** Yggdrasil. The XInput and Windows HID readers are `cfg(windows)`, -so the scenario uses a scripted source behind the same capture trait. -- **Only live proves:** real XInput sampling jitter on the viewer's host. -- **Starfire:** one Windows compile check of `muninn-daemon`, one job and no - burners (load budget). +**Verification:** Yggdrasil, `cargo test -p muninn-daemon`, one job. The +XInput and Windows HID readers are `cfg(windows)`, so the scenario uses a +scripted source behind the same capture trait. +- **Starfire:** one Windows compile check of `muninn-daemon`. It is batched + with H4's, one job and no burners (load budget). +- **Only live proves:** real XInput sampling jitter on the viewer's host (H5). -**Ledger:** about −250 (JSON paths, the losing transport path and its -advertisement) and +200. **Net about −50.** +**Build budget:** `muninn-daemon` only. One new module, no new target and no +new dependency. -### H3: Sleipnir moves to its own home (Q9); behaviour-neutral +**Ledger:** about −750 (the outbound client ~150, the JSON listener and match +functions ~235, the per-tick reader and emit ~195 (its read bodies relocate), +the JSON advertisement ~105, the discovery write and fence ~20, the rewritten +test ~60 net) and about +450 (the organ ~300, capture threads ~100, +intake/answer twin ~50). **Net about −300.** -Recommended per Q9 (a): **`GameCult/Sleipnir`**, created in the GameCult -organisation per project doctrine. +### H3: Sleipnir moves into Muninn's workspace (Q9); behaviour-neutral -- The source history comes from Odin with `git filter-repo --path - crates/sleipnir-daemon`. It is not a copy, so provenance survives. -- Dependencies: - - CultLib at Muninn's pin (`c2a9a6e`); - - `odin-core` from `pins/odin-core-cultlib-c2a9a6e` (`3e96c6c`, retagged per - the Q2 note); - - `muninn-contracts` at a Muninn revision (git, full sha); - - `vigem-client` unchanged. -- Pins move in lockstep with Muninn from then on. A guard test asserts that - Sleipnir's `muninn-contracts` resolves the same `cultcache-rs` source as its - own dependency. The "pin sibling checkouts" rule, applied to a git pin. -- **Deletes on the Odin side:** none now. Odin `main`'s copy becomes a - forbidden writer, and its removal is the post-ship Odin commit. -- **Verification:** - - `cargo test` on Yggdrasil (Linux, `LoggingBackend`). The existing 22 - Sleipnir tests (`S :2959-3660`) pass unchanged. - - The Windows build is proven by H5's recipe on Raven. - - `SleipnirInputMappingRecord` keeps coming from the odin-core tag. Moving it - into Sleipnir is a later subtraction, not this cut. +**Source:** Odin `379b826` `crates/sleipnir-daemon/{Cargo.toml, src/main.rs}`, +3,658 lines. That is 8 commits by path, the oldest through `4ad8bdb`, and +`--follow` finds no earlier rename. -### H4: Sleipnir applies edges (Sleipnir repo) +**How history moves.** This is the Muninn extraction precedent: Muninn's own +history came out of Odin whole (316 commits, root `2d88408`). +- In a scratch clone of Odin, run + `git filter-repo --path crates/sleipnir-daemon`. +- In Muninn, `git merge --allow-unrelated-histories` that result. It lands at + the same path, `crates/sleipnir-daemon/`. +- `git log -- crates/sleipnir-daemon` then works in Muninn. Odin keeps its own + copy of the history after its deletion commit. -**Deletes first:** -- The JSON subscription (`S :2303-2310`) and JSON decode (`:2497-2498`). -- Button coalescing, in which the latest record wins per device - (`coalesce_latest_*`, `:2543-2590`). Axes remain latest-wins, because they - are replaceable. -- Any path that writes `buttons` from a snapshot inside an epoch. +**Per-file changes, all in Muninn:** +- `Cargo.toml` (workspace): + - add `crates/sleipnir-daemon` to `members`; + - add a `[workspace.dependencies]` table holding the one CultLib rev + (`c2a9a6e526390808ef195759056fda0b9a55d8a3`) for `cultcache-rs`, + `cultmesh-rs` and `cultnet-rs`, plus `odin-core` at Odin `3e96c6c`. +- `crates/muninn-daemon/Cargo.toml:18-20, 24`: the four git pins become + `{ workspace = true }`. From here, "pins move in lockstep" is one line, not + a discipline. +- `crates/sleipnir-daemon/Cargo.toml`: + - the CultLib pins become `{ workspace = true }`. They were `a8aedda` in + Odin: the pin moves from Odin `main`'s to Muninn's; + - `odin-core = { path = "../odin-core" }` becomes `{ workspace = true }`; + - `edition`, `license` and `publish` already read `.workspace`, and + Muninn's workspace defines all three. +- `crates/sleipnir-daemon/src/main.rs`: **no edit**. The pin move from + `a8aedda` to `c2a9a6e` is the only behavioural exposure. Odin made the + same move for this crate in `3e96c6c`. +- `Cargo.lock`: regenerated. It gains `vigem-client` for Windows only, and + Muninn's recipe keeps building `-p muninn-daemon --bin muninn` (`:14-22`), + so Raven's Muninn build is unchanged. + +**Deletes:** none in Muninn. Odin `main`'s copy becomes a forbidden writer, +and its removal is the post-ship Odin commit. + +**Verification:** +- `cargo test -p sleipnir-daemon` on Yggdrasil (Linux, `LoggingBackend`). The + existing 22 tests (`S :2959-3660`) pass unchanged at the new pin. +- `cargo test -p muninn-daemon` passes unchanged. The workspace-dependency + hoist is proved by an unchanged `cargo tree -d -p muninn-daemon` for + `cultcache-rs`: exactly one. +- Starfire: one Windows `cargo check -p sleipnir-daemon`, because + `vigem-client` is `cfg(windows)`. +- `SleipnirInputMappingRecord` keeps coming from odin-core (`S :13`). Moving + it into Sleipnir is a later subtraction, not this cut. + +**Build budget:** one binary target, `sleipnir`, joins the workspace. It +leaves Odin's workspace at the post-ship commit, so the estate's target count +is unchanged. + +**Ledger:** 0 (a move) plus about −10 manifest lines (the pins hoisted). + +### H4: Sleipnir requests input and applies edges (Muninn repo) + +Developed with H2 on one Hands branch, and merged with it. + +**Authority map.** +- **Owner:** `commit_pad(state)` is the only caller of + `VirtualPadBackend::update`. Today there are five direct calls: `S :387`, + `:400`, `:472`, `:525`, and the `LoggingBackend` path. +- **Inputs:** + - typed input advertisements; + - the mapping record; + - the answered request; + - frames on its own session. +- **Outputs:** the request, acks, the pad timeline, and the Eve surface. +- **Derived state:** + - "available devices" is derived from advertisements; + - the selected endpoint is derived from the chosen advertisement; + - neither is discovered from JSON. +- **Forbidden writers:** + - any `backend.update` outside `commit_pad`; + - a snapshot writing `buttons` inside an epoch; + - JSON; + - `discover_provider_endpoints`. +- **Shared paths:** frames, epoch baselines, staleness neutralisation, mapping + change and reconnect all reach the pad through `commit_pad`. + +**Deletes first** (`S`, identical lines after H3): +1. **The losing path per Q10: Sleipnir's listening mode.** + - `Options.rudp_bind` (`:42`); + - the `--rudp-bind` | `--udp-bind` arm (`:2823-2829`); + - the server branch of `create_rudp_stream` (`:2244-2281`); + - `[--rudp-bind ADDR]` in `usage` (`:2906`). +2. **The JSON subscription.** + - `HidControllerRudpSubscription` (`:68-72`); + - `ActiveRudpStream.last_subscription*` (`:64-65`); + - `send_hid_subscription_if_due` (`:2283-2321`) and its call (`:426-431`). +3. **The JSON decode and the button coalescing.** + - `receive_rudp_records` (`:2480-2541`) is rewritten to decode + `MuninnHidWireRecord`; + - `coalesce_latest_timed_hid_records_by_device` (`:2543-2565`) and + `coalesce_latest_hid_records_by_device` (`:2567-2588`) are deleted, + with their tests (`:2959-2996`). Axes stay latest-wins, because the + frame carries only current axes. +4. **JSON and Odin-generic discovery** (B21). + - The second, provider-key pull in `pull_odin_catalog_snapshot` + (`:950-975`). The first pull's schema list becomes + `[muninn.input_stream_advertisement.v1, sleipnir.input_mapping.v1]`, + plus the request schema for the answer. + - `muninn_provider_keys_for_discovered_hid_hosts` (`:978-1003`) and + `hid_state_host_id_from_value` (`:1005-1019`). + - `effective_muninn_hid_endpoint` (`:1826-1833`). + - `discover_available_hid_devices` through `endpoint_looks_like_socket` + (`:1845-2131`). + - `resolve_socket_addr` stays. `discover_muninn_hid_endpoint` through + `stream_host_hint` (`:2370-2478`) goes. + - The eight discovery tests (`:3164-3513`) are replaced by three tests over + typed advertisements. + - Imports go: `EVE_PROVIDER_ADVERTISEMENT_SCHEMA` (used only at `:957`, + `:1890` and `:2420`), `MUNINN_HID_CONTROLLER_STATE_SCHEMA`, + `MuninnHidControllerStateRecord`, `OdinEndpointQuery` and + `discover_provider_endpoints` (`:10-14`). Sleipnir's odin-core symbols + fall from 10 to 4: the Eve advertisement and surface records, + `OdinDocuments`, and `SleipnirInputMappingRecord`. Pass 1 of H5 takes + `IdunnDaemonHealthRecord` too. +5. **Direct pad writes:** the five `backend.update` sites listed above. +6. **Removed-flag stubs that only reject old spellings:** + `--command-rudp-bind`, `--command-rudp-advertise` and + `--odin-cultmesh-rudp` (`:2830-2849`), with their tests (`:3605-3647`). + They are older than a Muninn-workspace body, and nothing passes them now + (`Ops idunn-deployment-targets.ps1:131`: `sleipnir --host raven`). **Adds:** -- `EdgeCursor` per subscribed device. -- An actuation queue drained by **one** `commit_pad(state)`, which is the - only caller of `VirtualPadBackend::update`. Frame axes, cursor edges, - epoch baselines and staleness neutralisation (`INPUT_STALE_NEUTRAL_AFTER`, - `S :28`) all go through it (invariant 10). -- The visibility hold of 24 ms, with the budget override at 100 ms: an edge - older than the budget is committed without the hold (invariant 13). +- `SleipnirDocuments`: `OdinDocuments` plus the two input records, on the + model of `MuninnDocuments` (`M :10503-10542`). It replaces `OdinDocuments` + at `S :269, 281`. +- `select_input_device(advertisements, mapping) -> Option<(advertisement, device_id)>`. + This is **one** function. It matches `mapping.stream_id` against the + advertisement's `stream_id`, and `mapping.device_filter` against + `device_ids` and `device_kinds`. The rules of `record_matches_filter` + (`S :2602-2612`) are kept. + - Persisted mapping records hold the old stream-id format + (`host:move_id:hid-controller-state`). They fall through to + `device_filter` and are rewritten on the next Eve mapping command. No + migration shim. +- **The request lifecycle,** the Ratatoskr pattern (`R catalog.rs:95-179`): + - publish `start` on selection, with `receiver_id = sleipnir.` and + `input_budget_ms` from the advertisement's default; + - publish `stop` on deselection, on mapping disable, and on clean exit; + - publish a **new** `start` (a fresh `request_id`) when the session is lost + or silent for `INPUT_STREAM_RECONNECT_AFTER` (`S :29`). A restarted + producer re-admits by demand anyway (B23); this covers the Odin document + being lost. +- **Dial:** the client branch of `create_rudp_stream` (`S :2196-2243`), with + `connect(receiver_id bytes)` in place of `connect(Vec::new())` + (`S :2232`, `:2365`), because the hub binds the session to the request by + its connect payload. +- `EdgeCursor` per selected device. +- An actuation queue drained by `commit_pad`. Frame axes, cursor edges, epoch + baselines, staleness neutralisation (`INPUT_STALE_NEUTRAL_AFTER`, `S :28`) + and a mapping change all go through it (invariant 10). +- The 24 ms visibility hold, overridden at the **request's** + `input_budget_ms`: an edge older than the budget is committed without the + hold (invariant 13). - `hid_edge_ack` for `applied_through` after each commit, and every tick while nonzero, on `latest`. -- `hid_subscription` as a typed record every 1 s. +- `map_record_to_virtual_pad` (`S :2677-2696`), `apply_pending_learn` + (`S :1714-1754`) and `input_latency_snapshot` (`S :2614-2651`) take the + cursor's pad input (axes plus held buttons plus the frame's timestamps) + instead of `MuninnHidControllerStateRecord`. The mapping logic is unchanged. +- The loop's sleep uses a 2 ms default for `--interval-ms` (`S :2791` + today: 8). -**Rules (on a recording backend, asserting the pad timeline, which is the -layer the game samples):** +**Rules** (on a recording backend, asserting the pad timeline, which is the +layer the game samples): - A quick tap under `loss-5pct` and `burst-8`: the timeline shows press, then release; the press is held ≥ 24 ms; nothing is doubled. - Reordered and duplicated frames produce the same timeline as clean. - A burst of 20 taps: every one appears. The last is committed no later than - 100 ms plus one tick after capture. Kill a mutant that removes the budget - override. Probe at `budget ± ε`. + `input_budget_ms` plus one tick after capture. Kill a mutant that removes the + budget override. Probe at `budget ± ε`, with the budget taken from a request + of 60 ms and one of 100 ms. The hold follows the request, not a constant. - A frame from epoch `current − 1` after a rotation is ignored, and the pad shows the new baseline. - Silence for 1.5 s neutralises through `commit_pad`. Assert that the backend - was called by the same function (a dev-only call-site counter). -- End to end on Yggdrasil: Muninn's H2 sender with a scripted source, through - a seeded drop relay, to Sleipnir with a recording backend. The relay and - profiles are the Cut 3b tool's. The test lives in Sleipnir, with Muninn's - scripted-source sender reached through `muninn-contracts` plus a thin test - driver. + was called only from that function (a dev-only call-site counter). +- Selection: + - a mapping naming a device kind picks the right advertisement among two + hosts; + - a mapping naming an absent device publishes no request; + - a `failed` answer surfaces its `detail` on the Eve surface. +- **End to end on Yggdrasil**, now inside one workspace: + - H2's `run_input_hub` with a scripted source, through a seeded drop relay + (the Cut 3b tool's relay and profiles), to Sleipnir's session loop with a + recording backend; + - an in-process CultMesh node stands in for Odin, holding the advertisement + and request. + - The test lives in `crates/sleipnir-daemon/tests/`, with `muninn-daemon`'s + organ reached as a dev-dependency only if Hands can make it a lib target + cheaply. Otherwise the test drives the hub from `muninn-contracts`'s state + machines plus a thin test driver, as in pass 2. -**Verification:** `cargo test` and `cargo mutants --in-diff` on Yggdrasil. +**Verification:** `cargo test -p sleipnir-daemon` and `cargo mutants --in-diff` +on Yggdrasil, one job batched with H2's. One Starfire Windows check covers both +crates. -**Ledger:** about −120 (JSON, coalescing) and +180. **Net about +60.** +**Build budget:** `sleipnir-daemon` only. It gains the `muninn-contracts` path +dependency and drops `serde_json` if nothing else in `main.rs` still needs it. +The Eve surface builds JSON (`S :1021-1455`), so it probably stays. + +**Ledger:** about −1,050 (listening mode ~45, JSON subscription ~50, +JSON decode and coalesce ~110, discovery ~420, removed-flag stubs ~20, their +tests ~450) and about +350 (selection and request ~120, cursor, queue and +commit ~130, tests ~250 minus the retired). **Net about −600.** ### H5: deploy, and the viewer's side -- **Sleipnir recipe** `deployment/idunn/raven-sleipnir.toml`, in the Sleipnir - repo: - - a `rust-host` build step, `cargo build --locked --release`, which builds - `vigem-client` on Windows; +**Preflight (Q13):** establish whether `raven-sleipnir` runs today (B16 says +unverified). On Raven, read: +- the scheduled task the legacy target keeps alive + (`Ops idunn-deployment-targets.ps1:126-138`); +- Sleipnir's published health `sleipnir.cultnet-rudp-input-mirror-health` on + Odin. + +If it runs, Raven's local pad mirror is live. It dials Raven's Muninn at +`10.77.0.4:17887` with JSON, and it breaks the moment Idunn deploys a +`main` that contains H2 (`raven-muninn` admits `refs/heads/main` by +`ref-head`, binding `raven-muninn.toml.in:7-9`). + +**Steps:** +- **Sleipnir recipe** `deployment/idunn/raven-sleipnir.toml` in **Muninn's** + repo, beside `raven-muninn.toml`: + - a `rust-host` build step, `cargo build --locked --release -p sleipnir-daemon + --bin sleipnir`, which builds `vigem-client` on Windows; - `[[dependencies]] odin.verse-rendezvous`, per Idunn's B3 ruling ("the recipe declares readiness"), because Sleipnir publishes its Eve surface - to Odin; - - its presence contract, modelled on `idunn_presence.rs` in Muninn; - - the binding goes in gamecult-ops. + and its requests to Odin; + - its presence contract. +- **Presence:** `M src/idunn_presence.rs` (229 lines; Muninn-specific only in + one doc line and one error string, `:14`, `:146`) moves **verbatim** into + `muninn-contracts` as `idunn_presence`. Both daemons call it. + - Delete Sleipnir's legacy `publish_idunn_health_if_configured` and + `publish_idunn_rudp_health` (`S :792-905`), their three flags + (`S :2853-2867`), and the `IdunnDaemonHealthRecord` import. That leaves + three odin-core symbols. + - Presence's rightful owner is CultLib, because every Idunn-launched daemon + needs it. Recorded under "Substrate missing"; not moved here. +- **Binding** `idunn/yggdrasil/bindings/raven-sleipnir.toml.in` in + gamecult-ops, modelled on `raven-muninn.toml.in`, with the same repository + (`GameCult/Muninn`) and a different `recipe_path`. + - In the same change, `raven-muninn.toml.in`'s argument binding + `hid_controller_rudp_advertise` (`:38`) becomes `input_rudp_advertise`. - **Retire** the legacy `raven-sleipnir` script target - (`idunn-deployment-targets.ps1:126-138`) in the same gamecult-ops change. - gamecult-ops owns it. -- **The viewer's Muninn**, per Q10. The viewer's host needs a Muninn that - captures the pad. For Starfire, that is the `starfire-muninn` target - rebound to an Idunn recipe; it is legacy today (teardown-map L74). A viewer - outside GameCult's hosts runs Muninn by hand. That limit is recorded, not - solved here. -- **Firewall:** the host that listens, per Q10, opens one UDP port. Under the - recommended direction that is the game host, Raven, which already opens - 5220 and 17887. + (`Ops idunn-deployment-targets.ps1:126-138`, `Repo = "Odin"`) and + `scripts/deploy-raven-sleipnir.ps1` / `restart-raven-sleipnir.ps1`, in the + same gamecult-ops change. gamecult-ops owns them. +- **The viewer's Muninn.** + - Starfire's is the legacy `starfire-muninn` script target + (`Ops :74-86`). `restart-starfire-muninn.ps1:14-15, 74-79` already passes + an optional HID bind and advertise; these become `--input-rudp-bind` and + `--input-rudp-advertise`, with Starfire's mesh address advertised. + - Rebinding `starfire-muninn` to an Idunn recipe is its own ops item, not + this cut. + - A viewer outside GameCult's hosts runs Muninn by hand. That limit is + recorded, not solved here. +- **Firewall:** a host whose Muninn produces input admits inbound UDP on its + input port (17887) from the game host, as a video producer does on its media port. Starfire needs one + Windows Firewall rule. It is scoped to the mesh interface where possible, + and recorded in gamecult-ops inventory beside Raven's 5220/17887. Raven + keeps 17887 open only while it also produces input for its own local + mirror. - **What only live proves.** Raven with a game, the operator present, and the - ported `xinput_edge_observer` example reading the ViGEm pad as the game - does: - - taps from Starfire's pad reach the game over LAN and over the mesh hairpin; + ported `xinput_edge_observer` example (`80adfd3`, 127 lines, into + `crates/sleipnir-daemon/examples/`) reading the ViGEm pad as the game does: + - Sleipnir on Raven finds Starfire's advertisement, requests, dials, and the + request is answered `running`; + - taps from Starfire's pad reach the game over LAN and over the mesh + hairpin; - the observer shows every tap at ≥ 24 ms visibility; - - end-to-end input latency. + - end-to-end input latency, from `edge_captured_ns` to the observer; + - a `serve` restart on Starfire resumes input with no operator action (B23). + +**Ledger (repo code only):** about −115 (legacy health) and +40 (presence +wiring). The recipe and binding are declarations. --- ## Sequencing -Two tracks, media and input. They share Muninn's `main.rs`, so their Hands -run **serially in Muninn**, and in parallel only across repositories and -worktrees. +Two tracks, media and input. From H3 on they share one repository, Muninn, +and `muninn-daemon/src/main.rs`, so their Hands run **serially in Muninn**, +and in parallel only across repositories and worktrees. **Now:** -1. **Cuts 0-1** (in Hands, `hands/rr-cut0-1`). +1. **Cuts 0-1** (merged to `main` at `c3520d1`). 2. **Retag `3e96c6c` as `pins/odin-core-cultlib-c2a9a6e`** (Q2 note). This is a tag only; Odin `main` is untouched. 3. **Muninn named cut 3** (retire `--idunn-rudp-health`). It removes one of @@ -1465,21 +1963,27 @@ worktrees. 6. **In parallel, other repos:** Cut 3 (CultLib worktree; Soul passes before the tag). -**Input track** (after step 5): -7. **H1** (`muninn-contracts`). It can run parallel to Cut 3, in a different - repo. -8. **H2** (Muninn), after Q10. -9. **H3, then H4** (Sleipnir's new repo), after Q9. There is no CultLib bump - and no new `pins/` commit: both sit on `c2a9a6e`. -10. **H5**, after Idunn B3 (the recipe declares its Odin dependency). This is - operator deployment. +**Input track:** +7. **H3** (Sleipnir into Muninn's workspace). Behaviour-neutral and + independent of named cut 1. It touches only manifests, the lock and the + imported crate, so it may run beside Cut 2 without touching `main.rs`. +8. **H1** (`muninn-contracts`), after step 5 and Q11. If Q11 rules CultLib, H1 + joins Cut 3's CultLib work and the input track waits for the pin gate + (step 12). +9. **H2 and H4** on one Hands branch, after Q12. Soul verifies each cut + separately. They **merge to `main` together, and only when H5 is ready to + deploy in the same window**, or with `raven-muninn`'s deployment brake + engaged, because `raven-muninn` deploys `main`'s head and a new-wire + Muninn breaks the legacy Sleipnir (Q13). +10. **H5**, after Idunn B3 (the recipe declares its Odin dependency), and the + Q13 preflight. This is operator deployment. **Media track:** 11. **Pin gate:** a single-purpose Odin commit bumps odin-core to Cut 3's CultLib tag, parented on `3e96c6c`, tagged - `pins/odin-core-cultlib-`. Muninn, Sleipnir and Ratatoskr then move - their pins in lockstep. **Sleipnir moves too**, because it shares - `muninn-contracts`. + `pins/odin-core-cultlib-`. Muninn's `[workspace.dependencies]` (H3) + moves once, which moves Muninn and Sleipnir together by construction. + Ratatoskr moves in lockstep. 12. **Cut 4** (Ratatoskr), then **Cut 5** (Muninn video parity). 13. **Cut 6a** (after Cut 2; independent of Cut 5), then **6b** (after Cut 5, for the ceiling), then **6c** (after `route/b3-declare` merges, Idunn B3, @@ -1498,60 +2002,96 @@ worktrees. After the ship: the Odin commit that deletes `crates/sleipnir-daemon`, and optionally Odin's own CultLib bump, which is the Odin Stability session's call. -**Recipe contention.** Only 6c edits `raven-muninn.toml`, and only on top of -`2260853` after it merges. H5 creates `raven-sleipnir.toml` in a different -repository. +**Recipe contention.** 6c and H2 both edit `raven-muninn.toml`, in different +blocks (H2: the input arguments `:62-65` and a `[[provides]]`; 6c: the encoder), +and both sit on top of `2260853` after it merges. Whichever lands second +rebases. H5 creates `raven-sleipnir.toml` beside it. **Yggdrasil load.** One job at a time for this campaign while StreamPixels' verification runs. 6a's apt install makes its jobs longer, so batch its unit -and integration runs into one job. +and integration runs into one job. H2 and H4 run as one job. --- ## Operator questions -Q1-Q8 are ruled; see the Rulings block. Two forks are open. Neither blocks the +Q1-Q10 are ruled; see the Rulings block. Three forks are open. None blocks the media track. -**Q9. Where does Sleipnir live once it changes?** +**Q11. Who owns the input-stream capability records?** - Context: - - Sleipnir is in Odin's workspace at Odin `main`'s CultLib pin (`a8aedda`). - That pin carries StreamPixels and must not move now. - - Consuming Muninn's HID records at Muninn's pin reopens the two-`cultcache-rs` - diamond inside Odin, and only moving that pin closes it (B16). + - The video capability's pair is CultLib's producer-agnostic + `gamecult.media_stream_*` (`C media_stream_contracts.rs:71-183`). Muninn, + Ratatoskr and StreamPixels' vendored copy share it across three repos. + - Input has one producer (Muninn) and one consumer (Sleipnir), and after Q9 + they share one workspace. + - Any CultLib change moves Muninn off `c2a9a6e`, and only the pin gate + closes the odin-core diamond that reopens (B11). - Options: - - (a) **Own repo `GameCult/Sleipnir`**, with history from Odin. Its pins match - Muninn's, and odin-core comes from the `pins/` tag. Odin `main` deletes its - copy after the ship. This is the Muninn and Ratatoskr pattern: one organ, - one repo, one deploy target. - - (b) **A crate in Muninn's workspace.** Cheapest: one pin set, and the - contracts crate is internal. But the capture broker's repo then also owns - the actuator on another host with different privileges (ViGEm). - - (c) **Stay in Odin, on a branch**, until the ship, then bump Odin's pin. - This puts an off-`main` body on Raven for the whole interval, which is the - drift the doctrine forbids, and it blocks H3-H5 on the ship. -- Recommendation: **(a)**. Choose (b) if a new repository's overhead (CI, - pins, a recipe) outweighs the separation for now. Both avoid Odin `main`. + - (a) **`muninn-contracts`, as `muninn.input_stream_*`.** H1 needs no CultLib + change and runs before Cut 3's tag. When a second producer or consumer + appears, the records move to CultLib under `gamecult.*`. They are live + documents republished every tick, not stored truth, so that move is a + schema rename with no migration. + - (b) **CultLib, as `gamecult.input_stream_*`,** beside the media pair. This + is the general-library shape a reasonable CultLib consumer would expect, + and it is the exact mirror. H1 joins Cut 3's CultLib tag, and the whole + input track waits for the pin gate (step 12), behind Cuts 3 and 4. +- Recommendation: **(a)**. The mechanism is mirrored exactly; only the + schema's owner differs, and it differs because the capability has one + implementation today. +- What depends on it: H1's location and ids; whether the input track waits + for the pin gate; `MuninnDocuments` and `SleipnirDocuments` registrations. -**Q10. Which end dials, for remote-play input?** +**Q12. Move evidence rides the HID listener. What happens to it?** (B20) - Context: - - Today Sleipnir dials Muninn's HID listener (Raven:17887). Muninn also has - an outbound path (B15). - - In remote play, Muninn runs on the **viewer's** host. If Sleipnir dials, - every viewer opens an inbound UDP port. - - That is what the 2026-09-10 media inversion ruled out for viewers - (teardown-map L486-495): "not something a user can be asked for". + - Muninn sends Move-evidence frames (MessagePack + `mimir.muninn_move_evidence_stream_frame.v1`) on channel `move-evidence` + of the HID connection, only to a session that sent the JSON + `hid.subscribe`. + - Mimir's runtime does that (`Mi MimirOdinMoveProofEvidenceRingProvider.cs:223`), + after finding the endpoint in the JSON `inputStreams` H2 deletes. + - Mimir's provider was last changed on 2026-07-14 (`345c46b`). Raven's + recipe sets no `--move-evidence-stream`. Nightwing and Starfire configure + it. - Options: - - (a) **The viewer's Muninn dials the game host's Sleipnir.** Sleipnir - listens and advertises its endpoint through Odin, as Muninn does for media. - Delete Muninn's ingress server. - - (b) **Keep Sleipnir dialling Muninn.** Delete Muninn's outbound client. - Every viewer admits inbound UDP. -- Recommendation: **(a)**, consistent with the media ruling. The game host - already admits UDP. Consequence: Sleipnir gains a listening - `CultNetRudpServerHub`, and **subscription** becomes Sleipnir telling each - attached Muninn which device it wants, over the same session. The records - are unchanged. + - (a) **Fold Move evidence into the input-stream capability.** Muninn + advertises a second `muninn.input_stream_advertisement.v1` per host, with + a `payload_schema` field distinguishing HID frames from Move-evidence + frames. Mimir requests it by document and dials with its `receiver_id`. + This needs a Mimir (C#) cut, "H2m", landing with H2. + - (b) **Keep a Move-evidence-only JSON `hid.subscribe` arm** on the new hub + until Mimir moves. This keeps JSON on a live wire (invariant 14) and a + second admission path (Q10), so it needs a named deletion line. + - (c) **Drop the remote Move-evidence transport**, if Mimir's move proof is + dormant. Delete the RUDP hand-off (`M :4267-4275`), `rudp_sender`, and the + bind coupling of `local_ring_enabled` (`:4238`). Mimir re-enters through + (a) when it next needs the stream. +- Recommendation: **(a) if anyone runs Mimir's move proof today, else (c).** + (b) only as a bounded bridge under (a). +- What depends on it: H2's deletion list items 2 and 7; whether a Mimir cut + joins the input track. + +**Q13. Is Raven's legacy Sleipnir live, and may its local mirror go dark +between the H2/H4 merge and H5?** +- Context: + - B16 left "whether it runs on Raven today" unverified. + - `raven-muninn` deploys `main`'s head. The moment `main` holds H2, Raven's + Muninn speaks the new wire, and a legacy JSON Sleipnir on Raven stops + receiving input. +- Options: + - (a) **Hold the H2/H4 merge until H5 deploys in the same window.** No dark + interval; the merge waits on Idunn B3. + - (b) **Merge when Soul passes, with `raven-muninn`'s deployment brake + engaged** until H5. Raven's Muninn stays on the previous release, and + media Cuts behind it wait too. + - (c) **Accept a dark interval** for Raven's local mirror, if the preflight + shows it is not in use. +- Recommendation: run H5's preflight first. If the mirror is live, choose + **(a)**. If not, choose **(c)**. (b) blocks the media track and is the + worst trade. +- What depends on it: when H2 and H4 may merge; whether H5 waits on B3 before + any of the input track lands on `main`. ---