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
@@ -0,0 +1,2305 @@
1
+ """Main replay-time extraction. Runs INSIDE qrenderdoc's embedded Python 3.10.
2
+
3
+ Invoked via:
4
+ qrenderdoc.exe --python replay_main.py
5
+
6
+ Args via env var RDC_INSIDE_ARGS, separated by \\x1f:
7
+ drop_dir, capture, area, drop_date, drop_label, stage_root
8
+
9
+ Writes CSVs to <stage_root>/<capture>/<table>.csv (one row per record).
10
+
11
+ This script cannot import the host bobframes package reliably from inside
12
+ qrenderdoc, so column tuples are duplicated here. Host-side parquetize
13
+ verifies headers against schemas.py.
14
+
15
+ CRITICAL:
16
+ - End with os._exit(0); sys.exit() lets qrenderdoc start its GUI.
17
+ - stdout is swallowed; everything important must go to <capture>/_replay.log.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import csv
23
+ import json
24
+ import os
25
+ import sys
26
+ import time
27
+ import traceback
28
+
29
+ _SEP = '\x1f'
30
+
31
+
32
+ # --- Schema constants (mirror schemas.py exactly) ---------------------------
33
+ #
34
+ # Duplicated by design (H-6): this script runs inside qrenderdoc's embedded Python and cannot import
35
+ # the host bobframes package, so these tuples mirror bobframes/schemas.py. Drift is guarded by
36
+ # bobframes/tests/test_replay_drift.py (CI) and re-checked at parquetize header-verify time.
37
+ # EVENTS/DRAWS/PASSES intentionally omit their *_norm + draw_class columns — those are derived
38
+ # host-side after replay (see derive_post_merge.py). Keep these in sync with schemas.py.
39
+
40
+ ID_COLS = ('area', 'drop_date', 'drop_label', 'capture')
41
+
42
+ EVENTS_COLS = ID_COLS + (
43
+ 'event_id', 'parent_marker_path', 'chunk_name', 'depth',
44
+ 'is_drawcall', 'is_dispatch', 'is_clear', 'is_copy', 'is_resolve',
45
+ 'is_marker_push', 'is_marker_pop', 'is_set_state',
46
+ 'num_indices', 'num_instances',
47
+ 'dispatch_x', 'dispatch_y', 'dispatch_z',
48
+ 'output_color_rt_id', 'output_depth_rt_id',
49
+ 'copy_source_id', 'copy_destination_id',
50
+ )
51
+
52
+ DRAWS_COLS = ID_COLS + (
53
+ 'event_id', 'parent_pass_path', 'draw_name',
54
+ 'num_indices', 'num_instances', 'base_vertex', 'vertex_offset', 'index_offset',
55
+ 'topology',
56
+ 'program_id', 'vs_shader_id', 'fs_shader_id',
57
+ 'color_rt_ids', 'depth_rt_id',
58
+ 'viewport_x', 'viewport_y', 'viewport_w', 'viewport_h',
59
+ 'scissor_x', 'scissor_y', 'scissor_w', 'scissor_h',
60
+ 'cull_mode', 'front_face',
61
+ 'depth_test_enable', 'depth_write_enable', 'depth_func',
62
+ 'stencil_enable',
63
+ 'stencil_front_pass_op', 'stencil_front_fail_op', 'stencil_front_depth_fail_op',
64
+ 'stencil_back_pass_op', 'stencil_back_fail_op', 'stencil_back_depth_fail_op',
65
+ 'stencil_ref', 'stencil_read_mask', 'stencil_write_mask',
66
+ 'blend_enable',
67
+ 'blend_src_color', 'blend_dst_color', 'blend_op_color',
68
+ 'blend_src_alpha', 'blend_dst_alpha', 'blend_op_alpha',
69
+ 'color_write_mask',
70
+ 'ibo_id', 'ibo_index_type',
71
+ 'gpu_duration_s',
72
+ 'post_vs_primitives', 'post_vs_vertices',
73
+ 'mesh_hash',
74
+ 'screen_min_x', 'screen_min_y', 'screen_max_x', 'screen_max_y',
75
+ 'screen_coverage_px',
76
+ )
77
+
78
+ PASSES_COLS = ID_COLS + (
79
+ 'marker_path', 'depth', 'first_event_id', 'last_event_id',
80
+ 'num_draws', 'num_dispatches', 'num_clears', 'num_other_actions',
81
+ 'num_primitives_pre_vs', 'num_primitives_post_vs',
82
+ 'num_vertices_pre_vs', 'num_vertices_post_vs',
83
+ 'gpu_duration_s',
84
+ 'unique_programs', 'unique_shaders', 'unique_meshes', 'unique_materials',
85
+ 'color_rt_id_first', 'depth_rt_id_first',
86
+ 'draws_by_class_opaque', 'draws_by_class_prepass', 'draws_by_class_translucent',
87
+ 'draws_by_class_decal', 'draws_by_class_shadow', 'draws_by_class_ui',
88
+ 'draws_by_class_postprocess', 'draws_by_class_additive', 'draws_by_class_other',
89
+ )
90
+
91
+ RT_COLS = ID_COLS + (
92
+ 'stable_key',
93
+ 'rt_id', 'format', 'width', 'height', 'depth', 'mip_levels', 'sample_count',
94
+ 'is_color', 'is_depth', 'is_stencil', 'is_swap_chain_target',
95
+ 'first_write_event', 'last_write_event', 'first_read_event', 'last_read_event',
96
+ 'num_write_events', 'num_read_events',
97
+ 'attached_to_fbo_ids', 'sampled_by_shader_ids',
98
+ 'min_value_r', 'min_value_g', 'min_value_b', 'min_value_a',
99
+ 'max_value_r', 'max_value_g', 'max_value_b', 'max_value_a',
100
+ )
101
+
102
+ RT_TIMELINE_COLS = ID_COLS + (
103
+ 'rt_id', 'event_id', 'usage_code', 'usage_name', 'view_id',
104
+ 'attachment_point_or_slot',
105
+ )
106
+
107
+ COUNTERS_COLS = ID_COLS + (
108
+ 'event_id', 'counter_name', 'counter_unit', 'value_double', 'value_uint64',
109
+ )
110
+
111
+ STATE_CHANGE_COLS = ID_COLS + (
112
+ 'event_id', 'parent_marker_path', 'call_name',
113
+ 'target_or_cap', 'arg_id', 'arg_int', 'arg_float', 'arg_extra_json',
114
+ )
115
+
116
+ CLEARS_COLS = ID_COLS + (
117
+ 'event_id', 'parent_marker_path', 'target',
118
+ 'color_r', 'color_g', 'color_b', 'color_a',
119
+ 'depth_value', 'stencil_value',
120
+ 'buffer_mask', 'fbo_id',
121
+ )
122
+
123
+ DISPATCHES_COLS = ID_COLS + (
124
+ 'event_id', 'parent_marker_path',
125
+ 'program_id', 'cs_shader_id',
126
+ 'group_count_x', 'group_count_y', 'group_count_z',
127
+ 'work_group_size_x', 'work_group_size_y', 'work_group_size_z',
128
+ 'total_threads',
129
+ 'ssbo_bindings', 'image_bindings', 'atomic_counter_bindings',
130
+ 'gpu_duration_s',
131
+ )
132
+
133
+ DRAW_BINDINGS_COLS = ID_COLS + (
134
+ 'event_id', 'slot_kind', 'slot_index', 'resource_id', 'sampler_id',
135
+ 'offset', 'size', 'stride',
136
+ )
137
+
138
+ VERTEX_INPUTS_COLS = ID_COLS + (
139
+ 'event_id', 'attribute_index', 'attribute_name', 'enabled',
140
+ 'component_count', 'component_type', 'normalized', 'integer',
141
+ 'stride_bytes', 'offset_bytes', 'buffer_id', 'vbo_slot',
142
+ 'divisor',
143
+ )
144
+
145
+ DESCRIPTOR_ACCESS_COLS = ID_COLS + (
146
+ 'event_id', 'descriptor_kind', 'slot_index', 'resource_id', 'view_id',
147
+ 'byte_offset', 'byte_size', 'access_type',
148
+ )
149
+
150
+ INDIRECT_ARGS_COLS = ID_COLS + (
151
+ 'event_id', 'call_name', 'indirect_buffer_id', 'offset',
152
+ 'count', 'instance_count', 'first', 'base_vertex', 'base_instance',
153
+ 'group_x', 'group_y', 'group_z', 'stride', 'draw_count',
154
+ )
155
+
156
+ VBO_SAMPLES_COLS = ID_COLS + (
157
+ 'buffer_id', 'vertex_index', 'byte_offset',
158
+ 'raw_hex', 'as_f32_0', 'as_f32_1', 'as_f32_2', 'as_f32_3',
159
+ )
160
+
161
+ IBO_SAMPLES_COLS = ID_COLS + (
162
+ 'buffer_id', 'index_position', 'index_value', 'index_type',
163
+ )
164
+
165
+ POST_VS_SAMPLES_COLS = ID_COLS + (
166
+ 'event_id', 'vertex_index',
167
+ 'position_x', 'position_y', 'position_z', 'position_w',
168
+ 'clipped',
169
+ )
170
+
171
+ TEXTURE_SAMPLES_COLS = ID_COLS + (
172
+ 'tex_id', 'row_index', 'col_index',
173
+ 'raw_hex', 'as_unorm8_r', 'as_unorm8_g', 'as_unorm8_b', 'as_unorm8_a',
174
+ )
175
+
176
+ PIXEL_HISTORY_COLS = ID_COLS + (
177
+ 'rt_id', 'sample_x', 'sample_y', 'mod_index',
178
+ 'event_id', 'primitive_id',
179
+ 'passed', 'backface_culled', 'depth_test_failed', 'stencil_test_failed',
180
+ 'scissor_clipped', 'shader_discarded', 'sample_masked',
181
+ 'depth_clipped', 'view_clipped',
182
+ 'shader_out_r', 'shader_out_g', 'shader_out_b', 'shader_out_a',
183
+ 'pre_mod_r', 'pre_mod_g', 'pre_mod_b', 'pre_mod_a',
184
+ 'post_mod_r', 'post_mod_g', 'post_mod_b', 'post_mod_a',
185
+ )
186
+
187
+ FBOS_COLS = ID_COLS + (
188
+ 'stable_key',
189
+ 'fbo_id', 'attachment_point', 'kind', 'resource_id', 'format',
190
+ 'width', 'height', 'sample_count',
191
+ 'mip_level', 'layer', 'created_at_event', 'bound_at_events',
192
+ 'num_clears', 'num_writes', 'num_reads', 'label',
193
+ )
194
+
195
+ FRAME_TOTALS_COLS = ID_COLS + (
196
+ 'n_events', 'n_draws', 'n_dispatches', 'n_clears',
197
+ 'total_primitives_pre_vs', 'total_vertices_pre_vs',
198
+ 'total_primitives_post_vs', 'total_vertices_post_vs',
199
+ 'total_gpu_duration_s',
200
+ 'glUseProgram_count', 'glBindBuffer_count', 'glBindTexture_count', 'glActiveTexture_count',
201
+ 'glBindFramebuffer_count', 'glBindBufferBase_count', 'glBindSampler_count',
202
+ 'glDrawElements_count', 'glDrawArrays_count', 'glDrawElementsInstanced_count',
203
+ 'glDispatchCompute_count', 'glClear_count', 'glClearBuffer_count',
204
+ 'total_vbo_bytes_uploaded', 'total_ibo_bytes_uploaded', 'total_ubo_bytes_uploaded',
205
+ 'total_texture_bytes_allocated', 'total_renderbuffer_bytes_allocated',
206
+ 'unique_programs_used', 'unique_shaders_used', 'unique_meshes_drawn',
207
+ 'unique_materials_drawn', 'unique_textures_bound',
208
+ 'fbo_switches', 'program_switches', 'texture_unit_switches',
209
+ )
210
+
211
+
212
+ # --- Helpers -----------------------------------------------------------------
213
+
214
+ def _parse_args() -> dict:
215
+ env = os.environ.get('RDC_INSIDE_ARGS', '')
216
+ parts = env.split(_SEP) if env else []
217
+ if len(parts) < 6:
218
+ raise RuntimeError(f'expected 6 args in RDC_INSIDE_ARGS, got {len(parts)}: {parts!r}')
219
+ return {
220
+ 'drop_dir': parts[0],
221
+ 'capture': parts[1],
222
+ 'area': parts[2],
223
+ 'drop_date': parts[3],
224
+ 'drop_label': parts[4],
225
+ 'stage_root': parts[5],
226
+ }
227
+
228
+
229
+ def _tee_setup(capture_stage: str):
230
+ log_path = os.path.join(capture_stage, '_replay.log')
231
+ os.makedirs(capture_stage, exist_ok=True)
232
+ log = open(log_path, 'w', encoding='utf-8', buffering=1)
233
+
234
+ class _Tee:
235
+ def __init__(self, *s): self.s = s
236
+ def write(self, d):
237
+ for o in self.s:
238
+ try: o.write(d); o.flush()
239
+ except Exception: pass
240
+ def flush(self):
241
+ for o in self.s:
242
+ try: o.flush()
243
+ except Exception: pass
244
+
245
+ sys.stdout = _Tee(sys.stdout, log)
246
+ sys.stderr = _Tee(sys.stderr, log)
247
+ return log
248
+
249
+
250
+ def _rid_int(rid) -> int:
251
+ """Extract trailing integer from a ResourceId."""
252
+ if rid is None:
253
+ return 0
254
+ try:
255
+ s = str(rid)
256
+ if '::' in s:
257
+ return int(s.split('::', 1)[1])
258
+ except Exception:
259
+ pass
260
+ return 0
261
+
262
+
263
+ def _enum_short(value) -> str:
264
+ """Decode rd enum value to its short name (after final '.')."""
265
+ s = str(value)
266
+ return s.rsplit('.', 1)[-1] if '.' in s else s
267
+
268
+
269
+ def _open_capture(rdc_path: str):
270
+ import renderdoc as rd # type: ignore
271
+ cap = rd.OpenCaptureFile()
272
+ status = cap.OpenFile(rdc_path, '', None)
273
+ if status != rd.ResultCode.Succeeded:
274
+ raise RuntimeError(f'OpenFile failed: {status}')
275
+ result = cap.OpenCapture(rd.ReplayOptions(), None)
276
+ if isinstance(result, tuple):
277
+ rc, ctrl = result[0], result[1]
278
+ else:
279
+ rc, ctrl = result.result, result.controller
280
+ if rc != rd.ResultCode.Succeeded:
281
+ cap.Shutdown()
282
+ raise RuntimeError(f'OpenCapture failed: {rc}')
283
+ return cap, ctrl
284
+
285
+
286
+ # --- Action tree walk (events + classification) ------------------------------
287
+
288
+ def _build_event_records(ctrl, sd, ctx):
289
+ """Walk action tree once, build:
290
+ - events[]: list of dicts (one per action node)
291
+ - draw_events[]: subset where is_drawcall=1
292
+ - marker_pushes[]: list of (event_id, depth, marker_path) for PUSH
293
+ - marker_pops[]: list of (event_id, depth) for POP
294
+ - action_by_event: {event_id: action node} (used later for SetFrameEvent)
295
+ - parent_path_by_event: {event_id: marker_path string}
296
+ Returns dict.
297
+ """
298
+ import renderdoc as rd # type: ignore
299
+
300
+ F = rd.ActionFlags
301
+ DRAW = int(F.Drawcall)
302
+ DISPATCH = int(F.Dispatch)
303
+ CLEAR = int(F.Clear)
304
+ COPY = int(F.Copy)
305
+ RESOLVE = int(F.Resolve)
306
+ PUSH = int(F.PushMarker)
307
+ POP = int(F.PopMarker)
308
+ SET_MARK = int(F.SetMarker) if hasattr(F, 'SetMarker') else 0
309
+ SET_STATE = 0 # rd has no SetState; treated as residual
310
+
311
+ events: list[dict] = []
312
+ draw_events: list[dict] = []
313
+ marker_pushes: list[tuple] = [] # (event_id, depth, marker_path)
314
+ marker_pops: list[tuple] = [] # (event_id, depth)
315
+ action_by_event: dict[int, object] = {}
316
+ parent_path_by_event: dict[int, str] = {}
317
+ chunk_name_by_event: dict[int, str] = {}
318
+ chunk_to_event_id: dict[int, int] = {}
319
+
320
+ null_rid = rd.ResourceId.Null()
321
+
322
+ def name_of(n):
323
+ if hasattr(n, 'GetName'):
324
+ try:
325
+ return n.GetName(sd) or ''
326
+ except Exception:
327
+ pass
328
+ return getattr(n, 'customName', '') or ''
329
+
330
+ def walk(nodes, stack, depth):
331
+ for n in nodes:
332
+ flags = int(getattr(n, 'flags', 0))
333
+ nm = name_of(n)
334
+ this_stack = stack
335
+ ev = int(getattr(n, 'eventId', 0))
336
+
337
+ if flags & PUSH:
338
+ this_stack = stack + [nm or 'marker']
339
+ marker_pushes.append((ev, depth, '/'.join(this_stack)))
340
+ elif flags & POP:
341
+ marker_pops.append((ev, depth))
342
+
343
+ outputs = getattr(n, 'outputs', None) or ()
344
+ depth_out_rid = _rid_int(getattr(n, 'depthOut', None))
345
+ color_out_ids: list[int] = []
346
+ for o in outputs:
347
+ v = _rid_int(o)
348
+ if v:
349
+ color_out_ids.append(v)
350
+ color_first = color_out_ids[0] if color_out_ids else 0
351
+ color_join = ';'.join(str(x) for x in color_out_ids)
352
+
353
+ dim = getattr(n, 'dispatchDimension', None) or (0, 0, 0)
354
+ try:
355
+ dx, dy, dz = int(dim[0]), int(dim[1]), int(dim[2])
356
+ except Exception:
357
+ dx = dy = dz = 0
358
+
359
+ row = {
360
+ **ctx,
361
+ 'event_id': ev,
362
+ 'parent_marker_path': '/'.join(stack),
363
+ 'chunk_name': nm,
364
+ 'depth': depth,
365
+ 'is_drawcall': int(bool(flags & DRAW)),
366
+ 'is_dispatch': int(bool(flags & DISPATCH)),
367
+ 'is_clear': int(bool(flags & CLEAR)),
368
+ 'is_copy': int(bool(flags & COPY)),
369
+ 'is_resolve': int(bool(flags & RESOLVE)),
370
+ 'is_marker_push': int(bool(flags & PUSH)),
371
+ 'is_marker_pop': int(bool(flags & POP)),
372
+ 'is_set_state': int(bool(flags & SET_MARK)),
373
+ 'num_indices': int(getattr(n, 'numIndices', 0) or 0),
374
+ 'num_instances': int(getattr(n, 'numInstances', 0) or 0),
375
+ 'dispatch_x': dx, 'dispatch_y': dy, 'dispatch_z': dz,
376
+ 'output_color_rt_id': color_first,
377
+ 'output_depth_rt_id': depth_out_rid,
378
+ 'copy_source_id': _rid_int(getattr(n, 'copySource', None)),
379
+ 'copy_destination_id': _rid_int(getattr(n, 'copyDestination', None)),
380
+ }
381
+ row['_color_ids_join'] = color_join
382
+
383
+ events.append(row)
384
+ action_by_event[ev] = n
385
+ parent_path_by_event[ev] = '/'.join(stack)
386
+ chunk_name_by_event[ev] = nm
387
+
388
+ # Build chunk_index -> event_id map from action's API events.
389
+ for api_ev in (getattr(n, 'events', None) or ()):
390
+ ci = getattr(api_ev, 'chunkIndex', -1)
391
+ if ci >= 0:
392
+ chunk_to_event_id[ci] = ev
393
+
394
+ if row['is_drawcall']:
395
+ draw_events.append(row)
396
+
397
+ children = getattr(n, 'children', None) or ()
398
+ if children:
399
+ walk(children, this_stack, depth + 1)
400
+
401
+ walk(ctrl.GetRootActions(), [], 0)
402
+
403
+ return {
404
+ 'events': events,
405
+ 'draw_events': draw_events,
406
+ 'marker_pushes': marker_pushes,
407
+ 'marker_pops': marker_pops,
408
+ 'action_by_event': action_by_event,
409
+ 'parent_path_by_event': parent_path_by_event,
410
+ 'chunk_name_by_event': chunk_name_by_event,
411
+ 'chunk_to_event_id': chunk_to_event_id,
412
+ }
413
+
414
+
415
+ # --- Topology + primitive math ----------------------------------------------
416
+
417
+ def _topology_short(topology) -> str:
418
+ return _enum_short(topology)
419
+
420
+
421
+ def _primitives_for(topology_short: str, ni: int, ic: int) -> int:
422
+ if ni == 0:
423
+ return 0
424
+ if topology_short == 'TriangleList':
425
+ return (ni // 3) * max(ic, 1)
426
+ if topology_short == 'TriangleStrip':
427
+ return max(0, ni - 2) * max(ic, 1)
428
+ if topology_short == 'LineList':
429
+ return (ni // 2) * max(ic, 1)
430
+ if topology_short == 'LineStrip':
431
+ return max(0, ni - 1) * max(ic, 1)
432
+ if topology_short == 'PointList':
433
+ return ni * max(ic, 1)
434
+ return (ni // 3) * max(ic, 1) # fallback triangle
435
+
436
+
437
+ # --- Draw classification ----------------------------------------------------
438
+
439
+ def _classify_draw(blend_enable: int, depth_write: int, marker_path: str,
440
+ blend_src_color: str, blend_dst_color: str) -> str:
441
+ mp = (marker_path or '').lower()
442
+ if 'shadow' in mp or 'shadowdepth' in mp:
443
+ return 'shadow'
444
+ if 'prepass' in mp or 'depthonly' in mp:
445
+ return 'prepass'
446
+ if 'slate' in mp or 'ui' in mp:
447
+ return 'ui'
448
+ if 'postprocess' in mp or 'tonemap' in mp or 'bloom' in mp or 'eyeadapt' in mp:
449
+ return 'postprocess'
450
+ if 'decal' in mp:
451
+ return 'decal'
452
+ if blend_enable:
453
+ bs = (blend_src_color or '').lower()
454
+ bd = (blend_dst_color or '').lower()
455
+ if bs == 'one' and bd == 'one':
456
+ return 'additive'
457
+ if 'src_alpha' in bs and 'one_minus_src_alpha' in bd:
458
+ return 'translucent'
459
+ return 'translucent'
460
+ if depth_write:
461
+ return 'opaque'
462
+ return 'other'
463
+
464
+
465
+ # --- Pipeline state read per draw ------------------------------------------
466
+
467
+ def _read_draw_state(ctrl, ev_id: int, base_row: dict, parent_path: str,
468
+ event_durations: dict[int, float]) -> dict:
469
+ import renderdoc as rd # type: ignore
470
+
471
+ ctrl.SetFrameEvent(ev_id, False)
472
+ pipe = ctrl.GetPipelineState()
473
+ glp = ctrl.GetGLPipelineState()
474
+
475
+ topology = _topology_short(pipe.GetPrimitiveTopology())
476
+
477
+ # depth state
478
+ ds = glp.depthState
479
+ depth_test = int(bool(ds.depthEnable))
480
+ depth_write = int(bool(ds.depthWrites))
481
+ depth_func = _enum_short(rd.CompareFunction(int(ds.depthFunction)))
482
+
483
+ # stencil state
484
+ ss = glp.stencilState
485
+ stencil_enable = int(bool(ss.stencilEnable))
486
+ sf = ss.frontFace
487
+ sb = ss.backFace
488
+ s_fp = _enum_short(rd.StencilOperation(int(sf.passOperation)))
489
+ s_ff = _enum_short(rd.StencilOperation(int(sf.failOperation)))
490
+ s_fd = _enum_short(rd.StencilOperation(int(sf.depthFailOperation)))
491
+ s_bp = _enum_short(rd.StencilOperation(int(sb.passOperation)))
492
+ s_bf = _enum_short(rd.StencilOperation(int(sb.failOperation)))
493
+ s_bd = _enum_short(rd.StencilOperation(int(sb.depthFailOperation)))
494
+ sref = int(getattr(sf, 'reference', 0) or 0)
495
+ srd = int(getattr(sf, 'compareMask', 0) or 0)
496
+ swr = int(getattr(sf, 'writeMask', 0) or 0)
497
+
498
+ # rasterizer
499
+ rast = glp.rasterizer
500
+ rstate = rast.state
501
+ cull = _enum_short(rd.CullMode(int(rstate.cullMode)))
502
+ front_face = 'CW' if not bool(getattr(rstate, 'frontCCW', True)) else 'CCW'
503
+ # viewport (first)
504
+ vp = rast.viewports[0] if rast.viewports else None
505
+ vp_x = int(vp.x) if vp else 0
506
+ vp_y = int(vp.y) if vp else 0
507
+ vp_w = int(vp.width) if vp else 0
508
+ vp_h = int(vp.height) if vp else 0
509
+ sc = rast.scissors[0] if getattr(rast, 'scissors', None) else None
510
+ sc_x = int(sc.x) if sc else 0
511
+ sc_y = int(sc.y) if sc else 0
512
+ sc_w = int(sc.width) if sc else 0
513
+ sc_h = int(sc.height) if sc else 0
514
+
515
+ # blend
516
+ fb = glp.framebuffer
517
+ bs = fb.blendState
518
+ b0 = bs.blends[0] if len(bs.blends) else None
519
+ blend_enable = int(bool(b0.enabled)) if b0 else 0
520
+ if b0:
521
+ cb = b0.colorBlend
522
+ ab = b0.alphaBlend
523
+ blend_src_color = _enum_short(rd.BlendMultiplier(int(cb.source)))
524
+ blend_dst_color = _enum_short(rd.BlendMultiplier(int(cb.destination)))
525
+ blend_op_color = _enum_short(rd.BlendOperation(int(cb.operation)))
526
+ blend_src_alpha = _enum_short(rd.BlendMultiplier(int(ab.source)))
527
+ blend_dst_alpha = _enum_short(rd.BlendMultiplier(int(ab.destination)))
528
+ blend_op_alpha = _enum_short(rd.BlendOperation(int(ab.operation)))
529
+ color_write_mask = int(b0.writeMask)
530
+ else:
531
+ blend_src_color = blend_dst_color = blend_op_color = ''
532
+ blend_src_alpha = blend_dst_alpha = blend_op_alpha = ''
533
+ color_write_mask = 15
534
+
535
+ # render targets
536
+ dfb = fb.drawFBO
537
+ color_rt_ids: list[int] = []
538
+ for a in getattr(dfb, 'colorAttachments', []) or []:
539
+ rid = _rid_int(getattr(a, 'resource', None))
540
+ if rid:
541
+ color_rt_ids.append(rid)
542
+ depth_rt_id = _rid_int(getattr(dfb.depthAttachment, 'resource', None)) if getattr(dfb, 'depthAttachment', None) else 0
543
+ color_rt_join = ';'.join(str(x) for x in color_rt_ids)
544
+
545
+ # shaders
546
+ program_id = _rid_int(getattr(glp.vertexShader, 'programResourceId', None)) or \
547
+ _rid_int(getattr(glp.fragmentShader, 'programResourceId', None))
548
+ vs_id = _rid_int(getattr(glp.vertexShader, 'shaderResourceId', None))
549
+ fs_id = _rid_int(getattr(glp.fragmentShader, 'shaderResourceId', None))
550
+
551
+ # ibo
552
+ vi = glp.vertexInput
553
+ ibo_id = _rid_int(getattr(vi, 'indexBuffer', None))
554
+ ibo_type = ''
555
+ if hasattr(vi, 'indexByteStride'):
556
+ s = int(vi.indexByteStride)
557
+ ibo_type = {1: 'UNSIGNED_BYTE', 2: 'UNSIGNED_SHORT', 4: 'UNSIGNED_INT'}.get(s, str(s))
558
+
559
+ # GPU duration: from pre-fetched counters
560
+ gpu_s = event_durations.get(ev_id, 0.0)
561
+
562
+ # Post-VS data (count only; samples done later)
563
+ post_vs_prim = 0
564
+ post_vs_vert = 0
565
+ try:
566
+ mf = ctrl.GetPostVSData(0, 0, rd.MeshDataStage.VSOut)
567
+ if mf and getattr(mf, 'vertexResourceId', None) and mf.vertexResourceId != rd.ResourceId.Null():
568
+ post_vs_vert = int(mf.numIndices)
569
+ post_vs_prim = _primitives_for(_topology_short(mf.topology), post_vs_vert, 1)
570
+ except Exception:
571
+ pass
572
+
573
+ draw_name = base_row.get('chunk_name', '')
574
+
575
+ return {
576
+ **{k: base_row[k] for k in ID_COLS},
577
+ 'event_id': ev_id,
578
+ 'parent_pass_path': parent_path,
579
+ 'draw_name': draw_name,
580
+ 'num_indices': base_row['num_indices'],
581
+ 'num_instances': base_row['num_instances'],
582
+ 'base_vertex': int(getattr(action_attr_get(ctrl, ev_id), 'baseVertex', 0) or 0),
583
+ 'vertex_offset': int(getattr(action_attr_get(ctrl, ev_id), 'vertexOffset', 0) or 0),
584
+ 'index_offset': int(getattr(action_attr_get(ctrl, ev_id), 'indexOffset', 0) or 0),
585
+ 'topology': topology,
586
+ 'program_id': program_id,
587
+ 'vs_shader_id': vs_id,
588
+ 'fs_shader_id': fs_id,
589
+ 'color_rt_ids': color_rt_join,
590
+ 'depth_rt_id': depth_rt_id,
591
+ 'viewport_x': vp_x, 'viewport_y': vp_y, 'viewport_w': vp_w, 'viewport_h': vp_h,
592
+ 'scissor_x': sc_x, 'scissor_y': sc_y, 'scissor_w': sc_w, 'scissor_h': sc_h,
593
+ 'cull_mode': cull, 'front_face': front_face,
594
+ 'depth_test_enable': depth_test, 'depth_write_enable': depth_write,
595
+ 'depth_func': depth_func,
596
+ 'stencil_enable': stencil_enable,
597
+ 'stencil_front_pass_op': s_fp,
598
+ 'stencil_front_fail_op': s_ff,
599
+ 'stencil_front_depth_fail_op': s_fd,
600
+ 'stencil_back_pass_op': s_bp,
601
+ 'stencil_back_fail_op': s_bf,
602
+ 'stencil_back_depth_fail_op': s_bd,
603
+ 'stencil_ref': sref, 'stencil_read_mask': srd, 'stencil_write_mask': swr,
604
+ 'blend_enable': blend_enable,
605
+ 'blend_src_color': blend_src_color,
606
+ 'blend_dst_color': blend_dst_color,
607
+ 'blend_op_color': blend_op_color,
608
+ 'blend_src_alpha': blend_src_alpha,
609
+ 'blend_dst_alpha': blend_dst_alpha,
610
+ 'blend_op_alpha': blend_op_alpha,
611
+ 'color_write_mask': color_write_mask,
612
+ 'ibo_id': ibo_id, 'ibo_index_type': ibo_type,
613
+ 'gpu_duration_s': gpu_s,
614
+ 'post_vs_primitives': post_vs_prim,
615
+ 'post_vs_vertices': post_vs_vert,
616
+ }
617
+
618
+
619
+ def action_attr_get(ctrl, ev_id: int):
620
+ """Reach the current action for ev_id via ctrl. Used to read base_vertex etc."""
621
+ # Note: ctrl.SetFrameEvent already positioned us; pipe.GetMeshFormat is one path
622
+ # but reading via the cached action node is simpler. We pass _action_by_event in.
623
+ return _ACTION_CACHE.get(ev_id, _Empty())
624
+
625
+
626
+ class _Empty: pass
627
+
628
+ _ACTION_CACHE: dict[int, object] = {}
629
+
630
+
631
+ # --- Per-draw aux extractors (bindings, vertex_inputs, descriptor_access, post_vs samples, fbo state) ---
632
+
633
+ def _extract_draw_aux(ctrl, ev_id: int, ctx: dict, draw_row: dict,
634
+ vi_rows: list, db_rows: list, da_rows: list, pvs_rows: list,
635
+ fbo_state_per_event: dict,
636
+ mesh_hash_cache: dict,
637
+ buffer_map: dict,
638
+ pvs_max_verts: int) -> None:
639
+ """Extend writer lists with bindings/vertex_inputs/descriptor_access/post_vs
640
+ samples for the current draw event. Mutates draw_row to add mesh_hash and
641
+ screen_min_x/min_y/max_x/max_y/screen_coverage_px. SetFrameEvent already done.
642
+ """
643
+ import renderdoc as rd # type: ignore
644
+ import struct
645
+ import hashlib
646
+
647
+ glp = ctrl.GetGLPipelineState()
648
+
649
+ # FBO state at this draw: drawFBO with its color/depth/stencil attachments
650
+ try:
651
+ dfb = glp.framebuffer.drawFBO
652
+ fbo_rid = _rid_int(getattr(dfb, 'resourceId', None))
653
+ if fbo_rid and fbo_rid not in fbo_state_per_event:
654
+ attachments: list[dict] = []
655
+ for i, a in enumerate(getattr(dfb, 'colorAttachments', None) or ()):
656
+ rid = _rid_int(getattr(a, 'resource', None))
657
+ if not rid:
658
+ continue
659
+ attachments.append({
660
+ 'attachment_point': f'GL_COLOR_ATTACHMENT{i}',
661
+ 'kind': 'tex',
662
+ 'resource_id': rid,
663
+ 'mip_level': int(getattr(a, 'firstMip', 0) or 0),
664
+ 'layer': int(getattr(a, 'firstSlice', 0) or 0),
665
+ 'first_event': ev_id,
666
+ })
667
+ for tag, attr_name in (('GL_DEPTH_ATTACHMENT', 'depthAttachment'),
668
+ ('GL_STENCIL_ATTACHMENT', 'stencilAttachment')):
669
+ a = getattr(dfb, attr_name, None)
670
+ if a is None:
671
+ continue
672
+ rid = _rid_int(getattr(a, 'resource', None))
673
+ if not rid:
674
+ continue
675
+ attachments.append({
676
+ 'attachment_point': tag,
677
+ 'kind': 'tex',
678
+ 'resource_id': rid,
679
+ 'mip_level': int(getattr(a, 'firstMip', 0) or 0),
680
+ 'layer': int(getattr(a, 'firstSlice', 0) or 0),
681
+ 'first_event': ev_id,
682
+ })
683
+ fbo_state_per_event[fbo_rid] = attachments
684
+ except Exception:
685
+ pass
686
+
687
+ # Vertex inputs (per attribute slot)
688
+ vi = glp.vertexInput
689
+ attrs = getattr(vi, 'attributes', None) or []
690
+ vbuffers = getattr(vi, 'vertexBuffers', None) or []
691
+ for i, attr in enumerate(attrs):
692
+ slot = int(getattr(attr, 'vertexBufferSlot', 0) or 0)
693
+ buf = vbuffers[slot] if 0 <= slot < len(vbuffers) else None
694
+ bytes_offset = int(getattr(attr, 'byteOffset', 0) or 0)
695
+ comp_type = _enum_short(getattr(attr.format, 'compType', '')) if hasattr(attr, 'format') else ''
696
+ comp_count = int(getattr(attr.format, 'compCount', 0) or 0) if hasattr(attr, 'format') else 0
697
+ normalized = int(bool(getattr(attr.format, 'compType', None) and 'NORM' in _enum_short(attr.format.compType).upper())) if hasattr(attr, 'format') else 0
698
+ integer = int(bool(comp_type.startswith('UInt') or comp_type.startswith('SInt'))) if comp_type else 0
699
+ stride = int(getattr(buf, 'byteStride', 0) or 0) if buf else 0
700
+ buffer_id = _rid_int(getattr(buf, 'resourceId', None)) if buf else 0
701
+ divisor = int(getattr(buf, 'instanceDivisor', 0) or 0) if buf else 0
702
+ vi_rows.append({
703
+ **ctx,
704
+ 'event_id': ev_id, 'attribute_index': i,
705
+ 'attribute_name': getattr(attr, 'name', '') or '',
706
+ 'enabled': int(bool(getattr(attr, 'enabled', True))),
707
+ 'component_count': comp_count, 'component_type': comp_type,
708
+ 'normalized': normalized, 'integer': integer,
709
+ 'stride_bytes': stride, 'offset_bytes': bytes_offset,
710
+ 'buffer_id': buffer_id, 'vbo_slot': slot,
711
+ 'divisor': divisor,
712
+ })
713
+
714
+ # Draw bindings: textures + samplers + uniform buffers + ssbos + image units
715
+ textures = getattr(glp, 'textures', None) or []
716
+ for i, t in enumerate(textures):
717
+ rid = _rid_int(getattr(t, 'resourceId', None))
718
+ if not rid:
719
+ continue
720
+ db_rows.append({
721
+ **ctx, 'event_id': ev_id,
722
+ 'slot_kind': 'texture', 'slot_index': i, 'resource_id': rid,
723
+ 'sampler_id': 0, 'offset': 0, 'size': 0, 'stride': 0,
724
+ })
725
+ samplers = getattr(glp, 'samplers', None) or []
726
+ for i, s in enumerate(samplers):
727
+ rid = _rid_int(getattr(s, 'resourceId', None))
728
+ if not rid:
729
+ continue
730
+ db_rows.append({
731
+ **ctx, 'event_id': ev_id,
732
+ 'slot_kind': 'sampler', 'slot_index': i, 'resource_id': 0,
733
+ 'sampler_id': rid, 'offset': 0, 'size': 0, 'stride': 0,
734
+ })
735
+ ubos = getattr(glp, 'uniformBuffers', None) or []
736
+ for i, b in enumerate(ubos):
737
+ rid = _rid_int(getattr(b, 'resourceId', None))
738
+ if not rid:
739
+ continue
740
+ db_rows.append({
741
+ **ctx, 'event_id': ev_id,
742
+ 'slot_kind': 'ubo', 'slot_index': i, 'resource_id': rid,
743
+ 'sampler_id': 0,
744
+ 'offset': int(getattr(b, 'byteOffset', 0) or 0),
745
+ 'size': int(getattr(b, 'byteSize', 0) or 0),
746
+ 'stride': 0,
747
+ })
748
+ ssbos = getattr(glp, 'shaderStorageBuffers', None) or []
749
+ for i, b in enumerate(ssbos):
750
+ rid = _rid_int(getattr(b, 'resourceId', None))
751
+ if not rid:
752
+ continue
753
+ db_rows.append({
754
+ **ctx, 'event_id': ev_id,
755
+ 'slot_kind': 'ssbo', 'slot_index': i, 'resource_id': rid,
756
+ 'sampler_id': 0,
757
+ 'offset': int(getattr(b, 'byteOffset', 0) or 0),
758
+ 'size': int(getattr(b, 'byteSize', 0) or 0),
759
+ 'stride': 0,
760
+ })
761
+
762
+ # Descriptor access (resources actually used by bound shaders)
763
+ try:
764
+ accesses = ctrl.GetDescriptorAccess()
765
+ except Exception:
766
+ accesses = ()
767
+ for a in accesses or ():
768
+ try:
769
+ kind = _enum_short(getattr(a, 'type', ''))
770
+ slot = int(getattr(a, 'index', 0) or 0)
771
+ rid = _rid_int(getattr(a, 'descriptorStore', None))
772
+ view = _rid_int(getattr(a, 'view', None))
773
+ byte_offset = int(getattr(a, 'byteOffset', 0) or 0)
774
+ byte_size = int(getattr(a, 'byteSize', 0) or 0)
775
+ access = _enum_short(getattr(a, 'stage', ''))
776
+ da_rows.append({
777
+ **ctx, 'event_id': ev_id,
778
+ 'descriptor_kind': kind, 'slot_index': slot,
779
+ 'resource_id': rid, 'view_id': view,
780
+ 'byte_offset': byte_offset, 'byte_size': byte_size,
781
+ 'access_type': access,
782
+ })
783
+ except Exception:
784
+ continue
785
+
786
+ # Post-VS samples + screen-space bbox derivation
787
+ vp_x = float(draw_row.get('viewport_x', 0) or 0)
788
+ vp_y = float(draw_row.get('viewport_y', 0) or 0)
789
+ vp_w = float(draw_row.get('viewport_w', 0) or 0)
790
+ vp_h = float(draw_row.get('viewport_h', 0) or 0)
791
+ min_x = min_y = float('inf')
792
+ max_x = max_y = float('-inf')
793
+ have_bbox = False
794
+ try:
795
+ mf = ctrl.GetPostVSData(0, 0, rd.MeshDataStage.VSOut)
796
+ if mf and mf.vertexResourceId and mf.vertexResourceId != rd.ResourceId.Null():
797
+ stride = int(getattr(mf, 'vertexByteStride', 0) or 0)
798
+ n_v = min(int(mf.numIndices), pvs_max_verts)
799
+ if stride >= 16 and n_v > 0:
800
+ raw = ctrl.GetBufferData(mf.vertexResourceId, 0, stride * n_v)
801
+ for vi_idx in range(n_v):
802
+ off = vi_idx * stride
803
+ try:
804
+ x, y, z, w = struct.unpack_from('<ffff', raw, off)
805
+ except struct.error:
806
+ break
807
+ clipped = 0
808
+ if w == 0.0 or abs(x) > abs(w) or abs(y) > abs(w) or abs(z) > abs(w):
809
+ clipped = 1
810
+ pvs_rows.append({
811
+ **ctx,
812
+ 'event_id': ev_id, 'vertex_index': vi_idx,
813
+ 'position_x': x, 'position_y': y, 'position_z': z, 'position_w': w,
814
+ 'clipped': clipped,
815
+ })
816
+ # NDC -> viewport pixel space
817
+ if w != 0.0 and not clipped:
818
+ ndc_x = x / w
819
+ ndc_y = y / w
820
+ sx = vp_x + (ndc_x * 0.5 + 0.5) * vp_w
821
+ sy = vp_y + (ndc_y * 0.5 + 0.5) * vp_h
822
+ if sx < min_x: min_x = sx
823
+ if sy < min_y: min_y = sy
824
+ if sx > max_x: max_x = sx
825
+ if sy > max_y: max_y = sy
826
+ have_bbox = True
827
+ except Exception:
828
+ pass
829
+
830
+ if have_bbox:
831
+ # clamp to viewport
832
+ cx0 = max(min_x, vp_x); cy0 = max(min_y, vp_y)
833
+ cx1 = min(max_x, vp_x + vp_w); cy1 = min(max_y, vp_y + vp_h)
834
+ draw_row['screen_min_x'] = float(min_x)
835
+ draw_row['screen_min_y'] = float(min_y)
836
+ draw_row['screen_max_x'] = float(max_x)
837
+ draw_row['screen_max_y'] = float(max_y)
838
+ if cx1 > cx0 and cy1 > cy0:
839
+ draw_row['screen_coverage_px'] = int(round((cx1 - cx0) * (cy1 - cy0)))
840
+ else:
841
+ draw_row['screen_coverage_px'] = 0
842
+ else:
843
+ draw_row['screen_min_x'] = 0.0
844
+ draw_row['screen_min_y'] = 0.0
845
+ draw_row['screen_max_x'] = 0.0
846
+ draw_row['screen_max_y'] = 0.0
847
+ draw_row['screen_coverage_px'] = 0
848
+
849
+ # Mesh hash for instancing-candidate detection
850
+ ibo_id = int(draw_row.get('ibo_id', 0) or 0)
851
+ n_indices = int(draw_row.get('num_indices', 0) or 0)
852
+ base_vertex = int(draw_row.get('base_vertex', 0) or 0)
853
+ index_offset = int(draw_row.get('index_offset', 0) or 0)
854
+ vbo_slot_0_id = 0
855
+ vbuffers = getattr(vi, 'vertexBuffers', None) or []
856
+ if vbuffers:
857
+ vbo_slot_0_id = _rid_int(getattr(vbuffers[0], 'resourceId', None))
858
+
859
+ if ibo_id > 0:
860
+ identity = (ibo_id, base_vertex, index_offset, n_indices)
861
+ cached = mesh_hash_cache.get(identity)
862
+ if cached is not None:
863
+ draw_row['mesh_hash'] = cached
864
+ else:
865
+ ibo_obj = buffer_map.get(ibo_id)
866
+ data_bytes = b''
867
+ if ibo_obj is not None:
868
+ try:
869
+ # index_offset in draws is the offset within IBO (in bytes typically
870
+ # for glDrawElementsBaseVertex* path; we read 1KB from there)
871
+ data_bytes = bytes(ctrl.GetBufferData(ibo_obj.resourceId,
872
+ max(index_offset, 0), 1024))
873
+ except Exception:
874
+ data_bytes = b''
875
+ h = hashlib.sha256()
876
+ h.update(f'{ibo_id}|{base_vertex}|{index_offset}|{n_indices}|'.encode())
877
+ h.update(data_bytes)
878
+ digest = h.hexdigest()[:24]
879
+ mesh_hash_cache[identity] = digest
880
+ draw_row['mesh_hash'] = digest
881
+ else:
882
+ identity = ('noidx', vbo_slot_0_id, int(draw_row.get('vertex_offset', 0) or 0), n_indices)
883
+ cached = mesh_hash_cache.get(identity)
884
+ if cached is None:
885
+ h = hashlib.sha256(f'noidx|{vbo_slot_0_id}|{draw_row.get("vertex_offset",0)}|{n_indices}'.encode())
886
+ cached = h.hexdigest()[:24]
887
+ mesh_hash_cache[identity] = cached
888
+ draw_row['mesh_hash'] = cached
889
+
890
+
891
+ # --- Uniforms per pass -------------------------------------------------------
892
+
893
+ def _snapshot_uniforms(ctrl, ev_id: int, marker_path: str, ctx: dict) -> dict | None:
894
+ """For the first draw under a unique pass marker, capture each bound
895
+ constant block: the binding metadata + the reflection layout (name,
896
+ type, byteOffset, byteSize per member) + a hex-encoded UBO data sample.
897
+
898
+ SetFrameEvent must have been called on ev_id. Returns dict or None.
899
+ """
900
+ import renderdoc as rd # type: ignore
901
+
902
+ glp = ctrl.GetGLPipelineState()
903
+ blocks_out: list[dict] = []
904
+
905
+ stages = [
906
+ ('vertex', getattr(glp, 'vertexShader', None)),
907
+ ('fragment', getattr(glp, 'fragmentShader', None)),
908
+ ('compute', getattr(glp, 'computeShader', None)),
909
+ ]
910
+ seen_buffer_keys: set[tuple[int, int, int]] = set()
911
+
912
+ # First fetch raw UBO bytes (needed to decode member values inline).
913
+ raw_by_binding: dict[int, dict] = {}
914
+ raw_bytes_by_slot: dict[int, bytes] = {}
915
+ try:
916
+ accesses = ctrl.GetDescriptorAccess()
917
+ except Exception:
918
+ accesses = ()
919
+ for a in accesses or ():
920
+ try:
921
+ kind = _enum_short(getattr(a, 'type', ''))
922
+ if kind != 'ConstantBuffer':
923
+ continue
924
+ slot = int(getattr(a, 'index', 0) or 0)
925
+ buf_rid = getattr(a, 'descriptorStore', None)
926
+ if buf_rid is None:
927
+ continue
928
+ offset = int(getattr(a, 'byteOffset', 0) or 0)
929
+ size = int(getattr(a, 'byteSize', 0) or 0)
930
+ if not size:
931
+ continue
932
+ sample = min(size, 1024)
933
+ data = bytes(ctrl.GetBufferData(buf_rid, offset, sample))
934
+ raw_by_binding[slot] = {
935
+ 'buffer_id': _rid_int(buf_rid),
936
+ 'offset': offset, 'size': size,
937
+ 'raw_hex': data.hex(),
938
+ }
939
+ raw_bytes_by_slot[slot] = data
940
+ except Exception:
941
+ continue
942
+
943
+ for stage_name, stage in stages:
944
+ if stage is None:
945
+ continue
946
+ refl = getattr(stage, 'reflection', None)
947
+ if refl is None:
948
+ continue
949
+ prog_rid = _rid_int(getattr(stage, 'programResourceId', None))
950
+ shader_rid = _rid_int(getattr(stage, 'shaderResourceId', None))
951
+ constant_blocks = getattr(refl, 'constantBlocks', None) or ()
952
+ for cb in constant_blocks:
953
+ binding_index = int(getattr(cb, 'bindPoint', 0) or 0)
954
+ members_out: list[dict] = []
955
+ ubo_data = raw_bytes_by_slot.get(binding_index, b'')
956
+ for var in getattr(cb, 'variables', None) or ():
957
+ vt = getattr(var, 'type', None)
958
+ byte_offset = int(getattr(var, 'byteOffset', 0) or 0)
959
+ base_type = _enum_short(getattr(vt, 'baseType', '')) if vt else ''
960
+ rows = int(getattr(vt, 'rows', 0) or 0) if vt else 0
961
+ cols = int(getattr(vt, 'columns', 0) or 0) if vt else 0
962
+ elements = int(getattr(vt, 'elements', 0) or 0) if vt else 0
963
+ m = {
964
+ 'name': getattr(var, 'name', ''),
965
+ 'byte_offset': byte_offset,
966
+ 'type': base_type,
967
+ 'rows': rows, 'columns': cols, 'elements': elements,
968
+ }
969
+ value, truncated = _decode_ubo_member(ubo_data, byte_offset,
970
+ base_type, rows, cols, elements,
971
+ array_cap=16)
972
+ if value is not None:
973
+ m['value'] = value
974
+ if truncated:
975
+ m['truncated'] = True
976
+ members_out.append(m)
977
+ byte_size = int(getattr(cb, 'byteSize', 0) or 0)
978
+ blocks_out.append({
979
+ 'stage': stage_name,
980
+ 'shader_id': shader_rid,
981
+ 'program_id': prog_rid,
982
+ 'block_name': getattr(cb, 'name', ''),
983
+ 'binding_index': binding_index,
984
+ 'byte_size': byte_size,
985
+ 'members': members_out,
986
+ })
987
+
988
+ if not blocks_out:
989
+ return None
990
+
991
+ return {
992
+ **ctx,
993
+ 'event_id': ev_id,
994
+ 'marker_path': marker_path,
995
+ 'constant_blocks': blocks_out,
996
+ 'raw_by_binding': raw_by_binding,
997
+ }
998
+
999
+
1000
+ def _decode_ubo_member(raw: bytes, byte_offset: int, base_type: str,
1001
+ rows: int, cols: int, elements: int,
1002
+ array_cap: int = 16):
1003
+ """Decode std140-laid-out scalars/vectors/matrices from raw UBO bytes.
1004
+
1005
+ Returns (value, truncated). value is None when decode not possible
1006
+ (out-of-range offset, unknown type, no bytes). Arrays beyond array_cap
1007
+ are truncated; truncated=True flagged.
1008
+ """
1009
+ import struct
1010
+ if not raw or byte_offset < 0:
1011
+ return None, False
1012
+ n = len(raw)
1013
+ # Determine scalar size + struct fmt by base type.
1014
+ fmt = None
1015
+ elem_size = 0
1016
+ if base_type in ('Float',):
1017
+ fmt = '<f'; elem_size = 4
1018
+ elif base_type in ('SInt32', 'SInt', 'Int'):
1019
+ fmt = '<i'; elem_size = 4
1020
+ elif base_type in ('UInt32', 'UInt'):
1021
+ fmt = '<I'; elem_size = 4
1022
+ else:
1023
+ return None, False
1024
+
1025
+ def read_one(off, rows_, cols_):
1026
+ if rows_ <= 1 and cols_ <= 1:
1027
+ if off + elem_size > n:
1028
+ return None
1029
+ return struct.unpack_from(fmt, raw, off)[0]
1030
+ if rows_ == 1 and cols_ > 1:
1031
+ need = elem_size * cols_
1032
+ if off + need > n:
1033
+ return None
1034
+ return list(struct.unpack_from(fmt * cols_, raw, off))
1035
+ # Matrix: GL std140 layout = each column on 16-byte boundary, column-major
1036
+ out = []
1037
+ for c in range(cols_):
1038
+ row_off = off + c * 16
1039
+ if row_off + elem_size * rows_ > n:
1040
+ return None
1041
+ out.append(list(struct.unpack_from(fmt * rows_, raw, row_off)))
1042
+ return out
1043
+
1044
+ n_elements = max(1, elements)
1045
+ truncated = False
1046
+ if n_elements > array_cap:
1047
+ n_elements = array_cap
1048
+ truncated = True
1049
+
1050
+ if elements <= 1:
1051
+ return read_one(byte_offset, rows, cols), False
1052
+
1053
+ # std140 array stride: 16 bytes per scalar/vec; mat stride = cols * 16
1054
+ if rows <= 1:
1055
+ stride = 16
1056
+ else:
1057
+ stride = cols * 16
1058
+ out_list = []
1059
+ for ei in range(n_elements):
1060
+ v = read_one(byte_offset + ei * stride, rows, cols)
1061
+ if v is None:
1062
+ break
1063
+ out_list.append(v)
1064
+ return out_list, truncated
1065
+
1066
+
1067
+ # --- Clears + dispatches extraction -----------------------------------------
1068
+
1069
+ _CLEAR_CHUNK_NAMES = {'glClear', 'glClearBufferfv', 'glClearBufferfi',
1070
+ 'glClearBufferiv', 'glClearBufferuiv'}
1071
+
1072
+
1073
+ def _decode_clear_chunk(chunk) -> dict:
1074
+ """Decode a clear chunk's args. Probed on Arm RD v1.43.
1075
+
1076
+ Children for glClearBufferfv: framebuffer (ResourceId), buffer (enum string),
1077
+ drawbuffer (int), value (array of 4 with sub.name='$el' .AsFloat()).
1078
+ Children for glClearBufferfi: framebuffer, buffer (GL_DEPTH_STENCIL),
1079
+ drawbuffer, depth (AsFloat), stencil (AsInt).
1080
+ Children for glClear: mask (bitfield via AsInt).
1081
+ """
1082
+ out = {
1083
+ 'color_r': 0.0, 'color_g': 0.0, 'color_b': 0.0, 'color_a': 0.0,
1084
+ 'depth_value': 0.0, 'stencil_value': 0,
1085
+ 'buffer_mask': 0, 'target': '', 'fbo_id': 0,
1086
+ }
1087
+ name = chunk.name
1088
+ fb = chunk.FindChild('framebuffer')
1089
+ if fb is not None:
1090
+ try:
1091
+ out['fbo_id'] = _rid_int(fb.AsResourceId())
1092
+ except Exception:
1093
+ pass
1094
+ if name == 'glClear':
1095
+ m = chunk.FindChild('mask')
1096
+ if m is not None:
1097
+ try: out['buffer_mask'] = int(m.AsInt() or 0)
1098
+ except Exception: pass
1099
+ elif name in ('glClearBufferfv', 'glClearBufferiv', 'glClearBufferuiv'):
1100
+ b = chunk.FindChild('buffer')
1101
+ if b is not None:
1102
+ try: out['target'] = b.AsString() or ''
1103
+ except Exception: pass
1104
+ v = chunk.FindChild('value')
1105
+ if v is not None:
1106
+ try:
1107
+ n = v.NumChildren()
1108
+ keys = ('color_r', 'color_g', 'color_b', 'color_a')
1109
+ for i in range(min(n, 4)):
1110
+ child = v.GetChild(i)
1111
+ if child is None: continue
1112
+ if name == 'glClearBufferfv':
1113
+ out[keys[i]] = float(child.AsFloat() or 0.0)
1114
+ else:
1115
+ out[keys[i]] = float(child.AsInt() or 0)
1116
+ except Exception:
1117
+ pass
1118
+ elif name == 'glClearBufferfi':
1119
+ b = chunk.FindChild('buffer')
1120
+ if b is not None:
1121
+ try: out['target'] = b.AsString() or ''
1122
+ except Exception: pass
1123
+ d = chunk.FindChild('depth')
1124
+ if d is not None:
1125
+ try: out['depth_value'] = float(d.AsFloat() or 0.0)
1126
+ except Exception: pass
1127
+ s = chunk.FindChild('stencil')
1128
+ if s is not None:
1129
+ try: out['stencil_value'] = int(s.AsInt() or 0)
1130
+ except Exception: pass
1131
+ return out
1132
+
1133
+
1134
+ def _extract_clears(sd, events: list[dict], action_by_event: dict,
1135
+ ctx: dict) -> list[dict]:
1136
+ """One row per clear action with decoded color/depth/stencil/fbo_id."""
1137
+ rows: list[dict] = []
1138
+ chunks = sd.chunks
1139
+ n_chunks = len(chunks)
1140
+ for e in events:
1141
+ if not e['is_clear']:
1142
+ continue
1143
+ ev_id = e['event_id']
1144
+ action = action_by_event.get(ev_id)
1145
+ decoded = None
1146
+ if action is not None:
1147
+ api_evs = getattr(action, 'events', None) or ()
1148
+ for api_ev in api_evs:
1149
+ ci = getattr(api_ev, 'chunkIndex', -1)
1150
+ if 0 <= ci < n_chunks:
1151
+ c = chunks[ci]
1152
+ if c.name in _CLEAR_CHUNK_NAMES:
1153
+ try:
1154
+ decoded = _decode_clear_chunk(c)
1155
+ except Exception:
1156
+ decoded = None
1157
+ break
1158
+ if decoded is None:
1159
+ decoded = {
1160
+ 'color_r': 0.0, 'color_g': 0.0, 'color_b': 0.0, 'color_a': 0.0,
1161
+ 'depth_value': 0.0, 'stencil_value': 0,
1162
+ 'buffer_mask': 0, 'target': '', 'fbo_id': 0,
1163
+ }
1164
+ rows.append({
1165
+ **ctx,
1166
+ 'event_id': ev_id,
1167
+ 'parent_marker_path': e['parent_marker_path'],
1168
+ 'target': decoded['target'],
1169
+ 'color_r': decoded['color_r'], 'color_g': decoded['color_g'],
1170
+ 'color_b': decoded['color_b'], 'color_a': decoded['color_a'],
1171
+ 'depth_value': decoded['depth_value'],
1172
+ 'stencil_value': decoded['stencil_value'],
1173
+ 'buffer_mask': decoded['buffer_mask'],
1174
+ 'fbo_id': decoded['fbo_id'],
1175
+ })
1176
+ return rows
1177
+
1178
+
1179
+ def _sample_vbos(ctrl, used_vbo_ids: set, ctx: dict, max_verts: int = 16) -> list[dict]:
1180
+ """Sample first `max_verts` raw bytes of each used VBO. 64 bytes per row."""
1181
+ import renderdoc as rd # type: ignore
1182
+
1183
+ rows: list[dict] = []
1184
+ buffers = {_rid_int(b.resourceId): b for b in ctrl.GetBuffers()}
1185
+ for bid in sorted(used_vbo_ids):
1186
+ if not bid or bid not in buffers:
1187
+ continue
1188
+ b = buffers[bid]
1189
+ size = int(b.length) if hasattr(b, 'length') else 0
1190
+ if size <= 0:
1191
+ continue
1192
+ sample_bytes = min(64 * max_verts, size)
1193
+ try:
1194
+ data = ctrl.GetBufferData(b.resourceId, 0, sample_bytes)
1195
+ except Exception:
1196
+ continue
1197
+ stride = 64 # presumed vertex stride; tools can re-interpret per layout
1198
+ n_v = min(max_verts, len(data) // stride if stride else 0)
1199
+ for vi in range(n_v):
1200
+ off = vi * stride
1201
+ seg = data[off:off + stride]
1202
+ import struct
1203
+ try:
1204
+ f0, f1, f2, f3 = struct.unpack_from('<ffff', seg, 0)
1205
+ except struct.error:
1206
+ f0 = f1 = f2 = f3 = 0.0
1207
+ rows.append({
1208
+ **ctx,
1209
+ 'buffer_id': bid,
1210
+ 'vertex_index': vi,
1211
+ 'byte_offset': off,
1212
+ 'raw_hex': bytes(seg[:64]).hex(),
1213
+ 'as_f32_0': float(f0), 'as_f32_1': float(f1),
1214
+ 'as_f32_2': float(f2), 'as_f32_3': float(f3),
1215
+ })
1216
+ return rows
1217
+
1218
+
1219
+ def _sample_ibos(ctrl, used_ibo_ids: set, ctx: dict, max_indices: int = 32) -> list[dict]:
1220
+ import renderdoc as rd # type: ignore
1221
+ import struct
1222
+
1223
+ rows: list[dict] = []
1224
+ buffers = {_rid_int(b.resourceId): b for b in ctrl.GetBuffers()}
1225
+ for bid in sorted(used_ibo_ids):
1226
+ if not bid or bid not in buffers:
1227
+ continue
1228
+ b = buffers[bid]
1229
+ size = int(b.length) if hasattr(b, 'length') else 0
1230
+ if size <= 0:
1231
+ continue
1232
+ # try uint16 first (mobile typical)
1233
+ nb = min(max_indices * 2, size)
1234
+ try:
1235
+ data = ctrl.GetBufferData(b.resourceId, 0, nb)
1236
+ except Exception:
1237
+ continue
1238
+ n = min(max_indices, len(data) // 2)
1239
+ for i in range(n):
1240
+ try:
1241
+ v = struct.unpack_from('<H', data, i * 2)[0]
1242
+ except struct.error:
1243
+ v = 0
1244
+ rows.append({
1245
+ **ctx,
1246
+ 'buffer_id': bid, 'index_position': i,
1247
+ 'index_value': int(v), 'index_type': 'uint16',
1248
+ })
1249
+ return rows
1250
+
1251
+
1252
+ def _sample_textures(ctrl, ctx: dict, max_dim: int = 256, max_cols: int = 16) -> list[dict]:
1253
+ import renderdoc as rd # type: ignore
1254
+ import struct
1255
+
1256
+ rows: list[dict] = []
1257
+ sub = rd.Subresource(0, 0, 0)
1258
+ for tex in ctrl.GetTextures():
1259
+ if tex.width <= 0 or tex.height <= 0:
1260
+ continue
1261
+ if tex.width > max_dim or tex.height > max_dim:
1262
+ continue
1263
+ try:
1264
+ data = ctrl.GetTextureData(tex.resourceId, sub)
1265
+ except Exception:
1266
+ continue
1267
+ if not data:
1268
+ continue
1269
+ # Heuristic: 4 bytes per pixel for unorm8 RGBA
1270
+ bpp = 4
1271
+ row_stride = tex.width * bpp
1272
+ if len(data) < row_stride:
1273
+ continue
1274
+ n_cols = min(max_cols, tex.width)
1275
+ for col in range(n_cols):
1276
+ off = col * bpp
1277
+ try:
1278
+ r, g, b, a = struct.unpack_from('BBBB', data, off)
1279
+ except struct.error:
1280
+ continue
1281
+ rows.append({
1282
+ **ctx,
1283
+ 'tex_id': _rid_int(tex.resourceId),
1284
+ 'row_index': 0, 'col_index': col,
1285
+ 'raw_hex': bytes(data[off:off + 16]).hex(),
1286
+ 'as_unorm8_r': r, 'as_unorm8_g': g,
1287
+ 'as_unorm8_b': b, 'as_unorm8_a': a,
1288
+ })
1289
+ return rows
1290
+
1291
+
1292
+ def _extract_dispatches(ctrl, events: list[dict], event_durations: dict,
1293
+ ctx: dict) -> list[dict]:
1294
+ """One row per compute dispatch with program + work group dimensions."""
1295
+ rows: list[dict] = []
1296
+ for e in events:
1297
+ if not e['is_dispatch']:
1298
+ continue
1299
+ ev = e['event_id']
1300
+ gpu = event_durations.get(ev, 0.0)
1301
+ program = 0; cs_shader = 0
1302
+ wg_x = wg_y = wg_z = 0
1303
+ try:
1304
+ ctrl.SetFrameEvent(ev, False)
1305
+ glp = ctrl.GetGLPipelineState()
1306
+ program = _rid_int(getattr(glp.computeShader, 'programResourceId', None))
1307
+ cs_shader = _rid_int(getattr(glp.computeShader, 'shaderResourceId', None))
1308
+ refl = getattr(glp.computeShader, 'reflection', None)
1309
+ if refl is not None:
1310
+ ds = getattr(refl, 'dispatchThreadsDimension', None) or (0, 0, 0)
1311
+ wg_x, wg_y, wg_z = int(ds[0]), int(ds[1]), int(ds[2])
1312
+ except Exception:
1313
+ pass
1314
+ gx, gy, gz = e['dispatch_x'], e['dispatch_y'], e['dispatch_z']
1315
+ rows.append({
1316
+ **ctx,
1317
+ 'event_id': ev, 'parent_marker_path': e['parent_marker_path'],
1318
+ 'program_id': program, 'cs_shader_id': cs_shader,
1319
+ 'group_count_x': gx, 'group_count_y': gy, 'group_count_z': gz,
1320
+ 'work_group_size_x': wg_x, 'work_group_size_y': wg_y, 'work_group_size_z': wg_z,
1321
+ 'total_threads': gx * gy * gz * max(1, wg_x) * max(1, wg_y) * max(1, wg_z),
1322
+ 'ssbo_bindings': '', 'image_bindings': '', 'atomic_counter_bindings': '',
1323
+ 'gpu_duration_s': gpu,
1324
+ })
1325
+ return rows
1326
+
1327
+
1328
+ # --- FBO finalizer ----------------------------------------------------------
1329
+
1330
+ def _build_fbo_rows(ctrl, fbo_state_per_event: dict[int, list[dict]],
1331
+ labels: dict[int, str], ctx: dict) -> list[dict]:
1332
+ """Turn the (fbo_id -> attachments list) map into one row per
1333
+ (fbo_id, attachment_point). Cross-reference texture metadata for
1334
+ format/width/height.
1335
+ """
1336
+ import renderdoc as rd # type: ignore
1337
+
1338
+ # Build texture metadata lookup
1339
+ tex_info: dict[int, dict] = {}
1340
+ for t in ctrl.GetTextures():
1341
+ rid = _rid_int(t.resourceId)
1342
+ if not rid:
1343
+ continue
1344
+ try:
1345
+ fmt = t.format.Name() if hasattr(t.format, 'Name') else str(t.format)
1346
+ except Exception:
1347
+ fmt = ''
1348
+ tex_info[rid] = {
1349
+ 'format': fmt,
1350
+ 'width': int(t.width), 'height': int(t.height),
1351
+ 'sample_count': int(t.msSamp),
1352
+ }
1353
+
1354
+ rows: list[dict] = []
1355
+ for fbo_id, attachments in fbo_state_per_event.items():
1356
+ for att in attachments:
1357
+ tinfo = tex_info.get(att['resource_id'], {'format': '', 'width': 0,
1358
+ 'height': 0, 'sample_count': 0})
1359
+ rows.append({
1360
+ **ctx,
1361
+ 'stable_key': '', # filled host-side from sorted attachments
1362
+ 'fbo_id': fbo_id,
1363
+ 'attachment_point': att['attachment_point'],
1364
+ 'kind': att['kind'],
1365
+ 'resource_id': att['resource_id'],
1366
+ 'format': tinfo['format'],
1367
+ 'width': tinfo['width'],
1368
+ 'height': tinfo['height'],
1369
+ 'sample_count': tinfo['sample_count'],
1370
+ 'mip_level': att['mip_level'],
1371
+ 'layer': att['layer'],
1372
+ 'created_at_event': -1,
1373
+ 'bound_at_events': str(att['first_event']),
1374
+ 'num_clears': 0, 'num_writes': 0, 'num_reads': 0,
1375
+ 'label': labels.get(fbo_id, ''),
1376
+ })
1377
+ return rows
1378
+
1379
+
1380
+ # --- Indirect args -----------------------------------------------------------
1381
+
1382
+ _INDIRECT_CHUNK_NAMES = {
1383
+ 'glDrawArraysIndirect',
1384
+ 'glDrawElementsIndirect',
1385
+ 'glMultiDrawArraysIndirect',
1386
+ 'glMultiDrawElementsIndirect',
1387
+ 'glDispatchComputeIndirect',
1388
+ }
1389
+
1390
+
1391
+ def _extract_indirect_args(ctrl, events: list[dict], ctx: dict) -> list[dict]:
1392
+ """For each indirect draw/dispatch event, read the indirect buffer at the
1393
+ bound offset and unpack the args. On Arm RD GL backend the bound indirect
1394
+ buffer is accessible via the structured chunk args; we use a best-effort
1395
+ approach via SetFrameEvent + GLPipelineState.vertexInput.indirectBuffer
1396
+ (when present). If we cannot recover the buffer reliably, skip silently.
1397
+ """
1398
+ import renderdoc as rd # type: ignore
1399
+ import struct
1400
+
1401
+ rows: list[dict] = []
1402
+ for e in events:
1403
+ cn = e['chunk_name']
1404
+ if cn not in _INDIRECT_CHUNK_NAMES:
1405
+ continue
1406
+ ev = e['event_id']
1407
+ try:
1408
+ ctrl.SetFrameEvent(ev, False)
1409
+ glp = ctrl.GetGLPipelineState()
1410
+ ind_rid = None
1411
+ offset = 0
1412
+ # Try various attribute names where the bound indirect buffer may live.
1413
+ for attr_path in (('vertexInput', 'indirectBuffer'),
1414
+ ('vertexInput', 'indirect'),
1415
+ ('framebuffer', 'indirectBuffer')):
1416
+ node = glp
1417
+ ok = True
1418
+ for a in attr_path:
1419
+ node = getattr(node, a, None)
1420
+ if node is None:
1421
+ ok = False
1422
+ break
1423
+ if ok and node is not None:
1424
+ rid = _rid_int(getattr(node, 'resourceId', None) or node)
1425
+ if rid:
1426
+ ind_rid = (getattr(node, 'resourceId', None) or node)
1427
+ offset = int(getattr(node, 'byteOffset', 0) or 0)
1428
+ break
1429
+ if ind_rid is None:
1430
+ continue
1431
+ data = ctrl.GetBufferData(ind_rid, offset, 64)
1432
+ row = {**ctx, 'event_id': ev, 'call_name': cn,
1433
+ 'indirect_buffer_id': _rid_int(ind_rid), 'offset': offset,
1434
+ 'count': 0, 'instance_count': 0, 'first': 0,
1435
+ 'base_vertex': 0, 'base_instance': 0,
1436
+ 'group_x': 0, 'group_y': 0, 'group_z': 0,
1437
+ 'stride': 0, 'draw_count': 0}
1438
+ if cn == 'glDrawArraysIndirect' and len(data) >= 16:
1439
+ count, instance_count, first, base_instance = struct.unpack_from('<IIII', data, 0)
1440
+ row.update(count=count, instance_count=instance_count,
1441
+ first=first, base_instance=base_instance)
1442
+ elif cn == 'glDrawElementsIndirect' and len(data) >= 20:
1443
+ count, instance_count, first, base_vertex, base_instance = struct.unpack_from('<IIIiI', data, 0)
1444
+ row.update(count=count, instance_count=instance_count,
1445
+ first=first, base_vertex=base_vertex, base_instance=base_instance)
1446
+ elif cn == 'glDispatchComputeIndirect' and len(data) >= 12:
1447
+ gx, gy, gz = struct.unpack_from('<III', data, 0)
1448
+ row.update(group_x=gx, group_y=gy, group_z=gz)
1449
+ rows.append(row)
1450
+ except Exception:
1451
+ continue
1452
+ return rows
1453
+
1454
+
1455
+ # --- Pixel history ----------------------------------------------------------
1456
+
1457
+ def _extract_pixel_history(ctrl, ctx: dict, last_event_id: int,
1458
+ grid: int = 4, min_size: int = 256) -> list[dict]:
1459
+ """Sample an N×N grid of pixels on each large color render target at
1460
+ last_event_id. Per-pixel PixelHistory() yields one row per modification.
1461
+
1462
+ Skips RTs smaller than min_size² (typical dummy/atlas textures).
1463
+ """
1464
+ import renderdoc as rd # type: ignore
1465
+
1466
+ rows: list[dict] = []
1467
+ if grid <= 0:
1468
+ return rows
1469
+ sub = rd.Subresource(0, 0, 0)
1470
+
1471
+ for tex in ctrl.GetTextures():
1472
+ flags = int(tex.creationFlags)
1473
+ if not (flags & int(rd.TextureCategory.ColorTarget)):
1474
+ continue
1475
+ if tex.width < min_size or tex.height < min_size:
1476
+ continue
1477
+ rid_int = _rid_int(tex.resourceId)
1478
+ margin_x = max(2, int(0.05 * tex.width))
1479
+ margin_y = max(2, int(0.05 * tex.height))
1480
+ for gy in range(grid):
1481
+ for gx in range(grid):
1482
+ denom = max(grid - 1, 1)
1483
+ x = margin_x + (gx * (tex.width - 2 * margin_x)) // denom
1484
+ y = margin_y + (gy * (tex.height - 2 * margin_y)) // denom
1485
+ try:
1486
+ hist = ctrl.PixelHistory(tex.resourceId, x, y, sub, rd.CompType.Typeless)
1487
+ except Exception:
1488
+ continue
1489
+ for idx, mod in enumerate(hist or ()):
1490
+ passed = not (mod.backfaceCulled or mod.depthTestFailed or
1491
+ mod.stencilTestFailed or mod.scissorClipped or
1492
+ mod.shaderDiscarded or mod.sampleMasked or
1493
+ mod.depthClipped or mod.viewClipped)
1494
+ so = mod.shaderOut.col.floatValue
1495
+ pre = mod.preMod.col.floatValue
1496
+ post = mod.postMod.col.floatValue
1497
+ rows.append({
1498
+ **ctx,
1499
+ 'rt_id': rid_int,
1500
+ 'sample_x': x, 'sample_y': y, 'mod_index': idx,
1501
+ 'event_id': int(mod.eventId),
1502
+ 'primitive_id': int(getattr(mod, 'primitiveID', -1) or -1),
1503
+ 'passed': int(bool(passed)),
1504
+ 'backface_culled': int(bool(mod.backfaceCulled)),
1505
+ 'depth_test_failed': int(bool(mod.depthTestFailed)),
1506
+ 'stencil_test_failed': int(bool(mod.stencilTestFailed)),
1507
+ 'scissor_clipped': int(bool(mod.scissorClipped)),
1508
+ 'shader_discarded': int(bool(mod.shaderDiscarded)),
1509
+ 'sample_masked': int(bool(mod.sampleMasked)),
1510
+ 'depth_clipped': int(bool(mod.depthClipped)),
1511
+ 'view_clipped': int(bool(mod.viewClipped)),
1512
+ 'shader_out_r': float(so[0]), 'shader_out_g': float(so[1]),
1513
+ 'shader_out_b': float(so[2]), 'shader_out_a': float(so[3]),
1514
+ 'pre_mod_r': float(pre[0]), 'pre_mod_g': float(pre[1]),
1515
+ 'pre_mod_b': float(pre[2]), 'pre_mod_a': float(pre[3]),
1516
+ 'post_mod_r': float(post[0]), 'post_mod_g': float(post[1]),
1517
+ 'post_mod_b': float(post[2]), 'post_mod_a': float(post[3]),
1518
+ })
1519
+ return rows
1520
+
1521
+
1522
+ # --- Counters fetch ----------------------------------------------------------
1523
+
1524
+ def _fetch_counters_per_event(ctrl, ctx) -> tuple[list[dict], dict[int, float]]:
1525
+ """Fetch ALL enumerable counters per event. Returns (rows, duration_by_event)."""
1526
+ import renderdoc as rd # type: ignore
1527
+
1528
+ rows: list[dict] = []
1529
+ duration_by_event: dict[int, float] = {}
1530
+
1531
+ counters = ctrl.EnumerateCounters()
1532
+ rt_double = (rd.CompType.Float, ) if False else ()
1533
+ # Use CounterResult.resultType-aware extraction. result_type attr may be int.
1534
+ try:
1535
+ FLOAT_T = int(rd.CompType.Float)
1536
+ DOUBLE_T = -1 # CompType has no Double; renderdoc uses Float for both internally
1537
+ except Exception:
1538
+ FLOAT_T = -1
1539
+ DOUBLE_T = -1
1540
+
1541
+ for counter in counters:
1542
+ d = ctrl.DescribeCounter(counter)
1543
+ try:
1544
+ results = ctrl.FetchCounters([counter])
1545
+ except Exception:
1546
+ continue
1547
+ unit = _enum_short(d.unit)
1548
+ name = d.name
1549
+ result_type = int(getattr(d, 'resultType', 0))
1550
+ # Heuristic: byte width 4 == 32-bit; width 8 + name says 'Duration' or
1551
+ # unit Seconds means double. Use d.resultByteWidth + result_type.
1552
+ rbw = int(getattr(d, 'resultByteWidth', 8))
1553
+ is_float_kind = ('Float' in _enum_short(getattr(d, 'resultType', 0))
1554
+ or unit in ('Seconds', 'Percentage', 'Hertz'))
1555
+ for r in results:
1556
+ ev = int(r.eventId)
1557
+ vd = 0.0
1558
+ vu = 0
1559
+ if is_float_kind:
1560
+ try: vd = float(r.value.d)
1561
+ except Exception: pass
1562
+ else:
1563
+ try: vu = int(r.value.u64)
1564
+ except Exception: pass
1565
+ rows.append({
1566
+ **ctx,
1567
+ 'event_id': ev,
1568
+ 'counter_name': name,
1569
+ 'counter_unit': unit,
1570
+ 'value_double': vd,
1571
+ 'value_uint64': vu,
1572
+ })
1573
+ if counter == rd.GPUCounter.EventGPUDuration:
1574
+ duration_by_event[ev] = vd if is_float_kind else float(vu)
1575
+
1576
+ return rows, duration_by_event
1577
+
1578
+
1579
+ # --- RT walk ----------------------------------------------------------------
1580
+
1581
+ def _walk_render_targets(ctrl, ctx) -> tuple[list[dict], list[dict]]:
1582
+ """Build render_targets.csv rows + rt_event_timeline rows.
1583
+
1584
+ A render target is any texture that has the ColorTarget or DepthTarget
1585
+ creation flag. For each one, GetUsage() yields per-event read/write rows.
1586
+ """
1587
+ import renderdoc as rd # type: ignore
1588
+
1589
+ rt_rows: list[dict] = []
1590
+ timeline_rows: list[dict] = []
1591
+
1592
+ null_rid = rd.ResourceId.Null()
1593
+ sub = rd.Subresource(0, 0, 0)
1594
+
1595
+ histogram_by_rt: dict[int, list[int]] = {}
1596
+ for tex in ctrl.GetTextures():
1597
+ flags = int(tex.creationFlags)
1598
+ is_color = bool(flags & int(rd.TextureCategory.ColorTarget))
1599
+ is_depth = bool(flags & int(rd.TextureCategory.DepthTarget))
1600
+ if not (is_color or is_depth):
1601
+ continue
1602
+ is_stencil = is_depth and tex.format.compType == rd.CompType.Depth
1603
+ is_swap = bool(flags & int(rd.TextureCategory.SwapBuffer))
1604
+ rid = _rid_int(tex.resourceId)
1605
+ if not rid:
1606
+ continue
1607
+ fmt = tex.format.Name() if hasattr(tex.format, 'Name') else str(tex.format)
1608
+
1609
+ # min/max for color RTs (depth often returns garbage on GLES replay)
1610
+ mn_r = mn_g = mn_b = mn_a = 0.0
1611
+ mx_r = mx_g = mx_b = mx_a = 0.0
1612
+ if is_color and tex.width > 1 and tex.height > 1:
1613
+ try:
1614
+ mn, mx = ctrl.GetMinMax(tex.resourceId, sub, rd.CompType.Typeless)
1615
+ mn_r, mn_g, mn_b, mn_a = float(mn.floatValue[0]), float(mn.floatValue[1]), float(mn.floatValue[2]), float(mn.floatValue[3])
1616
+ mx_r, mx_g, mx_b, mx_a = float(mx.floatValue[0]), float(mx.floatValue[1]), float(mx.floatValue[2]), float(mx.floatValue[3])
1617
+ except Exception:
1618
+ pass
1619
+ try:
1620
+ hist = ctrl.GetHistogram(tex.resourceId, sub, rd.CompType.Typeless,
1621
+ 0.0, 1.0, (True, True, True, True))
1622
+ histogram_by_rt[rid] = list(int(x) for x in hist)
1623
+ except Exception:
1624
+ pass
1625
+
1626
+ # Walk usage for timeline
1627
+ first_write = -1; last_write = -1; first_read = -1; last_read = -1
1628
+ n_writes = 0; n_reads = 0
1629
+ try:
1630
+ for u in ctrl.GetUsage(tex.resourceId):
1631
+ code = int(u.usage)
1632
+ kind = _enum_short(rd.ResourceUsage(code))
1633
+ evid = int(u.eventId)
1634
+ view_rid = _rid_int(getattr(u, 'view', None))
1635
+ tl = {
1636
+ **ctx,
1637
+ 'rt_id': rid, 'event_id': evid, 'usage_code': code,
1638
+ 'usage_name': kind, 'view_id': view_rid,
1639
+ 'attachment_point_or_slot': '',
1640
+ }
1641
+ timeline_rows.append(tl)
1642
+ lc = kind.lower()
1643
+ is_write_kind = ('target' in lc or 'write' in lc or 'clear' in lc or
1644
+ 'copydst' in lc or 'resolvedst' in lc)
1645
+ if is_write_kind:
1646
+ n_writes += 1
1647
+ if first_write < 0: first_write = evid
1648
+ last_write = evid
1649
+ else:
1650
+ n_reads += 1
1651
+ if first_read < 0: first_read = evid
1652
+ last_read = evid
1653
+ except Exception:
1654
+ pass
1655
+
1656
+ rt_rows.append({
1657
+ **ctx,
1658
+ 'stable_key': '', # filled host-side
1659
+ 'rt_id': rid,
1660
+ 'format': fmt,
1661
+ 'width': int(tex.width), 'height': int(tex.height),
1662
+ 'depth': int(tex.depth), 'mip_levels': int(tex.mips),
1663
+ 'sample_count': int(tex.msSamp),
1664
+ 'is_color': int(is_color),
1665
+ 'is_depth': int(is_depth),
1666
+ 'is_stencil': int(is_stencil),
1667
+ 'is_swap_chain_target': int(is_swap),
1668
+ 'first_write_event': first_write,
1669
+ 'last_write_event': last_write,
1670
+ 'first_read_event': first_read,
1671
+ 'last_read_event': last_read,
1672
+ 'num_write_events': n_writes,
1673
+ 'num_read_events': n_reads,
1674
+ 'attached_to_fbo_ids': '',
1675
+ 'sampled_by_shader_ids': '',
1676
+ 'min_value_r': mn_r, 'min_value_g': mn_g, 'min_value_b': mn_b, 'min_value_a': mn_a,
1677
+ 'max_value_r': mx_r, 'max_value_g': mx_g, 'max_value_b': mx_b, 'max_value_a': mx_a,
1678
+ })
1679
+
1680
+ return rt_rows, timeline_rows, histogram_by_rt
1681
+
1682
+
1683
+ # --- State-change chunks -----------------------------------------------------
1684
+
1685
+ STATE_CHANGE_CHUNK_NAMES = {
1686
+ 'glBindTexture', 'glActiveTexture', 'glUseProgram',
1687
+ 'glEnable', 'glDisable',
1688
+ 'glBindBuffer', 'glBindBufferBase', 'glBindBufferRange',
1689
+ 'glBindFramebuffer', 'glBindSampler',
1690
+ 'glStencilFunc', 'glStencilFuncSeparate',
1691
+ 'glStencilOp', 'glStencilOpSeparate',
1692
+ 'glStencilMask', 'glStencilMaskSeparate',
1693
+ 'glDepthFunc', 'glDepthMask', 'glDepthRangef',
1694
+ 'glCullFace', 'glFrontFace',
1695
+ 'glBlendFunc', 'glBlendFuncSeparate', 'glBlendFunci', 'glBlendFuncSeparatei',
1696
+ 'glBlendEquation', 'glBlendEquationSeparate',
1697
+ 'glBlendEquationi', 'glBlendEquationSeparatei',
1698
+ 'glColorMask', 'glColorMaski',
1699
+ 'glViewport', 'glScissor', 'glPolygonOffset',
1700
+ 'glPushGroupMarkerEXT', 'glPopGroupMarkerEXT',
1701
+ }
1702
+
1703
+
1704
+ def _build_state_change_rows(sd, parent_by_event, chunk_name_by_event,
1705
+ chunk_to_event_id, ctx) -> list[dict]:
1706
+ """Walk sd.chunks once, emit one row per state-change chunk.
1707
+
1708
+ chunk_to_event_id maps each chunk index to the event_id of the action
1709
+ whose api-events list includes it. Built during action tree walk.
1710
+ """
1711
+ rows: list[dict] = []
1712
+
1713
+ chunks = getattr(sd, 'chunks', None)
1714
+ if chunks is None:
1715
+ return rows
1716
+
1717
+ for ci, c in enumerate(chunks):
1718
+ name = c.name if hasattr(c, 'name') else ''
1719
+ if name not in STATE_CHANGE_CHUNK_NAMES:
1720
+ continue
1721
+ ev_id = chunk_to_event_id.get(ci, -1)
1722
+ parent = parent_by_event.get(ev_id, '') if ev_id >= 0 else ''
1723
+ # Args are too varied to fully decode; emit best-effort blob.
1724
+ arg_id = 0
1725
+ arg_int = 0
1726
+ arg_float = 0.0
1727
+ target_or_cap = ''
1728
+ args_repr: list = []
1729
+ try:
1730
+ for a in getattr(c, 'args', []) or []:
1731
+ args_repr.append(str(a))
1732
+ except Exception:
1733
+ pass
1734
+ rows.append({
1735
+ **ctx,
1736
+ 'event_id': ev_id,
1737
+ 'parent_marker_path': parent,
1738
+ 'call_name': name,
1739
+ 'target_or_cap': target_or_cap,
1740
+ 'arg_id': arg_id,
1741
+ 'arg_int': arg_int,
1742
+ 'arg_float': arg_float,
1743
+ 'arg_extra_json': json.dumps(args_repr) if args_repr else '',
1744
+ })
1745
+ return rows
1746
+
1747
+
1748
+ # --- Passes (markers aggregation) -------------------------------------------
1749
+
1750
+ def _build_passes(events: list[dict], event_durations: dict[int, float],
1751
+ draw_classes: dict[int, str], ctx,
1752
+ draws_by_event: dict[int, dict] | None = None,
1753
+ bindings_by_event: dict[int, list] | None = None) -> list[dict]:
1754
+ """Aggregate per-marker_path stats. Populates unique_programs/shaders/
1755
+ meshes/materials sets when draws_by_event + bindings_by_event are passed.
1756
+ """
1757
+ draws_by_event = draws_by_event or {}
1758
+ bindings_by_event = bindings_by_event or {}
1759
+ passes: dict[tuple[int, str], dict] = {}
1760
+
1761
+ for e in events:
1762
+ if e['is_marker_push'] or e['is_marker_pop']:
1763
+ continue
1764
+ path = e['parent_marker_path']
1765
+ if not path:
1766
+ continue
1767
+ ev = e['event_id']
1768
+ gpu = event_durations.get(ev, 0.0)
1769
+ is_draw = e['is_drawcall']; is_disp = e['is_dispatch']; is_clr = e['is_clear']
1770
+ d_row = draws_by_event.get(ev) if is_draw else None
1771
+
1772
+ # contribute to every prefix path
1773
+ parts = path.split('/')
1774
+ for d in range(1, len(parts) + 1):
1775
+ sub = '/'.join(parts[:d])
1776
+ key = (d - 1, sub)
1777
+ p = passes.get(key)
1778
+ if p is None:
1779
+ p = {
1780
+ 'marker_path': sub, 'depth': d - 1,
1781
+ 'first_event_id': ev, 'last_event_id': ev,
1782
+ 'num_draws': 0, 'num_dispatches': 0,
1783
+ 'num_clears': 0, 'num_other_actions': 0,
1784
+ 'num_primitives_pre_vs': 0, 'num_primitives_post_vs': 0,
1785
+ 'num_vertices_pre_vs': 0, 'num_vertices_post_vs': 0,
1786
+ 'gpu_duration_s': 0.0,
1787
+ 'unique_programs': set(), 'unique_shaders': set(),
1788
+ 'unique_meshes': set(), 'unique_materials': set(),
1789
+ 'color_rt_id_first': 0, 'depth_rt_id_first': 0,
1790
+ 'draws_by_class_opaque': 0, 'draws_by_class_prepass': 0,
1791
+ 'draws_by_class_translucent': 0, 'draws_by_class_decal': 0,
1792
+ 'draws_by_class_shadow': 0, 'draws_by_class_ui': 0,
1793
+ 'draws_by_class_postprocess': 0, 'draws_by_class_additive': 0,
1794
+ 'draws_by_class_other': 0,
1795
+ }
1796
+ if e.get('output_color_rt_id'):
1797
+ p['color_rt_id_first'] = e['output_color_rt_id']
1798
+ if e.get('output_depth_rt_id'):
1799
+ p['depth_rt_id_first'] = e['output_depth_rt_id']
1800
+ passes[key] = p
1801
+ p['last_event_id'] = ev
1802
+ p['gpu_duration_s'] += gpu
1803
+
1804
+ if is_draw:
1805
+ p['num_draws'] += 1
1806
+ ni = e['num_indices']; ic = e['num_instances'] or 1
1807
+ p['num_vertices_pre_vs'] += ni * ic
1808
+ p['num_primitives_pre_vs'] += (ni // 3) * ic
1809
+ cls = draw_classes.get(ev, 'other')
1810
+ col = f'draws_by_class_{cls}'
1811
+ if col in p:
1812
+ p[col] += 1
1813
+ if d_row is not None:
1814
+ pid = d_row.get('program_id') or 0
1815
+ vs = d_row.get('vs_shader_id') or 0
1816
+ fs = d_row.get('fs_shader_id') or 0
1817
+ mh = d_row.get('mesh_hash') or ''
1818
+ if pid: p['unique_programs'].add(pid)
1819
+ if vs: p['unique_shaders'].add(vs)
1820
+ if fs: p['unique_shaders'].add(fs)
1821
+ if mh: p['unique_meshes'].add(mh)
1822
+ # material identity = (program_id, sorted texture binding resource_ids)
1823
+ tex_ids = []
1824
+ for b in bindings_by_event.get(ev, ()):
1825
+ if b.get('slot_kind') == 'texture' and b.get('resource_id'):
1826
+ tex_ids.append(b['resource_id'])
1827
+ p['unique_materials'].add((pid, tuple(sorted(tex_ids))))
1828
+ elif is_disp:
1829
+ p['num_dispatches'] += 1
1830
+ elif is_clr:
1831
+ p['num_clears'] += 1
1832
+ else:
1833
+ p['num_other_actions'] += 1
1834
+
1835
+ # finalize: convert sets to ints
1836
+ rows: list[dict] = []
1837
+ for (_, _path), p in sorted(passes.items(), key=lambda kv: (kv[0][0], kv[0][1])):
1838
+ rows.append({
1839
+ **ctx,
1840
+ **{k: (len(v) if isinstance(v, set) else v) for k, v in p.items()},
1841
+ })
1842
+ return rows
1843
+
1844
+
1845
+ # --- Frame totals -----------------------------------------------------------
1846
+
1847
+ def _build_frame_totals(events: list[dict], draws: list[dict],
1848
+ event_durations: dict[int, float],
1849
+ unique_programs_used: set, unique_shaders_used: set,
1850
+ unique_textures_bound: set, ctx,
1851
+ sc_rows: list[dict] | None = None,
1852
+ bindings_by_event: dict[int, list] | None = None,
1853
+ buffers_rows: list[dict] | None = None,
1854
+ textures_rows: list[dict] | None = None) -> dict:
1855
+ sc_rows = sc_rows or []
1856
+ bindings_by_event = bindings_by_event or {}
1857
+ buffers_rows = buffers_rows or []
1858
+ textures_rows = textures_rows or []
1859
+
1860
+ n_draws = sum(1 for e in events if e['is_drawcall'])
1861
+ n_disp = sum(1 for e in events if e['is_dispatch'])
1862
+ n_clr = sum(1 for e in events if e['is_clear'])
1863
+
1864
+ total_pre_vs_vertices = 0
1865
+ total_pre_vs_primitives = 0
1866
+ total_post_vs_vertices = 0
1867
+ total_post_vs_primitives = 0
1868
+ for d in draws:
1869
+ ic = d['num_instances'] or 1
1870
+ total_pre_vs_vertices += d['num_indices'] * ic
1871
+ total_pre_vs_primitives += _primitives_for(d['topology'], d['num_indices'], ic)
1872
+ total_post_vs_vertices += d['post_vs_vertices']
1873
+ total_post_vs_primitives += d['post_vs_primitives']
1874
+
1875
+ total_gpu = sum(event_durations.values())
1876
+
1877
+ # State change call counts
1878
+ call_counts: dict[str, int] = {}
1879
+ for r in sc_rows:
1880
+ nm = r.get('call_name', '')
1881
+ call_counts[nm] = call_counts.get(nm, 0) + 1
1882
+ # Add draw-call chunk names (action tree gives is_drawcall, but for counts
1883
+ # we tally by chunk_name).
1884
+ for e in events:
1885
+ cn = e.get('chunk_name', '')
1886
+ if cn in ('glDrawElements', 'glDrawArrays', 'glDrawElementsInstanced',
1887
+ 'glDispatchCompute', 'glClear', 'glClearBufferfv',
1888
+ 'glClearBufferfi', 'glClearBufferiv', 'glClearBufferuiv'):
1889
+ call_counts[cn] = call_counts.get(cn, 0) + 1
1890
+ gl_clear_buffer_count = (call_counts.get('glClearBufferfv', 0)
1891
+ + call_counts.get('glClearBufferfi', 0)
1892
+ + call_counts.get('glClearBufferiv', 0)
1893
+ + call_counts.get('glClearBufferuiv', 0))
1894
+
1895
+ # Switches by walking draws/events in event_id order
1896
+ sorted_draws = sorted(draws, key=lambda d: d['event_id'])
1897
+ program_switches = 0
1898
+ prev_prog = None
1899
+ for d in sorted_draws:
1900
+ pid = d.get('program_id') or 0
1901
+ if prev_prog is not None and pid and pid != prev_prog:
1902
+ program_switches += 1
1903
+ if pid: prev_prog = pid
1904
+ fbo_switches = 0
1905
+ prev_rt = None
1906
+ for e in sorted(events, key=lambda x: x['event_id']):
1907
+ rt = e.get('output_color_rt_id') or 0
1908
+ if prev_rt is not None and rt and rt != prev_rt:
1909
+ fbo_switches += 1
1910
+ if rt: prev_rt = rt
1911
+ texture_unit_switches = call_counts.get('glActiveTexture', 0)
1912
+
1913
+ # Unique mesh + material from draws / bindings
1914
+ unique_meshes_drawn = len({d.get('mesh_hash') for d in draws if d.get('mesh_hash')})
1915
+ materials: set = set()
1916
+ for d in draws:
1917
+ pid = d.get('program_id') or 0
1918
+ ev = d['event_id']
1919
+ tex_ids = tuple(sorted(
1920
+ b['resource_id'] for b in bindings_by_event.get(ev, ())
1921
+ if b.get('slot_kind') == 'texture' and b.get('resource_id')
1922
+ ))
1923
+ materials.add((pid, tex_ids))
1924
+ unique_materials_drawn = len(materials)
1925
+
1926
+ # Bytes totals from buffers + textures
1927
+ total_vbo = sum(r.get('allocated_size_bytes', 0) for r in buffers_rows if r.get('used_as_vbo'))
1928
+ total_ibo = sum(r.get('allocated_size_bytes', 0) for r in buffers_rows if r.get('used_as_ibo'))
1929
+ total_ubo = sum(r.get('allocated_size_bytes', 0) for r in buffers_rows if r.get('used_as_ubo'))
1930
+ total_tex_bytes = sum(r.get('est_bytes', 0) or 0 for r in textures_rows)
1931
+
1932
+ return {
1933
+ **ctx,
1934
+ 'n_events': len(events),
1935
+ 'n_draws': n_draws, 'n_dispatches': n_disp, 'n_clears': n_clr,
1936
+ 'total_primitives_pre_vs': total_pre_vs_primitives,
1937
+ 'total_vertices_pre_vs': total_pre_vs_vertices,
1938
+ 'total_primitives_post_vs': total_post_vs_primitives,
1939
+ 'total_vertices_post_vs': total_post_vs_vertices,
1940
+ 'total_gpu_duration_s': total_gpu,
1941
+ 'glUseProgram_count': call_counts.get('glUseProgram', 0),
1942
+ 'glBindBuffer_count': call_counts.get('glBindBuffer', 0),
1943
+ 'glBindTexture_count': call_counts.get('glBindTexture', 0),
1944
+ 'glActiveTexture_count': call_counts.get('glActiveTexture', 0),
1945
+ 'glBindFramebuffer_count': call_counts.get('glBindFramebuffer', 0),
1946
+ 'glBindBufferBase_count': call_counts.get('glBindBufferBase', 0),
1947
+ 'glBindSampler_count': call_counts.get('glBindSampler', 0),
1948
+ 'glDrawElements_count': call_counts.get('glDrawElements', 0),
1949
+ 'glDrawArrays_count': call_counts.get('glDrawArrays', 0),
1950
+ 'glDrawElementsInstanced_count': call_counts.get('glDrawElementsInstanced', 0),
1951
+ 'glDispatchCompute_count': call_counts.get('glDispatchCompute', 0),
1952
+ 'glClear_count': call_counts.get('glClear', 0),
1953
+ 'glClearBuffer_count': gl_clear_buffer_count,
1954
+ 'total_vbo_bytes_uploaded': total_vbo,
1955
+ 'total_ibo_bytes_uploaded': total_ibo,
1956
+ 'total_ubo_bytes_uploaded': total_ubo,
1957
+ 'total_texture_bytes_allocated': total_tex_bytes,
1958
+ 'total_renderbuffer_bytes_allocated': 0,
1959
+ 'unique_programs_used': len(unique_programs_used),
1960
+ 'unique_shaders_used': len(unique_shaders_used),
1961
+ 'unique_meshes_drawn': unique_meshes_drawn,
1962
+ 'unique_materials_drawn': unique_materials_drawn,
1963
+ 'unique_textures_bound': len(unique_textures_bound),
1964
+ 'fbo_switches': fbo_switches,
1965
+ 'program_switches': program_switches,
1966
+ 'texture_unit_switches': texture_unit_switches,
1967
+ }
1968
+
1969
+
1970
+ # --- Frame metadata ---------------------------------------------------------
1971
+
1972
+ def _frame_metadata(ctrl, ctx, rdc_path: str, sd=None) -> dict:
1973
+ md = dict(ctx)
1974
+ try:
1975
+ api = ctrl.GetAPIProperties()
1976
+ md['driver_name'] = _enum_short(api.driver) if hasattr(api, 'driver') else ''
1977
+ md['gpu_vendor'] = _enum_short(getattr(api, 'vendor', ''))
1978
+ md['gpu_description'] = getattr(api, 'description', '') or ''
1979
+ except Exception:
1980
+ pass
1981
+ try:
1982
+ fi = ctrl.GetFrameInfo()
1983
+ md['frame_number'] = int(getattr(fi, 'frameNumber', 0))
1984
+ md['capture_time_us'] = int(getattr(fi, 'captureTime', 0))
1985
+ md['file_size_bytes'] = int(getattr(fi, 'fileSize', 0))
1986
+ except Exception:
1987
+ pass
1988
+ md['rdc_file_bytes'] = os.path.getsize(rdc_path)
1989
+ # Probe Internal::Driver Initialisation Parameters chunk for GL_VERSION/RENDERER + format details
1990
+ md['gl_version_string'] = ''
1991
+ md['gl_renderer_string'] = ''
1992
+ md['init_color_bits'] = 0
1993
+ md['init_depth_bits'] = 0
1994
+ md['init_stencil_bits'] = 0
1995
+ md['init_multi_samples'] = 0
1996
+ md['init_width'] = 0
1997
+ md['init_height'] = 0
1998
+ md['init_is_srgb'] = 0
1999
+ if sd is not None:
2000
+ try:
2001
+ for c in sd.chunks:
2002
+ if c.name != 'Internal::Driver Initialisation Parameters':
2003
+ continue
2004
+ params = c.FindChild('InitParams')
2005
+ src = params if params is not None else c
2006
+ def _get_str(name):
2007
+ ch = src.FindChild(name)
2008
+ try: return ch.AsString() if ch is not None else ''
2009
+ except Exception: return ''
2010
+ def _get_int(name):
2011
+ ch = src.FindChild(name)
2012
+ try: return int(ch.AsInt()) if ch is not None else 0
2013
+ except Exception: return 0
2014
+ md['gl_renderer_string'] = _get_str('renderer')
2015
+ md['gl_version_string'] = _get_str('version')
2016
+ md['init_color_bits'] = _get_int('colorBits')
2017
+ md['init_depth_bits'] = _get_int('depthBits')
2018
+ md['init_stencil_bits'] = _get_int('stencilBits')
2019
+ md['init_multi_samples'] = _get_int('multiSamples')
2020
+ md['init_width'] = _get_int('width')
2021
+ md['init_height'] = _get_int('height')
2022
+ md['init_is_srgb'] = _get_int('isSRGB')
2023
+ break
2024
+ except Exception:
2025
+ pass
2026
+ return md
2027
+
2028
+
2029
+ # --- Writers ----------------------------------------------------------------
2030
+
2031
+ def _write_rows(path: str, fields: tuple, rows: list[dict]) -> int:
2032
+ with open(path, 'w', encoding='utf-8', newline='') as f:
2033
+ w = csv.DictWriter(f, fieldnames=list(fields), extrasaction='ignore')
2034
+ w.writeheader()
2035
+ for r in rows:
2036
+ w.writerow(r)
2037
+ return len(rows)
2038
+
2039
+
2040
+ # --- Main --------------------------------------------------------------------
2041
+
2042
+ def main() -> None:
2043
+ args = _parse_args()
2044
+ capture_stage = os.path.join(args['stage_root'], args['capture'])
2045
+ log = _tee_setup(capture_stage)
2046
+
2047
+ try:
2048
+ rdc_path = os.path.join(args['drop_dir'], f"{args['capture']}.rdc")
2049
+ if not os.path.exists(rdc_path):
2050
+ raise FileNotFoundError(rdc_path)
2051
+ print(f'opening {rdc_path}')
2052
+ ctx = {k: args[k] for k in ID_COLS}
2053
+
2054
+ cap, ctrl = _open_capture(rdc_path)
2055
+ try:
2056
+ sd = ctrl.GetStructuredFile()
2057
+
2058
+ t0 = time.monotonic()
2059
+ tree = _build_event_records(ctrl, sd, ctx)
2060
+ print(f' walked action tree: {len(tree["events"])} events, {len(tree["draw_events"])} draws ({time.monotonic()-t0:.1f}s)')
2061
+
2062
+ global _ACTION_CACHE
2063
+ _ACTION_CACHE = tree['action_by_event']
2064
+
2065
+ t0 = time.monotonic()
2066
+ counter_rows, event_durations = _fetch_counters_per_event(ctrl, ctx)
2067
+ print(f' fetched counters: {len(counter_rows)} rows ({time.monotonic()-t0:.1f}s)')
2068
+
2069
+ t0 = time.monotonic()
2070
+ draws: list[dict] = []
2071
+ db_rows: list[dict] = []
2072
+ vi_rows: list[dict] = []
2073
+ da_rows: list[dict] = []
2074
+ pvs_rows: list[dict] = []
2075
+ fbo_state_per_event: dict[int, list[dict]] = {}
2076
+ uniforms_per_pass_rows: list[dict] = []
2077
+ seen_marker_paths: set = set()
2078
+ draw_classes: dict[int, str] = {}
2079
+ mesh_hash_cache: dict = {}
2080
+ buffer_map: dict = {_rid_int(b.resourceId): b for b in ctrl.GetBuffers()}
2081
+ unique_programs = set(); unique_shaders = set(); unique_textures = set()
2082
+ n_draws_total = len(tree['draw_events'])
2083
+ for i, e in enumerate(tree['draw_events']):
2084
+ ev_id = e['event_id']
2085
+ parent = tree['parent_path_by_event'].get(ev_id, '')
2086
+ try:
2087
+ row = _read_draw_state(ctrl, ev_id, e, parent, event_durations)
2088
+ except Exception as ex:
2089
+ print(f' draw {ev_id} failed: {ex}')
2090
+ continue
2091
+ draws.append(row)
2092
+ # aux per-draw extraction (re-uses the SetFrameEvent already done)
2093
+ try:
2094
+ _extract_draw_aux(ctrl, ev_id, ctx, row,
2095
+ vi_rows, db_rows, da_rows, pvs_rows,
2096
+ fbo_state_per_event, mesh_hash_cache, buffer_map,
2097
+ pvs_max_verts=32)
2098
+ except Exception as ex:
2099
+ print(f' draw {ev_id} aux failed: {ex}')
2100
+ # First-draw-per-marker uniforms snapshot
2101
+ if parent and parent not in seen_marker_paths:
2102
+ seen_marker_paths.add(parent)
2103
+ try:
2104
+ u_obj = _snapshot_uniforms(ctrl, ev_id, parent, ctx)
2105
+ if u_obj is not None:
2106
+ uniforms_per_pass_rows.append(u_obj)
2107
+ except Exception as ex:
2108
+ print(f' uniforms {ev_id} failed: {ex}')
2109
+ cls = _classify_draw(
2110
+ row['blend_enable'], row['depth_write_enable'], parent,
2111
+ row['blend_src_color'], row['blend_dst_color'],
2112
+ )
2113
+ draw_classes[ev_id] = cls
2114
+ if row['program_id']: unique_programs.add(row['program_id'])
2115
+ if row['vs_shader_id']: unique_shaders.add(row['vs_shader_id'])
2116
+ if row['fs_shader_id']: unique_shaders.add(row['fs_shader_id'])
2117
+ # Tally textures seen across draws
2118
+ for db in db_rows[-12:]: # cheap window; not exact but fast
2119
+ if db['slot_kind'] == 'texture' and db['resource_id']:
2120
+ unique_textures.add(db['resource_id'])
2121
+ if (i + 1) % 500 == 0:
2122
+ print(f' draws: {i+1}/{n_draws_total}')
2123
+ print(f' read draw states: {len(draws)} rows ({time.monotonic()-t0:.1f}s)')
2124
+ print(f' per-draw aux: bindings={len(db_rows)} vertex_inputs={len(vi_rows)} descriptor_access={len(da_rows)} post_vs_samples={len(pvs_rows)}')
2125
+
2126
+ # On Arm RD's GLES backend the per-stage GetReadOnlyResources()
2127
+ # returns empty arrays — resources are reached via the descriptor
2128
+ # store, and only GetDescriptorAccess() yields the actually-used
2129
+ # bindings. To keep draw_bindings populated, project from
2130
+ # descriptor_access. The columns differ slightly but both describe
2131
+ # "what this draw touched."
2132
+ _KIND_TO_SLOT = {
2133
+ 'ReadOnlyResource': 'texture',
2134
+ 'ImageSampler': 'texture', # Arm RD GLES: combined tex+sampler
2135
+ 'TypedBuffer': 'texture', # texture buffer object
2136
+ 'ReadWriteResource': 'ssbo',
2137
+ 'ReadWriteBuffer': 'ssbo',
2138
+ 'Sampler': 'sampler',
2139
+ 'ConstantBuffer': 'ubo',
2140
+ }
2141
+ for da in da_rows:
2142
+ slot_kind = _KIND_TO_SLOT.get(da['descriptor_kind'], da['descriptor_kind'].lower())
2143
+ db_rows.append({
2144
+ **{k: da[k] for k in ID_COLS},
2145
+ 'event_id': da['event_id'],
2146
+ 'slot_kind': slot_kind,
2147
+ 'slot_index': da['slot_index'],
2148
+ 'resource_id': da['resource_id'] if slot_kind != 'sampler' else 0,
2149
+ 'sampler_id': da['resource_id'] if slot_kind == 'sampler' else 0,
2150
+ 'offset': da['byte_offset'],
2151
+ 'size': da['byte_size'],
2152
+ 'stride': 0,
2153
+ })
2154
+
2155
+ t0 = time.monotonic()
2156
+ clears = _extract_clears(sd, tree['events'], tree['action_by_event'], ctx)
2157
+ dispatches = _extract_dispatches(ctrl, tree['events'], event_durations, ctx)
2158
+ print(f' clears={len(clears)} dispatches={len(dispatches)} ({time.monotonic()-t0:.1f}s)')
2159
+
2160
+ # Sample VBOs + IBOs that draws actually used
2161
+ used_vbo_ids: set = set()
2162
+ used_ibo_ids: set = set()
2163
+ for vi in vi_rows:
2164
+ if vi['buffer_id']:
2165
+ used_vbo_ids.add(vi['buffer_id'])
2166
+ for d in draws:
2167
+ if d['ibo_id']:
2168
+ used_ibo_ids.add(d['ibo_id'])
2169
+ t0 = time.monotonic()
2170
+ vbo_samples = _sample_vbos(ctrl, used_vbo_ids, ctx)
2171
+ ibo_samples = _sample_ibos(ctrl, used_ibo_ids, ctx)
2172
+ print(f' sampled VBOs={len(used_vbo_ids)}->{len(vbo_samples)} rows '
2173
+ f'IBOs={len(used_ibo_ids)}->{len(ibo_samples)} rows ({time.monotonic()-t0:.1f}s)')
2174
+
2175
+ t0 = time.monotonic()
2176
+ texture_samples = _sample_textures(ctrl, ctx)
2177
+ print(f' sampled small textures: {len(texture_samples)} rows ({time.monotonic()-t0:.1f}s)')
2178
+
2179
+ t0 = time.monotonic()
2180
+ rt_rows, timeline_rows, histogram_by_rt = _walk_render_targets(ctrl, ctx)
2181
+ print(f' walked RTs: {len(rt_rows)} rts, {len(timeline_rows)} timeline rows, {len(histogram_by_rt)} histograms ({time.monotonic()-t0:.1f}s)')
2182
+
2183
+ # Write histograms as sidecar JSON files
2184
+ hist_dir = os.path.join(capture_stage, 'histogram')
2185
+ os.makedirs(hist_dir, exist_ok=True)
2186
+ for rt_id, buckets in histogram_by_rt.items():
2187
+ with open(os.path.join(hist_dir, f'{rt_id}.json'), 'w', encoding='utf-8') as f:
2188
+ json.dump({'rt_id': rt_id, 'buckets': buckets}, f)
2189
+
2190
+ t0 = time.monotonic()
2191
+ sc_rows = _build_state_change_rows(sd, tree['parent_path_by_event'],
2192
+ tree['chunk_name_by_event'],
2193
+ tree['chunk_to_event_id'], ctx)
2194
+ print(f' state change chunks: {len(sc_rows)} rows ({time.monotonic()-t0:.1f}s)')
2195
+
2196
+ # Indices for passes/frame_totals: per-event lookups
2197
+ draws_by_event = {d['event_id']: d for d in draws}
2198
+ bindings_by_event: dict[int, list] = {}
2199
+ for b in db_rows:
2200
+ bindings_by_event.setdefault(b['event_id'], []).append(b)
2201
+
2202
+ t0 = time.monotonic()
2203
+ passes = _build_passes(tree['events'], event_durations, draw_classes, ctx,
2204
+ draws_by_event=draws_by_event,
2205
+ bindings_by_event=bindings_by_event)
2206
+ print(f' passes: {len(passes)} ({time.monotonic()-t0:.1f}s)')
2207
+
2208
+ # FBO inventory from per-draw state
2209
+ labels: dict[int, str] = {}
2210
+ labels_path = os.path.join(capture_stage, 'labels.json')
2211
+ if os.path.exists(labels_path):
2212
+ try:
2213
+ with open(labels_path, 'r', encoding='utf-8') as f:
2214
+ labels = {int(k): v for k, v in json.load(f).items()}
2215
+ except Exception:
2216
+ pass
2217
+ t0 = time.monotonic()
2218
+ fbo_rows = _build_fbo_rows(ctrl, fbo_state_per_event, labels, ctx)
2219
+ print(f' fbos: {len(fbo_rows)} rows ({time.monotonic()-t0:.1f}s)')
2220
+
2221
+ # Indirect args
2222
+ t0 = time.monotonic()
2223
+ indirect_rows = _extract_indirect_args(ctrl, tree['events'], ctx)
2224
+ print(f' indirect_args: {len(indirect_rows)} rows ({time.monotonic()-t0:.1f}s)')
2225
+
2226
+ # Pixel history at last event
2227
+ last_ev = max((e['event_id'] for e in tree['events']), default=0)
2228
+ try:
2229
+ ctrl.SetFrameEvent(last_ev, False)
2230
+ except Exception:
2231
+ pass
2232
+ t0 = time.monotonic()
2233
+ pixel_history = _extract_pixel_history(ctrl, ctx, last_ev,
2234
+ grid=int(os.environ.get('RDC_PIXEL_GRID', '4')))
2235
+ print(f' pixel_history: {len(pixel_history)} rows ({time.monotonic()-t0:.1f}s)')
2236
+
2237
+ totals = _build_frame_totals(
2238
+ tree['events'], draws, event_durations,
2239
+ unique_programs, unique_shaders, unique_textures, ctx,
2240
+ sc_rows=sc_rows,
2241
+ bindings_by_event=bindings_by_event,
2242
+ buffers_rows=[], # parser owns buffers.csv; aggregates via derive_post_merge
2243
+ textures_rows=rt_rows, # RTs are textures with est_bytes after derive
2244
+ )
2245
+
2246
+ md = _frame_metadata(ctrl, ctx, rdc_path, sd=sd)
2247
+
2248
+ # --- write outputs ---
2249
+ print('writing CSVs')
2250
+ n_events = _write_rows(os.path.join(capture_stage, 'events.csv'), EVENTS_COLS, tree['events'])
2251
+ n_draws_w = _write_rows(os.path.join(capture_stage, 'draws.csv'), DRAWS_COLS, draws)
2252
+ n_db = _write_rows(os.path.join(capture_stage, 'draw_bindings.csv'), DRAW_BINDINGS_COLS, db_rows)
2253
+ n_vi = _write_rows(os.path.join(capture_stage, 'vertex_inputs.csv'), VERTEX_INPUTS_COLS, vi_rows)
2254
+ n_da = _write_rows(os.path.join(capture_stage, 'descriptor_access.csv'), DESCRIPTOR_ACCESS_COLS, da_rows)
2255
+ n_pvs = _write_rows(os.path.join(capture_stage, 'post_vs_samples.csv'), POST_VS_SAMPLES_COLS, pvs_rows)
2256
+ n_cl = _write_rows(os.path.join(capture_stage, 'clears.csv'), CLEARS_COLS, clears)
2257
+ n_disp = _write_rows(os.path.join(capture_stage, 'dispatches.csv'), DISPATCHES_COLS, dispatches)
2258
+ n_vbo = _write_rows(os.path.join(capture_stage, 'vbo_samples.csv'), VBO_SAMPLES_COLS, vbo_samples)
2259
+ n_ibo = _write_rows(os.path.join(capture_stage, 'ibo_samples.csv'), IBO_SAMPLES_COLS, ibo_samples)
2260
+ n_ts = _write_rows(os.path.join(capture_stage, 'texture_samples.csv'), TEXTURE_SAMPLES_COLS, texture_samples)
2261
+ n_rt = _write_rows(os.path.join(capture_stage, 'render_targets.csv'), RT_COLS, rt_rows)
2262
+ n_tl = _write_rows(os.path.join(capture_stage, 'rt_event_timeline.csv'), RT_TIMELINE_COLS, timeline_rows)
2263
+ n_ctr = _write_rows(os.path.join(capture_stage, 'counters_per_event.csv'), COUNTERS_COLS, counter_rows)
2264
+ n_sc = _write_rows(os.path.join(capture_stage, 'state_change_events.csv'), STATE_CHANGE_COLS, sc_rows)
2265
+ n_p = _write_rows(os.path.join(capture_stage, 'passes.csv'), PASSES_COLS, passes)
2266
+ n_fbo = _write_rows(os.path.join(capture_stage, 'fbos.csv'), FBOS_COLS, fbo_rows)
2267
+ n_ind = _write_rows(os.path.join(capture_stage, 'indirect_args.csv'), INDIRECT_ARGS_COLS, indirect_rows)
2268
+ n_ph = _write_rows(os.path.join(capture_stage, 'pixel_history.csv'), PIXEL_HISTORY_COLS, pixel_history)
2269
+ totals['unique_textures_bound'] = len(unique_textures)
2270
+ n_ft = _write_rows(os.path.join(capture_stage, 'frame_totals.csv'), FRAME_TOTALS_COLS, [totals])
2271
+
2272
+ with open(os.path.join(capture_stage, 'frame_metadata.json'), 'w', encoding='utf-8') as f:
2273
+ json.dump(md, f, indent=2)
2274
+
2275
+ with open(os.path.join(capture_stage, 'uniforms_per_pass.jsonl'), 'w', encoding='utf-8') as f:
2276
+ for u in uniforms_per_pass_rows:
2277
+ f.write(json.dumps(u))
2278
+ f.write('\n')
2279
+
2280
+ print(f'wrote events={n_events} draws={n_draws_w} bindings={n_db} vinputs={n_vi}'
2281
+ f' desc_access={n_da} post_vs={n_pvs} clears={n_cl} disp={n_disp}'
2282
+ f' rt={n_rt} timeline={n_tl} counters={n_ctr} state_change={n_sc}'
2283
+ f' passes={n_p} fbos={n_fbo} indirect={n_ind} pixel_history={n_ph}'
2284
+ f' uniforms_passes={len(uniforms_per_pass_rows)} totals={n_ft}')
2285
+
2286
+ finally:
2287
+ try: ctrl.Shutdown()
2288
+ except Exception: pass
2289
+ try: cap.Shutdown()
2290
+ except Exception: pass
2291
+
2292
+ print('done')
2293
+ except Exception:
2294
+ traceback.print_exc()
2295
+ try: log.flush(); log.close()
2296
+ except Exception: pass
2297
+ os._exit(1)
2298
+
2299
+ try: log.flush(); log.close()
2300
+ except Exception: pass
2301
+ os._exit(0)
2302
+
2303
+
2304
+ if __name__ == '__main__':
2305
+ main()