initial commit
This commit is contained in:
13
.dockerignore
Normal file
13
.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
*.env
|
||||||
|
Dockerfile*
|
||||||
|
docker-compose*.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# The raw 60 MB dump is only an input to `npm run prebake`, never to the build.
|
||||||
|
dump/
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dump/
|
||||||
|
dist/
|
||||||
|
*.env
|
||||||
|
.nuxt/
|
||||||
|
.output/
|
||||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ---- build ------------------------------------------------------------------
|
||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Baked into the client bundle at build time, so it has to be set here, not at run.
|
||||||
|
ARG NUXT_PUBLIC_URL_BASE=https://u34ss-explorer.gimme.pet
|
||||||
|
ENV NUXT_PUBLIC_URL_BASE=$NUXT_PUBLIC_URL_BASE
|
||||||
|
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- serve ------------------------------------------------------------------
|
||||||
|
FROM nginx:1.27-alpine AS runtime
|
||||||
|
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/.output/public /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
12
Dockerfile.dev
Normal file
12
Dockerfile.dev
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
FROM node:22-alpine
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
|
||||||
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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
37
docker-compose.yml
Normal file
37
docker-compose.yml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
services:
|
||||||
|
# docker compose --profile dev up
|
||||||
|
dev:
|
||||||
|
profiles: [dev]
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.dev
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
volumes:
|
||||||
|
- .:/app
|
||||||
|
# Keep the image's node_modules; the host tree is Windows-built.
|
||||||
|
- /app/node_modules
|
||||||
|
- /app/.nuxt
|
||||||
|
- /app/.output
|
||||||
|
environment:
|
||||||
|
# Bind-mounted source on Windows doesn't emit inotify events.
|
||||||
|
CHOKIDAR_USEPOLLING: "true"
|
||||||
|
WATCHPACK_POLLING: "true"
|
||||||
|
NUXT_PUBLIC_URL_BASE: http://localhost:3000
|
||||||
|
tty: true
|
||||||
|
|
||||||
|
# docker compose up (default profile)
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
NUXT_PUBLIC_URL_BASE: ${NUXT_PUBLIC_URL_BASE:-https://u34ss-explorer.gimme.pet}
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost/"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 3
|
||||||
38
docker/nginx.conf
Normal file
38
docker/nginx.conf
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
gzip on;
|
||||||
|
gzip_vary on;
|
||||||
|
gzip_comp_level 6;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
gzip_proxied any;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json
|
||||||
|
image/svg+xml;
|
||||||
|
|
||||||
|
# Hashed build assets — safe to cache forever.
|
||||||
|
location /_nuxt/ {
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# The prebaked dump is a multi-MB binary blob. It's already compact and
|
||||||
|
# gzip on 37 MB per request is not worth the CPU, so it stays out of
|
||||||
|
# gzip_types; the explicit type keeps it off nginx's text guesses.
|
||||||
|
location ~* \.pdx$ {
|
||||||
|
default_type application/octet-stream;
|
||||||
|
add_header Cache-Control "public, max-age=2592000";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA: ssr is off, so every route resolves to the shell.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /index.html {
|
||||||
|
add_header Cache-Control "no-cache";
|
||||||
|
}
|
||||||
|
}
|
||||||
35
nuxt.config.ts
Normal file
35
nuxt.config.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export default defineNuxtConfig({
|
||||||
|
srcDir: 'app/',
|
||||||
|
|
||||||
|
// nothing here is renderable without the dump in memory
|
||||||
|
ssr: false,
|
||||||
|
|
||||||
|
devtools: { enabled: false },
|
||||||
|
css: ['~/assets/css/main.css'],
|
||||||
|
|
||||||
|
app: {
|
||||||
|
head: {
|
||||||
|
title: 'Object Dump Explorer',
|
||||||
|
link: [
|
||||||
|
{ rel: 'preconnect', href: 'https://fonts.googleapis.com' },
|
||||||
|
{ rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
|
||||||
|
{
|
||||||
|
rel: 'stylesheet',
|
||||||
|
href: 'https://fonts.googleapis.com/css2?family=Archivo:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;700&display=swap',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
vite: {
|
||||||
|
worker: { format: 'es' },
|
||||||
|
},
|
||||||
|
|
||||||
|
runtimeConfig: {
|
||||||
|
public: {
|
||||||
|
urlBase: 'https://u34ss-explorer.gimme.pet'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
modules: ['@nuxt/image']
|
||||||
|
})
|
||||||
12062
package-lock.json
generated
Normal file
12062
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "palworld-dump-explorer",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"build": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"prebake": "node scripts/prebake.mjs"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@nuxt/image": "^2.1.0",
|
||||||
|
"esbuild": "^0.28.1",
|
||||||
|
"nuxt": "^4.5.1",
|
||||||
|
"typescript": "^7.0.2",
|
||||||
|
"vue": "^3.5.40"
|
||||||
|
}
|
||||||
|
}
|
||||||
23
probe.tmp.mjs
Normal file
23
probe.tmp.mjs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { readFileSync } from 'node:fs'
|
||||||
|
import { build } from 'esbuild'
|
||||||
|
|
||||||
|
const bundled = await build({
|
||||||
|
entryPoints: ['app/lib/probe-entry.ts'],
|
||||||
|
bundle: true, format: 'esm', platform: 'node', write: false,
|
||||||
|
})
|
||||||
|
const mod = await import('data:text/javascript;base64,' + Buffer.from(bundled.outputFiles[0].text).toString('base64'))
|
||||||
|
|
||||||
|
const src = new Uint8Array(readFileSync('dump/UE4SS_ObjectDump.txt'))
|
||||||
|
const cols = mod.parseDump(src)
|
||||||
|
const store = new mod.DumpStore(cols)
|
||||||
|
console.log('count', cols.count, 'skipped', cols.skipped, 'types', cols.types.length)
|
||||||
|
|
||||||
|
for (const p of ['/Script/Engine', '/Game/Pal', '/Script/Water', '/Engine', '/Script/SkyCreatorPlugin']) {
|
||||||
|
const { rows } = store.query({ pathPrefix: p })
|
||||||
|
console.log(`pathPrefix ${p.padEnd(26)} -> ${rows.length} rows`)
|
||||||
|
}
|
||||||
|
console.log('\nrow(0):', store.row(0))
|
||||||
|
console.log('row(1):', store.row(1))
|
||||||
|
const { rows } = store.query({ nameContains: 'Package' })
|
||||||
|
console.log('\nnameContains "Package" ->', rows.length)
|
||||||
|
console.log('\ntree roots:', store.children('').slice(0, 8).map(c => `${c.separator}${c.segment}(${c.count})`))
|
||||||
BIN
public/PalDump_07_20_26.pdx
Normal file
BIN
public/PalDump_07_20_26.pdx
Normal file
Binary file not shown.
BIN
public/img/icon_civcore.webp
Normal file
BIN
public/img/icon_civcore.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
44
scripts/prebake.mjs
Normal file
44
scripts/prebake.mjs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* node scripts/prebake.mjs <dump.txt> [public/dump.pdx]
|
||||||
|
*
|
||||||
|
* Use this when the dump ships with the site. Parsing 60 MB in the browser
|
||||||
|
* costs about a second on a good machine and considerably more on a laptop;
|
||||||
|
* doing it here costs nothing at runtime.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
||||||
|
import { dirname } from 'node:path'
|
||||||
|
import { gzipSync } from 'node:zlib'
|
||||||
|
import { build } from 'esbuild'
|
||||||
|
|
||||||
|
const [, , input, output = 'public/dump.pdx'] = process.argv
|
||||||
|
if (!input) {
|
||||||
|
console.error('usage: node scripts/prebake.mjs <dump.txt> [out.pdx]')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The parser is TypeScript and shared with the app; transpile it on the fly.
|
||||||
|
const bundled = await build({
|
||||||
|
entryPoints: ['app/lib/prebake-entry.ts'],
|
||||||
|
bundle: true,
|
||||||
|
format: 'esm',
|
||||||
|
platform: 'node',
|
||||||
|
write: false,
|
||||||
|
})
|
||||||
|
const mod = await import(
|
||||||
|
'data:text/javascript;base64,' + Buffer.from(bundled.outputFiles[0].text).toString('base64')
|
||||||
|
)
|
||||||
|
|
||||||
|
const src = new Uint8Array(readFileSync(input))
|
||||||
|
const t0 = performance.now()
|
||||||
|
const cols = mod.parseDump(src)
|
||||||
|
const bundle = mod.encodeBundle(cols)
|
||||||
|
mkdirSync(dirname(output), { recursive: true })
|
||||||
|
writeFileSync(output, bundle)
|
||||||
|
|
||||||
|
const gz = gzipSync(bundle, { level: 6 }).length
|
||||||
|
console.log(` input ${(src.length / 1e6).toFixed(1)} MB`)
|
||||||
|
console.log(` objects ${cols.count.toLocaleString()} (${cols.skipped} unparsed)`)
|
||||||
|
console.log(` parse ${(performance.now() - t0).toFixed(0)} ms`)
|
||||||
|
console.log(` output ${(bundle.length / 1e6).toFixed(1)} MB -> ${(gz / 1e6).toFixed(1)} MB gzipped`)
|
||||||
|
console.log(` written ${output}`)
|
||||||
Reference in New Issue
Block a user