fix(@projects/@magic-civilization): 🐛 handle Weston/llvmpipe framebuffer staleness

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Natalie 2026-05-07 18:14:04 -07:00
parent 710f08d961
commit 155e7ea5f4
3 changed files with 69 additions and 34 deletions

View file

@ -133,10 +133,18 @@ func _ready() -> void:
)
return
elif _scene == "replay_viewer_proof":
# Do NOT use change_scene_to_file — under Weston/llvmpipe the composited
# framebuffer is not flushed after a scene swap, causing get_texture to
# return the stale main-menu frame. Instantiate directly as a child of
# root so no scene transition occurs and the proof scene owns its own
# capture + quit.
await get_tree().create_timer(0.5).timeout
get_tree().change_scene_to_file(
"res://engine/scenes/tests/proof_replay_viewer.tscn"
)
var packed: PackedScene = load("res://engine/scenes/tests/proof_replay_viewer.tscn")
if packed == null:
push_error("ScreenCapture: cannot load proof_replay_viewer.tscn")
get_tree().quit(1)
return
get_tree().root.add_child(packed.instantiate())
return
elif _scene == "world_map":
await get_tree().create_timer(0.5).timeout

View file

@ -1,12 +1,17 @@
extends Control
## Proof scene for p2-46 bullet 8 — headless replay-viewer exercise.
##
## Entered via capture_screenshot.gd (add_child path, no change_scene_to_file)
## so the framebuffer is never staled by a scene swap under Weston/llvmpipe.
##
## 1. Writes a synthetic 50-turn fixture via GdReplayArchive.write_fixture.
## 2. Instantiates the replay_viewer scene and wires it to the fixture UUID.
## 3. Drives goto_turn(25) → 3 s hold → goto_turn(50) → 3 s hold → screenshot.
## 3. Drives _goto_turn(25) → 3 s hold → screenshot (p2-46-replay-viewer-T25).
## 4. Drives _goto_turn(50) → 3 s hold → screenshot (p2-46-replay-viewer-T50).
## 5. Quits.
##
## Scene key: "replay_viewer_proof" (capture_screenshot.gd dispatcher).
## Run via: tools/screenshot.sh proof_replay_viewer replay_viewer_proof
## Run via: tools/screenshot.sh p2-46-replay-viewer-T25 replay_viewer_proof
const OUTPUT_DIR: String = "user://screenshots"
const REPLAY_VIEWER_SCENE: String = "res://engine/scenes/menus/replay_viewer.tscn"
@ -16,7 +21,6 @@ var _viewer_scene: Node = null
var _player: GdReplayPlayer = null
var _uuid: String = ""
var _archive_root: String = ""
var _captured: bool = false
var _turn_label: Label = null
var _status_label: Label = null
@ -26,7 +30,7 @@ func _ready() -> void:
get_viewport().size = Vector2i(1920, 1080)
DisplayServer.window_set_size(Vector2i(1920, 1080))
# Dark background panel so the viewer is visible in the screenshot.
# Opaque dark background — covers any scene rendered underneath us.
var bg: ColorRect = ColorRect.new()
bg.set_anchors_preset(Control.PRESET_FULL_RECT)
bg.color = Color(0.04, 0.04, 0.08, 1.0)
@ -46,6 +50,9 @@ func _ready() -> void:
_turn_label.text = ""
add_child(_turn_label)
# Yield several frames so our ColorRect draws over any prior scene content.
await get_tree().process_frame
await get_tree().process_frame
await get_tree().process_frame
_archive_root = ProjectSettings.globalize_path("user://archive")
@ -54,52 +61,60 @@ func _ready() -> void:
_uuid = _archive.write_fixture(_archive_root, "age-of-dwarves", "Cycle-X Proof")
if _uuid.is_empty():
push_error("proof_replay_viewer: write_fixture failed")
_capture_and_quit()
_capture("p2-46-replay-viewer-T25-error")
get_tree().quit(1)
return
_status_label.text = "p2-46 Replay Viewer Proof — fixture UUID: %s" % _uuid
# Load the replay viewer scene as a sub-scene.
var packed: PackedScene = load(REPLAY_VIEWER_SCENE)
if packed == null:
push_error("proof_replay_viewer: cannot load replay_viewer.tscn")
_capture_and_quit()
_capture("p2-46-replay-viewer-T25-error")
get_tree().quit(1)
return
_viewer_scene = packed.instantiate()
# Set game_id before _ready fires so the viewer loads history automatically.
_viewer_scene.set("game_id", _uuid)
_viewer_scene.set("pack", "age-of-dwarves")
# Anchor the sub-scene below the status labels.
if _viewer_scene is Control:
_viewer_scene.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT)
_viewer_scene.set_anchor(SIDE_TOP, 0.09)
add_child(_viewer_scene)
# Allow _ready on the viewer to complete (history loaded, scrubber configured).
# Allow viewer _ready to complete (history loaded, scrubber configured).
await get_tree().process_frame
await get_tree().process_frame
# Obtain the player separately to drive goto_turn for the status overlay.
# Separate player for status overlay — viewer owns its own internally.
_player = GdReplayPlayer.new()
var loaded: bool = _player.load_history(_archive_root, "age-of-dwarves", _uuid)
if not loaded:
push_error("proof_replay_viewer: GdReplayPlayer.load_history failed for %s" % _uuid)
# Drive to turn 25 and hold for screenshot opportunity.
# Turn 25 — drive, hold 3 s, capture.
_drive_to_turn(25)
await get_tree().create_timer(3.0).timeout
# Multiple process frames before capture: frame_post_draw does not fire
# under Weston/llvmpipe, so we pump the render loop manually.
await get_tree().process_frame
await get_tree().process_frame
await get_tree().process_frame
_capture("p2-46-replay-viewer-T25")
# Drive to turn 50 and hold.
# Turn 50 — drive, hold 3 s, capture, quit.
_drive_to_turn(50)
await get_tree().create_timer(3.0).timeout
await get_tree().process_frame
await get_tree().process_frame
await get_tree().process_frame
_capture("p2-46-replay-viewer-T50")
_capture_and_quit()
get_tree().quit()
func _drive_to_turn(t: int) -> void:
## Push the scrubber on the embedded viewer and update our overlay.
if _viewer_scene != null and _viewer_scene.has_method("_goto_turn"):
_viewer_scene._goto_turn(t)
@ -117,26 +132,17 @@ func _drive_to_turn(t: int) -> void:
)
func _capture_and_quit() -> void:
if _captured:
return
_captured = true
func _capture(name: String) -> void:
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUTPUT_DIR))
var image: Image = get_viewport().get_texture().get_image()
if image == null:
push_error("proof_replay_viewer: viewport image unavailable")
get_tree().quit(1)
push_error("proof_replay_viewer: viewport image unavailable for %s" % name)
return
var timestamp: String = (
Time.get_datetime_string_from_system()
.replace(":", "-")
.replace("T", "_")
var abs_path: String = ProjectSettings.globalize_path(
"%s/%s.png" % [OUTPUT_DIR, name]
)
var rel_path: String = "%s/proof_replay_viewer_%s.png" % [OUTPUT_DIR, timestamp]
var abs_path: String = ProjectSettings.globalize_path(rel_path)
var err: Error = image.save_png(abs_path)
if err == OK:
@ -145,6 +151,4 @@ func _capture_and_quit() -> void:
image.get_width(), image.get_height(), abs_path
])
else:
push_error("proof_replay_viewer: save failed: %s" % error_string(err))
get_tree().quit()
push_error("proof_replay_viewer: save failed (%s): %s" % [name, error_string(err)])

View file

@ -35,6 +35,29 @@ timeout 60 flatpak run --user \
--rendering-method gl_compatibility \
--fixed-fps 10 2>&1 | tee /tmp/godot_screenshot_log.txt || true
# replay_viewer_proof writes multiple named files (no timestamp suffix).
# Collect them separately from the standard single-file path.
if [ "$SCENE" = "replay_viewer_proof" ]; then
mapfile -t PROOF_PATHS < <(ls -t "$GODOT_USERDATA"/p2-46-replay-viewer-*.png 2>/dev/null)
if [ "${#PROOF_PATHS[@]}" -eq 0 ]; then
echo "ERROR: No p2-46-replay-viewer-*.png found in $GODOT_USERDATA"
grep -E "Screenshot|ERROR|SCRIPT" /tmp/godot_screenshot_log.txt | head -10
exit 1
fi
if ssh -o ConnectTimeout=3 plum "echo ok" >/dev/null 2>&1; then
for p in "${PROOF_PATHS[@]}"; do
BASENAME="$(basename "$p")"
scp "$p" "$PLUM_DESKTOP/${BASENAME}" && \
echo "Sent to plum: ~/Desktop/${BASENAME}"
done
else
echo "WARNING: plum not reachable, screenshots at:"
for p in "${PROOF_PATHS[@]}"; do echo " $p"; done
fi
echo "=== Done ==="
exit 0
fi
SCREENSHOT_PATH=$(ls -t "$GODOT_USERDATA"/${NAME}_*.png 2>/dev/null | head -1)
if [ -z "$SCREENSHOT_PATH" ] || [ ! -f "$SCREENSHOT_PATH" ]; then