diff --git a/installer/PSLauncher.iss b/installer/PSLauncher.iss
index 8e47839..13013d3 100644
--- a/installer/PSLauncher.iss
+++ b/installer/PSLauncher.iss
@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher"
-#define MyAppVersion "0.23.2"
+#define MyAppVersion "0.23.3"
#define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe"
diff --git a/src/PSLauncher.App/PSLauncher.App.csproj b/src/PSLauncher.App/PSLauncher.App.csproj
index 2bcbffe..f6f506f 100644
--- a/src/PSLauncher.App/PSLauncher.App.csproj
+++ b/src/PSLauncher.App/PSLauncher.App.csproj
@@ -15,9 +15,9 @@
PROSERVE Launcher
© 2026 ASTERION VR — All rights reserved
PSLauncher.App
- 0.23.2
- 0.23.2.0
- 0.23.2.0
+ 0.23.3
+ 0.23.3.0
+ 0.23.3.0
true
diff --git a/src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs b/src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs
index 9b311b5..958bfd3 100644
--- a/src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs
+++ b/src/PSLauncher.Core/Health/OpenVR/OpenVrService.cs
@@ -62,68 +62,112 @@ public sealed class OpenVrService : IOpenVrService, IDisposable
{
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,
- });
- }
+ // ============================================================
+ // MODE SAFE : on N'UTILISE PAS la vtable IVRSystem.
+ //
+ // Pourquoi : les fonctions de la vtable (IsTrackedDeviceConnected,
+ // GetBoolTrackedDeviceProperty, etc.) font du SHM/IPC avec vrserver
+ // qui peut être dans un état partiellement initialisé pendant et
+ // après le démarrage de SteamVR. Ces fonctions accèdent à de la
+ // mémoire pas encore mappée → AccessViolation natif → SEH passe
+ // au travers du CLR via Marshal.GetDelegateForFunctionPointer
+ // → process killed sans qu'aucun catch ne se déclenche.
+ //
+ // Repousser le délai de settle ne suffit pas (race window peut
+ // se rouvrir à n'importe quel moment de la session : (re)connexion
+ // d'un device, rechargement d'un driver…). On utilise donc UNIQUEMENT
+ // l'API flat C : VR_IsRuntimeInstalled + VR_IsHmdPresent qui ne
+ // touchent pas au SHM. Limitation : pas de batterie, pas de per-device
+ // info — juste "HMD détecté oui/non". La batterie reviendra en
+ // Phase 1.5 via un subprocess séparé (PSLauncher.VrProbe.exe) qui
+ // peut crasher sans tuer le launcher.
+ // ============================================================
- try
+ var label = ResolveLabel(deviceAlias);
+ var state = EnsureReadySafe();
+ LogStateTransition(state);
+
+ string detail;
+ switch (state)
{
- var index = ResolveDeviceIndex(deviceAlias);
- if (index is null)
- {
+ case VrRuntimeState.RuntimeMissing:
+ return new VrDeviceQueryResult(state, false, label, null,
+ OpenVRNative.ETrackedDeviceClass.Invalid,
+ null, null, null, null, null,
+ "SteamVR n'est pas installé sur cette machine");
+
+ case VrRuntimeState.HmdAbsent:
return new VrDeviceQueryResult(state, false, label, null,
OpenVRNative.ETrackedDeviceClass.Invalid,
false, null, null, null, null,
- $"Appareil « {deviceAlias} » non détecté");
- }
+ "SteamVR n'est pas lancé ou aucun HMD n'est branché");
- var idx = index.Value;
- var connected = _fnIsConnected!(idx);
- if (!connected)
- {
+ case VrRuntimeState.Ready:
+ detail = $"{label} : SteamVR actif, HMD détecté (info détaillée par appareil arrive en Phase 1.5)";
return new VrDeviceQueryResult(state, true, label, null,
- _fnDeviceClass!(idx),
- false, null, null, null, null,
- "Appareil déconnecté");
- }
+ OpenVRNative.ETrackedDeviceClass.HMD,
+ true, null, null, null, null,
+ detail);
- 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}");
+ default:
+ return new VrDeviceQueryResult(state, false, label, null,
+ OpenVRNative.ETrackedDeviceClass.Invalid,
+ null, null, null, null, null,
+ "Échec d'initialisation OpenVR");
}
}
}
+ ///
+ /// Variante safe d'EnsureReady : ne fait JAMAIS de VR_InitInternal2 ni
+ /// de GetGenericInterface. Décide l'état uniquement depuis :
+ /// - (lookup statique
+ /// dans openvr_api.dll, aucun IPC, immédiat),
+ /// - vrserver.exe alive (process check),
+ /// - (consulte un mutex/event
+ /// nommé partagé avec vrserver, mais pas de SHM).
+ ///
+ private VrRuntimeState EnsureReadySafe()
+ {
+ if (!_resolverEnsured)
+ {
+ OpenVRNative.EnsureResolverInstalled();
+ _resolverEnsured = true;
+ }
+
+ 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;
+ }
+
+ // vrserver.exe en l'air ?
+ if (!IsVrServerRunning()) return VrRuntimeState.HmdAbsent;
+
+ // Cooldown de settle : on garde le délai même en mode safe parce que
+ // VR_IsHmdPresent consulte un objet de synchro qui peut être pas encore
+ // créé pendant les premières secondes du boot SteamVR.
+ if (!HasVrServerSettled()) return VrRuntimeState.HmdAbsent;
+
+ try
+ {
+ if (!OpenVRNative.VR_IsHmdPresent()) return VrRuntimeState.HmdAbsent;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogDebug(ex, "VR_IsHmdPresent threw");
+ return VrRuntimeState.HmdAbsent;
+ }
+
+ return VrRuntimeState.Ready;
+ }
+
private VrRuntimeState EnsureReady()
{
if (!_resolverEnsured)
diff --git a/src/PSLauncher.Core/Health/SystemHealthService.cs b/src/PSLauncher.Core/Health/SystemHealthService.cs
index 92033a0..ffc4888 100644
--- a/src/PSLauncher.Core/Health/SystemHealthService.cs
+++ b/src/PSLauncher.Core/Health/SystemHealthService.cs
@@ -148,6 +148,19 @@ public sealed class SystemHealthService : ISystemHealthService
return new HealthResult(HealthSeverity.Error,
result.ErrorDetail ?? $"{result.DeviceLabel} non détecté");
+ // Mode safe (Phase 1) : OpenVR ne nous donne plus de batterie/activity/serial
+ // pour éviter le crash sur les vtable calls. Quand tous ces champs sont null
+ // mais qu'on est en Ready+Connected, on affiche le message court fourni par
+ // OpenVrService (ex. "HMD détecté — Phase 1.5 fournira la batterie").
+ if (result.BatteryPercentage is null
+ && result.IsCharging is null
+ && result.ActivityLevel is null
+ && string.IsNullOrEmpty(result.Serial))
+ {
+ return new HealthResult(HealthSeverity.Ok,
+ result.ErrorDetail ?? $"{result.DeviceLabel} détecté");
+ }
+
// Device présent : on assemble un résumé batterie + activité
var battery = result.BatteryPercentage;
var pctText = battery is null ? "—" : $"{Math.Round(battery.Value * 100)}%";
diff --git a/src/PSLauncher.Core/Localization/Strings.cs b/src/PSLauncher.Core/Localization/Strings.cs
index 77266bc..9b4c4e0 100644
--- a/src/PSLauncher.Core/Localization/Strings.cs
+++ b/src/PSLauncher.Core/Localization/Strings.cs
@@ -1001,11 +1001,11 @@ public static class Strings
);
public static string HealthEditorTargetVrDevice => T("APPAREIL VR", "VR DEVICE", "VR 设备", "อุปกรณ์ VR", "جهاز VR");
public static string HealthEditorTargetVrDeviceHint => T(
- "Lit batterie + état de charge + tracking via OpenVR. SteamVR doit tourner. La runtime est trouvée via la copie installée par SteamVR — rien à bundler.",
- "Reads battery + charging + tracking via OpenVR. SteamVR must be running. Runtime is found via SteamVR's installed copy — nothing to bundle.",
- "通过 OpenVR 读取电池 + 充电 + 跟踪状态。SteamVR 必须在运行。运行时通过 SteamVR 的安装副本查找 — 无需打包。",
- "อ่านแบตเตอรี่ + การชาร์จ + การติดตามผ่าน OpenVR ต้อง SteamVR ทำงาน รันไทม์ค้นหาผ่านสำเนาที่ติดตั้งของ SteamVR — ไม่ต้องบรรจุเอง",
- "يقرأ البطارية + الشحن + التتبع عبر OpenVR. يجب تشغيل SteamVR. يتم العثور على وقت التشغيل من خلال نسخة SteamVR المثبتة — لا حاجة للتجميع."
+ "Phase 1 : détecte la présence de SteamVR et d'un HMD branché (vert si oui, rouge sinon). La batterie + l'info par-appareil arrive en Phase 1.5 via un sous-process séparé pour être robuste aux crashs natifs OpenVR.",
+ "Phase 1: detects SteamVR running with an HMD plugged in (green if yes, red otherwise). Battery + per-device info coming in Phase 1.5 via a separate child process to be robust against OpenVR native crashes.",
+ "第 1 阶段:检测 SteamVR 是否运行以及是否插入了 HMD(是则绿色,否则红色)。电池 + 每个设备的信息将在第 1.5 阶段通过单独的子进程提供,以增强对 OpenVR 原生崩溃的鲁棒性。",
+ "เฟส 1: ตรวจจับว่า SteamVR กำลังทำงานและมี HMD เชื่อมต่ออยู่หรือไม่ (เขียวถ้าใช่ แดงถ้าไม่) ข้อมูลแบตเตอรี่ + ต่ออุปกรณ์จะมาในเฟส 1.5 ผ่านโปรเซสลูกแยกเพื่อความทนทานต่อข้อผิดพลาดเนทีฟ OpenVR",
+ "المرحلة 1: تكتشف تشغيل SteamVR مع توصيل HMD (أخضر إذا نعم، أحمر خلاف ذلك). البطارية + المعلومات لكل جهاز ستأتي في المرحلة 1.5 عبر عملية فرعية منفصلة لتكون متينة ضد الأعطال الأصلية OpenVR."
);
public static string HealthEditorTargetProcess => T("CIBLE — NOM DE PROCESSUS", "TARGET — PROCESS NAME", "目标 — 进程名", "เป้าหมาย — ชื่อโปรเซส", "الهدف — اسم العملية");
public static string HealthEditorTargetPing => T("CIBLE — IP OU HOSTNAME", "TARGET — IP OR HOSTNAME", "目标 — IP 或主机名", "เป้าหมาย — IP หรือชื่อโฮสต์", "الهدف — IP أو اسم المضيف");