using System.Text.Json; using System.Text.Json.Serialization; namespace SteamFrameVRCFTModule; /// Tunable parameters, stored as steamframe-config.json next to the module DLL and hot-reloaded while running. public sealed class ModuleConfig { public LidConfig Lid { get; set; } = new(); public WinkConfig Wink { get; set; } = new(); public BlinkConfig Blink { get; set; } = new(); public GazeConfig Gaze { get; set; } = new(); /// Swap left/right eyes (if the headset reports them the other way round). public bool SwapEyes { get; set; } /// Write 30 ms samples to %TEMP%\steamframe-trace.csv. tools/tune.py turns this on while it records or watches. public bool Trace { get; set; } public sealed class LidConfig { /// Fraction of the calibrated range at each end that reads as fully closed / fully open. public float Deadband { get; set; } = 0.06f; /// Per-update drift of the adaptive envelopes toward the current value (bigger = adapts faster). public float Tau { get; set; } = 0.0004f; public float MaxFloor { get; set; } = 0.40f; public float MinCeil { get; set; } = 0.55f; public float MinRange { get; set; } = 0.25f; /// Low-pass factor for the envelope tracker (0..1, bigger = less smoothing). public float Smoothing { get; set; } = 0.35f; /// Fixed calibration per eye (raw tracker units). When both closed and open are set for an eye, adaptation is off for it. public float? LeftClosed { get; set; } public float? LeftOpen { get; set; } public float? RightClosed { get; set; } public float? RightOpen { get; set; } } public sealed class WinkConfig { /// Lid difference above which the asymmetry is amplified. public float Threshold { get; set; } = 0.15f; public float Range { get; set; } = 0.25f; /// 0 disables wink sharpening. public float Strength { get; set; } = 1.0f; /// Wink assist: closing one eye tightens the other, so during a wink the tracker reports the other lid partly closed. /// When one eye is at its floor and the other sits clearly above its own floor for , treat it /// as a wink and show the other eye open. Blinks are too short to trigger it. public bool Assist { get; set; } /// Openness the assisted eye is shown at (0..1). public float AssistOpen { get; set; } = 0.95f; /// Mapped lid value at or below this counts as fully closed. public float AssistClosed { get; set; } = 0.06f; /// The other eye must be above this mapped value (i.e. above its closed floor) to count as "partly there". public float AssistMin { get; set; } = 0.08f; public int AssistPersistMs { get; set; } = 150; public int AssistReleaseMs { get; set; } = 120; } public sealed class BlinkConfig { /// Keep a lid at its lowest recent value this long so fast blinks reach full depth. public int HoldMs { get; set; } = 90; public float ReleasePerSec { get; set; } = 10f; /// A lopsided closure (one eye closed, the other open) shorter than this is a blink and closes both eyes; /// a longer one is a wink. The tracker often reports blinks as one lid at 0 and the other at 1. 0 disables. public int CoupleMs { get; set; } = 140; /// Normalised lid below this counts as closed, above as open, for the lopsided test. public float AsymClosed { get; set; } = 0.35f; public float AsymOpen { get; set; } = 0.60f; /// The tracker reports many blinks as one lid closed while the other is pinned at its ceiling (raw ~1.0). /// While that signature lasts, both eyes close (no time limit). A real wink keeps the open eye near its normal level. /// 0 disables. public float SaturatedRaw { get; set; } = 0.985f; /// The glitch signature must last this long before it counts, so a single saturated frame in a real wink is ignored. public int GlitchMinMs { get; set; } = 40; } public sealed class GazeConfig { /// Multiplier on the gaze angle (frameeyeosc sends +-1 == +-45 degrees). public float Scale { get; set; } = 1.0f; public bool InvertX { get; set; } public bool InvertY { get; set; } } private static readonly JsonSerializerOptions Json = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase, WriteIndented = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, DefaultIgnoreCondition = JsonIgnoreCondition.Never, }; /// Load and validate. Returns null (with a reason) when the file is unreadable or invalid, so the caller can keep /// the configuration it already has instead of silently dropping the user's calibration. A missing file gets the defaults. public static ModuleConfig? TryLoad(string path, out string error) { error = ""; try { if (!File.Exists(path)) { var def = new ModuleConfig(); File.WriteAllText(path, JsonSerializer.Serialize(def, Json)); return def; } var cfg = JsonSerializer.Deserialize(File.ReadAllText(path), Json); if (cfg == null) { error = "the file is empty or null"; return null; } error = cfg.Validate(); return error == "" ? cfg : null; } catch (Exception e) { error = e.Message; return null; } } /// Empty string when usable, otherwise the first problem found. public string Validate() { if (Lid == null || Wink == null || Blink == null || Gaze == null) return "a section (lid, wink, blink or gaze) is missing or null"; bool Bad(float v, float lo, float hi) => !float.IsFinite(v) || v < lo || v > hi; if (Bad(Lid.Deadband, 0f, 0.45f)) return "lid.deadband must be 0..0.45"; if (Bad(Lid.Smoothing, 0f, 1f) || Bad(Lid.Tau, 0f, 1f)) return "lid.smoothing and lid.tau must be 0..1"; if (Bad(Lid.MaxFloor, 0f, 1f) || Bad(Lid.MinCeil, 0f, 1f) || Bad(Lid.MinRange, 0.01f, 1f)) return "lid.maxFloor/minCeil/minRange out of range"; // the adaptive ceiling is at least max(minCeil, floor + minRange) and at most 1, with floor up to maxFloor if (Lid.MaxFloor + Lid.MinRange > 1f) return "lid.maxFloor + lid.minRange must not exceed 1"; if (Lid.MinCeil <= 0f || Lid.MinCeil > 1f) return "lid.minCeil must be above 0 and at most 1"; foreach (var (c, o, eye) in new[] { (Lid.LeftClosed, Lid.LeftOpen, "left"), (Lid.RightClosed, Lid.RightOpen, "right") }) { if (c is float fc && Bad(fc, 0f, 1f)) return $"lid.{eye}Closed must be 0..1"; if (o is float fo && Bad(fo, 0f, 1f)) return $"lid.{eye}Open must be 0..1"; if (c is float a && o is float b && b - a < 0.05f) return $"lid.{eye}Open must be above lid.{eye}Closed"; } if (Bad(Wink.Threshold, 0f, 1f) || Bad(Wink.Range, 0.01f, 2f) || Bad(Wink.Strength, 0f, 1f)) return "wink.threshold/range/strength out of range"; if (Bad(Wink.AssistOpen, 0f, 1f) || Bad(Wink.AssistClosed, 0f, 1f) || Bad(Wink.AssistMin, 0f, 1f)) return "wink.assist* levels must be 0..1"; if (Wink.AssistPersistMs < 0 || Wink.AssistReleaseMs < 0) return "wink.assist*Ms must not be negative"; if (Blink.HoldMs < 0 || Blink.CoupleMs < 0 || Blink.GlitchMinMs < 0) return "blink.*Ms must not be negative"; if (Bad(Blink.ReleasePerSec, 0f, 1000f) || Bad(Blink.AsymClosed, 0f, 1f) || Bad(Blink.AsymOpen, 0f, 1f) || Bad(Blink.SaturatedRaw, 0f, 1f)) return "blink.* values out of range"; if (Bad(Gaze.Scale, 0f, 10f)) return "gaze.scale must be 0..10"; return ""; } }