Files
ue4ss-explorer/app/app.vue
Hunter 45b1539c68 V.1
2026-08-07 17:11:04 -04:00

879 lines
23 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { useDump, type RowView } from './composables/useDump'
import { useFrames } from './composables/useFrames'
import { hue, abbreviate } from './lib/type-hue'
import { listDumps, dropDump, type CachedDump } from './lib/persist'
import { listHosted, download, fileNameOf, type HostedDump } from './lib/hosted'
import { encodeView, decodeView, emptyView, type ViewState } from './lib/view-state'
import type { SortKey } from './lib/dump-store'
import HeaderBar from '@/components/HeaderBar.vue'
const d = useDump()
const frames = useFrames()
const nameQuery = ref('')
const pathQuery = ref('')
const pathPrefix = ref('')
const activeTypes = ref<number[]>([])
const sort = ref<SortKey>('natural')
const descending = ref(false)
const inspected = ref<RowView | null>(null)
const outerOf = ref<RowView | null>(null)
const cached = ref<CachedDump[]>([])
const hosted = ref<HostedDump[]>([])
const showAllTypes = ref(false)
const dragging = ref(false)
const treeVersion = ref(0)
/**
* Filters carried in from a link, held until the dump they belong to is up.
*
* The hash is kept alongside them so that a recipient who did not have the
* dump, and then loads the file by hand, still lands on the sender's view —
* while someone who opens an unrelated dump instead does not inherit filters
* meant for a different one.
*/
const pending = ref<{ hash: string; view: ViewState } | null>(null)
const fmt = new Intl.NumberFormat()
// ------------------------------------------------------------------ the view
/**
* Type filters travel as names, not as the ids the store indexes by, so both
* directions have to go through the dump's type table. A name the current
* dump does not have simply drops out — a link from a newer dump should lose
* the filter it cannot express, not fail to open.
*/
function currentView(): ViewState {
return {
prefix: pathPrefix.value,
name: nameQuery.value.trim(),
path: pathQuery.value.trim(),
sort: sort.value,
desc: descending.value,
types: activeTypes.value
.map((id) => d.types.value[id])
.filter((n): n is string => !!n)
.sort(),
}
}
function applyView(v: ViewState) {
pathPrefix.value = v.prefix
nameQuery.value = v.name
pathQuery.value = v.path
sort.value = v.sort
descending.value = v.desc
activeTypes.value = v.types.map((n) => d.types.value.indexOf(n)).filter((i) => i >= 0)
}
function syncUrl(v: ViewState) {
history.replaceState(null, '', encodeView(d.dumpKey.value, v))
}
let timer: ReturnType<typeof setTimeout>
function schedule() {
clearTimeout(timer)
timer = setTimeout(commit, 120)
}
/**
* The one path by which a filter change becomes a frame, a URL and a query.
*
* Replaying a frame runs through here too, and needs no guard against
* recording itself: after `applyView` the refs are *equal* to the frame at
* the cursor, and `push` drops a frame identical to the current one.
*/
function commit() {
const v = currentView()
frames.push(v)
syncUrl(v)
run()
}
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,
descending: descending.value,
})
}
watch([nameQuery, pathQuery], schedule)
watch([pathPrefix, activeTypes, sort, descending], commit, { deep: true })
/** A new dump invalidates every frame: the paths and types are a different
* vocabulary, so the queue restarts rather than carrying over. */
function resetTo(v: ViewState) {
applyView(v)
inspected.value = null
outerOf.value = null
showAllTypes.value = false
treeVersion.value++
frames.reset(currentView())
syncUrl(frames.current.value)
run()
}
watch(d.count, () => {
const p = pending.value
const claimed = !!p && p.hash === d.dumpKey.value
resetTo(claimed ? p!.view : emptyView())
if (claimed) {
pending.value = null
linkMiss.value = false
}
refreshCached()
})
function setSort(key: SortKey, desc: boolean) {
sort.value = key
descending.value = desc
}
function step(delta: -1 | 1) {
const f = delta < 0 ? frames.back() : frames.forward()
if (!f) return
applyView(f)
syncUrl(f)
run()
}
const copied = ref(false)
async function copyLink() {
try {
await navigator.clipboard.writeText(location.href)
copied.value = true
setTimeout(() => (copied.value = false), 1400)
} catch {
/* clipboard blocked; the URL bar already shows the link */
}
}
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()
}
// ---------------------------------------------------------------- site dumps
const grabbing = ref('')
const grabPct = ref<number | null>(0)
const grabError = ref('')
/** Only dumps this browser has not already taken. The cached copy records the
* manifest id it came from, which is what makes "already have it" answerable
* without re-hashing a 37 MB file. */
const offered = computed(() => {
const have = new Set(cached.value.map((c) => c.source).filter(Boolean))
return hosted.value.filter((h) => !have.has(h.id))
})
/**
* Download on demand, never on load. One interaction fetches, caches, and
* opens the dump, so the offer disappearing from this list is the same
* gesture that puts it on screen.
*/
async function grab(h: HostedDump) {
grabbing.value = h.id
grabError.value = ''
grabPct.value = 0
try {
const buffer = await download(h, (f) => (grabPct.value = f))
await d.loadBuffer(buffer, fileNameOf(h), { format: h.format ?? 'text', source: h.id })
await refreshCached()
} catch (e) {
grabError.value = (e as Error).message
} finally {
grabbing.value = ''
grabPct.value = 0
}
}
/** 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() {
// The d.count watcher runs resetTo(), which clears every filter, the frame
// queue and the URL. Only the things outside that surface are left here.
await d.reset()
dragging.value = false
}
/**
* A link carries a dump hash and a set of filters, but never the dump — 37 MB
* does not fit in a URL. The recipient either already has that dump in
* IndexedDB, in which case it opens with the sender's filters intact, or they
* do not, and they land on the normal intro with a note saying so.
*/
const linkMiss = ref(false)
onMounted(async () => {
await refreshCached()
listHosted()
.then((h) => (hosted.value = h))
.catch(() => {})
const { dump, view } = decodeView(location.search)
if (!dump) return
pending.value = { hash: dump, view }
if (!(await d.restore(dump))) {
linkMiss.value = true
// Not surfaced as an error: a link arriving without its dump is the
// expected case, and `linkMiss` says so in plainer terms. The pending
// view stays put so that loading the file by hand still lands on it.
d.error.value = ''
}
})
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>
<p v-if="linkMiss" class="err mono">
That link points at a dump this browser has not loaded yet. Open the same
file below or drop it above, and the link will work from then on.
</p>
<div v-if="offered.length" class="cached">
<p class="eyebrow">Available from this site</p>
<div v-for="h in offered" :key="h.id" class="cache-row mono">
<span class="cache-name">{{ h.title }}</span>
<span class="cache-meta">
<template v-if="h.bytes">{{ mb(h.bytes) }}</template>
<template v-if="h.note"> · {{ h.note }}</template>
</span>
<button
class="get"
:disabled="!!grabbing"
@click="grab(h)"
>
<template v-if="grabbing !== h.id">download</template>
<template v-else-if="grabPct === null">downloading…</template>
<template v-else>{{ Math.round(grabPct * 100) }}%</template>
</button>
</div>
<div v-if="grabbing" class="meter thin">
<i :style="{ width: (grabPct ?? 0) * 100 + '%' }" />
</div>
<p v-if="grabError" class="err mono">{{ grabError }}</p>
</div>
<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">
<!-- Filter history. Every change to the query surface lands here as
a frame, so stepping back is stepping back through views. -->
<div class="frames mono">
<button
:disabled="!frames.canBack.value"
title="Previous view"
aria-label="Previous view"
@click="step(-1)"
>
</button>
<span class="tally" :title="`Frame ${frames.index.value + 1} of ${frames.frames.value.length}`">
{{ frames.index.value + 1 }}/{{ frames.frames.value.length }}
</span>
<button
:disabled="!frames.canForward.value"
title="Next view"
aria-label="Next view"
@click="step(1)"
>
</button>
</div>
<input v-model="nameQuery" placeholder="search name…" spellcheck="false" />
<input v-model="pathQuery" placeholder="search full path…" spellcheck="false" />
<button class="share mono" title="Copy a link to this exact view" @click="copyLink">
{{ copied ? 'copied' : 'link' }}
</button>
</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"
:sort="sort"
:descending="descending"
:fetch-window="d.fetchWindow"
@inspect="inspect"
@sort="setSort"
/>
<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);
}
.get {
flex: none;
font-family: inherit;
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--amber);
background: none;
border: 1px solid var(--rule);
border-radius: 3px;
padding: 4px 9px;
cursor: pointer;
}
.get:hover:not(:disabled) {
border-color: var(--amber);
}
.get:disabled {
color: var(--dimmer);
cursor: default;
}
.meter.thin {
width: 100%;
margin-top: 8px;
}
/* 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;
align-items: stretch;
}
.searches input {
flex: 1;
min-width: 0;
}
.frames {
display: flex;
align-items: center;
flex: none;
border: 1px solid var(--rule);
border-radius: 3px;
overflow: hidden;
}
.frames button {
width: 22px;
align-self: stretch;
font-size: 15px;
line-height: 1;
color: var(--amber);
background: none;
border: none;
cursor: pointer;
}
.frames button:disabled {
color: var(--dimmer);
cursor: default;
}
.frames button:not(:disabled):hover {
background: var(--raise);
}
.tally {
font-size: 10px;
color: var(--dim);
padding: 0 6px;
border-left: 1px solid var(--rule);
border-right: 1px solid var(--rule);
align-self: stretch;
display: flex;
align-items: center;
}
.share {
flex: none;
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--dim);
background: none;
border: 1px solid var(--rule);
border-radius: 3px;
padding: 0 10px;
cursor: pointer;
}
.share:hover {
color: var(--amber);
border-color: var(--amber);
}
.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>