This commit is contained in:
algodoogle
2026-07-26 13:49:46 +01:00
parent 23ec41c1d6
commit ae3e7ec674
4 changed files with 215 additions and 5 deletions
+89
View File
@@ -0,0 +1,89 @@
# Stitches the per-step screenshots from a test run into one side-by-side GIF,
# server on the left, client on the right.
#
# powershell -File test\make_gif.ps1
#
# Reads logs\mptest_frames_server\ and logs\mptest_frames_client\ (written when
# the driver runs with --mptest-frames) and writes logs\mptest_run.gif.
# run_mp_test_windowed.ps1 calls this automatically; run it by hand to rebuild
# the GIF after a manual session, or to re-render at a different speed.
#
# Each frame already carries its own caption: the on-screen overlay in the
# capture starts with [SERVER] or [CLIENT] and shows that step's log lines.
param(
[string]$FFmpeg = "ffmpeg",
# Seconds each step is held on screen.
[double]$SecondsPerStep = 1.2,
# Width of each peer's half of the frame, in pixels.
[int]$HalfWidth = 900,
[string]$Out = ""
)
$ErrorActionPreference = "Stop"
$proj = Split-Path -Parent $PSScriptRoot
$serverDir = Join-Path $proj "logs/mptest_frames_server"
$clientDir = Join-Path $proj "logs/mptest_frames_client"
if (-not $Out) { $Out = Join-Path $proj "logs/mptest_run.gif" }
if (-not (Get-Command $FFmpeg -EA SilentlyContinue)) {
Write-Host "ffmpeg not found. Install it, or pass -FFmpeg <path to ffmpeg.exe>."
exit 1
}
function Frame-Count($dir) {
if (-not (Test-Path $dir)) { return 0 }
return (Get-ChildItem (Join-Path $dir "frame_*.png") -EA SilentlyContinue).Count
}
$ns = Frame-Count $serverDir
$nc = Frame-Count $clientDir
Write-Host "server frames: $ns"
Write-Host "client frames: $nc"
if ($ns -eq 0 -and $nc -eq 0) {
Write-Host ""
Write-Host "No frames found. Run the test with frame capture first:"
Write-Host " powershell -File test\run_mp_test_windowed.ps1"
Write-Host "(frame capture needs a real window - a headless run renders nothing to grab)"
exit 1
}
$fps = [Math]::Round(1.0 / $SecondsPerStep, 4)
if ($ns -gt 0 -and $nc -gt 0) {
if ($ns -ne $nc) {
# hstack stops at the shorter input, so the tail of the longer one is lost.
Write-Host "note: frame counts differ, the GIF will stop after $([Math]::Min($ns,$nc)) steps"
}
# Two passes in one command: build a palette from the stacked frames, then
# apply it. A single global palette keeps the GIF small and stops colours
# shifting from frame to frame.
$filter = "[0:v]scale=${HalfWidth}:-1:flags=lanczos[l];" +
"[1:v]scale=${HalfWidth}:-1:flags=lanczos[r];" +
"[l][r]hstack=inputs=2[v];" +
"[v]split[a][b];[a]palettegen=max_colors=192[p];[b][p]paletteuse=dither=bayer:bayer_scale=3"
$args = @(
"-y", "-hide_banner", "-loglevel", "error",
"-framerate", $fps, "-i", (Join-Path $serverDir "frame_%04d.png"),
"-framerate", $fps, "-i", (Join-Path $clientDir "frame_%04d.png"),
"-filter_complex", $filter, "-loop", "0", $Out
)
} else {
# Only one peer produced frames (e.g. a solo manual session).
$dir = if ($ns -gt 0) { $serverDir } else { $clientDir }
$filter = "[0:v]scale=${HalfWidth}:-1:flags=lanczos[v];" +
"[v]split[a][b];[a]palettegen=max_colors=192[p];[b][p]paletteuse=dither=bayer:bayer_scale=3"
$args = @(
"-y", "-hide_banner", "-loglevel", "error",
"-framerate", $fps, "-i", (Join-Path $dir "frame_%04d.png"),
"-filter_complex", $filter, "-loop", "0", $Out
)
}
& $FFmpeg @args
if ($LASTEXITCODE -ne 0) { Write-Host "ffmpeg failed ($LASTEXITCODE)"; exit $LASTEXITCODE }
$size = [Math]::Round((Get-Item $Out).Length / 1MB, 2)
Write-Host ""
Write-Host "wrote $Out (${size} MB)"
+111 -3
View File
@@ -44,8 +44,9 @@ const SETUP_TIMEOUT_SEC := 30.0
const SNAP_TOLERANCE := 0.12
## Cooking and washing both take ~3s of station time; allow generously for it.
const STATION_WORK_TIMEOUT := 20.0
## Lines kept in the on-screen overlay.
const OVERLAY_LINES := 16
## Lines kept in the on-screen overlay. Enough to show a whole step, without the
## panel eating the view of the kitchen underneath it.
const OVERLAY_LINES := 11
## Where the debug camera sits and what it aims at: a fixed vantage point that
## frames the whole kitchen (Counter2 at x=-2 through DirtStation at x=2).
@@ -78,10 +79,16 @@ var _hand_detached := false
var _overlay: Label
var _overlay_lines: Array[String] = []
# Per-step screenshots, later stitched into a side-by-side GIF of both peers.
var _frames_enabled := false
var _frames_dir := ""
var _frame_index := 0
func _ready() -> void:
var args := OS.get_cmdline_user_args()
_auto_mode = "--mptest" in args
_frames_enabled = "--mptest-frames" in args
_step_pause = _arg_value(args, "--mptest-pause", 0.0)
_end_hold = _arg_value(args, "--mptest-hold", 0.0)
_world = get_parent()
@@ -96,6 +103,7 @@ func _ready() -> void:
_finish(false)
return
_build_debug_camera()
_setup_frames()
if not _auto_mode:
_log("MANUAL MODE - keys act on this window's peer:")
_log(" " + HELP)
@@ -173,6 +181,8 @@ func _run_server() -> void:
return
await _wait_frames(60)
_banner("starting sequence")
# A frame of the untouched kitchen, so the GIF opens on the starting state.
await _capture_step_frame("start")
# 1-2. Both peers can pick the plate up and put it down.
await _step("client", "client_grab_plate", "grab", ["Plate"])
@@ -290,6 +300,7 @@ func _step(actor: String, label: String, step: String, args: Array) -> void:
res = await _remote(step, args)
_record(label, actor, res)
await _audit_sync(label)
await _capture_step_frame(label)
await _pause()
@@ -300,6 +311,7 @@ func _both(label: String, step: String, args: Array) -> void:
_record(label, "server", await _run_local_step(step, args))
_record(label, "client", await _remote(step, args))
await _audit_sync(label)
await _capture_step_frame(label)
await _pause()
@@ -343,6 +355,8 @@ func _report() -> void:
for r in _results:
if not r["ok"]:
_log("FAILURE %s [%s]: %s" % [r["step"], r["side"], r["detail"]])
await _capture_step_frame("final")
_write_frame_index()
if not _auto_mode:
return
_quit_client.rpc_id(_client_id)
@@ -374,6 +388,7 @@ func _result(step: String, ok: bool, detail: String) -> void:
@rpc("authority", "reliable")
func _quit_client() -> void:
_write_frame_index()
if _end_hold > 0.0:
await get_tree().create_timer(_end_hold).timeout
_finish(true)
@@ -663,6 +678,94 @@ func _check_plate_contains(plate_name: String, food_id: String) -> Dictionary:
return {"ok": true, "detail": "%s contains %s" % [plate_name, str(pc.contained_ids)]}
# --- Per-step screenshots --------------------------------------------------
#
# One frame per peer per step, saved as PNGs and stitched into a single
# side-by-side GIF afterwards (see test/make_gif.ps1). Seeing both peers'
# viewports next to each other for the same step is the fastest way to spot a
# visual desync — the numbers in the log tell you something diverged, the GIF
# shows you what it looked like.
## Frames are captured at half the viewport's resolution: 60-odd full-size
## frames per peer is a lot of pixels to write and then re-encode.
const FRAME_SCALE := 0.5
func _setup_frames() -> void:
if not _frames_enabled:
return
if DisplayServer.get_name() == "headless":
_frames_enabled = false
_log("frame capture disabled: a headless run has no rendered output to grab")
return
var role := "server" if "--server" in OS.get_cmdline_user_args() else "client"
_frames_dir = "res://logs/mptest_frames_%s" % role
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(_frames_dir))
# Clear out a previous run's frames, or the GIF would splice the two together.
var dir := DirAccess.open(_frames_dir)
if dir:
for f in dir.get_files():
if f.ends_with(".png"):
dir.remove(f)
_log("capturing a frame per step into %s" % ProjectSettings.globalize_path(_frames_dir))
# The server numbers the frames and tells the client to grab the matching one, so
# frame N is the same step on both sides and they can be stitched in pairs.
func _capture_step_frame(label: String) -> void:
if not _frames_enabled:
return
_frame_index += 1
if NetworkManager.is_server() and _client_id != 0:
_capture_frame_rpc.rpc_id(_client_id, _frame_index, label)
await _save_frame(_frame_index, label)
@rpc("authority", "reliable")
func _capture_frame_rpc(index: int, label: String) -> void:
if not _frames_enabled:
return
_frame_index = index
await _save_frame(index, label)
func _save_frame(index: int, label: String) -> void:
# Wait for the frame to actually be drawn, or we capture whatever was in the
# buffer before this step's changes landed.
await RenderingServer.frame_post_draw
var tex := get_viewport().get_texture()
if not tex:
return
var img := tex.get_image()
if not img:
return
if FRAME_SCALE != 1.0:
img.resize(int(img.get_width() * FRAME_SCALE), int(img.get_height() * FRAME_SCALE),
Image.INTERPOLATE_BILINEAR)
# Index-only filenames so ffmpeg's image sequence reader can pick them up as
# frame_%04d.png; the step name is already legible in the on-screen overlay.
var err := img.save_png("%s/frame_%04d.png" % [_frames_dir, index])
if err != OK:
_log(" could not save frame %d (%s)" % [index, error_string(err)])
else:
_frame_labels.append("%04d %s" % [index, label])
# Written alongside the frames so a given frame number can be traced back to the
# step that produced it.
var _frame_labels: Array[String] = []
func _write_frame_index() -> void:
if not _frames_enabled or _frame_labels.is_empty():
return
var f := FileAccess.open("%s/frames.txt" % _frames_dir, FileAccess.WRITE)
if f:
for line in _frame_labels:
f.store_line(line)
f.close()
# --- Cross-peer sync audit -------------------------------------------------
#
# Runs after every step. Targeted per-step assertions only look at the one thing
@@ -878,6 +981,9 @@ func _manual(what: String, step: String, args: Array) -> void:
var res := await _run_local_step(step, args)
_running = false
_log("%s %s :: %s" % ["PASS" if res.get("ok") else "FAIL", what, res.get("detail", "")])
# Capture manual steps too, so a hand-driven repro can be turned into a GIF.
await _capture_step_frame(step)
_write_frame_index()
# --- Helpers ---------------------------------------------------------------
@@ -1031,7 +1137,9 @@ func _build_overlay() -> void:
panel.set_anchors_preset(Control.PRESET_TOP_WIDE)
panel.modulate = Color(1, 1, 1, 0.85)
_overlay = Label.new()
_overlay.add_theme_font_size_override("font_size", 13)
# Sized for a 1800x1200 canvas, and to stay legible once a captured frame has
# been scaled down into the GIF.
_overlay.add_theme_font_size_override("font_size", 22)
_overlay.autowrap_mode = TextServer.AUTOWRAP_OFF
_overlay.clip_text = true
panel.add_child(_overlay)
+15 -2
View File
@@ -13,7 +13,13 @@ param(
# Seconds to pause between steps, and to hold the final state on screen.
[double]$Pause = 1.5,
[double]$Hold = 20,
[int]$TimeoutSec = 300
[int]$TimeoutSec = 300,
# Passed through to make_gif.ps1. HalfWidth 900 keeps the on-screen
# step log readable in the GIF; lower it to shrink the file.
[int]$HalfWidth = 900,
[double]$SecondsPerStep = 1.2,
# Skip building the GIF (frames are still captured).
[switch]$NoGif
)
$ErrorActionPreference = "Stop"
@@ -30,7 +36,7 @@ function Start-Instance($extraArgs) {
# --xr-mode off keeps it on the desktop (SteamVR's OpenXR runtime otherwise
# takes over, and two instances can't share a headset anyway).
$a = "--xr-mode off --resolution 900x600 --path `"$proj`" $scene -- " +
"$($extraArgs -join ' ') --mptest --mptest-pause $Pause --mptest-hold $Hold"
"$($extraArgs -join ' ') --mptest --mptest-frames --mptest-pause $Pause --mptest-hold $Hold"
$p = Start-Process -FilePath $Godot -ArgumentList $a -PassThru
# Touching .Handle caches it, which is what makes .ExitCode readable later.
$null = $p.Handle
@@ -62,6 +68,13 @@ foreach ($pair in @(@("SERVER", $serverLog), @("CLIENT", $clientLog))) {
if (Test-Path $pair[1]) { Get-Content $pair[1] -Encoding UTF8 } else { Write-Host "(no log written)" }
}
if (-not $NoGif) {
Write-Host ""
Write-Host "======================== GIF ========================"
& powershell -ExecutionPolicy Bypass -File (Join-Path $PSScriptRoot "make_gif.ps1") `
-HalfWidth $HalfWidth -SecondsPerStep $SecondsPerStep
}
$server.WaitForExit(2000) | Out-Null
$code = if ($server.HasExited) { $server.ExitCode } else { 1 }
Write-Host ""