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

@@ -4,12 +4,15 @@ const COLON = 0x3a
const DOT = 0x2e
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 {
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'
sort?: SortKey
descending?: boolean
}
@@ -171,9 +174,11 @@ export class DumpStore {
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]!
if (mode === 'address') cmp = (a, b) => addr[a]! - addr[b]! || a - 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 === '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
const sorted = rows.slice().sort(cmp)
@@ -195,6 +200,44 @@ export class DumpStore {
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
/**

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
export interface CachedDump {
/** Content hash. Stable across machines, so it is safe to put in a URL. */
key: string
/** Display title: the original file name plus the moment it was parsed. */
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
parsedAt: number
cols: DumpColumns
@@ -43,16 +49,66 @@ function tx<T>(mode: IDBTransactionMode, fn: (s: IDBObjectStore) => IDBRequest<T
)
}
/**
* 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)}`
const PRIME = 0x01000193
/** Final avalanche, so that near-identical lane states diverge in the output. */
function mix(h: number): number {
h ^= h >>> 16
h = Math.imul(h, 0x85ebca6b)
h ^= h >>> 13
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. */
export const saveDump = (d: CachedDump) => tx('readwrite', (s) => s.put(d))
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(),
},
}
}