A MoonLive script is a class, with functions it can call - #66
Conversation
The IR walk existed three times, once per target, and the two device copies differed by two identifier tokens. It is now written once and each backend is a short adapter naming its assembler. Separately, the three roles were handed three different system-variable tables, so a name meant one thing in a layout and was reserved in an effect; there is one table now, and x/y/z are ordinary loop counters everywhere. Performance: no hot-path change. All four boards emit byte-identical exec blocks to the previous three-file version, which is the evidence the collapse preserves behavior. Core - moonlive_lower.h holds the one IR walk, a template over the assembler. What is per-target is encodings, and those already lived behind the assembler: no frame constant, register name or instruction appears in any backend file. 537 lines of triplicated algorithm became 190 shared plus 62 of adapter. - The template deduces the register and label types from the assembler rather than naming a global Reg, so it compiles as core code without a backend in scope. RegId/LabelId, not Reg/Label, because the backend's own names are in scope wherever it is instantiated and reusing them shadows those declarations. Light domain - One lightSysVars() table for all three roles; layoutSysVars/effectSysVars/ modifierSysVars remain as aliases so no call site changed. The split prevented no mistake (a layout reading width got a compile error, which is the same outcome as reading a value that is always zero) and created a trap: the tables were different vocabularies rather than nested ones, so disasm.py compiled against the widest and refused grid.mlv, the shipped default layout. - A modifier's coordinate is xPos/yPos/zPos. x/y/z are what an author reaches for as loop counters, and grid.mlv uses both, so reserving them globally would break the most ordinary code there is. - Host gained movReg/branchGeU/branchNe and adopted the devices' FillElems; its cmp/branchIf are private now, since a flags pair cannot be shared with a backend that has none. Those differences turned out to be free choices, not ISA facts. Tests - The test that specified the per-role split now specifies the single vocabulary, including that reading a coordinate outside a modifier is legal and reads 0. - A new test pins that the three role accessors return the same table, so a future re-split has to say so rather than silently reintroducing the trap. Docs/CI - Plan-20260813 is closed and marked (shipped): every step shipped, dropped or superseded, with reasons. Step 5 (delete the allocator) is dropped because its precondition never came true, and step 7 (factor the bindings onto one base) is superseded: the three bindings derive from sibling bases, so a shared base needs virtual inheritance and would change the layout of every module in the system. - Plan-20260817 takes over as the road to launch: scripts become classes with members, functions and named entry points, then if/else, reading a light back, arrays, wider values and the editing loop. Recursion is a requirement rather than a follow-up, per the predecessor's own table. - MIGRATING is exempt for MoonLive until it launches: nobody runs scripts on a device yet, so an entry would describe an upgrade path no user can take. The two pre-launch entries are removed and the policy is stated. - CLAUDE.md: running a gate script to check work in progress is still starting a gate list, which is the product owner's to fire. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai, can you review 22ae7f5 and report your findings in copy pastable content for agents |
A script declares a class and the host calls its functions, the way a compiled
module does: an effect writes tick(), a layout placeLights(), a modifier
modifyLogical(). The old form was a bare list of statements with controls declared
by a COMMENT, which is not C and did not resemble the thing it stands in for.
Performance: no hot-path change. Measured on the S3 at 8x8, before and after:
plasma 274 -> 231us, ripples 505 -> 466us, lines 125 -> 96us, and every exec block
is byte-identical, so the syntax is front-end only.
Core
- The grammar's top level is `class Name { ... }`, the only form. Optional was
considered and rejected: it would keep a second parse path, a second set of rules
to document and a second thing to test, permanently, so a handful of two-line
scripts could stay two lines shorter.
- Each function gets its OWN frame. The lowering emitted one prologue per program,
so an entry point's address pointed past the frame setup and calling it ran a
routine whose frame was never established. The host arguments moved with it: they
were parked at IR index 0, before any prologue, writing into a frame that did not
exist yet.
- A symbol table over the emitted block: the parser records the IR index a function
starts at, the shared lowering converts it to a byte offset while emitting, and
MoonLive::entry(name) turns a name into a callable address. One allocation holds
every function, so a script may define as many as it likes.
- The class name is carried for diagnostics, independent of the file name, the way
a C translation unit and the functions inside it are.
Light domain
- A NAME IS A MOMENT, NOT A ROLE. The host owns moments and runs whatever the
script defined for each: tick when a frame renders, placeLights when lights are
placed, modifyLogical when a coordinate is folded. Nothing validates which names
a class defines, so one class can serve several moments and an effect that also
folds coordinates needs no feature added for it. A per-role name with a tick()
fallback came first and was dropped: nothing needed the fallback, and it left two
ways to write a modifier.
- forEachCoord is renamed placeLights, in the compiled layouts as well as the
scripted ones. It never called back per coordinate: it runs once and emits all of
them, which is a producer, not an iteration. Checked against MoonLight before
renaming, where the equivalent is onLayout and the genuine per-item callbacks
(forEachLightIndex, our forEachDestination) are correctly named and untouched.
Tests
- The symbol table is pinned by a class with TWO functions, where offset 0 would be
wrong and calling the wrong one is silent because both compile. Verified to fail
when the map is stubbed back to zero.
- The structural checker re-reads the frame at every prologue instead of judging a
block by its first: with several routines per block, every function after the
first went unchecked, on the target where the frame contract is fatal. Verified
by shrinking the Xtensa reserve to 16, which makes it fire.
- Entry-point dispatch is pinned by two functions writing different pixels, and by
a class defining both tick and modifyLogical, each called at its own moment.
- mmScript() wraps a bare body in the class ceremony so a test still reads as the
one behavior it is about. It hands out a ring of buffers rather than one: a table
of scripts held every row aliased to the last, which would have tested the same
script N times instead of failing. Oversized input is refused rather than
truncated, after a 54 KB runaway-script case was silently clipped to 4 KB and
stopped being a runaway.
Docs
- The language reference, the controls example, the system-variable table and the
layout and modifier examples describe the class form.
- Plan-20260817 records the corrected step order: per-function frames come BEFORE
wiring the bindings, which the code taught us by segfaulting when they did not.
Step 5 gains its concrete answer, a held MoonLiveScript member rather than a
shared base, with the sibling-base blocker measured rather than assumed.
- Backlog: duplicate module names are reachable and silent (found on the bench, a
layout and an effect both named MoonLive, so the UI rendered one card's controls
under the other's heading), and deleting a module by name removes the first match.
Verified on all four boards (S3, classic ESP32, P4, S31): every binding compiles
and runs, exec blocks unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A script's class can now define helper functions and call them, including calling itself. These are real calls: the callee gets its own frame when it runs, so one helper can call another and a function can recurse. Recursion is bounded at run time, so a runaway script leaves the picture wrong where it stopped instead of resetting the device. Perf: classic ESP32 1726144 bytes flash (+2732, +0.16% over 2f4c292), tick 2151us / 464fps unchanged; desktop tick 133us. crosshair.mlv (three functions, two calls per frame) costs 204->219us on the classic; a script with no local call emits no guard code and is byte-identical. Core - IrOp::CallScript, carrying the callee's FUNCTION NUMBER. An IR index was the first choice and is wrong: the spill pass shifts every index past its first inserted Reload, so the call named an op that no longer started a function. - IrProgram::swap() now swaps the function table. It did not, so the spill pass's remapped boundaries were discarded and the lowering opened a frame two ops early, mid-statement. - The recursion depth guard, in the CALLEE's prologue: one copy per function rather than one per call site, and none at all unless the script calls. A refusing callee returns, so there is no branch-around at the call site and no counter to restore across a call. The counter is one arena byte (kDepthSlot), zeroed per run so a refused frame cannot shrink the next frame's budget. - The guard is emitted AFTER the host arguments are parked. Both it and the epilogue reach the arena through the parked frame copy, and a refusing activation jumps straight to the epilogue. - An EMPTY function still balances the counter. Its whole body is the argument parking, so it never reached the flush point while its epilogue still decremented: two calls wrapped the byte to 255 and the next legal call was refused as too deep. Found by the Reviewer. - kAsmLabels/kAsmFixups (48/96) moved into core from three identical private copies. 16/32 was sized when a script was one routine; a class allocates a label per function, so crosshair.mlv failed with the generic "too large". Costs 640 bytes of compile-path stack (lowerWith 480 -> 1120 bytes). Light domain - moonlive/effects/crosshair.mlv: the worked example, two helpers and a tick. Platform - callLabel(Label) on all three assemblers, reusing the branch fixup machinery with a per-ISA discriminator, because a call's displacement is encoded differently from a branch's. - alignForEntry(): Xtensa requires a 4-byte-aligned `entry`. The toolchain rejects anything else outright ("unaligned entry instruction") and CALLn encodes its target in 4-byte units, so an unaligned callee is not expressible. Instructions are 2 or 3 bytes, so a function following another lands anywhere. This is what `.align 4` does in hand-written assembly. - A local call PASSES THE HOST ARGUMENTS ON. Each prologue parks buf/nLights/cpl/t/ctrls out of the argument registers into its own frame, so a bare call left the callee parking garbage and its first control read faulted at EXCVADDR 0x9. On Xtensa they go in a10..a14, since call8 rotates the window by 8. The contract now lives once in core. - The host backend's fixup kinds became a FixKind enum, matching its siblings; the dead condition-code packing went with it. Tests - Argument passing one and two calls deep, an empty function not consuming the budget, and unbounded recursion that must return AND be runnable again at full depth. Each control-checked by reverting its fix. - Per-ISA: every function in a class starts where a call can reach it. - The structural checker gained a calling class and a recursive one, so every prologue it walks carries the argument reload and the guard. Control-checked by dropping the window reserve from the frame calculation. - An assembler stack-budget tripwire, in table entries rather than host bytes. Docs/CI - moonlive/README.md: local calls, the recursion bound, and the declare-helpers-above-callers rule. - Plan-20260817 step 1 and verification items 1, 2, 5 marked done, with the three hardware-only traps recorded. Item 5 is done WITH A CAVEAT: a refused call is silent, where the plan asked for a reported error. That needs a diagnostic channel from the emitted block to the binding, which does not exist yet. Reviews - 18 findings (Fable). Fixed: the empty-function counter bug; a test named "calls itself" that contained no self-call (removed, the unbounded test pins it honestly); a comment describing the abandoned crashing design; CallScript.imm documented as an IR index in two places; the missing callLabel/alignForEntry in the assembler contract list; a redundant arena reload per prologue; the contract duplicated across three backends; dead fnSeen; host magic fixup numbers; a misplaced comment; two false comments; a duplicate test; the tripwire's loose arithmetic; the depth limit's off-by-one between code, pseudo-code and README; US spelling and em-dashes. Verified on all four boards (S3, S31, classic, P4). The classic separately ran a deliberately unbounded recursion for 110 seconds at 109fps without resetting. KPI baselines: 28 scenario values the gate rewrote under build load (up to +1048%) were reverted; the 7 kept are +1.0% to +6.7% and desktop-only. A quiet re-run reproduced the kept values and produced no over-10% deltas. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Reviewer's pass over the whole branch, plus the docs the merge gates ask for. No behavior changes except one robustness fix: a function name too long to store is now refused instead of silently truncated. Perf: unchanged. The only emitted-code edit removes one dead ternary; exec blocks are byte-identical. S3 flash 1764871 bytes. Core - A function name longer than kMaxEntryName is REFUSED at parse time. The engine copies entry names into a fixed buffer, so a longer one was truncated there, and two functions sharing a 23-character prefix landed under the same name with entry() returning the first: a call dispatched to the wrong function, silently. - The lowering's scratch reservation is stated as what it is. The comment claimed a count conditional on which inline ops a program contains; the clamp made it always three. That clamp is now load-bearing rather than vestigial, because the depth guard uses both shared registers in a script that may hold no inline op at all, so a hasInline-derived count would hand it a register the program does not own. Dead ternary removed, comment corrected. - kEntryForEachCoord renamed kEntryPlaceLights. It already held "placeLights"; only the constant kept the deleted name. Tests - Two test names were wrapped in mmScript() by a mechanical sweep, so doctest reported them as multi-line class bodies. mmScript returns a pointer into a rotating buffer, so a failing test could report ANOTHER test's script text. - An over-long function name is refused, and one character shorter still compiles, so the limit is the limit and not an off-by-one. - The device-codegen comments no longer teach the per-role vocabulary this branch abolished; the three accessors are aliases of one table. Scripts/MoonDeck - check_prose.py: em-dashes and British spellings in ADDED lines only, so prose a rename merely touched stays as it was. Run by hand, deliberately not in the gate table: the tree still holds instances that predate it, and that sweep is not this change. The banned character is defined by codepoint, because a repo-wide sweep already rewrote the literal inside the detector and made it flag every comma. Docs/CI - MoonLiveLayout.md and MoonLiveModifier.md still showed tick() as the entry point after the rename, so they taught scripts that do not work. Both now name their moment, and MoonLiveEffect.md documents local calls, the declare-above-caller rule and the recursion bound. - performance.md: the cost of a calling script, the flash delta measured at the branch point, and the compile-path stack growth. - lessons.md: the branch's methodological traps. The sharpest is that a TIDIER emitted block can be the broken one: removing the spill pass's boundary remap makes crosshair.mlv disassemble better and keeps every host test green, and boot-loops an S3. - Two backlog items about module identity moved from HTTP and OTA to Architecture, which emptied the HTTP heading, so it is gone. - Plan-20260817's shipped-state text names placeLights rather than forEachCoord. Reviews - 👾 Reviewer over the 102-file branch diff, which is past the size where CodeRabbit declines, so it was the only review layer. 9 findings, all applied above except one deferred with a reason: the bindings scan the entry table twice per call (hasEntry then run). That is the cold rebuild path today and costs little, but it becomes a real hot-path cost when modifyLogicalTick arrives, so caching the CtrlFn at compile success belongs with that step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A MoonLive script was a bag of statements that did not resemble the compiled module it stands
in for. It is a class now: it declares a name, defines the functions the host calls, and can
define functions of its own and call them, including calling itself.
Three commits, in the order they had to happen:
made the rest affordable.
per role.
a runtime depth guard.
One lowering, every backend
src/core/moonlive/moonlive_lower.hholds the IR walk as a template over the assembler. 537lines of triplicated algorithm became 190 shared plus 62 of adapter, and the three
moonlive_lower_*.cppfiles lost 495 lines between them.What is genuinely per-target turned out to be only the encodings, and those already lived behind
the assembler: no frame constant, register name or instruction appears in any of the three
backend files. The host's apparent differences (a
Movthat added zero, a two-instructionbranch, a
FillElemsusing a third scratch register) were free choices rather than ISA facts, soit gained
movReg/branchGeU/branchNeand adopted the devices'FillElems.Evidence it preserved behavior: all four boards emitted byte-identical exec blocks to the
three-file version. Rose 1880 B, ripples 2372 B, plasma 1124 B, unchanged on S3, classic ESP32,
P4 and S31.
One vocabulary, every script
lightSysVars()serves all three roles; the role-named accessors remain as aliases, so no callsite changed.
The split it replaces prevented no mistake, since a layout reading
widthgot a compile error,which is the same outcome as reading a value that is always zero. What it did create was a trap:
the tables were different vocabularies rather than nested ones, so
disasm.pycompiled againstthe widest and refused
grid.mlv, the shipped default layout, as "name is a system variable". Amodifier's coordinate is
xPos/yPos/zPosnow, which freesx/y/zas the loop countersan author reaches for.
A script is a class
Mandatory, not optional. Keeping a bare statement list would mean a second parse path, a
second set of rules to document and a second thing to test, permanently, so that a handful of
two-line scripts can stay two lines. Nothing is released yet, so the cost is rewriting the
shipped scripts once. All 17 are converted.
The moment model. A function is called when it is present and its moment arrives:
tickforan effect,
placeLightsfor a layout,modifyLogicalfor a modifier. Role is not inferred fromthe class name, and nothing forbids a class defining several: the script author is in control and
responsible. This is what makes step 5's "which kind is this binding" question a dispatch
question rather than an inheritance one.
forEachCoordis renamedplaceLights, in scripted and compiled code, because a scriptedeffect and a compiled one should look as alike as possible. The old name described the mechanism
(a loop over coordinates) rather than the moment (place your lights).
A script calls its own functions, and itself
Real calls, not text pasted in by the compiler: the callee allocates its own frame when it runs,
which is what lets one helper call another and what makes recursion work. A function takes no
arguments and returns nothing yet, so a helper does a whole job rather than computing a value.
Helpers must be declared above their callers; a function can always call itself.
Recursion is bounded at run time. A render task has a fixed stack, so unbounded recursion
would reset the device, which the robustness rule forbids. The guard lives in the callee's
prologue and refuses by returning, so there is no branch-around at the call site and no counter
to restore across a call. It is one arena byte, and it is emitted only for a script that actually
contains a call, so every shipped script is byte-identical to before.
The classic ESP32 ran a deliberately non-terminating script for 110 seconds at 109 fps without
resetting. The deepest calls do nothing; the picture is wrong where the recursion stopped.
Four defects behind this, every one invisible to a green test suite:
IrProgram::swap()did not swap the function table, so the spill pass's remapped boundarieswent into the discarded half and the lowering opened a frame two ops early, mid-statement.
CallScriptcarried an IR index, which the spill pass invalidates: it inserts a Reloadbefore a read, so every index past the first insertion shifts. It carries a function number now,
which survives any rewrite.
entryoutright (
Error: unaligned entry instruction) and CALLn encodes its target in 4-byte units, soan unaligned callee is not expressible at all. Instructions are 2 or 3 bytes, so a function
following another lands anywhere.
alignForEntry()pads, which is what.align 4does inhand-written assembly.
argument registers into its own frame, so a bare call left the callee parking garbage and its
first control read faulted at
EXCVADDR 0x9. On Xtensa they go in a10..a14, sincecall8rotates the window by 8.
Performance
crosshair.mlv204 to 219 us on the classic (~5-10%)lowerWith480 to 1120 bytes (17% of the 12 KB main task)The flash figure is measured by building at the branch point and again with the work, not read
off a metrics diff. It is ~20 bytes per net line of source, which is high because nearly all of
it is emitter code instantiated once per backend: one line of the shared lowering becomes three
copies of emitted-instruction sequences in the image.
The stack growth is
kAsmLabels/kAsmFixupsgoing 16/32 to 48/96, because a class allocates alabel per function and
crosshair.mlvexhausted the old table. Both constants moved into core,so the three backends can no longer disagree about which scripts compile.
Tests
Argument passing one and two calls deep, an empty function not consuming the recursion budget,
unbounded recursion that must return and be runnable again at full depth, an over-long function
name refused rather than truncated, and per-ISA "every function in a class starts where a call can
reach it". Each was control-checked by reverting its fix and confirming it fails.
The structural checker gained a calling class and a recursive one, so every prologue it walks now
carries the argument reload and the depth guard. Control-checked by dropping the window reserve
from the frame calculation.
One honest limitation, written into the tests: the host backend cannot pin the
argument-passing contract. Its R0..R4 map onto the arm64 ABI argument registers and
blleavesthem alone, so removing the fix fails nothing there while crashing an S3. The boards are the only
check for that class of defect.
Review
This branch is 104 files, past the size where CodeRabbit declines to review, so the Reviewer
agent over the whole branch diff is the only review layer it gets. It returned 9 findings; all are
applied:
mmScript(...)by a mechanical sweep, so doctestreported them as multi-line
class T { tick() { ... } }blocks. Worse,mmScriptreturns apointer into a rotating buffer, so a failing test could report another test's script text.
Investigating it showed the clamp is now load-bearing: the depth guard uses both shared registers
in a script that may contain no inline op. The code was right, the comment was wrong, and the
dead ternary is gone.
kEntryForEachCoordstill carried the deleted name while holding"placeLights".prefix collided into one entry and
entry()returned the first: wrong-function dispatch with noerror. Refused at parse time now, where a control name already is.
backlog items filed under
## HTTP and OTAthat belong under## Architecture.One finding deferred with a reason: the bindings scan the entry table twice per call
(
hasEntrythenrun). Today that is the cold rebuild path and costs little, but it becomes areal hot-path cost when
modifyLogicalTickarrives. Caching theCtrlFnat compile success is thefix, and it belongs with that step rather than here.
Also
moondeck/check/check_prose.py: em-dashes and British spellings in ADDEDlines. Both rules were written down and then broken repeatedly, including in the commits that
swept them out of other files, so they needed a check that fails rather than an intention to do
better. Not yet registered in the gate table: it reports pre-existing instances that are a
separate sweep from this branch.
an entry documents an upgrade path no user can take. The two pre-launch entries are removed and
the policy is written down.
which is the product owner's to fire.
MoonLiveLayout.mdandMoonLiveModifier.mdstill showedtick()as the entry point after the rename, so they taught scripts that do not work.
lessons.mdgains the branch's methodological traps, the sharpest being that a tidieremitted block can be the broken one: removing the spill pass's boundary remap makes
crosshair.mlvdisassemble better and keeps every host test green, and boot-loops an S3.Plans
Plan-20260813 is closed and marked
(shipped). Step 5 was dropped ("delete the allocator"assumed the stack machine makes spilling unreachable; it does not) and step 7 superseded (a shared
binding base needs virtual inheritance and would change the layout of every module in the system to
serve three of them).
Plan-20260817 takes over and stays open: step 1 and verification items 1, 2 and 5 are done;
steps 2, 3 and 5-10 remain (typed members,
defineControls(),if/else, reading a light back,arrays, wider-than-a-byte values, the editing loop).
One item is done with a caveat. Verification item 5 asked that a runaway recursion report an
error. It does not: the refusal is silent. Reporting it needs a diagnostic channel from the emitted
block back to the binding, which does not exist yet.
Verification
All nine mechanical pre-merge gates green. All three ISAs build clean; all four firmware variants
rebuilt.
crosshair.mlvverified running on S3, S31, classic ESP32 and P4.🤖 Generated with Claude Code