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
+174
View File
@@ -0,0 +1,174 @@
# Pod-bay deployment plan (network-agnostic archive)
How the two emulated titles (BattleTech + Red Planet, the only DOSBox games)
ship into the existing TeslaConsole / TeslaLauncher pod-bay architecture as a
self-contained, air-gapped, statically-addressed install. Everything here is
settled unless marked **OPEN**.
**Built so far:** the supervising launch entry-point (`pod-launch/`, C# Job-Object
supervisor -- kill-cascade verified) and the install-side artifacts
(`deploy/`: `postinstall.bat`, `configure.ps1`, `net_*.conf.tmpl` -- rendering +
NIC-bind + `bayIP+100` + WATTCP-stamp verified against a scratch tree). Remaining
build work is in the OPEN list.
## The existing contract (do NOT change it)
- A pod bay is a Windows PC on a **self-contained, air-gapped network** (no
venue LAN, no internet) shared by the console + up to **32 pods** (~140 units
left worldwide -- a bounded preservation fleet, not a scaling problem).
- **All addresses are static, assigned by TeslaConsole.** No DHCP.
- **TeslaConsole -> TeslaLauncher**: transmits a zip; the zip has ONE folder +
a `postinstall.bat` at its root; TeslaLauncher extracts to the games root
(`C:\games` almost always) and runs `postinstall.bat` **elevated**.
- **TeslaConsole registers/invokes the launch** -- it tells TeslaLauncher to
execute a command in the game dir with args (args select game/mode).
`postinstall.bat` does NOT register anything.
- **TeslaLauncher also STOPS the app** -- e.g. on a console "kill" call when a
DOSBox hangs. Its control path must stay alive independent of DOSBox.
- Only **BT/RP 4.10 are emulated** (DOSBox); the rest of the catalog runs
**native** (preservation project). For every native title, TeslaLauncher and
the game share the bay's single IP as one process space; **these two are the
only exception**, because the game's TCP stack lives inside DOSBox, separate
from the host.
## Network model -- THE core of this plan
### Why a bridge, not slirp/NAT (settled, source-proven)
The pods form a **direct peer-to-peer TCP mesh**, not star-through-console:
each pod `TCP_OPEN`s to the other pods' real IPs from the egg roster
(`MasterMode`/`SlaveMode` decides who dials), tracked separately from the
console stream. Evidence: `NETNUB_TCP_OPEN` ("Opens a TCP stream to another
computer", NETNUB.HPP), `OpenConnection(...,int internet_address)`,
`NetworkStartupMode{SlaveMode,MasterMode}`, `numberOfMungaHostsConnected` vs
`numberOfConsoleHostsConnected` (L4NET.HPP). See memory `pod-multiplayer-mesh`.
=> NAT can't carry a pod dialing a same-subnet peer, so the emulated NIC must be
a **real bridged host** on the segment.
### Two IPs per bay -- launcher keeps the bay IP; the DOS game gets `bayIP+100`
The launcher's IP never moves. The DOSBox guest gets a **second** address so it
can be a first-class host without stealing the bay IP:
| Role | Address | Owner | Used for |
|---|---|---|---|
| **Bay / control IP** | e.g. `10.0.0.5` (static, console-assigned) | host Windows / TeslaLauncher | zip, launch, **kill** -- unchanged from native titles; alive even if DOSBox hangs |
| **Game IP** | `bayIP + 100` = `10.0.0.105` | DOSBox guest (bridged, own emulated-NIC MAC) | egg identity, mesh peer target, `:1501` |
Both live at once on the **one** physical NIC (host MAC = bay IP, emulated NE2000
MAC = game IP). The `+100` offset on the last octet is safe forever at this
scale: 32 bays fit in `.1-.32`, games in `.101-.132`, no wrap, no collision.
### The `+100` convention -- two edits, both derive the same value
1. **`postinstall.bat`** reads the host's static IP and stamps the guest
`WATTCP.CFG my_ip = bayIP+100` (netmask `255.255.255.0`, gateway harmless --
air-gapped, no routing; the mesh is all same-subnet L2).
2. **TeslaConsole** -- a per-title **"runs in DOSBox" flag**. When set, apply
`+100` at the single point where the console resolves a bay to its game
endpoint, so BOTH inherit it:
- the **roster entries written into the egg** (peers dial `+100`), AND
- the **address the console itself dials** to deliver the egg / poll
StateQuery / send StopMission (`bayIP+100:1501`, not the bay IP -- else the
SYN hits the launcher, not the DOS game).
**Blanket `+100` is valid because every host in a BT/RP egg is itself a DOSBox
pod.** Only the BT/RP 4.10 engine is emulated, and ALL of its roles -- cockpit,
camera ship, mission review -- are that same one DOSBoxed binary; every other
catalog title runs native. So a BT/RP mission roster is entirely emulated and
takes `+100` uniformly. The console's own address is never a roster entry (pods
reply on the console's inbound socket), so it's never offset.
### What this retires (dev-only artifacts, gone in deployment)
`SendToRxAdapters`, the Windows Network Bridge, the two-TAP setup, and
`tap2_mirror.py` all existed ONLY because dev co-located the console on the pod
PC and had to loop pcap frames back to the host stack. On the real air-gapped
segment the console is a separate machine and hears every pod over the wire.
Also gone: the machine-specific `realnic=DB5521D` GUID (postinstall binds the
one NIC) and the `200.0.0.x` hardcoding (postinstall stamps the assigned IPs).
## The archive (self-contained -- air-gap forbids any download)
Single root folder + `postinstall.bat`. Everything the install needs is inside:
- `dosbox-x.exe` + DLLs (bundle the **VC++ runtime**; don't install it separately)
- `ALPHA_1\` game content (REL410\BT, REL410\RP, SB16, GAUGE, ...) + `net-boot\`
drivers (LSL/NE2000/ODIPKT/NET.CFG @ PORT 340/INT 10, loop.bat)
- **Npcap silent installer** (bundled)
- The renderer (**OPEN:** freeze to one exe vs bundle embeddable Python -- decide
with David; "decide later")
- `net_*.conf` **templates** with `@@ROOT@@` / `@@REALNIC@@` tokens, launch
dispatcher, `postinstall.bat`
## `postinstall.bat` responsibilities (elevated, one-shot, no network fetch)
Implemented in `deploy/` (`postinstall.bat` = thin wrapper; `configure.ps1` does
2-4):
1. **Install Npcap** silently (`/S`, from the bundled installer -- the edition
whose installer supports unattended install).
2. **Bind the NIC**: pick the one active adapter, write a safe letter-leading
`realnic=` fragment (a contiguous substring of ONE GUID block, so it matches
`\Device\NPF_{GUID}`; avoids the leading-digit-as-index trap).
3. **Stamp game identity**: `WATTCP.CFG my_ip = <host static IP> + 100` in every
`ALPHA_1\...\WATTCP.CFG`, plus a stable `macaddr = 02:00:<gameIP hex>`.
4. **Relocate paths**: render `net_*.conf.tmpl` -> `net_*.conf`, filling
`@@ROOT@@` (the package dir), `@@REALNIC@@`, `@@MACADDR@@`.
NOT done here: registering the launch entry-point (console does that), firewall
rules, SendToRx, TAP, or mirror.
## Launch dispatch
TeslaConsole invokes a **single entry-point** in the game dir with args
selecting game/mode. The dispatcher maps args -> conf + renderer:
`bt`/looped -> `net_loop.conf`; `rp` -> `net_rp.conf`; the "alternate games/
modes" map onto the decoded `ExitCodeID` set (RunBattleTech/RunRedPlanet +
SinglePlayer variants, RunCamera, RunMissionReview, test patterns,
TestPlasmaDisplay, ResetRIO).
**The entry-point MUST be a supervising parent, not fire-and-forget** -- it stays
authoritative for the whole session, lifetime == the game's:
- 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 (also doing the window focus/layout, folding in `focus_dosbox.ps1`).
- **Kill the entry-point -> DOSBox + renderer die with it.** The kernel enforces
the teardown when the job's last handle closes (the instant the entry-point
process dies), so it holds even on a hard `TerminateProcess` -- which is
exactly the hung-DOSBox case, where no cooperative shutdown can be relied on.
No orphaned emulator/renderer, ever.
- When DOSBox exits on its own (mission end), the supervisor tears down the job
(renderer too) and returns DOSBox's exit code.
Ship the entry-point as a small **compiled supervisor exe** (C#/C++) that creates
the job, `CreateProcess`es DOSBox + the bridge *into* it, and blocks on
`WaitForSingleObject(dosbox)`. It **must NOT be a batch file / `cmd.exe`, nor use
`start` or `Start-Process`** -- all of those DECOUPLE from the child: a `start`ed
/ `Start-Process`ed DOSBox is an independent process, and even a foreground
`dosbox.exe` under `cmd.exe` is NOT killed when cmd is (Windows doesn't reap
children with the parent gone, absent a job object). The current
`launch_pod.ps1`/`pod_deploy.ps1` do exactly this wrong (`Start-Process` +
pid-files, then exit, leaving DOSBox running) and must be replaced by the
supervisor.
**Kill flow:** console -> TeslaLauncher terminates the entry-point process ->
job closes -> DOSBox + renderer die. Graceful mission-end still flows to the game
as StopMission on `:1501`, after which the supervisor exits with DOSBox.
## Provisioning rules
- Bays static in `.1-.32`, game endpoints auto-fall in `.101-.132`, console + its
special hosts elsewhere in the /24. All on one flat, air-gapped L2 segment.
- Dumb unmanaged switch; no venue cooperation needed.
## OPEN items
1. Renderer packaging -- freeze (PyInstaller, one exe) vs embeddable Python
(with David).
2. Per-rig display RECTS (already isolated in `pod_deploy.ps1`; deployment
variant of the window layout).
Related: `NET-NOTES.md`, `LAUNCH.md`, `ENV-VARS.md`, memory
`pod-multiplayer-mesh`, `pod-deployment`.
+83
View File
@@ -0,0 +1,83 @@
# deploy/ -- install-side artifacts
Sources for the self-contained, air-gapped pod install. See
[`../DEPLOYMENT-PLAN.md`](../DEPLOYMENT-PLAN.md) for the full design.
## Files
| File | Role |
|---|---|
| `postinstall.bat` | elevated entry TeslaLauncher runs after extraction: finds the package folder, installs bundled Npcap, calls `configure.ps1` |
| `configure.ps1` | the per-pod step: detects the NIC + bay IP, derives the game IP = **bayIP+100**, binds DOSBox's pcap (`realnic`), renders the conf templates, stamps `WATTCP.CFG my_ip` |
| `net_loop.conf.tmpl` | BattleTech conf template (looped netnub) |
| `net_rp.conf.tmpl` | Red Planet conf template (single-shot) |
| `package.ps1` | assembles the deployable zip from repo sources + a fresh self-contained `pod-launch` publish |
Template tokens filled at install: `@@ROOT@@` (package dir), `@@REALNIC@@` (this
pod's NIC GUID fragment), `@@MACADDR@@` (stable MAC derived from the game IP).
## Packaged zip layout
The archive extracts to the games root (`C:\games`). Zip root = one package
folder + `postinstall.bat`:
```
C:\games\
postinstall.bat <- from deploy/postinstall.bat
TeslaPod410\ <- the single package folder (= ROOT)
pod-launch.exe <- built from ../pod-launch (self-contained)
dosbox-x.exe (+ DLLs)
net_loop.conf.tmpl <- from deploy/ (rendered -> net_loop.conf)
net_rp.conf.tmpl <- from deploy/ (rendered -> net_rp.conf)
renderer.exe <- frozen renderer (OPEN: packaging w/ David)
roms\awe32.raw
ALPHA_1\... <- game content (incl. the WATTCP.CFG files)
net-boot\... <- ODI/packet drivers, NET.CFG @340/INT10
deploy\
configure.ps1 <- from deploy/
npcap.exe <- bundled Npcap silent installer (NOT in repo)
vc_redist.x64.exe <- optional, if the DOSBox build needs it
```
`postinstall.bat` derives `ROOT` as the single folder beside it, so the folder
can be named per package.
## What runs at install (elevated, no network -- air-gapped)
1. `postinstall.bat` locates `ROOT`, runs `ROOT\deploy\npcap.exe /S`.
2. `configure.ps1 -Root ROOT`:
- finds the one static IPv4 (the **bay IP**) + its adapter GUID;
- `realnic` = a contiguous, letter-leading fragment of a single GUID block
(DOSBox reads a leading digit as an interface index; the fragment must be a
substring of `\Device\NPF_{GUID}`);
- **game IP = bay IP + 100** on the last octet (≤32 pods, bays `.1-.100`);
- `macaddr` = `02:00:<game IP octets in hex>` (stable, locally-administered);
- renders `*.conf.tmpl` -> `*.conf` (tokens filled);
- stamps `my_ip = gameIP` into every `ALPHA_1\...\WATTCP.CFG`.
3. `pod-launch.exe` (the supervisor) then selects + launches the rendered conf.
Verified against a scratch package tree: NIC detect, `realnic` (contiguous-match
checked), `bayIP+100`, MAC, conf render, and WATTCP stamp all correct.
## Building the zip
```
deploy\package.ps1 [-PackageName TeslaPod410] [-Npcap <installer>] `
[-Renderer <exe>] [-VcRedist <exe>] [-NoContent] [-SkipBuild]
```
Publishes `pod-launch` self-contained, stages the tree (postinstall.bat at the
zip root + the package folder), and writes `emulator\dist\<PackageName>.zip`.
`-NoContent` skips `ALPHA_1`/`net-boot` for a fast structural check. Externally-
procured pieces are bundled only when their paths are passed (else flagged).
## Not yet in the repo / OPEN
- `npcap.exe` (licensed installer -- procured out-of-band) and any `vc_redist`.
- The frozen `renderer.exe` (packaging decision with David; `pod-launch` falls
back to `pyw live_bridge.py` when absent).
- **DOSBox-X runtime DLL closure** (MinGW/SDL DLLs): none sit beside
`dosbox-x.exe` in the dev tree -- it finds them via MSYS2 on PATH. The air-
gapped package must bundle them; `package.ps1` copies any DLLs beside the exe
and warns when there are none.
- RP loop-vs-single-shot for retail parity (template currently mirrors dev).
+86
View File
@@ -0,0 +1,86 @@
# configure.ps1 -- the per-pod install step, called by postinstall.bat (elevated).
# On THIS machine it: finds the NIC + its static IP (the bay IP), derives the
# DOSBox game IP = bayIP + 100, binds DOSBox's pcap to the NIC, renders the
# net_*.conf templates, and stamps the WATTCP.CFG my_ip. Air-gapped, static,
# <=32 pods. See ../DEPLOYMENT-PLAN.md.
param([Parameter(Mandatory = $true)][string]$Root)
$ErrorActionPreference = 'Stop'
function Log($m) { Write-Host "[configure] $m" }
# --- prefix length -> dotted netmask -------------------------------------
function PrefixToMask([int]$p) {
$bits = ('1' * $p).PadRight(32, '0')
$o = 0..3 | ForEach-Object { [Convert]::ToInt32($bits.Substring($_ * 8, 8), 2) }
return ($o -join '.')
}
# --- 1. the pod's NIC + bay IP (the one real static IPv4) ----------------
$ip = Get-NetIPAddress -AddressFamily IPv4 |
Where-Object { $_.IPAddress -notlike '127.*' -and $_.IPAddress -notlike '169.254.*' } |
Sort-Object -Property SkipAsSource, InterfaceMetric |
Select-Object -First 1
if (-not $ip) { throw "no usable static IPv4 found on any adapter" }
$bayIp = $ip.IPAddress
$ifIndex = $ip.InterfaceIndex
$adapter = Get-NetAdapter -InterfaceIndex $ifIndex
$guid = $adapter.InterfaceGuid.Trim('{', '}')
$mask = PrefixToMask $ip.PrefixLength
Log "bay IP $bayIp/$($ip.PrefixLength) on '$($adapter.Name)' guid $guid"
# --- 2. realnic fragment. Must be a CONTIGUOUS substring of the pcap device
# name (\Device\NPF_{GUID}, hyphens included), so take it from ONE
# hyphen-delimited GUID block -- letter-leading (DOSBox reads a leading
# DIGIT as an interface index) and length >= 6 for uniqueness. Longest. --
$realnic = $null
foreach ($blk in ($guid -split '-')) {
$mm = [regex]::Match($blk, '[A-Fa-f][0-9A-Fa-f]{5,}')
if ($mm.Success -and (-not $realnic -or $mm.Value.Length -gt $realnic.Length)) { $realnic = $mm.Value }
}
if (-not $realnic) { throw "no letter-leading >=6-char realnic fragment in any block of GUID $guid" }
Log "realnic fragment = $realnic"
# --- 3. game IP = bay IP + 100 on the last octet -------------------------
$o = $bayIp.Split('.')
$last = [int]$o[3] + 100
if ($last -gt 254) { throw "bay last octet $($o[3]) + 100 = $last overflows 254; keep bays <= .100" }
$gameIp = "$($o[0]).$($o[1]).$($o[2]).$last"
Log "game IP = $gameIp (bay + 100)"
# --- 4. stable locally-administered MAC from the game IP ------------------
$mac = "02:00:{0:X2}:{1:X2}:{2:X2}:{3:X2}" -f [int]$o[0], [int]$o[1], [int]$o[2], $last
Log "macaddr = $mac"
# --- 5. gateway/nameserver: the host's real gateway if set, else the host
# itself (always up + on-subnet -> WATTCP's boot ARP resolves; never
# actually routed, the mesh is same-subnet L2) -----------------------
$gw = $null
try { $gw = (Get-NetIPConfiguration -InterfaceIndex $ifIndex).IPv4DefaultGateway.NextHop } catch { }
if (-not $gw) { $gw = $bayIp }
Log "gateway/nameserver = $gw"
# --- 6. render the conf templates (@@ROOT@@/@@REALNIC@@/@@MACADDR@@) ------
$tokens = @{ '@@ROOT@@' = $Root; '@@REALNIC@@' = $realnic; '@@MACADDR@@' = $mac }
$tmpls = Get-ChildItem -Path $Root -Filter '*.conf.tmpl' -ErrorAction SilentlyContinue
if (-not $tmpls) { throw "no *.conf.tmpl found in $Root" }
foreach ($t in $tmpls) {
$text = Get-Content -Path $t.FullName -Raw
foreach ($k in $tokens.Keys) { $text = $text.Replace($k, $tokens[$k]) }
$out = Join-Path $Root ($t.Name -replace '\.tmpl$', '')
Set-Content -Path $out -Value $text -Encoding Ascii -NoNewline
Log "rendered $($t.Name -replace '\.tmpl$','')"
}
# --- 7. stamp WATTCP.CFG my_ip = game IP (all copies the game may read) ---
$wattcp = @(
(Join-Path $Root 'ALPHA_1\REL410\BT\WATTCP.CFG'),
(Join-Path $Root 'ALPHA_1\REL410\RP\WATTCP.CFG'),
(Join-Path $Root 'ALPHA_1\VGL_LABS\THISPOD\WATTCP.CFG')
)
$lines = @("my_ip = $gameIp", "netmask = $mask", "nameserver = $gw", "gateway = $gw")
$stamped = 0
foreach ($w in $wattcp) {
if (Test-Path $w) { Set-Content -Path $w -Value $lines -Encoding Ascii; $stamped++; Log "stamped $w" }
}
if ($stamped -eq 0) { throw "no WATTCP.CFG found under $Root\ALPHA_1" }
Log "OK: bay=$bayIp game=$gameIp mac=$mac realnic=$realnic ($stamped WATTCP.CFG)"
+58
View File
@@ -0,0 +1,58 @@
# DEPLOY TEMPLATE -- rendered to net_loop.conf by deploy/configure.ps1 at install.
# Tokens (filled by postinstall): @@ROOT@@ = package dir (C:\games\<folder>),
# @@REALNIC@@ = this pod's NIC GUID fragment, @@MACADDR@@ = stable derived MAC.
# BattleTech, GO.BAT-style netnub loop (pod stays connected + picks up missions).
[sdl]
output=opengl
priority=highest,highest
[dosbox]
memsize=32
machine=svga_s3
[cpu]
core=dynamic
cputype=pentium
cycles=max
[ne2000]
ne2000=true
nicbase=340
# IRQ 10, NOT 3: COM2 (plasma) owns IRQ 3 in-game.
nicirq=10
macaddr=@@MACADDR@@
backend=pcap
[ethernet, pcap]
realnic=@@REALNIC@@
[sblaster]
sbtype=sb16
sbbase=220
irq=5
dma=1
hdma=5
[mixer]
rate=44100
blocksize=1024
prebuffer=60
[serial]
serial1=directserial realport:COM1 rxpollus:100 rxburst:16
serial2=directserial realport:COM2
[autoexec]
mount c "@@ROOT@@\ALPHA_1"
mount d "@@ROOT@@\net-boot"
d:
echo === loading NE2000 packet-driver stack (pcap/bridge, port 340) ===
d:\lsl
d:\ne2000
d:\odipkt
c:
cd \rel410\bt
set VIDEOFORMAT=svga
set BLASTER=A220 I5 D1 H5 P330 T6
c:\sb16\diagnose /s
set BLASTER=A240 I7 D3 H6 P300 T6
c:\sb16\diagnose /s
set BLASTER=A220 I5 D1 H5 P330 T6
set TEMP=c:\
set HEAPSIZE=15000000
set L4GAUGE=640x480x16
call setenv.bat r f s p
echo === entering GO.BAT-style netnub loop (waits for console missions) ===
call d:\loop.bat
+63
View File
@@ -0,0 +1,63 @@
# DEPLOY TEMPLATE -- rendered to net_rp.conf by deploy/configure.ps1 at install.
# Tokens (filled by postinstall): @@ROOT@@ = package dir (C:\games\<folder>),
# @@REALNIC@@ = this pod's NIC GUID fragment, @@MACADDR@@ = stable derived MAC.
# Red Planet 4.10, networked pod boot (console pushes the mission egg over TCP).
# NOTE: single-shot netnub here (matches dev net_rp.conf); whether RP should loop
# like BT for retail-parity is an OPEN deploy decision (see DEPLOYMENT-PLAN.md).
[sdl]
output=opengl
priority=highest,highest
[dosbox]
memsize=32
machine=svga_s3
[cpu]
core=dynamic
cputype=pentium
cycles=max
[ne2000]
ne2000=true
nicbase=340
nicirq=10
macaddr=@@MACADDR@@
backend=pcap
[ethernet, pcap]
realnic=@@REALNIC@@
[sblaster]
sbtype=sb16
sbbase=220
irq=5
dma=1
hdma=5
[mixer]
rate=44100
blocksize=1024
prebuffer=60
[serial]
serial1=directserial realport:COM1 rxpollus:100 rxburst:16
serial2=directserial realport:COM2
[autoexec]
mount c "@@ROOT@@\ALPHA_1"
mount d "@@ROOT@@\net-boot"
d:
echo === loading NE2000 packet-driver stack (pcap/bridge, port 340) ===
d:\lsl
d:\ne2000
d:\odipkt
c:
cd \rel410\rp
set VIDEOFORMAT=svga
set BLASTER=A220 I5 D1 H5 P330 T6
c:\sb16\diagnose /s
set BLASTER=A240 I7 D3 H6 P300 T6
c:\sb16\diagnose /s
set BLASTER=A220 I5 D1 H5 P330 T6
set TEMP=c:\
set HEAPSIZE=15000000
set L4GAUGE=640x480x16
call setenv.bat r f s p
echo === launching Red Planet via NetNub (RIO + sound; waits for console) ===
32rtm.exe -x
netnub -p -f rpl4opt > nn.log
32rtm.exe -u
echo === RP-NET-RUN-DONE ===
pause
+93
View File
@@ -0,0 +1,93 @@
# package.ps1 -- assemble the deployable pod zip from repo sources + a fresh
# self-contained pod-launch publish. Output: <OutDir>\<PackageName>.zip whose
# ROOT holds postinstall.bat + the single package folder (extract to C:\games).
# See README.md for the tree. Externally-procured pieces (Npcap, frozen renderer)
# are bundled only if their paths are passed; otherwise they're flagged missing.
param(
[string]$PackageName = "TeslaPod410",
[string]$OutDir,
[string]$Npcap, # bundled Npcap silent installer -> deploy\npcap.exe
[string]$Renderer, # frozen renderer exe -> renderer.exe
[string]$VcRedist, # optional VC++ runtime -> deploy\vc_redist.x64.exe
[switch]$NoContent, # skip ALPHA_1 + net-boot (fast structural test)
[switch]$SkipBuild # reuse the last pod-launch publish
)
$ErrorActionPreference = 'Stop'
function Log($m) { Write-Host "[package] $m" }
function Warn($m) { Write-Warning "[package] $m" }
$Repo = (Resolve-Path "$PSScriptRoot\..\..").Path
$Emu = Join-Path $Repo 'emulator'
if (-not $OutDir) { $OutDir = Join-Path $Emu 'dist' }
New-Item -ItemType Directory -Force $OutDir | Out-Null
# --- 1. build pod-launch.exe (self-contained single-file, no runtime needed) --
$plProj = Join-Path $Emu 'pod-launch\pod-launch.csproj'
$plOut = Join-Path $Emu 'pod-launch\bin\pkg'
if (-not $SkipBuild) {
Log "publishing pod-launch (self-contained win-x64)..."
dotnet publish $plProj -c Release -r win-x64 --self-contained true `
-p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true `
-o $plOut | Out-Null
if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed" }
}
$podLaunch = Join-Path $plOut 'pod-launch.exe'
if (-not (Test-Path $podLaunch)) { throw "pod-launch.exe not found at $podLaunch" }
# --- 2. stage the tree --------------------------------------------------------
$stage = Join-Path $OutDir '_stage'
if (Test-Path $stage) { Remove-Item $stage -Recurse -Force }
$root = Join-Path $stage $PackageName
New-Item -ItemType Directory -Force (Join-Path $root 'deploy') | Out-Null
New-Item -ItemType Directory -Force (Join-Path $root 'roms') | Out-Null
# postinstall.bat -> zip ROOT (beside the package folder)
Copy-Item (Join-Path $Emu 'deploy\postinstall.bat') $stage
# supervisor
Copy-Item $podLaunch $root
# DOSBox-X + its DLLs
$dbDir = Join-Path $Emu 'src\src'
Copy-Item (Join-Path $dbDir 'dosbox-x.exe') $root
$dlls = Get-ChildItem (Join-Path $dbDir '*.dll') -ErrorAction SilentlyContinue
if ($dlls) { $dlls | ForEach-Object { Copy-Item $_.FullName $root } ; Log "copied $($dlls.Count) DLL(s) beside dosbox-x.exe" }
else { Warn "no DLLs beside dosbox-x.exe -- verify the MinGW/SDL runtime DLLs the build needs are present" }
# conf templates + configure.ps1
Copy-Item (Join-Path $Emu 'deploy\net_loop.conf.tmpl') $root
Copy-Item (Join-Path $Emu 'deploy\net_rp.conf.tmpl') $root
Copy-Item (Join-Path $Emu 'deploy\configure.ps1') (Join-Path $root 'deploy')
# AWE32 ROM
Copy-Item (Join-Path $Emu 'roms\awe32.raw') (Join-Path $root 'roms')
# game content + packet drivers (large)
if ($NoContent) {
Warn "-NoContent: skipping ALPHA_1 + net-boot (structural test only, NOT installable)"
} else {
Log "copying ALPHA_1 game content (large)..."
Copy-Item (Join-Path $Repo 'ALPHA_1') $root -Recurse
Copy-Item (Join-Path $Emu 'net-boot') $root -Recurse
}
# renderer (frozen exe) -- else flagged
if ($Renderer) { Copy-Item $Renderer (Join-Path $root 'renderer.exe'); Log "bundled renderer -> renderer.exe" }
else { Warn "no -Renderer: renderer NOT bundled (freeze pending). pod-launch needs --renderer or a bundled render-bridge at runtime." }
# Npcap installer -- else flagged
if ($Npcap) { Copy-Item $Npcap (Join-Path $root 'deploy\npcap.exe'); Log "bundled Npcap -> deploy\npcap.exe" }
else { Warn "no -Npcap: installer NOT bundled. postinstall will warn + the pcap bridge won't work until Npcap is present." }
if ($VcRedist) { Copy-Item $VcRedist (Join-Path $root 'deploy\vc_redist.x64.exe'); Log "bundled VC++ runtime" }
# --- 3. zip -------------------------------------------------------------------
$zip = Join-Path $OutDir "$PackageName.zip"
if (Test-Path $zip) { Remove-Item $zip -Force }
Log "compressing -> $zip"
Compress-Archive -Path (Join-Path $stage '*') -DestinationPath $zip
Remove-Item $stage -Recurse -Force
$size = "{0:N1} MB" -f ((Get-Item $zip).Length / 1MB)
Log "DONE: $zip ($size)"
Log "zip root = postinstall.bat + $PackageName\ (TeslaLauncher extracts to C:\games, runs postinstall.bat elevated)"
+46
View File
@@ -0,0 +1,46 @@
@echo off
setlocal enabledelayedexpansion
REM ==========================================================================
REM postinstall.bat -- runs ELEVATED after TeslaLauncher extracts the zip to
REM the games root (C:\games). The zip root holds this file + ONE package
REM folder; this script finds that folder, installs the bundled Npcap, and
REM hands off to deploy\configure.ps1 for the NIC bind / IP derivation / conf
REM rendering. Air-gapped: nothing is downloaded. See DEPLOYMENT-PLAN.md.
REM ==========================================================================
echo [postinstall] starting
REM --- locate ROOT: the single package folder beside this script -----------
set "HERE=%~dp0"
set "ROOT="
for /d %%D in ("%HERE%*") do set "ROOT=%%~fD"
if not defined ROOT (
echo [postinstall] ERROR: no package folder found beside postinstall.bat
exit /b 1
)
echo [postinstall] ROOT=!ROOT!
REM --- 1. Npcap silent install (bundled OEM-class installer supporting /S) --
set "NPCAP=!ROOT!\deploy\npcap.exe"
if exist "!NPCAP!" (
echo [postinstall] installing Npcap silently...
"!NPCAP!" /S
if errorlevel 1 ( echo [postinstall] ERROR: Npcap install failed & exit /b 1 )
) else (
echo [postinstall] WARNING: bundled Npcap not found at "!NPCAP!"
echo [postinstall] the pcap bridge will not work without it
)
REM --- optional: bundled VC++ runtime, if present --------------------------
set "VCREDIST=!ROOT!\deploy\vc_redist.x64.exe"
if exist "!VCREDIST!" (
echo [postinstall] installing VC++ runtime...
"!VCREDIST!" /install /quiet /norestart
)
REM --- 2-4. NIC bind + WATTCP my_ip=bayIP+100 + render confs (PowerShell) ---
echo [postinstall] configuring network + rendering confs...
powershell -NoProfile -ExecutionPolicy Bypass -File "!ROOT!\deploy\configure.ps1" -Root "!ROOT!"
if errorlevel 1 ( echo [postinstall] ERROR: configure.ps1 failed & exit /b 1 )
echo [postinstall] done
exit /b 0
+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>