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 "";
}
}