From 254d63dd54e97aa391802443f41646b8dd8eb3d6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 4 Jul 2026 22:38:43 +0100 Subject: [PATCH 01/15] Rework Odin transport ownership and service names --- crates/sleipnir-daemon/Cargo.toml | 22 + crates/sleipnir-daemon/src/main.rs | 3394 ++++++++++++++++++++++++++++ 2 files changed, 3416 insertions(+) create mode 100644 crates/sleipnir-daemon/Cargo.toml create mode 100644 crates/sleipnir-daemon/src/main.rs diff --git a/crates/sleipnir-daemon/Cargo.toml b/crates/sleipnir-daemon/Cargo.toml new file mode 100644 index 0000000..89294ea --- /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 = { path = "../../vendor/cultmesh-rs" } +cultnet-rs = { path = "../../vendor/cultnet-rs" } +cultcache-rs = { path = "../../vendor/cultcache-rs" } +odin-core = { path = "../odin-core" } +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..1593737 --- /dev/null +++ b/crates/sleipnir-daemon/src/main.rs @@ -0,0 +1,3394 @@ +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, + SleipnirInputMappingRecord, IdunnDaemonHealthRecord, MUNINN_HID_CONTROLLER_STATE_SCHEMA, + MuninnHidControllerStateRecord, OdinDocuments, OdinEndpointQuery, 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, + ignored_stream_frames: u64, + available_devices: Vec, + axis_map: HashMap, + button_map: HashMap, + pending_learn: Option, + updated_at: String, +} + +#[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, +} + +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, + ) + }; + 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 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 record in records { + 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 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(()) => { + 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 + ); + 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, + ) + }; + 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(); + if rudp_stream + .as_ref() + .is_none_or(|stream| stream.target.is_none()) + && surface_age >= Duration::from_secs(5) + { + if let Some(record) = last_applied_record.as_ref() { + recent_applied_record = Some(record.clone()); + } + 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, + ) + }; + publish_sleipnir_runtime_surface(&mut node, &options, &runtime_state, false)?; + 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, + ) -> 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()), + 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!("{}.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 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), + })?; + 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), + })?, + 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; + if trace { + eprintln!( + "Sleipnir received fast HID frame channel={} bytes={}", + frame.channel_id, + frame.payload.len() + ); + } + records.push( + serde_json::from_slice(&frame.payload) + .context("decoding Sleipnir RUDP HID frame")?, + ); + } + 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:#}"); + } + } + Ok(coalesce_latest_hid_records_by_device(records)) +} + +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 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, + ); + + assert_eq!(state.stream_state, "connected"); + let _ = fs::remove_file(store_path); + } + + #[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); + } +} From 6d98624792ebb043bab5197da581fef8153e45ad Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 6 Jul 2026 22:11:14 +0100 Subject: [PATCH 02/15] Expose Sleipnir input mirror latency --- crates/sleipnir-daemon/src/main.rs | 338 +++++++++++++++++++++++++---- 1 file changed, 300 insertions(+), 38 deletions(-) diff --git a/crates/sleipnir-daemon/src/main.rs b/crates/sleipnir-daemon/src/main.rs index 1593737..1f4189f 100644 --- a/crates/sleipnir-daemon/src/main.rs +++ b/crates/sleipnir-daemon/src/main.rs @@ -9,8 +9,8 @@ use cultnet_rs::{ }; use odin_core::{ EVE_PROVIDER_ADVERTISEMENT_SCHEMA, EveProviderAdvertisementRecord, EveSurfaceStateRecord, - SleipnirInputMappingRecord, IdunnDaemonHealthRecord, MUNINN_HID_CONTROLLER_STATE_SCHEMA, - MuninnHidControllerStateRecord, OdinDocuments, OdinEndpointQuery, discover_provider_endpoints, + IdunnDaemonHealthRecord, MUNINN_HID_CONTROLLER_STATE_SCHEMA, MuninnHidControllerStateRecord, + OdinDocuments, OdinEndpointQuery, SleipnirInputMappingRecord, discover_provider_endpoints, }; use std::collections::HashMap; use std::env; @@ -83,6 +83,7 @@ struct SleipnirRuntimeState { 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, @@ -91,6 +92,20 @@ struct SleipnirRuntimeState { 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, @@ -136,6 +151,19 @@ struct VirtualPadState { 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 { @@ -268,11 +296,8 @@ fn main() -> Result<()> { }; 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 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 { @@ -312,21 +337,19 @@ fn main() -> Result<()> { 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, - ); + 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; @@ -340,11 +363,8 @@ fn main() -> Result<()> { 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, - ); + 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); @@ -410,7 +430,9 @@ fn main() -> Result<()> { options.trace, )?; let records = receive_rudp_records(stream, options.trace)?; - for record in records { + 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; @@ -429,6 +451,7 @@ fn main() -> Result<()> { .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() @@ -448,6 +471,15 @@ fn main() -> Result<()> { } 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 = @@ -459,6 +491,14 @@ fn main() -> Result<()> { "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); } } @@ -488,8 +528,7 @@ fn main() -> Result<()> { if options.trace { eprintln!( "Sleipnir neutralized virtual pad after {:?} without a new selected input sequence for filter={:?}", - stale_for, - current_device_filter + stale_for, current_device_filter ); } } @@ -519,6 +558,7 @@ fn main() -> Result<()> { 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)?; @@ -533,14 +573,16 @@ fn main() -> Result<()> { last_rudp_discovery_attempt_at = Instant::now(); } let surface_age = last_surface_publish_at.elapsed(); - if rudp_stream + 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()) - && surface_age >= Duration::from_secs(5) + .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)) { - if let Some(record) = last_applied_record.as_ref() { - recent_applied_record = Some(record.clone()); - } let runtime_state = { let discovery_ref = discovery_node.as_ref().unwrap_or(&node); SleipnirRuntimeState::from_runtime( @@ -551,9 +593,15 @@ fn main() -> Result<()> { recent_applied_record.as_ref(), ignored_stream_frames, &desired_mapping, + recent_input_latency.as_ref(), ) }; - publish_sleipnir_runtime_surface(&mut node, &options, &runtime_state, false)?; + publish_sleipnir_runtime_surface( + &mut node, + &options, + &runtime_state, + has_new_applied_record, + )?; publish_idunn_health_if_configured( &options, &runtime_state, @@ -578,6 +626,7 @@ impl SleipnirRuntimeState { 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) @@ -628,6 +677,7 @@ impl SleipnirRuntimeState { 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(), @@ -917,7 +967,9 @@ fn pull_odin_catalog_snapshot(node: &mut cultmesh_rs::CultMeshNode, options: &Op } Ok(_) => {} Err(error) if options.trace => { - eprintln!("Sleipnir Odin Muninn provider snapshot pull failed from {target}: {error:#}"); + eprintln!( + "Sleipnir Odin Muninn provider snapshot pull failed from {target}: {error:#}" + ); } Err(_) => {} } @@ -1089,6 +1141,20 @@ fn sleipnir_surface_document(state: &SleipnirRuntimeState) -> serde_json::Value 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", @@ -1107,6 +1173,60 @@ fn sleipnir_surface_document(state: &SleipnirRuntimeState) -> serde_json::Value }) } +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( @@ -2355,16 +2475,15 @@ fn stream_host_hint(stream_id: Option<&str>) -> Option { (!normalized.is_empty()).then_some(normalized) } -fn receive_rudp_records( - stream: &mut ActiveRudpStream, - trace: bool, -) -> Result> { +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={}", @@ -2372,10 +2491,15 @@ fn receive_rudp_records( frame.payload.len() ); } - records.push( - serde_json::from_slice(&frame.payload) + 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; @@ -2406,7 +2530,36 @@ fn receive_rudp_records( eprintln!("Sleipnir RUDP resend warning: {error:#}"); } } - Ok(coalesce_latest_hid_records_by_device(records)) + 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( @@ -2456,6 +2609,69 @@ fn record_matches_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, @@ -2615,11 +2831,13 @@ impl Options { "--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-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")?; @@ -2890,12 +3108,56 @@ mod tests { 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!({ From e184f48adf398bf08e683a5a8c1a760803565249 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 4 Sep 2026 14:54:58 +0200 Subject: [PATCH 03/15] Consume CultCache and CultNet from CultLib instead of forking them The CultNet submodule and the in-tree CultCache copy are gone. Both now come from CultLib, and vendor/cultmesh-rs is the only fork left -- it too consumes CultLib, so exactly one version of each crate spans the graph. That mattered: mixing sources would have put two cultnet-rs crates in one build, and the types would not have unified. The in-flight CultMesh RUDP document server comes with it because it is a prerequisite, not a coincidence. The previously committed CultMesh calls assume_connected(), a CultNet method Odin's fork carried and CultLib does not, so the old code cannot build against CultLib at all. The newer code no longer calls it. Verified by reverting the file and watching it fail. Three API differences needed adapting: CultLib's transport options carry a reconnect_policy the fork lacked (7 sites), its create_ack builds the packet straight from ack state and so cannot fail, and its transport profile owns each channel's reliable expiry. Everything but odin-daemon builds; odin-daemon is Unix-only and does not compile on this machine either way. muninn and sleipnir pass 162 tests. The one failure, rudp_media_transport_options_follow_low_latency_profile, expects an Unreliable media channel while every CultNet lineage including both revisions Odin had pinned declares it Reliable, so it failed before this change too. The in-flight idunn-daemon work is deliberately left uncommitted; the committed tree was verified to build without it. Also left alone: the untracked odin-daemon crate and deployment recipe, and the root manifest, whose now-dead vendor excludes are inert. That recipe still requires the vendor/cultnet-rs gitlink and will refuse admission until that line is dropped -- the fix is in the working tree, waiting on that directory being tracked. Co-Authored-By: Claude Opus 5 --- crates/sleipnir-daemon/Cargo.toml | 4 ++-- crates/sleipnir-daemon/src/main.rs | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/sleipnir-daemon/Cargo.toml b/crates/sleipnir-daemon/Cargo.toml index 89294ea..f08651a 100644 --- a/crates/sleipnir-daemon/Cargo.toml +++ b/crates/sleipnir-daemon/Cargo.toml @@ -12,8 +12,8 @@ path = "src/main.rs" [dependencies] anyhow = "1" cultmesh-rs = { path = "../../vendor/cultmesh-rs" } -cultnet-rs = { path = "../../vendor/cultnet-rs" } -cultcache-rs = { path = "../../vendor/cultcache-rs" } +cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "cbde940df2f5ee0ccac50e8dbfe9f36ffbebb585", package = "cultnet-rs" } +cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "cbde940df2f5ee0ccac50e8dbfe9f36ffbebb585", package = "cultcache-rs" } odin-core = { path = "../odin-core" } rmp-serde = "1.3" serde_json = "1" diff --git a/crates/sleipnir-daemon/src/main.rs b/crates/sleipnir-daemon/src/main.rs index 1f4189f..29be8d3 100644 --- a/crates/sleipnir-daemon/src/main.rs +++ b/crates/sleipnir-daemon/src/main.rs @@ -2227,6 +2227,7 @@ fn create_rudp_stream( max_fragment_bytes: Some(HID_RUDP_MAX_FRAGMENT_BYTES), max_pending_reliable_packets: Some(256), media_reliable_expire_after_ms: Some(25), + reconnect_policy: None, })?; transport.connect(Vec::new())?; return Ok(Some(ActiveRudpStream { @@ -2267,6 +2268,7 @@ fn create_rudp_stream( max_fragment_bytes: Some(HID_RUDP_MAX_FRAGMENT_BYTES), max_pending_reliable_packets: Some(256), media_reliable_expire_after_ms: Some(25), + reconnect_policy: None, })?, target: None, last_connect_attempt_at: None, From f2f65906601c57b9037d2648f2a8170e4d2f7853 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Fri, 4 Sep 2026 15:28:32 +0200 Subject: [PATCH 04/15] Apply rustfmt The drift was 94 hunks across 4 files, not 94 files as I described it earlier. muninn-daemon carries almost all of it; sleipnir-daemon needed four lines. Checked rather than assumed. Ignoring whitespace, the only remaining deltas are trailing commas rustfmt adds when it wraps an argument list, and braces removed from single-expression closure bodies. Both are semantics-preserving. sleipnir-daemon's change is whitespace and nothing else. Everything except the Unix-only odin-daemon builds; muninn and sleipnir pass 162 tests, with the same pre-existing media-channel failure. vendor/cultmesh-rs is its own workspace so `cargo fmt` does not reach it, but it reports no drift of its own. Co-Authored-By: Claude Opus 5 --- crates/sleipnir-daemon/src/main.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/sleipnir-daemon/src/main.rs b/crates/sleipnir-daemon/src/main.rs index 29be8d3..06b056f 100644 --- a/crates/sleipnir-daemon/src/main.rs +++ b/crates/sleipnir-daemon/src/main.rs @@ -2227,7 +2227,7 @@ fn create_rudp_stream( max_fragment_bytes: Some(HID_RUDP_MAX_FRAGMENT_BYTES), max_pending_reliable_packets: Some(256), media_reliable_expire_after_ms: Some(25), - reconnect_policy: None, + reconnect_policy: None, })?; transport.connect(Vec::new())?; return Ok(Some(ActiveRudpStream { @@ -2268,7 +2268,7 @@ fn create_rudp_stream( max_fragment_bytes: Some(HID_RUDP_MAX_FRAGMENT_BYTES), max_pending_reliable_packets: Some(256), media_reliable_expire_after_ms: Some(25), - reconnect_policy: None, + reconnect_policy: None, })?, target: None, last_connect_attempt_at: None, From 731e0ed69ef271c076ac277cba5337d014375e56 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 5 Sep 2026 17:35:54 +0200 Subject: [PATCH 05/15] Bump CultLib to ae0ac0d and give odin-core its missing rmp-serde dev-dep Moves all six CultLib pins from cbde940 to ae0ac0d, which carries the reconciled cultmesh-rs. The delta is three commits and near-nothing in the packages Odin consumes: a license line, two lines in cultnet-rs replication, and one additive pub fn. The dev-dependency is a real pre-existing defect, not fallout from the bump. odin-core's tests use rmp_serde in crates/odin-core/src/idunn.rs but never declared it, so the lib-test target has never compiled. Verified pre-existing: 9 errors at the old pin, 8 at the new. With the dep declared, odin-core tests compile and 22 pass -- including idunn_records_round_trip_through_cultmesh, which matters for the extraction this branch is preparing. Note for anyone checking this on Windows: odin-daemon does not build here and that is expected, not a regression. It imports std::os::fd and from_raw_fd, which are Unix-only. Identical failure at both pins. Co-Authored-By: Claude Opus 5 --- crates/sleipnir-daemon/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/sleipnir-daemon/Cargo.toml b/crates/sleipnir-daemon/Cargo.toml index f08651a..430f45d 100644 --- a/crates/sleipnir-daemon/Cargo.toml +++ b/crates/sleipnir-daemon/Cargo.toml @@ -12,8 +12,8 @@ path = "src/main.rs" [dependencies] anyhow = "1" cultmesh-rs = { path = "../../vendor/cultmesh-rs" } -cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "cbde940df2f5ee0ccac50e8dbfe9f36ffbebb585", package = "cultnet-rs" } -cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "cbde940df2f5ee0ccac50e8dbfe9f36ffbebb585", package = "cultcache-rs" } +cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "ae0ac0d3c92ccaee1a7eee2bbe033740c9ea2e55", package = "cultnet-rs" } +cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "ae0ac0d3c92ccaee1a7eee2bbe033740c9ea2e55", package = "cultcache-rs" } odin-core = { path = "../odin-core" } rmp-serde = "1.3" serde_json = "1" From b5976d063d7ed19bfffb240ae8a6cf6221d965bc Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 5 Sep 2026 17:43:24 +0200 Subject: [PATCH 06/15] Sever idunn-daemon's last import from odin-core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IdunnDaemonHealthTrustBindingRecord and its schema constant moved to cultnet-rs (GameCult/CultLib@c13b6ba). odin-core now imports them from cultnet_rs instead of defining them, and idunn-daemon/provisioning.rs pulls all six provider-health symbols from cultnet_rs directly, merged into its existing cultnet_rs use block. Dropped the odin-core path dependency from idunn-daemon/Cargo.toml — nothing in the crate reaches odin_core:: anymore. Bumped the CultLib git rev across all six consuming manifests to the new commit. Co-Authored-By: Claude Opus 5 --- crates/sleipnir-daemon/Cargo.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/sleipnir-daemon/Cargo.toml b/crates/sleipnir-daemon/Cargo.toml index 430f45d..71fcba5 100644 --- a/crates/sleipnir-daemon/Cargo.toml +++ b/crates/sleipnir-daemon/Cargo.toml @@ -12,8 +12,8 @@ path = "src/main.rs" [dependencies] anyhow = "1" cultmesh-rs = { path = "../../vendor/cultmesh-rs" } -cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "ae0ac0d3c92ccaee1a7eee2bbe033740c9ea2e55", package = "cultnet-rs" } -cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "ae0ac0d3c92ccaee1a7eee2bbe033740c9ea2e55", package = "cultcache-rs" } +cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "c13b6ba07e99f2535b455da74d07d88c308731f5", package = "cultnet-rs" } +cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "c13b6ba07e99f2535b455da74d07d88c308731f5", package = "cultcache-rs" } odin-core = { path = "../odin-core" } rmp-serde = "1.3" serde_json = "1" From eccd6e0b27f75f5edbe62f0883564722697d916e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 5 Sep 2026 18:09:11 +0200 Subject: [PATCH 07/15] Cut idunn-daemon and the vendored cultmesh fork out of Odin Idunn now lives at https://github.com/GameCult/Idunn with its full 136-commit history. Odin no longer contains the deployment authority that manages it. Also removes vendor/cultmesh-rs, the private fork that had diverged from CultLib in both directions. Its unique capability -- the RUDP document server and the message_id correlation rework -- was reconciled into CultLib first, so this deletes a duplicate rather than a capability. The four crates that consumed it by path (muninn-daemon, odin-core, odin-daemon, sleipnir-daemon) now consume CultLib at c13b6ba like everything else. What deliberately stays in Odin: crates/odin-core/src/idunn.rs, which is Odin's own projection of Idunn state for aggregation and is Odin's to own; src/odin/idunn-rudp.cjs, its client for talking to Idunn; and deployment/idunn/recipe.toml, which despite the path is Odin's own deployment recipe expressed in Idunn's format -- it builds odin-daemon. odin-core: 22 passed, 0 failed, unchanged across the cut. odin-daemon still fails to compile on Windows for the pre-existing Unix-only std::os::fd reason. Co-Authored-By: Claude Opus 5 --- crates/sleipnir-daemon/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/sleipnir-daemon/Cargo.toml b/crates/sleipnir-daemon/Cargo.toml index 71fcba5..6179ff5 100644 --- a/crates/sleipnir-daemon/Cargo.toml +++ b/crates/sleipnir-daemon/Cargo.toml @@ -11,7 +11,7 @@ path = "src/main.rs" [dependencies] anyhow = "1" -cultmesh-rs = { path = "../../vendor/cultmesh-rs" } +cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "c13b6ba07e99f2535b455da74d07d88c308731f5", package = "cultmesh-rs" } cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "c13b6ba07e99f2535b455da74d07d88c308731f5", package = "cultnet-rs" } cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "c13b6ba07e99f2535b455da74d07d88c308731f5", package = "cultcache-rs" } odin-core = { path = "../odin-core" } From f6fea00413276393bcad62f02f585ad9022ac8e1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 10 Sep 2026 12:04:12 +0200 Subject: [PATCH 08/15] Bump CultLib to a8aedda for bounded fragment reassembly cultnet-rs removed a fragment buffer only on successful reassembly and refused new fragmented payloads once 64 sets were stranded, so any sustained loss on a fragmenting channel ended with the receiver returning "RUDP pending fragment-set limit reached" for every subsequent fragmented payload. Odin is the rendezvous every other target is observed through; a receiver that stops accepting fragmented traffic after a bounded number of losses is the wrong failure mode for that role. Measured on Raven at 3% loss, 12 Mbps: the old receiver dies at 3 and 6 fragments per payload, the new one delivers 91.0% and 82.7%, and the loss amplification matches 1-(1-p)^n exactly. The pin also carries the media stream contracts and the media delivery option, neither of which Odin uses, and the TypeScript and Python halves of the same eviction fix. All 11 CultLib revs across odin-core, odin-daemon, muninn-daemon and sleipnir-daemon move together; leaving any behind gives two cultcache-rs copies and a DatabaseEntry that is not DatabaseEntry. cargo test -p odin-core -p odin-daemon -> 22 passed, 10 passed, 4 passed, 0 failed muninn-psmoveapi-tracker still fails to link in the build image for want of libpsmoveapi. It declares no CultLib dependency, so the pin cannot reach it; that is a missing native library on the builder. Co-Authored-By: Claude Opus 5 --- crates/sleipnir-daemon/Cargo.toml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/sleipnir-daemon/Cargo.toml b/crates/sleipnir-daemon/Cargo.toml index 6179ff5..c0372a4 100644 --- a/crates/sleipnir-daemon/Cargo.toml +++ b/crates/sleipnir-daemon/Cargo.toml @@ -11,9 +11,9 @@ path = "src/main.rs" [dependencies] anyhow = "1" -cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "c13b6ba07e99f2535b455da74d07d88c308731f5", package = "cultmesh-rs" } -cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "c13b6ba07e99f2535b455da74d07d88c308731f5", package = "cultnet-rs" } -cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "c13b6ba07e99f2535b455da74d07d88c308731f5", package = "cultcache-rs" } +cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a8aeddacab7dd4b9e634b4420867ea3b402cb858", package = "cultmesh-rs" } +cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a8aeddacab7dd4b9e634b4420867ea3b402cb858", package = "cultnet-rs" } +cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a8aeddacab7dd4b9e634b4420867ea3b402cb858", package = "cultcache-rs" } odin-core = { path = "../odin-core" } rmp-serde = "1.3" serde_json = "1" From 22608537276a58fa4493d7d30fa70f4f03b5bd22 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 12:51:59 +0200 Subject: [PATCH 09/15] Declare the Odin dependency raven-muninn publishes to Idunn no longer infers how a target proves readiness, and a recipe with neither a route nor an Odin dependency is refused at admission. raven-muninn has no route and publishes to Odin over CultMesh RUDP, so its recipe declares the odin.verse-rendezvous dependency. Co-Authored-By: Claude Sonnet 5 --- deployment/idunn/raven-muninn.toml | 11 +++++++++++ 1 file changed, 11 insertions(+) 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" From fe33ce7f107974638e2786ada3d2d462ba73930c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 19:55:22 +0200 Subject: [PATCH 10/15] Re-map the HID cuts H0-H5 to Q9 and Q10 (Imagination pass 3) Sleipnir moves into the Muninn workspace with its history. Input mirrors the media request path: Muninn advertises an input stream on CultMesh, the consumer requests it and dials. Opens Q11-Q13. Co-Authored-By: Claude Opus 5.5 --- docs/realtime-robustness-cut.md | 1044 +++++++++++++++++++++++-------- 1 file changed, 786 insertions(+), 258 deletions(-) diff --git a/docs/realtime-robustness-cut.md b/docs/realtime-robustness-cut.md index a18d713..f52f1ef 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 @@ -130,16 +130,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 +336,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 +388,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 +466,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 +535,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 +1235,74 @@ 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. | + +**Consequence the operator accepted in Q10:** the producer is on the viewer, so +**the viewer's host admits inbound UDP** for input. That is the cost the +2026-09-10 media inversion refused for video viewers (teardown-map +L486-495). For input it is ruled acceptable. H5 records the firewall rule per +viewer host. + +**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 +1310,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 +1394,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 +1432,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, per the Q10 consequence:** each **viewer** host admits inbound + UDP on its input port (17887) from the game host. 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 +1951,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 +1990,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`. --- From 83b3947a8f7923d876e1a75eb7bd1f72f82131a7 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 19:58:10 +0200 Subject: [PATCH 11/15] Record Q11 (muninn-contracts) and Q12 (fold Move evidence in) Co-Authored-By: Claude Opus 5.5 --- docs/realtime-robustness-cut.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/realtime-robustness-cut.md b/docs/realtime-robustness-cut.md index f52f1ef..478562d 100644 --- a/docs/realtime-robustness-cut.md +++ b/docs/realtime-robustness-cut.md @@ -68,6 +68,13 @@ the ruled design. The earlier options survive only in git (`e70db69`). and Sleipnir requests it the way Ratatoskr requests video. The HID cuts H0-H5 need re-mapping to this shape before Hands. +**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 is open.** Run H5's preflight first, to find out whether Raven's legacy Sleipnir is live. + ## Target: ends and invariants **End.** A Muninn stream that loses packets degrades by what each signal class From 8d869ca8dfbd9e9f7deec0c5be8b1bbec5cda83b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:01:33 +0200 Subject: [PATCH 12/15] Record Q13: no live Sleipnir on Raven, H2 and H4 merge independently Co-Authored-By: Claude Opus 5.5 --- docs/realtime-robustness-cut.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/realtime-robustness-cut.md b/docs/realtime-robustness-cut.md index 478562d..cd5b568 100644 --- a/docs/realtime-robustness-cut.md +++ b/docs/realtime-robustness-cut.md @@ -73,7 +73,11 @@ the ruled design. The earlier options survive only in git (`e70db69`). 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 is open.** Run H5's preflight first, to find out whether Raven's legacy Sleipnir is live. +- **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 From d564543b70a961466be770e6170c1e4c4d564283 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:05:34 +0200 Subject: [PATCH 13/15] State the stream rule once: consumers dial the producing Muninn The input firewall rule is what any producing Muninn needs, not an input exception or an accepted cost of Q10. Co-Authored-By: Claude Opus 5.5 --- docs/realtime-robustness-cut.md | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/docs/realtime-robustness-cut.md b/docs/realtime-robustness-cut.md index cd5b568..24816fb 100644 --- a/docs/realtime-robustness-cut.md +++ b/docs/realtime-robustness-cut.md @@ -65,8 +65,8 @@ 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 @@ -1279,11 +1279,12 @@ that request path, read at the SHAs above. | 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. | -**Consequence the operator accepted in Q10:** the producer is on the viewer, so -**the viewer's host admits inbound UDP** for input. That is the cost the -2026-09-10 media inversion refused for video viewers (teardown-map -L486-495). For input it is ruled acceptable. H5 records the firewall rule per -viewer host. +**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: @@ -1922,8 +1923,8 @@ If it runs, Raven's local pad mirror is live. It dials Raven's Muninn at this cut. - A viewer outside GameCult's hosts runs Muninn by hand. That limit is recorded, not solved here. -- **Firewall, per the Q10 consequence:** each **viewer** host admits inbound - UDP on its input port (17887) from the game host. Starfire needs one +- **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 From 6a628ee551f115093c64f79e4e22317f8948e10a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:11:40 +0200 Subject: [PATCH 14/15] Hoist the CultLib and odin-core pins into one workspace table Sleipnir joins the workspace at Muninn's pins (CultLib c2a9a6e, odin-core Odin 3e96c6c) instead of Odin main's a8aedda and a path dependency. Muninn and Sleipnir now move together by construction. Manifests and lock only; no source change. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 23 +++++++++++++++++++++++ Cargo.toml | 9 +++++++++ crates/muninn-daemon/Cargo.toml | 8 ++++---- crates/sleipnir-daemon/Cargo.toml | 8 ++++---- 4 files changed, 40 insertions(+), 8 deletions(-) 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 index c0372a4..b10f048 100644 --- a/crates/sleipnir-daemon/Cargo.toml +++ b/crates/sleipnir-daemon/Cargo.toml @@ -11,10 +11,10 @@ path = "src/main.rs" [dependencies] anyhow = "1" -cultmesh-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a8aeddacab7dd4b9e634b4420867ea3b402cb858", package = "cultmesh-rs" } -cultnet-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a8aeddacab7dd4b9e634b4420867ea3b402cb858", package = "cultnet-rs" } -cultcache-rs = { git = "https://github.com/GameCult/CultLib.git", rev = "a8aeddacab7dd4b9e634b4420867ea3b402cb858", package = "cultcache-rs" } -odin-core = { path = "../odin-core" } +cultmesh-rs = { workspace = true } +cultnet-rs = { workspace = true } +cultcache-rs = { workspace = true } +odin-core = { workspace = true } rmp-serde = "1.3" serde_json = "1" From ed02890c12b50d89384ddfaf4607d1e0fbd875e2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Tue, 29 Sep 2026 20:16:07 +0200 Subject: [PATCH 15/15] Give Sleipnir the media_delivery field CultLib c2a9a6e requires Odin main's Sleipnir was written against CultLib a8aedda, and its two CultNetRudpSocketTransportOptions initialisers do not compile at Muninn's c2a9a6e pin. media_delivery: None is the field's default, so behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 --- crates/sleipnir-daemon/src/main.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/sleipnir-daemon/src/main.rs b/crates/sleipnir-daemon/src/main.rs index 06b056f..1be0193 100644 --- a/crates/sleipnir-daemon/src/main.rs +++ b/crates/sleipnir-daemon/src/main.rs @@ -2227,6 +2227,7 @@ fn create_rudp_stream( 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())?; @@ -2268,6 +2269,7 @@ fn create_rudp_stream( 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,