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:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user