Added selective dump features
This commit is contained in:
117
app/app.vue
117
app/app.vue
@@ -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);
|
||||||
|
|||||||
@@ -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%;
|
||||||
|
|||||||
247
app/components/ReportModal.vue
Normal file
247
app/components/ReportModal.vue
Normal 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>
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
160
app/lib/report.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user