bdmv2web 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,3515 @@
1
+ "use strict";
2
+ (() => {
3
+ var __defProp = Object.defineProperty;
4
+ var __defNormalProp = (obj, key, value) => key in obj ? __defProp(obj, key, { enumerable: true, configurable: true, writable: true, value }) : obj[key] = value;
5
+ var __publicField = (obj, key, value) => __defNormalProp(obj, typeof key !== "symbol" ? key + "" : key, value);
6
+
7
+ // ../hdmv/src/decode.ts
8
+ var goto = ["NOP", "GOTO", "BREAK"];
9
+ var jump = ["JUMP_OBJECT", "JUMP_TITLE", "CALL_OBJECT", "CALL_TITLE", "RESUME"];
10
+ var play = ["PLAY_PL", "PLAY_PL_PI", "PLAY_PL_PM", "TERMINATE_PL", "LINK_PI", "LINK_MK"];
11
+ var compare = ["", "BC", "EQ", "NE", "GE", "GT", "LE", "LT"];
12
+ var arithmetic = ["", "MOVE", "SWAP", "ADD", "SUB", "MUL", "DIV", "MOD", "RND", "AND", "OR", "XOR", "BITSET", "BITCLR", "SHL", "SHR"];
13
+ var system = ["", "SET_STREAM", "SET_NV_TIMER", "SET_BUTTON_PAGE", "ENABLE_BUTTON", "DISABLE_BUTTON", "SET_SEC_STREAM", "POPUP_OFF", "STILL_ON", "STILL_OFF", "SET_OUTPUT_MODE", "SET_STREAM_SS"];
14
+ function bytesFrom(input) {
15
+ const fallback = { path: "<raw-command>", origin: "primary", offset: "0" };
16
+ if (input instanceof Uint8Array) {
17
+ if (input.length !== 12) throw new RangeError(`HDMV command must be 12 bytes; received ${input.length}`);
18
+ const bytes2 = [...input].map((x) => x.toString(16).padStart(2, "0")).join("");
19
+ return { bytes: bytes2, words: [0, 4, 8].map((i) => new DataView(input.buffer, input.byteOffset + i, 4).getUint32(0, false)), source: fallback };
20
+ }
21
+ if (typeof input === "string") {
22
+ const bytes2 = input.replace(/\s|0x/gi, "").toLowerCase();
23
+ if (!/^[0-9a-f]{24}$/.test(bytes2)) throw new TypeError("HDMV command hex must contain exactly 24 hexadecimal characters");
24
+ const raw2 = Uint8Array.from(bytes2.match(/../g), (x) => Number.parseInt(x, 16));
25
+ return bytesFrom(raw2);
26
+ }
27
+ const bytes = input.bytes.toLowerCase();
28
+ if (!/^[0-9a-f]{24}$/.test(bytes)) throw new TypeError("M1 instruction bytes must contain exactly 24 hexadecimal characters");
29
+ const raw = Uint8Array.from(bytes.match(/../g), (x) => Number.parseInt(x, 16));
30
+ const words = [0, 4, 8].map((i) => new DataView(raw.buffer, i, 4).getUint32(0, false));
31
+ if ("words" in input && input.words.some((word, i) => word !== words[i])) throw new TypeError("M1 instruction words do not match its raw bytes");
32
+ return { bytes, words, source: input.source };
33
+ }
34
+ function operand(raw, immediate) {
35
+ if (immediate) return { kind: "immediate", value: raw >>> 0, raw: raw >>> 0 };
36
+ const isPsr = (raw & 2147483648) !== 0;
37
+ const validMask = isPsr ? 2147483775 : 4095;
38
+ return { kind: isPsr ? "psr" : "gpr", value: raw & (isPsr ? 127 : 4095), raw: raw >>> 0, ...raw & ~validMask ? { invalid: true } : {} };
39
+ }
40
+ function decodeInstruction(input, source2) {
41
+ const raw = bytesFrom(input);
42
+ const [w0, w1, w2] = raw.words;
43
+ const opCount = w0 >>> 29;
44
+ const group = w0 >>> 27 & 3;
45
+ const subGroup = w0 >>> 24 & 7;
46
+ const immOp1 = !!(w0 & 8388608);
47
+ const immOp2 = !!(w0 & 4194304);
48
+ const branchOption = w0 >>> 16 & 15;
49
+ const compareOption = w0 >>> 8 & 15;
50
+ const setOption = w0 & 31;
51
+ const issues = [];
52
+ if ((w0 & 3207392) !== 0) issues.push("HDMV_RESERVED_BITS_NONZERO");
53
+ if (opCount > 2) issues.push("HDMV_INVALID_OPERAND_COUNT");
54
+ const specialRegisterWords = group === 2 && subGroup === 1 && [1, 3, 6].includes(setOption);
55
+ const operands = [opCount > 0 ? specialRegisterWords && !immOp1 ? { kind: "encoded", value: w1 >>> 0, raw: w1 >>> 0 } : operand(w1, immOp1) : null, opCount > 1 ? specialRegisterWords && !immOp2 ? { kind: "encoded", value: w2 >>> 0, raw: w2 >>> 0 } : operand(w2, immOp2) : null];
56
+ const operandRecords = [operands[0], operands[1]].filter((x) => x !== null);
57
+ if (operandRecords.some((x) => "invalid" in x)) issues.push("HDMV_INVALID_REGISTER_ENCODING");
58
+ let opcode = null;
59
+ if (group === 0) opcode = subGroup === 0 ? goto[branchOption] ?? null : subGroup === 1 ? jump[branchOption] ?? null : subGroup === 2 ? play[branchOption] ?? null : null;
60
+ else if (group === 1) opcode = compare[compareOption] || null;
61
+ else if (group === 2) opcode = subGroup === 0 ? arithmetic[setOption] || null : subGroup === 1 ? system[setOption] ?? (setOption === 16 ? "SETSYSTEM_0x10" : null) : null;
62
+ else issues.push("HDMV_UNKNOWN_GROUP");
63
+ if (!opcode) issues.push("HDMV_UNKNOWN_OPCODE");
64
+ const expected = opcode === "NOP" || opcode === "BREAK" || opcode === "RESUME" || ["TERMINATE_PL", "POPUP_OFF", "STILL_ON", "STILL_OFF"].includes(opcode ?? "") ? 0 : opcode === "SETSYSTEM_0x10" ? 2 : ["SWAP", "MOVE", "ADD", "SUB", "MUL", "DIV", "MOD", "RND", "AND", "OR", "XOR", "BITSET", "BITCLR", "SHL", "SHR", "BC", "EQ", "NE", "GE", "GT", "LE", "LT", "PLAY_PL_PI", "PLAY_PL_PM", "SET_STREAM", "SET_NV_TIMER", "SET_BUTTON_PAGE", "SET_SEC_STREAM", "SET_STREAM_SS"].includes(opcode ?? "") ? 2 : 1;
65
+ if (opcode && opCount !== expected) issues.push("HDMV_ILLEGAL_OPERAND_COUNT");
66
+ if (operands.some((x) => x?.kind === "immediate") && opcode && ["MOVE", "SWAP", "ADD", "SUB", "MUL", "DIV", "MOD", "AND", "OR", "XOR", "BITSET", "BITCLR", "SHL", "SHR"].includes(opcode) && operands[0]?.kind === "immediate") issues.push("HDMV_ILLEGAL_DESTINATION");
67
+ if (opcode === "RND" && operands[0]?.kind !== "gpr") issues.push("HDMV_ILLEGAL_DESTINATION");
68
+ return { ...raw, source: source2 ?? raw.source, group, subGroup, opCount, immOp1, immOp2, branchOption, compareOption, setOption, opcode, operands, issues, status: !opcode ? "invalid" : issues.length ? "unverified" : "reference-only" };
69
+ }
70
+
71
+ // ../hdmv/src/registers.ts
72
+ var GPR_COUNT = 4096;
73
+ var PSR_COUNT = 128;
74
+ var READ_ONLY_PSRS = /* @__PURE__ */ new Set([
75
+ 13,
76
+ // parental level
77
+ ...Array.from({ length: 7 }, (_, i) => 15 + i),
78
+ // player settings/capabilities
79
+ ...Array.from({ length: 9 }, (_, i) => 23 + i),
80
+ // display/profile capabilities
81
+ ...Array.from({ length: 14 }, (_, i) => 48 + i)
82
+ // player capability words
83
+ ]);
84
+ var RegisterFault = class extends Error {
85
+ constructor(code, message) {
86
+ super(message);
87
+ this.code = code;
88
+ }
89
+ };
90
+ var RegisterFile = class {
91
+ constructor(profile = {}) {
92
+ __publicField(this, "gpr", new Uint32Array(GPR_COUNT));
93
+ __publicField(this, "psr", new Uint32Array(PSR_COUNT));
94
+ this.reset(profile);
95
+ }
96
+ reset(profile = {}) {
97
+ this.gpr.fill(0);
98
+ this.psr.fill(0);
99
+ const defaults = { 0: 1, 1: 255, 2: 268374015, 3: 1, 4: 65535, 5: 65535, 6: 0, 7: 0, 8: 0, 9: 0, 10: 65535, 11: 0, 12: 255, 13: 255, 14: 65535, 15: 43690, 16: 16777215, 17: 16777215, 18: 16777215, 19: 65535, 20: 2, 21: 0, 22: 0, 23: 0, 24: 0, 25: 0, 26: 0, 27: 0, 28: 0, 29: 3, 30: 131071, 31: 197120, 36: 65535, 37: 65535, 38: 0, 39: 0, 40: 0, 42: 65535, 43: 0, 44: 255 };
100
+ for (const [index, value] of Object.entries(defaults)) this.psr[Number(index)] = value;
101
+ if (profile.audioLanguage !== void 0) this.psr[16] = profileWord(profile.audioLanguage, 3, "audioLanguage");
102
+ if (profile.subtitleLanguage !== void 0) this.psr[17] = profileWord(profile.subtitleLanguage, 3, "subtitleLanguage");
103
+ if (profile.menuLanguage !== void 0) this.psr[18] = profileWord(profile.menuLanguage, 3, "menuLanguage");
104
+ if (profile.country !== void 0) this.psr[19] = profileWord(profile.country, 2, "country");
105
+ if (profile.region !== void 0) this.psr[20] = regionWord(profile.region);
106
+ this.psr[21] = uint32(profile.outputPreference ?? defaults[21], "outputPreference");
107
+ for (let i = 48; i <= 61; i++) this.psr[i] = 4294967295;
108
+ for (const [index, value] of Object.entries(profile.capabilityValues ?? {})) {
109
+ const psr = Number(index);
110
+ if (!(psr === 15 || psr >= 23 && psr <= 31 || psr >= 48 && psr <= 61)) throw new TypeError(`PSR ${psr} is not a player capability/profile register`);
111
+ this.psr[psr] = uint32(value, `capabilityValues[${psr}]`);
112
+ }
113
+ }
114
+ read(ref) {
115
+ const slot = ref.kind === "gpr" ? this.gpr : this.psr;
116
+ if (!Number.isInteger(ref.index) || ref.index < 0 || ref.index >= slot.length) throw new RegisterFault("INVALID_REGISTER_ACCESS", `${ref.kind.toUpperCase()} index ${ref.index} is out of range`);
117
+ return slot[ref.index];
118
+ }
119
+ write(ref, value) {
120
+ if (!Number.isInteger(value) || value < 0 || value > 4294967295) throw new RegisterFault("INVALID_REGISTER_VALUE", `Register value ${value} is not uint32`);
121
+ if (ref.kind === "psr") throw new RegisterFault("INVALID_REGISTER_ACCESS", `Program write to PSR ${ref.index} is not permitted`);
122
+ this.writeSlot(this.gpr, ref.index, value);
123
+ }
124
+ /** Apply a runtime/system PSR update using libbluray's writable-register policy. */
125
+ systemWrite(index, value) {
126
+ this.validateIndex(this.psr, index, "PSR");
127
+ if (READ_ONLY_PSRS.has(index)) throw new RegisterFault("INVALID_REGISTER_ACCESS", `System update to read-only PSR ${index} is not permitted`);
128
+ this.writeSlot(this.psr, index, value);
129
+ }
130
+ snapshot() {
131
+ return { gpr: [...this.gpr], psr: [...this.psr] };
132
+ }
133
+ writeSlot(slot, index, value) {
134
+ this.validateIndex(slot, index);
135
+ if (!Number.isInteger(value) || value < 0 || value > 4294967295) throw new RegisterFault("INVALID_REGISTER_VALUE", `Register value ${value} is not uint32`);
136
+ slot[index] = value >>> 0;
137
+ }
138
+ validateIndex(slot, index, kind = "Register") {
139
+ if (!Number.isInteger(index) || index < 0 || index >= slot.length) throw new RegisterFault("INVALID_REGISTER_ACCESS", `${kind} index ${index} is out of range`);
140
+ }
141
+ };
142
+ function profileWord(value, width, name) {
143
+ if (typeof value === "number") return uint32(value, name);
144
+ if (value.length !== width || !/^[A-Za-z]+$/.test(value)) throw new TypeError(`${name} must be ${width} ASCII letters or an encoded uint32 PSR word`);
145
+ let word = 0;
146
+ for (let i = 0; i < value.length; i += 1) word = word * 256 + value.charCodeAt(i) >>> 0;
147
+ return word;
148
+ }
149
+ function regionWord(value) {
150
+ if (typeof value === "number") return uint32(value, "region");
151
+ if (value === "A") return 1;
152
+ if (value === "B") return 2;
153
+ if (value === "C") return 4;
154
+ throw new TypeError("region must be A, B, C, or an encoded uint32 PSR word");
155
+ }
156
+ function uint32(value, name) {
157
+ if (!Number.isInteger(value) || value < 0 || value > 4294967295) throw new TypeError(`${name} must be an unsigned 32-bit integer`);
158
+ return value;
159
+ }
160
+
161
+ // ../core/dist/defaults.js
162
+ var DEFAULT_PLAYER_PROFILE = Object.freeze({
163
+ menuLanguage: "eng",
164
+ audioLanguage: "eng",
165
+ subtitleLanguage: "eng",
166
+ country: "US",
167
+ region: "A",
168
+ outputProfile: "portable",
169
+ videoProfiles: [],
170
+ audioProfiles: []
171
+ });
172
+
173
+ // ../core/dist/contract-error.js
174
+ var ContractError = class extends Error {
175
+ constructor(code, path, message) {
176
+ super(`${code} at ${path}: ${message}`);
177
+ __publicField(this, "code");
178
+ __publicField(this, "path");
179
+ this.code = code;
180
+ this.path = path;
181
+ this.name = "ContractError";
182
+ }
183
+ };
184
+
185
+ // ../core/dist/limits.js
186
+ var DEFAULT_LIMITS = Object.freeze({
187
+ maxReadWindowBytes: 8 * 1024 * 1024,
188
+ maxSegmentBytes: 16 * 1024 * 1024,
189
+ maxReassemblyBytes: 64 * 1024 * 1024,
190
+ maxObjectPixels: 16777216,
191
+ maxInstructionsPerTurn: 1e4,
192
+ maxQueueDepth: 1024,
193
+ maxCallDepth: 128,
194
+ maxExplorationStates: 1e5,
195
+ maxOutputBytes: null
196
+ });
197
+ function validateLimits(value) {
198
+ if (typeof value !== "object" || value === null || Array.isArray(value))
199
+ throw new ContractError("INVALID_LIMITS", "/", "Limits must be an object");
200
+ const allowedKeys = new Set(Object.keys(DEFAULT_LIMITS));
201
+ for (const key of Object.keys(value)) {
202
+ if (!allowedKeys.has(key))
203
+ throw new ContractError("INVALID_LIMIT", `/${key}`, "Unknown limit");
204
+ }
205
+ const merged = { ...DEFAULT_LIMITS, ...value };
206
+ for (const [key, v] of Object.entries(merged)) {
207
+ if (key === "maxOutputBytes" && v === null)
208
+ continue;
209
+ const zeroAllowed = (key === "maxCallDepth" || key === "maxQueueDepth") && v === 0;
210
+ if (typeof v !== "number" || !Number.isSafeInteger(v) || v < 0 || v === 0 && !zeroAllowed)
211
+ throw new ContractError("INVALID_LIMIT", `/${key}`, "Limit must be a non-negative safe integer for maxCallDepth/maxQueueDepth and a positive safe integer otherwise");
212
+ }
213
+ return merged;
214
+ }
215
+
216
+ // ../hdmv/src/vm.ts
217
+ var EMPTY_DELTA = { gpr: {}, psr: {} };
218
+ var WAIT_BY_ACTION = { PLAY_PLAYLIST: "ended", PLAY_PLAYLIST_ITEM: "ended", PLAY_PLAYLIST_MARK: "ended" };
219
+ var COMPARE_OPCODES = /* @__PURE__ */ new Set(["BC", "EQ", "NE", "GE", "GT", "LE", "LT"]);
220
+ var PSR_TIME_TICKS_PER_SECOND = 45e3;
221
+ var RANDOM_MULTIPLIER = 6364136223846793005n;
222
+ var RANDOM_MASK = 0xffffffffffffffffn;
223
+ function textInstruction(d) {
224
+ return `${d.opcode ?? "UNKNOWN"} ${d.operands.filter((x) => x !== null).map((x) => x.kind === "immediate" ? `#${x.value}` : `${x.kind}[${x.value}]`).join(",")}`.trim();
225
+ }
226
+ var HdmvVm = class {
227
+ constructor(programs, options = {}) {
228
+ __publicField(this, "limits");
229
+ __publicField(this, "registers");
230
+ __publicField(this, "trace", []);
231
+ __publicField(this, "actions", []);
232
+ __publicField(this, "diagnostics", []);
233
+ __publicField(this, "status", "idle");
234
+ __publicField(this, "programId", 0);
235
+ __publicField(this, "pc", 0);
236
+ __publicField(this, "generation", 0);
237
+ __publicField(this, "instructionsThisTurn", 0);
238
+ __publicField(this, "diagnostic", null);
239
+ __publicField(this, "programs", /* @__PURE__ */ new Map());
240
+ __publicField(this, "callStack", []);
241
+ __publicField(this, "continuation", null);
242
+ __publicField(this, "suspendedPlaylistContinuation", null);
243
+ __publicField(this, "suspendedPlaylistPlayback", null);
244
+ __publicField(this, "pendingTitleCall", null);
245
+ __publicField(this, "titleTransition", false);
246
+ __publicField(this, "menuCheckpoint", null);
247
+ __publicField(this, "sequence", 0);
248
+ __publicField(this, "opts");
249
+ __publicField(this, "consumedResponses", /* @__PURE__ */ new Set());
250
+ __publicField(this, "eventQueue", []);
251
+ __publicField(this, "actionQueue", []);
252
+ __publicField(this, "profile");
253
+ __publicField(this, "lastAction");
254
+ __publicField(this, "instructionDiagnostic", null);
255
+ __publicField(this, "resumeAfterPlaylistStop", false);
256
+ __publicField(this, "randomSeed");
257
+ __publicField(this, "randomState");
258
+ __publicField(this, "entries");
259
+ __publicField(this, "onTrace");
260
+ __publicField(this, "playback", { status: "idle", playlist: null, playItem: null, presentationTime: null, pendingActionId: null });
261
+ this.limits = Object.freeze(validateLimits(options.limits ?? {
262
+ ...DEFAULT_LIMITS,
263
+ ...options.maxInstructionsPerTurn === void 0 ? {} : { maxInstructionsPerTurn: options.maxInstructionsPerTurn },
264
+ ...options.maxCallDepth === void 0 ? {} : { maxCallDepth: options.maxCallDepth },
265
+ ...options.maxQueueDepth === void 0 ? {} : { maxQueueDepth: options.maxQueueDepth }
266
+ }));
267
+ this.opts = {
268
+ maxInstructionsPerTurn: this.limits.maxInstructionsPerTurn,
269
+ maxCallDepth: this.limits.maxCallDepth,
270
+ maxQueueDepth: this.limits.maxQueueDepth
271
+ };
272
+ this.entries = options.entries;
273
+ this.onTrace = options.onTrace;
274
+ this.profile = normalizeProfile(options.profile);
275
+ this.registers = new RegisterFile(this.profile);
276
+ if (options.limits && [options.maxInstructionsPerTurn, options.maxCallDepth, options.maxQueueDepth].some((value) => value !== void 0)) {
277
+ const diagnostic = { code: "VM_LIMITS_OVERRIDDEN", message: "The limits object takes precedence over scalar VM limit options.", source: null };
278
+ this.diagnostics.push(diagnostic);
279
+ this.record({ source: null, raw: null, decoded: "LIMITS", registerDelta: EMPTY_DELTA, status: "idle", diagnostic });
280
+ }
281
+ this.randomSeed = normalizeRandomSeed(options.randomSeed);
282
+ this.randomState = BigInt(this.randomSeed);
283
+ for (const p of programs) this.addProgram(p);
284
+ this.record({ source: null, raw: null, decoded: `RESET profile=${stableProfile(this.profile)} seed=${this.randomSeed}`, registerDelta: EMPTY_DELTA, status: "idle" });
285
+ }
286
+ addProgram(program) {
287
+ const p = program;
288
+ if (!Number.isInteger(p.id) || p.id < 0) throw new TypeError("Program id must be a non-negative integer");
289
+ this.programs.set(p.id, { id: p.id, instructions: p.instructions, ...p.resumeIntention !== void 0 ? { resumeIntention: p.resumeIntention } : {}, ...p.menuCallMask !== void 0 ? { menuCallMask: p.menuCallMask } : {}, ...p.titleSearchMask !== void 0 ? { titleSearchMask: p.titleSearchMask } : {} });
290
+ }
291
+ reset(programId = 0, profile = this.profile, randomSeed = this.randomSeed) {
292
+ const seed = normalizeRandomSeed(randomSeed);
293
+ this.randomSeed = seed;
294
+ this.randomState = BigInt(seed);
295
+ this.profile = normalizeProfile(profile);
296
+ this.registers.reset(this.profile);
297
+ this.programId = programId;
298
+ this.pc = 0;
299
+ this.status = "idle";
300
+ this.generation++;
301
+ this.callStack.length = 0;
302
+ this.continuation = null;
303
+ this.suspendedPlaylistContinuation = null;
304
+ this.suspendedPlaylistPlayback = null;
305
+ this.pendingTitleCall = null;
306
+ this.titleTransition = false;
307
+ this.menuCheckpoint = null;
308
+ this.eventQueue.length = 0;
309
+ this.actionQueue.length = 0;
310
+ this.actions.length = 0;
311
+ this.consumedResponses.clear();
312
+ this.instructionsThisTurn = 0;
313
+ this.diagnostic = null;
314
+ this.lastAction = void 0;
315
+ this.resumeAfterPlaylistStop = false;
316
+ this.playback = { status: "idle", playlist: null, playItem: null, presentationTime: null, pendingActionId: null };
317
+ this.record({ source: null, raw: null, decoded: `RESET profile=${stableProfile(this.profile)} seed=${seed}`, registerDelta: EMPTY_DELTA, status: "idle" });
318
+ }
319
+ snapshot() {
320
+ return { status: this.status, programId: this.programId, pc: this.pc, registers: this.registers.snapshot(), callStack: this.callStack.map((x) => ({ ...x })), playback: { ...this.playback }, generation: this.generation, waiting: this.continuation?.action ?? null, instructionsThisTurn: this.instructionsThisTurn, diagnostic: this.diagnostic };
321
+ }
322
+ /** Save all mutable execution state so a navigation branch can resume from this point. */
323
+ saveCheckpoint() {
324
+ return structuredClone({
325
+ status: this.status,
326
+ programId: this.programId,
327
+ pc: this.pc,
328
+ registers: this.registers.snapshot(),
329
+ callStack: this.callStack,
330
+ continuation: this.continuation,
331
+ suspendedPlaylistContinuation: this.suspendedPlaylistContinuation,
332
+ suspendedPlaylistPlayback: this.suspendedPlaylistPlayback,
333
+ pendingTitleCall: this.pendingTitleCall,
334
+ titleTransition: this.titleTransition,
335
+ menuCheckpoint: this.menuCheckpoint,
336
+ playback: this.playback,
337
+ generation: this.generation,
338
+ instructionsThisTurn: this.instructionsThisTurn,
339
+ diagnostic: this.diagnostic,
340
+ sequence: this.sequence,
341
+ profile: this.profile,
342
+ lastAction: this.lastAction,
343
+ resumeAfterPlaylistStop: this.resumeAfterPlaylistStop,
344
+ randomSeed: this.randomSeed,
345
+ randomState: this.randomState,
346
+ consumedResponses: [...this.consumedResponses],
347
+ eventQueue: this.eventQueue,
348
+ actionQueue: this.actionQueue,
349
+ trace: this.trace,
350
+ actions: this.actions,
351
+ diagnostics: this.diagnostics
352
+ });
353
+ }
354
+ /** Restore a checkpoint created by this VM's matching program set and options. */
355
+ restoreCheckpoint(checkpoint) {
356
+ if (checkpoint.registers.gpr.length !== this.registers.gpr.length || checkpoint.registers.psr.length !== this.registers.psr.length) throw new TypeError("VM checkpoint has incompatible register state");
357
+ normalizeRandomSeed(checkpoint.randomSeed);
358
+ const state = structuredClone(checkpoint);
359
+ this.status = state.status;
360
+ this.programId = state.programId;
361
+ this.pc = state.pc;
362
+ this.registers.gpr.set(state.registers.gpr);
363
+ this.registers.psr.set(state.registers.psr);
364
+ this.callStack.splice(0, this.callStack.length, ...state.callStack);
365
+ this.continuation = state.continuation;
366
+ this.suspendedPlaylistContinuation = state.suspendedPlaylistContinuation;
367
+ this.suspendedPlaylistPlayback = state.suspendedPlaylistPlayback;
368
+ this.pendingTitleCall = state.pendingTitleCall;
369
+ this.titleTransition = state.titleTransition;
370
+ this.menuCheckpoint = state.menuCheckpoint;
371
+ this.playback = state.playback;
372
+ this.generation = state.generation;
373
+ this.instructionsThisTurn = state.instructionsThisTurn;
374
+ this.diagnostic = state.diagnostic;
375
+ this.sequence = state.sequence;
376
+ this.profile = state.profile;
377
+ this.lastAction = state.lastAction;
378
+ this.resumeAfterPlaylistStop = state.resumeAfterPlaylistStop;
379
+ this.randomSeed = state.randomSeed;
380
+ this.randomState = state.randomState;
381
+ this.consumedResponses.clear();
382
+ for (const response of state.consumedResponses) this.consumedResponses.add(response);
383
+ this.eventQueue.splice(0, this.eventQueue.length, ...state.eventQueue);
384
+ this.actionQueue.splice(0, this.actionQueue.length, ...state.actionQueue);
385
+ this.trace.splice(0, this.trace.length, ...state.trace);
386
+ this.actions.splice(0, this.actions.length, ...state.actions);
387
+ this.diagnostics.splice(0, this.diagnostics.length, ...state.diagnostics);
388
+ }
389
+ /** Synchronize the active interactive-graphics page and focus into PSR11/PSR10. */
390
+ applyMenuContext(pageId, selectedButtonId) {
391
+ if (!Number.isInteger(pageId) || pageId < 0 || pageId > 255) throw new RangeError("Menu page ID must be an 8-bit unsigned integer");
392
+ if (selectedButtonId !== null && (!Number.isInteger(selectedButtonId) || selectedButtonId < 0 || selectedButtonId > 65535)) {
393
+ throw new RangeError("Selected button ID must be a 16-bit unsigned integer or null");
394
+ }
395
+ const before = this.registers.snapshot();
396
+ this.registers.systemWrite(10, selectedButtonId ?? 65535);
397
+ this.registers.systemWrite(11, pageId);
398
+ this.record({
399
+ source: null,
400
+ raw: null,
401
+ decoded: `MENU_CONTEXT page=${pageId} button=${selectedButtonId ?? "none"}`,
402
+ registerDelta: delta(before, this.registers.snapshot()),
403
+ status: this.status
404
+ });
405
+ }
406
+ /** Capture the live menu context before a user action can transfer control to a feature. */
407
+ beginMenuAction() {
408
+ if (this.status === "faulted" || this.status === "stopped") return;
409
+ this.menuCheckpoint = {
410
+ programId: this.programId,
411
+ pc: this.pc,
412
+ status: this.status,
413
+ registers: this.registers.snapshot(),
414
+ callStack: this.callStack.map((frame) => ({ ...frame })),
415
+ continuation: this.continuation,
416
+ suspendedPlaylistContinuation: this.suspendedPlaylistContinuation,
417
+ suspendedPlaylistPlayback: this.suspendedPlaylistPlayback ? { ...this.suspendedPlaylistPlayback } : null,
418
+ pendingTitleCall: this.pendingTitleCall,
419
+ titleTransition: this.titleTransition,
420
+ playback: { ...this.playback },
421
+ queuedActionIds: new Set(this.actionQueue.map((action) => action.id)),
422
+ diagnostic: this.diagnostic,
423
+ randomState: this.randomState,
424
+ firstAction: this.actions.length,
425
+ firstDiagnostic: this.diagnostics.length
426
+ };
427
+ this.diagnostic = null;
428
+ }
429
+ /** Restore the menu without fabricating an external player's completion event. */
430
+ recoverMenuAction(action) {
431
+ const saved = this.menuCheckpoint;
432
+ if (!saved || action && !this.actions.slice(saved.firstAction).some((candidate) => candidate.id === action.id)) return false;
433
+ this.registers.gpr.set(saved.registers.gpr);
434
+ this.registers.psr.set(saved.registers.psr);
435
+ this.programId = saved.programId;
436
+ this.pc = saved.pc;
437
+ this.status = saved.status;
438
+ this.callStack.splice(0, this.callStack.length, ...saved.callStack.map((frame) => ({ ...frame })));
439
+ this.continuation = saved.continuation;
440
+ this.suspendedPlaylistContinuation = saved.suspendedPlaylistContinuation;
441
+ this.suspendedPlaylistPlayback = saved.suspendedPlaylistPlayback ? { ...saved.suspendedPlaylistPlayback } : null;
442
+ this.pendingTitleCall = saved.pendingTitleCall;
443
+ this.playback = { ...saved.playback };
444
+ this.diagnostic = saved.diagnostic;
445
+ this.resumeAfterPlaylistStop = false;
446
+ this.lastAction = void 0;
447
+ this.titleTransition = saved.titleTransition;
448
+ this.randomState = saved.randomState;
449
+ if (this.diagnostics.length > saved.firstDiagnostic) this.diagnostics.splice(saved.firstDiagnostic);
450
+ if (saved.continuation) this.consumedResponses.delete(`${saved.continuation.action.generation}:${saved.continuation.action.id}`);
451
+ const retained = this.actionQueue.filter((queued) => saved.queuedActionIds.has(queued.id));
452
+ this.actionQueue.splice(0, this.actionQueue.length, ...retained);
453
+ this.menuCheckpoint = null;
454
+ this.record({ source: null, raw: null, decoded: "MENU_ACTION_RECOVERED", registerDelta: EMPTY_DELTA, status: this.status });
455
+ return true;
456
+ }
457
+ /** Execute one selected button's command in the current VM register/object context. Ordinary commands leave PC unchanged; object/title control transfers use the current VM context. GOTO and compare require executeButtonProgram(). The button identity is not an object ID. */
458
+ executeButtonCommand(command2, source2, context = "object") {
459
+ if (this.status === "faulted" || this.status === "stopped" || this.inputBlocked()) {
460
+ const diagnostic = { code: this.status === "suspended" ? "HDMV_INPUT_WHILE_SUSPENDED" : "HDMV_EVENT_AFTER_TERMINAL", message: `Button command rejected while VM is ${this.status}`, source: source2 ?? null };
461
+ this.record({ source: source2 ?? null, raw: null, decoded: null, registerDelta: EMPTY_DELTA, status: this.status, diagnostic });
462
+ return { ...this.snapshot(), buttonCommandTerminated: false };
463
+ }
464
+ this.resumeAfterPlaylistStop = false;
465
+ this.instructionsThisTurn = 0;
466
+ const savedPlaylistContinuation = this.hasPlaylistContinuation() ? this.continuation : null;
467
+ const before = this.registers.snapshot();
468
+ const from = { programId: this.programId, pc: this.pc };
469
+ let d;
470
+ try {
471
+ d = decodeInstruction(command2, source2);
472
+ } catch (error) {
473
+ this.failButtonProgram("HDMV_INVALID_INSTRUCTION", error instanceof Error ? error.message : String(error), source2 ?? null);
474
+ return { ...this.snapshot(), buttonCommandTerminated: false };
475
+ }
476
+ if (d.status === "invalid" || !d.opcode) {
477
+ this.failButtonProgram(d.issues[0] ?? "HDMV_UNKNOWN_OPCODE", `Cannot execute ${d.opcode ?? "unknown encoding"}: ${d.issues.join(", ") || d.bytes}`, d.source, d, before, from);
478
+ return { ...this.snapshot(), buttonCommandTerminated: false };
479
+ }
480
+ const program = this.programs.get(this.programId);
481
+ if (!program) {
482
+ this.failButtonProgram("HDMV_INVALID_TARGET", `No program/object ${this.programId}`, d.source, d, before, from);
483
+ return { ...this.snapshot(), buttonCommandTerminated: false };
484
+ }
485
+ if (this.instructionsThisTurn >= this.opts.maxInstructionsPerTurn) {
486
+ this.failButtonProgram("VM_EXECUTION_LIMIT", `Exceeded ${this.opts.maxInstructionsPerTurn} instructions in one turn`, d.source, d, before, from);
487
+ return { ...this.snapshot(), buttonCommandTerminated: false };
488
+ }
489
+ this.instructionsThisTurn++;
490
+ this.instructionDiagnostic = null;
491
+ if (d.opcode === "GOTO" || COMPARE_OPCODES.has(d.opcode)) {
492
+ this.noteIgnoredInstruction("HDMV_BUTTON_COMMAND_REQUIRES_PROGRAM", `${d.opcode} requires a button command sequence; use executeButtonProgram()`, d.source);
493
+ this.record({ programId: from.programId, pc: from.pc, source: d.source, raw: d.bytes, decoded: textInstruction(d), registerDelta: delta(before, this.registers.snapshot()), status: this.status, ...this.instructionDiagnostic ? { diagnostic: this.instructionDiagnostic } : {} });
494
+ this.instructionDiagnostic = null;
495
+ return { ...this.snapshot(), buttonCommandTerminated: false };
496
+ }
497
+ let buttonCommandTerminated;
498
+ if (d.opcode === "BREAK") {
499
+ buttonCommandTerminated = true;
500
+ this.record({ programId: from.programId, pc: from.pc, source: d.source, raw: d.bytes, decoded: textInstruction(d), registerDelta: EMPTY_DELTA, status: this.status });
501
+ return { ...this.snapshot(), buttonCommandTerminated };
502
+ }
503
+ if (this.status === "idle") this.status = "running";
504
+ try {
505
+ buttonCommandTerminated = this.execute(d, program, context) === true;
506
+ } catch (error) {
507
+ this.failButtonProgram(error instanceof RegisterFault ? error.code : "HDMV_EXECUTION_FAULT", error instanceof Error ? error.message : String(error), d.source, d, before, from);
508
+ return { ...this.snapshot(), buttonCommandTerminated: false };
509
+ }
510
+ this.record({ programId: from.programId, pc: from.pc, source: d.source, raw: d.bytes, decoded: textInstruction(d), ...this.lastAction ? { action: this.lastAction } : {}, registerDelta: delta(before, this.registers.snapshot()), status: this.status, ...this.instructionDiagnostic ? { diagnostic: this.instructionDiagnostic } : {} });
511
+ this.lastAction = void 0;
512
+ this.instructionDiagnostic = null;
513
+ if (this.resumeAfterPlaylistStop && this.status === "running") {
514
+ this.resumeAfterPlaylistStop = false;
515
+ return { ...this.runTurn(), buttonCommandTerminated };
516
+ }
517
+ if (savedPlaylistContinuation && this.continuation === savedPlaylistContinuation && this.status === "running") this.status = "suspended";
518
+ return { ...this.snapshot(), buttonCommandTerminated };
519
+ }
520
+ /** Execute a button's complete command array with a sequence-local PC. */
521
+ executeButtonProgram(commands, context = "object") {
522
+ const firstAction = this.actions.length;
523
+ if (this.status === "faulted" || this.status === "stopped" || this.inputBlocked()) {
524
+ const diagnostic = { code: this.status === "suspended" ? "HDMV_INPUT_WHILE_SUSPENDED" : "HDMV_EVENT_AFTER_TERMINAL", message: `Button program rejected while VM is ${this.status}`, source: null };
525
+ this.record({ source: null, raw: null, decoded: null, registerDelta: EMPTY_DELTA, status: this.status, diagnostic });
526
+ return { ...this.snapshot(), actions: [], terminated: false, buttonCommandTerminated: false, fault: this.status === "faulted" ? this.diagnostic : null };
527
+ }
528
+ const ownerProgramId = this.programId;
529
+ const ownerPc = this.pc;
530
+ const savedPlaylistContinuation = this.hasPlaylistContinuation() ? this.continuation : null;
531
+ if (this.status === "suspended") this.status = "running";
532
+ const program = this.programs.get(ownerProgramId) ?? { id: ownerProgramId, instructions: [] };
533
+ let pc = 0;
534
+ let terminated = false;
535
+ let buttonCommandTerminated = false;
536
+ this.resumeAfterPlaylistStop = false;
537
+ this.instructionsThisTurn = 0;
538
+ if (this.status === "idle") this.status = "running";
539
+ while (pc < commands.length && this.status === "running") {
540
+ if (this.instructionsThisTurn >= this.opts.maxInstructionsPerTurn) {
541
+ this.failButtonProgram("VM_EXECUTION_LIMIT", `Exceeded ${this.opts.maxInstructionsPerTurn} instructions in one turn`, null, void 0, void 0, { programId: ownerProgramId, pc });
542
+ break;
543
+ }
544
+ let d;
545
+ try {
546
+ d = decodeInstruction(commands[pc]);
547
+ } catch (error) {
548
+ this.failButtonProgram("HDMV_INVALID_INSTRUCTION", error instanceof Error ? error.message : String(error), null, void 0, void 0, { programId: ownerProgramId, pc });
549
+ break;
550
+ }
551
+ const instructionPc = pc;
552
+ pc += 1;
553
+ this.instructionsThisTurn++;
554
+ this.instructionDiagnostic = null;
555
+ const before = this.registers.snapshot();
556
+ const [a, b] = d.operands;
557
+ if (d.status === "invalid" || !d.opcode) {
558
+ this.failButtonProgram(d.issues[0] ?? "HDMV_UNKNOWN_OPCODE", `Cannot execute ${d.opcode ?? "unknown encoding"}: ${d.issues.join(", ") || d.bytes}`, d.source, d, before, { programId: ownerProgramId, pc: instructionPc });
559
+ break;
560
+ }
561
+ try {
562
+ if (d.opcode === "GOTO") {
563
+ pc = a ? this.value(a) : 0;
564
+ } else if (COMPARE_OPCODES.has(d.opcode)) {
565
+ const av = a ? this.value(a) : 0;
566
+ const bv = b ? this.value(b) : 0;
567
+ if (comparisonSkips(d.opcode, av, bv)) pc += 1;
568
+ } else if (d.opcode === "BREAK") {
569
+ terminated = true;
570
+ } else {
571
+ buttonCommandTerminated = this.execute(d, program, context) === true;
572
+ if (buttonCommandTerminated) terminated = true;
573
+ }
574
+ } catch (error) {
575
+ this.failButtonProgram(error instanceof RegisterFault ? error.code : "HDMV_EXECUTION_FAULT", error instanceof Error ? error.message : String(error), d.source, d, before, { programId: ownerProgramId, pc: instructionPc });
576
+ break;
577
+ }
578
+ this.record({ programId: ownerProgramId, pc: instructionPc, source: d.source, raw: d.bytes, decoded: textInstruction(d), ...this.lastAction ? { action: this.lastAction } : {}, registerDelta: delta(before, this.registers.snapshot()), status: this.status, ...this.instructionDiagnostic ? { diagnostic: this.instructionDiagnostic } : {} });
579
+ this.lastAction = void 0;
580
+ this.instructionDiagnostic = null;
581
+ if (terminated || this.status !== "running" || this.programId !== ownerProgramId || this.pc !== ownerPc) break;
582
+ }
583
+ if (!terminated && this.status === "running" && pc >= commands.length) terminated = true;
584
+ if (savedPlaylistContinuation && this.continuation === savedPlaylistContinuation && this.status === "running") this.status = "suspended";
585
+ if ((!savedPlaylistContinuation || this.continuation !== savedPlaylistContinuation) && this.status === "running" && (this.programId !== ownerProgramId || this.pc !== ownerPc)) this.runTurn();
586
+ if (this.resumeAfterPlaylistStop && this.status === "running") {
587
+ this.resumeAfterPlaylistStop = false;
588
+ this.runTurn();
589
+ }
590
+ const snapshot = this.snapshot();
591
+ return { ...snapshot, actions: this.actions.slice(firstAction), terminated, buttonCommandTerminated, fault: snapshot.status === "faulted" ? snapshot.diagnostic : null };
592
+ }
593
+ runTurn() {
594
+ if (this.status === "faulted" || this.status === "stopped" || this.status === "suspended") return this.snapshot();
595
+ this.status = "running";
596
+ this.instructionsThisTurn = 0;
597
+ while (this.status === "running") {
598
+ if (this.instructionsThisTurn >= this.opts.maxInstructionsPerTurn) {
599
+ this.fail("VM_EXECUTION_LIMIT", `Exceeded ${this.opts.maxInstructionsPerTurn} instructions in one turn`, this.currentSource());
600
+ break;
601
+ }
602
+ const program = this.programs.get(this.programId);
603
+ if (!program) {
604
+ this.fail("HDMV_INVALID_TARGET", `No program/object ${this.programId}`, null);
605
+ break;
606
+ }
607
+ const row = program.instructions[this.pc];
608
+ if (!row) {
609
+ if (this.pc >= program.instructions.length) {
610
+ this.status = "idle";
611
+ this.record({ source: null, raw: null, decoded: "PROGRAM_END", registerDelta: EMPTY_DELTA, status: this.status });
612
+ break;
613
+ }
614
+ this.fail("HDMV_INVALID_PC", `PC ${this.pc} is outside object ${this.programId}`, program.instructions.at(-1) ? decodeInstruction(program.instructions.at(-1)).source : null);
615
+ break;
616
+ }
617
+ let d;
618
+ try {
619
+ d = decodeInstruction(row);
620
+ } catch (error) {
621
+ this.fail("HDMV_INVALID_INSTRUCTION", String(error), null);
622
+ break;
623
+ }
624
+ this.instructionsThisTurn++;
625
+ const before = this.registers.snapshot();
626
+ const from = { programId: this.programId, pc: this.pc };
627
+ this.pc++;
628
+ this.instructionDiagnostic = null;
629
+ if (d.status === "invalid" || !d.opcode) {
630
+ this.fail(d.issues[0] ?? "HDMV_UNKNOWN_OPCODE", `Cannot execute ${d.opcode ?? "unknown encoding"}: ${d.issues.join(", ") || d.bytes}`, d.source, d, before, from);
631
+ break;
632
+ }
633
+ try {
634
+ this.execute(d, program);
635
+ } catch (error) {
636
+ this.fail(error instanceof RegisterFault ? error.code : "HDMV_EXECUTION_FAULT", error instanceof Error ? error.message : String(error), d.source, d, before, from);
637
+ break;
638
+ }
639
+ this.record({ programId: from.programId, pc: from.pc, source: d.source, raw: d.bytes, decoded: textInstruction(d), ...this.lastAction ? { action: this.lastAction } : {}, registerDelta: delta(before, this.registers.snapshot()), status: this.status, ...this.instructionDiagnostic ? { diagnostic: this.instructionDiagnostic } : {} });
640
+ this.lastAction = void 0;
641
+ this.instructionDiagnostic = null;
642
+ if (this.programId !== from.programId || this.pc !== from.pc + 1) {
643
+ }
644
+ }
645
+ return this.snapshot();
646
+ }
647
+ /** Resolve a title-search action using the disc index without inventing a media-ended event. */
648
+ selectTitle(title) {
649
+ if (this.pendingTitleCall !== null && this.pendingTitleCall !== title) {
650
+ const diagnostic = { code: "HDMV_TITLE_RESPONSE_MISMATCH", message: `CALL_TITLE is waiting for title ${this.pendingTitleCall}, received title ${title}`, source: null };
651
+ this.record({ source: null, raw: null, decoded: "TITLE_SEARCH_RESPONSE_IGNORED", registerDelta: EMPTY_DELTA, status: this.status, diagnostic });
652
+ return this.snapshot();
653
+ }
654
+ try {
655
+ if (!this.entries) throw new RegisterFault("HDMV_UNVERIFIED_OPCODE", "Title navigation requires the disc title table");
656
+ this.validateTitleTarget(title);
657
+ const entry = title === 0 ? this.entries.find((item) => item.kind === "topMenu") : title === 65535 ? this.entries.find((item) => item.kind === "firstPlay") : this.entries.filter((item) => item.kind === "title")[title - 1];
658
+ if (!entry) throw new RegisterFault("HDMV_INVALID_TARGET", `Title ${title} has no index entry`);
659
+ if (entry.target.objectType === "BDJ") throw new RegisterFault("HDMV_UNVERIFIED_OPCODE", `Title ${title} resolves to unsupported BD-J`);
660
+ if (entry.target.objectType !== "HDMV" || typeof entry.target.target !== "number") throw new RegisterFault("HDMV_INVALID_TARGET", `Title ${title} has no resolved HDMV object target`);
661
+ this.validateTarget(entry.target.target, 0);
662
+ const isCallReturn = this.pendingTitleCall !== null;
663
+ if (!isCallReturn && this.continuation) {
664
+ const action = this.continuation.action;
665
+ this.consumedResponses.add(`${action.generation}:${action.id}`);
666
+ this.removeAction(action.id);
667
+ }
668
+ if (!isCallReturn) this.continuation = null;
669
+ this.pendingTitleCall = null;
670
+ this.titleTransition = false;
671
+ this.playback = { status: "idle", playlist: null, playItem: null, presentationTime: null, pendingActionId: null };
672
+ const before = this.registers.snapshot();
673
+ this.registers.systemWrite(4, title);
674
+ this.programId = entry.target.target;
675
+ this.pc = 0;
676
+ this.status = "running";
677
+ this.record({ source: entry.source, raw: null, decoded: `TITLE_SELECTED title=${title} object=${entry.target.target}`, registerDelta: delta(before, this.registers.snapshot()), status: this.status });
678
+ return this.runTurn();
679
+ } catch (error) {
680
+ const issue = { code: error instanceof RegisterFault ? error.code : "HDMV_EXECUTION_FAULT", message: error instanceof Error ? error.message : String(error), source: null };
681
+ this.pendingTitleCall = null;
682
+ this.titleTransition = false;
683
+ this.diagnostic = issue;
684
+ this.diagnostics.push(issue);
685
+ if (this.status === "suspended" && !this.continuation) this.status = "idle";
686
+ this.record({ source: null, raw: null, decoded: "TITLE_SELECTION_REJECTED", registerDelta: EMPTY_DELTA, status: this.status, diagnostic: issue });
687
+ return this.snapshot();
688
+ }
689
+ }
690
+ /** Stop waiting for an external player whose playback state is not observable. */
691
+ releaseExternalPlayback(action) {
692
+ if (!this.continuation || this.continuation.action.id !== action.id || this.continuation.action.generation !== action.generation) return;
693
+ this.consumedResponses.add(`${action.generation}:${action.id}`);
694
+ this.removeAction(action.id);
695
+ this.continuation = null;
696
+ if (this.recoverMenuAction(action)) return;
697
+ this.playback = { status: "idle", playlist: null, playItem: null, presentationTime: null, pendingActionId: null };
698
+ this.status = "idle";
699
+ this.record({ source: action.source, raw: null, decoded: "EXTERNAL_PLAYER_HANDOFF", registerDelta: EMPTY_DELTA, status: this.status });
700
+ }
701
+ enqueueEvent(event) {
702
+ if (this.eventQueue.length >= this.opts.maxQueueDepth) {
703
+ this.fail("VM_QUEUE_LIMIT", `Event queue exceeds ${this.opts.maxQueueDepth}`, null);
704
+ return;
705
+ }
706
+ this.eventQueue.push(event);
707
+ }
708
+ drainActions() {
709
+ return this.actionQueue.splice(0);
710
+ }
711
+ processNextEvent() {
712
+ const event = this.eventQueue.shift();
713
+ if (event) this.handleEvent(event);
714
+ return this.snapshot();
715
+ }
716
+ handleEvent(event) {
717
+ if (this.status === "faulted" || this.status === "stopped") {
718
+ this.record({ source: null, raw: null, decoded: null, input: event, event, registerDelta: EMPTY_DELTA, status: this.status, diagnostic: { code: "HDMV_EVENT_AFTER_TERMINAL", message: `Ignoring ${event.type} after VM entered ${this.status}`, source: null } });
719
+ return this.snapshot();
720
+ }
721
+ if (event.type === "MENU_INPUT" || event.type === "TOP_MENU" || event.type === "POPUP_MENU" || event.type === "RETURN") return this.handleUserEvent(event);
722
+ if (this.titleTransition && !this.continuation && (event.type === "MEDIA_UNAVAILABLE" || event.type === "MEDIA_ERROR")) {
723
+ this.titleTransition = false;
724
+ this.status = "idle";
725
+ const issue = { code: event.type === "MEDIA_ERROR" ? "HDMV_MEDIA_ERROR" : "HDMV_MEDIA_UNAVAILABLE", message: `Title transition failed: ${event.type}`, source: null };
726
+ this.diagnostic = issue;
727
+ this.diagnostics.push(issue);
728
+ this.record({ source: null, raw: null, decoded: "TITLE_TRANSITION_RELEASED", input: event, event, registerDelta: EMPTY_DELTA, status: this.status, diagnostic: issue });
729
+ return this.snapshot();
730
+ }
731
+ const pending = this.continuation;
732
+ const eventKey = event.responseTo ? `${event.generation}:${event.responseTo}` : "";
733
+ const matchesPending = !!pending && event.generation === pending.action.generation && event.responseTo === pending.action.id;
734
+ if (event.type === "PLAYITEM_CHANGED" || event.type === "TIME_UPDATED" || event.type === "MEDIA_SEEKED") {
735
+ this.applyPlaybackStatusEvent(event);
736
+ const resumesWait = matchesPending && eventMatches(pending.action.waitFor, event.type);
737
+ if (!resumesWait) {
738
+ this.record({ source: pending?.action.source ?? null, raw: null, decoded: pending?.action.type ?? null, input: event, event, registerDelta: EMPTY_DELTA, status: this.status });
739
+ return this.snapshot();
740
+ }
741
+ }
742
+ if (eventKey && this.consumedResponses.has(eventKey)) {
743
+ this.record({ source: null, raw: null, decoded: null, input: event, event, registerDelta: EMPTY_DELTA, status: this.status, diagnostic: { code: "HDMV_DUPLICATE_EVENT", message: "Response already consumed", source: null } });
744
+ return this.snapshot();
745
+ }
746
+ if (!matchesPending) {
747
+ this.record({ source: null, raw: null, decoded: null, input: event, event, registerDelta: EMPTY_DELTA, status: this.status, diagnostic: { code: "HDMV_IGNORED_EVENT", message: !pending ? "No continuation is waiting" : "Generation or responseTo does not match current action", source: null } });
748
+ return this.snapshot();
749
+ }
750
+ if (event.type === "MEDIA_STARTED" && pending.action.type.startsWith("PLAY_")) {
751
+ this.playback.status = "playing";
752
+ this.record({ source: pending.action.source, raw: null, decoded: pending.action.type, input: event, event, registerDelta: EMPTY_DELTA, status: this.status });
753
+ return this.snapshot();
754
+ }
755
+ if (event.type === "MEDIA_ERROR" || event.type === "MEDIA_UNAVAILABLE") {
756
+ this.consumedResponses.add(eventKey);
757
+ this.removeAction(pending.action.id);
758
+ this.playback.status = event.type === "MEDIA_ERROR" ? "error" : "unavailable";
759
+ this.playback.pendingActionId = null;
760
+ this.continuation = null;
761
+ this.status = "stopped";
762
+ this.diagnostic = { code: event.type === "MEDIA_ERROR" ? "HDMV_MEDIA_ERROR" : "HDMV_MEDIA_UNAVAILABLE", message: `Action ${pending.action.id} failed: ${event.type}`, source: pending.action.source };
763
+ this.diagnostics.push(this.diagnostic);
764
+ this.record({ source: pending.action.source, raw: null, decoded: pending.action.type, input: event, event, registerDelta: EMPTY_DELTA, status: this.status, diagnostic: this.diagnostic });
765
+ this.recoverMenuAction(pending.action);
766
+ return this.snapshot();
767
+ }
768
+ if (!eventMatches(pending.action.waitFor, event.type)) {
769
+ this.record({ source: pending.action.source, raw: null, decoded: pending.action.type, input: event, event, registerDelta: EMPTY_DELTA, status: this.status, diagnostic: { code: "HDMV_EVENT_NOT_READY", message: `Waiting for ${pending.action.waitFor}; received ${event.type}`, source: pending.action.source } });
770
+ return this.snapshot();
771
+ }
772
+ this.consumedResponses.add(eventKey);
773
+ this.removeAction(pending.action.id);
774
+ const before = this.registers.snapshot();
775
+ this.continuation = null;
776
+ this.programId = pending.nextProgramId;
777
+ this.pc = pending.nextPc;
778
+ if (event.type === "MEDIA_ENDED") this.playback.status = "ended";
779
+ this.playback.pendingActionId = null;
780
+ this.status = "running";
781
+ this.record({ source: pending.action.source, raw: null, decoded: pending.action.type, input: event, event, registerDelta: delta(before, this.registers.snapshot()), status: this.status });
782
+ return this.runTurn();
783
+ }
784
+ applyPlaybackStatusEvent(event) {
785
+ if (event.type === "PLAYITEM_CHANGED") {
786
+ const n = Number(event.payload?.playItem);
787
+ if (Number.isInteger(n) && n >= 0 && n <= 4294967295) {
788
+ this.registers.systemWrite(7, n);
789
+ this.playback.playItem = n;
790
+ this.playback.status = "playing";
791
+ }
792
+ return;
793
+ }
794
+ const ticks = playbackTicks(event);
795
+ if (ticks === null) return;
796
+ this.registers.systemWrite(8, ticks);
797
+ this.playback.presentationTime = ticks;
798
+ }
799
+ inputBlocked() {
800
+ return this.titleTransition || this.pendingTitleCall !== null;
801
+ }
802
+ handleUserEvent(event) {
803
+ if (this.inputBlocked()) {
804
+ this.record({ source: null, raw: null, decoded: null, input: event, event, registerDelta: EMPTY_DELTA, status: this.status, diagnostic: { code: "HDMV_INPUT_WHILE_SUSPENDED", message: "Navigation input is rejected while a title or menu transition is pending", source: null } });
805
+ return this.snapshot();
806
+ }
807
+ try {
808
+ const program = this.programs.get(this.programId);
809
+ const source2 = { path: "<user-event>", origin: "primary", offset: "0" };
810
+ if (event.type === "MENU_INPUT") {
811
+ if (event.payload?.uoAllowed === false) {
812
+ this.record({ source: null, raw: null, decoded: null, input: event, event, registerDelta: EMPTY_DELTA, status: this.status, diagnostic: { code: "HDMV_UO_BLOCKED", message: "User operation is prohibited by the current UO mask", source: null } });
813
+ return this.snapshot();
814
+ }
815
+ const target = Number(event.payload?.objectId);
816
+ if (Number.isInteger(target) && target >= 0 && this.tryTransfer(target, 0, source2)) {
817
+ if (this.hasPlaylistContinuation()) this.stopPlayingContinuation(this.continuation, source2);
818
+ this.status = "running";
819
+ this.runTurn();
820
+ }
821
+ } else if (event.type === "TOP_MENU") {
822
+ if (event.payload?.uoAllowed === false || program?.menuCallMask === true) {
823
+ this.record({ source: null, raw: null, decoded: null, input: event, event, registerDelta: EMPTY_DELTA, status: this.status, diagnostic: { code: "HDMV_UO_BLOCKED", message: "TOP_MENU is blocked by UO/menu-call mask state", source: null } });
824
+ return this.snapshot();
825
+ }
826
+ this.suspendPlayingForMenuCall();
827
+ this.emit("TITLE_SEARCH", { title: 0 }, source2);
828
+ this.status = "suspended";
829
+ this.titleTransition = true;
830
+ } else if (event.type === "POPUP_MENU") {
831
+ if (event.payload?.uoAllowed === false) {
832
+ this.record({ source: null, raw: null, decoded: null, input: event, event, registerDelta: EMPTY_DELTA, status: this.status, diagnostic: { code: "HDMV_UO_BLOCKED", message: "POPUP_MENU is blocked by UO mask state", source: null } });
833
+ return this.snapshot();
834
+ }
835
+ this.emit("MENU_CALL", {}, source2);
836
+ } else {
837
+ if (!this.replayParkedPlayback()) this.emit("MENU_RETURN", {}, source2);
838
+ }
839
+ this.record({ source: null, raw: null, decoded: null, ...this.lastAction ? { action: this.lastAction } : {}, input: event, event, registerDelta: EMPTY_DELTA, status: this.status, ...this.instructionDiagnostic ? { diagnostic: this.instructionDiagnostic } : {} });
840
+ this.lastAction = void 0;
841
+ this.instructionDiagnostic = null;
842
+ return this.snapshot();
843
+ } catch (error) {
844
+ const code = error instanceof RegisterFault ? error.code : "HDMV_USER_EVENT_FAULT";
845
+ const message = error instanceof Error ? error.message : String(error);
846
+ const issue = { code, message, source: null };
847
+ this.fail(code, message, null);
848
+ this.record({ source: null, raw: null, decoded: null, input: event, event, registerDelta: EMPTY_DELTA, status: this.status, diagnostic: issue });
849
+ return this.snapshot();
850
+ }
851
+ }
852
+ execute(d, program, context = "object") {
853
+ const op = d.opcode;
854
+ const [a, b] = d.operands;
855
+ const av = a ? op === "SET_BUTTON_PAGE" ? this.buttonPageValue(a, d.immOp1) : this.value(a) : 0;
856
+ const bv = b ? op === "SET_BUTTON_PAGE" ? this.buttonPageValue(b, d.immOp2) : this.value(b) : 0;
857
+ switch (op) {
858
+ case "NOP":
859
+ return;
860
+ case "BREAK":
861
+ this.pc = program.instructions.length;
862
+ return;
863
+ case "GOTO":
864
+ this.pc = av;
865
+ return;
866
+ case "BC":
867
+ this.skipIf((av & ~bv) !== 0);
868
+ return;
869
+ case "EQ":
870
+ this.skipIf(av !== bv);
871
+ return;
872
+ case "NE":
873
+ this.skipIf(av === bv);
874
+ return;
875
+ case "GE":
876
+ this.skipIf(av < bv);
877
+ return;
878
+ case "GT":
879
+ this.skipIf(av <= bv);
880
+ return;
881
+ case "LE":
882
+ this.skipIf(av > bv);
883
+ return;
884
+ case "LT":
885
+ this.skipIf(av >= bv);
886
+ return;
887
+ case "MOVE":
888
+ this.write(a, bv);
889
+ return;
890
+ case "SWAP":
891
+ this.write(a, bv);
892
+ this.write(b, av);
893
+ return;
894
+ case "ADD":
895
+ this.write(a, Math.min(4294967295, av + bv));
896
+ return;
897
+ case "SUB":
898
+ this.write(a, av > bv ? av - bv : 0);
899
+ return;
900
+ case "MUL":
901
+ this.write(a, Math.min(4294967295, av * bv));
902
+ return;
903
+ case "DIV":
904
+ this.write(a, bv ? Math.floor(av / bv) : 4294967295);
905
+ return;
906
+ case "MOD":
907
+ this.write(a, bv ? av % bv : 4294967295);
908
+ return;
909
+ case "RND":
910
+ this.write(a, this.nextRandom(bv, d.source));
911
+ return;
912
+ case "AND":
913
+ this.write(a, av & bv);
914
+ return;
915
+ case "OR":
916
+ this.write(a, av | bv);
917
+ return;
918
+ case "XOR":
919
+ this.write(a, av ^ bv);
920
+ return;
921
+ case "BITSET":
922
+ case "BITCLR":
923
+ if (bv >= 32) throw new RegisterFault("HDMV_ILLEGAL_OPERAND", `Bit index ${bv} exceeds 31`);
924
+ this.write(a, op === "BITSET" ? av | 1 << bv : av & ~(1 << bv));
925
+ return;
926
+ case "SHL":
927
+ case "SHR":
928
+ if (bv >= 32) throw new RegisterFault("HDMV_ILLEGAL_OPERAND", `Shift count ${bv} exceeds 31`);
929
+ this.write(a, op === "SHL" ? av << bv : av >>> bv);
930
+ return;
931
+ case "JUMP_OBJECT":
932
+ if (!this.programs.has(av)) {
933
+ this.noteIgnoredInstruction("HDMV_INVALID_TARGET", `Target object ${av} does not exist`, d.source);
934
+ return;
935
+ }
936
+ if (context === "ig_object" && this.hasPlaylistContinuation()) this.stopPlayingContinuation(this.continuation, d.source);
937
+ this.tryTransfer(av, 0, d.source);
938
+ return;
939
+ case "CALL_OBJECT": {
940
+ if (!this.programs.has(av)) {
941
+ this.noteIgnoredInstruction("HDMV_INVALID_TARGET", `Target object ${av} does not exist`, d.source);
942
+ return;
943
+ }
944
+ if (this.opts.maxCallDepth < 1) throw new RegisterFault("VM_CALL_DEPTH_LIMIT", `Call depth exceeds ${this.opts.maxCallDepth}`);
945
+ this.saveCallPsrState();
946
+ const playing = context === "ig_object" && this.hasPlaylistContinuation() ? this.continuation : null;
947
+ if (playing) {
948
+ this.suspendedPlaylistContinuation = playing;
949
+ this.suspendedPlaylistPlayback = { ...this.playback };
950
+ this.stopPlayingContinuation(playing, d.source);
951
+ this.callStack.splice(0, this.callStack.length, { programId: playing.nextProgramId, pc: playing.nextPc });
952
+ } else {
953
+ this.callStack.splice(0, this.callStack.length, { programId: this.programId, pc: this.pc });
954
+ }
955
+ this.tryTransfer(av, 0, d.source);
956
+ return;
957
+ }
958
+ case "RESUME": {
959
+ const frame = this.callStack.pop();
960
+ if (!frame) {
961
+ if (!this.replayParkedPlayback()) this.noteIgnoredInstruction("HDMV_RESUME_WITHOUT_CALL", "RESUME has no saved call context", d.source);
962
+ return;
963
+ }
964
+ this.restoreCallPsrState();
965
+ this.programId = frame.programId;
966
+ this.pc = frame.pc;
967
+ if (this.suspendedPlaylistContinuation) {
968
+ this.replayParkedPlayback();
969
+ return;
970
+ }
971
+ return;
972
+ }
973
+ case "JUMP_TITLE": {
974
+ if (!this.tryValidateTitle(av, d.source)) return;
975
+ if (this.hasPlaylistContinuation()) this.stopPlayingContinuation(this.continuation, d.source);
976
+ this.callStack.length = 0;
977
+ this.suspendedPlaylistContinuation = null;
978
+ this.suspendedPlaylistPlayback = null;
979
+ this.resetCallPsrBackup();
980
+ this.emit("TITLE_SEARCH", { title: av }, d.source);
981
+ this.status = "suspended";
982
+ this.titleTransition = true;
983
+ return;
984
+ }
985
+ case "CALL_TITLE": {
986
+ if (!this.entries) {
987
+ this.unsupportedOperation("HDMV_UNVERIFIED_OPCODE", "CALL_TITLE requires a known HDMV title table", d.source);
988
+ return;
989
+ }
990
+ if (!this.tryValidateTitle(av, d.source)) return;
991
+ const entry = av === 0 ? this.entries.find((item) => item.kind === "topMenu") : av === 65535 ? this.entries.find((item) => item.kind === "firstPlay") : this.entries.filter((item) => item.kind === "title")[av - 1];
992
+ if (!entry) {
993
+ this.noteIgnoredInstruction("HDMV_INVALID_TARGET", `CALL_TITLE target ${av} has no index entry`, d.source);
994
+ return;
995
+ }
996
+ if (entry.target.objectType === "BDJ") {
997
+ this.unsupportedOperation("HDMV_UNVERIFIED_OPCODE", `CALL_TITLE target ${av} resolves to unsupported BD-J`, d.source);
998
+ return;
999
+ }
1000
+ if (entry.target.objectType !== "HDMV" || typeof entry.target.target !== "number") {
1001
+ this.noteIgnoredInstruction("HDMV_INVALID_TARGET", `CALL_TITLE target ${av} has no resolved HDMV object target`, d.source);
1002
+ return;
1003
+ }
1004
+ if (!this.programs.has(entry.target.target)) {
1005
+ this.noteIgnoredInstruction("HDMV_INVALID_TARGET", `CALL_TITLE target object ${entry.target.target} does not exist`, d.source);
1006
+ return;
1007
+ }
1008
+ if (this.opts.maxCallDepth < 1) throw new RegisterFault("VM_CALL_DEPTH_LIMIT", `Call depth exceeds ${this.opts.maxCallDepth}`);
1009
+ this.saveCallPsrState();
1010
+ const playing = context === "ig_object" && this.hasPlaylistContinuation() ? this.continuation : null;
1011
+ if (playing) {
1012
+ this.suspendedPlaylistContinuation = playing;
1013
+ this.suspendedPlaylistPlayback = { ...this.playback };
1014
+ this.stopPlayingContinuation(playing, d.source);
1015
+ this.callStack.splice(0, this.callStack.length, { programId: playing.nextProgramId, pc: playing.nextPc });
1016
+ } else {
1017
+ this.callStack.splice(0, this.callStack.length, { programId: this.programId, pc: this.pc });
1018
+ }
1019
+ this.pendingTitleCall = av;
1020
+ this.status = "suspended";
1021
+ this.emit("TITLE_SEARCH", { title: av }, d.source);
1022
+ return;
1023
+ }
1024
+ case "PLAY_PL":
1025
+ if (context !== "object") {
1026
+ this.noteInstructionDiagnostic(op, context, d.source);
1027
+ return;
1028
+ }
1029
+ this.actionAndSuspend("PLAY_PLAYLIST", { playlist: av }, d.source);
1030
+ return;
1031
+ case "PLAY_PL_PI":
1032
+ if (context !== "object") {
1033
+ this.noteInstructionDiagnostic(op, context, d.source);
1034
+ return;
1035
+ }
1036
+ this.actionAndSuspend("PLAY_PLAYLIST_ITEM", { playlist: av, playItem: bv }, d.source);
1037
+ return;
1038
+ case "PLAY_PL_PM":
1039
+ if (context !== "object") {
1040
+ this.noteInstructionDiagnostic(op, context, d.source);
1041
+ return;
1042
+ }
1043
+ this.actionAndSuspend("PLAY_PLAYLIST_MARK", { playlist: av, playMark: bv }, d.source);
1044
+ return;
1045
+ case "LINK_PI":
1046
+ if (context !== "ig_object") {
1047
+ this.noteInstructionDiagnostic(op, context, d.source);
1048
+ return;
1049
+ }
1050
+ this.emit("LINK_PLAY_ITEM", { playItem: av }, d.source);
1051
+ return;
1052
+ case "LINK_MK":
1053
+ if (context !== "ig_object") {
1054
+ this.noteInstructionDiagnostic(op, context, d.source);
1055
+ return;
1056
+ }
1057
+ this.emit("LINK_PLAY_MARK", { playMark: av }, d.source);
1058
+ return;
1059
+ case "TERMINATE_PL": {
1060
+ if (context !== "ig_object") {
1061
+ this.noteInstructionDiagnostic(op, context, d.source);
1062
+ return;
1063
+ }
1064
+ if (this.hasPlaylistContinuation()) this.resumeMovieObjectAfterPlaylistStop(d.source);
1065
+ else this.emit("PLAY_STOP", {}, d.source);
1066
+ return true;
1067
+ }
1068
+ case "SET_STREAM": {
1069
+ const destination = this.streamOperand(a, d.immOp1);
1070
+ const source2 = this.streamOperand(b, d.immOp2);
1071
+ const primaryAudioStream = destination & 2147483648 ? destination >>> 16 & 4095 : null;
1072
+ const interactiveGraphicsStream = source2 & 2147483648 ? source2 >>> 16 & 255 : null;
1073
+ const angle = source2 & 32768 ? source2 & 255 : null;
1074
+ if (primaryAudioStream !== null) this.registers.systemWrite(1, primaryAudioStream);
1075
+ if (interactiveGraphicsStream !== null) this.registers.systemWrite(0, interactiveGraphicsStream);
1076
+ if (angle !== null) this.registers.systemWrite(3, angle);
1077
+ const currentPg = this.registers.read({ kind: "psr", index: 2 });
1078
+ const pgSubtitleStream = destination & 32768 ? destination & 4095 : null;
1079
+ const pgSubtitleDisplay = (destination & 16384) !== 0;
1080
+ const nextPg = ((pgSubtitleDisplay ? 2147483648 : 0) | currentPg & 2147483647 & (pgSubtitleStream === null ? 4294967295 : 4294963200) | (pgSubtitleStream ?? 0)) >>> 0;
1081
+ this.registers.systemWrite(2, nextPg);
1082
+ this.emit("SET_STREAM", {
1083
+ destinationRaw: a?.raw ?? 0,
1084
+ sourceRaw: b?.raw ?? 0,
1085
+ destinationImmediate: d.immOp1,
1086
+ sourceImmediate: d.immOp2,
1087
+ destinationValue: destination,
1088
+ sourceValue: source2,
1089
+ primaryAudioStream,
1090
+ interactiveGraphicsStream,
1091
+ angle,
1092
+ pgSubtitleStream,
1093
+ pgSubtitleDisplay
1094
+ }, d.source);
1095
+ return;
1096
+ }
1097
+ case "SET_SEC_STREAM": {
1098
+ const destination = this.streamOperand(a, d.immOp1);
1099
+ const source2 = this.streamOperand(b, d.immOp2);
1100
+ let psr14 = this.registers.read({ kind: "psr", index: 14 });
1101
+ const secondaryVideoStream = destination & 2147483648 ? destination & 255 : null;
1102
+ const secondaryVideoSize = destination & 8388608 ? destination >>> 16 & 15 : null;
1103
+ const secondaryAudioStream = source2 & 2147483648 ? source2 >>> 16 & 255 : null;
1104
+ if (secondaryVideoStream !== null) psr14 = secondaryVideoStream << 8 | psr14 & 4294902015;
1105
+ if (secondaryVideoSize !== null) psr14 = secondaryVideoSize << 24 | psr14 & 4043309055;
1106
+ if (secondaryAudioStream !== null) psr14 = secondaryAudioStream | psr14 & 4294967040;
1107
+ psr14 = (destination >>> 30 & 1) << 31 | psr14 & 2147483647;
1108
+ psr14 = (source2 >>> 30 & 1) << 30 | psr14 & 3221225471;
1109
+ this.registers.systemWrite(14, psr14 >>> 0);
1110
+ let psr2 = this.registers.read({ kind: "psr", index: 2 });
1111
+ const pipPgStream = source2 & 32768 ? source2 & 4095 : null;
1112
+ if (pipPgStream !== null) psr2 = pipPgStream << 16 | psr2 & 4026597375;
1113
+ const textSubtitleFlags = source2 >>> 13 & 3;
1114
+ psr2 = textSubtitleFlags << 30 | psr2 & 1073741823;
1115
+ this.registers.systemWrite(2, psr2 >>> 0);
1116
+ this.emit("SET_SECONDARY_STREAM", {
1117
+ destinationRaw: a?.raw ?? 0,
1118
+ sourceRaw: b?.raw ?? 0,
1119
+ destinationImmediate: d.immOp1,
1120
+ sourceImmediate: d.immOp2,
1121
+ destinationValue: destination,
1122
+ sourceValue: source2,
1123
+ secondaryVideoStream,
1124
+ secondaryVideoSize,
1125
+ secondaryAudioStream,
1126
+ displayVideo: (destination & 1073741824) !== 0,
1127
+ displayAudio: (source2 & 1073741824) !== 0,
1128
+ pipPgStream,
1129
+ textSubtitleFlags
1130
+ }, d.source);
1131
+ return;
1132
+ }
1133
+ case "SET_NV_TIMER": {
1134
+ const objectId = av & 65535;
1135
+ const timeout = bv & 65535;
1136
+ if (timeout === 0) {
1137
+ this.registers.systemWrite(9, 0);
1138
+ return;
1139
+ }
1140
+ if (timeout > 300 || !this.programs.has(objectId)) return;
1141
+ this.registers.systemWrite(9, timeout);
1142
+ return;
1143
+ }
1144
+ case "SET_BUTTON_PAGE":
1145
+ return this.setButtonPage(av, bv, d.source, context);
1146
+ case "ENABLE_BUTTON":
1147
+ if (context === "ig_object") this.emit("ENABLE_BUTTON", { button: av }, d.source);
1148
+ else this.noteInstructionDiagnostic(op, context, d.source);
1149
+ return;
1150
+ case "DISABLE_BUTTON":
1151
+ if (context === "ig_object") this.emit("DISABLE_BUTTON", { button: av }, d.source);
1152
+ else this.noteInstructionDiagnostic(op, context, d.source);
1153
+ return;
1154
+ case "POPUP_OFF":
1155
+ if (context === "ig_object") this.emit("POPUP_OFF", {}, d.source);
1156
+ else this.noteInstructionDiagnostic(op, context, d.source);
1157
+ return;
1158
+ case "STILL_ON":
1159
+ if (context === "ig_object") this.emit("STILL_ON", {}, d.source);
1160
+ else this.noteInstructionDiagnostic(op, context, d.source);
1161
+ return;
1162
+ case "STILL_OFF":
1163
+ if (context === "ig_object") this.emit("STILL_OFF", {}, d.source);
1164
+ else this.noteInstructionDiagnostic(op, context, d.source);
1165
+ return;
1166
+ case "SET_OUTPUT_MODE": {
1167
+ if ((this.registers.read({ kind: "psr", index: 31 }) & 1245760) !== 1245760) return;
1168
+ const current = this.registers.read({ kind: "psr", index: 22 });
1169
+ this.registers.systemWrite(22, av & 1 ? current | 1 : current & ~1);
1170
+ return;
1171
+ }
1172
+ case "SET_STREAM_SS": {
1173
+ if ((this.registers.read({ kind: "psr", index: 22 }) & 1) === 0) return;
1174
+ this.noteInstructionDiagnostic(op, context, d.source, "3D subtitle alignment is unimplemented by pinned libbluray", "HDMV_3D_SUBTITLE_ALIGNMENT_UNIMPLEMENTED");
1175
+ return;
1176
+ }
1177
+ case "SETSYSTEM_0x10":
1178
+ this.registers.systemWrite(103, av);
1179
+ return;
1180
+ default:
1181
+ throw new RegisterFault("HDMV_UNVERIFIED_OPCODE", `${op} is decoded but is outside the executable subset`);
1182
+ }
1183
+ }
1184
+ value(op) {
1185
+ if (op.kind === "immediate" || op.kind === "encoded") return op.value >>> 0;
1186
+ if (op.invalid) return 0;
1187
+ return this.registers.read({ kind: op.kind, index: op.value });
1188
+ }
1189
+ streamOperand(op, immediate) {
1190
+ if (!op) return 0;
1191
+ if (immediate) return op.raw >>> 0;
1192
+ const encoded = op.raw >>> 0;
1193
+ const flags = encoded & 4026593280;
1194
+ const lowRegister = encoded & 4095;
1195
+ const highRegister = encoded >>> 16 & 4095;
1196
+ const lowValue = this.registers.read({ kind: "gpr", index: lowRegister }) & 4095;
1197
+ const highValue = this.registers.read({ kind: "gpr", index: highRegister }) & 4095;
1198
+ return (flags | lowValue | highValue << 16) >>> 0;
1199
+ }
1200
+ buttonPageValue(op, immediate) {
1201
+ if (immediate) return op.raw >>> 0;
1202
+ const flags = op.raw & 3221225472;
1203
+ const value = this.registers.read({ kind: "gpr", index: op.raw & 4095 }) & 1073741823;
1204
+ return (flags | value) >>> 0;
1205
+ }
1206
+ setButtonPage(destination, source2, location2, context) {
1207
+ if (context === "ig_object") {
1208
+ const packed = (source2 & 3221225472 | (destination & 2147483648) >>> 2 | (source2 & 255) << 16 | destination & 65535) >>> 0;
1209
+ this.emit("SET_BUTTON_PAGE", { packed }, location2);
1210
+ return true;
1211
+ }
1212
+ if (destination & 2147483648) this.registers.systemWrite(10, destination & 65535);
1213
+ if (source2 & 2147483648) this.registers.systemWrite(11, source2 & 255);
1214
+ return false;
1215
+ }
1216
+ hasPlaylistContinuation() {
1217
+ const action = this.continuation?.action;
1218
+ return action?.waitFor === "ended" && (action.type === "PLAY_PLAYLIST" || action.type === "PLAY_PLAYLIST_ITEM" || action.type === "PLAY_PLAYLIST_MARK");
1219
+ }
1220
+ stopPlayingContinuation(playing, source2) {
1221
+ this.removeAction(playing.action.id);
1222
+ this.continuation = null;
1223
+ this.playback = { status: "idle", playlist: null, playItem: null, presentationTime: null, pendingActionId: null };
1224
+ this.emit("PLAY_STOP", {}, source2);
1225
+ }
1226
+ /** Apply bd_menu_call()/hdmv_vm_suspend_pl(): keep the playing position when the MovieObject resume-intention flag is set. */
1227
+ suspendPlayingForMenuCall() {
1228
+ const playing = this.hasPlaylistContinuation() ? this.continuation : null;
1229
+ if (!playing) return;
1230
+ const playback = { ...this.playback };
1231
+ this.removeAction(playing.action.id);
1232
+ this.continuation = null;
1233
+ this.playback = { status: "idle", playlist: null, playItem: null, presentationTime: null, pendingActionId: null };
1234
+ const program = this.programs.get(playing.nextProgramId);
1235
+ if (program?.resumeIntention === true) {
1236
+ this.saveCallPsrState();
1237
+ this.suspendedPlaylistContinuation = playing;
1238
+ this.suspendedPlaylistPlayback = playback;
1239
+ this.callStack.splice(0, this.callStack.length, { programId: playing.nextProgramId, pc: playing.nextPc });
1240
+ }
1241
+ if (this.status === "suspended") this.status = "idle";
1242
+ }
1243
+ /** Re-issue the saved playlist action because the previous action was stopped when the title changed. */
1244
+ replayParkedPlayback() {
1245
+ const parked = this.suspendedPlaylistContinuation;
1246
+ if (!parked) return false;
1247
+ const playback = this.suspendedPlaylistPlayback;
1248
+ this.suspendedPlaylistContinuation = null;
1249
+ this.suspendedPlaylistPlayback = null;
1250
+ this.playback = playback ? { ...playback } : { status: "idle", playlist: null, playItem: null, presentationTime: null, pendingActionId: null };
1251
+ const action = this.emit(parked.action.type, parked.action.payload, parked.action.source);
1252
+ const waitFor = WAIT_BY_ACTION[action.type];
1253
+ if (waitFor) {
1254
+ this.playback.status = "requested";
1255
+ this.playback.pendingActionId = action.id;
1256
+ this.continuation = { action: { ...action, waitFor }, nextProgramId: parked.nextProgramId, nextPc: parked.nextPc };
1257
+ this.status = "suspended";
1258
+ } else {
1259
+ this.continuation = null;
1260
+ this.status = "running";
1261
+ }
1262
+ return true;
1263
+ }
1264
+ resumeMovieObjectAfterPlaylistStop(source2) {
1265
+ const pending = this.continuation;
1266
+ if (!pending || !this.hasPlaylistContinuation()) throw new RegisterFault("HDMV_INVALID_CONTINUATION", "PLAY_STOP has no playlist continuation to resume");
1267
+ this.removeAction(pending.action.id);
1268
+ const responseKey = `${pending.action.generation}:${pending.action.id}`;
1269
+ this.consumedResponses.add(responseKey);
1270
+ this.emit("PLAY_STOP", {}, source2);
1271
+ this.continuation = null;
1272
+ this.programId = pending.nextProgramId;
1273
+ this.pc = pending.nextPc;
1274
+ this.playback.status = "ended";
1275
+ this.playback.pendingActionId = null;
1276
+ this.status = "running";
1277
+ this.resumeAfterPlaylistStop = true;
1278
+ }
1279
+ noteInstructionDiagnostic(opcode, context, source2, detail, code = "HDMV_OPCODE_CONTEXT_SKIPPED") {
1280
+ const message = detail ? `${opcode} is not implemented for ${context} context: ${detail}` : `${opcode} is not valid in ${context} context`;
1281
+ this.instructionDiagnostic = { code, message, source: source2 };
1282
+ this.diagnostics.push(this.instructionDiagnostic);
1283
+ }
1284
+ /** Record a non-fatal instruction outcome; the VM continues with the next command. */
1285
+ noteIgnoredInstruction(code, message, source2) {
1286
+ const issue = { code, message, source: source2 };
1287
+ this.instructionDiagnostic = issue;
1288
+ this.diagnostics.push(issue);
1289
+ }
1290
+ /** Unsupported player capability: report, stop the current run, keep the VM reusable. */
1291
+ unsupportedOperation(code, message, source2) {
1292
+ const issue = { code, message, source: source2 };
1293
+ this.instructionDiagnostic = issue;
1294
+ this.diagnostics.push(issue);
1295
+ this.diagnostic = issue;
1296
+ }
1297
+ tryValidateTitle(title, source2) {
1298
+ try {
1299
+ this.validateTitleTarget(title);
1300
+ return true;
1301
+ } catch (error) {
1302
+ this.noteIgnoredInstruction(error instanceof RegisterFault ? error.code : "HDMV_INVALID_TARGET", error instanceof Error ? error.message : String(error), source2);
1303
+ return false;
1304
+ }
1305
+ }
1306
+ tryTransfer(programId, pc, source2) {
1307
+ const target = this.programs.get(programId);
1308
+ if (!target) {
1309
+ this.noteIgnoredInstruction("HDMV_INVALID_TARGET", `Target object ${programId} does not exist`, source2);
1310
+ return false;
1311
+ }
1312
+ if (!Number.isInteger(pc) || pc < 0 || pc > target.instructions.length) {
1313
+ this.noteIgnoredInstruction("HDMV_INVALID_PC", `Target PC ${pc} is invalid for object ${programId}`, source2);
1314
+ return false;
1315
+ }
1316
+ this.programId = programId;
1317
+ this.pc = pc;
1318
+ this.record({ source: source2, raw: null, decoded: "TRANSFER", registerDelta: EMPTY_DELTA, status: this.status });
1319
+ return true;
1320
+ }
1321
+ applyPlaybackPsrs(payload) {
1322
+ const playlist = typeof payload.playlist === "number" ? payload.playlist : null;
1323
+ if (playlist !== null) this.registers.systemWrite(6, playlist);
1324
+ this.registers.systemWrite(5, 65535);
1325
+ this.registers.systemWrite(7, typeof payload.playItem === "number" ? payload.playItem : 0);
1326
+ this.registers.systemWrite(8, 0);
1327
+ }
1328
+ write(op, value) {
1329
+ if (op.kind !== "gpr" || op.invalid) {
1330
+ this.noteIgnoredInstruction("HDMV_ILLEGAL_OPERAND", `Cannot write result to ${op.kind} operand`, null);
1331
+ return;
1332
+ }
1333
+ this.registers.write({ kind: "gpr", index: op.value }, value >>> 0);
1334
+ }
1335
+ nextRandom(range, source2) {
1336
+ this.randomState = this.randomState * RANDOM_MULTIPLIER + 1n & RANDOM_MASK;
1337
+ if (range === 0) {
1338
+ this.diagnostics.push({ code: "HDMV_RND_ZERO_RANGE", message: "RND range is zero; reference behavior returns 1", source: source2 });
1339
+ return 1;
1340
+ }
1341
+ return Number(this.randomState >> 32n & 0xffffffffn) % range + 1;
1342
+ }
1343
+ skipIf(skip) {
1344
+ if (skip) this.pc++;
1345
+ }
1346
+ saveCallPsrState() {
1347
+ for (let offset = 0; offset < 5; offset += 1) this.registers.systemWrite(36 + offset, this.registers.read({ kind: "psr", index: 4 + offset }));
1348
+ for (let offset = 0; offset < 3; offset += 1) this.registers.systemWrite(42 + offset, this.registers.read({ kind: "psr", index: 10 + offset }));
1349
+ }
1350
+ restoreCallPsrState() {
1351
+ for (let offset = 0; offset < 5; offset += 1) this.registers.systemWrite(4 + offset, this.registers.read({ kind: "psr", index: 36 + offset }));
1352
+ for (let offset = 0; offset < 3; offset += 1) this.registers.systemWrite(10 + offset, this.registers.read({ kind: "psr", index: 42 + offset }));
1353
+ this.resetCallPsrBackup();
1354
+ }
1355
+ resetCallPsrBackup() {
1356
+ const backupDefaults = [65535, 65535, 0, 0, 0, 65535, 0, 255];
1357
+ const backupIndices = [36, 37, 38, 39, 40, 42, 43, 44];
1358
+ backupIndices.forEach((index, offset) => this.registers.systemWrite(index, backupDefaults[offset]));
1359
+ }
1360
+ validateTitleTarget(title) {
1361
+ if (!this.entries) return;
1362
+ const firstPlay = this.entries.find((entry) => entry.kind === "firstPlay")?.target;
1363
+ const topMenu = this.entries.find((entry) => entry.kind === "topMenu")?.target;
1364
+ const titleCount = this.entries.filter((entry) => entry.kind === "title").length;
1365
+ const specialEntryAvailable = (target) => target?.objectType === "HDMV" && typeof target.target === "number" && target.target !== 65535;
1366
+ const valid = title === 0 ? specialEntryAvailable(topMenu) : title === 65535 ? specialEntryAvailable(firstPlay) : title > 0 && title <= titleCount;
1367
+ if (!valid) throw new RegisterFault("HDMV_INVALID_TARGET", `Title target ${title} is not present in the supplied ParsedDisc entries`);
1368
+ }
1369
+ validateTarget(programId, pc) {
1370
+ const target = this.programs.get(programId);
1371
+ if (!target) throw new RegisterFault("HDMV_INVALID_TARGET", `Target object ${programId} does not exist`);
1372
+ if (!Number.isInteger(pc) || pc < 0 || pc > target.instructions.length) throw new RegisterFault("HDMV_INVALID_PC", `Target PC ${pc} is invalid for object ${programId}`);
1373
+ }
1374
+ actionAndSuspend(type, payload, source2) {
1375
+ const action = this.emit(type, payload, source2);
1376
+ const waitFor = WAIT_BY_ACTION[type];
1377
+ if (type.startsWith("PLAY_")) {
1378
+ this.applyPlaybackPsrs(payload);
1379
+ this.playback.status = "requested";
1380
+ this.playback.playlist = typeof payload.playlist === "number" ? payload.playlist : this.playback.playlist;
1381
+ this.playback.playItem = typeof payload.playItem === "number" ? payload.playItem : this.playback.playItem;
1382
+ this.playback.pendingActionId = action.id;
1383
+ }
1384
+ if (waitFor) {
1385
+ this.continuation = { action: { ...action, waitFor }, nextProgramId: this.programId, nextPc: this.pc };
1386
+ this.status = "suspended";
1387
+ }
1388
+ }
1389
+ emit(type, payload, source2) {
1390
+ if (this.actionQueue.length >= this.opts.maxQueueDepth) throw new RegisterFault("VM_QUEUE_LIMIT", `Pending action queue exceeds ${this.opts.maxQueueDepth}`);
1391
+ const generation = ++this.generation;
1392
+ const action = { id: `hdmv-${generation}-${this.sequence + 1}`, type, generation, payload, source: source2, ...WAIT_BY_ACTION[type] ? { waitFor: WAIT_BY_ACTION[type] } : {} };
1393
+ this.actions.push(action);
1394
+ this.actionQueue.push(action);
1395
+ this.lastAction = action;
1396
+ return action;
1397
+ }
1398
+ removeAction(id) {
1399
+ const index = this.actionQueue.findIndex((action) => action.id === id);
1400
+ if (index >= 0) this.actionQueue.splice(index, 1);
1401
+ }
1402
+ currentSource() {
1403
+ const row = this.programs.get(this.programId)?.instructions[this.pc];
1404
+ if (!row) return null;
1405
+ try {
1406
+ return decodeInstruction(row).source;
1407
+ } catch {
1408
+ return null;
1409
+ }
1410
+ }
1411
+ fail(code, message, source2, d, before, location2) {
1412
+ if (this.continuation) this.removeAction(this.continuation.action.id);
1413
+ this.continuation = null;
1414
+ this.pendingTitleCall = null;
1415
+ this.titleTransition = false;
1416
+ this.suspendedPlaylistContinuation = null;
1417
+ this.suspendedPlaylistPlayback = null;
1418
+ this.playback.pendingActionId = null;
1419
+ const issue = { code, message, source: source2 };
1420
+ this.diagnostic = issue;
1421
+ this.diagnostics.push(issue);
1422
+ this.status = code === "HDMV_UNVERIFIED_OPCODE" ? "idle" : "faulted";
1423
+ this.record({ ...location2 ? { programId: location2.programId, pc: location2.pc } : {}, source: source2, raw: d?.bytes ?? null, decoded: d ? textInstruction(d) : null, registerDelta: before ? delta(before, this.registers.snapshot()) : EMPTY_DELTA, status: this.status, diagnostic: issue });
1424
+ }
1425
+ /** Record a fault raised while executing a button (interactive composition) program. libbluray _vm_run() clears only the faulting object (p->object = NULL); the MovieObject waiting for playlist end (playing_object) and any suspended call object stay untouched, so the pending playlist end can still resume the MovieObject. */
1426
+ failButtonProgram(code, message, source2, d, before, location2) {
1427
+ const issue = { code, message, source: source2 };
1428
+ this.diagnostic = issue;
1429
+ this.diagnostics.push(issue);
1430
+ this.status = this.continuation ? "suspended" : "faulted";
1431
+ this.record({ ...location2 ? { programId: location2.programId, pc: location2.pc } : {}, source: source2, raw: d?.bytes ?? null, decoded: d ? textInstruction(d) : null, registerDelta: before ? delta(before, this.registers.snapshot()) : EMPTY_DELTA, status: this.status, diagnostic: issue });
1432
+ }
1433
+ record(entry) {
1434
+ const trace = { sequence: ++this.sequence, generation: this.generation, programId: entry.programId ?? this.programId, pc: entry.pc ?? this.pc, source: entry.source, raw: entry.raw, decoded: entry.decoded, ...entry.input ? { input: entry.input } : {}, ...entry.action ? { action: entry.action } : {}, ...entry.event ? { event: entry.event } : {}, registerDelta: entry.registerDelta, callDepth: this.callStack.length, callStack: this.callStack.map((x) => ({ ...x })), playback: { ...this.playback }, limits: this.limits, status: entry.status, ...entry.diagnostic ? { diagnostic: entry.diagnostic } : {} };
1435
+ this.trace.push(trace);
1436
+ try {
1437
+ this.onTrace?.(trace);
1438
+ } catch {
1439
+ }
1440
+ }
1441
+ };
1442
+ function playbackTicks(event) {
1443
+ const presentationTime = event.payload?.presentationTime;
1444
+ if (typeof presentationTime === "number" && Number.isFinite(presentationTime) && presentationTime >= 0) return Math.min(4294967295, Math.floor(presentationTime));
1445
+ const seconds = event.payload?.mediaTimeSeconds;
1446
+ if (typeof seconds === "number" && Number.isFinite(seconds) && seconds >= 0) return Math.min(4294967295, Math.round(seconds * PSR_TIME_TICKS_PER_SECOND));
1447
+ return null;
1448
+ }
1449
+ function delta(before, after) {
1450
+ const gpr = {}, psr = {};
1451
+ before.gpr.forEach((v, i) => {
1452
+ if (v !== after.gpr[i]) gpr[i] = after.gpr[i];
1453
+ });
1454
+ before.psr.forEach((v, i) => {
1455
+ if (v !== after.psr[i]) psr[i] = after.psr[i];
1456
+ });
1457
+ return { gpr, psr };
1458
+ }
1459
+ function eventMatches(wait, event) {
1460
+ return wait === "started" ? event === "MEDIA_STARTED" : wait === "seeked" ? event === "MEDIA_SEEKED" : wait === "ended" ? event === "MEDIA_ENDED" : wait === "still-completed" ? event === "STILL_COMPLETED" : false;
1461
+ }
1462
+ function comparisonSkips(opcode, a, b) {
1463
+ switch (opcode) {
1464
+ case "BC":
1465
+ return (a & ~b) !== 0;
1466
+ case "EQ":
1467
+ return a !== b;
1468
+ case "NE":
1469
+ return a === b;
1470
+ case "GE":
1471
+ return a < b;
1472
+ case "GT":
1473
+ return a <= b;
1474
+ case "LE":
1475
+ return a > b;
1476
+ case "LT":
1477
+ return a >= b;
1478
+ default:
1479
+ return false;
1480
+ }
1481
+ }
1482
+ function stableProfile(profile) {
1483
+ const normalized = Object.fromEntries(Object.entries(profile).filter(([key]) => key !== "capabilityValues").sort(([a], [b]) => a.localeCompare(b)));
1484
+ if (profile.capabilityValues) normalized.capabilityValues = Object.fromEntries(Object.entries(profile.capabilityValues).sort(([a], [b]) => Number(a) - Number(b)));
1485
+ return JSON.stringify(normalized);
1486
+ }
1487
+ function normalizeProfile(profile) {
1488
+ const defaults = DEFAULT_PLAYER_PROFILE;
1489
+ return {
1490
+ ...defaults,
1491
+ ...profile,
1492
+ videoProfiles: [...profile?.videoProfiles ?? defaults.videoProfiles],
1493
+ audioProfiles: [...profile?.audioProfiles ?? defaults.audioProfiles],
1494
+ ...profile?.capabilityValues ? { capabilityValues: { ...profile.capabilityValues } } : {}
1495
+ };
1496
+ }
1497
+ function normalizeRandomSeed(value) {
1498
+ const seed = value ?? 1;
1499
+ if (!Number.isInteger(seed) || seed < 0 || seed > 4294967295) throw new TypeError("randomSeed must be an unsigned 32-bit integer");
1500
+ return seed;
1501
+ }
1502
+
1503
+ // ../hdmv/src/fixtures.ts
1504
+ var source = { path: "M3-test/MovieObject.bdmv", origin: "primary", offset: "128" };
1505
+ function command(opCount, group, subGroup, imm1, imm2, branch, compare2, set, w1, w2 = 0) {
1506
+ const w0 = ((opCount & 7) << 29 | (group & 3) << 27 | (subGroup & 7) << 24 | Number(imm1) << 23 | Number(imm2) << 22 | (branch & 15) << 16 | (compare2 & 15) << 8 | set & 31) >>> 0;
1507
+ const words = [w0, w1 >>> 0, w2 >>> 0];
1508
+ const bytes = words.map((x) => x.toString(16).padStart(8, "0")).join("");
1509
+ return { words, bytes, source };
1510
+ }
1511
+ var m3Fixtures = {
1512
+ move5: command(2, 2, 0, false, true, 0, 0, 1, 0, 5),
1513
+ add7: command(2, 2, 0, false, true, 0, 0, 3, 0, 7),
1514
+ compareEq7: command(2, 1, 0, false, true, 0, 2, 0, 0, 7),
1515
+ callObject1: command(1, 0, 1, true, false, 2, 0, 0, 1),
1516
+ jumpObject1: command(1, 0, 1, true, false, 0, 0, 0, 1),
1517
+ goto0: command(1, 0, 0, true, false, 1, 0, 0, 0),
1518
+ playPlaylist42: command(1, 0, 2, true, false, 0, 0, 0, 42),
1519
+ linkPlayItem3: command(1, 0, 2, true, false, 4, 0, 0, 3),
1520
+ resume: command(0, 0, 1, false, false, 4, 0, 0, 0),
1521
+ terminatePlaylist: command(0, 0, 2, false, false, 3, 0, 0, 0),
1522
+ popupOff: command(0, 2, 1, false, false, 0, 0, 7, 0),
1523
+ break: command(0, 0, 0, false, false, 2, 0, 0, 0),
1524
+ unknown: command(1, 0, 7, true, false, 0, 0, 0, 9)
1525
+ };
1526
+
1527
+ // src/igs-adapter.ts
1528
+ function selectPopupScene(scenes, clipId, pid, time90k, timeDomain) {
1529
+ const candidates = scenes.filter((scene) => scene.uiModel === 1 && scene.clipId?.toLocaleLowerCase("en-US") === clipId.toLocaleLowerCase("en-US") && scene.pid === pid);
1530
+ if (!candidates.length) return { status: "not-found" };
1531
+ if (time90k !== void 0) {
1532
+ if (!Number.isSafeInteger(time90k) || time90k < 0) throw new RangeError("Popup selection time must be a non-negative 90 kHz timestamp.");
1533
+ const active = candidates.filter((scene) => {
1534
+ const pts = scene.source?.pts90k;
1535
+ return pts !== null && pts !== void 0 && (timeDomain === void 0 || scene.source?.timeDomain === timeDomain) && BigInt(pts) <= BigInt(time90k);
1536
+ });
1537
+ if (!active.length) return { status: "not-found" };
1538
+ const latestPts = active.reduce((latest2, scene) => {
1539
+ const pts = BigInt(scene.source.pts90k);
1540
+ return pts > latest2 ? pts : latest2;
1541
+ }, 0n);
1542
+ const latest = active.filter((scene) => BigInt(scene.source.pts90k) === latestPts);
1543
+ return latest.length === 1 ? { status: "selected", scene: latest[0] } : { status: "ambiguous", scenes: latest };
1544
+ }
1545
+ return candidates.length === 1 ? { status: "selected", scene: candidates[0] } : { status: "ambiguous", scenes: [...candidates] };
1546
+ }
1547
+
1548
+ // src/index.ts
1549
+ var DEFAULT_MENU_INPUT_CONFIG = Object.freeze({ escapeMapping: "popup" });
1550
+ var CanvasViewport = class {
1551
+ constructor(width, height) {
1552
+ this.width = width;
1553
+ this.height = height;
1554
+ __publicField(this, "scale", 1);
1555
+ __publicField(this, "offsetX", 0);
1556
+ __publicField(this, "offsetY", 0);
1557
+ }
1558
+ resize(canvas, cssWidth, cssHeight, dpr) {
1559
+ const scale = Math.min(cssWidth / this.width, cssHeight / this.height);
1560
+ this.scale = scale;
1561
+ this.offsetX = (cssWidth - this.width * scale) / 2;
1562
+ this.offsetY = (cssHeight - this.height * scale) / 2;
1563
+ canvas.width = Math.round(cssWidth * dpr);
1564
+ canvas.height = Math.round(cssHeight * dpr);
1565
+ const context = canvas.getContext("2d");
1566
+ if (context) {
1567
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
1568
+ context.clearRect(0, 0, cssWidth, cssHeight);
1569
+ }
1570
+ }
1571
+ toSource(clientX, clientY, rect) {
1572
+ const x = (clientX - rect.left - this.offsetX) / this.scale, y = (clientY - rect.top - this.offsetY) / this.scale;
1573
+ return x < 0 || y < 0 || x >= this.width || y >= this.height ? null : { x, y };
1574
+ }
1575
+ toCss(context, cssWidth, cssHeight, dpr = 1) {
1576
+ context.setTransform(dpr, 0, 0, dpr, 0, 0);
1577
+ context.clearRect(0, 0, cssWidth, cssHeight);
1578
+ context.setTransform(dpr * this.scale, 0, 0, dpr * this.scale, dpr * this.offsetX, dpr * this.offsetY);
1579
+ }
1580
+ };
1581
+ var EventScheduler = class {
1582
+ constructor() {
1583
+ __publicField(this, "nowValue", 0);
1584
+ __publicField(this, "nextId", 1);
1585
+ __publicField(this, "events", /* @__PURE__ */ new Map());
1586
+ __publicField(this, "pausedAt", null);
1587
+ }
1588
+ get now() {
1589
+ return this.nowValue;
1590
+ }
1591
+ schedule(delayMs, scope, callback) {
1592
+ if (!Number.isFinite(delayMs) || delayMs < 0) throw new RangeError("delay must be non-negative");
1593
+ const id = this.nextId++;
1594
+ this.events.set(id, { due: this.nowValue + delayMs, scope, callback });
1595
+ return id;
1596
+ }
1597
+ cancelScope(scope) {
1598
+ for (const [id, event] of this.events) if (event.scope === scope) this.events.delete(id);
1599
+ }
1600
+ advance(deltaMs) {
1601
+ if (!Number.isFinite(deltaMs) || deltaMs < 0) throw new RangeError("delta must be non-negative");
1602
+ this.nowValue += deltaMs;
1603
+ while (true) {
1604
+ const due = [...this.events].filter(([, e]) => e.due <= this.nowValue).sort((a, b) => a[1].due - b[1].due || a[0] - b[0])[0];
1605
+ if (!due) break;
1606
+ this.events.delete(due[0]);
1607
+ due[1].callback();
1608
+ }
1609
+ }
1610
+ pause() {
1611
+ this.pausedAt = this.nowValue;
1612
+ }
1613
+ resume() {
1614
+ this.pausedAt = null;
1615
+ }
1616
+ saveCheckpoint() {
1617
+ return { now: this.nowValue, nextId: this.nextId, pausedAt: this.pausedAt, events: [...this.events].map(([id, event]) => [id, { ...event }]) };
1618
+ }
1619
+ restoreCheckpoint(checkpoint) {
1620
+ this.nowValue = checkpoint.now;
1621
+ this.nextId = checkpoint.nextId;
1622
+ this.pausedAt = checkpoint.pausedAt;
1623
+ this.events.clear();
1624
+ for (const [id, event] of checkpoint.events) this.events.set(id, { ...event });
1625
+ }
1626
+ advanceWhileRunning(deltaMs) {
1627
+ if (this.pausedAt === null) this.advance(deltaMs);
1628
+ }
1629
+ };
1630
+ var MenuSoundPlayer = class {
1631
+ constructor(sounds, createAudio, hooks) {
1632
+ this.sounds = sounds;
1633
+ this.createAudio = createAudio;
1634
+ this.hooks = hooks;
1635
+ __publicField(this, "requestId", 0);
1636
+ __publicField(this, "muted", false);
1637
+ __publicField(this, "activeAudio", /* @__PURE__ */ new Set());
1638
+ }
1639
+ setMuted(muted) {
1640
+ this.muted = muted;
1641
+ for (const audio of this.activeAudio) audio.muted = muted;
1642
+ }
1643
+ invalidate() {
1644
+ this.requestId++;
1645
+ }
1646
+ play(soundId, buttonId) {
1647
+ const requestId = ++this.requestId;
1648
+ const url = this.sounds[soundId];
1649
+ if (!url) {
1650
+ this.hooks.report("asset", `Sound ${soundId} for button ${buttonId} has no packaged WAV asset.`);
1651
+ return;
1652
+ }
1653
+ let audio;
1654
+ try {
1655
+ audio = this.createAudio(url);
1656
+ } catch (error) {
1657
+ this.hooks.report("asset", `Could not load sound ${soundId}: ${errorMessage(error)}`);
1658
+ return;
1659
+ }
1660
+ audio.muted = this.muted;
1661
+ this.trackAudio(audio);
1662
+ audio.addEventListener?.("error", () => {
1663
+ if (requestId === this.requestId) this.hooks.report("asset", `Sound ${soundId} asset failed to load (${url}).`);
1664
+ }, { once: true });
1665
+ const start = (retry) => {
1666
+ audio.muted = this.muted;
1667
+ let result;
1668
+ try {
1669
+ result = audio.play();
1670
+ } catch (error) {
1671
+ this.handleFailure(error, soundId, buttonId, url, requestId, audio, retry);
1672
+ return;
1673
+ }
1674
+ Promise.resolve(result).catch((error) => this.handleFailure(error, soundId, buttonId, url, requestId, audio, retry));
1675
+ };
1676
+ start(false);
1677
+ }
1678
+ handleFailure(error, soundId, buttonId, url, requestId, audio, retry) {
1679
+ this.activeAudio.delete(audio);
1680
+ if (requestId !== this.requestId) return;
1681
+ const name = typeof error === "object" && error !== null && "name" in error ? String(error.name) : "";
1682
+ if (name === "NotAllowedError") {
1683
+ if (!this.hooks.isCurrent(soundId, buttonId)) return;
1684
+ this.hooks.report("blocked", `Browser blocked sound ${soundId}; use the retry button to play it.`);
1685
+ this.hooks.offerRetry(() => {
1686
+ if (requestId === this.requestId && this.hooks.isCurrent(soundId, buttonId)) this.handleRetry(audio, soundId, buttonId, url, requestId);
1687
+ });
1688
+ } else if (name === "NotSupportedError") this.hooks.report("unsupported", `Browser does not support packaged sound ${soundId} (${url}).`);
1689
+ else this.hooks.report("playback", `Sound ${soundId} ${retry ? "retry" : "playback"} failed: ${errorMessage(error)}`);
1690
+ }
1691
+ handleRetry(audio, soundId, buttonId, url, requestId) {
1692
+ this.trackAudio(audio);
1693
+ audio.muted = this.muted;
1694
+ let result;
1695
+ try {
1696
+ result = audio.play();
1697
+ } catch (error) {
1698
+ this.handleFailure(error, soundId, buttonId, url, requestId, audio, true);
1699
+ return;
1700
+ }
1701
+ Promise.resolve(result).catch((error) => this.handleFailure(error, soundId, buttonId, url, requestId, audio, true));
1702
+ }
1703
+ trackAudio(audio) {
1704
+ if (!audio.addEventListener) return;
1705
+ this.activeAudio.add(audio);
1706
+ const release = () => this.activeAudio.delete(audio);
1707
+ audio.addEventListener("ended", release, { once: true });
1708
+ audio.addEventListener("error", release, { once: true });
1709
+ }
1710
+ };
1711
+ function errorMessage(error) {
1712
+ return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
1713
+ }
1714
+ var MenuController = class {
1715
+ constructor(scene, vm, hooks = {}, schedulerOrConfig = new EventScheduler()) {
1716
+ this.scene = scene;
1717
+ this.vm = vm;
1718
+ this.hooks = hooks;
1719
+ __publicField(this, "pageIndex", 0);
1720
+ __publicField(this, "selectedButtonId", null);
1721
+ __publicField(this, "activatedButtonId", null);
1722
+ __publicField(this, "disabled", /* @__PURE__ */ new Set());
1723
+ __publicField(this, "bogSelections", /* @__PURE__ */ new Map());
1724
+ __publicField(this, "scheduler");
1725
+ __publicField(this, "invalidUoMaskPages", /* @__PURE__ */ new Set());
1726
+ __publicField(this, "unsupportedTimeoutActivationPages", /* @__PURE__ */ new Set());
1727
+ __publicField(this, "started", false);
1728
+ __publicField(this, "autoActionDepth", 0);
1729
+ __publicField(this, "inputConfig");
1730
+ __publicField(this, "animationIndices", /* @__PURE__ */ new Map());
1731
+ __publicField(this, "animationElapsedMs", 0);
1732
+ __publicField(this, "paused", false);
1733
+ __publicField(this, "activeEffect", null);
1734
+ if (!scene.pages.length) throw new TypeError("menu scene must contain a page");
1735
+ const scheduler = isMenuScheduler(schedulerOrConfig);
1736
+ this.scheduler = scheduler ? schedulerOrConfig : new EventScheduler();
1737
+ const inputConfig = scheduler ? DEFAULT_MENU_INPUT_CONFIG : { ...DEFAULT_MENU_INPUT_CONFIG, ...schedulerOrConfig };
1738
+ if (!["popup", "topMenu", "custom", "none"].includes(inputConfig.escapeMapping)) throw new TypeError(`Invalid Escape mapping '${String(inputConfig.escapeMapping)}'.`);
1739
+ this.inputConfig = inputConfig;
1740
+ this.selectPageDefault(false);
1741
+ }
1742
+ get page() {
1743
+ return this.scene.pages[this.pageIndex];
1744
+ }
1745
+ get button() {
1746
+ return this.page.buttons.find((b) => b.id === this.selectedButtonId) ?? null;
1747
+ }
1748
+ saveCheckpoint() {
1749
+ if (!this.scheduler.saveCheckpoint || !this.scheduler.restoreCheckpoint) throw new TypeError("Menu scheduler does not support checkpoints");
1750
+ return {
1751
+ pageIndex: this.pageIndex,
1752
+ selectedButtonId: this.selectedButtonId,
1753
+ activatedButtonId: this.activatedButtonId,
1754
+ disabled: [...this.disabled],
1755
+ bogSelections: [...this.bogSelections],
1756
+ invalidUoMaskPages: [...this.invalidUoMaskPages],
1757
+ unsupportedTimeoutActivationPages: [...this.unsupportedTimeoutActivationPages],
1758
+ started: this.started,
1759
+ animationIndices: [...this.animationIndices],
1760
+ animationElapsedMs: this.animationElapsedMs,
1761
+ paused: this.paused,
1762
+ activeEffect: this.activeEffect ? { ...this.activeEffect, frames: [...this.activeEffect.frames], next: [...this.activeEffect.next] } : null,
1763
+ autoActionDepth: this.autoActionDepth,
1764
+ scheduler: this.scheduler.saveCheckpoint()
1765
+ };
1766
+ }
1767
+ restoreCheckpoint(checkpoint) {
1768
+ if (!Number.isInteger(checkpoint.pageIndex) || checkpoint.pageIndex < 0 || checkpoint.pageIndex >= this.scene.pages.length) throw new TypeError("Menu checkpoint has an invalid page index");
1769
+ if (!this.scheduler.restoreCheckpoint) throw new TypeError("Menu scheduler does not support checkpoints");
1770
+ const { scheduler: schedulerCheckpoint, ...state } = checkpoint;
1771
+ this.pageIndex = state.pageIndex;
1772
+ this.selectedButtonId = state.selectedButtonId;
1773
+ this.activatedButtonId = state.activatedButtonId;
1774
+ this.disabled.clear();
1775
+ for (const id of state.disabled) this.disabled.add(id);
1776
+ this.bogSelections.clear();
1777
+ for (const [key, button] of state.bogSelections) this.bogSelections.set(key, button);
1778
+ this.animationIndices = new Map(state.animationIndices);
1779
+ this.animationElapsedMs = state.animationElapsedMs;
1780
+ this.paused = state.paused;
1781
+ this.activeEffect = state.activeEffect ? { ...state.activeEffect, frames: [...state.activeEffect.frames], next: [...state.activeEffect.next] } : null;
1782
+ this.invalidUoMaskPages.clear();
1783
+ for (const page of state.invalidUoMaskPages) this.invalidUoMaskPages.add(page);
1784
+ this.unsupportedTimeoutActivationPages.clear();
1785
+ for (const page of state.unsupportedTimeoutActivationPages) this.unsupportedTimeoutActivationPages.add(page);
1786
+ this.started = state.started;
1787
+ this.autoActionDepth = state.autoActionDepth;
1788
+ this.scheduler.restoreCheckpoint(schedulerCheckpoint);
1789
+ }
1790
+ schedulePageEffect(delayMs, callback) {
1791
+ return this.scheduler.schedule(delayMs, `page:${this.page.id}`, callback);
1792
+ }
1793
+ pause() {
1794
+ this.paused = true;
1795
+ this.scheduler.pause();
1796
+ }
1797
+ resume() {
1798
+ this.paused = false;
1799
+ this.scheduler.resume();
1800
+ }
1801
+ advanceWhileRunning(deltaMs) {
1802
+ this.scheduler.advanceWhileRunning(deltaMs);
1803
+ if (!this.started || this.paused || !Number.isFinite(deltaMs) || deltaMs < 0) return;
1804
+ if (this.activeEffect) {
1805
+ this.advanceEffect(deltaMs);
1806
+ return;
1807
+ }
1808
+ const interval = animationFrameIntervalMs(this.scene.frameRateCode ?? 0, this.page.animationFrameRateCode ?? 0);
1809
+ if (!this.hasActiveAnimation()) return;
1810
+ this.animationElapsedMs += deltaMs;
1811
+ let steps = 0;
1812
+ while ((interval === 0 ? steps === 0 : this.animationElapsedMs >= interval) && steps < 256) {
1813
+ if (interval > 0) this.animationElapsedMs -= interval;
1814
+ this.advanceButtonAnimations();
1815
+ steps++;
1816
+ }
1817
+ if (steps) this.hooks.render?.();
1818
+ }
1819
+ get effectFrame() {
1820
+ return this.activeEffect?.frames[this.activeEffect.index] ?? null;
1821
+ }
1822
+ get isAnimating() {
1823
+ return !!this.activeEffect || this.hasActiveAnimation();
1824
+ }
1825
+ visualState(button) {
1826
+ const state = !button.enabled ? button.states.normal : button.id === this.activatedButtonId ? button.states.activated : button.id === this.selectedButtonId ? button.states.selected : button.states.normal;
1827
+ if (!state || state.startObjectId === void 0 || state.endObjectId === void 0) return state;
1828
+ const index = this.animationIndices.get(button.bog) ?? 0;
1829
+ const objectId = index < 0 && state.endObjectId < 65534 ? state.endObjectId : state.startObjectId + Math.max(0, index);
1830
+ const object = this.page.objects?.get(objectId);
1831
+ if (!object) {
1832
+ const { image: _image, ...withoutImage } = state;
1833
+ return this.page.objects ? withoutImage : state;
1834
+ }
1835
+ return { ...state, image: object.image, bounds: { x: state.bounds.x, y: state.bounds.y, width: object.width, height: object.height } };
1836
+ }
1837
+ start() {
1838
+ if (this.started) return;
1839
+ this.started = true;
1840
+ this.syncMenuContext();
1841
+ this.beginPageEffects([], this.page.effects?.enter ?? [], this.button?.autoAction === true);
1842
+ if (!this.activeEffect && this.button?.autoAction) this.runAutomaticAction(this.button);
1843
+ }
1844
+ syncVmContext() {
1845
+ this.syncMenuContext();
1846
+ }
1847
+ setPage(index) {
1848
+ this.changePage(index, true);
1849
+ }
1850
+ applySetButtonPage(packed) {
1851
+ if (!Number.isInteger(packed) || packed < 0 || packed > 4294967295) {
1852
+ this.hooks.diagnostic?.("VM emitted an invalid SET_BUTTON_PAGE value.");
1853
+ return;
1854
+ }
1855
+ const pageFlag = (packed & 2147483648) !== 0;
1856
+ const effectsFlag = (packed & 1073741824) !== 0;
1857
+ const buttonFlag = (packed & 536870912) !== 0;
1858
+ const pageId = packed >>> 16 & 255;
1859
+ const buttonId = packed & 65535;
1860
+ if (pageFlag) {
1861
+ if (pageId === this.page.id) return;
1862
+ const pageIndex = this.scene.pages.findIndex((page) => page.id === pageId);
1863
+ if (pageIndex < 0) {
1864
+ this.hooks.diagnostic?.(`SET_BUTTON_PAGE references missing page ${pageId}.`);
1865
+ return;
1866
+ }
1867
+ this.changePage(pageIndex, !buttonFlag, !effectsFlag);
1868
+ }
1869
+ if (!buttonFlag) return;
1870
+ if (buttonId === 65535) {
1871
+ this.selectedButtonId = null;
1872
+ this.activatedButtonId = null;
1873
+ this.syncMenuContext();
1874
+ this.hooks.invalidateSound?.();
1875
+ this.hooks.render?.();
1876
+ return;
1877
+ }
1878
+ const target = this.page.buttons.find((button) => button.id === buttonId);
1879
+ if (!target) {
1880
+ this.hooks.diagnostic?.(`SET_BUTTON_PAGE references unusable button ${buttonId} on page ${this.page.id}.`);
1881
+ return;
1882
+ }
1883
+ this.activateBogButton(target);
1884
+ }
1885
+ changePage(index, runAutoAction, playOutEffects = true) {
1886
+ if (!Number.isInteger(index) || index < 0 || index >= this.scene.pages.length) throw new RangeError("page index out of range");
1887
+ const oldPage = this.page;
1888
+ this.scheduler.cancelScope(`page:${oldPage.id}`);
1889
+ this.hooks.invalidateSound?.();
1890
+ this.pageIndex = index;
1891
+ this.activatedButtonId = null;
1892
+ this.selectPageDefault(false);
1893
+ const out = playOutEffects ? oldPage.effects?.exit ?? [] : [];
1894
+ const enter = this.page.effects?.enter ?? [];
1895
+ this.beginPageEffects(out, enter, runAutoAction && this.started);
1896
+ if (!this.activeEffect && runAutoAction && this.started && this.button?.autoAction) this.runAutomaticAction(this.button);
1897
+ this.hooks.render?.();
1898
+ }
1899
+ beginPageEffects(out, enter, pendingAutoAction) {
1900
+ const frames = out.length ? out : enter;
1901
+ this.activeEffect = frames.length ? {
1902
+ frames: [...frames],
1903
+ index: 0,
1904
+ remainingMs: frames[0].duration90k / 90,
1905
+ next: out.length ? [...enter] : [],
1906
+ pendingAutoAction
1907
+ } : null;
1908
+ }
1909
+ advanceEffect(deltaMs) {
1910
+ let remainingDelta = deltaMs;
1911
+ let transitions = 0;
1912
+ while (this.activeEffect && transitions < 512) {
1913
+ const active = this.activeEffect;
1914
+ if (active.remainingMs > remainingDelta) {
1915
+ active.remainingMs -= remainingDelta;
1916
+ break;
1917
+ }
1918
+ remainingDelta = Math.max(0, remainingDelta - active.remainingMs);
1919
+ if (active.index + 1 < active.frames.length) {
1920
+ active.index++;
1921
+ active.remainingMs = active.frames[active.index].duration90k / 90;
1922
+ } else if (active.next.length) {
1923
+ active.frames = active.next;
1924
+ active.next = [];
1925
+ active.index = 0;
1926
+ active.remainingMs = active.frames[0].duration90k / 90;
1927
+ } else {
1928
+ const runAutoAction = active.pendingAutoAction;
1929
+ this.activeEffect = null;
1930
+ if (runAutoAction && this.button?.autoAction) this.runAutomaticAction(this.button);
1931
+ }
1932
+ transitions++;
1933
+ if (remainingDelta === 0 && this.activeEffect?.remainingMs !== 0) break;
1934
+ }
1935
+ this.hooks.render?.();
1936
+ }
1937
+ hasActiveAnimation() {
1938
+ return this.page.buttons.some((button) => {
1939
+ if (!this.isDisplayed(button)) return false;
1940
+ const state = !button.enabled ? button.states.normal : button.id === this.activatedButtonId ? button.states.activated : button.id === this.selectedButtonId ? button.states.selected : button.states.normal;
1941
+ return !!state && (this.animationIndices.get(button.bog) ?? 0) >= 0 && state.endObjectId !== void 0 && state.startObjectId !== void 0 && state.endObjectId > state.startObjectId;
1942
+ });
1943
+ }
1944
+ advanceButtonAnimations() {
1945
+ const visibleBogs = new Set(this.page.buttons.filter((button) => this.isDisplayed(button)).map((button) => button.bog));
1946
+ for (const bog of visibleBogs) {
1947
+ const button = this.page.buttons.find((candidate) => candidate.bog === bog && this.isDisplayed(candidate));
1948
+ if (!button) continue;
1949
+ const state = !button.enabled ? button.states.normal : button.id === this.activatedButtonId ? button.states.activated : button.id === this.selectedButtonId ? button.states.selected : button.states.normal;
1950
+ if (!state || state.startObjectId === void 0 || state.endObjectId === void 0) continue;
1951
+ const range = state.endObjectId - state.startObjectId;
1952
+ if (range <= 0 || state.startObjectId >= 65535 || state.endObjectId >= 65535) continue;
1953
+ const current = this.animationIndices.get(bog) ?? 0;
1954
+ if (current < 0) continue;
1955
+ const next = current + 1;
1956
+ if (state.repeat) this.animationIndices.set(bog, next % (range + 1));
1957
+ else this.animationIndices.set(bog, next > range ? -1 : next);
1958
+ }
1959
+ }
1960
+ setButtonEnabled(id, enabled) {
1961
+ const target = this.page.buttons.find((button) => button.id === id);
1962
+ if (!target) return;
1963
+ if (!this.page.bogDefaults) {
1964
+ enabled ? this.disabled.delete(id) : this.disabled.add(id);
1965
+ if (!enabled && this.selectedButtonId === id) {
1966
+ this.hooks.invalidateSound?.();
1967
+ this.activatedButtonId = null;
1968
+ this.selectedButtonId = null;
1969
+ this.syncMenuContext();
1970
+ }
1971
+ this.hooks.render?.();
1972
+ return;
1973
+ }
1974
+ const key = this.bogSelectionKey(this.page.id, target.bog);
1975
+ const current = this.bogSelections.get(key);
1976
+ if (enabled) {
1977
+ this.animationIndices.set(target.bog, 0);
1978
+ this.bogSelections.set(key, id);
1979
+ if (current === this.selectedButtonId) this.selectButton(id);
1980
+ else if (!this.isUsable(this.button)) {
1981
+ const fallback = this.firstUsablePageButton();
1982
+ if (fallback) this.selectButton(fallback.id);
1983
+ }
1984
+ } else if (current === id) {
1985
+ this.bogSelections.set(key, 65535);
1986
+ if (this.selectedButtonId === id) {
1987
+ this.hooks.invalidateSound?.();
1988
+ this.activatedButtonId = null;
1989
+ this.selectedButtonId = this.firstUsablePageButton()?.id ?? null;
1990
+ this.syncMenuContext();
1991
+ }
1992
+ }
1993
+ this.hooks.render?.();
1994
+ }
1995
+ handle(kind) {
1996
+ if (this.activeEffect) return;
1997
+ if (kind === "escape") {
1998
+ if (!this.isUoAllowed(25)) return;
1999
+ const current = this.button;
2000
+ const hiddenAutoActions = current ? [...new Set(Object.values(current.neighbors))].map((id) => this.page.buttons.find((button2) => button2.id === id)).filter((button2) => !!button2 && this.isUsable(button2) && button2.autoAction === true && button2.commands.length > 0 && button2.states.selected?.bounds === void 0) : [];
2001
+ if (hiddenAutoActions.length === 1) {
2002
+ this.selectButton(hiddenAutoActions[0].id);
2003
+ return;
2004
+ }
2005
+ if (this.inputConfig.escapeMapping === "popup") this.handle("popup");
2006
+ else if (this.inputConfig.escapeMapping === "topMenu") this.handle("topMenu");
2007
+ else if (this.inputConfig.escapeMapping === "custom") this.hooks.escape?.();
2008
+ return;
2009
+ }
2010
+ if (kind === "popup" || kind === "topMenu") {
2011
+ const permission = kind === "popup" ? "popup" : "topMenu";
2012
+ if (this.page.allowedActions?.[permission] === false || !this.isUoAllowed(kind === "popup" ? 24 : 0)) return;
2013
+ this.vm.beginMenuAction?.();
2014
+ if (kind === "popup") {
2015
+ this.hooks.popup?.();
2016
+ this.vm.handleEvent({ type: "POPUP_MENU", channel: "keyboard" });
2017
+ } else {
2018
+ this.hooks.topMenu?.();
2019
+ this.vm.handleEvent({ type: "TOP_MENU", channel: "keyboard" });
2020
+ }
2021
+ this.hooks.dispatchActions?.();
2022
+ return;
2023
+ }
2024
+ if (["up", "down", "left", "right"].includes(kind)) {
2025
+ this.move(kind);
2026
+ return;
2027
+ }
2028
+ if (this.page.allowedActions?.activate === false || !this.isUoAllowed(19)) return;
2029
+ const button = this.button;
2030
+ if (!this.isUsable(button)) return;
2031
+ const activatedPageId = this.page.id;
2032
+ this.syncMenuContext();
2033
+ this.vm.beginMenuAction?.();
2034
+ this.activatedButtonId = button.id;
2035
+ const state = button.states.selected;
2036
+ if (state?.soundId !== void 0 && state.soundId !== 255) this.hooks.playSound?.(state.soundId, button.id);
2037
+ if (this.vm.executeButtonProgram) {
2038
+ try {
2039
+ const commands = button.commands.map(({ bytes, source: source2 }) => {
2040
+ const decoded = decodeInstruction(bytes, source2);
2041
+ return { bytes: decoded.bytes, words: decoded.words, source: decoded.source };
2042
+ });
2043
+ const result = this.vm.executeButtonProgram(commands, "ig_object");
2044
+ if (result.fault?.message) {
2045
+ this.hooks.diagnostic?.(result.fault.message);
2046
+ this.recoverMenuAction();
2047
+ } else if (result.diagnostic?.message) {
2048
+ this.hooks.diagnostic?.(result.diagnostic.message);
2049
+ this.recoverMenuAction();
2050
+ } else this.hooks.dispatchActions?.();
2051
+ } catch (error) {
2052
+ this.recoverMenuAction();
2053
+ this.hooks.diagnostic?.(error instanceof Error ? error.message : String(error));
2054
+ }
2055
+ this.hooks.activateButton?.(activatedPageId, button);
2056
+ } else if (this.vm.executeButtonCommand) {
2057
+ for (const command2 of button.commands) {
2058
+ const result = this.vm.executeButtonCommand(command2.bytes, command2.source, "ig_object");
2059
+ if (result.diagnostic?.message) this.hooks.diagnostic?.(result.diagnostic.message);
2060
+ if (result.status === "faulted" || result.diagnostic?.message) this.recoverMenuAction();
2061
+ else this.hooks.dispatchActions?.();
2062
+ if (result.buttonCommandTerminated || result.status === "faulted" || result.status === "suspended" || result.status === "stopped") break;
2063
+ }
2064
+ this.hooks.activateButton?.(activatedPageId, button);
2065
+ } else if (button.commands.length) this.hooks.diagnostic?.(`Button ${button.id} has commands, but no HDMV button-command executor is connected.`);
2066
+ this.hooks.render?.();
2067
+ }
2068
+ hit(x, y) {
2069
+ const orderedButtons = this.page.bogDefaults ? this.page.bogDefaults.flatMap(({ bog }) => this.page.buttons.filter((button) => button.bog === bog)) : this.page.buttons;
2070
+ return orderedButtons.find((button) => this.isUsable(button) && contains(button.states.selected?.bounds, x, y))?.id ?? null;
2071
+ }
2072
+ selectFromPointer(x, y) {
2073
+ if (!this.isUoAllowed(18)) return false;
2074
+ const id = this.hit(x, y);
2075
+ if (id === null) return false;
2076
+ this.selectButton(id, false);
2077
+ return this.selectedButtonId === id;
2078
+ }
2079
+ activatePointer(x, y) {
2080
+ if (!this.isUoAllowed(20)) return;
2081
+ const hit = this.hit(x, y);
2082
+ if (hit === null) return;
2083
+ const selectionChanged = this.selectedButtonId !== hit;
2084
+ if (selectionChanged && (!this.selectFromPointer(x, y) || this.selectedButtonId !== hit)) return;
2085
+ this.handle("activate");
2086
+ }
2087
+ move(direction) {
2088
+ const fieldIndex = { up: 14, down: 15, left: 16, right: 17 }[direction];
2089
+ if (this.page.allowedActions?.move === false || !this.isUoAllowed(fieldIndex)) return;
2090
+ const current = this.button;
2091
+ if (!this.isUsable(current)) return;
2092
+ const targetId = current.neighbors[direction];
2093
+ const target = this.page.buttons.find((b) => b.id === targetId);
2094
+ if (this.isUsable(target)) this.selectButton(targetId);
2095
+ }
2096
+ isUsable(button) {
2097
+ if (!button || !button.enabled || this.disabled.has(button.id) || !this.isBogEnabled(button)) return false;
2098
+ return !this.page.bogDefaults || this.page.bogDefaults.some((entry) => entry.bog === button.bog);
2099
+ }
2100
+ isBogEnabled(button) {
2101
+ return !this.page.bogDefaults || this.bogSelections.get(this.bogSelectionKey(this.page.id, button.bog)) === button.id;
2102
+ }
2103
+ isDisplayed(button) {
2104
+ return this.isBogEnabled(button);
2105
+ }
2106
+ activateBogButton(button) {
2107
+ this.bogSelections.set(this.bogSelectionKey(this.page.id, button.bog), button.id);
2108
+ this.selectButton(button.id, false);
2109
+ if (button.autoAction && this.selectedButtonId === button.id) this.runAutomaticAction(button);
2110
+ this.hooks.render?.();
2111
+ }
2112
+ firstUsablePageButton() {
2113
+ const declaredDefault = this.page.buttons.find((button) => button.id === this.page.defaultButtonId && this.isUsable(button));
2114
+ return declaredDefault ?? this.page.buttons.find((button) => this.isUsable(button));
2115
+ }
2116
+ bogSelectionKey(pageId, bog) {
2117
+ return `${pageId}:${bog}`;
2118
+ }
2119
+ recoverMenuAction() {
2120
+ if (this.vm.recoverMenuAction?.()) this.syncMenuContext();
2121
+ }
2122
+ isUoAllowed(fieldIndex) {
2123
+ const serialized = this.page.uoMask;
2124
+ if (serialized === void 0) return true;
2125
+ if (!/^(0|[1-9][0-9]*)$/.test(serialized)) {
2126
+ if (!this.invalidUoMaskPages.has(this.page.id)) {
2127
+ this.invalidUoMaskPages.add(this.page.id);
2128
+ this.hooks.diagnostic?.(`Page ${this.page.id} has an invalid decimal UO mask; menu input is blocked.`);
2129
+ }
2130
+ return false;
2131
+ }
2132
+ const sourceBit = 63 - fieldIndex;
2133
+ return (BigInt(serialized) >> BigInt(sourceBit) & 1n) === 0n;
2134
+ }
2135
+ selectButton(id, runAutoAction = true) {
2136
+ const target = this.page.buttons.find((button) => button.id === id);
2137
+ if (!this.isUsable(target)) return;
2138
+ const changed = this.selectedButtonId !== id;
2139
+ if (changed) {
2140
+ this.hooks.invalidateSound?.();
2141
+ this.activatedButtonId = null;
2142
+ this.selectedButtonId = id;
2143
+ this.animationIndices.set(target.bog, 0);
2144
+ this.syncMenuContext();
2145
+ this.hooks.render?.();
2146
+ if (runAutoAction && target.autoAction) this.runAutomaticAction(target);
2147
+ }
2148
+ }
2149
+ selectPageDefault(runAutoAction) {
2150
+ const page = this.page;
2151
+ this.animationIndices = new Map((page.bogDefaults ?? [...new Set(page.buttons.map((button) => button.bog))].map((bog) => ({ bog }))).map(({ bog }) => [bog, 0]));
2152
+ this.animationElapsedMs = 0;
2153
+ const previousSelection = this.selectedButtonId;
2154
+ for (const key of [...this.bogSelections.keys()]) if (key.startsWith(`${page.id}:`)) this.bogSelections.delete(key);
2155
+ for (const entry of page.bogDefaults ?? []) {
2156
+ const member = page.buttons.find((candidate) => candidate.id === entry.defaultButtonId && candidate.bog === entry.bog);
2157
+ if (member) {
2158
+ this.bogSelections.set(this.bogSelectionKey(page.id, entry.bog), member.id);
2159
+ const inertPlaceholder = !member.enabled && !member.autoAction && !member.states.normal && !member.states.selected && !member.commands.length;
2160
+ if (!member.enabled && !inertPlaceholder) this.hooks.diagnostic?.(`Page ${page.id} BOG ${entry.bog} default button ${entry.defaultButtonId} belongs to the BOG but is disabled or has no usable hit geometry.`);
2161
+ } else if (entry.defaultButtonId !== 65535) {
2162
+ this.hooks.diagnostic?.(`Page ${page.id} BOG ${entry.bog} default button ${entry.defaultButtonId} is not a member of that BOG.`);
2163
+ }
2164
+ }
2165
+ const declaredDefault = page.defaultButtonId === 65535 ? void 0 : page.buttons.find((b) => b.id === page.defaultButtonId && this.isUsable(b));
2166
+ const psrSelection = this.vm.snapshot?.().registers.psr[10];
2167
+ const preservedSelection = psrSelection === void 0 ? previousSelection : psrSelection === 65535 ? null : psrSelection;
2168
+ const priorEnabled = preservedSelection === null ? void 0 : page.buttons.find((b) => b.id === preservedSelection && this.isUsable(b));
2169
+ const firstEnabled = page.bogDefaults ? page.buttons.find((b) => this.isUsable(b)) : void 0;
2170
+ const initialSelection = declaredDefault ?? priorEnabled ?? firstEnabled;
2171
+ this.selectedButtonId = initialSelection?.id ?? null;
2172
+ this.activatedButtonId = null;
2173
+ this.syncMenuContext();
2174
+ const defaultActivatedButtonId = page.defaultActivatedButtonId;
2175
+ if (defaultActivatedButtonId !== void 0 && defaultActivatedButtonId !== 65534 && defaultActivatedButtonId !== 65535) {
2176
+ this.hooks.diagnostic?.(`Page ${page.id} declares default activated button ${defaultActivatedButtonId}, but its runtime semantics are unverified.`);
2177
+ }
2178
+ if (defaultActivatedButtonId === 65534 && this.hasSelectionTimeout() && !this.unsupportedTimeoutActivationPages.has(page.id)) {
2179
+ this.unsupportedTimeoutActivationPages.add(page.id);
2180
+ this.hooks.diagnostic?.(`Page ${page.id} requests activation of the selected button after the selection timeout, but timeout activation is not implemented.`);
2181
+ }
2182
+ if (initialSelection) this.bogSelections.set(this.bogSelectionKey(page.id, initialSelection.bog), initialSelection.id);
2183
+ if (runAutoAction && initialSelection?.autoAction) this.runAutomaticAction(initialSelection);
2184
+ }
2185
+ runAutomaticAction(button) {
2186
+ if (this.activeEffect) {
2187
+ this.activeEffect.pendingAutoAction = true;
2188
+ return;
2189
+ }
2190
+ if (this.autoActionDepth >= 32) {
2191
+ this.hooks.diagnostic?.("Menu auto-action chain exceeded 32 nested activations and was stopped.");
2192
+ return;
2193
+ }
2194
+ this.autoActionDepth++;
2195
+ try {
2196
+ this.handle("activate");
2197
+ } finally {
2198
+ this.autoActionDepth--;
2199
+ }
2200
+ }
2201
+ hasSelectionTimeout() {
2202
+ return this.scene.selectionTimeout90k !== null && this.scene.selectionTimeout90k !== void 0 && this.scene.selectionTimeout90k !== "0";
2203
+ }
2204
+ syncMenuContext() {
2205
+ this.vm.applyMenuContext?.(this.page.id, this.selectedButtonId);
2206
+ }
2207
+ };
2208
+ function isMenuScheduler(value) {
2209
+ return typeof value.schedule === "function" && typeof value.cancelScope === "function" && typeof value.pause === "function" && typeof value.resume === "function" && typeof value.advanceWhileRunning === "function" && typeof value.now === "number";
2210
+ }
2211
+ function drawMenu(context, scene, controller, viewport, cssWidth, cssHeight, dpr = 1) {
2212
+ const output = context.canvas;
2213
+ const pageLayer = output?.ownerDocument ? menuPageLayer(output, scene.width, scene.height) : null;
2214
+ const layerContext = pageLayer?.getContext("2d") ?? null;
2215
+ if (layerContext) {
2216
+ layerContext.setTransform(1, 0, 0, 1, 0, 0);
2217
+ layerContext.clearRect(0, 0, scene.width, scene.height);
2218
+ const effect = controller.effectFrame;
2219
+ if (effect) drawEffectFrame(layerContext, effect);
2220
+ else for (const button of controller.page.buttons) {
2221
+ if (!controller.isDisplayed(button)) continue;
2222
+ const state = controller.visualState(button);
2223
+ if (state?.image) layerContext.drawImage(state.image, state.bounds.x, state.bounds.y, state.bounds.width, state.bounds.height);
2224
+ }
2225
+ }
2226
+ viewport.toCss(context, cssWidth, cssHeight, dpr);
2227
+ context.save();
2228
+ context.beginPath();
2229
+ context.rect(0, 0, scene.width, scene.height);
2230
+ context.clip();
2231
+ if (layerContext && pageLayer) context.drawImage(pageLayer, 0, 0, scene.width, scene.height);
2232
+ else if (controller.effectFrame) drawEffectFrame(context, controller.effectFrame);
2233
+ else for (const button of controller.page.buttons) {
2234
+ if (!controller.isDisplayed(button)) continue;
2235
+ const state = controller.visualState(button);
2236
+ if (state?.image) context.drawImage(state.image, state.bounds.x, state.bounds.y, state.bounds.width, state.bounds.height);
2237
+ }
2238
+ context.restore();
2239
+ }
2240
+ function drawEffectFrame(context, frame) {
2241
+ context.clearRect(0, 0, context.canvas.width, context.canvas.height);
2242
+ for (const object of frame.objects) {
2243
+ if (object.crop) context.drawImage(object.image, object.crop.x, object.crop.y, object.crop.width, object.crop.height, object.x, object.y, object.crop.width, object.crop.height);
2244
+ else context.drawImage(object.image, object.x, object.y, object.width, object.height);
2245
+ }
2246
+ }
2247
+ function animationFrameIntervalMs(videoFrameRateCode, animationFrameRateCode) {
2248
+ const frameTicks = [0, 9e4 / 1001 * 24, 9e4 / 1e3 * 24, 9e4 / 1e3 * 25, 9e4 / 1001 * 30, 9e4 / 1e3 * 50, 9e4 / 1001 * 60, 0][videoFrameRateCode] ?? 0;
2249
+ return frameTicks * (animationFrameRateCode + 1) / 90;
2250
+ }
2251
+ var menuPageLayers = /* @__PURE__ */ new WeakMap();
2252
+ function menuPageLayer(output, width, height) {
2253
+ let layer = menuPageLayers.get(output);
2254
+ if (!layer) {
2255
+ layer = output.ownerDocument.createElement("canvas");
2256
+ menuPageLayers.set(output, layer);
2257
+ }
2258
+ if (layer.width !== width) layer.width = width;
2259
+ if (layer.height !== height) layer.height = height;
2260
+ return layer;
2261
+ }
2262
+ function contains(b, x, y) {
2263
+ return !!b && x >= b.x && y >= b.y && x < b.x + b.width && y < b.y + b.height;
2264
+ }
2265
+ var PreviewPlaybackBridge = class {
2266
+ constructor(vm, report) {
2267
+ this.vm = vm;
2268
+ this.report = report;
2269
+ __publicField(this, "currentGeneration", 0);
2270
+ }
2271
+ dispatch(action) {
2272
+ this.currentGeneration = Math.max(this.currentGeneration, action.generation);
2273
+ if (["PLAY_PLAYLIST", "PLAY_PLAYLIST_ITEM", "PLAY_PLAYLIST_MARK", "LINK_PLAY_ITEM", "LINK_PLAY_MARK", "TITLE_SEARCH"].includes(action.type)) {
2274
+ this.report(`Playback unavailable in menu preview (${action.type}).`);
2275
+ this.vm.handleEvent({ type: "MEDIA_UNAVAILABLE", generation: action.generation, responseTo: action.id });
2276
+ } else if (action.type === "MENU_CALL") {
2277
+ this.report("Popup composition is unavailable because this package does not have an in-browser title overlay context.");
2278
+ } else if (["STILL_ON", "STILL_OFF", "MENU_RETURN"].includes(action.type)) {
2279
+ this.report(`${action.type} is not available in menu preview.`);
2280
+ }
2281
+ }
2282
+ isCurrent(generation) {
2283
+ return generation === this.currentGeneration;
2284
+ }
2285
+ };
2286
+ var MenuLoopPlaybackBridge = class {
2287
+ constructor(vm, video, audio, media, report, hooks = {}, playlistMedia = []) {
2288
+ this.vm = vm;
2289
+ this.video = video;
2290
+ this.audio = audio;
2291
+ this.media = media;
2292
+ this.report = report;
2293
+ this.hooks = hooks;
2294
+ __publicField(this, "currentGeneration", 0);
2295
+ __publicField(this, "selectedAudioStream");
2296
+ __publicField(this, "confirmedPrimaryAudioStream");
2297
+ __publicField(this, "switchSequence", 0);
2298
+ __publicField(this, "mediaSequence", 0);
2299
+ __publicField(this, "started", false);
2300
+ __publicField(this, "startPromise", null);
2301
+ __publicField(this, "retryGeneration", 0);
2302
+ __publicField(this, "activePlaylistResponse", null);
2303
+ __publicField(this, "lastTimeUpdateSecond", -1);
2304
+ __publicField(this, "activeMedia");
2305
+ __publicField(this, "playlistMedia");
2306
+ this.playlistMedia = playlistMedia;
2307
+ this.activeMedia = media ?? null;
2308
+ this.confirmedPrimaryAudioStream = vm.registers.read({ kind: "psr", index: 1 });
2309
+ this.selectedAudioStream = media ? this.pickAudioStream(media) : 0;
2310
+ this.video.addEventListener?.("ended", () => {
2311
+ if (this.activeMedia?.background.loop) return;
2312
+ const response = this.activePlaylistResponse;
2313
+ if (!response) return;
2314
+ this.activePlaylistResponse = null;
2315
+ this.vm.handleEvent({ type: "MEDIA_ENDED", generation: response.generation, responseTo: response.id });
2316
+ });
2317
+ this.video.addEventListener?.("timeupdate", () => {
2318
+ const response = this.activePlaylistResponse;
2319
+ if (!response) return;
2320
+ const second = Math.floor(this.video.currentTime);
2321
+ if (second === this.lastTimeUpdateSecond) return;
2322
+ this.lastTimeUpdateSecond = second;
2323
+ this.vm.handleEvent({ type: "TIME_UPDATED", generation: response.generation, responseTo: response.id, payload: { presentationTime: Math.max(0, Math.round(this.video.currentTime * 45e3)) } });
2324
+ });
2325
+ }
2326
+ async dispatch(action) {
2327
+ if (action.generation < this.currentGeneration) return;
2328
+ if (action.generation > this.currentGeneration) this.switchSequence++;
2329
+ this.currentGeneration = Math.max(this.currentGeneration, action.generation);
2330
+ if (action.type === "SET_STREAM") {
2331
+ const stream = action.payload?.primaryAudioStream;
2332
+ if (typeof stream === "number" && stream > 0) await this.selectAudioStream(stream, action.generation);
2333
+ return;
2334
+ }
2335
+ if (action.type === "PLAY_STOP") {
2336
+ this.stopMedia();
2337
+ return;
2338
+ }
2339
+ if (["LINK_PLAY_ITEM", "LINK_PLAY_MARK", "STILL_ON", "STILL_OFF", "MENU_RETURN"].includes(action.type)) {
2340
+ const target2 = action.type === "LINK_PLAY_ITEM" ? action.payload?.playItem : action.type === "LINK_PLAY_MARK" ? action.payload?.playMark : void 0;
2341
+ this.report(`${action.type}${typeof target2 === "number" ? ` ${target2}` : ""} is not available in the packaged menu loop.`);
2342
+ return;
2343
+ }
2344
+ if (action.type !== "PLAY_PLAYLIST" && action.type !== "PLAY_PLAYLIST_ITEM" && action.type !== "PLAY_PLAYLIST_MARK") return;
2345
+ const playlist = action.payload?.playlist;
2346
+ if (typeof playlist !== "number") {
2347
+ this.report("Playback unavailable in menu preview (PLAY_PLAYLIST without a numeric playlist).");
2348
+ this.vm.handleEvent({ type: "MEDIA_UNAVAILABLE", generation: action.generation, responseTo: action.id });
2349
+ return;
2350
+ }
2351
+ const target = this.findMedia(playlist, this.playItemFor(playlist, action));
2352
+ if (!target) {
2353
+ this.report(`Playback unavailable in menu preview (PLAY_PLAYLIST ${String(playlist)}).`);
2354
+ this.vm.handleEvent({ type: "MEDIA_UNAVAILABLE", generation: action.generation, responseTo: action.id });
2355
+ return;
2356
+ }
2357
+ if (!target.background.path) {
2358
+ this.report("Menu background playback assets are unavailable.");
2359
+ this.vm.handleEvent({ type: "MEDIA_UNAVAILABLE", generation: action.generation, responseTo: action.id });
2360
+ return;
2361
+ }
2362
+ const response = { id: action.id, generation: action.generation };
2363
+ if (target !== this.activeMedia) {
2364
+ await this.switchMedia(target, response);
2365
+ return;
2366
+ }
2367
+ this.activePlaylistResponse = response;
2368
+ const started = await this.startMedia(response);
2369
+ if (!started && this.activePlaylistResponse === response) this.activePlaylistResponse = null;
2370
+ }
2371
+ isCurrent(generation) {
2372
+ return generation === this.currentGeneration;
2373
+ }
2374
+ get selectedPrimaryAudioStream() {
2375
+ const track = this.findAudioTrack(this.selectedAudioStream);
2376
+ return track ? this.psrStreamNumber(track) : null;
2377
+ }
2378
+ /** Starts the local menu loop from the Start gesture when Top Menu emits no matching PLAY_PL action. */
2379
+ async startMenuBackground() {
2380
+ if (!this.media?.background.path) {
2381
+ this.report("Menu background playback assets are unavailable.");
2382
+ return false;
2383
+ }
2384
+ if (this.activeMedia !== this.media) {
2385
+ this.mediaSequence++;
2386
+ this.activeMedia = this.media;
2387
+ this.selectedAudioStream = this.pickAudioStream(this.media);
2388
+ this.started = false;
2389
+ this.startPromise = null;
2390
+ this.activePlaylistResponse = null;
2391
+ }
2392
+ return this.startMedia();
2393
+ }
2394
+ async startMedia(response) {
2395
+ const mediaSequence = this.mediaSequence;
2396
+ const media = this.activeMedia;
2397
+ if (this.started) {
2398
+ this.hooks.clearRetry?.();
2399
+ const audioTrack = this.findAudioTrack(this.selectedAudioStream);
2400
+ if (this.video.paused || audioTrack && this.audio.paused) {
2401
+ try {
2402
+ await Promise.all([this.video.play(), ...audioTrack ? [this.audio.play()] : []]);
2403
+ } catch (error) {
2404
+ const name = typeof error === "object" && error !== null && "name" in error ? String(error.name) : "";
2405
+ const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
2406
+ if (name === "NotAllowedError") {
2407
+ this.report(`Browser blocked menu media playback; use the retry button to start it. (${message})`);
2408
+ const retryGeneration = ++this.retryGeneration;
2409
+ this.hooks.offerRetry?.(() => {
2410
+ if (retryGeneration !== this.retryGeneration || response && !this.isPending(response)) return;
2411
+ void this.startMedia(response);
2412
+ });
2413
+ return false;
2414
+ }
2415
+ this.report(`Menu media could not resume: ${message}`);
2416
+ if (response) this.vm.handleEvent({ type: "MEDIA_ERROR", generation: response.generation, responseTo: response.id, payload: { message } });
2417
+ return false;
2418
+ }
2419
+ }
2420
+ if (response) {
2421
+ this.activePlaylistResponse = response;
2422
+ this.vm.handleEvent({ type: "MEDIA_STARTED", generation: response.generation, responseTo: response.id });
2423
+ }
2424
+ return true;
2425
+ }
2426
+ if (!this.startPromise) {
2427
+ if (!media) return false;
2428
+ const audioTrack = this.findAudioTrack(this.selectedAudioStream);
2429
+ this.video.loop = media.background.loop;
2430
+ this.video.src = media.background.path;
2431
+ if (audioTrack) {
2432
+ this.audio.loop = media.background.loop;
2433
+ this.audio.src = audioTrack.path;
2434
+ }
2435
+ const attempt2 = Promise.resolve().then(() => Promise.all([this.video.play(), ...audioTrack ? [this.audio.play()] : []])).then(() => {
2436
+ if (this.mediaSequence !== mediaSequence || this.activeMedia !== media) return false;
2437
+ this.started = true;
2438
+ this.retryGeneration++;
2439
+ this.hooks.clearRetry?.();
2440
+ return true;
2441
+ }).catch((error) => {
2442
+ if (this.mediaSequence !== mediaSequence || this.activeMedia !== media) return false;
2443
+ const name = typeof error === "object" && error !== null && "name" in error ? String(error.name) : "";
2444
+ const message = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
2445
+ if (name === "NotAllowedError") {
2446
+ this.report(`Browser blocked menu media playback; use the retry button to start it. (${message})`);
2447
+ const retryGeneration = ++this.retryGeneration;
2448
+ this.hooks.offerRetry?.(() => {
2449
+ if (retryGeneration !== this.retryGeneration || response && !this.isPending(response)) return;
2450
+ void this.startMedia(response);
2451
+ });
2452
+ return false;
2453
+ }
2454
+ this.retryGeneration++;
2455
+ this.hooks.clearRetry?.();
2456
+ this.report(name === "NotSupportedError" ? `Menu media format is not supported: ${message}` : `Menu media could not start: ${message}`);
2457
+ if (response) this.vm.handleEvent({ type: "MEDIA_ERROR", generation: response.generation, responseTo: response.id, payload: { message } });
2458
+ return false;
2459
+ });
2460
+ this.startPromise = attempt2;
2461
+ }
2462
+ const attempt = this.startPromise;
2463
+ const started = await attempt;
2464
+ if (this.startPromise === attempt && !started) this.startPromise = null;
2465
+ if (started && response) {
2466
+ this.activePlaylistResponse = response;
2467
+ this.vm.handleEvent({ type: "MEDIA_STARTED", generation: response.generation, responseTo: response.id });
2468
+ }
2469
+ return started;
2470
+ }
2471
+ /** Swap the local video/audio source while the VM keeps its current generation. */
2472
+ async switchMedia(target, response) {
2473
+ this.switchSequence++;
2474
+ this.mediaSequence++;
2475
+ this.activeMedia = target;
2476
+ this.selectedAudioStream = this.pickAudioStream(target);
2477
+ this.started = false;
2478
+ this.startPromise = null;
2479
+ this.activePlaylistResponse = response;
2480
+ this.lastTimeUpdateSecond = -1;
2481
+ this.video.pause();
2482
+ this.audio.pause();
2483
+ try {
2484
+ this.video.currentTime = 0;
2485
+ this.audio.currentTime = 0;
2486
+ } catch {
2487
+ }
2488
+ const started = await this.startMedia(response);
2489
+ if (!started && this.activePlaylistResponse === response) this.activePlaylistResponse = null;
2490
+ }
2491
+ findMedia(playlist, playItem = 0) {
2492
+ const candidates = [this.media, ...this.playlistMedia].filter((candidate) => !!candidate && candidate.playlist === playlist);
2493
+ const exact = candidates.find((candidate) => (candidate.playItem ?? 0) === playItem);
2494
+ if (exact) return exact;
2495
+ const playlistLevel = candidates.find((candidate) => candidate.playItem === void 0);
2496
+ if (playlistLevel) return playlistLevel;
2497
+ return candidates.length === 1 ? candidates[0] : void 0;
2498
+ }
2499
+ /** The PlayItem a playlist request addresses; `LinkPI` and play marks are
2500
+ * resolved by the caller, which owns the package's mark table. */
2501
+ playItemFor(playlist, action) {
2502
+ const resolved = this.hooks.resolvePlayItem?.(playlist, action);
2503
+ if (typeof resolved === "number" && Number.isSafeInteger(resolved) && resolved >= 0) return resolved;
2504
+ const payload = action.payload;
2505
+ return typeof payload?.playItem === "number" ? payload.playItem : 0;
2506
+ }
2507
+ /** Switch the background for a `LinkPI`/`LinkMK` jump. No VM response is
2508
+ * expected: those actions move inside the playlist the VM is already playing,
2509
+ * so the menu keeps its current playback wait. */
2510
+ async showMenuItem(playlist, playItem) {
2511
+ const target = this.findMedia(playlist, playItem);
2512
+ if (!target?.background.path) return false;
2513
+ if (target === this.activeMedia) return this.startMedia();
2514
+ this.switchSequence++;
2515
+ this.mediaSequence++;
2516
+ this.activeMedia = target;
2517
+ this.selectedAudioStream = this.pickAudioStream(target);
2518
+ this.started = false;
2519
+ this.startPromise = null;
2520
+ this.activePlaylistResponse = null;
2521
+ this.lastTimeUpdateSecond = -1;
2522
+ this.video.pause();
2523
+ this.audio.pause();
2524
+ try {
2525
+ this.video.currentTime = 0;
2526
+ this.audio.currentTime = 0;
2527
+ } catch {
2528
+ }
2529
+ return this.startMedia();
2530
+ }
2531
+ pickAudioStream(media) {
2532
+ return this.pickAudioTrackForPsr(media, this.confirmedPrimaryAudioStream)?.streamNumber ?? [...media.audioTracks].sort((left, right) => left.streamNumber - right.streamNumber)[0]?.streamNumber ?? 0;
2533
+ }
2534
+ pickAudioTrackForPsr(media, psrStreamNumber) {
2535
+ const matches = media.audioTracks.filter((track) => this.psrStreamNumber(track) === psrStreamNumber);
2536
+ return matches.length === 1 ? matches[0] : void 0;
2537
+ }
2538
+ stopMedia() {
2539
+ this.activePlaylistResponse = null;
2540
+ this.video.pause();
2541
+ this.audio.pause();
2542
+ }
2543
+ isPending(response) {
2544
+ const waiting = this.vm.snapshot().waiting;
2545
+ return waiting?.id === response.id && waiting.generation === response.generation;
2546
+ }
2547
+ findAudioTrack(streamNumber) {
2548
+ if (!this.activeMedia) return void 0;
2549
+ const matches = this.activeMedia.audioTracks.filter((track) => track.streamNumber === streamNumber);
2550
+ return matches.length === 1 ? matches[0] : void 0;
2551
+ }
2552
+ findAudioTrackForPsrStream(psrStreamNumber) {
2553
+ if (!this.activeMedia) return void 0;
2554
+ const matches = this.activeMedia.audioTracks.filter((track) => this.psrStreamNumber(track) === psrStreamNumber);
2555
+ return matches.length === 1 ? matches[0] : void 0;
2556
+ }
2557
+ psrStreamNumber(track) {
2558
+ return track.psrStreamNumber ?? track.streamNumber + 1;
2559
+ }
2560
+ async selectAudioStream(psrStreamNumber, generation) {
2561
+ if (!this.activeMedia || this.activeMedia.audioTracks.length === 0) {
2562
+ this.rollbackPrimaryAudioStream(this.confirmedPrimaryAudioStream);
2563
+ return;
2564
+ }
2565
+ const sequence = ++this.switchSequence;
2566
+ const previousTrack = this.findAudioTrack(this.selectedAudioStream);
2567
+ const previousPsr = this.confirmedPrimaryAudioStream;
2568
+ const track = this.findAudioTrackForPsrStream(psrStreamNumber);
2569
+ if (!track) {
2570
+ this.rollbackPrimaryAudioStream(previousPsr);
2571
+ this.report(`Primary audio stream ${psrStreamNumber} is not included in this menu package.`);
2572
+ return;
2573
+ }
2574
+ if (track.streamNumber === this.selectedAudioStream && this.audio.src.endsWith(track.path)) {
2575
+ this.confirmedPrimaryAudioStream = psrStreamNumber;
2576
+ return;
2577
+ }
2578
+ const shouldPlay = this.started && !this.video.paused;
2579
+ const logicalTime = this.video.currentTime;
2580
+ try {
2581
+ await this.applyAudioTrack(track, logicalTime, shouldPlay, generation, sequence);
2582
+ if (!this.isCurrentTrackSwitch(generation, sequence)) return;
2583
+ this.selectedAudioStream = track.streamNumber;
2584
+ this.confirmedPrimaryAudioStream = psrStreamNumber;
2585
+ this.hooks.audioTrackChanged?.(track);
2586
+ } catch (error) {
2587
+ if (!this.isCurrentTrackSwitch(generation, sequence)) return;
2588
+ this.rollbackPrimaryAudioStream(previousPsr);
2589
+ let rollbackError;
2590
+ if (previousTrack) {
2591
+ try {
2592
+ await this.applyAudioTrack(previousTrack, logicalTime, shouldPlay, generation, sequence);
2593
+ } catch (restoreError) {
2594
+ rollbackError = restoreError;
2595
+ }
2596
+ }
2597
+ if (!this.isCurrentTrackSwitch(generation, sequence)) return;
2598
+ const failure = error instanceof Error ? error.message : String(error);
2599
+ const restoreMessage = rollbackError ? ` Previous audio restore failed: ${rollbackError instanceof Error ? rollbackError.message : typeof rollbackError === "string" ? rollbackError : "non-Error value"}` : "";
2600
+ this.report(`Primary audio stream ${psrStreamNumber} could not be confirmed: ${failure}.${restoreMessage}`);
2601
+ }
2602
+ }
2603
+ async applyAudioTrack(track, logicalTime, shouldPlay, generation, sequence) {
2604
+ this.audio.pause();
2605
+ this.audio.src = track.path;
2606
+ const expectedSrc = this.audio.src;
2607
+ const metadataWait = waitAudioMetadata(this.audio, this.hooks.trackSwitchTimeoutMs ?? 15e3, expectedSrc);
2608
+ this.audio.load();
2609
+ await metadataWait;
2610
+ this.assertCurrentTrackSwitch(generation, sequence);
2611
+ const duration = track.durationSeconds ?? this.activeMedia?.background.durationSeconds;
2612
+ if (typeof duration !== "number" || !Number.isFinite(duration) || duration <= 0) throw new Error("Menu package is missing a valid generated audio duration.");
2613
+ const targetTime = logicalTime % duration;
2614
+ if (Math.abs(this.audio.currentTime - targetTime) > 1e-6) {
2615
+ const seekWait = waitAudioEvent(this.audio, "seeked", this.hooks.trackSwitchTimeoutMs ?? 15e3);
2616
+ try {
2617
+ this.audio.currentTime = targetTime;
2618
+ } catch (error) {
2619
+ seekWait.cancel();
2620
+ throw error;
2621
+ }
2622
+ await seekWait.promise;
2623
+ }
2624
+ this.assertCurrentTrackSwitch(generation, sequence);
2625
+ if (this.audio.readyState < 2) {
2626
+ await waitAudioReady(this.audio, this.hooks.trackSwitchTimeoutMs ?? 15e3);
2627
+ this.assertCurrentTrackSwitch(generation, sequence);
2628
+ }
2629
+ if (shouldPlay) {
2630
+ await this.audio.play();
2631
+ this.assertCurrentTrackSwitch(generation, sequence);
2632
+ }
2633
+ }
2634
+ isCurrentTrackSwitch(generation, sequence) {
2635
+ return this.currentGeneration === generation && this.switchSequence === sequence;
2636
+ }
2637
+ assertCurrentTrackSwitch(generation, sequence) {
2638
+ if (!this.isCurrentTrackSwitch(generation, sequence)) throw new Error("Audio selection belongs to a stale generation.");
2639
+ }
2640
+ rollbackPrimaryAudioStream(streamNumber) {
2641
+ this.confirmedPrimaryAudioStream = streamNumber;
2642
+ this.vm.registers.systemWrite(1, streamNumber);
2643
+ }
2644
+ };
2645
+ function waitAudioEvent(audio, eventName, timeoutMs) {
2646
+ let cancel = () => {
2647
+ };
2648
+ const promise = new Promise((resolvePromise, rejectPromise) => {
2649
+ let settled = false;
2650
+ const cleanup = () => {
2651
+ clearTimeout(timer);
2652
+ audio.removeEventListener(eventName, onReady);
2653
+ audio.removeEventListener("error", onFailure);
2654
+ audio.removeEventListener("abort", onFailure);
2655
+ };
2656
+ const finish = (error) => {
2657
+ if (settled) return;
2658
+ settled = true;
2659
+ cleanup();
2660
+ if (error) rejectPromise(error);
2661
+ else resolvePromise();
2662
+ };
2663
+ const onReady = () => finish();
2664
+ const onFailure = () => finish(new Error("Audio seek failed."));
2665
+ const timer = setTimeout(() => finish(new Error(`Timed out waiting for audio ${eventName}.`)), timeoutMs);
2666
+ cancel = () => finish(new Error(`Audio ${eventName} was superseded.`));
2667
+ audio.addEventListener(eventName, onReady, { once: true });
2668
+ audio.addEventListener("error", onFailure, { once: true });
2669
+ audio.addEventListener("abort", onFailure, { once: true });
2670
+ });
2671
+ return { promise, cancel: () => cancel() };
2672
+ }
2673
+ function waitAudioMetadata(audio, timeoutMs, expectedSrc) {
2674
+ return new Promise((resolvePromise, rejectPromise) => {
2675
+ let settled = false;
2676
+ const cleanup = () => {
2677
+ clearTimeout(timer);
2678
+ audio.removeEventListener("loadedmetadata", onMetadata);
2679
+ audio.removeEventListener("error", onFailure);
2680
+ audio.removeEventListener("abort", onFailure);
2681
+ };
2682
+ const finish = (error) => {
2683
+ if (settled) return;
2684
+ settled = true;
2685
+ cleanup();
2686
+ if (error) rejectPromise(error);
2687
+ else resolvePromise();
2688
+ };
2689
+ const onMetadata = () => {
2690
+ if (audio.currentSrc === expectedSrc && audio.readyState >= 1) finish();
2691
+ };
2692
+ const onFailure = () => finish(new Error(`Audio metadata failed (media error ${audio.error?.code ?? "unknown"}).`));
2693
+ const timer = setTimeout(() => finish(new Error("Timed out waiting for target audio metadata.")), timeoutMs);
2694
+ audio.addEventListener("loadedmetadata", onMetadata);
2695
+ audio.addEventListener("error", onFailure, { once: true });
2696
+ audio.addEventListener("abort", onFailure, { once: true });
2697
+ });
2698
+ }
2699
+ function waitAudioReady(audio, timeoutMs) {
2700
+ if (audio.readyState >= 2) return Promise.resolve();
2701
+ return new Promise((resolvePromise, rejectPromise) => {
2702
+ let settled = false;
2703
+ const cleanup = () => {
2704
+ clearTimeout(timer);
2705
+ audio.removeEventListener("loadeddata", onReady);
2706
+ audio.removeEventListener("canplay", onReady);
2707
+ audio.removeEventListener("error", onFailure);
2708
+ audio.removeEventListener("abort", onFailure);
2709
+ };
2710
+ const finish = (error) => {
2711
+ if (!settled) {
2712
+ settled = true;
2713
+ cleanup();
2714
+ error ? rejectPromise(error) : resolvePromise();
2715
+ }
2716
+ };
2717
+ const onReady = () => {
2718
+ if (audio.readyState >= 2) finish();
2719
+ };
2720
+ const onFailure = () => finish(new Error("Audio track could not decode the selected position."));
2721
+ const timer = setTimeout(() => finish(new Error("Timed out waiting for decoded audio data.")), timeoutMs);
2722
+ audio.addEventListener("loadeddata", onReady);
2723
+ audio.addEventListener("canplay", onReady);
2724
+ audio.addEventListener("error", onFailure);
2725
+ audio.addEventListener("abort", onFailure);
2726
+ });
2727
+ }
2728
+ function installKeyboardInput(target, controller) {
2729
+ const held = /* @__PURE__ */ new Set();
2730
+ const onDown = (event) => {
2731
+ if (held.has(event.code)) return;
2732
+ held.add(event.code);
2733
+ const mapped = { ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right", Enter: "activate", Space: "activate", Escape: "escape", KeyT: "topMenu", KeyP: "popup" };
2734
+ const kind = mapped[event.code];
2735
+ if (kind) {
2736
+ event.preventDefault();
2737
+ controller.handle(kind);
2738
+ }
2739
+ };
2740
+ const onUp = (event) => held.delete(event.code);
2741
+ const onBlur = () => held.clear();
2742
+ target.addEventListener("keydown", onDown);
2743
+ target.addEventListener("keyup", onUp);
2744
+ target.addEventListener("blur", onBlur);
2745
+ return () => {
2746
+ held.clear();
2747
+ target.removeEventListener("keydown", onDown);
2748
+ target.removeEventListener("keyup", onUp);
2749
+ target.removeEventListener("blur", onBlur);
2750
+ };
2751
+ }
2752
+ function installCanvasPointerInput(canvas, viewport, controller, cssSize) {
2753
+ const point = (event) => {
2754
+ const position = viewport.toSource(event.clientX, event.clientY, canvas.getBoundingClientRect());
2755
+ if (position) controller.selectFromPointer(position.x, position.y);
2756
+ };
2757
+ const activate = (event) => {
2758
+ const rect = canvas.getBoundingClientRect();
2759
+ const position = viewport.toSource(event.clientX, event.clientY, rect);
2760
+ if (!position) return;
2761
+ controller.activatePointer(position.x, position.y);
2762
+ };
2763
+ const resize = () => {
2764
+ const size = cssSize();
2765
+ viewport.resize(canvas, size.width, size.height, window.devicePixelRatio || 1);
2766
+ controller.hooks.render?.();
2767
+ };
2768
+ canvas.addEventListener("pointermove", point);
2769
+ canvas.addEventListener("pointerdown", activate);
2770
+ window.addEventListener("resize", resize);
2771
+ resize();
2772
+ return () => {
2773
+ canvas.removeEventListener("pointermove", point);
2774
+ canvas.removeEventListener("pointerdown", activate);
2775
+ window.removeEventListener("resize", resize);
2776
+ };
2777
+ }
2778
+
2779
+ // ../media-export/src/external-targets.ts
2780
+ function resolveExternalPlayTarget(targets, request) {
2781
+ const unavailable = (code, message) => ({ status: "unavailable", code, message });
2782
+ if (!Number.isInteger(request.playlist) || request.playlist < 0 || request.playlist > 99999) return unavailable("EXTERNAL_PLAYLIST_INVALID", "Playlist number is invalid.");
2783
+ const playlistId = String(request.playlist).padStart(5, "0");
2784
+ const playlist = targets.playlists.find((item2) => item2.id === playlistId);
2785
+ if (!playlist) return unavailable("EXTERNAL_PLAYLIST_UNKNOWN", `Playlist ${playlistId} is not mapped.`);
2786
+ const mark = request.type === "PLAY_PLAYLIST_MARK" ? playlist.marks.find((item2) => item2.index === request.playMark) : void 0;
2787
+ if (request.type === "PLAY_PLAYLIST_MARK" && !mark) return unavailable("EXTERNAL_MARK_UNKNOWN", `Playlist ${playlistId} does not contain mark ${request.playMark}.`);
2788
+ const itemIndex = request.type === "PLAY_PLAYLIST_ITEM" ? request.playItem : mark?.playItemIndex ?? 0;
2789
+ const position = playlist.items.findIndex((item2) => item2.index === itemIndex);
2790
+ if (position < 0) return unavailable("EXTERNAL_PLAYITEM_UNKNOWN", `Playlist ${playlistId} does not contain PlayItem ${itemIndex}.`);
2791
+ const item = playlist.items[position];
2792
+ const start = BigInt(mark?.sourceTime90k ?? item.sourceIn90k);
2793
+ const end = BigInt(item.sourceOut90k);
2794
+ if (end <= BigInt(item.sourceIn90k) || start < BigInt(item.sourceIn90k) || start >= end) return unavailable("EXTERNAL_TIME_INVALID", `Playlist ${playlistId} PlayItem ${itemIndex} has an invalid interval or mark.`);
2795
+ if (!item.present) return unavailable("EXTERNAL_STREAM_MISSING", `Source ${item.streamPath} is missing.`);
2796
+ const remainingItems = playlist.items.slice(position + 1);
2797
+ const sourceOrigin = item.sourceOrigin90k === null ? null : BigInt(item.sourceOrigin90k);
2798
+ if (sourceOrigin !== null && start < sourceOrigin) return unavailable("EXTERNAL_TIME_BEFORE_STC", `Playlist ${playlistId} target time precedes the selected Clip STC start time.`);
2799
+ return { status: "available", playlistId, item, clipTime90k: start.toString(), seekTime90k: sourceOrigin === null ? null : (start - sourceOrigin).toString(), remainingItems };
2800
+ }
2801
+
2802
+ // src/browser.ts
2803
+ function createMenuVm(data, onTrace) {
2804
+ if (data.profile !== void 0 && (typeof data.profile !== "object" || data.profile === null || Array.isArray(data.profile))) throw new Error("Embedded player profile must be a JSON object.");
2805
+ return new HdmvVm(data.vmPrograms ?? [], { ...onTrace ? { onTrace } : {}, ...data.vmEntries ? { entries: data.vmEntries } : {}, ...data.profile ? { profile: data.profile } : {}, ...data.limits ? { limits: data.limits } : {} });
2806
+ }
2807
+ function createStatusToast(host, timeoutMs = 4500, messageHost = host) {
2808
+ let timer;
2809
+ return (message, options = {}) => {
2810
+ messageHost.textContent = message;
2811
+ host.classList?.add("is-visible");
2812
+ host.classList?.toggle("is-error", options.error === true);
2813
+ host.classList?.toggle("is-persistent", options.persist === true);
2814
+ if (timer !== void 0) clearTimeout(timer);
2815
+ timer = void 0;
2816
+ if (options.persist) return;
2817
+ timer = setTimeout(() => {
2818
+ timer = void 0;
2819
+ host.classList?.remove("is-visible");
2820
+ }, timeoutMs);
2821
+ };
2822
+ }
2823
+ var statusToast;
2824
+ var statusCopyButton;
2825
+ var statusMessage = "";
2826
+ function showStatus(message, options) {
2827
+ const element = document.getElementById("runtime-status");
2828
+ if (!element) return;
2829
+ statusMessage = message;
2830
+ statusCopyButton?.setAttribute?.("data-copied", "false");
2831
+ const messageElement = document.getElementById("runtime-status-message");
2832
+ statusToast ?? (statusToast = createStatusToast(element, void 0, messageElement ?? element));
2833
+ statusToast(message, options);
2834
+ if (!statusCopyButton) {
2835
+ const button = document.getElementById("runtime-status-copy");
2836
+ if (button) {
2837
+ statusCopyButton = button;
2838
+ attachStatusCopy(button, () => statusMessage, () => {
2839
+ if (statusMessage) showStatus(statusMessage);
2840
+ });
2841
+ }
2842
+ }
2843
+ }
2844
+ function attachStatusCopy(button, message, afterCopy) {
2845
+ let reset;
2846
+ button.addEventListener?.("click", () => {
2847
+ const text = message();
2848
+ if (!text) return;
2849
+ void copyText(text).then((copied) => {
2850
+ if (!copied) return;
2851
+ afterCopy?.();
2852
+ button.setAttribute?.("data-copied", "true");
2853
+ if (reset !== void 0) clearTimeout(reset);
2854
+ reset = setTimeout(() => {
2855
+ reset = void 0;
2856
+ button.setAttribute?.("data-copied", "false");
2857
+ }, 1600);
2858
+ });
2859
+ });
2860
+ }
2861
+ async function copyText(text) {
2862
+ const clipboard = globalThis.navigator?.clipboard;
2863
+ if (clipboard && typeof clipboard.writeText === "function") {
2864
+ try {
2865
+ await clipboard.writeText(text);
2866
+ return true;
2867
+ } catch {
2868
+ }
2869
+ }
2870
+ if (typeof document.createElement !== "function" || typeof document.body?.appendChild !== "function") return false;
2871
+ const area = document.createElement("textarea");
2872
+ area.value = text;
2873
+ area.setAttribute("readonly", "");
2874
+ area.style.position = "fixed";
2875
+ area.style.top = "-1000px";
2876
+ area.style.opacity = "0";
2877
+ document.body.appendChild(area);
2878
+ area.select();
2879
+ try {
2880
+ return document.execCommand("copy") === true;
2881
+ } catch {
2882
+ return false;
2883
+ } finally {
2884
+ if (typeof document.body.removeChild === "function") document.body.removeChild(area);
2885
+ }
2886
+ }
2887
+ function visibleError(error) {
2888
+ const status = document.getElementById("runtime-status");
2889
+ const detail = document.getElementById("runtime-diagnostics");
2890
+ if (status) status.role = "alert";
2891
+ showStatus("Menu preview could not start.", { persist: true, error: true });
2892
+ if (detail) {
2893
+ detail.hidden = false;
2894
+ detail.textContent = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
2895
+ }
2896
+ }
2897
+ function vmConsoleLogger(context) {
2898
+ let pending = [];
2899
+ let scheduled = false;
2900
+ return (entry) => {
2901
+ if (entry.decoded?.startsWith("RESET ")) return;
2902
+ pending.push(entry);
2903
+ if (scheduled) return;
2904
+ scheduled = true;
2905
+ queueMicrotask(() => {
2906
+ scheduled = false;
2907
+ const batch = pending;
2908
+ pending = [];
2909
+ const commands = batch.filter((item) => item.decoded && !item.decoded.startsWith("MENU_CONTEXT")).map((item) => `${item.programId}:${item.pc} ${item.decoded}`);
2910
+ const actions = summarizeLabels(batch.flatMap((item) => item.action ? [item.action.type] : []));
2911
+ const events = summarizeLabels(batch.flatMap((item) => {
2912
+ const type = item.event?.type ?? item.input?.type;
2913
+ return type ? [type] : [];
2914
+ }));
2915
+ const diagnostics = summarizeLabels(batch.flatMap((item) => item.diagnostic ? [item.diagnostic.code] : []));
2916
+ const faulted = batch.some((item) => item.status === "faulted");
2917
+ const heartbeatOnly = !actions && !diagnostics && !faulted && !!events && events.split(",").every((label) => label.startsWith("TIME_UPDATED"));
2918
+ if (heartbeatOnly) return;
2919
+ if (!commands.length && !actions && !events && !diagnostics) return;
2920
+ const shownCommands = commands.length <= 6 ? commands.join(" \u2192 ") : `${commands.slice(0, 2).join(" \u2192 ")} \u2192 \u2026 ${commands.length - 4} more \u2192 ${commands.slice(-2).join(" \u2192 ")}`;
2921
+ const where = context?.();
2922
+ const fields = where ? [where] : [];
2923
+ fields.push(`${commands.length} step${commands.length === 1 ? "" : "s"}`);
2924
+ if (shownCommands) fields.push(shownCommands);
2925
+ if (actions) fields.push(`actions=${actions}`);
2926
+ if (events) fields.push(`events=${events}`);
2927
+ if (diagnostics) fields.push(`diag=${diagnostics}`);
2928
+ const status = batch.at(-1)?.status;
2929
+ if (status) fields.push(`status=${status}`);
2930
+ console.info(`[VM] ${fields.join(" | ")}`);
2931
+ });
2932
+ };
2933
+ }
2934
+ function menuLog(message) {
2935
+ console.info(`[menu] ${message}`);
2936
+ }
2937
+ function sceneLabel(scene) {
2938
+ const clip = scene.clipId ?? scene.id ?? "?";
2939
+ const pid = typeof scene.pid === "number" ? `0x${scene.pid.toString(16)}` : "?";
2940
+ return `clip ${clip} pid ${pid}`;
2941
+ }
2942
+ function backgroundLabel(path) {
2943
+ if (!path) return "none";
2944
+ const parts = path.split("/");
2945
+ return parts.length >= 2 ? parts[parts.length - 2] : path;
2946
+ }
2947
+ function summarizeLabels(values) {
2948
+ const counts = /* @__PURE__ */ new Map();
2949
+ for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1);
2950
+ return [...counts].map(([value, count]) => count === 1 ? value : `${value} x${count}`).join(",");
2951
+ }
2952
+ function fileUrlFromBdmvPath(bdmvPath, streamPath) {
2953
+ const normalize = (path) => path.replace(/\\/g, "/").split("/").map((part, index) => index === 0 && /^[A-Za-z]:$/.test(part) ? part : encodeURIComponent(part)).join("/");
2954
+ const base = bdmvPath.replace(/\\/g, "/");
2955
+ let bdmvUrl;
2956
+ if (/^[A-Za-z]:\//.test(base)) bdmvUrl = new URL(`file:///${normalize(base)}/`);
2957
+ else if (base.startsWith("/")) bdmvUrl = new URL(`file://${normalize(base)}/`);
2958
+ else bdmvUrl = new URL(`${normalize(base.replace(/\/$/, ""))}/`, location.href);
2959
+ return new URL(normalize(streamPath), bdmvUrl).href;
2960
+ }
2961
+ function format90kSeconds(ticks) {
2962
+ const value = BigInt(ticks);
2963
+ if (value < 0n) throw new RangeError("Playback offset cannot be negative.");
2964
+ const whole = value / 90000n;
2965
+ const fraction = (value % 90000n * 1000000n / 90000n).toString().padStart(6, "0").replace(/0+$/, "");
2966
+ return fraction ? `${whole}.${fraction}` : whole.toString();
2967
+ }
2968
+ function openExternalVideo(bdmvPath, streamPath, seekTime90k) {
2969
+ const videoUrl = fileUrlFromBdmvPath(bdmvPath, streamPath);
2970
+ const platform = navigator.userAgent;
2971
+ if (/Macintosh|Mac OS X/i.test(platform)) {
2972
+ if (seekTime90k === null) throw new Error("The selected chapter position is unavailable.");
2973
+ return `iina://open?url=${encodeURIComponent(videoUrl)}&mpv_start=${format90kSeconds(seekTime90k)}`;
2974
+ }
2975
+ if (/Windows/i.test(platform)) return `potplayer://${videoUrl}`;
2976
+ throw new Error("External playback is available on macOS and Windows.");
2977
+ }
2978
+ async function boot() {
2979
+ try {
2980
+ const data = globalThis.__BDMV_MENU_DATA__;
2981
+ if (!data || typeof data !== "object" || !("ir" in data)) throw new Error("Missing or invalid embedded menu data.");
2982
+ validateEmbeddedDiscIR(data.ir);
2983
+ const assetMap = /* @__PURE__ */ new Map();
2984
+ for (const batch of globalThis.__BDMV_MENU_ASSETS__ ?? []) {
2985
+ if (!Array.isArray(batch)) throw new Error("Invalid embedded image batch.");
2986
+ await Promise.all(batch.map(async (asset) => {
2987
+ if (!asset || typeof asset.id !== "string" || typeof asset.dataUrl !== "string") throw new Error("Invalid embedded image record.");
2988
+ const image = new Image();
2989
+ image.src = asset.dataUrl;
2990
+ try {
2991
+ await image.decode();
2992
+ } catch {
2993
+ throw new Error(`Could not decode embedded PNG '${asset.id}'.`);
2994
+ }
2995
+ assetMap.set(asset.id, image);
2996
+ }));
2997
+ }
2998
+ if (!data.scene) throw new Error("The package contains no supported menu scene.");
2999
+ let scene = normalizeScene(data.scene, assetMap);
3000
+ let activeMenuSceneKey = menuSceneKey(data.scene);
3001
+ const status = document.getElementById("runtime-status");
3002
+ const start = document.getElementById("start-menu");
3003
+ const muteToggle = document.getElementById("audio-mute-toggle");
3004
+ const canvas = document.getElementById("menu-canvas");
3005
+ const popupCanvas = document.getElementById("popup-canvas");
3006
+ const video = document.getElementById("menu-background");
3007
+ const audio = document.getElementById("menu-audio");
3008
+ if (!status || !start || !muteToggle || !canvas) throw new Error("The menu page is missing required runtime elements.");
3009
+ let viewport = new CanvasViewport(scene.width, scene.height);
3010
+ const logState = { scene: sceneLabel(data.scene), button: "" };
3011
+ const vm = createMenuVm(data, vmConsoleLogger(() => {
3012
+ const button = logState.button;
3013
+ logState.button = "";
3014
+ return button ? `${logState.scene} \xB7 ${button}` : logState.scene;
3015
+ }));
3016
+ if (data.vmInitialProgramId !== void 0) {
3017
+ if (!Number.isSafeInteger(data.vmInitialProgramId) || !(data.vmPrograms ?? []).some((program) => program.id === data.vmInitialProgramId)) throw new Error(`Invalid initial MovieObject ${String(data.vmInitialProgramId)}.`);
3018
+ vm.reset(data.vmInitialProgramId);
3019
+ }
3020
+ let retrySound = null;
3021
+ let retryMedia = null;
3022
+ const menuMedia = (data.menuMedia ?? []).filter((item) => item.playlist !== data.media?.playlist || (item.playItem ?? 0) !== (data.media?.playItem ?? 0));
3023
+ const playlistHasMedia = (playlist) => data.media?.playlist === playlist || menuMedia.some((item) => item.playlist === playlist);
3024
+ const packagedMenuMedia = (playlist, playItem) => {
3025
+ const candidates = [data.media, ...menuMedia].filter((item) => !!item && item.playlist === playlist);
3026
+ return candidates.find((item) => (item.playItem ?? 0) === playItem) ?? (candidates.length === 1 ? candidates[0] : void 0);
3027
+ };
3028
+ const requestedPlayItem = (playlist, action) => {
3029
+ if ((action.type === "PLAY_PLAYLIST_ITEM" || action.type === "LINK_PLAY_ITEM") && typeof action.payload.playItem === "number") return action.payload.playItem;
3030
+ if ((action.type === "PLAY_PLAYLIST_MARK" || action.type === "LINK_PLAY_MARK") && typeof action.payload.playMark === "number") {
3031
+ return data.menuPlaylistMarks?.find((item) => item.playlistId === playlist)?.marks.find((mark) => mark.index === action.payload.playMark)?.playItemRef ?? 0;
3032
+ }
3033
+ return 0;
3034
+ };
3035
+ const playbackBridge = (data.media || data.menuMedia?.length) && video && audio ? new MenuLoopPlaybackBridge(vm, video, audio, data.media, (message) => {
3036
+ showStatus(message);
3037
+ }, {
3038
+ offerRetry: (retry) => {
3039
+ retryMedia = retry;
3040
+ start.textContent = "Retry menu media";
3041
+ start.hidden = false;
3042
+ start.disabled = false;
3043
+ },
3044
+ clearRetry: () => {
3045
+ retryMedia = null;
3046
+ if (!retrySound) start.hidden = true;
3047
+ },
3048
+ resolvePlayItem: (playlist, action) => requestedPlayItem(playlist, action)
3049
+ }, menuMedia) : new PreviewPlaybackBridge(vm, (message) => {
3050
+ showStatus(message);
3051
+ });
3052
+ let controller = null;
3053
+ let popupController = null;
3054
+ let popupViewport = null;
3055
+ let removePopupPointer;
3056
+ let removeKeyboard;
3057
+ let inputStarted = false;
3058
+ let animationFrameHandle;
3059
+ let lastAnimationFrameTime;
3060
+ const animateMenus = (time) => {
3061
+ animationFrameHandle = void 0;
3062
+ const delta2 = lastAnimationFrameTime === void 0 ? 0 : Math.max(0, time - lastAnimationFrameTime);
3063
+ lastAnimationFrameTime = time;
3064
+ controller?.advanceWhileRunning(delta2);
3065
+ popupController?.advanceWhileRunning(delta2);
3066
+ if (controller?.isAnimating || popupController?.isAnimating) animationFrameHandle = requestAnimationFrame(animateMenus);
3067
+ else lastAnimationFrameTime = void 0;
3068
+ };
3069
+ const ensureAnimationLoop = () => {
3070
+ if (animationFrameHandle === void 0 && (controller?.isAnimating || popupController?.isAnimating)) animationFrameHandle = requestAnimationFrame(animateMenus);
3071
+ };
3072
+ const closePopup = () => {
3073
+ const wasOpen = popupController !== null;
3074
+ if (!wasOpen) return;
3075
+ popupController?.pause();
3076
+ popupController = null;
3077
+ popupViewport = null;
3078
+ removePopupPointer?.();
3079
+ removePopupPointer = void 0;
3080
+ if (popupCanvas) {
3081
+ popupCanvas.hidden = true;
3082
+ popupCanvas.getContext("2d")?.clearRect(0, 0, popupCanvas.width, popupCanvas.height);
3083
+ }
3084
+ if (inputStarted && controller) queueMicrotask(() => {
3085
+ removeKeyboard?.();
3086
+ removeKeyboard = installKeyboardInput(window, controller);
3087
+ });
3088
+ if (controller) {
3089
+ controller.syncVmContext();
3090
+ redraw();
3091
+ }
3092
+ };
3093
+ const pendingActions = [];
3094
+ let activeMenuPlaylist = data.media?.playlist ?? data.menuPlaylistIds?.[0] ?? null;
3095
+ const missingMenuMedia = /* @__PURE__ */ new Set();
3096
+ const activateMenuScene = (target) => {
3097
+ const targetSceneKey = menuSceneKey(target.scene);
3098
+ if (activeMenuSceneKey === targetSceneKey) return;
3099
+ closePopup();
3100
+ removePointer?.();
3101
+ scene = normalizeScene(target.scene, assetMap);
3102
+ activeMenuSceneKey = targetSceneKey;
3103
+ logState.scene = sceneLabel(target.scene);
3104
+ viewport = new CanvasViewport(scene.width, scene.height);
3105
+ controller = new MenuController(scene, vm, { render: redraw, dispatchActions, diagnostic: (message) => showStatus(message), playSound: (soundId, buttonId) => soundPlayer.play(soundId, buttonId), invalidateSound: () => {
3106
+ soundPlayer.invalidate();
3107
+ retrySound = null;
3108
+ start.hidden = true;
3109
+ }, activateButton: (pageId, button) => {
3110
+ logState.button = `button ${button.id} (page ${pageId})`;
3111
+ } }, data.inputConfig);
3112
+ const rect = canvas.getBoundingClientRect();
3113
+ viewport.resize(canvas, rect.width, rect.height, window.devicePixelRatio || 1);
3114
+ controller.start();
3115
+ if (inputStarted) {
3116
+ removeKeyboard?.();
3117
+ removePointer = installCanvasPointerInput(canvas, viewport, controller, () => {
3118
+ const size = canvas.getBoundingClientRect();
3119
+ return { width: size.width, height: size.height };
3120
+ });
3121
+ removeKeyboard = installKeyboardInput(window, controller);
3122
+ }
3123
+ redraw();
3124
+ };
3125
+ const dispatchActions = () => {
3126
+ pendingActions.push(...vm.drainActions());
3127
+ if (!controller) return { menuPlaylistRequested: false, completion: Promise.resolve() };
3128
+ const mediaActions = [];
3129
+ let menuPlaylistRequested = false;
3130
+ const source2 = popupController?.activatedButtonId != null ? popupController : controller;
3131
+ const pressed = source2?.activatedButtonId != null ? `button ${source2.activatedButtonId} (page ${source2.page.id}) ` : "";
3132
+ const report = (message) => menuLog(`${pressed}${message}`);
3133
+ while (pendingActions.length) {
3134
+ const action = pendingActions.shift();
3135
+ const inputController = popupController ?? controller;
3136
+ if (action.type === "SET_BUTTON_PAGE") {
3137
+ const packed = action.payload.packed;
3138
+ if (typeof packed === "number") {
3139
+ const before = inputController ? `${inputController.page.id}:${inputController.selectedButtonId ?? "none"}` : "";
3140
+ inputController?.applySetButtonPage(packed);
3141
+ const after = inputController ? `${inputController.page.id}:${inputController.selectedButtonId ?? "none"}` : "";
3142
+ if (before !== after && inputController) report(`SET_BUTTON_PAGE #${packed.toString(16)} \u2192 page ${inputController.page.id} button ${inputController.selectedButtonId ?? "none"}`);
3143
+ } else showStatus("VM emitted SET_BUTTON_PAGE without its packed operand.");
3144
+ } else if ((action.type === "ENABLE_BUTTON" || action.type === "DISABLE_BUTTON") && typeof action.payload.button === "number") {
3145
+ inputController?.setButtonEnabled(action.payload.button, action.type === "ENABLE_BUTTON");
3146
+ } else if (action.type === "POPUP_OFF") {
3147
+ closePopup();
3148
+ } else if (action.type === "MENU_CALL") {
3149
+ const selected = data.scene?.clipId && data.scene.pid !== void 0 ? selectPopupScene(data.popupScenes ?? [], data.scene.clipId, data.scene.pid) : { status: "not-found" };
3150
+ if (selected.status !== "selected" || !popupCanvas) {
3151
+ showStatus("No unambiguous popup IGS composition matches the active menu clip and PID.");
3152
+ } else {
3153
+ closePopup();
3154
+ const popupScene = normalizeScene(selected.scene, assetMap);
3155
+ popupViewport = new CanvasViewport(popupScene.width, popupScene.height);
3156
+ popupCanvas.hidden = false;
3157
+ const drawPopup = () => {
3158
+ if (!popupController || !popupViewport || !popupCanvas) return;
3159
+ const rect2 = popupCanvas.getBoundingClientRect();
3160
+ const context = popupCanvas.getContext("2d");
3161
+ if (context) drawMenu(context, popupScene, popupController, popupViewport, rect2.width, rect2.height, window.devicePixelRatio || 1);
3162
+ ensureAnimationLoop();
3163
+ };
3164
+ popupController = new MenuController(popupScene, vm, { render: drawPopup, dispatchActions, diagnostic: (message) => showStatus(message), playSound: (soundId, buttonId) => soundPlayer.play(soundId, buttonId), invalidateSound: () => soundPlayer.invalidate(), activateButton: (pageId, button) => {
3165
+ logState.button = `popup button ${button.id} (page ${pageId})`;
3166
+ } }, data.inputConfig);
3167
+ const rect = popupCanvas.getBoundingClientRect();
3168
+ popupViewport.resize(popupCanvas, rect.width, rect.height, window.devicePixelRatio || 1);
3169
+ removePopupPointer = installCanvasPointerInput(popupCanvas, popupViewport, popupController, () => {
3170
+ const size = popupCanvas.getBoundingClientRect();
3171
+ return { width: size.width, height: size.height };
3172
+ });
3173
+ popupController.start();
3174
+ if (inputStarted) queueMicrotask(() => {
3175
+ removeKeyboard?.();
3176
+ if (popupController) removeKeyboard = installKeyboardInput(window, popupController);
3177
+ });
3178
+ drawPopup();
3179
+ }
3180
+ } else if (action.type === "TITLE_SEARCH" && typeof action.payload.title === "number") {
3181
+ closePopup();
3182
+ const selected = vm.selectTitle(action.payload.title);
3183
+ if (selected.diagnostic) {
3184
+ showStatus(selected.diagnostic.message);
3185
+ if (vm.recoverMenuAction()) {
3186
+ controller.syncVmContext();
3187
+ pendingActions.length = 0;
3188
+ continue;
3189
+ }
3190
+ }
3191
+ pendingActions.push(...vm.drainActions());
3192
+ } else if (["PLAY_PLAYLIST", "PLAY_PLAYLIST_ITEM", "PLAY_PLAYLIST_MARK"].includes(action.type) && typeof action.payload.playlist === "number" && data.menuPlaylistIds?.includes(action.payload.playlist)) {
3193
+ const playlist = action.payload.playlist;
3194
+ const playItem = requestedPlayItem(playlist, action);
3195
+ const menuScenesForPlaylist = data.menuScenes?.filter((item) => item.playlistId === playlist) ?? [];
3196
+ const target = menuScenesForPlaylist.find((item) => (item.playItem ?? 0) === playItem) ?? menuScenesForPlaylist[0];
3197
+ if (!target) {
3198
+ report(`${action.type} playlist ${playlist} has no packaged IGS composition; released the VM wait`);
3199
+ showStatus(`Menu playlist ${playlist} has no packaged IGS composition.`);
3200
+ vm.releaseExternalPlayback(action);
3201
+ continue;
3202
+ }
3203
+ activeMenuPlaylist = playlist;
3204
+ activateMenuScene(target);
3205
+ report(`${action.type} playlist ${playlist}${playItem ? ` PlayItem ${playItem}` : ""} \u2192 ${sceneLabel(target.scene)} \xB7 background ${backgroundLabel(packagedMenuMedia(playlist, playItem)?.background.path)}`);
3206
+ if (playlistHasMedia(playlist)) mediaActions.push(Promise.resolve(playbackBridge.dispatch(action)));
3207
+ else {
3208
+ vm.handleEvent({ type: "MEDIA_STARTED", generation: action.generation, responseTo: action.id });
3209
+ mediaActions.push(Promise.resolve(playbackBridge.startMenuBackground?.() ?? false).then(() => void 0));
3210
+ }
3211
+ menuPlaylistRequested = true;
3212
+ } else if (["LINK_PLAY_ITEM", "LINK_PLAY_MARK"].includes(action.type) && activeMenuPlaylist !== null) {
3213
+ const playlist = activeMenuPlaylist;
3214
+ const playItem = requestedPlayItem(playlist, action);
3215
+ const jump2 = `${action.type === "LINK_PLAY_MARK" ? `LINK_MK ${action.payload.playMark}` : `LINK_PI ${playItem}`} \u2192 playlist ${playlist} PlayItem ${playItem}`;
3216
+ const entry = (data.menuScenes ?? []).find((item) => item.playlistId === playlist && (item.playItem ?? 0) === playItem);
3217
+ if (!entry) {
3218
+ report(`${jump2} is not packaged; stayed on ${logState.scene}`);
3219
+ showStatus(`Menu playlist ${playlist} PlayItem ${playItem} is not packaged; the menu stayed on the current page.`, { persist: true });
3220
+ continue;
3221
+ }
3222
+ activateMenuScene(entry);
3223
+ const shown = playbackBridge.showMenuItem?.(playlist, playItem);
3224
+ if (!shown) {
3225
+ report(`${jump2} \u2192 ${sceneLabel(entry.scene)} \xB7 no packaged background, the current one keeps playing`);
3226
+ if (!missingMenuMedia.has(`${playlist}:${playItem}`)) {
3227
+ missingMenuMedia.add(`${playlist}:${playItem}`);
3228
+ showStatus(`Menu PlayItem ${playItem} has no packaged background; the current one keeps playing.`);
3229
+ }
3230
+ continue;
3231
+ }
3232
+ report(`${jump2} \u2192 ${sceneLabel(entry.scene)} \xB7 background ${backgroundLabel(packagedMenuMedia(playlist, playItem)?.background.path)}`);
3233
+ mediaActions.push(shown.then((ok) => {
3234
+ if (!ok) showStatus(`Menu PlayItem ${playItem} background could not start.`);
3235
+ }));
3236
+ } else if (["PLAY_PLAYLIST", "PLAY_PLAYLIST_ITEM", "PLAY_PLAYLIST_MARK"].includes(action.type) && typeof action.payload.playlist === "number" && data.preMenuPlaylistIds?.includes(action.payload.playlist)) {
3237
+ report(`${action.type} playlist ${action.payload.playlist} \u2192 intro completed in the VM`);
3238
+ vm.handleEvent({ type: "MEDIA_ENDED", generation: action.generation, responseTo: action.id });
3239
+ pendingActions.push(...vm.drainActions());
3240
+ continue;
3241
+ } else if (data.externalTargets && ["PLAY_PLAYLIST", "PLAY_PLAYLIST_ITEM", "PLAY_PLAYLIST_MARK"].includes(action.type) && !(action.type === "PLAY_PLAYLIST" && typeof action.payload.playlist === "number" && playlistHasMedia(action.payload.playlist))) {
3242
+ closePopup();
3243
+ const result = resolveExternalPlayTarget(data.externalTargets, { type: action.type, ...action.payload });
3244
+ if (result.status === "unavailable") {
3245
+ report(`${action.type} ${action.payload.playlist ?? action.payload.playMark ?? "?"} has no external target: ${result.message}`);
3246
+ showStatus(result.message, { persist: true });
3247
+ vm.releaseExternalPlayback(action);
3248
+ continue;
3249
+ }
3250
+ const bdmvPath = globalThis.__BDMV_BDMV_PATH__;
3251
+ if (!bdmvPath) {
3252
+ showStatus("The package is missing runtime/bdmv-config.js; set the BDMV path and reload.", { persist: true, error: true });
3253
+ vm.releaseExternalPlayback(action);
3254
+ continue;
3255
+ }
3256
+ try {
3257
+ const protocolUrl = openExternalVideo(bdmvPath, result.item.streamPath, result.seekTime90k);
3258
+ setMenuMuted(true);
3259
+ report(`${action.type} ${action.payload.playlist ?? action.payload.playMark ?? "?"} \u2192 external player ${result.item.streamPath}${typeof result.seekTime90k === "number" ? ` at ${format90kSeconds(String(result.seekTime90k))}s` : ""}`);
3260
+ showStatus(`Requested ${result.item.streamPath} in the external player.`);
3261
+ location.href = protocolUrl;
3262
+ } catch (error) {
3263
+ showStatus(error instanceof Error ? error.message : String(error), { persist: true, error: true });
3264
+ }
3265
+ vm.releaseExternalPlayback(action);
3266
+ } else {
3267
+ if (["PLAY_PLAYLIST", "PLAY_PLAYLIST_ITEM", "PLAY_PLAYLIST_MARK", "TOP_MENU", "MENU_RETURN"].includes(action.type)) closePopup();
3268
+ if (["LINK_PLAY_ITEM", "LINK_PLAY_MARK"].includes(action.type)) report(`${action.type} ${action.payload.playItem ?? action.payload.playMark ?? "?"} \u2192 no packaged menu playlist is active; handed to the fallback bridge`);
3269
+ if (action.type === "PLAY_PLAYLIST" && action.payload.playlist === data.media?.playlist) menuPlaylistRequested = true;
3270
+ mediaActions.push(Promise.resolve(playbackBridge.dispatch(action)));
3271
+ }
3272
+ }
3273
+ return { menuPlaylistRequested, completion: Promise.all(mediaActions).then(() => void 0) };
3274
+ };
3275
+ const soundPlayer = new MenuSoundPlayer(data.sounds ?? {}, (url) => new Audio(url), {
3276
+ report: (kind, message) => {
3277
+ showStatus(message);
3278
+ if (kind === "blocked") {
3279
+ start.textContent = "Retry sound";
3280
+ start.hidden = false;
3281
+ start.disabled = false;
3282
+ } else if (kind === "unsupported" || kind === "asset") {
3283
+ start.hidden = true;
3284
+ retrySound = null;
3285
+ }
3286
+ },
3287
+ offerRetry: (retry) => {
3288
+ retrySound = retry;
3289
+ start.textContent = "Retry sound";
3290
+ start.hidden = false;
3291
+ start.disabled = false;
3292
+ },
3293
+ isCurrent: (_soundId, buttonId) => !!controller && controller.button?.id === buttonId && controller.activatedButtonId === buttonId
3294
+ });
3295
+ let menuMuted = false;
3296
+ let manualMuted = false;
3297
+ let removeAutoMuteRestore = null;
3298
+ let armAutoMuteTimer;
3299
+ const applyMenuMuted = (muted) => {
3300
+ menuMuted = muted;
3301
+ if (video) video.muted = muted;
3302
+ if (audio) audio.muted = muted;
3303
+ soundPlayer.setMuted(muted);
3304
+ muteToggle.setAttribute("aria-pressed", String(muted));
3305
+ muteToggle.setAttribute("aria-label", muted ? "Unmute menu audio" : "Mute menu audio");
3306
+ muteToggle.title = muted ? "Unmute menu audio" : "Mute menu audio";
3307
+ };
3308
+ const disarmAutoMuteRestore = () => {
3309
+ if (armAutoMuteTimer !== void 0) clearTimeout(armAutoMuteTimer);
3310
+ armAutoMuteTimer = void 0;
3311
+ removeAutoMuteRestore?.();
3312
+ removeAutoMuteRestore = null;
3313
+ };
3314
+ const isMuteControl = (event) => {
3315
+ const target = event.target;
3316
+ if (target === muteToggle) return true;
3317
+ return !!target && typeof target.closest === "function" && target.closest("#audio-mute-toggle") !== null;
3318
+ };
3319
+ const restoreAutoMutedMenu = (event) => {
3320
+ if (manualMuted || !menuMuted || isMuteControl(event)) return;
3321
+ applyMenuMuted(false);
3322
+ disarmAutoMuteRestore();
3323
+ };
3324
+ const armAutoMuteRestore = () => {
3325
+ if (removeAutoMuteRestore) return;
3326
+ window.addEventListener("keydown", restoreAutoMutedMenu, true);
3327
+ window.addEventListener("pointerdown", restoreAutoMutedMenu, true);
3328
+ window.addEventListener("click", restoreAutoMutedMenu, true);
3329
+ removeAutoMuteRestore = () => {
3330
+ window.removeEventListener("keydown", restoreAutoMutedMenu, true);
3331
+ window.removeEventListener("pointerdown", restoreAutoMutedMenu, true);
3332
+ window.removeEventListener("click", restoreAutoMutedMenu, true);
3333
+ };
3334
+ };
3335
+ const setMenuMuted = (muted, manual = false) => {
3336
+ if (manual) {
3337
+ manualMuted = muted;
3338
+ disarmAutoMuteRestore();
3339
+ }
3340
+ applyMenuMuted(muted);
3341
+ if (muted && !manual && !manualMuted) {
3342
+ if (armAutoMuteTimer !== void 0) clearTimeout(armAutoMuteTimer);
3343
+ armAutoMuteTimer = setTimeout(() => {
3344
+ armAutoMuteTimer = void 0;
3345
+ if (menuMuted && !manualMuted) armAutoMuteRestore();
3346
+ }, 0);
3347
+ } else if (!muted) disarmAutoMuteRestore();
3348
+ };
3349
+ muteToggle.addEventListener("click", () => setMenuMuted(!menuMuted, true));
3350
+ const redraw = () => {
3351
+ if (!controller) return;
3352
+ const rect = canvas.getBoundingClientRect();
3353
+ const context = canvas.getContext("2d");
3354
+ if (context) drawMenu(context, scene, controller, viewport, rect.width, rect.height, window.devicePixelRatio || 1);
3355
+ ensureAnimationLoop();
3356
+ };
3357
+ controller = new MenuController(scene, vm, { render: redraw, dispatchActions, diagnostic: (message) => {
3358
+ showStatus(message);
3359
+ }, playSound: (soundId, buttonId) => soundPlayer.play(soundId, buttonId), invalidateSound: () => {
3360
+ soundPlayer.invalidate();
3361
+ retrySound = null;
3362
+ start.hidden = true;
3363
+ }, activateButton: (pageId, button) => {
3364
+ logState.button = `button ${button.id} (page ${pageId})`;
3365
+ } }, data.inputConfig);
3366
+ start.hidden = false;
3367
+ start.disabled = false;
3368
+ showStatus("Images decoded. Select Start menu preview to enable input.");
3369
+ let removePointer;
3370
+ start.addEventListener("click", async () => {
3371
+ if (retrySound) {
3372
+ const retry = retrySound;
3373
+ retrySound = null;
3374
+ start.disabled = true;
3375
+ start.hidden = true;
3376
+ retry();
3377
+ return;
3378
+ }
3379
+ if (retryMedia) {
3380
+ const retry = retryMedia;
3381
+ retryMedia = null;
3382
+ start.disabled = true;
3383
+ start.hidden = true;
3384
+ retry();
3385
+ return;
3386
+ }
3387
+ if (inputStarted) return;
3388
+ inputStarted = true;
3389
+ start.disabled = true;
3390
+ start.hidden = true;
3391
+ let menuMediaStarted;
3392
+ let pending = { menuPlaylistRequested: false, completion: Promise.resolve() };
3393
+ if (data.vmInitialProgramId !== void 0 && (data.media || data.menuMedia?.length) && video && audio) {
3394
+ if (data.vmFirstPlayProgramId !== void 0 && data.vmEntries?.some((entry) => entry.kind === "topMenu" && entry.target.objectType === "HDMV")) {
3395
+ vm.reset(data.vmFirstPlayProgramId);
3396
+ const initial = vm.runTurn();
3397
+ if (initial.status === "faulted") {
3398
+ showStatus(initial.diagnostic?.message ?? "First Play initialization failed.");
3399
+ return;
3400
+ }
3401
+ if (initial.waiting) vm.releaseExternalPlayback(initial.waiting);
3402
+ vm.drainActions();
3403
+ vm.selectTitle(0);
3404
+ } else vm.runTurn();
3405
+ pending = dispatchActions();
3406
+ }
3407
+ if (!pending.menuPlaylistRequested && playbackBridge.startMenuBackground && !["faulted", "stopped"].includes(vm.snapshot().status)) {
3408
+ const backgroundStart = playbackBridge.startMenuBackground();
3409
+ const [, started] = await Promise.all([pending.completion, backgroundStart]);
3410
+ menuMediaStarted = started;
3411
+ } else {
3412
+ await pending.completion;
3413
+ }
3414
+ if (retryMedia) return;
3415
+ const vmStatus = vm.snapshot().status;
3416
+ if (vmStatus === "faulted" || vmStatus === "stopped" || vmStatus === "suspended" && !pending.menuPlaylistRequested) return;
3417
+ if (menuMediaStarted !== false) showStatus("Menu preview ready. Use arrow keys and Enter, or select a visible button.");
3418
+ const initialRect = canvas.getBoundingClientRect();
3419
+ viewport.resize(canvas, initialRect.width, initialRect.height, window.devicePixelRatio || 1);
3420
+ controller.start();
3421
+ void dispatchActions().completion;
3422
+ removeKeyboard?.();
3423
+ removePointer?.();
3424
+ removeKeyboard = installKeyboardInput(window, popupController ?? controller);
3425
+ removePointer = installCanvasPointerInput(canvas, viewport, controller, () => {
3426
+ const rect = canvas.getBoundingClientRect();
3427
+ return { width: rect.width, height: rect.height };
3428
+ });
3429
+ redraw();
3430
+ });
3431
+ window.addEventListener("pagehide", () => {
3432
+ removeKeyboard?.();
3433
+ removePointer?.();
3434
+ disarmAutoMuteRestore();
3435
+ }, { once: true });
3436
+ } catch (error) {
3437
+ visibleError(error);
3438
+ }
3439
+ }
3440
+ function validateEmbeddedDiscIR(value) {
3441
+ if (!isRecord(value)) throw new Error("Embedded DiscIR must be a JSON object.");
3442
+ if (value.schemaVersion !== "1.0") throw new Error(`Unsupported DiscIR schema version '${typeof value.schemaVersion === "string" ? value.schemaVersion : "missing"}'. Expected 1.0.`);
3443
+ for (const key of ["entries", "programs", "menus", "playlists", "mediaAssets", "diagnostics"]) if (!Array.isArray(value[key])) throw new Error(`Embedded DiscIR is invalid: '${key}' must be an array.`);
3444
+ }
3445
+ function isRecord(value) {
3446
+ return typeof value === "object" && value !== null && !Array.isArray(value);
3447
+ }
3448
+ function normalizeScene(source2, assets) {
3449
+ if (!source2 || !Number.isSafeInteger(source2.width) || source2.width <= 0 || !Number.isSafeInteger(source2.height) || source2.height <= 0 || !Array.isArray(source2.pages) || source2.pages.length === 0) throw new Error("Invalid menu scene dimensions or pages.");
3450
+ return {
3451
+ width: source2.width,
3452
+ height: source2.height,
3453
+ ...source2.frameRateCode !== void 0 ? { frameRateCode: source2.frameRateCode } : {},
3454
+ compositionTimeout90k: source2.compositionTimeout90k,
3455
+ selectionTimeout90k: source2.selectionTimeout90k,
3456
+ pages: source2.pages.map((page) => ({
3457
+ id: page.id,
3458
+ uoMask: page.uoMask,
3459
+ defaultButtonId: page.defaultButtonId,
3460
+ ...page.defaultActivatedButtonId !== void 0 ? { defaultActivatedButtonId: page.defaultActivatedButtonId } : {},
3461
+ ...page.bogDefaults ? { bogDefaults: page.bogDefaults } : {},
3462
+ animationFrameRateCode: page.animationFrameRateCode,
3463
+ ...page.objects ? { objects: new Map(page.objects.flatMap((object) => {
3464
+ const image = assets.get(object.assetId);
3465
+ return image ? [[object.id, { image, width: object.width, height: object.height }]] : [];
3466
+ })) } : {},
3467
+ ...page.effects ? { effects: {
3468
+ enter: normalizeEffects(page.effects.enter, assets),
3469
+ exit: normalizeEffects(page.effects.exit, assets)
3470
+ } } : {},
3471
+ ...page.allowedActions ? { allowedActions: page.allowedActions } : {},
3472
+ buttons: page.buttons.map((button) => ({
3473
+ id: button.id,
3474
+ bog: button.bog ?? 0,
3475
+ enabled: button.enabled ?? true,
3476
+ ...button.autoAction !== void 0 ? { autoAction: button.autoAction } : {},
3477
+ neighbors: button.neighbors,
3478
+ states: {
3479
+ ...button.states.normal ? { normal: normalizeState(button.states.normal, assets) } : {},
3480
+ ...button.states.selected ? { selected: normalizeState(button.states.selected, assets) } : {},
3481
+ ...button.states.activated ? { activated: normalizeState(button.states.activated, assets) } : {}
3482
+ },
3483
+ commands: button.commands ?? []
3484
+ }))
3485
+ }))
3486
+ };
3487
+ }
3488
+ function menuSceneKey(scene) {
3489
+ return `${scene.clipId ?? scene.id ?? ""}:${scene.pid ?? ""}`;
3490
+ }
3491
+ function normalizeEffects(effects, assets) {
3492
+ return effects.map((effect) => ({
3493
+ duration90k: effect.duration90k,
3494
+ objects: effect.objects.flatMap((object) => {
3495
+ const image = assets.get(object.assetId);
3496
+ return image ? [{ ...object, image }] : [];
3497
+ })
3498
+ }));
3499
+ }
3500
+ function normalizeState(state, assets) {
3501
+ const { x, y, width, height } = state.bounds;
3502
+ if (![x, y, width, height].every(Number.isFinite) || width <= 0 || height <= 0) throw new Error("Invalid button state bounds.");
3503
+ const image = state.assetId === void 0 ? void 0 : assets.get(state.assetId);
3504
+ if (state.assetId !== void 0 && !image) throw new Error(`Button state references missing image '${state.assetId}'.`);
3505
+ return {
3506
+ bounds: { x, y, width, height },
3507
+ ...image ? { image } : {},
3508
+ ...state.soundId !== void 0 ? { soundId: state.soundId } : {},
3509
+ startObjectId: state.startObjectId,
3510
+ endObjectId: state.endObjectId,
3511
+ ...state.repeat !== void 0 ? { repeat: state.repeat } : {}
3512
+ };
3513
+ }
3514
+ if (typeof document !== "undefined") void boot();
3515
+ })();