@jgengine/editor 0.25.0 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -26,6 +26,71 @@ At publish, rename this heading to the new version and mirror the entries into
26
26
  `packages/core/src/meta/changelog.ts` (the typed `CHANGELOG` export).
27
27
  -->
28
28
 
29
+ ## 0.26.0
30
+
31
+ ### Migrate
32
+
33
+ - **Bump lockstep SDK packages to `^0.26.0`:** `@jgengine/{core,rapier,react,ws,node,sql,convex,shell,editor,assets,navbake}`. CLI `jgengine` is `0.23.0`; `@jgengine/github` is `0.13.0`.
34
+
35
+ ### Added
36
+
37
+ - `createLogBus` (`@jgengine/core/devtools/logBus`): a fixed-capacity ring of log lines tagged by owning system, with a separate ring for warnings and errors so a crash line is never pushed out by info traffic. `devtools.logs` now writes through it (`write(level, system, message)`, `snapshot()`, `counts()`, `dropped()`); `DevtoolsLogEntry` gained `system`.
38
+ - `devtools.probes.registerSystem(id, { counters, timers, state })`: a system reports its own counters, named timings and one state line in a few lines, with no shell wiring. Readings surface as `systems` in `devtools.snapshot()`, in `debug_snapshot`, and in every perf record.
39
+ - `PerfReport` (`@jgengine/core/devtools/report`): the one perf record shape shared by `bun run perf` and the in-page report.
40
+ - The shell samples frame telemetry in-page in dev builds: `window.__JG.report()` returns that record, and the page pushes it about once a second to the dev server's `GET/POST /__jgengine/report` route. `bun run debug <game>` prints the record of a game already open and exits non-zero on a budget breach in `scripts/perf-budget.json`.
41
+ - `createNeedSet` (`@jgengine/core/gameplay`) drains named needs on game time and reads them by index — decaying hunger, energy, fun with snapshot/restore and per-need rate retuning.
42
+ - `createUtilityBrain` (`@jgengine/core/world`) picks an action from decaying-need appeal: linear, quadratic, logistic, exponential and threshold response curves, candidate cost (travel, queues, price), a switch margin plus minimum hold (hysteresis), declared interrupts, optional seeded jitter, and a devtools probe string.
43
+ - `createNavRuntime` (`@jgengine/core/world`) consumes an editor-baked nav mesh: reads the `nav` bake off a scene document (`navMeshFromBake`), routes with the mesh query, resolves authored doors, ladders and jumps (`AuthoredNavLink`, priced by kind) onto the current surface, and swaps in a new bake after an edit while matching polygon identity by geometry — `applyMesh` returns a `remap` so only routes over changed polygons need re-planning.
44
+ - `createNavCrowd` (`@jgengine/core/world`) moves many agents across that mesh: plans a corridor, follows the funnel-smoothed waypoints, keeps agents apart with reciprocal velocity obstacle avoidance, bounds path searches per tick by count and milliseconds, fans converging agents out with `spread`, and round-trips through `snapshot`/`restore`.
45
+ - `createRvoCrowd` (`@jgengine/core/world`) is the avoidance solver on its own: RVO/ORCA half-plane solve over a uniform-grid neighbour search capped at `maxNeighbors`, deterministic tie-breaking, overlap relief, retunable `timeHorizon`/`neighborDist`/`maxNeighbors`/`sideBias`/`overlapRelief`, and `snapshot`/`restore` for saves and replays.
46
+ - `resolveRoutine` (`@jgengine/core/gameplay`) resolves an authored day into real arrival and departure times against the world clock. Travel time is injected per stop or as a callback, so a stop departs early enough to walk to the next one and an arrival that runs late slips instead of teleporting; `phaseAt(time)` returns the held place, the walking leg and its progress, and `nextChangeAfter(time)` drives a scheduler. A day whose walks cannot fit its cycle throws at construction.
47
+ - `createOffScreenWorld` (`@jgengine/core/gameplay`) keeps a whole population without simulating it: residents beyond the injected radius are cheap state (position, routine phase, needs) that only moves when the clock crosses a routine boundary, and they spawn into the game's own near simulation through a `NearAgents` interface when they come close. The radius, an optional per-agent cost model, a near budget and a per-tick switch budget are all injected; `snapshot`/`restore` round-trip the whole population, and crossing the boundary preserves identity, schedule progress, need values and position.
48
+ - `createNearAgents` (`@jgengine/core/gameplay`) is the default near half of that seam: a nav crowd with a need set and utility brain per agent, goal-tracking as routines move residents between places, and a decision counter for tests and probes.
49
+ - `createPerception` shares squad knowledge: `squadMates(observerId)` names an observer's roster, and a sighting one member makes is written into every mate's memory with `sourceId` (who saw it) and `sourceTick` (the observe pass it was recorded on). A mate's own sighting at the same instant replaces the hand-off. `maxSharesPerTick` (default 64, per observer pass) bounds a large roster and reports elided shares.
50
+ - Perception memory is auditable without guessing: `memoryOf(observerId, targetId)` returns the one remembered target with its confidence right now or `null` once it has faded, and `stats()` counts pair checks, range prunes, cone tests, line-of-sight calls, sightings, heard stimuli, shares and dropped shares. The check order is range → cone → line of sight, so occlusion work only sees the pruned set. `resetStats()` opens a measurement window; `memories` are pruned per observer so a 200-observer pass does not rescan every memory.
51
+ - `devtoolsName` registers a probe showing observers, remembered targets and the work counters, and `dispose()` removes it.
52
+ - `createCoverField` and `coverPointsFromSolids` (`@jgengine/core/combat`) derive stand-able cover from authored solid boxes instead of a coordinate list: one row of points per vertical face, clear of the corners, filtered by an injected `walkable` test, rebuilt when the world is edited, and queried through a bounded `near(position, radius, limit)`. `CoverSource` is the seam, so a game with its own spatial index can supply cover without the box generator.
53
+ - `selectCover` / `scoreCoverPoint` choose cover by threat: `hidden` is the confidence-weighted share of remembered threats whose line of sight the point (and a body-sized footprint at it) blocks, scored against the role's preferred fighting range, flank angle around the threat, distance, a squadmate's claim on the point, and whether the walk to it is a detour. Fading memories stop steering at `minConfidence`, and a point that hides from only one of two shooters is not cover.
54
+ - `createSquadCombat` (`@jgengine/core/combat`) drives a squad over existing seams: role-driven cover selection, fire discipline (hold, lean out, shoot), suppression from `underFire()`, retreat below a role's health threshold, and relocation under sustained fire. Threat comes only from `threatsFromMemory`, i.e. the perception memory the game already keeps; movement goes through `NavCrowd` with a tighter arrival tolerance so a body actually reaches the stand point. Serializable through `snapshot()`/`restore()` (member decisions, crowd, tuning), retunable, and a `devtoolsName` probe reports pins, peeks, shots and the worst exposure.
55
+ - `assignSquadRoles` and `threatsFromMemory` (`@jgengine/core/combat`): deterministic role assignment over a caller-authored `SquadComposition` (role ids, counts, ranges, flank angles, retreat thresholds and cover weights are all content), and a bounded, confidence-ranked threat list from one observer's memory.
56
+ - A 2v4 arena soak proves the behavior: 90 simulated seconds, 2700 ticks, 4 squad members against 2 scripted raiders, worst exposure 2.33 s (bar 3 s), 0 of 38 exposure spells over 3 s, per-tick cost p50 0.05 ms / p95 0.19 ms.
57
+ - `createDamageRuntime` (`@jgengine/core/combat`) composes a damage table, hit zones, the armor curve and carried statuses into one stateful model: `hit()` returns every stage of the number (`base × zone multiplier × type multiplier × (1 − armor mitigation)`), `tick()` runs status effects through the same table and armor, `timeToKill()` quotes a fight, and `table()` reads the whole chart. `snapshot()`/`restore()` carry registered targets and carried statuses; `retune()` swaps table, zones or status definitions without dropping either; a `devtoolsName` probe reports hits, crits, ticks and the last hit.
58
+ - A damage table (`combat/damageTable`) of damage type × resistance class, with `armor / (armor + halfValue)` mitigation — armor equal to the half value halves the hit — a per-type `bypassArmor`, and `readDamageTable` / `formatDamageTable` to read every row at given armor values. An unknown damage type throws instead of silently dealing 1×.
59
+ - Hit zones and crits (`combat/hitZones`): a zone carries a normal multiplier and an optional crit multiplier, `resolveHitZone` rolls once and returns one multiplier, and a crit replaces the zone multiplier rather than compounding with it. `hitZoneMultiplier` quotes the same number without a roll.
60
+ - `createStatusRuntime` (`combat/statusRuntime`): data-defined statuses with the existing `StatusStackPolicy` (`refresh`, stack to `max`, `replace`, `ignore`) as the only thing that changes stacks, per-tick effects of `{ kind: "damage", channel }` or `{ kind: "stat", stat }`, damage routed through an injected `applyDamage` so resistances and armor still apply, bounded ticks per call, and plain-data state out and back.
61
+ - Numbers from the tests: 25 base kinetic against a 150 HP target, 0.15 s between shots — 8 shots / 1.05 s unarmored, 20 shots / 2.85 s at 60 armor, 36 shots / 5.25 s at 200 armor; two stacked burns of 4 per tick deal 8, not 16; a 1.5× head zone with a 2.5× crit multiplier deals 2.5×, not 3.75×.
62
+ - `createWeaponSystem` (`@jgengine/core/combat`) runs one `WeaponDefinition`: single, automatic or burst fire, hitscan or ballistic projectiles, recoil that moves the aim the shot flies down, spread bloom, interrupted/resumed reloads, a readable ammo economy (`ammo()`, `stats()`), `snapshot()`/`restore()` and a devtools probe. Every hit, hitscan or projectile, resolves through `createDamageRuntime`. Pellet work is bounded per tick (`maxShotsPerTick`, default 4) and per weapon (`maxInFlight`, default 32).
63
+ - `WeaponDefinition` (`@jgengine/core/combat/weaponDefinition`) as plain JSON — rate, fire mode, delivery, magazine, handling, damage, falloff — with `assertWeaponDefinition`, `weaponIntervalMs`, `weaponDamageAt` and `weaponMaxInFlight`. A generated gun is the same shape.
64
+ - `Magazine.pauseReload()` / `resumeReload()` / `isPaused()` suspend a reload and keep its progress; a paused reload is not `isReloading()`, does not advance in `tick`, and round-trips through `snapshot()`/`restore()`.
65
+ - `environment.grading` on the scene document authors a per-game colour grade: shadow/midtone/highlight swatches plus tint, saturation and contrast bake into a small display-space LUT (`lutCubeFromGrading`, `colorGradeFromDocument` in `@jgengine/core/editor/environment`), edited in the Lighting workspace. The shell applies it from `editorLayers`, and a graded game needs no LUT asset or hardcoded pass.
66
+ - Clamped auto-exposure: `postProcessing.autoExposure: { min, max, adaptationSeconds } | false` meters the pre-tone-map frame every four frames toward 18% grey and stays inside the authored range. `WHITE_POINT` and `MID_GREY` in `@jgengine/core/render/colorPipeline` are the single documented calibration the pipeline uses.
67
+ - `GradeConfig.lut` also accepts a baked `{ cube: { size, data } }` LUT, sampled trilinearly after the numeric grade.
68
+ - `MATERIAL_LIBRARY` (`@jgengine/assets/catalogs/materialLibrary`) names 56 PBR surfaces — wet asphalt, painted metal, stucco, brick, wood, foliage with cut-out coverage and sheen, glass, water, sand, concrete and more — each mapping a name to an ambientCG source, a world tile size in metres and a physical response. `libraryMaterialAsset("asphalt/wet")` returns the editable `MaterialAsset` those names resolve to, and `filterLibraryMaterials` backs pickers. `assets pull library` pulls every pinned map set.
69
+ - `TriplanarAssetMaterial` / `useTriplanarAssetMaterial` / `applyTriplanarProjection` (`@jgengine/shell/render/triplanarMaterial`) render a material asset from world position, so world geometry without a `uv` attribute still gets colour, normal, roughness and AO maps. Tile size is world metres; instanced meshes project in instanced space.
70
+ - The editor's Materials workspace browses the library by name and writes the chosen entry into the document's material assets.
71
+
72
+ ### Changed
73
+
74
+ - `createPerception` (`@jgengine/core/world`, also `@jgengine/core/sensor/perception`) takes `isSegmentClear` instead of `occluded`: the game supplies collision as a "nothing blocks this segment" test, and it is only called for pairs already inside range and cone. **Migration:** flip the predicate — a `occluded: (from, to) => blocked(from, to)` becomes `isSegmentClear: (from, to) => !blocked(from, to)`.
75
+ - `MagazineSnapshot` gains an optional `paused: true` field, present only while a reload is suspended, so existing snapshots and idle shapes are unchanged.
76
+ - AgX is the pipeline's tone map: `postProcessing.toneMapping` defaults to `"agx"`, and the `cinematic`, `photoreal`, `comic`, `retro` and `STUDIO_STAGE_POST` configs now pick it too. `"toon"` keeps `"linear"` on purpose. Games that authored `"aces"` explicitly are unchanged.
77
+ - The grade pass samples LUT tiles at texel centres and blends the blue axis by hand, so a supplied strip LUT no longer bleeds across slices.
78
+
79
+ ### Fixed
80
+
81
+ - `bun run debug <game>` now finds the server `bun dev` started. The website dev server publishes the same checkout marker the runner does (`scripts/devServerMarker.ts`, written at listen, removed on shutdown, dev builds only), and the marker records the host that answers, since Vite's default `localhost` can be IPv6-only where `127.0.0.1` hangs.
82
+ - The website dev server serves `GET/POST /__jgengine/report` too, so a game opened at `/games/<id>` reports from the `/play` runner it embeds.
83
+ - The in-page sampler installs when the dev report route answers, not on a build-time `PROD` check: the `/play` runner is a production build, so the old check silently disabled the report on `bun dev`.
84
+ - A report taken inside the sampler's warmup is no longer judged on link/framebuffer counts, which only mean something after the warmup baseline exists.
85
+ - `extractMaterialMaps` and `buildMaterialCatalog` now recognise ambientCG's `_Opacity` map, so cut-out foliage and decal scans keep their coverage instead of rendering as solid cards.
86
+ - Capture runs no longer leak a ~150 MB temp Chrome profile each. `shoot`, `drive` and `perf` delete their profile on every exit path — success, throw, watchdog and SIGINT — `bun run shoot` reclaims profiles a killed run left untouched for three hours, the capture guard sweeps the profile of a run it force-kills, and the generated `browser.mjs` harness for game repos removes its own. A capture also refuses to start when the temp drive has less free space than it needs.
87
+ - The post-processing chain, graphics settings and textured terrain material keep a stable identity across renders. Before, every render rebuilt the effect composer and recompiled shaders, which dropped games to single-digit FPS.
88
+
89
+ ### Numbers
90
+
91
+ - Test fixture at 600 rpm, 30-round magazine: 2.9 s to empty; 17.0° pitch and 3.1° yaw climb over the magazine; back to half climb in 0.37 s and within 5% in 0.6 s; bloom 2.0 → 8.0 → 12.0 mrad (cap) by the tenth shot, settling to base 0.63 s after the last shot.
92
+ - A hitscan and a projectile contact at the same distance return the same damage (20 body, 30 on a 1.5x head zone) through one damage model; a 60 m/s projectile crosses 20 m in 0.33 s and drops 2.45 m in 0.5 s at 9.81 m/s².
93
+
29
94
  ## 0.25.0
30
95
 
31
96
  ### Migrate
@@ -1,8 +1,9 @@
1
1
  import type { EditorSession } from "@jgengine/core/editor/index";
2
2
  /**
3
3
  * Lighting workspace panel — edits `document.environment` (sky preset, time-of-day, intensities,
4
- * fog, optional horizon/zenith colors) through the session undo stack. No fabricated runtime data:
5
- * empty document shows defaults as display placeholders; the first edit writes a real bag.
4
+ * fog, optional horizon/zenith colors, local lights and baked light-probe volumes) through the
5
+ * session undo stack. No fabricated runtime data: empty document shows defaults as display
6
+ * placeholders; the first edit writes a real bag.
6
7
  *
7
8
  * @internal
8
9
  */
@@ -1,5 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { useSyncExternalStore } from "react";
3
+ import { bakeLightProbe } from "@jgengine/core/editor/environment";
4
+ import { editorDocumentBounds } from "@jgengine/core/editor/document";
3
5
  import { SliderRow } from "./chromeFields.js";
4
6
  import { BORDER, CONTROL, CONTROL_ACTIVE, FOCUS_RING, INPUT_CLS, MICRO_LABEL, PANEL_BG } from "./shell/theme.js";
5
7
  const PRESETS = [
@@ -36,10 +38,28 @@ function readDisplay(env) {
36
38
  zenithColor: env?.zenithColor ?? DISPLAY_DEFAULTS.zenithColor,
37
39
  };
38
40
  }
41
+ /**
42
+ * A new irradiance volume sized to the scene's authored extent: one grid over the whole play space,
43
+ * so the first bake already covers it. The author shrinks it to a room when it should be enclosed.
44
+ */
45
+ function newProbe(doc, index) {
46
+ const bounds = editorDocumentBounds(doc);
47
+ const halfX = bounds === null ? 30 : Math.max(30, (bounds.max.x - bounds.min.x) / 2 + 12);
48
+ const halfZ = bounds === null ? 30 : Math.max(30, (bounds.max.z - bounds.min.z) / 2 + 12);
49
+ return {
50
+ id: `probes-${index + 1}`,
51
+ label: `Volume ${index + 1}`,
52
+ center: [0, 4, 0],
53
+ size: [Math.round(halfX * 2), 12, Math.round(halfZ * 2)],
54
+ resolution: [4, 2, 4],
55
+ bake: { skyVisibility: 0.4, ceilingColor: "#b8b2a4", floorColor: "#494339", fillIntensity: 0.6 },
56
+ };
57
+ }
39
58
  /**
40
59
  * Lighting workspace panel — edits `document.environment` (sky preset, time-of-day, intensities,
41
- * fog, optional horizon/zenith colors) through the session undo stack. No fabricated runtime data:
42
- * empty document shows defaults as display placeholders; the first edit writes a real bag.
60
+ * fog, optional horizon/zenith colors, local lights and baked light-probe volumes) through the
61
+ * session undo stack. No fabricated runtime data: empty document shows defaults as display
62
+ * placeholders; the first edit writes a real bag.
43
63
  *
44
64
  * @internal
45
65
  */
@@ -50,6 +70,25 @@ export function LightingPanel({ session }) {
50
70
  const authored = env !== undefined;
51
71
  const source = env?.source;
52
72
  const pointLights = env?.pointLights ?? [];
73
+ const probes = env?.probes ?? [];
74
+ const grading = env?.grading;
75
+ const exposure = grading?.autoExposure;
76
+ const setGrading = (next) => {
77
+ const base = { ...(env ?? {}) };
78
+ if (next === undefined || Object.keys(next).length === 0)
79
+ delete base.grading;
80
+ else
81
+ base.grading = next;
82
+ commit(Object.keys(base).length === 0 ? undefined : base, "env:grading");
83
+ };
84
+ const patchGrading = (partial) => {
85
+ const next = { ...(grading ?? {}), ...partial };
86
+ for (const key of Object.keys(next)) {
87
+ if (next[key] === undefined)
88
+ delete next[key];
89
+ }
90
+ setGrading(next);
91
+ };
53
92
  const commit = (next, coalesce) => {
54
93
  session.dispatch({ type: "setEnvironment", environment: next }, coalesce === undefined ? undefined : { coalesce });
55
94
  };
@@ -74,7 +113,15 @@ export function LightingPanel({ session }) {
74
113
  ? { kind, sky: "#87b5e0", ground: "#3d4a38" }
75
114
  : kind === "hdri" ? { kind, url: "" } : { kind, urls: ["", "", "", "", "", ""] };
76
115
  patch({ source: nextSource }, "env:source");
77
- }, children: [_jsx("option", { value: "gradient", children: "Gradient" }), _jsx("option", { value: "hdri", children: "HDRI / EXR" }), _jsx("option", { value: "cube", children: "Cube map" })] }), source?.kind === "hdri" ? (_jsx("input", { className: `w-full px-2 py-1 ${INPUT_CLS}`, placeholder: "https://\u2026/environment.hdr", value: source.url, onChange: (event) => patch({ source: { ...source, url: event.target.value } }, "env:sourceUrl") })) : null, source?.kind === "cube" ? (_jsx("input", { className: `w-full px-2 py-1 ${INPUT_CLS}`, placeholder: " six face URLs, comma-separated", value: source.urls.join(","), onChange: (event) => patch({ source: { ...source, urls: event.target.value.split(",").slice(0, 6) } }, "env:sourceCube") })) : null] }), _jsxs("section", { className: "space-y-1.5 border-t border-white/[0.05] pt-2", children: [_jsx("span", { className: MICRO_LABEL, children: "Sun & ambient" }), _jsx(SliderRow, { label: "sun", value: display.sunIntensity, min: 0, max: 3, step: 0.05, onChange: (value) => patch({ sunIntensity: value }, "env:sun"), format: (v) => v.toFixed(2) }), _jsx(SliderRow, { label: "ambient", value: display.ambientIntensity, min: 0, max: 2, step: 0.05, onChange: (value) => patch({ ambientIntensity: value }, "env:ambient"), format: (v) => v.toFixed(2) }), _jsx(SliderRow, { label: "bearing", value: display.sunAzimuth, min: 0, max: 360, step: 1, onChange: (value) => patch({ sunAzimuth: value }, "env:sunAzimuth"), format: (v) => `${Math.round(v)}°` }), _jsx(SliderRow, { label: "height", value: display.sunElevation, min: 0, max: 90, step: 1, onChange: (value) => patch({ sunElevation: value }, "env:sunElevation"), format: (v) => `${Math.round(v)}°` })] }), _jsxs("section", { className: "space-y-1.5 border-t border-white/[0.05] pt-2", children: [_jsx("span", { className: MICRO_LABEL, children: "Fog" }), _jsx(SliderRow, { label: "near", value: display.fogNear, min: 0, max: 500, step: 1, onChange: (value) => patch({ fog: { near: value } }, "env:fogNear"), format: (v) => `${Math.round(v)}m` }), _jsx(SliderRow, { label: "far", value: display.fogFar, min: 10, max: 2000, step: 5, onChange: (value) => patch({ fog: { far: value } }, "env:fogFar"), format: (v) => `${Math.round(v)}m` }), _jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "color" }), _jsx("input", { type: "text", className: `w-36 px-2 py-1 ${INPUT_CLS}`, placeholder: "#a8c4d8", value: display.fogColor, onChange: (event) => {
116
+ }, children: [_jsx("option", { value: "gradient", children: "Gradient" }), _jsx("option", { value: "hdri", children: "HDRI / EXR" }), _jsx("option", { value: "cube", children: "Cube map" })] }), source?.kind === "hdri" ? (_jsx("input", { className: `w-full px-2 py-1 ${INPUT_CLS}`, placeholder: "https://\u2026/environment.hdr", value: source.url, onChange: (event) => patch({ source: { ...source, url: event.target.value } }, "env:sourceUrl") })) : null, source?.kind === "cube" ? (_jsx("input", { className: `w-full px-2 py-1 ${INPUT_CLS}`, placeholder: " six face URLs, comma-separated", value: source.urls.join(","), onChange: (event) => patch({ source: { ...source, urls: event.target.value.split(",").slice(0, 6) } }, "env:sourceCube") })) : null] }), _jsxs("section", { className: "space-y-1.5 border-t border-white/[0.05] pt-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: MICRO_LABEL, children: "Light probes" }), _jsxs("div", { className: "flex gap-1", children: [_jsx("button", { type: "button", className: `${CONTROL} px-2 py-1 text-[10px]`, title: "Add an irradiance volume around the scene centre, then bake it", onClick: () => patch({ probes: [...probes, newProbe(state.document, probes.length)] }, "env:probes"), children: "Add" }), _jsx("button", { type: "button", className: `${CONTROL} px-2 py-1 text-[10px]`, disabled: probes.length === 0, title: "Re-bake every volume from the document's lamps and its authored fill", onClick: () => commit({ ...(env ?? {}), probes: probes.map((probe) => bakeLightProbe(probe, state.document)) }, "env:probes"), children: "Bake" })] })] }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Ambient baked per volume: interiors take their own fill instead of the sky's." }), probes.map((probe, index) => (_jsxs("div", { className: "rounded border border-white/[0.06] p-1.5", children: [_jsxs("div", { className: "mb-1 flex items-center justify-between text-[10px] text-neutral-400", children: [_jsx("span", { children: probe.label ?? probe.id }), _jsxs("div", { className: "flex gap-1", children: [_jsx("button", { type: "button", className: `${CONTROL} px-1.5 py-0.5 text-[9px]`, title: "Bake this volume from the document's lamps", onClick: () => {
117
+ const next = [...probes];
118
+ next[index] = bakeLightProbe(probe, state.document);
119
+ patch({ probes: next }, "env:probes");
120
+ }, children: "Bake" }), _jsx("button", { type: "button", className: `${CONTROL} px-1.5 py-0.5 text-[9px]`, onClick: () => patch({ probes: probes.filter((_, i) => i !== index) }, "env:probes"), children: "Remove" })] })] }), _jsx(SliderRow, { label: "sky reach", value: probe.bake?.skyVisibility ?? 0.4, min: 0, max: 1, step: 0.05, format: (v) => (v <= 0.001 ? "enclosed" : v.toFixed(2)), onChange: (value) => {
121
+ const next = [...probes];
122
+ next[index] = { ...probe, bake: { ...(probe.bake ?? {}), skyVisibility: value } };
123
+ patch({ probes: next }, "env:probes");
124
+ } }), _jsxs("div", { className: "text-[9px] text-neutral-500", children: [probe.samples === undefined ? "not baked" : `${probe.resolution.join("×")} probes baked`, " \u00B7 ", probe.size.map((extent) => Math.round(extent)).join("×"), " m"] })] }, probe.id)))] }), _jsxs("section", { className: "space-y-1.5 border-t border-white/[0.05] pt-2", children: [_jsx("span", { className: MICRO_LABEL, children: "Sun & ambient" }), _jsx(SliderRow, { label: "sun", value: display.sunIntensity, min: 0, max: 3, step: 0.05, onChange: (value) => patch({ sunIntensity: value }, "env:sun"), format: (v) => v.toFixed(2) }), _jsx(SliderRow, { label: "ambient", value: display.ambientIntensity, min: 0, max: 2, step: 0.05, onChange: (value) => patch({ ambientIntensity: value }, "env:ambient"), format: (v) => v.toFixed(2) }), _jsx(SliderRow, { label: "bearing", value: display.sunAzimuth, min: 0, max: 360, step: 1, onChange: (value) => patch({ sunAzimuth: value }, "env:sunAzimuth"), format: (v) => `${Math.round(v)}°` }), _jsx(SliderRow, { label: "height", value: display.sunElevation, min: 0, max: 90, step: 1, onChange: (value) => patch({ sunElevation: value }, "env:sunElevation"), format: (v) => `${Math.round(v)}°` })] }), _jsxs("section", { className: "space-y-1.5 border-t border-white/[0.05] pt-2", children: [_jsx("span", { className: MICRO_LABEL, children: "Fog" }), _jsx(SliderRow, { label: "near", value: display.fogNear, min: 0, max: 500, step: 1, onChange: (value) => patch({ fog: { near: value } }, "env:fogNear"), format: (v) => `${Math.round(v)}m` }), _jsx(SliderRow, { label: "far", value: display.fogFar, min: 10, max: 2000, step: 5, onChange: (value) => patch({ fog: { far: value } }, "env:fogFar"), format: (v) => `${Math.round(v)}m` }), _jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "color" }), _jsx("input", { type: "text", className: `w-36 px-2 py-1 ${INPUT_CLS}`, placeholder: "#a8c4d8", value: display.fogColor, onChange: (event) => {
78
125
  const color = event.target.value.trim();
79
126
  const fog = { ...(env?.fog ?? {}) };
80
127
  if (color.length === 0)
@@ -87,7 +134,7 @@ export function LightingPanel({ session }) {
87
134
  else
88
135
  next.fog = fog;
89
136
  commit(Object.keys(next).length === 0 ? undefined : next, "env:fogColor");
90
- } })] })] }), _jsxs("section", { className: "space-y-1.5 border-t border-white/[0.05] pt-2", children: [_jsx("span", { className: MICRO_LABEL, children: "Sky colors (optional)" }), _jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "horizon" }), _jsx("input", { type: "text", className: `w-36 px-2 py-1 ${INPUT_CLS}`, placeholder: "#87ceeb", value: display.horizonColor, onChange: (event) => {
137
+ } })] })] }), _jsxs("section", { className: "space-y-1.5 border-t border-white/[0.05] pt-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("span", { className: MICRO_LABEL, children: "Colour grade" }), grading === undefined ? null : (_jsx("button", { type: "button", className: `${CONTROL} px-2 py-1 text-[10px]`, title: "Remove the authored grade; the game's own post chain applies unchanged", onClick: () => setGrading(undefined), children: "Clear" }))] }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Baked into a small LUT applied after tone mapping. Swatches tint their band; white leaves it alone." }), _jsx("div", { className: "grid grid-cols-3 gap-1", children: [["shadows", "shadows", "#2a3a52"], ["midtones", "mid", "#e8e2d6"], ["highlights", "high", "#ffe9c4"]].map(([key, label, placeholder]) => (_jsxs("label", { className: "flex flex-col gap-1 rounded border border-white/[0.06] p-1", children: [_jsx("span", { className: "text-[9px] uppercase tracking-wider text-neutral-500", children: label }), _jsxs("div", { className: "flex items-center gap-1", children: [_jsx("input", { type: "color", "aria-label": `Grade ${label} tint`, value: grading?.[key] ?? placeholder, onChange: (event) => patchGrading({ [key]: event.target.value }) }), grading?.[key] === undefined ? null : (_jsx("button", { type: "button", "aria-label": `Clear ${label} tint`, className: `${CONTROL} px-1 py-0.5 text-[9px]`, onClick: () => patchGrading({ [key]: undefined }), children: "\u00D7" }))] })] }, key))) }), _jsx(SliderRow, { label: "tint", value: grading?.tint ?? 0.45, min: 0, max: 1, step: 0.01, onChange: (value) => patchGrading({ tint: value }), format: (v) => v.toFixed(2) }), _jsx(SliderRow, { label: "saturation", value: grading?.saturation ?? 1, min: 0, max: 2, step: 0.01, onChange: (value) => patchGrading({ saturation: value }), format: (v) => v.toFixed(2) }), _jsx(SliderRow, { label: "contrast", value: grading?.contrast ?? 1, min: 0.5, max: 1.6, step: 0.01, onChange: (value) => patchGrading({ contrast: value }), format: (v) => v.toFixed(2) }), _jsx(SliderRow, { label: "min exposure", value: exposure?.min ?? 0.5, min: 0.1, max: 1, step: 0.05, onChange: (value) => patchGrading({ autoExposure: { ...(exposure ?? {}), min: value } }), format: (v) => `${v.toFixed(2)}×` }), _jsx(SliderRow, { label: "max exposure", value: exposure?.max ?? 2.5, min: 1, max: 6, step: 0.05, onChange: (value) => patchGrading({ autoExposure: { ...(exposure ?? {}), max: value } }), format: (v) => `${v.toFixed(2)}×` }), _jsx("div", { className: "text-[10px] text-neutral-500", children: "Auto-exposure meters the frame toward 18% grey and stays inside this range, so a dark room lifts instead of crushing." })] }), _jsxs("section", { className: "space-y-1.5 border-t border-white/[0.05] pt-2", children: [_jsx("span", { className: MICRO_LABEL, children: "Sky colors (optional)" }), _jsxs("label", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "text-[9px] font-semibold uppercase tracking-wider text-neutral-500", children: "horizon" }), _jsx("input", { type: "text", className: `w-36 px-2 py-1 ${INPUT_CLS}`, placeholder: "#87ceeb", value: display.horizonColor, onChange: (event) => {
91
138
  const horizonColor = event.target.value.trim();
92
139
  if (horizonColor.length === 0) {
93
140
  const next = { ...(env ?? {}) };
@@ -0,0 +1,17 @@
1
+ import type { MaterialAsset } from "@jgengine/core/material/materialAsset";
2
+ /** Adopting a library name writes a real, editable material asset into the scene document. */
3
+ export interface MaterialLibraryBrowserProps {
4
+ /** URL prefix the pulled maps resolve under; matches `assets pull library --dir <public>`. */
5
+ basePath?: string;
6
+ /** Called with the asset the author should now assign to a mesh or slot. */
7
+ onAdopt(asset: MaterialAsset): void;
8
+ /** Reports a name that could not be built, so a broken library entry is visible instead of silent. */
9
+ onError(message: string): void;
10
+ }
11
+ /**
12
+ * Browses the shared ambientCG material library by name and adds one entry to the document's
13
+ * material assets. Names, response parameters and CC0 attribution come from `@jgengine/assets`;
14
+ * this panel owns presentation only.
15
+ * @internal Mounted by the materials workspace.
16
+ */
17
+ export declare function MaterialLibraryBrowser({ basePath, onAdopt, onError }: MaterialLibraryBrowserProps): import("react").JSX.Element;
@@ -0,0 +1,23 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { filterLibraryMaterials, libraryMaterialAsset } from "@jgengine/assets/catalogs/materialLibrary";
3
+ import { useMemo, useState } from "react";
4
+ import { FOCUS_RING, INPUT_CLS, MICRO_LABEL } from "./shell/theme.js";
5
+ /**
6
+ * Browses the shared ambientCG material library by name and adds one entry to the document's
7
+ * material assets. Names, response parameters and CC0 attribution come from `@jgengine/assets`;
8
+ * this panel owns presentation only.
9
+ * @internal Mounted by the materials workspace.
10
+ */
11
+ export function MaterialLibraryBrowser({ basePath = "/materials", onAdopt, onError }) {
12
+ const [query, setQuery] = useState("");
13
+ const rows = useMemo(() => filterLibraryMaterials(query), [query]);
14
+ const adopt = (name) => {
15
+ try {
16
+ onAdopt(libraryMaterialAsset(name, { basePath }));
17
+ }
18
+ catch (error) {
19
+ onError(error instanceof Error ? error.message : String(error));
20
+ }
21
+ };
22
+ return _jsxs("details", { className: "rounded-[6px] border border-white/[0.07] bg-white/[0.02]", children: [_jsxs("summary", { className: `cursor-pointer p-2 text-[11px] text-neutral-300 ${FOCUS_RING}`, children: ["Material library (", rows.length, ")"] }), _jsxs("div", { className: "space-y-2 px-2 pb-2", children: [_jsx("input", { type: "search", value: query, onChange: (event) => setQuery(event.target.value), placeholder: "Filter by name, group or source\u2026", "aria-label": "Filter material library", className: `h-7 w-full px-2 ${INPUT_CLS}` }), _jsxs("p", { className: "text-[10px] leading-snug text-neutral-500", children: ["ambientCG PBR surfaces, pulled per source with ", _jsx("code", { children: "assets pull library" }), ". Adding one creates a reusable asset; assign it to a mesh slot or world surface."] }), _jsxs("div", { className: "max-h-64 space-y-0.5 overflow-auto", role: "list", "aria-label": "Material library", children: [rows.map((entry) => _jsxs("button", { type: "button", role: "listitem", onClick: () => adopt(entry.name), title: `${entry.name}${entry.source === undefined ? " (shader surface)" : ` · ${entry.source}`}`, className: `block w-full rounded-[5px] px-1.5 py-1 text-left hover:bg-white/[0.04] ${FOCUS_RING}`, children: [_jsx("span", { className: "block truncate text-[11px] text-neutral-200", children: entry.title }), _jsxs("span", { className: "block truncate text-[9px] text-neutral-600", children: [entry.name, " \u00B7 ", entry.group, " \u00B7 ", entry.surface, " \u00B7 ", entry.tileMetres, " m tile"] })] }, entry.name)), rows.length === 0 ? _jsxs("p", { className: "px-1.5 py-1 text-[10px] text-neutral-500", children: ["No library material matches \u201C", query, "\u201D."] }) : null] }), _jsxs("div", { className: MICRO_LABEL, children: [rows.length, " of ", filterLibraryMaterials("").length, " materials"] })] })] });
23
+ }
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useState } from "react";
3
3
  import { createMaterialTemplate, parseMaterialAssignments } from "@jgengine/core/material/materialAsset";
4
4
  import { clearMaterialAssignmentPatch } from "./authoredComponentMeta.js";
5
5
  import { MaterialAssetEditor } from "./MaterialAssetEditor.js";
6
+ import { MaterialLibraryBrowser } from "./MaterialLibraryBrowser.js";
6
7
  import { filterMaterialAssignments, listMaterialAssignments, summarizeMaterialUsage, } from "./materialAssignments.js";
7
8
  import { TERRAIN_MATERIALS } from "./uiStore.js";
8
9
  import { FOCUS_RING, INPUT_CLS, MICRO_LABEL } from "./shell/theme.js";
@@ -115,7 +116,12 @@ export function MaterialsWorkspacePanel({ session, api, preview, onSave, materia
115
116
  };
116
117
  const tabs = _jsx("div", { className: "flex shrink-0 gap-1 border-b border-white/[0.06] p-2", role: "tablist", "aria-label": "Material workspace views", children: ["assets", "assignments"].map((view) => _jsx("button", { type: "button", role: "tab", "aria-selected": tab === view, className: `${CHIP} flex-1 ${tab === view ? CHIP_ACTIVE : CHIP_IDLE}`, onClick: () => setTab(view), children: view === "assets" ? "Surface assets" : "Legacy assignments" }, view)) });
117
118
  if (tab === "assets")
118
- return _jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [tabs, _jsxs("div", { className: "min-h-0 flex-1 overflow-auto", children: [_jsxs("div", { className: "space-y-2 border-b border-white/[0.06] p-2", children: [_jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Reusable material asset" }), _jsx("select", { "aria-label": "Choose material asset", value: activeAsset?.id ?? "", className: `w-full px-2 py-1 ${INPUT_CLS}`, onChange: (event) => setActiveAssetId(event.target.value), children: assets.length ? assets.map((asset) => _jsx("option", { value: asset.id, children: asset.name }, asset.id)) : _jsx("option", { value: "", children: "No authored materials" }) })] }), activeAsset ? _jsx("button", { type: "button", className: `${CHIP} border-rose-400/25 text-rose-200`, onClick: () => { const result = api.handle({ method: "remove_material_asset", id: activeAsset.id }); setError(result.ok ? null : result.error ?? "Material operation failed."); }, children: "Remove unused asset" }) : null, _jsxs("details", { children: [_jsx("summary", { className: `cursor-pointer text-[11px] text-neutral-400 ${FOCUS_RING}`, children: "Create from an editable starting point" }), _jsxs("div", { className: "mt-2 space-y-2", children: [_jsx("select", { "aria-label": "Material starting point", className: `w-full px-2 py-1 ${INPUT_CLS}`, value: template, onChange: (event) => setTemplate(event.target.value), children: ["wool", "cotton", "silk", "brushed-metal", "glass", "coated-plastic", "stone", "hair-cards"].map((entry) => _jsx("option", { value: entry, children: entry }, entry)) }), _jsx("input", { "aria-label": "New material stable ID", value: newId, onChange: (event) => setNewId(event.target.value), placeholder: "game/my-original-surface", className: `w-full px-2 py-1 ${INPUT_CLS}` }), _jsx("button", { type: "button", disabled: !newId.trim() || assets.some((asset) => asset.id === newId.trim()), className: `${CHIP} ${CHIP_IDLE}`, onClick: () => { const asset = createMaterialTemplate(template, newId.trim()); const result = api.handle({ method: "upsert_material_asset", asset }); if (!result.ok)
119
+ return _jsxs("div", { className: "flex min-h-0 flex-1 flex-col", children: [tabs, _jsxs("div", { className: "min-h-0 flex-1 overflow-auto", children: [_jsxs("div", { className: "space-y-2 border-b border-white/[0.06] p-2", children: [_jsxs("label", { className: "block space-y-1", children: [_jsx("span", { className: MICRO_LABEL, children: "Reusable material asset" }), _jsx("select", { "aria-label": "Choose material asset", value: activeAsset?.id ?? "", className: `w-full px-2 py-1 ${INPUT_CLS}`, onChange: (event) => setActiveAssetId(event.target.value), children: assets.length ? assets.map((asset) => _jsx("option", { value: asset.id, children: asset.name }, asset.id)) : _jsx("option", { value: "", children: "No authored materials" }) })] }), activeAsset ? _jsx("button", { type: "button", className: `${CHIP} border-rose-400/25 text-rose-200`, onClick: () => { const result = api.handle({ method: "remove_material_asset", id: activeAsset.id }); setError(result.ok ? null : result.error ?? "Material operation failed."); }, children: "Remove unused asset" }) : null, _jsx(MaterialLibraryBrowser, { onAdopt: (asset) => { const result = api.handle({ method: "upsert_material_asset", asset }); if (result.ok) {
120
+ setActiveAssetId(asset.id);
121
+ setError(null);
122
+ }
123
+ else
124
+ setError(result.error ?? "Material operation failed."); }, onError: setError }), _jsxs("details", { children: [_jsx("summary", { className: `cursor-pointer text-[11px] text-neutral-400 ${FOCUS_RING}`, children: "Create from an editable starting point" }), _jsxs("div", { className: "mt-2 space-y-2", children: [_jsx("select", { "aria-label": "Material starting point", className: `w-full px-2 py-1 ${INPUT_CLS}`, value: template, onChange: (event) => setTemplate(event.target.value), children: ["wool", "cotton", "silk", "brushed-metal", "glass", "coated-plastic", "stone", "hair-cards"].map((entry) => _jsx("option", { value: entry, children: entry }, entry)) }), _jsx("input", { "aria-label": "New material stable ID", value: newId, onChange: (event) => setNewId(event.target.value), placeholder: "game/my-original-surface", className: `w-full px-2 py-1 ${INPUT_CLS}` }), _jsx("button", { type: "button", disabled: !newId.trim() || assets.some((asset) => asset.id === newId.trim()), className: `${CHIP} ${CHIP_IDLE}`, onClick: () => { const asset = createMaterialTemplate(template, newId.trim()); const result = api.handle({ method: "upsert_material_asset", asset }); if (!result.ok)
119
125
  setError(result.error ?? "Material operation failed.");
120
126
  else {
121
127
  setActiveAssetId(asset.id);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jgengine/editor",
3
- "version": "0.25.0",
3
+ "version": "0.26.0",
4
4
  "description": "Scene/world/asset editor for JGengine — loaded lazily by the runner; never import from game entrypoints.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -31,10 +31,11 @@
31
31
  "test": "bun test src"
32
32
  },
33
33
  "dependencies": {
34
- "@jgengine/core": "^0.25.0",
35
- "@jgengine/navbake": "^0.25.0",
36
- "@jgengine/react": "^0.25.0",
37
- "@jgengine/shell": "^0.25.0"
34
+ "@jgengine/assets": "^0.26.0",
35
+ "@jgengine/core": "^0.26.0",
36
+ "@jgengine/navbake": "^0.26.0",
37
+ "@jgengine/react": "^0.26.0",
38
+ "@jgengine/shell": "^0.26.0"
38
39
  },
39
40
  "peerDependencies": {
40
41
  "@react-three/drei": "^10.0.0",
@@ -6,7 +6,7 @@
6
6
 
7
7
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
8
8
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
9
- - `VERSION` (const): const VERSION: "0.25.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
9
+ - `VERSION` (const): const VERSION: "0.26.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
10
10
 
11
11
  ## @jgengine/core/authoring
12
12
 
@@ -82,25 +82,42 @@
82
82
  - `Devtools` (interface): interface Devtools — Top-level devtools facade aggregating frame, profile, render, logs, latency, controls, discover, overrides, and probes.
83
83
  - `DevtoolsControl` (interface): interface DevtoolsControl — Registered tunable control with its resolved schema metadata and read/write/reset accessors.
84
84
  - `DevtoolsControlKind` (type): type DevtoolsControlKind = | "slider" | "toggle" | "color" | "select" | "text" | "vec2" | "vec3" | "vec4" | "interval" | "angle" | "enum" — ⚠ undocumented
85
- - `DevtoolsLogEntry` (interface): interface DevtoolsLogEntry — A single captured log line with timestamp, level, and formatted message.
85
+ - `DevtoolsLogEntry` (interface): interface DevtoolsLogEntry — A single captured log line with timestamp, level, owning system tag, and formatted message.
86
86
  - `DevtoolsLogLevel` (type): type DevtoolsLogLevel = "log" | "info" | "warn" | "error" — Severity level for a captured devtools log entry.
87
+ - `DevtoolsLogs` (interface): interface DevtoolsLogs — Bounded log ring with per-system tags, a warning/error ring, and lifetime counts.
87
88
  - `DevtoolsOverrides` (interface): interface DevtoolsOverrides { version: number; enabled: string[]; values: Record<string, unknown>; schemas?: Record<string, {kind: DevtoolsControlKind; schemaVersion?: number}> } — ⚠ undocumented
88
89
  - `DevtoolsSnapshot` (interface): interface DevtoolsSnapshot — Serializable point-in-time snapshot of devtools state: frame, render, latency, logs, controls, and discovered fields.
89
90
  - `DiscoveredEntry` (interface): interface DiscoveredEntry — A control auto-discovered by scanning an object/table, with its binding metadata and current reader.
90
91
  - `DiscoverySkip` (interface): interface DiscoverySkip { readonly path: string; readonly reason: string } — ⚠ undocumented
91
92
  - `FrameRecordSample` (interface): interface FrameRecordSample — Per-frame timing input recorded by callers: total frame time, sim time, and optional named phase durations.
92
93
  - `FrameStats` (interface): interface FrameStats — Aggregated frame-timing statistics over the recent sampling window, including per-phase breakdown.
93
- - `LONG_FRAME_MS` (const): const LONG_FRAME_MS: 33.4 — Frame duration in milliseconds above which a frame is recorded as a long-frame spike (~30fps budget).
94
+ - `FrameWindow` (interface): interface FrameWindow — Frame timings over the measured window, as the player sees them.
95
+ - `GlReport` (interface): interface GlReport — GPU-object creation counts. `total` is since the page loaded, the named fields since warmup.
96
+ - `LONG_FRAME_MS` (const): const LONG_FRAME_MS: 33.4 — The one perf record shape. `bun run perf` (CDP, out of process) and the in-page telemetry report (`window.__JG.report()`, the dev-server route, `bun run debug`) both produce exactly this object, so a budget check and a diff read either one.
94
97
  - `LatencyStats` (interface): interface LatencyStats — Aggregated latency statistics (last/avg/min/max) over recorded latency samples.
98
+ - `LogBusCounts` (interface): interface LogBusCounts — Lifetime totals per level, plus how many lines the ring dropped.
99
+ - `LogBusEntry` (interface): interface LogBusEntry — One captured line: when, how bad, which system said it, and what it said.
100
+ - `LogBusSnapshot` (interface): interface LogBusSnapshot — Read-out of the log bus: newest-window entries, the warning/error ring, totals, and evictions.
95
101
  - `LongFrameEvent` (interface): interface LongFrameEvent — Recorded spike event for a frame that exceeded the long-frame threshold, with culprit attribution.
96
102
  - `NormalizedColor` (interface): interface NormalizedColor { readonly hex: string; readonly rgb: string; readonly alpha: number; readonly hasAlpha: boolean } — ⚠ undocumented
97
103
  - `OVERRIDES_FORMAT_VERSION` (const): const OVERRIDES_FORMAT_VERSION: 1 — ⚠ undocumented
98
104
  - `OverrideApplyDiagnostic` (interface): interface OverrideApplyDiagnostic { readonly id: string; readonly reason: string } — ⚠ undocumented
99
105
  - `OverrideApplyResult` (interface): interface OverrideApplyResult — Outcome of applying a devtools overrides payload: counts of applied and skipped entries plus diagnostics.
106
+ - `PerfQuality` (type): type PerfQuality = "full" | "draft" — Graphics tier the record was measured at.
107
+ - `PerfReport` (interface): interface PerfReport — One measured window of one game: the record `bun run perf`, `window.__JG.report()` and `bun run debug` print.
100
108
  - `PhaseStats` (interface): interface PhaseStats — Timing summary for a single named profiling phase across the sampling window.
109
+ - `ProfileEntry` (interface): interface ProfileEntry — One sampled stack frame reduced to a readable key and its self time.
110
+ - `ProfileReport` (interface): interface ProfileReport — CPU profile summary; only the CDP harness can produce one.
111
+ - `RenderReport` (interface): interface RenderReport — Renderer-side counts for the measured window; `null` where the source could not read them.
101
112
  - `RenderSample` (interface): interface RenderSample — Snapshot of renderer resource counts (draw calls, triangles, geometries, textures) for a frame.
113
+ - `ReportLogLine` (interface): interface ReportLogLine — One log line as it appears in the report.
114
+ - `ReportLogs` (interface): interface ReportLogs — Bounded log read-out carried by the report: warn/error lines survive even a flood of info logs.
115
+ - `ReportTelemetry` (interface): interface ReportTelemetry — Who produced the report, and how much producing it cost.
102
116
  - `ScanFieldMeta` (interface): interface ScanFieldMeta extends TunableOptions { kind?: DevtoolsControlKind } — ⚠ undocumented
103
117
  - `ScanMeta` (type): type ScanMeta = Readonly<Record<string, ScanFieldMeta>> — ⚠ undocumented
118
+ - `SceneNodeSource` (type): type SceneNodeSource = "scene-graph" | "heap-object3d" | "unavailable" — Where the scene-node count came from: the live scene graph, a heap walk, or nowhere.
119
+ - `SystemProbe` (interface): interface SystemProbe — What one system reports about itself: totals it keeps anyway, named timings, and one state line.
120
+ - `SystemProbeReading` (interface): interface SystemProbeReading — One system's probe read out on demand; missing sections stay empty rather than absent.
104
121
  - `Tunable` (interface): interface Tunable<T> — Typed handle to a registered control, exposing its live value and subscribe/set/reset operations.
105
122
  - `TunableAccessor` (interface): interface TunableAccessor — Get/set accessor pair plus initial value used to bind a discovered field to a control.
106
123
  - `TunableChoice` (interface): interface TunableChoice<T = unknown> { readonly value: T; readonly label?: string } — ⚠ undocumented
@@ -139,9 +156,20 @@
139
156
  ## @jgengine/core/devtools/frame
140
157
 
141
158
  - `FrameModule` (interface): interface FrameModule — Frame subsystem exposing the frame-timing, profiling, and render-sample facades.
142
- - `LONG_FRAME_MS` (const): const LONG_FRAME_MS: 33.4 — Frame duration in milliseconds above which a frame is recorded as a long-frame spike (~30fps budget).
143
159
  - `createFrameModule` (const): const createFrameModule: (deps: { readProbes: () => Record<string, unknown>; }) => FrameModule — Create the frame subsystem that records frame/sim/phase timings, computes stats, and captures long-frame events.
144
160
 
161
+ ## @jgengine/core/devtools/logBus
162
+
163
+ - `LOG_BUS_CAPACITY` (const): const LOG_BUS_CAPACITY: 200 — Default measured window for the log ring; older lines are evicted, never grown.
164
+ - `LOG_BUS_MESSAGE_MAX` (const): const LOG_BUS_MESSAGE_MAX: 400 — Longest kept message; a longer one is cut once, at write time.
165
+ - `LOG_BUS_SEVERE_CAPACITY` (const): const LOG_BUS_SEVERE_CAPACITY: 64 — Warnings and errors also land in their own ring, so an error is never pushed out by info traffic.
166
+ - `LogBus` (interface): interface LogBus — A fixed-capacity ring of log lines tagged by owning system. `write` reuses a preallocated slot — no array growth, no splice, no per-write object — so a hot path can call it and only the message string (already built by the caller) costs an allocation.
167
+ - `LogBusCounts` (interface): interface LogBusCounts — Lifetime totals per level, plus how many lines the ring dropped.
168
+ - `LogBusEntry` (interface): interface LogBusEntry — One captured line: when, how bad, which system said it, and what it said.
169
+ - `LogBusOptions` (interface): interface LogBusOptions — Tunables for the log ring: capacity, the severity ring's capacity, and the clock.
170
+ - `LogBusSnapshot` (interface): interface LogBusSnapshot — Read-out of the log bus: newest-window entries, the warning/error ring, totals, and evictions.
171
+ - `createLogBus` (function): function createLogBus(options: LogBusOptions = {}): LogBus — Create the bounded log bus the devtools log facade writes through.
172
+
145
173
  ## @jgengine/core/devtools/logs
146
174
 
147
175
  - `LogsModule` (interface): interface LogsModule — Logs subsystem exposing the log-capture and latency-tracking facades.
@@ -153,6 +181,27 @@
153
181
  - `OverridesModule` (interface): interface OverridesModule — Overrides subsystem exposing the export/apply facade for persisting and restoring control values.
154
182
  - `createOverridesModule` (const): const createOverridesModule: (deps: { signal: ChangeSignal; controlRecords: Map<string, ControlRecord>; writeControl: (record: ControlRecord, raw: unknown) => boolean; discoveredRecords: Map<string, DiscoveredRecord>; enableDiscovered: (id: string) => void; }) => OverridesModule — Create the overrides subsystem that serializes changed control values and re-applies a saved overrides payload.
155
183
 
184
+ ## @jgengine/core/devtools/report
185
+
186
+ - `FrameWindow` (interface): interface FrameWindow — Frame timings over the measured window, as the player sees them.
187
+ - `GlReport` (interface): interface GlReport — GPU-object creation counts. `total` is since the page loaded, the named fields since warmup.
188
+ - `LONG_FRAME_MS` (const): const LONG_FRAME_MS: 33.4 — The one perf record shape. `bun run perf` (CDP, out of process) and the in-page telemetry report (`window.__JG.report()`, the dev-server route, `bun run debug`) both produce exactly this object, so a budget check and a diff read either one.
189
+ - `PerfQuality` (type): type PerfQuality = "full" | "draft" — Graphics tier the record was measured at.
190
+ - `PerfReport` (interface): interface PerfReport — One measured window of one game: the record `bun run perf`, `window.__JG.report()` and `bun run debug` print.
191
+ - `ProfileEntry` (interface): interface ProfileEntry — One sampled stack frame reduced to a readable key and its self time.
192
+ - `ProfileReport` (interface): interface ProfileReport — CPU profile summary; only the CDP harness can produce one.
193
+ - `RenderReport` (interface): interface RenderReport — Renderer-side counts for the measured window; `null` where the source could not read them.
194
+ - `ReportLogLine` (interface): interface ReportLogLine — One log line as it appears in the report.
195
+ - `ReportLogs` (interface): interface ReportLogs — Bounded log read-out carried by the report: warn/error lines survive even a flood of info logs.
196
+ - `ReportTelemetry` (interface): interface ReportTelemetry — Who produced the report, and how much producing it cost.
197
+ - `SceneNodeSource` (type): type SceneNodeSource = "scene-graph" | "heap-object3d" | "unavailable" — Where the scene-node count came from: the live scene graph, a heap walk, or nowhere.
198
+
199
+ ## @jgengine/core/devtools/reportEndpoint
200
+
201
+ - `REPORT_ENDPOINT_PATH` (const): const REPORT_ENDPOINT_PATH: "/__jgengine/report" — The dev-only route a running game pushes its in-page report to, and that `bun run debug <game>` reads back. One process: the page gathers, the dev server holds the newest record per game, the CLI fetches it. Nothing here ships in a production build — the Vite plugin mounts it under `apply: "serve"`.
202
+ - `ReportPushBody` (interface): interface ReportPushBody — Body the page POSTs: the game it measured and the record itself.
203
+ - `ReportRouteError` (interface): interface ReportRouteError — Body sent when there is nothing to return yet.
204
+
156
205
  ## @jgengine/core/devtools/rewriteTunables
157
206
 
158
207
  - `TunableDelta` (interface): interface TunableDelta — One changed tunable to write back into game source: which table, key path, and new value.
@@ -203,8 +252,10 @@
203
252
 
204
253
  - `Devtools` (interface): interface Devtools — Top-level devtools facade aggregating frame, profile, render, logs, latency, controls, discover, overrides, and probes.
205
254
  - `DevtoolsControl` (interface): interface DevtoolsControl — Registered tunable control with its resolved schema metadata and read/write/reset accessors.
206
- - `DevtoolsLogEntry` (interface): interface DevtoolsLogEntry — A single captured log line with timestamp, level, and formatted message.
255
+ - `DevtoolsLatency` (interface): interface DevtoolsLatency — Latency samples recorded by instrumented transport methods.
256
+ - `DevtoolsLogEntry` (interface): interface DevtoolsLogEntry — A single captured log line with timestamp, level, owning system tag, and formatted message.
207
257
  - `DevtoolsLogLevel` (type): type DevtoolsLogLevel = "log" | "info" | "warn" | "error" — Severity level for a captured devtools log entry.
258
+ - `DevtoolsLogs` (interface): interface DevtoolsLogs — Bounded log ring with per-system tags, a warning/error ring, and lifetime counts.
208
259
  - `DevtoolsSnapshot` (interface): interface DevtoolsSnapshot — Serializable point-in-time snapshot of devtools state: frame, render, latency, logs, controls, and discovered fields.
209
260
  - `DiscoveredEntry` (interface): interface DiscoveredEntry — A control auto-discovered by scanning an object/table, with its binding metadata and current reader.
210
261
  - `FrameRecordSample` (interface): interface FrameRecordSample — Per-frame timing input recorded by callers: total frame time, sim time, and optional named phase durations.
@@ -214,6 +265,8 @@
214
265
  - `OverrideApplyResult` (interface): interface OverrideApplyResult — Outcome of applying a devtools overrides payload: counts of applied and skipped entries plus diagnostics.
215
266
  - `PhaseStats` (interface): interface PhaseStats — Timing summary for a single named profiling phase across the sampling window.
216
267
  - `RenderSample` (interface): interface RenderSample — Snapshot of renderer resource counts (draw calls, triangles, geometries, textures) for a frame.
268
+ - `SystemProbe` (interface): interface SystemProbe — What one system reports about itself: totals it keeps anyway, named timings, and one state line.
269
+ - `SystemProbeReading` (interface): interface SystemProbeReading — One system's probe read out on demand; missing sections stay empty rather than absent.
217
270
  - `Tunable` (interface): interface Tunable<T> — Typed handle to a registered control, exposing its live value and subscribe/set/reset operations.
218
271
  - `TunableAccessor` (interface): interface TunableAccessor — Get/set accessor pair plus initial value used to bind a discovered field to a control.
219
272
 
@@ -232,7 +285,7 @@
232
285
 
233
286
  - `CHANGELOG` (const): const CHANGELOG: Record<string, ChangelogEntry> — Per-version engine changelog keyed by semver string (e.g. `"0.10.0"`).
234
287
  - `ChangelogEntry` (interface): interface ChangelogEntry — One release's migrate steps plus added/changed/removed notes (typed mirror of CHANGELOG.md).
235
- - `VERSION` (const): const VERSION: "0.25.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
288
+ - `VERSION` (const): const VERSION: "0.26.0" — Installed `@jgengine/core` semver — compare against {@link CHANGELOG} keys when migrating.
236
289
 
237
290
  ## @jgengine/core/runtime/adapter
238
291
 
@@ -48,6 +48,10 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
48
48
 
49
49
  - `defineSystem` (function) · `import { defineSystem } from "@jgengine/shell/gameKit"`
50
50
 
51
+ ## devtools-log-bus — a bounded, per-system-tagged log ring a system writes to and a report reads
52
+
53
+ - `LogBus` (interface) · `import { LogBus } from "@jgengine/core/devtools/logBus"`
54
+
51
55
  ## editor-catalogs — Export typed gameplay catalogs for in-editor tuning.
52
56
 
53
57
  - `EditorCatalogDefinition` (interface) · `import { EditorCatalogDefinition } from "@jgengine/shell/gameKit"`
@@ -15,15 +15,18 @@
15
15
  - `DirectiveFootprint` (interface): interface DirectiveFootprint — A resolved directive footprint: the XZ bounds to scatter within, plus an optional clip polygon.
16
16
  - `DirectiveInstancePatch` (interface): interface DirectiveInstancePatch — A per-instance hand-tweak over a materialized directive, keyed by the minted instance id.
17
17
  - `DocumentBakeOptions` (interface): interface DocumentBakeOptions extends MinimapBakeOptions — Options for {@link bakeMinimapFromDocument}.
18
+ - `DocumentColorGrade` (interface): interface DocumentColorGrade — The renderer-facing result of a document's authored grade.
18
19
  - `DocumentLiveEvent` (interface): interface DocumentLiveEvent — Event emitted when the authoritative document changes on a {@link DocumentLiveSync}.
19
20
  - `DocumentLiveSync` (interface): interface DocumentLiveSync — Two-way live-sync bus: document patches out, runtime state deltas back.
20
21
  - `DocumentPatch` (type): type DocumentPatch = | { type: "snapshot"; revision?: number; baseRevision: number; document: EditorDocument; } | { type: "commands"; revision?: number; baseRevision: number; commands: readonly EditorCommand[]; } — One versioned document mutation on the live-sync stream. `snapshot` replaces the whole document; `commands` replays structural editor commands onto the current document. `baseRevision` must match the receiver's current revision unless `force` is set (document authority from the editor). `revision` preserves the authority cursor when replaying: snapshots may jump forward for reconnect recovery; commands must be the next revision.
21
22
  - `ENTITY_CATALOG_ID` (const): const ENTITY_CATALOG_ID: "entities" — Well-known catalog id for authored entity definitions. A `mob`/`boss` marker's `catalogId` references an entry (row) in this catalog, and {@link entityEntryFromCatalog} turns that row into the runtime {@link GameContextEntityEntry} a spawn consumes — so an entity's stats and speed are authored in the editor Data tab, not hardcoded in game TS.
23
+ - `EditorAutoExposure` (interface): interface EditorAutoExposure — Authored clamped auto-exposure range: how far the meter may pull a dark room or a bright street.
22
24
  - `EditorCatalogData` (interface): interface EditorCatalogData — Persisted values for one gameplay data catalog (weapons, waves, economy, …). Schemas are not stored here — they come from the game's `editorCatalogs` export and drive SchemaInspector.
23
25
  - `EditorCatalogDefinition` (interface): interface EditorCatalogDefinition — Game-exported catalog definition: a `ParamSchema` plus default entries. Schemas stay in code; entry values merge into `document.catalogs` and are what the editor/RPC edits and saves.
24
26
  - `EditorCatalogEntry` (interface): interface EditorCatalogEntry — One row in a gameplay data catalog — id + optional label + a meta bag matching the catalog's `ParamSchema`. Values persist on the scene document; the schema lives in the game export.
25
27
  - `EditorCatalogsInput` (type): type EditorCatalogsInput = | readonly EditorCatalogDefinition[] | (() => readonly EditorCatalogDefinition[]) — Accepted shape for a game's `editorCatalogs` export: definitions, or a factory.
26
28
  - `EditorCollection` (interface): interface EditorCollection — A named, persisted list of object ids — a selection bookmark (restore, add-to) that can also double as a production group: `locked` blocks `translate`/`setTransform`/`remove`/`removeMany` on its members, `color`/`visible` are UI-only hints for the collections panel.
29
+ - `EditorColorGrading` (interface): interface EditorColorGrading — Authored colour grading for the scene document's final image. The editor bakes a small display-space LUT from these controls and the shell applies it after tone mapping, so a game's grade is authored data like its sky, not a hardcoded pass in the game's config.
27
30
  - `EditorCommand` (type): type EditorCommand = | { type: "select"; ids: readonly string[] } | { type: "clearSelection" } | { type: "setTransform"; id: string; position?: EditorVec3; rotationY?: number } | { type: "translate"; ids: readonly string[]; delta: EditorVec3 } | { type: "setParent"; ids: readonly string[]; parentId:… — A single editor mutation — select, move, add, remove, undo/redo — dispatched to a session.
28
31
  - `EditorDirective` (type): type EditorDirective = EditorScatterDirective | EditorPopulationDirective — A procedural directive on the scene document: scatter props or a mob population.
29
32
  - `EditorDirectiveArea` (interface): interface EditorDirectiveArea — XZ world-space bounds, `[minX, minZ]`..`[maxX, maxZ]`, for a directive that names no region.
@@ -41,6 +44,7 @@
41
44
  - `EditorGridPaletteEntry` (interface): interface EditorGridPaletteEntry — One selectable value in a grid layer's palette: the cell value id plus how it reads (label, color) and how ASCII import/export maps it (`glyph`). Games register these so the editor and the import adapters share one legend. `meta` carries the typed payload (cost, damage, tags, …) a runtime attaches to every cell holding this value.
42
45
  - `EditorKindVisibility` (interface): interface EditorKindVisibility — Per-kind show/hide flags for the editor's layer panel.
43
46
  - `EditorLayersInput` (type): type EditorLayersInput = | EditorDocument | Partial<Omit<EditorDocument, "version">> | (() => EditorDocument | Partial<Omit<EditorDocument, "version">>) — Accepted shape for a game's `editorLayers` export: a document, partial data, or a factory.
47
+ - `EditorLightProbe` (interface): interface EditorLightProbe extends IrradianceVolume — An irradiance volume authored on the scene document: a grid of baked SH probes over a box, plus the bake settings that produced them so a scene can re-bake without the editor session.
44
48
  - `EditorMarker` (interface): interface EditorMarker — A placeable point object in the scene — spawn, mob, chest, POI, etc.
45
49
  - `EditorMinimapBake` (interface): interface EditorMinimapBake — A baked top-down minimap stored on the scene document (#1036): the PNG the editor rasterized from the authored terrain (a `data:image/png;base64,…` URI) plus the world bounds it spans. Runtime feeds these straight into the `Minimap`/`WorldMap` `background`/`mapBounds` props — no re-raster.
46
50
  - `EditorNote` (interface): interface EditorNote — A free-text annotation pinned to a world position for designers.
@@ -99,8 +103,11 @@
99
103
  - `applyDirectiveOverlay` (function): function applyDirectiveOverlay(instances: readonly MaterializedInstance[], overlay: EditorDirectiveOverlay): MaterializedInstance[] — Applies a directive overlay to its materialized instances: patched fields override, `deleted` drops the instance. Instances with no patch pass through untouched.
100
104
  - `applyDocumentPatch` (function): function applyDocumentPatch(document: EditorDocument, revision: number, patch: DocumentPatch, options?: { force?: boolean }): ApplyDocumentPatchResult — Applies a versioned document patch. Rejects base-revision mismatches unless `force` (document is authoritative — the editor forces when publishing its own session state).
101
105
  - `applyRuntimeStateDelta` (function): function applyRuntimeStateDelta(snapshot: RuntimeStateSnapshot, delta: Omit<RuntimeStateDelta, "seq"> & { seq?: number }): { snapshot: RuntimeStateSnapshot; delta: RuntimeStateDelta } — Merges a runtime delta into a snapshot without touching the scene document — runtime overrides stay ephemeral until an explicit write-back produces a document patch.
106
+ - `authoredLightsFromDocument` (function): function authoredLightsFromDocument(doc: EditorDocument): readonly { position: readonly [number, number, number]; color?: string; intensity?: number; distance?: number }[] — The lamps a bake should include: authored light markers plus the environment's point lights.
107
+ - `bakeLightProbe` (function): function bakeLightProbe(probe: EditorLightProbe, doc: EditorDocument): EditorLightProbe — Bakes one authored irradiance volume from its own settings and the document's lamps: sky where the region is open, an authored ceiling/floor fill where it is enclosed, and the authored lamps on top. Pure and deterministic, so the editor's Bake button and a script produce the same samples.
102
108
  - `bakeMinimapFromDocument` (function): function bakeMinimapFromDocument(doc: EditorDocument, sampleHeight: (x: number, z: number) => number, options: DocumentBakeOptions = {}): { background: string; mapBounds: MinimapBakeBounds } — Bakes a minimap image from a scene document plus a terrain height sampler: sizes the footprint to the authored objects (or an explicit `bounds`), tints biome-colored volumes, and rasterizes the terrain top-down. Returns `{ background, mapBounds }` for the `Minimap` props. The editor runs this as its bake action and stores the result on the document.
103
109
  - `cloneGridLayer` (function): function cloneGridLayer(layer: EditorGridLayer): EditorGridLayer — Deep-clones a grid layer so authoring edits and history snapshots never alias source cells.
110
+ - `colorGradeFromDocument` (function): function colorGradeFromDocument(doc: EditorDocument): DocumentColorGrade | undefined — The colour grade a scene document authored, baked for the renderer. `undefined` when the document authored no grade at all, so an ungraded game keeps its own post chain untouched. The shell folds this into the post config the same way {@link lightProbesFromDocument} feeds the irradiance volumes, so a game's grade is authored data rather than a pass wired in its config.
104
111
  - `consumeRuntimePlayStep` (function): function consumeRuntimePlayStep(play: RuntimePlayControl): { runFrame: boolean; next: RuntimePlayControl; } — Advances the play-control step counter: when paused with pending steps, consumes one and reports whether the sim should run this frame. When not paused, always runs.
105
112
  - `createDocumentLiveSync` (function): function createDocumentLiveSync(initial: EditorDocument): DocumentLiveSync — Creates an in-memory two-way live-sync bus seeded from an initial document. Document is authoritative; runtime overrides are ephemeral until {@link DocumentLiveSync.writeBackOverride}.
106
113
  - `createGridLayer` (function): function createGridLayer(init: CreateGridLayerInit): EditorGridLayer — Creates a grid layer from a partial init, defaulting cell size to `1`, origin to the world origin, and axes to `"xz"`. Runs the result through {@link migrateGridLayer} so it is normalized and bounds-clean.
@@ -136,8 +143,10 @@
136
143
  - `importCsvGrid` (function): function importCsvGrid(text: string, options: CsvGridImportOptions): EditorGridLayer — Parses a CSV grid — one row per line, one value id per comma-separated field — into a grid layer. Blank fields become the empty value. Unlike ASCII this carries multi-character value ids directly, so it suits grids whose cell values are names rather than single glyphs.
137
144
  - `inGridBounds` (function): function inGridBounds(layer: EditorGridLayer, col: number, row: number): boolean — True when `col,row` falls inside the layer's declared bounds.
138
145
  - `installDocumentLiveSync` (function): function installDocumentLiveSync(sync: DocumentLiveSync): () => void — Publishes a live-sync bus globally so AuthoredScene / games can subscribe without prop drilling.
146
+ - `lightProbesFromDocument` (function): function lightProbesFromDocument(doc: EditorDocument): readonly IrradianceVolume[] — The baked irradiance volumes a scene document authored, flattened for the renderer.
139
147
  - `lightingFromDocument` (function): function lightingFromDocument(doc: EditorDocument): LightingConfig | undefined — Converts authored `light` markers into the runtime lighting contract.
140
148
  - `loadWorldDocument` (function): function loadWorldDocument(manifest: WorldManifest, resolve: WorldShardResolver, query?: WorldQuery): EditorDocument — Loads the live neighborhood document for a query: reads each selected shard through `resolve`, applies its overlay (if any) on top, then merges them into one {@link EditorDocument} downstream consumers (`placeAuthoredObjects`, `collectAuthoredTriggers`, scatter, …) read unchanged. The monolith becomes an internal detail of the loader; a one-shard world yields exactly that shard.
149
+ - `lutCubeFromGrading` (function): function lutCubeFromGrading(grading: EditorColorGrading): LutCube — Bakes an authored grade into a display-space LUT cube. Contrast and saturation work around luma, then each band pulls its share of the frame toward the authored swatch, scaled to the pixel's own brightness so a swatch tints a band instead of painting over it. Pure and deterministic, so the editor's preview and the runtime produce the same bytes.
141
150
  - `materializeDirective` (function): function materializeDirective(doc: EditorDocument, directive: EditorDirective): MaterializedInstance[] — Materializes one directive against a document, resolving its footprint first. Returns an empty list when the region does not resolve.
142
151
  - `materializeDirectives` (function): function materializeDirectives(doc: EditorDocument, overlays?: readonly EditorDirectiveOverlay[]): MaterializedInstance[] — Materializes every directive on a document into one flat instance list, applying each directive's overlay (matched by `over` === directive id) as it goes. The bulk-content counterpart to `resolveAuthoredObjects` / `resolveScatter`: games and headless tests read the same list.
143
152
  - `materializePopulationDirective` (function): function materializePopulationDirective(directive: EditorPopulationDirective, footprint: DirectiveFootprint): MaterializedInstance[] — Materializes a population directive into deterministic mob spawn placements: at most `cap` of each weighted species, distributed across the footprint. Each placement carries a stable id and its species id in `asset`, ready to feed `ctx.scene.entity.spawn` / `ai/populationDirector`.
@@ -212,11 +221,17 @@
212
221
 
213
222
  ## @jgengine/core/editor/environment
214
223
 
224
+ - `DocumentColorGrade` (interface): interface DocumentColorGrade — The renderer-facing result of a document's authored grade.
215
225
  - `EnvironmentContent` (interface): interface EnvironmentContent — The coordinate/placement content of a world, derived from its scene document.
216
226
  - `EnvironmentContentOptions` (interface): interface EnvironmentContentOptions extends ClearanceOptions — World-authoring convergence (#1018, epic #1006 Phase 4): derive the **coordinate/placement** content of an `environment()` world from the scene document, so a game with an authored document needs no coordinate arrays in `world.ts` — the remaining `world.ts` surface is engine tuning (noise, materials, sky preset, physics). Terrain footprint follows the authored objects, ground clearings come from authored spawns/POIs (`clearanceZonesFrom`), and the sculpt snapshot is the document's own terrain overlay. Pure and serializable; consumers pass the result straight to `environment({ terrain: terrain({ bounds }), clearings, sculpt })`.
227
+ - `authoredLightsFromDocument` (function): function authoredLightsFromDocument(doc: EditorDocument): readonly { position: readonly [number, number, number]; color?: string; intensity?: number; distance?: number }[] — The lamps a bake should include: authored light markers plus the environment's point lights.
228
+ - `bakeLightProbe` (function): function bakeLightProbe(probe: EditorLightProbe, doc: EditorDocument): EditorLightProbe — Bakes one authored irradiance volume from its own settings and the document's lamps: sky where the region is open, an authored ceiling/floor fill where it is enclosed, and the authored lamps on top. Pure and deterministic, so the editor's Bake button and a script produce the same samples.
229
+ - `colorGradeFromDocument` (function): function colorGradeFromDocument(doc: EditorDocument): DocumentColorGrade | undefined — The colour grade a scene document authored, baked for the renderer. `undefined` when the document authored no grade at all, so an ungraded game keeps its own post chain untouched. The shell folds this into the post config the same way {@link lightProbesFromDocument} feeds the irradiance volumes, so a game's grade is authored data rather than a pass wired in its config.
217
230
  - `environmentContentFromDocument` (function): function environmentContentFromDocument(doc: EditorDocument, options: EnvironmentContentOptions = {}): EnvironmentContent — Derives the coordinate/placement content of an `environment()` world from its scene document: terrain footprint (via {@link terrainBoundsFromDocument}), ground clearings under authored spawns/POIs (via `clearanceZonesFrom`), and the document's sculpt snapshot. This is the seam that lets a game author its world footprint and flatten regions in the editor instead of hardcoding them — the `world.ts` that consumes it carries only engine tuning.
218
231
  - `lakebedFromWaterVolumes` (function): function lakebedFromWaterVolumes(doc: EditorDocument): TerraformSnapshot | undefined — Derives a terrain-depression sculpt snapshot from the document's authored `water` volumes: each water box carves a lake bed that ramps from the shoreline down to below the box's bottom face, so an editor-authored pond/lake sits *in* the ground instead of floating as a sheet on top of it (and ground-sampled vegetation sinks below the surface rather than growing through it). Returns `undefined` when the document has no water volumes.
232
+ - `lightProbesFromDocument` (function): function lightProbesFromDocument(doc: EditorDocument): readonly IrradianceVolume[] — The baked irradiance volumes a scene document authored, flattened for the renderer.
219
233
  - `lightingFromDocument` (function): function lightingFromDocument(doc: EditorDocument): LightingConfig | undefined — Converts authored `light` markers into the runtime lighting contract.
234
+ - `lutCubeFromGrading` (function): function lutCubeFromGrading(grading: EditorColorGrading): LutCube — Bakes an authored grade into a display-space LUT cube. Contrast and saturation work around luma, then each band pulls its share of the frame toward the authored swatch, scaled to the pixel's own brightness so a swatch tints a band instead of painting over it. Pure and deterministic, so the editor's preview and the runtime produce the same bytes.
220
235
  - `skyFromDocument` (function): function skyFromDocument(doc: EditorDocument): SkyEnvironmentConfig | undefined — Maps the scene document's serializable environment bag onto the runtime sky config consumed by `sky()` / `environment({ sky })`. Returns `undefined` when the document has no environment field so callers can fall back to a world.ts default without inventing values.
221
236
  - `terrainBoundsFromDocument` (function): function terrainBoundsFromDocument(doc: EditorDocument, options: EnvironmentContentOptions = {}): WorldBounds — The origin-centered terrain footprint that covers every authored object in a document, padded and floored to `minBounds`. An empty document yields exactly `minBounds`. Because `environment()` terrain is centered on the origin, the footprint is sized symmetrically to the farthest object on each axis, so no authored object falls off the ground.
222
237
 
@@ -357,12 +372,14 @@
357
372
 
358
373
  ## @jgengine/core/editor/types
359
374
 
375
+ - `EditorAutoExposure` (interface): interface EditorAutoExposure — Authored clamped auto-exposure range: how far the meter may pull a dark room or a bright street.
360
376
  - `EditorBake` (interface): interface EditorBake — A named baked artifact produced by an editor or build tool.
361
377
  - `EditorCatalogData` (interface): interface EditorCatalogData — Persisted values for one gameplay data catalog (weapons, waves, economy, …). Schemas are not stored here — they come from the game's `editorCatalogs` export and drive SchemaInspector.
362
378
  - `EditorCatalogDefinition` (interface): interface EditorCatalogDefinition — Game-exported catalog definition: a `ParamSchema` plus default entries. Schemas stay in code; entry values merge into `document.catalogs` and are what the editor/RPC edits and saves.
363
379
  - `EditorCatalogEntry` (interface): interface EditorCatalogEntry — One row in a gameplay data catalog — id + optional label + a meta bag matching the catalog's `ParamSchema`. Values persist on the scene document; the schema lives in the game export.
364
380
  - `EditorCatalogsInput` (type): type EditorCatalogsInput = | readonly EditorCatalogDefinition[] | (() => readonly EditorCatalogDefinition[]) — Accepted shape for a game's `editorCatalogs` export: definitions, or a factory.
365
381
  - `EditorCollection` (interface): interface EditorCollection — A named, persisted list of object ids — a selection bookmark (restore, add-to) that can also double as a production group: `locked` blocks `translate`/`setTransform`/`remove`/`removeMany` on its members, `color`/`visible` are UI-only hints for the collections panel.
382
+ - `EditorColorGrading` (interface): interface EditorColorGrading — Authored colour grading for the scene document's final image. The editor bakes a small display-space LUT from these controls and the shell applies it after tone mapping, so a game's grade is authored data like its sky, not a hardcoded pass in the game's config.
366
383
  - `EditorDirective` (type): type EditorDirective = EditorScatterDirective | EditorPopulationDirective — A procedural directive on the scene document: scatter props or a mob population.
367
384
  - `EditorDirectiveArea` (interface): interface EditorDirectiveArea — XZ world-space bounds, `[minX, minZ]`..`[maxX, maxZ]`, for a directive that names no region.
368
385
  - `EditorDirectiveKind` (type): type EditorDirectiveKind = EditorDirective["kind"] — The distinct directive kinds a materializer expands.
@@ -372,6 +389,7 @@
372
389
  - `EditorFragmentContent` (interface): interface EditorFragmentContent — The four placeable-object collections a prefab fragment or clipboard fragment carries.
373
390
  - `EditorKindVisibility` (interface): interface EditorKindVisibility — Per-kind show/hide flags for the editor's layer panel.
374
391
  - `EditorLayersInput` (type): type EditorLayersInput = | EditorDocument | Partial<Omit<EditorDocument, "version">> | (() => EditorDocument | Partial<Omit<EditorDocument, "version">>) — Accepted shape for a game's `editorLayers` export: a document, partial data, or a factory.
392
+ - `EditorLightProbe` (interface): interface EditorLightProbe extends IrradianceVolume — An irradiance volume authored on the scene document: a grid of baked SH probes over a box, plus the bake settings that produced them so a scene can re-bake without the editor session.
375
393
  - `EditorMarker` (interface): interface EditorMarker — A placeable point object in the scene — spawn, mob, chest, POI, etc.
376
394
  - `EditorMinimapBake` (interface): interface EditorMinimapBake — A baked top-down minimap stored on the scene document (#1036): the PNG the editor rasterized from the authored terrain (a `data:image/png;base64,…` URI) plus the world bounds it spans. Runtime feeds these straight into the `Minimap`/`WorldMap` `background`/`mapBounds` props — no re-raster.
377
395
  - `EditorNote` (interface): interface EditorNote — A free-text annotation pinned to a world position for designers.
@@ -564,6 +582,10 @@
564
582
 
565
583
  - `MaterialAssetEditorProps` (interface): interface MaterialAssetEditorProps — A callback-driven physical material editor; the host owns persistence, undo and preview rendering.
566
584
 
585
+ ## @jgengine/editor/MaterialLibraryBrowser
586
+
587
+ - `MaterialLibraryBrowserProps` (interface): interface MaterialLibraryBrowserProps — Adopting a library name writes a real, editable material asset into the scene document.
588
+
567
589
  ## @jgengine/editor/PerfProbe
568
590
 
569
591
  - `PerfProbe` (const): const PerfProbe: React.MemoExoticComponent<({ api }: { api: EditorHostApi; }) => null> — In-canvas frame counter: publishes fps/draw-call samples to the editor host every 500ms.
@@ -8,6 +8,11 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
8
8
 
9
9
  - `authoredMaterialAssignments` (function) · `import { authoredMaterialAssignments } from "@jgengine/core/editor/materialAuthoring"`
10
10
 
11
+ ## color-grading — read the scene document's authored grade at runtime
12
+
13
+ - `colorGradeFromDocument` (function) · `import { colorGradeFromDocument } from "@jgengine/core/editor"`
14
+ - `lutCubeFromGrading` (function) · `import { lutCubeFromGrading } from "@jgengine/core/editor"`
15
+
11
16
  ## creator-export — export validated player scenes with approved catalogs and finite budgets
12
17
 
13
18
  - `exportCreatorDocument` (function) · `import { exportCreatorDocument } from "@jgengine/core/editor/creatorStorage"`
@@ -100,6 +105,13 @@ Reach for these before hand-rolling. Each row is *the thing you need* → *the p
100
105
  - `runtimeEntityMetaWriteBackCommand` (function) · `import { runtimeEntityMetaWriteBackCommand } from "@jgengine/core/editor"`
101
106
  - `summarizeRuntimeInspector` (function) · `import { summarizeRuntimeInspector } from "@jgengine/core/editor"`
102
107
 
108
+ ## light-probes — author a baked irradiance volume on the scene document
109
+
110
+ - `EditorLightProbe` (interface) · `import { EditorLightProbe } from "@jgengine/core/editor"`
111
+ - `authoredLightsFromDocument` (function) · `import { authoredLightsFromDocument } from "@jgengine/core/editor"`
112
+ - `bakeLightProbe` (function) · `import { bakeLightProbe } from "@jgengine/core/editor"`
113
+ - `lightProbesFromDocument` (function) · `import { lightProbesFromDocument } from "@jgengine/core/editor"`
114
+
103
115
  ## minimap-bake — persist a baked minimap PNG + bounds on the scene document
104
116
 
105
117
  - `EditorMinimapBake` (interface) · `import { EditorMinimapBake } from "@jgengine/core/editor"`
@@ -14,6 +14,23 @@ Scene and catalog modules are optional. A present module with a broken dependenc
14
14
 
15
15
  `dispatch` with `setEnvironment` validates only the candidate environment before changing the session, undo history, or live document revision. Supported presets are `day`, `dusk`, and `night`; custom palette, sun, intensity, and fog fields remain explicit authored values. Invalid inputs report `$.environment` paths. Validated nested fields are copied, so mutating the command afterward cannot change the document. Passing `environment: undefined` removes the bag and remains undoable. Repair unsupported authored values through an explicit editor command; preserve the author's palette and fog rather than substituting a default.
16
16
 
17
+ `environment.grading` is the colour pipeline's authored face, edited in the Lighting workspace's Colour grade section:
18
+
19
+ ```json
20
+ "grading": {
21
+ "size": 8,
22
+ "shadows": "#25304a",
23
+ "midtones": "#e6dcc8",
24
+ "highlights": "#fff0d2",
25
+ "tint": 0.42,
26
+ "saturation": 1.12,
27
+ "contrast": 1.08,
28
+ "autoExposure": { "min": 0.5, "max": 2.6 }
29
+ }
30
+ ```
31
+
32
+ Each band swatch is a display colour the pixels of that band are pulled toward by `tint` (absent band = untouched); saturation and contrast work around luma first. The runtime bakes the block into one `size³` cube LUT (`lutCubeFromGrading`, `@jgengine/core/editor/environment`, size 2..16, default 8 — a few hundred bytes of JSON, no texture asset to ship) and applies it after tone mapping; `autoExposure` is the clamped range the meter may use. The shell reads it from `editorLayers` with `colorGradeFromDocument`, so a game carries its own grade by authoring this block — never by hardcoding a LUT URL or grade in its config. Swatches must be hex colours and cube sizes outside 2..16 are rejected with their `$.environment.grading` path.
33
+
17
34
  ## Atomic authoring batches
18
35
 
19
36
  Use `document_revision` to inspect the current revision, then send one `push_document_patch` for a complete placement operation. Commands use the `EditorCommand` shapes from `@jgengine/core/editor/commands`, rather than RPC verb shapes. Earlier commands' ids are available to later commands in the same batch: