From d6745353b18a74e2ae24d237ede5e6114d4dad46 Mon Sep 17 00:00:00 2001 From: Cyd Date: Sun, 12 Jul 2026 12:41:29 -0500 Subject: [PATCH] L4D3D: survive missing textures instead of crashing at mission load d3d_OBJECT::LoadTexture never checked D3DXCreateTextureFromFileA, cached the NULL texture, and unconditionally AddRef()ed it - an access violation on any missing/unreadable texture, hit by every bare working copy because the pod skins (VIDEO\player1-8) come from the presets/replacement-material path, not the depot. Failures now log the filename+hr and the draw op renders untextured, matching the existing no-texture-filename path. Also guard the unchecked gReplacementData->find() in LoadObject (same latent UB one branch earlier). Verified in the sandbox working copy: the game now boots to a running RPL4 window with -windowed -egg TEST.EGG (RIO served by vRIO), logging the eight missing pod skins instead of dying in MakeEntityRenderables. Co-Authored-By: Claude Fable 5 --- BUILD.md | 25 +- MUNGA_L4/L4D3D.cpp | 30 +- assets/RP411/startrp-800x600.bat | 54 ++++ assets/RP411/startrp-800x600.ps1 | 477 +++++++++++++++++++++++++++++++ docs/RP412-ROADMAP.md | 9 +- 5 files changed, 574 insertions(+), 21 deletions(-) create mode 100644 assets/RP411/startrp-800x600.bat create mode 100644 assets/RP411/startrp-800x600.ps1 diff --git a/BUILD.md b/BUILD.md index bb90d02..79bb82a 100644 --- a/BUILD.md +++ b/BUILD.md @@ -79,17 +79,22 @@ optimization levels, and `/FORCE:MULTIPLE` (see below). Deliberate changes: defined in multiple TUs (LNK4006 warnings, same as the VC9 build). Moving them to a single TU is future cleanup. -## 4. Known runtime issue (pre-existing, both toolchains) +## 4. Runtime notes -Standalone mission load (`rpl4opt.exe -windowed -res 640 480 -egg TEST.EGG`, -run from a game working copy such as `assets/RP411/`) **crashes with an access -violation in `d3d_OBJECT::LoadTexture`** ([MUNGA_L4/L4D3D.cpp](MUNGA_L4/L4D3D.cpp) @ 262, -called from `LoadObject` → `DPLRenderer::MakeEntityRenderables`) right after -the `Entity -1:10x class42 couldn't figure out how to MakeEntityRenderables` -log lines. The VC9 build of this tree crashes at the identical point, so this -is a defect (or asset/config mismatch) in the 2007 DPL→D3D9 port, not a -migration regression. The v143 build produces full PDBs — debug with -`cdb -g -G -lines -y Release rpl4opt.exe ...` from the working directory. +**Fixed (2026-07-12):** standalone mission load used to crash with an access +violation in `d3d_OBJECT::LoadTexture` ([MUNGA_L4/L4D3D.cpp](MUNGA_L4/L4D3D.cpp)): +`D3DXCreateTextureFromFileA` failures were never checked and the NULL texture +was cached and `AddRef()`ed. It crashed identically on VC9 — a latent defect +in the 2007 DPL→D3D9 port, exposed by the pod-skin textures (`VIDEO\player1–8`) +that a bare working copy doesn't contain (they normally come from the +replacement-material/presets path, `WTPresets` → `VIDEO\.png`). Missing +textures now log `L4D3D.cpp couldn't load texture …` and render untextured; +the game boots to a running window with `-windowed -res 640 480 -egg TEST.EGG` +from a working copy like `assets/RP411/`. + +For runtime debugging the v143 build produces full PDBs — run +`cdb -g -G -lines -y Release rpl4opt.exe ...` from the working directory +(cdb ships in this machine's Windows Kits). Also note: `Fail()` compiles to `abort()` in release (`MUNGA/DEBUGOFF.h`) — e.g. running with `L4CONTROLS=KEYBOARD` alone aborts at VTV creation because diff --git a/MUNGA_L4/L4D3D.cpp b/MUNGA_L4/L4D3D.cpp index aaf363a..46c15b2 100644 --- a/MUNGA_L4/L4D3D.cpp +++ b/MUNGA_L4/L4D3D.cpp @@ -121,14 +121,21 @@ d3d_OBJECT *d3d_OBJECT::LoadObject(LPDIRECT3DDEVICE9 device, char *fileName) { hash_map::const_iterator iter = gReplacementData->find(string(replaceMatName)); - ReplacementMaterialData data = (*iter).second; + if (iter != gReplacementData->end()) + { + ReplacementMaterialData data = (*iter).second; - object->mDrawOps[i].material.Diffuse.r = data.r; - object->mDrawOps[i].material.Diffuse.g = data.g; - object->mDrawOps[i].material.Diffuse.b = data.b; + object->mDrawOps[i].material.Diffuse.r = data.r; + object->mDrawOps[i].material.Diffuse.g = data.g; + object->mDrawOps[i].material.Diffuse.b = data.b; - replaceTexName = new char[data.texName.size() + 1]; - strcpy_s(replaceTexName, data.texName.size() + 1, data.texName.c_str()); + replaceTexName = new char[data.texName.size() + 1]; + strcpy_s(replaceTexName, data.texName.size() + 1, data.texName.c_str()); + } + else + { + DEBUG_STREAM << "L4D3D.cpp no replacement material data for " << replaceMatName << "\n" << std::flush; + } delete [] replaceMatName; } @@ -252,14 +259,21 @@ L4TEXOP d3d_OBJECT::LoadTexture(LPDIRECT3DDEVICE9 device, const char *fileName) texOp.scrollVDelta = fileTexOp.scrollVDelta; } - D3DXCreateTextureFromFileA(device, fileName, &texOp.texture); + HRESULT hr = D3DXCreateTextureFromFileA(device, fileName, &texOp.texture); + if (FAILED(hr)) + { + // texOp.texture stays NULL (memset above); draw ops render untextured, + // matching the "no texture filename" path in LoadObject. + DEBUG_STREAM << "L4D3D.cpp couldn't load texture " << fileName << " (hr=0x" << std::hex << hr << std::dec << ")\n" << std::flush; + } mTextureCache.insert(std::pair(std::string(fileName), texOp)); } else texOp = mTextureCache[std::string(fileName)]; - texOp.texture->AddRef(); + if (texOp.texture != NULL) + texOp.texture->AddRef(); return texOp; } diff --git a/assets/RP411/startrp-800x600.bat b/assets/RP411/startrp-800x600.bat new file mode 100644 index 0000000..8c6f583 --- /dev/null +++ b/assets/RP411/startrp-800x600.bat @@ -0,0 +1,54 @@ +@echo off +rem ============================================================================ +rem Red Planet pod-cockpit launcher (wrapper around rpl4opt.exe) +rem ============================================================================ +rem +rem Starts the game windowed with an 800x600 main view and rebuilds the full +rem 7-display pod cockpit on the desktop: +rem +rem [ 1: main 3D view ] [ 3: MFD W3.R ] [ 4: MFD W3.G ] [ 5: MFD W3.B ] +rem [ 6: MFD W4.R ] [ 2: map, 90CW ] [ 7: MFD W4.G ] +rem +rem The game itself only creates 4 windows. The five monochrome green MFDs are +rem packed into the color channels of game windows 3 and 4 (window 3 carries +rem three MFDs in R/G/B, window 4 two more in R/G). The wrapper hides those +rem source windows behind the main view and shows live green-screen mirrors of +rem each channel, plus the map display rotated 90 degrees clockwise (portrait, +rem as mounted in the pod). Mirror windows have title bars and can be dragged. +rem +rem All arguments are passed to startrp-800x600.ps1. Wrapper options: +rem +rem -Width N -Height N main view size (default 800x600) +rem -TileOnly no mirrors - just resize + tile the 4 real windows +rem -SaveLayout record the window arrangement on exit: launch with +rem this, drag windows where you want them, quit the +rem game -> positions saved to cockpit-layout.json and +rem restored automatically on every later launch +rem -ResetLayout delete the saved layout, return to the default grid +rem -TopMost keep the mirror displays above all other windows +rem +rem Anything else replaces the game's own arguments (default: -net 1501). +rem Useful game switches (see RedPlanet-CommandLine-and-Environment.md): +rem -net connect to the pod network / Tesla console (TCP) +rem -egg standalone: run a local mission, e.g. -egg TEST.EGG +rem -lc Live Cam spectator station -mr Mission Review +rem +rem Examples: +rem startrp-800x600.bat +rem normal pod: -net 1501, cockpit mirrors, saved layout if present +rem startrp-800x600.bat -egg TEST.EGG +rem offline test mission, no console needed +rem startrp-800x600.bat -SaveLayout -TopMost +rem arrange your cockpit, quit, and the layout sticks for future runs +rem startrp-800x600.bat -Width 1024 -Height 768 -net 1501 +rem bigger main view on the pod network +rem +rem Lifecycle: PowerShell stays running hidden to drive the mirrors and exits +rem by itself when the game quits. Closing any mirror window shuts down all +rem mirrors and brings the real game windows back out. Note: the mirrors are +rem required because the game renders with D3D9 - the main window is always +rem created at full screen size by the exe (the -res switch only sets the +rem backbuffer), and windows parked off-screen stop rendering entirely, so +rem the sources are hidden behind the main view instead. +rem ============================================================================ +start "" powershell -NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -File "%~dp0startrp-800x600.ps1" %* diff --git a/assets/RP411/startrp-800x600.ps1 b/assets/RP411/startrp-800x600.ps1 new file mode 100644 index 0000000..7846d6a --- /dev/null +++ b/assets/RP411/startrp-800x600.ps1 @@ -0,0 +1,477 @@ +# Red Planet launcher wrapper: starts rpl4opt.exe and reconstructs the 7-display pod +# cockpit on a desktop monitor: +# +# [ 1: main 3D view ] [ 3: MFD W3.R ] [ 4: MFD W3.G ] [ 5: MFD W3.B ] +# [ 6: MFD W4.R ] [ 2: map, 90CW ] [ 7: MFD W4.G ] +# (centered) +# +# Background: the game creates 4 windows - main 3D view, the tactical/map display, and +# two "packed" gauge windows. A real pod has 7 displays: main, map (portrait-mounted), +# and five monochrome green MFDs (Multi Function Displays). The game packs the five MFD +# images into the COLOR CHANNELS of its 3rd and 4th windows: window 3 carries three +# MFDs in its R, G and B channels; window 4 carries two more in R and G. This wrapper +# splits them back out: each MFD view captures its source window, extracts one channel +# and renders it as a green screen. The map is shown rotated 90 degrees clockwise +# (portrait, as mounted in the pod). +# +# Implementation notes: +# - rpl4opt.exe always creates its main window at full screen size (hardcoded +# GetSystemMetrics at 0x49fe4b); -res only sets the D3D backbuffer. We resize the +# main window's client area ourselves. +# - Windows cannot rotate or channel-split a live window, so the map and MFD views are +# live mirrors: the three source windows are hidden BEHIND the main window and +# captured with BitBlt each tick. Sources must stay on-screen - the game's D3D9 +# device stops presenting to fully off-screen windows and captures read black; +# occluded-but-on-screen keeps presenting. +# - Window identity is by CREATION ORDER (stable across runs; verified by content +# fingerprinting over repeated launches - window handle values shuffle per run): +# 1 = main, 2 = tactical/map, 3 = MFD pack 1 (red/blue), 4 = MFD pack 2 (BOOST/CHUTE). +# +# Layout persistence: launch with -SaveLayout, arrange the windows how you like, and +# the positions (main + all mirrors) are recorded to cockpit-layout.json next to this +# script when you exit. Every later launch restores that arrangement automatically. +# Use -ResetLayout to discard it and return to the default grid. +# +# Usage: +# startrp-800x600.ps1 -> rpl4opt.exe -net 1501 -windowed -res 800 600 +# startrp-800x600.ps1 -Width 1024 -Height 768 -> bigger main window +# startrp-800x600.ps1 -TileOnly -> no mirrors, just resize + tile the 4 windows +# startrp-800x600.ps1 -SaveLayout -> record the window arrangement on exit +# startrp-800x600.ps1 -ResetLayout -> forget the saved window layout +# startrp-800x600.ps1 -TopMost -> keep the mirror displays above other windows +# startrp-800x600.ps1 -egg TEST.EGG -> replace default game args (standalone mission) + +param( + [int]$Width = 800, + [int]$Height = 600, + [switch]$TileOnly, + [switch]$SaveLayout, + [switch]$ResetLayout, + [switch]$TopMost, + [Parameter(ValueFromRemainingArguments = $true)][string[]]$GameArgs +) + +$ErrorActionPreference = 'Stop' + +if (-not $GameArgs -or $GameArgs.Count -eq 0) { $GameArgs = @('-net', '1501') } +$GameArgs = @($GameArgs) + @('-windowed', '-res', "$Width", "$Height") + +$gameDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$exe = Join-Path $gameDir 'rpl4opt.exe' +if (-not (Test-Path $exe)) { throw "rpl4opt.exe not found in $gameDir" } + +$script:layoutPath = Join-Path $gameDir 'cockpit-layout.json' +if ($ResetLayout -and (Test-Path $script:layoutPath)) { + Remove-Item $script:layoutPath -Force + Write-Host "Saved layout discarded; using the default grid." +} + +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +Add-Type -TypeDefinition @' +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using System.Text; + +public static class RPWin { + [StructLayout(LayoutKind.Sequential)] + public struct RECT { public int L, T, R, B; } + + delegate bool EnumProc(IntPtr hWnd, IntPtr lParam); + [DllImport("user32.dll")] static extern bool EnumWindows(EnumProc cb, IntPtr lParam); + [DllImport("user32.dll")] static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint pid); + [DllImport("user32.dll")] public static extern bool IsWindowVisible(IntPtr hWnd); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr hWnd, out RECT r); + [DllImport("user32.dll")] public static extern bool GetClientRect(IntPtr hWnd, out RECT r); + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr hWnd, IntPtr after, int x, int y, int w, int h, uint flags); + [DllImport("user32.dll")] public static extern int GetSystemMetrics(int index); + [DllImport("user32.dll")] public static extern IntPtr GetDC(IntPtr hWnd); + [DllImport("user32.dll")] public static extern int ReleaseDC(IntPtr hWnd, IntPtr hdc); + [DllImport("gdi32.dll")] public static extern bool BitBlt(IntPtr dst, int dx, int dy, int w, int h, IntPtr src, int sx, int sy, uint rop); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetClassName(IntPtr hWnd, StringBuilder sb, int max); + [DllImport("user32.dll", CharSet = CharSet.Unicode)] static extern int GetWindowText(IntPtr hWnd, StringBuilder sb, int max); + + // EnumWindows returns windows top-of-z-order first. + public static List GetProcessWindows(uint pid) { + var list = new List(); + EnumWindows((h, l) => { + uint wpid; GetWindowThreadProcessId(h, out wpid); + if (wpid == pid && IsWindowVisible(h)) list.Add(h); + return true; + }, IntPtr.Zero); + return list; + } + public static string ClassOf(IntPtr h) { var sb = new StringBuilder(256); GetClassName(h, sb, 256); return sb.ToString(); } + public static string TitleOf(IntPtr h) { var sb = new StringBuilder(256); GetWindowText(h, sb, 256); return sb.ToString(); } +} +'@ + +# --- launch ------------------------------------------------------------- +Write-Host "Starting: rpl4opt.exe $($GameArgs -join ' ')" +$proc = Start-Process -FilePath $exe -WorkingDirectory $gameDir -ArgumentList $GameArgs -PassThru +$pidOfGame = [uint32]$proc.Id + +# --- constants / helpers ------------------------------------------------- +$SWP_RESIZE = 0x0234 # SWP_NOMOVE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_FRAMECHANGED +$SWP_MOVE = 0x0015 # SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE +$SWP_MOVE_Z = 0x0011 # SWP_NOSIZE | SWP_NOACTIVATE (allows z-order change) +$HWND_BOTTOM = [IntPtr]1 + +$script:lockedOrder = $null +function Get-OrderedWindows([uint32]$targetPid, [IntPtr]$hMain) { + # Order = main window first, then the gauge windows in CREATION order. EnumWindows + # returns top-of-z-order first, and at startup (before anything is clicked) newer + # windows sit above older ones, so reversing the enumeration gives creation order. + # The order is locked the first time all four windows exist so later z-order + # changes (clicks) can't renumber them. + if ($script:lockedOrder) { return $script:lockedOrder } + $wins = [RPWin]::GetProcessWindows($targetPid) + $others = @($wins | Where-Object { $_ -ne $hMain }) + [array]::Reverse($others) + $ordered = @($hMain) + $others + if ($wins.Count -ge 4) { $script:lockedOrder = $ordered } + return $ordered +} + +function Get-MainWindow([uint32]$targetPid) { + $best = [IntPtr]::Zero; $bestArea = -1 + foreach ($h in [RPWin]::GetProcessWindows($targetPid)) { + $r = New-Object RPWin+RECT + [void][RPWin]::GetWindowRect($h, [ref]$r) + $area = ($r.R - $r.L) * ($r.B - $r.T) + if ([RPWin]::ClassOf($h) -eq 'MainWndClass') { $area += 1000000000 } + if ($area -gt $bestArea) { $bestArea = $area; $best = $h } + } + return $best +} + +function Set-ClientSize([IntPtr]$hWnd, [int]$w, [int]$h) { + # Resize by measured client-vs-outer delta rather than AdjustWindowRectEx, whose + # metrics don't match the game window's real frame under Win11 DPI scaling. + for ($i = 0; $i -lt 3; $i++) { + $wr = New-Object RPWin+RECT; $cr = New-Object RPWin+RECT + [void][RPWin]::GetWindowRect($hWnd, [ref]$wr) + [void][RPWin]::GetClientRect($hWnd, [ref]$cr) + $dw = $w - ($cr.R - $cr.L); $dh = $h - ($cr.B - $cr.T) + if ($dw -eq 0 -and $dh -eq 0) { return } + [void][RPWin]::SetWindowPos($hWnd, [IntPtr]::Zero, 0, 0, ($wr.R - $wr.L + $dw), ($wr.B - $wr.T + $dh), $SWP_RESIZE) + } +} + +function Set-WindowTiling([uint32]$targetPid, [IntPtr]$hMain) { + # Simple non-overlapping row (wraps at the desktop edge). Used while the game loads + # and as the fallback arrangement when the mirrors are not running. + $ordered = Get-OrderedWindows $targetPid $hMain + $screenW = [RPWin]::GetSystemMetrics(78) # SM_CXVIRTUALSCREEN + if ($screenW -le 0) { $screenW = [RPWin]::GetSystemMetrics(0) } # SM_CXSCREEN + $x = 0; $y = 0; $rowH = 0 + foreach ($h in $ordered) { + $r = New-Object RPWin+RECT + [void][RPWin]::GetWindowRect($h, [ref]$r) + $w = $r.R - $r.L; $ht = $r.B - $r.T + if (($x + $w) -gt $screenW -and $x -gt 0) { $x = 0; $y += $rowH; $rowH = 0 } + if ($r.L -ne $x -or $r.T -ne $y) { + [void][RPWin]::SetWindowPos($h, [IntPtr]::Zero, $x, $y, 0, 0, $SWP_MOVE) + } + $x += $w + if ($ht -gt $rowH) { $rowH = $ht } + } +} + +function Get-ClientSizeOf([IntPtr]$hWnd) { + $c = New-Object RPWin+RECT + [void][RPWin]::GetClientRect($hWnd, [ref]$c) + return @(($c.R - $c.L), ($c.B - $c.T)) +} + +# --- wait for the main window, then pin size + tiling for a few seconds -- +$hMain = [IntPtr]::Zero +$deadline = (Get-Date).AddSeconds(60) +while ((Get-Date) -lt $deadline) { + if ($proc.HasExited) { Write-Host "Game exited before a window appeared."; exit 1 } + $hMain = Get-MainWindow $pidOfGame + if ($hMain -ne [IntPtr]::Zero) { break } + Start-Sleep -Milliseconds 250 +} +if ($hMain -eq [IntPtr]::Zero) { Write-Host "Timed out waiting for the game window."; exit 1 } + +Write-Host ("Main window found (class '{0}', title '{1}') - resizing client area to {2}x{3}" -f ` + [RPWin]::ClassOf($hMain), [RPWin]::TitleOf($hMain), $Width, $Height) + +$pinUntil = (Get-Date).AddSeconds(10) +while ((Get-Date) -lt $pinUntil) { + if ($proc.HasExited) { break } + $c = New-Object RPWin+RECT + [void][RPWin]::GetClientRect($hMain, [ref]$c) + if (($c.R - $c.L) -ne $Width -or ($c.B - $c.T) -ne $Height) { Set-ClientSize $hMain $Width $Height } + Set-WindowTiling $pidOfGame $hMain + Start-Sleep -Milliseconds 500 +} + +$ordered = Get-OrderedWindows $pidOfGame $hMain +if ($TileOnly -or $ordered.Count -lt 4) { + if ($ordered.Count -lt 4) { Write-Host "Fewer than 4 game windows found; leaving plain tiling." } + Write-Host "Done. Game is running (PID $pidOfGame); windows tiled without overlap." + exit 0 +} + +# --- pod cockpit mirrors -------------------------------------------------- +# Sources: 2 = map, 3 = MFD pack 1 (R,G,B), 4 = MFD pack 2 (R,G). +$script:hMap = $ordered[1] +$script:hMfd1 = $ordered[2] +$script:hMfd2 = $ordered[3] +$script:gameProc = $proc + +$mapSize = Get-ClientSizeOf $script:hMap +$mfd1Size = Get-ClientSizeOf $script:hMfd1 +$mfd2Size = Get-ClientSizeOf $script:hMfd2 +$cellW = [int]$mfd1Size[0]; $cellH = [int]$mfd1Size[1] + +# Hide all three source windows behind the main window (bottom of z-order at 0,0, +# where the larger main window covers them). They must remain on-screen or the game's +# D3D9 device stops presenting and captures read black. +foreach ($h in @($script:hMap, $script:hMfd1, $script:hMfd2)) { + [void][RPWin]::SetWindowPos($h, $HWND_BOTTOM, 0, 0, 0, 0, $SWP_MOVE_Z) +} + +# ImageAttributes that render one source channel (0=R,1=G,2=B) as a green screen: +# output G = input , output R = B = 0. +function New-GreenChannelAttrs([int]$channel) { + $rows = [System.Array]::CreateInstance([single[]], 5) + for ($i = 0; $i -lt 5; $i++) { $rows[$i] = New-Object 'single[]' 5 } + $rows[$channel][1] = 1.0 # source channel -> green output + $rows[3][3] = 1.0 # alpha passthrough + $rows[4][4] = 1.0 + $cm = New-Object System.Drawing.Imaging.ColorMatrix -ArgumentList @(,$rows) + $ia = New-Object System.Drawing.Imaging.ImageAttributes + $ia.SetColorMatrix($cm) + return $ia +} + +function Get-WindowBitmap([IntPtr]$hWnd, [int]$w, [int]$h) { + $bmp = New-Object System.Drawing.Bitmap($w, $h) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $hdcDst = $g.GetHdc() + $hdcSrc = [RPWin]::GetDC($hWnd) + [void][RPWin]::BitBlt($hdcDst, 0, 0, $w, $h, $hdcSrc, 0, 0, 0x00CC0020) # SRCCOPY + [void][RPWin]::ReleaseDC($hWnd, $hdcSrc) + $g.ReleaseHdc($hdcDst) + $g.Dispose() + return $bmp +} + +# Layout (see banner at the top of this file). Grid sits to the right of the main +# window; map is portrait (rotated 90CW) centered under the middle (green) MFD. +# The mirrors get title bars and fixed borders so the user can drag them around, so +# positions are computed from OUTER window sizes after each form's chrome is applied. +$mr = New-Object RPWin+RECT +[void][RPWin]::GetWindowRect($hMain, [ref]$mr) +$gx = $mr.R - $mr.L # grid origin = right edge of main window +$mapRotW = [int]$mapSize[1]; $mapRotH = [int]$mapSize[0] + +$viewDefs = @( + @{ Name = 'MFD 1 (W3 red)'; Src = 'mfd1'; Chan = 0; W = $cellW; H = $cellH } + @{ Name = 'MFD 2 (W3 green)'; Src = 'mfd1'; Chan = 1; W = $cellW; H = $cellH } + @{ Name = 'MFD 3 (W3 blue)'; Src = 'mfd1'; Chan = 2; W = $cellW; H = $cellH } + @{ Name = 'MFD 4 (W4 red)'; Src = 'mfd2'; Chan = 0; W = $cellW; H = $cellH } + @{ Name = 'Map (90CW)'; Src = 'map'; Chan = -1; W = $mapRotW; H = $mapRotH } + @{ Name = 'MFD 5 (W4 green)'; Src = 'mfd2'; Chan = 1; W = $cellW; H = $cellH } +) + +$script:views = @() +$script:forms = @() +foreach ($def in $viewDefs) { + $form = New-Object System.Windows.Forms.Form + $form.FormBorderStyle = 'FixedSingle' # title bar + border: movable, not resizable + $form.MaximizeBox = $false + $form.StartPosition = 'Manual' + $form.Text = "RPL4 " + $def.Name + $form.BackColor = [System.Drawing.Color]::Black + $form.ClientSize = New-Object System.Drawing.Size($def.W, $def.H) + $form.ShowInTaskbar = $false + $form.TopMost = [bool]$TopMost + $pic = New-Object System.Windows.Forms.PictureBox + $pic.Dock = 'Fill' + $pic.BackColor = [System.Drawing.Color]::Black + $form.Controls.Add($pic) + $attrs = $null + if ($def.Chan -ge 0) { $attrs = New-GreenChannelAttrs $def.Chan } + $script:views += [pscustomobject]@{ Src = $def.Src; Chan = $def.Chan; Pic = $pic; Attrs = $attrs; W = $def.W; H = $def.H } + $script:forms += $form +} + +# Position the grid using outer sizes (client size + title bar/border chrome). +$fW3R = $script:forms[0]; $fW3G = $script:forms[1]; $fW3B = $script:forms[2] +$fW4R = $script:forms[3]; $fMap = $script:forms[4]; $fW4G = $script:forms[5] +$fW3R.Location = New-Object System.Drawing.Point($gx, 0) +$fW3G.Location = New-Object System.Drawing.Point(($gx + $fW3R.Width), 0) +$fW3B.Location = New-Object System.Drawing.Point(($gx + $fW3R.Width + $fW3G.Width), 0) +$rowY = $fW3R.Height +$fW4R.Location = New-Object System.Drawing.Point($gx, $rowY) +$fMap.Location = New-Object System.Drawing.Point(($fW3G.Left + [int](($fW3G.Width - $fMap.Width) / 2)), $rowY) +$fW4G.Location = New-Object System.Drawing.Point($fW3B.Left, $rowY) + +# --- saved layout: restore user-arranged window positions ----------------- +$script:formsByName = @{} +for ($i = 0; $i -lt $viewDefs.Count; $i++) { $script:formsByName[$viewDefs[$i].Name] = $script:forms[$i] } + +function Get-ClampedPos([int]$x, [int]$y, [int]$w, [int]$h) { + # Keep at least part of the window reachable on the current virtual desktop + # (saved layouts can reference monitors that are no longer attached). + $vx = [RPWin]::GetSystemMetrics(76); $vy = [RPWin]::GetSystemMetrics(77) + $vw = [RPWin]::GetSystemMetrics(78); $vh = [RPWin]::GetSystemMetrics(79) + if ($vw -le 0) { return @($x, $y) } + $x = [Math]::Max(($vx - $w + 60), [Math]::Min($x, ($vx + $vw - 60))) + $y = [Math]::Max($vy, [Math]::Min($y, ($vy + $vh - 40))) + return @($x, $y) +} + +$script:savedLayout = $null +if (Test-Path $script:layoutPath) { + try { + $json = Get-Content $script:layoutPath -Raw | ConvertFrom-Json + $script:savedLayout = @{} + foreach ($prop in $json.psobject.Properties) { $script:savedLayout[$prop.Name] = $prop.Value } + } catch { $script:savedLayout = $null } +} +if ($script:savedLayout) { + foreach ($name in @($script:formsByName.Keys)) { + if ($script:savedLayout.ContainsKey($name)) { + $f = $script:formsByName[$name] + $p = $script:savedLayout[$name] + $pos = Get-ClampedPos ([int]$p.X) ([int]$p.Y) $f.Width $f.Height + $f.Location = New-Object System.Drawing.Point($pos[0], $pos[1]) + } + } + if ($script:savedLayout.ContainsKey('Main')) { + $p = $script:savedLayout['Main'] + $pos = Get-ClampedPos ([int]$p.X) ([int]$p.Y) ($mr.R - $mr.L) ($mr.B - $mr.T) + [void][RPWin]::SetWindowPos($hMain, [IntPtr]::Zero, $pos[0], $pos[1], 0, 0, $SWP_MOVE) + } + Write-Host "Restored saved window layout from cockpit-layout.json" +} + +$script:lastMainPos = $null +function Save-Layout { + # Snapshot current positions (skipping minimized windows) so the arrangement + # survives the next launch. Only invoked on exit when -SaveLayout was given. + try { + $data = [ordered]@{} + if ([RPWin]::IsWindowVisible($script:hMain)) { + $r = New-Object RPWin+RECT + [void][RPWin]::GetWindowRect($script:hMain, [ref]$r) + if ($r.L -gt -10000) { $data['Main'] = @{ X = $r.L; Y = $r.T } } + } elseif ($script:lastMainPos) { + # game already exited; use the position seen on the last mirror tick + $data['Main'] = @{ X = $script:lastMainPos[0]; Y = $script:lastMainPos[1] } + } + foreach ($name in $script:formsByName.Keys) { + $f = $script:formsByName[$name] + if (-not $f.IsDisposed -and $f.WindowState -eq 'Normal') { + $data[$name] = @{ X = $f.Location.X; Y = $f.Location.Y } + } + } + if ($data.Count -gt 0) { + ($data | ConvertTo-Json) | Set-Content -Path $script:layoutPath -Encoding UTF8 + Write-Host "Window layout recorded to cockpit-layout.json" + } + } catch { } +} + + +$script:srcSizes = @{ map = $mapSize; mfd1 = $mfd1Size; mfd2 = $mfd2Size } +$script:srcWnds = @{ map = $script:hMap; mfd1 = $script:hMfd1; mfd2 = $script:hMfd2 } +$script:closing = $false +$script:wantSaveLayout = [bool]$SaveLayout + +function Restore-Sources { + # Bring the parked windows back into a visible row (used if mirrors are closed + # while the game is still running). + if ($script:gameProc.HasExited) { return } + $mr2 = New-Object RPWin+RECT + [void][RPWin]::GetWindowRect($script:hMain, [ref]$mr2) + $x = $mr2.R - $mr2.L + foreach ($h in @($script:hMap, $script:hMfd1, $script:hMfd2)) { + $r = New-Object RPWin+RECT + [void][RPWin]::GetWindowRect($h, [ref]$r) + [void][RPWin]::SetWindowPos($h, [IntPtr]::Zero, $x, 0, 0, 0, $SWP_MOVE_Z) # HWND_TOP + $x += ($r.R - $r.L) + } +} +$script:hMain = $hMain + +$script:timer = New-Object System.Windows.Forms.Timer +$script:timer.Interval = 50 # ~20 fps +$script:timer.Add_Tick({ + if ($script:gameProc.HasExited) { + $script:timer.Stop() + [System.Windows.Forms.Application]::Exit() + return + } + # keep the parked sources tucked under the main window even if the user drags it + # (skip while minimized - GetWindowRect then reports -32000 and chasing it would + # push the sources off-screen, which stops the game presenting into them) + $mrT = New-Object RPWin+RECT + [void][RPWin]::GetWindowRect($script:hMain, [ref]$mrT) + if ($mrT.L -gt -10000) { + $script:lastMainPos = @($mrT.L, $mrT.T) # remembered for Save-Layout after the game exits + foreach ($h in @($script:hMap, $script:hMfd1, $script:hMfd2)) { + $sr = New-Object RPWin+RECT + [void][RPWin]::GetWindowRect($h, [ref]$sr) + if ($sr.L -ne $mrT.L -or $sr.T -ne $mrT.T) { + [void][RPWin]::SetWindowPos($h, $HWND_BOTTOM, $mrT.L, $mrT.T, 0, 0, $SWP_MOVE_Z) + } + } + } + # capture each source once per tick + $caps = @{} + foreach ($key in @('map', 'mfd1', 'mfd2')) { + $sz = $script:srcSizes[$key] + $caps[$key] = Get-WindowBitmap $script:srcWnds[$key] ([int]$sz[0]) ([int]$sz[1]) + } + foreach ($v in $script:views) { + $srcBmp = $caps[$v.Src] + if ($v.Chan -lt 0) { + # map view: rotate 90 degrees clockwise, full color + $out = $srcBmp.Clone() + $out.RotateFlip([System.Drawing.RotateFlipType]::Rotate90FlipNone) + } else { + # MFD view: render one channel as a green screen + $out = New-Object System.Drawing.Bitmap($v.W, $v.H) + $g = [System.Drawing.Graphics]::FromImage($out) + $rect = New-Object System.Drawing.Rectangle(0, 0, $v.W, $v.H) + $g.DrawImage($srcBmp, $rect, 0, 0, $srcBmp.Width, $srcBmp.Height, [System.Drawing.GraphicsUnit]::Pixel, $v.Attrs) + $g.Dispose() + } + $old = $v.Pic.Image + $v.Pic.Image = $out + if ($old) { $old.Dispose() } + } + foreach ($bmp in $caps.Values) { $bmp.Dispose() } +}) + +foreach ($form in $script:forms) { + $form.Add_FormClosed({ + if ($script:closing) { return } + $script:closing = $true + $script:timer.Stop() + if ($script:wantSaveLayout) { Save-Layout } + Restore-Sources + [System.Windows.Forms.Application]::Exit() + }) + $form.Show() +} + +Write-Host "Pod cockpit layout active: main + 5 green-screen MFDs + rotated map." +Write-Host "Sources for windows 2-4 are hidden behind the main window; closing any mirror restores them." +Write-Host "Wrapper stays running to drive the mirrors; it exits when the game does." + +$script:timer.Start() +[System.Windows.Forms.Application]::Run() + +# If we fell out of the message loop because the game exited, record the layout (when +# requested) and close any open forms. +if ($script:wantSaveLayout -and -not $script:closing) { Save-Layout } +$script:closing = $true +foreach ($form in $script:forms) { if (-not $form.IsDisposed) { $form.Close() } } diff --git a/docs/RP412-ROADMAP.md b/docs/RP412-ROADMAP.md index 4e6deec..23cd36d 100644 --- a/docs/RP412-ROADMAP.md +++ b/docs/RP412-ROADMAP.md @@ -105,9 +105,12 @@ TeslaConsole drives over TCP 1501 becomes in-game UI: vRIO). Remaining cleanups called out there: un-`/Zp1` the Windows-header boundary, port `stdext::hash_map` → `std::unordered_map`, single-TU the duplicated globals so `/FORCE:MULTIPLE` can go away. -- ⚠️ Known pre-existing crash (both toolchains): standalone mission load AVs in - `d3d_OBJECT::LoadTexture` (`L4D3D.cpp:262`) — first debugging target now - that the v143 build has full PDBs (BUILD.md §4). +- ✅ The pre-existing mission-load crash (NULL-texture `AddRef` in + `d3d_OBJECT::LoadTexture`) is fixed — missing textures render untextured and + log. The game boots standalone to a running window (BUILD.md §4). Note for + Workstream B: pod skins (`player1–8`) come from the presets / + replacement-material path the console used to drive; the in-game session UI + must own that. ## Suggested order