diff --git a/emulator/DEPLOYMENT-PLAN.md b/emulator/DEPLOYMENT-PLAN.md new file mode 100644 index 0000000..37dc7af --- /dev/null +++ b/emulator/DEPLOYMENT-PLAN.md @@ -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 = + 100` in every + `ALPHA_1\...\WATTCP.CFG`, plus a stable `macaddr = 02:00:`. +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`. diff --git a/emulator/deploy/README.md b/emulator/deploy/README.md new file mode 100644 index 0000000..74d7230 --- /dev/null +++ b/emulator/deploy/README.md @@ -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:` (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 ] ` + [-Renderer ] [-VcRedist ] [-NoContent] [-SkipBuild] +``` + +Publishes `pod-launch` self-contained, stages the tree (postinstall.bat at the +zip root + the package folder), and writes `emulator\dist\.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). diff --git a/emulator/deploy/configure.ps1 b/emulator/deploy/configure.ps1 new file mode 100644 index 0000000..40a296d --- /dev/null +++ b/emulator/deploy/configure.ps1 @@ -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)" diff --git a/emulator/deploy/net_loop.conf.tmpl b/emulator/deploy/net_loop.conf.tmpl new file mode 100644 index 0000000..c072d21 --- /dev/null +++ b/emulator/deploy/net_loop.conf.tmpl @@ -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\), +# @@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 diff --git a/emulator/deploy/net_rp.conf.tmpl b/emulator/deploy/net_rp.conf.tmpl new file mode 100644 index 0000000..8496411 --- /dev/null +++ b/emulator/deploy/net_rp.conf.tmpl @@ -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\), +# @@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 diff --git a/emulator/deploy/package.ps1 b/emulator/deploy/package.ps1 new file mode 100644 index 0000000..5a2589c --- /dev/null +++ b/emulator/deploy/package.ps1 @@ -0,0 +1,93 @@ +# package.ps1 -- assemble the deployable pod zip from repo sources + a fresh +# self-contained pod-launch publish. Output: \.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)" diff --git a/emulator/deploy/postinstall.bat b/emulator/deploy/postinstall.bat new file mode 100644 index 0000000..80d8378 --- /dev/null +++ b/emulator/deploy/postinstall.bat @@ -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 diff --git a/emulator/pod-launch/.gitignore b/emulator/pod-launch/.gitignore new file mode 100644 index 0000000..cd42ee3 --- /dev/null +++ b/emulator/pod-launch/.gitignore @@ -0,0 +1,2 @@ +bin/ +obj/ diff --git a/emulator/pod-launch/ChildProcess.cs b/emulator/pod-launch/ChildProcess.cs new file mode 100644 index 0000000..d39f49d --- /dev/null +++ b/emulator/pod-launch/ChildProcess.cs @@ -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 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; + } + } +} diff --git a/emulator/pod-launch/Focus.cs b/emulator/pod-launch/Focus.cs new file mode 100644 index 0000000..af1a7c6 --- /dev/null +++ b/emulator/pod-launch/Focus.cs @@ -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(); + } +} diff --git a/emulator/pod-launch/JobObject.cs b/emulator/pod-launch/JobObject.cs new file mode 100644 index 0000000..e60da6e --- /dev/null +++ b/emulator/pod-launch/JobObject.cs @@ -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); + } +} diff --git a/emulator/pod-launch/Modes.cs b/emulator/pod-launch/Modes.cs new file mode 100644 index 0000000..2998186 --- /dev/null +++ b/emulator/pod-launch/Modes.cs @@ -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(); + } + } +} diff --git a/emulator/pod-launch/Options.cs b/emulator/pod-launch/Options.cs new file mode 100644 index 0000000..962a42e --- /dev/null +++ b/emulator/pod-launch/Options.cs @@ -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 `. + +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 same as the positional mode + --conf explicit conf (overrides the mode's conf) + --root base dir for dosbox/conf/bridge (default: exe dir) + --work runtime/work dir (default: %LOCALAPPDATA%\Temp\vwe-pod) + --layout cockpit|explode window layout (default cockpit) + --dosbox explicit dosbox-x.exe + --renderer frozen renderer exe (else falls back to pyw live_bridge) + --bridge-script live_bridge.py (dev) + --awe-rom 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"); + } + } +} diff --git a/emulator/pod-launch/Program.cs b/emulator/pod-launch/Program.cs new file mode 100644 index 0000000..207e856 --- /dev/null +++ b/emulator/pod-launch/Program.cs @@ -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 + { + // 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 */ } + } + } +} diff --git a/emulator/pod-launch/README.md b/emulator/pod-launch/README.md new file mode 100644 index 0000000..7a62083 --- /dev/null +++ b/emulator/pod-launch/README.md @@ -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 ` (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 +``` + +`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). diff --git a/emulator/pod-launch/pod-launch.csproj b/emulator/pod-launch/pod-launch.csproj new file mode 100644 index 0000000..1d2a013 --- /dev/null +++ b/emulator/pod-launch/pod-launch.csproj @@ -0,0 +1,25 @@ + + + + + + Exe + net8.0-windows + pod-launch + VwePod + disable + disable + latest + x64 + + VWE Pod Launch Supervisor + + +