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.
Files changed (130) hide show
  1. bobframes/__init__.py +3 -0
  2. bobframes/_version.py +1 -0
  3. bobframes/catalog.py +154 -0
  4. bobframes/cli.py +266 -0
  5. bobframes/derive_post_merge.py +365 -0
  6. bobframes/derives/__init__.py +0 -0
  7. bobframes/derives/pass_class_breakdown.py +102 -0
  8. bobframes/derives/texture_usage.py +121 -0
  9. bobframes/discovery.py +132 -0
  10. bobframes/global_entities.py +99 -0
  11. bobframes/html/__init__.py +0 -0
  12. bobframes/html/template.py +1056 -0
  13. bobframes/lint.py +114 -0
  14. bobframes/manifest.py +127 -0
  15. bobframes/parquetize.py +282 -0
  16. bobframes/parsers/__init__.py +0 -0
  17. bobframes/parsers/derive_program_transitions.py +73 -0
  18. bobframes/parsers/parse_init_state.py +675 -0
  19. bobframes/paths.py +111 -0
  20. bobframes/probes/__init__.py +0 -0
  21. bobframes/probes/whatif.py +165 -0
  22. bobframes/qrd_harness.py +119 -0
  23. bobframes/query_examples.py +222 -0
  24. bobframes/rdcmd.py +72 -0
  25. bobframes/replay/__init__.py +26 -0
  26. bobframes/replay/replay_main.py +2305 -0
  27. bobframes/reports/__init__.py +0 -0
  28. bobframes/reports/_dashboard.py +425 -0
  29. bobframes/reports/ab.py +88 -0
  30. bobframes/reports/base.py +114 -0
  31. bobframes/reports/cache.py +147 -0
  32. bobframes/reports/chrome.py +1306 -0
  33. bobframes/reports/cli.py +99 -0
  34. bobframes/reports/delta.py +167 -0
  35. bobframes/reports/discovery.py +118 -0
  36. bobframes/reports/draws_by_class.py +165 -0
  37. bobframes/reports/formatters.py +122 -0
  38. bobframes/reports/instancing_opportunities.py +276 -0
  39. bobframes/reports/orchestrator.py +59 -0
  40. bobframes/reports/overdraw.py +293 -0
  41. bobframes/reports/pass_gpu.py +190 -0
  42. bobframes/reports/shader_hotlist.py +240 -0
  43. bobframes/reports/trend_table.py +444 -0
  44. bobframes/resource_labels.py +162 -0
  45. bobframes/run.py +480 -0
  46. bobframes/schemas.py +426 -0
  47. bobframes/stable_keys.py +83 -0
  48. bobframes/tests/__init__.py +0 -0
  49. bobframes/tests/_render_util.py +84 -0
  50. bobframes/tests/data/golden/_reports/draws_by_class.html +323 -0
  51. bobframes/tests/data/golden/_reports/drill/District 01/2026-05-28_r110600/index.html +1560 -0
  52. bobframes/tests/data/golden/_reports/index.html +264 -0
  53. bobframes/tests/data/golden/_reports/instancing_opportunities.html +266 -0
  54. bobframes/tests/data/golden/_reports/overdraw.html +275 -0
  55. bobframes/tests/data/golden/_reports/pass_gpu.html +277 -0
  56. bobframes/tests/data/golden/_reports/shader_hotlist.html +265 -0
  57. bobframes/tests/data/golden/_reports/trend_table.html +390 -0
  58. bobframes/tests/data/golden/index.html +1175 -0
  59. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/_manifest.json +51 -0
  60. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/buffers.parquet +0 -0
  61. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/clears.parquet +0 -0
  62. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/counters_per_event.parquet +0 -0
  63. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/descriptor_access.parquet +0 -0
  64. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/dispatches.parquet +0 -0
  65. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draw_bindings.parquet +0 -0
  66. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draws.parquet +0 -0
  67. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/events.parquet +0 -0
  68. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/fbos.parquet +0 -0
  69. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/frame_totals.parquet +0 -0
  70. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/ibo_samples.parquet +0 -0
  71. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/indirect_args.parquet +0 -0
  72. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/passes.parquet +0 -0
  73. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/pixel_history.parquet +0 -0
  74. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/post_vs_samples.parquet +0 -0
  75. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/program_transitions.parquet +0 -0
  76. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/programs.parquet +0 -0
  77. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/render_targets.parquet +0 -0
  78. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/resource_creation.parquet +0 -0
  79. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/rt_event_timeline.parquet +0 -0
  80. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/samplers.parquet +0 -0
  81. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/shaders.parquet +0 -0
  82. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/state_change_events.parquet +0 -0
  83. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/texture_samples.parquet +0 -0
  84. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/textures.parquet +0 -0
  85. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vbo_samples.parquet +0 -0
  86. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vertex_inputs.parquet +0 -0
  87. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/_manifest.json +51 -0
  88. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/buffers.parquet +0 -0
  89. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/clears.parquet +0 -0
  90. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/counters_per_event.parquet +0 -0
  91. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/descriptor_access.parquet +0 -0
  92. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/dispatches.parquet +0 -0
  93. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draw_bindings.parquet +0 -0
  94. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draws.parquet +0 -0
  95. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/events.parquet +0 -0
  96. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/fbos.parquet +0 -0
  97. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/frame_totals.parquet +0 -0
  98. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/ibo_samples.parquet +0 -0
  99. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/indirect_args.parquet +0 -0
  100. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/passes.parquet +0 -0
  101. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/pixel_history.parquet +0 -0
  102. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/post_vs_samples.parquet +0 -0
  103. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/program_transitions.parquet +0 -0
  104. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/programs.parquet +0 -0
  105. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/render_targets.parquet +0 -0
  106. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/resource_creation.parquet +0 -0
  107. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/rt_event_timeline.parquet +0 -0
  108. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/samplers.parquet +0 -0
  109. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/shaders.parquet +0 -0
  110. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/state_change_events.parquet +0 -0
  111. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/texture_samples.parquet +0 -0
  112. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/textures.parquet +0 -0
  113. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vbo_samples.parquet +0 -0
  114. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vertex_inputs.parquet +0 -0
  115. bobframes/tests/make_synthetic.py +171 -0
  116. bobframes/tests/smoke.py +199 -0
  117. bobframes/tests/test_determinism.py +19 -0
  118. bobframes/tests/test_discovery.py +97 -0
  119. bobframes/tests/test_hardening.py +142 -0
  120. bobframes/tests/test_parity.py +22 -0
  121. bobframes/tests/test_perf.py +18 -0
  122. bobframes/tests/test_replay_drift.py +115 -0
  123. bobframes/tests/test_schemas.py +26 -0
  124. bobframes/tests/test_schemas_unit.py +55 -0
  125. bobframes/tests/test_stable_keys.py +61 -0
  126. bobframes-0.1.0.dist-info/METADATA +144 -0
  127. bobframes-0.1.0.dist-info/RECORD +130 -0
  128. bobframes-0.1.0.dist-info/WHEEL +4 -0
  129. bobframes-0.1.0.dist-info/entry_points.txt +2 -0
  130. 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()
@@ -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)