V.1
This commit is contained in:
@@ -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
70
app/lib/hosted.ts
Normal 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
|
||||
@@ -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
77
app/lib/view-state.ts
Normal 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(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user