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 <noreply@anthropic.com>
This commit is contained in:
parent
1ff6a323de
commit
d41c744b2e
34 changed files with 4 additions and 22638 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
27
Cargo.lock
generated
27
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
] }
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -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]
|
||||
|
|
@ -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.
|
||||
|
|
@ -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<MoveSphereCandidate> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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<MoveTrackerDispatchPlan> {
|
||||
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<Vec<MoveMarkerCandidate>> {
|
||||
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<MoveMarkerCandidate> {
|
||||
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<usize> {
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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]
|
||||
|
|
@ -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<Controller>,
|
||||
colors: Vec<(String, [u8; 3])>,
|
||||
camera_info: PsmoveApiCameraInfo,
|
||||
}
|
||||
|
||||
impl PsmoveApiTracker {
|
||||
pub fn open(
|
||||
camera_index: i32,
|
||||
exposure: f32,
|
||||
colors: &[(String, [u8; 3])],
|
||||
) -> Result<Self, PsmoveApiTrackerError> {
|
||||
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<PsmoveApiObservation> {
|
||||
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<u8>)> {
|
||||
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<Self, PsmoveApiTrackerError> {
|
||||
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<PsmoveApiObservation> {
|
||||
Vec::new()
|
||||
}
|
||||
|
||||
pub fn rgb_image(&self) -> Option<(u32, u32, Vec<u8>)> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
<https://developer.nvidia.com/blog/introducing-video-codec-sdk-10-presets/>
|
||||
- NVIDIA CloudXR publicly frames low-latency spatial streaming around NVENC,
|
||||
adaptive bitrate, foveated streaming, diagnostics, and bidirectional data
|
||||
channels:
|
||||
<https://docs.nvidia.com/cloudxr-sdk/latest/overview/overview.html>
|
||||
- NVIDIA's Optical Flow SDK exposes hardware motion vectors for real-time
|
||||
interpolation/extrapolation and related video processing:
|
||||
<https://developer.nvidia.com/optical-flow-sdk>
|
||||
|
||||
## 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.
|
||||
262
docs/muninn.md
262
docs/muninn.md
|
|
@ -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:<host>:quest-access:<serial>`. The record advertises:
|
||||
|
||||
- `muninn:<host>:quest-input`: Quest buttons/analog/controller input once a
|
||||
Quest/OpenXR witness is running.
|
||||
- `muninn:<host>:quest-poses`: headset and controller poses from that same
|
||||
witness.
|
||||
- `muninn:<host>: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:<host>: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 <stream-id>` or `--move-evidence-verse <verse-id>`
|
||||
only when the default `muninn:<host>: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.
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
@echo off
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File %~dp0activate-muninn-raven-av-srt.ps1
|
||||
|
|
@ -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."
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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<string>();
|
||||
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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
@echo off
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File %~dp0restart-muninn.ps1
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)"
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
Loading…
Reference in a new issue