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:
@@ -0,0 +1,2 @@
|
||||
bin/
|
||||
obj/
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user