using System.Runtime.InteropServices;
using System.Text;
using Microsoft.Extensions.Logging;
// Alias pour éviter la collision avec le namespace PSLauncher.Core.Process
using SysProcess = System.Diagnostics.Process;
namespace PSLauncher.Core.Health.OpenVRInterop;
///
/// Wrapper haut niveau autour de . Singleton
/// thread-safe : init paresseuse au premier appel , état
/// mémorisé pour les appels suivants. La vtable IVRSystem reste vivante toute
/// la durée de vie du process — coût CPU négligeable au repos, init-shutdown
/// répété coûte cher (centaines de ms).
///
/// Robustesse : tout va dans des try/catch. SteamVR crashé / fermé pendant
/// la session → le service détecte au prochain Query (vtable call lève) et
/// repasse en proprement, prêt à
/// re-tenter au tick suivant.
///
public sealed class OpenVrService : IOpenVrService, IDisposable
{
private const string IVRSystemInterfaceVersion = "FnTable:IVRSystem_022";
private readonly ILogger _logger;
private readonly object _lock = new();
private bool _resolverEnsured;
private bool _initAttempted;
private VrRuntimeState _lastLoggedState = (VrRuntimeState)(-99); // sentinelle « jamais loggé »
private uint _initToken;
private IntPtr _vrSystemVTable = IntPtr.Zero;
// Delegates marshalés une seule fois après init pour éviter le coût
// GetDelegateForFunctionPointer à chaque tick.
private OpenVRNative.GetTrackedDeviceActivityLevelFn? _fnActivity;
private OpenVRNative.GetTrackedDeviceIndexForControllerRoleFn? _fnRoleToIndex;
private OpenVRNative.GetTrackedDeviceClassFn? _fnDeviceClass;
private OpenVRNative.IsTrackedDeviceConnectedFn? _fnIsConnected;
private OpenVRNative.GetBoolTrackedDevicePropertyFn? _fnGetBool;
private OpenVRNative.GetFloatTrackedDevicePropertyFn? _fnGetFloat;
private OpenVRNative.GetStringTrackedDevicePropertyFn? _fnGetString;
public OpenVrService(ILogger logger)
{
_logger = logger;
}
public VrDeviceQueryResult Query(string deviceAlias)
{
lock (_lock)
{
var state = EnsureReady();
LogStateTransition(state);
var label = ResolveLabel(deviceAlias);
if (state != VrRuntimeState.Ready)
{
return new VrDeviceQueryResult(state, false, label, null,
OpenVRNative.ETrackedDeviceClass.Invalid,
null, null, null, null, null,
state switch
{
VrRuntimeState.RuntimeMissing => "SteamVR n'est pas installé sur cette machine",
VrRuntimeState.HmdAbsent => "SteamVR n'est pas lancé ou aucun HMD n'est branché",
VrRuntimeState.InitFailed => "Échec d'initialisation OpenVR",
_ => null,
});
}
try
{
var index = ResolveDeviceIndex(deviceAlias);
if (index is null)
{
return new VrDeviceQueryResult(state, false, label, null,
OpenVRNative.ETrackedDeviceClass.Invalid,
false, null, null, null, null,
$"Appareil « {deviceAlias} » non détecté");
}
var idx = index.Value;
var connected = _fnIsConnected!(idx);
if (!connected)
{
return new VrDeviceQueryResult(state, true, label, null,
_fnDeviceClass!(idx),
false, null, null, null, null,
"Appareil déconnecté");
}
var deviceClass = _fnDeviceClass!(idx);
var activity = _fnActivity!(idx);
var serial = ReadStringProperty(idx, OpenVRNative.DeviceProperty.Prop_SerialNumber_String);
bool? wireless = ReadBoolProperty(idx, OpenVRNative.DeviceProperty.Prop_DeviceIsWireless_Bool);
bool? charging = ReadBoolProperty(idx, OpenVRNative.DeviceProperty.Prop_DeviceIsCharging_Bool);
float? battery = ReadFloatProperty(idx, OpenVRNative.DeviceProperty.Prop_DeviceBatteryPercentage_Float);
return new VrDeviceQueryResult(state, true, label, serial,
deviceClass, true, charging, wireless, battery, activity, null);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OpenVR query failed for {Alias}, marking session unhealthy", deviceAlias);
// Marquer l'état comme cassé pour qu'au prochain Query on retente l'init.
MarkSessionBroken();
return new VrDeviceQueryResult(VrRuntimeState.HmdAbsent, false, label, null,
OpenVRNative.ETrackedDeviceClass.Invalid,
null, null, null, null, null,
$"Erreur OpenVR : {ex.Message}");
}
}
}
private VrRuntimeState EnsureReady()
{
if (!_resolverEnsured)
{
OpenVRNative.EnsureResolverInstalled();
_resolverEnsured = true;
}
if (_vrSystemVTable != IntPtr.Zero) return VrRuntimeState.Ready;
// Probe runtime + HMD ; les deux sont des appels rapides qui ne déclenchent
// pas l'init du compositor même si SteamVR est éteint.
try
{
if (!OpenVRNative.VR_IsRuntimeInstalled())
return VrRuntimeState.RuntimeMissing;
}
catch (DllNotFoundException) { return VrRuntimeState.RuntimeMissing; }
catch (Exception ex)
{
_logger.LogDebug(ex, "VR_IsRuntimeInstalled threw");
return VrRuntimeState.RuntimeMissing;
}
try
{
if (!OpenVRNative.VR_IsHmdPresent()) return VrRuntimeState.HmdAbsent;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "VR_IsHmdPresent threw");
return VrRuntimeState.HmdAbsent;
}
// Garde supplémentaire avant l'init : vrserver.exe DOIT être actif. Sans
// lui, VR_InitInternal2 en mode Background peut soit hang, soit charger
// des drivers (lighthouse, oculus_link…) qui plantent en SEH si SteamVR
// n'a jamais été démarré sur cette session. Cette garde évite ce scénario
// au prix de ne plus rien lire jusqu'à ce que l'utilisateur lance SteamVR
// — ce qui est le comportement attendu de toute façon.
if (!IsVrServerRunning())
{
return VrRuntimeState.HmdAbsent;
}
// Init en mode Background : on lit les states sans devenir une scene app.
// L'init peut prendre 100-500 ms la première fois ; bloquer le caller est OK
// car le health loop tourne sur thread dédié.
if (_initAttempted && _initToken == 0) return VrRuntimeState.InitFailed;
_initAttempted = true;
try
{
_initToken = OpenVRNative.VR_InitInternal2(out var initError, OpenVRNative.VRApplicationBackground, null);
if (initError != 0 || _initToken == 0)
{
_logger.LogWarning("VR_InitInternal2 failed: {Err}", OpenVRNative.GetInitErrorDescription(initError));
_initToken = 0;
return VrRuntimeState.InitFailed;
}
_vrSystemVTable = OpenVRNative.VR_GetGenericInterface(IVRSystemInterfaceVersion, out var ifaceError);
if (_vrSystemVTable == IntPtr.Zero || ifaceError != 0)
{
_logger.LogWarning("VR_GetGenericInterface({Iface}) failed: {Err}",
IVRSystemInterfaceVersion, OpenVRNative.GetInitErrorDescription(ifaceError));
ShutdownInternal();
return VrRuntimeState.InitFailed;
}
BindVTableDelegates();
return VrRuntimeState.Ready;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "OpenVR init threw");
ShutdownInternal();
return VrRuntimeState.InitFailed;
}
}
private void BindVTableDelegates()
{
IntPtr P(int slot) => OpenVRNative.GetVTableSlot(_vrSystemVTable, slot);
_fnActivity = Marshal.GetDelegateForFunctionPointer(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceActivityLevel));
_fnRoleToIndex = Marshal.GetDelegateForFunctionPointer(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceIndexForControllerRole));
_fnDeviceClass = Marshal.GetDelegateForFunctionPointer(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceClass));
_fnIsConnected = Marshal.GetDelegateForFunctionPointer(P(OpenVRNative.IVRSystemSlot.IsTrackedDeviceConnected));
_fnGetBool = Marshal.GetDelegateForFunctionPointer(P(OpenVRNative.IVRSystemSlot.GetBoolTrackedDeviceProperty));
_fnGetFloat = Marshal.GetDelegateForFunctionPointer(P(OpenVRNative.IVRSystemSlot.GetFloatTrackedDeviceProperty));
_fnGetString = Marshal.GetDelegateForFunctionPointer(P(OpenVRNative.IVRSystemSlot.GetStringTrackedDeviceProperty));
}
private uint? ResolveDeviceIndex(string alias)
{
if (_fnRoleToIndex is null || _fnDeviceClass is null) return null;
var a = alias.Trim().ToLowerInvariant();
const uint K_unMaxTrackedDeviceCount = 64;
const uint K_InvalidIndex = 0xFFFFFFFF;
switch (a)
{
case "hmd":
return 0; // index 0 = HMD principal par convention OpenVR
case "left_controller":
{
var idx = _fnRoleToIndex(OpenVRNative.ETrackedControllerRole.LeftHand);
return idx == K_InvalidIndex ? null : idx;
}
case "right_controller":
{
var idx = _fnRoleToIndex(OpenVRNative.ETrackedControllerRole.RightHand);
return idx == K_InvalidIndex ? null : idx;
}
}
// tracker_N / base_station_N : on énumère tous les devices et on prend le N-ième de la classe demandée
if (TryParseIndexed(a, "tracker_", out var trackerN))
return FindNthOfClass(OpenVRNative.ETrackedDeviceClass.GenericTracker, trackerN);
if (TryParseIndexed(a, "base_station_", out var stationN))
return FindNthOfClass(OpenVRNative.ETrackedDeviceClass.TrackingReference, stationN);
// Fallback : si l'utilisateur a tapé un index brut "0", "1", … on le respecte
if (uint.TryParse(a, out var raw) && raw < K_unMaxTrackedDeviceCount)
return raw;
return null;
}
private static bool TryParseIndexed(string alias, string prefix, out int n)
{
n = 0;
if (!alias.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) return false;
var tail = alias[prefix.Length..];
return int.TryParse(tail, out n) && n >= 1;
}
private uint? FindNthOfClass(OpenVRNative.ETrackedDeviceClass targetClass, int n)
{
if (_fnDeviceClass is null) return null;
const uint K_unMaxTrackedDeviceCount = 64;
var found = 0;
for (uint i = 0; i < K_unMaxTrackedDeviceCount; i++)
{
if (_fnDeviceClass(i) == targetClass)
{
found++;
if (found == n) return i;
}
}
return null;
}
private static string ResolveLabel(string alias) => alias.Trim().ToLowerInvariant() switch
{
"hmd" => "HMD",
"left_controller" => "Manette gauche",
"right_controller" => "Manette droite",
var a when a.StartsWith("tracker_") => $"Tracker {a[8..]}",
var a when a.StartsWith("base_station_") => $"Base station {a[13..]}",
_ => alias,
};
private bool? ReadBoolProperty(uint deviceIndex, int property)
{
try
{
var v = _fnGetBool!(deviceIndex, property, out var err);
return err == 0 ? v : null;
}
catch { return null; }
}
private float? ReadFloatProperty(uint deviceIndex, int property)
{
try
{
var v = _fnGetFloat!(deviceIndex, property, out var err);
return err == 0 ? v : null;
}
catch { return null; }
}
private string? ReadStringProperty(uint deviceIndex, int property)
{
try
{
var buf = Marshal.AllocHGlobal(256);
try
{
var written = _fnGetString!(deviceIndex, property, buf, 256, out var err);
if (err != 0 || written == 0) return null;
// OpenVR renvoie un C-string ANSI null-terminé
return Marshal.PtrToStringAnsi(buf, (int)Math.Min(written, 256u)).TrimEnd('\0');
}
finally
{
Marshal.FreeHGlobal(buf);
}
}
catch { return null; }
}
///
/// Log au niveau INFO la première occurrence d'un état + chaque transition.
/// Évite de spammer (un check s'exécute toutes les 5 s, on aurait 700 messages
/// par heure si on loggait à chaque tick) tout en laissant une trace claire
/// dans le journal au cas où ça re-crasherait — la dernière ligne avant le
/// silence de Serilog dirait par exemple « OpenVR state: HmdAbsent ».
///
private void LogStateTransition(VrRuntimeState newState)
{
if (newState == _lastLoggedState) return;
_lastLoggedState = newState;
var detail = newState switch
{
VrRuntimeState.RuntimeMissing => "openvr_api.dll introuvable (SteamVR pas installé)",
VrRuntimeState.HmdAbsent => "vrserver.exe pas lancé ou aucun HMD branché",
VrRuntimeState.InitFailed => "VR_InitInternal2 a échoué",
VrRuntimeState.Ready => "OK, vtable IVRSystem_022 bindée",
_ => "?",
};
_logger.LogInformation("OpenVR state: {State} ({Detail})", newState, detail);
}
///
/// vrserver.exe est le process central de SteamVR ; sa présence garantit qu'on
/// peut init OpenVR sans déclencher de chargement de drivers risqué. Cache de
/// 2 s pour ne pas faire un GetProcessesByName à chaque tick.
///
private DateTime _vrServerCacheUntil = DateTime.MinValue;
private bool _vrServerCacheValue;
private bool IsVrServerRunning()
{
var now = DateTime.UtcNow;
if (now < _vrServerCacheUntil) return _vrServerCacheValue;
try
{
var procs = SysProcess.GetProcessesByName("vrserver");
try
{
_vrServerCacheValue = procs.Length > 0;
}
finally
{
foreach (var p in procs) p.Dispose();
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "vrserver process probe failed, assuming absent");
_vrServerCacheValue = false;
}
_vrServerCacheUntil = now.AddSeconds(2);
return _vrServerCacheValue;
}
private void MarkSessionBroken()
{
ShutdownInternal();
_initAttempted = false; // permettre une retente au prochain tick
}
private void ShutdownInternal()
{
_vrSystemVTable = IntPtr.Zero;
_fnActivity = null;
_fnRoleToIndex = null;
_fnDeviceClass = null;
_fnIsConnected = null;
_fnGetBool = null;
_fnGetFloat = null;
_fnGetString = null;
if (_initToken != 0)
{
try { OpenVRNative.VR_ShutdownInternal(); } catch { /* ignore */ }
_initToken = 0;
}
}
public void Dispose()
{
lock (_lock) ShutdownInternal();
}
}