// Copyright Epic Games, Inc. All Rights Reserved. // Entry point: boots the viewer, owns the model, wires tabs / sidebar / // search / threads list / session panel. import { getSession, getThreads, getChannels, getScopeStats, getScopeNames, getLogCategories, getBookmarks, getRegions, getCsvCategories, getCsvStats } from "./api.js"; import { Timeline } from "./timeline.js"; import { StatsView } from "./stats.js"; import { MemoryView } from "./memory.js"; import { LogsView } from "./logs.js"; import { CsvStatsView } from "./csvstats.js"; import { CountersView } from "./counters.js"; import { escapeHtml } from "./util.js"; function formatTimeMs(us) { if (us < 1000) return `${us} µs`; if (us < 1_000_000) return `${(us / 1000).toFixed(2)} ms`; return `${(us / 1_000_000).toFixed(2)} s`; } function formatNum(n) { return Number(n).toLocaleString(); } function stripNul(s) { return (s || "").replace(/\u0000/g, ""); } function getThemePreference() { const params = new URLSearchParams(window.location.search); const theme = params.get("theme"); if (theme === "dark" || theme === "light" || theme === "system") { return theme; } const stored = window.localStorage.getItem("zen-trace-theme"); if (stored === "dark" || stored === "light" || stored === "system") { return stored; } return "system"; } function applyTheme(theme) { document.documentElement.setAttribute("data-theme", theme); window.localStorage.setItem("zen-trace-theme", theme); const url = new URL(window.location.href); if (theme === "system") { url.searchParams.delete("theme"); } else { url.searchParams.set("theme", theme); } window.history.replaceState({}, "", url); const btn = document.getElementById("theme-toggle"); if (btn) { btn.textContent = theme === "dark" ? "Dark" : theme === "light" ? "Light" : "System"; btn.title = `Theme: ${theme}. Click to cycle.`; } } function setupThemeToggle() { const btn = document.getElementById("theme-toggle"); if (!btn) { return; } const themes = ["system", "dark", "light"]; let theme = getThemePreference(); applyTheme(theme); btn.addEventListener("click", () => { const index = themes.indexOf(theme); theme = themes[(index + 1) % themes.length]; applyTheme(theme); }); } async function main() { setupThemeToggle(); const loadingEl = document.getElementById("loading"); try { const [session, threads, channels, scopeStats, scopeNames, logCategories, bookmarks, regionsResponse, csvCategories, csvStats] = await Promise.all([ getSession(), getThreads(), getChannels(), getScopeStats(), getScopeNames(), getLogCategories(), getBookmarks(), getRegions(), getCsvCategories(), getCsvStats(), ]); // Normalize strings (tourist sometimes leaves trailing NULs in FieldStr). for (const t of threads) t.name = stripNul(t.name); session.app_name = stripNul(session.app_name); session.project_name = stripNul(session.project_name); session.branch = stripNul(session.branch); session.build_version = stripNul(session.build_version); session.platform = stripNul(session.platform); session.command_line = stripNul(session.command_line); for (const s of scopeStats) s.name = stripNul(s.name); for (let i = 0; i < scopeNames.length; i++) scopeNames[i] = stripNul(scopeNames[i]); for (const c of logCategories) c.name = stripNul(c.name); for (const b of bookmarks) { b.text = stripNul(b.text); b.file = stripNul(b.file); } const regionCategories = regionsResponse && regionsResponse.categories ? regionsResponse.categories : []; for (const cat of regionCategories) { cat.name = stripNul(cat.name); for (const r of cat.regions) { r.name = stripNul(r.name); } } for (const cat of csvCategories) cat.name = stripNul(cat.name); for (const s of csvStats) s.name = stripNul(s.name); // Precompute name → id for highlight lookups. const scopeNameIds = new Map(); for (let i = 0; i < scopeNames.length; i++) { scopeNameIds.set(scopeNames[i], i); } const model = { session, threads, channels, scopeStats, scopeNames, scopeNameIds, logCategories, bookmarks, regionCategories, csvCategories, csvStats }; renderHeader(model); renderSessionView(model); const timeline = new Timeline({ canvas: document.getElementById("timeline-canvas"), tooltip: document.getElementById("tooltip"), selectionEl: document.getElementById("selection-panel"), viewportInfoEl: document.getElementById("viewport-info"), zoomResetBtn: document.getElementById("zoom-reset"), model, onScopeSelect: (name) => { timeline.setHighlightName(name); stats.selectByName(name); }, }); const stats = new StatsView( document.getElementById("stats-tbody"), document.querySelector(".stats-table thead tr"), model, (name) => { timeline.setHighlightName(name); timeline.jumpToScopeName(name); }, ); const memoryView = new MemoryView(model, document.getElementById("memory-content")); const logsView = new LogsView(model, document.getElementById("logs-content")); const csvView = new CsvStatsView(model, document.getElementById("csv-content")); const countersView = new CountersView(model, document.getElementById("counters-content")); const threadsListApi = renderThreadsList(model, timeline); renderRegionCategories(model, timeline); setupTabs(memoryView, logsView, csvView, countersView); setupSearch(model, timeline, stats); const bookmarksToggle = document.getElementById("bookmarks-toggle"); bookmarksToggle.addEventListener("change", () => { timeline.setBookmarksVisible(bookmarksToggle.checked); }); const lodToggle = document.getElementById("lod-toggle"); lodToggle.addEventListener("change", () => { timeline.setLodEnabled(lodToggle.checked); }); const compactToggle = document.getElementById("compact-toggle"); compactToggle.addEventListener("change", () => { timeline.setCompact(compactToggle.checked); }); // 'c' toggles compact mode while the timeline tab is active. Skipped // when focus is in a text input so typing 'c' in the search box still // works normally. document.addEventListener("keydown", (e) => { if (e.key !== "c" && e.key !== "C") return; if (e.ctrlKey || e.metaKey || e.altKey) return; const target = e.target; if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return; const timelineTab = document.querySelector(".tab[data-tab='timeline']"); if (!timelineTab || !timelineTab.classList.contains("active")) return; e.preventDefault(); compactToggle.checked = !compactToggle.checked; timeline.setCompact(compactToggle.checked); }); // Enable all threads that actually have captured scopes by default; if // none do, enable every thread so the swimlanes still show up empty. const withScopes = model.threads.filter((t) => t.scope_count > 0).map((t) => t.thread_id); const initialEnabled = withScopes.length > 0 ? withScopes : model.threads.map((t) => t.thread_id); for (const id of initialEnabled) { const cb = document.querySelector(`.thread-row input[data-tid="${id}"]`); if (cb) cb.checked = true; } threadsListApi.syncAllGroupCheckboxes(); timeline.setEnabledThreads(initialEnabled); // "deselect all / select all" toggle for the Threads panel const toggleAllBtn = document.getElementById("threads-toggle-all"); const threadsList = document.getElementById("threads-list"); toggleAllBtn.addEventListener("click", () => { const allBoxes = threadsList.querySelectorAll(".thread-row input[type=checkbox]"); const anyChecked = Array.from(allBoxes).some((cb) => cb.checked); const newState = !anyChecked; for (const cb of allBoxes) { cb.checked = newState; } // Sync group checkboxes for (const gcb of threadsList.querySelectorAll(".group-checkbox")) { gcb.checked = newState; gcb.indeterminate = false; } toggleAllBtn.textContent = newState ? "deselect all" : "select all"; const enabled = []; if (newState) { for (const cb of allBoxes) { enabled.push(Number(cb.dataset.tid)); } } timeline.setEnabledThreads(enabled); }); loadingEl.classList.add("hidden"); } catch (e) { loadingEl.textContent = `Failed to load trace: ${e.message}`; console.error(e); } } function renderHeader(model) { const { session } = model; const hdrFile = document.getElementById("hdr-file"); hdrFile.textContent = session.file_path || ""; hdrFile.title = session.file_path || ""; const stats = document.getElementById("hdr-stats"); stats.innerHTML = `events:` + `threads:` + `duration:` + `parse:`; const vs = stats.querySelectorAll(".v"); vs[0].textContent = formatNum(session.total_events || 0); vs[1].textContent = formatNum(model.threads.length); vs[2].textContent = formatTimeMs((session.trace_end_us || 0) - (session.trace_start_us || 0)); vs[3].textContent = `${session.parse_time_ms} ms`; } function renderSessionView(model) { const { session, threads, channels } = model; const el = document.getElementById("session-content"); const rows = []; rows.push("

Session

"); rows.push("
"); const row = (k, v) => v && rows.push(`
${k}
${escapeHtml(v)}
`); row("File", session.file_path); row("Size", `${formatNum(session.file_size)} bytes`); row("Events", formatNum(session.total_events)); row("Parse time", `${session.parse_time_ms} ms`); row("Platform", session.platform); row("App", session.app_name); row("Project", session.project_name); row("Branch", session.branch); row("Build", session.build_version); if (session.changelist) row("Changelist", String(session.changelist)); row("Command line", session.command_line); rows.push("
"); rows.push("

Threads

"); rows.push(""); rows.push(``); rows.push(""); for (const t of threads) { rows.push( `` + `` + `` + ``, ); } rows.push("
NameTIDSystem IDScopes
${escapeHtml(t.name || "")}${t.thread_id}${t.system_id}${formatNum(t.scope_count || 0)}
"); if (channels && channels.length) { rows.push("

Trace channels

"); rows.push(""); for (const c of channels) { const cls = c.enabled ? "chan-enabled" : "chan-disabled"; const ro = c.readonly ? `read-only` : ""; rows.push(``); } rows.push("
NameState
${escapeHtml(c.name || "")}${c.enabled ? "enabled" : "disabled"}${ro}
"); } el.innerHTML = rows.join(""); } function renderThreadsList(model, timeline) { const list = document.getElementById("threads-list"); const cmp = (a, b) => { // SortHint first (UE sets low values for important threads like GameThread) if (a.sort_hint !== b.sort_hint) return a.sort_hint - b.sort_hint; // Then threads with scopes before empty ones if ((a.scope_count > 0) !== (b.scope_count > 0)) return b.scope_count - a.scope_count; // Then by thread ID (lower = created earlier, main thread is typically first) if (a.thread_id !== b.thread_id) return a.thread_id - b.thread_id; return (a.name || "").localeCompare(b.name || "", undefined, { numeric: true }); }; // Build groups: ungrouped threads, named groups, and lanes const lanes = model.threads.filter(t => t.is_lane).sort(cmp); const grouped = new Map(); // groupName → [threads] const ungrouped = []; for (const t of model.threads) { if (t.is_lane) continue; const g = t.group || ""; if (g) { if (!grouped.has(g)) grouped.set(g, []); grouped.get(g).push(t); } else { ungrouped.push(t); } } ungrouped.sort(cmp); for (const [, threads] of grouped) threads.sort(cmp); // Sort group names naturally const groupNames = Array.from(grouped.keys()).sort((a, b) => a.localeCompare(b, undefined, { numeric: true })); const collapsed = new Set(); const parts = []; function renderGroup(label, threads, collapsible) { if (threads.length === 0) return; const collapseAttr = collapsible ? ` data-group="${label}"` : ""; const chevron = collapsible ? `` : ""; const groupCb = collapsible ? `` : ""; parts.push(`
${groupCb}${chevron}${label} (${threads.length})
`); for (const t of threads) { const emptyCls = t.scope_count === 0 ? " empty" : ""; const laneCls = t.is_lane ? " lane" : ""; const groupAttr = collapsible ? ` data-group-member="${label}"` : ""; parts.push( ``, ); } } // Ungrouped threads first, then named groups, then lanes if (ungrouped.length > 0 && (grouped.size > 0 || lanes.length > 0)) { renderGroup("Threads", ungrouped, false); } else { // No groups at all — render without a header for (const t of ungrouped) { const emptyCls = t.scope_count === 0 ? " empty" : ""; parts.push( ``, ); } } for (const name of groupNames) renderGroup(name, grouped.get(name), true); renderGroup("Lanes", lanes, lanes.length > 0); list.innerHTML = parts.join(""); function syncTimeline() { const enabled = new Set(); for (const box of list.querySelectorAll(".thread-row input[type=checkbox]")) { if (box.checked) enabled.add(Number(box.dataset.tid)); } timeline.setEnabledThreads(Array.from(enabled)); } function syncGroupCheckbox(groupName) { const members = list.querySelectorAll(`[data-group-member="${groupName}"] input[type=checkbox]`); const gcb = list.querySelector(`.group-checkbox[data-group-toggle="${groupName}"]`); if (!gcb || members.length === 0) return; const checkedCount = Array.from(members).filter((c) => c.checked).length; gcb.checked = checkedCount === members.length; gcb.indeterminate = checkedCount > 0 && checkedCount < members.length; } function syncAllGroupCheckboxes() { for (const gcb of list.querySelectorAll(".group-checkbox")) { syncGroupCheckbox(gcb.dataset.groupToggle); } } // Wire up collapsible group headers (click on the label area, not checkbox) for (const hdr of list.querySelectorAll(".thread-group-header[data-group]")) { hdr.addEventListener("click", (e) => { // Don't collapse when clicking the group checkbox if (e.target.classList.contains("group-checkbox")) return; const group = hdr.dataset.group; const isCollapsed = collapsed.has(group); if (isCollapsed) { collapsed.delete(group); hdr.classList.remove("collapsed"); } else { collapsed.add(group); hdr.classList.add("collapsed"); } for (const row of list.querySelectorAll(`[data-group-member="${group}"]`)) { row.style.display = isCollapsed ? "" : "none"; } }); } // Wire up group checkboxes — toggle all children for (const gcb of list.querySelectorAll(".group-checkbox")) { gcb.addEventListener("change", () => { const group = gcb.dataset.groupToggle; const checked = gcb.checked; for (const row of list.querySelectorAll(`[data-group-member="${group}"]`)) { const cb = row.querySelector("input[type=checkbox]"); if (cb) cb.checked = checked; } syncTimeline(); }); } // Wire up thread checkboxes for (const row of list.querySelectorAll(".thread-row")) { const cb = row.querySelector("input"); const nameEl = row.querySelector(".thread-name"); const tid = Number(cb.dataset.tid); const thread = model.threads.find((t) => t.thread_id === tid); nameEl.textContent = (thread && thread.name) || `tid ${tid}`; cb.addEventListener("change", () => { const groupMember = row.dataset.groupMember; if (groupMember) syncGroupCheckbox(groupMember); syncTimeline(); }); } return { syncAllGroupCheckboxes }; } function renderRegionCategories(model, timeline) { const categories = model.regionCategories || []; if (categories.length === 0) return; const panel = document.getElementById("regions-panel"); panel.hidden = false; const list = document.getElementById("regions-list"); const parts = []; for (let i = 0; i < categories.length; i++) { const cat = categories[i]; const count = cat.regions ? cat.regions.length : 0; parts.push( ``, ); } list.innerHTML = parts.join(""); // Set label text via DOM to avoid XSS for (const row of list.querySelectorAll(".thread-row")) { const cb = row.querySelector("input"); const nameEl = row.querySelector(".thread-name"); const idx = Number(cb.dataset.catIdx); nameEl.textContent = categories[idx].name || "Uncategorized"; } function syncTimeline() { const enabled = new Set(); for (const cb of list.querySelectorAll("input[type=checkbox]")) { if (cb.checked) enabled.add(Number(cb.dataset.catIdx)); } timeline.setEnabledRegionCategories(enabled); } for (const cb of list.querySelectorAll("input[type=checkbox]")) { cb.addEventListener("change", syncTimeline); } // "deselect all / select all" toggle const toggleBtn = document.getElementById("regions-toggle-all"); toggleBtn.addEventListener("click", () => { const allBoxes = list.querySelectorAll("input[type=checkbox]"); const anyChecked = Array.from(allBoxes).some((cb) => cb.checked); const newState = !anyChecked; for (const cb of allBoxes) cb.checked = newState; toggleBtn.textContent = newState ? "deselect all" : "select all"; syncTimeline(); }); // Initial state: all enabled const allIndices = new Set(categories.map((_, i) => i)); timeline.setEnabledRegionCategories(allIndices); } function setupTabs(memoryView, logsView, csvView, countersView) { const tabs = document.querySelectorAll(".tab"); const views = document.querySelectorAll(".view"); const validTabs = new Set(Array.from(tabs, (tab) => tab.dataset.tab)); function activateTab(key, updateUrl = true) { for (const tab of tabs) { tab.classList.toggle("active", tab.dataset.tab === key); } for (const view of views) { view.hidden = view.dataset.view !== key; } if (updateUrl) { const url = new URL(window.location.href); url.searchParams.set("tab", key); window.history.replaceState({}, "", url); } if (key === "memory" && memoryView) { memoryView.ensureLoaded(); } if (key === "logs" && logsView) { logsView.ensureLoaded(); } if (key === "csv" && csvView) { csvView.ensureLoaded(); } if (key === "counters" && countersView) { countersView.ensureLoaded(); } } for (const tab of tabs) { tab.addEventListener("click", () => activateTab(tab.dataset.tab)); } const initialTab = new URLSearchParams(window.location.search).get("tab"); if (initialTab && validTabs.has(initialTab)) { activateTab(initialTab, false); } } function setupSearch(model, timeline, stats) { const input = document.getElementById("search-input"); const results = document.getElementById("search-results"); function render() { const q = input.value.trim().toLowerCase(); if (!q) { results.innerHTML = ""; timeline.setHighlightName(null); return; } const matches = []; for (const s of model.scopeStats) { if (s.name.toLowerCase().includes(q)) { matches.push(s); if (matches.length >= 50) break; } } const parts = []; for (const m of matches) { parts.push( `
` + `${escapeHtml(m.name)}` + `${formatNum(m.count)}` + `
`, ); } results.innerHTML = parts.join(""); for (const hit of results.querySelectorAll(".hit")) { hit.addEventListener("click", () => { const name = hit.dataset.name; timeline.setHighlightName(name); timeline.jumpToScopeName(name); stats.selectByName(name); }); } if (matches.length > 0) { timeline.setHighlightName(matches[0].name); } } input.addEventListener("input", render); } main();