Files
ue4ss-explorer/app/lib/persist.ts
Hunter 45b1539c68 V.1
2026-08-07 17:11:04 -04:00

117 lines
4.0 KiB
TypeScript

import type { DumpColumns } from './dump-parse'
const DB = 'pdx'
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
}
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)
}),
)
}
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))
export const listDumps = () => tx<CachedDump[]>('readonly', (s) => s.getAll())
export const dropDump = (key: string) => tx('readwrite', (s) => s.delete(key))