MoonCore¶
The domain-neutral runtime: the platform abstraction, the services that bridge to hardware, and how several devices behave as one system. It knows nothing about lights, which is what lets MoonLight stay simple on top of it.
The runtime is described from the outside in: what a service is, how several devices behave as one, how a device is named, and last the platform layer every one of them calls through.
flowchart TB
q{"does the module consume<br/>the light output buffer?"}
drv["driver<br/>lives in the light domain"]
svc["service<br/>lives here, in core"]
q -->|"yes"| drv
q -->|"no"| svc
subgraph core["MoonCore · knows nothing about lights"]
direction TB
services["Services<br/>user-added: gyro, mic, relay"]
system["System<br/>wired by code: identity, network"]
multi["multi-device<br/>discovery, and a shared clock"]
end
plat["platform abstraction<br/>time · memory · allocExec<br/>sockets · scheduling"]
hw["ESP32 · Teensy · desktop"]
svc --> services
services --> plat
system --> plat
multi --> plat
drv -.->|"reaches hardware<br/>the same way"| plat
plat --> hw
classDef ask fill:#2d3561,stroke:#7b88c9,color:#fff
classDef light fill:#4d3d1f,stroke:#c9a95f,color:#fff
classDef neutral fill:#1f4d3d,stroke:#5fb89a,color:#fff
classDef seam fill:#3d2d61,stroke:#a07bc9,color:#fff
class q ask
class drv light
class svc,services,system,multi neutral
class plat,hw seam
One question sorts every module, and it is about the data relationship rather than the connector: a DMX sender speaks over a UART and is still a driver, because it sends the rendered buffer. Everything below the seam is reached only through the platform layer, which is why the same tree runs on a board and on a laptop.
Services¶
A service is a MoonModule (role ModuleRole::Service) that bridges to the outside world (hardware or network) independently of the light pipeline. Examples: a gyro/IMU over I²C, a microphone over I²S, a relay or GPIO toggled out, a status push to Home Assistant. Services are domain-neutral and live in core; the platform transport they use (I²C, UART, GPIO) is itself a domain-neutral platform primitive.
"Service" here means a user-added capability bridge, not the ESP32's own on-chip peripherals (LCD_CAM, SPI, PARLIO, RMT). Those on-chip blocks are how drivers clock data out to LEDs, reached through the platform layer.
The defining line is the data relationship, not the connector: does the module consume the light output buffer? If yes it's a driver (ArtNet, DMX, SPI-LED all consume the buffer, differing only in transport; a DMX sender uses a UART/RS-485 transport but is a driver because it sends the rendered buffer). If no, it's a service.
Services are user-add/deletable children of the Services container, the core-domain twin of the light pipeline's Effects/Drivers: a top-level container holding user-added children of one role. The firmware is identical whether or not the hardware is wired. The user adds the module when they solder a gyro on and removes it later, reusing the generic child add, replace, delete and persistence machinery; Services declares acceptsChildRoles("service"). Fixed device infrastructure lives under System instead, wired by code rather than user-added: identity, network, the inspection tool Tasks, and the board's I2C bus. That is the System/Services split. Direction is per-module, not a role: a service may read (gyro), write (relay), or both, so one Service role spans the category. Each is a header-only or .h+.cpp core module under src/core/, reaches hardware only through a domain-neutral platform primitive (platform::i2c*, platform::audioMic*, …), and gets a spec in docs/moonmodules/core/services.md (enforced by check_specs.py). Most poll in tick20ms or tick1s. The exception is a service whose data an effect consumes every frame: AudioService reads and analyzes its microphone on an audio task, and its tick() hands the effects the newest frame; a board without tasks analyzes in tick(), inside the render budget.
An effect reads a service's data through the shared-struct pull pattern from Data exchange, with no new mechanism. The service owns a small POD struct overwritten in place each poll or tick, and the consuming effect holds a const pointer to it. The first concrete case is audio: AudioService produces an AudioFrame (level + 16-band spectrum + peak) that AudioSpectrumEffect and the other audio effects consume. It reaches the frame through a static AudioService::latestFrame() rather than a boot-time setter. That variation exists because an audio effect can be added through the UI after boot and must still find the one live mic (a setter only wired the boot instance). The active mic registers itself in setup() and clears the pointer in release(), so add/remove in any order returns either the live frame or a static silent one, never null. A service that only displays its readings (the gyro today) skips the consumer side entirely.
A lossy channel drops at the source¶
On a lossy stream, drop at the source and let TCP pace the rest. Write only what the socket takes now, skip the frame when the previous one has not drained, report the skip to the receiver as its one honest congestion signal, and close only on a real error or a FIN.
Treating a slow socket as a fault is the trap: it converts ordinary congestion into disconnects, disconnects into re-primed state, and re-primed state into more congestion. Every give-up budget must bound lack of progress rather than elapsed total, or a slow-but-healthy transfer is truncated.
Multi-device runtime¶
Three domain-neutral services let several controllers act as one installation. They're core because nothing about them is light-specific; any domain spanning multiple devices uses the same three.
- Discovery: devices find each other by presence packets on a multicast group and the broadcast address WLED listens on, and
NetworkModuleadvertises each over mDNS. Live. - Shared controls: boards follow each other's control surface over OSC, the industry-standard protocol for it. A surface change on one board goes to the others as feedback, by unicast to
hostsor to a multicast group. Each writes it onto its own surface and may send it on once, back to its origin too. Only a changed value is sent, so the origin, already holding it, stops it there. Live. - Clock sync: a shared monotonic clock, so boards with the same controls also render the same moment. Its design is the backlog card Clock sync.
What the synced clock is for is a domain question; the light domain's use of it (synced animation across a wall) is in Multi-device sync.
Device name: one identity, every network name derives from it¶
A device has one network name, deviceName, and every name it presents on the network is that exact string. That covers the mDNS hostname (<deviceName>.local), the SoftAP SSID shown as the captive portal when unprovisioned, and the DHCP hostname the router's client list shows. They are not three settings that happen to match, there is a single source and the others read it, so a device shows one identity everywhere and the three can never drift apart.
- Sole owner:
SystemModule.deviceNameis a control onSystemModule(defaultMM-XXXXfrom the MAC). It is the only place the name is stored or edited. Every consumer readsSystemModule::deviceName(); no other module holds a name of its own.NetworkModulereads it for the mDNS / AP / DHCP names;main.cppreads it for theMM_DEVICE=<deviceName>.localboot-serial token the web installer uses to offer a clickable.locallink. So to know what name a device advertises, you read one accessor, you never inspect NetworkModule or the platform to discover it. - Always a valid hostname. Because all three uses are DNS/SSID names,
deviceNamemust satisfy the RFC-1123 label rules ([A-Za-z0-9-], no spaces, no leading/trailing hyphen).SystemModuleenforces this at the source: it runsmm::sanitizeHostname()(incore/Control.h) on the value insetup()and everytick1s(), coercing whatever the user typed or persistence restored ("My Living Room!"→"My-Living-Room") and falling back to the MAC-derivedMM-XXXXif the result is empty. Sanitising at the owner means every consumer is correct for free, no per-consumer validation, no chance a raw name reaches mDNS. (unit_sanitizeHostnamepins the rule.) - Follows a live rename. Renaming the device re-advertises immediately, no reboot, the live-reconfiguration rule applied to identity.
NetworkModule::syncMdns()(called eachtick1s()) compares the current name to the last-registered one and re-registers mDNS when it changed, so<new-name>.localresolves within a tick.
A machine-facing identity that an external system binds to is never the editable name. An MQTT topic prefix, a Home Assistant discovery unique_id, an API key path: anything a foreign system keys off must derive from an immutable hardware id such as the MAC or chip-id, as in MoonLight/<last6-of-MAC>. A live deviceName rename would otherwise repoint every topic silently and orphan the peer's config. The human-readable name rides a separate, published-but-non-identifying field (WLED, Tasmota, ESPHome, and HA discovery all anchor identity this way). deviceName above is the network-presentation identity (mDNS / AP / DHCP, where the name is the address); an external-integration identity is the opposite case and stays decoupled from it.
Web UI¶

The UI is a handful of hand-maintained files: index.html, app.js, style.css, plus two focused ES modules app.js imports (preview3d.js for the WebGL 3D preview, install-picker.js shared with the web installer). No frameworks, no build tools, no npm. Served directly by the embedded HTTP server.
The UI is MoonModule-driven. It contains no hard-coded knowledge of specific effects, layouts, or drivers. It queries the system for the current MoonModule tree (layers, effects, modifiers, layouts, drivers, each with their controls) and renders generically:
- Each MoonModule shows as a card with its name and declared controls.
- Controls are auto-rendered by type (slider, toggle, color picker, text input, dropdown).
- Modules can be switched (change which effect a layer uses) and linked (assign a layout to a layer).
Adding a new MoonModule with controls needs zero changes to the UI files. This extends to the tree-mutation affordances: which modules accept children (and of what role) comes from each type's acceptsChildRoles(), and whether a module can be deleted/replaced comes from its userEditable(): both declared on the C++ side and reported in /api/types + /api/state. The UI hardcodes no list of "which types are containers" or "which roles are editable"; a new container type or a fixed child is a one-line C++ override.
The light domain plugs into the UI at three points. A fixed top-level tree: Layouts, Effects and Drivers are pinned in main.cpp, so root reorder is disabled while child reorder works by drag-and-drop. A binary WebSocket preview channel, PreviewDriver, sending a 0x03 coordinate table once per LUT rebuild plus per-frame 0x02 RGB point lists, so sparse layouts preview at their real positions. Per-role emoji for the chip filter, where the ROLE_EMOJI map in app.js is the single source of truth. Full UI spec: docs/moonmodules/core/ui.md.
Platform abstraction¶
Only abstract what you need:
- Time:
millis(),micros(). Monotonic, microsecond resolution. (esp_timer/std::chrono) - Memory:
alloc(size),free(ptr). Prefers PSRAM on ESP32, falls back to regular heap;allocInternal(size)where only internal RAM will do (see Internal RAM).freeHeap(),maxAllocBlock()for diagnostics. (heap_caps_malloc/std::malloc) - Executable memory:
allocExec(size)/freeExec(ptr, size)allocate memory the CPU can fetch and execute from, andwriteExec(dst, src, len)copies emitted machine code into it safely. Used by the MoonLive live-script engine (below) to place the native code it compiles. All the W^X and instruction-cache quirks live behind these three functions. On ESP32 that is IRAM viaMALLOC_CAP_EXEC, with 32-bit-aligned stores and a cache sync so the core fetches fresh code. On POSIX desktops it is anmmapPROT_EXECpage, withMAP_JITand a write-protect toggle on macOS-arm64. On Windows it is aVirtualAllocPAGE_EXECUTE_READWRITEpage plusFlushInstructionCache. (heap_caps_malloc(MALLOC_CAP_EXEC)/mmap/VirtualAlloc) - Networking:
UdpSocketfor ArtNet send.TcpConnection/TcpServerfor HTTP + WebSocket;TcpConnection::writeSomeis a non-blocking partial write (returns bytes written, 0 = would-block) so a backpressured browser can't stall the render loop. (lwIP sockets / BSD sockets) - Scheduling:
yield()(cooperative yield to OS/RTOS),delayMs(ms)(blocking sleep, off-path only),delayUs(us)(a microsecond busy-wait, only for sub-millisecond hardware timing a driver owns, such as the WS2812 300 µs inter-frame latch inRmtLedDriver), andreboot(). General pacing uses the non-blockingmillis()gate instead. (vTaskDelay/esp_rom_delay_us/esp_restarton ESP32;std::this_thread::sleep_foron desktop, wherereboot()starts the program again: in place byexecv, by a new process on Windows) - Platform config:
platform_config.hper platform: compile-time constants likehasPsramandhasWiFi. Each platform provides its own version;types.hincludes it without#ifdef. Core code branches on these viaif constexpr(such as NetworkModule drops its WiFi cascade whenhasWiFiis false), so the dead branch is removed from the binary with no#ifdefoutsidesrc/platform/.
Abstractions are added when a concrete implementation needs them, not pre-designed.
Internal RAM¶
On an ESP32 internal RAM is the scarce memory. WiFi, lwIP, the task stacks and every DMA buffer need it, while a board with PSRAM has megabytes of the other kind. Whatever can live in PSRAM does. Internal RAM is kept for what cannot leave it:
- What a DMA engine reads, the LED drivers' buffers.
- What a cache-safe interrupt reads. PSRAM sits behind the flash cache, and a flash write on either core switches that cache off, even in the middle of an interrupt on the other core. So the interrupt's data comes from
platform::allocInternal, and an object it reads inheritsplatform::InternalRamObject, whosenewplaces it there.ParallelLedDriverandMoonI80Peripheralare the examples; the LCD-MM ring refuses to run when what its refill reads fell back to PSRAM, rather than fault at the next save. - The code and stacks of those paths.
Everything else asks for PSRAM first: a module through MoonModule's operator new, a buffer through platform::alloc, a plain malloc through CONFIG_SPIRAM_MALLOC_ALWAYSINTERNAL=0. WiFi, lwIP and TLS do so through their options in esp32/sdkconfig.defaults, where each option carries its reason.
The floor. platform::kInternalFreeFloor, 100 KB, is the internal RAM a board with PSRAM keeps free for WiFi, HTTP and the UI. Its largest free block stays at 50 KB, for a TLS handshake or a state send. What would take the rest respects it. LCD-MM's ringAuto sizes its ring by the lead the wire needs and stops at the floor. The HTTP server queues a send on a socket only while internal RAM stays above half of it, so a slow client waits rather than draining the board. scenario_RAM_floor holds the floor with the UI open on the S3 and the P4.
A board without PSRAM runs the same calls, which fall back to internal RAM. Its network buffers then share that RAM, so it holds no floor, and the send back-pressure is what keeps an open UI on a weak link from exhausting it. The measurements are in performance.md.
Platform boundary (hard rule). All #ifdef, #if defined, platform-specific #includes, and hardware API calls live exclusively in src/platform/. Everything outside src/platform/ compiles on every target without modification. Compile-time platform branching uses if constexpr on platform_config.h flags, never a preprocessor #ifdef. Domain boundary. A file in src/core includes nothing from src/light: core declares an interface, and the light domain implements and registers it.
The platform boundary is a rule of the code check, at zero, and a crossing raises the count the ratchet refuses. The domain boundary is the build's: core compiles through an include root that holds no light/, every core header on its own. A crossing fails the test build on macOS, Linux and CI. An include path never steps through .., a rule of the code check, so that root is the only way in. LightOutput is the domain boundary's shape: the light domain's Drivers implements it, and the WLED shim, MQTT and MoonStats read the device shape and the palettes through it.
The desktop build runs everything (hard rule). Every module, effect and driver in the repo
links and runs on the host, the platform layer has no silicon behind the call. Where a peripheral is absent the host emulates it rather than declaring itself incapable: the parallel WS2812 buses are backed by heap buffers, lcdLanes / parlioLanes / rmtTxChannels report a real chip's counts, and hasLcdCam is true. Code excluded from the host binary is code that cannot be unit-tested and cannot be seen by any AST-based check. It only ever runs where it is hardest to debug, which is what the LED drivers were until they were linked here.
A capability flag therefore answers "can this build exercise the path?", not "is this real hardware?". Where a flag must mean the latter (hasLcdCam gating the pin expander), that is a deliberate, commented exception. Timing, wire protocol and pin state are NOT emulated: they need silicon, and faking them would let a self-test report on hardware it never touched.