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 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<number | null>(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"
/>
<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
a frame, so stepping back is stepping back through views. -->
<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
:disabled="!frames.canBack.value"
title="Previous view"
@@ -479,9 +481,21 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
</dd>
</dl>
<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>
</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>
</template>
@@ -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);

View File

@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted, watch } from 'vue'
import { ref, onMounted, onBeforeUnmount, watch } from 'vue'
import type { TreeChild } from '../lib/dump-store'
const props = defineProps<{
@@ -7,18 +7,21 @@ const props = defineProps<{
fetchChildren: (prefix: string) => Promise<{ prefix: string; children: TreeChild[] }>
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
open: boolean
children: Node[] | null
children: TreeNode[] | null
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 }
}
@@ -29,7 +32,7 @@ async function loadRoots() {
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) {
node.open = false
return
@@ -43,22 +46,18 @@ async function toggle(node: Node) {
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 collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
function arrange(nodes: Node[]): Node[] {
function arrange(nodes: TreeNode[]): TreeNode[] {
const by =
order.value === 'alpha'
? (a: Node, b: Node) => collator.compare(a.segment, b.segment)
: (a: Node, b: Node) => b.count - a.count || collator.compare(a.segment, b.segment)
? (a: TreeNode, b: TreeNode) => 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)
}
function flatten(nodes: Node[], out: Node[] = []): Node[] {
function flatten(nodes: TreeNode[], out: TreeNode[] = []): TreeNode[] {
for (const n of arrange(nodes)) {
out.push(n)
if (n.open && n.children) flatten(n.children, out)
@@ -66,8 +65,66 @@ function flatten(nodes: Node[], out: Node[] = []): Node[] {
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()
onMounted(loadRoots)
onBeforeUnmount(disarm)
watch(() => props.version, loadRoots)
</script>
@@ -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)"
>
<button
class="twist"
@@ -128,6 +186,21 @@ watch(() => props.version, loadRoots)
</button>
</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>
</template>
@@ -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 <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) {
.spine {
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) =>
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,

View File

@@ -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<ViewState[]>([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),

View File

@@ -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.

View File

@@ -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<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 { 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 }
}
}