Deploy: pod install scaffold -- supervisor entry-point, postinstall, packaging

Network-agnostic, air-gapped pod install for the two DOSBox titles (BT/RP 4.10),
fitting the existing TeslaConsole/TeslaLauncher pod-bay architecture.

emulator/DEPLOYMENT-PLAN.md
  Full design: bridge on the one NIC (pods form a source-proven P2P TCP mesh, so
  NAT/slirp is out); launcher keeps the bay IP, the DOSBox guest bridges at
  bayIP+100 (egg/mesh/game endpoint); two-edit +100 convention (postinstall +
  console DOSBox flag); static, air-gapped, <=32 pods.

emulator/pod-launch/  (C# net8 supervising entry-point)
  Creates a Job Object (KILL_ON_JOB_CLOSE), launches DOSBox-X + the render bridge
  into it and blocks -- kill this process and both die (kernel-enforced, even on
  a hard TerminateProcess of a hung session; verified). Mode dispatch: bt/rp
  wired (also serve camera + live mission-review via egg hostType); review +
  diagnostics recognized but fail clean until their DOS launch args are known.

emulator/deploy/  (install side)
  postinstall.bat (thin elevated wrapper) + configure.ps1 (NIC detect, realnic
  bind as a contiguous letter-leading GUID fragment, game IP = bayIP+100, stable
  MAC, render net_*.conf templates, stamp WATTCP.CFG my_ip) + tokenized conf
  templates + package.ps1 (assemble the zip). Render/bind/stamp + packaging
  verified against a scratch tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-10 10:36:30 -05:00
co-authored by Claude Opus 4.8
parent d1635d4694
commit 9df87f2c01
16 changed files with 1426 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
bin/
obj/
+53
View File
@@ -0,0 +1,53 @@
// Launch a child process and put it in the job immediately, redirecting its
// stdout/stderr to log files (matches the pod_out/err.txt + bridge_out/err.txt
// the old launch_pod.ps1 produced).
//
// The assign happens right after Start(), before DOSBox/pythonw has a chance to
// spawn anything, so the whole tree is captured. (A CREATE_SUSPENDED launch
// would close even that microscopic gap; neither of these children forks at
// startup, so Start()+assign is sufficient here.)
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
namespace VwePod
{
internal static class ChildProcess
{
public static Process StartInJob(
JobObject job, string exe, string args,
IDictionary<string, string> extraEnv,
string outPath, string errPath, string workingDir)
{
var psi = new ProcessStartInfo
{
FileName = exe,
Arguments = args,
UseShellExecute = false, // inherit our env; allow job assign
CreateNoWindow = false, // children make their own windows
RedirectStandardOutput = true,
RedirectStandardError = true,
};
if (!string.IsNullOrEmpty(workingDir)) psi.WorkingDirectory = workingDir;
if (extraEnv != null)
foreach (var kv in extraEnv) psi.Environment[kv.Key] = kv.Value;
var proc = new Process { StartInfo = psi };
var outWriter = new StreamWriter(outPath, false) { AutoFlush = true };
var errWriter = new StreamWriter(errPath, false) { AutoFlush = true };
proc.OutputDataReceived += (s, e) => { if (e.Data != null) lock (outWriter) outWriter.WriteLine(e.Data); };
proc.ErrorDataReceived += (s, e) => { if (e.Data != null) lock (errWriter) errWriter.WriteLine(e.Data); };
proc.Exited += (s, e) => { try { outWriter.Dispose(); errWriter.Dispose(); } catch { } };
proc.EnableRaisingEvents = true;
proc.Start();
job.Add(proc.Handle); // capture before it can spawn a child
proc.BeginOutputReadLine();
proc.BeginErrorReadLine();
return proc;
}
}
}
+89
View File
@@ -0,0 +1,89 @@
// Window layout, folded in from focus_dosbox.ps1:
// 1. the RENDERER (GL bridge) -> always-on-top, so the out-the-window view is
// never covered by a head window.
// 2. DOSBox-X -> parked at (x,y) with FOREGROUND focus, so its emulation
// thread keeps foreground priority -- else the RIO ACK deadline is missed
// and the board storms (the RIO focus-sensitivity issue).
// A topmost renderer stays visually above the focused-but-non-topmost DOSBox,
// yet DOSBox stays the active window.
using System;
using System.Runtime.InteropServices;
using System.Text;
namespace VwePod
{
internal static class Focus
{
public static string Apply(int x, int y)
{
var s = new StringBuilder();
// 1) renderer -> topmost
IntPtr r = FindRenderer();
if (r != IntPtr.Zero)
{
SetWindowPos(r, HWND_TOPMOST, 0, 0, 0, 0, SWP_NOMOVE | SWP_NOSIZE);
s.Append("renderer topmost; ");
}
else s.Append("renderer NOT found; ");
// 2) DOSBox -> parked + foreground
IntPtr d = FindWindow("DOSBox-X");
if (d == IntPtr.Zero) return s.Append("DOSBox NOT found").ToString();
SetWindowPos(d, IntPtr.Zero, x, y, 0, 0, SWP_NOSIZE | SWP_NOZORDER);
ShowWindow(d, SW_RESTORE);
uint fgThread = GetWindowThreadProcessId(GetForegroundWindow(), out _);
uint me = GetCurrentThreadId();
AttachThreadInput(me, fgThread, true);
BringWindowToTop(d);
SetForegroundWindow(d);
AttachThreadInput(me, fgThread, false);
s.Append("DOSBox at ").Append(x).Append(',').Append(y)
.Append(" focused=").Append(GetForegroundWindow() == d);
return s.ToString();
}
private static IntPtr FindRenderer()
{
IntPtr h = FindWindow("dpl3-revive renderer");
return h != IntPtr.Zero ? h : FindWindow("VelociRender");
}
private static IntPtr FindWindow(string needle)
{
IntPtr found = IntPtr.Zero;
EnumWindows((h, l) =>
{
if (!IsWindowVisible(h)) return true;
var b = new byte[512];
int n = GetWindowTextA(h, b, b.Length);
if (n > 0 && Encoding.ASCII.GetString(b, 0, n).Contains(needle)) { found = h; return false; }
return true;
}, IntPtr.Zero);
return found;
}
// ---- native ----
private static readonly IntPtr HWND_TOPMOST = new IntPtr(-1);
private const uint SWP_NOSIZE = 0x0001, SWP_NOMOVE = 0x0002, SWP_NOZORDER = 0x0004;
private const int SW_RESTORE = 9;
private delegate bool EnumProc(IntPtr h, IntPtr l);
[DllImport("user32.dll")] private static extern bool EnumWindows(EnumProc cb, IntPtr l);
[DllImport("user32.dll")] private static extern int GetWindowTextA(IntPtr h, byte[] b, int max);
[DllImport("user32.dll")] private static extern bool IsWindowVisible(IntPtr h);
[DllImport("user32.dll")] private static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int w, int hh, uint flags);
[DllImport("user32.dll")] private static extern bool SetForegroundWindow(IntPtr h);
[DllImport("user32.dll")] private static extern bool BringWindowToTop(IntPtr h);
[DllImport("user32.dll")] private static extern bool ShowWindow(IntPtr h, int cmd);
[DllImport("user32.dll")] private static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(IntPtr h, out uint pid);
[DllImport("user32.dll")] private static extern bool AttachThreadInput(uint a, uint b, bool attach);
[DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId();
}
}
+109
View File
@@ -0,0 +1,109 @@
// A Windows Job Object configured to KILL every process in it the instant the
// job's last handle closes. This process holds that handle for the whole
// session, so when it dies -- gracefully, on Ctrl-C, or on a hard
// TerminateProcess by TeslaLauncher -- DOSBox and the render bridge die too.
// Kernel-enforced; no cooperative shutdown required (the hung-DOSBox case).
using System;
using System.Runtime.InteropServices;
namespace VwePod
{
internal sealed class JobObject : IDisposable
{
private IntPtr _handle;
private bool _disposed;
public JobObject()
{
_handle = CreateJobObject(IntPtr.Zero, null);
if (_handle == IntPtr.Zero)
throw new InvalidOperationException("CreateJobObject failed: " + Marshal.GetLastWin32Error());
var ext = new JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
BasicLimitInformation = new JOBOBJECT_BASIC_LIMIT_INFORMATION
{
LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
}
};
int len = Marshal.SizeOf(ext);
IntPtr p = Marshal.AllocHGlobal(len);
try
{
Marshal.StructureToPtr(ext, p, false);
if (!SetInformationJobObject(_handle, JobObjectExtendedLimitInformation, p, (uint)len))
throw new InvalidOperationException("SetInformationJobObject failed: " + Marshal.GetLastWin32Error());
}
finally { Marshal.FreeHGlobal(p); }
}
// Assign a process to the job. Any process the job member later spawns is
// automatically in the job too, so a whole tree is captured.
public void Add(IntPtr processHandle)
{
if (!AssignProcessToJobObject(_handle, processHandle))
throw new InvalidOperationException("AssignProcessToJobObject failed: " + Marshal.GetLastWin32Error());
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_handle != IntPtr.Zero)
{
CloseHandle(_handle); // last handle -> KILL_ON_JOB_CLOSE fires
_handle = IntPtr.Zero;
}
}
// ---- native ----
private const int JobObjectExtendedLimitInformation = 9;
private const uint JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x2000;
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_BASIC_LIMIT_INFORMATION
{
public long PerProcessUserTimeLimit;
public long PerJobUserTimeLimit;
public uint LimitFlags;
public UIntPtr MinimumWorkingSetSize;
public UIntPtr MaximumWorkingSetSize;
public uint ActiveProcessLimit;
public UIntPtr Affinity;
public uint PriorityClass;
public uint SchedulingClass;
}
[StructLayout(LayoutKind.Sequential)]
private struct IO_COUNTERS
{
public ulong ReadOperationCount, WriteOperationCount, OtherOperationCount;
public ulong ReadTransferCount, WriteTransferCount, OtherTransferCount;
}
[StructLayout(LayoutKind.Sequential)]
private struct JOBOBJECT_EXTENDED_LIMIT_INFORMATION
{
public JOBOBJECT_BASIC_LIMIT_INFORMATION BasicLimitInformation;
public IO_COUNTERS IoInfo;
public UIntPtr ProcessMemoryLimit;
public UIntPtr JobMemoryLimit;
public UIntPtr PeakProcessMemoryUsed;
public UIntPtr PeakJobMemoryUsed;
}
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr CreateJobObject(IntPtr lpJobAttributes, string lpName);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool SetInformationJobObject(IntPtr hJob, int infoClass, IntPtr lpInfo, uint cbInfoLength);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool AssignProcessToJobObject(IntPtr hJob, IntPtr hProcess);
[DllImport("kernel32.dll", SetLastError = true)]
private static extern bool CloseHandle(IntPtr hObject);
}
}
+65
View File
@@ -0,0 +1,65 @@
// Mode dispatch -- the console's ExitCodeID vocabulary -> how the emulator
// launches it.
//
// KEY (see ../CAMERA-REVIEW-NOTES.md): cockpit, Live-Cam, and LIVE mission-review
// are the SAME networked boot -- the console assigns the role per-IP via the
// egg's hostType= (0 pod / 1 camera / 2 review), so they are NOT separate launch
// modes. `bt` and `rp` cover all three. The only genuinely distinct launches are
// review PLAYBACK (replays last.spl through the non-networked playback app) and
// the DOS diagnostics; their launch mechanics aren't in the archive, so they're
// recognized here but marked unsupported (fail clean, don't guess).
using System;
using System.Text;
namespace VwePod
{
internal sealed class Mode
{
public string Name;
public string ConfBase; // rendered conf basename (no ext); null if not a conf-launch
public bool Supported;
public string Note;
}
internal static class Modes
{
private static readonly Mode[] All =
{
new Mode { Name = "bt", ConfBase = "net_loop", Supported = true,
Note = "BattleTech, networked cockpit. Also serves the camera ship and LIVE mission-review -- the console sets the role per-IP via the egg hostType, so no separate mode is needed." },
new Mode { Name = "rp", ConfBase = "net_rp", Supported = true,
Note = "Red Planet, networked cockpit (also camera / live mission-review via egg hostType)." },
// Distinct launches, mechanics not yet wired on the emulator:
new Mode { Name = "review", ConfBase = null, Supported = false,
Note = "Mission-review PLAYBACK: replays last.spl through the non-networked playback app (BTL4PlaybackApplication). The DOS launch arg for playback mode is not in the archive -- confirm before wiring." },
new Mode { Name = "test-plasma", ConfBase = null, Supported = false,
Note = "TestPlasmaDisplay diagnostic -- DOS launch mechanic not yet wired." },
new Mode { Name = "reset-rio", ConfBase = null, Supported = false,
Note = "ResetRIO diagnostic -- not yet wired." },
new Mode { Name = "audio-test", ConfBase = null, Supported = false,
Note = "RunAudioTest diagnostic -- not yet wired." },
};
public static Mode Find(string name)
{
if (name == null) return null;
foreach (var m in All)
if (string.Equals(m.Name, name, StringComparison.OrdinalIgnoreCase)) return m;
return null;
}
public static string Names()
{
var sb = new StringBuilder();
foreach (var m in All)
{
if (sb.Length > 0) sb.Append(", ");
sb.Append(m.Name);
if (!m.Supported) sb.Append("(unsupported)");
}
return sb.ToString();
}
}
}
+169
View File
@@ -0,0 +1,169 @@
// Argument parsing + path resolution. Paths resolve relative to --root (default:
// the exe's own directory, i.e. the deployed game dir), with dev fallbacks so
// the same exe runs against the emulator/ source tree via `--root <emulator>`.
using System;
using System.Collections.Generic;
using System.IO;
namespace VwePod
{
internal enum LayoutMode { Cockpit, Explode }
internal sealed class Options
{
public Mode Mode;
public LayoutMode Layout = LayoutMode.Cockpit;
public string Root;
public string Work;
public string DosBox;
public string DosBoxDir;
public string Conf;
public string BridgeScript; // dev: live_bridge.py via pyw
public string RendererExe; // deploy: frozen renderer exe (else null)
public string AweRom;
public bool NoBridge;
public bool NoSound;
public bool Mipmap;
public bool NoFocus;
public bool DryRun;
public string BridgePos = "0,0"; // main out-the-window head
public int BridgeW = 800;
public int BridgeH = 600;
public int DosBoxX = 10;
public int DosBoxY = 10;
// Cockpit head rects (deployment variant of pod_deploy.ps1's RECTS).
public string RadarRect = "800,0,640,480"; // VPX_WIN0 radar (HEAD C)
public string Mfd3Rect = "1440,0,640,480"; // VPX_WIN4 3-color MFD (HEAD B)
public string Mfd2Rect = "2080,0,640,480"; // VPX_WIN3 2-color MFD (HEAD A)
public static Options Parse(string[] args)
{
var o = new Options();
string modeName = null, confArg = null, rootArg = null, dosboxArg = null,
bridgeScriptArg = null, rendererArg = null, aweArg = null;
for (int i = 0; i < args.Length; i++)
{
string a = args[i];
string Next(string name)
{
if (i + 1 >= args.Length) throw new ArgumentException(name + " needs a value");
return args[++i];
}
if (!a.StartsWith("-")) { modeName = a; continue; } // positional mode
switch (a)
{
case "--mode": modeName = Next(a); break;
case "--conf": confArg = Next(a); break;
case "--root": rootArg = Next(a); break;
case "--work": o.Work = Next(a); break;
case "--dosbox": dosboxArg = Next(a); break;
case "--bridge-script": bridgeScriptArg = Next(a); break;
case "--renderer": rendererArg = Next(a); break;
case "--awe-rom": aweArg = Next(a); break;
case "--layout": o.Layout = Next(a).Equals("explode", StringComparison.OrdinalIgnoreCase) ? LayoutMode.Explode : LayoutMode.Cockpit; break;
case "--bridge-pos": o.BridgePos = Next(a); break;
case "--bridge-size": ParseWH(Next(a), out o.BridgeW, out o.BridgeH); break;
case "--dosbox-xy": ParseWH(Next(a), out o.DosBoxX, out o.DosBoxY); break;
case "--no-bridge": o.NoBridge = true; break;
case "--no-sound": o.NoSound = true; break;
case "--mipmap": o.Mipmap = true; break;
case "--no-focus": o.NoFocus = true; break;
case "--dry-run": o.DryRun = true; break;
case "-h": case "--help": Usage(); Environment.Exit(0); break;
default: throw new ArgumentException("unknown arg: " + a);
}
}
o.Root = rootArg ?? AppContext.BaseDirectory;
o.Mode = Modes.Find(modeName ?? "bt");
if (o.Mode == null)
throw new ArgumentException("unknown mode '" + modeName + "'; valid: " + Modes.Names());
// Unsupported modes: return with only the Mode set; Program prints the
// reason and exits without touching the launch env.
if (!o.Mode.Supported) return o;
o.DosBox = dosboxArg ?? FirstExisting(
Path.Combine(o.Root, "dosbox-x.exe"),
Path.Combine(o.Root, "src", "src", "dosbox-x.exe"))
?? throw new ArgumentException("dosbox-x.exe not found under root; pass --dosbox");
o.DosBoxDir = Path.GetDirectoryName(o.DosBox);
o.Conf = confArg ?? FirstExisting(Path.Combine(o.Root, o.Mode.ConfBase + ".conf"))
?? throw new ArgumentException(o.Mode.ConfBase + ".conf not found under root; pass --conf");
o.RendererExe = rendererArg ?? FirstExisting(
Path.Combine(o.Root, "renderer.exe"),
Path.Combine(o.Root, "render-bridge", "renderer.exe"));
o.BridgeScript = bridgeScriptArg ?? FirstExisting(
Path.Combine(o.Root, "live_bridge.py"),
Path.Combine(o.Root, "render-bridge", "live_bridge.py"));
if (!o.NoBridge && o.RendererExe == null && o.BridgeScript == null)
throw new ArgumentException("no renderer found; pass --renderer, --bridge-script, or --no-bridge");
o.AweRom = aweArg ?? FirstExisting(
Path.Combine(o.Root, "roms", "awe32.raw"),
Path.Combine(o.Root, "..", "roms", "awe32.raw"));
o.Work = o.Work ?? Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Temp", "vwe-pod");
return o;
}
private static void ParseWH(string s, out int a, out int b)
{
var parts = s.Split(',');
if (parts.Length != 2 || !int.TryParse(parts[0], out a) || !int.TryParse(parts[1], out b))
throw new ArgumentException("expected 'x,y': " + s);
}
private static string FirstExisting(params string[] paths)
{
foreach (var p in paths)
{
if (p == null) continue;
var full = Path.GetFullPath(p);
if (File.Exists(full)) return full;
}
return null;
}
public static void Usage()
{
Console.Error.WriteLine(@"pod-launch [mode] [options]
Supervising entry-point: launches DOSBox-X + the render bridge into a job
object and blocks; killing this process kills them too (no orphans).
mode bt | rp (default bt). bt/rp also serve the camera ship
+ live mission-review roles -- the console picks the
role via the egg hostType, so no separate mode. Also
recognized (not yet wired): review, test-plasma,
reset-rio, audio-test.
--mode <name> same as the positional mode
--conf <path> explicit conf (overrides the mode's conf)
--root <dir> base dir for dosbox/conf/bridge (default: exe dir)
--work <dir> runtime/work dir (default: %LOCALAPPDATA%\Temp\vwe-pod)
--layout cockpit|explode window layout (default cockpit)
--dosbox <exe> explicit dosbox-x.exe
--renderer <exe> frozen renderer exe (else falls back to pyw live_bridge)
--bridge-script <py> live_bridge.py (dev)
--awe-rom <raw> AWE32 SoundFont ROM
--bridge-pos x,y main render window position (default 0,0)
--bridge-size w,h main render window size (default 800,600)
--dosbox-xy x,y DOSBox window position + focus (default 10,10)
--no-bridge pod only, no GL render window
--no-sound skip the ~4-min SoundFont upload
--mipmap VRVIEW_MIPMAP=1 (RP floor shimmer fix; non-authentic)
--no-focus skip the window layout/focus pass
--dry-run resolve paths + print the launch plan, then exit");
}
}
}
+225
View File
@@ -0,0 +1,225 @@
// pod-launch -- supervising launch entry-point for the two DOSBox titles.
//
// Contract (see ../DEPLOYMENT-PLAN.md "Launch dispatch"):
// * TeslaConsole -> TeslaLauncher runs this exe in the game dir with args
// selecting game/mode.
// * It creates a Windows Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
// launches DOSBox-X AND the render bridge INTO the job, then BLOCKS on
// DOSBox. It is the single authoritative handle for the session.
// * Kill this process -> the kernel kills DOSBox + the renderer when the job's
// last handle closes -- holds even on a hard TerminateProcess of a hung
// DOSBox, where no cooperative shutdown can be relied on. No orphans.
// * DOSBox exits on its own (mission end) -> we tear the job down (renderer
// too) and return DOSBox's exit code.
//
// It MUST NOT be a batch/cmd/start/Start-Process hand-off: those decouple from
// the child, which then survives the parent. This process HOLDS the job handle
// and WAITS.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
namespace VwePod
{
internal static class Program
{
private static int Main(string[] args)
{
Options opt;
try { opt = Options.Parse(args); }
catch (Exception ex)
{
Console.Error.WriteLine("pod-launch: " + ex.Message);
Options.Usage();
return 2;
}
if (!opt.Mode.Supported)
{
// Recognized ExitCodeID mode with no emulator launch wired yet.
// Fail clean with the reason instead of guessing a launch.
Console.Error.WriteLine($"pod-launch: mode '{opt.Mode.Name}' is not supported on the emulator yet.");
Console.Error.WriteLine(" " + opt.Mode.Note);
return 3;
}
if (opt.DryRun)
{
Console.WriteLine("pod-launch DRY RUN");
Console.WriteLine($" mode : {opt.Mode.Name}");
Console.WriteLine($" layout : {opt.Layout}");
Console.WriteLine($" root : {opt.Root}");
Console.WriteLine($" dosbox : {opt.DosBox}");
Console.WriteLine($" conf : {opt.Conf}");
Console.WriteLine($" renderer : {(opt.RendererExe ?? "(pyw) " + opt.BridgeScript)}");
Console.WriteLine($" awe-rom : {(opt.AweRom ?? "(none -> sound off)")}");
Console.WriteLine($" work : {opt.Work}");
Console.WriteLine($" bridge : pos={opt.BridgePos} size={opt.BridgeW}x{opt.BridgeH} dosbox@={opt.DosBoxX},{opt.DosBoxY}");
Console.WriteLine($" flags : nobridge={opt.NoBridge} nosound={opt.NoSound} mipmap={opt.Mipmap} nofocus={opt.NoFocus}");
return 0;
}
Directory.CreateDirectory(opt.Work);
// pcap backend needs wpcap.dll; without it the NE2000 comes up dead
// ("NO PACKET DRIVER FOUND" at netnub). Don't trust the caller's PATH.
PrependNpcapToPath();
// Fresh wire artifacts each run (the RIO tap appends across runs).
TryDelete(Path.Combine(opt.Work, "live.fifodump"));
TryDelete(Path.Combine(opt.Work, "riotap.txt"));
ApplySharedEnv(opt); // env DOSBox inherits (VPX_*/VWE_*/layout)
JobObject job = null;
Process dosbox = null;
Process bridge = null;
try
{
job = new JobObject();
// ---- DOSBox-X: the pod. Launched into the job first. ----
string dbArgs = "-conf \"" + opt.Conf + "\"";
dosbox = ChildProcess.StartInJob(
job, opt.DosBox, dbArgs, null,
Path.Combine(opt.Work, "pod_out.txt"),
Path.Combine(opt.Work, "pod_err.txt"),
opt.DosBoxDir);
Console.WriteLine($"pod PID {dosbox.Id} conf={Path.GetFileName(opt.Conf)} work={opt.Work}");
if (!opt.NoSound)
Console.WriteLine("sound ON: boot adds ~4 min for the SoundFont upload");
// ---- Render bridge (Dave's GL): also into the job. ----
if (!opt.NoBridge)
{
var benv = new Dictionary<string, string>
{
// SDL honors the window position at creation. Set it ONLY
// for the bridge -- setting it globally would also move
// DOSBox-X's own SDL window.
["SDL_VIDEO_WINDOW_POS"] = opt.BridgePos,
["BRIDGE_W"] = opt.BridgeW.ToString(),
["BRIDGE_H"] = opt.BridgeH.ToString(),
};
if (opt.Mipmap) benv["VRVIEW_MIPMAP"] = "1";
string brExe, brArgs;
if (opt.RendererExe != null)
{
// Deploy: a frozen renderer exe (renderer packaging is an
// OPEN item -- freeze vs embedded Python).
brExe = opt.RendererExe;
brArgs = "tcp:8621 \"" + Path.Combine(opt.Work, "live.fifodump") + "\"";
}
else
{
// Dev: windowless Python launcher (pyw), NOT py (py opens a
// console that parks over the displays).
brExe = "pyw";
brArgs = "-3.13 \"" + opt.BridgeScript + "\" tcp:8621 \"" +
Path.Combine(opt.Work, "live.fifodump") + "\"";
}
bridge = ChildProcess.StartInJob(
job, brExe, brArgs, benv,
Path.Combine(opt.Work, "bridge_out.txt"),
Path.Combine(opt.Work, "bridge_err.txt"),
null);
Console.WriteLine($"bridge PID {bridge.Id} [GL]");
}
// Ctrl-C / console close: tear the job down (kills children) so an
// interactive stop is as clean as a TeslaLauncher kill. (A hard
// TerminateProcess skips this, but the job still closes with us.)
Console.CancelKeyPress += (s, e) => { e.Cancel = false; job.Dispose(); };
// Window layout LAST so it wins over the child windows: renderer
// -> always-on-top; DOSBox -> parked + foreground (keeps the emu
// thread at foreground priority so the RIO ACK deadline isn't
// missed). Give the windows a moment to exist first.
if (!opt.NoFocus)
{
Thread.Sleep(3000);
Console.WriteLine("focus: " + Focus.Apply(opt.DosBoxX, opt.DosBoxY));
}
// Authoritative for the whole session: block until DOSBox exits.
dosbox.WaitForExit();
int code = dosbox.ExitCode;
Console.WriteLine($"pod exited ({code}); tearing down session");
return code;
}
finally
{
// Closing the job handle fires KILL_ON_JOB_CLOSE -> the renderer
// (and anything DOSBox spawned) dies. This runs on normal exit;
// on a hard kill of THIS process the OS does the same for us.
job?.Dispose();
dosbox?.Dispose();
bridge?.Dispose();
}
}
private static void PrependNpcapToPath()
{
const string npcap = @"C:\Windows\System32\Npcap";
string path = Environment.GetEnvironmentVariable("PATH") ?? "";
if (path.IndexOf("\\Npcap", StringComparison.OrdinalIgnoreCase) < 0 && Directory.Exists(npcap))
Environment.SetEnvironmentVariable("PATH", npcap + ";" + path);
}
// Env every child inherits (DOSBox reads these). Bridge-only vars are set
// per-process in Main so they don't leak onto DOSBox's SDL window.
private static void ApplySharedEnv(Options opt)
{
void Set(string k, string v) => Environment.SetEnvironmentVariable(k, v);
Set("VPXLOG", Path.Combine(opt.Work, "vpxresp.txt"));
Set("VPX_RESPOND", "1");
Set("VPX_RENDER", "1");
Set("VPX_DUMPDIR", opt.Work);
Set("VPX_FIFODUMP", Path.Combine(opt.Work, "live.fifodump"));
Set("VPX_FIFOSOCK", "8621");
Set("RIO_TAP", Path.Combine(opt.Work, "riotap.txt"));
Set("VPX_NOMAIN", "1"); // the GL bridge is the main out-the-window view
Set("VPX_CLEAR", "0,0,0");
// Display layout.
if (opt.Layout == LayoutMode.Cockpit)
{
Set("VPX_COCKPIT", "1");
Environment.SetEnvironmentVariable("VPX_EXPLODE", null);
// Head windows (edit for the rig; deployment variant of the
// pod_deploy.ps1 RECTS block -- OPEN item).
Set("VPX_WIN0", opt.RadarRect); // radar (HEAD C)
Set("VPX_WIN4", opt.Mfd3Rect); // 3-color MFD (HEAD B)
Set("VPX_WIN3", opt.Mfd2Rect); // 2-color MFD (HEAD A)
}
else
{
Set("VPX_EXPLODE", "1"); // 7-window debug spread
Environment.SetEnvironmentVariable("VPX_COCKPIT", null);
}
if (opt.NoSound)
{
Environment.SetEnvironmentVariable("VWE_AWE32", null);
Environment.SetEnvironmentVariable("VWE_AWE_ROM", null);
}
else
{
Set("VWE_AWE32", "1");
if (opt.AweRom != null) Set("VWE_AWE_ROM", opt.AweRom);
}
}
private static void TryDelete(string p)
{
try { if (File.Exists(p)) File.Delete(p); } catch { /* in use / gone */ }
}
}
}
+86
View File
@@ -0,0 +1,86 @@
# pod-launch
The supervising launch **entry-point** for the two DOSBox titles (BattleTech /
Red Planet 4.10). TeslaConsole → TeslaLauncher invokes this exe in the game dir
with args selecting game/mode; it is the single authoritative handle for the
whole session. See [`../DEPLOYMENT-PLAN.md`](../DEPLOYMENT-PLAN.md).
## Why an exe, not a batch/script
The entry-point must **own** the child processes' lifetime, not hand off and
exit. `pod-launch`:
1. Creates a **Windows Job Object** with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`.
2. Launches **DOSBox-X** and the **render bridge** *into* the job.
3. **Blocks** on DOSBox for the whole mission.
Kill this process (TeslaLauncher force-killing a hung DOSBox on a console
command) → the job's last handle closes → the **kernel** kills DOSBox + the
renderer. This holds even on a hard `TerminateProcess`, where no cooperative
shutdown can run. When DOSBox exits on its own (mission end) the supervisor
tears the job down and returns DOSBox's exit code.
A batch file / `cmd.exe` / `start` / `Start-Process` can't do this: they decouple
from the child, which then survives the parent. Verified: hard-killing the
supervisor reaps its child via the kernel (see the job-object cascade test).
## Build
Dev (needs the .NET 8 runtime present):
```
dotnet build -c Release
```
Deploy — self-contained single-file, so the air-gapped pod needs no .NET runtime
installed:
```
dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true
```
## Usage
```
pod-launch [mode] [options]
```
**Modes** (the console's ExitCodeID vocabulary → a launch): `bt``net_loop.conf`,
`rp``net_rp.conf` (supported). `bt`/`rp` also serve the **camera ship** and
**live mission-review** roles — the console assigns those per-IP via the egg
`hostType`, so they're *not* separate modes (see `../CAMERA-REVIEW-NOTES.md`).
`review` (playback of `last.spl`) and the diagnostics (`test-plasma`,
`reset-rio`, `audio-test`) are recognized but fail clean (exit 3 + reason) until
their DOS launch mechanics are confirmed.
Paths resolve relative to `--root` (default: the exe's own dir = the deployed
game dir), with dev fallbacks so the same exe runs against the `emulator/` tree:
```
pod-launch bt --root C:\VWE\TeslaRel410\emulator --dry-run
```
`--dry-run` prints the resolved plan (dosbox / conf / renderer / work / layout)
without launching — use it to validate a rig's layout. Key flags: `--layout
cockpit|explode`, `--no-sound`, `--mipmap`, `--dosbox-xy x,y`, `--bridge-pos
x,y`, `--renderer <exe>` (frozen renderer; else falls back to `pyw
live_bridge.py`). `-h` for the full list.
## Deploy layout it expects (under the game dir / `--root`)
```
pod-launch.exe dosbox-x.exe net_loop.conf net_rp.conf
renderer.exe (frozen) roms\awe32.raw <game content + net-boot drivers>
```
`postinstall.bat` lays this out and renders the `net_*.conf` (paths, `realnic`,
`WATTCP my_ip = bayIP+100`). This exe just selects + launches.
## TODO (tracked in DEPLOYMENT-PLAN.md OPEN items)
- Wire the distinct non-networked modes once their DOS launch args are
confirmed: `review` (playback app + `last.spl`) and the diagnostics. (The mode
table in `Modes.cs` is the single place to add each.)
- Swap the dev `pyw live_bridge.py` for the frozen renderer exe once packaging
is decided (freeze vs embedded Python — with David).
- Per-rig cockpit head RECTS (currently the `pod_deploy.ps1` defaults).
+25
View File
@@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<!--
pod-launch: the supervising launch entry-point for the two DOSBox titles
(BattleTech / Red Planet 4.10). Owns the session lifetime via a Windows Job
Object (KILL_ON_JOB_CLOSE): kill this process and DOSBox-X + the render
bridge die with it, kernel-enforced. Build commands are in README.md.
See ../DEPLOYMENT-PLAN.md.
-->
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0-windows</TargetFramework>
<AssemblyName>pod-launch</AssemblyName>
<RootNamespace>VwePod</RootNamespace>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<LangVersion>latest</LangVersion>
<PlatformTarget>x64</PlatformTarget>
<!-- A console app: DOSBox and the bridge make their own windows; this
process just supervises and can be killed cleanly by TeslaLauncher. -->
<AssemblyTitle>VWE Pod Launch Supervisor</AssemblyTitle>
</PropertyGroup>
</Project>