Agents can't see what they render. SSWorld closes the loop.
An MCP server that lets any MCP-capable agent app (Claude Code, Codex, Hermes, Cursor …) author,
compile, render and inspect 3D geographic scenes — written in SSDL, a QML-like scene
description language — on the SSEngine WebGPU runtime. The picture above is a scene an agent
built: 3.28 km² of real Guanlan (Shenzhen) streets rebuilt from OpenStreetMap; the pipeline that
generates it is in examples/GuanlanRoadTemplate0924.
- A real engine, not a preview. Deferred rendering, atmospheric sky, shadows, post-processing, glTF assets, GPU instancing. Scenes stand on a real globe: terrain, imagery layers, 3D Tiles and GeoJSON, anchored by longitude and latitude. 60 fps with hundreds of thousands of instanced objects.
- The agent gets the frame back.
ssworld_capture_framerenders through the engine and returns luma percentiles, exposure tails, colour coverage per 3×3 region, top colours, the camera pose it actually used and the deviation from the one requested — plus a receipt binding the frame to the source digest and the answering page. An agent can tell that its sky came out orange-brown, and fix it, without a human looking. - Traps that cost a real session become compile errors. Every failure below was first hit on real
hardware, where it showed up as a blank page with nothing in the log. Each one now names the node
and the member at compile time: a sky tinted through
rayleighScattering, aLatheradius of 0, anormalMapon geometry without tangents, aheightslist counted as cells instead of corners.
| GuanlanRoadTemplate0924 | SkylineGarden0919 | DaxiongHall0924 |
|---|---|---|
![]() |
![]() |
![]() |
| Real streets rebuilt from OpenStreetMap by a reusable pipeline: point it at another bbox and it builds that site. | An original procedural city with traffic, signals, a train, park life, fountains and a day–night cycle. | A Chinese temple hall — double eaves, bracket sets, marble terrace, incense smoke — with a cinematic camera tour. |
All three carry only their hand-written sources and generators — meshes, textures and the .ssdl
components are regenerated by the scripts — so each is under 400 KB. Every mesh and texture is
generated; the one external input is the Guanlan street network, © OpenStreetMap contributors
(ODbL), fetched at build time. See examples/.
npx -y ssworld-mcp installThis installs the package globally, registers the ssworld MCP server with every agent app it finds
(--client=claude,codex,hermes,cursor,dsh to choose), and downloads the pinned engine (≈54 MB) into ~/.ssworld/engine/.
Requirements: Node.js ≥ 22, npm, a WebGPU browser (Chrome/Edge) to look at previews.
Claude Code and Codex both read plugin manifests straight out of this repository, so one command registers the MCP server and the SSDL authoring skill with no global npm install. The pinned engine still downloads on the first preview.
# Claude Code
/plugin marketplace add DataArche/SSWorld
/plugin install ssworld@ssworld
# Codex
codex plugin marketplace add DataArche/SSWorld
codex plugin add ssworld --marketplace ssworldHermes, Cursor and DSH have no plugin package that can carry an MCP server — each registers servers
in its own config — so ssworld-mcp install is the native path there and writes exactly what that
host reads: mcp_servers: in ~/.hermes/config.yaml, mcpServers in ~/.cursor/mcp.json, and an
@deepseek-ai/dsh-mcp-client row in DSH's user patch layer ~/.dsh/cordis.patch.yml. Hermes and DSH
also get the skill copied into their own skills/ directory, which is the whole registration.
npx -y ssworld-mcp install --client=hermes,dshManual registration for any other client:
{ "mcpServers": { "ssworld": { "command": "node", "args": ["<npm root -g>/ssworld-mcp/bin/ssworld-mcp.mjs"] } } }Fourteen tools — click to expand the full table
| tool | purpose |
|---|---|
ssworld_catalog |
component index / one contract / components: [...] in one call (detail: compact hoists shared members); catalog_digest + if_digest for cache hits; units and rotation conventions annotated per member |
ssworld_project_list |
projects in ~/.ssworld/projects |
ssworld_project_create |
new runnable project (anchored at lon/lat/height), compiled |
ssworld_source_read / ssworld_source_write |
bounded reads (max_chars, offset/limit with has_more/next_offset; mode: metadata for digests, sizes and compile staleness without text; node: "<id>" for one block; file: "*" for all files) and whole-file writes of .ssdl sources; editing the files on disk with any tool also works (ssworld_compile rebuilds from disk) but bypasses the digest lock |
ssworld_source_patch |
exact-span edit (old_string → new_string, uniqueness checked, replace_all opt-in) with the same digest guard |
ssworld_source_batch |
atomic multi-edit: text patches and node-level {node_id, set, unset} across files; all validated in memory before anything is written; validate: compile + rollback restores the previous sources on a failed compile |
ssworld_scene_inspect |
compiled-scene facts without rendering: budget ratio, subtrees by size, leaf children by type, nodes per source file, primitive extent, the requested camera (lookAt-derived heading/pitch); render stats honestly unavailable |
ssworld_compile |
SSDL 0.3 compiler with real diagnostics, node_count, budget usage and logic (declared properties, states, host calls); catalog/runtime mismatches are compile errors (Label without a font, material animations on a Group/Model, undeclared host calls) |
ssworld_preview |
starts the local preview server, returns http://127.0.0.1:8880/projects/<name>/index.html, whether the page is open, page.clients (every browser syncing the page: id, visibility, canvas, user agent) and a structured next (open_webgpu_viewer / capture_frame / bring_page_to_front) |
ssworld_capture_frame |
screenshot of the open preview through the engine (saveImage2Base64); stats with luma percentiles, exposure tails, colour-class coverage overall and per 3×3 region, top colours; runtime errors deduplicated and mapped to scene.ssdl:line:column; camera pose with `source: scene |
ssworld_environment_read |
ask the engine what it actually received for the environment: the adopted sun's direction read back from the native sun and converted to azimuth/elevation at the anchor (with the deviation from the scene's request), which DirectionalLight drives the atmosphere, and every environment component's live native values. The probe lives in the project's index.html, which is project-owned, so a page created before 0.9.8 answers page_probe_unavailable and the error names the handler to paste in |
ssworld_geo_read |
ask the engine what it holds for the geographic layers: whether the terrain provider loaded, anchor_above_terrain_m (how far the scene's local z: 0 sits above the ground under the anchor, because terrain moves the ground and not the scene), the imagery stack in real draw order with each layer's native_index, and every Tileset / GeoJsonLayer's readiness, extent and feature count. Same project-owned probe as the environment read, so an older index.html answers page_probe_unavailable |
ssworld_geometry_read |
measure the scene from the engine instead of a screenshot: every geometry node's and Model's dimensions (object axes, metres), world position/rotation/scale composed through the live scene graph, axis-aligned world_bounds (tight or an envelope), ground contact (on / buried / above z = 0, and a Box is centred on its position so z: 0 buries half of it), mesh facts the engine measured (vertex/triangle counts, closed, manifold, area_m2 / volume_m3); Groups and GeoAnchors as the union of their descendants, Instances as the native union, the scene as its whole extent; overlaps: true lists intersecting boxes with the overlap volume, ids narrows, detail: brief trims. Same project-owned probe, so an older index.html answers page_probe_unavailable |
ssworld_logic_read / ssworld_logic_write |
read the open page's scene logic (and whether the scene module actually mounted: runtime.state plus errors mapped to scene.ssdl:line) without a screenshot, or set declared properties in one transaction ({set: {p: 0.4, pace: 0.0025}}) to put a game into a situation before capturing; a refused value rolls the whole set back and names the failing binding |
ssworld_engine_status |
engine pair installed? (install: true to download) |
Budgets, what actually runs out, and in which order
Budgets are per-project values in showcase.manifest.json; the defaults and the ceilings come from
src/ssdl/tools/probe_engine_limits.py (which asks every facade for its own capabilities()) and
src/ssdl/tools/probe_scale_limits.py (which raises the budgets and builds a scene past them on real
hardware). Defaults: 4096 native objects / 4096 material shells / 32 distinct images / 4096 bindings
/ 2048 handlers / 256 timers / 256 timelines / 1024 Group locators / 1024 Prefabs / 524288 instance
rows. Four of those are the engine's own and a manifest cannot raise them (resolveBudgets clamps
distinct images, timers, timelines and Group locators), because a scene past them compiles and then
fails to mount.
What actually runs out, in order:
- Not rendering. 6401 native objects with 409 600 instance rows across 200 prefabs compiled in 1.2 s, mounted in 4.0 s and ran at 61 fps with no runtime errors.
ssworld_capture_framefirst. The offscreen readback dies of a wasm "memory access out of bounds" somewhere between 4001 native objects (captures,render_verified, ~21k distinct colours) and 5001 (does not). Past roughly 4000 nodes a scene runs and cannot be screenshotted, which is whynative_objectssits just under that and why instancing — 409 600 rows for 200 native objects — is the way to build something large. The capture timeout is now the caller's to set: the page used to give the readback a fixed 8 s whatevertimeout_mssaid, so a scene big enough to be worth a screenshot always timed out.- Bindings used to be the hard wall, and are not any more.
AnimationFacade.captureBatchrefuses a batch of more than 64 items and refuses it whole, so a scene whose first frame carried 65 bindings failed to mount with a receipt that said "evaluated 65, committed 0, failed 65" and named nothing — while the shipped budget said 256. The runtime now flushes bindings in chunks of 64 and restores the chunks that already landed if a later one fails, so the flush stays all-or-nothing to the author. Measured after the fix: 1500 bindings + 600 handlers + 914 locators mount in 2.2 s at 44 fps.
Three engine ceilings have no budget dimension and are compile errors of their own:
scene_depth_exceeded (SceneGraphFacade.max_depth, 16 levels of nesting), model_budget
(ModelFacade.max_model_instances, 64 Model mounts — use one as a Prefab source instead of a copy
per placement) and texture_budget (MaterialFacade.max_texture_bytes_total, 64 MiB of distinct
images however small each file is).
Colour space, what the compiler refuses, procedural geometry, geographic layers, time and sky, model assets, scene logic, runtime evidence
#rrggbb is read as sRGB — the value a colour picker gives you. The runtime converts it to linear before handing it to
the engine, so the frame shows the colour that was picked and reading the value back returns the same hex. Before 0.9.8
that conversion was missing and every flat colour rendered roughly three times too bright (#16260f came out as a light
green). Native materials keep 8 bits of linear light per channel, so very dark colours quantise: #16260f reads back
as #16260d, and anything below about #0a0a0a collapses towards black. Verified on real hardware with
src/ssdl/tools/probe_color_space.py (headless Chromium + WebGPU, frames captured through the engine).
Several failures that used to surface only as a dead page are now compile errors naming the node and the member:
sky_scattering_refused—SkyAtmosphere'srayleighScattering,mieScattering,mieAbsorption,otherAbsorptionandskyLuminanceFactorare normalised direction vectors, not colours (their magnitude lives in the neighbouring*Scale). Writing a "neutral-looking" vector raises red and turns the whole sky orange-brown. Tint through the sun'slightColorinstead; the error text carries each member's real engine default.cloud_kilometres_expected—VolumetricCloud'slayerBottomAltitude,layerHeight,tracingStartMaxDistance,tracingMaxDistanceandshadowTracingDistanceare in kilometres, the unit UE uses for this component, while the rest of SSDL is in metres. Metre values do not merely misplace the deck: the ray-march sample count is fixed (min(96 x viewSampleCountScale, 768)) and does not grow with the tracing distance, so a ray stretched a thousandfold aliases the cloud noise into vertical white streaks across the sky. A deck islayerBottomAltitude: 1.8; layerHeight: 0.5.mesh_degenerate— a radius of 0 in aLatheprofile, exactly repeated adjacent points, aTubeorSweeppath that doubles back or repeats a point, two identical adjacentLoftrings, aMeshface with zero area, a ring with no area. These producedGeometryFacade.createMesh: triangle is degenerateand took the entire scene module down without naming a node. (A vertical firstTubesegment is fine: the parallel-transport frame switches reference axis at|tangent.z| >= 0.9.)mesh_invalid— a parameter outside its range or a shape the generator cannot build: aSweepprofile or anExtrudedPolygonoutline that crosses itself, ataper/bevelthat eats an edge (the message names the edge), aMeshface index out of range, aRooffootprint that crosses itself or whose skeleton cannot be closed (the message names the edge to simplify),ridgeon a polygonRooforgables/lowEdgeon a quadrilateral one,Capsulewithheight <= 2 * radiusor a segment count not divisible by four,sampleswithoutsmooth: "catmullrom",capon a closedLoft.placement_invalid—Instancesmembers that belong to another placement mode, aringwithout a positiveradius,along_pathwith both or neither ofstep/count, more than 512 instances in one batch, orfaceCenter/alignToPathnext to an explicit rotation member.spawnable_invalid— apragma spawnablecomponent that cannot be one: a root that is not a singleGroup, apropertyoutsidereal/bool/string/length/degrees/durationor one without a default (the default is what the fragment's budget and mesh limits are checked against), a scene singleton (Camera,Environment,Globe, a light that owns the sun, a geographic layer), aKeyHandlerorPointerHandler(both claim the whole window), or aModel(it loads asynchronously, andapi.scene.spawnreturns a live handle in the same turn). The entryscene.ssdlitself cannot be spawnable.spawn_budget/spawn_parameter_invalid/spawn_parameter_immutable/spawn_handle_unknown/spawn_placement_invalid— runtime refusals fromapi.scene.spawn_budgetnames the dimension and shows static + already-spawned + this copy against the manifest limit, and nothing is built.spawn_parameter_immutablemeans the parameter feeds acreate_onlymember, so changing it isdispose+spawn, notset.spawn_replay_failed— a hot reload replayed the spawn log into the new generation and one entry no longer fits the component as it was edited. It does not stop the mount; the entries are inssworld_logic_readunderdynamic.spawn_failures, and the objects they named are simply gone.runtime_unsupported—Label(no font),sunAzimuth/sunElevationwithoutatmosphereSunLight: true, and the members a light may not write in the mode it is in.multiple_writer/environment_duplicate— anEnvironmentowns the sun direction, so aDirectionalLightthat also pinssunAzimuth/sunElevation, or aSunSkybeside it, is refused rather than left to a last-writer-of-the-frame race; and the scene-globalEnvironmentslot takes exactly one claimant.sky_light_capture_owned—SkyLight.realTimeCapture: falsebeside anEnvironment. TheEnvironmentowns the sky light capture and keeps it running, so thefalsewould be a no-op; it is refused with the reason instead.material_requires_tangent— anormalMapon geometry without tangents (onlyHeightField/Lathe/Tube/Lofthave them).value_out_of_range—emissiveColoroutside its[r, g, b]0..16 range.
List values may span lines since 0.9.8: a newline after sections: [, one ring per line and comments inside the
brackets all compile. A property still ends at a newline or ;, so this only applies inside [ ].
Four parametric generators compile to constant parameters and are tessellated by the runtime (MeshData/v1, ccw outward,
≤ 65535 vertices per node, mesh_budget beyond): HeightField { width; depth; columns; rows; heights }
(columns/rows count cells, heights counts the (columns+1)*(rows+1) corners around them — row-major from -depth/2),
Lathe { profile: [[radius, 0, height] …]; segments; closed }, Tube { path; radius; segments; closed },
Loft { sections: [[ring] …]; cap }. ssworld_compile reports their cost as usage.mesh. There are no per-vertex
functions: arbitrary meshes are managed assets (Model).
Every scene already stands on the globe at its manifest anchor; four components let it reach the globe
itself, all of them direct children of Scene with no transform of their own (geographic longitude/latitude
and the scene's local ENU metres are two different worlds, and SSDL does not pretend otherwise):
Globe { id: earth; terrain: "default"; lighting: false }
ImageryLayer { id: base; source: "https://<your tile service>/{z}/{x}/{y}.png"; webMercator: true }
Tileset { id: city; source: "https://<host>/tileset.json"; offset: [0, 0, -12] }
GeoJsonLayer { id: parks; source: "assets/parks.geojson"; geometry: "polygon"; fillColor: "#2e7d32" }
Globe— terrain, ground colour, opacity and lighting; at most one, and the last scene to write it hands the globe back as it found it on unload.ImageryLayer—kind: "xyz" | "wms" | "arcgis" | "single". Draw order is declaration order, the first layer being the base map;visibleandalphacan be bound and animated by scene logic. At most 8.Tileset— 3D Tiles, or a Gaussian splat set withsplat: true.offset/rotation/scaletransform the tileset's own root (offset.zis the height correction most datasets need). This engine has nomaximumScreenSpaceError;geometricErrorScale(0.2–12) is the LOD dial. At most 4.GeoJsonLayer— one layer draws one kind of feature (geometry: "polygon" | "line" | "point"). The document is a managedassets/*.geojsonwhose geometry is checked againstgeometryat compile time, or a remoteurlthe page fetches — a cross-origin server withoutAccess-Control-Allow-Originreportsgeojson_fetch_failedinstead of leaving a silently empty layer. Polygons are filled: withextrudeHeightFieldthey become solid blocks, without it a flat plane. At most 8.
Terrain moves the ground, not the scene: local z: 0 stays at the anchor's ellipsoid height, so a scene built
flat can end up buried in a hillside the moment terrain loads. ssworld_geo_read answers with
anchor_above_terrain_m, the imagery stack in real draw order and each layer's readiness. No basemap ships
with this server — a tile service carries terms and often a key, and neither is ours to accept for you;
"template": "geo" lays out the scene with the ImageryLayer line commented out, waiting for a URL.
Environment is the scene clock and the astronomy solver. Give it an instant and a site and the sun, the moon and the
star field go where they really were — Simon 1994 ephemerides, an IAU 2006 ICRF-to-fixed rotation, station azimuth and
elevation, the same solve a planetarium does:
Environment {
id: clock
dateTime: "2026-09-12T18:30:00+08:00" // ISO-8601 WITH an offset
timeScale: 60 // simulated seconds per real second; 0 freezes the sky
latitude: 22.6433; longitude: 113.938 // omit both to follow the camera's ground point
cloudCoverage: 1.8; windDirection: 240; windSpeed: 9
}
- It owns the sun. While an
Environmentis declared,DirectionalLight.sunAzimuth/sunElevationandSunSkyare refused asmultiple_writer. To pin the sun without giving up the clock, usesunAzimuthOverride/sunElevationOverrideon theEnvironmentitself. One per scene, no parent. dateTimeneeds an explicit offset. A naive instant would silently mean the viewer's timezone, so it is refused.- Stars fade on their own — fully out above 0° of sun elevation, fully in below −12° — so
starsIntensityis a ceiling, not a switch.starsRealRotationputs the constellations where they belong. sunIntensityis an absolute level (default4.65, the Ultra Dynamic SkySun.SunLightIntensity). Nothing downstream renormalises it: at1.0the sky falls into the tonemapper's toe and reads as near black while the clouds still look lit. Leave it alone unless you are matching an Unreal project value by value.- Weather is opt-in.
cloudCoveragefollows the UDS scale (0..3, factory1.14) and thickens the cloud deck and the height fog with it (fogDensityClear→fogDensityCloudy, the UDS curve);windDirection(0 = north, clockwise) andwindSpeeddrift the deck. Omit them and the clouds and fog stay exactly as authored onVolumetricCloud/ExponentialHeightFog— which is what you want when those values came out of Unreal. - It drives the sky light too. The ambient half of the sky is a cubemap the engine captures, convolves into an SH
set for the diffuse ambient, and reuses as the sky reflected in water and metal. A declared
Environmentforces that capture on and keeps it running, so the ambient and those reflections follow the clock instead of holding one instant;SkyLight.realTimeCapture: falseis refused beside it (sky_light_capture_owned) rather than silently ignored. The capture is sliced over five frames (sky faces, clouds, two passes of GGX pre-convolution for the reflection mips, then the diffuse SH). The frame as a whole settles far more slowly than one round: after a day -> night jump the mean luma was measured falling 26 -> 2 over ~30 s, which is whyssworld_capture_frametakesstable: {}(sample small frames until the brightness stops moving) for a capture right after the clock moves. The capture defaults to on in the engine, so a plainSkyLightalready tracks a moving sun;realTimeCapture: falseis how an author freezes the ambient on purpose, and that is the write theEnvironmenttakes away. fogGetsColorFromAtmosphere(defaulttrue) makes the height fog take its inscattering colour from the atmosphere, so it reddens at sunset and goes dark after it instead of holding the authored daytime blue.- The cloud look is the rest of
VolumetricCloud, and every member there is named after its UE / Ultra Dynamic Sky input —minimumErosion,hightFrequencyNoiseAmount(the UE spelling),extinctionScaleTop,noisePosition, andphaseG/phaseG2/phaseBlend/multiScattering*from the UEVolumetricAdvancedMaterialOutputnode — so a cloudscape tuned in Unreal transfers value by value. The defaults are the UDS factory look. SunSkyis the UE 4.27 spelling of one fixed instant (month/day/solarTime/timeZone; UE'sSunSkycarries no year). It lowers onto the same solver and pins the clock, so reach forEnvironmentwhenever time should run.
Copy glb files (and png/jpg textures) into <project>/assets/ and reference them by project-relative path:
Model { id: tree; source: "assets/tree.glb"; position: [10, 0, 0] }. ssworld_compile discovers everything under
assets/ (usage.assets), embeds each file's digest into SceneIR and the preview page fetches and verifies the bytes
by that path. Limits: 32 MiB per glb, 8 MiB per texture, 256 assets per project (asset_budget); a path outside
assets/ is asset_unresolved. A Model carries its own materials; only its position/rotation/scale/visible animate.
Scene state is declared on the Scene root (property real score: 0, types real/bool/string/length/degrees/duration/radians),
changed by handler assignments (score = score + 1, one transaction per handler) and read by bindings
(when: score >= 8 && misses < 3; operators + - * / === !== < <= > >= && || ! ?:, functions min/max/clamp/lerp).
Host JavaScript is reached only through declared calls: host_interfaces.json holds the contract
({"Game":{"methods":{"hit":{"args":[{"name":"targetId","type":"string"}]}}}}), logic.mjs implements it
(export function createHostInterfaces(api) { return { Game: { hit({ targetId }) { api.logical.write("score", …) } } }; }),
and SSDL calls it with TapHandler { onTapped: { Game.hit(targetId: "balloonA"); } }. Mismatches are compile errors
(host_interface_unknown, host_method_unknown, host_arg_missing, host_arg_unknown); a missing implementation refuses to
mount (host_interface_missing); callbacks are synchronous, return nothing and change the scene only through
api.logical.write. window.SSWorld.logical.read()/write() expose the same state on the page; ssworld_capture_frame returns it as logic.
logic.mjs is re-imported on every hot reload and declared properties restart at their initial values (ssworld_compile says so
as hot_reload.logic_reset while a page is open; ssworld_logic_write restores a situation).
Bindings and handler assignments commit as one transaction per event/frame. If any bound value is refused by its target
(a negative width, a wrong type, a native refusal) the whole batch rolls back, the binding turns invalid and the affected
values simply stop changing. The page reports this as a runtime error of kind binding_error
(<node>.<property>: <code>: <message>, counted with repeats instead of flooding) and ssworld_capture_frame /
ssworld_logic_read map it to the member line inside that node's block; logic.bindings.invalid lists the bindings
concerned. Prefer clamp/lerp/min/max over a progress property to branchy ?: chains, and keep every branch inside the
target's valid range. A Behavior eases every change of its target over duration, so on a per-frame property the
presented value lags by about speed × duration; use it for discrete jumps.
A desktop preview pane and an automation browser can both have the page open. The preview server keeps one record per
page (client id from the page's heartbeat): ssworld_preview lists page.clients, a capture is answered by the most
recently synced visible page unless client names another, and every receipt carries receipt.client
(id, visibility, canvas, user_agent) plus clients_connected, so a frame can be attributed to the page it came from.
The project template places the WebGPU canvas and the info panel side by side (nothing floats over the canvas, so taps
reach the scene's TapHandlers). Pages are project-owned; older projects keep their layout.
Clients that support skills also receive skills/ssworld, so they pick the server on their own for 3D-scene requests:
ssworld-mcp install copies it to $HERMES_HOME/skills/ssworld for Hermes and ~/.codex/skills/ssworld for Codex
(Codex discovers skill directories on its own; [[skills.config]] only records the ones that were explicitly disabled).
ssworld-mcp # MCP stdio server (what agent apps launch)
ssworld-mcp install [--client=claude,codex] [--no-engine]
ssworld-mcp engine # download / verify the pinned engine
ssworld-mcp preview [--port=8880]
ssworld-mcp city [--reset] [--pause=S] [--no-play] # the real-site city demo: import the bundled site, serve it, play the chain
ssworld-mcp doctor
Environment: SSWORLD_HOME (default ~/.ssworld), SSWORLD_PREVIEW_PORT (8880), SSWORLD_ENGINE_DIR (use a local engine pair instead of the release download).
ssdl/compiler is the byte-identical SSDL 0.3 compiler closure, ssdl/runtime the browser runtime, ssdl/catalog the
component catalog, engine.lock.json pins the SSmap.js/SSmap.wasm pair published as a GitHub Release asset.
Built by src/ssdl/tools/pack_ssworld_mcp.py in the SSEngine3 repository; do not edit here.
Two tracks, on purpose.
The MCP server, the SSDL compiler, the browser runtime, the component catalog, the project template
and the skill are Apache-2.0 (LICENSE) — read them, fork them, ship them.
The rendering engine (engine/SSmap.js, engine/SSmap.wasm, downloaded by the installer, never
checked in) is proprietary and covered by ENGINE-LICENSE.txt, which grants free use and
redistribution in binary form, including commercially. NOTICE lists the split file by file plus
the third-party components (qtloader.js is Qt's, under its own terms).


