Repository navigation
Expand file tree
/
Copy pathscript.ts
More file actions
482 lines (435 loc) · 24.3 KB
/
Copy pathscript.ts
File metadata and controls
482 lines (435 loc) · 24.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
/**
* The script as part of the map: its files and build manifest live as extra archive
* members under `trigscript\` (`api.document.extras`), and the triggers it generated
* are a contiguous *block* of the trigger list that the manifest points at.
*
* The block is identified by content, not just position: the manifest records the
* block's start, length and a hash of its encoded records. `findBlock` looks for the
* records at the recorded start and, failing that, anywhere in the list — so a hand
* trigger inserted before the block in the Trigger Editor moves the block without
* breaking it (`relocateManifest` then rewrites the manifest). Only an edit *inside*
* the block makes it stale. The manifest also hashes every record on its own, so a
* stale block can still be told apart record by record: the next Apply can replace the
* records that are still the build's and keep only the edited ones as hand triggers
* (`replaceStale`), or append a fresh block and leave them all.
*
* Everything here is pure over a trigger list and a `Map` of the members: the plugin
* reads the map's extras into that shape and writes the result back (see `service.ts`),
* the tests hand lists and maps in directly.
*/
import { cloneTrigger, encodeTriggers, type TriggerRecord } from "./vendor/triggers";
import { ENTRY_FILE, normalizePath, type CompileResult, type ScriptFiles, type TriggerSource } from "./compiler/compiler";
import { HEAP_CELLS, STACK_DEPTH, heapCells, stackDepth } from "./compiler/ir";
import { LIBRARY_ID, LIBRARY_PATH, libraryPath, type ScriptLibraries } from "./compiler/libraries";
/** The archive folder the script's files live in, next to `staredit\`. */
export const SCRIPT_FOLDER = "trigscript\\";
export const MANIFEST_MEMBER = `${SCRIPT_FOLDER}build.json`;
/** The entry file's member name. */
export const ENTRY_MEMBER = `${SCRIPT_FOLDER}${ENTRY_FILE}`;
export type Extras = ReadonlyMap<string, Uint8Array>;
/**
* What the map's author chose about how its programs are built, kept with the script (`trigscript\\settings.json`) and
* not with the editor: the built map depends on it, so it has to be the same on every computer the map is saved on.
* A member that is absent, or a field that is, means the default.
*/
export const SETTINGS_MEMBER = `${SCRIPT_FOLDER}settings.json`;
export interface ScriptSettings {
/** The cells of the heap the programs' growing arrays share (`compiler/ir.ts#HEAP_CELLS`). */
heapCells: number;
/** How many calls deep a function that calls itself may go (`compiler/ir.ts#STACK_DEPTH`). */
stackDepth: number;
/** A failing `test()` refuses the build: Save, Test Map and an export say so and write the map as it stands. */
testsGuardBuild: boolean;
}
export const DEFAULT_SETTINGS: ScriptSettings = { heapCells: HEAP_CELLS, stackDepth: STACK_DEPTH, testsGuardBuild: false };
export function readSettings(extras: Extras): ScriptSettings {
const bytes = member(extras, SETTINGS_MEMBER);
if (!bytes) return { ...DEFAULT_SETTINGS };
try {
const raw = JSON.parse(decoder.decode(bytes)) as Partial<ScriptSettings> | null;
return { heapCells: heapCells(raw?.heapCells), stackDepth: stackDepth(raw?.stackDepth), testsGuardBuild: raw?.testsGuardBuild === true };
} catch {
return { ...DEFAULT_SETTINGS };
}
}
/** The members with these settings; settings that are all the default leave no member behind. */
export function withSettings(extras: Extras, settings: Partial<ScriptSettings>): Map<string, Uint8Array> {
// Only what differs from the default is written, so a map keeps following a default that changes.
const chosen: Partial<ScriptSettings> = {};
const heap = heapCells(settings.heapCells);
const depth = stackDepth(settings.stackDepth);
if (heap !== DEFAULT_SETTINGS.heapCells) chosen.heapCells = heap;
if (depth !== DEFAULT_SETTINGS.stackDepth) chosen.stackDepth = depth;
if (settings.testsGuardBuild === true) chosen.testsGuardBuild = true;
return withMember(extras, SETTINGS_MEMBER, Object.keys(chosen).length ? encoder.encode(JSON.stringify(chosen, null, 2)) : null);
}
export interface ScriptManifest {
version: 2;
/**
* How many `program()`s the script had when it was last applied: what tells the build step,
* without compiling, whether the map has anything to build. Absent in a manifest from
* before 3.0, whose programs were triggers of the block.
*/
programs?: number;
/** Index of the first generated trigger. */
start: number;
count: number;
/** `hashTriggers` of the generated records. */
hash: string;
/** Per generated trigger, where it came from; null for a hyper trigger. */
sources: (TriggerSource | null)[];
/** Every file the block was built from, by path — so a protected map with no listfile still gives them up by name. */
files: string[];
/** `hashFiles` of the files the block was built from. */
sourceHash: string;
/** `hashRecord` of each generated record, so an edited block can be taken apart; absent in a manifest from before 2.4.1. */
records?: string[];
}
export interface ScriptBlock {
start: number;
count: number;
sources: (TriggerSource | null)[];
}
const decoder = new TextDecoder();
const encoder = new TextEncoder();
/** Member names compare without regard to case or slash direction, as the archive does. */
export const memberKey = (name: string) => name.replace(/\//g, "\\").toLowerCase();
/** Whether a member name is one of the script's: anything under its folder. */
export function isScriptMember(name: string): boolean {
return memberKey(name).startsWith(memberKey(SCRIPT_FOLDER));
}
/** A file a build wrote for a clip the script plays (`compiler/sound.ts`): `staredit\wav\trigscript-<hash>.wav`, or `.ogg` (8 hex digits before 3.13, 16 since). */
export const isBuiltSound = (name: string): boolean => /^staredit\\wav\\trigscript-(?:[0-9a-f]{8}|[0-9a-f]{16})\.(wav|ogg)$/.test(memberKey(name));
/**
* The members with the clips of this build in place of the last one's. A clip already in the map keeps its bytes as
* they are: the name says what it holds. One that is not, and has no bytes — an Ogg Vorbis clip from a check, which
* leaves it unencoded — is refused: the map would play nothing.
*/
export function withSounds(extras: Extras, sounds: readonly { path: string; bytes?: Uint8Array }[]): Map<string, Uint8Array> {
const next = new Map<string, Uint8Array>();
for (const [name, bytes] of extras) if (!isBuiltSound(name)) next.set(name, bytes);
for (const s of sounds) {
const bytes = [...extras].find(([name]) => memberKey(name) === memberKey(s.path))?.[1] ?? s.bytes;
if (!bytes) throw new Error(`The sound ${s.path} was not encoded: compile it as a build (with its sounds) before installing it.`);
next.set(s.path, bytes);
}
return next;
}
/** The member name of a script file (`ai/waves.ts` → `trigscript\ai\waves.ts`). */
export function memberOf(path: string): string {
return `${SCRIPT_FOLDER}${normalizePath(path).replace(/\//g, "\\")}`;
}
/** The script path of a member under the folder, or null for anything else (the manifest and the libraries' copies included). */
export function pathOf(member: string): string | null {
if (!isScriptMember(member) || isLibraryMember(member)) return null;
const rest = member.replace(/\//g, "\\").slice(SCRIPT_FOLDER.length);
return SCRIPT_FILE.test(rest) ? rest.replace(/\\/g, "/") : null;
}
/** What a file of the script ends in: TypeScript, TypeScript with JSX, or a video, an image or a sound it imports (`compiler/media.ts`). */
const SCRIPT_FILE = /\.(?:tsx?|video|image|sound|notes)$/i;
/** A file name a script may use: letters, digits, `_`, `-`, `.`, folders with `/`, ending in `.ts` (or `.tsx`, `.video`, `.image`, `.sound`, `.notes`). */
export const FILE_NAME = /^(?:[A-Za-z0-9_\-.]+\/)*[A-Za-z0-9_\-.]+\.(?:tsx?|video|image|sound|notes)$/;
function member(extras: Extras, name: string): Uint8Array | undefined {
const key = memberKey(name);
for (const [k, v] of extras) if (memberKey(k) === key) return v;
return undefined;
}
function withMember(extras: Extras, name: string, data: Uint8Array | null): Map<string, Uint8Array> {
const next = new Map(extras);
const key = memberKey(name);
for (const k of next.keys()) if (memberKey(k) === key) next.delete(k);
if (data) next.set(name, data);
return next;
}
/** The script's files, by path; empty when the map has none. */
export function readFiles(extras: Extras): ScriptFiles {
const out: ScriptFiles = {};
for (const [name, bytes] of extras) {
const path = pathOf(name);
if (path) out[path] = decoder.decode(bytes);
}
return out;
}
/**
* Where the map keeps its copy of each library of another plugin's the script imports: `trigscript\@libraries\<id>\`,
* the modules at their paths and `library.json` saying which library it is. No file of the script's can be there: "@"
* is not in a file name. The copy is what the map builds with, wherever it is opened — the plugin that provides the
* library is only where a copy comes from, and a newer one (`ScriptService.updateLibraries`).
*/
export const LIBRARIES_FOLDER = `${SCRIPT_FOLDER}@libraries\\`;
const LIBRARY_INFO = "library.json";
/** A library as the map keeps it: which one, the version it was copied at, and its modules by path. */
export interface LibraryCopy {
id: string;
/** What the editor calls it ("Stamp Library"). */
label: string;
version: string;
modules: Record<string, string>;
}
export const isLibraryMember = (name: string): boolean => memberKey(name).startsWith(memberKey(LIBRARIES_FOLDER));
/** The libraries the map keeps copies of, by id; a folder with no module in it, or an id no library has, is passed over. */
export function readLibraries(extras: Extras): Record<string, LibraryCopy> {
const out: Record<string, LibraryCopy> = {};
for (const [name, bytes] of extras) {
if (!isLibraryMember(name)) continue;
const [id, ...rest] = name.replace(/\//g, "\\").slice(LIBRARIES_FOLDER.length).split("\\");
if (!LIBRARY_ID.test(id) || rest.length === 0) continue;
const path = rest.join("/");
const copy = (out[id] ??= { id, label: id, version: "", modules: {} });
if (path === LIBRARY_INFO) {
try {
const info = JSON.parse(decoder.decode(bytes)) as { label?: unknown; version?: unknown } | null;
if (typeof info?.label === "string" && info.label) copy.label = info.label;
if (typeof info?.version === "string") copy.version = info.version;
} catch { /* A broken library.json leaves the id for its name. */ }
} else if (LIBRARY_PATH.test(path)) copy.modules[path] = decoder.decode(bytes);
}
for (const id of Object.keys(out)) if (Object.keys(out[id].modules).length === 0) delete out[id];
return out;
}
/** The members with the map's copies of the libraries replaced by these: a library not among them is removed. A member whose text is the same keeps its bytes. */
export function withLibraries(extras: Extras, libraries: Readonly<Record<string, LibraryCopy>>): Map<string, Uint8Array> {
const next = new Map<string, Uint8Array>();
const was = new Map<string, Uint8Array>();
for (const [name, bytes] of extras) (isLibraryMember(name) ? was.set(memberKey(name), bytes) : next.set(name, bytes));
const put = (name: string, text: string) => {
const old = was.get(memberKey(name));
next.set(name, old && decoder.decode(old) === text ? old : encoder.encode(text));
};
for (const lib of Object.values(libraries)) {
const folder = `${LIBRARIES_FOLDER}${lib.id}\\`;
put(`${folder}${LIBRARY_INFO}`, JSON.stringify({ label: lib.label, version: lib.version }, null, 2));
for (const [path, text] of Object.entries(lib.modules)) put(`${folder}${path.replace(/\//g, "\\")}`, text);
}
return next;
}
/** The copies as the compiler takes them: each library's modules by id. */
export function librarySources(libraries: Readonly<Record<string, LibraryCopy>>): ScriptLibraries {
return Object.fromEntries(Object.values(libraries).map((l) => [l.id, l.modules]));
}
/** The members with the script's files replaced by these (a file not in `files` is removed). */
export function withFiles(extras: Extras, files: ScriptFiles): Map<string, Uint8Array> {
const next = new Map<string, Uint8Array>();
for (const [name, bytes] of extras) if (!pathOf(name)) next.set(name, bytes);
for (const [path, text] of Object.entries(files)) next.set(memberOf(path), encoder.encode(text));
return next;
}
export function readManifest(extras: Extras): ScriptManifest | null {
const bytes = member(extras, MANIFEST_MEMBER);
if (!bytes) return null;
try {
const m = JSON.parse(decoder.decode(bytes)) as Partial<ScriptManifest>;
if (m.version !== 2 || typeof m.start !== "number" || typeof m.count !== "number" || typeof m.hash !== "string") return null;
const programs = typeof m.programs === "number" && m.programs >= 0 ? { programs: Math.floor(m.programs) } : {};
const sources = Array.isArray(m.sources) ? m.sources.map((s) => (s && typeof s === "object" && typeof s.file === "string" && typeof s.line === "number" ? { file: s.file, line: s.line } : null)) : [];
const files = Array.isArray(m.files) ? m.files.filter((f): f is string => typeof f === "string") : [];
const records = Array.isArray(m.records) && m.records.length === m.count && m.records.every((r) => typeof r === "string") ? m.records : undefined;
return { version: 2, ...programs, start: m.start, count: m.count, hash: m.hash, sources, files, sourceHash: typeof m.sourceHash === "string" ? m.sourceHash : "", ...(records ? { records } : {}) };
} catch {
return null;
}
}
export function withManifest(extras: Extras, manifest: ScriptManifest | null): Map<string, Uint8Array> {
return withMember(extras, MANIFEST_MEMBER, manifest ? encoder.encode(JSON.stringify(manifest)) : null);
}
function fnv1a(bytes: Uint8Array): string {
let h = 0x811c9dc5;
for (let i = 0; i < bytes.length; i++) {
h ^= bytes[i];
h = Math.imul(h, 0x01000193) >>> 0;
}
return h.toString(16).padStart(8, "0");
}
/** FNV-1a over the encoded records, prefixed with the count. */
export function hashTriggers(list: TriggerRecord[]): string {
return `${list.length}:${fnv1a(encodeTriggers(list))}`;
}
/** FNV-1a over one encoded record. */
export function hashRecord(t: TriggerRecord): string {
return fnv1a(encodeTriggers([t]));
}
export function hashText(text: string): string {
return fnv1a(encoder.encode(text));
}
/** One hash over every file, by path, so a change to any of them counts. */
export function hashFiles(files: ScriptFiles): string {
const paths = Object.keys(files).map(normalizePath).sort();
return hashText(paths.map((p) => `${p}\0${files[p] ?? ""}\0`).join(""));
}
/**
* `hashFiles` over the files and the libraries they were compiled with, so that a library updated in the map counts as
* a change of the script's. With no libraries it is `hashFiles` itself: a manifest from before libraries still matches.
*/
export function hashSources(files: ScriptFiles, libraries: Readonly<Record<string, LibraryCopy>> = {}): string {
const libs = Object.values(libraries);
if (libs.length === 0) return hashFiles(files);
const all: ScriptFiles = { ...files };
for (const lib of libs) for (const [path, text] of Object.entries(lib.modules)) all[libraryPath(lib.id, path)] = text;
return hashFiles(all);
}
/** Where the manifest's block actually is in the list, or null when its records are gone. */
export function findBlock(list: TriggerRecord[], manifest: ScriptManifest): ScriptBlock | null {
const { start, count } = manifest;
const at = (s: number) => s >= 0 && s + count <= list.length && hashTriggers(list.slice(s, s + count)) === manifest.hash;
if (at(start)) return { start, count, sources: manifest.sources };
if (count === 0) return { start: Math.min(start, list.length), count, sources: manifest.sources };
for (let s = 0; s + count <= list.length; s++) if (s !== start && at(s)) return { start: s, count, sources: manifest.sources };
return null;
}
/** A stale block taken apart: the indices at the manifest's start whose records are still the build's, and those that were edited. */
export interface StaleRecords {
unchanged: number[];
changed: number[];
}
/**
* When the block is stale, which of the records at the manifest's start are still the
* build's own, by their individual hashes. Null when the manifest has none (an older
* build), or none of them is still there (the block was removed or moved, not edited).
*/
export function staleRecords(list: TriggerRecord[], manifest: ScriptManifest): StaleRecords | null {
if (!manifest.records) return null;
const out: StaleRecords = { unchanged: [], changed: [] };
for (let i = 0; i < manifest.count; i++) {
const at = manifest.start + i;
if (at >= list.length) break;
(hashRecord(list[at]) === manifest.records[i] ? out.unchanged : out.changed).push(at);
}
return out.unchanged.length ? out : null;
}
export interface ScriptState {
/** The script's files; null when the map has none. */
files: ScriptFiles | null;
/** The entry file's text, for callers that deal in one file; null when there is none. */
source: string | null;
manifest: ScriptManifest | null;
/** The generated block, when the manifest's records are still in the list. */
block: ScriptBlock | null;
/** A manifest exists but its records were edited or removed. */
stale: boolean;
/** A stale block the next build can take apart: how many records are still the build's, how many were edited. Null when it cannot. */
edited: { unchanged: number; changed: number } | null;
/** The files differ from what the block was built from (or were never built). */
unbuilt: boolean;
/** The script had programs when it was last applied: the saved map is built with them and needs StarCraft: Remastered. */
programs: number;
/** The libraries of other plugins' the map keeps a copy of: what the script builds with. */
libraries: { id: string; label: string; version: string }[];
}
export function scriptState(triggers: TriggerRecord[] | null, extras: Extras): ScriptState {
const read = readFiles(extras);
const files = Object.keys(read).length ? read : null;
const libraries = readLibraries(extras);
const manifest = readManifest(extras);
const block = triggers && manifest ? findBlock(triggers, manifest) : null;
const unbuilt = files !== null && (!manifest || manifest.sourceHash !== hashSources(files, libraries));
const stale = !!manifest && !block;
const parts = stale && triggers ? staleRecords(triggers, manifest!) : null;
return { files, source: files?.[ENTRY_FILE] ?? null, manifest, block, stale, edited: parts ? { unchanged: parts.unchanged.length, changed: parts.changed.length } : null, unbuilt, programs: manifest?.programs ?? 0, libraries: Object.values(libraries).map(({ id, label, version }) => ({ id, label, version })) };
}
export function isGenerated(state: ScriptState, index: number): boolean {
return !!state.block && index >= state.block.start && index < state.block.start + state.block.count;
}
/** After another editor rewrote the list: point the manifest at where the block went. Null when nothing changed. */
export function relocateManifest(triggers: TriggerRecord[], extras: Extras): Map<string, Uint8Array> | null {
const manifest = readManifest(extras);
if (!manifest) return null;
const block = findBlock(triggers, manifest);
if (!block || block.start === manifest.start) return null;
return withManifest(extras, { ...manifest, start: block.start });
}
/** The compiled records with their local string ids resolved through `intern` (the map's string table). */
/** The compile's local string ids → the map's, interning each text once. */
export function stringResolver(compiled: CompileResult, intern: (text: string) => number): (local: number) => number {
const cache = new Map<number, number>();
return (local: number): number => {
if (local === 0) return 0;
const hit = cache.get(local);
if (hit !== undefined) return hit;
const s = compiled.strings[local - 1];
const index = !s ? 0 : "index" in s ? s.index : intern(s.text);
cache.set(local, index);
return index;
};
}
export function resolveStrings(compiled: CompileResult, intern: (text: string) => number): TriggerRecord[] {
const resolve = stringResolver(compiled, intern);
return compiled.triggers.map((t) => {
const next = cloneTrigger(t);
for (const a of next.actions) {
a.text = resolve(a.text);
a.wav = resolve(a.wav);
}
return next;
});
}
export interface BuildOptions {
/** Replace the *whole* list with the script's triggers (ejecting every hand trigger into the block). */
takeOver?: boolean;
/**
* Leave the archive's files as they are instead of storing the compiled ones: they were
* edited while the compile ran, and the newer text must not be overwritten by the older.
* The manifest still records what the block was built from, so the state reads as unbuilt.
*/
keepFiles?: boolean;
/**
* The previous block was edited by hand (`ScriptState.stale`): remove the records that
* are still the build's own and put the new block in their place, keeping the edited
* records as hand triggers right after it. Without it, the new block is appended and
* the old records all stay.
*/
replaceStale?: boolean;
/**
* The libraries the compile used, to keep as the map's copies in place of the ones there (a library the script no
* longer imports goes). Absent: the copies stay as they are, and the files are taken to have been compiled with them.
*/
libraries?: Readonly<Record<string, LibraryCopy>>;
}
export interface BuildPlan {
/** The trigger list as it should be after the build. */
list: TriggerRecord[];
/** The members as they should be after the build. */
extras: Map<string, Uint8Array>;
block: ScriptBlock;
/** With `replaceStale`: how many of the previous block's records were removed, and how many edited ones kept. */
replaced?: { removed: number; kept: number };
}
/**
* Plan a successful compile's installation: the list with the current block replaced
* (or a new one appended), and the files and manifest to store. `intern` answers the
* string-table index for a text — inside a `document.update`, that is `tx.strings.intern`.
*/
export function buildScript(triggers: TriggerRecord[], extras: Extras, files: ScriptFiles, compiled: CompileResult, intern: (text: string) => number, options: BuildOptions = {}): BuildPlan {
const records = resolveStrings(compiled, intern);
const state = scriptState(triggers, extras);
let start: number;
let before: TriggerRecord[];
let after: TriggerRecord[];
let replaced: BuildPlan["replaced"];
const parts = options.replaceStale && state.stale && state.manifest ? staleRecords(triggers, state.manifest) : null;
if (options.takeOver) {
start = 0; before = []; after = [];
} else if (state.block) {
start = state.block.start;
before = triggers.slice(0, start);
after = triggers.slice(start + state.block.count);
} else if (parts) {
// The edited records stay, as hand triggers right after the new block; the untouched ones go.
start = state.manifest!.start;
const end = Math.min(triggers.length, start + state.manifest!.count);
before = triggers.slice(0, start);
after = [...parts.changed.map((i) => triggers[i]), ...triggers.slice(end)];
replaced = { removed: parts.unchanged.length, kept: parts.changed.length };
} else {
start = triggers.length; before = triggers.slice(); after = [];
}
const manifest: ScriptManifest = { version: 2, programs: compiled.ir.length, start, count: records.length, hash: hashTriggers(records), sources: compiled.sources, files: Object.keys(files).map(normalizePath).sort(), sourceHash: hashSources(files, options.libraries ?? readLibraries(extras)), records: records.map(hashRecord) };
const stored = options.keepFiles ? extras : options.libraries ? withLibraries(withFiles(extras, files), options.libraries) : withFiles(extras, files);
return { list: [...before, ...records, ...after], extras: withSounds(withManifest(stored, manifest), compiled.sounds ?? []), block: { start, count: records.length, sources: manifest.sources }, ...(replaced ? { replaced } : {}) };
}
/** Which trigger a source line of a file belongs to (the trigger whose source starts at or before the line), if any. */
export function triggerAtLine(block: ScriptBlock, file: string, line: number): number | null {
let hit: number | null = null;
const path = normalizePath(file);
block.sources.forEach((s, i) => { if (s && normalizePath(s.file) === path && s.line <= line) hit = block.start + i; });
return hit;
}