bobframes 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- bobframes/__init__.py +3 -0
- bobframes/_version.py +1 -0
- bobframes/catalog.py +154 -0
- bobframes/cli.py +266 -0
- bobframes/derive_post_merge.py +365 -0
- bobframes/derives/__init__.py +0 -0
- bobframes/derives/pass_class_breakdown.py +102 -0
- bobframes/derives/texture_usage.py +121 -0
- bobframes/discovery.py +132 -0
- bobframes/global_entities.py +99 -0
- bobframes/html/__init__.py +0 -0
- bobframes/html/template.py +1056 -0
- bobframes/lint.py +114 -0
- bobframes/manifest.py +127 -0
- bobframes/parquetize.py +282 -0
- bobframes/parsers/__init__.py +0 -0
- bobframes/parsers/derive_program_transitions.py +73 -0
- bobframes/parsers/parse_init_state.py +675 -0
- bobframes/paths.py +111 -0
- bobframes/probes/__init__.py +0 -0
- bobframes/probes/whatif.py +165 -0
- bobframes/qrd_harness.py +119 -0
- bobframes/query_examples.py +222 -0
- bobframes/rdcmd.py +72 -0
- bobframes/replay/__init__.py +26 -0
- bobframes/replay/replay_main.py +2305 -0
- bobframes/reports/__init__.py +0 -0
- bobframes/reports/_dashboard.py +425 -0
- bobframes/reports/ab.py +88 -0
- bobframes/reports/base.py +114 -0
- bobframes/reports/cache.py +147 -0
- bobframes/reports/chrome.py +1306 -0
- bobframes/reports/cli.py +99 -0
- bobframes/reports/delta.py +167 -0
- bobframes/reports/discovery.py +118 -0
- bobframes/reports/draws_by_class.py +165 -0
- bobframes/reports/formatters.py +122 -0
- bobframes/reports/instancing_opportunities.py +276 -0
- bobframes/reports/orchestrator.py +59 -0
- bobframes/reports/overdraw.py +293 -0
- bobframes/reports/pass_gpu.py +190 -0
- bobframes/reports/shader_hotlist.py +240 -0
- bobframes/reports/trend_table.py +444 -0
- bobframes/resource_labels.py +162 -0
- bobframes/run.py +480 -0
- bobframes/schemas.py +426 -0
- bobframes/stable_keys.py +83 -0
- bobframes/tests/__init__.py +0 -0
- bobframes/tests/_render_util.py +84 -0
- bobframes/tests/data/golden/_reports/draws_by_class.html +323 -0
- bobframes/tests/data/golden/_reports/drill/District 01/2026-05-28_r110600/index.html +1560 -0
- bobframes/tests/data/golden/_reports/index.html +264 -0
- bobframes/tests/data/golden/_reports/instancing_opportunities.html +266 -0
- bobframes/tests/data/golden/_reports/overdraw.html +275 -0
- bobframes/tests/data/golden/_reports/pass_gpu.html +277 -0
- bobframes/tests/data/golden/_reports/shader_hotlist.html +265 -0
- bobframes/tests/data/golden/_reports/trend_table.html +390 -0
- bobframes/tests/data/golden/index.html +1175 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/_manifest.json +51 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/buffers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/clears.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/counters_per_event.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/descriptor_access.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/dispatches.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draw_bindings.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draws.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/fbos.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/frame_totals.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/ibo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/indirect_args.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/passes.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/pixel_history.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/post_vs_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/program_transitions.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/programs.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/render_targets.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/resource_creation.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/rt_event_timeline.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/samplers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/shaders.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/state_change_events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/texture_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/textures.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vbo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vertex_inputs.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/_manifest.json +51 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/buffers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/clears.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/counters_per_event.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/descriptor_access.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/dispatches.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draw_bindings.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draws.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/fbos.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/frame_totals.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/ibo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/indirect_args.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/passes.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/pixel_history.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/post_vs_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/program_transitions.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/programs.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/render_targets.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/resource_creation.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/rt_event_timeline.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/samplers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/shaders.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/state_change_events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/texture_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/textures.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vbo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vertex_inputs.parquet +0 -0
- bobframes/tests/make_synthetic.py +171 -0
- bobframes/tests/smoke.py +199 -0
- bobframes/tests/test_determinism.py +19 -0
- bobframes/tests/test_discovery.py +97 -0
- bobframes/tests/test_hardening.py +142 -0
- bobframes/tests/test_parity.py +22 -0
- bobframes/tests/test_perf.py +18 -0
- bobframes/tests/test_replay_drift.py +115 -0
- bobframes/tests/test_schemas.py +26 -0
- bobframes/tests/test_schemas_unit.py +55 -0
- bobframes/tests/test_stable_keys.py +61 -0
- bobframes-0.1.0.dist-info/METADATA +144 -0
- bobframes-0.1.0.dist-info/RECORD +130 -0
- bobframes-0.1.0.dist-info/WHEEL +4 -0
- bobframes-0.1.0.dist-info/entry_points.txt +2 -0
- bobframes-0.1.0.dist-info/licenses/LICENSE +21 -0
bobframes/paths.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Single source of truth for output paths.
|
|
2
|
+
|
|
3
|
+
Layout (Option A — outputs separated from RDC inputs):
|
|
4
|
+
|
|
5
|
+
<root>/
|
|
6
|
+
index.html # root catalog VIEW
|
|
7
|
+
<area>/<drop>/ # raw RDC inputs (untouched)
|
|
8
|
+
_data/ # pipeline outputs
|
|
9
|
+
_catalog.parquet (+.csv, .json)
|
|
10
|
+
_global_entities.parquet (+.csv)
|
|
11
|
+
_query_examples.md
|
|
12
|
+
<area>/<drop>/ # per-drop data
|
|
13
|
+
*.parquet (29 tables)
|
|
14
|
+
_manifest.json, _resource_labels.json
|
|
15
|
+
shader_src/*.glsl, histogram/, jsonl sidecars
|
|
16
|
+
done.marker
|
|
17
|
+
_reports/ # rendered HTML
|
|
18
|
+
*.html (dashboard + 6 reports)
|
|
19
|
+
ab/<pair>/*.html
|
|
20
|
+
drill/<area>/<drop>/index.html # per-drop browser
|
|
21
|
+
_cache/
|
|
22
|
+
|
|
23
|
+
Catalog `analysis_out_path` column stores RELATIVE path (e.g. `_data/Police station/2026-05-27_r110565`)
|
|
24
|
+
for portability. Reports resolve via resolve_drop_dir(root, analysis_out_path).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import os
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def data_root(root: str) -> str:
|
|
33
|
+
return os.path.join(root, '_data')
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def drop_data_dir(root: str, area: str, drop_label_dated: str) -> str:
|
|
37
|
+
"""<root>/_data/<area>/<drop>/"""
|
|
38
|
+
return os.path.join(data_root(root), area, drop_label_dated)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def drop_data_dir_tmp(root: str, area: str, drop_label_dated: str) -> str:
|
|
42
|
+
"""Staging dir for atomic commit. Renamed to drop_data_dir on success."""
|
|
43
|
+
return drop_data_dir(root, area, drop_label_dated) + '.tmp'
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def drop_drill_dir(root: str, area: str, drop_label_dated: str) -> str:
|
|
47
|
+
"""<root>/_reports/drill/<area>/<drop>/ (per-drop browser HTML)"""
|
|
48
|
+
return os.path.join(root, '_reports', 'drill', area, drop_label_dated)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def reports_dir(root: str) -> str:
|
|
52
|
+
return os.path.join(root, '_reports')
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def reports_cache_dir(root: str) -> str:
|
|
56
|
+
return os.path.join(reports_dir(root), '_cache')
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def catalog_parquet(root: str) -> str:
|
|
60
|
+
return os.path.join(data_root(root), '_catalog.parquet')
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def catalog_csv(root: str) -> str:
|
|
64
|
+
return os.path.join(data_root(root), '_catalog.csv')
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def catalog_json(root: str) -> str:
|
|
68
|
+
return os.path.join(data_root(root), '_catalog.json')
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def global_entities_parquet(root: str) -> str:
|
|
72
|
+
return os.path.join(data_root(root), '_global_entities.parquet')
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def global_entities_csv(root: str) -> str:
|
|
76
|
+
return os.path.join(data_root(root), '_global_entities.csv')
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def query_examples_md(root: str) -> str:
|
|
80
|
+
return os.path.join(data_root(root), '_query_examples.md')
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def root_index_html(root: str) -> str:
|
|
84
|
+
return os.path.join(root, 'index.html')
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def drop_dir_rel(area: str, drop_label_dated: str) -> str:
|
|
88
|
+
"""Relative path stored in catalog.analysis_out_path column.
|
|
89
|
+
Combine with root via resolve_drop_dir() at read time."""
|
|
90
|
+
return os.path.join('_data', area, drop_label_dated).replace('\\', '/')
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def resolve_drop_dir(root: str, analysis_out_path: str) -> str:
|
|
94
|
+
"""Convert catalog's stored path to an absolute drop data dir.
|
|
95
|
+
Tolerates legacy absolute paths for back-compat during migration."""
|
|
96
|
+
if not analysis_out_path:
|
|
97
|
+
return ''
|
|
98
|
+
if os.path.isabs(analysis_out_path):
|
|
99
|
+
return analysis_out_path
|
|
100
|
+
return os.path.join(root, analysis_out_path)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def drop_dir_to_drill_dir(drop_dir: str) -> str:
|
|
104
|
+
"""Given absolute <root>/_data/<area>/<drop>, return <root>/_reports/drill/<area>/<drop>.
|
|
105
|
+
Used by report drill-link helpers."""
|
|
106
|
+
parts = os.path.normpath(drop_dir).split(os.sep)
|
|
107
|
+
try:
|
|
108
|
+
i = parts.index('_data')
|
|
109
|
+
except ValueError:
|
|
110
|
+
return drop_dir
|
|
111
|
+
return os.sep.join(parts[:i] + ['_reports', 'drill'] + parts[i + 1:])
|
|
File without changes
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""WHATIF shader-override probe. NOT WIRED INTO run.py.
|
|
2
|
+
|
|
3
|
+
Stub holding the BuildTargetShader + ReplaceResource pattern from
|
|
4
|
+
Appendix A.10 for ad-hoc use. Run separately when a human has identified
|
|
5
|
+
shader IDs they want to stub-test for GPU-time savings.
|
|
6
|
+
|
|
7
|
+
Usage (from inside qrenderdoc.exe --python):
|
|
8
|
+
set RDC_INSIDE_ARGS=<rdc_path>\x1f<shader_id>[\x1f<shader_id>...]\x1f<N_samples>
|
|
9
|
+
qrenderdoc.exe --python probes/whatif.py
|
|
10
|
+
|
|
11
|
+
Writes results to <rdc_dir>/_whatif_<shader_id>.csv.
|
|
12
|
+
|
|
13
|
+
Pattern documentation (verbatim from Appendix A.10):
|
|
14
|
+
|
|
15
|
+
- GetResources() returns a list with .resourceId fields; str(rid) is
|
|
16
|
+
'ResourceId::N'. Parse N to map int handles to ResourceId objects.
|
|
17
|
+
- BuildTargetShader returns a tuple of (ResourceId, str) OR (str, ResourceId)
|
|
18
|
+
depending on RD version. Find the ResourceId via isinstance walk.
|
|
19
|
+
- ReplaceResource requires both arguments to be real ResourceId objects;
|
|
20
|
+
rd.ResourceId(int) is rejected.
|
|
21
|
+
- FetchCounters([rd.GPUCounter.EventGPUDuration]) returns one CounterResult
|
|
22
|
+
per event with value.d in seconds.
|
|
23
|
+
- Median 3-5 baseline + 3-5 replaced samples per shader for reliable deltas;
|
|
24
|
+
single-sample variance is +/- 10%.
|
|
25
|
+
- Always FreeTargetResource() and RemoveReplacement() after each test.
|
|
26
|
+
|
|
27
|
+
The actual implementation is left as an exercise for the downstream report
|
|
28
|
+
layer that drives this probe with specific shader IDs and sample counts.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import csv
|
|
34
|
+
import os
|
|
35
|
+
import statistics
|
|
36
|
+
import sys
|
|
37
|
+
import time
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
FS_GRAY_STUB = """\
|
|
41
|
+
#version 310 es
|
|
42
|
+
precision mediump float;
|
|
43
|
+
out vec4 out_FragColor;
|
|
44
|
+
void main() { out_FragColor = vec4(0.5, 0.5, 0.5, 1.0); }
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def run_whatif_for_shader(ctrl, shader_handle: int, samples: int = 3) -> dict:
|
|
49
|
+
"""Run baseline + replaced timing for one shader. Returns dict of medians."""
|
|
50
|
+
import renderdoc as rd # type: ignore
|
|
51
|
+
|
|
52
|
+
resources = ctrl.GetResources()
|
|
53
|
+
handle_to_rid: dict[int, object] = {}
|
|
54
|
+
for r in resources:
|
|
55
|
+
s = str(r.resourceId)
|
|
56
|
+
if '::' in s:
|
|
57
|
+
try:
|
|
58
|
+
handle_to_rid[int(s.split('::', 1)[1])] = r.resourceId
|
|
59
|
+
except Exception:
|
|
60
|
+
continue
|
|
61
|
+
|
|
62
|
+
if shader_handle not in handle_to_rid:
|
|
63
|
+
raise KeyError(f'shader {shader_handle} not found in capture resources')
|
|
64
|
+
orig_rid = handle_to_rid[shader_handle]
|
|
65
|
+
|
|
66
|
+
def measure_total() -> float:
|
|
67
|
+
rs = ctrl.FetchCounters([rd.GPUCounter.EventGPUDuration])
|
|
68
|
+
return float(sum(float(r.value.d) for r in rs))
|
|
69
|
+
|
|
70
|
+
baseline_samples = [measure_total() for _ in range(samples)]
|
|
71
|
+
|
|
72
|
+
result = ctrl.BuildTargetShader(
|
|
73
|
+
'main', rd.ShaderEncoding.GLSL,
|
|
74
|
+
bytes(FS_GRAY_STUB, 'utf-8'),
|
|
75
|
+
rd.ShaderCompileFlags(),
|
|
76
|
+
rd.ShaderStage.Pixel,
|
|
77
|
+
)
|
|
78
|
+
new_id = None
|
|
79
|
+
err_str = ''
|
|
80
|
+
if isinstance(result, tuple) and len(result) >= 2:
|
|
81
|
+
for x in result:
|
|
82
|
+
if isinstance(x, rd.ResourceId):
|
|
83
|
+
new_id = x
|
|
84
|
+
else:
|
|
85
|
+
err_str = str(x)
|
|
86
|
+
if new_id is None or new_id == rd.ResourceId.Null():
|
|
87
|
+
raise RuntimeError(f'compile failed: {err_str}')
|
|
88
|
+
|
|
89
|
+
ctrl.ReplaceResource(orig_rid, new_id)
|
|
90
|
+
try:
|
|
91
|
+
replaced_samples = [measure_total() for _ in range(samples)]
|
|
92
|
+
finally:
|
|
93
|
+
ctrl.RemoveReplacement(orig_rid)
|
|
94
|
+
ctrl.FreeTargetResource(new_id)
|
|
95
|
+
|
|
96
|
+
bm = statistics.median(baseline_samples)
|
|
97
|
+
rm = statistics.median(replaced_samples)
|
|
98
|
+
return {
|
|
99
|
+
'shader_id': shader_handle,
|
|
100
|
+
'baseline_median_s': bm,
|
|
101
|
+
'baseline_samples': baseline_samples,
|
|
102
|
+
'replaced_median_s': rm,
|
|
103
|
+
'replaced_samples': replaced_samples,
|
|
104
|
+
'delta_s_median': bm - rm,
|
|
105
|
+
'delta_pct_median': (bm - rm) / bm * 100.0 if bm else 0.0,
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def main() -> int:
|
|
110
|
+
env = os.environ.get('RDC_INSIDE_ARGS', '')
|
|
111
|
+
parts = env.split('\x1f') if env else []
|
|
112
|
+
if len(parts) < 2:
|
|
113
|
+
print('usage: set RDC_INSIDE_ARGS=<rdc>\\x1f<shader_id>[\\x1f<shader_id>...][\\x1f<samples>]')
|
|
114
|
+
os._exit(2)
|
|
115
|
+
|
|
116
|
+
rdc_path = parts[0]
|
|
117
|
+
samples = 3
|
|
118
|
+
shader_ids = []
|
|
119
|
+
for p in parts[1:]:
|
|
120
|
+
try:
|
|
121
|
+
sh = int(p)
|
|
122
|
+
if sh < 100: # treat small ints as samples count
|
|
123
|
+
samples = sh
|
|
124
|
+
else:
|
|
125
|
+
shader_ids.append(sh)
|
|
126
|
+
except ValueError:
|
|
127
|
+
continue
|
|
128
|
+
|
|
129
|
+
if not shader_ids:
|
|
130
|
+
print('no shader ids supplied')
|
|
131
|
+
os._exit(2)
|
|
132
|
+
|
|
133
|
+
import renderdoc as rd # type: ignore
|
|
134
|
+
cap = rd.OpenCaptureFile()
|
|
135
|
+
cap.OpenFile(rdc_path, '', None)
|
|
136
|
+
res = cap.OpenCapture(rd.ReplayOptions(), None)
|
|
137
|
+
rc, ctrl = (res if isinstance(res, tuple) else (res.result, res.controller))
|
|
138
|
+
if rc != rd.ResultCode.Succeeded:
|
|
139
|
+
print(f'open failed: {rc}')
|
|
140
|
+
os._exit(1)
|
|
141
|
+
|
|
142
|
+
out_path = os.path.join(os.path.dirname(rdc_path),
|
|
143
|
+
f'_whatif_{os.path.basename(rdc_path).replace(".rdc","")}.csv')
|
|
144
|
+
fields = ['shader_id', 'baseline_median_s', 'baseline_samples',
|
|
145
|
+
'replaced_median_s', 'replaced_samples',
|
|
146
|
+
'delta_s_median', 'delta_pct_median']
|
|
147
|
+
with open(out_path, 'w', encoding='utf-8', newline='') as f:
|
|
148
|
+
w = csv.DictWriter(f, fieldnames=fields)
|
|
149
|
+
w.writeheader()
|
|
150
|
+
for sh in shader_ids:
|
|
151
|
+
r = run_whatif_for_shader(ctrl, sh, samples=samples)
|
|
152
|
+
r['baseline_samples'] = ';'.join(f'{x:.6f}' for x in r['baseline_samples'])
|
|
153
|
+
r['replaced_samples'] = ';'.join(f'{x:.6f}' for x in r['replaced_samples'])
|
|
154
|
+
w.writerow(r)
|
|
155
|
+
print(f' shader {sh}: baseline={r["baseline_median_s"]*1000:.2f}ms '
|
|
156
|
+
f'replaced={r["replaced_median_s"]*1000:.2f}ms '
|
|
157
|
+
f'delta={r["delta_s_median"]*1000:.2f}ms ({r["delta_pct_median"]:.1f}%)')
|
|
158
|
+
|
|
159
|
+
ctrl.Shutdown(); cap.Shutdown()
|
|
160
|
+
print(f'wrote {out_path}')
|
|
161
|
+
os._exit(0)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
if __name__ == '__main__':
|
|
165
|
+
main()
|
bobframes/qrd_harness.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Launch qrenderdoc.exe --python <script> with payload args via env.
|
|
2
|
+
|
|
3
|
+
qrenderdoc swallows positional argv after the script path (treats them as
|
|
4
|
+
captures to open) so we pass arguments via the RDC_INSIDE_ARGS env var,
|
|
5
|
+
joined by \\x1f (unit separator).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import subprocess
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
|
|
15
|
+
_DEFAULT_QRD = (
|
|
16
|
+
r'c:/Program Files/Arm/Arm Performance Studio 2026.2'
|
|
17
|
+
r'/renderdoc_for_arm_gpus/qrenderdoc.exe'
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
_SEP = '\x1f'
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _kill_tree(pid: int) -> None:
|
|
24
|
+
"""Kill a process AND its descendants. qrenderdoc spawns GPU/replay
|
|
25
|
+
grandchildren that survive a bare child kill and keep file locks held for
|
|
26
|
+
the next run (R-4, ADR-4); `subprocess` only reaps the direct child. Uses
|
|
27
|
+
Windows `taskkill /T /F`. Best-effort — never raises."""
|
|
28
|
+
try:
|
|
29
|
+
subprocess.run(
|
|
30
|
+
['taskkill', '/T', '/F', '/PID', str(pid)],
|
|
31
|
+
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=30,
|
|
32
|
+
)
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def find_qrenderdoc() -> str:
|
|
38
|
+
env = os.environ.get('RENDERDOC_QRENDERDOC', '').strip()
|
|
39
|
+
if env and os.path.exists(env):
|
|
40
|
+
return env
|
|
41
|
+
if os.path.exists(_DEFAULT_QRD):
|
|
42
|
+
return _DEFAULT_QRD
|
|
43
|
+
raise FileNotFoundError(
|
|
44
|
+
'qrenderdoc.exe not found. Set RENDERDOC_QRENDERDOC env var or install '
|
|
45
|
+
'Arm Performance Studio 2026.2.'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def run(
|
|
50
|
+
script_path: str,
|
|
51
|
+
payload_args: list[str],
|
|
52
|
+
log_path: str | None = None,
|
|
53
|
+
timeout_s: float = 600.0,
|
|
54
|
+
) -> tuple[int, float]:
|
|
55
|
+
"""Launch qrenderdoc --python script_path with payload via env.
|
|
56
|
+
|
|
57
|
+
Returns (returncode, elapsed_seconds). The replay script is responsible
|
|
58
|
+
for writing its own output files; we only forward the exit code.
|
|
59
|
+
|
|
60
|
+
Output is redirected directly to log_path (NOT captured through PIPE) to
|
|
61
|
+
avoid Windows hang: capture_output=True keeps pipes open until ALL inherited
|
|
62
|
+
handles close, including any grandchild qrenderdoc helpers that may inherit
|
|
63
|
+
them; the pipes can stay open indefinitely past the main process exit.
|
|
64
|
+
"""
|
|
65
|
+
qrd = find_qrenderdoc()
|
|
66
|
+
env = dict(os.environ)
|
|
67
|
+
env['RDC_INSIDE_ARGS'] = _SEP.join(payload_args)
|
|
68
|
+
|
|
69
|
+
if log_path:
|
|
70
|
+
os.makedirs(os.path.dirname(log_path), exist_ok=True)
|
|
71
|
+
|
|
72
|
+
t0 = time.monotonic()
|
|
73
|
+
logf = None
|
|
74
|
+
if log_path:
|
|
75
|
+
logf = open(log_path, 'a', encoding='utf-8', buffering=1)
|
|
76
|
+
try:
|
|
77
|
+
if logf:
|
|
78
|
+
logf.write(f'\n--- qrd_harness launching {os.path.basename(script_path)} ---\n')
|
|
79
|
+
logf.flush()
|
|
80
|
+
stdout = logf
|
|
81
|
+
stderr = subprocess.STDOUT
|
|
82
|
+
else:
|
|
83
|
+
stdout = subprocess.DEVNULL
|
|
84
|
+
stderr = subprocess.DEVNULL
|
|
85
|
+
|
|
86
|
+
# Popen (not subprocess.run) so we hold the pid for a process-tree kill on timeout.
|
|
87
|
+
proc = subprocess.Popen([qrd, '--python', script_path], env=env,
|
|
88
|
+
stdout=stdout, stderr=stderr)
|
|
89
|
+
try:
|
|
90
|
+
proc.communicate(timeout=timeout_s)
|
|
91
|
+
rc = proc.returncode
|
|
92
|
+
if logf:
|
|
93
|
+
logf.write(f'\n--- rc={rc}, elapsed={time.monotonic()-t0:.2f}s ---\n')
|
|
94
|
+
except subprocess.TimeoutExpired:
|
|
95
|
+
_kill_tree(proc.pid)
|
|
96
|
+
try:
|
|
97
|
+
proc.wait(timeout=30)
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
rc = -1
|
|
101
|
+
if logf:
|
|
102
|
+
logf.write(f'\n--- TIMEOUT after {timeout_s}s; killed process tree pid={proc.pid} ---\n')
|
|
103
|
+
finally:
|
|
104
|
+
if logf:
|
|
105
|
+
logf.close()
|
|
106
|
+
elapsed = time.monotonic() - t0
|
|
107
|
+
return rc, elapsed
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def parse_inside_args(argv: list[str]) -> list[str]:
|
|
111
|
+
"""Called from inside qrenderdoc to retrieve payload args.
|
|
112
|
+
|
|
113
|
+
qrenderdoc passes script path as argv[0] (approximately) and may swallow
|
|
114
|
+
later positionals; the canonical source is RDC_INSIDE_ARGS.
|
|
115
|
+
"""
|
|
116
|
+
env = os.environ.get('RDC_INSIDE_ARGS', '')
|
|
117
|
+
if env:
|
|
118
|
+
return env.split(_SEP)
|
|
119
|
+
return argv[1:] if len(argv) > 1 else []
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
"""Emit <root>/_query_examples.md.
|
|
2
|
+
|
|
3
|
+
Canonical DuckDB / polars query patterns for Layer 2 reports.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
_CONTENT = r"""# Canonical query patterns
|
|
12
|
+
|
|
13
|
+
All snippets target DuckDB. Replace `**/` with the root path or a specific
|
|
14
|
+
drop folder when needed. Reports may also use polars / pyarrow on the same
|
|
15
|
+
Parquet files.
|
|
16
|
+
|
|
17
|
+
## Read the catalog
|
|
18
|
+
|
|
19
|
+
```sql
|
|
20
|
+
SELECT * FROM read_parquet('_catalog.parquet') ORDER BY drop_date, area, capture;
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
Each row = one capture. Has `row_count_*` per table, schema_version, build
|
|
24
|
+
timestamp, replay_status, and `analysis_out_path` pointing at the drop folder.
|
|
25
|
+
|
|
26
|
+
## Total GPU duration per area per drop
|
|
27
|
+
|
|
28
|
+
```sql
|
|
29
|
+
SELECT area, drop_date, drop_label, sum(total_gpu_duration_s) AS gpu_s
|
|
30
|
+
FROM read_parquet('**/frame_totals.parquet')
|
|
31
|
+
GROUP BY area, drop_date, drop_label
|
|
32
|
+
ORDER BY drop_date, area;
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Top shaders by total draw count, across all drops
|
|
36
|
+
|
|
37
|
+
```sql
|
|
38
|
+
SELECT stable_key, count(*) AS draw_uses,
|
|
39
|
+
any_value(shader_type) AS type,
|
|
40
|
+
any_value(src_len) AS src_len
|
|
41
|
+
FROM read_parquet('**/shaders.parquet') s
|
|
42
|
+
JOIN read_parquet('**/draws.parquet') d
|
|
43
|
+
ON s.area = d.area
|
|
44
|
+
AND s.drop_date = d.drop_date
|
|
45
|
+
AND s.drop_label = d.drop_label
|
|
46
|
+
AND s.capture = d.capture
|
|
47
|
+
AND (s.shader_id = d.fs_shader_id OR s.shader_id = d.vs_shader_id)
|
|
48
|
+
WHERE s.stable_key != ''
|
|
49
|
+
GROUP BY stable_key
|
|
50
|
+
ORDER BY draw_uses DESC
|
|
51
|
+
LIMIT 20;
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
## Use _global_entities for single-key joins
|
|
55
|
+
|
|
56
|
+
```sql
|
|
57
|
+
-- All draws whose fragment shader matches a given stable_key
|
|
58
|
+
WITH g AS (
|
|
59
|
+
SELECT area, drop_date, drop_label, capture, local_id
|
|
60
|
+
FROM read_parquet('_global_entities.parquet')
|
|
61
|
+
WHERE kind = 'shader' AND stable_key = '390139100b660402...'
|
|
62
|
+
)
|
|
63
|
+
SELECT d.* FROM read_parquet('**/draws.parquet') d
|
|
64
|
+
JOIN g USING (area, drop_date, drop_label, capture)
|
|
65
|
+
WHERE d.fs_shader_id = g.local_id;
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## Pass-level GPU time across drops
|
|
69
|
+
|
|
70
|
+
```sql
|
|
71
|
+
SELECT marker_path_norm, drop_date, sum(gpu_duration_s) AS gpu_s,
|
|
72
|
+
sum(num_draws) AS draws
|
|
73
|
+
FROM read_parquet('**/passes.parquet')
|
|
74
|
+
WHERE marker_path_norm != ''
|
|
75
|
+
GROUP BY marker_path_norm, drop_date
|
|
76
|
+
ORDER BY marker_path_norm, drop_date;
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`marker_path_norm` has the `Frame N/` prefix stripped so passes match across
|
|
80
|
+
captures and drops.
|
|
81
|
+
|
|
82
|
+
## Draws-by-class per area
|
|
83
|
+
|
|
84
|
+
```sql
|
|
85
|
+
SELECT area, draw_class, count(*) AS n,
|
|
86
|
+
sum(num_indices * num_instances) AS pre_vs_verts
|
|
87
|
+
FROM read_parquet('**/draws.parquet')
|
|
88
|
+
GROUP BY area, draw_class
|
|
89
|
+
ORDER BY area, n DESC;
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
## Find non-instanced repeated draws (potential merge targets)
|
|
93
|
+
|
|
94
|
+
```sql
|
|
95
|
+
SELECT area, capture, parent_pass_path_norm, fs_shader_id,
|
|
96
|
+
count(*) AS occurrences
|
|
97
|
+
FROM read_parquet('**/draws.parquet')
|
|
98
|
+
WHERE num_instances <= 1 AND draw_class IN ('opaque', 'prepass')
|
|
99
|
+
GROUP BY area, capture, parent_pass_path_norm, fs_shader_id
|
|
100
|
+
HAVING count(*) >= 4
|
|
101
|
+
ORDER BY occurrences DESC
|
|
102
|
+
LIMIT 50;
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
## Largest render targets per area
|
|
106
|
+
|
|
107
|
+
```sql
|
|
108
|
+
SELECT area, format, width, height, count(*) AS rt_count,
|
|
109
|
+
any_value(label) AS sample_label
|
|
110
|
+
FROM read_parquet('**/render_targets.parquet')
|
|
111
|
+
WHERE width > 0
|
|
112
|
+
GROUP BY area, format, width, height
|
|
113
|
+
ORDER BY width * height DESC
|
|
114
|
+
LIMIT 50;
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Texture inventory across drops (deduped via stable_key)
|
|
118
|
+
|
|
119
|
+
```sql
|
|
120
|
+
SELECT stable_key, any_value(label) AS label,
|
|
121
|
+
any_value(format) AS format,
|
|
122
|
+
any_value(width) AS w, any_value(height) AS h,
|
|
123
|
+
count(DISTINCT area || drop_date || drop_label || capture) AS in_captures
|
|
124
|
+
FROM read_parquet('**/textures.parquet')
|
|
125
|
+
WHERE stable_key != ''
|
|
126
|
+
GROUP BY stable_key
|
|
127
|
+
ORDER BY in_captures DESC, label
|
|
128
|
+
LIMIT 100;
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Pixel-history rejection summary per RT
|
|
132
|
+
|
|
133
|
+
```sql
|
|
134
|
+
SELECT rt_id, count(*) AS samples,
|
|
135
|
+
sum(passed) AS passed,
|
|
136
|
+
sum(depth_test_failed) AS depth_failed,
|
|
137
|
+
sum(shader_discarded) AS discarded,
|
|
138
|
+
sum(scissor_clipped) AS scissored
|
|
139
|
+
FROM read_parquet('**/pixel_history.parquet')
|
|
140
|
+
GROUP BY rt_id
|
|
141
|
+
ORDER BY samples DESC;
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
## Cross-drop entity stability check
|
|
145
|
+
|
|
146
|
+
```sql
|
|
147
|
+
-- How many unique shader stable_keys exist across all drops?
|
|
148
|
+
SELECT count(DISTINCT stable_key) FROM read_parquet('_global_entities.parquet')
|
|
149
|
+
WHERE kind = 'shader';
|
|
150
|
+
|
|
151
|
+
-- Which shaders appear in every drop?
|
|
152
|
+
WITH per_drop AS (
|
|
153
|
+
SELECT stable_key, drop_date, count(*) AS n
|
|
154
|
+
FROM read_parquet('_global_entities.parquet')
|
|
155
|
+
WHERE kind = 'shader'
|
|
156
|
+
GROUP BY stable_key, drop_date
|
|
157
|
+
)
|
|
158
|
+
SELECT stable_key, count(DISTINCT drop_date) AS in_drops
|
|
159
|
+
FROM per_drop
|
|
160
|
+
GROUP BY stable_key
|
|
161
|
+
HAVING count(DISTINCT drop_date) = (SELECT count(DISTINCT drop_date) FROM per_drop)
|
|
162
|
+
ORDER BY stable_key;
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
## Per-capture totals from catalog (no joins needed)
|
|
166
|
+
|
|
167
|
+
```sql
|
|
168
|
+
SELECT area, capture, row_count_draws, row_count_state_change_events,
|
|
169
|
+
row_count_pixel_history
|
|
170
|
+
FROM read_parquet('_catalog.parquet')
|
|
171
|
+
ORDER BY area, capture;
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
## Reading shader source on demand
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
import json, glob, os
|
|
178
|
+
shader_id = 16844
|
|
179
|
+
# Find captures that have this shader id
|
|
180
|
+
import pyarrow.parquet as pq
|
|
181
|
+
for p in glob.glob('*/2026-05-27_*/_analysis_out/shaders.parquet'):
|
|
182
|
+
t = pq.read_table(p, columns=['area','capture','shader_id','src_file_path'])
|
|
183
|
+
df = t.to_pandas()
|
|
184
|
+
hit = df[df['shader_id'] == shader_id]
|
|
185
|
+
for _, r in hit.iterrows():
|
|
186
|
+
src = os.path.join(os.path.dirname(p), r['src_file_path'])
|
|
187
|
+
with open(src) as f:
|
|
188
|
+
print(f.read())
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
## Uniform values per pass
|
|
192
|
+
|
|
193
|
+
`uniforms_per_pass.jsonl` (per drop) carries one JSON object per pass-first-draw
|
|
194
|
+
with the bound constant-block layouts and raw UBO bytes. Read line-by-line:
|
|
195
|
+
|
|
196
|
+
```python
|
|
197
|
+
import json
|
|
198
|
+
for p in glob.glob('*/2026-05-27_*/_analysis_out/uniforms_per_pass.jsonl'):
|
|
199
|
+
with open(p) as f:
|
|
200
|
+
for line in f:
|
|
201
|
+
o = json.loads(line)
|
|
202
|
+
if 'MobileBasePass' in o['marker_path']:
|
|
203
|
+
# o['constant_blocks'] -> list of {stage, block_name, members, ...}
|
|
204
|
+
# o['raw_by_binding'][slot] -> {buffer_id, offset, size, raw_hex}
|
|
205
|
+
...
|
|
206
|
+
```
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def write_query_examples(root: str) -> str:
|
|
211
|
+
from . import paths as _paths
|
|
212
|
+
os.makedirs(_paths.data_root(root), exist_ok=True)
|
|
213
|
+
path = _paths.query_examples_md(root)
|
|
214
|
+
with open(path, 'w', encoding='utf-8') as f:
|
|
215
|
+
f.write(_CONTENT)
|
|
216
|
+
return path
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
if __name__ == '__main__':
|
|
220
|
+
import sys
|
|
221
|
+
p = write_query_examples(sys.argv[1] if len(sys.argv) > 1 else '.')
|
|
222
|
+
print(f'wrote {p}')
|
bobframes/rdcmd.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""renderdoccmd wrapper.
|
|
2
|
+
|
|
3
|
+
Locates renderdoccmd.exe via env or the known Arm Performance Studio path
|
|
4
|
+
and exposes a convert() helper that produces .xml / .zip.xml from .rdc.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
_DEFAULT_PATHS = [
|
|
15
|
+
r'c:/Program Files/Arm/Arm Performance Studio 2026.2/renderdoc_for_arm_gpus/renderdoccmd.exe',
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def find_renderdoccmd() -> str:
|
|
20
|
+
env = os.environ.get('RENDERDOCCMD', '').strip()
|
|
21
|
+
if env and os.path.exists(env):
|
|
22
|
+
return env
|
|
23
|
+
for p in _DEFAULT_PATHS:
|
|
24
|
+
if os.path.exists(p):
|
|
25
|
+
return p
|
|
26
|
+
raise FileNotFoundError(
|
|
27
|
+
'renderdoccmd not found. Set RENDERDOCCMD env var or install '
|
|
28
|
+
'Arm Performance Studio 2026.2 at the default path.'
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def convert(rdc_path: str, out_path: str, fmt: str = 'xml', timeout_s: float = 120.0) -> float:
|
|
33
|
+
"""Convert .rdc to xml or zip.xml. Returns elapsed seconds.
|
|
34
|
+
|
|
35
|
+
fmt is 'xml' or 'zip.xml'. Raises RuntimeError on failure.
|
|
36
|
+
"""
|
|
37
|
+
if fmt not in ('xml', 'zip.xml'):
|
|
38
|
+
raise ValueError(f'unknown fmt: {fmt!r}')
|
|
39
|
+
cmd = [
|
|
40
|
+
find_renderdoccmd(), 'convert',
|
|
41
|
+
'-f', rdc_path,
|
|
42
|
+
'-o', out_path,
|
|
43
|
+
'-i', 'rdc',
|
|
44
|
+
'-c', fmt,
|
|
45
|
+
]
|
|
46
|
+
t0 = time.monotonic()
|
|
47
|
+
try:
|
|
48
|
+
rc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout_s)
|
|
49
|
+
except subprocess.TimeoutExpired as e:
|
|
50
|
+
# capture_output swallows stderr on timeout; surface the tail before re-raising (R-8).
|
|
51
|
+
out = e.stderr or e.output or ''
|
|
52
|
+
if isinstance(out, bytes):
|
|
53
|
+
out = out.decode('utf-8', 'replace')
|
|
54
|
+
print(f'renderdoccmd convert timed out after {timeout_s}s (fmt={fmt}): {out[-400:]}',
|
|
55
|
+
file=sys.stderr)
|
|
56
|
+
raise
|
|
57
|
+
elapsed = time.monotonic() - t0
|
|
58
|
+
if rc.returncode != 0:
|
|
59
|
+
tail = (rc.stderr or rc.stdout or '')[-400:]
|
|
60
|
+
raise RuntimeError(
|
|
61
|
+
f'renderdoccmd convert failed (rc={rc.returncode}, fmt={fmt}): {tail}'
|
|
62
|
+
)
|
|
63
|
+
if not os.path.exists(out_path):
|
|
64
|
+
raise RuntimeError(f'renderdoccmd convert reported success but {out_path} is missing')
|
|
65
|
+
return elapsed
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def needs_export(rdc_path: str, out_path: str) -> bool:
|
|
69
|
+
"""True if out_path is missing or older than rdc_path."""
|
|
70
|
+
if not os.path.exists(out_path):
|
|
71
|
+
return True
|
|
72
|
+
return os.path.getmtime(out_path) < os.path.getmtime(rdc_path)
|