index.html 34 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993
  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. <span id="drop-hint" class="muted">or drag &amp; drop it onto this page</span>
  56. <div id="progress-wrap" class="hidden">
  57. <div class="progress"><div id="progress-bar"></div></div>
  58. <span id="status" class="muted"></span>
  59. </div>
  60. </div>
  61. </header>
  62. <main>
  63. <section id="error-box" class="warn hidden"></section>
  64. <section id="summary-section" class="hidden">
  65. <h2>Recording summary</h2>
  66. <div id="notices"></div>
  67. <div id="summary" class="summary-grid"></div>
  68. </section>
  69. <section id="timeline-section" class="hidden">
  70. <h2>Frame timeline</h2>
  71. <div class="controls">
  72. <button id="range-all">All frames</button>
  73. <label>From frame <input type="number" id="range-start" min="0" step="1"></label>
  74. <label>to <input type="number" id="range-end" min="0" step="1"></label>
  75. <button id="range-apply">Apply range</button>
  76. <button id="frame-prev">&#9664; prev</button>
  77. <button id="frame-next">next &#9654;</button>
  78. <span id="frame-label" class="muted"></span>
  79. </div>
  80. <div id="timeline-wrap"><canvas id="timeline"></canvas></div>
  81. <div id="timeline-caption" class="muted"></div>
  82. </section>
  83. <div class="grid2">
  84. <section id="tree-section" class="hidden">
  85. <h2>Call tree</h2>
  86. <div class="controls">
  87. <label>Filter <input type="search" id="filter" placeholder="name contains..."></label>
  88. <button id="expand-all">Expand all</button>
  89. <button id="collapse-all">Collapse all</button>
  90. <span id="tree-count" class="muted"></span>
  91. </div>
  92. <div class="scroll">
  93. <table id="tree-table">
  94. <thead><tr id="tree-head"></tr></thead>
  95. <tbody id="tree-body"></tbody>
  96. </table>
  97. </div>
  98. </section>
  99. <div>
  100. <section id="budget-section" class="hidden">
  101. <h2>Budget groups</h2>
  102. <div class="scroll">
  103. <table id="budget-table">
  104. <thead><tr><th class="nosort">Group</th><th class="nosort num">Calls</th>
  105. <th class="nosort num">Total</th><th class="nosort num">%</th></tr></thead>
  106. <tbody id="budget-body"></tbody>
  107. </table>
  108. </div>
  109. </section>
  110. <section id="top-section" class="hidden">
  111. <h2>Top nodes</h2>
  112. <div class="scroll">
  113. <table id="top-table">
  114. <thead><tr><th class="nosort">Scope</th><th class="nosort num">Total</th>
  115. <th class="nosort num">Self</th><th class="nosort num">Calls</th></tr></thead>
  116. <tbody id="top-body"></tbody>
  117. </table>
  118. </div>
  119. </section>
  120. </div>
  121. </div>
  122. </main>
  123. <footer>
  124. VProf binary format documentation: see <code>VPROF_FORMAT.md</code> in the repository.
  125. </footer>
  126. <!-- ===================== Parsing worker (inline, self-contained) ===================== -->
  127. <script id="worker-src" type="text/js-worker">
  128. "use strict";
  129. // The 31 budget groups registered by CVProfile::CVProfile(), in fixed ID order.
  130. var DEFAULT_BUDGET_GROUPS = [
  131. ["Unaccounted", 6],
  132. ["World Rendering", 1],
  133. ["Displacement_Rendering", 1],
  134. ["Game", 6],
  135. ["Player", 6],
  136. ["NPCs", 6],
  137. ["Server Animation", 6],
  138. ["Client_Animation", 1],
  139. ["Physics", 6],
  140. ["Static_Prop_Rendering", 1],
  141. ["Other_Model_Rendering", 1],
  142. ["Fast Path Model Rendering", 1],
  143. ["Light_Cache", 1],
  144. ["Brush_Model_Rendering", 1],
  145. ["Shadow_Rendering", 1],
  146. ["Detail_Prop_Rendering", 1],
  147. ["Particle/Effect_Rendering", 1],
  148. ["Ropes", 1],
  149. ["Dynamic_Light_Rendering", 1],
  150. ["Networking", 6],
  151. ["Sound", 6],
  152. ["VGUI", 6],
  153. ["FileSystem", 6],
  154. ["Prediction", 1],
  155. ["Interpolation", 1],
  156. ["Swap_Buffers", 1],
  157. ["Occlusion", 1],
  158. ["Overlays", 1],
  159. ["Tools", 5],
  160. ["Texture_Cache", 1],
  161. ["Replay", 2]
  162. ];
  163. var TOKEN_START_FRAME = 0;
  164. var TOKEN_ADD_NODE = 1;
  165. var TOKEN_ADD_BUDGET_GROUP = 2;
  166. var TOKEN_TIMINGS = 3;
  167. var TOKEN_END_OF_FRAME = 4;
  168. var TOKEN_FILE_FINISHED = 5;
  169. self.onmessage = function (e) {
  170. var msg = e.data;
  171. if (!msg || msg.cmd !== "parse") return;
  172. try {
  173. var out = parseVprof(msg.buffer, msg.opts || {});
  174. self.postMessage({ type: "done", result: out.result }, out.transfer);
  175. } catch (err) {
  176. self.postMessage({ type: "error", message: (err && err.message) || String(err) });
  177. }
  178. };
  179. function parseVprof(buffer, opts) {
  180. var t0 = (self.performance && self.performance.now) ? self.performance.now() : Date.now();
  181. var dv = new DataView(buffer);
  182. var totalBytes = buffer.byteLength;
  183. var pos = 0;
  184. var decoder = new TextDecoder("utf-8");
  185. function u8() { return dv.getUint8(pos++); }
  186. function u16() { var v = dv.getUint16(pos, true); pos += 2; return v; }
  187. function u32() { var v = dv.getUint32(pos, true); pos += 4; return v; }
  188. function i32() { var v = dv.getInt32(pos, true); pos += 4; return v; }
  189. function cstr() {
  190. var start = pos;
  191. while (dv.getUint8(pos) !== 0) pos++;
  192. var s = decoder.decode(new Uint8Array(buffer, start, pos - start));
  193. pos++;
  194. return s;
  195. }
  196. if (totalBytes < 8) throw new Error("File is too small to be a .vprof recording.");
  197. var version = i32();
  198. var rootNodeId = i32();
  199. // ---- Budget groups (seed the 31 defaults so sparse recordings still resolve) ----
  200. var budgetGroups = [];
  201. var groupIdByName = Object.create(null);
  202. for (var i = 0; i < DEFAULT_BUDGET_GROUPS.length; i++) {
  203. var g = DEFAULT_BUDGET_GROUPS[i];
  204. budgetGroups.push({ id: i, name: g[0], flags: g[1], isDefault: true });
  205. groupIdByName[g[0]] = i;
  206. }
  207. function addBudgetGroup(name, flags) {
  208. var id = groupIdByName[name];
  209. if (id !== undefined) { budgetGroups[id].flags = flags; return id; }
  210. id = budgetGroups.length;
  211. budgetGroups.push({ id: id, name: name, flags: flags, isDefault: false });
  212. groupIdByName[name] = id;
  213. return id;
  214. }
  215. // ---- Nodes (sparse array indexed by unique node id) ----
  216. var nodesById = [];
  217. function makeNode(id, name, parentId, budgetGroupId) {
  218. var n = { id: id, name: name, parentId: parentId, budgetGroupId: budgetGroupId, children: [] };
  219. nodesById[id] = n;
  220. return n;
  221. }
  222. makeNode(rootNodeId, "Root", null, 0);
  223. // ---- Aggregates (sparse array indexed by unique node id) ----
  224. var agg = [];
  225. function aggFor(id) {
  226. var a = agg[id];
  227. if (!a) { a = { calls: 0, us: 0, maxUs: 0, frames: 0 }; agg[id] = a; }
  228. return a;
  229. }
  230. // ---- Per-frame storage budget ----
  231. var maxSamples = (opts.maxSamples && opts.maxSamples > 0) ? opts.maxSamples : 4000000;
  232. var allowSamples = opts.noSamples !== true;
  233. var storing = allowSamples;
  234. var overflow = false;
  235. var totalSamples = 0;
  236. var samples = [];
  237. var frameTicks = [];
  238. var frameTotals = [];
  239. var frameCalls = [];
  240. var addedNodesByFrame = [];
  241. var anyTime = false;
  242. var anyCalls = false;
  243. // Outer variables reused by readTimings (avoids allocating a closure per frame)
  244. var curSamples = null;
  245. var frameTotalUs = 0;
  246. var frameTotalCalls = 0;
  247. function readTimings(nodeId) {
  248. var callsToken = u8();
  249. var calls;
  250. if (callsToken === 255) calls = u16(); else calls = callsToken;
  251. var timeUnits = u16();
  252. var us;
  253. if (timeUnits === 0xFFFF) us = u32() * 4; else us = timeUnits * 4;
  254. if (calls > 0) anyCalls = true;
  255. if (us > 0) anyTime = true;
  256. totalSamples++;
  257. if (storing) {
  258. curSamples.ids.push(nodeId);
  259. curSamples.calls.push(calls);
  260. curSamples.us.push(us);
  261. if (totalSamples > maxSamples) {
  262. // Too large to keep every frame; fall back to whole-recording aggregate.
  263. storing = false;
  264. overflow = true;
  265. samples.length = 0;
  266. curSamples = null;
  267. }
  268. }
  269. frameTotalUs += us;
  270. frameTotalCalls += calls;
  271. var a = aggFor(nodeId);
  272. a.calls += calls;
  273. a.us += us;
  274. if (us > a.maxUs) a.maxUs = us;
  275. a.frames++;
  276. var kids = nodesById[nodeId].children;
  277. for (var k = 0; k < kids.length; k++) readTimings(kids[k]);
  278. }
  279. var frameCount = 0;
  280. var progressAt = 0;
  281. var totalFramesApprox = -1;
  282. while (pos < totalBytes) {
  283. var token = u8();
  284. if (token === TOKEN_FILE_FINISHED) break;
  285. if (token !== TOKEN_START_FRAME) {
  286. throw new Error("Expected start-of-frame token at offset " + (pos - 1) + ", got " + token + ".");
  287. }
  288. var tick = i32();
  289. frameTicks.push(tick);
  290. frameTotalUs = 0;
  291. frameTotalCalls = 0;
  292. curSamples = storing ? { ids: [], calls: [], us: [] } : null;
  293. var added = [];
  294. while (true) {
  295. var inner = u8();
  296. if (inner === TOKEN_END_OF_FRAME) break;
  297. if (inner === TOKEN_ADD_BUDGET_GROUP) {
  298. var bgName = cstr();
  299. var bgFlags = i32();
  300. addBudgetGroup(bgName, bgFlags);
  301. } else if (inner === TOKEN_ADD_NODE) {
  302. var parentId = i32();
  303. var nodeName = cstr();
  304. var bgId = i32();
  305. var nodeId = i32();
  306. var parent = nodesById[parentId];
  307. if (!parent) throw new Error("AddNode references unknown parent " + parentId + " at offset " + (pos - 4) + ".");
  308. if (nodesById[nodeId]) throw new Error("AddNode re-declares node " + nodeId + " at offset " + (pos - 4) + ".");
  309. makeNode(nodeId, nodeName, parentId, bgId);
  310. parent.children.unshift(nodeId); // CVProfNode::GetSubNode() prepends
  311. added.push(nodeId);
  312. } else if (inner === TOKEN_TIMINGS) {
  313. readTimings(rootNodeId);
  314. } else {
  315. throw new Error("Unexpected token " + inner + " inside frame " + frameCount + " at offset " + (pos - 1) + ".");
  316. }
  317. }
  318. if (storing && curSamples) samples.push(curSamples);
  319. frameTotals.push(frameTotalUs);
  320. frameCalls.push(frameTotalCalls);
  321. addedNodesByFrame.push(added);
  322. frameCount++;
  323. if (frameCount - progressAt >= 500) {
  324. progressAt = frameCount;
  325. self.postMessage({ type: "progress", frames: frameCount, pos: pos, total: totalBytes });
  326. }
  327. }
  328. // ---- Compact and collect transferables ----
  329. var transfer = [];
  330. var frameTicksOut = Int32Array.from(frameTicks);
  331. var frameTotalsOut = Float64Array.from(frameTotals);
  332. var frameCallsOut = Uint32Array.from(frameCalls);
  333. transfer.push(frameTicksOut.buffer, frameTotalsOut.buffer, frameCallsOut.buffer);
  334. var addedOut = [];
  335. for (var f = 0; f < addedNodesByFrame.length; f++) {
  336. var arr = Int32Array.from(addedNodesByFrame[f]);
  337. transfer.push(arr.buffer);
  338. addedOut.push(arr);
  339. }
  340. var samplesOut = null;
  341. if (storing) {
  342. samplesOut = [];
  343. for (var s = 0; s < samples.length; s++) {
  344. var fr = samples[s];
  345. var ids = Int32Array.from(fr.ids);
  346. var callsArr = Uint32Array.from(fr.calls);
  347. var usArr = Float64Array.from(fr.us);
  348. transfer.push(ids.buffer, callsArr.buffer, usArr.buffer);
  349. samplesOut.push({ ids: ids, calls: callsArr, us: usArr });
  350. }
  351. }
  352. var t1 = (self.performance && self.performance.now) ? self.performance.now() : Date.now();
  353. return {
  354. result: {
  355. version: version,
  356. rootNodeId: rootNodeId,
  357. budgetGroups: budgetGroups,
  358. nodes: nodesById,
  359. aggregates: agg,
  360. frameCount: frameCount,
  361. frameTicks: frameTicksOut,
  362. frameTotals: frameTotalsOut,
  363. frameCalls: frameCallsOut,
  364. addedNodesByFrame: addedOut,
  365. samples: samplesOut,
  366. samplesStored: storing,
  367. samplesOverflow: overflow,
  368. totalSamples: totalSamples,
  369. anyTime: anyTime,
  370. anyCalls: anyCalls,
  371. parseMs: t1 - t0
  372. },
  373. transfer: transfer
  374. };
  375. }
  376. </script>
  377. <!-- ===================== UI ===================== -->
  378. <script>
  379. "use strict";
  380. /* ------------------------------- helpers ------------------------------- */
  381. function $(id) { return document.getElementById(id); }
  382. function esc(s) {
  383. return String(s).replace(/[&<>"']/g, function (c) {
  384. return ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c];
  385. });
  386. }
  387. function fmtUs(us) {
  388. if (!us) return "0";
  389. var ms = us / 1000;
  390. if (Math.abs(ms) >= 1000) return (ms / 1000).toFixed(3) + " s";
  391. if (Math.abs(ms) >= 1) return ms.toFixed(3) + " ms";
  392. return us.toFixed(1) + " \u00b5s";
  393. }
  394. function fmtInt(n) {
  395. return (n === undefined || n === null) ? "0" : Number(n).toLocaleString();
  396. }
  397. function fmtBytes(n) {
  398. if (n < 1024) return n + " B";
  399. if (n < 1024 * 1024) return (n / 1024).toFixed(1) + " KiB";
  400. if (n < 1024 * 1024 * 1024) return (n / 1048576).toFixed(1) + " MiB";
  401. return (n / 1073741824).toFixed(2) + " GiB";
  402. }
  403. function flagsDecoded(flags) {
  404. var out = [];
  405. if (flags & 1) out.push("CLIENT");
  406. if (flags & 2) out.push("SERVER");
  407. if (flags & 4) out.push("OTHER");
  408. if (flags & 32768) out.push("HIDDEN");
  409. return out;
  410. }
  411. /* ------------------------------- state ------------------------------- */
  412. var state = null;
  413. /* ------------------------------- loading ------------------------------- */
  414. var worker = null;
  415. var lastFile = null;
  416. function setError(msg) {
  417. var box = $("error-box");
  418. if (!msg) { box.classList.add("hidden"); box.textContent = ""; return; }
  419. box.classList.remove("hidden");
  420. box.textContent = msg;
  421. }
  422. function startParse(file) {
  423. lastFile = file;
  424. setError("");
  425. $("progress-wrap").classList.remove("hidden");
  426. $("progress-bar").style.width = "0%";
  427. $("status").textContent = "Reading " + file.name + " (" + fmtBytes(file.size) + ")...";
  428. file.arrayBuffer().then(function (buf) {
  429. if (worker) { worker.terminate(); worker = null; }
  430. var src = $("worker-src").textContent;
  431. var url = URL.createObjectURL(new Blob([src], { type: "text/javascript" }));
  432. worker = new Worker(url);
  433. worker.onmessage = onWorkerMessage;
  434. worker.onerror = function (e) {
  435. setError("Worker error: " + (e.message || "unknown"));
  436. };
  437. $("status").textContent = "Parsing...";
  438. worker.postMessage({ cmd: "parse", buffer: buf, opts: { maxSamples: 4000000 } }, [buf]);
  439. }).catch(function (err) {
  440. setError("Could not read file: " + err);
  441. });
  442. }
  443. function onWorkerMessage(e) {
  444. var msg = e.data;
  445. if (msg.type === "progress") {
  446. var pct = msg.total ? Math.floor(100 * msg.pos / msg.total) : 0;
  447. $("progress-bar").style.width = pct + "%";
  448. $("status").textContent = "Parsing... " + fmtInt(msg.frames) + " frames (" + pct + "%)";
  449. } else if (msg.type === "error") {
  450. setError("Parse error: " + msg.message);
  451. $("status").textContent = "Failed.";
  452. } else if (msg.type === "done") {
  453. $("progress-bar").style.width = "100%";
  454. $("status").textContent = "Done.";
  455. $("progress-wrap").classList.add("hidden");
  456. buildState(msg.result, lastFile);
  457. renderAll();
  458. }
  459. }
  460. /* ------------------------------- state build ------------------------------- */
  461. function buildState(result, file) {
  462. var nodeById = result.nodes;
  463. var nodes = [];
  464. for (var i = 0; i < nodeById.length; i++) if (nodeById[i]) nodes.push(nodeById[i]);
  465. var groupName = Object.create(null);
  466. for (var g = 0; g < result.budgetGroups.length; g++) groupName[result.budgetGroups[g].id] = result.budgetGroups[g].name;
  467. // Collapse everything except the root unless the tree is small.
  468. var collapsed = new Set();
  469. for (var k = 0; k < nodes.length; k++) collapsed.add(nodes[k].id);
  470. collapsed.delete(result.rootNodeId);
  471. if (nodes.length <= 200) {
  472. // small tree: expand fully
  473. collapsed.clear();
  474. }
  475. state = {
  476. r: result,
  477. file: file,
  478. nodeById: nodeById,
  479. nodes: nodes,
  480. groupName: groupName,
  481. maxId: nodeById.length,
  482. frameCount: result.frameCount,
  483. rangeStart: 0,
  484. rangeEnd: Math.max(0, result.frameCount - 1),
  485. metricTime: result.anyTime,
  486. sortKey: result.anyTime ? "total" : "name",
  487. sortDir: result.anyTime ? -1 : 1,
  488. collapsed: collapsed,
  489. filter: "",
  490. agg: result.aggregates,
  491. self: new Float64Array(nodeById.length),
  492. rootTotal: 0,
  493. percentBase: 1
  494. };
  495. updateRangeInputs();
  496. }
  497. /* ------------------------------- aggregation ------------------------------- */
  498. function getAggregate(start, end) {
  499. var s = state;
  500. if (!s.r.samples || (start <= 0 && end >= s.frameCount - 1)) return s.r.aggregates;
  501. var agg = [];
  502. function get(id) {
  503. var a = agg[id];
  504. if (!a) { a = { calls: 0, us: 0, maxUs: 0, frames: 0 }; agg[id] = a; }
  505. return a;
  506. }
  507. var endClamped = Math.min(end, s.frameCount - 1);
  508. for (var f = Math.max(0, start); f <= endClamped; f++) {
  509. var fr = s.r.samples[f];
  510. if (!fr) continue;
  511. var ids = fr.ids, calls = fr.calls, us = fr.us;
  512. for (var i = 0; i < ids.length; i++) {
  513. var id = ids[i];
  514. var a = get(id);
  515. a.calls += calls[i];
  516. a.us += us[i];
  517. if (us[i] > a.maxUs) a.maxUs = us[i];
  518. a.frames++;
  519. }
  520. }
  521. return agg;
  522. }
  523. function recompute() {
  524. var s = state;
  525. var agg = getAggregate(s.rangeStart, s.rangeEnd);
  526. s.agg = agg;
  527. var self = new Float64Array(s.maxId);
  528. for (var id = 0; id < agg.length; id++) if (agg[id]) self[id] = agg[id].us;
  529. for (var id2 = 0; id2 < agg.length; id2++) {
  530. if (!agg[id2]) continue;
  531. var n = s.nodeById[id2];
  532. if (!n) continue;
  533. var kids = n.children;
  534. for (var c = 0; c < kids.length; c++) if (agg[kids[c]]) self[id2] -= agg[kids[c]].us;
  535. }
  536. s.self = self;
  537. var rootAgg = agg[s.r.rootNodeId];
  538. s.rootTotal = rootAgg ? rootAgg.us : 0;
  539. if (s.metricTime) {
  540. s.percentBase = s.rootTotal || 1;
  541. } else {
  542. s.percentBase = (rootAgg && rootAgg.calls) || 1;
  543. }
  544. }
  545. /* ------------------------------- rendering ------------------------------- */
  546. function renderAll() {
  547. recompute();
  548. renderSummary();
  549. drawTimeline();
  550. renderTree();
  551. renderBudgets();
  552. renderTop();
  553. ["summary-section", "timeline-section", "tree-section", "budget-section", "top-section"]
  554. .forEach(function (id) { $(id).classList.remove("hidden"); });
  555. // Range scoping needs per-frame samples; disable it when they were dropped.
  556. var rangeDisabled = !state.r.samples;
  557. ["range-start", "range-end", "range-apply", "frame-prev", "frame-next"].forEach(function (id) {
  558. $(id).disabled = rangeDisabled;
  559. });
  560. }
  561. function renderSummary() {
  562. var s = state, r = s.r;
  563. var notices = $("notices");
  564. notices.innerHTML = "";
  565. if (!r.anyTime) {
  566. var d = document.createElement("div");
  567. d.className = "warn";
  568. d.innerHTML = "<strong>No timing data in this recording.</strong> Every sampled " +
  569. "microsecond value is zero, so time/self/percentage columns are not meaningful. " +
  570. "This is common for captures made by the engine's snapshot path, which clears the " +
  571. "current frame's counters before writing them. The call tree, call counts and " +
  572. "structure below are still shown.";
  573. notices.appendChild(d);
  574. }
  575. if (r.samplesOverflow) {
  576. var d2 = document.createElement("div");
  577. d2.className = "notice";
  578. d2.textContent = "This recording is very large (" + fmtInt(r.totalSamples) +
  579. " samples), so per-frame samples were not retained. Statistics cover the whole " +
  580. "recording and frame-range selection is disabled.";
  581. notices.appendChild(d2);
  582. }
  583. var rows = [
  584. ["File", s.file ? s.file.name : "(unknown)"],
  585. ["Size", s.file ? fmtBytes(s.file.size) : "-"],
  586. ["Format version", r.version],
  587. ["Frames", fmtInt(r.frameCount)],
  588. ["Nodes", fmtInt(s.nodes.length)],
  589. ["Budget groups", fmtInt(r.budgetGroups.length)],
  590. ["Samples", fmtInt(r.totalSamples)],
  591. ["Total time", fmtUs(Math.round(s.rootTotal))],
  592. ["Parse time", Math.round(r.parseMs) + " ms"]
  593. ];
  594. $("summary").innerHTML = rows.map(function (row) {
  595. return '<div><div class="k">' + esc(row[0]) + '</div><div class="v">' + esc(row[1]) + "</div></div>";
  596. }).join("");
  597. }
  598. /* ---- timeline ---- */
  599. var drag = null;
  600. function useCallsForTimeline() {
  601. return !state.r.anyTime;
  602. }
  603. function timelineData() {
  604. return useCallsForTimeline() ? state.r.frameCalls : state.r.frameTotals;
  605. }
  606. function frameAt(clientX) {
  607. var c = $("timeline");
  608. var rect = c.getBoundingClientRect();
  609. var x = clientX - rect.left;
  610. var f = Math.floor((x / rect.width) * state.frameCount);
  611. if (f < 0) f = 0;
  612. if (f > state.frameCount - 1) f = state.frameCount - 1;
  613. return f;
  614. }
  615. function drawTimeline() {
  616. var c = $("timeline");
  617. var wrap = c.parentElement;
  618. var w = wrap.clientWidth || 600;
  619. var h = 120;
  620. var dpr = window.devicePixelRatio || 1;
  621. c.width = Math.round(w * dpr);
  622. c.height = Math.round(h * dpr);
  623. c.style.width = w + "px";
  624. c.style.height = h + "px";
  625. var ctx = c.getContext("2d");
  626. ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  627. ctx.clearRect(0, 0, w, h);
  628. var s = state;
  629. var data = timelineData();
  630. var max = 0;
  631. for (var i = 0; i < s.frameCount; i++) if (data[i] > max) max = data[i];
  632. var selA = s.rangeStart, selB = s.rangeEnd;
  633. if (drag && drag.active) {
  634. selA = Math.min(drag.start, drag.end);
  635. selB = Math.max(drag.start, drag.end);
  636. }
  637. var padTop = 4, padBottom = 16;
  638. var plotH = h - padTop - padBottom;
  639. var bw = s.frameCount ? (w / s.frameCount) : w;
  640. for (var f = 0; f < s.frameCount; f++) {
  641. var v = data[f];
  642. var bh = max ? (v / max) * plotH : 0;
  643. var x = f * bw;
  644. var inSel = (f >= selA && f <= selB);
  645. ctx.fillStyle = inSel ? "#2b6cb0" : "#b9c6d6";
  646. ctx.fillRect(x, padTop + plotH - bh, Math.max(1, bw - 0.25), bh);
  647. }
  648. // selection background edges
  649. ctx.strokeStyle = "#e0a800";
  650. ctx.lineWidth = 1;
  651. if (selA > 0) { var xa = selA * bw; ctx.beginPath(); ctx.moveTo(xa, padTop); ctx.lineTo(xa, padTop + plotH); ctx.stroke(); }
  652. if (selB < s.frameCount - 1) { var xb = (selB + 1) * bw; ctx.beginPath(); ctx.moveTo(xb, padTop); ctx.lineTo(xb, padTop + plotH); ctx.stroke(); }
  653. ctx.fillStyle = "#333";
  654. ctx.font = "11px system-ui, sans-serif";
  655. var unit = useCallsForTimeline() ? "calls" : "time";
  656. ctx.fillText("max " + (useCallsForTimeline() ? fmtInt(max) : fmtUs(max)) + " per frame (" + unit + ")", 4, h - 4);
  657. ctx.fillText("frame 0", 4, h - 4 - 0);
  658. var lastLabel = "frame " + (s.frameCount - 1);
  659. ctx.fillText(lastLabel, Math.max(4, w - ctx.measureText(lastLabel).width - 4), h - 4);
  660. $("timeline-caption").textContent = useCallsForTimeline()
  661. ? "Showing per-frame total call counts (no timing data available). Drag to select a frame range."
  662. : "Showing per-frame total microseconds. Drag to select a frame range.";
  663. $("frame-label").textContent = "selected frames " + selA + "-" + selB;
  664. }
  665. function applyRange(a, b) {
  666. var s = state;
  667. a = Math.max(0, Math.min(a, s.frameCount - 1));
  668. b = Math.max(0, Math.min(b, s.frameCount - 1));
  669. if (a > b) { var t = a; a = b; b = t; }
  670. s.rangeStart = a; s.rangeEnd = b;
  671. updateRangeInputs();
  672. renderAll();
  673. }
  674. function updateRangeInputs() {
  675. $("range-start").value = state.rangeStart;
  676. $("range-end").value = state.rangeEnd;
  677. }
  678. /* ---- tree ---- */
  679. function compareIds(aId, bId) {
  680. var s = state;
  681. if (s.sortKey === "name") {
  682. return s.nodeById[aId].name.localeCompare(s.nodeById[bId].name) * s.sortDir;
  683. }
  684. var a = s.agg[aId] || { calls: 0, us: 0, maxUs: 0, frames: 0 };
  685. var b = s.agg[bId] || { calls: 0, us: 0, maxUs: 0, frames: 0 };
  686. var av, bv;
  687. switch (s.sortKey) {
  688. case "calls": av = a.calls; bv = b.calls; break;
  689. case "self": av = s.self[aId] || 0; bv = s.self[bId] || 0; break;
  690. case "avg": av = a.frames ? a.us / a.frames : 0; bv = b.frames ? b.us / b.frames : 0; break;
  691. case "max": av = a.maxUs; bv = b.maxUs; break;
  692. default: av = a.us; bv = b.us;
  693. }
  694. if (av === bv) return s.nodeById[aId].name.localeCompare(s.nodeById[bId].name);
  695. return (av < bv ? -1 : 1) * s.sortDir;
  696. }
  697. function computeMatchSet(text) {
  698. if (!text) return null;
  699. var s = state;
  700. var t = text.toLowerCase();
  701. var set = new Set();
  702. for (var i = 0; i < s.nodes.length; i++) {
  703. var n = s.nodes[i];
  704. if (n.name.toLowerCase().indexOf(t) !== -1) {
  705. set.add(n.id);
  706. var p = n.parentId;
  707. while (p !== null && p !== undefined) { set.add(p); p = s.nodeById[p].parentId; }
  708. }
  709. }
  710. return set;
  711. }
  712. function renderTree() {
  713. var s = state;
  714. var matchSet = computeMatchSet(s.filter);
  715. var orderCache = Object.create(null);
  716. function order(id) {
  717. var c = orderCache[id];
  718. if (c) return c;
  719. c = s.nodeById[id].children.slice();
  720. c.sort(compareIds);
  721. orderCache[id] = c;
  722. return c;
  723. }
  724. var rows = [];
  725. function visit(id, depth) {
  726. var n = s.nodeById[id];
  727. if (!n) return;
  728. if (matchSet && !matchSet.has(id)) return;
  729. var a = s.agg[id] || { calls: 0, us: 0, maxUs: 0, frames: 0 };
  730. var totalUs = a.us;
  731. var selfUs = s.self[id] || 0;
  732. var pct = s.percentBase ? ((s.metricTime ? totalUs : a.calls) / s.percentBase) * 100 : 0;
  733. rows.push({ id: id, depth: depth, n: n, a: a, totalUs: totalUs, selfUs: selfUs, pct: pct });
  734. var collapsed = !matchSet && s.collapsed.has(id);
  735. if (!collapsed) { order(id).forEach(function (c) { visit(c, depth + 1); }); }
  736. }
  737. visit(s.r.rootNodeId, 0);
  738. // Header
  739. var cols = [
  740. { key: "name", label: "Scope", num: false },
  741. { key: "calls", label: "Calls", num: true },
  742. { key: "total", label: "Total", num: true },
  743. { key: "self", label: "Self", num: true },
  744. { key: "avg", label: "Avg", num: true },
  745. { key: "max", label: "Max", num: true },
  746. { key: "_pct", label: "%", num: true }
  747. ];
  748. $("tree-head").innerHTML = cols.map(function (c) {
  749. var sortable = c.key !== "_pct";
  750. var marker = "";
  751. if (sortable && s.sortKey === c.key) marker = s.sortDir < 0 ? " \u25bc" : " \u25b2";
  752. return '<th class="' + (sortable ? "" : "nosort") + (c.num ? " num" : "") +
  753. '" data-key="' + (sortable ? c.key : "") + '">' + esc(c.label) + marker + "</th>";
  754. }).join("");
  755. Array.prototype.forEach.call($("tree-head").children, function (th) {
  756. var key = th.getAttribute("data-key");
  757. if (!key) return;
  758. th.onclick = function () {
  759. if (state.sortKey === key) state.sortDir = -state.sortDir;
  760. else { state.sortKey = key; state.sortDir = key === "name" ? 1 : -1; }
  761. renderTree();
  762. };
  763. });
  764. var maxMetric = 0;
  765. rows.forEach(function (r) { maxMetric = Math.max(maxMetric, s.metricTime ? r.totalUs : r.a.calls); });
  766. var html = rows.map(function (r) {
  767. var hasKids = r.n.children.length > 0;
  768. var collapsed = !matchSet && s.collapsed.has(r.id);
  769. var toggle = hasKids ? (collapsed ? "\u25b6" : "\u25bc") : "\u00a0";
  770. var indent = "padding-left:" + (4 + r.depth * 14) + "px";
  771. var metric = s.metricTime ? r.totalUs : r.a.calls;
  772. var barW = maxMetric ? Math.round(60 * metric / maxMetric) : 0;
  773. var avg = r.a.frames ? r.a.us / r.a.frames : 0;
  774. var groupName = s.groupName[r.n.budgetGroupId] || ("group " + r.n.budgetGroupId);
  775. return '<tr data-id="' + r.id + '">' +
  776. '<td class="tree-name" style="' + indent + '"><span class="toggle">' + toggle + "</span>" +
  777. esc(r.n.name) + ' <span class="muted">[' + esc(groupName) + "]</span>" +
  778. '<div class="bar" style="width:' + barW + 'px"></div></td>' +
  779. '<td class="num">' + fmtInt(r.a.calls) + "</td>" +
  780. '<td class="num">' + (s.metricTime ? fmtUs(r.totalUs) : "-") + "</td>" +
  781. '<td class="num">' + (s.metricTime ? fmtUs(r.selfUs) : "-") + "</td>" +
  782. '<td class="num">' + (s.metricTime && r.a.frames ? fmtUs(avg) : "-") + "</td>" +
  783. '<td class="num">' + (s.metricTime ? fmtUs(r.a.maxUs) : "-") + "</td>" +
  784. '<td class="num">' + (s.metricTime ? r.pct.toFixed(2) + "%" : "-") + "</td>" +
  785. "</tr>";
  786. }).join("");
  787. $("tree-body").innerHTML = html;
  788. $("tree-count").textContent = rows.length + " rows shown";
  789. Array.prototype.forEach.call($("tree-body").querySelectorAll("tr"), function (tr) {
  790. tr.onclick = function (ev) {
  791. var id = Number(tr.getAttribute("data-id"));
  792. var n = s.nodeById[id];
  793. if (!n || !n.children.length) return;
  794. if (s.collapsed.has(id)) s.collapsed.delete(id); else s.collapsed.add(id);
  795. renderTree();
  796. };
  797. });
  798. }
  799. /* ---- budget groups ---- */
  800. function renderBudgets() {
  801. var s = state;
  802. var perGroup = Object.create(null);
  803. for (var id = 0; id < s.agg.length; id++) {
  804. var a = s.agg[id];
  805. if (!a) continue;
  806. var n = s.nodeById[id];
  807. if (!n) continue;
  808. var gid = n.budgetGroupId;
  809. var e = perGroup[gid];
  810. if (!e) e = perGroup[gid] = { us: 0, calls: 0 };
  811. e.us += a.us; e.calls += a.calls;
  812. }
  813. var rows = [];
  814. for (var gi = 0; gi < s.r.budgetGroups.length; gi++) {
  815. var g = s.r.budgetGroups[gi];
  816. var e2 = perGroup[g.id];
  817. if (!e2 && !g.isDefault) continue;
  818. rows.push({ id: g.id, name: g.name, us: e2 ? e2.us : 0, calls: e2 ? e2.calls : 0 });
  819. }
  820. var metric = s.metricTime ? "us" : "calls";
  821. rows.sort(function (a, b) { return b[metric] - a[metric]; });
  822. $("budget-body").innerHTML = rows.map(function (r) {
  823. var pct = s.percentBase ? (r[metric] / s.percentBase) * 100 : 0;
  824. return "<tr><td>" + esc(r.name) + "</td><td class='num'>" + fmtInt(r.calls) + "</td>" +
  825. "<td class='num'>" + (s.metricTime ? fmtUs(r.us) : "-") + "</td>" +
  826. "<td class='num'>" + (s.metricTime ? pct.toFixed(2) + "%" : "-") + "</td></tr>";
  827. }).join("");
  828. }
  829. /* ---- top nodes ---- */
  830. function renderTop() {
  831. var s = state;
  832. var rows = [];
  833. for (var id = 0; id < s.agg.length; id++) {
  834. var a = s.agg[id];
  835. if (!a) continue;
  836. var n = s.nodeById[id];
  837. if (!n) continue;
  838. rows.push({ id: id, name: n.name, us: a.us, self: s.self[id] || 0, calls: a.calls });
  839. }
  840. if (s.metricTime) rows.sort(function (a, b) { return b.us - a.us; });
  841. else rows.sort(function (a, b) { return b.calls - a.calls; });
  842. rows = rows.slice(0, 100);
  843. $("top-body").innerHTML = rows.map(function (r) {
  844. return "<tr><td>" + esc(r.name) + "</td>" +
  845. "<td class='num'>" + (s.metricTime ? fmtUs(r.us) : "-") + "</td>" +
  846. "<td class='num'>" + (s.metricTime ? fmtUs(r.self) : "-") + "</td>" +
  847. "<td class='num'>" + fmtInt(r.calls) + "</td></tr>";
  848. }).join("");
  849. }
  850. /* ------------------------------- events ------------------------------- */
  851. $("file").addEventListener("change", function (e) {
  852. var f = e.target.files && e.target.files[0];
  853. if (f) startParse(f);
  854. });
  855. document.addEventListener("dragover", function (e) { e.preventDefault(); });
  856. document.addEventListener("drop", function (e) {
  857. e.preventDefault();
  858. var f = e.dataTransfer && e.dataTransfer.files && e.dataTransfer.files[0];
  859. if (f) startParse(f);
  860. });
  861. $("range-all").onclick = function () { applyRange(0, state.frameCount - 1); };
  862. $("range-apply").onclick = function () {
  863. applyRange(Number($("range-start").value), Number($("range-end").value));
  864. };
  865. $("frame-prev").onclick = function () {
  866. var f = Math.max(0, state.rangeStart - 1); applyRange(f, f);
  867. };
  868. $("frame-next").onclick = function () {
  869. var f = Math.min(state.frameCount - 1, state.rangeEnd + 1); applyRange(f, f);
  870. };
  871. $("expand-all").onclick = function () { state.collapsed.clear(); renderTree(); };
  872. $("collapse-all").onclick = function () {
  873. state.collapsed.clear();
  874. for (var i = 0; i < state.nodes.length; i++) state.collapsed.add(state.nodes[i].id);
  875. state.collapsed.delete(state.r.rootNodeId);
  876. renderTree();
  877. };
  878. var filterTimer = null;
  879. $("filter").addEventListener("input", function (e) {
  880. clearTimeout(filterTimer);
  881. filterTimer = setTimeout(function () { state.filter = e.target.value.trim(); renderTree(); }, 120);
  882. });
  883. /* timeline drag selection */
  884. (function () {
  885. var c = $("timeline");
  886. c.addEventListener("mousedown", function (e) {
  887. if (!state || !state.r.samples) return;
  888. e.preventDefault();
  889. drag = { active: true, start: frameAt(e.clientX), end: frameAt(e.clientX) };
  890. drawTimeline();
  891. });
  892. window.addEventListener("mousemove", function (e) {
  893. if (!drag || !drag.active) return;
  894. drag.end = frameAt(e.clientX);
  895. drawTimeline();
  896. });
  897. window.addEventListener("mouseup", function (e) {
  898. if (!drag || !drag.active) return;
  899. drag.end = frameAt(e.clientX);
  900. var a = drag.start, b = drag.end;
  901. drag = null;
  902. applyRange(a, b);
  903. });
  904. window.addEventListener("resize", function () { if (state) drawTimeline(); });
  905. })();
  906. </script>
  907. </body>
  908. </html>