A plugin for scmJS, the browser-based StarCraft 1 / Brood War map editor. It is a library: it has no editor of its own. It runs eudplib, the trigger compiler behind euddraft that StarCraft: Remastered EUD maps are built with, inside the editor — in a Web Worker, through Pyodide — and holds it out as a service other plugins build maps through. Magenta and TrigScript use it. Nothing about a map leaves the machine, and there is no server.
In scmJS: Plugins ▸ Manage Plugins…, paste
https://github.com/scm-js/plugin-eudplib
and add it. A plugin that needs it names it in its manifest, so installing that plugin installs this one with it.
The runtime is not part of the plugin: it is downloaded once, on the first build, after a dialog says what it is and how big — about 15 MB from cdn.jsdelivr.net (Pyodide, a Python for the browser, and the eudplib wheel), kept by the browser for later builds. Cancel and the build does not happen; the next one asks again. The plugin's page in Edit ▸ Preferences ▸ Plugins ▸ eudplib shows what is installed, the versions, and has Install and Remove.
The scmJS desktop app and container image carry the runtime themselves, so there is nothing to download there and a build works with no network. That copy is made for one release of this plugin: update the plugin past the editor's and it downloads as above.
Each build starts a fresh Python from the download, about two seconds, then eudplib's own work — a second or two for a typical map. A build cannot be paused, only stopped.
Depend on it in your manifest, and reach the service by name:
{ "requires": ["github:scm-js/plugin-eudplib"] }import type { EudplibService } from "@scm-js/plugin-eudplib/contract"; // or copy contract.d.ts
api.services.watch<EudplibService>("eudplib.build", (eudplib) => { /* null until the library is on */ });ensure() asks the user for the download when it is absent and answers true once the runtime
is in; build() calls it itself. A build request is the map's bytes, the euddraft plugin
sections as data (the .eds sections: name → settings), and any euddraft plugins of your own as
Python source, loaded like the bundled ones — settings in their globals, onPluginStart /
beforeTriggerExec / afterTriggerExec hooks. The bundled ones are euddraft's eight: MSQC,
bgmplayer, cammove, chatEvent, dataDumper, eudTurbo, noAirCollision, unlimiter.
The usual way to use the library is not to call build() at all. The library registers the
editor's build step (api.document.buildSteps), which runs whenever the map is saved, tested
or exported, and your plugin contributes to it:
const mine = eudplib.contribute({
id: "my-plugin",
label: "My Plugin",
applies: () => hasSomethingToBuild(), // asked on every save: cheap and synchronous
collect: async ({ purpose, signal }) => ({ // compile here; the same fields a build request takes
plugins: { mine: { ir: "/work/files/mine.json" } },
sources: { mine: MY_EUDDRAFT_PLUGIN_PY },
files: { "mine.json": JSON.stringify(compile()) },
}),
});Everything that applies is merged into one request and the map is built once, so a map that uses two plugins of this kind has both in it. The user has one file: what Save writes is the built map, the editor keeps the map from before the build inside it and shows that one again on open. When nothing contributes, nothing is built and Save is what it always was.
Throw from collect with a message worded for the user (main.ts:3 — no such unit): the map
is saved without the build and the editor's notice carries your label and message. Two
contributions may ask for the same bundled plugin with the same settings (eudTurbo: {});
two different things under one name is an error that names both. Sections run in the order
the contributions were made. onBuild(listener) reports start, each log line, and done
or failed (with from, the id of the contribution whose collect threw, or null when the
build itself failed) — what a plugin needs to show a log or put a marker on a line.
A build that fails in Python rejects with an error whose message is the first exception's
own sentence (trigscript: no such unit at main.ts:12:5), not the traceback; the traceback is
the error's detail and the last lines of the failed event's log. Word what your euddraft
plugin raises for the person who will read it in the editor's notice.
build() stays for a one-off: a probe map, a tool that wants bytes and not a save.
The contract (contract.d.ts, ServiceInfo.version 2; version 1 had no contribute or onBuild):
export interface EudplibBuildRequest {
map: Uint8Array; // a .scm/.scx archive (a bare .chk is taken too)
plugins: Record<string, Record<string, string | number>>; // .eds sections, name → settings
sources?: Record<string, string>; // your euddraft plugins, module name → Python
files?: Record<string, string>; // data files, name → text, at /work/files/<name> for a setting to name
options?: { shufflePayload?: boolean; sectorSize?: number }; // euddraft's; shuffle on and 15 by default
}
export interface EudplibBuildResult { map: Uint8Array; log: string; chkBytes: number; ms: number }
export type EudplibState = "absent" | "installing" | "ready" | "failed";
export interface EudplibService {
versions: { plugin: string; eudplib: string; pyodide: string; euddraft: string };
state(): EudplibState;
downloadBytes: number;
ensure(opts?: { reason?: string }): Promise<boolean>; // false when the user declined
build(request: EudplibBuildRequest, opts?: { signal?: AbortSignal; onLog?: (line: string) => void }): Promise<EudplibBuildResult>;
contribute(contribution: EudplibContribution): { dispose(): void };
onBuild(listener: (event: EudplibBuildEvent) => void): { dispose(): void };
}
export interface EudplibContribution {
id: string;
label: string;
applies(): boolean;
collect(ctx: { purpose: "save" | "test" | "export"; signal: AbortSignal }): Promise<{ plugins; sources?; files? }>;
}A request is checked before anything runs: a section's values may be strings or numbers
(numbers become strings), a key may not contain [ ] : = or a line break, a value may not
name a file, and main and freeze are the library's own. What euddraft would have read is in
the log as .eds text.
The built map is a new archive: the scenario eudplib wrote, every member of the input that its listfile names, and whatever the plugins added, PKWARE-compressed, readable by every StarCraft build. A member the input's listfile does not name cannot be carried and the log says so.
dist/<wheel>is eudplib built for Pyodide;wheel/says how and holds the one patch.- The worker (
src/eudplib.worker.ts→dist/worker.js) is fetched from this repository at the plugin's version tag on jsDelivr, never from the plugin's own module, which the editor may have compiled into itself. Pyodide comes from its own CDN folder at a pinned version. - eudplib's Python is untouched. Two things are arranged around it at run time
(
python/driver.py): the archive API it expects from StormLib ispython/mpqshim.py, an in-memory registry the main thread fills with the scenario and the listfile's names and reads the built members back from (mopaq does the archive); andplatform.system()answers Linux while eudplib imports, since its epscript loader indexes a Windows / Linux / macOS table at import time. - "Installed" means the browser's Cache API holds every file of the download
(
src/urls.tslists them with their sizes). The worker fetches the same addresses, which the install has just put in the HTTP cache too. - An editor carries the runtime by copying the files
runtime.jsonlists (each file's place and where to get it;npm run manifestwrites it fromsrc/urls.ts) intoplugin-runtime/eudplib/<version>/beside its page, withruntime.jsonitself. At activation the plugin asks for thatruntime.jsonat its own version; when it answers, the worker loads everything from there and the runtime counts as installed. scmJS does this in its desktop and container builds (scripts/bundle-plugin-runtimes.mjsthere), not in the hosted editor, where the lookup is one 404 and jsDelivr serves the download. - A
runtimeBasesetting in the plugin's storage (a folder serving this repository, such ashttp://localhost:8080/) points the worker and the wheel somewhere else for development.
npm install
npm run typecheck && npm test && npm run build # dist/plugin.js and dist/worker.js
npm run smoke -- <request.json> [magenta.py] [expected triggers]npm run smoke runs the built worker module under Node with the pyodide npm package standing
in for the CDN: a captured {map, plugins} request (base64 map, sections) is built twice, the
archive put together and opened again, and its trigger count checked — the Python side proven
without a browser. wheel/README.md covers rebuilding the wheel and the byte-for-byte
comparison against a native euddraft.
MIT. See ATTRIBUTION.md for eudplib, euddraft, Pyodide and mopaq.