v0.23.0 — SteamVR / OpenVR : check VrDevice avec batterie + tracking
Nouveau Kind « VrDevice » dans le bandeau de santé. Pour chaque appareil SteamVR (HMD, manettes, trackers, base stations) on lit via OpenVR : batterie %, état de charge, niveau d'activité (actif / veille / inactif) et serial number. Severity du pill calculée sur le batterie : < 15% rouge, < 30% orange, sinon vert ; charging force le vert. Implémentation : P/Invoke direct dans openvr_api.dll (BSD-3) — pas de NuGet, pas de bundling de la DLL. La runtime est résolue dynamiquement via DllImportResolver depuis : 1. %LocalAppData%\openvr\openvrpaths.vrpath (canon écrit par SteamVR), 2. fallback C:\Program Files (x86)\Steam\steamapps\common\SteamVR\bin\win64\. Si SteamVR n'est pas installé → pill « SteamVR non installé » (Unknown, gris). Si SteamVR pas lancé → pill « SteamVR non lancé / aucun HMD » (Error, rouge). Init en mode VRApplication_Background, donc on lit l'état sans devenir scene application ni monopoliser le compositor. Éditeur : nouveau radio « Appareil SteamVR » à côté de Process / Ping ; quand il est sélectionné, le champ Target devient une ComboBox d'alias (HMD, Manette gauche/droite, Tracker 1-3, Base station 1-2) au lieu du TextBox libre. Le mapping alias → TrackedDeviceIndex se fait à chaque query (controllers via GetTrackedDeviceIndexForControllerRole, trackers et base stations par énumération avec compteur de classe). Robustesse : init lazy une fois pour toute la vie du process (init/shutdown répétés coûtent 100-500 ms). Vtable IVRSystem_022 résolue par index slot dans une struct contiguë de IntPtr (pas de struct marshaling, donc immune aux changements de slots qu'on n'utilise pas). Si SteamVR crash en cours de session, le service détecte au prochain tick et marque la session broken pour retenter au tick suivant. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
49
src/PSLauncher.Core/Health/OpenVR/IOpenVrService.cs
Normal file
49
src/PSLauncher.Core/Health/OpenVR/IOpenVrService.cs
Normal file
@@ -0,0 +1,49 @@
|
||||
namespace PSLauncher.Core.Health.OpenVRInterop;
|
||||
|
||||
/// <summary>
|
||||
/// Lecture en arrière-plan (sans démarrer SteamVR ni prendre le compositor)
|
||||
/// de l'état des devices SteamVR : HMD, controllers, trackers, base stations.
|
||||
/// Implémentation : <see cref="OpenVrService"/>.
|
||||
/// </summary>
|
||||
public interface IOpenVrService
|
||||
{
|
||||
/// <summary>
|
||||
/// Interroge un device par alias :
|
||||
/// <c>hmd</c>, <c>left_controller</c>, <c>right_controller</c>,
|
||||
/// <c>tracker_1..N</c>, <c>base_station_1..N</c>.
|
||||
/// </summary>
|
||||
VrDeviceQueryResult Query(string deviceAlias);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// État de disponibilité de la runtime OpenVR. <c>RuntimeMissing</c> = openvr_api.dll
|
||||
/// introuvable (SteamVR pas installé). <c>HmdAbsent</c> = SteamVR installé mais
|
||||
/// vrserver pas lancé ou aucun HMD branché. <c>InitFailed</c> = API trouvée mais
|
||||
/// VR_InitInternal2 a échoué (cas rare : permission denied, version mismatch).
|
||||
/// <c>Ready</c> = OK, on peut lire les properties.
|
||||
/// </summary>
|
||||
public enum VrRuntimeState
|
||||
{
|
||||
Unknown,
|
||||
RuntimeMissing,
|
||||
HmdAbsent,
|
||||
InitFailed,
|
||||
Ready,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Résultat d'une requête sur un device. <see cref="Found"/> est false quand
|
||||
/// l'alias résolu ne correspond à aucun device connecté (ex. tracker_3 absent).
|
||||
/// </summary>
|
||||
public sealed record VrDeviceQueryResult(
|
||||
VrRuntimeState RuntimeState,
|
||||
bool Found,
|
||||
string DeviceLabel, // ex. "HMD", "Manette gauche"
|
||||
string? Serial, // serial number ou null
|
||||
OpenVRNative.ETrackedDeviceClass DeviceClass,
|
||||
bool? IsConnected,
|
||||
bool? IsCharging,
|
||||
bool? IsWireless,
|
||||
float? BatteryPercentage, // 0.0–1.0, null si propriété non exposée par le device
|
||||
OpenVRNative.EDeviceActivityLevel? ActivityLevel,
|
||||
string? ErrorDetail);
|
||||
237
src/PSLauncher.Core/Health/OpenVR/OpenVRNative.cs
Normal file
237
src/PSLauncher.Core/Health/OpenVR/OpenVRNative.cs
Normal file
@@ -0,0 +1,237 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace PSLauncher.Core.Health.OpenVRInterop;
|
||||
|
||||
/// <summary>
|
||||
/// P/Invoke minimal vers <c>openvr_api.dll</c> (SDK Valve OpenVR, BSD-3).
|
||||
/// On NE bundle PAS la DLL : on la résout dynamiquement via la copie installée
|
||||
/// par SteamVR (chemin canonique <c>%LocalAppData%\openvr\openvrpaths.vrpath</c>),
|
||||
/// donc :
|
||||
/// - SteamVR pas installé → la DLL n'est pas trouvée et init renvoie une erreur claire,
|
||||
/// - SteamVR mis à jour → on profite automatiquement de la dernière openvr_api.dll
|
||||
/// sans avoir à respinner un build du launcher.
|
||||
///
|
||||
/// Le SDK OpenVR expose une "flat C API" : quelques fonctions globales pour
|
||||
/// init/shutdown, et une <c>GetGenericInterface("FnTable:IVRSystem_022")</c>
|
||||
/// qui renvoie un pointeur sur une struct C de pointeurs de fonction (vtable).
|
||||
/// Cette classe expose juste ce qu'on consomme dans <see cref="OpenVrService"/> :
|
||||
/// présence de la runtime, présence d'un HMD, et la vtable IVRSystem.
|
||||
/// </summary>
|
||||
public static class OpenVRNative
|
||||
{
|
||||
private const string DllName = "openvr_api";
|
||||
|
||||
/// <summary>
|
||||
/// EVRApplicationType passé à VR_InitInternal2. <c>Background = 4</c> : on
|
||||
/// veut LIRE l'état de SteamVR sans déclencher de rendu, sans devenir une
|
||||
/// scene application, sans monopoliser le compositor.
|
||||
/// </summary>
|
||||
public const int VRApplicationBackground = 4;
|
||||
|
||||
/// <summary>Indices de slots dans la vtable IVRSystem_022. Stables depuis SteamVR 1.10 (2019).</summary>
|
||||
public static class IVRSystemSlot
|
||||
{
|
||||
public const int GetTrackedDeviceActivityLevel = 16;
|
||||
public const int GetTrackedDeviceIndexForControllerRole = 18;
|
||||
public const int GetTrackedDeviceClass = 20;
|
||||
public const int IsTrackedDeviceConnected = 21;
|
||||
public const int GetBoolTrackedDeviceProperty = 22;
|
||||
public const int GetFloatTrackedDeviceProperty = 23;
|
||||
public const int GetStringTrackedDeviceProperty = 28;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Indices ETrackedDeviceProperty les plus utiles pour notre usage. Liste
|
||||
/// complète dans <c>openvr.h</c> ; on en garde un sous-ensemble pour ne pas
|
||||
/// dupliquer 800 enums dont 99% inutiles.
|
||||
/// </summary>
|
||||
public static class DeviceProperty
|
||||
{
|
||||
public const int Prop_DeviceIsWireless_Bool = 1003;
|
||||
public const int Prop_DeviceIsCharging_Bool = 1004;
|
||||
public const int Prop_DeviceProvidesBatteryStatus_Bool = 1026; // varie par version, fallback safe sur GetBool
|
||||
public const int Prop_DeviceBatteryPercentage_Float = 1029;
|
||||
public const int Prop_SerialNumber_String = 1002;
|
||||
public const int Prop_TrackingSystemName_String = 1000;
|
||||
public const int Prop_ModelNumber_String = 1001;
|
||||
}
|
||||
|
||||
/// <summary>Classe OpenVR d'un device tracké. 1=HMD, 2=Controller, 3=GenericTracker, 4=TrackingReference.</summary>
|
||||
public enum ETrackedDeviceClass
|
||||
{
|
||||
Invalid = 0,
|
||||
HMD = 1,
|
||||
Controller = 2,
|
||||
GenericTracker = 3,
|
||||
TrackingReference = 4,
|
||||
DisplayRedirect = 5,
|
||||
}
|
||||
|
||||
public enum ETrackedControllerRole
|
||||
{
|
||||
Invalid = 0,
|
||||
LeftHand = 1,
|
||||
RightHand = 2,
|
||||
OptOut = 3,
|
||||
Treadmill = 4,
|
||||
Stylus = 5,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Niveau d'activité du device. Idle = en veille, n'envoie plus de pose.
|
||||
/// Standby = posé mais réveille rapidement. UserInteraction = en main.
|
||||
/// </summary>
|
||||
public enum EDeviceActivityLevel
|
||||
{
|
||||
Unknown = -1,
|
||||
Idle = 0,
|
||||
UserInteraction = 1,
|
||||
UserInteraction_Timeout = 2,
|
||||
Standby = 3,
|
||||
Idle_Timeout = 4,
|
||||
}
|
||||
|
||||
[DllImport(DllName, EntryPoint = "VR_IsRuntimeInstalled", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern bool VR_IsRuntimeInstalled();
|
||||
|
||||
[DllImport(DllName, EntryPoint = "VR_IsHmdPresent", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern bool VR_IsHmdPresent();
|
||||
|
||||
[DllImport(DllName, EntryPoint = "VR_InitInternal2", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
|
||||
public static extern uint VR_InitInternal2(out int peError, int eApplicationType, [MarshalAs(UnmanagedType.LPStr)] string? pStartupInfo);
|
||||
|
||||
[DllImport(DllName, EntryPoint = "VR_ShutdownInternal", CallingConvention = CallingConvention.Cdecl)]
|
||||
public static extern void VR_ShutdownInternal();
|
||||
|
||||
[DllImport(DllName, EntryPoint = "VR_GetGenericInterface", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
|
||||
public static extern IntPtr VR_GetGenericInterface([MarshalAs(UnmanagedType.LPStr)] string pchInterfaceVersion, out int peError);
|
||||
|
||||
[DllImport(DllName, EntryPoint = "VR_GetVRInitErrorAsEnglishDescription", CallingConvention = CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
|
||||
public static extern IntPtr VR_GetVRInitErrorAsEnglishDescription(int error);
|
||||
|
||||
/// <summary>
|
||||
/// Récupère un pointeur de fonction depuis la vtable IVRSystem (struct
|
||||
/// contiguë de IntPtr à <paramref name="vtablePtr"/>) au slot demandé.
|
||||
/// </summary>
|
||||
public static IntPtr GetVTableSlot(IntPtr vtablePtr, int slotIndex)
|
||||
{
|
||||
unsafe
|
||||
{
|
||||
return ((IntPtr*)vtablePtr)[slotIndex];
|
||||
}
|
||||
}
|
||||
|
||||
public static string GetInitErrorDescription(int error)
|
||||
{
|
||||
try
|
||||
{
|
||||
var ptr = VR_GetVRInitErrorAsEnglishDescription(error);
|
||||
return ptr == IntPtr.Zero ? $"unknown ({error})" : Marshal.PtrToStringAnsi(ptr) ?? $"unknown ({error})";
|
||||
}
|
||||
catch { return $"error code {error}"; }
|
||||
}
|
||||
|
||||
// ============= DLL RESOLUTION =============
|
||||
|
||||
private static int _resolverInstalled;
|
||||
|
||||
/// <summary>
|
||||
/// Installe (idempotent) un <see cref="DllImportResolver"/> qui, lorsque
|
||||
/// .NET demande "openvr_api", le résout vers la DLL de l'install SteamVR
|
||||
/// active. Pas d'effet de bord si SteamVR est absent — la DllImport
|
||||
/// échouera ensuite comme d'habitude et le service catch.
|
||||
/// </summary>
|
||||
public static void EnsureResolverInstalled()
|
||||
{
|
||||
if (Interlocked.Exchange(ref _resolverInstalled, 1) == 1) return;
|
||||
try
|
||||
{
|
||||
NativeLibrary.SetDllImportResolver(typeof(OpenVRNative).Assembly, ResolveOpenVrDll);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Déjà installé ailleurs, ou plateforme qui ne supporte pas. On laisse
|
||||
// le DllImport faire son travail (cherchera dans le PATH).
|
||||
}
|
||||
}
|
||||
|
||||
private static IntPtr ResolveOpenVrDll(string libraryName, System.Reflection.Assembly assembly, DllImportSearchPath? searchPath)
|
||||
{
|
||||
if (!string.Equals(libraryName, DllName, StringComparison.OrdinalIgnoreCase))
|
||||
return IntPtr.Zero;
|
||||
|
||||
var path = LocateOpenVrDll();
|
||||
if (path is null) return IntPtr.Zero;
|
||||
|
||||
try { return NativeLibrary.Load(path); }
|
||||
catch { return IntPtr.Zero; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Cherche openvr_api.dll dans cet ordre :
|
||||
/// 1. <c>%LocalAppData%\openvr\openvrpaths.vrpath</c> (canon écrit par SteamVR au setup).
|
||||
/// 2. Dossier Steam standard : <c>C:\Program Files (x86)\Steam\steamapps\common\SteamVR\bin\win64\</c>.
|
||||
/// </summary>
|
||||
private static string? LocateOpenVrDll()
|
||||
{
|
||||
// 1. Lire openvrpaths.vrpath (JSON)
|
||||
try
|
||||
{
|
||||
var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
var pathsFile = Path.Combine(localAppData, "openvr", "openvrpaths.vrpath");
|
||||
if (File.Exists(pathsFile))
|
||||
{
|
||||
var json = File.ReadAllText(pathsFile, Encoding.UTF8);
|
||||
using var doc = JsonDocument.Parse(json);
|
||||
if (doc.RootElement.TryGetProperty("runtime", out var runtimes) && runtimes.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var elt in runtimes.EnumerateArray())
|
||||
{
|
||||
var dir = elt.GetString();
|
||||
if (string.IsNullOrEmpty(dir)) continue;
|
||||
var dll = Path.Combine(dir, "bin", "win64", "openvr_api.dll");
|
||||
if (File.Exists(dll)) return dll;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { /* fallback */ }
|
||||
|
||||
// 2. Chemin Steam standard
|
||||
try
|
||||
{
|
||||
var steamPath = @"C:\Program Files (x86)\Steam\steamapps\common\SteamVR\bin\win64\openvr_api.dll";
|
||||
if (File.Exists(steamPath)) return steamPath;
|
||||
}
|
||||
catch { }
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============= DELEGATES POUR LES SLOTS UTILISÉS =============
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
public delegate EDeviceActivityLevel GetTrackedDeviceActivityLevelFn(uint deviceIndex);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
public delegate uint GetTrackedDeviceIndexForControllerRoleFn(ETrackedControllerRole role);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
public delegate ETrackedDeviceClass GetTrackedDeviceClassFn(uint deviceIndex);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
[return: MarshalAs(UnmanagedType.I1)]
|
||||
public delegate bool IsTrackedDeviceConnectedFn(uint deviceIndex);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
[return: MarshalAs(UnmanagedType.I1)]
|
||||
public delegate bool GetBoolTrackedDevicePropertyFn(uint deviceIndex, int property, out int peError);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
public delegate float GetFloatTrackedDevicePropertyFn(uint deviceIndex, int property, out int peError);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
public delegate uint GetStringTrackedDevicePropertyFn(uint deviceIndex, int property, IntPtr buffer, uint bufferSize, out int peError);
|
||||
}
|
||||
335
src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs
Normal file
335
src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs
Normal file
@@ -0,0 +1,335 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
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 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();
|
||||
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;
|
||||
}
|
||||
|
||||
// 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; }
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user