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(); } }