Files
MobileGL/tools/trace_replay
swung0x48andClaude Opus 5 ea9fc7fa71 [MG_Remote] (P6/cp): a real trace retraces across two processes, pixel-for-pixel
OpenRA.DirectGLES.SPAWN is green: ssim=1.000000, mismatchPixels=0, zero
Fatal{ lines, against a server in a process the client started by name. The
inproc SPLIT arm stays beside it as the control, so "did the transport break
it" and "did the phase break it" remain separately answerable.

Five defects stood between the handshake and that picture. Each one was
silent in its own way, and none of them looked like what it was.

1. THE DOORBELL COULD NOT WAKE ITSELF. SocketDoorbell's one-fd form sends on
   the end it polls, so a write is delivered to the PEER - correct for waking
   the other process, useless for waking a thread in this one. The server's
   control pump posts into the apply thread's mailbox and must ring the bell
   that thread parks on. Symptom: every eglInitialize crossed, landed in the
   mailbox, and timed out at 5 s. The two-fd form (park on [0], notify on [1])
   is restored here, and the bell handover goes from two descriptors to THREE
   - the client needs the same split for the same reason. Seven offers now.

2. InitCapabilities HAD NO WIRE KIND. CONTRACT-P6 §2 predicted this exactly
   ("the request has a live client caller and the codec refuses to encode
   it"). SurfaceOpKind gains 11, append-only; the decode range's upper bound
   moves with it, or the new tag reads as out-of-range and surfaces as
   Fatal{ProtocolCorruption} rather than the missing row it is. The op's name
   loses its InprocOnly suffix, which stopped being true here.

3. THE CHILD LOADED NO CONFIG AT ALL. mobilegl_server_main calls
   InitServerRoleForSpawn directly, and MG_ConfigLoader::Init is a step of
   MobileGL::Initialize - so every MG_Config value in the server was its
   static default. MOBILEGL_BACKEND_TYPE survives the launcher's scrub on
   purpose and was read by nobody: a DirectVulkan session would have brought
   up a DirectGLES server and then disagreed with its client about which
   backend the capability mask describes.

4. AND THEREFORE Transport READ AS Monolith, which CONTRACT-P6 §3.1 (D1)
   forbids in as many words. a6 measured the cost: 226 live lines test
   `Transport != Monolith` to select the SERVER arm, 148 in MG_Backend.
   Monolith flips all of them to frontend glue in the one process with no
   frontend. Observed precisely as predicted - UnitTexturesByHandle() answered
   false, Clear took the legacy arm that dereferences borrowed FRONTEND slot
   pointers, and the apply thread took a SIGSEGV inside SyncNeccessaryTextures
   on the first Clear record of the trace. It is pinned in-process rather than
   through the environment because the environment is where it must NOT be:
   LooksLikeAClientEnvironment exits 65 on a surviving MOBILEGL_TRANSPORT, and
   that check is anti-recursion catch (b). Dial, not Transport, is that axis.

5. THE LOG WAS CORRUPT, NOT MERELY INTERLEAVED. Two processes opened the same
   MOBILEGL_LOG_FILE_PATH with "w" and each carried its OWN file offset, so
   they overwrote each other's bytes - half-lines like `ote client: CapsMirror
   generation 1 adopted`, and the server's whole startup block vanishing under
   the client's later writes. This cost real diagnosis time twice, because a
   missing line read as a code path not taken. O_APPEND now; the client still
   truncates once so a fresh run is not a pile of old ones, and the server
   never does - it would delete the very startup lines a failed session needs.
   run_trace_case.cmake's Fatal{ census reads exactly this file, so it was
   also reading a lie.

The retrace runner's split assertions become transport-aware: one row per
transport it admits, and a transport with no row is a FATAL rather than a
fall-through, because the fall-through is the accident the block exists to
prevent. The spawn row carries a SECOND sentence and it is the stronger half
- ConfigLoader's marker proves the value RESOLVED, while `spawn ARMED - the
server role runs in pid N` is written only after the launch, the connect and
the handshake all succeeded, and carries a pid that is not this process's.
inproc resolves its own marker while running the server role on a thread
here; nothing on that path can write this one.

G14: one test name added (TheEleventhFramedOpIsInitCapabilitiesAndItCrosses-
TheWire), none removed. The ten-op table keeps its name and its scope rather
than being renamed to eleven.

Two unit failures predate this change and are environmental in this tree:
PipeCatalogue.FrontendNeverTakesAnApplierAddress (the frontend source root
resolves empty) and SessionHandshakeTest.TheAbiFingerprintChangesWhenAnyOf-
ItsInputsDoes (GIT_COMMIT_HASH_SHORT is empty here, which a6 already recorded
as red in this configuration).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 12:23:41 -04:00
..

MobileGL trace replay

This directory builds a Linux command line replay runner for apitrace files. It is an integration testing infrastructure of MobileGL.

The bundled fixtures cover:

  • OpenRA: sourced from GL4ES' apitrace corpus. OpenRA golden
  • minecraft-1.21.4-startup: captured from Minecraft 1.21.4's startup screen. Minecraft 1.21.4 startup golden
  • minecraft-1.21.4-main-menu: captured from Minecraft 1.21.4's main menu. Minecraft 1.21.4 main menu golden
  • minecraft-1.21.11-main-menu: captured from Minecraft 1.21.11's main menu on a Pixel 8 Pro through FCL MobileGL. Minecraft 1.21.11 main menu golden
  • minecraft-1.17-main-menu-854: captured from Minecraft 1.17's 854x480 main menu through FCL MobileGL capture. Minecraft 1.17 854x480 main menu golden
  • minecraft-1.21.4-in-world: captured from Minecraft 1.21.4 after entering a singleplayer world. Minecraft 1.21.4 in-world golden
  • minecraft-1.21.4-rd12-odinlite-in-world: captured on an Android device (FCL MobileGL Magma capture, 854x480) from vanilla Minecraft 1.21.4 at render distance 12, drifting down a river valley in a boat. Unlike the other in-world fixtures this one is a 251-frame window (gltrim -f 1094-1343) rather than a single frame, so it doubles as the campaign's benchmark scene: benchmark mode replays the whole window and the tail frames measure steady-state in-world frame time. The golden is still the final frame, so it works as an ordinary correctness case too. Minecraft 1.21.4 render distance 12 in-world golden
  • minecraft-1.21.4-fabric-sodium-in-world: captured from Minecraft 1.21.4 Fabric with Sodium after entering a singleplayer world with Fancy graphics. Minecraft 1.21.4 Fabric Sodium in-world golden
  • improved-transparency-minecraft-26.3: captured from the Minecraft 26.3 improved-transparency scene. Minecraft 26.3 improved-transparency golden
  • minecraft-1.21.4-fabric-common-mods-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, REI, Xaero's Minimap, Xaero's World Map, JourneyMap, and Modern UI, with shader packs disabled. Minecraft 1.21.4 Fabric common mods in-world golden
  • minecraft-1.21.4-fabric-common-mods-inventory: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, REI, Xaero's Minimap, Xaero's World Map, JourneyMap, and Modern UI with the creative inventory and REI item list open. Minecraft 1.21.4 Fabric common mods inventory golden
  • minecraft-1.21.4-fabric-rei-inventory: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and REI, with shader packs disabled and the creative inventory and REI item list open. Minecraft 1.21.4 Fabric REI inventory golden
  • minecraft-1.21.4-fabric-xaero-minimap-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and Xaero's Minimap after entering a singleplayer world with shader packs disabled. Minecraft 1.21.4 Fabric Xaero's Minimap in-world golden
  • minecraft-1.21.4-fabric-xaero-world-map-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and Xaero's World Map, with shader packs disabled and the world map screen open. Minecraft 1.21.4 Fabric Xaero's World Map in-world golden
  • minecraft-1.21.4-fabric-journeymap-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and JourneyMap after entering a singleplayer world with shader packs disabled. Minecraft 1.21.4 Fabric JourneyMap in-world golden
  • minecraft-1.21.4-fabric-modernui-inventory: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and Modern UI, with shader packs disabled and the creative inventory open. Minecraft 1.21.4 Fabric Modern UI inventory golden
  • minecraft-1.21.4-fabric-rei-inventory-normal-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and REI in a normal singleplayer world, with shader packs disabled and the creative inventory and REI item list open. Minecraft 1.21.4 Fabric REI inventory normal-world golden
  • minecraft-1.21.4-fabric-xaero-minimap-in-world-normal-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and Xaero's Minimap after entering a normal singleplayer world with shader packs disabled. Minecraft 1.21.4 Fabric Xaero's Minimap normal-world golden
  • minecraft-1.21.4-fabric-xaero-world-map-in-world-normal-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and Xaero's World Map in a normal singleplayer world, with shader packs disabled and the world map screen open. Minecraft 1.21.4 Fabric Xaero's World Map normal-world golden
  • minecraft-1.21.4-fabric-journeymap-in-world-normal-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and JourneyMap after entering a normal singleplayer world with shader packs disabled. Minecraft 1.21.4 Fabric JourneyMap normal-world golden
  • minecraft-1.21.4-fabric-modernui-inventory-normal-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and Modern UI in a normal singleplayer world, with shader packs disabled and the creative inventory open. Minecraft 1.21.4 Fabric Modern UI inventory normal-world golden
  • minecraft-1.21.4-fabric-iris-bsl-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and BSL Shaders after entering a singleplayer world. Minecraft 1.21.4 Fabric Iris BSL in-world golden
  • minecraft-1.21.4-fabric-iris-bsl-esc-menu-854: captured on an Android device (Mali-G77, FCL MobileGL capture) from Minecraft 1.21.4 Fabric with Sodium, Iris, and BSL Shaders, at the pause menu over a BSL-blurred world. The frame pins glyph rendering: every menu label, the menu title and the tutorial toast must be present. Regressions in the DirectGLES per-draw texture memo have made the whole text path disappear here while sprites kept rendering, so a failure that leaves the buttons but empties them is the signature to look for in the diff. Minecraft 1.21.4 Fabric Iris BSL ESC menu 854x480 golden
  • minecraft-1.21.4-fabric-iris-makeup-ultrafast-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and MakeUP UltraFast after entering a singleplayer world. Minecraft 1.21.4 Fabric Iris MakeUP UltraFast in-world golden
  • minecraft-1.21.4-fabric-iris-super-duper-vanilla-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and Super Duper Vanilla after entering a singleplayer world. Minecraft 1.21.4 Fabric Iris Super Duper Vanilla in-world golden
  • minecraft-1.21.4-fabric-iris-complementary-reimagined-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and Complementary Reimagined after entering a singleplayer world. Minecraft 1.21.4 Fabric Iris Complementary Reimagined in-world golden
  • minecraft-1.21.4-fabric-iris-complementary-unbound-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and Complementary Unbound after entering a singleplayer world. Minecraft 1.21.4 Fabric Iris Complementary Unbound in-world golden
  • minecraft-1.21.4-fabric-iris-mellow-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and Mellow after entering a singleplayer world. Minecraft 1.21.4 Fabric Iris Mellow in-world golden
  • minecraft-1.21.4-fabric-iris-nostalgia-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and Nostalgia after entering a singleplayer world. Minecraft 1.21.4 Fabric Iris Nostalgia in-world golden
  • minecraft-1.21.4-fabric-iris-bliss-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and Bliss after entering a singleplayer world. Minecraft 1.21.4 Fabric Iris Bliss in-world golden
  • minecraft-1.21.4-fabric-iris-chocapic-v6-lite-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and Chocapic V6 Lite after entering a singleplayer world. Minecraft 1.21.4 Fabric Iris Chocapic V6 Lite in-world golden
  • minecraft-1.21.4-fabric-iris-iterationt-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and iterationT after entering a singleplayer world. Minecraft 1.21.4 Fabric Iris iterationT in-world golden
  • minecraft-1.21.4-fabric-iris-iterationt-nodsa-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and iterationT after entering a singleplayer world, with Iris' DSA path disabled. Minecraft 1.21.4 Fabric Iris iterationT no-DSA in-world golden
  • minecraft-1.21.4-fabric-iris-iterationrp-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and iterationRP after entering a singleplayer world, framing the iterationRP name overlay over a lake with far-shore tree reflections. iterationRP's temporal auto-exposure makes a single-frame trim overexpose and drop the overlay, so the fixture is a prefix trace (all calls up to the target frame) that replays the temporal state. The pack also gates an NVIDIA-only shadow path (subgroupPartitionNV, GL_NV_shader_subgroup_partitioned) on the GL vendor string, so the capture reports a masked vendor and the trace carries the portable subgroupShuffleXor path that non-NVIDIA GPUs take. The trace archive and golden are not committed yet (the repository's Git LFS quota rejects new objects with GH009); the case stays registered and its fixture files are hydrated from the trace fixture mirror.
  • minecraft-1.21.4-fabric-iris-photon-v1.1-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and Photon v1.1 after entering a singleplayer world. Minecraft 1.21.4 Fabric Iris Photon v1.1 in-world golden
  • minecraft-1.21.4-fabric-iris-photon-v1.3b-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and Photon v1.3b after entering a singleplayer world. Minecraft 1.21.4 Fabric Iris Photon v1.3b in-world golden
  • minecraft-1.21.4-fabric-iris-derivative-main-d24.4.14-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and Derivative Main d24.4.14 after entering a singleplayer world. Minecraft 1.21.4 Fabric Iris Derivative Main d24.4.14 in-world golden
  • minecraft-1.21.4-fabric-iris-sundial-lite-in-world: captured from Minecraft 1.21.4 Fabric with Sodium, Iris, and Sundial Lite after entering a singleplayer world. Minecraft 1.21.4 Fabric Iris Sundial Lite in-world golden
  • minecraft-1.21.1-neoforge-create-indirect-in-world: captured from Minecraft 1.21.1 NeoForge with Create, Sodium, and Iris (no shader pack) in a world facing Create water wheels and a large cogwheel, with Flywheel's flywheel:indirect backend (compute-shader culling, glMultiDrawElementsIndirect, persistent-mapped staging). Minecraft 1.21.1 NeoForge Create indirect in-world golden
  • minecraft-1.21.1-neoforge-create-instancing-in-world: same world and camera as the indirect case, with Flywheel's flywheel:instancing backend (texture-buffer instance data, glDrawElementsInstancedBaseVertex). Minecraft 1.21.1 NeoForge Create instancing in-world golden

Build from the MobileGL repository root:

cmake -S . -B build-test -G Ninja \
  -DMOBILEGL_BUILD_TEST=ON \
  -DMOBILEGL_BUILD_BENCHMARK=OFF \
  -DMOBILEGL_BUILD_TRACE_REPLAY=ON
cmake --build build-test

Run the fixture tests:

ctest --test-dir build-test -V -R 'MobileGLTraceReplay\.'

Run the CLI directly:

build-test/tools/trace_replay/mobilegl_trace_replay \
  --trace openra.trace \
  --golden openra.0000031249.png \
  --output out/openra \
  --backend DirectGLES \
  --mobilegl-library build-test/libMobileGL.so \
  --target-call 31249 \
  --width 640 \
  --height 480 \
  --crop-x 1 \
  --crop-y 1 \
  --crop-width 638 \
  --crop-height 478 \
  --ssim-threshold 0.99

Dumping framebuffer attachments mid-frame

--target-call snapshots one framebuffer. To see inside a frame - which intermediate render target a pass actually produced - pass --dump-fbo-attachments CALL:DIR[:FBO,FBO,...], repeatably:

build-test/tools/trace_replay/mobilegl_trace_replay \
  --trace trace.trace --golden golden.png --output out --target-call 2667619 \
  --dump-fbo-attachments 2666231:out/fbos-before \
  --dump-fbo-attachments 2666232:out/fbos-after

At each call boundary it walks every live framebuffer object (or only the named ones), reads back every colour attachment and the depth attachment, and writes fbo<N>-att<M>.png / fbo<N>-depth.png plus a manifest.txt line per attachment recording the attached object, size, internal format, component type and per-channel min/max/mean and a content hash. Attachments are read as floats whatever their storage, so HDR accumulation buffers stay legible in the statistics even though the PNG has to clamp.

The manifest is the useful part when comparing two drivers: dump the same call on both stacks and diff/paste the two manifests, and the first attachment whose hash differs names the pass that diverged. Read-side and pixel-pack state is saved and restored, so the replay continues unperturbed; without the flag nothing is installed and the replay is byte-for-byte what it was.

Run the macOS native-window DirectVulkan retrace matrix and render the same HTML overview shape as CI:

cmake -S . -B cmake-build-macos-trace-arm64 -G Ninja \
  -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_OSX_ARCHITECTURES=arm64 \
  -DMOBILEGL_BUILD_TEST=OFF \
  -DMOBILEGL_BUILD_BENCHMARK=OFF \
  -DMOBILEGL_BUILD_TRACE_REPLAY=ON
cmake --build cmake-build-macos-trace-arm64 --target MobileGL mobilegl_trace_replay
python3 tools/trace_replay/run_macos_window_retrace_local.py --ci --all
open .trace-work/macos-window-retrace-summary/mobilegl-macos-window-vulkan-retrace-overview.html

The macOS runner hydrates missing fixtures from the trace fixture mirror with parallel downloads before falling back to Git LFS. It reuses the cmake-build-macos-trace-arm64 harness by default on Apple Silicon, passes --window-surface, and defaults to DirectVulkan only. If a native-window replay hits a fatal assertion, the runner writes a failure result and stops before launching later cases; use --continue-after-fatal to collect the full matrix, or --skip-case NAME for known fatal cases.

Android device replay

Build and install the generic trace APK from the repository root. Both DirectGLES and DirectVulkan use the same APK and package; select the backend with the intent's backend extra.

gradle --no-daemon -p android-plugin :app:assembleTraceDebug
TRACE_APK=$(find android-plugin/app/build/outputs/apk/trace/debug -maxdepth 1 -name '*.apk' -print -quit)
adb install -r "$TRACE_APK"

Prepare a fixture and copy it into the app-private directory:

mkdir -p /tmp/mobilegl-openra
tar -xzf tools/trace_replay/fixtures/openra.tgz -C /tmp/mobilegl-openra
adb push /tmp/mobilegl-openra/openra.trace /data/local/tmp/mobilegl-openra.trace
adb push tools/trace_replay/fixtures/openra.0000031249.png /data/local/tmp/mobilegl-openra.golden.png

PKG=top.mobilegl.plugin.trace
APP_DIR=/data/user/0/$PKG/files/trace-replay
adb shell run-as $PKG rm -rf files/trace-replay
adb shell run-as $PKG mkdir -p files/trace-replay/input files/trace-replay/output
adb shell run-as $PKG cp /data/local/tmp/mobilegl-openra.trace files/trace-replay/input/openra.trace
adb shell run-as $PKG cp /data/local/tmp/mobilegl-openra.golden.png files/trace-replay/input/openra.golden.png

Launch the standalone trace runner Activity:

adb shell am force-stop $PKG
adb shell am start -W -a top.mobilegl.plugin.TRACE_REPLAY \
  -n $PKG/top.mobilegl.plugin.trace.TraceReplayActivity \
  --es trace_path $APP_DIR/input/openra.trace \
  --es golden_path $APP_DIR/input/openra.golden.png \
  --es output_dir $APP_DIR/output \
  --es diff_path $APP_DIR/output/openra-diff.png \
  --es backend DirectGLES \
  --el target_call 31249 \
  --ei width 640 \
  --ei height 480 \
  --ei crop_x 1 \
  --ei crop_y 1 \
  --ei crop_width 638 \
  --ei crop_height 478 \
  --es ssim_threshold 0.99

Read back the result and images:

adb shell run-as $PKG cat files/trace-replay/output/result.json
adb exec-out run-as $PKG cat files/trace-replay/output/actual.png > openra-actual.png
adb exec-out run-as $PKG cat files/trace-replay/output/openra-diff.png > openra-diff.png

For Vulkan replay, keep the same APK and $PKG, then pass --es backend DirectVulkan. DirectGLES also renders to the Activity surface by default; pass --ez use_pbuffer true to use the offscreen pbuffer path. Always adb shell am force-stop $PKG before another replay: apitrace snapshot state is process-local. For cases registered with coherent_as_flush (Flywheel-style unflushed persistent maps, e.g. the Create fixtures), pass --ez coherent_as_flush true so the replay runs with MOBILEGL_COHERENT_AS_FLUSH=1. For cases registered with avoid_angle_llvmpipe_explicit_lod_bias (DirectGLES on ANGLE llvmpipe, e.g. the sundial-lite fixture), pass --ez avoid_angle_llvmpipe_explicit_lod_bias true so the replay runs with MOBILEGL_ESPRYT_AVOID_EXPLICIT_LOD_BIAS=1.

Benchmark mode (frame timing)

Benchmark mode reuses the same fixtures as a performance harness instead of a correctness one: it replays the trace from the first call to the last, takes a wall-clock timestamp at every frame boundary, and takes no snapshot and runs no SSIM comparison. passed then only means the replay reached the end of the trace without an error.

Frame times include GPU completion by default, because a swap boundary on a tiled mobile GPU returns long before the tiler is done and would otherwise time CPU submission alone. That is what benchmark_finish / --benchmark-finish controls: on (the default) it issues a glFinish through the replayed context at every frame boundary, which serializes CPU/GPU overlap - pessimistic against a real running game, but deterministic and comparable between backends and revisions. Turn it off to measure CPU-side submission only.

The mean/median/p95 are computed over the last benchmark_tail_frames frames (default 200, clamped to the frames actually recorded); the head of a trace is dominated by shader compiles and first-use uploads. The full per-frame array is in the timing JSON, next to the headline numbers, which also appear in result.json.

Whole device runs, one line per run plus the best of the repeats:

python tools/trace_replay/run_android_retrace_local.py --benchmark \
  --case minecraft-1.21.4-fabric-iris-photon-in-world --backend DirectVulkan \
  --benchmark-repeats 3

--benchmark-tail-frames N, --benchmark-no-finish and --benchmark-timeout-seconds N are available; only the first repeat installs the APK and pushes the trace. Each run's benchmark.json is kept next to the case result as benchmark-run<N>.json.

The Activity takes the same settings directly:

adb shell am start -a top.mobilegl.plugin.TRACE_REPLAY \
  -n $PKG/top.mobilegl.plugin.trace.TraceReplayActivity \
  --es trace_path $APP_DIR/input/openra.trace \
  --es output_dir $APP_DIR/output \
  --es backend DirectGLES \
  --ei width 640 --ei height 480 \
  --ez benchmark true \
  --ei benchmark_tail_frames 200 \
  --ez benchmark_finish true \
  --es benchmark_result_path $APP_DIR/output/benchmark.json
adb exec-out run-as $PKG cat files/trace-replay/output/benchmark.json > benchmark.json

golden_path and target_call are not needed in benchmark mode. The Linux CLI takes the same options:

./mobilegl_trace_replay --trace openra.trace --output out --backend DirectGLES \
  --benchmark --benchmark-tail-frames=200 --benchmark-finish=1 \
  --benchmark-result=out/benchmark.json

Two caveats when reading the numbers. Frame times are taken at eglSwapBuffers, so a trace that ends frames with glFrameTerminatorGREMEDY instead is not timed. And on a window surface the swap can block on the compositor, which pins frame times to the display refresh; replay against the pbuffer surface (--ez use_pbuffer true) to measure the renderer rather than the presentation path.

Reproducing the Android DirectGLES lane on Linux (ANGLE on lavapipe)

The APK workflow's DirectGLES lane is not the same stack as the Linux one, which is why a case can be green here and red there:

lane stack
Linux Test retrace, DirectGLES Espryt -> Mesa GLES -> llvmpipe
Android APK retrace, DirectGLES Espryt -> ANGLE -> Mesa Vulkan (lavapipe)
Android APK retrace, DirectVulkan Magma -> lavapipe (no ANGLE)

Only the Android DirectGLES lane puts ANGLE in the middle, so an ANGLE translation difference shows up in exactly one of the six combinations. That stack can be reproduced on Linux without an emulator, which is far faster to iterate on than a CI round trip. The Android emulator SDK ships a glibc ANGLE:

ANGLE=$ANDROID_SDK_ROOT/emulator/lib64/gles_angle
mkdir -p ~/angle-farm && cd ~/angle-farm
# MobileGL dlopens these two names; ANGLE's own libEGL then dlopens the
# unsuffixed libGLESv2.so from the same directory - without that symlink it
# loads a truncated entry-point table and dies on a missing EGL function.
ln -sf $ANGLE/libEGL.so    libEGL_angle.so
ln -sf $ANGLE/libGLESv2.so libGLESv2_angle.so
ln -sf $ANGLE/libEGL.so    libEGL.so
ln -sf $ANGLE/libGLESv2.so libGLESv2.so
ln -sf $ANGLE/libvulkan.so.1 libvulkan.so.1   # else eglInitialize fails

MOBILEGL_ESPRYT_USE_ANGLE=1 \
LD_LIBRARY_PATH=~/angle-farm:/path/to/build/ \
VK_ICD_FILENAMES=/usr/share/vulkan/icd.d/lvp_icd.json \
ANGLE_DEFAULT_PLATFORM=vulkan \
  ./mobilegl_trace_replay --trace trace.trace --golden golden.png \
    --target-call N --width 854 --height 480 --backend DirectGLES \
    --output outdir --pbuffer-surface

ANGLE_DEFAULT_PLATFORM=vulkan is required: ANGLE otherwise picks its OpenGL backend and you get ANGLE (Mesa, llvmpipe ..., OpenGL 4.6 (Core Profile)) instead of the CI-shaped ANGLE (Mesa, Vulkan 1.x (llvmpipe ...)). Check MOBILEGL_TRACE_GL_RENDERER in outdir/retrace.log before trusting a result. Run the binary directly rather than through ctest, whose ENVIRONMENT property overrides these variables. Build with clang, not gcc: gcc rejects GLXImpl.cpp under -Wchanges-meaning.

One more caveat before attributing anything: the emulator SDK's ANGLE is not the ANGLE the Android lane runs. The CI lane uses a pinned build (MOBILEGL_TRACE_ANGLE_VARIANT, default ec889e6ea831) whose version and extension set differ from the SDK copy (GL_EXT_texture_buffer support, ES 3.2 entry points). Compare GL_RENDERER and the relevant extension lists on both stacks before treating a local result as a statement about CI.