From 43e9f6a14cb50157b6bb842d15fce155b274adf3 Mon Sep 17 00:00:00 2001 From: Hunter Date: Sat, 22 Aug 2026 12:16:54 -0400 Subject: [PATCH] Added selective dump features --- app/app.vue | 117 ++++++++++------ app/components/PathTree.vue | 146 ++++++++++++++++--- app/components/ReportModal.vue | 247 +++++++++++++++++++++++++++++++++ app/composables/useDump.ts | 4 + app/composables/useFrames.ts | 34 ++--- app/lib/dump-parse.ts | 22 ++- app/lib/dump-store.ts | 44 ++++-- app/lib/report.ts | 160 +++++++++++++++++++++ app/workers/dump.worker.ts | 17 +++ 9 files changed, 680 insertions(+), 111 deletions(-) create mode 100644 app/components/ReportModal.vue create mode 100644 app/lib/report.ts diff --git a/app/app.vue b/app/app.vue index 8fa6921..8dd03c5 100644 --- a/app/app.vue +++ b/app/app.vue @@ -25,26 +25,13 @@ const showAllTypes = ref(false) const dragging = ref(false) const treeVersion = ref(0) -/** - * Filters carried in from a link, held until the dump they belong to is up. - * - * The hash is kept alongside them so that a recipient who did not have the - * dump, and then loads the file by hand, still lands on the sender's view — - * while someone who opens an unrelated dump instead does not inherit filters - * meant for a different one. - */ +// pending view const pending = ref<{ hash: string; view: ViewState } | null>(null) const fmt = new Intl.NumberFormat() -// ------------------------------------------------------------------ the view +// --- view -/** - * Type filters travel as names, not as the ids the store indexes by, so both - * directions have to go through the dump's type table. A name the current - * dump does not have simply drops out — a link from a newer dump should lose - * the filter it cannot express, not fail to open. - */ function currentView(): ViewState { return { prefix: pathPrefix.value, @@ -78,13 +65,6 @@ function schedule() { timer = setTimeout(commit, 120) } -/** - * The one path by which a filter change becomes a frame, a URL and a query. - * - * Replaying a frame runs through here too, and needs no guard against - * recording itself: after `applyView` the refs are *equal* to the frame at - * the cursor, and `push` drops a frame identical to the current one. - */ function commit() { const v = currentView() frames.push(v) @@ -107,8 +87,7 @@ async function run() { watch([nameQuery, pathQuery], schedule) watch([pathPrefix, activeTypes, sort, descending], commit, { deep: true }) -/** A new dump invalidates every frame: the paths and types are a different - * vocabulary, so the queue restarts rather than carrying over. */ +/** A new dump invalidates every frame: the paths and types are a different vocabulary, so the queue restarts rather than carrying over. */ function resetTo(v: ViewState) { applyView(v) inspected.value = null @@ -136,6 +115,11 @@ function setSort(key: SortKey, desc: boolean) { descending.value = desc } +function clearHistory() { + frames.clear() + syncUrl(currentView()) +} + function step(delta: -1 | 1) { const f = delta < 0 ? frames.back() : frames.forward() if (!f) return @@ -184,6 +168,25 @@ function jumpTo(path: string) { pathQuery.value = '' } +// -------------------------------------------------------------- subtree dump + +const report = ref({ open: false, root: '', text: '', objects: 0, truncated: false, error: '' }) +const reportLoading = ref(false) + +/** Walk everything at or under `path` and render it as a reference sheet. */ +async function dumpSubtree(path: string) { + report.value = { open: true, root: path, text: '', objects: 0, truncated: false, error: '' } + reportLoading.value = true + try { + const r = await d.buildReport(path) + report.value = { ...report.value, ...r } + } catch (e) { + report.value = { ...report.value, error: (e as Error).message } + } finally { + reportLoading.value = false + } +} + async function onDrop(e: DragEvent) { dragging.value = false const file = e.dataTransfer?.files?.[0] @@ -204,25 +207,22 @@ async function forget(key: string) { refreshCached() } -// ---------------------------------------------------------------- site dumps +// --- site dumps const grabbing = ref('') const grabPct = ref(0) const grabError = ref('') -/** Only dumps this browser has not already taken. The cached copy records the - * manifest id it came from, which is what makes "already have it" answerable - * without re-hashing a 37 MB file. */ +/** Only dumps this browser has not already taken. */ const offered = computed(() => { const have = new Set(cached.value.map((c) => c.source).filter(Boolean)) return hosted.value.filter((h) => !have.has(h.id)) }) /** - * Download on demand, never on load. One interaction fetches, caches, and - * opens the dump, so the offer disappearing from this list is the same - * gesture that puts it on screen. + * Download on demand, never on load. */ + async function grab(h: HostedDump) { grabbing.value = h.id grabError.value = '' @@ -239,21 +239,15 @@ async function grab(h: HostedDump) { } } -/** Unload the dump and drop back to the intro screen. The parsed copy stays - * in IndexedDB, so it reappears under "Already parsed". */ + async function clearDump() { - // The d.count watcher runs resetTo(), which clears every filter, the frame - // queue and the URL. Only the things outside that surface are left here. await d.reset() dragging.value = false } -/** - * A link carries a dump hash and a set of filters, but never the dump — 37 MB - * does not fit in a URL. The recipient either already has that dump in - * IndexedDB, in which case it opens with the sender's filters intact, or they - * do not, and they land on the normal intro with a note saying so. - */ + + +// 'cache miss' client did not have downloaded dump const linkMiss = ref(false) onMounted(async () => { @@ -267,9 +261,7 @@ onMounted(async () => { pending.value = { hash: dump, view } if (!(await d.restore(dump))) { linkMiss.value = true - // Not surfaced as an error: a link arriving without its dump is the - // expected case, and `linkMiss` says so in plainer terms. The pending - // view stays put so that loading the file by hand still lands on it. + // a link arriving without its dump is the expected case d.error.value = '' } }) @@ -368,6 +360,7 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB' v-model="pathPrefix" :fetch-children="d.fetchChildren" :version="treeVersion" + @dump="dumpSubtree" />
@@ -376,6 +369,15 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
+ + + +
Release to parse a new dump
@@ -674,6 +688,17 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB' .frames button:not(:disabled):hover { background: var(--raise); } + +/* Destructive, so it reads as an aside to the two arrows rather than a third + navigation control. */ +.frames .wipe { + color: var(--dim); + font-size: 13px; + border-right: 1px solid var(--rule); +} +.frames .wipe:not(:disabled):hover { + color: var(--t-bool); +} .tally { font-size: 10px; color: var(--dim); @@ -834,6 +859,10 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB' text-decoration: underline; } +.scope + .scope { + margin-top: 6px; +} + .scope { margin: 4px 12px 0; width: calc(100% - 24px); diff --git a/app/components/PathTree.vue b/app/components/PathTree.vue index b5660dc..bcb4442 100644 --- a/app/components/PathTree.vue +++ b/app/components/PathTree.vue @@ -1,5 +1,5 @@ @@ -109,8 +166,9 @@ watch(() => props.version, loadRoots) v-for="node in flatten(roots)" :key="node.path" class="node mono" - :class="{ active: modelValue === node.path }" + :class="{ active: modelValue === node.path, aimed: menu?.path === node.path }" :style="{ paddingLeft: 8 + node.depth * 13 + 'px' }" + @contextmenu.prevent="openMenu($event, node)" > + + + + + @@ -214,6 +287,11 @@ watch(() => props.version, loadRoots) background: var(--amber-soft); color: var(--ink-strong); } +/* Keeps the right-clicked row identifiable while the menu is over it. */ +.node.aimed { + background: var(--raise); + box-shadow: inset 2px 0 0 var(--amber); +} .node.root { padding-left: 21px; color: var(--dim); @@ -268,6 +346,42 @@ watch(() => props.version, loadRoots) flex: none; } +/* Teleported to , but Vue still stamps it with this component's scope + id, so these rules reach it. */ +.menu { + position: fixed; + z-index: 50; + min-width: 210px; + padding: 4px; + background: var(--panel); + border: 1px solid var(--rule); + border-radius: 3px; + box-shadow: 0 10px 28px rgb(0 0 0 / 45%); +} +.menu button { + display: block; + width: 100%; + text-align: left; + font-family: inherit; + font-size: 11px; + color: var(--ink); + background: none; + border: none; + border-radius: 2px; + padding: 7px 9px; + cursor: pointer; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.menu button b { + font-weight: 400; + color: var(--amber); +} +.menu button:hover { + background: var(--raise); +} + @media (max-width: 900px) { .spine { width: 100%; diff --git a/app/components/ReportModal.vue b/app/components/ReportModal.vue new file mode 100644 index 0000000..f0f3166 --- /dev/null +++ b/app/components/ReportModal.vue @@ -0,0 +1,247 @@ + + + + + diff --git a/app/composables/useDump.ts b/app/composables/useDump.ts index e1b4979..3f34775 100644 --- a/app/composables/useDump.ts +++ b/app/composables/useDump.ts @@ -151,6 +151,9 @@ export function useDump() { const resolveAddress = (address: number) => call<{ row: RowView | null }>('resolve', { address }) + const buildReport = (prefix: string) => + call<{ text: string; objects: number; truncated: boolean }>('report', { prefix }) + return { loadFile, loadBuffer, @@ -160,6 +163,7 @@ export function useDump() { fetchWindow, fetchChildren, resolveAddress, + buildReport, loading, progress, error, diff --git a/app/composables/useFrames.ts b/app/composables/useFrames.ts index 5bb7d73..2cd3dcd 100644 --- a/app/composables/useFrames.ts +++ b/app/composables/useFrames.ts @@ -1,16 +1,8 @@ import { ref, computed, shallowRef } from 'vue' import { emptyView, sameView, type ViewState } from '../lib/view-state' -/** - * The filter history: a queue of frames plus a cursor into it. - * - * This is deliberately the app's own history rather than the browser's. - * Browser history cannot be truncated — pushing after going back leaves the - * forward entries reachable by gesture — and it is shared with whatever the - * user did before arriving here, so "back" would eventually walk off the - * site mid-investigation. Owning the queue means the URL can be rewritten in - * place (replaceState) and still be exactly as linkable. - */ +export const FRAME_LIMIT = 50 + export function useFrames() { const frames = shallowRef([emptyView()]) const index = ref(0) @@ -19,25 +11,20 @@ export function useFrames() { const canBack = computed(() => index.value > 0) const canForward = computed(() => index.value < frames.value.length - 1) - /** - * Record a frame. A change made from anywhere but the newest frame discards - * everything after the cursor first: the user has branched, and the frames - * they walked back past are no longer reachable from where they now are. - * - * The equality check is what makes replay safe. Stepping back sets the - * filter refs to the frame at the cursor, which re-triggers the watchers - * that call this — and a frame identical to the current one is dropped, so - * no flag is needed to tell a replay from a real change. - */ function push(v: ViewState) { if (sameView(v, current.value)) return const kept = frames.value.slice(0, index.value + 1) kept.push(v) - frames.value = kept - index.value = kept.length - 1 + + frames.value = kept.length > FRAME_LIMIT ? kept.slice(kept.length - FRAME_LIMIT) : kept + index.value = frames.value.length - 1 + } + + function clear() { + frames.value = [current.value] + index.value = 0 } - /** Drop the whole queue — used when a different dump is opened. */ function reset(v: ViewState = emptyView()) { frames.value = [v] index.value = 0 @@ -57,6 +44,7 @@ export function useFrames() { canBack, canForward, push, + clear, reset, back: () => go(-1), forward: () => go(1), diff --git a/app/lib/dump-parse.ts b/app/lib/dump-parse.ts index f6f22a2..c63b687 100644 --- a/app/lib/dump-parse.ts +++ b/app/lib/dump-parse.ts @@ -12,17 +12,13 @@ * next line, leaving one line with no trailing groups and * one with no `[ADDR]` prefix. * - * Two things make shape 1 harder than it looks: + * Notes: * - * - ObjectPath CAN contain spaces (`... (Director BP)_C:UberGraphFrame`, - * `Default__SkyCreator:Sun Light Component`) - 5.6k lines' worth. So the - * path is not "up to the first space"; the trailing `[k: v]` groups have - * to be peeled off the RIGHT and the path is whatever is left. - * - Enum constants have no path at all, so a left-to-right scan reads the - * first group as the path and files 15k objects under a bogus `[n` root. + * - ObjectPath CAN contain spaces (`... (Director BP)_C:UberGraphFrameDefault__SkyCreator:Sun Light Component`) - 5.6k lines' worth. So the + * path is not "up to the first space"; the trailing `[k: v]` groups have to be peeled off the RIGHT and the path is whatever is left. + * - Enum constants have no path at all, so a left-to-right scan reads the first group as the path and files 15k objects under a bogus `[n` root. * - * Peeling from the right is only safe because no object path in the dump - * contains `[` or `]`; that is asserted by treating a leftover bracket in the + * Peeling from the right is only safe because no object path in the dump contains `[` or `]`; that is asserted by treating a leftover bracket in the * path region as damage rather than as text. */ @@ -56,10 +52,10 @@ export interface DumpColumns { * * rootOff: * - * Byte offset at which the real `/...` path begins. Array inner properties - * are dumped as `ComponentTags./Script/Engine.ActorComponent:ComponentTags`; - * that leading qualifier is kept for display but skipped when indexing, so - * the inner property files under its array rather than at the tree root. + * Byte offset at which the real `/...` path begins. + * + * Array inner properties are dumped as `ComponentTags./Script/Engine.ActorComponent:ComponentTags`; + * that leading qualifier is kept for display but skipped when indexing, so the inner property files under its array rather than at the tree root. */ rootOff: Uint16Array outer: Float64Array // Address from `or:` (outer) or `owr:` (owner), whichever the line carried. 0 = none. diff --git a/app/lib/dump-store.ts b/app/lib/dump-store.ts index 24e30f0..ecbe1e6 100644 --- a/app/lib/dump-store.ts +++ b/app/lib/dump-store.ts @@ -35,7 +35,7 @@ function encodeLower(s: string): Uint8Array { export class DumpStore { readonly cols: DumpColumns private decoder = new TextDecoder() - /** Row indices ordered by path bytes. Built lazily; powers the tree. */ + /** Row indices ordered by path bytes. */ private byPath: Uint32Array | null = null constructor(cols: DumpColumns) { @@ -51,11 +51,7 @@ export class DumpStore { return this.decoder.decode(pathBlob.subarray(pathStart[row]!, pathStart[row + 1]!)) } - /** - * A zero here is a real value, not a missing one: row 0 starts at - * pathStart 0, and nameOff is 0 for any path with no separator at all - * (`CIM_Linear`). Guarding with `!x` dropped both. - */ + name(row: number): string { const { pathStart, pathBlob, nameOff } = this.cols return this.decoder.decode( @@ -63,7 +59,6 @@ export class DumpStore { ) } - /** Path minus the leaf name, minus the trailing separator. */ container(row: number): string { const { pathStart, pathBlob, nameOff } = this.cols if (!nameOff[row]) return '' // the whole path is the name @@ -95,7 +90,7 @@ export class DumpStore { } } - // ---------------------------------------------------------------- matching + // --- matching // rootOff is 0 for every path that already starts with '/', which is nearly // all of them, so the old `!rootOff[row]` guard rejected everything except @@ -128,14 +123,9 @@ export class DumpStore { return false } - // ------------------------------------------------------------------ query + // --- query + - /** - * Single linear pass over every row. At 800k rows this is single-digit - * milliseconds for type filters and ~40 ms for a substring scan, which is - * why there is no inverted index here — it would cost more to maintain - * than it saves. - */ query(q: Query): { rows: Uint32Array; typeCounts: Int32Array } { const { count, typeId, types } = this.cols const typeCounts = new Int32Array(types.length) @@ -290,6 +280,30 @@ export class DumpStore { return [start, lo] } + /** + * Every row at exactly `prefix` or beneath it, in path order. + */ + subtree(prefix: string): Uint32Array { + const order = this.ensurePathOrder() + if (!prefix) return order.slice() + + const pat = new TextEncoder().encode(prefix) + const [lo, hi] = this.range(pat) + const { pathStart, pathBlob, rootOff } = this.cols + + const out = new Uint32Array(hi - lo) + let n = 0 + for (let i = lo; i < hi; i++) { + const row = order[i]! + const after = pathStart[row]! + rootOff[row]! + pat.length + const end = pathStart[row + 1]! + if (after > end) continue + const c = pathBlob[after] + if (after === end || c === SLASH || c === DOT || c === COLON) out[n++] = row + } + return out.slice(0, n) + } + private childCache = new Map() /** diff --git a/app/lib/report.ts b/app/lib/report.ts new file mode 100644 index 0000000..bc569b0 --- /dev/null +++ b/app/lib/report.ts @@ -0,0 +1,160 @@ +import type { DumpStore } from './dump-store' +import { KIND_ENUM } from './dump-parse' + +/** + * A subtree rendered as a reference sheet for writing reflection code. + * + * The audience is someone about to hook a UFunction: they need the object + * path to look it up, and the parameter layout — names, types, and byte + * offsets in declaration order — to lay out the params struct the hook + * receives. So the report is two passes over the same rows. The outline + * answers "what is in here", and the detail section answers "what do I have + * to write", with functions given the offset table that is the whole point. + */ + +const FUNCTION_TYPES = new Set(['Function', 'DelegateFunction', 'SparseDelegateFunction']) + +/** Beyond this the report stops being a reference and starts being the dump. */ +export const REPORT_CAP = 25_000 + +export interface ReportResult { + text: string + objects: number + truncated: boolean +} + +const hex16 = (n: number) => '0x' + n.toString(16).toUpperCase().padStart(16, '0') +const hexOff = (n: number) => '+0x' + n.toString(16).toUpperCase().padStart(4, '0') + +function stamp(t: number): string { + const p = (n: number) => String(n).padStart(2, '0') + const d = new Date(t) + return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}` +} + +/** + * The part of a display path that hangs below `root`. + * + * Not simply `path.slice(root.length)`. Array and map inner properties are + * dumped with a leading qualifier — `K2Node_MakeArray_Array./Game/…:UpdateHP` + * — which the store skips when indexing, so such a row is legitimately inside + * the subtree while its *displayed* path does not begin with the root at all. + * Slicing blind turns those lines into garbage at a random offset. + */ +function relativeTo(root: string, path: string): string { + if (!root) return path + const at = path.indexOf(root) + return at < 0 ? path : path.slice(at + root.length) +} + +/** Separator depth, for indenting the outline. */ +function depthOf(rel: string): number { + let d = 0 + for (let i = 0; i < rel.length; i++) { + const c = rel[i] + if (c === '/' || c === '.' || c === ':') d++ + } + return d +} + +export function buildReport( + store: DumpStore, + rows: Uint32Array, + meta: { root: string; label: string }, +): ReportResult { + const truncated = rows.length > REPORT_CAP + const used = truncated ? rows.subarray(0, REPORT_CAP) : rows + + // One pass to materialise, because both sections read the same rows and + // `store.row` decodes strings out of the path blob each time it is called. + const objs = Array.from(used, (r) => store.row(r)) + + // Children are grouped by their container so that a function can find its + // parameters without a second scan per function. + const byContainer = new Map() + for (const o of objs) { + const list = byContainer.get(o.container) + if (list) list.push(o) + else byContainer.set(o.container, [o]) + } + + const out: string[] = [] + const { root, label } = meta + + out.push('UE4SS OBJECT EXPLORER — SUBTREE REPORT') + out.push('='.repeat(72)) + out.push(`root ${root || '(everything)'}`) + out.push(`dump ${label}`) + out.push(`objects ${objs.length.toLocaleString()}${truncated ? ` (capped from ${rows.length.toLocaleString()})` : ''}`) + out.push(`generated ${stamp(Date.now())}`) + if (truncated) { + out.push('') + out.push(`NOTE This subtree holds ${rows.length.toLocaleString()} objects; only the first`) + out.push(` ${REPORT_CAP.toLocaleString()} are listed. Scope to a narrower node for a complete report.`) + } + out.push('') + + // ------------------------------------------------------------- outline + out.push('OUTLINE') + out.push('-'.repeat(72)) + for (const o of objs) { + const rel = relativeTo(root, o.path) + const bits = [`${' '.repeat(depthOf(rel))}${rel || o.path}`] + // Flag the qualifier rather than dropping it: an inner property listed + // only by its tail reads as a duplicate of the array it belongs to. + if (root && !o.path.startsWith(root)) bits.push(`(inner of ${o.path.slice(0, o.path.indexOf(root))})`) + bits.push(`· ${o.type}`) + if (o.address) bits.push(`@ ${hex16(o.address)}`) + if (o.offset >= 0) bits.push(hexOff(o.offset)) + if (o.kind === KIND_ENUM && o.value !== null) bits.push(`= ${o.value}`) + out.push(bits.join(' ')) + } + out.push('') + + // ------------------------------------------------------------ functions + const fns = objs.filter((o) => FUNCTION_TYPES.has(o.type)) + if (fns.length) { + out.push('FUNCTIONS') + out.push('-'.repeat(72)) + out.push(`${fns.length} callable${fns.length === 1 ? '' : 's'} in this subtree.`) + out.push('') + + for (const f of fns) { + // Declaration order is offset order: UE lays parameters out in the + // params struct in the order they are declared, return value last. + const params = (byContainer.get(f.path) ?? []).slice().sort((a, b) => a.offset - b.offset) + + out.push(f.path) + out.push(` name ${f.name}`) + out.push(` type ${f.type}`) + out.push(` address ${hex16(f.address)}`) + if (f.outer) out.push(` outer ${hex16(f.outer)}`) + if (!params.length) { + out.push(' params none') + } else { + out.push(` params ${params.length}`) + const w = Math.max(...params.map((p) => p.name.length), 4) + out.push(` ${'OFFSET'.padEnd(9)}${'NAME'.padEnd(w + 2)}TYPE`) + for (const p of params) { + out.push(` ${hexOff(p.offset).padEnd(9)}${p.name.padEnd(w + 2)}${p.type}`) + } + } + out.push('') + } + } + + // --------------------------------------------------------------- objects + out.push('OBJECTS') + out.push('-'.repeat(72)) + for (const o of objs) { + out.push(o.path) + out.push(` type ${o.type}`) + out.push(` address ${hex16(o.address)}`) + if (o.outer) out.push(` outer ${hex16(o.outer)}`) + if (o.offset >= 0) out.push(` offset ${hexOff(o.offset)} (${o.offset})`) + if (o.kind === KIND_ENUM && o.value !== null) out.push(` value ${o.value}`) + out.push('') + } + + return { text: out.join('\n'), objects: objs.length, truncated } +} diff --git a/app/workers/dump.worker.ts b/app/workers/dump.worker.ts index dcd2bf4..e87fa8b 100644 --- a/app/workers/dump.worker.ts +++ b/app/workers/dump.worker.ts @@ -2,6 +2,7 @@ import { parseDump } from '../lib/dump-parse' import { decodeBundle } from '../lib/bundle' import { DumpStore, type Query } from '../lib/dump-store' +import { buildReport } from '../lib/report' import { contentHash, titleFor, saveDump, loadDump } from '../lib/persist' /** @@ -10,6 +11,8 @@ import { contentHash, titleFor, saveDump, loadDump } from '../lib/persist' */ let store: DumpStore | null = null let current: Uint32Array = new Uint32Array(0) +/** Title of the open dump, so a report can name what it was taken from. */ +let currentLabel = '' type Req = | { @@ -27,6 +30,7 @@ type Req = | { id: number; op: 'window'; start: number; end: number } | { id: number; op: 'children'; prefix: string } | { id: number; op: 'resolve'; address: number } + | { id: number; op: 'report'; prefix: string } | { id: number; op: 'close' } function summary() { @@ -59,6 +63,7 @@ async function handle(msg: Req) { const cached = await loadDump(key).catch(() => undefined) if (cached) { store = new DumpStore(cached.cols) + currentLabel = cached.label return { ...summary(), key, label: cached.label, cached: true } } @@ -72,6 +77,7 @@ async function handle(msg: Req) { store = new DumpStore(cols) const parsedAt = Date.now() const label = titleFor(msg.name, parsedAt) + currentLabel = label await saveDump({ key, label, @@ -88,6 +94,7 @@ async function handle(msg: Req) { const cached = await loadDump(msg.key) if (!cached) throw new Error('That dump is no longer cached. Load the file again.') store = new DumpStore(cached.cols) + currentLabel = cached.label return { ...summary(), key: cached.key, label: cached.label, cached: true } } @@ -118,11 +125,21 @@ async function handle(msg: Req) { return { row: row >= 0 ? store!.row(row) : null } } + // Built here rather than on the main thread: the report walks the whole + // subtree and decodes a string per field, and only the finished text + // crosses back. + case 'report': { + const s = store! + const rows = s.subtree(msg.prefix) + return buildReport(s, rows, { root: msg.prefix, label: currentLabel }) + } + // Drops the only references to the columns; the dump stays in IndexedDB, // so 'restore' can bring it back without re-parsing. case 'close': { store = null current = new Uint32Array(0) + currentLabel = '' return { closed: true } } }