initial commit

This commit is contained in:
Hunter
2026-07-29 14:20:30 -04:00
commit 6702030406
27 changed files with 14828 additions and 0 deletions

13
.dockerignore Normal file
View 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
View File

@@ -0,0 +1,6 @@
node_modules/
dump/
dist/
*.env
.nuxt/
.output/

22
Dockerfile Normal file
View 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
View 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
View 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
View 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;
}
}

View 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>

View 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
View 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
View 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
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
export { parseDump } from './dump-parse'
export { encodeBundle } from './bundle'

2
app/lib/probe-entry.ts Normal file
View File

@@ -0,0 +1,2 @@
export { parseDump } from './dump-parse'
export { DumpStore } from './dump-store'

78
app/lib/type-hue.ts Normal file
View 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
View 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 3080 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

18
package.json Normal file
View 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
View 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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

44
scripts/prebake.mjs Normal file
View 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}`)