Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Binary file modified public/synth/ae3synth.wasm
Binary file not shown.
8 changes: 5 additions & 3 deletions public/synth/se.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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),
};
});
Expand Down
30 changes: 30 additions & 0 deletions src/content-identity.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<ContentFingerprint> {
const source: Uint8Array<ArrayBuffer> = 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;
}
34 changes: 34 additions & 0 deletions src/disc-identity.ts
Original file line number Diff line number Diff line change
@@ -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);
}
43 changes: 43 additions & 0 deletions src/disc-open.ts
Original file line number Diff line number Diff line change
@@ -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<void> = Promise.resolve();

run(operation: () => Promise<void>): Promise<void> {
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;
}
}
Loading
Loading