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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 22 additions & 17 deletions README.md

Large diffs are not rendered by default.

56 changes: 47 additions & 9 deletions compile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
* again: the script is never run on the main thread, where nothing could stop an endless
* loop in it.
*/
import type { CompileResult, ScriptFiles } from "./compiler/compiler";
import type { CompileResult, OwnCheck, ScriptFiles } from "./compiler/compiler";
import type { OwnBuildInput, OwnBuildOutcome } from "./compiler/backend/build";
import { ENTRY_URL } from "./compiler/entry";
import type { ScriptNames } from "./compiler/names";
import type { TestRunOptions } from "./compiler/testing";
Expand Down Expand Up @@ -76,6 +77,8 @@ export interface CompileInput {
tests?: TestRunOptions;
/** Encode the sounds the map is to get (a build): a check leaves an Ogg Vorbis one as its name (`compiler/vorbis.ts`). */
sounds?: boolean;
/** Ask whether TrigScript's own build takes the programs for this map (`CompileResult.ownRefusal`). */
own?: OwnCheck;
}

interface CompileRequest extends CompileInput {
Expand All @@ -85,9 +88,17 @@ interface CompileRequest extends CompileInput {
vorbisUrl?: string;
}

/** A map of our own built in the worker (`compiler/backend/build.ts`), off the main thread like a compile. */
interface BuildRequest {
id: number;
moduleUrl: string;
libUrl: string;
build: OwnBuildInput;
}

interface CompileResponse {
id: number;
result?: CompileResult;
result?: CompileResult | OwnBuildOutcome;
error?: string;
/** The worker could not load its compiler at all: every later request would fail the same way. */
fatal?: boolean;
Expand All @@ -98,20 +109,26 @@ importScripts(${JSON.stringify(TS_URL)});
let loading = null;
let lib = null;
self.onmessage = async (e) => {
const { id, moduleUrl, libUrl, vorbisUrl, files, names, tests } = e.data;
const { id, moduleUrl, libUrl, vorbisUrl, files, names, tests, own, build } = e.data;
try {
if (!loading) loading = import(moduleUrl);
let mod;
try { mod = await loading; } catch (err) { loading = null; postMessage({ id, error: String((err && err.message) || err), fatal: true }); return; }
// The plugin fetched as one bundle is a blob too, but one that exports the plugin and not the compiler.
if (typeof mod.compileScript !== "function") { loading = null; postMessage({ id, error: "the module has no compiler in it", fatal: true }); return; }
// A build of a map of our own: the compile's programs into the scenario's bytes, no TypeScript needed.
if (build) {
if (typeof mod.buildOwnMap !== "function") throw new Error("This compiler bundle cannot build a map of its own: update the plugin.");
postMessage({ id, result: mod.buildOwnMap(build) });
return;
}
if (lib === null) {
const r = await fetch(libUrl);
if (!r.ok) throw new Error("Could not load the standard library from " + libUrl + " (" + r.status + ").");
lib = await r.text();
}
// A check names the script's clips and makes none of them; a build makes them, the long ones encoded below.
const result = mod.compileScript(self.ts, files, names, { lib, tests, sounds: Boolean(vorbisUrl) });
const result = mod.compileScript(self.ts, files, names, { lib, tests, sounds: Boolean(vorbisUrl), own });
// A build's long clips encoded as Ogg Vorbis; a check's left as names, their samples not sent.
if (typeof mod.finishSounds === "function") {
const encoder = vorbisUrl && result.sounds.some((s) => s.vorbis) ? await mod.loadVorbis(vorbisUrl) : null;
Expand Down Expand Up @@ -140,7 +157,8 @@ let worker: Worker | null = null;
let blobRefused = false;
let unavailable: { reason: string; at: number } | null = null;
let seq = 0;
const pending = new Map<number, { resolve: (r: CompileResult) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
/** What is in flight: a compile, which a newer compile supersedes, or a build, which nothing does. */
const pending = new Map<number, { resolve: (r: CompileResult | OwnBuildOutcome) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout>; build: boolean }>();

/**
* The worker is a whole TypeScript instance (tens of MB of heap) and it is only busy while
Expand Down Expand Up @@ -222,7 +240,7 @@ function timeOut(id: number, ms: number) {
worker?.terminate();
worker = null;
rejectAll("The compile was stopped.");
p.reject(new Error(`The script did not finish in ${ms / 1000} seconds. Is there an endless loop outside program()?`));
p.reject(new Error(p.build ? `The build did not finish in ${ms / 1000} seconds.` : `The script did not finish in ${ms / 1000} seconds. Is there an endless loop outside program()?`));
}

/** The worker, started if there is none. Throws `CompilerUnavailable` when one cannot be made, or could not a moment ago. */
Expand Down Expand Up @@ -267,19 +285,39 @@ export function compileInBackground(input: CompileInput, dist: string = DEFAULT_
let w: Worker;
try { w = getWorker(); } catch (err) { return Promise.reject(err as Error); }
const id = ++seq;
// Anything still in flight is stale now.
// Any compile still in flight is stale now (a build is not: it answers whatever is typed meanwhile).
for (const [old, p] of pending) {
if (p.build) continue;
pending.delete(old);
clearTimeout(p.timer);
p.reject(new CompileSuperseded());
}
return new Promise<CompileResult>((resolve, reject) => {
const ms = input.sounds ? BUILD_TIMEOUT_MS : COMPILE_TIMEOUT_MS;
pending.set(id, { resolve, reject, timer: setTimeout(() => timeOut(id, ms), ms) });
const req: CompileRequest = { id, moduleUrl: workerModuleUrl(dist), libUrl: libUrl(dist), files: input.files, names: input.names, ...(input.tests ? { tests: input.tests } : {}), ...(input.sounds ? { vorbisUrl: vorbisUrl(dist) } : {}) };
pending.set(id, { resolve: (r) => resolve(r as CompileResult), reject, timer: setTimeout(() => timeOut(id, ms), ms), build: false });
const req: CompileRequest = { id, moduleUrl: workerModuleUrl(dist), libUrl: libUrl(dist), files: input.files, names: input.names, ...(input.tests ? { tests: input.tests } : {}), ...(input.sounds ? { vorbisUrl: vorbisUrl(dist) } : {}), ...(input.own ? { own: input.own } : {}) };
w.postMessage(req);
}).catch((err: Error) => {
if (err.message === RETRY) return compileInBackground(input, dist);
throw err;
});
}

/**
* Build a map of our own in the background (`compiler/backend/build.ts`): the compile's programs laid out into the
* scenario's bytes, which the plugin's build step hands the editor. Not superseded by a compile, and supersedes none.
*/
export function buildInBackground(input: OwnBuildInput, dist: string = DEFAULT_DIST): Promise<OwnBuildOutcome> {
let w: Worker;
try { w = getWorker(); } catch (err) { return Promise.reject(err as Error); }
const id = ++seq;
return new Promise<OwnBuildOutcome>((resolve, reject) => {
const ms = BUILD_TIMEOUT_MS;
pending.set(id, { resolve: (r) => resolve(r as OwnBuildOutcome), reject, timer: setTimeout(() => timeOut(id, ms), ms), build: true });
const req: BuildRequest = { id, moduleUrl: workerModuleUrl(dist), libUrl: libUrl(dist), build: input };
w.postMessage(req);
}).catch((err: Error) => {
if (err.message === RETRY) return buildInBackground(input, dist);
throw err;
});
}
167 changes: 167 additions & 0 deletions compiler/backend/build.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
/**
* A map of our own, built from a scenario file (TrigScript 4, step 14: the build in the editor). What
* `scripts/build-own.mts` did with the editor's code, done on the CHK's bytes alone (`chk.ts`), so that the plugin's
* build step can do it on Save, Test Map and an export, in the compile worker, with no eudplib and no Python:
*
* 1. The map's facts: its size, who plays and in which force, its own triggers (the script's `trigger()` records
* among them, once applied), the unit types and the unit-properties slots in use.
* 2. eudplib's fixes to the map (`fixes.ts`), so that a map built either way is the same map.
* 3. The programs lowered whole (`own.ts`, `emitOwn`): every text known when built goes into the strings, one after
* another from the next index; every printed text and every action's text or sound file is interned; a text
* printed with values in it gets a string as long as its template, which is then written over that string.
* 4. The layout (`layout.ts`): the payload after the strings, where Remastered keeps them, and the start-up in TRIG.
* 5. The slot of unit properties the carriers of the players' input are made with, marked in transit.
*
* A program the emitter does not take is a refusal, said in the user's words with the line; the map is then not
* built this way (the editor builds it through eudplib while that is still there, or saves it without its programs).
*/
import type { CompileResult } from "../compiler";
import type { TriggerRecord } from "../../vendor/triggers";
import { Sections, addString, freeCuwpSlot, intern, markInTransit, readFacts, readStrings, readTriggers, unitTypesUsed, writeStrings } from "./chk";
import { fixMap } from "./fixes";
import { STRINGS, layout } from "./layout";
import { emitOwn, type OwnOptions } from "./own";
import type { MapPlayers } from "./emit";
import type { Graph } from "./graph";

/** The Valkyrie: the carrier of the players' input unless the map's units or triggers name it. */
const CARRIER = 58;
const UNIT_TYPES = 228;

export interface OwnBuildInput {
/** The scenario as the editor would save it: the script applied. */
chk: Uint8Array;
/**
* The compile. Its `triggers` are records the map's trigger list does not hold yet (a probe built under Node);
* the editor passes none, the block being in the list already.
*/
compiled: Pick<CompileResult, "ir" | "strings" | "input" | "triggers">;
/** The script's settings: the heap's cells, the stack's depth. */
settings?: OwnOptions;
/** Leave the map's own triggers out (a probe built on a melee map, whose stock triggers would end the game). */
dropMapTriggers?: boolean;
/** A dword written into every free cell of the payload (a probe's marker). */
free?: number;
}

export interface OwnBuildStats {
runs: number;
/** The programs' runs, as "program 2 as P1". */
runNames: string[];
graphTriggers: number;
payloadBytes: number;
address: number;
/** The map's own triggers laid out, and the copies of them (one a player they run for). */
mapTriggers: number;
mapTriggerCopies: number;
hyperTriggersLeftOut: number;
startUpTriggers: number;
input: { keys: number; mouse: boolean; syncs: number; carrier: number; ring: number; rings: number; humans: number[]; cuwp: number; bits: number } | null;
/** How many strings the map had before, and has now. */
stringsBefore: number;
stringsAfter: number;
}

export interface OwnBuild {
chk: Uint8Array;
/** What was done, for the Output panel: the fixes, the input, the counts. */
lines: string[];
stats: OwnBuildStats;
}

export type OwnBuildOutcome = OwnBuild | { refused: string };

export function buildOwnMap(input: OwnBuildInput): OwnBuildOutcome {
const s = new Sections(input.chk);
const lines: string[] = [];
// The map's facts come first, before the fixes change its bytes.
const facts = readFacts(s);
const players: MapPlayers = { slots: facts.slots, forces: facts.forces };
const mapTriggers: TriggerRecord[] = input.dropMapTriggers ? [] : readTriggers(s);
const used = unitTypesUsed(s, [...mapTriggers, ...input.compiled.triggers]);
const carrier = used.has(CARRIER) ? [...Array(UNIT_TYPES).keys()].find((t) => !used.has(t)) ?? CARRIER : CARRIER;
const cuwp = freeCuwpSlot([...mapTriggers, ...input.compiled.triggers]);
// The strings: STRx as they are, or STR's read on as STRx (a Remastered file keeps its strings there).
const strx = s.get("STRx");
const str = s.get("STR ");
if (!strx && !str) throw new Error("The map has no string table (STR or STRx).");
const table = readStrings(strx ?? str!, !!strx);
const stringsBefore = table.entries.length;
const textBase = stringsBefore + 1;
lines.push(...fixMap(s));
if (!strx) lines.push(`the strings written as STRx (Remastered's table), from STR`);

const lowered = emitOwn(input.compiled, players, textBase, { humans: facts.humans, width: facts.width * 32, height: facts.height * 32, cuwp, carrier, triggers: mapTriggers }, input.settings);
if (typeof lowered === "string") return { refused: lowered };
// The pool of texts: the map's next strings, one after another, as the programs count on.
lowered.texts.forEach((text, i) => {
if (addString(table, text) !== textBase + i) throw new Error("The pool of texts did not go into the map's strings one after another.");
});
const graphs: Graph[] = lowered.runs.map((run) => run.graph);
const texts = new Map<string, number>();
const want = (text: string) => { if (!texts.has(text)) texts.set(text, intern(table, text)); };
for (const t of [...graphs.flatMap((g) => g.triggers), ...lowered.triggers]) for (const a of t.acts) {
if (a.display !== undefined) want(a.display);
for (const x of [a.strings?.text, a.strings?.wav]) if (x && "text" in x) want(x.text);
if (a.amountText) want(a.amountText.text);
}
// A string for each text printed with values in it, as long as its template and three bytes more: once the strings
// are laid out, the template is written over it from its first dword's start, and the string's offset moved there
// (the game's own table of strings names only its first 64 KB in 1.16.1, so the template stays among them).
const templates = new Map<string, number>();
for (const t of graphs.flatMap((g) => g.templates ?? [])) {
if (t.at !== undefined || t.data) continue;
const name = `(TrigScript text ${t.id})`;
templates.set(t.id, addString(table, name + "\r".repeat(Math.max(0, t.bytes.length + 3 - name.length))));
}
const written = writeStrings(table);
const strings = written.data;
const offsets = new DataView(strings.buffer, strings.byteOffset, strings.byteLength);
const templateAt = new Map<string, number>();
for (const t of graphs.flatMap((g) => g.templates ?? [])) {
if (t.at !== undefined || t.data) continue;
const index = templates.get(t.id)!;
const at = (STRINGS + written.offsets[index - 1] + 3) & ~3;
strings.set(t.bytes, at - STRINGS);
offsets.setUint32(4 * index, at - STRINGS, true);
templateAt.set(t.id, at);
}
const own = layout({
runs: lowered.runs.map((run) => ({ graph: run.graph, owner: run.player })),
players: players.slots,
triggers: lowered.triggers,
strings: strings.length,
text: (t) => texts.get(t)!,
arrays: lowered.values,
template: (id) => templates.get(id)!,
templateAt,
...(input.free !== undefined ? { free: input.free } : {}),
});
const withPayload = new Uint8Array(strings.length + own.payload.length);
withPayload.set(strings);
withPayload.set(own.payload, strings.length);
if (strx) s.set("STRx", withPayload);
else s.replace("STR ", "STRx", withPayload);
s.set("TRIG", own.trig);
if (lowered.input) markInTransit(s, cuwp);

const stats: OwnBuildStats = {
runs: lowered.runs.length,
runNames: lowered.runs.map((run) => `program ${run.program + 1} as P${run.player + 1}`),
graphTriggers: graphs.reduce((n, g) => n + g.triggers.length, 0),
payloadBytes: own.payload.length,
address: own.address,
mapTriggers: lowered.triggers.length,
mapTriggerCopies: lowered.triggers.reduce((n, t) => n + t.slots.length, 0),
hyperTriggersLeftOut: lowered.hyperTriggersLeftOut,
startUpTriggers: own.trig.length / 2400,
input: lowered.input ? { keys: lowered.input.keys.length, mouse: lowered.input.mouse, syncs: lowered.input.syncs, carrier, ring: lowered.input.ring, rings: lowered.input.rings, humans: lowered.input.humans, cuwp, bits: lowered.input.bits } : null,
stringsBefore,
stringsAfter: table.entries.length,
};
if (stats.input) lines.push(`input: ${stats.input.keys} keys, ${stats.input.mouse ? "the mouse, " : ""}${stats.input.syncs} sync slots; carriers of type ${carrier}, ${stats.input.ring} a ring, ${stats.input.rings} rings, for players ${stats.input.humans.map((h) => h + 1).join(", ")}; unit-properties slot ${cuwp}; ${stats.input.bits} bits a value`);
lines.push(`${stats.runs} run${stats.runs === 1 ? "" : "s"} (${stats.runNames.join(", ")}), ${stats.graphTriggers} triggers of the programs, payload ${stats.payloadBytes} bytes at 0x${stats.address.toString(16)}, ${stats.mapTriggers} trigger${stats.mapTriggers === 1 ? "" : "s"} of the map's own (${stats.mapTriggerCopies} copies${stats.hyperTriggersLeftOut ? `, ${stats.hyperTriggersLeftOut} hyper triggers left out` : ""}), start-up ${stats.startUpTriggers} triggers, ${stats.stringsAfter - stats.stringsBefore} strings added`);
return { chk: s.bytes(), lines, stats };
}

export const isRefused = (o: OwnBuildOutcome): o is { refused: string } => "refused" in o;
Loading
Loading