# v2 data formats (contract, v1.3) Concrete schemas for the contracts in `agents/world-format-v2.md` and its area docs in `agents/v2/`. Draft 1, 2026-09-27, from the formats design pass; v1.1 the same day, resolving the 18 issues the catalogue pilot found and adding the schemas the spec needed but lacked (vehicles, clothing and bodies, world rules, asset references, system manifests, third-party origin labels); v1.2 the same day, resolving the adversarial review of v1.1 (15 items), the dialogue engine's two contract needs and the kernel's phase names (tables at the end); v1.3 (forge MVP) adds the `weld` overlay entry and the `trigger` and `speaker` logic classes. Change here first, then code; `v2/tools/validate.mjs` enforces what is below. Where a choice was not made by the spec it is listed under "Decisions taken here"; challenge any of them before code depends on them. Nothing has shipped, so v1.3 still keeps `format: 1` for every kind. ## Conventions - Every file has `format: ` and `kind`. The integer is versioned per `kind`; CI migrates N to N+1. (`public/world/registry.json` uses `v: 1` today: migrate or alias.) - **Content ids:** `modid:type/name`, lowercase, extra `/` segments allowed (`ncl:prop/bin-wheelie-ncc-green`). The type segment is fixed by `kind` for every kind except `class`: `physmat` `phys/`, `surfmat` `surf/`, `overlay` `overlay/`, `mission` `mission/`, `sound` `sound/`, `particle` `particle/`, `decal` `decal/`, `pattern` `pattern/`, `wear` `wear/`, `handling` `handling/`, `system` `system/`, `strings` `strings/`, the dialogue kinds (section 15) `concept/`, `crit/`, `rule/`, `resp/`, `line/`, `release/` and `dialogue/` (a pack), and `world/` for a world base. Classes pick their own type (`prop`, `gib`, `logic`, `vehicle`, `vpart`, `weapon`, `npc`, ...) but never one of those reserved words, and an abstract base may be a single segment (`core:prop_physics`, `core:vehicle_wheeled`, Source's naming). - `core` is a reserved mod id: the engine built-ins ship as the `core` mod, versioned with the engine; the official city is the mod `ncl`. A mod defines ids only in its own namespace, and may reference another namespace only if it lists that mod in `depends` or `optional`. **The official bundle** (`core` and `ncl`) depends only on itself and ships signed (section 5), so a third-party mod calling itself `ncl` is not official. - Registry ids stay bare (`NZ2694265221`, `NZ2694265221-W2`): facts of the base world, not mod content, only in fields typed as registry refs. - Instance ids: `#`. Pipeline-placed props: `gen#..`. - **Frame:** world-local metres from `world.json` `origin` (EPSG:27700), the v1 local frame: right-handed, **Y up**, x east, z south, y above 30 m OD. Quaternions are `[x, y, z, w]` (glTF order), unit length. Units are SI (table below). **Angles are radians**; a field holding degrees must say so with a `Deg` suffix (`coneDeg`), and nothing else may be in degrees. - Document keys are camelCase with a unit suffix where the unit isn't obvious (`densityKgM3`, `ttlS`, `rangeM`). In suffixes `N` is newtons and `Nm` is always newton-metres (a torque); "per metre" is `pm` (`stiffnessNpm` N/m, `dampingNspm` N·s/m). Component and field **names** (the things systems read and write) are snake_case (`smart_object`, `air_temp`, `heat_emit`); I/O and event names are PascalCase. - **Hashes** are `sha256:` (WebCrypto native in browsers). A **file** is always hashed over its exact bytes. A **record built in memory** that has no file (a `look`, the expanded world rules, the lock id table) is hashed over its canonical JSON: keys sorted, no whitespace, numbers as JavaScript's shortest round-trip form, UTF-8 (`canon()` in the validator). Those are the only two rules. - **Origin** (who made it, whether we may redistribute it): absent means native v2. Anything imported from v1 or taken from a third party carries `origin` on the document (classes, materials, asset references, wear, handling) or the overlay entry, and v1 imports also mark individual fields `prov.src: "v1"`. So it can always be found and replaced: `validate.mjs` prints every non-native or placeholder item grouped by vendor. - **Origin taint:** a document stands on everything it references (class parents, parts' materials and surfaces, variant surfaces, gibs, `spawnOnBreak`, handling, outfit wear and patterns, a material's sounds, particle and decal, a surface's physmat, a particle's sound, armour physmat). If any of that is `redistributable: false`, so is the document in effect: it can't be `exportable`, and the origin summary lists it under that vendor as `[via ]`. Referencing official non-redistributable content from a third-party mod is allowed (the bytes never leave the official bundle); exporting it is not. ```ts type Id = string; type RegId = string; type IId = string; type Hash = `sha256:${string}`; type Vec3 = [number,number,number]; type Quat = [number,number,number,number] /* x,y,z,w */; type Prov = { src: "authored"|"measured"|"ai"|"derived"|"v1"; model?: string; run?: string; from?: string /* where the value came from: repo path#fragment or URL */; conf?: number; at?: string; note?: string /* free text for the next human */ }; type Origin = "v1" /* shorthand for { from: "v1" } */ | { from?: "v1"|"vendor"; // omitted = native v2 (then only `placeholder` is meaningful) vendor?: string /* slug, e.g. "quaternius"; required when from = "vendor" */; licence?: string /* SPDX-ish, e.g. "QAL-1.0"; required for vendor */; url?: string; redistributable?: boolean /* required for vendor; false = only inside official ncl/core, never exported */; placeholder?: boolean /* true = replace this later */ }; type ParamType = "void"|"bool"|"int"|"float"|"string"|"vec3"|"iid"|"id"; type Moderation = "draft"|"pending"|"approved"|"rejected"; // only in the hub's approvals list (section 12) ``` Documents that can hold third-party files also take `exportable?: boolean` (the forge, the duplicator or the mod SDK may hand the files to users). `redistributable: false` content may live only in the official `core`/`ncl` bundle and may never be `exportable`, directly or through taint. ### Units | Quantity | Unit | Fields | |---|---|---| | length, position | m | `offset`, `world`, `dims`, `*M` (`rangeM`, `radiusM`, `scaleM`) | | mass | kg | `mass`, `fuelKg`, `massKg` | | density | kg/m³ | `densityKgM3` | | angle | rad | joint `limits`, `steer`, alias `xform` drot; `*Deg` fields only | | time | s | `delay`, `ttlS`, `easeS`, `shiftS`, `lifeS`, timer `interval`/`random` (floor 0.1 s) | | tick | 1/60 s; `every: k` runs at 60/k Hz | system `every` | | world time | s since 1970-01-01T00:00Z, UTC, float64 | recording `clock.epoch` | | force / torque | N / N·m | `breakForce`, `breakTorque`, `*N`, `*Nm` | | linear stiffness / damping | N/m / N·s/m | `stiffnessNpm`, `antiRollNpm`, `dampingNspm` | | power | W | `drawW`, `supplyW` | | temperature | °C | `ignitionC` | | specific heat | J/(kg·K) | `cpJkgK` | | heat capacity | J/K, whole entity (derived: sum of part mass x cp) | `systems.heatCapacity` | | conductivity | W/(m·K) | `conductivity` | | burn rate | kg/(m²·s) of exposed surface | `burnRate` | | strength | MPa, the stress that triggers `fail`: compressive for `crumble`, yield for `bend`/`dent-tear`, flexural for `shatter`/`splinter` | `strengthMPa` | | projectile absorption | J per m of thickness | `absorbJm` | | energy / impulse | J / N·s | `energyJ`, `impulseNs` | | health | HP, where 1 HP = 1 J of damage energy absorbed | part `health`, `destructible.health` | | drag | dimensionless coefficient Cd | `sail.drag` | | flow | m³/s | `buoyancy.fillM3s` | | pitch | playback-rate multiplier, 1 = as recorded | sound `pitch` | | colour | linear-light Rec.709 RGB; palettes are 256 sRGB 8-bit entries | `tint` (multiplier 0-4), `paint` (0-1), palette indices | | insulation | clo (1 clo = 0.155 m²·K/W) | `insulationClo` | | rates, ratios | 0-1 unless named | `wetGripMul`, `waterproof`, `flammability` | ## 1. Entity class (catalogue entry) Hammer FGD-style inheritance (`extends`). Mesh semantics live in JSON and point at glTF node names; glTF `extras` are an import convenience only. Collider fields modelled on the draft `KHR_physics_rigid_bodies` / `OMI_physics_body`. ```ts interface EntityClass { format: 1; kind: "class"; id: Id; origin?: Origin; exportable?: boolean; extends?: Id[]; abstract?: boolean; sim?: "deterministic"|"authoritative"|"owned"|"cosmetic"; // who simulates this entity's body variants?: Record; mesh?: string }>; lods?: { mesh: string; node?: string; maxDist: number }[]; // default []: logic entities have none parts?: Part[]; // default []; single-part props have one part "root" joints?: Joint[]; components?: Record; // light, seat[], smart_object, vehicle, vpart, weapon, body, outfit... io?: { outputs: Record; inputs: Record }; keyvalues?: Record; systems?: SystemsAttrs; destructible?: { mode: "gibs"|"voxel"|"prefractured"|"none"; health: number; gibs?: Id[]; voxel?: Hash; spawnOnBreak?: Id /* class spawned where it broke, e.g. an explosion */ }; prov?: Record; // JSON pointer ("/parts/0/mass") -> who set it } interface Part { name: string; node: string; physmat?: Id /* default: surf's physmat; wins if both */; surf?: Id; mass?: number; collider: { shape: "hull"|"box"|"sphere"|"capsule"|"trimesh"; hulls?: string; dims?: number[]; offset?: Vec3; rot?: Quat /* the collider in the part node's frame; default identity */ }; health?: number } interface Joint { a: string; b: string /* part or "@world" */; type: "fixed"|"hinge"|"ball"|"slider"|"rope"|"keepupright"; anchor: Vec3; axis?: Vec3; limits?: [number,number] /* rad (hinge), m (slider) */; angLimits?: [[number,number],[number,number],[number,number]] /* ball only: rad about the class x, y, z axes at the bind pose */; breakForce?: number; breakTorque?: number; motor?: object; onBreak?: string /* output fired, e.g. "OnLidOff" */ } interface SystemsAttrs { heatCapacity?: number; ignitionC?: number; fuelKg?: number; power?: { role: "source"|"conductor"|"consumer"; drawW?: number; supplyW?: number; ports?: string[] }; buoyancy?: { pontoons: Vec3[]; volumeM3: number; fillM3s?: number }; sail?: { areaM2: number; drag: number; attach?: string } } interface Seat { pos: Vec3; facing: Vec3; pose: "sit"|"drive"|"ride"|"stand"|"lie"; part?: string; driver?: boolean; exposure?: "enclosed"|"open"; exit?: Vec3 } // components.seat: Seat[] ``` - **`systems` vs `components`:** `systems` holds only the numbers the world systems (heat, fire, power, water, wind) read; everything else is a component. Power lives in `systems.power`, never in `components`. - **`sim`:** `owned` is peer-owned physics under the hybrid model (props, the driver's car), replicated and sanity-checked by the server; `authoritative` is server-run (logic entities, traffic spine, fire); `deterministic` replays from inputs; `cosmetic` is local only (gibs, debris that doesn't matter). Inherited like any scalar; **`core:entity` declares `authoritative`**, so every class has a value and the safe one is the default. - **Cosmetic bodies** are not stepped by the shared `physics` phase: a `post`-phase cosmetic system steps them in a separate local physics world that reads the shared bodies as static-for-the-tick colliders and never pushes them back (one-way). So "only `post` may hold cosmetic systems" (section 14) holds for gibs too. - **Keyvalue bounds:** `min`/`max` bound int and float keyvalues (elementwise for arrays), checked on the default and on every placement; `unit` names the unit for the forge. The timer's `interval` and `random` have `min: 0.1` (section 4). - **Inheritance:** every class implicitly extends `core:entity` first. Parents apply in `extends` order, then the class itself: scalars (`sim`) are replaced; `components`, `keyvalues`, `variants`, `io.inputs`, `io.outputs` and `systems` merge one level deep by key (a child's `light` replaces the parent's whole `light`); arrays (`lods`, `parts`, `joints`) and `destructible` replace wholesale. `abstract`, `origin` and `prov` are not inherited (origin *taint* is, see Conventions). - **Common I/O** (`core:entity`, on every entity): inputs `Kill`, `KillHierarchy`, `AddOutput(string)`, `FireUser1`..`FireUser4`; outputs `OnUser1`..`OnUser4`, `OnKilled`. - **Health and breaking:** damage (J) to a part subtracts from the part's `health` and from the class `destructible.health` (both HP, 1 HP = 1 J). A part at 0 breaks every joint on it (each firing its `onBreak` output) and becomes a free prop; parts without `health` break only by joint force. The class pool at 0 fires `OnBroken`, applies `destructible.mode` (gibs replace the entity, voxel carves it) and spawns `spawnOnBreak`. Example: ```json {"format":1,"kind":"class","id":"ncl:prop/bin-wheelie-ncc-green","extends":["core:prop_physics"], "parts":[{"name":"body","node":"Body","physmat":"ncl:phys/hdpe","collider":{"shape":"hull","hulls":"Body_COL"}}, {"name":"lid","node":"Lid","physmat":"ncl:phys/hdpe","collider":{"shape":"hull","hulls":"Lid_COL"}}], "joints":[{"a":"body","b":"lid","type":"hinge","anchor":[0,1.05,-0.3],"axis":[1,0,0],"limits":[0,4.712389],"breakForce":900,"onBreak":"OnLidOff"}], "systems":{"sail":{"areaM2":0.6,"drag":1.1}}, "prov":{"/parts/0/mass":{"src":"ai","model":"haiku","conf":0.6}}} ``` ## 2. Materials ```ts interface PhysMat { format: 1; kind: "physmat"; id: Id; origin?: Origin; extends?: Id; densityKgM3: number; friction: number; restitution: number; strengthMPa?: number; fail: "bend"|"shatter"|"splinter"|"crumble"|"dent-tear"|"none"; sounds?: { impact?: Id[]; scrape?: Id; break?: Id[] } /* sound ids */; particle?: Id; decal?: Id; absorbJm?: number /* the spec's `absorb` */; cpJkgK?: number; conductivity?: number; ignitionC?: number; burnRate?: number; wetGripMul?: number; iceGripMul?: number; voxelPalette?: number /* 0-255, Teardown-style */; prov?: Record } interface SurfMat { format: 1; kind: "surfmat"; id: Id; origin?: Origin; exportable?: boolean; physmat: Id; albedo: string; normal?: string; orm?: string /* AO/rough/metal packed, KTX2 */; triplanar?: boolean; scaleM?: number; tint?: [number,number,number] /* linear multiplier */; wetResponse?: { darken: number; gloss: number }; prov?: Record } ``` ## 3. Overlay files and instances ```ts interface OverlayFile { format: 1; kind: "overlay"; id: Id; layer: Layer; author: string; mod: Id; requires?: Id[]; entries: OverlayEntry[] } type Layer = "generated"|"overlay"|"canonical"|"proposed"|"personal"|"session"; type OverlayEntry = | { op: "place"; iid: IId; class: Id; origin?: Origin; variant?: string; name?: string; at: { reg: RegId; offset: Vec3; rot: Quat } | { world: Vec3; rot: Quat }; kv?: Record; conns?: Conn[]; parent?: IId; reset?: ResetPolicy; author?: string } | { op: "modify"; target: IId|RegId; set?: Record; unset?: string[] } | { op: "remove"; target: IId } | { op: "building"; reg: RegId; model?: string; facade?: Record } | { op: "terrain"; stamp: { bbox: [number,number,number,number]; heightDelta?: Hash; paint?: Hash } } | { op: "weld"; iid: IId; a: IId; b: IId|"@world"; partA?: string; partB?: string; breakForce?: number; breakTorque?: number; author?: string }; interface ResetPolicy { mode: "never"|"ttl"|"session"|"wipe"; ttlS?: number; regrow?: "instant"|"eased"; easeS?: number } ``` **Weld** (forge MVP, v1.3): a fixed joint between two placed instances, or one instance and `"@world"`, holding the pose they have when the layer loads (no anchor: the forge welds things where they stand). `partA`/`partB` name a part of each class (default: its first part). A weld has its own `iid` under the overlay's prefix, so it is removed and undone like a placement and merges per id; it has no I/O and no `kv`. Breaking it needs `breakForce` or `breakTorque` (N, N·m); omitted means unbreakable. Welding another mod's instance changes its physics, so it needs that instance in `touches` like any foreign edit. (`parent` stays the transform hierarchy only; a weld is physics only.) **Merge order:** generated, overlay (mod load order, then file path), canonical, proposed (preview only), personal, session. A later layer wins per field (`modify`) or per id (`place` / `remove`). Overlays are also the only way one mod changes another's world; catalogue ids are never redefined across mods (decision 18 of v1.1). **What a mod may touch.** Anything an overlay changes that its mod doesn't own is declared in `mod.json` `touches` (registry ids, instance ids, or `"#*"` for all of one overlay's instances): `modify`/`remove` of another mod's instance, `modify` of a registry id, and every `building` op. `modify.set`/`unset` names placement fields only, never `iid` or `op`; on another mod's instance only `at`, `variant`, `kv` and `reset` (its `class`, `name`, `conns` and `parent` are its owner's wiring and identity). The hub shows `touches` next to the mod, and two mods touching one key is a visible conflict, not a silent merge. ## 4. I/O connections and logic entities ```ts interface Conn { output: string; target: string /* iid | name | "name*" | "!self" | "!activator" | "!caller" | "!player" */; input: string; param?: unknown /* omitted = pass the output's value */; delay?: number /* s */; times?: number /* -1 infinite, 1 = once */ } ``` Logic entities are ordinary classes under `core:logic/*`, configured by `kv`, with no `lods` or `parts`: - `relay`: inputs Trigger, Enable, Disable, Toggle; output OnTrigger. - `case`: `kv.cases` (string array); inputs InValue, PickRandom, PickRandomShuffle; outputs OnCase01..16, OnDefault. - `branch`: `kv.initial`; inputs SetValue, Toggle, Test; outputs OnTrue, OnFalse. - `counter`: `kv.min/max/start`; inputs Add, Subtract, SetValue; outputs OnHitMax, OnHitMin, OutValue. - `timer`: `kv.interval` (s, min 0.1), `kv.random` (float array `[lo, hi]`, s, min 0.1); inputs Enable, Disable, FireNow; output OnTimer. - `trigger` (forge MVP, v1.3): a box volume, `kv.size` (vec3, m, full extents in the entity's frame), `kv.startDisabled`; inputs Enable, Disable, Toggle; outputs OnPlayerEnter(iid), OnPlayerExit(iid) (the activator is the player). - `speaker` (forge MVP, v1.3): a point voice source, `kv.voice` (the speaker archetype the line must allow, `"*"` any), `kv.radiusM`; input PlayVoice(id: a `line` id from the catalogue, never free text); output OnPlayed(id). The line still obeys the world's `rules.language` and its own `speaker` list (section 15). A keyvalue with `array: true` holds an array of its `type`; I/O parameters stay scalar. **Timing.** An input fired by an output is never delivered in the tick that fired it: `delay: 0` means the next tick, and every delay rounds **up** to whole ticks (1/60 s). So a loop of relays advances one hop per tick and can't hang a tick. **Budget.** Each mod has a per-tick budget of I/O deliveries (placeholder 256, set by the bench harness); deliveries over it are deferred to the next tick in order and the mod is flagged in the dev console. A timer can't fire faster than every 0.1 s. **Scope.** A target resolves only within the caller's scope: the instances of the caller's own mod in its layer (or the mission's session layer) plus entities the caller holds a lease on. Names and wildcards match only there; an explicit iid outside it is dropped and logged. That applies to what `Kill`, `KillHierarchy` and `AddOutput` can reach too (an `AddOutput` target string is resolved under the same rule when it fires). The validator enforces the static part: a conn in one mod naming another mod's instance is an error. Example: `{"output":"OnBroken","target":"bank_alarm*","input":"Enable","delay":0.5,"times":1}`. ## 5. World, mod manifest and lockfile Prior art: Factorio `info.json` (structured fields instead of its prefix syntax), Cargo.lock. ```ts interface World { format: 1; kind: "world"; base: Id /* ":world/" */; engine: string /* exact x.y.z */; mods: string[]; build: string /* repo path of the base world file */; registry: string /* repo path */; components: { rules: Rules | { preset: "rules12"|"rules18" } } } // the world entity's components interface ModManifest { format: 1; kind: "mod"; id: string /* [a-z0-9_]{2,32} */; version: string; title: string; authors: string[]; license: string; engine: string /* semver range */; depends?: Record; optional?: Record; conflicts?: Record; loadAfter?: string[]; role: "always"|"gamemode" /* gmod: active only while played */; content: { path: string; kind: ContentKind; hash: Hash }[]; touches?: (RegId|IId|`${Id}#*`)[]; descriptors?: Partial>; wasm?: { module: string; hash: Hash; caps: Cap[]; fuelPerTick: number; memMB: number; spawnMax?: number; storageKB?: number /* requests, clamped by the host */ } } type Descriptor = "violence"|"blood"|"fear"|"language"|"crime"|"interaction"|"nudity"|"gambling"; type ContentKind = "class"|"physmat"|"surfmat"|"overlay"|"mission"|"sound"|"particle"|"decal"|"pattern" |"wear"|"handling"|"system"|"asset" /* raw file: glb, ktx2, ogg */|"script"|"strings" |"concept"|"criterion"|"rule"|"response"|"line"|"release"|"dialogue-pack"; type Cap = "entity.read"|"entity.write:own"|"entity.write:leased"|"io.fire:own"|"io.fire:leased"|"io.listen" |"field.read"|"storage.mod"|"ui.widgets"|"spawn:session"; interface Lockfile { format: 1; kind: "lock"; world: { base: Id; build: Hash; registry: Hash; rules: Hash /* canon(expanded rules) */ }; engine: string; mods: { id: string; version: string; source: string /* cdn|workshop|path */; hash: Hash }[]; order: string[]; ids: Hash /* canon(lock id table) */ } ``` - `world.json` is the lockfile's input; `validate.mjs --write` produces `world.lock.json`. - `lock.world.build` and `lock.world.registry` hash the raw bytes of the two base world files. Every other `build: Hash` in this contract (canonical events, aliases, quarantine) means `lock.world.registry`: ids are keyed to the registry. - `lock.world.rules` hashes the **expanded** rule set (a preset is expanded first), so changing the rules, and so the rating, changes the world's identity: saves, recordings and hub listings pin it. - Per-file hashes are over raw bytes: a package is fetched content-addressed, so the hash must be of the bytes delivered. A mod's lock hash is the sha256 of its `mod.json` exactly as `--write` emits it (2-space JSON plus newline), which lists every file hash, so it pins the whole package. Reformatting a file changes its hash; that is intended. - **Lock id table:** every catalogue id in the world (dialogue pack entries included), mods in load order, ids sorted within a mod. An id's position is the **u32 index** the wire uses (look records, `PlayVoice`, spawn messages); `lock.ids` pins the table's hash, so two peers with the same lock agree on every index. It is derived, never stored. - Load order: topological sort over `depends`, `optional` and `loadAfter`; ties by id. - **Signing:** an official release of `core`/`ncl` carries a detached ed25519 signature by the project key over its lock hash (`mod.sig` beside `mod.json`, not in the content table). The loader treats a mod as official only if the signature verifies; the id alone proves nothing. Dev builds are unsigned and the validator checks by id. - **WASM limits:** `fuelPerTick`, `memMB`, `spawnMax` and `storageKB` are requests; the host applies `min(request, ceiling)` (placeholder ceilings 5M fuel, 64 MB, 256 spawns, 1 MB storage until the harness measures) and the validator warns above a ceiling. `spawn:session` counts against `spawnMax`; `storage.mod` against `storageKB`. `ui.widgets` renders only inside game-owned chrome (a panel the host draws, frames and labels with the mod's name), never over the world or the host's own UI. There is no network capability of any kind, so there is nothing to deny (`net.none` is gone). `io.fire:own` fires inputs on the mod's own spawns and instances, `io.fire:leased` on entities it holds a lease on. - **Descriptors** are IARC-style (0 none .. 3 strong). A world's rating is the max, per descriptor, over what its `rules` imply (table below) and every mod's `descriptors`; the validator prints it and warns when a mod raises a descriptor above what the rules imply. The mapping to PEGI bands is the owner's call. | Rules field | Descriptor it sets | |---|---| | `violence` none/slapstick/stylised/realistic | violence 0/1/2/3 | | `pedHarm: "lethal"` | violence 3 | | `blood` off/stylised/realistic | blood 0/2/3 | | `gore: true` | blood 3 | | `language` none/mild/strong | language 0/1/3 | | (none) | fear, crime, interaction, nudity, gambling: from mods (and missions) only | ## 6. Mission Only the schema text changes in v1.2; the validator's mission checks land with the missions work. ```ts interface Mission { format: 1; kind: "mission"; id: Id; type: "rails"|"generated"; placement: "private_only"|"prefer_shared"; players: [number, number]; params?: Record; leases: { target: RegId|IId|string /* may use ${param} */; mode: "exclusive"|"shared-read" }[]; layer: OverlayEntry[]; // session layer; names local to the mission objectives: { id: string; text: Id /* string-table key */; done: { on: OutputRef }; optional?: boolean }[]; fail?: { on?: OutputRef[]; timeoutS?: number }; // any fires, or the clock runs out conns?: Conn[]; script?: { module: string; hash: Hash; caps: Cap[] }; consequences?: (OverlayEntry & { reset: ResetPolicy })[]; // written on success; reset required rewards?: { money?: number }; // server-evaluated descriptors?: Partial>; // raise the world rating while it runs state: { fields: Record } } type OutputRef = { target: string /* iid or mission-local name */; output: string }; ``` - `OutputRef` replaces `"iid.Output"`: iids contain dots, so the string form was ambiguous. - **`${param}` grammar:** `${name}` with `name` matching `[a-z][a-z0-9_]*` and declared in `params`; allowed only in `leases[].target`, `OutputRef.target`, conn `target`, placement `at.reg` and string `kv` values; substituted as whole text, no expressions, no nesting. - A mission ends on its first `fail` trigger, its timeout, or all non-optional objectives. - `descriptors` count toward the rating of the world a mission runs in, so a heist mod can't slip violence into a 12-rated world through a mission. ## 7. Canonical event log and T2 damage ```ts interface CanonEvent { format: 1; seq: number; ts: string; author: string; key: RegId|IId|`terrain:${string}`; op: "put"|"modify"|"remove"|"revert"|"rekey"; blob?: Hash /* OverlayEntry JSON */; parent: number|null /* previous seq on the same key */; via?: { proposal: string; reviewer: "agent"|string }; build: Hash /* registry build it was written against */ } interface DamageRow { id: string; world: Id; key: RegId|IId; author: string; ts: string; seq: number; kind: "voxel"|"health"|"removed"|"burnt"; blob?: Hash /* voxel diff: palette idx, RLE bricks */; health?: number; reset: ResetPolicy; expires?: string } ``` One sweeper applies reset timing across both overlay `reset` policies and `DamageRow.reset`. ## 8. Recording, keyframes and the state hash Under hybrid authority, inputs alone can't replay a session: peer-owned physics and server systems aren't input-driven. Recordings carry keyframes and the authoritative event stream too (as Source demos do). ```ts interface Recording { format: 1; kind: "recording"; lock: Hash|Lockfile; seed: string; tickHz: 60; startTick: number; clock: { epoch: number /* s since 1970-01-01T00:00Z at startTick */; timeScale: number; weatherSeed: number; weatherMode: string }; keyframes: { tick: number; blob: Hash }[]; // Keyframe, every N s streams: { inputs: Hash; authEvents: Hash; checksums?: Hash }; players: { slot: number; account?: string }[]; engineBuild: string } interface Keyframe { // blob: gzip of canon JSON (a binary column format replaces it once the harness sizes it) tick: number; lock: Hash; entities: [eid: number, iid: IId|null /* null = runtime spawn */, cls: number /* lock id index */][]; // ascending eid components: Record; // per component: rows [eid, ...fields in schema order], ascending eid fields: Record; // shared field grids, raw little-endian bytes leases: [eid: number, owner: string, epoch: number][]; dialogue: object } // the dialogue engine's world state (cooldowns, shuffle bags) ``` - **State hash (decided 2026-09-27):** the per-tick checksum is defined over entities in ascending entity-id order, and within an entity over its components in name order, never over storage layout (dense arrays, archetype tables). So a Rust port on `hecs` (or any other ECS) can match the JS kernel bit for bit. Cosmetic components are excluded. Landed as kernel format 2 (2026-09-27): components are tagged by their ordinal among the non-cosmetic components sorted by name, and the entity walk is folded in blocks of 64 so the kernel can cache digests; the exact layering is in `v2/sim/core/CLAUDE.md`. - Dialogue state (cooldowns, shuffle bags) is world-scoped and call-order dependent: it is server state and part of every keyframe. Saves use the same identity: a lockfile hash, not separate world version and mod hashes. ## 9. Registry aliases and quarantine ```ts interface RegistryAliases { format: 1; kind: "reg-alias"; base: Id; entries: { old: RegId; new: RegId[] /* [] = tombstone */; why: "split"|"merge"|"redraw"|"demolished"|"face-renumber"; build: Hash; at: string; weights?: number[]; xform?: [number,number,number] /* dx m, dz m, drot rad */ }[] } interface Quarantine { format: 1; kind: "quarantine"; build: Hash; items: { source: string; key: RegId|IId; reason: string; suggest?: RegId[] }[] } ``` Append-only; chains resolve transitively at load. **Built 2026-09-27:** `build/registry_alias.py` (`diff`, `quarantine`, `history`, `refetch` dry run; tests `build/tests/test_registry_alias.py`, unittest). Real history had no splits, merges or redraws (one demolished outbuilding; the 27 Sep OSM refetch kept all 14,413 ids), so those paths are proven on synthetic and edited real data. Open: (1) ~~`registry.py` must treat alias-table ids as reserved~~ done: the ledger is `public/world/registry-aliases.json` and every building id that is an `old` there is skipped when minting and never carried (tests `build/tests/test_registry_ids.py`); (2) a split whose larger piece keeps the id (within 25% area) counts as kept, stranding anchors on the lost part (now a `registry.py check` warning at 10% area change, and `diff` reports it as `kept_area_changed`; not yet resolved into an alias); (3) decision 10 must fix the frame of anchored offsets (the tool assumes world axes, keeping placements fixed in the world); (4) the face-naming loop is duplicated from `registry.build` (the `history` check and a real-data test catch drift). ## 10. Asset references Raw files (glb, ktx2, ogg) are `asset` entries in the mod's content table. Anything another document points at by id is a small JSON document of its own, so references resolve and carry an origin. File paths are relative to the mod folder. ```ts interface SoundRef { format: 1; kind: "sound"; id: Id; origin?: Origin; exportable?: boolean; files: string[] /* one picked per play */; bus: "sfx"|"ambience"|"dialogue"|"music"|"ui"; gainDb?: number; pitch?: [number,number] /* playback-rate multiplier range */; rangeM?: number; loop?: boolean; prov?: Record } interface ParticleRef { format: 1; kind: "particle"; id: Id; origin?: Origin; exportable?: boolean; texture: string; count: [number,number]; lifeS: [number,number]; speedMs: [number,number]; sizeM: [number,number]; gravityMul?: number; sound?: Id; prov?: Record } interface DecalRef { format: 1; kind: "decal"; id: Id; origin?: Origin; exportable?: boolean; albedo: string; normal?: string; sizeM: [number,number]; lifeS?: number; prov?: Record } interface PatternRef { format: 1; kind: "pattern"; id: Id; origin?: Origin; exportable?: boolean; albedo: string; scaleM: number } // clothing patterns, curated library; moderation in approvals ``` ## 11. Vehicles A vehicle is an ordinary class with a `vehicle` component; its panels, doors and wheels are parts on joints. Tyre curves and assists are a shared `handling` preset. ```ts interface Handling { format: 1; kind: "handling"; id: Id; origin?: Origin; tyres: Record; blendBelowMs?: number /* kinematic no-slip below, default 3 */; substeps?: number /* owned car, default 4 */; assists?: { steerSpeedLock?: number /* lock / (1 + v x this), s/m */; steerRateRads?: number; steerReturnRads?: number; yawDamp?: number /* 0-5 */; handbrakeRearGrip?: number /* grip multiplier */; handbrakeGripReturnS?: number; yawCapRads?: number; rolloverGuard?: boolean; brakeRampS?: number; abs?: boolean; tcs?: boolean; governorMs?: number /* no drive at or above */; nitro?: { forceMul: number; topMul: number; drainS: number; rechargeS: number } }; prov?: Record } interface VehicleComp { // components.vehicle base: "wheeled"|"twowheel"|"rail"|"boat"|"rotor"; handling: Id; chassis: string /* part */; com?: Vec3 /* centre of mass in the chassis frame, m */; wheels?: { part: string; radiusM: number; widthM?: number; steer?: number /* max lock, rad; 0 fixed */; drive: number /* torque share */; brake: number /* brake share */; handbrake?: boolean; tyre: string /* key in handling.tyres */; suspension: { restM: number; travelM: number; stiffnessNpm: number /* N/m */; dampingNspm: number /* N·s/m, bump */; reboundNspm?: number /* N·s/m, default dampingNspm */; antiRollNpm?: number /* N/m of compression difference */; maxN?: number } }[]; engine?: { torque: [number,number][] /* [rpm, N·m], rising rpm */; inertiaKgM2: number; idleRpm: number; redlineRpm: number; limiterRpm?: number; brakeNm?: number; electric?: boolean /* draws via systems.power */ }; clutch?: { maxNm: number }; gearbox?: { ratios: number[]; reverse: number; final: number; shiftS: number; auto: boolean }; diff?: { type: "open"|"locked"|"lsd"; bias?: number; frontShare?: number /* AWD split */ }; brakes?: { maxNm: number /* total at the wheels, split by brake shares */; handbrakeNm?: number /* total, over handbrake wheels */ }; rail?: { bogiesM: [number,number]; tractionN: number; brakeN: number }; boat?: { thrustN: number; rudderRad: number }; interior?: { name: string; min: Vec3; max: Vec3 }[]; // moving-frame volumes; seats are components.seat slots?: Record; // upgrade slots tune?: Record } // allowed tune ranges, by stat path interface VPartComp { slot: string; fits: string[]; mods: Record /* stat path -> multiplier */ } ``` - Added 2026-09-27 by the vehicles build (brief L), all optional: `com`, `brakes`, suspension `reboundNspm` and `maxN`, and the assists `steerRateRads`, `steerReturnRads`, `handbrakeGripReturnS`, `governorMs`, `nitro` (v1's `car.js` has each of these and `core:handling/v1-arcade` needed them to reproduce it); `yawDamp`'s range is 0-5 (v1 used 1.12). Wheel mounts are the chassis-to-wheel joint anchors; wheel inertia is 0.5 x part mass x radius squared. The chassis part's `box` collider `dims` are full extents (m). - Upgrades are classes (`ncl:vpart/*`) with a `vpart` component; a slot accepts a part when their `fits` tags intersect. Final stats = class, then parts' multipliers, then tune. - An instance of a vehicle class may carry the reserved `kv.build = { slots: Record, paint?: [r,g,b] /* linear 0-1 */, livery?: Id /* decal */, tune?: Record }` (not a declared keyvalue); tune values are clamped to the class's `tune` ranges, and the server checks behaviour against the build's hash. - `wheeled`/`twowheel` need `wheels`, `rail` needs `rail`, `boat` needs `boat` and `systems.buoyancy`. Drive and brake shares each sum to at most 1. - `weapon` component (Violence section): `{ kind: string; energyJ: number; impulseNs: number; rangeM: number; pellets?: number; magazine?: number; lethality: "lethal"|"less_lethal" }`. ## 12. Characters: wear, body, outfit, look; approvals ```ts type Slot = "hair"|"hat"|"face"|"top_inner"|"top_outer"|"hands"|"legs"|"feet"|"back"|"accessory"; type Region = "head"|"face"|"neck"|"torso_upper"|"torso_lower"|"pelvis"|"arm_upper_l"|"arm_upper_r" |"arm_lower_l"|"arm_lower_r"|"hand_l"|"hand_r"|"leg_upper_l"|"leg_upper_r"|"leg_lower_l"|"leg_lower_r" |"foot_l"|"foot_r"; interface Wear { format: 1; kind: "wear"; id: Id /* ":wear/" */; origin?: Origin; exportable?: boolean; slot: Slot; layer: 0|1|2|3|4|5; hides?: Region[]; occludes?: Slot[]; lods: { mesh: string; node?: string; maxDist: number }[]; morphs: string[] /* body shape morphs it carries */; tints?: 0|1|2|3 /* channels */; pattern?: boolean; massKg: number; insulationClo: number; waterproof: number; flammability: number; armour?: { physmat: Id; regions: Region[] }; hiVis?: boolean; concealsFace?: boolean; prov?: Record } interface Body { // components.body base: "mpfb2"|"quaternius" /* quaternius: the class must carry that vendor origin (or inherit it) */; heightM: number /* adult: 1.45-2.10 */; morphs: { masc: number; weight: number; muscle: number; proportions: number } /* 0-1, all within adult ranges */; skin: number /* palette index 0-255 */; face: { preset: string; morphs?: Record /* ARKit 52 names, 0-1, at most 8 */ } } interface Outfit { // components.outfit, at most 12 items items: { wear: Id; tints?: number[] /* palette indices, <= wear.tints */; pattern?: Id }[] } interface Approvals { format: 1; kind: "approvals"; hub: string; issued: string /* ISO time */; items: { hash: Hash /* the exact bytes approved */; id: Id; state: Moderation; at: string; reason?: string }[]; sig: string /* ed25519 by the hub key over canon of the rest */ } ``` - At most one item per slot except `accessory`. Unknown ids fall back to slot defaults. - **Adult bodies only** (spec, decided): `heightM` is bounded to 1.45-2.10 m and every morph's 0-1 range is authored to stay within adult proportions (0 is the smallest adult, not a child), so no value in range produces a child's build. - **Moderation is not content.** Approving an item must not change its bytes, or every save and lockfile that pinned it would orphan. `state` lives in the hub's signed `Approvals` list, keyed by the content hash of the exact file approved (a new version needs a new approval). Clients fetch the list; the server checks look ids against it. The official bundle is approved by being signed (section 5), and may only use its own wear. - **`look`** is the networked record, named by `sha256(canon({body, outfit}))`. Wire form (little-endian): body morphs 4 x u8, height u8 (1.45 + v x 0.65/255 m), skin u8, face preset u32 (lock id index; presets are ids), face slider count u8 then (u8 ARKit index, u8 value) pairs, item count u8, then per item: wear u32 (lock id index), 3 tint bytes, pattern u32 (lock id index, `0xFFFFFFFF` none). Typical (4 sliders, 6 items): 86 bytes; bounded by the limits (8 sliders, 12 items) at 160 bytes. The server checks every index against the lock id table and the approvals list. - Official content may dress characters only in wear from the official bundle. - Added 2026-09-27 by the characters build (goal 10): collider `dims` are full extents, so a box is `[x, y, z]` and a capsule `[radius, length tip to tip]` along its local +Y; a collider may sit off its part node with `offset`/`rot` (a ragdoll part's node is its bone). Ragdoll classes (`ncl:npc/*`) carry `parts` per segment (16, named after their VRM bone segment) and `joints` fitted from the baked body by `v2/sim/characters/fit.mjs`, masses the body's volume x `ncl:phys/flesh` density. Ball joints take `angLimits` (three `[lo, hi]` pairs about the class axes at the bind pose, within [-π, π]). - Wear `morphs` name glTF morph targets. The MPFB2 base carries eight: `masc_0`, `masc_1`, `weight_0`, `weight_1`, `muscle_0`, `muscle_1`, `proportions_0`, `proportions_1` (the shape at that axis end, the others at 0.5); the bake applies the exact macro blend instead. - **Look hashing snaps to wire precision** (decided in the build, challenge if wrong): the record hashed is the one the wire can carry (morphs and face values to /255, `heightM` to 1.45 + k x 0.65/255), so every peer that decodes the bytes names the same look. Tint byte 255 means "unset" on the wire (palette index 255 is unusable as a tint). The wire form has no `base` field (mpfb2 assumed) and face presets are not yet a catalogue kind, so they have no lock id: both open. ## 13. World rules The world entity's `rules` component (`world.json` `components.rules`), read by systems; nothing hard-codes a rating. ```ts interface Rules { violence: "none"|"slapstick"|"stylised"|"realistic"; blood: "off"|"stylised"|"realistic"; gore: boolean /* dismemberment, limb detach */; weapons: Id[] /* classes with a weapon component */; pvp: "off"|"opt_in"|"on"; pedHarm: "none"|"down" /* harm ends in down, never a corpse */|"lethal"; language: "none"|"mild"|"strong" /* strongest dialogue line allowed */ } ``` `realistic` blood and `gore` need `violence: "realistic"`. `components.rules` is either a full `Rules` or `{preset}` (no overrides: a preset with changes is a different rule set, so write it out). | Preset | violence | blood | gore | weapons | pvp | pedHarm | language | |---|---|---|---|---|---|---|---| | `rules18` (the canonical `ncl` world, decided PEGI 18) | realistic | realistic | true | [] | opt_in | lethal | strong | | `rules12` (kids and school worlds; the spec's original PEGI 12 proposal) | stylised | off | false | [] | off | down | mild | `world.json` writes the canonical set out in full; the validator reports which preset it matches. `weapons` stays `[]` in both until weapon classes exist. ## 14. System manifest Principle 7's lesson "declare reads and writes": every world system ships a manifest, so coupling lives in data and the validator can check it. ```ts interface SystemManifest { format: 1; kind: "system"; id: Id /* ":system/" */; phase: "input"|"environment"|"systems"|"physics"|"post"; // = v2/sim/core PHASES after?: Id[] /* same-phase systems that must run first */; mode: "deterministic"|"authoritative"|"cosmetic"; every?: number /* run on ticks divisible by k, default 1 */; reads: string[]; writes: string[]; // component names fieldsRead: string[]; fieldsWritten: string[]; // shared field names eventsIn: string[]; eventsOut: string[]; // event names prov?: Record } ``` - Tick: deliver last tick's events (in raise order), then run phases in the order above; within a phase, `after` then id. Only `post` may hold `cosmetic` systems, and it must. `every: k` runs at 60/k Hz with `dt = k/60` (the kernel's field; replaces `rateHz`, which could name rates that don't divide the tick). - Checks: every field read has a declared writer; every event heard is raised by a system, is an I/O output, or is an input of some class (wiring and player input fire those); nothing non-cosmetic reads what a cosmetic system writes; several writers of one component or field are allowed (they are declared, which is the point). - The state hash rule is in section 8 (entity-id order, layout-independent). - (The kernel's manifests use `fields_read`-style names; the content manifests camelCase them to match every other document key; whatever loads them into the kernel maps one to the other.) ## 15. Dialogue (Response Rules) The NPC bark engine in `v2/sim/dialogue` (Valve's Response Rules). Each kind is an ordinary catalogue document; the dialogue module owns their shape and reference checks and the catalogue validator calls it, adding files, namespaces and `depends`. ```ts interface Concept { format: 1; kind: "concept"; id: Id /* ":concept/" */; desc: string; facts?: string[] } interface Criterion { format: 1; kind: "criterion"; id: Id /* ":crit/" */; key: string /* speaker.|memory.|world.|event.|player. dotted key */; op: "eq"|"ne"|"lt"|"le"|"gt"|"ge"|"in"|"nin"|"exists"|"absent"; value?: unknown; weight?: number /* default 1 */ } interface Rule { format: 1; kind: "rule"; id: Id /* ":rule/" */; concept: Id; criteria: (Id | Omit)[]; response: Id; once?: false|"speaker"|"world"; cooldown?: { speaker?: number; global?: number } /* s */; remember?: Record } interface Response { format: 1; kind: "response"; id: Id /* ":resp/" */; lines: Id[] /* seeded shuffle bag */ } interface Line { format: 1; kind: "line"; id: Id /* ":line/" */; text: string; speaker: "*"|string|string[]; content: { language: "none"|"mild"|"strong" }; audio: null | { ref: Hash|null; visemes: Hash|null; release: Id } } interface Release { format: 1; kind: "release"; id: Id /* ":release/" */; performer: string /* account */; scope: ("game"|"trailers"|"steam")[]; cloning: boolean; minor: boolean; countersignedBy?: string /* parent account; required when minor */; publishName?: boolean /* never when minor */ } interface DialoguePack { format: 1; kind: "dialogue-pack"; id: Id /* ":dialogue/" */; prov?: Record /* "/entries" covers every entry */; entries: (Concept|Criterion|Rule|Response|Line|Release)[] } ``` - A pack is only a container, so ~130 small documents don't need ~130 files; its entries are full documents with their own ids, in the pack's mod namespace, and join the lock id table like any other (so `PlayVoice{line}` sends a u32). - A line plays only if its `content.language` is at or below the world's `rules.language`. - Recorded audio needs a `release`; under-18 performers need `countersignedBy` and never `publishName`; `cloning` is always explicit (spec, "Contributions"). ## 16. Netcode rows The wire shapes behind the spec's "Netcode v2". Binary, little-endian; the codec lives in the Rust core (spec) so client and server can't drift. ```ts type EntityRef = { eid: number /* u32, per shard session, from 1, never reused in a session */; epoch: number /* u16 ownership epoch, wraps; compare with serial-number arithmetic (RFC 1982) */ }; interface Spawn { eid: number; iid: IId|null /* null = runtime spawn */; cls: number /* u32 lock id index */; look?: Hash; owner: string; epoch: number } // control channel, reliable; binds eid <-> iid interface NetRow { // state channel, one per entity per packet eid: number /* varint */; epoch: number /* u16 */; flags: number /* u8: at_rest, has_vel, has_ang, teleport */; chunk: number /* u16 chunk index, 256 m chunks */; pos: [number, number, number] /* x, z: 18-bit over 256 m (~1 mm); y: 16-bit over -64..+192 m */; rot: number /* u32 smallest-three: 2-bit largest index + 3 x 10-bit */; vel?: [number, number, number] /* 3 x 11-bit, ±64 m/s */; ang?: [number, number, number] /* 3 x 11-bit, ±32 rad/s */ } ``` - Every packet's rows carry `(eid, epoch)`; a stale epoch is dropped (spec: that is what makes double ownership impossible). `claim`/`grant`/`deny`/`impulse`/`correction` carry an `EntityRef`. - Quantise on the sender before stepping (spec), so the owner simulates exactly what peers receive. A car row with velocities is ~22 bytes. - `iid` is the persistent name, `eid` the session's: saves and overlays use iids, packets and keyframes eids, and `Spawn` is the only place the two meet. ## Decisions taken here (challenge before code depends on them) 1. Registry ids stay bare; if other cities ever arrive as mods, they need a world scope. 2. Pipeline-placed props get stable anchored ids (nearest face/building + class + ordinal) and go through the alias table, so overlays overriding them survive rebuilds. 3. `iid` is stable and unique; `name` is a Hammer targetname and may repeat. A mission's names resolve in its session layer first. 4. Merge order is explicit (section 3); the spec's layer diagram is not a precedence order. 5. `times` replaces "once". 6. The AI attribute pass records provenance per field; authored and measured values always beat AI ones, and a rerun only overwrites AI-sourced fields. 7. Materials carry per-unit physics; classes carry mass and optional overrides. 8. SHA-256 over BLAKE3 (native in browsers). 9. One `format` integer per `kind`. 10. Anchored placements store an offset so an alias `xform` can rebase them. 11. Mission leases are all-or-nothing at start; any failure sends the mission private. 12. WASM capabilities are an explicit list with no network capability; writes only to own spawns or leased entities. 13. Splits can renumber face ids, so the rebuild writes `face-renumber` aliases too. ## Resolved in v1.1 (the catalogue pilot's open issues, 2026-09-27) 1. **`core:` content:** confirmed, a `core` mod versioned with the engine; `core` is a reserved id. Referencing another namespace now requires listing that mod in `depends` or `optional` (one explicit edge beats implicit engine magic). 2. **Id grammar:** `modid:type/name`; the type is fixed per kind (Conventions), classes choose any non-reserved type, and only classes may be single-segment (abstract bases: the spec already names `core:prop_physics`, `core:vehicle_wheeled`). Keeps both spellings the spec uses while making the type segment mean something. 3. **Lockfile input:** confirmed `world.json`, kind `world` (section 5); it also carries the world entity's components (`rules`). One file already names base, engine and mods. 4. **Lock `build`/`registry` hashes:** confirmed (raw bytes of the base world and registry); every other `build: Hash` means the registry hash, since ids are keyed to it. 5. **Raw vs canonical:** raw bytes everywhere; the mod hash is over `mod.json` as `--write` emits it. Content-addressed fetch needs the hash of delivered bytes, and canonicalising JSON in every browser buys nothing. (v1.2 adds canonical JSON for in-memory records only.) 6. **Arrays:** confirmed `array: true` on keyvalues; I/O parameters stay scalar (arrays on the wire are what `AddOutput` strings are for in Source, and nothing needs more yet). 7. **`SurfMat.prov`:** added, as on every catalogue kind. 8. **`Prov.from`:** added (repo path#fragment or URL); `run` means a pipeline run id only. The pilot's `run: "build/texlib.json#..."` moved to `from`. 9. **Angles:** radians in data; degrees only in fields suffixed `Deg`. Radians are what Rapier, three.js and glTF take, so no load-time conversion can be forgotten; the suffix keeps human-friendly fields unambiguous (`coneDeg`). The bin's hinge is now `[0, 4.712389]` and the validator bounds hinge limits to [-2π, 2π]. 10. **Units:** units table added. `heatCapacity` is whole-entity J/K; `burnRate` kg/(m²·s); `strengthMPa` is the stress that triggers the material's own `fail` mode (matches the pilot values: compressive concrete, yield steel, flexural glass); `penetration` became `absorbJm` (J per metre), the spec's `absorb`, so the same energy model serves bullets, bones and car doors. 11. **`onBreak`:** a joint's `onBreak` is an output name; the destructible's became `spawnOnBreak` (a class id). Part and class health semantics are defined in section 1. 12. **`lods`/`parts`:** optional, default `[]`; logic entities omit them. 13. **Power:** only `systems.power`. `systems` holds what world systems read; components everything else; the validator rejects system attributes under `components`. 14. **Part `physmat` vs `surf.physmat`:** the part's wins; `physmat` may be omitted when `surf` is given. A mismatch stays a warning (painted steel is legitimate, a typo is likelier). 15. **Inheritance merge:** confirmed FGD rules, now written down in section 1, plus the implicit root `core:entity`. 16. **Peer-owned physics:** the per-entity setting moved out of `systems` to `sim`, which adds `owned`; `mode` stays the name for a *system's* networking class (section 14), as the spec's "Tick, determinism and replay" uses it. Props default to `owned`. 17. **Asset references:** new kinds `sound`, `particle`, `decal` and `pattern` (section 10); physmat sound/particle/decal ids now resolve. 18. **Common inputs and overrides:** `core:entity`, the implicit root of every class, holds the common I/O. No mod may redefine another mod's id (duplicates stay errors); to change another mod's content, extend it under a new id or `modify` its instances in an overlay. Keeps the lockfile's meaning (an id means one document) simple. 19. **Third-party origin labels** (owner, 2026-09-27): `origin` generalised from `"v1"` to the `Origin` union (v1, or a vendor with licence, url, `redistributable`, `placeholder`), on classes, materials, asset references, wear, handling and overlay entries. `redistributable: false` is allowed only in the official `core`/`ncl` bundle and never with `exportable: true`; the validator errors otherwise and prints every non-native or placeholder item by vendor. Lets QAL-1.0 assets (Quaternius: free in games, never redistributed as assets) be used now and found for removal later. ## Resolved in v1.2 (the review of v1.1, the dialogue engine, the kernel; 2026-09-27) Each is enforced by `v2/tools/validate.mjs` and tested in `v2/tools/validate.test.mjs` unless marked "text" (a runtime or hub rule the static validator can't see). | # | Issue | Decision | Where | |---|---|---|---| | R1 | Canonical `rules` prose still showed the PEGI 12 proposal; no named 12 set; the spec wrote `ped_harm: false` | Canonical is `rules18` (PEGI 18, decided); `rules12` is a published preset (the old proposal); `components.rules` may be `{preset}`; `pedHarm` is an enum, a boolean is an error; the spec's 12-rated set now reads `pedHarm: "down"` | s13, validator, spec | | R2 | Rules not in the lockfile | `lock.world.rules` = hash of the expanded rules; a rating change changes identity | s5 | | R3 | Moderation `state` inside hashed content | `state` removed from wear and pattern (an error now); moderation is the hub's signed `Approvals` list keyed by content hash; official bundle approved by signature | s12 | | R4 | Origin taint lost through `extends` | Taint propagates through every reference (Conventions); a tainted document can't be `exportable` and is listed `[via]` under the vendor; `core`/`ncl` releases are signed, the official bundle depends only on itself | Conventions, s5 | | R5 | `AddOutput`/`Kill` and wildcards escape leases | Targets resolve only within the caller's mod-in-layer plus leases (cross-mod iid conns are errors); `io.fire` split into `io.fire:own` / `io.fire:leased` | s4, s5 | | R6 | I/O loops unbounded | `delay: 0` = next tick, delays round up to ticks; per-mod deliveries-per-tick budget (text); keyvalue `min`/`max` with the timer floored at 0.1 s | s1, s4 | | R7 | WASM limits self-declared | Requests clamped by host ceilings (warned above); `spawnMax`, `storageKB` quotas; `ui.widgets` only in game-owned chrome; `net.none` dropped (an error) | s5 | | R8 | Look record: hashing, no id table, face morphs too big, no pattern table | Records hash as canonical JSON, files as raw bytes; the lock id table gives every id a u32 (`lock.ids` pins it); face sliders <= 8 ARKit names, items <= 12; patterns use the same table; 86 bytes typical, 160 max | Conventions, s5, s12 | | R9 | Adult bodies not enforced; Quaternius bodies impossible | `heightM` 1.45-2.10; morph ranges adult by construction (text); `base: "quaternius"` allowed only with that vendor's origin taint | s12 | | R10 | No netcode row or keyframe format | `NetRow`, `EntityRef`, spawn and ownership messages defined; keyframe = canon JSON (binary later), ascending eid | s8, s16 | | R11 | Unit collisions and unstated units; frame | `stiffnessNm`->`stiffnessNpm`, `dampingNsm`->`dampingNspm`, `antiRollNm`->`antiRollNpm`, `fillRate`->`fillM3s` (old names are errors); units for health (HP = J), drag (Cd), pitch, colour (linear Rec.709), clock epoch, timer; quaternions xyzw, Y up, x east, z south | Conventions, units, s11 | | R12 | Missions: `iid.Output`, `${param}`, no fail, consequences without reset, no descriptors | `OutputRef {target, output}`; `${param}` grammar; `fail {on, timeoutS}`; consequences require `reset`; mission `descriptors` count toward the rating (text; validator with the missions work) | s6 | | R13 | Cosmetic gibs vs "only `present` is cosmetic"; `rateHz` must divide 60; `core:entity` `sim` | Cosmetic bodies step in a separate one-way world inside `post`; `rateHz` replaced by the kernel's integer `every` (an error now); `core:entity` declares `sim: "authoritative"` | s1, s14 | | R14 | No `nudity`/`gambling`; no rules-to-descriptor mapping | Both added; mapping table in s5; the validator computes and prints the rating and warns when a mod exceeds its rules | s5 | | R15 | `touches` unenforced; `modify.set` rewrites others' `conns` | Every change to something the mod doesn't own must be in `touches`; on another mod's instance only `at`, `variant`, `kv`, `reset` may change | s3 | | D1 | Dialogue: rules need `language` | `rules.language` (none, mild, strong), required; the engine's numeric `rating` fallback is not in the contract | s13 | | D2 | Dialogue kinds not catalogue kinds | `concept`, `criterion`, `rule`, `response`, `line`, `release`, `dialogue-pack` are content kinds with fixed id types; the catalogue validator expands packs and delegates shapes to `v2/sim/dialogue/validate.mjs`; the sample pack validates as `ncl` content (tested); release keys camelCased (`countersignedBy`, `publishName`); pack `prov` is a pointer map | s15 | | K1 | Phase names differ from the merged kernel | Phases are the kernel's `input environment systems physics post`. Old to new: `input`->`input`, `clock`->`environment`, `environment`->`environment`, `fields`->`systems`, `rules`->`systems`, `motion`->`physics`, `physics`->`physics`, `present`->`post`; old names are errors that name the new one. The eight core manifests follow the kernel's schedule (`damage` moved to `input` as the kernel runs it; `after` keeps heat, fire, snow, power in order) | s14 | | K2 | State hash depends on storage layout | Defined in ascending entity-id order, layout-independent, so a `hecs` port can match; landed as kernel format 2 with cached per-entry digests | s8 | ## Open from the navmesh prototype (2026-09-27) - Add the `navtile` and `navmesh` (manifest: tile -> input hash, output hash) kinds. - Data the v2 build needs to emit: a carriageway polygon (only centrelines + width are committed), crossing type and direction (zebra/signal; points today), footway widths, access (school grounds, yards), garden gates as door links, step height/direction. - Cost tuning for the owner: main road cost 10 lets a ped cross mid-block when the nearest crossing is ~65 m away; raise it if peds should always walk to a crossing. ## Open from the recorder (2026-09-27) - Add the `recording-script` kind (exported scripts for voice sessions) and an optional `emotion` on `line` (the recorder currently infers mood from the playing rule's criteria). - Take filenames map id `:` and `/` to `.`, which is reversible only because id segments can't contain `.` or `__`: make that an id-grammar rule. ## Open from audio v0 (2026-09-27) - New kinds `station` (radio playlists) and `acoustic-zones` (2 m zone map + presets). - Dialogue `line.audio` also carries `durationS`, `loudnessLufs`, `peakDbtp`; define the viseme track format for `audio.visemes` (Rhubarb JSON mouth cues today).