Files
TeslaRel410/emulator/pod-launch/Program.cs
T
CydandClaude Opus 4.8 86d6b950e5 emulator: B2 RIO lamp/button bezel on the VDB heads + explode-view polish
Draw the cockpit RIO buttons around the mono-MFD and radar heads (explode
layout), lit by the host-commanded lamp state -- the display side of the
in-fork glass cockpit. Live-validated 2026-07-24 (operator).

- vpxlog.cpp: per-head button bezel in pal_draw, reading serialrio's new
  RIO_GetPanelState seam. The 5 MFD heads get red buttons (4 top / 4 bottom,
  100px tall tucked under a grown 640x500 display so a 10px lip shows); the
  radar gets amber Secondary/Screen side columns (6x 104px each) plus a
  centered bottom indicator strip (the 4 spares). Lamp byte decoded to
  off/dim/bright (vRIO RioLampState, brighter of the two brightness fields);
  a press shows white-hot. No labels -- the MFD shows each button's function.
  Also swapped the two upper-outer MFD window NAMES to match their (already
  position-swapped) desktop locations.
- serialrio.{cpp,h}: RIO_GetPanelState(lamps, pressed) accessor -- returns
  false when no rio port, so non-rio configs and other heads render normally.
- pod-launch: in explode (dev) layout the Division bridge is a normal, freely
  movable window -- not pinned topmost (Focus.cs) and nudged to 8,40 so its
  title bar clears the top of the screen (its client was at 0,0, pushing the
  frame off-screen). Cockpit/kiosk keeps the topmost + 0,0 borderless look.

Only affects the explode layout and only when a serial=rio port is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 10:43:41 -05:00

233 lines
11 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: DOSBox ->
// parked + foreground (keeps the emu thread at foreground priority
// so the RIO ACK deadline isn't missed). The renderer is pinned
// topmost only in cockpit (kiosk) layout; in explode (dev) it stays
// a normal, freely movable window. Give the windows a moment first.
if (!opt.NoFocus)
{
Thread.Sleep(3000);
Console.WriteLine("focus: " + Focus.Apply(
opt.DosBoxX, opt.DosBoxY, opt.Layout == LayoutMode.Cockpit));
}
// 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 */ }
}
}
}