07-modding-content.md · 138 requirements

07 — Modding & Content Pipeline

This document specifies Every Last City's content and modding system: the mod package format and manifest; semantic versioning and dependency resolution; the content-identity hashes that make a multiplayer game verifiable; the data formats for unit sets, terrain sets, maps, scenarios, rule presets, command libraries and victory Terms; asset packs and localization; the sandboxed WASM plugin ABI for AI players and map generators; the map/scenario editor; and the in-game workshop that distributes all of it. Its organising claim is a boundary: content that is data is safe to load from anywhere and cannot desync a game, and content that is code runs behind an explicit trust decision in a sandbox. Everything in this document is an argument about which side of that line a capability belongs on, and about making the data side wide enough that almost nothing needs the other one.

Status: Draft v0.2 · Owner: unassigned · Depends on: docs/design/00-direction.md, docs/design/01-decision-turn-model.md, 01-game-rules.md (map, terrain, Installations, setup parameters, generation invariants), 02-units-and-industry.md (the unit definition schema, the capability-flag vocabulary, the Industry ladder, unit-set validation), 03-architecture.md (determinism, canonical hashing, the WASM plugin host, PRNG streams), 04-ui-ux.md (presentation of the editors and the setup screen), 05-multiplayer.md (content pinning and distribution), 06-ai.md (the AI plugin contract), 08-services-platform.md (workshop hosting and quotas), 10-turn-model.md (orders as data, the Orders phase), 11-combat.md (the win predictor the editor displays), 12-economy.md (city classes and traits), 13-command.md (Postures, Sanctions, Doctrines), 14-victory.md (the Track catalogue and the Terms)

Replacement notice, binding. This document previously specified a content format transcribed from another game's unit database, and an importer that read that game's data files. Both are deleted in full under docs/design/00-direction.md §2. Section 10 (EDCE migration and importers) and its requirements MOD-920, MOD-930, MOD-940, MOD-950, MOD-960, MOD-970 and MOD-980 are retired. Those identifiers MUST NOT be reused for new requirements, because a reused number silently redirects an existing citation rather than breaking it. 06-ai.md AI-265 and its §5 float-discipline paragraph both cite MOD-980 and are now stale; MOD-1290 records the obligation.

The prior draft also carried a legacyKey field, an x-edce extension block, a per-target Combat Mod matrix, a per-unit terrain-cost map, four named built-in unit sets, and a "Classic mode" that pinned certified content hashes. None of those correspond to anything in the current specification set and all are gone. Where a requirement number survives, its content has been rewritten in place against 01-game-rules.md, 02-units-and-industry.md, 10-turn-model.md, 13-command.md and 14-victory.md.


1. Content model and mod packages

MOD-010 All game content — unit sets, terrain sets, rule presets, asset packs (graphics, audio, name lists), localization packs, maps, scenarios, command libraries, victory Terms presets, AI players and map generators — MUST be delivered as mod packages in a single documented format. There MUST be no "magic directory" installation mechanism, no loose-file drop location, and no content path that bypasses validation.

Rationale: one pipeline is what buys validation, discoverability, hot-loading, an uninstall that actually removes something, and — the load-bearing one — a content hash a lobby can compare. Loose files give none of those, and every failure they produce arrives mid-game rather than at install.

MOD-020 A mod package MUST be a ZIP archive with the extension .elcmod, containing a manifest.json at the archive root plus content files in documented subpaths. All package formats, JSON schemas, and the plugin ABI MUST be published in the project's public documentation; a package MUST be creatable and readable with standard tools outside the game. Archive entries MUST use only the store and deflate compression methods and MUST NOT be encrypted; Zip64 extensions MUST NOT be required by a conforming package.

Rationale: a browser client reads the archive in JavaScript over the platform's own DecompressionStream. Every method beyond store/deflate obliges the client to ship an additional decoder for content it has not yet verified, which is both weight and attack surface; the MOD-885 caps keep conforming packages well inside the Zip64 threshold.

MOD-030 manifest.json MUST contain at minimum the following fields:

Field Type Requirement
manifestVersion integer Format version of the manifest itself; readers MUST reject unknown major versions with a clear message.
id string Globally unique, stable package identity: reverse-DNS style (com.author.packname) or UUID. MUST never change across versions.
name string (localizable key permitted) Display name.
version string Semantic version (MAJOR.MINOR.PATCH per SemVer 2.0.0).
authors array of strings Author display names.
license string SPDX identifier or custom with a licenseFile path.
description string Short description (localizable).
engineApi string SemVer range of the content/plugin API the package targets; the client MUST refuse to load packages outside its supported range with a human-readable error.
content object Declares the content items in the package by type (see MOD-040).
dependencies array Each entry {id, version} with version a SemVer range (see MOD-050).
tags array of strings Free-form discovery tags.
icon string path Optional PNG/SVG icon.

MOD-040 The content object MUST enumerate the package's items under typed keys, each item naming its root file inside the archive. The complete set of content types, the section that owns each format, and its classification under MOD-150, MUST be exactly:

Key Format owned by Classification
unitSets §3 sim-affecting
terrainSets §4 sim-affecting
maps, scenarios §9 sim-affecting
rulePresets §11 sim-affecting
plugins (with kindai | worldgen) §8 sim-affecting
commandLibraries §11 sim-affecting
victoryTerms §11 sim-affecting
assetPacks §6 presentation-only
localization §7 presentation-only
nameLists §6 presentation-only
encyclopedias §3 (MOD-295) presentation-only

A package MAY contain multiple items and multiple types — a unit set plus its asset pack plus a demo scenario in one package is the expected shape. A content type not on this list MUST be rejected at validation rather than ignored.

MOD-050 Dependency resolution MUST use SemVer ranges. The client MUST refuse to enable a package whose dependencies are missing or version-incompatible, listing exactly what is missing and offering one-click resolution via the workshop when online (MOD-860). Circular dependencies MUST be rejected at install time.

MOD-060 The shipped unit set (02-units-and-industry.md §8), the shipped terrain set (01-game-rules.md §3), the stock rule presets, the stock victory Terms presets (14-victory.md §12.5), the stock asset packs, the stock AI plugins and the stock map generators MUST ship as ordinary read-only mod packages conforming to this specification — data, not code paths. Built-in packages are certified: their content hashes (MOD-140) are pinned per client release.

Rationale: this is the only honest way to prove the formats are sufficient. If the game's own content needed one private code path, every modder's content would need it and would not have it — which is the same argument 02-units-and-industry.md US-2070 makes about the flag vocabulary, and it is right for the same reason.

MOD-070 The client MUST provide an in-game Mod Manager that can browse installed packages, install from file or workshop, enable/disable per package, update, and uninstall — without restarting the application. Per-game content selection happens at game setup; the Mod Manager governs what is available to select.

MOD-080 Package installation MUST validate the package against the published schemas before activation and MUST report failures as human-readable, itemized errors identifying file, field, and reason. A malformed package MUST never crash the client or abort application startup. Validation MUST report every failure in one pass rather than stopping at the first, matching the obligation 02-units-and-industry.md US-800 places on the unit-set validator.

MOD-085 Archive intake limits. Reading a .elcmod MUST be bounded before any content is materialized. The reader MUST run off the main thread — a Web Worker in a browser client, a worker_thread in Node — so that a hostile or merely enormous archive cannot freeze the UI or stall a running game, and MUST enforce a total-decompressed ceiling, a per-entry decompressed ceiling, an entry-count ceiling, and a decompression-ratio guard, defaulting to the corresponding MOD-885 figures and configurable downward per platform. Exceeding any ceiling MUST abort with an itemized error (MOD-080), never with an out-of-memory failure, and MUST leave no partially installed package. Entry names MUST be relative POSIX paths whose segments match MOD-120's character rules; absolute paths, drive letters, .. segments, symlinks, and entries colliding after case folding MUST be rejected.

Rationale: MOD-885's caps are a publication control and explicitly do not apply to sideloading (MOD-900) — which is precisely the path an attacker controls, and the one a browser client exposes to any file a user is talked into dropping on it. On a desktop build an over-large extraction fails a disk write; in a browser tab it kills the tab and takes the running game with it.

MOD-090 Content hot-loading: data and asset changes to an enabled package MUST take effect without an application restart, and in a browser client without a page reload. Data that affects a running simulation — anything MOD-040 classifies sim-affecting — MUST NOT change mid-game; such changes take effect for the next game created. Presentation-only changes MAY apply immediately to a running game.

MOD-095 Untrusted content isolation. A mod package MUST NOT be able to introduce executable web content into the client. Package files MUST NOT be loaded as HTML documents, scripts, stylesheets, or module URLs; manifest.json, content JSON, and localization files are parsed as data only. SVG assets (MOD-410) MUST be sanitized at install — scripting, external references, and foreignObject stripped — and rasterized through a non-executing path (an image decode from a blob, never markup injected into the document). The client MUST ship a Content-Security-Policy that denies unsafe-eval and confines script-src to the application's own bundle, and MUST render every content-supplied string as text rather than markup (MOD-520). WebAssembly under §8 is the only executable content a package may carry, and it runs under MOD-590's sandbox with none of the ambient authority the page itself holds.

Rationale: this is the largest attack surface a web client introduces. In a DOM application, content that reaches an innerHTML or a script URL runs with the client's own origin privileges — the player's session, their saved games, their account token. A rule is required; good intentions in an asset loader are not.

MOD-100 When multiple enabled packages supply the same asset key (MOD-460) or localization key, the client MUST resolve the conflict by a user-orderable package priority list, deterministic and persisted per profile. Sim-affecting content MUST NOT silently override by priority: each is selected explicitly by identity at game setup.

MOD-110 The content location MUST be surfaced honestly, per platform. On builds that have a filesystem (Tauri desktop and mobile, self-hosted servers, the CLI) the user content directory MUST be a normal in-game setting, and the Mod Manager MUST display the resolved content locations and offer "open in file manager". A browser client has no such directory and MUST NOT pretend otherwise: there the Mod Manager MUST show the origin-private store of MOD-115 instead — its usage against the browser's quota, whether persistent storage was granted, a control to export any installed package back out as a .elcmod file (MOD-890), and a control to clear the store. Display and graphics settings belong to the normal settings UI (04-ui-ux.md).

MOD-115 Where installed packages live. Installed packages MUST be held in a store the client owns, and the Mod Manager MUST show which store is in use:

Platform Store
Browser Origin-private storage — the Origin Private File System where available, otherwise IndexedDB — holding the original .elcmod bytes plus the derived bundle of MOD-505. There is no user-visible directory and no user-editable file.
Tauri desktop/mobile, self-hosted server, CLI An ordinary content directory on the filesystem, located per MOD-110.

Origin-private storage is evictable, so a browser client MUST request persistent storage, MUST treat eviction as an expected condition rather than corruption, and MUST keep the installed-package record(id, version, packageHash, simHash, source) — in the profile rather than in the evicted store, so that a missing package can be re-fetched from its origin (MOD-125) or re-requested from the user (MOD-900) without losing what was installed. Quota exhaustion MUST surface as an itemized, actionable error naming what could not be stored (MOD-080). A package that is evicted while a game using it is in progress MUST be re-fetched, or the game MUST stop with a clear error naming the package — it MUST NOT continue with substituted or absent content.

MOD-120 All content keys and package ids MUST be compared case-sensitively by the engine, and package validation MUST reject any two keys within one scope that differ only by letter case. Identifiers MUST match the pattern that owns them:

Identifier Pattern Owner
Unit definition id, setId ^[a-z][a-z0-9_]{1,31}$ 02-units-and-industry.md US-070
Terrain type id, terrain set id ^[a-z][a-z0-9_]{1,31}$ §4, by analogy with US-070
Package id reverse-DNS or UUID MOD-030
Asset key, localization key, preset key [A-Za-z0-9_.-]{1,64} this document

Rationale: case-insensitive collision rejection removes a class of bug that only appears on someone else's filesystem. Adopting US-070's pattern verbatim for unit ids, rather than declaring a looser one here, means a set that validates against the published JSON Schema also passes the core's own US-720 check — two validators that can disagree is the defect this table exists to prevent.

MOD-125 Delivery to a browser client. A browser client fetches every package over HTTP(S) under the origin policy and can hold none of it on a filesystem, so:


2. Content identity and the compatibility hash

MOD-130 Every package version MUST be identified by the triple (id, version, contentHash). Published versions are immutable: re-publishing changed content under the same version MUST be rejected by the workshop (MOD-840) and detected locally by hash mismatch.

MOD-140 contentHash MUST be a SHA-256 digest computed over a canonical serialization of the package's content: JSON documents canonicalized per the rules in 03-architecture.md (sorted keys, normalized numbers, UTF-8), binary assets hashed raw, combined as a Merkle tree over sorted paths. The hash MUST be reproducible by third-party tools from the documented algorithm. Two hashes MUST be computed and recorded: the package hash (everything) and the sim hash (sim-affecting content only, per MOD-150).

Canonicalization MUST operate on the document's own textual form and on exact integer values; a parse-and-re-serialize round trip through the host language's native number type MUST NOT be used to produce it. TypeScript has exactly one numeric type (IEEE-754 float64), so that round trip silently reformats numeric literals and cannot represent integers beyond 2^53 exactly — two clients would then compute different hashes for identical bytes, or identical hashes for different content. The canonical number grammar MUST be published, and a package containing a number that grammar cannot represent exactly MUST fail validation (MOD-080) rather than be hashed approximately.

Rationale: the sim-affecting content this document carries is integer throughout — 02's schema (US-030), 01's terrain attributes (GR-350), 12's per-mille traits, 14's Track parameters (VC-110) — so a canonical number grammar that admits only exact integers within a stated range is sufficient and is far cheaper to specify than a general one. The failure this guards against is silent, intermittent, and only ever visible as a multiplayer content mismatch (MOD-160) that no player can explain.

MOD-145 Computing and re-checking hashes without a local directory. MOD-140's digests MUST be computed once, over the bytes as they arrive, and thereafter carried by the installed-package record (MOD-115) — never by scanning a content directory, because a browser client has no directory to scan. The normative sequence:

  1. On install, from any source — a workshop or server fetch (MOD-125, MOD-870) or a file the user handed the client (MOD-900) — the client MUST stream the archive, compute the package hash and the sim hash per MOD-140 using the platform's subtle-crypto SHA-256 (Node: the standard crypto module), and record (id, version, packageHash, simHash, source, installedAt).
  2. Against an advertisement. Where the source advertised a hash — a workshop listing, or a lobby under MOD-160 — the computed value MUST be compared before activation, and a mismatch MUST abort the install showing both values. Content MUST NOT be trusted because of where it came from; TLS authenticates the origin, not the package.
  3. On later use, the check is against the stored record, not a re-scan. A browser client's package store is not user-editable, so a recorded hash stays valid as long as the stored bytes are the bytes that were hashed. The client MUST re-hash the stored bytes on demand from the Mod Manager, whenever a lobby check fails, and after any storage error, and SHOULD re-hash lazily in the background.
  4. On platforms with a real filesystem the content directory is user-editable, so the client MUST re-hash a package whenever its file has changed since install — size and mtime as a cheap gate, a full re-hash on any difference — and MUST re-hash unconditionally before a multiplayer launch (MOD-160).
  5. Developer-mode unpacked packages (MOD-670) have no stable hash by construction. They MUST be marked uncertified, MUST cause the game to be labelled Modded (MOD-170), and MUST be refused by multiplayer verification (MOD-160) rather than hashed opportunistically.

Rationale: hashing at the moment of receipt makes the check a property of the transfer rather than of a directory the user may have edited between games. The weaker case is the desktop build, which is why item 4 exists; what would otherwise be one uniform rule is two, and the desktop path is the one that can go stale.

MOD-150 Every content type MUST be classified as sim-affecting or presentation-only, per the table in MOD-040. Presentation-only content MUST NOT be able to alter any value the simulation computes, MUST NOT enter the sim hash, and MUST NOT be required to match between players in a multiplayer game.

The classification is not a convention, it is a testable property: a CI check MUST load a reference game twice, once with every presentation-only package in the catalogue enabled and once with none, and MUST assert that the canonical state hash (03-architecture.md) after a fixed replay is identical.

Rationale: "presentation-only" is the promise that makes localization packs, art packs and name lists free to share and free to mismatch across a lobby. A promise this load-bearing needs a test, because the way it breaks is that some well-meaning code reads a display string to sort a list — which 04-ui-ux.md may legitimately do for display, and which MOD-545 forbids reaching state.

MOD-160 Multiplayer content verification: a lobby MUST advertise the (id, version, sim hash) of every sim-affecting package the game uses; clients MUST verify local sim hashes match before the game starts, and mismatches MUST block launch with a diff of what differs. Missing packages are fetched per MOD-870. This is the content half of the pinning 05-multiplayer.md MP-110 requires and the distribution MP-120 specifies; enforcement mechanics are owned there.

MOD-170 A game MUST be labelled Certified when every sim-affecting package it uses is a certified built-in (MOD-060) at its pinned hash, and Modded otherwise. The label MUST appear in setup, in-game, in saves and replays, and in multiplayer listings. Consequences for ladders and matchmaking are specified in 05-multiplayer.md and 08-services-platform.md.

Rationale: the previous draft made this a "Classic mode" gate on reproducing another game exactly. There is no such mode and no such obligation. What survives is the part that was always doing real work: a competitive ladder needs to know whether two results were produced under the same rules, and one boolean derived from hashes the client already computes answers that without asking anyone to declare anything. Modded is a label, not a penalty — 08-services-platform.md decides what, if anything, it costs.


3. Unit sets as content

02-units-and-industry.md owns what a unit is. This section owns how that is written down, what the editor shows, and what a loader must refuse. Where the two could disagree, 02 wins and this document is the defect.

MOD-180 A unit set MUST be a single JSON document (unitset.json) validating against a published JSON Schema, and that schema MUST be an exact expression of the unit definition schema of 02-units-and-industry.md §2 and the capability-flag vocabulary of its §3. The document MUST contain a set identifier, a schema version, and an array of unit definitions, per US-700. The core's own validator (US-710 through US-800) is authoritative; the published JSON Schema exists so that authors, generators and the workshop's pre-publication scan (MOD-880) can reject a bad set without loading the core, and MUST NOT accept anything the core rejects.

MOD-185 The closed-vocabulary rule. Several vocabularies this document serializes are closed by the specification that owns them, and no data package may extend one. A loader MUST reject a document that names a value outside the owning enumeration, rather than ignoring it. The complete list, with its owner:

Vocabulary Size Closed by
Unit class 18 US-090
Capability flags 18 US-470
domain, terrainClass 3, 5 US-120
Tile Installations; edge features 3; 3 GR-420, GR-450
Movement classes in the terrain cost table 5 GR-290
City traits 9 EC-460
Postures; Sanctions 6 + Idle; 4 CM-330, CM-570
Stance 4 TM-630
Victory Tracks 14 VC-880

Rationale: this table is the single most useful thing this document can state, because the honest answer to "what can a mod change?" is otherwise discovered one rejection at a time. Each of these vocabularies is closed for a reason its owner argues — a nineteenth flag is an untested branch (US-480), a seventh Posture is a rules change requiring a commandLogicVersion bump (CM-330), a fifteenth Track breaks every readout and estimator in 14-victory.md (VC-3200). The modding promise is not that everything is editable; it is that everything editable is data, and that the line is published rather than found. Where a genuinely new behaviour is wanted, §8's plugin ABI is the escape hatch and US-2060 makes it the only one.

MOD-190 Set-level metadata MUST include: setId (matching MOD-120 and US-070), a stable guid (UUID) that survives renaming, localizable name and description, schemaVersion, an auto-incremented revision bumped on each editor save, and an optional suggestedRulePreset naming a rule preset (§11) by package id and key. Selecting the set at game setup MUST apply the suggested preset as defaults which the setup player may override.

MOD-200 A unit set MUST NOT contain any reserved, undeletable, or specially-named unit record, and the format MUST NOT provide a set-level map binding a role to a unit id. Every behavioural role — capture, garrison, administration, supply projection, construction — MUST be selected by the engine from the class enumeration (US-090) and the capability flags (US-470) of §3, never by identity.

Rationale: this reverses the prior draft, which required five named records and a roles object mapping each to an id. US-060 forbids the core branching on a unit's id at all and requires a CI lint to prove it, so a role map is a mechanism the engine is not permitted to consume. It is also worse design on its own terms: with roles bound to flags, a set that ships three different administrator units works, and a set that ships none simply has no Integration path — which is a legitimate thing to build and which the old scheme made a validation error. The validation this removes is not lost; US-750's reachability check and MOD-350's warnings cover the real hazard, which is a set nobody can play rather than a set missing a magic key.

MOD-210 Per-unit identity fields MUST be exactly the five of US-070 and no others:

Field Role
id Stable unique unit-type identifier within the set. Every cross-reference in the package resolves by id: sprites.json entries (MOD-430), name pools (MOD-480), encyclopedia entries (MOD-295), and scenario placed units (MOD-770). MUST NOT change once shipped (US-080).
setId The owning set. MUST equal the document's set identifier.
name Display name, localizable, 1–32 characters. Renaming a unit MUST change only this.
role The unit's one-sentence role, 1–160 characters, localizable (US-1620).
class One of the eighteen values of US-090.

There MUST be no second identifier, no short-code, and no compatibility alias. Cross-package references MUST be packageId:setId:unitId written in full.

Rationale: the prior draft carried a two-letter compatibility key alongside id and used it as the asset-lookup key, so a renamed unit kept its art and a set with a full two-letter namespace could not add a unit. Both problems were self-inflicted by an interoperability obligation that no longer exists. One identifier, used everywhere, is the whole of the design now.

MOD-220 class MUST be serialized as the lowercase enum string of US-090. The schema MUST NOT derive, infer, or accept a synonym for it, and MUST NOT carry any boolean subclass marker alongside it: every consumer that needs a grouping — 13-command.md's Requisitions (CM-1240) and Doctrine defaults (CM-790), 12-economy.md's class-scoped trait discounts — reads class per US-100.

MOD-225 Field-name conformance (normative). The unitset.json schema MUST use exactly the field names of 02-units-and-industry.md §2 — US-070, US-120, US-180, US-230, US-300, US-350 and US-380 — and the flag names of its §3. Where this document and 02 could differ in spelling, the 02 name wins and this document is corrected rather than reconciled.

The published JSON Schema MUST reject unknown top-level unit fields and unknown set-level fields, with no carve-out of any kind. There is no extension block, no x- prefix convention, and no opaque object a loader is permitted to accept and ignore.

Rationale: the prior draft carried exactly one carve-out, an x-edce block that existed so an importer's residue would validate against the schema it had to validate against. The importer is deleted, so the carve-out has no purpose — and it was never free: open question 17 of the prior draft recorded that the residue entered the sim hash and therefore changed a set's multiplayer identity for data the engine never read. Closing the schema completely deletes that question rather than answering it. US-720 gives the affirmative reason as well: silently ignoring an unknown field is how an author spends an afternoon wondering why stealth: true does nothing.

MOD-230 The per-unit stat block MUST serialize exactly the fields of US-120, US-180, US-230 and US-300, as integers, within the ranges those requirements declare. The complete list, grouped as the editor presents it:

Group Fields
Movement and vision (US-120) domain, terrainClass, draft, move, vision, visionAir, endurance, zoc, mayOccupyCity
Cost (US-180) worksCost, manpowerCost, costEscalationPerOwnedPerMille
Combat (US-230) hits, atkLand, atkSea, atkAir, def, flak, strikeRun, initiativeBase
Transport (US-300) slots, capacity, carryDomains, carryMaxSlots

Every value MUST be a whole number written without exponent, sign, or fractional part, and a value outside the declared range MUST be a blocking validation error naming the field, the value and the range. There MUST be no fixed-point or floating-point field anywhere in the format; where a ratio is needed the field is per-mille per US-030 and its name MUST end PerMille.

MOD-235 The Industry ladder as content. The ladder is content in exactly one respect and the format MUST expose exactly that one: which units each Industry level unlocks is expressed entirely by the per-unit buildRequirements.minIndustry value (US-350), so a custom set redistributes its own roster across the four levels freely and needs no ladder document at all.

The ladder's other properties are rules, not content. The number of levels, their range [1, 4] (US-810), their Works multipliers and their upgrade costs (US-820) MUST NOT be fields of a unit set. A rule preset (§11) MAY carry an override for the multipliers and upgrade costs only where 02-units-and-industry.md declares a legal range for them; until it does, the format MUST NOT expose them at all and a preset naming them MUST be rejected. MOD-1210 records the request.

Rationale: this is the escalating-complexity curve the direction brief asks for, and putting the whole of it in one integer per unit is the reason it is moddable at all. A teaching set that puts everything at Industry 1, a historical set that gates armour behind Workshop, and a naval set whose Arsenal unlocks nothing but hulls are all one column of a spreadsheet apart. The multipliers are a different kind of thing: US-880's flat upgrade costs and US-820's multiplicative output are load- bearing for the industrial geography the whole design argues for, and 02's own open question 3 says that decision is the one most likely to be reversed. Exposing a knob to modders before its owner has settled its range would be shipping someone else's undecided question as a feature.

MOD-240 Per-unit capability flags MUST be serialized as a flags object whose keys are drawn from the eighteen names of US-470 and whose values are booleans. The complete vocabulary, grouped as 02 groups it:

Group Flags
Ground and capture (US §3.1) capture, entrench, garrisonOnly, reactiveOnly
Transport and delivery (US §3.2) cargoLost, assaultLanding, airdrop, airbase
Fire support (US §3.3) siege, ambush
Detection and concealment (US §3.4) submerged, sonar, highAltitude, intercept
Special (US §3.5) expendable, administrator, emplace, supplySource

An unknown flag name MUST be a blocking error (US-470). An absent flag MUST mean false; the schema MUST NOT distinguish absent from false. The three flags US-490 classifies restrictivereactiveOnly, garrisonOnly, expendable — MUST be marked as such in the published schema and in the editor, so an author reads them as costs rather than as features.

01-game-rules.md GR-1700 additionally asks 02 for a recon flag that its vocabulary does not yet carry. If 02 adds it the vocabulary becomes nineteen and this table follows; this document MUST NOT add it unilaterally.

MOD-250 Transport MUST be serialized as the four independent fields of US-300 — slots, capacity, carryDomains, carryMaxSlots — and nothing else. What a carrier accepts is carried by carryDomains and carryMaxSlots; how much by capacity; what a unit costs to carry by slots. The schema MUST NOT provide a per-cargo-unit whitelist: US-310's loading test names domains and slot sizes only, and a whitelist by id would be a per-unit table the core would have to read by identity, which US-060 forbids.

A unit with capacity > 0 MUST carry cargoLost (US-540); a set violating this MUST fail validation, not warn.

MOD-260 Combat is eight integers, and the format MUST NOT offer a matrix. A unit's entire combat profile is hits, atkLand, atkSea, atkAir, def, flak, strikeRun and initiativeBase (US-230). The schema MUST NOT provide, and a loader MUST reject, any of: a per-target-unit attack table; a per-target-class attack table; a per-domain defence vector (US-250 forbids it outright); a per-terrain combat override on a unit record (terrain is a property of the map and 01-game-rules.md GR-380 hands 11-combat.md one number for it); or a modifier keyed on an opposing unit's id.

Rationale: the prior draft specified a full attacker × defender × terrain Combat Mod matrix, which for a twenty-unit set is four hundred cells before terrain and eight thousand after. US-240 names three attack integers as "the load-bearing simplification of the whole roster" and gives the reason: every counter relationship is readable off a card, and "this cannot touch that" is a zero in a column rather than a rule in a manual. A matrix would restore expressive power nobody asked for at the cost of the one property that makes a custom set comprehensible to the person playing against it — and it would make the non-domination test of US-1660 and the editor's counter-web view (MOD-295) impossible to compute or display. The expressiveness a matrix would have bought is available through the flag vocabulary, which is closed, tested and documented.

MOD-270 Terrain interaction is not per-unit data. A unit record MUST carry exactly three terrain-related fields — domain, terrainClass and draft (US-120) — and the schema MUST NOT provide a per-terrain cost map, a passability map, a road-effect record, or a landing-terrain list on a unit record. A loader MUST reject a unit record carrying one, naming the field.

Movement costs live in the terrain set (§4) as one table indexed by movement class and terrain type, per GR-290. Passability is the absence of an entry in that table. Road, Fort and Airfield effects are Installation properties owned by GR-420 and are not per-unit.

Rationale: the prior draft put a full terrain map on every unit, which is the same value written unitCount × terrainCount times and drifting after the third edit. One table of 5 × 11 integers says the same thing exactly once, is what GR-290 actually specifies, and makes a custom terrain set composable with any unit set — which is the whole reason the two are separate content types. It also deletes an entire class of validation: with costs on the terrain side, "a unit that can enter nothing" is GR-370's cross-check rather than a per-unit invariant nobody runs.

MOD-280 Build requirements and capability parameters MUST be serialized exactly as US-350 and US-380 declare them:

Record Fields
buildRequirements (US-350) minIndustry, requiresCoastal, requiresTraits, minCityClass, minIntegration
Capability parameters (US-380) bombardRange, bombardShots, interceptZone, cityStrike, repairAdjacent, repairEmbarked, buildables, commandRating

requiresTraits MUST name traits from the closed vocabulary of 12-economy.md EC-460 and MUST carry at most three entries; buildables MUST name constructions from the closed vocabulary of 01-game-rules.md GR-420 (road, airfield, fort, bridge). A name outside either vocabulary MUST be a blocking error (US-730). commandRating MUST be permitted to be null and MUST be null in the shipped set (US-400); where non-null it overrides 13-command.md CM-850's span of 8.

There MUST be no separate "construction times" record anywhere in the format. 01-game-rules.md owns what an Installation costs to build; a unit either can build a thing or cannot.

MOD-290 Vision, detection and endurance MUST be serialized as the US-120 fields vision, visionAir and endurance, together with the four detection flags of US §3.4 and the interceptZone parameter of US-380. The schema MUST NOT carry a visibility whitelist naming which unit types may see a concealed unit: US-640 makes submerged visible to sonar units within vision and to adjacent units, which is a rule, not a per-unit list.

visionAir ≥ vision MUST hold for every unit (US-140) and MUST be a blocking validation error, not a warning.

MOD-295 The encyclopedia document. Because the unit schema is closed (MOD-225) and because US-1630 requires every unit's counter relationships to be shown on its card, a unit set package MUST be permitted to carry a companion encyclopedia.json mapping each unit id to:

Field Type Meaning
counters ordered list of unit id, class value, or localizable free text What this unit answers (US-1630, US-1640).
counteredBy same What answers it.
notes localizable text, optional Author's commentary; never read by the engine.

The encyclopedia MUST be presentation-only (MOD-150) and MUST NOT enter the sim hash. Where a set ships none, the client MUST still generate a complete encyclopedia from the stat lines and flags per US-2110 and US-930, and MUST label the counter section as derived rather than authored. An id naming a unit not in the set MUST be reported as a warning, not an error.

Rationale: US-1640 states the shipped roster's counter web as prose in 02, which is right for the shipped set and impossible for a custom one — and US-2110 requires the encyclopedia to be generated from the loaded set so that a custom set is documented in-game to the same standard. A separate presentation-only document squares those: an author who wants their web stated writes it, an author who does not gets a derived one, and neither can change what the simulation computes by editing prose. Keeping it out of unitset.json is what lets MOD-225 close that schema completely.

MOD-300 A unit set MUST contain no executable code, no expression language, no formula strings, and no reference to a file outside the package (US-700, US-2050). A unit set MUST NOT be able to reach the plugin ABI of §8 in any way — not by naming a plugin, not by declaring a dependency on one, and not by carrying a field a plugin could read (US-2060).

Rationale: this is the boundary the whole document is organised around. A data set is safe to load from anywhere, deterministic by construction, and incapable of desyncing a multiplayer game; a plugin is none of those and belongs behind a separate, explicit trust decision. Blurring them would make every shared unit set a code-execution question, and the answer players would reach is "do not install unit sets", which costs the feature entirely.


4. Terrain sets

MOD-301 A terrain set MUST be a single JSON document (terrainset.json) validating against a published JSON Schema, carrying a terrainSetId (MOD-120), a guid, localizable name and description, a schemaVersion, and an ordered array of terrain type definitions. A terrain set is sim-affecting (MOD-040) and MUST enter the sim hash.

MOD-302 Each terrain type MUST carry a stable id (MOD-120), a localizable name, a localizable one-line identity string, a family grouping used only for presentation, and exactly the six rules-visible attributes of 01-game-rules.md GR-350 — defence, block, view, sight, concealment, depth — as integers. The schema MUST NOT define a seventh rules-visible attribute: GR-350 states that no subsystem may read a terrain property outside that list, so a field the format carries and no rule consumes is a promise the engine cannot keep.

MOD-303 Movement costs MUST be serialized as one table on the terrain set, keyed by terrain id and then by the five movement classes of GR-290 (foot, tracked, wheeled, naval, air), whose values are the integer entry costs. Absence of an entry MUST mean the class may not enter that terrain; the format MUST NOT carry a separate passability boolean, because two representations of one fact drift.

The table MUST cover the wheeled class even when no unit in any loaded set uses it, per GR-320.

Rationale: absence-means-impassable is the representation GR-290's own table uses, with in the cell. Encoding it the same way means the shipped terrain set is a transcription of GR-290 rather than a re-derivation of it, and a reader can check one against the other by eye.

MOD-304 Installations and edge features MUST NOT be defined by a terrain set. The three tile Installations of GR-420 and the three edge features of GR-450, and every effect either carries, are rules. A terrain set MAY declare, per terrain type, whether each Installation may be built there — which is a parameter GR-420 already varies by terrain (the Airfield's foot entry cost ≤ 8 test) — and MUST NOT declare anything else about them.

MOD-305 A terrain set MUST be validated at load against 01-game-rules.md GR-370 in full, and the loader MUST reject the whole set — never partially load it — naming every failure in one pass. GR-370 is authoritative and this document MUST NOT restate its bounds. The format adds exactly three checks of its own:

MOD-306 A terrain set and a unit set MUST be checked against each other at game setup, not at package install, because either may be installed without the other. The check MUST include GR-370's cross-condition — no movement class present in the loaded unit set may be left with no land terrain it can enter — and MUST additionally verify that every draft value in [1, 3] used by a loaded naval unit is reachable by at least one water terrain's depth (GR-590). A failure MUST block game creation with a named reason and MUST NOT be discoverable only after generation.

MOD-307 Map generation presets MUST be terrain-set content: a terrain set MAY carry named terrainMix presets, each a table of per-cent shares over its own land terrain types summing to 100, serving the terrainMix setup parameter of GR-1520. A terrain set MUST carry at least one such preset, and the shipped set MUST carry the four GR-1520 names.

Rationale: a custom terrain set with no mix preset is a set no generator can use, because terrainMix is a setup parameter whose legal values are exactly the presets the loaded set declares. Requiring one is cheaper than specifying a default derivation nobody would like.

MOD-308 A terrain set MUST declare, per terrain type, the asset keys (MOD-460) its tiles are drawn from, and MUST NOT embed art. The depth attribute governs water assignment at generation per GR-1540 and MUST NOT be inferred from the asset.


5. Content editors

MOD-310 The client MUST provide an in-game Unit Set editor and an in-game Terrain Set editor, each offering four screen-level operations: Review (read-only view of any set, including certified ones), New (blank, or a copy of an existing set), Edit (custom sets only; certified sets are read-only reference models), and Delete. Copying a set MUST copy its set-scoped assets automatically.

MOD-320 The Unit Set editor MUST organize per-unit editing into panels that mirror the schema groups of MOD-230, MOD-240, MOD-280 and MOD-290, so that the editor's shape and the file's shape are the same shape:

Panel Contents
Identity id, name, role, class; asset binding (MOD-330); encyclopedia entry (MOD-295).
Movement & Vision domain, terrainClass, draft, move, vision, visionAir, endurance, zoc, mayOccupyCity; a live per-terrain cost readout computed from the loaded terrain set (§4), read-only.
Cost & Build worksCost, manpowerCost, costEscalationPerOwnedPerMille, buildRequirements; live turns-to-build against the reference producer of US-870.
Combat the eight integers of MOD-260, plus the live prediction of MOD-340.
Transport slots, capacity, carryDomains, carryMaxSlots; a live list of which units in the set this unit could carry and which could carry it, derived from US-310.
Capabilities the eighteen flags of MOD-240 with restrictive ones marked, and the eight parameters of US-380.

Every control MUST carry inline documentation — hover and expandable help — naming the requirement that owns the field, so that an author can read the rule rather than guess the behaviour.

MOD-330 The Identity panel MUST let a unit reference another unit's art rather than its own, by naming an asset key (MOD-460) instead of relying on the default lookup by id (MOD-430). Add Unit MUST prompt for a unique id; Copy Unit MUST duplicate the selected unit under a new unique id and MUST NOT copy its sprites.json binding by default, since two units sharing art silently is the more surprising outcome.

MOD-340 The Combat panel MUST display, live as the integers are edited, the deterministic per-mille win prediction that 11-combat.md provides — the same value 13-command.md CM-150 and CM-2150 require for a sanctioned automated attack, and the same value a player is shown before a manual attack — for a selected attacker, defender and terrain. It MUST additionally show the expected number of rounds and the round-by-round survival distribution, and MUST label every figure as computed at full strength, in supply, with no Disorder, so nobody mistakes the editor's figure for a promise about a real engagement.

The editor MUST NOT implement its own combat arithmetic. It MUST call the core's predictor.

Rationale: an editor with a second implementation of the combat model is an editor that lies, and it lies most convincingly right after a balance change. Calling the core also means the prediction stays correct for a custom terrain set and a modded rule preset without the editor knowing either exists.

MOD-350 The editor MUST validate continuously and list problems inline. Blocking errors are exactly the core's own validation failures (US-710 through US-800) plus the format checks of §3, and the editor MUST surface them in the core's own words rather than paraphrasing:

Warnings, which MUST NOT block save-for-play, MUST include: a strictly dominated unit, naming its dominator (US-770 — a warning for custom sets and an error only for the shipped one, per US-770's own rule); a unit outside the 3-to-11-turn build band (US-780); a unit above the tier power ceiling (US-790); a flag or class no unit in the set carries (US-760 exempts custom sets); a sonar unit in a set with no submerged unit; and an encyclopedia entry naming a missing id (MOD-295).

Rationale for the split: we hold the shipped set to standards we deliberately do not impose on modders, and US-770 says so explicitly. A joke set, a teaching set with a strictly-worse trainer, or a historical set full of obsolete equipment are all legitimate and none of them should fail to load. Warning is the right strength; refusing would be paternalistic.

MOD-360 Both editors MUST provide unlimited undo/redo and a persistent per-set version history with named checkpoints and one-click restore. History MUST include the set's assets. Set identity is the guid (MOD-190); branching a set is done by Copy, which assigns a new guid.

MOD-370 Both editors MUST offer a side-by-side diff of any two sets, or two versions of one set, highlighting stat, flag and build-requirement differences, and — for unit sets — a counter-web view showing which units each unit beats and loses to, derived from the eight integers of MOD-260 against the loaded terrain set. The diff MUST additionally show which units moved between Industry tiers, since that is the edit most likely to change how a set plays and least likely to be visible in a numeric diff.

MOD-380 The client MUST provide a Rule Preset editor covering every parameter §11 declares settable, with plain-language descriptions per parameter, live legal ranges taken from the owning requirement, and an estimated-game-length readout for victory parameters supplied by 14-victory.md's estimator (VC §12.3). It MUST refuse to save a preset whose values fall outside a declared range, naming the parameter and the range.

MOD-385 The Terrain Set editor MUST present the six GR-350 attributes and the movement-cost table of MOD-303 as one editable grid per terrain type, with a live preview of the resulting terrain defence value of GR-380 for a selected attacker elevation and river condition, and a live check of GR-370's bounds and of MOD-306's cross-set conditions against the currently loaded unit set.

MOD-390 Set-scoped assets — images, sounds, name pools — MUST live inside the set's package and be managed from its editor (import, preview, assign), with no manual file placement anywhere in the flow.


6. Asset packs

MOD-400 An asset pack maps asset keys (MOD-460) to files. Asset packs are either global (overriding or extending default art and audio for any content) or scoped (bundled inside a unit set, terrain set or map package and applying only there). Scoped assets take precedence over global packs, which take precedence over built-ins; ties among global packs resolve per MOD-100.

MOD-410 Image assets MUST be accepted as PNG (with alpha) or SVG, and MAY additionally be supplied as WebP or AVIF. Arbitrary dimensions MUST be supported. Atlas packing, mipmap generation, and any GPU texture-compression encoding belong to the install-time pipeline (MOD-505), never to the authored package. SVG MUST be sanitized and rasterized at install to the platform's device-pixel sizes per MOD-095: the WebGL map layer cannot consume vector art, so an SVG's benefit is resolution-independent authoring, not runtime scaling. Rasterization differs slightly between browser engines; that is acceptable precisely because image assets are presentation-only (MOD-150) and can never reach the sim hash. The published art guide SHOULD state the reference grid size used by the stock art so mixed packs compose well.

MOD-420 Player-colour theming MUST use a dedicated tint-mask channel or companion mask layer, authored as such. The format MUST NOT use chroma-keying — reserving particular pixel values as meaning "player colour" — because it makes those colours unusable in ordinary art and fails silently on a resampled or lossily-compressed asset. Two independently coloured regions MUST be expressible: a foreground and a background per player position.

Theming has two consumers and both paths MUST exist:

Both paths MUST produce visually identical output for identical inputs, and the pre-baked set MUST be regenerated when a position's colours change. The set is bounded by player count, not by palette, because colours are chosen at setup.

Rationale: splitting the map layer from the rest of the UI is what buys real tables, real text and real accessibility — but it means every piece of themed art has two renderers, and a divergence between them is a visual bug players report as "the icon in the production panel is the wrong colour". Naming both paths keeps that a known cost rather than a surprise. Two colours rather than one is what keeps eight positions distinguishable at a 24-pixel icon size, which is the size the Dispatch and the unit table actually use.

MOD-430 Unit sprite selection MUST be declarative: a sprites.json whose entries are keyed by unit id (MOD-210) maps presentation states to asset keys. The presentation-state vocabulary MUST be exactly the following closed set, each entry derived from named simulation state and never from anything else:

State True when Defined by
normal always — the required default MOD-440
damaged hitsRemaining < hits US-410, US-430
loaded cargo is non-empty US-410
embarked carriedBy is non-null US-410
entrenched entrenchment ≥ 1 TM-700
disordered the unit's Disorder is above the threshold 11-combat.md publishes CB-1190
emplaced the unit has completed an emplace conversion US-680
lowEndurance enduranceRemaining ≤ 1 and endurance > 0 US-450
veteran veterancy ≥ 1 US-440
submerged the unit is submerged and undetected by the viewing player US-640

A client MUST NOT add a state to this vocabulary, and a sprites.json naming an unknown state MUST be rejected with the state name. Filenames are arbitrary and carry no meaning.

Rationale: the prior draft's five states were another game's display flags and three of them name nothing this simulation has. Deriving the vocabulary from state the specification actually carries means every entry is checkable against a requirement, and it means the fog rule on submerged is stated once, here, rather than discovered as an information leak in an art pack.

MOD-440 Sprite resolution MUST be declaration order, first match wins: the client evaluates the unit's sprites.json entries in the order the document lists them, selects the first whose state conditions all hold, and falls back to the normal entry when none does. An entry MAY name several states, which MUST be conjunctive. Every unit MUST have a normal entry; MOD-350 makes its absence a blocking error. Where a unit has no sprites.json entry at all, the client MUST fall back to the asset key unit.<id> (MOD-460), and then to a generated placeholder that renders the unit's class glyph — never to a missing sprite.

Rationale: the prior draft specified a numeric bitfield searched over all subsets of the active flags in descending code order, which is a rule an author cannot hold in their head and cannot debug. Declaration order is a rule anyone who has written CSS already knows, it makes the author's intent explicit rather than emergent, and it makes the resolution trace displayable in the editor as a highlighted line. The cost is that an author must order their entries most-specific-first, which the editor warns about when an earlier entry shadows a later one completely.

MOD-450 City sprite selection MUST be keyed by the pair (Industry level, Integration band), and the bands MUST be declared in data so that a variant asset pack may redefine them. The Industry axis has exactly the four levels of US-810 and MUST NOT be redefined. The Integration axis MUST default to four bands over 12-economy.md's [0, 100] Integration — 0–24, 25–59, 60–89, 90–100 — giving sixteen states, and a pack MAY declare between 1 and 8 bands covering the range without gaps or overlaps. A city sprite entry MAY additionally name the presentation states garrisoned, besieged and unrest, resolved by MOD-440's rule.

Rationale: the two axes are the two facts about a city a player most needs to read at a glance from the map, and they are the two the design has made load-bearing — Industry decides what a city can build and is worth a two-hundred-tile raid (US-970), Integration decides what it actually produces and how fast a conquest digests. Four bands rather than eight because a player reading a map is distinguishing "raw, settling, working, ours" and not a percentage.

MOD-460 All non-unit imagery — terrain, cities, Installations, rivers and fords, markers, map furniture, Landmark and Region markers — MUST be addressed by a published asset-key registry. Terrain art MUST be supplied as atlases with named tiles (for example woods.edge_n), never as positional sprite strips whose meaning depends on order. Named tiles are the authored form; the packed runtime form is the derived bundle of MOD-505, so no pack author ever hand-packs a sheet or depends on its layout. The registry MUST be versioned, and a key added in a later version MUST be optional so that an older pack keeps loading.

MOD-470 Audio assets: the client MUST support at minimum per-unit attack, defeat and movement sounds, assignable per unit id. Accepted authoring formats: OGG Vorbis, Opus, WAV, FLAC and MP3. Acceptance at authoring time is not playability at runtime: no single codec is decodable by every browser the client targets, so the install-time pipeline (MOD-505) MUST produce for each sound at least one variant the running platform can decode, and the client MUST choose a variant by capability probe rather than by file extension. A sound that cannot be decoded on the current platform MUST degrade to silence with a warning (MOD-500) — never fail the package, and never fail the game. Audio MUST load asynchronously on demand: adding sounds MUST NOT increase application startup time, which on a browser client additionally means decoding off the main thread and never blocking first paint.

The baseline variant the pipeline always emits MUST be one every target browser decodes; that codec is unsettled pending a confirmed decode matrix (Open question 8).

MOD-480 Random unit-name pools MUST be supplied as UTF-8 JSON ({"unit": "<id>", "names": [...]}), one pool per unit id (MOD-210). Default naming without a pool MUST be ordinal. Name pools are presentation-only (MOD-150).

MOD-490 Name-list assets MUST also cover AI player name pools and the game-name generator list, both replaceable and extendable via packages. Both are presentation-only.

MOD-500 Asset validation MUST run at install: decodability, declared-key coverage, size and budget limits (documented per platform), and mask-channel sanity, with itemized human-readable warnings and errors (MOD-080). Oversized assets degrade (downscale) with a warning rather than fail, except where a hard platform limit applies. On a browser client the budgets MUST additionally account for the storage quota of MOD-115 and for texture memory on a device the client cannot query directly; where a hard limit does apply — maximum texture dimension, quota exhaustion — the failure MUST be reported as an itemized, actionable error naming the offending asset, never as a silently missing sprite.

MOD-505 Compiled asset bundle (what the renderer actually loads). Installing a package that carries graphics or audio MUST produce a derived bundle alongside the stored archive: atlas pages and their frame index in the client's documented runtime atlas format, tint-mask channels and pre-baked tinted rasters (MOD-420), rasterized SVG at the platform's device-pixel sizes (MOD-410), and audio variants the platform can decode (MOD-470) — all addressed by asset key (MOD-460). The bundle is what the Phaser map layer and the DOM UI load at runtime; the archive is what the hashes of MOD-140 cover.

Rationale: with a web client the compile step is ours and, on a browser, it runs on the player's machine at install time — so it has to be bounded, resumable, and explicitly excluded from content identity, or the first launch after subscribing to a large pack becomes an unexplained stall.


7. Localization

MOD-510 Every displayed string in the client and in content MUST be externalized behind a string key; nothing user-visible may be hard-coded. The master string catalog MUST be published with the game.

MOD-520 Localization files MUST be UTF-8 JSON mapping keys to ICU MessageFormat strings, with plural, gender and select support, one file per locale per package.

MOD-530 Partial overrides MUST be supported: a localization pack may supply any subset of keys; unresolved keys fall back per-key through package priority (MOD-100) to the base locale, then to English.

MOD-540 Text rendering MUST support full Unicode with automatic font fallback covering at minimum Latin, Cyrillic, Japanese, Chinese, Hangul, Arabic and Devanagari, including RTL layout for Arabic. See 04-ui-ux.md for typography. Fonts MUST be delivered as subsetted WOFF2 by script through the client's own asset pipeline and MUST NOT be fetched from a third-party font host — no package and no locale may introduce a network dependency (MOD-095), and offline play (MOD-900) must not silently lose a script. Text drawn onto the Phaser canvas MUST wait for the faces it needs to be ready before its first paint; unlike DOM text, canvas text does not repaint itself when a font finishes loading.

MOD-545 Locale-dependent behaviour is presentation-only. Locale-aware collation, number and date formatting, and case mapping MAY be used freely for display, but MUST NOT determine any ordering, comparison, or value that reaches simulation state, a content hash (MOD-140), or a canonical serialization. Content keys and ids are compared by the case-sensitive, locale-independent rule of MOD-120, and every ordering the simulation observes is fixed elsewhere — unit definitions in ascending id (US-050), tiles by tile index (GR-140), entities by sequence number (VC-180).

Rationale: one language now spans client and server, with one string type and locale-sensitive comparison available from anywhere in it. A list sorted for display in one locale and then hashed in another is a content mismatch (MOD-160) that reproduces only for some players, which is the most expensive kind to diagnose.

MOD-550 Mod content strings — unit name and role, terrain names, rule preset text, Doctrine names, Terms text, editor text — MUST be localizable by key through the same mechanism. Free text typed by players in-game (unit renames, map labels, city names) is stored verbatim and never machine-localized.

MOD-560 Tooling: the client MUST export a translation kit (all keys, source strings and comments) and re-import completed translations; a pseudo-locale (expanded, accented) MUST exist for layout testing. Community translation packs are ordinary presentation-only packages published through the workshop.


8. WASM plugin ABI

MOD-570 Behavioral extensions — AI players and map generators — MUST be sandboxed WebAssembly plugins. Native code loading MUST NOT be supported in any form. Plugin kinds at v1 are exactly ai and worldgen. Two further kinds are reserved and MUST NOT be loadable in v1: scenario-script (§9) and victory (MOD-1120).

MOD-575 Plugin host. Plugins MUST be executed by the platform's own WebAssembly implementation — the standard WebAssembly API in a browser, the same API in Node — through one shared host module used by both. No WASM runtime is embedded, bundled, or compiled into the product, and there MUST NOT be a second execution path for any platform: a plugin the server runs MUST run identically in a browser tab, because client preview and authoritative resolution share the same core. The host is the client or the server, never @everylastcity/core: the core is a pure package with no DOM, no Node APIs, and no I/O, so it can neither instantiate, sandbox, nor meter a module. The host owns the WebAssembly API surface, the worker boundary, and the metering of MOD-610, and hands plugin results to the core as ordinary data.

Each plugin instance MUST run in a worker (a Web Worker in a browser client, a worker_thread in Node) that the host can terminate unilaterally at any point — and the worker holds more than the instance. Every §8 ABI import is a synchronous WASM call, so answering view.*, orders.submit, or a MOD-590 PRNG draw from another thread would require a blocking cross-thread round trip: SharedArrayBuffer + Atomics.wait (and therefore cross-origin isolation) or JavaScript Promise Integration. 06-ai.md AI-235 forbids the ABI depending on either by name; 03-architecture.md AR-860 makes cross-origin isolation a feature-detected optimization the client MUST work without. The host therefore materializes into the worker exactly what the plugin's imports must be answered from, in-thread:

Plugin kind Materialized in the worker by the host Crossing the message boundary
ai The WASM instance plus a @everylastcity/core replica, materialized from authoritative state at the start of the Orders phase for that seat (06-ai.md AI-235) State materialization in; the seat's order records for that turn out, which the authority appends to the order log (10-turn-model.md TM-200)
worldgen The WASM instance, the MOD-640 request record, and the mapgen draw cursor whose draw functions 03-architecture.md AR-400(a) requires the host to own Request in; the MOD-640 result and the advanced mapgen cursor out, so the stream remains host-owned

The prohibition is therefore on authority, not on data: a plugin worker MUST have no handle to the authoritative simulation context, to the document, to the network, or to storage, and the plugin module itself MUST reach the host only through the vetted imports of MOD-590. Host functions MUST be supplied as an import object the host constructs, never assembled from anything the package provides (MOD-095), and any host-side glue between the ABI and the engine — marshalling shims, generated bindings, worker plumbing — is part of the ABI surface and MUST NOT close over ambient host capabilities. A core replica MUST be materialized only where the host already legitimately holds that state: a client MUST NOT materialize one for a seat it does not own, and in a fog-enforced online game the AI's Orders phase runs server-side.

Rationale: the browser makes an embedded runtime both unnecessary and unjustifiable — the engine is already present, already sandboxed, and is the best-tested WASM implementation on the machine. What the platform does not give us is that runtime's control surface: fuel metering, NaN canonicalization and feature gating were engine settings and are now our responsibility, which is why MOD-590, MOD-600 and MOD-610 carry weight they did not previously carry. The replica is not a second source of truth — because orders, not state, cross the boundary, the authoritative core re-derives everything it is handed.

MOD-580 Plugin interfaces MUST be defined in WIT and versioned as empire:ai@X.Y.Z and empire:worldgen@X.Y.Z over a shared empire:host@X.Y.Z. WIT is the definition and code-generation source; the artifact a package ships and a host instantiates is a core WebAssembly module, whose imports and exports are derived from the WIT world by a single published, versioned canonical lowering (Canonical-ABI value encoding over the guest's exported memory and allocator). The SDK performs that lowering at build time, so the bytes that are published are the bytes that run and the bytes that are hashed (MOD-145). A host MUST NOT transpile, componentize, or synthesize glue from package bytes at load time. The host MUST declare its supported ABI version ranges; a plugin outside the range MUST be refused with a clear message naming both versions. ABI evolution follows SemVer: additive = minor, breaking = major; the host SHOULD support the current and previous major for at least one release cycle.

Rationale: no browser instantiates a WASM component natively, and the usual workaround — transpiling a component into core modules plus generated JavaScript glue — would mean synthesizing script from untrusted bytes inside the client's own origin, which MOD-095 forbids outright. Fixing the lowering at build time keeps WIT's authoring and versioning benefits, keeps exactly one artifact per plugin, and keeps the host's load path down to instantiate-plus-a-fixed-import-object, which is small enough to audit.

MOD-590 Sandbox: plugins MUST have no filesystem, network, clock, thread, or environment access. The only capabilities are the host imports: logging, the host-owned seeded PRNG stream, read access to the fog-filtered view appropriate to the plugin's role, and order or result submission. WASI MUST NOT be exposed at all: the sanctioned import set is exactly the empire:host functions of the negotiated ABI version, and a module importing anything else MUST be rejected at load, naming the offending import. SDK templates MUST therefore build against a WASI-free target; where a guest toolchain unavoidably emits an abort or diagnostic-write import for panics, the host MAY satisfy exactly the documented shim pair (trap, diagnostic log) and nothing further.

Because a browser's WebAssembly engine cannot be configured to withhold capabilities or features the way an embedded runtime could, this rejection MUST be performed by the host's own static validation of the module bytes — at install, and again before instantiation. The engine will cheerfully instantiate anything it supports; refusal is now our code, not a runtime flag. One implementation of that validator MUST serve the client, the server, and the workshop's pre-publication scan (MOD-880).

MOD-600 Determinism: given identical inputs and PRNG stream, a plugin invocation MUST produce identical outputs on every platform. Under the standard WebAssembly API this MUST be achieved by constraining the module, not by configuring the engine:

All randomness MUST come from the host PRNG, which the core seeds and logs (03-architecture.md AR-350, AR-370).

MOD-610 Resource limits:

Rationale: this is the single place where dropping the embedded runtime costs real work. Fuel was a runtime feature we would have switched on; it is now a compiler pass we own, version and must test. The compensation is that the metering is then identical in the browser, in Node and in the CLI, because it lives in the module rather than in whichever engine happens to be running it. The line between mandatory and optional instrumentation is drawn at verification evidence, not at convenience: solo play may skip the tax, and pays for it by not counting as proof of anything.

MOD-615 Derived plugin artifacts — the instrumented module of MOD-610, any host-side compiled form, and cached compiled-module objects — are derived, never published, and never hashed. MOD-140's digests cover the bytes in the archive, so instrumentation and compilation caches may change with a client update without changing a plugin's identity (MOD-130) or its multiplayer compatibility (MOD-160). Hosts SHOULD compile streaming where the platform offers it and MAY cache compiled modules keyed by (module hash, instrumentation version); a cache MUST be invalidated when either changes, and a cache miss MUST cost nothing but time.

MOD-620 Failure containment: a plugin trap, fuel exhaustion, or watchdog termination MUST NOT crash or corrupt the game.

AI plugin faults are owned by 06-ai.md AI-240, which specifies the fault handling, the safe fallback for the remainder of the turn, and what happens after repeated faults; this document MUST NOT state a different threshold or a different action. Whatever orders the fault path issues on a seat's behalf enter the authoritative order log attributed by the Issuer field of 03-architecture.md AR-260, never by an ad-hoc flag, so the difference between a fallback turn and a substituted seat is legible in the log itself. A seat whose AI plugin faults MUST NOT freeze: 10-turn-model.md TM-290 guarantees that a player who submits no order records at all still has a well-defined turn, because every unit executes its persisted order stack — which is the correct fallback and needs no invention.

Worldgen faults are owned here: a trap, fuel exhaustion, or an invalid result aborts generation with the plugin's diagnostics shown at setup (MOD-640), leaves the map unchanged, and MUST leave the setup screen usable — a failing generator MUST never be the reason a game cannot be started with a different one.

On both hosts a trap surfaces as an exception at the host call boundary and MUST be caught there. A plugin that is instead unresponsive — wedged, looping, or running uninstrumented under MOD-610's local-interactive path — MUST be recoverable by terminating its worker (MOD-575), and that termination MUST be handled exactly as a trap, so what a player sees never depends on how the failure was detected.

MOD-630 Options: every plugin MUST export options-schema() returning a JSON Schema describing its tunable parameters, and default-options(). The setup UI renders the schema generically (04-ui-ux.md). Option configurations MUST be savable and loadable as named presets, MUST be shareable, and MUST be recorded with the game so a replay knows exactly which options were active.

MOD-640 Worldgen interface. generate(request) -> result, where:

request = { width, height, topology, playerCount, startCities, seed: u64, terrainSetRef, setupParams, optionsJson }. topology MUST be one of the three values of 01-game-rules.md GR-190 (flat, cylinder, torus) — not a pair of independent wrap booleans. setupParams MUST carry the generation-relevant parameters of GR-1480 (landRatio, terrainMix, cityDensity, minCitySpacing, riverDensity, roadDensity, neutralDefence, minStartSeparation). seed is host-supplied and derived per 03-architecture.md AR-400(b); a generator MUST NOT obtain entropy from anywhere else, and under MOD-590 it has nowhere else to obtain any.

result MUST carry:

Field Content Owner of the rules
terrain the tile grid, terrain ids from the active terrain set, row-major by tile index GR-140, GR-260
edges River and Ford edge features GR-450, GR-1550
installations pre-generated Roads (and nothing else at generation) GR-420, GR-1480 roadDensity
cities per city: name, class, traits, Industry, Population, Landmark marker, Region index EC-460, US-810, GR-850
regions M named, disjoint Regions partitioning every city, each with a regional capital GR-1030, VC-3110
landmarks L Landmarks with proper names, L odd GR-1080, VC-3110
starts each player's startCities start cities GR-1570

The host MUST validate the result before use and MUST reject it with diagnostics when it violates any map invariant 01-game-rules.md states — GR-310 (every land tile foot-enterable), GR-850 (no city on Mountains, Marsh or Icefield), GR-1550 (a Ford per river chain of 4 or more), GR-1560 through GR-1620 (the fairness invariants), and GR-1540's depth assignment. A generator that cannot satisfy them MUST be retried with the next PRNG state up to GR-1560's budget of 64 attempts and MUST then fail loudly.

Rationale: the invariants are 01's and the retry budget is 01's, but the enforcement point is here, because a third-party generator is precisely the code that will not have read them. Validating the result rather than trusting the generator is what lets an untrusted plugin produce a playable map at all — and it is why the invariant list is stated as citations rather than restated, so that a change in 01 fails the validator rather than silently diverging from it.

MOD-650 Worldgen determinism and preview: an identical request, including seed and options, MUST yield an identical map for a fixed generator build (03-architecture.md AR-400). The editor and game setup MUST render live previews by running the real generate call, so preview equals final output; seeds MUST be displayed, re-rollable, lockable and shareable. Preview MUST run in the plugin's worker (MOD-575) and MUST be cancellable, so an expensive generator cannot freeze the setup screen or the editor; a superseded preview's result MUST be discarded rather than raced onto the canvas.

MOD-660 AI plugin transport. This document owns the transport; 06-ai.md owns the decision-making interface, the shipped roster and the budget classes. The transport MUST be shaped by the turn model of 10-turn-model.md and not by sequential turn-taking:

  1. Lifecycle. create → start-game → (per turn) prepare-orders → game-over, with serialize and restore around saves and loads. One instance per seat, memory-isolated from every other instance (06-ai.md AI-340).
  2. Per-turn invocation. The host MUST invoke prepare-orders once per turn, during that seat's Orders phase, against a fog-filtered read-only view of the board as it stands at the start of that phase — which TM-340 guarantees is stable for the whole phase. The plugin MUST NOT be invoked during the Cascade, MUST NOT be invoked between two units' activations, and MUST NOT be shown any Cascade result before the Reckoning completes.
  3. Order submission. The plugin MUST submit order records through the same Order API a human client uses (TM-200, TM-210, 03-architecture.md AR-245), and the host MUST append them to the authoritative order log. An illegal order MUST be rejected at submission with the typed rejection of TM-250 and MUST NOT crash or end the seat's turn. An order that is legal when issued and unachievable at activation MUST NOT be reported as an error at any point (TM-270).
  4. Persistence. Plugin-private memory blobs MUST be persisted with the save as separate container entries outside hashed simulation state (06-ai.md AI-460), and MUST be droppable without affecting replay (AI-465).
  5. Parity. An AI seat MUST receive no extra time, no extra information and no extra order records relative to a human seat (TM-410), and MUST commit within the same Orders phase.

Rationale, and the change: the prior draft specified an AI that drove a whole turn synchronously, submitting an order, observing its resolution, and deciding the next one — which is exactly the sequential model the binding turn-model decision replaced. Under Orders and the Cascade there is nothing to observe mid-phase, because nothing resolves until every seat has committed. The compensation is real and is the reason the model was chosen: TM-280 makes orders persistent conditional policies, and 13-command.md's Postures and Sanctions are the conditional vocabulary an AI writes its plan in, so a plugin commits a policy that a late-initiative unit re-evaluates against everything that happened earlier in the Cascade. An AI that ports the old shape will find it has one call where it expected thirty, which is why MOD-1290 asks 06-ai.md to restate its own §3 and §5 against this requirement.

MOD-670 Developer experience. The plugin SDK MUST be published as @everylastcity/plugin-sdk and MUST provide all three of the following.

(a) Language templates. This document owns the SDK language-template roster; 06-ai.md AI-260 states the AI-specific surface of the same roster and MUST name the same set, so a change here MUST be reconciled there rather than diverging silently. Every template MUST emit a core module conforming to MOD-580's canonical lowering and MUST pass MOD-590 and MOD-600 validation unmodified, out of the box. First-class means maintained in-repo, built in CI, and shipped with the generated ABI bindings, the determinism lint and fuel-profiling output. The v1 roster:

Template Standing
AssemblyScript First-class. Its documentation MUST say plainly that AssemblyScript is a TypeScript-shaped language with its own type system and standard library, not TypeScript — @everylastcity/core types are not importable into a plugin, and the ABI's typed records are what an author codes against. The SDK ships AssemblyScript implementations of the integer helpers the boundary needs rather than re-exporting the core's.
Rust First-class. The smallest and fastest modules and the most mature WASM toolchain; the reference implementation of each stock plugin SHOULD be one of these.
TinyGo MAY ship as a community-contributed template on the same conformance terms. Not a release gate.

Any toolchain that emits a conforming module declaring only sanctioned imports is loadable, on the plugin author's own maintenance; 06-ai.md AI-265 states the same rule from the AI side. A genuine-TypeScript template — one carrying an embedded JavaScript engine inside the guest — is deliberately not in the v1 roster and MUST NOT be advertised as one: it costs a second determinism surface and a large per-turn fuel bill, and MOD-885's per-module cap would have to be re-derived against a real build of it first (Open question 10).

(b) Local build and test tooling, including a headless harness that runs a plugin against recorded games. The harness runs on Node and MUST be the same code path the cross-engine differential job of MOD-600 drives, so a failure an author can reproduce locally is the failure CI reports.

(c) A developer mode that watches an unpacked package and hot-reloads data and assets live, and plugins at the next turn boundary. Watching a directory needs a filesystem, so this mode is full on the desktop build and degraded in a browser tab: a browser client MUST support it by loading the unpacked package from a local development server over HTTP with change notification, and MAY additionally use a user-granted directory handle where the browser offers one. Unpacked packages are uncertified and excluded from every hashed context per MOD-145.

MOD-680 The stock map generators MUST ship as open-source worldgen plugins, and MUST between them cover the six landRatio presets of GR-1510 and the terrain mixes a terrain set declares (MOD-307). At least one stock generator MUST expose an option restricting generation to a named subset of the loaded terrain set's types, so that a map of only open ground is expressible without authoring a second terrain set. Stock generators MUST satisfy GR-1560's invariants within the retry budget on every shipped size tier and player count, and that MUST be a CI gate rather than an aspiration.

Rationale: GR-1590 and GR-1620 exist precisely because a generator that occasionally strands a player on a peninsula is a generator that decides a tournament. Making the invariants a gate on the stock generators rather than a validation the host performs after the fact is what stops the retry budget from being consumed on every generation.


9. Map & scenario editor

MOD-690 The editor MUST distinguish two artifacts: a map — geography, Regions, Landmarks and neutral cities, with player starts chosen at setup — and a scenario — a map plus seat definitions, optional pre-placed units, and any victory parameters the scenario fixes (§11), where nothing is randomly generated at game start. Fog and the intelligence model still apply to a scenario per the setup parameters GR-1630 owns.

MOD-700 New-map flow: the author sets dimensions within GR-120's [16, 2048] range and a topology from GR-190's three values, then either (a) generates via any installed worldgen plugin, or (b) creates an empty map filled with a single chosen terrain type. The editor MUST show the size tier a chosen dimension pair corresponds to, or state that it lies between tiers (GR-240), and MUST warn when either axis exceeds 1024 that the map is outside the tested performance envelope (GR-250).

MOD-710 Map palette: paint any terrain type from the loaded terrain set with a square brush sized 1×1 through 10×10; erase to a designated erase terrain; place and remove the three tile Installations of GR-420 (Road, Fort, Airfield) subject to their own placement rules; place and remove the edge features of GR-450 (River, Ford, Bridge) on tile edges. A defaults panel MUST govern fill terrain, erase terrain and brush settings.

The palette MUST NOT offer any object outside GR-420 and GR-450 (MOD-185). Where 01-game-rules.md adds one, this list follows.

MOD-720 City editing: per-city editable attributes MUST be exactly name, city class (town / city / metropolis), traits (0–3, from EC-460, within the per-class range EC-320 declares), Industry level in [1, 4] (US-810), Population, Landmark marker (GR-1080), and Region assignment (GR-1030). The editor MUST enforce EC-460's trait restrictions — Shipworks and Free Port coastal-only, Farmland and Quarry mutually exclusive — at edit time with a named reason, and MUST enforce GR-850's terrain restriction on city placement.

MOD-730 Region and Landmark editing MUST be first-class, because 14-victory.md's Hegemony (T4), The Keys (T5) and Ascendancy (T6) are unplayable without them and GR-1030 requires Regions to partition every city. The editor MUST show, live: the count of Regions M and of Landmarks L; whether L is odd (GR-1080); any city assigned to no Region; and any Region with no regional capital. Each MUST be a blocking publish error, and MUST be a warning during editing.

MOD-740 Map-edit tools: add and remove outer rows and columns; symmetry generation producing horizontally, vertically or quad-symmetric maps; and a live symmetry-painting mode that mirrors brush strokes across the selected symmetry in real time. Symmetry operations MUST respect topology: a cylinder map's symmetry across the wrapping axis is a rotation, not a mirror, and the editor MUST say which it is doing.

MOD-750 The editor MUST provide unlimited undo/redo covering every editor operation — terrain, installations, edges, cities, Regions, Landmarks, seats, units, visibility, resizes and pastes — with a browsable history list.

MOD-760 Copy and paste: rectangular region select; selective paste of terrain, installations, cities, and units-and-seats layers; rotation of the clipboard in 90° steps; horizontal and vertical flip; multiple named clipboard slots; and an on-map paste preview before commit.

MOD-770 Scenario palette: per-seat initial tile knowledge (marked tiles enter that seat's Terrain Record at game start per 01-game-rules.md §9, after which the normal vision and decay rules apply); per-seat Seat designation, which is the capital city 14-victory.md's Throne Track (T14) and its Tenure penalty key on; and unit placement under a designer-selected unit set.

The scenario MUST NOT be hard-bound to that set. At game time, placed units MUST be matched by id only; a placed unit whose id is absent from the game's selected unit set MUST be reported at setup and omitted, and the editor's validation (MOD-800) MUST list every such unit before publish. There MUST be no secondary matching key.

Placed units MUST support editing of owner, carrier, hitsRemaining, veterancy and name without delete-and-replace, and MUST be placeable only where 01-game-rules.md §5 and §6 permit that unit to be — stacking limits (GR-720) included.

Rationale for dropping the fallback match: the prior draft matched a placed unit by id and then by a legacy short code, so a scenario silently played differently under a set that happened to reuse a two-letter code. id is globally namespaced by setId (US-070) and stable across versions (US-080); one key that either matches or does not is a scenario an author can reason about.

MOD-780 Seats: each playable seat MUST define a foreground and a background colour (MOD-420), an Industry cap in [1, 4] (US-900), a starting Works grant, and — where the scenario enables Charters — a Charter selection from the pool of 14-victory.md VC-1630. Seat icon generation MUST run in the background without blocking editing. A scenario's seat count fixes the setup seat list: seats MUST NOT be added or removed at setup when using a scenario.

Rationale for the Industry cap sitting on the seat: US-900 makes it a per-player value set at setup and US-920 makes it a handicap on roster complexity rather than on production, which is exactly the kind of asymmetry a designed scenario wants — a tutorial scenario capping the learner at Depot and the AI at Workshop is one integer per seat, and it is the only handicap in the design that does not lie about the board.

MOD-790 Generator integration: the editor MUST host live worldgen preview (MOD-650) with an editable, copyable seed, parameter controls rendered from the plugin's options schema (MOD-630), side-by-side comparison of candidate seeds, and named save/load of generator configurations. Generated output lands in the editor as ordinary editable content, and the editor MUST retain the generator provenance 03-architecture.md AR-400 requires.

MOD-800 Playtest-from-editor: one action MUST launch a playable game from the current editor state — solo, or versus chosen AI plugins — and return to the editor with undo history intact. Before playtest or publish the editor MUST run validation covering, as warnings during editing and as errors before publish: GR-1560 through GR-1620's fairness invariants where the map is intended for random starts; MOD-730's Region and Landmark completeness; placed-unit ids absent from the selected set; seat count against the scenario's own victory Terms; and any victory parameter outside the range 14-victory.md VC-470 declares.

MOD-810 Map and scenario file formats MUST be documented JSON manifests with tile layers stored as row-major RLE arrays in tile-index order (GR-140), optionally externalized into a compressed binary chunk referenced by the manifest for very large maps; both forms fully documented. That chunk's compression MUST be one every target platform decodes natively (gzip or raw deflate), for the same reason MOD-020 restricts archive entries. Embedded metadata MUST include author, dimensions, topology, seat count, M, L, and the unit-set and terrain-set dependencies as (id, version range) pairs, so services can index content structurally rather than by parsing display names. Scenarios declare their unit set and terrain set as normal package dependencies (MOD-050) while remaining loadable under other unit sets per MOD-770.


10. Workshop

MOD-820 A single in-game workshop MUST cover all content kinds of MOD-040. The workshop is reachable from the Mod Manager, from game setup, and from the editors.

MOD-830 Publishing MUST be an in-game flow requiring a cloud account (08-services-platform.md); offline and guest play never require one, publishing does. The publish form captures name, description, tags and screenshots; structural facts — dimensions, topology, seat count, Region and Landmark counts, content types, dependencies and hashes — MUST be extracted from the package automatically. Map and scenario publishes SHOULD auto-generate a minimap thumbnail. Display names MUST support full Unicode.

MOD-840 Versioning: workshop items are updated by publishing a new SemVer version under the same package id by the same owning account. Published versions are immutable (MOD-130); each version carries a changelog. Subscribers MUST be notified of updates and choose per-item auto-update or pinning; in-progress games keep the exact versions they started with.

MOD-850 Discovery: browsing by creator MUST be supported, plus full-text search, filters by tag/type/size/seat count, and sorting by rating, downloads and recency. Downloaded and subscribed state MUST be tracked per account server-side; already-subscribed items are marked, and updated items resurface. Workshop content MUST also be browsable on the web.

MOD-860 Installing a workshop item MUST resolve and fetch its dependency closure (MOD-050) with a single confirmation listing everything to be installed — subscribing to a scenario pulls its unit set and terrain set automatically.

MOD-870 Multiplayer integration: when a lobby's game uses content a joining player lacks, the client MUST offer one-click download of the exact required versions, verified by hash per MOD-160, before the game starts. Self-hosted servers MUST be able to serve required content directly to joiners for games they host, so private content never requires the public workshop. A server serving content to browser clients MUST satisfy MOD-125's origin requirements; content delivered without readable CORS headers cannot be hashed (MOD-145) and MUST be refused rather than installed unverified, and the client MUST say so in those terms rather than reporting a generic download failure.

05-multiplayer.md MP-120 additionally requires a one-time per-plugin consent prompt before any sandboxed WASM plugin is fetched for a game; data content is fetched without one. That distinction is MP-120's and this document MUST NOT weaken it.

MOD-880 Moderation basics: every item MUST carry its license (MOD-030) and an in-app Report control. The service MUST support a takedown and appeal process including DMCA, delisting (hidden from discovery; existing subscribers and in-progress games keep functioning) as distinct from deletion (malware or illegal content only), and automated pre-publication scanning: schema validation, asset decode checks, size-cap enforcement (MOD-885), and WASM static verification that plugins import only the sanctioned host interface (MOD-590). Operational policies live in 08-services-platform.md.

MOD-885 Publication size caps. This document owns the per-content-type upload limits that 08-services-platform.md SVC-485 and SVC-490 delegate here. Every publish MUST be rejected before listing, with a human-readable message naming the offending item and both the actual and permitted size, when any of the following is exceeded. Defaults are instance-configurable within documented bounds, and the official instance's effective values MUST be advertised by the service so the client can pre-check locally before spending an upload:

Scope Default cap
.elcmod archive, total compressed 512 MiB
.elcmod archive, total decompressed 2 GiB
Any single file within the archive, decompressed 256 MiB
Overall decompression ratio (zip-bomb guard) 100:1
Unit set (unitset.json + sprites.json + encyclopedia.json, data only) 4 MiB
Terrain set (terrainset.json, data only) 1 MiB
Rule preset, command library, or victory Terms preset 256 KiB each
Map 8 MiB
Scenario 16 MiB
Localization pack, per locale file 4 MiB
Name list, per pool 1 MiB
Graphics asset pack 256 MiB
Audio asset pack 256 MiB
WASM plugin module (ai, worldgen), per module 32 MiB
Manifest manifest.json 256 KiB
Screenshots, each / per item 8 MiB / 10

Caps are a publication control and are distinct from MOD-500's per-platform install-time asset budgets, which govern client memory and VRAM and degrade by downscaling rather than rejecting, and from MOD-085's intake limits, which bound what any archive may expand to before it is trusted. Sideloading (MOD-900) is not subject to these caps — they protect the hosting service, not the engine; MOD-085 is what protects the engine.

The 32 MiB per-module figure is measured on the uncompressed module as stored in the archive, and is satisfiable by every template of MOD-670(a): an AssemblyScript module for a stock-scale AI is well under 1 MiB and a Rust one is single-digit MiB, so the cap is sized for the largest case the roster admits. It sits inside the 256 MiB per-entry decompressed cap and cannot on its own approach the 2 GiB archive total, so the three limits are mutually satisfiable as written.

The unit-set and terrain-set figures deserve a note, because they moved: with the combat matrix and the per-unit terrain map deleted (MOD-260, MOD-270), a twenty-unit set is roughly forty integers and two dozen strings per unit and comfortably under 100 KiB. 4 MiB is therefore not a constraint on any plausible set but a guard against a generated or accidentally-duplicated document, which is the only way a data file of this shape gets large.

Per-account aggregate publisher quota is the service's concern (08-services-platform.md SVC-490).

Rationale: SVC-485 and SVC-490 both delegate size limits to this document. The numbers are engineering estimates pending calibration against a real stock-scale asset pack (Open question 6); the decompressed-size and ratio guards, not the per-type figures, are the security-relevant ones.

MOD-890 Self-hosting and portability: the workshop service MUST be part of the self-hostable @everylastcity/server; a self-hosted instance runs its own independent catalog. Any item a user owns MUST be exportable as its .elcmod file, and sideloading that file into any instance MUST work (MOD-900) — which is how content moves between instances in v1. Cross-instance workshop mirroring MUST NOT exist in v1: 08-services-platform.md SVC-980 excludes federation, and SVC-840 forbids a self-hosted instance contacting the official service as a side effect of normal operation. Automatic mirroring is deferred to the post-v1 federation revisit that SVC-980 anticipates.

MOD-900 Sideloading MUST always work: installing a .elcmod from local disk requires no account and no network. A browser client has no disk, so there "from local disk" means the user handing the client the file: a file picker and drag-and-drop onto the Mod Manager MUST both work, and MUST work while offline. Sideloaded content is validated identically (MOD-080), bounded by MOD-085 (the MOD-885 publication caps do not apply to it), hashed on receipt (MOD-145), and participates in multiplayer verification by hash like any other content. All installed content MUST be fully usable offline — which on a browser client additionally obliges the application shell itself to be offline-capable via a service worker (Open question 12).

MOD-910 Abuse resistance: package ids are first-publish-owned per account; the service MUST prevent id squatting takeovers, rate-limit publishes, and require re-verification when ownership transfers. Ratings MUST be limited to accounts that downloaded the item.


11. Rules, command, and victory as content

The three subsystems whose vocabularies are closed, and whose parameters are not. This section is where the modding promise is at its narrowest and its statement of the boundary therefore matters most.

11.1 Rule presets

MOD-1000 A rule preset MUST be a single JSON document carrying a presetId (MOD-120), a guid, a localizable name and description, and a flat map of parameter keys to integer values. A rule preset is sim-affecting (MOD-040) and MUST enter the sim hash.

MOD-1010 A rule preset MUST be permitted to set exactly the parameters the owning specifications declare settable, and no others:

Group Parameters Owner
Map and generation every entry of the GR-1480 table — size, topology, landRatio, terrainMix, cityDensity, minCitySpacing, riverDensity, roadDensity, neutralDefence, startCities, minStartSeparation, fogEnabled, intelligenceDecay 01-game-rules.md
Turn model the Orders deadline of TM-480 10-turn-model.md
Industry only where 02-units-and-industry.md declares a range (MOD-235) 02-units-and-industry.md
Victory every Track parameter VC-470 declares settable, plus Charter Mode, Warrant Mode, SO, and the Horizon H 14-victory.md

A key outside this table MUST be rejected at load with the key name. A value outside the range its owner declares MUST be rejected at load with a named error, per VC-470 and GR-1490 — never clamped, never defaulted.

Rationale: clamping an out-of-range value is the failure mode that produces a game nobody configured and nobody can explain. Both owners say so independently — GR-1490 requires rejection before generation is attempted, VC-470 requires rejection at load — and this document's only job is to make the file format incapable of expressing the thing they forbid.

MOD-1020 A rule preset MUST NOT be able to change any closed vocabulary of MOD-185, any formula, any threshold not listed in MOD-1010, or the classification of any content type under MOD-150. A preset that would need one of those is asking for a rules change, and a rules change is a RulesVersion bump (03-architecture.md AR-880), not content.

11.2 Command content

MOD-1030 The Posture vocabulary (CM-330), the Sanction set (CM-570), the Reflex set and priority (CM-290, CM-300), the Stance values (TM-630) and the order stack depth (CM-230) are closed to content. No package may add, remove, rename or re-parameterise any of them. CM-330 and TM-630 both state that adding a value is a rules change requiring a version bump, and CM-2060 requires any change to a Posture algorithm, a Sanction test, a tie-break, a threshold or a default to bump commandLogicVersion — which a mod cannot do, because a mod cannot make a replay recorded under it replayable.

Rationale, stated because this is the answer people will not expect: the command layer looks like the most obviously moddable thing in the game and is the least. Its automation runs in the simulation core and is therefore replay-relevant (CM-100, CM-2050), so a seventh Posture is not a preference, it is a fork of the rules that silently invalidates every recorded game. What makes this acceptable rather than merely restrictive is the shape CM-570 chose: four orthogonal Sanctions over six Postures is a combinatorial space far larger than a longer list of named behaviours, and that space is fully open to content through MOD-1040.

MOD-1040 A command library MUST be a JSON document carrying named Doctrines — each a bundle of a Posture, all four Sanction values, an optional Formation role and an optional Stance override, exactly as CM-770 defines a Doctrine — plus an optional default-Doctrine mapping from unit class (US-090) to Doctrine name, serving CM-790. It is sim-affecting (MOD-040), because a Doctrine is authoritative game state (CM-770) and a default Doctrine determines the orders a newly built unit carries.

A command library MUST NOT carry a unit-specific Posture parameter — a particular Screen line, Survey region or March route — because CM-810 forbids a Doctrine from expressing one.

MOD-1050 Installing a command library MUST NOT alter any unit in a running game. A library supplies a starting Doctrine set for a new game and a set the player may import into a running one; importing MUST be an explicit player action and MUST follow CM-800's rule that editing a Doctrine does not retroactively retask units already created under it, offering the counted apply to the N units currently on this Doctrine action instead.

Rationale: CM-800 calls silently retasking three hundred units the fastest possible way to destroy trust in automation. A shared Doctrine library that did it on install would be that, arriving from a stranger.

MOD-1060 A command library MAY additionally carry named Templates and Requisition rows (CM-1240, CM-1310) keyed on unit class, so that a shared library can supply a complete production and posting policy rather than only a set of dials. Requisition rows keyed on unit id MUST be rejected, per US-100.

11.3 Victory content

MOD-1070 A victory Terms preset MUST be a JSON document naming which Tracks of the 14-victory.md catalogue are enabled and, for each, the values of its settable parameters — together with Charter Mode, Warrant Mode, the number of Sealed Orders SO, and the Horizon H. It MUST carry a localizable name and description and is sim-affecting.

MOD-1080 A Terms preset, and a scenario (MOD-770), MUST be able to set every parameter 14-victory.md declares settable, within the ranges it declares — which is the first half of the obligation VC-3200 places on this document — and the format MUST express all of them without exception, including Vigils, Ebb floors, thresholds, RotP, A, WarPeriod, and per-Track enable flags.

MOD-1090 A Terms preset, a scenario, and every other form of content MUST NOT be able to: add a Track to the catalogue of VC-880; disable Last Standing (VC-450); alter a Standing weight; alter a tie chain; alter the Ebb formula; or change any reward in VC-1860's exhaustive list. This is the second half of VC-3200 and the format MUST make each of them inexpressible rather than merely rejected — there is no key for a Standing weight in the schema at all.

Rationale, quoting the obligation's own reason: parameters are content, the scoring model is rules. A mod that changes what Standing counts changes what every tie-break in the game means, and no readout, estimate or lint rule would still be true (VC-3200). The stronger form — no key rather than a rejected key — is chosen because a rejected key invites an author to ask for it to be accepted, whereas an absent one states the design.

MOD-1100 Every Terms preset MUST pass 14-victory.md's setup linter (VC §12.4) before it may be published, and the publish flow MUST show the generated Terms paragraph (VC §12.3) and the estimated game length. A preset failing linter check L4 — the termination proof of VC-090, or the event-cadence requirement of VC-060 — MUST be rejected, not warned about.

Rationale: VC-090 requires every legal setup to be provably finite and makes an unbounded one a linter error. A workshop that let an unbounded Terms preset be published would be distributing a game that cannot end, and the person who downloads it finds out at turn 400.

MOD-1110 The subjects of Charters, Sealed Orders and Warrants — which Region, which Landmark, which rival — are drawn at generation from the generated map per VC-170 and are not content. A scenario MAY fix a Charter per seat from the pool of VC-1630 (MOD-780) and MAY fix the Sealed Order count, but MUST NOT author a new Charter, a new Sealed Order text, or a new Warrant form: all three pools are enumerated in 14-victory.md (VC-1630, VC-1750, VC-1810) and are closed for the same reason the Track catalogue is.

MOD-1120 The victory plugin kind: designed, reserved, and not shipped in v1. A mod that adds a genuinely new victory condition is a compelling capability and this document specifies its shape so that admitting it later is a decision rather than a redesign. It is not loadable in v1, and it MUST NOT be loadable until 14-victory.md amends VC-880 and VC-3200 to admit it.

Its designed shape, should it be admitted:

The reasons it is not shipped in v1 are worth stating rather than leaving as an omission. First, VC-3200 forbids it today and this document does not get to overrule its owner. Second, VC-030 requires every enabled Track to be answerable in integers at every Seal for the readout, the setup estimator, the AI evaluator and the setup linter — and a plugin-supplied Track defeats the estimator and the linter specifically, because VC-090's termination proof and VC-2030's length estimate are computed from the catalogue and cannot be computed for an arbitrary function. Admitting the kind therefore requires an answer to "what does the setup screen tell a player about how long this game will take", and "we cannot say" is a real answer but it is 14-victory.md's to give. Third, the natural first request — "first to build a Carrier", "hold three Arsenals" — is already on the table as 02-units-and-industry.md US-2180, and if 14 admits unit- and Industry-valued terms into the existing catalogue then most of the demand for this kind evaporates without any plugin at all.

MOD-1230 records the request; Open question 2 records what must be decided.


12. Contracts on other documents

Obligations this specification places on subsystems it does not own, recorded so that a change on either side is detectable.

MOD-1200 02-units-and-industry.md owns the unit definition schema and this document's §3 is its serialization. Where the two disagree, 02 wins. Specifically: §3 assumes US-070's identity fields are the complete identity set with no compatibility alias; US-720's rejection of unknown fields with no exemption; US-230's eight combat integers with no per-target table; US-120's three terrain-related fields with no per-unit cost map; and US-470's eighteen flags as a closed set.

MOD-1210 02-units-and-industry.md MUST state whether the Industry Works multipliers and upgrade costs of US-820 are settable by a rule preset and, if so, their legal ranges. Until it does, MOD-235 keeps them out of the format entirely. Its own open question 3 says this is the decision most likely to be reversed, which is precisely why a modding surface should not be opened onto it first.

MOD-1220 02-units-and-industry.md MUST decide where a custom set's counter relationships live. US-1630 requires both lists on every unit card and US-2110 requires the encyclopedia to be generated from the loaded set, but US-1640 states the shipped web as prose in that document and §2's schema carries no field for it. MOD-295 proposes a presentation-only companion document as the answer, which keeps US-720's closed schema intact; if 02 prefers fields on the unit record instead, MOD-295 is withdrawn and MOD-225 follows.

MOD-1230 14-victory.md VC-3200's first half is satisfied by MOD-1080 and its second half by MOD-1090. Beyond that, 14-victory.md MUST decide whether a victory plugin kind (MOD-1120) may ever be admitted, and if so what VC-090's termination proof and VC-2030's length estimate say about a Track whose progress function the engine cannot analyse. It MUST also resolve 02-units-and-industry.md US-2180 — whether Industry level, unit classes built, or possession of specific unit types are legal Track terms — because a positive answer there removes most of the motivation for the plugin kind.

MOD-1240 13-command.md MUST confirm that a Doctrine library (MOD-1040) is a legitimate content type and that importing one into a running game is the explicit, counted action CM-800 requires. Its §6 currently describes Doctrines as created in-game only, and says nothing about a shared, versioned, distributable set of them; nothing in CM-770 through CM-830 forbids it, and MOD-1030 states the boundary this document believes CM-330, CM-570 and CM-2060 draw. If that reading is wrong, the error is here.

MOD-1250 01-game-rules.md owns terrain and this document's §4 is its serialization. GR-370's validation is authoritative and MOD-305 adds only format-level checks. Where GR-1700 asks 02 for a recon flag, MOD-240's table follows 02's answer and does not anticipate it. GR-420's three Installations and GR-450's three edge features are the complete palette of MOD-710.

MOD-1260 01-game-rules.md MUST state whether a custom terrain set may declare a number of terrain types other than eleven. GR-370 caps the count at 256 and GR-260 says the base set is exactly eleven, which reads as "custom sets may differ" but is not said. MOD-301 assumes they may; every downstream rule this document could find — GR-290's table, GR-350's attributes, GR-1520's mixes — is already written per-type rather than per-eleven, so the assumption looks safe and is recorded here so that it is checked rather than inherited.

MOD-1270 10-turn-model.md TM-200 and TM-210 are the transport MOD-660 requires of an AI plugin: order records into the authoritative log, one submission per seat per turn, during that seat's Orders phase. TM-340's stable board and TM-290's well-defined empty turn are what make a per-turn plugin invocation and a faulted-plugin fallback correct with no special case.

MOD-1280 03-architecture.md owns canonical JSON serialization (used by MOD-140), the plugin host (AR-855), the PRNG streams (AR-350, AR-370) and the worldgen entropy rule (AR-400). AR-750's pathfinding requirement and AR-430, AR-440, AR-450, AR-460 and AR-470's fog rules are still written against the withdrawn content model and cite unit and rule behaviour that no longer exists; they need re-resolving against 01-game-rules.md §9 and §10 and against 02-units-and-industry.md §3.4. This document cites AR-750 only for its deterministic tie-break.

MOD-1290 06-ai.md carries two stale citations of the deleted MOD-980 — in AI-265 and in the float-discipline paragraph of its §5 — which MUST be re-resolved: the roster obligation those citations reach for is MOD-670(a), and the float discipline is MOD-600. More substantially, its §3 and §5 describe a per-turn AI that submits an order, observes its resolution, and decides the next one (AI-230), which the Orders-and-Cascade model forecloses; MOD-660 states the transport this document owns and 06-ai.md MUST restate its lifecycle against it. Its AI-600 also contemplates a custom unit set carrying "WASM scripted abilities", which 02-units-and-industry.md US-2060 and MOD-300 both forbid. 06-ai.md's roster of shipped AI personas, its handicap levers and its view surface are its own and this document takes no position on them.

MOD-1300 05-multiplayer.md MP-110 and MP-120 own content pinning and distribution enforcement; MOD-160 and MOD-870 supply the hashes and the fetch. 08-services-platform.md SVC-485 and SVC-490 delegate size caps here and MOD-885 answers them; SVC-980 and SVC-840 bound MOD-890. 04-ui-ux.md owns the presentation of the Mod Manager, both editors, the setup screen and the workshop, implementing the content requirements stated here; its dependency list still names the void 02-unit-sets-content.md and needs correcting.


Open questions

  1. Does the unit-set format need a versioned migration path? US-080 fixes id forever and MOD-190 carries a schemaVersion, but nothing says what happens when 02 adds a nineteenth flag or a new capability parameter and a hundred workshop sets predate it. The cheap answer is that a missing field defaults and the set's engineApi range keeps it out of incompatible clients; the honest answer is that a defaulted flag silently changes how a set plays. A written migration contract — additive fields default, removed fields are a major schemaVersion bump, renamed fields never happen — probably belongs in this document and is not yet written.

  2. The victory plugin kind. MOD-1120 designs it and gates it. What has to be settled, in order: whether 14-victory.md will amend VC-880 and VC-3200 at all; if so, what the setup screen tells a player about the length of a game whose Track the estimator cannot analyse (VC-2030) and what the termination proof of VC-090 says about it; whether admitting unit- and Industry-valued terms into the existing catalogue (US-2180) removes most of the demand first; and whether a Modded-only, ranked-excluded capability is worth the ABI surface it costs.

  3. Where does a scenario's authored victory state live relative to the Ebb? A scenario may fix a Track's threshold (MOD-1080) and 14-victory.md's Ebb then descends it from that value (VC §7). A designer who set a threshold as a floor rather than a start will find it drifting. Either the Terms format needs an explicit "no Ebb" flag per Track — which VC-1050 already implies for Hegemony at its floor — or scenario thresholds need a different semantic from preset ones. Raised, not adjudicated.

  4. Does a terrain set need a canonical presentation ordering? MOD-301 stores terrain types as an ordered array and MOD-302 gives each a stable id. The palette, the encyclopedia and the legend all need an order, and array order is the obvious one — but it means reordering a document for readability changes the sim hash. A separate displayOrder integer would decouple them at the cost of one more field nobody thinks about. Same question applies to unit sets, where US-050 already fixes iteration order as ascending id but says nothing about display.

  5. Is one unit set per game the right restriction for content? US-2080 rejects mixing sets at setup and this document follows it without argument. It is almost certainly right for balance, and it also means a "just this one extra unit" mod is impossible — an author must fork the whole shipped set, and then never receives a balance fix. A declarative overlay format (add these units, override these fields, against a named base set at a named version) would solve it, is a real amount of specification, and would need US-2080's blessing and a merge rule that survives hashing.

  6. Publication size-cap defaults. MOD-885's numbers are engineering estimates. Re-derive the graphics, audio and total-archive caps from a real stock-scale asset pack, and confirm with 08-services-platform.md that SVC-490's publisher quota composes sensibly with them. The data-format caps moved down when the combat matrix and per-unit terrain maps were deleted and should be re-checked against a real twenty-unit set rather than against arithmetic.

  7. How far the no-floating-point rule reaches into a plugin. 06-ai.md AI-385 binds plugin code whose output influences game state to 03-architecture.md AR-080's no-floating-point-arithmetic rule, while MOD-600 permits plugin-internal float arithmetic and keeps only the ABI boundary float-free. Decide, before the plugin validator is written, whether the validator rejects float instructions outright — which would exclude AssemblyScript, whose standard library is float-bearing, and therefore a first-class template of MOD-670(a) — or polices only the boundary MOD-600 fixes.

  8. Audio codec baseline. MOD-470 needs a confirmed decode matrix across the supported browser versions to fix the one variant the pipeline always emits. Until then a stock sound could be silent on a real player's device.

  9. GPU-compressed textures. MOD-410 leaves atlas encoding to the install-time pipeline. Measure KTX2/Basis against a stock-scale pack: does the texture-memory saving on low-end mobile justify transcoding inside a browser tab at install time, and does the per-device format matrix leave a fallback still worth shipping?

  10. Plugin module size and a genuine-TypeScript guest. MOD-885's 32 MiB per-module cap is satisfiable by the MOD-670(a) roster as written. If a template carrying a JavaScript engine inside the guest is ever admitted, re-derive the cap against a real build of it — and re-check it against the 256 MiB per-entry and 2 GiB archive caps — before that template is promised anywhere.

  11. The metering instrumentation pass. MOD-610 turns fuel from a runtime setting into a compiler pass this project owns. Who owns it, what a unit of budget costs per instruction class, how its version is recorded alongside 06-ai.md AI-410's fuel limits, whether the transform runs at publication or at install, and how a game recorded under the uninstrumented local-interactive path is marked so it can never be mistaken for verification evidence later.

  12. Browser storage budget and the offline shell. MOD-115: how much stock content must a browser client hold before it can start its first game, is mid-game eviction always recoverable, and does the answer force a streamed per-asset delivery mode rather than whole-package install? MOD-900 additionally requires a service-worker-cached application shell whose update policy must compose with MOD-125's immutable content URLs and with client-release-pinned certified hashes (MOD-060).

  13. Certified-content updates. When a shipped unit-set or terrain-set value needs a fix, what re-certification process keeps MOD-170's Certified label coherent across client versions, and what happens to an in-progress game pinned to the old hash? With 03-architecture.md and 08-services-platform.md.

  14. Collaborative editing. Multi-user cloud editing of maps and scenarios is deliberately out of scope for v1; revisit after the workshop ships.

  15. Post-v1 workshop federation. MOD-890 defers cross-instance mirroring to SVC-980's federation revisit. When that opens, this document owes the content half: licence propagation across instances, whether a mirrored item keeps its origin (id, version, contentHash), and how sim-hash verification (MOD-160) behaves for content fetched from a mirror.

  16. Canonical lowering versus a component-native host. MOD-580 fixes a core-module lowering because browsers cannot instantiate components. If a server-side host later adopts a component-native runtime for convenience, the two paths could diverge in exactly the way MOD-575 forbids. Decide whether that door stays closed permanently or reopens only behind the MOD-600 differential job.

  17. The worldgen half of the in-worker host. MOD-575 places the mapgen draw cursor in the plugin worker for the duration of a generate call, because 03-architecture.md AR-400(a)'s draw functions are synchronous imports. Unlike the AI case, which 06-ai.md AI-235 specifies end to end, no partner requirement states the worldgen arrangement: confirm with 03-architecture.md that a cursor lent to a worker and returned advanced still satisfies AR-370's host-owned-stream rule, decide what happens to the cursor when a generation is cancelled (MOD-650) or the worker is terminated (MOD-620), and settle whether 03 or this document owns that statement.