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:
@@ -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`.
|
||||
@@ -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).
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)"
|
||||
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
bin/
|
||||
obj/
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user