initial commit
This commit is contained in:
592
app/app.vue
Normal file
592
app/app.vue
Normal file
@@ -0,0 +1,592 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { useDump, type RowView } from './composables/useDump'
|
||||
import { hue, abbreviate } from './lib/type-hue'
|
||||
import { listDumps, dropDump, type CachedDump } from './lib/persist'
|
||||
import HeaderBar from '@/components/HeaderBar.vue'
|
||||
const d = useDump()
|
||||
|
||||
const nameQuery = ref('')
|
||||
const pathQuery = ref('')
|
||||
const pathPrefix = ref('')
|
||||
const activeTypes = ref<number[]>([])
|
||||
const sort = ref<'natural' | 'path' | 'address' | 'offset'>('natural')
|
||||
const inspected = ref<RowView | null>(null)
|
||||
const outerOf = ref<RowView | null>(null)
|
||||
const cached = ref<CachedDump[]>([])
|
||||
const showAllTypes = ref(false)
|
||||
const dragging = ref(false)
|
||||
const treeVersion = ref(0)
|
||||
|
||||
const fmt = new Intl.NumberFormat()
|
||||
|
||||
let timer: ReturnType<typeof setTimeout>
|
||||
function schedule() {
|
||||
clearTimeout(timer)
|
||||
timer = setTimeout(run, 120)
|
||||
}
|
||||
|
||||
async function run() {
|
||||
if (!d.ready.value) return
|
||||
await d.runQuery({
|
||||
typeIds: activeTypes.value,
|
||||
pathPrefix: pathPrefix.value || undefined,
|
||||
nameContains: nameQuery.value.trim() || undefined,
|
||||
pathContains: pathQuery.value.trim() || undefined,
|
||||
sort: sort.value,
|
||||
})
|
||||
}
|
||||
|
||||
watch([nameQuery, pathQuery], schedule)
|
||||
watch([pathPrefix, activeTypes, sort], run, { deep: true })
|
||||
watch(d.count, () => {
|
||||
treeVersion.value++
|
||||
pathPrefix.value = ''
|
||||
activeTypes.value = []
|
||||
inspected.value = null
|
||||
run()
|
||||
})
|
||||
|
||||
function toggleType(id: number) {
|
||||
const i = activeTypes.value.indexOf(id)
|
||||
i === -1 ? activeTypes.value.push(id) : activeTypes.value.splice(i, 1)
|
||||
}
|
||||
|
||||
/** Types the current filter can actually reach, busiest first. */
|
||||
const typeChips = computed(() => {
|
||||
const counts = d.typeCounts.value
|
||||
return d.types.value
|
||||
.map((name, id) => ({ id, name, count: counts[id] ?? 0 }))
|
||||
.filter((t) => t.count > 0 || activeTypes.value.includes(t.id))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
})
|
||||
const shownChips = computed(() =>
|
||||
showAllTypes.value ? typeChips.value : typeChips.value.slice(0, 14),
|
||||
)
|
||||
|
||||
async function inspect(row: RowView) {
|
||||
inspected.value = row
|
||||
outerOf.value = null
|
||||
if (row.outer) outerOf.value = (await d.resolveAddress(row.outer)).row
|
||||
}
|
||||
|
||||
function jumpTo(path: string) {
|
||||
pathPrefix.value = path
|
||||
nameQuery.value = ''
|
||||
pathQuery.value = ''
|
||||
}
|
||||
|
||||
async function onDrop(e: DragEvent) {
|
||||
dragging.value = false
|
||||
const file = e.dataTransfer?.files?.[0]
|
||||
if (file) await d.loadFile(file)
|
||||
}
|
||||
|
||||
function onPick(e: Event) {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (file) d.loadFile(file)
|
||||
}
|
||||
|
||||
async function refreshCached() {
|
||||
cached.value = await listDumps().catch(() => [])
|
||||
}
|
||||
|
||||
async function forget(key: string) {
|
||||
await dropDump(key)
|
||||
refreshCached()
|
||||
}
|
||||
|
||||
/** 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() {
|
||||
await d.reset()
|
||||
// The d.count watcher clears pathPrefix / activeTypes / inspected; the rest
|
||||
// of the query surface is ours.
|
||||
nameQuery.value = ''
|
||||
pathQuery.value = ''
|
||||
sort.value = 'natural'
|
||||
showAllTypes.value = false
|
||||
outerOf.value = null
|
||||
dragging.value = false
|
||||
}
|
||||
|
||||
onMounted(refreshCached)
|
||||
watch(d.count, refreshCached)
|
||||
|
||||
const tail = (s: string, max = 72) =>
|
||||
s.length <= max ? s : '…' + s.slice(s.length - max + 1)
|
||||
const hex = (n: number) => '0x' + n.toString(16).toUpperCase().padStart(12, '0')
|
||||
const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app" @dragover.prevent="dragging = true" @dragleave="dragging = false" @drop.prevent="onDrop">
|
||||
|
||||
<!-- header -->
|
||||
<header>
|
||||
<HeaderBar :onPick="onPick" :onClear="clearDump" :shows-clear="d.ready.value">
|
||||
<div v-if="d.ready.value" class="readout mono">
|
||||
<span class="file">{{ d.label.value }}</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{{ fmt.format(d.count.value) }} objects</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{{ d.types.value.length }} types</span>
|
||||
<span v-if="d.skipped.value" class="sep">·</span>
|
||||
<span v-if="d.skipped.value" class="warn">{{ d.skipped.value }} unparsed lines</span>
|
||||
</div>
|
||||
|
||||
</HeaderBar>
|
||||
|
||||
</header>
|
||||
|
||||
<!-- first run -->
|
||||
<section v-if="!d.ready.value" class="intro">
|
||||
<div class="intro-card">
|
||||
<p class="eyebrow">Start Here</p>
|
||||
<h1>Drop a UE4SS object dump.</h1>
|
||||
<NuxtLink href="https://github.com/UE4SS-RE/RE-UE4SS/blob/main/docs/feature-overview/dumpers.md" target="_blank">See documentation about dumpers here.</NuxtLink>
|
||||
<p class="lede">
|
||||
Drag and drop a UE4SS object dump below. This tool is not meant for unreal header dumps, but the actual object dump. It's generally a single large text file.
|
||||
</p>
|
||||
<p class="lede">
|
||||
Dumps are saved locally. While this tool was created for Palworld, it will probably work for most other UE4SS supported games.
|
||||
</p>
|
||||
<label class="drop" :class="{ hot: dragging }">
|
||||
<input type="file" accept=".txt,.log,.dump" hidden @change="onPick" />
|
||||
<span v-if="!d.loading.value" class="mono">Drop UE4SS_ObjectDump.txt here</span>
|
||||
<span v-else class="mono">Parsing… {{ Math.round(d.progress.value * 100) }}%</span>
|
||||
<div v-if="d.loading.value" class="meter">
|
||||
<i :style="{ width: d.progress.value * 100 + '%' }" />
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<p v-if="d.error.value" class="err mono">{{ d.error.value }}</p>
|
||||
|
||||
<div v-if="cached.length" class="cached">
|
||||
<p class="eyebrow">Already parsed</p>
|
||||
<button v-for="c in cached" :key="c.key" class="cache-row mono" @click="d.restore(c.key)">
|
||||
<span class="cache-name">{{ c.label }}</span>
|
||||
<span class="cache-meta">{{ fmt.format(c.cols.count) }} objects · {{ mb(c.bytes) }}</span>
|
||||
<span class="cache-drop" @click.stop="forget(c.key)">forget</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- explorer -->
|
||||
<main v-else class="body">
|
||||
<PathTree
|
||||
v-model="pathPrefix"
|
||||
:fetch-children="d.fetchChildren"
|
||||
:version="treeVersion"
|
||||
/>
|
||||
|
||||
<section class="main">
|
||||
<div class="controls">
|
||||
<div class="searches">
|
||||
<input v-model="nameQuery" placeholder="search name…" spellcheck="false" />
|
||||
<input v-model="pathQuery" placeholder="search full path…" spellcheck="false" />
|
||||
<select v-model="sort" class="mono">
|
||||
<option value="natural">dump order</option>
|
||||
<option value="path">path</option>
|
||||
<option value="address">address</option>
|
||||
<option value="offset">field offset</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="pathPrefix" class="crumb mono">
|
||||
<span class="eyebrow">under</span>
|
||||
<span class="crumb-path" :title="pathPrefix">{{ tail(pathPrefix) }}</span>
|
||||
<button @click="pathPrefix = ''">×</button>
|
||||
</div>
|
||||
|
||||
<div class="chips">
|
||||
<button
|
||||
v-for="t in shownChips"
|
||||
:key="t.id"
|
||||
class="chip mono"
|
||||
:class="{ on: activeTypes.includes(t.id) }"
|
||||
:style="{ '--chip': hue(t.name) }"
|
||||
:title="t.name"
|
||||
@click="toggleType(t.id)"
|
||||
>
|
||||
<i class="tick" />{{ abbreviate(t.name) }}
|
||||
<b>{{ fmt.format(t.count) }}</b>
|
||||
</button>
|
||||
<button
|
||||
v-if="typeChips.length > 14"
|
||||
class="chip more"
|
||||
@click="showAllTypes = !showAllTypes"
|
||||
>
|
||||
{{ showAllTypes ? 'fewer' : `+${typeChips.length - 14} more` }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ObjectTable
|
||||
:total="d.total.value"
|
||||
:shared-digits="d.sharedAddressDigits.value"
|
||||
:fetch-window="d.fetchWindow"
|
||||
@inspect="inspect"
|
||||
/>
|
||||
|
||||
<footer class="status mono">
|
||||
<span>{{ fmt.format(d.total.value) }} / {{ fmt.format(d.count.value) }} shown</span>
|
||||
<span class="sep">·</span>
|
||||
<span>{{ d.queryMs.value.toFixed(0) }} ms</span>
|
||||
<span v-if="d.parseMs.value" class="sep">·</span>
|
||||
<span v-if="d.parseMs.value">parsed in {{ (d.parseMs.value / 1000).toFixed(1) }} s</span>
|
||||
</footer>
|
||||
</section>
|
||||
|
||||
<!-- inspector -->
|
||||
<aside v-if="inspected" class="inspect">
|
||||
<div class="inspect-head">
|
||||
<span class="eyebrow">Object</span>
|
||||
<button @click="inspected = null">×</button>
|
||||
</div>
|
||||
<dl class="mono">
|
||||
<dt>type</dt>
|
||||
<dd :style="{ color: hue(inspected.type) }">{{ inspected.type }}</dd>
|
||||
<dt>name</dt>
|
||||
<dd class="strong">{{ inspected.name }}</dd>
|
||||
<dt>address</dt>
|
||||
<dd class="addr">{{ hex(inspected.address) }}</dd>
|
||||
<dt v-if="inspected.offset >= 0">offset</dt>
|
||||
<dd v-if="inspected.offset >= 0">
|
||||
0x{{ inspected.offset.toString(16).toUpperCase() }}
|
||||
<i class="dec">({{ inspected.offset }})</i>
|
||||
</dd>
|
||||
<dt>path</dt>
|
||||
<dd class="wrap">{{ inspected.path }}</dd>
|
||||
<dt v-if="outerOf">owner</dt>
|
||||
<dd v-if="outerOf" class="wrap">
|
||||
<button class="link" @click="jumpTo(outerOf.path)">{{ outerOf.path }}</button>
|
||||
</dd>
|
||||
</dl>
|
||||
<button class="scope" @click="jumpTo(inspected.path)">Scope tree to this object</button>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<div v-if="dragging && d.ready.value" class="veil mono">Release to parse a new dump</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* first run */
|
||||
.intro {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
overflow: auto;
|
||||
}
|
||||
.intro-card {
|
||||
width: min(560px, 100%);
|
||||
}
|
||||
.intro h1 {
|
||||
font-size: 30px;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.025em;
|
||||
margin: 10px 0 12px;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
.lede {
|
||||
color: var(--dim);
|
||||
line-height: 1.65;
|
||||
margin: 10px 0 22px;
|
||||
max-width: 52ch;
|
||||
}
|
||||
|
||||
.drop {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
gap: 12px;
|
||||
height: 130px;
|
||||
border: 1px dashed var(--rule);
|
||||
border-radius: 4px;
|
||||
color: var(--dim);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, color 0.15s;
|
||||
}
|
||||
.drop:hover,
|
||||
.drop.hot {
|
||||
border-color: var(--amber);
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.meter {
|
||||
width: 260px;
|
||||
height: 2px;
|
||||
background: var(--rule);
|
||||
}
|
||||
.meter i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
background: var(--amber);
|
||||
transition: width 0.1s linear;
|
||||
}
|
||||
|
||||
.err {
|
||||
color: var(--t-bool);
|
||||
font-size: 12px;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.cached {
|
||||
margin-top: 28px;
|
||||
}
|
||||
.cache-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
padding: 9px 0;
|
||||
border-bottom: 1px solid var(--rule-soft);
|
||||
font-size: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
.cache-row:hover .cache-name {
|
||||
color: var(--amber);
|
||||
}
|
||||
.cache-name {
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
.cache-meta {
|
||||
color: var(--dimmer);
|
||||
font-size: 11px;
|
||||
margin-left: auto;
|
||||
}
|
||||
.cache-drop {
|
||||
color: var(--dimmer);
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.cache-drop:hover {
|
||||
color: var(--t-bool);
|
||||
}
|
||||
|
||||
/* explorer */
|
||||
.body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.main {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.controls {
|
||||
flex: none;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.searches {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.searches input {
|
||||
flex: 1;
|
||||
}
|
||||
.searches select {
|
||||
flex: none;
|
||||
font-size: 11px;
|
||||
color: var(--ink);
|
||||
background: var(--void);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.crumb {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 11px;
|
||||
color: var(--amber);
|
||||
}
|
||||
.crumb-path {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.crumb button {
|
||||
color: var(--dim);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
.chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 11px;
|
||||
padding: 3px 8px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--rule);
|
||||
color: var(--dim);
|
||||
}
|
||||
.chip .tick {
|
||||
width: 3px;
|
||||
height: 9px;
|
||||
border-radius: 1px;
|
||||
background: var(--chip);
|
||||
}
|
||||
.chip b {
|
||||
font-weight: 400;
|
||||
color: var(--dimmer);
|
||||
}
|
||||
.chip:hover {
|
||||
border-color: var(--chip);
|
||||
color: var(--ink);
|
||||
}
|
||||
.chip.on {
|
||||
border-color: var(--chip);
|
||||
color: var(--ink-strong);
|
||||
background: color-mix(in srgb, var(--chip) 12%, transparent);
|
||||
}
|
||||
.chip.more {
|
||||
color: var(--dim);
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.status {
|
||||
flex: none;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
height: 26px;
|
||||
padding: 0 12px;
|
||||
font-size: 11px;
|
||||
color: var(--dim);
|
||||
background: var(--panel);
|
||||
border-top: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
/* inspector */
|
||||
.inspect {
|
||||
width: 320px;
|
||||
flex: none;
|
||||
background: var(--panel);
|
||||
border-left: 1px solid var(--rule);
|
||||
padding: 0 0 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
.inspect-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
.inspect-head button {
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.inspect dl {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.inspect dt {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--dimmer);
|
||||
margin-top: 12px;
|
||||
}
|
||||
.inspect dt:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
.inspect dd {
|
||||
margin: 3px 0 0;
|
||||
color: var(--ink);
|
||||
}
|
||||
.inspect dd.strong {
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
.inspect dd.addr {
|
||||
color: var(--amber);
|
||||
}
|
||||
.inspect dd.wrap {
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.dec {
|
||||
color: var(--dimmer);
|
||||
font-style: normal;
|
||||
}
|
||||
.link {
|
||||
color: var(--t-container);
|
||||
text-align: left;
|
||||
word-break: break-all;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.scope {
|
||||
margin: 4px 12px 0;
|
||||
width: calc(100% - 24px);
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
padding: 7px;
|
||||
}
|
||||
.scope:hover {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.veil {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: color-mix(in srgb, var(--void) 82%, transparent);
|
||||
color: var(--amber);
|
||||
font-size: 13px;
|
||||
border: 1px dashed var(--amber);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.body {
|
||||
flex-direction: column;
|
||||
}
|
||||
.inspect {
|
||||
width: 100%;
|
||||
border-left: none;
|
||||
border-top: 1px solid var(--rule);
|
||||
max-height: 45vh;
|
||||
}
|
||||
.readout {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
121
app/assets/css/main.css
Normal file
121
app/assets/css/main.css
Normal file
@@ -0,0 +1,121 @@
|
||||
/*
|
||||
Visual thesis: this is a hex editor that happens to understand UObjects.
|
||||
Colour is data, never decoration — every hue in the table encodes the type
|
||||
category of the row, so 400k rows stay scannable at a glance. The one
|
||||
ornamental liberty is the address gutter, which ghosts the hex digits every
|
||||
object in the dump shares and lights only the ones that differ.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--void: #0f1319;
|
||||
--panel: #161c25;
|
||||
--raise: #1d2531;
|
||||
--rule: #263041;
|
||||
--rule-soft: #1c2432;
|
||||
--ink: #c6d0dc;
|
||||
--ink-strong: #eef3f8;
|
||||
--dim: #6b7a8d;
|
||||
--dimmer: #3d4859;
|
||||
--amber: #e8a33d;
|
||||
--amber-soft: #4a3413;
|
||||
|
||||
/* type-category hues */
|
||||
--t-container: #7fa9ff;
|
||||
--t-callable: #c08cff;
|
||||
--t-numeric: #58c99a;
|
||||
--t-reference: #e8a33d;
|
||||
--t-bool: #ff8fa9;
|
||||
--t-text: #63cfdf;
|
||||
--t-other: #8493a5;
|
||||
|
||||
--mono: 'Fira Sans', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
--ui: 'Archivo', system-ui, -apple-system, sans-serif;
|
||||
|
||||
--row-h: 26px;
|
||||
--gutter-w: 128px;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#__nuxt {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--void);
|
||||
color: var(--ink);
|
||||
font-family: var(--ui);
|
||||
font-size: 13px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Micro-labels. Every section header in the app is one of these: uppercase,
|
||||
heavily tracked, small. They read as register markings on an instrument. */
|
||||
.eyebrow {
|
||||
font-family: var(--ui);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: var(--mono);
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
button {
|
||||
font-family: inherit;
|
||||
color: inherit;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
input {
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
color: var(--ink-strong);
|
||||
background: var(--void);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
padding: 6px 9px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--dimmer);
|
||||
}
|
||||
|
||||
input:focus-visible,
|
||||
button:focus-visible,
|
||||
[tabindex]:focus-visible {
|
||||
outline: 2px solid var(--amber);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 11px;
|
||||
height: 11px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--rule);
|
||||
border: 3px solid var(--void);
|
||||
border-radius: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
123
app/components/HeaderBar.vue
Normal file
123
app/components/HeaderBar.vue
Normal file
@@ -0,0 +1,123 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
|
||||
const props = defineProps<{
|
||||
onPick: (event: Event) => void
|
||||
onClear: () => Promise<void>
|
||||
showsClear: boolean
|
||||
}>();
|
||||
const config = useRuntimeConfig()
|
||||
const homeUrl = config.public.urlBase;
|
||||
const iconUrl = "/img/icon_civcore.webp"
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bar">
|
||||
<div class="brand">
|
||||
<img :src="iconUrl"/>
|
||||
<span class="mark mono">[<NuxtLink :href="homeUrl" style="color: inherit; text-decoration: none;"><i>UE4SS Object Explorer</i></NuxtLink>]</span>
|
||||
</div>
|
||||
|
||||
<slot/>
|
||||
|
||||
<div class="actions">
|
||||
<label class="load">
|
||||
<input type="file" accept=".txt,.log,.dump" hidden @change="onPick" />
|
||||
<span>Load dump</span>
|
||||
</label>
|
||||
|
||||
<button v-if="showsClear" type="button" class="load clear" @click="onClear">
|
||||
Clear dump
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
padding: 0 14px;
|
||||
height: 46px;
|
||||
flex: none;
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
.mark {
|
||||
color: var(--dimmer);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.mark i {
|
||||
color: var(--amber);
|
||||
font-style: normal;
|
||||
}
|
||||
.title {
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
|
||||
.readout {
|
||||
font-size: 11px;
|
||||
color: var(--dim);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.readout .file {
|
||||
color: var(--ink);
|
||||
}
|
||||
.sep {
|
||||
color: var(--dimmer);
|
||||
}
|
||||
.warn {
|
||||
color: var(--t-bool);
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-left: auto;
|
||||
flex: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.load {
|
||||
flex: none;
|
||||
font-family: inherit;
|
||||
line-height: 1;
|
||||
background: none;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
border: 1px solid var(--rule);
|
||||
border-radius: 3px;
|
||||
padding: 6px 11px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.load:hover {
|
||||
border-color: var(--amber);
|
||||
}
|
||||
|
||||
.clear {
|
||||
color: var(--dim);
|
||||
}
|
||||
.clear:hover {
|
||||
color: var(--t-bool);
|
||||
border-color: var(--t-bool);
|
||||
}
|
||||
|
||||
</style>
|
||||
269
app/components/ObjectTable.vue
Normal file
269
app/components/ObjectTable.vue
Normal file
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import type { RowView } from '../composables/useDump'
|
||||
import { hue, abbreviate } from '../lib/type-hue'
|
||||
|
||||
const props = defineProps<{
|
||||
total: number
|
||||
sharedDigits: number
|
||||
fetchWindow: (start: number, end: number) => Promise<{ start: number; rows: RowView[] }>
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ (e: 'inspect', row: RowView): void }>()
|
||||
|
||||
const ROW_H = 26
|
||||
const OVERSCAN = 24
|
||||
|
||||
const viewport = ref<HTMLElement | null>(null)
|
||||
const scrollTop = ref(0)
|
||||
const height = ref(600)
|
||||
const buffer = ref<{ start: number; rows: RowView[] }>({ start: 0, rows: [] })
|
||||
const selected = ref(-1)
|
||||
let generation = 0
|
||||
|
||||
const firstVisible = computed(() => Math.max(0, Math.floor(scrollTop.value / ROW_H) - OVERSCAN))
|
||||
const lastVisible = computed(() =>
|
||||
Math.min(props.total, Math.ceil((scrollTop.value + height.value) / ROW_H) + OVERSCAN),
|
||||
)
|
||||
|
||||
async function refill() {
|
||||
const g = ++generation
|
||||
const { start, rows } = await props.fetchWindow(firstVisible.value, lastVisible.value)
|
||||
if (g === generation) buffer.value = { start, rows }
|
||||
}
|
||||
|
||||
let queued = false
|
||||
function onScroll() {
|
||||
scrollTop.value = viewport.value?.scrollTop ?? 0
|
||||
if (queued) return
|
||||
queued = true
|
||||
requestAnimationFrame(() => {
|
||||
queued = false
|
||||
refill()
|
||||
})
|
||||
}
|
||||
|
||||
function measure() {
|
||||
height.value = viewport.value?.clientHeight ?? 600
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
measure()
|
||||
new ResizeObserver(measure).observe(viewport.value!)
|
||||
refill()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.total,
|
||||
() => {
|
||||
if (viewport.value) viewport.value.scrollTop = 0
|
||||
scrollTop.value = 0
|
||||
selected.value = -1
|
||||
refill()
|
||||
},
|
||||
)
|
||||
|
||||
const visible = computed(() => {
|
||||
const { start, rows } = buffer.value
|
||||
const out: { i: number; row: RowView | null }[] = []
|
||||
for (let i = firstVisible.value; i < lastVisible.value; i++) {
|
||||
out.push({ i, row: rows[i - start] ?? null })
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const hex = (n: number) => n.toString(16).toUpperCase().padStart(12, '0')
|
||||
|
||||
/**
|
||||
* Show the tail of a long path, which is the part that identifies it.
|
||||
* Done in JS rather than with `direction: rtl`, because these strings are
|
||||
* full of slashes and colons — neutral characters that bidi reordering
|
||||
* happily moves to the wrong end.
|
||||
*/
|
||||
function tail(s: string, max = 64) {
|
||||
return s.length <= max ? s : '…' + s.slice(s.length - max + 1)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="table">
|
||||
<header class="head mono">
|
||||
<span class="col-addr">ADDRESS</span>
|
||||
<span class="col-type">TYPE</span>
|
||||
<span class="col-name">NAME</span>
|
||||
<span class="col-path">CONTAINER</span>
|
||||
<span class="col-off">OFF</span>
|
||||
</header>
|
||||
|
||||
<div ref="viewport" class="viewport" @scroll.passive="onScroll" tabindex="0">
|
||||
<div class="spacer" :style="{ height: total * ROW_H + 'px' }">
|
||||
<div
|
||||
v-for="cell in visible"
|
||||
:key="cell.i"
|
||||
class="row mono"
|
||||
:class="{ sel: selected === cell.i, pending: !cell.row }"
|
||||
:style="{ top: cell.i * ROW_H + 'px' }"
|
||||
@click="cell.row && ((selected = cell.i), emit('inspect', cell.row))"
|
||||
>
|
||||
<template v-if="cell.row">
|
||||
<span class="col-addr">
|
||||
<i class="ghost">{{ hex(cell.row.address).slice(0, sharedDigits) }}</i
|
||||
>{{ hex(cell.row.address).slice(sharedDigits) }}
|
||||
</span>
|
||||
<span
|
||||
class="col-type"
|
||||
:style="{ color: hue(cell.row.type) }"
|
||||
:title="cell.row.type"
|
||||
>
|
||||
<i class="tick" :style="{ background: hue(cell.row.type) }" />
|
||||
{{ abbreviate(cell.row.type) }}
|
||||
</span>
|
||||
<span class="col-name">{{ cell.row.name }}</span>
|
||||
<span class="col-path" :title="cell.row.path">{{ tail(cell.row.container) }}</span>
|
||||
<span class="col-off">{{
|
||||
cell.row.offset < 0 ? '' : '0x' + cell.row.offset.toString(16).toUpperCase()
|
||||
}}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="total === 0" class="empty">
|
||||
No objects match. Widen the search, or clear the path filter in the spine.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.table {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.head {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
padding: 0 12px;
|
||||
height: 28px;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--dim);
|
||||
background: var(--panel);
|
||||
border-bottom: 1px solid var(--rule);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.viewport {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
position: relative;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.row {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 26px;
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
border-bottom: 1px solid var(--rule-soft);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.row:hover {
|
||||
background: var(--panel);
|
||||
}
|
||||
.row.sel {
|
||||
background: var(--raise);
|
||||
box-shadow: inset 2px 0 0 var(--amber);
|
||||
}
|
||||
.row.pending {
|
||||
opacity: 0.25;
|
||||
}
|
||||
|
||||
/* Signature: shared address digits recede, the differing tail stays lit. */
|
||||
.col-addr {
|
||||
width: var(--gutter-w);
|
||||
flex: none;
|
||||
color: var(--amber);
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.ghost {
|
||||
color: var(--dimmer);
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.col-type {
|
||||
width: 138px;
|
||||
flex: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tick {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 10px;
|
||||
vertical-align: -1px;
|
||||
margin-right: 5px;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.col-name {
|
||||
width: 300px;
|
||||
flex: none;
|
||||
color: var(--ink-strong);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.col-path {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--dim);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.col-off {
|
||||
width: 56px;
|
||||
flex: none;
|
||||
text-align: right;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.empty {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
color: var(--dim);
|
||||
font-size: 12px;
|
||||
max-width: 28ch;
|
||||
margin: auto;
|
||||
text-align: center;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.col-name {
|
||||
width: 180px;
|
||||
}
|
||||
.col-off,
|
||||
.col-path {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
213
app/components/PathTree.vue
Normal file
213
app/components/PathTree.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch } from 'vue'
|
||||
import type { TreeChild } from '../lib/dump-store'
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: string
|
||||
fetchChildren: (prefix: string) => Promise<{ prefix: string; children: TreeChild[] }>
|
||||
version: number
|
||||
}>()
|
||||
const emit = defineEmits<{ (e: 'update:modelValue', v: string): void }>()
|
||||
|
||||
interface Node extends TreeChild {
|
||||
depth: number
|
||||
open: boolean
|
||||
children: Node[] | null
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
const roots = ref<Node[]>([])
|
||||
|
||||
function toNode(c: TreeChild, depth: number): Node {
|
||||
return { ...c, depth, open: false, children: null, loading: false }
|
||||
}
|
||||
|
||||
async function loadRoots() {
|
||||
roots.value = []
|
||||
const { children } = await props.fetchChildren('')
|
||||
roots.value = children.map((c) => toNode(c, 0))
|
||||
if (roots.value.length === 1 && roots.value[0]) toggle(roots.value[0])
|
||||
}
|
||||
|
||||
async function toggle(node: Node) {
|
||||
if (node.open) {
|
||||
node.open = false
|
||||
return
|
||||
}
|
||||
if (!node.children) {
|
||||
node.loading = true
|
||||
const { children } = await props.fetchChildren(node.path)
|
||||
node.children = children.map((c) => toNode(c, node.depth + 1))
|
||||
node.loading = false
|
||||
}
|
||||
node.open = true
|
||||
}
|
||||
|
||||
function flatten(nodes: Node[], out: Node[] = []): Node[] {
|
||||
for (const n of nodes) {
|
||||
out.push(n)
|
||||
if (n.open && n.children) flatten(n.children, out)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
const fmt = new Intl.NumberFormat()
|
||||
onMounted(loadRoots)
|
||||
watch(() => props.version, loadRoots)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="spine">
|
||||
<div class="spine-head">
|
||||
<span class="eyebrow">Script path</span>
|
||||
<button v-if="modelValue" class="clear" @click="emit('update:modelValue', '')">clear</button>
|
||||
</div>
|
||||
|
||||
<div class="scroll">
|
||||
<button
|
||||
class="node mono root"
|
||||
:class="{ active: modelValue === '' }"
|
||||
@click="emit('update:modelValue', '')"
|
||||
>
|
||||
<span class="seg">all objects</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-for="node in flatten(roots)"
|
||||
:key="node.path"
|
||||
class="node mono"
|
||||
:class="{ active: modelValue === node.path }"
|
||||
:style="{ paddingLeft: 8 + node.depth * 13 + 'px' }"
|
||||
>
|
||||
<button
|
||||
class="twist"
|
||||
:class="{ open: node.open, spin: node.loading }"
|
||||
@click="toggle(node)"
|
||||
:aria-label="node.open ? 'Collapse' : 'Expand'"
|
||||
>
|
||||
{{ node.count > 1 ? '›' : '·' }}
|
||||
</button>
|
||||
<button class="pick" @click="emit('update:modelValue', node.path)">
|
||||
<span class="sep">{{ node.separator }}</span
|
||||
><span class="seg">{{ node.segment }}</span>
|
||||
<span v-if="node.isObject" class="dot" title="An object exists at this exact path" />
|
||||
<span class="count">{{ fmt.format(node.count) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.spine {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 300px;
|
||||
flex: none;
|
||||
background: var(--panel);
|
||||
border-right: 1px solid var(--rule);
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.spine-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.clear {
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
}
|
||||
|
||||
.scroll {
|
||||
overflow: auto;
|
||||
padding: 6px 0 20px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
font-size: 12px;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
.node:hover {
|
||||
background: var(--raise);
|
||||
}
|
||||
.node.active {
|
||||
background: var(--amber-soft);
|
||||
color: var(--ink-strong);
|
||||
}
|
||||
.node.root {
|
||||
padding-left: 21px;
|
||||
color: var(--dim);
|
||||
}
|
||||
|
||||
.twist {
|
||||
width: 13px;
|
||||
flex: none;
|
||||
color: var(--dim);
|
||||
transition: transform 0.12s ease;
|
||||
line-height: 1;
|
||||
}
|
||||
.twist.open {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
.twist.spin {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.pick {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
padding-right: 10px;
|
||||
}
|
||||
|
||||
.sep {
|
||||
color: var(--dimmer);
|
||||
flex: none;
|
||||
}
|
||||
.seg {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
background: var(--t-container);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.count {
|
||||
margin-left: auto;
|
||||
font-size: 10px;
|
||||
color: var(--dimmer);
|
||||
flex: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.spine {
|
||||
width: 100%;
|
||||
max-height: 40vh;
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--rule);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
164
app/composables/useDump.ts
Normal file
164
app/composables/useDump.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { ref, shallowRef, computed } from 'vue'
|
||||
import type { Query } from '../lib/dump-store'
|
||||
import type { TreeChild } from '../lib/dump-store'
|
||||
|
||||
export interface RowView {
|
||||
index: number
|
||||
address: number
|
||||
type: string
|
||||
typeId: number
|
||||
path: string
|
||||
name: string
|
||||
container: string
|
||||
outer: number
|
||||
offset: number
|
||||
}
|
||||
|
||||
let worker: Worker | null = null
|
||||
let seq = 0
|
||||
const pending = new Map<number, { resolve: (v: any) => void; reject: (e: any) => void }>()
|
||||
|
||||
const loading = ref(false)
|
||||
const progress = ref(0)
|
||||
const error = ref('')
|
||||
const label = ref('')
|
||||
const dumpKey = ref('')
|
||||
const count = ref(0)
|
||||
const skipped = ref(0)
|
||||
const types = shallowRef<string[]>([])
|
||||
const typeCounts = shallowRef<Int32Array>(new Int32Array(0))
|
||||
const sharedAddressDigits = ref(0)
|
||||
const total = ref(0)
|
||||
const queryMs = ref(0)
|
||||
const parseMs = ref(0)
|
||||
|
||||
function ensureWorker(): Worker {
|
||||
if (worker) return worker
|
||||
worker = new Worker(new URL('../workers/dump.worker.ts', import.meta.url), { type: 'module' })
|
||||
worker.onmessage = (e) => {
|
||||
const { id, result, error: err, event, fraction } = e.data
|
||||
if (event === 'progress') {
|
||||
progress.value = fraction
|
||||
return
|
||||
}
|
||||
const p = pending.get(id)
|
||||
if (!p) return
|
||||
pending.delete(id)
|
||||
err ? p.reject(new Error(err)) : p.resolve(result)
|
||||
}
|
||||
return worker
|
||||
}
|
||||
|
||||
function call<T = any>(op: string, payload: Record<string, unknown> = {}, transfer: Transferable[] = []): Promise<T> {
|
||||
const w = ensureWorker()
|
||||
const id = ++seq
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
pending.set(id, { resolve, reject })
|
||||
w.postMessage({ id, op, ...payload }, transfer)
|
||||
})
|
||||
}
|
||||
|
||||
export function useDump() {
|
||||
async function loadFile(file: File) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
progress.value = 0
|
||||
try {
|
||||
const buffer = await file.arrayBuffer()
|
||||
// Transferred, not copied: a 60 MB structured clone is a visible stall.
|
||||
const r = await call('parse', { buffer, name: file.name }, [buffer])
|
||||
applyMeta(r)
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message
|
||||
} finally {
|
||||
loading.value = false
|
||||
progress.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
async function restore(key: string) {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
applyMeta(await call('restore', { key }))
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** Unload the current dump without deleting it from the cache. */
|
||||
async function reset() {
|
||||
await call('close').catch(() => {})
|
||||
loading.value = false
|
||||
error.value = ''
|
||||
progress.value = 0
|
||||
label.value = ''
|
||||
dumpKey.value = ''
|
||||
types.value = []
|
||||
typeCounts.value = new Int32Array(0)
|
||||
skipped.value = 0
|
||||
sharedAddressDigits.value = 0
|
||||
total.value = 0
|
||||
queryMs.value = 0
|
||||
parseMs.value = 0
|
||||
// Last: `ready` and the app's reset watcher both hang off this.
|
||||
count.value = 0
|
||||
}
|
||||
|
||||
function applyMeta(r: any) {
|
||||
count.value = r.count
|
||||
types.value = r.types
|
||||
skipped.value = r.skipped
|
||||
sharedAddressDigits.value = r.sharedAddressDigits
|
||||
label.value = r.label
|
||||
dumpKey.value = r.key
|
||||
parseMs.value = r.parseMs ?? 0
|
||||
}
|
||||
|
||||
async function runQuery(q: Query) {
|
||||
const plain: Query = {
|
||||
...q,
|
||||
typeIds: q.typeIds ? Array.from(q.typeIds) : undefined,
|
||||
}
|
||||
const r = await call('query', { query: plain })
|
||||
total.value = r.total
|
||||
typeCounts.value = r.typeCounts
|
||||
queryMs.value = r.ms
|
||||
return r
|
||||
}
|
||||
|
||||
const fetchWindow = (start: number, end: number) =>
|
||||
call<{ start: number; rows: RowView[] }>('window', { start, end })
|
||||
|
||||
const fetchChildren = (prefix: string) =>
|
||||
call<{ prefix: string; children: TreeChild[] }>('children', { prefix })
|
||||
|
||||
const resolveAddress = (address: number) =>
|
||||
call<{ row: RowView | null }>('resolve', { address })
|
||||
|
||||
return {
|
||||
loadFile,
|
||||
restore,
|
||||
reset,
|
||||
runQuery,
|
||||
fetchWindow,
|
||||
fetchChildren,
|
||||
resolveAddress,
|
||||
loading,
|
||||
progress,
|
||||
error,
|
||||
label,
|
||||
dumpKey,
|
||||
count,
|
||||
skipped,
|
||||
types,
|
||||
typeCounts,
|
||||
sharedAddressDigits,
|
||||
total,
|
||||
queryMs,
|
||||
parseMs,
|
||||
ready: computed(() => count.value > 0),
|
||||
}
|
||||
}
|
||||
101
app/lib/bundle.ts
Normal file
101
app/lib/bundle.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { DumpColumns } from './dump-parse'
|
||||
|
||||
/**
|
||||
* A parsed dump serialised as one flat file.
|
||||
*
|
||||
* [0..4) magic
|
||||
* [4..8) manifest length in bytes
|
||||
* [8..12) payload base offset
|
||||
* [12..16) reserved
|
||||
* [16..) JSON manifest, then zero padding, then the column buffers
|
||||
*
|
||||
* The payload offset lives in the fixed header rather than the manifest so
|
||||
* that writing it cannot change the manifest's own length.
|
||||
*
|
||||
* This exists for the case where the dump ships with the site instead of
|
||||
* being uploaded. Parse once at build time and the browser does one fetch
|
||||
* plus a few typed-array views over the response - no parsing, no IndexedDB.
|
||||
* It also gzips far better than JSON, because each column is homogeneous.
|
||||
*/
|
||||
|
||||
// Bumped to \x02 when `kind` and `value` were added; a \x01 bundle has no way
|
||||
// to tell enum constants from objects, so it is rejected rather than upgraded.
|
||||
const MAGIC = 0x50445802 // "PDX\x02"
|
||||
const MAGIC_V1 = 0x50445801
|
||||
const HEADER = 16
|
||||
|
||||
const COLUMNS = [
|
||||
['addr', Float64Array],
|
||||
['typeId', Uint16Array],
|
||||
['pathStart', Uint32Array],
|
||||
['pathBlob', Uint8Array],
|
||||
['nameOff', Uint16Array],
|
||||
['rootOff', Uint16Array],
|
||||
['outer', Float64Array],
|
||||
['offset', Int32Array],
|
||||
['kind', Uint8Array],
|
||||
['value', BigInt64Array],
|
||||
] as const
|
||||
|
||||
const align8 = (n: number) => (8 - (n % 8)) % 8
|
||||
|
||||
export function encodeBundle(cols: DumpColumns): Uint8Array {
|
||||
const layout: Record<string, { offset: number; length: number }> = {}
|
||||
const parts: { at: number; bytes: Uint8Array }[] = []
|
||||
let size = 0
|
||||
|
||||
for (const [key] of COLUMNS) {
|
||||
const view = cols[key] as ArrayBufferView & { length: number }
|
||||
size += align8(size)
|
||||
layout[key] = { offset: size, length: view.length }
|
||||
parts.push({
|
||||
at: size,
|
||||
bytes: new Uint8Array(view.buffer, view.byteOffset, view.byteLength),
|
||||
})
|
||||
size += view.byteLength
|
||||
}
|
||||
|
||||
const json = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
count: cols.count,
|
||||
types: cols.types,
|
||||
skipped: cols.skipped,
|
||||
damaged: cols.damaged,
|
||||
columns: layout,
|
||||
}),
|
||||
)
|
||||
const base = HEADER + json.length + align8(HEADER + json.length)
|
||||
|
||||
const out = new Uint8Array(base + size)
|
||||
const dv = new DataView(out.buffer)
|
||||
dv.setUint32(0, MAGIC, true)
|
||||
dv.setUint32(4, json.length, true)
|
||||
dv.setUint32(8, base, true)
|
||||
out.set(json, HEADER)
|
||||
for (const { at, bytes } of parts) out.set(bytes, base + at)
|
||||
return out
|
||||
}
|
||||
|
||||
export function decodeBundle(buf: ArrayBuffer): DumpColumns {
|
||||
const dv = new DataView(buf)
|
||||
const magic = dv.getUint32(0, true)
|
||||
if (magic === MAGIC_V1) {
|
||||
throw new Error('This .pdx was built by an older parser. Re-run `npm run prebake`.')
|
||||
}
|
||||
if (magic !== MAGIC) throw new Error('Not a .pdx bundle.')
|
||||
const jsonLen = dv.getUint32(4, true)
|
||||
const base = dv.getUint32(8, true)
|
||||
const manifest = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, HEADER, jsonLen)))
|
||||
|
||||
const cols: Record<string, unknown> = {
|
||||
count: manifest.count,
|
||||
types: manifest.types,
|
||||
skipped: manifest.skipped,
|
||||
damaged: manifest.damaged ?? 0,
|
||||
}
|
||||
for (const [key, Ctor] of COLUMNS) {
|
||||
const { offset, length } = manifest.columns[key]
|
||||
cols[key] = new Ctor(buf, base + offset, length)
|
||||
}
|
||||
return cols as unknown as DumpColumns
|
||||
}
|
||||
352
app/lib/dump-parse.ts
Normal file
352
app/lib/dump-parse.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Byte-level tokenizer for UE4SS `DumpObjects` output.
|
||||
*
|
||||
* Why? Because the dump is huge, and parsing it into a structured object graph is slow and memory-hungry.
|
||||
* Instead, we parse it into a compact columnar representation that can be queried efficiently.
|
||||
*
|
||||
* There is more than one line shape in the file. Three, in the 292k-line dump:
|
||||
*
|
||||
* 1. Objects [ADDR] TypeName ObjectPath [k: v] [k: v] ...
|
||||
* 2. Enum constants [0000000000000000] EnumName::ValueName [n: HEX] [v: DECIMAL]
|
||||
* 3. Damaged lines UE4SS truncates a record mid-write and resumes on the
|
||||
* next line, leaving one line with no trailing groups and
|
||||
* one with no `[ADDR]` prefix.
|
||||
*
|
||||
* Two things make shape 1 harder than it looks:
|
||||
*
|
||||
* - ObjectPath CAN contain spaces (`... (Director BP)_C:UberGraphFrame`,
|
||||
* `Default__SkyCreator:Sun Light Component`) - 5.6k lines' worth. So the
|
||||
* path is not "up to the first space"; the trailing `[k: v]` groups have
|
||||
* to be peeled off the RIGHT and the path is whatever is left.
|
||||
* - Enum constants have no path at all, so a left-to-right scan reads the
|
||||
* first group as the path and files 15k objects under a bogus `[n` root.
|
||||
*
|
||||
* Peeling from the right is only safe because no object path in the dump
|
||||
* contains `[` or `]`; that is asserted by treating a leftover bracket in the
|
||||
* path region as damage rather than as text.
|
||||
*/
|
||||
|
||||
const NL = 0x0a
|
||||
const CR = 0x0d
|
||||
const LB = 0x5b // [
|
||||
const RB = 0x5d // ]
|
||||
const SP = 0x20
|
||||
const COLON = 0x3a
|
||||
const DOT = 0x2e
|
||||
const SLASH = 0x2f
|
||||
const MINUS = 0x2d
|
||||
|
||||
/** Row kinds. `kind` is what keeps enum constants out of the package tree. */
|
||||
export const KIND_OBJECT = 0
|
||||
export const KIND_ENUM = 1
|
||||
export const KIND_DAMAGED = 2
|
||||
|
||||
/** Synthetic type for enum constants written without an `EnumName::` qualifier. */
|
||||
const BARE_ENUM_TYPE = 'EnumConstant'
|
||||
|
||||
export interface DumpColumns {
|
||||
count: number
|
||||
addr: Float64Array // UObject address. 48-bit in practice, so it survives as an f64 exactly.
|
||||
typeId: Uint16Array // Index into `types`.
|
||||
types: string[]
|
||||
pathStart: Uint32Array
|
||||
pathBlob: Uint8Array
|
||||
nameOff: Uint16Array // Byte offset from pathStart[i] at which the leaf name begins.
|
||||
/**
|
||||
*
|
||||
* rootOff:
|
||||
*
|
||||
* Byte offset at which the real `/...` path begins. Array inner properties
|
||||
* are dumped as `ComponentTags./Script/Engine.ActorComponent:ComponentTags`;
|
||||
* that leading qualifier is kept for display but skipped when indexing, so
|
||||
* the inner property files under its array rather than at the tree root.
|
||||
*/
|
||||
rootOff: Uint16Array
|
||||
outer: Float64Array // Address from `or:` (outer) or `owr:` (owner), whichever the line carried. 0 = none.
|
||||
offset: Int32Array // field offset, or -1 when the line had none.
|
||||
kind: Uint8Array // KIND_OBJECT | KIND_ENUM | KIND_DAMAGED.
|
||||
/**
|
||||
* `v:` on enum constants. i64 because four of them are 2^50..2^56 bit flags
|
||||
* and one (2^56+1) is not representable exactly as an f64.
|
||||
*/
|
||||
value: BigInt64Array
|
||||
skipped: number // Lines that carried no usable record at all.
|
||||
damaged: number // Rows recovered from a truncated line; their groups are gone.
|
||||
}
|
||||
|
||||
function hexAt(b: Uint8Array, i: number, end: number): number {
|
||||
let v = 0
|
||||
for (; i < end; i++) {
|
||||
const c = b[i]!
|
||||
if (c >= 0x30 && c <= 0x39) v = v * 16 + (c - 0x30)
|
||||
else if (c >= 0x61 && c <= 0x66) v = v * 16 + (c - 0x57)
|
||||
else if (c >= 0x41 && c <= 0x46) v = v * 16 + (c - 0x37)
|
||||
else break
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
/** `v:` is decimal and signed, unlike every other value in the file. */
|
||||
function decAt(b: Uint8Array, i: number, end: number): bigint {
|
||||
let neg = false
|
||||
if (i < end && b[i] === MINUS) {
|
||||
neg = true
|
||||
i++
|
||||
}
|
||||
let v = 0n
|
||||
for (; i < end; i++) {
|
||||
const c = b[i]!
|
||||
if (c < 0x30 || c > 0x39) break
|
||||
v = v * 10n + BigInt(c - 0x30)
|
||||
}
|
||||
return neg ? -v : v
|
||||
}
|
||||
|
||||
const isAlpha = (c: number) => (c >= 0x61 && c <= 0x7a) || (c >= 0x41 && c <= 0x5a)
|
||||
|
||||
export function parseDump( src: Uint8Array, onProgress?: (fraction: number) => void): DumpColumns {
|
||||
|
||||
const len = src.length
|
||||
|
||||
let lines = 0
|
||||
for (let i = 0; i < len; i++) if (src[i] === NL) lines++
|
||||
if (len > 0 && src[len - 1] !== NL) lines++
|
||||
|
||||
const addr = new Float64Array(lines)
|
||||
const typeId = new Uint16Array(lines)
|
||||
const pathStart = new Uint32Array(lines + 1)
|
||||
const nameOff = new Uint16Array(lines)
|
||||
const rootOff = new Uint16Array(lines)
|
||||
const outer = new Float64Array(lines)
|
||||
const offset = new Int32Array(lines)
|
||||
const kind = new Uint8Array(lines)
|
||||
const value = new BigInt64Array(lines)
|
||||
const pathBlob = new Uint8Array(len) // Upper bound.
|
||||
|
||||
const types: string[] = []
|
||||
// hash -> type id. Collision risk across distinct type names is nil and skipping the decode here is worth several hundred ms.
|
||||
const typeIndex = new Map<number, number>()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
/** Intern a type name held as a byte range. */
|
||||
function internRange(s: number, end: number): number {
|
||||
let h = 0x811c9dc5
|
||||
for (let k = s; k < end; k++) h = Math.imul(h ^ src[k]!, 0x01000193)
|
||||
h = (h ^ (end - s)) >>> 0
|
||||
let id = typeIndex.get(h)
|
||||
if (id === undefined) {
|
||||
id = types.length
|
||||
types.push(decoder.decode(src.subarray(s, end)))
|
||||
typeIndex.set(h, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/** Intern a type name we synthesised rather than read. */
|
||||
const synthetic = new Map<string, number>()
|
||||
function internString(name: string): number {
|
||||
let id = synthetic.get(name)
|
||||
if (id === undefined) {
|
||||
id = types.length
|
||||
types.push(name)
|
||||
synthetic.set(name, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
let row = 0
|
||||
let w = 0
|
||||
let skipped = 0
|
||||
let damaged = 0
|
||||
let i = 0
|
||||
let nextProgress = len >> 5
|
||||
|
||||
while (i < len) {
|
||||
let lineEnd = i
|
||||
while (lineEnd < len && src[lineEnd] !== NL) lineEnd++
|
||||
let e = lineEnd
|
||||
if (e > i && src[e - 1] === CR) e--
|
||||
|
||||
let p = i
|
||||
i = lineEnd + 1
|
||||
|
||||
if (onProgress && p > nextProgress) {
|
||||
onProgress(p / len)
|
||||
nextProgress = p + (len >> 5)
|
||||
}
|
||||
|
||||
while (p < e && src[p] === SP) p++
|
||||
if (p >= e) continue // blank
|
||||
|
||||
// --- address ---
|
||||
// A truncated continuation line has no `[ADDR]`; it still names a real
|
||||
// object, so recover it with a null address rather than dropping it.
|
||||
let address = 0
|
||||
let isFragment = false
|
||||
if (src[p] === LB) {
|
||||
let q = p + 1
|
||||
while (q < e && src[q] !== RB) q++
|
||||
if (q >= e) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
address = hexAt(src, p + 1, q)
|
||||
p = q + 1
|
||||
} else {
|
||||
isFragment = true
|
||||
}
|
||||
while (p < e && src[p] === SP) p++
|
||||
|
||||
// --- type name ---
|
||||
const tStart = p
|
||||
while (p < e && src[p] !== SP) p++
|
||||
const tEnd = p
|
||||
if (tEnd === tStart) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
while (p < e && src[p] === SP) p++
|
||||
|
||||
// --- trailing [key: value] groups, peeled right to left ---
|
||||
const bodyStart = p
|
||||
let outerV = 0
|
||||
let offsetV = -1
|
||||
let valueV = 0n
|
||||
let hasValue = false
|
||||
let groupsAt = e
|
||||
|
||||
for (;;) {
|
||||
let s = groupsAt
|
||||
while (s > bodyStart && src[s - 1] === SP) s--
|
||||
if (s <= bodyStart || src[s - 1] !== RB) break
|
||||
|
||||
// No object path contains a bracket, so the nearest '[' opens this group.
|
||||
let open = s - 2
|
||||
while (open >= bodyStart && src[open] !== LB) open--
|
||||
if (open < bodyStart) break
|
||||
|
||||
// Must look like `key: value` with a short alphabetic key.
|
||||
let k = open + 1
|
||||
while (k < s - 1 && isAlpha(src[k]!)) k++
|
||||
if (k === open + 1 || k - (open + 1) > 6 || k >= s - 1 || src[k] !== COLON) break
|
||||
|
||||
const kStart = open + 1
|
||||
const kLen = k - kStart
|
||||
let vStart = k + 1
|
||||
while (vStart < s - 1 && src[vStart] === SP) vStart++
|
||||
const vEnd = s - 1
|
||||
|
||||
if (kLen === 1 && src[kStart] === 0x6f) {
|
||||
offsetV = hexAt(src, vStart, vEnd) // o: field offset
|
||||
} else if (kLen === 1 && src[kStart] === 0x76) {
|
||||
valueV = decAt(src, vStart, vEnd) // v: enum constant
|
||||
hasValue = true
|
||||
} else if (kLen === 2 && src[kStart] === 0x6f && src[kStart + 1] === 0x72) {
|
||||
outerV = hexAt(src, vStart, vEnd) // or: outer
|
||||
} else if (
|
||||
kLen === 3 &&
|
||||
src[kStart] === 0x6f &&
|
||||
src[kStart + 1] === 0x77 &&
|
||||
src[kStart + 2] === 0x72
|
||||
) {
|
||||
outerV = hexAt(src, vStart, vEnd) // owr: owner
|
||||
}
|
||||
|
||||
groupsAt = open
|
||||
}
|
||||
|
||||
// --- object path: everything between the type name and the first group ---
|
||||
const pStart = bodyStart
|
||||
let pEnd = groupsAt
|
||||
while (pEnd > pStart && src[pEnd - 1] === SP) pEnd--
|
||||
|
||||
// A bracket surviving in the path region means the line was cut mid-group.
|
||||
let rowKind = isFragment ? KIND_DAMAGED : KIND_OBJECT
|
||||
for (let k = pStart; k < pEnd; k++) {
|
||||
if (src[k] === LB || src[k] === RB) {
|
||||
pEnd = k
|
||||
while (pEnd > pStart && src[pEnd - 1] === SP) pEnd--
|
||||
rowKind = KIND_DAMAGED
|
||||
outerV = 0
|
||||
offsetV = -1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let tid: number
|
||||
if (pEnd === pStart) {
|
||||
// No path: an enum constant, `EnumName::ValueName` or a bare `CIM_Linear`.
|
||||
// The qualifier is the useful facet, so it becomes the type and the whole
|
||||
// token becomes the path, which gives the row a searchable leaf name.
|
||||
rowKind = KIND_ENUM
|
||||
let sep = -1
|
||||
for (let k = tStart; k + 1 < tEnd; k++) {
|
||||
if (src[k] === COLON && src[k + 1] === COLON) {
|
||||
sep = k
|
||||
break
|
||||
}
|
||||
}
|
||||
tid = sep < 0 ? internString(BARE_ENUM_TYPE) : internRange(tStart, sep)
|
||||
pathBlob.set(src.subarray(tStart, tEnd), w)
|
||||
// Leaf name is the part after `::`.
|
||||
nameOff[row] = sep < 0 ? 0 : sep + 2 - tStart
|
||||
rootOff[row] = 0
|
||||
pathStart[row] = w
|
||||
w += tEnd - tStart
|
||||
} else {
|
||||
tid = internRange(tStart, tEnd)
|
||||
|
||||
// --- indexable root: skip a leading `Qualifier.` before the first '/' ---
|
||||
let r = pStart
|
||||
if (r < pEnd && src[r] !== SLASH) {
|
||||
let k = r
|
||||
while (k < pEnd && src[k] !== SLASH) k++
|
||||
if (k < pEnd) r = k
|
||||
}
|
||||
|
||||
// --- leaf name: last '/', '.' or ':' ---
|
||||
let n = pEnd
|
||||
while (n > pStart) {
|
||||
const c = src[n - 1]
|
||||
if (c === COLON || c === DOT || c === SLASH) break
|
||||
n--
|
||||
}
|
||||
|
||||
pathStart[row] = w
|
||||
nameOff[row] = Math.min(n - pStart, 0xffff)
|
||||
rootOff[row] = Math.min(r - pStart, 0xffff)
|
||||
pathBlob.set(src.subarray(pStart, pEnd), w)
|
||||
w += pEnd - pStart
|
||||
}
|
||||
|
||||
if (rowKind === KIND_DAMAGED) damaged++
|
||||
|
||||
addr[row] = address
|
||||
typeId[row] = tid
|
||||
outer[row] = outerV
|
||||
offset[row] = offsetV
|
||||
kind[row] = rowKind
|
||||
value[row] = hasValue ? valueV : 0n
|
||||
row++
|
||||
}
|
||||
|
||||
pathStart[row] = w
|
||||
onProgress?.(1)
|
||||
|
||||
return {
|
||||
count: row,
|
||||
addr: addr.subarray(0, row),
|
||||
typeId: typeId.subarray(0, row),
|
||||
types,
|
||||
pathStart: pathStart.subarray(0, row + 1),
|
||||
pathBlob: pathBlob.slice(0, w),
|
||||
nameOff: nameOff.subarray(0, row),
|
||||
rootOff: rootOff.subarray(0, row),
|
||||
outer: outer.subarray(0, row),
|
||||
offset: offset.subarray(0, row),
|
||||
kind: kind.subarray(0, row),
|
||||
value: value.subarray(0, row),
|
||||
skipped,
|
||||
damaged,
|
||||
}
|
||||
}
|
||||
323
app/lib/dump-store.ts
Normal file
323
app/lib/dump-store.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { KIND_ENUM, type DumpColumns } from './dump-parse'
|
||||
|
||||
const COLON = 0x3a
|
||||
const DOT = 0x2e
|
||||
const SLASH = 0x2f
|
||||
|
||||
export interface Query {
|
||||
typeIds?: number[] // Type ids to keep. Empty/undefined = all types.
|
||||
pathPrefix?: string // Exact prefix on the object path, e.g. "/Script/Engine".
|
||||
nameContains?: string // Case-insensitive substring on the leaf name.
|
||||
pathContains?: string // Case-insensitive substring on the whole path.
|
||||
sort?: 'path' | 'address' | 'offset' | 'natural'
|
||||
descending?: boolean
|
||||
}
|
||||
|
||||
export interface TreeChild {
|
||||
segment: string // Segment text, without its leading separator.
|
||||
separator: string // The separator that introduced it: '/', '.' or ':'.
|
||||
path: string // Full path prefix including this segment.
|
||||
count: number // Objects at or under this node.
|
||||
isObject: boolean // True when an object exists at exactly this path.
|
||||
}
|
||||
|
||||
function fold(c: number): number {
|
||||
return c >= 0x41 && c <= 0x5a ? c + 32 : c
|
||||
}
|
||||
|
||||
function encodeLower(s: string): Uint8Array {
|
||||
return new TextEncoder().encode(s.toLowerCase())
|
||||
}
|
||||
|
||||
export class DumpStore {
|
||||
readonly cols: DumpColumns
|
||||
private decoder = new TextDecoder()
|
||||
/** Row indices ordered by path bytes. Built lazily; powers the tree. */
|
||||
private byPath: Uint32Array | null = null
|
||||
|
||||
constructor(cols: DumpColumns) {
|
||||
this.cols = cols
|
||||
}
|
||||
|
||||
get count() {
|
||||
return this.cols.count
|
||||
}
|
||||
|
||||
path(row: number): string {
|
||||
const { pathStart, pathBlob } = this.cols
|
||||
return this.decoder.decode(pathBlob.subarray(pathStart[row]!, pathStart[row + 1]!))
|
||||
}
|
||||
|
||||
/**
|
||||
* A zero here is a real value, not a missing one: row 0 starts at
|
||||
* pathStart 0, and nameOff is 0 for any path with no separator at all
|
||||
* (`CIM_Linear`). Guarding with `!x` dropped both.
|
||||
*/
|
||||
name(row: number): string {
|
||||
const { pathStart, pathBlob, nameOff } = this.cols
|
||||
return this.decoder.decode(
|
||||
pathBlob.subarray(pathStart[row]! + nameOff[row]!, pathStart[row + 1]!),
|
||||
)
|
||||
}
|
||||
|
||||
/** Path minus the leaf name, minus the trailing separator. */
|
||||
container(row: number): string {
|
||||
const { pathStart, pathBlob, nameOff } = this.cols
|
||||
if (!nameOff[row]) return '' // the whole path is the name
|
||||
const end = pathStart[row]! + nameOff[row]! - 1
|
||||
return this.decoder.decode(pathBlob.subarray(pathStart[row]!, end))
|
||||
}
|
||||
|
||||
typeName(row: number): string {
|
||||
// typeId 0 is a legitimate type - it is whichever type the first line
|
||||
// used - so this cannot fall back on falsiness.
|
||||
return this.cols.types[this.cols.typeId[row]!] ?? 'Unknown'
|
||||
}
|
||||
|
||||
row(index: number) {
|
||||
const kind = this.cols.kind[index]!
|
||||
return {
|
||||
index,
|
||||
address: this.cols.addr[index]!,
|
||||
type: this.typeName(index),
|
||||
typeId: this.cols.typeId[index]!,
|
||||
path: this.path(index),
|
||||
name: this.name(index),
|
||||
container: this.container(index),
|
||||
outer: this.cols.outer[index]!,
|
||||
offset: this.cols.offset[index]!,
|
||||
kind,
|
||||
// Only meaningful on enum constants; i64, so it prints rather than maths.
|
||||
value: kind === KIND_ENUM ? this.cols.value[index]! : null,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- matching
|
||||
|
||||
// rootOff is 0 for every path that already starts with '/', which is nearly
|
||||
// all of them, so the old `!rootOff[row]` guard rejected everything except
|
||||
// array/map inner properties.
|
||||
private hasPrefix(row: number, pat: Uint8Array): boolean {
|
||||
const { pathStart, pathBlob, rootOff } = this.cols
|
||||
const s = pathStart[row]! + rootOff[row]!
|
||||
if (pathStart[row + 1]! - s < pat.length) return false
|
||||
for (let k = 0; k < pat.length; k++) {
|
||||
if (fold(pathBlob[s + k]!) !== pat[k]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private hasSub(row: number, pat: Uint8Array, fromName: boolean): boolean {
|
||||
const { pathStart, pathBlob, nameOff } = this.cols
|
||||
const s = pathStart[row]! + (fromName ? nameOff[row]! : 0)
|
||||
const e = pathStart[row + 1]!
|
||||
|
||||
const m = pat.length
|
||||
if (m === 0) return true
|
||||
const last = e - m
|
||||
const first = pat[0]
|
||||
for (let i = s; i <= last; i++) {
|
||||
if (fold(pathBlob[i]!) !== first) continue
|
||||
let k = 1
|
||||
while (k < m && fold(pathBlob[i + k]!) === pat[k]) k++
|
||||
if (k === m) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ query
|
||||
|
||||
/**
|
||||
* Single linear pass over every row. At 800k rows this is single-digit
|
||||
* milliseconds for type filters and ~40 ms for a substring scan, which is
|
||||
* why there is no inverted index here — it would cost more to maintain
|
||||
* than it saves.
|
||||
*/
|
||||
query(q: Query): { rows: Uint32Array; typeCounts: Int32Array } {
|
||||
const { count, typeId, types } = this.cols
|
||||
const typeCounts = new Int32Array(types.length)
|
||||
|
||||
let typeMask: Uint8Array | null = null
|
||||
if (q.typeIds && q.typeIds.length) {
|
||||
typeMask = new Uint8Array(types.length)
|
||||
for (const t of q.typeIds) typeMask[t] = 1
|
||||
}
|
||||
|
||||
const prefix = q.pathPrefix ? encodeLower(q.pathPrefix) : null
|
||||
const nameSub = q.nameContains ? encodeLower(q.nameContains) : null
|
||||
const pathSub = q.pathContains ? encodeLower(q.pathContains) : null
|
||||
|
||||
const out = new Uint32Array(count)
|
||||
let n = 0
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (prefix && !this.hasPrefix(i, prefix)) continue
|
||||
if (nameSub && !this.hasSub(i, nameSub, true)) continue
|
||||
if (pathSub && !this.hasSub(i, pathSub, false)) continue
|
||||
// Facet counts reflect everything *except* the type filter, so the
|
||||
// type list stays usable while a type filter is active.
|
||||
typeCounts[typeId[i]!]++
|
||||
if (typeMask && !typeMask[typeId[i]!]) continue
|
||||
out[n++] = i
|
||||
}
|
||||
|
||||
const rows = out.subarray(0, n)
|
||||
return { rows: this.sort(rows, q), typeCounts }
|
||||
}
|
||||
|
||||
private sort(rows: Uint32Array, q: Query): Uint32Array {
|
||||
const mode = q.sort ?? 'natural'
|
||||
if (mode === 'natural' && !q.descending) return rows
|
||||
const { addr, offset } = this.cols
|
||||
|
||||
let cmp: (a: number, b: number) => number
|
||||
if (mode === 'address') cmp = (a, b) => addr[a]! - addr[b]!
|
||||
else if (mode === 'offset') cmp = (a, b) => offset[a]! - offset[b]!
|
||||
else if (mode === 'path') cmp = (a, b) => this.cmpPath(a, b)
|
||||
else cmp = (a, b) => a - b
|
||||
|
||||
const sorted = rows.slice().sort(cmp)
|
||||
if (q.descending) sorted.reverse()
|
||||
return sorted
|
||||
}
|
||||
|
||||
/** Byte order over the *indexable* path, so inner properties sort with their array. */
|
||||
private cmpPath(a: number, b: number): number {
|
||||
const { pathStart, pathBlob, rootOff } = this.cols
|
||||
let i = pathStart[a]! + rootOff[a]!
|
||||
let j = pathStart[b]! + rootOff[b]!
|
||||
const ae = pathStart[a + 1]!
|
||||
const be = pathStart[b + 1]!
|
||||
while (i < ae && j < be) {
|
||||
const d = pathBlob[i++]! - pathBlob[j++]!
|
||||
if (d) return d
|
||||
}
|
||||
return ae - i - (be - j)
|
||||
}
|
||||
|
||||
// tree
|
||||
|
||||
/**
|
||||
* One comparator sort of the whole table. ~2 s at 800k rows; done once.
|
||||
*
|
||||
* Enum constants are excluded: they have no package path, so including them
|
||||
* grouped 15k rows under a junk root.
|
||||
*/
|
||||
private ensurePathOrder(): Uint32Array {
|
||||
if (this.byPath) return this.byPath
|
||||
const { count, kind } = this.cols
|
||||
const idx = new Uint32Array(count)
|
||||
let n = 0
|
||||
for (let i = 0; i < count; i++) if (kind[i] !== KIND_ENUM) idx[n++] = i
|
||||
const trimmed = idx.subarray(0, n)
|
||||
trimmed.sort((a, b) => this.cmpPath(a, b))
|
||||
this.byPath = trimmed
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private cmpPrefix(row: number, pat: Uint8Array): number {
|
||||
const { pathStart, pathBlob, rootOff } = this.cols
|
||||
const s = pathStart[row]! + rootOff[row]!
|
||||
const e = pathStart[row + 1]!
|
||||
for (let k = 0; k < pat.length; k++) {
|
||||
if (s + k >= e) return -1
|
||||
const d = pathBlob[s + k]! - pat[k]!
|
||||
if (d) return d < 0 ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Half-open range of path-ordered rows whose indexable path starts with `pat`. */
|
||||
private range(pat: Uint8Array): [number, number] {
|
||||
const order = this.ensurePathOrder()
|
||||
let lo = 0
|
||||
let hi = order.length
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (this.cmpPrefix(order[mid]!, pat) < 0) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
const start = lo
|
||||
hi = order.length
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (this.cmpPrefix(order[mid]!, pat) <= 0) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
return [start, lo]
|
||||
}
|
||||
|
||||
private childCache = new Map<string, TreeChild[]>()
|
||||
|
||||
/**
|
||||
* Direct children of a path prefix.
|
||||
*
|
||||
* The binary search only bounds the scan; the grouping itself is linear.
|
||||
* Jumping block-to-block looks tempting because the rows are sorted, but
|
||||
* segments are not reliably contiguous: `.` (0x2E) and `:` (0x3A) straddle
|
||||
* the digits, so `/Script/Engine.Actor:Tick`, `/Script/Engine.Actor2` and
|
||||
* `/Script/Engine.Actor.Sub` interleave. Linear + cached is correct and,
|
||||
* at a few million byte comparisons, fast enough that it does not matter.
|
||||
*/
|
||||
children(prefix: string): TreeChild[] {
|
||||
const cached = this.childCache.get(prefix)
|
||||
if (cached) return cached
|
||||
|
||||
const order = this.ensurePathOrder()
|
||||
const { pathStart, pathBlob, rootOff } = this.cols
|
||||
const patBytes = new TextEncoder().encode(prefix)
|
||||
const base = patBytes.length
|
||||
const [lo, hi] = prefix ? this.range(patBytes) : [0, order.length]
|
||||
|
||||
const groups = new Map<string, TreeChild>()
|
||||
for (let n = lo; n < hi; n++) {
|
||||
const row = order[n]!
|
||||
const s = pathStart[row]! + rootOff[row]!
|
||||
const e = pathStart[row + 1]!
|
||||
if (s + base >= e) continue // object sitting exactly at the prefix
|
||||
const sep = pathBlob[s + base]!
|
||||
let k = s + base + 1
|
||||
while (k < e && pathBlob[k] !== COLON && pathBlob[k] !== DOT && pathBlob[k] !== SLASH) k++
|
||||
|
||||
let seg = ''
|
||||
for (let m = s + base + 1; m < k; m++) seg += String.fromCharCode(pathBlob[m]!)
|
||||
const key = String.fromCharCode(sep) + seg
|
||||
|
||||
let node = groups.get(key)
|
||||
if (!node) {
|
||||
node = {
|
||||
segment: seg,
|
||||
separator: String.fromCharCode(sep),
|
||||
path: prefix + key,
|
||||
count: 0,
|
||||
isObject: false,
|
||||
}
|
||||
groups.set(key, node)
|
||||
}
|
||||
node.count++
|
||||
if (k === e) node.isObject = true
|
||||
}
|
||||
|
||||
const out = [...groups.values()].sort(
|
||||
(a, b) => b.count - a.count || a.segment.localeCompare(b.segment),
|
||||
)
|
||||
this.childCache.set(prefix, out)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Resolve a `[or:]` / `[owr:]` pointer back to a row. Built on demand. */
|
||||
private addrIndex: Map<number, number> | null = null
|
||||
rowByAddress(address: number): number {
|
||||
if (!this.addrIndex) {
|
||||
this.addrIndex = new Map()
|
||||
// Enum constants and recovered fragments all carry address 0; indexing
|
||||
// them would make every null pointer resolve to an arbitrary row.
|
||||
for (let i = 0; i < this.cols.count; i++) {
|
||||
const a = this.cols.addr[i]!
|
||||
if (a) this.addrIndex.set(a, i)
|
||||
}
|
||||
}
|
||||
if (!address) return -1
|
||||
return this.addrIndex.get(address) ?? -1
|
||||
}
|
||||
}
|
||||
60
app/lib/persist.ts
Normal file
60
app/lib/persist.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { DumpColumns } from './dump-parse'
|
||||
|
||||
const DB = 'pdx'
|
||||
const STORE = 'dumps'
|
||||
const VERSION = 1
|
||||
|
||||
export interface CachedDump {
|
||||
key: string
|
||||
label: string
|
||||
bytes: number
|
||||
parsedAt: number
|
||||
cols: DumpColumns
|
||||
}
|
||||
|
||||
let conn: Promise<IDBDatabase> | null = null
|
||||
|
||||
function open(): Promise<IDBDatabase> {
|
||||
if (conn) return conn
|
||||
conn = new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB, VERSION)
|
||||
req.onupgradeneeded = () => {
|
||||
if (!req.result.objectStoreNames.contains(STORE)) {
|
||||
req.result.createObjectStore(STORE, { keyPath: 'key' })
|
||||
}
|
||||
}
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => {
|
||||
conn = null
|
||||
reject(req.error)
|
||||
}
|
||||
})
|
||||
return conn
|
||||
}
|
||||
|
||||
function tx<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
|
||||
return open().then(
|
||||
(db) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const req = fn(db.transaction(STORE, mode).objectStore(STORE))
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity for a dump. Size plus a sparse byte sample: reading 60 MB to hash it properly costs more than re-parsing, and two dumps that agree on size and 64 spread samples are the same dump for our purposes.
|
||||
*/
|
||||
export function fingerprint(name: string, buf: Uint8Array): string {
|
||||
let h = 0x811c9dc5
|
||||
const step = Math.max(1, Math.floor(buf.length / 64))
|
||||
for (let i = 0; i < buf.length; i += step) h = Math.imul(h ^ buf[i], 0x01000193)
|
||||
return `${name}:${buf.length}:${(h >>> 0).toString(16)}`
|
||||
}
|
||||
|
||||
/** Typed arrays survive structured clone, so there is no serialisation step. */
|
||||
export const saveDump = (d: CachedDump) => tx('readwrite', (s) => s.put(d))
|
||||
export const loadDump = (key: string) => tx<CachedDump | undefined>('readonly', (s) => s.get(key))
|
||||
export const listDumps = () => tx<CachedDump[]>('readonly', (s) => s.getAll())
|
||||
export const dropDump = (key: string) => tx('readwrite', (s) => s.delete(key))
|
||||
2
app/lib/prebake-entry.ts
Normal file
2
app/lib/prebake-entry.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { parseDump } from './dump-parse'
|
||||
export { encodeBundle } from './bundle'
|
||||
2
app/lib/probe-entry.ts
Normal file
2
app/lib/probe-entry.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { parseDump } from './dump-parse'
|
||||
export { DumpStore } from './dump-store'
|
||||
78
app/lib/type-hue.ts
Normal file
78
app/lib/type-hue.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* UE type names collapse into six families that a modder actually reasons
|
||||
* about. The table paints rows by family so a 400k-row scroll still reads as
|
||||
* structure rather than noise.
|
||||
*/
|
||||
export type Family = 'container' | 'callable' | 'numeric' | 'reference' | 'bool' | 'text' | 'other'
|
||||
|
||||
const EXACT: Record<string, Family> = {
|
||||
Package: 'container',
|
||||
Class: 'container',
|
||||
BlueprintGeneratedClass: 'container',
|
||||
WidgetBlueprintGeneratedClass: 'container',
|
||||
AnimBlueprintGeneratedClass: 'container',
|
||||
ScriptStruct: 'container',
|
||||
Enum: 'container',
|
||||
UserDefinedEnum: 'container',
|
||||
UserDefinedStruct: 'container',
|
||||
Function: 'callable',
|
||||
DelegateFunction: 'callable',
|
||||
SparseDelegateFunction: 'callable',
|
||||
BoolProperty: 'bool',
|
||||
}
|
||||
|
||||
const SUFFIX: [string, Family][] = [
|
||||
['DelegateProperty', 'callable'],
|
||||
['ObjectProperty', 'reference'],
|
||||
['ClassProperty', 'reference'],
|
||||
['StructProperty', 'reference'],
|
||||
['ArrayProperty', 'reference'],
|
||||
['MapProperty', 'reference'],
|
||||
['SetProperty', 'reference'],
|
||||
['InterfaceProperty', 'reference'],
|
||||
['NameProperty', 'text'],
|
||||
['StrProperty', 'text'],
|
||||
['TextProperty', 'text'],
|
||||
['EnumProperty', 'numeric'],
|
||||
['ByteProperty', 'numeric'],
|
||||
['IntProperty', 'numeric'],
|
||||
['Int8Property', 'numeric'],
|
||||
['Int16Property', 'numeric'],
|
||||
['Int64Property', 'numeric'],
|
||||
['UInt16Property', 'numeric'],
|
||||
['UInt32Property', 'numeric'],
|
||||
['UInt64Property', 'numeric'],
|
||||
['FloatProperty', 'numeric'],
|
||||
['DoubleProperty', 'numeric'],
|
||||
]
|
||||
|
||||
const cache = new Map<string, Family>()
|
||||
|
||||
export function family(type: string): Family {
|
||||
const hit = cache.get(type)
|
||||
if (hit) return hit
|
||||
let f: Family = EXACT[type] ?? 'other'
|
||||
if (f === 'other') {
|
||||
for (const [suffix, fam] of SUFFIX) {
|
||||
if (type.endsWith(suffix)) {
|
||||
f = fam
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (f === 'other' && type.endsWith('Property')) f = 'reference'
|
||||
cache.set(type, f)
|
||||
return f
|
||||
}
|
||||
|
||||
export const hue = (type: string) => `var(--t-${family(type)})`
|
||||
|
||||
/** UE prefixes every type; the table shows the short form and keeps the rest as a title. */
|
||||
export function abbreviate(type: string): string {
|
||||
if (type.endsWith('Property')) return type.slice(0, -8)
|
||||
if (type === 'BlueprintGeneratedClass') return 'BPClass'
|
||||
if (type.endsWith('BlueprintGeneratedClass')) return type.slice(0, -22) + 'BPClass'
|
||||
if (type === 'SparseDelegateFunction') return 'SparseDlg'
|
||||
if (type === 'DelegateFunction') return 'Delegate'
|
||||
return type
|
||||
}
|
||||
118
app/workers/dump.worker.ts
Normal file
118
app/workers/dump.worker.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
/// <reference lib="webworker" />
|
||||
import { parseDump } from '../lib/dump-parse'
|
||||
import { DumpStore, type Query } from '../lib/dump-store'
|
||||
import { fingerprint, saveDump, loadDump } from '../lib/persist'
|
||||
|
||||
/**
|
||||
* The store never crosses to the main thread. The UI asks for a window of
|
||||
* rows and gets back ~60 plain objects; the 30–80 MB of columns stay here.
|
||||
*/
|
||||
let store: DumpStore | null = null
|
||||
let current: Uint32Array = new Uint32Array(0)
|
||||
|
||||
type Req =
|
||||
| { id: number; op: 'parse'; buffer: ArrayBuffer; name: string }
|
||||
| { id: number; op: 'restore'; key: string }
|
||||
| { id: number; op: 'query'; query: Query }
|
||||
| { id: number; op: 'window'; start: number; end: number }
|
||||
| { id: number; op: 'children'; prefix: string }
|
||||
| { id: number; op: 'resolve'; address: number }
|
||||
| { id: number; op: 'close' }
|
||||
|
||||
function summary() {
|
||||
const s = store!
|
||||
const { addr, count, types } = s.cols
|
||||
let min = Infinity
|
||||
let max = 0
|
||||
for (let i = 0; i < count; i++) {
|
||||
const el = addr[i];
|
||||
if(!el) continue
|
||||
if (el < min) min = el
|
||||
if (el > max) max = el
|
||||
}
|
||||
// Every address in a dump shares a leading run of hex digits. Finding it
|
||||
// lets the table ghost the noise and highlight the digits that differ.
|
||||
const a = min.toString(16).padStart(12, '0').toUpperCase()
|
||||
const b = max.toString(16).padStart(12, '0').toUpperCase()
|
||||
let shared = 0
|
||||
while (shared < a.length && a[shared] === b[shared]) shared++
|
||||
return { count, types, sharedAddressDigits: shared, skipped: s.cols.skipped }
|
||||
}
|
||||
|
||||
async function handle(msg: Req) {
|
||||
switch (msg.op) {
|
||||
case 'parse': {
|
||||
const src = new Uint8Array(msg.buffer)
|
||||
const key = fingerprint(msg.name, src)
|
||||
const cached = await loadDump(key).catch(() => undefined)
|
||||
if (cached) {
|
||||
store = new DumpStore(cached.cols)
|
||||
return { ...summary(), key, label: cached.label, cached: true }
|
||||
}
|
||||
const t0 = performance.now()
|
||||
const cols = parseDump(src, (f) =>
|
||||
self.postMessage({ id: -1, event: 'progress', fraction: f }),
|
||||
)
|
||||
const ms = performance.now() - t0
|
||||
store = new DumpStore(cols)
|
||||
await saveDump({
|
||||
key,
|
||||
label: msg.name,
|
||||
bytes: src.length,
|
||||
parsedAt: Date.now(),
|
||||
cols,
|
||||
}).catch(() => {})
|
||||
return { ...summary(), key, label: msg.name, cached: false, parseMs: ms }
|
||||
}
|
||||
|
||||
case 'restore': {
|
||||
const cached = await loadDump(msg.key)
|
||||
if (!cached) throw new Error('That dump is no longer cached. Load the file again.')
|
||||
store = new DumpStore(cached.cols)
|
||||
return { ...summary(), key: cached.key, label: cached.label, cached: true }
|
||||
}
|
||||
|
||||
case 'query': {
|
||||
const t0 = performance.now()
|
||||
const { rows, typeCounts } = store!.query(msg.query)
|
||||
current = rows
|
||||
return { total: rows.length, typeCounts, ms: performance.now() - t0 }
|
||||
}
|
||||
|
||||
case 'window': {
|
||||
const end = Math.min(msg.end, current.length)
|
||||
const out = []
|
||||
for (let i = msg.start; i < end; i++) {
|
||||
const el = current[i];
|
||||
if(!el) continue
|
||||
const row = store!.row(el)
|
||||
if (row) out.push(row)
|
||||
}
|
||||
return { start: msg.start, rows: out }
|
||||
}
|
||||
|
||||
case 'children':
|
||||
return { prefix: msg.prefix, children: store!.children(msg.prefix) }
|
||||
|
||||
case 'resolve': {
|
||||
const row = store!.rowByAddress(msg.address)
|
||||
return { row: row >= 0 ? store!.row(row) : null }
|
||||
}
|
||||
|
||||
// Drops the only references to the columns; the dump stays in IndexedDB,
|
||||
// so 'restore' can bring it back without re-parsing.
|
||||
case 'close': {
|
||||
store = null
|
||||
current = new Uint32Array(0)
|
||||
return { closed: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
self.onmessage = async (e: MessageEvent<Req>) => {
|
||||
try {
|
||||
self.postMessage({ id: e.data.id, result: await handle(e.data) })
|
||||
} catch (err) {
|
||||
self.postMessage({ id: e.data.id, error: (err as Error).message })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user