Two-pod LAN mesh test: multiplayer verified through the seam

tools/two-pod-test.ps1 stands up two -net pods on loopback and
marshals them with a minimal console feeder speaking the Munga
protocol (TeslaSuite vendored Munga Net.dll): state polling, egg
chunk delivery with ACK-after-mesh, RunMission when both pods reach
WaitingForLaunch, StopMission(0) at time expiry, EndMission score
intake.

First run passed clean: pod A listened on its game port, pod B
connected from its bound port, both ACKed after the mesh completed,
raced the same 60s mission on Wiseguy's Wake, stopped on command,
and reported final scores. The deterministic TCP mesh works end to
end through the NetTransport seam - the feeder logic is exactly the
marshal the Steam lobby owner will run in-process.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Cyd
2026-07-12 20:14:47 -05:00
co-authored by Claude Fable 5
parent 22421fb418
commit 7315f3488d
2 changed files with 271 additions and 0 deletions
+10
View File
@@ -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:
+261
View File
@@ -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