The API, group by group
The complete typings are the package's own index.d.ts, and every one of them is generated into
a page at docs.scmjs.dev/api; this is the tour. Every
method that reads the map returns null / [] / false when no map is open rather than
throwing.
Promises, and the one thing that is synchronous#
Everything asynchronous is a promise. There is no completion callback and no
(err, result) anywhere in the API: await it and read the answer, or check it for
null. That covers opening, saving, exporting and rendering a map
(document.open / create / save / saveAs / close / export / renderImage /
changeTileset), loading game data (tileset.load, data.load, graphics.load,
terrain.checkIsom), and everything that waits for the user (ui.pickArea, pickTile,
pickFiles, saveFile, loadImage, readClipboardImage, confirm, alert, prompt,
ask). A user who dismisses something resolves the promise with null or false
rather than rejecting, so the ordinary path needs no try:
const rect = await api.ui.pickArea({ prompt: "Pick an area to flatten" });
if (!rect) return; // Esc, a right-click, or no map
await api.tileset.load(); // the graphics the fill needs
api.document.edit("Flatten", tx => tx.stampTerrain(rect, terrainId));
activate itself may be async — the host awaits it before the plugin counts as
loaded — and so may a dialog button's run, which keeps the dialog open until it
settles and closes it on anything but false.
The callbacks that remain are the ones that are genuinely callbacks rather than a
deferred answer: event listeners (api.events.on(…)), the DOM handlers of ui.widgets, a
dialog's or panel's mount, and the pointer and draw hooks of ui.mapTool and
ui.overlay. Every one of them returns a Disposable or a cleanup function, so there
is no off() to pair up and nothing to unregister at deactivation.
The one exception is a transaction's builder. document.edit(label, build) and
document.update(label, build) take a synchronous build: its operations apply as
they are called and the commit closes the transaction the moment it returns. An async
builder would therefore commit whatever ran before its first await and let the rest
mutate the map outside that entry, where undo cannot reach it. TypeScript refuses one,
and the host also catches it at runtime — the result's notes and the console say so —
for a plugin written in plain JavaScript.
// Wrong: commits at the await, and the placement lands outside the undo entry.
api.document.edit("Place", async tx => {
await api.data.load();
tx.placeUnit(0, 0, 128, 128);
});
// Right: await first, then write in one go.
await api.data.load();
api.document.edit("Place", tx => tx.placeUnit(0, 0, 128, 128));
Long work of your own gets a progress panel that does not block the editor;
handle.cancelled() is the poll and handle.signal the same answer as an
AbortSignal, so anything that takes one stops with the panel:
const job = api.ui.progress("Converting", { cancellable: true });
try {
for (let i = 0; i < steps; i++) {
if (job.cancelled()) break;
job.report(i / steps, `Row ${i}`);
const data = await fetch(url, { signal: job.signal });
}
} finally {
job.done();
}
The three kinds of write#
Everything a plugin can change about the open map goes through one of three, and they differ in what they cost:
| what it covers | undo | |
|---|---|---|
document.edit(label, build) |
terrain and objects — tiles, ISOM, units, sprites, doodads, locations, fog | one history entry, like a brush stroke |
document.update(label, build) |
the tables and settings — triggers, briefing, the string table, switch names, the scenario's name and description, players, forces and colours, unit / upgrade / technology settings, sounds, the map revision | none: a settings-dialog transaction, as in StarEdit |
document.sections.* |
the file's own bytes, any section, modelled or not | none, and the history is dropped (as Resize) |
They are the editor's own three: a stroke, a dialog's OK, and a raw file edit. Both transactions apply their operations as they are called, so later ones see earlier ones' results, and both commit once at the end — which is why the builder is synchronous (above).
api.document#
isOpen() |
Whether a scenario is loaded. |
info() |
{ name, description, width, height, tileset, era, version, fileName, modified }. |
scenario() |
The live Scenario object, for reading. Mutating it directly bypasses undo and dirty tracking. |
edit(label, build) |
Run build(tx) and record what it did as one undo entry named label. Returns an EditResult with counts per list. |
update(label, build) |
The tables and settings, as one settings-style transaction — triggers, strings, switch names, the scenario's properties, and everything the Scenario menu's dialogs write (see UpdateTransaction). Not in the undo model. Returns an UpdateResult. |
undo() / redo() |
The Edit menu's. |
history() |
{ undo, redo, undoDepth, redoDepth }: the labels the Edit menu shows and how deep each stack is, without moving anything — so a plugin can tell whether its own edit is still the top entry before undoing it. |
open(file, fileName?) |
Open a map file (File, Blob or bytes; .scx / .scm / .chk) in place of the current one, the way File ▸ Open does. A modified map goes through the Close Scenario dialog first when Preferences say to ask. Resolves true once the file is the open document, false when the user kept the current map or the file could not be read (the status bar says which). |
create({ width, height, tileset, name?, description?, terrainId?, startLocations?, startLayout? }) |
A blank map in place of the current one, the way File ▸ New makes one — flat ground of the tileset's default terrain (or terrainId), an ISOM lattice to match, every section a fresh map needs — through the same unsaved-changes gate as open. startLocations lays one down for each of players 1..N as tx.placeStartLocations would ("ring" unless startLayout says "corners"); they are part of making the map, so a fresh scenario has no history to undo them from. Resolves true once the new map is the open document, false when the user kept the current one. |
export({ format?, fileName?, saveOptions? }) |
The open map as a File, as Save writes it — the save options last confirmed for this map (or their defaults: PKWARE and encryption for a new map, the way it was opened for an opened one), archive extras included — scx / scm / a bare chk; saveOptions overrides compression, encryption and what is left out. Null with no map. Hand it to a FormData and it uploads. |
save({ copy? }) / saveAs({ copy? }) |
File ▸ Save and Save As. save writes back where the map came from with its remembered options — into the file when the browser gave a handle, else through the browser's save dialog or as a download — and a map with no file yet goes through the Save dialog; saveAs always opens it; copy writes a copy and leaves the document's name and clean state alone. Resolve true once written, false when the user dismissed a dialog or the write failed. |
close() |
File ▸ Close, through the same unsaved-changes gate as open; true once the map is gone. |
changeTileset({ tileset, terrainId?, keepTiles? }) |
Map Properties' tileset change: ERA moves and the terrain is laid again with terrainId (the new tileset's default when omitted) after the new graphics load, the doodads go, everything else stays; keepTiles changes only ERA. A transaction outside the undo model that drops both history stacks, like resize. |
renderImage({ pixelsPerTile?, … }) |
A PNG Blob of the map as File ▸ Export ▸ Image draws it; 32 pixels per tile is the game's art, 1 is a minimap. Needs the tileset graphics (null without them or without a map). |
resize({ width, height, anchor?, terrainId?, clampLocations? }) |
Scenario ▸ Resize / Crop Map: content keeps its place relative to the anchor (a 3 × 3 grid, 4 = centre), the new ground is terrainId or the tileset's default, objects outside the new bounds are dropped and locations clamped. A transaction outside the undo model that drops both history stacks, as the dialog does. Returns the ResizeResult (what was dropped), null with no map. |
extras |
The files stored in the archive next to staredit\scenario.chk — custom sounds, and anything a plugin wants to keep with the map: list(), get(name), set(name, bytes), remove(name). Names are archive paths with backslashes; keep yours under a folder of your own (my-plugin\notes.json). set / remove mark the map modified; the members are written on the next Save. |
sections |
The scenario at the byte level — see the next section. |
api.document.sections#
The CHK as a list of sections, the way the game reads it and Save writes it, with unsaved
edits already encoded: list() gives every occurrence in file order as a SectionInfo
(index, the four-character name, offset, size, declaredSize / truncated for a
file that ended early, occurrence / occurrences for a repeated name, dirty when the
editor holds changes it will encode there, and spec — what the registry knows: what,
the combine mode on repeat, the fixed size the game reads for this map or null, the
record stride of a list, and modelled, whether the editor decodes it). bytes(index)
is a copy of one occurrence's payload, combined(name) the bytes the game acts on with
repeats folded the way the registry says, file() the whole CHK, spec(name) / known()
the registry.
The writes — write(index, bytes), rename(index, name), insert(index, name, bytes),
remove(index), move(from, to) and replaceFile(bytes) — are a different kind of
transaction from edit: the edited file is parsed again from scratch and installed as
the open document, so the change reaches every part of the editor
whether or not it models the section, and, as with Resize, the undo history is dropped
and every selection cleared. The map is marked modified and "document" fires. Each
returns { warnings }, what the parser said of the result; a bad index or a name longer
than four characters throws, and so does any write without a map. Indices shift when a
section is inserted or removed before them, so take a fresh list() after every edit.
Section Explorer is the worked example.
Around them, what a repair needs: trailing() is the bytes after the last chunk the
reader could act on (what follows a header with a negative length, say — Save writes
them back as they are; a replaceFile without them drops them), required() the names
a file of the open map's revision must carry to load, as Check Map tests them (STRx in
place of STR on a Remastered file), defaults(name) the bytes File ▸ New would write
for a section on a map of this size, tileset and revision (StarEdit's defaults for a
settings table, the fixed VCOD, an empty list, null terrain; null for a name the editor
cannot produce), and rebuild(names?) re-encodes sections from the editor's model the
way Save writes a dirty one and installs the result like any other raw edit — repeated
occurrences collapse into one, a truncated or oversized section comes back at the size
the model encodes to, a string table whose offsets point nowhere is rewritten with every
string the editor could read. Names the editor does not model, and modelled ones whose
model is absent (no ISOM, no settings table), are left alone and missing from the
result's rebuilt; omit names for every modelled section the map has a model for.
Repair is the worked example for these.
EditTransaction#
tx applies each operation immediately, so a later operation sees the state the
previous one left (a tileAt after a setTile reads the new tile). When build
returns, the transaction lifts doodads the terrain edit broke, removes units the new
ground cannot hold (when Remove stranded units is on, as for a stroke), commits, and
repaints.
| Terrain | |
|---|---|
tileAt(x, y) / groundAt(x, y) |
MTXM / TILE at a cell. |
setTile(x, y, id) |
One tile, both sections. |
setTiles(cells, id) |
Many; cells is a Rect or cell indices (y * width + x). |
stampTerrain(cells, terrainId, variation?) |
The Rect brush: flat pairs by column parity, one random variation per pair. Needs the tileset graphics. Returns tiles changed. |
fillFlat(rect, terrainId) |
Lay terrain the way a new map is laid, ISOM lattice included. |
rebuildIsom() |
Reconstruct the ISOM from the tiles — for a map that arrived without one, or whose lattice no longer matches after Rect / Tile edits: exact for terrain laid down isometrically, a best guess under doodads and for hand-placed tiles. A missing or wrongly sized ISOM is created (undo removes it again); an existing one gets only the diamonds that differ. Needs the tileset graphics; null without them, else { created, changed, diamonds, unresolved }. |
paintIsom(diamond, terrainId, extent = 1) |
The isometric brush on one diamond: sets the ISOM and generates the cliff/shore tiles around it. Needs ISOM and the tileset. |
tilesFromIsom() |
The reverse of rebuildIsom: every tile regenerated from the lattice, what StarEdit does after an isometric edit. Needs ISOM and the tileset; tiles changed, or null. |
replaceTerrain(from, to, rect?) |
Tools ▸ Replace Terrain: every tile matching from — { kind: "terrain", id } for a flat terrain by ISOM id, { kind: "tile", id } for one exact tile — becomes to, over rect or the whole map, pairs laid as the Rect brush lays them. Returns tiles changed. |
fillArea(x, y, { terrainId } | { tileId }, match?) |
The bucket fill: the connected area of the same terrain type ("terrain", the Rect fill's reading — needs the graphics) or the same exact tile ("tile"), mirrored under the symmetry mode, laid with a terrain or set to a tile. |
placeBlend(x, y, side, id) |
The Blend brush: id on the cell beside the anchor on side; terrain.blendCandidates says what fits. |
mirror(cells) / mirrorPoint(px, py) |
The cells' (or the pixel's) images under Tools ▸ Symmetry, the way the built-in brushes and palettes take them. |
| Objects | |
|---|---|
makeUnit(unitId, owner, x, y) |
A StarEdit-style record (serial, masks) at map pixels. |
addUnits(records) / removeUnits(indices) / updateUnits(indices, patch) |
|
moveUnits(indices, dx, dy, snap?) |
Shift by a pixel delta. With snap (the palette's option by default) the destination is snapped — a building to the tile grid by its placement box, anything else to the nearest tile centre — so a unit that sits off the grid is brought onto it. |
placeStartLocations({ players, layout?, margin?, replace? }) |
Tools ▸ Auto-place Start Locations: one per player (from 1) on a "ring" or in the "corners", each moved to the nearest spot the placement checks accept; replace removes the existing ones first. Returns { changes, placed, removed }, placed null for a player nothing within reach fit. |
placeUnit(unitId, owner, x, y) |
A unit the way the Units palette places one: with its Snap to grid on, a building's placement box goes on the tile grid and anything else on the nearest tile centre; nothing leaves the map. Returns the index. No checks — |
canPlaceUnit(unitId, x, y) |
— ask this first if you want them: the palette's collision and terrain checks with its current options. |
makeSprite(kind, id, owner, x, y, opts?) / addSprites / removeSprites / placeSprite(...) |
placeSprite is make + add, kept on the map; returns the index. |
updateSprites(indices, patch) / moveSprites(indices, dx, dy) |
Owner, flags, position — in place, so indices hold. |
placeDoodad(doodadId, tx, ty, owner) / removeDoodads(indices) / updateDoodads(indices, { owner?, disabled? }) |
Doodads stamp MTXM and may carry an overlay sprite; all three keep the tiles, the record and the overlay together. |
addLocation(bounds, name?, elevationFlags?) / editLocation(index, patch) / removeLocations(indices) |
Slot 63 (Anywhere) and unused slots are refused by editLocation; addLocation also puts Anywhere back if it was missing. |
restoreAnywhere() |
Anywhere back to the whole map; true when it had to move. |
setFog(cells, players, "fog" | "clear") |
players is a bit mask; creates MASK on first use. |
invertFog(players) / copyFog(from, toMask) / floodFog(x, y, player, players, mode) |
The Fog palette's other three: flip the bits, copy one player's fog onto the players in a mask, fill the connected area that shares one player's state. |
note(text) |
A line for the status bar, alongside the label. |
UpdateTransaction#
The second kind of write. Operations apply immediately — a string interned on one line is
in the table for the trigger added on the next — and the commit at the end marks the map
modified and tells the chrome to re-read. The result is
{ changed, sections, notes }: which CHK sections were actually touched (["TRIG", "STR "]),
so changed is false when every operation was a no-op.
| Triggers | |
|---|---|
tx.triggers |
TRIG as a list: list(), count(), set(list), add(trigger, at?), replace(index, trigger), remove(indices), move(from, to), fromText(source, { replace? }). |
tx.briefing |
MBRF, the same shape. |
| Tables | |
|---|---|
tx.strings |
list(), intern(text) (an identical entry, else a new one; never overwrites, because the old index may be shared with a trigger), set(index, text) (overwrite one slot — everything pointing at it sees the new text; slot 0 is refused), apply(list) (a whole table; unreferenced trailing blanks are dropped, every other index keeps its place), import(text) (File ▸ Import ▸ Strings' index<TAB>text form, see api.exchange). |
tx.switches |
names() (256, "" where a switch has none) and setName(index, name); creates SWNM on the first name. |
tx.properties({ name?, description? }) |
SPRP. "" restores the file-name default. |
tx.note(text) |
A line for the status bar. |
| Settings | |
|---|---|
tx.players |
list() — the 12 slots as PlayerSlotViews (0-based slot, type / typeName, race / raceName, and for the eight playable slots color (COLR index), colorHex, rgb (the CRGB custom colour in effect, else null), force (0-based) / forceName) — and set(slot, { type?, race?, color?, rgb?, force? }). rgb: [r, g, b] sets a Remastered custom colour, rgb: null puts the slot back on its palette colour; CRGB is dropped again when every slot is. OWNR is always written with IOWN. |
tx.forces |
list() — four ForceViews (name, flags and the allied / alliedVictory / sharedVision / randomStart booleans, players: the 0-based slots in the force) — and set(force, { name?, allied?, alliedVictory?, sharedVision?, randomStart?, flags?, players? }); players moves those slots into the force. |
tx.unitTypes |
get(unitId) — a UnitTypeView with the effective numbers (units.dat's where the type is on "use default"; hit points in whole points), the type's weapons with their effective damage, defaults (the dat's numbers, null without the game data) and availability (PUNI: defaultAvailable and per player true / false / "default") — and set(unitId, patch). Setting any number turns "use default" off for the type and seeds its untouched columns from the dat, as the dialog does; useDefault: true puts it back; name is the custom name ("" restores the default, the string is interned); weapons: [{ id, damage?, bonus? }]; available: [{ player: 0-based or "default", value: true / false / "default" }]. Which of UNIS / UNIx is written follows the file's revision. |
tx.upgrades |
get(upgradeId) — an UpgradeView (effective costs and factors, defaults, levels: the default start and cap and each player's effective { start, max, usesDefault }) — and set(upgradeId, { useDefault?, mineralCost?, mineralFactor?, gasCost?, gasFactor?, timeCost?, timeFactor?, levels? }) with levels: [{ player: 0-based or "default", start?, max?, useDefault? }]. |
tx.techs |
get(techId) — a TechView (effective costs, defaults, state: the default column and each player's effective { available, researched, usesDefault }) — and set(techId, { useDefault?, mineralCost?, gasCost?, researchTime?, energyCost?, state? }) with state: [{ player, available?, researched?, useDefault? }]. |
tx.sounds |
list() — the WAV slots in use as SoundRows (slot, path, present, size, usedBy) — add(path, bytes?) (the first free slot, or the slot the path already has; with bytes the file goes into the archive under staredit\wav\) and remove(slot, deleteFile?). |
tx.cuwp |
Triggers ▸ Unit Properties Slots: list() / get(index) — CuwpSlotViews (0-based index; hitPointsPercent, shieldsPercent, energyPercent, resources, hangar as numbers or null where the created units keep the type's default; cloaked … invincible as booleans or null; used, references, summary) — set(index, patch, used?) (a number sets the field and its "applied" bit, null clears it; a boolean forces a state, null leaves it) and clear(index). The Create Unit with Properties action stores the slot 1-based in target. |
tx.setVersion(version, extendedStrings?) |
Scenario ▸ Map Revision: "original", "hybrid", "broodwar" or "remastered" — VER and TYPE, and the string table's width (STR ↔ STRx) when moving to or from Remastered. |
Ids are the game's: units.dat ids for unitTypes, upgrades.dat / techdata.dat ids for
the other two (api.names.units() / upgrades() / techs() list them with their
names). Players are 0-based here, as in the records; the chrome shows slot + 1.
const { condition, action, comparison, player } = api.consts.triggers;
api.document.update("Add a countdown", (tx) => {
const trigger = api.triggers.newTrigger([player.Player1]);
const timer = api.triggers.newCondition(condition.CountdownTimer);
timer.comparison = comparison.AtMost;
timer.amount = 30;
trigger.conditions[0] = timer;
const say = api.triggers.newAction(action.DisplayText);
say.text = tx.strings.intern("30 seconds remaining"); // interned above, readable here
trigger.actions[0] = say;
trigger.actions[1] = api.triggers.newAction(action.PreserveTrigger);
tx.triggers.add(trigger);
});
There is no undo entry, so a plugin that wants one keeps its own copy of what it replaced
(api.triggers.list() before, tx.triggers.set(...) to put it back).
api.settings#
The same views without a transaction, for reading: players() / player(slot),
forces(), unitType(id) / unitTypes() (every type with a name), upgrade(id) /
upgrades(), tech(id) / techs(), sounds(), unitAvailable(player, unitId) (PUNI
resolved against its default), cuwpSlots() / cuwpSlot(index), version()
({ version, label, fileVersion, type, extendedStrings, extension }). Empty lists and
nulls with no map. Writing goes through document.update.
api.triggers#
Reading triggers, and everything needed to show one. Writing is document.update.
list() / briefing() |
TRIG / MBRF, cloned. A record is 16 conditions and 64 actions of plain numbers — the editor's codec knows no types. |
defs |
What each type means: conditions(), condition(type), actions(briefing?), action(type, briefing?). Each def carries args, the argument list in the order StarEdit's TrigEdit shows it, each { kind, field, label } — which record field holds it and what kind of value it is. This is the table the editor's own trigger dialogs and the text printer read; a plugin that wants to render an editable trigger reads the same one. |
defs.choices(kind) / choiceLabel(kind, value) / choiceValue(kind, text) |
The values an enumerated argument can take (comparisons, switch states, resource types, orders …), with their labels and aliases. |
text.print(list, { briefing? }) / text.one(trigger) / text.parse(source) |
The text trigger format, resolved against the open map's names. parse throws a TriggerTextError carrying the line. |
names() |
The TriggerNames context those use: the map's locations, units, switches and strings, by name and by number. |
newTrigger(players?) / newCondition(type) / newAction(type, briefing?) |
Blank records with StarEdit's defaults. |
isPreserved(t) / setPreserved(t, on) |
The preserve-trigger flag. |
triggersFor(list, groups) |
Indices of the triggers any of those player groups own. |
summarize(t, briefing?) |
The three lines the trigger list shows: players, conditions, actions. |
comment(t) |
A trigger's Comment action text, if it has one. |
switchNames() / switchUsage() |
SWNM, and how many conditions and actions mention each switch. |
claim(spec) |
Tell the editor that a run of the trigger list is generated by this plugin. The Trigger Editor badges those rows (spec.badge, the plugin's id by default), locks them and shows spec.describe(index, list) with a button that calls spec.open(index, list) (spec.openLabel, Open <plugin name> by default) in place of the form; the Text Trigger Editor fences them in comments; Import Triggers says what a replace would remove. The run is found by content: spec.locate(list) is asked with whatever list an editor holds — the map's, or a working copy with local inserts in it — and answers { start, count } or null when the records are not there (edited by hand, or gone), so keep a hash of what you generated and look for it, as the Trigger Script plugin does. spec.label is the words a sentence uses ("the trigger script"). The handle has refresh() (after a rebuild, so editors ask locate again) and remove(); the claim leaves with the plugin. |
Generating triggers. There is no fluent builder here on purpose: tx.triggers.fromText
already is one, and it is a better one. A record is 16 conditions and 64 actions of bare numbers,
so building one field by field means knowing which field each argument lives in
(defs.action(type).args will tell you, but you have to ask); writing the trigger in the
text format instead means writing what the map maker would read in the Text Trigger
Editor, and getting the names resolved against the open map for free.
const source = `
Trigger("Player 1"){
Conditions:
Bring("Current Player", "Any unit", "Beacon Alpha", At least, 1);
Actions:
Display Text Message(Always Display, "You found it!");
Preserve Trigger();
}`;
api.document.update("Add the beacon trigger", (tx) => {
tx.triggers.fromText(source); // throws with the line number when it does not parse
});
fromText is the whole of it — it parses, interns the strings the text names, resolves
"Beacon Alpha" against the map's own locations, and appends (or replaces the list with
{ replace: true }). triggers.text.parse is the same parse without the write, for a
plugin that wants the records first; text.print goes back the other way, so a plugin can
read what it wrote. Reach for newTrigger / newCondition / newAction when you are
editing one field of an existing record, not when you are producing a run of them.
api.query#
Reading the open map: what is where, and the analyses the editor already does. Nothing here writes, and everything answers empty without a map.
unitAt(px, py) / spriteAt(px, py) / doodadAt(tx, ty) / locationAt(px, py) |
The topmost thing under a point, or -1 — the same hit-testing the layers use (a sprite's box comes from its loaded GRP, a unit's from units.dat). locationAt never picks Anywhere. |
unitsIn(rect) / spritesIn(rect) / locationsIn(rect) |
Units and sprites whose centre is in a tile rect; locations wholly inside it. |
unitsOf(owner) |
Every unit a player owns (0-based). |
startLocations() |
{ index, owner, x, y, tx, ty } per start location, by player. |
placement(unitId, x, y) |
The Units palette's verdict: { problem: "terrain" | "collision" | null, blocker, reason } — reason is the problem in words ("the ground is unwalkable", "it overlaps Terran Marine"), null when it fits. Null with no map. |
fogAt(tx, ty) |
The MASK bits at a tile (bit n = player n + 1 starts fogged; every bit when the map has no MASK). |
strings() |
The string table as it stands. |
validate() |
Check Map's Issue[] — { level, text, where, target? }, and target is what view.goTo takes. |
statistics() |
Tools ▸ Statistics: tile, terrain, unit, resource and per-player counts, the briefing's too. |
find(options) |
The Ctrl+F search: { kind: "units" | "locations" | "sprites" | "doodads" | "strings" | "triggers" | "briefing", query, matchCase?, limit? } → { kind, index, label, detail, x?, y? }[]. |
stringUsage() / unusedStrings() |
Which records refer to each string index, and which slots nothing refers to. |
A linter plugin is validate() plus find() plus view.goTo and nothing else.
api.view#
Where the viewport is looking. A plugin that finds something needs this to show the user where it is.
zoom() / setZoom(z) |
Clamped to 0.05…8 (the zoom control's own steps run 0.25…4). |
visible() |
The tiles on screen, as a Rect. |
center(x, y) |
Scroll so a tile is in the middle. |
goTo(target) |
{ kind: "tile", x, y }, or { kind: "unit" | "sprite" | "location", index } — scrolls there and selects the object. An Issue.target from query.validate() is one of these. |
cursorTile() |
The tile under the pointer, as the status bar shows it. |
flags() / setFlags(patch) |
The View menu's ticks: grid, locations, locationNames, units, sprites, doodads, fog, elevation, buildability, startLocations, animateWater, animateUnits. |
gridSize() / setGridSize(8 | 16 | 32 | 64 | 128) |
Grid spacing in map pixels. |
api.data#
The game's own tables as the editor decoded them — units.dat and its neighbours — for
the numbers api.names only labels: hit points, costs, build times, armour, weapons,
flags, the sprite and image each unit draws through. ready(), load(), then units(),
weapons(), upgrades(), techs(), sprites(), flingy(), images(), plus
race(unitId) and imagePath(imageId). Everything is null until the tables are loaded,
and stays null when the game data was never extracted — degrade, do not throw.
api.consts#
The numbers a record is written in, so a plugin does not carry the hex itself. These are the editor's own tables — the very objects its codec encodes with, handed over at run time, not copies that can drift from them.
tile |
32 — map pixels to a tile. UNIT and THG2 store pixels; MTXM, MRGN and the brushes count tiles. |
unit.startLocation |
214, the Start Location marker. |
unit.mineralFields / unit.vespeneGeyser |
[176, 177, 178] and 188. isResource(unitId) is either. |
unit.defaultMinerals / unit.defaultGas |
1500 and 5000, what StarEdit writes on a fresh resource. |
unit.valid / unit.used / unit.state / unit.relation |
The four UNIT bit masks: validProperties (which special-property fields the game reads), validStates (which of the record's fields are set at all), stateFlags (the properties themselves), relationType (NydusLink, Addon). |
sprite.flags |
THG2's PureSprite / Flipped / Disabled. PureSprite is the one that decides whether spriteId is a sprites.dat id the game only draws, or a units.dat id it creates the unit for. |
location.anywhere |
63. That slot is Anywhere and the editor protects it everywhere — no builder returns it, locationAt never picks it, the viewport draws no box for it. tx.restoreAnywhere() is what puts it back. |
location.elevation |
elevationFlags. A set bit excludes that elevation, so 0 means everywhere. |
triggers |
The numbers a TRIG / MBRF record is written in — see below. |
consts.triggers. A trigger record is sixteen conditions and sixty-four actions of
plain numbers, and triggers.defs only says which field each argument lives in. This
says what to put in it: condition and action (and briefingAction, where the same
byte means something else) are the type numbers, player the 27 player-group values —
which are also the indices of a trigger's own players array — and the rest are the
enumerated arguments: comparison, switchState, switchAction, modifier,
unitState (Set Doodad State / Set Invincibility), order, alliance, resource,
score, and unitClass for the four ids past units.dat (Any unit, Men, Buildings,
Factories). conditionFlags, actionFlags and triggerFlags are the flag bits, and
deathsTable is the address an EUD player value is counted from
(epd = (address - deathsTable) / 4 + 0x2000).
Those argument keys are ArgDef.kind, so a generic argument editor can look one up with
the kind the def handed it:
const arg = api.triggers.defs.action(record.type).args[0];
const values = api.consts.triggers[arg.kind]; // e.g. { AtLeast: 0, AtMost: 1, Exactly: 10 }
For generating a run of triggers, tx.triggers.fromText is still the better tool — it
resolves location and unit names against the open map, which no constant can. These are
for editing a field of an existing record, and for reading one back
(record.type === api.consts.triggers.condition.Bring).
Why this is on api and not in the npm package: @scm-js/plugin-api is types only, and
import type is erased before the loader sees the specifier — which is exactly what lets
a plugin depend on a package at all. A value imported from it type-checks and is then
undefined at run time. Anything you need while the plugin runs has to arrive on api.
api.graphics#
The pictures the viewport draws, for a plugin's own lists and previews. Nothing is rendered anew: a unit or sprite frame comes out of the same cache the viewport blits from, so listing five hundred units costs about what the Units palette costs.
ready() / load() |
{ tileset, units } — whether the graphics and the tables are in memory, and a fetch for both. |
unitImage(unitId, { owner? }) |
A { image, width, height } canvas in the player's colours, in the unit's editor pose. |
spriteImage(kind, id, { owner?, flipped? }) |
The same for a THG2 sprite. |
tileImage(tileId) |
One 32 × 32 megatile of the open map's tileset. |
doodadImage(doodadId) |
A doodad drawn from the tiles it stamps. |
renderRect(rect, options?) |
Part of the map as File ▸ Export ▸ Image draws it, cropped to a tile rect, as a PNG Blob. pixelsPerTile defaults to 8 here. |
playerColor(owner) |
#rrggbb. |
requestUnit(id) / requestSprite(kind, id) / onImageLoaded(fn) |
GRPs load lazily, so the first unitImage for a type is often null: ask for it, redraw on onImageLoaded, and the list fills in. |
api.commands#
Named things a plugin can do, so a menu item, a hotkey, a context entry and another plugin all reach the same one.
api.commands.register({ id: "convert", title: "Convert Image…", run: () => open() });
api.menu.add("Tools", { label: "Convert Image…", command: "convert" });
api.hotkeys.add("Ctrl+Shift+I", { command: "convert" });
register(spec) returns a Disposable; run(id, ...args) runs one, whoever registered
it (undefined when there is no such command or its enabled() says no); has(id) and
list() ({ id, title, pluginId, enabled }[]) see every plugin's. An id without a dot is
namespaced under the plugin ("convert" → "image-to-terrain.convert"); one with a dot
is taken as it is, so a plugin can publish a stable name for others to call.
api.terrain#
Read-only helpers over the current tileset: types() (paintable flat terrains with
name, group, height, buildable), isomTypes() (ids the isometric brush can paint),
hasIsom(), tileInfo(id); terrainAt(tx, ty) — the terrain id (as types() lists them) a tile belongs to: its own group when it is flat ground, else what the ISOM lattice says there (under a cliff, one of the two terrains it joins), null when neither tells, color(tileId) (the atlas average, 0xRRGGBB),
terrainColor(terrainId) (mean of the pair's common variations), heightOf(terrainId)
(0 low / 1 high / 2 higher, null for anything that is not a flat terrain), diamondAt(px, py),
isDiamond(d), diamondsIn(rect) (every lattice diamond whose centre tile is in the
rect), floodRegion(x, y, match?) (the bucket fill's area, by terrain type or exact
tile), blendCandidates(anchorTileId, side, options?) (the Blend palette's ranked list,
with the pixel distance of each seam), flatGroupOf(terrainId) (the even CV5 group of a
flat pair), active() / setActive(...) for the palette's brush, terrain, tile, size and
Rect variation, and the symmetry mode: symmetry() / setSymmetry(mode) ("none",
"h", "v", "hv", "rot180", "rot90", "diag", "adiag"),
symmetryAvailable(mode) (the last three need a square map), mirror(cells) and
mirrorPoint(px, py) — the images the built-in brushes paint and the palettes place on,
so a plugin edit can honour the user's setting the way tx.fillArea does by itself.
checkIsom() is asynchronous: it waits for the tileset graphics (rejecting when they are
missing) and resolves with how well the ISOM describes the tiles — rects measured,
mismatched among them, stale when the share is past what the palette warns at — or
null when the map has no ISOM or no map is open.
api.tileset#
id(), name(), isLoaded(), load() (resolves false when the graphics were never
extracted — that is a normal state, degrade), raw() for the decoded LoadedTileset.
api.selection#
markedArea() / markArea(rect | null) — the Cut / Copy / Paste layer's marked
rectangle, the editor's one "region" concept; units(), sprites(), doodads(),
locations() (indices, copied — sort yours freely), each with a setter
(setUnits, setSprites, setDoodads, setLocations); layer() /
setLayer(); lockedLayers() / setLayerLocked(layer, on) for the Layers panel's
padlocks (a locked layer's tools refuse to change the map).
api.clipboard#
The Cut / Copy / Paste layer, sharing the user's own clip: clip() / setClip(clip | null); copy(source?) and cut(source?), where source is { rect } for a tile rect
or { units?, sprites?, doodads?, locations? } for objects by index — omitted, they take
what Ctrl+C would: the object layer's selection, else the marked area — with the parts
ticked in parts(); paste(tx, ty, { parts?, mode? }), the clip's top-left at a tile, one
undo step, the pasted area marked afterwards, returning the PasteResult (counts per
list, notes for what was skipped); parts() / setParts(patch), mode() / setMode("merge" | "replace"), pasting() / setPasting(on) (arm the layer so the next click stamps),
and summary(clip). A Clip is self-contained — it outlives the map it came from and
pastes into another, with terrain and doodads refused across tilesets.
api.exchange#
The file formats behind File ▸ Import / Export: encodeTrg(triggers) / decodeTrg(bytes)
for SCMDraft's raw .trg (2400-byte records; string indices are the map's own), and
formatStrings() / parseStrings(text) for the index<TAB>text strings file (control
bytes as <XX>), which tx.strings.import applies.
api.palette#
What the Units, Sprites, Doodads and Fog of War palettes have picked, and what they list —
so a plugin can paint "whatever the user chose" without a picker of its own (Paint does
exactly this: switch layers and its brush follows). The Terrain palette's pick is
terrain.active().
active() / setActive({...}) |
A PaletteChoice: unit and owner (0-based; 0 is Player 1), spriteKind with sprite / unitSprite, spriteFlipped / spriteDisabled, doodad (-1 before one was picked), fogPlayers (a bit mask, bit n = player n + 1), fogMode and fogViewPlayer (whose fog the viewport draws). |
placementOptions() / setPlacementOptions(patch) |
The Units palette's rules — checkCollision, checkTerrain, snapToGrid, removeStranded — which govern placeUnit, canPlaceUnit, query.placement and whether an edit removes stranded units. Remembered in the browser (scmjs.placement), so a change outlives the session. |
doodadPlacement() / setDoodadPlacement(patch) |
The Doodads palette's placeAnywhere and snapToGrid (the two-tile isometric grid, never View ▸ Grid Settings' spacing). Remembered in the browser (scmjs.doodadPlacement). |
locationSnap() / setLocationSnap(step) |
The Locations layer's snap step in pixels (0 off, 8, 16, 32, 64). |
playerColor(owner) |
The colour a player's units are shown in, #rrggbb — Remastered custom colours included. |
unitGroups() / unitName(id) / unitSize(id) |
The Units palette's grouping, StarEdit's names, and a type's placement box in pixels with building / flyer flags (a one-tile box without the unit tables). |
spriteGroups() / spriteName(kind, id) |
The Sprites palette's groups (empty until the unit tables are loaded) and names. |
doodadCategories() / doodadInfo(id) |
The open map's doodads by category, each with its footprint in tiles (empty without the tileset graphics). |
api.names#
The names behind the numbers a map stores, so a plugin that shows raw values need not
carry the game's tables: unit(id) / units() (StarEdit's names, plus Any unit, Men,
Buildings, Factories for the trigger classes 228–231), upgrade / upgrades, tech
/ techs, weapon / weapons, playerType / playerTypes (OWNR controllers), race /
races (SIDE), playerGroup / playerGroups (the 27 trigger groups), condition /
conditions and action(type, briefing?) / actions(briefing?) (trigger and briefing
types), aiScript(code). The list forms return { value, label }[] for a drop-down. The
per-map ones read the open scenario and answer a placeholder without one: string(index)
(null for 0 or out of range), location(index) (0-based slot; 63 is Anywhere),
switch(index), player(slot), and tile(id) — the terrain a MTXM id belongs to, null
without the tileset graphics.
api.text#
StarCraft's <XX> text control codes — bytes 0x01–0x1F in a string, which set the colour,
move the text or hide it. This is the editor's own table, the one the String Editor's
buttons and preview are drawn from, so a plugin that shows or rewrites map text carries no
copy of its own. Worth using rather than reimplementing: the numbering is easy to get
wrong, and the editor's own table was wrong from 0x12 up until it was checked against the
classic player palette.
codes() / code(byte) |
Every byte the game gives a meaning, in order, or one of them (null for a byte it ignores). A TextCode is { byte, code, label, effect, rgb, player? } — effect is "color", "mimic", "invisible", "align", "clip", "nothing" or "space", and rgb is #rrggbb for the colours and null for the rest. The twelve that are a player colour carry player. |
insertable() |
The codes worth offering as buttons: everything but tab, the newlines and the byte that does nothing. |
defaultColor() |
What the game starts a string in. |
escape(byte) |
<0E>, the way every StarCraft editor writes a control byte. |
runs(text, options?) |
The string split into lines of coloured runs, the way the game draws it: TextLine { runs, align }, TextRun { text, color, invisible, clipped }. invisible marks what an <0B> / <14> hides rather than dropping it, clipped what an <0C> cut off, and align reads <12> / <13>. |
plain(text) |
The text with every control byte removed — what the string actually says. |
bleedingLines(text) / fixBleeding(text) |
See below. |
The Remastered newline change. StarCraft 1.16.1 reset the text colour at every line
break; Remastered carries it onto the next line of the same string. So a multi-line string
written before the remaster — most map descriptions, objectives and briefing text — can be
drawn today in colours its author never chose. runs models Remastered's rule; pass
{ resetPerLine: true } to see the old rendering. bleedingLines(text) returns the lines
that differ ({ line, carried }, carried being the whole TextCode inherited), and
fixBleeding(text) writes the default colour at the head of each of them so both games
draw the string alike — idempotent, and it never changes what the string says. The Repair
plugin's string finding is exactly these two functions over api.query.strings().
Text stacking — the 1.16.1 trick of drawing lines on top of each other — is a different thing, and there is nothing here for it: Remastered does not render the overlap at all, and the intended picture was the overlap, so there is nothing to restore it to.
api.ui#
status(text) / statusText() |
The status bar. |
toast({ kind?, title, detail?, ttl? }) |
A notice over the map that leaves by itself — how Save reports ("ok", "info", "warn", "error"; ttl 0 keeps it until dismissed). |
saveFile(data, fileName) |
Write bytes or a Blob to disk the way the editor's own exports do: through the browser's save dialog where it has one, else as a download. Resolves { route, fileName }, or null when dismissed. |
dialog(spec) |
Opens a dialog in the editor's chrome. spec.mount(body, handle) is called with an empty <div> inside the dialog body; return a cleanup function if you need one. spec.buttons draws the footer ({ label, primary?, run?(handle), closes? }); default is a single Close. spec.onPaste(transfer, handle) fires for Ctrl+V anywhere in the dialog while it is the topmost one (a paste into one of your own text fields is left alone unless it carries files), spec.onDrop for a drop on the body; a DialogTransfer is { files, text }. Escape closes the dialog unless spec.keepOpenOnEscape(target) answers true for the element the key landed on — for something inside that handles Escape itself, such as a code editor dismissing its own popups. Returns a handle with close(), isOpen() and setTitle(text). |
panel(spec) |
A panel that floats over the map and blocks nothing: the user keeps drawing, scrolling and using hotkeys while it is open (except while typing in one of its fields). spec.mount(body, handle) fills an empty <div> as a dialog's does; width is in CSS pixels (260 by default) and the panel is as tall as its content; onClose fires however it closes. The user drags it by its title bar and closes it with the ×; it opens at the top-right of the map and remembers where it was left for the session. The handle has close(), isOpen(), setTitle(). Open as many as you like; they all close with the plugin. |
mapTool(spec) |
Take over the pointer on the map. The viewport hands the tool every press, move and release ahead of the active layer's own tools (onDown / onMove / onUp, each with a MapPointer: map pixels, the tile, inMap, down, and the modifier keys — kept inside the map while a button is held, as the built-in brushes do), hides the layer's brush ghost, shows name and hint in the HUD, and calls draw(ctx, view) last on every repaint so the tool can preview what it will do (view.x(px) / view.y(py) map to canvas pixels; view.tilePx, view.zoom, view.visible). handle.redraw() repaints now; call it from onMove. Esc or a right-click calls onCancel — return true to keep running (you dropped a gesture of your own), otherwise the tool stops — and onStop(reason) is told once whichever way it ends: "stopped" (your stop()), "cancelled", "document" (the map closed or changed), "replaced" (another tool started; one runs at a time), "disabled". A pickArea / pickTile in progress is served first. Paint is the worked example. |
overlay(spec) |
A picture over the map the user can switch on and off, and that stays while they work on any layer: it is listed under View (after the built-in overlays) and in the Layers panel with an eye of its own. draw(ctx, view) runs at every repaint while visible, at the slot above names — "terrain" (under doodad footprints, units, sprites and locations; the default), "objects" (under fog of war) or "everything" (under a running map tool's drawing only) — with the same MapView a map tool gets. onHover(p) hears the pointer on every layer, and while a map tool runs, with null once when it leaves the map; the overlay never takes the pointer, so clicks go to the active layer's tools. onToggle(visible) fires whichever way it was switched. The handle has show(), hide(), toggle(), isVisible(), redraw() and remove(). visible is the starting state (true by default); what the user last set an overlay of that name to wins for the session, so a reloaded plugin comes back as it was left. Register at activation and keep the handle; the overlay leaves with the plugin. Walkability is the worked example. |
pickFiles({ accept, multiple }) |
The file picker, resolved with File[] (empty on cancel). |
pickArea({ prompt }) |
The user drags a rectangle on the map: the viewport shows a crosshair and a teal marquee, the HUD shows your prompt, and the gesture goes to you ahead of the active layer's tools. Resolves with the tile Rect (exclusive x1 / y1), or null on Esc / right-click, when no map is open, when the map is replaced meanwhile, or when the plugin is disabled. One pick at a time — starting another cancels the first. A dialog is modal and covers the map, so close yours before picking and reopen it with the result (Terrain from Image does exactly this: Pick on Map…). |
pickTile({ prompt }) |
The same for a single click; resolves with { x, y }. |
loadImage(source) |
Decode a File / Blob, a data: URL or an http(s) URL into an ImageBitmap. A remote URL is fetched with CORS and, failing that, loaded through an <img crossOrigin>; a site that allows neither rejects with a message that says to save the picture and choose the file. |
readClipboardImage() |
The picture on the system clipboard as a Blob (the browser may ask permission), or null. For Ctrl+V use onPaste instead — it needs no permission. |
confirm(message, opts?) / alert(message, opts?) / prompt(message, opts?) |
A yes/no, a note, and a line of text, as dialogs in the editor's chrome rather than the browser's blocking boxes. confirm resolves false and prompt null on Cancel, Escape or the ×. Options: title, confirmLabel, cancelLabel, danger (a destructive primary button), and for prompt also value, placeholder, multiline. |
progress(label, { title?, cancellable? }) |
A progress panel over the map for long work — it blocks nothing, so report often: report(0…1, text?), cancelled() (check it in your loop; the × counts as cancelling, done() does not), signal (the same answer as an AbortSignal, for fetch and anything else that takes one), done(), isOpen(). A modal dialog covers the map and dims the panel behind it, so start the work from a panel, a menu item, or after closing your dialog. |
el(tag, props?, ...children) |
The DOM helper the widgets are built from: style takes an object, on* keys take listeners, everything else is a property or an attribute. |
widgets |
Buttons, fields, forms and lists in the editor's own styles, as plain DOM: button(label, { primary, danger, ghost, onClick }), checkbox(label, { value, radio, name, onChange }) (the <label> carries its input), text(...), number({ min, max, step, ... }), select(items, ...), form(rows) (a two-column grid of { label, field }), group(title, ...children), row(...), column(...), hint(text), separator(), list(items, { selected, height, onPick }). Use them and a plugin's dialog looks like a built-in one; el is the escape hatch. |
open(dialogId, payload?) |
Any built-in dialog ("mapProperties", "unitSettings", …), fire and forget. |
ask(dialogId, payload?) |
A built-in dialog that answers — "saveAs", "confirmClose", "newMap" — resolving true when it went through, false when it was dismissed. |
repaint() |
Redraw the viewport when you changed something the transaction did not cover (an overlay's picture, say). Raises no event. |
api.menu / api.contextMenu / api.hotkeys#
menu.add(path, item):pathis a top-level menu ("File","Edit","View","Layer","Scenario","Triggers","Tools","Plugins","Help") or a submenu by label ("File/Import"). Plugin items appear after a separator at the end of that menu, unlessafternames a built-in item or submenu (after: "Open Recent"), in which case the item sits directly under it. A last segment that names no submenu gets one of the plugin's own at the end of the menu ("Tools/AI"), so a plugin with many items can keep them together;separator: trueon an item draws a line above it (never two in a row).itemis{ label, shortcut?, icon?, after?, enabled?(), run() }.iconputs a mark in front of the label:"plugin"for the plugin's own icon (the manifest's), or anyPluginIcon— use it for items that do something no built-in does, such as reaching a server, so the user can tell at a glance which entries are the plugin's.contextMenu.add(surface, item): surfaces are"viewport"(the map) and"terrainPalette".run(ctx),enabled?(ctx)andvisible?(ctx)get aContextMenuContext: the tile and pixel under the pointer (viewport), the active layer, terrain mode and terrain, and the marked area.hotkeys.add("Ctrl+Shift+I", run): modifiers in any order, then a key name. Plugin hotkeys are checked before the built-ins and never while typing in a field or while a dialog is open.- All three take
{ command: "id" }(orcommand:on the item) instead of arunof their own — seeapi.commands. A context item's command is called with theContextMenuContextas its argument.
api.events#
on(event, fn) for "document" (opened, closed, replaced), "terrain" (every committed
edit, stroke, undo and redo bumps it, terrain or not — fog edits included — so it is the
"something changed on the map" event), "units", "sprites" (the doodads revision,
which THG2 records ride on), "doodads", "locations", "settings" (every settings
dialog's OK, Map Properties included), "triggers", "layer", "selection",
"clipboard" (the marked area or the clip), "view" (scrolled, zoomed, a View tick
moved, or an overlay registered or toggled), "tool" (a map tool or pick started or
stopped), "modified" (the unsaved-changes flag), "palette" (a palette's pick
changed: terrain brush, unit and owner, sprite, doodad, fog players), "options" (an
editing option moved: symmetry, placement and doodad rules, location snap, the fog view
player, clip parts and paste mode, locked layers, the grid look, Preferences), "file"
(the document's name or handle after a Save, its save options, the archive extras, the
recent list) and "commands" (a plugin registered or removed a command — how a plugin
that calls another's by id learns it has arrived, since plugins activate in no fixed
order; check commands.has in the listener).
The "document" listener is handed a DocumentEvent: reason is "open" (File ▸ Open,
a drop, document.open from any plugin), "new" (File ▸ New, the startup map included),
"close", or "replace" (the open map parsed again from edited bytes — a
document.sections write, by any plugin, yours included), and fileName is the file's
name or null. A plugin that acts on maps as they open listens for "open" and lets the
rest pass; the other events carry nothing.
Listeners are notifications, not a pipeline: they run after the change, in the order the
plugins were activated, and cannot veto, delay or reorder one another. There is no plugin
ordering and none is planned — a listener that rewrites the map in response (Repair does,
through document.sections) simply raises a fresh "document" event with reason
"replace", which every other listener sees in turn, so whatever a plugin computed from
the earlier state is recomputed from the later one.
api.storage#
get(key, fallback), set(key, value), remove(key): JSON in localStorage under a
per-plugin prefix (scmjs.plugin.<id>.). Safe when storage is unavailable (falls back to
memory). The user can see and throw it away — Preferences ▸ General ▸ Browser storage lists
your keys as one row under your plugin's id, opening onto the values, with a Clear button of
its own, and Clear all data sweeps every scmjs. key — so treat what you store as a
convenience, never as the only copy of something, and keep it small and readable.
api.plugin, api.apiVersion, api.log(...)#
Who you are (id, name, source), which API you got, and a console logger with the
plugin's name prefixed.