Files
CydandClaude Fable 5 7710be1a07 Deploy: pod-launch warns that --no-sound freezes bt/rp
--no-sound strips VWE_AWE32, the FAST (28Hz SOS) clock never ticks, and
BTL4OPT hangs forever in the RIO-reset busy-wait (L4RIO.cpp SetDTR loop on
a frozen Now()) -- looks exactly like a vRIO/serial failure but isn't.
Root-caused during the 2026-07-10 dist smoke test. Only safe with a
SLOW-clock conf (setenv arg2=s). Warn at launch + document in usage/README.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 23:47:24 -05:00

231 lines
10 KiB
C#

// 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");
else
Console.WriteLine(
"WARNING: --no-sound removes the AWE32, and BT/RP's FAST SOS clock is\n" +
" sound-driven -- the game FREEZES at RIO init (Now() never ticks).\n" +
" Use only with a conf that selects the SLOW clock (setenv arg2=s).");
// ---- 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 */ }
}
}
}