mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-09-12 14:18:31 +09:00
[Refactor] (TraceReplay): reorganize trace skills into uniform packages with bundled scripts
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
# MobileGL trace-replay skills
|
||||
|
||||
Task-focused skills for capturing, replaying, debugging, and authoring MobileGL
|
||||
apitrace fixtures. Each skill is a self-contained package:
|
||||
|
||||
- `SKILL.md` — the skill (frontmatter `name` + `description`, then the body). The
|
||||
directory name equals the frontmatter `name`.
|
||||
- `agents/openai.yaml` — OpenAI agent descriptor (`display_name`,
|
||||
`short_description`, `default_prompt`).
|
||||
- `scripts/` and/or `references/` — bundled tooling and supporting docs, when the
|
||||
skill has them.
|
||||
|
||||
## Skills
|
||||
|
||||
| Skill | What it does |
|
||||
| --- | --- |
|
||||
| [trace-fixture-authoring-on-android-fcl](trace-fixture-authoring-on-android-fcl/SKILL.md) | Capture an on-device Android apitrace from FCL's MobileGL renderers (DirectGLES / Magma / SimpleFPEWrapper), mark the defect frame, and pull `full.trace`. |
|
||||
| [renderdoc-debug-on-trace-replay](renderdoc-debug-on-trace-replay/SKILL.md) | Capture and validate an exact frame from a MobileGL retrace on a connected Android device with RenderDoc / rdc-cli. |
|
||||
| [mismatch-retrace-debugging](mismatch-retrace-debugging/SKILL.md) | Localize the first divergent render pass and draw call when a fixture replays correctly in a golden environment but renders differently on a target backend. |
|
||||
| [trace-fixture-authoring](trace-fixture-authoring/SKILL.md) | Author a deterministic trace-replay fixture — trim, golden, package under the size budget, register in `trace_cases.json`, and validate on Linux and Android. |
|
||||
+5
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: mismatch-retrace-debugging
|
||||
description: Localize the first divergent render pass and draw call when a MobileGL apitrace fixture replays correctly in a golden environment but renders differently under mobilegl_trace_replay, Android trace replay, or another backend. Use to binary-search pass/draw endpoints, diff GL state around the first bad call, and classify the fault as a vertex/VS, fragment/FS, or framebuffer/composition mismatch.
|
||||
---
|
||||
|
||||
# Mismatch retrace debugging
|
||||
|
||||
Use this when an apitrace fixture replays correctly on one environment but
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "MobileGL Mismatch Retrace Debugging"
|
||||
short_description: "Localize the first divergent draw in a mismatching MobileGL retrace"
|
||||
default_prompt: "Use $mismatch-retrace-debugging to find the first divergent render pass and draw call in a MobileGL retrace mismatch."
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
name: renderdoc-debug-on-trace-replay
|
||||
description: Capture and validate an exact frame from a MobileGL apitrace retrace on a connected Android device with RenderDoc/rdc-cli. Use for DirectVulkan or DirectGLES trace replay, mapping a target API call to an eglSwapBuffers frame, producing an .rdc plus a complete command manifest, checking capture stability, or troubleshooting Android TargetControl timing and replay failures.
|
||||
---
|
||||
|
||||
# Capture a RenderDoc Trace Frame
|
||||
|
||||
Use the repository tool to queue TargetControl before launching the replay activity, keep the connection alive until capture completion, pull the RDC, and write a reproducible JSON manifest.
|
||||
|
||||
## Prepare
|
||||
|
||||
1. Work from the MobileGL repository root.
|
||||
2. Confirm `python`, `adb`, `apitrace`, and `rdc` are on `PATH`.
|
||||
3. Confirm the trace replay APK is installed and debuggable:
|
||||
|
||||
```powershell
|
||||
adb devices -l
|
||||
adb -s SERIAL shell pm path top.mobilegl.plugin.trace
|
||||
rdc doctor
|
||||
```
|
||||
|
||||
4. Pass the unpacked `trace.trace`, its golden PNG, the fixture target call, backend, and output path to `tools/trace_replay/skills/renderdoc-debug-on-trace-replay/scripts/capture_android_retrace.py`.
|
||||
|
||||
## Capture
|
||||
|
||||
Let the tool infer the zero-based target swap from `eglSwapBuffers` calls:
|
||||
|
||||
```powershell
|
||||
python tools/trace_replay/skills/renderdoc-debug-on-trace-replay/scripts/capture_android_retrace.py --trace .trace-work/case/trace.trace --golden tools/trace_replay/fixtures/case.0002667619.png --target-call 2667619 --backend DirectVulkan --output captures/case-vulkan.rdc --serial SERIAL --json
|
||||
```
|
||||
|
||||
Change only the backend and output for GLES:
|
||||
|
||||
```powershell
|
||||
python tools/trace_replay/skills/renderdoc-debug-on-trace-replay/scripts/capture_android_retrace.py --trace .trace-work/case/trace.trace --golden tools/trace_replay/fixtures/case.0002667619.png --target-call 2667619 --backend DirectGLES --output captures/case-gles.rdc --serial SERIAL --json
|
||||
```
|
||||
|
||||
Use `--target-swap N` when the mapping is already known. Use `--capture-frame N` only to override the backend rule deliberately.
|
||||
|
||||
The default mapping is:
|
||||
|
||||
- DirectGLES: capture the zero-based target swap.
|
||||
- DirectVulkan: capture `target swap - 1`, so the capture closes at the terminal target Present. Queueing the terminal Vulkan swap itself can never finish when the retrace stops immediately after that Present.
|
||||
|
||||
The tool applies a 256 MiB RenderDoc `softMemoryLimit` for Vulkan, installs the GLES layer only for GLES, queues before Activity launch, drains asynchronous TargetControl registration messages, and restores device properties and forwards afterward. Do not replace it with a late `rdc script` call.
|
||||
|
||||
## Inspect the Output
|
||||
|
||||
Read `<output>.rdc.json`. Require all of the following:
|
||||
|
||||
- `success` is `true`.
|
||||
- `capture.frame` equals the requested capture frame.
|
||||
- `capture.api` matches Vulkan or OpenGLES.
|
||||
- `byteSize` is non-zero and `sha256` is present.
|
||||
- `retraceResult` says the replay completed; treat its SSIM separately from RDC validity.
|
||||
- `commands` and `commandLines` contain the full reproducible command history.
|
||||
|
||||
A DirectVulkan SSIM below a GLES golden threshold can be a backend rendering difference. Do not reject the RDC solely for that reason if RenderDoc can replay it and the target render output is present.
|
||||
|
||||
## Validate with RenderDoc
|
||||
|
||||
Validate GLES locally:
|
||||
|
||||
```powershell
|
||||
rdc close
|
||||
rdc open captures/case-gles.rdc
|
||||
rdc info --json
|
||||
rdc count events
|
||||
rdc count draws
|
||||
rdc count passes
|
||||
rdc assert-clean --min-severity high --json
|
||||
```
|
||||
|
||||
Validate device-specific Vulkan captures on the original Android GPU:
|
||||
|
||||
```powershell
|
||||
rdc android setup --json
|
||||
adb -s SERIAL forward --list
|
||||
rdc close
|
||||
rdc open captures/case-vulkan.rdc --proxy 127.0.0.1:FORWARDED_REMOTE_PORT
|
||||
rdc info --json
|
||||
rdc count events
|
||||
rdc count draws
|
||||
rdc count passes
|
||||
rdc assert-clean --min-severity high --json
|
||||
```
|
||||
|
||||
Use the local TCP port mapped to `localabstract:renderdoc_39920` as `FORWARDED_REMOTE_PORT`. Prefer Android remote replay when a desktop GPU lacks the Android capture's memory types.
|
||||
|
||||
## Diagnose Failures
|
||||
|
||||
Read [references/android-renderdoc-troubleshooting.md](references/android-renderdoc-troubleshooting.md) when capture metadata is missing, a capture times out, the GLES layer does not load, Vulkan stalls on initial contents, `rdc android setup --serial` cannot select a listed device, or desktop replay rejects a Vulkan RDC.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "RenderDoc Debug on Trace Replay"
|
||||
short_description: "Capture and debug an exact MobileGL trace-replay frame in RenderDoc"
|
||||
default_prompt: "Use $renderdoc-debug-on-trace-replay to capture and validate an exact Android trace-replay frame in RenderDoc."
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
# Android RenderDoc retrace troubleshooting
|
||||
|
||||
## TargetControl timing
|
||||
|
||||
- Start `tools/trace_replay/skills/renderdoc-debug-on-trace-replay/scripts/queue_android_frame.py` before launching `TraceReplayActivity`. A fast retrace can pass the requested frame before a late client connects.
|
||||
- Keep TargetControl connected until `NewCapture` arrives. A queued request alone is not sufficient evidence that the RDC finished.
|
||||
- Drain the asynchronous `RegisterAPI` and `CapturableWindowCount` messages before calling `QueueCapture`; otherwise `NewCapture` can be lost.
|
||||
- Do not use the daemon-backed `rdc script` path for a capture that may exceed 30 seconds. Its outer RPC times out even when the device later writes a valid RDC. The repository helper imports the RenderDoc module discovered by `rdc` directly and has an independent capture timeout.
|
||||
|
||||
## Frame selection
|
||||
|
||||
- Map the fixture target call to the exact `eglSwapBuffers` call, or to the first swap after a non-swap target call.
|
||||
- Use the zero-based swap index for DirectGLES.
|
||||
- Use `target swap - 1` for DirectVulkan when the target swap is the retrace's terminal Present. RenderDoc needs that Present to close the queued frame.
|
||||
- Override with `--capture-frame` only after inspecting the trace structure.
|
||||
|
||||
## Vulkan capture and replay
|
||||
|
||||
- Keep the default 256 MiB `softMemoryLimit`. Large Vulkan initial contents can otherwise create a device memory spike and stall or kill capture. The tool encodes and restores CaptureOptions automatically.
|
||||
- Replay Adreno captures on the original Android GPU when a desktop GPU reports an unavailable memory type. This is a replay compatibility issue, not proof that the RDC is corrupt.
|
||||
- Use the local port from `adb forward --list` whose remote endpoint is `localabstract:renderdoc_39920` with `rdc open --proxy`.
|
||||
- Expect an Android Vulkan render target export to appear vertically flipped when the surface transform is preserved.
|
||||
|
||||
## GLES layer injection
|
||||
|
||||
The GLES loader must be able to load `libVkLayer_GLES_RenderDoc.so` from the debuggable app directory. The tool copies it from:
|
||||
|
||||
```text
|
||||
/data/local/debug/vulkan/libVkLayer_GLES_RenderDoc.so
|
||||
```
|
||||
|
||||
If that source is missing, run `rdc android setup` first. Verify injection with:
|
||||
|
||||
```powershell
|
||||
adb -s SERIAL shell getprop debug.gles.layers
|
||||
adb -s SERIAL shell run-as top.mobilegl.plugin.trace ls -l /data/user/0/top.mobilegl.plugin.trace/libVkLayer_GLES_RenderDoc.so
|
||||
```
|
||||
|
||||
## Android debug settings
|
||||
|
||||
Require these global settings for the replay package:
|
||||
|
||||
```text
|
||||
gpu_debug_app=top.mobilegl.plugin.trace
|
||||
enable_gpu_debug_layers=1
|
||||
gpu_debug_layers=VK_LAYER_RENDERDOC_Capture
|
||||
```
|
||||
|
||||
Some Xiaomi ROMs reject shell writes with `WRITE_SECURE_SETTINGS`. Configure the RenderDoc GPU debug layer through the device developer options or RenderDoc setup flow. The tool avoids rewriting settings that are already correct and restores only keys it actually changed.
|
||||
|
||||
## rdc-cli device selection
|
||||
|
||||
RenderDoc 1.41 can enumerate a device as a raw serial while `rdc android setup --serial` compares against `adb://SERIAL`. If the explicit command says the listed serial is not found and exactly one device is connected, retry without `--serial`. The repository tool performs this bounded fallback automatically.
|
||||
|
||||
## Completion checks
|
||||
|
||||
Treat capture as successful only when all checks pass:
|
||||
|
||||
1. TargetControl returns `NewCapture` with the expected frame and API.
|
||||
2. `adb pull` produces a non-empty RDC and the manifest records its SHA-256.
|
||||
3. RenderDoc opens the RDC and reports more than zero events and draws.
|
||||
4. `rdc assert-clean --min-severity high` passes.
|
||||
5. The retrace result reaches the target call. Evaluate fixture SSIM according to the selected backend.
|
||||
+701
@@ -0,0 +1,701 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Capture an exact frame from MobileGL's Android trace replay activity."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Sequence
|
||||
|
||||
from rdc.discover import find_renderdoc
|
||||
|
||||
|
||||
DEFAULT_PACKAGE = "top.mobilegl.plugin.trace"
|
||||
DEFAULT_ACTIVITY = "top.mobilegl.plugin.trace.TraceReplayActivity"
|
||||
DEFAULT_ACTION = "top.mobilegl.plugin.TRACE_REPLAY"
|
||||
RENDERDOC_VULKAN_LAYER = "VK_LAYER_RENDERDOC_Capture"
|
||||
RENDERDOC_GLES_LAYER = "libVkLayer_GLES_RenderDoc.so"
|
||||
RENDERDOC_CMD_PACKAGE = "org.renderdoc.renderdoccmd.arm64"
|
||||
REMOTE_GLES_LAYER = f"/data/local/debug/vulkan/{RENDERDOC_GLES_LAYER}"
|
||||
|
||||
|
||||
class CommandRunner:
|
||||
def __init__(self, *, verbose: bool = True) -> None:
|
||||
self.verbose = verbose
|
||||
self.commands: list[list[str]] = []
|
||||
|
||||
def run(
|
||||
self,
|
||||
command: Sequence[object],
|
||||
*,
|
||||
check: bool = True,
|
||||
timeout: float | None = None,
|
||||
capture_output: bool = True,
|
||||
) -> subprocess.CompletedProcess[str]:
|
||||
argv = [str(part) for part in command]
|
||||
self.commands.append(argv)
|
||||
if self.verbose:
|
||||
print(f"+ {subprocess.list2cmdline(argv)}", file=sys.stderr, flush=True)
|
||||
completed = subprocess.run(
|
||||
argv,
|
||||
check=False,
|
||||
timeout=timeout,
|
||||
capture_output=capture_output,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
if self.verbose and completed.stdout and not capture_output:
|
||||
print(completed.stdout, end="", file=sys.stderr)
|
||||
if check and completed.returncode != 0:
|
||||
detail = completed.stderr.strip() or completed.stdout.strip() or "no output"
|
||||
raise RuntimeError(
|
||||
f"command failed with exit code {completed.returncode}: "
|
||||
f"{subprocess.list2cmdline(argv)}\n{detail}"
|
||||
)
|
||||
return completed
|
||||
|
||||
|
||||
def _adb(runner: CommandRunner, serial: str, *args: object, **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
||||
return runner.run(["adb", "-s", serial, *args], **kwargs)
|
||||
|
||||
|
||||
def _connected_serials(runner: CommandRunner) -> list[str]:
|
||||
completed = runner.run(["adb", "devices"], timeout=10)
|
||||
serials: list[str] = []
|
||||
for line in completed.stdout.splitlines()[1:]:
|
||||
fields = line.split()
|
||||
if len(fields) >= 2 and fields[1] == "device":
|
||||
serials.append(fields[0])
|
||||
return serials
|
||||
|
||||
|
||||
def _resolve_serial(runner: CommandRunner, requested: str | None) -> str:
|
||||
serials = _connected_serials(runner)
|
||||
if requested:
|
||||
if requested not in serials:
|
||||
raise RuntimeError(
|
||||
f"Android device {requested!r} is not connected; available: {serials or 'none'}"
|
||||
)
|
||||
return requested
|
||||
if len(serials) != 1:
|
||||
raise RuntimeError(
|
||||
"exactly one Android device must be connected when --serial is omitted; "
|
||||
f"found {serials or 'none'}"
|
||||
)
|
||||
return serials[0]
|
||||
|
||||
|
||||
def _setup_renderdoc(runner: CommandRunner, serial: str, connected: list[str]) -> None:
|
||||
command = ["rdc", "android", "setup", "--serial", serial, "--json"]
|
||||
completed = runner.run(command, check=False, timeout=90)
|
||||
if completed.returncode == 0:
|
||||
return
|
||||
# rdc-cli 1.41 can enumerate a raw serial but compare it against adb://SERIAL.
|
||||
# Falling back is safe only when the requested device is the sole connected device.
|
||||
if connected == [serial] and "not found" in (completed.stderr + completed.stdout).lower():
|
||||
runner.run(["rdc", "android", "setup", "--json"], timeout=90)
|
||||
return
|
||||
detail = completed.stderr.strip() or completed.stdout.strip()
|
||||
raise RuntimeError(f"rdc android setup failed: {detail}")
|
||||
|
||||
|
||||
def _get_property(runner: CommandRunner, serial: str, key: str) -> str:
|
||||
return _adb(runner, serial, "shell", "getprop", key, timeout=10).stdout.strip()
|
||||
|
||||
|
||||
def _set_property(runner: CommandRunner, serial: str, key: str, value: str) -> None:
|
||||
_adb(runner, serial, "shell", "setprop", key, value, timeout=10)
|
||||
|
||||
|
||||
def _get_setting(runner: CommandRunner, serial: str, key: str) -> str | None:
|
||||
value = _adb(
|
||||
runner, serial, "shell", "settings", "get", "global", key, timeout=10
|
||||
).stdout.strip()
|
||||
return None if value in {"", "null"} else value
|
||||
|
||||
|
||||
def _set_setting(runner: CommandRunner, serial: str, key: str, value: str | None) -> None:
|
||||
if value is None:
|
||||
_adb(runner, serial, "shell", "settings", "delete", "global", key, timeout=10)
|
||||
else:
|
||||
_adb(runner, serial, "shell", "settings", "put", "global", key, value, timeout=10)
|
||||
|
||||
|
||||
def _ensure_debug_settings(
|
||||
runner: CommandRunner,
|
||||
serial: str,
|
||||
package: str,
|
||||
changed: dict[str, str | None],
|
||||
) -> None:
|
||||
expected = {
|
||||
"gpu_debug_app": package,
|
||||
"enable_gpu_debug_layers": "1",
|
||||
"gpu_debug_layers": RENDERDOC_VULKAN_LAYER,
|
||||
}
|
||||
original = {key: _get_setting(runner, serial, key) for key in expected}
|
||||
for key, value in expected.items():
|
||||
if original[key] != value:
|
||||
changed[key] = original[key]
|
||||
_set_setting(runner, serial, key, value)
|
||||
actual = _get_setting(runner, serial, key)
|
||||
if actual != value:
|
||||
raise RuntimeError(
|
||||
f"failed to set Android global setting {key}={value!r}; got {actual!r}. "
|
||||
"Enable the developer option that permits USB debugging to write secure "
|
||||
"settings, or configure RenderDoc's GPU debug layer from the device UI."
|
||||
)
|
||||
|
||||
|
||||
def _encode_capture_options(soft_memory_limit_mib: int) -> str:
|
||||
rd = find_renderdoc()
|
||||
if rd is None:
|
||||
raise RuntimeError("RenderDoc Python module not found; run 'rdc setup-renderdoc'")
|
||||
options = rd.GetDefaultCaptureOptions()
|
||||
options.softMemoryLimit = soft_memory_limit_mib
|
||||
return options.EncodeAsString()
|
||||
|
||||
|
||||
def _map_target_call_to_swap(
|
||||
runner: CommandRunner, trace: Path, target_call: int, apitrace: str
|
||||
) -> tuple[int, int, str]:
|
||||
completed = runner.run(
|
||||
[apitrace, "dump", "--grep=eglSwapBuffers", str(trace)], timeout=120
|
||||
)
|
||||
swaps: list[int] = []
|
||||
for line in completed.stdout.splitlines():
|
||||
match = re.match(r"^(\d+)\s+eglSwapBuffers\b", line)
|
||||
if match:
|
||||
swaps.append(int(match.group(1)))
|
||||
if not swaps:
|
||||
raise RuntimeError(f"no eglSwapBuffers calls found in {trace}")
|
||||
for index, call in enumerate(swaps):
|
||||
if call == target_call:
|
||||
return index, call, "exact-swap-call"
|
||||
if call > target_call:
|
||||
return index, call, "first-swap-after-call"
|
||||
raise RuntimeError(
|
||||
f"target call {target_call} is after the final eglSwapBuffers call {swaps[-1]}"
|
||||
)
|
||||
|
||||
|
||||
def _capture_frame_for_backend(backend: str, target_swap: int) -> int:
|
||||
if backend == "DirectVulkan":
|
||||
if target_swap == 0:
|
||||
raise RuntimeError("DirectVulkan cannot capture target swap 0 with the -1 frame rule")
|
||||
return target_swap - 1
|
||||
return target_swap
|
||||
|
||||
|
||||
def _forward_map(runner: CommandRunner, serial: str) -> dict[int, str]:
|
||||
completed = _adb(runner, serial, "forward", "--list", timeout=10)
|
||||
forwards: dict[int, str] = {}
|
||||
for line in completed.stdout.splitlines():
|
||||
fields = line.split()
|
||||
if len(fields) < 3 or fields[0] != serial or not fields[1].startswith("tcp:"):
|
||||
continue
|
||||
try:
|
||||
forwards[int(fields[1].removeprefix("tcp:"))] = fields[2]
|
||||
except ValueError:
|
||||
continue
|
||||
return forwards
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as source:
|
||||
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest().upper()
|
||||
|
||||
|
||||
def _read_remote_json(
|
||||
runner: CommandRunner, serial: str, package: str, path: str
|
||||
) -> dict[str, object] | None:
|
||||
completed = _adb(
|
||||
runner,
|
||||
serial,
|
||||
"exec-out",
|
||||
"run-as",
|
||||
package,
|
||||
"cat",
|
||||
path,
|
||||
check=False,
|
||||
timeout=15,
|
||||
)
|
||||
if completed.returncode != 0 or not completed.stdout.strip():
|
||||
return None
|
||||
return json.loads(completed.stdout)
|
||||
|
||||
|
||||
def _write_manifest(path: Path, data: dict[str, object]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Replay an apitrace fixture with MobileGL on Android and capture an exact "
|
||||
"DirectVulkan or DirectGLES frame with RenderDoc."
|
||||
)
|
||||
)
|
||||
parser.add_argument("--trace", type=Path, required=True)
|
||||
parser.add_argument("--golden", type=Path, required=True)
|
||||
parser.add_argument("--target-call", type=int, required=True)
|
||||
parser.add_argument("--target-swap", type=int, help="zero-based target swap; infer when omitted")
|
||||
parser.add_argument("--capture-frame", type=int, help="override backend-specific frame mapping")
|
||||
parser.add_argument("--backend", choices=("DirectVulkan", "DirectGLES"), required=True)
|
||||
parser.add_argument("--output", type=Path, required=True)
|
||||
parser.add_argument("--serial")
|
||||
parser.add_argument("--package", default=DEFAULT_PACKAGE)
|
||||
parser.add_argument("--activity", default=DEFAULT_ACTIVITY)
|
||||
parser.add_argument("--action", default=DEFAULT_ACTION)
|
||||
parser.add_argument("--width", type=int, default=854)
|
||||
parser.add_argument("--height", type=int, default=480)
|
||||
parser.add_argument("--ssim-threshold", type=float, default=0.99)
|
||||
parser.add_argument("--apitrace", default="apitrace")
|
||||
parser.add_argument("--local-port", type=int, default=38920)
|
||||
parser.add_argument("--target-ident", type=int, default=38920)
|
||||
parser.add_argument("--connect-timeout", type=float, default=20.0)
|
||||
parser.add_argument("--capture-timeout", type=float, default=90.0)
|
||||
parser.add_argument("--soft-memory-limit", type=int, default=256, metavar="MIB")
|
||||
parser.add_argument("--no-setup-renderdoc", action="store_true")
|
||||
parser.add_argument("--keep-device-files", action="store_true")
|
||||
parser.add_argument("--keep-device-state", action="store_true")
|
||||
parser.add_argument("--overwrite", action="store_true")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
runner = CommandRunner(verbose=True)
|
||||
trace = args.trace.resolve()
|
||||
golden = args.golden.resolve()
|
||||
output = args.output.resolve()
|
||||
manifest_path = output.with_suffix(output.suffix + ".json")
|
||||
for path, label in ((trace, "trace"), (golden, "golden")):
|
||||
if not path.is_file():
|
||||
raise RuntimeError(f"{label} file does not exist: {path}")
|
||||
if output.exists() and not args.overwrite:
|
||||
raise RuntimeError(f"output exists; pass --overwrite to replace it: {output}")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
serial = _resolve_serial(runner, args.serial)
|
||||
connected = _connected_serials(runner)
|
||||
initial_forwards = _forward_map(runner, serial)
|
||||
|
||||
if args.target_swap is None:
|
||||
target_swap, target_swap_call, mapping = _map_target_call_to_swap(
|
||||
runner, trace, args.target_call, args.apitrace
|
||||
)
|
||||
else:
|
||||
target_swap = args.target_swap
|
||||
target_swap_call = args.target_call
|
||||
mapping = "explicit"
|
||||
capture_frame = (
|
||||
args.capture_frame
|
||||
if args.capture_frame is not None
|
||||
else _capture_frame_for_backend(args.backend, target_swap)
|
||||
)
|
||||
|
||||
token = f"{int(time.time())}-{os.getpid()}"
|
||||
remote_trace = f"/data/local/tmp/renderdoc-retrace-{token}.trace"
|
||||
remote_golden = f"/data/local/tmp/renderdoc-retrace-{token}-golden.png"
|
||||
replay_root = (
|
||||
f"/data/user/0/{args.package}/files/trace-replay/renderdoc-capture-{token}"
|
||||
)
|
||||
replay_output = f"{replay_root}/output"
|
||||
remote_result = f"{replay_output}/result.json"
|
||||
queue_script = Path(__file__).with_name("queue_android_frame.py")
|
||||
|
||||
original_capopts = _get_property(runner, serial, "debug.rdoc.RENDERDOC_CAPOPTS")
|
||||
original_gles_layers = _get_property(runner, serial, "debug.gles.layers")
|
||||
original_settings: dict[str, str | None] = {}
|
||||
original_forward = initial_forwards.get(args.local_port)
|
||||
touched_forward_ports = {args.local_port}
|
||||
private_gles_layer = f"/data/user/0/{args.package}/{RENDERDOC_GLES_LAYER}"
|
||||
private_layer_existed = (
|
||||
_adb(
|
||||
runner,
|
||||
serial,
|
||||
"shell",
|
||||
"run-as",
|
||||
args.package,
|
||||
"test",
|
||||
"-f",
|
||||
private_gles_layer,
|
||||
check=False,
|
||||
timeout=10,
|
||||
).returncode
|
||||
== 0
|
||||
)
|
||||
capture_process: subprocess.Popen[str] | None = None
|
||||
result: dict[str, object] = {}
|
||||
succeeded = False
|
||||
stop_renderdoc_server = False
|
||||
|
||||
try:
|
||||
if not args.no_setup_renderdoc:
|
||||
_setup_renderdoc(runner, serial, connected)
|
||||
setup_forwards = _forward_map(runner, serial)
|
||||
stop_renderdoc_server = any(
|
||||
remote == "localabstract:renderdoc_39920"
|
||||
and initial_forwards.get(port) != remote
|
||||
for port, remote in setup_forwards.items()
|
||||
)
|
||||
touched_forward_ports.update(
|
||||
port
|
||||
for port in set(initial_forwards) | set(setup_forwards)
|
||||
if initial_forwards.get(port) != setup_forwards.get(port)
|
||||
)
|
||||
original_forward = setup_forwards.get(args.local_port)
|
||||
|
||||
_adb(runner, serial, "shell", "am", "force-stop", args.package, timeout=15)
|
||||
_ensure_debug_settings(runner, serial, args.package, original_settings)
|
||||
if args.backend == "DirectVulkan":
|
||||
capopts = _encode_capture_options(args.soft_memory_limit)
|
||||
_set_property(runner, serial, "debug.rdoc.RENDERDOC_CAPOPTS", capopts)
|
||||
_set_property(runner, serial, "debug.gles.layers", ":")
|
||||
else:
|
||||
_adb(
|
||||
runner,
|
||||
serial,
|
||||
"shell",
|
||||
"run-as",
|
||||
args.package,
|
||||
"cp",
|
||||
REMOTE_GLES_LAYER,
|
||||
private_gles_layer,
|
||||
timeout=30,
|
||||
)
|
||||
_adb(
|
||||
runner,
|
||||
serial,
|
||||
"shell",
|
||||
"run-as",
|
||||
args.package,
|
||||
"chmod",
|
||||
"755",
|
||||
private_gles_layer,
|
||||
timeout=10,
|
||||
)
|
||||
_set_property(runner, serial, "debug.gles.layers", RENDERDOC_GLES_LAYER)
|
||||
|
||||
_adb(runner, serial, "push", trace, remote_trace, timeout=120)
|
||||
_adb(runner, serial, "push", golden, remote_golden, timeout=60)
|
||||
_adb(
|
||||
runner,
|
||||
serial,
|
||||
"shell",
|
||||
"run-as",
|
||||
args.package,
|
||||
"rm",
|
||||
"-rf",
|
||||
replay_root,
|
||||
timeout=20,
|
||||
)
|
||||
_adb(
|
||||
runner,
|
||||
serial,
|
||||
"shell",
|
||||
"run-as",
|
||||
args.package,
|
||||
"mkdir",
|
||||
"-p",
|
||||
replay_output,
|
||||
timeout=20,
|
||||
)
|
||||
|
||||
wanted_forward = f"localabstract:renderdoc_{args.target_ident}"
|
||||
if original_forward != wanted_forward:
|
||||
if original_forward is not None:
|
||||
_adb(
|
||||
runner,
|
||||
serial,
|
||||
"forward",
|
||||
"--remove",
|
||||
f"tcp:{args.local_port}",
|
||||
timeout=10,
|
||||
)
|
||||
_adb(
|
||||
runner,
|
||||
serial,
|
||||
"forward",
|
||||
f"tcp:{args.local_port}",
|
||||
wanted_forward,
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
queue_command = [
|
||||
sys.executable,
|
||||
str(queue_script),
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--ident",
|
||||
str(args.local_port),
|
||||
"--frame",
|
||||
str(capture_frame),
|
||||
"--connect-timeout",
|
||||
str(args.connect_timeout),
|
||||
"--capture-timeout",
|
||||
str(args.capture_timeout),
|
||||
"--json",
|
||||
]
|
||||
runner.commands.append(queue_command)
|
||||
print(f"+ {subprocess.list2cmdline(queue_command)}", file=sys.stderr, flush=True)
|
||||
capture_process = subprocess.Popen(
|
||||
queue_command,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
time.sleep(0.5)
|
||||
|
||||
component = f"{args.package}/{args.activity}"
|
||||
launch_command = [
|
||||
"adb",
|
||||
"-s",
|
||||
serial,
|
||||
"shell",
|
||||
"am",
|
||||
"start",
|
||||
"-W",
|
||||
"-a",
|
||||
args.action,
|
||||
"-n",
|
||||
component,
|
||||
"--es",
|
||||
"trace_path",
|
||||
remote_trace,
|
||||
"--es",
|
||||
"golden_path",
|
||||
remote_golden,
|
||||
"--es",
|
||||
"output_dir",
|
||||
replay_output,
|
||||
"--es",
|
||||
"diff_path",
|
||||
f"{replay_output}/diff.png",
|
||||
"--es",
|
||||
"backend",
|
||||
args.backend,
|
||||
"--el",
|
||||
"target_call",
|
||||
str(args.target_call),
|
||||
"--ei",
|
||||
"width",
|
||||
str(args.width),
|
||||
"--ei",
|
||||
"height",
|
||||
str(args.height),
|
||||
"--es",
|
||||
"ssim_threshold",
|
||||
str(args.ssim_threshold),
|
||||
"--ei",
|
||||
"crop_x",
|
||||
"0",
|
||||
"--ei",
|
||||
"crop_y",
|
||||
"0",
|
||||
"--ei",
|
||||
"crop_width",
|
||||
"0",
|
||||
"--ei",
|
||||
"crop_height",
|
||||
"0",
|
||||
"--ez",
|
||||
"coherent_as_flush",
|
||||
"true",
|
||||
]
|
||||
runner.run(launch_command, timeout=30)
|
||||
|
||||
stdout, stderr = capture_process.communicate(
|
||||
timeout=args.connect_timeout + args.capture_timeout + 15
|
||||
)
|
||||
capture_returncode = capture_process.returncode
|
||||
capture_process = None
|
||||
if stderr.strip():
|
||||
print(stderr, end="", file=sys.stderr)
|
||||
if not stdout.strip():
|
||||
raise RuntimeError("queue helper returned no JSON output")
|
||||
capture_result = json.loads(stdout)
|
||||
if capture_result.get("success") is not True:
|
||||
raise RuntimeError(f"RenderDoc capture failed: {capture_result.get('error')}")
|
||||
if capture_returncode != 0:
|
||||
raise RuntimeError(f"queue helper exited with {capture_returncode}")
|
||||
capture = capture_result["capture"]
|
||||
if not isinstance(capture, dict) or not capture.get("path"):
|
||||
raise RuntimeError(f"queue helper returned invalid capture metadata: {capture!r}")
|
||||
|
||||
_adb(runner, serial, "pull", capture["path"], output, timeout=180)
|
||||
if not output.is_file() or output.stat().st_size <= 0:
|
||||
raise RuntimeError(f"adb pull did not produce a non-empty RDC: {output}")
|
||||
retrace_result = _read_remote_json(
|
||||
runner, serial, args.package, remote_result
|
||||
)
|
||||
result = {
|
||||
"success": True,
|
||||
"deviceSerial": serial,
|
||||
"backend": args.backend,
|
||||
"targetCall": args.target_call,
|
||||
"targetSwap": target_swap,
|
||||
"targetSwapCall": target_swap_call,
|
||||
"targetSwapMapping": mapping,
|
||||
"captureFrame": capture_frame,
|
||||
"capture": capture,
|
||||
"retraceResult": retrace_result,
|
||||
"output": str(output),
|
||||
"byteSize": output.stat().st_size,
|
||||
"sha256": _sha256(output),
|
||||
}
|
||||
succeeded = True
|
||||
except Exception as exc: # noqa: BLE001 - stable CLI error boundary
|
||||
result = {
|
||||
"success": False,
|
||||
"deviceSerial": serial,
|
||||
"backend": args.backend,
|
||||
"targetCall": args.target_call,
|
||||
"targetSwap": target_swap,
|
||||
"captureFrame": capture_frame,
|
||||
"output": str(output),
|
||||
"error": str(exc),
|
||||
}
|
||||
finally:
|
||||
if capture_process is not None and capture_process.poll() is None:
|
||||
capture_process.kill()
|
||||
capture_process.wait(timeout=10)
|
||||
if not args.keep_device_state:
|
||||
try:
|
||||
_set_property(
|
||||
runner,
|
||||
serial,
|
||||
"debug.rdoc.RENDERDOC_CAPOPTS",
|
||||
original_capopts,
|
||||
)
|
||||
_set_property(runner, serial, "debug.gles.layers", original_gles_layers or ":")
|
||||
for key, value in original_settings.items():
|
||||
_set_setting(runner, serial, key, value)
|
||||
if args.backend == "DirectGLES" and not private_layer_existed:
|
||||
_adb(
|
||||
runner,
|
||||
serial,
|
||||
"shell",
|
||||
"run-as",
|
||||
args.package,
|
||||
"rm",
|
||||
"-f",
|
||||
private_gles_layer,
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
_adb(
|
||||
runner,
|
||||
serial,
|
||||
"shell",
|
||||
"am",
|
||||
"force-stop",
|
||||
args.package,
|
||||
check=False,
|
||||
timeout=15,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"warning: device state cleanup failed: {exc}", file=sys.stderr)
|
||||
try:
|
||||
current_forwards = _forward_map(runner, serial)
|
||||
for port in sorted(touched_forward_ports):
|
||||
current = current_forwards.get(port)
|
||||
original = initial_forwards.get(port)
|
||||
if current == original:
|
||||
continue
|
||||
if current is not None:
|
||||
_adb(
|
||||
runner,
|
||||
serial,
|
||||
"forward",
|
||||
"--remove",
|
||||
f"tcp:{port}",
|
||||
check=False,
|
||||
timeout=10,
|
||||
)
|
||||
if original is not None:
|
||||
_adb(
|
||||
runner,
|
||||
serial,
|
||||
"forward",
|
||||
f"tcp:{port}",
|
||||
original,
|
||||
timeout=10,
|
||||
)
|
||||
if stop_renderdoc_server:
|
||||
_adb(
|
||||
runner,
|
||||
serial,
|
||||
"shell",
|
||||
"am",
|
||||
"force-stop",
|
||||
RENDERDOC_CMD_PACKAGE,
|
||||
check=False,
|
||||
timeout=15,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"warning: adb forward cleanup failed: {exc}", file=sys.stderr)
|
||||
|
||||
if not args.keep_device_files:
|
||||
try:
|
||||
_adb(
|
||||
runner,
|
||||
serial,
|
||||
"shell",
|
||||
"rm",
|
||||
"-f",
|
||||
remote_trace,
|
||||
remote_golden,
|
||||
check=False,
|
||||
timeout=20,
|
||||
)
|
||||
_adb(
|
||||
runner,
|
||||
serial,
|
||||
"shell",
|
||||
"run-as",
|
||||
args.package,
|
||||
"rm",
|
||||
"-rf",
|
||||
replay_root,
|
||||
check=False,
|
||||
timeout=20,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"warning: device file cleanup failed: {exc}", file=sys.stderr)
|
||||
|
||||
result["commands"] = runner.commands
|
||||
result["commandLines"] = [
|
||||
subprocess.list2cmdline(command) for command in runner.commands
|
||||
]
|
||||
try:
|
||||
_write_manifest(manifest_path, result)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
print(f"warning: failed to write manifest: {exc}", file=sys.stderr)
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
elif succeeded:
|
||||
print(output)
|
||||
print(f"manifest: {manifest_path}", file=sys.stderr)
|
||||
print(f"sha256: {result['sha256']}", file=sys.stderr)
|
||||
else:
|
||||
print(f"error: {result['error']}", file=sys.stderr)
|
||||
return 0 if succeeded else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Queue an exact RenderDoc frame through an Android TargetControl socket."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from rdc.discover import find_renderdoc
|
||||
|
||||
|
||||
MESSAGE_DISCONNECTED = 1
|
||||
MESSAGE_NOOP = 3
|
||||
MESSAGE_NEW_CAPTURE = 4
|
||||
|
||||
|
||||
def _drain_pending_messages(target: Any) -> None:
|
||||
"""Consume asynchronous registration messages before queueing a capture."""
|
||||
for _ in range(20):
|
||||
message = target.ReceiveMessage(None)
|
||||
message_type = int(message.type)
|
||||
if message_type == MESSAGE_NOOP:
|
||||
return
|
||||
if message_type == MESSAGE_DISCONNECTED:
|
||||
raise RuntimeError("RenderDoc target disconnected during initial message drain")
|
||||
|
||||
|
||||
def _connect(rd: Any, host: str, ident: int, timeout: float) -> Any:
|
||||
deadline = time.monotonic() + timeout
|
||||
last_error = "target is not listening"
|
||||
while time.monotonic() < deadline:
|
||||
target = None
|
||||
try:
|
||||
target = rd.CreateTargetControl(host, ident, "renderdoc-trace-capture", True)
|
||||
if target is not None and target.Connected():
|
||||
return target
|
||||
last_error = "CreateTargetControl returned a disconnected target"
|
||||
except Exception as exc: # noqa: BLE001 - RenderDoc raises SWIG exceptions
|
||||
last_error = str(exc)
|
||||
if target is not None:
|
||||
target.Shutdown()
|
||||
time.sleep(0.05)
|
||||
raise RuntimeError(
|
||||
f"failed to connect to RenderDoc target {host}:{ident} within {timeout:g}s: "
|
||||
f"{last_error}"
|
||||
)
|
||||
|
||||
|
||||
def queue_capture(
|
||||
*,
|
||||
host: str,
|
||||
ident: int,
|
||||
frame: int,
|
||||
count: int,
|
||||
connect_timeout: float,
|
||||
capture_timeout: float,
|
||||
) -> dict[str, object]:
|
||||
rd = find_renderdoc()
|
||||
if rd is None:
|
||||
raise RuntimeError("RenderDoc Python module not found; run 'rdc setup-renderdoc'")
|
||||
|
||||
target = _connect(rd, host, ident, connect_timeout)
|
||||
started = time.monotonic()
|
||||
try:
|
||||
_drain_pending_messages(target)
|
||||
target.QueueCapture(frame, count)
|
||||
deadline = time.monotonic() + capture_timeout
|
||||
while time.monotonic() < deadline:
|
||||
if not target.Connected():
|
||||
raise RuntimeError("RenderDoc target disconnected before capture completed")
|
||||
message = target.ReceiveMessage(None)
|
||||
message_type = int(message.type)
|
||||
if message_type == MESSAGE_NEW_CAPTURE and message.newCapture is not None:
|
||||
capture = message.newCapture
|
||||
return {
|
||||
"queued": True,
|
||||
"host": host,
|
||||
"ident": ident,
|
||||
"frame": frame,
|
||||
"count": count,
|
||||
"elapsedSeconds": round(time.monotonic() - started, 3),
|
||||
"capture": {
|
||||
"captureId": capture.captureId,
|
||||
"path": capture.path,
|
||||
"frame": capture.frameNumber,
|
||||
"byteSize": capture.byteSize,
|
||||
"api": capture.api,
|
||||
"local": capture.local,
|
||||
},
|
||||
}
|
||||
if message_type == MESSAGE_DISCONNECTED:
|
||||
raise RuntimeError("RenderDoc target disconnected before capture completed")
|
||||
time.sleep(0.01)
|
||||
raise RuntimeError(
|
||||
f"timed out after {capture_timeout:g}s waiting for frame {frame} capture"
|
||||
)
|
||||
finally:
|
||||
target.Shutdown()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"Wait for an Android RenderDoc TargetControl socket, queue an exact frame, "
|
||||
"and keep the connection alive until the RDC is complete."
|
||||
)
|
||||
)
|
||||
parser.add_argument("--host", default="127.0.0.1")
|
||||
parser.add_argument("--ident", type=int, default=38920)
|
||||
parser.add_argument("--frame", type=int, required=True)
|
||||
parser.add_argument("--count", type=int, default=1)
|
||||
parser.add_argument("--connect-timeout", type=float, default=20.0)
|
||||
parser.add_argument("--capture-timeout", type=float, default=90.0)
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
result = queue_capture(
|
||||
host=args.host,
|
||||
ident=args.ident,
|
||||
frame=args.frame,
|
||||
count=args.count,
|
||||
connect_timeout=args.connect_timeout,
|
||||
capture_timeout=args.capture_timeout,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - convert tool failures to stable CLI output
|
||||
if args.json:
|
||||
print(json.dumps({"success": False, "error": str(exc)}))
|
||||
else:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
if args.json:
|
||||
print(json.dumps({"success": True, **result}))
|
||||
else:
|
||||
capture = result["capture"]
|
||||
assert isinstance(capture, dict)
|
||||
print(capture["path"])
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,137 @@
|
||||
---
|
||||
name: trace-fixture-authoring-on-android-fcl
|
||||
description: Capture an on-device Android apitrace from FCL's MobileGL renderers. Use when preparing a reproducible MobileGL DirectGLES, Magma (DirectVulkan), or SimpleFPEWrapper rendering trace, marking the frame of a visual defect, pulling the resulting full.trace, or turning a device capture into a replay fixture.
|
||||
---
|
||||
|
||||
# MobileGL Android trace capture
|
||||
|
||||
## Overview
|
||||
|
||||
Use FCL's Android `egltrace.so` wrapper, not Perfetto. When enabled before
|
||||
launch, it records the complete EGL/GL call stream to `full.trace`. The game's
|
||||
**MobileGL Trace → Capture** control marks the next swap frame in
|
||||
`capture-result.json`; it does not start or stop recording and does not produce
|
||||
a one-frame trace by itself.
|
||||
|
||||
Run commands from the FoldCraftLauncher repository root:
|
||||
|
||||
```sh
|
||||
export REPO="$PWD"
|
||||
export CAPTURE="$REPO/MobileGL/tools/trace_replay/skills/trace-fixture-authoring-on-android-fcl/scripts"
|
||||
export SERIAL=<adb-device-serial> # omit --serial only if exactly one device is attached
|
||||
```
|
||||
|
||||
The capture scripts are bundled inside this skill under `scripts/`; they
|
||||
auto-detect the FoldCraftLauncher repository root from their own location, so
|
||||
`--repo` only needs to be passed for a non-standard checkout layout.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Use an FCL build containing `MobileGLTraceCapture` and the in-game Capture
|
||||
menu entry.
|
||||
- Select one of these renderers: MobileGL (DirectGLES), MobileGL Magma
|
||||
(DirectVulkan), or SimpleFPEWrapper. MobileGlues is not supported by this
|
||||
capture wrapper.
|
||||
- Install `adb` and make it available on `PATH`; authorize USB debugging.
|
||||
- Build the wrapper with Android NDK, CMake, Ninja, Python 3, and the checked
|
||||
out in-tree `MobileGL/3rdparty/apitrace` submodule.
|
||||
|
||||
Confirm the attached device and ABI before building. The wrapper ABI must match
|
||||
the device process ABI.
|
||||
|
||||
```sh
|
||||
adb devices -l
|
||||
adb -s "$SERIAL" shell getprop ro.product.cpu.abi
|
||||
```
|
||||
|
||||
Use `arm64-v8a` for the usual `arm64-v8a` result; use the matching NDK ABI for
|
||||
other devices.
|
||||
|
||||
## Build and install the wrapper
|
||||
|
||||
Build once per ABI or after changing apitrace/wrapper sources:
|
||||
|
||||
```sh
|
||||
python3 "$CAPTURE/build_android_egltrace.py" --abi arm64-v8a
|
||||
```
|
||||
|
||||
This generates `egltrace.so` under the skill's `scripts/out/` directory. Push
|
||||
it and write FCL's enable sentinel before launching the game:
|
||||
|
||||
```sh
|
||||
python3 "$CAPTURE/adb_capture.py" --serial "$SERIAL" install-wrapper
|
||||
python3 "$CAPTURE/adb_capture.py" --serial "$SERIAL" enable
|
||||
```
|
||||
|
||||
The device-side control directory is `/sdcard/FCL/mobilegl-trace`. FCL copies
|
||||
the shared `egltrace.so` into its private files directory at launch, replaces
|
||||
the renderer's EGL library with it, and forwards to the real MobileGL library.
|
||||
|
||||
## Capture a reproduction
|
||||
|
||||
1. Start FCL after the wrapper and enable sentinel are in place. Select the
|
||||
intended supported MobileGL renderer and launch the game.
|
||||
2. Trace mode forces the game to `854x480`; account for that when reproducing
|
||||
and comparing output.
|
||||
3. Reproduce the issue. Start close to the target scene because tracing starts
|
||||
when the game launches and trace files can grow rapidly.
|
||||
4. At the desired visual state, open FCL's right-side game menu and press
|
||||
**MobileGL Trace → Capture**. Let at least one frame present afterward.
|
||||
5. Exit the game cleanly, then pull the latest session:
|
||||
|
||||
```sh
|
||||
python3 "$CAPTURE/adb_capture.py" --serial "$SERIAL" pull-latest
|
||||
```
|
||||
|
||||
The default local result is:
|
||||
|
||||
```text
|
||||
.trace-work/pulled-mobilegl-captures/capture-YYYYMMDD-HHMMSS-<renderer>/
|
||||
full.trace
|
||||
capture-status.json
|
||||
capture-result.json
|
||||
```
|
||||
|
||||
`capture-result.json` must show `"status": "captured"`. Its `targetFrame`
|
||||
is the one-based swap count used by `gltrim`; `zeroBasedFrame` is included for
|
||||
tools that use zero-based indexing.
|
||||
|
||||
## Diagnose setup failures
|
||||
|
||||
Inspect the active device session directly:
|
||||
|
||||
```sh
|
||||
adb -s "$SERIAL" shell cat /sdcard/FCL/mobilegl-trace/latest-session.txt
|
||||
adb -s "$SERIAL" shell ls -lh /sdcard/FCL/mobilegl-trace
|
||||
adb -s "$SERIAL" shell cat /sdcard/FCL/mobilegl-trace/capture-*/capture-status.json
|
||||
adb -s "$SERIAL" shell cat /sdcard/FCL/mobilegl-trace/capture-*/capture-result.json
|
||||
```
|
||||
|
||||
If `capture-status.json` reports a missing `egltrace.so`, rebuild/push the
|
||||
correct ABI and relaunch. If `capture-result.json` is absent, Capture was
|
||||
pressed without an active trace session, or no subsequent `eglSwapBuffers`
|
||||
occurred. The menu button itself only writes `capture-once.request`.
|
||||
|
||||
Disable tracing when finished; otherwise the next supported MobileGL launch
|
||||
will trace again:
|
||||
|
||||
```sh
|
||||
python3 "$CAPTURE/adb_capture.py" --serial "$SERIAL" disable
|
||||
```
|
||||
|
||||
## Create a replay fixture (optional)
|
||||
|
||||
Keep the raw `full.trace` until replay validation succeeds. To frame-trim and
|
||||
package the marked frame for MobileGL trace replay, use the existing helper:
|
||||
|
||||
```sh
|
||||
python3 "$CAPTURE/package_capture_fixture.py" \
|
||||
--serial "$SERIAL" \
|
||||
--case <case-name> \
|
||||
--apitrace <path-to-in-tree-apitrace>
|
||||
```
|
||||
|
||||
It pulls the latest capture if necessary, uses `capture-result.json` to select
|
||||
the frame, runs `apitrace gltrim`, creates a golden image, and enforces the
|
||||
fixture archive-size limit. Follow `../trace-fixture-authoring/SKILL.md` for
|
||||
deterministic scene setup, verification, and registry changes.
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "Trace Fixture Authoring on Android (FCL)"
|
||||
short_description: "Capture and package a MobileGL trace fixture on Android FCL"
|
||||
default_prompt: "Use $trace-fixture-authoring-on-android-fcl to capture a MobileGL trace on my Android device and package it into a replay fixture."
|
||||
@@ -0,0 +1,3 @@
|
||||
# Build output produced by build_android_egltrace.py (ABI-specific, regenerated).
|
||||
out/
|
||||
__pycache__/
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REMOTE_ROOT = "/sdcard/FCL/mobilegl-trace"
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
DEFAULT_WRAPPER = SCRIPT_DIR / "out" / "egltrace.so"
|
||||
|
||||
|
||||
def adb(serial, args):
|
||||
cmd = ["adb"]
|
||||
if serial:
|
||||
cmd += ["-s", serial]
|
||||
cmd += args
|
||||
print("+", " ".join(cmd), flush=True)
|
||||
subprocess.run(cmd, check=True)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--serial")
|
||||
sub = parser.add_subparsers(dest="command", required=True)
|
||||
|
||||
install = sub.add_parser("install-wrapper")
|
||||
install.add_argument("--wrapper", default=str(DEFAULT_WRAPPER))
|
||||
|
||||
sub.add_parser("enable")
|
||||
sub.add_parser("disable")
|
||||
sub.add_parser("capture-once")
|
||||
|
||||
pull = sub.add_parser("pull-latest")
|
||||
pull.add_argument("--output", default=".trace-work/pulled-mobilegl-captures")
|
||||
|
||||
args = parser.parse_args()
|
||||
serial = args.serial
|
||||
|
||||
if args.command == "install-wrapper":
|
||||
wrapper = Path(args.wrapper)
|
||||
if not wrapper.exists():
|
||||
raise SystemExit(f"missing wrapper: {wrapper}")
|
||||
adb(serial, ["shell", "mkdir", "-p", REMOTE_ROOT])
|
||||
adb(serial, ["push", str(wrapper), f"{REMOTE_ROOT}/egltrace.so"])
|
||||
elif args.command == "enable":
|
||||
adb(serial, ["shell", "mkdir", "-p", REMOTE_ROOT])
|
||||
adb(serial, ["shell", f"printf enabled > {REMOTE_ROOT}/enable"])
|
||||
elif args.command == "disable":
|
||||
adb(serial, ["shell", "rm", "-f", f"{REMOTE_ROOT}/enable"])
|
||||
elif args.command == "capture-once":
|
||||
adb(serial, ["shell", "mkdir", "-p", REMOTE_ROOT])
|
||||
adb(serial, ["shell", f"date +%s%3N > {REMOTE_ROOT}/capture-once.request"])
|
||||
elif args.command == "pull-latest":
|
||||
tmp = subprocess.check_output((["adb"] + (["-s", serial] if serial else []) +
|
||||
["shell", "cat", f"{REMOTE_ROOT}/latest-session.txt"]),
|
||||
text=True, encoding="utf-8", errors="replace").strip()
|
||||
if not tmp:
|
||||
raise SystemExit("no latest-session.txt on device")
|
||||
output = Path(args.output)
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
adb(serial, ["pull", tmp, str(output / Path(tmp).name)])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+191
@@ -0,0 +1,191 @@
|
||||
cmake_minimum_required(VERSION 3.22.1)
|
||||
|
||||
project(mobilegl_android_egltrace)
|
||||
|
||||
set(APITRACE_ROOT "" CACHE PATH "Path to apitrace source tree")
|
||||
set(PATCHED_EGLTRACE_CPP "" CACHE FILEPATH "Generated and patched egltrace.cpp")
|
||||
set(PATCHED_GLPROC_EGL_CPP "" CACHE FILEPATH "Patched glproc_egl.cpp")
|
||||
if(NOT EXISTS "${APITRACE_ROOT}/wrappers")
|
||||
message(FATAL_ERROR "APITRACE_ROOT must point to apitrace")
|
||||
endif()
|
||||
if(NOT EXISTS "${PATCHED_EGLTRACE_CPP}")
|
||||
message(FATAL_ERROR "PATCHED_EGLTRACE_CPP is required")
|
||||
endif()
|
||||
if(NOT EXISTS "${PATCHED_GLPROC_EGL_CPP}")
|
||||
message(FATAL_ERROR "PATCHED_GLPROC_EGL_CPP is required")
|
||||
endif()
|
||||
|
||||
set(APITRACE_BINARY_DIR "${CMAKE_CURRENT_BINARY_DIR}/apitrace")
|
||||
set(APITRACE_VERSION "mobilegl-capture")
|
||||
|
||||
find_package(Python3 REQUIRED)
|
||||
find_package(Threads REQUIRED)
|
||||
|
||||
include("${APITRACE_ROOT}/cmake/ConvenienceLibrary.cmake")
|
||||
|
||||
set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
|
||||
set(ENABLE_STATIC_SNAPPY ON CACHE BOOL "" FORCE)
|
||||
set(DOC_INSTALL_DIR "doc" CACHE PATH "" FORCE)
|
||||
set(HAVE_X86 OFF CACHE BOOL "" FORCE)
|
||||
set(ZLIB_FOUND OFF CACHE BOOL "" FORCE)
|
||||
set(PNG_FOUND OFF CACHE BOOL "" FORCE)
|
||||
set(Snappy_FOUND OFF CACHE BOOL "" FORCE)
|
||||
set(BROTLIDEC_FOUND OFF CACHE BOOL "" FORCE)
|
||||
set(BROTLIENC_FOUND OFF CACHE BOOL "" FORCE)
|
||||
set(ZSTD_FOUND OFF CACHE BOOL "" FORCE)
|
||||
set(CMAKE_EXECUTABLE_FORMAT "MobileGLAndroid" CACHE INTERNAL "" FORCE)
|
||||
|
||||
add_custom_target(check)
|
||||
add_subdirectory("${APITRACE_ROOT}/thirdparty" "${APITRACE_BINARY_DIR}/thirdparty")
|
||||
|
||||
set(APITRACE_GENERATED_DIR "${APITRACE_BINARY_DIR}/generated")
|
||||
file(MAKE_DIRECTORY "${APITRACE_GENERATED_DIR}")
|
||||
configure_file("${APITRACE_ROOT}/version.h.in" "${APITRACE_GENERATED_DIR}/version.h" @ONLY)
|
||||
|
||||
add_custom_command(
|
||||
OUTPUT
|
||||
"${APITRACE_GENERATED_DIR}/glproc.hpp"
|
||||
"${APITRACE_GENERATED_DIR}/glproc.cpp"
|
||||
COMMAND ${Python3_EXECUTABLE}
|
||||
"${APITRACE_ROOT}/dispatch/glproc.py"
|
||||
"${APITRACE_GENERATED_DIR}/glproc.hpp"
|
||||
"${APITRACE_GENERATED_DIR}/glproc.cpp"
|
||||
DEPENDS
|
||||
"${APITRACE_ROOT}/dispatch/glproc.py"
|
||||
"${APITRACE_ROOT}/dispatch/dispatch.py"
|
||||
"${APITRACE_ROOT}/specs/wglapi.py"
|
||||
"${APITRACE_ROOT}/specs/glxapi.py"
|
||||
"${APITRACE_ROOT}/specs/cglapi.py"
|
||||
"${APITRACE_ROOT}/specs/eglapi.py"
|
||||
"${APITRACE_ROOT}/specs/glapi.py"
|
||||
"${APITRACE_ROOT}/specs/gltypes.py"
|
||||
"${APITRACE_ROOT}/specs/stdapi.py")
|
||||
|
||||
add_library(apitrace_os STATIC
|
||||
"${APITRACE_ROOT}/lib/os/os_backtrace.cpp"
|
||||
"${APITRACE_ROOT}/lib/os/os_crtdbg.cpp"
|
||||
"${APITRACE_ROOT}/lib/os/os_posix.cpp")
|
||||
target_include_directories(apitrace_os PUBLIC
|
||||
"${APITRACE_ROOT}/compat"
|
||||
"${APITRACE_ROOT}/thirdparty"
|
||||
"${APITRACE_ROOT}/lib/os"
|
||||
"${APITRACE_ROOT}/lib/trace")
|
||||
target_link_libraries(apitrace_os PUBLIC Threads::Threads)
|
||||
|
||||
add_library(glproc STATIC
|
||||
"${APITRACE_GENERATED_DIR}/glproc.cpp"
|
||||
"${PATCHED_GLPROC_EGL_CPP}")
|
||||
target_include_directories(glproc PUBLIC
|
||||
"${APITRACE_GENERATED_DIR}"
|
||||
"${APITRACE_ROOT}/wrappers"
|
||||
"${APITRACE_ROOT}/dispatch"
|
||||
"${APITRACE_ROOT}/lib/os"
|
||||
"${APITRACE_ROOT}/thirdparty/khronos")
|
||||
target_link_libraries(glproc PUBLIC apitrace_os dl)
|
||||
|
||||
add_library(highlight STATIC "${APITRACE_ROOT}/lib/highlight/highlight.cpp")
|
||||
target_include_directories(highlight PUBLIC "${APITRACE_ROOT}/lib/highlight")
|
||||
|
||||
add_library(guids STATIC "${APITRACE_ROOT}/lib/guids/guids.cpp")
|
||||
target_include_directories(guids PUBLIC
|
||||
"${APITRACE_ROOT}/lib/guids"
|
||||
"${APITRACE_ROOT}/lib/os")
|
||||
|
||||
add_library(common STATIC
|
||||
"${APITRACE_ROOT}/lib/trace/trace_callset.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_dump.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_fast_callset.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_file.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_file_read.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_file_zlib.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_file_brotli.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_file_snappy.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_file_zstd.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_file_zstd_seekable.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_model.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_option.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_ostream_snappy.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_ostream_zlib.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_ostream_zstd.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_parser.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_parser_flags.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_parser_loop.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_profiler.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_writer.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_writer_local.cpp"
|
||||
"${APITRACE_ROOT}/lib/trace/trace_writer_model.cpp")
|
||||
target_include_directories(common PUBLIC
|
||||
"${APITRACE_ROOT}/compat"
|
||||
"${APITRACE_ROOT}/thirdparty"
|
||||
"${APITRACE_ROOT}/lib/guids"
|
||||
"${APITRACE_ROOT}/lib/highlight"
|
||||
"${APITRACE_ROOT}/lib/os"
|
||||
"${APITRACE_ROOT}/lib/trace"
|
||||
"${APITRACE_ROOT}/lib/ubjson")
|
||||
target_link_libraries(common PUBLIC
|
||||
guids
|
||||
highlight
|
||||
apitrace_os
|
||||
Snappy::snappy
|
||||
ZLIB::ZLIB
|
||||
PkgConfig::BROTLIDEC
|
||||
PkgConfig::ZSTD
|
||||
zstd_seekable)
|
||||
|
||||
add_convenience_library(trace
|
||||
"${APITRACE_ROOT}/wrappers/memtrace.hpp"
|
||||
"${APITRACE_ROOT}/wrappers/memtrace.cpp")
|
||||
target_include_directories(trace PUBLIC
|
||||
"${APITRACE_ROOT}/thirdparty/crc32c")
|
||||
target_link_libraries(trace
|
||||
common
|
||||
guids
|
||||
crc32c)
|
||||
|
||||
add_library(glhelpers STATIC
|
||||
"${APITRACE_ROOT}/helpers/glfeatures.cpp"
|
||||
"${APITRACE_ROOT}/helpers/eglsize.cpp")
|
||||
target_include_directories(glhelpers PUBLIC
|
||||
"${APITRACE_GENERATED_DIR}"
|
||||
"${APITRACE_ROOT}/dispatch"
|
||||
"${APITRACE_ROOT}/helpers"
|
||||
"${APITRACE_ROOT}/lib/os"
|
||||
"${APITRACE_ROOT}/thirdparty/khronos")
|
||||
target_link_libraries(glhelpers PUBLIC glproc apitrace_os)
|
||||
|
||||
add_convenience_library(gltrace_common
|
||||
"${APITRACE_ROOT}/wrappers/glcaps.cpp"
|
||||
"${APITRACE_ROOT}/wrappers/config.cpp"
|
||||
"${APITRACE_ROOT}/wrappers/gltrace_arrays.cpp"
|
||||
"${APITRACE_ROOT}/wrappers/gltrace_state.cpp"
|
||||
"${APITRACE_ROOT}/wrappers/glmemshadow.hpp"
|
||||
"${APITRACE_ROOT}/wrappers/glmemshadow.cpp"
|
||||
"${APITRACE_ROOT}/wrappers/gltrace_unpack_compressed.hpp"
|
||||
"${APITRACE_ROOT}/wrappers/gltrace_unpack_compressed.cpp")
|
||||
add_dependencies(gltrace_common glproc)
|
||||
target_include_directories(gltrace_common PUBLIC
|
||||
"${APITRACE_ROOT}/wrappers")
|
||||
target_link_libraries(gltrace_common
|
||||
glhelpers
|
||||
trace)
|
||||
|
||||
add_library(egltrace SHARED
|
||||
"${PATCHED_EGLTRACE_CPP}"
|
||||
"${APITRACE_ROOT}/wrappers/dlsym.cpp"
|
||||
"${PATCHED_GLPROC_EGL_CPP}")
|
||||
add_dependencies(egltrace glproc)
|
||||
set_target_properties(egltrace PROPERTIES PREFIX "")
|
||||
target_compile_definitions(egltrace PRIVATE -DEGLTRACE=1)
|
||||
target_include_directories(egltrace PRIVATE
|
||||
"${APITRACE_ROOT}/wrappers"
|
||||
"${APITRACE_GENERATED_DIR}"
|
||||
"${APITRACE_ROOT}/helpers"
|
||||
"${APITRACE_ROOT}/dispatch"
|
||||
"${APITRACE_ROOT}/lib/os"
|
||||
"${APITRACE_ROOT}/lib/trace"
|
||||
"${APITRACE_ROOT}/thirdparty/khronos")
|
||||
target_link_libraries(egltrace
|
||||
gltrace_common
|
||||
glproc
|
||||
Threads::Threads
|
||||
dl)
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# This script is bundled inside the trace-fixture-authoring-on-android-fcl skill at
|
||||
# <FCL>/MobileGL/tools/trace_replay/skills/trace-fixture-authoring-on-android-fcl/scripts/.
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
DEFAULT_REPO = SCRIPT_DIR.parents[5] # -> FoldCraftLauncher repo root
|
||||
DEFAULT_OUTPUT = SCRIPT_DIR / "out" / "egltrace.so"
|
||||
|
||||
|
||||
def run(cmd, cwd=None):
|
||||
print("+", " ".join(str(part) for part in cmd), flush=True)
|
||||
subprocess.run(cmd, cwd=cwd, check=True)
|
||||
|
||||
|
||||
def find_ndk(repo):
|
||||
for key in ("ANDROID_NDK_HOME", "ANDROID_NDK_ROOT"):
|
||||
value = os.environ.get(key)
|
||||
if value:
|
||||
return Path(value)
|
||||
for key in ("ANDROID_HOME", "ANDROID_SDK_ROOT"):
|
||||
value = os.environ.get(key)
|
||||
if value:
|
||||
ndk_root = Path(value) / "ndk"
|
||||
if ndk_root.exists():
|
||||
versions = sorted([p for p in ndk_root.iterdir() if p.is_dir()])
|
||||
if versions:
|
||||
return versions[-1]
|
||||
local = repo / "local.properties"
|
||||
if local.exists():
|
||||
sdk = None
|
||||
ndk = None
|
||||
for line in local.read_text(encoding="utf-8", errors="ignore").splitlines():
|
||||
if line.startswith("sdk.dir="):
|
||||
sdk = Path(line.split("=", 1)[1].replace("\\:", ":"))
|
||||
if line.startswith("ndk.dir="):
|
||||
ndk = Path(line.split("=", 1)[1].replace("\\:", ":"))
|
||||
if ndk:
|
||||
return ndk
|
||||
if sdk:
|
||||
ndk_root = sdk / "ndk"
|
||||
if ndk_root.exists():
|
||||
versions = sorted([p for p in ndk_root.iterdir() if p.is_dir()])
|
||||
if versions:
|
||||
return versions[-1]
|
||||
raise SystemExit("Android NDK not found; set ANDROID_NDK_HOME or local.properties sdk.dir/ndk.dir")
|
||||
|
||||
|
||||
def generate_and_patch(repo, build_dir):
|
||||
wrapper_dir = repo / "MobileGL" / "3rdparty" / "apitrace" / "wrappers"
|
||||
generated = build_dir / "patched" / "egltrace.cpp"
|
||||
generated.parent.mkdir(parents=True, exist_ok=True)
|
||||
with generated.open("w", encoding="utf-8", newline="\n") as out:
|
||||
subprocess.run([sys.executable, str(wrapper_dir / "egltrace.py")], cwd=wrapper_dir, stdout=out, check=True)
|
||||
|
||||
text = generated.read_text(encoding="utf-8")
|
||||
helper = r'''
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
|
||||
static unsigned long long mobilegl_capture_swap_count = 0;
|
||||
|
||||
static long long mobilegl_capture_time_ms(void) {
|
||||
struct timespec ts;
|
||||
clock_gettime(CLOCK_REALTIME, &ts);
|
||||
return (long long) ts.tv_sec * 1000LL + ts.tv_nsec / 1000000LL;
|
||||
}
|
||||
|
||||
static int mobilegl_capture_exists(const char *path) {
|
||||
return path != NULL && path[0] != '\0' && access(path, F_OK) == 0;
|
||||
}
|
||||
|
||||
static void mobilegl_capture_record_request(void) {
|
||||
++mobilegl_capture_swap_count;
|
||||
const char *request = getenv("MOBILEGL_TRACE_CAPTURE_REQUEST_FILE");
|
||||
const char *output = getenv("MOBILEGL_TRACE_CAPTURE_FRAME_FILE");
|
||||
if (!mobilegl_capture_exists(request) || output == NULL || output[0] == '\0') {
|
||||
return;
|
||||
}
|
||||
unlink(request);
|
||||
FILE *file = fopen(output, "w");
|
||||
if (file == NULL) {
|
||||
return;
|
||||
}
|
||||
const char *trace_file = getenv("TRACE_FILE");
|
||||
fprintf(file,
|
||||
"{\n"
|
||||
" \"status\": \"captured\",\n"
|
||||
" \"swapCount\": %llu,\n"
|
||||
" \"targetFrame\": %llu,\n"
|
||||
" \"zeroBasedFrame\": %llu,\n"
|
||||
" \"capturedAtMs\": %lld,\n"
|
||||
" \"traceFile\": \"%s\"\n"
|
||||
"}\n",
|
||||
mobilegl_capture_swap_count,
|
||||
mobilegl_capture_swap_count,
|
||||
mobilegl_capture_swap_count == 0 ? 0 : mobilegl_capture_swap_count - 1,
|
||||
mobilegl_capture_time_ms(),
|
||||
trace_file == NULL ? "" : trace_file);
|
||||
fclose(file);
|
||||
}
|
||||
'''
|
||||
insert_at = text.find("#include")
|
||||
if insert_at < 0:
|
||||
raise SystemExit("generated egltrace.cpp has no include block")
|
||||
next_block = text.find("\n\n", insert_at)
|
||||
text = text[:next_block] + "\n" + helper + text[next_block:]
|
||||
|
||||
needle = "EGLBoolean EGLAPIENTRY eglSwapBuffers(EGLDisplay dpy, EGLSurface surface)"
|
||||
start = text.find(needle)
|
||||
if start < 0:
|
||||
raise SystemExit("generated egltrace.cpp has no eglSwapBuffers wrapper to patch")
|
||||
brace = text.find("{", start)
|
||||
if brace < 0:
|
||||
raise SystemExit("eglSwapBuffers wrapper has no function body")
|
||||
text = text[:brace + 1] + "\n mobilegl_capture_record_request();" + text[brace + 1:]
|
||||
generated.write_text(text, encoding="utf-8", newline="\n")
|
||||
return generated
|
||||
|
||||
|
||||
def patch_glproc_egl(repo, build_dir):
|
||||
source = repo / "MobileGL" / "3rdparty" / "apitrace" / "wrappers" / "glproc_egl.cpp"
|
||||
patched = build_dir / "patched" / "glproc_egl.cpp"
|
||||
patched.parent.mkdir(parents=True, exist_ok=True)
|
||||
text = source.read_text(encoding="utf-8")
|
||||
text = text.replace('#include "dlopen.hpp"\n', '#include "dlopen.hpp"\n#include <stdlib.h>\n')
|
||||
needle = """void *
|
||||
_getPublicProcAddress(const char *procName)
|
||||
{
|
||||
void *proc;
|
||||
|
||||
"""
|
||||
replacement = """void *
|
||||
_getPublicProcAddress(const char *procName)
|
||||
{
|
||||
void *proc;
|
||||
|
||||
static void *traceLibGL = NULL;
|
||||
static bool triedTraceLibGL = false;
|
||||
if (!triedTraceLibGL) {
|
||||
triedTraceLibGL = true;
|
||||
const char *traceLibGLName = getenv("TRACE_LIBGL");
|
||||
if (traceLibGLName && traceLibGLName[0]) {
|
||||
traceLibGL = _dlopen(traceLibGLName, RTLD_GLOBAL | RTLD_LAZY | RTLD_DEEPBIND);
|
||||
}
|
||||
}
|
||||
if (traceLibGL) {
|
||||
proc = dlsym(traceLibGL, procName);
|
||||
if (proc) {
|
||||
return proc;
|
||||
}
|
||||
}
|
||||
|
||||
"""
|
||||
if needle not in text:
|
||||
raise SystemExit("glproc_egl.cpp patch point not found")
|
||||
text = text.replace(needle, replacement, 1)
|
||||
patched.write_text(text, encoding="utf-8", newline="\n")
|
||||
return patched
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--repo", default=str(DEFAULT_REPO), help="FoldCraftLauncher repo root")
|
||||
parser.add_argument("--abi", default="arm64-v8a")
|
||||
parser.add_argument("--android-platform", default="android-23")
|
||||
parser.add_argument("--build-dir", default=".trace-work/build-android-egltrace")
|
||||
parser.add_argument("--output", default=str(DEFAULT_OUTPUT))
|
||||
args = parser.parse_args()
|
||||
|
||||
repo = Path(args.repo).resolve()
|
||||
ndk = find_ndk(repo)
|
||||
build_dir = (repo / args.build_dir / args.abi).resolve()
|
||||
apitrace = repo / "MobileGL" / "3rdparty" / "apitrace"
|
||||
source_dir = SCRIPT_DIR / "android_egltrace"
|
||||
toolchain = ndk / "build" / "cmake" / "android.toolchain.cmake"
|
||||
if not apitrace.exists():
|
||||
raise SystemExit(f"missing apitrace checkout: {apitrace}")
|
||||
if not source_dir.exists():
|
||||
raise SystemExit(f"missing wrapper CMake project: {source_dir}")
|
||||
if not toolchain.exists():
|
||||
raise SystemExit(f"missing Android toolchain: {toolchain}")
|
||||
cache = build_dir / "CMakeCache.txt"
|
||||
if cache.exists() and "CMAKE_GENERATOR:INTERNAL=Ninja" not in cache.read_text(encoding="utf-8", errors="ignore"):
|
||||
shutil.rmtree(build_dir)
|
||||
elif cache.exists() and str(source_dir).replace("\\", "/") not in cache.read_text(encoding="utf-8", errors="ignore").replace("\\", "/"):
|
||||
shutil.rmtree(build_dir)
|
||||
|
||||
ninja = shutil.which("ninja")
|
||||
if ninja is None:
|
||||
cmake_ninjas = sorted((Path(os.environ.get("ANDROID_HOME", "")) / "cmake").glob("*/bin/ninja.exe"))
|
||||
ninja = str(cmake_ninjas[-1]) if cmake_ninjas else None
|
||||
if ninja is None:
|
||||
raise SystemExit("ninja not found; install Ninja or Android SDK CMake")
|
||||
|
||||
patched_egltrace = generate_and_patch(repo, build_dir)
|
||||
patched_glproc_egl = patch_glproc_egl(repo, build_dir)
|
||||
|
||||
run([
|
||||
"cmake", "-G", "Ninja", "-S", str(source_dir), "-B", str(build_dir),
|
||||
"-DCMAKE_BUILD_TYPE=Release",
|
||||
f"-DCMAKE_TOOLCHAIN_FILE={toolchain}",
|
||||
f"-DCMAKE_MAKE_PROGRAM={ninja}",
|
||||
f"-DANDROID_ABI={args.abi}",
|
||||
f"-DANDROID_PLATFORM={args.android_platform}",
|
||||
f"-DAPITRACE_ROOT={apitrace}",
|
||||
f"-DPATCHED_EGLTRACE_CPP={patched_egltrace}",
|
||||
f"-DPATCHED_GLPROC_EGL_CPP={patched_glproc_egl}",
|
||||
])
|
||||
run(["cmake", "--build", str(build_dir), "--target", "egltrace", "--parallel"])
|
||||
|
||||
output = Path(args.output)
|
||||
if not output.is_absolute():
|
||||
output = repo / output
|
||||
output = output.resolve()
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
candidates = list(build_dir.rglob("egltrace.so"))
|
||||
if not candidates:
|
||||
raise SystemExit("egltrace.so was not produced")
|
||||
shutil.copy2(candidates[0], output)
|
||||
print(output)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
import tarfile
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_MAX_ARCHIVE_BYTES = 20 * 1024 * 1024
|
||||
# Bundled under the skill at .../skills/trace-fixture-authoring-on-android-fcl/scripts/.
|
||||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||||
DEFAULT_REPO = SCRIPT_DIR.parents[5] # -> FoldCraftLauncher repo root
|
||||
|
||||
|
||||
def run(cmd, cwd=None, capture=False):
|
||||
print("+", " ".join(str(part) for part in cmd), flush=True)
|
||||
if capture:
|
||||
return subprocess.check_output(cmd, cwd=cwd, text=True, encoding="utf-8", errors="replace")
|
||||
subprocess.run(cmd, cwd=cwd, check=True)
|
||||
return ""
|
||||
|
||||
|
||||
def adb(args, serial=None):
|
||||
cmd = ["adb"]
|
||||
if serial:
|
||||
cmd += ["-s", serial]
|
||||
cmd += args
|
||||
return run(cmd, capture=True)
|
||||
|
||||
|
||||
def pull_latest(serial, dest):
|
||||
latest = adb(["shell", "cat", "/sdcard/FCL/mobilegl-trace/latest-session.txt"], serial).strip()
|
||||
if not latest:
|
||||
raise SystemExit("device has no /sdcard/FCL/mobilegl-trace/latest-session.txt")
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
local = dest / Path(latest).name
|
||||
if local.exists():
|
||||
shutil.rmtree(local)
|
||||
adb(["pull", latest, str(local)], serial)
|
||||
return local
|
||||
|
||||
|
||||
def choose_target_frame(capture_dir, explicit_frame):
|
||||
if explicit_frame is not None:
|
||||
return explicit_frame
|
||||
result = capture_dir / "capture-result.json"
|
||||
if not result.exists():
|
||||
raise SystemExit(f"missing {result}; press the FCL capture button or create capture-once.request first")
|
||||
data = json.loads(result.read_text(encoding="utf-8"))
|
||||
if "targetFrame" not in data:
|
||||
raise SystemExit(f"{result} has no targetFrame")
|
||||
return int(data["targetFrame"])
|
||||
|
||||
|
||||
def choose_snapshot(golden_dir):
|
||||
pngs = sorted(golden_dir.glob("*.png"))
|
||||
if not pngs:
|
||||
raise SystemExit(f"no snapshots produced in {golden_dir}")
|
||||
def call_no(path):
|
||||
match = re.search(r"\.(\d+)\.png$", path.name)
|
||||
return int(match.group(1)) if match else -1
|
||||
return max(pngs, key=call_no)
|
||||
|
||||
|
||||
def choose_target_call(explicit_call, golden):
|
||||
if explicit_call is not None:
|
||||
return explicit_call
|
||||
if golden is None:
|
||||
return None
|
||||
match = re.search(r"\.(\d+)\.png$", golden.name)
|
||||
return int(match.group(1)) if match else None
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--repo", default=str(DEFAULT_REPO), help="FoldCraftLauncher repo root")
|
||||
parser.add_argument("--serial", help="adb serial; when set, pull latest capture from device")
|
||||
parser.add_argument("--capture-dir", help="local capture directory; defaults to pulled latest")
|
||||
parser.add_argument("--pull-root", default=".trace-work/pulled-mobilegl-captures")
|
||||
parser.add_argument("--case", required=True)
|
||||
parser.add_argument("--target-frame", type=int)
|
||||
parser.add_argument("--target-call", type=int)
|
||||
parser.add_argument("--golden", help="existing golden PNG, normally produced by Android replay")
|
||||
parser.add_argument("--skip-desktop-golden", action="store_true",
|
||||
help="skip apitrace replay --headless; requires --golden and --target-call")
|
||||
parser.add_argument("--apitrace", default="apitrace")
|
||||
parser.add_argument("--fixtures-dir", default="MobileGL/tools/trace_replay/fixtures")
|
||||
parser.add_argument("--width", type=int, default=854)
|
||||
parser.add_argument("--height", type=int, default=480)
|
||||
parser.add_argument("--ssim-threshold", default="0.99")
|
||||
parser.add_argument("--max-archive-bytes", type=int, default=DEFAULT_MAX_ARCHIVE_BYTES)
|
||||
args = parser.parse_args()
|
||||
|
||||
repo = Path(args.repo).resolve()
|
||||
if args.capture_dir:
|
||||
capture_dir = Path(args.capture_dir).resolve()
|
||||
elif args.serial:
|
||||
capture_dir = pull_latest(args.serial, repo / args.pull_root)
|
||||
else:
|
||||
raise SystemExit("pass --capture-dir or --serial")
|
||||
|
||||
full_trace = capture_dir / "full.trace"
|
||||
if not full_trace.exists():
|
||||
raise SystemExit(f"missing trace: {full_trace}")
|
||||
target_frame = choose_target_frame(capture_dir, args.target_frame)
|
||||
work = capture_dir / "fixture-work"
|
||||
if work.exists():
|
||||
shutil.rmtree(work)
|
||||
work.mkdir(parents=True)
|
||||
|
||||
frames_txt = work / "frames.txt"
|
||||
frames_txt.write_text(run([args.apitrace, "dump", "--calls=frame", str(full_trace)], capture=True), encoding="utf-8")
|
||||
|
||||
trimmed = work / "trace.trace"
|
||||
run([args.apitrace, "gltrim", "-f", str(target_frame), "--output", str(trimmed), str(full_trace)])
|
||||
|
||||
supplied_golden = Path(args.golden).resolve() if args.golden else None
|
||||
target_call = choose_target_call(args.target_call, supplied_golden)
|
||||
if args.skip_desktop_golden:
|
||||
if supplied_golden is None or target_call is None:
|
||||
raise SystemExit("--skip-desktop-golden requires --golden and --target-call")
|
||||
golden = supplied_golden
|
||||
else:
|
||||
golden_dir = work / "golden"
|
||||
golden_dir.mkdir()
|
||||
prefix = golden_dir / f"{args.case}."
|
||||
run([args.apitrace, "replay", "--headless", "--snapshot-prefix", str(prefix), "--call-nos", str(trimmed)])
|
||||
golden = choose_snapshot(golden_dir)
|
||||
target_call = choose_target_call(args.target_call, golden)
|
||||
if target_call is None:
|
||||
raise SystemExit(f"cannot infer target call from {golden}")
|
||||
|
||||
fixtures = (repo / args.fixtures_dir).resolve()
|
||||
fixtures.mkdir(parents=True, exist_ok=True)
|
||||
archive_root = work / "archive"
|
||||
archive_root.mkdir()
|
||||
shutil.copy2(trimmed, archive_root / "trace.trace")
|
||||
tgz = fixtures / f"{args.case}.tgz"
|
||||
with tarfile.open(tgz, "w:gz") as tar:
|
||||
tar.add(archive_root / "trace.trace", arcname="trace.trace")
|
||||
archive_size = tgz.stat().st_size
|
||||
if archive_size > args.max_archive_bytes:
|
||||
raise SystemExit(
|
||||
f"{tgz} is {archive_size} bytes, over the {args.max_archive_bytes} byte fixture limit; "
|
||||
"choose an earlier/smaller frame and re-run gltrim"
|
||||
)
|
||||
golden_out = fixtures / f"{args.case}.{target_call:010d}.png"
|
||||
shutil.copy2(golden, golden_out)
|
||||
|
||||
manifest = {
|
||||
"name": args.case,
|
||||
"trace_archive": tgz.name,
|
||||
"trace_file": "trace.trace",
|
||||
"golden": golden_out.name,
|
||||
"target_call": target_call,
|
||||
"width": args.width,
|
||||
"height": args.height,
|
||||
"ssim_threshold": float(args.ssim_threshold),
|
||||
"archive_size": archive_size,
|
||||
}
|
||||
manifest_path = capture_dir / f"{args.case}.fixture.json"
|
||||
manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
||||
print(json.dumps(manifest, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
+5
@@ -1,3 +1,8 @@
|
||||
---
|
||||
name: trace-fixture-authoring
|
||||
description: Author a deterministic MobileGL trace-replay fixture from a captured apitrace - build the in-tree apitrace fork, capture a reproducible scene, frame-trim with gltrim, generate and verify a golden image, package under the archive-size budget, register the case in trace_cases.json, and validate on Linux and Android. Use when adding or re-trimming a trace_replay regression fixture.
|
||||
---
|
||||
|
||||
# Trace fixture authoring
|
||||
|
||||
## Variables
|
||||
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "MobileGL Trace Fixture Authoring"
|
||||
short_description: "Author and register a MobileGL trace-replay fixture"
|
||||
default_prompt: "Use $trace-fixture-authoring to author and register a MobileGL trace replay fixture."
|
||||
Reference in New Issue
Block a user