diff --git a/docs/RP412-FRONTEND-DESIGN.md b/docs/RP412-FRONTEND-DESIGN.md index acc1530..0ad464b 100644 --- a/docs/RP412-FRONTEND-DESIGN.md +++ b/docs/RP412-FRONTEND-DESIGN.md @@ -105,6 +105,16 @@ front end (owner collects FakeIPs/loadouts via lobby data, builds and distributes the canonical egg, runs the RPL4CONSOLE marshal) → install with `NetTransport_Set` at WinMain when launched under Steam. +**Multi-pod verified (2026-07-12):** `tools/two-pod-test.ps1` runs two +`-net` pods on loopback (console ports 1501/1601 → game ports 1502/1602) +marshaled by a minimal console feeder built on TeslaSuite's vendored +`Munga Net.dll`. Confirmed end to end through the seam: egg chunks + +per-pod ACK after "All connections completed!" (pod A listened, pod B +connected from its bound game port — the deterministic mesh), both pods +raced the same 60s mission, StopMission(0) ended it, and both pods +returned EndMission final scores. The feeder is exactly the marshal the +Steam lobby owner will run in-process. + ## 4. Implementation options (decide here) **A. In-engine front end (recommended).** Port the egg builder (~300 lines: diff --git a/tools/two-pod-test.ps1 b/tools/two-pod-test.ps1 new file mode 100644 index 0000000..1bfb74c --- /dev/null +++ b/tools/two-pod-test.ps1 @@ -0,0 +1,261 @@ +# Two-pod LAN mesh test: two -net pods on loopback, marshaled by a +# minimal console feeder speaking the Munga protocol (vendored +# TeslaSuite Munga Net.dll). Proves the deterministic TCP mesh works +# through the NetTransport seam end to end: +# pod A listens on game port 1502, pod B connects to it from 1602, +# both ACK the egg only after "All connections completed!", race the +# selected 60s, stop on the console's StopMission(0), and report +# final scores. +param( + # a full game install: rpl4opt.exe + RPL4.RES + *.INI + AUDIO/GAUGE/VIDEO + [string]$GameDir = "$PSScriptRoot\..\dist", + # the vendored console-protocol library from TeslaSuite + [string]$MungaNetDll = 'C:\VWE\TeslaSuite\Console\lib\Munga Net.dll', + # ordinal plasma art is lifted from the reference egg + [string]$ReferenceEgg = "$PSScriptRoot\..\assets\RP411\TEST.EGG", + [string]$WorkDir = "$env:TEMP\rp412-twopod" +) +$ErrorActionPreference = 'Stop' +$GameDir = (Resolve-Path $GameDir).Path +$ReferenceEgg = (Resolve-Path $ReferenceEgg).Path +if (-not (Test-Path $WorkDir)) { New-Item -ItemType Directory $WorkDir | Out-Null } +$scratch = $WorkDir +$smoke = $GameDir + +Add-Type -Path $MungaNetDll +Add-Type -AssemblyName System.Drawing +Add-Type -AssemblyName System.Windows.Forms +$src = @' +using System; +using System.Runtime.InteropServices; +public class PodWin { + [DllImport("user32.dll")] public static extern bool SetWindowPos(IntPtr h, IntPtr after, int x, int y, int cx, int cy, uint flags); + [DllImport("user32.dll")] public static extern bool GetWindowRect(IntPtr h, out RECT r); + [StructLayout(LayoutKind.Sequential)] public struct RECT { public int L, T, R, B; } +} +'@ +Add-Type -TypeDefinition $src + +#----------------------------------------------------------------- +# Pod working dirs: junction the big asset dirs, copy the roots +#----------------------------------------------------------------- +foreach ($pod in 'podA', 'podB') { + $dir = "$scratch\$pod" + if (-not (Test-Path $dir)) { New-Item -ItemType Directory $dir | Out-Null } + foreach ($assets in 'AUDIO', 'GAUGE', 'VIDEO') { + if (-not (Test-Path "$dir\$assets")) { + New-Item -ItemType Junction -Path "$dir\$assets" -Target "$smoke\$assets" | Out-Null + } + } + foreach ($file in 'environ.ini', 'JOYSTICK.INI', 'RPDPL.INI', 'RPL4.RES', 'libsndfile-1.dll', 'OpenAL32.dll', 'rpl4opt.exe') { + if (Test-Path "$smoke\$file") { Copy-Item "$smoke\$file" $dir -Force } + } + Remove-Item "$dir\rpl4.log", "$dir\last.egg" -Force -Confirm:$false -ErrorAction SilentlyContinue +} + +#----------------------------------------------------------------- +# The two-pilot egg (RPMission.ToEggString layout; blank plasma +# name bitmaps - the glass is hidden in RP412 anyway; ordinals +# lifted verbatim from TEST.EGG) +#----------------------------------------------------------------- +function Add-BlankBitmap([Text.StringBuilder]$sb, [int]$w, [int]$h) { + $row = 'bitmap=' + ('0' * ($w / 4)) + for ($i = 0; $i -lt $h; $i++) { [void]$sb.AppendLine($row) } + [void]$sb.AppendLine("x=$w"); [void]$sb.AppendLine("y=$h") +} + +$test = [IO.File]::ReadAllText($ReferenceEgg) +$ordinals = $test.Substring($test.IndexOf('[ordinals]')) + +$pilots = @( + @{ addr = '127.0.0.1:1502'; name = 'PODA'; vehicle = 'speck'; color = 'Red' }, + @{ addr = '127.0.0.1:1602'; name = 'PODB'; vehicle = 'roach'; color = 'Blue' } +) + +$sb = New-Object Text.StringBuilder +[void]$sb.AppendLine('[mission]') +[void]$sb.AppendLine('adventure=Red Planet') +[void]$sb.AppendLine('map=wise') +[void]$sb.AppendLine('scenario=race') +[void]$sb.AppendLine('time=day') +[void]$sb.AppendLine('weather=clear') +[void]$sb.AppendLine('temperature=0') +[void]$sb.AppendLine('compression=0') +[void]$sb.AppendLine('length=60') +[void]$sb.AppendLine('[pilots]') +foreach ($p in $pilots) { [void]$sb.AppendLine("pilot=$($p.addr)") } +$index = 1 +foreach ($p in $pilots) { + [void]$sb.AppendLine("[$($p.addr)]") + [void]$sb.AppendLine('hostType=0') + [void]$sb.AppendLine('dropzone=one') + [void]$sb.AppendLine("name=$($p.name)") + [void]$sb.AppendLine("bitmapindex=$index") + [void]$sb.AppendLine('loadzones=1') + [void]$sb.AppendLine("vehicle=$($p.vehicle)") + [void]$sb.AppendLine("color=$($p.color)") + [void]$sb.AppendLine('badge=None') + $index++ +} +[void]$sb.AppendLine('[largebitmap]') +foreach ($p in $pilots) { [void]$sb.AppendLine("bitmap=BitMap::Large::$($p.name)") } +[void]$sb.AppendLine('[smallbitmap]') +foreach ($p in $pilots) { [void]$sb.AppendLine("bitmap=BitMap::Small::$($p.name)") } +foreach ($p in $pilots) { + [void]$sb.AppendLine("[BitMap::Large::$($p.name)]") + Add-BlankBitmap $sb 128 32 + [void]$sb.AppendLine('width=8') + [void]$sb.AppendLine("[BitMap::Small::$($p.name)]") + Add-BlankBitmap $sb 64 16 + [void]$sb.AppendLine('width=4') +} +[void]$sb.Append($ordinals) +$eggText = $sb.ToString() +[IO.File]::WriteAllText("$scratch\twopod.egg", $eggText) +Write-Output "egg built: $($eggText.Length) chars" + +# wire form: newlines -> NUL, chunked into EggFileMessages (RPMission.ToEggFileMessages) +$wire = $eggText.Replace("`r`n", "`0").Replace("`n", "`0") +$bytes = [Text.Encoding]::ASCII.GetBytes($wire) +$chunks = @() +for ($off = 0; $off -lt $bytes.Length; $off += 1000) { + $n = [Math]::Min(1000, $bytes.Length - $off) + $buf = New-Object byte[] 1000 + [Buffer]::BlockCopy($bytes, $off, $buf, 0, $n) + $chunks += New-Object Munga.Net.EggFileMessage([int]($off / 1000), $bytes.Length, $n, $buf) +} +Write-Output "egg chunks: $($chunks.Count)" + +#----------------------------------------------------------------- +# Launch the pods +#----------------------------------------------------------------- +Get-Process rpl4opt -ErrorAction SilentlyContinue | Stop-Process -Force -Confirm:$false -ErrorAction SilentlyContinue +Start-Sleep -Seconds 1 +$procs = @{} +$procs['A'] = Start-Process -FilePath "$scratch\podA\rpl4opt.exe" -ArgumentList '-windowed','-res','1920','1080','-net','1501' -WorkingDirectory "$scratch\podA" -PassThru +Start-Sleep -Seconds 2 +$procs['B'] = Start-Process -FilePath "$scratch\podB\rpl4opt.exe" -ArgumentList '-windowed','-res','1920','1080','-net','1601' -WorkingDirectory "$scratch\podB" -PassThru +Start-Sleep -Seconds 8 + +# offset pod B's window so a desktop screenshot shows both +if ($procs['B'].MainWindowHandle -ne 0) { + [PodWin]::SetWindowPos($procs['B'].MainWindowHandle, [IntPtr]::Zero, 700, 250, 0, 0, 0x0015) | Out-Null +} + +#----------------------------------------------------------------- +# The feeder: connect, poll state, feed eggs, launch, time, stop +#----------------------------------------------------------------- +$pods = @{} +foreach ($k in @('A','B')) { + $port = if ($k -eq 'A') { 1501 } else { 1601 } + $sock = New-Object Munga.Net.MungaSocket + $sock.Connect([Net.IPAddress]::Loopback, [uint16]$port) + $pods[$k] = @{ + sock = $sock; state = $null; ack = $false + eggSent = [DateTime]::MinValue; lastQuery = [DateTime]::MinValue + score = $null; runSent = $false; stopSent = $false + } + Write-Output "console connected to pod $k (port $port)" +} + +$stateQuery = New-Object Munga.Net.StateQueryMessage(1) +$runStart = $null +$deadline = (Get-Date).AddMinutes(6) +$raceShotTaken = $false +$log = New-Object System.Collections.Generic.List[string] + +while ((Get-Date) -lt $deadline) { + foreach ($k in @('A','B')) { + $pod = $pods[$k] + $now = Get-Date + if (($now - $pod.lastQuery).TotalSeconds -ge 1) { + $pod.sock.Send(0, 1, $stateQuery) + $pod.lastQuery = $now + } + for ($m = $pod.sock.Receive(); $m -ne $null; $m = $pod.sock.Receive()) { + $msg = $m.Message + if ($msg -eq $null) { continue } + $type = $msg.GetType().Name + switch ($type) { + 'StateResponseMessage' { + if ("$($pod.state)" -ne "$($msg.ApplicationState)") { + $log.Add("pod ${k}: state -> $($msg.ApplicationState)") + Write-Output "pod ${k}: state -> $($msg.ApplicationState)" + } + $pod.state = $msg.ApplicationState + } + 'AcknowledgeEggFileMessage' { + if (-not $pod.ack) { $log.Add("pod ${k}: EGG ACK (mesh complete)"); Write-Output "pod ${k}: EGG ACK (mesh complete)" } + $pod.ack = $true + } + 'EndMissionMessage' { + $pod.score = $msg.FinalScore + $log.Add("pod ${k}: FINAL SCORE host=$($msg.PlayerHostID) score=$($msg.FinalScore)") + Write-Output "pod ${k}: FINAL SCORE host=$($msg.PlayerHostID) score=$($msg.FinalScore)" + } + default { + $log.Add("pod ${k}: $type") + } + } + } + if ("$($pod.state)" -eq 'WaitingForEgg' -and -not $pod.ack -and + ($now - $pod.eggSent).TotalSeconds -ge 5) { + Write-Output "pod ${k}: sending egg ($($chunks.Count) chunks)" + foreach ($chunk in $chunks) { $pod.sock.Send(0, 1, $chunk) } + $pod.eggSent = $now + } + } + + $bothWaiting = ("$($pods['A'].state)" -eq 'WaitingForLaunch') -and ("$($pods['B'].state)" -eq 'WaitingForLaunch') + if ($bothWaiting -and -not $pods['A'].runSent) { + Write-Output 'both pods WaitingForLaunch: sending RunMission' + foreach ($k in @('A','B')) { + $pods[$k].sock.Send(0, 1, (New-Object Munga.Net.RunMissionMessage)) + $pods[$k].runSent = $true + } + } + + $bothRunning = ("$($pods['A'].state)" -eq 'RunningMission') -and ("$($pods['B'].state)" -eq 'RunningMission') + if ($bothRunning -and $runStart -eq $null) { + $runStart = Get-Date + Write-Output "both pods RUNNING at $runStart - 60s race underway" + } + + if ($runStart -ne $null -and -not $raceShotTaken -and ((Get-Date) - $runStart).TotalSeconds -ge 20) { + $raceShotTaken = $true + $screen = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds + $bmp = New-Object System.Drawing.Bitmap($screen.Width, $screen.Height) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.CopyFromScreen(0, 0, 0, 0, $bmp.Size) + $g.Dispose() + $bmp.Save("$scratch\twopod_race.png", [System.Drawing.Imaging.ImageFormat]::Png) + $bmp.Dispose() + Write-Output 'race screenshot saved' + } + + if ($runStart -ne $null -and -not $pods['A'].stopSent -and + ((Get-Date) - $runStart).TotalSeconds -ge 60) { + Write-Output 'race time expired: sending StopMission(0) to both pods' + foreach ($k in @('A','B')) { + $pods[$k].sock.Send(0, 1, (New-Object Munga.Net.StopMissionMessage(0))) + $pods[$k].stopSent = $true + } + } + + if ($pods['A'].stopSent -and $pods['A'].score -ne $null -and $pods['B'].score -ne $null) { + Write-Output 'SUCCESS: both pods raced the mesh, stopped on command, scores collected' + break + } + Start-Sleep -Milliseconds 250 +} + +Start-Sleep -Seconds 5 +Write-Output '=== summary ===' +Write-Output "pod A: ack=$($pods['A'].ack) finalState=$($pods['A'].state) score=$($pods['A'].score)" +Write-Output "pod B: ack=$($pods['B'].ack) finalState=$($pods['B'].state) score=$($pods['B'].score)" +foreach ($k in @('A','B')) { try { $pods[$k].sock.Shutdown() } catch {} } +Get-Process rpl4opt -ErrorAction SilentlyContinue | Stop-Process -Force -Confirm:$false -ErrorAction SilentlyContinue +Write-Output '=== pod A netlog ===' +Get-Content "$scratch\podA\rpl4.log" -ErrorAction SilentlyContinue | Select-String 'isten|onnect|ccept|egg|Egg|host|Host' | Select-Object -First 14 | ForEach-Object Line +Write-Output '=== pod B netlog ===' +Get-Content "$scratch\podB\rpl4.log" -ErrorAction SilentlyContinue | Select-String 'isten|onnect|ccept|egg|Egg|host|Host' | Select-Object -First 14 | ForEach-Object Line \ No newline at end of file