v0.23.3 — Mode safe : OpenVR sans vtable (Phase 1 stripped down)

Init OpenVR + binding de la vtable IVRSystem_022 fonctionne (confirmé par
les logs : "OpenVR state: Ready, vtable IVRSystem_022 bindée"), mais le
PREMIER appel vtable crashe en AccessViolation natif. Pas une race au
boot — ça plante même 8s après que vrserver est apparu, et probablement
à n'importe quel moment de la session SteamVR.

Diagnostic : les fonctions de la vtable (IsTrackedDeviceConnected,
GetBoolTrackedDeviceProperty, etc.) accèdent à des shared memories avec
vrserver qui peuvent être (re)mappées dynamiquement quand un device se
connecte/déconnecte. L'AccessViolation natif passe au travers du CLR
via Marshal.GetDelegateForFunctionPointer — non rattrapable par
catch (Exception) ni catch (AccessViolationException).

Fix : on retire TOUS les appels vtable. OpenVrService.Query utilise
maintenant uniquement l'API flat C qui ne fait pas de SHM/IPC :

  - VR_IsRuntimeInstalled() — lookup statique dans la DLL
  - VR_IsHmdPresent() — consulte un mutex/event nommé, pas de SHM
  - GetProcessesByName("vrserver") — process check Windows

Résultat : check VR robuste mais réduit à "SteamVR actif + HMD présent
oui/non". Plus de batterie, plus d'info per-device, le device picker du
dialog devient cosmétique (tous les aliases retournent la même réponse).

La batterie + l'info per-device reviendra en Phase 1.5 via une approche
sub-process : un PSLauncher.VrProbe.exe séparé qui fait les vtable calls
et communique par stdout JSON. Si VrProbe crashe en AccessViolation, le
launcher voit juste "exit code != 0" et report "VR query failed" sans
être affecté lui-même.

Le code de la vtable (delegates, BindVTableDelegates, Init/Shutdown) reste
en place dans OpenVrService.cs comme référence pour Phase 1.5 — n'est
juste plus appelé depuis Query.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-03 20:16:16 +02:00
parent 07ead6011f
commit 7cb2b01403
5 changed files with 116 additions and 59 deletions

View File

@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher" #define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher" #define MyAppShortName "PS_Launcher"
#define MyAppVersion "0.23.2" #define MyAppVersion "0.23.3"
#define MyAppPublisher "ASTERION VR" #define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com" #define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe" #define MyAppExeName "PS_Launcher.exe"

View File

@@ -15,9 +15,9 @@
<Product>PROSERVE Launcher</Product> <Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright> <Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace> <RootNamespace>PSLauncher.App</RootNamespace>
<Version>0.23.2</Version> <Version>0.23.3</Version>
<AssemblyVersion>0.23.2.0</AssemblyVersion> <AssemblyVersion>0.23.3.0</AssemblyVersion>
<FileVersion>0.23.2.0</FileVersion> <FileVersion>0.23.3.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) --> <!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile> <PublishSingleFile>true</PublishSingleFile>

View File

@@ -62,66 +62,110 @@ public sealed class OpenVrService : IOpenVrService, IDisposable
{ {
lock (_lock) lock (_lock)
{ {
var state = EnsureReady(); // ============================================================
LogStateTransition(state); // 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.
// ============================================================
var label = ResolveLabel(deviceAlias); var label = ResolveLabel(deviceAlias);
if (state != VrRuntimeState.Ready) var state = EnsureReadySafe();
LogStateTransition(state);
string detail;
switch (state)
{ {
case VrRuntimeState.RuntimeMissing:
return new VrDeviceQueryResult(state, false, label, null, return new VrDeviceQueryResult(state, false, label, null,
OpenVRNative.ETrackedDeviceClass.Invalid, OpenVRNative.ETrackedDeviceClass.Invalid,
null, null, null, null, null, null, null, null, null, null,
state switch "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,
"SteamVR n'est pas lancé ou aucun HMD n'est branché");
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,
OpenVRNative.ETrackedDeviceClass.HMD,
true, null, null, null, null,
detail);
default:
return new VrDeviceQueryResult(state, false, label, null,
OpenVRNative.ETrackedDeviceClass.Invalid,
null, null, null, null, null,
"Échec d'initialisation OpenVR");
}
}
}
/// <summary>
/// Variante safe d'EnsureReady : ne fait JAMAIS de VR_InitInternal2 ni
/// de GetGenericInterface. Décide l'état uniquement depuis :
/// - <see cref="OpenVRNative.VR_IsRuntimeInstalled"/> (lookup statique
/// dans openvr_api.dll, aucun IPC, immédiat),
/// - vrserver.exe alive (process check),
/// - <see cref="OpenVRNative.VR_IsHmdPresent"/> (consulte un mutex/event
/// nommé partagé avec vrserver, mais pas de SHM).
/// </summary>
private VrRuntimeState EnsureReadySafe()
{ {
VrRuntimeState.RuntimeMissing => "SteamVR n'est pas installé sur cette machine", if (!_resolverEnsured)
VrRuntimeState.HmdAbsent => "SteamVR n'est pas lancé ou aucun HMD n'est branché", {
VrRuntimeState.InitFailed => "Échec d'initialisation OpenVR", OpenVRNative.EnsureResolverInstalled();
_ => null, _resolverEnsured = true;
});
} }
try try
{ {
var index = ResolveDeviceIndex(deviceAlias); if (!OpenVRNative.VR_IsRuntimeInstalled())
if (index is null) return VrRuntimeState.RuntimeMissing;
}
catch (DllNotFoundException) { return VrRuntimeState.RuntimeMissing; }
catch (Exception ex)
{ {
return new VrDeviceQueryResult(state, false, label, null, _logger.LogDebug(ex, "VR_IsRuntimeInstalled threw");
OpenVRNative.ETrackedDeviceClass.Invalid, return VrRuntimeState.RuntimeMissing;
false, null, null, null, null,
$"Appareil « {deviceAlias} » non détecté");
} }
var idx = index.Value; // vrserver.exe en l'air ?
var connected = _fnIsConnected!(idx); if (!IsVrServerRunning()) return VrRuntimeState.HmdAbsent;
if (!connected)
// 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
{ {
return new VrDeviceQueryResult(state, true, label, null, if (!OpenVRNative.VR_IsHmdPresent()) return VrRuntimeState.HmdAbsent;
_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) catch (Exception ex)
{ {
_logger.LogWarning(ex, "OpenVR query failed for {Alias}, marking session unhealthy", deviceAlias); _logger.LogDebug(ex, "VR_IsHmdPresent threw");
// Marquer l'état comme cassé pour qu'au prochain Query on retente l'init. return VrRuntimeState.HmdAbsent;
MarkSessionBroken();
return new VrDeviceQueryResult(VrRuntimeState.HmdAbsent, false, label, null,
OpenVRNative.ETrackedDeviceClass.Invalid,
null, null, null, null, null,
$"Erreur OpenVR : {ex.Message}");
}
} }
return VrRuntimeState.Ready;
} }
private VrRuntimeState EnsureReady() private VrRuntimeState EnsureReady()

View File

@@ -148,6 +148,19 @@ public sealed class SystemHealthService : ISystemHealthService
return new HealthResult(HealthSeverity.Error, return new HealthResult(HealthSeverity.Error,
result.ErrorDetail ?? $"{result.DeviceLabel} non détecté"); 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é // Device présent : on assemble un résumé batterie + activité
var battery = result.BatteryPercentage; var battery = result.BatteryPercentage;
var pctText = battery is null ? "—" : $"{Math.Round(battery.Value * 100)}%"; var pctText = battery is null ? "—" : $"{Math.Round(battery.Value * 100)}%";

View File

@@ -1001,11 +1001,11 @@ public static class Strings
); );
public static string HealthEditorTargetVrDevice => T("APPAREIL VR", "VR DEVICE", "VR 设备", "อุปกรณ์ VR", "جهاز VR"); public static string HealthEditorTargetVrDevice => T("APPAREIL VR", "VR DEVICE", "VR 设备", "อุปกรณ์ VR", "جهاز VR");
public static string HealthEditorTargetVrDeviceHint => T( 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.", "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.",
"Reads battery + charging + tracking via OpenVR. SteamVR must be running. Runtime is found via SteamVR's installed copy — nothing to bundle.", "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.",
"通过 OpenVR 读取电池 + 充电 + 跟踪状态。SteamVR 必须在运行。运行时通过 SteamVR 的安装副本查找 — 无需打包。", "第 1 阶段:检测 SteamVR 是否运行以及是否插入了 HMD是则绿色否则红色。电池 + 每个设备的信息将在第 1.5 阶段通过单独的子进程提供,以增强对 OpenVR 原生崩溃的鲁棒性。",
"อ่านแบตเตอรี่ + การชาร์จ + การติดตามผ่าน OpenVR ต้อง SteamVR ทำงาน รันไทม์ค้นหาผ่านสำเนาที่ติดตั้งของ SteamVR — ไม่ต้องบรรจุเอง", "เฟส 1: ตรวจจับว่า SteamVR กำลังทำงานและมี HMD เชื่อมต่ออยู่หรือไม่ (เขียวถ้าใช่ แดงถ้าไม่) ข้อมูลแบตเตอรี่ + ต่ออุปกรณ์จะมาในเฟส 1.5 ผ่านโปรเซสลูกแยกเพื่อความทนทานต่อข้อผิดพลาดเนทีฟ OpenVR",
"يقرأ البطارية + الشحن + التتبع عبر OpenVR. يجب تشغيل SteamVR. يتم العثور على وقت التشغيل من خلال نسخة SteamVR المثبتة — لا حاجة للتجميع." "المرحلة 1: تكتشف تشغيل SteamVR مع توصيل HMD (أخضر إذا نعم، أحمر خلاف ذلك). البطارية + المعلومات لكل جهاز ستأتي في المرحلة 1.5 عبر عملية فرعية منفصلة لتكون متينة ضد الأعطال الأصلية OpenVR."
); );
public static string HealthEditorTargetProcess => T("CIBLE — NOM DE PROCESSUS", "TARGET — PROCESS NAME", "目标 — 进程名", "เป้าหมาย — ชื่อโปรเซส", "الهدف — اسم العملية"); 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 أو اسم المضيف"); public static string HealthEditorTargetPing => T("CIBLE — IP OU HOSTNAME", "TARGET — IP OR HOSTNAME", "目标 — IP 或主机名", "เป้าหมาย — IP หรือชื่อโฮสต์", "الهدف — IP أو اسم المضيف");