Single ligne par transition de state (RuntimeMissing → HmdAbsent → Ready). Si jamais ça re-crash dans cette zone, on aura la dernière trace avant le silence de Serilog ; ex. « OpenVR state: HmdAbsent (vrserver.exe pas lancé) ». Pas de spam : on log uniquement à la transition (un check tourne toutes les 5 s, sans dédup ce serait 700 messages/heure). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
406 lines
16 KiB
C#
406 lines
16 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Wrapper haut niveau autour de <see cref="OpenVRNative"/>. Singleton
|
|
/// thread-safe : init paresseuse au premier appel <see cref="Query"/>, é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 <see cref="VrRuntimeState.HmdAbsent"/> proprement, prêt à
|
|
/// re-tenter au tick suivant.
|
|
/// </summary>
|
|
public sealed class OpenVrService : IOpenVrService, IDisposable
|
|
{
|
|
private const string IVRSystemInterfaceVersion = "FnTable:IVRSystem_022";
|
|
|
|
private readonly ILogger<OpenVrService> _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<OpenVrService> 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<OpenVRNative.GetTrackedDeviceActivityLevelFn>(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceActivityLevel));
|
|
_fnRoleToIndex = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetTrackedDeviceIndexForControllerRoleFn>(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceIndexForControllerRole));
|
|
_fnDeviceClass = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetTrackedDeviceClassFn>(P(OpenVRNative.IVRSystemSlot.GetTrackedDeviceClass));
|
|
_fnIsConnected = Marshal.GetDelegateForFunctionPointer<OpenVRNative.IsTrackedDeviceConnectedFn>(P(OpenVRNative.IVRSystemSlot.IsTrackedDeviceConnected));
|
|
_fnGetBool = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetBoolTrackedDevicePropertyFn>(P(OpenVRNative.IVRSystemSlot.GetBoolTrackedDeviceProperty));
|
|
_fnGetFloat = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetFloatTrackedDevicePropertyFn>(P(OpenVRNative.IVRSystemSlot.GetFloatTrackedDeviceProperty));
|
|
_fnGetString = Marshal.GetDelegateForFunctionPointer<OpenVRNative.GetStringTrackedDevicePropertyFn>(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; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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 ».
|
|
/// </summary>
|
|
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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
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();
|
|
}
|
|
}
|