This commit is contained in:
Hunter
2026-08-07 17:11:04 -04:00
parent 6702030406
commit 45b1539c68
12 changed files with 851 additions and 61 deletions

View File

@@ -1,29 +1,95 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { useDump, type RowView } from './composables/useDump' import { useDump, type RowView } from './composables/useDump'
import { useFrames } from './composables/useFrames'
import { hue, abbreviate } from './lib/type-hue' import { hue, abbreviate } from './lib/type-hue'
import { listDumps, dropDump, type CachedDump } from './lib/persist' import { listDumps, dropDump, type CachedDump } from './lib/persist'
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' import HeaderBar from '@/components/HeaderBar.vue'
const d = useDump() const d = useDump()
const frames = useFrames()
const nameQuery = ref('') const nameQuery = ref('')
const pathQuery = ref('') const pathQuery = ref('')
const pathPrefix = ref('') const pathPrefix = ref('')
const activeTypes = ref<number[]>([]) const activeTypes = ref<number[]>([])
const sort = ref<'natural' | 'path' | 'address' | 'offset'>('natural') const sort = ref<SortKey>('natural')
const descending = ref(false)
const inspected = ref<RowView | null>(null) const inspected = ref<RowView | null>(null)
const outerOf = ref<RowView | null>(null) const outerOf = ref<RowView | null>(null)
const cached = ref<CachedDump[]>([]) const cached = ref<CachedDump[]>([])
const hosted = ref<HostedDump[]>([])
const showAllTypes = ref(false) const showAllTypes = ref(false)
const dragging = ref(false) const dragging = ref(false)
const treeVersion = ref(0) 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() 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> let timer: ReturnType<typeof setTimeout>
function schedule() { function schedule() {
clearTimeout(timer) clearTimeout(timer)
timer = setTimeout(run, 120) 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() { async function run() {
@@ -34,19 +100,61 @@ async function run() {
nameContains: nameQuery.value.trim() || undefined, nameContains: nameQuery.value.trim() || undefined,
pathContains: pathQuery.value.trim() || undefined, pathContains: pathQuery.value.trim() || undefined,
sort: sort.value, sort: sort.value,
descending: descending.value,
}) })
} }
watch([nameQuery, pathQuery], schedule) watch([nameQuery, pathQuery], schedule)
watch([pathPrefix, activeTypes, sort], run, { deep: true }) watch([pathPrefix, activeTypes, sort, descending], commit, { deep: true })
watch(d.count, () => {
treeVersion.value++ /** A new dump invalidates every frame: the paths and types are a different
pathPrefix.value = '' * vocabulary, so the queue restarts rather than carrying over. */
activeTypes.value = [] function resetTo(v: ViewState) {
applyView(v)
inspected.value = null inspected.value = null
outerOf.value = null
showAllTypes.value = false
treeVersion.value++
frames.reset(currentView())
syncUrl(frames.current.value)
run() 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) { function toggleType(id: number) {
const i = activeTypes.value.indexOf(id) const i = activeTypes.value.indexOf(id)
i === -1 ? activeTypes.value.push(id) : activeTypes.value.splice(i, 1) i === -1 ? activeTypes.value.push(id) : activeTypes.value.splice(i, 1)
@@ -96,22 +204,75 @@ async function forget(key: string) {
refreshCached() 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 /** Unload the dump and drop back to the intro screen. The parsed copy stays
* in IndexedDB, so it reappears under "Already parsed". */ * in IndexedDB, so it reappears under "Already parsed". */
async function clearDump() { 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() 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 dragging.value = false
} }
onMounted(refreshCached) /**
watch(d.count, refreshCached) * 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) => const tail = (s: string, max = 72) =>
s.length <= max ? s : '…' + s.slice(s.length - max + 1) s.length <= max ? s : '…' + s.slice(s.length - max + 1)
@@ -161,6 +322,34 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
</label> </label>
<p v-if="d.error.value" class="err mono">{{ d.error.value }}</p> <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"> <div v-if="cached.length" class="cached">
<p class="eyebrow">Already parsed</p> <p class="eyebrow">Already parsed</p>
@@ -184,14 +373,36 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
<section class="main"> <section class="main">
<div class="controls"> <div class="controls">
<div class="searches"> <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="nameQuery" placeholder="search name…" spellcheck="false" />
<input v-model="pathQuery" placeholder="search full path…" spellcheck="false" /> <input v-model="pathQuery" placeholder="search full path…" spellcheck="false" />
<select v-model="sort" class="mono">
<option value="natural">dump order</option> <button class="share mono" title="Copy a link to this exact view" @click="copyLink">
<option value="path">path</option> {{ copied ? 'copied' : 'link' }}
<option value="address">address</option> </button>
<option value="offset">field offset</option>
</select>
</div> </div>
<div v-if="pathPrefix" class="crumb mono"> <div v-if="pathPrefix" class="crumb mono">
@@ -226,8 +437,11 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
<ObjectTable <ObjectTable
:total="d.total.value" :total="d.total.value"
:shared-digits="d.sharedAddressDigits.value" :shared-digits="d.sharedAddressDigits.value"
:sort="sort"
:descending="descending"
:fetch-window="d.fetchWindow" :fetch-window="d.fetchWindow"
@inspect="inspect" @inspect="inspect"
@sort="setSort"
/> />
<footer class="status mono"> <footer class="status mono">
@@ -375,6 +589,32 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
color: var(--t-bool); 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 */ /* explorer */
.body { .body {
flex: 1; flex: 1;
@@ -402,18 +642,64 @@ const mb = (n: number) => (n / 1e6).toFixed(1) + ' MB'
.searches { .searches {
display: flex; display: flex;
gap: 8px; gap: 8px;
align-items: stretch;
} }
.searches input { .searches input {
flex: 1; flex: 1;
min-width: 0;
} }
.searches select {
.frames {
display: flex;
align-items: center;
flex: none; flex: none;
font-size: 11px;
color: var(--ink);
background: var(--void);
border: 1px solid var(--rule); border: 1px solid var(--rule);
border-radius: 3px; border-radius: 3px;
padding: 0 8px; 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 { .crumb {

View File

@@ -1,15 +1,52 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import type { RowView } from '../composables/useDump' import type { RowView } from '../composables/useDump'
import type { SortKey } from '../lib/dump-store'
import { hue, abbreviate } from '../lib/type-hue' import { hue, abbreviate } from '../lib/type-hue'
const props = defineProps<{ const props = defineProps<{
total: number total: number
sharedDigits: number sharedDigits: number
sort: SortKey
descending: boolean
fetchWindow: (start: number, end: number) => Promise<{ start: number; rows: RowView[] }> fetchWindow: (start: number, end: number) => Promise<{ start: number; rows: RowView[] }>
}>() }>()
const emit = defineEmits<{ (e: 'inspect', row: RowView): void }>() const emit = defineEmits<{
(e: 'inspect', row: RowView): void
(e: 'sort', key: SortKey, descending: boolean): void
}>()
/**
* Sorting is free here, and that is not obvious given the table streams its
* rows in windows.
*
* The windowing is over an index array the worker already holds — `query`
* produces a Uint32Array of matching row numbers, sorts it, and the UI asks
* for slices of it by position. Order is therefore decided before any chunk
* is handed out, not assembled across chunks, so a re-sort is one comparator
* pass in the worker (a few hundred ms at ~800k rows, the same cost the
* existing path sort already pays) followed by the scroll reset that a new
* query does anyway. Nothing has to be re-fetched or merged.
*/
const COLUMNS: { key: SortKey; label: string; cls: string }[] = [
{ key: 'address', label: 'ADDRESS', cls: 'col-addr' },
{ key: 'type', label: 'TYPE', cls: 'col-type' },
{ key: 'name', label: 'NAME', cls: 'col-name' },
{ key: 'path', label: 'CONTAINER', cls: 'col-path' },
{ key: 'offset', label: 'OFF', cls: 'col-off' },
]
/** asc → desc → off. The third click restores dump order, which is a view
* people want back and would otherwise have no control for. */
function cycle(key: SortKey) {
if (props.sort !== key) emit('sort', key, false)
else if (!props.descending) emit('sort', key, true)
else emit('sort', 'natural', false)
}
const arrow = (key: SortKey) =>
props.sort !== key ? '' : props.descending ? '↓' : '↑'
const ROW_H = 26 const ROW_H = 26
const OVERSCAN = 24 const OVERSCAN = 24
@@ -88,11 +125,24 @@ function tail(s: string, max = 64) {
<template> <template>
<div class="table"> <div class="table">
<header class="head mono"> <header class="head mono">
<span class="col-addr">ADDRESS</span> <button
<span class="col-type">TYPE</span> v-for="c in COLUMNS"
<span class="col-name">NAME</span> :key="c.key"
<span class="col-path">CONTAINER</span> type="button"
<span class="col-off">OFF</span> class="sorter"
:class="[c.cls, { on: sort === c.key }]"
:title="
sort === c.key && descending
? 'Sorted descending — click for dump order'
: sort === c.key
? 'Sorted ascending — click for descending'
: `Sort by ${c.label.toLowerCase()}`
"
:aria-sort="sort === c.key ? (descending ? 'descending' : 'ascending') : 'none'"
@click="cycle(c.key)"
>
{{ c.label }}<i class="arrow">{{ arrow(c.key) }}</i>
</button>
</header> </header>
<div ref="viewport" class="viewport" @scroll.passive="onScroll" tabindex="0"> <div ref="viewport" class="viewport" @scroll.passive="onScroll" tabindex="0">
@@ -157,6 +207,37 @@ function tail(s: string, max = 64) {
flex: none; flex: none;
} }
/* Headers are buttons but must keep the exact column geometry of the rows
below them, so they take the same .col-* classes and add nothing but the
affordance. */
.head .sorter {
display: flex;
align-items: center;
gap: 4px;
height: 100%;
padding: 0;
font: inherit;
letter-spacing: inherit;
color: inherit;
background: none;
border: none;
cursor: pointer;
white-space: nowrap;
}
.head .sorter:hover {
color: var(--ink);
}
.head .sorter.on {
color: var(--amber);
}
.head .sorter.col-off {
justify-content: flex-end;
}
.arrow {
font-style: normal;
font-size: 9px;
}
.viewport { .viewport {
flex: 1; flex: 1;
overflow: auto; overflow: auto;

View File

@@ -43,8 +43,25 @@ async function toggle(node: Node) {
node.open = true node.open = true
} }
/**
* Ordering is applied at render rather than at fetch, so flipping it costs a
* re-sort of the nodes already on screen and never re-walks the store. The
* store hands back count order; `localeCompare` with numeric collation is
* what makes `Item_2` precede `Item_10` in the alphabetical mode.
*/
const order = ref<'count' | 'alpha'>('count')
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
function arrange(nodes: Node[]): Node[] {
const by =
order.value === 'alpha'
? (a: Node, b: Node) => collator.compare(a.segment, b.segment)
: (a: Node, b: Node) => b.count - a.count || collator.compare(a.segment, b.segment)
return nodes.slice().sort(by)
}
function flatten(nodes: Node[], out: Node[] = []): Node[] { function flatten(nodes: Node[], out: Node[] = []): Node[] {
for (const n of nodes) { for (const n of arrange(nodes)) {
out.push(n) out.push(n)
if (n.open && n.children) flatten(n.children, out) if (n.open && n.children) flatten(n.children, out)
} }
@@ -60,8 +77,26 @@ watch(() => props.version, loadRoots)
<nav class="spine"> <nav class="spine">
<div class="spine-head"> <div class="spine-head">
<span class="eyebrow">Script path</span> <span class="eyebrow">Script path</span>
<div class="head-tools">
<div class="order" role="group" aria-label="Sort tree">
<button
:class="{ on: order === 'count' }"
title="Sort by number of objects"
@click="order = 'count'"
>
count
</button>
<button
:class="{ on: order === 'alpha' }"
title="Sort alphabetically"
@click="order = 'alpha'"
>
az
</button>
</div>
<button v-if="modelValue" class="clear" @click="emit('update:modelValue', '')">clear</button> <button v-if="modelValue" class="clear" @click="emit('update:modelValue', '')">clear</button>
</div> </div>
</div>
<div class="scroll"> <div class="scroll">
<button <button
@@ -118,6 +153,12 @@ watch(() => props.version, loadRoots)
flex: none; flex: none;
} }
.head-tools {
display: flex;
align-items: center;
gap: 10px;
}
.clear { .clear {
font-size: 10px; font-size: 10px;
letter-spacing: 0.08em; letter-spacing: 0.08em;
@@ -125,6 +166,33 @@ watch(() => props.version, loadRoots)
color: var(--amber); color: var(--amber);
} }
.order {
display: flex;
border: 1px solid var(--rule);
border-radius: 3px;
overflow: hidden;
}
.order button {
font-family: var(--mono);
font-size: 10px;
letter-spacing: 0.06em;
padding: 3px 7px;
color: var(--dim);
background: none;
border: none;
cursor: pointer;
}
.order button + button {
border-left: 1px solid var(--rule);
}
.order button:hover {
color: var(--ink);
}
.order button.on {
color: var(--ink-strong);
background: var(--raise);
}
.scroll { .scroll {
overflow: auto; overflow: auto;
padding: 6px 0 20px; padding: 6px 0 20px;

View File

@@ -59,30 +59,43 @@ function call<T = any>(op: string, payload: Record<string, unknown> = {}, transf
} }
export function useDump() { export function useDump() {
async function loadFile(file: File) { /**
* @param buffer is transferred, not copied: a 60 MB structured clone is a
* visible stall. The caller must not touch it afterwards.
*/
async function loadBuffer(
buffer: ArrayBuffer,
name: string,
opts: { format?: 'text' | 'pdx'; source?: string } = {},
): Promise<boolean> {
loading.value = true loading.value = true
error.value = '' error.value = ''
progress.value = 0 progress.value = 0
try { try {
const buffer = await file.arrayBuffer() applyMeta(await call('parse', { buffer, name, ...opts }, [buffer]))
// Transferred, not copied: a 60 MB structured clone is a visible stall. return true
const r = await call('parse', { buffer, name: file.name }, [buffer])
applyMeta(r)
} catch (e) { } catch (e) {
error.value = (e as Error).message error.value = (e as Error).message
return false
} finally { } finally {
loading.value = false loading.value = false
progress.value = 0 progress.value = 0
} }
} }
async function restore(key: string) { const loadFile = async (file: File) => loadBuffer(await file.arrayBuffer(), file.name)
/** Resolves false when the key is not in the cache, which the URL restore
* path treats as "link received without the dump" rather than an error. */
async function restore(key: string): Promise<boolean> {
loading.value = true loading.value = true
error.value = '' error.value = ''
try { try {
applyMeta(await call('restore', { key })) applyMeta(await call('restore', { key }))
return true
} catch (e) { } catch (e) {
error.value = (e as Error).message error.value = (e as Error).message
return false
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -140,6 +153,7 @@ export function useDump() {
return { return {
loadFile, loadFile,
loadBuffer,
restore, restore,
reset, reset,
runQuery, runQuery,

View File

@@ -0,0 +1,64 @@
import { ref, computed, shallowRef } from 'vue'
import { emptyView, sameView, type ViewState } from '../lib/view-state'
/**
* The filter history: a queue of frames plus a cursor into it.
*
* This is deliberately the app's own history rather than the browser's.
* Browser history cannot be truncated — pushing after going back leaves the
* forward entries reachable by gesture — and it is shared with whatever the
* user did before arriving here, so "back" would eventually walk off the
* site mid-investigation. Owning the queue means the URL can be rewritten in
* place (replaceState) and still be exactly as linkable.
*/
export function useFrames() {
const frames = shallowRef<ViewState[]>([emptyView()])
const index = ref(0)
const current = computed(() => frames.value[index.value] ?? emptyView())
const canBack = computed(() => index.value > 0)
const canForward = computed(() => index.value < frames.value.length - 1)
/**
* Record a frame. A change made from anywhere but the newest frame discards
* everything after the cursor first: the user has branched, and the frames
* they walked back past are no longer reachable from where they now are.
*
* The equality check is what makes replay safe. Stepping back sets the
* filter refs to the frame at the cursor, which re-triggers the watchers
* that call this — and a frame identical to the current one is dropped, so
* no flag is needed to tell a replay from a real change.
*/
function push(v: ViewState) {
if (sameView(v, current.value)) return
const kept = frames.value.slice(0, index.value + 1)
kept.push(v)
frames.value = kept
index.value = kept.length - 1
}
/** Drop the whole queue — used when a different dump is opened. */
function reset(v: ViewState = emptyView()) {
frames.value = [v]
index.value = 0
}
const go = (delta: number) => {
const next = index.value + delta
if (next < 0 || next >= frames.value.length) return null
index.value = next
return frames.value[next]!
}
return {
frames,
index,
current,
canBack,
canForward,
push,
reset,
back: () => go(-1),
forward: () => go(1),
}
}

View File

@@ -4,12 +4,15 @@ const COLON = 0x3a
const DOT = 0x2e const DOT = 0x2e
const SLASH = 0x2f const SLASH = 0x2f
/** `natural` is dump order — the order the lines appeared in the file. */
export type SortKey = 'natural' | 'path' | 'address' | 'offset' | 'name' | 'type'
export interface Query { export interface Query {
typeIds?: number[] // Type ids to keep. Empty/undefined = all types. typeIds?: number[] // Type ids to keep. Empty/undefined = all types.
pathPrefix?: string // Exact prefix on the object path, e.g. "/Script/Engine". pathPrefix?: string // Exact prefix on the object path, e.g. "/Script/Engine".
nameContains?: string // Case-insensitive substring on the leaf name. nameContains?: string // Case-insensitive substring on the leaf name.
pathContains?: string // Case-insensitive substring on the whole path. pathContains?: string // Case-insensitive substring on the whole path.
sort?: 'path' | 'address' | 'offset' | 'natural' sort?: SortKey
descending?: boolean descending?: boolean
} }
@@ -171,9 +174,11 @@ export class DumpStore {
const { addr, offset } = this.cols const { addr, offset } = this.cols
let cmp: (a: number, b: number) => number let cmp: (a: number, b: number) => number
if (mode === 'address') cmp = (a, b) => addr[a]! - addr[b]! if (mode === 'address') cmp = (a, b) => addr[a]! - addr[b]! || a - b
else if (mode === 'offset') cmp = (a, b) => offset[a]! - offset[b]! else if (mode === 'offset') cmp = (a, b) => offset[a]! - offset[b]! || a - b
else if (mode === 'path') cmp = (a, b) => this.cmpPath(a, b) else if (mode === 'path') cmp = (a, b) => this.cmpPath(a, b)
else if (mode === 'name') cmp = (a, b) => this.cmpName(a, b) || this.cmpPath(a, b)
else if (mode === 'type') cmp = (a, b) => this.cmpType(a, b) || this.cmpPath(a, b)
else cmp = (a, b) => a - b else cmp = (a, b) => a - b
const sorted = rows.slice().sort(cmp) const sorted = rows.slice().sort(cmp)
@@ -195,6 +200,44 @@ export class DumpStore {
return ae - i - (be - j) return ae - i - (be - j)
} }
/**
* Case-folded byte order over the leaf name. Folding matters here in a way
* it does not for `cmpPath`: names are the column people read, and raw byte
* order files every lowercase name after every uppercase one.
*/
private cmpName(a: number, b: number): number {
const { pathStart, pathBlob, nameOff } = this.cols
let i = pathStart[a]! + nameOff[a]!
let j = pathStart[b]! + nameOff[b]!
const ae = pathStart[a + 1]!
const be = pathStart[b + 1]!
while (i < ae && j < be) {
const d = fold(pathBlob[i++]!) - fold(pathBlob[j++]!)
if (d) return d
}
return ae - i - (be - j)
}
/**
* Type ids are assigned in first-seen order, so they say nothing about
* alphabetical order. Rank them once — there are a few hundred types
* against up to a million rows, so this turns every row comparison into
* one array lookup.
*/
private typeRank: Int32Array | null = null
private cmpType(a: number, b: number): number {
if (!this.typeRank) {
const { types } = this.cols
const order = types.map((_, i) => i)
order.sort((x, y) => types[x]!.localeCompare(types[y]!))
const rank = new Int32Array(types.length)
order.forEach((id, r) => (rank[id] = r))
this.typeRank = rank
}
const { typeId } = this.cols
return this.typeRank[typeId[a]!]! - this.typeRank[typeId[b]!]!
}
// tree // tree
/** /**

70
app/lib/hosted.ts Normal file
View File

@@ -0,0 +1,70 @@
/**
* Dumps the site ships with.
*
* These are never fetched on load. A prebaked dump is tens of megabytes, and
* most visitors arrive with their own file or with one already in IndexedDB;
* spending their bandwidth on a speculative download would be rude. The
* manifest is a few hundred bytes and is all we read until someone asks for
* a dump by name.
*/
export interface HostedDump {
/** Stable id, independent of the file name. Recorded on the cached copy so
* a downloaded dump can be struck from the offer list. */
id: string
/** Path under the site root. */
file: string
title: string
note?: string
/** Bytes on the wire, for the "how big is this" question. Optional. */
bytes?: number
/** 'pdx' is a prebaked column bundle; 'text' is a raw UE4SS dump. */
format?: 'text' | 'pdx'
}
const MANIFEST = '/dumps.json'
export async function listHosted(): Promise<HostedDump[]> {
const res = await fetch(MANIFEST, { cache: 'no-cache' })
if (!res.ok) return []
const body = await res.json()
return Array.isArray(body) ? body : (body.dumps ?? [])
}
/**
* Fetch with byte progress. `Content-Length` is absent under chunked transfer
* encoding, which nginx will use for these once gzip is on, so the caller has
* to tolerate a null fraction rather than a bogus one.
*/
export async function download(
d: HostedDump,
onProgress?: (fraction: number | null, received: number) => void,
): Promise<ArrayBuffer> {
const res = await fetch(d.file)
if (!res.ok) throw new Error(`Could not fetch ${d.file} (${res.status})`)
const declared = Number(res.headers.get('content-length')) || d.bytes || 0
if (!res.body) return res.arrayBuffer()
const reader = res.body.getReader()
const chunks: Uint8Array[] = []
let received = 0
for (;;) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
received += value.length
onProgress?.(declared ? Math.min(1, received / declared) : null, received)
}
const out = new Uint8Array(received)
let at = 0
for (const c of chunks) {
out.set(c, at)
at += c.length
}
return out.buffer
}
/** File name to record on the cached copy, so titles read like a real file. */
export const fileNameOf = (d: HostedDump) => d.file.split('/').pop() || d.id

View File

@@ -5,8 +5,14 @@ const STORE = 'dumps'
const VERSION = 1 const VERSION = 1
export interface CachedDump { export interface CachedDump {
/** Content hash. Stable across machines, so it is safe to put in a URL. */
key: string key: string
/** Display title: the original file name plus the moment it was parsed. */
label: string label: string
/** File name as it arrived, without the datetime suffix. */
name: string
/** Manifest id when this came from the site's own dump list, else absent. */
source?: string
bytes: number bytes: number
parsedAt: number parsedAt: number
cols: DumpColumns cols: DumpColumns
@@ -43,16 +49,66 @@ function tx<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T
) )
} }
/** const PRIME = 0x01000193
* 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.
*/ /** Final avalanche, so that near-identical lane states diverge in the output. */
export function fingerprint(name: string, buf: Uint8Array): string { function mix(h: number): number {
let h = 0x811c9dc5 h ^= h >>> 16
const step = Math.max(1, Math.floor(buf.length / 64)) h = Math.imul(h, 0x85ebca6b)
for (let i = 0; i < buf.length; i += step) h = Math.imul(h ^ buf[i], 0x01000193) h ^= h >>> 13
return `${name}:${buf.length}:${(h >>> 0).toString(16)}` h = Math.imul(h, 0xc2b2ae35)
return (h ^ (h >>> 16)) >>> 0
} }
/**
* Identity for a dump: 64 bits of FNV-1a over every byte of the file, and
* nothing else.
*
* Content-only is the whole point — this hash goes in the URL, so two people
* holding the same file must derive the same id from it. That rules out the
* file name (people rename dumps) and the parse time. It also rules out the
* sparse sampling this used to do: a shared link is only as trustworthy as
* the odds that two different dumps collide, and 64 sampled bytes out of
* 60 MB is not a bet worth taking when the dumps being compared are near
* identical by construction (same game, one patch apart).
*
* Four interleaved lanes rather than one, because a single-lane FNV over
* 60 MB is a serial dependency chain; the lanes let the CPU overlap the
* multiplies. Costs ~100 ms on a 60 MB dump, against ~1 s to parse it.
*/
export function contentHash(buf: Uint8Array): string {
let a = 0x811c9dc5
let b = 0x9e3779b9
let c = 0x85ebca6b
let d = 0xc2b2ae35
const n = buf.length
const quads = n - (n % 4)
let i = 0
for (; i < quads; i += 4) {
a = Math.imul(a ^ buf[i]!, PRIME)
b = Math.imul(b ^ buf[i + 1]!, PRIME)
c = Math.imul(c ^ buf[i + 2]!, PRIME)
d = Math.imul(d ^ buf[i + 3]!, PRIME)
}
for (; i < n; i++) a = Math.imul(a ^ buf[i]!, PRIME)
// Length participates too, so that a truncated file cannot land on the
// same lanes as the whole one.
const hi = mix(a ^ Math.imul(b, PRIME) ^ n)
const lo = mix(c ^ Math.imul(d, PRIME) ^ n)
return hi.toString(16).padStart(8, '0') + lo.toString(16).padStart(8, '0')
}
const stamp = (t: number) => {
const p = (n: number) => String(n).padStart(2, '0')
const d = new Date(t)
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`
}
/** Titles carry the datetime so two loads of the same file stay tellable apart. */
export const titleFor = (name: string, parsedAt: number) => `${name} · ${stamp(parsedAt)}`
/** Typed arrays survive structured clone, so there is no serialisation step. */ /** Typed arrays survive structured clone, so there is no serialisation step. */
export const saveDump = (d: CachedDump) => tx('readwrite', (s) => s.put(d)) 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 loadDump = (key: string) => tx<CachedDump | undefined>('readonly', (s) => s.get(key))

77
app/lib/view-state.ts Normal file
View File

@@ -0,0 +1,77 @@
import type { SortKey } from './dump-store'
/**
* Everything about "what am I looking at" that is worth putting in a link.
*
* Types are carried as names rather than as the numeric ids the store uses.
* Ids are assigned in first-seen order, so they are stable for a given dump —
* but only for that dump, and a link that silently means something else
* against a different dump is worse than one that drops a filter it cannot
* resolve.
*/
export interface ViewState {
prefix: string
name: string
path: string
sort: SortKey
desc: boolean
types: string[]
}
export const emptyView = (): ViewState => ({
prefix: '',
name: '',
path: '',
sort: 'natural',
desc: false,
types: [],
})
const SORTS: SortKey[] = ['natural', 'path', 'address', 'offset', 'name', 'type']
export function sameView(a: ViewState, b: ViewState): boolean {
return (
a.prefix === b.prefix &&
a.name === b.name &&
a.path === b.path &&
a.sort === b.sort &&
a.desc === b.desc &&
a.types.length === b.types.length &&
a.types.every((t, i) => t === b.types[i])
)
}
/**
* Only non-default fields are written, so the common case — a dump open with
* no filters — is a URL you can read aloud.
*/
export function encodeView(dump: string, v: ViewState): string {
const q = new URLSearchParams()
if (dump) q.set('d', dump)
if (v.prefix) q.set('p', v.prefix)
if (v.name) q.set('n', v.name)
if (v.path) q.set('q', v.path)
if (v.sort !== 'natural') q.set('s', v.sort)
if (v.desc) q.set('o', 'desc')
if (v.types.length) q.set('t', v.types.join(','))
const s = q.toString()
return s ? '?' + s : location.pathname
}
export function decodeView(search: string): { dump: string; view: ViewState } {
const q = new URLSearchParams(search)
const sort = q.get('s') as SortKey | null
return {
dump: q.get('d') ?? '',
view: {
prefix: q.get('p') ?? '',
name: q.get('n') ?? '',
path: q.get('q') ?? '',
sort: sort && SORTS.includes(sort) ? sort : 'natural',
desc: q.get('o') === 'desc',
// Sorted on the way in as well as out: `sameView` compares positionally,
// and a hand-edited URL should not read as a different view.
types: (q.get('t') ?? '').split(',').filter(Boolean).sort(),
},
}
}

View File

@@ -1,7 +1,8 @@
/// <reference lib="webworker" /> /// <reference lib="webworker" />
import { parseDump } from '../lib/dump-parse' import { parseDump } from '../lib/dump-parse'
import { decodeBundle } from '../lib/bundle'
import { DumpStore, type Query } from '../lib/dump-store' import { DumpStore, type Query } from '../lib/dump-store'
import { fingerprint, saveDump, loadDump } from '../lib/persist' import { contentHash, titleFor, saveDump, loadDump } from '../lib/persist'
/** /**
* The store never crosses to the main thread. The UI asks for a window of * The store never crosses to the main thread. The UI asks for a window of
@@ -11,7 +12,16 @@ let store: DumpStore | null = null
let current: Uint32Array = new Uint32Array(0) let current: Uint32Array = new Uint32Array(0)
type Req = type Req =
| { id: number; op: 'parse'; buffer: ArrayBuffer; name: string } | {
id: number
op: 'parse'
buffer: ArrayBuffer
name: string
/** 'pdx' skips the parser: the file is already a serialised column set. */
format?: 'text' | 'pdx'
/** Manifest id, when this arrived from the site's own dump list. */
source?: string
}
| { id: number; op: 'restore'; key: string } | { id: number; op: 'restore'; key: string }
| { id: number; op: 'query'; query: Query } | { id: number; op: 'query'; query: Query }
| { id: number; op: 'window'; start: number; end: number } | { id: number; op: 'window'; start: number; end: number }
@@ -43,26 +53,35 @@ async function handle(msg: Req) {
switch (msg.op) { switch (msg.op) {
case 'parse': { case 'parse': {
const src = new Uint8Array(msg.buffer) const src = new Uint8Array(msg.buffer)
const key = fingerprint(msg.name, src) // Hashed before anything else: the id is the file's content, so it is
// known even for a dump we turn out to already hold.
const key = contentHash(src)
const cached = await loadDump(key).catch(() => undefined) const cached = await loadDump(key).catch(() => undefined)
if (cached) { if (cached) {
store = new DumpStore(cached.cols) store = new DumpStore(cached.cols)
return { ...summary(), key, label: cached.label, cached: true } return { ...summary(), key, label: cached.label, cached: true }
} }
const t0 = performance.now() const t0 = performance.now()
const cols = parseDump(src, (f) => const cols =
self.postMessage({ id: -1, event: 'progress', fraction: f }), msg.format === 'pdx'
) ? decodeBundle(msg.buffer)
: parseDump(src, (f) => self.postMessage({ id: -1, event: 'progress', fraction: f }))
const ms = performance.now() - t0 const ms = performance.now() - t0
store = new DumpStore(cols) store = new DumpStore(cols)
const parsedAt = Date.now()
const label = titleFor(msg.name, parsedAt)
await saveDump({ await saveDump({
key, key,
label: msg.name, label,
name: msg.name,
source: msg.source,
bytes: src.length, bytes: src.length,
parsedAt: Date.now(), parsedAt,
cols, cols,
}).catch(() => {}) }).catch(() => {})
return { ...summary(), key, label: msg.name, cached: false, parseMs: ms } return { ...summary(), key, label, cached: false, parseMs: ms }
} }
case 'restore': { case 'restore': {

12
public/dumps.json Normal file
View File

@@ -0,0 +1,12 @@
{
"dumps": [
{
"id": "palworld-2026-08-07",
"file": "/PalDump_08_07_26.pdx",
"title": "Palworld · 2026-08-07",
"note": "Prebaked object dump. Loads without parsing.",
"bytes": 40128248,
"format": "pdx"
}
]
}