Added selective dump features

This commit is contained in:
Hunter
2026-08-22 12:16:54 -04:00
parent eef9854cc1
commit 43e9f6a14c
9 changed files with 680 additions and 111 deletions

View File

@@ -25,26 +25,13 @@ const showAllTypes = ref(false)
const dragging = ref(false) const dragging = ref(false)
const treeVersion = ref(0) const treeVersion = ref(0)
/** // pending view
* 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.
*/
const pending = ref<{ hash: string; view: ViewState } | null>(null) const pending = ref<{ hash: string; view: ViewState } | null>(null)
const fmt = new Intl.NumberFormat() 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 { function currentView(): ViewState {
return { return {
prefix: pathPrefix.value, prefix: pathPrefix.value,
@@ -78,13 +65,6 @@ function schedule() {
timer = setTimeout(commit, 120) 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() { function commit() {
const v = currentView() const v = currentView()
frames.push(v) frames.push(v)
@@ -107,8 +87,7 @@ async function run() {
watch([nameQuery, pathQuery], schedule) watch([nameQuery, pathQuery], schedule)
watch([pathPrefix, activeTypes, sort, descending], commit, { deep: true }) watch([pathPrefix, activeTypes, sort, descending], commit, { deep: true })
/** A new dump invalidates every frame: the paths and types are a different /** A new dump invalidates every frame: the paths and types are a different vocabulary, so the queue restarts rather than carrying over. */
* vocabulary, so the queue restarts rather than carrying over. */
function resetTo(v: ViewState) { function resetTo(v: ViewState) {
applyView(v) applyView(v)
inspected.value = null inspected.value = null
@@ -136,6 +115,11 @@ function setSort(key: SortKey, desc: boolean) {
descending.value = desc descending.value = desc
} }
function clearHistory() {
frames.clear()
syncUrl(currentView())
}
function step(delta: -1 | 1) { function step(delta: -1 | 1) {
const f = delta < 0 ? frames.back() : frames.forward() const f = delta < 0 ? frames.back() : frames.forward()
if (!f) return if (!f) return
@@ -184,6 +168,25 @@ function jumpTo(path: string) {
pathQuery.value = '' 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) { async function onDrop(e: DragEvent) {
dragging.value = false dragging.value = false
const file = e.dataTransfer?.files?.[0] const file = e.dataTransfer?.files?.[0]
@@ -204,25 +207,22 @@ async function forget(key: string) {
refreshCached() refreshCached()
} }
// ---------------------------------------------------------------- site dumps // --- site dumps
const grabbing = ref('') const grabbing = ref('')
const grabPct = ref<number | null>(0) const grabPct = ref<number | null>(0)
const grabError = ref('') const grabError = ref('')
/** Only dumps this browser has not already taken. The cached copy records the /** Only dumps this browser has not already taken. */
* manifest id it came from, which is what makes "already have it" answerable
* without re-hashing a 37 MB file. */
const offered = computed(() => { const offered = computed(() => {
const have = new Set(cached.value.map((c) => c.source).filter(Boolean)) const have = new Set(cached.value.map((c) => c.source).filter(Boolean))
return hosted.value.filter((h) => !have.has(h.id)) return hosted.value.filter((h) => !have.has(h.id))
}) })
/** /**
* Download on demand, never on load. One interaction fetches, caches, and * Download on demand, never on load.
* opens the dump, so the offer disappearing from this list is the same
* gesture that puts it on screen.
*/ */
async function grab(h: HostedDump) { async function grab(h: HostedDump) {
grabbing.value = h.id grabbing.value = h.id
grabError.value = '' 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() { 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() await d.reset()
dragging.value = false 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 // 'cache miss' client did not have downloaded dump
* 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.
*/
const linkMiss = ref(false) const linkMiss = ref(false)
onMounted(async () => { onMounted(async () => {
@@ -267,9 +261,7 @@ onMounted(async () => {
pending.value = { hash: dump, view } pending.value = { hash: dump, view }
if (!(await d.restore(dump))) { if (!(await d.restore(dump))) {
linkMiss.value = true linkMiss.value = true
// Not surfaced as an error: a link arriving without its dump is the // a link arriving without its dump is the expected case
// 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.
d.error.value = '' d.error.value = ''
} }
}) })
@@ -368,6 +360,7 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
v-model="pathPrefix" v-model="pathPrefix"
:fetch-children="d.fetchChildren" :fetch-children="d.fetchChildren"
:version="treeVersion" :version="treeVersion"
@dump="dumpSubtree"
/> />
<section class="main"> <section class="main">
@@ -376,6 +369,15 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
<!-- Filter history. Every change to the query surface lands here as <!-- Filter history. Every change to the query surface lands here as
a frame, so stepping back is stepping back through views. --> a frame, so stepping back is stepping back through views. -->
<div class="frames mono"> <div class="frames mono">
<button
class="wipe"
:disabled="frames.frames.value.length < 2"
title="Clear filter history"
aria-label="Clear filter history"
@click="clearHistory"
>
×
</button>
<button <button
:disabled="!frames.canBack.value" :disabled="!frames.canBack.value"
title="Previous view" title="Previous view"
@@ -479,9 +481,21 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
</dd> </dd>
</dl> </dl>
<button class="scope" @click="jumpTo(inspected.path)">Scope tree to this object</button> <button class="scope" @click="jumpTo(inspected.path)">Scope tree to this object</button>
<button class="scope" @click="dumpSubtree(inspected.path)">Dump tree under this object</button>
</aside> </aside>
</main> </main>
<ReportModal
:open="report.open"
:root="report.root"
:text="report.text"
:objects="report.objects"
:truncated="report.truncated"
:error="report.error"
:loading="reportLoading"
@close="report.open = false"
/>
<div v-if="dragging && d.ready.value" class="veil mono">Release to parse a new dump</div> <div v-if="dragging && d.ready.value" class="veil mono">Release to parse a new dump</div>
</div> </div>
</template> </template>
@@ -674,6 +688,17 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
.frames button:not(:disabled):hover { .frames button:not(:disabled):hover {
background: var(--raise); 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 { .tally {
font-size: 10px; font-size: 10px;
color: var(--dim); color: var(--dim);
@@ -834,6 +859,10 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
text-decoration: underline; text-decoration: underline;
} }
.scope + .scope {
margin-top: 6px;
}
.scope { .scope {
margin: 4px 12px 0; margin: 4px 12px 0;
width: calc(100% - 24px); width: calc(100% - 24px);

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch } from 'vue' import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import type { TreeChild } from '../lib/dump-store' import type { TreeChild } from '../lib/dump-store'
const props = defineProps<{ const props = defineProps<{
@@ -7,18 +7,21 @@ const props = defineProps<{
fetchChildren: (prefix: string) => Promise<{ prefix: string; children: TreeChild[] }> fetchChildren: (prefix: string) => Promise<{ prefix: string; children: TreeChild[] }>
version: number version: number
}>() }>()
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>() const emit = defineEmits<{
(e: 'update:modelValue', v: string): void
(e: 'dump', path: string): void
}>()
interface Node extends TreeChild { interface TreeNode extends TreeChild {
depth: number depth: number
open: boolean open: boolean
children: Node[] | null children: TreeNode[] | null
loading: boolean loading: boolean
} }
const roots = ref<Node[]>([]) const roots = ref<TreeNode[]>([])
function toNode(c: TreeChild, depth: number): Node { function toNode(c: TreeChild, depth: number): TreeNode {
return { ...c, depth, open: false, children: null, loading: false } return { ...c, depth, open: false, children: null, loading: false }
} }
@@ -29,7 +32,7 @@ async function loadRoots() {
if (roots.value.length === 1 && roots.value[0]) toggle(roots.value[0]) if (roots.value.length === 1 && roots.value[0]) toggle(roots.value[0])
} }
async function toggle(node: Node) { async function toggle(node: TreeNode) {
if (node.open) { if (node.open) {
node.open = false node.open = false
return return
@@ -43,22 +46,18 @@ async function toggle(node: Node) {
node.open = true node.open = true
} }
/**
* Ordering is applied at render rather than at fetch, so flipping it costs a re-sort of the nodes already on screen and never re-walks the store. The
* store hands back count order; `localeCompare` with numeric collation is what makes `Item_2` precede `Item_10` in the alphabetical mode.
*/
const order = ref<'count' | 'alpha'>('count') const order = ref<'count' | 'alpha'>('count')
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' }) const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
function arrange(nodes: Node[]): Node[] { function arrange(nodes: TreeNode[]): TreeNode[] {
const by = const by =
order.value === 'alpha' order.value === 'alpha'
? (a: Node, b: Node) => collator.compare(a.segment, b.segment) ? (a: TreeNode, b: TreeNode) => collator.compare(a.segment, b.segment)
: (a: Node, b: Node) => b.count - a.count || collator.compare(a.segment, b.segment) : (a: TreeNode, b: TreeNode) => b.count - a.count || collator.compare(a.segment, b.segment)
return nodes.slice().sort(by) return nodes.slice().sort(by)
} }
function flatten(nodes: Node[], out: Node[] = []): Node[] { function flatten(nodes: TreeNode[], out: TreeNode[] = []): TreeNode[] {
for (const n of arrange(nodes)) { for (const n of arrange(nodes)) {
out.push(n) out.push(n)
if (n.open && n.children) flatten(n.children, out) if (n.open && n.children) flatten(n.children, out)
@@ -66,8 +65,66 @@ function flatten(nodes: Node[], out: Node[] = []): Node[] {
return out return out
} }
// ------------------------------------------------------------ context menu
const menu = ref<{ x: number; y: number; path: string; label: string } | null>(null)
const menuEl = ref<HTMLElement | null>(null)
let armed = false
function arm() {
if (armed) return
armed = true
window.addEventListener('pointerdown', onOutside)
window.addEventListener('keydown', onKey)
window.addEventListener('resize', closeMenu)
// Capture, because the scroll that matters happens in .scroll, not on window.
window.addEventListener('scroll', closeMenu, true)
}
function disarm() {
if (!armed) return
armed = false
window.removeEventListener('pointerdown', onOutside)
window.removeEventListener('keydown', onKey)
window.removeEventListener('resize', closeMenu)
window.removeEventListener('scroll', closeMenu, true)
}
function onOutside(e: PointerEvent) {
if (menuEl.value?.contains(e.target as Node)) return
closeMenu()
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') closeMenu()
}
function openMenu(e: MouseEvent, node: TreeNode) {
// Clamped so a right-click near the bottom or right edge does not open the
// menu off-screen; the sizes are the menu's own, which is fixed content.
menu.value = {
x: Math.min(e.clientX, window.innerWidth - 240),
y: Math.min(e.clientY, window.innerHeight - 60),
path: node.path,
label: node.segment,
}
requestAnimationFrame(arm)
}
function closeMenu() {
menu.value = null
disarm()
}
function dumpFromMenu() {
if (menu.value) emit('dump', menu.value.path)
closeMenu()
}
const fmt = new Intl.NumberFormat() const fmt = new Intl.NumberFormat()
onMounted(loadRoots) onMounted(loadRoots)
onBeforeUnmount(disarm)
watch(() => props.version, loadRoots) watch(() => props.version, loadRoots)
</script> </script>
@@ -109,8 +166,9 @@ watch(() => props.version, loadRoots)
v-for="node in flatten(roots)" v-for="node in flatten(roots)"
:key="node.path" :key="node.path"
class="node mono" 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' }" :style="{ paddingLeft: 8 + node.depth * 13 + 'px' }"
@contextmenu.prevent="openMenu($event, node)"
> >
<button <button
class="twist" class="twist"
@@ -128,6 +186,21 @@ watch(() => props.version, loadRoots)
</button> </button>
</div> </div>
</div> </div>
<!-- Teleported out of the .spine, which scrolls and clips. -->
<Teleport to="body">
<div
v-if="menu"
ref="menuEl"
class="menu mono"
:style="{ left: menu.x + 'px', top: menu.y + 'px' }"
@contextmenu.prevent
>
<button @click="dumpFromMenu">
Dump tree under <b>{{ menu.label }}</b>
</button>
</div>
</Teleport>
</nav> </nav>
</template> </template>
@@ -214,6 +287,11 @@ watch(() => props.version, loadRoots)
background: var(--amber-soft); background: var(--amber-soft);
color: var(--ink-strong); 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 { .node.root {
padding-left: 21px; padding-left: 21px;
color: var(--dim); color: var(--dim);
@@ -268,6 +346,42 @@ watch(() => props.version, loadRoots)
flex: none; flex: none;
} }
/* Teleported to <body>, 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) { @media (max-width: 900px) {
.spine { .spine {
width: 100%; width: 100%;

View File

@@ -0,0 +1,247 @@
<script setup lang="ts">
import { ref, computed, watch, onBeforeUnmount } from 'vue'
const props = defineProps<{
open: boolean
root: string
text: string
objects: number
truncated: boolean
loading: boolean
error: string
}>()
const emit = defineEmits<{ (e: 'close'): void }>()
const PREVIEW_LINES = 400
const lines = computed(() => (props.text ? props.text.split('\n') : []))
const preview = computed(() => lines.value.slice(0, PREVIEW_LINES).join('\n'))
const hidden = computed(() => Math.max(0, lines.value.length - PREVIEW_LINES))
/** Last path segment, which is what names the file usefully. */
const slug = computed(() => {
const seg = props.root.split(/[/.:]/).filter(Boolean).pop() || 'objects'
return seg.replace(/[^\w.-]+/g, '_').slice(0, 60)
})
const copied = ref(false)
async function copy() {
try {
await navigator.clipboard.writeText(props.text)
copied.value = true
setTimeout(() => (copied.value = false), 1400)
} catch {
/* clipboard blocked; the download still works */
}
}
function save() {
const blob = new Blob([props.text], { type: 'text/plain;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${slug.value}.dump.txt`
a.click()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
/** Escape has to come off the window: the scrim is a plain div, so it never
* takes focus and never sees a key event of its own. */
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') emit('close')
}
watch(
() => props.open,
(v) => {
if (v) {
copied.value = false
window.addEventListener('keydown', onKey)
} else {
window.removeEventListener('keydown', onKey)
}
},
)
onBeforeUnmount(() => window.removeEventListener('keydown', onKey))
const fmt = new Intl.NumberFormat()
</script>
<template>
<div v-if="open" class="scrim" @click.self="emit('close')">
<section class="sheet">
<header>
<div class="what">
<p class="eyebrow">Subtree dump</p>
<p class="root mono" :title="root">{{ root || 'everything' }}</p>
</div>
<button class="x" aria-label="Close" @click="emit('close')">×</button>
</header>
<div class="meta mono">
<template v-if="loading">Walking the subtree…</template>
<span v-else-if="error" class="bad">{{ error }}</span>
<template v-else>
<span>{{ fmt.format(objects) }} objects</span>
<span class="sep">·</span>
<span>{{ fmt.format(lines.length) }} lines</span>
<span v-if="truncated" class="sep">·</span>
<span v-if="truncated" class="warn">capped</span>
</template>
</div>
<pre v-if="!loading && !error" class="body mono">{{ preview }}</pre>
<p v-if="hidden" class="more mono">
… {{ fmt.format(hidden) }} more lines. Download or copy for the whole report.
</p>
<footer>
<button class="act" :disabled="loading || !!error" @click="copy">
{{ copied ? 'Copied' : 'Copy' }}
</button>
<button class="act primary" :disabled="loading || !!error" @click="save">
Download .txt
</button>
</footer>
</section>
</div>
</template>
<style scoped>
.scrim {
position: fixed;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 24px;
background: color-mix(in srgb, var(--void) 78%, transparent);
}
.sheet {
display: flex;
flex-direction: column;
width: min(920px, 100%);
max-height: 100%;
background: var(--panel);
border: 1px solid var(--rule);
border-radius: 4px;
overflow: hidden;
}
header {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 14px;
border-bottom: 1px solid var(--rule);
flex: none;
}
.what {
min-width: 0;
}
.root {
margin: 4px 0 0;
font-size: 12px;
color: var(--ink-strong);
word-break: break-all;
line-height: 1.5;
}
.x {
margin-left: auto;
flex: none;
font-size: 16px;
color: var(--dim);
background: none;
border: none;
cursor: pointer;
}
.x:hover {
color: var(--t-bool);
}
.meta {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 14px;
font-size: 11px;
color: var(--dim);
border-bottom: 1px solid var(--rule-soft);
flex: none;
}
.sep {
color: var(--dimmer);
}
.warn {
color: var(--amber);
}
.bad {
color: var(--t-bool);
}
.body {
flex: 1;
min-height: 0;
overflow: auto;
margin: 0;
padding: 12px 14px;
font-size: 11px;
line-height: 1.55;
color: var(--ink);
background: var(--void);
white-space: pre;
tab-size: 2;
}
.more {
flex: none;
margin: 0;
padding: 7px 14px;
font-size: 10px;
color: var(--dimmer);
background: var(--void);
border-top: 1px solid var(--rule-soft);
}
footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 10px 14px;
border-top: 1px solid var(--rule);
flex: none;
}
.act {
font-family: inherit;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--dim);
background: none;
border: 1px solid var(--rule);
border-radius: 3px;
padding: 7px 12px;
cursor: pointer;
}
.act:hover:not(:disabled) {
color: var(--ink);
border-color: var(--dim);
}
.act.primary {
color: var(--amber);
}
.act.primary:hover:not(:disabled) {
color: var(--amber);
border-color: var(--amber);
}
.act:disabled {
color: var(--dimmer);
cursor: default;
}
</style>

View File

@@ -151,6 +151,9 @@ export function useDump() {
const resolveAddress = (address: number) => const resolveAddress = (address: number) =>
call<{ row: RowView | null }>('resolve', { address }) call<{ row: RowView | null }>('resolve', { address })
const buildReport = (prefix: string) =>
call<{ text: string; objects: number; truncated: boolean }>('report', { prefix })
return { return {
loadFile, loadFile,
loadBuffer, loadBuffer,
@@ -160,6 +163,7 @@ export function useDump() {
fetchWindow, fetchWindow,
fetchChildren, fetchChildren,
resolveAddress, resolveAddress,
buildReport,
loading, loading,
progress, progress,
error, error,

View File

@@ -1,16 +1,8 @@
import { ref, computed, shallowRef } from 'vue' import { ref, computed, shallowRef } from 'vue'
import { emptyView, sameView, type ViewState } from '../lib/view-state' import { emptyView, sameView, type ViewState } from '../lib/view-state'
/** export const FRAME_LIMIT = 50
* 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 function useFrames() { export function useFrames() {
const frames = shallowRef<ViewState[]>([emptyView()]) const frames = shallowRef<ViewState[]>([emptyView()])
const index = ref(0) const index = ref(0)
@@ -19,25 +11,20 @@ export function useFrames() {
const canBack = computed(() => index.value > 0) const canBack = computed(() => index.value > 0)
const canForward = computed(() => index.value < frames.value.length - 1) 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) { function push(v: ViewState) {
if (sameView(v, current.value)) return if (sameView(v, current.value)) return
const kept = frames.value.slice(0, index.value + 1) const kept = frames.value.slice(0, index.value + 1)
kept.push(v) 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()) { function reset(v: ViewState = emptyView()) {
frames.value = [v] frames.value = [v]
index.value = 0 index.value = 0
@@ -57,6 +44,7 @@ export function useFrames() {
canBack, canBack,
canForward, canForward,
push, push,
clear,
reset, reset,
back: () => go(-1), back: () => go(-1),
forward: () => go(1), forward: () => go(1),

View File

@@ -12,17 +12,13 @@
* next line, leaving one line with no trailing groups and * next line, leaving one line with no trailing groups and
* one with no `[ADDR]` prefix. * one with no `[ADDR]` prefix.
* *
* Two things make shape 1 harder than it looks: * Notes:
* *
* - ObjectPath CAN contain spaces (`... (Director BP)_C:UberGraphFrame`, * - ObjectPath CAN contain spaces (`... (Director BP)_C:UberGraphFrameDefault__SkyCreator:Sun Light Component`) - 5.6k lines' worth. So the
* `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.
* path is not "up to the first space"; the trailing `[k: v]` groups have * - 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.
* 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 * 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
* contains `[` or `]`; that is asserted by treating a leftover bracket in the
* path region as damage rather than as text. * path region as damage rather than as text.
*/ */
@@ -56,10 +52,10 @@ export interface DumpColumns {
* *
* rootOff: * rootOff:
* *
* Byte offset at which the real `/...` path begins. Array inner properties * Byte offset at which the real `/...` path begins.
* are dumped as `ComponentTags./Script/Engine.ActorComponent:ComponentTags`; *
* that leading qualifier is kept for display but skipped when indexing, so * Array inner properties are dumped as `ComponentTags./Script/Engine.ActorComponent:ComponentTags`;
* the inner property files under its array rather than at the tree root. * 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 rootOff: Uint16Array
outer: Float64Array // Address from `or:` (outer) or `owr:` (owner), whichever the line carried. 0 = none. outer: Float64Array // Address from `or:` (outer) or `owr:` (owner), whichever the line carried. 0 = none.

View File

@@ -35,7 +35,7 @@ function encodeLower(s: string): Uint8Array {
export class DumpStore { export class DumpStore {
readonly cols: DumpColumns readonly cols: DumpColumns
private decoder = new TextDecoder() 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 private byPath: Uint32Array | null = null
constructor(cols: DumpColumns) { constructor(cols: DumpColumns) {
@@ -51,11 +51,7 @@ export class DumpStore {
return this.decoder.decode(pathBlob.subarray(pathStart[row]!, pathStart[row + 1]!)) 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 { name(row: number): string {
const { pathStart, pathBlob, nameOff } = this.cols const { pathStart, pathBlob, nameOff } = this.cols
return this.decoder.decode( return this.decoder.decode(
@@ -63,7 +59,6 @@ export class DumpStore {
) )
} }
/** Path minus the leaf name, minus the trailing separator. */
container(row: number): string { container(row: number): string {
const { pathStart, pathBlob, nameOff } = this.cols const { pathStart, pathBlob, nameOff } = this.cols
if (!nameOff[row]) return '' // the whole path is the name 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 // 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 // all of them, so the old `!rootOff[row]` guard rejected everything except
@@ -128,14 +123,9 @@ export class DumpStore {
return false 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 } { query(q: Query): { rows: Uint32Array; typeCounts: Int32Array } {
const { count, typeId, types } = this.cols const { count, typeId, types } = this.cols
const typeCounts = new Int32Array(types.length) const typeCounts = new Int32Array(types.length)
@@ -290,6 +280,30 @@ export class DumpStore {
return [start, lo] 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<string, TreeChild[]>() private childCache = new Map<string, TreeChild[]>()
/** /**

160
app/lib/report.ts Normal file
View File

@@ -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<string, typeof objs>()
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 }
}

View File

@@ -2,6 +2,7 @@
import { parseDump } from '../lib/dump-parse' import { parseDump } from '../lib/dump-parse'
import { decodeBundle } from '../lib/bundle' import { decodeBundle } from '../lib/bundle'
import { DumpStore, type Query } from '../lib/dump-store' import { DumpStore, type Query } from '../lib/dump-store'
import { buildReport } from '../lib/report'
import { contentHash, titleFor, saveDump, loadDump } from '../lib/persist' 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 store: DumpStore | null = null
let current: Uint32Array = new Uint32Array(0) 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 = type Req =
| { | {
@@ -27,6 +30,7 @@ type Req =
| { id: number; op: 'window'; start: number; end: number } | { id: number; op: 'window'; start: number; end: number }
| { id: number; op: 'children'; prefix: string } | { id: number; op: 'children'; prefix: string }
| { id: number; op: 'resolve'; address: number } | { id: number; op: 'resolve'; address: number }
| { id: number; op: 'report'; prefix: string }
| { id: number; op: 'close' } | { id: number; op: 'close' }
function summary() { function summary() {
@@ -59,6 +63,7 @@ async function handle(msg: Req) {
const cached = await loadDump(key).catch(() => undefined) const cached = await loadDump(key).catch(() => undefined)
if (cached) { if (cached) {
store = new DumpStore(cached.cols) store = new DumpStore(cached.cols)
currentLabel = cached.label
return { ...summary(), key, label: cached.label, cached: true } return { ...summary(), key, label: cached.label, cached: true }
} }
@@ -72,6 +77,7 @@ async function handle(msg: Req) {
store = new DumpStore(cols) store = new DumpStore(cols)
const parsedAt = Date.now() const parsedAt = Date.now()
const label = titleFor(msg.name, parsedAt) const label = titleFor(msg.name, parsedAt)
currentLabel = label
await saveDump({ await saveDump({
key, key,
label, label,
@@ -88,6 +94,7 @@ async function handle(msg: Req) {
const cached = await loadDump(msg.key) const cached = await loadDump(msg.key)
if (!cached) throw new Error('That dump is no longer cached. Load the file again.') if (!cached) throw new Error('That dump is no longer cached. Load the file again.')
store = new DumpStore(cached.cols) store = new DumpStore(cached.cols)
currentLabel = cached.label
return { ...summary(), key: cached.key, label: cached.label, cached: true } 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 } 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, // Drops the only references to the columns; the dump stays in IndexedDB,
// so 'restore' can bring it back without re-parsing. // so 'restore' can bring it back without re-parsing.
case 'close': { case 'close': {
store = null store = null
current = new Uint32Array(0) current = new Uint32Array(0)
currentLabel = ''
return { closed: true } return { closed: true }
} }
} }