Wormhole rendering
Frozen contract that every wormhole workstream builds against.
A wormhole lamp is N rings of 24 LEDs. The chain is ring r = physical indices r*24 .. r*24+23. Two render modes:
- strip (default, out-of-the-box) — the effect renders the whole construct as a flat strip. Render grid =
(24·ringCount) × 1; a script seesidx=0 .. 24·rings-1and, if it wants ring structure, derivesring = floor(idx / 24)andposition-on-ring = idx mod 24itself (24 is the fixed wormhole ring size).x = idx,y = 0. - mirror — the effect renders one 24-LED ring; firmware tiles that ring onto every physical ring with per-ring transforms. Firmware-only; the VM and
shade()never see it.
Geometry: render vs physical
The one architectural change: render geometry ≠ physical geometry.
| Mode | Render grid | Render pixels | Physical LEDs |
|---|---|---|---|
| strip | (24·rings) × 1 |
24*rings |
led_count |
| mirror | 24 × 1 |
24 |
led_count |
Strip mode is a flat strip — h is always 1. A ring-aware effect derives the rings from idx (ring = floor(idx/24)); a flat effect just uses idx. An effect built for strip mode that is played in mirror mode sees w = 24, so rings collapses to 1 and it degrades to a single ring rather than breaking.
led_count == 24 * rings always holds for a wormhole. The player renders a render buffer of renderPixels pixels, then wormhole_expand() produces the physical buffer of led_count pixels that goes to led_control.
wormhole_expand() — the single tiling function
One function, used by both the JS player and the WebSocket stream path.
void wormhole_expand(const led_color_t *render, int render_pixels,
led_color_t *physical, int rings, bool mirror);
For each ring r (0..rings-1) and local position p (0..23):
srcRing = mirror ? 0 : r
reverse = face[r] ^ direction[r] ^ (mirror ? creativeReverse[r] : 0)
shift = (physOffset[r] + (mirror ? creativeOffset[r] : 0)) mod 24
bright = mirror ? creativeBrightness[r] : 1.0
q = reverse ? (23 - p) : p
s = (q + shift) mod 24
physical[r*24 + p] = render[srcRing*24 + s] * bright
All index arithmetic is mod-24 and in-bounds by construction — the only failure mode (led_count % 24 != 0) is caught by the geometry gate, never reached here.
Identity guarantee: in strip mode with default physical config (all rings face=0,direction=0,offset=0), wormhole_expand is a byte-identical copy. Existing wormhole builds are unaffected. wormhole_expand only runs when form == "wormhole"; all other forms keep the exact current code path.
The three physical fields (per ring)
Set-once mounting facts, applied in both modes. NVS-stored, see below.
face(0|1) — the half-turn the ring is mounted at. A ring facing the opposite way has its angular sweep mirrored to a fixed observer → contributes a reversal bit.direction(0|1) — winding direction the LEDs were soldered in → contributes a reversal bit.offset(0..23) — physical index sitting at the ring's canonical 12-o'clock → contributes an additive bit.
Net reverse is the XOR of face, direction (and creative). Net shift is the mod-24 sum. face and direction are kept distinct because they are two independent physical facts, even though both reduce to a reversal bit.
The three creative fields (per ring)
Mirror mode only, per-lamp (not per-effect). NVS-stored.
reverse(bool) — extra reversal bit, XORed in.offset(0..23) — extra additive bit, summed in.brightness(0.0..1.0) — per-ring output scale. Range is 0..1 only.
Physical offset and creative offset are not in conflict — just two addends of one mod-24 sum.
Config keys (NVS, namespace plaiiin_cfg)
All wiped by factory reset alongside the rest of the namespace.
wh_mode— string,"strip"(default) or"mirror".wh_rings— i32, explicit ring count. Defaultled_count / 24. v1=2, v2=4.wh_phys— JSON array string, one object per ring, physical fields:
[{"face":0,"direction":0,"offset":0},
{"face":1,"direction":1,"offset":0}]
Fewer entries than wh_rings → missing rings default to all-zero.
wh_creative— JSON array string, one object per ring, creative fields:
[{"reverse":false,"offset":0,"brightness":1.0},
{"reverse":false,"offset":0,"brightness":1.0}]
Missing rings default to {reverse:false, offset:0, brightness:1.0}.
Both arrays follow the share_keys pattern: a JSON array packed into one NVS string. Unset keys fall back to the defaults above.
Geometry gate
mirror mode is allowed only when all of:
lamp_form == "wormhole", andled_count % 24 == 0, andwh_rings == led_count / 24.
wormhole_mirror_allowed() returns this boolean. A request to set mode=="mirror" while it is false is rejected (see endpoints). On boot, if wh_mode=="mirror" but the gate fails, firmware falls back to strip and logs a warning — it never plays into an invalid geometry.
HTTP endpoints
JSON style matches /api/grid / /api/orientation.
GET /api/wormhole — role: user
{
"mode": "strip",
"rings": 2,
"mirrorAllowed": true,
"renderPixels": 48,
"streamPixels": 48,
"physical": [
{"face":0,"direction":0,"offset":0},
{"face":1,"direction":1,"offset":0}
],
"creative": [
{"reverse":false,"offset":0,"brightness":1.0},
{"reverse":false,"offset":0,"brightness":1.0}
]
}
renderPixels / streamPixels = 24 in mirror mode, 24*rings in strip mode. A streaming client reads streamPixels to know the frame size to send.
For non-wormhole lamps the endpoint returns 404.
POST /api/wormhole — role: admin
Body: any subset of {"mode","rings","physical"}.
mode—"strip"|"mirror"."mirror"whenmirrorAllowedis false →409 Conflict, body{"error":"mirror not allowed for this geometry"}.rings— i32 ≥ 1.physical— full JSON array as inwh_phys.
Side effects on a mode or rings change:
- the JS player is re-initialised (render geometry changes) — a brief re-init flicker is expected and acceptable;
- if a WebSocket stream is active it is closed with close code
4002(see below).
Returns the same body as GET /api/wormhole.
POST /api/wormhole/creative — role: creator
Body: {"creative":[ … ]} (full array) or {"ring":N,"reverse":…,"offset":…, "brightness":…} (single ring patch). Stored regardless of current mode; only applied in mirror mode. No player re-init, no stream close — creative knobs take effect on the next frame. Returns the GET /api/wormhole body.
WebSocket streaming (/ws)
Binary protocol unchanged (CMD_COLOR_FRAME=0x01, CMD_POWER=0x02, CMD_CLEAR=0x03). What changes for a wormhole lamp:
- A streaming client must first
GET /api/wormholeand sendCMD_COLOR_FRAMEframes carrying exactlystreamPixelspixels. - The firmware routes wormhole stream frames through the same
wormhole_expand()the player uses, then toled_control. - strip mode:
streamPixels == 24*rings— the frame is the full render grid (firmware still applies the physical per-ring transform). - mirror mode:
streamPixels == 24— the frame is one ring; firmware tiles it onto all rings.
Stream rejection — server closes the WebSocket
No error frame, no queueing. The server sends a WebSocket Close frame with an application close code and stops:
4001— frame size mismatch: aCMD_COLOR_FRAMEwhose pixel count is notstreamPixelsfor the current mode.4002— render mode changed: aPOST /api/wormholechangedmodeorringswhile this stream was open; the next frame (or the change itself) closes the socket.
The client is expected to reconnect, re-GET /api/wormhole, and resume with the new frame size.
Form-prompt: mode-aware (/api/form-prompt)
For form == "wormhole", GET /api/form-prompt gains two fields:
{
"form": "wormhole",
"mode": "strip",
"default": "<descriptor for the current mode>",
"override": "",
"hasOverride": false,
"effective": "<override if set, else default-for-current-mode>",
"byMode": {
"strip": "<strip-mode descriptor>",
"mirror": "<mirror-mode descriptor>"
}
}
byModealways carries both firmware default descriptors so a client can preview/switch without a round-trip. Non-wormhole forms omitbyModeandmodeentirely (response shape unchanged for non-wormhole lamps).defaultandeffectivereflect the currentwh_mode.- A user
override(PUT/api/form-prompt) applies globally — it is theeffectivevalue for both modes (v1 decision).byModestill shows the two defaults. - The client picks
byMode.stripvsbyMode.mirrorto inject into AI compose prompts based on the mode it wants to compose for.
Form-template file (form-template/wormhole.txt)
The burned template gains two sections, split by line-leading markers:
@@strip
PHYSICAL FORM: wormhole (strip mode). … {rings} … {count} …
@@mirror
PHYSICAL FORM: wormhole (mirror mode). Render ONE 24-LED ring …
form_prompt.c parsing rule: if the file contains @@strip / @@mirror markers, the section for the current mode is used (and both go into byMode); if no markers are present the whole file is used for both modes. Non-wormhole templates have no markers and are unaffected. {placeholder} substitution runs per section as before.
@mode script annotation
Effect authors add a leading comment // @mode mirror or // @mode strip to declare which render mode a shade() script is written for. As of Phase 41:
- The PLBC compiler parses
@modeinto the program (plbc_program_t.mode:-1none /0strip /1mirror) and serializes it in the.bcheader (format bumped to v2 — stale v1.bcare recompiled on boot viajs_storage_bc_current). js_api_play()auto-switcheswh_modeto the effect's declared mode on a wormhole lamp before the player inits its render grid (reusing the geometry gate + strip fallback). Effects with no@modeleavewh_modeuntouched.- The declared mode is surfaced to clients as the top-level
"mode"field ofGET /api/js/<name>/params, so the dashboard tile shows a render-mode toggle (wired toPOST /api/wormhole) for effects that carry one. - The
POST /api/wormholereinit replay usesjs_api_play_ex(…, autoswitch= false)so a hand-set mode isn't immediately reverted by the auto-switch.
A new tune type rides along: // @param NAME switch = 0 declares a 0/1 toggle (plbc_param_t.type == PLBC_PARAM_SWITCH), surfaced as "type":"switch" in the params JSON and rendered as a switch (not a knob) by all clients.
Out of scope
- "Rocket areas" / heterogeneous segment tiling — a separate, larger feature.
- Mirror-aware preview in
shade-runtime.js— preview renders a single 24-LED ring in mirror mode. Documented caveat, not a bug.
This page is generated from docs/wormhole-api.md in the firmware repository, which stays the source of truth. View it on GitHub.