# 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() } }