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,190 @@
1
+ """Per-area pass GPU breakdown. Stacked bars colored by draw_class.
2
+
3
+ Aggregation key: (area, marker_path_norm). Captures + classes summed.
4
+ Bar widths normalized within each area (share-of-area-total).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ from collections import defaultdict
12
+
13
+ import pyarrow.parquet as papq
14
+
15
+ from . import base
16
+
17
+
18
+ def _aggregate(drops: list, ok_caps: set) -> dict:
19
+ """Return {area: {marker: {drop_key: {'gpu': sum, 'draws': sum,
20
+ 'verts': sum,
21
+ 'class_gpu': {cls: sum}}}}}."""
22
+ out: dict = defaultdict(lambda: defaultdict(lambda: defaultdict(
23
+ lambda: {'gpu': 0.0, 'draws': 0, 'verts': 0,
24
+ 'class_gpu': defaultdict(float)})))
25
+ for d in drops:
26
+ drop_key = d.key
27
+ for r in d.rows:
28
+ p = os.path.join(r.drop_dir, 'pass_class_breakdown.parquet')
29
+ if not os.path.exists(p):
30
+ continue
31
+ try:
32
+ t = papq.read_table(p)
33
+ except Exception:
34
+ continue
35
+ cols = {c: t.column(c).to_pylist() for c in t.column_names}
36
+ for i in range(t.num_rows):
37
+ cap = cols['capture'][i]
38
+ key = (r.area, r.drop_date, r.drop_label, cap)
39
+ if ok_caps and key not in ok_caps:
40
+ continue
41
+ marker = cols['marker_path_norm'][i] or ''
42
+ cls = cols['draw_class'][i] or 'other'
43
+ gpu = cols['sum_gpu_duration_s'][i] or 0.0
44
+ ndr = cols['n_draws'][i] or 0
45
+ verts = cols['sum_pre_vs_vertices'][i] or 0
46
+ bucket = out[r.area][marker][drop_key]
47
+ bucket['gpu'] += gpu
48
+ bucket['draws'] += ndr
49
+ bucket['verts'] += verts
50
+ bucket['class_gpu'][cls] += gpu
51
+ return out
52
+
53
+
54
+ def _drop_dir_for_area(drops: list, drop_key: str, area: str) -> str:
55
+ for d in drops:
56
+ if d.key != drop_key:
57
+ continue
58
+ for r in d.rows:
59
+ if r.area == area:
60
+ return r.drop_dir
61
+ return ''
62
+
63
+
64
+ def build(root: str, *, drops: list | None = None, ab=None) -> str:
65
+ if drops is None:
66
+ drops = base.discover_drops(root)
67
+ out_path = base.output_path(root, 'pass_gpu', ab)
68
+ out_dir = os.path.dirname(out_path)
69
+
70
+ ok_caps = base.ok_capture_set(root)
71
+ data = _aggregate(drops, ok_caps)
72
+ drop_keys = [d.key for d in drops]
73
+
74
+ parts = [base.page_open('pass gpu', hdr_offset_px=120)]
75
+ parts.append(base.header(
76
+ 'pass gpu',
77
+ drops=len(drops),
78
+ captures=sum(d.n_captures for d in drops),
79
+ build_ts=base.now_iso(),
80
+ crumb_depth=base.crumb_depth(ab),
81
+ ))
82
+ parts.append(base.ab_strip(ab))
83
+ parts.append(base.ab_picker_for(root, 'pass_gpu', ab=ab))
84
+
85
+ # Summary bar: top pass globally + area count
86
+ if data:
87
+ global_top = None
88
+ global_top_gpu = 0.0
89
+ for area, markers in data.items():
90
+ for marker, drop_buckets in markers.items():
91
+ mg = max((b['gpu'] for b in drop_buckets.values()), default=0.0)
92
+ if mg > global_top_gpu:
93
+ global_top_gpu = mg
94
+ global_top = (area, base.pass_suffix(marker) or marker)
95
+ if global_top is not None:
96
+ area, marker_short = global_top
97
+ parts.append(base.summary_bar(
98
+ 'top pass',
99
+ f'{area} / {marker_short}',
100
+ sub=f'across {len(data)} areas',
101
+ link_href=f'#{base.h(area)}',
102
+ link_text='area',
103
+ tone='neutral',
104
+ ))
105
+
106
+ parts.append(base.legend())
107
+
108
+ if not data:
109
+ parts.append('<p class="note">no pass_class_breakdown data found</p>')
110
+ else:
111
+ for area in sorted(data.keys()):
112
+ markers = data[area]
113
+ ranked = sorted(
114
+ markers.items(),
115
+ key=lambda kv: max((b['gpu'] for b in kv[1].values()), default=0.0),
116
+ reverse=True,
117
+ )[:20]
118
+ area_total = sum(
119
+ max((b['gpu'] for b in m.values()), default=0.0)
120
+ for _, m in ranked
121
+ ) or 1.0
122
+
123
+ area_body = []
124
+ for marker, drop_buckets in ranked:
125
+ max_gpu = max((b['gpu'] for b in drop_buckets.values()), default=0.0)
126
+ latest_bucket = drop_buckets.get(drop_keys[-1], {})
127
+ latest_class_gpu = dict(latest_bucket.get('class_gpu', {}))
128
+ latest_gpu = latest_bucket.get('gpu', 0.0)
129
+ latest_draws = latest_bucket.get('draws', 0)
130
+ latest_verts = latest_bucket.get('verts', 0)
131
+
132
+ bar_total = latest_gpu if latest_gpu > 0 else max_gpu
133
+ bar_weights = latest_class_gpu
134
+ if bar_total <= 0:
135
+ bar_weights = {'other': 1.0}
136
+ bar_total = 1.0
137
+
138
+ rep_drop = drop_keys[-1] if latest_gpu > 0 else next(
139
+ (k for k in drop_keys if drop_buckets.get(k, {}).get('gpu', 0) > 0),
140
+ drop_keys[-1] if drop_keys else ''
141
+ )
142
+ drop_dir = _drop_dir_for_area(drops, rep_drop, area)
143
+ link = base.rel_path_to_drop_index(out_dir, drop_dir, 'passes') if drop_dir else '#'
144
+
145
+ pct_share = (max_gpu / area_total) * 100.0 if area_total > 0 else 0.0
146
+
147
+ area_body.append('<div class="bar-row">')
148
+ short = base.pass_short(marker)
149
+ if len(short) > 60:
150
+ short = base.trunc_left(short, 60)
151
+ area_body.append(
152
+ f'<span class="key" title="{base.h(marker)}">'
153
+ f'<a href="{base.h(link)}" data-link-kind="drill">'
154
+ f'{base.safe_chrome_text(short)}</a></span>'
155
+ )
156
+ bar_html = base.class_segments_bar(bar_weights, bar_total)
157
+ area_body.append(f'<div style="width: {pct_share:.2f}%;">{bar_html}</div>')
158
+ area_body.append(f'<span class="total">{pct_share:.1f}%</span>')
159
+ area_body.append('</div>')
160
+
161
+ if len(drop_keys) >= 2:
162
+ cells = ['<div class="bar-row">',
163
+ '<span class="key" style="color: var(--text-2)">drops</span>',
164
+ '<div style="display:flex;gap:var(--sp-3);font:var(--fs-small) ui-monospace,monospace;align-items:center;color:var(--text-2)">']
165
+ prev_gpu = None
166
+ for i, k in enumerate(drop_keys):
167
+ b = drop_buckets.get(k, {})
168
+ g = b.get('gpu', 0.0)
169
+ cells.append(f'<span>{base.h(k)}: {base.fmt_float(g, 3)}</span>')
170
+ if i > 0:
171
+ cells.append(base.delta_pill(
172
+ g, prev_gpu,
173
+ lower_is_better=True, fmt='{:+,.3f}'))
174
+ prev_gpu = g
175
+ cells.append('</div>')
176
+ cells.append(f'<span class="total" style="color:var(--text-3)">'
177
+ f'd={base.fmt_int(latest_draws)} v={base.fmt_int(latest_verts)}</span>')
178
+ cells.append('</div>')
179
+ area_body.append(''.join(cells))
180
+
181
+ parts.append(f'<h2 id="{base.h(area)}">{base.h(area)}</h2>')
182
+ parts.append(''.join(area_body))
183
+
184
+ parts.append(base.page_close())
185
+
186
+ return base.write_report(out_path, parts)
187
+
188
+
189
+ if __name__ == '__main__':
190
+ sys.exit(base.run_report(build, module_name='pass_gpu'))
@@ -0,0 +1,240 @@
1
+ """Top fragment shaders by complexity * uses.
2
+
3
+ Reads _reports/_cache/shader_summary_per_drop.parquet when present; falls
4
+ back to live scan of **/shaders.parquet.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ from collections import Counter, defaultdict
12
+
13
+ import pyarrow.parquet as papq
14
+
15
+ from . import base
16
+
17
+
18
+ _SHADER_COLS = [
19
+ 'area', 'drop_date', 'drop_label', 'capture', 'shader_id', 'stable_key',
20
+ 'shader_type', 'src_len', 'complexity_score', 'total_branches',
21
+ 'total_loops', 'total_discards', 'total_dfdx_dfdy',
22
+ 'total_texture_samples', 'used_by_draw_count', 'src_file_path',
23
+ 'fb_fetch', 'uses_cubemap',
24
+ ]
25
+
26
+
27
+ def _iter_shaders(root: str, drops: list):
28
+ cache = base.cache_path(root, 'shader_summary')
29
+ if os.path.exists(cache):
30
+ try:
31
+ t = papq.read_table(cache)
32
+ cols = {c: t.column(c).to_pylist() for c in t.column_names}
33
+ wanted = {(d.date, d.label) for d in drops}
34
+ for i in range(t.num_rows):
35
+ if (cols['drop_date'][i], cols['drop_label'][i]) not in wanted:
36
+ continue
37
+ yield {c: cols[c][i] for c in cols}
38
+ return
39
+ except Exception:
40
+ pass
41
+ for d in drops:
42
+ for r in d.rows:
43
+ p = os.path.join(r.drop_dir, 'shaders.parquet')
44
+ if not os.path.exists(p):
45
+ continue
46
+ schema_cols = set(papq.read_schema(p).names)
47
+ want = [c for c in _SHADER_COLS if c in schema_cols]
48
+ try:
49
+ t = papq.read_table(p, columns=want)
50
+ except Exception:
51
+ continue
52
+ cols = {c: t.column(c).to_pylist() for c in t.column_names}
53
+ for i in range(t.num_rows):
54
+ row = {c: cols[c][i] for c in cols}
55
+ row['drop_date'] = r.drop_date
56
+ row['drop_label'] = r.drop_label
57
+ yield row
58
+
59
+
60
+ def _drop_dir_first(drops: list, drop_date, drop_label) -> str:
61
+ for d in drops:
62
+ if d.date == drop_date and d.label == drop_label and d.rows:
63
+ return d.rows[0].drop_dir
64
+ return ''
65
+
66
+
67
+ def build(root: str, *, drops: list | None = None, ab=None,
68
+ stage: str = 'fragment') -> str:
69
+ if drops is None:
70
+ drops = base.discover_drops(root)
71
+ out_path = base.output_path(root, 'shader_hotlist', ab)
72
+ out_dir = os.path.dirname(out_path)
73
+
74
+ drop_keys = [d.key for d in drops]
75
+
76
+ per_key: dict = defaultdict(lambda: {
77
+ 'uses_by_drop': Counter(),
78
+ 'complexity': 0.0,
79
+ 'branches': 0,
80
+ 'loops': 0,
81
+ 'discards': 0,
82
+ 'dfdx_dfdy': 0,
83
+ 'tex_samples': 0,
84
+ 'src_len': 0,
85
+ 'shader_type': '',
86
+ 'rep_drop_date': None,
87
+ 'rep_drop_label': None,
88
+ 'rep_shader_id': 0,
89
+ 'rep_src_path': '',
90
+ 'rep_capture': '',
91
+ 'fb_fetch': False,
92
+ 'uses_cubemap': False,
93
+ })
94
+
95
+ for row in _iter_shaders(root, drops):
96
+ stype = row.get('shader_type') or ''
97
+ if stage and stype != stage:
98
+ continue
99
+ sk = row.get('stable_key') or ''
100
+ if not sk:
101
+ continue
102
+ drop_key = f"{row['drop_date']}_{row['drop_label']}"
103
+ p = per_key[sk]
104
+ p['uses_by_drop'][drop_key] += int(row.get('used_by_draw_count') or 0)
105
+ p['complexity'] = max(p['complexity'], float(row.get('complexity_score') or 0))
106
+ p['branches'] = max(p['branches'], int(row.get('total_branches') or 0))
107
+ p['loops'] = max(p['loops'], int(row.get('total_loops') or 0))
108
+ p['discards'] = max(p['discards'], int(row.get('total_discards') or 0))
109
+ p['dfdx_dfdy'] = max(p['dfdx_dfdy'], int(row.get('total_dfdx_dfdy') or 0))
110
+ p['tex_samples'] = max(p['tex_samples'], int(row.get('total_texture_samples') or 0))
111
+ p['src_len'] = max(p['src_len'], int(row.get('src_len') or 0))
112
+ p['shader_type'] = stype
113
+ if row.get('fb_fetch'):
114
+ p['fb_fetch'] = True
115
+ if row.get('uses_cubemap'):
116
+ p['uses_cubemap'] = True
117
+ if p['rep_shader_id'] == 0 and row.get('shader_id'):
118
+ p['rep_drop_date'] = row['drop_date']
119
+ p['rep_drop_label'] = row['drop_label']
120
+ p['rep_shader_id'] = row.get('shader_id') or 0
121
+ p['rep_src_path'] = row.get('src_file_path') or ''
122
+ p['rep_capture'] = row.get('capture') or ''
123
+
124
+ ranked = []
125
+ for sk, p in per_key.items():
126
+ total_uses = sum(p['uses_by_drop'].values())
127
+ cost = p['complexity'] * total_uses
128
+ ranked.append((sk, p, total_uses, cost))
129
+ ranked.sort(key=lambda x: x[3], reverse=True)
130
+ ranked = ranked[:50]
131
+
132
+ max_cost = max((c for _, _, _, c in ranked), default=0.0)
133
+
134
+ parts = [base.page_open(f'shader hotlist ({stage})', hdr_offset_px=120)]
135
+ parts.append(base.header(
136
+ f'shader hotlist ({stage})',
137
+ drops=len(drops),
138
+ captures=sum(d.n_captures for d in drops),
139
+ build_ts=base.now_iso(),
140
+ crumb_depth=base.crumb_depth(ab),
141
+ ))
142
+ parts.append(base.ab_strip(ab))
143
+ parts.append(base.ab_picker_for(root, 'shader_hotlist', ab=ab))
144
+
145
+ # Summary bar: top shader by cost proxy
146
+ if ranked:
147
+ sk, p, total_uses, cost = ranked[0]
148
+ label_top = f'{p["shader_type"][:4]}-cplx-{int(p["complexity"])}'
149
+ parts.append(base.summary_bar(
150
+ 'top shader',
151
+ f'{label_top}',
152
+ sub=f'{base.fmt_int(total_uses)} uses across drops',
153
+ link_href='#shaders',
154
+ link_text='table',
155
+ tone='neutral',
156
+ ))
157
+
158
+ sec = []
159
+ sec.append(f'<h2 id="shaders">top {stage} shaders by complexity * uses</h2>')
160
+ sec.append('<div class="table-wrap"><rdc-sortable-table data-default-sort="cost proxy" data-default-dir="desc">')
161
+ sec.append('<table class="report"><thead><tr>')
162
+ sec.append('<th>shader</th>')
163
+ sec.append('<th class="num">complexity</th>')
164
+ sec.append('<th class="num">uses total</th>')
165
+ single = len(drop_keys) == 1
166
+ for i, k in enumerate(drop_keys):
167
+ head = 'uses' if single else f'uses@{base.h(k)}'
168
+ sec.append(f'<th class="num">{head}</th>')
169
+ if i > 0:
170
+ latest = ' delta-latest' if i == len(drop_keys) - 1 else ''
171
+ sec.append(f'<th class="num{latest}">delta</th>')
172
+ if len(drop_keys) >= 3:
173
+ sec.append('<th class="num">trend</th>')
174
+ sec.extend([
175
+ '<th class="num">cost proxy</th>',
176
+ '<th class="num">branches</th>',
177
+ '<th class="num">loops</th>',
178
+ '<th class="num">tex samples</th>',
179
+ '<th class="num">src bytes</th>',
180
+ '<th>flags</th>',
181
+ '<th>src</th>',
182
+ '</tr></thead><tbody>',
183
+ ])
184
+
185
+ for rank_i, (sk, p, total_uses, cost) in enumerate(ranked, 1):
186
+ sec.append('<tr>')
187
+ rp = base.rank_pill(rank_i) if rank_i <= 3 else ''
188
+ shader_label = f'{p["shader_type"][:4]}-cplx-{int(p["complexity"])}'
189
+ drop_dir = _drop_dir_first(drops, p['rep_drop_date'], p['rep_drop_label'])
190
+ src_link = base.rel_path_to_drop_file(out_dir, drop_dir, p['rep_src_path'])
191
+ if src_link:
192
+ sec.append(f'<td>{rp}<a href="{base.h(src_link)}" data-link-kind="inline" target="_blank" rel="noopener">{base.h(shader_label)}{base.icon("link-out")}</a></td>')
193
+ else:
194
+ sec.append(f'<td>{rp}{base.h(shader_label)}</td>')
195
+ sec.append(f'<td class="num">{base.fmt_float(p["complexity"], 2)}</td>')
196
+ sec.append(f'<td class="num">{base.fmt_int(total_uses)}</td>')
197
+ prev = None
198
+ series = []
199
+ for i, k in enumerate(drop_keys):
200
+ v = p['uses_by_drop'].get(k, 0)
201
+ series.append(v)
202
+ sec.append(f'<td class="num">{base.fmt_int(v)}</td>')
203
+ if i > 0:
204
+ sec.append(base.delta_cell(v, prev,
205
+ lower_is_better=None, fmt='{:+,.0f}',
206
+ regression_threshold_pct=None))
207
+ prev = v
208
+ if len(drop_keys) >= 3:
209
+ sec.append(f'<td class="num">{base.sparkline_svg(series)}</td>')
210
+
211
+ bar = base.inline_bar(cost, max_cost) if max_cost > 0 else ''
212
+ sec.append(f'<td class="num">{base.fmt_float(cost, 1)}{bar}</td>')
213
+ sec.append(f'<td class="num">{base.fmt_int(p["branches"])}</td>')
214
+ sec.append(f'<td class="num">{base.fmt_int(p["loops"])}</td>')
215
+ sec.append(f'<td class="num">{base.fmt_int(p["tex_samples"])}</td>')
216
+ sec.append(f'<td class="num">{base.fmt_int(p["src_len"])}</td>')
217
+
218
+ flags = []
219
+ if p['fb_fetch']:
220
+ flags.append('fb_fetch')
221
+ if p['uses_cubemap']:
222
+ flags.append('cubemap')
223
+ sec.append(f'<td>{base.h(",".join(flags))}</td>')
224
+
225
+ if src_link:
226
+ sec.append(f'<td><a href="{base.h(src_link)}" data-link-kind="inline" target="_blank" rel="noopener">{base.h(p["rep_src_path"])}{base.icon("file")}</a></td>')
227
+ else:
228
+ sec.append('<td></td>')
229
+
230
+ sec.append('</tr>')
231
+ sec.append('</tbody></table></rdc-sortable-table></div>')
232
+ parts.append(''.join(sec))
233
+
234
+ parts.append(base.page_close())
235
+
236
+ return base.write_report(out_path, parts)
237
+
238
+
239
+ if __name__ == '__main__':
240
+ sys.exit(base.run_report(build, module_name='shader_hotlist'))