// 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 { // 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 */ } } } }