ci(gl-qualification): capture waits for core frames; unbuffered shaders_gl; macOS 640x480 + capture diagnostics

Run 41727418224 (3eef3305) read per leg:
- Windows x64: the preset loop passed under the tracked exit hang (494 built, 501 launches killed 5 s after
  their screenshot, #1167) but the shaders_gl allowance failed: the test's stdout verdict lines were lost when
  the hung process was killed (a pipe is fully buffered; only the stderr [shader] lines survived).
  vj_shader_tests writes stdout unbuffered from main() on.
- Windows arm64: 493 built, CuNNy-8x32-nvl-2x-rgb timed out at 150 s on the 128-bit llvmpipe. The Windows
  per-launch timeout is 240 s like the other legs.
- macOS: shaders_gl passes at 384x288 (10 min). In the seeded 480x360 window every capture, --shader off
  included, came back a uniform white picture at 1.9 s a launch; not the game (yarc is black at core frame
  1-2 and shows its picture from frame 10 on), not the theme. The leg seeds 640x480 (fits the hosted display,
  keeps crt-royale above 341 px) and a "Capture diagnostics" step launches `off` once per window
  configuration (default, 480x360, +frame 120, +emu frames, no viewports, no vsync, 480x362, 640x480,
  960x760) and prints each picture's statistics to the step summary, so the artifact names the variable.

App: --screenshot-emu-frames M (dev): the capture also waits until the core has shown M frames, with a line
and a capture anyway 600 UI frames later if it never does; gl_qualify.py --emu-frames passes it, every leg
uses 60. VideoOut::emuFrames() exposes the counter.

Co-Authored-By: Claude Fable 5.1 <[email protected]>
This commit is contained in:
Joseph MattielloandClaude Fable 5.1 committed 2026-11-03 21:18:59 -05:00
1 parent 3eef3305e2
commit 6afaae1aa8
9 files changed
+102 -22

No files matched your search

+60 -9
View File
@@ -84,9 +84,10 @@ env:
REFRESH_GOLDENS: ${{ inputs.refresh_goldens && 'true' || 'false' }}
# The picture the chains filter: the committed public ROM the smoke tests use.
CONTENT_ROM: test/roms/yarc.j64
# UI frames before the capture. The emulation thread runs the core meanwhile, so the game
# has drawn long before frame 20 even on a slow software renderer.
# UI frames before the capture, and core frames the capture also waits for (yarc shows its picture from
# core frame 10 on), so the screenshot is the game's picture however fast or crowded the host is.
SHOT_FRAME: '20'
SHOT_EMU_FRAMES: '60'
QUALIFY: app/test/gl-qualification/gl_qualify.py
LISTS: app/test/gl-qualification
@@ -152,7 +153,7 @@ jobs:
export LIBGL_ALWAYS_SOFTWARE=1 SDL_VIDEO_DRIVER=x11
xvfb-run -a -s '-screen 0 1600x1200x24 +extension GLX' \
python3 -I "$QUALIFY" --os linux --exe stage/bin/virtualjaguar --shaders stage/bin/shaders \
--out glq --content "$CONTENT_ROM" --frame "$SHOT_FRAME" --jobs 2 --timeout 240 \
--out glq --content "$CONTENT_ROM" --frame "$SHOT_FRAME" --emu-frames "$SHOT_EMU_FRAMES" --jobs 2 --timeout 240 \
--expect-renderer 'llvmpipe' \
--refused-allowlist "$LISTS/refused-linux.txt" --identical-ok "$LISTS/identical-to-plain.txt" --known-broken "$LISTS/known-broken.txt" \
--goldens app/test/golden/gl/linux --preset-set "$PRESET_SET" ${refresh[@]+"${refresh[@]}"}
@@ -415,7 +416,7 @@ jobs:
fi
hang=(); [ -z "${{ steps.known.outputs.exit_hang }}" ] || hang=(--known-exit-hang "${{ steps.known.outputs.exit_hang }}")
python -I "$QUALIFY" --os windows --exe stage/bin/virtualjaguar.exe --shaders stage/bin/shaders \
--out glq --content "$CONTENT_ROM" --frame "$SHOT_FRAME" --jobs 1 --timeout 150 ${hang[@]+"${hang[@]}"} \
--out glq --content "$CONTENT_ROM" --frame "$SHOT_FRAME" --emu-frames "$SHOT_EMU_FRAMES" --jobs 1 --timeout 240 ${hang[@]+"${hang[@]}"} \
--expect-renderer 'llvmpipe|D3D12' ${expect_log[@]+"${expect_log[@]}"} \
--refused-allowlist "$LISTS/refused-windows.txt" --identical-ok "$LISTS/identical-to-plain.txt" --known-broken "$LISTS/known-broken.txt" \
--goldens app/test/golden/gl/windows --preset-set "$PRESET_SET" ${refresh[@]+"${refresh[@]}"}
@@ -436,8 +437,9 @@ jobs:
timeout 600 build/vj_shader_tests.exe gl 2>&1 | tee glq/shaders_gl.txt
code=${PIPESTATUS[0]}
set -e
# The tracked exit hang: the test's verdict line comes after SDL_Quit, which never
# returns there, so the evidence is "nothing failed or skipped and the last check ran".
# The tracked exit hang: the test's final verdict line comes after SDL_Quit, which never
# returns there, so the evidence is "nothing failed or skipped and the last check ran"
# (the test writes stdout unbuffered, so a killed run still has every line before it).
if [ "$code" -eq 124 ] && [ -n "${{ steps.known.outputs.exit_hang }}" ] \
&& ! grep -q 'shader_tests: FAIL\|shader_tests gl: SKIP' glq/shaders_gl.txt \
&& grep -q '^broken shader -> chain build failed' glq/shaders_gl.txt; then
@@ -512,15 +514,64 @@ jobs:
perl -e 'alarm shift; exec @ARGV' 120 build/vj_gl_context_integration 2>&1 | tee glq/gl-context-integration.txt
test "${PIPESTATUS[0]}" -eq 0
# Run 41727418224: in a seeded 480x360 window every capture (--shader off included) came back a uniform
# white, 1.9 s a launch, while 960x642 windows (run 3) and the other legs' 960x760 windows show the game.
# The same `off` capture under several window configurations, side by side, so the artifact says which
# of the size, the seeded config.ini, ImGui's extra OS windows or vsync is the variable.
- name: Capture diagnostics (one `off` launch per window configuration)
run: |
set -u
D="$PWD/glq/capture-diag"; mkdir -p "$D"
system_profiler SPDisplaysDataType 2>/dev/null | grep -E 'Resolution|UI Looks like' | sed 's/^ *//' | tee "$D/display.txt" || true
run_one() { # name, extra app args..., with CFG holding the config.ini text ("" = none)
local name=$1; shift
local data="$D/$name"; mkdir -p "$data"
[ -n "$CFG" ] && printf '%s\n' "$CFG" > "$data/config.ini"
local t0=$SECONDS
perl -e 'alarm shift; exec @ARGV' 90 stage/bin/virtualjaguar --data-dir "$data" --shader off \
--screenshot "$D/$name.png" --screenshot-frame "$SHOT_FRAME" --low-dpi "$@" "$CONTENT_ROM" \
> "$D/$name.log" 2>&1
local rc=$? dt=$((SECONDS - t0))
python3 -I - "$D/$name.png" "$name" "$rc" "$dt" <<'PY' | tee -a "$D/summary.txt"
import sys, os
sys.path.insert(0, os.path.join(os.environ["GITHUB_WORKSPACE"], "app", "test", "gl-qualification"))
png, name, rc, dt = sys.argv[1:5]
try:
import gl_qualify as g
p = g.Png.read(png)
seen = {}
for row in p.rows:
for x in range(p.width):
c = tuple(row[x * p.channels:x * p.channels + 3]); seen[c] = seen.get(c, 0) + 1
top = sorted(seen.items(), key=lambda kv: -kv[1])[:3]
lit, mean = p.rgb_stats()
print("%-22s exit %-3s %3ss %dx%d lit %.3f mean %6.1f colours %d top %s" % (name, rc, dt, p.width, p.height, lit, mean, len(seen), top))
except Exception as e:
print("%-22s exit %-3s %3ss no picture (%s)" % (name, rc, dt, e))
PY
}
CFG="" run_one default-960x760
CFG=$'window.w=480\nwindow.h=360' run_one seeded-480x360
CFG=$'window.w=480\nwindow.h=360' run_one seeded-480x360-f120 --screenshot-frame 120
CFG=$'window.w=480\nwindow.h=360' run_one seeded-480x360-emu60 --screenshot-emu-frames 60
CFG=$'window.w=480\nwindow.h=360\nui.viewports=0' run_one seeded-480x360-novp
CFG=$'window.w=480\nwindow.h=360\nvideo.vsync=0' run_one seeded-480x360-novsync
CFG=$'window.w=480\nwindow.h=362' run_one seeded-480x362
CFG=$'window.w=640\nwindow.h=480' run_one seeded-640x480
CFG=$'window.w=960\nwindow.h=760' run_one seeded-960x760
echo; echo "## macOS capture diagnostics" >> "$GITHUB_STEP_SUMMARY"
{ echo '```'; cat "$D/display.txt" "$D/summary.txt"; echo '```'; } >> "$GITHUB_STEP_SUMMARY"
rm -rf "${D:?}"/*/ # the data folders (lock files, saves) are noise in the artifact
- name: Render every bundled preset
run: |
set -eu
# (${arr[@]+"${arr[@]}"}: an empty array under set -u is an error on macOS's bash 3.2)
refresh=(); [ "$REFRESH_GOLDENS" = true ] && refresh=(--refresh-goldens)
# Apple's software renderer: ~75 s a launch at 960x760 (run 41571727219 managed 205 of 500 in
# 150 min at 2 jobs), so this leg runs 3 launches at once in a 480x360 window.
# The hosted display is too small for the default 960x760 window (it came back 960x642 and ~45 s a
# launch; run 41571727219 managed 205 of 500 in 150 min at 2 jobs): 640x480 (small-window.ini), 3 at once.
python3 -I "$QUALIFY" --os macos --exe stage/bin/virtualjaguar --shaders stage/bin/shaders \
--out glq --content "$CONTENT_ROM" --frame "$SHOT_FRAME" --jobs 3 --timeout 240 --low-dpi \
--out glq --content "$CONTENT_ROM" --frame "$SHOT_FRAME" --emu-frames "$SHOT_EMU_FRAMES" --jobs 3 --timeout 240 --low-dpi \
--seed-config "$LISTS/small-window.ini" --expect-renderer 'Apple' \
--refused-allowlist "$LISTS/refused-macos.txt" --identical-ok "$LISTS/identical-to-plain.txt" --known-broken "$LISTS/known-broken.txt" \
--goldens app/test/golden/gl/macos --preset-set "$PRESET_SET" ${refresh[@]+"${refresh[@]}"}
+13 -3
View File
@@ -2808,9 +2808,19 @@ int App::runInteractive(const CliArgs& a) {
if (perf.on && perf.gpu) Perf::finish();
perf.lap(Perf::Ui);
tvScriptFrameEnd(); // the script's `shot`: this frame's TV drawing is in the buffer, not yet swapped
if (!a.screenshot.empty() && ++uiFrames == a.screenshotFrame) {
saveWindowScreenshot(a.screenshot);
quit = true;
// --screenshot: after N UI frames and (--screenshot-emu-frames M) once the core has shown M frames, so the
// capture is the game's picture whatever the host's speed. A core that never gets there (no content, a
// halt) is captured 600 UI frames later anyway, with a line saying so.
if (!a.screenshot.empty() && ++uiFrames >= a.screenshotFrame) {
const bool emuReady = video.emuFrames() >= a.screenshotEmuFrames;
const bool gaveUp = !emuReady && uiFrames >= a.screenshotFrame + 600;
if (emuReady || gaveUp) {
if (gaveUp)
std::fprintf(stderr, "[shot] the core showed %llu frame(s) in %u UI frames, wanted %u: capturing anyway\n",
(unsigned long long)video.emuFrames(), uiFrames, a.screenshotEmuFrames);
saveWindowScreenshot(a.screenshot);
quit = true;
}
}
presentViewports();
guardSwap(); // before the timestamps: the latch must not see it as swap time
+1
View File
@@ -74,6 +74,7 @@ struct CliArgs {
bool verbose = false;
std::string screenshot; // dev: save the window's pixels to this PNG after --screenshot-frame frames, then exit
unsigned screenshotFrame = 120;
unsigned screenshotEmuFrames = 0; // dev: ... and not before the core has shown this many frames (--screenshot-emu-frames; 0 = UI frames only)
std::string showWindow; // dev: open a window at start: settings[-video|-input|-audio|-storage|-interface][:FILTER], states, about, cheats, library, login, achievements, keypad
std::string theme; // --theme system|dark|light: the colour theme for this run (not saved); "" = the saved choice
std::string shader; // --shader NAME|FILE|off: the shader preset for this run (not saved); "" = the setting (#1154)
+3 -1
View File
@@ -52,7 +52,8 @@ static void usage(const char* argv0) {
"dev: --perf-frames N [--perf-gpu] [--perf-pause] [--perf-halt] [--perf-no-reuse] [--perf-shader-out WxH] [--perf-input]\n"
" print where a UI frame's time goes over N frames (and the shader chain's GPU cost), then exit\n"
"dev: --dump-native-menu print and check the real macOS menu bar and Dock menu, then exit (77 = none on this OS)\n"
"dev: --screenshot PNG [--screenshot-frame N] [--ui-scale X] [--low-dpi] [--pixel-density N]\n"
"dev: --screenshot PNG [--screenshot-frame N] [--screenshot-emu-frames M] [--ui-scale X] [--low-dpi] [--pixel-density N]\n"
" (the capture waits for N UI frames and, with M, for the core to have shown M frames: a picture, not the boot)\n"
" [--font retro|system|mono[:SIZE]|custom:FILE] [--font-then SPEC] (this run only; --font-then switches live after 60 frames)\n"
" [--show-window settings[-video|-input|-audio|-bios|-storage|-interface]|states|about|cheats|library|login|achievements|keypad|netplay|controls|theme-editor]\n"
" [--show-window memory|bitmap|palette|scanner|cpu|objects|blitter|textures|scope|tools] (Tools windows; needs a game; tools = all)\n"
@@ -274,6 +275,7 @@ int main(int argc, char** argv) {
}
else if (!std::strcmp(s, "--screenshot")) a.screenshot = need(s);
else if (!std::strcmp(s, "--screenshot-frame")) a.screenshotFrame = (unsigned)std::strtoul(need(s), nullptr, 10);
else if (!std::strcmp(s, "--screenshot-emu-frames")) a.screenshotEmuFrames = (unsigned)std::strtoul(need(s), nullptr, 10);
else if (!std::strcmp(s, "--show-window")) a.showWindow = need(s);
else if (!std::strcmp(s, "--ui-scale")) a.uiScale = (float)std::strtod(need(s), nullptr);
else if (!std::strcmp(s, "--low-dpi")) a.lowDpi = true;
+1
View File
@@ -58,6 +58,7 @@ public:
// Small helper textures for save-state thumbnails (RGBA8, tight).
static unsigned createTextureRGBA(const void* pixels, int w, int h);
static void destroyTexture(unsigned tex);
uint64_t emuFrames() const { return emuFrames_; } // distinct core frames uploaded so far
private:
unsigned tex_ = 0;
+5
View File
@@ -217,6 +217,8 @@ def launch(args, label, shader_arg, out_dir):
log = logs / (safe_name(label) + ".log")
cmd = [str(args.exe), "--data-dir", str(data), "--shader", shader_arg,
"--screenshot", str(png), "--screenshot-frame", str(args.frame)]
if args.emu_frames:
cmd += ["--screenshot-emu-frames", str(args.emu_frames)]
if args.low_dpi:
cmd.append("--low-dpi")
cmd += args.extra_arg
@@ -372,6 +374,9 @@ def main():
ap.add_argument("--os", required=True, choices=["linux", "windows", "macos"], help="leg name, for the summary and the golden layout")
ap.add_argument("--content", type=pathlib.Path, default=None, help="a ROM to run (the picture the chain filters); default: no content")
ap.add_argument("--frame", type=int, default=20, help="--screenshot-frame (UI frames before the capture)")
ap.add_argument("--emu-frames", type=int, default=0,
help="--screenshot-emu-frames: the capture also waits until the core has shown this many frames "
"(the picture is the game's, however fast or crowded the host)")
ap.add_argument("--timeout", type=int, default=180, help="seconds per launch")
ap.add_argument("--jobs", type=int, default=1, help="launches at once (each has its own --data-dir)")
ap.add_argument("--known-exit-hang", default="", help="this leg's app is known not to exit (cite the issue): a launch with a complete screenshot is killed after --hang-grace seconds and passes with a note")
+9 -6
View File
@@ -1,6 +1,9 @@
# Seeded as config.ini into every launch's data folder by gl_qualify.py --seed-config on legs
# whose software renderer makes 960x760 too slow (hosted macOS, Apple Software Renderer: ~75 s a
# launch; 205 of 500 presets in the 150-minute budget). 480x360 keeps crt-royale above the 341 px
# its mask passes need. Screenshots and goldens of that leg are this size.
window.w=480
window.h=360
# Seeded as config.ini into every launch's data folder by gl_qualify.py --seed-config on legs whose
# display or software renderer makes the default 960x760 window a bad fit (hosted macOS: the display is
# too small for it, the window came back clamped to 960x642 and ~45 s a launch; run 41571727219 managed
# 205 of 500 presets in 150 min). 640x480 fits that display, keeps crt-royale above the 341 px its mask
# passes need, and is not the 4:3 480x360 of run 41727418224, whose every capture came back a uniform
# white (the "Capture diagnostics" step of that leg tries the window sizes side by side). Screenshots
# and goldens of that leg are this size.
window.w=640
window.h=480
+3
View File
@@ -885,6 +885,9 @@ static int runProbe(const char* dir) {
}
int main(int argc, char** argv) {
// Unbuffered: a run that is killed (the x64 bundled Mesa never returns from SDL_Quit, #1167) must still have
// every verdict line in its log; the stderr [shader] lines already are.
std::setvbuf(stdout, nullptr, _IONBF, 0);
std::string mode = argc > 1 ? argv[1] : "unit";
if (mode == "probe") return runProbe(argc > 2 ? argv[2] : nullptr);
if (mode == "unit") return runUnit();
+7 -3
View File
@@ -387,7 +387,9 @@ it is `-E shaders_gl`; the step is `continue-on-error` and a "shaders_gl verdict
job, so the screenshots exist even when the test is red, and a test that has to be killed cannot
wedge the window server before the loop). The loop:
`app/test/gl-qualification/gl_qualify.py` launches `stage/bin/virtualjaguar --shader <preset>
--screenshot <png> --screenshot-frame 20 test/roms/yarc.j64` for `--shader off` and every preset.
--screenshot <png> --screenshot-frame 20 --screenshot-emu-frames 60 test/roms/yarc.j64` for `--shader off`
and every preset (the capture waits for 20 UI frames *and* 60 core frames, so it is the game's picture
however fast or crowded the host is).
Per (leg, preset) it asserts: exit 0 before the timeout; the `[gl] version=...; renderer=` line
matches the leg (`llvmpipe` / `llvmpipe|D3D12` after `automatically retrying with bundled Mesa
OpenGL` / `Apple`); the PNG decodes and is not black; a `[shader] <preset> built` line
@@ -397,8 +399,10 @@ summary says how many were not run), so a leg with no working runtime fails in m
`[shader] ... refused` line (macOS OpenGL 4.1 has no
`GL_ARB_arrays_of_arrays`), or a `... failed` line for a preset the leg's GL cannot compile
(packHalf2x16 on macOS), passes only for presets in `refused-<os>.txt`, so that set can shrink but
never grow. The macOS leg runs in a 480x360 window (`small-window.ini`, `--seed-config`): Apple's
software renderer takes ~75 s a launch at 960x760. Goldens are a second pass: `app/test/golden/gl/<os>/<preset>.png` is
never grow. The macOS leg runs in a 640x480 window (`small-window.ini`, `--seed-config`): the hosted
display is too small for 960x760 (the window came back 960x642, ~45 s a launch), and a seeded 480x360
window gave uniform white captures (run 41727418224); its "Capture diagnostics" step launches `off`
once per window configuration and prints each picture's statistics. Goldens are a second pass: `app/test/golden/gl/<os>/<preset>.png` is
compared and reported (not gated) when present; the `refresh_goldens` dispatch input collects
candidates into the artifact.