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. -->
+
+