From 106fa610c0ff598a62636e1e2e37298b96ba9fb1 Mon Sep 17 00:00:00 2001 From: Cyd Date: Sat, 11 Jul 2026 21:26:38 -0500 Subject: [PATCH] vPOD: real system volume + pre-uninstall.bat, behind the existing opt-ins MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two launcher behaviors vPOD only simulated are now available for real, matching the pod exactly: - "Actually set system volume": set_VolumeLevel drives this machine's master volume through the launcher's own chain — nircmd.exe in the games root, else Core Audio (Vista+), else winmm. The chain moved out of TeslaLauncher.cs into Launcher/VolumeControl.cs and is compiled into both apps as linked source (the MiniZip pattern); launcher behavior is unchanged. Off by default: the value is stored/echoed only, as before. - pre-uninstall.bat now runs before the product directory is deleted on UninstallApp (working dir, hidden window, 120 s wait, exit code logged — mirrors CleanupProductDirectory). Gated behind the renamed "Run package install/uninstall scripts" checkbox (was "Run postinstall.bat after install"; RunPostInstall -> RunPackageScripts), closing the asymmetry where install scripts had an opt-in but uninstall scripts silently never ran. Verified: 106/106 diff tests; live master-volume set/restore through vPOD's build of VolumeControl; functional probe of UninstallApp against an isolated games root with the flag off (script skipped, dir removed) and on (script ran, then dir removed). Co-Authored-By: Claude Fable 5 --- Launcher/TeslaLauncher.cs | 115 ++---------------------------- Launcher/VolumeControl.cs | 142 ++++++++++++++++++++++++++++++++++++++ vPOD/LauncherRpcServer.cs | 11 +-- vPOD/VPodForm.cs | 45 ++++++++---- vPOD/VirtualLauncher.cs | 78 +++++++++++++++++++-- vPOD/vPOD.csproj | 3 + 6 files changed, 260 insertions(+), 134 deletions(-) create mode 100644 Launcher/VolumeControl.cs diff --git a/Launcher/TeslaLauncher.cs b/Launcher/TeslaLauncher.cs index f1e471b..c8a4d69 100644 --- a/Launcher/TeslaLauncher.cs +++ b/Launcher/TeslaLauncher.cs @@ -938,10 +938,8 @@ namespace Tesla.Launcher // ── Volume control ──────────────────────────────────────────────────── // The Console stores/retrieves volume as a float (0.0–1.0 scalar). We // cache the exact value the Console sent so get returns the same value - // without device roundtrip quantization error. - // - // Setter chain: nircmd.exe (legacy, works everywhere incl. XP) - // → CoreAudio (Vista+) → winmm waveOutSetVolume (XP fallback). + // without device roundtrip quantization error. The device chain + // (nircmd → CoreAudio → winmm) lives in VolumeControl.cs, shared with vPOD. private float _cachedVolumeScalar = 1.0f; @@ -953,26 +951,7 @@ namespace Tesla.Launcher private void SetMasterVolume(float scalar) { _cachedVolumeScalar = scalar; - - var nircmd = Path.Combine(GAMES_DIR, "nircmd.exe"); - if (File.Exists(nircmd)) - { - try - { - var p = Process.Start(nircmd, "setsysvolume " + (int)(scalar * 65535)); - if (p != null) p.Dispose(); - return; - } - catch { /* fall through to API */ } - } - - if (Environment.OSVersion.Version.Major >= 6) - { - try { CoreAudio.SetMasterScalar(scalar); return; } - catch { /* fall through */ } - } - - WinMmVolume.SetMasterScalar(scalar); + VolumeControl.SetMasterScalar(scalar, GAMES_DIR); } // ── Session key ─────────────────────────────────────────────────────── @@ -1201,91 +1180,5 @@ namespace Tesla.Launcher public bool AutoRestart { get; set; } } - // ── Windows Core Audio API — minimal COM interop (Vista+) ───────────────── - // No external dependencies. Vtable slot order matches the SDK headers exactly. - // Methods we don't call are declared as void stubs to preserve vtable offsets. - // NOT available on XP — callers must gate on OS version. - - internal static class CoreAudio - { - internal static void SetMasterScalar(float scalar) - { - var ep = GetEndpointVolume(); - try { var ctx = Guid.Empty; ep.SetMasterVolumeLevelScalar(scalar, ref ctx); } - finally { Marshal.ReleaseComObject(ep); } - } - - private static IAudioEndpointVolume GetEndpointVolume() - { - var enumerator = (IMMDeviceEnumerator)new MMAudioEnumeratorComClass(); - try - { - IMMDevice device; - enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out device); - try - { - var iid = typeof(IAudioEndpointVolume).GUID; - object obj; - device.Activate(ref iid, 23 /*CLSCTX_ALL*/, IntPtr.Zero, out obj); - return (IAudioEndpointVolume)obj; - } - finally { Marshal.ReleaseComObject(device); } - } - finally { Marshal.ReleaseComObject(enumerator); } - } - } - - [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] - internal class MMAudioEnumeratorComClass { } - - [Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), - InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - internal interface IMMDeviceEnumerator - { - void _unused_EnumAudioEndpoints(); // slot 0 — not used - [PreserveSig] - int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint); - } - - [Guid("D666063F-1587-4E43-81F1-B948E807363F"), - InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - internal interface IMMDevice - { - [PreserveSig] - int Activate(ref Guid iid, int clsCtx, IntPtr pActivationParams, - [MarshalAs(UnmanagedType.IUnknown)] out object interfacePointer); - } - - // Vtable order (after IUnknown): RegisterControlChangeNotify(0), - // UnregisterControlChangeNotify(1), GetChannelCount(2), - // SetMasterVolumeLevel(3), SetMasterVolumeLevelScalar(4), - // GetMasterVolumeLevel(5), GetMasterVolumeLevelScalar(6) - [Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), - InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] - internal interface IAudioEndpointVolume - { - void _unused_RegisterControlChangeNotify(); // slot 0 - void _unused_UnregisterControlChangeNotify(); // slot 1 - void _unused_GetChannelCount(); // slot 2 - [PreserveSig] int SetMasterVolumeLevel(float levelDB, ref Guid ctx); - [PreserveSig] int SetMasterVolumeLevelScalar(float level, ref Guid ctx); - [PreserveSig] int GetMasterVolumeLevel(out float levelDB); - [PreserveSig] int GetMasterVolumeLevelScalar(out float level); - } - - // ── winmm wave-out volume (XP fallback) ─────────────────────────────────── - // waveOutSetVolume with device -1 sets the wave mixer level of the default - // device: low 16 bits = left channel, high 16 bits = right channel. - - internal static class WinMmVolume - { - [DllImport("winmm.dll")] - private static extern int waveOutSetVolume(IntPtr hwo, uint dwVolume); - - internal static void SetMasterScalar(float scalar) - { - uint level = (uint)(Math.Max(0f, Math.Min(1f, scalar)) * 0xFFFF); - try { waveOutSetVolume(IntPtr.Zero, (level << 16) | level); } catch { } - } - } + // (Core Audio / winmm volume interop moved to VolumeControl.cs — shared with vPOD.) } diff --git a/Launcher/VolumeControl.cs b/Launcher/VolumeControl.cs new file mode 100644 index 0000000..1169977 --- /dev/null +++ b/Launcher/VolumeControl.cs @@ -0,0 +1,142 @@ +// ============================================================================= +// TeslaLauncher — system master volume (shared with vPOD) +// ============================================================================= +// The Console's set_VolumeLevel ends here. Setter chain: +// nircmd.exe (legacy, works everywhere incl. XP, if present in the games dir) +// → Core Audio (Vista+) → winmm waveOutSetVolume (XP fallback). +// +// Like MiniZip.cs, this file is compiled into BOTH the launcher and vPOD +// (linked source): vPOD's "Actually set system volume" mode applies the +// console's volume commands through the exact code the real pod runs. +// ============================================================================= + +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; + +namespace Tesla.Launcher +{ + internal static class VolumeControl + { + /// Sets the system master volume to + /// (clamped to 0.0–1.0). is probed for + /// nircmd.exe first (the legacy path used on the original pods); the + /// Windows APIs are the fallback. Never throws. + public static void SetMasterScalar(float scalar, string nircmdDir) + { + scalar = Math.Max(0f, Math.Min(1f, scalar)); + + if (!string.IsNullOrEmpty(nircmdDir)) + { + var nircmd = Path.Combine(nircmdDir, "nircmd.exe"); + if (File.Exists(nircmd)) + { + try + { + var p = Process.Start(nircmd, "setsysvolume " + (int)(scalar * 65535)); + if (p != null) p.Dispose(); + return; + } + catch { /* fall through to API */ } + } + } + + if (Environment.OSVersion.Version.Major >= 6) + { + try { CoreAudio.SetMasterScalar(scalar); return; } + catch { /* fall through */ } + } + + WinMmVolume.SetMasterScalar(scalar); + } + } + + // ── Windows Core Audio API — minimal COM interop (Vista+) ───────────────── + // No external dependencies. Vtable slot order matches the SDK headers exactly. + // Methods we don't call are declared as void stubs to preserve vtable offsets. + // NOT available on XP — callers must gate on OS version. + + internal static class CoreAudio + { + internal static void SetMasterScalar(float scalar) + { + var ep = GetEndpointVolume(); + try { var ctx = Guid.Empty; ep.SetMasterVolumeLevelScalar(scalar, ref ctx); } + finally { Marshal.ReleaseComObject(ep); } + } + + private static IAudioEndpointVolume GetEndpointVolume() + { + var enumerator = (IMMDeviceEnumerator)new MMAudioEnumeratorComClass(); + try + { + IMMDevice device; + enumerator.GetDefaultAudioEndpoint(0 /*eRender*/, 1 /*eMultimedia*/, out device); + try + { + var iid = typeof(IAudioEndpointVolume).GUID; + object obj; + device.Activate(ref iid, 23 /*CLSCTX_ALL*/, IntPtr.Zero, out obj); + return (IAudioEndpointVolume)obj; + } + finally { Marshal.ReleaseComObject(device); } + } + finally { Marshal.ReleaseComObject(enumerator); } + } + } + + [ComImport, Guid("BCDE0395-E52F-467C-8E3D-C4579291692E")] + internal class MMAudioEnumeratorComClass { } + + [Guid("A95664D2-9614-4F35-A746-DE8DB63617E6"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface IMMDeviceEnumerator + { + void _unused_EnumAudioEndpoints(); // slot 0 — not used + [PreserveSig] + int GetDefaultAudioEndpoint(int dataFlow, int role, out IMMDevice endpoint); + } + + [Guid("D666063F-1587-4E43-81F1-B948E807363F"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface IMMDevice + { + [PreserveSig] + int Activate(ref Guid iid, int clsCtx, IntPtr pActivationParams, + [MarshalAs(UnmanagedType.IUnknown)] out object interfacePointer); + } + + // Vtable order (after IUnknown): RegisterControlChangeNotify(0), + // UnregisterControlChangeNotify(1), GetChannelCount(2), + // SetMasterVolumeLevel(3), SetMasterVolumeLevelScalar(4), + // GetMasterVolumeLevel(5), GetMasterVolumeLevelScalar(6) + [Guid("5CDF2C82-841E-4546-9722-0CF74078229A"), + InterfaceType(ComInterfaceType.InterfaceIsIUnknown)] + internal interface IAudioEndpointVolume + { + void _unused_RegisterControlChangeNotify(); // slot 0 + void _unused_UnregisterControlChangeNotify(); // slot 1 + void _unused_GetChannelCount(); // slot 2 + [PreserveSig] int SetMasterVolumeLevel(float levelDB, ref Guid ctx); + [PreserveSig] int SetMasterVolumeLevelScalar(float level, ref Guid ctx); + [PreserveSig] int GetMasterVolumeLevel(out float levelDB); + [PreserveSig] int GetMasterVolumeLevelScalar(out float level); + } + + // ── winmm wave-out volume (XP fallback) ─────────────────────────────────── + // waveOutSetVolume with device -1 sets the wave mixer level of the default + // device: low 16 bits = left channel, high 16 bits = right channel. + + internal static class WinMmVolume + { + [DllImport("winmm.dll")] + private static extern int waveOutSetVolume(IntPtr hwo, uint dwVolume); + + internal static void SetMasterScalar(float scalar) + { + uint level = (uint)(Math.Max(0f, Math.Min(1f, scalar)) * 0xFFFF); + try { waveOutSetVolume(IntPtr.Zero, (level << 16) | level); } catch { } + } + } +} diff --git a/vPOD/LauncherRpcServer.cs b/vPOD/LauncherRpcServer.cs index 518ece9..d3e005b 100644 --- a/vPOD/LauncherRpcServer.cs +++ b/vPOD/LauncherRpcServer.cs @@ -29,9 +29,10 @@ namespace VPod; /// - Install completion reports 99% (not 100) — the console's /// InstallProductWorker breaks its retry loop only on 99. /// -/// All state lives in ; a packaged postinstall.bat -/// is logged and removed unrun unless -/// is set from the vPOD window, in which case it is executed like the real service. +/// All state lives in ; packaged product scripts +/// (postinstall.bat here, pre-uninstall.bat in UninstallApp) are logged and +/// removed unrun unless is set +/// from the vPOD window, in which case they run like on the real pod. /// internal sealed class LauncherRpcServer { @@ -350,13 +351,13 @@ internal sealed class LauncherRpcServer Log?.Invoke($"Install {callId:N}: extracted to {gamesRoot}"); // The real service runs (then deletes) a packaged postinstall.bat here. - // vPOD only does so when the operator opts in via RunPostInstall; + // vPOD only does so when the operator opts in via RunPackageScripts; // otherwise the script is logged and removed unrun (default), since it // runs package code on the host machine. string postInstall = Path.Combine(gamesRoot, "postinstall.bat"); if (File.Exists(postInstall)) { - if (mLauncher.RunPostInstall) + if (mLauncher.RunPackageScripts) { mLauncher.UpdateProgress(callId, 96, "Running postinstall..."); Log?.Invoke($"Install {callId:N}: running postinstall.bat..."); diff --git a/vPOD/VPodForm.cs b/vPOD/VPodForm.cs index 802c2bd..516bcdb 100644 --- a/vPOD/VPodForm.cs +++ b/vPOD/VPodForm.cs @@ -55,9 +55,10 @@ internal sealed class VPodForm : Form private Label mInstallStatusLabel; private ProgressBar mInstallProgressBar; private Button mReprovisionButton; - private CheckBox mRunPostInstallCheckbox; + private CheckBox mRunScriptsCheckbox; private CheckBox mRealLaunchCheckbox; private CheckBox mRealAutoRestartCheckbox; + private CheckBox mRealVolumeCheckbox; private ListView mAppsView; private bool mPoweredOn; @@ -283,7 +284,7 @@ internal sealed class VPodForm : Form Padding = new Padding(8) }; - Panel siteTop = new Panel { Dock = DockStyle.Top, Height = 278 }; + Panel siteTop = new Panel { Dock = DockStyle.Top, Height = 300 }; mProvisionStatusLabel = new Label { @@ -354,21 +355,22 @@ internal sealed class VPodForm : Form }; mReprovisionButton.Click += ReprovisionClicked; - // Off (default) = a packaged postinstall.bat is logged and removed unrun; - // on = it is executed at the end of an install, like the real Agent. - mRunPostInstallCheckbox = new CheckBox + // Off (default) = packaged product scripts (postinstall.bat on install, + // pre-uninstall.bat on uninstall) are logged and removed unrun; on = + // they are executed like on the real pod. + mRunScriptsCheckbox = new CheckBox { - Text = "Run postinstall.bat after install", + Text = "Run package install/uninstall scripts", Location = new Point(4, 206), AutoSize = true, Checked = false }; - mRunPostInstallCheckbox.CheckedChanged += (s, e) => + mRunScriptsCheckbox.CheckedChanged += (s, e) => { - mLauncher.RunPostInstall = mRunPostInstallCheckbox.Checked; - OnSiteLog(mRunPostInstallCheckbox.Checked - ? "Installs now EXECUTE a packaged postinstall.bat." - : "Installs skip (and remove) a packaged postinstall.bat."); + mLauncher.RunPackageScripts = mRunScriptsCheckbox.Checked; + OnSiteLog(mRunScriptsCheckbox.Checked + ? "Packaged postinstall.bat / pre-uninstall.bat now EXECUTE." + : "Packaged postinstall.bat / pre-uninstall.bat are skipped (and removed)."); }; // Off = LaunchApp records simulated PIDs; on = start/kill real processes @@ -408,6 +410,24 @@ internal sealed class VPodForm : Form : "Watchdog OFF: exited apps stay down."); }; + // Off (default) = set_VolumeLevel only stores/echoes the float; on = it + // drives this machine's real master volume through the launcher's own + // chain (nircmd in the games root -> Core Audio -> winmm). + mRealVolumeCheckbox = new CheckBox + { + Text = "Actually set system volume", + Location = new Point(4, 272), + AutoSize = true, + Checked = false + }; + mRealVolumeCheckbox.CheckedChanged += (s, e) => + { + mLauncher.RealVolume = mRealVolumeCheckbox.Checked; + OnSiteLog(mRealVolumeCheckbox.Checked + ? "set_VolumeLevel now changes the REAL system master volume." + : "set_VolumeLevel now only stores the value (no audio change)."); + }; + siteTop.Controls.Add(mProvisionStatusLabel); siteTop.Controls.Add(mPassphrasePanel); siteTop.Controls.Add(mNetConfigLabel); @@ -415,9 +435,10 @@ internal sealed class VPodForm : Form siteTop.Controls.Add(mInstallStatusLabel); siteTop.Controls.Add(mInstallProgressBar); siteTop.Controls.Add(mReprovisionButton); - siteTop.Controls.Add(mRunPostInstallCheckbox); + siteTop.Controls.Add(mRunScriptsCheckbox); siteTop.Controls.Add(mRealLaunchCheckbox); siteTop.Controls.Add(mRealAutoRestartCheckbox); + siteTop.Controls.Add(mRealVolumeCheckbox); mAppsView = new ListView { diff --git a/vPOD/VirtualLauncher.cs b/vPOD/VirtualLauncher.cs index 0c5e4de..b1d450a 100644 --- a/vPOD/VirtualLauncher.cs +++ b/vPOD/VirtualLauncher.cs @@ -6,6 +6,7 @@ using System.Threading; // No System.Text.Json on net40 — persistence goes through Newtonsoft, which // writes the same JSON shape (public fields, PascalCase; see PodRpcProtocol). using Newtonsoft.Json; +using Tesla.Launcher; using Tesla.Net; namespace VPod; @@ -53,11 +54,20 @@ internal sealed class VirtualLauncher /// exactly like the real pod. Toggled from the vPOD window. public bool RealAutoRestart { get; set; } = true; - /// When set, a packaged postinstall.bat is executed at the end of a - /// product install (like the real Agent) before being deleted, instead of - /// being logged and removed unrun. Toggled from the vPOD window; off by - /// default, since it runs package script code on the host machine. - public bool RunPostInstall { get; set; } + /// When set, packaged product scripts are executed like on the real + /// pod: postinstall.bat at the end of a product install (before being + /// deleted), and pre-uninstall.bat before the product directory is removed + /// on uninstall. Off by default — both run package script code on the host + /// machine — in which case they are logged and removed unrun. Toggled from + /// the vPOD window. + public bool RunPackageScripts { get; set; } + + /// When set, the console's set_VolumeLevel changes this machine's + /// REAL master volume through the launcher's own chain (nircmd.exe in the + /// games root → Core Audio → winmm), exactly like the real pod. Off by + /// default: the value is only stored and displayed. Toggled from the vPOD + /// window. + public bool RealVolume { get; set; } // Bumped to cancel watchdog restarts pending in their 2 s delay — the pod // "machine" went dark (power off / reboot / reprovision), so nothing may @@ -140,7 +150,17 @@ internal sealed class VirtualLauncher set { lock (mLock) { mVolumeLevel = value; } - Log?.Invoke($"Volume set to {value:P0}."); + if (RealVolume) + { + // The launcher's own device chain; probes GamesRoot for + // nircmd.exe first, like the real pod's GAMES_DIR. + VolumeControl.SetMasterScalar(value, GamesRoot); + Log?.Invoke($"Volume set to {value:P0} (applied to system)."); + } + else + { + Log?.Invoke($"Volume set to {value:P0} (simulated)."); + } } } @@ -235,6 +255,7 @@ internal sealed class VirtualLauncher { if (Directory.Exists(cleanupDir)) { + RunPreUninstallScript(cleanupDir); Directory.Delete(cleanupDir, recursive: true); Log?.Invoke($"UninstallApp: removed product directory {cleanupDir}"); } @@ -248,6 +269,51 @@ internal sealed class VirtualLauncher AppsChanged?.Invoke(); } + /// The real Agent's pre-uninstall hook: a product may ship a + /// pre-uninstall.bat (e.g. RIOJoy removes its driver) that runs before its + /// directory is deleted. Executed only when + /// is opted in — otherwise logged and removed unrun (it dies with the + /// directory). Mirrors the launcher's CleanupProductDirectory. + private void RunPreUninstallScript(string productDir) + { + string preUninstall = Path.Combine(productDir, "pre-uninstall.bat"); + if (!File.Exists(preUninstall)) + { + return; + } + if (!RunPackageScripts) + { + Log?.Invoke("UninstallApp: pre-uninstall.bat present — NOT executed (vPOD), removed with the directory."); + return; + } + Log?.Invoke("UninstallApp: running pre-uninstall.bat..."); + try + { + ProcessStartInfo psi = new ProcessStartInfo + { + FileName = preUninstall, + WorkingDirectory = productDir, + UseShellExecute = false, + CreateNoWindow = true + }; + using (Process proc = Process.Start(psi)) + { + if (proc != null && proc.WaitForExit(120000)) + { + Log?.Invoke($"UninstallApp: pre-uninstall.bat exited with code {proc.ExitCode}."); + } + else if (proc != null) + { + Log?.Invoke("UninstallApp: pre-uninstall.bat still running after 2 min — continuing."); + } + } + } + catch (Exception ex) + { + Log?.Invoke($"UninstallApp: pre-uninstall.bat failed to run: {ex.Message}"); + } + } + public void RemoveApp(Guid launchKey) { lock (mLock) diff --git a/vPOD/vPOD.csproj b/vPOD/vPOD.csproj index b0230b2..22288d1 100644 --- a/vPOD/vPOD.csproj +++ b/vPOD/vPOD.csproj @@ -58,6 +58,9 @@ behavior to the real pod service, and the differential suite's install round-trip exercises MiniZip against real ZipArchive-built archives. --> + +