Added row nesting and arg/return highlighting for packages, classes and functions. Added noscript page
This commit is contained in:
BIN
Screenshot 2026-08-24 at 15-24-19 Object Dump Explorer.png
Normal file
BIN
Screenshot 2026-08-24 at 15-24-19 Object Dump Explorer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 283 KiB |
168
app/app.vue
168
app/app.vue
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch, onMounted } from 'vue'
|
import { ref, computed, watch, onMounted } from 'vue'
|
||||||
import { useDump, type RowView } from './composables/useDump'
|
import { useDump, type RowView, type Members } from './composables/useDump'
|
||||||
import { useFrames } from './composables/useFrames'
|
import { useFrames } from './composables/useFrames'
|
||||||
import { hue, abbreviate } from './lib/type-hue'
|
import { hue, abbreviate } from './lib/type-hue'
|
||||||
import { listDumps, dropDump, type CachedDump } from './lib/persist'
|
import { listDumps, dropDump, type CachedDump } from './lib/persist'
|
||||||
@@ -19,6 +19,7 @@ const sort = ref<SortKey>('natural')
|
|||||||
const descending = ref(false)
|
const descending = ref(false)
|
||||||
const inspected = ref<RowView | null>(null)
|
const inspected = ref<RowView | null>(null)
|
||||||
const outerOf = ref<RowView | null>(null)
|
const outerOf = ref<RowView | null>(null)
|
||||||
|
const members = ref<Members | null>(null)
|
||||||
const cached = ref<CachedDump[]>([])
|
const cached = ref<CachedDump[]>([])
|
||||||
const hosted = ref<HostedDump[]>([])
|
const hosted = ref<HostedDump[]>([])
|
||||||
const showAllTypes = ref(false)
|
const showAllTypes = ref(false)
|
||||||
@@ -92,6 +93,7 @@ function resetTo(v: ViewState) {
|
|||||||
applyView(v)
|
applyView(v)
|
||||||
inspected.value = null
|
inspected.value = null
|
||||||
outerOf.value = null
|
outerOf.value = null
|
||||||
|
members.value = null
|
||||||
showAllTypes.value = false
|
showAllTypes.value = false
|
||||||
treeVersion.value++
|
treeVersion.value++
|
||||||
frames.reset(currentView())
|
frames.reset(currentView())
|
||||||
@@ -156,12 +158,48 @@ const shownChips = computed(() =>
|
|||||||
showAllTypes.value ? typeChips.value : typeChips.value.slice(0, 14),
|
showAllTypes.value ? typeChips.value : typeChips.value.slice(0, 14),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
let inspectSeq = 0
|
||||||
async function inspect(row: RowView) {
|
async function inspect(row: RowView) {
|
||||||
|
const seq = ++inspectSeq
|
||||||
inspected.value = row
|
inspected.value = row
|
||||||
outerOf.value = null
|
outerOf.value = null
|
||||||
if (row.outer) outerOf.value = (await d.resolveAddress(row.outer)).row
|
const [owner, m] = await Promise.all([
|
||||||
|
row.outer ? d.resolveAddress(row.outer).then((r) => r.row) : null,
|
||||||
|
d.fetchMembers(row.index),
|
||||||
|
])
|
||||||
|
// A quicker click elsewhere wins; a slow answer must not overwrite it.
|
||||||
|
if (seq !== inspectSeq) return
|
||||||
|
outerOf.value = owner
|
||||||
|
members.value = m
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeInspector() {
|
||||||
|
inspected.value = null
|
||||||
|
members.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The inspector's member list, split the way a reader uses it: for a function,
|
||||||
|
* what you pass, what you get back, and the Blueprint temporaries last; for a
|
||||||
|
* class or struct, its properties and then its functions.
|
||||||
|
*/
|
||||||
|
const memberSections = computed(() => {
|
||||||
|
const m = members.value
|
||||||
|
if (!m?.head) return []
|
||||||
|
const all = m.members
|
||||||
|
if (m.head.role === 'function') {
|
||||||
|
return [
|
||||||
|
{ label: 'Params', rows: all.filter((r) => r.role === 'param'), dim: false },
|
||||||
|
{ label: 'Returns', rows: all.filter((r) => r.role === 'return'), dim: false },
|
||||||
|
{ label: 'Locals', rows: all.filter((r) => r.role === 'local'), dim: true },
|
||||||
|
].filter((s) => s.rows.length)
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ label: 'Properties', rows: all.filter((r) => r.role !== 'function'), dim: false },
|
||||||
|
{ label: 'Functions', rows: all.filter((r) => r.role === 'function'), dim: false },
|
||||||
|
].filter((s) => s.rows.length)
|
||||||
|
})
|
||||||
|
|
||||||
function jumpTo(path: string) {
|
function jumpTo(path: string) {
|
||||||
pathPrefix.value = path
|
pathPrefix.value = path
|
||||||
nameQuery.value = ''
|
nameQuery.value = ''
|
||||||
@@ -438,16 +476,26 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
|
|||||||
|
|
||||||
<ObjectTable
|
<ObjectTable
|
||||||
:total="d.total.value"
|
:total="d.total.value"
|
||||||
|
:revision="d.revision.value"
|
||||||
:shared-digits="d.sharedAddressDigits.value"
|
:shared-digits="d.sharedAddressDigits.value"
|
||||||
:sort="sort"
|
:sort="sort"
|
||||||
:descending="descending"
|
:descending="descending"
|
||||||
|
:selected-index="inspected?.index ?? -1"
|
||||||
|
:selected-group="inspected?.group ?? -1"
|
||||||
:fetch-window="d.fetchWindow"
|
:fetch-window="d.fetchWindow"
|
||||||
@inspect="inspect"
|
@inspect="inspect"
|
||||||
@sort="setSort"
|
@sort="setSort"
|
||||||
|
@scope="jumpTo"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<footer class="status mono">
|
<footer class="status mono">
|
||||||
<span>{{ fmt.format(d.total.value) }} / {{ fmt.format(d.count.value) }} shown</span>
|
<span>{{ fmt.format(d.matches.value) }} / {{ fmt.format(d.count.value) }} shown</span>
|
||||||
|
<template v-if="d.total.value > d.matches.value">
|
||||||
|
<span class="sep">·</span>
|
||||||
|
<span :title="'Owners listed so each match appears under its parent'">
|
||||||
|
+{{ fmt.format(d.total.value - d.matches.value) }} owners
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
<span class="sep">·</span>
|
<span class="sep">·</span>
|
||||||
<span>{{ d.queryMs.value.toFixed(0) }} ms</span>
|
<span>{{ d.queryMs.value.toFixed(0) }} ms</span>
|
||||||
<span v-if="d.parseMs.value" class="sep">·</span>
|
<span v-if="d.parseMs.value" class="sep">·</span>
|
||||||
@@ -459,7 +507,7 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
|
|||||||
<aside v-if="inspected" class="inspect">
|
<aside v-if="inspected" class="inspect">
|
||||||
<div class="inspect-head">
|
<div class="inspect-head">
|
||||||
<span class="eyebrow">Object</span>
|
<span class="eyebrow">Object</span>
|
||||||
<button @click="inspected = null">×</button>
|
<button @click="closeInspector">×</button>
|
||||||
</div>
|
</div>
|
||||||
<dl class="mono">
|
<dl class="mono">
|
||||||
<dt>type</dt>
|
<dt>type</dt>
|
||||||
@@ -480,6 +528,42 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
|
|||||||
<button class="link" @click="jumpTo(outerOf.path)">{{ outerOf.path }}</button>
|
<button class="link" @click="jumpTo(outerOf.path)">{{ outerOf.path }}</button>
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
|
|
||||||
|
<!-- Signature for a function (or the function a parameter belongs
|
||||||
|
to); member list for a class or struct. -->
|
||||||
|
<section v-if="members?.head" class="members mono">
|
||||||
|
<div class="members-head">
|
||||||
|
<span class="eyebrow">{{ members.head.role === 'function' ? 'Signature' : 'Members' }}</span>
|
||||||
|
<button
|
||||||
|
v-if="members.head.index !== inspected.index"
|
||||||
|
class="link"
|
||||||
|
:title="members.head.path"
|
||||||
|
@click="inspect(members.head)"
|
||||||
|
>
|
||||||
|
{{ members.head.name }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!memberSections.length" class="members-none">none</p>
|
||||||
|
<div v-for="s in memberSections" :key="s.label" class="members-group" :class="{ dim: s.dim }">
|
||||||
|
<p class="members-label">{{ s.label }} <b>{{ s.rows.length }}</b></p>
|
||||||
|
<button
|
||||||
|
v-for="m in s.rows"
|
||||||
|
:key="m.index"
|
||||||
|
class="member"
|
||||||
|
:class="{ on: m.index === inspected.index }"
|
||||||
|
:title="m.path"
|
||||||
|
@click="inspect(m)"
|
||||||
|
>
|
||||||
|
<span class="m-off">{{ m.offset < 0 ? '' : '+' + m.offset.toString(16).toUpperCase() }}</span>
|
||||||
|
<span class="m-name">{{ m.name }}</span>
|
||||||
|
<span class="m-type" :style="{ color: hue(m.type) }">{{ abbreviate(m.type) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="members.total > members.members.length" class="members-none">
|
||||||
|
+{{ fmt.format(members.total - members.members.length) }} more not listed
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<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>
|
<button class="scope" @click="dumpSubtree(inspected.path)">Dump tree under this object</button>
|
||||||
</aside>
|
</aside>
|
||||||
@@ -859,6 +943,82 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.members {
|
||||||
|
margin: 0 12px 14px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--rule);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.members-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
.members-head .link {
|
||||||
|
font-size: 11px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.members-none {
|
||||||
|
color: var(--dimmer);
|
||||||
|
margin: 6px 0 0;
|
||||||
|
}
|
||||||
|
.members-label {
|
||||||
|
margin: 10px 0 3px;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--dimmer);
|
||||||
|
}
|
||||||
|
.members-label b {
|
||||||
|
font-weight: 400;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
.member {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 3px 4px;
|
||||||
|
text-align: left;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.member:hover {
|
||||||
|
background: var(--raise);
|
||||||
|
}
|
||||||
|
.member.on {
|
||||||
|
background: var(--raise);
|
||||||
|
box-shadow: inset 2px 0 0 var(--amber);
|
||||||
|
}
|
||||||
|
.m-off {
|
||||||
|
flex: none;
|
||||||
|
width: 4ch;
|
||||||
|
text-align: right;
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
.m-name {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
color: var(--ink-strong);
|
||||||
|
}
|
||||||
|
.m-type {
|
||||||
|
flex: none;
|
||||||
|
max-width: 40%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.members-group.dim .m-name,
|
||||||
|
.members-group.dim .m-type {
|
||||||
|
color: var(--dim) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.scope + .scope {
|
.scope + .scope {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,17 +6,34 @@ import { hue, abbreviate } from '../lib/type-hue'
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
total: number
|
total: number
|
||||||
|
/** Changes on every query, including a re-sort that leaves `total` alone. */
|
||||||
|
revision: number
|
||||||
sharedDigits: number
|
sharedDigits: number
|
||||||
sort: SortKey
|
sort: SortKey
|
||||||
descending: boolean
|
descending: boolean
|
||||||
|
/** Row id (not position) of the inspected row, so it survives a re-sort. */
|
||||||
|
selectedIndex: number
|
||||||
|
selectedGroup: number
|
||||||
fetchWindow: (start: number, end: number) => Promise<{ start: number; rows: RowView[] }>
|
fetchWindow: (start: number, end: number) => Promise<{ start: number; rows: RowView[] }>
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'inspect', row: RowView): void
|
(e: 'inspect', row: RowView): void
|
||||||
(e: 'sort', key: SortKey, descending: boolean): void
|
(e: 'sort', key: SortKey, descending: boolean): void
|
||||||
|
(e: 'scope', path: string): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Nesting shifts the whole row right, so a nested row sits visibly askew of
|
||||||
|
* its parent. CONTAINER absorbs the shift, which keeps the right edge (OFF)
|
||||||
|
* aligned and means depth never widens the table past the room reserved for
|
||||||
|
* MAX_DEPTH. Beyond that the indent stops and a `+n` marker carries the rest.
|
||||||
|
*/
|
||||||
|
const MAX_DEPTH = 3
|
||||||
|
const INDENT = 14
|
||||||
|
|
||||||
|
const indentPx = (row: RowView | null) => Math.min(row?.depth ?? 0, MAX_DEPTH) * INDENT
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sorting is free here, and that is not obvious given the table streams its
|
* Sorting is free here, and that is not obvious given the table streams its
|
||||||
* rows in windows.
|
* rows in windows.
|
||||||
@@ -71,7 +88,8 @@ const vars = computed(() => {
|
|||||||
'--w-off': w.off + 'px',
|
'--w-off': w.off + 'px',
|
||||||
// Header and rows both take this as a *minimum*: narrower than the
|
// Header and rows both take this as a *minimum*: narrower than the
|
||||||
// viewport and CONTAINER absorbs the slack, wider and the table scrolls.
|
// viewport and CONTAINER absorbs the slack, wider and the table scrolls.
|
||||||
'--row-w': sum + GAP * (COLUMNS.length - 1) + PAD * 2 + 'px',
|
// The deepest indent is reserved up front so a nested row never overhangs.
|
||||||
|
'--row-w': sum + GAP * (COLUMNS.length - 1) + PAD * 2 + MAX_DEPTH * INDENT + 'px',
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -136,7 +154,6 @@ const viewport = ref<HTMLElement | null>(null)
|
|||||||
const scrollTop = ref(0)
|
const scrollTop = ref(0)
|
||||||
const height = ref(600)
|
const height = ref(600)
|
||||||
const buffer = ref<{ start: number; rows: RowView[] }>({ start: 0, rows: [] })
|
const buffer = ref<{ start: number; rows: RowView[] }>({ start: 0, rows: [] })
|
||||||
const selected = ref(-1)
|
|
||||||
let generation = 0
|
let generation = 0
|
||||||
|
|
||||||
const firstVisible = computed(() => Math.max(0, Math.floor(scrollTop.value / ROW_H) - OVERSCAN))
|
const firstVisible = computed(() => Math.max(0, Math.floor(scrollTop.value / ROW_H) - OVERSCAN))
|
||||||
@@ -183,15 +200,25 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.total,
|
() => props.revision,
|
||||||
() => {
|
() => {
|
||||||
if (viewport.value) viewport.value.scrollTop = 0
|
if (viewport.value) viewport.value.scrollTop = 0
|
||||||
scrollTop.value = 0
|
scrollTop.value = 0
|
||||||
selected.value = -1
|
|
||||||
refill()
|
refill()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function rowClass(row: RowView | null) {
|
||||||
|
if (!row) return { pending: true }
|
||||||
|
return {
|
||||||
|
sel: row.index === props.selectedIndex,
|
||||||
|
grp: props.selectedGroup >= 0 && row.group === props.selectedGroup,
|
||||||
|
ctx: row.ctx,
|
||||||
|
local: row.role === 'local',
|
||||||
|
pkg: row.role === 'package',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const visible = computed(() => {
|
const visible = computed(() => {
|
||||||
const { start, rows } = buffer.value
|
const { start, rows } = buffer.value
|
||||||
const out: { i: number; row: RowView | null }[] = []
|
const out: { i: number; row: RowView | null }[] = []
|
||||||
@@ -220,6 +247,10 @@ const pathPx = ref(420)
|
|||||||
const charPx = ref(7.2)
|
const charPx = ref(7.2)
|
||||||
const containerMax = computed(() => Math.max(8, Math.floor((pathPx.value - 2) / charPx.value)))
|
const containerMax = computed(() => Math.max(8, Math.floor((pathPx.value - 2) / charPx.value)))
|
||||||
|
|
||||||
|
/** An indented row's CONTAINER is narrower than the header cell it was measured from. */
|
||||||
|
const rowContainerMax = (row: RowView) =>
|
||||||
|
Math.max(8, containerMax.value - Math.ceil(indentPx(row) / charPx.value))
|
||||||
|
|
||||||
function tail(s: string, max: number) {
|
function tail(s: string, max: number) {
|
||||||
return s.length <= max ? s : '…' + s.slice(s.length - max + 1)
|
return s.length <= max ? s : '…' + s.slice(s.length - max + 1)
|
||||||
}
|
}
|
||||||
@@ -287,10 +318,11 @@ function measureChar() {
|
|||||||
v-for="cell in visible"
|
v-for="cell in visible"
|
||||||
:key="cell.i"
|
:key="cell.i"
|
||||||
class="row mono"
|
class="row mono"
|
||||||
:class="{ sel: selected === cell.i, pending: !cell.row }"
|
:class="rowClass(cell.row)"
|
||||||
:style="{ top: cell.i * ROW_H + 'px' }"
|
:style="{ top: cell.i * ROW_H + 'px', paddingLeft: PAD + indentPx(cell.row) + 'px' }"
|
||||||
@click="cell.row && ((selected = cell.i), emit('inspect', cell.row))"
|
@click="cell.row && emit('inspect', cell.row)"
|
||||||
>
|
>
|
||||||
|
<i v-if="indentPx(cell.row)" class="indent" :style="{ width: indentPx(cell.row) + 'px' }" />
|
||||||
<template v-if="cell.row">
|
<template v-if="cell.row">
|
||||||
<span class="col-addr">
|
<span class="col-addr">
|
||||||
<i class="ghost">{{ hex(cell.row.address).slice(0, sharedDigits) }}</i
|
<i class="ghost">{{ hex(cell.row.address).slice(0, sharedDigits) }}</i
|
||||||
@@ -304,9 +336,22 @@ function measureChar() {
|
|||||||
<i class="tick" :style="{ background: hue(cell.row.type) }" />
|
<i class="tick" :style="{ background: hue(cell.row.type) }" />
|
||||||
{{ abbreviate(cell.row.type) }}
|
{{ abbreviate(cell.row.type) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="col-name" :title="cell.row.name">{{ cell.row.name }}</span>
|
<span class="col-name" :title="cell.row.name">
|
||||||
|
<i v-if="(cell.row.depth ?? 0) > MAX_DEPTH" class="deep">+{{ cell.row.depth! - MAX_DEPTH }}</i>
|
||||||
|
<i v-if="cell.row.role === 'return'" class="ret" title="Return value">→</i>
|
||||||
|
<span class="label">{{ cell.row.name }}</span>
|
||||||
|
<button
|
||||||
|
v-if="cell.row.role === 'package'"
|
||||||
|
type="button"
|
||||||
|
class="pkg-scope"
|
||||||
|
title="Scope the tree to this package"
|
||||||
|
@click.stop="emit('scope', cell.row.path)"
|
||||||
|
>
|
||||||
|
scope
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
<span class="col-path" :title="cell.row.path">{{
|
<span class="col-path" :title="cell.row.path">{{
|
||||||
tail(cell.row.container, containerMax)
|
tail(cell.row.container, rowContainerMax(cell.row))
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="col-off">{{
|
<span class="col-off">{{
|
||||||
cell.row.offset < 0 ? '' : '0x' + cell.row.offset.toString(16).toUpperCase()
|
cell.row.offset < 0 ? '' : '0x' + cell.row.offset.toString(16).toUpperCase()
|
||||||
@@ -465,6 +510,36 @@ function measureChar() {
|
|||||||
opacity: 0.25;
|
opacity: 0.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* The selected row's function / class group, as one unit. Under .sel so the
|
||||||
|
selected row itself keeps its stronger fill. */
|
||||||
|
.row.grp {
|
||||||
|
background: color-mix(in srgb, var(--amber) 6%, transparent);
|
||||||
|
}
|
||||||
|
.row.sel {
|
||||||
|
background: var(--raise);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Owners pulled in so a match is not shown without its parent. */
|
||||||
|
.row.ctx > * {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Blueprint frame temporaries: real, but rarely what you are looking for. */
|
||||||
|
.row.local .col-name,
|
||||||
|
.row.local .col-type {
|
||||||
|
color: var(--dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Packages are not parents in the table; they are places to scope to. */
|
||||||
|
.row.pkg {
|
||||||
|
background: color-mix(in srgb, var(--t-container) 7%, transparent);
|
||||||
|
box-shadow: inset 2px 0 0 var(--t-container);
|
||||||
|
}
|
||||||
|
.row.pkg.sel {
|
||||||
|
background: var(--raise);
|
||||||
|
box-shadow: inset 2px 0 0 var(--amber);
|
||||||
|
}
|
||||||
|
|
||||||
/* Every width comes from the custom properties the script maintains, so a
|
/* Every width comes from the custom properties the script maintains, so a
|
||||||
drag moves the header cell and all ~60 live rows in one recalculation. */
|
drag moves the header cell and all ~60 live rows in one recalculation. */
|
||||||
|
|
||||||
@@ -502,9 +577,61 @@ function measureChar() {
|
|||||||
flex: none;
|
flex: none;
|
||||||
color: var(--ink-strong);
|
color: var(--ink-strong);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
.col-name .label {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* One faint guide per level in the row's indent gutter, drawn rather than
|
||||||
|
nested so a row costs nothing extra to render however deep it sits. */
|
||||||
|
.indent {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 12px;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
to right,
|
||||||
|
transparent 0 5px,
|
||||||
|
var(--rule) 5px 6px,
|
||||||
|
transparent 6px 14px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
.deep {
|
||||||
|
flex: none;
|
||||||
|
font-style: normal;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--dim);
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
.ret {
|
||||||
|
flex: none;
|
||||||
|
font-style: normal;
|
||||||
|
color: var(--t-callable);
|
||||||
|
margin-right: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pkg-scope {
|
||||||
|
flex: none;
|
||||||
|
margin-left: 8px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 9px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--t-container);
|
||||||
|
border: 1px solid color-mix(in srgb, var(--t-container) 40%, transparent);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.pkg-scope:hover {
|
||||||
|
border-color: var(--t-container);
|
||||||
|
color: var(--ink-strong);
|
||||||
|
}
|
||||||
|
|
||||||
/* The one column that grows: on a window wider than the columns need, the
|
/* The one column that grows: on a window wider than the columns need, the
|
||||||
slack goes to the longest strings in the table rather than to dead space. */
|
slack goes to the longest strings in the table rather than to dead space. */
|
||||||
.col-path {
|
.col-path {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { ref, shallowRef, computed } from 'vue'
|
import { ref, shallowRef, computed } from 'vue'
|
||||||
import type { Query } from '../lib/dump-store'
|
import type { Query, Role } from '../lib/dump-store'
|
||||||
import type { TreeChild } from '../lib/dump-store'
|
import type { TreeChild } from '../lib/dump-store'
|
||||||
|
|
||||||
export interface RowView {
|
export interface RowView {
|
||||||
@@ -12,6 +12,20 @@ export interface RowView {
|
|||||||
container: string
|
container: string
|
||||||
outer: number
|
outer: number
|
||||||
offset: number
|
offset: number
|
||||||
|
role: Role
|
||||||
|
/** Row whose selection highlights this one as a unit. */
|
||||||
|
group: number
|
||||||
|
/** Nesting depth in the current view; only set on table rows. */
|
||||||
|
depth?: number
|
||||||
|
/** Shown only as the owner of a match, not as a match itself. */
|
||||||
|
ctx?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Members {
|
||||||
|
head: RowView | null
|
||||||
|
members: RowView[]
|
||||||
|
/** Before the inspector cap. */
|
||||||
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
let worker: Worker | null = null
|
let worker: Worker | null = null
|
||||||
@@ -28,7 +42,12 @@ const skipped = ref(0)
|
|||||||
const types = shallowRef<string[]>([])
|
const types = shallowRef<string[]>([])
|
||||||
const typeCounts = shallowRef<Int32Array>(new Int32Array(0))
|
const typeCounts = shallowRef<Int32Array>(new Int32Array(0))
|
||||||
const sharedAddressDigits = ref(0)
|
const sharedAddressDigits = ref(0)
|
||||||
|
/** Display rows, including owners shown for context. */
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
|
/** Rows that matched the query. */
|
||||||
|
const matches = ref(0)
|
||||||
|
/** Bumped per query, so the table refetches even when `total` happens not to change (a re-sort). */
|
||||||
|
const revision = ref(0)
|
||||||
const queryMs = ref(0)
|
const queryMs = ref(0)
|
||||||
const parseMs = ref(0)
|
const parseMs = ref(0)
|
||||||
|
|
||||||
@@ -114,6 +133,7 @@ export function useDump() {
|
|||||||
skipped.value = 0
|
skipped.value = 0
|
||||||
sharedAddressDigits.value = 0
|
sharedAddressDigits.value = 0
|
||||||
total.value = 0
|
total.value = 0
|
||||||
|
matches.value = 0
|
||||||
queryMs.value = 0
|
queryMs.value = 0
|
||||||
parseMs.value = 0
|
parseMs.value = 0
|
||||||
// Last: `ready` and the app's reset watcher both hang off this.
|
// Last: `ready` and the app's reset watcher both hang off this.
|
||||||
@@ -137,8 +157,10 @@ export function useDump() {
|
|||||||
}
|
}
|
||||||
const r = await call('query', { query: plain })
|
const r = await call('query', { query: plain })
|
||||||
total.value = r.total
|
total.value = r.total
|
||||||
|
matches.value = r.matches
|
||||||
typeCounts.value = r.typeCounts
|
typeCounts.value = r.typeCounts
|
||||||
queryMs.value = r.ms
|
queryMs.value = r.ms
|
||||||
|
revision.value++
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -151,6 +173,8 @@ export function useDump() {
|
|||||||
const resolveAddress = (address: number) =>
|
const resolveAddress = (address: number) =>
|
||||||
call<{ row: RowView | null }>('resolve', { address })
|
call<{ row: RowView | null }>('resolve', { address })
|
||||||
|
|
||||||
|
const fetchMembers = (row: number) => call<Members>('members', { row })
|
||||||
|
|
||||||
const buildReport = (prefix: string) =>
|
const buildReport = (prefix: string) =>
|
||||||
call<{ text: string; objects: number; truncated: boolean }>('report', { prefix })
|
call<{ text: string; objects: number; truncated: boolean }>('report', { prefix })
|
||||||
|
|
||||||
@@ -163,6 +187,7 @@ export function useDump() {
|
|||||||
fetchWindow,
|
fetchWindow,
|
||||||
fetchChildren,
|
fetchChildren,
|
||||||
resolveAddress,
|
resolveAddress,
|
||||||
|
fetchMembers,
|
||||||
buildReport,
|
buildReport,
|
||||||
loading,
|
loading,
|
||||||
progress,
|
progress,
|
||||||
@@ -175,6 +200,8 @@ export function useDump() {
|
|||||||
typeCounts,
|
typeCounts,
|
||||||
sharedAddressDigits,
|
sharedAddressDigits,
|
||||||
total,
|
total,
|
||||||
|
matches,
|
||||||
|
revision,
|
||||||
queryMs,
|
queryMs,
|
||||||
parseMs,
|
parseMs,
|
||||||
ready: computed(() => count.value > 0),
|
ready: computed(() => count.value > 0),
|
||||||
|
|||||||
@@ -7,6 +7,64 @@ const SLASH = 0x2f
|
|||||||
/** `natural` is dump order — the order the lines appeared in the file. */
|
/** `natural` is dump order — the order the lines appeared in the file. */
|
||||||
export type SortKey = 'natural' | 'path' | 'address' | 'offset' | 'name' | 'type'
|
export type SortKey = 'natural' | 'path' | 'address' | 'offset' | 'name' | 'type'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a row is *for*, as far as nesting and display care.
|
||||||
|
*
|
||||||
|
* `param` / `return` / `local` only apply to properties owned by a function.
|
||||||
|
* The dump carries no CPF_Parm flags, so they are told apart by name: native
|
||||||
|
* functions name their return `ReturnValue`, while Blueprint functions also
|
||||||
|
* own frame temporaries — `CallFunc_X_ReturnValue` holds the result of a call
|
||||||
|
* to X, it is *not* this function's return — prefixed as below.
|
||||||
|
*/
|
||||||
|
export const ROLES = [
|
||||||
|
'object',
|
||||||
|
'package',
|
||||||
|
'class',
|
||||||
|
'struct',
|
||||||
|
'function',
|
||||||
|
'member',
|
||||||
|
'param',
|
||||||
|
'return',
|
||||||
|
'local',
|
||||||
|
] as const
|
||||||
|
export type Role = (typeof ROLES)[number]
|
||||||
|
|
||||||
|
const R_OBJECT = 0
|
||||||
|
const R_PACKAGE = 1
|
||||||
|
const R_CLASS = 2
|
||||||
|
const R_STRUCT = 3
|
||||||
|
const R_FUNCTION = 4
|
||||||
|
const R_MEMBER = 5
|
||||||
|
const R_PARAM = 6
|
||||||
|
const R_RETURN = 7
|
||||||
|
const R_LOCAL = 8
|
||||||
|
|
||||||
|
const FUNCTION_TYPES = new Set(['Function', 'DelegateFunction', 'SparseDelegateFunction'])
|
||||||
|
const LOCAL_PREFIXES = ['CallFunc_', 'K2Node_', 'Temp_']
|
||||||
|
|
||||||
|
function typeRole(type: string): number {
|
||||||
|
if (type === 'Package') return R_PACKAGE
|
||||||
|
if (type === 'Class' || type.endsWith('GeneratedClass')) return R_CLASS
|
||||||
|
if (type === 'ScriptStruct' || type === 'UserDefinedStruct') return R_STRUCT
|
||||||
|
if (FUNCTION_TYPES.has(type)) return R_FUNCTION
|
||||||
|
if (type.endsWith('Property')) return R_MEMBER
|
||||||
|
return R_OBJECT
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ownership chains in the dump top out at 14; anything longer is a cycle. */
|
||||||
|
const MAX_CHAIN = 64
|
||||||
|
|
||||||
|
export interface Nested {
|
||||||
|
/** Display order: every match plus the owners that give it context. */
|
||||||
|
rows: Uint32Array
|
||||||
|
/** True nesting depth per display row. The table clamps the indent. */
|
||||||
|
depth: Uint8Array
|
||||||
|
/** 1 where the row is shown only as a match's owner, not as a match. */
|
||||||
|
ctx: Uint8Array
|
||||||
|
/** Rows that actually matched the query. */
|
||||||
|
matches: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface Query {
|
export interface Query {
|
||||||
typeIds?: number[] // Type ids to keep. Empty/undefined = all types.
|
typeIds?: number[] // Type ids to keep. Empty/undefined = all types.
|
||||||
pathPrefix?: string // Exact prefix on the object path, e.g. "/Script/Engine".
|
pathPrefix?: string // Exact prefix on the object path, e.g. "/Script/Engine".
|
||||||
@@ -87,6 +145,114 @@ export class DumpStore {
|
|||||||
kind,
|
kind,
|
||||||
// Only meaningful on enum constants; i64, so it prints rather than maths.
|
// Only meaningful on enum constants; i64, so it prints rather than maths.
|
||||||
value: kind === KIND_ENUM ? this.cols.value[index]! : null,
|
value: kind === KIND_ENUM ? this.cols.value[index]! : null,
|
||||||
|
role: this.role(index),
|
||||||
|
group: this.groupOf(index),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- ownership
|
||||||
|
|
||||||
|
/** Row of each row's `or:`/`owr:` owner, or -1. */
|
||||||
|
private parent: Int32Array | null = null
|
||||||
|
private roles: Uint8Array | null = null
|
||||||
|
/** Owned rows per owner, in dump order: kidList[kidStart[r] .. kidStart[r + 1]). */
|
||||||
|
private kidStart: Uint32Array | null = null
|
||||||
|
private kidList: Uint32Array | null = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One linear pass over the owner pointers. Derived rather than stored, so
|
||||||
|
* cached dumps and `.pdx` bundles from before this existed still load.
|
||||||
|
*/
|
||||||
|
private ensureOwnership() {
|
||||||
|
if (this.parent) return
|
||||||
|
const { count, outer, typeId, types } = this.cols
|
||||||
|
|
||||||
|
const parent = new Int32Array(count).fill(-1)
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const p = this.rowByAddress(outer[i]!)
|
||||||
|
if (p >= 0 && p !== i) parent[i] = p
|
||||||
|
}
|
||||||
|
// UE ownership is a tree, but a damaged line or a reused address could
|
||||||
|
// still close a loop, and every walk below assumes one cannot happen.
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
let p = parent[i]!
|
||||||
|
let steps = 0
|
||||||
|
while (p >= 0 && ++steps < MAX_CHAIN) p = parent[p]!
|
||||||
|
if (steps >= MAX_CHAIN) parent[i] = -1
|
||||||
|
}
|
||||||
|
|
||||||
|
const byType = Uint8Array.from(types, typeRole)
|
||||||
|
const roles = new Uint8Array(count)
|
||||||
|
for (let i = 0; i < count; i++) roles[i] = byType[typeId[i]!]!
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const p = parent[i]!
|
||||||
|
if (roles[i] !== R_MEMBER || p < 0 || roles[p] !== R_FUNCTION) continue
|
||||||
|
const name = this.name(i)
|
||||||
|
roles[i] =
|
||||||
|
name === 'ReturnValue'
|
||||||
|
? R_RETURN
|
||||||
|
: LOCAL_PREFIXES.some((s) => name.startsWith(s))
|
||||||
|
? R_LOCAL
|
||||||
|
: R_PARAM
|
||||||
|
}
|
||||||
|
|
||||||
|
const kidStart = new Uint32Array(count + 1)
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
const p = parent[i]!
|
||||||
|
if (p >= 0) kidStart[p + 1] = kidStart[p + 1]! + 1
|
||||||
|
}
|
||||||
|
for (let i = 0; i < count; i++) kidStart[i + 1] = kidStart[i + 1]! + kidStart[i]!
|
||||||
|
const fill = kidStart.slice(0, count)
|
||||||
|
const kidList = new Uint32Array(kidStart[count]!)
|
||||||
|
for (let i = 0; i < count; i++) if (parent[i]! >= 0) kidList[fill[parent[i]!]!++] = i
|
||||||
|
|
||||||
|
this.parent = parent
|
||||||
|
this.roles = roles
|
||||||
|
this.kidStart = kidStart
|
||||||
|
this.kidList = kidList
|
||||||
|
}
|
||||||
|
|
||||||
|
role(row: number): Role {
|
||||||
|
this.ensureOwnership()
|
||||||
|
return ROLES[this.roles![row]!]!
|
||||||
|
}
|
||||||
|
|
||||||
|
ownerOf(row: number): number {
|
||||||
|
this.ensureOwnership()
|
||||||
|
return this.parent![row]!
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rows owned directly by `row`, in dump order. */
|
||||||
|
members(row: number): Uint32Array {
|
||||||
|
this.ensureOwnership()
|
||||||
|
return this.kidList!.subarray(this.kidStart![row]!, this.kidStart![row + 1]!)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owner for display purposes. Packages are never shown as parents — they
|
||||||
|
* are scoped to instead — so whatever a package owns is a top-level row.
|
||||||
|
*/
|
||||||
|
private nestParent(row: number): number {
|
||||||
|
const p = this.parent![row]!
|
||||||
|
return p < 0 || this.roles![p] === R_PACKAGE ? -1 : p
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The row whose selection highlights `row` as one unit: the nearest
|
||||||
|
* function, class or struct at or above it, else its top-level ancestor.
|
||||||
|
* So a parameter groups with its function, and a class property with the
|
||||||
|
* class, without the class pulling every one of its functions in too.
|
||||||
|
*/
|
||||||
|
groupOf(row: number): number {
|
||||||
|
this.ensureOwnership()
|
||||||
|
const roles = this.roles!
|
||||||
|
let r = row
|
||||||
|
for (;;) {
|
||||||
|
const role = roles[r]
|
||||||
|
if (role === R_FUNCTION || role === R_CLASS || role === R_STRUCT) return r
|
||||||
|
const p = this.nestParent(r)
|
||||||
|
if (p < 0) return r
|
||||||
|
r = p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,7 +292,7 @@ export class DumpStore {
|
|||||||
// --- query
|
// --- query
|
||||||
|
|
||||||
|
|
||||||
query(q: Query): { rows: Uint32Array; typeCounts: Int32Array } {
|
query(q: Query): Nested & { 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)
|
||||||
|
|
||||||
@@ -154,26 +320,130 @@ export class DumpStore {
|
|||||||
out[n++] = i
|
out[n++] = i
|
||||||
}
|
}
|
||||||
|
|
||||||
const rows = out.subarray(0, n)
|
return { ...this.nest(out.subarray(0, n), q), typeCounts }
|
||||||
return { rows: this.sort(rows, q), typeCounts }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private sort(rows: Uint32Array, q: Query): Uint32Array {
|
/**
|
||||||
|
* Matches → display order.
|
||||||
|
*
|
||||||
|
* Every match pulls in its owner chain (as context rows, when the owner did
|
||||||
|
* not match itself), then the result is laid out depth-first. The sort is
|
||||||
|
* applied among siblings at every level, so a child never leaves its parent
|
||||||
|
* and a group's place is decided by its head alone. The one exception is a
|
||||||
|
* function's children, which always stay in offset order: that order *is*
|
||||||
|
* the params struct, and it is the reason to look at a function at all.
|
||||||
|
*/
|
||||||
|
private nest(match: Uint32Array, q: Query): Nested {
|
||||||
|
this.ensureOwnership()
|
||||||
|
const { count, offset } = this.cols
|
||||||
|
const roles = this.roles!
|
||||||
|
|
||||||
|
// 1 = matched, 2 = owner of a match shown for context.
|
||||||
|
const inc = new Uint8Array(count)
|
||||||
|
for (let k = 0; k < match.length; k++) inc[match[k]!] = 1
|
||||||
|
for (let k = 0; k < match.length; k++) {
|
||||||
|
let p = this.nestParent(match[k]!)
|
||||||
|
while (p >= 0 && !inc[p]) {
|
||||||
|
inc[p] = 2
|
||||||
|
p = this.nestParent(p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Children per included parent, CSR over row ids. Filled in row order, so
|
||||||
|
// natural order needs no sort at all.
|
||||||
|
const start = new Uint32Array(count + 1)
|
||||||
|
let total = 0
|
||||||
|
let roots = 0
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
if (!inc[i]) continue
|
||||||
|
total++
|
||||||
|
const p = this.nestParent(i)
|
||||||
|
if (p < 0) roots++
|
||||||
|
else start[p + 1] = start[p + 1]! + 1
|
||||||
|
}
|
||||||
|
for (let i = 0; i < count; i++) start[i + 1] = start[i + 1]! + start[i]!
|
||||||
|
const fill = start.slice(0, count)
|
||||||
|
const kids = new Uint32Array(start[count]!)
|
||||||
|
const top = new Uint32Array(roots)
|
||||||
|
roots = 0
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
if (!inc[i]) continue
|
||||||
|
const p = this.nestParent(i)
|
||||||
|
if (p < 0) top[roots++] = i
|
||||||
|
else kids[fill[p]!++] = i
|
||||||
|
}
|
||||||
|
|
||||||
|
const cmp = this.comparator(q)
|
||||||
|
const byOffset = (a: number, b: number) => offset[a]! - offset[b]! || a - b
|
||||||
|
if (cmp) top.sort(cmp)
|
||||||
|
for (let p = 0; p < count; p++) {
|
||||||
|
const s = start[p]!
|
||||||
|
const e = start[p + 1]!
|
||||||
|
if (e - s < 2) continue
|
||||||
|
if (roles[p] === R_FUNCTION) kids.subarray(s, e).sort(byOffset)
|
||||||
|
else if (cmp) kids.subarray(s, e).sort(cmp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Depth-first, iteratively: chains are shallow but there are many of them.
|
||||||
|
const rows = new Uint32Array(total)
|
||||||
|
const depth = new Uint8Array(total)
|
||||||
|
const ctx = new Uint8Array(total)
|
||||||
|
const stack: number[] = []
|
||||||
|
const stackDepth: number[] = []
|
||||||
|
let n = 0
|
||||||
|
for (let t = 0; t < top.length; t++) {
|
||||||
|
stack.push(top[t]!)
|
||||||
|
stackDepth.push(0)
|
||||||
|
while (stack.length) {
|
||||||
|
const r = stack.pop()!
|
||||||
|
const d = stackDepth.pop()!
|
||||||
|
rows[n] = r
|
||||||
|
depth[n] = Math.min(d, 255)
|
||||||
|
ctx[n] = inc[r] === 2 ? 1 : 0
|
||||||
|
n++
|
||||||
|
for (let c = start[r + 1]! - 1; c >= start[r]!; c--) {
|
||||||
|
stack.push(kids[c]!)
|
||||||
|
stackDepth.push(d + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { rows, depth, ctx, matches: match.length }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sibling comparator for the chosen column, or null when dump order stands.
|
||||||
|
*
|
||||||
|
* Rows without a value — no offset on a class or function, the null address
|
||||||
|
* on a recovered fragment — sort last in *both* directions. Otherwise an
|
||||||
|
* ascending OFF sort would hoist every group head above every member.
|
||||||
|
*/
|
||||||
|
private comparator(q: Query): ((a: number, b: number) => number) | null {
|
||||||
const mode = q.sort ?? 'natural'
|
const mode = q.sort ?? 'natural'
|
||||||
if (mode === 'natural' && !q.descending) return rows
|
if (mode === 'natural' && !q.descending) return null
|
||||||
const { addr, offset } = this.cols
|
const { addr, offset } = this.cols
|
||||||
|
const dir = q.descending ? -1 : 1
|
||||||
|
|
||||||
let cmp: (a: number, b: number) => number
|
let base: (a: number, b: number) => number
|
||||||
if (mode === 'address') cmp = (a, b) => addr[a]! - addr[b]! || a - b
|
let missing: ((r: number) => boolean) | null = null
|
||||||
else if (mode === 'offset') cmp = (a, b) => offset[a]! - offset[b]! || a - b
|
if (mode === 'address') {
|
||||||
else if (mode === 'path') cmp = (a, b) => this.cmpPath(a, b)
|
base = (a, b) => addr[a]! - addr[b]!
|
||||||
else if (mode === 'name') cmp = (a, b) => this.cmpName(a, b) || this.cmpPath(a, b)
|
missing = (r) => !addr[r]
|
||||||
else if (mode === 'type') cmp = (a, b) => this.cmpType(a, b) || this.cmpPath(a, b)
|
} else if (mode === 'offset') {
|
||||||
else cmp = (a, b) => a - b
|
base = (a, b) => offset[a]! - offset[b]!
|
||||||
|
missing = (r) => offset[r]! < 0
|
||||||
|
} else if (mode === 'path') base = (a, b) => this.cmpPath(a, b)
|
||||||
|
else if (mode === 'name') base = (a, b) => this.cmpName(a, b) || this.cmpPath(a, b)
|
||||||
|
else if (mode === 'type') base = (a, b) => this.cmpType(a, b) || this.cmpPath(a, b)
|
||||||
|
else base = (a, b) => a - b
|
||||||
|
|
||||||
const sorted = rows.slice().sort(cmp)
|
if (!missing) return (a, b) => dir * base(a, b) || a - b
|
||||||
if (q.descending) sorted.reverse()
|
const m = missing
|
||||||
return sorted
|
return (a, b) => {
|
||||||
|
const ma = m(a)
|
||||||
|
if (ma !== m(b)) return ma ? 1 : -1
|
||||||
|
return dir * base(a, b) || a - b
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Byte order over the *indexable* path, so inner properties sort with their array. */
|
/** Byte order over the *indexable* path, so inner properties sort with their array. */
|
||||||
|
|||||||
@@ -12,8 +12,6 @@ import { KIND_ENUM } from './dump-parse'
|
|||||||
* to write", with functions given the offset table that is the whole point.
|
* 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. */
|
/** Beyond this the report stops being a reference and starts being the dump. */
|
||||||
export const REPORT_CAP = 25_000
|
export const REPORT_CAP = 25_000
|
||||||
|
|
||||||
@@ -69,15 +67,6 @@ export function buildReport(
|
|||||||
// `store.row` decodes strings out of the path blob each time it is called.
|
// `store.row` decodes strings out of the path blob each time it is called.
|
||||||
const objs = Array.from(used, (r) => store.row(r))
|
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 out: string[] = []
|
||||||
const { root, label } = meta
|
const { root, label } = meta
|
||||||
|
|
||||||
@@ -112,7 +101,7 @@ export function buildReport(
|
|||||||
out.push('')
|
out.push('')
|
||||||
|
|
||||||
// ------------------------------------------------------------ functions
|
// ------------------------------------------------------------ functions
|
||||||
const fns = objs.filter((o) => FUNCTION_TYPES.has(o.type))
|
const fns = objs.filter((o) => o.role === 'function')
|
||||||
if (fns.length) {
|
if (fns.length) {
|
||||||
out.push('FUNCTIONS')
|
out.push('FUNCTIONS')
|
||||||
out.push('-'.repeat(72))
|
out.push('-'.repeat(72))
|
||||||
@@ -122,7 +111,10 @@ export function buildReport(
|
|||||||
for (const f of fns) {
|
for (const f of fns) {
|
||||||
// Declaration order is offset order: UE lays parameters out in the
|
// Declaration order is offset order: UE lays parameters out in the
|
||||||
// params struct in the order they are declared, return value last.
|
// 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)
|
// Found by owner pointer, so a param outside a truncated report still lists.
|
||||||
|
const params = Array.from(store.members(f.index), (r) => store.row(r)).sort(
|
||||||
|
(a, b) => a.offset - b.offset,
|
||||||
|
)
|
||||||
|
|
||||||
out.push(f.path)
|
out.push(f.path)
|
||||||
out.push(` name ${f.name}`)
|
out.push(` name ${f.name}`)
|
||||||
@@ -134,9 +126,10 @@ export function buildReport(
|
|||||||
} else {
|
} else {
|
||||||
out.push(` params ${params.length}`)
|
out.push(` params ${params.length}`)
|
||||||
const w = Math.max(...params.map((p) => p.name.length), 4)
|
const w = Math.max(...params.map((p) => p.name.length), 4)
|
||||||
out.push(` ${'OFFSET'.padEnd(9)}${'NAME'.padEnd(w + 2)}TYPE`)
|
const t = Math.max(...params.map((p) => p.type.length), 4)
|
||||||
|
out.push(` ${'OFFSET'.padEnd(9)}${'NAME'.padEnd(w + 2)}${'TYPE'.padEnd(t + 2)}ROLE`)
|
||||||
for (const p of params) {
|
for (const p of params) {
|
||||||
out.push(` ${hexOff(p.offset).padEnd(9)}${p.name.padEnd(w + 2)}${p.type}`)
|
out.push(` ${hexOff(p.offset).padEnd(9)}${p.name.padEnd(w + 2)}${p.type.padEnd(t + 2)}${p.role}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.push('')
|
out.push('')
|
||||||
|
|||||||
50
app/seo/noscript.ts
Normal file
50
app/seo/noscript.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/**
|
||||||
|
* Rendered into <noscript> at the top of <body> (see nuxt.config.ts). The app
|
||||||
|
* itself is client-only, so this is the whole page for crawlers that do not run
|
||||||
|
* JavaScript and for visitors who have it turned off. main.css is linked in the
|
||||||
|
* head regardless, so its tokens (--void, --ink, --amber...) apply here too.
|
||||||
|
*
|
||||||
|
* Plain HTML in a template string: no backticks or ${} in the markup.
|
||||||
|
*/
|
||||||
|
export const NOSCRIPT = /* html */ `
|
||||||
|
<style>
|
||||||
|
.static { max-width: 720px; margin: 0 auto; padding: 40px 20px 64px; line-height: 1.65; }
|
||||||
|
.static h1 { font-size: 30px; letter-spacing: -0.025em; color: var(--ink-strong); margin: 8px 0 12px; }
|
||||||
|
.static h2 { font-size: 15px; color: var(--ink-strong); margin: 32px 0 8px; }
|
||||||
|
.static p, .static li { color: var(--ink); max-width: 64ch; }
|
||||||
|
.static a { color: var(--amber); }
|
||||||
|
.static code, .static pre { font-family: var(--mono); font-size: 12px; }
|
||||||
|
.static pre { overflow-x: auto; padding: 12px; background: var(--panel); border: 1px solid var(--rule); border-radius: 3px; color: var(--dim); }
|
||||||
|
.static .notice { padding: 10px 12px; border: 1px solid var(--amber); border-radius: 3px; color: var(--amber); }
|
||||||
|
.static .eyebrow { margin: 0; }
|
||||||
|
</style>
|
||||||
|
<main class="static">
|
||||||
|
<p class="eyebrow">UE4SS Object Explorer</p>
|
||||||
|
<h1>Browse UE4SS object dumps in your browser</h1>
|
||||||
|
|
||||||
|
<p class="notice">
|
||||||
|
This explorer runs entirely in your browser and needs JavaScript. Enable it
|
||||||
|
and reload the page to open a dump.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
UE4SS Object Explorer is a free, browser-based viewer for the output of <a href="https://github.com/UE4SS-RE/RE-UE4SS">UE4SS</a>'s <code>DumpObjects</code>
|
||||||
|
command: the very large text file an Unreal Engine game writes when it enumerates every live <code>UObject</code>. Drop the dump in and it becomes a
|
||||||
|
searchable, filterable, linkable index of the game's classes, functions, properties, structs and enums.
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
It was originally built for Palworld modding, but the format works with dumps from most games UE4SS supports.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Privacy</h2>
|
||||||
|
<p>
|
||||||
|
Nothing is uploaded. The dump is parsed in a Web Worker and cached in your browser's own storage, so a large dump opens instantly the next time.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>How to make a dump</h2>
|
||||||
|
<p>
|
||||||
|
Heh.
|
||||||
|
Install <a href="https://github.com/UE4SS-RE/RE-UE4SS">UE4SS</a> for your game and trigger its object dumper (the <kbd>Ctrl</kbd>+<kbd>J</kbd> key binding by default). It writes <code>UE4SS_ObjectDump.txt</code> next to the UE4SS install. See the <a href="https://github.com/UE4SS-RE/RE-UE4SS/blob/main/docs/feature-overview/dumpers.md">UE4SS dumper documentation</a> for details.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
|
`
|
||||||
@@ -11,6 +11,11 @@ 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)
|
||||||
|
let currentDepth: Uint8Array = new Uint8Array(0)
|
||||||
|
let currentCtx: Uint8Array = new Uint8Array(0)
|
||||||
|
|
||||||
|
/** Classes run to hundreds of members; the inspector is not the place to read all of them. */
|
||||||
|
const MEMBER_CAP = 400
|
||||||
/** Title of the open dump, so a report can name what it was taken from. */
|
/** Title of the open dump, so a report can name what it was taken from. */
|
||||||
let currentLabel = ''
|
let currentLabel = ''
|
||||||
|
|
||||||
@@ -30,6 +35,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: 'members'; row: number }
|
||||||
| { id: number; op: 'report'; prefix: string }
|
| { id: number; op: 'report'; prefix: string }
|
||||||
| { id: number; op: 'close' }
|
| { id: number; op: 'close' }
|
||||||
|
|
||||||
@@ -100,23 +106,52 @@ async function handle(msg: Req) {
|
|||||||
|
|
||||||
case 'query': {
|
case 'query': {
|
||||||
const t0 = performance.now()
|
const t0 = performance.now()
|
||||||
const { rows, typeCounts } = store!.query(msg.query)
|
const { rows, depth, ctx, matches, typeCounts } = store!.query(msg.query)
|
||||||
current = rows
|
current = rows
|
||||||
return { total: rows.length, typeCounts, ms: performance.now() - t0 }
|
currentDepth = depth
|
||||||
|
currentCtx = ctx
|
||||||
|
return { total: rows.length, matches, typeCounts, ms: performance.now() - t0 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Row 0 is a real row, so positions are read straight through rather than
|
||||||
|
// skipping falsy ids — that skip used to shift every row after it.
|
||||||
case 'window': {
|
case 'window': {
|
||||||
const end = Math.min(msg.end, current.length)
|
const end = Math.min(msg.end, current.length)
|
||||||
const out = []
|
const out = []
|
||||||
for (let i = msg.start; i < end; i++) {
|
for (let i = msg.start; i < end; i++) {
|
||||||
const el = current[i];
|
out.push({ ...store!.row(current[i]!), depth: currentDepth[i]!, ctx: !!currentCtx[i] })
|
||||||
if(!el) continue
|
|
||||||
const row = store!.row(el)
|
|
||||||
if (row) out.push(row)
|
|
||||||
}
|
}
|
||||||
return { start: msg.start, rows: out }
|
return { start: msg.start, rows: out }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// What the inspector lists under an object. A parameter, return or local
|
||||||
|
// shows its function's signature, so the pane does not go blank when one
|
||||||
|
// row of the group is picked over another.
|
||||||
|
case 'members': {
|
||||||
|
const s = store!
|
||||||
|
let head = msg.row
|
||||||
|
const role = s.role(head)
|
||||||
|
if (role === 'param' || role === 'return' || role === 'local') head = s.ownerOf(head)
|
||||||
|
const headRole = head >= 0 ? s.role(head) : 'object'
|
||||||
|
if (headRole !== 'function' && headRole !== 'class' && headRole !== 'struct') {
|
||||||
|
return { head: null, members: [], total: 0 }
|
||||||
|
}
|
||||||
|
const { offset } = s.cols
|
||||||
|
// Offset order, which is declaration order; functions (no offset) after
|
||||||
|
// the properties, in dump order.
|
||||||
|
const ids = Array.from(s.members(head)).sort((a, b) => {
|
||||||
|
const oa = offset[a]!
|
||||||
|
const ob = offset[b]!
|
||||||
|
if (oa < 0 !== ob < 0) return oa < 0 ? 1 : -1
|
||||||
|
return oa - ob || a - b
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
head: s.row(head),
|
||||||
|
members: ids.slice(0, MEMBER_CAP).map((r) => s.row(r)),
|
||||||
|
total: ids.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
case 'children':
|
case 'children':
|
||||||
return { prefix: msg.prefix, children: store!.children(msg.prefix) }
|
return { prefix: msg.prefix, children: store!.children(msg.prefix) }
|
||||||
|
|
||||||
@@ -139,6 +174,8 @@ async function handle(msg: Req) {
|
|||||||
case 'close': {
|
case 'close': {
|
||||||
store = null
|
store = null
|
||||||
current = new Uint32Array(0)
|
current = new Uint32Array(0)
|
||||||
|
currentDepth = new Uint8Array(0)
|
||||||
|
currentCtx = new Uint8Array(0)
|
||||||
currentLabel = ''
|
currentLabel = ''
|
||||||
return { closed: true }
|
return { closed: true }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
import { NOSCRIPT } from './app/seo/noscript'
|
||||||
|
|
||||||
|
/** Canonical origin. Also the default for runtimeConfig.public.urlBase; robots.txt and sitemap.xml repeat it. */
|
||||||
|
const SITE = 'https://u34ss-explorer.gimme.pet'
|
||||||
|
const TITLE = 'UE4SS Object Explorer: browse UE4SS object dumps in your browser'
|
||||||
|
const DESCRIPTION =
|
||||||
|
'Free browser-based explorer for UE4SS object dumps. Search, filter and navigate an Unreal Engine ' +
|
||||||
|
"game's classes, functions, parameters and properties. Built for Palworld modding; nothing is uploaded."
|
||||||
|
|
||||||
export default defineNuxtConfig({
|
export default defineNuxtConfig({
|
||||||
srcDir: 'app/',
|
srcDir: 'app/',
|
||||||
|
|
||||||
@@ -9,8 +18,40 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
app: {
|
app: {
|
||||||
head: {
|
head: {
|
||||||
title: 'Object Dump Explorer',
|
htmlAttrs: { lang: 'en' },
|
||||||
|
title: TITLE,
|
||||||
|
meta: [
|
||||||
|
{ name: 'description', content: DESCRIPTION },
|
||||||
|
{ name: 'theme-color', content: '#0f1319' },
|
||||||
|
{ property: 'og:type', content: 'website' },
|
||||||
|
{ property: 'og:site_name', content: 'UE4SS Object Explorer' },
|
||||||
|
{ property: 'og:title', content: TITLE },
|
||||||
|
{ property: 'og:description', content: DESCRIPTION },
|
||||||
|
{ property: 'og:url', content: SITE + '/' },
|
||||||
|
{ name: 'twitter:card', content: 'summary' },
|
||||||
|
],
|
||||||
|
script: [
|
||||||
|
{
|
||||||
|
type: 'application/ld+json',
|
||||||
|
innerHTML: JSON.stringify({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'WebApplication',
|
||||||
|
name: 'UE4SS Object Explorer',
|
||||||
|
url: SITE + '/',
|
||||||
|
description: DESCRIPTION,
|
||||||
|
applicationCategory: 'DeveloperApplication',
|
||||||
|
operatingSystem: 'Any (runs in a web browser)',
|
||||||
|
browserRequirements: 'Requires JavaScript',
|
||||||
|
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// The app is client-only, so the generated body is otherwise empty.
|
||||||
|
noscript: [{ innerHTML: NOSCRIPT, tagPosition: 'bodyOpen' }],
|
||||||
link: [
|
link: [
|
||||||
|
// Shared views are `?dump=…` query strings on the one page; they all
|
||||||
|
// describe the same document as far as an index is concerned.
|
||||||
|
{ rel: 'canonical', href: SITE + '/' },
|
||||||
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||||
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
|
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
|
||||||
{
|
{
|
||||||
@@ -27,7 +68,7 @@ export default defineNuxtConfig({
|
|||||||
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
public: {
|
public: {
|
||||||
urlBase: 'https://u34ss-explorer.gimme.pet'
|
urlBase: SITE
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
5
public/robots.txt
Normal file
5
public/robots.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
User-agent: *
|
||||||
|
# Prebaked dumps are multi-MB binaries with nothing to index.
|
||||||
|
Disallow: /*.pdx$
|
||||||
|
|
||||||
|
Sitemap: https://u34ss-explorer.gimme.pet/sitemap.xml
|
||||||
6
public/sitemap.xml
Normal file
6
public/sitemap.xml
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://u34ss-explorer.gimme.pet/</loc>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
Reference in New Issue
Block a user