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

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
}