initial commit
This commit is contained in:
101
app/lib/bundle.ts
Normal file
101
app/lib/bundle.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import type { DumpColumns } from './dump-parse'
|
||||
|
||||
/**
|
||||
* A parsed dump serialised as one flat file.
|
||||
*
|
||||
* [0..4) magic
|
||||
* [4..8) manifest length in bytes
|
||||
* [8..12) payload base offset
|
||||
* [12..16) reserved
|
||||
* [16..) JSON manifest, then zero padding, then the column buffers
|
||||
*
|
||||
* The payload offset lives in the fixed header rather than the manifest so
|
||||
* that writing it cannot change the manifest's own length.
|
||||
*
|
||||
* This exists for the case where the dump ships with the site instead of
|
||||
* being uploaded. Parse once at build time and the browser does one fetch
|
||||
* plus a few typed-array views over the response - no parsing, no IndexedDB.
|
||||
* It also gzips far better than JSON, because each column is homogeneous.
|
||||
*/
|
||||
|
||||
// Bumped to \x02 when `kind` and `value` were added; a \x01 bundle has no way
|
||||
// to tell enum constants from objects, so it is rejected rather than upgraded.
|
||||
const MAGIC = 0x50445802 // "PDX\x02"
|
||||
const MAGIC_V1 = 0x50445801
|
||||
const HEADER = 16
|
||||
|
||||
const COLUMNS = [
|
||||
['addr', Float64Array],
|
||||
['typeId', Uint16Array],
|
||||
['pathStart', Uint32Array],
|
||||
['pathBlob', Uint8Array],
|
||||
['nameOff', Uint16Array],
|
||||
['rootOff', Uint16Array],
|
||||
['outer', Float64Array],
|
||||
['offset', Int32Array],
|
||||
['kind', Uint8Array],
|
||||
['value', BigInt64Array],
|
||||
] as const
|
||||
|
||||
const align8 = (n: number) => (8 - (n % 8)) % 8
|
||||
|
||||
export function encodeBundle(cols: DumpColumns): Uint8Array {
|
||||
const layout: Record<string, { offset: number; length: number }> = {}
|
||||
const parts: { at: number; bytes: Uint8Array }[] = []
|
||||
let size = 0
|
||||
|
||||
for (const [key] of COLUMNS) {
|
||||
const view = cols[key] as ArrayBufferView & { length: number }
|
||||
size += align8(size)
|
||||
layout[key] = { offset: size, length: view.length }
|
||||
parts.push({
|
||||
at: size,
|
||||
bytes: new Uint8Array(view.buffer, view.byteOffset, view.byteLength),
|
||||
})
|
||||
size += view.byteLength
|
||||
}
|
||||
|
||||
const json = new TextEncoder().encode(
|
||||
JSON.stringify({
|
||||
count: cols.count,
|
||||
types: cols.types,
|
||||
skipped: cols.skipped,
|
||||
damaged: cols.damaged,
|
||||
columns: layout,
|
||||
}),
|
||||
)
|
||||
const base = HEADER + json.length + align8(HEADER + json.length)
|
||||
|
||||
const out = new Uint8Array(base + size)
|
||||
const dv = new DataView(out.buffer)
|
||||
dv.setUint32(0, MAGIC, true)
|
||||
dv.setUint32(4, json.length, true)
|
||||
dv.setUint32(8, base, true)
|
||||
out.set(json, HEADER)
|
||||
for (const { at, bytes } of parts) out.set(bytes, base + at)
|
||||
return out
|
||||
}
|
||||
|
||||
export function decodeBundle(buf: ArrayBuffer): DumpColumns {
|
||||
const dv = new DataView(buf)
|
||||
const magic = dv.getUint32(0, true)
|
||||
if (magic === MAGIC_V1) {
|
||||
throw new Error('This .pdx was built by an older parser. Re-run `npm run prebake`.')
|
||||
}
|
||||
if (magic !== MAGIC) throw new Error('Not a .pdx bundle.')
|
||||
const jsonLen = dv.getUint32(4, true)
|
||||
const base = dv.getUint32(8, true)
|
||||
const manifest = JSON.parse(new TextDecoder().decode(new Uint8Array(buf, HEADER, jsonLen)))
|
||||
|
||||
const cols: Record<string, unknown> = {
|
||||
count: manifest.count,
|
||||
types: manifest.types,
|
||||
skipped: manifest.skipped,
|
||||
damaged: manifest.damaged ?? 0,
|
||||
}
|
||||
for (const [key, Ctor] of COLUMNS) {
|
||||
const { offset, length } = manifest.columns[key]
|
||||
cols[key] = new Ctor(buf, base + offset, length)
|
||||
}
|
||||
return cols as unknown as DumpColumns
|
||||
}
|
||||
352
app/lib/dump-parse.ts
Normal file
352
app/lib/dump-parse.ts
Normal file
@@ -0,0 +1,352 @@
|
||||
/**
|
||||
* Byte-level tokenizer for UE4SS `DumpObjects` output.
|
||||
*
|
||||
* Why? Because the dump is huge, and parsing it into a structured object graph is slow and memory-hungry.
|
||||
* Instead, we parse it into a compact columnar representation that can be queried efficiently.
|
||||
*
|
||||
* There is more than one line shape in the file. Three, in the 292k-line dump:
|
||||
*
|
||||
* 1. Objects [ADDR] TypeName ObjectPath [k: v] [k: v] ...
|
||||
* 2. Enum constants [0000000000000000] EnumName::ValueName [n: HEX] [v: DECIMAL]
|
||||
* 3. Damaged lines UE4SS truncates a record mid-write and resumes on the
|
||||
* next line, leaving one line with no trailing groups and
|
||||
* one with no `[ADDR]` prefix.
|
||||
*
|
||||
* Two things make shape 1 harder than it looks:
|
||||
*
|
||||
* - ObjectPath CAN contain spaces (`... (Director BP)_C:UberGraphFrame`,
|
||||
* `Default__SkyCreator:Sun Light Component`) - 5.6k lines' worth. So the
|
||||
* path is not "up to the first space"; the trailing `[k: v]` groups have
|
||||
* to be peeled off the RIGHT and the path is whatever is left.
|
||||
* - Enum constants have no path at all, so a left-to-right scan reads the
|
||||
* first group as the path and files 15k objects under a bogus `[n` root.
|
||||
*
|
||||
* Peeling from the right is only safe because no object path in the dump
|
||||
* contains `[` or `]`; that is asserted by treating a leftover bracket in the
|
||||
* path region as damage rather than as text.
|
||||
*/
|
||||
|
||||
const NL = 0x0a
|
||||
const CR = 0x0d
|
||||
const LB = 0x5b // [
|
||||
const RB = 0x5d // ]
|
||||
const SP = 0x20
|
||||
const COLON = 0x3a
|
||||
const DOT = 0x2e
|
||||
const SLASH = 0x2f
|
||||
const MINUS = 0x2d
|
||||
|
||||
/** Row kinds. `kind` is what keeps enum constants out of the package tree. */
|
||||
export const KIND_OBJECT = 0
|
||||
export const KIND_ENUM = 1
|
||||
export const KIND_DAMAGED = 2
|
||||
|
||||
/** Synthetic type for enum constants written without an `EnumName::` qualifier. */
|
||||
const BARE_ENUM_TYPE = 'EnumConstant'
|
||||
|
||||
export interface DumpColumns {
|
||||
count: number
|
||||
addr: Float64Array // UObject address. 48-bit in practice, so it survives as an f64 exactly.
|
||||
typeId: Uint16Array // Index into `types`.
|
||||
types: string[]
|
||||
pathStart: Uint32Array
|
||||
pathBlob: Uint8Array
|
||||
nameOff: Uint16Array // Byte offset from pathStart[i] at which the leaf name begins.
|
||||
/**
|
||||
*
|
||||
* rootOff:
|
||||
*
|
||||
* Byte offset at which the real `/...` path begins. Array inner properties
|
||||
* are dumped as `ComponentTags./Script/Engine.ActorComponent:ComponentTags`;
|
||||
* that leading qualifier is kept for display but skipped when indexing, so
|
||||
* the inner property files under its array rather than at the tree root.
|
||||
*/
|
||||
rootOff: Uint16Array
|
||||
outer: Float64Array // Address from `or:` (outer) or `owr:` (owner), whichever the line carried. 0 = none.
|
||||
offset: Int32Array // field offset, or -1 when the line had none.
|
||||
kind: Uint8Array // KIND_OBJECT | KIND_ENUM | KIND_DAMAGED.
|
||||
/**
|
||||
* `v:` on enum constants. i64 because four of them are 2^50..2^56 bit flags
|
||||
* and one (2^56+1) is not representable exactly as an f64.
|
||||
*/
|
||||
value: BigInt64Array
|
||||
skipped: number // Lines that carried no usable record at all.
|
||||
damaged: number // Rows recovered from a truncated line; their groups are gone.
|
||||
}
|
||||
|
||||
function hexAt(b: Uint8Array, i: number, end: number): number {
|
||||
let v = 0
|
||||
for (; i < end; i++) {
|
||||
const c = b[i]!
|
||||
if (c >= 0x30 && c <= 0x39) v = v * 16 + (c - 0x30)
|
||||
else if (c >= 0x61 && c <= 0x66) v = v * 16 + (c - 0x57)
|
||||
else if (c >= 0x41 && c <= 0x46) v = v * 16 + (c - 0x37)
|
||||
else break
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
/** `v:` is decimal and signed, unlike every other value in the file. */
|
||||
function decAt(b: Uint8Array, i: number, end: number): bigint {
|
||||
let neg = false
|
||||
if (i < end && b[i] === MINUS) {
|
||||
neg = true
|
||||
i++
|
||||
}
|
||||
let v = 0n
|
||||
for (; i < end; i++) {
|
||||
const c = b[i]!
|
||||
if (c < 0x30 || c > 0x39) break
|
||||
v = v * 10n + BigInt(c - 0x30)
|
||||
}
|
||||
return neg ? -v : v
|
||||
}
|
||||
|
||||
const isAlpha = (c: number) => (c >= 0x61 && c <= 0x7a) || (c >= 0x41 && c <= 0x5a)
|
||||
|
||||
export function parseDump( src: Uint8Array, onProgress?: (fraction: number) => void): DumpColumns {
|
||||
|
||||
const len = src.length
|
||||
|
||||
let lines = 0
|
||||
for (let i = 0; i < len; i++) if (src[i] === NL) lines++
|
||||
if (len > 0 && src[len - 1] !== NL) lines++
|
||||
|
||||
const addr = new Float64Array(lines)
|
||||
const typeId = new Uint16Array(lines)
|
||||
const pathStart = new Uint32Array(lines + 1)
|
||||
const nameOff = new Uint16Array(lines)
|
||||
const rootOff = new Uint16Array(lines)
|
||||
const outer = new Float64Array(lines)
|
||||
const offset = new Int32Array(lines)
|
||||
const kind = new Uint8Array(lines)
|
||||
const value = new BigInt64Array(lines)
|
||||
const pathBlob = new Uint8Array(len) // Upper bound.
|
||||
|
||||
const types: string[] = []
|
||||
// hash -> type id. Collision risk across distinct type names is nil and skipping the decode here is worth several hundred ms.
|
||||
const typeIndex = new Map<number, number>()
|
||||
const decoder = new TextDecoder()
|
||||
|
||||
/** Intern a type name held as a byte range. */
|
||||
function internRange(s: number, end: number): number {
|
||||
let h = 0x811c9dc5
|
||||
for (let k = s; k < end; k++) h = Math.imul(h ^ src[k]!, 0x01000193)
|
||||
h = (h ^ (end - s)) >>> 0
|
||||
let id = typeIndex.get(h)
|
||||
if (id === undefined) {
|
||||
id = types.length
|
||||
types.push(decoder.decode(src.subarray(s, end)))
|
||||
typeIndex.set(h, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
/** Intern a type name we synthesised rather than read. */
|
||||
const synthetic = new Map<string, number>()
|
||||
function internString(name: string): number {
|
||||
let id = synthetic.get(name)
|
||||
if (id === undefined) {
|
||||
id = types.length
|
||||
types.push(name)
|
||||
synthetic.set(name, id)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
let row = 0
|
||||
let w = 0
|
||||
let skipped = 0
|
||||
let damaged = 0
|
||||
let i = 0
|
||||
let nextProgress = len >> 5
|
||||
|
||||
while (i < len) {
|
||||
let lineEnd = i
|
||||
while (lineEnd < len && src[lineEnd] !== NL) lineEnd++
|
||||
let e = lineEnd
|
||||
if (e > i && src[e - 1] === CR) e--
|
||||
|
||||
let p = i
|
||||
i = lineEnd + 1
|
||||
|
||||
if (onProgress && p > nextProgress) {
|
||||
onProgress(p / len)
|
||||
nextProgress = p + (len >> 5)
|
||||
}
|
||||
|
||||
while (p < e && src[p] === SP) p++
|
||||
if (p >= e) continue // blank
|
||||
|
||||
// --- address ---
|
||||
// A truncated continuation line has no `[ADDR]`; it still names a real
|
||||
// object, so recover it with a null address rather than dropping it.
|
||||
let address = 0
|
||||
let isFragment = false
|
||||
if (src[p] === LB) {
|
||||
let q = p + 1
|
||||
while (q < e && src[q] !== RB) q++
|
||||
if (q >= e) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
address = hexAt(src, p + 1, q)
|
||||
p = q + 1
|
||||
} else {
|
||||
isFragment = true
|
||||
}
|
||||
while (p < e && src[p] === SP) p++
|
||||
|
||||
// --- type name ---
|
||||
const tStart = p
|
||||
while (p < e && src[p] !== SP) p++
|
||||
const tEnd = p
|
||||
if (tEnd === tStart) {
|
||||
skipped++
|
||||
continue
|
||||
}
|
||||
while (p < e && src[p] === SP) p++
|
||||
|
||||
// --- trailing [key: value] groups, peeled right to left ---
|
||||
const bodyStart = p
|
||||
let outerV = 0
|
||||
let offsetV = -1
|
||||
let valueV = 0n
|
||||
let hasValue = false
|
||||
let groupsAt = e
|
||||
|
||||
for (;;) {
|
||||
let s = groupsAt
|
||||
while (s > bodyStart && src[s - 1] === SP) s--
|
||||
if (s <= bodyStart || src[s - 1] !== RB) break
|
||||
|
||||
// No object path contains a bracket, so the nearest '[' opens this group.
|
||||
let open = s - 2
|
||||
while (open >= bodyStart && src[open] !== LB) open--
|
||||
if (open < bodyStart) break
|
||||
|
||||
// Must look like `key: value` with a short alphabetic key.
|
||||
let k = open + 1
|
||||
while (k < s - 1 && isAlpha(src[k]!)) k++
|
||||
if (k === open + 1 || k - (open + 1) > 6 || k >= s - 1 || src[k] !== COLON) break
|
||||
|
||||
const kStart = open + 1
|
||||
const kLen = k - kStart
|
||||
let vStart = k + 1
|
||||
while (vStart < s - 1 && src[vStart] === SP) vStart++
|
||||
const vEnd = s - 1
|
||||
|
||||
if (kLen === 1 && src[kStart] === 0x6f) {
|
||||
offsetV = hexAt(src, vStart, vEnd) // o: field offset
|
||||
} else if (kLen === 1 && src[kStart] === 0x76) {
|
||||
valueV = decAt(src, vStart, vEnd) // v: enum constant
|
||||
hasValue = true
|
||||
} else if (kLen === 2 && src[kStart] === 0x6f && src[kStart + 1] === 0x72) {
|
||||
outerV = hexAt(src, vStart, vEnd) // or: outer
|
||||
} else if (
|
||||
kLen === 3 &&
|
||||
src[kStart] === 0x6f &&
|
||||
src[kStart + 1] === 0x77 &&
|
||||
src[kStart + 2] === 0x72
|
||||
) {
|
||||
outerV = hexAt(src, vStart, vEnd) // owr: owner
|
||||
}
|
||||
|
||||
groupsAt = open
|
||||
}
|
||||
|
||||
// --- object path: everything between the type name and the first group ---
|
||||
const pStart = bodyStart
|
||||
let pEnd = groupsAt
|
||||
while (pEnd > pStart && src[pEnd - 1] === SP) pEnd--
|
||||
|
||||
// A bracket surviving in the path region means the line was cut mid-group.
|
||||
let rowKind = isFragment ? KIND_DAMAGED : KIND_OBJECT
|
||||
for (let k = pStart; k < pEnd; k++) {
|
||||
if (src[k] === LB || src[k] === RB) {
|
||||
pEnd = k
|
||||
while (pEnd > pStart && src[pEnd - 1] === SP) pEnd--
|
||||
rowKind = KIND_DAMAGED
|
||||
outerV = 0
|
||||
offsetV = -1
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
let tid: number
|
||||
if (pEnd === pStart) {
|
||||
// No path: an enum constant, `EnumName::ValueName` or a bare `CIM_Linear`.
|
||||
// The qualifier is the useful facet, so it becomes the type and the whole
|
||||
// token becomes the path, which gives the row a searchable leaf name.
|
||||
rowKind = KIND_ENUM
|
||||
let sep = -1
|
||||
for (let k = tStart; k + 1 < tEnd; k++) {
|
||||
if (src[k] === COLON && src[k + 1] === COLON) {
|
||||
sep = k
|
||||
break
|
||||
}
|
||||
}
|
||||
tid = sep < 0 ? internString(BARE_ENUM_TYPE) : internRange(tStart, sep)
|
||||
pathBlob.set(src.subarray(tStart, tEnd), w)
|
||||
// Leaf name is the part after `::`.
|
||||
nameOff[row] = sep < 0 ? 0 : sep + 2 - tStart
|
||||
rootOff[row] = 0
|
||||
pathStart[row] = w
|
||||
w += tEnd - tStart
|
||||
} else {
|
||||
tid = internRange(tStart, tEnd)
|
||||
|
||||
// --- indexable root: skip a leading `Qualifier.` before the first '/' ---
|
||||
let r = pStart
|
||||
if (r < pEnd && src[r] !== SLASH) {
|
||||
let k = r
|
||||
while (k < pEnd && src[k] !== SLASH) k++
|
||||
if (k < pEnd) r = k
|
||||
}
|
||||
|
||||
// --- leaf name: last '/', '.' or ':' ---
|
||||
let n = pEnd
|
||||
while (n > pStart) {
|
||||
const c = src[n - 1]
|
||||
if (c === COLON || c === DOT || c === SLASH) break
|
||||
n--
|
||||
}
|
||||
|
||||
pathStart[row] = w
|
||||
nameOff[row] = Math.min(n - pStart, 0xffff)
|
||||
rootOff[row] = Math.min(r - pStart, 0xffff)
|
||||
pathBlob.set(src.subarray(pStart, pEnd), w)
|
||||
w += pEnd - pStart
|
||||
}
|
||||
|
||||
if (rowKind === KIND_DAMAGED) damaged++
|
||||
|
||||
addr[row] = address
|
||||
typeId[row] = tid
|
||||
outer[row] = outerV
|
||||
offset[row] = offsetV
|
||||
kind[row] = rowKind
|
||||
value[row] = hasValue ? valueV : 0n
|
||||
row++
|
||||
}
|
||||
|
||||
pathStart[row] = w
|
||||
onProgress?.(1)
|
||||
|
||||
return {
|
||||
count: row,
|
||||
addr: addr.subarray(0, row),
|
||||
typeId: typeId.subarray(0, row),
|
||||
types,
|
||||
pathStart: pathStart.subarray(0, row + 1),
|
||||
pathBlob: pathBlob.slice(0, w),
|
||||
nameOff: nameOff.subarray(0, row),
|
||||
rootOff: rootOff.subarray(0, row),
|
||||
outer: outer.subarray(0, row),
|
||||
offset: offset.subarray(0, row),
|
||||
kind: kind.subarray(0, row),
|
||||
value: value.subarray(0, row),
|
||||
skipped,
|
||||
damaged,
|
||||
}
|
||||
}
|
||||
323
app/lib/dump-store.ts
Normal file
323
app/lib/dump-store.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import { KIND_ENUM, type DumpColumns } from './dump-parse'
|
||||
|
||||
const COLON = 0x3a
|
||||
const DOT = 0x2e
|
||||
const SLASH = 0x2f
|
||||
|
||||
export interface Query {
|
||||
typeIds?: number[] // Type ids to keep. Empty/undefined = all types.
|
||||
pathPrefix?: string // Exact prefix on the object path, e.g. "/Script/Engine".
|
||||
nameContains?: string // Case-insensitive substring on the leaf name.
|
||||
pathContains?: string // Case-insensitive substring on the whole path.
|
||||
sort?: 'path' | 'address' | 'offset' | 'natural'
|
||||
descending?: boolean
|
||||
}
|
||||
|
||||
export interface TreeChild {
|
||||
segment: string // Segment text, without its leading separator.
|
||||
separator: string // The separator that introduced it: '/', '.' or ':'.
|
||||
path: string // Full path prefix including this segment.
|
||||
count: number // Objects at or under this node.
|
||||
isObject: boolean // True when an object exists at exactly this path.
|
||||
}
|
||||
|
||||
function fold(c: number): number {
|
||||
return c >= 0x41 && c <= 0x5a ? c + 32 : c
|
||||
}
|
||||
|
||||
function encodeLower(s: string): Uint8Array {
|
||||
return new TextEncoder().encode(s.toLowerCase())
|
||||
}
|
||||
|
||||
export class DumpStore {
|
||||
readonly cols: DumpColumns
|
||||
private decoder = new TextDecoder()
|
||||
/** Row indices ordered by path bytes. Built lazily; powers the tree. */
|
||||
private byPath: Uint32Array | null = null
|
||||
|
||||
constructor(cols: DumpColumns) {
|
||||
this.cols = cols
|
||||
}
|
||||
|
||||
get count() {
|
||||
return this.cols.count
|
||||
}
|
||||
|
||||
path(row: number): string {
|
||||
const { pathStart, pathBlob } = this.cols
|
||||
return this.decoder.decode(pathBlob.subarray(pathStart[row]!, pathStart[row + 1]!))
|
||||
}
|
||||
|
||||
/**
|
||||
* A zero here is a real value, not a missing one: row 0 starts at
|
||||
* pathStart 0, and nameOff is 0 for any path with no separator at all
|
||||
* (`CIM_Linear`). Guarding with `!x` dropped both.
|
||||
*/
|
||||
name(row: number): string {
|
||||
const { pathStart, pathBlob, nameOff } = this.cols
|
||||
return this.decoder.decode(
|
||||
pathBlob.subarray(pathStart[row]! + nameOff[row]!, pathStart[row + 1]!),
|
||||
)
|
||||
}
|
||||
|
||||
/** Path minus the leaf name, minus the trailing separator. */
|
||||
container(row: number): string {
|
||||
const { pathStart, pathBlob, nameOff } = this.cols
|
||||
if (!nameOff[row]) return '' // the whole path is the name
|
||||
const end = pathStart[row]! + nameOff[row]! - 1
|
||||
return this.decoder.decode(pathBlob.subarray(pathStart[row]!, end))
|
||||
}
|
||||
|
||||
typeName(row: number): string {
|
||||
// typeId 0 is a legitimate type - it is whichever type the first line
|
||||
// used - so this cannot fall back on falsiness.
|
||||
return this.cols.types[this.cols.typeId[row]!] ?? 'Unknown'
|
||||
}
|
||||
|
||||
row(index: number) {
|
||||
const kind = this.cols.kind[index]!
|
||||
return {
|
||||
index,
|
||||
address: this.cols.addr[index]!,
|
||||
type: this.typeName(index),
|
||||
typeId: this.cols.typeId[index]!,
|
||||
path: this.path(index),
|
||||
name: this.name(index),
|
||||
container: this.container(index),
|
||||
outer: this.cols.outer[index]!,
|
||||
offset: this.cols.offset[index]!,
|
||||
kind,
|
||||
// Only meaningful on enum constants; i64, so it prints rather than maths.
|
||||
value: kind === KIND_ENUM ? this.cols.value[index]! : null,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- matching
|
||||
|
||||
// rootOff is 0 for every path that already starts with '/', which is nearly
|
||||
// all of them, so the old `!rootOff[row]` guard rejected everything except
|
||||
// array/map inner properties.
|
||||
private hasPrefix(row: number, pat: Uint8Array): boolean {
|
||||
const { pathStart, pathBlob, rootOff } = this.cols
|
||||
const s = pathStart[row]! + rootOff[row]!
|
||||
if (pathStart[row + 1]! - s < pat.length) return false
|
||||
for (let k = 0; k < pat.length; k++) {
|
||||
if (fold(pathBlob[s + k]!) !== pat[k]) return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private hasSub(row: number, pat: Uint8Array, fromName: boolean): boolean {
|
||||
const { pathStart, pathBlob, nameOff } = this.cols
|
||||
const s = pathStart[row]! + (fromName ? nameOff[row]! : 0)
|
||||
const e = pathStart[row + 1]!
|
||||
|
||||
const m = pat.length
|
||||
if (m === 0) return true
|
||||
const last = e - m
|
||||
const first = pat[0]
|
||||
for (let i = s; i <= last; i++) {
|
||||
if (fold(pathBlob[i]!) !== first) continue
|
||||
let k = 1
|
||||
while (k < m && fold(pathBlob[i + k]!) === pat[k]) k++
|
||||
if (k === m) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ query
|
||||
|
||||
/**
|
||||
* Single linear pass over every row. At 800k rows this is single-digit
|
||||
* milliseconds for type filters and ~40 ms for a substring scan, which is
|
||||
* why there is no inverted index here — it would cost more to maintain
|
||||
* than it saves.
|
||||
*/
|
||||
query(q: Query): { rows: Uint32Array; typeCounts: Int32Array } {
|
||||
const { count, typeId, types } = this.cols
|
||||
const typeCounts = new Int32Array(types.length)
|
||||
|
||||
let typeMask: Uint8Array | null = null
|
||||
if (q.typeIds && q.typeIds.length) {
|
||||
typeMask = new Uint8Array(types.length)
|
||||
for (const t of q.typeIds) typeMask[t] = 1
|
||||
}
|
||||
|
||||
const prefix = q.pathPrefix ? encodeLower(q.pathPrefix) : null
|
||||
const nameSub = q.nameContains ? encodeLower(q.nameContains) : null
|
||||
const pathSub = q.pathContains ? encodeLower(q.pathContains) : null
|
||||
|
||||
const out = new Uint32Array(count)
|
||||
let n = 0
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (prefix && !this.hasPrefix(i, prefix)) continue
|
||||
if (nameSub && !this.hasSub(i, nameSub, true)) continue
|
||||
if (pathSub && !this.hasSub(i, pathSub, false)) continue
|
||||
// Facet counts reflect everything *except* the type filter, so the
|
||||
// type list stays usable while a type filter is active.
|
||||
typeCounts[typeId[i]!]++
|
||||
if (typeMask && !typeMask[typeId[i]!]) continue
|
||||
out[n++] = i
|
||||
}
|
||||
|
||||
const rows = out.subarray(0, n)
|
||||
return { rows: this.sort(rows, q), typeCounts }
|
||||
}
|
||||
|
||||
private sort(rows: Uint32Array, q: Query): Uint32Array {
|
||||
const mode = q.sort ?? 'natural'
|
||||
if (mode === 'natural' && !q.descending) return rows
|
||||
const { addr, offset } = this.cols
|
||||
|
||||
let cmp: (a: number, b: number) => number
|
||||
if (mode === 'address') cmp = (a, b) => addr[a]! - addr[b]!
|
||||
else if (mode === 'offset') cmp = (a, b) => offset[a]! - offset[b]!
|
||||
else if (mode === 'path') cmp = (a, b) => this.cmpPath(a, b)
|
||||
else cmp = (a, b) => a - b
|
||||
|
||||
const sorted = rows.slice().sort(cmp)
|
||||
if (q.descending) sorted.reverse()
|
||||
return sorted
|
||||
}
|
||||
|
||||
/** Byte order over the *indexable* path, so inner properties sort with their array. */
|
||||
private cmpPath(a: number, b: number): number {
|
||||
const { pathStart, pathBlob, rootOff } = this.cols
|
||||
let i = pathStart[a]! + rootOff[a]!
|
||||
let j = pathStart[b]! + rootOff[b]!
|
||||
const ae = pathStart[a + 1]!
|
||||
const be = pathStart[b + 1]!
|
||||
while (i < ae && j < be) {
|
||||
const d = pathBlob[i++]! - pathBlob[j++]!
|
||||
if (d) return d
|
||||
}
|
||||
return ae - i - (be - j)
|
||||
}
|
||||
|
||||
// tree
|
||||
|
||||
/**
|
||||
* One comparator sort of the whole table. ~2 s at 800k rows; done once.
|
||||
*
|
||||
* Enum constants are excluded: they have no package path, so including them
|
||||
* grouped 15k rows under a junk root.
|
||||
*/
|
||||
private ensurePathOrder(): Uint32Array {
|
||||
if (this.byPath) return this.byPath
|
||||
const { count, kind } = this.cols
|
||||
const idx = new Uint32Array(count)
|
||||
let n = 0
|
||||
for (let i = 0; i < count; i++) if (kind[i] !== KIND_ENUM) idx[n++] = i
|
||||
const trimmed = idx.subarray(0, n)
|
||||
trimmed.sort((a, b) => this.cmpPath(a, b))
|
||||
this.byPath = trimmed
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private cmpPrefix(row: number, pat: Uint8Array): number {
|
||||
const { pathStart, pathBlob, rootOff } = this.cols
|
||||
const s = pathStart[row]! + rootOff[row]!
|
||||
const e = pathStart[row + 1]!
|
||||
for (let k = 0; k < pat.length; k++) {
|
||||
if (s + k >= e) return -1
|
||||
const d = pathBlob[s + k]! - pat[k]!
|
||||
if (d) return d < 0 ? -1 : 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/** Half-open range of path-ordered rows whose indexable path starts with `pat`. */
|
||||
private range(pat: Uint8Array): [number, number] {
|
||||
const order = this.ensurePathOrder()
|
||||
let lo = 0
|
||||
let hi = order.length
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (this.cmpPrefix(order[mid]!, pat) < 0) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
const start = lo
|
||||
hi = order.length
|
||||
while (lo < hi) {
|
||||
const mid = (lo + hi) >> 1
|
||||
if (this.cmpPrefix(order[mid]!, pat) <= 0) lo = mid + 1
|
||||
else hi = mid
|
||||
}
|
||||
return [start, lo]
|
||||
}
|
||||
|
||||
private childCache = new Map<string, TreeChild[]>()
|
||||
|
||||
/**
|
||||
* Direct children of a path prefix.
|
||||
*
|
||||
* The binary search only bounds the scan; the grouping itself is linear.
|
||||
* Jumping block-to-block looks tempting because the rows are sorted, but
|
||||
* segments are not reliably contiguous: `.` (0x2E) and `:` (0x3A) straddle
|
||||
* the digits, so `/Script/Engine.Actor:Tick`, `/Script/Engine.Actor2` and
|
||||
* `/Script/Engine.Actor.Sub` interleave. Linear + cached is correct and,
|
||||
* at a few million byte comparisons, fast enough that it does not matter.
|
||||
*/
|
||||
children(prefix: string): TreeChild[] {
|
||||
const cached = this.childCache.get(prefix)
|
||||
if (cached) return cached
|
||||
|
||||
const order = this.ensurePathOrder()
|
||||
const { pathStart, pathBlob, rootOff } = this.cols
|
||||
const patBytes = new TextEncoder().encode(prefix)
|
||||
const base = patBytes.length
|
||||
const [lo, hi] = prefix ? this.range(patBytes) : [0, order.length]
|
||||
|
||||
const groups = new Map<string, TreeChild>()
|
||||
for (let n = lo; n < hi; n++) {
|
||||
const row = order[n]!
|
||||
const s = pathStart[row]! + rootOff[row]!
|
||||
const e = pathStart[row + 1]!
|
||||
if (s + base >= e) continue // object sitting exactly at the prefix
|
||||
const sep = pathBlob[s + base]!
|
||||
let k = s + base + 1
|
||||
while (k < e && pathBlob[k] !== COLON && pathBlob[k] !== DOT && pathBlob[k] !== SLASH) k++
|
||||
|
||||
let seg = ''
|
||||
for (let m = s + base + 1; m < k; m++) seg += String.fromCharCode(pathBlob[m]!)
|
||||
const key = String.fromCharCode(sep) + seg
|
||||
|
||||
let node = groups.get(key)
|
||||
if (!node) {
|
||||
node = {
|
||||
segment: seg,
|
||||
separator: String.fromCharCode(sep),
|
||||
path: prefix + key,
|
||||
count: 0,
|
||||
isObject: false,
|
||||
}
|
||||
groups.set(key, node)
|
||||
}
|
||||
node.count++
|
||||
if (k === e) node.isObject = true
|
||||
}
|
||||
|
||||
const out = [...groups.values()].sort(
|
||||
(a, b) => b.count - a.count || a.segment.localeCompare(b.segment),
|
||||
)
|
||||
this.childCache.set(prefix, out)
|
||||
return out
|
||||
}
|
||||
|
||||
/** Resolve a `[or:]` / `[owr:]` pointer back to a row. Built on demand. */
|
||||
private addrIndex: Map<number, number> | null = null
|
||||
rowByAddress(address: number): number {
|
||||
if (!this.addrIndex) {
|
||||
this.addrIndex = new Map()
|
||||
// Enum constants and recovered fragments all carry address 0; indexing
|
||||
// them would make every null pointer resolve to an arbitrary row.
|
||||
for (let i = 0; i < this.cols.count; i++) {
|
||||
const a = this.cols.addr[i]!
|
||||
if (a) this.addrIndex.set(a, i)
|
||||
}
|
||||
}
|
||||
if (!address) return -1
|
||||
return this.addrIndex.get(address) ?? -1
|
||||
}
|
||||
}
|
||||
60
app/lib/persist.ts
Normal file
60
app/lib/persist.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import type { DumpColumns } from './dump-parse'
|
||||
|
||||
const DB = 'pdx'
|
||||
const STORE = 'dumps'
|
||||
const VERSION = 1
|
||||
|
||||
export interface CachedDump {
|
||||
key: string
|
||||
label: string
|
||||
bytes: number
|
||||
parsedAt: number
|
||||
cols: DumpColumns
|
||||
}
|
||||
|
||||
let conn: Promise<IDBDatabase> | null = null
|
||||
|
||||
function open(): Promise<IDBDatabase> {
|
||||
if (conn) return conn
|
||||
conn = new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open(DB, VERSION)
|
||||
req.onupgradeneeded = () => {
|
||||
if (!req.result.objectStoreNames.contains(STORE)) {
|
||||
req.result.createObjectStore(STORE, { keyPath: 'key' })
|
||||
}
|
||||
}
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => {
|
||||
conn = null
|
||||
reject(req.error)
|
||||
}
|
||||
})
|
||||
return conn
|
||||
}
|
||||
|
||||
function tx<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T>): Promise<T> {
|
||||
return open().then(
|
||||
(db) =>
|
||||
new Promise<T>((resolve, reject) => {
|
||||
const req = fn(db.transaction(STORE, mode).objectStore(STORE))
|
||||
req.onsuccess = () => resolve(req.result)
|
||||
req.onerror = () => reject(req.error)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Identity for a dump. Size plus a sparse byte sample: reading 60 MB to hash it properly costs more than re-parsing, and two dumps that agree on size and 64 spread samples are the same dump for our purposes.
|
||||
*/
|
||||
export function fingerprint(name: string, buf: Uint8Array): string {
|
||||
let h = 0x811c9dc5
|
||||
const step = Math.max(1, Math.floor(buf.length / 64))
|
||||
for (let i = 0; i < buf.length; i += step) h = Math.imul(h ^ buf[i], 0x01000193)
|
||||
return `${name}:${buf.length}:${(h >>> 0).toString(16)}`
|
||||
}
|
||||
|
||||
/** Typed arrays survive structured clone, so there is no serialisation step. */
|
||||
export const saveDump = (d: CachedDump) => tx('readwrite', (s) => s.put(d))
|
||||
export const loadDump = (key: string) => tx<CachedDump | undefined>('readonly', (s) => s.get(key))
|
||||
export const listDumps = () => tx<CachedDump[]>('readonly', (s) => s.getAll())
|
||||
export const dropDump = (key: string) => tx('readwrite', (s) => s.delete(key))
|
||||
2
app/lib/prebake-entry.ts
Normal file
2
app/lib/prebake-entry.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { parseDump } from './dump-parse'
|
||||
export { encodeBundle } from './bundle'
|
||||
2
app/lib/probe-entry.ts
Normal file
2
app/lib/probe-entry.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { parseDump } from './dump-parse'
|
||||
export { DumpStore } from './dump-store'
|
||||
78
app/lib/type-hue.ts
Normal file
78
app/lib/type-hue.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* UE type names collapse into six families that a modder actually reasons
|
||||
* about. The table paints rows by family so a 400k-row scroll still reads as
|
||||
* structure rather than noise.
|
||||
*/
|
||||
export type Family = 'container' | 'callable' | 'numeric' | 'reference' | 'bool' | 'text' | 'other'
|
||||
|
||||
const EXACT: Record<string, Family> = {
|
||||
Package: 'container',
|
||||
Class: 'container',
|
||||
BlueprintGeneratedClass: 'container',
|
||||
WidgetBlueprintGeneratedClass: 'container',
|
||||
AnimBlueprintGeneratedClass: 'container',
|
||||
ScriptStruct: 'container',
|
||||
Enum: 'container',
|
||||
UserDefinedEnum: 'container',
|
||||
UserDefinedStruct: 'container',
|
||||
Function: 'callable',
|
||||
DelegateFunction: 'callable',
|
||||
SparseDelegateFunction: 'callable',
|
||||
BoolProperty: 'bool',
|
||||
}
|
||||
|
||||
const SUFFIX: [string, Family][] = [
|
||||
['DelegateProperty', 'callable'],
|
||||
['ObjectProperty', 'reference'],
|
||||
['ClassProperty', 'reference'],
|
||||
['StructProperty', 'reference'],
|
||||
['ArrayProperty', 'reference'],
|
||||
['MapProperty', 'reference'],
|
||||
['SetProperty', 'reference'],
|
||||
['InterfaceProperty', 'reference'],
|
||||
['NameProperty', 'text'],
|
||||
['StrProperty', 'text'],
|
||||
['TextProperty', 'text'],
|
||||
['EnumProperty', 'numeric'],
|
||||
['ByteProperty', 'numeric'],
|
||||
['IntProperty', 'numeric'],
|
||||
['Int8Property', 'numeric'],
|
||||
['Int16Property', 'numeric'],
|
||||
['Int64Property', 'numeric'],
|
||||
['UInt16Property', 'numeric'],
|
||||
['UInt32Property', 'numeric'],
|
||||
['UInt64Property', 'numeric'],
|
||||
['FloatProperty', 'numeric'],
|
||||
['DoubleProperty', 'numeric'],
|
||||
]
|
||||
|
||||
const cache = new Map<string, Family>()
|
||||
|
||||
export function family(type: string): Family {
|
||||
const hit = cache.get(type)
|
||||
if (hit) return hit
|
||||
let f: Family = EXACT[type] ?? 'other'
|
||||
if (f === 'other') {
|
||||
for (const [suffix, fam] of SUFFIX) {
|
||||
if (type.endsWith(suffix)) {
|
||||
f = fam
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if (f === 'other' && type.endsWith('Property')) f = 'reference'
|
||||
cache.set(type, f)
|
||||
return f
|
||||
}
|
||||
|
||||
export const hue = (type: string) => `var(--t-${family(type)})`
|
||||
|
||||
/** UE prefixes every type; the table shows the short form and keeps the rest as a title. */
|
||||
export function abbreviate(type: string): string {
|
||||
if (type.endsWith('Property')) return type.slice(0, -8)
|
||||
if (type === 'BlueprintGeneratedClass') return 'BPClass'
|
||||
if (type.endsWith('BlueprintGeneratedClass')) return type.slice(0, -22) + 'BPClass'
|
||||
if (type === 'SparseDelegateFunction') return 'SparseDlg'
|
||||
if (type === 'DelegateFunction') return 'Delegate'
|
||||
return type
|
||||
}
|
||||
Reference in New Issue
Block a user