diff --git a/dpl3-revive/patha/vrview_gl.py b/dpl3-revive/patha/vrview_gl.py index 23372a6..3cbc908 100644 --- a/dpl3-revive/patha/vrview_gl.py +++ b/dpl3-revive/patha/vrview_gl.py @@ -218,6 +218,12 @@ void main() { # VRVIEW_HUDSCALE once the right size is found vs period footage. HUDSCALE = [float(os.environ.get('VRVIEW_HUDSCALE', '1.0'))] +# Scene-pass wireframe (debug): VRVIEW_WIREFRAME=1 or the bridge's 'w' key. +# Scoped to the 3D instance/particle passes only -- global GL wireframe would +# also line-ify the fullscreen present blit and blank the window. (The period +# equivalent, dpl wireframe / ALT-W, is dead code in shipped BTL4OPT.) +WIREFRAME = [os.environ.get('VRVIEW_WIREFRAME', '0') != '0'] + HUD_FS = """ #version 330 uniform vec3 u_col; @@ -499,6 +505,8 @@ class GLRenderer(vrview.Renderer): FIX = np.eye(4) FIX[:3, :3] = [[0, 0, -1], [0, 1, 0], [1, 0, 0]] + ctx.wireframe = bool(WIREFRAME[0]) # scene pass only (see WIREFRAME) + for inst in c.instances: if not vrview.inst_visible(board.nodes, inst): continue @@ -536,6 +544,7 @@ class GLRenderer(vrview.Renderer): self._draw_particles(board, V, vp, self.skip / 60.0) self._draw_psfx(board, V) + ctx.wireframe = False # HUD + present stay solid self._draw_hud2d_gl(board, vp) # present: FBO -> window with the DAC gamma diff --git a/emulator/DEPLOYMENT-PLAN.md b/emulator/DEPLOYMENT-PLAN.md index 6165324..437062a 100644 --- a/emulator/DEPLOYMENT-PLAN.md +++ b/emulator/DEPLOYMENT-PLAN.md @@ -213,10 +213,12 @@ as StopMission on `:1501`, after which the supervisor exits with DOSBox. ## 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 +1. Per-rig display RECTS (already isolated in `pod_deploy.ps1`; deployment variant of the window layout). +(Renderer packaging was OPEN here; RESOLVED 2026-07-10 -- frozen renderer.exe, +see the archive section. An XP/Win11 fleet-floor port is planned but +BACK-BURNERED: `XP-PORT-PLAN.md`.) + Related: `NET-NOTES.md`, `LAUNCH.md`, `ENV-VARS.md`, memory `pod-multiplayer-mesh`, `pod-deployment`. diff --git a/emulator/LAUNCH.md b/emulator/LAUNCH.md index 1205977..b99cd0b 100644 --- a/emulator/LAUNCH.md +++ b/emulator/LAUNCH.md @@ -24,12 +24,24 @@ view, then parks DOSBox at 10,10 with focus (keeps the RIO fed). ``` Layout (edit the RECTS at the top of `pod_deploy.ps1` for this rig's outputs): -main/bridge `0,0` 800x600 · radar/win0 `800,0` · 3-color MFD/win4 `1440,0` · +main/bridge `0,0` 832x512 · radar/win0 `800,0` · 3-color MFD/win4 `1440,0` · 2-color MFD/win3 `2080,0` (heads 640x480). Re-assert DOSBox focus anytime: `& .\emulator\render-bridge\focus_dosbox.ps1`. TODO: borderless-fullscreen main (BRIDGE_BORDERLESS), portrait-radar rotation in cockpit mode if this pod's radar CRT is sideways. +**Main render = 832x512 (2026-07-11), the dPL3 board's NATIVE framebuffer +resolution** — the original Division card rendered 832x512 and scanned it out +itself (NTSC/SVGA off the card). Three independent confirmations: the game's +view flush carries an 832x512 screen (`dpl3-revive/spec/VELOCIRENDER_PROTOCOL.md`), +BT's death-camera stream has floats [10..11] = 832x512 +(`render-bridge/DEATH-SEQUENCE-NOTES.md`), and pick/fire events arrive in +832x512 board-pixel coordinates (`dpl3-revive/patha/vrboard.py`). We render 1:1 +at that size (pod_deploy RECTS + pod-launch `--bridge-size` default) until +everything is wired and verified; presenting on an 800x600 output (letterbox / +scale — the old 800x600 window was a suspected source of rendering issues) is +an OPEN decision after that. + ## Networked (console-driven) — the main ones ```powershell diff --git a/emulator/RIO-NOTES.md b/emulator/RIO-NOTES.md index edf6744..c43a55c 100644 --- a/emulator/RIO-NOTES.md +++ b/emulator/RIO-NOTES.md @@ -5,6 +5,31 @@ plasma display is COM2, handled later). DOSBox-X talks to a real RIO through `serial1=directserial realport:COM1` (`game_rio.conf`). On this host the RIO is a **Prolific USB-to-Serial adapter enumerated as COM1**. +## Named-pipe transport for vRIO/vPLASMA (2026-07-12, replaces com0com) + +The fork now has a `namedpipe` serial backend so the VIRTUAL peripherals need +no com0com pair (no kernel driver, no signing pain, sub-ms latency): + +``` +serial1=namedpipe pipe:vrio rxpollus:100 rxburst:16 # vRIO +serial2=namedpipe pipe:vplasma # plasma readout +``` + +DOSBox is the pipe **client** (500ms background retry; unconnected pipe = +unplugged cable: modem-in lines low, TX discarded). vRIO/vPLASMA are the +**servers** on `\\.\pipe\vrio` / `\\.\pipe\vplasma`. One duplex byte-mode +pipe, typed frames both directions: `0x00 ` = serial data, +`0x01 ` = sender's own DTR(bit0)/RTS(bit1), receiver applies the +null-modem cross (their DTR -> our DSR+CD, their RTS -> our CTS); each side +sends one `0x01` on connect (vRIO sends 0x03 = board present); unknown type = +drop the connection. Contract pinned with the vRIO session 2026-07-12; source +of truth = the `serialnamedpipe.h` header comment (tracked copy in +`emulator/vpx-device/`, apply steps in its README). Smoke-tested end-to-end +(connect, line handshake, DTR/RTS edges on DOS COM open, data both ways, +clean disconnect). Real pods keep `directserial realport:COM1` — the real +board is untouched by this. vRIO side pending: NamedPipeLink + framer (other +session builds it; DTR edge feeds the existing HostHandshake event). + ## The analog-poll latency problem (solved) The initial RIO *check* request tolerates latency and passed easily. But the @@ -348,3 +373,32 @@ event type (8A KeyPressed: unit,key). Verified live 2026-07-06: vRIO hat glances DO work in-game (units 0x41-0x44 arriving), so the 88/89 button path is validated end-to-end -- weapons-not-firing is NOT a unit-code problem; suspect weapon-group arming/mission state (see MECHWEAP.CTL) instead. + +## RIO keypad usage in the games (2026-07-11) + +Keypad = the separate `8A`/`8B` key press/release events, body = (unit, key): +unit selects the keyboard -- 0 = KeyboardPilot (cockpit internal keypad), +1 = KeyboardExternal (operator keypad), 2 = KeyboardPC -- and the engine +converts key to ASCII (0-9 -> '0'-'9', >=10 -> 'A'...) before feeding +`keyboardGroup[unit]` plus a typed-string matcher +([L4CTRL.CPP:2329](../CODE/RP/MUNGA_L4/L4CTRL.CPP)). + +- **RP: the keypad is dead plumbing.** Nothing subscribes to KeyboardPilot or + KeyboardExternal; the only string registration in the shipped source -- + `stringManager.Add("A90", KeyboardPilot, ...)`, type A-9-0 to begin + calibration -- is COMMENTED OUT ([L4CTRL.CPP:850](../CODE/RP/MUNGA_L4/L4CTRL.CPP)). +- **BT: pilot keypad works ONLY in mission review.** Ground truth from the + shipped BTL4OPT binary (C:\VWE\BT411 reconstruction, btl4mppr.cpp): + `MechRIOMapper` subscribes KeyboardPilot -> Mech `KeypressMessageID` under + mode mask `0x200000`, and that mode bit is toggled by the Mech's + mission-review flag -- so keypad presses reach the game only while + reviewing (review navigation; the Mech-side key handler is not yet + recovered, so per-key meanings are still open). In normal cockpit missions + BOTH games ignore the keypad entirely. +- **KeyboardExternal (operator keypad) has no subscriber in either game.** +- Related panel-button findings from the same dig: BT's eight AuxUpperRight + buttons (`0x30-0x37`) are the **"hotbox" pilot-target-select group**, each + with a linked lamp, and the Panic button's (`0x3D`) lamp doubles as the + review-mode/config lamp under `0x200000`. +- **vRIO implication:** keypad emulation is not needed for normal missions; + it only matters if/when mission-review pods are emulated. diff --git a/emulator/XP-PORT-PLAN.md b/emulator/XP-PORT-PLAN.md new file mode 100644 index 0000000..0675b19 --- /dev/null +++ b/emulator/XP-PORT-PLAN.md @@ -0,0 +1,202 @@ +# One archive, Windows XP -> Windows 11 (fleet-floor plan) + +> **STATUS 2026-07-11: BACK-BURNERED (operator call).** This is the plan of +> record for when the port is picked up, not active work. Active effort stays +> on the modern archive's remaining details (DEPLOYMENT-PLAN OPEN items + +> the Phase 0 fleet answers below, which are cheap to gather meanwhile). + +Fleet reality (operator, 2026-07-11): the surviving ~140 cockpits are +**overwhelmingly Windows XP**, a **few Windows 2000**, **4 original DOS PCs** +(Japan), and **Win10 migrations are only now starting**. The cockpit PCs are +**more modern and powerful than "runs XP" suggests** -- the OS floor is a +software constraint, not a hardware one. So the deployable archive must +install and run on **XP SP3 (32-bit) through Windows 11 (x64)** from the +**same zip** -- not an XP fork. + +Current state (see `DEPLOYMENT-PLAN.md`): the packaged pod is INSTALL-VERIFIED +on modern Windows, but every shipped binary is x64 and the floor is effectively +Win10: pod-launch is .NET 8, renderer.exe is frozen Python 3.13 (moderngl, +GL 3.3), configure.ps1 needs the Win8+ NetAdapter cmdlets, and the capture +stack policy assumes Npcap (Win8.1+). None of it loads on XP. + +## Scope decisions (settled unless marked OPEN) + +- **Floor = XP SP3, 32-bit.** Every artifact *inside the zip* becomes 32-bit + x86, PE subsystem 5.1, XP-safe API surface. 32-bit runs unchanged on + Win10/11 under WoW64 -- that is the whole dual-target trick. +- **The 4 DOS cockpits are out of scope** -- they run BT/RP 4.10 natively on + the original hardware; the emulation archive is irrelevant there. +- **Windows 2000 is OUT OF SCOPE (operator call, 2026-07-11).** The few 2000 + units either get lifted to XP SP3 or wait for their Win10 migration; the + archive never targets 2000. +- **Dev-side tooling stays modern.** `package.ps1`, `freeze.ps1`, the build + rigs -- none of it ships; only pod-side artifacts change. +- **The TeslaConsole/TeslaLauncher contract is unchanged** (zip + one folder + + `postinstall.bat` elevated; console registers/invokes/kills). XP has no UAC, + so "elevated" degrades gracefully to the admin account TeslaLauncher already + runs under on the fleet. + +## The five blockers -> five workstreams + +| # | Component | Today | XP->11 plan | +|---|---|---|---| +| A | dosbox-x.exe | x64 MinGW64 static (v2026.06.02 + our patches) | 32-bit XP-compatible build of the same patched tree | +| B | pod-launch | .NET 8 win-x64 | native Win32 C++ supervisor, 32-bit, /SUBSYSTEM 5.01 | +| C | renderer.exe | PyInstaller Py3.13 + moderngl (GL 3.3) | native port of Dave's vrview (fixed-function GL) -- the crux | +| D | configure.ps1 / postinstall | PowerShell 3+ cmdlets | fold into the supervisor exe (`--configure`); batch stays batch | +| E | capture stack | Npcap (manual, Win8.1+) | WinPcap 4.1.3 on XP / Npcap on Win10+, same wpcap.dll API | + +### A. DOSBox-X: 32-bit XP-compatible build of our patched tree + +Upstream DOSBox-X has continuously shipped "Windows XP compatible" 32-bit +MinGW builds; our tree is `emulator/src` = upstream **v2026.06.02** plus our +self-contained patches (VPX device, pcap RX filter, `serial3=file`, etc.). + +1. Verify v2026.06.02 still has the XP build recipe (their MinGW "lowend" + configs / release channel). If upstream dropped it, find the last + XP-supporting tag and forward-port our patches (they are small and + peripheral -- device layer, pcap filter, serial -- not core-emulation + surgery). +2. Reproduce their XP toolchain (pinned 32-bit MinGW, msvcrt-linked -- NOT + today's MSYS2 MINGW64/UCRT) and build **static**, same as the current + packaging rule: imports = Windows system DLLs + delay-load `wpcap.dll` only. +3. SDL choice follows upstream's XP builds (their in-tree SDL1 is the + conservative default; SDL2-for-XP only if their recipe blesses it). + `output=surface`/`ddraw` on XP -- do not assume a GL-capable desktop. +4. Re-verify on XP the features the pod depends on: VPX TCP frame stream + (:8621), NE2000+pcap against WinPcap, AWE32/EMU8000 (bt/rp REQUIRE sound -- + the FAST SOS clock dies without it), `serial3=file` logging, window title / + positioning hooks that `pod_deploy` relies on. + +### B. Supervisor: native Win32 C++ (replaces .NET 8 pod-launch) + +The design survives verbatim -- Job Object + `KILL_ON_JOB_CLOSE` exists since +Win2000, and `JobObject.cs`/`ChildProcess.cs`/`Focus.cs` are already thin +P/Invoke wrappers around the exact Win32 calls the C++ version will make +directly. Port, don't redesign: CreateJobObject/SetInformationJobObject/ +AssignProcessToJobObject/CreateProcess suspended -> assign -> resume -> +WaitForSingleObject, plus the window-layout/focus pass. + +- Build 32-bit, XP-targeting toolset (MSVC `v141_xp` or the same pinned MinGW + as A), statically linked CRT -- **zero runtime deps** (rejecting .NET + Framework 4.0: it would add an air-gapped framework install to every XP + cockpit for no benefit). +- **XP has no nested job objects** (one job per process before Win8). OPEN + (Phase 0 gate): does TeslaLauncher on the fleet already put children in a + job? If yes: launch the supervisor `CREATE_BREAKAWAY_FROM_JOB` (needs + launcher-side `JOB_OBJECT_LIMIT_BREAKAWAY_OK`) or fall back to a + watchdog/kill-tree mode. If no (likely -- native titles are plain children): + nothing changes. +- Drop `createdump.exe`-style diagnostics; a supervisor log file replaces it. + +### C. Renderer: native vrview port -- the crux, and a decision with Dave + +Python is a dead end on XP: CPython dropped XP at 3.5, Dave's GL backend needs +moderngl (GL 3.3 core -- no XP-era GPU/driver has it), and the numpy software +rasterizer (`vrview.Renderer`, today's fallback in `_backend.py`) is a +debugging reference, not something a P4 will push at mission rate. + +**Plan of record: port vrview to C/C++ against fixed-function OpenGL +(1.1 floor, 1.4-1.5 typical on XP-era GeForce/Radeon).** The scene is +1995-vintage art (untextured/lightly-textured polys, flat/gouraud lighting, +the death-camera pass) -- it predates shaders; fixed-function multitexture +covers it. One binary then runs XP through Win11 (GL 1.x still works +everywhere), and the modern moderngl renderer becomes a dev-rig reference. + +- The interface is already frozen and OS-agnostic: `renderer.exe tcp:8621 + ` -- a TCP client consuming the VPX frame stream. The port + swaps cleanly under pod-launch and package.ps1 with zero contract change. +- Spike first (with Dave): inventory what `vrview_gl` actually uses of GL 3.3 + and map each feature to fixed-function or CPU-side transform. Frame + coalescing and SceneCache logic translate 1:1. +- Fallbacks, in order: (1) **two-artifact split** -- ship both renderers, + dispatcher picks by OS (modern rigs keep the verified moderngl exe; XP gets + the port) -- acceptable but two codebases; (2) software rasterizer in C + (SSE2) if the GPU census says the XP boxes have no usable GL driver at all; + (3) companion mini-PC driving the main view head over the existing TCP + bridge -- architecturally free, but it changes cockpit wiring, so it is a + per-site rescue, not the plan. + +### D. Install tooling: one native exe configures, batch stays batch + +`postinstall.bat` is already cmd-only and runs on anything NT -- keep it. +`configure.ps1` cannot run on XP (PowerShell 2.0 max, no NetAdapter module), +and VBScript is being removed from Win11, so scripting is the wrong home. + +**Fold steps 2-4 into the supervisor: `pod-supervisor --configure -Root ...`.** +Same logic, Win32 APIs that exist on both ends: `GetAdaptersAddresses` (XP+) +for the physical-adapter/static-IPv4 scan (keep the fail-loud ambiguity rule +and `-BayIp`/`-ConsoleIp` overrides), the same letter-leading `realnic=` GUID +fragment, bayIP+100 derivation, `02:00:...` MAC, WATTCP.CFG stamping, and +`@@TOKEN@@` template rendering. postinstall calls the exe instead of +PowerShell. One compiled artifact then owns configure + launch + kill on every +OS, and the PowerShell dependency leaves the archive entirely. + +Also: drop `vc_redist.x64.exe` handling (wrong arch for the fleet; statically +linked artifacts need no redist at all). + +### E. Capture stack: WinPcap on XP, Npcap on Win10+, one dlopen surface + +DOSBox-X talks to `wpcap.dll`, and WinPcap 4.1.3 and Npcap both provide it +(Npcap in WinPcap-compat mode). The manual-install-per-cockpit policy +(operator consensus 2026-07-10) already fits: it becomes **"install the +capture stack for your OS, once"** -- WinPcap 4.1.3 on XP, Npcap on Win10/11. + +- WinPcap's BSD-style license permits redistribution, so the XP installer CAN + be bundled in `deploy\` and auto-installed by postinstall (unlike Npcap, + whose free license forbids bundling -- part of why the manual policy + exists). postinstall detection becomes: `sc query npcap || sc query npf`. +- The co-located checksum-offload killer is Npcap/host-capture specific; + real deployments (console on its own machine) are immune on any OS. If a + co-located XP rig is ever needed, offload disable lives in the NIC's + advanced properties instead of `Disable-NetAdapterChecksumOffload`. + +## CPU headroom: verify, but likely a non-issue + +BT/RP 4.10 targeted P90-PPro200; DOSBox-X's 32-bit dynamic core wants a +multi-GHz host to emulate that class of CPU *while also* running EMU8000 +synthesis and feeding the VPX stream -- and the renderer competes for the same +cores. Per the operator (2026-07-11) the cockpit PCs are notably more powerful +than their XP installs suggest, so this is expected to pass; the Phase 1 bench +on a representative box stays as the cheap confirmation gate (Core2-class or +better = comfortable), not as a program risk. + +## Phasing + +- **Phase 0 -- census + contract checks (blocks everything).** + Per-cockpit survey template: CPU/RAM, GPU + driver GL caps, video head + count/wiring, NIC model, OS + SP, free disk. Fleet answers needed: + TeslaLauncher job-object usage (B), which console build the venues run and + where the `+100 DOSBox flag` lands (console workstream, unchanged from + DEPLOYMENT-PLAN), Win2000 unit count (scope), representative XP loaner box + for the bench. +- **Phase 1 -- DOSBox-X XP build + THE BENCH (biggest unknown first).** + Workstream A; exit gate = egg->mission at FAST clock with sound on + representative XP hardware, WinPcap mesh verified between two XP boxes. +- **Phase 2 -- supervisor + configure exe (B + D).** Exit gate = fresh zip + extract -> postinstall -> configure -> launch -> kill-cascade on XP SP3 VM + and Win11, same zip. +- **Phase 3 -- renderer port (C, with Dave).** Spike -> port -> live-verify + against the frozen VPX stream on both OS ends. +- **Phase 4 -- packaging + validation matrix.** package.ps1 swaps in the + 32-bit artifact set (it already auto-bundles what's present). Matrix: XP SP3 + VM (bridged pcap), Win11 x64, then a real XP cockpit soak; renderer decay / + death-camera / mission-review passes re-run per `CAMERA-REVIEW-NOTES.md`. + +The current x64 package keeps shipping to Win10/11 rigs throughout; the XP +work replaces it only when the same zip passes the full matrix on both ends. + +## OPEN items + +1. Phase 0 census results -- GPU/driver GL caps decide the renderer fallback + (CPU expected fine per operator; confirm with the bench). +2. TeslaLauncher job-object behavior on the fleet (breakaway or not). +3. Fleet console build + where the per-title DOSBox `+100` flag is applied. +4. Upstream v2026.06.02 XP-build recipe intact? (else: last XP-supporting tag + + patch forward-port). +5. Renderer port ownership: likely **in-house** (operator, 2026-07-11 -- + "we may have to do the renderer work ourselves"); Dave's involvement + reduces to the vrview_gl feature inventory / consultation. The native + fixed-function port stays the plan of record either way -- vrview (the + software reference) plus the frozen VPX stream give us a full spec and a + pixel-comparison oracle without needing the original author. diff --git a/emulator/net_rp_pipe.conf b/emulator/net_rp_pipe.conf new file mode 100644 index 0000000..57a00c7 --- /dev/null +++ b/emulator/net_rp_pipe.conf @@ -0,0 +1,63 @@ +# Red Planet 4.10 networked pod boot (production path, no -egg): console pushes +# the mission egg over TCP 1501; pod runs RPL4OPT under netnub. Same pod HW as +# BT: VPX board @0x150, RIO COM1, dual AWE32, plasma COM2. RP's setenv.bat +# auto-selects rpdpl.ini. NIC on IRQ 10 (COM2/plasma owns IRQ 3 -- see +# net_full.conf header). The dpl3-revive bridge renders RP content (same +# VelociRender wire). Modern TeslaConsole is RP-native, so this is its home game. +[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 +backend=pcap +[ethernet, pcap] +realnic=DB5521D +[sblaster] +sbtype=sb16 +sbbase=220 +irq=5 +dma=1 +hdma=5 +[mixer] +rate=44100 +blocksize=1024 +prebuffer=60 +[serial] +serial1=namedpipe pipe:vrio rxpollus:100 rxburst:16 +serial2=namedpipe pipe:vplasma +[autoexec] +mount c "C:\VWE\TeslaRel410\ALPHA_1" +mount d "C:\VWE\TeslaRel410\emulator\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/pod-launch/Options.cs b/emulator/pod-launch/Options.cs index aa23239..0004a49 100644 --- a/emulator/pod-launch/Options.cs +++ b/emulator/pod-launch/Options.cs @@ -31,8 +31,11 @@ namespace VwePod public bool DryRun; public string BridgePos = "0,0"; // main out-the-window head - public int BridgeW = 800; - public int BridgeH = 600; + // 832x512 = the dPL3 board's native framebuffer res (the game's view + // flush / pick coords are 832x512). Render 1:1 until the 800x600 + // presentation question is settled (see LAUNCH.md). + public int BridgeW = 832; + public int BridgeH = 512; public int DosBoxX = 10; public int DosBoxY = 10; @@ -167,7 +170,7 @@ namespace VwePod --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) + --bridge-size w,h main render window size (default 832,512 = dPL3-native) --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. WARNING: no AWE32 = diff --git a/emulator/pod-launch/README.md b/emulator/pod-launch/README.md index 5bf8525..e6458ff 100644 --- a/emulator/pod-launch/README.md +++ b/emulator/pod-launch/README.md @@ -75,7 +75,7 @@ see `../CAMERA-REVIEW-NOTES.md`. Default mode: `bt`. | Arg | Effect | |---|---| | `--bridge-pos x,y` | main render window position (default `0,0`) | -| `--bridge-size w,h` | main render size (default `800,600`) | +| `--bridge-size w,h` | main render size (default `832,512` — the dPL3 board's native framebuffer res; see `../LAUNCH.md`) | | `--dosbox-xy x,y` | DOSBox window position + focus (default `10,10`) | ### Path / asset overrides (dev / edge — `postinstall` already wires these) diff --git a/emulator/render-bridge/COCKPIT-CAGE-NOTES.md b/emulator/render-bridge/COCKPIT-CAGE-NOTES.md new file mode 100644 index 0000000..a12e940 --- /dev/null +++ b/emulator/render-bridge/COCKPIT-CAGE-NOTES.md @@ -0,0 +1,500 @@ +# Cockpit cage, glances, and the "black rear view" (2026-07-11) + +> **SUPERSEDED IN PART -- see "2026-07-12: the BT411 cross-check" at the +> bottom.** The geometry forensics below stand; the identities and the fix +> plan were revised after reading the BT411 port's reconstruction. + +Operator report: hat-down (rear glance) = black display; sliding the seat cam +back showed "a large black cube-ish". Full forensics from a live-session +fifodump snapshot (offline renders + wire scans). Resolution shipped in +`live_bridge.py` (cage twist + unframed glances). + +## What the geometry actually is + +Two cockpit fixtures ride the player mech (own-chain, `gated`): + +| inst | dcs | mount (wire) | shape | +|---|---|---|---| +| `9fd` **cockpit CAGE** | `9fe` | `dcs_link` DIRECTLY under the vehicle root (`7 9fc 9fe`) | 45 verts / 40 tris, 21x21x42, **all faces inward** -- open framed panes forward, SOLID textured wall aft; invisible from outside (backface-culled) | +| `a11` canopy trim | `a10` | linked into the head rig (`7 a12 a10`, `7 a10 a3e`) | 5 geoms, r=3.4, at head height; the near-cull (<10 units) hides it in first person | + +Both DCS create-records carry **zone `9fb`** (type-2 node, created at mission +staging alongside the two boot zones). The three ER-laser beam instances are +own-chain + gated too (r=2000; the fixture filter excludes them by radius). + +- **The black rear view = the cage's solid rear wall**, verified by rendering + the snapshot offline: forward through the panes is a perfect canopy view, + rear is wall-to-wall black. Seat-cam slid back = the same shell seen from + its edge (plus the canopy un-culling past 10 units). +- The mech has NO real model instance of itself in the scene -- from outside + (chase cam) the player mech IS a small black box. Other mechs are full + models; your own is cage + canopy + beams only. + +## What the wire does NOT do + +Scanned the whole session dump (~43k records): + +- The game **never updates** the cage/canopy nodes after staging -- no + arm/disarm, no DCS re-pose, nothing per-glance. +- **No view switching** during glances: ONE type-3 view node (handle 3); its + 36 flushes are projection/fog only (incl. the mission-start fog sweep and + the screen dims -- the game itself programs **832x512**, w12/w13). +- Zone masks (`ffffffff` -> `0fffffff`) are set once at boot/staging for all + zones uniformly -- not per-glance. +- Texture alpha-cutout is NOT the rear-wall mechanism: zero of the 15 texmap + nodes carry the alpha flag, and the wall texels (17,17,17 sum=51) wouldn't + pass the <=24 discard threshold anyway. +- Glances arrive as ordinary head-DCS chain motion (nothing else flushes). + +## Ground truth (operator, ran the real pods) + +Tank-turret model: **torso twist = turning the turret; the cockpit cage +tracks the twist.** The hat moves the PILOT'S HEAD on top of that (twist 90 +right + hat right = looking back down your path). And **glance views are +UNFRAMED** -- the real pods showed a clean world view with no cockpit framing +on hat glances. (So hat-rear was never "through the cage's rear wall" -- the +real views simply didn't render the cockpit fixtures.) + +## Fix shipped (live_bridge.py -- bridge policy, Dave's core untouched) + +1. **Cage twist**: `hook_chain_matrix` wraps `Renderer.chain_matrix`; chains + rooted at the cage DCS get the camera's composed torso-twist yaw + pre-multiplied in model space (`v @ Y @ M`; cage chain is [cage_dcs, root] + with identity local, so Y lands exactly as a yaw about the mech axis). + `CAGE_TWIST_SIGN` flips (default follows the camera's twist). +2. **Unframed glances**: `fp_cam` now reports `.glance` = look direction + deviating > `GLANCE_DEG` (45) HORIZONTALLY from the twisted hull heading + (stick-Y pitch can't trip it; TORSO.SUB vertical limits are +10/-30). + While glancing, `render()` draws with the cockpit fixtures filtered out. + `GLANCE_HIDE=0` disables. +3. Fixture identification (`cockpit_refresh`, after every SceneCache + rebuild): own-chain + gated + not billboard + radius < 100; the cage = + the one hanging directly under the root (chain length 2, radius > 5). + Verified on the snapshot: picks exactly {9fd, a11}, cage dcs 9fe. + +## Open / related + +- **Laser aim vs torso** (operator): beams render/aim along the hull heading, + missiles track the torso. The beam chains ride the DCS rig, and the rig's + flushed values EXCLUDE joint angles (the torso joint arrives only as 0x1f + (sin,cos) entries) -- composing joint rotations into instance chains at the + right chain position is the proper fix (would move the beams' origin AND + direction with the twist). The pick ray (CAM backchannel) already follows + the twisted look -- likely why missiles track. +- Canopy trim (`a11`) twist: same joint-composition gap; currently masked by + the near-cull in first person. +- Whether the ORIGINAL board treated zone-9fb instances specially + (camera-space / per-view zone enables) is still undetermined -- our fix + implements the operator-specified behavior at the bridge level instead. +- The pod crashed at session end with a guest "Illegal Unhandled Interrupt 6" + storm (game-side; unrelated to the bridge -- device untouched today). + +## 2026-07-12: the BT411 cross-check (C:\VWE\BT411, fresh pull) -- REVISED PICTURE + +The concurrent BT411 port hit the SAME black-enclosure cockpit problem, and +their reconstruction settles the identities and the authentic mechanism: + +1. **Instance `a11` = `MAX_COP.BGF`, the REAL cockpit shell** -- byte-proven: + 272 verts / extent 2.5x2.3x3.3 / Y 0.4..2.7 matches a11's five geoms + exactly (every mech has one: `*_COP.BGF`, measured with + scratchpad cop_bounds.py). Per BT411 (open-questions.md): it is **the + TORSO segment's inside-skeleton mesh** -- so it rotates with the torso on + the real rig (operator's turret model confirmed) -- and **its window panes + are PUNCH texels**: "with punch live the black pane texels become holes + and the world shows through the frame". THE PUNCH CUTOUT is why real + glances read as (nearly) unframed world -- the shell is never hidden. The + operator's memory of seeing "the cage or the mech's arm" on left/right + glances fits: thin frame lines + arm through the punched side panes. + (BT411's interim policy is ours too: they hide `_cop` by name filter + until their punch/skeleton branch works, `BT_INSIDE_COCKPIT=1` reveals.) +2. **Instance `9fd` (the 21x21x42 inward box, root-linked, hidden-until- + armed) = the mission fade shroud** (`BTPOVStartEndRenderable`, armed for + MissionStartingState=3 / MissionEndingState=4 with dplMainZone+ + dplDeathZone). Session 1's "black rear wall" happened because THAT + mission had ENDED (egg timer expired) and the shroud was armed; session 2 + (fresh mission) never armed it -- which is why the twist hook "did + nothing": the visible frame was a11 all along. +3. **Glances = `EyepointRotation`** (mech attribute; mapper look states + LookLeft/Right/Behind/Down; LookBehind = yaw pi + pitch), composed into + the view every frame by DPLEyeRenderable. The look commit ALSO walks the + weapon roster setting the weapon+0x3e0 gate per look state -- **glancing + inhibits FIRE, it does NOT hide cockpit fixtures** (my "camera children" + read was wrong; those offsets are the weapon fields from the fire-gate + disasm). BT411 has the whole commit block deferred (offset conflicts) -- + their port has no glances yet either. +4. **On our wire, glances/twist ride the 0x1f articulation batches only** + (cam-chain DCS bodies flush exactly once at creation -- verified across + the whole session dump). The "hats click but nothing renders" at session + end: the final 10% of the dump has ZERO 0x1f batches -- the game had + stopped articulating entirely (wedged/ended state, pre-crash), not a + bridge regression. + +### GLANCES SOLVED + LIVE-CONFIRMED 2026-07-14 -- it was a KeyError crash + +**Operator confirmed hat glances now render correctly.** The whole saga +resolved to a one-line bug of my own making, found via the operator's key +symptom: "when the hat is held the screen STOPS rendering and snaps to the +current view on release." That = render() was THROWING during glances. + +Root cause: render()'s glance-hide guard referenced `_ckpt['fix']`, but I had +renamed that key to `'fixh'` days earlier (cockpit_refresh) and never updated +this line -> **KeyError EVERY frame where fp_cam.glance was true** (i.e. every +glance frame). The exception was swallowed by render()'s try/except, so NO +frame drew during a hold (screen froze), and on release fp_cam.glance went +false, the guard short-circuited before the bad key, and the draw resumed +("snaps to current"). fp_cam was computing the glance camera correctly the +WHOLE time (out_yaw provably swung with the hat) -- the crash was downstream +of it, in the draw dispatch. + +Fix: REMOVED the glance-hide block entirely (always `r.draw(board)`). This +both kills the crash AND implements the already-decided descope (pilots +confirm the canopy stays visible during glances -- a11/MAX_COP has no rear +geometry so rear reads unframed naturally). The glance rides the camera via +fp_cam, which reads the eye-DCS 0x1f absolute pose -- exactly the authentic +game->board wire op the operator insisted it was. + +**The operator was right at every turn:** it was a wire operation (action +0x1f eye-DCS reflush), NOT input/transport/binding; do NOT tee from vRIO. My +successive "game-side / fix_degenerate / not-emitted" theories were all +wrong; the persistence on "find where we drop it in the data path" was +correct -- we were crashing on it. + +### REAR VIEW (hat-down) 2026-07-14 -- canopy drop, LIVE-CONFIRMED + +Right after glances started rendering, the rear glance (hat-down / LookBehind) +came up BLACK, and sliding the seat back showed a black box. Chased it: +- NOT the 9fd shroud: in gameplay 9fd is inst_visible=0 (unarmed, not drawn); + hiding it was a no-op. The live probe's "own drawn" list showed the ONLY + own-mech instance drawn is a11 (the canopy, MAX_COP, r3). +- The canopy is a shell around the head: open front, dark solid BACK. Looking + forward = through the open front (world + strut framing); looking behind = + the canopy's dark interior back = black. GL render confirmed: rear WITH a11 + = black, rear WITHOUT a11 = full arena (geometry is all there behind). + +OPERATOR (ran the real pods): **hat-down on original hardware gave a CLEAR +rear view, NO canopy framing** (left/right glances DO keep the canopy). So the +authentic rule is direction-dependent. Fix: fp_cam computes glance_dev (look +deviation from twisted hull heading); render() drops the canopy fixtures when +glance_dev > REAR_DEG (110 deg default -- left/right ~50 deg keep it, rear +~180 deg drops it). Operator confirmed "acceptable behaviour." + +COCKPIT CAMERA COMPLETE + ALL LIVE-CONFIRMED: twist (turret-true, zero +parallax) + lasers follow torso + hat glances left/right (canopy framed) + +hat-down clean rear view + the pipe transport (vRIO/vPLASMA). Remaining is +cosmetic (IR/thermal palette on action 0x1b, texture interpolation, per-mech +seat trim). Diagnostic scaffolding (glance_probe, fp_cam.out_yaw telemetry, +own-drawn list, BRIDGE_AUTOSAVE) left in, harmless; trim in a cleanup pass. + +### GLANCE: THE forensics trail 2026-07-14 (era A/B -- superseded by the fix above) + +**There is no glance regression -- the wire NEVER carried a hat glance, in +any era.** Prompted by the operator's challenge ("engine unchanged, only the +transport changed com0com->pipe -- so why did it break?"), diffed a 7/07 +working-era capture vs 7/13: +- Cam-chain DCSs flush ONCE at construction in BOTH eras; no per-glance + reflush ever (era_diff.py). +- `fix_degenerate` provably changes the look yaw by ZERO (bisect_glance.py: + raw == fixd at every checkpoint) -- EXONERATED as the culprit. +- Decomposing the look into hull + twist + residual (find_glance.py): the + residual is FLAT across both 7/07 captures (only the +-180 chain- + construction artifact). No independent glance rotation in the wire. + +**What actually happened (from our own memory + these forensics):** the game +never emitted a chain-independent glance through our HLE device. The single +time hat glances "worked" (2026-07-07) was a BRIDGE fp_cam hack (recover the +glance from the degenerate chain, swap yaw<->pitch) that the operator +confirmed "momentarily" -- and it was REVERTED the same day because it +corrupted stick-Y vertical aim into yaw. The "source fix" that replaced it +(`fix_degenerate`) is inert for glances (proven above). So there is NOTHING +to bisect: glances were a reverted experiment, not a lost feature; the +com0com->pipe transport change coincided with "stopped working" purely by +timing. This RETIRES the prior "game-side binding / keyboard-dead" and +"fix_degenerate ate it" theories -- both wrong. + +**Path forward (operator principle 2026-07-14: DO NOT tee from vRIO -- the +glance is an authentic game->board WIRE op; find where we drop/misdecode +it).** This is CORRECT and cracked open the key protocol fact: + +**RUNTIME DCS UPDATES TRANSMIT AS ACTION 0x1f, NOT 3.** BT411 FUN_0048ffa4 +(flush dirty-DCS list) + FUN_0048e440 -> FUN_00492580(**0x1f**, ...): a +runtime DCS matrix update -- INCLUDING the eyepoint reflush -- packs per +DCS either 12 floats (type-0, full matrix) or 2 words (type 1-3) and sends +on **action 0x1f**, the same action as the articulation batch. So every +earlier "no reflush" scan (which looked at action 3) was blind to it. The +glance lives in the 0x1f stream. + +**Where our render stands vs that:** the eye DCS = the node the game +list-adds to the view (6df in glance_snap); it IS a 12-float 0x1f node and +IS in our cam_chain -> chain_matrix already applies it, so a real glance +reflush WOULD render for free. BUT: our fp_cam only composes 0x1f *joints* +(2-float sin/cos) for cam_chain members (the twist sum); a glance carried +as a 2-word entry on a DCS OUTSIDE our heuristic chain would be parsed and +DISCARDED -- a concrete renderer gap if that's the form. + +**What the captures show (glance_snap, plateau.py):** NO 0x1f handle shows a +sustained hold-then-return matching the 5s hat holds -- the eye DCS (6df) +moves only with hull/drive, walk joints just oscillate (runs 15-45), static +mounts sit at 180. So in OUR broken-setup capture the game isn't emitting a +glance reflush. But this capture is from the NON-working setup, so it can't +distinguish "renderer drops it" from "game doesn't emit it" -- need a +capture that contains a real glance. + +**DECISIVE next step (probe rewritten -- glance_probe now watches the RIGHT +signal: eye/leaf 0x1f yaw + out-of-chain 0x1f movers, per 4s report):** a +NARRATED 5s hold next session settles it -- leaf yaw plateaus with the hold += game emits, we must render it (bridge application bug, find it); +out-of-chain mover plateaus = we parse-and-drop it (apply that handle); +both flat = game not emitting (then it's upstream, the hat->look-state +binding). NO vRIO tee. + +### GLANCE MECHANISM DECODED 2026-07-14 (disasm) + eyepoint pipeline (reference) + +Chased the hat glance through the shipped BTL4OPT binary (BT411 decomp). +The camera path, end to end: +- The piloted mech builds as **insideEntity** (btl4vid.cpp:300-304) -> the + **eyepoint camera is a board DCS** created by **DPLEyeRenderable** + (ctor @004579a8): eye DCS handle at [this+0x10], and a pointer to the + mech's **EyepointRotation** attribute at [this+0x48]. +- Per frame, **DPLEyeRenderable update @00457b48**: if EyepointRotation + changed (vs cached [this+0x4c]), recompose the eye DCS matrix from the + euler and mark it dirty (@0045fbf4 appends to the renderer's dirty-DCS + list @+0x2c8) -> the eye DCS gets **reflushed to the board**. +- EyepointRotation (mech+0x360) is written by the **mapper look-commit** + (mechmppr.cpp KeypressMessageHandler / InterpretControls, part_013.c:418): + when look-state (0x198, derived from lookLeft/Right/Behind/Down at + param+0x134..0x140) changes, it writes the euler (LookBehind = yaw pi = + 0x40490fdb, pitch from torso+0x570) and re-slaves the camera children. + +**So a glance = an eye-DCS reflush, and that DCS is IN our cam chain -- +we'd render it for FREE if it happened.** It doesn't: the glance-probe +premise (validated on glance_snap: all 11 cam-DCS reflushes are at staging +22.6%, ZERO during the 80-98% holds) proves the game never moves the eye +during our glances. + +**Root cause (narrowed):** EyepointRotation never changes => the mapper +look-commit never fires => the look-state fields never change. Those fields +are set by the ARROW keys via KeypressMessageHandler (keyboard is DEAD in +the pod, see above) -- and the HAT button reaches an audio-mapped action +(hence the glance SOUND) but, in this build/config, does not set the +look-state fields. Both glance routes are therefore blocked in the current +-egg trim; "worked on 7/07" was a different session config. Overturns two +earlier claims: NOT a view/zone re-parent (the only view list-ops are at +staging) and NOT a wire-parse blind spot (zero 0x1f parse failures). + +**Decisive next step (telemetry SHIPPED, live_bridge glance_probe):** a +NARRATED hat hold ("holding left NOW") vs the `GLANCE-PROBE: cam DCS .. +REFLUSHED` line settles it: a reflush during a hold = camera IS moving +(bridge bug, unlikely per forensics); silence = game-side gate confirmed, +and the fix moves to the input binding (make the hat set the look-state +fields -- CTL binding / the same fields the dead arrow keys drive), OR a +bridge-synthetic glance (compose glance yaw in fp_cam like twist -- but the +bridge needs a hat-state feed it doesn't currently have; the game's +per-hat instance-visibility blip on inst-701-family + zone is the only +wire-observable candidate and needs the narrated capture to decode). +The in-game keyboard being dead is now ON the glance critical path -- fixing +it (or binding the hat to look) may be the whole fix. + +### PUNCH-CUTOUT DESCOPED 2026-07-13 (pilot testimony) + +Operators who flew the pods report **seeing canopy geometry when hat-glancing +left/right** -- the shell structure was VISIBLE in glance views. This matches +a11/MAX_COP's measured geometry exactly: all five geoms sit FORWARD of the +head (bbox z -3.4..-0.7, NO rear geometry), so rear glance = naturally +unframed world and side glances = sweeping past the struts. NO punch cutout +is needed for glance correctness; the 7/12 "black rear wall" was the 9fd +fade shroud (mission-end state), not the canopy. Consequences: +- The punch-cutout work item is DROPPED for the canopy (BT411's D3D port + still needs it for their blx_cop texture; and the punch texel semantics + may still matter for OTHER textures someday -- footnote only). +- **The GLANCE-HIDE policy must RETIRE once glances render**: authentic + glances show the canopy sweeping past, so hiding fixtures during glances + is anti-authentic. Keep GLANCE_HIDE only until the eyepoint pipeline + works, then default it OFF. + +### Revised fix plan + +- **The authentic fix for the shell = implement the PUNCH texel cutout** for + `_cop` textures in the renderer (their windows render as holes). Our wire + carries NO texmap alpha-flag words (all 15 = 0), and the shell's dark + texels (17,17,17, sum 51) exceed vrview_gl's alpha-cut threshold (<=24) -- + so BT punch is a DIFFERENT flag/semantic than the FLYK-era cutout: find + where the punch attribute travels on the VPX wire (texture upload header? + b2z tag?) or key it on the `_cop` texture content. With punch live, the + glance-hide policy for a11 RETIRES (authentic = shell always on, windows + see through); keep hiding 9fd behavior as-is (it is a fade shroud -- its + blackness is the point). +- **Twist**: a11 is torso-mounted per BT411, and the operator watched it + stay chassis-locked -- so our chains genuinely lack the torso joint + rotation (confirmed joint-composition gap). Bridge yaw hook targets + fixture DCS a10; first live test DOUBLED (sign inverted in model space); + `CAGE_TWIST_SIGN=-1` is loaded but UNTESTED (sim was retired for the + night). The proper engine-level fix stays: compose the 0x1f joint + (sin,cos) rotations into instance chains at the joint DCS -- BT411's + gyro/eye-joint reconstruction (IntegrateEyeJoint @004b2ec0, currently a + NaN-stub for them) is the reference for WHERE the joints sit. +- **Lasers vs missiles**: BT411 confirms the crosshair = torso boresight + (stick twists the torso to aim; no free-aim on twist mechs). Their + open item (b): "on a TWIST-CAPABLE mech the crosshair should deflect with + the torso twist AND the torso should visibly lead the legs". Our beam + render + aim ride the joint-less chain = hull heading; the pick ray (CAM + backchannel) is twist-aware = missiles track. Same joint-composition fix + covers the beams (origin AND direction). +- 9fd arming = mission state; a mission-review/production run will show the + start blackout + end blackout arming on the wire (watch instance body + word4 flushes on 9fd). + +## 2026-07-13: twist SOLVED (root-pivot + mirrored basis); glances = wire-silent + +First full pipe-transport session (namedpipe vRIO/vPLASMA: controls + plasma +confirmed good). Offline forensics on the glance/twist capture +(scratchpad glance_snap.fifodump, torso held to +68deg at 92.2%): + +**LIVE-CONFIRMED 2026-07-13 night (operator): the canopy tracks the torso +twist (turret-true), and the LASER BEAMS follow the torso too.** Final +recipe: root-pivot yaw conjugation (M inv(Mr) Y Mr) at CAGE_TWIST_SIGN=+1 +applied to ALL own gated instances (canopy + shroud + the three gun-mount +beam instances -- twist_dcs; beams excluded from the glance-hide set), plus +the camera EYE swung along the same turret arc in fp_cam (operator: the +head is rigid with the torso -- zero shell/view parallax; the eye-position +arc is the authentic world-view motion). Closes both "cage locked to +chassis" and "lasers fire where the chassis faces". + +ALSO FOUND 2026-07-13 night: **in-game PC keyboard input is DEAD in the +pod** ('l' searchlight dev key, '+'/'-' zoom, arrows: nothing; DOS-prompt +typing fine). Never historically validated -- may have been broken forever. +Suspects for a cold test: the shipped binary's L4CONTROLS parse +(setenv echoes RIO,KEYBOARD so policy is right), or the VPX device's extra +SDL head windows eating keystrokes. Consequence: the arrow-key glance +experiment is VOID -- keyboard nulls prove nothing about the look pipeline. +Glance regression suspect narrowed to vRIO hold semantics (old COM build +glanced fine yesterday; glances are the hat's only HOLD-driven function; +ask vRIO's log: 88 on deflect, 89 only on release?). + +**Twist sign CORRECTED 2026-07-13 late (rendered A/B, ab2_*.png): default +CAGE_TWIST_SIGN=+1.** The -1 default came from a numeric calibration that +mixed yaw-sign conventions; the rendered A/B at the held +68deg twist shows ++1 brings the shell around WITH the twist (turret behavior; the small +parallax = head-off-axis, authentic) while -1 rotates it OUT of view (the +"canopy does not rotate" live report -- it was leaving the frustum). +Also learned: handle namespaces alternate between missions (9fc/a10 vs +69c/6b0 rigs) -- never hardcode; the fixture detection handles it. +One live confirm pending. + +**BT411 new-pull digest (2026-07-13 evening, renderer-relevant):** +- FOG restored (task #63): authored per-map/time/WEATHER in BTDPL.INI + (weather = an EGG field: clear/fog/soup); the arcade fog = + dpl_fog_type_pixel_lin (per-pixel linear) -- Dave's GL shader is already + per-pixel linear, so our fog is authentic; useful for checking per-egg + fog VALUES. +- Gait (task #59): piloted mech plays INTERIOR 'i'-suffix clips -- NO hip + lean (level cockpit) + jointshakey cockpit rattle; exterior mechs lean + -8/-11 deg into walk/run. Our wire carries whatever the game plays => + the bridge inherits level+rattle natively; other mechs SHOULD visibly + lean (check live). +- Torso twist live (task #57/58) w/ centered boresight crosshair -- + reference for our laser-aim/joint-composition item. +- Missile splash/damage-economy fixes + DAFC muzzle flash (task #60-62) + and the i860 specialfx forensics (065c114) -- board-side effect engine + decode, groundwork for rendering detonations/impact FX in the bridge. + +**Twist -- measured and closed.** Yaw ledger: the camera = hull yaw (chain +Z-row, contains NO twist) + JOINTS twist sum; the canopy is HULL-LOCKED +natively (its world yaw tracks the hull exactly through twists). The three +confusing live observations all came from ONE bug: the hook's model-space +pre-multiply yawed the canopy about its own local origin, swinging it +sideways in an arc. Fix: compose the yaw ABOUT THE VEHICLE-ROOT AXIS +(M' = M inv(Mr) Y Mr, Mr = root pose from anim_abs) -- distance-from-axis +preserved exactly (pivot_check.py). One subtlety: the rig's root basis is +MIRRORED (det -1, same family as FP_RIGHT_SIGN/fliplr), which inverts the +yaw sense under conjugation -- hence default CAGE_TWIST_SIGN=-1, calibrated +offline (canopy lands exactly on native+twist). AWAITING one live confirm. + +**Glances -- the game never puts them on the wire (in the test egg).** +Around-the-clock 5s holds captured; exhaustive scans of the tail: NO pose +node rotates, NO joint pulses match holds (the 12-joint flurry = the legs +WALKING; the +-64deg sweeps = the twist test itself; the zero-translation +anim nodes = STATIC rear-facing mounts at yaw 180 always -- NOT eyepoints; +my 6b3 identification was wrong). Meanwhile the game DOES react internally +(range-bar jumps on hats). Best hypothesis, matching BT411: the glance +renders via the GYROSCOPE's eye-joint integrators (IntegrateEyeJoint +@004b2ec0 -- incomplete/NaN-stubbed in their port too), and that subsystem +never emits in our -egg trim -- OR earlier "working glances" (7/07) came +through a path that today's state doesn't drive. The 7/07 sessions DID +render glances, so the signal exists under some conditions -- find what +differs (egg? mission state? control mode BAS/MID/ADV? gyro power?). + +## 2026-07-13: vision modes (IR/thermal) + the shipped keyboard map + +Looked up the rumored CTRL-W / CTRL-T vision keys in BTL4OPT.EXE +(BT411 decomp + reconstruction). Verdict: **no such bindings exist in the +shipped binary.** Plain `w`/`t` are pilot-target select (slots 0/3). The +RP-era engine SOURCE had ALT-W = wireframe and ALT-V = "predator vision" +debug keys (L4APP.cpp), but in the shipped BT binary the wireframe toggle +(@0045fdd8, prints "wireframe ON/OFF") is DEAD CODE -- no caller, no +pointer-table reference -- and no app-level ALT-key switch exists at all. +The tip most likely drifted from ALT-W/ALT-V or a different build. + +**The REAL IR/thermal mode -- and how to render it:** +- `ThermalSight` subsystem (BT411 thermalsight.hpp, classID 0x0BDE, a + PowerWatcher): driven by the cockpit IR BUTTON (`requestedOn`), gated by + generator power + heat + being the locally-viewed mech. +- Its sim calls the global pvision toggle @0045fe44 (prints "pvision + ON"/"pvision OFF") which issues **`dpl_Effect`(type 0, handle 0, + {c,c,c}, mode) with mode = -1 (ON) / -2 (OFF)** -- a special view-effect + = the Division board's thermal palette flip. THAT is the wire signature + our renderer must honor: watch for the effect/fog-mode carrying -1/-2 on + the view and swap the output palette (the "IR button = thermal render + mode, nothing to render it" note from 7/06 closes here). +- dpl_Effect type map (from callers): 0 = view color effect/fog-family + (color + mode), 1 = per-handle effect, 2 = clear/reset. The pvision call + is type 0 on handle 0 (the main view). +- **Wire encoding (host stub @0048e168): type-0 effects transmit ACTION + 0x1b, payload = [mode:u32][node:u32][0x40-byte color struct]** -- fog uses + the same action with ordinary modes (0x1b already appears in our session + histograms); **pvision = mode 0xFFFFFFFF (ON) / 0xFFFFFFFE (OFF)**. Bridge + implementation = watch action 0x1b word0 for -1/-2, flip a thermal + palette in the present pass. (Type-1 effects = action 0x1d, 0x108 bytes; + type-2 = action 0x23, 8 bytes.) + +**"Some mechs use IR, some use another vision mode" (operator) -- tracked +down 2026-07-13, game-side verdict: there is exactly ONE vision mode.** +Every mech roster in BTL4.RES carries the SAME ThermalSight subsystem +(BT411 weapsub audit, all chains), it has no per-mech model fields, the IR +button gate at record+0x25c is the electrical-short event flag (not a +capability switch), and the pvision toggle takes no parameters. Candidate +explanations for the memory, for the old pilots to arbitrate: +(a) the OTHER vision aid on every roster is the SEARCHLIGHT (night +illumination cone) -- "IR mech vs searchlight mech" habits; (b) ThermalSight +SHUTS OFF at heat alarm level >= 2 -- hot energy-boat mechs lose IR +constantly, reading as "this mech doesn't have IR"; (c) a different build +('96 BTLIVE vs 4.10) or the sensor-MFD display modes. Ask: WHICH mechs had +which mode, and what did it look like (white-hot vs green)? +Archaeology bonus: sda4/DPL3/VRENDER/VWE.DOC = Division musing on a +Pixel-Planes 5 / DEC Alpha board for VWE (the PXPL5SUP dir) -- pvision's +official name in the engine is "predator vision". + +**Shipped keyboard map (PC keyboard, KeypressMessageHandler @004d1bf0)**, +useful for dev testing from the DOSBox window: +`+`/`-` target-range zoom; `1-5` + `a b c d f g s v x z` weapon/preset +modes; `w e r t y u i o` pilot-target select; `j`/`l` fake LRM10 / +Searchlight buttons; F1/F2 joystick align begin/end; 0x13d/0x13e cycle +control/display mode; **ARROW KEYS = the eyepoint look keys** (UP=forward, +LEFT/RIGHT=glance left/right, DOWN=look BEHIND, CTRL-UP=look down); +Ctrl-F1..F10 = fake subsystem buttons (GeneratorA-D, Condenser1, LRM15 +fire/ammo/load, Avionics, Hud); `\` = mech message 0x19; `&` = stop +mission. NOTE for the glance hunt: the arrow keys drive the SAME +lookLeft/lookRight/lookBehind/lookDown fields as the hat -- a keyboard +glance test needs no vRIO and isolates the eyepoint->board question. + +**Renderer backlog (operator, 2026-07-13): the Division card did TEXTURE +INTERPOLATION (bilinear filtering)** -- check vrview_gl's texture sampler +(likely NEAREST) and implement/verify linear filtering for authenticity. + +**Bridge state:** the eyepoint camera compose is EXPLICIT OPT-IN only +(GLANCE_DCS=) -- auto-detection would have latched the static 180 +mounts and flipped the camera permanently. New telemetry for the narrated +hunt: the 4s report prints `wire: NEW anim/joints=[...]` whenever handles +first appear in the 0x1f stream -- next session, have the pilot narrate +("holding hat-left NOW") and read the log; if nothing appears during holds, +compare against a 7/07-style run (gauge_arena_sound.conf, no net stack) to +isolate what enables the eyepoint emission. diff --git a/emulator/render-bridge/gauge_arena_pipe_sound.conf b/emulator/render-bridge/gauge_arena_pipe_sound.conf new file mode 100644 index 0000000..90b93f5 --- /dev/null +++ b/emulator/render-bridge/gauge_arena_pipe_sound.conf @@ -0,0 +1,78 @@ +[sdl] +output=opengl +# higher,higher not highest: HIGH_PRIORITY_CLASS starved the host desktop; +# with the retry patches a rare dropout self-recovers (see gauge_rio.conf). +priority=higher,higher +[dosbox] +memsize=32 +machine=svga_s3 +[cpu] +core=dynamic +cputype=pentium +cycles=max +[sblaster] +sbtype=sb16 +sbbase=220 +irq=5 +dma=1 +hdma=5 +[mixer] +# match the EMU8000s' native rate (no resample) and buffer ~60ms so brief +# emulation-thread stalls (RIO retry recovery) don't audibly chop +rate=44100 +blocksize=1024 +prebuffer=60 +[ne2000] +# real pods loaded the ODI stack in AUTOEXEC before ANY game launch -- +# weapons-fire test: bare -egg run WITH the packet stack resident (the game's +# WATTCP identity is 200.0.0.113 from REL410\BT\WATTCP.CFG). nicbase 340: +# 300 clashes with the VDB and blanks heads. backend=slirp: THIS build lacks +# pcap ("Backend not supported"); slirp = user-mode NAT, no LAN peers, but +# the packet driver functions and accepts sends -- enough for a solo fire +# test. For pod<->console runs use a pcap-enabled build (net_full.conf). +ne2000=true +nicbase=340 +nicirq=3 +backend=slirp +[serial] +# RIO on COM1 with the low-latency options (rxpollus/rxburst) so the board's +# few-ms ACK deadline is met; plasma display on COM2 (real pod has both). +serial1=namedpipe pipe:vrio rxpollus:100 rxburst:16 +serial2=namedpipe pipe:vplasma +[autoexec] +mount c "C:\VWE\TeslaRel410\ALPHA_1" +mount d "C:\VWE\TeslaRel410\emulator\net-boot" +d: +echo === loading NE2000 packet-driver stack (as production AUTOEXEC did) === +d:\lsl +d:\ne2000 +d:\odipkt +c: +cd \REL410\BT +set VIDEOFORMAT=svga +rem production pod card init (PARAMETR.BAT:181-186): DIAGNOSE + AWEUTIL per +rem card -- AWEUTIL /S does the EMU8000 bring-up and DRAM detect the HMI SOS +rem driver relies on; skipping it left the cards uninitialized (silent). +rem aweutil /s SKIPPED for now: it verifies the AWE32 GM ROM, which the +rem emulated cards lack (hangs in a retry loop) -- restore once the ROM is +rem dumped from a real card. diagnose /s kept (passes, sets mixer config). +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:\ +rem arena1 city mission (TESTARN.EGG: map=arena1, time=day), RIO attached, +rem bare -egg launch (no netnub) = the user's real-world test-egg setup. +set HEAPSIZE=15000000 +set L4GAUGE=640x480x16 +call setenv.bat r f s p +32rtm.exe -x +rem stdout -> log: this optimized build still emits DEBUG_STREAM lines (the +rem RIO retry spam proved it) -- capture the weapon fire-refusal reason. +rem DOS flushes in 4KB chunks; the tail lands on clean exit (mission timer). +btl4opt.exe -egg testarn.egg > c:\weaponlog.txt +32rtm.exe -u +echo ALPHA1-RUN-DONE +pause + diff --git a/emulator/render-bridge/live_bridge.py b/emulator/render-bridge/live_bridge.py index 86997de..6f1ec3e 100644 --- a/emulator/render-bridge/live_bridge.py +++ b/emulator/render-bridge/live_bridge.py @@ -18,9 +18,10 @@ path = sys.argv[1] catchup = sys.argv[2] if len(sys.argv) > 2 else None board = VirtualBoard() board.munga = False -# BRIDGE_W/BRIDGE_H size the main out-the-window render window (default the -# 832x512 debug size; pod deploy sets the main display's native res e.g. -# 800x600). BRIDGE_BORDERLESS=1 drops the title bar for a kiosk deploy. +# BRIDGE_W/BRIDGE_H size the main out-the-window render window. Default AND +# deploy standard = 832x512, the dPL3 board's native framebuffer res (see +# LAUNCH.md; 800x600-output presentation is an open decision). +# BRIDGE_BORDERLESS=1 drops the title bar for a kiosk deploy. r = Renderer(w=int(os.environ.get('BRIDGE_W', '832')), h=int(os.environ.get('BRIDGE_H', '512')), title=f"dpl3-revive renderer (Dave) -- LIVE from our pod [{backend}]") @@ -74,6 +75,182 @@ def track_joints(payload): if nf in (2, 5): JOINTS[h] = struct.unpack_from('<2f', p, o) +# --- cockpit fixtures (cage + canopy trim) --------------------------------- +# The game links the cockpit CAGE (an inward-facing shell around the mech, +# open panes forward / solid wall aft) directly under the VEHICLE ROOT and +# never touches it again -- but the pod behaves like a tank turret (operator, +# 2026-07-11): the cage must yaw WITH the torso twist, and glance views (hat: +# left/right/rear) are UNFRAMED -- real pods showed a clean world view, no +# cockpit framing (which is also why hat-rear looked "black": we were +# rendering the cage's solid rear wall the real views never showed). +# So per frame: (a) yaw the cage chain by the camera's torso-twist angle +# (CAGE_TWIST_SIGN flips, default follows FP_TWIST_SIGN); (b) when the look +# direction deviates > GLANCE_DEG (45) horizontally from the twisted hull +# heading, hide all cockpit fixtures (GLANCE_HIDE=0 disables). +# +1: calibrated by RENDERED A/B against the +68deg twist capture (ab2_*.png, +# 2026-07-13): +1 brings the shell around WITH the twist (turret behavior, +# small head-off-axis parallax is authentic); -1 rotates it out of view +# entirely ("canopy does not rotate" report). The earlier numeric-only +# calibration mixed yaw conventions -- trust the pictures. +CAGE_SIGN = float(os.environ.get('CAGE_TWIST_SIGN', '1')) +# REAR glance (hat-down / LookBehind) drops the canopy for a CLEAN rear view +# (operator 2026-07-14: original hardware gave a clear rear, NO canopy +# framing, on hat-down; left/right glances DO keep the canopy). Hide the +# fixtures when the look deviates more than this from the twisted hull +# heading. Left/right glances ~50 deg; rear ~180 deg -> 110 separates them. +REAR_DEG = float(os.environ.get('REAR_GLANCE_DEG', '110')) +GLANCE_HIDE = os.environ.get('GLANCE_HIDE', '1') != '0' +GLANCE_DEG = float(os.environ.get('GLANCE_DEG', '45')) +_ckpt = {'insts': None, 'fixh': frozenset(), 'cage': None, + 'twist_dcs': frozenset(), 'shroud': None} + +def eyepoint_refresh(cache): + """EYEPOINT (hat-glance) camera compose -- EXPLICIT OPT-IN ONLY. + Wire forensics 2026-07-13: the game's glance (BT411 EyepointRotation / + gyro eye-joint chain) never reached the 0x1f stream in the test egg -- + the zero-translation anim nodes are STATIC rear-facing mounts (yaw 180 + always), so auto-detection would permanently flip the camera. Until a + live narrated capture identifies the real glance signal, the compose + activates only with GLANCE_DCS= naming the node explicitly.""" + ov = os.environ.get('GLANCE_DCS') + _ckpt['eye'] = int(ov, 16) if ov else None + +def glance_probe(cache): + """THE decisive glance test (rev 2026-07-14). CORRECTION: runtime DCS + updates -- incl. the eyepoint reflush (BT411 FUN_0048e440 -> transmit + action 0x1f, NOT action 3) -- ride the 0x1f articulation stream. The + eye DCS = the cam-chain LEAF (the node the game list-adds to the view). + This logs the leaf's live yaw AND flags any large-swing 0x1f mover that + is OUTSIDE the cam chain (which we'd parse but never apply). A NARRATED + 5s hat hold vs this line is the whole diagnosis: leaf yaw plateaus with + the hold = the game IS emitting the glance and we should render it (find + why we don't); leaf flat + an out-of-chain mover plateaus = we're + dropping it (apply that handle); both flat = the game isn't emitting + (upstream). Reports every ~4s alongside the frame line.""" + chain = cache.cam_chain or [] + if not chain: + return + leaf = chain[0] + f = board.anim_abs.get(leaf) + yaw = None + if f and len(f) >= 12: + yaw = math.degrees(math.atan2(f[6], -f[8])) + now = time.time() + if now - getattr(glance_probe, 'last', 0) > 4: + glance_probe.last = now + inchain = set(chain) + movers = [] + base = getattr(glance_probe, 'base', {}) + for h, sc in JOINTS.items(): + if h in inchain: + continue + ang = math.degrees(math.atan2(sc[0], sc[1])) + b = base.setdefault(h, ang) + if abs(ang - b) > 30: + movers.append('%x=%+.0f' % (h, ang)) + glance_probe.base = base + # which OWN-mech instances are actually being DRAWN (inst_visible)? + # the rear-black occluder is one of these -- identify it live. + import vrview as _vv + root = chain[-1] + drawn = [] + for inst in cache.instances: + if root in inst['chain'] and _vv.inst_visible(board.nodes, inst): + drawn.append('%x(r%.0f)' % (inst['handle'], + inst.get('radius', 0))) + print(f"GLANCE-PROBE: eye(leaf {leaf:x}) yaw=" + f"{('%+.1f' % yaw) if yaw is not None else 'n/a'}" + f" fp_cam.out_yaw={getattr(fp_cam, 'out_yaw', None)}" + f" own drawn: {drawn}" + f" movers: {movers[:4]}", flush=True) + +def cockpit_refresh(cache): + """(Re)identify cockpit fixtures after a SceneCache rebuild: gated + own-chain instances of small radius (beams are gated too but r=2000). + The cage = the shell hanging DIRECTLY under the root (chain length 2). + Keyed by HANDLE (instance dicts are recreated every rebuild) and + keep-last-good: a rebuild that transiently fails detection (e.g. empty + cam_chain mid-stream) must not wipe a previously found cage.""" + if _ckpt['insts'] is cache.instances: + return + _ckpt['insts'] = cache.instances + root = cache.cam_chain[-1] if cache.cam_chain else None + fixh, cage, tdcs, shroud = set(), None, set(), None + if root is not None: + for inst in cache.instances: + ch = inst['chain'] + if not (root in ch and inst.get('gated') + and not inst.get('billboard')): + continue + # ALL own gated instances twist with the turret (canopy, shroud, + # AND the laser beams -- gun mounts orbit the same root axis; + # fixes "lasers fire where the chassis faces", 2026-07-13)... + tdcs.add(ch[0]) + if inst.get('radius', 0) >= 100: + continue # ...but beams never glance-HIDE + fixh.add(inst['handle']) + if len(ch) == 2 and inst.get('radius', 0) > 5: + cage = ch[0] + # ...and the big root-linked inward box = the mission-fade + # SHROUD (9fd/BTPOVStartEndRenderable). It surrounds the mech + # (open front, SOLID rear) so forward looks fine but a rear + # glance / seat-back sees its black wall. It is a start/end + # fade effect, NOT gameplay geometry -> hide it always + # (operator 2026-07-14: "large black cube behind the cage"). + shroud = inst['handle'] + if fixh: + changed = (fixh != _ckpt['fixh']) or (cage != _ckpt['cage']) + _ckpt['fixh'], _ckpt['cage'] = frozenset(fixh), cage + _ckpt['twist_dcs'] = frozenset(tdcs) + _ckpt['shroud'] = shroud + if changed: + print(f"cockpit fixtures: {['%x' % h for h in sorted(fixh)]} " + f"cage_dcs={'%x' % cage if cage else None} " + f"shroud={'%x' % shroud if shroud else None} " + f"twist_dcs={['%x' % d for d in sorted(tdcs)]} " + f"root={root:x} ninst={len(cache.instances)}", flush=True) + elif _ckpt['fixh']: + print(f"cockpit refresh found nothing (root=" + f"{'%x' % root if root else None}, ninst=" + f"{len(cache.instances)}) -- keeping previous", flush=True) + +def hook_chain_matrix(r): + """Wrap Renderer.chain_matrix: cockpit-fixture chains get the camera's + torso-twist yaw composed ABOUT THE VEHICLE-ROOT AXIS (M' = M inv(Mr) Y + Mr). Measured 2026-07-13: the canopy is hull-locked natively (its yaw + tracks the hull exactly through twists) while the camera = hull yaw + + JOINTS twist, so the fixture needs +twist -- but pivoted at the root: + the earlier model-space pre-multiply (Y @ M) yawed the canopy about its + OWN local origin, swinging it sideways in an arc ("moves further than + the view").""" + orig = r.chain_matrix + def cm(board_, chain_, **kw): + M = orig(board_, chain_, **kw) + if chain_ and chain_[0] in _ckpt['twist_dcs']: + a = CAGE_SIGN * getattr(fp_cam, 'twist_angle', 0.0) + now = time.time() + if now - cm.last > 3.0: + cm.last = now + print(f"fixture twist: dcs={chain_[0]:x} angle={a:+.2f}", + flush=True) + root = chain_[-1] + f = board_.anim_abs.get(root) if a else None + if a and f is not None and len(f) >= 12: + Mr = np.eye(4) + Mr[:3, :3] = np.array(f[:9], float).reshape(3, 3) + Mr[3, :3] = f[9:12] + cs, sn = math.cos(a), math.sin(a) + Y = np.eye(4) + Y[0, 0], Y[0, 2], Y[2, 0], Y[2, 2] = cs, -sn, sn, cs + try: + M = np.asarray(M, float) @ np.linalg.inv(Mr) @ Y @ Mr + except np.linalg.LinAlgError: + pass + return M + cm.last = 0.0 + r.chain_matrix = cm + print(f"chain_matrix hook installed on {type(r).__name__}", flush=True) + def fp_cam(board, cache): """First-person cockpit camera, best source first: 1. HEAD-LOOK (default): the munga cam DCS chain's translation row = the @@ -82,7 +259,10 @@ def fp_cam(board, cache): rows collapse to +-Y), so only eye + Z row are trusted and the basis is rebuilt y-up. FP_CAM=vehicle forces the fallback. 2. Fallback: the player vehicle's 0x1f root pose -- eye at hull + VEH_EYE, - forward = -Z row (FP_FWD_SIGN flips).""" + forward = -Z row (FP_FWD_SIGN flips). + Side products (function attrs): .root (vehicle root DCS), .twist_angle + (composed torso-twist yaw), .glance (look deviates > GLANCE_DEG from the + twisted hull heading -- drives the unframed-glance cockpit hide).""" anim = board.anim_abs chain = cache.cam_chain h = None @@ -103,6 +283,7 @@ def fp_cam(board, cache): t = np.array(f[9:12]) worldup = np.array([0.0, 1.0, 0.0]) eye = fwd = None + fp_cam.path = 'vehicle' # telemetry: which camera source won if os.environ.get('FP_CAM', 'chain') != 'vehicle': try: Mc = np.asarray(CHAIN_CAM(board), float) @@ -112,6 +293,7 @@ def fp_cam(board, cache): (t is None or np.linalg.norm(ec - t) < 100.0)): eye = ec + worldup * UPOFF[0] fwd = fc / n + fp_cam.path = 'chain' except Exception: pass if eye is None and R is None: @@ -135,15 +317,70 @@ def fp_cam(board, cache): # +-Y) or in the vRIO input mapping, without touching the look-vector. # torso twist: yaw the look direction by any chain-member joint angle # (jointtorso lives IN the cam chain; the shadow joint does not) + twist_total = 0.0 if TWIST_ON: for jh in (chain or ()): sc = JOINTS.get(jh) if sc is None: continue th = TWIST_SIGN * math.atan2(sc[0], sc[1]) + twist_total += th cs, sn = math.cos(th), math.sin(th) fwd = np.array([cs * fwd[0] + sn * fwd[2], fwd[1], -sn * fwd[0] + cs * fwd[2]]) + # the EYE rides the torso too: swing it along the same turret arc about + # the vehicle-root axis (operator 2026-07-13: the pilot head and cockpit + # shell are rigid on the rotating torso -- ZERO shell/view parallax; the + # eye-position arc is the authentic residual motion of the world view) + if twist_total and t is not None: + er = eye - t + cs, sn = math.cos(twist_total), math.sin(twist_total) + eye = t + np.array([cs * er[0] + sn * er[2], er[1], + -sn * er[0] + cs * er[2]]) + fp_cam.twist_angle = twist_total + # hat-glance: compose the EYEPOINT rotation (eyepoint_refresh finds the + # node; the game flushes it via 0x1f only while deflected, LookBehind = + # yaw pi) ON TOP of the twist -- turret model: the glance is the pilot's + # head relative to the twisted torso. The eyepoint hangs on a link + # branch OUTSIDE the cam chain, so the chain camera alone never sees it + # (wire-proven 2026-07-13). GLANCE_YAW_SIGN / GLANCE_PITCH_SIGN flip. + fp_cam.glance_yaw = 0.0 + eh = _ckpt.get('eye') + ef = board.anim_abs.get(eh) if eh is not None else None + if ef is not None and len(ef) >= 12: + gy = math.atan2(ef[6], -ef[8]) * \ + float(os.environ.get('GLANCE_YAW_SIGN', '1')) + gp = math.asin(max(-1.0, min(1.0, ef[7]))) * \ + float(os.environ.get('GLANCE_PITCH_SIGN', '1')) + if abs(gy) > 0.005 or abs(gp) > 0.005: + fp_cam.glance_yaw = gy + cs, sn = math.cos(gy), math.sin(gy) + fwd = np.array([cs * fwd[0] + sn * fwd[2], fwd[1], + -sn * fwd[0] + cs * fwd[2]]) + if abs(gp) > 0.005: + right = np.cross(worldup, fwd) + rn = np.linalg.norm(right) + if rn > 1e-6: + right /= rn + cp, sp = math.cos(gp), math.sin(gp) + fwd = fwd * cp + np.cross(right, fwd) * sp + # glance detection: horizontal angle between the look and the TWISTED + # hull heading; > GLANCE_DEG = a hat glance is held (drives the + # unframed-glance cockpit hide). Stick-Y pitch never trips this + # (horizontal-only; TORSO.SUB vertical limits are +10/-30 deg anyway). + fp_cam.glance = False + fp_cam.glance_dev = 0.0 # look deviation from twisted hull heading (deg) + if R is not None: + vf = float(os.environ.get('FP_FWD_SIGN', '-1')) * R[2] + cs, sn = math.cos(twist_total), math.sin(twist_total) + vf = np.array([cs * vf[0] + sn * vf[2], 0.0, + -sn * vf[0] + cs * vf[2]]) + lf = np.array([fwd[0], 0.0, fwd[2]]) + nv, nl = np.linalg.norm(vf), np.linalg.norm(lf) + if nv > 1e-6 and nl > 1e-6: + cosang = max(-1.0, min(1.0, float(np.dot(vf, lf) / (nv * nl)))) + fp_cam.glance = cosang < math.cos(math.radians(GLANCE_DEG)) + fp_cam.glance_dev = math.degrees(math.acos(cosang)) eye = eye + fwd * FWDOFF[0] # seat forward/back trim back = -fwd right = np.cross(worldup, back) @@ -159,8 +396,11 @@ def fp_cam(board, cache): right *= float(os.environ.get('FP_RIGHT_SIGN', '1')) M = np.eye(4) M[0, :3], M[1, :3], M[2, :3], M[3, :3] = right, up, back, eye + fp_cam.out_yaw = math.degrees(math.atan2(fwd[0], -fwd[2])) return M +hook_chain_matrix(r) # cage twist rides every instance-chain evaluation + def send_cam(M): # Backchannel on the fifosock: hand the device OUR camera (the # user-validated cockpit view, twist + glance included) so its reticle @@ -205,15 +445,60 @@ def render(board): flush=True) except Exception: pass + if ev.key == pg.K_w: # scene wireframe (GL backend) + try: + import vrview_gl + vrview_gl.WIREFRAME[0] = not vrview_gl.WIREFRAME[0] + print(f"wireframe " + f"{'ON' if vrview_gl.WIREFRAME[0] else 'OFF'}", + flush=True) + except Exception: + pass if hstep or fstep: print(f"eye trim: height {UPOFF[0]:+.1f} " f"forward {FWDOFF[0]:+.1f}", flush=True) r.cache.maybe_rebuild(board) + cockpit_refresh(r.cache) + eyepoint_refresh(r.cache) + glance_probe(r.cache) M = fp_cam(board, r.cache) if M is not None: r.cam_matrix = lambda _b, _M=M: _M send_cam(M) - r.draw(board) + # Glance-hide RETIRED 2026-07-14: (1) it referenced the stale _ckpt + # key 'fix' (renamed to 'fixh'), throwing KeyError EVERY glance frame + # -> render aborted -> screen froze during a hold and snapped back on + # release (the "glances don't render" bug -- fp_cam was fine, the + # crash was here). (2) Pilots confirm the canopy IS visible when + # glancing (a11/MAX_COP has no rear geometry -> rear reads unframed + # naturally), so hiding fixtures was anti-authentic anyway. + # HIDE set: (a) the mission-fade SHROUD (9fd) always -- start/end fade + # effect, not gameplay geometry; (b) on a REAR glance, the CANOPY too + # (a11) -- hat-down gave a CLEAN rear view with NO framing on the + # original hardware (operator 2026-07-14). Left/right glances keep the + # canopy (dev ~50 < REAR_DEG); only the rear look (dev ~180) drops it. + hide = set() + if _ckpt.get('shroud') is not None: + hide.add(_ckpt['shroud']) + if getattr(fp_cam, 'glance_dev', 0.0) > REAR_DEG: + hide |= set(_ckpt.get('fixh', ())) # canopy + shroud + if hide: + saved = r.cache.instances + r.cache.instances = [i for i in saved if i['handle'] not in hide] + try: + r.draw(board) + finally: + r.cache.instances = saved + else: + r.draw(board) + # AUTOSAVE: dump the live-rendered frame every ~20 frames so a held + # glance can be grabbed and compared (diagnostic 2026-07-14). + if os.environ.get('BRIDGE_AUTOSAVE') and frames % 20 == 0: + try: + from _backend import save_frame + save_frame(r, os.environ['BRIDGE_AUTOSAVE']) + except Exception: + pass except KeyboardInterrupt: raise except Exception as e: @@ -349,7 +634,21 @@ while True: time.sleep(0.02) # socket mode already waited in recv if time.time() - last_report > 4: last_report = time.time() + # narrated-test aid: announce articulation handles first seen since + # the last report (glance hunts: "holding hat-left NOW" vs this line) + wa = set(board.anim_abs) - globals().get('_seen_anim', set()) + wj = set(JOINTS) - globals().get('_seen_joints', set()) + globals()['_seen_anim'] = set(board.anim_abs) + globals()['_seen_joints'] = set(JOINTS) + if wa or wj: + print(f"wire: NEW anim={['%x' % h for h in sorted(wa)]} " + f"joints={['%x' % h for h in sorted(wj)]}", flush=True) print(f"frames={frames} skipped={skipped} nodes={len(board.nodes)} " f"uploads={len(board.uploads)} tex={len(board.tex)} " f"munga={board.munga} anim_abs={len(board.anim_abs)} " - f"backlog={len(pending)}B", flush=True) + f"backlog={len(pending)}B " + f"twist={getattr(fp_cam, 'twist_angle', 0.0):+.2f} " + f"glance={int(getattr(fp_cam, 'glance', False))} " + f"gyaw={math.degrees(getattr(fp_cam, 'glance_yaw', 0.0)):+.0f} " + f"joints={len(JOINTS)} cam={getattr(fp_cam, 'path', '?')}", + flush=True) diff --git a/emulator/render-bridge/pod_deploy.ps1 b/emulator/render-bridge/pod_deploy.ps1 index 7f783b9..ed84e90 100644 --- a/emulator/render-bridge/pod_deploy.ps1 +++ b/emulator/render-bridge/pod_deploy.ps1 @@ -17,7 +17,12 @@ param( $ErrorActionPreference = 'Stop' # ---- POD DISPLAY LAYOUT (x,y[,w,h]); edit for this rig's outputs ---------- -$MAIN = '0,0'; $MAIN_W = 800; $MAIN_H = 600 # GL bridge (out-the-window) +# Main render = 832x512, the dPL3 board's NATIVE framebuffer res (the game's +# view flush, pick coords, and death-cam stream are all 832x512; see +# dpl3-revive/spec/VELOCIRENDER_PROTOCOL.md). Rendering 1:1 removes the +# aspect/scale variable while wiring things up; how to present on an 800x600 +# output (letterbox vs scale) is decided AFTER everything is verified at 1:1. +$MAIN = '0,0'; $MAIN_W = 832; $MAIN_H = 512 # GL bridge (out-the-window) $RADAR = '800,0,640,480' # Head C -> win0 $MFD3 = '1440,0,640,480' # Head B, 3-color (UL/UC/UR) -> win4 $MFD2 = '2080,0,640,480' # Head A, 2-color (LL/LR) -> win3 @@ -32,7 +37,7 @@ $env:VPX_WIN4 = $MFD3 # 3-color MFD head (HEAD B) $env:VPX_WIN3 = $MFD2 # 2-color MFD head (HEAD A) # To swap the two MFD heads, just swap the x-coords in the $MFD3/$MFD2 lines # above (1440 <-> 2080) -- the RECTS block is the single source of truth. -# Main render window size = the main display's native res. +# Main render window size = the board-native 832x512 (see RECTS comment). $env:BRIDGE_W = "$MAIN_W" $env:BRIDGE_H = "$MAIN_H" diff --git a/emulator/rp_egg_pipe.conf b/emulator/rp_egg_pipe.conf new file mode 100644 index 0000000..9c0a384 --- /dev/null +++ b/emulator/rp_egg_pipe.conf @@ -0,0 +1,58 @@ +# Red Planet 4.10 BARE -egg run (quick drop-in, no console/netnub) on the +# namedpipe vRIO/vPLASMA transport. Derived from net_rp.conf; networking = +# slirp (the dev-tree exe has no pcap backend -- pcap lives in the packaged +# static build only) with the ODI stack loaded as production did. +[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 +backend=slirp +[sblaster] +sbtype=sb16 +sbbase=220 +irq=5 +dma=1 +hdma=5 +[mixer] +rate=44100 +blocksize=1024 +prebuffer=60 +[serial] +serial1=namedpipe pipe:vrio rxpollus:100 rxburst:16 +serial2=namedpipe pipe:vplasma +[autoexec] +mount c "C:\VWE\TeslaRel410\ALPHA_1" +mount d "C:\VWE\TeslaRel410\emulator\net-boot" +d: +echo === loading NE2000 packet-driver stack (slirp, 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, bare -egg (test.egg) === +32rtm.exe -x +rpl4opt.exe -egg test.egg > nn.log +32rtm.exe -u +echo === RP-EGG-RUN-DONE === +pause diff --git a/emulator/vpx-device/README.md b/emulator/vpx-device/README.md index a9ca0e4..4fe6920 100644 --- a/emulator/vpx-device/README.md +++ b/emulator/vpx-device/README.md @@ -17,6 +17,13 @@ version control because the DOSBox-X source tree itself - **`emu8k.cpp` / `emu8k.h` / `emu8k_shim.h`** — EMU8000 wavetable core vendored from 86Box (GPL-2.0-or-later, same license as DOSBox-X), with a minimal shim; local changes are listed in the emu8k.cpp header. +- **`serialnamedpipe.cpp` / `serialnamedpipe.h`** — `serial=namedpipe + pipe:` backend (2026-07-12): serial-over-named-pipe for vRIO/vPLASMA, + replacing com0com. DOSBox is the pipe CLIENT with 500ms background retry; + typed frames carry data (`0x00 len bytes`) and DTR/RTS line state (`0x01 + bits`); the full wire contract is in the header comment (pinned with the + vRIO session). Supports the directserial `rxpollus`/`rxburst`/`rxdelay` + low-latency knobs. Smoke-tested end-to-end 2026-07-12. ## Applying to a DOSBox-X source checkout @@ -40,6 +47,25 @@ Tested against DOSBox-X `v2026.06.02`, MSYS2 mingw64. and `void VWEAWE_Init();` next to the other `*_Init()` prototypes; call `VPXLOG_Init();` right after `GLIDE_Init();` and `VWEAWE_Init();` right after `SBLASTER_Init();` (it needs the mixer initialized). +3b. The namedpipe serial backend: + ``` + cp emulator/vpx-device/serialnamedpipe.cpp emulator/src/src/hardware/serialport/ + cp emulator/vpx-device/serialnamedpipe.h emulator/src/src/hardware/serialport/ + ``` + plus four small stock edits: + - `src/src/hardware/serialport/Makefile.am`: append + `serialnamedpipe.cpp serialnamedpipe.h` to `libserial_a_SOURCES` + (same generated-Makefile caveat as step 2). + - `src/include/serialport.h`: add `SERIAL_TYPE_NAMED_PIPE` to + `SerialTypesE` under `#if defined(WIN32)` (before + `SERIAL_TYPE_DIRECT_SERIAL`). + - `src/src/hardware/serialport/serialport.cpp`: `#include + "serialnamedpipe.h"`; add the `type=="namedpipe"` case to BOTH dispatch + switches (SERIALPORTS ctor + the SERIAL command) and `"namedpipe"` to + the `serialTypes[]` string table — grep for how `"file"` appears and + copy the pattern. + - `src/src/dosbox.cpp`: add `"namedpipe"` to the `serials[]` allowed-values + list (without this the config parser silently falls back to `dummy`). 4. Build: ``` cd emulator/src diff --git a/emulator/vpx-device/serialnamedpipe.cpp b/emulator/vpx-device/serialnamedpipe.cpp new file mode 100644 index 0000000..d3b3f42 --- /dev/null +++ b/emulator/vpx-device/serialnamedpipe.cpp @@ -0,0 +1,379 @@ +/* + * VWE fork: serial-over-named-pipe backend. See serialnamedpipe.h for the + * wire contract. RX pacing is a faithful copy of the (patched) directserial + * state machine so the RIO's validated rxpollus/rxburst tuning carries over. + */ + +#include "dosbox.h" + +#if defined(WIN32) + +#include "logging.h" +#include "pic.h" +#include "setup.h" +#include "serialport.h" +#include "serialnamedpipe.h" + +bool getBituSubstring(const char* name, Bitu* data, CommandLine* cmd); + +#define PIPE_RETRY_MS 500.0 + +enum { P_RX_IDLE = 0, P_RX_WAIT, P_RX_BLOCKED, P_RX_FASTWAIT }; + +// frame types (identical both directions) +enum { FRAME_DATA = 0x00, FRAME_LINES = 0x01 }; + +CSerialNamedPipe::CSerialNamedPipe(Bitu id, CommandLine* cmd) : CSerial(id, cmd) { + InstallationSuccessful = false; + + std::string tmp; + if (!cmd->FindStringBegin("pipe:", tmp, false)) { + LOG_MSG("Serial%d: namedpipe requires pipe:", (int)COMNUMBER); + return; + } + // pipe:vrio -> \\.\pipe\vrio; a full \\... path passes through + if (tmp.size() >= 2 && tmp[0] == '\\' && tmp[1] == '\\') + pipename = tmp; + else + pipename = std::string("\\\\.\\pipe\\") + tmp; + + Bitu rx_poll_us = 0; + if (getBituSubstring("rxpollus:", &rx_poll_us, cmd)) { + if (rx_poll_us < 50) rx_poll_us = 50; + if (rx_poll_us > 1000) rx_poll_us = 1000; + rx_poll_ms = (float)rx_poll_us / 1000.0f; + } + Bitu rx_burst = 0; + if (getBituSubstring("rxburst:", &rx_burst, cmd)) { + if (rx_burst < 1) rx_burst = 1; + if (rx_burst > 64) rx_burst = 64; + rx_burst_div = (float)rx_burst; + } + if (getBituSubstring("rxdelay:", &rx_retry_max, cmd)) { + if (!(rx_retry_max <= 10000)) rx_retry_max = 0; + } + + LOG_MSG("Serial%d: namedpipe client of %s (poll %.2fms burst x%g)", + (int)COMNUMBER, pipename.c_str(), rx_poll_ms, rx_burst_div); + + CSerial::Init_Registers(); + // unplugged cable until the peer's 0x01 arrives + setRI(false); + setCD(false); + setDSR(false); + setCTS(false); + + InstallationSuccessful = true; + rx_state = P_RX_IDLE; + tryConnect(); // eager first attempt + setEvent(SERIAL_POLLING_EVENT, rx_poll_ms); // receive/reconnect tick +} + +CSerialNamedPipe::~CSerialNamedPipe() { + if (pipe != INVALID_HANDLE_VALUE) { + CancelIo(pipe); + CloseHandle(pipe); + } + // events are cleared by the framework on port destruction +} + +void CSerialNamedPipe::tryConnect() { + next_retry_ms = PIC_FullIndex() + PIPE_RETRY_MS; + HANDLE h = CreateFileA(pipename.c_str(), GENERIC_READ | GENERIC_WRITE, + 0, NULL, OPEN_EXISTING, FILE_FLAG_OVERLAPPED, NULL); + if (h == INVALID_HANDLE_VALUE) return; // no server yet: cable stays unplugged + DWORD mode = PIPE_READMODE_BYTE; + SetNamedPipeHandleState(h, &mode, NULL, NULL); + pipe = h; + inbuf.clear(); + txq.clear(); + tx_inflight = false; + LOG_MSG("Serial%d: namedpipe connected to %s", (int)COMNUMBER, pipename.c_str()); + sendLineState(); // contract: one 0x01 on connect +} + +void CSerialNamedPipe::dropConnection(const char* why) { + if (pipe != INVALID_HANDLE_VALUE) { + CancelIo(pipe); + CloseHandle(pipe); + pipe = INVALID_HANDLE_VALUE; + } + tx_inflight = false; + txq.clear(); + inbuf.clear(); // partial frame is meaningless now + // disconnect = all lines low (rxq keeps already-delivered wire bytes) + setCTS(false); + setDSR(false); + setCD(false); + next_retry_ms = PIC_FullIndex() + PIPE_RETRY_MS; + LOG_MSG("Serial%d: namedpipe disconnected (%s)", (int)COMNUMBER, why); +} + +// TX is queue + single in-flight overlapped write, reaped from the poll +// tick. The emu thread NEVER waits on the pipe: a peer that isn't reading +// (e.g. both sides sending their connect hello before either reads -- +// first live vRIO run, 2026-07-13) wedges the queue, not the emulator; +// after TX_WEDGE_MS of no progress the connection drops (unplugged cable). +#define TXQ_MAX 8192 +#define TX_WEDGE_MS 2000.0 + +void CSerialNamedPipe::queueSend(const uint8_t* data, DWORD len) { + if (pipe == INVALID_HANDLE_VALUE) return; // unplugged: discard + if (txq.size() + len > TXQ_MAX) { + dropConnection("tx queue overflow (peer not reading)"); + return; + } + txq.insert(txq.end(), data, data + len); + kickTx(); +} + +void CSerialNamedPipe::kickTx() { + if (pipe == INVALID_HANDLE_VALUE || tx_inflight || txq.empty()) return; + tx_pend.swap(txq); + txq.clear(); + ZeroMemory(&tx_ov, sizeof(tx_ov)); + DWORD done = 0; + if (WriteFile(pipe, tx_pend.data(), (DWORD)tx_pend.size(), &done, &tx_ov)) { + return; // completed synchronously + } + if (GetLastError() == ERROR_IO_PENDING) { + tx_inflight = true; + tx_wedge_ms = PIC_FullIndex() + TX_WEDGE_MS; + return; + } + dropConnection("write failed"); +} + +void CSerialNamedPipe::pollTx() { + if (pipe == INVALID_HANDLE_VALUE || !tx_inflight) return; + DWORD done = 0; + if (GetOverlappedResult(pipe, &tx_ov, &done, FALSE)) { + tx_inflight = false; + kickTx(); + return; + } + DWORD err = GetLastError(); + if (err == ERROR_IO_INCOMPLETE) { + if (PIC_FullIndex() >= tx_wedge_ms) + dropConnection("write wedged (peer not reading)"); + return; + } + tx_inflight = false; + dropConnection("write failed"); +} + +void CSerialNamedPipe::sendLineState() { + uint8_t f[2] = { FRAME_LINES, + (uint8_t)((cur_dtr ? 1 : 0) | (cur_rts ? 2 : 0)) }; + queueSend(f, 2); +} + +void CSerialNamedPipe::pumpPipe() { + if (pipe == INVALID_HANDLE_VALUE) return; + for (;;) { + DWORD avail = 0; + if (!PeekNamedPipe(pipe, NULL, 0, NULL, &avail, NULL)) { + dropConnection("peer closed"); + return; + } + if (avail == 0) break; + uint8_t buf[512]; + DWORD want = avail < sizeof(buf) ? avail : (DWORD)sizeof(buf); + DWORD got = 0; + ZeroMemory(&rx_ov, sizeof(rx_ov)); + // overlapped handle needs an OVERLAPPED; Peek guaranteed the bytes + // are buffered, so the wait below completes immediately. + if (!ReadFile(pipe, buf, want, &got, &rx_ov)) { + if (GetLastError() != ERROR_IO_PENDING || + !GetOverlappedResult(pipe, &rx_ov, &got, TRUE)) { + dropConnection("read failed"); + return; + } + } + if (got == 0) { + dropConnection("read failed"); + return; + } + inbuf.insert(inbuf.end(), buf, buf + got); + } + // slice complete frames + size_t pos = 0; + while (pos < inbuf.size()) { + uint8_t type = inbuf[pos]; + if (type == FRAME_DATA) { + if (pos + 2 > inbuf.size()) break; // need len + uint8_t len = inbuf[pos + 1]; + if (len == 0) { // contract: len >= 1 + dropConnection("zero-length data frame"); + return; + } + if (pos + 2 + len > inbuf.size()) break; // incomplete + for (uint8_t i = 0; i < len; i++) + rxq.push_back(inbuf[pos + 2 + i]); + pos += 2 + (size_t)len; + } else if (type == FRAME_LINES) { + if (pos + 2 > inbuf.size()) break; + uint8_t lines = inbuf[pos + 1]; + // null-modem cross: their DTR -> our DSR+CD, their RTS -> our CTS + setDSR((lines & 1) != 0); + setCD((lines & 1) != 0); + setCTS((lines & 2) != 0); + pos += 2; + } else { + LOG_MSG("Serial%d: namedpipe unknown frame type 0x%02x -- protocol bug", + (int)COMNUMBER, type); + dropConnection("unknown frame type"); + return; + } + } + inbuf.erase(inbuf.begin(), inbuf.begin() + pos); +} + +bool CSerialNamedPipe::doReceive() { + if (rxq.empty()) pumpPipe(); + if (rxq.empty()) return false; + uint8_t b = rxq.front(); + rxq.pop_front(); + receiveByteEx(b, 0); + return true; +} + +void CSerialNamedPipe::handleUpperEvent(uint16_t type) { + switch (type) { + case SERIAL_POLLING_EVENT: { + setEvent(SERIAL_POLLING_EVENT, rx_poll_ms); + if (pipe == INVALID_HANDLE_VALUE) { + if (PIC_FullIndex() >= next_retry_ms) tryConnect(); + } else { + pollTx(); // reap / restart the overlapped write + pumpPipe(); // keep line-state frames fresh even when idle + } + // directserial's RX state machine (rxpollus/rxburst semantics) + switch (rx_state) { + case P_RX_IDLE: + if (CanReceiveByte()) { + if (doReceive()) { + rx_state = P_RX_WAIT; + setEvent(SERIAL_RX_EVENT, bytetime * 0.9f / rx_burst_div); + } + } else { + rx_state = P_RX_BLOCKED; + setEvent(SERIAL_RX_EVENT, bytetime * 0.9f / rx_burst_div); + } + break; + case P_RX_BLOCKED: + if (!CanReceiveByte()) { + rx_retry++; + if (rx_retry >= rx_retry_max) { + rx_retry = 0; + removeEvent(SERIAL_RX_EVENT); + if (doReceive()) { + while (doReceive()); // overrun, like directserial + rx_state = P_RX_WAIT; + setEvent(SERIAL_RX_EVENT, bytetime * 0.9f / rx_burst_div); + } else { + rx_state = P_RX_IDLE; + } + } + } else { + removeEvent(SERIAL_RX_EVENT); + rx_retry = 0; + if (doReceive()) { + rx_state = P_RX_FASTWAIT; + setEvent(SERIAL_RX_EVENT, bytetime * 0.65f / rx_burst_div); + } else { + rx_state = P_RX_IDLE; + } + } + break; + case P_RX_WAIT: + case P_RX_FASTWAIT: + break; + } + break; + } + case SERIAL_RX_EVENT: { + switch (rx_state) { + case P_RX_IDLE: + LOG_MSG("internal error in serialnamedpipe"); + break; + case P_RX_BLOCKED: + case P_RX_WAIT: + case P_RX_FASTWAIT: + if (CanReceiveByte()) { + rx_retry = 0; + if (doReceive()) { + if (rx_state == P_RX_WAIT) + setEvent(SERIAL_RX_EVENT, bytetime * 0.9f / rx_burst_div); + else { + rx_state = P_RX_FASTWAIT; + setEvent(SERIAL_RX_EVENT, bytetime * 0.65f / rx_burst_div); + } + } else { + rx_state = P_RX_IDLE; + } + } else { + setEvent(SERIAL_RX_EVENT, bytetime * 0.65f / rx_burst_div); + rx_state = P_RX_BLOCKED; + } + break; + } + break; + } + case SERIAL_TX_EVENT: { + if (rx_state == P_RX_IDLE && CanReceiveByte()) { + if (doReceive()) { + rx_state = P_RX_WAIT; + setEvent(SERIAL_RX_EVENT, bytetime * 0.9f / rx_burst_div); + } + } + ByteTransmitted(); + break; + } + case SERIAL_THR_EVENT: { + ByteTransmitting(); + setEvent(SERIAL_TX_EVENT, bytetime * 1.1f); + break; + } + } +} + +void CSerialNamedPipe::updatePortConfig(uint16_t divider, uint8_t lcr) { + (void)divider; (void)lcr; // a pipe has no baud rate +} + +void CSerialNamedPipe::updateMSR() { + // modem-in lines are event-driven (0x01 frames); nothing to poll +} + +void CSerialNamedPipe::transmitByte(uint8_t val, bool first) { + if (first) setEvent(SERIAL_THR_EVENT, bytetime / 10); + else setEvent(SERIAL_TX_EVENT, bytetime); + uint8_t f[3] = { FRAME_DATA, 1, val }; + queueSend(f, 3); // unconnected pipe swallows it (unplugged cable) +} + +void CSerialNamedPipe::setBreak(bool value) { + (void)value; // not in the contract; RIO/plasma don't use break +} + +void CSerialNamedPipe::setRTSDTR(bool rts, bool dtr) { + if (rts == cur_rts && dtr == cur_dtr) return; + cur_rts = rts; + cur_dtr = dtr; + sendLineState(); +} + +void CSerialNamedPipe::setRTS(bool val) { + if (val == cur_rts) return; + cur_rts = val; + sendLineState(); +} + +void CSerialNamedPipe::setDTR(bool val) { + if (val == cur_dtr) return; + cur_dtr = val; + sendLineState(); +} + +#endif // WIN32 diff --git a/emulator/vpx-device/serialnamedpipe.h b/emulator/vpx-device/serialnamedpipe.h new file mode 100644 index 0000000..37089f8 --- /dev/null +++ b/emulator/vpx-device/serialnamedpipe.h @@ -0,0 +1,95 @@ +/* + * VWE fork: serial-over-named-pipe backend (Windows only). + * + * Replaces com0com virtual COM pairs for the vRIO cockpit board and the + * vPLASMA score display: DOSBox is the pipe CLIENT with background retry; + * the peripheral app (vRIO / vPLASMA) is the pipe SERVER. An unconnected + * pipe behaves like an unplugged cable: the UART exists, modem-in lines + * (CTS/DSR/CD) are low, writes are discarded. + * + * Wire format (single duplex byte-mode pipe, typed frames, identical in + * both directions -- contract pinned with the vRIO session 2026-07-12): + * 0x00 serial data, len >= 1 (batching allowed) + * 0x01 sender's own OUTPUT lines: bit0 DTR, bit1 RTS; + * the receiver applies the null-modem cross + * (their DTR -> our DSR+CD, their RTS -> our CTS) + * On connect each side sends one 0x01 with its current state. An unknown + * frame type is a protocol bug: log + drop the connection, no resync. + * + * Conf: serial1=namedpipe pipe:vrio [rxpollus:100] [rxburst:16] [rxdelay:n] + * (pipe: maps to \\.\pipe\; a full \\... path also works; + * rx options have directserial semantics) + */ + +#ifndef DOSBOX_SERIALNAMEDPIPE_H +#define DOSBOX_SERIALNAMEDPIPE_H + +#include "dosbox.h" + +#if defined(WIN32) + +#include "serialport.h" + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include + +#include +#include +#include + +class CSerialNamedPipe : public CSerial { +public: + CSerialNamedPipe(Bitu id, CommandLine* cmd); + virtual ~CSerialNamedPipe(); + + void updatePortConfig(uint16_t divider, uint8_t lcr); + void updateMSR(); + void transmitByte(uint8_t val, bool first); + void setBreak(bool value); + void setRTSDTR(bool rts, bool dtr); + void setRTS(bool val); + void setDTR(bool val); + void handleUpperEvent(uint16_t type); + +private: + void tryConnect(); + void dropConnection(const char* why); + void pumpPipe(); // drain pipe -> parse frames -> rxq / line state + bool doReceive(); // deliver one rx byte to the UART if available + void queueSend(const uint8_t* data, DWORD len); // NEVER blocks + void kickTx(); // start the next overlapped write if idle + void pollTx(); // reap a completed / wedged overlapped write + void sendLineState(); // frame 0x01 with our current DTR/RTS + + HANDLE pipe = INVALID_HANDLE_VALUE; + std::string pipename; + + double next_retry_ms = 0.0; // PIC_FullIndex() of the next connect attempt + + // ALL pipe I/O is overlapped: a blocking write here deadlocked the emu + // thread against a peer that also writes-first (both hellos, no reader). + OVERLAPPED tx_ov = {}; + OVERLAPPED rx_ov = {}; + bool tx_inflight = false; + double tx_wedge_ms = 0.0; // deadline for an unfinished write + std::vector txq; // bytes waiting for the next write + std::vector tx_pend; // buffer owned by the in-flight write + + std::vector inbuf; // partial frame reassembly + std::deque rxq; // decoded serial bytes awaiting the UART + + bool cur_dtr = false; // our output lines (mirrors MCR writes) + bool cur_rts = false; + + // receive pacing, directserial semantics (VWE RIO low-latency knobs) + float rx_poll_ms = 1.0f; + float rx_burst_div = 1.0f; + Bitu rx_retry = 0; + Bitu rx_retry_max = 0; + Bitu rx_state = 0; +}; + +#endif // WIN32 +#endif // DOSBOX_SERIALNAMEDPIPE_H