jiff
This commit is contained in:
Binary file not shown.
@@ -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
@@ -44,8 +44,9 @@ const SETUP_TIMEOUT_SEC := 30.0
|
|||||||
const SNAP_TOLERANCE := 0.12
|
const SNAP_TOLERANCE := 0.12
|
||||||
## Cooking and washing both take ~3s of station time; allow generously for it.
|
## Cooking and washing both take ~3s of station time; allow generously for it.
|
||||||
const STATION_WORK_TIMEOUT := 20.0
|
const STATION_WORK_TIMEOUT := 20.0
|
||||||
## Lines kept in the on-screen overlay.
|
## Lines kept in the on-screen overlay. Enough to show a whole step, without the
|
||||||
const OVERLAY_LINES := 16
|
## 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
|
## 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).
|
## 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: Label
|
||||||
var _overlay_lines: Array[String] = []
|
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:
|
func _ready() -> void:
|
||||||
var args := OS.get_cmdline_user_args()
|
var args := OS.get_cmdline_user_args()
|
||||||
_auto_mode = "--mptest" in args
|
_auto_mode = "--mptest" in args
|
||||||
|
_frames_enabled = "--mptest-frames" in args
|
||||||
_step_pause = _arg_value(args, "--mptest-pause", 0.0)
|
_step_pause = _arg_value(args, "--mptest-pause", 0.0)
|
||||||
_end_hold = _arg_value(args, "--mptest-hold", 0.0)
|
_end_hold = _arg_value(args, "--mptest-hold", 0.0)
|
||||||
_world = get_parent()
|
_world = get_parent()
|
||||||
@@ -96,6 +103,7 @@ func _ready() -> void:
|
|||||||
_finish(false)
|
_finish(false)
|
||||||
return
|
return
|
||||||
_build_debug_camera()
|
_build_debug_camera()
|
||||||
|
_setup_frames()
|
||||||
if not _auto_mode:
|
if not _auto_mode:
|
||||||
_log("MANUAL MODE - keys act on this window's peer:")
|
_log("MANUAL MODE - keys act on this window's peer:")
|
||||||
_log(" " + HELP)
|
_log(" " + HELP)
|
||||||
@@ -173,6 +181,8 @@ func _run_server() -> void:
|
|||||||
return
|
return
|
||||||
await _wait_frames(60)
|
await _wait_frames(60)
|
||||||
_banner("starting sequence")
|
_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.
|
# 1-2. Both peers can pick the plate up and put it down.
|
||||||
await _step("client", "client_grab_plate", "grab", ["Plate"])
|
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)
|
res = await _remote(step, args)
|
||||||
_record(label, actor, res)
|
_record(label, actor, res)
|
||||||
await _audit_sync(label)
|
await _audit_sync(label)
|
||||||
|
await _capture_step_frame(label)
|
||||||
await _pause()
|
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, "server", await _run_local_step(step, args))
|
||||||
_record(label, "client", await _remote(step, args))
|
_record(label, "client", await _remote(step, args))
|
||||||
await _audit_sync(label)
|
await _audit_sync(label)
|
||||||
|
await _capture_step_frame(label)
|
||||||
await _pause()
|
await _pause()
|
||||||
|
|
||||||
|
|
||||||
@@ -343,6 +355,8 @@ func _report() -> void:
|
|||||||
for r in _results:
|
for r in _results:
|
||||||
if not r["ok"]:
|
if not r["ok"]:
|
||||||
_log("FAILURE %s [%s]: %s" % [r["step"], r["side"], r["detail"]])
|
_log("FAILURE %s [%s]: %s" % [r["step"], r["side"], r["detail"]])
|
||||||
|
await _capture_step_frame("final")
|
||||||
|
_write_frame_index()
|
||||||
if not _auto_mode:
|
if not _auto_mode:
|
||||||
return
|
return
|
||||||
_quit_client.rpc_id(_client_id)
|
_quit_client.rpc_id(_client_id)
|
||||||
@@ -374,6 +388,7 @@ func _result(step: String, ok: bool, detail: String) -> void:
|
|||||||
|
|
||||||
@rpc("authority", "reliable")
|
@rpc("authority", "reliable")
|
||||||
func _quit_client() -> void:
|
func _quit_client() -> void:
|
||||||
|
_write_frame_index()
|
||||||
if _end_hold > 0.0:
|
if _end_hold > 0.0:
|
||||||
await get_tree().create_timer(_end_hold).timeout
|
await get_tree().create_timer(_end_hold).timeout
|
||||||
_finish(true)
|
_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)]}
|
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 -------------------------------------------------
|
# --- Cross-peer sync audit -------------------------------------------------
|
||||||
#
|
#
|
||||||
# Runs after every step. Targeted per-step assertions only look at the one thing
|
# 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)
|
var res := await _run_local_step(step, args)
|
||||||
_running = false
|
_running = false
|
||||||
_log("%s %s :: %s" % ["PASS" if res.get("ok") else "FAIL", what, res.get("detail", "")])
|
_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 ---------------------------------------------------------------
|
# --- Helpers ---------------------------------------------------------------
|
||||||
@@ -1031,7 +1137,9 @@ func _build_overlay() -> void:
|
|||||||
panel.set_anchors_preset(Control.PRESET_TOP_WIDE)
|
panel.set_anchors_preset(Control.PRESET_TOP_WIDE)
|
||||||
panel.modulate = Color(1, 1, 1, 0.85)
|
panel.modulate = Color(1, 1, 1, 0.85)
|
||||||
_overlay = Label.new()
|
_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.autowrap_mode = TextServer.AUTOWRAP_OFF
|
||||||
_overlay.clip_text = true
|
_overlay.clip_text = true
|
||||||
panel.add_child(_overlay)
|
panel.add_child(_overlay)
|
||||||
|
|||||||
@@ -13,7 +13,13 @@ param(
|
|||||||
# Seconds to pause between steps, and to hold the final state on screen.
|
# Seconds to pause between steps, and to hold the final state on screen.
|
||||||
[double]$Pause = 1.5,
|
[double]$Pause = 1.5,
|
||||||
[double]$Hold = 20,
|
[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"
|
$ErrorActionPreference = "Stop"
|
||||||
@@ -30,7 +36,7 @@ function Start-Instance($extraArgs) {
|
|||||||
# --xr-mode off keeps it on the desktop (SteamVR's OpenXR runtime otherwise
|
# --xr-mode off keeps it on the desktop (SteamVR's OpenXR runtime otherwise
|
||||||
# takes over, and two instances can't share a headset anyway).
|
# takes over, and two instances can't share a headset anyway).
|
||||||
$a = "--xr-mode off --resolution 900x600 --path `"$proj`" $scene -- " +
|
$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
|
$p = Start-Process -FilePath $Godot -ArgumentList $a -PassThru
|
||||||
# Touching .Handle caches it, which is what makes .ExitCode readable later.
|
# Touching .Handle caches it, which is what makes .ExitCode readable later.
|
||||||
$null = $p.Handle
|
$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 (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
|
$server.WaitForExit(2000) | Out-Null
|
||||||
$code = if ($server.HasExited) { $server.ExitCode } else { 1 }
|
$code = if ($server.HasExited) { $server.ExitCode } else { 1 }
|
||||||
Write-Host ""
|
Write-Host ""
|
||||||
|
|||||||
Reference in New Issue
Block a user