Files
RP412/assets/RP411/startrp-800x600.ps1
T
CydandClaude Fable 5 d6745353b1 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 <noreply@anthropic.com>
2026-07-12 12:41:29 -05:00

478 lines
22 KiB
PowerShell

# 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<IntPtr> GetProcessWindows(uint pid) {
var list = new List<IntPtr>();
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 <channel>, 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() } }