From d41c744b2ee4bdb1df3f54bd3973febb2b4f2ad3 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Thu, 10 Sep 2026 23:39:16 +0200 Subject: [PATCH] Muninn has one body: drop the workspace copy The three muninn crates, their seventeen actuator scripts, the two Raven capture-mux wrappers that called one of them, and the two Muninn docs all leave this workspace. GameCult/Muninn has carried them since 2026-09-09 and has moved on (media stream advertisement, receivers dialling the producer); this copy was a fork drifting behind under the same name, and gamecult-ops now binds every Muninn target to the Muninn repository. Odin keeps the Muninn records in odin-core until they are moved; that is the remaining piece of the extraction. Co-Authored-By: Claude Fable 5.1 --- AGENTS.md | 5 +- Cargo.lock | 27 - Cargo.toml | 3 - crates/muninn-daemon/Cargo.toml | 40 - crates/muninn-daemon/src/main.rs | 14567 ---------------- crates/muninn-daemon/src/media_packetizer.rs | 4244 ----- crates/muninn-move-tracker/Cargo.toml | 11 - crates/muninn-move-tracker/README.md | 31 - .../shaders/MoveSphereCandidate.comp.hlsl | 106 - crates/muninn-move-tracker/src/lib.rs | 308 - crates/muninn-psmoveapi-tracker/Cargo.toml | 10 - crates/muninn-psmoveapi-tracker/src/lib.rs | 359 - docs/architecture.md | 4 +- docs/muninn-media-streaming.md | 374 - docs/muninn.md | 262 - scripts/activate-muninn-raven-av-srt.cmd | 2 - scripts/activate-muninn-raven-media.ps1 | 1 - scripts/deploy-raven-muninn-binary.ps1 | 321 - scripts/health-muninn.cmd | 3 - scripts/health-muninn.ps1 | 217 - scripts/health-nightwing-muninn.cmd | 3 - scripts/health-nightwing-muninn.ps1 | 114 - scripts/health-starfire-muninn.cmd | 3 - scripts/health-starfire-muninn.ps1 | 50 - scripts/repair-raven-muninn-task-actions.ps1 | 156 - scripts/restart-muninn.cmd | 10 - scripts/restart-muninn.ps1 | 897 - scripts/restart-nightwing-muninn.cmd | 10 - scripts/restart-nightwing-muninn.ps1 | 190 - scripts/restart-raven-capture-mux.cmd | 2 - scripts/restart-raven-capture-mux.ps1 | 7 - scripts/restart-starfire-muninn.cmd | 10 - scripts/restart-starfire-muninn.ps1 | 174 - scripts/verify-muninn-media-profile.ps1 | 121 - 34 files changed, 4 insertions(+), 22638 deletions(-) delete mode 100644 crates/muninn-daemon/Cargo.toml delete mode 100644 crates/muninn-daemon/src/main.rs delete mode 100644 crates/muninn-daemon/src/media_packetizer.rs delete mode 100644 crates/muninn-move-tracker/Cargo.toml delete mode 100644 crates/muninn-move-tracker/README.md delete mode 100644 crates/muninn-move-tracker/shaders/MoveSphereCandidate.comp.hlsl delete mode 100644 crates/muninn-move-tracker/src/lib.rs delete mode 100644 crates/muninn-psmoveapi-tracker/Cargo.toml delete mode 100644 crates/muninn-psmoveapi-tracker/src/lib.rs delete mode 100644 docs/muninn-media-streaming.md delete mode 100644 docs/muninn.md delete mode 100644 scripts/activate-muninn-raven-av-srt.cmd delete mode 100644 scripts/activate-muninn-raven-media.ps1 delete mode 100644 scripts/deploy-raven-muninn-binary.ps1 delete mode 100644 scripts/health-muninn.cmd delete mode 100644 scripts/health-muninn.ps1 delete mode 100644 scripts/health-nightwing-muninn.cmd delete mode 100644 scripts/health-nightwing-muninn.ps1 delete mode 100644 scripts/health-starfire-muninn.cmd delete mode 100644 scripts/health-starfire-muninn.ps1 delete mode 100644 scripts/repair-raven-muninn-task-actions.ps1 delete mode 100644 scripts/restart-muninn.cmd delete mode 100644 scripts/restart-muninn.ps1 delete mode 100644 scripts/restart-nightwing-muninn.cmd delete mode 100644 scripts/restart-nightwing-muninn.ps1 delete mode 100644 scripts/restart-raven-capture-mux.cmd delete mode 100644 scripts/restart-raven-capture-mux.ps1 delete mode 100644 scripts/restart-starfire-muninn.cmd delete mode 100644 scripts/restart-starfire-muninn.ps1 delete mode 100644 scripts/verify-muninn-media-profile.ps1 diff --git a/AGENTS.md b/AGENTS.md index d5777bf..c52f9d6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,9 +51,8 @@ ssh ygg 'sudo docker run --rm -v /srv/build/odin:/w -v /etc/machine-id:/etc/mach with `Linux machine-id is unavailable` — a wall of failures that looks like broken code and is a missing mount. -`muninn-psmoveapi-tracker` fails to link in that image for want of -`libpsmoveapi`. It declares no CultLib dependency; scope test runs to the crates -you are changing. +Scope test runs to the crates you are changing. Muninn's crates live in +`GameCult/Muninn` now, not in this workspace. ## Session Bootstrap And Re-entry Protocol diff --git a/Cargo.lock b/Cargo.lock index 366ec20..5aeea89 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -573,33 +573,6 @@ version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" -[[package]] -name = "muninn-daemon" -version = "0.1.0" -dependencies = [ - "anyhow", - "cultmesh-rs", - "cultnet-rs", - "libc", - "muninn-move-tracker", - "muninn-psmoveapi-tracker", - "odin-core", - "rmp-serde", - "serde", - "serde_bytes", - "serde_json", - "socket2", - "windows-sys 0.59.0", -] - -[[package]] -name = "muninn-move-tracker" -version = "0.1.0" - -[[package]] -name = "muninn-psmoveapi-tracker" -version = "0.1.0" - [[package]] name = "num-traits" version = "0.2.19" diff --git a/Cargo.toml b/Cargo.toml index 6b4c955..dff69f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,6 @@ [workspace] members = [ "crates/sleipnir-daemon", - "crates/muninn-daemon", - "crates/muninn-move-tracker", - "crates/muninn-psmoveapi-tracker", "crates/odin-core", "crates/odin-daemon", ] diff --git a/crates/muninn-daemon/Cargo.toml b/crates/muninn-daemon/Cargo.toml deleted file mode 100644 index 3710975..0000000 --- a/crates/muninn-daemon/Cargo.toml +++ /dev/null @@ -1,40 +0,0 @@ -[package] -name = "muninn-daemon" -version = "0.1.0" -edition.workspace = true -license.workspace = true -publish.workspace = true - -[features] -default = [] -psmoveapi-tracker = ["dep:muninn-psmoveapi-tracker"] - -[[bin]] -name = "muninn" -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" } -libc = "0.2" -muninn-move-tracker = { path = "../muninn-move-tracker" } -muninn-psmoveapi-tracker = { path = "../muninn-psmoveapi-tracker", optional = true } -odin-core = { path = "../odin-core" } -rmp-serde = "1.3" -serde = { version = "1", features = ["derive"] } -serde_bytes = "0.11" -serde_json = "1" -socket2 = "0.5" - -[target.'cfg(windows)'.dependencies] -windows-sys = { version = "0.59", features = [ - "Win32_Devices_DeviceAndDriverInstallation", - "Win32_Devices_HumanInterfaceDevice", - "Win32_Foundation", - "Win32_Security", - "Win32_Storage_FileSystem", - "Win32_System_IO", - "Win32_System_Threading", - "Win32_UI_Input_XboxController", -] } diff --git a/crates/muninn-daemon/src/main.rs b/crates/muninn-daemon/src/main.rs deleted file mode 100644 index 6fed050..0000000 --- a/crates/muninn-daemon/src/main.rs +++ /dev/null @@ -1,14567 +0,0 @@ -mod media_packetizer; - -use crate::media_packetizer::{ - AudioPcmStreamSendConfig, AudioPcmStreamSendState, MuninnMediaSendPayload, - MuninnMediaWireRecord, VideoAnnexBStreamSendConfig, VideoAnnexBStreamSendState, - decode_media_wire_record, -}; -use anyhow::{Context, Result, anyhow}; -use cultmesh_rs::{ - CultMesh, CultMeshNodeOptions, CultMeshRudpDocumentPublishOptions, CultMeshRudpSnapshotOptions, - CultMeshSharedMemoryFrameRing, CultMeshStreamBodyTransport, CultMeshStreamCatalog, - CultMeshStreamClock, CultMeshStreamDescriptor, CultMeshStreamKind, -}; -use cultnet_rs::{ - CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, - CultNetRudpSocketTransportConnection, CultNetRudpSocketTransportOptions, CultNetTransportFrame, - CultNetWireContract, decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, -}; -#[cfg(feature = "psmoveapi-tracker")] -use odin_core::MuninnMoveTrackerHealthRecord; -use odin_core::{ - EVE_PROVIDER_ADVERTISEMENT_SCHEMA, EveProviderAdvertisementRecord, EveSurfaceStateRecord, - IdunnDaemonHealthRecord, MUNINN_MOVE_HUE_PROGRAM_SCHEMA, MUNINN_OBS_STREAM_CATALOG_SCHEMA, - MuninnCaptureStreamCommandRecord, MuninnCaptureStreamRecord, MuninnCommandBoundaryCompatRecord, - MuninnHidControllerStateRecord, MuninnMediaReceiverFeedbackRecord, - MuninnMoveControllerStateRecord, MuninnMoveEvidenceTransportHealthRecord, - MuninnMoveHueProgramRecord, MuninnMoveIdentityRecord, MuninnMoveLightCommandRecord, - MuninnMoveMarkerCandidateRecord, MuninnObsStreamCatalogRecord, MuninnQuestAccessRecord, - MuninnTelemetrySurfaceRecord, MuninnTransportProfileCompatRecord, OdinDocuments, - OdinEndpointQuery, discover_provider_endpoints, -}; -use serde::{Deserialize, Serialize}; -use serde_json::json; -use std::collections::{HashMap, HashSet, VecDeque}; -use std::env; -use std::fs; -#[cfg(feature = "psmoveapi-tracker")] -use std::io::BufReader; -#[cfg(any(not(windows), feature = "psmoveapi-tracker"))] -use std::io::Write; -use std::io::{ErrorKind, Read}; -use std::net::{SocketAddr, UdpSocket}; -use std::path::Path; -use std::path::PathBuf; -use std::process::{Child, Command, Stdio}; -use std::sync::{ - Arc, Mutex, - atomic::{AtomicU64, Ordering}, - mpsc, -}; -use std::thread; -use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - -#[cfg(windows)] -use std::ffi::OsStr; -#[cfg(unix)] -use std::os::fd::AsRawFd; -#[cfg(unix)] -use std::os::unix::fs::OpenOptionsExt; -#[cfg(windows)] -use std::os::windows::ffi::OsStrExt; -#[cfg(windows)] -use windows_sys::Win32::UI::Input::XboxController::{XINPUT_GAMEPAD, XINPUT_STATE, XInputGetState}; - -const CULTNET_RUDP_PROTOCOL_ID: &str = "cultnet.transport.rudp.v0"; -const MUNINN_MEDIA_RUDP_SCHEMA: &str = "muninn.media.rudp.v1"; -const IDUNN_HEALTH_RUDP_CONNECTION_ID: u32 = 0x1d0d_0001; -const MUNINN_MEDIA_RUDP_CONNECTION_ID: u32 = 0x6d75_0001; -const MUNINN_AUDIO_RUDP_CONNECTION_ID: u32 = 0x6d75_0004; -const MUNINN_HID_CONTROLLER_RUDP_CONNECTION_ID: u32 = 0x6d75_0005; -const MUNINN_COMMAND_RUDP_CONNECTION_ID: u32 = 0x61e0_0001; -const MUNINN_RUDP_MEDIA_PROFILE_ID: &str = "muninn.rudp.low_latency_h264_lan.v1"; -const MUNINN_RUDP_MEDIA_VIDEO_BITRATE_KBPS: u32 = 12_000; -const MUNINN_RUDP_MEDIA_VBV_FRAME_BUDGETS: u32 = 1; -const MUNINN_RUDP_MEDIA_LOW_DELAY_KEY_FRAME_SCALE: u32 = 4; -const MUNINN_RUDP_MEDIA_VIDEO_DPB_SIZE: u32 = 1; -const MUNINN_RUDP_MEDIA_PACKET_BYTES: usize = 848; -const MUNINN_RUDP_IPV4_UDP_PAYLOAD_BYTES: usize = 1_472; -const MUNINN_RUDP_FIXED_HEADER_BYTES: usize = 36; -const MUNINN_RUDP_MEDIA_MAX_FRAGMENT_BYTES: usize = MUNINN_RUDP_IPV4_UDP_PAYLOAD_BYTES - - MUNINN_RUDP_FIXED_HEADER_BYTES - - crate::media_packetizer::MUNINN_MEDIA_RUDP_CHANNEL.len(); -const MUNINN_RUDP_MEDIA_RESEND_DELAY_MS: u64 = 5; -const MUNINN_RUDP_MEDIA_RECEIVER_ASSEMBLY_DEADLINE_MS: u64 = 2_000; -const MUNINN_RUDP_MEDIA_RECEIVER_GAP_WAIT_MS: u64 = 16; -const MUNINN_RUDP_MEDIA_REPAIR_CACHE_CHUNKS: usize = 16_384; -const MUNINN_RUDP_MEDIA_REPAIR_BURST_CHUNKS: usize = 2_048; -const MUNINN_RUDP_MEDIA_REPAIR_INITIAL_CHUNKS_PER_SECOND: usize = 4_096; -const MUNINN_RUDP_MEDIA_REPAIR_MIN_CHUNKS_PER_SECOND: usize = 8; -const MUNINN_RUDP_MEDIA_REPAIR_MAX_CHUNKS_PER_SECOND: usize = 16_384; -const MUNINN_RUDP_MEDIA_REPAIR_ADD_CHUNKS_PER_SECOND: usize = 2_048; -const MUNINN_RUDP_MEDIA_REPAIR_RECOVERY_INTERVAL_MS: u64 = 2_000; -const MUNINN_RUDP_MEDIA_REPAIR_MAX_FEEDBACK_PER_POLL: usize = 32; -const MUNINN_RUDP_MEDIA_REPAIR_MAX_CHUNKS_PER_POLL: usize = 256; -const MUNINN_RUDP_MEDIA_SOCKET_BUFFER_BYTES: usize = 16 * 1024 * 1024; -const MUNINN_RUDP_MEDIA_SEND_PACE_EVERY_PAYLOADS: usize = 4; -const MUNINN_RUDP_MEDIA_SEND_PACE_SLEEP_US: u64 = 250; -const MUNINN_RUDP_ACTIVE_CATALOG_REPUBLISH_MS: u64 = 2_000; -const MUNINN_ODIN_PROVIDER_LEASE_REFRESH_SECONDS: u64 = 30; -const PS_MOVE_LED_REPORT_LEN: usize = 49; -const MUNINN_DISABLED_VIDEO_SOURCE_ID: &str = "video:none"; -const MUNINN_DISABLED_AUDIO_SOURCE_ID: &str = "audio:none"; -const MUNINN_DEFAULT_ACTIVATION_STORE_PATH: &str = "C:/Meta/Odin/state/muninn.activate.cc"; -const WINDOWS_XINPUT_SOURCE_PREFIX: &str = "xinput://"; -const XINPUT_GAMEPAD_DPAD_UP_MASK: u16 = 0x0001; -const XINPUT_GAMEPAD_DPAD_DOWN_MASK: u16 = 0x0002; -const XINPUT_GAMEPAD_DPAD_LEFT_MASK: u16 = 0x0004; -const XINPUT_GAMEPAD_DPAD_RIGHT_MASK: u16 = 0x0008; -const XINPUT_GAMEPAD_START_MASK: u16 = 0x0010; -const XINPUT_GAMEPAD_BACK_MASK: u16 = 0x0020; -const XINPUT_GAMEPAD_LEFT_THUMB_MASK: u16 = 0x0040; -const XINPUT_GAMEPAD_RIGHT_THUMB_MASK: u16 = 0x0080; -const XINPUT_GAMEPAD_LEFT_SHOULDER_MASK: u16 = 0x0100; -const XINPUT_GAMEPAD_RIGHT_SHOULDER_MASK: u16 = 0x0200; -const XINPUT_GAMEPAD_A_MASK: u16 = 0x1000; -const XINPUT_GAMEPAD_B_MASK: u16 = 0x2000; -const XINPUT_GAMEPAD_X_MASK: u16 = 0x4000; -const XINPUT_GAMEPAD_Y_MASK: u16 = 0x8000; - -#[derive(Clone, Debug, PartialEq, Eq)] -enum Mode { - Serve, - Activate, - Health, - DryRun, - RequestStream, - RequestMoveLight, - SetMoveHueProgram, - MoveHueProgramStatus, - CaptureStreamStatus, - ObsCatalogStatus, - MoveLightStatus, - MoveIdentityStatus, - MoveSourceStatus, - MoveStateStatus, - ClaimMoveHost, - QuestAccessStatus, - MoveTrackerWorker, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct Options { - mode: Mode, - store_path: PathBuf, - activation_store_path: Option, - surface_id: String, - stream_id: String, - stream_filter_explicit: bool, - stream_action: String, - host_id: String, - target_host: String, - port: u16, - obs_target_host: Option, - obs_port: u16, - media_transport: MediaTransport, - media_packet_bytes: usize, - rudp_video_bitrate_kbps: u32, - rudp_latency_budget_ms: u32, - width: u32, - height: u32, - framerate: u32, - ddagrab_output_index: u32, - capture_video: bool, - capture_audio: bool, - audio_device: String, - audio_source_id_override: Option, - video_sources: Vec, - audio_sources: Vec, - audio_sample_rate: u32, - audio_channels: u32, - ffmpeg_path: String, - loopback_script: PathBuf, - log_root: PathBuf, - interval_seconds: Option, - move_id: String, - move_filter: Option, - hidraw_path: String, - move_colors: Vec, - move_durations_ms: Vec, - move_repeat_count: u32, - move_hue_cycle_ms: u64, - move_hue_cycle_ms_explicit: bool, - move_hue_mode: Option, - move_hue_order_mode: Option, - command_id: Option, - move_host_address: Option, - move_state_sources: Vec, - move_marker_camera_sources: Vec, - move_marker_width: u32, - move_marker_height: u32, - move_marker_stride_bytes: Option, - move_marker_fps: u32, - move_marker_threshold_min: u8, - move_marker_min_area_px: u32, - move_marker_max_candidates: u32, - move_psmoveapi_tracker: bool, - move_light_passive: bool, - move_tracker_exposure_milli: u32, - move_tracker_camera_exposure_milli: HashMap, - move_evidence_stream_id: Option, - move_evidence_verse_id: String, - move_evidence_ring_slots: usize, - move_evidence_slot_bytes: usize, - move_evidence_snapshot_path: Option, - quest_adb: bool, - quest_serial: Option, - quest_input_stream_id: Option, - quest_pose_stream_id: Option, - quest_video_input_stream_id: Option, - idunn_rudp_health: Option, - odin_cultmesh_uri: Option, - hid_controller_rudp_target: Option, - hid_controller_rudp_bind: Option, - hid_controller_rudp_advertise: Option, - command_rudp_bind: Option, - command_rudp_advertise: Option, - hid_controller_receipt_retention_seconds: u64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct CatalogSource { - id: String, - label: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct MoveMarkerCameraSource { - camera_id: String, - device_path: PathBuf, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum AudioSourceKind { - Loopback, - Input, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct AudioSourceSpec { - kind: AudioSourceKind, - device: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -enum MediaTransport { - Rudp, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct MuninnRudpMediaProfile { - profile_id: &'static str, - video_codec: &'static str, - video_encoder: &'static str, - video_preset: &'static str, - video_tune: &'static str, - video_bitrate_kbps: u32, - media_packet_bytes: usize, - max_fragment_bytes: usize, - video_b_frames: u8, - video_rc_lookahead: u8, - sender_queue_deadline_ms: u64, - sender_resend_delay_ms: u64, - sender_reliable_expire_after_ms: u64, - sender_pace_every_payloads: usize, - sender_pace_sleep_us: u64, - receiver_assembly_deadline_ms: u64, - receiver_gap_wait_ms: u64, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct MoveStateSource { - move_id: String, - hidraw_path: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct IdunnRudpHealthOptions { - endpoint: SocketAddr, - daemon_id: String, - health_contract: String, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct MuxPlan { - command_line: String, - command_script: String, - command_file: PathBuf, - targets: Vec, -} - -struct ActiveMoveLightCommand { - command: MuninnMoveLightCommandRecord, - colors: Vec<(u8, u8, u8)>, - step_index: usize, - repeats_done: u32, - next_write_at: Instant, -} - -struct ActiveCaptureStreamCommand { - command_id: String, - stream_id: String, - command: MuninnCaptureStreamCommandRecord, - child: Child, -} - -struct ActiveMoveStateSource { - source: MoveStateSource, - sequence: u64, - joystick_axes: [i16; 16], - joystick_buttons: [bool; 32], - light_hidraw_path: Option, - latest_move_record: Option, -} - -struct ActiveMoveMarkerCameraSource { - source: MoveMarkerCameraSource, - frame_source: MoveMarkerFrameSource, - sequence: u64, - #[cfg(feature = "psmoveapi-tracker")] - psmoveapi_observations: - Option>>>>, - #[cfg(feature = "psmoveapi-tracker")] - psmoveapi_health: Option>>>, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct DefaultMoveLightTarget { - path: String, - identity: String, -} - -#[derive(Clone, Debug, PartialEq)] -struct MoveMarkerFrameSource { - stream_id: String, - host_id: String, - camera_id: String, - fps: u32, - tracker_config: muninn_move_tracker::MoveTrackerConfig, -} - -#[derive(Serialize)] -struct MuninnMoveEvidenceStreamFrame<'a>( - &'a str, - &'a str, - i64, - &'a [MuninnMoveMarkerCandidateRecord], - &'a [MuninnMoveControllerStateRecord], -); - -#[derive(Serialize)] -struct MimirMoveProofEvidenceFrameSnapshot<'a>( - &'a str, - &'a str, - &'a str, - &'a str, - i64, - u64, - #[serde(with = "serde_bytes")] &'a [u8], -); - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct JoystickEvent { - event_type: u8, - number: u8, - value: i16, -} - -fn main() -> Result<()> { - let options = Options::parse(env::args().skip(1))?; - match options.mode { - Mode::Serve => serve(options), - Mode::Activate => activate(options), - Mode::Health => health_check(&options), - Mode::RequestStream => request_capture_stream(options), - Mode::RequestMoveLight => request_move_light(options), - Mode::SetMoveHueProgram => set_move_hue_program(options), - Mode::MoveHueProgramStatus => move_hue_program_status(options), - Mode::CaptureStreamStatus => capture_stream_status(options), - Mode::ObsCatalogStatus => obs_catalog_status(options), - Mode::MoveLightStatus => move_light_status(options), - Mode::MoveIdentityStatus => move_identity_status(options), - Mode::MoveSourceStatus => move_source_status(options), - Mode::MoveStateStatus => move_state_status(options), - Mode::ClaimMoveHost => claim_move_host(options), - Mode::QuestAccessStatus => quest_access_status(options), - Mode::MoveTrackerWorker => run_move_tracker_worker(options), - Mode::DryRun => { - require_media_target_uri(&options)?; - let plan = build_mux_plan(&options, "dry-run".to_string()); - println!("{}", plan.command_line); - Ok(()) - } - } -} - -fn serve(options: Options) -> Result<()> { - ensure_state_dirs(&options)?; - let move_hue_program = load_or_initialize_move_hue_program(&options)?; - let mut move_evidence_stream = create_move_evidence_stream(&options)?; - let mut active_move_lights = Vec::new(); - let suppressed_default_move_light_paths = Arc::new(Mutex::new(HashSet::new())); - let mut hid_controller_stream = create_hid_controller_stream(&options)?; - let mut last_move_host_claim_attempt_at = None; - let mut last_move_bluetooth_pickup_attempt_at = None; - let move_runtime_enabled = serve_should_manage_move_runtime(&options); - let mut active_move_states = - active_move_state_sources(serve_move_state_sources(&options, move_runtime_enabled)); - start_daemon_health_worker(&options); - let mut active_move_marker_cameras = - active_move_marker_camera_sources(&options, Arc::clone(&move_hue_program)); - let mut active_capture_streams = Vec::new(); - let move_evidence_rudp_sender = start_hid_controller_rudp_ingress( - &options, - move_evidence_stream - .as_ref() - .map(|stream| Arc::clone(&stream.counters)), - )?; - if let Some(stream) = move_evidence_stream.as_mut() { - stream.rudp_sender = move_evidence_rudp_sender; - } - let move_evidence_transport_health = - move_evidence_stream - .as_ref() - .map(|stream| MoveEvidenceTransportHealth { - stream_id: stream.stream_id.clone(), - counters: Arc::clone(&stream.counters), - }); - let latest_move_controller_states = Arc::new(Mutex::new(Vec::new())); - if let Some(stream) = move_evidence_stream.take() { - start_move_evidence_aggregator( - stream, - move_evidence_camera_inputs(&active_move_marker_cameras), - Arc::clone(&latest_move_controller_states), - ); - } - if !options.move_light_passive { - start_default_move_light_worker( - &options, - Arc::clone(&suppressed_default_move_light_paths), - Arc::clone(&move_hue_program), - ); - } - start_move_hue_program_sync_worker(&options, Arc::clone(&move_hue_program)); - start_provider_command_ingress(&options, Arc::clone(&move_hue_program))?; - start_odin_provider_lease_worker(&options); - - loop { - let live_move_sources = serve_move_state_sources(&options, move_runtime_enabled); - sync_active_move_state_sources(&mut active_move_states, live_move_sources.clone()); - let active_stream_ids = - tick_capture_stream_commands(&options, &mut active_capture_streams)?; - { - let mut node = open_node(&options, "muninn-daemon")?; - reconcile_move_identity_records( - &mut node, - &options, - &live_move_sources, - &active_move_states, - )?; - register_move_light_commands(&mut node, &options, &mut active_move_lights)?; - update_suppressed_default_move_light_paths( - &suppressed_default_move_light_paths, - &active_move_lights, - ); - tick_move_light_commands(&mut node, &mut active_move_lights, &mut HidMoveLightWriter)?; - update_suppressed_default_move_light_paths( - &suppressed_default_move_light_paths, - &active_move_lights, - ); - publish_move_controller_states( - &mut node, - &options, - &mut active_move_states, - &mut HidMoveControllerStateReader, - None, - hid_controller_stream.as_mut(), - )?; - let controller_states: Vec = active_move_states - .iter() - .filter_map(|state| state.latest_move_record.clone()) - .collect(); - if let Ok(mut latest) = latest_move_controller_states.lock() { - *latest = controller_states; - } - publish_move_tracker_health(&mut node, &mut active_move_marker_cameras)?; - publish_move_evidence_transport_health( - &mut node, - &options, - move_evidence_transport_health.as_ref(), - )?; - publish_quest_access_if_requested(&mut node, &options)?; - let state = if active_stream_ids.is_empty() { - "idle" - } else { - "streaming" - }; - publish_surface(&mut node, &options, state, &active_stream_ids)?; - publish_runtime_boundary_records( - &mut node, - &options, - state, - &active_stream_ids, - &live_move_sources, - )?; - publish_obs_catalog_idle(&mut node, &options)?; - } - claim_move_host_if_due(&options, &mut last_move_host_claim_attempt_at); - pickup_bluetooth_moves_if_due( - &active_move_states, - &mut last_move_bluetooth_pickup_attempt_at, - ); - let has_platform_default_move_lights = serve_should_manage_platform_move_lights(&options); - if options.interval_seconds.is_none() - && active_move_lights.is_empty() - && active_move_states.is_empty() - && active_move_marker_cameras.is_empty() - && active_capture_streams.is_empty() - && !has_platform_default_move_lights - { - return Ok(()); - } - let sleep = if !active_move_lights.is_empty() - || !active_move_states.is_empty() - || !active_move_marker_cameras.is_empty() - || !active_capture_streams.is_empty() - || has_platform_default_move_lights - { - Duration::from_millis(250) - } else { - Duration::from_secs(options.interval_seconds.unwrap_or(15)) - }; - thread::sleep(sleep); - } -} - -fn start_daemon_health_worker(options: &Options) { - if options.idunn_rudp_health.is_none() { - return; - } - let options = options.clone(); - thread::spawn(move || { - loop { - if let Err(error) = run_daemon_health_publisher(&options) { - eprintln!("Muninn Idunn health publish failed: {error:#}"); - } - thread::sleep(Duration::from_secs(5)); - } - }); -} - -fn move_hue_program_key(host_id: &str) -> String { - format!("muninn:{host_id}:move-hue-program") -} - -fn move_hue_surface_key(host_id: &str) -> String { - format!("surface:muninn.telemetry.{host_id}.move-hue") -} - -fn load_or_initialize_move_hue_program( - options: &Options, -) -> Result>> { - let key = move_hue_program_key(&options.host_id); - let mut node = open_node(options, "muninn-move-hue-program-bootstrap")?; - let program = node - .get::(&key)? - .unwrap_or_else(|| bootstrap_move_hue_program(options)); - validate_move_hue_program(&program)?; - node.put(&key, &program)?; - Ok(Arc::new(Mutex::new(program))) -} - -fn bootstrap_move_hue_program(options: &Options) -> MuninnMoveHueProgramRecord { - MuninnMoveHueProgramRecord { - program_id: move_hue_program_key(&options.host_id), - host_id: options.host_id.clone(), - mode: "animated".to_string(), - cycle_ms: options.move_hue_cycle_ms, - epoch_ns: 0, - hold_at_ns: 0, - requested_by: "muninn-bootstrap".to_string(), - updated_at: timestamp().unwrap_or_else(|_| "unix-0".to_string()), - order_mode: "descending".to_string(), - transition_percent: 100, - transition_percent_explicit: true, - } -} - -fn validate_move_hue_program(program: &MuninnMoveHueProgramRecord) -> Result<()> { - if !matches!(program.mode.as_str(), "animated" | "hold" | "static") { - return Err(anyhow!("unsupported Move hue mode {}", program.mode)); - } - if program.cycle_ms == 0 { - return Err(anyhow!("Move hue cycle_ms must be greater than zero")); - } - if !matches!( - program.order_mode.as_str(), - "descending" | "ascending" | "bounce" | "rotating-lead" | "golden-permutation" - ) { - return Err(anyhow!( - "unsupported Move hue order mode {}", - program.order_mode - )); - } - if program.transition_percent > 100 { - return Err(anyhow!("Move hue transition_percent must be at most 100")); - } - Ok(()) -} - -fn move_hue_program_timestamp_ns(program: &MuninnMoveHueProgramRecord, now_ns: i128) -> i128 { - match program.mode.as_str() { - "hold" => i128::from(program.hold_at_ns.max(program.epoch_ns)), - "static" => i128::from(program.epoch_ns), - _ => now_ns, - } -} - -fn effective_transition_percent(program: &MuninnMoveHueProgramRecord) -> u8 { - if program.transition_percent_explicit { - program.transition_percent - } else { - 100 - } -} - -fn start_move_hue_program_sync_worker( - options: &Options, - runtime_program: Arc>, -) { - let options = options.clone(); - let mut activation_options = options.clone(); - activation_options.store_path = options - .activation_store_path - .as_ref() - .cloned() - .unwrap_or_else(|| options.store_path.clone()); - thread::spawn(move || { - loop { - let key = move_hue_program_key(&options.host_id); - let main_program = open_node(&options, "muninn-move-hue-program-sync") - .ok() - .and_then(|node| node.get::(&key).ok().flatten()); - let activation_program = open_node( - &activation_options, - "muninn-move-hue-program-activation-sync", - ) - .ok() - .and_then(|node| node.get::(&key).ok().flatten()); - let program = [main_program, activation_program] - .into_iter() - .flatten() - .filter(|program| validate_move_hue_program(program).is_ok()) - .max_by_key(|program| unix_timestamp_sort_key(&program.updated_at)); - if let Some(program) = program - && let Ok(mut current) = runtime_program.lock() - && *current != program - { - *current = program.clone(); - if let Ok(mut node) = open_node(&options, "muninn-move-hue-program-projection") { - let _ = node.put(&key, &program); - } - } - thread::sleep(Duration::from_millis(250)); - } - }); -} - -fn start_provider_command_ingress( - options: &Options, - runtime_program: Arc>, -) -> Result<()> { - let Some(bind) = options.command_rudp_bind else { - return Ok(()); - }; - let socket = UdpSocket::bind(bind) - .with_context(|| format!("binding Muninn provider command route at {bind}"))?; - socket.set_read_timeout(Some(Duration::from_millis(5)))?; - let options = options.clone(); - thread::spawn(move || { - if let Err(error) = run_provider_command_ingress(socket, options, runtime_program) { - eprintln!("Muninn provider command ingress stopped: {error:#}"); - } - }); - Ok(()) -} - -fn run_provider_command_ingress( - socket: UdpSocket, - options: Options, - runtime_program: Arc>, -) -> Result<()> { - let mut transport = - CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { - runtime_id: format!("muninn-{}-command-ingress", options.host_id), - socket, - mode: cultnet_rs::CultNetRudpSocketMode::Server, - remote_addr: None, - connection_id: MUNINN_COMMAND_RUDP_CONNECTION_ID, - initial_sequence: 1, - resend_delay_ms: 15, - transport_id: Some("muninn-provider-command-rudp".to_string()), - max_payload_bytes: None, - max_fragment_bytes: Some(1200), - max_pending_reliable_packets: Some(64), - media_reliable_expire_after_ms: None, - reconnect_policy: None, - })?; - loop { - if let Some(frame) = transport.receive_once()? - && frame.channel_id == "schema" - { - let message = decode_cultnet_message_from_slice( - &frame.payload, - CultNetWireContract::CultNetSchemaV0, - )?; - if let CultNetMessage::DocumentPutRaw { document, .. } = message { - apply_provider_command_document(&options, &runtime_program, document)?; - } - } - transport.poll_resends()?; - } -} - -fn apply_provider_command_document( - options: &Options, - runtime_program: &Arc>, - document: CultNetRawDocumentRecord, -) -> Result<()> { - if document.schema_id != "gamecult.eve.command.v1" { - return Ok(()); - } - let value: serde_json::Value = match document.payload_encoding { - CultNetRawPayloadEncoding::Messagepack => rmp_serde::from_slice(&document.payload)?, - }; - let command_type = value - .get("command") - .or_else(|| value.get("type")) - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - if command_type != "muninn.set-move-hue-program" { - return Ok(()); - } - let payload = value - .get("payload") - .filter(|payload| payload.is_object()) - .unwrap_or(&value); - let provider_id = value - .get("providerId") - .and_then(serde_json::Value::as_str) - .unwrap_or_default(); - if provider_id != muninn_provider_id(options) { - return Err(anyhow!( - "Move hue command targets provider {provider_id}, not {}", - muninn_provider_id(options) - )); - } - let mut program = runtime_program - .lock() - .map_err(|_| anyhow!("Move hue runtime program lock is poisoned"))? - .clone(); - if let Some(mode) = payload.get("mode").and_then(serde_json::Value::as_str) { - program.mode = mode.to_string(); - if mode == "hold" { - program.hold_at_ns = timestamp_ns()?; - } - } - if let Some(order) = payload.get("orderMode").and_then(serde_json::Value::as_str) { - program.order_mode = order.to_string(); - } - if let Some(cycle_ms) = payload.get("cycleMs").and_then(serde_json::Value::as_u64) { - program.cycle_ms = cycle_ms; - } - if let Some(value) = payload - .get("transitionPercent") - .and_then(serde_json::Value::as_u64) - { - program.transition_percent = u8::try_from(value) - .map_err(|_| anyhow!("Move hue transitionPercent must be at most 100"))?; - program.transition_percent_explicit = true; - } - program.requested_by = value - .get("publishedBy") - .and_then(serde_json::Value::as_str) - .unwrap_or("eve-command") - .to_string(); - program.updated_at = timestamp()?; - validate_move_hue_program(&program)?; - let key = move_hue_program_key(&options.host_id); - open_node(options, "muninn-provider-command")?.put(&key, &program)?; - *runtime_program - .lock() - .map_err(|_| anyhow!("Move hue runtime program lock is poisoned"))? = program; - Ok(()) -} - -fn tick_capture_stream_commands( - options: &Options, - active: &mut Vec, -) -> Result> { - reap_capture_stream_children(options, active)?; - let mut activation_options = options.clone(); - activation_options.store_path = options - .activation_store_path - .as_ref() - .cloned() - .unwrap_or_else(|| options.store_path.clone()); - let mut node = open_node(&activation_options, "muninn-activation-controller")?; - let mut commands = node.cache().get_all::()?; - commands.sort_by(|left, right| left.updated_at.cmp(&right.updated_at)); - let latest_command_by_stream = latest_capture_stream_command_ids(&commands, &options.host_id); - for command in commands { - if command.host_id != options.host_id { - continue; - } - let command_stream_key = canonical_muninn_stream_id(&command.stream_id); - if let Some(latest_command_id) = latest_command_by_stream.get(&command_stream_key) { - if latest_command_id != &command.command_id { - supersede_capture_stream_command(&mut node, command, latest_command_id)?; - continue; - } - } - match (command.action.as_str(), command.state.as_str()) { - ("start", "pending") => { - start_capture_stream_command(options, &mut node, active, command)?; - } - ("stop", "pending") => { - stop_capture_stream_command(&mut node, active, command)?; - } - ("start", "running") => { - if !active - .iter() - .any(|session| session.command_id == command.command_id) - { - start_capture_stream_command(options, &mut node, active, command)?; - } - } - _ => {} - } - } - Ok(active - .iter() - .map(|session| session.stream_id.clone()) - .collect()) -} - -fn pull_odin_obs_catalog_snapshot(node: &mut cultmesh_rs::CultMeshNode, options: &Options) { - let Some(target) = resolve_odin_cultmesh_uri(options) else { - return; - }; - if let Err(error) = node.pull_rudp_catalog_snapshot(CultMeshRudpSnapshotOptions { - target, - runtime_id: format!("muninn-{}-obs-catalog-client", options.host_id), - schema_ids: Some(vec![MUNINN_OBS_STREAM_CATALOG_SCHEMA.to_string()]), - connect_timeout: Duration::from_millis(2000), - response_timeout: Duration::from_millis(5000), - resend_delay_ms: 15, - ..CultMeshRudpSnapshotOptions::default() - }) { - eprintln!("Muninn could not pull OBS catalog from Odin: {error:#}"); - } -} - -fn obs_catalog_status(options: Options) -> Result<()> { - ensure_state_dirs(&options)?; - let mut node = open_node(&options, "muninn-obs-catalog-client")?; - pull_odin_obs_catalog_snapshot(&mut node, &options); - let catalogs = node.cache().get_all::()?; - if catalogs.is_empty() { - return Err(anyhow!( - "Odin did not return any {} records for OBS discovery", - MUNINN_OBS_STREAM_CATALOG_SCHEMA - )); - } - 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) => { - eprintln!("Muninn could not resolve Odin CultMesh URI {uri}: {error:#}"); - None - } - } -} - -fn latest_capture_stream_command_ids( - commands: &[MuninnCaptureStreamCommandRecord], - host_id: &str, -) -> HashMap { - let mut latest_command_by_stream = HashMap::new(); - for command in commands.iter().filter(|command| command.host_id == host_id) { - latest_command_by_stream.insert( - canonical_muninn_stream_id(&command.stream_id), - command.command_id.clone(), - ); - } - latest_command_by_stream -} - -fn latest_capture_stream_command_key(host_id: &str, stream_id: &str) -> String { - format!( - "{}:{}:capture-stream:latest", - host_id, - canonical_muninn_stream_id(stream_id) - ) -} - -fn supersede_capture_stream_command( - node: &mut cultmesh_rs::CultMeshNode, - command: MuninnCaptureStreamCommandRecord, - latest_command_id: &str, -) -> Result<()> { - if capture_stream_command_is_terminal(&command.state) { - return Ok(()); - } - let command_id = command.command_id.clone(); - node.put( - &command_id, - &MuninnCaptureStreamCommandRecord { - state: "completed".to_string(), - detail: format!("Superseded by newer command {latest_command_id}."), - updated_at: timestamp()?, - ..command - }, - )?; - Ok(()) -} - -fn capture_stream_command_is_terminal(state: &str) -> bool { - matches!(state, "completed" | "failed") -} - -fn reap_capture_stream_children( - options: &Options, - active: &mut Vec, -) -> Result<()> { - let Some(activation_store_path) = options.activation_store_path.as_ref() else { - return Ok(()); - }; - let mut index = 0; - while index < active.len() { - match active[index].child.try_wait()? { - Some(status) => { - let mut activation_options = options.clone(); - activation_options.store_path = activation_store_path.clone(); - let mut node = open_node(&activation_options, "muninn-activation-controller")?; - if let Some(command) = - node.get::(&active[index].command_id)? - { - let command_id = command.command_id.clone(); - let state = if status.success() { - "completed" - } else { - "failed" - }; - node.put( - &command_id, - &MuninnCaptureStreamCommandRecord { - state: state.to_string(), - detail: format!("activation child exited with {status}"), - updated_at: timestamp()?, - ..command - }, - )?; - } - active.remove(index); - } - None => index += 1, - } - } - Ok(()) -} - -fn start_capture_stream_command( - options: &Options, - node: &mut cultmesh_rs::CultMeshNode, - active: &mut Vec, - command: MuninnCaptureStreamCommandRecord, -) -> Result<()> { - let command_stream_key = canonical_muninn_stream_id(&command.stream_id); - if let Some(session) = active - .iter_mut() - .find(|session| session.stream_id == command_stream_key) - { - if capture_stream_commands_start_equivalent(&session.command, &command) { - eprintln!( - "Muninn serve kept existing capture stream {} bitrate_kbps={} latency_budget_ms={}.", - command.stream_id, - command_rudp_video_bitrate_kbps(&command), - command_rudp_latency_budget_ms(&command) - ); - let running = MuninnCaptureStreamCommandRecord { - state: "running".to_string(), - detail: format!( - "Muninn serve kept existing activation child from command {}.", - session.command_id - ), - updated_at: timestamp()?, - ..command.clone() - }; - node.put(&running.command_id, &running)?; - session.command_id = running.command_id.clone(); - session.command = running; - return Ok(()); - } - } - - for session in active.iter_mut() { - if session.stream_id == command_stream_key { - terminate_child_tree(&mut session.child); - let _ = session.child.wait(); - } - } - active.retain(|session| session.stream_id != command_stream_key); - - eprintln!( - "Muninn serve spawning capture stream {} bitrate_kbps={} latency_budget_ms={}.", - command.stream_id, - command_rudp_video_bitrate_kbps(&command), - command_rudp_latency_budget_ms(&command) - ); - let child = spawn_capture_stream_activation(options, &command)?; - let running = MuninnCaptureStreamCommandRecord { - state: "running".to_string(), - detail: "Muninn serve spawned the local activation child.".to_string(), - updated_at: timestamp()?, - ..command.clone() - }; - node.put(&running.command_id, &running)?; - active.push(ActiveCaptureStreamCommand { - command_id: command.command_id.clone(), - stream_id: command_stream_key, - command, - child, - }); - Ok(()) -} - -fn capture_stream_commands_start_equivalent( - active: &MuninnCaptureStreamCommandRecord, - command: &MuninnCaptureStreamCommandRecord, -) -> bool { - active.host_id == command.host_id - && canonical_muninn_stream_id(&active.stream_id) - == canonical_muninn_stream_id(&command.stream_id) - && active.action == "start" - && command.action == "start" - && active.target_host == command.target_host - && active.port == command.port - && active.obs_target_host == command.obs_target_host - && active.obs_port == command.obs_port - && active.media_transport == command.media_transport - && active.media_packet_bytes == command.media_packet_bytes - && command_rudp_video_bitrate_kbps(active) == command_rudp_video_bitrate_kbps(command) - && command_rudp_latency_budget_ms(active) == command_rudp_latency_budget_ms(command) - && command_video_source_id(active) == command_video_source_id(command) - && command_audio_source_id(active) == command_audio_source_id(command) -} - -fn command_rudp_video_bitrate_kbps(command: &MuninnCaptureStreamCommandRecord) -> u32 { - if command.rudp_video_bitrate_kbps == 0 { - MUNINN_RUDP_MEDIA_VIDEO_BITRATE_KBPS - } else { - command.rudp_video_bitrate_kbps - } -} - -fn command_rudp_latency_budget_ms(command: &MuninnCaptureStreamCommandRecord) -> u32 { - if command.rudp_latency_budget_ms == 0 { - MUNINN_RUDP_MEDIA_RECEIVER_ASSEMBLY_DEADLINE_MS as u32 - } else { - command.rudp_latency_budget_ms - } -} - -fn canonical_muninn_stream_id(stream_id: &str) -> String { - for suffix in [".rudp"] { - if let Some(prefix) = stream_id.strip_suffix(suffix) { - return prefix.to_string(); - } - } - if let Some((prefix, suffix)) = stream_id.rsplit_once(':') { - if !suffix.is_empty() && suffix.chars().all(|ch| ch.is_ascii_digit()) { - return prefix.to_string(); - } - } - stream_id.to_string() -} - -fn command_video_source_id(command: &MuninnCaptureStreamCommandRecord) -> &str { - if command.video_source_id.is_empty() { - "display:0" - } else { - command.video_source_id.as_str() - } -} - -fn command_audio_source_id(command: &MuninnCaptureStreamCommandRecord) -> &str { - if command.audio_source_id.is_empty() { - "wasapi-loopback:Realtek" - } else { - command.audio_source_id.as_str() - } -} - -fn command_requests_video(command: &MuninnCaptureStreamCommandRecord) -> bool { - command_video_source_id(command) != MUNINN_DISABLED_VIDEO_SOURCE_ID -} - -fn command_requests_audio(command: &MuninnCaptureStreamCommandRecord) -> bool { - command_audio_source_id(command) != MUNINN_DISABLED_AUDIO_SOURCE_ID -} - -fn stop_capture_stream_command( - node: &mut cultmesh_rs::CultMeshNode, - active: &mut Vec, - command: MuninnCaptureStreamCommandRecord, -) -> Result<()> { - let mut stopped = false; - let command_stream_key = canonical_muninn_stream_id(&command.stream_id); - let mut index = 0; - while index < active.len() { - if active[index].stream_id == command_stream_key { - terminate_child_tree(&mut active[index].child); - let _ = active[index].child.wait(); - active.remove(index); - stopped = true; - } else { - index += 1; - } - } - let command_id = command.command_id.clone(); - node.put( - &command_id, - &MuninnCaptureStreamCommandRecord { - state: "completed".to_string(), - detail: if stopped { - "Muninn serve stopped the active capture stream.".to_string() - } else { - "No active capture stream matched the stop request.".to_string() - }, - updated_at: timestamp()?, - ..command - }, - )?; - Ok(()) -} - -fn terminate_child_tree(child: &mut Child) { - #[cfg(windows)] - { - let _ = Command::new("taskkill.exe") - .args(["/PID", &child.id().to_string(), "/T", "/F"]) - .status(); - } - #[cfg(not(windows))] - { - let _ = child.kill(); - } -} - -fn spawn_capture_stream_activation( - options: &Options, - command: &MuninnCaptureStreamCommandRecord, -) -> Result { - let exe = env::current_exe().context("resolving current Muninn executable")?; - let activation_stderr = options.log_root.join(format!( - "muninn-activation-{}.err.log", - safe_log_component(&command.command_id) - )); - let mut args = vec![ - "activate".to_string(), - "--store".to_string(), - options.store_path.display().to_string(), - "--host".to_string(), - options.host_id.clone(), - "--stream".to_string(), - command.stream_id.clone(), - "--target-host".to_string(), - command.target_host.clone(), - "--port".to_string(), - command.port.to_string(), - "--media-transport".to_string(), - command.media_transport.clone(), - "--media-packet-bytes".to_string(), - command.media_packet_bytes.to_string(), - "--rudp-video-bitrate-kbps".to_string(), - command_rudp_video_bitrate_kbps(command).to_string(), - "--rudp-latency-budget-ms".to_string(), - command_rudp_latency_budget_ms(command).to_string(), - ]; - if command_requests_video(command) { - let output_index = video_source_output_index(command_video_source_id(command)) - .unwrap_or(options.ddagrab_output_index); - args.extend([ - "--ddagrab-output-index".to_string(), - output_index.to_string(), - ]); - } else { - args.push("--no-video".to_string()); - } - if command_requests_audio(command) { - args.extend([ - "--audio-source-id".to_string(), - command_audio_source_id(command).to_string(), - ]); - } else { - args.push("--no-audio".to_string()); - } - if let Some(obs_target_host) = command.obs_target_host.as_deref() - && !obs_target_host.trim().is_empty() - && command.obs_port != 0 - { - args.extend([ - "--obs-target-host".to_string(), - obs_target_host.to_string(), - "--obs-port".to_string(), - command.obs_port.to_string(), - ]); - } - args.extend([ - "--audio-sample-rate".to_string(), - options.audio_sample_rate.to_string(), - "--audio-channels".to_string(), - options.audio_channels.to_string(), - "--ffmpeg".to_string(), - options.ffmpeg_path.clone(), - "--loopback-script".to_string(), - options.loopback_script.display().to_string(), - "--log-root".to_string(), - options.log_root.display().to_string(), - ]); - Command::new(exe) - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(fs::File::create(&activation_stderr).with_context(|| { - format!( - "creating activation stderr log {}", - activation_stderr.display() - ) - })?) - .spawn() - .context("spawning daemon-owned Muninn capture activation child") -} - -fn video_source_id_for_options(options: &Options) -> String { - if options.capture_video { - format!("display:{}", options.ddagrab_output_index) - } else { - MUNINN_DISABLED_VIDEO_SOURCE_ID.to_string() - } -} - -fn video_source_label_for_options(options: &Options) -> String { - format!( - "{} display {}", - options.host_id, - options.ddagrab_output_index + 1 - ) -} - -fn audio_source_id_for_options(options: &Options) -> String { - if options.capture_audio { - if let Some(source_id) = options.audio_source_id_override.as_ref() { - if parse_audio_source_spec(source_id).is_some() { - return source_id.clone(); - } - } - format!("wasapi-loopback:{}", options.audio_device) - } else { - MUNINN_DISABLED_AUDIO_SOURCE_ID.to_string() - } -} - -fn audio_source_label_for_options(options: &Options) -> String { - let spec = audio_source_spec_for_options(options); - match spec.kind { - AudioSourceKind::Loopback => { - format!("{} loopback ({})", options.host_id, spec.device) - } - AudioSourceKind::Input => format!("{} input ({})", options.host_id, spec.device), - } -} - -fn video_source_catalog(options: &Options) -> Vec { - if options.video_sources.is_empty() { - vec![CatalogSource { - id: video_source_id_for_options(options), - label: video_source_label_for_options(options), - }] - } else { - options.video_sources.clone() - } -} - -fn audio_source_catalog(options: &Options) -> Vec { - if options.audio_sources.is_empty() { - vec![CatalogSource { - id: audio_source_id_for_options(options), - label: audio_source_label_for_options(options), - }] - } else { - options.audio_sources.clone() - } -} - -fn video_source_output_index(source_id: &str) -> Option { - source_id - .strip_prefix("display:") - .and_then(|value| value.parse::().ok()) -} - -fn parse_audio_source_spec(source_id: &str) -> Option { - let (kind, device) = if let Some(value) = source_id.strip_prefix("wasapi-loopback:") { - (AudioSourceKind::Loopback, value) - } else if let Some(value) = source_id.strip_prefix("wasapi-input:") { - (AudioSourceKind::Input, value) - } else { - return None; - }; - let device = device.trim(); - if device.is_empty() { - return None; - } - Some(AudioSourceSpec { - kind, - device: device.to_string(), - }) -} - -fn audio_source_spec_for_options(options: &Options) -> AudioSourceSpec { - if let Some(source_id) = options.audio_source_id_override.as_ref() { - if let Some(spec) = parse_audio_source_spec(source_id) { - return spec; - } - } - AudioSourceSpec { - kind: AudioSourceKind::Loopback, - device: options.audio_device.clone(), - } -} - -fn safe_log_component(value: &str) -> String { - value - .chars() - .map(|ch| { - if ch.is_ascii_alphanumeric() || matches!(ch, '.' | '-' | '_') { - ch - } else { - '_' - } - }) - .collect() -} - -fn active_move_state_sources(sources: Vec) -> Vec { - sources.into_iter().map(active_move_state_source).collect() -} - -fn active_move_state_source(source: MoveStateSource) -> ActiveMoveStateSource { - ActiveMoveStateSource { - light_hidraw_path: default_move_light_path(&source.hidraw_path), - source, - sequence: 0, - joystick_axes: [0; 16], - joystick_buttons: [false; 32], - latest_move_record: None, - } -} - -fn sync_active_move_state_sources( - active: &mut Vec, - desired: Vec, -) { - active.retain(|state| desired.iter().any(|source| source == &state.source)); - for source in desired { - if active.iter().any(|state| state.source == source) { - continue; - } - active.push(active_move_state_source(source)); - } -} - -fn publish_move_identity_records( - node: &mut cultmesh_rs::CultMeshNode, - options: &Options, - sources: &[MoveStateSource], -) -> Result<()> { - let observed_at = timestamp()?; - let bluetooth_host_address = options.move_host_address.clone().unwrap_or_default(); - for source in sources { - let record = MuninnMoveIdentityRecord { - identity_id: format!("{}:{}:move-identity", options.host_id, source.move_id), - host_id: options.host_id.clone(), - move_id: source.move_id.clone(), - source_path: source.hidraw_path.clone(), - bluetooth_host_address: bluetooth_host_address.clone(), - state: "usb-visible".to_string(), - detail: "Muninn discovered this PS Move on a local USB/HID input path; controller-state records may be absent when the platform HID input collection is silent.".to_string(), - observed_at: observed_at.clone(), - }; - node.put(&record.identity_id, &record)?; - } - Ok(()) -} - -fn reconcile_move_identity_records( - node: &mut cultmesh_rs::CultMeshNode, - options: &Options, - sources: &[MoveStateSource], - active: &[ActiveMoveStateSource], -) -> Result<()> { - publish_move_identity_records(node, options, sources)?; - publish_bluetooth_move_identity_records(node, options, active)?; - - let current_ids = current_move_identity_records_from_sources(options, sources)? - .into_iter() - .map(|identity| identity.identity_id) - .collect::>(); - let existing = node.cache().get_all::()?; - for identity in existing { - if identity.host_id == options.host_id - && !current_ids - .iter() - .any(|current| current == &identity.identity_id) - { - node.delete::(&identity.identity_id)?; - } - } - Ok(()) -} - -#[cfg(unix)] -fn publish_bluetooth_move_identity_records( - node: &mut cultmesh_rs::CultMeshNode, - options: &Options, - active: &[ActiveMoveStateSource], -) -> Result<()> { - let observed_at = timestamp()?; - let bluetooth_host_address = platform_default_bluetooth_host_address().unwrap_or_default(); - let devices = bluetoothctl_motion_controller_devices()?; - for device in devices { - let Some(move_id) = bluetooth_address_move_id(&device.address) else { - continue; - }; - if active.iter().any(|state| { - state.source.move_id.eq_ignore_ascii_case(&move_id) - && !is_joystick_path(&state.source.hidraw_path) - }) { - continue; - } - let Some(record) = build_bluetooth_move_identity_record( - options, - &device, - bluetooth_host_address.clone(), - observed_at.clone(), - ) else { - continue; - }; - node.put(&record.identity_id, &record)?; - } - Ok(()) -} - -fn build_bluetooth_move_identity_record( - options: &Options, - device: &BluetoothMoveDevice, - bluetooth_host_address: String, - observed_at: String, -) -> Option { - let move_id = bluetooth_address_move_id(&device.address)?; - let state = if device.connected { - "bluetooth-connected" - } else if device.trusted { - "bluetooth-waiting" - } else { - "bluetooth-known" - }; - let detail = if device.connected { - "Muninn sees this PS Move connected through BlueZ." - } else if device.trusted { - "Muninn sees this trusted PS Move in BlueZ and will attempt bounded pickup while disconnected." - } else { - "Muninn sees this PS Move in BlueZ, but it is not trusted for automatic pickup." - }; - Some(MuninnMoveIdentityRecord { - identity_id: format!("{}:{}:move-identity", options.host_id, move_id), - host_id: options.host_id.clone(), - move_id, - source_path: format!("bluetooth:{}", device.address), - bluetooth_host_address, - state: state.to_string(), - detail: detail.to_string(), - observed_at, - }) -} - -#[cfg(not(unix))] -fn publish_bluetooth_move_identity_records( - _node: &mut cultmesh_rs::CultMeshNode, - _options: &Options, - _active: &[ActiveMoveStateSource], -) -> Result<()> { - Ok(()) -} - -fn live_move_state_sources(options: &Options) -> Vec { - let discovered = platform_move_state_sources(); - if discovered.is_empty() { - return options.move_state_sources.clone(); - } - - merge_move_state_sources(discovered, &options.move_state_sources) -} - -fn merge_move_state_sources( - discovered: Vec, - configured: &[MoveStateSource], -) -> Vec { - let mut sources = discovered; - for source in configured { - if is_joystick_path(&source.hidraw_path) { - if let Some(discovered) = sources - .iter_mut() - .find(|discovered| discovered.hidraw_path == source.hidraw_path) - { - if discovered.move_id.starts_with("hid-") { - discovered.move_id = source.move_id.clone(); - } - continue; - } - #[cfg(unix)] - if !Path::new(&source.hidraw_path).exists() { - continue; - } - } - if !sources - .iter() - .any(|discovered| discovered == source || discovered.move_id == source.move_id) - { - sources.push(source.clone()); - } - } - sources -} - -fn claim_move_host_if_due(options: &Options, last_attempt_at: &mut Option) { - if !serve_should_claim_move_host(options) { - return; - } - - let cadence = Duration::from_secs(5); - if last_attempt_at - .as_ref() - .is_some_and(|instant| instant.elapsed() < cadence) - { - return; - } - *last_attempt_at = Some(Instant::now()); - - if let Err(error) = prepare_move_bluetooth_host_for_claim() { - eprintln!("Muninn could not prepare Bluetooth host for USB PS Move claim: {error:#}"); - } - - let Some(host) = move_host_address_for_claim(options) else { - return; - }; - if let Err(error) = claim_ps_move_host(&host) { - eprintln!("Muninn could not claim USB PS Move host {host}: {error:#}"); - } -} - -fn serve_should_claim_move_host(options: &Options) -> bool { - options - .move_host_address - .as_deref() - .is_some_and(|host| !host.trim().is_empty()) -} - -fn serve_should_manage_move_runtime(options: &Options) -> bool { - serve_should_claim_move_host(options) - || !options.move_state_sources.is_empty() - || options.move_evidence_stream_id.is_some() -} - -fn serve_should_manage_platform_move_lights(options: &Options) -> bool { - serve_should_manage_move_runtime(options) -} - -fn serve_move_state_sources(options: &Options, move_runtime_enabled: bool) -> Vec { - if move_runtime_enabled { - return live_move_state_sources(options); - } - Vec::new() -} - -fn pickup_bluetooth_moves_if_due( - active: &[ActiveMoveStateSource], - last_attempt_at: &mut Option, -) { - let cadence = Duration::from_secs(5); - if last_attempt_at - .as_ref() - .is_some_and(|instant| instant.elapsed() < cadence) - { - return; - } - *last_attempt_at = Some(Instant::now()); - - if let Err(error) = pickup_bluetooth_moves(active) { - eprintln!("Muninn could not attempt PS Move Bluetooth pickup: {error:#}"); - } -} - -#[cfg(unix)] -fn prepare_move_bluetooth_host_for_claim() -> Result<()> { - for args in [["pairable", "on"], ["discoverable", "on"]] { - let output = Command::new("bluetoothctl") - .args(args) - .output() - .with_context(|| format!("bluetoothctl {} {} could not run", args[0], args[1]))?; - if output.status.success() { - continue; - } - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow!( - "bluetoothctl {} {} failed: {}", - args[0], - args[1], - stderr.trim() - )); - } - Ok(()) -} - -#[cfg(not(unix))] -fn prepare_move_bluetooth_host_for_claim() -> Result<()> { - Ok(()) -} - -#[cfg(unix)] -fn pickup_bluetooth_moves(_active: &[ActiveMoveStateSource]) -> Result<()> { - let devices = bluetoothctl_motion_controller_devices()?; - for device in devices { - if !bluetooth_move_needs_pickup(&device) { - continue; - } - match bluetoothctl_connect_device(&device.address) { - Ok(true) => println!( - "bluetooth_move_pickup address={} state=connected", - device.address - ), - Ok(false) => {} - Err(error) => eprintln!( - "bluetooth_move_pickup address={} state=failed error={error:#}", - device.address - ), - } - } - Ok(()) -} - -fn bluetooth_move_needs_pickup(device: &BluetoothMoveDevice) -> bool { - device.trusted && !device.connected -} - -#[cfg(not(unix))] -fn pickup_bluetooth_moves(_active: &[ActiveMoveStateSource]) -> Result<()> { - Ok(()) -} - -fn bluetooth_address_move_id(address: &str) -> Option { - parse_bluetooth_address_little_endian(address) - .ok() - .map(|bytes| { - format!( - "move-{}", - bytes - .iter() - .rev() - .map(|byte| format!("{byte:02x}")) - .collect::() - ) - }) -} - -fn move_host_address_for_claim(options: &Options) -> Option { - if let Some(host) = options.move_host_address.as_deref() { - if !host.trim().is_empty() { - return Some(host.to_string()); - } - } - platform_default_bluetooth_host_address() -} - -struct ActiveMoveEvidenceStream { - catalog: CultMeshStreamCatalog, - stream_id: String, - producer_peer_id: String, - frame_counter: u64, - snapshot_path: Option, - rudp_sender: Option>>>>, - counters: Arc, - local_ring_enabled: bool, -} - -#[derive(Clone)] -struct MoveEvidenceTransportHealth { - stream_id: String, - counters: Arc, -} - -#[derive(Default)] -struct MoveEvidenceTransportCounters { - produced_frames: AtomicU64, - local_ring_admissions: AtomicU64, - remote_handoffs: AtomicU64, - remote_sends: AtomicU64, -} - -fn activate(options: Options) -> Result<()> { - require_media_target_uri(&options)?; - activate_rudp(options) -} - -fn activate_rudp(options: Options) -> Result<()> { - ensure_state_dirs(&options)?; - let mut node = open_node(&options, "muninn-activation")?; - let plan = build_mux_plan(&options, timestamp()?); - write_command_file(&plan)?; - - let supervisor_pid = std::process::id(); - let mut restart_count = 0; - - loop { - publish_surface( - &mut node, - &options, - "active", - std::slice::from_ref(&options.stream_id), - )?; - publish_runtime_boundary_records( - &mut node, - &options, - "active", - std::slice::from_ref(&options.stream_id), - &[], - )?; - - match run_rudp_mux_once(&options, &plan, &mut node, supervisor_pid, restart_count) { - Ok(restart) => { - restart_count += 1; - publish_stream( - &mut node, - &options, - &plan, - "restarting", - supervisor_pid, - None, - restart_count, - &restart.detail, - )?; - thread::sleep(restart.delay); - } - Err(error) => { - restart_count += 1; - publish_stream( - &mut node, - &options, - &plan, - "failed", - supervisor_pid, - None, - restart_count, - &format!("RUDP mux failed: {error:#}"), - )?; - return Err(error); - } - } - } -} - -struct RudpMuxRestart { - detail: String, - delay: Duration, -} - -fn rudp_media_activity_detail(options: &Options) -> String { - match (options.capture_video, options.capture_audio) { - (true, true) => "typed video/audio access units are publishing over CultNet RUDP media", - (true, false) => "typed video access units are publishing over CultNet RUDP media", - (false, true) => "typed audio packets are publishing over CultNet RUDP media", - (false, false) => "Muninn RUDP media is idle", - } - .to_string() -} - -fn republish_running_stream_if_due( - node: &mut cultmesh_rs::CultMeshNode, - options: &Options, - plan: &MuxPlan, - supervisor_pid: u32, - mux_pid: u32, - restart_count: u32, - last_stream_publish_at: &mut Instant, -) -> Result<()> { - if last_stream_publish_at.elapsed() - < Duration::from_millis(MUNINN_RUDP_ACTIVE_CATALOG_REPUBLISH_MS) - { - return Ok(()); - } - publish_stream( - node, - options, - plan, - "running", - supervisor_pid, - Some(mux_pid), - restart_count, - &rudp_media_activity_detail(options), - )?; - *last_stream_publish_at = Instant::now(); - Ok(()) -} - -fn run_rudp_mux_once( - options: &Options, - plan: &MuxPlan, - node: &mut cultmesh_rs::CultMeshNode, - supervisor_pid: u32, - restart_count: u32, -) -> Result { - let timestamp = timestamp()?; - let loopback_stderr = options - .log_root - .join(format!("muninn-{timestamp}.loopback.err.log")); - let video_ffmpeg_stderr = options - .log_root - .join(format!("muninn-{timestamp}.ffmpeg-video.err.log")); - let audio_ffmpeg_stderr = options - .log_root - .join(format!("muninn-{timestamp}.ffmpeg-audio.err.log")); - - let mut loopback = if options.capture_audio { - Some( - Command::new("powershell.exe") - .args(loopback_args(options)) - .stdout(Stdio::piped()) - .stderr(fs::File::create(&loopback_stderr)?) - .spawn() - .with_context(|| { - format!( - "starting loopback capture {}", - options.loopback_script.display() - ) - })?, - ) - } else { - None - }; - let loopback_stdout = if let Some(process) = loopback.as_mut() { - Some( - process - .stdout - .take() - .context("loopback stdout was not piped")?, - ) - } else { - None - }; - - let mut video_ffmpeg = if options.capture_video { - Some( - Command::new(&options.ffmpeg_path) - .args(rudp_video_ffmpeg_args(options)) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(fs::File::create(&video_ffmpeg_stderr)?) - .spawn() - .with_context(|| format!("starting {} video encoder", options.ffmpeg_path))?, - ) - } else { - None - }; - let video_ffmpeg_stdout = if let Some(process) = video_ffmpeg.as_mut() { - Some( - process - .stdout - .take() - .context("video ffmpeg stdout was not piped")?, - ) - } else { - None - }; - let mut audio_ffmpeg = if options.capture_audio { - Some( - Command::new(&options.ffmpeg_path) - .args(rudp_audio_ffmpeg_args(options)) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(fs::File::create(&audio_ffmpeg_stderr)?) - .spawn() - .with_context(|| format!("starting {} audio encoder", options.ffmpeg_path))?, - ) - } else { - None - }; - let audio_ffmpeg_stdout = if let Some(process) = audio_ffmpeg.as_mut() { - Some( - process - .stdout - .take() - .context("audio ffmpeg stdout was not piped")?, - ) - } else { - None - }; - let audio_pump = match (loopback_stdout, audio_ffmpeg.as_mut()) { - (Some(mut reader), Some(process)) => { - let mut audio_ffmpeg_stdin = process - .stdin - .take() - .context("audio ffmpeg stdin was not piped")?; - Some(thread::spawn(move || -> Result<()> { - std::io::copy(&mut reader, &mut audio_ffmpeg_stdin)?; - Ok(()) - })) - } - _ => None, - }; - - let mut video_sender = None; - let mut audio_sender = None; - let result = (|| -> Result { - let media_profile = muninn_rudp_media_profile_for_options(options); - let mut video_transport = open_media_rudp_transport( - options, - node, - MUNINN_MEDIA_RUDP_CONNECTION_ID, - None, - "video", - )?; - let mut audio_transport = open_media_rudp_transport( - options, - node, - MUNINN_AUDIO_RUDP_CONNECTION_ID, - Some(media_profile.receiver_assembly_deadline_ms), - "audio", - )?; - publish_stream( - node, - options, - plan, - "running", - supervisor_pid, - video_ffmpeg - .as_ref() - .map(Child::id) - .or_else(|| audio_ffmpeg.as_ref().map(Child::id)), - restart_count, - &rudp_media_activity_detail(options), - )?; - let mut last_stream_publish_at = Instant::now(); - - let (payload_tx, payload_rx) = mpsc::channel::>(); - video_sender = if let Some(stdout) = video_ffmpeg_stdout { - Some(video_rudp_payload_reader( - payload_tx.clone(), - stdout, - VideoAnnexBStreamSendConfig { - stream_id: options.stream_id.clone(), - session_id: format!("{}:{timestamp}:video", options.host_id), - codec: media_profile.video_codec.to_string(), - first_frame_id: 0, - first_pts_ticks: 0, - frame_duration_ticks: video_frame_duration_ticks(options)?, - timebase_num: 1, - timebase_den: 90_000, - deadline_delay_ticks: rudp_media_deadline_delay_ticks(&media_profile), - max_payload_bytes: options.media_packet_bytes.max(256), - max_pending_bytes: options.media_packet_bytes.max(256) * 4096, - source_runtime_id: options.host_id.clone(), - source_role: "muninn.rudp.video".to_string(), - }, - )) - } else { - None - }; - audio_sender = if let Some(stdout) = audio_ffmpeg_stdout { - Some(audio_rudp_payload_reader( - payload_tx.clone(), - stdout, - AudioPcmStreamSendConfig { - stream_id: options.stream_id.clone(), - session_id: format!("{}:{timestamp}:audio", options.host_id), - codec: "pcm-f32le-interleaved".to_string(), - first_packet_id: 0, - first_pts_ticks: 0, - packet_duration_ticks: 480, - timebase_num: 1, - timebase_den: options.audio_sample_rate, - deadline_delay_ticks: rudp_audio_deadline_delay_ticks(options, &media_profile), - channels: options.audio_channels, - bytes_per_sample: 4, - max_pending_bytes: 480usize - .saturating_mul(options.audio_channels as usize) - .saturating_mul(4) - .saturating_mul(128), - source_runtime_id: options.host_id.clone(), - source_role: "muninn.rudp.audio".to_string(), - }, - )) - } else { - None - }; - drop(payload_tx); - - let mut payloads_sent = 0_u64; - let mut payloads_queue_expired = 0_u64; - let mut payloads_send_expired = 0_u64; - let mut receiver_feedback = MuninnRudpReceiverFeedbackStats::default(); - let mut handled_keyframe_requests = 0_u64; - let mut repair_cache = - RecentVideoChunkRepairCache::new(MUNINN_RUDP_MEDIA_REPAIR_CACHE_CHUNKS); - let mut repair_budget = MuninnRudpRepairBudget::new( - MUNINN_RUDP_MEDIA_REPAIR_INITIAL_CHUNKS_PER_SECOND, - MUNINN_RUDP_MEDIA_REPAIR_BURST_CHUNKS, - ); - let mut video_send_pacer = MuninnRudpMediaSendPacer::new( - media_profile.sender_pace_every_payloads, - Duration::from_micros(media_profile.sender_pace_sleep_us), - ); - let mut audio_send_pacer = MuninnRudpMediaSendPacer::new(0, Duration::ZERO); - let mut pending_payloads = PendingMuninnMediaSendQueues::default(); - let mut payload_channel_disconnected = false; - loop { - if pending_payloads.is_empty() && !payload_channel_disconnected { - payload_channel_disconnected = receive_pending_media_payloads( - &payload_rx, - &mut pending_payloads, - Duration::from_millis(5), - )?; - } else if !payload_channel_disconnected { - payload_channel_disconnected = - drain_available_media_payloads(&payload_rx, &mut pending_payloads)?; - } - - if let Some(queued) = pending_payloads.pop_next() { - if media_payload_queue_age_exceeded( - queued.queued_at, - Instant::now(), - Duration::from_millis(media_profile.sender_queue_deadline_ms), - ) { - payloads_queue_expired += 1; - let payloads_dropped = payloads_queue_expired + payloads_send_expired; - poll_rudp_media_receiver_feedback( - &mut video_transport, - &mut receiver_feedback, - &repair_cache, - &mut repair_budget, - &media_profile, - &mut video_send_pacer, - payloads_dropped, - )?; - record_receiver_keyframe_pressure( - &receiver_feedback, - &mut handled_keyframe_requests, - ); - poll_rudp_resends_with_backpressure(&mut video_transport)?; - poll_rudp_resends_with_backpressure(&mut audio_transport)?; - republish_running_stream_if_due( - node, - options, - plan, - supervisor_pid, - video_ffmpeg - .as_ref() - .map(Child::id) - .or_else(|| audio_ffmpeg.as_ref().map(Child::id)) - .unwrap_or(supervisor_pid), - restart_count, - &mut last_stream_publish_at, - )?; - if payloads_dropped == 1 || payloads_dropped % 300 == 0 { - let expired = reliable_packets_expired(&video_transport, &audio_transport); - eprintln!( - "{}", - rudp_media_progress_detail( - payloads_sent, - payloads_dropped, - payloads_queue_expired, - payloads_send_expired, - expired, - &receiver_feedback - ) - ); - } - continue; - } - - let payload_len = queued.payload.payload.len(); - let sent = match queued.kind { - QueuedMuninnMediaKind::Video => send_rudp_media_payload_with_backpressure( - &mut video_transport, - queued.payload.clone(), - queued.queued_at, - Duration::from_millis(media_profile.sender_queue_deadline_ms), - &mut video_send_pacer, - )?, - QueuedMuninnMediaKind::Audio => send_rudp_media_payload_with_backpressure( - &mut audio_transport, - queued.payload.clone(), - queued.queued_at, - Duration::from_millis(media_profile.sender_queue_deadline_ms), - &mut audio_send_pacer, - )?, - }; - if !sent { - payloads_send_expired += 1; - continue; - } - let payloads_dropped = payloads_queue_expired + payloads_send_expired; - if queued.kind == QueuedMuninnMediaKind::Video { - repair_cache.remember(&queued.payload)?; - } - poll_rudp_media_receiver_feedback( - &mut video_transport, - &mut receiver_feedback, - &repair_cache, - &mut repair_budget, - &media_profile, - &mut video_send_pacer, - payloads_dropped, - )?; - record_receiver_keyframe_pressure( - &receiver_feedback, - &mut handled_keyframe_requests, - ); - poll_rudp_resends_with_backpressure(&mut video_transport)?; - poll_rudp_resends_with_backpressure(&mut audio_transport)?; - republish_running_stream_if_due( - node, - options, - plan, - supervisor_pid, - video_ffmpeg - .as_ref() - .map(Child::id) - .or_else(|| audio_ffmpeg.as_ref().map(Child::id)) - .unwrap_or(supervisor_pid), - restart_count, - &mut last_stream_publish_at, - )?; - payloads_sent += 1; - if payloads_sent == 1 || payloads_sent % 900 == 0 { - let expired = reliable_packets_expired(&video_transport, &audio_transport); - eprintln!( - "{}; pending_audio={} pending_video={}; latest {:?} payload was {payload_len} bytes.", - rudp_media_progress_detail( - payloads_sent, - payloads_dropped, - payloads_queue_expired, - payloads_send_expired, - expired, - &receiver_feedback - ), - pending_payloads.audio_len(), - pending_payloads.video_len(), - queued.kind - ); - } - continue; - } - - if payload_channel_disconnected { - let expired = reliable_packets_expired(&video_transport, &audio_transport); - let payloads_dropped = payloads_queue_expired + payloads_send_expired; - break Ok(RudpMuxRestart { - detail: format!( - "encoder stdout ended; {}", - rudp_media_progress_detail( - payloads_sent, - payloads_dropped, - payloads_queue_expired, - payloads_send_expired, - expired, - &receiver_feedback - ) - ), - delay: default_rudp_mux_restart_delay(restart_count), - }); - } - - { - let payloads_dropped = payloads_queue_expired + payloads_send_expired; - poll_rudp_media_receiver_feedback( - &mut video_transport, - &mut receiver_feedback, - &repair_cache, - &mut repair_budget, - &media_profile, - &mut video_send_pacer, - payloads_dropped, - )?; - record_receiver_keyframe_pressure( - &receiver_feedback, - &mut handled_keyframe_requests, - ); - poll_rudp_resends_with_backpressure(&mut video_transport)?; - poll_rudp_resends_with_backpressure(&mut audio_transport)?; - republish_running_stream_if_due( - node, - options, - plan, - supervisor_pid, - video_ffmpeg - .as_ref() - .map(Child::id) - .or_else(|| audio_ffmpeg.as_ref().map(Child::id)) - .unwrap_or(supervisor_pid), - restart_count, - &mut last_stream_publish_at, - )?; - } - } - })(); - - if let Some(process) = video_ffmpeg.as_mut() { - let _ = process.kill(); - let _ = process.wait(); - } - if let Some(process) = audio_ffmpeg.as_mut() { - let _ = process.kill(); - let _ = process.wait(); - } - if let Some(process) = loopback.as_mut() { - let _ = process.kill(); - let _ = process.wait(); - } - if let Some(pump) = audio_pump { - let _ = pump.join(); - } - if let Some(sender) = video_sender { - let _ = sender.join(); - } - if let Some(sender) = audio_sender { - let _ = sender.join(); - } - result -} - -struct QueuedMuninnMediaSendPayload { - payload: MuninnMediaSendPayload, - queued_at: Instant, - kind: QueuedMuninnMediaKind, -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum QueuedMuninnMediaKind { - Video, - Audio, -} - -#[derive(Default)] -struct PendingMuninnMediaSendQueues { - audio: VecDeque, - video: VecDeque, -} - -impl PendingMuninnMediaSendQueues { - fn push(&mut self, payload: QueuedMuninnMediaSendPayload) { - match payload.kind { - QueuedMuninnMediaKind::Audio => self.audio.push_back(payload), - QueuedMuninnMediaKind::Video => self.video.push_back(payload), - } - } - - fn pop_next(&mut self) -> Option { - self.audio.pop_front().or_else(|| self.video.pop_front()) - } - - fn is_empty(&self) -> bool { - self.audio.is_empty() && self.video.is_empty() - } - - fn audio_len(&self) -> usize { - self.audio.len() - } - - fn video_len(&self) -> usize { - self.video.len() - } -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -struct MuninnRudpReceiverFeedbackStats { - feedback_records: u64, - requested_keyframes: u64, - late_frames: u64, - missing_video_chunks: u64, - repaired_video_chunks: u64, - deferred_repair_chunks: u64, - repair_chunks_per_second: usize, - highest_decodable_frame_id: Option, -} - -#[derive(Debug)] -struct MuninnRudpRepairBudget { - chunks_per_second: usize, - min_chunks_per_second: usize, - max_chunks_per_second: usize, - add_chunks_per_second: usize, - recovery_interval: Duration, - max_available_chunks: usize, - available_chunks: usize, - last_refill_at: Instant, - last_rate_adjust_at: Instant, - last_queue_dropped: u64, -} - -impl MuninnRudpRepairBudget { - fn new(chunks_per_second: usize, max_available_chunks: usize) -> Self { - let now = Instant::now(); - let max_available_chunks = max_available_chunks.max(1); - Self { - chunks_per_second: chunks_per_second.max(1), - min_chunks_per_second: MUNINN_RUDP_MEDIA_REPAIR_MIN_CHUNKS_PER_SECOND, - max_chunks_per_second: MUNINN_RUDP_MEDIA_REPAIR_MAX_CHUNKS_PER_SECOND, - add_chunks_per_second: MUNINN_RUDP_MEDIA_REPAIR_ADD_CHUNKS_PER_SECOND, - recovery_interval: Duration::from_millis(MUNINN_RUDP_MEDIA_REPAIR_RECOVERY_INTERVAL_MS), - max_available_chunks, - available_chunks: max_available_chunks, - last_refill_at: now, - last_rate_adjust_at: now, - last_queue_dropped: 0, - } - } - - fn chunks_per_second(&self) -> usize { - self.chunks_per_second - } - - fn take(&mut self, requested_chunks: usize, now: Instant, queue_dropped: u64) -> usize { - self.adjust_rate(now, queue_dropped, requested_chunks); - self.refill(now); - let allowed = requested_chunks.min(self.available_chunks); - self.available_chunks -= allowed; - allowed - } - - fn adjust_rate(&mut self, now: Instant, queue_dropped: u64, requested_chunks: usize) { - if queue_dropped > self.last_queue_dropped { - self.chunks_per_second = (self.chunks_per_second / 2).max(self.min_chunks_per_second); - self.available_chunks = self.available_chunks.min(self.chunks_per_second); - self.last_queue_dropped = queue_dropped; - self.last_rate_adjust_at = now; - return; - } - self.last_queue_dropped = queue_dropped; - if requested_chunks == 0 - || now.saturating_duration_since(self.last_rate_adjust_at) < self.recovery_interval - { - return; - } - if self.chunks_per_second < self.max_chunks_per_second { - self.chunks_per_second = self - .chunks_per_second - .saturating_add(self.add_chunks_per_second) - .min(self.max_chunks_per_second); - self.last_rate_adjust_at = now; - } - } - - fn refill(&mut self, now: Instant) { - let elapsed_ms = now - .saturating_duration_since(self.last_refill_at) - .as_millis() as usize; - if elapsed_ms == 0 { - return; - } - let refill_chunks = elapsed_ms.saturating_mul(self.chunks_per_second) / 1_000; - if refill_chunks == 0 { - return; - } - self.available_chunks = self - .available_chunks - .saturating_add(refill_chunks) - .min(self.max_available_chunks); - self.last_refill_at = now; - } -} - -#[derive(Debug)] -struct RecentVideoChunkRepairCache { - max_entries: usize, - order: VecDeque, - entries: HashMap, -} - -impl RecentVideoChunkRepairCache { - fn new(max_entries: usize) -> Self { - Self { - max_entries, - order: VecDeque::new(), - entries: HashMap::new(), - } - } - - fn remember(&mut self, payload: &MuninnMediaSendPayload) -> Result<()> { - let Some(key) = video_repair_cache_key_from_payload(payload)? else { - return Ok(()); - }; - if self.entries.contains_key(&key) { - self.entries.insert(key, payload.clone()); - return Ok(()); - } - self.order.push_back(key.clone()); - self.entries.insert(key, payload.clone()); - while self.entries.len() > self.max_entries { - let Some(expired) = self.order.pop_front() else { - break; - }; - self.entries.remove(&expired); - } - Ok(()) - } - - fn repair_payloads_for_feedback( - &self, - feedback: &MuninnMediaReceiverFeedbackRecord, - ) -> Vec { - feedback - .missing_video_chunk_keys - .iter() - .filter_map(|chunk_key| { - self.entries - .get(&video_repair_cache_key( - &feedback.stream_id, - &feedback.session_id, - chunk_key, - )) - .cloned() - }) - .collect() - } -} - -fn video_repair_cache_key(stream_id: &str, session_id: &str, chunk_key: &str) -> String { - format!("{stream_id}:{session_id}:video:{chunk_key}") -} - -fn video_repair_cache_key_from_payload(payload: &MuninnMediaSendPayload) -> Result> { - if payload.channel_id != crate::media_packetizer::MUNINN_MEDIA_RUDP_CHANNEL { - return Ok(None); - } - let MuninnMediaWireRecord::Video(video) = decode_media_wire_record(&payload.payload)? else { - return Ok(None); - }; - Ok(Some(video_repair_cache_key( - &video.stream_id, - &video.session_id, - &crate::media_packetizer::video_chunk_feedback_key(video.frame_id, video.chunk_index), - ))) -} - -fn queue_muninn_media_payload( - tx: &mpsc::Sender>, - payload: MuninnMediaSendPayload, - kind: QueuedMuninnMediaKind, -) -> Result<()> { - tx.send(Ok(QueuedMuninnMediaSendPayload { - payload, - queued_at: Instant::now(), - kind, - })) - .context("queueing typed Muninn media payload") -} - -fn receive_pending_media_payloads( - rx: &mpsc::Receiver>, - pending: &mut PendingMuninnMediaSendQueues, - timeout: Duration, -) -> Result { - match rx.recv_timeout(timeout) { - Ok(Ok(payload)) => { - pending.push(payload); - drain_available_media_payloads(rx, pending) - } - Ok(Err(error)) => Err(error), - Err(mpsc::RecvTimeoutError::Timeout) => Ok(false), - Err(mpsc::RecvTimeoutError::Disconnected) => Ok(true), - } -} - -fn drain_available_media_payloads( - rx: &mpsc::Receiver>, - pending: &mut PendingMuninnMediaSendQueues, -) -> Result { - loop { - match rx.try_recv() { - Ok(Ok(payload)) => pending.push(payload), - Ok(Err(error)) => return Err(error), - Err(mpsc::TryRecvError::Empty) => return Ok(false), - Err(mpsc::TryRecvError::Disconnected) => return Ok(true), - } - } -} - -fn media_payload_queue_age_exceeded(queued_at: Instant, now: Instant, max_age: Duration) -> bool { - now.saturating_duration_since(queued_at) > max_age -} - -fn poll_rudp_resends_with_backpressure( - transport: &mut CultNetRudpSocketTransportConnection, -) -> Result<()> { - match transport.poll_resends() { - Ok(()) => Ok(()), - Err(error) if is_would_block_error(&error) => Ok(()), - Err(error) => Err(error).context("polling Muninn RUDP media resends"), - } -} - -fn send_rudp_media_payload_with_backpressure( - transport: &mut CultNetRudpSocketTransportConnection, - payload: MuninnMediaSendPayload, - queued_at: Instant, - max_age: Duration, - send_pacer: &mut MuninnRudpMediaSendPacer, -) -> Result { - loop { - match transport.send(payload.channel_id, payload.payload.clone()) { - Ok(()) => { - send_pacer.observe_sent_payload(); - return Ok(true); - } - Err(error) if is_would_block_error(&error) => { - if media_payload_queue_age_exceeded(queued_at, Instant::now(), max_age) { - return Ok(false); - } - poll_rudp_resends_with_backpressure(transport)?; - thread::sleep(Duration::from_millis(1)); - } - Err(error) => { - return Err(error).context("sending typed Muninn media payload over RUDP media"); - } - } - } -} - -#[derive(Debug)] -struct MuninnRudpMediaSendPacer { - payloads_since_pause: usize, - every_payloads: usize, - sleep_for: Duration, -} - -impl MuninnRudpMediaSendPacer { - fn new(every_payloads: usize, sleep_for: Duration) -> Self { - Self { - payloads_since_pause: 0, - every_payloads: every_payloads.max(1), - sleep_for, - } - } - - fn observe_sent_payload(&mut self) { - if self.sleep_for.is_zero() { - return; - } - self.payloads_since_pause = self.payloads_since_pause.saturating_add(1); - if self.payloads_since_pause < self.every_payloads { - return; - } - self.payloads_since_pause = 0; - thread::sleep(self.sleep_for); - } -} - -fn is_would_block_error(error: &anyhow::Error) -> bool { - error.chain().any(|cause| { - cause - .downcast_ref::() - .is_some_and(|io| io.kind() == ErrorKind::WouldBlock) - || cause.to_string().contains("os error 10035") - }) -} - -fn default_rudp_mux_restart_delay(restart_count: u32) -> Duration { - Duration::from_secs((2 + restart_count).min(30) as u64) -} - -fn record_receiver_keyframe_pressure( - receiver_feedback: &MuninnRudpReceiverFeedbackStats, - handled_keyframe_requests: &mut u64, -) { - if receiver_feedback.requested_keyframes <= *handled_keyframe_requests { - return; - } - *handled_keyframe_requests = receiver_feedback.requested_keyframes; - eprintln!( - "Muninn RUDP receiver requested a fresh keyframe; continuing current low-latency encoder session until explicit encoder control exists." - ); -} - -fn rudp_media_progress_detail( - sent: u64, - queue_dropped: u64, - queue_expired: u64, - send_expired: u64, - reliable_expired: u64, - receiver_feedback: &MuninnRudpReceiverFeedbackStats, -) -> String { - format!( - "Muninn RUDP media progress: sent={sent} queue_dropped={queue_dropped} queue_expired={queue_expired} send_expired={send_expired} reliable_expired={reliable_expired} receiver_feedback={} receiver_keyframes={} receiver_late_frames={} receiver_missing_chunks={} receiver_repaired_chunks={} receiver_deferred_repairs={} repair_rate={} receiver_highest_decodable={}", - receiver_feedback.feedback_records, - receiver_feedback.requested_keyframes, - receiver_feedback.late_frames, - receiver_feedback.missing_video_chunks, - receiver_feedback.repaired_video_chunks, - receiver_feedback.deferred_repair_chunks, - receiver_feedback.repair_chunks_per_second, - receiver_feedback - .highest_decodable_frame_id - .map(|frame_id| frame_id.to_string()) - .unwrap_or_else(|| "none".to_string()) - ) -} - -fn poll_rudp_media_receiver_feedback( - transport: &mut CultNetRudpSocketTransportConnection, - stats: &mut MuninnRudpReceiverFeedbackStats, - repair_cache: &RecentVideoChunkRepairCache, - repair_budget: &mut MuninnRudpRepairBudget, - media_profile: &MuninnRudpMediaProfile, - send_pacer: &mut MuninnRudpMediaSendPacer, - queue_dropped: u64, -) -> Result<()> { - let mut feedback_processed = 0_usize; - loop { - match transport.receive_once() { - Ok(Some(frame)) => { - if feedback_processed >= MUNINN_RUDP_MEDIA_REPAIR_MAX_FEEDBACK_PER_POLL { - return Ok(()); - } - feedback_processed += 1; - let repair_payloads = - record_rudp_media_receiver_feedback(&frame, stats, repair_cache)?; - let requested_repairs = repair_payloads.len(); - let poll_limited_repairs = - requested_repairs.min(MUNINN_RUDP_MEDIA_REPAIR_MAX_CHUNKS_PER_POLL); - let allowed_repairs = - repair_budget.take(poll_limited_repairs, Instant::now(), queue_dropped); - stats.repair_chunks_per_second = repair_budget.chunks_per_second(); - stats.deferred_repair_chunks = stats - .deferred_repair_chunks - .saturating_add(requested_repairs.saturating_sub(allowed_repairs) as u64); - for payload in repair_payloads.into_iter().take(allowed_repairs) { - if send_rudp_media_payload_with_backpressure( - transport, - payload, - Instant::now(), - Duration::from_millis(media_profile.sender_queue_deadline_ms), - send_pacer, - )? { - stats.repaired_video_chunks = stats.repaired_video_chunks.saturating_add(1); - } - } - } - Ok(None) => return Ok(()), - Err(error) if is_would_block_error(&error) => return Ok(()), - Err(error) => return Err(error).context("polling Muninn RUDP media feedback"), - } - } -} - -fn record_rudp_media_receiver_feedback( - frame: &CultNetTransportFrame, - stats: &mut MuninnRudpReceiverFeedbackStats, - repair_cache: &RecentVideoChunkRepairCache, -) -> Result> { - if frame.channel_id != crate::media_packetizer::MUNINN_MEDIA_RUDP_CHANNEL { - return Ok(Vec::new()); - } - - let MuninnMediaWireRecord::Feedback(feedback) = decode_media_wire_record(&frame.payload)? - else { - return Ok(Vec::new()); - }; - - let repair_payloads = repair_cache.repair_payloads_for_feedback(&feedback); - stats.feedback_records = stats.feedback_records.saturating_add(1); - if feedback.requested_keyframe { - stats.requested_keyframes = stats.requested_keyframes.saturating_add(1); - } - stats.late_frames = stats - .late_frames - .saturating_add(feedback.late_frame_ids.len() as u64); - stats.missing_video_chunks = stats - .missing_video_chunks - .saturating_add(feedback.missing_video_chunk_keys.len() as u64); - if let Some(frame_id) = feedback.highest_decodable_frame_id { - stats.highest_decodable_frame_id = Some( - stats - .highest_decodable_frame_id - .map(|current| current.max(frame_id)) - .unwrap_or(frame_id), - ); - } - Ok(repair_payloads) -} - -fn open_media_rudp_transport( - options: &Options, - node: &mut cultmesh_rs::CultMeshNode, - connection_id: u32, - reliable_expire_after_ms: Option, - role: &str, -) -> Result { - let media_profile = muninn_rudp_media_profile_for_options(options); - let endpoint = resolve_media_rudp_endpoint(options, node)?; - let socket = UdpSocket::bind("0.0.0.0:0").context("binding Muninn media RUDP client socket")?; - configure_media_rudp_socket_buffers(&socket) - .context("configuring Muninn media RUDP socket buffers")?; - socket - .set_nonblocking(true) - .context("setting Muninn media RUDP client nonblocking")?; - let mut transport = CultNetRudpSocketTransportConnection::new(muninn_media_rudp_options( - socket, - endpoint, - &media_profile, - connection_id, - reliable_expire_after_ms, - ))?; - transport.connect(options.stream_id.as_bytes().to_vec())?; - let deadline = Instant::now() + Duration::from_secs(5); - while !transport.connected() { - let _ = transport.receive_once()?; - transport.poll_resends()?; - if Instant::now() >= deadline { - return Err(anyhow!( - "timed out connecting Muninn {role} RUDP stream to {endpoint}" - )); - } - thread::sleep(Duration::from_millis(2)); - } - Ok(transport) -} - -fn resolve_media_rudp_endpoint( - options: &Options, - node: &mut cultmesh_rs::CultMeshNode, -) -> Result { - require_media_target_uri(options)?; - if let Some(obs_target_host) = options.obs_target_host.as_deref() - && !obs_target_host.trim().is_empty() - && options.obs_port != 0 - { - let endpoint = format!("{}:{}", obs_target_host.trim(), options.obs_port); - return endpoint.parse().with_context(|| { - format!( - "parsing command-owned OBS Muninn media RUDP endpoint {endpoint} for {}", - options.target_host - ) - }); - } - pull_odin_media_catalog_snapshot(node, options); - let endpoint = discover_provider_endpoints( - node, - OdinEndpointQuery { - schema: Some(MUNINN_MEDIA_RUDP_SCHEMA), - transport_contains: Some("rudp"), - host_hint: Some(&options.target_host), - device_filter: Some(&options.stream_id), - }, - ) - .into_iter() - .next() - .or_else(|| { - discover_provider_endpoints( - node, - OdinEndpointQuery { - schema: Some(MUNINN_MEDIA_RUDP_SCHEMA), - transport_contains: Some("rudp"), - host_hint: Some(&options.target_host), - device_filter: None, - }, - ) - .into_iter() - .next() - }) - .ok_or_else(|| { - anyhow!( - "Odin provider catalog did not advertise a {} endpoint for {}", - MUNINN_MEDIA_RUDP_SCHEMA, - options.target_host - ) - })?; - endpoint.address.parse().with_context(|| { - format!( - "parsing Odin-discovered Muninn media RUDP endpoint {} for {}", - endpoint.address, options.target_host - ) - }) -} - -fn pull_odin_media_catalog_snapshot(node: &mut cultmesh_rs::CultMeshNode, options: &Options) { - let Some(target) = resolve_odin_cultmesh_uri(options) else { - return; - }; - if let Err(error) = node.pull_rudp_catalog_snapshot(CultMeshRudpSnapshotOptions { - target, - runtime_id: format!("muninn-{}-media-target-catalog-client", options.host_id), - schema_ids: Some(vec![EVE_PROVIDER_ADVERTISEMENT_SCHEMA.to_string()]), - connect_timeout: Duration::from_millis(150), - response_timeout: Duration::from_millis(150), - resend_delay_ms: 15, - ..CultMeshRudpSnapshotOptions::default() - }) { - eprintln!("Muninn Odin media target catalog pull failed from {target}: {error:#}"); - } -} - -fn configure_media_rudp_socket_buffers(socket: &UdpSocket) -> Result<()> { - let socket = socket2::SockRef::from(socket); - socket - .set_send_buffer_size(MUNINN_RUDP_MEDIA_SOCKET_BUFFER_BYTES) - .context("setting Muninn media RUDP send buffer")?; - socket - .set_recv_buffer_size(MUNINN_RUDP_MEDIA_SOCKET_BUFFER_BYTES) - .context("setting Muninn media RUDP receive buffer")?; - Ok(()) -} - -fn muninn_media_rudp_options( - socket: UdpSocket, - endpoint: SocketAddr, - media_profile: &MuninnRudpMediaProfile, - connection_id: u32, - reliable_expire_after_ms: Option, -) -> CultNetRudpSocketTransportOptions { - let mut options = - CultNetRudpSocketTransportOptions::client("muninn-media", socket, endpoint, connection_id); - options.resend_delay_ms = media_profile.sender_resend_delay_ms; - options.max_fragment_bytes = Some(media_profile.max_fragment_bytes as u32); - options.media_reliable_expire_after_ms = reliable_expire_after_ms; - options -} - -fn reliable_packets_expired( - video_transport: &CultNetRudpSocketTransportConnection, - audio_transport: &CultNetRudpSocketTransportConnection, -) -> u64 { - video_transport - .stats() - .reliable_packets_expired - .saturating_add(audio_transport.stats().reliable_packets_expired) -} - -fn video_rudp_payload_reader( - tx: mpsc::Sender>, - mut reader: R, - config: VideoAnnexBStreamSendConfig, -) -> thread::JoinHandle<()> -where - R: Read + Send + 'static, -{ - thread::spawn(move || { - if let Err(error) = read_video_rudp_payloads(&tx, &mut reader, config) { - let _ = tx.send(Err(error)); - } - }) -} - -fn audio_rudp_payload_reader( - tx: mpsc::Sender>, - mut reader: R, - config: AudioPcmStreamSendConfig, -) -> thread::JoinHandle<()> -where - R: Read + Send + 'static, -{ - thread::spawn(move || { - if let Err(error) = read_audio_rudp_payloads(&tx, &mut reader, config) { - let _ = tx.send(Err(error)); - } - }) -} - -fn read_video_rudp_payloads( - tx: &mpsc::Sender>, - reader: &mut R, - config: VideoAnnexBStreamSendConfig, -) -> Result<()> -where - R: Read, -{ - let mut sender = VideoAnnexBStreamSendState::new(config)?; - let mut buffer = vec![0_u8; 64 * 1024]; - loop { - let read = reader - .read(&mut buffer) - .context("reading encoded Annex B video from ffmpeg stdout")?; - if read == 0 { - for payload in sender.finish(×tamp()?)? { - queue_muninn_media_payload(tx, payload, QueuedMuninnMediaKind::Video) - .context("queueing final typed video media payload")?; - } - return Ok(()); - } - for payload in sender.push(×tamp()?, &buffer[..read])? { - queue_muninn_media_payload(tx, payload, QueuedMuninnMediaKind::Video) - .context("queueing typed video media payload")?; - } - } -} - -fn read_audio_rudp_payloads( - tx: &mpsc::Sender>, - reader: &mut R, - config: AudioPcmStreamSendConfig, -) -> Result<()> -where - R: Read, -{ - let mut sender = AudioPcmStreamSendState::new(config)?; - let mut buffer = vec![0_u8; 16 * 1024]; - loop { - let read = reader - .read(&mut buffer) - .context("reading PCM audio from ffmpeg stdout")?; - if read == 0 { - for payload in sender.finish(×tamp()?)? { - queue_muninn_media_payload(tx, payload, QueuedMuninnMediaKind::Audio) - .context("queueing final typed audio media payload")?; - } - return Ok(()); - } - for payload in sender.push(×tamp()?, &buffer[..read])? { - queue_muninn_media_payload(tx, payload, QueuedMuninnMediaKind::Audio) - .context("queueing typed audio media payload")?; - } - } -} - -fn video_frame_duration_ticks(options: &Options) -> Result { - if options.framerate == 0 { - return Err(anyhow!("framerate must be greater than zero")); - } - Ok((90_000_u32 / options.framerate).max(1)) -} - -fn rudp_media_deadline_delay_ticks(media_profile: &MuninnRudpMediaProfile) -> i64 { - i64::try_from( - media_profile - .receiver_assembly_deadline_ms - .saturating_mul(90), - ) - .unwrap_or(i64::MAX) -} - -fn rudp_audio_deadline_delay_ticks( - options: &Options, - media_profile: &MuninnRudpMediaProfile, -) -> i64 { - if options.audio_sample_rate == 0 { - return i64::from(1_024); - } - i64::try_from( - media_profile - .receiver_assembly_deadline_ms - .saturating_mul(u64::from(options.audio_sample_rate)) - / 1_000, - ) - .unwrap_or(i64::MAX) - .max(i64::from(1_024)) -} - -fn publish_surface( - node: &mut cultmesh_rs::CultMeshNode, - options: &Options, - state: &str, - active_streams: &[String], -) -> Result<()> { - let video_sources = video_source_catalog(options); - let audio_sources = audio_source_catalog(options); - let record = MuninnTelemetrySurfaceRecord { - surface_id: options.surface_id.clone(), - host_id: options.host_id.clone(), - state: state.to_string(), - available_sources: available_sources(options), - stream_affordances: vec![ - "screen.capture.ddagrab".to_string(), - "audio.loopback.wasapi".to_string(), - "psmove.light.command".to_string(), - "psmove.controller.state".to_string(), - "quest.usb.access".to_string(), - "quest.input.openxr.witness".to_string(), - "quest.video.warp_corrected_input".to_string(), - "audio.input.enumeration.pending".to_string(), - "video.input.enumeration.pending".to_string(), - ], - active_streams: active_streams.to_vec(), - activation_authority: "muninn.activate.explicit-request".to_string(), - detail: if active_streams.is_empty() { - "Muninn is idle; no capture streams are consuming resources.".to_string() - } else { - "Muninn has explicit active stream requests.".to_string() - }, - updated_at: timestamp()?, - primary_stream_id: options.stream_id.clone(), - primary_stream_label: format!("{} screen and loopback A/V", options.host_id), - command_rudp_target: String::new(), - media_target_host: options.target_host.clone(), - media_port: options.port, - media_packet_bytes: options.media_packet_bytes as u32, - rudp_video_bitrate_kbps: options.rudp_video_bitrate_kbps, - rudp_latency_budget_ms: options.rudp_latency_budget_ms, - video_source_ids: video_sources - .iter() - .map(|source| source.id.clone()) - .collect(), - video_source_labels: video_sources - .iter() - .map(|source| source.label.clone()) - .collect(), - audio_source_ids: audio_sources - .iter() - .map(|source| source.id.clone()) - .collect(), - audio_source_labels: audio_sources - .iter() - .map(|source| source.label.clone()) - .collect(), - }; - node.put("latest", &record)?; - node.put(&record.surface_id, &record)?; - publish_move_hue_eve_surface(node, options)?; - Ok(()) -} - -fn publish_move_hue_eve_surface( - node: &mut cultmesh_rs::CultMeshNode, - options: &Options, -) -> Result<()> { - let program_key = move_hue_program_key(&options.host_id); - let program = node - .get::(&program_key)? - .unwrap_or_else(|| bootstrap_move_hue_program(options)); - let provider_id = muninn_provider_id(options); - let surface_key = move_hue_surface_key(&options.host_id); - let action = |id: &str, - label: &str, - mode: Option<&str>, - order_mode: Option<&str>, - cycle_ms: Option, - transition_percent: Option| { - json!({ - "id": format!("{provider_id}.move-hue.{id}"), - "kind": "card", - "props": { - "title": label, - "commandId": "muninn.set-move-hue-program", - "action": { - "type": "muninn.set-move-hue-program", - "providerId": provider_id, - "programId": program_key, - "schema": MUNINN_MOVE_HUE_PROGRAM_SCHEMA, - "mode": mode, - "orderMode": order_mode, - "cycleMs": cycle_ms, - "transitionPercent": transition_percent - } - }, - "children": [] - }) - }; - let surface = EveSurfaceStateRecord { - provider_id: provider_id.clone(), - title: format!("Muninn {} Move Hue Program", options.host_id), - version: SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as i64, - updated_at: timestamp()?, - surface: json!({ - "schema": "gamecult.eve.surface.v1", - "id": format!("{provider_id}.move-hue.surface"), - "title": "Move Hue Program", - "root": { - "id": format!("{provider_id}.move-hue.root"), - "kind": "dashboard", - "props": { - "title": "Move Hue Program", - "summary": format!("{} / {} / {} ms / {}% transition", program.mode, program.order_mode, program.cycle_ms, effective_transition_percent(&program)) - }, - "children": [ - { - "id": format!("{provider_id}.move-hue.state"), - "kind": "pane", - "props": { "title": "State" }, - "children": [ - { "id": format!("{provider_id}.move-hue.state.mode"), "kind": "text", "props": { "text": format!("mode: {}", program.mode) } }, - { "id": format!("{provider_id}.move-hue.state.order"), "kind": "text", "props": { "text": format!("order: {}", program.order_mode) } }, - { "id": format!("{provider_id}.move-hue.state.rate"), "kind": "text", "props": { "text": format!("cycle: {} ms", program.cycle_ms) } }, - { "id": format!("{provider_id}.move-hue.state.transition"), "kind": "text", "props": { "text": format!("transition: {}%", effective_transition_percent(&program)) } } - ] - }, - { - "id": format!("{provider_id}.move-hue.mode"), - "kind": "pane", - "props": { "title": "Mode" }, - "children": [ - action("mode-animated", "Animate", Some("animated"), None, None, None), - action("mode-hold", "Hold Current Colors", Some("hold"), None, None, None), - action("mode-static", "Static Palette", Some("static"), None, None, None) - ] - }, - { - "id": format!("{provider_id}.move-hue.order"), - "kind": "pane", - "props": { "title": "Update Order" }, - "children": [ - action("order-descending", "Descending", None, Some("descending"), None, None), - action("order-ascending", "Ascending", None, Some("ascending"), None, None), - action("order-bounce", "Bounce", None, Some("bounce"), None, None), - action("order-rotating", "Rotating Lead", None, Some("rotating-lead"), None, None), - action("order-golden", "Golden Permutation", None, Some("golden-permutation"), None, None) - ] - }, - { - "id": format!("{provider_id}.move-hue.rate"), - "kind": "pane", - "props": { "title": "Cycle Rate" }, - "children": [ - action("rate-500", "2 Hz", None, None, Some(500), None), - action("rate-1000", "1 Hz", None, None, Some(1000), None), - action("rate-2000", "0.5 Hz", None, None, Some(2000), None) - ] - }, - { - "id": format!("{provider_id}.move-hue.transition"), - "kind": "pane", - "props": { "title": "Transition Duration" }, - "children": [ - action("transition-0", "Off", None, None, None, Some(0)), - action("transition-10", "10%", None, None, None, Some(10)), - action("transition-25", "25%", None, None, None, Some(25)), - action("transition-50", "50%", None, None, None, Some(50)), - action("transition-75", "75%", None, None, None, Some(75)), - action("transition-100", "100%", None, None, None, Some(100)) - ] - } - ] - } - }), - }; - node.put(&surface_key, &surface)?; - if let Some(target) = resolve_odin_cultmesh_uri(options) { - let _ = node.publish_document_to_rudp_catalog( - &program_key, - &program, - CultMeshRudpDocumentPublishOptions { - target, - runtime_id: muninn_daemon_id(options), - source_role: Some("muninn.move-hue-program-state".to_string()), - tags: vec!["muninn".to_string(), "move-hue-program".to_string()], - flush_timeout: Duration::from_millis(300), - resend_delay_ms: 15, - ..CultMeshRudpDocumentPublishOptions::default() - }, - ); - let _ = node.publish_document_to_rudp_catalog( - &surface_key, - &surface, - CultMeshRudpDocumentPublishOptions { - target, - runtime_id: muninn_daemon_id(options), - source_role: Some("muninn.move-hue-eve-surface".to_string()), - tags: vec!["muninn".to_string(), "eve-surface".to_string()], - flush_timeout: Duration::from_millis(300), - resend_delay_ms: 15, - ..CultMeshRudpDocumentPublishOptions::default() - }, - ); - } - Ok(()) -} - -fn publish_runtime_boundary_records( - node: &mut cultmesh_rs::CultMeshNode, - options: &Options, - state: &str, - active_streams: &[String], - live_move_sources: &[MoveStateSource], -) -> Result<()> { - let provider_id = muninn_provider_id(options); - let daemon_id = muninn_daemon_id(options); - let updated_at = timestamp()?; - let available_sources = available_sources(options); - let command_boundary_key = format!("command-boundary:{daemon_id}"); - let transport_profile_key = format!("transport-profile:{daemon_id}"); - let store_path = options.store_path.display().to_string(); - let log_root = options.log_root.display().to_string(); - let stream_id = options.stream_id.clone(); - let activation_store_path = options - .activation_store_path - .as_deref() - .unwrap_or(&options.store_path); - let activation_command = if options.target_host.trim().is_empty() { - None - } else { - let command = format!( - "muninn request-stream --store {} --activate-store {} --host {} --stream {} --target-host {} --port {} --media-transport {}", - store_path, - activation_store_path.display(), - options.host_id, - options.stream_id, - options.target_host, - options.port, - media_transport_cli(&options.media_transport) - ); - Some(command) - }; - let command_lowerings = activation_command - .as_ref() - .map(|command| json!([command])) - .unwrap_or_else(|| json!([])); - let current_transport = if options.idunn_rudp_health.is_some() { - "daemon-published-rudp-health + daemon-owned-cultcache-telemetry-store" - } else { - "daemon-owned-cultcache-telemetry-store + missing-rudp-health-publication" - }; - let transport_state = if options.idunn_rudp_health.is_some() { - "rudp-health-and-provider-store-live" - } else { - "cultcache-provider-store-only" - }; - let hid_controller_endpoint = options.hid_controller_rudp_bind.map(|bind| { - options - .hid_controller_rudp_advertise - .clone() - .unwrap_or_else(|| bind.to_string()) - }); - let mut provider_routes = vec![json!({ - "transport": "cultcache-store", - "address": options.store_path.display().to_string() - })]; - if let Some(bind) = options.command_rudp_bind { - let address = options - .command_rudp_advertise - .clone() - .unwrap_or_else(|| bind.to_string()); - provider_routes.push(json!({ - "id": "muninn.provider.command", - "role": "muninn provider command ingress", - "uri": format!("cultmesh://{address}/muninn/{}/commands", options.host_id), - "transport": CULTNET_RUDP_PROTOCOL_ID, - "address": address, - "connectionId": MUNINN_COMMAND_RUDP_CONNECTION_ID, - "channel": "schema", - "schema": "gamecult.eve.command.v1", - "tags": ["muninn", "command", "provider-owned"] - })); - } - let hid_controller_devices_snake = live_move_sources - .iter() - .map(|source| { - json!({ - "device_id": source.move_id, - "device_kind": hid_controller_kind_from_source(source), - "source_path": source.hidraw_path - }) - }) - .collect::>(); - let hid_controller_devices_camel = live_move_sources - .iter() - .map(|source| { - json!({ - "deviceId": source.move_id, - "deviceKind": hid_controller_kind_from_source(source), - "sourcePath": source.hidraw_path - }) - }) - .collect::>(); - let input_stream_id = format!("muninn:{}:hid-controller-state", options.host_id); - let transport_input_streams = hid_controller_endpoint - .as_ref() - .map(|endpoint| { - let mut streams = vec![json!({ - "stream_id": input_stream_id, - "schema": "muninn.hid_controller_state.v1", - "transport": CULTNET_RUDP_PROTOCOL_ID, - "address": endpoint, - "connection_id": MUNINN_HID_CONTROLLER_RUDP_CONNECTION_ID, - "channel_id": "latest", - "producer": "Muninn IO daemon", - "devices": hid_controller_devices_snake - })]; - if let Some(stream_id) = options.move_evidence_stream_id.as_ref() { - streams.push(json!({ - "stream_id": stream_id, - "schema": "mimir.muninn_move_evidence_stream_frame.v1", - "transport": CULTNET_RUDP_PROTOCOL_ID, - "address": endpoint, - "connection_id": MUNINN_HID_CONTROLLER_RUDP_CONNECTION_ID, - "channel_id": "move-evidence", - "producer": "Muninn Move evidence runtime" - })); - } - json!(streams) - }) - .unwrap_or_else(|| json!([])); - let provider_input_streams = hid_controller_endpoint - .as_ref() - .map(|endpoint| { - let mut streams = vec![json!({ - "streamId": input_stream_id, - "schema": "muninn.hid_controller_state.v1", - "transport": CULTNET_RUDP_PROTOCOL_ID, - "address": endpoint, - "connectionId": MUNINN_HID_CONTROLLER_RUDP_CONNECTION_ID, - "channel": "latest", - "devices": hid_controller_devices_camel - })]; - if let Some(stream_id) = options.move_evidence_stream_id.as_ref() { - streams.push(json!({ - "streamId": stream_id, - "schema": "mimir.muninn_move_evidence_stream_frame.v1", - "transport": CULTNET_RUDP_PROTOCOL_ID, - "address": endpoint, - "connectionId": MUNINN_HID_CONTROLLER_RUDP_CONNECTION_ID, - "channel": "move-evidence" - })); - } - json!(streams) - }) - .unwrap_or_else(|| json!([])); - let mut provider_endpoints = vec![json!({ - "transport": "cultcache-store", - "address": options.store_path.display().to_string() - })]; - if let Some(endpoint) = hid_controller_endpoint.as_ref() { - provider_endpoints.push(json!({ - "transport": CULTNET_RUDP_PROTOCOL_ID, - "role": "muninn.hid_controller_state", - "schema": "muninn.hid_controller_state.v1", - "address": endpoint, - "connectionId": MUNINN_HID_CONTROLLER_RUDP_CONNECTION_ID, - "channel": "latest" - })); - if options.move_evidence_stream_id.is_some() { - provider_endpoints.push(json!({ - "transport": CULTNET_RUDP_PROTOCOL_ID, - "role": "muninn.move_evidence_stream", - "schema": "mimir.muninn_move_evidence_stream_frame.v1", - "address": endpoint, - "connectionId": MUNINN_HID_CONTROLLER_RUDP_CONNECTION_ID, - "channel": "move-evidence" - })); - } - } - let media_profile = muninn_rudp_media_profile_for_options(options); - let command_boundary = MuninnCommandBoundaryCompatRecord { - value: json!({ - "schema": "muninn.command_boundary.v1", - "boundary_id": command_boundary_key, - "daemon_id": daemon_id, - "provider_id": provider_id, - "service_id": provider_id, - "host_id": options.host_id, - "owner": "Muninn telemetry runtime", - "updated_at": updated_at, - "state_store": store_path, - "log_root": log_root, - "lifecycle_authority": "idunn-supervisor-command", - "health_publication": options.idunn_rudp_health.as_ref().map(|idunn| json!({ - "contract": idunn.health_contract, - "transport": CULTNET_RUDP_PROTOCOL_ID, - "publication_source": "daemon-published", - "endpoint": idunn.endpoint.to_string(), - "state_owner": "Muninn serve process" - })).unwrap_or_else(|| json!({ - "contract": serde_json::Value::Null, - "transport": "unconfigured", - "publication_source": "missing-daemon-publication", - "state_owner": "Muninn local store" - })), - "commands": [ - { - "command": "muninn.capture_stream_command", - "ingress": "cultmesh-document", - "schema": "muninn.capture_stream_command.v1", - "invocation": activation_command, - "state": if activation_command.is_some() { "configured" } else { "missing-media-target" }, - "owns": [ - "explicit capture stream activation", - "ffmpeg/loopback process launch", - "active muninn.capture_stream receipts" - ] - }, - { - "command": "muninn.move_light_command", - "ingress": "cultmesh-document", - "schema": "muninn.move_light_command.v1", - "owns": [ - "PS Move light pulses for attached local controllers" - ] - }, - { - "command": "muninn.set-move-hue-program", - "ingress": "cultmesh-document", - "schema": MUNINN_MOVE_HUE_PROGRAM_SCHEMA, - "record_key": move_hue_program_key(&options.host_id), - "owns": [ - "live Move hue mode", - "cycle duration", - "deterministic update order", - "hold timestamp" - ] - } - ], - "forbidden_authority": "Task Scheduler, HTTP probes, OBS consumers, and one-shot health commands do not own Muninn telemetry truth or stream activation state.", - }), - }; - let transport_profile = MuninnTransportProfileCompatRecord { - value: json!({ - "schema": "muninn.transport_profile.v1", - "profile_id": transport_profile_key, - "daemon_id": daemon_id, - "provider_id": provider_id, - "host_id": options.host_id, - "target_transport": CULTNET_RUDP_PROTOCOL_ID, - "current_transport": current_transport, - "state": transport_state, - "health_contract": options.idunn_rudp_health.as_ref().map(|idunn| idunn.health_contract.clone()), - "provider_advertisement_schema": "gamecult.eve.provider_advertisement.v1", - "command_boundary_schema": "muninn.command_boundary.v1", - "telemetry_surface_schema": "muninn.telemetry_surface.v1", - "media_profile": { - "profile_id": media_profile.profile_id, - "owner": "Muninn capture runtime", - "strategy": "hardware-codec-owned-prediction-over-fixed-budget-rudp", - "visual_prediction_owner": "NVENC H.264 inter-frame prediction", - "transport_owner": "CultNet RUDP media channel", - "codec": media_profile.video_codec, - "encoder": media_profile.video_encoder, - "encoder_preset": media_profile.video_preset, - "encoder_tune": media_profile.video_tune, - "video_bitrate_kbps": media_profile.video_bitrate_kbps, - "video_bitrate": muninn_rudp_video_bitrate_arg(&media_profile), - "video_maxrate": muninn_rudp_video_bitrate_arg(&media_profile), - "video_bufsize": muninn_rudp_video_vbv_buffer_arg(options, &media_profile), - "video_low_delay_key_frame_scale": MUNINN_RUDP_MEDIA_LOW_DELAY_KEY_FRAME_SCALE, - "media_packet_bytes": media_profile.media_packet_bytes, - "max_fragment_bytes": media_profile.max_fragment_bytes, - "video_b_frames": media_profile.video_b_frames, - "video_gop_frames": muninn_rudp_video_gop_frames(options), - "video_rate_control": "cbr", - "video_rc_lookahead": media_profile.video_rc_lookahead, - "sender_queue_deadline_ms": media_profile.sender_queue_deadline_ms, - "sender_resend_delay_ms": media_profile.sender_resend_delay_ms, - "sender_reliable_expire_after_ms": media_profile.sender_reliable_expire_after_ms, - "receiver_assembly_deadline_ms": media_profile.receiver_assembly_deadline_ms, - "receiver_gap_wait_ms": media_profile.receiver_gap_wait_ms, - "late_media_policy": "drop expired queued media; do not repair frames outside the latency budget", - "recovery": "fixed quarter-second IDR budget with receiver feedback pressure telemetry" - }, - "input_streams": transport_input_streams, - "debug_lowerings": [], - "command_lowerings": command_lowerings, - "cut_line": "Muninn's telemetry store owns provider advertisement, command boundary, transport profile, telemetry surface, and daemon health state. Local CLI activation is a command lowering; health is published by the serve process over CultNet/RUDP.", - "updated_at": updated_at, - }), - }; - let provider_advertisement = EveProviderAdvertisementRecord { - value: json!({ - "schema": "gamecult.eve.provider_advertisement.v1", - "providerId": provider_id, - "daemonId": daemon_id, - "title": muninn_provider_title(options), - "description": muninn_provider_description(options), - "canonicalService": "asgard.muninn", - "locatedService": format!("asgard.{}.muninn", options.host_id), - "verseId": format!("{}.local", options.host_id), - "cultMeshAddress": format!("asgard.{}.muninn/telemetry", options.host_id), - "status": "active", - "mode": "daemon-live", - "updatedAt": updated_at, - "stateStore": store_path, - "surfaceId": options.surface_id, - "streamId": stream_id, - "surfaceState": state, - "availableSources": available_sources, - "activeStreams": active_streams, - "capabilities": muninn_capabilities(options), - "endpoints": provider_endpoints, - "inputStreams": provider_input_streams, - "routes": provider_routes, - "surfaces": [{ - "surfaceId": format!("{provider_id}.move-hue.surface"), - "surfaceKind": "gamecult.eve.surface_state.v1", - "recordRef": move_hue_surface_key(&options.host_id), - "title": "Move Hue Program" - }], - "commandSurface": { - "commandBoundaryId": command_boundary_key, - "transportProfileId": transport_profile_key - } - }), - }; - node.put(&provider_id, &provider_advertisement)?; - node.put(&command_boundary_key, &command_boundary)?; - node.put(&transport_profile_key, &transport_profile)?; - Ok(()) -} - -fn start_odin_provider_lease_worker(options: &Options) { - if resolve_odin_cultmesh_uri(options).is_none() { - return; - } - - let options = options.clone(); - thread::spawn(move || { - let mut has_published = false; - loop { - let result = open_node(&options, "muninn-odin-provider-lease") - .and_then(|node| publish_odin_provider_lease(&node, &options)); - match result { - Ok(()) => { - has_published = true; - thread::sleep(Duration::from_secs( - MUNINN_ODIN_PROVIDER_LEASE_REFRESH_SECONDS, - )); - } - Err(error) => { - eprintln!("Muninn could not renew its Odin provider lease: {error:#}"); - thread::sleep(if has_published { - Duration::from_secs(MUNINN_ODIN_PROVIDER_LEASE_REFRESH_SECONDS) - } else { - Duration::from_secs(1) - }); - } - } - } - }); -} - -fn publish_odin_provider_lease(node: &cultmesh_rs::CultMeshNode, options: &Options) -> Result<()> { - let Some(target) = resolve_odin_cultmesh_uri(options) else { - return Ok(()); - }; - let provider_id = muninn_provider_id(options); - let provider = node.get_required::(&provider_id)?; - node.publish_document_to_rudp_catalog( - &provider_id, - &provider, - CultMeshRudpDocumentPublishOptions { - target, - runtime_id: muninn_daemon_id(options), - source_role: Some("muninn.telemetry-provider".to_string()), - tags: vec![ - "provider-lease".to_string(), - "odin-verse-discovery".to_string(), - ], - ..CultMeshRudpDocumentPublishOptions::default() - }, - ) -} - -fn muninn_provider_id(options: &Options) -> String { - format!("muninn.telemetry.{}", options.host_id) -} - -fn muninn_daemon_id(options: &Options) -> String { - if let Some(idunn) = options.idunn_rudp_health.as_ref() { - return idunn.daemon_id.clone(); - } - match options.host_id.as_str() { - "raven" => "muninn".to_string(), - "starfire" => "starfire-muninn".to_string(), - "nightwing" => "nightwing-muninn".to_string(), - _ => format!("muninn-{}", options.host_id), - } -} - -fn muninn_provider_title(options: &Options) -> String { - format!("Muninn {} Telemetry", title_case_host(&options.host_id)) -} - -fn muninn_provider_description(options: &Options) -> String { - let mut detail = format!( - "Muninn telemetry runtime on {} publishing local capture affordances and typed telemetry state.", - title_case_host(&options.host_id) - ); - if options.quest_adb { - detail.push_str(" Quest USB access is enabled on this body."); - } - if !options.move_state_sources.is_empty() { - detail.push_str(" Move HID/controller evidence is enabled on this body."); - } - detail -} - -fn title_case_host(host_id: &str) -> String { - let mut chars = host_id.chars(); - match chars.next() { - Some(first) => first.to_uppercase().collect::() + chars.as_str(), - None => "Unknown".to_string(), - } -} - -fn muninn_capabilities(options: &Options) -> Vec { - let mut capabilities = vec![ - "screen.capture.ddagrab".to_string(), - "audio.loopback.wasapi".to_string(), - "muninn.telemetry_surface".to_string(), - "muninn.capture_stream".to_string(), - "muninn.command_boundary".to_string(), - "muninn.transport_profile".to_string(), - ]; - if options.quest_adb { - capabilities.push("quest.usb.access".to_string()); - } - if !options.move_state_sources.is_empty() { - capabilities.push("psmove.controller.state".to_string()); - capabilities.push("muninn.move_evidence_stream".to_string()); - } - capabilities -} - -fn available_sources(options: &Options) -> Vec { - let mut sources = vec![ - format!("screen:ddagrab:output_idx={}", options.ddagrab_output_index), - format!( - "audio-loopback:wasapi:{}:{}ch@{}", - options.audio_device, options.audio_channels, options.audio_sample_rate - ), - "sensor:microphone:enumeration-pending".to_string(), - "sensor:camera:enumeration-pending".to_string(), - ]; - if options.quest_adb { - sources.push(format!( - "sensor:quest:adb:{}", - options - .quest_serial - .as_deref() - .unwrap_or("any-authorized-quest") - )); - } else { - sources.push("sensor:quest:adb:activation-required".to_string()); - } - sources -} - -fn publish_stream( - node: &mut cultmesh_rs::CultMeshNode, - options: &Options, - plan: &MuxPlan, - state: &str, - supervisor_pid: u32, - mux_pid: Option, - restart_count: u32, - detail: &str, -) -> Result<()> { - let record = MuninnCaptureStreamRecord { - stream_id: options.stream_id.clone(), - host_id: options.host_id.clone(), - state: state.to_string(), - video_source: if options.capture_video { - format!( - "ddagrab:output_idx={}:{}x{}@{}", - options.ddagrab_output_index, options.width, options.height, options.framerate - ) - } else { - "disabled".to_string() - }, - audio_source: if options.capture_audio { - format!( - "wasapi-loopback:{}:{}ch@{}", - options.audio_device, options.audio_channels, options.audio_sample_rate - ) - } else { - "disabled".to_string() - }, - transport: media_transport_id(&options.media_transport).to_string(), - targets: plan.targets.clone(), - command_witness: plan.command_file.display().to_string(), - supervisor_pid: Some(supervisor_pid), - mux_pid, - restart_count, - detail: detail.to_string(), - updated_at: timestamp()?, - }; - node.put("latest-stream", &record)?; - node.put(&record.stream_id, &record)?; - Ok(()) -} - -fn publish_obs_catalog_idle(node: &mut cultmesh_rs::CultMeshNode, options: &Options) -> Result<()> { - let mut stream_ids = vec![options.stream_id.clone()]; - let mut labels = vec![format!("{} screen and loopback A/V", options.host_id)]; - let mut urls = vec![String::new()]; - let mut states = vec!["activation-required".to_string()]; - for source in available_sources(options) { - stream_ids.push(format!("{}:{}", options.surface_id, source)); - labels.push(source); - urls.push(String::new()); - states.push("affordance".to_string()); - } - publish_obs_catalog(node, options, stream_ids, labels, urls, states) -} - -fn publish_obs_catalog( - node: &mut cultmesh_rs::CultMeshNode, - options: &Options, - stream_ids: Vec, - labels: Vec, - urls: Vec, - states: Vec, -) -> Result<()> { - let video_sources = video_source_catalog(options); - let audio_sources = audio_source_catalog(options); - let record = MuninnObsStreamCatalogRecord { - catalog_id: "muninn.obs.streams".to_string(), - host_id: options.host_id.clone(), - stream_ids, - labels, - urls, - states, - updated_at: timestamp()?, - command_rudp_target: String::new(), - media_target_host: options.target_host.clone(), - media_port: options.port, - media_packet_bytes: options.media_packet_bytes as u32, - rudp_video_bitrate_kbps: options.rudp_video_bitrate_kbps, - rudp_latency_budget_ms: options.rudp_latency_budget_ms, - video_source_ids: video_sources - .iter() - .map(|source| source.id.clone()) - .collect(), - video_source_labels: video_sources - .iter() - .map(|source| source.label.clone()) - .collect(), - audio_source_ids: audio_sources - .iter() - .map(|source| source.id.clone()) - .collect(), - audio_source_labels: audio_sources - .iter() - .map(|source| source.label.clone()) - .collect(), - }; - node.put("obs", &record)?; - if let Some(target) = resolve_odin_cultmesh_uri(options) - && let Err(error) = node.publish_document_to_rudp_catalog( - "obs", - &record, - CultMeshRudpDocumentPublishOptions { - target, - runtime_id: muninn_daemon_id(options), - source_role: Some("muninn.obs-catalog-provider".to_string()), - tags: vec![ - "obs-discovery".to_string(), - "odin-verse-discovery".to_string(), - ], - ..CultMeshRudpDocumentPublishOptions::default() - }, - ) - { - eprintln!("Muninn could not publish OBS catalog to Odin: {error:#}"); - } - Ok(()) -} - -fn create_move_evidence_stream(options: &Options) -> Result> { - if options.move_state_sources.is_empty() - && options.move_marker_camera_sources.is_empty() - && options.move_evidence_stream_id.is_none() - { - return Ok(None); - } - - let stream_id = options - .move_evidence_stream_id - .clone() - .unwrap_or_else(|| format!("muninn:{}:move-evidence", options.host_id)); - let producer_peer_id = format!("muninn:{}", options.host_id); - let clock_domain_id = format!("{}:clock", producer_peer_id); - let descriptor = CultMeshStreamDescriptor::new( - stream_id.clone(), - options.move_evidence_verse_id.clone(), - producer_peer_id.clone(), - CultMeshStreamKind::Bytes, - CultMeshStreamClock::new(clock_domain_id)? - .source_id(stream_id.clone())? - .confidence(1.0) - .evidence_kind("muninn-move-evidence")?, - vec![ - CultMeshStreamBodyTransport::SharedMemory, - CultMeshStreamBodyTransport::CultCachePage, - ], - )? - .label("Muninn Move evidence")? - .max_in_flight_frames(options.move_evidence_ring_slots as u32) - .metadata_schema_id("mimir.muninn_move_evidence_stream_frame.v1")?; - - let mut catalog = CultMesh::create_stream_catalog(); - catalog.declare(descriptor); - catalog.create_shared_memory_ring( - &stream_id, - options.move_evidence_ring_slots, - options.move_evidence_slot_bytes, - )?; - - Ok(Some(ActiveMoveEvidenceStream { - catalog, - stream_id, - producer_peer_id, - frame_counter: 0, - snapshot_path: options.move_evidence_snapshot_path.clone(), - rudp_sender: None, - counters: Arc::new(MoveEvidenceTransportCounters::default()), - local_ring_enabled: options.hid_controller_rudp_bind.is_none(), - })) -} - -fn publish_move_evidence_stream_frame( - stream: &mut ActiveMoveEvidenceStream, - marker_candidates: &[MuninnMoveMarkerCandidateRecord], - controller_states: &[MuninnMoveControllerStateRecord], -) -> Result> { - if marker_candidates.is_empty() && controller_states.is_empty() { - return Ok(None); - } - - let published_at_ns = timestamp_ns()?; - let frame_id = format!("{}:{}", stream.stream_id, stream.frame_counter); - stream.frame_counter = stream.frame_counter.saturating_add(1); - stream - .counters - .produced_frames - .fetch_add(1, Ordering::Relaxed); - let frame = MuninnMoveEvidenceStreamFrame( - &frame_id, - &stream.producer_peer_id, - published_at_ns, - marker_candidates, - controller_states, - ); - let payload = - rmp_serde::to_vec(&frame).context("encoding Muninn Move evidence stream frame")?; - if let Some(sender) = stream.rudp_sender.as_ref() { - if let Ok(mut latest) = sender.lock() { - *latest = Some(payload.clone()); - } - stream - .counters - .remote_handoffs - .fetch_add(1, Ordering::Relaxed); - } - if let Some(path) = stream.snapshot_path.as_deref() { - write_move_evidence_snapshot( - path, - &stream.stream_id, - &frame_id, - &stream.producer_peer_id, - published_at_ns, - &payload, - )?; - } - let handle = if stream.local_ring_enabled { - let ring: &mut CultMeshSharedMemoryFrameRing = - stream - .catalog - .ring_mut(&stream.stream_id) - .ok_or_else(|| anyhow!("missing Muninn Move evidence ring"))?; - ring.try_publish_copy(&payload, published_at_ns, 0)? - } else { - None - }; - if let Some(handle) = handle { - stream - .counters - .local_ring_admissions - .fetch_add(1, Ordering::Relaxed); - stream.catalog.publish_frame(handle.clone())?; - Ok(Some(handle)) - } else { - Ok(None) - } -} - -fn write_move_evidence_snapshot( - path: &Path, - stream_id: &str, - frame_id: &str, - producer_peer_id: &str, - published_at_ns: i64, - payload: &[u8], -) -> Result<()> { - if payload.is_empty() { - return Err(anyhow!("cannot write empty Move evidence snapshot")); - } - - if let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - fs::create_dir_all(parent).with_context(|| { - format!( - "creating Move evidence snapshot directory {}", - parent.display() - ) - })?; - } - - let captured_at_ns = u64::try_from(timestamp_ns()?) - .context("Move evidence snapshot capture timestamp does not fit u64")?; - let snapshot_id = format!("{frame_id}:snapshot"); - let snapshot = MimirMoveProofEvidenceFrameSnapshot( - &snapshot_id, - stream_id, - frame_id, - producer_peer_id, - published_at_ns, - captured_at_ns, - payload, - ); - let bytes = - rmp_serde::to_vec(&snapshot).context("encoding Mimir Move proof evidence snapshot")?; - fs::write(path, bytes) - .with_context(|| format!("writing Move proof evidence snapshot {}", path.display())) -} - -fn extract_move_marker_candidates_from_luma_frame( - source: &MoveMarkerFrameSource, - y8_frame: &[u8], - observed_at: String, -) -> Result> { - let candidates = muninn_move_tracker::extract_luma_candidates(y8_frame, source.tracker_config) - .ok_or_else(|| { - anyhow!( - "invalid Move marker Y8 frame for camera {}: expected width={} height={} stride={}", - source.camera_id, - source.tracker_config.width, - source.tracker_config.height, - source.tracker_config.stride_bytes - ) - })?; - Ok(candidates - .into_iter() - .map(|candidate| { - build_move_marker_candidate_record( - &source.stream_id, - &source.host_id, - &source.camera_id, - candidate, - observed_at.clone(), - ) - }) - .collect()) -} - -fn build_move_marker_candidate_record( - stream_id: &str, - host_id: &str, - camera_id: &str, - candidate: muninn_move_tracker::MoveMarkerCandidate, - observed_at: String, -) -> MuninnMoveMarkerCandidateRecord { - MuninnMoveMarkerCandidateRecord { - stream_id: stream_id.to_string(), - host_id: host_id.to_string(), - camera_id: camera_id.to_string(), - frame_sequence: candidate.frame_sequence, - source_id_hash: candidate.source_id_hash, - tile_x: candidate.tile_x, - tile_y: candidate.tile_y, - center_x_px: candidate.center_x_px, - center_y_px: candidate.center_y_px, - radius_px: candidate.radius_px, - area_px: candidate.area_px, - mean_luma: candidate.mean_luma, - peak_luma: candidate.peak_luma, - score: candidate.score, - observed_at, - move_id: String::new(), - } -} - -trait MoveLightWriter { - fn write_report(&mut self, hidraw_path: &str, report: &[u8]) -> Result<()>; -} - -struct HidMoveLightWriter; - -impl MoveLightWriter for HidMoveLightWriter { - #[cfg(not(windows))] - fn write_report(&mut self, hidraw_path: &str, report: &[u8]) -> Result<()> { - let mut device = fs::OpenOptions::new() - .read(true) - .write(true) - .custom_flags(libc::O_NONBLOCK) - .open(hidraw_path) - .with_context(|| format!("opening PS Move HID path {hidraw_path}"))?; - device - .write_all(report) - .with_context(|| format!("writing PS Move HID report to {hidraw_path}")) - } - - #[cfg(windows)] - fn write_report(&mut self, hidraw_path: &str, report: &[u8]) -> Result<()> { - write_windows_hid_report(hidraw_path, report) - } -} - -trait MoveControllerStateReader { - fn read_report(&mut self, hidraw_path: &str) -> Result>>; - fn read_joystick_events(&mut self, joystick_path: &str) -> Result>; -} - -trait MoveMarkerCameraFrameReader { - fn read_luma_frame( - &mut self, - source: &MoveMarkerCameraSource, - frame_source: &MoveMarkerFrameSource, - ) -> Result>>; -} - -#[derive(Default)] -struct PlatformMoveMarkerCameraFrameReader; - -impl MoveMarkerCameraFrameReader for PlatformMoveMarkerCameraFrameReader { - #[cfg(unix)] - fn read_luma_frame( - &mut self, - source: &MoveMarkerCameraSource, - frame_source: &MoveMarkerFrameSource, - ) -> Result>> { - read_v4l2_yuyv_luma_frame(source, frame_source) - } - - #[cfg(not(unix))] - fn read_luma_frame( - &mut self, - source: &MoveMarkerCameraSource, - _frame_source: &MoveMarkerFrameSource, - ) -> Result>> { - Err(anyhow!( - "Move marker camera source {} at {} requires a Unix V4L2 runtime", - source.camera_id, - source.device_path.display() - )) - } -} - -#[cfg(unix)] -const V4L2_BUF_TYPE_VIDEO_CAPTURE: u32 = 1; -#[cfg(unix)] -const V4L2_FIELD_NONE: u32 = 1; -#[cfg(unix)] -const V4L2_MEMORY_MMAP: u32 = 1; -#[cfg(unix)] -const VIDIOC_G_FMT: libc::c_ulong = 0xC0D05604; -#[cfg(unix)] -const VIDIOC_S_FMT: libc::c_ulong = 0xC0D05605; -#[cfg(unix)] -const VIDIOC_REQBUFS: libc::c_ulong = 0xC0145608; -#[cfg(unix)] -const VIDIOC_QUERYBUF: libc::c_ulong = 0xC0585609; -#[cfg(unix)] -const VIDIOC_QBUF: libc::c_ulong = 0xC058560F; -#[cfg(unix)] -const VIDIOC_DQBUF: libc::c_ulong = 0xC0585611; -#[cfg(unix)] -const VIDIOC_STREAMON: libc::c_ulong = 0x40045612; -#[cfg(unix)] -const VIDIOC_STREAMOFF: libc::c_ulong = 0x40045613; -#[cfg(unix)] -const VIDIOC_G_PARM: libc::c_ulong = 0xC0CC5615; -#[cfg(unix)] -const VIDIOC_S_PARM: libc::c_ulong = 0xC0CC5616; - -#[cfg(unix)] -#[repr(C)] -#[derive(Clone, Copy)] -struct V4l2PixFormat { - width: u32, - height: u32, - pixelformat: u32, - field: u32, - bytesperline: u32, - sizeimage: u32, - colorspace: u32, - priv_: u32, - flags: u32, - ycbcr_enc: u32, - quantization: u32, - xfer_func: u32, -} - -#[cfg(unix)] -#[repr(C)] -union V4l2FormatUnion { - pix: V4l2PixFormat, - raw: [u8; 200], - align: u64, -} - -#[cfg(unix)] -#[repr(C)] -struct V4l2Format { - type_: u32, - fmt: V4l2FormatUnion, -} - -#[cfg(unix)] -#[repr(C)] -struct V4l2RequestBuffers { - count: u32, - type_: u32, - memory: u32, - capabilities: u32, - flags: u8, - reserved: [u8; 3], -} - -#[cfg(unix)] -#[repr(C)] -#[derive(Clone, Copy)] -struct V4l2Fract { - numerator: u32, - denominator: u32, -} - -#[cfg(unix)] -#[repr(C)] -#[derive(Clone, Copy)] -struct V4l2CaptureParm { - capability: u32, - capturemode: u32, - timeperframe: V4l2Fract, - extendedmode: u32, - readbuffers: u32, - reserved: [u32; 4], -} - -#[cfg(unix)] -#[repr(C)] -union V4l2StreamParmUnion { - capture: V4l2CaptureParm, - raw: [u8; 200], -} - -#[cfg(unix)] -#[repr(C)] -struct V4l2StreamParm { - type_: u32, - parm: V4l2StreamParmUnion, -} - -#[cfg(unix)] -#[repr(C)] -#[derive(Clone, Copy)] -struct V4l2Timecode { - type_: u32, - flags: u32, - frames: u8, - seconds: u8, - minutes: u8, - hours: u8, - userbits: [u8; 4], -} - -#[cfg(unix)] -#[repr(C)] -#[derive(Clone, Copy)] -union V4l2BufferUnion { - offset: u32, - userptr: libc::c_ulong, - planes: *mut libc::c_void, - fd: i32, -} - -#[cfg(unix)] -#[repr(C)] -struct V4l2Buffer { - index: u32, - type_: u32, - bytesused: u32, - flags: u32, - field: u32, - timestamp: libc::timeval, - timecode: V4l2Timecode, - sequence: u32, - memory: u32, - m: V4l2BufferUnion, - length: u32, - reserved2: u32, - request_fd: i32, -} - -#[cfg(unix)] -struct V4l2MappedBuffer { - ptr: *mut libc::c_void, - length: usize, -} - -#[cfg(unix)] -fn read_v4l2_yuyv_luma_frame( - source: &MoveMarkerCameraSource, - frame_source: &MoveMarkerFrameSource, -) -> Result>> { - use std::ffi::CString; - use std::os::unix::ffi::OsStrExt; - - let config = frame_source.tracker_config; - if config.stride_bytes != config.width { - return Err(anyhow!( - "Move marker V4L2 reader emits compact Y8 frames, so stride {} must equal width {}", - config.stride_bytes, - config.width - )); - } - - let device = CString::new(source.device_path.as_os_str().as_bytes()) - .with_context(|| format!("invalid V4L2 device path {}", source.device_path.display()))?; - let fd = unsafe { libc::open(device.as_ptr(), libc::O_RDWR | libc::O_NONBLOCK) }; - if fd < 0 { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("opening V4L2 camera {}", source.device_path.display())); - } - - let result = read_v4l2_yuyv_luma_frame_from_fd(fd, source, frame_source); - let close_result = unsafe { libc::close(fd) }; - if close_result != 0 && result.is_ok() { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("closing V4L2 camera {}", source.device_path.display())); - } - result -} - -#[cfg(unix)] -fn read_v4l2_yuyv_luma_frame_from_fd( - fd: libc::c_int, - source: &MoveMarkerCameraSource, - frame_source: &MoveMarkerFrameSource, -) -> Result>> { - let config = frame_source.tracker_config; - let yuyv = fourcc("YUYV"); - let mut stream_on = false; - let mut maps = Vec::new(); - let read_result = (|| -> Result>> { - set_v4l2_frame_interval(fd, frame_source.fps)?; - - let mut fmt = zeroed_v4l2_format(); - fmt.type_ = V4L2_BUF_TYPE_VIDEO_CAPTURE; - ioctl_mut(fd, VIDIOC_G_FMT, &mut fmt).with_context(|| { - format!("querying V4L2 format for {}", source.device_path.display()) - })?; - fmt.fmt.pix.width = config.width; - fmt.fmt.pix.height = config.height; - fmt.fmt.pix.pixelformat = yuyv; - fmt.fmt.pix.field = V4L2_FIELD_NONE; - ioctl_mut(fd, VIDIOC_S_FMT, &mut fmt).with_context(|| { - format!( - "setting V4L2 YUYV format for {}", - source.device_path.display() - ) - })?; - let actual = unsafe { fmt.fmt.pix }; - if actual.width != config.width || actual.height != config.height { - return Err(anyhow!( - "V4L2 camera {} returned {}x{}, expected {}x{}", - source.device_path.display(), - actual.width, - actual.height, - config.width, - config.height - )); - } - if actual.pixelformat != yuyv { - return Err(anyhow!( - "V4L2 camera {} did not accept YUYV format", - source.device_path.display() - )); - } - - let mut req = V4l2RequestBuffers { - count: 2, - type_: V4L2_BUF_TYPE_VIDEO_CAPTURE, - memory: V4L2_MEMORY_MMAP, - capabilities: 0, - flags: 0, - reserved: [0; 3], - }; - ioctl_mut(fd, VIDIOC_REQBUFS, &mut req).with_context(|| { - format!( - "requesting V4L2 buffers for {}", - source.device_path.display() - ) - })?; - if req.count == 0 { - return Err(anyhow!( - "V4L2 camera {} returned no mmap buffers", - source.device_path.display() - )); - } - - for index in 0..req.count { - let mut buf = zeroed_v4l2_buffer(); - buf.type_ = V4L2_BUF_TYPE_VIDEO_CAPTURE; - buf.memory = V4L2_MEMORY_MMAP; - buf.index = index; - ioctl_mut(fd, VIDIOC_QUERYBUF, &mut buf).with_context(|| { - format!( - "querying V4L2 buffer {index} for {}", - source.device_path.display() - ) - })?; - let length = usize::try_from(buf.length).context("V4L2 buffer length overflow")?; - let offset = unsafe { buf.m.offset }; - let ptr = unsafe { - libc::mmap( - std::ptr::null_mut(), - length, - libc::PROT_READ | libc::PROT_WRITE, - libc::MAP_SHARED, - fd, - offset as libc::off_t, - ) - }; - if ptr == libc::MAP_FAILED { - return Err(std::io::Error::last_os_error()).with_context(|| { - format!( - "mapping V4L2 buffer {index} for {}", - source.device_path.display() - ) - }); - } - maps.push(V4l2MappedBuffer { ptr, length }); - ioctl_mut(fd, VIDIOC_QBUF, &mut buf).with_context(|| { - format!( - "queueing V4L2 buffer {index} for {}", - source.device_path.display() - ) - })?; - } - - let mut capture_type = V4L2_BUF_TYPE_VIDEO_CAPTURE as libc::c_int; - ioctl_mut(fd, VIDIOC_STREAMON, &mut capture_type).with_context(|| { - format!("starting V4L2 stream for {}", source.device_path.display()) - })?; - stream_on = true; - - let mut poll_fd = libc::pollfd { - fd, - events: libc::POLLIN, - revents: 0, - }; - let ready = unsafe { libc::poll(&mut poll_fd, 1, 250) }; - if ready < 0 { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("polling V4L2 camera {}", source.device_path.display())); - } - if ready == 0 { - return Ok(None); - } - - let mut buf = zeroed_v4l2_buffer(); - buf.type_ = V4L2_BUF_TYPE_VIDEO_CAPTURE; - buf.memory = V4L2_MEMORY_MMAP; - ioctl_mut(fd, VIDIOC_DQBUF, &mut buf) - .with_context(|| format!("reading V4L2 frame from {}", source.device_path.display()))?; - let index = usize::try_from(buf.index).context("V4L2 buffer index overflow")?; - let mapping = maps.get(index).ok_or_else(|| { - anyhow!( - "V4L2 camera {} returned unknown buffer index {}", - source.device_path.display(), - index - ) - })?; - let bytes_used = usize::try_from(buf.bytesused).context("V4L2 bytesused overflow")?; - let raw_len = bytes_used.min(mapping.length); - let raw = unsafe { std::slice::from_raw_parts(mapping.ptr as *const u8, raw_len) }; - Ok(Some(yuyv_to_compact_luma( - raw, - config.width, - config.height, - actual.bytesperline, - )?)) - })(); - - if stream_on { - let mut capture_type = V4L2_BUF_TYPE_VIDEO_CAPTURE as libc::c_int; - let _ = ioctl_mut(fd, VIDIOC_STREAMOFF, &mut capture_type); - } - for mapping in maps { - unsafe { - libc::munmap(mapping.ptr, mapping.length); - } - } - read_result -} - -#[cfg(unix)] -fn set_v4l2_frame_interval(fd: libc::c_int, fps: u32) -> Result<()> { - let mut parm = zeroed_v4l2_stream_parm(); - parm.type_ = V4L2_BUF_TYPE_VIDEO_CAPTURE; - let _ = ioctl_mut(fd, VIDIOC_G_PARM, &mut parm); - parm.parm.capture.timeperframe.numerator = 1; - parm.parm.capture.timeperframe.denominator = fps.max(1); - ioctl_mut(fd, VIDIOC_S_PARM, &mut parm) -} - -#[cfg(unix)] -fn yuyv_to_compact_luma(raw: &[u8], width: u32, height: u32, stride_bytes: u32) -> Result> { - let width = usize::try_from(width).context("YUYV width overflow")?; - let height = usize::try_from(height).context("YUYV height overflow")?; - let stride = usize::try_from(stride_bytes).context("YUYV stride overflow")?; - let required = height - .checked_sub(1) - .and_then(|last_row| last_row.checked_mul(stride)) - .and_then(|last_row| last_row.checked_add(width.saturating_mul(2))) - .unwrap_or(0); - if raw.len() < required { - return Err(anyhow!( - "YUYV frame is too short: {} bytes, expected at least {}", - raw.len(), - required - )); - } - let mut luma = Vec::with_capacity(width.saturating_mul(height)); - for row in 0..height { - let start = row * stride; - let row_bytes = &raw[start..start + width * 2]; - luma.extend(row_bytes.iter().step_by(2).copied()); - } - Ok(luma) -} - -#[cfg(unix)] -fn fourcc(value: &str) -> u32 { - let bytes = value.as_bytes(); - u32::from(bytes[0]) - | (u32::from(bytes[1]) << 8) - | (u32::from(bytes[2]) << 16) - | (u32::from(bytes[3]) << 24) -} - -#[cfg(unix)] -fn ioctl_mut(fd: libc::c_int, request: libc::c_ulong, value: &mut T) -> Result<()> { - let result = unsafe { libc::ioctl(fd, request, value as *mut T) }; - if result == 0 { - Ok(()) - } else { - Err(std::io::Error::last_os_error()).context("V4L2 ioctl failed") - } -} - -#[cfg(unix)] -fn zeroed_v4l2_format() -> V4l2Format { - unsafe { std::mem::zeroed() } -} - -#[cfg(unix)] -fn zeroed_v4l2_stream_parm() -> V4l2StreamParm { - unsafe { std::mem::zeroed() } -} - -#[cfg(unix)] -fn zeroed_v4l2_buffer() -> V4l2Buffer { - unsafe { std::mem::zeroed() } -} - -struct HidMoveControllerStateReader; - -impl MoveControllerStateReader for HidMoveControllerStateReader { - #[cfg(unix)] - fn read_report(&mut self, hidraw_path: &str) -> Result>> { - let mut device = fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NONBLOCK) - .open(hidraw_path) - .with_context(|| format!("opening PS Move HID input path {hidraw_path}"))?; - let mut report = vec![0u8; 64]; - match device.read(&mut report) { - Ok(0) => Ok(None), - Ok(count) => { - report.truncate(count); - Ok(Some(report)) - } - Err(error) if error.kind() == ErrorKind::WouldBlock => Ok(None), - Err(error) => Err(error) - .with_context(|| format!("reading PS Move HID input report from {hidraw_path}")), - } - } - - #[cfg(all(not(unix), not(windows)))] - fn read_report(&mut self, hidraw_path: &str) -> Result>> { - if hidraw_path.trim().is_empty() { - return Ok(None); - } - Err(anyhow!( - "PS Move controller state HID reads are currently implemented for Unix hidraw paths" - )) - } - - #[cfg(windows)] - fn read_report(&mut self, hidraw_path: &str) -> Result>> { - if hidraw_path.trim().is_empty() { - return Ok(None); - } - if is_windows_ps_move_source(hidraw_path) { - return windows_ps_move_input_report(hidraw_path); - } - Err(anyhow!( - "Windows PS Move controller state reads require the windows-psmove source path" - )) - } - - #[cfg(unix)] - fn read_joystick_events(&mut self, joystick_path: &str) -> Result> { - let mut device = fs::OpenOptions::new() - .read(true) - .custom_flags(libc::O_NONBLOCK) - .open(joystick_path) - .with_context(|| format!("opening PS Move joystick input path {joystick_path}"))?; - let mut events = Vec::new(); - loop { - let mut report = [0u8; 8]; - match device.read_exact(&mut report) { - Ok(()) => events.push(JoystickEvent { - event_type: report[6], - number: report[7], - value: i16::from_le_bytes([report[4], report[5]]), - }), - Err(error) if error.kind() == ErrorKind::WouldBlock => break, - Err(error) if error.kind() == ErrorKind::UnexpectedEof => break, - Err(error) => { - return Err(error).with_context(|| { - format!("reading PS Move joystick event from {joystick_path}") - }); - } - } - } - Ok(events) - } - - #[cfg(not(unix))] - fn read_joystick_events(&mut self, joystick_path: &str) -> Result> { - if joystick_path.trim().is_empty() { - return Ok(Vec::new()); - } - Err(anyhow!( - "PS Move controller state joystick reads are currently implemented for Unix input paths" - )) - } -} - -fn publish_move_controller_states( - node: &mut cultmesh_rs::CultMeshNode, - options: &Options, - active: &mut [ActiveMoveStateSource], - reader: &mut impl MoveControllerStateReader, - move_evidence_stream: Option<&mut ActiveMoveEvidenceStream>, - mut hid_controller_stream: Option<&mut ActiveHidControllerStream>, -) -> Result<()> { - let mut published_records = Vec::new(); - for state in active { - let record = if is_joystick_path(&state.source.hidraw_path) { - let events = match reader.read_joystick_events(&state.source.hidraw_path) { - Ok(events) => events, - Err(error) => { - eprintln!( - "Muninn skipped Move source {} at {}: {error:#}", - state.source.move_id, state.source.hidraw_path - ); - continue; - } - }; - for event in events { - match event.event_type & 0x7f { - 0x01 => { - if let Some(button) = state.joystick_buttons.get_mut(event.number as usize) - { - *button = event.value != 0; - } - } - 0x02 => { - if let Some(axis) = state.joystick_axes.get_mut(event.number as usize) { - *axis = event.value; - } - } - _ => {} - } - } - state.sequence = state.sequence.saturating_add(1); - let observed_at = timestamp()?; - let source_timestamp_ns = timestamp_ns()?; - let hid_record = build_hid_controller_state_record_from_joystick( - options, - &state.source, - state.sequence, - state.joystick_axes, - state.joystick_buttons, - source_timestamp_ns, - observed_at.clone(), - ); - put_hid_controller_state_receipt(node, options, &hid_record)?; - publish_hid_controller_state_to_stream( - hid_controller_stream.as_deref_mut(), - &hid_record, - ); - publish_hid_controller_state_to_odin(node, &hid_record); - build_move_controller_state_record_from_joystick( - options, - &state.source, - state.sequence, - state.joystick_axes, - state.joystick_buttons, - source_timestamp_ns, - observed_at, - ) - } else if is_xinput_source_path(&state.source.hidraw_path) { - let index = match xinput_index_from_source_path(&state.source.hidraw_path) { - Some(index) => index, - None => { - eprintln!( - "Muninn skipped XInput source {} at {}: invalid xinput source path", - state.source.move_id, state.source.hidraw_path - ); - continue; - } - }; - let gamepad = match platform_xinput_gamepad(index) { - Ok(Some(gamepad)) => gamepad, - Ok(None) => continue, - Err(error) => { - eprintln!( - "Muninn skipped XInput source {} at {}: {error:#}", - state.source.move_id, state.source.hidraw_path - ); - continue; - } - }; - state.sequence = state.sequence.saturating_add(1); - let observed_at = timestamp()?; - let hid_record = build_hid_controller_state_record_from_xinput_gamepad( - options, - &state.source, - state.sequence, - &gamepad, - timestamp_ns()?, - observed_at, - ); - put_hid_controller_state_receipt(node, options, &hid_record)?; - publish_hid_controller_state_to_stream( - hid_controller_stream.as_deref_mut(), - &hid_record, - ); - publish_hid_controller_state_to_odin(node, &hid_record); - continue; - } else { - #[cfg(windows)] - if options.hid_controller_rudp_bind.is_some() - && is_windows_ps_move_source(&state.source.hidraw_path) - { - continue; - } - let report = match reader.read_report(&state.source.hidraw_path) { - Ok(report) => report, - Err(error) => { - eprintln!( - "Muninn skipped Move source {} at {}: {error:#}", - state.source.move_id, state.source.hidraw_path - ); - continue; - } - }; - let Some(report) = report else { - continue; - }; - state.sequence = state.sequence.saturating_add(1); - let observed_at = timestamp()?; - let source_timestamp_ns = timestamp_ns()?; - let hid_record = build_hid_controller_state_record_from_report( - options, - &state.source, - state.sequence, - &report, - source_timestamp_ns, - observed_at.clone(), - ); - trace_hid_controller_record("report", &hid_record, &report); - put_hid_controller_state_receipt(node, options, &hid_record)?; - publish_hid_controller_state_to_stream( - hid_controller_stream.as_deref_mut(), - &hid_record, - ); - publish_hid_controller_state_to_odin(node, &hid_record); - build_move_controller_state_record( - options, - &state.source, - state.sequence, - &report, - source_timestamp_ns, - observed_at, - ) - }; - put_move_controller_state_receipt(node, options, &record)?; - state.latest_move_record = Some(record.clone()); - published_records.push(record); - } - if let Some(stream) = move_evidence_stream { - publish_move_evidence_stream_frame(stream, &[], &published_records)?; - } - Ok(()) -} - -fn active_move_marker_camera_sources( - options: &Options, - _move_hue_program: Arc>, -) -> Vec { - options - .move_marker_camera_sources - .iter() - .map(|source| { - let stream_id = format!( - "muninn:{}:{}:move-marker-candidates", - options.host_id, source.camera_id - ); - #[cfg(feature = "psmoveapi-tracker")] - let (psmoveapi_observations, psmoveapi_health) = if options.move_psmoveapi_tracker { - video_device_index(&source.device_path) - .and_then(|camera_index| { - Some(start_psmoveapi_tracker_worker( - options.store_path.clone(), - options.host_id.clone(), - source.camera_id.clone(), - camera_index, - options - .move_tracker_camera_exposure_milli - .get(&source.camera_id) - .copied() - .unwrap_or(options.move_tracker_exposure_milli) - as f32 - / 1000.0, - serve_move_state_sources(options, true), - Arc::clone(&_move_hue_program), - )) - }) - .map_or((None, None), |(observations, health)| { - (Some(observations), Some(health)) - }) - } else { - (None, None) - }; - ActiveMoveMarkerCameraSource { - source: source.clone(), - frame_source: MoveMarkerFrameSource { - stream_id, - host_id: options.host_id.clone(), - camera_id: source.camera_id.clone(), - fps: options.move_marker_fps, - tracker_config: muninn_move_tracker::MoveTrackerConfig { - width: options.move_marker_width, - height: options.move_marker_height, - stride_bytes: options - .move_marker_stride_bytes - .unwrap_or(options.move_marker_width), - tile_size: 16, - threshold_min: options.move_marker_threshold_min, - min_area_px: options.move_marker_min_area_px, - max_candidates: options.move_marker_max_candidates, - source_id_hash: 0, - frame_sequence: 0, - }, - }, - sequence: 0, - #[cfg(feature = "psmoveapi-tracker")] - psmoveapi_observations, - #[cfg(feature = "psmoveapi-tracker")] - psmoveapi_health, - } - }) - .collect() -} - -fn move_evidence_camera_inputs( - active: &[ActiveMoveMarkerCameraSource], -) -> Vec { - active - .iter() - .map(|camera| ActiveMoveMarkerCameraSource { - source: camera.source.clone(), - frame_source: camera.frame_source.clone(), - sequence: 0, - #[cfg(feature = "psmoveapi-tracker")] - psmoveapi_observations: camera.psmoveapi_observations.as_ref().map(Arc::clone), - #[cfg(feature = "psmoveapi-tracker")] - psmoveapi_health: None, - }) - .collect() -} - -fn start_move_evidence_aggregator( - mut stream: ActiveMoveEvidenceStream, - mut cameras: Vec, - latest_move_controller_states: Arc>>, -) { - thread::spawn(move || { - let mut reader = PlatformMoveMarkerCameraFrameReader::default(); - let fps = cameras - .iter() - .map(|camera| camera.frame_source.fps) - .max() - .unwrap_or(60) - .max(1); - let cadence = Duration::from_secs_f64(1.0 / f64::from(fps)); - let mut next_tick_at = Instant::now(); - loop { - let now = Instant::now(); - if now < next_tick_at { - thread::sleep(next_tick_at - now); - } - next_tick_at += cadence; - if next_tick_at < Instant::now() { - next_tick_at = Instant::now() + cadence; - } - let controller_states = latest_move_controller_states - .lock() - .map(|states| states.clone()) - .unwrap_or_default(); - if let Err(error) = publish_move_marker_camera_frames( - &mut cameras, - &mut reader, - &controller_states, - Some(&mut stream), - ) { - eprintln!("Muninn Move evidence aggregator warning: {error:#}"); - } - } - }); -} - -fn publish_move_marker_camera_frames( - active: &mut [ActiveMoveMarkerCameraSource], - reader: &mut impl MoveMarkerCameraFrameReader, - latest_move_controller_states: &[MuninnMoveControllerStateRecord], - move_evidence_stream: Option<&mut ActiveMoveEvidenceStream>, -) -> Result<()> { - let Some(stream) = move_evidence_stream else { - return Ok(()); - }; - let mut marker_candidates = Vec::new(); - for camera in active { - camera.sequence = camera.sequence.saturating_add(1); - let mut frame_source = camera.frame_source.clone(); - frame_source.tracker_config.frame_sequence = camera.sequence; - frame_source.tracker_config.source_id_hash = - stable_marker_camera_source_hash(&frame_source); - #[cfg(feature = "psmoveapi-tracker")] - if let Some(latest) = camera.psmoveapi_observations.as_ref() { - let observed_at = timestamp()?; - let observations = latest - .lock() - .ok() - .and_then(|mut value| value.take()) - .unwrap_or_default(); - marker_candidates.extend(observations.into_iter().map(|observation| { - let radius = observation.radius_px.max(0.0); - MuninnMoveMarkerCandidateRecord { - stream_id: frame_source.stream_id.clone(), - host_id: frame_source.host_id.clone(), - camera_id: frame_source.camera_id.clone(), - frame_sequence: camera.sequence, - source_id_hash: frame_source.tracker_config.source_id_hash, - tile_x: 0, - tile_y: 0, - center_x_px: observation.center_x_px, - center_y_px: observation.center_y_px, - radius_px: radius, - area_px: (std::f32::consts::PI * radius * radius).round() as u32, - mean_luma: 0.0, - peak_luma: 0, - // PSMoveAPI exposes position age, not optical fit quality. - score: 0.5, - observed_at: observed_at.clone(), - move_id: observation.move_id, - } - })); - continue; - } - let Some(frame) = reader.read_luma_frame(&camera.source, &frame_source)? else { - continue; - }; - let observed_at = timestamp()?; - marker_candidates.extend(extract_move_marker_candidates_from_luma_frame( - &frame_source, - &frame, - observed_at, - )?); - } - if !marker_candidates.is_empty() { - publish_move_evidence_stream_frame( - stream, - &marker_candidates, - latest_move_controller_states, - )?; - } - Ok(()) -} - -#[cfg(feature = "psmoveapi-tracker")] -fn start_psmoveapi_tracker_worker( - store_path: PathBuf, - host_id: String, - camera_id: String, - camera_index: i32, - exposure: f32, - move_state_sources: Vec, - move_hue_program: Arc>, -) -> ( - Arc>>>, - Arc>>, -) { - let observations_latest = Arc::new(Mutex::new(None)); - let health_latest = Arc::new(Mutex::new(None)); - let observations_output = Arc::clone(&observations_latest); - let health_output = Arc::clone(&health_latest); - thread::spawn(move || { - let mut command = - Command::new(env::current_exe().unwrap_or_else(|_| PathBuf::from("muninn"))); - command - .arg("move-tracker-worker") - .arg("--store") - .arg(store_path) - .arg("--host") - .arg(&host_id) - .arg("--move-marker-camera") - .arg(format!("{camera_id}=/dev/video{camera_index}")) - .arg("--move-tracker-exposure-milli") - .arg(((exposure * 1000.0).round() as u32).to_string()); - for source in move_state_sources { - command - .arg("--move-state") - .arg(format!("{}={}", source.move_id, source.hidraw_path)); - } - let child = command - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::inherit()) - .spawn(); - let Ok(mut child) = child else { - return; - }; - let Some(mut stdin) = child.stdin.take() else { - return; - }; - let program_source = Arc::clone(&move_hue_program); - thread::spawn(move || { - let mut previous = None; - loop { - let Some(program) = program_source - .lock() - .ok() - .map(|value| MoveTrackerWorkerProgram::from(&*value)) - else { - return; - }; - if previous.as_ref() != Some(&program) { - if write_length_framed_message(&mut stdin, &program).is_err() { - return; - } - previous = Some(program); - } - thread::sleep(Duration::from_millis(100)); - } - }); - let Some(stdout) = child.stdout.take() else { - return; - }; - let mut reader = BufReader::new(stdout); - loop { - let frame = match read_move_tracker_worker_frame(&mut reader) { - Ok(frame) => frame, - Err(error) => { - eprintln!( - "Muninn Move tracker worker stream stopped camera={camera_id}: {error:#}" - ); - break; - } - }; - let observations = frame - .observations - .into_iter() - .map(|value| muninn_psmoveapi_tracker::PsmoveApiObservation { - move_id: value.move_id, - center_x_px: value.center_x_px, - center_y_px: value.center_y_px, - radius_px: value.radius_px, - age_ms: value.age_ms, - }) - .collect(); - if let Ok(mut latest) = health_output.lock() { - *latest = Some(frame.health); - } - if let Ok(mut latest) = observations_output.lock() { - *latest = Some(observations); - } - } - let _ = child.wait(); - }); - (observations_latest, health_latest) -} - -#[cfg(feature = "psmoveapi-tracker")] -fn read_move_tracker_worker_frame(reader: &mut impl Read) -> Result { - read_length_framed_message(reader) -} - -#[cfg(feature = "psmoveapi-tracker")] -fn read_length_framed_message Deserialize<'de>>(reader: &mut impl Read) -> Result { - let mut length = [0u8; 4]; - reader.read_exact(&mut length)?; - let length = u32::from_le_bytes(length) as usize; - if length > 1024 * 1024 { - return Err(anyhow!("Move tracker worker frame exceeds 1 MiB")); - } - let mut payload = vec![0u8; length]; - reader.read_exact(&mut payload)?; - rmp_serde::from_slice(&payload).context("decoding length-framed MessagePack") -} - -#[cfg(feature = "psmoveapi-tracker")] -fn write_move_tracker_worker_frame( - writer: &mut impl Write, - frame: &MoveTrackerWorkerFrame, -) -> Result<()> { - write_length_framed_message(writer, frame) -} - -#[cfg(feature = "psmoveapi-tracker")] -fn write_length_framed_message(writer: &mut impl Write, value: &impl Serialize) -> Result<()> { - let payload = rmp_serde::to_vec(value)?; - writer.write_all(&(payload.len() as u32).to_le_bytes())?; - writer.write_all(&payload)?; - writer.flush()?; - Ok(()) -} - -#[cfg(feature = "psmoveapi-tracker")] -impl From<&MuninnMoveHueProgramRecord> for MoveTrackerWorkerProgram { - fn from(value: &MuninnMoveHueProgramRecord) -> Self { - Self { - mode: value.mode.clone(), - cycle_ms: value.cycle_ms, - epoch_ns: value.epoch_ns, - hold_at_ns: value.hold_at_ns, - order_mode: value.order_mode.clone(), - transition_percent: value.transition_percent, - transition_percent_explicit: value.transition_percent_explicit, - } - } -} - -#[cfg(feature = "psmoveapi-tracker")] -impl MoveTrackerWorkerProgram { - fn as_record(&self, host_id: &str) -> MuninnMoveHueProgramRecord { - MuninnMoveHueProgramRecord { - program_id: move_hue_program_key(host_id), - host_id: host_id.to_string(), - mode: self.mode.clone(), - cycle_ms: self.cycle_ms, - epoch_ns: self.epoch_ns, - hold_at_ns: self.hold_at_ns, - requested_by: "parent-worker-pipe".to_string(), - updated_at: "worker-live".to_string(), - order_mode: self.order_mode.clone(), - transition_percent: self.transition_percent, - transition_percent_explicit: self.transition_percent_explicit, - } - } -} - -#[cfg(feature = "psmoveapi-tracker")] -fn run_move_tracker_worker(options: Options) -> Result<()> { - let source = options - .move_marker_camera_sources - .first() - .context("move-tracker-worker requires one --move-marker-camera")?; - if options.move_marker_camera_sources.len() != 1 { - return Err(anyhow!("move-tracker-worker accepts exactly one camera")); - } - let camera_index = video_device_index(&source.device_path) - .context("move-tracker-worker camera must be /dev/videoN")?; - let mut roster = serve_move_state_sources(&options, true) - .into_iter() - .map(|source| source.move_id) - .collect::>(); - roster.sort(); - roster.dedup(); - let mut input = BufReader::new(std::io::stdin()); - let initial: MoveTrackerWorkerProgram = read_length_framed_message(&mut input)?; - let mut program = initial.as_record(&options.host_id); - let (program_sender, program_receiver) = mpsc::sync_channel(1); - thread::spawn(move || { - while let Ok(program) = read_length_framed_message::(&mut input) { - match program_sender.try_send(program) { - Ok(()) | Err(mpsc::TrySendError::Full(_)) => {} - Err(mpsc::TrySendError::Disconnected(_)) => return, - } - } - }); - let exposure = options.move_tracker_exposure_milli as f32 / 1000.0; - let colors = tracker_colors(&roster, &program); - let mut tracker = - muninn_psmoveapi_tracker::PsmoveApiTracker::open(camera_index, exposure, &colors)?; - let camera_info = tracker.camera_info().clone(); - let mut update_count = 0u64; - let mut observation_count = 0u64; - let mut rejected_stale_count = 0u64; - let mut rejected_radius_count = 0u64; - let mut rejected_bounds_count = 0u64; - let mut rejected_continuity_count = 0u64; - let mut admitted = HashMap::::new(); - let mut last_observation_at = String::new(); - let mut last_calibration = Instant::now(); - let mut last_image_evidence = Instant::now() - Duration::from_secs(1); - let mut image_mean_rgb = Vec::new(); - let mut image_peak_rgb = Vec::new(); - let mut color_evidence_move_ids = Vec::new(); - let mut color_evidence_pixel_counts = Vec::new(); - let stdout = std::io::stdout(); - let mut writer = stdout.lock(); - loop { - while let Ok(latest) = program_receiver.try_recv() { - program = latest.as_record(&options.host_id); - } - if last_calibration.elapsed() >= Duration::from_secs(30) { - tracker.observe_connected(); - last_calibration = Instant::now(); - } - let expected_colors = tracker_colors(&roster, &program); - for (identity, color) in &expected_colors { - tracker.set_expected_color(identity, *color); - } - let observed_now = Instant::now(); - let observations = tracker - .update() - .into_iter() - .filter(|observation| { - match admit_psmoveapi_observation( - observation, - camera_info.width, - camera_info.height, - admitted.get(&observation.move_id), - observed_now, - ) { - Ok(next) => { - admitted.insert(observation.move_id.clone(), next); - true - } - Err(PsmoveApiObservationRejection::Stale) => { - rejected_stale_count = rejected_stale_count.saturating_add(1); - false - } - Err(PsmoveApiObservationRejection::Radius) => { - rejected_radius_count = rejected_radius_count.saturating_add(1); - false - } - Err(PsmoveApiObservationRejection::Bounds) => { - rejected_bounds_count = rejected_bounds_count.saturating_add(1); - false - } - Err(PsmoveApiObservationRejection::Continuity) => { - rejected_continuity_count = rejected_continuity_count.saturating_add(1); - false - } - } - }) - .collect::>(); - update_count = update_count.saturating_add(1); - observation_count = observation_count.saturating_add(observations.len() as u64); - if !observations.is_empty() { - last_observation_at = timestamp()?; - } - if last_image_evidence.elapsed() >= Duration::from_millis(250) { - if let Some((_, _, rgb)) = tracker.rgb_image() { - ( - image_mean_rgb, - image_peak_rgb, - color_evidence_move_ids, - color_evidence_pixel_counts, - ) = summarize_tracker_rgb(&rgb, &expected_colors); - } - last_image_evidence = Instant::now(); - } - let health = MuninnMoveTrackerHealthRecord { - health_id: format!( - "muninn:{}:{}:move-tracker-health", - options.host_id, source.camera_id - ), - host_id: options.host_id.clone(), - camera_id: source.camera_id.clone(), - camera_index, - state: "running".to_string(), - camera_name: camera_info.name.clone(), - camera_api: camera_info.api.clone(), - width: camera_info.width, - height: camera_info.height, - exposure: camera_info.exposure, - calibrated_controller_count: tracker.tracked_controller_count() as u32, - update_count, - observation_count, - latest_observation_count: observations.len() as u32, - last_observation_at: last_observation_at.clone(), - detail: "private subprocess worker".to_string(), - updated_at: timestamp()?, - image_mean_rgb: image_mean_rgb.clone(), - image_peak_rgb: image_peak_rgb.clone(), - color_evidence_move_ids: color_evidence_move_ids.clone(), - color_evidence_pixel_counts: color_evidence_pixel_counts.clone(), - rejected_stale_count, - rejected_radius_count, - rejected_bounds_count, - rejected_continuity_count, - }; - let frame = MoveTrackerWorkerFrame { - health, - observations: observations - .into_iter() - .map(|value| MoveTrackerWorkerObservation { - move_id: value.move_id, - center_x_px: value.center_x_px, - center_y_px: value.center_y_px, - radius_px: value.radius_px, - age_ms: value.age_ms, - }) - .collect(), - }; - write_move_tracker_worker_frame(&mut writer, &frame)?; - thread::sleep(Duration::from_millis(4)); - } -} - -#[cfg(feature = "psmoveapi-tracker")] -#[derive(Clone, Copy, Debug, PartialEq)] -struct AdmittedMoveObservation { - center_x_px: f32, - center_y_px: f32, - radius_px: f32, - admitted_at: Instant, -} - -#[cfg(feature = "psmoveapi-tracker")] -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum PsmoveApiObservationRejection { - Stale, - Radius, - Bounds, - Continuity, -} - -#[cfg(feature = "psmoveapi-tracker")] -fn admit_psmoveapi_observation( - observation: &muninn_psmoveapi_tracker::PsmoveApiObservation, - width: u32, - height: u32, - previous: Option<&AdmittedMoveObservation>, - now: Instant, -) -> std::result::Result { - if observation.age_ms < 0 || observation.age_ms > 50 { - return Err(PsmoveApiObservationRejection::Stale); - } - if !observation.radius_px.is_finite() || observation.radius_px < 2.0 { - return Err(PsmoveApiObservationRejection::Radius); - } - if !observation.center_x_px.is_finite() - || !observation.center_y_px.is_finite() - || observation.center_x_px < 0.0 - || observation.center_x_px >= width as f32 - || observation.center_y_px < 0.0 - || observation.center_y_px >= height as f32 - { - return Err(PsmoveApiObservationRejection::Bounds); - } - if let Some(previous) = previous { - let elapsed = now - .saturating_duration_since(previous.admitted_at) - .as_secs_f32(); - let dx = observation.center_x_px - previous.center_x_px; - let dy = observation.center_y_px - previous.center_y_px; - let distance = (dx * dx + dy * dy).sqrt(); - let radius_ratio = observation.radius_px / previous.radius_px; - if distance > 80.0 + 2_500.0 * elapsed.min(0.25) || !(0.25..=4.0).contains(&radius_ratio) { - return Err(PsmoveApiObservationRejection::Continuity); - } - } - Ok(AdmittedMoveObservation { - center_x_px: observation.center_x_px, - center_y_px: observation.center_y_px, - radius_px: observation.radius_px, - admitted_at: now, - }) -} - -#[cfg(all(test, feature = "psmoveapi-tracker"))] -mod psmoveapi_observation_admission_tests { - use super::*; - fn observation( - x: f32, - y: f32, - radius: f32, - age_ms: i32, - ) -> muninn_psmoveapi_tracker::PsmoveApiObservation { - muninn_psmoveapi_tracker::PsmoveApiObservation { - move_id: "move-test".to_string(), - center_x_px: x, - center_y_px: y, - radius_px: radius, - age_ms, - } - } - #[test] - fn rejects_stale_tiny_and_out_of_frame_positions() { - let now = Instant::now(); - assert_eq!( - Err(PsmoveApiObservationRejection::Stale), - admit_psmoveapi_observation(&observation(100.0, 100.0, 8.0, 51), 640, 480, None, now) - ); - assert_eq!( - Err(PsmoveApiObservationRejection::Radius), - admit_psmoveapi_observation(&observation(100.0, 100.0, 0.7, 1), 640, 480, None, now) - ); - assert_eq!( - Err(PsmoveApiObservationRejection::Bounds), - admit_psmoveapi_observation(&observation(641.0, 100.0, 8.0, 1), 640, 480, None, now) - ); - } - #[test] - fn rejects_fresh_teleport_without_calling_age_confidence() { - let now = Instant::now(); - let previous = - admit_psmoveapi_observation(&observation(100.0, 100.0, 8.0, 1), 640, 480, None, now) - .unwrap(); - assert_eq!( - Err(PsmoveApiObservationRejection::Continuity), - admit_psmoveapi_observation( - &observation(500.0, 400.0, 8.0, 1), - 640, - 480, - Some(&previous), - now + Duration::from_millis(4) - ) - ); - assert!( - admit_psmoveapi_observation( - &observation(108.0, 104.0, 8.3, 1), - 640, - 480, - Some(&previous), - now + Duration::from_millis(4) - ) - .is_ok() - ); - } -} - -#[cfg(feature = "psmoveapi-tracker")] -fn summarize_tracker_rgb( - rgb: &[u8], - expected: &[(String, [u8; 3])], -) -> (Vec, Vec, Vec, Vec) { - let mut sums = [0u64; 3]; - let mut peaks = [0u32; 3]; - let mut counts = vec![0u32; expected.len()]; - let pixels = rgb.len() / 3; - for pixel in rgb.chunks_exact(3) { - for channel in 0..3 { - sums[channel] += u64::from(pixel[channel]); - peaks[channel] = peaks[channel].max(u32::from(pixel[channel])); - } - let max = *pixel.iter().max().unwrap_or(&0); - let min = *pixel.iter().min().unwrap_or(&0); - if max < 40 || max.saturating_sub(min) < 30 { - continue; - } - let pixel_norm = (pixel - .iter() - .map(|value| f64::from(*value).powi(2)) - .sum::()) - .sqrt(); - for (index, (_, color)) in expected.iter().enumerate() { - let color_norm = (color - .iter() - .map(|value| f64::from(*value).powi(2)) - .sum::()) - .sqrt(); - let dot = pixel - .iter() - .zip(color) - .map(|(left, right)| f64::from(*left) * f64::from(*right)) - .sum::(); - if pixel_norm > 0.0 && color_norm > 0.0 && dot / (pixel_norm * color_norm) >= 0.985 { - counts[index] = counts[index].saturating_add(1); - } - } - } - let means = if pixels == 0 { - vec![0, 0, 0] - } else { - sums.iter() - .map(|sum| (*sum / pixels as u64) as u32) - .collect() - }; - ( - means, - peaks.to_vec(), - expected.iter().map(|(id, _)| id.clone()).collect(), - counts, - ) -} - -#[cfg(feature = "psmoveapi-tracker")] -fn tracker_colors( - roster: &[String], - program: &MuninnMoveHueProgramRecord, -) -> Vec<(String, [u8; 3])> { - let now_ns = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() as i128; - let timestamp_ns = move_hue_program_timestamp_ns(program, now_ns); - roster - .iter() - .filter_map(|identity| { - scheduled_golden_move_color_with_order( - identity, - roster, - i128::from(program.epoch_ns), - i128::from(program.cycle_ms) * 1_000_000, - timestamp_ns, - &program.order_mode, - effective_transition_percent(program), - ) - .map(|(color, _, _)| (identity.clone(), [color.0, color.1, color.2])) - }) - .collect() -} - -#[cfg(not(feature = "psmoveapi-tracker"))] -fn run_move_tracker_worker(_options: Options) -> Result<()> { - Err(anyhow!( - "move-tracker-worker requires the psmoveapi-tracker feature" - )) -} - -#[cfg(feature = "psmoveapi-tracker")] -#[derive(Clone, Debug, Serialize, Deserialize)] -struct MoveTrackerWorkerObservation { - move_id: String, - center_x_px: f32, - center_y_px: f32, - radius_px: f32, - age_ms: i32, -} - -#[cfg(feature = "psmoveapi-tracker")] -#[derive(Clone, Debug, Serialize, Deserialize)] -struct MoveTrackerWorkerFrame { - health: MuninnMoveTrackerHealthRecord, - observations: Vec, -} - -#[cfg(feature = "psmoveapi-tracker")] -#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] -struct MoveTrackerWorkerProgram { - mode: String, - cycle_ms: u64, - epoch_ns: i64, - hold_at_ns: i64, - order_mode: String, - transition_percent: u8, - transition_percent_explicit: bool, -} - -#[cfg(feature = "psmoveapi-tracker")] -fn publish_move_tracker_health( - node: &mut cultmesh_rs::CultMeshNode, - active: &mut [ActiveMoveMarkerCameraSource], -) -> Result<()> { - for camera in active { - let Some(latest) = camera.psmoveapi_health.as_ref() else { - continue; - }; - let record = latest.lock().ok().and_then(|mut value| value.take()); - if let Some(record) = record { - node.put(&record.health_id, &record)?; - } - } - Ok(()) -} - -#[cfg(not(feature = "psmoveapi-tracker"))] -fn publish_move_tracker_health( - _node: &mut cultmesh_rs::CultMeshNode, - _active: &mut [ActiveMoveMarkerCameraSource], -) -> Result<()> { - Ok(()) -} - -fn publish_move_evidence_transport_health( - node: &mut cultmesh_rs::CultMeshNode, - options: &Options, - stream: Option<&MoveEvidenceTransportHealth>, -) -> Result<()> { - let Some(stream) = stream else { - return Ok(()); - }; - let record = MuninnMoveEvidenceTransportHealthRecord { - health_id: format!("muninn:{}:move-evidence-transport-health", options.host_id), - host_id: options.host_id.clone(), - stream_id: stream.stream_id.clone(), - produced_frames: stream.counters.produced_frames.load(Ordering::Relaxed), - local_ring_admissions: stream - .counters - .local_ring_admissions - .load(Ordering::Relaxed), - remote_handoffs: stream.counters.remote_handoffs.load(Ordering::Relaxed), - remote_sends: stream.counters.remote_sends.load(Ordering::Relaxed), - updated_at: timestamp()?, - }; - node.put(&record.health_id, &record)?; - Ok(()) -} - -#[cfg(feature = "psmoveapi-tracker")] -fn video_device_index(path: &Path) -> Option { - path.file_name()? - .to_str()? - .strip_prefix("video")? - .parse() - .ok() -} - -fn stable_marker_camera_source_hash(source: &MoveMarkerFrameSource) -> u64 { - stable_u64_hash(&format!( - "{}:{}:{}", - source.host_id, source.camera_id, source.stream_id - )) -} - -fn put_hid_controller_state_receipt( - node: &mut cultmesh_rs::CultMeshNode, - options: &Options, - record: &MuninnHidControllerStateRecord, -) -> Result<()> { - node.put(&record.stream_id, record)?; - node.put( - &sequence_receipt_key(&record.stream_id, record.sequence), - record, - )?; - prune_hid_controller_state_receipt(node, options, &record.stream_id, record.sequence) -} - -fn put_move_controller_state_receipt( - node: &mut cultmesh_rs::CultMeshNode, - options: &Options, - record: &MuninnMoveControllerStateRecord, -) -> Result<()> { - node.put(&record.stream_id, record)?; - node.put( - &sequence_receipt_key(&record.stream_id, record.sequence), - record, - )?; - prune_move_controller_state_receipt(node, options, &record.stream_id, record.sequence) -} - -fn prune_hid_controller_state_receipt( - node: &mut cultmesh_rs::CultMeshNode, - options: &Options, - stream_id: &str, - current_sequence: u64, -) -> Result<()> { - let Some(first_retained_sequence) = first_retained_receipt_sequence(options, current_sequence) - else { - return Ok(()); - }; - for key in stale_sequence_receipt_keys(node, stream_id, first_retained_sequence) { - let _ = node.delete::(&key)?; - } - Ok(()) -} - -fn prune_move_controller_state_receipt( - node: &mut cultmesh_rs::CultMeshNode, - options: &Options, - stream_id: &str, - current_sequence: u64, -) -> Result<()> { - let Some(first_retained_sequence) = first_retained_receipt_sequence(options, current_sequence) - else { - return Ok(()); - }; - for key in stale_sequence_receipt_keys(node, stream_id, first_retained_sequence) { - let _ = node.delete::(&key)?; - } - Ok(()) -} - -fn first_retained_receipt_sequence(options: &Options, current_sequence: u64) -> Option { - let interval_seconds = options.interval_seconds.unwrap_or(15).max(1); - let retained_sequences = (options.hid_controller_receipt_retention_seconds / interval_seconds) - .saturating_add(2) - .max(1); - current_sequence.checked_sub(retained_sequences) -} - -fn stale_sequence_receipt_keys( - node: &cultmesh_rs::CultMeshNode, - stream_id: &str, - first_retained_sequence: u64, -) -> Vec { - node.cache() - .snapshot() - .into_iter() - .filter_map(|envelope| { - let sequence = sequence_from_receipt_key(stream_id, &envelope.key)?; - (sequence < first_retained_sequence).then_some(envelope.key) - }) - .collect() -} - -fn sequence_from_receipt_key(stream_id: &str, key: &str) -> Option { - let suffix = key.strip_prefix(stream_id)?.strip_prefix(':')?; - suffix.parse().ok() -} - -fn sequence_receipt_key(stream_id: &str, sequence: u64) -> String { - format!("{stream_id}:{sequence}") -} - -fn is_joystick_path(path: &str) -> bool { - path.contains("/dev/input/js") || path.contains("-joystick") -} - -fn is_xinput_source_path(path: &str) -> bool { - path.to_ascii_lowercase() - .starts_with(WINDOWS_XINPUT_SOURCE_PREFIX) -} - -fn xinput_index_from_source_path(path: &str) -> Option { - path.to_ascii_lowercase() - .strip_prefix(WINDOWS_XINPUT_SOURCE_PREFIX)? - .parse::() - .ok() - .filter(|index| *index < 4) -} - -struct ActiveHidControllerStream { - target: SocketAddr, - transport: CultNetRudpSocketTransportConnection, - last_connect_attempt_at: Option, - connected_logged: bool, - sent_frames: u64, - last_wait_log_at: Option, - last_sent_at: Option, - last_stale_log_at: Option, -} - -struct ActiveHidControllerRudpSource { - source: MoveStateSource, - sequence: u64, - joystick_axes: [i16; 16], - joystick_buttons: [bool; 32], - latest_report: Option, - last_emitted_axes: Option>, - last_emitted_buttons: Option>, - last_emitted_at: Option, - #[cfg(windows)] - report_rx: Option>, - last_read_error_log_at: Option, -} - -fn active_hid_controller_rudp_source(source: MoveStateSource) -> ActiveHidControllerRudpSource { - ActiveHidControllerRudpSource { - #[cfg(windows)] - report_rx: start_windows_hid_report_reader_if_supported(&source.hidraw_path), - source, - sequence: 0, - joystick_axes: [0; 16], - joystick_buttons: [false; 32], - latest_report: None, - last_emitted_axes: None, - last_emitted_buttons: None, - last_emitted_at: None, - last_read_error_log_at: None, - } -} - -fn sync_hid_controller_rudp_sources( - active: &mut Vec, - desired: Vec, -) { - active.retain(|state| desired.iter().any(|source| source == &state.source)); - for source in desired { - if active.iter().any(|state| state.source == source) { - continue; - } - active.push(active_hid_controller_rudp_source(source)); - } -} - -#[derive(Clone, Debug)] -struct LatestHidReport { - bytes: Vec, - source_timestamp_ns: i64, - observed_at: String, -} - -const HID_CONTROLLER_RUDP_HEARTBEAT_AFTER: Duration = Duration::from_millis(50); -const HID_CONTROLLER_RUDP_AXIS_EPSILON: f32 = 0.01; -const HID_CONTROLLER_RUDP_MAX_FRAGMENT_BYTES: u32 = 1_200; -const HID_CONTROLLER_RUDP_MAX_REPORT_DRAIN: usize = 256; - -#[derive(Clone, Debug, Deserialize)] -struct HidControllerRudpSubscription { - #[serde(rename = "deviceFilter")] - device_filter: Option, - #[serde(rename = "streamId")] - stream_id: Option, -} - -fn start_hid_controller_rudp_ingress( - options: &Options, - counters: Option>, -) -> Result>>>>> { - let Some(bind_address) = options.hid_controller_rudp_bind else { - return Ok(None); - }; - if live_move_state_sources(options).is_empty() { - return Ok(None); - } - let socket = UdpSocket::bind(bind_address) - .with_context(|| format!("binding Muninn HID controller RUDP stream at {bind_address}"))?; - socket - .set_read_timeout(Some(Duration::from_millis(1))) - .with_context(|| format!("setting Muninn HID controller RUDP timeout at {bind_address}"))?; - let move_evidence_latest = Arc::new(Mutex::new(None)); - let ingress_evidence = Arc::clone(&move_evidence_latest); - let runtime_options = options.clone(); - thread::spawn(move || { - if let Err(error) = - run_hid_controller_rudp_ingress(socket, runtime_options, ingress_evidence, counters) - { - eprintln!("Muninn HID controller RUDP stream stopped: {error:#}"); - } - }); - Ok(Some(move_evidence_latest)) -} - -fn run_hid_controller_rudp_ingress( - socket: UdpSocket, - options: Options, - move_evidence_latest: Arc>>>, - counters: Option>, -) -> Result<()> { - let local_addr = socket.local_addr()?; - println!("Muninn HID controller RUDP stream listening at {local_addr}."); - let mut transport = - CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { - runtime_id: "muninn-hid-controller-rudp".to_string(), - socket, - mode: cultnet_rs::CultNetRudpSocketMode::Server, - remote_addr: None, - connection_id: MUNINN_HID_CONTROLLER_RUDP_CONNECTION_ID, - initial_sequence: 1, - resend_delay_ms: 5, - transport_id: Some("muninn-hid-controller-rudp".to_string()), - max_payload_bytes: None, - max_fragment_bytes: Some(HID_CONTROLLER_RUDP_MAX_FRAGMENT_BYTES), - max_pending_reliable_packets: Some(256), - media_reliable_expire_after_ms: Some(25), - reconnect_policy: None, - })?; - let mut sources = live_move_state_sources(&options) - .into_iter() - .map(active_hid_controller_rudp_source) - .collect::>(); - let mut last_source_refresh_at = Instant::now(); - let mut reader = HidMoveControllerStateReader; - let mut sent_frames = 0u64; - let mut last_sent_at = None::; - let mut last_stale_log_at = None::; - let mut selected_device_filter = None::; - let mut selected_stream_id = None::; - let mut last_waiting_for_subscription_log_at = None::; - loop { - if last_source_refresh_at.elapsed() >= Duration::from_millis(500) { - sync_hid_controller_rudp_sources(&mut sources, live_move_state_sources(&options)); - last_source_refresh_at = Instant::now(); - } - for _ in 0..16 { - match transport.receive_once() { - Ok(Some(frame)) if frame.channel_id == "hid.subscribe" => { - match serde_json::from_slice::(&frame.payload) { - Ok(subscription) => { - let next_device_filter = subscription - .device_filter - .filter(|filter| !filter.trim().is_empty()); - let next_stream_id = subscription - .stream_id - .filter(|stream_id| !stream_id.trim().is_empty()); - if selected_device_filter != next_device_filter - || selected_stream_id != next_stream_id - { - eprintln!( - "Muninn HID controller RUDP subscription device_filter={:?} stream_id={:?}", - next_device_filter, next_stream_id - ); - } - selected_device_filter = next_device_filter; - selected_stream_id = next_stream_id; - } - Err(error) => { - eprintln!( - "Muninn ignored invalid HID controller RUDP subscription: {error:#}" - ); - } - } - } - Ok(Some(_)) => {} - Ok(None) => break, - Err(error) => { - eprintln!("Muninn HID controller RUDP receive warning: {error:#}"); - break; - } - } - } - if transport.connected() && selected_device_filter.is_none() && selected_stream_id.is_none() - { - let should_log = last_waiting_for_subscription_log_at - .is_none_or(|logged_at| logged_at.elapsed() >= Duration::from_secs(5)); - if should_log { - eprintln!( - "Muninn HID controller RUDP has a consumer but no selected HID subscription; not broadcasting inputs" - ); - last_waiting_for_subscription_log_at = Some(Instant::now()); - } - thread::sleep(Duration::from_millis(8)); - continue; - } - if transport.connected() { - let payload = move_evidence_latest - .lock() - .ok() - .and_then(|mut latest| latest.take()); - if let Some(payload) = payload { - if let Err(error) = transport.send("move-evidence", payload) { - eprintln!("Muninn Move evidence RUDP send warning: {error:#}"); - } else if let Some(counters) = counters.as_ref() { - counters.remote_sends.fetch_add(1, Ordering::Relaxed); - } - } - } - for source in &mut sources { - if !hid_controller_source_matches_subscription( - &options, - &source.source, - selected_device_filter.as_deref(), - selected_stream_id.as_deref(), - ) { - continue; - } - let Some(record) = read_hid_controller_state_for_stream(&options, source, &mut reader) - else { - continue; - }; - if !hid_controller_record_matches_subscription( - &record, - selected_device_filter.as_deref(), - selected_stream_id.as_deref(), - ) { - continue; - } - if transport.connected() { - let payload = serde_json::to_vec(&sanitize_hid_controller_record(record.clone())) - .context("encoding Muninn HID controller stream record")?; - if let Err(error) = transport.send("latest", payload) { - eprintln!("Muninn HID controller RUDP send warning: {error:#}"); - break; - } - sent_frames = sent_frames.saturating_add(1); - last_sent_at = Some(Instant::now()); - last_stale_log_at = None; - if sent_frames % 600 == 0 { - eprintln!( - "Muninn HID controller RUDP served frame #{} device={} seq={}", - sent_frames, record.device_id, record.sequence - ); - } - } - } - if let Err(error) = transport.poll_resends() { - eprintln!("Muninn HID controller RUDP resend warning: {error:#}"); - } - if transport.connected() - && last_sent_at.is_some_and(|sent_at| sent_at.elapsed() >= Duration::from_secs(2)) - { - let should_log = last_stale_log_at - .is_none_or(|logged_at| logged_at.elapsed() >= Duration::from_secs(5)); - if should_log { - eprintln!( - "Muninn HID controller RUDP has an active consumer but has sent no frames for {:?}", - last_sent_at.map(|sent_at| sent_at.elapsed()) - ); - last_stale_log_at = Some(Instant::now()); - } - } - thread::sleep(Duration::from_millis(8)); - } -} - -fn hid_controller_source_matches_subscription( - options: &Options, - source: &MoveStateSource, - device_filter: Option<&str>, - stream_id: Option<&str>, -) -> bool { - let source_stream_id = format!( - "{}:{}:hid-controller-state", - options.host_id, source.move_id - ); - let stream_matches = stream_id.is_none_or(|stream_id| source_stream_id == stream_id); - let device_matches = device_filter.is_none_or(|filter| { - source.move_id == filter - || source_stream_id == filter - || source.hidraw_path.contains(filter) - || hid_controller_kind_from_source(source) == filter - }); - stream_matches && device_matches -} - -fn hid_controller_record_matches_subscription( - record: &MuninnHidControllerStateRecord, - device_filter: Option<&str>, - stream_id: Option<&str>, -) -> bool { - let stream_matches = stream_id.is_none_or(|stream_id| record.stream_id == stream_id); - let device_matches = device_filter.is_none_or(|filter| { - record.device_id == filter - || record.stream_id == filter - || record.source_path.contains(filter) - || record.device_kind == filter - }); - stream_matches && device_matches -} - -fn read_hid_controller_state_for_stream( - options: &Options, - active: &mut ActiveHidControllerRudpSource, - reader: &mut impl MoveControllerStateReader, -) -> Option { - let source = &active.source; - if is_joystick_path(&source.hidraw_path) { - let events = match reader.read_joystick_events(&source.hidraw_path) { - Ok(events) => { - active.last_read_error_log_at = None; - events - } - Err(error) => { - let should_log = active - .last_read_error_log_at - .is_none_or(|logged_at| logged_at.elapsed() >= Duration::from_secs(5)); - if should_log { - eprintln!( - "Muninn skipped HID controller stream source {} at {}: {error:#}", - source.move_id, source.hidraw_path - ); - active.last_read_error_log_at = Some(Instant::now()); - } - return None; - } - }; - for event in events { - match event.event_type & 0x7f { - 0x01 => { - if let Some(button) = active.joystick_buttons.get_mut(event.number as usize) { - *button = event.value != 0; - } - } - 0x02 => { - if let Some(axis) = active.joystick_axes.get_mut(event.number as usize) { - *axis = event.value; - } - } - _ => {} - } - } - let record = build_hid_controller_state_record_from_joystick( - options, - source, - active.sequence.saturating_add(1), - active.joystick_axes, - active.joystick_buttons, - timestamp_ns().ok()?, - timestamp().ok()?, - ); - return emit_hid_controller_record_if_due(active, record); - } - if !is_xinput_source_path(&source.hidraw_path) { - #[cfg(windows)] - if let Some(rx) = active.report_rx.as_ref() { - let mut saw_report = false; - let mut drained_reports = 0usize; - for _ in 0..HID_CONTROLLER_RUDP_MAX_REPORT_DRAIN { - let Ok(report) = rx.try_recv() else { - break; - }; - active.latest_report = Some(report); - saw_report = true; - drained_reports += 1; - } - if drained_reports == HID_CONTROLLER_RUDP_MAX_REPORT_DRAIN { - eprintln!( - "Muninn Windows HID reader drained {drained_reports} queued reports for {}; keeping latest", - source.move_id - ); - } - if saw_report { - active.last_read_error_log_at = None; - } else if active.latest_report.is_none() || !hid_controller_stream_heartbeat_due(active) - { - return None; - } - let report = active.latest_report.as_ref()?; - let record = build_hid_controller_state_record_from_report( - options, - source, - active.sequence.saturating_add(1), - &report.bytes, - report.source_timestamp_ns, - report.observed_at.clone(), - ); - trace_hid_controller_record("rudp-report", &record, &report.bytes); - return emit_hid_controller_record_if_due(active, record); - } - let report = { - match reader.read_report(&source.hidraw_path) { - Ok(Some(report)) => report, - Ok(None) => return None, - Err(error) => { - let should_log = active - .last_read_error_log_at - .is_none_or(|logged_at| logged_at.elapsed() >= Duration::from_secs(5)); - if should_log { - eprintln!( - "Muninn skipped HID controller stream source {} at {}: {error:#}", - source.move_id, source.hidraw_path - ); - active.last_read_error_log_at = Some(Instant::now()); - } - return None; - } - } - }; - let record = build_hid_controller_state_record_from_report( - options, - source, - active.sequence.saturating_add(1), - &report, - timestamp_ns().ok()?, - timestamp().ok()?, - ); - trace_hid_controller_record("rudp-report", &record, &report); - return emit_hid_controller_record_if_due(active, record); - } - let Some(index) = xinput_index_from_source_path(&source.hidraw_path) else { - return None; - }; - let gamepad = match platform_xinput_gamepad(index) { - Ok(Some(gamepad)) => { - active.last_read_error_log_at = None; - gamepad - } - Ok(None) => return None, - Err(error) => { - let should_log = active - .last_read_error_log_at - .is_none_or(|logged_at| logged_at.elapsed() >= Duration::from_secs(5)); - if should_log { - eprintln!( - "Muninn skipped HID controller stream source {} at {}: {error:#}", - source.move_id, source.hidraw_path - ); - active.last_read_error_log_at = Some(Instant::now()); - } - return None; - } - }; - let record = build_hid_controller_state_record_from_xinput_gamepad( - options, - source, - active.sequence.saturating_add(1), - &gamepad, - timestamp_ns().ok()?, - timestamp().ok()?, - ); - emit_hid_controller_record_if_due(active, record) -} - -fn emit_hid_controller_record_if_due( - active: &mut ActiveHidControllerRudpSource, - mut record: MuninnHidControllerStateRecord, -) -> Option { - let axes_changed = active - .last_emitted_axes - .as_ref() - .is_none_or(|previous| hid_controller_axes_changed(previous, &record.axes)); - let buttons_changed = active - .last_emitted_buttons - .as_ref() - .is_none_or(|previous| previous != &record.buttons); - let heartbeat_due = active - .last_emitted_at - .is_none_or(|last| last.elapsed() >= HID_CONTROLLER_RUDP_HEARTBEAT_AFTER); - if !(axes_changed || buttons_changed || heartbeat_due) { - return None; - } - active.sequence = active.sequence.saturating_add(1); - record.sequence = active.sequence; - active.last_emitted_axes = Some(record.axes.clone()); - active.last_emitted_buttons = Some(record.buttons.clone()); - active.last_emitted_at = Some(Instant::now()); - Some(record) -} - -fn hid_controller_stream_heartbeat_due(active: &ActiveHidControllerRudpSource) -> bool { - active - .last_emitted_at - .is_none_or(|last| last.elapsed() >= HID_CONTROLLER_RUDP_HEARTBEAT_AFTER) -} - -fn hid_controller_axes_changed(previous: &[f32], current: &[f32]) -> bool { - previous.len() != current.len() - || previous - .iter() - .zip(current.iter()) - .any(|(previous, current)| { - (previous - current).abs() > HID_CONTROLLER_RUDP_AXIS_EPSILON - }) -} - -fn build_hid_controller_state_record_from_report( - options: &Options, - source: &MoveStateSource, - sequence: u64, - report: &[u8], - source_timestamp_ns: i64, - observed_at: String, -) -> MuninnHidControllerStateRecord { - MuninnHidControllerStateRecord { - stream_id: format!( - "{}:{}:hid-controller-state", - options.host_id, source.move_id - ), - host_id: options.host_id.clone(), - device_id: source.move_id.clone(), - device_kind: hid_controller_kind_from_source(source), - sequence, - source_timestamp_ns, - axes: hid_controller_axes_from_report(source, report), - buttons: hid_controller_button_names(source, report), - battery01: move_battery01(report.get(12).copied().unwrap_or_default()), - observed_at, - source_path: source.hidraw_path.clone(), - } -} - -fn trace_hid_controller_record( - source: &str, - record: &MuninnHidControllerStateRecord, - report: &[u8], -) { - if !muninn_hid_trace_enabled() { - return; - } - let raw = report - .iter() - .take(24) - .map(|byte| format!("{byte:02x}")) - .collect::>() - .join(" "); - eprintln!( - "Muninn HID trace source={source} device={} kind={} seq={} axes={:?} buttons={:?} raw24=[{}] path={}", - record.device_id, - record.device_kind, - record.sequence, - record.axes, - record.buttons, - raw, - record.source_path - ); -} - -fn muninn_hid_trace_enabled() -> bool { - env::var("MUNINN_HID_TRACE") - .map(|value| { - let value = value.trim(); - value == "1" || value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes") - }) - .unwrap_or(false) -} - -fn hid_controller_axes_from_report(source: &MoveStateSource, report: &[u8]) -> Vec { - if hid_controller_kind_from_source(source) == "ps3-navigation" { - return vec![ - byte_axis_to_signed_unit(report.get(6).copied()), - byte_axis_to_signed_unit(report.get(7).copied()), - trigger_to_signed_unit(report.get(18).copied().unwrap_or_default()), - ]; - } - vec![ - signed_report_axis_to_unit(report, 19), - signed_report_axis_to_unit(report, 21), - trigger_to_signed_unit(report.get(6).copied().unwrap_or_default()), - signed_report_axis_to_unit(report, 25), - signed_report_axis_to_unit(report, 27), - ] -} - -fn hid_controller_button_names(source: &MoveStateSource, report: &[u8]) -> Vec { - if hid_controller_kind_from_source(source) == "ps3-navigation" { - return ps3_navigation_button_names(report); - } - move_button_names(report) -} - -fn ps3_navigation_button_names(report: &[u8]) -> Vec { - let bits = report.get(1).copied().unwrap_or_default() as u32 - | ((report.get(2).copied().unwrap_or_default() as u32) << 8) - | ((report.get(3).copied().unwrap_or_default() as u32) << 16); - [ - (1 << 0, "up"), - (1 << 1, "right"), - (1 << 2, "down"), - (1 << 3, "left"), - (1 << 4, "triangle"), - (1 << 5, "circle"), - (1 << 6, "cross"), - (1 << 7, "square"), - (1 << 8, "select"), - (1 << 9, "l3"), - (1 << 10, "r3"), - (1 << 11, "start"), - (1 << 12, "up"), - (1 << 13, "right"), - (1 << 14, "down"), - (1 << 15, "left"), - (1 << 16, "ps"), - (1 << 18, "l1"), - (1 << 19, "l2"), - (1 << 20, "trigger"), - ] - .iter() - .filter_map(|(mask, name)| { - if bits & mask != 0 { - Some((*name).to_string()) - } else { - None - } - }) - .fold(Vec::::new(), |mut buttons, button| { - if !buttons.iter().any(|existing| existing == &button) { - buttons.push(button); - } - buttons - }) -} - -fn byte_axis_to_signed_unit(value: Option) -> f32 { - match value { - Some(value) => ((value as f32 / 255.0) * 2.0 - 1.0).clamp(-1.0, 1.0), - None => 0.0, - } -} - -fn signed_report_axis_to_unit(report: &[u8], offset: usize) -> f32 { - axis_to_signed_unit(read_le_i16(report, offset)) -} - -fn sanitize_hid_controller_record( - mut record: MuninnHidControllerStateRecord, -) -> MuninnHidControllerStateRecord { - if !record.battery01.is_finite() { - record.battery01 = -1.0; - } - for axis in &mut record.axes { - if !axis.is_finite() { - *axis = 0.0; - } - } - record -} - -fn create_hid_controller_stream(options: &Options) -> Result> { - let Some(target) = options.hid_controller_rudp_target else { - return Ok(None); - }; - let bind_address = if target.is_ipv4() { - "0.0.0.0:0" - } else { - "[::]:0" - }; - let socket = UdpSocket::bind(bind_address) - .with_context(|| format!("binding Muninn HID RUDP sender at {bind_address}"))?; - socket.set_read_timeout(Some(Duration::from_millis(1)))?; - eprintln!("Muninn HID fast stream targeting {target} over CultNet RUDP"); - let transport = CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { - runtime_id: "muninn-hid-controller-rudp".to_string(), - socket, - mode: cultnet_rs::CultNetRudpSocketMode::Client, - remote_addr: Some(target), - connection_id: MUNINN_HID_CONTROLLER_RUDP_CONNECTION_ID, - initial_sequence: 1, - resend_delay_ms: 5, - transport_id: Some("muninn-hid-controller-rudp".to_string()), - max_payload_bytes: None, - max_fragment_bytes: Some(HID_CONTROLLER_RUDP_MAX_FRAGMENT_BYTES), - max_pending_reliable_packets: Some(256), - media_reliable_expire_after_ms: Some(25), - reconnect_policy: None, - })?; - Ok(Some(ActiveHidControllerStream { - target, - transport, - last_connect_attempt_at: None, - connected_logged: false, - sent_frames: 0, - last_wait_log_at: None, - last_sent_at: None, - last_stale_log_at: None, - })) -} - -fn publish_hid_controller_state_to_stream( - stream: Option<&mut ActiveHidControllerStream>, - record: &MuninnHidControllerStateRecord, -) { - let Some(stream) = stream else { - return; - }; - if let Err(error) = publish_hid_controller_state_to_stream_inner(stream, record) { - eprintln!( - "Muninn could not publish HID controller {} to fast RUDP stream {}: {error:#}", - record.device_id, stream.target - ); - } -} - -fn publish_hid_controller_state_to_stream_inner( - stream: &mut ActiveHidControllerStream, - record: &MuninnHidControllerStateRecord, -) -> Result<()> { - if !stream.transport.connected() { - if stream.connected_logged { - eprintln!( - "Muninn HID fast stream lost connection to {}; reconnecting", - stream.target - ); - stream.connected_logged = 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()); - eprintln!("Muninn HID fast stream connecting to {}", stream.target); - stream.transport.connect(Vec::new())?; - } - for _ in 0..8 { - if stream.transport.connected() { - break; - } - let _ = stream.transport.receive_once()?; - stream.transport.poll_resends()?; - } - if !stream.transport.connected() { - let should_log_wait = stream - .last_wait_log_at - .is_none_or(|logged| logged.elapsed() >= Duration::from_secs(2)); - if should_log_wait { - stream.last_wait_log_at = Some(Instant::now()); - eprintln!( - "Muninn HID fast stream waiting for RUDP accept from {}", - stream.target - ); - } - return Ok(()); - } - } - if !stream.connected_logged { - eprintln!("Muninn HID fast stream connected to {}", stream.target); - stream.connected_logged = true; - } - if stream.transport.check_timeout(2_000) { - eprintln!( - "Muninn HID fast stream timed out waiting for {}", - stream.target - ); - stream.connected_logged = false; - return Ok(()); - } - let mut frame = record.clone(); - if !frame.battery01.is_finite() { - frame.battery01 = -1.0; - } - for axis in &mut frame.axes { - if !axis.is_finite() { - *axis = 0.0; - } - } - let payload = serde_json::to_vec(&frame).context("encoding HID controller stream frame")?; - stream.transport.send("latest", payload)?; - stream.last_sent_at = Some(Instant::now()); - stream.last_stale_log_at = None; - for _ in 0..4 { - if stream.transport.receive_once()?.is_none() { - break; - } - } - stream.transport.poll_resends()?; - stream.sent_frames = stream.sent_frames.saturating_add(1); - if stream.sent_frames <= 5 || stream.sent_frames % 120 == 0 { - eprintln!( - "Muninn HID fast stream sent frame #{} device={} seq={} buttons=[{}]", - stream.sent_frames, - record.device_id, - record.sequence, - record.buttons.join(",") - ); - } - Ok(()) -} - -fn publish_hid_controller_state_to_odin( - node: &mut cultmesh_rs::CultMeshNode, - record: &MuninnHidControllerStateRecord, -) { - if let Err(error) = node.put(&record.stream_id, record) { - eprintln!( - "Muninn could not store HID controller {} discovery record: {error:#}", - record.device_id - ); - } -} - -#[cfg(windows)] -fn is_windows_ps_move_source(path: &str) -> bool { - path.eq_ignore_ascii_case("windows-psmove") - || path.eq_ignore_ascii_case("windows-psmove-col01") - || path - .to_ascii_lowercase() - .starts_with(WINDOWS_PS_MOVE_SOURCE_PREFIX) -} - -fn build_move_controller_state_record( - options: &Options, - source: &MoveStateSource, - sequence: u64, - report: &[u8], - source_timestamp_ns: i64, - observed_at: String, -) -> MuninnMoveControllerStateRecord { - MuninnMoveControllerStateRecord { - stream_id: format!( - "{}:{}:move-controller-state", - options.host_id, source.move_id - ), - host_id: options.host_id.clone(), - move_id: source.move_id.clone(), - sequence, - source_timestamp_ns, - accelerometer_xyz: vec![ - read_le_i16(report, 19) as f32, - read_le_i16(report, 21) as f32, - read_le_i16(report, 23) as f32, - ], - gyroscope_xyz: vec![ - read_le_i16(report, 25) as f32, - read_le_i16(report, 27) as f32, - read_le_i16(report, 29) as f32, - ], - magnetometer_xyz: vec![ - read_le_i16(report, 31) as f32, - read_le_i16(report, 33) as f32, - read_le_i16(report, 35) as f32, - ], - trigger_value: report.get(6).copied().unwrap_or_default() as f32 / 255.0, - buttons: move_button_names(report), - battery01: move_battery01(report.get(12).copied().unwrap_or_default()), - observed_at, - source_path: source.hidraw_path.clone(), - } -} - -fn build_move_controller_state_record_from_joystick( - options: &Options, - source: &MoveStateSource, - sequence: u64, - axes: [i16; 16], - buttons: [bool; 32], - source_timestamp_ns: i64, - observed_at: String, -) -> MuninnMoveControllerStateRecord { - MuninnMoveControllerStateRecord { - stream_id: format!( - "{}:{}:move-controller-state", - options.host_id, source.move_id - ), - host_id: options.host_id.clone(), - move_id: source.move_id.clone(), - sequence, - source_timestamp_ns, - accelerometer_xyz: vec![axes[0] as f32, axes[1] as f32, axes[2] as f32], - gyroscope_xyz: vec![axes[3] as f32, axes[4] as f32, axes[5] as f32], - magnetometer_xyz: vec![axes[6] as f32, axes[7] as f32, axes[8] as f32], - trigger_value: axis_to_unit(axes[2]), - buttons: joystick_button_names(buttons), - battery01: f32::NAN, - observed_at, - source_path: source.hidraw_path.clone(), - } -} - -fn build_hid_controller_state_record_from_joystick( - options: &Options, - source: &MoveStateSource, - sequence: u64, - axes: [i16; 16], - buttons: [bool; 32], - source_timestamp_ns: i64, - observed_at: String, -) -> MuninnHidControllerStateRecord { - MuninnHidControllerStateRecord { - stream_id: format!( - "{}:{}:hid-controller-state", - options.host_id, source.move_id - ), - host_id: options.host_id.clone(), - device_id: source.move_id.clone(), - device_kind: hid_controller_kind_from_source(source), - sequence, - source_timestamp_ns, - axes: axes.into_iter().map(axis_to_signed_unit).collect(), - buttons: joystick_button_names(buttons), - battery01: f32::NAN, - observed_at, - source_path: source.hidraw_path.clone(), - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -struct XinputGamepadSnapshot { - buttons: u16, - left_trigger: u8, - right_trigger: u8, - thumb_lx: i16, - thumb_ly: i16, - thumb_rx: i16, - thumb_ry: i16, -} - -fn build_hid_controller_state_record_from_xinput_gamepad( - options: &Options, - source: &MoveStateSource, - sequence: u64, - gamepad: &XinputGamepadSnapshot, - source_timestamp_ns: i64, - observed_at: String, -) -> MuninnHidControllerStateRecord { - MuninnHidControllerStateRecord { - stream_id: format!( - "{}:{}:hid-controller-state", - options.host_id, source.move_id - ), - host_id: options.host_id.clone(), - device_id: source.move_id.clone(), - device_kind: "xinput-controller".to_string(), - sequence, - source_timestamp_ns, - axes: vec![ - axis_to_signed_unit(gamepad.thumb_lx), - axis_to_signed_unit(gamepad.thumb_ly), - trigger_to_signed_unit(gamepad.left_trigger), - axis_to_signed_unit(gamepad.thumb_rx), - axis_to_signed_unit(gamepad.thumb_ry), - trigger_to_signed_unit(gamepad.right_trigger), - ], - buttons: xinput_button_names(gamepad.buttons), - battery01: f32::NAN, - observed_at, - source_path: source.hidraw_path.clone(), - } -} - -fn hid_controller_kind_from_source(source: &MoveStateSource) -> String { - let haystack = format!( - "{} {}", - source.move_id.to_ascii_lowercase(), - source.hidraw_path.to_ascii_lowercase() - ); - if haystack.contains("nav") || haystack.contains("navigation") || haystack.contains("042f") { - "ps3-navigation".to_string() - } else if haystack.contains("xinput") || haystack.contains("xbox") { - "xinput-controller".to_string() - } else if haystack.contains("move") || haystack.contains("03d5") { - "ps-move".to_string() - } else { - "generic-hid-controller".to_string() - } -} - -fn joystick_button_names(buttons: [bool; 32]) -> Vec { - [ - (0, "select"), - (1, "l3"), - (2, "r3"), - (3, "start"), - (4, "up"), - (5, "right"), - (6, "down"), - (7, "left"), - (8, "l2"), - (9, "r2"), - (10, "l1"), - (11, "r1"), - (12, "triangle"), - (13, "circle"), - (14, "cross"), - (15, "square"), - (16, "ps"), - (17, "move"), - (18, "trigger"), - ] - .iter() - .filter_map(|(index, name)| { - if buttons.get(*index).copied().unwrap_or(false) { - Some((*name).to_string()) - } else { - None - } - }) - .collect() -} - -fn axis_to_unit(value: i16) -> f32 { - ((value as f32 + 32768.0) / 65535.0).clamp(0.0, 1.0) -} - -fn axis_to_signed_unit(value: i16) -> f32 { - if value == i16::MIN { - -1.0 - } else { - (value as f32 / i16::MAX as f32).clamp(-1.0, 1.0) - } -} - -fn trigger_to_signed_unit(value: u8) -> f32 { - ((value as f32 / 255.0) * 2.0 - 1.0).clamp(-1.0, 1.0) -} - -fn xinput_button_names(buttons: u16) -> Vec { - [ - (XINPUT_GAMEPAD_DPAD_UP_MASK, "up"), - (XINPUT_GAMEPAD_DPAD_DOWN_MASK, "down"), - (XINPUT_GAMEPAD_DPAD_LEFT_MASK, "left"), - (XINPUT_GAMEPAD_DPAD_RIGHT_MASK, "right"), - (XINPUT_GAMEPAD_START_MASK, "start"), - (XINPUT_GAMEPAD_BACK_MASK, "back"), - (XINPUT_GAMEPAD_LEFT_THUMB_MASK, "l3"), - (XINPUT_GAMEPAD_RIGHT_THUMB_MASK, "r3"), - (XINPUT_GAMEPAD_LEFT_SHOULDER_MASK, "l1"), - (XINPUT_GAMEPAD_RIGHT_SHOULDER_MASK, "r1"), - (XINPUT_GAMEPAD_A_MASK, "a"), - (XINPUT_GAMEPAD_B_MASK, "b"), - (XINPUT_GAMEPAD_X_MASK, "x"), - (XINPUT_GAMEPAD_Y_MASK, "y"), - ] - .iter() - .filter_map(|(mask, name)| { - if buttons & *mask != 0 { - Some((*name).to_string()) - } else { - None - } - }) - .collect() -} - -#[cfg(windows)] -fn platform_xinput_gamepad(index: u32) -> Result> { - let mut state: XINPUT_STATE = unsafe { std::mem::zeroed() }; - let result = unsafe { XInputGetState(index, &mut state) }; - if result != 0 { - return Ok(None); - } - Ok(Some(xinput_snapshot_from_gamepad(state.Gamepad))) -} - -#[cfg(not(windows))] -fn platform_xinput_gamepad(index: u32) -> Result> { - if index >= 4 { - return Err(anyhow!( - "XInput index {index} is outside the supported 0..3 range" - )); - } - Ok(None) -} - -#[cfg(windows)] -fn xinput_snapshot_from_gamepad(gamepad: XINPUT_GAMEPAD) -> XinputGamepadSnapshot { - XinputGamepadSnapshot { - buttons: gamepad.wButtons, - left_trigger: gamepad.bLeftTrigger, - right_trigger: gamepad.bRightTrigger, - thumb_lx: gamepad.sThumbLX, - thumb_ly: gamepad.sThumbLY, - thumb_rx: gamepad.sThumbRX, - thumb_ry: gamepad.sThumbRY, - } -} - -fn move_button_names(report: &[u8]) -> Vec { - let bits = report.get(1).copied().unwrap_or_default() as u32 - | ((report.get(2).copied().unwrap_or_default() as u32) << 8) - | ((report.get(3).copied().unwrap_or_default() as u32) << 16); - [ - (1 << 4, "triangle"), - (1 << 5, "circle"), - (1 << 6, "cross"), - (1 << 7, "square"), - (1 << 8, "select"), - (1 << 11, "start"), - (1 << 16, "ps"), - (1 << 19, "move"), - (1 << 20, "trigger"), - ] - .iter() - .filter_map(|(mask, name)| { - if bits & mask != 0 { - Some((*name).to_string()) - } else { - None - } - }) - .collect() -} - -fn move_battery01(value: u8) -> f32 { - match value { - 0x00..=0x05 => value as f32 / 5.0, - 0xee | 0xef => 1.0, - _ => f32::NAN, - } -} - -fn read_le_i16(report: &[u8], offset: usize) -> i16 { - let Some(lo) = report.get(offset).copied() else { - return 0; - }; - let hi = report.get(offset + 1).copied().unwrap_or_default(); - i16::from_le_bytes([lo, hi]) -} - -fn register_move_light_commands( - node: &mut cultmesh_rs::CultMeshNode, - options: &Options, - active: &mut Vec, -) -> Result<()> { - let commands = node.cache().get_all::()?; - for command in commands { - if command.host_id != options.host_id { - continue; - } - if command.state != "pending" - && !(command.state == "running" - && !active - .iter() - .any(|active| active.command.command_id == command.command_id)) - { - continue; - } - - let active_command = match prepare_move_light_command(command.clone()) { - Ok(active_command) => active_command, - Err(error) => { - let command_id = command.command_id.clone(); - node.put(&command_id, &command_failed(command, &error.to_string())?)?; - continue; - } - }; - let running = MuninnMoveLightCommandRecord { - state: "running".to_string(), - detail: "Muninn is refreshing local PS Move HID reports.".to_string(), - updated_at: timestamp()?, - ..command.clone() - }; - node.put(&running.command_id, &running)?; - active.push(ActiveMoveLightCommand { - command: running, - ..active_command - }); - } - Ok(()) -} - -fn prepare_move_light_command( - command: MuninnMoveLightCommandRecord, -) -> Result { - let colors = parse_move_colors(&command.colors)?; - if command.hidraw_path.trim().is_empty() { - return Err(anyhow!("hidraw_path is required")); - } - if command.repeat_count == 0 { - return Err(anyhow!("repeat_count must be greater than zero")); - } - if command.repeat_count > 86_400 { - return Err(anyhow!("repeat_count must be 86400 or less")); - } - if command.durations_ms.len() != colors.len() { - return Err(anyhow!( - "durations_ms must contain one duration for each color" - )); - } - if command - .durations_ms - .iter() - .any(|duration| *duration > 60_000) - { - return Err(anyhow!("durations_ms values must be 60000 or less")); - } - - Ok(ActiveMoveLightCommand { - command, - colors, - step_index: 0, - repeats_done: 0, - next_write_at: Instant::now(), - }) -} - -fn tick_move_light_commands( - node: &mut cultmesh_rs::CultMeshNode, - active: &mut Vec, - writer: &mut impl MoveLightWriter, -) -> Result<()> { - let now = Instant::now(); - let mut index = 0; - while index < active.len() { - if active[index].next_write_at > now { - index += 1; - continue; - } - - let command_id = active[index].command.command_id.clone(); - let (red, green, blue) = active[index].colors[active[index].step_index]; - let report = move_light_report(red, green, blue); - if let Err(error) = writer.write_report(&active[index].command.hidraw_path, &report) { - let failed = command_failed(active[index].command.clone(), &format!("{error:#}"))?; - node.put(&command_id, &failed)?; - active.remove(index); - continue; - } - - let duration_ms = active[index].command.durations_ms[active[index].step_index]; - active[index].step_index += 1; - if active[index].step_index >= active[index].colors.len() { - active[index].step_index = 0; - active[index].repeats_done += 1; - } - - if active[index].repeats_done >= active[index].command.repeat_count { - let completed = MuninnMoveLightCommandRecord { - state: "completed".to_string(), - detail: format!( - "wrote {} PS Move light step(s) for {} repeat(s)", - active[index].colors.len(), - active[index].command.repeat_count - ), - updated_at: timestamp()?, - ..active[index].command.clone() - }; - node.put(&command_id, &completed)?; - active.remove(index); - } else { - active[index].next_write_at = - now + Duration::from_millis(u64::from(duration_ms.max(1))); - index += 1; - } - } - Ok(()) -} - -fn start_default_move_light_worker( - options: &Options, - suppressed_paths: Arc>>, - move_hue_program: Arc>, -) { - if !serve_should_manage_platform_move_lights(options) { - return; - } - - let options = options.clone(); - thread::spawn(move || { - let mut writer = HidMoveLightWriter; - let mut last_error_log_at = None::; - let mut written_color_by_target = HashMap::::new(); - let mut targets = Vec::::new(); - let mut last_target_refresh_at = None::; - loop { - if targets.is_empty() - || last_target_refresh_at - .is_none_or(|refreshed_at| refreshed_at.elapsed() >= Duration::from_millis(500)) - { - let states = active_move_state_sources(serve_move_state_sources(&options, true)); - targets = default_move_light_paths(&states, true); - last_target_refresh_at = Some(Instant::now()); - } - let mut roster = targets - .iter() - .map(|target| target.identity.clone()) - .collect::>(); - roster.sort(); - roster.dedup(); - let suppressed = suppressed_paths - .lock() - .map(|paths| paths.clone()) - .unwrap_or_default(); - let now_ns = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() as i128; - let program = move_hue_program - .lock() - .map(|program| program.clone()) - .unwrap_or_else(|_| MuninnMoveHueProgramRecord { - program_id: move_hue_program_key(&options.host_id), - host_id: options.host_id.clone(), - mode: "static".to_string(), - cycle_ms: options.move_hue_cycle_ms, - epoch_ns: 0, - hold_at_ns: 0, - requested_by: "poisoned-runtime-fallback".to_string(), - updated_at: "unix-0".to_string(), - order_mode: "descending".to_string(), - transition_percent: 100, - transition_percent_explicit: true, - }); - let program_timestamp_ns = move_hue_program_timestamp_ns(&program, now_ns); - for target in &targets { - if suppressed.contains(&target.path) { - continue; - } - let Some((color, _, _)) = scheduled_golden_move_color_with_order( - &target.identity, - &roster, - i128::from(program.epoch_ns), - i128::from(program.cycle_ms) * 1_000_000, - program_timestamp_ns, - &program.order_mode, - effective_transition_percent(&program), - ) else { - continue; - }; - let target_key = format!("{}:{}", target.identity, target.path); - if written_color_by_target.get(&target_key) == Some(&color) { - continue; - } - let report = default_move_light_report(color); - if let Err(error) = writer.write_report(&target.path, &report) { - let should_log = last_error_log_at - .is_none_or(|logged_at| logged_at.elapsed() >= Duration::from_secs(5)); - if should_log { - eprintln!( - "Muninn default Move light refresh failed identity={} path={}: {error:#}", - target.identity, target.path - ); - last_error_log_at = Some(Instant::now()); - } - } else { - written_color_by_target.insert(target_key, color); - } - } - const MOVE_HUE_UPDATE_NS: u128 = 25_000_000; - let current_ns = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos(); - let until_boundary_ns = MOVE_HUE_UPDATE_NS - current_ns % MOVE_HUE_UPDATE_NS; - thread::sleep(Duration::from_nanos( - until_boundary_ns.min(u128::from(u64::MAX)) as u64, - )); - } - }); -} - -fn update_suppressed_default_move_light_paths( - suppressed_paths: &Arc>>, - active_commands: &[ActiveMoveLightCommand], -) { - if let Ok(mut paths) = suppressed_paths.lock() { - paths.clear(); - paths.extend( - active_commands - .iter() - .map(|command| command.command.hidraw_path.clone()), - ); - } -} - -fn default_move_light_paths( - states: &[ActiveMoveStateSource], - include_platform_defaults: bool, -) -> Vec { - let mut paths = Vec::new(); - for state in states { - if let Some(path) = state.light_hidraw_path.as_ref() { - push_unique_light_target( - &mut paths, - DefaultMoveLightTarget { - path: path.clone(), - identity: state.source.move_id.clone(), - }, - ); - } - } - if include_platform_defaults { - for path in platform_default_move_light_paths() { - push_unique_light_target(&mut paths, path); - } - } - paths -} - -fn push_unique_light_target( - paths: &mut Vec, - target: DefaultMoveLightTarget, -) { - if !paths.iter().any(|existing| existing.path == target.path) { - paths.push(target); - } -} - -fn default_move_color_for_identity(identity: &str) -> (u8, u8, u8) { - let hue = (stable_u64_hash(identity) % 360) as f64; - hsv_to_rgb(hue, 0.82, 1.0) -} - -fn golden_move_color_for_roster(identity: &str, roster: &[String]) -> (u8, u8, u8) { - const GOLDEN_RATIO_CONJUGATE: f64 = 0.618_033_988_749_894_9; - let Some(index) = roster.iter().position(|candidate| candidate == identity) else { - return default_move_color_for_identity(identity); - }; - let hue = ((index as f64 * GOLDEN_RATIO_CONJUGATE).fract() * 360.0 + 15.0) % 360.0; - hsv_to_rgb(hue, 1.0, 1.0) -} - -fn scheduled_golden_move_color( - identity: &str, - roster: &[String], - epoch_ns: i128, - cycle_ns: i128, - timestamp_ns: i128, -) -> Option<((u8, u8, u8), i128, bool)> { - scheduled_golden_move_color_with_order( - identity, - roster, - epoch_ns, - cycle_ns, - timestamp_ns, - "descending", - 100, - ) -} - -fn scheduled_golden_move_color_with_order( - identity: &str, - roster: &[String], - epoch_ns: i128, - cycle_ns: i128, - timestamp_ns: i128, - order_mode: &str, - transition_percent: u8, -) -> Option<((u8, u8, u8), i128, bool)> { - const GOLDEN_RATIO_CONJUGATE: f64 = 0.618_033_988_749_894_9; - if roster.is_empty() || cycle_ns <= 0 { - return None; - } - let identity_index = roster.iter().position(|candidate| candidate == identity)? as i128; - let roster_len = roster.len() as i128; - let elapsed_ns = (timestamp_ns - epoch_ns).max(0); - let completed_steps = elapsed_ns.saturating_mul(roster_len).div_euclid(cycle_ns); - let completed_cycles = completed_steps.div_euclid(roster_len); - let completed_in_cycle = completed_steps.rem_euclid(roster_len); - let update_order = move_hue_update_order(roster.len(), completed_cycles, order_mode); - let order_position = update_order - .iter() - .position(|index| *index == identity_index as usize)? as i128; - let advanced_in_cycle = i128::from(order_position < completed_in_cycle); - let sequence_index = identity_index + completed_cycles + advanced_in_cycle; - let source_hue = (sequence_index as f64 * GOLDEN_RATIO_CONJUGATE).fract(); - let hue = if order_position == completed_in_cycle { - let subslot_ns = cycle_ns.div_euclid(roster_len).max(1); - let subslot_elapsed_ns = elapsed_ns.rem_euclid(subslot_ns); - let amount = if transition_percent == 0 { - 1.0 - } else { - let transition_ns = subslot_ns - .saturating_mul(i128::from(transition_percent.min(100))) - .div_euclid(100) - .max(1); - smootherstep((subslot_elapsed_ns as f64 / transition_ns as f64).min(1.0)) - }; - let target_hue = ((sequence_index + 1) as f64 * GOLDEN_RATIO_CONJUGATE).fract(); - wrapped_hue_lerp(source_hue, target_hue, amount) - } else { - source_hue - }; - Some(( - hsv_to_rgb(hue * 360.0, 1.0, 1.0), - sequence_index, - order_position == completed_in_cycle, - )) -} - -fn move_hue_update_order(move_count: usize, cycle: i128, mode: &str) -> Vec { - let mut order = (0..move_count).collect::>(); - match mode { - "ascending" => order, - "bounce" if cycle.rem_euclid(2) == 1 => order, - "rotating-lead" => { - order.reverse(); - if !order.is_empty() { - let rotation = cycle.rem_euclid(order.len() as i128) as usize; - order.rotate_left(rotation); - } - order - } - "golden-permutation" => { - const GOLDEN_RATIO_CONJUGATE: f64 = 0.618_033_988_749_894_9; - order.sort_by(|left, right| { - let left_phase = (((cycle * move_count as i128 + *left as i128) as f64) - * GOLDEN_RATIO_CONJUGATE) - .fract(); - let right_phase = (((cycle * move_count as i128 + *right as i128) as f64) - * GOLDEN_RATIO_CONJUGATE) - .fract(); - left_phase.total_cmp(&right_phase) - }); - order - } - _ => { - order.reverse(); - order - } - } -} - -fn hsv_to_rgb(hue_degrees: f64, saturation: f64, value: f64) -> (u8, u8, u8) { - let chroma = value * saturation; - let hue_sector = (hue_degrees.rem_euclid(360.0)) / 60.0; - let secondary = chroma * (1.0 - ((hue_sector % 2.0) - 1.0).abs()); - let (red, green, blue) = match hue_sector.floor() as u8 { - 0 => (chroma, secondary, 0.0), - 1 => (secondary, chroma, 0.0), - 2 => (0.0, chroma, secondary), - 3 => (0.0, secondary, chroma), - 4 => (secondary, 0.0, chroma), - _ => (chroma, 0.0, secondary), - }; - let floor = value - chroma; - ( - ((red + floor) * 255.0).round() as u8, - ((green + floor) * 255.0).round() as u8, - ((blue + floor) * 255.0).round() as u8, - ) -} - -fn stable_u64_hash(value: &str) -> u64 { - let mut hash = 0xcbf29ce484222325u64; - for byte in value.as_bytes() { - hash ^= u64::from(*byte); - hash = hash.wrapping_mul(0x100000001b3); - } - if hash == 0 { 1 } else { hash } -} - -fn default_move_light_report(color: (u8, u8, u8)) -> [u8; PS_MOVE_LED_REPORT_LEN] { - move_light_report(color.0, color.1, color.2) -} - -fn move_light_report(red: u8, green: u8, blue: u8) -> [u8; PS_MOVE_LED_REPORT_LEN] { - let mut report = [0u8; PS_MOVE_LED_REPORT_LEN]; - report[0] = 0x06; - report[2] = red; - report[3] = green; - report[4] = blue; - report -} - -fn default_move_light_path(source_path: &str) -> Option { - if source_path.contains("/dev/hidraw") { - return Some(source_path.to_string()); - } - if is_joystick_path(source_path) { - return joystick_light_hidraw_path(source_path); - } - None -} - -#[cfg(unix)] -fn joystick_light_hidraw_path(joystick_path: &str) -> Option { - let joystick_name = Path::new(joystick_path).file_name()?.to_str()?; - let mut cursor = fs::canonicalize(format!("/sys/class/input/{joystick_name}/device")).ok()?; - for _ in 0..12 { - let hidraw_dir = cursor.join("hidraw"); - if let Ok(entries) = fs::read_dir(hidraw_dir) { - for entry in entries.flatten() { - let name = entry.file_name(); - let name = name.to_str()?; - if name.starts_with("hidraw") { - return Some(format!("/dev/{name}")); - } - } - } - if !cursor.pop() { - break; - } - } - None -} - -#[cfg(unix)] -fn platform_move_state_sources() -> Vec { - let mut candidates: Vec<(MoveStateSource, u8)> = Vec::new(); - let Ok(entries) = fs::read_dir("/dev/input") else { - return Vec::new(); - }; - - for entry in entries.flatten() { - let Some(name) = entry.file_name().to_str().map(str::to_string) else { - continue; - }; - if !name.starts_with("js") { - continue; - } - let joystick_path = format!("/dev/input/{name}"); - let Some(sysfs_device) = fs::canonicalize(format!("/sys/class/input/{name}/device")).ok() - else { - continue; - }; - let uevent = read_move_uevent_chain(&sysfs_device); - let input_name = read_sysfs_input_name(&sysfs_device); - if !is_muninn_hid_controller_uevent(&uevent, input_name.as_deref()) { - continue; - } - - let hidraw_path = joystick_light_hidraw_path(&joystick_path); - let move_id = hid_controller_unique_id_from_uevent(&uevent, input_name.as_deref()) - .or_else(|| { - hidraw_path - .as_deref() - .and_then(controller_id_from_hidraw) - .map(|id| format!("move-{id}")) - }) - .unwrap_or_else(|| format!("hid-{name}")); - let score = if uevent.contains("HID_ID=0005:0000054C:000003D5") { - 2 - } else { - 1 - }; - - candidates.push(( - MoveStateSource { - move_id, - hidraw_path: joystick_path, - }, - score, - )); - } - - let mut selected: Vec<(MoveStateSource, u8)> = Vec::new(); - for candidate in candidates { - if let Some(existing) = selected - .iter_mut() - .find(|existing| existing.0.move_id == candidate.0.move_id) - { - if candidate.1 >= existing.1 { - *existing = candidate; - } - continue; - } - selected.push(candidate); - } - selected.into_iter().map(|(source, _)| source).collect() -} - -#[cfg(windows)] -const WINDOWS_PS_MOVE_SOURCE_PREFIX: &str = "windows-psmove:"; - -#[cfg(windows)] -fn platform_move_state_sources() -> Vec { - let targets = windows_ps_move_state_paths().unwrap_or_default(); - let mut sources = targets - .into_iter() - .enumerate() - .map(|(index, target)| { - let path_lower = target.path.to_ascii_lowercase(); - let move_id = if target.identity.starts_with("move-") - || target.identity.starts_with("nav-") - { - target.identity - } else if path_lower.contains("pid_042f") || path_lower.contains("vid_054c&pid_042f") { - format!("nav-windows-psnav-{index}") - } else { - format!("move-windows-psmove-{index}") - }; - MoveStateSource { - move_id, - hidraw_path: windows_ps_move_source_token(&target.path), - } - }) - .collect::>(); - for index in 0..4 { - if matches!(platform_xinput_gamepad(index), Ok(Some(_))) { - let source = MoveStateSource { - move_id: format!("xbox-xinput-{index}"), - hidraw_path: format!("{WINDOWS_XINPUT_SOURCE_PREFIX}{index}"), - }; - if !sources.iter().any(|existing| { - existing - .hidraw_path - .eq_ignore_ascii_case(&source.hidraw_path) - }) { - sources.push(source); - } - } - } - sources -} - -#[cfg(windows)] -fn windows_ps_move_source_token(path: &str) -> String { - format!("{WINDOWS_PS_MOVE_SOURCE_PREFIX}{path}") -} - -#[cfg(not(unix))] -#[cfg(not(windows))] -fn platform_move_state_sources() -> Vec { - Vec::new() -} - -#[cfg(unix)] -fn read_move_uevent_chain(start: &Path) -> String { - let mut cursor = start.to_path_buf(); - let mut text = String::new(); - for _ in 0..6 { - if let Ok(uevent) = fs::read_to_string(cursor.join("uevent")) { - text.push_str(&uevent); - text.push('\n'); - } - if !cursor.pop() { - break; - } - } - text -} - -#[cfg(unix)] -fn read_sysfs_input_name(sysfs_device: &Path) -> Option { - fs::read_to_string(sysfs_device.join("name")) - .ok() - .map(|name| name.trim().to_string()) - .filter(|name| !name.is_empty()) -} - -#[cfg(unix)] -fn is_muninn_hid_controller_uevent(uevent: &str, input_name: Option<&str>) -> bool { - is_ps_move_uevent(uevent) || is_ps3_navigation_uevent(uevent, input_name) -} - -#[cfg(unix)] -fn is_ps_move_uevent(uevent: &str) -> bool { - uevent.contains("ID_VENDOR_ID=054c") && uevent.contains("ID_MODEL_ID=03d5") - || uevent.contains("ID_MODEL=Motion_Controller") - || uevent.contains("HID_ID=0005:0000054C:000003D5") - || uevent.contains("HID_ID=0003:0000054C:000003D5") -} - -#[cfg(unix)] -fn is_ps3_navigation_uevent(uevent: &str, input_name: Option<&str>) -> bool { - uevent.contains("ID_VENDOR_ID=054c") && uevent.contains("ID_MODEL_ID=042f") - || uevent.contains("HID_ID=0005:0000054C:0000042F") - || uevent.contains("HID_ID=0003:0000054C:0000042F") - || input_name.is_some_and(|name| { - name.to_ascii_lowercase() - .contains("sony navigation controller") - }) -} - -#[cfg(unix)] -fn hid_controller_unique_id_from_uevent(uevent: &str, input_name: Option<&str>) -> Option { - value_from_uevent(uevent, "HID_UNIQ") - .map(|value| value.replace(':', "")) - .filter(|value| !value.is_empty()) - .map(|value| { - if is_ps3_navigation_uevent(uevent, input_name) { - format!("nav-{value}") - } else { - format!("move-{value}") - } - }) -} - -#[cfg(unix)] -fn value_from_uevent(uevent: &str, key: &str) -> Option { - uevent.lines().find_map(|line| { - let (candidate_key, value) = line.split_once('=')?; - (candidate_key == key).then(|| value.trim().to_string()) - }) -} - -#[cfg(unix)] -fn controller_id_from_hidraw(hidraw_path: &str) -> Option { - let device = fs::OpenOptions::new() - .read(true) - .write(true) - .custom_flags(libc::O_NONBLOCK) - .open(hidraw_path) - .ok()?; - let mut report = [0u8; 16]; - report[0] = 4; - let request = hid_iocgfeature(report.len()); - let ok = unsafe { libc::ioctl(device.as_raw_fd(), request, report.as_mut_ptr()) }; - if ok < 0 { - return None; - } - Some( - report[1..7] - .iter() - .rev() - .map(|byte| format!("{byte:02x}")) - .collect::(), - ) -} - -#[cfg(unix)] -fn hid_iocgfeature(length: usize) -> libc::c_ulong { - const IOC_READ: libc::c_ulong = 2; - const IOC_WRITE: libc::c_ulong = 1; - const IOC_NRBITS: libc::c_ulong = 8; - const IOC_TYPEBITS: libc::c_ulong = 8; - const IOC_SIZEBITS: libc::c_ulong = 14; - const IOC_NRSHIFT: libc::c_ulong = 0; - const IOC_TYPESHIFT: libc::c_ulong = IOC_NRSHIFT + IOC_NRBITS; - const IOC_SIZESHIFT: libc::c_ulong = IOC_TYPESHIFT + IOC_TYPEBITS; - const IOC_DIRSHIFT: libc::c_ulong = IOC_SIZESHIFT + IOC_SIZEBITS; - ((IOC_READ | IOC_WRITE) << IOC_DIRSHIFT) - | ((length as libc::c_ulong) << IOC_SIZESHIFT) - | ((b'H' as libc::c_ulong) << IOC_TYPESHIFT) - | (0x07 << IOC_NRSHIFT) -} - -#[cfg(unix)] -fn hid_iocsfeature(length: usize) -> libc::c_ulong { - const IOC_READ: libc::c_ulong = 2; - const IOC_WRITE: libc::c_ulong = 1; - const IOC_NRBITS: libc::c_ulong = 8; - const IOC_TYPEBITS: libc::c_ulong = 8; - const IOC_SIZEBITS: libc::c_ulong = 14; - const IOC_NRSHIFT: libc::c_ulong = 0; - const IOC_TYPESHIFT: libc::c_ulong = IOC_NRSHIFT + IOC_NRBITS; - const IOC_SIZESHIFT: libc::c_ulong = IOC_TYPESHIFT + IOC_TYPEBITS; - const IOC_DIRSHIFT: libc::c_ulong = IOC_SIZESHIFT + IOC_SIZEBITS; - ((IOC_READ | IOC_WRITE) << IOC_DIRSHIFT) - | ((length as libc::c_ulong) << IOC_SIZESHIFT) - | ((b'H' as libc::c_ulong) << IOC_TYPESHIFT) - | (0x06 << IOC_NRSHIFT) -} - -#[cfg(unix)] -fn platform_default_bluetooth_host_address() -> Option { - let entries = fs::read_dir("/sys/class/bluetooth").ok()?; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name) = name.to_str() else { - continue; - }; - if !name.starts_with("hci") { - continue; - } - let Ok(address) = fs::read_to_string(entry.path().join("address")) else { - continue; - }; - let address = address.trim(); - if !address.is_empty() { - return Some(address.to_string()); - } - } - bluetoothctl_host_address() -} - -#[cfg(unix)] -fn bluetoothctl_host_address() -> Option { - let output = Command::new("bluetoothctl").arg("show").output().ok()?; - if !output.status.success() { - return None; - } - let text = String::from_utf8_lossy(&output.stdout); - text.lines().find_map(|line| { - let trimmed = line.trim(); - let address = trimmed - .strip_prefix("Controller ")? - .split_whitespace() - .next()?; - parse_bluetooth_address_little_endian(address) - .ok() - .map(|_| address.to_string()) - }) -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct BluetoothMoveDevice { - address: String, - trusted: bool, - connected: bool, -} - -#[cfg(unix)] -fn bluetoothctl_motion_controller_devices() -> Result> { - let output = Command::new("bluetoothctl") - .arg("devices") - .output() - .context("bluetoothctl devices could not run")?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow!("bluetoothctl devices failed: {}", stderr.trim())); - } - let stdout = String::from_utf8_lossy(&output.stdout); - let mut devices = Vec::new(); - for line in stdout.lines() { - let Some(address) = parse_bluetoothctl_motion_controller_device_line(line) else { - continue; - }; - if let Some(device) = bluetoothctl_device_info(&address) { - devices.push(device); - } - } - Ok(devices) -} - -fn parse_bluetoothctl_motion_controller_device_line(line: &str) -> Option { - let trimmed = line.trim(); - let rest = trimmed.strip_prefix("Device ")?; - let mut parts = rest.split_whitespace(); - let address = parts.next()?; - let name = parts.collect::>().join(" "); - if !name.eq_ignore_ascii_case("Motion Controller") { - return None; - } - parse_bluetooth_address_little_endian(address) - .ok() - .map(|_| address.to_string()) -} - -#[cfg(unix)] -fn bluetoothctl_device_info(address: &str) -> Option { - let output = Command::new("bluetoothctl") - .args(["info", address]) - .output() - .ok()?; - if !output.status.success() { - return None; - } - Some(parse_bluetoothctl_device_info( - address, - &String::from_utf8_lossy(&output.stdout), - )) -} - -fn parse_bluetoothctl_device_info(address: &str, text: &str) -> BluetoothMoveDevice { - BluetoothMoveDevice { - address: address.to_string(), - trusted: bluetoothctl_info_flag(text, "Trusted"), - connected: bluetoothctl_info_flag(text, "Connected"), - } -} - -fn bluetoothctl_info_flag(text: &str, name: &str) -> bool { - let prefix = format!("{name}:"); - text.lines().any(|line| { - let trimmed = line.trim(); - trimmed - .strip_prefix(&prefix) - .is_some_and(|value| value.trim().eq_ignore_ascii_case("yes")) - }) -} - -#[cfg(unix)] -fn bluetoothctl_connect_device(address: &str) -> Result { - let mut child = Command::new("bluetoothctl") - .args(["connect", address]) - .stdout(Stdio::null()) - .stderr(Stdio::piped()) - .spawn() - .with_context(|| format!("bluetoothctl connect {address} could not start"))?; - let deadline = Instant::now() + Duration::from_secs(3); - loop { - if let Some(status) = child - .try_wait() - .with_context(|| format!("waiting for bluetoothctl connect {address}"))? - { - return Ok(status.success()); - } - if Instant::now() >= deadline { - let _ = child.kill(); - let _ = child.wait(); - return Ok(false); - } - thread::sleep(Duration::from_millis(50)); - } -} - -#[cfg(not(unix))] -fn platform_default_bluetooth_host_address() -> Option { - None -} - -#[cfg(unix)] -fn unix_usb_move_hidraw_paths() -> Vec { - let mut paths = Vec::new(); - let Ok(entries) = fs::read_dir("/sys/class/hidraw") else { - return paths; - }; - for entry in entries.flatten() { - let name = entry.file_name(); - let Some(name) = name.to_str() else { - continue; - }; - let Ok(uevent) = fs::read_to_string(entry.path().join("device").join("uevent")) else { - continue; - }; - if uevent.contains("HID_ID=0003:0000054C:000003D5") { - paths.push(format!("/dev/{name}")); - } - } - paths.sort(); - paths -} - -#[cfg(unix)] -fn unix_claim_ps_move_host(host: &[u8; 6]) -> Result { - let mut claims = 0usize; - let mut hard_errors = Vec::new(); - for path in unix_usb_move_hidraw_paths() { - let device = match fs::OpenOptions::new() - .read(true) - .write(true) - .custom_flags(libc::O_NONBLOCK) - .open(&path) - { - Ok(device) => device, - Err(error) => { - hard_errors.push(format!("{path}: {error}")); - continue; - } - }; - - let before = controller_and_host_from_hidraw_device(&device); - let mut report = [0u8; 23]; - report[0] = 0x05; - report[1..7].copy_from_slice(host); - let request = hid_iocsfeature(report.len()); - let ok = unsafe { libc::ioctl(device.as_raw_fd(), request, report.as_mut_ptr()) }; - if ok < 0 { - eprintln!( - "path={path} skipped error={}", - std::io::Error::last_os_error() - ); - continue; - } - let after = controller_and_host_from_hidraw_device(&device); - let controller = after - .as_ref() - .or(before.as_ref()) - .map(|addresses| addresses.0.as_str()) - .unwrap_or("(unknown)"); - let host_before = before - .as_ref() - .map(|addresses| addresses.1.as_str()) - .unwrap_or("(unknown)"); - let host_after = after - .as_ref() - .map(|addresses| addresses.1.as_str()) - .unwrap_or("(unknown)"); - println!( - "path={path} controller={controller} host_before={host_before} host_after={host_after}" - ); - claims += 1; - } - - if claims == 0 && !hard_errors.is_empty() { - return Err(anyhow!( - "no USB PS Move host claims succeeded: {}", - hard_errors.join("; ") - )); - } - Ok(claims) -} - -#[cfg(unix)] -fn controller_and_host_from_hidraw_device(device: &fs::File) -> Option<(String, String)> { - let mut report = [0u8; 16]; - report[0] = 4; - let request = hid_iocgfeature(report.len()); - let ok = unsafe { libc::ioctl(device.as_raw_fd(), request, report.as_mut_ptr()) }; - if ok < 0 { - return None; - } - Some(( - format_bluetooth_address_little_endian(&report[1..7]), - format_bluetooth_address_little_endian(&report[10..16]), - )) -} - -#[cfg(not(unix))] -fn joystick_light_hidraw_path(_joystick_path: &str) -> Option { - None -} - -#[cfg(not(windows))] -fn platform_default_move_light_paths() -> Vec { - Vec::new() -} - -#[cfg(windows)] -fn platform_default_move_light_paths() -> Vec { - windows_ps_move_light_paths().unwrap_or_default() -} - -#[cfg(windows)] -fn windows_ps_move_light_paths() -> Result> { - use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{ - DIGCF_DEVICEINTERFACE, DIGCF_PRESENT, SP_DEVICE_INTERFACE_DATA, - SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInterfaces, SetupDiGetClassDevsW, - }; - use windows_sys::Win32::Devices::HumanInterfaceDevice::{ - HIDD_ATTRIBUTES, HIDP_CAPS, HIDP_STATUS_SUCCESS, HidD_FreePreparsedData, - HidD_GetAttributes, HidD_GetHidGuid, HidD_GetPreparsedData, HidP_GetCaps, - }; - use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::Storage::FileSystem::{ - CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, - }; - - let mut hid_guid = unsafe { std::mem::zeroed() }; - unsafe { HidD_GetHidGuid(&mut hid_guid) }; - let info_set = unsafe { - SetupDiGetClassDevsW( - &hid_guid, - std::ptr::null(), - std::ptr::null_mut(), - DIGCF_PRESENT | DIGCF_DEVICEINTERFACE, - ) - }; - if info_set == INVALID_HANDLE_VALUE as isize { - return Ok(Vec::new()); - } - - let mut paths = Vec::new(); - let mut index = 0; - loop { - let mut interface_data = SP_DEVICE_INTERFACE_DATA { - cbSize: std::mem::size_of::() as u32, - InterfaceClassGuid: unsafe { std::mem::zeroed() }, - Flags: 0, - Reserved: 0, - }; - let ok = unsafe { - SetupDiEnumDeviceInterfaces( - info_set, - std::ptr::null_mut(), - &hid_guid, - index, - &mut interface_data, - ) - }; - if ok == 0 { - break; - } - index += 1; - - let Some(path) = (unsafe { windows_hid_interface_path(info_set, &mut interface_data) }) - else { - continue; - }; - if !path.to_ascii_lowercase().contains("&col01#") { - continue; - } - - let wide = wide_null(&path); - let handle = unsafe { - CreateFileW( - wide.as_ptr(), - 0, - FILE_SHARE_READ | FILE_SHARE_WRITE, - std::ptr::null_mut(), - OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, - std::ptr::null_mut(), - ) - }; - if handle == INVALID_HANDLE_VALUE { - continue; - } - - let mut attributes = HIDD_ATTRIBUTES { - Size: std::mem::size_of::() as u32, - VendorID: 0, - ProductID: 0, - VersionNumber: 0, - }; - let mut preparsed = 0; - let is_move_light = unsafe { - HidD_GetAttributes(handle, &mut attributes) != 0 - && attributes.VendorID == 0x054c - && attributes.ProductID == 0x03d5 - && HidD_GetPreparsedData(handle, &mut preparsed) != 0 - }; - if is_move_light { - let mut caps: HIDP_CAPS = unsafe { std::mem::zeroed() }; - let caps_ok = unsafe { HidP_GetCaps(preparsed, &mut caps) == HIDP_STATUS_SUCCESS }; - if caps_ok && caps.OutputReportByteLength > 0 { - let identity = windows_ps_move_controller_identity(handle) - .map(|id| format!("move-{id}")) - .unwrap_or_else(|| path.clone()); - paths.push(DefaultMoveLightTarget { path, identity }); - } - } - if preparsed != 0 { - unsafe { HidD_FreePreparsedData(preparsed) }; - } - unsafe { CloseHandle(handle) }; - } - - unsafe { SetupDiDestroyDeviceInfoList(info_set) }; - paths.sort_by(|a, b| a.identity.cmp(&b.identity).then(a.path.cmp(&b.path))); - Ok(paths) -} - -#[cfg(windows)] -fn windows_ps_move_state_paths() -> Result> { - use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{ - DIGCF_DEVICEINTERFACE, DIGCF_PRESENT, SP_DEVICE_INTERFACE_DATA, - SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInterfaces, SetupDiGetClassDevsW, - }; - use windows_sys::Win32::Devices::HumanInterfaceDevice::{ - HIDD_ATTRIBUTES, HIDP_CAPS, HIDP_STATUS_SUCCESS, HidD_FreePreparsedData, - HidD_GetAttributes, HidD_GetHidGuid, HidD_GetPreparsedData, HidP_GetCaps, - }; - use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::Storage::FileSystem::{ - CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, - }; - - let sibling_identities = windows_ps_move_identity_by_physical_key()?; - let mut hid_guid = unsafe { std::mem::zeroed() }; - unsafe { HidD_GetHidGuid(&mut hid_guid) }; - let info_set = unsafe { - SetupDiGetClassDevsW( - &hid_guid, - std::ptr::null(), - std::ptr::null_mut(), - DIGCF_PRESENT | DIGCF_DEVICEINTERFACE, - ) - }; - if info_set == INVALID_HANDLE_VALUE as isize { - return Ok(Vec::new()); - } - - let mut paths = Vec::new(); - let mut index = 0; - loop { - let mut interface_data = SP_DEVICE_INTERFACE_DATA { - cbSize: std::mem::size_of::() as u32, - InterfaceClassGuid: unsafe { std::mem::zeroed() }, - Flags: 0, - Reserved: 0, - }; - let ok = unsafe { - SetupDiEnumDeviceInterfaces( - info_set, - std::ptr::null_mut(), - &hid_guid, - index, - &mut interface_data, - ) - }; - if ok == 0 { - break; - } - index += 1; - - let Some(path) = (unsafe { windows_hid_interface_path(info_set, &mut interface_data) }) - else { - continue; - }; - let wide = wide_null(&path); - let handle = unsafe { - CreateFileW( - wide.as_ptr(), - 0, - FILE_SHARE_READ | FILE_SHARE_WRITE, - std::ptr::null_mut(), - OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, - std::ptr::null_mut(), - ) - }; - if handle == INVALID_HANDLE_VALUE { - continue; - } - - let mut attributes = HIDD_ATTRIBUTES { - Size: std::mem::size_of::() as u32, - VendorID: 0, - ProductID: 0, - VersionNumber: 0, - }; - let mut preparsed = 0; - let is_move = unsafe { - HidD_GetAttributes(handle, &mut attributes) != 0 - && attributes.VendorID == 0x054c - && matches!(attributes.ProductID, 0x03d5 | 0x042f) - && HidD_GetPreparsedData(handle, &mut preparsed) != 0 - }; - if is_move { - let mut caps: HIDP_CAPS = unsafe { std::mem::zeroed() }; - let caps_ok = unsafe { HidP_GetCaps(preparsed, &mut caps) == HIDP_STATUS_SUCCESS }; - if caps_ok && caps.InputReportByteLength > 0 { - let identity = windows_ps_move_controller_identity(handle) - .or_else(|| { - windows_ps_move_physical_key(&path) - .and_then(|key| sibling_identities.get(&key).cloned()) - }) - .map(|id| { - if attributes.ProductID == 0x042f { - format!("nav-{id}") - } else { - format!("move-{id}") - } - }) - .unwrap_or_else(|| path.clone()); - paths.push(DefaultMoveLightTarget { path, identity }); - } - } - if preparsed != 0 { - unsafe { HidD_FreePreparsedData(preparsed) }; - } - unsafe { CloseHandle(handle) }; - } - - unsafe { SetupDiDestroyDeviceInfoList(info_set) }; - paths.sort_by(|a, b| a.identity.cmp(&b.identity).then(a.path.cmp(&b.path))); - Ok(paths) -} - -#[cfg(windows)] -fn windows_ps_move_identity_by_physical_key() -> Result> { - use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{ - DIGCF_DEVICEINTERFACE, DIGCF_PRESENT, SP_DEVICE_INTERFACE_DATA, - SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInterfaces, SetupDiGetClassDevsW, - }; - use windows_sys::Win32::Devices::HumanInterfaceDevice::{ - HIDD_ATTRIBUTES, HidD_GetAttributes, HidD_GetHidGuid, - }; - use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::Storage::FileSystem::{ - CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, - }; - - let mut hid_guid = unsafe { std::mem::zeroed() }; - unsafe { HidD_GetHidGuid(&mut hid_guid) }; - let info_set = unsafe { - SetupDiGetClassDevsW( - &hid_guid, - std::ptr::null(), - std::ptr::null_mut(), - DIGCF_PRESENT | DIGCF_DEVICEINTERFACE, - ) - }; - if info_set == INVALID_HANDLE_VALUE as isize { - return Ok(std::collections::HashMap::new()); - } - - let mut identities = std::collections::HashMap::new(); - let mut index = 0; - loop { - let mut interface_data = SP_DEVICE_INTERFACE_DATA { - cbSize: std::mem::size_of::() as u32, - InterfaceClassGuid: unsafe { std::mem::zeroed() }, - Flags: 0, - Reserved: 0, - }; - let ok = unsafe { - SetupDiEnumDeviceInterfaces( - info_set, - std::ptr::null_mut(), - &hid_guid, - index, - &mut interface_data, - ) - }; - if ok == 0 { - break; - } - index += 1; - - let Some(path) = (unsafe { windows_hid_interface_path(info_set, &mut interface_data) }) - else { - continue; - }; - let Some(key) = windows_ps_move_physical_key(&path) else { - continue; - }; - let wide = wide_null(&path); - let handle = unsafe { - CreateFileW( - wide.as_ptr(), - 0, - FILE_SHARE_READ | FILE_SHARE_WRITE, - std::ptr::null_mut(), - OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, - std::ptr::null_mut(), - ) - }; - if handle == INVALID_HANDLE_VALUE { - continue; - } - - let mut attributes = HIDD_ATTRIBUTES { - Size: std::mem::size_of::() as u32, - VendorID: 0, - ProductID: 0, - VersionNumber: 0, - }; - let is_move = unsafe { - HidD_GetAttributes(handle, &mut attributes) != 0 - && attributes.VendorID == 0x054c - && attributes.ProductID == 0x03d5 - }; - if is_move { - if let Some(identity) = windows_ps_move_controller_identity(handle) { - identities.insert(key, identity); - } - } - unsafe { CloseHandle(handle) }; - } - - unsafe { SetupDiDestroyDeviceInfoList(info_set) }; - Ok(identities) -} - -#[cfg(windows)] -fn windows_ps_move_physical_key(path: &str) -> Option { - let lower = path.to_ascii_lowercase(); - if !lower.contains("vid_054c&pid_03d5") { - return None; - } - let (before_guid, _) = lower.split_once("#{").unwrap_or((lower.as_str(), "")); - let without_collection = before_guid - .replace("&col01", "") - .replace("&col02", "") - .replace("&col03", "") - .replace("&col04", ""); - let Some((prefix, suffix)) = without_collection.rsplit_once('&') else { - return Some(without_collection); - }; - if suffix.len() == 4 - && suffix - .chars() - .all(|character| character.is_ascii_hexdigit()) - { - return Some(prefix.to_string()); - } - Some(without_collection) -} - -#[cfg(windows)] -fn windows_ps_move_controller_identity(handle: *mut std::ffi::c_void) -> Option { - use windows_sys::Win32::Devices::HumanInterfaceDevice::HidD_GetFeature; - - let mut report = [0u8; 16]; - report[0] = 0x04; - let ok = unsafe { HidD_GetFeature(handle, report.as_mut_ptr().cast(), report.len() as u32) }; - if ok == 0 { - return None; - } - Some( - report[1..7] - .iter() - .rev() - .map(|byte| format!("{byte:02x}")) - .collect::(), - ) -} - -fn parse_bluetooth_address_little_endian(value: &str) -> Result<[u8; 6]> { - let parts = value.split([':', '-']).collect::>(); - if parts.len() != 6 { - return Err(anyhow!("Bluetooth address must have six hex bytes")); - } - let mut address = [0u8; 6]; - for (index, part) in parts.iter().enumerate() { - let byte = u8::from_str_radix(part, 16) - .with_context(|| format!("parsing Bluetooth address byte {part}"))?; - address[5 - index] = byte; - } - Ok(address) -} - -fn format_bluetooth_address_little_endian(address: &[u8]) -> String { - address - .iter() - .rev() - .map(|byte| format!("{byte:02x}")) - .collect::>() - .join(":") -} - -#[cfg(windows)] -fn windows_ps_move_bluetooth_addresses(handle: *mut std::ffi::c_void) -> Option<(String, String)> { - use windows_sys::Win32::Devices::HumanInterfaceDevice::HidD_GetFeature; - - let mut report = [0u8; 16]; - report[0] = 0x04; - let ok = unsafe { HidD_GetFeature(handle, report.as_mut_ptr().cast(), report.len() as u32) }; - if ok == 0 { - return None; - } - Some(( - format_bluetooth_address_little_endian(&report[1..7]), - format_bluetooth_address_little_endian(&report[10..16]), - )) -} - -#[cfg(windows)] -fn windows_claim_ps_move_host(host: &[u8; 6]) -> Result { - use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{ - DIGCF_DEVICEINTERFACE, DIGCF_PRESENT, SP_DEVICE_INTERFACE_DATA, - SetupDiDestroyDeviceInfoList, SetupDiEnumDeviceInterfaces, SetupDiGetClassDevsW, - }; - use windows_sys::Win32::Devices::HumanInterfaceDevice::{ - HIDD_ATTRIBUTES, HIDP_CAPS, HIDP_STATUS_SUCCESS, HidD_FreePreparsedData, - HidD_GetAttributes, HidD_GetHidGuid, HidD_GetPreparsedData, HidD_SetFeature, HidP_GetCaps, - }; - use windows_sys::Win32::Foundation::{CloseHandle, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::Storage::FileSystem::{ - CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, - }; - - let mut hid_guid = unsafe { std::mem::zeroed() }; - unsafe { HidD_GetHidGuid(&mut hid_guid) }; - let info_set = unsafe { - SetupDiGetClassDevsW( - &hid_guid, - std::ptr::null(), - std::ptr::null_mut(), - DIGCF_PRESENT | DIGCF_DEVICEINTERFACE, - ) - }; - if info_set == INVALID_HANDLE_VALUE as isize { - return Err(std::io::Error::last_os_error()).context("enumerating Windows HID devices"); - } - - let mut claims = 0usize; - let mut index = 0; - loop { - let mut interface_data = SP_DEVICE_INTERFACE_DATA { - cbSize: std::mem::size_of::() as u32, - InterfaceClassGuid: unsafe { std::mem::zeroed() }, - Flags: 0, - Reserved: 0, - }; - let ok = unsafe { - SetupDiEnumDeviceInterfaces( - info_set, - std::ptr::null_mut(), - &hid_guid, - index, - &mut interface_data, - ) - }; - if ok == 0 { - break; - } - index += 1; - - let Some(path) = (unsafe { windows_hid_interface_path(info_set, &mut interface_data) }) - else { - continue; - }; - if !path.to_ascii_lowercase().contains("&col02#") { - continue; - } - - let wide = wide_null(&path); - let handle = unsafe { - CreateFileW( - wide.as_ptr(), - 0, - FILE_SHARE_READ | FILE_SHARE_WRITE, - std::ptr::null_mut(), - OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, - std::ptr::null_mut(), - ) - }; - if handle == INVALID_HANDLE_VALUE { - eprintln!( - "path={} open=failed error={}", - path, - std::io::Error::last_os_error() - ); - continue; - } - - let mut attributes = HIDD_ATTRIBUTES { - Size: std::mem::size_of::() as u32, - VendorID: 0, - ProductID: 0, - VersionNumber: 0, - }; - let mut preparsed = 0; - let is_move = unsafe { - HidD_GetAttributes(handle, &mut attributes) != 0 - && attributes.VendorID == 0x054c - && attributes.ProductID == 0x03d5 - && HidD_GetPreparsedData(handle, &mut preparsed) != 0 - }; - if !is_move { - unsafe { CloseHandle(handle) }; - continue; - } - - let mut caps: HIDP_CAPS = unsafe { std::mem::zeroed() }; - let caps_ok = unsafe { HidP_GetCaps(preparsed, &mut caps) == HIDP_STATUS_SUCCESS }; - unsafe { HidD_FreePreparsedData(preparsed) }; - if !caps_ok || caps.FeatureReportByteLength < 23 { - unsafe { CloseHandle(handle) }; - continue; - } - - let before = windows_ps_move_bluetooth_addresses(handle); - let mut report = [0u8; 23]; - report[0] = 0x05; - report[1..7].copy_from_slice(host); - let ok = - unsafe { HidD_SetFeature(handle, report.as_mut_ptr().cast(), report.len() as u32) }; - if ok == 0 { - let error = std::io::Error::last_os_error(); - unsafe { CloseHandle(handle) }; - eprintln!("path={} set_feature=failed error={}", path, error); - continue; - } - let after = windows_ps_move_bluetooth_addresses(handle); - unsafe { CloseHandle(handle) }; - - let controller = after - .as_ref() - .or(before.as_ref()) - .map(|addresses| addresses.0.as_str()) - .unwrap_or("(unknown)"); - let host_before = before - .as_ref() - .map(|addresses| addresses.1.as_str()) - .unwrap_or("(unknown)"); - let host_after = after - .as_ref() - .map(|addresses| addresses.1.as_str()) - .unwrap_or("(unknown)"); - println!( - "path={} controller={} host_before={} host_after={}", - path, controller, host_before, host_after - ); - claims += 1; - } - - unsafe { SetupDiDestroyDeviceInfoList(info_set) }; - Ok(claims) -} - -#[cfg(windows)] -fn windows_ps_move_input_report(source: &str) -> Result>> { - use windows_sys::Win32::Devices::HumanInterfaceDevice::{ - HIDP_CAPS, HIDP_STATUS_SUCCESS, HidD_FreePreparsedData, HidD_GetInputReport, - HidD_GetPreparsedData, HidP_GetCaps, - }; - use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_READ, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::Storage::FileSystem::{ - CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_FLAG_OVERLAPPED, FILE_SHARE_READ, - FILE_SHARE_WRITE, OPEN_EXISTING, - }; - - let Some(path) = windows_ps_move_input_path(source)? else { - return Ok(None); - }; - let wide = wide_null(&path); - let handle = unsafe { - CreateFileW( - wide.as_ptr(), - GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_WRITE, - std::ptr::null_mut(), - OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL | FILE_FLAG_OVERLAPPED, - std::ptr::null_mut(), - ) - }; - if handle == INVALID_HANDLE_VALUE { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("opening Windows PS Move input HID path {path}")); - } - - let mut preparsed = 0; - if unsafe { HidD_GetPreparsedData(handle, &mut preparsed) } == 0 { - let error = std::io::Error::last_os_error(); - unsafe { CloseHandle(handle) }; - return Err(error).with_context(|| format!("reading HID preparsed data from {path}")); - } - - let mut caps: HIDP_CAPS = unsafe { std::mem::zeroed() }; - let caps_ok = unsafe { HidP_GetCaps(preparsed, &mut caps) == HIDP_STATUS_SUCCESS }; - unsafe { HidD_FreePreparsedData(preparsed) }; - if !caps_ok || caps.InputReportByteLength == 0 { - unsafe { CloseHandle(handle) }; - return Ok(None); - } - - let interrupt_report = - windows_read_hid_interrupt_report(handle, caps.InputReportByteLength as usize, &path)?; - if interrupt_report.is_some() { - unsafe { CloseHandle(handle) }; - return Ok(interrupt_report); - } - - let mut report = vec![0u8; caps.InputReportByteLength as usize]; - report[0] = 0x01; - let ok = - unsafe { HidD_GetInputReport(handle, report.as_mut_ptr().cast(), report.len() as u32) }; - unsafe { CloseHandle(handle) }; - if ok != 0 { - return Ok(Some(report)); - } - Ok(None) -} - -#[cfg(windows)] -fn windows_ps_move_input_path(source: &str) -> Result> { - if source.eq_ignore_ascii_case("windows-psmove") - || source.eq_ignore_ascii_case("windows-psmove-col01") - { - return Ok(windows_ps_move_light_paths()? - .into_iter() - .next() - .map(|target| target.path)); - } - let Some(path) = source - .to_ascii_lowercase() - .strip_prefix(WINDOWS_PS_MOVE_SOURCE_PREFIX) - .map(|_| &source[WINDOWS_PS_MOVE_SOURCE_PREFIX.len()..]) - else { - return Ok(None); - }; - if path.trim().is_empty() { - return Ok(None); - } - Ok(Some(path.to_string())) -} - -#[cfg(windows)] -fn start_windows_hid_report_reader_if_supported( - source: &str, -) -> Option> { - if !is_windows_ps_move_source(source) { - return None; - } - let path = match windows_ps_move_input_path(source) { - Ok(Some(path)) => path, - Ok(None) => return None, - Err(error) => { - eprintln!("Muninn could not resolve Windows HID input source {source}: {error:#}"); - return None; - } - }; - let (tx, rx) = mpsc::channel(); - thread::Builder::new() - .name("muninn-windows-hid-reader".to_string()) - .spawn(move || run_windows_hid_report_reader(path, tx)) - .ok()?; - Some(rx) -} - -#[cfg(windows)] -fn run_windows_hid_report_reader(path: String, tx: mpsc::Sender) { - loop { - match read_windows_hid_reports_until_error(&path, &tx) { - Ok(()) => return, - Err(error) => { - eprintln!("Muninn Windows HID reader for {path} restarting after: {error:#}"); - thread::sleep(Duration::from_millis(100)); - } - } - } -} - -#[cfg(windows)] -fn read_windows_hid_reports_until_error( - path: &str, - tx: &mpsc::Sender, -) -> Result<()> { - use windows_sys::Win32::Devices::HumanInterfaceDevice::{ - HIDP_CAPS, HIDP_STATUS_SUCCESS, HidD_FreePreparsedData, HidD_GetPreparsedData, HidP_GetCaps, - }; - use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_READ, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::Storage::FileSystem::{ - CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, - ReadFile, - }; - - let wide = wide_null(path); - let handle = unsafe { - CreateFileW( - wide.as_ptr(), - GENERIC_READ, - FILE_SHARE_READ | FILE_SHARE_WRITE, - std::ptr::null_mut(), - OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, - std::ptr::null_mut(), - ) - }; - if handle == INVALID_HANDLE_VALUE { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("opening persistent Windows HID input path {path}")); - } - - let mut preparsed = 0; - if unsafe { HidD_GetPreparsedData(handle, &mut preparsed) } == 0 { - let error = std::io::Error::last_os_error(); - unsafe { CloseHandle(handle) }; - return Err(error).with_context(|| format!("reading HID preparsed data from {path}")); - } - - let mut caps: HIDP_CAPS = unsafe { std::mem::zeroed() }; - let caps_ok = unsafe { HidP_GetCaps(preparsed, &mut caps) == HIDP_STATUS_SUCCESS }; - unsafe { HidD_FreePreparsedData(preparsed) }; - if !caps_ok || caps.InputReportByteLength == 0 { - unsafe { CloseHandle(handle) }; - return Err(anyhow!( - "Windows HID input path {path} has no input reports" - )); - } - - eprintln!( - "Muninn Windows HID reader attached to {path} report_len={}", - caps.InputReportByteLength - ); - loop { - let mut report = vec![0u8; caps.InputReportByteLength as usize]; - let mut bytes_read = 0; - let ok = unsafe { - ReadFile( - handle, - report.as_mut_ptr(), - report.len() as u32, - &mut bytes_read, - std::ptr::null_mut(), - ) - }; - if ok == 0 { - let error = std::io::Error::last_os_error(); - unsafe { CloseHandle(handle) }; - return Err(error) - .with_context(|| format!("reading persistent HID report from {path}")); - } - if bytes_read == 0 { - continue; - } - report.truncate(bytes_read as usize); - let latest = LatestHidReport { - bytes: report, - source_timestamp_ns: timestamp_ns().unwrap_or_default(), - observed_at: timestamp().unwrap_or_else(|_| "unix:0".to_string()), - }; - if tx.send(latest).is_err() { - unsafe { CloseHandle(handle) }; - return Ok(()); - } - } -} - -#[cfg(windows)] -fn windows_read_hid_interrupt_report( - handle: *mut std::ffi::c_void, - report_len: usize, - path: &str, -) -> Result>> { - use windows_sys::Win32::Foundation::{ - CloseHandle, ERROR_IO_PENDING, ERROR_OPERATION_ABORTED, ERROR_SEM_TIMEOUT, ERROR_TIMEOUT, - WAIT_OBJECT_0, WAIT_TIMEOUT, - }; - use windows_sys::Win32::Storage::FileSystem::ReadFile; - use windows_sys::Win32::System::IO::{CancelIoEx, GetOverlappedResult, OVERLAPPED}; - use windows_sys::Win32::System::Threading::{CreateEventW, WaitForSingleObject}; - - let event = unsafe { CreateEventW(std::ptr::null(), 1, 0, std::ptr::null()) }; - if event.is_null() { - return Err(std::io::Error::last_os_error()).context("creating Windows HID read event"); - } - - let mut report = vec![0u8; report_len]; - let mut overlapped: OVERLAPPED = unsafe { std::mem::zeroed() }; - overlapped.hEvent = event; - let mut bytes_read = 0; - let ok = unsafe { - ReadFile( - handle, - report.as_mut_ptr(), - report.len() as u32, - &mut bytes_read, - &mut overlapped, - ) - }; - if ok != 0 { - unsafe { CloseHandle(event) }; - report.truncate(bytes_read as usize); - return Ok(Some(report)); - } - - let error = std::io::Error::last_os_error(); - if error.raw_os_error() != Some(ERROR_IO_PENDING as i32) { - unsafe { CloseHandle(event) }; - if matches!( - error.raw_os_error(), - Some(code) - if code == ERROR_TIMEOUT as i32 - || code == ERROR_SEM_TIMEOUT as i32 - || code == ERROR_OPERATION_ABORTED as i32 - ) { - return Ok(None); - } - return Err(error) - .with_context(|| format!("starting Windows PS Move input read from {path}")); - } - - let wait = unsafe { WaitForSingleObject(event, 2) }; - if wait == WAIT_TIMEOUT { - unsafe { - CancelIoEx(handle, &mut overlapped); - CloseHandle(event); - } - return Ok(None); - } - if wait != WAIT_OBJECT_0 { - unsafe { - CancelIoEx(handle, &mut overlapped); - CloseHandle(event); - } - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("waiting for Windows PS Move input read from {path}")); - } - - let mut transferred = 0; - let done = unsafe { GetOverlappedResult(handle, &mut overlapped, &mut transferred, 0) }; - unsafe { CloseHandle(event) }; - if done == 0 { - let error = std::io::Error::last_os_error(); - if matches!( - error.raw_os_error(), - Some(code) - if code == ERROR_OPERATION_ABORTED as i32 - || code == ERROR_TIMEOUT as i32 - || code == ERROR_SEM_TIMEOUT as i32 - ) { - return Ok(None); - } - return Err(error) - .with_context(|| format!("completing Windows PS Move input read from {path}")); - } - report.truncate(transferred as usize); - Ok(Some(report)) -} - -#[cfg(windows)] -unsafe fn windows_hid_interface_path( - info_set: isize, - interface_data: &mut windows_sys::Win32::Devices::DeviceAndDriverInstallation::SP_DEVICE_INTERFACE_DATA, -) -> Option { - use windows_sys::Win32::Devices::DeviceAndDriverInstallation::{ - SP_DEVICE_INTERFACE_DETAIL_DATA_W, SetupDiGetDeviceInterfaceDetailW, - }; - - let mut required_size = 0; - unsafe { - SetupDiGetDeviceInterfaceDetailW( - info_set, - interface_data, - std::ptr::null_mut(), - 0, - &mut required_size, - std::ptr::null_mut(), - ); - } - if required_size == 0 { - return None; - } - - let mut buffer = vec![0u8; required_size as usize]; - let detail = buffer.as_mut_ptr() as *mut SP_DEVICE_INTERFACE_DETAIL_DATA_W; - unsafe { - (*detail).cbSize = if cfg!(target_pointer_width = "64") { - 8 - } else { - 6 - }; - } - let ok = unsafe { - SetupDiGetDeviceInterfaceDetailW( - info_set, - interface_data, - detail, - required_size, - std::ptr::null_mut(), - std::ptr::null_mut(), - ) - }; - if ok == 0 { - return None; - } - - let path_ptr = unsafe { (*detail).DevicePath.as_ptr() }; - let mut len = 0; - unsafe { - while *path_ptr.add(len) != 0 { - len += 1; - } - Some(String::from_utf16_lossy(std::slice::from_raw_parts( - path_ptr, len, - ))) - } -} - -#[cfg(windows)] -fn write_windows_hid_report(path: &str, report: &[u8]) -> Result<()> { - use windows_sys::Win32::Foundation::{CloseHandle, GENERIC_WRITE, INVALID_HANDLE_VALUE}; - use windows_sys::Win32::Storage::FileSystem::{ - CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING, - WriteFile, - }; - - let wide = wide_null(path); - let handle = unsafe { - CreateFileW( - wide.as_ptr(), - GENERIC_WRITE, - FILE_SHARE_READ | FILE_SHARE_WRITE, - std::ptr::null_mut(), - OPEN_EXISTING, - FILE_ATTRIBUTE_NORMAL, - std::ptr::null_mut(), - ) - }; - if handle == INVALID_HANDLE_VALUE { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("opening Windows PS Move HID path {path}")); - } - - let mut written = 0; - let ok = unsafe { - WriteFile( - handle, - report.as_ptr().cast(), - report.len() as u32, - &mut written, - std::ptr::null_mut(), - ) - }; - unsafe { CloseHandle(handle) }; - if ok == 0 { - return Err(std::io::Error::last_os_error()) - .with_context(|| format!("writing Windows PS Move HID report to {path}")); - } - Ok(()) -} - -#[cfg(windows)] -fn wide_null(value: &str) -> Vec { - OsStr::new(value).encode_wide().chain(Some(0)).collect() -} - -#[cfg(test)] -fn execute_move_light_command( - command: MuninnMoveLightCommandRecord, - writer: &mut impl MoveLightWriter, -) -> Result { - let active = match prepare_move_light_command(command.clone()) { - Ok(active) => active, - Err(error) => return command_failed(command, &error.to_string()), - }; - - for _ in 0..active.command.repeat_count { - for (index, (red, green, blue)) in active.colors.iter().copied().enumerate() { - let report = move_light_report(red, green, blue); - if let Err(error) = writer.write_report(&active.command.hidraw_path, &report) { - return command_failed(active.command, &format!("{error:#}")); - } - let duration_ms = active.command.durations_ms[index]; - if duration_ms > 0 { - thread::sleep(Duration::from_millis(u64::from(duration_ms))); - } - } - } - - Ok(MuninnMoveLightCommandRecord { - state: "completed".to_string(), - detail: format!( - "wrote {} PS Move light step(s) for {} repeat(s)", - active.colors.len(), - active.command.repeat_count - ), - updated_at: timestamp()?, - ..active.command - }) -} - -fn command_failed( - command: MuninnMoveLightCommandRecord, - detail: &str, -) -> Result { - Ok(MuninnMoveLightCommandRecord { - state: "failed".to_string(), - detail: detail.to_string(), - updated_at: timestamp()?, - ..command - }) -} - -fn parse_move_colors(colors: &[String]) -> Result> { - if colors.is_empty() { - return Err(anyhow!("colors must contain at least one #rrggbb value")); - } - colors.iter().map(|color| parse_move_color(color)).collect() -} - -fn parse_move_color(color: &str) -> Result<(u8, u8, u8)> { - let trimmed = color.trim(); - let text = trimmed.strip_prefix('#').unwrap_or(trimmed); - if text.len() != 6 || !text.chars().all(|ch| ch.is_ascii_hexdigit()) { - return Err(anyhow!("invalid color {color:?}; expected #rrggbb")); - } - let red = u8::from_str_radix(&text[0..2], 16)?; - let green = u8::from_str_radix(&text[2..4], 16)?; - let blue = u8::from_str_radix(&text[4..6], 16)?; - Ok((red, green, blue)) -} - -fn publish_quest_access_if_requested( - node: &mut cultmesh_rs::CultMeshNode, - options: &Options, -) -> Result<()> { - if !options.quest_adb { - return Ok(()); - } - - let record = probe_quest_access(options)?; - node.put("quest-access", &record)?; - node.put(&record.access_id, &record)?; - Ok(()) -} - -fn probe_quest_access(options: &Options) -> Result { - let output = match Command::new("adb").args(["devices", "-l"]).output() { - Ok(output) => output, - Err(error) => { - return build_quest_access_record( - options, - ParsedQuestDevice::default(), - "unavailable", - &format!("adb devices -l could not run: {error}"), - ); - } - }; - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - if !output.status.success() { - return build_quest_access_record( - options, - ParsedQuestDevice::default(), - "unavailable", - &format!("adb devices -l failed: {}", stderr.trim()), - ); - } - - let parsed = parse_quest_device_from_adb(&stdout, options.quest_serial.as_deref()); - let parsed_device = parsed.unwrap_or_default(); - let (state, detail): (String, String) = match &parsed_device { - device if device.connection_state == "device" => ( - "usb-authorized".to_string(), - "Quest is authorized over USB. Muninn can publish access and route Quest input/video surfaces; tracked poses still require a Quest/OpenXR witness.".to_string(), - ), - device if device.serial.is_empty() => ( - "unavailable".to_string(), - "No matching Quest device was found in adb devices -l.".to_string(), - ), - device => ( - device.connection_state.clone(), - "Quest is visible over USB but not authorized for local access.".to_string(), - ), - }; - build_quest_access_record(options, parsed_device, &state, &detail) -} - -fn build_quest_access_record( - options: &Options, - device: ParsedQuestDevice, - state: &str, - detail: &str, -) -> Result { - let serial = if device.serial.is_empty() { - options - .quest_serial - .clone() - .unwrap_or_else(|| "unknown".to_string()) - } else { - device.serial - }; - Ok(MuninnQuestAccessRecord { - access_id: format!("muninn:{}:quest-access:{}", options.host_id, serial), - host_id: options.host_id.clone(), - serial, - connection_state: device.connection_state, - product: device.product, - model: device.model, - device: device.device, - transport_id: device.transport_id, - input_stream_id: options - .quest_input_stream_id - .clone() - .unwrap_or_else(|| format!("muninn:{}:quest-input", options.host_id)), - pose_stream_id: options - .quest_pose_stream_id - .clone() - .unwrap_or_else(|| format!("muninn:{}:quest-poses", options.host_id)), - video_input_stream_id: options - .quest_video_input_stream_id - .clone() - .unwrap_or_else(|| format!("muninn:{}:quest-warped-video-input", options.host_id)), - video_input_transport: "brokkr-unity-editor-warped-frame-stream".to_string(), - state: state.to_string(), - detail: detail.to_string(), - observed_at: timestamp()?, - }) -} - -#[derive(Clone, Debug, Default, PartialEq, Eq)] -struct ParsedQuestDevice { - serial: String, - connection_state: String, - product: String, - model: String, - device: String, - transport_id: String, -} - -fn parse_quest_device_from_adb( - adb_devices_output: &str, - requested_serial: Option<&str>, -) -> Option { - adb_devices_output - .lines() - .filter_map(parse_adb_device_line) - .find(|device| { - if let Some(serial) = requested_serial { - return device.serial == serial; - } - device.model.contains("Quest") - || device.product == "hollywood" - || device.device == "hollywood" - }) -} - -fn parse_adb_device_line(line: &str) -> Option { - let tokens: Vec<&str> = line.split_whitespace().collect(); - if tokens.len() < 2 || tokens[0] == "List" { - return None; - } - - let mut device = ParsedQuestDevice { - serial: tokens[0].to_string(), - connection_state: tokens[1].to_string(), - ..ParsedQuestDevice::default() - }; - for token in tokens.iter().skip(2) { - let Some((key, value)) = token.split_once(':') else { - continue; - }; - match key { - "product" => device.product = value.to_string(), - "model" => device.model = value.to_string(), - "device" => device.device = value.to_string(), - "transport_id" => device.transport_id = value.to_string(), - _ => {} - } - } - Some(device) -} - -fn quest_access_status(options: Options) -> Result<()> { - let node = open_node(&options, "muninn-quest-access-status")?; - let record = node - .get_required::("quest-access") - .context("Muninn Quest access record is unavailable")?; - println!( - "{} state={} model={} product={} input={} poses={} video={} detail={}", - record.serial, - record.state, - record.model, - record.product, - record.input_stream_id, - record.pose_stream_id, - record.video_input_stream_id, - record.detail - ); - Ok(()) -} - -fn health_check(options: &Options) -> Result<()> { - let observed_at = idunn_timestamp()?; - let result = evaluate_health(options); - let (state, detail) = match &result { - Ok(detail) => ("active", detail.clone()), - Err(error) => ("failed", error.to_string()), - }; - if let Some(idunn) = options.idunn_rudp_health.as_ref() { - let publish_result = publish_idunn_rudp_health(idunn, state, &detail, &observed_at); - if let Err(publish_error) = publish_result { - return match result { - Ok(_) => Err(publish_error).context("publishing Muninn health to Idunn RUDP"), - Err(health_error) => Err(anyhow!( - "{health_error}; also failed to publish Muninn health to Idunn RUDP: {publish_error}" - )), - }; - } - } - - match result { - Ok(detail) => { - println!("{detail}"); - Ok(()) - } - Err(error) => Err(error), - } -} - -fn evaluate_health(options: &Options) -> Result { - let node = open_node(options, "muninn-health")?; - evaluate_health_from_node(options, &node) -} - -fn evaluate_health_from_node( - options: &Options, - node: &cultmesh_rs::CultMeshNode, -) -> Result { - let surface = node - .get_required::("latest") - .context("Muninn telemetry surface is unavailable")?; - if surface.state != "idle" && surface.state != "active" { - return Err(anyhow!( - "Muninn telemetry surface is {}: {}", - surface.state, - surface.detail - )); - } - - verify_move_sources_fresh(options, &node)?; - - Ok(format!( - "Muninn healthy: {} on {} ({})", - surface.surface_id, surface.host_id, surface.state - )) -} - -fn publish_idunn_rudp_health( - options: &IdunnRudpHealthOptions, - state: &str, - detail: &str, - observed_at: &str, -) -> Result<()> { - let mut transport = connect_idunn_rudp_health(options)?; - transport - .send( - "schema", - idunn_health_payload(options, state, detail, observed_at)?, - ) - .with_context(|| format!("sending Idunn health to {}", options.endpoint))?; - Ok(()) -} - -fn smootherstep(value: f64) -> f64 { - let t = value.clamp(0.0, 1.0); - t * t * t * (t * (t * 6.0 - 15.0) + 10.0) -} - -fn wrapped_hue_lerp(source: f64, target: f64, amount: f64) -> f64 { - let delta = (target - source + 0.5).rem_euclid(1.0) - 0.5; - (source + delta * amount).rem_euclid(1.0) -} - -fn run_daemon_health_publisher(options: &Options) -> Result<()> { - let idunn = options - .idunn_rudp_health - .as_ref() - .context("Muninn daemon health publisher requires Idunn RUDP options")?; - let mut transport = connect_idunn_rudp_health(idunn)?; - let cadence = Duration::from_secs(options.interval_seconds.unwrap_or(15).max(1)); - let mut next_publish_at = Instant::now(); - loop { - if Instant::now() >= next_publish_at { - let observed_at = idunn_timestamp()?; - let (state, detail) = match evaluate_health(options) { - Ok(detail) => ("active", detail), - Err(error) => ("failed", error.to_string()), - }; - transport - .send( - "schema", - idunn_health_payload(idunn, state, &detail, &observed_at)?, - ) - .with_context(|| format!("sending Idunn health to {}", idunn.endpoint))?; - next_publish_at = Instant::now() + cadence; - } - let _ = transport.receive_once()?; - transport.poll_resends()?; - } -} - -fn idunn_health_payload( - 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!( - "muninn-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("muninn-daemon".to_string()), - source_agent_id: None, - source_role: Some("daemon-health-publisher".to_string()), - tags: Some(vec![CULTNET_RUDP_PROTOCOL_ID.to_string()]), - }, - }; - encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0) - .context("encoding Idunn health CultNet message") -} - -fn connect_idunn_rudp_health( - options: &IdunnRudpHealthOptions, -) -> Result { - 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 Muninn RUDP sender at {bind_address}"))?; - socket.set_read_timeout(Some(Duration::from_secs(1)))?; - let mut transport = - CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions::client( - "muninn-daemon", - socket, - options.endpoint, - IDUNN_HEALTH_RUDP_CONNECTION_ID, - ))?; - transport.connect(Vec::new())?; - let deadline = Instant::now() + Duration::from_secs(2); - while !transport.connected() { - let _ = transport.receive_once()?; - transport.poll_resends()?; - if Instant::now() >= deadline { - return Err(anyhow!( - "timed out connecting Muninn RUDP sender to {}", - options.endpoint - )); - } - } - Ok(transport) -} - -fn verify_move_sources_fresh(options: &Options, node: &cultmesh_rs::CultMeshNode) -> Result<()> { - let move_state_sources = - serve_move_state_sources(options, serve_should_manage_move_runtime(options)); - if move_state_sources.is_empty() { - return Ok(()); - } - - let now = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("system clock is before Unix epoch")? - .as_secs(); - let max_age_seconds = options - .interval_seconds - .unwrap_or(15) - .saturating_mul(4) - .max(30); - let states = node.cache().get_all::()?; - - for source in &move_state_sources { - let latest = states - .iter() - .filter(|state| { - state.host_id == options.host_id - && state.move_id == source.move_id - && state.source_path == source.hidraw_path - }) - .max_by_key(|state| unix_timestamp_sort_key(&state.observed_at)) - .ok_or_else(|| { - anyhow!( - "Muninn Move source {} at {} has no controller-state records", - source.move_id, - source.hidraw_path - ) - })?; - let observed_seconds = parse_unix_timestamp(&latest.observed_at).with_context(|| { - format!( - "Muninn Move source {} has invalid observed_at", - source.move_id - ) - })?; - let age = now.saturating_sub(observed_seconds); - if age > max_age_seconds { - return Err(anyhow!( - "Muninn Move source {} is stale: observed {} seconds ago, max {}", - source.move_id, - age, - max_age_seconds - )); - } - } - - Ok(()) -} - -fn parse_unix_timestamp(value: &str) -> Result { - value - .strip_prefix("unix-") - .ok_or_else(|| anyhow!("timestamp must start with unix-"))? - .parse() - .context("timestamp seconds must be an integer") -} - -fn unix_timestamp_sort_key(value: &str) -> u64 { - parse_unix_timestamp(value).unwrap_or(0) -} - -fn request_move_light(options: Options) -> Result<()> { - ensure_state_dirs(&options)?; - let mut node = open_node(&options, "muninn-move-light-request")?; - let command = build_move_light_command(&options)?; - node.put(&command.command_id, &command)?; - println!( - "Published Muninn Move light command {} for {} on {}.", - command.command_id, command.move_id, command.host_id - ); - Ok(()) -} - -fn request_capture_stream(options: Options) -> Result<()> { - require_media_target_uri(&options)?; - publish_obs_media_receiver_to_odin(&options)?; - let command = build_capture_stream_command(&options)?; - publish_capture_command_to_odin(&options, &command)?; - println!( - "Published Muninn capture stream command {} {} {} for {} through Odin/CultMesh.", - command.command_id, command.action, command.stream_id, command.host_id - ); - Ok(()) -} - -fn publish_obs_media_receiver_to_odin(options: &Options) -> Result<()> { - let Some(address_host) = options.obs_target_host.as_deref() else { - return Ok(()); - }; - if address_host.trim().is_empty() || options.obs_port == 0 { - return Ok(()); - } - let Some(target) = resolve_odin_cultmesh_uri(options) else { - return Err(anyhow!( - "muninn request-stream requires --odin-cultmesh-uri before OBS can advertise its media receiver" - )); - }; - let address = format!("{address_host}:{}", options.obs_port); - let provider_id = format!("mimir.obs.media-receiver.{}", options.host_id); - let advertisement = EveProviderAdvertisementRecord { - value: json!({ - "schema": "gamecult.eve.provider_advertisement.v1", - "providerId": provider_id, - "title": "Mimir OBS Muninn media receiver", - "description": "OBS-owned Muninn RUDP media receiver endpoint advertised for Odin-routed capture activation.", - "canonicalService": "mimir.obs.media-receiver", - "locatedService": options.target_host, - "cultMeshAddress": options.target_host, - "status": "active", - "updatedAt": timestamp()?, - "inputStreams": [{ - "streamId": format!("{}#{}", options.target_host, options.stream_id), - "schema": MUNINN_MEDIA_RUDP_SCHEMA, - "transport": CULTNET_RUDP_PROTOCOL_ID, - "address": address, - "connectionId": MUNINN_MEDIA_RUDP_CONNECTION_ID, - "channel": "media", - "producer": "Mimir OBS Muninn source" - }], - "routes": [{ - "schema": MUNINN_MEDIA_RUDP_SCHEMA, - "transport": CULTNET_RUDP_PROTOCOL_ID, - "address": address - }], - }), - }; - let node = open_node(options, "muninn-obs-media-receiver-publisher")?; - node.publish_document_to_rudp_catalog( - &provider_id, - &advertisement, - CultMeshRudpDocumentPublishOptions { - target, - runtime_id: "muninn-request-stream".to_string(), - source_role: Some("obs-media-receiver-provider".to_string()), - tags: vec![ - "odin-media-receiver-route".to_string(), - "muninn.media-rudp".to_string(), - ], - ..CultMeshRudpDocumentPublishOptions::default() - }, - ) -} - -fn publish_capture_command_to_odin( - options: &Options, - command: &MuninnCaptureStreamCommandRecord, -) -> Result<()> { - let Some(target) = resolve_odin_cultmesh_uri(options) else { - return Err(anyhow!( - "muninn request-stream requires --odin-cultmesh-uri; direct activation-store writes and raw RUDP command targets have been removed" - )); - }; - let node = open_node(options, "muninn-capture-command-odin-publisher")?; - node.publish_document_to_rudp_catalog( - &latest_capture_stream_command_key(&command.host_id, &command.stream_id), - command, - CultMeshRudpDocumentPublishOptions { - target, - runtime_id: "muninn-request-stream".to_string(), - source_role: Some("capture-command-publisher".to_string()), - tags: vec![ - "odin-cultmesh-command-route".to_string(), - "muninn.capture-stream".to_string(), - ], - ..CultMeshRudpDocumentPublishOptions::default() - }, - ) -} - -fn move_light_status(options: Options) -> Result<()> { - let node = open_node(&options, "muninn-move-light-status")?; - let mut commands = node.cache().get_all::()?; - commands.retain(|command| command.host_id == options.host_id); - if let Some(command_id) = options.command_id.as_deref() { - commands.retain(|command| command.command_id == command_id); - } - commands.sort_by(|left, right| left.command_id.cmp(&right.command_id)); - - if commands.is_empty() { - println!( - "No Muninn Move light commands found for {}.", - options.host_id - ); - return Ok(()); - } - - for command in commands { - println!( - "{} {} {} {} {}", - command.command_id, command.host_id, command.move_id, command.state, command.detail - ); - } - Ok(()) -} - -fn set_move_hue_program(options: Options) -> Result<()> { - let key = move_hue_program_key(&options.host_id); - let mut node = open_node(&options, "muninn-set-move-hue-program")?; - let mut program = node - .get::(&key)? - .unwrap_or_else(|| bootstrap_move_hue_program(&options)); - if let Some(mode) = options.move_hue_mode.as_deref() { - program.mode = mode.to_string(); - if mode == "hold" { - program.hold_at_ns = timestamp_ns()?; - } - } - if let Some(order_mode) = options.move_hue_order_mode.as_deref() { - program.order_mode = order_mode.to_string(); - } - if options.move_hue_cycle_ms_explicit { - program.cycle_ms = options.move_hue_cycle_ms; - } - program.requested_by = "muninn-cli-or-eve-lowering".to_string(); - program.updated_at = timestamp()?; - validate_move_hue_program(&program)?; - node.put(&key, &program)?; - if let Some(target) = resolve_odin_cultmesh_uri(&options) { - node.publish_document_to_rudp_catalog( - &key, - &program, - CultMeshRudpDocumentPublishOptions { - target, - runtime_id: format!("muninn-{}-move-hue-command", options.host_id), - source_role: Some("muninn.move-hue-program-command".to_string()), - tags: vec!["muninn".to_string(), "move-hue-program".to_string()], - ..CultMeshRudpDocumentPublishOptions::default() - }, - )?; - } - println!( - "{} mode={} order={} cycle_ms={} hold_at_ns={} updated={}", - program.program_id, - program.mode, - program.order_mode, - program.cycle_ms, - program.hold_at_ns, - program.updated_at - ); - Ok(()) -} - -fn move_hue_program_status(options: Options) -> Result<()> { - let key = move_hue_program_key(&options.host_id); - let node = open_node(&options, "muninn-move-hue-program-status")?; - let program = node - .get_required::(&key) - .context("Muninn Move hue program is unavailable")?; - println!( - "{} mode={} order={} cycle_ms={} epoch_ns={} hold_at_ns={} requested_by={} updated={}", - program.program_id, - program.mode, - program.order_mode, - program.cycle_ms, - program.epoch_ns, - program.hold_at_ns, - program.requested_by, - program.updated_at - ); - Ok(()) -} - -fn capture_stream_status(options: Options) -> Result<()> { - let mut status_options = options.clone(); - if let Some(activation_store_path) = status_options.activation_store_path.as_ref() { - status_options.store_path = activation_store_path.clone(); - } - let node = open_node(&status_options, "muninn-capture-stream-status")?; - let mut commands = node.cache().get_all::()?; - commands.retain(|command| command.host_id == status_options.host_id); - if let Some(command_id) = status_options.command_id.as_deref() { - commands.retain(|command| command.command_id == command_id); - } - if status_options.stream_filter_explicit && !status_options.stream_id.trim().is_empty() { - commands.retain(|command| command.stream_id == status_options.stream_id); - } - commands.sort_by(|left, right| { - left.stream_id - .cmp(&right.stream_id) - .then(left.command_id.cmp(&right.command_id)) - }); - - if commands.is_empty() { - println!( - "No Muninn capture stream commands found for host {}{}{}.", - status_options.host_id, - if !status_options.stream_filter_explicit || status_options.stream_id.trim().is_empty() - { - "" - } else { - " stream " - }, - if !status_options.stream_filter_explicit || status_options.stream_id.trim().is_empty() - { - "" - } else { - status_options.stream_id.as_str() - } - ); - return Ok(()); - } - - for command in commands { - let target_display = if command.action == "start" { - format!("{}:{}", command.target_host, command.port) - } else { - "n/a".to_string() - }; - let video_source_display = if command.action == "start" { - command_video_source_id(&command).to_string() - } else { - "n/a".to_string() - }; - let audio_source_display = if command.action == "start" { - command_audio_source_id(&command).to_string() - } else { - "n/a".to_string() - }; - println!( - "{} host={} stream={} action={} state={} target={} video_source={} audio_source={} detail={} updated={}", - command.command_id, - command.host_id, - command.stream_id, - command.action, - command.state, - target_display, - video_source_display, - audio_source_display, - command.detail, - command.updated_at - ); - } - Ok(()) -} - -fn move_state_status(options: Options) -> Result<()> { - let node = open_node(&options, "muninn-move-state-status")?; - let mut states = node.cache().get_all::()?; - states.retain(|state| state.host_id == options.host_id); - if let Some(move_filter) = options.move_filter.as_deref() { - states.retain(|state| state.move_id == move_filter); - } - for source in live_move_state_sources(&options) { - if options - .move_filter - .as_deref() - .is_some_and(|move_filter| source.move_id != move_filter) - { - continue; - } - let key = format!( - "{}:{}:move-controller-state", - options.host_id, source.move_id - ); - if let Some(state) = node.get::(&key)? { - states.push(state); - } - } - states.sort_by(|a, b| { - a.stream_id - .cmp(&b.stream_id) - .then( - unix_timestamp_sort_key(&a.observed_at) - .cmp(&unix_timestamp_sort_key(&b.observed_at)), - ) - .then(a.sequence.cmp(&b.sequence)) - }); - states.dedup_by(|left, right| { - left.stream_id == right.stream_id && left.sequence == right.sequence - }); - if states.is_empty() { - println!( - "No Muninn Move controller state records found for host {}.", - options.host_id - ); - return Ok(()); - } - for state in states { - println!( - "{} seq={} move={} source={} buttons=[{}] trigger={:.3} accel={:?} gyro={:?} battery={:.3} observed={}", - state.stream_id, - state.sequence, - state.move_id, - state.source_path, - state.buttons.join(","), - state.trigger_value, - state.accelerometer_xyz, - state.gyroscope_xyz, - state.battery01, - state.observed_at - ); - } - Ok(()) -} - -fn move_source_status(options: Options) -> Result<()> { - let sources = live_move_state_sources(&options); - if sources.is_empty() { - println!( - "No live Muninn Move state sources found for host {}.", - options.host_id - ); - return Ok(()); - } - let mut reader = HidMoveControllerStateReader; - for source in sources { - let report_status = if is_joystick_path(&source.hidraw_path) { - match reader.read_joystick_events(&source.hidraw_path) { - Ok(events) => format!("joystick_events={}", events.len()), - Err(error) => format!("read_error={error:#}"), - } - } else { - match reader.read_report(&source.hidraw_path) { - Ok(Some(report)) => format!("report_bytes={}", report.len()), - Ok(None) => "report_bytes=none".to_string(), - Err(error) => format!("read_error={error:#}"), - } - }; - println!( - "{} move={} source={} {}", - options.host_id, source.move_id, source.hidraw_path, report_status - ); - } - Ok(()) -} - -fn move_identity_status(options: Options) -> Result<()> { - let mut identities = current_move_identity_records(&options)?; - if let Some(move_filter) = options.move_filter.as_deref() { - identities.retain(|identity| identity.move_id == move_filter); - } - identities.sort_by(|a, b| { - a.identity_id.cmp(&b.identity_id).then( - unix_timestamp_sort_key(&b.observed_at).cmp(&unix_timestamp_sort_key(&a.observed_at)), - ) - }); - identities.dedup_by(|left, right| left.identity_id == right.identity_id); - if identities.is_empty() { - println!( - "No Muninn Move identity records found for host {}.", - options.host_id - ); - return Ok(()); - } - for identity in identities { - println!( - "{} move={} source={} bluetooth_host={} state={} observed={} detail={}", - identity.identity_id, - identity.move_id, - identity.source_path, - identity.bluetooth_host_address, - identity.state, - identity.observed_at, - identity.detail - ); - } - Ok(()) -} - -fn current_move_identity_records(options: &Options) -> Result> { - let sources = live_move_state_sources(options); - current_move_identity_records_from_sources(options, &sources) -} - -fn current_move_identity_records_from_sources( - options: &Options, - sources: &[MoveStateSource], -) -> Result> { - let observed_at = timestamp()?; - let bluetooth_host_address = move_host_address_for_claim(options).unwrap_or_default(); - let mut identities = sources - .iter() - .map(|source| MuninnMoveIdentityRecord { - identity_id: format!("{}:{}:move-identity", options.host_id, source.move_id), - host_id: options.host_id.clone(), - move_id: source.move_id.clone(), - source_path: source.hidraw_path.clone(), - bluetooth_host_address: bluetooth_host_address.clone(), - state: "usb-visible".to_string(), - detail: "Muninn currently sees this PS Move on a local USB/HID input path.".to_string(), - observed_at: observed_at.clone(), - }) - .collect::>(); - identities.extend(current_bluetooth_move_identity_records( - options, - &observed_at, - )); - identities.sort_by(|a, b| { - a.identity_id.cmp(&b.identity_id).then( - unix_timestamp_sort_key(&b.observed_at).cmp(&unix_timestamp_sort_key(&a.observed_at)), - ) - }); - identities.dedup_by(|left, right| left.identity_id == right.identity_id); - Ok(identities) -} - -#[cfg(unix)] -fn current_bluetooth_move_identity_records( - options: &Options, - observed_at: &str, -) -> Vec { - let bluetooth_host_address = platform_default_bluetooth_host_address().unwrap_or_default(); - bluetoothctl_motion_controller_devices() - .unwrap_or_default() - .into_iter() - .filter_map(|device| { - build_bluetooth_move_identity_record( - options, - &device, - bluetooth_host_address.clone(), - observed_at.to_string(), - ) - }) - .collect() -} - -#[cfg(not(unix))] -fn current_bluetooth_move_identity_records( - _options: &Options, - _observed_at: &str, -) -> Vec { - Vec::new() -} - -fn claim_move_host(options: Options) -> Result<()> { - let host = options - .move_host_address - .as_deref() - .ok_or_else(|| anyhow!("--move-host is required for claim-move-host"))?; - claim_ps_move_host(host) -} - -#[cfg(unix)] -fn claim_ps_move_host(host_address: &str) -> Result<()> { - let host = parse_bluetooth_address_little_endian(host_address)?; - let claims = unix_claim_ps_move_host(&host)?; - if claims == 0 { - println!("no USB PS Move pairing collections found"); - } - Ok(()) -} - -#[cfg(all(not(unix), not(windows)))] -fn claim_ps_move_host(_host_address: &str) -> Result<()> { - Err(anyhow!( - "claim-move-host is implemented in Muninn on Unix and Windows" - )) -} - -#[cfg(windows)] -fn claim_ps_move_host(host_address: &str) -> Result<()> { - let host = parse_bluetooth_address_little_endian(host_address)?; - let claims = windows_claim_ps_move_host(&host)?; - if claims == 0 { - println!("no USB PS Move pairing collections found"); - } - Ok(()) -} - -fn build_move_light_command(options: &Options) -> Result { - parse_move_colors(&options.move_colors)?; - if options.hidraw_path.trim().is_empty() { - return Err(anyhow!("--hidraw is required for request-move-light")); - } - if options.move_repeat_count == 0 { - return Err(anyhow!("--repeat-count must be greater than zero")); - } - if options.move_durations_ms.len() != options.move_colors.len() { - return Err(anyhow!( - "--duration-ms must be provided once for each --color" - )); - } - - let now = timestamp()?; - Ok(MuninnMoveLightCommandRecord { - command_id: format!("{}:{}:move-light:{}", options.host_id, options.move_id, now), - host_id: options.host_id.clone(), - move_id: options.move_id.clone(), - hidraw_path: options.hidraw_path.clone(), - colors: options.move_colors.clone(), - durations_ms: options.move_durations_ms.clone(), - repeat_count: options.move_repeat_count, - authority: "muninn.operator-request".to_string(), - state: "pending".to_string(), - detail: "operator requested a typed Move light command".to_string(), - updated_at: now, - }) -} - -fn build_capture_stream_command(options: &Options) -> Result { - require_media_target_uri(options)?; - if options.stream_action != "start" && options.stream_action != "stop" { - return Err(anyhow!("--stream-action must be start or stop")); - } - let now = timestamp()?; - let command_id = options.command_id.clone().unwrap_or_else(|| { - format!( - "{}:{}:capture-stream:{}:{}", - options.host_id, options.stream_id, options.stream_action, now - ) - }); - Ok(MuninnCaptureStreamCommandRecord { - command_id, - host_id: options.host_id.clone(), - stream_id: options.stream_id.clone(), - state: "pending".to_string(), - action: options.stream_action.clone(), - target_host: options.target_host.clone(), - port: options.port, - obs_target_host: options.obs_target_host.clone(), - obs_port: options.obs_port, - media_transport: media_transport_cli(&options.media_transport).to_string(), - media_packet_bytes: options.media_packet_bytes as u32, - requested_by: "muninn.request-stream".to_string(), - detail: "operator requested a daemon-owned capture stream lifecycle change".to_string(), - updated_at: now, - rudp_video_bitrate_kbps: options.rudp_video_bitrate_kbps, - rudp_latency_budget_ms: options.rudp_latency_budget_ms, - video_source_id: video_source_id_for_options(options), - audio_source_id: audio_source_id_for_options(options), - }) -} - -fn build_mux_plan(options: &Options, timestamp: String) -> MuxPlan { - let command_file = options.log_root.join(format!("muninn-{timestamp}.ps1")); - let loopback_stderr = options - .log_root - .join(format!("muninn-{timestamp}.loopback.err.log")); - let ffmpeg_stderr = options - .log_root - .join(format!("muninn-{timestamp}.ffmpeg.err.log")); - let targets = build_targets(options); - let loopback_args = loopback_args(options); - let ffmpeg_args = ffmpeg_args(options); - let loopback_args_literal = powershell_array_literal(&loopback_args); - let ffmpeg_args_literal = powershell_array_literal(&ffmpeg_args); - let command_line = format!( - "powershell.exe {} | {} {}", - loopback_args - .iter() - .map(|arg| quote_powershell(arg)) - .collect::>() - .join(" "), - options.ffmpeg_path, - ffmpeg_args - .iter() - .map(|arg| quote_powershell(arg)) - .collect::>() - .join(" ") - ); - let command_script = format!( - concat!( - "$ErrorActionPreference = 'Stop'\r\n", - "$ProgressPreference = 'SilentlyContinue'\r\n", - "$loopbackArgs = {0}\r\n", - "$ffmpegArgs = {1}\r\n", - "$loopbackErrorPath = {2}\r\n", - "$ffmpegErrorPath = {3}\r\n", - "function Quote-NativeArgument([string] $Value) {{\r\n", - " if ([string]::IsNullOrEmpty($Value)) {{ return '\"\"' }}\r\n", - " if ($Value -match '[\\s\"]') {{ return '\"' + $Value.Replace('\"', '\\\"') + '\"' }}\r\n", - " return $Value\r\n", - "}}\r\n", - "function Start-RedirectTask([System.IO.StreamReader] $Reader, [string] $Path) {{\r\n", - " return [System.Threading.Tasks.Task]::Run([Action]{{\r\n", - " $writer = [System.IO.StreamWriter]::new($Path, $false, [System.Text.Encoding]::ASCII)\r\n", - " try {{\r\n", - " while (($line = $Reader.ReadLine()) -ne $null) {{ $writer.WriteLine($line); $writer.Flush() }}\r\n", - " }} finally {{ $writer.Dispose() }}\r\n", - " }})\r\n", - "}}\r\n", - "$loopbackInfo = [System.Diagnostics.ProcessStartInfo]::new()\r\n", - "$loopbackInfo.FileName = 'powershell.exe'\r\n", - "$loopbackInfo.UseShellExecute = $false\r\n", - "$loopbackInfo.RedirectStandardOutput = $true\r\n", - "$loopbackInfo.RedirectStandardError = $true\r\n", - "$loopbackInfo.CreateNoWindow = $true\r\n", - "$loopbackInfo.Arguments = ($loopbackArgs | ForEach-Object {{ Quote-NativeArgument $_ }}) -join ' '\r\n", - "$ffmpegInfo = [System.Diagnostics.ProcessStartInfo]::new()\r\n", - "$ffmpegInfo.FileName = {4}\r\n", - "$ffmpegInfo.UseShellExecute = $false\r\n", - "$ffmpegInfo.RedirectStandardInput = $true\r\n", - "$ffmpegInfo.RedirectStandardError = $true\r\n", - "$ffmpegInfo.CreateNoWindow = $true\r\n", - "$ffmpegInfo.Arguments = ($ffmpegArgs | ForEach-Object {{ Quote-NativeArgument $_ }}) -join ' '\r\n", - "$loopback = [System.Diagnostics.Process]::Start($loopbackInfo)\r\n", - "$ffmpeg = [System.Diagnostics.Process]::Start($ffmpegInfo)\r\n", - "$loopbackErrTask = Start-RedirectTask $loopback.StandardError $loopbackErrorPath\r\n", - "$ffmpegErrTask = Start-RedirectTask $ffmpeg.StandardError $ffmpegErrorPath\r\n", - "$buffer = [byte[]]::new(65536)\r\n", - "try {{\r\n", - " while (($read = $loopback.StandardOutput.BaseStream.Read($buffer, 0, $buffer.Length)) -gt 0) {{\r\n", - " $ffmpeg.StandardInput.BaseStream.Write($buffer, 0, $read)\r\n", - " $ffmpeg.StandardInput.BaseStream.Flush()\r\n", - " }}\r\n", - " $ffmpeg.StandardInput.Close()\r\n", - " $ffmpeg.WaitForExit()\r\n", - " $loopback.WaitForExit()\r\n", - " [System.Threading.Tasks.Task]::WaitAll(@($loopbackErrTask, $ffmpegErrTask))\r\n", - " if ($ffmpeg.ExitCode -ne 0) {{ throw ('ffmpeg exited with code ' + $ffmpeg.ExitCode) }}\r\n", - " if ($loopback.ExitCode -ne 0) {{ throw ('loopback exited with code ' + $loopback.ExitCode) }}\r\n", - "}} finally {{\r\n", - " if (-not $ffmpeg.HasExited) {{ $ffmpeg.Kill() }}\r\n", - " if (-not $loopback.HasExited) {{ $loopback.Kill() }}\r\n", - "}}\r\n" - ), - loopback_args_literal, - ffmpeg_args_literal, - quote_powershell(&loopback_stderr.display().to_string()), - quote_powershell(&ffmpeg_stderr.display().to_string()), - quote_powershell(&options.ffmpeg_path) - ); - - MuxPlan { - command_line, - command_script, - command_file, - targets, - } -} - -fn require_media_target_uri(options: &Options) -> Result<()> { - if options.target_host.trim().is_empty() { - return Err(anyhow!( - "--target-host is required for Muninn media activation; provide an Odin/CultMesh media target URI" - )); - } - if !options.target_host.starts_with("cultmesh://") { - return Err(anyhow!( - "Muninn media activation requires a cultmesh:// target URI resolved through Odin; raw hosts, IPs, and ports are not accepted" - )); - } - Ok(()) -} - -fn build_targets(options: &Options) -> Vec { - vec![rudp_endpoint_for_options(options)] -} - -fn rudp_endpoint_for_options(options: &Options) -> String { - format!("{}#{}", options.target_host, options.stream_id) -} - -fn muninn_rudp_media_profile() -> MuninnRudpMediaProfile { - muninn_rudp_media_profile_for_bitrate(MUNINN_RUDP_MEDIA_VIDEO_BITRATE_KBPS) -} - -fn muninn_rudp_media_profile_for_options(options: &Options) -> MuninnRudpMediaProfile { - muninn_rudp_media_profile_for_bitrate_and_latency( - options.rudp_video_bitrate_kbps, - options.rudp_latency_budget_ms, - ) -} - -fn muninn_rudp_media_profile_for_bitrate(video_bitrate_kbps: u32) -> MuninnRudpMediaProfile { - muninn_rudp_media_profile_for_bitrate_and_latency( - video_bitrate_kbps, - MUNINN_RUDP_MEDIA_RECEIVER_ASSEMBLY_DEADLINE_MS as u32, - ) -} - -fn muninn_rudp_media_profile_for_bitrate_and_latency( - video_bitrate_kbps: u32, - latency_budget_ms: u32, -) -> MuninnRudpMediaProfile { - let latency_budget_ms = u64::from(latency_budget_ms.max(1)); - MuninnRudpMediaProfile { - profile_id: MUNINN_RUDP_MEDIA_PROFILE_ID, - video_codec: "h264", - video_encoder: "h264_nvenc", - video_preset: "p5", - video_tune: "ull", - video_bitrate_kbps: video_bitrate_kbps.max(1), - media_packet_bytes: MUNINN_RUDP_MEDIA_PACKET_BYTES, - max_fragment_bytes: MUNINN_RUDP_MEDIA_MAX_FRAGMENT_BYTES, - video_b_frames: 0, - video_rc_lookahead: 0, - sender_queue_deadline_ms: latency_budget_ms, - sender_resend_delay_ms: MUNINN_RUDP_MEDIA_RESEND_DELAY_MS, - sender_reliable_expire_after_ms: latency_budget_ms, - sender_pace_every_payloads: MUNINN_RUDP_MEDIA_SEND_PACE_EVERY_PAYLOADS, - sender_pace_sleep_us: MUNINN_RUDP_MEDIA_SEND_PACE_SLEEP_US, - receiver_assembly_deadline_ms: latency_budget_ms, - receiver_gap_wait_ms: MUNINN_RUDP_MEDIA_RECEIVER_GAP_WAIT_MS, - } -} - -fn muninn_rudp_video_bitrate_arg(profile: &MuninnRudpMediaProfile) -> String { - format!("{}k", profile.video_bitrate_kbps) -} - -fn muninn_rudp_video_vbv_buffer_arg(options: &Options, profile: &MuninnRudpMediaProfile) -> String { - let framerate = options.framerate.max(1); - let frame_budget_kbits = profile.video_bitrate_kbps.div_ceil(framerate).max(1); - let burst_budget_kbits = frame_budget_kbits - .saturating_mul(MUNINN_RUDP_MEDIA_VBV_FRAME_BUDGETS) - .max(frame_budget_kbits); - format!("{burst_budget_kbits}k") -} - -fn muninn_rudp_video_gop_frames(options: &Options) -> u32 { - (options.framerate.max(1).div_ceil(4)).max(1) -} - -fn loopback_args(options: &Options) -> Vec { - let audio_source = audio_source_spec_for_options(options); - vec![ - "-NoProfile".to_string(), - "-ExecutionPolicy".to_string(), - "Bypass".to_string(), - "-File".to_string(), - options.loopback_script.display().to_string(), - "-Output".to_string(), - "stdout".to_string(), - "-SampleRate".to_string(), - options.audio_sample_rate.to_string(), - "-Channels".to_string(), - options.audio_channels.to_string(), - "-Device".to_string(), - audio_source.device, - "-DataFlow".to_string(), - match audio_source.kind { - AudioSourceKind::Loopback => "Render".to_string(), - AudioSourceKind::Input => "Capture".to_string(), - }, - ] - .into_iter() - .chain(match audio_source.kind { - AudioSourceKind::Loopback => vec!["-Loopback".to_string()], - AudioSourceKind::Input => Vec::new(), - }) - .collect() -} - -fn rudp_video_ffmpeg_args(options: &Options) -> Vec { - let profile = muninn_rudp_media_profile_for_options(options); - vec![ - "-hide_banner".to_string(), - "-loglevel".to_string(), - "warning".to_string(), - "-fflags".to_string(), - "nobuffer".to_string(), - "-flags".to_string(), - "low_delay".to_string(), - "-thread_queue_size".to_string(), - "256".to_string(), - "-f".to_string(), - "lavfi".to_string(), - "-i".to_string(), - format!( - "ddagrab=framerate={}:output_idx={}:draw_mouse=1", - options.framerate, options.ddagrab_output_index - ), - "-map".to_string(), - "0:v:0".to_string(), - "-an".to_string(), - "-c:v".to_string(), - profile.video_encoder.to_string(), - "-preset".to_string(), - profile.video_preset.to_string(), - "-tune".to_string(), - profile.video_tune.to_string(), - "-zerolatency".to_string(), - "1".to_string(), - "-bf".to_string(), - profile.video_b_frames.to_string(), - "-delay".to_string(), - "0".to_string(), - "-rc".to_string(), - "cbr".to_string(), - "-rc-lookahead".to_string(), - profile.video_rc_lookahead.to_string(), - "-multipass".to_string(), - "disabled".to_string(), - "-strict_gop".to_string(), - "1".to_string(), - "-ldkfs".to_string(), - MUNINN_RUDP_MEDIA_LOW_DELAY_KEY_FRAME_SCALE.to_string(), - "-spatial_aq".to_string(), - "1".to_string(), - "-temporal_aq".to_string(), - "1".to_string(), - "-aq-strength".to_string(), - "8".to_string(), - "-nonref_p".to_string(), - "1".to_string(), - "-b_ref_mode".to_string(), - "disabled".to_string(), - "-dpb_size".to_string(), - MUNINN_RUDP_MEDIA_VIDEO_DPB_SIZE.to_string(), - "-b:v".to_string(), - muninn_rudp_video_bitrate_arg(&profile), - "-maxrate".to_string(), - muninn_rudp_video_bitrate_arg(&profile), - "-bufsize".to_string(), - muninn_rudp_video_vbv_buffer_arg(options, &profile), - "-g".to_string(), - muninn_rudp_video_gop_frames(options).to_string(), - "-keyint_min".to_string(), - muninn_rudp_video_gop_frames(options).to_string(), - "-forced-idr".to_string(), - "1".to_string(), - "-aud".to_string(), - "1".to_string(), - "-fps_mode".to_string(), - "cfr".to_string(), - "-r".to_string(), - options.framerate.max(1).to_string(), - "-f".to_string(), - profile.video_codec.to_string(), - "pipe:1".to_string(), - ] -} - -fn rudp_audio_ffmpeg_args(options: &Options) -> Vec { - vec![ - "-hide_banner".to_string(), - "-loglevel".to_string(), - "warning".to_string(), - "-fflags".to_string(), - "nobuffer".to_string(), - "-flags".to_string(), - "low_delay".to_string(), - "-thread_queue_size".to_string(), - "256".to_string(), - "-f".to_string(), - "f32le".to_string(), - "-ar".to_string(), - options.audio_sample_rate.to_string(), - "-ac".to_string(), - options.audio_channels.to_string(), - "-i".to_string(), - "pipe:0".to_string(), - "-vn".to_string(), - "-ar".to_string(), - options.audio_sample_rate.to_string(), - "-ac".to_string(), - options.audio_channels.to_string(), - "-f".to_string(), - "f32le".to_string(), - "pipe:1".to_string(), - ] -} - -fn ffmpeg_args(options: &Options) -> Vec { - let _ = options.media_transport; - rudp_video_ffmpeg_args(options) -} - -fn write_command_file(plan: &MuxPlan) -> Result<()> { - fs::write(&plan.command_file, &plan.command_script) - .with_context(|| format!("writing {}", plan.command_file.display())) -} - -fn quote_powershell(value: &str) -> String { - format!("'{}'", value.replace('\'', "''")) -} - -fn powershell_array_literal(values: &[String]) -> String { - let joined = values - .iter() - .map(|value| quote_powershell(value)) - .collect::>() - .join(", "); - format!("@({joined})") -} - -fn ensure_state_dirs(options: &Options) -> Result<()> { - fs::create_dir_all(&options.log_root) - .with_context(|| format!("creating {}", options.log_root.display()))?; - if let Some(parent) = options.store_path.parent() { - fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; - } - if let Some(parent) = options - .activation_store_path - .as_ref() - .and_then(|path| path.parent()) - { - fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; - } - Ok(()) -} - -fn open_node(options: &Options, runtime_id: &str) -> Result { - CultMesh::create_node( - &options.store_path, - OdinDocuments, - CultMeshNodeOptions { - runtime_id: runtime_id.to_string(), - pull_on_start: true, - }, - ) - .context("opening Muninn CultMesh store") -} - -impl Options { - fn parse(args: impl Iterator) -> Result { - let mut options = Options { - mode: Mode::Serve, - store_path: PathBuf::from("C:/Meta/Odin/state/muninn.telemetry.cc"), - activation_store_path: Some(PathBuf::from(MUNINN_DEFAULT_ACTIVATION_STORE_PATH)), - surface_id: "muninn.telemetry.local".to_string(), - stream_id: "muninn.raven.av.rudp".to_string(), - stream_filter_explicit: false, - stream_action: "start".to_string(), - host_id: "raven".to_string(), - target_host: String::new(), - port: 5200, - obs_target_host: None, - obs_port: 5204, - media_transport: MediaTransport::Rudp, - media_packet_bytes: MUNINN_RUDP_MEDIA_PACKET_BYTES, - rudp_video_bitrate_kbps: MUNINN_RUDP_MEDIA_VIDEO_BITRATE_KBPS, - rudp_latency_budget_ms: MUNINN_RUDP_MEDIA_RECEIVER_ASSEMBLY_DEADLINE_MS as u32, - width: 1920, - height: 1080, - framerate: 30, - ddagrab_output_index: 0, - capture_video: true, - capture_audio: true, - audio_device: "Realtek".to_string(), - audio_source_id_override: None, - video_sources: Vec::new(), - audio_sources: Vec::new(), - audio_sample_rate: 48000, - audio_channels: 2, - ffmpeg_path: "ffmpeg".to_string(), - loopback_script: PathBuf::from("scripts/wasapi-loopback-capture.ps1"), - log_root: PathBuf::from("C:/Meta/Odin/logs/muninn"), - interval_seconds: None, - move_id: "move-usb".to_string(), - move_filter: None, - hidraw_path: String::new(), - move_colors: Vec::new(), - move_durations_ms: Vec::new(), - move_repeat_count: 1, - move_hue_cycle_ms: 1_000, - move_hue_cycle_ms_explicit: false, - move_hue_mode: None, - move_hue_order_mode: None, - command_id: None, - move_host_address: None, - move_state_sources: Vec::new(), - move_marker_camera_sources: Vec::new(), - move_marker_width: 320, - move_marker_height: 240, - move_marker_stride_bytes: None, - move_marker_fps: 187, - move_marker_threshold_min: 180, - move_marker_min_area_px: 4, - move_marker_max_candidates: 64, - move_psmoveapi_tracker: false, - move_light_passive: false, - move_tracker_exposure_milli: 100, - move_tracker_camera_exposure_milli: HashMap::new(), - move_evidence_stream_id: None, - move_evidence_verse_id: "mimir-live".to_string(), - move_evidence_ring_slots: 4, - move_evidence_slot_bytes: 8192, - move_evidence_snapshot_path: None, - quest_adb: false, - quest_serial: None, - quest_input_stream_id: None, - quest_pose_stream_id: None, - quest_video_input_stream_id: None, - idunn_rudp_health: None, - odin_cultmesh_uri: Some("cultmesh://odin/rendezvous/provider-catalog".to_string()), - hid_controller_rudp_target: None, - hid_controller_rudp_bind: None, - hid_controller_rudp_advertise: None, - command_rudp_bind: None, - command_rudp_advertise: None, - hid_controller_receipt_retention_seconds: 3_600, - }; - - let mut args = args.peekable(); - let mut idunn_rudp_health_endpoint = None; - let mut idunn_daemon_id = None; - let mut idunn_health_contract = None; - while let Some(arg) = args.next() { - match arg.as_str() { - "serve" => options.mode = Mode::Serve, - "activate" => options.mode = Mode::Activate, - "request-stream" => options.mode = Mode::RequestStream, - "request-move-light" => options.mode = Mode::RequestMoveLight, - "set-move-hue-program" => options.mode = Mode::SetMoveHueProgram, - "move-hue-program-status" => options.mode = Mode::MoveHueProgramStatus, - "capture-stream-status" => options.mode = Mode::CaptureStreamStatus, - "obs-catalog-status" => options.mode = Mode::ObsCatalogStatus, - "move-light-status" => options.mode = Mode::MoveLightStatus, - "move-identity-status" => options.mode = Mode::MoveIdentityStatus, - "move-source-status" => options.mode = Mode::MoveSourceStatus, - "move-state-status" => options.mode = Mode::MoveStateStatus, - "claim-move-host" => options.mode = Mode::ClaimMoveHost, - "quest-access-status" => options.mode = Mode::QuestAccessStatus, - "move-tracker-worker" => options.mode = Mode::MoveTrackerWorker, - "--health" => options.mode = Mode::Health, - "--dry-run" => options.mode = Mode::DryRun, - "--store" => options.store_path = PathBuf::from(take_value(&mut args, "--store")?), - "--activate-store" => { - options.activation_store_path = - Some(PathBuf::from(take_value(&mut args, "--activate-store")?)) - } - "--surface" => options.surface_id = take_value(&mut args, "--surface")?, - "--stream" => { - options.stream_id = take_value(&mut args, "--stream")?; - options.stream_filter_explicit = true; - } - "--stream-action" => { - options.stream_action = take_value(&mut args, "--stream-action")? - } - "--host" => options.host_id = take_value(&mut args, "--host")?, - "--target-host" => options.target_host = take_value(&mut args, "--target-host")?, - "--port" => options.port = take_value(&mut args, "--port")?.parse()?, - "--obs-target-host" => { - options.obs_target_host = Some(take_value(&mut args, "--obs-target-host")?) - } - "--obs-port" => options.obs_port = take_value(&mut args, "--obs-port")?.parse()?, - "--media-transport" => { - options.media_transport = - parse_media_transport(&take_value(&mut args, "--media-transport")?)? - } - "--media-packet-bytes" => { - options.media_packet_bytes = - take_value(&mut args, "--media-packet-bytes")?.parse()? - } - "--rudp-video-bitrate-kbps" => { - let bitrate = take_value(&mut args, "--rudp-video-bitrate-kbps")? - .parse() - .context("--rudp-video-bitrate-kbps must be a positive integer")?; - if bitrate == 0 { - return Err(anyhow!( - "--rudp-video-bitrate-kbps must be greater than zero" - )); - } - options.rudp_video_bitrate_kbps = bitrate; - } - "--rudp-latency-budget-ms" => { - let latency_budget = take_value(&mut args, "--rudp-latency-budget-ms")? - .parse() - .context("--rudp-latency-budget-ms must be a positive integer")?; - if latency_budget == 0 { - return Err(anyhow!( - "--rudp-latency-budget-ms must be greater than zero" - )); - } - options.rudp_latency_budget_ms = latency_budget; - } - "--width" => options.width = take_value(&mut args, "--width")?.parse()?, - "--height" => options.height = take_value(&mut args, "--height")?.parse()?, - "--framerate" => { - options.framerate = take_value(&mut args, "--framerate")?.parse()? - } - "--ddagrab-output-index" => { - options.ddagrab_output_index = - take_value(&mut args, "--ddagrab-output-index")?.parse()? - } - "--no-video" => options.capture_video = false, - "--no-audio" => options.capture_audio = false, - "--audio-device" => options.audio_device = take_value(&mut args, "--audio-device")?, - "--audio-source-id" => { - options.audio_source_id_override = - Some(take_value(&mut args, "--audio-source-id")?) - } - "--video-source" => options.video_sources.push(parse_catalog_source(&take_value( - &mut args, - "--video-source", - )?)?), - "--audio-source" => options.audio_sources.push(parse_catalog_source(&take_value( - &mut args, - "--audio-source", - )?)?), - "--audio-sample-rate" => { - options.audio_sample_rate = - take_value(&mut args, "--audio-sample-rate")?.parse()? - } - "--audio-channels" => { - options.audio_channels = take_value(&mut args, "--audio-channels")?.parse()? - } - "--ffmpeg" => options.ffmpeg_path = take_value(&mut args, "--ffmpeg")?, - "--loopback-script" => { - options.loopback_script = - PathBuf::from(take_value(&mut args, "--loopback-script")?) - } - "--log-root" => { - options.log_root = PathBuf::from(take_value(&mut args, "--log-root")?) - } - "--interval-seconds" => { - options.interval_seconds = Some( - take_value(&mut args, "--interval-seconds")? - .parse() - .context("--interval-seconds must be a positive integer")?, - ) - } - "--move" => { - let value = take_value(&mut args, "--move")?; - options.move_id = value.clone(); - options.move_filter = Some(value); - } - "--hidraw" => options.hidraw_path = take_value(&mut args, "--hidraw")?, - "--move-state" => { - let value = take_value(&mut args, "--move-state")?; - options - .move_state_sources - .push(parse_move_state_source(&value)?); - } - "--move-marker-camera" => { - let value = take_value(&mut args, "--move-marker-camera")?; - options - .move_marker_camera_sources - .push(parse_move_marker_camera_source(&value)?); - } - "--move-marker-width" => { - options.move_marker_width = - take_value(&mut args, "--move-marker-width")?.parse()? - } - "--move-marker-height" => { - options.move_marker_height = - take_value(&mut args, "--move-marker-height")?.parse()? - } - "--move-marker-stride-bytes" => { - options.move_marker_stride_bytes = - Some(take_value(&mut args, "--move-marker-stride-bytes")?.parse()?); - } - "--move-marker-fps" => { - options.move_marker_fps = take_value(&mut args, "--move-marker-fps")?.parse()? - } - "--move-marker-threshold-min" => { - options.move_marker_threshold_min = - take_value(&mut args, "--move-marker-threshold-min")?.parse()? - } - "--move-marker-min-area-px" => { - options.move_marker_min_area_px = - take_value(&mut args, "--move-marker-min-area-px")?.parse()? - } - "--move-marker-max-candidates" => { - options.move_marker_max_candidates = - take_value(&mut args, "--move-marker-max-candidates")?.parse()? - } - "--move-psmoveapi-tracker" => options.move_psmoveapi_tracker = true, - "--move-light-passive" => options.move_light_passive = true, - "--move-tracker-exposure-milli" => { - options.move_tracker_exposure_milli = - take_value(&mut args, "--move-tracker-exposure-milli")?.parse()? - } - "--move-tracker-camera-exposure" => { - let value = take_value(&mut args, "--move-tracker-camera-exposure")?; - let (camera_id, exposure) = value - .split_once('=') - .context("--move-tracker-camera-exposure must be camera-id=0..1000")?; - let exposure = exposure - .parse::() - .context("--move-tracker-camera-exposure must be camera-id=0..1000")?; - if camera_id.trim().is_empty() || exposure > 1000 { - return Err(anyhow!( - "--move-tracker-camera-exposure must be camera-id=0..1000" - )); - } - options - .move_tracker_camera_exposure_milli - .insert(camera_id.to_string(), exposure); - } - "--move-evidence-stream" => { - options.move_evidence_stream_id = - Some(take_value(&mut args, "--move-evidence-stream")?) - } - "--move-evidence-verse" => { - options.move_evidence_verse_id = take_value(&mut args, "--move-evidence-verse")? - } - "--move-evidence-ring-slots" => { - options.move_evidence_ring_slots = - take_value(&mut args, "--move-evidence-ring-slots")? - .parse() - .context("--move-evidence-ring-slots must be a positive integer")? - } - "--move-evidence-slot-bytes" => { - options.move_evidence_slot_bytes = - take_value(&mut args, "--move-evidence-slot-bytes")? - .parse() - .context("--move-evidence-slot-bytes must be a positive integer")? - } - "--move-evidence-snapshot" => { - let value = take_value(&mut args, "--move-evidence-snapshot")?; - if value.trim().is_empty() { - return Err(anyhow!("--move-evidence-snapshot must be non-empty")); - } - - options.move_evidence_snapshot_path = Some(PathBuf::from(value)); - } - "--quest-adb" => options.quest_adb = true, - "--quest-serial" => { - options.quest_serial = Some(take_value(&mut args, "--quest-serial")?) - } - "--quest-input-stream" => { - options.quest_input_stream_id = - Some(take_value(&mut args, "--quest-input-stream")?) - } - "--quest-pose-stream" => { - options.quest_pose_stream_id = - Some(take_value(&mut args, "--quest-pose-stream")?) - } - "--quest-video-input-stream" => { - options.quest_video_input_stream_id = - Some(take_value(&mut args, "--quest-video-input-stream")?) - } - "--idunn-rudp-health" => { - idunn_rudp_health_endpoint = Some( - take_value(&mut args, "--idunn-rudp-health")? - .parse() - .context("--idunn-rudp-health must be a socket address")?, - ) - } - "--idunn-daemon" => { - idunn_daemon_id = Some(take_value(&mut args, "--idunn-daemon")?) - } - "--idunn-health-contract" => { - idunn_health_contract = Some(take_value(&mut args, "--idunn-health-contract")?) - } - "--odin-cultmesh-rudp" => { - let _ = take_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(take_value(&mut args, "--odin-cultmesh-uri")?) - } - "--hid-controller-rudp-target" | "--hid-controller-udp-target" => { - options.hid_controller_rudp_target = Some( - take_value(&mut args, &arg)? - .parse() - .context("--hid-controller-rudp-target must be a socket address")?, - ) - } - "--hid-controller-rudp-bind" => { - options.hid_controller_rudp_bind = Some( - take_value(&mut args, "--hid-controller-rudp-bind")? - .parse() - .context("--hid-controller-rudp-bind must be a socket address")?, - ) - } - "--hid-controller-rudp-advertise" => { - options.hid_controller_rudp_advertise = - Some(take_value(&mut args, "--hid-controller-rudp-advertise")?) - } - "--command-rudp-bind" => { - options.command_rudp_bind = Some( - take_value(&mut args, "--command-rudp-bind")? - .parse() - .context("--command-rudp-bind must be a socket address")?, - ) - } - "--command-rudp-advertise" => { - options.command_rudp_advertise = - Some(take_value(&mut args, "--command-rudp-advertise")?) - } - "--hid-controller-receipt-retention-seconds" => { - options.hid_controller_receipt_retention_seconds = take_value( - &mut args, - "--hid-controller-receipt-retention-seconds", - )? - .parse() - .context( - "--hid-controller-receipt-retention-seconds must be a positive integer", - )?; - if options.hid_controller_receipt_retention_seconds == 0 { - return Err(anyhow!( - "--hid-controller-receipt-retention-seconds must be greater than zero" - )); - } - } - "--capture-command-rudp-bind" => { - let _ = take_value(&mut args, "--capture-command-rudp-bind")?; - return Err(anyhow!( - "--capture-command-rudp-bind has been removed; Muninn serve reads capture commands from Odin/CultMesh discovery" - )); - } - "--capture-command-rudp-target" => { - let _ = take_value(&mut args, "--capture-command-rudp-target")?; - return Err(anyhow!( - "--capture-command-rudp-target has been removed; use --odin-cultmesh-uri so request-stream publishes through Odin/CultMesh" - )); - } - "--color" => options.move_colors.push(take_value(&mut args, "--color")?), - "--duration-ms" => options - .move_durations_ms - .push(take_value(&mut args, "--duration-ms")?.parse()?), - "--repeat-count" => { - options.move_repeat_count = take_value(&mut args, "--repeat-count")? - .parse() - .context("--repeat-count must be a positive integer")? - } - "--move-hue-cycle-ms" => { - options.move_hue_cycle_ms = take_value(&mut args, "--move-hue-cycle-ms")? - .parse() - .context("--move-hue-cycle-ms must be a positive integer")?; - options.move_hue_cycle_ms_explicit = true; - } - "--move-hue-mode" => { - options.move_hue_mode = Some(take_value(&mut args, "--move-hue-mode")?) - } - "--move-hue-order" => { - options.move_hue_order_mode = Some(take_value(&mut args, "--move-hue-order")?) - } - "--command" => options.command_id = Some(take_value(&mut args, "--command")?), - "--move-host" => { - options.move_host_address = Some(take_value(&mut args, "--move-host")?) - } - "--help" | "-h" => return Err(anyhow!(help_text())), - other => { - return Err(anyhow!( - "unknown Muninn argument: {other}\n\n{}", - help_text() - )); - } - } - } - - if options.interval_seconds == Some(0) { - return Err(anyhow!("--interval-seconds must be greater than zero")); - } - if options.move_hue_cycle_ms == 0 { - return Err(anyhow!("--move-hue-cycle-ms must be greater than zero")); - } - if !options.capture_video && !options.capture_audio { - return Err(anyhow!( - "Muninn activation must keep at least one leg alive; do not pass both --no-video and --no-audio" - )); - } - if options.move_evidence_verse_id.trim().is_empty() { - return Err(anyhow!("--move-evidence-verse must be non-empty")); - } - if options - .move_evidence_stream_id - .as_ref() - .is_some_and(|value| value.trim().is_empty()) - { - return Err(anyhow!("--move-evidence-stream must be non-empty")); - } - if options.move_evidence_ring_slots == 0 { - return Err(anyhow!( - "--move-evidence-ring-slots must be greater than zero" - )); - } - if options.move_evidence_slot_bytes == 0 { - return Err(anyhow!( - "--move-evidence-slot-bytes must be greater than zero" - )); - } - if options - .move_evidence_snapshot_path - .as_ref() - .is_some_and(|path| path.as_os_str().is_empty()) - { - return Err(anyhow!("--move-evidence-snapshot must be non-empty")); - } - if options.move_marker_width == 0 { - return Err(anyhow!("--move-marker-width must be greater than zero")); - } - if options.move_marker_height == 0 { - return Err(anyhow!("--move-marker-height must be greater than zero")); - } - if options - .move_marker_stride_bytes - .is_some_and(|stride| stride < options.move_marker_width) - { - return Err(anyhow!( - "--move-marker-stride-bytes must be at least --move-marker-width" - )); - } - if options.move_marker_fps == 0 { - return Err(anyhow!("--move-marker-fps must be greater than zero")); - } - if options.move_marker_min_area_px == 0 { - return Err(anyhow!( - "--move-marker-min-area-px must be greater than zero" - )); - } - if options.move_marker_max_candidates == 0 { - return Err(anyhow!( - "--move-marker-max-candidates must be greater than zero" - )); - } - if options.move_tracker_exposure_milli > 1000 { - return Err(anyhow!( - "--move-tracker-exposure-milli must be within 0..=1000" - )); - } - if options.move_psmoveapi_tracker && !cfg!(feature = "psmoveapi-tracker") { - return Err(anyhow!( - "--move-psmoveapi-tracker requires a Muninn build with the psmoveapi-tracker feature" - )); - } - for (name, value) in [ - ("--quest-serial", options.quest_serial.as_ref()), - ( - "--quest-input-stream", - options.quest_input_stream_id.as_ref(), - ), - ("--quest-pose-stream", options.quest_pose_stream_id.as_ref()), - ( - "--quest-video-input-stream", - options.quest_video_input_stream_id.as_ref(), - ), - ("--move-host", options.move_host_address.as_ref()), - ("--idunn-daemon", idunn_daemon_id.as_ref()), - ("--idunn-health-contract", idunn_health_contract.as_ref()), - ] { - if value.is_some_and(|value| value.trim().is_empty()) { - return Err(anyhow!("{name} must be non-empty")); - } - } - 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 take_value(args: &mut impl Iterator, name: &str) -> Result { - args.next() - .ok_or_else(|| anyhow!("{name} requires a value")) -} - -fn timestamp() -> Result { - let seconds = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("system clock is before Unix epoch")? - .as_secs(); - Ok(format!("unix-{seconds}")) -} - -fn idunn_timestamp() -> Result { - let seconds = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("system clock is before Unix epoch")? - .as_secs(); - Ok(format!("unix:{seconds}")) -} - -fn timestamp_ns() -> Result { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .context("system clock is before Unix epoch")? - .as_nanos(); - i64::try_from(nanos).context("system timestamp does not fit i64 nanoseconds") -} - -fn media_transport_id(transport: &MediaTransport) -> &'static str { - match transport { - MediaTransport::Rudp => CULTNET_RUDP_PROTOCOL_ID, - } -} - -fn media_transport_cli(transport: &MediaTransport) -> &'static str { - match transport { - MediaTransport::Rudp => "rudp", - } -} - -fn parse_media_transport(value: &str) -> Result { - match value.to_ascii_lowercase().as_str() { - "rudp" | "cultnet-rudp" | "cultmesh-rudp" | CULTNET_RUDP_PROTOCOL_ID => { - Ok(MediaTransport::Rudp) - } - _ => Err(anyhow!( - "--media-transport must be one of: rudp, cultnet-rudp" - )), - } -} - -fn parse_catalog_source(value: &str) -> Result { - let Some((id, label)) = value.split_once('=') else { - return Err(anyhow!( - "source catalog entries must be formatted as =" - )); - }; - let id = id.trim(); - let label = label.trim(); - if id.is_empty() || label.is_empty() { - return Err(anyhow!( - "source catalog entries must include a non-empty source id and display label" - )); - } - Ok(CatalogSource { - id: id.to_string(), - label: label.to_string(), - }) -} - -fn parse_move_marker_camera_source(value: &str) -> Result { - let Some((camera_id, device_path)) = value.split_once('=') else { - return Err(anyhow!( - "--move-marker-camera must be formatted as =" - )); - }; - if camera_id.trim().is_empty() || device_path.trim().is_empty() { - return Err(anyhow!( - "--move-marker-camera requires non-empty camera id and device path" - )); - } - Ok(MoveMarkerCameraSource { - camera_id: camera_id.to_string(), - device_path: PathBuf::from(device_path), - }) -} - -fn help_text() -> &'static str { - "Usage: muninn [serve|activate|request-stream|capture-stream-status|obs-catalog-status|request-move-light|move-light-status|move-identity-status|move-source-status|move-state-status|claim-move-host|quest-access-status] [--store ] [--activate-store ] [--stream-action ] [--target-host ] [--media-transport ] [--media-packet-bytes ] [--rudp-video-bitrate-kbps ] [--rudp-latency-budget-ms ] [--video-source ] [--audio-source ] [--audio-source-id ] [--no-video] [--no-audio] [--loopback-script ] [--ffmpeg ] [--odin-cultmesh-uri ] [--move-state =] [--move-marker-camera =] [--move-psmoveapi-tracker] [--move-tracker-exposure-milli <0..1000>] [--move-marker-width ] [--move-marker-height ] [--move-marker-fps ] [--move-host ] [--move-evidence-stream ] [--move-evidence-verse ] [--move-evidence-ring-slots ] [--move-evidence-slot-bytes ] [--move-evidence-snapshot ] [--quest-adb] [--quest-serial ] [--quest-input-stream ] [--quest-pose-stream ] [--quest-video-input-stream ] [--idunn-rudp-health ] [--idunn-daemon ] [--idunn-health-contract ] [--dry-run] [--health]\n\nMuninn is Odin's portable telemetry Verse assembler. serve publishes cheap typed telemetry affordances, optional Quest USB access surfaces, and the explicitly configured Move runtime; when serve receives --move-state, --move-marker-camera, --move-host, or --move-evidence-stream it may publish source-local Move controller state, source-local optical marker candidates, typed Move identity records, a CultMesh Move evidence stream, optionally write a latest one-copy Move proof evidence snapshot for Mimir field capture/replay, and keep USB-attached PS Moves claimed to that explicit Bluetooth host; --move-psmoveapi-tracker delegates camera exposure and optical extraction to the reference PSMoveAPI backend while Muninn retains stable-ID light actuation; serve consumes typed capture stream commands from its provider-owned activation store and owns the local ffmpeg/loopback activation child lifecycle, resolves media targets from Odin/CultMesh provider advertisements, and publishes its discovery advertisement through --odin-cultmesh-uri; activate starts an explicitly requested local CultNet RUDP stream as a daemon child after resolving the cultmesh:// media target URI through Odin; request-stream discovers the provider through Odin and sends its typed command to that provider; obs-catalog-status pulls Odin-owned muninn.obs_stream_catalog discovery into the local compatibility store for OBS; capture-stream-status reads typed capture stream command receipts; use --no-video or --no-audio to request one leg over the CultNet RUDP media lane; request-move-light publishes a typed Move light command for Muninn serve to execute; move-light-status reads typed command receipts; move-identity-status reads typed Move identity records; move-source-status prints live Move source discovery; move-state-status reads typed controller-state records; claim-move-host assigns USB-attached PS Moves to a Bluetooth host; quest-access-status reads typed Quest access state. In --health mode, the Idunn RUDP flags publish the same typed daemon health document to Idunn for explicit diagnostics." -} - -fn parse_move_state_source(value: &str) -> Result { - let Some((move_id, hidraw_path)) = value.split_once('=') else { - return Err(anyhow!( - "--move-state must be formatted as =" - )); - }; - if move_id.trim().is_empty() || hidraw_path.trim().is_empty() { - return Err(anyhow!( - "--move-state requires non-empty move id and hidraw path" - )); - } - Ok(MoveStateSource { - move_id: move_id.to_string(), - hidraw_path: hidraw_path.to_string(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - use serde::Deserialize; - - #[derive(Default)] - struct RecordingMoveLightWriter { - writes: Vec<(String, Vec)>, - } - - impl MoveLightWriter for RecordingMoveLightWriter { - fn write_report(&mut self, hidraw_path: &str, report: &[u8]) -> Result<()> { - self.writes.push((hidraw_path.to_string(), report.to_vec())); - Ok(()) - } - } - - fn pending_move_light_command() -> MuninnMoveLightCommandRecord { - MuninnMoveLightCommandRecord { - command_id: "cmd-1".to_string(), - host_id: "raven".to_string(), - move_id: "move-1".to_string(), - hidraw_path: "/dev/hidraw3".to_string(), - colors: vec!["#ff4008".to_string()], - durations_ms: vec![0], - repeat_count: 1, - authority: "mimir.structured-light".to_string(), - state: "pending".to_string(), - detail: "test".to_string(), - updated_at: "unix-0".to_string(), - } - } - - #[test] - fn serve_is_default_and_does_not_activate_streams() { - let options = Options::parse([].into_iter()).unwrap(); - - assert_eq!(options.mode, Mode::Serve); - assert!(options.interval_seconds.is_none()); - assert_eq!( - options.activation_store_path, - Some(PathBuf::from(MUNINN_DEFAULT_ACTIVATION_STORE_PATH)) - ); - assert_eq!( - options.rudp_video_bitrate_kbps, - MUNINN_RUDP_MEDIA_VIDEO_BITRATE_KBPS - ); - } - - #[test] - fn serve_can_publish_distinct_activation_store_path() { - let options = Options::parse( - [ - "serve", - "--store", - "C:/Meta/Odin/state/muninn.telemetry.cc", - "--activate-store", - "C:/Meta/Odin/state/muninn.activate.cc", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert_eq!( - options.activation_store_path, - Some(PathBuf::from("C:/Meta/Odin/state/muninn.activate.cc")) - ); - } - - #[test] - fn parse_accepts_independent_obs_source_catalog_entries() { - let options = Options::parse( - [ - "serve", - "--video-source", - "display:0=Raven display 1", - "--video-source", - "display:1=Raven display 2", - "--audio-source", - "wasapi-loopback:Realtek=Raven Realtek loopback", - "--audio-source", - "wasapi-loopback:Headphones=Raven headphones loopback", - "--audio-source", - "wasapi-input:Microphone=Raven microphone input", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert_eq!( - video_source_catalog(&options), - vec![ - CatalogSource { - id: "display:0".to_string(), - label: "Raven display 1".to_string(), - }, - CatalogSource { - id: "display:1".to_string(), - label: "Raven display 2".to_string(), - } - ] - ); - assert_eq!( - audio_source_catalog(&options), - vec![ - CatalogSource { - id: "wasapi-loopback:Realtek".to_string(), - label: "Raven Realtek loopback".to_string(), - }, - CatalogSource { - id: "wasapi-loopback:Headphones".to_string(), - label: "Raven headphones loopback".to_string(), - }, - CatalogSource { - id: "wasapi-input:Microphone".to_string(), - label: "Raven microphone input".to_string(), - } - ] - ); - } - - #[test] - fn parse_audio_source_spec_accepts_loopback_and_input_sources() { - assert_eq!( - parse_audio_source_spec("wasapi-loopback:Realtek"), - Some(AudioSourceSpec { - kind: AudioSourceKind::Loopback, - device: "Realtek".to_string(), - }) - ); - assert_eq!( - parse_audio_source_spec("wasapi-input:Microphone"), - Some(AudioSourceSpec { - kind: AudioSourceKind::Input, - device: "Microphone".to_string(), - }) - ); - } - - #[test] - fn obs_source_catalog_falls_back_to_configured_capture_pair() { - let options = Options::parse( - [ - "serve", - "--host", - "raven", - "--ddagrab-output-index", - "1", - "--audio-device", - "Realtek", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert_eq!( - video_source_catalog(&options), - vec![CatalogSource { - id: "display:1".to_string(), - label: "raven display 2".to_string(), - }] - ); - assert_eq!( - audio_source_catalog(&options), - vec![CatalogSource { - id: "wasapi-loopback:Realtek".to_string(), - label: "raven loopback (Realtek)".to_string(), - }] - ); - } - - #[test] - fn parse_accepts_single_leg_rudp_activation_requests() { - let video_only = Options::parse( - [ - "activate", - "--media-transport", - "rudp", - "--no-audio", - "--stream", - "muninn.raven.video.rudp", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - assert!(video_only.capture_video); - assert!(!video_only.capture_audio); - assert_eq!( - video_source_id_for_options(&video_only), - "display:0".to_string() - ); - assert_eq!( - audio_source_id_for_options(&video_only), - MUNINN_DISABLED_AUDIO_SOURCE_ID.to_string() - ); - - let audio_only = Options::parse( - [ - "activate", - "--media-transport", - "rudp", - "--no-video", - "--stream", - "muninn.raven.audio.rudp", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - assert!(!audio_only.capture_video); - assert!(audio_only.capture_audio); - assert_eq!( - video_source_id_for_options(&audio_only), - MUNINN_DISABLED_VIDEO_SOURCE_ID.to_string() - ); - assert_eq!( - audio_source_id_for_options(&audio_only), - "wasapi-loopback:Realtek".to_string() - ); - } - - #[test] - fn loopback_args_switch_to_capture_mode_for_input_sources() { - let options = Options::parse( - [ - "activate", - "--target-host", - "cultmesh://odin/media/muninn-raven-av", - "--audio-source-id", - "wasapi-input:Microphone (USB Audio)", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - let args = loopback_args(&options); - assert!(args.contains(&"Capture".to_string())); - assert!(!args.contains(&"-Loopback".to_string())); - assert!(args.contains(&"Microphone (USB Audio)".to_string())); - } - - #[test] - fn parse_rejects_activation_when_both_media_legs_are_disabled() { - let error = Options::parse( - [ - "activate", - "--media-transport", - "rudp", - "--no-video", - "--no-audio", - ] - .into_iter() - .map(String::from), - ) - .unwrap_err() - .to_string(); - - assert!(error.contains("at least one leg alive")); - } - - #[test] - fn media_activation_requires_cultmesh_target_uri() { - let options = Options::parse(["activate"].into_iter().map(String::from)).unwrap(); - - let error = require_media_target_uri(&options).unwrap_err().to_string(); - assert!(error.contains("--target-host is required")); - - let options = Options::parse( - ["activate", "--target-host", "198.51.100.66"] - .into_iter() - .map(String::from), - ) - .unwrap(); - let error = require_media_target_uri(&options).unwrap_err().to_string(); - assert!(error.contains("cultmesh://")); - - let options = Options::parse(["request-stream"].into_iter().map(String::from)).unwrap(); - - let error = build_capture_stream_command(&options) - .unwrap_err() - .to_string(); - assert!(error.contains("--target-host is required")); - } - - #[test] - fn parse_rejects_blank_obs_source_catalog_entries() { - let error = Options::parse( - ["serve", "--video-source", "display:0="] - .into_iter() - .map(String::from), - ) - .unwrap_err(); - - assert!(error.to_string().contains("non-empty source id")); - } - - #[test] - fn health_idunn_rudp_options_are_explicit_bundle() { - let options = Options::parse( - [ - "--health", - "--idunn-rudp-health", - "127.0.0.1:17870", - "--idunn-daemon", - "starfire-muninn", - "--idunn-health-contract", - "muninn.cultnet-rudp-local-telemetry-and-quest-access", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - let idunn = options.idunn_rudp_health.unwrap(); - assert_eq!(options.mode, Mode::Health); - assert_eq!(idunn.endpoint, "127.0.0.1:17870".parse().unwrap()); - assert_eq!(idunn.daemon_id, "starfire-muninn"); - assert_eq!( - idunn.health_contract, - "muninn.cultnet-rudp-local-telemetry-and-quest-access" - ); - - let error = Options::parse( - ["--health", "--idunn-rudp-health", "127.0.0.1:17870"] - .into_iter() - .map(String::from), - ) - .unwrap_err() - .to_string(); - assert!(error.contains("must be provided together")); - } - - #[test] - fn serve_accepts_odin_cultmesh_uri_startup_target() { - let options = Options::parse( - [ - "serve", - "--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 serve_rejects_removed_odin_cultmesh_rudp_startup_target() { - let error = Options::parse( - ["serve", "--odin-cultmesh-rudp", "203.0.113.10:17871"] - .into_iter() - .map(String::from), - ) - .unwrap_err() - .to_string(); - - assert!(error.contains("--odin-cultmesh-rudp has been removed")); - } - - #[test] - fn request_stream_rejects_raw_capture_command_rudp_target() { - let error = Options::parse( - [ - "request-stream", - "--target-host", - "cultmesh://odin/media/muninn-raven-av", - "--capture-command-rudp-target", - "127.0.0.1:17872", - ] - .into_iter() - .map(String::from), - ) - .unwrap_err() - .to_string(); - - assert!(error.contains("--capture-command-rudp-target has been removed")); - } - - #[test] - fn serve_rejects_removed_capture_command_rudp_bind() { - let error = Options::parse( - ["serve", "--capture-command-rudp-bind", "127.0.0.1:17884"] - .into_iter() - .map(String::from), - ) - .unwrap_err() - .to_string(); - - assert!(error.contains("--capture-command-rudp-bind has been removed")); - } - - #[test] - fn request_stream_requires_odin_cultmesh_command_route() { - let options = Options::parse( - [ - "request-stream", - "--host", - "raven", - "--stream", - "muninn.raven.av.rudp", - "--target-host", - "cultmesh://odin/media/muninn-raven-av", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let error = request_capture_stream(options).unwrap_err().to_string(); - - assert!(error.contains("request-stream requires --odin-cultmesh-uri")); - } - - #[test] - fn idunn_rudp_health_publisher_sends_raw_daemon_health_document() { - let receiver = UdpSocket::bind("127.0.0.1:0").unwrap(); - receiver - .set_read_timeout(Some(Duration::from_secs(1))) - .unwrap(); - let receiver_addr = receiver.local_addr().unwrap(); - let handle = thread::spawn(move || { - let mut transport = CultNetRudpSocketTransportConnection::new( - CultNetRudpSocketTransportOptions::server( - "idunn-test", - receiver, - IDUNN_HEALTH_RUDP_CONNECTION_ID, - ), - ) - .unwrap(); - let deadline = Instant::now() + Duration::from_secs(2); - loop { - if let Some(frame) = transport.receive_once().unwrap() { - return cultnet_rs::decode_cultnet_message_from_slice( - &frame.payload, - CultNetWireContract::CultNetSchemaV0, - ) - .unwrap(); - } - transport.poll_resends().unwrap(); - if Instant::now() >= deadline { - panic!("timed out waiting for Muninn RUDP health frame"); - } - } - }); - let options = IdunnRudpHealthOptions { - endpoint: receiver_addr, - daemon_id: "starfire-muninn".to_string(), - health_contract: "muninn.cultnet-rudp-local-telemetry-and-quest-access".to_string(), - }; - - publish_idunn_rudp_health(&options, "active", "Muninn healthy", "unix:100").unwrap(); - - let message = handle.join().unwrap(); - let CultNetMessage::DocumentPutRaw { document, .. } = message else { - panic!("expected raw document put"); - }; - assert_eq!(document.schema_id, "idunn.daemon_health"); - assert_eq!(document.record_key, "starfire-muninn"); - assert_eq!( - document.payload_encoding, - CultNetRawPayloadEncoding::Messagepack - ); - let health: IdunnDaemonHealthRecord = rmp_serde::from_slice(&document.payload).unwrap(); - assert_eq!(health.daemon_id, "starfire-muninn"); - assert_eq!(health.state, "active"); - assert_eq!( - health.health_contract, - "muninn.cultnet-rudp-local-telemetry-and-quest-access" - ); - assert_eq!(health.publication_source, "daemon-published"); - assert_eq!(health.transport, CULTNET_RUDP_PROTOCOL_ID); - assert_eq!(health.observed_at, "unix:100"); - } - - #[test] - fn repeated_capture_stream_start_is_equivalent_when_media_shape_matches() { - let options = Options::parse( - [ - "request-stream", - "--host", - "raven", - "--stream", - "muninn.raven.av.rudp", - "--target-host", - "cultmesh://odin/media/muninn-raven-av", - "--port", - "5204", - "--media-transport", - "rudp", - "--media-packet-bytes", - "800", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let mut active = build_capture_stream_command(&options).unwrap(); - active.command_id = "first-command".to_string(); - active.state = "running".to_string(); - let mut retry = build_capture_stream_command(&options).unwrap(); - retry.command_id = "retry-command".to_string(); - - assert!(capture_stream_commands_start_equivalent(&active, &retry)); - - retry.media_packet_bytes = 940; - assert!(!capture_stream_commands_start_equivalent(&active, &retry)); - } - - #[test] - fn repeated_capture_stream_start_is_not_equivalent_when_split_leg_changes() { - let mut video_only = build_capture_stream_command( - &Options::parse( - [ - "request-stream", - "--media-transport", - "rudp", - "--stream", - "muninn.raven.video.rudp", - "--target-host", - "cultmesh://odin/media/muninn-raven-av", - "--no-audio", - ] - .into_iter() - .map(String::from), - ) - .unwrap(), - ) - .unwrap(); - video_only.command_id = "video-only".to_string(); - video_only.state = "running".to_string(); - - let mut combined = build_capture_stream_command( - &Options::parse( - [ - "request-stream", - "--media-transport", - "rudp", - "--stream", - "muninn.raven.video.rudp", - "--target-host", - "cultmesh://odin/media/muninn-raven-av", - ] - .into_iter() - .map(String::from), - ) - .unwrap(), - ) - .unwrap(); - combined.command_id = "combined".to_string(); - - assert!(!capture_stream_commands_start_equivalent( - &video_only, - &combined - )); - assert_eq!( - command_audio_source_id(&video_only), - MUNINN_DISABLED_AUDIO_SOURCE_ID - ); - assert!(command_requests_video(&video_only)); - assert!(!command_requests_audio(&video_only)); - } - - #[test] - fn repeated_capture_stream_start_is_equivalent_across_transport_specific_stream_ids() { - let mut active = build_capture_stream_command( - &Options::parse( - [ - "request-stream", - "--host", - "raven", - "--stream", - "muninn.raven.av.rudp", - "--target-host", - "cultmesh://odin/media/muninn-raven-av", - "--port", - "5204", - "--media-transport", - "rudp", - "--audio-source-id", - "wasapi-loopback:Speakers (Realtek(R) Audio)", - ] - .into_iter() - .map(String::from), - ) - .unwrap(), - ) - .unwrap(); - active.command_id = "active-command".to_string(); - active.state = "running".to_string(); - - let mut retry = build_capture_stream_command( - &Options::parse( - [ - "request-stream", - "--host", - "raven", - "--stream", - "muninn.raven.av.rudp", - "--target-host", - "cultmesh://odin/media/muninn-raven-av", - "--port", - "5204", - "--media-transport", - "rudp", - "--audio-source-id", - "wasapi-loopback:Speakers (Realtek(R) Audio)", - ] - .into_iter() - .map(String::from), - ) - .unwrap(), - ) - .unwrap(); - retry.command_id = "retry-command".to_string(); - - assert_eq!( - canonical_muninn_stream_id(&active.stream_id), - canonical_muninn_stream_id(&retry.stream_id) - ); - assert!(capture_stream_commands_start_equivalent(&active, &retry)); - } - - #[test] - fn latest_capture_stream_command_ids_canonicalizes_transport_specific_variants() { - let mut older = build_capture_stream_command( - &Options::parse( - [ - "request-stream", - "--host", - "raven", - "--stream", - "muninn.raven.av.rudp", - "--target-host", - "cultmesh://odin/media/muninn-raven-av", - "--port", - "5204", - "--media-transport", - "rudp", - ] - .into_iter() - .map(String::from), - ) - .unwrap(), - ) - .unwrap(); - older.command_id = "older-command".to_string(); - older.updated_at = "unix-1000".to_string(); - - let mut newer = older.clone(); - newer.stream_id = "muninn.raven.av.rudp".to_string(); - newer.command_id = "newer-command".to_string(); - newer.updated_at = "unix-2000".to_string(); - - let latest = latest_capture_stream_command_ids(&[older, newer], "raven"); - - assert_eq!(latest.len(), 1); - assert_eq!( - latest.get("muninn.raven.av").map(String::as_str), - Some("newer-command") - ); - } - - #[test] - fn media_target_contract_accepts_only_cultmesh_rudp() { - let options = Options::parse( - [ - "activate", - "--target-host", - "cultmesh://odin/media/muninn-raven-av", - "--media-transport", - "rudp", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - assert_eq!(options.media_transport, MediaTransport::Rudp); - assert!(require_media_target_uri(&options).is_ok()); - - let raw_target_options = Options::parse( - ["activate", "--target-host", "198.51.100.66"] - .into_iter() - .map(String::from), - ) - .unwrap(); - let error = require_media_target_uri(&raw_target_options) - .unwrap_err() - .to_string(); - assert!(error.contains("cultmesh://")); - } - - #[test] - fn builds_rudp_media_target_for_obs_plugin_activation() { - let options = Options::parse( - [ - "activate", - "--media-transport", - "rudp", - "--stream", - "muninn.raven.av.rudp", - "--target-host", - "cultmesh://odin/media/muninn-raven-av", - "--port", - "5204", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert_eq!(options.media_packet_bytes, MUNINN_RUDP_MEDIA_PACKET_BYTES); - let plan = build_mux_plan(&options, "test".to_string()); - - assert_eq!( - plan.targets, - vec!["cultmesh://odin/media/muninn-raven-av#muninn.raven.av.rudp"] - ); - assert!(!plan.command_line.contains("tee")); - } - - #[test] - fn build_capture_stream_command_marks_disabled_legs_explicitly() { - let video_only = build_capture_stream_command( - &Options::parse( - [ - "request-stream", - "--media-transport", - "rudp", - "--stream", - "muninn.raven.video.rudp", - "--no-audio", - "--target-host", - "cultmesh://odin/media/muninn-raven-av", - "--port", - "5204", - ] - .into_iter() - .map(String::from), - ) - .unwrap(), - ) - .unwrap(); - assert_eq!(video_only.video_source_id, "display:0"); - assert_eq!(video_only.audio_source_id, MUNINN_DISABLED_AUDIO_SOURCE_ID); - - let audio_only = build_capture_stream_command( - &Options::parse( - [ - "request-stream", - "--media-transport", - "rudp", - "--stream", - "muninn.raven.audio.rudp", - "--no-video", - "--target-host", - "cultmesh://odin/media/muninn-raven-av", - "--port", - "5204", - ] - .into_iter() - .map(String::from), - ) - .unwrap(), - ) - .unwrap(); - assert_eq!(audio_only.video_source_id, MUNINN_DISABLED_VIDEO_SOURCE_ID); - assert_eq!(audio_only.audio_source_id, "wasapi-loopback:Realtek"); - } - - #[test] - fn rudp_video_encoder_outputs_annex_b_h264_for_packetizer() { - let options = Options::parse( - ["activate", "--media-transport", "rudp", "--framerate", "60"] - .into_iter() - .map(String::from), - ) - .unwrap(); - - let args = rudp_video_ffmpeg_args(&options); - let profile = muninn_rudp_media_profile(); - - assert!( - args.windows(2) - .any(|pair| pair[0] == "-f" && pair[1] == "h264") - ); - assert_eq!(profile.profile_id, MUNINN_RUDP_MEDIA_PROFILE_ID); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-tune" && pair[1] == "ull") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-bf" && pair[1] == "0") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-rc" && pair[1] == "cbr") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-rc-lookahead" && pair[1] == "0") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-multipass" && pair[1] == "disabled") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-strict_gop" && pair[1] == "1") - ); - assert!(args.windows(2).any(|pair| pair[0] == "-ldkfs" - && pair[1] == MUNINN_RUDP_MEDIA_LOW_DELAY_KEY_FRAME_SCALE.to_string())); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-spatial_aq" && pair[1] == "1") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-temporal_aq" && pair[1] == "1") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-aq-strength" && pair[1] == "8") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-nonref_p" && pair[1] == "1") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-b_ref_mode" && pair[1] == "disabled") - ); - assert!(args.windows(2).any(|pair| pair[0] == "-dpb_size" - && pair[1] == MUNINN_RUDP_MEDIA_VIDEO_DPB_SIZE.to_string())); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-preset" && pair[1] == "p5") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-b:v" && pair[1] == "12000k") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-maxrate" && pair[1] == "12000k") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-bufsize" && pair[1] == "200k") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-g" && pair[1] == "15") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-keyint_min" && pair[1] == "15") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-forced-idr" && pair[1] == "1") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-aud" && pair[1] == "1") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-fps_mode" && pair[1] == "cfr") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-r" && pair[1] == "60") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-an" && pair[1] == "-c:v") - ); - assert_eq!(args.last().map(String::as_str), Some("pipe:1")); - } - - #[test] - fn rudp_video_encoder_uses_explicit_bitrate_budget() { - let options = Options::parse( - [ - "activate", - "--media-transport", - "rudp", - "--target-host", - "cultmesh://odin/media/muninn-raven-av", - "--framerate", - "30", - "--rudp-video-bitrate-kbps", - "8000", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - let args = rudp_video_ffmpeg_args(&options); - let profile = muninn_rudp_media_profile_for_options(&options); - - assert_eq!(profile.video_bitrate_kbps, 8000); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-b:v" && pair[1] == "8000k") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-maxrate" && pair[1] == "8000k") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-bufsize" && pair[1] == "267k") - ); - } - - #[test] - fn rudp_video_vbv_buffer_tracks_lan_burst_budget() { - let thirty_fps = Options::parse( - ["activate", "--media-transport", "rudp", "--framerate", "30"] - .into_iter() - .map(String::from), - ) - .unwrap(); - let sixty_fps = Options::parse( - ["activate", "--media-transport", "rudp", "--framerate", "60"] - .into_iter() - .map(String::from), - ) - .unwrap(); - let profile = muninn_rudp_media_profile(); - - assert_eq!( - muninn_rudp_video_vbv_buffer_arg(&thirty_fps, &profile), - "400k" - ); - assert_eq!( - muninn_rudp_video_vbv_buffer_arg(&sixty_fps, &profile), - "200k" - ); - } - - #[test] - fn rudp_video_gop_tracks_quarter_second_recovery_budget() { - let thirty_fps = Options::parse( - ["activate", "--media-transport", "rudp", "--framerate", "30"] - .into_iter() - .map(String::from), - ) - .unwrap(); - let sixty_fps = Options::parse( - ["activate", "--media-transport", "rudp", "--framerate", "60"] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert_eq!(muninn_rudp_video_gop_frames(&thirty_fps), 8); - assert_eq!(muninn_rudp_video_gop_frames(&sixty_fps), 15); - } - - #[test] - fn default_rudp_media_packet_size_keeps_late_typed_video_wire_under_udp_mtu() { - let mut access_unit = Vec::new(); - access_unit.extend_from_slice(&[0, 0, 0, 1, 0x65]); - access_unit.resize(MUNINN_RUDP_MEDIA_PACKET_BYTES, 0x80); - - let payloads = crate::media_packetizer::video_annex_b_stream_send_payloads( - crate::media_packetizer::VideoAnnexBStreamWireOptions { - packetize: crate::media_packetizer::VideoAnnexBStreamPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - first_frame_id: 1_000_000, - first_pts_ticks: 3_000_000_000, - frame_duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_delay_ticks: 1_800, - max_payload_bytes: MUNINN_RUDP_MEDIA_PACKET_BYTES, - }, - stored_at: "2026-06-18T00:00:00Z", - source_runtime_id: "muninn-test", - source_role: "media-test", - }, - &access_unit, - ) - .unwrap(); - - assert_eq!(payloads.len(), 1); - assert!( - payloads[0].payload.len() <= MUNINN_RUDP_MEDIA_MAX_FRAGMENT_BYTES, - "typed media payload was {} bytes", - payloads[0].payload.len() - ); - assert!( - payloads[0].payload.len() - + MUNINN_RUDP_FIXED_HEADER_BYTES - + crate::media_packetizer::MUNINN_MEDIA_RUDP_CHANNEL.len() - <= MUNINN_RUDP_IPV4_UDP_PAYLOAD_BYTES - ); - } - - #[test] - fn default_rudp_media_packet_size_keeps_chunked_video_wire_under_rudp_fragment_limit() { - let mut access_unit = Vec::new(); - access_unit.extend_from_slice(&[0, 0, 0, 1, 0x65]); - access_unit.resize(MUNINN_RUDP_MEDIA_PACKET_BYTES * 64, 0x80); - - let payloads = crate::media_packetizer::video_annex_b_stream_send_payloads( - crate::media_packetizer::VideoAnnexBStreamWireOptions { - packetize: crate::media_packetizer::VideoAnnexBStreamPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "raven:2026-06-19T14-56-23Z:video", - codec: "h264", - first_frame_id: 1_000_000, - first_pts_ticks: 3_000_000_000, - frame_duration_ticks: 1_500, - timebase_num: 1, - timebase_den: 90_000, - deadline_delay_ticks: 1_500, - max_payload_bytes: MUNINN_RUDP_MEDIA_PACKET_BYTES, - }, - stored_at: "2026-06-19T14:56:23Z", - source_runtime_id: "raven", - source_role: "muninn.rudp.video", - }, - &access_unit, - ) - .unwrap(); - - assert!(payloads.len() > 1); - let largest = payloads - .iter() - .map(|payload| payload.payload.len()) - .max() - .unwrap(); - assert!( - largest <= MUNINN_RUDP_MEDIA_MAX_FRAGMENT_BYTES, - "largest typed media payload was {largest} bytes" - ); - } - - #[test] - fn rudp_media_transport_options_follow_low_latency_profile() { - let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); - let endpoint: SocketAddr = "127.0.0.1:5204".parse().unwrap(); - let profile = muninn_rudp_media_profile(); - - let options = muninn_media_rudp_options( - socket, - endpoint, - &profile, - MUNINN_MEDIA_RUDP_CONNECTION_ID, - None, - ); - - assert_eq!(options.runtime_id, "muninn-media"); - assert_eq!(options.remote_addr, Some(endpoint)); - assert_eq!(options.connection_id, MUNINN_MEDIA_RUDP_CONNECTION_ID); - assert_eq!(options.resend_delay_ms, MUNINN_RUDP_MEDIA_RESEND_DELAY_MS); - assert_eq!( - options.max_fragment_bytes, - Some(MUNINN_RUDP_MEDIA_MAX_FRAGMENT_BYTES as u32) - ); - assert_eq!(options.media_reliable_expire_after_ms, None); - let transport = CultNetRudpSocketTransportConnection::new(options).unwrap(); - let channels = transport - .profile - .transports - .first() - .unwrap() - .channels - .iter(); - let media_channel = channels - .clone() - .find(|channel| channel.channel_id == "media") - .unwrap(); - assert_eq!( - media_channel.delivery, - cultnet_rs::CultNetTransportDelivery::Unreliable - ); - assert_eq!(media_channel.reliable_expire_after_ms, None); - } - - #[test] - fn rudp_audio_transport_uses_separate_reliable_media_connection() { - let socket = UdpSocket::bind("127.0.0.1:0").unwrap(); - let endpoint: SocketAddr = "127.0.0.1:5204".parse().unwrap(); - let profile = muninn_rudp_media_profile(); - - let options = muninn_media_rudp_options( - socket, - endpoint, - &profile, - MUNINN_AUDIO_RUDP_CONNECTION_ID, - Some(MUNINN_RUDP_MEDIA_RECEIVER_ASSEMBLY_DEADLINE_MS), - ); - - assert_eq!(options.connection_id, MUNINN_AUDIO_RUDP_CONNECTION_ID); - assert_eq!( - options.media_reliable_expire_after_ms, - Some(MUNINN_RUDP_MEDIA_RECEIVER_ASSEMBLY_DEADLINE_MS) - ); - } - - #[test] - fn rudp_audio_encoder_outputs_pcm_for_packetizer() { - let options = Options::parse( - [ - "activate", - "--media-transport", - "rudp", - "--audio-sample-rate", - "48000", - "--audio-channels", - "2", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - let args = rudp_audio_ffmpeg_args(&options); - - assert!( - args.windows(2) - .any(|pair| pair[0] == "-f" && pair[1] == "f32le") - ); - assert!( - args.windows(2) - .any(|pair| pair[0] == "-vn" && pair[1] == "-ar") - ); - assert_eq!(args.last().map(String::as_str), Some("pipe:1")); - } - - #[test] - fn media_payload_queue_deadline_is_strictly_bounded() { - let queued_at = Instant::now(); - let deadline = Duration::from_millis(muninn_rudp_media_profile().sender_queue_deadline_ms); - - assert!(!media_payload_queue_age_exceeded( - queued_at, - queued_at + deadline, - deadline - )); - assert!(media_payload_queue_age_exceeded( - queued_at, - queued_at + deadline + Duration::from_millis(1), - deadline - )); - } - - #[test] - fn rudp_media_sender_prioritizes_pending_audio_over_video() { - let queued_at = Instant::now(); - let mut pending = PendingMuninnMediaSendQueues::default(); - pending.push(QueuedMuninnMediaSendPayload { - payload: MuninnMediaSendPayload { - channel_id: crate::media_packetizer::MUNINN_MEDIA_RUDP_CHANNEL, - payload: vec![0x76], - }, - queued_at, - kind: QueuedMuninnMediaKind::Video, - }); - pending.push(QueuedMuninnMediaSendPayload { - payload: MuninnMediaSendPayload { - channel_id: crate::media_packetizer::MUNINN_MEDIA_RUDP_CHANNEL, - payload: vec![0x61], - }, - queued_at, - kind: QueuedMuninnMediaKind::Audio, - }); - - let first = pending.pop_next().expect("audio payload should be queued"); - let second = pending - .pop_next() - .expect("video payload should remain queued"); - - assert_eq!(first.kind, QueuedMuninnMediaKind::Audio); - assert_eq!(first.payload.payload, vec![0x61]); - assert_eq!(second.kind, QueuedMuninnMediaKind::Video); - assert_eq!(second.payload.payload, vec![0x76]); - assert!(pending.is_empty()); - } - - #[test] - fn rudp_latency_budget_owns_sender_and_record_deadlines() { - let options = Options::parse( - [ - "activate", - "--media-transport", - "rudp", - "--target-host", - "cultmesh://odin/media/muninn-raven-av", - "--framerate", - "30", - "--audio-sample-rate", - "48000", - "--rudp-latency-budget-ms", - "2000", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let profile = muninn_rudp_media_profile_for_options(&options); - - assert_eq!(profile.sender_queue_deadline_ms, 2000); - assert_eq!(profile.sender_reliable_expire_after_ms, 2000); - assert_eq!( - profile.sender_pace_every_payloads, - MUNINN_RUDP_MEDIA_SEND_PACE_EVERY_PAYLOADS - ); - assert_eq!( - profile.sender_pace_sleep_us, - MUNINN_RUDP_MEDIA_SEND_PACE_SLEEP_US - ); - assert_eq!(profile.receiver_assembly_deadline_ms, 2000); - assert_eq!(rudp_media_deadline_delay_ticks(&profile), 180_000); - assert_eq!(rudp_audio_deadline_delay_ticks(&options, &profile), 96_000); - assert_eq!( - rudp_endpoint_for_options(&options), - "cultmesh://odin/media/muninn-raven-av#muninn.raven.av.rudp" - ); - } - - #[test] - fn rudp_media_progress_detail_reports_queue_and_transport_pressure() { - let receiver_feedback = MuninnRudpReceiverFeedbackStats { - feedback_records: 2, - requested_keyframes: 1, - late_frames: 3, - missing_video_chunks: 4, - repaired_video_chunks: 0, - deferred_repair_chunks: 5, - repair_chunks_per_second: 64, - highest_decodable_frame_id: Some(88), - }; - - assert_eq!( - rudp_media_progress_detail(120, 3, 2, 1, 9, &receiver_feedback), - "Muninn RUDP media progress: sent=120 queue_dropped=3 queue_expired=2 send_expired=1 reliable_expired=9 receiver_feedback=2 receiver_keyframes=1 receiver_late_frames=3 receiver_missing_chunks=4 receiver_repaired_chunks=0 receiver_deferred_repairs=5 repair_rate=64 receiver_highest_decodable=88" - ); - } - - #[test] - fn rudp_repair_budget_backs_off_on_media_drops_and_recovers_when_stable() { - let start = Instant::now(); - let mut budget = MuninnRudpRepairBudget { - chunks_per_second: 64, - min_chunks_per_second: 8, - max_chunks_per_second: 128, - add_chunks_per_second: 8, - recovery_interval: Duration::from_secs(2), - max_available_chunks: 4, - available_chunks: 4, - last_refill_at: start, - last_rate_adjust_at: start, - last_queue_dropped: 0, - }; - - assert_eq!(budget.take(4, start, 0), 4); - assert_eq!(budget.chunks_per_second(), 64); - assert_eq!(budget.take(3, start + Duration::from_millis(10), 1), 0); - assert_eq!(budget.chunks_per_second(), 32); - assert_eq!(budget.take(3, start + Duration::from_secs(1), 1), 3); - assert_eq!(budget.chunks_per_second(), 32); - assert_eq!(budget.take(3, start + Duration::from_secs(3), 1), 3); - assert_eq!(budget.chunks_per_second(), 40); - } - - #[test] - fn default_rudp_repair_budget_has_lan_stream_headroom() { - let mut budget = MuninnRudpRepairBudget::new( - MUNINN_RUDP_MEDIA_REPAIR_INITIAL_CHUNKS_PER_SECOND, - MUNINN_RUDP_MEDIA_REPAIR_BURST_CHUNKS, - ); - let start = budget.last_refill_at; - - assert_eq!(budget.chunks_per_second(), 4_096); - assert_eq!(budget.take(2_048, start, 0), 2_048); - assert_eq!(budget.take(8_192, start + Duration::from_secs(1), 0), 2_048); - assert_eq!(budget.take(8_192, start + Duration::from_secs(3), 0), 2_048); - assert_eq!(budget.chunks_per_second(), 6_144); - assert_eq!(budget.take(512, start + Duration::from_secs(4), 1), 512); - assert_eq!(budget.chunks_per_second(), 3_072); - } - - #[test] - fn receiver_feedback_keyframe_requests_are_recorded_without_encoder_restart() { - let mut handled = 0; - let mut receiver_feedback = MuninnRudpReceiverFeedbackStats::default(); - - record_receiver_keyframe_pressure(&receiver_feedback, &mut handled); - assert_eq!(handled, 0); - - receiver_feedback.requested_keyframes = 1; - record_receiver_keyframe_pressure(&receiver_feedback, &mut handled); - assert_eq!(handled, 1); - record_receiver_keyframe_pressure(&receiver_feedback, &mut handled); - assert_eq!(handled, 1); - - receiver_feedback.requested_keyframes = 2; - record_receiver_keyframe_pressure(&receiver_feedback, &mut handled); - assert_eq!(handled, 2); - } - - #[test] - fn rudp_media_receiver_feedback_updates_sender_pressure_stats() { - let feedback = crate::media_packetizer::build_receiver_feedback( - crate::media_packetizer::ReceiverFeedbackOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "raven:session:video", - receiver_id: "starfire.obs", - highest_decodable_frame_id: Some(41), - missing_frame_ids: Vec::new(), - missing_video_chunk_keys: vec!["42:1".to_string(), "42:3".to_string()], - late_frame_ids: vec![42, 43], - requested_keyframe: true, - jitter_us: 500, - decode_queue_us: 2_000, - observed_at: "unix:1000", - }, - ) - .unwrap(); - let payload = crate::media_packetizer::encode_media_wire_record( - &crate::media_packetizer::MuninnMediaWireRecord::Feedback(feedback), - "unix:1000", - "starfire", - "mimir.obs", - ) - .unwrap(); - let frame = CultNetTransportFrame { - channel_id: crate::media_packetizer::MUNINN_MEDIA_RUDP_CHANNEL.to_string(), - payload, - }; - let mut stats = MuninnRudpReceiverFeedbackStats::default(); - let repair_cache = RecentVideoChunkRepairCache::new(16); - - let repairs = - record_rudp_media_receiver_feedback(&frame, &mut stats, &repair_cache).unwrap(); - - assert_eq!(stats.feedback_records, 1); - assert_eq!(stats.requested_keyframes, 1); - assert_eq!(stats.late_frames, 2); - assert_eq!(stats.missing_video_chunks, 2); - assert_eq!(stats.repaired_video_chunks, 0); - assert_eq!(stats.highest_decodable_frame_id, Some(41)); - assert!(repairs.is_empty()); - } - - #[test] - fn repair_cache_returns_recent_missing_video_chunks_from_feedback() { - let video = odin_core::MuninnMediaVideoAccessUnitRecord { - stream_id: "muninn.raven.av.rudp".to_string(), - session_id: "raven:session:video".to_string(), - frame_id: 42, - codec: "h264".to_string(), - pts_ticks: 126_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - keyframe: false, - dependency_frame_id: Some(41), - deadline_ticks: 127_800, - chunk_index: 3, - chunk_count: 5, - payload: vec![1, 2, 3, 4], - }; - let payload = MuninnMediaSendPayload { - channel_id: crate::media_packetizer::MUNINN_MEDIA_RUDP_CHANNEL, - payload: crate::media_packetizer::encode_media_wire_record( - &crate::media_packetizer::MuninnMediaWireRecord::Video(video), - "unix:1000", - "muninn-test", - "repair-cache-test", - ) - .unwrap(), - }; - let feedback = crate::media_packetizer::build_receiver_feedback( - crate::media_packetizer::ReceiverFeedbackOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "raven:session:video", - receiver_id: "starfire.obs", - highest_decodable_frame_id: Some(41), - missing_frame_ids: Vec::new(), - missing_video_chunk_keys: vec!["42:3".to_string(), "42:4".to_string()], - late_frame_ids: vec![42], - requested_keyframe: true, - jitter_us: 500, - decode_queue_us: 2_000, - observed_at: "unix:1000", - }, - ) - .unwrap(); - - let mut cache = RecentVideoChunkRepairCache::new(16); - cache.remember(&payload).unwrap(); - let repairs = cache.repair_payloads_for_feedback(&feedback); - - assert_eq!(repairs, vec![payload]); - } - - #[test] - fn idle_surface_advertises_affordances_without_active_streams() { - let options = Options::parse([].into_iter()).unwrap(); - let sources = available_sources(&options); - - assert!(sources.iter().any(|source| source.starts_with("screen:"))); - assert!( - sources - .iter() - .any(|source| source.starts_with("audio-loopback:")) - ); - } - - #[test] - fn muninn_eve_surface_exposes_live_move_hue_program_controls() { - let store_path = std::env::temp_dir().join(format!( - "muninn-move-hue-eve-{}.cc", - timestamp_ns().unwrap() - )); - let options = Options::parse( - [ - "serve", - "--store", - store_path.to_str().unwrap(), - "--host", - "nightwing", - "--odin-cultmesh-uri", - "none", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let _ = load_or_initialize_move_hue_program(&options).unwrap(); - let mut node = open_node(&options, "muninn-move-hue-eve-test").unwrap(); - publish_surface(&mut node, &options, "idle", &[]).unwrap(); - let surface = node - .get_required::(&move_hue_surface_key("nightwing")) - .unwrap(); - let encoded = surface.surface.to_string(); - assert!(encoded.contains("Hold Current Colors")); - assert!(encoded.contains("golden-permutation")); - assert!(encoded.contains("Transition Duration")); - assert!(encoded.contains("transitionPercent")); - assert!(encoded.contains(MUNINN_MOVE_HUE_PROGRAM_SCHEMA)); - drop(node); - let _ = fs::remove_file(store_path); - } - - #[test] - fn move_hue_program_command_updates_typed_live_state() { - let store_path = std::env::temp_dir().join(format!( - "muninn-move-hue-command-{}.cc", - timestamp_ns().unwrap() - )); - let options = Options::parse( - [ - "set-move-hue-program", - "--store", - store_path.to_str().unwrap(), - "--host", - "nightwing", - "--move-hue-mode", - "hold", - "--move-hue-order", - "bounce", - "--move-hue-cycle-ms", - "1500", - "--odin-cultmesh-uri", - "none", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - set_move_hue_program(options.clone()).unwrap(); - let node = open_node(&options, "muninn-move-hue-command-test").unwrap(); - let program = node - .get_required::(&move_hue_program_key("nightwing")) - .unwrap(); - assert_eq!(program.mode, "hold"); - assert_eq!(program.order_mode, "bounce"); - assert_eq!(program.cycle_ms, 1500); - assert!(program.hold_at_ns > 0); - drop(node); - let _ = fs::remove_file(store_path); - } - - #[test] - fn provider_command_rudp_ingress_applies_move_hue_command() { - let store_path = std::env::temp_dir().join(format!( - "muninn-provider-command-{}.cc", - timestamp_ns().unwrap() - )); - let options = Options::parse( - [ - "serve", - "--store", - store_path.to_str().unwrap(), - "--host", - "nightwing", - "--odin-cultmesh-uri", - "none", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let runtime_program = load_or_initialize_move_hue_program(&options).unwrap(); - let server_socket = UdpSocket::bind("127.0.0.1:0").unwrap(); - server_socket - .set_read_timeout(Some(Duration::from_millis(5))) - .unwrap(); - let server_address = server_socket.local_addr().unwrap(); - let server_options = options.clone(); - let server_program = Arc::clone(&runtime_program); - thread::spawn(move || { - run_provider_command_ingress(server_socket, server_options, server_program).unwrap() - }); - - let client_socket = UdpSocket::bind("127.0.0.1:0").unwrap(); - client_socket - .set_read_timeout(Some(Duration::from_millis(5))) - .unwrap(); - let mut client = - CultNetRudpSocketTransportConnection::new(CultNetRudpSocketTransportOptions { - runtime_id: "muninn-provider-command-test-client".to_string(), - socket: client_socket, - mode: cultnet_rs::CultNetRudpSocketMode::Client, - remote_addr: Some(server_address), - connection_id: MUNINN_COMMAND_RUDP_CONNECTION_ID, - initial_sequence: 1, - resend_delay_ms: 15, - transport_id: Some("muninn-provider-command-test".to_string()), - max_payload_bytes: None, - max_fragment_bytes: Some(1200), - max_pending_reliable_packets: Some(64), - media_reliable_expire_after_ms: None, - reconnect_policy: None, - }) - .unwrap(); - client.connect(Vec::new()).unwrap(); - let deadline = Instant::now() + Duration::from_secs(2); - while !client.connected() && Instant::now() < deadline { - let _ = client.receive_once().unwrap(); - client.poll_resends().unwrap(); - thread::sleep(Duration::from_millis(1)); - } - assert!(client.connected(), "provider command RUDP handshake"); - let command = json!({ - "type": "surface-command", - "schema": "gamecult.eve.command.v1", - "providerId": "muninn.telemetry.nightwing", - "command": "muninn.set-move-hue-program", - "payload": { - "type": "muninn.set-move-hue-program", - "mode": "hold", - "orderMode": "bounce", - "cycleMs": 1500, - "transitionPercent": 25 - }, - "publishedBy": "provider-command-test" - }); - let message = CultNetMessage::DocumentPutRaw { - message_id: "provider-command-test".to_string(), - document: CultNetRawDocumentRecord { - schema_id: "gamecult.eve.command.v1".to_string(), - record_key: "provider-command-test".to_string(), - stored_at: timestamp().unwrap(), - payload_encoding: CultNetRawPayloadEncoding::Messagepack, - payload: rmp_serde::to_vec_named(&command).unwrap(), - source_runtime_id: Some("provider-command-test".to_string()), - source_agent_id: None, - source_role: Some("test".to_string()), - tags: None, - }, - }; - client - .send( - "schema", - encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0) - .unwrap(), - ) - .unwrap(); - let apply_deadline = Instant::now() + Duration::from_secs(2); - while runtime_program.lock().unwrap().mode != "hold" && Instant::now() < apply_deadline { - client.poll_resends().unwrap(); - thread::sleep(Duration::from_millis(2)); - } - assert_eq!(runtime_program.lock().unwrap().mode, "hold"); - assert_eq!(runtime_program.lock().unwrap().order_mode, "bounce"); - assert_eq!(runtime_program.lock().unwrap().cycle_ms, 1500); - assert_eq!(runtime_program.lock().unwrap().transition_percent, 25); - let _ = fs::remove_file(store_path); - } - - #[test] - fn move_light_command_writes_ps_move_led_report() { - let command = pending_move_light_command(); - let mut writer = RecordingMoveLightWriter::default(); - - let result = execute_move_light_command(command, &mut writer).unwrap(); - - assert_eq!(result.state, "completed"); - assert_eq!(writer.writes.len(), 1); - assert_eq!(writer.writes[0].0, "/dev/hidraw3"); - assert_eq!(writer.writes[0].1.len(), PS_MOVE_LED_REPORT_LEN); - assert_eq!(&writer.writes[0].1[..5], &[0x06, 0, 255, 64, 8]); - assert!(writer.writes[0].1[5..].iter().all(|byte| *byte == 0)); - } - - #[test] - fn default_move_light_report_holds_full_identity_color() { - let full = default_move_light_report((100, 80, 60)); - assert_eq!(full.len(), PS_MOVE_LED_REPORT_LEN); - assert_eq!(&full[..5], &[0x06, 0, 100, 80, 60]); - assert!(full[5..].iter().all(|byte| *byte == 0)); - } - - #[test] - fn default_move_color_is_stable_for_physical_identity() { - assert_eq!( - default_move_color_for_identity("move-0006f523e2d1"), - default_move_color_for_identity("move-0006f523e2d1") - ); - assert_ne!( - default_move_color_for_identity("move-0006f523e2d1"), - default_move_color_for_identity("move-000704a6be5f") - ); - let roster = [ - "move-0006f523e2d1", - "move-000704a39772", - "move-000704a6be5f", - "move-000704a800d0", - ]; - let colors = roster.map(default_move_color_for_identity); - assert_eq!( - colors.into_iter().collect::>().len(), - roster.len() - ); - assert!( - colors - .into_iter() - .all(|color| color.0.max(color.1).max(color.2) == 255) - ); - } - - #[test] - fn golden_move_roster_spreads_neighboring_stable_ids_across_hue_space() { - let roster = vec![ - "move-0006f523e2d1".to_string(), - "move-000704a39772".to_string(), - "move-000704a6be5f".to_string(), - "move-000704a800d0".to_string(), - ]; - let colors = roster - .iter() - .map(|identity| golden_move_color_for_roster(identity, &roster)) - .collect::>(); - assert_eq!(colors.iter().copied().collect::>().len(), 4); - assert!( - colors - .iter() - .all(|color| color.0.max(color.1).max(color.2) == 255) - ); - assert!(colors.iter().any(|color| color.0 == 255 && color.1 < 100)); - assert!(colors.iter().any(|color| color.1 == 255 && color.0 < 100)); - assert!(colors.iter().any(|color| color.2 == 255 && color.0 < 100)); - } - - #[test] - fn scheduled_golden_colors_shift_window_in_descending_move_order() { - let roster = (0..4) - .map(|index| format!("move-{index}")) - .collect::>(); - let state = |millis: i128| { - roster - .iter() - .map(|identity| { - scheduled_golden_move_color( - identity, - &roster, - 0, - 1_000_000_000, - millis * 1_000_000, - ) - .map(|(_, sequence, next)| (sequence, next)) - .unwrap() - }) - .collect::>() - }; - - assert_eq!( - state(0), - vec![(0, false), (1, false), (2, false), (3, true)] - ); - assert_eq!( - state(249), - vec![(0, false), (1, false), (2, false), (3, true)] - ); - assert_eq!( - state(250), - vec![(0, false), (1, false), (2, true), (4, false)] - ); - assert_eq!( - state(500), - vec![(0, false), (1, true), (3, false), (4, false)] - ); - assert_eq!( - state(750), - vec![(0, true), (2, false), (3, false), (4, false)] - ); - assert_eq!( - state(1_000), - vec![(1, false), (2, false), (3, false), (4, true)] - ); - } - - #[test] - fn scheduled_golden_color_smoothersteps_across_the_full_subslot() { - let roster = (0..4) - .map(|index| format!("move-{index}")) - .collect::>(); - let color_at = |millis: i128| { - scheduled_golden_move_color("move-3", &roster, 0, 1_000_000_000, millis * 1_000_000) - .unwrap() - .0 - }; - - assert_eq!( - color_at(0), - hsv_to_rgb( - (3.0 * 0.618_033_988_749_894_9_f64).fract() * 360.0, - 1.0, - 1.0 - ) - ); - assert_eq!( - color_at(125), - hsv_to_rgb( - wrapped_hue_lerp( - (3.0 * 0.618_033_988_749_894_9_f64).fract(), - (4.0 * 0.618_033_988_749_894_9_f64).fract(), - 0.5, - ) * 360.0, - 1.0, - 1.0 - ) - ); - assert_eq!( - color_at(250), - hsv_to_rgb( - (4.0 * 0.618_033_988_749_894_9_f64).fract() * 360.0, - 1.0, - 1.0 - ) - ); - } - - #[test] - fn scheduled_golden_color_transition_uses_selected_subslot_percentage() { - let roster = (0..4) - .map(|index| format!("move-{index}")) - .collect::>(); - let color_at = |millis: i128| { - scheduled_golden_move_color_with_order( - "move-3", - &roster, - 0, - 1_000_000_000, - millis * 1_000_000, - "descending", - 25, - ) - .unwrap() - .0 - }; - let source = (3.0 * 0.618_033_988_749_894_9_f64).fract(); - let target = (4.0 * 0.618_033_988_749_894_9_f64).fract(); - assert_ne!(color_at(31), hsv_to_rgb(source * 360.0, 1.0, 1.0)); - assert_ne!(color_at(31), hsv_to_rgb(target * 360.0, 1.0, 1.0)); - assert_eq!(color_at(63), hsv_to_rgb(target * 360.0, 1.0, 1.0)); - assert_eq!(color_at(200), hsv_to_rgb(target * 360.0, 1.0, 1.0)); - } - - #[test] - fn zero_percent_transition_hard_steps_at_subslot_boundary() { - let roster = (0..4) - .map(|index| format!("move-{index}")) - .collect::>(); - let color = scheduled_golden_move_color_with_order( - "move-3", - &roster, - 0, - 1_000_000_000, - 0, - "descending", - 0, - ) - .unwrap() - .0; - let target = (4.0 * 0.618_033_988_749_894_9_f64).fract(); - assert_eq!(color, hsv_to_rgb(target * 360.0, 1.0, 1.0)); - } - - #[test] - fn hue_update_order_modes_are_deterministic_per_cycle() { - assert_eq!(move_hue_update_order(4, 0, "descending"), vec![3, 2, 1, 0]); - assert_eq!(move_hue_update_order(4, 0, "ascending"), vec![0, 1, 2, 3]); - assert_eq!(move_hue_update_order(4, 0, "bounce"), vec![3, 2, 1, 0]); - assert_eq!(move_hue_update_order(4, 1, "bounce"), vec![0, 1, 2, 3]); - assert_eq!( - move_hue_update_order(4, 0, "rotating-lead"), - vec![3, 2, 1, 0] - ); - assert_eq!( - move_hue_update_order(4, 1, "rotating-lead"), - vec![2, 1, 0, 3] - ); - let first = move_hue_update_order(4, 23, "golden-permutation"); - assert_eq!(first, move_hue_update_order(4, 23, "golden-permutation")); - assert_eq!(first.iter().copied().collect::>().len(), 4); - } - - #[test] - fn move_light_command_rejects_duration_shape_mismatch() { - let command = MuninnMoveLightCommandRecord { - colors: vec!["#ff0000".to_string(), "#00ff00".to_string()], - durations_ms: vec![0], - ..pending_move_light_command() - }; - let mut writer = RecordingMoveLightWriter::default(); - - let result = execute_move_light_command(command, &mut writer).unwrap(); - - assert_eq!(result.state, "failed"); - assert!(result.detail.contains("durations_ms")); - assert!(writer.writes.is_empty()); - } - - #[test] - fn move_light_command_rejects_invalid_color() { - let command = MuninnMoveLightCommandRecord { - colors: vec!["blue".to_string()], - ..pending_move_light_command() - }; - let mut writer = RecordingMoveLightWriter::default(); - - let result = execute_move_light_command(command, &mut writer).unwrap(); - - assert_eq!(result.state, "failed"); - assert!(result.detail.contains("expected #rrggbb")); - assert!(writer.writes.is_empty()); - } - - #[test] - fn request_move_light_builds_pending_typed_command() { - let options = Options::parse( - [ - "request-move-light", - "--host", - "nightwing", - "--move", - "move-usb", - "--hidraw", - "/dev/hidraw1", - "--color", - "#35ff6c", - "--duration-ms", - "0", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - let command = build_move_light_command(&options).unwrap(); - - assert_eq!(command.host_id, "nightwing"); - assert_eq!(command.move_id, "move-usb"); - assert_eq!(command.hidraw_path, "/dev/hidraw1"); - assert_eq!(command.colors, vec!["#35ff6c"]); - assert_eq!(command.durations_ms, vec![0]); - assert_eq!(command.state, "pending"); - } - - #[test] - fn move_light_status_accepts_command_filter() { - let options = Options::parse( - [ - "move-light-status", - "--host", - "nightwing", - "--command", - "cmd-1", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert_eq!(options.mode, Mode::MoveLightStatus); - assert_eq!(options.host_id, "nightwing"); - assert_eq!(options.command_id.as_deref(), Some("cmd-1")); - } - - #[test] - fn capture_stream_status_accepts_stream_and_command_filters() { - let options = Options::parse( - [ - "capture-stream-status", - "--host", - "raven", - "--stream", - "muninn.probe.video.rudp", - "--command", - "cmd-1", - "--activate-store", - "C:/Meta/Odin/state/muninn.activate.cc", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert_eq!(options.mode, Mode::CaptureStreamStatus); - assert_eq!(options.host_id, "raven"); - assert_eq!(options.stream_id, "muninn.probe.video.rudp"); - assert!(options.stream_filter_explicit); - assert_eq!(options.command_id.as_deref(), Some("cmd-1")); - assert_eq!( - options.activation_store_path, - Some(PathBuf::from("C:/Meta/Odin/state/muninn.activate.cc")) - ); - } - - #[test] - fn capture_stream_status_uses_default_activation_store_path() { - let options = Options::parse( - ["capture-stream-status", "--host", "raven"] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert_eq!( - options.activation_store_path, - Some(PathBuf::from(MUNINN_DEFAULT_ACTIVATION_STORE_PATH)) - ); - } - - #[test] - fn capture_stream_status_does_not_filter_on_default_stream_id() { - let options = Options::parse( - ["capture-stream-status", "--host", "raven"] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert_eq!(options.mode, Mode::CaptureStreamStatus); - assert_eq!(options.host_id, "raven"); - assert_eq!(options.stream_id, "muninn.raven.av.rudp"); - assert!(!options.stream_filter_explicit); - } - - #[test] - fn tick_capture_stream_commands_supersedes_older_nonterminal_receipts() { - let store_path = std::env::temp_dir().join(format!( - "muninn-capture-command-supersede-{}.cc", - timestamp_ns().unwrap() - )); - let options = Options::parse( - [ - "serve", - "--host", - "raven", - "--store", - store_path.to_str().unwrap(), - "--activate-store", - store_path.to_str().unwrap(), - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let mut older = build_capture_stream_command( - &Options::parse( - [ - "request-stream", - "--host", - "raven", - "--stream", - "muninn.probe.video.rudp", - "--target-host", - "cultmesh://odin/media/muninn-raven-av", - ] - .into_iter() - .map(String::from), - ) - .unwrap(), - ) - .unwrap(); - older.command_id = "older-command".to_string(); - older.updated_at = "unix-1000".to_string(); - older.state = "pending".to_string(); - - let mut newer = older.clone(); - newer.command_id = "newer-command".to_string(); - newer.updated_at = "unix-2000".to_string(); - newer.state = "completed".to_string(); - newer.detail = "already handled elsewhere".to_string(); - - let mut node = open_node(&options, "muninn-capture-command-supersede-test").unwrap(); - node.put(&older.command_id, &older).unwrap(); - node.put(&newer.command_id, &newer).unwrap(); - drop(node); - - let active = &mut Vec::new(); - let active_stream_ids = tick_capture_stream_commands(&options, active).unwrap(); - - assert!(active_stream_ids.is_empty()); - - let node = open_node(&options, "muninn-capture-command-supersede-status").unwrap(); - let superseded = node - .get_required::("older-command") - .unwrap(); - let latest = node - .get_required::("newer-command") - .unwrap(); - assert_eq!(superseded.state, "completed"); - assert_eq!( - superseded.detail, - "Superseded by newer command newer-command." - ); - assert_eq!(latest.state, "completed"); - assert_eq!(latest.detail, "already handled elsewhere"); - let _ = fs::remove_file(store_path); - } - - #[test] - fn move_state_status_accepts_move_filter() { - let options = Options::parse( - [ - "move-state-status", - "--host", - "nightwing", - "--move", - "move-usb", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert_eq!(options.mode, Mode::MoveStateStatus); - assert_eq!(options.host_id, "nightwing"); - assert_eq!(options.move_id, "move-usb"); - } - - #[test] - fn move_source_status_is_a_read_only_diagnostic_mode() { - let options = Options::parse( - ["move-source-status", "--host", "starfire"] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert_eq!(options.mode, Mode::MoveSourceStatus); - assert_eq!(options.host_id, "starfire"); - } - - #[test] - fn move_identity_status_accepts_move_filter() { - let options = Options::parse( - [ - "move-identity-status", - "--host", - "starfire", - "--move", - "move-0006f523e2d1", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert_eq!(options.mode, Mode::MoveIdentityStatus); - assert_eq!(options.host_id, "starfire"); - assert_eq!(options.move_id, "move-0006f523e2d1"); - } - - #[test] - fn claim_move_host_accepts_target_bluetooth_address() { - let options = Options::parse( - ["claim-move-host", "--move-host", "5C:93:A2:9C:A8:A8"] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert_eq!(options.mode, Mode::ClaimMoveHost); - assert_eq!( - options.move_host_address.as_deref(), - Some("5C:93:A2:9C:A8:A8") - ); - } - - #[test] - fn serve_only_claims_move_host_when_explicitly_configured() { - let implicit = - Options::parse(["serve", "--host", "raven"].into_iter().map(String::from)).unwrap(); - let explicit = Options::parse( - [ - "serve", - "--host", - "starfire", - "--move-host", - "5C:93:A2:9C:A8:A8", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert!(!serve_should_claim_move_host(&implicit)); - assert!(serve_should_claim_move_host(&explicit)); - } - - #[test] - fn serve_only_manages_platform_move_lights_when_move_runtime_is_explicit() { - let plain = - Options::parse(["serve", "--host", "raven"].into_iter().map(String::from)).unwrap(); - let move_host = Options::parse( - [ - "serve", - "--host", - "starfire", - "--move-host", - "5C:93:A2:9C:A8:A8", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let move_state = Options::parse( - [ - "serve", - "--host", - "starfire", - "--move-state", - "move-usb=windows-psmove", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert!(!serve_should_manage_move_runtime(&plain)); - assert!(serve_should_manage_move_runtime(&move_host)); - assert!(serve_should_manage_move_runtime(&move_state)); - assert!(!serve_should_manage_platform_move_lights(&plain)); - assert!(serve_should_manage_platform_move_lights(&move_host)); - assert!(serve_should_manage_platform_move_lights(&move_state)); - } - - #[test] - fn parses_bluetoothctl_motion_controller_device_line() { - assert_eq!( - parse_bluetoothctl_motion_controller_device_line( - "Device 00:07:04:A8:00:D0 Motion Controller" - ) - .as_deref(), - Some("00:07:04:A8:00:D0") - ); - assert!( - parse_bluetoothctl_motion_controller_device_line("Device 00:11:22:33:44:55 Keyboard") - .is_none() - ); - } - - #[test] - fn parses_bluetoothctl_motion_controller_flags() { - let info = "\ -Device 00:07:04:A8:00:D0 (public) -\tTrusted: yes -\tConnected: no -"; - let device = parse_bluetoothctl_device_info("00:07:04:A8:00:D0", info); - - assert_eq!(device.address, "00:07:04:A8:00:D0"); - assert!(device.trusted); - assert!(!device.connected); - } - - #[test] - fn bluetooth_address_maps_to_move_id() { - assert_eq!( - bluetooth_address_move_id("00:07:04:A8:00:D0").as_deref(), - Some("move-000704a800d0") - ); - } - - #[test] - fn bluetooth_move_identity_record_describes_waiting_pickup() { - let options = Options::parse( - ["serve", "--host", "nightwing"] - .into_iter() - .map(String::from), - ) - .unwrap(); - let device = BluetoothMoveDevice { - address: "00:07:04:A8:00:D0".to_string(), - trusted: true, - connected: false, - }; - - let record = build_bluetooth_move_identity_record( - &options, - &device, - "5C:93:A2:9C:A8:A8".to_string(), - "unix-1781667000".to_string(), - ) - .unwrap(); - - assert_eq!( - record.identity_id, - "nightwing:move-000704a800d0:move-identity" - ); - assert_eq!(record.source_path, "bluetooth:00:07:04:A8:00:D0"); - assert_eq!(record.bluetooth_host_address, "5C:93:A2:9C:A8:A8"); - assert_eq!(record.state, "bluetooth-waiting"); - assert!(bluetooth_move_needs_pickup(&device)); - } - - #[test] - fn bluetooth_pickup_uses_bluez_state_not_stale_usb_sources() { - let disconnected_trusted = BluetoothMoveDevice { - address: "00:07:04:A8:00:D0".to_string(), - trusted: true, - connected: false, - }; - let connected = BluetoothMoveDevice { - connected: true, - ..disconnected_trusted.clone() - }; - let untrusted = BluetoothMoveDevice { - trusted: false, - ..disconnected_trusted.clone() - }; - - assert!(bluetooth_move_needs_pickup(&disconnected_trusted)); - assert!(!bluetooth_move_needs_pickup(&connected)); - assert!(!bluetooth_move_needs_pickup(&untrusted)); - } - - #[cfg(windows)] - #[test] - fn windows_ps_move_source_token_preserves_concrete_hid_path() { - let path = r"\\?\hid#vid_054c&pid_03d5&col01#example#{guid}"; - let token = windows_ps_move_source_token(path); - - assert!(is_windows_ps_move_source(&token)); - assert_eq!( - windows_ps_move_input_path(&token).unwrap().as_deref(), - Some(path) - ); - } - - #[cfg(windows)] - #[test] - fn windows_ps_move_physical_key_groups_sibling_collections() { - let col01 = r"\\?\hid#vid_054c&pid_03d5&col01#a&976df89&0&0000#{guid}"; - let col02 = r"\\?\hid#vid_054c&pid_03d5&col02#a&976df89&0&0001#{guid}"; - - assert_eq!( - windows_ps_move_physical_key(col01), - windows_ps_move_physical_key(col02) - ); - } - - #[test] - fn serve_accepts_move_state_sources() { - let options = Options::parse( - [ - "serve", - "--host", - "nightwing", - "--move-state", - "move-usb=/dev/hidraw1", - "--move-evidence-stream", - "muninn:nightwing:move-evidence", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert_eq!( - options.move_state_sources, - vec![MoveStateSource { - move_id: "move-usb".to_string(), - hidraw_path: "/dev/hidraw1".to_string(), - }] - ); - assert_eq!( - options.move_evidence_stream_id.as_deref(), - Some("muninn:nightwing:move-evidence") - ); - assert_eq!(options.move_evidence_verse_id, "mimir-live"); - } - - #[test] - fn serve_accepts_quest_access_streams() { - let options = Options::parse( - [ - "serve", - "--host", - "starfire", - "--quest-adb", - "--quest-serial", - "1WMHHB68PG1515", - "--quest-input-stream", - "muninn:starfire:quest-input", - "--quest-pose-stream", - "muninn:starfire:quest-poses", - "--quest-video-input-stream", - "muninn:starfire:quest-warped-video-input", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert!(options.quest_adb); - assert_eq!(options.quest_serial.as_deref(), Some("1WMHHB68PG1515")); - assert_eq!( - options.quest_video_input_stream_id.as_deref(), - Some("muninn:starfire:quest-warped-video-input") - ); - } - - #[test] - fn parses_authorized_quest_from_adb_devices() { - let output = "List of devices attached\n1WMHHB68PG1515 device product:hollywood model:Quest_2 device:hollywood transport_id:5\n"; - let device = parse_quest_device_from_adb(output, None).expect("Quest should parse"); - - assert_eq!(device.serial, "1WMHHB68PG1515"); - assert_eq!(device.connection_state, "device"); - assert_eq!(device.product, "hollywood"); - assert_eq!(device.model, "Quest_2"); - assert_eq!(device.transport_id, "5"); - } - - #[test] - fn quest_access_record_defaults_streams_to_host_owned_muninn_surfaces() { - let options = Options::parse( - ["serve", "--host", "starfire", "--quest-adb"] - .into_iter() - .map(String::from), - ) - .unwrap(); - let record = build_quest_access_record( - &options, - ParsedQuestDevice { - serial: "1WMHHB68PG1515".to_string(), - connection_state: "device".to_string(), - product: "hollywood".to_string(), - model: "Quest_2".to_string(), - device: "hollywood".to_string(), - transport_id: "5".to_string(), - }, - "usb-authorized", - "test", - ) - .unwrap(); - - assert_eq!( - record.access_id, - "muninn:starfire:quest-access:1WMHHB68PG1515" - ); - assert_eq!(record.input_stream_id, "muninn:starfire:quest-input"); - assert_eq!(record.pose_stream_id, "muninn:starfire:quest-poses"); - assert_eq!( - record.video_input_stream_id, - "muninn:starfire:quest-warped-video-input" - ); - assert_eq!( - record.video_input_transport, - "brokkr-unity-editor-warped-frame-stream" - ); - } - - #[derive(Deserialize)] - struct DecodedMoveEvidenceStreamFrame( - String, - String, - i64, - Vec, - Vec, - ); - - #[derive(Deserialize)] - struct DecodedMimirMoveProofEvidenceFrameSnapshot( - String, - String, - String, - String, - i64, - u64, - Vec, - ); - - #[derive(Deserialize)] - struct DecodedMarkerCandidate { - stream_id: String, - host_id: String, - camera_id: String, - frame_sequence: u64, - source_id_hash: u64, - tile_x: u32, - tile_y: u32, - center_x_px: f32, - center_y_px: f32, - radius_px: f32, - area_px: u32, - mean_luma: f32, - peak_luma: u32, - score: f32, - observed_at: String, - move_id: String, - } - - struct RecordingMoveStateReader { - reports: Vec>, - joystick_events: Vec, - failing_joystick_path: Option, - } - - #[derive(Default)] - struct RecordingMoveMarkerCameraReader { - frames: Vec>, - configs: Vec, - } - - impl MoveMarkerCameraFrameReader for RecordingMoveMarkerCameraReader { - fn read_luma_frame( - &mut self, - _source: &MoveMarkerCameraSource, - frame_source: &MoveMarkerFrameSource, - ) -> Result>> { - self.configs.push(frame_source.tracker_config); - if self.frames.is_empty() { - return Ok(None); - } - Ok(Some(self.frames.remove(0))) - } - } - - impl MoveControllerStateReader for RecordingMoveStateReader { - fn read_report(&mut self, _hidraw_path: &str) -> Result>> { - if self.reports.is_empty() { - return Ok(None); - } - Ok(Some(self.reports.remove(0))) - } - - fn read_joystick_events(&mut self, joystick_path: &str) -> Result> { - if self - .failing_joystick_path - .as_deref() - .is_some_and(|path| path == joystick_path) - { - return Err(anyhow!("simulated missing joystick")); - } - Ok(std::mem::take(&mut self.joystick_events)) - } - } - - #[test] - fn move_identity_publishes_without_controller_state_report() { - let store_path = std::env::temp_dir().join(format!( - "muninn-move-identity-{}.cc", - timestamp_ns().unwrap() - )); - let options = Options::parse( - [ - "serve", - "--host", - "starfire", - "--store", - store_path.to_str().unwrap(), - "--move-host", - "5C:93:A2:9C:A8:A8", - "--move-state", - "move-0006f523e2d1=windows-psmove://silent", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let mut node = open_node(&options, "muninn-move-identity-test").unwrap(); - - publish_move_identity_records(&mut node, &options, &options.move_state_sources).unwrap(); - - let record = node - .get_required::("starfire:move-0006f523e2d1:move-identity") - .unwrap(); - assert_eq!(record.host_id, "starfire"); - assert_eq!(record.move_id, "move-0006f523e2d1"); - assert_eq!(record.bluetooth_host_address, "5C:93:A2:9C:A8:A8"); - assert_eq!(record.state, "usb-visible"); - let _ = fs::remove_file(store_path); - } - - #[test] - fn runtime_boundary_records_use_explicit_activation_store_path() { - let store_path = - std::env::temp_dir().join(format!("muninn-boundary-{}.cc", timestamp_ns().unwrap())); - let options = Options::parse( - [ - "serve", - "--host", - "raven", - "--store", - store_path.to_str().unwrap(), - "--activate-store", - "C:/Meta/Odin/state/muninn.activate.cc", - "--target-host", - "cultmesh://odin/media/muninn-raven-av", - "--idunn-rudp-health", - "198.51.100.10:17870", - "--idunn-daemon", - "muninn", - "--idunn-health-contract", - "muninn.cultnet-rudp-remote-telemetry-health", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let mut node = open_node(&options, "muninn-boundary-test").unwrap(); - - publish_runtime_boundary_records(&mut node, &options, "idle", &[], &[]).unwrap(); - - let boundary = node - .get_required::("command-boundary:muninn") - .unwrap(); - let transport = node - .get_required::("transport-profile:muninn") - .unwrap(); - let provider = node - .get_required::("muninn.telemetry.raven") - .unwrap(); - - let invocation = boundary - .value - .get("commands") - .and_then(|commands| commands.as_array()) - .and_then(|commands| commands.first()) - .and_then(|command| command.get("invocation")) - .and_then(|value| value.as_str()) - .unwrap(); - assert!(invocation.contains("C:/Meta/Odin/state/muninn.activate.cc")); - assert!(invocation.contains(store_path.to_str().unwrap())); - assert!(invocation.contains("request-stream")); - assert!(invocation.contains("--target-host cultmesh://odin/media/muninn-raven-av")); - - let command_lowerings = transport - .value - .get("command_lowerings") - .and_then(|value| value.as_array()) - .unwrap(); - assert!(command_lowerings.iter().any(|value| { - value - .as_str() - .is_some_and(|entry| entry.contains("C:/Meta/Odin/state/muninn.activate.cc")) - })); - - let media_profile = transport - .value - .get("media_profile") - .and_then(|value| value.as_object()) - .unwrap(); - assert_eq!( - media_profile - .get("profile_id") - .and_then(|value| value.as_str()), - Some(MUNINN_RUDP_MEDIA_PROFILE_ID) - ); - assert_eq!( - media_profile - .get("strategy") - .and_then(|value| value.as_str()), - Some("hardware-codec-owned-prediction-over-fixed-budget-rudp") - ); - assert_eq!( - media_profile - .get("visual_prediction_owner") - .and_then(|value| value.as_str()), - Some("NVENC H.264 inter-frame prediction") - ); - assert_eq!( - media_profile - .get("transport_owner") - .and_then(|value| value.as_str()), - Some("CultNet RUDP media channel") - ); - assert_eq!( - media_profile - .get("video_rate_control") - .and_then(|value| value.as_str()), - Some("cbr") - ); - assert_eq!( - media_profile - .get("video_rc_lookahead") - .and_then(|value| value.as_u64()), - Some(0) - ); - assert_eq!( - media_profile - .get("video_bitrate") - .and_then(|value| value.as_str()), - Some("12000k") - ); - assert_eq!( - media_profile - .get("video_bufsize") - .and_then(|value| value.as_str()), - Some("400k") - ); - assert_eq!( - media_profile - .get("video_low_delay_key_frame_scale") - .and_then(|value| value.as_u64()), - Some(MUNINN_RUDP_MEDIA_LOW_DELAY_KEY_FRAME_SCALE as u64) - ); - assert_eq!( - media_profile - .get("media_packet_bytes") - .and_then(|value| value.as_u64()), - Some(MUNINN_RUDP_MEDIA_PACKET_BYTES as u64) - ); - assert_eq!( - media_profile - .get("max_fragment_bytes") - .and_then(|value| value.as_u64()), - Some(MUNINN_RUDP_MEDIA_MAX_FRAGMENT_BYTES as u64) - ); - assert_eq!( - media_profile - .get("receiver_gap_wait_ms") - .and_then(|value| value.as_u64()), - Some(MUNINN_RUDP_MEDIA_RECEIVER_GAP_WAIT_MS) - ); - assert_eq!( - media_profile - .get("sender_reliable_expire_after_ms") - .and_then(|value| value.as_u64()), - Some(MUNINN_RUDP_MEDIA_RECEIVER_ASSEMBLY_DEADLINE_MS) - ); - assert_eq!( - media_profile - .get("sender_resend_delay_ms") - .and_then(|value| value.as_u64()), - Some(MUNINN_RUDP_MEDIA_RESEND_DELAY_MS) - ); - assert_eq!( - media_profile - .get("late_media_policy") - .and_then(|value| value.as_str()), - Some("drop expired queued media; do not repair frames outside the latency budget") - ); - - let routes = provider - .value - .get("routes") - .and_then(|value| value.as_array()) - .unwrap(); - assert!(!routes.iter().any(|route| { - route - .get("address") - .and_then(|value| value.as_str()) - .is_some_and(|entry| entry.contains("C:/Meta/Odin/state/muninn.activate.cc")) - })); - let _ = fs::remove_file(store_path); - } - - #[test] - fn runtime_boundary_advertises_hid_and_move_evidence_streams() { - let store_path = std::env::temp_dir().join(format!( - "muninn-boundary-hid-sources-{}.cc", - timestamp_ns().unwrap() - )); - let options = Options::parse( - [ - "serve", - "--host", - "starfire", - "--store", - store_path.to_str().unwrap(), - "--move-evidence-stream", - "muninn:nightwing:move-evidence", - "--hid-controller-rudp-bind", - "0.0.0.0:17888", - "--hid-controller-rudp-advertise", - "198.51.100.66:17888", - "--command-rudp-bind", - "0.0.0.0:17889", - "--command-rudp-advertise", - "198.51.100.66:17889", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let mut node = open_node(&options, "muninn-boundary-hid-sources-test").unwrap(); - let live_sources = vec![MoveStateSource { - move_id: "nav-windows-psnav-0".to_string(), - hidraw_path: "windows-psmove://nav".to_string(), - }]; - - publish_runtime_boundary_records(&mut node, &options, "idle", &[], &live_sources).unwrap(); - - let provider = node - .get_required::("muninn.telemetry.starfire") - .unwrap(); - let command_route = provider - .value - .get("routes") - .and_then(|value| value.as_array()) - .and_then(|routes| { - routes.iter().find(|route| { - route.get("role").and_then(|value| value.as_str()) - == Some("muninn provider command ingress") - }) - }) - .expect("provider-owned command route"); - assert_eq!( - command_route.get("uri").and_then(|value| value.as_str()), - Some("cultmesh://198.51.100.66:17889/muninn/starfire/commands") - ); - let streams = provider - .value - .get("inputStreams") - .and_then(|value| value.as_array()) - .unwrap(); - let hid_stream = streams.first().unwrap(); - assert_eq!(streams.len(), 2); - assert_eq!( - hid_stream.get("streamId").and_then(|value| value.as_str()), - Some("muninn:starfire:hid-controller-state") - ); - assert_eq!( - hid_stream.get("address").and_then(|value| value.as_str()), - Some("198.51.100.66:17888") - ); - let devices = hid_stream - .get("devices") - .and_then(|value| value.as_array()) - .unwrap(); - assert_eq!( - devices - .first() - .and_then(|device| device.get("deviceId")) - .and_then(|value| value.as_str()), - Some("nav-windows-psnav-0") - ); - assert_eq!( - devices - .first() - .and_then(|device| device.get("deviceKind")) - .and_then(|value| value.as_str()), - Some("ps3-navigation") - ); - let evidence_stream = &streams[1]; - assert_eq!( - evidence_stream - .get("streamId") - .and_then(|value| value.as_str()), - Some("muninn:nightwing:move-evidence") - ); - assert_eq!( - evidence_stream - .get("channel") - .and_then(|value| value.as_str()), - Some("move-evidence") - ); - let _ = fs::remove_file(store_path); - } - - #[test] - fn ps3_navigation_hid_report_uses_live_stick_bytes() { - let source = MoveStateSource { - move_id: "nav-windows-psnav-0".to_string(), - hidraw_path: "windows-psmove://vid_054c&pid_042f".to_string(), - }; - let mut report = vec![0u8; 49]; - report[0] = 0x01; - report[6] = 0x7d; - report[7] = 0x80; - report[18] = 0x00; - - let axes = hid_controller_axes_from_report(&source, &report); - - assert!( - axes[0].abs() < 0.02, - "x axis should be near neutral: {}", - axes[0] - ); - assert!( - axes[1].abs() < 0.02, - "y axis should be near neutral: {}", - axes[1] - ); - assert_eq!(axes[2], -1.0); - - report[6] = 0xff; - report[7] = 0x00; - report[18] = 0xff; - let axes = hid_controller_axes_from_report(&source, &report); - - assert!(axes[0] > 0.95); - assert!(axes[1] < -0.95); - assert_eq!(axes[2], 1.0); - } - - #[test] - fn runtime_boundary_reports_explicit_rudp_video_bitrate() { - let store_path = std::env::temp_dir().join(format!( - "muninn-boundary-bitrate-{}.cc", - timestamp_ns().unwrap() - )); - let options = Options::parse( - [ - "serve", - "--store", - store_path.to_str().unwrap(), - "--host", - "raven", - "--media-transport", - "rudp", - "--rudp-video-bitrate-kbps", - "8000", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let mut node = open_node(&options, "muninn-boundary-bitrate-test").unwrap(); - - publish_runtime_boundary_records(&mut node, &options, "idle", &[], &[]).unwrap(); - - let transport = node - .get_required::("transport-profile:muninn") - .unwrap(); - let media_profile = transport - .value - .get("media_profile") - .and_then(|value| value.as_object()) - .unwrap(); - assert_eq!( - media_profile - .get("video_bitrate") - .and_then(|value| value.as_str()), - Some("8000k") - ); - assert_eq!( - media_profile - .get("video_bufsize") - .and_then(|value| value.as_str()), - Some("267k") - ); - let _ = fs::remove_file(store_path); - } - - #[test] - fn move_identity_status_reads_reopened_store() { - let store_path = std::env::temp_dir().join(format!( - "muninn-move-identity-reopen-{}.cc", - timestamp_ns().unwrap() - )); - let options = Options::parse( - [ - "serve", - "--host", - "nightwing", - "--store", - store_path.to_str().unwrap(), - "--move-host", - "5C:93:A2:9C:A8:A8", - "--move-state", - "move-000704a39772=/dev/input/js0", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let mut node = open_node(&options, "muninn-move-identity-reopen-test").unwrap(); - - publish_move_identity_records(&mut node, &options, &options.move_state_sources).unwrap(); - drop(node); - - let reopened = open_node(&options, "muninn-move-identity-reopen-status").unwrap(); - let identities = reopened - .cache() - .get_all::() - .unwrap(); - - assert_eq!(identities.len(), 1); - assert_eq!(identities[0].move_id, "move-000704a39772"); - assert_eq!(identities[0].source_path, "/dev/input/js0"); - let _ = fs::remove_file(store_path); - } - - #[test] - fn move_identity_reconcile_deletes_stale_host_records() { - let store_path = std::env::temp_dir().join(format!( - "muninn-move-identity-reconcile-{}.cc", - timestamp_ns().unwrap() - )); - let options = Options::parse( - [ - "serve", - "--host", - "starfire", - "--store", - store_path.to_str().unwrap(), - "--move-host", - "5C:93:A2:9C:A8:A8", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let mut node = open_node(&options, "muninn-move-identity-reconcile-test").unwrap(); - let stale = MuninnMoveIdentityRecord { - identity_id: "starfire:move-0006f523e2d1:move-identity".to_string(), - host_id: "starfire".to_string(), - move_id: "move-0006f523e2d1".to_string(), - source_path: "windows-psmove://stale".to_string(), - bluetooth_host_address: "5C:93:A2:9C:A8:A8".to_string(), - state: "usb-visible".to_string(), - detail: "stale test record".to_string(), - observed_at: "unix-1".to_string(), - }; - node.put(&stale.identity_id, &stale).unwrap(); - let current = vec![MoveStateSource { - move_id: "move-000704a800d0".to_string(), - hidraw_path: "windows-psmove://current".to_string(), - }]; - - reconcile_move_identity_records(&mut node, &options, ¤t, &[]).unwrap(); - - assert!( - node.get::("starfire:move-0006f523e2d1:move-identity") - .unwrap() - .is_none() - ); - let current_record = node - .get_required::("starfire:move-000704a800d0:move-identity") - .unwrap(); - assert_eq!(current_record.source_path, "windows-psmove://current"); - let _ = fs::remove_file(store_path); - } - - #[test] - fn joystick_move_state_publishes_fresh_record_without_new_events() { - let store_path = std::env::temp_dir().join(format!( - "muninn-empty-joystick-events-{}.cc", - timestamp_ns().unwrap() - )); - let options = Options::parse( - [ - "serve", - "--host", - "nightwing", - "--store", - store_path.to_str().unwrap(), - "--move-state", - "move-usb=/dev/input/js0", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let source = options.move_state_sources[0].clone(); - let mut active = vec![ActiveMoveStateSource { - source, - sequence: 0, - joystick_axes: [0; 16], - joystick_buttons: [false; 32], - light_hidraw_path: None, - latest_move_record: None, - }]; - let mut reader = RecordingMoveStateReader { - reports: Vec::new(), - joystick_events: Vec::new(), - failing_joystick_path: None, - }; - let mut node = open_node(&options, "muninn-empty-joystick-test").unwrap(); - - publish_move_controller_states(&mut node, &options, &mut active, &mut reader, None, None) - .unwrap(); - - let record = node - .get_required::( - "nightwing:move-usb:move-controller-state", - ) - .unwrap(); - assert_eq!( - active[0] - .latest_move_record - .as_ref() - .map(|record| record.sequence), - Some(1) - ); - assert_eq!(record.sequence, 1); - assert_eq!(record.host_id, "nightwing"); - assert_eq!(record.move_id, "move-usb"); - assert_eq!(record.accelerometer_xyz, vec![0.0, 0.0, 0.0]); - let _ = fs::remove_file(store_path); - } - - #[test] - fn windows_hid_move_state_publishes_hid_controller_record() { - let store_path = std::env::temp_dir().join(format!( - "muninn-windows-hid-controller-state-{}.cc", - timestamp_ns().unwrap() - )); - let options = Options::parse( - [ - "serve", - "--host", - "starfire", - "--store", - store_path.to_str().unwrap(), - "--move-state", - "nav-windows-psnav-0=windows-psmove://vid_054c&pid_042f", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let mut active = active_move_state_sources(options.move_state_sources.clone()); - let mut report = vec![0u8; 49]; - report[0] = 0x01; - report[6] = 0xff; - report[7] = 0x00; - report[18] = 0xff; - let mut reader = RecordingMoveStateReader { - reports: vec![report], - joystick_events: Vec::new(), - failing_joystick_path: None, - }; - let mut node = open_node(&options, "muninn-windows-hid-controller-state-test").unwrap(); - - publish_move_controller_states(&mut node, &options, &mut active, &mut reader, None, None) - .unwrap(); - - let record = node - .get_required::( - "starfire:nav-windows-psnav-0:hid-controller-state", - ) - .unwrap(); - assert_eq!(record.device_kind, "ps3-navigation"); - assert_eq!(record.sequence, 1); - assert!(record.axes[0] > 0.95); - assert!(record.axes[1] < -0.95); - assert_eq!(record.axes[2], 1.0); - let _ = fs::remove_file(store_path); - } - - #[test] - fn missing_move_state_source_does_not_block_other_sources() { - let store_path = std::env::temp_dir().join(format!( - "muninn-missing-joystick-events-{}.cc", - timestamp_ns().unwrap() - )); - let options = Options::parse( - [ - "serve", - "--host", - "nightwing", - "--store", - store_path.to_str().unwrap(), - "--move-state", - "missing=/dev/input/js-missing", - "--move-state", - "present=/dev/input/js-present", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let mut active = active_move_state_sources(options.move_state_sources.clone()); - let mut reader = RecordingMoveStateReader { - reports: Vec::new(), - joystick_events: Vec::new(), - failing_joystick_path: Some("/dev/input/js-missing".to_string()), - }; - let mut node = open_node(&options, "muninn-missing-joystick-test").unwrap(); - - publish_move_controller_states(&mut node, &options, &mut active, &mut reader, None, None) - .unwrap(); - - assert!( - node.get_required::( - "nightwing:missing:move-controller-state" - ) - .is_err() - ); - let record = node - .get_required::( - "nightwing:present:move-controller-state", - ) - .unwrap(); - assert_eq!(record.move_id, "present"); - let _ = fs::remove_file(store_path); - } - - #[test] - fn xinput_gamepad_snapshot_maps_to_generic_hid_controller_record() { - let options = - Options::parse(["serve", "--host", "raven"].into_iter().map(String::from)).unwrap(); - let source = MoveStateSource { - move_id: "xbox-raven".to_string(), - hidraw_path: "xinput://0".to_string(), - }; - let gamepad = XinputGamepadSnapshot { - buttons: XINPUT_GAMEPAD_A_MASK - | XINPUT_GAMEPAD_B_MASK - | XINPUT_GAMEPAD_X_MASK - | XINPUT_GAMEPAD_Y_MASK - | XINPUT_GAMEPAD_DPAD_UP_MASK, - left_trigger: 0, - right_trigger: 255, - thumb_lx: i16::MAX, - thumb_ly: i16::MIN, - thumb_rx: 0, - thumb_ry: 0, - }; - - let record = build_hid_controller_state_record_from_xinput_gamepad( - &options, - &source, - 12, - &gamepad, - 34, - "unix-56".to_string(), - ); - - assert_eq!(record.stream_id, "raven:xbox-raven:hid-controller-state"); - assert_eq!(record.device_kind, "xinput-controller"); - assert_eq!(record.axes[0], 1.0); - assert_eq!(record.axes[1], -1.0); - assert_eq!(record.axes[2], -1.0); - assert_eq!(record.axes[5], 1.0); - assert_eq!(record.buttons, vec!["up", "a", "b", "x", "y"]); - } - - #[test] - fn active_move_state_sync_replaces_stale_hotplug_identity() { - let mut active = active_move_state_sources(vec![MoveStateSource { - move_id: "move-old".to_string(), - hidraw_path: "/dev/input/js0".to_string(), - }]); - - sync_active_move_state_sources( - &mut active, - vec![ - MoveStateSource { - move_id: "move-new".to_string(), - hidraw_path: "/dev/input/js0".to_string(), - }, - MoveStateSource { - move_id: "move-bt".to_string(), - hidraw_path: "/dev/input/js1".to_string(), - }, - ], - ); - - assert_eq!(active.len(), 2); - assert!( - active - .iter() - .any(|state| state.source.move_id == "move-new") - ); - assert!(active.iter().any(|state| state.source.move_id == "move-bt")); - assert!( - !active - .iter() - .any(|state| state.source.move_id == "move-old") - ); - } - - #[test] - fn configured_move_identity_canonicalizes_discovered_joystick_path() { - let discovered = vec![MoveStateSource { - move_id: "hid-js0".to_string(), - hidraw_path: "/dev/input/js0".to_string(), - }]; - let configured = vec![MoveStateSource { - move_id: "move-000704a800d0".to_string(), - hidraw_path: "/dev/input/js0".to_string(), - }]; - - let merged = merge_move_state_sources(discovered, &configured); - - assert_eq!( - merged, - vec![MoveStateSource { - move_id: "move-000704a800d0".to_string(), - hidraw_path: "/dev/input/js0".to_string(), - }] - ); - } - - #[test] - fn physical_move_identity_survives_configured_path_reuse() { - let discovered = vec![MoveStateSource { - move_id: "move-000704a6be5f".to_string(), - hidraw_path: "/dev/input/js2".to_string(), - }]; - let configured = vec![MoveStateSource { - move_id: "move-000704a39772".to_string(), - hidraw_path: "/dev/input/js2".to_string(), - }]; - - assert_eq!( - merge_move_state_sources(discovered.clone(), &configured), - discovered - ); - } - - #[test] - fn rudp_sources_reconcile_hotplugged_physical_identity() { - let mut active = vec![active_hid_controller_rudp_source(MoveStateSource { - move_id: "move-000704a39772".to_string(), - hidraw_path: "/dev/input/js2".to_string(), - })]; - let desired = vec![MoveStateSource { - move_id: "move-000704a39772".to_string(), - hidraw_path: "/dev/input/js0".to_string(), - }]; - - sync_hid_controller_rudp_sources(&mut active, desired.clone()); - - assert_eq!(active.len(), 1); - assert_eq!(active[0].source, desired[0]); - } - - #[test] - fn move_controller_state_publishes_mimir_compatible_cultmesh_frame() { - let options = Options::parse( - [ - "serve", - "--host", - "nightwing", - "--move-state", - "move-usb=/dev/input/js0", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let source = options.move_state_sources[0].clone(); - let record = build_move_controller_state_record_from_joystick( - &options, - &source, - 7, - [1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 0, 0, 0, 0, 0, 0], - { - let mut buttons = [false; 32]; - buttons[16] = true; - buttons[17] = true; - buttons - }, - 123_456_789, - "unix-1".to_string(), - ); - let mut stream = create_move_evidence_stream(&options) - .unwrap() - .expect("move state source should create a stream"); - let rudp_latest = Arc::new(Mutex::new(None)); - stream.rudp_sender = Some(Arc::clone(&rudp_latest)); - - let handle = publish_move_evidence_stream_frame(&mut stream, &[], &[record.clone()]) - .unwrap() - .expect("controller state should publish a frame"); - - assert_eq!(handle.stream_id, "muninn:nightwing:move-evidence"); - assert_eq!( - stream - .catalog - .latest_frame("muninn:nightwing:move-evidence") - .unwrap() - .sequence, - 0 - ); - assert_eq!( - stream - .catalog - .get("muninn:nightwing:move-evidence") - .unwrap() - .metadata_schema_id - .as_deref(), - Some("mimir.muninn_move_evidence_stream_frame.v1") - ); - let lease = stream - .catalog - .ring("muninn:nightwing:move-evidence") - .and_then(CultMeshSharedMemoryFrameRing::try_acquire_latest_read) - .expect("latest frame should be readable"); - let decoded: DecodedMoveEvidenceStreamFrame = rmp_serde::from_slice(lease.bytes()).unwrap(); - let remote_payload = rudp_latest - .lock() - .unwrap() - .take() - .expect("RUDP evidence copy"); - assert_eq!(remote_payload, lease.bytes()); - - assert_eq!(decoded.0, "muninn:nightwing:move-evidence:0"); - assert_eq!(decoded.1, "muninn:nightwing"); - assert!(decoded.2 > 0); - assert!(decoded.3.is_empty()); - assert_eq!(decoded.4.len(), 1); - assert_eq!(decoded.4[0].stream_id, record.stream_id); - assert_eq!(decoded.4[0].host_id, record.host_id); - assert_eq!(decoded.4[0].move_id, record.move_id); - assert_eq!(decoded.4[0].sequence, record.sequence); - assert_eq!(decoded.4[0].source_timestamp_ns, record.source_timestamp_ns); - assert_eq!(decoded.4[0].accelerometer_xyz, record.accelerometer_xyz); - assert_eq!(decoded.4[0].gyroscope_xyz, record.gyroscope_xyz); - assert_eq!(decoded.4[0].magnetometer_xyz, record.magnetometer_xyz); - assert_eq!(decoded.4[0].buttons, record.buttons); - assert!(decoded.4[0].battery01.is_nan()); - } - - #[test] - fn move_marker_candidates_publish_in_mimir_compatible_cultmesh_frame() { - let options = Options::parse( - [ - "serve", - "--host", - "nightwing", - "--move-evidence-stream", - "muninn:nightwing:move-evidence", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let mut y8 = vec![0u8; 32 * 32]; - for y in 8..12 { - for x in 10..14 { - y8[y * 32 + x] = 255; - } - } - let frame_source = MoveMarkerFrameSource { - stream_id: "muninn:nightwing:ps3eye0:move-markers".to_string(), - host_id: "nightwing".to_string(), - camera_id: "ps3eye0".to_string(), - fps: 187, - tracker_config: muninn_move_tracker::MoveTrackerConfig { - width: 32, - height: 32, - stride_bytes: 32, - tile_size: 16, - threshold_min: 180, - min_area_px: 4, - max_candidates: 8, - source_id_hash: 42, - frame_sequence: 7, - }, - }; - let mut stream = create_move_evidence_stream(&options) - .unwrap() - .expect("move evidence option should create a stream"); - - let marker_candidates = extract_move_marker_candidates_from_luma_frame( - &frame_source, - &y8, - "unix-1".to_string(), - ) - .unwrap(); - let handle = publish_move_evidence_stream_frame(&mut stream, &marker_candidates, &[]) - .unwrap() - .expect("marker candidates should publish a frame"); - - assert_eq!(handle.stream_id, "muninn:nightwing:move-evidence"); - let lease = stream - .catalog - .ring("muninn:nightwing:move-evidence") - .and_then(CultMeshSharedMemoryFrameRing::try_acquire_latest_read) - .expect("latest frame should be readable"); - let decoded: DecodedMoveEvidenceStreamFrame = rmp_serde::from_slice(lease.bytes()).unwrap(); - - assert_eq!(decoded.0, "muninn:nightwing:move-evidence:0"); - assert_eq!(decoded.1, "muninn:nightwing"); - assert!(decoded.2 > 0); - assert_eq!(decoded.3.len(), 1); - assert!(decoded.4.is_empty()); - let marker = &decoded.3[0]; - assert_eq!(marker.stream_id, "muninn:nightwing:ps3eye0:move-markers"); - assert_eq!(marker.host_id, "nightwing"); - assert_eq!(marker.camera_id, "ps3eye0"); - assert_eq!(marker.frame_sequence, 7); - assert_eq!(marker.source_id_hash, 42); - assert_eq!(marker.tile_x, 0); - assert_eq!(marker.tile_y, 0); - assert!(marker.center_x_px > 11.0 && marker.center_x_px < 12.0); - assert!(marker.center_y_px > 9.0 && marker.center_y_px < 10.0); - assert!(marker.radius_px > 2.0); - assert_eq!(marker.area_px, 16); - assert_eq!(marker.peak_luma, 255); - assert!(marker.mean_luma > 250.0); - assert!(marker.score > 0.65); - assert_eq!(marker.observed_at, "unix-1"); - assert!(marker.move_id.is_empty()); - } - - #[test] - fn move_marker_camera_option_builds_owned_evidence_source() { - let options = Options::parse( - [ - "serve", - "--host", - "nightwing", - "--move-marker-camera", - "ps3eye0=/dev/video0", - "--move-marker-width", - "32", - "--move-marker-height", - "32", - "--move-marker-fps", - "120", - "--move-marker-threshold-min", - "170", - "--move-marker-min-area-px", - "3", - "--move-marker-max-candidates", - "9", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - - assert_eq!(options.move_marker_camera_sources.len(), 1); - assert_eq!(options.move_marker_camera_sources[0].camera_id, "ps3eye0"); - assert_eq!( - options.move_marker_camera_sources[0].device_path, - PathBuf::from("/dev/video0") - ); - let active = active_move_marker_camera_sources( - &options, - Arc::new(Mutex::new(bootstrap_move_hue_program(&options))), - ); - assert_eq!(active.len(), 1); - assert_eq!( - active[0].frame_source.stream_id, - "muninn:nightwing:ps3eye0:move-marker-candidates" - ); - assert_eq!(active[0].frame_source.fps, 120); - assert_eq!(active[0].frame_source.tracker_config.width, 32); - assert_eq!(active[0].frame_source.tracker_config.height, 32); - assert_eq!(active[0].frame_source.tracker_config.threshold_min, 170); - assert_eq!(active[0].frame_source.tracker_config.min_area_px, 3); - assert_eq!(active[0].frame_source.tracker_config.max_candidates, 9); - assert!( - create_move_evidence_stream(&options).unwrap().is_some(), - "marker camera alone should create the evidence stream" - ); - } - - #[test] - fn move_marker_camera_tick_publishes_extracted_evidence_frame() { - let options = Options::parse( - [ - "serve", - "--host", - "nightwing", - "--move-marker-camera", - "ps3eye0=/dev/video0", - "--move-evidence-stream", - "muninn:nightwing:move-evidence", - "--move-marker-width", - "32", - "--move-marker-height", - "32", - "--move-marker-threshold-min", - "180", - "--move-marker-min-area-px", - "4", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let mut y8 = vec![0u8; 32 * 32]; - for y in 8..12 { - for x in 10..14 { - y8[y * 32 + x] = 255; - } - } - let mut active = active_move_marker_camera_sources( - &options, - Arc::new(Mutex::new(bootstrap_move_hue_program(&options))), - ); - let mut reader = RecordingMoveMarkerCameraReader { - frames: vec![y8], - configs: Vec::new(), - }; - let mut stream = create_move_evidence_stream(&options) - .unwrap() - .expect("marker camera should create stream"); - - publish_move_marker_camera_frames(&mut active, &mut reader, &[], Some(&mut stream)) - .unwrap(); - - assert_eq!(active[0].sequence, 1); - assert_eq!(reader.configs.len(), 1); - assert_eq!(reader.configs[0].frame_sequence, 1); - assert_ne!(reader.configs[0].source_id_hash, 0); - let lease = stream - .catalog - .ring("muninn:nightwing:move-evidence") - .and_then(CultMeshSharedMemoryFrameRing::try_acquire_latest_read) - .expect("latest marker evidence frame should be readable"); - let decoded: DecodedMoveEvidenceStreamFrame = rmp_serde::from_slice(lease.bytes()).unwrap(); - - assert_eq!(decoded.0, "muninn:nightwing:move-evidence:0"); - assert_eq!(decoded.1, "muninn:nightwing"); - assert_eq!(decoded.3.len(), 1); - assert!(decoded.4.is_empty()); - assert_eq!( - decoded.3[0].stream_id, - "muninn:nightwing:ps3eye0:move-marker-candidates" - ); - assert_eq!(decoded.3[0].camera_id, "ps3eye0"); - assert_eq!(decoded.3[0].frame_sequence, 1); - assert_eq!( - decoded.3[0].source_id_hash, - reader.configs[0].source_id_hash - ); - } - - #[test] - fn move_marker_camera_tick_bundles_latest_controller_state() { - let options = Options::parse( - [ - "serve", - "--host", - "nightwing", - "--move-state", - "move-usb=/dev/input/js0", - "--move-marker-camera", - "ps3eye0=/dev/video0", - "--move-marker-camera", - "ps3eye1=/dev/video1", - "--move-evidence-stream", - "muninn:nightwing:move-evidence", - "--move-marker-width", - "32", - "--move-marker-height", - "32", - "--move-marker-threshold-min", - "180", - "--move-marker-min-area-px", - "4", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let mut y8 = vec![0u8; 32 * 32]; - for y in 8..12 { - for x in 10..14 { - y8[y * 32 + x] = 255; - } - } - let mut active_cameras = active_move_marker_camera_sources( - &options, - Arc::new(Mutex::new(bootstrap_move_hue_program(&options))), - ); - let mut reader = RecordingMoveMarkerCameraReader { - frames: vec![y8.clone(), y8], - configs: Vec::new(), - }; - let source = options.move_state_sources[0].clone(); - let controller = build_move_controller_state_record_from_joystick( - &options, - &source, - 7, - [1, 2, 3, 4, 5, 6, 7, 8, 9, 0, 0, 0, 0, 0, 0, 0], - [false; 32], - 123_456_789, - "unix-123".to_string(), - ); - let mut stream = create_move_evidence_stream(&options) - .unwrap() - .expect("marker camera should create stream"); - - publish_move_marker_camera_frames( - &mut active_cameras, - &mut reader, - std::slice::from_ref(&controller), - Some(&mut stream), - ) - .unwrap(); - - let lease = stream - .catalog - .ring("muninn:nightwing:move-evidence") - .and_then(CultMeshSharedMemoryFrameRing::try_acquire_latest_read) - .expect("latest bundled evidence frame should be readable"); - let decoded: DecodedMoveEvidenceStreamFrame = rmp_serde::from_slice(lease.bytes()).unwrap(); - - assert_eq!(decoded.3.len(), 2); - assert_eq!(decoded.4.len(), 1); - assert_ne!(decoded.3[0].source_id_hash, decoded.3[1].source_id_hash); - assert_eq!(decoded.4[0].move_id, "move-usb"); - assert_eq!(decoded.4[0].sequence, 7); - assert_eq!(decoded.4[0].source_path, "/dev/input/js0"); - } - - #[test] - fn move_evidence_snapshot_writes_mimir_compatible_frame_artifact() { - let snapshot_path = std::env::temp_dir().join(format!( - "muninn-move-evidence-snapshot-{}.mpack", - timestamp_ns().unwrap() - )); - let options = Options::parse( - [ - "serve", - "--host", - "nightwing", - "--move-marker-camera", - "ps3eye0=/dev/video0", - "--move-evidence-stream", - "muninn:nightwing:move-evidence", - "--move-evidence-snapshot", - snapshot_path.to_str().unwrap(), - "--move-marker-width", - "32", - "--move-marker-height", - "32", - "--move-marker-threshold-min", - "180", - "--move-marker-min-area-px", - "4", - ] - .into_iter() - .map(String::from), - ) - .unwrap(); - let mut y8 = vec![0u8; 32 * 32]; - for y in 8..12 { - for x in 10..14 { - y8[y * 32 + x] = 255; - } - } - let mut active = active_move_marker_camera_sources( - &options, - Arc::new(Mutex::new(bootstrap_move_hue_program(&options))), - ); - let mut reader = RecordingMoveMarkerCameraReader { - frames: vec![y8], - configs: Vec::new(), - }; - let mut stream = create_move_evidence_stream(&options) - .unwrap() - .expect("marker camera should create stream"); - - publish_move_marker_camera_frames(&mut active, &mut reader, &[], Some(&mut stream)) - .unwrap(); - - let snapshot_bytes = fs::read(&snapshot_path).expect("snapshot should be written"); - let snapshot: DecodedMimirMoveProofEvidenceFrameSnapshot = - rmp_serde::from_slice(&snapshot_bytes).unwrap(); - let payload_frame: DecodedMoveEvidenceStreamFrame = - rmp_serde::from_slice(&snapshot.6).unwrap(); - let payload_start = snapshot_bytes - .len() - .checked_sub(snapshot.6.len()) - .expect("payload should be contained in snapshot bytes"); - if snapshot.6.len() <= u8::MAX as usize { - assert_eq!(snapshot_bytes[payload_start - 2], 0xc4); - assert_eq!(snapshot_bytes[payload_start - 1] as usize, snapshot.6.len()); - } else { - assert_eq!(snapshot_bytes[payload_start - 3], 0xc5); - assert_eq!( - u16::from_be_bytes([ - snapshot_bytes[payload_start - 2], - snapshot_bytes[payload_start - 1] - ]) as usize, - snapshot.6.len() - ); - } - - assert_eq!(snapshot.0, "muninn:nightwing:move-evidence:0:snapshot"); - assert_eq!(snapshot.1, "muninn:nightwing:move-evidence"); - assert_eq!(snapshot.2, "muninn:nightwing:move-evidence:0"); - assert_eq!(snapshot.3, "muninn:nightwing"); - assert!(snapshot.4 > 0); - assert!(snapshot.5 > 0); - assert_eq!(payload_frame.0, snapshot.2); - assert_eq!(payload_frame.1, snapshot.3); - assert_eq!(payload_frame.2, snapshot.4); - assert_eq!(payload_frame.3.len(), 1); - assert!(payload_frame.4.is_empty()); - assert_eq!( - payload_frame.3[0].stream_id, - "muninn:nightwing:ps3eye0:move-marker-candidates" - ); - let _ = fs::remove_file(snapshot_path); - } - - #[test] - fn move_controller_state_projects_raw_hid_report() { - let options = Options::parse( - ["serve", "--host", "nightwing"] - .into_iter() - .map(String::from), - ) - .unwrap(); - let source = MoveStateSource { - move_id: "move-usb".to_string(), - hidraw_path: "/dev/hidraw1".to_string(), - }; - let mut report = vec![0u8; 64]; - report[1] = 0b1111_0000; - report[2] = 0b0000_1001; - report[3] = 0b0001_1000; - report[6] = 128; - report[12] = 4; - report[19..21].copy_from_slice(&123i16.to_le_bytes()); - report[21..23].copy_from_slice(&(-456i16).to_le_bytes()); - report[23..25].copy_from_slice(&789i16.to_le_bytes()); - report[25..27].copy_from_slice(&11i16.to_le_bytes()); - report[27..29].copy_from_slice(&22i16.to_le_bytes()); - report[29..31].copy_from_slice(&33i16.to_le_bytes()); - - let record = build_move_controller_state_record( - &options, - &source, - 42, - &report, - 123_456, - "unix-1".to_string(), - ); - - assert_eq!(record.stream_id, "nightwing:move-usb:move-controller-state"); - assert_eq!(record.sequence, 42); - assert_eq!(record.accelerometer_xyz, vec![123.0, -456.0, 789.0]); - assert_eq!(record.gyroscope_xyz, vec![11.0, 22.0, 33.0]); - assert!(record.buttons.contains(&"triangle".to_string())); - assert!(record.buttons.contains(&"trigger".to_string())); - assert!((record.trigger_value - (128.0 / 255.0)).abs() < f32::EPSILON); - assert!((record.battery01 - 0.8).abs() < f32::EPSILON); - } -} diff --git a/crates/muninn-daemon/src/media_packetizer.rs b/crates/muninn-daemon/src/media_packetizer.rs deleted file mode 100644 index 3d6b364..0000000 --- a/crates/muninn-daemon/src/media_packetizer.rs +++ /dev/null @@ -1,4244 +0,0 @@ -use anyhow::{Context, Result, anyhow}; -use cultnet_rs::{ - CultNetMessage, CultNetRawDocumentRecord, CultNetRawPayloadEncoding, CultNetWireContract, - decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, -}; -use odin_core::{ - MUNINN_MEDIA_AUDIO_PACKET_SCHEMA, MUNINN_MEDIA_RECEIVER_FEEDBACK_SCHEMA, - MUNINN_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA, MUNINN_MEDIA_VIDEO_PARITY_SHARD_SCHEMA, - MuninnMediaAudioPacketRecord, MuninnMediaReceiverFeedbackRecord, - MuninnMediaVideoAccessUnitRecord, MuninnMediaVideoParityShardRecord, -}; -use serde::{Serialize, de::DeserializeOwned}; -use std::collections::BTreeMap; - -pub const MUNINN_MEDIA_RUDP_CHANNEL: &str = "media"; - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct VideoAccessUnit { - pub bytes: Vec, - pub keyframe: bool, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -struct NalUnit<'a> { - start: usize, - end: usize, - payload: &'a [u8], - nal_type: u8, -} - -pub fn video_annex_b_access_units(codec: &str, input: &[u8]) -> Result> { - match normalized_video_codec(codec).as_deref() { - Some("h264") => h264_annex_b_access_units(input), - Some("h265") => h265_annex_b_access_units(input), - Some("av1") => Err(anyhow!( - "AV1 access unit splitting is not Annex B; provide an AV1 OBU packetizer" - )), - _ => Err(anyhow!("unsupported Annex B video codec {codec}")), - } -} - -pub fn h264_annex_b_access_units(input: &[u8]) -> Result> { - let nal_units = annex_b_nal_units(input, "H.264", h264_nal_type)?; - let mut access_units = Vec::new(); - let mut current_start = None; - let mut current_end = 0_usize; - let mut current_has_vcl = false; - let mut current_keyframe = false; - - for nal in nal_units { - let starts_new = if nal.nal_type == 9 { - current_start.is_some() - } else if is_h264_vcl_nal(nal.nal_type) { - current_has_vcl && h264_first_mb_in_slice(nal.payload).unwrap_or(1) == 0 - } else { - false - }; - - if starts_new { - if let Some(start) = current_start { - access_units.push(VideoAccessUnit { - bytes: input[start..current_end].to_vec(), - keyframe: current_keyframe, - }); - } - current_start = None; - current_has_vcl = false; - current_keyframe = false; - } - - if current_start.is_none() { - current_start = Some(nal.start); - } - current_end = nal.end; - if is_h264_vcl_nal(nal.nal_type) { - current_has_vcl = true; - } - if nal.nal_type == 5 { - current_keyframe = true; - } - } - - if let Some(start) = current_start { - access_units.push(VideoAccessUnit { - bytes: input[start..current_end].to_vec(), - keyframe: current_keyframe, - }); - } - - Ok(access_units) -} - -pub fn h265_annex_b_access_units(input: &[u8]) -> Result> { - let nal_units = annex_b_nal_units(input, "H.265", h265_nal_type)?; - let mut access_units = Vec::new(); - let mut current_start = None; - let mut current_end = 0_usize; - let mut current_has_vcl = false; - let mut current_keyframe = false; - - for nal in nal_units { - let starts_new = if nal.nal_type == 35 { - current_start.is_some() - } else if is_h265_vcl_nal(nal.nal_type) { - current_has_vcl && h265_first_slice_segment_in_pic(nal.payload).unwrap_or(false) - } else { - current_has_vcl && is_h265_pre_vcl_boundary_nal(nal.nal_type) - }; - - if starts_new { - if let Some(start) = current_start { - access_units.push(VideoAccessUnit { - bytes: input[start..current_end].to_vec(), - keyframe: current_keyframe, - }); - } - current_start = None; - current_has_vcl = false; - current_keyframe = false; - } - - if current_start.is_none() { - current_start = Some(nal.start); - } - current_end = nal.end; - if is_h265_vcl_nal(nal.nal_type) { - current_has_vcl = true; - } - if is_h265_irap_nal(nal.nal_type) { - current_keyframe = true; - } - } - - if let Some(start) = current_start { - access_units.push(VideoAccessUnit { - bytes: input[start..current_end].to_vec(), - keyframe: current_keyframe, - }); - } - - Ok(access_units) -} - -pub struct VideoFramePacketizeOptions<'a> { - pub stream_id: &'a str, - pub session_id: &'a str, - pub codec: &'a str, - pub frame_id: u64, - pub pts_ticks: i64, - pub duration_ticks: u32, - pub timebase_num: u32, - pub timebase_den: u32, - pub deadline_ticks: i64, - pub max_payload_bytes: usize, -} - -pub struct VideoAnnexBStreamPacketizeOptions<'a> { - pub stream_id: &'a str, - pub session_id: &'a str, - pub codec: &'a str, - pub first_frame_id: u64, - pub first_pts_ticks: i64, - pub frame_duration_ticks: u32, - pub timebase_num: u32, - pub timebase_den: u32, - pub deadline_delay_ticks: i64, - pub max_payload_bytes: usize, -} - -pub struct VideoAnnexBStreamWireOptions<'a> { - pub packetize: VideoAnnexBStreamPacketizeOptions<'a>, - pub stored_at: &'a str, - pub source_runtime_id: &'a str, - pub source_role: &'a str, -} - -pub struct VideoAnnexBStreamSendConfig { - pub stream_id: String, - pub session_id: String, - pub codec: String, - pub first_frame_id: u64, - pub first_pts_ticks: i64, - pub frame_duration_ticks: u32, - pub timebase_num: u32, - pub timebase_den: u32, - pub deadline_delay_ticks: i64, - pub max_payload_bytes: usize, - pub max_pending_bytes: usize, - pub source_runtime_id: String, - pub source_role: String, -} - -pub struct AudioPacketizeOptions<'a> { - pub stream_id: &'a str, - pub session_id: &'a str, - pub codec: &'a str, - pub packet_id: u64, - pub pts_ticks: i64, - pub duration_ticks: u32, - pub timebase_num: u32, - pub timebase_den: u32, - pub deadline_ticks: i64, -} - -pub struct AudioPacketWireOptions<'a> { - pub packetize: AudioPacketizeOptions<'a>, - pub stored_at: &'a str, - pub source_runtime_id: &'a str, - pub source_role: &'a str, -} - -pub struct AudioAdtsStreamSendConfig { - pub stream_id: String, - pub session_id: String, - pub codec: String, - pub first_packet_id: u64, - pub first_pts_ticks: i64, - pub packet_duration_ticks: u32, - pub timebase_num: u32, - pub timebase_den: u32, - pub deadline_delay_ticks: i64, - pub max_pending_bytes: usize, - pub source_runtime_id: String, - pub source_role: String, -} - -pub struct AudioPcmStreamSendConfig { - pub stream_id: String, - pub session_id: String, - pub codec: String, - pub first_packet_id: u64, - pub first_pts_ticks: i64, - pub packet_duration_ticks: u32, - pub timebase_num: u32, - pub timebase_den: u32, - pub deadline_delay_ticks: i64, - pub channels: u32, - pub bytes_per_sample: u32, - pub max_pending_bytes: usize, - pub source_runtime_id: String, - pub source_role: String, -} - -pub struct ReceiverFeedbackOptions<'a> { - pub stream_id: &'a str, - pub session_id: &'a str, - pub receiver_id: &'a str, - pub highest_decodable_frame_id: Option, - pub missing_frame_ids: Vec, - pub missing_video_chunk_keys: Vec, - pub late_frame_ids: Vec, - pub requested_keyframe: bool, - pub jitter_us: i64, - pub decode_queue_us: i64, - pub observed_at: &'a str, -} - -pub struct ExpiredVideoFrameFeedbackOptions<'a> { - pub receiver_id: &'a str, - pub jitter_us: i64, - pub decode_queue_us: i64, - pub observed_at: &'a str, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum MuninnMediaWireRecord { - Video(MuninnMediaVideoAccessUnitRecord), - VideoParity(MuninnMediaVideoParityShardRecord), - Audio(MuninnMediaAudioPacketRecord), - Feedback(MuninnMediaReceiverFeedbackRecord), -} - -#[derive(Serialize)] -struct VideoAccessUnitWirePayload<'a>( - &'a str, - &'a str, - u64, - &'a str, - i64, - u32, - u32, - u32, - bool, - Option, - i64, - u16, - u16, - #[serde(with = "serde_bytes")] &'a [u8], -); - -#[derive(Serialize)] -struct VideoParityShardWirePayload<'a>( - &'a str, - &'a str, - u64, - &'a str, - i64, - u32, - u32, - u32, - bool, - Option, - i64, - u16, - u16, - u16, - u32, - u32, - #[serde(with = "serde_bytes")] &'a [u8], -); - -#[derive(Serialize)] -struct AudioPacketWirePayload<'a>( - &'a str, - &'a str, - u64, - &'a str, - i64, - u32, - u32, - u32, - i64, - #[serde(with = "serde_bytes")] &'a [u8], -); - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct MuninnMediaSendPayload { - pub channel_id: &'static str, - pub payload: Vec, -} - -pub struct VideoAnnexBStreamSendState { - config: VideoAnnexBStreamSendConfig, - pending: Vec, - next_frame_id: u64, - next_pts_ticks: i64, -} - -impl VideoAnnexBStreamSendState { - pub fn new(config: VideoAnnexBStreamSendConfig) -> Result { - if matches!(normalized_video_codec(&config.codec), None | Some("av1")) { - return Err(anyhow!( - "Annex B stream sender requires H.264/AVC or H.265/HEVC codec" - )); - } - if config.stream_id.is_empty() { - return Err(anyhow!("stream_id must be non-empty")); - } - if config.session_id.is_empty() { - return Err(anyhow!("session_id must be non-empty")); - } - if config.frame_duration_ticks == 0 { - return Err(anyhow!("frame_duration_ticks must be greater than zero")); - } - if config.timebase_num == 0 || config.timebase_den == 0 { - return Err(anyhow!("video timebase must be non-zero")); - } - if config.deadline_delay_ticks < 0 { - return Err(anyhow!("deadline_delay_ticks must be non-negative")); - } - if config.max_payload_bytes == 0 { - return Err(anyhow!("max_payload_bytes must be greater than zero")); - } - if config.max_pending_bytes == 0 { - return Err(anyhow!("max_pending_bytes must be greater than zero")); - } - if config.source_runtime_id.is_empty() { - return Err(anyhow!("source_runtime_id must be non-empty")); - } - if config.source_role.is_empty() { - return Err(anyhow!("source_role must be non-empty")); - } - - Ok(Self { - next_frame_id: config.first_frame_id, - next_pts_ticks: config.first_pts_ticks, - config, - pending: Vec::new(), - }) - } - - pub fn push(&mut self, stored_at: &str, bytes: &[u8]) -> Result> { - if !bytes.is_empty() { - self.pending.extend_from_slice(bytes); - } - if self.pending.len() > self.config.max_pending_bytes { - return Err(anyhow!( - "Annex B stream sender pending buffer exceeded {} bytes without a complete frame", - self.config.max_pending_bytes - )); - } - self.emit_available(stored_at, false) - } - - pub fn finish(&mut self, stored_at: &str) -> Result> { - self.emit_available(stored_at, true) - } - - pub fn pending_bytes(&self) -> usize { - self.pending.len() - } - - pub fn next_frame_id(&self) -> u64 { - self.next_frame_id - } - - fn emit_available( - &mut self, - stored_at: &str, - flush_last: bool, - ) -> Result> { - if stored_at.is_empty() { - return Err(anyhow!("stored_at must be non-empty")); - } - if self.pending.is_empty() { - return Ok(Vec::new()); - } - - let access_units = match video_annex_b_access_units(&self.config.codec, &self.pending) { - Ok(access_units) => access_units, - Err(error) - if error.to_string().contains("has no start codes") - || error.to_string().contains("has no NAL payloads") => - { - return Ok(Vec::new()); - } - Err(error) => return Err(error), - }; - let emit_count = if flush_last { - access_units.len() - } else { - access_units.len().saturating_sub(1) - }; - if emit_count == 0 { - return Ok(Vec::new()); - } - - let mut send_payloads = Vec::new(); - let mut emitted_bytes = 0_usize; - for access_unit in access_units.iter().take(emit_count) { - emitted_bytes = emitted_bytes - .checked_add(access_unit.bytes.len()) - .ok_or_else(|| anyhow!("emitted Annex B byte count overflow"))?; - let deadline_ticks = self - .next_pts_ticks - .checked_add(self.config.deadline_delay_ticks) - .ok_or_else(|| anyhow!("video deadline_ticks overflow"))?; - let records = packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: &self.config.stream_id, - session_id: &self.config.session_id, - codec: &self.config.codec, - frame_id: self.next_frame_id, - pts_ticks: self.next_pts_ticks, - duration_ticks: self.config.frame_duration_ticks, - timebase_num: self.config.timebase_num, - timebase_den: self.config.timebase_den, - deadline_ticks, - max_payload_bytes: self.config.max_payload_bytes, - }, - access_unit, - )?; - let wire_records = video_wire_records_with_parity(&records)?; - send_payloads.extend(wire_payloads_to_media_send(encode_media_wire_records( - &wire_records, - stored_at, - &self.config.source_runtime_id, - &self.config.source_role, - )?)); - self.advance_frame_clock()?; - } - - self.pending.drain(..emitted_bytes); - Ok(send_payloads) - } - - fn advance_frame_clock(&mut self) -> Result<()> { - self.next_frame_id = self - .next_frame_id - .checked_add(1) - .ok_or_else(|| anyhow!("video frame_id overflow"))?; - self.next_pts_ticks = self - .next_pts_ticks - .checked_add(i64::from(self.config.frame_duration_ticks)) - .ok_or_else(|| anyhow!("video pts_ticks overflow"))?; - Ok(()) - } -} - -pub struct AudioAdtsStreamSendState { - config: AudioAdtsStreamSendConfig, - pending: Vec, - next_packet_id: u64, - next_pts_ticks: i64, -} - -impl AudioAdtsStreamSendState { - pub fn new(config: AudioAdtsStreamSendConfig) -> Result { - if !matches!( - config.codec.trim().to_ascii_lowercase().as_str(), - "aac" | "aac-adts" | "adts" | "audio/aac" - ) { - return Err(anyhow!("ADTS stream sender requires AAC/ADTS codec")); - } - if config.stream_id.is_empty() { - return Err(anyhow!("stream_id must be non-empty")); - } - if config.session_id.is_empty() { - return Err(anyhow!("session_id must be non-empty")); - } - if config.packet_duration_ticks == 0 { - return Err(anyhow!("packet_duration_ticks must be greater than zero")); - } - if config.timebase_num == 0 || config.timebase_den == 0 { - return Err(anyhow!("audio timebase must be non-zero")); - } - if config.deadline_delay_ticks < 0 { - return Err(anyhow!("deadline_delay_ticks must be non-negative")); - } - if config.max_pending_bytes == 0 { - return Err(anyhow!("max_pending_bytes must be greater than zero")); - } - if config.source_runtime_id.is_empty() { - return Err(anyhow!("source_runtime_id must be non-empty")); - } - if config.source_role.is_empty() { - return Err(anyhow!("source_role must be non-empty")); - } - - Ok(Self { - next_packet_id: config.first_packet_id, - next_pts_ticks: config.first_pts_ticks, - config, - pending: Vec::new(), - }) - } - - pub fn push(&mut self, stored_at: &str, bytes: &[u8]) -> Result> { - if !bytes.is_empty() { - self.pending.extend_from_slice(bytes); - } - if self.pending.len() > self.config.max_pending_bytes { - return Err(anyhow!( - "ADTS stream sender pending buffer exceeded {} bytes without a complete packet", - self.config.max_pending_bytes - )); - } - self.emit_available(stored_at) - } - - pub fn finish(&mut self, stored_at: &str) -> Result> { - let payloads = self.emit_available(stored_at)?; - if !self.pending.is_empty() { - return Err(anyhow!( - "ADTS stream sender finished with {} trailing bytes", - self.pending.len() - )); - } - Ok(payloads) - } - - pub fn pending_bytes(&self) -> usize { - self.pending.len() - } - - pub fn next_packet_id(&self) -> u64 { - self.next_packet_id - } - - fn emit_available(&mut self, stored_at: &str) -> Result> { - if stored_at.is_empty() { - return Err(anyhow!("stored_at must be non-empty")); - } - - let frames = complete_adts_frames(&self.pending)?; - if frames.consumed_bytes == 0 { - return Ok(Vec::new()); - } - - let mut payloads = Vec::new(); - for frame in frames.frames { - let deadline_ticks = self - .next_pts_ticks - .checked_add(self.config.deadline_delay_ticks) - .ok_or_else(|| anyhow!("audio deadline_ticks overflow"))?; - payloads.push(audio_packet_send_payload( - AudioPacketWireOptions { - packetize: AudioPacketizeOptions { - stream_id: &self.config.stream_id, - session_id: &self.config.session_id, - codec: &self.config.codec, - packet_id: self.next_packet_id, - pts_ticks: self.next_pts_ticks, - duration_ticks: self.config.packet_duration_ticks, - timebase_num: self.config.timebase_num, - timebase_den: self.config.timebase_den, - deadline_ticks, - }, - stored_at, - source_runtime_id: &self.config.source_runtime_id, - source_role: &self.config.source_role, - }, - &frame, - )?); - self.advance_packet_clock()?; - } - - self.pending.drain(..frames.consumed_bytes); - Ok(payloads) - } - - fn advance_packet_clock(&mut self) -> Result<()> { - self.next_packet_id = self - .next_packet_id - .checked_add(1) - .ok_or_else(|| anyhow!("audio packet_id overflow"))?; - self.next_pts_ticks = self - .next_pts_ticks - .checked_add(i64::from(self.config.packet_duration_ticks)) - .ok_or_else(|| anyhow!("audio pts_ticks overflow"))?; - Ok(()) - } -} - -pub struct AudioPcmStreamSendState { - config: AudioPcmStreamSendConfig, - pending: Vec, - next_packet_id: u64, - next_pts_ticks: i64, -} - -impl AudioPcmStreamSendState { - pub fn new(config: AudioPcmStreamSendConfig) -> Result { - if config.stream_id.is_empty() { - return Err(anyhow!("stream_id must be non-empty")); - } - if config.session_id.is_empty() { - return Err(anyhow!("session_id must be non-empty")); - } - if config.codec.is_empty() { - return Err(anyhow!("codec must be non-empty")); - } - if config.packet_duration_ticks == 0 { - return Err(anyhow!("packet_duration_ticks must be greater than zero")); - } - if config.timebase_num == 0 || config.timebase_den == 0 { - return Err(anyhow!("audio timebase must be non-zero")); - } - if config.deadline_delay_ticks < 0 { - return Err(anyhow!("deadline_delay_ticks must be non-negative")); - } - if config.channels == 0 { - return Err(anyhow!("audio channels must be non-zero")); - } - if config.bytes_per_sample == 0 { - return Err(anyhow!("audio bytes_per_sample must be non-zero")); - } - if config.max_pending_bytes == 0 { - return Err(anyhow!("max_pending_bytes must be greater than zero")); - } - if config.source_runtime_id.is_empty() { - return Err(anyhow!("source_runtime_id must be non-empty")); - } - if config.source_role.is_empty() { - return Err(anyhow!("source_role must be non-empty")); - } - - Ok(Self { - next_packet_id: config.first_packet_id, - next_pts_ticks: config.first_pts_ticks, - config, - pending: Vec::new(), - }) - } - - pub fn push(&mut self, stored_at: &str, bytes: &[u8]) -> Result> { - if !bytes.is_empty() { - self.pending.extend_from_slice(bytes); - } - if self.pending.len() > self.config.max_pending_bytes { - return Err(anyhow!( - "PCM stream sender pending buffer exceeded {} bytes without a complete packet", - self.config.max_pending_bytes - )); - } - self.emit_available(stored_at) - } - - pub fn finish(&mut self, stored_at: &str) -> Result> { - let payloads = self.emit_available(stored_at)?; - if !self.pending.is_empty() { - return Err(anyhow!( - "PCM stream sender finished with {} trailing bytes", - self.pending.len() - )); - } - Ok(payloads) - } - - fn emit_available(&mut self, stored_at: &str) -> Result> { - if stored_at.is_empty() { - return Err(anyhow!("stored_at must be non-empty")); - } - - let bytes_per_frame = usize::try_from(self.config.channels) - .ok() - .and_then(|channels| { - usize::try_from(self.config.bytes_per_sample) - .ok() - .map(|bytes_per_sample| channels.saturating_mul(bytes_per_sample)) - }) - .ok_or_else(|| anyhow!("PCM stream sender bytes_per_frame overflow"))?; - let bytes_per_packet = bytes_per_frame - .checked_mul(self.config.packet_duration_ticks as usize) - .ok_or_else(|| anyhow!("PCM stream sender bytes_per_packet overflow"))?; - if bytes_per_packet == 0 { - return Err(anyhow!( - "PCM stream sender bytes_per_packet must be non-zero" - )); - } - let complete_packets = self.pending.len() / bytes_per_packet; - if complete_packets == 0 { - return Ok(Vec::new()); - } - - let mut payloads = Vec::with_capacity(complete_packets); - for packet_index in 0..complete_packets { - let start = packet_index * bytes_per_packet; - let end = start + bytes_per_packet; - let deadline_ticks = self - .next_pts_ticks - .checked_add(self.config.deadline_delay_ticks) - .ok_or_else(|| anyhow!("audio deadline_ticks overflow"))?; - payloads.push(audio_packet_send_payload( - AudioPacketWireOptions { - packetize: AudioPacketizeOptions { - stream_id: &self.config.stream_id, - session_id: &self.config.session_id, - codec: &self.config.codec, - packet_id: self.next_packet_id, - pts_ticks: self.next_pts_ticks, - duration_ticks: self.config.packet_duration_ticks, - timebase_num: self.config.timebase_num, - timebase_den: self.config.timebase_den, - deadline_ticks, - }, - stored_at, - source_runtime_id: &self.config.source_runtime_id, - source_role: &self.config.source_role, - }, - &self.pending[start..end], - )?); - self.advance_packet_clock()?; - } - - self.pending.drain(..complete_packets * bytes_per_packet); - Ok(payloads) - } - - fn advance_packet_clock(&mut self) -> Result<()> { - self.next_packet_id = self - .next_packet_id - .checked_add(1) - .ok_or_else(|| anyhow!("audio packet_id overflow"))?; - self.next_pts_ticks = self - .next_pts_ticks - .checked_add(i64::from(self.config.packet_duration_ticks)) - .ok_or_else(|| anyhow!("audio pts_ticks overflow"))?; - Ok(()) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct VideoChunkKey { - pub frame_id: u64, - pub chunk_index: u16, -} - -impl VideoChunkKey { - pub fn new(frame_id: u64, chunk_index: u16) -> Self { - Self { - frame_id, - chunk_index, - } - } - - pub fn parse(value: &str) -> Result { - if value.matches(':').count() != 1 { - return Err(anyhow!( - "video chunk key must contain exactly one ':' separator" - )); - } - let (frame_id, chunk_index) = value - .split_once(':') - .ok_or_else(|| anyhow!("video chunk key must be ':'"))?; - Ok(Self { - frame_id: frame_id - .parse() - .map_err(|_| anyhow!("video chunk key frame_id must be an integer"))?, - chunk_index: chunk_index - .parse() - .map_err(|_| anyhow!("video chunk key chunk_index must be an integer"))?, - }) - } - - pub fn as_feedback_key(&self) -> String { - format!("{}:{}", self.frame_id, self.chunk_index) - } -} - -pub fn video_chunk_feedback_key(frame_id: u64, chunk_index: u16) -> String { - VideoChunkKey::new(frame_id, chunk_index).as_feedback_key() -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ExpiredVideoFrame { - pub key: VideoFrameKey, - pub deadline_ticks: i64, - pub missing_video_chunk_keys: Vec, -} - -#[derive(Clone, Debug)] -pub struct VideoFrameAssembly { - stream_id: String, - session_id: String, - frame_id: u64, - chunk_count: u16, - chunks: BTreeMap, -} - -impl VideoFrameAssembly { - pub fn new(first_chunk: MuninnMediaVideoAccessUnitRecord) -> Result { - if first_chunk.chunk_count == 0 { - return Err(anyhow!("video frame assembly chunk_count must be non-zero")); - } - if first_chunk.chunk_index >= first_chunk.chunk_count { - return Err(anyhow!( - "video frame assembly chunk_index {} is outside chunk_count {}", - first_chunk.chunk_index, - first_chunk.chunk_count - )); - } - if first_chunk.payload.is_empty() { - return Err(anyhow!( - "video frame assembly chunk payload must be non-empty" - )); - } - - let mut chunks = BTreeMap::new(); - chunks.insert(first_chunk.chunk_index, first_chunk.clone()); - Ok(Self { - stream_id: first_chunk.stream_id, - session_id: first_chunk.session_id, - frame_id: first_chunk.frame_id, - chunk_count: first_chunk.chunk_count, - chunks, - }) - } - - pub fn insert(&mut self, chunk: MuninnMediaVideoAccessUnitRecord) -> Result<()> { - self.require_matching_frame(&chunk)?; - if self.chunks.contains_key(&chunk.chunk_index) { - return Err(anyhow!( - "video frame assembly has duplicate chunk_index {}", - chunk.chunk_index - )); - } - self.chunks.insert(chunk.chunk_index, chunk); - Ok(()) - } - - pub fn is_complete(&self) -> bool { - self.chunks.len() == self.chunk_count as usize - } - - pub fn missing_video_chunk_keys(&self) -> Vec { - (0..self.chunk_count) - .filter(|chunk_index| !self.chunks.contains_key(chunk_index)) - .map(|chunk_index| video_chunk_feedback_key(self.frame_id, chunk_index)) - .collect() - } - - pub fn deadline_ticks(&self) -> i64 { - self.chunks - .values() - .next() - .map(|chunk| chunk.deadline_ticks) - .unwrap_or_default() - } - - pub fn reassemble(&self) -> Result { - let chunks = self.chunks.values().cloned().collect::>(); - reassemble_video_access_unit(&chunks) - } - - fn require_matching_frame(&self, chunk: &MuninnMediaVideoAccessUnitRecord) -> Result<()> { - if chunk.stream_id != self.stream_id { - return Err(anyhow!("video frame assembly received mixed stream_id")); - } - if chunk.session_id != self.session_id { - return Err(anyhow!("video frame assembly received mixed session_id")); - } - if chunk.frame_id != self.frame_id { - return Err(anyhow!("video frame assembly received mixed frame_id")); - } - if chunk.chunk_count != self.chunk_count { - return Err(anyhow!("video frame assembly received mixed chunk_count")); - } - if chunk.chunk_index >= self.chunk_count { - return Err(anyhow!( - "video frame assembly chunk_index {} is outside chunk_count {}", - chunk.chunk_index, - self.chunk_count - )); - } - if chunk.payload.is_empty() { - return Err(anyhow!( - "video frame assembly chunk payload must be non-empty" - )); - } - if let Some(first) = self.chunks.values().next() { - if chunk.codec != first.codec - || chunk.pts_ticks != first.pts_ticks - || chunk.duration_ticks != first.duration_ticks - || chunk.timebase_num != first.timebase_num - || chunk.timebase_den != first.timebase_den - || chunk.deadline_ticks != first.deadline_ticks - || chunk.keyframe != first.keyframe - || chunk.dependency_frame_id != first.dependency_frame_id - { - return Err(anyhow!( - "video frame assembly received mixed media metadata" - )); - } - } - Ok(()) - } -} - -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] -pub struct VideoFrameKey { - pub stream_id: String, - pub session_id: String, - pub frame_id: u64, -} - -impl VideoFrameKey { - pub fn from_chunk(chunk: &MuninnMediaVideoAccessUnitRecord) -> Self { - Self { - stream_id: chunk.stream_id.clone(), - session_id: chunk.session_id.clone(), - frame_id: chunk.frame_id, - } - } -} - -#[derive(Default)] -pub struct VideoFrameAssemblySet { - frames: BTreeMap, -} - -impl VideoFrameAssemblySet { - pub fn insert_chunk( - &mut self, - chunk: MuninnMediaVideoAccessUnitRecord, - ) -> Result> { - let key = VideoFrameKey::from_chunk(&chunk); - if let Some(assembly) = self.frames.get_mut(&key) { - assembly.insert(chunk)?; - if assembly.is_complete() { - let access_unit = assembly.reassemble()?; - self.frames.remove(&key); - return Ok(Some(access_unit)); - } - return Ok(None); - } - - let assembly = VideoFrameAssembly::new(chunk)?; - if assembly.is_complete() { - return Ok(Some(assembly.reassemble()?)); - } - self.frames.insert(key, assembly); - Ok(None) - } - - pub fn missing_video_chunk_keys(&self, key: &VideoFrameKey) -> Vec { - self.frames - .get(key) - .map(VideoFrameAssembly::missing_video_chunk_keys) - .unwrap_or_default() - } - - pub fn pending_frame_count(&self) -> usize { - self.frames.len() - } - - pub fn expire_late_frames(&mut self, now_ticks: i64) -> Vec { - let expired_keys = self - .frames - .iter() - .filter_map(|(key, assembly)| { - if assembly.deadline_ticks() <= now_ticks { - Some(key.clone()) - } else { - None - } - }) - .collect::>(); - - expired_keys - .into_iter() - .filter_map(|key| { - self.frames.remove(&key).map(|assembly| ExpiredVideoFrame { - key, - deadline_ticks: assembly.deadline_ticks(), - missing_video_chunk_keys: assembly.missing_video_chunk_keys(), - }) - }) - .collect() - } -} - -pub fn packetize_video_access_unit( - options: VideoFramePacketizeOptions<'_>, - access_unit: &VideoAccessUnit, -) -> Result> { - if options.stream_id.is_empty() { - return Err(anyhow!("stream_id must be non-empty")); - } - if options.session_id.is_empty() { - return Err(anyhow!("session_id must be non-empty")); - } - if options.codec.is_empty() { - return Err(anyhow!("codec must be non-empty")); - } - if options.timebase_num == 0 || options.timebase_den == 0 { - return Err(anyhow!("video timebase must be non-zero")); - } - if options.duration_ticks == 0 { - return Err(anyhow!("video duration_ticks must be greater than zero")); - } - if options.deadline_ticks < options.pts_ticks { - return Err(anyhow!("video deadline_ticks must not precede pts_ticks")); - } - if options.max_payload_bytes == 0 { - return Err(anyhow!("max_payload_bytes must be greater than zero")); - } - if access_unit.bytes.is_empty() { - return Err(anyhow!("access unit payload must be non-empty")); - } - - let chunk_count = access_unit.bytes.len().div_ceil(options.max_payload_bytes); - if chunk_count > u16::MAX as usize { - return Err(anyhow!("video access unit requires more than 65535 chunks")); - } - - let mut records = Vec::with_capacity(chunk_count); - for (chunk_index, payload) in access_unit - .bytes - .chunks(options.max_payload_bytes) - .enumerate() - { - records.push(MuninnMediaVideoAccessUnitRecord { - stream_id: options.stream_id.to_string(), - session_id: options.session_id.to_string(), - frame_id: options.frame_id, - codec: options.codec.to_string(), - pts_ticks: options.pts_ticks, - duration_ticks: options.duration_ticks, - timebase_num: options.timebase_num, - timebase_den: options.timebase_den, - keyframe: access_unit.keyframe, - dependency_frame_id: if access_unit.keyframe - || !access_unit_references_previous_frame(options.codec, access_unit) - { - None - } else { - options.frame_id.checked_sub(1) - }, - deadline_ticks: options.deadline_ticks, - chunk_index: chunk_index as u16, - chunk_count: chunk_count as u16, - payload: payload.to_vec(), - }); - } - - Ok(records) -} - -const MUNINN_VIDEO_PARITY_STRIPES: u16 = 16; - -pub fn build_video_parity_shards( - chunks: &[MuninnMediaVideoAccessUnitRecord], -) -> Result> { - if chunks.len() <= 1 { - return Ok(Vec::new()); - } - let first = chunks - .first() - .ok_or_else(|| anyhow!("video parity requires at least one chunk"))?; - validate_video_record(first)?; - if chunks.len() != first.chunk_count as usize { - return Err(anyhow!( - "video parity requires all chunks: expected {}, received {}", - first.chunk_count, - chunks.len() - )); - } - - let mut by_index = BTreeMap::new(); - for chunk in chunks { - validate_video_record(chunk)?; - if chunk.stream_id != first.stream_id - || chunk.session_id != first.session_id - || chunk.frame_id != first.frame_id - || chunk.codec != first.codec - || chunk.pts_ticks != first.pts_ticks - || chunk.duration_ticks != first.duration_ticks - || chunk.timebase_num != first.timebase_num - || chunk.timebase_den != first.timebase_den - || chunk.keyframe != first.keyframe - || chunk.dependency_frame_id != first.dependency_frame_id - || chunk.deadline_ticks != first.deadline_ticks - || chunk.chunk_count != first.chunk_count - { - return Err(anyhow!("video parity chunks have mixed metadata")); - } - if by_index.insert(chunk.chunk_index, chunk).is_some() { - return Err(anyhow!( - "video parity received duplicate chunk_index {}", - chunk.chunk_index - )); - } - } - - let mut chunk_payload_len = 0_usize; - let mut last_chunk_payload_bytes = 0_u32; - for index in 0..first.chunk_count { - let chunk = by_index - .get(&index) - .ok_or_else(|| anyhow!("video parity missing chunk_index {index}"))?; - chunk_payload_len = chunk_payload_len.max(chunk.payload.len()); - if index == first.chunk_count - 1 { - last_chunk_payload_bytes = u32::try_from(chunk.payload.len()) - .context("video parity last chunk payload length exceeds u32")?; - } - } - let chunk_payload_bytes = u32::try_from(chunk_payload_len) - .context("video parity chunk payload length exceeds u32")?; - let parity_count = first.chunk_count.min(MUNINN_VIDEO_PARITY_STRIPES); - let mut records = Vec::with_capacity(parity_count as usize); - for parity_index in 0..parity_count { - let mut parity_len = 0_usize; - for index in (parity_index..first.chunk_count).step_by(parity_count as usize) { - let chunk = by_index.get(&index).expect("chunk index was checked above"); - parity_len = parity_len.max(chunk.payload.len()); - } - if parity_len == 0 { - continue; - } - let mut parity = vec![0_u8; parity_len]; - for index in (parity_index..first.chunk_count).step_by(parity_count as usize) { - let chunk = by_index.get(&index).expect("chunk index was checked above"); - for (offset, byte) in chunk.payload.iter().enumerate() { - parity[offset] ^= *byte; - } - } - records.push(MuninnMediaVideoParityShardRecord { - stream_id: first.stream_id.clone(), - session_id: first.session_id.clone(), - frame_id: first.frame_id, - codec: first.codec.clone(), - pts_ticks: first.pts_ticks, - duration_ticks: first.duration_ticks, - timebase_num: first.timebase_num, - timebase_den: first.timebase_den, - keyframe: first.keyframe, - dependency_frame_id: first.dependency_frame_id, - deadline_ticks: first.deadline_ticks, - chunk_count: first.chunk_count, - parity_index, - parity_count, - chunk_payload_bytes, - last_chunk_payload_bytes, - payload: parity, - }); - } - Ok(records) -} - -fn video_wire_records_with_parity( - records: &[MuninnMediaVideoAccessUnitRecord], -) -> Result> { - let mut wire_records = Vec::new(); - let mut offset = 0_usize; - while offset < records.len() { - let first = &records[offset]; - let mut end = offset + 1; - while end < records.len() - && records[end].stream_id == first.stream_id - && records[end].session_id == first.session_id - && records[end].frame_id == first.frame_id - { - end += 1; - } - let frame_records = &records[offset..end]; - wire_records.extend( - frame_records - .iter() - .cloned() - .map(MuninnMediaWireRecord::Video), - ); - for parity in build_video_parity_shards(frame_records)? { - wire_records.push(MuninnMediaWireRecord::VideoParity(parity)); - } - offset = end; - } - Ok(wire_records) -} - -pub fn packetize_video_annex_b_stream( - options: VideoAnnexBStreamPacketizeOptions<'_>, - input: &[u8], -) -> Result> { - if options.frame_duration_ticks == 0 { - return Err(anyhow!("frame_duration_ticks must be greater than zero")); - } - if options.deadline_delay_ticks < 0 { - return Err(anyhow!("deadline_delay_ticks must be non-negative")); - } - - let access_units = video_annex_b_access_units(options.codec, input)?; - let mut records = Vec::new(); - for (index, access_unit) in access_units.iter().enumerate() { - let frame_offset = u64::try_from(index).context("video frame index overflow")?; - let frame_id = options - .first_frame_id - .checked_add(frame_offset) - .ok_or_else(|| anyhow!("video frame_id overflow"))?; - let pts_offset = i64::try_from(index) - .ok() - .and_then(|index| index.checked_mul(i64::from(options.frame_duration_ticks))) - .ok_or_else(|| anyhow!("video pts_ticks overflow"))?; - let pts_ticks = options - .first_pts_ticks - .checked_add(pts_offset) - .ok_or_else(|| anyhow!("video pts_ticks overflow"))?; - let deadline_ticks = pts_ticks - .checked_add(options.deadline_delay_ticks) - .ok_or_else(|| anyhow!("video deadline_ticks overflow"))?; - - records.extend(packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: options.stream_id, - session_id: options.session_id, - codec: options.codec, - frame_id, - pts_ticks, - duration_ticks: options.frame_duration_ticks, - timebase_num: options.timebase_num, - timebase_den: options.timebase_den, - deadline_ticks, - max_payload_bytes: options.max_payload_bytes, - }, - access_unit, - )?); - } - - Ok(records) -} - -pub fn packetize_audio_packet( - options: AudioPacketizeOptions<'_>, - payload: &[u8], -) -> Result { - if options.stream_id.is_empty() { - return Err(anyhow!("stream_id must be non-empty")); - } - if options.session_id.is_empty() { - return Err(anyhow!("session_id must be non-empty")); - } - if options.codec.is_empty() { - return Err(anyhow!("codec must be non-empty")); - } - if options.timebase_num == 0 || options.timebase_den == 0 { - return Err(anyhow!("audio timebase must be non-zero")); - } - if options.duration_ticks == 0 { - return Err(anyhow!("audio duration_ticks must be greater than zero")); - } - if options.deadline_ticks < options.pts_ticks { - return Err(anyhow!("audio deadline_ticks must not precede pts_ticks")); - } - if payload.is_empty() { - return Err(anyhow!("audio packet payload must be non-empty")); - } - - Ok(MuninnMediaAudioPacketRecord { - stream_id: options.stream_id.to_string(), - session_id: options.session_id.to_string(), - packet_id: options.packet_id, - codec: options.codec.to_string(), - pts_ticks: options.pts_ticks, - duration_ticks: options.duration_ticks, - timebase_num: options.timebase_num, - timebase_den: options.timebase_den, - deadline_ticks: options.deadline_ticks, - payload: payload.to_vec(), - }) -} - -#[derive(Default)] -pub struct AudioPacketBuffer { - stream_id: Option, - session_id: Option, - codec: Option, - timebase_num: Option, - timebase_den: Option, - next_packet_id: Option, - emitted_any: bool, - packets: BTreeMap, -} - -impl AudioPacketBuffer { - pub fn insert(&mut self, packet: MuninnMediaAudioPacketRecord) -> Result<()> { - self.require_matching_audio_stream(&packet)?; - if packet.payload.is_empty() { - return Err(anyhow!("audio packet buffer payload must be non-empty")); - } - if self.packets.contains_key(&packet.packet_id) { - return Err(anyhow!( - "audio packet buffer has duplicate packet_id {}", - packet.packet_id - )); - } - match self.next_packet_id { - None => self.next_packet_id = Some(packet.packet_id), - Some(next_packet_id) if packet.packet_id < next_packet_id && !self.emitted_any => { - self.next_packet_id = Some(packet.packet_id); - } - Some(next_packet_id) if packet.packet_id < next_packet_id => { - return Err(anyhow!( - "audio packet buffer received stale packet_id {} before next expected {}", - packet.packet_id, - next_packet_id - )); - } - _ => {} - } - self.packets.insert(packet.packet_id, packet); - Ok(()) - } - - pub fn pop_ready_packets(&mut self) -> Vec { - let Some(mut next_packet_id) = self.next_packet_id else { - return Vec::new(); - }; - let mut ready = Vec::new(); - while let Some(packet) = self.packets.remove(&next_packet_id) { - ready.push(packet); - next_packet_id = next_packet_id.saturating_add(1); - } - if !ready.is_empty() { - self.emitted_any = true; - } - self.next_packet_id = Some(next_packet_id); - ready - } - - pub fn expire_late_packets(&mut self, now_ticks: i64) -> Vec { - let expired_ids = self - .packets - .iter() - .filter_map(|(packet_id, packet)| { - if packet.deadline_ticks <= now_ticks { - Some(*packet_id) - } else { - None - } - }) - .collect::>(); - for packet_id in &expired_ids { - self.packets.remove(packet_id); - } - if let Some(next_packet_id) = self.next_packet_id { - if expired_ids.contains(&next_packet_id) { - self.next_packet_id = self.packets.keys().next().copied(); - } - } - expired_ids - } - - pub fn pending_packet_count(&self) -> usize { - self.packets.len() - } - - fn require_matching_audio_stream( - &mut self, - packet: &MuninnMediaAudioPacketRecord, - ) -> Result<()> { - if packet.stream_id.is_empty() { - return Err(anyhow!("audio packet buffer stream_id must be non-empty")); - } - if packet.session_id.is_empty() { - return Err(anyhow!("audio packet buffer session_id must be non-empty")); - } - if packet.codec.is_empty() { - return Err(anyhow!("audio packet buffer codec must be non-empty")); - } - if packet.timebase_num == 0 || packet.timebase_den == 0 { - return Err(anyhow!("audio packet buffer timebase must be non-zero")); - } - - match ( - self.stream_id.as_deref(), - self.session_id.as_deref(), - self.codec.as_deref(), - self.timebase_num, - self.timebase_den, - ) { - (None, None, None, None, None) => { - self.stream_id = Some(packet.stream_id.clone()); - self.session_id = Some(packet.session_id.clone()); - self.codec = Some(packet.codec.clone()); - self.timebase_num = Some(packet.timebase_num); - self.timebase_den = Some(packet.timebase_den); - Ok(()) - } - ( - Some(stream_id), - Some(session_id), - Some(codec), - Some(timebase_num), - Some(timebase_den), - ) if stream_id == packet.stream_id - && session_id == packet.session_id - && codec == packet.codec - && timebase_num == packet.timebase_num - && timebase_den == packet.timebase_den => - { - Ok(()) - } - _ => Err(anyhow!( - "audio packet buffer received mixed stream metadata" - )), - } - } -} - -pub fn reassemble_video_access_unit( - chunks: &[MuninnMediaVideoAccessUnitRecord], -) -> Result { - let first = chunks - .first() - .ok_or_else(|| anyhow!("video access unit requires at least one chunk"))?; - if first.chunk_count == 0 { - return Err(anyhow!("video access unit chunk_count must be non-zero")); - } - - let mut by_index = BTreeMap::new(); - for chunk in chunks { - if chunk.stream_id != first.stream_id { - return Err(anyhow!( - "video access unit chunks have mixed stream_id values" - )); - } - if chunk.session_id != first.session_id { - return Err(anyhow!( - "video access unit chunks have mixed session_id values" - )); - } - if chunk.frame_id != first.frame_id { - return Err(anyhow!( - "video access unit chunks have mixed frame_id values" - )); - } - if chunk.codec != first.codec { - return Err(anyhow!("video access unit chunks have mixed codec values")); - } - if chunk.pts_ticks != first.pts_ticks - || chunk.duration_ticks != first.duration_ticks - || chunk.timebase_num != first.timebase_num - || chunk.timebase_den != first.timebase_den - || chunk.deadline_ticks != first.deadline_ticks - { - return Err(anyhow!("video access unit chunks have mixed timing values")); - } - if chunk.keyframe != first.keyframe - || chunk.dependency_frame_id != first.dependency_frame_id - { - return Err(anyhow!( - "video access unit chunks have mixed dependency values" - )); - } - if chunk.chunk_count != first.chunk_count { - return Err(anyhow!( - "video access unit chunks have mixed chunk_count values" - )); - } - if chunk.chunk_index >= first.chunk_count { - return Err(anyhow!( - "video access unit chunk_index {} is outside chunk_count {}", - chunk.chunk_index, - first.chunk_count - )); - } - if chunk.payload.is_empty() { - return Err(anyhow!("video access unit chunk payload must be non-empty")); - } - if by_index - .insert(chunk.chunk_index, chunk.payload.as_slice()) - .is_some() - { - return Err(anyhow!( - "video access unit has duplicate chunk_index {}", - chunk.chunk_index - )); - } - } - - if by_index.len() != first.chunk_count as usize { - return Err(anyhow!( - "video access unit is missing chunks: expected {}, received {}", - first.chunk_count, - by_index.len() - )); - } - - let mut bytes = Vec::new(); - for index in 0..first.chunk_count { - let payload = by_index - .get(&index) - .ok_or_else(|| anyhow!("video access unit is missing chunk_index {index}"))?; - bytes.extend_from_slice(payload); - } - - Ok(VideoAccessUnit { - bytes, - keyframe: first.keyframe, - }) -} - -pub fn build_receiver_feedback( - options: ReceiverFeedbackOptions<'_>, -) -> Result { - if options.stream_id.is_empty() { - return Err(anyhow!("stream_id must be non-empty")); - } - if options.session_id.is_empty() { - return Err(anyhow!("session_id must be non-empty")); - } - if options.receiver_id.is_empty() { - return Err(anyhow!("receiver_id must be non-empty")); - } - if options.observed_at.is_empty() { - return Err(anyhow!("observed_at must be non-empty")); - } - if options.jitter_us < 0 { - return Err(anyhow!("jitter_us must be non-negative")); - } - if options.decode_queue_us < 0 { - return Err(anyhow!("decode_queue_us must be non-negative")); - } - - let mut missing_frame_ids = options.missing_frame_ids; - missing_frame_ids.sort_unstable(); - missing_frame_ids.dedup(); - - let missing_video_chunk_keys = - normalize_video_chunk_feedback_keys(options.missing_video_chunk_keys)?; - - let mut late_frame_ids = options.late_frame_ids; - late_frame_ids.sort_unstable(); - late_frame_ids.dedup(); - - let requested_keyframe = options.requested_keyframe - || !missing_frame_ids.is_empty() - || !missing_video_chunk_keys.is_empty(); - - Ok(MuninnMediaReceiverFeedbackRecord { - stream_id: options.stream_id.to_string(), - session_id: options.session_id.to_string(), - receiver_id: options.receiver_id.to_string(), - highest_decodable_frame_id: options.highest_decodable_frame_id, - missing_frame_ids, - late_frame_ids, - requested_keyframe, - jitter_us: options.jitter_us, - decode_queue_us: options.decode_queue_us, - observed_at: options.observed_at.to_string(), - missing_video_chunk_keys, - }) -} - -pub fn build_feedback_for_expired_video_frames( - expired: &[ExpiredVideoFrame], - options: ExpiredVideoFrameFeedbackOptions<'_>, -) -> Result> { - let Some(first) = expired.first() else { - return Ok(None); - }; - - let mut late_frame_ids = Vec::with_capacity(expired.len()); - let mut missing_video_chunk_keys = Vec::new(); - for frame in expired { - if frame.key.stream_id != first.key.stream_id - || frame.key.session_id != first.key.session_id - { - return Err(anyhow!( - "expired video frame feedback requires one stream_id/session_id" - )); - } - late_frame_ids.push(frame.key.frame_id); - missing_video_chunk_keys.extend(frame.missing_video_chunk_keys.iter().cloned()); - } - - build_receiver_feedback(ReceiverFeedbackOptions { - stream_id: &first.key.stream_id, - session_id: &first.key.session_id, - receiver_id: options.receiver_id, - highest_decodable_frame_id: first.key.frame_id.checked_sub(1), - missing_frame_ids: Vec::new(), - missing_video_chunk_keys, - late_frame_ids, - requested_keyframe: true, - jitter_us: options.jitter_us, - decode_queue_us: options.decode_queue_us, - observed_at: options.observed_at, - }) - .map(Some) -} - -pub fn encode_media_wire_record( - record: &MuninnMediaWireRecord, - stored_at: &str, - source_runtime_id: &str, - source_role: &str, -) -> Result> { - if stored_at.is_empty() { - return Err(anyhow!("stored_at must be non-empty")); - } - if source_runtime_id.is_empty() { - return Err(anyhow!("source_runtime_id must be non-empty")); - } - if source_role.is_empty() { - return Err(anyhow!("source_role must be non-empty")); - } - - let (schema_id, record_key, payload) = match record { - MuninnMediaWireRecord::Video(record) => ( - MUNINN_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA, - video_record_key(record), - encode_video_record_payload(record)?, - ), - MuninnMediaWireRecord::VideoParity(record) => ( - MUNINN_MEDIA_VIDEO_PARITY_SHARD_SCHEMA, - video_parity_record_key(record), - encode_video_parity_record_payload(record)?, - ), - MuninnMediaWireRecord::Audio(record) => ( - MUNINN_MEDIA_AUDIO_PACKET_SCHEMA, - audio_record_key(record), - encode_audio_record_payload(record)?, - ), - MuninnMediaWireRecord::Feedback(record) => ( - MUNINN_MEDIA_RECEIVER_FEEDBACK_SCHEMA, - feedback_record_key(record), - encode_record_payload(record)?, - ), - }; - - let message = CultNetMessage::DocumentPutRaw { - message_id: format!( - "muninn-media:{}:{}", - schema_id, - record_key.replace(':', "-") - ), - document: CultNetRawDocumentRecord { - schema_id: schema_id.to_string(), - record_key, - stored_at: stored_at.to_string(), - payload_encoding: CultNetRawPayloadEncoding::Messagepack, - payload, - source_runtime_id: Some(source_runtime_id.to_string()), - source_agent_id: None, - source_role: Some(source_role.to_string()), - tags: Some(vec!["muninn.media".to_string()]), - }, - }; - - encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0) - .map_err(Into::into) -} - -pub fn encode_media_wire_records( - records: &[MuninnMediaWireRecord], - stored_at: &str, - source_runtime_id: &str, - source_role: &str, -) -> Result>> { - records - .iter() - .map(|record| encode_media_wire_record(record, stored_at, source_runtime_id, source_role)) - .collect() -} - -pub fn encode_video_annex_b_stream_wire_records( - options: VideoAnnexBStreamWireOptions<'_>, - input: &[u8], -) -> Result>> { - let records = packetize_video_annex_b_stream(options.packetize, input)?; - let wire_records = video_wire_records_with_parity(&records)?; - encode_media_wire_records( - &wire_records, - options.stored_at, - options.source_runtime_id, - options.source_role, - ) -} - -pub fn video_annex_b_stream_send_payloads( - options: VideoAnnexBStreamWireOptions<'_>, - input: &[u8], -) -> Result> { - encode_video_annex_b_stream_wire_records(options, input).map(wire_payloads_to_media_send) -} - -pub fn encode_audio_packet_wire_record( - options: AudioPacketWireOptions<'_>, - payload: &[u8], -) -> Result> { - let record = packetize_audio_packet(options.packetize, payload)?; - encode_media_wire_record( - &MuninnMediaWireRecord::Audio(record), - options.stored_at, - options.source_runtime_id, - options.source_role, - ) -} - -pub fn audio_packet_send_payload( - options: AudioPacketWireOptions<'_>, - payload: &[u8], -) -> Result { - encode_audio_packet_wire_record(options, payload).map(wire_payload_to_media_send) -} - -fn wire_payloads_to_media_send(payloads: Vec>) -> Vec { - payloads - .into_iter() - .map(wire_payload_to_media_send) - .collect() -} - -fn wire_payload_to_media_send(payload: Vec) -> MuninnMediaSendPayload { - MuninnMediaSendPayload { - channel_id: MUNINN_MEDIA_RUDP_CHANNEL, - payload, - } -} - -pub fn decode_media_wire_record(payload: &[u8]) -> Result { - let message = decode_cultnet_message_from_slice(payload, CultNetWireContract::CultNetSchemaV0)?; - let CultNetMessage::DocumentPutRaw { document, .. } = message else { - return Err(anyhow!("expected cultnet.document_put_raw.v0")); - }; - if document.payload_encoding != CultNetRawPayloadEncoding::Messagepack { - return Err(anyhow!("Muninn media document payload must be MessagePack")); - } - - match document.schema_id.as_str() { - MUNINN_MEDIA_VIDEO_ACCESS_UNIT_SCHEMA => { - let record: MuninnMediaVideoAccessUnitRecord = - decode_record_payload(&document.payload)?; - validate_video_record(&record)?; - let expected_key = video_record_key(&record); - if document.record_key != expected_key { - return Err(anyhow!( - "Muninn video media record key mismatch: expected {}, received {}", - expected_key, - document.record_key - )); - } - Ok(MuninnMediaWireRecord::Video(record)) - } - MUNINN_MEDIA_VIDEO_PARITY_SHARD_SCHEMA => { - let record: MuninnMediaVideoParityShardRecord = - decode_record_payload(&document.payload)?; - validate_video_parity_record(&record)?; - let expected_key = video_parity_record_key(&record); - if document.record_key != expected_key { - return Err(anyhow!( - "Muninn video parity media record key mismatch: expected {}, received {}", - expected_key, - document.record_key - )); - } - Ok(MuninnMediaWireRecord::VideoParity(record)) - } - MUNINN_MEDIA_AUDIO_PACKET_SCHEMA => { - let record: MuninnMediaAudioPacketRecord = decode_record_payload(&document.payload)?; - validate_audio_record(&record)?; - let expected_key = audio_record_key(&record); - if document.record_key != expected_key { - return Err(anyhow!( - "Muninn audio media record key mismatch: expected {}, received {}", - expected_key, - document.record_key - )); - } - Ok(MuninnMediaWireRecord::Audio(record)) - } - MUNINN_MEDIA_RECEIVER_FEEDBACK_SCHEMA => { - let record: MuninnMediaReceiverFeedbackRecord = - decode_record_payload(&document.payload)?; - validate_feedback_record(&record)?; - let expected_key = feedback_record_key(&record); - if document.record_key != expected_key { - return Err(anyhow!( - "Muninn receiver feedback record key mismatch: expected {}, received {}", - expected_key, - document.record_key - )); - } - Ok(MuninnMediaWireRecord::Feedback(record)) - } - schema_id => Err(anyhow!("unsupported Muninn media schema {schema_id}")), - } -} - -fn encode_record_payload(record: &T) -> Result> { - rmp_serde::to_vec(record).map_err(Into::into) -} - -fn encode_video_record_payload(record: &MuninnMediaVideoAccessUnitRecord) -> Result> { - encode_record_payload(&VideoAccessUnitWirePayload( - &record.stream_id, - &record.session_id, - record.frame_id, - &record.codec, - record.pts_ticks, - record.duration_ticks, - record.timebase_num, - record.timebase_den, - record.keyframe, - record.dependency_frame_id, - record.deadline_ticks, - record.chunk_index, - record.chunk_count, - &record.payload, - )) -} - -fn encode_video_parity_record_payload( - record: &MuninnMediaVideoParityShardRecord, -) -> Result> { - encode_record_payload(&VideoParityShardWirePayload( - &record.stream_id, - &record.session_id, - record.frame_id, - &record.codec, - record.pts_ticks, - record.duration_ticks, - record.timebase_num, - record.timebase_den, - record.keyframe, - record.dependency_frame_id, - record.deadline_ticks, - record.chunk_count, - record.parity_index, - record.parity_count, - record.chunk_payload_bytes, - record.last_chunk_payload_bytes, - &record.payload, - )) -} - -fn encode_audio_record_payload(record: &MuninnMediaAudioPacketRecord) -> Result> { - encode_record_payload(&AudioPacketWirePayload( - &record.stream_id, - &record.session_id, - record.packet_id, - &record.codec, - record.pts_ticks, - record.duration_ticks, - record.timebase_num, - record.timebase_den, - record.deadline_ticks, - &record.payload, - )) -} - -fn decode_record_payload(payload: &[u8]) -> Result { - rmp_serde::from_slice(payload).map_err(Into::into) -} - -fn validate_video_record(record: &MuninnMediaVideoAccessUnitRecord) -> Result<()> { - if record.stream_id.is_empty() { - return Err(anyhow!("video media record stream_id must be non-empty")); - } - if record.session_id.is_empty() { - return Err(anyhow!("video media record session_id must be non-empty")); - } - if record.codec.is_empty() { - return Err(anyhow!("video media record codec must be non-empty")); - } - if record.timebase_num == 0 || record.timebase_den == 0 { - return Err(anyhow!("video media record timebase must be non-zero")); - } - if record.duration_ticks == 0 { - return Err(anyhow!( - "video media record duration_ticks must be greater than zero" - )); - } - if record.deadline_ticks < record.pts_ticks { - return Err(anyhow!( - "video media record deadline_ticks must not precede pts_ticks" - )); - } - if record.chunk_count == 0 { - return Err(anyhow!("video media record chunk_count must be non-zero")); - } - if record.chunk_index >= record.chunk_count { - return Err(anyhow!( - "video media record chunk_index {} is outside chunk_count {}", - record.chunk_index, - record.chunk_count - )); - } - if record.payload.is_empty() { - return Err(anyhow!("video media record payload must be non-empty")); - } - Ok(()) -} - -fn validate_video_parity_record(record: &MuninnMediaVideoParityShardRecord) -> Result<()> { - if record.stream_id.is_empty() { - return Err(anyhow!( - "video parity media record stream_id must be non-empty" - )); - } - if record.session_id.is_empty() { - return Err(anyhow!( - "video parity media record session_id must be non-empty" - )); - } - if record.codec.is_empty() { - return Err(anyhow!("video parity media record codec must be non-empty")); - } - if record.timebase_num == 0 || record.timebase_den == 0 { - return Err(anyhow!( - "video parity media record timebase must be non-zero" - )); - } - if record.duration_ticks == 0 { - return Err(anyhow!( - "video parity media record duration_ticks must be greater than zero" - )); - } - if record.deadline_ticks < record.pts_ticks { - return Err(anyhow!( - "video parity media record deadline_ticks must not precede pts_ticks" - )); - } - if record.chunk_count == 0 { - return Err(anyhow!( - "video parity media record chunk_count must be non-zero" - )); - } - if record.parity_count == 0 || record.parity_index >= record.parity_count { - return Err(anyhow!( - "video parity media record has invalid parity stripe metadata" - )); - } - if record.parity_count > record.chunk_count { - return Err(anyhow!( - "video parity media record parity_count exceeds chunk_count" - )); - } - if record.chunk_payload_bytes == 0 || record.last_chunk_payload_bytes == 0 { - return Err(anyhow!( - "video parity media record chunk lengths must be non-zero" - )); - } - if record.last_chunk_payload_bytes > record.chunk_payload_bytes { - return Err(anyhow!( - "video parity media record last chunk length exceeds regular chunk length" - )); - } - if record.payload.is_empty() { - return Err(anyhow!( - "video parity media record payload must be non-empty" - )); - } - if record.payload.len() > record.chunk_payload_bytes as usize { - return Err(anyhow!( - "video parity media record payload exceeds declared chunk length" - )); - } - Ok(()) -} - -fn validate_audio_record(record: &MuninnMediaAudioPacketRecord) -> Result<()> { - if record.stream_id.is_empty() { - return Err(anyhow!("audio media record stream_id must be non-empty")); - } - if record.session_id.is_empty() { - return Err(anyhow!("audio media record session_id must be non-empty")); - } - if record.codec.is_empty() { - return Err(anyhow!("audio media record codec must be non-empty")); - } - if record.timebase_num == 0 || record.timebase_den == 0 { - return Err(anyhow!("audio media record timebase must be non-zero")); - } - if record.duration_ticks == 0 { - return Err(anyhow!( - "audio media record duration_ticks must be greater than zero" - )); - } - if record.deadline_ticks < record.pts_ticks { - return Err(anyhow!( - "audio media record deadline_ticks must not precede pts_ticks" - )); - } - if record.payload.is_empty() { - return Err(anyhow!("audio media record payload must be non-empty")); - } - Ok(()) -} - -fn validate_feedback_record(record: &MuninnMediaReceiverFeedbackRecord) -> Result<()> { - if record.stream_id.is_empty() { - return Err(anyhow!( - "receiver feedback media record stream_id must be non-empty" - )); - } - if record.session_id.is_empty() { - return Err(anyhow!( - "receiver feedback media record session_id must be non-empty" - )); - } - if record.receiver_id.is_empty() { - return Err(anyhow!( - "receiver feedback media record receiver_id must be non-empty" - )); - } - if record.observed_at.is_empty() { - return Err(anyhow!( - "receiver feedback media record observed_at must be non-empty" - )); - } - if record.jitter_us < 0 { - return Err(anyhow!( - "receiver feedback media record jitter_us must be non-negative" - )); - } - if record.decode_queue_us < 0 { - return Err(anyhow!( - "receiver feedback media record decode_queue_us must be non-negative" - )); - } - normalize_video_chunk_feedback_keys(record.missing_video_chunk_keys.clone())?; - Ok(()) -} - -fn video_record_key(record: &MuninnMediaVideoAccessUnitRecord) -> String { - format!( - "{}:{}:video:{}:{}", - record.stream_id, record.session_id, record.frame_id, record.chunk_index - ) -} - -fn video_parity_record_key(record: &MuninnMediaVideoParityShardRecord) -> String { - format!( - "{}:{}:video-parity:{}:{}", - record.stream_id, record.session_id, record.frame_id, record.parity_index - ) -} - -fn audio_record_key(record: &MuninnMediaAudioPacketRecord) -> String { - format!( - "{}:{}:audio:{}", - record.stream_id, record.session_id, record.packet_id - ) -} - -fn feedback_record_key(record: &MuninnMediaReceiverFeedbackRecord) -> String { - format!( - "{}:{}:feedback:{}", - record.stream_id, record.session_id, record.receiver_id - ) -} - -fn normalize_video_chunk_feedback_keys(keys: Vec) -> Result> { - let mut parsed = keys - .iter() - .map(|key| VideoChunkKey::parse(key)) - .collect::>>()?; - parsed.sort_unstable(); - parsed.dedup(); - Ok(parsed - .into_iter() - .map(|key| key.as_feedback_key()) - .collect()) -} - -fn normalized_video_codec(codec: &str) -> Option<&'static str> { - match codec.trim().to_ascii_lowercase().as_str() { - "h264" | "h.264" | "avc" | "avc1" | "video/avc" => Some("h264"), - "h265" | "h.265" | "hevc" | "hev1" | "hvc1" | "video/hevc" => Some("h265"), - "av1" | "av01" | "video/av1" => Some("av1"), - _ => None, - } -} - -struct CompleteAdtsFrames { - frames: Vec>, - consumed_bytes: usize, -} - -fn complete_adts_frames(input: &[u8]) -> Result { - let mut frames = Vec::new(); - let mut offset = 0_usize; - - while offset < input.len() { - let remaining = &input[offset..]; - if remaining.len() < 2 { - break; - } - if remaining[0] != 0xff || (remaining[1] & 0xf0) != 0xf0 { - return Err(anyhow!("ADTS frame must start with sync word")); - } - if remaining.len() < 7 { - break; - } - - let protection_absent = (remaining[1] & 0x01) != 0; - let header_len = if protection_absent { 7 } else { 9 }; - let frame_len = (((remaining[3] & 0x03) as usize) << 11) - | ((remaining[4] as usize) << 3) - | ((remaining[5] as usize) >> 5); - if frame_len < header_len { - return Err(anyhow!( - "ADTS frame length {frame_len} is shorter than header length {header_len}" - )); - } - if remaining.len() < frame_len { - break; - } - - frames.push(remaining[..frame_len].to_vec()); - offset = offset - .checked_add(frame_len) - .ok_or_else(|| anyhow!("ADTS consumed byte count overflow"))?; - } - - Ok(CompleteAdtsFrames { - frames, - consumed_bytes: offset, - }) -} - -fn annex_b_nal_units<'a>( - input: &'a [u8], - codec_name: &str, - nal_type: fn(&[u8]) -> Option, -) -> Result>> { - let mut starts = Vec::new(); - let mut offset = 0_usize; - while let Some((start, prefix_len)) = find_annex_b_start_code(input, offset) { - starts.push((start, prefix_len)); - offset = start + prefix_len; - } - - if starts.is_empty() { - return Err(anyhow!("{codec_name} Annex B stream has no start codes")); - } - - let mut nal_units = Vec::new(); - for index in 0..starts.len() { - let (start, prefix_len) = starts[index]; - let payload_start = start + prefix_len; - let end = starts - .get(index + 1) - .map(|(next_start, _)| *next_start) - .unwrap_or(input.len()); - if payload_start >= end { - continue; - } - let payload = &input[payload_start..end]; - if let Some(nal_type) = nal_type(payload) { - nal_units.push(NalUnit { - start, - end, - payload, - nal_type, - }); - } - } - - if nal_units.is_empty() { - return Err(anyhow!("{codec_name} Annex B stream has no NAL payloads")); - } - - Ok(nal_units) -} - -fn find_annex_b_start_code(input: &[u8], from: usize) -> Option<(usize, usize)> { - let mut index = from; - while index + 3 <= input.len() { - if input[index..].starts_with(&[0, 0, 1]) { - return Some((index, 3)); - } - if index + 4 <= input.len() && input[index..].starts_with(&[0, 0, 0, 1]) { - return Some((index, 4)); - } - index += 1; - } - None -} - -fn is_h264_vcl_nal(nal_type: u8) -> bool { - matches!(nal_type, 1..=5) -} - -fn access_unit_references_previous_frame(codec: &str, access_unit: &VideoAccessUnit) -> bool { - if access_unit.keyframe { - return false; - } - match normalized_video_codec(codec).as_deref() { - Some("h264") => h264_access_unit_has_reference_vcl(&access_unit.bytes).unwrap_or(true), - _ => true, - } -} - -fn h264_access_unit_has_reference_vcl(input: &[u8]) -> Result { - let nal_units = annex_b_nal_units(input, "H.264", h264_nal_type)?; - Ok(nal_units - .iter() - .any(|nal| is_h264_vcl_nal(nal.nal_type) && h264_nal_ref_idc(nal.payload) > 0)) -} - -fn h264_nal_type(payload: &[u8]) -> Option { - payload.first().map(|byte| byte & 0x1f) -} - -fn h264_nal_ref_idc(payload: &[u8]) -> u8 { - payload - .first() - .map(|byte| (byte >> 5) & 0x03) - .unwrap_or_default() -} - -fn h264_first_mb_in_slice(nal_payload: &[u8]) -> Option { - if nal_payload.len() < 2 { - return None; - } - let rbsp = h264_ebsp_to_rbsp(&nal_payload[1..]); - read_unsigned_exp_golomb(&rbsp) -} - -fn is_h265_vcl_nal(nal_type: u8) -> bool { - nal_type <= 31 -} - -fn is_h265_irap_nal(nal_type: u8) -> bool { - matches!(nal_type, 16..=21) -} - -fn is_h265_pre_vcl_boundary_nal(nal_type: u8) -> bool { - matches!(nal_type, 32..=34 | 39 | 40) -} - -fn h265_nal_type(payload: &[u8]) -> Option { - if payload.len() < 2 { - return None; - } - Some((payload[0] >> 1) & 0x3f) -} - -fn h265_first_slice_segment_in_pic(nal_payload: &[u8]) -> Option { - if nal_payload.len() < 3 { - return None; - } - let rbsp = h264_ebsp_to_rbsp(&nal_payload[2..]); - rbsp.first().map(|byte| (byte & 0x80) != 0) -} - -fn h264_ebsp_to_rbsp(payload: &[u8]) -> Vec { - let mut rbsp = Vec::with_capacity(payload.len()); - let mut zero_count = 0_u8; - for &byte in payload { - if zero_count >= 2 && byte == 0x03 { - zero_count = 0; - continue; - } - rbsp.push(byte); - zero_count = if byte == 0 { zero_count + 1 } else { 0 }; - } - rbsp -} - -fn read_unsigned_exp_golomb(payload: &[u8]) -> Option { - let mut reader = BitReader::new(payload); - let mut leading_zero_bits = 0_u32; - while reader.read_bit()? == 0 { - leading_zero_bits += 1; - if leading_zero_bits > 63 { - return None; - } - } - - let mut value = 1_u64; - for _ in 0..leading_zero_bits { - value = (value << 1) | u64::from(reader.read_bit()?); - } - Some(value - 1) -} - -struct BitReader<'a> { - bytes: &'a [u8], - bit_index: usize, -} - -impl<'a> BitReader<'a> { - fn new(bytes: &'a [u8]) -> Self { - Self { - bytes, - bit_index: 0, - } - } - - fn read_bit(&mut self) -> Option { - if self.bit_index >= self.bytes.len() * 8 { - return None; - } - let byte = self.bytes[self.bit_index / 8]; - let shift = 7 - (self.bit_index % 8); - self.bit_index += 1; - Some((byte >> shift) & 1) - } -} - -#[cfg(test)] -mod tests { - use super::*; - - fn start_code() -> [u8; 4] { - [0, 0, 0, 1] - } - - #[test] - fn splits_h264_annex_b_access_units_on_aud_boundaries() -> Result<()> { - let mut stream = Vec::new(); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&[0x09, 0xf0]); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&[0x67, 0x42, 0x00, 0x1f]); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&[0x65, 0x80]); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&[0x09, 0xf0]); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&[0x41, 0x80]); - - let access_units = h264_annex_b_access_units(&stream)?; - - assert_eq!(access_units.len(), 2); - assert!(access_units[0].keyframe); - assert!(!access_units[1].keyframe); - assert!(access_units[0].bytes.starts_with(&start_code())); - assert!(access_units[1].bytes.starts_with(&start_code())); - Ok(()) - } - - #[test] - fn splits_h264_annex_b_access_units_on_new_slice_zero() -> Result<()> { - let mut stream = Vec::new(); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&[0x65, 0x80]); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&[0x41, 0x80]); - - let access_units = h264_annex_b_access_units(&stream)?; - - assert_eq!(access_units.len(), 2); - assert!(access_units[0].keyframe); - assert!(!access_units[1].keyframe); - Ok(()) - } - - #[test] - fn video_annex_b_dispatches_h264_aliases() -> Result<()> { - let mut stream = Vec::new(); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&[0x65, 0x80]); - - let access_units = video_annex_b_access_units("AVC", &stream)?; - - assert_eq!(access_units.len(), 1); - assert!(access_units[0].keyframe); - Ok(()) - } - - fn h265_nal(nal_type: u8, slice_first: Option) -> Vec { - let mut nal = vec![nal_type << 1, 0x01]; - if let Some(first) = slice_first { - nal.push(if first { 0x80 } else { 0x00 }); - } else { - nal.push(0x00); - } - nal - } - - #[test] - fn splits_h265_annex_b_access_units_on_aud_boundaries() -> Result<()> { - let mut stream = Vec::new(); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&h265_nal(35, None)); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&h265_nal(32, None)); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&h265_nal(33, None)); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&h265_nal(34, None)); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&h265_nal(19, Some(true))); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&h265_nal(35, None)); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&h265_nal(1, Some(true))); - - let access_units = h265_annex_b_access_units(&stream)?; - - assert_eq!(access_units.len(), 2); - assert!(access_units[0].keyframe); - assert!(!access_units[1].keyframe); - assert!(access_units[0].bytes.starts_with(&start_code())); - assert!(access_units[1].bytes.starts_with(&start_code())); - Ok(()) - } - - #[test] - fn splits_h265_annex_b_access_units_on_first_slice_flag() -> Result<()> { - let mut stream = Vec::new(); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&h265_nal(19, Some(true))); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&h265_nal(1, Some(true))); - - let access_units = h265_annex_b_access_units(&stream)?; - - assert_eq!(access_units.len(), 2); - assert!(access_units[0].keyframe); - assert!(!access_units[1].keyframe); - Ok(()) - } - - #[test] - fn video_annex_b_dispatches_hevc_aliases() -> Result<()> { - let mut stream = Vec::new(); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&h265_nal(19, Some(true))); - - let access_units = video_annex_b_access_units("HEVC", &stream)?; - - assert_eq!(access_units.len(), 1); - assert!(access_units[0].keyframe); - Ok(()) - } - - #[test] - fn video_annex_b_rejects_av1_without_obu_packetizer() { - let error = video_annex_b_access_units("av1", &[1, 2, 3]).unwrap_err(); - - assert!( - error - .to_string() - .contains("AV1 access unit splitting is not Annex B") - ); - } - - #[test] - fn video_annex_b_rejects_unknown_codec() { - let error = video_annex_b_access_units("vp9", &[1, 2, 3]).unwrap_err(); - - assert!( - error - .to_string() - .contains("unsupported Annex B video codec vp9") - ); - } - - #[test] - fn packetizes_access_unit_into_typed_video_chunks() -> Result<()> { - let access_unit = VideoAccessUnit { - bytes: vec![1, 2, 3, 4, 5], - keyframe: false, - }; - - let records = packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - frame_id: 9, - pts_ticks: 27_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_ticks: 28_800, - max_payload_bytes: 2, - }, - &access_unit, - )?; - - assert_eq!(records.len(), 3); - assert_eq!(records[0].chunk_index, 0); - assert_eq!(records[0].chunk_count, 3); - assert_eq!(records[0].dependency_frame_id, Some(8)); - assert_eq!(records[2].payload, vec![5]); - Ok(()) - } - - #[test] - fn builds_video_parity_shards_for_burst_loss_recovery() -> Result<()> { - let access_unit = VideoAccessUnit { - bytes: (1_u8..=40).collect(), - keyframe: false, - }; - let records = packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - frame_id: 9, - pts_ticks: 27_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_ticks: 28_800, - max_payload_bytes: 2, - }, - &access_unit, - )?; - - let parity = build_video_parity_shards(&records)?; - - assert_eq!(records.len(), 20); - assert_eq!(parity.len(), 16); - assert!( - parity.iter().enumerate().all( - |(index, shard)| shard.parity_index == index as u16 && shard.parity_count == 16 - ) - ); - assert!(parity.iter().all(|shard| shard.chunk_payload_bytes == 2)); - - let mut recovered_tail = Vec::new(); - for missing_index in 16_u16..20 { - let shard = parity - .iter() - .find(|shard| shard.parity_index == missing_index % shard.parity_count) - .expect("tail chunk stripe exists"); - let mut recovered = shard.payload.clone(); - for chunk in records.iter().filter(|chunk| { - chunk.chunk_index != missing_index - && chunk.chunk_index % shard.parity_count == shard.parity_index - }) { - for (offset, byte) in chunk.payload.iter().enumerate() { - recovered[offset] ^= *byte; - } - } - recovered.truncate(records[missing_index as usize].payload.len()); - recovered_tail.extend(recovered); - } - let expected_tail: Vec = records[16..20] - .iter() - .flat_map(|record| record.payload.iter().copied()) - .collect(); - assert_eq!(recovered_tail, expected_tail); - Ok(()) - } - - #[test] - fn packetizes_annex_b_stream_into_timed_video_records() -> Result<()> { - let mut stream = Vec::new(); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&[0x65, 0x80]); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&[0x41, 0x80]); - - let records = packetize_video_annex_b_stream( - VideoAnnexBStreamPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "avc", - first_frame_id: 9, - first_pts_ticks: 27_000, - frame_duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_delay_ticks: 1_800, - max_payload_bytes: 16, - }, - &stream, - )?; - - assert_eq!(records.len(), 2); - assert_eq!(records[0].frame_id, 9); - assert_eq!(records[0].pts_ticks, 27_000); - assert_eq!(records[0].deadline_ticks, 28_800); - assert!(records[0].keyframe); - assert_eq!(records[1].frame_id, 10); - assert_eq!(records[1].pts_ticks, 30_000); - assert_eq!(records[1].deadline_ticks, 31_800); - assert_eq!(records[1].dependency_frame_id, Some(9)); - assert_eq!(records[1].codec, "avc"); - Ok(()) - } - - #[test] - fn packetizes_non_reference_h264_p_frames_without_dependency() -> Result<()> { - let mut stream = Vec::new(); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&[0x65, 0x80]); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&[0x01, 0x80]); - - let records = packetize_video_annex_b_stream( - VideoAnnexBStreamPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - first_frame_id: 9, - first_pts_ticks: 27_000, - frame_duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_delay_ticks: 1_800, - max_payload_bytes: 16, - }, - &stream, - )?; - - assert_eq!(records.len(), 2); - assert!(records[0].keyframe); - assert_eq!(records[0].dependency_frame_id, None); - assert!(!records[1].keyframe); - assert_eq!(records[1].dependency_frame_id, None); - Ok(()) - } - - #[test] - fn rejects_negative_annex_b_stream_deadline_delay() { - let error = packetize_video_annex_b_stream( - VideoAnnexBStreamPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - first_frame_id: 9, - first_pts_ticks: 27_000, - frame_duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_delay_ticks: -1, - max_payload_bytes: 4, - }, - &[0, 0, 0, 1, 0x65, 0x80], - ) - .unwrap_err(); - - assert!( - error - .to_string() - .contains("deadline_delay_ticks must be non-negative") - ); - } - - #[test] - fn rejects_zero_duration_video_access_units() { - let access_unit = VideoAccessUnit { - bytes: vec![1, 2, 3], - keyframe: true, - }; - - let error = packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - frame_id: 9, - pts_ticks: 27_000, - duration_ticks: 0, - timebase_num: 1, - timebase_den: 90_000, - deadline_ticks: 28_800, - max_payload_bytes: 2, - }, - &access_unit, - ) - .unwrap_err(); - - assert!( - error - .to_string() - .contains("video duration_ticks must be greater than zero") - ); - } - - #[test] - fn rejects_video_deadline_before_pts() { - let access_unit = VideoAccessUnit { - bytes: vec![1, 2, 3], - keyframe: true, - }; - - let error = packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - frame_id: 9, - pts_ticks: 27_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_ticks: 26_999, - max_payload_bytes: 2, - }, - &access_unit, - ) - .unwrap_err(); - - assert!( - error - .to_string() - .contains("video deadline_ticks must not precede pts_ticks") - ); - } - - #[test] - fn video_chunk_key_round_trips_feedback_key() -> Result<()> { - let key = VideoChunkKey::parse("42:7")?; - - assert_eq!(key, VideoChunkKey::new(42, 7)); - assert_eq!(key.as_feedback_key(), "42:7"); - assert_eq!(video_chunk_feedback_key(42, 7), "42:7"); - Ok(()) - } - - #[test] - fn rejects_malformed_video_chunk_keys() { - let error = VideoChunkKey::parse("42:7:extra").unwrap_err(); - - assert!(error.to_string().contains("exactly one")); - } - - #[test] - fn packetizes_audio_payload_into_typed_packet() -> Result<()> { - let record = packetize_audio_packet( - AudioPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "opus", - packet_id: 12, - pts_ticks: 48_000, - duration_ticks: 960, - timebase_num: 1, - timebase_den: 48_000, - deadline_ticks: 48_960, - }, - &[0xf8, 0xff, 0xfe], - )?; - - assert_eq!(record.stream_id, "muninn.raven.av.rudp"); - assert_eq!(record.codec, "opus"); - assert_eq!(record.packet_id, 12); - assert_eq!(record.payload, vec![0xf8, 0xff, 0xfe]); - Ok(()) - } - - #[test] - fn rejects_empty_audio_payloads() { - let error = packetize_audio_packet( - AudioPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "opus", - packet_id: 12, - pts_ticks: 48_000, - duration_ticks: 960, - timebase_num: 1, - timebase_den: 48_000, - deadline_ticks: 48_960, - }, - &[], - ) - .unwrap_err(); - - assert!(error.to_string().contains("payload must be non-empty")); - } - - #[test] - fn rejects_zero_duration_audio_packets() { - let error = packetize_audio_packet( - AudioPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "opus", - packet_id: 12, - pts_ticks: 48_000, - duration_ticks: 0, - timebase_num: 1, - timebase_den: 48_000, - deadline_ticks: 48_960, - }, - &[0xf8, 0xff, 0xfe], - ) - .unwrap_err(); - - assert!( - error - .to_string() - .contains("audio duration_ticks must be greater than zero") - ); - } - - #[test] - fn rejects_audio_deadline_before_pts() { - let error = packetize_audio_packet( - AudioPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "opus", - packet_id: 12, - pts_ticks: 48_000, - duration_ticks: 960, - timebase_num: 1, - timebase_den: 48_000, - deadline_ticks: 47_999, - }, - &[0xf8, 0xff, 0xfe], - ) - .unwrap_err(); - - assert!( - error - .to_string() - .contains("audio deadline_ticks must not precede pts_ticks") - ); - } - - fn audio_packet(packet_id: u64, deadline_ticks: i64) -> MuninnMediaAudioPacketRecord { - packetize_audio_packet( - AudioPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "opus", - packet_id, - pts_ticks: (packet_id as i64) * 960, - duration_ticks: 960, - timebase_num: 1, - timebase_den: 48_000, - deadline_ticks, - }, - &[0xf8, 0xff, packet_id as u8], - ) - .unwrap() - } - - #[test] - fn audio_packet_buffer_emits_contiguous_packets_in_order() -> Result<()> { - let mut buffer = AudioPacketBuffer::default(); - - buffer.insert(audio_packet(7, 10_000))?; - buffer.insert(audio_packet(9, 12_000))?; - let ready = buffer.pop_ready_packets(); - - assert_eq!( - ready - .iter() - .map(|packet| packet.packet_id) - .collect::>(), - vec![7] - ); - assert_eq!(buffer.pending_packet_count(), 1); - - buffer.insert(audio_packet(8, 11_000))?; - let ready = buffer.pop_ready_packets(); - - assert_eq!( - ready - .iter() - .map(|packet| packet.packet_id) - .collect::>(), - vec![8, 9] - ); - assert_eq!(buffer.pending_packet_count(), 0); - Ok(()) - } - - #[test] - fn audio_packet_buffer_tracks_lowest_packet_before_first_emit() -> Result<()> { - let mut buffer = AudioPacketBuffer::default(); - - buffer.insert(audio_packet(9, 12_000))?; - buffer.insert(audio_packet(7, 10_000))?; - buffer.insert(audio_packet(8, 11_000))?; - - let ready = buffer.pop_ready_packets(); - - assert_eq!( - ready - .iter() - .map(|packet| packet.packet_id) - .collect::>(), - vec![7, 8, 9] - ); - assert_eq!(buffer.pending_packet_count(), 0); - Ok(()) - } - - #[test] - fn audio_packet_buffer_rejects_stale_packets_after_emit() -> Result<()> { - let mut buffer = AudioPacketBuffer::default(); - - buffer.insert(audio_packet(7, 10_000))?; - assert_eq!(buffer.pop_ready_packets().len(), 1); - let error = buffer.insert(audio_packet(6, 9_000)).unwrap_err(); - - assert!(error.to_string().contains("stale packet_id")); - Ok(()) - } - - #[test] - fn audio_packet_buffer_expires_late_packets() -> Result<()> { - let mut buffer = AudioPacketBuffer::default(); - - buffer.insert(audio_packet(7, 10_000))?; - buffer.insert(audio_packet(8, 12_000))?; - - let expired = buffer.expire_late_packets(10_000); - - assert_eq!(expired, vec![7]); - assert_eq!(buffer.pending_packet_count(), 1); - assert_eq!( - buffer - .pop_ready_packets() - .into_iter() - .map(|packet| packet.packet_id) - .collect::>(), - vec![8] - ); - Ok(()) - } - - #[test] - fn audio_packet_buffer_rejects_mixed_stream_metadata() -> Result<()> { - let mut buffer = AudioPacketBuffer::default(); - let mut mixed = audio_packet(8, 12_000); - mixed.session_id = "session-2".to_string(); - - buffer.insert(audio_packet(7, 10_000))?; - let error = buffer.insert(mixed).unwrap_err(); - - assert!(error.to_string().contains("mixed stream metadata")); - Ok(()) - } - - #[test] - fn reassembles_video_chunks_in_chunk_index_order() -> Result<()> { - let access_unit = VideoAccessUnit { - bytes: vec![1, 2, 3, 4, 5], - keyframe: true, - }; - let records = packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - frame_id: 9, - pts_ticks: 27_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_ticks: 28_800, - max_payload_bytes: 2, - }, - &access_unit, - )?; - let shuffled = vec![records[2].clone(), records[0].clone(), records[1].clone()]; - - let reassembled = reassemble_video_access_unit(&shuffled)?; - - assert_eq!(reassembled, access_unit); - Ok(()) - } - - #[test] - fn rejects_video_chunks_from_mixed_frames() -> Result<()> { - let access_unit = VideoAccessUnit { - bytes: vec![1, 2, 3, 4], - keyframe: false, - }; - let mut records = packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - frame_id: 9, - pts_ticks: 27_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_ticks: 28_800, - max_payload_bytes: 2, - }, - &access_unit, - )?; - records[1].frame_id = 10; - - let error = reassemble_video_access_unit(&records).unwrap_err(); - - assert!(error.to_string().contains("mixed frame_id")); - Ok(()) - } - - #[test] - fn rejects_incomplete_video_chunk_sets() -> Result<()> { - let access_unit = VideoAccessUnit { - bytes: vec![1, 2, 3, 4, 5], - keyframe: false, - }; - let mut records = packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - frame_id: 9, - pts_ticks: 27_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_ticks: 28_800, - max_payload_bytes: 2, - }, - &access_unit, - )?; - records.pop(); - - let error = reassemble_video_access_unit(&records).unwrap_err(); - - assert!(error.to_string().contains("missing chunks")); - Ok(()) - } - - #[test] - fn builds_receiver_feedback_with_sorted_unique_damage_lists() -> Result<()> { - let feedback = build_receiver_feedback(ReceiverFeedbackOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - receiver_id: "starfire.obs", - highest_decodable_frame_id: Some(40), - missing_frame_ids: vec![43, 42, 43], - missing_video_chunk_keys: vec![ - video_chunk_feedback_key(43, 2), - video_chunk_feedback_key(42, 1), - video_chunk_feedback_key(42, 1), - ], - late_frame_ids: vec![39, 39, 38], - requested_keyframe: false, - jitter_us: 700, - decode_queue_us: 2_000, - observed_at: "2026-06-18T00:00:00Z", - })?; - - assert_eq!(feedback.missing_frame_ids, vec![42, 43]); - assert_eq!(feedback.missing_video_chunk_keys, vec!["42:1", "43:2"]); - assert_eq!(feedback.late_frame_ids, vec![38, 39]); - assert!(feedback.requested_keyframe); - Ok(()) - } - - #[test] - fn rejects_negative_receiver_feedback_timing() { - let error = build_receiver_feedback(ReceiverFeedbackOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - receiver_id: "starfire.obs", - highest_decodable_frame_id: Some(40), - missing_frame_ids: Vec::new(), - missing_video_chunk_keys: Vec::new(), - late_frame_ids: Vec::new(), - requested_keyframe: false, - jitter_us: -1, - decode_queue_us: 2_000, - observed_at: "2026-06-18T00:00:00Z", - }) - .unwrap_err(); - - assert!(error.to_string().contains("jitter_us")); - } - - #[test] - fn rejects_malformed_receiver_feedback_chunk_keys() { - let error = build_receiver_feedback(ReceiverFeedbackOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - receiver_id: "starfire.obs", - highest_decodable_frame_id: Some(40), - missing_frame_ids: Vec::new(), - missing_video_chunk_keys: vec!["frame:chunk".to_string()], - late_frame_ids: Vec::new(), - requested_keyframe: false, - jitter_us: 0, - decode_queue_us: 2_000, - observed_at: "2026-06-18T00:00:00Z", - }) - .unwrap_err(); - - assert!(error.to_string().contains("frame_id")); - } - - #[test] - fn video_frame_assembly_reports_missing_chunks_and_reassembles() -> Result<()> { - let access_unit = VideoAccessUnit { - bytes: vec![1, 2, 3, 4, 5], - keyframe: true, - }; - let records = packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - frame_id: 9, - pts_ticks: 27_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_ticks: 28_800, - max_payload_bytes: 2, - }, - &access_unit, - )?; - let mut assembly = VideoFrameAssembly::new(records[0].clone())?; - - assert!(!assembly.is_complete()); - assert_eq!( - assembly.missing_video_chunk_keys(), - vec![ - video_chunk_feedback_key(9, 1), - video_chunk_feedback_key(9, 2) - ] - ); - - assembly.insert(records[2].clone())?; - assert_eq!( - assembly.missing_video_chunk_keys(), - vec![video_chunk_feedback_key(9, 1)] - ); - - assembly.insert(records[1].clone())?; - assert!(assembly.is_complete()); - assert_eq!(assembly.reassemble()?, access_unit); - Ok(()) - } - - #[test] - fn video_frame_assembly_rejects_mixed_metadata() -> Result<()> { - let access_unit = VideoAccessUnit { - bytes: vec![1, 2, 3, 4], - keyframe: false, - }; - let mut records = packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - frame_id: 9, - pts_ticks: 27_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_ticks: 28_800, - max_payload_bytes: 2, - }, - &access_unit, - )?; - let mut assembly = VideoFrameAssembly::new(records[0].clone())?; - records[1].pts_ticks += 1; - - let error = assembly.insert(records[1].clone()).unwrap_err(); - - assert!(error.to_string().contains("metadata")); - Ok(()) - } - - #[test] - fn video_frame_assembly_set_handles_interleaved_frames() -> Result<()> { - let frame_one = VideoAccessUnit { - bytes: vec![1, 2, 3, 4, 5], - keyframe: true, - }; - let frame_two = VideoAccessUnit { - bytes: vec![6, 7, 8, 9], - keyframe: false, - }; - let frame_one_chunks = packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - frame_id: 9, - pts_ticks: 27_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_ticks: 28_800, - max_payload_bytes: 2, - }, - &frame_one, - )?; - let frame_two_chunks = packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - frame_id: 10, - pts_ticks: 30_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_ticks: 31_800, - max_payload_bytes: 2, - }, - &frame_two, - )?; - let key_one = VideoFrameKey::from_chunk(&frame_one_chunks[0]); - let mut assemblies = VideoFrameAssemblySet::default(); - - assert!( - assemblies - .insert_chunk(frame_one_chunks[0].clone())? - .is_none() - ); - assert!( - assemblies - .insert_chunk(frame_two_chunks[1].clone())? - .is_none() - ); - assert_eq!(assemblies.pending_frame_count(), 2); - assert_eq!( - assemblies.missing_video_chunk_keys(&key_one), - vec![ - video_chunk_feedback_key(9, 1), - video_chunk_feedback_key(9, 2) - ] - ); - - assert!( - assemblies - .insert_chunk(frame_one_chunks[2].clone())? - .is_none() - ); - let completed = assemblies - .insert_chunk(frame_one_chunks[1].clone())? - .expect("frame one should complete"); - - assert_eq!(completed, frame_one); - assert_eq!(assemblies.pending_frame_count(), 1); - assert!(assemblies.missing_video_chunk_keys(&key_one).is_empty()); - Ok(()) - } - - #[test] - fn video_frame_assembly_set_expires_late_incomplete_frames() -> Result<()> { - let frame_one = VideoAccessUnit { - bytes: vec![1, 2, 3, 4, 5], - keyframe: true, - }; - let frame_two = VideoAccessUnit { - bytes: vec![6, 7, 8, 9], - keyframe: false, - }; - let frame_one_chunks = packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - frame_id: 9, - pts_ticks: 27_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_ticks: 28_800, - max_payload_bytes: 2, - }, - &frame_one, - )?; - let frame_two_chunks = packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - frame_id: 10, - pts_ticks: 30_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_ticks: 31_800, - max_payload_bytes: 2, - }, - &frame_two, - )?; - let key_one = VideoFrameKey::from_chunk(&frame_one_chunks[0]); - let key_two = VideoFrameKey::from_chunk(&frame_two_chunks[0]); - let mut assemblies = VideoFrameAssemblySet::default(); - - assemblies.insert_chunk(frame_one_chunks[0].clone())?; - assemblies.insert_chunk(frame_two_chunks[0].clone())?; - - let expired = assemblies.expire_late_frames(30_000); - - assert_eq!(expired.len(), 1); - assert_eq!(expired[0].key, key_one); - assert_eq!( - expired[0].missing_video_chunk_keys, - vec![ - video_chunk_feedback_key(9, 1), - video_chunk_feedback_key(9, 2) - ] - ); - assert_eq!(assemblies.pending_frame_count(), 1); - assert!(!assemblies.missing_video_chunk_keys(&key_two).is_empty()); - - let feedback = build_feedback_for_expired_video_frames( - &expired, - ExpiredVideoFrameFeedbackOptions { - receiver_id: "starfire.obs", - jitter_us: 700, - decode_queue_us: 2_000, - observed_at: "2026-06-18T00:00:00Z", - }, - )? - .expect("expired frames should produce feedback"); - - assert_eq!(feedback.late_frame_ids, vec![9]); - assert_eq!( - feedback.missing_video_chunk_keys, - vec![ - video_chunk_feedback_key(9, 1), - video_chunk_feedback_key(9, 2) - ] - ); - assert!(feedback.requested_keyframe); - Ok(()) - } - - #[test] - fn expired_video_feedback_rejects_mixed_streams() { - let expired = vec![ - ExpiredVideoFrame { - key: VideoFrameKey { - stream_id: "muninn.raven.av.rudp".to_string(), - session_id: "session-1".to_string(), - frame_id: 9, - }, - deadline_ticks: 28_800, - missing_video_chunk_keys: vec![video_chunk_feedback_key(9, 1)], - }, - ExpiredVideoFrame { - key: VideoFrameKey { - stream_id: "muninn.nightwing.av.rudp".to_string(), - session_id: "session-1".to_string(), - frame_id: 10, - }, - deadline_ticks: 31_800, - missing_video_chunk_keys: vec![video_chunk_feedback_key(10, 1)], - }, - ]; - - let error = build_feedback_for_expired_video_frames( - &expired, - ExpiredVideoFrameFeedbackOptions { - receiver_id: "starfire.obs", - jitter_us: 700, - decode_queue_us: 2_000, - observed_at: "2026-06-18T00:00:00Z", - }, - ) - .unwrap_err(); - - assert!(error.to_string().contains("one stream_id/session_id")); - } - - #[test] - fn media_wire_round_trips_video_document() -> Result<()> { - let access_unit = VideoAccessUnit { - bytes: vec![1, 2, 3], - keyframe: true, - }; - let record = packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - frame_id: 9, - pts_ticks: 27_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_ticks: 28_800, - max_payload_bytes: 4, - }, - &access_unit, - )? - .remove(0); - - let wire = encode_media_wire_record( - &MuninnMediaWireRecord::Video(record.clone()), - "2026-06-18T00:00:00Z", - "muninn-test", - "media-test", - )?; - - assert_eq!( - decode_media_wire_record(&wire)?, - MuninnMediaWireRecord::Video(record) - ); - Ok(()) - } - - #[test] - fn media_wire_round_trips_video_parity_document() -> Result<()> { - let access_unit = VideoAccessUnit { - bytes: vec![1, 2, 3, 4, 5], - keyframe: false, - }; - let records = packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - frame_id: 9, - pts_ticks: 27_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_ticks: 28_800, - max_payload_bytes: 2, - }, - &access_unit, - )?; - let parity = build_video_parity_shards(&records)? - .into_iter() - .next() - .expect("multi-chunk frame gets parity"); - - let wire = encode_media_wire_record( - &MuninnMediaWireRecord::VideoParity(parity.clone()), - "2026-06-18T00:00:00Z", - "muninn-test", - "media-test", - )?; - - assert_eq!( - decode_media_wire_record(&wire)?, - MuninnMediaWireRecord::VideoParity(parity) - ); - Ok(()) - } - - #[test] - fn media_wire_batches_annex_b_video_records() -> Result<()> { - let mut stream = Vec::new(); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&[0x65, 0x80]); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&[0x41, 0x80]); - let records = packetize_video_annex_b_stream( - VideoAnnexBStreamPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - first_frame_id: 9, - first_pts_ticks: 27_000, - frame_duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_delay_ticks: 1_800, - max_payload_bytes: 16, - }, - &stream, - )?; - let wire_records = records - .iter() - .cloned() - .map(MuninnMediaWireRecord::Video) - .collect::>(); - - let wire = encode_media_wire_records( - &wire_records, - "2026-06-18T00:00:00Z", - "muninn-test", - "media-test", - )?; - - assert_eq!(wire.len(), 2); - assert_eq!(decode_media_wire_record(&wire[0])?, wire_records[0]); - assert_eq!(decode_media_wire_record(&wire[1])?, wire_records[1]); - Ok(()) - } - - #[test] - fn media_wire_encodes_annex_b_stream_for_sender() -> Result<()> { - let mut stream = Vec::new(); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&[0x65, 0x80]); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&[0x41, 0x80]); - - let wire = encode_video_annex_b_stream_wire_records( - VideoAnnexBStreamWireOptions { - packetize: VideoAnnexBStreamPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - first_frame_id: 9, - first_pts_ticks: 27_000, - frame_duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_delay_ticks: 1_800, - max_payload_bytes: 16, - }, - stored_at: "2026-06-18T00:00:00Z", - source_runtime_id: "muninn-test", - source_role: "media-test", - }, - &stream, - )?; - - assert_eq!(wire.len(), 2); - let first = decode_media_wire_record(&wire[0])?; - let second = decode_media_wire_record(&wire[1])?; - let MuninnMediaWireRecord::Video(first) = first else { - panic!("expected video media record"); - }; - let MuninnMediaWireRecord::Video(second) = second else { - panic!("expected video media record"); - }; - assert_eq!(first.frame_id, 9); - assert_eq!(first.deadline_ticks, 28_800); - assert_eq!(second.frame_id, 10); - assert_eq!(second.deadline_ticks, 31_800); - Ok(()) - } - - #[test] - fn media_send_payloads_pin_video_to_media_channel() -> Result<()> { - let mut stream = Vec::new(); - stream.extend_from_slice(&start_code()); - stream.extend_from_slice(&[0x65, 0x80]); - - let payloads = video_annex_b_stream_send_payloads( - VideoAnnexBStreamWireOptions { - packetize: VideoAnnexBStreamPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - first_frame_id: 9, - first_pts_ticks: 27_000, - frame_duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_delay_ticks: 1_800, - max_payload_bytes: 16, - }, - stored_at: "2026-06-18T00:00:00Z", - source_runtime_id: "muninn-test", - source_role: "media-test", - }, - &stream, - )?; - - assert_eq!(payloads.len(), 1); - assert_eq!(payloads[0].channel_id, MUNINN_MEDIA_RUDP_CHANNEL); - let MuninnMediaWireRecord::Video(record) = decode_media_wire_record(&payloads[0].payload)? - else { - panic!("expected video media record"); - }; - assert_eq!(record.frame_id, 9); - Ok(()) - } - - fn stream_send_config() -> VideoAnnexBStreamSendConfig { - VideoAnnexBStreamSendConfig { - stream_id: "muninn.raven.av.rudp".to_string(), - session_id: "session-1".to_string(), - codec: "h264".to_string(), - first_frame_id: 9, - first_pts_ticks: 27_000, - frame_duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_delay_ticks: 1_800, - max_payload_bytes: 16, - max_pending_bytes: 64, - source_runtime_id: "muninn-test".to_string(), - source_role: "media-test".to_string(), - } - } - - fn adts_stream_send_config() -> AudioAdtsStreamSendConfig { - AudioAdtsStreamSendConfig { - stream_id: "muninn.raven.av.rudp".to_string(), - session_id: "session-1".to_string(), - codec: "aac-adts".to_string(), - first_packet_id: 12, - first_pts_ticks: 48_000, - packet_duration_ticks: 1_024, - timebase_num: 1, - timebase_den: 48_000, - deadline_delay_ticks: 1_024, - max_pending_bytes: 128, - source_runtime_id: "muninn-test".to_string(), - source_role: "media-test".to_string(), - } - } - - fn adts_frame(payload: &[u8]) -> Vec { - let frame_length = 7 + payload.len(); - let mut frame = vec![ - 0xff, - 0xf1, - 0x4c, - 0x80 | (((frame_length >> 11) & 0x03) as u8), - ((frame_length >> 3) & 0xff) as u8, - (((frame_length & 0x07) << 5) as u8) | 0x1f, - 0xfc, - ]; - frame.extend_from_slice(payload); - frame - } - - #[test] - fn annex_b_stream_send_state_emits_only_completed_frames() -> Result<()> { - let mut first_frame = Vec::new(); - first_frame.extend_from_slice(&start_code()); - first_frame.extend_from_slice(&[0x65, 0x80]); - let mut second_frame = Vec::new(); - second_frame.extend_from_slice(&start_code()); - second_frame.extend_from_slice(&[0x41, 0x80]); - let mut sender = VideoAnnexBStreamSendState::new(stream_send_config())?; - - let first_push = sender.push("2026-06-18T00:00:00Z", &first_frame)?; - - assert!(first_push.is_empty()); - assert_eq!(sender.next_frame_id(), 9); - assert_eq!(sender.pending_bytes(), first_frame.len()); - - let second_push = sender.push("2026-06-18T00:00:00Z", &second_frame)?; - - assert_eq!(second_push.len(), 1); - assert_eq!(second_push[0].channel_id, MUNINN_MEDIA_RUDP_CHANNEL); - let MuninnMediaWireRecord::Video(first) = - decode_media_wire_record(&second_push[0].payload)? - else { - panic!("expected video media record"); - }; - assert_eq!(first.frame_id, 9); - assert_eq!(first.pts_ticks, 27_000); - assert!(first.keyframe); - assert_eq!(sender.next_frame_id(), 10); - assert_eq!(sender.pending_bytes(), second_frame.len()); - - let tail = sender.finish("2026-06-18T00:00:00Z")?; - - assert_eq!(tail.len(), 1); - let MuninnMediaWireRecord::Video(second) = decode_media_wire_record(&tail[0].payload)? - else { - panic!("expected video media record"); - }; - assert_eq!(second.frame_id, 10); - assert_eq!(second.pts_ticks, 30_000); - assert_eq!(second.dependency_frame_id, Some(9)); - assert_eq!(sender.pending_bytes(), 0); - assert_eq!(sender.next_frame_id(), 11); - Ok(()) - } - - #[test] - fn annex_b_stream_send_state_rejects_non_annex_b_codecs() { - let mut config = stream_send_config(); - config.codec = "av1".to_string(); - - let error = match VideoAnnexBStreamSendState::new(config) { - Ok(_) => panic!("AV1 must not create an Annex B stream sender"), - Err(error) => error, - }; - - assert!( - error - .to_string() - .contains("Annex B stream sender requires H.264/AVC or H.265/HEVC codec") - ); - } - - #[test] - fn annex_b_stream_send_state_rejects_unbounded_pending_bytes() -> Result<()> { - let mut config = stream_send_config(); - config.max_pending_bytes = 4; - let mut sender = VideoAnnexBStreamSendState::new(config)?; - - let error = sender - .push("2026-06-18T00:00:00Z", &[0x47, 0x40, 0x00, 0x10, 0x00]) - .unwrap_err(); - - assert!( - error - .to_string() - .contains("pending buffer exceeded 4 bytes") - ); - Ok(()) - } - - #[test] - fn adts_stream_send_state_emits_complete_packets() -> Result<()> { - let first_frame = adts_frame(&[0x11, 0x22, 0x33]); - let second_frame = adts_frame(&[0x44, 0x55]); - let mut sender = AudioAdtsStreamSendState::new(adts_stream_send_config())?; - - let first_push = sender.push("2026-06-18T00:00:00Z", &first_frame[..4])?; - - assert!(first_push.is_empty()); - assert_eq!(sender.pending_bytes(), 4); - assert_eq!(sender.next_packet_id(), 12); - - let mut tail = first_frame[4..].to_vec(); - tail.extend_from_slice(&second_frame); - let second_push = sender.push("2026-06-18T00:00:00Z", &tail)?; - - assert_eq!(second_push.len(), 2); - assert_eq!(second_push[0].channel_id, MUNINN_MEDIA_RUDP_CHANNEL); - assert_eq!(second_push[1].channel_id, MUNINN_MEDIA_RUDP_CHANNEL); - - let MuninnMediaWireRecord::Audio(first) = - decode_media_wire_record(&second_push[0].payload)? - else { - panic!("expected audio media record"); - }; - assert_eq!(first.packet_id, 12); - assert_eq!(first.pts_ticks, 48_000); - assert_eq!(first.deadline_ticks, 49_024); - assert_eq!(first.codec, "aac-adts"); - assert_eq!(first.payload, first_frame); - - let MuninnMediaWireRecord::Audio(second) = - decode_media_wire_record(&second_push[1].payload)? - else { - panic!("expected audio media record"); - }; - assert_eq!(second.packet_id, 13); - assert_eq!(second.pts_ticks, 49_024); - assert_eq!(second.deadline_ticks, 50_048); - assert_eq!(second.payload, second_frame); - assert_eq!(sender.pending_bytes(), 0); - assert_eq!(sender.next_packet_id(), 14); - Ok(()) - } - - #[test] - fn adts_stream_send_state_rejects_wrong_sync() -> Result<()> { - let mut sender = AudioAdtsStreamSendState::new(adts_stream_send_config())?; - - let error = sender - .push("2026-06-18T00:00:00Z", &[0x47, 0x40, 0x00, 0x10]) - .unwrap_err(); - - assert!( - error - .to_string() - .contains("ADTS frame must start with sync word") - ); - Ok(()) - } - - #[test] - fn adts_stream_send_state_rejects_trailing_bytes_on_finish() -> Result<()> { - let mut sender = AudioAdtsStreamSendState::new(adts_stream_send_config())?; - - let push = sender.push("2026-06-18T00:00:00Z", &[0xff])?; - assert!(push.is_empty()); - - let error = sender.finish("2026-06-18T00:00:00Z").unwrap_err(); - - assert!(error.to_string().contains("trailing bytes")); - Ok(()) - } - - #[test] - fn adts_stream_send_state_rejects_non_adts_codecs() { - let mut config = adts_stream_send_config(); - config.codec = "opus".to_string(); - - let error = match AudioAdtsStreamSendState::new(config) { - Ok(_) => panic!("Opus must not create an ADTS stream sender"), - Err(error) => error, - }; - - assert!( - error - .to_string() - .contains("ADTS stream sender requires AAC/ADTS codec") - ); - } - - #[test] - fn media_wire_encodes_audio_packet_for_sender() -> Result<()> { - let wire = encode_audio_packet_wire_record( - AudioPacketWireOptions { - packetize: AudioPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "opus", - packet_id: 12, - pts_ticks: 48_000, - duration_ticks: 960, - timebase_num: 1, - timebase_den: 48_000, - deadline_ticks: 48_960, - }, - stored_at: "2026-06-18T00:00:00Z", - source_runtime_id: "muninn-test", - source_role: "media-test", - }, - &[0xf8, 0xff, 0xfe], - )?; - - let decoded = decode_media_wire_record(&wire)?; - let MuninnMediaWireRecord::Audio(decoded) = decoded else { - panic!("expected audio media record"); - }; - assert_eq!(decoded.packet_id, 12); - assert_eq!(decoded.codec, "opus"); - assert_eq!(decoded.payload, vec![0xf8, 0xff, 0xfe]); - Ok(()) - } - - #[test] - fn media_send_payload_pins_audio_to_media_channel() -> Result<()> { - let payload = audio_packet_send_payload( - AudioPacketWireOptions { - packetize: AudioPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "opus", - packet_id: 12, - pts_ticks: 48_000, - duration_ticks: 960, - timebase_num: 1, - timebase_den: 48_000, - deadline_ticks: 48_960, - }, - stored_at: "2026-06-18T00:00:00Z", - source_runtime_id: "muninn-test", - source_role: "media-test", - }, - &[0xf8, 0xff, 0xfe], - )?; - - assert_eq!(payload.channel_id, MUNINN_MEDIA_RUDP_CHANNEL); - let MuninnMediaWireRecord::Audio(record) = decode_media_wire_record(&payload.payload)? - else { - panic!("expected audio media record"); - }; - assert_eq!(record.packet_id, 12); - Ok(()) - } - - #[test] - fn media_wire_round_trips_audio_document() -> Result<()> { - let record = packetize_audio_packet( - AudioPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "opus", - packet_id: 12, - pts_ticks: 48_000, - duration_ticks: 960, - timebase_num: 1, - timebase_den: 48_000, - deadline_ticks: 48_960, - }, - &[0xf8, 0xff, 0xfe], - )?; - - let wire = encode_media_wire_record( - &MuninnMediaWireRecord::Audio(record.clone()), - "2026-06-18T00:00:00Z", - "muninn-test", - "media-test", - )?; - - assert_eq!( - decode_media_wire_record(&wire)?, - MuninnMediaWireRecord::Audio(record) - ); - Ok(()) - } - - #[test] - fn media_wire_round_trips_feedback_document() -> Result<()> { - let record = build_receiver_feedback(ReceiverFeedbackOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - receiver_id: "starfire.obs", - highest_decodable_frame_id: Some(40), - missing_frame_ids: vec![42], - missing_video_chunk_keys: vec![video_chunk_feedback_key(42, 2)], - late_frame_ids: vec![39], - requested_keyframe: true, - jitter_us: 700, - decode_queue_us: 2_000, - observed_at: "2026-06-18T00:00:00Z", - })?; - - let wire = encode_media_wire_record( - &MuninnMediaWireRecord::Feedback(record.clone()), - "2026-06-18T00:00:00Z", - "muninn-test", - "media-test", - )?; - - assert_eq!( - decode_media_wire_record(&wire)?, - MuninnMediaWireRecord::Feedback(record) - ); - Ok(()) - } - - #[test] - fn media_wire_decodes_obs_bridge_receiver_feedback_fixture() -> Result<()> { - let wire = hex_fixture( - "83ad736368656d6156657273696f6ebb63756c746e65742e646f63756d656e745f7075745f7261772e7630a96d6573736167654964d96a6d756e696e6e2d6d656469613a6d756e696e6e2e6d656469615f72656365697665725f666565646261636b2e76313a6d756e696e6e2e726176656e2e61762e727564703a726176656e3a746573743a766964656f3a666565646261636b3a73746172666972652e6f6273a8646f63756d656e7489a8736368656d614964d9216d756e696e6e2e6d656469615f72656365697665725f666565646261636b2e7631a97265636f72644b6579d93b6d756e696e6e2e726176656e2e61762e727564703a726176656e3a746573743a766964656f3a666565646261636b3a73746172666972652e6f6273a873746f7265644174ac756e69782d6d733a31303030af7061796c6f6164456e636f64696e67ab6d6573736167657061636ba77061796c6f6164c4539bb46d756e696e6e2e726176656e2e61762e72756470b0726176656e3a746573743a766964656fac73746172666972652e6f62732990912ac30000ac756e69782d6d733a3130303092a434323a31a434323a33af736f7572636552756e74696d654964a87374617266697265ad736f757263654167656e744964c0aa736f75726365526f6c65a96d696d69722e6f6273a47461677391ac6d756e696e6e2e6d65646961", - )?; - - let MuninnMediaWireRecord::Feedback(feedback) = decode_media_wire_record(&wire)? else { - panic!("expected receiver feedback"); - }; - - assert_eq!(feedback.stream_id, "muninn.raven.av.rudp"); - assert_eq!(feedback.session_id, "raven:test:video"); - assert_eq!(feedback.receiver_id, "starfire.obs"); - assert_eq!(feedback.highest_decodable_frame_id, Some(41)); - assert!(feedback.missing_frame_ids.is_empty()); - assert_eq!(feedback.late_frame_ids, vec![42]); - assert!(feedback.requested_keyframe); - assert_eq!(feedback.jitter_us, 0); - assert_eq!(feedback.decode_queue_us, 0); - assert_eq!(feedback.observed_at, "unix-ms:1000"); - assert_eq!(feedback.missing_video_chunk_keys, vec!["42:1", "42:3"]); - Ok(()) - } - - #[test] - fn media_wire_rejects_invalid_video_record_timing() -> Result<()> { - let access_unit = VideoAccessUnit { - bytes: vec![1, 2, 3], - keyframe: true, - }; - let mut record = packetize_video_access_unit( - VideoFramePacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "h264", - frame_id: 9, - pts_ticks: 27_000, - duration_ticks: 3_000, - timebase_num: 1, - timebase_den: 90_000, - deadline_ticks: 28_800, - max_payload_bytes: 4, - }, - &access_unit, - )? - .remove(0); - record.deadline_ticks = 26_999; - let wire = encode_media_wire_record( - &MuninnMediaWireRecord::Video(record), - "2026-06-18T00:00:00Z", - "muninn-test", - "media-test", - )?; - - let error = decode_media_wire_record(&wire).unwrap_err(); - - assert!( - error - .to_string() - .contains("video media record deadline_ticks must not precede pts_ticks") - ); - Ok(()) - } - - #[test] - fn media_wire_rejects_invalid_audio_record_payload() -> Result<()> { - let mut record = packetize_audio_packet( - AudioPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "opus", - packet_id: 12, - pts_ticks: 48_000, - duration_ticks: 960, - timebase_num: 1, - timebase_den: 48_000, - deadline_ticks: 48_960, - }, - &[0xf8, 0xff, 0xfe], - )?; - record.payload.clear(); - let wire = encode_media_wire_record( - &MuninnMediaWireRecord::Audio(record), - "2026-06-18T00:00:00Z", - "muninn-test", - "media-test", - )?; - - let error = decode_media_wire_record(&wire).unwrap_err(); - - assert!( - error - .to_string() - .contains("audio media record payload must be non-empty") - ); - Ok(()) - } - - #[test] - fn media_wire_rejects_invalid_feedback_record_pressure() -> Result<()> { - let mut record = build_receiver_feedback(ReceiverFeedbackOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - receiver_id: "starfire.obs", - highest_decodable_frame_id: Some(40), - missing_frame_ids: vec![42], - missing_video_chunk_keys: vec![video_chunk_feedback_key(42, 2)], - late_frame_ids: vec![39], - requested_keyframe: true, - jitter_us: 700, - decode_queue_us: 2_000, - observed_at: "2026-06-18T00:00:00Z", - })?; - record.decode_queue_us = -1; - let wire = encode_media_wire_record( - &MuninnMediaWireRecord::Feedback(record), - "2026-06-18T00:00:00Z", - "muninn-test", - "media-test", - )?; - - let error = decode_media_wire_record(&wire).unwrap_err(); - - assert!( - error - .to_string() - .contains("receiver feedback media record decode_queue_us must be non-negative") - ); - Ok(()) - } - - #[test] - fn feedback_payload_decodes_legacy_record_without_chunk_keys() -> Result<()> { - #[derive(serde::Serialize)] - struct LegacyFeedbackRecord { - stream_id: String, - session_id: String, - receiver_id: String, - highest_decodable_frame_id: Option, - missing_frame_ids: Vec, - late_frame_ids: Vec, - requested_keyframe: bool, - jitter_us: i64, - decode_queue_us: i64, - observed_at: String, - } - - let payload = encode_record_payload(&LegacyFeedbackRecord { - stream_id: "muninn.raven.av.rudp".to_string(), - session_id: "session-1".to_string(), - receiver_id: "starfire.obs".to_string(), - highest_decodable_frame_id: Some(41), - missing_frame_ids: vec![42], - late_frame_ids: vec![40], - requested_keyframe: true, - jitter_us: 750, - decode_queue_us: 2_000, - observed_at: "2026-06-18T00:00:00Z".to_string(), - })?; - - let decoded: MuninnMediaReceiverFeedbackRecord = decode_record_payload(&payload)?; - - assert_eq!(decoded.missing_video_chunk_keys, Vec::::new()); - assert_eq!(decoded.missing_frame_ids, vec![42]); - assert!(decoded.requested_keyframe); - Ok(()) - } - - #[test] - fn media_wire_rejects_mismatched_record_key() -> Result<()> { - let record = packetize_audio_packet( - AudioPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "opus", - packet_id: 12, - pts_ticks: 48_000, - duration_ticks: 960, - timebase_num: 1, - timebase_den: 48_000, - deadline_ticks: 48_960, - }, - &[0xf8, 0xff, 0xfe], - )?; - let wire = encode_media_wire_record( - &MuninnMediaWireRecord::Audio(record), - "2026-06-18T00:00:00Z", - "muninn-test", - "media-test", - )?; - let CultNetMessage::DocumentPutRaw { - message_id, - mut document, - } = decode_cultnet_message_from_slice(&wire, CultNetWireContract::CultNetSchemaV0)? - else { - panic!("expected raw document put"); - }; - document.record_key = "wrong:key".to_string(); - let tampered = encode_cultnet_message_to_vec( - &CultNetMessage::DocumentPutRaw { - message_id, - document, - }, - CultNetWireContract::CultNetSchemaV0, - )?; - - let error = decode_media_wire_record(&tampered).unwrap_err(); - - assert!(error.to_string().contains("record key mismatch")); - Ok(()) - } - - #[test] - fn media_wire_rejects_unsupported_schema() -> Result<()> { - let record = packetize_audio_packet( - AudioPacketizeOptions { - stream_id: "muninn.raven.av.rudp", - session_id: "session-1", - codec: "opus", - packet_id: 12, - pts_ticks: 48_000, - duration_ticks: 960, - timebase_num: 1, - timebase_den: 48_000, - deadline_ticks: 48_960, - }, - &[0xf8, 0xff, 0xfe], - )?; - let wire = encode_media_wire_record( - &MuninnMediaWireRecord::Audio(record), - "2026-06-18T00:00:00Z", - "muninn-test", - "media-test", - )?; - let CultNetMessage::DocumentPutRaw { - message_id, - mut document, - } = decode_cultnet_message_from_slice(&wire, CultNetWireContract::CultNetSchemaV0)? - else { - panic!("expected raw document put"); - }; - document.schema_id = "muninn.media_unknown.v1".to_string(); - let tampered = encode_cultnet_message_to_vec( - &CultNetMessage::DocumentPutRaw { - message_id, - document, - }, - CultNetWireContract::CultNetSchemaV0, - )?; - - let error = decode_media_wire_record(&tampered).unwrap_err(); - - assert!( - error - .to_string() - .contains("unsupported Muninn media schema") - ); - Ok(()) - } - - #[test] - fn media_wire_rejects_non_raw_document_messages() -> Result<()> { - let wire = encode_cultnet_message_to_vec( - &CultNetMessage::DocumentPut { - message_id: "not-raw-media".to_string(), - document: cultnet_rs::CultNetDocumentRecord { - schema_id: MUNINN_MEDIA_AUDIO_PACKET_SCHEMA.to_string(), - record_key: "muninn.raven.av.rudp:session-1:audio:12".to_string(), - stored_at: "2026-06-18T00:00:00Z".to_string(), - payload: serde_json::json!({ "packet_id": 12 }), - source_runtime_id: Some("muninn-test".to_string()), - source_agent_id: None, - source_role: Some("media-test".to_string()), - tags: Some(vec!["muninn.media".to_string()]), - }, - }, - CultNetWireContract::CultNetSchemaV0, - )?; - - let error = decode_media_wire_record(&wire).unwrap_err(); - - assert!( - error - .to_string() - .contains("expected cultnet.document_put_raw.v0") - ); - Ok(()) - } - - fn hex_fixture(value: &str) -> Result> { - if value.len() % 2 != 0 { - return Err(anyhow!("hex fixture must have an even length")); - } - let mut bytes = Vec::with_capacity(value.len() / 2); - for index in (0..value.len()).step_by(2) { - let byte = u8::from_str_radix(&value[index..index + 2], 16) - .with_context(|| format!("parsing hex fixture byte at offset {index}"))?; - bytes.push(byte); - } - Ok(bytes) - } -} diff --git a/crates/muninn-move-tracker/Cargo.toml b/crates/muninn-move-tracker/Cargo.toml deleted file mode 100644 index 678dd52..0000000 --- a/crates/muninn-move-tracker/Cargo.toml +++ /dev/null @@ -1,11 +0,0 @@ -[package] -name = "muninn-move-tracker" -version = "0.1.0" -edition = "2024" -description = "Muninn-owned Rust orchestration and CPU mirror for PS Move marker candidate extraction" - -[lib] -name = "muninn_move_tracker" -crate-type = ["rlib", "cdylib", "staticlib"] - -[dependencies] diff --git a/crates/muninn-move-tracker/README.md b/crates/muninn-move-tracker/README.md deleted file mode 100644 index a530a8a..0000000 --- a/crates/muninn-move-tracker/README.md +++ /dev/null @@ -1,31 +0,0 @@ -# Muninn Move Tracker - -Muninn owns PS Move sensor stream exposure. This crate is Muninn's first native -optical marker candidate extractor: Rust owns orchestration, FFI, validation, -and the CPU mirror; the compute shader owns the parallel image reduction stage. -The shader includes CultMath's canonical HLSL mirror, so shader compilation must -add `E:\Projects\CultLib\packages\cultmath\shaders` or the packaged CultMath shader content to -the include path. - -This crate deliberately stops at per-frame marker candidates. Final pose, -stereo triangulation, controller association, IMU fusion, prediction, and -calibration remain separate later owners. Mimir consumes Muninn-emitted typed -candidate/observation streams; it does not own raw optical extraction. - -## Current Contract - -- Input: one luma frame (`Y8`) with width, height, stride, source hash, and frame - sequence. -- GPU stage: one 16px tile per thread group, emitting at most one candidate per - tile. -- Output: `MoveMarkerCandidate` records containing weighted centroid, radius, - area, peak/mean luma, and score. -- Rust mirror: deterministic CPU implementation and FFI wrappers for tests and - non-GPU smoke paths. -- FFI prefix: `muninn_move_tracker_*`. -- Shader IDs are carried as `uint2` low/high pairs to avoid making 64-bit HLSL - integer support a dependency of the first extraction pass. - -This is the first stage required for PlayStation-class wand tracking. The -remaining stack needs calibrated camera rays, controlled sphere colors/exposure, -multi-camera association, triangulation, IMU fusion, and latency prediction. diff --git a/crates/muninn-move-tracker/shaders/MoveSphereCandidate.comp.hlsl b/crates/muninn-move-tracker/shaders/MoveSphereCandidate.comp.hlsl deleted file mode 100644 index 9900868..0000000 --- a/crates/muninn-move-tracker/shaders/MoveSphereCandidate.comp.hlsl +++ /dev/null @@ -1,106 +0,0 @@ -// Muninn PS Move sphere candidate extraction. -// One thread group owns one image tile. The shader emits at most one bright -// marker candidate per 16px tile; Rust owns dispatch sizing and downstream ranking. - -#include "CultMath.hlsl" - -struct MoveSphereCandidate -{ - uint2 sourceIdHash; - uint2 frameSequence; - uint tileX; - uint tileY; - float centerXPx; - float centerYPx; - float radiusPx; - uint areaPx; - float meanLuma; - uint peakLuma; - float score; -}; - -cbuffer MoveTrackerConfig : register(b0) -{ - uint width; - uint height; - uint strideBytes; - uint tileSize; - uint thresholdMin; - uint minAreaPx; - uint maxCandidates; - uint _pad0; - uint2 sourceIdHash; - uint2 frameSequence; -}; - -ByteAddressBuffer frameY8 : register(t0); -RWStructuredBuffer candidates : register(u0); -RWByteAddressBuffer candidateCounter : register(u1); - -groupshared uint gArea; -groupshared uint gSumX; -groupshared uint gSumY; -groupshared uint gSumW; -groupshared uint gPeak; - -[numthreads(16, 16, 1)] -void main(uint3 groupId : SV_GroupID, uint3 groupThreadId : SV_GroupThreadID) -{ - if (groupThreadId.x == 0 && groupThreadId.y == 0) - { - gArea = 0; - gSumX = 0; - gSumY = 0; - gSumW = 0; - gPeak = 0; - } - GroupMemoryBarrierWithGroupSync(); - - uint x = groupId.x * tileSize + groupThreadId.x; - uint y = groupId.y * tileSize + groupThreadId.y; - if (groupThreadId.x < tileSize && groupThreadId.y < tileSize && x < width && y < height) - { - uint byteOffset = y * strideBytes + x; - uint packed = frameY8.Load(byteOffset & ~3u); - uint shift = (byteOffset & 3u) * 8u; - uint luma = (packed >> shift) & 0xffu; - if (luma >= thresholdMin) - { - uint weight = luma - thresholdMin + 1u; - InterlockedAdd(gArea, 1u); - InterlockedAdd(gSumX, x * weight); - InterlockedAdd(gSumY, y * weight); - InterlockedAdd(gSumW, weight); - InterlockedMax(gPeak, luma); - } - } - - GroupMemoryBarrierWithGroupSync(); - - if (groupThreadId.x == 0 && groupThreadId.y == 0 && gArea >= minAreaPx && gSumW > 0) - { - uint index; - candidateCounter.InterlockedAdd(0, 1, index); - if (index < maxCandidates) - { - float area = (float)gArea; - float tilePixels = (float)(tileSize * tileSize); - float areaScore = cultmath_saturate(area / tilePixels); - float brightnessScore = (float)gPeak / 255.0; - - MoveSphereCandidate candidate; - candidate.sourceIdHash = sourceIdHash; - candidate.frameSequence = frameSequence; - candidate.tileX = groupId.x; - candidate.tileY = groupId.y; - candidate.centerXPx = (float)gSumX / (float)gSumW; - candidate.centerYPx = (float)gSumY / (float)gSumW; - candidate.radiusPx = sqrt(area / 3.14159265358979323846); - candidate.areaPx = gArea; - candidate.meanLuma = (float)thresholdMin + (((float)gSumW / area) - 1.0); - candidate.peakLuma = gPeak; - candidate.score = brightnessScore * (0.65 + areaScore * 0.35); - candidates[index] = candidate; - } - } -} diff --git a/crates/muninn-move-tracker/src/lib.rs b/crates/muninn-move-tracker/src/lib.rs deleted file mode 100644 index 0b2d373..0000000 --- a/crates/muninn-move-tracker/src/lib.rs +++ /dev/null @@ -1,308 +0,0 @@ -#![deny(unsafe_op_in_unsafe_fn)] - -use core::ffi::c_int; - -pub const MUNINN_MOVE_TRACKER_OK: c_int = 0; -pub const MUNINN_MOVE_TRACKER_NULL: c_int = -1; -pub const MUNINN_MOVE_TRACKER_INVALID: c_int = -2; - -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct MoveTrackerConfig { - pub width: u32, - pub height: u32, - pub stride_bytes: u32, - pub tile_size: u32, - pub threshold_min: u8, - pub min_area_px: u32, - pub max_candidates: u32, - pub source_id_hash: u64, - pub frame_sequence: u64, -} - -impl Default for MoveTrackerConfig { - fn default() -> Self { - Self { - width: 320, - height: 240, - stride_bytes: 320, - tile_size: 16, - threshold_min: 180, - min_area_px: 4, - max_candidates: 64, - source_id_hash: 0, - frame_sequence: 0, - } - } -} - -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct MoveTrackerDispatchPlan { - pub group_size_x: u32, - pub group_size_y: u32, - pub groups_x: u32, - pub groups_y: u32, - pub max_candidates: u32, -} - -#[repr(C)] -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct MoveMarkerCandidate { - pub source_id_hash: u64, - pub frame_sequence: u64, - pub tile_x: u32, - pub tile_y: u32, - pub center_x_px: f32, - pub center_y_px: f32, - pub radius_px: f32, - pub area_px: u32, - pub mean_luma: f32, - pub peak_luma: u32, - pub score: f32, -} - -pub fn dispatch_plan(config: MoveTrackerConfig) -> Option { - if !config_is_valid(config) { - return None; - } - - let tile = config.tile_size; - Some(MoveTrackerDispatchPlan { - group_size_x: tile, - group_size_y: tile, - groups_x: config.width.div_ceil(tile), - groups_y: config.height.div_ceil(tile), - max_candidates: config.max_candidates, - }) -} - -pub fn extract_luma_candidates( - frame: &[u8], - config: MoveTrackerConfig, -) -> Option> { - if !config_is_valid(config) { - return None; - } - - let required = required_frame_bytes(config)?; - if frame.len() < required { - return None; - } - - let mut candidates = Vec::new(); - let tile = config.tile_size; - let groups_x = config.width.div_ceil(tile); - let groups_y = config.height.div_ceil(tile); - - for tile_y in 0..groups_y { - for tile_x in 0..groups_x { - if candidates.len() >= config.max_candidates as usize { - return Some(candidates); - } - - if let Some(candidate) = extract_tile(frame, config, tile_x, tile_y) { - candidates.push(candidate); - } - } - } - - candidates.sort_by(|left, right| { - right - .score - .partial_cmp(&left.score) - .unwrap_or(core::cmp::Ordering::Equal) - }); - Some(candidates) -} - -fn extract_tile( - frame: &[u8], - config: MoveTrackerConfig, - tile_x: u32, - tile_y: u32, -) -> Option { - let x0 = tile_x * config.tile_size; - let y0 = tile_y * config.tile_size; - let x1 = (x0 + config.tile_size).min(config.width); - let y1 = (y0 + config.tile_size).min(config.height); - let threshold = u32::from(config.threshold_min); - let mut area = 0u32; - let mut sum_x = 0u64; - let mut sum_y = 0u64; - let mut sum_w = 0u64; - let mut peak = 0u32; - - for y in y0..y1 { - let row = y as usize * config.stride_bytes as usize; - for x in x0..x1 { - let luma = u32::from(frame[row + x as usize]); - if luma < threshold { - continue; - } - - area += 1; - peak = peak.max(luma); - let weight = u64::from(luma - threshold + 1); - sum_x += u64::from(x) * weight; - sum_y += u64::from(y) * weight; - sum_w += weight; - } - } - - if area < config.min_area_px || sum_w == 0 { - return None; - } - - let center_x = sum_x as f32 / sum_w as f32; - let center_y = sum_y as f32 / sum_w as f32; - let radius = (area as f32 / core::f32::consts::PI).sqrt(); - let mean_luma = threshold as f32 + ((sum_w as f32 / area as f32) - 1.0); - let area_score = (area as f32 / (config.tile_size * config.tile_size) as f32).min(1.0); - let brightness_score = peak as f32 / 255.0; - - Some(MoveMarkerCandidate { - source_id_hash: config.source_id_hash, - frame_sequence: config.frame_sequence, - tile_x, - tile_y, - center_x_px: center_x, - center_y_px: center_y, - radius_px: radius, - area_px: area, - mean_luma, - peak_luma: peak, - score: brightness_score * (0.65 + area_score * 0.35), - }) -} - -fn config_is_valid(config: MoveTrackerConfig) -> bool { - config.width > 0 - && config.height > 0 - && config.stride_bytes >= config.width - && config.tile_size == 16 - && config.min_area_px > 0 - && config.max_candidates > 0 -} - -fn required_frame_bytes(config: MoveTrackerConfig) -> Option { - let rows = usize::try_from(config.height).ok()?; - let stride = usize::try_from(config.stride_bytes).ok()?; - rows.checked_mul(stride) -} - -#[unsafe(no_mangle)] -pub extern "C" fn muninn_move_tracker_dispatch_plan( - config: MoveTrackerConfig, - out_plan: *mut MoveTrackerDispatchPlan, -) -> c_int { - if out_plan.is_null() { - return MUNINN_MOVE_TRACKER_NULL; - } - - let Some(plan) = dispatch_plan(config) else { - return MUNINN_MOVE_TRACKER_INVALID; - }; - - unsafe { - *out_plan = plan; - } - MUNINN_MOVE_TRACKER_OK -} - -#[unsafe(no_mangle)] -pub extern "C" fn muninn_move_tracker_extract_luma_cpu( - config: MoveTrackerConfig, - frame: *const u8, - frame_len: usize, - out_candidates: *mut MoveMarkerCandidate, - out_capacity: usize, - out_count: *mut usize, -) -> c_int { - if frame.is_null() || out_candidates.is_null() || out_count.is_null() { - return MUNINN_MOVE_TRACKER_NULL; - } - - let frame = unsafe { core::slice::from_raw_parts(frame, frame_len) }; - let Some(candidates) = extract_luma_candidates(frame, config) else { - return MUNINN_MOVE_TRACKER_INVALID; - }; - - let written = candidates.len().min(out_capacity); - unsafe { - core::ptr::copy_nonoverlapping(candidates.as_ptr(), out_candidates, written); - *out_count = written; - } - MUNINN_MOVE_TRACKER_OK -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn dispatch_plan_covers_partial_tiles() { - let config = MoveTrackerConfig { - width: 33, - height: 17, - stride_bytes: 40, - tile_size: 16, - ..MoveTrackerConfig::default() - }; - - let plan = dispatch_plan(config).expect("valid plan"); - assert_eq!(3, plan.groups_x); - assert_eq!(2, plan.groups_y); - assert_eq!(16, plan.group_size_x); - assert_eq!(16, plan.group_size_y); - } - - #[test] - fn extracts_weighted_marker_candidate() { - let config = MoveTrackerConfig { - width: 32, - height: 24, - stride_bytes: 32, - tile_size: 16, - threshold_min: 180, - min_area_px: 3, - max_candidates: 8, - source_id_hash: 42, - frame_sequence: 7, - }; - let mut frame = vec![0u8; (config.stride_bytes * config.height) as usize]; - for (x, y, luma) in [ - (10u32, 8u32, 230u8), - (11, 8, 250), - (10, 9, 240), - (11, 9, 255), - ] { - frame[(y * config.stride_bytes + x) as usize] = luma; - } - - let candidates = extract_luma_candidates(&frame, config).expect("candidates"); - assert_eq!(1, candidates.len()); - let candidate = candidates[0]; - assert_eq!(42, candidate.source_id_hash); - assert_eq!(7, candidate.frame_sequence); - assert_eq!(0, candidate.tile_x); - assert_eq!(0, candidate.tile_y); - assert_eq!(4, candidate.area_px); - assert_eq!(255, candidate.peak_luma); - assert!(candidate.center_x_px > 10.4 && candidate.center_x_px < 10.7); - assert!(candidate.center_y_px > 8.4 && candidate.center_y_px < 8.7); - assert!(candidate.score > 0.65); - } - - #[test] - fn rejects_short_frames_and_empty_config() { - assert!(extract_luma_candidates(&[], MoveTrackerConfig::default()).is_none()); - assert!( - dispatch_plan(MoveTrackerConfig { - width: 0, - ..MoveTrackerConfig::default() - }) - .is_none() - ); - } -} diff --git a/crates/muninn-psmoveapi-tracker/Cargo.toml b/crates/muninn-psmoveapi-tracker/Cargo.toml deleted file mode 100644 index fa0fcf3..0000000 --- a/crates/muninn-psmoveapi-tracker/Cargo.toml +++ /dev/null @@ -1,10 +0,0 @@ -[package] -name = "muninn-psmoveapi-tracker" -version = "0.1.0" -edition = "2024" -description = "Safe Muninn ownership wrapper for the upstream PSMoveAPI tracker ABI" - -[lib] -name = "muninn_psmoveapi_tracker" - -[dependencies] diff --git a/crates/muninn-psmoveapi-tracker/src/lib.rs b/crates/muninn-psmoveapi-tracker/src/lib.rs deleted file mode 100644 index 0548abf..0000000 --- a/crates/muninn-psmoveapi-tracker/src/lib.rs +++ /dev/null @@ -1,359 +0,0 @@ -#[derive(Clone, Debug, PartialEq)] -pub struct PsmoveApiObservation { - pub move_id: String, - pub center_x_px: f32, - pub center_y_px: f32, - pub radius_px: f32, - pub age_ms: i32, -} - -#[derive(Clone, Debug, PartialEq)] -pub struct PsmoveApiCameraInfo { - pub name: String, - pub api: String, - pub width: u32, - pub height: u32, - pub exposure: f32, -} - -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct PsmoveApiTrackerError(pub String); - -impl core::fmt::Display for PsmoveApiTrackerError { - fn fmt(&self, formatter: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { - formatter.write_str(&self.0) - } -} - -impl std::error::Error for PsmoveApiTrackerError {} - -#[cfg(target_os = "linux")] -mod linux { - use super::{PsmoveApiCameraInfo, PsmoveApiObservation, PsmoveApiTrackerError}; - use core::ffi::{c_char, c_float, c_int, c_uchar, c_void}; - use std::ffi::CStr; - - const TRACKER_CALIBRATED: c_int = 2; - - #[repr(C)] - struct CameraInfo { - camera_name: *const c_char, - camera_api: *const c_char, - width: c_int, - height: c_int, - } - - #[repr(C)] - struct TrackerRgbImage { - data: *const u8, - width: c_int, - height: c_int, - } - - #[link(name = "psmoveapi")] - #[link(name = "psmoveapi_tracker")] - unsafe extern "C" { - fn psmove_count_connected() -> c_int; - fn psmove_connect_by_id(id: c_int) -> *mut c_void; - fn psmove_get_serial(controller: *mut c_void) -> *const c_char; - fn psmove_disconnect(controller: *mut c_void); - fn psmove_tracker_new_with_camera(camera: c_int) -> *mut c_void; - fn psmove_tracker_free(tracker: *mut c_void); - fn psmove_tracker_enable_color_observer( - tracker: *mut c_void, - controller: *mut c_void, - red: c_uchar, - green: c_uchar, - blue: c_uchar, - ) -> c_int; - fn psmove_tracker_set_expected_color( - tracker: *mut c_void, - controller: *mut c_void, - red: c_uchar, - green: c_uchar, - blue: c_uchar, - ) -> bool; - fn psmove_tracker_disable(tracker: *mut c_void, controller: *mut c_void); - fn psmove_tracker_set_exposure(tracker: *mut c_void, exposure: c_float); - fn psmove_tracker_get_exposure(tracker: *mut c_void) -> c_float; - fn psmove_tracker_update_image(tracker: *mut c_void); - fn psmove_tracker_update(tracker: *mut c_void, controller: *mut c_void) -> c_int; - fn psmove_tracker_get_position( - tracker: *mut c_void, - controller: *mut c_void, - x: *mut c_float, - y: *mut c_float, - radius: *mut c_float, - ) -> c_int; - fn psmove_tracker_get_camera_info(tracker: *mut c_void) -> *const CameraInfo; - fn psmove_tracker_get_image(tracker: *mut c_void) -> TrackerRgbImage; - } - - struct Controller { - handle: *mut c_void, - move_id: String, - } - - pub struct PsmoveApiTracker { - tracker: *mut c_void, - controllers: Vec, - colors: Vec<(String, [u8; 3])>, - camera_info: PsmoveApiCameraInfo, - } - - impl PsmoveApiTracker { - pub fn open( - camera_index: i32, - exposure: f32, - colors: &[(String, [u8; 3])], - ) -> Result { - if !(0.0..=1.0).contains(&exposure) { - return Err(PsmoveApiTrackerError(format!( - "PSMoveAPI exposure must be within 0..=1, got {exposure}" - ))); - } - let tracker = unsafe { psmove_tracker_new_with_camera(camera_index) }; - if tracker.is_null() { - return Err(PsmoveApiTrackerError(format!( - "PSMoveAPI could not open camera index {camera_index}" - ))); - } - unsafe { psmove_tracker_set_exposure(tracker, exposure) }; - - let mut result = Self { - tracker, - controllers: Vec::new(), - colors: colors.to_vec(), - camera_info: PsmoveApiCameraInfo { - name: "unknown".to_string(), - api: "unknown".to_string(), - width: 0, - height: 0, - exposure, - }, - }; - result.observe_connected(); - result.camera_info = unsafe { - let info = psmove_tracker_get_camera_info(tracker); - if info.is_null() { - PsmoveApiCameraInfo { - exposure: psmove_tracker_get_exposure(tracker), - ..result.camera_info.clone() - } - } else { - PsmoveApiCameraInfo { - name: text((*info).camera_name), - api: text((*info).camera_api), - width: (*info).width.max(0) as u32, - height: (*info).height.max(0) as u32, - exposure: psmove_tracker_get_exposure(tracker), - } - } - }; - Ok(result) - } - - pub fn calibrate_connected(&mut self) -> usize { - self.observe_connected() - } - - pub fn observe_connected(&mut self) -> usize { - let count = unsafe { psmove_count_connected() }.max(0); - for index in 0..count { - let handle = unsafe { psmove_connect_by_id(index) }; - if handle.is_null() { - continue; - } - let serial = unsafe { text(psmove_get_serial(handle)) }; - let move_id = format!("move-{}", serial.replace(':', "").to_ascii_lowercase()); - if self - .controllers - .iter() - .any(|controller| controller.move_id == move_id) - { - unsafe { psmove_disconnect(handle) }; - continue; - } - let Some((_, color)) = self - .colors - .iter() - .find(|(identity, _)| identity.eq_ignore_ascii_case(&move_id)) - else { - unsafe { psmove_disconnect(handle) }; - continue; - }; - let status = unsafe { - psmove_tracker_enable_color_observer( - self.tracker, - handle, - color[0], - color[1], - color[2], - ) - }; - if status == TRACKER_CALIBRATED { - self.controllers.push(Controller { handle, move_id }); - } else { - unsafe { psmove_disconnect(handle) }; - } - } - - self.controllers.len() - } - - pub fn set_expected_color(&mut self, move_id: &str, color: [u8; 3]) -> bool { - let Some(controller) = self - .controllers - .iter() - .find(|controller| controller.move_id.eq_ignore_ascii_case(move_id)) - else { - return false; - }; - unsafe { - psmove_tracker_set_expected_color( - self.tracker, - controller.handle, - color[0], - color[1], - color[2], - ) - } - } - - pub fn camera_info(&self) -> &PsmoveApiCameraInfo { - &self.camera_info - } - - pub fn tracked_controller_count(&self) -> usize { - self.controllers.len() - } - - pub fn update(&mut self) -> Vec { - unsafe { psmove_tracker_update_image(self.tracker) }; - let mut observations = Vec::new(); - for controller in &self.controllers { - unsafe { psmove_tracker_update(self.tracker, controller.handle) }; - let mut x = 0.0; - let mut y = 0.0; - let mut radius = 0.0; - let age_ms = unsafe { - psmove_tracker_get_position( - self.tracker, - controller.handle, - &mut x, - &mut y, - &mut radius, - ) - }; - if age_ms >= 0 && radius > 0.0 { - observations.push(PsmoveApiObservation { - move_id: controller.move_id.clone(), - center_x_px: x, - center_y_px: y, - radius_px: radius, - age_ms, - }); - } - } - observations - } - - pub fn rgb_image(&self) -> Option<(u32, u32, Vec)> { - let image = unsafe { psmove_tracker_get_image(self.tracker) }; - if image.data.is_null() || image.width <= 0 || image.height <= 0 { - return None; - } - let byte_len = (image.width as usize) - .checked_mul(image.height as usize)? - .checked_mul(3)?; - let bytes = unsafe { core::slice::from_raw_parts(image.data, byte_len) }; - Some((image.width as u32, image.height as u32, bytes.to_vec())) - } - } - - impl Drop for PsmoveApiTracker { - fn drop(&mut self) { - for controller in self.controllers.drain(..) { - unsafe { - psmove_tracker_disable(self.tracker, controller.handle); - psmove_disconnect(controller.handle); - } - } - unsafe { psmove_tracker_free(self.tracker) }; - } - } - - unsafe fn text(value: *const c_char) -> String { - if value.is_null() { - return String::new(); - } - unsafe { CStr::from_ptr(value) } - .to_string_lossy() - .into_owned() - } -} - -#[cfg(target_os = "linux")] -pub use linux::PsmoveApiTracker; - -#[cfg(not(target_os = "linux"))] -pub struct PsmoveApiTracker; - -#[cfg(not(target_os = "linux"))] -impl PsmoveApiTracker { - pub fn open( - _camera_index: i32, - _exposure: f32, - _colors: &[(String, [u8; 3])], - ) -> Result { - Err(PsmoveApiTrackerError( - "PSMoveAPI tracker backend is currently available on Linux".to_string(), - )) - } - - pub fn camera_info(&self) -> &PsmoveApiCameraInfo { - unreachable!() - } - - pub fn calibrate_connected(&mut self) -> usize { - 0 - } - - pub fn observe_connected(&mut self) -> usize { - 0 - } - - pub fn set_expected_color(&mut self, _move_id: &str, _color: [u8; 3]) -> bool { - false - } - - pub fn tracked_controller_count(&self) -> usize { - 0 - } - - pub fn update(&mut self) -> Vec { - Vec::new() - } - - pub fn rgb_image(&self) -> Option<(u32, u32, Vec)> { - None - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn observation_preserves_stable_move_identity() { - let observation = PsmoveApiObservation { - move_id: "move-000704a39772".to_string(), - center_x_px: 369.0, - center_y_px: 430.0, - radius_px: 10.6, - age_ms: 1, - }; - assert_eq!(observation.move_id, "move-000704a39772"); - assert!(observation.radius_px > 0.0); - } -} diff --git a/docs/architecture.md b/docs/architecture.md index c5f9391..9f69cee 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -109,8 +109,8 @@ delivery, or renderer layout. Keepalive loops belong in Idunn, not Odin's coordinator or individual daemons. Muninn is the portable local telemetry Verse assembler. Its Rust body lives in -`crates/muninn-daemon` and publishes `muninn.telemetry_surface.v1` through -CultMesh/CultCache. Muninn may run on Raven, Nightwing, Starfire, or any future +the `GameCult/Muninn` repository and publishes `muninn.telemetry_surface.v1` +through CultMesh/CultCache. Muninn may run on Raven, Nightwing, Starfire, or any future device body. It names locally accessible telemetry affordances: screen capture, loopback audio, microphones, cameras, and future sensors. Muninn does not start expensive capture streams merely because the daemon is alive. The default diff --git a/docs/muninn-media-streaming.md b/docs/muninn-media-streaming.md deleted file mode 100644 index d3105ff..0000000 --- a/docs/muninn-media-streaming.md +++ /dev/null @@ -1,374 +0,0 @@ -# Muninn Media Streaming - -Muninn live A/V is the stress organ for CultMesh transport. The goal is not -"make OBS show something once." The goal is encoded game video and audio moving -between nearby PCs at LAN speed with bounded latency, observable ownership, and -transport behavior that teaches CultMesh how to carry hot media. - -## Objective - -Raven Muninn captures screen video and Realtek loopback audio, encodes them with -hardware-first low-latency settings, publishes them through CultNet/CultMesh -media streams, and lets the Muninn OBS plugin render the stream without owning -capture, daemon lifecycle, or transport truth. - -## Current Mechanism - -The current Raven A/V path is a compatibility path: - -1. OBS reads Raven's `muninn.obs_stream_catalog` from the synced CultCache - store. -2. The OBS plugin sends a typed `muninn.capture_stream_command` to Raven's - Muninn daemon over CultNet RUDP. -3. Raven Muninn `serve` owns command acceptance and spawns a daemon-owned - activation child for the requested stream. -4. The activation child starts WASAPI loopback capture and FFmpeg. -5. FFmpeg muxes encoded video and audio into MPEG-TS on stdout. -6. Muninn slices that byte stream into fixed-size chunks and sends those chunks - over the RUDP `media` channel. -7. The OBS plugin forwards received bytes into a local FFmpeg source. - -That path proved activation, capture, hardware encode, and cross-machine -delivery. It is not the final media architecture. MPEG-TS byte chunks over an -unordered lossy hot path give CultMesh no frame identity, no deadline, no media -dependency graph, and no clean way to choose between retransmit, conceal, -discard, keyframe request, or audio/video resync. - -## Invariants - -- Muninn owns local capture and stream activation for the machine body where - the sensors live. -- OBS and Mimir are consumers. They may request a stream and report receiver - health, but they do not start capture by implication and do not own Raven - daemon state. -- Idunn owns daemon supervision and health pressure. It does not infer that - liveness means screen/audio capture should be burning. -- CultMesh owns live media stream semantics: stream identity, frame identity, - timestamps, deadlines, dependencies, channel policy, and receiver feedback. -- CultCache records are receipts, catalog entries, and operator/debug state. - They are not the hot media lane. -- Audio and video have separate clocks and recovery policy. They may share a - session, but they must not be hidden inside an opaque transport byte soup. -- Transport reliability is deadline-bound. Late media is damage, not treasure. - -## Intended Change - -Replace "MPEG-TS stdout sliced into RUDP packets" with "codec access units and -audio packets published as typed CultMesh media frames." - -The stream owner should emit media documents shaped around decisions the -transport and decoder actually need: - -```text -muninn.media_video_access_unit.v1 - stream_id - session_id - frame_id - codec - pts_ticks - duration_ticks - timebase_num - timebase_den - keyframe - dependency_frame_id - deadline_ticks - chunk_index - chunk_count - payload - -muninn.media_audio_packet.v1 - stream_id - session_id - packet_id - codec - pts_ticks - duration_ticks - timebase_num - timebase_den - deadline_ticks - payload - -muninn.media_receiver_feedback.v1 - stream_id - session_id - receiver_id - highest_decodable_frame_id - missing_frame_ids - missing_video_chunk_keys - late_frame_ids - requested_keyframe - jitter_us - decode_queue_us -``` - -The exact schema names can move when promoted into the shared document catalog, -but the ownership shape should not: frame/access-unit identity is load-bearing. - -## Authority Map - -- Owner: Muninn capture runtime owns source capture, encode configuration, and - emission of video access units plus audio packets for a requested stream. -- Inputs: explicit `muninn.capture_stream_command`, local capture devices, - encoder capabilities, receiver feedback, stream policy, and Idunn-supervised - daemon runtime state. -- Outputs: typed active stream receipts in CultCache, CultMesh media frames over - CultNet RUDP, and typed receiver/transport health receipts. -- Derived state: OBS catalog entries are discovery hints; local FFmpeg bridge - URLs are compatibility lowering details; packet counters and logs are - observability only. -- Forbidden writers: OBS source settings, local UDP bridge behavior, scheduled - task wrappers, health scripts, and replayed command receipts must not decide - capture state, stream identity, frame order, or media repair policy. -- Shared paths: direct operator requests, OBS plugin requests, future Mimir - requests, restart recovery, and reconnect recovery must all publish the same - typed capture command and consume the same active stream receipts. -- Deletion line: delete the hot-path assumption that a media stream is an - MPEG-TS byte stream. Keep it only as a named compatibility lowering until the - OBS receiver can consume typed media frames directly. - -## Boring Stream Discovery Contract - -The desired operator experience is boring: OBS asks the Verse for Muninns, the -plugin shows live sources, and a selected source either activates or reports the -specific owner that refused it. No local plugin fallback may invent Raven -devices. A fake `display:0` or Realtek loopback row is worse than an empty list -because it makes stale state look selectable. - -Live ownership: - -- Owner: Muninn `serve` owns source inventory for the body where the devices - exist. It publishes video and audio source ids, labels, command boundary, - media profile, and current active sessions. -- Inputs: local display/audio enumeration, explicit configured source hints, - daemon health, and accepted stream commands. -- Outputs: `muninn.telemetry_surface.v1`, `gamecult.eve.provider_advertisement.v1`, - `muninn.command_boundary.v1`, `muninn.transport_profile.v1`, active - `muninn.capture_stream.v1` receipts, and the temporary - `muninn.obs_stream_catalog.v1` compatibility record. -- Derived state: the OBS dropdown is a lowering of discovered Muninn state. It - is not an inventory owner, activation owner, or health owner. -- Forbidden writers: OBS defaults, local hard-coded device names, synced stale - CultCache mirrors, scheduled-task launchers, and previous activation receipts - must not create selectable devices or active streams. -- Shared paths: initial OBS load, refresh button, periodic refresh, source - selection, reconnect, and OBS restart must all read the same live-discovered - source list and issue the same typed capture command when activation is - needed. -- Deletion line: the plugin may display `discovery missing`, but it must not - synthesize `Display 1` or `Realtek loopback` as if Raven advertised them. - Until Odin/Verse discovery is the direct plugin input, the OBS catalog remains - a compatibility projection of Muninn-owned live state, not its own truth. - Compatibility store fallback is allowed only as availability fallback: the - plugin must use the first current store that yields Muninn telemetry and must - not merge stale stores into a more attractive fake inventory. - A store older than the Muninn health freshness budget is not a discovery - source; it is an outage receipt. - -This contract also splits the media session truth: - -- Video session: selected video source id, encoder profile, bitrate, frame - timestamps, access-unit ids, keyframe policy, feedback pressure, and video - receiver queue. -- Audio session: selected audio source id, sample format, packet timestamps, - reorder budget, continuity/drop policy, and OBS audio output cadence. -- Combined OBS source: a convenience lowering that may request one video session - and one audio session together. It does not merge their clocks or transport - state into one opaque "A/V target". - -## Known-Good Checkpoint (2026-06-23) - -The current system is in a usable compatibility state worth preserving before -the next transport experiment. - -What is working: - -- Raven Muninn runs continuously as the capture owner and accepts stream - activation over CultNet RUDP. -- OBS on Starfire shows the live-discovered Muninn source inventory instead of - synthesizing fake devices from stale local defaults. -- Video and audio sessions activate separately through the combined OBS source - lowering and the stream comes up cleanly again after reconnect. -- The OBS plugin currently receives the temporary live - `muninn.obs_stream_catalog.v1` projection over UDP `17874` and uses it as the - live discovery source before any CultCache fallback. - -Current compatibility details: - -- Raven command ingress is currently bound to `0.0.0.0:17883`. -- Starfire OBS listens for the temporary live OBS catalog on UDP `17874`. -- The daemon `serve` loop must continue publishing the OBS catalog while idle; - otherwise OBS falls back to stale store state and reports `discovery-missing` - or shows dead inventory. -- The current OBS plugin is still a compatibility client. It does not yet host - the Rust CultMesh runtime directly and it is not yet consuming Odin/Verse - discovery as a first-class Verse client. - -Known debt that remains intentionally visible: - -- Idunn health publication can still time out independently of the media path. - That is operational debt, not proof that Muninn discovery or streaming is - down. -- The OBS catalog is still a temporary projection. The boring end state is live - Verse discovery with no plugin-owned catalog receiver and no stale store - merge behavior. - -## Codec Direction - -Use vendor hardware encode before inventing a codec. NVIDIA's practical answer -in this space is NVENC/NVDEC, low-latency encode presets, high LAN bitrate, -short GOP or intra-refresh, no B-frames, no lookahead, and application-level -transport that understands frame deadlines. - -Shipping order: - -1. H.264 NVENC low-latency as the compatibility baseline for broad decoder - support, simple failure analysis, and OBS bridge stability. -2. HEVC NVENC low-latency when Raven encode and OBS decode both prove the - target latency budget. -3. AV1 NVENC only after the exact Raven-to-OBS path proves encode, decode, and - queue latency inside budget. AV1 can be the quality/bandwidth winner, but it - must earn the low-latency lane on our hardware instead of getting promoted by - codec fashion. - -The codec should produce elementary video access units, not a muxed MPEG-TS -program. Audio should use Opus low-delay packets unless the OBS lowering needs -a temporary PCM/AAC compatibility bridge. - -Recommended first LAN profile: - -- Profile id: `muninn.rudp.low_latency_h264_lan.v1`. -- Video: H.264 NVENC, `p1`, ultra-low-latency tune, CBR high enough that LAN - bandwidth is not the constraint, no B-frames, no lookahead, short GOP or - intra-refresh, periodic IDR/keyframe on feedback pressure. -- VBV: derive `bufsize` from roughly one frame of bitrate - (`bitrate_kbps / framerate`) instead of a large fixed encoder reservoir. - The point is bounded latency, not hoarding compressed video in a nicer hat. -- Audio: Opus low-delay when OBS lowering can consume it directly; otherwise a - temporary PCM/AAC bridge with separate packet identity and clock. -- Transport: payload elementary access units as typed media records, never a - muxed container as the unit of truth. -- Packet size: default typed media chunks are `480` bytes. That leaves headroom - for CultNet/MessagePack/RUDP headers inside a normal Ethernet MTU so the OBS - bridge does not depend on IP fragmentation or private RUDP fragment - reassembly. The sender also sets CultNet RUDP `max_fragment_bytes` to `1431` - bytes (`1472` byte IPv4 UDP payload budget minus `36` RUDP fixed header bytes - minus the five-byte `media` channel id), so oversized typed records fragment - in CultNet deliberately instead of falling through to accidental IP - fragmentation. -- Resend cadence: sender RUDP media resends are scheduled every `5` ms while - the packet is still inside the `75` ms reliable-expiry budget. -- Control: sender may adapt bitrate, keyframe cadence, chunk size, and playout - budget from receiver feedback; the receiver must not silently stretch latency - to preserve visual perfection. -- Receiver URL policy: RUDP stream URLs carry `profile`, - `sender_resend_delay_ms`, `reliable_expire_after_ms`, - `assembly_deadline_ms`, and `gap_wait_ms` so the OBS bridge receives timing - budget from Muninn's stream profile instead of hard-coding receiver policy - privately. - -### Motion Vectors And Reconstruction - -Do not make CultMesh a bespoke game-streaming codec in the first shipping path. -The motion-vector/delta idea is real, but it belongs as an augmentation around -the hardware codec, not as the primary authority. - -NVIDIA's relevant public pattern is: - -- NVENC/NVDEC for hardware video encode and decode. -- Low-latency encode tuning, high refresh rate, adaptive bitrate, and explicit - network/frame timing for cloud gaming. -- Foveated streaming for bandwidth reduction when a trusted focus region exists. -- Optical Flow / motion estimation hardware for interpolation, extrapolation, - analysis, or concealment experiments. - -For Muninn, that means: - -- If we only have desktop capture pixels, trust NVENC for motion estimation and - compression. Do not compute our own patch stream before the codec has failed a - measured requirement. -- If a game or renderer we control can expose motion vectors, depth, regions of - interest, or dirty rectangles, publish them as optional sideband media hints. - They may guide encoder ROI, decoder concealment, frame extrapolation, or - foveated quality allocation. They do not replace the encoded video access - unit unless a separate research path proves that decoder under the same OBS - latency budget. -- Stochastic reconstruction is allowed only inside a bounded playout budget. - When the deadline expires, the receiver displays the best available frame, - conceals damage, or requests a keyframe. It must not preserve dead packets by - growing latency. - -Useful references for this direction: - -- NVIDIA Video Codec SDK presets describe low-latency and ultra-low-latency - tuning plus preset tradeoffs for encoder tools such as GOP structure, - B-frames, and lookahead: - -- NVIDIA CloudXR publicly frames low-latency spatial streaming around NVENC, - adaptive bitrate, foveated streaming, diagnostics, and bidirectional data - channels: - -- NVIDIA's Optical Flow SDK exposes hardware motion vectors for real-time - interpolation/extrapolation and related video processing: - - -## CultNet RUDP Media Policy - -The RUDP `media` channel should become a media-aware lane, not merely a reliable -unordered byte lane. - -Required behavior: - -- Frame-level fragmentation and reassembly keyed by `(stream_id, session_id, - frame_id)`. -- Deadline-aware retransmission: resend while useful, drop when the playout - budget is gone. -- Receiver feedback that can NACK missing chunks, request a keyframe, and - report jitter/decode queue pressure. -- Small playout buffer chosen by policy, for example 8-20 ms on a wired LAN. -- Optional FEC/parity for video chunks where one repair packet beats a resend. -- Separate audio policy that favors continuity and bounded drift over perfect - packet recovery. - -## OBS Lowering - -The OBS plugin should lower typed media frames into OBS-friendly video/audio. -The clean target is direct decode from elementary streams using libavcodec or -NVDEC where available, then hand decoded video frames and audio packets to OBS. - -Temporary bridge allowed: - -- Reconstruct an elementary stream from typed video access units. -- Feed the local FFmpeg source only as a compatibility decoder. -- Keep audio as a separate OBS audio path so audio sync is explicit instead of - being hidden inside a damaged MPEG-TS stream. - -The compatibility bridge must not own stream truth. It is a lowering target, -not the protocol. - -## Cut Plan - -1. Promote media-frame schemas in Odin/CultLib and add unit tests for - encode/decode round trips. -2. Teach Muninn activation to emit video access units and audio packets instead - of arbitrary MPEG-TS byte chunks. -3. Teach CultNet RUDP media receive to reassemble by frame and deadline, with - explicit NACK/keyframe feedback. -4. Teach the OBS plugin to consume typed media frames and feed OBS through a - direct decoder or a narrowly named compatibility elementary-stream bridge. -5. Remove the MPEG-TS byte-chunk hot path once the typed receiver is stable. - -## Verification - -The rebuild is not proven by "OBS shows video once." Minimum proof: - -- Raven `serve` remains idle until an explicit capture command arrives. -- OBS request activates Raven without manual Raven terminals. -- Sender logs and receiver telemetry agree on stream id, session id, frame id, - codec, PTS, deadlines, drops, NACKs, and keyframe requests. -- A forced packet drop causes bounded damage and recovery, not OBS freeze. -- Audio remains present as an OBS audio source and stays within the chosen - sync budget. -- Restarting OBS does not strand Raven capture or poison the next request with - stale command receipts. -- Restarting Raven Muninn restores catalog/health without starting capture - until requested. diff --git a/docs/muninn.md b/docs/muninn.md deleted file mode 100644 index 1db43e2..0000000 --- a/docs/muninn.md +++ /dev/null @@ -1,262 +0,0 @@ -# Muninn - -Muninn is Odin's portable local telemetry Verse assembler. - -It runs on a device body and publishes a typed CultMesh/CultCache surface for -what that body can sense or expose locally: screen capture, loopback audio, -microphones, cameras, and future sensors. - -## Authority - -- Owner: Muninn owns local telemetry discovery, stream affordance publication, - and explicit activation of requested local streams. -- Inputs: cheap local probes, operator or Verse activation requests, and local - capture tools such as FFmpeg or WASAPI helpers. -- Outputs: `muninn.telemetry_surface.v1`, active `muninn.capture_stream.v1`, - Quest access records, Move receipt records, and a CultMesh bytes stream - carrying live Move evidence frames for Mimir. -- Forbidden writers: daemon startup, Idunn keepalive, Mimir ingest, OBS, Odin, - Gjallar, and renderer bodies must not start capture by implication. - -## Runtime - -```powershell -cargo build -p muninn-daemon -muninn serve --store C:\Meta\Odin\state\muninn.telemetry.cc --interval-seconds 15 --idunn-rudp-health $env:IDUNN_RUDP_HEALTH --idunn-daemon starfire-muninn --idunn-health-contract muninn.cultnet-rudp-local-telemetry-and-quest-access -muninn --health --store C:\Meta\Odin\state\muninn.telemetry.cc -``` - -`serve` is cheap and idle. It publishes affordances, consumes addressed pending -`muninn.move_light_command.v1` records, and keeps the local Verse surface fresh -without starting FFmpeg, screen capture, microphone capture, or loopback -capture. - -Muninn writes its stream affordance catalog as typed CultCache state inside: - -```text -C:\Meta\Odin\state\muninn.telemetry.cc -``` - -The record is type `muninn.obs_stream_catalog` with schema -`muninn.obs_stream_catalog.v1` at key `obs`. Consumers discover the catalog -through Odin/CultMesh; Muninn does not publish a parallel OBS RUDP catalog. - -Activation is explicit typed state: - -```powershell -muninn request-stream --target-host cultmesh://odin/media/muninn-raven-av --media-transport rudp -``` - -`target_host` in the legacy command record field is a CultMesh URI, not a host -or IP. The activation child resolves `muninn.media.rudp.v1` endpoints from -Odin's provider catalog before opening the CultNet RUDP media lane. SRT, OBS -target flags, and standalone Raven activation scripts are archived; Mimir, -OBS, and other renderers are consumers of Odin/CultMesh discovery, not transport -owners. - -## Quest Access And Unity Return Video - -Quest hardware attached to Starfire is a Muninn local telemetry surface, not a -Mimir-owned preflight. Enable it by running Muninn on Starfire with ADB probing: - -```powershell -muninn serve ` - --store C:\Meta\Odin\state\muninn.telemetry.cc ` - --host starfire ` - --quest-adb ` - --quest-serial 1WMHHB68PG1515 -``` - -When `adb devices -l` reports the Quest as `device`, Muninn publishes -`muninn.quest_access.v1` at `quest-access` and -`muninn::quest-access:`. The record advertises: - -- `muninn::quest-input`: Quest buttons/analog/controller input once a - Quest/OpenXR witness is running. -- `muninn::quest-poses`: headset and controller poses from that same - witness. -- `muninn::quest-warped-video-input`: warp-corrected video frames that - Brokkr can route from Starfire Unity editor play mode toward the Quest device. - -ADB authorization proves local USB access only. It does not by itself expose -OpenXR poses or accept video frames. A Quest/OpenXR witness still owns headset -runtime sampling, while Brokkr owns the Unity editor adapter that sends -warp-corrected play-mode frames to Muninn's advertised Quest video input. - -Read the current record with: - -```powershell -muninn quest-access-status --store C:\Meta\Odin\state\muninn.telemetry.cc -``` - -For Starfire's local Quest-attached daemon, Idunn supervises the -`starfire-muninn` target through: - -```powershell -E:\Projects\Odin\scripts\restart-starfire-muninn.cmd -E:\Projects\Odin\scripts\health-starfire-muninn.cmd -``` - -The restart script launches Muninn hidden with `--host starfire --quest-adb`, -`--idunn-rudp-health` from explicit `-IdunnRudpHealth` or -`IDUNN_RUDP_HEALTH`, `--idunn-daemon starfire-muninn`, and -`--idunn-health-contract muninn.cultnet-rudp-local-telemetry-and-quest-access`. -If the CultCache store at `C:\Meta\Odin\state\starfire.muninn.telemetry.cc` -fails MessagePack decode on boot, the restart path archives the corrupt file, -clears the stale `.lock`, and relaunches the daemon instead of leaving the lane -dead. - -The deployed loopback helper must accept Muninn's command contract: - -```powershell -wasapi-loopback-capture.ps1 -Output stdout -Device Realtek -SampleRate 48000 -Channels 2 -``` - -`stdout` is an alias for binary standard output, and `Device` is the requested -render-device hint. Current helper builds may ignore the hint and use the -default render endpoint, but they must accept the argument so Muninn's generated -mux command remains executable. - -## Move Marker Candidates - -`crates/muninn-move-tracker` is Muninn's native/Rust PS Move optical candidate -extractor. It owns dispatch planning, FFI, configuration validation, a CPU -mirror, and the HLSL 16px-tile luma reduction shader. It emits marker -candidates only: weighted centroid, radius, area, peak/mean luma, and score for -one camera frame. - -Muninn owns publishing those candidates as `muninn.move_marker_candidate.v1`. -Muninn also publishes USB controller facts as -`muninn.move_controller_state.v1`: accelerometer, gyro, magnetometer, button, -trigger, battery, sequence, and source timestamp. Mimir is the consumer of both -sensor streams. Odin may discover and project the schemas, but Odin does not -own raw capture, candidate extraction, calibration, triangulation, IMU fusion, -prediction, or final 6DoF pose. Muninn does not synthesize wand pose; it -reports what the local body saw and read from USB. - -The hot tracking path is a CultMesh stream, not CultCache polling. When -`serve` has one or more `--move-state` sources, it declares -`muninn::move-evidence` in Verse `mimir-live` and publishes -MessagePack `mimir.muninn_move_evidence_stream_frame.v1` bytes through a -shared-memory frame ring. The frame contains any marker candidates available -from local optical extraction plus the controller states read from USB. The -`muninn.move_marker_candidate.v1` and `muninn.move_controller_state.v1` records -remain receipts/debug state in the `.cc` store; Mimir drinks the stream and -owns association, calibration, fusion, prediction, and final pose. - -On Linux hosts, enable the controller-state feed by passing one or more Move -joystick sources to `serve`: - -```bash -muninn serve \ - --store ~/.local/state/gamecult/muninn/muninn.telemetry.cc \ - --host nightwing \ - --move-state move-usb=/dev/input/by-id/usb-Sony_Computer_Entertainment_Motion_Controller-joystick -``` - -The published values are raw Linux joystick/HID counts. Mimir owns calibration, -axis interpretation, unit conversion, fusion, prediction, and resolved pose -publication. Hidraw remains the local output path for LED reports. -Use `--move-evidence-stream ` or `--move-evidence-verse ` -only when the default `muninn::move-evidence` / `mimir-live` address is -not the desired Mimir-facing stream identity. - -## Move Light Commands - -Muninn is also the local output owner for USB-attached PS Moves. When Mimir -wants structured light pulses for calibration or tracking, it publishes a typed -`muninn.move_light_command.v1` command over CultNet/CultMesh to the Muninn -daemon on the host that owns the Move. `serve` consumes `pending` commands -whose `host_id` matches the local Muninn host, writes PS Move HID report `0x06` -to the command's `hidraw_path`, and updates the same command record to -`running`, `completed`, or `failed`. - -For operator smoke and bring-up, `request-move-light` writes that typed command -into the local Muninn store without touching HID directly: - -```bash -muninn request-move-light \ - --store ~/.local/state/gamecult/muninn/muninn.telemetry.cc \ - --host nightwing \ - --move move-usb \ - --hidraw /dev/hidraw1 \ - --color 35ff6c \ - --duration-ms 0 \ - --repeat-count 1 - -muninn move-light-status \ - --store ~/.local/state/gamecult/muninn/muninn.telemetry.cc \ - --host nightwing -``` - -Idunn keeps the Muninn daemon alive. Idunn does not learn a Move-specific -watcher, and Mimir does not write HID directly except through temporary smoke -scripts used to prove hardware behavior before a Muninn daemon is available. -When `serve` is launched with `--idunn-rudp-health`, `--idunn-daemon`, and -`--idunn-health-contract`, the long-running Muninn body publishes -`idunn.daemon_health` directly to Idunn over `cultnet.transport.rudp.v0` on its -normal cadence. `--health` keeps the same publication path for manual proof and -compatibility probes, but the live owner is the daemon's `serve` process. -Quest ADB probing is a telemetry input, not daemon liveness. If `adb` is -missing or the Quest is unavailable, Muninn publishes `muninn.quest_access` as -`unavailable` and keeps serving the local telemetry surface. - -## Host Deployments - -Raven runs Muninn from `C:\Meta\Odin\Muninn`. `scripts/restart-muninn.ps1` -recreates only the `GameCult-Muninn` scheduled task. It also unregisters the -obsolete `GameCult-Muninn-Activate` and `GameCult-Muninn-VideoProof` tasks if -they are present. The serve launcher starts `muninn.exe` with -`-WindowStyle Hidden`, requires `--idunn-rudp-health` from explicit -`-IdunnRudpHealth` or `IDUNN_RUDP_HEALTH`, passes `--idunn-daemon muninn`, and -`--idunn-health-contract muninn.cultnet-rudp-remote-telemetry-health`, and -requires `-MediaTargetUri` / `MUNINN_MEDIA_TARGET_URI` to be a `cultmesh://` -URI resolved through Odin at activation time. It -redirects logs under `C:\Meta\Odin\logs\muninn`. -Raven is an operator-consented host: Muninn operations on Raven must be -background-only and must not create visible terminal windows. `.cmd` files may -exist only as manual compatibility entrypoints that call the same hidden VBS -launchers; neither Task Scheduler nor the hidden VBS layer may route through a -`cmdPath` trampoline on Raven. -Health must fail if the obsolete activation or video-proof tasks still exist on -Raven. If Raven is unreachable, the repo is prepared but the live scheduler is -not clean. - -Nightwing Muninn is kept alive by the single Idunn supervisor through the -`nightwing-muninn` daemon target. Idunn learns that target through Odin's typed -daemon surface and invokes `scripts/health-nightwing-muninn.ps1` and -`scripts/restart-nightwing-muninn.ps1`; those scripts are health/restart -actuators only, not lifecycle owners. The binary is installed at -`/home/metacrat/.local/bin/muninn`, the store lives at -`/home/metacrat/.local/state/gamecult/muninn/muninn.telemetry.cc`, and the -restart actuator launches `serve --host nightwing --interval-seconds 15` with -`--move-state move-usb=/dev/input/by-id/usb-Sony_Computer_Entertainment_Motion_Controller-joystick`, -`--idunn-rudp-health` supplied by explicit `-IdunnRudpHealth` or -`IDUNN_RUDP_HEALTH`, -`--idunn-daemon nightwing-muninn`, -`--idunn-health-contract muninn.cultnet-rudp-remote-telemetry-and-move-hid`, -PID, and logs under -`/home/metacrat/.local/state/gamecult/muninn`. -With that Move state source attached, `serve` also publishes the -`muninn:nightwing:move-evidence` CultMesh stream frame body that Mimir's native -Move evidence reservoir consumes. -Nightwing source discovery must emit one Move source per controller id. The USB -pairing collection and Bluetooth joystick can expose the same controller as -separate `/dev/input/js*` paths; `nightwing-move-state-sources.sh` prefers the -Bluetooth `HID_ID=0005:0000054C:000003D5` path so Idunn health does not demand -fresh records from two faces of the same controller. The Nightwing health -actuator requires the same configured Idunn RUDP endpoint, but that command is -fallback proof only; the live keepalive contract is now published by the -long-running Nightwing `serve` process. - -Each private PSMoveAPI Eye worker owns optical observation admission before the -parent aggregates evidence. Positions older than 50 ms, blobs below 2 px, -out-of-frame coordinates, implausible radius jumps, and frame-to-frame -teleports are rejected locally. Typed `muninn.move_tracker_health.v1` records -publish rejection counts by stale/radius/bounds/continuity reason. - -PSMoveAPI's position age is freshness, not optical confidence. The aggregate -marker candidate therefore carries an explicit uncalibrated score of `0.5` -until the observer exposes a real fit-quality measure. Consumers must not treat -the thresholded color-blob radius as a calibrated physical sphere silhouette; -per-Eye radius response belongs to Mimir's calibration evidence. diff --git a/scripts/activate-muninn-raven-av-srt.cmd b/scripts/activate-muninn-raven-av-srt.cmd deleted file mode 100644 index 279d605..0000000 --- a/scripts/activate-muninn-raven-av-srt.cmd +++ /dev/null @@ -1,2 +0,0 @@ -@echo off -powershell.exe -NoProfile -ExecutionPolicy Bypass -File %~dp0activate-muninn-raven-av-srt.ps1 diff --git a/scripts/activate-muninn-raven-media.ps1 b/scripts/activate-muninn-raven-media.ps1 deleted file mode 100644 index 64021dc..0000000 --- a/scripts/activate-muninn-raven-media.ps1 +++ /dev/null @@ -1 +0,0 @@ -throw "activate-muninn-raven-media.ps1 archived: Muninn media activation is daemon-owned and target resolution is through Odin/CultMesh. Redeploy Muninn through Idunn and publish a typed muninn.capture_stream_command with a cultmesh:// media target URI." diff --git a/scripts/deploy-raven-muninn-binary.ps1 b/scripts/deploy-raven-muninn-binary.ps1 deleted file mode 100644 index ebd5cac..0000000 --- a/scripts/deploy-raven-muninn-binary.ps1 +++ /dev/null @@ -1,321 +0,0 @@ -param( - [string] $RavenHost = "raven.Home", - [string] $MuninnExe = "C:\Meta\Odin\Muninn\muninn.exe", - [string] $LocalMuninnExe = "", - [int] $ConnectTimeoutSeconds = 10, - [string] $SshUser = "madman's lullaby", - [string] $IdentityFile = "C:\Users\Meta\.ssh\id_ed25519_192_168_1_84", - [switch] $SkipBuild, - [switch] $SkipRestart, - [switch] $PreflightOnly -) - -$ErrorActionPreference = "Stop" -$repoRoot = Split-Path -Parent $PSScriptRoot - -if ([string]::IsNullOrWhiteSpace($LocalMuninnExe)) { - $LocalMuninnExe = Join-Path $repoRoot "target\release\muninn.exe" -} - -if (-not $SkipBuild) { - Push-Location $repoRoot - try { - cargo build -p muninn-daemon --release - if ($LASTEXITCODE -ne 0) { - throw "cargo build -p muninn-daemon --release failed with exit code $LASTEXITCODE" - } - } finally { - Pop-Location - } -} - -if (-not (Test-Path -LiteralPath $LocalMuninnExe)) { - throw "Local Muninn executable was not found at '$LocalMuninnExe'." -} - -if ($ConnectTimeoutSeconds -lt 1) { - throw "ConnectTimeoutSeconds must be at least 1." -} - -if (-not $SkipRestart) { - & powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "verify-muninn-media-profile.ps1") ` - -RestartScript (Join-Path $PSScriptRoot "restart-muninn.ps1") - if ($LASTEXITCODE -ne 0) { - throw "verify-muninn-media-profile.ps1 failed with exit code $LASTEXITCODE" - } -} - -function Set-AsciiFile { - param( - [Parameter(Mandatory = $true)] [string] $Path, - [Parameter(Mandatory = $true)] [string] $Content - ) - - [System.IO.File]::WriteAllText($Path, ($Content -replace "`r?`n", "`r`n"), [System.Text.Encoding]::ASCII) -} - -function ConvertTo-PowerShellStringLiteral { - param([Parameter(Mandatory = $true)] [string] $Value) - return "'" + $Value.Replace("'", "''") + "'" -} - -function Get-SshCommonArgs { - param([int] $TimeoutSeconds = $ConnectTimeoutSeconds) - - $args = @( - "-o", "BatchMode=yes", - "-o", "ConnectTimeout=$TimeoutSeconds", - "-o", "ConnectionAttempts=1" - ) - if (-not [string]::IsNullOrWhiteSpace($IdentityFile)) { - $args += @("-i", $IdentityFile) - } - return $args -} - -function Get-SshTarget { - param([Parameter(Mandatory = $true)] [string] $Target) - - if ([string]::IsNullOrWhiteSpace($SshUser)) { - return $Target - } - return "${SshUser}@${Target}" -} - -function Get-SshEffectiveConfig { - param([Parameter(Mandatory = $true)] [string] $Target) - - $previousErrorActionPreference = $ErrorActionPreference - $ErrorActionPreference = "Continue" - try { - $commonArgs = Get-SshCommonArgs - $sshTarget = Get-SshTarget -Target $Target - $output = & ssh.exe -G @commonArgs $sshTarget 2>&1 - } finally { - $ErrorActionPreference = $previousErrorActionPreference - } - - $config = @{ - hostname = $Target - user = $null - identityfile = @() - } - if ($LASTEXITCODE -ne 0) { - $config.error = ($output -join " ") - return $config - } - - foreach ($line in $output) { - if ($line -match "^hostname\s+(.+)$") { - $config.hostname = $Matches[1] - } elseif ($line -match "^user\s+(.+)$") { - $config.user = $Matches[1] - } elseif ($line -match "^identityfile\s+(.+)$") { - $config.identityfile += $Matches[1] - } - } - - return $config -} - -function Test-TcpPort { - param( - [Parameter(Mandatory = $true)] [string] $HostName, - [Parameter(Mandatory = $true)] [int] $Port, - [Parameter(Mandatory = $true)] [int] $TimeoutSeconds - ) - - $client = [System.Net.Sockets.TcpClient]::new() - try { - $async = $client.BeginConnect($HostName, $Port, $null, $null) - if (-not $async.AsyncWaitHandle.WaitOne([TimeSpan]::FromSeconds($TimeoutSeconds))) { - return "timeout" - } - $client.EndConnect($async) - return "reachable" - } catch { - return $_.Exception.Message - } finally { - $client.Close() - } -} - -function Test-IcmpHost { - param( - [Parameter(Mandatory = $true)] [string] $HostName, - [Parameter(Mandatory = $true)] [int] $TimeoutSeconds - ) - - $ping = [System.Net.NetworkInformation.Ping]::new() - try { - $reply = $ping.Send($HostName, [Math]::Max(1, $TimeoutSeconds) * 1000) - if ($null -ne $reply -and $reply.Status -eq [System.Net.NetworkInformation.IPStatus]::Success) { - return "reachable" - } - if ($null -ne $reply) { - return $reply.Status.ToString() - } - return "unreachable" - } catch { - return $_.Exception.Message - } finally { - $ping.Dispose() - } -} - -function Get-RavenSshDiagnostic { - param( - [Parameter(Mandatory = $true)] [string] $Target, - [Parameter(Mandatory = $true)] [int] $TimeoutSeconds - ) - - $config = Get-SshEffectiveConfig -Target $Target - $hostName = [string] $config.hostname - $icmp = Test-IcmpHost -HostName $hostName -TimeoutSeconds $TimeoutSeconds - $tcp22 = Test-TcpPort -HostName $hostName -Port 22 -TimeoutSeconds $TimeoutSeconds - $identity = if ($config.identityfile.Count -gt 0) { - ($config.identityfile -join ",") - } else { - "default" - } - $user = if ([string]::IsNullOrWhiteSpace($config.user)) { "default" } else { $config.user } - $configError = if ($config.ContainsKey("error")) { "; ssh_config_error=$($config.error)" } else { "" } - - return "resolved_host=$hostName user=$user identityfile=$identity icmp=$icmp tcp22=$tcp22$configError" -} - -function Invoke-RavenSshPreflight { - param( - [Parameter(Mandatory = $true)] [string] $Target, - [Parameter(Mandatory = $true)] [int] $TimeoutSeconds - ) - - $sshArgs = @( - (Get-SshTarget -Target $Target), - "cmd /c echo raven-ssh-ready" - ) - $previousErrorActionPreference = $ErrorActionPreference - $ErrorActionPreference = "Continue" - try { - $commonArgs = Get-SshCommonArgs -TimeoutSeconds $TimeoutSeconds - $output = & ssh.exe @commonArgs @sshArgs 2>&1 - } finally { - $ErrorActionPreference = $previousErrorActionPreference - } - if ($LASTEXITCODE -ne 0) { - $diagnostic = Get-RavenSshDiagnostic -Target $Target -TimeoutSeconds $TimeoutSeconds - throw "Raven SSH preflight failed for '$Target' within ${TimeoutSeconds}s: $($output -join ' '); $diagnostic" - } -} - -Invoke-RavenSshPreflight -Target $RavenHost -TimeoutSeconds $ConnectTimeoutSeconds - -if ($PreflightOnly) { - Write-Host "Raven SSH preflight succeeded for '$RavenHost'." - exit 0 -} - -$deployId = [guid]::NewGuid().ToString("N") -$localTempRoot = Join-Path $env:TEMP "odin-raven-muninn-binary-$deployId" -$localRemoteScript = Join-Path $localTempRoot "deploy-raven-muninn-binary.ps1" -$localSftpBatch = Join-Path $localTempRoot "deploy-raven-muninn-binary.sftp" -$remoteExeSftpPath = "C:/Windows/Temp/muninn-$deployId.exe" -$remoteScriptSftpPath = "C:/Windows/Temp/deploy-raven-muninn-binary-$deployId.ps1" -$remoteExePath = "C:\Windows\Temp\muninn-$deployId.exe" -$remoteScriptPath = "C:\Windows\Temp\deploy-raven-muninn-binary-$deployId.ps1" -$remoteBackupPath = "$MuninnExe.bak-$deployId" - -try { - New-Item -ItemType Directory -Force -Path $localTempRoot | Out-Null - - $remoteScript = @" -`$ErrorActionPreference = "Stop" -`$ProgressPreference = "SilentlyContinue" -`$target = $(ConvertTo-PowerShellStringLiteral $MuninnExe) -`$incoming = $(ConvertTo-PowerShellStringLiteral $remoteExePath) -`$backup = $(ConvertTo-PowerShellStringLiteral $remoteBackupPath) - -if (-not (Test-Path -LiteralPath `$incoming)) { - throw "Uploaded Muninn executable was not found at `$incoming" -} - -New-Item -ItemType Directory -Force -Path (Split-Path -Parent `$target) | Out-Null - -Get-CimInstance Win32_Process | - Where-Object { `$_.Name -ieq "muninn.exe" } | - ForEach-Object { - & cmd.exe /c ("taskkill /PID {0} /T /F >nul 2>&1" -f `$_.ProcessId) | Out-Null - } - -if (Test-Path -LiteralPath `$target) { - Copy-Item -LiteralPath `$target -Destination `$backup -Force -} - -Copy-Item -LiteralPath `$incoming -Destination `$target -Force -Remove-Item -LiteralPath `$incoming -Force -ErrorAction SilentlyContinue -`$item = Get-Item -LiteralPath `$target -Write-Output ("muninn.exe deployed length={0} path={1}" -f `$item.Length, `$item.FullName) -`$global:LASTEXITCODE = 0 -"@ - - Set-AsciiFile -Path $localRemoteScript -Content $remoteScript - Set-AsciiFile -Path $localSftpBatch -Content (@( - 'put "{0}" "{1}"' -f $LocalMuninnExe, $remoteExeSftpPath - 'put "{0}" "{1}"' -f $localRemoteScript, $remoteScriptSftpPath - ) -join "`r`n") - - $sftpArgs = @( - "-b", $localSftpBatch, - (Get-SshTarget -Target $RavenHost) - ) - $commonArgs = Get-SshCommonArgs - & sftp.exe @commonArgs @sftpArgs - if ($LASTEXITCODE -ne 0) { - throw "sftp upload to Raven failed with exit code $LASTEXITCODE" - } - - $remoteRunner = @" -`$ErrorActionPreference = "Stop" -try { - & "$remoteScriptPath" - exit 0 -} finally { - Remove-Item -LiteralPath "$remoteScriptPath" -Force -ErrorAction SilentlyContinue -} -"@ - $encodedRunner = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteRunner)) - $commonArgs = Get-SshCommonArgs - $sshTarget = Get-SshTarget -Target $RavenHost - & ssh.exe @commonArgs $sshTarget "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -OutputFormat Text -EncodedCommand $encodedRunner" - if ($LASTEXITCODE -ne 0) { - throw "remote Muninn binary deploy failed with exit code $LASTEXITCODE" - } -} finally { - Remove-Item -LiteralPath $localTempRoot -Recurse -Force -ErrorAction SilentlyContinue -} - -if (-not $SkipRestart) { - $restartArgs = @( - "-NoProfile", - "-ExecutionPolicy", - "Bypass", - "-File", - (Join-Path $PSScriptRoot "restart-muninn.ps1"), - "-RavenHost", - $RavenHost, - "-MuninnExe", - $MuninnExe, - "-ConnectTimeoutSeconds", - $ConnectTimeoutSeconds.ToString() - ) - if (-not [string]::IsNullOrWhiteSpace($SshUser)) { - $restartArgs += @("-SshUser", $SshUser) - } - if (-not [string]::IsNullOrWhiteSpace($IdentityFile)) { - $restartArgs += @("-IdentityFile", $IdentityFile) - } - & powershell.exe @restartArgs - if ($LASTEXITCODE -ne 0) { - throw "restart-muninn.ps1 failed with exit code $LASTEXITCODE" - } -} diff --git a/scripts/health-muninn.cmd b/scripts/health-muninn.cmd deleted file mode 100644 index 7811b7d..0000000 --- a/scripts/health-muninn.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -echo health-muninn.cmd archived: Raven Muninn health is daemon-published over CultNet/RUDP as muninn.cultnet-rudp-remote-telemetry-health. Use health-muninn.ps1 only as an explicit witness inspection, not Idunn health truth. -exit /b 2 diff --git a/scripts/health-muninn.ps1 b/scripts/health-muninn.ps1 deleted file mode 100644 index f783148..0000000 --- a/scripts/health-muninn.ps1 +++ /dev/null @@ -1,217 +0,0 @@ -param( - [string] $RavenHost = "raven-local", - [string] $MuninnExe = "C:\Meta\Odin\Muninn\muninn.exe", - [string] $StorePath = "C:\Meta\Odin\state\muninn.telemetry.cc", - [string] $ActivateStorePath = "C:\Meta\Odin\state\muninn.activate.cc", - [string] $LogRoot = "C:\Meta\Odin\logs\muninn", - [int] $MaxStoreAgeSeconds = 180, - [string] $IdunnRudpHealth = $env:IDUNN_RUDP_HEALTH, - [int] $ConnectTimeoutSeconds = 10, - [string] $SshUser = "madman's lullaby", - [string] $IdentityFile = "" -) - -$ErrorActionPreference = "Stop" - -if ([string]::IsNullOrWhiteSpace($IdunnRudpHealth)) { - throw "Idunn RUDP health endpoint must be supplied by -IdunnRudpHealth or IDUNN_RUDP_HEALTH; no Starfire LAN default is allowed." -} - -function Set-AsciiFile { - param( - [Parameter(Mandatory = $true)] [string] $Path, - [Parameter(Mandatory = $true)] [string] $Content - ) - - [System.IO.File]::WriteAllText($Path, ($Content -replace "`r?`n", "`r`n"), [System.Text.Encoding]::ASCII) -} - -function Get-SshCommonArgs { - $args = @( - "-o", "BatchMode=yes", - "-o", "ConnectTimeout=$ConnectTimeoutSeconds", - "-o", "ConnectionAttempts=1" - ) - if (-not [string]::IsNullOrWhiteSpace($IdentityFile)) { - $args += @("-i", $IdentityFile) - } - return $args -} - -function Get-SshTarget { - param([Parameter(Mandatory = $true)] [string] $Target) - - if ([string]::IsNullOrWhiteSpace($SshUser)) { - return $Target - } - return "${SshUser}@${Target}" -} - -function Invoke-RavenUploadedPowerShell { - param( - [Parameter(Mandatory = $true)] [string] $RavenHost, - [Parameter(Mandatory = $true)] [string] $RemoteScriptContent, - [Parameter(Mandatory = $true)] [string] $TempPrefix - ) - - $uploadId = [guid]::NewGuid().ToString("N") - $localTempRoot = Join-Path $env:TEMP "$TempPrefix-$uploadId" - $localRemoteScript = Join-Path $localTempRoot "$TempPrefix-$uploadId.ps1" - $localSftpBatch = Join-Path $localTempRoot "$TempPrefix-$uploadId.sftp" - $remoteSftpPath = "C:/Windows/Temp/$TempPrefix-$uploadId.ps1" - $remotePsPath = "C:\Windows\Temp\$TempPrefix-$uploadId.ps1" - - try { - New-Item -ItemType Directory -Force -Path $localTempRoot | Out-Null - Set-AsciiFile -Path $localRemoteScript -Content $RemoteScriptContent - Set-AsciiFile -Path $localSftpBatch -Content ('put "{0}" "{1}"' -f $localRemoteScript, $remoteSftpPath) - - $commonArgs = Get-SshCommonArgs - $sshTarget = Get-SshTarget -Target $RavenHost - & sftp.exe @commonArgs -b $localSftpBatch $sshTarget - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - - $remoteRunner = @" -`$ErrorActionPreference = "Stop" -`$ProgressPreference = "SilentlyContinue" -try { - & "$remotePsPath" - exit 0 -} finally { - Remove-Item -LiteralPath "$remotePsPath" -Force -ErrorAction SilentlyContinue -} -"@ - $encodedRunner = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteRunner)) - $commonArgs = Get-SshCommonArgs - $sshTarget = Get-SshTarget -Target $RavenHost - & ssh.exe @commonArgs $sshTarget "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -OutputFormat Text -EncodedCommand $encodedRunner" - } finally { - Remove-Item -LiteralPath $localTempRoot -Recurse -Force -ErrorAction SilentlyContinue - } -} - -$remoteScript = @" -`$ErrorActionPreference = "Stop" -`$ProgressPreference = "SilentlyContinue" -if (-not (Test-Path -LiteralPath "$MuninnExe")) { - throw "Muninn executable not found at $MuninnExe" -} -`$muninnDir = Split-Path -Parent "$MuninnExe" -`$taskExpectations = @( - @{ Name = "GameCult-Muninn"; Launcher = "powershell"; Vbs = Join-Path `$muninnDir "start-muninn-serve-hidden.vbs"; Ps = Join-Path `$muninnDir "start-muninn-serve.ps1" } -) -foreach (`$expectation in `$taskExpectations) { - `$task = Get-ScheduledTask -TaskName `$expectation.Name -ErrorAction Stop - `$action = @(`$task.Actions)[0] - if (`$expectation.Launcher -eq "powershell") { - if (`$action.Execute -notmatch 'powershell\.exe$') { - throw "`$(`$expectation.Name) action executes `$(`$action.Execute), expected powershell.exe" - } - if (`$action.Arguments -notlike "*`$(`$expectation.Ps)*") { - throw "`$(`$expectation.Name) action arguments `$(`$action.Arguments) do not reference `$(`$expectation.Ps)" - } - if (`$action.Arguments -notlike "*-File*") { - throw "`$(`$expectation.Name) action arguments `$(`$action.Arguments) do not execute a PowerShell launcher" - } - } else { - if (`$action.Execute -notmatch '(^|\\)wscript\.exe$') { - throw "`$(`$expectation.Name) action executes `$(`$action.Execute), expected wscript.exe" - } - if (`$action.Arguments -notlike "*`$(`$expectation.Vbs)*") { - throw "`$(`$expectation.Name) action arguments `$(`$action.Arguments) do not reference `$(`$expectation.Vbs)" - } - if (`$action.Arguments -notlike "*//B*" -or `$action.Arguments -notlike "*//Nologo*") { - throw "`$(`$expectation.Name) action arguments `$(`$action.Arguments) do not force hidden WScript execution" - } - } - if (-not (Test-Path -LiteralPath `$expectation.Vbs)) { - throw "Missing hidden launcher for `$(`$expectation.Name) at `$(`$expectation.Vbs)" - } - if (-not (Test-Path -LiteralPath `$expectation.Ps)) { - throw "Missing PowerShell launcher for `$(`$expectation.Name) at `$(`$expectation.Ps)" - } - `$psContent = Get-Content -LiteralPath `$expectation.Ps -Raw - if (`$psContent -like "*'' | ConvertFrom-Json*") { - throw "`$(`$expectation.Ps) has an empty encoded-arguments payload" - } - if (`$psContent -notmatch 'Start-Process') { - throw "`$(`$expectation.Ps) does not launch a hidden process" - } - `$vbs = Get-Content -LiteralPath `$expectation.Vbs -Raw - if (`$vbs -match 'cmdPath\s*=') { - throw "`$(`$expectation.Vbs) still routes through a cmdPath trampoline" - } - if (`$vbs -notmatch '\.ps1') { - throw "`$(`$expectation.Vbs) does not reference a PowerShell launcher" - } -} -if (Get-ScheduledTask -TaskName "GameCult-Muninn-Activate" -ErrorAction SilentlyContinue) { - throw "Obsolete GameCult-Muninn-Activate task is still present on Raven" -} -if (Get-ScheduledTask -TaskName "GameCult-Muninn-VideoProof" -ErrorAction SilentlyContinue) { - throw "Obsolete GameCult-Muninn-VideoProof task is still present on Raven" -} -`$servePidPath = Join-Path "$LogRoot" "muninn-serve.pid" -`$servePid = `$null -if (Test-Path -LiteralPath `$servePidPath) { - `$servePidText = (Get-Content -LiteralPath `$servePidPath -Raw).Trim() - if (`$servePidText -match '^\d+$') { - `$servePid = [int] `$servePidText - } -} -`$process = `$null -if (`$null -ne `$servePid) { - `$process = Get-CimInstance Win32_Process -Filter ("ProcessId = {0}" -f `$servePid) -ErrorAction SilentlyContinue | - Where-Object { `$_.Name -ieq "muninn.exe" -and `$_.CommandLine -like "*serve*" } | - Select-Object -First 1 -} -if (`$null -eq `$process) { - `$process = Get-CimInstance Win32_Process | - Where-Object { `$_.Name -ieq "muninn.exe" -and `$_.CommandLine -like "*serve*" } | - Select-Object -First 1 -} -if (`$null -eq `$process) { - throw "Muninn serve process is not running" -} -foreach (`$pattern in @( - "--host raven", - "--activate-store $ActivateStorePath", - "--idunn-rudp-health $IdunnRudpHealth", - "--idunn-daemon muninn", - "--idunn-health-contract muninn.cultnet-rudp-remote-telemetry-health" -)) { - if (`$process.CommandLine -notlike "*`$pattern*") { - throw "Muninn serve process is missing expected command-line segment `${pattern}: `$(`$process.CommandLine)" - } -} -`$conflictingWriter = Get-CimInstance Win32_Process | - Where-Object { - `$_.Name -like "muninn*.exe" -and - `$_.ProcessId -ne `$process.ProcessId -and - `$_.ParentProcessId -ne `$process.ProcessId -and - `$_.CommandLine -like "*activate*" -and - `$_.CommandLine -like "*$StorePath*" - } | - Select-Object -First 1 -if (`$null -ne `$conflictingWriter) { - throw "Conflicting Muninn activate writer still targets ${StorePath}: `$(`$conflictingWriter.CommandLine)" -} -if (-not (Test-Path -LiteralPath "$StorePath")) { - throw "Muninn telemetry store is missing at $StorePath" -} -`$storeAgeSeconds = (([DateTime]::UtcNow) - (Get-Item -LiteralPath "$StorePath").LastWriteTimeUtc).TotalSeconds -if (`$storeAgeSeconds -gt $MaxStoreAgeSeconds) { - throw "Muninn telemetry store is stale (`$([math]::Round(`$storeAgeSeconds))s old)" -} -if (-not (Test-Path -LiteralPath `$servePidPath)) { - throw "Muninn serve PID file is missing at `$servePidPath" -} -if (`$null -ne `$servePid -and `$process.ProcessId -ne `$servePid) { - throw "Muninn serve PID file references `$servePid but live Muninn serve process is `$(`$process.ProcessId)" -} -"@ - -Invoke-RavenUploadedPowerShell -RavenHost $RavenHost -RemoteScriptContent $remoteScript -TempPrefix "odin-raven-muninn-health" -exit $LASTEXITCODE diff --git a/scripts/health-nightwing-muninn.cmd b/scripts/health-nightwing-muninn.cmd deleted file mode 100644 index 6b6554b..0000000 --- a/scripts/health-nightwing-muninn.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -echo health-nightwing-muninn.cmd archived: Nightwing Muninn health is daemon-published over CultNet/RUDP as muninn.cultnet-rudp-remote-telemetry-and-move-hid. Use health-nightwing-muninn.ps1 only as an explicit witness inspection, not Idunn health truth. -exit /b 2 diff --git a/scripts/health-nightwing-muninn.ps1 b/scripts/health-nightwing-muninn.ps1 deleted file mode 100644 index ce2ce79..0000000 --- a/scripts/health-nightwing-muninn.ps1 +++ /dev/null @@ -1,114 +0,0 @@ -param( - [string] $SshTarget = "nightwing", - [string] $MuninnExe = "/home/metacrat/.local/bin/muninn", - [string] $StorePath = "/home/metacrat/.local/state/gamecult/muninn/muninn.telemetry.cc", - [string] $PidPath = "/home/metacrat/.local/state/gamecult/muninn/muninn.pid", - [string[]] $MoveState = @(), - [int] $IntervalSeconds = 15, - [int] $MaxStoreAgeSeconds = 180, - [string] $IdunnRudpHealth = $env:IDUNN_RUDP_HEALTH -) - -$ErrorActionPreference = "Stop" - -if ([string]::IsNullOrWhiteSpace($IdunnRudpHealth)) { - throw "Idunn RUDP health endpoint must be supplied by -IdunnRudpHealth or IDUNN_RUDP_HEALTH; no WireGuard endpoint default is allowed." -} - -function Set-AsciiFile { - param( - [Parameter(Mandatory = $true)] [string] $Path, - [Parameter(Mandatory = $true)] [string] $Content - ) - - [System.IO.File]::WriteAllText($Path, ($Content -replace "`r?`n", "`n"), [System.Text.Encoding]::ASCII) -} - -function Invoke-NightwingUploadedShell { - param( - [Parameter(Mandatory = $true)] [string] $SshTarget, - [Parameter(Mandatory = $true)] [string] $RemoteScriptContent, - [Parameter(Mandatory = $true)] [string] $TempPrefix - ) - - $uploadId = [guid]::NewGuid().ToString("N") - $localTempRoot = Join-Path $env:TEMP "$TempPrefix-$uploadId" - $localRemoteScript = Join-Path $localTempRoot "$TempPrefix-$uploadId.sh" - $localSftpBatch = Join-Path $localTempRoot "$TempPrefix-$uploadId.sftp" - $remoteScriptPath = "/tmp/$TempPrefix-$uploadId.sh" - - try { - New-Item -ItemType Directory -Force -Path $localTempRoot | Out-Null - Set-AsciiFile -Path $localRemoteScript -Content $RemoteScriptContent - Set-AsciiFile -Path $localSftpBatch -Content ('put "{0}" "{1}"' -f $localRemoteScript, $remoteScriptPath) - - & sftp.exe -b $localSftpBatch $SshTarget - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - - & ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $SshTarget "chmod +x '$remoteScriptPath' && bash '$remoteScriptPath'" - $remoteExit = $LASTEXITCODE - & ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $SshTarget "rm -f '$remoteScriptPath'" - exit $remoteExit - } finally { - Remove-Item -LiteralPath $localTempRoot -Recurse -Force -ErrorAction SilentlyContinue - } -} - -$remoteScript = @" -#!/usr/bin/env bash -set -euo pipefail -if [ ! -x '$MuninnExe' ]; then - echo 'Muninn executable not found at $MuninnExe' >&2 - exit 1 -fi -if [ -f '$PidPath' ] && kill -0 "`$(cat '$PidPath')" 2>/dev/null; then - : -elif pgrep -f '[m]uninn serve .*--host nightwing' >/dev/null 2>&1; then - : -else - echo 'Muninn serve process is not running on Nightwing' >&2 - exit 1 -fi -process_line="`$(pgrep -af '[m]uninn serve .*--host nightwing' | head -n1 || true)" -if [ -z "`$process_line" ]; then - echo 'Nightwing Muninn serve command line is unavailable' >&2 - exit 1 -fi -for pattern in \ - '--idunn-rudp-health $IdunnRudpHealth' \ - '--idunn-daemon nightwing-muninn' \ - '--idunn-health-contract muninn.cultnet-rudp-remote-telemetry-and-move-hid' -do - if ! printf '%s\n' "`$process_line" | grep -F -- "`$pattern" >/dev/null 2>&1; then - echo "Nightwing Muninn serve command line is missing `$pattern" >&2 - exit 1 - fi -done -if [ ! -f '$StorePath' ]; then - echo 'Muninn telemetry store is missing on Nightwing' >&2 - exit 1 -fi -store_mtime="`$(stat -c %Y '$StorePath')" -now="`$(date +%s)" -store_age="`$((now - store_mtime))" -if [ "`$store_age" -gt '$MaxStoreAgeSeconds' ]; then - echo "Muninn telemetry store is stale on Nightwing (`$store_age s old)" >&2 - exit 1 -fi -if printf '%s\n' "`$process_line" | grep -F -- '--move-state' >/dev/null 2>&1 || \ - printf '%s\n' "`$process_line" | grep -F -- '--move-evidence-stream' >/dev/null 2>&1; then - identity_output="`$('$MuninnExe' move-identity-status --store '$StorePath' --host nightwing)" - if printf '%s\n' "`$identity_output" | grep -Fq 'No Muninn Move identity records found'; then - echo 'Muninn Move identity roster is empty on Nightwing' >&2 - exit 1 - fi - identity_count="`$(printf '%s\n' "`$identity_output" | grep -c 'move-identity move=')" - echo "Muninn healthy: nightwing store_age=`${store_age}s move_runtime=explicit move_identities=`${identity_count}" -else - echo "Muninn healthy: nightwing store_age=`${store_age}s move_runtime=disabled" -fi -"@ - -Invoke-NightwingUploadedShell -SshTarget $SshTarget -RemoteScriptContent $remoteScript -TempPrefix "odin-nightwing-muninn-health" diff --git a/scripts/health-starfire-muninn.cmd b/scripts/health-starfire-muninn.cmd deleted file mode 100644 index 36d421c..0000000 --- a/scripts/health-starfire-muninn.cmd +++ /dev/null @@ -1,3 +0,0 @@ -@echo off -echo health-starfire-muninn.cmd archived: Starfire Muninn health is daemon-published over CultNet/RUDP as muninn.cultnet-rudp-local-telemetry-and-quest-access. Use health-starfire-muninn.ps1 only as an explicit witness inspection, not Idunn health truth. -exit /b 2 diff --git a/scripts/health-starfire-muninn.ps1 b/scripts/health-starfire-muninn.ps1 deleted file mode 100644 index c331145..0000000 --- a/scripts/health-starfire-muninn.ps1 +++ /dev/null @@ -1,50 +0,0 @@ -param( - [string] $MuninnExe = "C:\Meta\Odin\Muninn\muninn.exe", - [string] $StorePath = "C:\Meta\Odin\state\starfire.muninn.telemetry.cc", - [string] $LogRoot = "C:\Meta\Odin\logs\starfire-muninn", - [string[]] $MoveState = @(), - [int] $MaxStoreAgeSeconds = 180, - [string] $IdunnRudpHealth = $env:IDUNN_RUDP_HEALTH -) - -$ErrorActionPreference = "Stop" - -if ([string]::IsNullOrWhiteSpace($IdunnRudpHealth)) { - throw "Idunn RUDP health endpoint must be supplied by -IdunnRudpHealth or IDUNN_RUDP_HEALTH; no Starfire LAN default is allowed." -} - -if (-not (Test-Path -LiteralPath $MuninnExe)) { - throw "Muninn executable not found at $MuninnExe" -} - -$process = Get-CimInstance Win32_Process | - Where-Object { - $_.Name -ieq "muninn.exe" -and - $_.CommandLine -like "*serve*" -and - $_.CommandLine -like "*--host*starfire*" -and - $_.CommandLine -like "*--quest-adb*" - } | - Select-Object -First 1 -if ($null -eq $process) { - throw "Starfire Muninn serve process is not running with Quest access enabled" -} -foreach ($pattern in @( - "--idunn-rudp-health $IdunnRudpHealth", - "--idunn-daemon starfire-muninn", - "--idunn-health-contract muninn.cultnet-rudp-local-telemetry-and-quest-access" -)) { - if ($process.CommandLine -notlike "*$pattern*") { - throw "Starfire Muninn serve process is missing expected command-line segment ${pattern}: $($process.CommandLine)" - } -} -if (-not (Test-Path -LiteralPath $StorePath)) { - throw "Starfire Muninn telemetry store is missing at $StorePath" -} -$storeAgeSeconds = (([DateTime]::UtcNow) - (Get-Item -LiteralPath $StorePath).LastWriteTimeUtc).TotalSeconds -if ($storeAgeSeconds -gt $MaxStoreAgeSeconds) { - throw "Starfire Muninn telemetry store is stale ($([math]::Round($storeAgeSeconds))s old)" -} -$pidPath = Join-Path $LogRoot "muninn-serve.pid" -if (-not (Test-Path -LiteralPath $pidPath)) { - throw "Starfire Muninn serve PID file is missing at $pidPath" -} diff --git a/scripts/repair-raven-muninn-task-actions.ps1 b/scripts/repair-raven-muninn-task-actions.ps1 deleted file mode 100644 index 040631a..0000000 --- a/scripts/repair-raven-muninn-task-actions.ps1 +++ /dev/null @@ -1,156 +0,0 @@ -param( - [string] $RavenHost = "raven", - [string] $MuninnDir = "C:\Meta\Odin\Muninn" -) - -$ErrorActionPreference = "Stop" - -$remoteScript = @" -`$ErrorActionPreference = "Stop" -`$ProgressPreference = "SilentlyContinue" - -function Register-HiddenVbsTask { - param( - [Parameter(Mandatory = `$true)] [string] `$TaskName, - [Parameter(Mandatory = `$true)] [string] `$VbsPath - ) - - if (-not (Test-Path -LiteralPath `$VbsPath)) { - throw "Hidden launcher not found for `$TaskName at `$VbsPath" - } - - `$action = New-ScheduledTaskAction -Execute "wscript.exe" -Argument "//B //Nologo ""`$VbsPath""" - `$trigger = New-ScheduledTaskTrigger -Once -At ([DateTime]::Today.AddHours(23).AddMinutes(59)) - `$principal = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Limited - `$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew - Register-ScheduledTask -TaskName `$TaskName -Action `$action -Trigger `$trigger -Principal `$principal -Settings `$settings -Force | Out-Null -} - -function Assert-HiddenVbsTask { - param( - [Parameter(Mandatory = `$true)] [string] `$TaskName, - [Parameter(Mandatory = `$true)] [string] `$VbsPath - ) - - `$task = Get-ScheduledTask -TaskName `$TaskName -ErrorAction Stop - `$action = @(`$task.Actions)[0] - if (`$action.Execute -notmatch '(^|\\)wscript\.exe$') { - throw "`$TaskName action executes `$(`$action.Execute), expected wscript.exe" - } - if (`$action.Arguments -notlike "*`$VbsPath*") { - throw "`$TaskName action arguments `$(`$action.Arguments) do not reference `$VbsPath" - } - if (`$action.Arguments -notlike "*//B*" -or `$action.Arguments -notlike "*//Nologo*") { - throw "`$TaskName action arguments `$(`$action.Arguments) do not force background WScript execution" - } - `$vbs = Get-Content -LiteralPath `$VbsPath -Raw - if (`$vbs -match 'cmdPath\s*=') { - throw "`$TaskName hidden launcher at `$VbsPath still routes through a cmdPath trampoline" - } - if (`$vbs -notmatch '\.ps1') { - throw "`$TaskName hidden launcher at `$VbsPath does not reference a PowerShell launcher" - } -} - -`$serveVbs = Join-Path "$MuninnDir" "start-muninn-serve-hidden.vbs" -`$activateCmd = Join-Path "$MuninnDir" "activate-raven-av-srt.cmd" -`$activatePs = Join-Path "$MuninnDir" "activate-raven-av-srt.ps1" -`$activateVbs = Join-Path "$MuninnDir" "activate-raven-av-srt-hidden.vbs" -`$videoProofCmd = Join-Path "$MuninnDir" "muninn-raven-video-to-starfire-obs.cmd" -`$videoProofPs = Join-Path "$MuninnDir" "muninn-raven-video-to-starfire-obs.ps1" -`$videoProofVbs = Join-Path "$MuninnDir" "muninn-raven-video-to-starfire-obs-hidden.vbs" - -function Write-HiddenPowerShellVbsLauncher { - param( - [Parameter(Mandatory = `$true)] [string] `$PsPath, - [Parameter(Mandatory = `$true)] [string] `$VbsPath - ) - - if (-not (Test-Path -LiteralPath `$PsPath)) { - throw "PowerShell launcher not found at `$PsPath" - } - - `$lines = @( - 'Set fso = CreateObject("Scripting.FileSystemObject")', - 'scriptDir = fso.GetParentFolderName(WScript.ScriptFullName)', - 'Set shell = CreateObject("WScript.Shell")', - "psLauncher = ""`$PsPath""", - 'shell.CurrentDirectory = scriptDir', - 'shell.Run "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File """ & psLauncher & """", 0, False' - ) - Set-Content -LiteralPath `$VbsPath -Value `$lines -Encoding ASCII -} - -function Write-WscriptCmdLauncher { - param( - [Parameter(Mandatory = `$true)] [string] `$CmdPath, - [Parameter(Mandatory = `$true)] [string] `$WorkingDirectory, - [Parameter(Mandatory = `$true)] [string] `$VbsPath - ) - - `$cmdLines = @( - '@echo off', - "cd /d ""`$WorkingDirectory""", - "wscript.exe //B //Nologo ""`$VbsPath""" - ) - Set-Content -LiteralPath `$CmdPath -Value `$cmdLines -Encoding ASCII -} - -if (Test-Path -LiteralPath `$activatePs) { - Write-HiddenPowerShellVbsLauncher -PsPath `$activatePs -VbsPath `$activateVbs - if (Test-Path -LiteralPath `$activateCmd) { - Write-WscriptCmdLauncher -CmdPath `$activateCmd -WorkingDirectory "$MuninnDir" -VbsPath `$activateVbs - } -} -if (Test-Path -LiteralPath `$videoProofPs) { - Write-HiddenPowerShellVbsLauncher -PsPath `$videoProofPs -VbsPath `$videoProofVbs - if (Test-Path -LiteralPath `$videoProofCmd) { - Write-WscriptCmdLauncher -CmdPath `$videoProofCmd -WorkingDirectory "$MuninnDir" -VbsPath `$videoProofVbs - } -} - -Register-HiddenVbsTask -TaskName "GameCult-Muninn" -VbsPath `$serveVbs -if (Test-Path -LiteralPath `$activateVbs) { - Register-HiddenVbsTask -TaskName "GameCult-Muninn-Activate" -VbsPath `$activateVbs -} -if (Test-Path -LiteralPath `$videoProofVbs) { - Register-HiddenVbsTask -TaskName "GameCult-Muninn-VideoProof" -VbsPath `$videoProofVbs -} - -Assert-HiddenVbsTask -TaskName "GameCult-Muninn" -VbsPath `$serveVbs -if (Test-Path -LiteralPath `$activateVbs) { - Assert-HiddenVbsTask -TaskName "GameCult-Muninn-Activate" -VbsPath `$activateVbs -} -if (Test-Path -LiteralPath `$videoProofVbs) { - Assert-HiddenVbsTask -TaskName "GameCult-Muninn-VideoProof" -VbsPath `$videoProofVbs -} -"@ - -$uploadId = [guid]::NewGuid().ToString("N") -$localRemoteScript = Join-Path $env:TEMP "odin-raven-muninn-task-repair-$uploadId.ps1" -$localSftpBatch = Join-Path $env:TEMP "odin-raven-muninn-task-repair-$uploadId.sftp" -$remoteSftpPath = "C:/Windows/Temp/odin-raven-muninn-task-repair-$uploadId.ps1" -$remotePsPath = "C:\Windows\Temp\odin-raven-muninn-task-repair-$uploadId.ps1" -try { - Set-Content -LiteralPath $localRemoteScript -Encoding ASCII -Value $remoteScript - Set-Content -LiteralPath $localSftpBatch -Encoding ASCII -Value "put ""$localRemoteScript"" ""$remoteSftpPath""" - & sftp.exe -b $localSftpBatch $RavenHost - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - $remoteRunner = @" -`$ErrorActionPreference = "Stop" -try { - & "$remotePsPath" - `$code = `$LASTEXITCODE -} finally { - Remove-Item -LiteralPath "$remotePsPath" -Force -ErrorAction SilentlyContinue -} -exit `$code -"@ - $encodedRunner = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteRunner)) - & ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $RavenHost "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand $encodedRunner" -} finally { - Remove-Item -LiteralPath $localRemoteScript, $localSftpBatch -Force -ErrorAction SilentlyContinue -} -exit $LASTEXITCODE diff --git a/scripts/restart-muninn.cmd b/scripts/restart-muninn.cmd deleted file mode 100644 index 4785b68..0000000 --- a/scripts/restart-muninn.cmd +++ /dev/null @@ -1,10 +0,0 @@ -@echo off -if not "%IDUNN_ACTUATOR%"=="1" ( - echo restart-muninn.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2 - exit /b 1 -) -if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" ( - echo restart-muninn.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2 - exit /b 1 -) -powershell.exe -NoProfile -ExecutionPolicy Bypass -File %~dp0restart-muninn.ps1 diff --git a/scripts/restart-muninn.ps1 b/scripts/restart-muninn.ps1 deleted file mode 100644 index 3d52a7b..0000000 --- a/scripts/restart-muninn.ps1 +++ /dev/null @@ -1,897 +0,0 @@ -param( - [string] $RavenHost = "raven.Home", - [string] $MuninnExe = "C:\Meta\Odin\Muninn\muninn.exe", - [string] $StorePath = "C:\Meta\Odin\state\muninn.telemetry.cc", - [string] $ActivateStorePath = "C:\Meta\Odin\state\muninn.activate.cc", - [string] $LogRoot = "C:\Meta\Odin\logs\muninn", - [string] $LocalLoopbackScript = (Join-Path $PSScriptRoot "wasapi-loopback-capture.ps1"), - [string] $LoopbackScript = "C:\Meta\Odin\Muninn\scripts\wasapi-loopback-capture.ps1", - [string] $Ffmpeg = "C:\Users\Madman's Lullaby\AppData\Local\Microsoft\WinGet\Links\ffmpeg.exe", - [string] $MediaTargetUri = $env:MUNINN_MEDIA_TARGET_URI, - [string] $AudioDevice = "Realtek", - [string[]] $VideoSources = @(), - [string[]] $AudioSources = @(), - [string] $IdunnRudpHealth = $env:IDUNN_RUDP_HEALTH, - [string] $IdunnDaemon = "muninn", - [string] $IdunnHealthContract = "muninn.cultnet-rudp-remote-telemetry-health", - [string] $OdinCultMeshUri = $(if ($env:ODIN_CULTMESH_URI) { $env:ODIN_CULTMESH_URI } else { "cultmesh://odin/rendezvous/provider-catalog" }), - [string] $OdinCultMeshRudpEndpoint = $env:CULTMESH_URI_ODIN_RUDP, - [string[]] $MoveStates = @("xbox-raven=xinput://0"), - [string] $HidControllerRudpTarget = "", - [string] $HidControllerRudpBind = "0.0.0.0:17887", - [string] $HidControllerRudpAdvertise = $env:MUNINN_HID_CONTROLLER_RUDP_ADVERTISE, - [int] $ConnectTimeoutSeconds = 10, - [int] $ServeStartTimeoutSeconds = 20, - [string] $SshUser = "madman's lullaby", - [string] $IdentityFile = "C:\Users\Meta\.ssh\id_ed25519_192_168_1_84" -) - -$ErrorActionPreference = "Stop" - -if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") { - throw "restart-muninn.ps1 is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path." -} - -if (-not (Test-Path -LiteralPath $LocalLoopbackScript)) { - throw "Local Muninn loopback script not found at $LocalLoopbackScript" -} -if ([string]::IsNullOrWhiteSpace($IdunnRudpHealth)) { - throw "Idunn RUDP health endpoint must be supplied by -IdunnRudpHealth or IDUNN_RUDP_HEALTH; no Starfire LAN default is allowed." -} -if ([string]::IsNullOrWhiteSpace($MediaTargetUri) -or -not $MediaTargetUri.StartsWith("cultmesh://", [System.StringComparison]::OrdinalIgnoreCase)) { - throw "Muninn media target URI must be supplied by -MediaTargetUri or MUNINN_MEDIA_TARGET_URI and must start with cultmesh://." -} -if (-not [string]::IsNullOrWhiteSpace($OdinCultMeshUri) -and $OdinCultMeshUri.StartsWith("cultmesh://", [System.StringComparison]::OrdinalIgnoreCase) -and [string]::IsNullOrWhiteSpace($OdinCultMeshRudpEndpoint)) { - throw "Odin CultMesh RUDP endpoint must be supplied by -OdinCultMeshRudpEndpoint or CULTMESH_URI_ODIN_RUDP when -OdinCultMeshUri is a cultmesh:// URI." -} -if (-not [string]::IsNullOrWhiteSpace($HidControllerRudpBind) -and [string]::IsNullOrWhiteSpace($HidControllerRudpAdvertise)) { - throw "HID controller RUDP advertise endpoint must be supplied by -HidControllerRudpAdvertise or MUNINN_HID_CONTROLLER_RUDP_ADVERTISE when HID RUDP bind is enabled; no Raven LAN default is allowed." -} - -function Set-AsciiFile { - param( - [Parameter(Mandatory = $true)] [string] $Path, - [Parameter(Mandatory = $true)] [string] $Content - ) - - [System.IO.File]::WriteAllText($Path, ($Content -replace "`r?`n", "`r`n"), [System.Text.Encoding]::ASCII) -} - -function ConvertTo-PowerShellStringLiteral { - param( - [Parameter(Mandatory = $true)] [string] $Value - ) - - return "'" + $Value.Replace("'", "''") + "'" -} - -function ConvertTo-PowerShellArrayLiteral { - param( - [Parameter(Mandatory = $true)] [string[]] $Values - ) - - $lines = $Values | ForEach-Object { " {0}" -f (ConvertTo-PowerShellStringLiteral $_) } - return "@(`r`n{0}`r`n)" -f ($lines -join ",`r`n") -} - -function ConvertTo-PowerShellEnvAssignmentLines { - param( - [Parameter(Mandatory = $true)] [hashtable] $Environment - ) - - $lines = @() - foreach ($key in ($Environment.Keys | Sort-Object)) { - $name = [string] $key - if ($name -notmatch '^[A-Za-z_][A-Za-z0-9_]*$') { - throw "Invalid environment variable name for Muninn launcher: $name" - } - $value = [string] $Environment[$key] - $lines += '$env:{0} = {1}' -f $name, (ConvertTo-PowerShellStringLiteral $value) - } - return ($lines -join "`r`n") -} - -function Get-SshCommonArgs { - $args = @( - "-o", "BatchMode=yes", - "-o", "ConnectTimeout=$ConnectTimeoutSeconds", - "-o", "ConnectionAttempts=1" - ) - if (-not [string]::IsNullOrWhiteSpace($IdentityFile)) { - $args += @("-i", $IdentityFile) - } - return $args -} - -function Get-SshTarget { - param([Parameter(Mandatory = $true)] [string] $Target) - - if ([string]::IsNullOrWhiteSpace($SshUser)) { - return $Target - } - return "${SshUser}@${Target}" -} - -function Test-LikelyVirtualDisplayToken { - param([Parameter(Mandatory = $true)] [string] $Token) - - $trimmed = $Token.Trim() - if ([string]::IsNullOrWhiteSpace($trimmed)) { - return $true - } - return ( - $trimmed.StartsWith('MSBDD_', [System.StringComparison]::OrdinalIgnoreCase) -or - $trimmed.StartsWith('MSNIL', [System.StringComparison]::OrdinalIgnoreCase) -or - $trimmed.StartsWith('NOEDID', [System.StringComparison]::OrdinalIgnoreCase) -or - $trimmed.StartsWith('UGD', [System.StringComparison]::OrdinalIgnoreCase) - ) -} - -function Get-GraphicsDriverDisplayTokens { - $connectivityRoot = 'HKLM:\SYSTEM\CurrentControlSet\Control\GraphicsDrivers\Connectivity' - if (-not (Test-Path -LiteralPath $connectivityRoot)) { - return @() - } - - $bestTokens = @() - $bestScore = [int]::MinValue - foreach ($key in Get-ChildItem -LiteralPath $connectivityRoot -ErrorAction SilentlyContinue) { - $item = Get-ItemProperty -LiteralPath $key.PSPath -ErrorAction SilentlyContinue - if ($null -eq $item) { - continue - } - foreach ($property in $item.PSObject.Properties) { - if ($property.Name -like 'PS*' -or $property.Value -isnot [string]) { - continue - } - $tokens = @( - $property.Value.Split('+') | - ForEach-Object { $_.Trim() } | - Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - ) - if ($tokens.Count -lt 1) { - continue - } - $realCount = @($tokens | Where-Object { -not (Test-LikelyVirtualDisplayToken $_) }).Count - $score = ($realCount * 100) + $tokens.Count - if ($score -gt $bestScore) { - $bestScore = $score - $bestTokens = $tokens - } - } - } - - return @($bestTokens | Select-Object -Unique) -} - -function Get-DefaultMuninnVideoSourceLabels { - param([Parameter(Mandatory = $true)] [string] $HostLabel) - - $displayNames = @( - Get-CimInstance Win32_DesktopMonitor -ErrorAction SilentlyContinue | - Where-Object { [string]::IsNullOrWhiteSpace($_.Status) -or $_.Status -eq 'OK' } | - ForEach-Object { - if ([string]::IsNullOrWhiteSpace($_.Name)) { - 'Display' - } else { - $_.Name.Trim() - } - } - ) - - $useRegistryFallback = ( - $displayNames.Count -lt 1 -or - ($displayNames.Count -eq 1 -and ( - $displayNames[0] -match '^\s*WinDisc\s*$' -or - $displayNames[0] -match '^\s*Generic PnP Monitor\s*$' -or - $displayNames[0] -match '^\s*Display\s*$' - )) - ) - - if ($useRegistryFallback) { - $displayTokens = @(Get-GraphicsDriverDisplayTokens) - if ($displayTokens.Count -gt 0) { - return @(for ($index = 0; $index -lt $displayTokens.Count; $index++) { - '{0} display {1}' -f $HostLabel, ($index + 1) - }) - } - } - - if ($displayNames.Count -lt 1) { - $displayNames = @('Display') - } - - return @(for ($index = 0; $index -lt $displayNames.Count; $index++) { - $monitorName = $displayNames[$index] - if ($monitorName -eq 'Display') { - '{0} display {1}' -f $HostLabel, ($index + 1) - } else { - '{0} display {1} ({2})' -f $HostLabel, ($index + 1), $monitorName - } - }) -} - -function New-HiddenNativeLauncherPowerShellContent { - param( - [Parameter(Mandatory = $false)] [string] $StorePath, - [Parameter(Mandatory = $true)] [string] $LogRoot, - [Parameter(Mandatory = $true)] [string[]] $Arguments, - [Parameter(Mandatory = $true)] [string] $FilePath, - [Parameter(Mandatory = $true)] [string] $StdoutPath, - [Parameter(Mandatory = $true)] [string] $StderrPath, - [Parameter(Mandatory = $true)] [string] $PidPath, - [Parameter(Mandatory = $false)] [string] $WorkingDirectory, - [Parameter(Mandatory = $false)] [hashtable] $Environment = @{}, - [Parameter(Mandatory = $false)] [string] $DynamicArgumentsScript, - [Parameter(Mandatory = $false)] [switch] $WaitForExit - ) - - $template = @' -$ErrorActionPreference = "Stop" -$ProgressPreference = "SilentlyContinue" -__STORE_DIR_LINE__ -New-Item -ItemType Directory -Force -Path __LOG_ROOT__ | Out-Null -$launcherLog = __PID_PATH__ + '.launcher.log' -('launcher-start ' + [DateTime]::UtcNow.ToString('o')) | Set-Content -Encoding ASCII -LiteralPath $launcherLog -__ENVIRONMENT_LINES__ -$arguments = __ARGUMENTS__ -__DYNAMIC_ARGUMENTS_SCRIPT__ -function Quote-NativeArgument([string] $Value) { - if ($Value -match '[\s"]') { - return '"' + $Value.Replace('"', '\"') + '"' - } - return $Value -} -$argumentLine = ($arguments | ForEach-Object { Quote-NativeArgument $_ }) -join ' ' -('launcher-args ' + $argumentLine) | Add-Content -Encoding ASCII -LiteralPath $launcherLog -if (__RUN_INLINE__) { - $PID | Set-Content -Encoding ASCII -LiteralPath __PID_PATH__ - ('launcher-pid ' + $PID + ' powershell-inline') | Add-Content -Encoding ASCII -LiteralPath $launcherLog - $previousErrorActionPreference = $ErrorActionPreference - $ErrorActionPreference = "Continue" - try { - & __FILE_PATH__ @arguments > __STDOUT_PATH__ 2> __STDERR_PATH__ - $exitCode = $LASTEXITCODE - } catch { - ('launcher-error ' + $_.Exception.Message) | Add-Content -Encoding ASCII -LiteralPath $launcherLog - throw - } finally { - $ErrorActionPreference = $previousErrorActionPreference - } - ('launcher-exit ' + $exitCode) | Add-Content -Encoding ASCII -LiteralPath $launcherLog - exit $exitCode -} -try { - $process = Start-Process -FilePath __FILE_PATH__ -ArgumentList $argumentLine __WORKING_DIRECTORY_CLAUSE__-WindowStyle Hidden -PassThru -RedirectStandardOutput __STDOUT_PATH__ -RedirectStandardError __STDERR_PATH__ - $process.Id | Set-Content -Encoding ASCII -LiteralPath __PID_PATH__ - ('launcher-pid ' + $process.Id) | Add-Content -Encoding ASCII -LiteralPath $launcherLog -} catch { - ('launcher-error ' + $_.Exception.Message) | Add-Content -Encoding ASCII -LiteralPath $launcherLog - throw -} -__WAIT_FOR_EXIT_LINE__ -'@ - - $storeDirLine = if ([string]::IsNullOrWhiteSpace($StorePath)) { - "" - } else { - "New-Item -ItemType Directory -Force -Path (Split-Path -Parent {0}) | Out-Null" -f (ConvertTo-PowerShellStringLiteral $StorePath) - } - $workingDirectoryClause = if ([string]::IsNullOrWhiteSpace($WorkingDirectory)) { - "" - } else { - "-WorkingDirectory {0} " -f (ConvertTo-PowerShellStringLiteral $WorkingDirectory) - } - - return $template. - Replace("__STORE_DIR_LINE__", $storeDirLine). - Replace("__LOG_ROOT__", (ConvertTo-PowerShellStringLiteral $LogRoot)). - Replace("__ENVIRONMENT_LINES__", (ConvertTo-PowerShellEnvAssignmentLines $Environment)). - Replace("__ARGUMENTS__", (ConvertTo-PowerShellArrayLiteral $Arguments)). - Replace("__DYNAMIC_ARGUMENTS_SCRIPT__", $DynamicArgumentsScript). - Replace("__FILE_PATH__", (ConvertTo-PowerShellStringLiteral $FilePath)). - Replace("__WORKING_DIRECTORY_CLAUSE__", $workingDirectoryClause). - Replace("__STDOUT_PATH__", (ConvertTo-PowerShellStringLiteral $StdoutPath)). - Replace("__STDERR_PATH__", (ConvertTo-PowerShellStringLiteral $StderrPath)). - Replace("__PID_PATH__", (ConvertTo-PowerShellStringLiteral $PidPath)). - Replace("__RUN_INLINE__", $(if ($WaitForExit) { '$true' } else { '$false' })). - Replace("__WAIT_FOR_EXIT_LINE__", $(if ($WaitForExit) { '$process.WaitForExit(); exit $process.ExitCode' } else { "" })) -} - -function New-HiddenPowerShellVbsLauncherContent { - param( - [Parameter(Mandatory = $true)] [string] $PsPath, - [Parameter(Mandatory = $false)] [switch] $WaitForExit - ) - - return (@' -Set fso = CreateObject("Scripting.FileSystemObject") -scriptDir = fso.GetParentFolderName(WScript.ScriptFullName) -psLauncher = "{0}" -logPath = psLauncher & ".vbs.log" -Set logFile = fso.OpenTextFile(logPath, 8, True) -logFile.WriteLine "vbs-start " & Now -logFile.Close -Set shell = CreateObject("WScript.Shell") -shell.CurrentDirectory = scriptDir -exitCode = shell.Run("powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File """ & psLauncher & """", 0, {1}) -Set logFile = fso.OpenTextFile(logPath, 8, True) -logFile.WriteLine "vbs-exit " & exitCode & " " & Now -logFile.Close -'@) -f $PsPath, ($(if ($WaitForExit) { "True" } else { "False" })) -} - -function New-WscriptCmdLauncherContent { - param( - [Parameter(Mandatory = $true)] [string] $WorkingDirectory, - [Parameter(Mandatory = $true)] [string] $VbsPath - ) - - return (@' -@echo off -cd /d "{0}" -wscript.exe //B //Nologo "{1}" -'@) -f $WorkingDirectory, $VbsPath -} - -function Invoke-RavenUploadedPowerShell { - param( - [Parameter(Mandatory = $true)] [string] $RavenHost, - [Parameter(Mandatory = $true)] [string] $RemoteScriptContent, - [Parameter(Mandatory = $true)] [object[]] $UploadSpecs, - [Parameter(Mandatory = $true)] [string] $TempPrefix - ) - - $uploadId = [guid]::NewGuid().ToString("N") - $localTempRoot = Join-Path $env:TEMP "$TempPrefix-$uploadId" - $localRemoteScript = Join-Path $localTempRoot "$TempPrefix-$uploadId.ps1" - $localSftpBatch = Join-Path $localTempRoot "$TempPrefix-$uploadId.sftp" - $remoteSftpPath = "C:/Windows/Temp/$TempPrefix-$uploadId.ps1" - $remotePsPath = "C:\Windows\Temp\$TempPrefix-$uploadId.ps1" - - try { - New-Item -ItemType Directory -Force -Path $localTempRoot | Out-Null - Set-AsciiFile -Path $localRemoteScript -Content $RemoteScriptContent - - $batchLines = @() - foreach ($spec in $UploadSpecs) { - $batchLines += 'put "{0}" "{1}"' -f $spec.LocalPath, $spec.RemotePath - } - $batchLines += 'put "{0}" "{1}"' -f $localRemoteScript, $remoteSftpPath - Set-AsciiFile -Path $localSftpBatch -Content ($batchLines -join "`r`n") - - $commonArgs = Get-SshCommonArgs - $sshTarget = Get-SshTarget -Target $RavenHost - & sftp.exe @commonArgs -b $localSftpBatch $sshTarget - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - - $remoteRunner = @" -`$ErrorActionPreference = "Stop" -`$ProgressPreference = "SilentlyContinue" -try { - & "$remotePsPath" - exit 0 -} finally { - Remove-Item -LiteralPath "$remotePsPath" -Force -ErrorAction SilentlyContinue -} -"@ - $encodedRunner = [Convert]::ToBase64String([Text.Encoding]::Unicode.GetBytes($remoteRunner)) - $commonArgs = Get-SshCommonArgs - $sshTarget = Get-SshTarget -Target $RavenHost - & ssh.exe @commonArgs $sshTarget "powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -OutputFormat Text -EncodedCommand $encodedRunner" - } finally { - Remove-Item -LiteralPath $localTempRoot -Recurse -Force -ErrorAction SilentlyContinue - } -} - -$muninnDir = Split-Path -Parent $MuninnExe -$serveCmdPath = Join-Path $muninnDir "start-muninn-serve.cmd" -$servePsPath = Join-Path $muninnDir "start-muninn-serve.ps1" -$serveVbsPath = Join-Path $muninnDir "start-muninn-serve-hidden.vbs" - -$serveArguments = @( - "serve", - "--store", $StorePath, - "--activate-store", $ActivateStorePath, - "--log-root", $LogRoot, - "--host", "raven", - "--stream", "muninn.raven.av.rudp", - "--target-host", $MediaTargetUri, - "--media-transport", "rudp" -) -if (-not [string]::IsNullOrWhiteSpace($OdinCultMeshUri)) { - $serveArguments += @("--odin-cultmesh-uri", $OdinCultMeshUri) -} -if (-not [string]::IsNullOrWhiteSpace($HidControllerRudpTarget)) { - $serveArguments += @("--hid-controller-rudp-target", $HidControllerRudpTarget) -} -if (-not [string]::IsNullOrWhiteSpace($HidControllerRudpBind)) { - $serveArguments += @("--hid-controller-rudp-bind", $HidControllerRudpBind) -} -if (-not [string]::IsNullOrWhiteSpace($HidControllerRudpAdvertise)) { - $serveArguments += @("--hid-controller-rudp-advertise", $HidControllerRudpAdvertise) -} -foreach ($videoSource in $VideoSources) { - $serveArguments += @("--video-source", $videoSource) -} -foreach ($audioSource in $AudioSources) { - $serveArguments += @("--audio-source", $audioSource) -} -foreach ($moveState in $MoveStates) { - if (-not [string]::IsNullOrWhiteSpace($moveState)) { - $serveArguments += @("--move-state", $moveState) - } -} -$serveArguments += @( - "--audio-device", $AudioDevice, - "--ffmpeg", $Ffmpeg, - "--loopback-script", $LoopbackScript, - "--interval-seconds", "15", - "--idunn-rudp-health", $IdunnRudpHealth, - "--idunn-daemon", $IdunnDaemon, - "--idunn-health-contract", $IdunnHealthContract -) -$serveDynamicArgumentsScript = @' -function Test-LikelyVirtualDisplayToken { - param([Parameter(Mandatory = $true)] [string] $Token) - - $trimmed = $Token.Trim() - if ([string]::IsNullOrWhiteSpace($trimmed)) { - return $true - } - return ( - $trimmed.StartsWith('MSBDD_', [System.StringComparison]::OrdinalIgnoreCase) -or - $trimmed.StartsWith('MSNIL', [System.StringComparison]::OrdinalIgnoreCase) -or - $trimmed.StartsWith('NOEDID', [System.StringComparison]::OrdinalIgnoreCase) -or - $trimmed.StartsWith('UGD', [System.StringComparison]::OrdinalIgnoreCase) - ) -} - -function Get-GraphicsDriverDisplayTokens { - $connectivityRoot = 'HKLM:\SYSTEM\CurrentControlSet\Control\GraphicsDrivers\Connectivity' - if (-not (Test-Path -LiteralPath $connectivityRoot)) { - return @() - } - - $bestTokens = @() - $bestScore = [int]::MinValue - foreach ($key in Get-ChildItem -LiteralPath $connectivityRoot -ErrorAction SilentlyContinue) { - $item = Get-ItemProperty -LiteralPath $key.PSPath -ErrorAction SilentlyContinue - if ($null -eq $item) { - continue - } - foreach ($property in $item.PSObject.Properties) { - if ($property.Name -like 'PS*' -or $property.Value -isnot [string]) { - continue - } - $tokens = @( - $property.Value.Split('+') | - ForEach-Object { $_.Trim() } | - Where-Object { -not [string]::IsNullOrWhiteSpace($_) } - ) - if ($tokens.Count -lt 1) { - continue - } - $realCount = @($tokens | Where-Object { -not (Test-LikelyVirtualDisplayToken $_) }).Count - $score = ($realCount * 100) + $tokens.Count - if ($score -gt $bestScore) { - $bestScore = $score - $bestTokens = $tokens - } - } - } - - return @($bestTokens | Select-Object -Unique) -} - -function Get-DefaultMuninnVideoSourceLabels { - param([Parameter(Mandatory = $true)] [string] $HostLabel) - - $displayNames = @( - Get-CimInstance Win32_DesktopMonitor -ErrorAction SilentlyContinue | - Where-Object { [string]::IsNullOrWhiteSpace($_.Status) -or $_.Status -eq 'OK' } | - ForEach-Object { - if ([string]::IsNullOrWhiteSpace($_.Name)) { - 'Display' - } else { - $_.Name.Trim() - } - } - ) - - $useRegistryFallback = ( - $displayNames.Count -lt 1 -or - ($displayNames.Count -eq 1 -and ( - $displayNames[0] -match '^\s*WinDisc\s*$' -or - $displayNames[0] -match '^\s*Generic PnP Monitor\s*$' -or - $displayNames[0] -match '^\s*Display\s*$' - )) - ) - - if ($useRegistryFallback) { - $displayTokens = @(Get-GraphicsDriverDisplayTokens) - if ($displayTokens.Count -gt 0) { - return @(for ($index = 0; $index -lt $displayTokens.Count; $index++) { - '{0} display {1}' -f $HostLabel, ($index + 1) - }) - } - } - - if ($displayNames.Count -lt 1) { - $displayNames = @('Display') - } - - return @(for ($index = 0; $index -lt $displayNames.Count; $index++) { - $monitorName = $displayNames[$index] - if ($monitorName -eq 'Display') { - '{0} display {1}' -f $HostLabel, ($index + 1) - } else { - '{0} display {1} ({2})' -f $HostLabel, ($index + 1), $monitorName - } - }) -} - -$hostArgumentIndex = [Array]::IndexOf($arguments, '--host') -$hostLabel = if ($hostArgumentIndex -ge 0 -and ($hostArgumentIndex + 1) -lt $arguments.Count) { - $arguments[$hostArgumentIndex + 1] -} else { - 'host' -} -$hasExplicitVideoSources = @($arguments | Where-Object { $_ -eq '--video-source' }).Count -gt 0 -$hasExplicitAudioSources = @($arguments | Where-Object { $_ -eq '--audio-source' }).Count -gt 0 -if (-not ('Muninn.AudioEndpointDiscovery' -as [type])) { - Add-Type -TypeDefinition @" -using System; -using System.Collections.Generic; -using System.Runtime.InteropServices; - -namespace Muninn { - public static class AudioEndpointDiscovery { - const int eRender = 0; - const int eCapture = 1; - const uint DEVICE_STATE_ACTIVE = 0x00000001; - static readonly PROPERTYKEY DeviceFriendlyNameKey = new PROPERTYKEY( - new Guid("A45C254E-DF1C-4EFD-8020-67D146A850E0"), - 14); - - [StructLayout(LayoutKind.Sequential)] - struct PROPERTYKEY { - public Guid fmtid; - public uint pid; - - public PROPERTYKEY(Guid formatId, uint propertyId) { - fmtid = formatId; - pid = propertyId; - } - } - - [StructLayout(LayoutKind.Sequential)] - struct PROPVARIANT { - public ushort vt; - public ushort wReserved1; - public ushort wReserved2; - public ushort wReserved3; - public IntPtr pointerValue; - public int intValue; - } - - [ComImport] - [Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] - class MMDeviceEnumerator { } - - [ComImport] - [Guid("A95664D2-9614-4F35-A746-DE8DB63617E6")] - [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - interface IMMDeviceEnumerator { - [PreserveSig] - int EnumAudioEndpoints(int dataFlow, uint stateMask, out IMMDeviceCollection devices); - } - - [ComImport] - [Guid("0BD7A1BE-7A1A-44DB-8397-CC5392387B5E")] - [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - interface IMMDeviceCollection { - [PreserveSig] - int GetCount(out uint count); - [PreserveSig] - int Item(uint index, out IMMDevice device); - } - - [ComImport] - [Guid("D666063F-1587-4E43-81F1-B948E807363F")] - [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - interface IMMDevice { - [PreserveSig] - int Activate(ref Guid iid, int dwClsCtx, IntPtr pActivationParams, out object audioClient); - [PreserveSig] - int OpenPropertyStore(uint access, out IPropertyStore properties); - } - - [ComImport] - [Guid("886D8EEB-8CF2-4446-8D02-CDBA1DBDCF99")] - [InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - interface IPropertyStore { - [PreserveSig] - int GetCount(out uint propertyCount); - [PreserveSig] - int GetAt(uint propertyIndex, out PROPERTYKEY key); - [PreserveSig] - int GetValue(ref PROPERTYKEY key, out PROPVARIANT value); - [PreserveSig] - int SetValue(ref PROPERTYKEY key, ref PROPVARIANT value); - [PreserveSig] - int Commit(); - } - - [DllImport("ole32.dll")] - static extern int PropVariantClear(ref PROPVARIANT variant); - - public static string[] FriendlyNames(int dataFlow) { - var names = new List(); - IMMDeviceEnumerator enumerator = (IMMDeviceEnumerator)(new MMDeviceEnumerator()); - IMMDeviceCollection devices; - Marshal.ThrowExceptionForHR(enumerator.EnumAudioEndpoints(dataFlow, DEVICE_STATE_ACTIVE, out devices)); - try { - uint count; - Marshal.ThrowExceptionForHR(devices.GetCount(out count)); - for (uint index = 0; index < count; index++) { - IMMDevice device; - Marshal.ThrowExceptionForHR(devices.Item(index, out device)); - try { - var name = DeviceFriendlyName(device); - if (!String.IsNullOrWhiteSpace(name)) { - names.Add(name.Trim()); - } - } finally { - Marshal.ReleaseComObject(device); - } - } - } finally { - Marshal.ReleaseComObject(devices); - Marshal.ReleaseComObject(enumerator); - } - return names.ToArray(); - } - - static string DeviceFriendlyName(IMMDevice device) { - IPropertyStore store; - Marshal.ThrowExceptionForHR(device.OpenPropertyStore(0, out store)); - try { - PROPERTYKEY key = DeviceFriendlyNameKey; - PROPVARIANT value; - Marshal.ThrowExceptionForHR(store.GetValue(ref key, out value)); - try { - if (value.vt == 31 && value.pointerValue != IntPtr.Zero) { - return Marshal.PtrToStringUni(value.pointerValue) ?? ""; - } - return ""; - } finally { - PropVariantClear(ref value); - } - } finally { - Marshal.ReleaseComObject(store); - } - } - } -} -"@ -} -if (-not $hasExplicitVideoSources) { - $videoSourceLabels = @(Get-DefaultMuninnVideoSourceLabels -HostLabel $hostLabel) - for ($index = 0; $index -lt $videoSourceLabels.Count; $index++) { - $arguments += @('--video-source', ('display:{0}={1}' -f $index, $videoSourceLabels[$index])) - } -} -if (-not $hasExplicitAudioSources) { - $loopbackNames = @([Muninn.AudioEndpointDiscovery]::FriendlyNames(0) | Sort-Object -Unique) - $inputNames = @([Muninn.AudioEndpointDiscovery]::FriendlyNames(1) | Sort-Object -Unique) - if ($loopbackNames.Count -lt 1 -and $inputNames.Count -lt 1) { - $audioDeviceIndex = [Array]::IndexOf($arguments, '--audio-device') - if ($audioDeviceIndex -ge 0 -and ($audioDeviceIndex + 1) -lt $arguments.Count) { - $loopbackNames = @($arguments[$audioDeviceIndex + 1]) - } - } - foreach ($audioName in $loopbackNames) { - $arguments += @('--audio-source', ('wasapi-loopback:{0}={1} loopback ({0})' -f $audioName.Trim(), $hostLabel)) - } - foreach ($audioName in $inputNames) { - $arguments += @('--audio-source', ('wasapi-input:{0}={1} input ({0})' -f $audioName.Trim(), $hostLabel)) - } -} -'@ - -$servePsContent = New-HiddenNativeLauncherPowerShellContent ` - -StorePath $StorePath ` - -LogRoot $LogRoot ` - -Arguments $serveArguments ` - -FilePath $MuninnExe ` - -StdoutPath (Join-Path $LogRoot "muninn-serve.out.log") ` - -StderrPath (Join-Path $LogRoot "muninn-serve.err.log") ` - -PidPath (Join-Path $LogRoot "muninn-serve.pid") ` - -Environment @{ CULTMESH_URI_ODIN_RUDP = $OdinCultMeshRudpEndpoint } ` - -DynamicArgumentsScript $serveDynamicArgumentsScript - -$uploadRoot = Join-Path $env:TEMP ("odin-raven-muninn-launchers-" + [guid]::NewGuid().ToString("N")) - -try { - New-Item -ItemType Directory -Force -Path $uploadRoot | Out-Null - - $localServePs = Join-Path $uploadRoot "start-muninn-serve.ps1" - $localServeVbs = Join-Path $uploadRoot "start-muninn-serve-hidden.vbs" - $localServeCmd = Join-Path $uploadRoot "start-muninn-serve.cmd" - - Set-AsciiFile -Path $localServePs -Content $servePsContent - Set-AsciiFile -Path $localServeVbs -Content (New-HiddenPowerShellVbsLauncherContent -PsPath $servePsPath) - Set-AsciiFile -Path $localServeCmd -Content (New-WscriptCmdLauncherContent -WorkingDirectory $muninnDir -VbsPath $serveVbsPath) - - $remoteScript = @" -`$ErrorActionPreference = "Stop" -`$ProgressPreference = "SilentlyContinue" - -if (-not (Test-Path -LiteralPath "$MuninnExe")) { - throw "Muninn executable not found at $MuninnExe" -} -if (-not (Test-Path -LiteralPath "$LoopbackScript")) { - throw "Muninn loopback script not found at $LoopbackScript" -} -if (-not (Test-Path -LiteralPath "$Ffmpeg")) { - throw "FFmpeg executable not found at $Ffmpeg" -} -foreach (`$path in @( - "$servePsPath", - "$serveVbsPath" -)) { - if (-not (Test-Path -LiteralPath `$path)) { - throw "Required launcher path not found at `$path" - } -} - -`$obsoleteActivateTask = Get-ScheduledTask -TaskName "GameCult-Muninn-Activate" -ErrorAction SilentlyContinue -if (`$null -ne `$obsoleteActivateTask) { - Unregister-ScheduledTask -TaskName "GameCult-Muninn-Activate" -Confirm:`$false | Out-Null -} -`$obsoleteVideoProofTask = Get-ScheduledTask -TaskName "GameCult-Muninn-VideoProof" -ErrorAction SilentlyContinue -if (`$null -ne `$obsoleteVideoProofTask) { - Unregister-ScheduledTask -TaskName "GameCult-Muninn-VideoProof" -Confirm:`$false | Out-Null -} - -Get-CimInstance Win32_Process | - Where-Object { `$_.Name -like "muninn*.exe" } | - ForEach-Object { - & cmd.exe /c ("taskkill /PID {0} /T /F >nul 2>&1" -f `$_.ProcessId) | Out-Null - } - -New-Item -ItemType Directory -Force -Path "$LogRoot" | Out-Null -New-Item -ItemType Directory -Force -Path (Split-Path -Parent "$StorePath") | Out-Null -& netsh.exe advfirewall firewall delete rule name="GameCult Muninn Capture Command RUDP" | Out-Null 2>`$null -if (-not [string]::IsNullOrWhiteSpace("$HidControllerRudpBind")) { - `$hidControllerUdpPort = ([string] "$HidControllerRudpBind").Split(':')[-1] - & netsh.exe advfirewall firewall delete rule name="GameCult Muninn HID Controller RUDP" | Out-Null 2>`$null - & netsh.exe advfirewall firewall add rule name="GameCult Muninn HID Controller RUDP" dir=in action=allow protocol=UDP localport=`$hidControllerUdpPort | Out-Null -} - -function Register-HiddenVbsTask { - param( - [Parameter(Mandatory = `$true)] [string] `$TaskName, - [Parameter(Mandatory = `$true)] [string] `$VbsPath - ) - - `$taskAction = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\wscript.exe" -Argument "//B //Nologo ""`$VbsPath""" -WorkingDirectory (Split-Path -Parent `$VbsPath) - `$taskTrigger = New-ScheduledTaskTrigger -Once -At ([DateTime]::Today.AddHours(23).AddMinutes(59)) - `$taskPrincipal = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Highest - `$taskSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew - Register-ScheduledTask -TaskName `$TaskName -Action `$taskAction -Trigger `$taskTrigger -Principal `$taskPrincipal -Settings `$taskSettings -Force | Out-Null -} - -function Register-HiddenPowerShellTask { - param( - [Parameter(Mandatory = `$true)] [string] `$TaskName, - [Parameter(Mandatory = `$true)] [string] `$PsPath - ) - - `$taskArguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -WindowStyle Hidden -File "' + `$PsPath + '"' - `$taskAction = New-ScheduledTaskAction -Execute "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" -Argument `$taskArguments -WorkingDirectory (Split-Path -Parent `$PsPath) - `$taskTrigger = New-ScheduledTaskTrigger -Once -At ([DateTime]::Today.AddHours(23).AddMinutes(59)) - `$taskPrincipal = New-ScheduledTaskPrincipal -UserId ([System.Security.Principal.WindowsIdentity]::GetCurrent().Name) -LogonType Interactive -RunLevel Highest - `$taskSettings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew - Register-ScheduledTask -TaskName `$TaskName -Action `$taskAction -Trigger `$taskTrigger -Principal `$taskPrincipal -Settings `$taskSettings -Force | Out-Null -} - -function Assert-HiddenVbsTask { - param( - [Parameter(Mandatory = `$true)] [string] `$TaskName, - [Parameter(Mandatory = `$true)] [string] `$VbsPath, - [Parameter(Mandatory = `$true)] [string] `$PsPath - ) - - `$task = Get-ScheduledTask -TaskName `$TaskName -ErrorAction Stop - `$action = @(`$task.Actions)[0] - if (`$action.Execute -notmatch '(^|\\)wscript\.exe$') { - throw "`$TaskName action executes `$(`$action.Execute), expected wscript.exe" - } - if (`$action.Arguments -notlike "*`$VbsPath*") { - throw "`$TaskName action arguments `$(`$action.Arguments) do not reference `$VbsPath" - } - if (`$action.Arguments -notlike "*//B*" -or `$action.Arguments -notlike "*//Nologo*") { - throw "`$TaskName action arguments `$(`$action.Arguments) do not force background WScript execution" - } - if (-not (Test-Path -LiteralPath `$PsPath)) { - throw "`$TaskName PowerShell launcher not found at `$PsPath" - } - `$vbs = Get-Content -LiteralPath `$VbsPath -Raw - if (`$vbs -match 'cmdPath\s*=') { - throw "`$TaskName hidden launcher at `$VbsPath still routes through a cmdPath trampoline" - } - if (`$vbs -notmatch '\.ps1') { - throw "`$TaskName hidden launcher at `$VbsPath does not reference a PowerShell launcher" - } -} - -function Assert-HiddenPowerShellTask { - param( - [Parameter(Mandatory = `$true)] [string] `$TaskName, - [Parameter(Mandatory = `$true)] [string] `$PsPath - ) - - `$task = Get-ScheduledTask -TaskName `$TaskName -ErrorAction Stop - `$action = @(`$task.Actions)[0] - if (`$action.Execute -notmatch 'powershell\.exe$') { - throw "`$TaskName action executes `$(`$action.Execute), expected powershell.exe" - } - if (`$action.Arguments -notlike "*`$PsPath*") { - throw "`$TaskName action arguments `$(`$action.Arguments) do not reference `$PsPath" - } - if (`$action.Arguments -notlike "*-File*") { - throw "`$TaskName action arguments `$(`$action.Arguments) do not execute a PowerShell launcher" - } - if (-not (Test-Path -LiteralPath `$PsPath)) { - throw "`$TaskName PowerShell launcher not found at `$PsPath" - } -} - -Register-HiddenPowerShellTask -TaskName "GameCult-Muninn" -PsPath "$servePsPath" - -Assert-HiddenPowerShellTask -TaskName "GameCult-Muninn" -PsPath "$servePsPath" - -Start-ScheduledTask -TaskName "GameCult-Muninn" - -`$deadline = [DateTime]::UtcNow.AddSeconds($ServeStartTimeoutSeconds) -`$process = `$null -do { - Start-Sleep -Milliseconds 500 - `$process = Get-CimInstance Win32_Process | - Where-Object { `$_.Name -ieq "muninn.exe" -and `$_.CommandLine -like "*serve*" -and `$_.CommandLine -like "*--host*raven*" } | - Select-Object -First 1 -} while (`$null -eq `$process -and [DateTime]::UtcNow -lt `$deadline) -if (`$null -eq `$process) { - throw "Muninn serve process did not start on Raven within $ServeStartTimeoutSeconds seconds" -} -foreach (`$pattern in @( - "--idunn-rudp-health", - "$IdunnRudpHealth", - "--idunn-daemon", - "$IdunnDaemon", - "--idunn-health-contract", - "$IdunnHealthContract" -)) { - if (`$process.CommandLine -notlike "*`$pattern*") { - throw "Muninn Raven serve command line is missing `${pattern}: `$(`$process.CommandLine)" - } -} -"@ - - $uploadSpecs = @( - @{ LocalPath = $LocalLoopbackScript; RemotePath = ($LoopbackScript -replace "\\", "/") }, - @{ LocalPath = $localServePs; RemotePath = ($servePsPath -replace "\\", "/") }, - @{ LocalPath = $localServeVbs; RemotePath = ($serveVbsPath -replace "\\", "/") }, - @{ LocalPath = $localServeCmd; RemotePath = ($serveCmdPath -replace "\\", "/") } - ) - - Invoke-RavenUploadedPowerShell -RavenHost $RavenHost -RemoteScriptContent $remoteScript -UploadSpecs $uploadSpecs -TempPrefix "odin-raven-muninn-restart" -} finally { - Remove-Item -LiteralPath $uploadRoot -Recurse -Force -ErrorAction SilentlyContinue -} - -exit $LASTEXITCODE diff --git a/scripts/restart-nightwing-muninn.cmd b/scripts/restart-nightwing-muninn.cmd deleted file mode 100644 index 59823d4..0000000 --- a/scripts/restart-nightwing-muninn.cmd +++ /dev/null @@ -1,10 +0,0 @@ -@echo off -if not "%IDUNN_ACTUATOR%"=="1" ( - echo restart-nightwing-muninn.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2 - exit /b 1 -) -if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" ( - echo restart-nightwing-muninn.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2 - exit /b 1 -) -powershell.exe -NoProfile -ExecutionPolicy Bypass -File %~dp0restart-nightwing-muninn.ps1 -DiscoverMoveState -ClaimUsbMoves diff --git a/scripts/restart-nightwing-muninn.ps1 b/scripts/restart-nightwing-muninn.ps1 deleted file mode 100644 index 9f92fa4..0000000 --- a/scripts/restart-nightwing-muninn.ps1 +++ /dev/null @@ -1,190 +0,0 @@ -param( - [string] $SshTarget = "nightwing", - [string] $MuninnExe = "/home/metacrat/.local/bin/muninn", - [string] $StorePath = "/home/metacrat/.local/state/gamecult/muninn/muninn.telemetry.cc", - [string] $LogRoot = "/home/metacrat/.local/state/gamecult/muninn", - [string[]] $MoveState = @(), - [switch] $DiscoverMoveState = $true, - [switch] $ClaimUsbMoves = $true, - [string] $MoveEvidenceStream = "muninn:nightwing:move-evidence", - [string[]] $MoveMarkerCamera = @( - "nightwing-eye-0=/dev/video2", - "nightwing-eye-1=/dev/video3" - ), - [int] $MoveTrackerExposureMilli = 100, - [hashtable] $MoveTrackerCameraExposure = @{ - "nightwing-eye-0" = 300 - "nightwing-eye-1" = 100 - }, - [int] $IntervalSeconds = 15, - [string] $IdunnRudpHealth = $env:IDUNN_RUDP_HEALTH, - [string] $IdunnDaemon = "nightwing-muninn", - [string] $IdunnHealthContract = "muninn.cultnet-rudp-remote-telemetry-and-move-hid", - [string] $OdinCultMeshUri = $(if ($env:ODIN_CULTMESH_URI) { $env:ODIN_CULTMESH_URI } else { "cultmesh://odin/rendezvous/provider-catalog" }), - [string] $OdinCultMeshRudpEndpoint = $(if ($env:CULTMESH_URI_ODIN_RUDP) { $env:CULTMESH_URI_ODIN_RUDP } else { "10.77.0.2:17871" }), - [string] $HidControllerRudpBind = "0.0.0.0:17888", - [string] $HidControllerRudpAdvertise = $(if ($env:MUNINN_HID_CONTROLLER_RUDP_ADVERTISE) { $env:MUNINN_HID_CONTROLLER_RUDP_ADVERTISE } else { "10.77.0.3:17888" }), - [string] $CommandRudpBind = "0.0.0.0:17889", - [string] $CommandRudpAdvertise = "10.77.0.3:17889" -) - -$ErrorActionPreference = "Stop" - -if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") { - throw "restart-nightwing-muninn.ps1 is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path." -} - -if ([string]::IsNullOrWhiteSpace($IdunnRudpHealth)) { - throw "Idunn RUDP health endpoint must be supplied by -IdunnRudpHealth or IDUNN_RUDP_HEALTH; no Starfire LAN default is allowed." -} -if ($IdunnRudpHealth -match '^(127\.0\.0\.1|localhost):(\d+)$') { - $IdunnRudpHealth = "10.77.0.2:$($Matches[2])" -} -if (-not [string]::IsNullOrWhiteSpace($HidControllerRudpBind) -and [string]::IsNullOrWhiteSpace($HidControllerRudpAdvertise)) { - throw "HID controller RUDP advertise endpoint must be supplied by -HidControllerRudpAdvertise or MUNINN_HID_CONTROLLER_RUDP_ADVERTISE when HID RUDP bind is enabled; no Nightwing LAN default is allowed." -} - -function Quote-ShSingle([string] $Value) { - return "'" + ($Value -replace "'", "'\''") + "'" -} - -function Set-AsciiFile { - param( - [Parameter(Mandatory = $true)] [string] $Path, - [Parameter(Mandatory = $true)] [string] $Content - ) - - [System.IO.File]::WriteAllText($Path, ($Content -replace "`r?`n", "`n"), [System.Text.Encoding]::ASCII) -} - -function Invoke-UploadedShell { - param( - [Parameter(Mandatory = $true)] [string] $SshTarget, - [Parameter(Mandatory = $true)] [string] $RemoteScriptContent - ) - - $uploadId = [guid]::NewGuid().ToString("N") - $localTempRoot = Join-Path $env:TEMP "odin-nightwing-muninn-restart-$uploadId" - $localRemoteScript = Join-Path $localTempRoot "restart-nightwing-muninn-$uploadId.sh" - $localSftpBatch = Join-Path $localTempRoot "restart-nightwing-muninn-$uploadId.sftp" - $remoteScriptPath = "/tmp/restart-nightwing-muninn-$uploadId.sh" - - try { - New-Item -ItemType Directory -Force -Path $localTempRoot | Out-Null - Set-AsciiFile -Path $localRemoteScript -Content $RemoteScriptContent - Set-AsciiFile -Path $localSftpBatch -Content ('put "{0}" "{1}"' -f $localRemoteScript, $remoteScriptPath) - - & sftp.exe -b $localSftpBatch $SshTarget - if ($LASTEXITCODE -ne 0) { - exit $LASTEXITCODE - } - - & ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $SshTarget "chmod +x '$remoteScriptPath' && bash '$remoteScriptPath'" - $remoteExit = $LASTEXITCODE - & ssh.exe -o BatchMode=yes -o ConnectTimeout=10 $SshTarget "rm -f '$remoteScriptPath'" - exit $remoteExit - } finally { - Remove-Item -LiteralPath $localTempRoot -Recurse -Force -ErrorAction SilentlyContinue - } -} - -$moveStateSpecs = @($MoveState | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) -if ($ClaimUsbMoves.IsPresent -and -not $DiscoverMoveState.IsPresent -and $moveStateSpecs.Count -eq 0) { - throw "-ClaimUsbMoves requires -DiscoverMoveState or at least one explicit -MoveState value." -} -if ($DiscoverMoveState.IsPresent) { - $claimScript = Join-Path $PSScriptRoot "claim-nightwing-usb-moves.ps1" - if ($ClaimUsbMoves.IsPresent) { - & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $claimScript -SshTarget $SshTarget - } - $sourceScript = Join-Path $PSScriptRoot "get-nightwing-move-state-sources.ps1" - $moveStateSpecs = @(& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $sourceScript -SshTarget $SshTarget) - if ($moveStateSpecs.Count -eq 0) { - throw "Nightwing Move runtime was explicitly requested, but no live Move state sources were discovered." - } -} - -$moveStateSetLines = ($moveStateSpecs | ForEach-Object { - "set -- ""`$@"" --move-state $(Quote-ShSingle $_)" -}) -join "`n" -$moveRuntimeSetLines = @() -if ($moveStateSpecs.Count -gt 0) { - $moveRuntimeSetLines += $moveStateSetLines - $moveRuntimeSetLines += "set -- ""`$@"" --move-evidence-stream $(Quote-ShSingle $MoveEvidenceStream)" - if ($MoveMarkerCamera.Count -gt 0) { - foreach ($camera in $MoveMarkerCamera) { - $moveRuntimeSetLines += "set -- ""`$@"" --move-marker-camera $(Quote-ShSingle $camera)" - } - $moveRuntimeSetLines += "set -- ""`$@"" --move-psmoveapi-tracker" - $moveRuntimeSetLines += "set -- ""`$@"" --move-tracker-exposure-milli $(Quote-ShSingle ([string]$MoveTrackerExposureMilli))" - foreach ($cameraId in @($MoveTrackerCameraExposure.Keys | Sort-Object)) { - $moveRuntimeSetLines += "set -- ""`$@"" --move-tracker-camera-exposure $(Quote-ShSingle ("{0}={1}" -f $cameraId, $MoveTrackerCameraExposure[$cameraId]))" - } - } -} -$moveRuntimeSetBlock = ($moveRuntimeSetLines -join "`n") -$odinCultMeshUriSetBlock = "" -if (-not [string]::IsNullOrWhiteSpace($OdinCultMeshUri)) { - $odinCultMeshUriSetBlock = "set -- ""`$@"" --odin-cultmesh-uri $(Quote-ShSingle $OdinCultMeshUri)" -} -$hidControllerRudpSetLines = @() -if (-not [string]::IsNullOrWhiteSpace($HidControllerRudpBind)) { - $hidControllerRudpSetLines += "set -- ""`$@"" --hid-controller-rudp-bind $(Quote-ShSingle $HidControllerRudpBind)" -} -if (-not [string]::IsNullOrWhiteSpace($HidControllerRudpAdvertise)) { - $hidControllerRudpSetLines += "set -- ""`$@"" --hid-controller-rudp-advertise $(Quote-ShSingle $HidControllerRudpAdvertise)" -} -$hidControllerRudpSetBlock = ($hidControllerRudpSetLines -join "`n") -$commandRudpSetBlock = "set -- ""`$@"" --command-rudp-bind $(Quote-ShSingle $CommandRudpBind) --command-rudp-advertise $(Quote-ShSingle $CommandRudpAdvertise)" -$remoteScript = @" -set -eu -mkdir -p '$LogRoot' -if [ ! -x '$MuninnExe' ]; then - echo 'Muninn executable not found at $MuninnExe' >&2 - exit 1 -fi -if [ -f '$LogRoot/muninn.pid' ] && kill -0 "`$(cat '$LogRoot/muninn.pid')" 2>/dev/null; then - kill "`$(cat '$LogRoot/muninn.pid')" 2>/dev/null || true - sleep 1 -fi -for pid in `$(pgrep -f '[m]uninn move-tracker-worker .*--host nightwing' 2>/dev/null || true); do - kill "`$pid" 2>/dev/null || true -done -for pid in `$(pgrep -f '[m]uninn serve .*--host nightwing' 2>/dev/null || true); do - kill "`$pid" 2>/dev/null || true -done -for pid in `$(pgrep -f '[m]uninn serve .*--host nightwing-eye0' 2>/dev/null || true); do - kill "`$pid" 2>/dev/null || true -done -set -- serve \ - --store '$StorePath' \ - --log-root '$LogRoot' \ - --host nightwing -$moveRuntimeSetBlock -set -- "`$@" \ - --interval-seconds '$IntervalSeconds' \ - --idunn-rudp-health '$IdunnRudpHealth' \ - --idunn-daemon '$IdunnDaemon' \ - --idunn-health-contract '$IdunnHealthContract' -$odinCultMeshUriSetBlock -$hidControllerRudpSetBlock -$commandRudpSetBlock -CULTNET_RUDP_TRACE=1 CULTMESH_URI_ODIN_RUDP='$OdinCultMeshRudpEndpoint' nohup '$MuninnExe' "`$@" \ - > '$LogRoot/muninn-serve.out.log' \ - 2> '$LogRoot/muninn-serve.err.log' \ - < /dev/null & -echo `$! > '$LogRoot/muninn.pid' -sleep 1 -muninn_pid="`$(cat '$LogRoot/muninn.pid')" -kill -0 "`$muninn_pid" 2>/dev/null -muninn_cmdline="`$(tr '\0' ' ' < "/proc/`$muninn_pid/cmdline")" -if ! printf '%s\n' "`$muninn_cmdline" | grep -F -- '--host nightwing' >/dev/null 2>&1 || - ! printf '%s\n' "`$muninn_cmdline" | grep -F -- '--idunn-rudp-health $IdunnRudpHealth' >/dev/null 2>&1 || - ! printf '%s\n' "`$muninn_cmdline" | grep -F -- '--move-marker-camera nightwing-eye-0=/dev/video2' >/dev/null 2>&1 || - ! printf '%s\n' "`$muninn_cmdline" | grep -F -- '--move-marker-camera nightwing-eye-1=/dev/video3' >/dev/null 2>&1; then - echo 'Nightwing Muninn serve command line is missing Idunn health or dual-camera arguments' >&2 - exit 1 -fi -"@ - -Invoke-UploadedShell -SshTarget $SshTarget -RemoteScriptContent $remoteScript diff --git a/scripts/restart-raven-capture-mux.cmd b/scripts/restart-raven-capture-mux.cmd deleted file mode 100644 index 50520b1..0000000 --- a/scripts/restart-raven-capture-mux.cmd +++ /dev/null @@ -1,2 +0,0 @@ -@echo off -powershell.exe -NoProfile -ExecutionPolicy Bypass -File %~dp0restart-muninn.ps1 diff --git a/scripts/restart-raven-capture-mux.ps1 b/scripts/restart-raven-capture-mux.ps1 deleted file mode 100644 index f1c6571..0000000 --- a/scripts/restart-raven-capture-mux.ps1 +++ /dev/null @@ -1,7 +0,0 @@ -param( - [string] $RavenHost = "raven" -) - -$script = Join-Path $PSScriptRoot "restart-muninn.ps1" -& powershell.exe -NoProfile -ExecutionPolicy Bypass -File $script -RavenHost $RavenHost -exit $LASTEXITCODE diff --git a/scripts/restart-starfire-muninn.cmd b/scripts/restart-starfire-muninn.cmd deleted file mode 100644 index fa78a28..0000000 --- a/scripts/restart-starfire-muninn.cmd +++ /dev/null @@ -1,10 +0,0 @@ -@echo off -if not "%IDUNN_ACTUATOR%"=="1" ( - echo restart-starfire-muninn.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2 - exit /b 1 -) -if /I not "%IDUNN_COMMAND_AUTHORITY%"=="idunn-daemon" ( - echo restart-starfire-muninn.cmd is an Idunn actuator wrapper. Redeploy by poking Idunn; direct service restart is not an owned path. 1>&2 - exit /b 1 -) -powershell.exe -NoProfile -ExecutionPolicy Bypass -File %~dp0restart-starfire-muninn.ps1 diff --git a/scripts/restart-starfire-muninn.ps1 b/scripts/restart-starfire-muninn.ps1 deleted file mode 100644 index 70b79f6..0000000 --- a/scripts/restart-starfire-muninn.ps1 +++ /dev/null @@ -1,174 +0,0 @@ -param( - [string] $MuninnExe = "C:\Meta\Odin\Muninn\muninn-d4d0616.exe", - [string] $StorePath = "C:\Meta\Odin\state\starfire.muninn.telemetry.cc", - [string] $LogRoot = "C:\Meta\Odin\logs\starfire-muninn", - [string] $QuestSerial = "1WMHHB68PG1515", - [string] $MoveBluetoothHost = "", - [string[]] $MoveState = @(), - [switch] $EnableUsbMoveState, - [string] $IdunnRudpHealth = $env:IDUNN_RUDP_HEALTH, - [string] $IdunnDaemon = "starfire-muninn", - [string] $IdunnHealthContract = "muninn.cultnet-rudp-local-telemetry-and-quest-access", - [string] $OdinCultMeshUri = $(if ($env:ODIN_CULTMESH_URI) { $env:ODIN_CULTMESH_URI } else { "cultmesh://odin/rendezvous/provider-catalog" }), - [string] $OdinCultMeshRudpEndpoint = $(if ($env:CULTMESH_URI_ODIN_RUDP) { $env:CULTMESH_URI_ODIN_RUDP } else { "127.0.0.1:17871" }), - [string] $HidControllerRudpBind = "", - [string] $HidControllerRudpAdvertise = "" -) - -$ErrorActionPreference = "Stop" - -$restartMutex = [System.Threading.Mutex]::new($false, "Global\GameCult-Starfire-Muninn-Restart") -if (-not $restartMutex.WaitOne([TimeSpan]::FromMinutes(2))) { - throw "Timed out waiting for the Starfire Muninn restart actuator mutex." -} - -if ($env:IDUNN_ACTUATOR -ne "1" -or $env:IDUNN_COMMAND_AUTHORITY -ne "idunn-daemon") { - throw "restart-starfire-muninn.ps1 is an Idunn actuator body. Redeploy by poking Idunn; direct service restart is not an owned path." -} - -if (-not (Test-Path -LiteralPath $MuninnExe)) { - throw "Muninn executable not found at $MuninnExe" -} -if ([string]::IsNullOrWhiteSpace($IdunnRudpHealth)) { - throw "Idunn RUDP health endpoint must be supplied by -IdunnRudpHealth or IDUNN_RUDP_HEALTH; no Starfire LAN default is allowed." -} -if (-not [string]::IsNullOrWhiteSpace($HidControllerRudpBind) -and [string]::IsNullOrWhiteSpace($HidControllerRudpAdvertise)) { - throw "HID controller RUDP advertise endpoint must be supplied by -HidControllerRudpAdvertise or MUNINN_HID_CONTROLLER_RUDP_ADVERTISE when HID RUDP bind is enabled; no Starfire LAN default is allowed." -} - -Get-CimInstance Win32_Process | - Where-Object { - $_.Name -like "muninn*.exe" -and - $_.CommandLine -like "*serve*" -and - $_.CommandLine -like "*--host*starfire*" - } | - ForEach-Object { - & taskkill.exe /PID $_.ProcessId /T /F | Out-Null - } - -New-Item -ItemType Directory -Force -Path $LogRoot | Out-Null -$storeParent = Split-Path -Parent $StorePath -if (-not [string]::IsNullOrWhiteSpace($storeParent)) { - New-Item -ItemType Directory -Force -Path $storeParent | Out-Null -} - -if (-not [string]::IsNullOrWhiteSpace($MoveBluetoothHost)) { - & $MuninnExe claim-move-host --move-host $MoveBluetoothHost -} - -$arguments = @( - "serve", - "--store", $StorePath, - "--log-root", $LogRoot, - "--host", "starfire", - "--interval-seconds", "15", - "--quest-adb", - "--quest-serial", $QuestSerial, - "--idunn-rudp-health", $IdunnRudpHealth, - "--idunn-daemon", $IdunnDaemon, - "--idunn-health-contract", $IdunnHealthContract -) -if (-not [string]::IsNullOrWhiteSpace($OdinCultMeshUri)) { - $arguments += @("--odin-cultmesh-uri", $OdinCultMeshUri) -} -if (-not [string]::IsNullOrWhiteSpace($HidControllerRudpBind)) { - $arguments += @("--hid-controller-rudp-bind", $HidControllerRudpBind) -} -if (-not [string]::IsNullOrWhiteSpace($HidControllerRudpAdvertise)) { - $arguments += @("--hid-controller-rudp-advertise", $HidControllerRudpAdvertise) -} -if (-not [string]::IsNullOrWhiteSpace($MoveBluetoothHost)) { - $arguments += @("--move-host", $MoveBluetoothHost) -} -foreach ($source in $MoveState) { - if (-not [string]::IsNullOrWhiteSpace($source)) { - $arguments += @("--move-state", $source) - } -} -if ($EnableUsbMoveState) { - $arguments += @("--move-state", "move-starfire-usb=windows-psmove") -} - -$serveOutLog = Join-Path $LogRoot "muninn-serve.out.log" -$serveErrLog = Join-Path $LogRoot "muninn-serve.err.log" -$pidPath = Join-Path $LogRoot "muninn-serve.pid" -$lockPath = "$StorePath.lock" - -function Start-MuninnServeProcess { - param([string[]] $ArgumentList) - - $previousOdinEndpoint = $env:CULTMESH_URI_ODIN_RUDP - try { - $env:CULTMESH_URI_ODIN_RUDP = $OdinCultMeshRudpEndpoint - $process = Start-Process ` - -FilePath $MuninnExe ` - -ArgumentList $ArgumentList ` - -WindowStyle Hidden ` - -PassThru ` - -RedirectStandardOutput $serveOutLog ` - -RedirectStandardError $serveErrLog - } finally { - $env:CULTMESH_URI_ODIN_RUDP = $previousOdinEndpoint - } - $process.Id | Set-Content -Encoding ASCII -LiteralPath $pidPath - return $process -} - -function Get-StarfireMuninnServeProcess { - return Get-CimInstance Win32_Process | - Where-Object { - $_.Name -like "muninn*.exe" -and - $_.CommandLine -like "*serve*" -and - $_.CommandLine -like "*--host*starfire*" - } | - Select-Object -First 1 -} - -function Reset-CorruptMuninnStore { - $timestamp = Get-Date -Format "yyyyMMdd-HHmmss" - if (Test-Path -LiteralPath $StorePath) { - Move-Item -LiteralPath $StorePath -Destination "$StorePath.corrupt-$timestamp" -Force - } - Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue - $storeLeaf = Split-Path -Leaf $StorePath - Get-ChildItem -LiteralPath $storeParent -ErrorAction SilentlyContinue | Where-Object { - $_.Name -like "$storeLeaf.*.tmp" - } | ForEach-Object { - Remove-Item -LiteralPath $_.FullName -Force -ErrorAction SilentlyContinue - } -} - -Remove-Item -LiteralPath $lockPath -Force -ErrorAction SilentlyContinue - -$null = Start-MuninnServeProcess -ArgumentList $arguments - -Start-Sleep -Seconds 2 -$processCheck = Get-StarfireMuninnServeProcess -if ($null -eq $processCheck) { - $serveError = if (Test-Path -LiteralPath $serveErrLog) { - Get-Content -LiteralPath $serveErrLog -Raw - } else { - "" - } - if ($serveError -like "*failed to decode MessagePack*") { - Reset-CorruptMuninnStore - $null = Start-MuninnServeProcess -ArgumentList $arguments - Start-Sleep -Seconds 2 - $processCheck = Get-StarfireMuninnServeProcess - } -} -if ($null -eq $processCheck) { - throw "Starfire Muninn serve process is not running after restart" -} -foreach ($pattern in @( - "--idunn-rudp-health", - $IdunnRudpHealth, - "--idunn-daemon", - $IdunnDaemon, - "--idunn-health-contract", - $IdunnHealthContract -)) { - if ($processCheck.CommandLine -notlike "*$pattern*") { - throw "Starfire Muninn serve command line is missing ${pattern}: $($processCheck.CommandLine)" - } -} diff --git a/scripts/verify-muninn-media-profile.ps1 b/scripts/verify-muninn-media-profile.ps1 deleted file mode 100644 index f29f87d..0000000 --- a/scripts/verify-muninn-media-profile.ps1 +++ /dev/null @@ -1,121 +0,0 @@ -param( - [string] $RestartScript = (Join-Path $PSScriptRoot "restart-muninn.ps1") -) - -$ErrorActionPreference = "Stop" - -function Fail-MuninnMediaProfile { - param([string] $Message) - throw "Muninn media profile verification failed: $Message" -} - -function Get-AssignmentAst { - param( - [System.Management.Automation.Language.Ast] $Ast, - [string] $VariableName - ) - - $matches = @($Ast.FindAll({ - param($Node) - if ($Node -isnot [System.Management.Automation.Language.AssignmentStatementAst]) { - return $false - } - $left = $Node.Left - return $left -is [System.Management.Automation.Language.VariableExpressionAst] -and - $left.VariablePath.UserPath -eq $VariableName - }, $true)) - - if ($matches.Count -ne 1) { - Fail-MuninnMediaProfile "expected exactly one assignment to `$$VariableName, found $($matches.Count)" - } - - return $matches[0] -} - -function Get-ConstantAssignmentValue { - param( - [System.Management.Automation.Language.Ast] $Ast, - [string] $VariableName - ) - - $assignment = Get-AssignmentAst -Ast $Ast -VariableName $VariableName - $right = $assignment.Right - if ($right -is [System.Management.Automation.Language.CommandExpressionAst]) { - $right = $right.Expression - } - if ($right -isnot [System.Management.Automation.Language.ConstantExpressionAst]) { - Fail-MuninnMediaProfile "`$$VariableName is not a constant assignment" - } - - return $right.Value -} - -function Assert-TokenPair { - param( - [string[]] $Tokens, - [string] $Name, - [string] $Value - ) - - for ($index = 0; $index -lt ($Tokens.Count - 1); $index++) { - if ($Tokens[$index] -eq $Name -and $Tokens[$index + 1] -eq $Value) { - return - } - } - - Fail-MuninnMediaProfile "video proof arguments are missing '$Name $Value'" -} - -if (-not (Test-Path -LiteralPath $RestartScript)) { - Fail-MuninnMediaProfile "restart script not found at $RestartScript" -} - -$tokens = $null -$errors = $null -$ast = [System.Management.Automation.Language.Parser]::ParseFile( - (Resolve-Path -LiteralPath $RestartScript), - [ref] $tokens, - [ref] $errors -) - -if ($errors.Count -gt 0) { - Fail-MuninnMediaProfile (($errors | ForEach-Object { $_.ToString() }) -join "; ") -} - -$framerate = Get-ConstantAssignmentValue -Ast $ast -VariableName "videoProofFramerate" -$bitrateKbps = Get-ConstantAssignmentValue -Ast $ast -VariableName "videoProofBitrateKbps" -if ($framerate -ne 30) { - Fail-MuninnMediaProfile "video proof framerate is $framerate, expected 30" -} -if ($bitrateKbps -ne 12000) { - Fail-MuninnMediaProfile "video proof bitrate is $bitrateKbps, expected 12000" -} - -$videoProofAssignment = Get-AssignmentAst -Ast $ast -VariableName "videoProofArguments" -$stringConstants = @($videoProofAssignment.Right.FindAll({ - param($Node) - $Node -is [System.Management.Automation.Language.StringConstantExpressionAst] - }, $true)) -$literalTokens = @($stringConstants | ForEach-Object { $_.Value }) - -Assert-TokenPair -Tokens $literalTokens -Name "-fflags" -Value "nobuffer" -Assert-TokenPair -Tokens $literalTokens -Name "-flags" -Value "low_delay" -Assert-TokenPair -Tokens $literalTokens -Name "-c:v" -Value "h264_nvenc" -Assert-TokenPair -Tokens $literalTokens -Name "-preset" -Value "p1" -Assert-TokenPair -Tokens $literalTokens -Name "-tune" -Value "ull" -Assert-TokenPair -Tokens $literalTokens -Name "-zerolatency" -Value "1" -Assert-TokenPair -Tokens $literalTokens -Name "-bf" -Value "0" -Assert-TokenPair -Tokens $literalTokens -Name "-delay" -Value "0" -Assert-TokenPair -Tokens $literalTokens -Name "-rc" -Value "cbr" -Assert-TokenPair -Tokens $literalTokens -Name "-rc-lookahead" -Value "0" -Assert-TokenPair -Tokens $literalTokens -Name "-forced-idr" -Value "1" - -$videoProofText = $videoProofAssignment.Extent.Text -if ($videoProofText -notmatch '\$videoProofVbvKbits') { - Fail-MuninnMediaProfile "video proof bufsize is not derived from the one-frame VBV budget" -} -if ($videoProofText -match '"p4"|''p4''|24000k') { - Fail-MuninnMediaProfile "video proof still contains legacy p4 or 24000k latency settings" -} - -Write-Host "Muninn media profile verification passed for $RestartScript"