diff --git a/README.md b/README.md index 26e6f16..26eadc3 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,30 @@ AAC encoding through WebCodecs in a worker. If OPFS is unavailable or the browser declines a write, the app reads from the attached ISO for that session instead of persisting the extracted data. +### Disc build support + +This matrix records client-side browsing through the real application flow, +measured with +[`ae3-sdk` revision `5b2207e3580ae4cc1d588641f4fe3f898350e501`](https://github.com/pxdl/ae3-sdk/commit/5b2207e3580ae4cc1d588641f4fe3f898350e501). +`Untested` means no claim is made for that build or asset family. + +For each regional serial below, measurements cover one observed asset family +only. A serial does not establish whole-disc support; unmeasured families remain +`Untested`. +The UI keeps a conservative partial/untested warning for every non-US or +unknown serial, including builds with one measured family in this table. +The PAL beta reports the same `SCES_536.42` serial as retail; the measured PAL +row applies only to the retail build, and the beta remains untested. + +| Build | Images | Effects | FMV | +| --- | --- | --- | --- | +| US retail (`SCUS_975.01`) | Tested: 11,931 textures / 11,950 pictures | Tested: 101 banks | Tested: 22/22 inspected | +| Japanese demo (`PCPX_966.57`) | Tested: 3,518 textures / 3,527 pictures | Untested | Untested | +| Korean retail (`SCKA_200.62`) | Untested | Tested: 101 banks; `boss_specter` inspected | Untested | +| PAL retail (`SCES_536.42`) | Untested | Untested | Tested: 22/22 inspected; `new_play01` played | +| PAL beta (`SCES_536.42`; serial shared with retail) | Untested | Untested | Untested | +| All other builds and regions | Untested | Untested | Untested | + ## Development Use Node.js 20.19 or newer and npm. CI runs on Node.js 24. diff --git a/public/synth/ae3synth.wasm b/public/synth/ae3synth.wasm index a4b3fed..7dcbcaf 100755 Binary files a/public/synth/ae3synth.wasm and b/public/synth/ae3synth.wasm differ diff --git a/public/synth/se.mjs b/public/synth/se.mjs index 88da9f6..02677cd 100644 --- a/public/synth/se.mjs +++ b/public/synth/se.mjs @@ -165,11 +165,13 @@ function requestInfo(raw, programs) { const loopVoices = active.filter((voice) => voice.tone.sampleLoop); const sustainedVoices = loopVoices.filter((voice) => voice.tone.indefinite).length; - const sourceEndExactFrame = loopVoices.reduce( + const finiteLoopVoices = loopVoices.filter( + (voice) => Number.isFinite(voice.tone.envelopeFrames)); + const sourceEndExactFrame = finiteLoopVoices.reduce( (end, voice) => Math.max( end, voice.startExactFrame + voice.tone.envelopeFrames), exactFrame); - const sourceEndConsoleFrame = loopVoices.reduce( + const sourceEndConsoleFrame = finiteLoopVoices.reduce( (end, voice) => Math.max( end, voice.startConsoleFrame + voice.tone.envelopeFrames), consoleFrame); @@ -321,7 +323,7 @@ function programDetails(hd, bd) { noise, adsr1, adsr2, - envelopeFrames: envFrames, + ...(Number.isFinite(envFrames) ? { envelopeFrames: envFrames } : {}), indefinite: !silent && sample.sampleLoop && !Number.isFinite(envFrames), }; }); diff --git a/src/content-identity.ts b/src/content-identity.ts new file mode 100644 index 0000000..7ef0425 --- /dev/null +++ b/src/content-identity.ts @@ -0,0 +1,30 @@ +export interface ContentFingerprint { + readonly bytes: number; + readonly sha256: string; +} + +const SHA256_HEX = /^[0-9a-f]{64}$/; + +export function isContentFingerprint(value: unknown): value is ContentFingerprint { + if (value === null || typeof value !== "object") return false; + const fingerprint = value as Record; + return Number.isSafeInteger(fingerprint.bytes) + && (fingerprint.bytes as number) >= 0 + && typeof fingerprint.sha256 === "string" + && SHA256_HEX.test(fingerprint.sha256); +} + +export async function fingerprintBytes(bytes: Uint8Array): Promise { + const source: Uint8Array = bytes.buffer instanceof ArrayBuffer + ? new Uint8Array(bytes.buffer, bytes.byteOffset, bytes.byteLength) + : new Uint8Array(bytes); + const digest = await crypto.subtle.digest("SHA-256", source); + const sha256 = Array.from(new Uint8Array(digest), byte => + byte.toString(16).padStart(2, "0")).join(""); + return { bytes: bytes.byteLength, sha256 }; +} + +export function contentFingerprintMatches(left: ContentFingerprint, + right: ContentFingerprint): boolean { + return left.bytes === right.bytes && left.sha256 === right.sha256; +} diff --git a/src/disc-identity.ts b/src/disc-identity.ts new file mode 100644 index 0000000..8b01e70 --- /dev/null +++ b/src/disc-identity.ts @@ -0,0 +1,34 @@ +export interface DiscCacheIdentity { + readonly key: string; +} + +const CACHE_MISMATCH = "cached assets belong to a different disc session " + + "-- clear data before switching discs"; +const SOURCE_MISMATCH = "this ISO is a different disc than the cached one " + + "-- clear data before switching discs"; +const IDENTITY_UNAVAILABLE = "disc source identity is unavailable " + + "-- reconnect the disc before using cached assets"; + +function requireIdentity(key: string): void { + if (typeof key !== "string" || key.length === 0) + throw new Error(IDENTITY_UNAVAILABLE); +} + + +/** Refuse a cache namespace that was not derived from this session's source. */ +export function assertCacheSourceIdentity(cache: DiscCacheIdentity | null, + sourceKey: string): void { + requireIdentity(sourceKey); + if (cache) requireIdentity(cache.key); + if (cache && cache.key !== sourceKey) + throw new Error(CACHE_MISMATCH); +} + +/** Refuse to attach source containers from another disc to a cached catalog. */ +export function assertAttachedDiscIdentity(expectedKey: string, + attachedKey: string): void { + requireIdentity(expectedKey); + requireIdentity(attachedKey); + if (expectedKey !== attachedKey) + throw new Error(SOURCE_MISMATCH); +} diff --git a/src/disc-open.ts b/src/disc-open.ts new file mode 100644 index 0000000..e43f2d3 --- /dev/null +++ b/src/disc-open.ts @@ -0,0 +1,43 @@ +export interface DiscOpenTicket { + readonly generation: number; + readonly signal: AbortSignal; +} + +/** One generation shared by startup resume and every explicit disc open. */ +export class DiscOpenCoordinator { + private generation = 0; + private controller: AbortController | null = null; + + begin(): DiscOpenTicket { + this.controller?.abort(); + const controller = new AbortController(); + this.controller = controller; + return { + generation: ++this.generation, + signal: controller.signal, + }; + } + + isCurrent(ticket: DiscOpenTicket): boolean { + return ticket.generation === this.generation && !ticket.signal.aborted; + } + + complete(ticket: DiscOpenTicket): void { + if (this.isCurrent(ticket)) this.controller = null; + } +} + +/** Serializes teardown so a superseding open cannot bypass older cleanup. */ +export class SerializedDiscCleanup { + private tail: Promise = Promise.resolve(); + + run(operation: () => Promise): Promise { + const result = this.tail.then(operation); + /* The caller still receives `result`; the queue tail is settled so a + * reported cleanup failure cannot strand every future reset. */ + this.tail = result.catch(error => { + console.error("disc cleanup failed", error); + }); + return result; + } +} diff --git a/src/disc.ts b/src/disc.ts index 6188a9f..37c3d93 100644 --- a/src/disc.ts +++ b/src/disc.ts @@ -1,14 +1,15 @@ /* Disc session: ISO -> @ae3/extract openDisc() -> assets in OPFS. * - * First visit: the user points at their .iso, the BGM asset set (~50 MB: - * banks, sequences, the two IRX donors, the mastering DB's song table) is - * extracted CLIENT-SIDE into origin-private storage keyed per disc, and a - * meta.json completeness marker is written LAST. Later visits resume from - * OPFS via localStorage's last-disc pointer -- no ISO needed. "Forget my disc" - * wipes both. + * First visit: the user points at their .iso. The BGM asset set (~50 MB: + * banks, sequences, the two IRX donors, and mastering data) plus available + * viewer UI packages is cached CLIENT-SIDE in origin-private storage keyed + * per disc; meta.json is written LAST as the initial-session completeness + * marker. Later visits can resume BGM without the ISO. Uncached libraries + * still require the same ISO to be reattached. "Forget my disc" wipes both + * the cache and its pointer. * - * Without OPFS (or if the user declines persistence) the session still works, - * reading straight from the ISO File for this visit only. */ + * Without OPFS (or if persistence fails) the session still works, reading + * straight from the ISO File for this visit only. */ import { openDisc, BlobSource, OpfsCache, type BgmSong, type VfiEntry } from "./vendor/extract/index.ts"; @@ -19,36 +20,149 @@ import { ImageStore } from "./images.ts"; import { ModelStore } from "./models.ts"; import { StagePreviewStore } from "./stage-previews.ts"; import type { SongAssets } from "./player.ts"; +import { technicalReason } from "./errors.ts"; +import { + contentFingerprintMatches, + fingerprintBytes, + isContentFingerprint, + type ContentFingerprint, +} from "./content-identity.ts"; export const LAST_DISC_KEY = "ae3.lastDisc"; const META = "meta.json"; +const EPHEMERAL_HISTORY_KEY = "ae3EphemeralDisc"; -/** The disc every gate runs against; anything else is best-effort (ยง4.6). */ +function resumeBlockedForThisHistoryEntry(): boolean { + if (typeof history === "undefined") return false; + const state = history.state; + return state !== null && typeof state === "object" + && Reflect.get(state, EPHEMERAL_HISTORY_KEY) === true; +} + +function setResumeBlockedForThisHistoryEntry(blocked: boolean): void { + if (typeof history === "undefined") { + if (blocked) throw new Error("History API is unavailable"); + return; + } + const current = history.state; + const next: Record = {}; + if (current !== null && typeof current === "object") + Object.assign(next, current); + if (blocked) + next[EPHEMERAL_HISTORY_KEY] = true; + else + Reflect.deleteProperty(next, EPHEMERAL_HISTORY_KEY); + history.replaceState(next, ""); +} + + +/** Original US retail regression serial. */ export const US_SERIAL = "SCUS_975.01"; -/** Human-readable form of open/read failures for the picker + status line. */ +export function discSupportWarning(serial: string | null): string { + if (serial === US_SERIAL) return ""; + return serial + ? ` - partially tested build (${serial}); untested paths may be off` + : " - untested build (unknown serial); things may be off"; +} + +/** Human-readable, path-safe form of open/read failures for UI surfaces. */ export function friendlyError(e: unknown): string { if (e instanceof DOMException && (e.name === "NotReadableError" || e.name === "NotFoundError")) return "the ISO file could not be read -- if it is on a removable " + "drive or was moved, reconnect it and choose it again"; - const msg = e instanceof Error ? e.message : String(e); - if (/short read/.test(msg)) - return `${msg} -- the image ends too early; the ISO file looks ` + const reason = technicalReason(e); + if (/short read/.test(reason)) + return `${reason} -- the image ends too early; the ISO file looks ` + "truncated or partially copied"; - if (/ in DATA\.BIN/.test(msg)) - return `${msg} -- the disc was read but its layout is unrecognized ` - + "(non-US discs are untested; please report your disc serial)"; - return msg; + if (/ in DATA\.BIN/.test(reason)) + return `${reason} -- the disc was read but its layout is not supported; ` + + "see the build-support matrix or report the disc serial"; + return reason; +} + +interface CachedAssetIdentity extends ContentFingerprint { + readonly name: string; } interface Meta { - v: 1; - serial: string | null; - volumeId: string; - songs: BgmSong[]; - hasIrx: boolean; - hasLibsd: boolean; + readonly v: 2; + readonly sourceKey: string; + readonly serial: string | null; + readonly volumeId: string; + readonly songs: readonly BgmSong[]; + readonly hasIrx: boolean; + readonly hasLibsd: boolean; + readonly assets: readonly CachedAssetIdentity[]; +} + +function isBgmSong(value: unknown): value is BgmSong { + if (value === null || typeof value !== "object") return false; + const song = value as Record; + return typeof song.name === "string" && song.name.length > 0 + && typeof song.mid === "string" && song.mid.length > 0 + && typeof song.hd === "string" && song.hd.length > 0 + && typeof song.bd === "string" && song.bd.length > 0 + && Number.isSafeInteger(song.songvol) + && (song.songvol as number) >= 0 && (song.songvol as number) <= 127 + && typeof song.volumeScale === "number" + && Number.isFinite(song.volumeScale) && song.volumeScale >= 0; +} + +function isCachedAssetIdentity(value: unknown): value is CachedAssetIdentity { + if (!isContentFingerprint(value)) return false; + const asset = value as ContentFingerprint & Record; + return typeof asset.name === "string" && asset.name.length > 0; +} + +function damagedMeta(cause?: unknown): Error { + return new Error( + "cached disc metadata is damaged -- choose the same ISO to rebuild it or clear this site's data", + cause === undefined ? undefined : { cause }, + ); +} + +function parseMeta(raw: Uint8Array, expectedSourceKey: string): Meta | null { + let value: unknown; + try { + value = JSON.parse(new TextDecoder().decode(raw)); + } catch (cause) { + throw damagedMeta(cause); + } + if (value === null || typeof value !== "object") + throw damagedMeta(); + const meta = value as Record; + if (meta.v !== 2) return null; + if (meta.sourceKey !== expectedSourceKey + || !(meta.serial === null + || (typeof meta.serial === "string" && meta.serial.length > 0)) + || typeof meta.volumeId !== "string" || meta.volumeId.length === 0 + || !Array.isArray(meta.songs) || meta.songs.length === 0 + || !meta.songs.every(isBgmSong) + || typeof meta.hasIrx !== "boolean" + || typeof meta.hasLibsd !== "boolean" + || !Array.isArray(meta.assets) || meta.assets.length === 0 + || !meta.assets.every(isCachedAssetIdentity)) + throw damagedMeta(); + const songNames = meta.songs.map(song => song.name); + const assets = meta.assets as CachedAssetIdentity[]; + const assetNames = assets.map(asset => asset.name); + if (new Set(songNames).size !== songNames.length + || new Set(assetNames).size !== assetNames.length) + throw damagedMeta(); + const available = new Set(assetNames); + for (const song of meta.songs as BgmSong[]) { + if (!available.has(`bgm/${song.hd}`) + || !available.has(`bgm/${song.bd}`) + || !available.has(`bgm/${song.mid}`)) + throw damagedMeta(); + } + if (meta.hasIrx && !available.has("irx/sg2iopm1.irx")) + throw damagedMeta(); + if (meta.hasLibsd && !available.has("irx/libsd.irx")) + throw damagedMeta(); + return meta as unknown as Meta; } export interface DiscSession { @@ -56,6 +170,7 @@ export interface DiscSession { serial: string | null; volumeId: string; cached: boolean; /* true = OPFS-backed, ISO-free */ + persistenceWarning: string | null; /* non-null = usable, not resumable */ streams: StreamStore; /* sound/stream phase (STREAMS tab) */ se: SeStore; /* lazy sound/se phase (SE tab) */ movies: MovieStore; /* lazy per-movie FMV phase */ @@ -83,47 +198,121 @@ async function assetsFor(session: DiscSession, song: BgmSong, }; } +async function forgetSessionCache(cache: OpfsCache | null): Promise { + const failures: unknown[] = []; + try { + localStorage.removeItem(LAST_DISC_KEY); + } catch (error) { + failures.push(error); + } + try { + await cache?.forget(); + } catch (error) { + failures.push(error); + } + if (failures.length === 1) throw failures[0]; + if (failures.length > 1) + throw new AggregateError(failures, "failed to forget the disc cache"); +} + +function persistenceFallback(error: unknown): string { + return `browser persistence is unavailable (${technicalReason(error)}); ` + + "using the attached ISO for this session"; +} + +function cachedAssetIdentities(meta: Meta): Map { + return new Map(meta.assets.map(asset => [asset.name, asset])); +} + +async function readVerifiedCachedAsset( + cache: OpfsCache, + name: string, + identities: ReadonlyMap, + verified: Set, +): Promise { + const expected = identities.get(name); + if (!expected) return null; + const bytes = await cache.read(name); + if (!bytes) return null; + if (!verified.has(name)) { + const actual = await fingerprintBytes(bytes); + if (!contentFingerprintMatches(expected, actual)) + throw new Error("cached asset content does not match its metadata"); + verified.add(name); + } + return bytes; +} + /** Resume the previous disc from OPFS; null = nothing cached (show picker). */ export async function resumeSession(): Promise { + if (resumeBlockedForThisHistoryEntry()) return null; const last = localStorage.getItem(LAST_DISC_KEY); if (!last || !OpfsCache.supported()) return null; - try { - const cache = await OpfsCache.open(last); - const metaRaw = await cache.read(META); - if (!metaRaw) return null; - const meta = JSON.parse(new TextDecoder().decode(metaRaw)) as Meta; - if (meta.v !== 1 || !Array.isArray(meta.songs)) return null; - const session: DiscSession = { - songs: meta.songs, - serial: meta.serial, - volumeId: meta.volumeId, - cached: true, - streams: new StreamStore(cache, null, last), - se: new SeStore(cache, null, last), - movies: new MovieStore(cache, null, last), - images: new ImageStore(cache, null, last), - models: new ModelStore(cache, null, last), - stagePreviews: new StagePreviewStore(cache, null, last), - read: (n) => cache.read(n), - songAssets: (song) => - assetsFor(session, song, meta.hasIrx, meta.hasLibsd), - forget: async () => { - localStorage.removeItem(LAST_DISC_KEY); - await cache.forget(); - }, - }; - return session; - } catch { - return null; /* stale/corrupt cache: fall back to the picker */ - } + const cache = await OpfsCache.open(last); + const metaRaw = await cache.read(META); + if (!metaRaw) return null; + const meta = parseMeta(metaRaw, last); + if (!meta) return null; + const identities = cachedAssetIdentities(meta); + const verified = new Set(); + const session: DiscSession = { + songs: [...meta.songs], + serial: meta.serial, + volumeId: meta.volumeId, + cached: true, + persistenceWarning: null, + streams: new StreamStore(cache, null, last), + se: new SeStore(cache, null, last), + movies: new MovieStore(cache, null, last), + images: new ImageStore(cache, null, last), + models: new ModelStore(cache, null, last), + stagePreviews: new StagePreviewStore(cache, null, last), + read: (name) => + readVerifiedCachedAsset(cache, name, identities, verified), + songAssets: (song) => + assetsFor(session, song, meta.hasIrx, meta.hasLibsd), + forget: () => forgetSessionCache(cache), + }; + return session; } export type Progress = (done: number, total: number, name: string) => void; -/** Open an ISO, extract the BGM asset set into OPFS (with progress), return - * the session. Extraction failures fall back to reading from the File. */ -export async function openIso(file: File, progress: Progress): Promise { +/** Open an ISO and extract the BGM asset set into OPFS (with progress). + * OPFS setup or write failures fall back to the ISO for this session; ISO read + * and format failures abort the open. */ +export async function openIso(file: File, progress: Progress, + signal?: AbortSignal): Promise { const disc = await openDisc(new BlobSource(file)); + signal?.throwIfAborted(); + let persistenceWarning: string | null = null; + let persistenceAvailable = true; + try { + localStorage.removeItem(LAST_DISC_KEY); + } catch (error) { + signal?.throwIfAborted(); + try { + setResumeBlockedForThisHistoryEntry(true); + } catch (historyError) { + throw new AggregateError( + [error, historyError], + "browser storage could not safely invalidate the previous disc session", + ); + } + persistenceWarning = persistenceFallback(error); + persistenceAvailable = false; + console.warn("Disc resume pointer clear failed; playing from the ISO", error); + } + if (persistenceAvailable) { + try { + setResumeBlockedForThisHistoryEntry(false); + } catch (error) { + signal?.throwIfAborted(); + persistenceWarning = persistenceFallback(error); + persistenceAvailable = false; + console.warn("Disc resume state clear failed; playing from the ISO", error); + } + } const cacheName = (e: VfiEntry) => `bgm/${e.path.slice(e.path.lastIndexOf("/") + 1)}`; const jobs: Array<[string, VfiEntry]> = [ ...disc.assets.hd.map((e): [string, VfiEntry] => [cacheName(e), e]), @@ -133,10 +322,9 @@ export async function openIso(file: File, progress: Progress): Promise = [ ["viewer/ci_studio.pck.sz", "/cinema/ci_studio/ui.pck.sz"], ["viewer/ui_title.pck.sz", "/etc/title/ui_title.pck.sz"], @@ -148,83 +336,129 @@ export async function openIso(file: File, progress: Progress): Promise(); + const assetIdentities: CachedAssetIdentity[] = []; + if (persistenceAvailable && OpfsCache.supported()) { try { cache = await OpfsCache.open(disc.cacheKey); - } catch (e) { - console.warn("OPFS unavailable; playing from the ISO", e); - } - } - for (let i = 0; i < jobs.length && cache; i++) { - const [name, entry] = jobs[i]!; - progress(i, jobs.length, name); - try { - if (await cache.has(name)) continue; - } catch (e) { - console.warn("OPFS failed; playing from the ISO", e); + ownedCache = cache; + const raw = await cache.read(META); + previousMeta = raw ? parseMeta(raw, disc.cacheKey) : null; + signal?.throwIfAborted(); + } catch (error) { + signal?.throwIfAborted(); + persistenceWarning = persistenceFallback(error); + console.warn("OPFS unavailable; playing from the ISO", error); cache = null; - break; } + } + const previousAssets = previousMeta + ? cachedAssetIdentities(previousMeta) + : new Map(); + for (let index = 0; index < jobs.length && cache; index++) { + signal?.throwIfAborted(); + const [name, entry] = jobs[index]!; + progress(index, jobs.length, name); const data = await disc.vfi.read(entry); /* hard failure */ + signal?.throwIfAborted(); try { - await cache.write(name, data); - } catch (e) { - console.warn("OPFS write failed; playing from the ISO", e); + const current = await fingerprintBytes(data); + signal?.throwIfAborted(); + assetIdentities.push({ name, ...current }); + const previous = previousAssets.get(name); + const reusable = previous !== undefined + && contentFingerprintMatches(previous, current) + && await cache.has(name); + signal?.throwIfAborted(); + if (!reusable) { + await cache.write(name, data); + verifiedCacheAssets.add(name); + signal?.throwIfAborted(); + } + } catch (error) { + signal?.throwIfAborted(); + persistenceWarning = persistenceFallback(error); + console.warn("OPFS asset validation failed; playing from the ISO", error); cache = null; } } + const meta: Meta = { + v: 2, + sourceKey: disc.cacheKey, + serial: disc.serial, + volumeId: disc.volumeId, + songs: disc.songs, + hasIrx: disc.assets.sg2iopm1 !== null, + hasLibsd: disc.assets.libsd !== null, + assets: assetIdentities, + }; if (cache) { try { await cache.write(META, new TextEncoder().encode(JSON.stringify(meta))); + signal?.throwIfAborted(); + } catch (error) { + signal?.throwIfAborted(); + persistenceWarning = persistenceFallback(error); + console.warn("OPFS metadata write failed; playing from the ISO", error); + cache = null; + } + } + if (cache) { + try { + signal?.throwIfAborted(); localStorage.setItem(LAST_DISC_KEY, disc.cacheKey); - progress(jobs.length, jobs.length, "done"); - } catch (e) { - console.warn("OPFS write failed; playing from the ISO", e); + } catch (error) { + signal?.throwIfAborted(); + persistenceWarning = persistenceFallback(error); + console.warn("Disc resume pointer write failed; playing from the ISO", error); cache = null; } } + signal?.throwIfAborted(); + progress(jobs.length, jobs.length, "done"); const byName = new Map(jobs.map(([n, e]) => [n, e])); + const currentAssetIdentities = cachedAssetIdentities(meta); const session: DiscSession = { songs: disc.songs, serial: disc.serial, volumeId: disc.volumeId, cached: cache !== null, + persistenceWarning, streams: new StreamStore(cache, disc.vfi, disc.cacheKey), se: new SeStore(cache, disc.vfi, disc.cacheKey), movies: new MovieStore(cache, disc.vfi, disc.cacheKey), images: new ImageStore(cache, disc.vfi, disc.cacheKey), models: new ModelStore(cache, disc.vfi, disc.cacheKey), stagePreviews: new StagePreviewStore(cache, disc.vfi, disc.cacheKey), - read: async (n) => { + read: async (name) => { if (cache) { - const b = await cache.read(n); - if (b) return b; + try { + const bytes = await readVerifiedCachedAsset( + cache, name, currentAssetIdentities, verifiedCacheAssets); + if (bytes) return bytes; + } catch (error) { + console.warn("OPFS asset validation failed; using the attached ISO", error); + cache = null; + session.cached = false; + session.persistenceWarning = persistenceFallback(error); + } } - const e = byName.get(n); - return e ? disc.vfi.read(e) : null; + const entry = byName.get(name); + return entry ? disc.vfi.read(entry) : null; }, songAssets: (song) => assetsFor(session, song, meta.hasIrx, meta.hasLibsd), - forget: async () => { - localStorage.removeItem(LAST_DISC_KEY); - await cache?.forget(); - }, + forget: () => forgetSessionCache(ownedCache), }; - await session.movies.catalog(); return session; } diff --git a/src/errors.ts b/src/errors.ts new file mode 100644 index 0000000..237cab7 --- /dev/null +++ b/src/errors.ts @@ -0,0 +1,42 @@ +const MAX_TECHNICAL_REASON = 320; +const SOURCE_OFFSET = /\s+at\s+0x[0-9a-f]+:\s*/i; +const PATH_PREFIX = /^(.+?):\s+(.+)$/s; +const DISC_PATH = /(^|[\s("'`=,:;\[])(?:[A-Za-z]:[\\/]|\/|\\\\)?(?:[A-Za-z0-9_.-]+[\\/])+(?:[A-Za-z0-9_.-]+)/g; + +function hasPathSyntax(value: string): boolean { + return value.includes("/") || value.includes("\\") + || /^file:/i.test(value) || /^[a-z]:/i.test(value); +} + +function containsLocalPath(value: string): boolean { + const normalized = value.replaceAll("\\", "/").toLowerCase(); + return /(^|[\s("'`=,:;\[])\/(?!\/)/.test(value) + || normalized.includes("/users/") + || normalized.includes("/home/") + || normalized.includes("/volumes/") + || normalized.includes("file://") + || /(^|[\s("'`=,:;\[])[a-z]:\//i.test(normalized) + || /(^|[\s("'`=,:;\[])\\\\[^\\\s]+\\/i.test(value); +} + +/** A bounded technical reason suitable for UI and persisted catalog errors. */ +export function technicalReason(cause: unknown): string { + const raw = cause instanceof Error ? cause.message : String(cause); + let reason = raw.replace(/\s+/g, " ").trim(); + if (!reason) return "unknown error"; + + const offset = SOURCE_OFFSET.exec(reason); + if (offset && hasPathSyntax(reason.slice(0, offset.index))) + reason = `disc format${reason.slice(offset.index)}`; + else { + const prefixed = PATH_PREFIX.exec(reason); + if (prefixed && hasPathSyntax(prefixed[1]!)) + reason = `disc format: ${prefixed[2]}`; + } + + if (containsLocalPath(reason)) + return "local data access failed"; + + reason = reason.replace(DISC_PATH, "$1[disc asset]").trim(); + return reason.slice(0, MAX_TECHNICAL_REASON) || "unknown error"; +} diff --git a/src/export.ts b/src/export.ts index 2e12ad1..01486db 100644 --- a/src/export.ts +++ b/src/export.ts @@ -7,6 +7,7 @@ import type { SeAssets, SongAssets } from "./player.ts"; import { RATE } from "./timeline.ts"; import { storeZip } from "./zip.ts"; +import { technicalReason } from "./errors.ts"; export interface ExportOpts { songvol: number; @@ -27,7 +28,7 @@ export interface SeExportOpts { loop: boolean; } -const base = import.meta.env.BASE_URL; +const base = import.meta.env?.BASE_URL ?? "/"; /** Deliver `bytes` to the user as a file download. */ export function download(bytes: Uint8Array | ArrayBuffer | Blob, file: string, @@ -45,39 +46,187 @@ export function download(bytes: Uint8Array | ArrayBuffer | Blob, file: string, setTimeout(() => URL.revokeObjectURL(url), 10_000); } +function record(value: unknown): Record | null { + return value !== null && typeof value === "object" + ? value as Record + : null; +} + +function hasOnlyKeys( + value: Record, + allowed: ReadonlySet, +): boolean { + return Object.keys(value).every(key => allowed.has(key)); +} + +const PROGRESS_KEYS = new Set(["t", "frames"]); +const DONE_KEYS = new Set(["t", "wav", "frames"]); +const ERROR_KEYS = new Set(["t", "id", "message"]); +const KIT_DONE_KEYS = new Set(["t", "entries"]); +const KIT_ENTRY_KEYS = new Set(["name", "wav"]); + +function isFrameCount(value: unknown): value is number { + return typeof value === "number" + && Number.isSafeInteger(value) + && value >= 0; +} + +function isWav(value: unknown): value is Uint8Array { + if (!(value instanceof Uint8Array) || value.byteLength < 44) return false; + const view = new DataView(value.buffer, value.byteOffset, value.byteLength); + return value[0] === 0x52 && value[1] === 0x49 + && value[2] === 0x46 && value[3] === 0x46 + && value[8] === 0x57 && value[9] === 0x41 + && value[10] === 0x56 && value[11] === 0x45 + && view.getUint32(4, true) === value.byteLength - 8; +} + +function isProgress(message: Record): boolean { + return hasOnlyKeys(message, PROGRESS_KEYS) + && message.t === "progress" + && isFrameCount(message.frames); +} + +function isDone(message: Record): boolean { + return hasOnlyKeys(message, DONE_KEYS) + && message.t === "done" + && isFrameCount(message.frames) + && isWav(message.wav); +} + +function isWorkerError(message: Record): boolean { + return hasOnlyKeys(message, ERROR_KEYS) + && message.t === "error" + && message.id === undefined + && typeof message.message === "string" + && message.message.length > 0; +} + +function isKitDone(message: Record): boolean { + if (!hasOnlyKeys(message, KIT_DONE_KEYS) + || message.t !== "kit-done" + || !Array.isArray(message.entries) + || message.entries.length > 0xffff) + return false; + return message.entries.every(value => { + const entry = record(value); + return entry !== null + && hasOnlyKeys(entry, KIT_ENTRY_KEYS) + && typeof entry.name === "string" + && entry.name.length > 0 + && isWav(entry.wav); + }); +} + export class Exporter { busy = false; onstatus: ((msg: string, err: boolean) => void) | null = null; private worker: Worker | null = null; + private reportFailure(cause: unknown): void { + this.busy = false; + this.onstatus?.(`EXPORT FAILED: ${technicalReason(cause)}`, true); + } + + private failWorker(worker: Worker, cause: unknown): void { + if (this.worker !== worker) return; + this.worker = null; + worker.onmessage = null; + worker.onerror = null; + worker.onmessageerror = null; + worker.terminate(); + this.reportFailure(cause); + } + + private ensure(): Worker { + if (this.worker) return this.worker; + const worker = new Worker(`${base}synth/export-worker.mjs`, + { type: "module" }); + this.worker = worker; + worker.onerror = event => { + event.preventDefault(); + this.failWorker( + worker, + new Error(event.message || "audio export worker failed"), + ); + }; + worker.onmessageerror = () => { + this.failWorker(worker, new Error("invalid audio export worker response")); + }; + return worker; + } + + private begin( + progress: ((frames: number) => string) | null, + complete: (message: Record, worker: Worker) => void, + ): Worker | null { + if (this.busy) return null; + this.busy = true; + let worker: Worker; + try { + worker = this.ensure(); + } catch (cause) { + this.reportFailure(cause); + return null; + } + worker.onmessage = event => { + if (this.worker !== worker) return; + try { + const message = record(event.data); + if (!message) + throw new Error("invalid audio export worker response"); + if (isProgress(message) && progress) { + this.onstatus?.(progress(message.frames as number), false); + return; + } + if (isWorkerError(message)) { + this.failWorker(worker, new Error(message.message as string)); + return; + } + complete(message, worker); + } catch (cause) { + this.failWorker(worker, cause); + } + }; + return worker; + } + + private finish(worker: Worker): boolean { + if (this.worker !== worker) return false; + worker.onmessage = null; + this.busy = false; + return true; + } + + private send( + worker: Worker, + message: Record, + transfer: Transferable[], + ): void { + try { + worker.postMessage(message, transfer); + } catch (cause) { + this.failWorker(worker, cause); + } + } + /** Render `name{suffix}.wav` off-thread and download it. */ start(name: string, suffix: string, assets: SongAssets, o: ExportOpts): void { - if (this.busy) return; - this.busy = true; - this.worker ??= new Worker(`${base}synth/export-worker.mjs`, - { type: "module" }); const file = `${name}${suffix}.wav`; + const worker = this.begin( + frames => `EXPORTING ${file}... ${(frames / RATE).toFixed(0)}s`, + (message, current) => { + if (!isDone(message)) + throw new Error("invalid audio export worker response"); + download(message.wav as Uint8Array, file, "audio/wav"); + if (this.finish(current)) + this.onstatus?.(`EXPORTED ${file}`, false); + }, + ); + if (!worker) return; this.onstatus?.(`EXPORTING ${file} (VOL ${o.songvol}` + `${o.loop >= 2 ? " LOOPED" : ""})...`, false); const withRev = o.revDepth > 0 && assets.libsd !== null; - this.worker.onmessage = (e) => { - const m = e.data; - if (m.t === "progress") { - this.onstatus?.( - `EXPORTING ${file}... ${(m.frames / RATE).toFixed(0)}s`, false); - } else if (m.t === "done") { - this.busy = false; - download(m.wav, file, "audio/wav"); - this.onstatus?.(`EXPORTED ${file}`, false); - } else if (m.t === "error") { - this.busy = false; - this.onstatus?.(`EXPORT FAILED: ${m.message}`, true); - } - }; - this.worker.onerror = (e) => { - this.busy = false; - this.onstatus?.(`EXPORT FAILED: ${e.message}`, true); - }; const files = { hd: assets.hd, bd: assets.bd, mid: assets.mid, irx: assets.irx, @@ -88,39 +237,43 @@ export class Exporter { revDepth: withRev ? o.revDepth : null, exact: o.exact, bright: o.bright, loop: o.loop, }; - this.worker.postMessage({ t: "render", files, opts }, - [assets.hd.buffer, assets.bd.buffer, assets.mid.buffer, - ...(assets.irx ? [assets.irx.buffer] : []), - ...(withRev ? [assets.libsd!.buffer] : [])]); + this.send(worker, { t: "render", files, opts }, [ + assets.hd.buffer, + assets.bd.buffer, + assets.mid.buffer, + ...(assets.irx ? [assets.irx.buffer] : []), + ...(withRev ? [assets.libsd!.buffer] : []), + ]); + } + + dispose(): void { + const worker = this.worker; + this.worker = null; + if (worker) { + worker.onmessage = null; + worker.onerror = null; + worker.onmessageerror = null; + worker.terminate(); + } + this.busy = false; } /** Render one embedded SE request, capped for authored infinite loops. */ se(name: string, assets: SeAssets, o: SeExportOpts): void { - if (this.busy) return; - this.busy = true; - this.worker ??= new Worker(`${base}synth/export-worker.mjs`, - { type: "module" }); const file = `${name}_${o.bank}_${o.request}.wav`; + const worker = this.begin( + frames => `EXPORTING ${file}... ${(frames / RATE).toFixed(1)}s`, + (message, current) => { + if (!isDone(message)) + throw new Error("invalid audio export worker response"); + download(message.wav as Uint8Array, file, "audio/wav"); + if (this.finish(current)) + this.onstatus?.(`EXPORTED ${file}`, false); + }, + ); + if (!worker) return; this.onstatus?.(`EXPORTING ${file} (VOL ${o.volume})...`, false); const withRev = o.revDepth > 0 && assets.libsd !== null; - this.worker.onmessage = (e) => { - const m = e.data; - if (m.t === "progress") { - this.onstatus?.( - `EXPORTING ${file}... ${(m.frames / RATE).toFixed(1)}s`, false); - } else if (m.t === "done") { - this.busy = false; - download(m.wav, file, "audio/wav"); - this.onstatus?.(`EXPORTED ${file}`, false); - } else if (m.t === "error") { - this.busy = false; - this.onstatus?.(`EXPORT FAILED: ${m.message}`, true); - } - }; - this.worker.onerror = (e) => { - this.busy = false; - this.onstatus?.(`EXPORT FAILED: ${e.message}`, true); - }; const files = { hd: assets.hd, bd: assets.bd, irx: assets.irx, libsd: withRev ? assets.libsd : null, @@ -131,40 +284,40 @@ export class Exporter { exact: o.exact, bright: o.bright, bank: o.bank, request: o.request, seconds: o.seconds, loop: o.loop, }; - this.worker.postMessage({ t: "se-render", files, opts }, - [assets.hd.buffer, assets.bd.buffer, - ...(assets.irx ? [assets.irx.buffer] : []), - ...(withRev ? [assets.libsd!.buffer] : [])]); + this.send(worker, { t: "se-render", files, opts }, [ + assets.hd.buffer, + assets.bd.buffer, + ...(assets.irx ? [assets.irx.buffer] : []), + ...(withRev ? [assets.libsd!.buffer] : []), + ]); } /** Build `name`_samples.zip (every bank waveform as a WAV) off-thread * and download it. Same worker + busy flag as the WAV renders. */ kit(name: string, assets: SongAssets): void { - if (this.busy) return; - this.busy = true; - this.worker ??= new Worker(`${base}synth/export-worker.mjs`, - { type: "module" }); const file = `${name}_samples.zip`; + const worker = this.begin(null, (message, current) => { + if (!isKitDone(message)) + throw new Error("invalid audio export worker response"); + const entries = (message.entries as Record[]) + .map(entry => [ + entry.name as string, + entry.wav as Uint8Array, + ] as [string, Uint8Array]); + const archive = storeZip(entries); + download(archive, file, "application/zip"); + if (this.finish(current)) + this.onstatus?.( + `EXPORTED ${file} (${entries.length} SAMPLES)`, + false, + ); + }); + if (!worker) return; this.onstatus?.(`EXPORTING ${file}...`, false); - this.worker.onmessage = (e) => { - const m = e.data; - if (m.t === "kit-done") { - this.busy = false; - const entries: [string, Uint8Array][] = - (m.entries as { name: string; wav: Uint8Array }[]) - .map((x) => [x.name, x.wav]); - download(storeZip(entries), file, "application/zip"); - this.onstatus?.(`EXPORTED ${file} (${entries.length} SAMPLES)`, false); - } else if (m.t === "error") { - this.busy = false; - this.onstatus?.(`EXPORT FAILED: ${m.message}`, true); - } - }; - this.worker.onerror = (e) => { - this.busy = false; - this.onstatus?.(`EXPORT FAILED: ${e.message}`, true); - }; - this.worker.postMessage({ t: "kit", files: { hd: assets.hd, bd: assets.bd } }, - [assets.hd.buffer, assets.bd.buffer]); + this.send( + worker, + { t: "kit", files: { hd: assets.hd, bd: assets.bd } }, + [assets.hd.buffer, assets.bd.buffer], + ); } } diff --git a/src/images.ts b/src/images.ts index 7ac04be..cf20dfe 100644 --- a/src/images.ts +++ b/src/images.ts @@ -2,25 +2,52 @@ import { BlobSource, decodeTim2, inflateSz, + inspectTim2, memberBytes, openDisc, OpfsCache, - readImageTexture, unpackPck, scanImageTextures, type ImageRole, + type ImageRoleEvidence, type ImageTexture, type Vfi, } from "./vendor/extract/index.ts"; +import { type ImageFormat } from "./vendor/extract/image-format.ts"; +import { + assertAttachedDiscIdentity, + assertCacheSourceIdentity, +} from "./disc-identity.ts"; +import { technicalReason } from "./errors.ts"; +import { + contentFingerprintMatches, + fingerprintBytes, + isContentFingerprint, + type ContentFingerprint, +} from "./content-identity.ts"; +import { assertZipEntryPath } from "./zip.ts"; const META = "image_meta.json"; const CONTAINER_CACHE_LIMIT = 4; +export interface ImageSourceIdentity extends ContentFingerprint { + readonly entryOffset: number; + readonly sourcePath: string; +} + +export interface ImageCatalogIssue { + readonly format: ImageFormat; + readonly reason: string; +} + export interface ImageCatalog { - v: 3; - storage?: "textures" | "containers"; - textures: ImageTexture[]; + readonly v: 4; + readonly sourceKey: string; + readonly storage: "containers"; + readonly textures: readonly ImageTexture[]; + readonly sources: readonly ImageSourceIdentity[]; + readonly issues: readonly ImageCatalogIssue[]; } export interface ImageEntry { @@ -99,7 +126,10 @@ export function imageExportPath(entry: ImageEntry, extension: "png" | "tm2"): st : source; const original = entry.texture.fileName.replace(/\.tm2$/i, ""); const suffix = entry.texture.pictures.length > 1 ? `_${entry.pictureIndex}` : ""; - return `${directory}/${original}${suffix}.${extension}`.replace(/^\/+/, ""); + const fileName = `${original}${suffix}.${extension}`; + const path = directory ? `${directory}/${fileName}` : fileName; + assertZipEntryPath(path); + return path; } export async function imagePng(data: Uint8Array, pictureIndex: number): Promise { @@ -122,120 +152,368 @@ export async function imagePng(data: Uint8Array, pictureIndex: number): Promise< return new Uint8Array(await blob.arrayBuffer()); } +function isImageRoleEvidence(value: unknown): value is ImageRoleEvidence { + return value === "ui-reference" + || value === "model-reference" + || value === "ui-global-reference" + || value === "model-global-reference" + || value === "ui-package" + || value === "model-package" + || value === "ui-name-prefix" + || value === "direct" + || value === "unclassified"; +} + +function isSafeInteger(value: unknown, minimum = 0): value is number { + return typeof value === "number" && Number.isSafeInteger(value) + && value >= minimum; +} + +interface ImageTextureLocator { + readonly containerId: string; + readonly entryOffset: number; + readonly memberIndex: number | null; +} + +const IMAGE_TEXTURE_ID = /^([0-9a-f]{8})-(direct|0|[1-9][0-9]*)$/i; + +function imageTextureLocator(id: unknown): ImageTextureLocator | null { + if (typeof id !== "string") return null; + const match = IMAGE_TEXTURE_ID.exec(id); + if (!match) return null; + const entryOffset = Number.parseInt(match[1]!, 16); + const memberIndex = match[2]!.toLowerCase() === "direct" + ? null + : Number(match[2]); + if (!Number.isSafeInteger(entryOffset) + || (memberIndex !== null && !Number.isSafeInteger(memberIndex))) + return null; + return { + containerId: entryOffset.toString(16).padStart(8, "0"), + entryOffset, + memberIndex, + }; +} + +function isImageFormat(value: unknown): value is ImageFormat { + return value === "TIM2" || value === "PCK" || value === "SZ"; +} + +function isImageCatalogIssue(value: unknown): value is ImageCatalogIssue { + if (value === null || typeof value !== "object") return false; + const issue = value as Record; + return isImageFormat(issue.format) + && typeof issue.reason === "string" + && issue.reason.length > 0 + && issue.reason.length <= 320 + && technicalReason(issue.reason) === issue.reason; +} + +function sourceIdentityKey(entryOffset: number, sourcePath: string): string { + return `${entryOffset}\0${sourcePath}`; +} + +function isImageCatalog(value: unknown, expectedSourceKey: string): value is ImageCatalog { + if (value === null || typeof value !== "object") return false; + const catalog = value as Record; + if (catalog.v !== 4 + || catalog.sourceKey !== expectedSourceKey + || catalog.storage !== "containers" + || !Array.isArray(catalog.textures) + || !Array.isArray(catalog.sources) + || !Array.isArray(catalog.issues) + || catalog.issues.length > 4096 + || catalog.issues.some(issue => !isImageCatalogIssue(issue))) + return false; + + const sources = new Map(); + for (const value of catalog.sources) { + if (value === null || typeof value !== "object") return false; + const candidate = value as Record; + if (!isContentFingerprint(value) + || !isSafeInteger(candidate.entryOffset) + || typeof candidate.sourcePath !== "string" + || candidate.sourcePath.length === 0) + return false; + const source = value as ImageSourceIdentity; + const key = sourceIdentityKey(source.entryOffset, source.sourcePath); + if (sources.has(key)) return false; + sources.set(key, source); + } + + const ids = new Set(); + const usedSources = new Set(); + for (const value of catalog.textures) { + if (value === null || typeof value !== "object") return false; + const texture = value as Record; + const locator = imageTextureLocator(texture.id); + if (!locator + || ids.has(texture.id as string) + || locator.memberIndex !== texture.memberIndex + || typeof texture.sourcePath !== "string" + || texture.sourcePath.length === 0 + || !sources.has(sourceIdentityKey( + locator.entryOffset, + texture.sourcePath, + )) + || typeof texture.fileName !== "string" + || texture.fileName.length === 0 + || typeof texture.attrs !== "string" + || !isSafeInteger(texture.byteLength, 1) + || (texture.role !== "sprite" + && texture.role !== "texture" + && texture.role !== "other") + || !isImageRoleEvidence(texture.roleEvidence) + || !Array.isArray(texture.pictures) + || texture.pictures.length === 0) + return false; + const memberPair = + (texture.memberIndex === null && texture.memberName === null) + || (isSafeInteger(texture.memberIndex) + && typeof texture.memberName === "string" + && texture.memberName.length > 0); + if (!memberPair || texture.pictures.some((value, index) => { + if (value === null || typeof value !== "object") return true; + const picture = value as Record; + return picture.index !== index + || !isSafeInteger(picture.width, 1) + || !isSafeInteger(picture.height, 1) + || !isSafeInteger(picture.imageType) + || !isSafeInteger(picture.clutType) + || !isSafeInteger(picture.colorCount) + || !isSafeInteger(picture.mipmapCount, 1); + })) + return false; + ids.add(texture.id as string); + usedSources.add(sourceIdentityKey(locator.entryOffset, texture.sourcePath)); + } + return usedSources.size === sources.size; +} + export class ImageStore { private cache: OpfsCache | null; private vfi: Vfi | null; - private readonly cacheKey: string | null; + private readonly cacheKey: string; private catalog: ImageCatalog | null = null; private containerCache = new Map>(); + private cacheWarning: string | null = null; constructor(cache: OpfsCache | null, vfi: Vfi | null, - cacheKey: string | null) { + cacheKey: string) { + assertCacheSourceIdentity(cache, cacheKey); this.cache = cache; this.vfi = vfi; this.cacheKey = cacheKey; } hasIso(): boolean { return this.vfi !== null; } + get persistenceWarning(): string | null { return this.cacheWarning; } async cached(): Promise { if (this.catalog) return this.catalog; if (!this.cache) return null; - const raw = await this.cache.read(META); + let raw: Uint8Array | null; + try { + raw = await this.cache.read(META); + } catch (error) { + this.useIsoAfterCacheFailure( + error, + "the image cache is unavailable -- reconnect the same disc to rebuild it or forget the disc cache", + ); + return null; + } if (!raw) return null; + let parsed: unknown; try { - const catalog = JSON.parse(new TextDecoder().decode(raw)) as ImageCatalog; - if (catalog.v !== 3 || !Array.isArray(catalog.textures) - || (catalog.storage !== undefined - && catalog.storage !== "textures" - && catalog.storage !== "containers") - || catalog.textures.some(texture => - !Array.isArray(texture.pictures) - || !["sprite", "texture", "other"].includes(texture.role))) - return null; - this.catalog = catalog; - return catalog; - } catch { + parsed = JSON.parse(new TextDecoder().decode(raw)); + } catch (error) { + this.useIsoAfterCacheFailure( + error, + "the cached image index is damaged -- reconnect the same disc to rebuild it or forget the disc cache", + ); return null; } + if (!isImageCatalog(parsed, this.cacheKey)) { + this.useIsoAfterCacheFailure( + new Error("the cached image index has an invalid schema"), + "the cached image index is damaged -- reconnect the same disc to rebuild it or forget the disc cache", + ); + return null; + } + this.catalog = parsed; + return parsed; } async attachIso(file: File): Promise { const disc = await openDisc(new BlobSource(file)); - if (this.cacheKey && disc.cacheKey !== this.cacheKey) - throw new Error("this ISO is a different disc than the cached one " - + "-- forget the disc first to switch"); + assertAttachedDiscIdentity(this.cacheKey, disc.cacheKey); this.vfi = disc.vfi; + this.containerCache.clear(); + } + + private useIsoAfterCacheFailure(error: unknown, message: string): void { + if (!this.vfi) + throw new Error(message, { cause: error }); + this.cacheWarning = `${message} (${technicalReason(error)}); ` + + "using the attached ISO for this session"; + console.warn(`${message}; using the attached ISO`, error); + this.cache = null; + this.containerCache.clear(); } async extract(progress: ImageProgress): Promise { if (!this.vfi) throw new Error("no ISO attached -- choose your disc image first"); let cache = this.cache; - const textures = await scanImageTextures(this.vfi, { + const sources: ImageSourceIdentity[] = []; + const scan = await scanImageTextures(this.vfi, { progress, container: async (entry, bytes) => { + const identity = await fingerprintBytes(bytes); + sources.push({ + entryOffset: entry.entryOff, + sourcePath: entry.path, + ...identity, + }); if (!cache) return; const id = entry.entryOff.toString(16).padStart(8, "0"); - const path = `image-container/${id}.bin`; try { - if (!await cache.has(path)) await cache.write(path, bytes); + await cache.write(`image-container/${id}.bin`, bytes); } catch (error) { - console.warn("OPFS failed; images remain available from the ISO", error); + this.useIsoAfterCacheFailure( + error, + "cached image storage is unavailable", + ); cache = null; } }, }); - const catalog: ImageCatalog = { v: 3, storage: "containers", textures }; - if (cache) - await cache.write(META, - new TextEncoder().encode(JSON.stringify(catalog))); + sources.sort((left, right) => left.entryOffset - right.entryOffset); + const issues = scan.issues.map(issue => ({ + format: issue.format, + reason: technicalReason(issue.reason), + })); + const catalog: ImageCatalog = { + v: 4, + sourceKey: this.cacheKey, + storage: "containers", + textures: scan.textures, + sources, + issues, + }; + if (cache) { + try { + await cache.write(META, + new TextEncoder().encode(JSON.stringify(catalog))); + } catch (error) { + this.useIsoAfterCacheFailure( + error, + "cached image storage is unavailable", + ); + } + } + this.containerCache.clear(); this.catalog = catalog; return catalog; } - private async readContainer(texture: ImageTexture): Promise { - if (!this.cache) return null; - const id = texture.id.slice(0, 8); + private sourceIdentity(texture: ImageTexture, + locator: ImageTextureLocator, + catalog: ImageCatalog): ImageSourceIdentity { + const source = catalog.sources.find(candidate => + candidate.entryOffset === locator.entryOffset + && candidate.sourcePath === texture.sourcePath); + if (!source) + throw new Error("the image source identity is missing from the catalog"); + return source; + } + + private async loadContainer(texture: ImageTexture, + locator: ImageTextureLocator, + expected: ImageSourceIdentity): Promise { + let stored: Uint8Array | null; + if (this.vfi) { + const entry = this.vfi.entries.find(candidate => + candidate.entryOff === locator.entryOffset + && candidate.path === expected.sourcePath); + if (!entry) + throw new Error("the image source is missing from the attached disc"); + stored = await this.vfi.read(entry); + } else { + if (!this.cache) return null; + try { + stored = await this.cache.read( + `image-container/${locator.containerId}.bin`, + ); + } catch (error) { + this.useIsoAfterCacheFailure( + error, + "the image cache is unavailable -- reconnect the same disc or forget the disc cache", + ); + return null; + } + } + if (!stored) return null; + const actual = await fingerprintBytes(stored); + if (!contentFingerprintMatches(actual, expected)) { + this.catalog = null; + this.containerCache.clear(); + throw new Error( + "the image source content does not match this catalog -- " + + "reconnect the same disc or forget the disc cache", + ); + } + return /\.sz$/i.test(texture.sourcePath) + ? inflateSz(stored, "image source") + : stored; + } + + private async readContainer(texture: ImageTexture, + locator: ImageTextureLocator, + expected: ImageSourceIdentity): Promise { + const id = `${locator.containerId}:${expected.sha256}`; let pending = this.containerCache.get(id); if (pending) { this.containerCache.delete(id); this.containerCache.set(id, pending); } else { - pending = this.cache.read(`image-container/${id}.bin`).then(stored => { - if (!stored) return null; - return /\.sz$/i.test(texture.sourcePath) ? inflateSz(stored) : stored; - }); + pending = this.loadContainer(texture, locator, expected); this.containerCache.set(id, pending); if (this.containerCache.size > CONTAINER_CACHE_LIMIT) { const oldest = this.containerCache.keys().next().value as string | undefined; if (oldest !== undefined) this.containerCache.delete(oldest); } } - const container = await pending; - if (!container) { + try { + const container = await pending; + if (!container) this.containerCache.delete(id); + return container; + } catch (error) { this.containerCache.delete(id); - return null; + throw error; } - if (texture.memberIndex === null) return container; - const members = unpackPck(container); - const member = members?.[texture.memberIndex]; - if (!member || member.name !== texture.memberName) - throw new Error(`${texture.sourcePath}: cached image member changed`); - return memberBytes(container, member); } async read(texture: ImageTexture): Promise { - if (this.cache) { - const catalog = this.catalog ?? await this.cached(); - if (catalog?.storage === "containers") { - const cached = await this.readContainer(texture); - if (cached) return cached; - } else { - const cached = await this.cache.read(`image/${texture.id}.tm2`); - if (cached) return cached; - } + const locator = imageTextureLocator(texture.id); + if (!locator || locator.memberIndex !== texture.memberIndex) + throw new Error("the image texture identifier is invalid"); + const catalog = this.catalog ?? await this.cached(); + if (!catalog) return null; + const expected = this.sourceIdentity(texture, locator, catalog); + const container = await this.readContainer(texture, locator, expected); + if (!container) return null; + + if (locator.memberIndex === null) { + inspectTim2(container, "image source"); + return container; } - return this.vfi ? readImageTexture(this.vfi, texture) : null; + const members = unpackPck(container, "image source"); + const member = members?.[locator.memberIndex]; + if (!member || member.name !== texture.memberName) + throw new Error("the image member does not match this catalog"); + const bytes = memberBytes(container, member); + inspectTim2(bytes, "image member"); + return bytes; } } diff --git a/src/main.ts b/src/main.ts index 7c11579..87fc172 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,8 +3,18 @@ * harness/bgmplay.c keys, defaults, layout). */ import "./style.css"; -import { resumeSession, openIso, friendlyError, US_SERIAL, - type DiscSession } from "./disc.ts"; +import { + discSupportWarning, + friendlyError, + openIso, + resumeSession, + type DiscSession, +} from "./disc.ts"; +import { + DiscOpenCoordinator, + SerializedDiscCleanup, + type DiscOpenTicket, +} from "./disc-open.ts"; import { WorkletPlayer, RATE, type EngineConfig, type Snapshot } from "./player.ts"; import { buildTimeline, displayPos, bpmOf, midiDurationSamples, type Timeline } from "./timeline.ts"; import { Viz } from "./viz.ts"; @@ -108,12 +118,15 @@ const cfg = { songvol: 44, revDepth: 30, exact: true, gaussian: true, loop: true cueOn: false, cueScale: 44.5 / 127, duckDemo: false, duckPhone: false }; const exporter = new Exporter(); +const discOpens = new DiscOpenCoordinator(); +const discCleanup = new SerializedDiscCleanup(); let audioMode: "bgm" | "se" | null = null; let viewerRevision = 0; const VIEWER_LEVEL_COUNT = 32; const viewerLevelHistory = new Float32Array(VIEWER_LEVEL_COUNT); let viewerLevelHead = 0; let bgmDurations = new Float64Array(); +let bgmDurationWarning: string | null = null; function ingestViewerLevels(snapshot: Snapshot): void { for (let offset = 0; offset + 5 <= snapshot.wcols.length; offset += 5) { @@ -129,8 +142,14 @@ function ingestViewerLevels(snapshot: Snapshot): void { } /* streams tab */ -let tab: "bgm" | "streams" | "se" | "fmv" | "images" | "stage-previews" | "models" = "bgm"; +type AppTab = "bgm" | "streams" | "se" | "fmv" | "images" + | "stage-previews" | "models"; +let tab: AppTab = "bgm"; let sCatalog: StreamCatalog | null = null; +let streamCatalogRequest: { + target: DiscSession; + promise: Promise; +} | null = null; /* The sidebar is a fold tree: MUSIC/VOICE sections -> prefix groups -> * entries. Selection walks every visible row (headers included), so the * keyboard can jump categories; groups start folded. */ @@ -158,6 +177,10 @@ let fmvVisibleNames: string[] = []; let fmvRenderedSearch = ""; let fmvRenderedEntryName: string | null = null; let imageCatalog: ImageCatalog | null = null; +let imageCatalogRequest: { + target: DiscSession; + promise: Promise; +} | null = null; let imageAllRows: ImageEntry[] = []; let imageEntryById = new Map(); let imageRows: ImageEntry[] = []; @@ -196,9 +219,13 @@ function ensureStagePreviewBrowser(): StagePreviewBrowser { function ensureModelBrowser(): Promise { if (modelBrowser) return Promise.resolve(modelBrowser); modelBrowserLoading ??= import("./model-browser.ts").then(({ ModelBrowser }) => { - modelBrowser = new ModelBrowser(status); + modelBrowser = new ModelBrowser((message, error) => + statusWithWarnings(session, "models", message, error)); if (session) modelBrowser.setStore(session.models); return modelBrowser; + }).catch(error => { + modelBrowserLoading = null; + throw error; }); return modelBrowserLoading; } @@ -209,6 +236,10 @@ type SeRow = | { kind: "bank"; entry: SeBankEntry } | { kind: "cue"; entry: SeBankEntry; bank: number; request: number }; let seCatalog: SeCatalog | null = null; +let seCatalogRequest: { + target: DiscSession; + promise: Promise; +} | null = null; let seRows: SeRow[] = []; let seSel = 0; let seBusy = false; @@ -222,6 +253,7 @@ let sePlayingInfo: SeRequestInfo | null = null; let seGlyph = ""; let seKnownFrames: number | null = null; let seKnownEstimated = false; +let seMeasureError: string | null = null; let seMeasuring = false; let seMeasureToken = 0; let sePastEvents = false; @@ -283,6 +315,8 @@ function renderList(): void { async function loadBgmDurations(target: DiscSession): Promise { const durations = bgmDurations; + let failures = 0; + let firstFailure: unknown; for (let index = 0; index < target.songs.length; index++) { const song = target.songs[index]!; try { @@ -290,10 +324,18 @@ async function loadBgmDurations(target: DiscSession): Promise { if (!midi) throw new Error(`missing bgm/${song.mid}`); durations[index] = midiDurationSamples(midi); } catch (error) { + if (failures === 0) firstFailure = error; + failures++; console.warn(`Could not calculate duration for ${song.name}`, error); } } - if (session === target && bgmDurations === durations) renderList(); + if (session !== target || bgmDurations !== durations) return; + bgmDurationWarning = failures > 0 + ? `${failures} song duration${failures === 1 ? "" : "s"} unavailable: ` + + friendlyError(firstFailure) + : null; + renderList(); + if (tab === "bgm") statusWithWarnings(target, "bgm"); } function scrollSelIntoView(): void { @@ -326,42 +368,60 @@ function renderDials(): void { * later resume()) is user-initiated under stricter Auto-Play policies. * Chrome/Firefox are indifferent to the timing. */ let playerReady: Promise | null = null; +let playerGeneration = 0; function ensurePlayer(): Promise { - playerReady ??= WorkletPlayer.create().then((p) => { - player = p; - p.onended = () => { finished = true; }; - p.onerror = (m) => status(m, true); - p.onsnapshot = (s) => { - if (audioMode === "se") seViz?.ingest(s); - else viz?.ingest(s); - ingestViewerLevels(s); + if (playerReady) return playerReady; + const generation = playerGeneration; + let pending: Promise; + pending = WorkletPlayer.create().then(async (next) => { + if (generation !== playerGeneration) { + try { + await next.ctx.close(); + } catch (error) { + console.error("superseded audio context close failed", error); + } + throw new DOMException("audio setup superseded", "AbortError"); + } + player = next; + next.onended = () => { finished = true; }; + next.onerror = (message) => status(message, true); + next.onsnapshot = (snapshot) => { + if (audioMode === "se") seViz?.ingest(snapshot); + else viz?.ingest(snapshot); + ingestViewerLevels(snapshot); }; - return p; - }, (e) => { - playerReady = null; /* allow a retry on the next click */ + return next; + }).catch((error) => { + if (playerReady === pending) playerReady = null; + if (generation !== playerGeneration) throw error; $("song-name").textContent = "audio setup failed"; - throw new Error(`audio setup failed: ` - + `${e instanceof Error ? e.message : e}` - + " -- an ad/content blocker may be blocking the synth"); + throw new Error( + `audio setup failed: ${friendlyError(error)}` + + " -- an ad/content blocker may be blocking the synth", + { cause: error }, + ); }); - return playerReady; + playerReady = pending; + return pending; } /* Only call from a user-gesture handler: the context is created and resumed * inside that gesture, and the load round-trip cannot complete while it is * suspended. */ async function loadSong(idx: number, autoplay: boolean): Promise { - if (!session || loading) return; - const song = session.songs[idx]; + const target = session; + if (!target || loading) return; + const song = target.songs[idx]; if (!song) return; let p: WorkletPlayer; try { p = await ensurePlayer(); /* sync up to the first await */ - } catch (e) { - status(e instanceof Error ? e.message : String(e), true); + } catch (error) { + if (session === target) status(friendlyError(error), true); return; } + if (session !== target) return; p.resume(); sPlayer.pause(); /* one thing plays at a time */ loading = true; @@ -369,12 +429,14 @@ async function loadSong(idx: number, autoplay: boolean): Promise { finished = false; status(`loading ${song.name}...`); try { - const assets = await session.songAssets(song); + const assets = await target.songAssets(song); + if (session !== target) return; if (authored) cfg.songvol = song.songvol; cfg.cueScale = song.volumeScale > 0 ? song.volumeScale : 44.5 / 127; - const r = await p.load(assets, engineConfig()); + const result = await p.load(assets, engineConfig()); + if (session !== target) return; audioMode = "bgm"; - timeline = buildTimeline(r.events, r.ppqn); + timeline = buildTimeline(result.events, result.ppqn); bgmDurations[idx] = timeline.lenSamp; playingIdx = idx; p.snap = null; @@ -383,15 +445,22 @@ async function loadSong(idx: number, autoplay: boolean): Promise { viewerLevelHead = 0; $("song-name").textContent = song.name; $("time-len").textContent = fmtTime(timeline.lenSamp); - status(r.warning ? `warning: ${r.warning}` : "", !!r.warning); + statusWithWarnings( + target, + "bgm", + result.warning ? `warning: ${result.warning}` : "", + !!result.warning, + ); if (autoplay) p.play(); $("playbtn").disabled = false; - } catch (e) { - status(friendlyError(e), true); + } catch (error) { + if (session === target) status(friendlyError(error), true); } finally { - loading = false; - renderList(); - renderDials(); + if (session === target) { + loading = false; + renderList(); + renderDials(); + } } } @@ -526,6 +595,7 @@ function renderFmvList(snapshot: MovieControllerSnapshot): void { const query = $("fmv-search").value.trim().toLocaleLowerCase(); fmvRenderedSearch = query; const entries = snapshot.catalog?.entries ?? []; + const issues = snapshot.catalog?.issues ?? []; const groups = ["opening", "story", "gameplay"] as const; const groupedEntries = groups.map(group => entries.filter(entry => { if (entry.group !== group) return false; @@ -534,6 +604,10 @@ function renderFmvList(snapshot: MovieControllerSnapshot): void { || entry.name.toLocaleLowerCase().includes(query) || entry.movie.path.toLocaleLowerCase().includes(query); })); + const visibleIssues = issues.filter(issue => !query + || issue.label.toLocaleLowerCase().includes(query) + || issue.name.toLocaleLowerCase().includes(query) + || issue.reason.toLocaleLowerCase().includes(query)); const visible = groupedEntries.flat(); fmvVisibleNames = visible.map(entry => entry.name); if (!snapshot.catalog) { @@ -542,11 +616,11 @@ function renderFmvList(snapshot: MovieControllerSnapshot): void { : "Loading movie catalogโ€ฆ"}`; return; } - if (!visible.length) { + if (!visible.length && !visibleIssues.length) { list.innerHTML = `
  • No matching movies
  • `; return; } - list.innerHTML = groupedEntries.map(groupEntries => { + const availableMarkup = groupedEntries.map(groupEntries => { if (!groupEntries.length) return ""; const heading = movieGroupLabel(groupEntries[0]!); const rows = groupEntries.map(entry => { @@ -565,6 +639,14 @@ function renderFmvList(snapshot: MovieControllerSnapshot): void { }).join(""); return `
  • ${escapeMarkup(heading)}

  • ${rows}`; }).join(""); + const issueMarkup = visibleIssues.length + ? `
  • Unavailable on this disc

  • ` + + visibleIssues.map(issue => + `
  • ${escapeMarkup(issue.label)} + ${escapeMarkup(issue.name)}
    + ${escapeMarkup(issue.reason)}
  • `).join("") + : ""; + list.innerHTML = availableMarkup + issueMarkup; list.querySelector('[aria-current="true"]') ?.scrollIntoView({ block: "nearest" }); } @@ -596,9 +678,9 @@ function renderFmvInspector(snapshot: MovieControllerSnapshot): void { } const cacheValue = (value: number | undefined): string => value === undefined ? "โ€”" : movieBytesDetail(value); - const complete = snapshot.cache - ? movieSourceComplete(entry, snapshot.cache) ? "Yes" : "No" - : "โ€”"; + let complete = "โ€”"; + if (snapshot.cache) + complete = movieSourceComplete(entry, snapshot.cache) ? "Yes" : "No"; const order = entry.video.fieldOrder; const subtitles = entry.subtitleCues ? `${entry.subtitleCues} cues ยท final cue ${fmtSec(entry.subtitleEnd ?? 0)}` @@ -700,7 +782,8 @@ function moveFmv(delta: number, mode: "preview" | "play"): void { if (!fmvVisibleNames.length) return; const current = movieController.snapshot().entry?.name; const selected = fmvVisibleNames.indexOf(current ?? ""); - const origin = selected < 0 ? (delta > 0 ? -1 : 0) : selected; + let origin = selected; + if (selected < 0) origin = delta > 0 ? -1 : 0; const index = (origin + delta + fmvVisibleNames.length) % fmvVisibleNames.length; movieController.select(fmvVisibleNames[index]!, mode); } @@ -1087,16 +1170,19 @@ function renderImageInspector(entry: ImageEntry): void { } async function selectImage(entry: ImageEntry): Promise { - if (!session) return; + const target = session; + if (!target) return; imageSelected = entry; markImageSelection(entry.id); const generation = ++imageLoadGeneration; status(`DECODING ${entry.texture.fileName}...`); try { - const data = await session.images.read(entry.texture); + const data = await target.images.read(entry.texture); if (!data) throw new Error("source texture is not cached; reconnect the disc"); const image = decodeTim2(data, entry.pictureIndex, entry.texture.fileName); - if (generation !== imageLoadGeneration || imageSelected?.id !== entry.id) return; + if (session !== target || generation !== imageLoadGeneration + || imageSelected?.id !== entry.id) + return; const canvas = $("image-canvas"); canvas.classList.toggle("small-image", image.width <= 256 && image.height <= 256); @@ -1111,10 +1197,14 @@ async function selectImage(entry: ImageEntry): Promise { ); $("image-empty").hidden = true; renderImageInspector(entry); - status(`${entry.texture.fileName} ยท ${image.width}ร—${image.height} ยท ` - + `${IMAGE_TYPE_LABELS[image.imageType] ?? `type ${image.imageType}`}`); + statusWithWarnings( + target, + "images", + `${entry.texture.fileName} ยท ${image.width}ร—${image.height} ยท ` + + `${IMAGE_TYPE_LABELS[image.imageType] ?? `type ${image.imageType}`}`, + ); } catch (error) { - if (generation !== imageLoadGeneration) return; + if (session !== target || generation !== imageLoadGeneration) return; status(`IMAGE FAILED: ${friendlyError(error)}`, true); } } @@ -1147,25 +1237,43 @@ function installImageCatalog(catalog: ImageCatalog): void { updateImageRoleOptions(); } -async function ensureImageCatalog(): Promise { - if (!session || imageCatalog) return; - const cached = await session.images.cached(); +function ensureImageCatalog(): Promise { + const target = session; + if (!target || imageCatalog) return Promise.resolve(); + if (imageCatalogRequest?.target === target) + return imageCatalogRequest.promise; + const promise = loadImageCatalog(target); + imageCatalogRequest = { target, promise }; + const clear = (): void => { + if (imageCatalogRequest?.promise === promise) + imageCatalogRequest = null; + }; + void promise.then(clear, clear); + return promise; +} + +async function loadImageCatalog(target: DiscSession): Promise { + const cached = await target.images.cached(); + if (session !== target) return; if (cached) { installImageCatalog(cached); $("image-setup").hidden = true; renderImageList(); - status(`${imageAllRows.length} images and sprite sheets`); + if (tab === "images") + statusWithWarnings(target, "images", + `${imageAllRows.length} images and sprite sheets`); return; } $("image-setup").hidden = false; - const hasIso = session.images.hasIso(); + const hasIso = target.images.hasIso(); $("image-extract").hidden = true; $("image-iso-pick").hidden = hasIso; if (hasIso) await extractImages(); } async function extractImages(): Promise { - if (!session || imageExtracting) return; + const target = session; + if (!target || imageExtracting) return; const setupStatus = $("image-setup-status"); const progress = $("image-progress"); const button = $("image-extract"); @@ -1176,51 +1284,65 @@ async function extractImages(): Promise { button.hidden = true; imageExtracting = true; try { - const catalog = await session.images.extract((done, total, path) => { + const catalog = await target.images.extract((done, total, path) => { + if (session !== target) return; progress.value = total > 0 ? done / total : 0; setupStatus.textContent = path === "done" ? "catalog complete" : `scanning ${path} (${done}/${total})`; }); + if (session !== target) return; installImageCatalog(catalog); $("image-setup").hidden = true; renderImageList(); - status(`${imageAllRows.length} images and sprite sheets ยท select one to preview`); + if (tab === "images") + statusWithWarnings(target, "images", + `${imageAllRows.length} images and sprite sheets ยท select one to preview`); } catch (error) { + if (session !== target) return; setupStatus.textContent = friendlyError(error); setupStatus.classList.add("err"); progress.hidden = true; button.hidden = false; } finally { - button.disabled = false; - imageExtracting = false; + if (session === target) { + button.disabled = false; + imageExtracting = false; + } } } async function exportSelectedImage(kind: "png" | "tm2"): Promise { - if (!session || !imageSelected || imageExporting) return; + const target = session; + if (!target || !imageSelected || imageExporting) return; const entry = imageSelected; imageExporting = true; updateImageActions(); try { - const source = await session.images.read(entry.texture); + const source = await target.images.read(entry.texture); + if (session !== target) return; if (!source) throw new Error("source texture is not cached; reconnect the disc"); const path = imageExportPath(entry, kind); const data = kind === "png" ? await imagePng(source, entry.pictureIndex) : source; + if (session !== target) return; download(data, path.slice(path.lastIndexOf("/") + 1), kind === "png" ? "image/png" : "application/octet-stream"); - status(`EXPORTED ${path}`); + statusWithWarnings(target, "images", `EXPORTED ${path}`); } catch (error) { - status(`EXPORT FAILED: ${friendlyError(error)}`, true); + if (session === target) + status(`EXPORT FAILED: ${friendlyError(error)}`, true); } finally { - imageExporting = false; - updateImageActions(); + if (session === target) { + imageExporting = false; + updateImageActions(); + } } } async function exportAllImages(): Promise { - if (!session || !imageCatalog || imageExporting) return; + const target = session; + if (!target || !imageCatalog || imageExporting) return; const entries = imageAllRows; imageExporting = true; updateImageActions(); @@ -1232,26 +1354,85 @@ async function exportAllImages(): Promise { const entry = entries[index]!; if (entry.texture.id !== textureId) { textureId = entry.texture.id; - source = await session.images.read(entry.texture); + source = await target.images.read(entry.texture); + if (session !== target) return; } if (!source) throw new Error(`${entry.texture.fileName} is not cached`); status(`ENCODING PNG ${index + 1}/${entries.length} ยท ${entry.texture.fileName}`); - files.push([imageExportPath(entry, "png"), - await imagePng(source, entry.pictureIndex)]); + const png = await imagePng(source, entry.pictureIndex); + if (session !== target) return; + files.push([imageExportPath(entry, "png"), png]); } + if (session !== target) return; download(storeZipBlob(files), "ape-escape-3-images.zip", "application/zip"); - status(`EXPORTED ape-escape-3-images.zip ยท ${files.length} PNG files`); + statusWithWarnings(target, "images", + `EXPORTED ape-escape-3-images.zip ยท ${files.length} PNG files`); } catch (error) { - status(`EXPORT FAILED: ${friendlyError(error)}`, true); + if (session === target) + status(`EXPORT FAILED: ${friendlyError(error)}`, true); } finally { - imageExporting = false; - updateImageActions(); + if (session === target) { + imageExporting = false; + updateImageActions(); + } } } -function switchTab(t: "bgm" | "streams" | "se" | "fmv" | "images" | "stage-previews" | "models"): void { +function imageCatalogIssueWarning(catalog: ImageCatalog | null): string { + if (!catalog?.issues.length) return ""; + const shown = catalog.issues.slice(0, 2) + .map(issue => `${issue.format}: ${issue.reason}`); + const remainder = catalog.issues.length - shown.length; + return `${catalog.issues.length.toLocaleString()} malformed image ` + + `container${catalog.issues.length === 1 ? "" : "s"} skipped` + + ` (${shown.join("; ")}${remainder > 0 ? `; +${remainder} more` : ""})`; +} + +function persistenceWarnings(target: DiscSession, targetTab: AppTab): string { + const warnings: string[] = []; + const support = discSupportWarning(target.serial).replace(/^ - /, ""); + if (support) warnings.push(support); + if (targetTab === "bgm" && bgmDurationWarning) + warnings.push(bgmDurationWarning); + if (targetTab === "streams" && target.streams.persistenceWarning) + warnings.push(target.streams.persistenceWarning); + if (targetTab === "images" && target.images.persistenceWarning) + warnings.push(target.images.persistenceWarning); + if (targetTab === "images") { + const issueWarning = imageCatalogIssueWarning(imageCatalog); + if (issueWarning) warnings.push(issueWarning); + } + if (targetTab === "se" && target.se.persistenceWarning) + warnings.push(target.se.persistenceWarning); + if (targetTab === "fmv" && target.movies.persistenceWarning) + warnings.push(target.movies.persistenceWarning); + if (target.persistenceWarning) + warnings.push(target.persistenceWarning); + return warnings.join(" ยท "); +} + +function statusWithWarnings(target: DiscSession | null, targetTab: AppTab, + message = "", messageIsError = false): void { + if (!target || session !== target || tab !== targetTab) return; + const warning = persistenceWarnings(target, targetTab); + status([message, warning].filter(Boolean).join(" ยท "), + messageIsError || warning.length > 0); +} + +function showLazyCatalogError(origin: AppTab, target: DiscSession | null, + statusId: string, error: unknown): void { + if (session !== target) return; + const message = friendlyError(error); + const setupStatus = $(statusId); + setupStatus.textContent = message; + setupStatus.classList.add("err"); + if (tab === origin) status(message, true); +} + +function switchTab(t: AppTab): void { if (t !== "se") seSourceLoopViz?.clear(); tab = t; + const target = session; $("tab-bgm").classList.toggle("on", t === "bgm"); $("tab-streams").classList.toggle("on", t === "streams"); $("tab-se").classList.toggle("on", t === "se"); @@ -1290,40 +1471,70 @@ function switchTab(t: "bgm" | "streams" | "se" | "fmv" | "images" | "stage-previ : t === "stage-previews" ? KEYS_STAGE_PREVIEWS : KEYS_MODELS; - status(""); + statusWithWarnings(target, t); viewerRevision++; movieController.setActive(t === "fmv" || viewerChannel === "cinema"); - if (t === "streams") void ensureStreams(); - if (t === "se") void ensureSeCatalog(); + if (t === "streams") + void ensureStreams().catch(error => + showLazyCatalogError(t, target, "s-setup-status", error)); + if (t === "se") + void ensureSeCatalog().catch(error => + showLazyCatalogError(t, target, "se-setup-status", error)); if (t === "fmv") renderFmvStatic(movieController.snapshot()); - if (t === "images") void ensureImageCatalog(); + if (t === "images") + void ensureImageCatalog().catch(error => + showLazyCatalogError(t, target, "image-setup-status", error)); if (t === "stage-previews") ensureStagePreviewBrowser().open(); else stagePreviewBrowser?.close(); - if (t === "models") void ensureModelBrowser().then(browser => browser.open()); - else modelBrowser?.close(); + if (t === "models") { + void ensureModelBrowser().then(browser => { + if (session === target && tab === t) return browser.open(); + }).catch(error => + showLazyCatalogError(t, target, "model-setup-status", error)); + } else { + modelBrowser?.close(); + } } /* First entry to the tab: catalog from OPFS, or the setup panel. */ -async function ensureStreams(): Promise { - if (!session || sCatalog) return; - const c = await session.streams.cached(); - if (c) { - sCatalog = c; +function ensureStreams(): Promise { + const target = session; + if (!target || sCatalog) return Promise.resolve(); + if (streamCatalogRequest?.target === target) + return streamCatalogRequest.promise; + const promise = loadStreamCatalog(target); + streamCatalogRequest = { target, promise }; + const clear = (): void => { + if (streamCatalogRequest?.promise === promise) + streamCatalogRequest = null; + }; + void promise.then(clear, clear); + return promise; +} + +async function loadStreamCatalog(target: DiscSession): Promise { + const catalog = await target.streams.cached(); + if (session !== target) return; + if (catalog) { + sCatalog = catalog; $("s-setup").hidden = true; $("s-stage").hidden = false; renderStreamList(); - status(`${c.entries.length} streams - SPACE or click one to play`); + if (tab === "streams") + statusWithWarnings(target, "streams", + `${catalog.entries.length} streams - SPACE or click one to play`); return; } $("s-setup").hidden = false; - const iso = session.streams.hasIso(); + const hasIso = target.streams.hasIso(); $("s-extract").hidden = true; - $("s-iso-pick").hidden = iso; - if (iso) await extractStreams(); + $("s-iso-pick").hidden = hasIso; + if (hasIso) await extractStreams(); } async function extractStreams(): Promise { - if (!session || sExtracting) return; + const target = session; + if (!target || sExtracting) return; const pstat = $("s-setup-status"); const bar = $("s-progress"); const button = $("s-extract"); @@ -1334,22 +1545,30 @@ async function extractStreams(): Promise { button.hidden = true; sExtracting = true; try { - sCatalog = await session.streams.extract((done, total, name) => { + const catalog = await target.streams.extract((done, total, name) => { + if (session !== target) return; bar.value = total > 0 ? done / total : 0; pstat.textContent = `extracting ${name} (${done}/${total})`; }); + if (session !== target) return; + sCatalog = catalog; $("s-setup").hidden = true; $("s-stage").hidden = false; renderStreamList(); - status(`${sCatalog.entries.length} streams - SPACE or click one to play`); - } catch (e) { - pstat.textContent = friendlyError(e); + if (tab === "streams") + statusWithWarnings(target, "streams", + `${catalog.entries.length} streams - SPACE or click one to play`); + } catch (error) { + if (session !== target) return; + pstat.textContent = friendlyError(error); pstat.classList.add("err"); bar.hidden = true; button.hidden = false; } finally { - button.disabled = false; - sExtracting = false; + if (session === target) { + button.disabled = false; + sExtracting = false; + } } } @@ -1467,18 +1686,21 @@ function renderStreamInfo(): void { } async function loadStreamEntry(e: StreamEntry, autoplay: boolean): Promise { - if (!session || sLoading) return; + const target = session; + if (!target || sLoading) return; sLoading = true; status(`loading ${e.name}...`); try { - const bytes = await session.streams.read(e.name); + const bytes = await target.streams.read(e.name); + if (session !== target) return; if (!bytes) throw new Error(`missing stream ${e.name} -- re-extract, or ` + "re-open the ISO"); - const d = await sDecoder.decode(e.name, bytes); - sPlayer.load(d, sTrim); + const decoded = await sDecoder.decode(e.name, bytes); + if (session !== target) return; + sPlayer.load(decoded, sTrim); sPlayingName = e.name; - sViz?.set(d, sTrim); + sViz?.set(decoded, sTrim); $("s-name").textContent = e.name.replace(/\.x$/, ""); $("s-len").textContent = fmtSec(sPlayer.dur()); renderStreamInfo(); @@ -1486,16 +1708,18 @@ async function loadStreamEntry(e: StreamEntry, autoplay: boolean): Promise $("s-playbtn").disabled = false; $("s-exportbtn").disabled = false; $("s-rawbtn").disabled = false; - status(""); + statusWithWarnings(target, "streams"); if (autoplay) { player?.pause(); /* one thing plays at a time */ sPlayer.play(); } - } catch (err) { - status(friendlyError(err), true); + } catch (error) { + if (session === target) status(friendlyError(error), true); } finally { - sLoading = false; - renderStreamList(); + if (session === target) { + sLoading = false; + renderStreamList(); + } } } @@ -1547,45 +1771,55 @@ function sToggleTrim(): void { * anyone who wants the file format itself (the MIDI/bank-pair precedent: * straight bytes, no worker, no busy state). */ async function sExportRaw(): Promise { - if (!session) return; - const d = sPlayer.decoded(); - if (!d) return; + const target = session; + if (!target) return; + const decoded = sPlayer.decoded(); + if (!decoded) return; try { - const bytes = await session.streams.read(d.name); + const bytes = await target.streams.read(decoded.name); + if (session !== target) return; if (!bytes) - throw new Error(`missing stream ${d.name} -- re-extract, or ` + throw new Error(`missing stream ${decoded.name} -- re-extract, or ` + "re-open the ISO"); - download(bytes, d.name, "application/octet-stream"); - status(`EXPORTED ${d.name}`); - } catch (e) { - status(`EXPORT FAILED: ${e instanceof Error ? e.message : e}`, true); + download(bytes, decoded.name, "application/octet-stream"); + status(`EXPORTED ${decoded.name}`); + } catch (error) { + if (session === target) + status(`EXPORT FAILED: ${friendlyError(error)}`, true); } } async function sExport(): Promise { - if (!session || sBusy) return; - const d = sPlayer.decoded(); - if (!d) return; + const target = session; + if (!target || sBusy) return; + const decoded = sPlayer.decoded(); + if (!decoded) return; + const trim = sTrim; sBusy = true; - const stemName = d.name.replace(/\.x$/, ""); - const file = `${stemName}${sTrim ? "_trim" : ""}.wav`; + const stemName = decoded.name.replace(/\.x$/, ""); + const file = `${stemName}${trim ? "_trim" : ""}.wav`; const btn = $("s-exportbtn"); btn.disabled = true; btn.textContent = "EXPORTING"; status(`EXPORTING ${file}...`); try { - const bytes = await session.streams.read(d.name); + const bytes = await target.streams.read(decoded.name); + if (session !== target) return; if (!bytes) - throw new Error(`missing stream ${d.name}`); - const wav = await sDecoder.wav(d.name, bytes, sTrim); + throw new Error(`missing stream ${decoded.name}`); + const wav = await sDecoder.wav(decoded.name, bytes, trim); + if (session !== target) return; download(wav, file, "audio/wav"); status(`EXPORTED ${file}`); - } catch (e) { - status(`EXPORT FAILED: ${e instanceof Error ? e.message : e}`, true); + } catch (error) { + if (session === target) + status(`EXPORT FAILED: ${friendlyError(error)}`, true); } finally { - sBusy = false; - btn.disabled = !sPlayer.decoded(); - btn.textContent = "EXPORT WAV"; + if (session === target) { + sBusy = false; + btn.disabled = !sPlayer.decoded(); + btn.textContent = "EXPORT WAV"; + } } } @@ -1764,15 +1998,30 @@ function renderSeList(): void { viewerRevision++; } -async function ensureSeCatalog(): Promise { - if (!session) return false; - const meta = await session.se.cached(); +function ensureSeCatalog(): Promise { + const target = session; + if (!target) return Promise.resolve(false); + if (seCatalogRequest?.target === target) + return seCatalogRequest.promise; + const promise = loadSeCatalog(target); + seCatalogRequest = { target, promise }; + const clear = (): void => { + if (seCatalogRequest?.promise === promise) + seCatalogRequest = null; + }; + void promise.then(clear, clear); + return promise; +} + +async function loadSeCatalog(target: DiscSession): Promise { + const meta = await target.se.cached(); + if (session !== target) return false; if (!meta) { - const needsIso = !session.se.hasIso(); + const needsIso = !target.se.hasIso(); seSetup(true, needsIso); if (!needsIso) { await extractSeBanks(); - return seCatalog !== null; + return session === target && seCatalog !== null; } return false; } @@ -1782,7 +2031,8 @@ async function ensureSeCatalog(): Promise { return true; } async function extractSeBanks(): Promise { - if (!session || seExtracting) return; + const target = session; + if (!target || seExtracting) return; const btn = $("se-extract"); const st = $("se-setup-status"); const progress = $("se-progress"); @@ -1792,28 +2042,37 @@ async function extractSeBanks(): Promise { st.classList.remove("err"); seExtracting = true; try { - seCatalog = await session.se.extract((done, total, name) => { + const catalog = await target.se.extract((done, total, name) => { + if (session !== target) return; progress.max = total; progress.value = done; st.textContent = `extracting ${done}/${total}: ${name}`; }); - st.textContent = `${seCatalog.entries.length} SE banks ready`; + if (session !== target) return; + seCatalog = catalog; + st.textContent = `${catalog.entries.length} SE banks ready`; seSetup(false); renderSeList(); - status(`${seCatalog.entries.length} SE banks cached`); - } catch (e) { - st.textContent = friendlyError(e); + if (tab === "se") + statusWithWarnings(target, "se", + `${catalog.entries.length} SE banks cached`); + } catch (error) { + if (session !== target) return; + st.textContent = friendlyError(error); st.classList.add("err"); btn.hidden = false; } finally { - btn.disabled = false; - seExtracting = false; - progress.hidden = true; + if (session === target) { + btn.disabled = false; + progress.hidden = true; + seExtracting = false; + } } } async function seActivate(i: number, play: boolean): Promise { - if (!session || seLoading) return; + const target = session; + if (!target || seLoading) return; const row = seRows[i]; if (!row) return; if (row.kind === "cue") { @@ -1830,17 +2089,21 @@ async function seActivate(i: number, play: boolean): Promise { seLoading = true; status(`reading ${row.entry.name} sequence table...`); try { - const files = await session.se.bank(row.entry); + const files = await target.se.bank(row.entry); + if (session !== target) return; const inspection = await seInspector.inspect(files); + if (session !== target) return; seShape = inspection.requests; seDetails = inspection.details; seOpenBank = row.entry.name; - status(""); - } catch (e) { - status(friendlyError(e), true); + statusWithWarnings(target, "se"); + } catch (error) { + if (session === target) status(friendlyError(error), true); } finally { - seLoading = false; - renderSeList(); + if (session === target) { + seLoading = false; + renderSeList(); + } } } @@ -1856,12 +2119,11 @@ const seEngineConfig = (info = currentSeInfo()): EngineConfig => ({ duckPhone: false, }); -async function seSynthAssets(entry: SeBankEntry) { - if (!session) throw new Error("no disc session"); - const files = await session.se.bank(entry); +async function seSynthAssets(target: DiscSession, entry: SeBankEntry) { + const files = await target.se.bank(entry); const [irx, libsd] = await Promise.all([ - session.read("irx/sg2iopm1.irx"), - session.read("irx/libsd.irx"), + target.read("irx/sg2iopm1.irx"), + target.read("irx/libsd.irx"), ]); return { files, assets: { ...files, irx, libsd } }; } @@ -1875,12 +2137,21 @@ function cloneSeMeasureFiles(assets: SeMeasureFiles): SeMeasureFiles { }; } +function recordSeMeasurementFailure(error: unknown): void { + const reason = friendlyError(error); + seMeasureError = reason; + console.error("SE duration analysis failed", error); + if (tab === "se") + status(`SE duration analysis unavailable: ${reason}`, true); +} + function startSeMeasurement( row: Extract, assets: SeMeasureFiles, + token = ++seMeasureToken, ): void { - const token = ++seMeasureToken; seKnownFrames = null; seKnownEstimated = false; + seMeasureError = null; seMeasuring = true; renderSeLength(); void seInspector.measure( @@ -1890,9 +2161,10 @@ function startSeMeasurement( if (token !== seMeasureToken) return; seKnownFrames = measure.frames; seKnownEstimated = measure.estimated; + seMeasureError = null; }, (error) => { if (token !== seMeasureToken) return; - console.error("SE duration analysis failed", error); + recordSeMeasurementFailure(error); }).finally(() => { if (token !== seMeasureToken) return; seMeasuring = false; @@ -1902,26 +2174,43 @@ function startSeMeasurement( } async function refreshSeMeasurement(): Promise { - if (!session || !sePlaying) return; + const target = session; + const row = sePlaying; + if (!target || !row) return; + const token = ++seMeasureToken; + seKnownFrames = null; + seKnownEstimated = false; + seMeasureError = null; + seMeasuring = true; + renderSeLength(); try { - const { assets } = await seSynthAssets(sePlaying.entry); - startSeMeasurement(sePlaying, assets); + const { assets } = await seSynthAssets(target, row.entry); + if (token !== seMeasureToken || session !== target || sePlaying !== row) + return; + startSeMeasurement(row, assets, token); } catch (error) { - console.error("SE duration analysis failed", error); + if (token !== seMeasureToken || session !== target || sePlaying !== row) + return; + seMeasuring = false; + recordSeMeasurementFailure(error); + renderSeLength(); + renderSeInfo(); } } async function loadSeCue( row: Extract, autoplay: boolean, ): Promise { - if (!session || seLoading) return; + const target = session; + if (!target || seLoading) return; let p: WorkletPlayer; try { p = await ensurePlayer(); - } catch (e) { - status(e instanceof Error ? e.message : String(e), true); + } catch (error) { + if (session === target) status(friendlyError(error), true); return; } + if (session !== target) return; p.resume(); sPlayer.pause(); seLoading = true; @@ -1931,10 +2220,12 @@ async function loadSeCue( seSourceLoopViz?.clear(); try { const info = currentSeInfo(row); - const { assets } = await seSynthAssets(row.entry); + const { assets } = await seSynthAssets(target, row.entry); + if (session !== target) return; startSeMeasurement(row, assets); - const r = await p.loadSe( + const result = await p.loadSe( assets, row.bank, row.request, seEngineConfig(info)); + if (session !== target) return; sePlaying = row; sePlayingInfo = info; sePastEvents = false; @@ -1947,22 +2238,34 @@ async function loadSeCue( viewerLevelHead = 0; seSequenceViz?.set(info); $("se-name").textContent = row.entry.name; - $("se-coord").textContent = `bank ${row.bank} \u00B7 request ${row.request}`; + $("se-coord").textContent = `bank ${row.bank} ยท request ${row.request}`; renderSeLength(); $("se-playbtn").disabled = false; $("se-bankbtn").disabled = false; updateSeExportBtn(); - status(r.warning ? `warning: ${r.warning}` : "", !!r.warning); + if (seMeasureError) + status(`SE duration analysis unavailable: ${seMeasureError}`, true); + else + statusWithWarnings( + target, + "se", + result.warning ? `warning: ${result.warning}` : "", + !!result.warning, + ); if (autoplay) p.play(); - } catch (e) { - status(friendlyError(e), true); - seKnownEstimated = false; - seMeasureToken++; - seMeasuring = false; + } catch (error) { + if (session === target) { + status(friendlyError(error), true); + seKnownEstimated = false; + seMeasureToken++; + seMeasuring = false; + } } finally { - seLoading = false; - renderSeList(); - renderSeDials(); + if (session === target) { + seLoading = false; + renderSeList(); + renderSeDials(); + } } } @@ -2009,6 +2312,9 @@ function renderSeLength(): void { `${seKnownEstimated ? "โ‰ˆ" : ""}${fmtSeTime(seKnownFrames)} audio`; } else if (seMeasuring) { $("se-len").textContent = "measuring audioโ€ฆ"; + } else if (seMeasureError) { + $("se-len").textContent = + `${fmtSeTime(seEventFrames(info))} events ยท audio unavailable`; } else { $("se-len").textContent = `${fmtSeTime(seEventFrames(info))} events`; } @@ -2025,34 +2331,41 @@ function renderSeInfo(): void { if (!sePlaying) return; const info = currentSeInfo(); const counts = info ? seNoteCounts(info) : null; - const stream = info && counts - ? `${counts.starts} start${counts.starts === 1 ? "" : "s"} ยท ` - + `${counts.stops} stop${counts.stops === 1 ? "" : "s"} ยท ` - + `${info.controls} control${info.controls === 1 ? "" : "s"} ยท ` - + (info.loop - ? `${info.loop.count === 0 ? "infinite jump" : `jump ร—${info.loop.count}`} ` - + `${fmtSeTime(seLoopFrames(info)!.cycle)} cycle` - : `${fmtSeTime(seEventFrames(info))} event track`) - : "event metadata unavailable"; + let stream = "event metadata unavailable"; + if (info && counts) { + let track = `${fmtSeTime(seEventFrames(info))} event track`; + if (info.loop) { + const jump = info.loop.count === 0 + ? "infinite jump" + : `jump ร—${info.loop.count}`; + track = `${jump} ${fmtSeTime(seLoopFrames(info)!.cycle)} cycle`; + } + stream = `${counts.starts} start${counts.starts === 1 ? "" : "s"} ยท ` + + `${counts.stops} stop${counts.stops === 1 ? "" : "s"} ยท ` + + `${info.controls} control${info.controls === 1 ? "" : "s"} ยท ${track}`; + } const sourceEnd = info ? seSourceEndFrames(info) : 0; - const sources = info - ? (seIsStopOnly(info) - ? `voice-stop request: stops matching live ${seStopTargets(info)}; ` - + "starts no audio source by itself" - : info.sustained - ? `${info.sustainedVoices} non-decaying loop/noise ` - + `voice${info.sustainedVoices === 1 ? "" : "s"} remain after the event track` - : info.loopingVoices && sourceEnd > seEventFrames(info) - ? `${info.loopingVoices} loop/noise source ` - + `voice${info.loopingVoices === 1 ? "" : "s"} ` - + `${info.loopingVoices === 1 ? "remains" : "remain"} after events; ` - + `envelope endpoint โ‰ˆ${fmtSeTime(sourceEnd)}` - : info.activeVoices - ? `${info.activeVoices} note${info.activeVoices === 1 ? "" : "s"} ` - + `${info.activeVoices === 1 ? "has" : "have"} no explicit note-off; ` - + "waveform/envelope lifetime ends the source" - : "all started notes receive an explicit note-off") - : ""; + let sources = ""; + if (info) { + if (seIsStopOnly(info)) { + sources = `voice-stop request: stops matching live ${seStopTargets(info)}; ` + + "starts no audio source by itself"; + } else if (info.sustained) { + sources = `${info.sustainedVoices} non-decaying loop/noise ` + + `voice${info.sustainedVoices === 1 ? "" : "s"} remain after the event track`; + } else if (info.loopingVoices && sourceEnd > seEventFrames(info)) { + sources = `${info.loopingVoices} loop/noise source ` + + `voice${info.loopingVoices === 1 ? "" : "s"} ` + + `${info.loopingVoices === 1 ? "remains" : "remain"} after events; ` + + `envelope endpoint โ‰ˆ${fmtSeTime(sourceEnd)}`; + } else if (info.activeVoices) { + sources = `${info.activeVoices} note${info.activeVoices === 1 ? "" : "s"} ` + + `${info.activeVoices === 1 ? "has" : "have"} no explicit note-off; ` + + "waveform/envelope lifetime ends the source"; + } else { + sources = "all started notes receive an explicit note-off"; + } + } const now = sePastEvents && info?.loopingVoices ? "\nplayback now: event track ended; loop/noise source envelope is still sounding" : ""; @@ -2070,14 +2383,20 @@ function renderSeDials(): void { const loop = $("se-loop"); const hostLoop = info?.loop?.count === 0; loop.disabled = !hostLoop; - loop.textContent = hostLoop - ? (seCfg.loop ? "LOOP STREAM" : "STREAM ONCE") - : (info?.loop - ? `REPEAT \u00D7${info.loop.count}` - : info?.sustained - ? "SUSTAINED SOURCE" - : info?.loopingVoices ? "SOURCE LOOP" : "ONE SHOT"); - loop.classList.toggle("on", !!hostLoop && seCfg.loop); + let loopLabel: string; + if (hostLoop) { + loopLabel = seCfg.loop ? "LOOP STREAM" : "STREAM ONCE"; + } else if (info?.loop) { + loopLabel = `REPEAT \u00D7${info.loop.count}`; + } else if (info?.sustained) { + loopLabel = "SUSTAINED SOURCE"; + } else if (info?.loopingVoices) { + loopLabel = "SOURCE LOOP"; + } else { + loopLabel = "ONE SHOT"; + } + loop.textContent = loopLabel; + loop.classList.toggle("on", hostLoop && seCfg.loop); $("se-timing").textContent = seCfg.exact ? "EXACT" : "TICK"; $("se-timing").classList.toggle("on", seCfg.exact); $("se-kernel").textContent = seCfg.gaussian ? "GAUSS" : "BRIGHT"; @@ -2110,6 +2429,7 @@ function seToggleExact(): void { seCfg.exact = !seCfg.exact; seKnownFrames = null; seKnownEstimated = false; + seMeasureError = null; sePastEvents = false; if (audioMode === "se") { finished = false; @@ -2156,6 +2476,7 @@ function seToggleReverb(): void { seCfg.revDepth = seCfg.revDepth > 0 ? 0 : 30; seKnownFrames = null; seKnownEstimated = false; + seMeasureError = null; if (audioMode === "se") { player?.set("revDepth", seCfg.revDepth); void refreshSeMeasurement(); @@ -2186,7 +2507,8 @@ async function exportSeCue( looping = currentSeInfo()?.loop?.count === 0 && seCfg.loop, seconds = 10, ): Promise { - if (!session || !sePlaying || seBusy || exporter.busy) return; + const target = session; + if (!target || !sePlaying || seBusy || exporter.busy) return; const row = sePlaying; const info = currentSeInfo(); looping = looping && info?.loop?.count === 0; @@ -2208,34 +2530,42 @@ async function exportSeCue( updateSeExportBtn(); status(`EXPORTING se_${row.entry.name}_${row.bank}_${row.request}_${mode}.wav...`); try { - const { assets } = await seSynthAssets(row.entry); + const { assets } = await seSynthAssets(target, row.entry); + if (session !== target) return; const opts: SeExportOpts = { bank: row.bank, request: row.request, volume: seCfg.volume, revDepth: seCfg.revDepth, exact: seCfg.exact, bright: !seCfg.gaussian, seconds: cap, loop: looping, }; exporter.se(`se_${row.entry.name}_${mode}`, assets, opts); - } catch (e) { - status(`EXPORT FAILED: ${e instanceof Error ? e.message : e}`, true); + } catch (error) { + if (session === target) + status(`EXPORT FAILED: ${friendlyError(error)}`, true); } finally { - seBusy = false; - updateSeExportBtn(); + if (session === target) { + seBusy = false; + updateSeExportBtn(); + } } } async function exportSeBank(): Promise { - if (!session || !sePlaying) return; + const target = session; + const row = sePlaying; + if (!target || !row) return; try { - const { hd, bd } = await session.se.bank(sePlaying.entry); + const { hd, bd } = await target.se.bank(row.entry); + if (session !== target) return; const zip = storeZip([ - [sePlaying.entry.hd, hd], - [sePlaying.entry.bd, bd], + [row.entry.hd, hd], + [row.entry.bd, bd], ]); - const file = `${sePlaying.entry.name}_bank.zip`; + const file = `${row.entry.name}_bank.zip`; download(zip, file, "application/zip"); - status(`EXPORTED ${file}`); - } catch (e) { - status(`EXPORT FAILED: ${e instanceof Error ? e.message : e}`, true); + statusWithWarnings(target, "se", `EXPORTED ${file}`); + } catch (error) { + if (session === target) + status(`EXPORT FAILED: ${friendlyError(error)}`, true); } } @@ -2289,9 +2619,13 @@ function tick(): void { cc.setAttribute("aria-pressed", String(movie.captionsEnabled)); const statusElement = $("fmv-status"); - const message = movie.status || "Nothing leaves this browser."; + const persistence = session?.movies.persistenceWarning ?? ""; + const message = [ + movie.status || "Nothing leaves this browser.", + persistence, + ].filter(Boolean).join(" ยท "); if (statusElement.textContent !== message) statusElement.textContent = message; - statusElement.classList.toggle("err", movie.error); + statusElement.classList.toggle("err", movie.error || persistence.length > 0); const busy = movie.loading || movie.exporting; const jobProgress = $("fmv-job-progress"); jobProgress.setAttribute("aria-valuenow", String(Math.round(movie.progress * 100))); @@ -2333,6 +2667,7 @@ function tick(): void { if (finished && pos > 0 && pos !== seKnownFrames) { seKnownFrames = pos; seKnownEstimated = false; + seMeasureError = null; seMeasuring = false; renderSeLength(); } @@ -2436,43 +2771,50 @@ function updateExportBtn(): void { } async function exportWav(mode: "current" | "authored" | "loop"): Promise { - if (!session || playingIdx < 0 || exporter.busy) return; - const song = session.songs[playingIdx]!; + const target = session; + if (!target || playingIdx < 0 || exporter.busy) return; + const song = target.songs[playingIdx]!; let suffix = ""; - let o: ExportOpts; - if (mode === "authored") { /* the curated listening-set render */ + let options: ExportOpts; + if (mode === "authored") { suffix = "_authored"; - o = { songvol: song.songvol, revDepth: 30, exact: true, - bright: false, loop: 0 }; - } else { /* what you hear (minus the looping) */ - o = { songvol: cfg.songvol, revDepth: cfg.revDepth, exact: cfg.exact, - bright: !cfg.gaussian, loop: 0 }; - if (mode === "loop") { suffix = `_loop${loopN}`; o.loop = loopN; } + options = { songvol: song.songvol, revDepth: 30, exact: true, + bright: false, loop: 0 }; + } else { + options = { songvol: cfg.songvol, revDepth: cfg.revDepth, exact: cfg.exact, + bright: !cfg.gaussian, loop: 0 }; + if (mode === "loop") { + suffix = `_loop${loopN}`; + options.loop = loopN; + } } try { - const assets = await session.songAssets(song); - exporter.start(song.name, suffix, assets, o); - } catch (e) { - status(friendlyError(e), true); + const assets = await target.songAssets(song); + if (session !== target) return; + exporter.start(song.name, suffix, assets, options); + } catch (error) { + if (session === target) status(friendlyError(error), true); } - updateExportBtn(); + if (session === target) updateExportBtn(); } /* MIDI export: the sequence exactly as it sits on the disc (a standard SMF; * the CC99 20/30 loop markers ride along as plain controller events). No * render involved, so no worker and no busy state. */ async function exportMidi(): Promise { - if (!session || playingIdx < 0) return; - const song = session.songs[playingIdx]!; + const target = session; + if (!target || playingIdx < 0) return; + const song = target.songs[playingIdx]!; try { - const mid = await session.read(`bgm/${song.mid}`); + const mid = await target.read(`bgm/${song.mid}`); + if (session !== target) return; if (!mid) throw new Error(`missing asset bgm/${song.mid}` + " -- forget the disc and re-open the ISO"); download(mid, song.mid, "audio/midi"); status(`EXPORTED ${song.mid}`); - } catch (e) { - status(friendlyError(e), true); + } catch (error) { + if (session === target) status(friendlyError(error), true); } } @@ -2480,15 +2822,17 @@ async function exportMidi(): Promise { * loop points + root key as smpl chunks), zipped. Decode runs in the export * worker through the SDK's bank-introspection API. */ async function exportKit(): Promise { - if (!session || playingIdx < 0 || exporter.busy) return; - const song = session.songs[playingIdx]!; + const target = session; + if (!target || playingIdx < 0 || exporter.busy) return; + const song = target.songs[playingIdx]!; try { - const assets = await session.songAssets(song); + const assets = await target.songAssets(song); + if (session !== target) return; exporter.kit(song.name, assets); - } catch (e) { - status(friendlyError(e), true); + } catch (error) { + if (session === target) status(friendlyError(error), true); } - updateExportBtn(); + if (session === target) updateExportBtn(); } /* Bank export: the song's instrument bank exactly as it sits on the disc -- @@ -2496,22 +2840,24 @@ async function exportKit(): Promise { * downloads: a click is one gesture, and Chrome silently blocks the second * same-gesture download until the user allows "multiple downloads". */ async function exportBank(): Promise { - if (!session || playingIdx < 0) return; - const song = session.songs[playingIdx]!; + const target = session; + if (!target || playingIdx < 0) return; + const song = target.songs[playingIdx]!; try { const files: [string, Uint8Array][] = []; for (const name of [song.hd, song.bd]) { - const b = await session.read(`bgm/${name}`); - if (!b) + const bytes = await target.read(`bgm/${name}`); + if (session !== target) return; + if (!bytes) throw new Error(`missing asset bgm/${name}` + " -- forget the disc and re-open the ISO"); - files.push([name, b]); + files.push([name, bytes]); } const file = `${song.name}_bank.zip`; download(storeZip(files), file, "application/zip"); status(`EXPORTED ${file}`); - } catch (e) { - status(friendlyError(e), true); + } catch (error) { + if (session === target) status(friendlyError(error), true); } } @@ -3137,6 +3483,14 @@ export function viewerExport(): void { else void exportWav("current"); } +async function attachMovieIso(file: File): Promise { + try { + await movieController.attachIso(file); + } catch (error) { + movieController.reportError(friendlyError(error)); + } +} + export function viewerChooseDisc(): void { if (!session) { $("file").click(); @@ -3147,7 +3501,7 @@ export function viewerChooseDisc(): void { input.onchange = () => { const file = input.files?.[0]; input.remove(); - if (file) void movieController.attachIso(file); + if (file) void attachMovieIso(file); }; input.click(); } else if (tab === "streams" && !sCatalog) { @@ -3160,9 +3514,109 @@ export function viewerChooseDisc(): void { } /* ---- app states --------------------------------------------------------- */ -async function enterPlayer(s: DiscSession): Promise { +async function performDiscReset(): Promise { + const audio = player; + session = null; + player = null; + playerReady = null; + playerGeneration++; + audio?.pause(); + sPlayer.stop(); + sDecoder.dispose(); + seInspector.dispose(); + exporter.dispose(); + + timeline = null; + viz = null; + seViz = null; + seSequenceViz = null; + seSourceLoopViz = null; + audioMode = null; + sel = 0; + playingIdx = -1; + loading = false; + finished = false; + bgmDurations = new Float64Array(); + bgmDurationWarning = null; + + sCatalog = null; + streamCatalogRequest = null; + sRows = []; + sFolded.clear(); + sFoldInit = false; + sSel = 0; + sPlayingName = null; + sLoading = false; + sBusy = false; + sExtracting = false; + sViz = null; + + seCatalog = null; + seCatalogRequest = null; + seRows = []; + seSel = 0; + seBusy = false; + seOpenBank = null; + seShape = null; + seDetails = null; + seLoading = false; + seExtracting = false; + sePlaying = null; + sePlayingInfo = null; + seKnownFrames = null; + seKnownEstimated = false; + seMeasureError = null; + seMeasuring = false; + seMeasureToken++; + + imageCatalog = null; + imageCatalogRequest = null; + imageAllRows = []; + imageRows = []; + imageEntryById.clear(); + imageSelected = null; + imageExtracting = false; + imageExporting = false; + imageLoadGeneration++; + imageRenderGeneration++; + imageListObserver?.disconnect(); + imageThumbnailObserver?.disconnect(); + imageThumbnailQueue = []; + imageThumbnailActive = 0; + + fmvRenderedRevision = -1; + fmvAttachedRevision = -1; + fmvVisibleNames = []; + fmvRenderedEntryName = null; + modelBrowser?.setStore(null); + stagePreviewBrowser?.dispose(); + stagePreviewBrowser = null; + tab = "bgm"; + viewerChannel = "music"; + viewerRevision++; + + $("app").hidden = true; + $("picker").hidden = false; + await Promise.all([ + movieController.dispose(), + audio && audio.ctx.state !== "closed" + ? audio.ctx.close().catch(error => { + console.error("audio context close failed during disc reset", error); + }) + : Promise.resolve(), + ]); +} + +function resetDiscBoundState(): Promise { + return discCleanup.run(performDiscReset); +} + +async function enterPlayer(s: DiscSession, ticket: DiscOpenTicket): Promise { + const isCurrent = (): boolean => discOpens.isCurrent(ticket); + if (!isCurrent()) return; + await movieController.connect(s, isCurrent); + if (!isCurrent()) return; session = s; - await movieController.connect(s); modelBrowser?.setStore(s.models); stagePreviewBrowser?.setStore(s.stagePreviews); $("picker").hidden = true; @@ -3170,6 +3624,7 @@ async function enterPlayer(s: DiscSession): Promise { $("disc-id").textContent = s.cached ? (s.serial ?? s.volumeId) : `${s.serial ?? s.volumeId} (no cache)`; bgmDurations = new Float64Array(s.songs.length); + bgmDurationWarning = null; bgmDurations.fill(Number.NaN); renderList(); void loadBgmDurations(s); @@ -3185,9 +3640,12 @@ async function enterPlayer(s: DiscSession): Promise { /* no audio yet: the whole stack is built inside the first click/key * (ensurePlayer) so Safari associates the AudioContext with a gesture */ $("playbtn").disabled = false; - const region = s.serial && s.serial !== US_SERIAL - ? ` - untested region disc (${s.serial}), things may be off` : ""; - status(`${session.songs.length} songs - SPACE or click one to play${region}`); + const region = discSupportWarning(s.serial); + const persistence = s.persistenceWarning ? ` - ${s.persistenceWarning}` : ""; + status( + `${session.songs.length} songs - SPACE or click one to play${region}${persistence}`, + s.persistenceWarning !== null, + ); requestAnimationFrame(tick); } @@ -3198,20 +3656,30 @@ function wirePicker(): void { const bar = $("picker-progress"); async function open(file: File): Promise { + const ticket = discOpens.begin(); pstat.classList.remove("err"); pstat.textContent = "reading disc..."; bar.hidden = false; bar.value = 0; try { - const s = await openIso(file, (done, total, name) => { + await resetDiscBoundState(); + if (!discOpens.isCurrent(ticket)) return; + const next = await openIso(file, (done, total, name) => { + if (!discOpens.isCurrent(ticket)) return; bar.value = total > 0 ? done / total : 0; pstat.textContent = `extracting ${name} (${done}/${total})`; - }); - await enterPlayer(s); - } catch (e) { - pstat.textContent = friendlyError(e); + }, ticket.signal); + ticket.signal.throwIfAborted(); + if (!discOpens.isCurrent(ticket)) return; + await enterPlayer(next, ticket); + } catch (error) { + if (!discOpens.isCurrent(ticket) || ticket.signal.aborted) return; + pstat.textContent = friendlyError(error); pstat.classList.add("err"); bar.hidden = true; + } finally { + if (discOpens.isCurrent(ticket)) input.value = ""; + discOpens.complete(ticket); } } @@ -3252,7 +3720,7 @@ async function selftest(): Promise { put(`ctx:${p.ctx.state}`); setTimeout(() => put(`after-click:${p.ctx.state}`), 800); } catch (e) { - put(`setup:FAIL ${e instanceof Error ? e.message : e}`); + put(`setup:FAIL ${friendlyError(e)}`); } }; put("press-play-to-test-audio-unlock"); @@ -3399,14 +3867,17 @@ async function main(): Promise { const input = $("image-iso"); const file = input.files?.[0]; if (!file || !session) return; + const target = session; const setupStatus = $("image-setup-status"); setupStatus.classList.remove("err"); setupStatus.textContent = "reading disc..."; try { - await session.images.attachIso(file); + await target.images.attachIso(file); + if (session !== target) return; $("image-iso-pick").hidden = true; await extractImages(); } catch (error) { + if (session !== target) return; setupStatus.textContent = friendlyError(error); setupStatus.classList.add("err"); } finally { @@ -3427,8 +3898,11 @@ async function main(): Promise { const input = $("fmv-iso"); const file = input.files?.[0]; if (!file) return; - await movieController.attachIso(file); - input.value = ""; + try { + await attachMovieIso(file); + } finally { + input.value = ""; + } }; $("fmv-prev").onclick = () => moveFmv(-1, "play"); $("fmv-play").onclick = () => movieController.togglePlayback(); @@ -3476,15 +3950,18 @@ async function main(): Promise { $("s-iso").onchange = async () => { const f = $("s-iso").files?.[0]; if (!f || !session) return; + const target = session; const pstat = $("s-setup-status"); pstat.classList.remove("err"); pstat.textContent = "reading disc..."; try { - await session.streams.attachIso(f); + await target.streams.attachIso(f); + if (session !== target) return; $("s-iso-pick").hidden = true; await extractStreams(); - } catch (err) { - pstat.textContent = friendlyError(err); + } catch (error) { + if (session !== target) return; + pstat.textContent = friendlyError(error); pstat.classList.add("err"); } }; @@ -3526,20 +4003,26 @@ async function main(): Promise { const input = $("se-iso"); const file = input.files?.[0]; if (!file || !session) return; + const target = session; const setupStatus = $("se-setup-status"); setupStatus.classList.remove("err"); setupStatus.textContent = "reading disc..."; try { - await session.se.attachIso(file); + await target.se.attachIso(file); + if (session !== target) return; $("se-iso-pick").hidden = true; await extractSeBanks(); - } catch (e) { - setupStatus.textContent = friendlyError(e); + } catch (error) { + if (session !== target) return; + setupStatus.textContent = friendlyError(error); setupStatus.classList.add("err"); } }; renderSeDials(); sPlayer.onended = () => { /* glyph flips via the tick loop */ }; + sPlayer.onerror = error => { + status(`STREAM PLAYBACK FAILED: ${friendlyError(error)}`, true); + }; $("d-vol").onclick = () => { if (playingIdx >= 0 && session) setVol(session.songs[playingIdx]!.songvol, true); @@ -3638,8 +4121,24 @@ async function main(): Promise { if (new URLSearchParams(location.search).has("selftest")) return selftest(); - const resumed = await resumeSession(); - if (resumed) await enterPlayer(resumed); + const resumeTicket = discOpens.begin(); + try { + const resumed = await resumeSession(); + if (resumed && discOpens.isCurrent(resumeTicket)) + await enterPlayer(resumed, resumeTicket); + } finally { + discOpens.complete(resumeTicket); + } } -void main(); +void main().catch(error => { + console.error("application startup failed", error); + const picker = document.getElementById("picker"); + const app = document.getElementById("app"); + if (picker) picker.hidden = false; + if (app) app.hidden = true; + const pickerStatus = document.getElementById("picker-status"); + if (!pickerStatus) return; + pickerStatus.textContent = friendlyError(error); + pickerStatus.classList.add("err"); +}); diff --git a/src/movie-controller.ts b/src/movie-controller.ts index 8f3430d..17d72d6 100644 --- a/src/movie-controller.ts +++ b/src/movie-controller.ts @@ -44,6 +44,7 @@ export class MovieController { private session: DiscSession | null = null; private catalog: MovieCatalog | null = null; private catalogResolved = false; + private catalogLoading = false; private selectedName: string | null = null; private playback: MoviePlaybackSession | null = null; private cache: MovieCacheInfo | null = null; @@ -62,31 +63,35 @@ export class MovieController { private playbackAutoplay = false; private playbackTask: Promise | null = null; private playbackDisposal: Promise = Promise.resolve(); + private playbackDisposalWarning: string | null = null; private connectionGeneration = 0; constructor(hooks: MovieControllerHooks) { this.hooks = hooks; } - async connect(session: DiscSession): Promise { + async connect(session: DiscSession, + isCurrent: () => boolean = () => true): Promise { + if (!isCurrent()) return; const generation = ++this.connectionGeneration; this.abortAll(); this.session = null; this.catalog = null; this.catalogResolved = false; + this.catalogLoading = false; this.selectedName = null; this.cache = null; this.attaching = false; this.preparing = false; this.exporting = false; this.progress = 0; - this.status = "Loading movie catalogโ€ฆ"; + this.status = ""; this.error = false; await this.disposePlayback(); - if (generation !== this.connectionGeneration) return; + if (generation !== this.connectionGeneration || !isCurrent()) return; this.session = session; this.touch(); - void this.loadCatalog(session, generation); + if (this.active) this.requestCatalog(); } setActive(active: boolean): void { @@ -95,6 +100,7 @@ export class MovieController { if (active) { this.hooks.pauseOtherMedia(); this.touch(); + this.requestCatalog(); if (this.selectedName) void this.previewIfAvailable("preview"); return; } @@ -110,6 +116,10 @@ export class MovieController { const hasIso = this.session?.movies.hasIso() ?? false; const sourceComplete = entry !== null && this.cache !== null && movieSourceComplete(entry, this.cache); + const status = this.currentStatus(playback); + const cleanupStatus = this.playbackDisposalWarning + ? `Movie decoder cleanup warning: ${this.playbackDisposalWarning}` + : ""; return { revision: this.revision, active: this.active, @@ -118,19 +128,20 @@ export class MovieController { cache: this.cache, prepared: playback !== null && playback.state !== "error", hasIso, - sourceRequired: this.catalogResolved && !hasIso && ( + sourceRequired: this.catalogResolved && ( this.catalog === null - || (entry !== null && this.cache !== null && !sourceComplete) + || (!hasIso && entry !== null && !sourceComplete) ), cancellable: this.attachAbort !== null || this.exportAbort !== null || this.playbackAbort !== null || this.playbackBusy(playback), - loading: this.attaching || this.preparing, + loading: this.catalogLoading || this.attaching || this.preparing, exporting: this.exporting, progress: this.progress, - status: this.currentStatus(playback), - error: this.error || playback?.state === "error", + status: [status, cleanupStatus].filter(Boolean).join(" ยท "), + error: this.error || playback?.state === "error" + || this.playbackDisposalWarning !== null, current: playback?.currentTime ?? 0, - duration: playback?.duration ?? this.selectedEntry()?.duration ?? 0, + duration: playback?.duration ?? entry?.duration ?? 0, playing: playback?.playing ?? false, caption: playback?.caption ?? null, captionsEnabled: playback?.captionsEnabled ?? true, @@ -207,11 +218,13 @@ export class MovieController { } async attachIso(file: File): Promise { - if (!this.session || this.attaching || this.preparing || this.exporting) return; + if (!this.session || this.catalogLoading || this.attaching + || this.preparing || this.exporting) + return; const target = this.session; this.invalidatePlayIntent(); const priorTask = this.playbackTask; - if (priorTask) await priorTask.catch(() => {}); + if (priorTask) await priorTask; await this.disposePlayback(); if (this.session !== target) return; @@ -236,7 +249,9 @@ export class MovieController { if (this.session !== target) return; if (!this.catalog?.entries.some(entry => entry.name === this.selectedName)) this.selectedName = this.catalog?.entries[0]?.name ?? null; - this.status = `${this.catalog?.entries.length ?? 0} movies indexed locally`; + const unavailable = this.catalog?.issues.length ?? 0; + this.status = `${this.catalog?.entries.length ?? 0} movies indexed locally` + + (unavailable ? `; ${unavailable} unavailable` : ""); this.error = false; if (this.selectedName) await this.refreshCache(this.selectedName, controller.signal); } catch (cause) { @@ -249,7 +264,8 @@ export class MovieController { this.touch(); } } - if (this.active && this.session === target && !controller.signal.aborted) + if (this.active && this.session === target + && !controller.signal.aborted && !this.error) await this.previewIfAvailable("preview"); } @@ -335,6 +351,7 @@ export class MovieController { await this.disposePlayback(); this.catalog = null; this.catalogResolved = false; + this.catalogLoading = false; this.selectedName = null; this.cache = null; this.attaching = false; @@ -359,15 +376,29 @@ export class MovieController { this.hooks.changed(); } + private requestCatalog(): void { + const target = this.session; + if (!target || this.catalogResolved || this.catalogLoading) return; + const generation = this.connectionGeneration; + this.catalogLoading = true; + this.status = "Loading movie catalogโ€ฆ"; + this.error = false; + this.touch(); + void this.loadCatalog(target, generation); + } + private async loadCatalog(target: DiscSession, generation: number): Promise { try { const catalog = await target.movies.catalog(); if (this.session !== target || generation !== this.connectionGeneration) return; + this.catalogLoading = false; this.catalog = catalog; this.catalogResolved = true; this.selectedName = catalog?.entries[0]?.name ?? null; + const unavailable = catalog?.issues.length ?? 0; this.status = catalog ? `${catalog.entries.length} movies indexed locally` + + (unavailable ? `; ${unavailable} unavailable` : "") : "Attach the source ISO once to scan its movie archive."; this.error = false; this.touch(); @@ -376,6 +407,7 @@ export class MovieController { else await this.refreshCache(this.selectedName); } catch (cause) { if (this.session !== target || generation !== this.connectionGeneration) return; + this.catalogLoading = false; this.catalogResolved = true; this.fail(cause); } @@ -389,6 +421,8 @@ export class MovieController { private abortAll(): void { this.invalidatePlayIntent(); + this.playbackAbort = null; + this.playbackTask = null; this.attachAbort?.abort(); this.exportAbort?.abort(); this.attachAbort = null; @@ -411,8 +445,16 @@ export class MovieController { this.playback = null; if (!playback) return this.playbackDisposal; const disposal = this.playbackDisposal.then(() => playback.dispose()); - this.playbackDisposal = disposal.catch(() => {}); - return disposal; + this.playbackDisposal = disposal.then( + () => { + this.playbackDisposalWarning = null; + }, + cause => { + this.playbackDisposalWarning = friendlyError(cause); + console.error("movie playback disposal failed", cause); + }, + ); + return this.playbackDisposal; } private async refreshCache(name: string, signal?: AbortSignal): Promise { @@ -432,7 +474,7 @@ export class MovieController { private async finishSelection(name: string, mode: MovieStartMode, priorTask: Promise | null): Promise { - if (priorTask) await priorTask.catch(() => {}); + if (priorTask) await priorTask; await this.disposePlayback(); if (this.selectedName !== name || !this.active) { if (this.selectedName === name) await this.refreshCache(name); @@ -448,8 +490,12 @@ export class MovieController { } const task = this.prepareSelected(mode); this.playbackTask = task; - void task.finally(() => { + void task.catch(cause => { + if (this.playbackTask === task) this.fail(cause); + }).finally(() => { if (this.playbackTask === task) this.playbackTask = null; + }).catch(cause => { + console.error("movie preparation failure handler failed", cause); }); } @@ -572,11 +618,12 @@ export class MovieController { this.exportAbort = controller; this.exporting = true; this.progress = 0; - this.status = kind === "mp4" - ? "Starting Fast MP4 exportโ€ฆ" - : kind === "mkv" - ? "Starting Lossless MKV exportโ€ฆ" - : `Preparing ${kind} exportโ€ฆ`; + if (kind === "mp4") + this.status = "Starting Fast MP4 exportโ€ฆ"; + else if (kind === "mkv") + this.status = "Starting Lossless MKV exportโ€ฆ"; + else + this.status = `Preparing ${kind} exportโ€ฆ`; this.error = false; this.touch(); try { diff --git a/src/movie-decoder-protocol.ts b/src/movie-decoder-protocol.ts index 0717e44..d097e35 100644 --- a/src/movie-decoder-protocol.ts +++ b/src/movie-decoder-protocol.ts @@ -134,84 +134,177 @@ export function movieFrameByteLength(width: number, height: number): number { return width * height * 3 / 2; } +type UnknownRecord = Record; + +const RESPONSE_IDENTITY_KEYS = ["type", "requestId", "generation"] as const; +const READY_KEYS = new Set([ + ...RESPONSE_IDENTITY_KEYS, + "width", "height", "format", "outputRate", "frameDuration", + "sourceFrames", "outputFrames", "firstSeekPoint", +]); +const FRAMES_KEYS = new Set([ + ...RESPONSE_IDENTITY_KEYS, "frames", "eof", "stats", +]); +const SEEKED_KEYS = new Set([ + ...RESPONSE_IDENTITY_KEYS, "target", "timestamp", +]); +const STATS_RESPONSE_KEYS = new Set([...RESPONSE_IDENTITY_KEYS, "stats"]); +const TERMINAL_KEYS = new Set(RESPONSE_IDENTITY_KEYS); +const ERROR_KEYS = new Set([ + ...RESPONSE_IDENTITY_KEYS, "stage", "message", +]); +const FRAME_KEYS = new Set([ + "index", "timestamp", "duration", "width", "height", + "format", "data", "layout", +]); +const PLANE_KEYS = new Set(["offset", "stride"]); +const STATS_KEYS = new Set([ + "packets", "decodedFrames", "outputFrames", "droppedFrames", + "decodeWallTime", "pendingBytes", "wasmBytes", +]); +const SEEK_POINT_KEYS = new Set(["offset", "frame"]); + export function isMovieDecoderResponse(value: unknown): value is MovieDecoderResponse { - if (!hasResponseIdentity(value)) - return false; - switch (value.type) { + const response = responseRecord(value); + if (!response) return false; + switch (response.type) { case "ready": - return hasNumber(value, "width") && hasNumber(value, "height") - && hasStringValue(value, "format", "I420") - && hasNumber(value, "outputRate") && hasNumber(value, "frameDuration") - && hasNumber(value, "sourceFrames") && hasNumber(value, "outputFrames") - && "firstSeekPoint" in value && isSeekPoint(value.firstSeekPoint); + return hasOnlyKeys(response, READY_KEYS) + && hasDimensions(response.width, response.height) + && response.format === "I420" + && isPositiveFinite(response.outputRate) + && isPositiveFinite(response.frameDuration) + && isPositiveSafeInteger(response.sourceFrames) + && isPositiveSafeInteger(response.outputFrames) + && response.outputFrames >= response.sourceFrames + && isSeekPoint(response.firstSeekPoint); case "frames": - return "frames" in value && Array.isArray(value.frames) - && value.frames.every(isDecodedMovieFrame) - && "eof" in value && typeof value.eof === "boolean" - && "stats" in value && isDecoderStats(value.stats); + return hasOnlyKeys(response, FRAMES_KEYS) + && Array.isArray(response.frames) + && response.frames.every(isDecodedMovieFrame) + && typeof response.eof === "boolean" + && isDecoderStats(response.stats); case "seeked": - return hasNumber(value, "target") && hasNumber(value, "timestamp"); + return hasOnlyKeys(response, SEEKED_KEYS) + && isNonnegativeFinite(response.target) + && isNonnegativeFinite(response.timestamp); case "eof": - return "stats" in value && isDecoderStats(value.stats); + return hasOnlyKeys(response, STATS_RESPONSE_KEYS) + && isDecoderStats(response.stats); case "disposed": case "cancelled": - return true; + return hasOnlyKeys(response, TERMINAL_KEYS); case "error": - return "stage" in value && isErrorStage(value.stage) - && "message" in value && typeof value.message === "string"; + return hasOnlyKeys(response, ERROR_KEYS) + && isErrorStage(response.stage) + && typeof response.message === "string" + && response.message.length > 0; default: return false; } } -function hasResponseIdentity(value: unknown): value is { - type: string; - requestId: number; - generation: number; -} { +function record(value: unknown): UnknownRecord | null { return typeof value === "object" && value !== null - && "type" in value && typeof value.type === "string" - && "requestId" in value && typeof value.requestId === "number" - && Number.isSafeInteger(value.requestId) - && "generation" in value && typeof value.generation === "number" - && Number.isSafeInteger(value.generation); + ? value as UnknownRecord + : null; +} + +function responseRecord(value: unknown): UnknownRecord | null { + const response = record(value); + return response + && typeof response.type === "string" + && isPositiveSafeInteger(response.requestId) + && isPositiveSafeInteger(response.generation) + ? response + : null; +} + +function hasOnlyKeys( + value: UnknownRecord, + allowed: ReadonlySet, +): boolean { + return Object.keys(value).every(key => allowed.has(key)); +} + +function hasDimensions(width: unknown, height: unknown): boolean { + return isPositiveSafeInteger(width) + && isPositiveSafeInteger(height) + && (width & 1) === 0 + && (height & 1) === 0 + && Number.isSafeInteger(width * height * 3 / 2); } function isDecodedMovieFrame(value: unknown): value is DecodedMovieFrame { - return typeof value === "object" && value !== null - && hasNumber(value, "index") && hasNumber(value, "timestamp") - && hasNumber(value, "duration") && hasNumber(value, "width") - && hasNumber(value, "height") && hasStringValue(value, "format", "I420") - && "data" in value && value.data instanceof ArrayBuffer - && "layout" in value && Array.isArray(value.layout) - && value.layout.length === 3 && value.layout.every(isPlaneLayout); + const frame = record(value); + if (!frame + || !hasOnlyKeys(frame, FRAME_KEYS) + || !isNonnegativeSafeInteger(frame.index) + || !isNonnegativeFinite(frame.timestamp) + || !isPositiveFinite(frame.duration) + || !hasDimensions(frame.width, frame.height) + || frame.format !== "I420" + || !(frame.data instanceof ArrayBuffer) + || !Array.isArray(frame.layout) + || frame.layout.length !== 3) + return false; + const width = frame.width as number; + const height = frame.height as number; + const yBytes = width * height; + const chromaBytes = yBytes / 4; + return frame.data.byteLength === movieFrameByteLength(width, height) + && isPlaneLayout(frame.layout[0], 0, width) + && isPlaneLayout(frame.layout[1], yBytes, width / 2) + && isPlaneLayout(frame.layout[2], yBytes + chromaBytes, width / 2); } -function isPlaneLayout(value: unknown): value is MoviePlaneLayout { - return typeof value === "object" && value !== null - && hasNumber(value, "offset") && hasNumber(value, "stride"); +function isPlaneLayout( + value: unknown, + offset: number, + stride: number, +): value is MoviePlaneLayout { + const plane = record(value); + return plane !== null + && hasOnlyKeys(plane, PLANE_KEYS) + && plane.offset === offset + && plane.stride === stride; } function isDecoderStats(value: unknown): value is MovieDecoderStats { - return typeof value === "object" && value !== null - && hasNumber(value, "packets") && hasNumber(value, "decodedFrames") - && hasNumber(value, "outputFrames") && hasNumber(value, "droppedFrames") - && hasNumber(value, "decodeWallTime") && hasNumber(value, "pendingBytes") - && hasNumber(value, "wasmBytes"); + const stats = record(value); + return stats !== null + && hasOnlyKeys(stats, STATS_KEYS) + && isNonnegativeSafeInteger(stats.packets) + && isNonnegativeSafeInteger(stats.decodedFrames) + && isNonnegativeSafeInteger(stats.outputFrames) + && isNonnegativeSafeInteger(stats.droppedFrames) + && isNonnegativeFinite(stats.decodeWallTime) + && isNonnegativeSafeInteger(stats.pendingBytes) + && isNonnegativeSafeInteger(stats.wasmBytes); } function isSeekPoint(value: unknown): value is Mpeg2SeekPoint { - return typeof value === "object" && value !== null - && hasNumber(value, "offset") && hasNumber(value, "frame"); + const point = record(value); + return point !== null + && hasOnlyKeys(point, SEEK_POINT_KEYS) + && isNonnegativeSafeInteger(point.offset) + && isNonnegativeSafeInteger(point.frame); +} + +function isPositiveSafeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value > 0; +} + +function isNonnegativeSafeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; } -function hasNumber(value: object, key: string): boolean { - return key in value && typeof value[key as keyof typeof value] === "number" - && Number.isFinite(value[key as keyof typeof value]); +function isPositiveFinite(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; } -function hasStringValue(value: object, key: string, expected: string): boolean { - return key in value && value[key as keyof typeof value] === expected; +function isNonnegativeFinite(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; } function isErrorStage(value: unknown): value is MovieDecoderErrorStage { diff --git a/src/movies.ts b/src/movies.ts index 8caf5fe..85b37e9 100644 --- a/src/movies.ts +++ b/src/movies.ts @@ -1,10 +1,22 @@ import { - BlobSource, OpfsCache, openDisc, locateFmvAssets, inspectFmvAsset, demuxFmv, + BlobSource, OpfsCache, openDisc, locateFmvAssets, inspectFmvPrefix, demuxFmv, indexMpeg2SeekPoints, parseFmvSubtitles, subtitlesToSrt, subtitlesToVtt, type FmvAsset, type FmvDemux, type FmvHeader, type FmvVideoInfo, type Mpeg2SeekIndex, type SubtitleCue, type Vfi, type VfiEntry, } from "./vendor/extract/index.ts"; -import { storeZip } from "./zip.ts"; +import { assertZipEntryPath, storeZip } from "./zip.ts"; +import { + assertAttachedDiscIdentity, + assertCacheSourceIdentity, +} from "./disc-identity.ts"; +import { technicalReason } from "./errors.ts"; +import { FmvFormatError, type FmvDiscovery } from "./vendor/extract/fmv.ts"; +import { + contentFingerprintMatches, + fingerprintBytes, + isContentFingerprint, + type ContentFingerprint, +} from "./content-identity.ts"; const META = "movie_meta.json"; const CACHE_VERSION = "v1-ffmpeg-0.12.10-mediabunny-1.50.9"; @@ -20,25 +32,40 @@ const LEGACY_OUTPUT_FORMATS = ["mkv", "mp4", "webm"] as const; export type MovieGroup = "opening" | "story" | "gameplay"; +export interface MovieSourceFileIdentity extends ContentFingerprint { + readonly name: string; +} + export interface MovieEntry { - name: string; - label: string; - group: MovieGroup; - movie: VfiEntry; - subtitleBin: VfiEntry | null; - subtitleSbt: VfiEntry | null; - sourceBytes: number; - subtitleBytes: number; - subtitleCues: number; - subtitleEnd: number | null; - header: FmvHeader; - video: FmvVideoInfo; - duration: number; + readonly name: string; + readonly label: string; + readonly group: MovieGroup; + readonly movie: VfiEntry; + readonly subtitleBin: VfiEntry | null; + readonly subtitleSbt: VfiEntry | null; + readonly sourceBytes: number; + readonly subtitleBytes: number; + readonly subtitleCues: number; + readonly subtitleEnd: number | null; + readonly header: FmvHeader; + readonly video: FmvVideoInfo; + readonly duration: number; + readonly sourceFingerprints: readonly MovieSourceFileIdentity[]; +} + +export interface MovieCatalogIssue { + readonly name: string; + readonly label: string; + readonly group: MovieGroup; + readonly sourceBytes: number; + readonly reason: string; } export interface MovieCatalog { - v: 1; - entries: MovieEntry[]; + readonly v: 3; + readonly sourceKey: string; + readonly entries: readonly MovieEntry[]; + readonly issues: readonly MovieCatalogIssue[]; } export interface MovieCacheInfo { @@ -95,12 +122,177 @@ function group(name: string): MovieGroup { return "opening"; } -function validCatalog(value: unknown): value is MovieCatalog { - if (!value || typeof value !== "object") return false; - const candidate = value as Partial; - return candidate.v === 1 && Array.isArray(candidate.entries) - && candidate.entries.every(entry => !!entry && typeof entry.name === "string" - && typeof entry.sourceBytes === "number" && !!entry.header && !!entry.video); +function objectValue(value: unknown): Record | null { + return value !== null && typeof value === "object" + ? value as Record + : null; +} + +function isSafeInteger(value: unknown, minimum = 0): value is number { + return typeof value === "number" && Number.isSafeInteger(value) + && value >= minimum; +} + +function isPositiveNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function isZipEntryName(value: string): boolean { + try { + assertZipEntryPath(value); + return true; + } catch { + return false; + } +} + +function isVfiEntry(value: unknown): value is VfiEntry { + const entry = objectValue(value); + return entry !== null + && typeof entry.name === "string" && entry.name.length > 0 + && typeof entry.path === "string" && entry.path.length > 0 + && isSafeInteger(entry.entryOff) + && isSafeInteger(entry.entrySize) + && isSafeInteger(entry.parentOff) + && isSafeInteger(entry.sector) + && isSafeInteger(entry.size, 1); +} + +function isFmvHeader(value: unknown): value is FmvHeader { + const header = objectValue(value); + return header !== null + && isSafeInteger(header.fields, 1) + && isPositiveNumber(header.fieldRate) + && isSafeInteger(header.groups, 1) + && isSafeInteger(header.sampleRate, 1) + && isSafeInteger(header.channels, 1) + && isSafeInteger(header.interleave, 1) + && isSafeInteger(header.audioBlock, 1) + && isSafeInteger(header.preload, 1) + && isSafeInteger(header.audioBytes, 1); +} + +function isPositivePair(value: unknown): value is readonly [number, number] { + return Array.isArray(value) && value.length === 2 + && isPositiveNumber(value[0]) && isPositiveNumber(value[1]); +} + +function isFmvVideoInfo(value: unknown): value is FmvVideoInfo { + const video = objectValue(value); + return video !== null + && isSafeInteger(video.width, 1) + && isSafeInteger(video.height, 1) + && isPositiveNumber(video.frameRate) + && (video.fieldOrder === "progressive" + || video.fieldOrder === "tt" + || video.fieldOrder === "bb") + && isPositivePair(video.sampleAspect) + && isPositivePair(video.displayAspect); +} + +function isMovieSourceFingerprints(value: unknown, + movie: VfiEntry, + subtitleBin: VfiEntry | null, + subtitleSbt: VfiEntry | null): + value is readonly MovieSourceFileIdentity[] { + if (!Array.isArray(value)) return false; + const expected = new Map([ + [movie.name, movie.size], + ...(subtitleBin ? [[subtitleBin.name, subtitleBin.size] as const] : []), + ...(subtitleSbt ? [[subtitleSbt.name, subtitleSbt.size] as const] : []), + ]); + if (value.length !== expected.size) return false; + const names = new Set(); + for (const item of value) { + const candidate = objectValue(item); + if (!candidate + || typeof candidate.name !== "string" + || !isZipEntryName(candidate.name) + || names.has(candidate.name) + || !isContentFingerprint(candidate) + || candidate.bytes !== expected.get(candidate.name)) + return false; + names.add(candidate.name); + } + return names.size === expected.size; +} + +function isMovieEntry(value: unknown): value is MovieEntry { + const entry = objectValue(value); + if (entry === null || typeof entry.name !== "string" || entry.name.length === 0 + || !isZipEntryName(`${entry.name}.m2v`) + || typeof entry.label !== "string" || entry.label.length === 0 + || !["opening", "story", "gameplay"].includes(entry.group as string) + || !isVfiEntry(entry.movie) + || !isZipEntryName(entry.movie.name) + || !isSafeInteger(entry.sourceBytes, 1) + || entry.sourceBytes !== entry.movie.size + || !isSafeInteger(entry.subtitleBytes) + || !isSafeInteger(entry.subtitleCues) + || !(entry.subtitleEnd === null + || (typeof entry.subtitleEnd === "number" + && Number.isFinite(entry.subtitleEnd) && entry.subtitleEnd >= 0)) + || !isFmvHeader(entry.header) + || !isFmvVideoInfo(entry.video) + || !isPositiveNumber(entry.duration)) + return false; + if (entry.subtitleBin === null && entry.subtitleSbt === null) { + return entry.subtitleBytes === 0 + && isMovieSourceFingerprints( + entry.sourceFingerprints, + entry.movie, + null, + null, + ); + } + if (!isVfiEntry(entry.subtitleBin) || !isVfiEntry(entry.subtitleSbt) + || !isZipEntryName(entry.subtitleBin.name) + || !isZipEntryName(entry.subtitleSbt.name) + || !isMovieSourceFingerprints( + entry.sourceFingerprints, + entry.movie, + entry.subtitleBin, + entry.subtitleSbt, + )) + return false; + return entry.subtitleBytes === entry.subtitleBin.size + entry.subtitleSbt.size; +} + +function isMovieCatalogIssue(value: unknown): value is MovieCatalogIssue { + const issue = objectValue(value); + return issue !== null + && typeof issue.name === "string" && issue.name.length > 0 + && isZipEntryName(`${issue.name}.m2v`) + && typeof issue.label === "string" && issue.label.length > 0 + && ["opening", "story", "gameplay"].includes(issue.group as string) + && isSafeInteger(issue.sourceBytes, 1) + && typeof issue.reason === "string" && issue.reason.length > 0 + && issue.reason.length <= 320 + && technicalReason(issue.reason) === issue.reason; +} + +function parseCatalog(value: unknown, + expectedSourceKey: string): MovieCatalog | null { + const candidate = objectValue(value); + if (candidate === null || candidate.v !== 3 + || candidate.sourceKey !== expectedSourceKey + || !Array.isArray(candidate.entries) + || !candidate.entries.every(isMovieEntry) + || !Array.isArray(candidate.issues) + || !candidate.issues.every(isMovieCatalogIssue)) + return null; + const names = [ + ...candidate.entries.map(entry => entry.name), + ...candidate.issues.map(issue => issue.name), + ]; + if (new Set(names).size !== names.length) + return null; + return { + v: 3, + sourceKey: expectedSourceKey, + entries: candidate.entries, + issues: candidate.issues, + }; } async function subtitleData(vfi: Vfi, asset: FmvAsset, @@ -111,6 +303,9 @@ async function subtitleData(vfi: Vfi, asset: FmvAsset, const [bin, sbt] = await Promise.all([ vfi.read(asset.subtitleBin), vfi.read(asset.subtitleSbt), ]); + if (bin.byteLength !== asset.subtitleBin.size + || sbt.byteLength !== asset.subtitleSbt.size) + throw new Error("subtitle source short read"); signal?.throwIfAborted(); return { cues: parseFmvSubtitles(bin, sbt, asset.name), @@ -119,38 +314,88 @@ async function subtitleData(vfi: Vfi, asset: FmvAsset, }; } -export async function scanMovieCatalog(vfi: Vfi, progress?: MovieProgress, - signal?: AbortSignal): Promise { +function movieCatalogIssue(asset: FmvDiscovery, label: string, + movieGroup: MovieGroup, cause: FmvFormatError): MovieCatalogIssue { + return { + name: asset.name, + label, + group: movieGroup, + sourceBytes: asset.movie.size, + reason: technicalReason(cause), + }; +} + +export async function scanMovieCatalog( + vfi: Vfi, + sourceKey: string, + progress?: MovieProgress, + signal?: AbortSignal, +): Promise { + assertCacheSourceIdentity(null, sourceKey); const assets = locateFmvAssets(vfi); const entries: MovieEntry[] = []; + const issues: MovieCatalogIssue[] = []; for (let index = 0; index < assets.length; index++) { signal?.throwIfAborted(); const asset = assets[index]!; + const label = title(asset.name); + const movieGroup = group(asset.name); progress?.(index / assets.length, `scanning ${asset.name}`); - const [{ header, videoInfo }, subtitles] = await Promise.all([ - inspectFmvAsset(vfi, asset.movie), - subtitleData(vfi, asset, signal), - ]); - signal?.throwIfAborted(); - entries.push({ - name: asset.name, - label: title(asset.name), - group: group(asset.name), - movie: asset.movie, - subtitleBin: asset.subtitleBin, - subtitleSbt: asset.subtitleSbt, - sourceBytes: asset.movie.size, - subtitleBytes: (asset.subtitleBin?.size ?? 0) + (asset.subtitleSbt?.size ?? 0), - subtitleCues: subtitles.cues.length, - subtitleEnd: subtitles.cues.at(-1)?.end ?? null, - header, - video: videoInfo, - duration: header.fields / header.fieldRate, - }); + if ("formatError" in asset) { + issues.push(movieCatalogIssue( + asset, label, movieGroup, asset.formatError)); + continue; + } + try { + const movieBytes = await vfi.read(asset.movie); + if (movieBytes.byteLength !== asset.movie.size) + throw new Error("movie source short read"); + signal?.throwIfAborted(); + const { header, videoInfo } = + inspectFmvPrefix(movieBytes, `movie ${asset.name}`); + const subtitles = await subtitleData(vfi, asset, signal); + signal?.throwIfAborted(); + const sourceFiles: Array<[string, Uint8Array]> = [ + [asset.movie.name, movieBytes], + ]; + if (asset.subtitleBin && subtitles.bin) + sourceFiles.push([asset.subtitleBin.name, subtitles.bin]); + if (asset.subtitleSbt && subtitles.sbt) + sourceFiles.push([asset.subtitleSbt.name, subtitles.sbt]); + const sourceFingerprints = await Promise.all( + sourceFiles.map(async ([name, bytes]) => ({ + name, + ...await fingerprintBytes(bytes), + })), + ); + signal?.throwIfAborted(); + entries.push({ + name: asset.name, + label, + group: movieGroup, + movie: asset.movie, + subtitleBin: asset.subtitleBin, + subtitleSbt: asset.subtitleSbt, + sourceBytes: asset.movie.size, + subtitleBytes: (asset.subtitleBin?.size ?? 0) + + (asset.subtitleSbt?.size ?? 0), + subtitleCues: subtitles.cues.length, + subtitleEnd: subtitles.cues.at(-1)?.end ?? null, + header, + video: videoInfo, + duration: header.fields / header.fieldRate, + sourceFingerprints, + }); + } catch (cause) { + signal?.throwIfAborted(); + if (!(cause instanceof FmvFormatError)) + throw cause; + issues.push(movieCatalogIssue(asset, label, movieGroup, cause)); + } } signal?.throwIfAborted(); progress?.(1, "movie catalog ready"); - return { v: 1, entries }; + return { v: 3, sourceKey, entries, issues }; } function movieRoot(name: string): string { @@ -169,22 +414,77 @@ function legacyExportKey(name: string, format: string, captions: boolean): strin return `${movieRoot(name)}/export-${format}-${captions ? "captions" : "plain"}.${format}`; } + +interface MovieSourceIdentity { + readonly v: 2; + readonly sourceKey: string; + readonly files: readonly MovieSourceFileIdentity[]; +} + +function sourceMetaKey(name: string): string { + return `${movieRoot(name)}/source_meta.json`; +} + +function catalogSourceFingerprints(entry: MovieEntry): + Map { + return new Map(entry.sourceFingerprints.map(file => [file.name, file])); +} + + +function parseSourceIdentity(value: unknown, entry: MovieEntry, + expectedSourceKey: string): + Map | null { + const identity = objectValue(value); + if (!identity || identity.v !== 2 + || identity.sourceKey !== expectedSourceKey + || !Array.isArray(identity.files)) + return null; + const expected = catalogSourceFingerprints(entry); + const files: MovieSourceFileIdentity[] = []; + const names = new Set(); + for (const value of identity.files) { + const candidate = objectValue(value); + if (!candidate + || typeof candidate.name !== "string" + || !isZipEntryName(candidate.name) + || names.has(candidate.name) + || !isContentFingerprint(candidate)) + return null; + const fingerprint = expected.get(candidate.name); + if (!fingerprint + || !contentFingerprintMatches(candidate, fingerprint)) + return null; + files.push(candidate as unknown as MovieSourceFileIdentity); + names.add(candidate.name); + } + if (files.length !== expected.size) return null; + return new Map(files.map(file => [file.name, file])); +} + export class MovieStore { private cache: OpfsCache | null; private vfi: Vfi | null; - private readonly cacheKey: string | null; + private readonly cacheKey: string; private catalogValue: MovieCatalog | null; private readonly memory = new Map(); + private cacheWarning: string | null = null; - constructor(cache: OpfsCache | null, vfi: Vfi | null, cacheKey: string | null, + constructor(cache: OpfsCache | null, vfi: Vfi | null, cacheKey: string, catalog: MovieCatalog | null = null) { + assertCacheSourceIdentity(cache, cacheKey); + if (catalog && catalog.sourceKey !== cacheKey) + throw new Error("movie catalog belongs to a different disc session"); + const parsed = catalog ? parseCatalog(catalog, cacheKey) : null; + if (catalog && !parsed) + throw new Error("movie catalog has an invalid schema"); this.cache = cache; this.vfi = vfi; this.cacheKey = cacheKey; - this.catalogValue = catalog; + this.catalogValue = parsed; } hasIso(): boolean { return this.vfi !== null; } + get persistenceWarning(): string | null { return this.cacheWarning; } async catalog(signal?: AbortSignal): Promise { signal?.throwIfAborted(); @@ -196,33 +496,59 @@ export class MovieStore { raw = await this.cache.read(META); signal?.throwIfAborted(); } catch (error) { - return this.recoverCatalog(error, signal); + signal?.throwIfAborted(); + this.useAttachedIsoAfterCacheFailure( + error, + "the movie cache is unavailable -- reconnect the same disc or forget the disc cache", + ); + raw = null; } if (raw) { try { - const parsed: unknown = JSON.parse(textDecoder.decode(raw)); - if (!validCatalog(parsed)) + const parsed = parseCatalog( + JSON.parse(textDecoder.decode(raw)), + this.cacheKey, + ); + if (!parsed) throw new Error("the cached movie index has an invalid schema"); signal?.throwIfAborted(); - this.catalogValue = parsed; - return parsed; + if (!this.vfi) { + this.catalogValue = parsed; + return parsed; + } } catch (error) { - return this.recoverCatalog(error, signal); + return this.recoverCorruptCatalog(error, signal); } } } signal?.throwIfAborted(); if (!this.vfi) return null; - this.catalogValue = await scanMovieCatalog(this.vfi, undefined, signal); + this.catalogValue = await scanMovieCatalog( + this.vfi, this.cacheKey, undefined, signal); signal?.throwIfAborted(); await this.persistCatalog(signal); signal?.throwIfAborted(); return this.catalogValue; } + private useAttachedIsoAfterCacheFailure(error: unknown, message: string): void { + if (!this.vfi) throw new Error(message, { cause: error }); + this.cacheWarning = `${message} (${technicalReason(error)}); ` + + "using the attached ISO for this session"; + console.warn("Movie cache failed; using the attached ISO", error); + this.cache = null; + } - private async recoverCatalog(error: unknown, - signal?: AbortSignal): Promise { + private recordCacheWriteFailure(error: unknown, message: string): void { + this.cacheWarning = `${message} (${technicalReason(error)}); ` + + "movie data remains available for this session"; + console.warn("Movie cache write failed; keeping a session copy", error); + this.cache = null; + } + + + private async recoverCorruptCatalog(error: unknown, + signal?: AbortSignal): Promise { signal?.throwIfAborted(); if (!this.vfi) { throw new Error( @@ -231,7 +557,8 @@ export class MovieStore { ); } console.warn("Cached movie index is damaged; rebuilding it from the attached ISO", error); - this.catalogValue = await scanMovieCatalog(this.vfi, undefined, signal); + this.catalogValue = await scanMovieCatalog( + this.vfi, this.cacheKey, undefined, signal); signal?.throwIfAborted(); await this.persistCatalog(signal); signal?.throwIfAborted(); @@ -247,10 +574,11 @@ export class MovieStore { textEncoder.encode(JSON.stringify(this.catalogValue))); signal?.throwIfAborted(); } catch (error) { - if (signal?.aborted) - throw error; - console.warn("OPFS movie catalog write failed; using the attached ISO", error); - this.cache = null; + if (signal?.aborted) throw error; + this.recordCacheWriteFailure( + error, + "the movie catalog could not be persisted", + ); } } @@ -259,9 +587,9 @@ export class MovieStore { signal?.throwIfAborted(); const disc = await openDisc(new BlobSource(file)); signal?.throwIfAborted(); - if (this.cacheKey && disc.cacheKey !== this.cacheKey) - throw new Error("this ISO is a different disc than the cached one -- forget the disc first to switch"); - const catalog = await scanMovieCatalog(disc.vfi, progress, signal); + assertAttachedDiscIdentity(this.cacheKey, disc.cacheKey); + const catalog = await scanMovieCatalog( + disc.vfi, this.cacheKey, progress, signal); signal?.throwIfAborted(); this.vfi = disc.vfi; this.catalogValue = catalog; @@ -274,6 +602,9 @@ export class MovieStore { const catalog = await this.catalog(signal); signal?.throwIfAborted(); const entry = catalog?.entries.find(candidate => candidate.name === name); + const issue = catalog?.issues.find(candidate => candidate.name === name); + if (issue) + throw new Error(`movie ${issue.name} is unavailable: ${issue.reason}`); if (!entry) throw new Error(`movie ${name} is not in this disc catalog`); return entry; } @@ -286,9 +617,18 @@ export class MovieStore { return memory; } if (!this.cache) return null; - const cached = await this.cache.read(key); - signal?.throwIfAborted(); - return cached; + try { + const cached = await this.cache.read(key); + signal?.throwIfAborted(); + return cached; + } catch (error) { + signal?.throwIfAborted(); + this.useAttachedIsoAfterCacheFailure( + error, + "the movie cache is unavailable -- reconnect the same disc or forget the disc cache", + ); + return null; + } } private async cacheWrite(key: string, bytes: Uint8Array): Promise { @@ -300,56 +640,226 @@ export class MovieStore { await this.cache.write(key, bytes); return true; } catch (error) { - console.warn("OPFS movie cache write failed; keeping this session's copy", error); - this.cache = null; + this.recordCacheWriteFailure( + error, + "movie data could not be persisted", + ); this.memory.set(key, bytes); return false; } } - private async readDiscEntry(entry: VfiEntry, signal?: AbortSignal): Promise { + private async readDiscEntry(entry: VfiEntry, + expected: ContentFingerprint | undefined, + signal?: AbortSignal): Promise { signal?.throwIfAborted(); if (!this.vfi) throw new Error("this movie is not cached -- reconnect the same disc to prepare it"); + if (!expected) + throw new Error("the movie catalog source identity is incomplete"); const current = this.vfi.find(entry.path); - if (!current) throw new Error(`${entry.path} is missing from the attached disc`); + if (!current) + throw new Error("a movie source file is missing from the attached disc"); const bytes = await this.vfi.read(current); signal?.throwIfAborted(); + const actual = await fingerprintBytes(bytes); + signal?.throwIfAborted(); + if (!contentFingerprintMatches(expected, actual)) + throw new Error( + "the attached movie source content does not match this catalog " + + "-- reopen the disc to rebuild the movie catalog", + ); return bytes; } - private async source(entry: MovieEntry, signal?: AbortSignal): Promise { + private async readSourceIdentity( + entry: MovieEntry, + signal?: AbortSignal, + ): Promise | null> { + const raw = await this.cachedRead(sourceMetaKey(entry.name), signal); signal?.throwIfAborted(); - const key = `${movieRoot(entry.name)}/${entry.movie.name}`; - const cached = await this.cachedRead(key, signal); + if (!raw) return null; + let value: unknown; + try { + value = JSON.parse(textDecoder.decode(raw)); + } catch (error) { + this.useAttachedIsoAfterCacheFailure( + error, + "the cached movie source metadata is damaged -- reconnect the same disc or remove this movie cache", + ); + return null; + } + const identity = parseSourceIdentity(value, entry, this.cacheKey); + if (!identity) { + this.useAttachedIsoAfterCacheFailure( + new Error("the cached movie source metadata has an invalid schema"), + "the cached movie source metadata is damaged -- reconnect the same disc or remove this movie cache", + ); + return null; + } + return identity; + } + + private async sourceIdentity( + entry: MovieEntry, + signal?: AbortSignal, + ): Promise> { + const identity = await this.readSourceIdentity(entry, signal); + if (!identity) + throw new Error( + "the cached movie source is incomplete -- reconnect the same disc or remove this movie cache", + ); + return identity; + } + private async verifiedCachedSource( + key: string, + expected: ContentFingerprint | undefined, + signal?: AbortSignal, + ): Promise { + if (!expected) + throw new Error("the cached movie source metadata is incomplete"); + const bytes = await this.cachedRead(key, signal); signal?.throwIfAborted(); - if (cached) return cached; - const source = await this.readDiscEntry(entry.movie, signal); + if (!bytes) + throw new Error( + "the cached movie source is incomplete -- reconnect the same disc or remove this movie cache", + ); + const actual = await fingerprintBytes(bytes); signal?.throwIfAborted(); - return source; + if (!contentFingerprintMatches(expected, actual)) + throw new Error( + "the cached movie source is damaged -- reconnect the same disc or remove this movie cache", + ); + return bytes; } - private async subtitles(entry: MovieEntry, signal?: AbortSignal): Promise { + private async source(entry: MovieEntry, + signal?: AbortSignal): Promise { + signal?.throwIfAborted(); + const expected = catalogSourceFingerprints(entry); + if (this.vfi) + return this.readDiscEntry( + entry.movie, + expected.get(entry.movie.name), + signal, + ); + const identities = await this.sourceIdentity(entry, signal); + const key = `${movieRoot(entry.name)}/${entry.movie.name}`; + return this.verifiedCachedSource( + key, identities.get(entry.movie.name), signal); + } + + private async subtitles(entry: MovieEntry, + signal?: AbortSignal): Promise { signal?.throwIfAborted(); if (!entry.subtitleBin || !entry.subtitleSbt) return EMPTY_SUBTITLES; const root = movieRoot(entry.name); - const binKey = `${root}/${entry.subtitleBin.name}`; - const sbtKey = `${root}/${entry.subtitleSbt.name}`; - signal?.throwIfAborted(); - const [cachedBin, cachedSbt] = await Promise.all([ - this.cachedRead(binKey, signal), this.cachedRead(sbtKey, signal), - ]); - signal?.throwIfAborted(); - const [bin, sbt] = await Promise.all([ - cachedBin ?? this.readDiscEntry(entry.subtitleBin, signal), - cachedSbt ?? this.readDiscEntry(entry.subtitleSbt, signal), - ]); + let bin: Uint8Array; + let sbt: Uint8Array; + if (this.vfi) { + const expected = catalogSourceFingerprints(entry); + [bin, sbt] = await Promise.all([ + this.readDiscEntry( + entry.subtitleBin, + expected.get(entry.subtitleBin.name), + signal, + ), + this.readDiscEntry( + entry.subtitleSbt, + expected.get(entry.subtitleSbt.name), + signal, + ), + ]); + } else { + const identities = await this.sourceIdentity(entry, signal); + [bin, sbt] = await Promise.all([ + this.verifiedCachedSource( + `${root}/${entry.subtitleBin.name}`, + identities.get(entry.subtitleBin.name), + signal, + ), + this.verifiedCachedSource( + `${root}/${entry.subtitleSbt.name}`, + identities.get(entry.subtitleSbt.name), + signal, + ), + ]); + } signal?.throwIfAborted(); const cues = parseFmvSubtitles(bin, sbt, entry.name); signal?.throwIfAborted(); return { bin, sbt, cues }; } + private sourceFiles( + entry: MovieEntry, + source: Uint8Array, + subtitles: SubtitleBundle, + ): Array<[string, Uint8Array]> { + const files: Array<[string, Uint8Array]> = [[entry.movie.name, source]]; + if (entry.subtitleBin && subtitles.bin) + files.push([entry.subtitleBin.name, subtitles.bin]); + if (entry.subtitleSbt && subtitles.sbt) + files.push([entry.subtitleSbt.name, subtitles.sbt]); + return files; + } + + private async persistedSourceMatches( + entry: MovieEntry, + source: Uint8Array, + subtitles: SubtitleBundle, + signal?: AbortSignal, + ): Promise { + const raw = await this.cachedRead(sourceMetaKey(entry.name), signal); + signal?.throwIfAborted(); + if (!raw) return false; + let value: unknown; + try { + value = JSON.parse(textDecoder.decode(raw)); + } catch { + return false; + } + const expected = parseSourceIdentity(value, entry, this.cacheKey); + if (!expected) return false; + for (const [name, bytes] of this.sourceFiles(entry, source, subtitles)) { + const actual = await fingerprintBytes(bytes); + signal?.throwIfAborted(); + const fingerprint = expected.get(name); + if (!fingerprint || !contentFingerprintMatches(fingerprint, actual)) + return false; + } + return true; + } + + private async persistSources( + entry: MovieEntry, + source: Uint8Array, + subtitles: SubtitleBundle, + signal?: AbortSignal, + ): Promise { + const files = this.sourceFiles(entry, source, subtitles); + const identities = entry.sourceFingerprints; + signal?.throwIfAborted(); + const root = movieRoot(entry.name); + let persistent = true; + for (const [name, bytes] of files) { + const stored = await this.cacheWrite(`${root}/${name}`, bytes); + persistent = stored && persistent; + signal?.throwIfAborted(); + } + const metadata: MovieSourceIdentity = { + v: 2, + sourceKey: this.cacheKey, + files: identities, + }; + const metadataStored = await this.cacheWrite( + sourceMetaKey(entry.name), + textEncoder.encode(JSON.stringify(metadata)), + ); + signal?.throwIfAborted(); + return persistent && metadataStored; + } + private async demux(entry: MovieEntry, progress: MovieProgress, signal?: AbortSignal): Promise<{ demuxed: FmvDemux; subtitles: SubtitleBundle; sourcePersistent: boolean; @@ -363,23 +873,8 @@ export class MovieStore { progress(0.08, "validating and demuxing"); const demuxed = demuxFmv(source, entry.name); signal?.throwIfAborted(); - const root = movieRoot(entry.name); - signal?.throwIfAborted(); - let sourcePersistent = await this.cacheWrite(`${root}/${entry.movie.name}`, source); - signal?.throwIfAborted(); - - if (entry.subtitleBin && subtitles.bin) { - signal?.throwIfAborted(); - sourcePersistent = await this.cacheWrite( - `${root}/${entry.subtitleBin.name}`, subtitles.bin) && sourcePersistent; - signal?.throwIfAborted(); - } - if (entry.subtitleSbt && subtitles.sbt) { - signal?.throwIfAborted(); - sourcePersistent = await this.cacheWrite( - `${root}/${entry.subtitleSbt.name}`, subtitles.sbt) && sourcePersistent; - signal?.throwIfAborted(); - } + const sourcePersistent = await this.persistSources( + entry, source, subtitles, signal); if (!sourcePersistent) progress(0.09, "persistent storage unavailable; movie source is session-only"); return { demuxed, subtitles, sourcePersistent }; @@ -396,7 +891,16 @@ export class MovieStore { for (const key of keys) { signal?.throwIfAborted(); this.memory.delete(key); - await this.cache?.remove(key); + if (!this.cache) continue; + try { + await this.cache.remove(key); + } catch (error) { + signal?.throwIfAborted(); + this.cacheWarning = `obsolete movie cache data could not be removed ` + + `(${technicalReason(error)}); playback remains available`; + console.warn("Obsolete movie cache cleanup failed", error); + this.cache = null; + } } signal?.throwIfAborted(); } @@ -409,16 +913,6 @@ export class MovieStore { const key = format === "mkv" ? directMkvKey(entry.name, captions) : directMp4Key(entry.name, captions); - const sessionCached = this.memory.has(key); - const cached = await this.cachedRead(key, signal); - signal?.throwIfAborted(); - if (cached) { - progress(1, sessionCached - ? `using session-only ${label}` - : `using cached ${label}`); - return cached; - } - progress(0.02, `reading ${label} source`); const [source, subtitles] = await Promise.all([ this.source(entry, signal), @@ -426,21 +920,25 @@ export class MovieStore { ]); signal?.throwIfAborted(); - progress(0.08, "persisting original movie source"); - const root = movieRoot(entry.name); - let sourcePersistent = await this.cacheWrite( - `${root}/${entry.movie.name}`, source); + const sourceMatches = this.vfi + ? await this.persistedSourceMatches(entry, source, subtitles, signal) + : true; signal?.throwIfAborted(); - if (entry.subtitleBin && subtitles.bin) { - sourcePersistent = await this.cacheWrite( - `${root}/${entry.subtitleBin.name}`, subtitles.bin) && sourcePersistent; - signal?.throwIfAborted(); - } - if (entry.subtitleSbt && subtitles.sbt) { - sourcePersistent = await this.cacheWrite( - `${root}/${entry.subtitleSbt.name}`, subtitles.sbt) && sourcePersistent; + if (sourceMatches) { + const sessionCached = this.memory.has(key); + const cached = await this.cachedRead(key, signal); signal?.throwIfAborted(); + if (cached) { + progress(1, sessionCached + ? `using session-only ${label}` + : `using cached ${label}`); + return cached; + } } + + progress(0.08, "persisting original movie source"); + const sourcePersistent = await this.persistSources( + entry, source, subtitles, signal); if (!sourcePersistent) progress(0.14, "original source ready; cache is session-only"); @@ -521,7 +1019,10 @@ export class MovieStore { const sourceKeys = [`${root}/${entry.movie.name}`]; if (entry.subtitleBin) sourceKeys.push(`${root}/${entry.subtitleBin.name}`); if (entry.subtitleSbt) sourceKeys.push(`${root}/${entry.subtitleSbt.name}`); - const sourceBytes = await this.totalSize(sourceKeys, signal); + const identity = await this.readSourceIdentity(entry, signal); + const sourceBytes = identity + ? await this.totalSize(sourceKeys, signal) + : 0; const exportKeys = [ directMp4Key(name, false), directMp4Key(name, true), @@ -542,9 +1043,19 @@ export class MovieStore { const memory = this.memory.get(key); if (memory) return memory.byteLength; - const bytes = await this.cache?.size(key) ?? 0; - signal?.throwIfAborted(); - return bytes; + if (!this.cache) return 0; + try { + const bytes = await this.cache.size(key); + signal?.throwIfAborted(); + return bytes ?? 0; + } catch (error) { + signal?.throwIfAborted(); + this.useAttachedIsoAfterCacheFailure( + error, + "the movie cache could not be measured -- reconnect the same disc or forget the disc cache", + ); + return 0; + } } private async totalSize(keys: readonly string[], signal?: AbortSignal): Promise { diff --git a/src/se.ts b/src/se.ts index c735a59..2c5e99f 100644 --- a/src/se.ts +++ b/src/se.ts @@ -1,22 +1,40 @@ /* Embedded-SE tab backbone: lazy sound/se extraction, bank catalog, and * worker-side table inspection through the public ae3_synth_se_* API. - * Everything comes from the user's disc and stays in origin-private storage. */ + * Disc data is cached in OPFS when available; otherwise the attached ISO + * remains the source for this session. */ import { BlobSource, OpfsCache, openDisc, type Vfi, type VfiEntry } from "./vendor/extract/index.ts"; +import { + assertAttachedDiscIdentity, + assertCacheSourceIdentity, +} from "./disc-identity.ts"; +import { technicalReason } from "./errors.ts"; +import { + contentFingerprintMatches, + fingerprintBytes, + isContentFingerprint, + type ContentFingerprint, +} from "./content-identity.ts"; +import { assertZipEntryPath } from "./zip.ts"; const META = "se_meta.json"; export type SeProgress = (done: number, total: number, name: string) => void; export interface SeBankEntry { - name: string; /* pair stem, e.g. "gmecha" */ - hd: string; - bd: string; - bytes: number; + readonly name: string; + readonly hd: string; + readonly bd: string; + readonly bytes: number; + readonly hdFingerprint: ContentFingerprint; + readonly bdFingerprint: ContentFingerprint; +} +export interface SeCatalog { + readonly v: 2; + readonly sourceKey: string; + readonly entries: readonly SeBankEntry[]; } - -export interface SeCatalog { v: 1; entries: SeBankEntry[]; } export interface SeBankFiles { hd: Uint8Array; bd: Uint8Array; } export interface SeBytecodeEvent { @@ -89,46 +107,114 @@ export interface SeMeasureFiles extends SeBankFiles { libsd: Uint8Array | null; } + +function isZipEntryName(value: string): boolean { + try { + assertZipEntryPath(value); + return true; + } catch { + return false; + } +} + +function isSeCatalog(value: unknown, expectedSourceKey: string): value is SeCatalog { + if (value === null || typeof value !== "object") return false; + const catalog = value as Record; + if (catalog.v !== 2 || catalog.sourceKey !== expectedSourceKey + || !Array.isArray(catalog.entries) + || catalog.entries.length === 0) + return false; + const names = new Set(); + for (const value of catalog.entries) { + if (value === null || typeof value !== "object") return false; + const entry = value as Record; + if (typeof entry.name !== "string" || entry.name.length === 0 + || names.has(entry.name) + || entry.hd !== `${entry.name}.hd` + || entry.bd !== `${entry.name}.bd` + || !isZipEntryName(entry.hd as string) + || !isZipEntryName(entry.bd as string) + || !Number.isSafeInteger(entry.bytes) + || (entry.bytes as number) <= 0 + || !isContentFingerprint(entry.hdFingerprint) + || !isContentFingerprint(entry.bdFingerprint) + || entry.bytes !== entry.hdFingerprint.bytes + + entry.bdFingerprint.bytes) + return false; + names.add(entry.name); + } + return true; +} export class SeStore { private cache: OpfsCache | null; private vfi: Vfi | null; - private readonly cacheKey: string | null; + private readonly cacheKey: string; private byName = new Map(); private catalog: SeCatalog | null = null; + private cacheWarning: string | null = null; constructor(cache: OpfsCache | null, vfi: Vfi | null, - cacheKey: string | null) { + cacheKey: string) { + assertCacheSourceIdentity(cache, cacheKey); this.cache = cache; this.vfi = vfi; this.cacheKey = cacheKey; } hasIso(): boolean { return this.vfi !== null; } + get persistenceWarning(): string | null { return this.cacheWarning; } async cached(): Promise { if (this.catalog) return this.catalog; if (!this.cache) return null; - const raw = await this.cache.read(META); + let raw: Uint8Array | null; + try { + raw = await this.cache.read(META); + } catch (error) { + this.useIsoAfterCacheFailure( + error, + "the Effects cache is unavailable -- reconnect the same disc to rebuild it or forget the disc cache", + ); + return null; + } if (!raw) return null; + let parsed: unknown; try { - const catalog = JSON.parse(new TextDecoder().decode(raw)) as SeCatalog; - if (catalog.v !== 1 || !Array.isArray(catalog.entries)) return null; - this.catalog = catalog; - return catalog; - } catch { + parsed = JSON.parse(new TextDecoder().decode(raw)); + } catch (error) { + this.useIsoAfterCacheFailure( + error, + "the cached Effects index is damaged -- reconnect the same disc to rebuild it or forget the disc cache", + ); + return null; + } + if (!isSeCatalog(parsed, this.cacheKey)) { + this.useIsoAfterCacheFailure( + new Error("the cached Effects index has an invalid schema"), + "the cached Effects index is damaged -- reconnect the same disc to rebuild it or forget the disc cache", + ); return null; } + this.catalog = parsed; + return parsed; } async attachIso(file: File): Promise { const disc = await openDisc(new BlobSource(file)); - if (this.cacheKey && disc.cacheKey !== this.cacheKey) - throw new Error("this ISO is a different disc than the cached one " - + "-- forget the disc first to switch"); + assertAttachedDiscIdentity(this.cacheKey, disc.cacheKey); this.vfi = disc.vfi; this.byName.clear(); } + private useIsoAfterCacheFailure(error: unknown, message: string): void { + if (!this.vfi) + throw new Error(message, { cause: error }); + this.cacheWarning = `${message} (${technicalReason(error)}); ` + + "using the attached ISO for this session"; + console.warn(`${message}; using the attached ISO`, error); + this.cache = null; + } + private locate(): Map { if (this.byName.size || !this.vfi) return this.byName; const all = this.vfi.entries.filter((entry) => @@ -136,18 +222,30 @@ export class SeStore { const byDir = new Map(); for (const entry of all) { const dir = entry.path.slice(0, entry.path.lastIndexOf("/")); - (byDir.get(dir) ?? byDir.set(dir, []).get(dir)!).push(entry); + let entries = byDir.get(dir); + if (!entries) { + entries = []; + byDir.set(dir, entries); + } + entries.push(entry); + } + let best: VfiEntry[] = []; + for (const entries of byDir.values()) { + if (entries.length > best.length) best = entries; } - const best = [...byDir.values()] - .sort((a, b) => b.length - a.length)[0] ?? []; for (const entry of best) this.byName.set(entry.path.slice(entry.path.lastIndexOf("/") + 1), entry); return this.byName; } - private pairs(): SeBankEntry[] { + private pairs(): Array<{ + name: string; + hd: string; + bd: string; + bytes: number; + }> { const files = this.locate(); - const rows: SeBankEntry[] = []; + const rows: Array<{ name: string; hd: string; bd: string; bytes: number }> = []; for (const [hd, header] of files) { if (!hd.endsWith(".hd")) continue; const name = hd.slice(0, -3); @@ -161,57 +259,306 @@ export class SeStore { async extract(progress: SeProgress): Promise { if (!this.vfi) throw new Error("no ISO attached -- choose your disc image first"); - const rows = this.pairs(); - if (rows.length === 0) + const pairs = this.pairs(); + if (pairs.length === 0) throw new Error("no sound/se bank pairs found in DATA.BIN"); - const files = rows.flatMap((row) => [row.hd, row.bd]); + const total = pairs.length * 2; + const entries: SeBankEntry[] = []; + let done = 0; let cache = this.cache; - for (let i = 0; i < files.length; i++) { - const name = files[i]!; - progress(i, files.length, name); - if (!cache) continue; - try { - if (await cache.has(`se/${name}`)) continue; - } catch (error) { - console.warn("OPFS failed; SE banks from the ISO this visit", error); - cache = null; - continue; + for (const pair of pairs) { + const fingerprints: ContentFingerprint[] = []; + for (const name of [pair.hd, pair.bd]) { + progress(done++, total, name); + const source = this.locate().get(name); + if (!source) throw new Error("an Effects bank source disappeared"); + const data = await this.vfi.read(source); + fingerprints.push(await fingerprintBytes(data)); + if (!cache) continue; + try { + await cache.write(`se/${name}`, data); + } catch (error) { + this.useIsoAfterCacheFailure( + error, + "cached Effects storage is unavailable", + ); + cache = null; + } } - const entry = this.locate().get(name)!; - const data = await this.vfi.read(entry); + entries.push({ + ...pair, + hdFingerprint: fingerprints[0]!, + bdFingerprint: fingerprints[1]!, + }); + } + const catalog: SeCatalog = { + v: 2, + sourceKey: this.cacheKey, + entries, + }; + if (cache) { try { - await cache.write(`se/${name}`, data); + await cache.write(META, + new TextEncoder().encode(JSON.stringify(catalog))); } catch (error) { - console.warn("OPFS write failed; SE banks from the ISO this visit", error); - cache = null; + this.useIsoAfterCacheFailure( + error, + "cached Effects storage is unavailable", + ); } } - const catalog: SeCatalog = { v: 1, entries: rows }; - if (cache) - await cache.write(META, - new TextEncoder().encode(JSON.stringify(catalog))); this.catalog = catalog; - progress(files.length, files.length, "done"); + progress(total, total, "done"); return catalog; } - private async read(name: string): Promise { - if (this.cache) { - const data = await this.cache.read(`se/${name}`); - if (data) return data; + private contentMismatch(message: string): never { + this.cache = null; + this.catalog = null; + throw new Error(message); + } + + private async read(name: string, + expected: ContentFingerprint): Promise { + if (this.vfi) { + const entry = this.locate().get(name); + if (!entry) return null; + const data = await this.vfi.read(entry); + const actual = await fingerprintBytes(data); + if (!contentFingerprintMatches(expected, actual)) + this.contentMismatch( + "the cached Effects index does not match the attached disc content " + + "-- re-extract or forget the disc cache", + ); + return data; + } + if (!this.cache) return null; + let data: Uint8Array | null; + try { + data = await this.cache.read(`se/${name}`); + } catch (error) { + throw new Error( + "cached Effects data is unavailable -- reconnect the same disc or forget the disc cache", + { cause: error }, + ); } - const entry = this.locate().get(name); - return entry && this.vfi ? this.vfi.read(entry) : null; + if (!data) return null; + const actual = await fingerprintBytes(data); + if (!contentFingerprintMatches(expected, actual)) + this.contentMismatch( + "cached Effects data is damaged -- reconnect the same disc or forget the disc cache", + ); + return data; } async bank(entry: SeBankEntry): Promise { - const [hd, bd] = await Promise.all([this.read(entry.hd), this.read(entry.bd)]); + const [hd, bd] = await Promise.all([ + this.read(entry.hd, entry.hdFingerprint), + this.read(entry.bd, entry.bdFingerprint), + ]); if (!hd || !bd) - throw new Error(`missing SE bank ${entry.name} -- re-extract, or re-open the ISO`); + throw new Error("an Effects bank source is missing -- re-extract or re-open the ISO"); return { hd, bd }; } } +function asError(cause: unknown): Error { + return cause instanceof Error ? cause : new Error(String(cause)); +} + +function record(value: unknown): Record | null { + return value !== null && typeof value === "object" + ? value as Record + : null; +} + +function isSafeNonnegativeInteger(value: unknown): value is number { + return typeof value === "number" + && Number.isSafeInteger(value) + && value >= 0; +} + +function isByte(value: unknown): value is number { + return isSafeNonnegativeInteger(value) && value <= 0xff; +} + +function hasOnlyKeys(value: Record, + allowed: ReadonlySet): boolean { + return Object.keys(value).every(key => allowed.has(key)); +} + +const LOOP_KEYS = new Set([ + "startTick", "endTick", "cycleTicks", "count", + "startExactFrame", "endExactFrame", "cycleExactFrames", + "startConsoleFrame", "endConsoleFrame", "cycleConsoleFrames", +]); + +function isSeLoopInfo(value: unknown): value is SeLoopInfo { + const loop = record(value); + if (!loop || !hasOnlyKeys(loop, LOOP_KEYS)) + return false; + for (const key of LOOP_KEYS) { + if (!isSafeNonnegativeInteger(loop[key])) return false; + } + if (!isByte(loop.count)) return false; + const checked = loop as unknown as SeLoopInfo; + return checked.startTick <= checked.endTick + && checked.cycleTicks === checked.endTick - checked.startTick + && checked.startExactFrame <= checked.endExactFrame + && checked.cycleExactFrames + === checked.endExactFrame - checked.startExactFrame + && checked.startConsoleFrame <= checked.endConsoleFrame + && checked.cycleConsoleFrames + === checked.endConsoleFrame - checked.startConsoleFrame; +} + +const EVENT_COMMON_KEYS = [ + "tick", "offset", "exactFrame", "consoleFrame", "kind", +] as const; +const EVENT_NOTE_KEYS = ["key", "velocity", "program"] as const; +const EVENT_CONTROL_KEYS = ["command", "args"] as const; +const EVENT_TONE_KEYS = [ + "source", "sampleLoop", "sampleFrames", "root", "cutGroup", "reverb", + "noise", "adsr1", "adsr2", "envelopeFrames", "indefinite", +] as const; +const BASIC_NOTE_EVENT_KEYS = new Set([ + ...EVENT_COMMON_KEYS, ...EVENT_NOTE_KEYS, +]); +const NOTE_EVENT_KEYS = new Set([ + ...BASIC_NOTE_EVENT_KEYS, ...EVENT_TONE_KEYS, +]); +const CONTROL_EVENT_KEYS = new Set([ + ...EVENT_COMMON_KEYS, ...EVENT_CONTROL_KEYS, +]); + +function hasToneMetadata(event: Record): boolean { + return EVENT_TONE_KEYS.some(key => event[key] !== undefined); +} + +function isToneMetadata(event: Record): boolean { + if (typeof event.source !== "string" + || event.source.length === 0 || event.source.length > 128 + || /[^\x20-\x7e]/.test(event.source) + || typeof event.sampleLoop !== "boolean" + || !isSafeNonnegativeInteger(event.sampleFrames) + || !isByte(event.root) + || !isByte(event.cutGroup) + || typeof event.reverb !== "boolean" + || typeof event.noise !== "boolean" + || !isSafeNonnegativeInteger(event.adsr1) || event.adsr1 > 0xffff + || !isSafeNonnegativeInteger(event.adsr2) || event.adsr2 > 0xffff + || typeof event.indefinite !== "boolean") + return false; + return event.indefinite + ? event.envelopeFrames === undefined + : isSafeNonnegativeInteger(event.envelopeFrames); +} + +function isSeBytecodeEvent( + value: unknown, + info: Pick, +): value is SeBytecodeEvent { + const event = record(value); + if (!event + || !isSafeNonnegativeInteger(event.tick) + || !isSafeNonnegativeInteger(event.offset) + || !isSafeNonnegativeInteger(event.exactFrame) + || !isSafeNonnegativeInteger(event.consoleFrame) + || event.tick > info.durationTicks + || event.exactFrame > info.exactFrames + || event.consoleFrame > info.consoleFrames) + return false; + + if (event.kind === "note" || event.kind === "off") { + const tone = hasToneMetadata(event); + if (!hasOnlyKeys(event, tone ? NOTE_EVENT_KEYS : BASIC_NOTE_EVENT_KEYS) + || !isByte(event.key) + || !isByte(event.velocity) + || !isByte(event.program)) + return false; + if (event.kind === "off") return !tone && event.velocity === 0; + return !tone || isToneMetadata(event); + } + if (event.kind !== "control" && event.kind !== "loop") return false; + if (!hasOnlyKeys(event, CONTROL_EVENT_KEYS) + || !isByte(event.command) + || !Array.isArray(event.args) + || !event.args.every(isByte)) + return false; + const expectedArgs = event.command === 7 + || event.command === 10 + || event.command === 0x41 ? 4 : 3; + return event.args.length === expectedArgs + && (event.kind === "loop" + ? event.command === 0x60 + : event.command !== 0x60); +} + +const REQUEST_KEYS = new Set([ + "durationTicks", "exactFrames", "consoleFrames", "notes", "controls", + "activeVoices", "loopingVoices", "sustainedVoices", "sustained", + "sourceEndExactFrame", "sourceEndConsoleFrame", "loop", "events", +]); +const REQUEST_NUMBER_KEYS = [ + "durationTicks", "exactFrames", "consoleFrames", "notes", "controls", + "activeVoices", "loopingVoices", "sustainedVoices", + "sourceEndExactFrame", "sourceEndConsoleFrame", +] as const; + + +function isSeRequestInfo(value: unknown): value is SeRequestInfo { + const info = record(value); + if (!info || !hasOnlyKeys(info, REQUEST_KEYS) + || REQUEST_NUMBER_KEYS.some(key => + !isSafeNonnegativeInteger(info[key])) + || typeof info.sustained !== "boolean" + || !Array.isArray(info.events)) + return false; + const checked = info as unknown as SeRequestInfo; + if (!(checked.loop === null || isSeLoopInfo(checked.loop)) + || !checked.events.every(event => + isSeBytecodeEvent(event, checked))) + return false; + const notes = checked.events.filter(event => + event.kind === "note" || event.kind === "off").length; + const controls = checked.events.length - notes; + return checked.notes === notes + && checked.controls === controls + && checked.loopingVoices <= checked.activeVoices + && checked.sustainedVoices <= checked.loopingVoices + && checked.sustained === (checked.sustainedVoices > 0) + && checked.sourceEndExactFrame >= checked.exactFrames + && checked.sourceEndConsoleFrame >= checked.consoleFrames; +} + +function isSeInspection(value: unknown): value is SeInspection { + const inspection = record(value); + if (!inspection + || !(inspection.requests instanceof Uint16Array) + || !Array.isArray(inspection.details) + || inspection.details.length !== inspection.requests.length + || !isSafeNonnegativeInteger(inspection.ticksPerSecond) + || inspection.ticksPerSecond === 0) + return false; + const checked = inspection as unknown as SeInspection; + return checked.details.every((details, bank) => + Array.isArray(details) + && details.length === checked.requests[bank] + && details.every(detail => detail === null || isSeRequestInfo(detail))); +} + +const MEASURE_KEYS = new Set(["frames", "sustained", "estimated"]); + +function isSePlaybackMeasure(value: unknown): value is SePlaybackMeasure { + const measure = record(value); + return measure !== null + && hasOnlyKeys(measure, MEASURE_KEYS) + && (measure.frames === null + || isSafeNonnegativeInteger(measure.frames)) + && typeof measure.sustained === "boolean" + && typeof measure.estimated === "boolean"; +} + export class SeInspector { private worker: Worker | null = null; private seq = 0; @@ -224,40 +571,98 @@ export class SeInspector { reject: (error: Error) => void; }>(); + private failWorker(worker: Worker, error: Error): void { + if (this.worker !== worker) return; + this.worker = null; + worker.terminate(); + for (const pending of this.pending.values()) pending.reject(error); + for (const pending of this.measurePending.values()) pending.reject(error); + this.pending.clear(); + this.measurePending.clear(); + } + private ensure(): Worker { if (this.worker) return this.worker; - this.worker = new Worker( - `${import.meta.env.BASE_URL}synth/export-worker.mjs`, - { type: "module" }); - this.worker.onmessage = (event) => { - const message = event.data; - const pending = this.pending.get(message.id) - ?? this.measurePending.get(message.id); - if (!pending) return; - this.pending.delete(message.id); - this.measurePending.delete(message.id); - if (message.t === "error") pending.reject(new Error(message.message)); - else if (message.t === "se-measure-done") - pending.resolve(message.measure); - else - pending.resolve(message.inspection); + const baseUrl = import.meta.env?.BASE_URL ?? "/"; + const worker = new Worker(`${baseUrl}synth/export-worker.mjs`, + { type: "module" }); + this.worker = worker; + worker.onmessage = (event: MessageEvent) => { + try { + const message = record(event.data); + if (!message || !isSafeNonnegativeInteger(message.id) + || message.id === 0) { + this.failWorker(worker, new Error("invalid SE worker response")); + return; + } + const id = message.id; + const pending = this.pending.get(id); + const measurePending = this.measurePending.get(id); + if (!pending && !measurePending) { + this.failWorker(worker, new Error("invalid SE worker response")); + return; + } + if (message.t === "error" + && hasOnlyKeys(message, new Set(["t", "id", "message"])) + && typeof message.message === "string" + && message.message.length > 0) { + const error = new Error(technicalReason(message.message)); + pending?.reject(error); + measurePending?.reject(error); + } else if (message.t === "se-inspect-done" + && hasOnlyKeys(message, new Set(["t", "id", "inspection"])) + && pending && isSeInspection(message.inspection)) { + pending.resolve(message.inspection); + } else if (message.t === "se-measure-done" + && hasOnlyKeys(message, new Set(["t", "id", "measure"])) + && measurePending && isSePlaybackMeasure(message.measure)) { + measurePending.resolve(message.measure); + } else { + this.failWorker(worker, new Error("invalid SE worker response")); + return; + } + this.pending.delete(id); + this.measurePending.delete(id); + } catch { + this.failWorker(worker, new Error("invalid SE worker response")); + } + }; + worker.onerror = (event) => { + event.preventDefault(); + this.failWorker( + worker, + new Error(technicalReason(event.message || "SE inspector worker failed")), + ); }; - this.worker.onerror = (event) => { - const error = new Error(event.message || "SE inspector worker failed"); - for (const pending of this.pending.values()) pending.reject(error); - for (const pending of this.measurePending.values()) pending.reject(error); - this.pending.clear(); - this.measurePending.clear(); + worker.onmessageerror = () => { + this.failWorker(worker, new Error("invalid SE worker response")); }; - return this.worker; + return worker; + } + + dispose(): void { + const error = new Error("SE operation cancelled for another disc"); + if (this.worker) { + this.failWorker(this.worker, error); + return; + } + for (const pending of this.pending.values()) pending.reject(error); + for (const pending of this.measurePending.values()) pending.reject(error); + this.pending.clear(); + this.measurePending.clear(); } inspect(files: SeBankFiles): Promise { const id = ++this.seq; const { promise, resolve, reject } = Promise.withResolvers(); this.pending.set(id, { resolve, reject }); - this.ensure().postMessage({ t: "se-inspect", id, files }, - [files.hd.buffer, files.bd.buffer]); + try { + this.ensure().postMessage({ t: "se-inspect", id, files }, + [files.hd.buffer, files.bd.buffer]); + } catch (cause) { + this.pending.delete(id); + reject(asError(cause)); + } return promise; } @@ -270,10 +675,15 @@ export class SeInspector { const buffers = [files.hd, files.bd, files.irx, files.libsd] .filter((file) => file !== null) .map((file) => file.buffer); - this.ensure().postMessage({ - t: "se-measure", id, files, - opts: { bank, request, exact, revDepth }, - }, buffers); + try { + this.ensure().postMessage({ + t: "se-measure", id, files, + opts: { bank, request, exact, revDepth }, + }, buffers); + } catch (cause) { + this.measurePending.delete(id); + reject(asError(cause)); + } return promise; } } diff --git a/src/streams.ts b/src/streams.ts index a86dac0..f4c9e52 100644 --- a/src/streams.ts +++ b/src/streams.ts @@ -14,6 +14,11 @@ import { OpfsCache, openDisc, BlobSource, type Vfi, type VfiEntry } from "./vendor/extract/index.ts"; +import { + assertAttachedDiscIdentity, + assertCacheSourceIdentity, +} from "./disc-identity.ts"; +import { technicalReason } from "./errors.ts"; const META = "stream_meta.json"; @@ -27,7 +32,11 @@ export interface StreamEntry { length: number; /* header length field; 16 files overstate (spec ยง4) */ } -export interface StreamCatalog { v: 1; entries: StreamEntry[]; } +export interface StreamCatalog { + readonly v: 2; + readonly sourceKey: string; + readonly entries: readonly StreamEntry[]; +} /* Catalog-time header read (magic + channels s16@+0x06 + rate u32@+0x08 + * length u32@+0x14, little-endian) -- the same fields ae3_exst_parse @@ -50,7 +59,7 @@ function xHeader(b: Uint8Array, size: number, name: string): Omit(), voice: new Map() }; @@ -67,17 +76,52 @@ export function groupStreams(entries: StreamEntry[]): return { music: order(buckets.music), voice: order(buckets.voice) }; } +const STREAM_NAME = /^[^/\\\u0000-\u001f\u007f-\u009f]+\.x$/i; + +function isSafeInteger(value: unknown, minimum = 0): value is number { + return typeof value === "number" + && Number.isSafeInteger(value) + && value >= minimum; +} + +function isStreamCatalog(value: unknown, + expectedSourceKey: string): value is StreamCatalog { + if (value === null || typeof value !== "object") return false; + const catalog = value as Record; + if (catalog.v !== 2 || catalog.sourceKey !== expectedSourceKey + || !Array.isArray(catalog.entries) + || catalog.entries.length === 0) + return false; + const names = new Set(); + return catalog.entries.every(value => { + if (value === null || typeof value !== "object") return false; + const entry = value as Record; + if (typeof entry.name !== "string" + || !STREAM_NAME.test(entry.name) + || names.has(entry.name) + || !isSafeInteger(entry.channels, 1) || entry.channels > 8 + || !isSafeInteger(entry.rate, 1) + || !isSafeInteger(entry.sectors) + || !isSafeInteger(entry.length)) + return false; + names.add(entry.name); + return true; + }); +} + /* ---- store: OPFS phase + payload access ---------------------------------- */ export class StreamStore { private cache: OpfsCache | null; private vfi: Vfi | null; - private readonly cacheKey: string | null; + private readonly cacheKey: string; private byName = new Map(); private catalog: StreamCatalog | null = null; /* in-memory (no-OPFS runs) */ + private cacheWarning: string | null = null; constructor(cache: OpfsCache | null, vfi: Vfi | null, - cacheKey: string | null) { + cacheKey: string) { + assertCacheSourceIdentity(cache, cacheKey); this.cache = cache; this.vfi = vfi; this.cacheKey = cacheKey; @@ -85,35 +129,63 @@ export class StreamStore { /** ISO reachable this session (constructed from one, or attachIso ran). */ hasIso(): boolean { return this.vfi !== null; } + get persistenceWarning(): string | null { return this.cacheWarning; } /** Completed catalog, if the streams phase ever finished (or ran * cache-less this session). null = show the setup panel. */ async cached(): Promise { if (this.catalog) return this.catalog; if (!this.cache) return null; - const raw = await this.cache.read(META); + let raw: Uint8Array | null; + try { + raw = await this.cache.read(META); + } catch (error) { + this.useAttachedIsoAfterCacheFailure( + error, + "the Streams cache is unavailable -- reconnect the same disc to rebuild it or forget the disc cache", + ); + return null; + } if (!raw) return null; + let parsed: unknown; try { - const c = JSON.parse(new TextDecoder().decode(raw)) as StreamCatalog; - if (c.v !== 1 || !Array.isArray(c.entries)) return null; - this.catalog = c; - return c; - } catch { + parsed = JSON.parse(new TextDecoder().decode(raw)); + } catch (error) { + this.useAttachedIsoAfterCacheFailure( + error, + "the cached Streams index is damaged -- reconnect the same disc to rebuild it or forget the disc cache", + ); + return null; + } + if (!isStreamCatalog(parsed, this.cacheKey)) { + this.useAttachedIsoAfterCacheFailure( + new Error("the cached Streams index has an invalid schema"), + "the cached Streams index is damaged -- reconnect the same disc to rebuild it or forget the disc cache", + ); return null; } + this.catalog = parsed; + return parsed; } /** Re-attach the ISO for a session resumed from OPFS. Refuses a * different disc: the streams would land under the wrong cache key. */ async attachIso(file: File): Promise { const disc = await openDisc(new BlobSource(file)); - if (this.cacheKey && disc.cacheKey !== this.cacheKey) - throw new Error("this ISO is a different disc than the cached one " - + "-- forget the disc first to switch"); + assertAttachedDiscIdentity(this.cacheKey, disc.cacheKey); this.vfi = disc.vfi; this.byName.clear(); } + private useAttachedIsoAfterCacheFailure(error: unknown, message: string): void { + if (!this.vfi) throw new Error(message, { cause: error }); + this.cacheWarning = `${message} (${technicalReason(error)}); ` + + "using the attached ISO for this session"; + console.warn("Streams cache failed; using the attached ISO", error); + this.cache = null; + this.byName.clear(); + } + private locate(): Map { if (this.byName.size || !this.vfi) return this.byName; /* region-tolerant like locateBgmAssets: best-populated stream dir */ @@ -152,23 +224,43 @@ export class StreamStore { if (!cache) continue; try { if (await cache.has(`stream/${name}`)) continue; - } catch (err) { - console.warn("OPFS failed; streams from the ISO this visit", err); + } catch (error) { + this.useAttachedIsoAfterCacheFailure( + error, + "the Streams cache is unavailable", + ); cache = null; continue; } const data = await this.vfi.read(e); /* hard failure */ try { await cache.write(`stream/${name}`, data); - } catch (err) { - console.warn("OPFS write failed; streams from the ISO this visit", err); + } catch (error) { + this.useAttachedIsoAfterCacheFailure( + error, + "the Streams cache is unavailable", + ); cache = null; } } - const catalog: StreamCatalog = { v: 1, entries: rows }; - if (cache) - await cache.write(META, - new TextEncoder().encode(JSON.stringify(catalog))); + const catalog: StreamCatalog = { + v: 2, + sourceKey: this.cacheKey, + entries: rows, + }; + if (cache) { + try { + await cache.write( + META, + new TextEncoder().encode(JSON.stringify(catalog)), + ); + } catch (error) { + this.useAttachedIsoAfterCacheFailure( + error, + "the Streams index could not be persisted", + ); + } + } this.catalog = catalog; progress(entries.length, entries.length, "done"); return catalog; @@ -176,12 +268,21 @@ export class StreamStore { /** One stream's bytes: OPFS first, ISO fallback when in hand. */ async read(name: string): Promise { + if (!STREAM_NAME.test(name)) + throw new Error("the stream name is invalid"); if (this.cache) { - const b = await this.cache.read(`stream/${name}`); - if (b) return b; + try { + const bytes = await this.cache.read(`stream/${name}`); + if (bytes) return bytes; + } catch (error) { + this.useAttachedIsoAfterCacheFailure( + error, + "the Streams cache is unavailable", + ); + } } - const e = this.locate().get(name); - return e && this.vfi ? this.vfi.read(e) : null; + const entry = this.locate().get(name); + return entry && this.vfi ? this.vfi.read(entry) : null; } } @@ -197,6 +298,93 @@ export interface DecodedStream { samplesPerChannel: number; /* untrimmed */ pcm: Int16Array; /* untrimmed, interleaved */ } +interface StreamDone { + readonly t: "stream-done"; + readonly id: number; + readonly header: DecodedStream["header"]; + readonly sectors: number; + readonly padFrames: number; + readonly samplesPerChannel: number; + readonly pcm: Int16Array; +} + +interface StreamWavDone { + readonly t: "stream-wav-done"; + readonly id: number; + readonly name: string; + readonly wav: Uint8Array; +} + +type StreamWorkerSuccess = StreamDone | StreamWavDone; +type StreamPendingKind = "decode" | "wav"; + +function objectRecord(value: unknown): Record | null { + return value !== null && typeof value === "object" + ? value as Record + : null; +} + +function hasOnlyKeys( + value: Record, + allowed: ReadonlySet, +): boolean { + return Object.keys(value).every(key => allowed.has(key)); +} + +const STREAM_ERROR_KEYS = new Set(["t", "id", "message"]); + +function isIntegerArray(value: unknown, length: number): value is number[] { + return Array.isArray(value) + && value.length === length + && value.every(item => typeof item === "number" + && Number.isSafeInteger(item)); +} + +function isStreamDone(value: unknown): value is StreamDone { + const message = objectRecord(value); + const header = objectRecord(message?.header); + if (!message || message.t !== "stream-done" + || !hasOnlyKeys(message, new Set([ + "t", "id", "header", "sectors", "padFrames", + "samplesPerChannel", "pcm", + ])) + || !isSafeInteger(message.id, 1) + || !header + || !hasOnlyKeys(header, new Set([ + "channels", "rate", "loop", "loop_start", "length", + "vol_l", "vol_r", "reverb", + ])) + || !isSafeInteger(header.channels, 1) || header.channels > 8 + || !isSafeInteger(header.rate, 1) + || !(header.loop === 0 || header.loop === 1) + || !isSafeInteger(header.loop_start, -1) + || !isSafeInteger(header.length) + || !isIntegerArray(header.vol_l, 8) + || !isIntegerArray(header.vol_r, 8) + || !isIntegerArray(header.reverb, 8) + || !isSafeInteger(message.sectors) + || !isSafeInteger(message.padFrames) + || !isSafeInteger(message.samplesPerChannel) + || !(message.pcm instanceof Int16Array)) + return false; + const samples = message.samplesPerChannel as number; + const channels = header.channels as number; + return Number.isSafeInteger(samples * channels) + && message.pcm.length === samples * channels + && (message.padFrames as number) * 28 <= samples; +} + +function isStreamWavDone(value: unknown, expectedName: string): value is StreamWavDone { + const message = objectRecord(value); + return message !== null + && hasOnlyKeys(message, new Set(["t", "id", "name", "wav"])) + && message.t === "stream-wav-done" + && isSafeInteger(message.id, 1) + && message.name === expectedName + && message.wav instanceof Uint8Array + && message.wav.byteLength >= 44; +} + /* Own worker (same script as the exporter's): stream decodes never queue * behind a WAV render, and vice versa. */ @@ -204,52 +392,146 @@ export class StreamDecoder { private worker: Worker | null = null; private seq = 0; private pending = new Map void; reject: (e: Error) => void }>(); + kind: StreamPendingKind; + name: string; + resolve: (value: StreamWorkerSuccess) => void; + reject: (error: Error) => void; + }>(); + + private failWorker(worker: Worker, error: Error): void { + if (this.worker !== worker) return; + worker.terminate(); + this.worker = null; + for (const pending of this.pending.values()) pending.reject(error); + this.pending.clear(); + } private ensure(): Worker { if (this.worker) return this.worker; - this.worker = new Worker( - `${import.meta.env.BASE_URL}synth/export-worker.mjs`, - { type: "module" }); - this.worker.onmessage = (e) => { - const m = e.data; - const p = this.pending.get(m.id); - if (!p) return; - this.pending.delete(m.id); - if (m.t === "error") p.reject(new Error(m.message)); - else p.resolve(m); + const worker = new Worker( + `${import.meta.env?.BASE_URL ?? "/"}synth/export-worker.mjs`, + { type: "module" }, + ); + this.worker = worker; + worker.onmessage = event => { + if (this.worker !== worker) return; + const message = objectRecord(event.data); + if (!message || !isSafeInteger(message.id, 1)) { + this.failWorker(worker, new Error("invalid stream worker response")); + return; + } + const id = message.id; + const pending = this.pending.get(id); + if (!pending) { + this.failWorker(worker, new Error("invalid stream worker response")); + return; + } + if (message.t === "error") { + if (!hasOnlyKeys(message, STREAM_ERROR_KEYS) + || typeof message.message !== "string" + || message.message.length === 0) { + this.failWorker(worker, new Error("invalid stream worker response")); + return; + } + this.pending.delete(id); + pending.reject(new Error(technicalReason(message.message))); + return; + } + let result: StreamWorkerSuccess; + if (pending.kind === "decode") { + if (!isStreamDone(message)) { + this.failWorker(worker, new Error("invalid stream worker response")); + return; + } + result = message; + } else { + if (!isStreamWavDone(message, pending.name)) { + this.failWorker(worker, new Error("invalid stream worker response")); + return; + } + result = message; + } + this.pending.delete(id); + pending.resolve(result); + }; + worker.onerror = event => { + event.preventDefault(); + this.failWorker( + worker, + new Error(technicalReason(event.message || "stream worker failed")), + ); }; - this.worker.onerror = (e) => { - const err = new Error(e.message || "stream worker failed"); - for (const p of this.pending.values()) p.reject(err); - this.pending.clear(); + worker.onmessageerror = () => { + this.failWorker(worker, new Error("invalid stream worker response")); }; - return this.worker; + return worker; } - private call(msg: Record, - transfer: Transferable[]): Promise { + dispose(): void { + const error = new Error("stream operation cancelled for another disc"); + if (this.worker) { + this.failWorker(this.worker, error); + return; + } + for (const pending of this.pending.values()) pending.reject(error); + this.pending.clear(); + } + + private call(kind: "decode", name: string, + msg: Record, + transfer: Transferable[]): Promise; + private call(kind: "wav", name: string, + msg: Record, + transfer: Transferable[]): Promise; + private call(kind: StreamPendingKind, name: string, + msg: Record, + transfer: Transferable[]): Promise { const id = ++this.seq; - return new Promise((resolve, reject) => { - this.pending.set(id, { resolve, reject }); + const { promise, resolve, reject } = + Promise.withResolvers(); + this.pending.set(id, { kind, name, resolve, reject }); + try { this.ensure().postMessage({ ...msg, id }, transfer); - }); + } catch (cause) { + const error = cause instanceof Error ? cause : new Error(String(cause)); + const worker = this.worker; + if (worker) this.failWorker(worker, error); + else { + this.pending.delete(id); + reject(error); + } + } + return promise; } /** Full untrimmed decode (the UI derives the trimmed view locally). */ async decode(name: string, file: Uint8Array): Promise { - const m = await this.call({ t: "stream", file }, [file.buffer]); - return { name, header: m.header, sectors: m.sectors, - padFrames: m.padFrames, - samplesPerChannel: m.samplesPerChannel, pcm: m.pcm }; + const message = await this.call( + "decode", + name, + { t: "stream", file }, + [file.buffer], + ); + return { + name, + header: message.header, + sectors: message.sectors, + padFrames: message.padFrames, + samplesPerChannel: message.samplesPerChannel, + pcm: message.pcm, + }; } /** WAV bytes exactly as `ae3 exst --decode [--trim-pad]` frames them. */ async wav(name: string, file: Uint8Array, trimPad: boolean): Promise { - const m = await this.call( - { t: "stream-wav", file, trimPad, name }, [file.buffer]); - return m.wav; + const message = await this.call( + "wav", + name, + { t: "stream-wav", file, trimPad, name }, + [file.buffer], + ); + return message.wav; } } @@ -261,6 +543,7 @@ export class StreamDecoder { * tail samples, so no re-decode round-trip. */ export class StreamPlayer { onended: (() => void) | null = null; + onerror: ((error: Error) => void) | null = null; private ctx: AudioContext | null = null; private node: AudioBufferSourceNode | null = null; private stream: DecodedStream | null = null; @@ -269,13 +552,20 @@ export class StreamPlayer { private playing_ = false; private t0 = 0; /* ctx.currentTime at start */ private off = 0; /* seconds into the buffer at start */ + private generation = 0; /** Lazy context -- call from a user gesture (Safari autoplay policy, * same stance as the BGM player). */ - private ensureCtx(): AudioContext { + private ensureCtx(generation: number): AudioContext { this.ctx ??= new AudioContext(); - void this.ctx.resume(); - return this.ctx; + const ctx = this.ctx; + void ctx.resume().catch(cause => { + if (this.ctx !== ctx || this.generation !== generation) return; + const error = cause instanceof Error ? cause : new Error(String(cause)); + this.stopNode(); + this.onerror?.(error); + }); + return ctx; } private samples(trim: boolean): number { @@ -335,7 +625,8 @@ export class StreamPlayer { play(): void { if (!this.stream || this.playing_) return; - const ctx = this.ensureCtx(); + const generation = ++this.generation; + const ctx = this.ensureCtx(generation); if (this.off >= this.dur()) this.off = 0; /* SPACE after end restarts */ const node = ctx.createBufferSource(); node.buffer = this.buffer(this.trim); @@ -354,10 +645,17 @@ export class StreamPlayer { } private stopNode(): void { + this.generation++; if (this.node) { - const n = this.node; + const node = this.node; this.node = null; /* mute onended */ - try { n.stop(); } catch { /* not started */ } + try { + node.stop(); + } catch (error) { + if (!(error instanceof DOMException + && error.name === "InvalidStateError")) + console.error("stream source stop failed", error); + } } this.playing_ = false; } diff --git a/src/vendor/SDK_COMMIT b/src/vendor/SDK_COMMIT index 375e233..981207d 100644 --- a/src/vendor/SDK_COMMIT +++ b/src/vendor/SDK_COMMIT @@ -1,2 +1,2 @@ -ae3-sdk 3586deb170ac53dd2a5dd91423534e3fbc591e96 -synced 2026-08-09T04:52:19Z +ae3-sdk 5b2207e3580ae4cc1d588641f4fe3f898350e501 +synced 2026-08-10T02:23:12Z diff --git a/src/vendor/extract/fmv.ts b/src/vendor/extract/fmv.ts index 890eca2..80fe750 100644 --- a/src/vendor/extract/fmv.ts +++ b/src/vendor/extract/fmv.ts @@ -5,9 +5,19 @@ import { f32, u32 } from "./bytes.ts"; import { type Vfi, type VfiEntry } from "./vfi.ts"; const SECTOR = 0x800; +const FIVE_AUDIO_TRACKS = 5; +const FIRST_GROUP_PROBE_BYTES = 80; +const MAX_FIRST_VIDEO_INSPECTION_BYTES = 0x10000; +const MAX_FMV_INSPECTION_PREFIX_BYTES = 0x70000; +const MAX_WAV_BYTES = 64 * 1024 * 1024; +const UINT16_MAX = 0xffff; +const UINT32_MAX = 0xffffffff; const GROUP_TAG = "GroupOfDataInfo"; const VIDEO_TAG = "Mpeg2Video"; -const GROUP_TAG_BYTES = new TextEncoder().encode(`${GROUP_TAG}\0`); +const ENCODER = new TextEncoder(); +const ASCII = new TextDecoder("ascii"); +const GROUP_TAG_BYTES = fixedTag(GROUP_TAG); +const VIDEO_TAG_BYTES = fixedTag(VIDEO_TAG); const UTF8 = new TextDecoder("utf-8", { fatal: true }); const FRAME_RATES: Readonly> = { 1: 24000 / 1001, @@ -30,6 +40,28 @@ export interface FmvAsset { subtitleSbt: VfiEntry | null; } +export class FmvFormatError extends Error { + readonly source: string; + readonly offset: number; + readonly detail: string; + + constructor(source: string, offset: number, detail: string) { + super(`${source} at 0x${offset.toString(16)}: ${detail}`); + this.name = "FmvFormatError"; + this.source = source; + this.offset = offset; + this.detail = detail; + } +} + +export interface FmvDiscoveryIssue { + name: string; + movie: VfiEntry; + formatError: FmvFormatError; +} + +export type FmvDiscovery = FmvAsset | FmvDiscoveryIssue; + export interface FmvHeader { fields: number; fieldRate: number; @@ -47,7 +79,7 @@ export interface FmvVideoInfo { height: number; frameRate: number; fieldOrder: "progressive" | "tt" | "bb"; - sampleAspect: readonly [7, 6]; + sampleAspect: readonly [number, number]; displayAspect: readonly [number, number]; } @@ -87,6 +119,12 @@ interface ChunkRange { size: number; } +interface ContainerStart { + groupOffset: number; + audioTracks: 1 | 5; + preloadStart: number; +} + interface ContainerLayout { header: FmvHeader; video: ChunkRange[]; @@ -94,17 +132,32 @@ interface ContainerLayout { groups: FmvGroup[]; videoBytes: number; } +interface WavLayout { + samplesPerChannel: number; + bodyBytes: number; + totalBytes: number; + riffBytes: number; + byteRate: number; + blockAlign: number; +} + function fail(source: string, offset: number, message: string): never { + throw new FmvFormatError(source, offset, message); +} + +function sourceFailure(source: string, offset: number, message: string): never { throw new Error(`${source} at 0x${offset.toString(16)}: ${message}`); } function requireRange(bytes: Uint8Array, offset: number, size: number, source: string, label: string): void { + const end = offset + size; if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(size) - || offset < 0 || size < 0 || offset + size > bytes.length) + || !Number.isSafeInteger(end) || offset < 0 || size < 0 + || end > bytes.length) fail(source, Math.max(0, offset), - `${label} range ends at 0x${(offset + size).toString(16)}, ` + `${label} range ends at 0x${end.toString(16)}, ` + `past EOF 0x${bytes.length.toString(16)}`); } @@ -119,14 +172,36 @@ function gcd(a: number, b: number): number { return a; } -function tagAt(bytes: Uint8Array, offset: number): string { +function fixedTag(tag: string): Uint8Array { + const bytes = new Uint8Array(16); + bytes.set(ENCODER.encode(tag)); + return bytes; +} + +function matchesTag(bytes: Uint8Array, offset: number, tag: Uint8Array): boolean { + if (offset < 0 || offset + tag.length > bytes.length) return false; + for (let i = 0; i < tag.length; i++) + if (bytes[offset + i] !== tag[i]) return false; + return true; +} + +function describeTag(bytes: Uint8Array, offset: number): string { + if (offset < 0 || offset + 16 > bytes.length) return "truncated data"; let end = offset; - while (end < offset + 16 && bytes[end] !== 0) end++; - return new TextDecoder("ascii").decode(bytes.subarray(offset, end)); + while (end < offset + 16 && bytes[end] !== 0) { + if (bytes[end] < 0x20 || bytes[end] > 0x7e) return "non-ASCII data"; + end++; + } + if (end === offset) return "empty data"; + const value = ASCII.decode(bytes.subarray(offset, end)); + if (end === offset + 16) return `"${value}" without NUL padding`; + if (!allZero(bytes, end, offset + 16)) return `"${value}" with nonzero tag padding`; + return `"${value}"`; } -function findTag(bytes: Uint8Array, tag: Uint8Array, start: number): number { - const limit = bytes.length - tag.length; +function findTag(bytes: Uint8Array, tag: Uint8Array, start: number, + last = bytes.length - tag.length): number { + const limit = Math.min(bytes.length - tag.length, last); outer: for (let offset = start; offset <= limit; offset++) { for (let i = 0; i < tag.length; i++) if (bytes[offset + i] !== tag[i]) continue outer; @@ -135,7 +210,7 @@ function findTag(bytes: Uint8Array, tag: Uint8Array, start: number): number { return -1; } -export function locateFmvAssets(vfi: Vfi): FmvAsset[] { +export function locateFmvAssets(vfi: Vfi): FmvDiscovery[] { const movies = vfi.entries.filter(entry => /(^|\/)movie\/[^/]+\.str$/i.test(entry.path)); if (movies.length === 0) throw new Error("no movie/*.str assets found in DATA.BIN"); @@ -162,7 +237,15 @@ export function locateFmvAssets(vfi: Vfi): FmvAsset[] { const subtitleBin = inDirectory.get(`${key}.bin`) ?? null; const subtitleSbt = inDirectory.get(`${key}.sbt`) ?? null; if ((subtitleBin === null) !== (subtitleSbt === null)) - throw new Error(`${movie.path}: incomplete subtitle pair for ${key}`); + return { + name, + movie, + formatError: new FmvFormatError( + movie.path, + 0, + `incomplete subtitle pair for ${key}`, + ), + }; return { name, movie, subtitleBin, subtitleSbt }; }).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); } @@ -185,11 +268,18 @@ export function parseFmvHeader(bytes: Uint8Array, source = "FMV"): FmvHeader { if ([fields, rawFieldRate, groups, sampleRate, channels, interleave, audioBlock, preload, audioBytes].some(value => value === 0)) fail(source, 0x08, "zero required header value"); - const channelGroup = interleave * channels; - if (!Number.isSafeInteger(channelGroup) || interleave % 16 !== 0 - || audioBlock % channelGroup !== 0 || preload % channelGroup !== 0) + if (sampleRate !== 48000) + fail(source, 0x20, `unsupported sample rate ${sampleRate}; expected 48000`); + if (channels !== 2) + fail(source, 0x24, `unsupported channel count ${channels}; expected stereo`); + const interleaveGroupBytes = interleave * channels; + if (!Number.isSafeInteger(interleaveGroupBytes) || interleave % 16 !== 0 + || audioBlock % interleaveGroupBytes !== 0 + || preload % interleaveGroupBytes !== 0) fail(source, 0x24, "invalid channel/interleave arithmetic"); const expectedAudio = preload + (groups - 1) * audioBlock; + if (!Number.isSafeInteger(expectedAudio) || expectedAudio > UINT32_MAX) + fail(source, 0x34, "audio total exceeds the header's u32 range"); if (audioBytes !== expectedAudio) fail(source, 0x34, `audio total ${audioBytes} != ${expectedAudio}`); return { @@ -206,33 +296,128 @@ export function parseFmvHeader(bytes: Uint8Array, source = "FMV"): FmvHeader { } function readChunk(bytes: Uint8Array, offset: number, expected: string, - source: string): { range: ChunkRange; next: number } { + expectedBytes: Uint8Array, source: string): + { range: ChunkRange; next: number; index: number } { requireRange(bytes, offset, 32, source, `${expected} header`); - const actual = tagAt(bytes, offset); - if (actual !== expected) fail(source, offset, `expected ${expected}, found ${actual || "empty"}`); + if (!matchesTag(bytes, offset, expectedBytes)) + fail(source, offset, `expected ${expected}, found ${describeTag(bytes, offset)}`); + const index = u32(bytes, offset + 0x10); const size = u32(bytes, offset + 0x14); if (u32(bytes, offset + 0x18) !== 0 || u32(bytes, offset + 0x1c) !== 0) fail(source, offset + 0x18, "nonzero chunk reserved word"); const start = offset + 32; - const paddedEnd = start + ((size + 15) & ~15); + const paddedEnd = start + Math.ceil(size / 16) * 16; requireRange(bytes, start, size, source, `${expected} payload`); requireRange(bytes, start, paddedEnd - start, source, `${expected} padded payload`); if (!allZero(bytes, start + size, paddedEnd)) fail(source, start + size, "nonzero chunk padding"); - return { range: { start, size }, next: paddedEnd }; + return { range: { start, size }, next: paddedEnd, index }; +} + +function firstGroupOffsets(header: FmvHeader): { oneTrack: number; fiveTrack: number } { + const interleaveGroupBytes = header.interleave * header.channels; + return { + oneTrack: SECTOR + header.preload, + fiveTrack: SECTOR + FIVE_AUDIO_TRACKS * (header.preload + 2 * interleaveGroupBytes), + }; +} +async function readFmvAssetRange(vfi: Vfi, movie: VfiEntry, base: number, + offset: number, size: number, source: string, + label: string): Promise { + const end = offset + size; + if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(size) + || !Number.isSafeInteger(end) || offset < 0 || size < 0 + || end > movie.size) + fail(source, Math.max(0, offset), + `${label} ends at 0x${end.toString(16)}, ` + + `past movie EOF 0x${movie.size.toString(16)}`); + const absolute = base + offset; + const absoluteEnd = absolute + size; + if (!Number.isSafeInteger(absolute) || !Number.isSafeInteger(absoluteEnd) + || absolute < 0 || absoluteEnd > vfi.src.size) + sourceFailure(source, offset, + `${label} ends past source EOF 0x${vfi.src.size.toString(16)}`); + const data = await vfi.src.read(absolute, size); + if (data.length !== size) + sourceFailure(source, offset + data.length, + `short read (${data.length} of ${size} ${label} bytes)`); + return data; +} + + +function locateContainerStart(bytes: Uint8Array, header: FmvHeader, + source: string): ContainerStart { + const offsets = firstGroupOffsets(header); + if (matchesTag(bytes, offsets.oneTrack, GROUP_TAG_BYTES)) + return { groupOffset: offsets.oneTrack, audioTracks: 1, preloadStart: SECTOR }; + if (matchesTag(bytes, offsets.fiveTrack, GROUP_TAG_BYTES)) { + return { + groupOffset: offsets.fiveTrack, + audioTracks: FIVE_AUDIO_TRACKS, + preloadStart: offsets.fiveTrack - FIVE_AUDIO_TRACKS * header.preload, + }; + } + fail(source, offsets.oneTrack, + `expected first ${GROUP_TAG} at one-track offset 0x${offsets.oneTrack.toString(16)} ` + + `or five-track offset 0x${offsets.fiveTrack.toString(16)}; found ` + + `${describeTag(bytes, offsets.oneTrack)} and ` + + `${describeTag(bytes, offsets.fiveTrack)}`); +} + +function validateAudioRange(bytes: Uint8Array, range: ChunkRange, header: FmvHeader, + source: string, label: string): void { + const interleaveGroupBytes = header.interleave * header.channels; + requireRange(bytes, range.start, range.size, source, label); + if (range.size % interleaveGroupBytes !== 0) + fail(source, range.start, `${label} is not interleave-group aligned`); + for (let frame = range.start; frame < range.start + range.size; frame += 16) { + const filter = bytes[frame] >> 4; + const shift = bytes[frame] & 0x0f; + const flags = bytes[frame + 1]; + if (filter >= ADPCM_COEFFICIENTS.length) + fail(source, frame, `${label} uses unsupported ADPCM filter ${filter}`); + if (shift > 12) + fail(source, frame, `${label} uses unsupported ADPCM shift ${shift}`); + if (flags !== 0 && flags !== 2) + fail(source, frame + 1, + `${label} uses unsupported ADPCM flags 0x${flags.toString(16)}`); + } +} + +function audioPreloads(bytes: Uint8Array, header: FmvHeader, start: ContainerStart, + source: string): ChunkRange[] { + requireRange(bytes, SECTOR, start.groupOffset - SECTOR, source, + `${start.audioTracks}-track pre-group region`); + const ranges: ChunkRange[] = []; + for (let track = 0; track < start.audioTracks; track++) { + const range = { + start: start.preloadStart + track * header.preload, + size: header.preload, + }; + validateAudioRange(bytes, range, header, source, `audio track ${track} preload`); + ranges.push(range); + } + return ranges; } function inspectContainer(bytes: Uint8Array, source: string): ContainerLayout { const header = parseFmvHeader(bytes, source); - requireRange(bytes, SECTOR, header.preload, source, "audio preload"); + const start = locateContainerStart(bytes, header, source); + const preloads = audioPreloads(bytes, header, start, source); const video: ChunkRange[] = []; - const audio: ChunkRange[] = [{ start: SECTOR, size: header.preload }]; + const audio: ChunkRange[] = [preloads[0]]; const groups: FmvGroup[] = []; - let offset = SECTOR + header.preload; + let offset = start.groupOffset; + let fieldOffset = 0; + let lastVideoIndex = 0; let videoBytes = 0; for (let groupIndex = 0; groupIndex < header.groups; groupIndex++) { - const groupChunk = readChunk(bytes, offset, GROUP_TAG, source); + const groupChunk = readChunk( + bytes, offset, GROUP_TAG, GROUP_TAG_BYTES, source); + if (groupChunk.index !== fieldOffset) + fail(source, offset + 0x10, + `group index ${groupChunk.index} != expected field ${fieldOffset}`); if (groupChunk.range.size !== 16) fail(source, groupChunk.range.start, `group payload is ${groupChunk.range.size} bytes instead of 16`); @@ -242,38 +427,71 @@ function inspectContainer(bytes: Uint8Array, source: string): ContainerLayout { videoChunks: u32(bytes, groupOffset + 4), unknown: u32(bytes, groupOffset + 8), }; + if (group.fields === 0) fail(source, groupOffset, "group has no fields"); + if (group.videoChunks === 0) + fail(source, groupOffset + 4, "group has no video chunks"); if (u32(bytes, groupOffset + 12) !== 0) fail(source, groupOffset + 12, "nonzero group reserved word"); + const groupEnd = fieldOffset + group.fields; + if (!Number.isSafeInteger(groupEnd) || groupEnd > header.fields) + fail(source, groupOffset, + `group fields end at ${groupEnd}, past header total ${header.fields}`); groups.push(group); offset = groupChunk.next; for (let chunkIndex = 0; chunkIndex < group.videoChunks; chunkIndex++) { - const chunk = readChunk(bytes, offset, VIDEO_TAG, source); + const chunk = readChunk(bytes, offset, VIDEO_TAG, VIDEO_TAG_BYTES, source); + if (chunk.index < fieldOffset || chunk.index >= groupEnd) + fail(source, offset + 0x10, + `video index ${chunk.index} is outside group fields ` + + `${fieldOffset}..${groupEnd - 1}`); + if (chunk.index < lastVideoIndex) + fail(source, offset + 0x10, + `video index ${chunk.index} follows ${lastVideoIndex}`); + if (video.length === 0 && chunk.index !== 0) + fail(source, offset + 0x10, + `first video index is ${chunk.index} instead of zero`); + lastVideoIndex = chunk.index; video.push(chunk.range); videoBytes += chunk.range.size; if (!Number.isSafeInteger(videoBytes)) fail(source, offset, "video size exceeds safe integer range"); offset = chunk.next; } + fieldOffset = groupEnd; if (groupIndex < header.groups - 1) { - const nextGroup = findTag(bytes, GROUP_TAG_BYTES, offset); - if (nextGroup < 0) fail(source, offset, "missing following GroupOfDataInfo"); - const gapSize = nextGroup - offset; - if (gapSize < header.audioBlock) - fail(source, offset, `audio gap ${gapSize} is smaller than ${header.audioBlock}`); - const audioStart = nextGroup - header.audioBlock; + const trackBytes = start.audioTracks * header.audioBlock; + const firstPossible = offset + trackBytes; + if (!Number.isSafeInteger(firstPossible)) + fail(source, offset, "audio gap exceeds safe integer range"); + requireRange(bytes, offset, trackBytes, source, "audio tracks"); + const nextGroup = findTag( + bytes, GROUP_TAG_BYTES, firstPossible, firstPossible + SECTOR - 1); + if (nextGroup < 0) + fail(source, offset, + `missing following ${GROUP_TAG} after ${start.audioTracks} ` + + `audio track${start.audioTracks === 1 ? "" : "s"}`); + const audioStart = nextGroup - trackBytes; if (!allZero(bytes, offset, audioStart)) fail(source, offset, "nonzero leading audio-gap padding"); + for (let track = 0; track < start.audioTracks; track++) { + validateAudioRange(bytes, { + start: audioStart + track * header.audioBlock, + size: header.audioBlock, + }, header, source, `audio track ${track} block`); + } audio.push({ start: audioStart, size: header.audioBlock }); offset = nextGroup; } } + const trailing = bytes.length - offset; + if (trailing >= SECTOR) + fail(source, offset, `trailing padding is ${trailing} bytes, not less than a sector`); if (!allZero(bytes, offset, bytes.length)) fail(source, offset, "nonzero trailing data"); - const fields = groups.reduce((sum, group) => sum + group.fields, 0); - if (fields !== header.fields) - fail(source, 0x08, `group fields ${fields} != header ${header.fields}`); + if (fieldOffset !== header.fields) + fail(source, 0x08, `group fields ${fieldOffset} != header ${header.fields}`); const audioBytes = audio.reduce((sum, range) => sum + range.size, 0); if (audioBytes !== header.audioBytes) fail(source, 0x34, `walked ${audioBytes} audio bytes, header declares ${header.audioBytes}`); @@ -363,11 +581,12 @@ export function parseMpeg2VideoInfo(video: Uint8Array, source = "MPEG-2"): FmvVi let bits = new BitReader(sequence.payload); let width: number; let height: number; + let aspectRatioCode: number; let frameRateCode: number; try { width = bits.read(12); height = bits.read(12); - bits.read(4); + aspectRatioCode = bits.read(4); frameRateCode = bits.read(4); } catch (error) { fail(source, sequence.offset, (error as Error).message); @@ -375,6 +594,12 @@ export function parseMpeg2VideoInfo(video: Uint8Array, source = "MPEG-2"): FmvVi const frameRate = FRAME_RATES[frameRateCode!]; if (!width! || !height! || frameRate === undefined) fail(source, sequence.offset, "invalid sequence dimensions or frame rate"); + if (aspectRatioCode! !== 1) + fail(source, sequence.offset, + `unsupported MPEG aspect-ratio code ${aspectRatioCode!}`); + if (frameRateCode! !== 3 && frameRateCode! !== 4) + fail(source, sequence.offset, + `unsupported Ape Escape 3 MPEG frame-rate code ${frameRateCode!}`); let progressiveSequence: boolean | null = null; let progressiveFrame: boolean | null = null; @@ -411,15 +636,19 @@ export function parseMpeg2VideoInfo(video: Uint8Array, source = "MPEG-2"): FmvVi if (!progressiveSequence && (topFieldFirst === null || progressiveFrame === null)) fail(source, sequence.offset, "missing picture coding extension field metadata"); const progressive = progressiveSequence || progressiveFrame === true; - - const divisor = gcd(width! * 7, height! * 6); + const sampleAspect: readonly [number, number] = + frameRateCode === 3 ? [4, 3] : [7, 6]; + const divisor = gcd(width! * sampleAspect[0], height! * sampleAspect[1]); return { width: width!, height: height!, frameRate, fieldOrder: progressive ? "progressive" : topFieldFirst ? "tt" : "bb", - sampleAspect: [7, 6], - displayAspect: [width! * 7 / divisor, height! * 6 / divisor], + sampleAspect, + displayAspect: [ + width! * sampleAspect[0] / divisor, + height! * sampleAspect[1] / divisor, + ], }; } @@ -428,103 +657,240 @@ export function parseMpeg2VideoInfo(video: Uint8Array, source = "MPEG-2"): FmvVi export function inspectFmvPrefix(bytes: Uint8Array, source = "FMV"): { header: FmvHeader; videoInfo: FmvVideoInfo } { const header = parseFmvHeader(bytes, source); - requireRange(bytes, SECTOR, header.preload, source, "audio preload"); - const group = readChunk(bytes, SECTOR + header.preload, GROUP_TAG, source); + const start = locateContainerStart(bytes, header, source); + audioPreloads(bytes, header, start, source); + const group = readChunk( + bytes, start.groupOffset, GROUP_TAG, GROUP_TAG_BYTES, source); + if (group.index !== 0) + fail(source, start.groupOffset + 0x10, + `first group index is ${group.index} instead of zero`); if (group.range.size !== 16) fail(source, group.range.start, `group payload is ${group.range.size} bytes instead of 16`); + const fields = u32(bytes, group.range.start); const videoChunks = u32(bytes, group.range.start + 4); - if (videoChunks === 0) fail(source, group.range.start + 4, "first group has no video"); - const video = readChunk(bytes, group.next, VIDEO_TAG, source).range; + if (fields === 0) fail(source, group.range.start, "first group has no fields"); + if (fields > header.fields) + fail(source, group.range.start, + `group fields end at ${fields}, past header total ${header.fields}`); + if (videoChunks === 0) + fail(source, group.range.start + 4, "first group has no video"); + if (u32(bytes, group.range.start + 12) !== 0) + fail(source, group.range.start + 12, "nonzero group reserved word"); + const video = readChunk(bytes, group.next, VIDEO_TAG, VIDEO_TAG_BYTES, source); + if (video.index !== 0) + fail(source, group.next + 0x10, + `first video index is ${video.index} instead of zero`); return { header, videoInfo: parseMpeg2VideoInfo( - bytes.subarray(video.start, video.start + video.size), `${source} video`), + bytes.subarray(video.range.start, video.range.start + video.range.size), + `${source} video`), }; } -/** Read only the bytes needed to inspect one movie in a VFI archive. */ +/** Read only the bounded bytes needed to inspect one movie in a VFI archive. */ export async function inspectFmvAsset(vfi: Vfi, movie: VfiEntry, source = movie.path): Promise<{ header: FmvHeader; videoInfo: FmvVideoInfo }> { const base = vfi.byteOffset(movie); - const headerBytes = await vfi.src.read(base, SECTOR); + const headerBytes = await readFmvAssetRange( + vfi, movie, base, 0, SECTOR, source, "header sector"); const header = parseFmvHeader(headerBytes, source); - const groupOffset = SECTOR + header.preload; - const skeleton = await vfi.src.read(base + groupOffset, 80); - requireRange(skeleton, 0, 80, source, "first group and video headers"); - const videoSize = u32(skeleton, 48 + 0x14); - const prefixSize = groupOffset + 48 + 32 + Math.ceil(videoSize / 16) * 16; - if (!Number.isSafeInteger(prefixSize) - || prefixSize < groupOffset || prefixSize > movie.size) - fail(source, groupOffset + 48 + 0x14, + const offsets = firstGroupOffsets(header); + const oneTrackEnd = offsets.oneTrack + FIRST_GROUP_PROBE_BYTES; + const fiveTrackEnd = offsets.fiveTrack + FIRST_GROUP_PROBE_BYTES; + const oneTrackFits = Number.isSafeInteger(oneTrackEnd) + && oneTrackEnd <= movie.size; + const fiveTrackFits = Number.isSafeInteger(fiveTrackEnd) + && fiveTrackEnd <= movie.size; + + const oneTrackProbe = oneTrackFits + ? await readFmvAssetRange(vfi, movie, base, offsets.oneTrack, + FIRST_GROUP_PROBE_BYTES, source, "one-track first-group probe") + : null; + let fiveTrackProbe: Uint8Array | null = null; + let start: ContainerStart | null = null; + let groupProbe: Uint8Array | null = null; + if (oneTrackProbe !== null && matchesTag(oneTrackProbe, 0, GROUP_TAG_BYTES)) { + start = { groupOffset: offsets.oneTrack, audioTracks: 1, preloadStart: SECTOR }; + groupProbe = oneTrackProbe; + } else if (fiveTrackFits) { + fiveTrackProbe = await readFmvAssetRange( + vfi, movie, base, offsets.fiveTrack, FIRST_GROUP_PROBE_BYTES, + source, "five-track first-group probe"); + if (matchesTag(fiveTrackProbe, 0, GROUP_TAG_BYTES)) { + start = { + groupOffset: offsets.fiveTrack, + audioTracks: FIVE_AUDIO_TRACKS, + preloadStart: offsets.fiveTrack - FIVE_AUDIO_TRACKS * header.preload, + }; + groupProbe = fiveTrackProbe; + } + } + if (start === null || groupProbe === null) { + const oneTrackFound = oneTrackProbe === null + ? `range ends at 0x${oneTrackEnd.toString(16)} past movie EOF` + : describeTag(oneTrackProbe, 0); + const fiveTrackFound = fiveTrackProbe === null + ? `range ends at 0x${fiveTrackEnd.toString(16)} past movie EOF` + : describeTag(fiveTrackProbe, 0); + fail(source, offsets.oneTrack, + `expected first ${GROUP_TAG} at one-track offset ` + + `0x${offsets.oneTrack.toString(16)} or five-track offset ` + + `0x${offsets.fiveTrack.toString(16)}; found ` + + `${oneTrackFound} and ${fiveTrackFound}`); + } + if (u32(groupProbe, 0x10) !== 0) + fail(source, start.groupOffset + 0x10, + `first group index is ${u32(groupProbe, 0x10)} instead of zero`); + const groupSize = u32(groupProbe, 0x14); + if (groupSize !== 16) + fail(source, start.groupOffset + 0x14, + `group payload is ${groupSize} bytes instead of 16`); + if (u32(groupProbe, 0x18) !== 0 || u32(groupProbe, 0x1c) !== 0) + fail(source, start.groupOffset + 0x18, "nonzero chunk reserved word"); + const fields = u32(groupProbe, 0x20); + const videoChunks = u32(groupProbe, 0x24); + if (fields === 0) fail(source, start.groupOffset + 0x20, "first group has no fields"); + if (fields > header.fields) + fail(source, start.groupOffset + 0x20, + `group fields end at ${fields}, past header total ${header.fields}`); + if (videoChunks === 0) + fail(source, start.groupOffset + 0x24, "first group has no video"); + if (u32(groupProbe, 0x2c) !== 0) + fail(source, start.groupOffset + 0x2c, "nonzero group reserved word"); + const videoHeaderOffset = 48; + const videoHeader = start.groupOffset + videoHeaderOffset; + if (!matchesTag(groupProbe, videoHeaderOffset, VIDEO_TAG_BYTES)) + fail(source, videoHeader, + `expected ${VIDEO_TAG}, found ${describeTag(groupProbe, videoHeaderOffset)}`); + const videoIndex = u32(groupProbe, videoHeaderOffset + 0x10); + if (videoIndex !== 0) + fail(source, videoHeader + 0x10, + `first video index is ${videoIndex} instead of zero`); + if (u32(groupProbe, videoHeaderOffset + 0x18) !== 0 + || u32(groupProbe, videoHeaderOffset + 0x1c) !== 0) + fail(source, videoHeader + 0x18, "nonzero chunk reserved word"); + const videoSize = u32(groupProbe, videoHeaderOffset + 0x14); + if (videoSize > MAX_FIRST_VIDEO_INSPECTION_BYTES) + fail(source, videoHeader + 0x14, + `first video payload is ${videoSize} bytes, exceeds inspection cap ` + + `${MAX_FIRST_VIDEO_INSPECTION_BYTES}`); + + const prefixSize = videoHeader + 32 + Math.ceil(videoSize / 16) * 16; + if (!Number.isSafeInteger(prefixSize) || prefixSize < videoHeader + || prefixSize > movie.size) + fail(source, videoHeader + 0x14, `first video chunk ends at 0x${prefixSize.toString(16)}, ` + `past movie EOF 0x${movie.size.toString(16)}`); - const prefix = await vfi.src.read(base, prefixSize); - if (prefix.length !== prefixSize) - fail(source, prefix.length, - `short read (${prefix.length} of ${prefixSize} inspection bytes)`); + if (prefixSize > MAX_FMV_INSPECTION_PREFIX_BYTES) + fail(source, videoHeader + 0x14, + `inspection prefix is ${prefixSize} bytes, exceeds cap ` + + `${MAX_FMV_INSPECTION_PREFIX_BYTES}`); + const prefix = await readFmvAssetRange( + vfi, movie, base, 0, prefixSize, source, "inspection prefix"); return inspectFmvPrefix(prefix, source); } -function writeWavHeader(wav: Uint8Array, channels: number, sampleRate: number, - bodyBytes: number): void { - wav.set(new TextEncoder().encode("RIFF"), 0); +function wavLayout(header: FmvHeader, source: string): WavLayout { + const adpcmBytesPerChannel = header.audioBytes / header.channels; + if (!Number.isSafeInteger(adpcmBytesPerChannel) || adpcmBytesPerChannel % 16 !== 0) + fail(source, 0x34, "per-channel audio is not a whole number of ADPCM frames"); + const adpcmFramesPerChannel = adpcmBytesPerChannel / 16; + const samplesPerChannel = adpcmFramesPerChannel * 28; + const blockAlign = header.channels * 2; + const byteRate = header.sampleRate * blockAlign; + const bodyBytes = samplesPerChannel * blockAlign; + const riffBytes = 36 + bodyBytes; + const totalBytes = 44 + bodyBytes; + if (!Number.isSafeInteger(adpcmFramesPerChannel) + || !Number.isSafeInteger(samplesPerChannel) + || !Number.isSafeInteger(blockAlign) + || !Number.isSafeInteger(byteRate) + || !Number.isSafeInteger(bodyBytes) + || !Number.isSafeInteger(riffBytes) + || !Number.isSafeInteger(totalBytes)) + fail(source, 0x34, "WAV arithmetic exceeds the safe integer range"); + if (samplesPerChannel > UINT32_MAX) + fail(source, 0x34, "per-channel sample count exceeds the u32 decoder counter"); + if (blockAlign > UINT16_MAX) + fail(source, 0x24, "WAV block alignment exceeds the u16 field"); + if (byteRate > UINT32_MAX) + fail(source, 0x20, "WAV byte rate exceeds the u32 field"); + if (bodyBytes % blockAlign !== 0 || bodyBytes > UINT32_MAX + || riffBytes > UINT32_MAX) + fail(source, 0x34, "WAV body is not representable by RIFF u32 fields"); + if (totalBytes > MAX_WAV_BYTES) + fail(source, 0x34, + `WAV allocation is ${totalBytes} bytes, exceeds cap ${MAX_WAV_BYTES}`); + return { + samplesPerChannel, + bodyBytes, + totalBytes, + riffBytes, + byteRate, + blockAlign, + }; +} + +function writeWavHeader(wav: Uint8Array, header: FmvHeader, + layout: WavLayout): void { + wav.set(ENCODER.encode("RIFF"), 0); const view = new DataView(wav.buffer, wav.byteOffset, wav.byteLength); - view.setUint32(4, 36 + bodyBytes, true); - wav.set(new TextEncoder().encode("WAVEfmt "), 8); + view.setUint32(4, layout.riffBytes, true); + wav.set(ENCODER.encode("WAVEfmt "), 8); view.setUint32(16, 16, true); view.setUint16(20, 1, true); - view.setUint16(22, channels, true); - view.setUint32(24, sampleRate, true); - view.setUint32(28, sampleRate * channels * 2, true); - view.setUint16(32, channels * 2, true); + view.setUint16(22, header.channels, true); + view.setUint32(24, header.sampleRate, true); + view.setUint32(28, layout.byteRate, true); + view.setUint16(32, layout.blockAlign, true); view.setUint16(34, 16, true); - wav.set(new TextEncoder().encode("data"), 36); - view.setUint32(40, bodyBytes, true); + wav.set(ENCODER.encode("data"), 36); + view.setUint32(40, layout.bodyBytes, true); } function decodeAudio(bytes: Uint8Array, ranges: readonly ChunkRange[], header: FmvHeader, source: string): Uint8Array { - const bytesPerChannel = header.audioBytes / header.channels; - if (bytesPerChannel % 16 !== 0) - fail(source, 0x34, "per-channel audio is not a whole number of ADPCM frames"); - const samplesPerChannel = bytesPerChannel / 16 * 28; - const bodyBytes = samplesPerChannel * header.channels * 2; - const wav = new Uint8Array(44 + bodyBytes); - writeWavHeader(wav, header.channels, header.sampleRate, bodyBytes); + const layout = wavLayout(header, source); + const samplesPerChannel = layout.samplesPerChannel; + const wav = new Uint8Array(layout.totalBytes); + writeWavHeader(wav, header, layout); const view = new DataView(wav.buffer, wav.byteOffset, wav.byteLength); - const histories = Array.from({ length: header.channels }, () => [0, 0]); + const previousSamples = new Int32Array(header.channels); + const olderSamples = new Int32Array(header.channels); const sampleOffsets = new Uint32Array(header.channels); - const channelGroup = header.interleave * header.channels; + const interleaveGroupBytes = header.interleave * header.channels; for (const range of ranges) { - if (range.size % channelGroup !== 0) + if (range.size % interleaveGroupBytes !== 0) fail(source, range.start, "audio range is not interleave-group aligned"); - for (let group = range.start; group < range.start + range.size; group += channelGroup) { + for (let interleaveGroupStart = range.start; + interleaveGroupStart < range.start + range.size; + interleaveGroupStart += interleaveGroupBytes) { for (let channel = 0; channel < header.channels; channel++) { - const blockEnd = group + (channel + 1) * header.interleave; - let frame = group + channel * header.interleave; + const blockEnd = interleaveGroupStart + (channel + 1) * header.interleave; + let frame = interleaveGroupStart + channel * header.interleave; for (; frame < blockEnd; frame += 16) { - let shift = bytes[frame] & 0x0f; - let filter = (bytes[frame] >> 4) & 0x0f; - if (shift > 12) shift = 9; - if (filter > 4) filter = 0; + const shift = bytes[frame] & 0x0f; + const filter = (bytes[frame] >> 4) & 0x0f; const [coefficient0, coefficient1] = ADPCM_COEFFICIENTS[filter]; for (let i = 0; i < 14; i++) { const packed = bytes[frame + 2 + i]; - for (const nibble of [packed & 0x0f, packed >> 4]) { + for (let nibbleIndex = 0; nibbleIndex < 2; nibbleIndex++) { + const nibble = nibbleIndex === 0 ? packed & 0x0f : packed >> 4; let sample = nibble << 12; if ((sample & 0x8000) !== 0) sample -= 0x10000; sample >>= shift; - sample += (histories[channel][0] * coefficient0 - + histories[channel][1] * coefficient1) >> 6; + sample += (previousSamples[channel] * coefficient0 + + olderSamples[channel] * coefficient1) >> 6; sample = Math.max(-32768, Math.min(32767, sample)); const sampleIndex = sampleOffsets[channel]++; view.setInt16(44 + (sampleIndex * header.channels + channel) * 2, sample, true); - histories[channel][1] = histories[channel][0]; - histories[channel][0] = sample; + olderSamples[channel] = previousSamples[channel]; + previousSamples[channel] = sample; } } } @@ -631,7 +997,8 @@ export function parseFmvSubtitles(bin: Uint8Array, sbt: Uint8Array, const timings = parseSubtitleTimings(sbt, `${source} .sbt`); const text = parseSubtitleText(bin, `${source} .bin`); if (text.length !== timings.starts.length) - throw new Error(`${source}: ${text.length} strings != ${timings.starts.length} timings`); + fail(source, 0, + `${text.length} strings != ${timings.starts.length} timings`); return text.map((value, i) => ({ index: i + 1, start: timings.starts[i], diff --git a/src/vendor/extract/image-format.ts b/src/vendor/extract/image-format.ts new file mode 100644 index 0000000..fe17b2c --- /dev/null +++ b/src/vendor/extract/image-format.ts @@ -0,0 +1,18 @@ +export type ImageFormat = "TIM2" | "PCK" | "SZ"; + +/** A proven violation of one image container format. */ +export class ImageFormatError extends Error { + readonly format: ImageFormat; + readonly source: string; + readonly offset: number; + readonly detail: string; + + constructor(source: string, offset: number, detail: string, format: ImageFormat) { + super(`${source} at 0x${offset.toString(16)}: ${detail}`); + this.name = "ImageFormatError"; + this.format = format; + this.source = source; + this.offset = offset; + this.detail = detail; + } +} diff --git a/src/vendor/extract/images.ts b/src/vendor/extract/images.ts index 54919af..38cec57 100644 --- a/src/vendor/extract/images.ts +++ b/src/vendor/extract/images.ts @@ -8,6 +8,7 @@ import { } from "./pck.ts"; import { inspectTim2, type Tim2PictureInfo } from "./tim2.ts"; import { type Vfi, type VfiEntry } from "./vfi.ts"; +import { ImageFormatError, type ImageFormat } from "./image-format.ts"; export type ImageRole = "sprite" | "texture" | "other"; export type ImageRoleEvidence = @@ -28,13 +29,27 @@ export interface ImageTexture { pictures: Tim2PictureInfo[]; } +export interface ImageScanIssue { + /** Sanitized VFI path of the failed container, bounded for display. */ + path: string; + /** Container format whose structural validation failed. */ + format: ImageFormat; + /** Sanitized parser context and reason, bounded for display. */ + reason: string; +} + +export interface ImageScanResult { + textures: ImageTexture[]; + issues: ImageScanIssue[]; +} + export interface ImageScanOptions { progress?: (done: number, total: number, path: string) => void; texture?: (texture: ImageTexture, bytes: Uint8Array) => void | Promise; container?: (entry: VfiEntry, bytes: Uint8Array) => void | Promise; } -function isTim2(data: Uint8Array): boolean { +function hasTim2Magic(data: Uint8Array): boolean { return data.length >= 4 && data[0] === 0x54 && data[1] === 0x49 && data[2] === 0x4d && data[3] === 0x32; } @@ -132,9 +147,38 @@ interface ScannedImageContainer { const IMAGE_SCAN_CONCURRENCY = 8; -async function scanImageContainer(vfi: Vfi, - entry: VfiEntry): Promise { - const stored = await vfi.read(entry); +const MAX_ISSUE_TEXT = 256; + +function boundedIssueText(value: string, fallback: string): string { + const text = value.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?") + .replace(/(?:^|\s)(?:[A-Za-z]:[\\/]|\/)[^\s)]+/g, "$1") + .slice(0, MAX_ISSUE_TEXT); + return text || fallback; +} + +function issuePath(path: string): string { + const normalized = path.replace(/\\/g, "/") + .replace(/^[A-Za-z]:\/+/, "") + .replace(/^\/+/, ""); + const segments = normalized.split("/") + .filter(segment => segment.length > 0 && segment !== "." && segment !== "..") + .map(segment => segment.replace(/[\u0000-\u001f\u007f-\u009f]/g, "?")); + return boundedIssueText(segments.join("/"), "image-container"); +} + +function imageIssue(entry: VfiEntry, error: ImageFormatError): ImageScanIssue { + return { + path: issuePath(entry.path), + format: error.format, + reason: boundedIssueText( + `${error.source}: ${error.detail} (offset 0x${error.offset.toString(16)})`, + "invalid image container format", + ), + }; +} + +async function scanImageContainer(entry: VfiEntry, + stored: Uint8Array): Promise { if (/\.tm2$/i.test(entry.path)) { const fileName = entry.path.slice(entry.path.lastIndexOf("/") + 1); return { @@ -154,16 +198,15 @@ async function scanImageContainer(vfi: Vfi, }; } - const pck = /\.sz$/i.test(entry.path) ? await inflateSz(stored) : stored; - const members = unpackPck(pck); - if (!members) throw new Error(`${entry.path}: not a PCK`); + const pck = /\.sz$/i.test(entry.path) ? await inflateSz(stored, entry.path) : stored; + const members = unpackPck(pck, entry.path); + if (!members) + throw new ImageFormatError(entry.path, 0, "not a PCK", "PCK"); const names = pckFileNames(members); - const imageMembers = members.filter(member => { - const bytes = memberBytes(pck, member); - return typeOf(member.attrs) === "tm2" || isTim2(bytes); - }); - const classification = classifyMembers(pck, members, imageMembers); - const images = imageMembers.map(member => { + const tim2Members = members.filter(member => + hasTim2Magic(memberBytes(pck, member))); + const classification = classifyMembers(pck, members, tim2Members); + const images = tim2Members.map(member => { const bytes = memberBytes(pck, member); const label = `${entry.path}#${member.index}:${member.name}`; const role = classification.roles.get(member.index)!; @@ -187,6 +230,20 @@ async function scanImageContainer(vfi: Vfi, }; } +type ImageScanOutcome = + | { container: ScannedImageContainer } + | { issue: ImageScanIssue }; + +async function scanImageOutcome(vfi: Vfi, entry: VfiEntry): Promise { + const stored = await vfi.read(entry); + try { + return { container: await scanImageContainer(entry, stored) }; + } catch (error) { + if (!(error instanceof ImageFormatError)) throw error; + return { issue: imageIssue(entry, error) }; + } +} + function refineUnclassified(textures: ImageTexture[], uiReferences: ReadonlySet, modelReferences: ReadonlySet): void { @@ -211,13 +268,18 @@ function refineUnclassified(textures: ImageTexture[], /** * Inspect every direct TIM2 and every TIM2 member of every PCK in DATA.BIN. * The callback receives each original source texture once, including - * multi-picture textures, after cross-container role evidence is resolved. + * multi-picture textures. Unclassified roles are deferred until cross-container + * evidence is resolved; already-classified textures may be delivered per batch. * No decoded pixels are retained by the scanner. + * A proven TIM2/PCK/SZ format violation after a successful VFI read is + * returned as one bounded issue for that container; source/I/O, resource, + * abort, callback, and unexpected failures still reject the scan. */ export async function scanImageTextures(vfi: Vfi, - options: ImageScanOptions = {}): Promise { + options: ImageScanOptions = {}): Promise { const containers = locateImageContainers(vfi); const textures: ImageTexture[] = []; + const issues: ImageScanIssue[] = []; const uiReferences = new Set(); const modelReferences = new Set(); const deferred: Array<{ texture: ImageTexture; bytes: Uint8Array }> = []; @@ -225,8 +287,15 @@ export async function scanImageTextures(vfi: Vfi, const batch = containers.slice(start, start + IMAGE_SCAN_CONCURRENCY); for (let offset = 0; offset < batch.length; offset++) options.progress?.(start + offset, containers.length, batch[offset]!.path); - const scanned = await Promise.all(batch.map(entry => scanImageContainer(vfi, entry))); - for (const container of scanned) { + const scanned = await Promise.all(batch.map(entry => scanImageOutcome(vfi, entry))); + const successful: ScannedImageContainer[] = []; + for (const outcome of scanned) { + if ("issue" in outcome) { + issues.push(outcome.issue); + continue; + } + const container = outcome.container; + successful.push(container); for (const name of container.uiReferences) uiReferences.add(name); for (const name of container.modelReferences) modelReferences.add(name); for (const image of container.images) { @@ -235,7 +304,7 @@ export async function scanImageTextures(vfi: Vfi, deferred.push({ texture: image.texture, bytes: image.bytes.slice() }); } } - await Promise.all(scanned.map(async container => { + await Promise.all(successful.map(async container => { if (container.images.length === 0) return; await options.container?.(container.entry, container.stored); for (const image of container.images) @@ -250,7 +319,7 @@ export async function scanImageTextures(vfi: Vfi, .map(image => options.texture!(image.texture, image.bytes))); } options.progress?.(containers.length, containers.length, "done"); - return textures; + return { textures, issues }; } /** Re-read one source TIM2 represented by a scan result. */ @@ -265,8 +334,8 @@ export async function readImageTexture(vfi: Vfi, inspectTim2(stored, texture.sourcePath); return stored; } - const pck = /\.sz$/i.test(entry.path) ? await inflateSz(stored) : stored; - const members = unpackPck(pck); + const pck = /\.sz$/i.test(entry.path) ? await inflateSz(stored, entry.path) : stored; + const members = unpackPck(pck, entry.path); const member = members?.[texture.memberIndex]; if (!member || member.name !== texture.memberName) throw new Error(`${texture.sourcePath}: image member ${texture.memberIndex} changed`); diff --git a/src/vendor/extract/pck.ts b/src/vendor/extract/pck.ts index 93fca81..ce18430 100644 --- a/src/vendor/extract/pck.ts +++ b/src/vendor/extract/pck.ts @@ -8,6 +8,7 @@ * bytes), so consumers must key on the member table, not on filenames. */ import { cstrAt, u32 } from "./bytes.ts"; +import { ImageFormatError } from "./image-format.ts"; export interface PckMember { index: number; @@ -17,25 +18,39 @@ export interface PckMember { size: number; } +function fail(source: string, offset: number, detail: string): never { + throw new ImageFormatError(source, offset, detail, "PCK"); +} + /** Member table, or null if the blob is not a PCK. */ -export function unpackPck(data: Uint8Array): PckMember[] | null { +export function unpackPck(data: Uint8Array, source = "PCK"): PckMember[] | null { if (!(data.length >= 4 && data[0] === 0x50 && data[1] === 0x43 && data[2] === 0x4b && data[3] === 0)) return null; if (data.length < 12) - throw new Error("PCK truncated before its header"); + fail(source, 0, "PCK truncated before its header"); const infoOff = u32(data, 4); const files = u32(data, 8); + const tableEnd = infoOff + files * 16; + if (!Number.isSafeInteger(tableEnd) || infoOff < 12 || tableEnd > data.length) + fail(source, infoOff, "PCK member table exceeds PCK data"); const out: PckMember[] = []; for (let i = 0; i < files; i++) { const o = infoOff + i * 16; - if (o + 16 > data.length) break; + const nameOff = u32(data, o); + const attrOff = u32(data, o + 4); + const offset = u32(data, o + 8); + const size = u32(data, o + 12); + if (nameOff >= data.length || attrOff >= data.length) + fail(source, o, `PCK member ${i} string offset is outside PCK data`); + if (offset + size > data.length) + fail(source, o + 8, `PCK member ${i} data exceeds PCK data`); out.push({ index: i, - name: cstrAt(data, u32(data, o)), - attrs: cstrAt(data, u32(data, o + 4)), - offset: u32(data, o + 8), - size: u32(data, o + 12), + name: cstrAt(data, nameOff), + attrs: cstrAt(data, attrOff), + offset, + size, }); } return out; diff --git a/src/vendor/extract/sz.ts b/src/vendor/extract/sz.ts index 7c52be5..061e7cb 100644 --- a/src/vendor/extract/sz.ts +++ b/src/vendor/extract/sz.ts @@ -10,19 +10,50 @@ * natively. Zero library code either way. */ import { u32 } from "./bytes.ts"; +import { ImageFormatError } from "./image-format.ts"; -export async function inflateSz(data: Uint8Array): Promise { +const DEFLATE_FORMAT_FAILURE = + /\b(?:adler|checksum|unexpected end|invalid|incorrect) (?:deflate|compressed|stored|fixed|dynamic|huffman|header|distance|literal|length|block|code|data)\b|\b(?:deflate|compressed) data (?:error|invalid|truncated)\b/i; + +function fail(source: string, detail: string): never { + throw new ImageFormatError(source, 0, detail, "SZ"); +} + +function isDeflateFormatFailure(error: unknown): boolean { + if (error instanceof RangeError) return false; + const value = typeof error === "object" && error !== null + ? error as { name?: unknown; message?: unknown; code?: unknown } + : null; + const name = String(value?.name ?? ""); + if (name === "AbortError" || name === "QuotaExceededError" + || name === "NotReadableError") + return false; + if (error instanceof TypeError || value?.code === "Z_DATA_ERROR") + return true; + const message = error instanceof Error ? error.message : String(error); + return DEFLATE_FORMAT_FAILURE.test(message); +} + +export async function inflateSz(data: Uint8Array, source = "SZ"): Promise { if (data.length < 10) // header + shortest deflate stream + trailer - throw new Error(`.sz too short (${data.length} bytes)`); + fail(source, `.sz too short (${data.length} bytes)`); const declared = u32(data, 0); const zstream = new Uint8Array(2 + data.length - 4); zstream[0] = 0x78; // CMF: deflate, 32K window zstream[1] = 0x9c; // FLG: check bits valid, no dictionary zstream.set(data.subarray(4), 2); + const decompressor = new DecompressionStream("deflate"); const stream = new Blob([zstream as BlobPart]).stream() - .pipeThrough(new DecompressionStream("deflate")); - const out = new Uint8Array(await new Response(stream).arrayBuffer()); - if (out.length !== declared) - throw new Error(`.sz inflated to ${out.length} bytes, declared ${declared}`); - return out; + .pipeThrough(decompressor); + try { + const out = new Uint8Array(await new Response(stream).arrayBuffer()); + if (out.length !== declared) + fail(source, `.sz inflated to ${out.length} bytes, declared ${declared}`); + return out; + } catch (error) { + if (error instanceof ImageFormatError) throw error; + if (isDeflateFormatFailure(error)) + fail(source, ".sz contains an invalid deflate stream"); + throw error; + } } diff --git a/src/vendor/extract/tim2.ts b/src/vendor/extract/tim2.ts index 856a367..e803f1f 100644 --- a/src/vendor/extract/tim2.ts +++ b/src/vendor/extract/tim2.ts @@ -1,3 +1,5 @@ +import { ImageFormatError } from "./image-format.ts"; + const RGBA16 = 1; const RGB24 = 2; const RGBA32 = 3; @@ -24,30 +26,35 @@ interface ParsedPicture extends Tim2PictureInfo { clutSize: number; } +function fail(path: string, offset: number, detail: string): never { + throw new ImageFormatError(path, offset, detail, "TIM2"); +} + function requireRange(data: Uint8Array, offset: number, length: number, - label: string): void { + path: string, label: string): void { if (!Number.isSafeInteger(offset) || !Number.isSafeInteger(length) || offset < 0 || length < 0 || offset + length > data.length) - throw new Error(`${label} exceeds TIM2 data (${offset}+${length}/${data.length})`); + fail(path, Math.max(0, offset), + `${label} exceeds TIM2 data (${offset}+${length}/${data.length})`); } function parsedPictures(data: Uint8Array, path: string): ParsedPicture[] { if (data.length < 0x10 || data[0] !== 0x54 || data[1] !== 0x49 || data[2] !== 0x4d || data[3] !== 0x32) - throw new Error(`${path}: not a TIM2 texture`); + fail(path, 0, "not a TIM2 texture"); if (data[4] !== 4) - throw new Error(`${path}: TIM2 version ${data[4]} is unsupported`); + fail(path, 4, `TIM2 version ${data[4]} is unsupported`); const format = data[5]!; if (format !== 0 && format !== 1) - throw new Error(`${path}: TIM2 format ${format} is unsupported`); + fail(path, 5, `TIM2 format ${format} is unsupported`); const pictureCount = data[6]! | data[7]! << 8; - if (pictureCount < 1) throw new Error(`${path}: TIM2 contains no pictures`); + if (pictureCount < 1) fail(path, 6, "TIM2 contains no pictures"); const view = new DataView(data.buffer, data.byteOffset, data.byteLength); const pictures: ParsedPicture[] = []; let offset = format === 0 ? 0x10 : 0x80; for (let index = 0; index < pictureCount; index++) { - requireRange(data, offset, 0x30, `${path}: picture ${index} header`); + requireRange(data, offset, 0x30, path, `picture ${index} header`); const totalSize = view.getUint32(offset, true); const clutSize = view.getUint32(offset + 4, true); const imageSize = view.getUint32(offset + 8, true); @@ -59,13 +66,14 @@ function parsedPictures(data: Uint8Array, path: string): ParsedPicture[] { const width = view.getUint16(offset + 20, true); const height = view.getUint16(offset + 22, true); if (totalSize < headerSize || headerSize < 0x30) - throw new Error(`${path}: picture ${index} has invalid sizes`); - requireRange(data, offset, totalSize, `${path}: picture ${index}`); + fail(path, offset, `picture ${index} has invalid sizes`); + requireRange(data, offset, totalSize, path, `picture ${index}`); if (!width || !height) - throw new Error(`${path}: picture ${index} has invalid dimensions ${width}x${height}`); + fail(path, offset + 20, + `picture ${index} has invalid dimensions ${width}x${height}`); const body = offset + headerSize; - requireRange(data, body, imageSize + clutSize, - `${path}: picture ${index} payload`); + requireRange(data, body, imageSize + clutSize, path, + `picture ${index} payload`); pictures.push({ index, width, height, imageType, clutType, colorCount, mipmapCount, body, imageSize, clutSize }); offset += totalSize; @@ -96,12 +104,12 @@ function decodePalette(data: Uint8Array, picture: ParsedPicture, const bytesPerColor = kind === RGBA32 ? 4 : kind === RGB24 ? 3 : kind === RGBA16 ? 2 : 0; if (!bytesPerColor) - throw new Error(`${path}: unsupported TIM2 CLUT type 0x${picture.clutType.toString(16)}`); + fail(path, 0, `unsupported TIM2 CLUT type 0x${picture.clutType.toString(16)}`); if (picture.colorCount * bytesPerColor > picture.clutSize) - throw new Error(`${path}: TIM2 palette exceeds declared CLUT size`); + fail(path, picture.body + picture.imageSize, + "TIM2 palette exceeds declared CLUT size"); const offset = picture.body + picture.imageSize; - requireRange(data, offset, picture.colorCount * bytesPerColor, `${path}: TIM2 palette`); - + requireRange(data, offset, picture.colorCount * bytesPerColor, path, "TIM2 palette"); const palette = new Uint8ClampedArray(picture.colorCount * 4); const csm2 = (picture.clutType & 0x80) !== 0; for (let index = 0; index < picture.colorCount; index++) { @@ -131,24 +139,25 @@ function decodePalette(data: Uint8Array, picture: ParsedPicture, export function decodeTim2(data: Uint8Array, pictureIndex = 0, path = "TIM2"): Tim2Image { const picture = parsedPictures(data, path)[pictureIndex]; - if (!picture) throw new Error(`${path}: TIM2 picture ${pictureIndex} does not exist`); + if (!picture) fail(path, 0, `TIM2 picture ${pictureIndex} does not exist`); const pixels = picture.width * picture.height; const rgba = new Uint8ClampedArray(pixels * 4); if (picture.imageType === IDTEX4 || picture.imageType === IDTEX8) { if (!picture.clutSize || !picture.colorCount) - throw new Error(`${path}: indexed TIM2 picture has no palette`); + fail(path, picture.body, "indexed TIM2 picture has no palette"); const palette = decodePalette(data, picture, path); const indexBytes = picture.imageType === IDTEX8 ? pixels : Math.ceil(pixels / 2); if (indexBytes > picture.imageSize) - throw new Error(`${path}: TIM2 indices exceed declared image size`); - requireRange(data, picture.body, indexBytes, `${path}: TIM2 indices`); + fail(path, picture.body, "TIM2 indices exceed declared image size"); + requireRange(data, picture.body, indexBytes, path, "TIM2 indices"); for (let pixel = 0; pixel < pixels; pixel++) { const packed = data[picture.body + (picture.imageType === IDTEX8 ? pixel : pixel >> 1)]!; const index = picture.imageType === IDTEX8 ? packed : pixel & 1 ? packed >> 4 : packed & 0x0f; if (index >= picture.colorCount) - throw new Error(`${path}: TIM2 palette index ${index} exceeds ${picture.colorCount}`); + fail(path, picture.body, + `TIM2 palette index ${index} exceeds ${picture.colorCount}`); rgba.set(palette.subarray(index * 4, index * 4 + 4), pixel * 4); } } else if (picture.imageType === RGBA32 || picture.imageType === RGB24 @@ -156,8 +165,8 @@ export function decodeTim2(data: Uint8Array, pictureIndex = 0, const bytesPerPixel = picture.imageType === RGBA32 ? 4 : picture.imageType === RGB24 ? 3 : 2; if (pixels * bytesPerPixel > picture.imageSize) - throw new Error(`${path}: TIM2 pixels exceed declared image size`); - requireRange(data, picture.body, pixels * bytesPerPixel, `${path}: TIM2 pixels`); + fail(path, picture.body, "TIM2 pixels exceed declared image size"); + requireRange(data, picture.body, pixels * bytesPerPixel, path, "TIM2 pixels"); for (let pixel = 0; pixel < pixels; pixel++) { const source = picture.body + pixel * bytesPerPixel; const target = pixel * 4; @@ -172,7 +181,7 @@ export function decodeTim2(data: Uint8Array, pictureIndex = 0, } } } else { - throw new Error(`${path}: unsupported TIM2 image type ${picture.imageType}`); + fail(path, picture.body, `unsupported TIM2 image type ${picture.imageType}`); } const { body: _body, imageSize: _imageSize, clutSize: _clutSize, ...info } = picture; return { ...info, rgba }; diff --git a/src/zip.ts b/src/zip.ts index 168a14c..4837da1 100644 --- a/src/zip.ts +++ b/src/zip.ts @@ -1,7 +1,7 @@ /* Store-only ZIP writer (no compression, no dependency). Needed because a * single click may only trigger one download: Chrome's multiple-downloads - * policy silently blocks the second same-gesture download, so multi-file - * exports (the .hd/.bd bank pair, the future sample kit) ship as one zip. + * policy silently blocks the second same-gesture download, so current + * multi-file exports such as .hd/.bd bank pairs ship as one zip. * Output is deterministic -- fixed 1980-01-01 timestamps -- so exports can * be byte-compared in gates. */ @@ -16,6 +16,48 @@ const CRC_TABLE = (() => { return t; })(); +const ZIP_PATH_MAX_BYTES = 4096; +const ZIP_COMPONENT_MAX_BYTES = 255; +const CONTROL_CHARACTER = /[\u0000-\u001f\u007f-\u009f]/; +const DRIVE_PREFIX = /^[A-Za-z]:/; +const textEncoder = new TextEncoder(); + +function isWellFormedUnicode(value: string): boolean { + for (let index = 0; index < value.length; index++) { + const unit = value.charCodeAt(index); + if (unit >= 0xd800 && unit <= 0xdbff) { + const next = value.charCodeAt(++index); + if (!(next >= 0xdc00 && next <= 0xdfff)) return false; + } else if (unit >= 0xdc00 && unit <= 0xdfff) { + return false; + } + } + return true; +} + +/** Require one canonical, traversal-safe relative POSIX archive path. */ +export function assertZipEntryPath(path: string): void { + if (typeof path !== "string" || path.length === 0) + throw new Error("ZIP entry path is empty"); + if (!isWellFormedUnicode(path) || path.normalize("NFC") !== path) + throw new Error("ZIP entry path must be well-formed NFC Unicode"); + if (CONTROL_CHARACTER.test(path)) + throw new Error("ZIP entry path contains a control character"); + if (path.startsWith("/") || path.includes("\\") || DRIVE_PREFIX.test(path) + || path.includes(":")) + throw new Error("ZIP entry path must be a platform-safe relative POSIX path"); + + const components = path.split("/"); + if (components.some(component => + component.length === 0 || component === "." || component === "..")) + throw new Error("ZIP entry path contains an empty or dot component"); + if (components.some(component => + textEncoder.encode(component).byteLength > ZIP_COMPONENT_MAX_BYTES)) + throw new Error("ZIP entry path component is too long"); + if (textEncoder.encode(path).byteLength > ZIP_PATH_MAX_BYTES) + throw new Error("ZIP entry path is too long"); +} + function crc32(b: Uint8Array): number { let c = 0xffffffff; for (let i = 0; i < b.length; i++) @@ -23,23 +65,43 @@ function crc32(b: Uint8Array): number { return (c ^ 0xffffffff) >>> 0; } -/** Pack `entries` (ASCII names) into a store-only zip. */ +/** Pack `entries` with UTF-8 names into a store-only zip. */ export function storeZip(entries: [name: string, data: Uint8Array][]): Uint8Array { - const enc = new TextEncoder(); - const files = entries.map(([name, data]) => - ({ name: enc.encode(name), data, crc: crc32(data), offset: 0 })); - - const localSize = files.reduce((s, f) => s + 30 + f.name.length + f.data.length, 0); - const centralSize = files.reduce((s, f) => s + 46 + f.name.length, 0); - const out = new Uint8Array(localSize + centralSize + 22); + if (entries.length > 0xffff) + throw new Error(`ZIP has ${entries.length} files; ZIP32 supports at most 65535`); + const files: Array<{ + name: Uint8Array; + data: Uint8Array; + crc: number; + offset: number; + }> = []; + let localSize = 0; + let centralSize = 0; + for (const [path, data] of entries) { + assertZipEntryPath(path); + if (data.length > 0xffffffff) + throw new Error(`${path}: file is too large for ZIP32`); + const name = textEncoder.encode(path); + localSize += 30 + name.length + data.length; + centralSize += 46 + name.length; + if (localSize > 0xffffffff) + throw new Error("ZIP payload exceeds the 4 GiB ZIP32 limit"); + if (centralSize > 0xffffffff) + throw new Error("ZIP directory exceeds the ZIP32 limit"); + files.push({ name, data, crc: crc32(data), offset: 0 }); + } + const archiveSize = localSize + centralSize + 22; + if (archiveSize > 0xffffffff) + throw new Error("ZIP archive exceeds the 4 GiB ZIP32 limit"); + const out = new Uint8Array(archiveSize); const v = new DataView(out.buffer); let p = 0; - /* common fields of local header + central entry: version 2.0, no flags, + /* common fields of local header + central entry: version 2.0, UTF-8 flag, * method 0 (store), DOS timestamp 1980-01-01 00:00:00 */ const common = (f: typeof files[0]) => { v.setUint16(p, 20, true); p += 2; /* version needed */ - v.setUint16(p, 0, true); p += 2; /* flags */ + v.setUint16(p, 0x0800, true); p += 2; /* UTF-8 names */ v.setUint16(p, 0, true); p += 2; /* method: store */ v.setUint16(p, 0, true); p += 2; /* mod time */ v.setUint16(p, 0x21, true); p += 2; /* mod date */ @@ -87,7 +149,7 @@ export function storeZip(entries: [name: string, data: Uint8Array][]): Uint8Arra export function storeZipBlob(entries: [name: string, data: Uint8Array][]): Blob { if (entries.length > 0xffff) throw new Error(`ZIP has ${entries.length} files; ZIP32 supports at most 65535`); - const enc = new TextEncoder(); + const enc = textEncoder; const parts: BlobPart[] = []; const files: Array<{ name: Uint8Array; @@ -97,6 +159,7 @@ export function storeZipBlob(entries: [name: string, data: Uint8Array][]): Blob }> = []; let offset = 0; for (const [path, data] of entries) { + assertZipEntryPath(path); if (data.length > 0xffffffff) throw new Error(`${path}: file is too large for ZIP32`); const name = enc.encode(path); diff --git a/tests/disc-regional.test.ts b/tests/disc-regional.test.ts new file mode 100644 index 0000000..ff5e5b1 --- /dev/null +++ b/tests/disc-regional.test.ts @@ -0,0 +1,1800 @@ +import assert from "node:assert/strict"; +import { setImmediate as waitForImmediate } from "node:timers/promises"; +import { test } from "node:test"; + +import { + discSupportWarning, + friendlyError, + LAST_DISC_KEY, + resumeSession, + type DiscSession, +} from "../src/disc.ts"; +import { + assertAttachedDiscIdentity, + assertCacheSourceIdentity, +} from "../src/disc-identity.ts"; +import { ImageStore, type ImageCatalog } from "../src/images.ts"; +import { fingerprintBytes } from "../src/content-identity.ts"; +import { Exporter } from "../src/export.ts"; +import { + DiscOpenCoordinator, + SerializedDiscCleanup, + type DiscOpenTicket, +} from "../src/disc-open.ts"; +import { MovieController } from "../src/movie-controller.ts"; +import { + MovieStore, + scanMovieCatalog, + type MovieCatalog, +} from "../src/movies.ts"; +import { SeInspector, SeStore } from "../src/se.ts"; +import { StreamDecoder, StreamPlayer } from "../src/streams.ts"; +import { + OpfsCache, + type ImageTexture, + type Vfi, + type VfiEntry, +} from "../src/vendor/extract/index.ts"; +async function commitDiscWhenCurrent( + coordinator: DiscOpenCoordinator, + ticket: DiscOpenTicket, + pending: Promise, + commit: (value: T) => void, +): Promise { + const value = await pending; + if (coordinator.isCurrent(ticket)) commit(value); +} + + +const encoder = new TextEncoder(); + +function setU32(bytes: Uint8Array, offset: number, value: number, + littleEndian = true): void { + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength) + .setUint32(offset, value, littleEndian); +} + +function makeInspectableFmv(): Uint8Array { + const preload = 0x20; + const groupOffset = 0x800 + preload; + const video = new Uint8Array(30); + video.set([0, 0, 1, 0xb3], 0); + setU32(video, 4, (720 << 20) | (448 << 8) | (1 << 4) | 4, false); + video.set([0, 0, 1, 0xb5, 0x10, 0x08, 0, 0], 8); + video.set([0, 0, 1, 0xb8], 16); + video.set([0, 0, 1, 0x00, 0x00, 0x08], 20); + video.set([0, 0, 1, 0xb7], 26); + + const bytes = new Uint8Array(groupOffset + 48 + 32 + 32); + bytes.set(encoder.encode("str\0"), 0); + setU32(bytes, 0x08, 2); + setU32(bytes, 0x0c, 5994); + setU32(bytes, 0x10, 1); + setU32(bytes, 0x20, 48_000); + setU32(bytes, 0x24, 2); + setU32(bytes, 0x28, 0x10); + setU32(bytes, 0x2c, 0x20); + setU32(bytes, 0x30, preload); + setU32(bytes, 0x34, preload); + + bytes.set(encoder.encode("GroupOfDataInfo\0"), groupOffset); + setU32(bytes, groupOffset + 0x14, 16); + setU32(bytes, groupOffset + 0x20, 2); + setU32(bytes, groupOffset + 0x24, 1); + + const videoOffset = groupOffset + 48; + bytes.set(encoder.encode("Mpeg2Video\0"), videoOffset); + setU32(bytes, videoOffset + 0x14, video.length); + bytes.set(video, videoOffset + 32); + return bytes; +} + + +function mixedMovieVfi(): Vfi { + const valid = makeInspectableFmv(); + const invalid = new Uint8Array(0x800); + const validEntry: VfiEntry = { + entryOff: 16, + entrySize: 16, + parentOff: 0, + sector: 1, + size: valid.length, + name: "new_play01.str", + path: "regional/private/movie/new_play01.str", + }; + const invalidEntry: VfiEntry = { + entryOff: 80, + entrySize: 16, + parentOff: 0, + sector: 5, + size: invalid.length, + name: "broken.str", + path: "regional/private/movie/broken.str", + }; + const storage = new Uint8Array(0x3000); + storage.set(valid, validEntry.sector * 0x800); + storage.set(invalid, invalidEntry.sector * 0x800); + const entries = [validEntry, invalidEntry]; + return { + entries, + src: { + size: storage.length, + async read(offset: number, size: number): Promise { + return storage.slice(offset, offset + size); + }, + }, + byteOffset(entry: VfiEntry): number { + return entry.sector * 0x800; + }, + async read(entry: VfiEntry): Promise { + const offset = entry.sector * 0x800; + return storage.slice(offset, offset + entry.size); + }, + find(path: string): VfiEntry | null { + return entries.find(entry => entry.path === path) ?? null; + }, + } as unknown as Vfi; +} + +interface CacheDouble { + key: string; + read(name: string): Promise; + has(name: string): Promise; + write(name: string, data: Uint8Array): Promise; + size(name: string): Promise; + remove(name: string, recursive?: boolean): Promise; +} + +function cacheDouble(overrides: Partial = {}): OpfsCache { + return Object.assign({ + key: "disc", + async read(): Promise { return null; }, + async has(): Promise { return false; }, + async write(): Promise {}, + async size(): Promise { return null; }, + async remove(): Promise {}, + }, overrides) as unknown as OpfsCache; +} + +function makeTim2(): Uint8Array { + const imageSize = 1; + const clutSize = 256 * 4; + const bytes = new Uint8Array(0x10 + 0x30 + imageSize + clutSize); + const picture = 0x10; + const view = new DataView(bytes.buffer); + bytes.set(encoder.encode("TIM2"), 0); + bytes[4] = 4; + bytes[6] = 1; + view.setUint32(picture, 0x30 + imageSize + clutSize, true); + view.setUint32(picture + 4, clutSize, true); + view.setUint32(picture + 8, imageSize, true); + view.setUint16(picture + 12, 0x30, true); + view.setUint16(picture + 14, 256, true); + bytes[picture + 17] = 1; + bytes[picture + 18] = 0x83; + bytes[picture + 19] = 5; + view.setUint16(picture + 20, 1, true); + view.setUint16(picture + 22, 1, true); + return bytes; +} + +function imageFixture(): { + bytes: Uint8Array; + texture: ImageTexture; + vfi: Vfi; +} { + const bytes = makeTim2(); + const entry: VfiEntry = { + entryOff: 0x10, + entrySize: 16, + parentOff: 0, + sector: 0, + size: bytes.length, + name: "test.tm2", + path: "debug/test/static/test.tm2", + }; + const texture: ImageTexture = { + id: "00000010-direct", + sourcePath: entry.path, + memberIndex: null, + memberName: null, + fileName: entry.name, + attrs: "tm2", + byteLength: bytes.length, + role: "other", + roleEvidence: "direct", + pictures: [{ + index: 0, + width: 1, + height: 1, + imageType: 5, + clutType: 0x83, + colorCount: 256, + mipmapCount: 1, + }], + }; + const vfi = { + entries: [entry], + async read(candidate: VfiEntry): Promise { + assert.equal(candidate, entry); + return bytes; + }, + find(path: string): VfiEntry | null { + return path === entry.path ? entry : null; + }, + } as unknown as Vfi; + return { bytes, texture, vfi }; +} +function mixedImageVfi(): { readonly valid: Uint8Array; readonly vfi: Vfi } { + const valid = makeTim2(); + const malformed = new Uint8Array(16); + const entries: VfiEntry[] = [ + { + entryOff: 0x10, + entrySize: 16, + parentOff: 0, + sector: 0, + size: valid.length, + name: "valid.tm2", + path: "debug/test/static/valid.tm2", + }, + { + entryOff: 0x20, + entrySize: 16, + parentOff: 0, + sector: 1, + size: malformed.length, + name: "placeholder.tm2", + path: "debug/test/static/placeholder.tm2", + }, + ]; + const byOffset = new Map([ + [entries[0]!.entryOff, valid], + [entries[1]!.entryOff, malformed], + ]); + const vfi = { + entries, + async read(entry: VfiEntry): Promise { + return byOffset.get(entry.entryOff)!; + }, + } as unknown as Vfi; + return { valid, vfi }; +} + +async function imageCatalogFor(texture: ImageTexture, + bytes: Uint8Array, + sourceKey = "disc"): Promise { + return { + v: 4, + sourceKey, + storage: "containers", + textures: [texture], + sources: [{ + entryOffset: Number.parseInt(texture.id.slice(0, 8), 16), + sourcePath: texture.sourcePath, + ...await fingerprintBytes(bytes), + }], + issues: [], + }; +} + + +function effectsFixture(): { + entry: { name: string; hd: string; bd: string; bytes: number }; + hd: Uint8Array; + bd: Uint8Array; + vfi: Vfi; +} { + const hd = Uint8Array.of(1, 2, 3); + const bd = Uint8Array.of(4, 5, 6, 7); + const entries: VfiEntry[] = [ + { + entryOff: 0x10, + entrySize: 16, + parentOff: 0, + sector: 1, + size: hd.length, + name: "effect.hd", + path: "debug/test/sound/se/effect.hd", + }, + { + entryOff: 0x20, + entrySize: 16, + parentOff: 0, + sector: 2, + size: bd.length, + name: "effect.bd", + path: "debug/test/sound/se/effect.bd", + }, + ]; + const byName = new Map([ + ["effect.hd", hd], + ["effect.bd", bd], + ]); + const vfi = { + entries, + async read(candidate: VfiEntry): Promise { + return byName.get(candidate.name)!; + }, + } as unknown as Vfi; + return { + entry: { name: "effect", hd: "effect.hd", bd: "effect.bd", bytes: 7 }, + hd, + bd, + vfi, + }; +} + +function incompleteSubtitleVfi(): Vfi { + const movie = makeInspectableFmv(); + const play: VfiEntry = { + entryOff: 16, + entrySize: 16, + parentOff: 0, + sector: 1, + size: movie.length, + name: "new_play01.str", + path: "debug/test/movie/new_play01.str", + }; + const scene: VfiEntry = { + entryOff: 80, + entrySize: 16, + parentOff: 0, + sector: 5, + size: movie.length, + name: "new_scene01.str", + path: "debug/test/movie/new_scene01.str", + }; + const subtitle: VfiEntry = { + entryOff: 144, + entrySize: 16, + parentOff: 0, + sector: 9, + size: 1, + name: "scene01.bin", + path: "debug/test/movie/scene01.bin", + }; + const storage = new Uint8Array(0x6000); + storage.set(movie, play.sector * 0x800); + storage.set(movie, scene.sector * 0x800); + const entries = [play, scene, subtitle]; + return { + entries, + src: { + size: storage.length, + async read(offset: number, size: number): Promise { + return storage.slice(offset, offset + size); + }, + }, + byteOffset(entry: VfiEntry): number { + return entry.sector * 0x800; + }, + async read(entry: VfiEntry): Promise { + const offset = entry.sector * 0x800; + return storage.slice(offset, offset + entry.size); + }, + find(path: string): VfiEntry | null { + return entries.find(entry => entry.path === path) ?? null; + }, + } as unknown as Vfi; +} + +function unreadableSubtitleVfi(): Vfi { + const movie = makeInspectableFmv(); + const entries: VfiEntry[] = [ + { + entryOff: 16, + entrySize: 16, + parentOff: 0, + sector: 1, + size: movie.length, + name: "new_scene01.str", + path: "debug/test/movie/new_scene01.str", + }, + { + entryOff: 80, + entrySize: 16, + parentOff: 0, + sector: 5, + size: 4, + name: "scene01.bin", + path: "debug/test/movie/scene01.bin", + }, + { + entryOff: 144, + entrySize: 16, + parentOff: 0, + sector: 6, + size: 4, + name: "scene01.sbt", + path: "debug/test/movie/scene01.sbt", + }, + ]; + const storage = new Uint8Array(0x4000); + storage.set(movie, entries[0]!.sector * 0x800); + return { + entries, + src: { + size: storage.length, + async read(offset: number, size: number): Promise { + return storage.slice(offset, offset + size); + }, + }, + byteOffset(entry: VfiEntry): number { + return entry.sector * 0x800; + }, + async read(entry: VfiEntry): Promise { + if (entry !== entries[0]) + throw new Error("subtitle source offline"); + const offset = entry.sector * 0x800; + return storage.slice(offset, offset + entry.size); + }, + find(path: string): VfiEntry | null { + return entries.find(entry => entry.path === path) ?? null; + }, + } as unknown as Vfi; +} + +function installLocalStorage(storage: Storage): () => void { + const prior = Object.getOwnPropertyDescriptor(globalThis, "localStorage"); + Object.defineProperty(globalThis, "localStorage", { + configurable: true, + value: storage, + }); + return () => { + if (prior) + Object.defineProperty(globalThis, "localStorage", prior); + else + Reflect.deleteProperty(globalThis, "localStorage"); + }; +} + +function installHistoryState(state: unknown): () => void { + const prior = Object.getOwnPropertyDescriptor(globalThis, "history"); + let current = state; + Object.defineProperty(globalThis, "history", { + configurable: true, + value: { + get state(): unknown { return current; }, + replaceState(next: unknown): void { current = next; }, + } as unknown as History, + }); + return () => { + if (prior) + Object.defineProperty(globalThis, "history", prior); + else + Reflect.deleteProperty(globalThis, "history"); + }; +} + +class SeWorkerDouble { + static readonly instances: SeWorkerDouble[] = []; + onmessage: ((event: { data: unknown }) => void) | null = null; + onerror: ((event: { + message: string; + preventDefault(): void; + }) => void) | null = null; + onmessageerror: (() => void) | null = null; + posted: unknown = null; + postError: Error | null = null; + terminated = false; + errorPrevented = false; + + constructor(..._args: unknown[]) { + SeWorkerDouble.instances.push(this); + } + + postMessage(message: unknown): void { + if (this.postError) throw this.postError; + this.posted = message; + } + + terminate(): void { + this.terminated = true; + } + + emitMessage(data: unknown): void { + this.onmessage?.({ data }); + } + + emitError(message: string): void { + this.onerror?.({ + message, + preventDefault: () => { + this.errorPrevented = true; + }, + }); + } + + emitMessageError(): void { + this.onmessageerror?.(); + } +} + +function postedWorkerId(worker: SeWorkerDouble): number { + assert.ok(worker.posted !== null && typeof worker.posted === "object"); + const id = Reflect.get(worker.posted, "id"); + assert.equal(typeof id, "number"); + return id; +} + +function installWorkerDouble(): () => void { + const prior = Object.getOwnPropertyDescriptor(globalThis, "Worker"); + SeWorkerDouble.instances.length = 0; + Object.defineProperty(globalThis, "Worker", { + configurable: true, + value: SeWorkerDouble, + }); + return () => { + if (prior) + Object.defineProperty(globalThis, "Worker", prior); + else + Reflect.deleteProperty(globalThis, "Worker"); + }; +} + +test("movie catalog loading is deferred until FMV tab activation", async () => { + const pending = Promise.withResolvers(); + let catalogCalls = 0; + const session = { + movies: { + catalog(): Promise { + catalogCalls++; + return pending.promise; + }, + hasIso(): boolean { return true; }, + }, + } as unknown as DiscSession; + const controller = new MovieController({ + changed() {}, + pauseOtherMedia() {}, + deliver() {}, + }); + + await controller.connect(session); + try { + assert.equal(catalogCalls, 0); + controller.setActive(true); + assert.equal(catalogCalls, 1); + pending.resolve({ v: 1, entries: [], issues: [] }); + await waitForImmediate(); + assert.equal(controller.snapshot().catalog?.entries.length, 0); + } finally { + pending.resolve({ v: 1, entries: [], issues: [] }); + await waitForImmediate(); + await controller.dispose(); + } +}); + +test("explicit open wins when delayed resume resolves first", async () => { + const coordinator = new DiscOpenCoordinator(); + let active: string | null = null; + const resume = Promise.withResolvers(); + const explicit = Promise.withResolvers(); + const resumeTicket = coordinator.begin(); + const resumeCommit = commitDiscWhenCurrent( + coordinator, resumeTicket, resume.promise, value => { active = value; }); + + const explicitTicket = coordinator.begin(); + active = null; + const explicitCommit = commitDiscWhenCurrent( + coordinator, explicitTicket, explicit.promise, value => { active = value; }); + resume.resolve("resumed"); + await resumeCommit; + assert.equal(active, null); + explicit.resolve("explicit"); + await explicitCommit; + assert.equal(active, "explicit"); +}); + +test("delayed resume cannot overwrite an explicit open that resolves first", async () => { + const coordinator = new DiscOpenCoordinator(); + let active: string | null = null; + const resume = Promise.withResolvers(); + const explicit = Promise.withResolvers(); + const resumeTicket = coordinator.begin(); + const resumeCommit = commitDiscWhenCurrent( + coordinator, resumeTicket, resume.promise, value => { active = value; }); + + const explicitTicket = coordinator.begin(); + active = null; + const explicitCommit = commitDiscWhenCurrent( + coordinator, explicitTicket, explicit.promise, value => { active = value; }); + explicit.resolve("explicit"); + await explicitCommit; + assert.equal(active, "explicit"); + resume.resolve("resumed"); + await resumeCommit; + assert.equal(active, "explicit"); +}); + +test("superseding open waits for prior disc cleanup before parsing", async () => { + const coordinator = new DiscOpenCoordinator(); + const cleanup = new SerializedDiscCleanup(); + const firstClose = Promise.withResolvers(); + const events: string[] = []; + + async function open(name: string, close: Promise): Promise { + const ticket = coordinator.begin(); + await cleanup.run(async () => { + events.push(`reset:${name}`); + await close; + }); + if (coordinator.isCurrent(ticket)) events.push(`parse:${name}`); + } + + const first = open("first", firstClose.promise); + await waitForImmediate(); + const second = open("second", Promise.resolve()); + await waitForImmediate(); + assert.deepEqual(events, ["reset:first"]); + + firstClose.resolve(); + await Promise.all([first, second]); + assert.deepEqual(events, ["reset:first", "reset:second", "parse:second"]); +}); + +test("new disc movie preparation is not blocked by an aborted prior task", async () => { + const catalog = await scanMovieCatalog(mixedMovieVfi(), "disc"); + const pending = new Promise(() => {}); + let firstPreparations = 0; + let secondPreparations = 0; + const sessionFor = (prepare: () => Promise): DiscSession => ({ + movies: { + async catalog(): Promise { return catalog; }, + hasIso(): boolean { return true; }, + async cacheInfo(): Promise<{ + sourceBytes: number; + exportBytes: number; + totalBytes: number; + }> { + return { sourceBytes: 0, exportBytes: 0, totalBytes: 0 }; + }, + prepare, + }, + } as unknown as DiscSession); + const first = sessionFor(() => { + firstPreparations++; + return pending; + }); + const second = sessionFor(() => { + secondPreparations++; + return pending; + }); + const controller = new MovieController({ + changed() {}, + pauseOtherMedia() {}, + deliver() {}, + }); + + controller.setActive(true); + await controller.connect(first); + await waitForImmediate(); + assert.equal(firstPreparations, 1); + + await controller.connect(second); + await waitForImmediate(); + assert.equal(secondPreparations, 1); + await controller.dispose(); +}); +test("failed movie source attachment keeps its scoped error and catalog", async context => { + + context.mock.method(console, "error", () => {}); + const catalog = await scanMovieCatalog(mixedMovieVfi(), "disc"); + const session = { + movies: { + async catalog(): Promise { return catalog; }, + hasIso(): boolean { return false; }, + async cacheInfo(): Promise<{ sourceBytes: number; exportBytes: number; + totalBytes: number }> { + return { sourceBytes: 0, exportBytes: 0, totalBytes: 0 }; + }, + async attachIso(): Promise { + throw new Error("this ISO is a different disc than the cached one"); + }, + }, + } as unknown as DiscSession; + const controller = new MovieController({ + changed() {}, + pauseOtherMedia() {}, + deliver() {}, + }); + + try { + await controller.connect(session); + controller.setActive(true); + await waitForImmediate(); + await controller.attachIso({} as File); + const snapshot = controller.snapshot(); + assert.equal(snapshot.error, true); + assert.match(snapshot.status, /different disc/); + assert.equal(snapshot.catalog?.entries.length, 1); + } finally { + await controller.dispose(); + } +}); + +test("failed attached movie scan offers source reattachment", async context => { + context.mock.method(console, "error", () => {}); + const session = { + movies: { + async catalog(): Promise { + throw new Error("movie source offline"); + }, + hasIso(): boolean { return true; }, + }, + } as unknown as DiscSession; + const controller = new MovieController({ + changed() {}, + pauseOtherMedia() {}, + deliver() {}, + }); + + try { + await controller.connect(session); + controller.setActive(true); + await waitForImmediate(); + const snapshot = controller.snapshot(); + assert.equal(snapshot.error, true); + assert.equal(snapshot.catalog, null); + assert.equal(snapshot.hasIso, true); + assert.equal(snapshot.sourceRequired, true); + assert.match(snapshot.status, /movie source offline/); + } finally { + await controller.dispose(); + } +}); + +test("movie scan preserves valid assets beside an unsupported container", async () => { + const catalog = await scanMovieCatalog(mixedMovieVfi(), "disc"); + assert.deepEqual(catalog.entries.map(entry => entry.name), ["new_play01"], + JSON.stringify(catalog.issues)); + assert.equal(catalog.issues.length, 1); + assert.equal(catalog.issues[0]?.name, "broken"); + assert.match(catalog.issues[0]?.reason ?? "", /bad str magic/); + assert.doesNotMatch(catalog.issues[0]?.reason ?? "", /regional|private|movie\//i); +}); + +test("incomplete subtitle discovery isolates one movie and keeps neighbors", async () => { + const catalog = await scanMovieCatalog(incompleteSubtitleVfi(), "disc"); + assert.deepEqual(catalog.entries.map(entry => entry.name), ["new_play01"]); + assert.deepEqual(catalog.issues.map(issue => issue.name), ["new_scene01"]); + assert.match(catalog.issues[0]!.reason, /incomplete subtitle pair/); + assert.doesNotMatch(catalog.issues[0]!.reason, /debug[\\/]test|scene01\.bin/i); +}); + +test("movie scanning propagates operational source failures", async () => { + const base = mixedMovieVfi(); + const failures: Array<{ + reason: RegExp; + read(offset: number, size: number): Promise; + }> = [ + { + reason: /movie source offline/, + async read(): Promise { + throw new Error("movie source offline"); + }, + }, + { + reason: /short read/, + async read(offset: number, size: number): Promise { + const data = await base.src.read(offset, size); + return data.subarray(0, Math.max(0, data.length - 1)); + }, + }, + { + reason: /disc source was removed/, + async read(): Promise { + throw new DOMException( + "disc source was removed", + "NotReadableError", + ); + }, + }, + ]; + for (const failure of failures) { + const failing = { + entries: base.entries, + src: { + size: base.src.size, + read: failure.read, + }, + byteOffset(entry: VfiEntry): number { + return base.byteOffset(entry); + }, + read(entry: VfiEntry): Promise { + return failure.read(base.byteOffset(entry), entry.size); + }, + find(path: string): VfiEntry | null { + return base.find(path); + }, + } as unknown as Vfi; + await assert.rejects(scanMovieCatalog(failing, "disc"), failure.reason); + } +}); + +test("subtitle source failures remain scan-fatal", async () => { + await assert.rejects( + scanMovieCatalog(unreadableSubtitleVfi(), "disc"), + /subtitle source offline/, + ); +}); + +test("operational movie-cache reads fall back to the attached disc", async context => { + context.mock.method(console, "warn", () => {}); + const cache = cacheDouble({ + async read(): Promise { + throw new Error("OPFS read denied"); + }, + }); + const store = new MovieStore(cache, mixedMovieVfi(), "disc"); + const catalog = await store.catalog(); + assert.equal(catalog?.entries.length, 1); + assert.match( + store.persistenceWarning ?? "", + /movie cache is unavailable.*using the attached ISO/i, + ); +}); + +test("attached movie playback survives cache accounting and cleanup failures", async context => { + context.mock.method(console, "warn", () => {}); + const catalog = await scanMovieCatalog(mixedMovieVfi(), "disc"); + const measured = new MovieStore(cacheDouble({ + async size(): Promise { + throw new Error("OPFS size denied"); + }, + }), mixedMovieVfi(), "disc", catalog); + assert.deepEqual(await measured.cacheInfo("new_play01"), { + sourceBytes: 0, + exportBytes: 0, + totalBytes: 0, + }); + assert.match(measured.persistenceWarning ?? "", /using the attached ISO/i); + + const corrupted = new MovieStore(cacheDouble({ + async read(name: string): Promise { + return name.endsWith("/source_meta.json") + ? encoder.encode("{") + : null; + }, + }), mixedMovieVfi(), "disc", catalog); + assert.deepEqual(await corrupted.cacheInfo("new_play01"), { + sourceBytes: 0, + exportBytes: 0, + totalBytes: 0, + }); + assert.match(corrupted.persistenceWarning ?? "", /using the attached ISO/i); + + const cleanup = new MovieStore(cacheDouble({ + async remove(): Promise { + throw new Error("OPFS remove denied"); + }, + }), mixedMovieVfi(), "disc", catalog); + const prepared = await cleanup.prepare("new_play01", () => {}); + assert.ok(prepared.video.byteLength > 0); + assert.match(cleanup.persistenceWarning ?? "", /playback remains available/i); +}); +test("movie cache rejects incomplete, impossible, and intersecting metadata", async () => { + const catalog = await scanMovieCatalog(mixedMovieVfi(), "disc"); + const entry = catalog.entries[0]!; + const issue = catalog.issues[0]!; + const incomplete = { + + v: 1, + entries: [{ + ...entry, + video: { ...entry.video, frameRate: undefined }, + }], + issues: [], + }; + const duplicate = { + v: 1, + entries: [entry, entry], + issues: [], + }; + const zeroSized = { + v: 1, + entries: [{ + ...entry, + movie: { ...entry.movie, size: 0 }, + sourceBytes: 0, + }], + issues: [], + }; + const entryIssueIntersection = { + v: 1, + entries: [entry], + issues: [{ ...issue, name: entry.name }], + }; + for (const metadata of [ + incomplete, + duplicate, + zeroSized, + entryIssueIntersection, + ]) { + const raw = encoder.encode(JSON.stringify(metadata)); + const store = new MovieStore(cacheDouble({ + async read(): Promise { return raw; }, + }), null, "disc"); + await assert.rejects(store.catalog(), /movie index is damaged/); + } +}); + +test("complete persisted movie metadata remains reusable without an ISO", async () => { + const catalog = await scanMovieCatalog(mixedMovieVfi(), "disc"); + const raw = encoder.encode(JSON.stringify(catalog)); + const store = new MovieStore(cacheDouble({ + async read(): Promise { return raw; }, + }), null, "disc"); + const cached = await store.catalog(); + assert.deepEqual(cached?.entries.map(entry => entry.name), ["new_play01"]); + assert.deepEqual(cached?.issues.map(issue => issue.name), ["broken"]); +}); + +test("movie source requirement stays conservative while cache state resolves", async () => { + const catalog = await scanMovieCatalog(mixedMovieVfi(), "disc"); + const pending = Promise.withResolvers<{ + sourceBytes: number; + exportBytes: number; + totalBytes: number; + }>(); + const session = { + movies: { + async catalog(): Promise { return catalog; }, + hasIso(): boolean { return false; }, + cacheInfo(): Promise<{ + sourceBytes: number; + exportBytes: number; + totalBytes: number; + }> { + return pending.promise; + }, + }, + } as unknown as DiscSession; + const controller = new MovieController({ + changed() {}, + pauseOtherMedia() {}, + deliver() {}, + }); + + try { + await controller.connect(session); + controller.setActive(true); + await waitForImmediate(); + const snapshot = controller.snapshot(); + assert.equal(snapshot.catalog?.entries.length, 1); + assert.equal(snapshot.cache, null); + assert.equal(snapshot.sourceRequired, true); + } finally { + pending.resolve({ sourceBytes: 0, exportBytes: 0, totalBytes: 0 }); + await waitForImmediate(); + await controller.dispose(); + } +}); + +test("source attachment is blocked while an older catalog load is pending", async () => { + const catalog = await scanMovieCatalog(mixedMovieVfi(), "disc"); + const pending = Promise.withResolvers(); + let attachCalls = 0; + const session = { + movies: { + catalog(): Promise { return pending.promise; }, + hasIso(): boolean { return false; }, + async attachIso(): Promise { attachCalls++; }, + async cacheInfo(): Promise<{ + sourceBytes: number; + exportBytes: number; + totalBytes: number; + }> { + return { sourceBytes: 0, exportBytes: 0, totalBytes: 0 }; + }, + }, + } as unknown as DiscSession; + const controller = new MovieController({ + changed() {}, + pauseOtherMedia() {}, + deliver() {}, + }); + + try { + await controller.connect(session); + controller.setActive(true); + const attachment = controller.attachIso({} as File); + await waitForImmediate(); + assert.equal(attachCalls, 0); + await attachment; + } finally { + pending.resolve(catalog); + await waitForImmediate(); + await controller.dispose(); + } +}); + +test("cache-only Images and Effects failures remain actionable", async () => { + const failing = cacheDouble({ + async read(): Promise { + throw new Error("OPFS read denied"); + }, + }); + await assert.rejects( + new ImageStore(failing, null, "disc").cached(), + /image cache is unavailable.*reconnect the same disc/i, + ); + await assert.rejects( + new SeStore(failing, null, "disc").cached(), + /Effects cache is unavailable.*reconnect the same disc/, + ); + const malformed = cacheDouble({ + async read(): Promise { + return encoder.encode("{\"v\":1,\"entries\":[{}]}"); + }, + }); + await assert.rejects( + new ImageStore(malformed, null, "disc").cached(), + /image index is damaged.*reconnect the same disc/i, + ); + await assert.rejects( + new SeStore(malformed, null, "disc").cached(), + /Effects index is damaged.*reconnect the same disc/, + ); +}); + +test("cache-only image payload failures request source reattachment", async () => { + const fixture = imageFixture(); + const catalog = await imageCatalogFor(fixture.texture, fixture.bytes); + const metadata = encoder.encode(JSON.stringify(catalog)); + const cache = cacheDouble({ + async read(name: string): Promise { + if (name === "image_meta.json") return metadata; + throw new Error("OPFS image container read denied"); + }, + }); + const store = new ImageStore(cache, null, "disc"); + assert.ok(await store.cached()); + await assert.rejects( + store.read(fixture.texture), + /image cache is unavailable.*reconnect the same disc/i, + ); +}); + +test("Images and Effects reject impossible persisted catalogs", async () => { + const fixture = imageFixture(); + const texture = fixture.texture; + const picture = texture.pictures[0]!; + const base = await imageCatalogFor(texture, fixture.bytes); + const invalidTextures = [ + { ...texture, pictures: [] }, + { ...texture, pictures: [{ ...picture, index: 1 }] }, + { ...texture, pictures: [picture, picture] }, + { ...texture, sourcePath: "" }, + { ...texture, byteLength: 0 }, + { ...texture, roleEvidence: "unsupported" }, + ]; + for (const invalid of invalidTextures) { + const raw = encoder.encode(JSON.stringify({ + ...base, + textures: [invalid], + })); + await assert.rejects( + new ImageStore(cacheDouble({ + async read(): Promise { return raw; }, + }), null, "disc").cached(), + /image index is damaged.*reconnect the same disc/i, + ); + } + + const emptyEffects = encoder.encode(JSON.stringify({ v: 1, entries: [] })); + await assert.rejects( + new SeStore(cacheDouble({ + async read(): Promise { return emptyEffects; }, + }), null, "disc").cached(), + /Effects index is damaged.*reconnect the same disc/, + ); +}); +test("image catalogs reject lossy texture IDs before container lookup", async () => { + const fixture = imageFixture(); + const base = await imageCatalogFor(fixture.texture, fixture.bytes); + const invalidTextures = [ + { ...fixture.texture, id: "000000010-direct" }, + { ...fixture.texture, id: "0000001g-direct" }, + { ...fixture.texture, id: "00000010-direct0" }, + { ...fixture.texture, id: "00000010-00", + memberIndex: 0, memberName: "test.tm2" }, + { ...fixture.texture, id: "00000010-01", + memberIndex: 1, memberName: "test.tm2" }, + { ...fixture.texture, id: "00000010-0" }, + { ...fixture.texture, id: "00000010-direct", + memberIndex: 0, memberName: "test.tm2" }, + { ...fixture.texture, id: "00000010-9007199254740992", + memberIndex: 9_007_199_254_740_992, memberName: "test.tm2" }, + ]; + + for (const texture of invalidTextures) { + const reads: string[] = []; + const raw = encoder.encode(JSON.stringify({ + ...base, + textures: [texture], + })); + await assert.rejects( + new ImageStore(cacheDouble({ + async read(name: string): Promise { + reads.push(name); + return raw; + }, + }), null, "disc").cached(), + /image index is damaged.*reconnect the same disc/i, + ); + assert.deepEqual(reads, ["image_meta.json"]); + } +}); + +test("image source fingerprints reject same-layout payload changes", async () => { + const fixture = imageFixture(); + const catalog = await imageCatalogFor(fixture.texture, fixture.bytes); + const changed = fixture.bytes.slice(); + changed[changed.length - 1] ^= 1; + const metadata = encoder.encode(JSON.stringify(catalog)); + const store = new ImageStore(cacheDouble({ + async read(name: string): Promise { + if (name === "image_meta.json") return metadata; + if (name === "image-container/00000010.bin") return changed; + return null; + }, + }), null, "disc"); + const restored = await store.cached(); + assert.ok(restored); + await assert.rejects( + store.read(restored.textures[0]!), + /image source content does not match this catalog/, + ); +}); + +test("malformed image containers persist as issues beside valid neighbors", async () => { + const fixture = mixedImageVfi(); + const files = new Map(); + const cache = cacheDouble({ + async read(name: string): Promise { + return files.get(name) ?? null; + }, + async has(name: string): Promise { + return files.has(name); + }, + async write(name: string, data: Uint8Array): Promise { + files.set(name, data.slice()); + }, + }); + const attached = new ImageStore(cache, fixture.vfi, "disc"); + const extracted = await attached.extract(() => {}); + assert.equal(extracted.textures.length, 1); + assert.equal(extracted.issues.length, 1); + assert.equal(extracted.issues[0]!.format, "TIM2"); + assert.doesNotMatch(extracted.issues[0]!.reason, + /debug[\\/]test|placeholder\.tm2|\/Users\//i); + assert.deepEqual( + await attached.read(extracted.textures[0]!), + fixture.valid, + ); + + const resumed = new ImageStore(cache, null, "disc"); + const cached = await resumed.cached(); + assert.ok(cached); + assert.deepEqual(cached.issues, extracted.issues); + assert.deepEqual( + await resumed.read(cached.textures[0]!), + fixture.valid, + ); +}); + + +test("attached Images and Effects fall back after cache read failures", + async context => { + context.mock.method(console, "warn", () => {}); + const failing = (): OpfsCache => cacheDouble({ + async read(): Promise { + throw new Error("OPFS read denied"); + }, + }); + const image = imageFixture(); + const imageStore = new ImageStore(failing(), image.vfi, "disc"); + assert.equal(await imageStore.cached(), null); + const imageCatalog = await imageStore.extract(() => {}); + const imageBytes = await imageStore.read(imageCatalog.textures[0]!); + assert.deepEqual(imageBytes, image.bytes); + assert.match(imageStore.persistenceWarning ?? "", + /OPFS read denied.*using the attached ISO for this session/); + + const effects = effectsFixture(); + const effectsStore = new SeStore(failing(), effects.vfi, "disc"); + assert.equal(await effectsStore.cached(), null); + const effectsCatalog = await effectsStore.extract(() => {}); + const bank = await effectsStore.bank(effectsCatalog.entries[0]!); + assert.deepEqual(bank.hd, effects.hd); + assert.deepEqual(bank.bd, effects.bd); + assert.match(effectsStore.persistenceWarning ?? "", + /OPFS read denied.*using the attached ISO for this session/); +}); + +test("Images survive data and metadata cache-write failures", async context => { + context.mock.method(console, "warn", () => {}); + const dataFixture = imageFixture(); + const dataStore = new ImageStore(cacheDouble({ + async has(): Promise { return false; }, + async write(): Promise { + throw new Error("container write denied"); + }, + }), dataFixture.vfi, "disc"); + const dataCatalog = await dataStore.extract(() => {}); + assert.equal(dataCatalog.textures.length, 1); + assert.deepEqual( + await dataStore.read(dataCatalog.textures[0]!), + dataFixture.bytes, + ); + + const metadataFixture = imageFixture(); + const metadataStore = new ImageStore(cacheDouble({ + async has(): Promise { return true; }, + async write(name: string): Promise { + if (name === "image_meta.json") + throw new Error("metadata write denied"); + assert.match(name, /^image-container\/[0-9a-f]{8}\.bin$/); + }, + }), metadataFixture.vfi, "disc"); + const metadataCatalog = await metadataStore.extract(() => {}); + assert.equal(metadataCatalog.textures.length, 1); + assert.deepEqual( + await metadataStore.read(metadataCatalog.textures[0]!), + metadataFixture.bytes, + ); +}); + +test("Effects survive data and metadata cache-write failures", async context => { + context.mock.method(console, "warn", () => {}); + const dataFixture = effectsFixture(); + const dataStore = new SeStore(cacheDouble({ + async has(): Promise { return false; }, + async write(): Promise { + throw new Error("bank write denied"); + }, + }), dataFixture.vfi, "disc"); + const dataCatalog = await dataStore.extract(() => {}); + assert.equal(dataCatalog.entries.length, 1); + assert.deepEqual(await dataStore.bank(dataCatalog.entries[0]!), { + hd: dataFixture.hd, + bd: dataFixture.bd, + }); + + const metadataFixture = effectsFixture(); + const metadataStore = new SeStore(cacheDouble({ + async has(): Promise { return true; }, + async write(name: string): Promise { + assert.equal(name, "se_meta.json"); + throw new Error("metadata write denied"); + }, + }), metadataFixture.vfi, "disc"); + const metadataCatalog = await metadataStore.extract(() => {}); + assert.equal(metadataCatalog.entries.length, 1); + assert.deepEqual(await metadataStore.bank(metadataCatalog.entries[0]!), { + hd: metadataFixture.hd, + bd: metadataFixture.bd, + }); +}); + +test("Effects worker rejects malformed responses and recovers after failure", async () => { + const restore = installWorkerDouble(); + const inspector = new SeInspector(); + const files = () => ({ + hd: Uint8Array.of(1), + bd: Uint8Array.of(2), + }); + try { + const malformed = inspector.inspect(files()); + const firstWorker = SeWorkerDouble.instances[0]!; + firstWorker.emitMessage({ + t: "unexpected", + id: postedWorkerId(firstWorker), + }); + await assert.rejects(malformed, /invalid SE worker response/); + assert.equal(firstWorker.terminated, true); + + const crashed = inspector.inspect(files()); + const secondWorker = SeWorkerDouble.instances[1]!; + secondWorker.emitError("worker crashed"); + await assert.rejects(crashed, /worker crashed/); + assert.equal(secondWorker.errorPrevented, true); + assert.equal(secondWorker.terminated, true); + + const recovered = inspector.inspect(files()); + const thirdWorker = SeWorkerDouble.instances[2]!; + thirdWorker.emitMessage({ + t: "se-inspect-done", + id: postedWorkerId(thirdWorker), + inspection: { + requests: Uint16Array.of(7), + details: [Array(7).fill(null)], + ticksPerSecond: 480, + }, + }); + const inspection = await recovered; + assert.deepEqual(inspection.requests, Uint16Array.of(7)); + + const measured = inspector.measure( + { ...files(), irx: null, libsd: null }, + 0, + 0, + true, + 30, + ); + thirdWorker.emitMessage({ + t: "se-measure-done", + id: postedWorkerId(thirdWorker), + measure: { frames: 480, sustained: false, estimated: false }, + }); + assert.deepEqual(await measured, { + frames: 480, + sustained: false, + estimated: false, + }); + + thirdWorker.postError = new Error("worker post denied"); + await assert.rejects(inspector.inspect(files()), /worker post denied/); + + thirdWorker.postError = null; + const invalidTicks = inspector.inspect(files()); + thirdWorker.emitMessage({ + t: "se-inspect-done", + id: postedWorkerId(thirdWorker), + inspection: { + requests: Uint16Array.of(1), + details: [[null]], + ticksPerSecond: Number.NaN, + }, + }); + await assert.rejects(invalidTicks, /invalid SE worker response/); + assert.equal(thirdWorker.terminated, true); + + const mismatchedDetails = inspector.inspect(files()); + const fourthWorker = SeWorkerDouble.instances[3]!; + fourthWorker.emitMessage({ + t: "se-inspect-done", + id: postedWorkerId(fourthWorker), + inspection: { + requests: Uint16Array.of(1), + details: [[]], + ticksPerSecond: 480, + }, + }); + await assert.rejects(mismatchedDetails, /invalid SE worker response/); + assert.equal(fourthWorker.terminated, true); + + const impossibleDetail = inspector.inspect(files()); + const fifthWorker = SeWorkerDouble.instances[4]!; + fifthWorker.emitMessage({ + t: "se-inspect-done", + id: postedWorkerId(fifthWorker), + inspection: { + requests: Uint16Array.of(1), + details: [[{ + durationTicks: 1, + exactFrames: 1, + consoleFrames: 1, + notes: 0, + controls: 0, + activeVoices: 0, + loopingVoices: 1, + sustainedVoices: 0, + sustained: false, + sourceEndExactFrame: 1, + sourceEndConsoleFrame: 1, + loop: null, + events: [], + }]], + ticksPerSecond: 480, + }, + }); + await assert.rejects(impossibleDetail, /invalid SE worker response/); + assert.equal(fifthWorker.terminated, true); + + const invalidMeasure = inspector.measure( + { ...files(), irx: null, libsd: null }, + 0, + 0, + true, + 30, + ); + const sixthWorker = SeWorkerDouble.instances[5]!; + sixthWorker.emitMessage({ + t: "se-measure-done", + id: postedWorkerId(sixthWorker), + measure: { frames: -1, sustained: false, estimated: false }, + }); + + await assert.rejects(invalidMeasure, /invalid SE worker response/); + assert.equal(sixthWorker.terminated, true); + + const undecodable = inspector.inspect(files()); + const seventhWorker = SeWorkerDouble.instances[6]!; + seventhWorker.emitMessageError(); + await assert.rejects(undecodable, /invalid SE worker response/); + assert.equal(seventhWorker.terminated, true); + } finally { + inspector.dispose(); + restore(); + } +}); + +test("stream worker rejects unknown IDs and message deserialization failures", async () => { + const restore = installWorkerDouble(); + const decoder = new StreamDecoder(); + try { + const unknown = decoder.decode("call.vag", Uint8Array.of(1)); + const firstWorker = SeWorkerDouble.instances[0]!; + firstWorker.emitMessage({ + t: "stream-done", + id: postedWorkerId(firstWorker) + 1, + }); + await assert.rejects(unknown, /invalid stream worker response/); + assert.equal(firstWorker.terminated, true); + + const undecodable = decoder.decode("call.vag", Uint8Array.of(1)); + const secondWorker = SeWorkerDouble.instances[1]!; + secondWorker.emitMessageError(); + await assert.rejects(undecodable, /invalid stream worker response/); + assert.equal(secondWorker.terminated, true); + } finally { + decoder.dispose(); + restore(); + } +}); +test("stream playback reports AudioContext resume failures", async () => { + const priorContext = Object.getOwnPropertyDescriptor(globalThis, "AudioContext"); + const priorBuffer = Object.getOwnPropertyDescriptor(globalThis, "AudioBuffer"); + let stopped = false; + class AudioBufferDouble { + constructor(_options: unknown) {} + copyToChannel(): void {} + } + class AudioContextDouble { + readonly destination = {}; + readonly currentTime = 0; + resume(): Promise { + return Promise.reject(new Error("audio output denied")); + } + createBufferSource() { + return { + buffer: null, + onended: null, + connect() {}, + start() {}, + stop() { stopped = true; }, + }; + } + } + Object.defineProperty(globalThis, "AudioContext", { + configurable: true, + value: AudioContextDouble, + }); + Object.defineProperty(globalThis, "AudioBuffer", { + configurable: true, + value: AudioBufferDouble, + }); + const player = new StreamPlayer(); + const failure = Promise.withResolvers(); + player.onerror = failure.resolve; + player.load({ + name: "voice.x", + header: { + channels: 1, + rate: 48000, + loop: 0, + loop_start: -1, + length: 1, + vol_l: Array(8).fill(0), + vol_r: Array(8).fill(0), + reverb: Array(8).fill(0), + }, + sectors: 1, + padFrames: 0, + samplesPerChannel: 1, + pcm: Int16Array.of(0), + }, true); + try { + player.play(); + assert.match((await failure.promise).message, /audio output denied/); + assert.equal(player.playing(), false); + assert.equal(stopped, true); + } finally { + player.stop(); + if (priorContext) + Object.defineProperty(globalThis, "AudioContext", priorContext); + else + Reflect.deleteProperty(globalThis, "AudioContext"); + if (priorBuffer) + Object.defineProperty(globalThis, "AudioBuffer", priorBuffer); + else + Reflect.deleteProperty(globalThis, "AudioBuffer"); + } +}); + +test("audio exporter retires workers after malformed responses", () => { + const restore = installWorkerDouble(); + const exporter = new Exporter(); + const failures: string[] = []; + exporter.onstatus = (message, error) => { + if (error) failures.push(message); + }; + const song = { + hd: Uint8Array.of(1), + bd: Uint8Array.of(2), + mid: Uint8Array.of(3), + irx: null, + libsd: null, + }; + const opts = { + songvol: 44, + revDepth: 0, + exact: true, + bright: true, + loop: 0, + }; + try { + exporter.start("song", "", song, opts); + const firstWorker = SeWorkerDouble.instances[0]!; + firstWorker.emitMessage({ t: "done", frames: 0, wav: null }); + assert.equal(exporter.busy, false); + assert.equal(firstWorker.terminated, true); + assert.match(failures.at(-1) ?? "", /invalid audio export worker response/); + + exporter.start("song", "", song, opts); + const secondWorker = SeWorkerDouble.instances[1]!; + secondWorker.emitMessageError(); + assert.equal(exporter.busy, false); + assert.equal(secondWorker.terminated, true); + + exporter.kit("song", song); + const thirdWorker = SeWorkerDouble.instances[2]!; + thirdWorker.emitMessage({ + t: "kit-done", + entries: [{ name: "sample.wav", wav: new Uint8Array(44) }], + }); + assert.equal(exporter.busy, false); + assert.equal(thirdWorker.terminated, true); + } finally { + exporter.dispose(); + restore(); + } +}); + +test("resume surfaces localStorage operational failures", async () => { + const storage = { + getItem(): string | null { + throw new Error("localStorage access denied"); + }, + } as unknown as Storage; + const restore = installLocalStorage(storage); + try { + await assert.rejects(resumeSession(), /localStorage access denied/); + } finally { + restore(); + } +}); + +test("ephemeral disc reload does not resume a stale cached pointer", async () => { + let pointerReads = 0; + const storage = { + getItem(): string | null { + pointerReads++; + return "stale-disc"; + }, + } as unknown as Storage; + const restoreStorage = installLocalStorage(storage); + const restoreHistory = installHistoryState({ ae3EphemeralDisc: true }); + try { + assert.equal(await resumeSession(), null); + assert.equal(pointerReads, 0); + } finally { + restoreHistory(); + restoreStorage(); + } +}); + +test("resume surfaces OPFS operational failures", async context => { + const storage = { + getItem(key: string): string | null { + assert.equal(key, LAST_DISC_KEY); + return "disc"; + }, + } as unknown as Storage; + const restore = installLocalStorage(storage); + context.mock.method(OpfsCache, "supported", () => true); + context.mock.method(OpfsCache, "open", async (): Promise => + cacheDouble({ + async read(): Promise { + throw new Error("OPFS session metadata read denied"); + }, + })); + try { + await assert.rejects( + resumeSession(), + /OPFS session metadata read denied/, + ); + } finally { + restore(); + } +}); + +test("resume reports damaged current metadata and ignores old versions", async context => { + const storage = { + getItem(): string | null { return "disc"; }, + } as unknown as Storage; + const restore = installLocalStorage(storage); + const payloads = [ + encoder.encode("{"), + encoder.encode(JSON.stringify({ + v: 1, + serial: null, + volumeId: "APE3", + songs: [], + hasIrx: false, + hasLibsd: false, + })), + ]; + context.mock.method(OpfsCache, "supported", () => true); + context.mock.method(OpfsCache, "open", async (): Promise => + cacheDouble({ + async read(): Promise { + return payloads.shift() ?? null; + }, + })); + try { + await assert.rejects(resumeSession(), /cached disc metadata is damaged/); + assert.equal(await resumeSession(), null); + } finally { + restore(); + } +}); + +test("resume accepts a complete persisted session shape", async context => { + const storage = { + getItem(): string | null { return "disc"; }, + } as unknown as Storage; + const restore = installLocalStorage(storage); + const metadata = encoder.encode(JSON.stringify({ + v: 2, + sourceKey: "disc", + serial: "SCUS_975.01", + volumeId: "APE3", + songs: [{ + name: "song", + mid: "song.mid", + hd: "song.hd", + bd: "song.bd", + songvol: 64, + volumeScale: 1, + }], + hasIrx: true, + hasLibsd: true, + assets: [ + "bgm/song.hd", + "bgm/song.bd", + "bgm/song.mid", + "irx/sg2iopm1.irx", + "irx/libsd.irx", + ].map((name, index) => ({ + name, + bytes: index + 1, + sha256: index.toString(16).repeat(64), + })), + })); + context.mock.method(OpfsCache, "supported", () => true); + context.mock.method(OpfsCache, "open", async (): Promise => + cacheDouble({ + async read(): Promise { return metadata; }, + })); + try { + const resumed = await resumeSession(); + assert.equal(resumed?.serial, "SCUS_975.01"); + assert.deepEqual(resumed?.songs.map(song => song.name), ["song"]); + assert.equal(resumed?.persistenceWarning, null); + } finally { + restore(); + } +}); + +test("every non-US or unknown build keeps a conservative support warning", () => { + assert.equal(discSupportWarning("SCUS_975.01"), ""); + for (const serial of [ + "PCPX_966.57", + "SCKA_200.62", + "SCES_536.42", + null, + ]) { + const warning = discSupportWarning(serial); + assert.match(warning, /partially tested|untested/); + if (serial) assert.match(warning, new RegExp(serial.replace(".", "\\."))); + } +}); + +test("friendly errors retain technical detail without exposing source paths", () => { + const local = friendlyError(new Error( + "/Users/example/Downloads/Ape Escape 3.iso: permission denied", + )); + assert.doesNotMatch(local, /\/Users|Downloads|Ape Escape 3\.iso/); + assert.match(local, /permission denied|local data/i); + + const spacedPosix = friendlyError(new Error( + "could not read /tmp/Ape Escape 3.iso", + )); + assert.equal(spacedPosix, "local data access failed"); + + const internal = friendlyError(new Error( + "debug/uk/movie/new_play01.str at 0x830: expected GroupOfDataInfo, found empty", + )); + assert.doesNotMatch(internal, /debug\/uk|new_play01\.str/); + assert.match(internal, /0x830.*expected GroupOfDataInfo/); + + const punctuated = friendlyError(new Error( + "parser failed: debug/uk/movie/new_play02.str: bad header", + )); + assert.doesNotMatch(punctuated, /debug[\\/]uk|new_play02/); + assert.match(punctuated, /parser failed.*bad header/); + + const windowsRelative = friendlyError(new Error( + "parser failed; debug\\kr\\movie\\new_scene01.str: bad header", + )); + assert.doesNotMatch(windowsRelative, /debug|new_scene01/); + assert.match(windowsRelative, /bad header/); + + for (const source of [ + "file:///Users/example/Downloads/Ape%20Escape%203.iso: denied", + "open failed: C:\\Users\\example\\disc.iso: denied", + "open failed: \\\\server\\share\\secret\\disc.iso: denied", + ]) { + const reason = friendlyError(new Error(source)); + assert.doesNotMatch( + reason, + /file:|Users|Downloads|C:\\|server|share|secret|disc\.iso/i, + ); + } +}); + +test("cache and attached-source guards reject cross-disc reuse", () => { + const cache = { key: "disc-a" }; + assert.doesNotThrow(() => { + assertCacheSourceIdentity(cache, "disc-a"); + assertAttachedDiscIdentity("disc-a", "disc-a"); + }); + assert.throws( + () => assertCacheSourceIdentity(null, null as never), + /identity is unavailable/, + ); + assert.throws( + () => assertAttachedDiscIdentity(null as never, "disc-a"), + /identity is unavailable/, + ); + assert.throws(() => { + new MovieStore(null, null, null as never); + }, /identity is unavailable/); + assert.throws(() => { + new ImageStore(null, null, null as never); + }, /identity is unavailable/); + assert.throws(() => { + new SeStore(null, null, null as never); + }, /identity is unavailable/); + assert.throws( + () => assertCacheSourceIdentity(cache, "disc-b"), + /different disc session/, + ); + assert.throws( + () => assertAttachedDiscIdentity("disc-a", "disc-b"), + /different disc/, + ); + assert.throws(() => { + new MovieStore(cache as never, null, "disc-b"); + }, /different disc session/); + assert.throws(() => { + new ImageStore(cache as never, null, "disc-b"); + }, /different disc session/); + assert.throws(() => { + new SeStore(cache as never, null, "disc-b"); + }, /different disc session/); +}); diff --git a/tests/images.test.ts b/tests/images.test.ts index 4e02f43..652ad50 100644 --- a/tests/images.test.ts +++ b/tests/images.test.ts @@ -9,10 +9,12 @@ import { imageTreePath, type ImageCatalog, } from "../src/images.ts"; -import { storeZipBlob } from "../src/zip.ts"; +import { storeZip, storeZipBlob } from "../src/zip.ts"; const catalog: ImageCatalog = { - v: 3, + v: 4, + sourceKey: "disc", + storage: "containers", textures: [ { id: "00000010-direct", @@ -47,6 +49,21 @@ const catalog: ImageCatalog = { ], }, ], + sources: [ + { + entryOffset: 0x10, + sourcePath: "debug/us/static/logo.tm2", + bytes: 128, + sha256: "0".repeat(64), + }, + { + entryOffset: 0x20, + sourcePath: "debug/us/stage/zero/ui.pck.sz", + bytes: 256, + sha256: "1".repeat(64), + }, + ], + issues: [], }; test("image catalog expands every TIM2 picture", () => { @@ -67,6 +84,35 @@ test("image export paths preserve package hierarchy and picture identity", () => assert.equal(imageExportPath(entries[2]!, "tm2"), "debug/us/stage/zero/ui/cursor_1.tm2"); }); +test("image export paths reject untrusted source and member names", () => { + const direct = imageEntries(catalog)[0]!; + const unsafe = [ + { ...direct, texture: { + ...direct.texture, + fileName: "../escape.tm2", + } }, + { ...direct, texture: { + ...direct.texture, + sourcePath: "/absolute/logo.tm2", + } }, + { ...direct, texture: { + ...direct.texture, + sourcePath: "logo.tm2", + fileName: "C:/escape.tm2", + } }, + { ...direct, texture: { + ...direct.texture, + fileName: "\\\\server\\share\\escape.tm2", + } }, + { ...direct, texture: { + ...direct.texture, + fileName: "bad\0name.tm2", + } }, + ]; + for (const entry of unsafe) + assert.throws(() => imageExportPath(entry, "png"), /ZIP entry path/); +}); + test("image filters use semantic roles and source metadata", () => { const entries = imageEntries(catalog); @@ -123,3 +169,43 @@ test("large image ZIP uses valid store-only Blob parts", async () => { assert.equal(view.getUint32(bytes.length - 22, true), 0x06054b50); assert.equal(view.getUint16(bytes.length - 14, true), 2); }); +test("every ZIP writer rejects non-canonical or unsafe entry paths", () => { + const payload = Uint8Array.of(1); + const unsafe = [ + "", + "/absolute.bin", + "C:/drive.bin", + "\\\\server\\share\\file.bin", + "dir\\file.bin", + "file:alternate-stream.bin", + "dir/../escape.bin", + "dir/./file.bin", + "dir//file.bin", + "nul\0file.bin", + "control\u001ffile.bin", + "e\u0301.bin", + `${"a".repeat(256)}.bin`, + `${Array.from({ length: 20 }, () => "a".repeat(220)).join("/")}.bin`, + "\ud800.bin", + ]; + const writers = [ + (path: string): unknown => storeZip([[path, payload]]), + (path: string): unknown => storeZipBlob([[path, payload]]), + ]; + for (const writer of writers) { + for (const path of unsafe) + assert.throws(() => writer(path), /ZIP entry path/); + } +}); + +test("byte-array ZIP rejects entry counts beyond ZIP32", () => { + const empty = new Uint8Array(); + const entries: [string, Uint8Array][] = Array.from( + { length: 0x10000 }, + (_, index) => [`f${index}`, empty], + ); + assert.throws( + () => storeZip(entries), + /ZIP32 supports at most 65535/, + ); +}); diff --git a/tests/movie-decoder-client.test.ts b/tests/movie-decoder-client.test.ts index ed772c5..150711d 100644 --- a/tests/movie-decoder-client.test.ts +++ b/tests/movie-decoder-client.test.ts @@ -134,3 +134,45 @@ test("rejects all pending work for an unknown current-generation request", async }); assert.equal(worker.terminated, true); }); + +test("rejects malformed nested frame data for the current request", async () => { + const { client, worker } = await initializedClient(); + const pulling = client.pull({ untilTimestamp: 1, maxFrames: 1, maxBytes: 6 }); + const request = worker.requests.at(-1)!; + worker.respond({ + type: "frames", + requestId: request.requestId, + generation: request.generation, + frames: [{ + index: -1, + timestamp: 0, + duration: 1001 / 30000, + width: 2, + height: 2, + format: "I420", + data: new ArrayBuffer(6), + layout: [ + { offset: 0, stride: 2 }, + { offset: 4, stride: 1 }, + { offset: 5, stride: 1 }, + ], + }], + eof: false, + stats: { + packets: 1, + decodedFrames: 1, + outputFrames: 1, + droppedFrames: 0, + decodeWallTime: 0, + pendingBytes: 0, + wasmBytes: 1, + }, + } as unknown as MovieDecoderResponse); + + await assert.rejects(pulling, (error: unknown) => { + assert.ok(error instanceof MovieDecoderWorkerError); + assert.match(error.message, /malformed response/); + return true; + }); + assert.equal(worker.terminated, true); +});