1
0

index.html 49 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429
  1. <!DOCTYPE html>
  2. <html lang="en">
  3. <head>
  4. <meta charset="utf-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1">
  6. <title>Source VProf Viewer</title>
  7. <style>
  8. /* Layout only. A visual design pass will replace this. */
  9. * { box-sizing: border-box; }
  10. body { font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
  11. font-size: 13px; margin: 0; color: #111; background: #fafafa; }
  12. header { padding: 8px 12px; background: #1a202c; color: #fff; }
  13. header h1 { font-size: 16px; margin: 0 0 6px 0; }
  14. header .row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
  15. header label { font-size: 12px; }
  16. main { padding: 12px; display: flex; flex-direction: column; gap: 12px; }
  17. section { background: #fff; border: 1px solid #ddd; padding: 8px; }
  18. h2 { font-size: 14px; margin: 0 0 6px 0; }
  19. h3 { font-size: 13px; margin: 10px 0 4px 0; }
  20. table { border-collapse: collapse; width: 100%; }
  21. th, td { border: 1px solid #ddd; padding: 2px 6px; text-align: left;
  22. font-size: 12px; vertical-align: top; }
  23. th { background: #f0f0f0; cursor: pointer; white-space: nowrap; }
  24. th.nosort { cursor: default; }
  25. td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
  26. .muted { color: #666; }
  27. .warn { background: #fff5e6; border: 1px solid #e0a800; padding: 6px 8px; }
  28. .notice { background: #e8f0fe; border: 1px solid #6b9bd2; padding: 6px 8px; }
  29. .summary-grid { display: flex; flex-wrap: wrap; gap: 12px 24px; }
  30. .summary-grid div { min-width: 120px; }
  31. .summary-grid .k { color: #666; font-size: 11px; }
  32. .summary-grid .v { font-size: 14px; font-weight: 600; }
  33. .controls { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
  34. .controls input[type=number] { width: 90px; }
  35. #timeline-wrap { width: 100%; }
  36. #timeline { display: block; width: 100%; height: 120px; background: #fff;
  37. border: 1px solid #ddd; cursor: crosshair; }
  38. .grid2 { display: grid; grid-template-columns: minmax(0,1fr) 360px; gap: 12px; }
  39. @media (max-width: 900px) { .grid2 { grid-template-columns: 1fr; } }
  40. .toggle { cursor: pointer; user-select: none; display: inline-block; width: 12px; }
  41. .tree-name { white-space: nowrap; }
  42. .bar { display: inline-block; height: 8px; background: #6b9bd2; vertical-align: middle; }
  43. .progress { height: 6px; background: #ddd; width: 200px; }
  44. .progress > div { height: 100%; background: #2b6cb0; width: 0%; }
  45. .scroll { max-height: 520px; overflow: auto; }
  46. .hidden { display: none; }
  47. footer { padding: 8px 12px; color: #666; font-size: 11px; }
  48. </style>
  49. </head>
  50. <body>
  51. <header>
  52. <h1>Source VProf Viewer</h1>
  53. <div class="row">
  54. <label>Choose a .vprof file: <input type="file" id="file"></label>
  55. <button id="open-picker" class="hidden">Open with file picker&hellip;</button>
  56. <button id="reopen-last" class="hidden">Reopen last recording</button>
  57. <span id="drop-hint" class="muted">or drag &amp; drop it onto this page</span>
  58. <div id="progress-wrap" class="hidden">
  59. <div class="progress"><div id="progress-bar"></div></div>
  60. <span id="status" class="muted"></span>
  61. </div>
  62. </div>
  63. </header>
  64. <main>
  65. <section id="error-box" class="warn hidden"></section>
  66. <section id="summary-section" class="hidden">
  67. <h2>Recording summary</h2>
  68. <div id="notices"></div>
  69. <div id="summary" class="summary-grid"></div>
  70. </section>
  71. <section id="timeline-section" class="hidden">
  72. <h2>Frame timeline</h2>
  73. <div class="controls">
  74. <button id="range-all">All frames</button>
  75. <label>From frame <input type="number" id="range-start" min="0" step="1"></label>
  76. <label>to <input type="number" id="range-end" min="0" step="1"></label>
  77. <button id="range-apply">Apply range</button>
  78. <button id="frame-prev">&#9664; prev</button>
  79. <button id="frame-next">next &#9654;</button>
  80. <button id="zoom-out" title="Zoom out (mouse wheel down)">&#8722;</button>
  81. <button id="zoom-in" title="Zoom in (mouse wheel up)">+</button>
  82. <button id="zoom-selection" title="Zoom the timeline to the selected frame range">Zoom to selection</button>
  83. <button id="zoom-reset" title="Show all frames">Reset zoom</button>
  84. <span id="frame-label" class="muted"></span>
  85. <span id="zoom-label" class="muted"></span>
  86. <span id="range-status" class="muted"></span>
  87. </div>
  88. <div id="timeline-wrap"><canvas id="timeline"></canvas></div>
  89. <div id="timeline-caption" class="muted"></div>
  90. </section>
  91. <div class="grid2">
  92. <section id="tree-section" class="hidden">
  93. <h2>Call tree</h2>
  94. <div class="controls">
  95. <label>Filter <input type="search" id="filter" placeholder="name contains..."></label>
  96. <button id="expand-all">Expand all</button>
  97. <button id="collapse-all">Collapse all</button>
  98. <span id="tree-count" class="muted"></span>
  99. </div>
  100. <div class="scroll">
  101. <table id="tree-table">
  102. <thead><tr id="tree-head"></tr></thead>
  103. <tbody id="tree-body"></tbody>
  104. </table>
  105. </div>
  106. </section>
  107. <div>
  108. <section id="budget-section" class="hidden">
  109. <h2>Budget groups</h2>
  110. <div class="scroll">
  111. <table id="budget-table">
  112. <thead><tr><th class="nosort">Group</th><th class="nosort num">Calls</th>
  113. <th class="nosort num">Total</th><th class="nosort num">%</th></tr></thead>
  114. <tbody id="budget-body"></tbody>
  115. </table>
  116. </div>
  117. </section>
  118. <section id="top-section" class="hidden">
  119. <h2>Top nodes</h2>
  120. <div class="scroll">
  121. <table id="top-table">
  122. <thead><tr><th class="nosort">Scope</th><th class="nosort num">Total</th>
  123. <th class="nosort num">Self</th><th class="nosort num">Calls</th></tr></thead>
  124. <tbody id="top-body"></tbody>
  125. </table>
  126. </div>
  127. </section>
  128. </div>
  129. </div>
  130. </main>
  131. <footer>
  132. VProf binary format documentation: see <code>VPROF_FORMAT.md</code> in the repository.
  133. </footer>
  134. <!-- ===================== Parsing worker (inline, self-contained) ===================== -->
  135. <script id="worker-src" type="text/js-worker">
  136. "use strict";
  137. // The 31 budget groups registered by CVProfile::CVProfile(), in fixed ID order.
  138. var DEFAULT_BUDGET_GROUPS = [
  139. ["Unaccounted", 6],
  140. ["World Rendering", 1],
  141. ["Displacement_Rendering", 1],
  142. ["Game", 6],
  143. ["Player", 6],
  144. ["NPCs", 6],
  145. ["Server Animation", 6],
  146. ["Client_Animation", 1],
  147. ["Physics", 6],
  148. ["Static_Prop_Rendering", 1],
  149. ["Other_Model_Rendering", 1],
  150. ["Fast Path Model Rendering", 1],
  151. ["Light_Cache", 1],
  152. ["Brush_Model_Rendering", 1],
  153. ["Shadow_Rendering", 1],
  154. ["Detail_Prop_Rendering", 1],
  155. ["Particle/Effect_Rendering", 1],
  156. ["Ropes", 1],
  157. ["Dynamic_Light_Rendering", 1],
  158. ["Networking", 6],
  159. ["Sound", 6],
  160. ["VGUI", 6],
  161. ["FileSystem", 6],
  162. ["Prediction", 1],
  163. ["Interpolation", 1],
  164. ["Swap_Buffers", 1],
  165. ["Occlusion", 1],
  166. ["Overlays", 1],
  167. ["Tools", 5],
  168. ["Texture_Cache", 1],
  169. ["Replay", 2]
  170. ];
  171. var TOKEN_START_FRAME = 0;
  172. var TOKEN_ADD_NODE = 1;
  173. var TOKEN_ADD_BUDGET_GROUP = 2;
  174. var TOKEN_TIMINGS = 3;
  175. var TOKEN_END_OF_FRAME = 4;
  176. var TOKEN_FILE_FINISHED = 5;
  177. // Retained after a parse so range queries can re-parse only a byte range
  178. // without keeping every sample in memory.
  179. var g_recording = null;
  180. self.onmessage = function (e) {
  181. var msg = e.data;
  182. if (!msg) return;
  183. if (msg.cmd === "parse") {
  184. try {
  185. var out = parseVprof(msg.buffer, msg.opts || {});
  186. self.postMessage({ type: "done", result: out.result }, out.transfer);
  187. } catch (err) {
  188. self.postMessage({ type: "error", message: (err && err.message) || String(err) });
  189. }
  190. } else if (msg.cmd === "query") {
  191. var t0 = (self.performance && self.performance.now) ? self.performance.now() : Date.now();
  192. try {
  193. var agg = parseRange(msg.buffer, msg.startFrame, msg.endFrame);
  194. var t1 = (self.performance && self.performance.now) ? self.performance.now() : Date.now();
  195. self.postMessage({ type: "range", requestId: msg.requestId, startFrame: msg.startFrame, endFrame: msg.endFrame, aggregates: agg, ms: t1 - t0 });
  196. } catch (err) {
  197. self.postMessage({ type: "rangeError", requestId: msg.requestId, message: (err && err.message) || String(err) });
  198. }
  199. }
  200. };
  201. function parseVprof(buffer, opts) {
  202. var t0 = (self.performance && self.performance.now) ? self.performance.now() : Date.now();
  203. var dv = new DataView(buffer);
  204. var totalBytes = buffer.byteLength;
  205. var pos = 0;
  206. var decoder = new TextDecoder("utf-8");
  207. function u8() { return dv.getUint8(pos++); }
  208. function u16() { var v = dv.getUint16(pos, true); pos += 2; return v; }
  209. function u32() { var v = dv.getUint32(pos, true); pos += 4; return v; }
  210. function i32() { var v = dv.getInt32(pos, true); pos += 4; return v; }
  211. function cstr() {
  212. var start = pos;
  213. while (dv.getUint8(pos) !== 0) pos++;
  214. var s = decoder.decode(new Uint8Array(buffer, start, pos - start));
  215. pos++;
  216. return s;
  217. }
  218. if (totalBytes < 8) throw new Error("File is too small to be a .vprof recording.");
  219. var version = i32();
  220. var rootNodeId = i32();
  221. // ---- Budget groups (seed the 31 defaults so sparse recordings still resolve) ----
  222. var budgetGroups = [];
  223. var groupIdByName = Object.create(null);
  224. for (var i = 0; i < DEFAULT_BUDGET_GROUPS.length; i++) {
  225. var g = DEFAULT_BUDGET_GROUPS[i];
  226. budgetGroups.push({ id: i, name: g[0], flags: g[1], isDefault: true });
  227. groupIdByName[g[0]] = i;
  228. }
  229. function addBudgetGroup(name, flags) {
  230. var id = groupIdByName[name];
  231. if (id !== undefined) { budgetGroups[id].flags = flags; return id; }
  232. id = budgetGroups.length;
  233. budgetGroups.push({ id: id, name: name, flags: flags, isDefault: false });
  234. groupIdByName[name] = id;
  235. return id;
  236. }
  237. // ---- Nodes (sparse array indexed by unique node id) ----
  238. var nodesById = [];
  239. // Frame index in which each node was first declared. Because
  240. // CVProfNode::GetSubNode() only ever *prepends*, the children order at any
  241. // frame is the final order restricted to nodes whose addedFrame <= frame.
  242. // That lets a range be parsed without replaying structure from the start.
  243. var nodesAddedFrame = [];
  244. var frameCount = 0;
  245. function makeNode(id, name, parentId, budgetGroupId) {
  246. var n = { id: id, name: name, parentId: parentId, budgetGroupId: budgetGroupId, children: [] };
  247. nodesById[id] = n;
  248. nodesAddedFrame[id] = frameCount;
  249. return n;
  250. }
  251. makeNode(rootNodeId, "Root", null, 0);
  252. // ---- Aggregates (sparse array indexed by unique node id) ----
  253. var agg = [];
  254. function aggFor(id) {
  255. var a = agg[id];
  256. if (!a) { a = { calls: 0, us: 0, maxUs: 0, frames: 0 }; agg[id] = a; }
  257. return a;
  258. }
  259. // ---- Per-frame storage budget ----
  260. var maxSamples = (opts.maxSamples && opts.maxSamples > 0) ? opts.maxSamples : 4000000;
  261. var allowSamples = opts.noSamples !== true;
  262. var storing = allowSamples;
  263. var overflow = false;
  264. var totalSamples = 0;
  265. var samples = [];
  266. var frameTicks = [];
  267. var frameTotals = [];
  268. var frameCalls = [];
  269. var addedNodesByFrame = [];
  270. // Byte offset of each frame's Token_StartFrame and of the byte just past its
  271. // Token_EndOfFrame. A [start,end] frame range maps to one contiguous
  272. // file.slice(frameStartOffsets[start], frameEndOffsets[end]).
  273. var frameStartOffsets = [];
  274. var frameEndOffsets = [];
  275. var anyTime = false;
  276. var anyCalls = false;
  277. // Outer variables reused by readTimings (avoids allocating a closure per frame)
  278. var curSamples = null;
  279. var frameTotalUs = 0;
  280. var frameTotalCalls = 0;
  281. function readTimings(nodeId) {
  282. var callsToken = u8();
  283. var calls;
  284. if (callsToken === 255) calls = u16(); else calls = callsToken;
  285. var timeUnits = u16();
  286. var us;
  287. if (timeUnits === 0xFFFF) us = u32() * 4; else us = timeUnits * 4;
  288. if (calls > 0) anyCalls = true;
  289. if (us > 0) anyTime = true;
  290. totalSamples++;
  291. if (storing) {
  292. curSamples.ids.push(nodeId);
  293. curSamples.calls.push(calls);
  294. curSamples.us.push(us);
  295. if (totalSamples > maxSamples) {
  296. // Too large to keep every frame; fall back to whole-recording aggregate.
  297. storing = false;
  298. overflow = true;
  299. samples.length = 0;
  300. curSamples = null;
  301. }
  302. }
  303. frameTotalUs += us;
  304. frameTotalCalls += calls;
  305. var a = aggFor(nodeId);
  306. a.calls += calls;
  307. a.us += us;
  308. if (us > a.maxUs) a.maxUs = us;
  309. a.frames++;
  310. var kids = nodesById[nodeId].children;
  311. for (var k = 0; k < kids.length; k++) readTimings(kids[k]);
  312. }
  313. var progressAt = 0;
  314. var totalFramesApprox = -1;
  315. while (pos < totalBytes) {
  316. var frameStart = pos;
  317. var token = u8();
  318. if (token === TOKEN_FILE_FINISHED) break;
  319. if (token !== TOKEN_START_FRAME) {
  320. throw new Error("Expected start-of-frame token at offset " + (pos - 1) + ", got " + token + ".");
  321. }
  322. var tick = i32();
  323. frameTicks.push(tick);
  324. frameTotalUs = 0;
  325. frameTotalCalls = 0;
  326. curSamples = storing ? { ids: [], calls: [], us: [] } : null;
  327. var added = [];
  328. while (true) {
  329. var inner = u8();
  330. if (inner === TOKEN_END_OF_FRAME) break;
  331. if (inner === TOKEN_ADD_BUDGET_GROUP) {
  332. var bgName = cstr();
  333. var bgFlags = i32();
  334. addBudgetGroup(bgName, bgFlags);
  335. } else if (inner === TOKEN_ADD_NODE) {
  336. var parentId = i32();
  337. var nodeName = cstr();
  338. var bgId = i32();
  339. var nodeId = i32();
  340. var parent = nodesById[parentId];
  341. if (!parent) throw new Error("AddNode references unknown parent " + parentId + " at offset " + (pos - 4) + ".");
  342. if (nodesById[nodeId]) throw new Error("AddNode re-declares node " + nodeId + " at offset " + (pos - 4) + ".");
  343. makeNode(nodeId, nodeName, parentId, bgId);
  344. parent.children.unshift(nodeId); // CVProfNode::GetSubNode() prepends
  345. added.push(nodeId);
  346. } else if (inner === TOKEN_TIMINGS) {
  347. readTimings(rootNodeId);
  348. } else {
  349. throw new Error("Unexpected token " + inner + " inside frame " + frameCount + " at offset " + (pos - 1) + ".");
  350. }
  351. }
  352. if (storing && curSamples) samples.push(curSamples);
  353. frameTotals.push(frameTotalUs);
  354. frameCalls.push(frameTotalCalls);
  355. addedNodesByFrame.push(added);
  356. frameStartOffsets.push(frameStart);
  357. frameEndOffsets.push(pos);
  358. frameCount++;
  359. if (frameCount - progressAt >= 500) {
  360. progressAt = frameCount;
  361. self.postMessage({ type: "progress", frames: frameCount, pos: pos, total: totalBytes });
  362. }
  363. }
  364. // ---- Compact and collect transferables ----
  365. var transfer = [];
  366. var frameTicksOut = Int32Array.from(frameTicks);
  367. var frameTotalsOut = Float64Array.from(frameTotals);
  368. var frameCallsOut = Uint32Array.from(frameCalls);
  369. var frameStartOffsetsOut = Float64Array.from(frameStartOffsets);
  370. var frameEndOffsetsOut = Float64Array.from(frameEndOffsets);
  371. transfer.push(frameTicksOut.buffer, frameTotalsOut.buffer, frameCallsOut.buffer,
  372. frameStartOffsetsOut.buffer, frameEndOffsetsOut.buffer);
  373. var addedOut = [];
  374. for (var f = 0; f < addedNodesByFrame.length; f++) {
  375. var arr = Int32Array.from(addedNodesByFrame[f]);
  376. transfer.push(arr.buffer);
  377. addedOut.push(arr);
  378. }
  379. var samplesOut = null;
  380. if (storing) {
  381. samplesOut = [];
  382. for (var s = 0; s < samples.length; s++) {
  383. var fr = samples[s];
  384. var ids = Int32Array.from(fr.ids);
  385. var callsArr = Uint32Array.from(fr.calls);
  386. var usArr = Float64Array.from(fr.us);
  387. transfer.push(ids.buffer, callsArr.buffer, usArr.buffer);
  388. samplesOut.push({ ids: ids, calls: callsArr, us: usArr });
  389. }
  390. }
  391. var t1 = (self.performance && self.performance.now) ? self.performance.now() : Date.now();
  392. // Keep the parsed tree around so later range queries can walk it.
  393. g_recording = {
  394. nodes: nodesById,
  395. nodesAddedFrame: nodesAddedFrame,
  396. rootNodeId: rootNodeId
  397. };
  398. return {
  399. result: {
  400. version: version,
  401. rootNodeId: rootNodeId,
  402. budgetGroups: budgetGroups,
  403. nodes: nodesById,
  404. aggregates: agg,
  405. frameCount: frameCount,
  406. frameTicks: frameTicksOut,
  407. frameTotals: frameTotalsOut,
  408. frameCalls: frameCallsOut,
  409. frameStartOffsets: frameStartOffsetsOut,
  410. frameEndOffsets: frameEndOffsetsOut,
  411. addedNodesByFrame: addedOut,
  412. samples: samplesOut,
  413. samplesStored: storing,
  414. samplesOverflow: overflow,
  415. totalSamples: totalSamples,
  416. anyTime: anyTime,
  417. anyCalls: anyCalls,
  418. parseMs: t1 - t0
  419. },
  420. transfer: transfer
  421. };
  422. }
  423. // Re-parse only the frames in a contiguous byte range and aggregate per node.
  424. // `buffer` must start on the Token_StartFrame of `startFrame` and end just past
  425. // the Token_EndOfFrame of `endFrame` (i.e. file.slice(frameStartOffsets[start],
  426. // frameEndOffsets[end])).
  427. function parseRange(buffer, startFrame, endFrame) {
  428. if (!g_recording) throw new Error("No recording loaded for a range query.");
  429. var rec = g_recording;
  430. var dv = new DataView(buffer);
  431. var totalBytes = buffer.byteLength;
  432. var pos = 0;
  433. var decoder = new TextDecoder("utf-8");
  434. function u8() { return dv.getUint8(pos++); }
  435. function u16() { var v = dv.getUint16(pos, true); pos += 2; return v; }
  436. function u32() { var v = dv.getUint32(pos, true); pos += 4; return v; }
  437. function i32() { var v = dv.getInt32(pos, true); pos += 4; return v; }
  438. function cstr() {
  439. var start = pos;
  440. while (dv.getUint8(pos) !== 0) pos++;
  441. var s = decoder.decode(new Uint8Array(buffer, start, pos - start));
  442. pos++;
  443. return s;
  444. }
  445. var agg = [];
  446. function aggFor(id) {
  447. var a = agg[id];
  448. if (!a) { a = { calls: 0, us: 0, maxUs: 0, frames: 0 }; agg[id] = a; }
  449. return a;
  450. }
  451. function readTimings(nodeId, frameIndex) {
  452. var callsToken = u8();
  453. var calls;
  454. if (callsToken === 255) calls = u16(); else calls = callsToken;
  455. var timeUnits = u16();
  456. var us;
  457. if (timeUnits === 0xFFFF) us = u32() * 4; else us = timeUnits * 4;
  458. var a = aggFor(nodeId);
  459. a.calls += calls;
  460. a.us += us;
  461. if (us > a.maxUs) a.maxUs = us;
  462. a.frames++;
  463. // Only descend into children that existed at this frame; the final child
  464. // order already reflects the prepend-only evolution of the tree.
  465. var kids = rec.nodes[nodeId].children;
  466. for (var k = 0; k < kids.length; k++) {
  467. if (rec.nodesAddedFrame[kids[k]] <= frameIndex) readTimings(kids[k], frameIndex);
  468. }
  469. }
  470. var frameIndex = startFrame;
  471. while (pos < totalBytes) {
  472. var token = u8();
  473. if (token === TOKEN_FILE_FINISHED) break;
  474. if (token !== TOKEN_START_FRAME) {
  475. throw new Error("Range parse: expected start-of-frame at offset " + (pos - 1) + ", got " + token + ".");
  476. }
  477. i32(); // tick
  478. while (true) {
  479. var inner = u8();
  480. if (inner === TOKEN_END_OF_FRAME) break;
  481. if (inner === TOKEN_ADD_BUDGET_GROUP) {
  482. cstr(); i32();
  483. } else if (inner === TOKEN_ADD_NODE) {
  484. i32(); cstr(); i32(); i32();
  485. } else if (inner === TOKEN_TIMINGS) {
  486. readTimings(rec.rootNodeId, frameIndex);
  487. } else {
  488. throw new Error("Range parse: unexpected token " + inner + " at offset " + (pos - 1) + ".");
  489. }
  490. }
  491. frameIndex++;
  492. if (frameIndex > endFrame + 1) break;
  493. }
  494. return agg;
  495. }
  496. </script>
  497. <!-- ===================== UI ===================== -->
  498. <script>
  499. "use strict";
  500. /* ------------------------------- helpers ------------------------------- */
  501. function $(id) { return document.getElementById(id); }
  502. function esc(s) {
  503. return String(s).replace(/[&<>"']/g, function (c) {
  504. return ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c];
  505. });
  506. }
  507. function fmtUs(us) {
  508. if (!us) return "0";
  509. var ms = us / 1000;
  510. if (Math.abs(ms) >= 1000) return (ms / 1000).toFixed(3) + " s";
  511. if (Math.abs(ms) >= 1) return ms.toFixed(3) + " ms";
  512. return us.toFixed(1) + " \u00b5s";
  513. }
  514. function fmtInt(n) {
  515. return (n === undefined || n === null) ? "0" : Number(n).toLocaleString();
  516. }
  517. function fmtBytes(n) {
  518. if (n < 1024) return n + " B";
  519. if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KiB";
  520. if (n < 1024 * 1024 * 1024) return (n / 1048576).toFixed(1) + " MiB";
  521. return (n / 1073741824).toFixed(2) + " GiB";
  522. }
  523. function flagsDecoded(flags) {
  524. var out = [];
  525. if (flags & 1) out.push("CLIENT");
  526. if (flags & 2) out.push("SERVER");
  527. if (flags & 4) out.push("OTHER");
  528. if (flags & 32768) out.push("HIDDEN");
  529. return out;
  530. }
  531. /* ------------------------------- state ------------------------------- */
  532. var state = null;
  533. /* ------------------------------- loading ------------------------------- */
  534. var worker = null;
  535. var lastFile = null;
  536. function setError(msg) {
  537. var box = $("error-box");
  538. if (!msg) { box.classList.add("hidden"); box.textContent = ""; return; }
  539. box.classList.remove("hidden");
  540. box.textContent = msg;
  541. }
  542. function startParse(file, handle) {
  543. lastFile = file;
  544. if (handle) rememberHandle(handle);
  545. setError("");
  546. $("progress-wrap").classList.remove("hidden");
  547. $("progress-bar").style.width = "0%";
  548. $("status").textContent = "Reading " + file.name + " (" + fmtBytes(file.size) + ")...";
  549. file.arrayBuffer().then(function (buf) {
  550. if (worker) { worker.terminate(); worker = null; }
  551. var src = $("worker-src").textContent;
  552. var url = URL.createObjectURL(new Blob([src], { type: "text/javascript" }));
  553. worker = new Worker(url);
  554. worker.onmessage = onWorkerMessage;
  555. worker.onerror = function (e) {
  556. setError("Worker error: " + (e.message || "unknown"));
  557. };
  558. $("status").textContent = "Parsing...";
  559. worker.postMessage({ cmd: "parse", buffer: buf, opts: { maxSamples: 4000000 } }, [buf]);
  560. }).catch(function (err) {
  561. setError("Could not read file: " + err);
  562. });
  563. }
  564. function onWorkerMessage(e) {
  565. var msg = e.data;
  566. if (msg.type === "progress") {
  567. var pct = msg.total ? Math.floor(100 * msg.pos / msg.total) : 0;
  568. $("progress-bar").style.width = pct + "%";
  569. $("status").textContent = "Parsing... " + fmtInt(msg.frames) + " frames (" + pct + "%)";
  570. } else if (msg.type === "error") {
  571. setError("Parse error: " + msg.message);
  572. $("status").textContent = "Failed.";
  573. } else if (msg.type === "range") {
  574. var s = state;
  575. if (!s || msg.requestId !== s.requestId) return;
  576. s.agg = msg.aggregates;
  577. s.rangeCache.set(msg.startFrame + ":" + msg.endFrame, msg.aggregates);
  578. trimRangeCache(s.rangeCache);
  579. s.rangePending = false;
  580. renderAll();
  581. updateRangeStatus("Frames " + msg.startFrame + "\u2013" + msg.endFrame + " parsed in " + Math.round(msg.ms) + " ms");
  582. } else if (msg.type === "rangeError") {
  583. var s2 = state;
  584. if (!s2 || msg.requestId !== s2.requestId) return;
  585. s2.rangePending = false;
  586. renderAll();
  587. updateRangeStatus("Range parse failed: " + msg.message);
  588. } else if (msg.type === "done") {
  589. $("progress-bar").style.width = "100%";
  590. $("status").textContent = "Done.";
  591. $("progress-wrap").classList.add("hidden");
  592. buildState(msg.result, lastFile);
  593. renderAll();
  594. }
  595. }
  596. /* --------------------------- file system access --------------------------- */
  597. // Persist the FileSystemFileHandle (when the browser supports the File System
  598. // Access API) so a large recording can be reopened next time and its byte range
  599. // re-read on demand for granular frame queries. Falls back silently to the
  600. // plain <input type=file> File object, which is re-readable within a session.
  601. var HANDLE_DB = "vprof-viewer";
  602. var HANDLE_STORE = "handles";
  603. var HANDLE_KEY = "last";
  604. function idbOpen() {
  605. return new Promise(function (resolve, reject) {
  606. if (!window.indexedDB) return reject(new Error("IndexedDB unavailable"));
  607. var req = indexedDB.open(HANDLE_DB, 1);
  608. req.onupgradeneeded = function () {
  609. if (!req.result.objectStoreNames.contains(HANDLE_STORE)) req.result.createObjectStore(HANDLE_STORE);
  610. };
  611. req.onsuccess = function () { resolve(req.result); };
  612. req.onerror = function () { reject(req.error); };
  613. });
  614. }
  615. function idbSet(key, value) {
  616. return idbOpen().then(function (db) {
  617. return new Promise(function (resolve, reject) {
  618. var tx = db.transaction(HANDLE_STORE, "readwrite");
  619. tx.objectStore(HANDLE_STORE).put(value, key);
  620. tx.oncomplete = function () { resolve(); };
  621. tx.onerror = function () { reject(tx.error); };
  622. });
  623. });
  624. }
  625. function idbGet(key) {
  626. return idbOpen().then(function (db) {
  627. return new Promise(function (resolve, reject) {
  628. var req = db.transaction(HANDLE_STORE, "readonly").objectStore(HANDLE_STORE).get(key);
  629. req.onsuccess = function () { resolve(req.result); };
  630. req.onerror = function () { reject(req.error); };
  631. });
  632. });
  633. }
  634. function rememberHandle(handle) {
  635. if (!handle || !handle.getFile) return;
  636. handle.getFile().then(function (file) {
  637. idbSet(HANDLE_KEY, { handle: handle, name: file.name, size: file.size, lastModified: file.lastModified });
  638. }).catch(function () {});
  639. }
  640. function showReopenButton(entry) {
  641. var btn = $("reopen-last");
  642. btn.textContent = "Reopen " + entry.name;
  643. btn.classList.remove("hidden");
  644. btn.onclick = function () {
  645. var permission = entry.handle.queryPermission
  646. ? entry.handle.queryPermission({ mode: "read" })
  647. : Promise.resolve("granted");
  648. permission.then(function (status) {
  649. if (status !== "granted" && entry.handle.requestPermission) {
  650. return entry.handle.requestPermission({ mode: "read" });
  651. }
  652. return status;
  653. }).then(function (status) {
  654. if (status !== "granted") throw new Error("read permission denied");
  655. return entry.handle.getFile();
  656. }).then(function (file) {
  657. startParse(file, entry.handle);
  658. }).catch(function (err) {
  659. setError("Could not reopen recording: " + err);
  660. });
  661. };
  662. }
  663. /* ------------------------------- state build ------------------------------- */
  664. function buildState(result, file) {
  665. var nodeById = result.nodes;
  666. var nodes = [];
  667. for (var i = 0; i < nodeById.length; i++) if (nodeById[i]) nodes.push(nodeById[i]);
  668. var groupName = Object.create(null);
  669. for (var g = 0; g < result.budgetGroups.length; g++) groupName[result.budgetGroups[g].id] = result.budgetGroups[g].name;
  670. // Collapse everything except the root unless the tree is small.
  671. var collapsed = new Set();
  672. for (var k = 0; k < nodes.length; k++) collapsed.add(nodes[k].id);
  673. collapsed.delete(result.rootNodeId);
  674. if (nodes.length <= 200) {
  675. // small tree: expand fully
  676. collapsed.clear();
  677. }
  678. state = {
  679. r: result,
  680. file: file,
  681. nodeById: nodeById,
  682. nodes: nodes,
  683. groupName: groupName,
  684. maxId: nodeById.length,
  685. frameCount: result.frameCount,
  686. rangeStart: 0,
  687. rangeEnd: Math.max(0, result.frameCount - 1),
  688. viewStart: 0,
  689. viewEnd: Math.max(0, result.frameCount - 1),
  690. metricTime: result.anyTime,
  691. sortKey: result.anyTime ? "total" : "name",
  692. sortDir: result.anyTime ? -1 : 1,
  693. collapsed: collapsed,
  694. filter: "",
  695. agg: result.aggregates,
  696. self: new Float64Array(nodeById.length),
  697. rootTotal: 0,
  698. percentBase: 1,
  699. rangeCache: new Map(), // "start:end" -> aggregate (bounded)
  700. requestId: 0, // newest outstanding range request
  701. rangePending: false
  702. };
  703. updateRangeInputs();
  704. }
  705. /* ------------------------------- aggregation ------------------------------- */
  706. // Aggregate per-node timings from the per-frame samples retained for small
  707. // captures.
  708. function computeFromSamples(start, end) {
  709. var s = state;
  710. var agg = [];
  711. function get(id) {
  712. var a = agg[id];
  713. if (!a) { a = { calls: 0, us: 0, maxUs: 0, frames: 0 }; agg[id] = a; }
  714. return a;
  715. }
  716. var endClamped = Math.min(end, s.frameCount - 1);
  717. for (var f = Math.max(0, start); f <= endClamped; f++) {
  718. var fr = s.r.samples[f];
  719. if (!fr) continue;
  720. var ids = fr.ids, calls = fr.calls, us = fr.us;
  721. for (var i = 0; i < ids.length; i++) {
  722. var id = ids[i];
  723. var a = get(id);
  724. a.calls += calls[i];
  725. a.us += us[i];
  726. if (us[i] > a.maxUs) a.maxUs = us[i];
  727. a.frames++;
  728. }
  729. }
  730. return agg;
  731. }
  732. // Ranges can be served from retained samples or by re-parsing the original
  733. // file's byte range on demand (the frame offset index makes that seekable).
  734. function canQueryRanges() {
  735. var s = state;
  736. return !!(s && (s.r.samples || (s.file && s.r.frameStartOffsets && worker)));
  737. }
  738. function trimRangeCache(cache) {
  739. while (cache.size > 32) {
  740. var first = cache.keys().next().value;
  741. cache.delete(first);
  742. }
  743. }
  744. function updateRangeStatus(msg) {
  745. var el = $("range-status");
  746. if (el) el.textContent = msg || "";
  747. }
  748. // Compute state.agg for the current range: synchronously when cheap, otherwise
  749. // ask the worker to parse just the selected byte span.
  750. function refreshAggregate() {
  751. var s = state;
  752. var a = s.rangeStart, b = s.rangeEnd;
  753. if (a <= 0 && b >= s.frameCount - 1) {
  754. s.agg = s.r.aggregates;
  755. s.rangePending = false;
  756. renderAll();
  757. return;
  758. }
  759. var key = a + ":" + b;
  760. var cached = s.rangeCache.get(key);
  761. if (cached) {
  762. s.agg = cached;
  763. s.rangePending = false;
  764. renderAll();
  765. return;
  766. }
  767. if (s.r.samples) {
  768. s.agg = computeFromSamples(a, b);
  769. s.rangeCache.set(key, s.agg);
  770. trimRangeCache(s.rangeCache);
  771. s.rangePending = false;
  772. renderAll();
  773. return;
  774. }
  775. if (!s.file || !s.r.frameStartOffsets || !worker) {
  776. // No way to re-read the source; fall back to the whole-recording aggregate.
  777. s.agg = s.r.aggregates;
  778. s.rangePending = false;
  779. renderAll();
  780. return;
  781. }
  782. var requestId = ++s.requestId;
  783. s.rangePending = true;
  784. updateRangeStatus("Loading frames " + a + "\u2013" + b + "\u2026");
  785. drawTimeline();
  786. var startOff = s.r.frameStartOffsets[a];
  787. var endOff = s.r.frameEndOffsets[b];
  788. s.file.slice(startOff, endOff).arrayBuffer().then(function (buf) {
  789. if (!state || state.requestId !== requestId) return;
  790. worker.postMessage({ cmd: "query", buffer: buf, startFrame: a, endFrame: b, requestId: requestId }, [buf]);
  791. }).catch(function (err) {
  792. if (!state || state.requestId !== requestId) return;
  793. state.rangePending = false;
  794. updateRangeStatus("Range read failed: " + err);
  795. renderAll();
  796. });
  797. }
  798. function recompute() {
  799. var s = state;
  800. var agg = s.agg || s.r.aggregates;
  801. s.agg = agg;
  802. var self = new Float64Array(s.maxId);
  803. for (var id = 0; id < agg.length; id++) if (agg[id]) self[id] = agg[id].us;
  804. for (var id2 = 0; id2 < agg.length; id2++) {
  805. if (!agg[id2]) continue;
  806. var n = s.nodeById[id2];
  807. if (!n) continue;
  808. var kids = n.children;
  809. for (var c = 0; c < kids.length; c++) if (agg[kids[c]]) self[id2] -= agg[kids[c]].us;
  810. }
  811. s.self = self;
  812. var rootAgg = agg[s.r.rootNodeId];
  813. s.rootTotal = rootAgg ? rootAgg.us : 0;
  814. if (s.metricTime) {
  815. s.percentBase = s.rootTotal || 1;
  816. } else {
  817. s.percentBase = (rootAgg && rootAgg.calls) || 1;
  818. }
  819. }
  820. /* ------------------------------- rendering ------------------------------- */
  821. function renderAll() {
  822. recompute();
  823. renderSummary();
  824. drawTimeline();
  825. renderTree();
  826. renderBudgets();
  827. renderTop();
  828. ["summary-section", "timeline-section", "tree-section", "budget-section", "top-section"]
  829. .forEach(function (id) { $(id).classList.remove("hidden"); });
  830. // Range scoping works with retained samples or by re-parsing the source file
  831. // on demand, so only disable it when neither is available.
  832. var rangeDisabled = !canQueryRanges();
  833. ["range-start", "range-end", "range-apply", "frame-prev", "frame-next"].forEach(function (id) {
  834. $(id).disabled = rangeDisabled;
  835. });
  836. if (!state.rangePending) updateRangeStatus("");
  837. }
  838. function renderSummary() {
  839. var s = state, r = s.r;
  840. var notices = $("notices");
  841. notices.innerHTML = "";
  842. if (!r.anyTime) {
  843. var d = document.createElement("div");
  844. d.className = "warn";
  845. d.innerHTML = "<strong>No timing data in this recording.</strong> Every sampled " +
  846. "microsecond value is zero, so time/self/percentage columns are not meaningful. " +
  847. "This is common for captures made by the engine's snapshot path, which clears the " +
  848. "current frame's counters before writing them. The call tree, call counts and " +
  849. "structure below are still shown.";
  850. notices.appendChild(d);
  851. }
  852. if (r.samplesOverflow) {
  853. var d2 = document.createElement("div");
  854. d2.className = "notice";
  855. d2.textContent = "This recording is very large (" + fmtInt(r.totalSamples) +
  856. " samples), so per-frame samples were not kept in memory. Frame ranges are " +
  857. "still available: the viewer re-parses only the selected byte range from the " +
  858. "source file on demand.";
  859. notices.appendChild(d2);
  860. }
  861. var rows = [
  862. ["File", s.file ? s.file.name : "(unknown)"],
  863. ["Size", s.file ? fmtBytes(s.file.size) : "-"],
  864. ["Format version", r.version],
  865. ["Frames", fmtInt(r.frameCount)],
  866. ["Range", s.rangeStart + "\u2013" + s.rangeEnd + " (" + fmtInt(s.rangeEnd - s.rangeStart + 1) + " frames)"],
  867. ["Nodes", fmtInt(s.nodes.length)],
  868. ["Budget groups", fmtInt(r.budgetGroups.length)],
  869. ["Samples", fmtInt(r.totalSamples)],
  870. ["Total time", fmtUs(Math.round(s.rootTotal))],
  871. ["Parse time", Math.round(r.parseMs) + " ms"]
  872. ];
  873. $("summary").innerHTML = rows.map(function (row) {
  874. return '<div><div class="k">' + esc(row[0]) + '</div><div class="v">' + esc(row[1]) + "</div></div>";
  875. }).join("");
  876. }
  877. /* ---- timeline ---- */
  878. var drag = null;
  879. function useCallsForTimeline() {
  880. return !state.r.anyTime;
  881. }
  882. function timelineData() {
  883. return useCallsForTimeline() ? state.r.frameCalls : state.r.frameTotals;
  884. }
  885. function frameAt(clientX) {
  886. var s = state;
  887. var c = $("timeline");
  888. var rect = c.getBoundingClientRect();
  889. if (!rect.width) return s.viewStart;
  890. var x = clientX - rect.left;
  891. var count = s.viewEnd - s.viewStart + 1;
  892. var f = s.viewStart + Math.floor((x / rect.width) * count);
  893. if (f < s.viewStart) f = s.viewStart;
  894. if (f > s.viewEnd) f = s.viewEnd;
  895. if (f < 0) f = 0;
  896. if (f > s.frameCount - 1) f = s.frameCount - 1;
  897. return f;
  898. }
  899. function drawTimeline() {
  900. var c = $("timeline");
  901. var wrap = c.parentElement;
  902. var w = wrap.clientWidth || 600;
  903. var h = 120;
  904. var dpr = window.devicePixelRatio || 1;
  905. c.width = Math.round(w * dpr);
  906. c.height = Math.round(h * dpr);
  907. c.style.width = w + "px";
  908. c.style.height = h + "px";
  909. var ctx = c.getContext("2d");
  910. ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  911. ctx.clearRect(0, 0, w, h);
  912. var s = state;
  913. var data = timelineData();
  914. var viewStart = s.viewStart, viewEnd = s.viewEnd;
  915. var viewCount = Math.max(1, viewEnd - viewStart + 1);
  916. var max = 0;
  917. for (var i = viewStart; i <= viewEnd; i++) if (data[i] > max) max = data[i];
  918. var selA = s.rangeStart, selB = s.rangeEnd;
  919. if (drag && drag.active) {
  920. selA = Math.min(drag.start, drag.end);
  921. selB = Math.max(drag.start, drag.end);
  922. }
  923. var padTop = 4, padBottom = 16;
  924. var plotH = h - padTop - padBottom;
  925. var bw = w / viewCount;
  926. for (var f = viewStart; f <= viewEnd; f++) {
  927. var v = data[f] || 0;
  928. var bh = max ? (v / max) * plotH : 0;
  929. var x = (f - viewStart) * bw;
  930. var inSel = (f >= selA && f <= selB);
  931. ctx.fillStyle = inSel ? "#2b6cb0" : "#b9c6d6";
  932. ctx.fillRect(x, padTop + plotH - bh, Math.max(1, bw - 0.25), bh);
  933. }
  934. // selection edges (only when they fall inside the current view)
  935. ctx.strokeStyle = "#e0a800";
  936. ctx.lineWidth = 1;
  937. if (selA > viewStart) { var xa = (selA - viewStart) * bw; ctx.beginPath(); ctx.moveTo(xa, padTop); ctx.lineTo(xa, padTop + plotH); ctx.stroke(); }
  938. if (selB < viewEnd) { var xb = (selB - viewStart + 1) * bw; ctx.beginPath(); ctx.moveTo(xb, padTop); ctx.lineTo(xb, padTop + plotH); ctx.stroke(); }
  939. ctx.fillStyle = "#333";
  940. ctx.font = "11px system-ui, sans-serif";
  941. var unit = useCallsForTimeline() ? "calls" : "time";
  942. ctx.fillText("max " + (useCallsForTimeline() ? fmtInt(max) : fmtUs(max)) + " per frame (" + unit + ")", 4, 11);
  943. var firstLabel = "frame " + viewStart;
  944. ctx.fillText(firstLabel, 4, h - 5);
  945. var lastLabel = "frame " + viewEnd;
  946. ctx.fillText(lastLabel, Math.max(4, w - ctx.measureText(lastLabel).width - 4), h - 5);
  947. var hint = useCallsForTimeline()
  948. ? "Showing per-frame total call counts (no timing data available)."
  949. : "Showing per-frame total microseconds.";
  950. hint += " Drag to select a range, scroll to zoom, shift+drag to pan.";
  951. $("timeline-caption").textContent = hint;
  952. $("frame-label").textContent = "selected frames " + selA + "-" + selB;
  953. updateZoomLabel();
  954. }
  955. /* ---- timeline zoom / pan ---- */
  956. // setView clamps a requested frame window to the recording and stores it.
  957. function setView(start, end) {
  958. var s = state;
  959. var total = s.frameCount;
  960. if (total < 1) { s.viewStart = 0; s.viewEnd = 0; return; }
  961. var count = end - start + 1;
  962. if (count > total) count = total;
  963. if (count < 1) count = 1;
  964. if (start < 0) start = 0;
  965. if (start + count - 1 > total - 1) start = total - count;
  966. s.viewStart = start;
  967. s.viewEnd = start + count - 1;
  968. }
  969. // factor > 1 zooms in (fewer visible frames); centerFrame keeps the frame
  970. // under the cursor fixed when given.
  971. function zoomView(factor, centerFrame) {
  972. var s = state;
  973. var count = s.viewEnd - s.viewStart + 1;
  974. var newCount = Math.round(count / factor);
  975. if (newCount < 1) newCount = 1;
  976. if (newCount > s.frameCount) newCount = s.frameCount;
  977. if (newCount === count) return;
  978. var anchor = (typeof centerFrame === "number") ? centerFrame : (s.viewStart + s.viewEnd) / 2;
  979. var rel = count > 1 ? (anchor - s.viewStart) / (count - 1) : 0.5;
  980. var newStart = Math.round(anchor - rel * (newCount - 1));
  981. setView(newStart, newStart + newCount - 1);
  982. drawTimeline();
  983. }
  984. function panView(deltaFrames) {
  985. var s = state;
  986. var count = s.viewEnd - s.viewStart + 1;
  987. if (count >= s.frameCount) return;
  988. setView(s.viewStart + deltaFrames, s.viewEnd + deltaFrames);
  989. drawTimeline();
  990. }
  991. // If a selection moves completely outside the current view, scroll to it.
  992. function revealSelection(a, b) {
  993. var s = state;
  994. if (b >= s.viewStart && a <= s.viewEnd) return;
  995. var count = s.viewEnd - s.viewStart + 1;
  996. var start = a;
  997. if (start + count - 1 > s.frameCount - 1) start = s.frameCount - count;
  998. setView(start, start + count - 1);
  999. }
  1000. function updateZoomLabel() {
  1001. var el = $("zoom-label");
  1002. if (!el || !state) return;
  1003. var s = state;
  1004. var full = s.viewStart <= 0 && s.viewEnd >= s.frameCount - 1;
  1005. el.textContent = full
  1006. ? ""
  1007. : "viewing frames " + s.viewStart + "\u2013" + s.viewEnd + " of " + fmtInt(s.frameCount);
  1008. }
  1009. function applyRange(a, b) {
  1010. var s = state;
  1011. a = Math.max(0, Math.min(a, s.frameCount - 1));
  1012. b = Math.max(0, Math.min(b, s.frameCount - 1));
  1013. if (a > b) { var t = a; a = b; b = t; }
  1014. s.rangeStart = a; s.rangeEnd = b;
  1015. updateRangeInputs();
  1016. revealSelection(a, b);
  1017. refreshAggregate();
  1018. }
  1019. function updateRangeInputs() {
  1020. $("range-start").value = state.rangeStart;
  1021. $("range-end").value = state.rangeEnd;
  1022. }
  1023. /* ---- tree ---- */
  1024. function compareIds(aId, bId) {
  1025. var s = state;
  1026. if (s.sortKey === "name") {
  1027. return s.nodeById[aId].name.localeCompare(s.nodeById[bId].name) * s.sortDir;
  1028. }
  1029. var a = s.agg[aId] || { calls: 0, us: 0, maxUs: 0, frames: 0 };
  1030. var b = s.agg[bId] || { calls: 0, us: 0, maxUs: 0, frames: 0 };
  1031. var av, bv;
  1032. switch (s.sortKey) {
  1033. case "calls": av = a.calls; bv = b.calls; break;
  1034. case "self": av = s.self[aId] || 0; bv = s.self[bId] || 0; break;
  1035. case "avg": av = a.frames ? a.us / a.frames : 0; bv = b.frames ? b.us / b.frames : 0; break;
  1036. case "max": av = a.maxUs; bv = b.maxUs; break;
  1037. default: av = a.us; bv = b.us;
  1038. }
  1039. if (av === bv) return s.nodeById[aId].name.localeCompare(s.nodeById[bId].name);
  1040. return (av < bv ? -1 : 1) * s.sortDir;
  1041. }
  1042. function computeMatchSet(text) {
  1043. if (!text) return null;
  1044. var s = state;
  1045. var t = text.toLowerCase();
  1046. var set = new Set();
  1047. for (var i = 0; i < s.nodes.length; i++) {
  1048. var n = s.nodes[i];
  1049. if (n.name.toLowerCase().indexOf(t) !== -1) {
  1050. set.add(n.id);
  1051. var p = n.parentId;
  1052. while (p !== null && p !== undefined) { set.add(p); p = s.nodeById[p].parentId; }
  1053. }
  1054. }
  1055. return set;
  1056. }
  1057. function renderTree() {
  1058. var s = state;
  1059. var matchSet = computeMatchSet(s.filter);
  1060. var orderCache = Object.create(null);
  1061. function order(id) {
  1062. var c = orderCache[id];
  1063. if (c) return c;
  1064. c = s.nodeById[id].children.slice();
  1065. c.sort(compareIds);
  1066. orderCache[id] = c;
  1067. return c;
  1068. }
  1069. var rows = [];
  1070. function visit(id, depth) {
  1071. var n = s.nodeById[id];
  1072. if (!n) return;
  1073. if (matchSet && !matchSet.has(id)) return;
  1074. var a = s.agg[id] || { calls: 0, us: 0, maxUs: 0, frames: 0 };
  1075. var totalUs = a.us;
  1076. var selfUs = s.self[id] || 0;
  1077. var pct = s.percentBase ? ((s.metricTime ? totalUs : a.calls) / s.percentBase) * 100 : 0;
  1078. rows.push({ id: id, depth: depth, n: n, a: a, totalUs: totalUs, selfUs: selfUs, pct: pct });
  1079. var collapsed = !matchSet && s.collapsed.has(id);
  1080. if (!collapsed) { order(id).forEach(function (c) { visit(c, depth + 1); }); }
  1081. }
  1082. visit(s.r.rootNodeId, 0);
  1083. // Header
  1084. var cols = [
  1085. { key: "name", label: "Scope", num: false },
  1086. { key: "calls", label: "Calls", num: true },
  1087. { key: "total", label: "Total", num: true },
  1088. { key: "self", label: "Self", num: true },
  1089. { key: "avg", label: "Avg", num: true },
  1090. { key: "max", label: "Max", num: true },
  1091. { key: "_pct", label: "%", num: true }
  1092. ];
  1093. $("tree-head").innerHTML = cols.map(function (c) {
  1094. var sortable = c.key !== "_pct";
  1095. var marker = "";
  1096. if (sortable && s.sortKey === c.key) marker = s.sortDir < 0 ? " \u25bc" : " \u25b2";
  1097. return '<th class="' + (sortable ? "" : "nosort") + (c.num ? " num" : "") +
  1098. '" data-key="' + (sortable ? c.key : "") + '">' + esc(c.label) + marker + "</th>";
  1099. }).join("");
  1100. Array.prototype.forEach.call($("tree-head").children, function (th) {
  1101. var key = th.getAttribute("data-key");
  1102. if (!key) return;
  1103. th.onclick = function () {
  1104. if (state.sortKey === key) state.sortDir = -state.sortDir;
  1105. else { state.sortKey = key; state.sortDir = key === "name" ? 1 : -1; }
  1106. renderTree();
  1107. };
  1108. });
  1109. var maxMetric = 0;
  1110. rows.forEach(function (r) { maxMetric = Math.max(maxMetric, s.metricTime ? r.totalUs : r.a.calls); });
  1111. var html = rows.map(function (r) {
  1112. var hasKids = r.n.children.length > 0;
  1113. var collapsed = !matchSet && s.collapsed.has(r.id);
  1114. var toggle = hasKids ? (collapsed ? "\u25b6" : "\u25bc") : "\u00a0";
  1115. var indent = "padding-left:" + (4 + r.depth * 14) + "px";
  1116. var metric = s.metricTime ? r.totalUs : r.a.calls;
  1117. var barW = maxMetric ? Math.round(60 * metric / maxMetric) : 0;
  1118. var avg = r.a.frames ? r.a.us / r.a.frames : 0;
  1119. var groupName = s.groupName[r.n.budgetGroupId] || ("group " + r.n.budgetGroupId);
  1120. return '<tr data-id="' + r.id + '">' +
  1121. '<td class="tree-name" style="' + indent + '"><span class="toggle">' + toggle + "</span>" +
  1122. esc(r.n.name) + ' <span class="muted">[' + esc(groupName) + "]</span>" +
  1123. '<div class="bar" style="width:' + barW + 'px"></div></td>' +
  1124. '<td class="num">' + fmtInt(r.a.calls) + "</td>" +
  1125. '<td class="num">' + (s.metricTime ? fmtUs(r.totalUs) : "-") + "</td>" +
  1126. '<td class="num">' + (s.metricTime ? fmtUs(r.selfUs) : "-") + "</td>" +
  1127. '<td class="num">' + (s.metricTime && r.a.frames ? fmtUs(avg) : "-") + "</td>" +
  1128. '<td class="num">' + (s.metricTime ? fmtUs(r.a.maxUs) : "-") + "</td>" +
  1129. '<td class="num">' + (s.metricTime ? r.pct.toFixed(2) + "%" : "-") + "</td>" +
  1130. "</tr>";
  1131. }).join("");
  1132. $("tree-body").innerHTML = html;
  1133. $("tree-count").textContent = rows.length + " rows shown";
  1134. Array.prototype.forEach.call($("tree-body").querySelectorAll("tr"), function (tr) {
  1135. tr.onclick = function (ev) {
  1136. var id = Number(tr.getAttribute("data-id"));
  1137. var n = s.nodeById[id];
  1138. if (!n || !n.children.length) return;
  1139. if (s.collapsed.has(id)) s.collapsed.delete(id); else s.collapsed.add(id);
  1140. renderTree();
  1141. };
  1142. });
  1143. }
  1144. /* ---- budget groups ---- */
  1145. function renderBudgets() {
  1146. var s = state;
  1147. var perGroup = Object.create(null);
  1148. for (var id = 0; id < s.agg.length; id++) {
  1149. var a = s.agg[id];
  1150. if (!a) continue;
  1151. var n = s.nodeById[id];
  1152. if (!n) continue;
  1153. var gid = n.budgetGroupId;
  1154. var e = perGroup[gid];
  1155. if (!e) e = perGroup[gid] = { us: 0, calls: 0 };
  1156. e.us += a.us; e.calls += a.calls;
  1157. }
  1158. var rows = [];
  1159. for (var gi = 0; gi < s.r.budgetGroups.length; gi++) {
  1160. var g = s.r.budgetGroups[gi];
  1161. var e2 = perGroup[g.id];
  1162. if (!e2 && !g.isDefault) continue;
  1163. rows.push({ id: g.id, name: g.name, us: e2 ? e2.us : 0, calls: e2 ? e2.calls : 0 });
  1164. }
  1165. var metric = s.metricTime ? "us" : "calls";
  1166. rows.sort(function (a, b) { return b[metric] - a[metric]; });
  1167. $("budget-body").innerHTML = rows.map(function (r) {
  1168. var pct = s.percentBase ? (r[metric] / s.percentBase) * 100 : 0;
  1169. return "<tr><td>" + esc(r.name) + "</td><td class='num'>" + fmtInt(r.calls) + "</td>" +
  1170. "<td class='num'>" + (s.metricTime ? fmtUs(r.us) : "-") + "</td>" +
  1171. "<td class='num'>" + (s.metricTime ? pct.toFixed(2) + "%" : "-") + "</td></tr>";
  1172. }).join("");
  1173. }
  1174. /* ---- top nodes ---- */
  1175. function renderTop() {
  1176. var s = state;
  1177. var rows = [];
  1178. for (var id = 0; id < s.agg.length; id++) {
  1179. var a = s.agg[id];
  1180. if (!a) continue;
  1181. var n = s.nodeById[id];
  1182. if (!n) continue;
  1183. rows.push({ id: id, name: n.name, us: a.us, self: s.self[id] || 0, calls: a.calls });
  1184. }
  1185. if (s.metricTime) rows.sort(function (a, b) { return b.us - a.us; });
  1186. else rows.sort(function (a, b) { return b.calls - a.calls; });
  1187. rows = rows.slice(0, 100);
  1188. $("top-body").innerHTML = rows.map(function (r) {
  1189. return "<tr><td>" + esc(r.name) + "</td>" +
  1190. "<td class='num'>" + (s.metricTime ? fmtUs(r.us) : "-") + "</td>" +
  1191. "<td class='num'>" + (s.metricTime ? fmtUs(r.self) : "-") + "</td>" +
  1192. "<td class='num'>" + fmtInt(r.calls) + "</td></tr>";
  1193. }).join("");
  1194. }
  1195. /* ------------------------------- events ------------------------------- */
  1196. $("file").addEventListener("change", function (e) {
  1197. var f = e.target.files && e.target.files[0];
  1198. if (f) startParse(f);
  1199. });
  1200. document.addEventListener("dragover", function (e) { e.preventDefault(); });
  1201. document.addEventListener("drop", function (e) {
  1202. e.preventDefault();
  1203. var f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
  1204. if (f) startParse(f);
  1205. });
  1206. // File System Access API: use it when available so the source handle can be
  1207. // persisted and reused for on-demand range reads.
  1208. (function initFileSystemAccess() {
  1209. if (window.showOpenFilePicker) {
  1210. var picker = $("open-picker");
  1211. picker.classList.remove("hidden");
  1212. picker.onclick = function () {
  1213. window.showOpenFilePicker({
  1214. multiple: false,
  1215. types: [{ description: "VProf recording", accept: { "application/octet-stream": [".vprof"] } }]
  1216. }).then(function (handles) {
  1217. return handles[0].getFile().then(function (file) { startParse(file, handles[0]); });
  1218. }).catch(function (err) {
  1219. if (err && err.name === "AbortError") return;
  1220. setError("Could not open file: " + err);
  1221. });
  1222. };
  1223. }
  1224. idbGet(HANDLE_KEY).then(function (entry) {
  1225. if (entry && entry.handle) showReopenButton(entry);
  1226. }).catch(function () {});
  1227. })();
  1228. $("range-all").onclick = function () { applyRange(0, state.frameCount - 1); };
  1229. $("range-apply").onclick = function () {
  1230. applyRange(Number($("range-start").value), Number($("range-end").value));
  1231. };
  1232. $("frame-prev").onclick = function () {
  1233. var f = Math.max(0, state.rangeStart - 1); applyRange(f, f);
  1234. };
  1235. $("frame-next").onclick = function () {
  1236. var f = Math.min(state.frameCount - 1, state.rangeEnd + 1); applyRange(f, f);
  1237. };
  1238. $("expand-all").onclick = function () { state.collapsed.clear(); renderTree(); };
  1239. $("collapse-all").onclick = function () {
  1240. state.collapsed.clear();
  1241. for (var i = 0; i < state.nodes.length; i++) state.collapsed.add(state.nodes[i].id);
  1242. state.collapsed.delete(state.r.rootNodeId);
  1243. renderTree();
  1244. };
  1245. var filterTimer = null;
  1246. $("filter").addEventListener("input", function (e) {
  1247. clearTimeout(filterTimer);
  1248. filterTimer = setTimeout(function () { state.filter = e.target.value.trim(); renderTree(); }, 120);
  1249. });
  1250. /* timeline interaction: drag selects, shift+drag pans, wheel zooms */
  1251. (function () {
  1252. var c = $("timeline");
  1253. var pan = null;
  1254. c.addEventListener("mousedown", function (e) {
  1255. if (!state) return;
  1256. e.preventDefault();
  1257. if (e.shiftKey) {
  1258. pan = { startX: e.clientX, startView: state.viewStart, count: state.viewEnd - state.viewStart + 1 };
  1259. return;
  1260. }
  1261. if (!canQueryRanges()) return;
  1262. drag = { active: true, start: frameAt(e.clientX), end: frameAt(e.clientX) };
  1263. drawTimeline();
  1264. });
  1265. window.addEventListener("mousemove", function (e) {
  1266. if (pan) {
  1267. var rect = c.getBoundingClientRect();
  1268. if (!rect.width) return;
  1269. var delta = Math.round((e.clientX - pan.startX) / rect.width * pan.count);
  1270. setView(pan.startView - delta, pan.startView - delta + pan.count - 1);
  1271. drawTimeline();
  1272. return;
  1273. }
  1274. if (!drag || !drag.active) return;
  1275. drag.end = frameAt(e.clientX);
  1276. drawTimeline();
  1277. });
  1278. window.addEventListener("mouseup", function (e) {
  1279. if (pan) { pan = null; return; }
  1280. if (!drag || !drag.active) return;
  1281. drag.end = frameAt(e.clientX);
  1282. var a = drag.start, b = drag.end;
  1283. drag = null;
  1284. applyRange(a, b);
  1285. });
  1286. c.addEventListener("wheel", function (e) {
  1287. if (!state) return;
  1288. e.preventDefault();
  1289. if (e.shiftKey) {
  1290. var count = state.viewEnd - state.viewStart + 1;
  1291. var rect = c.getBoundingClientRect();
  1292. var step = Math.max(1, Math.round(((e.deltaY || e.deltaX) / (rect.height || 1)) * count));
  1293. panView(e.deltaY > 0 ? step : -step);
  1294. return;
  1295. }
  1296. zoomView(e.deltaY < 0 ? 1.3 : 1 / 1.3, frameAt(e.clientX));
  1297. }, { passive: false });
  1298. window.addEventListener("resize", function () { if (state) drawTimeline(); });
  1299. })();
  1300. $("zoom-in").onclick = function () { if (state) zoomView(1.5); };
  1301. $("zoom-out").onclick = function () { if (state) zoomView(1 / 1.5); };
  1302. $("zoom-selection").onclick = function () {
  1303. if (!state) return;
  1304. setView(state.rangeStart, state.rangeEnd);
  1305. drawTimeline();
  1306. };
  1307. $("zoom-reset").onclick = function () {
  1308. if (!state) return;
  1309. setView(0, state.frameCount - 1);
  1310. drawTimeline();
  1311. };
  1312. </script>
  1313. </body>
  1314. </html>