Skip to content

A MoonLive script is a class, with functions it can call - #66

Merged
MoonModules merged 4 commits into
mainfrom
next-iteration
Aug 18, 2026
Merged

A MoonLive script is a class, with functions it can call#66
MoonModules merged 4 commits into
mainfrom
next-iteration

Conversation

@ewowi

@ewowi ewowi commented Aug 17, 2026

Copy link
Copy Markdown
Collaborator

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:

  1. One lowering for every backend, one vocabulary for every script, the subtraction that
    made the rest affordable.
  2. A MoonLive script is a class, the grammar's only top-level form, with named entry points
    per role.
  3. A script calls its own functions, and itself: real calls with a frame per activation, and
    a runtime depth guard.

One lowering, every backend

src/core/moonlive/moonlive_lower.h holds the IR walk as a template over the assembler. 537
lines of triplicated algorithm became 190 shared plus 62 of adapter, and the three
moonlive_lower_*.cpp files 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 Mov that added zero, a two-instruction
branch, a FillElems using a third scratch register) were free choices rather than ISA facts, so
it gained movReg/branchGeU/branchNe and 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 call
site changed.

The split it replaces prevented no mistake, since a layout reading width got 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.py compiled against
the widest and refused grid.mlv, the shipped default layout, as "name is a system variable". A
modifier's coordinate is xPos/yPos/zPos now, which frees x/y/z as the loop counters
an author reaches for.

A script is a class

class CrosshairEffect {
  uint8_t bpm = 30;   // @control 1..240

  column() { for (y = 0; y < height; y = y + 1) { setRGB(y * width + scale(beat(bpm, t), width), 255, 40, 0); } }
  tick()   { fill(0, 0, 0); column(); }
}

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: tick for
an effect, placeLights for a layout, modifyLogical for a modifier. Role is not inferred from
the 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.

forEachCoord is renamed placeLights, in scripted and compiled code, because a scripted
effect 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 boundaries
    went into the discarded half and the lowering opened a frame two ops early, mid-statement.
  • CallScript carried an IR index, which the spill pass invalidates: it inserts a Reload
    before a read, so every index past the first insertion shifts. It carries a function number now,
    which survives any rewrite.
  • Xtensa function entries were not 4-byte aligned. The toolchain rejects an unaligned entry
    outright (Error: unaligned entry instruction) and CALLn encodes its target in 4-byte units, so
    an 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 4 does in
    hand-written assembly.
  • A local call passed no arguments. 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.

Performance

Flash classic ESP32 1723295 to 1726027 bytes, +2732 (+0.16%)
Calling script crosshair.mlv 204 to 219 us on the classic (~5-10%)
Non-calling script unchanged, no guard code emitted
Compile-path stack lowerWith 480 to 1120 bytes (17% of the 12 KB main task)
Desktop tick 150 to 133 us across the cycle

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/kAsmFixups going 16/32 to 48/96, because a class allocates a
label per function and crosshair.mlv exhausted 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 bl leaves
them 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:

  • The blocker: two test names were wrapped in mmScript(...) by a mechanical sweep, so doctest
    reported them as multi-line class T { tick() { ... } } blocks. Worse, mmScript returns a
    pointer into a rotating buffer, so a failing test could report another test's script text.
  • The scratch-register comment described a conditional reservation the code does not do.
    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.
  • kEntryForEachCoord still carried the deleted name while holding "placeLights".
  • Test comments still taught the abolished per-role vocabulary.
  • A robustness gap: function names over 23 characters truncated silently, so two sharing a
    prefix collided into one entry and entry() returned the first: wrong-function dispatch with no
    error. Refused at parse time now, where a control name already is.
  • Entry-lookup double-scanning, plan text naming the old function, a stray blank line, and two
    backlog items filed under ## HTTP and OTA that belong under ## Architecture.

One finding deferred with a reason: the bindings scan the entry table twice per call
(hasEntry then run). Today that is the cold rebuild path and costs little, but it becomes a
real hot-path cost when modifyLogicalTick arrives. Caching the CtrlFn at compile success is the
fix, and it belongs with that step rather than here.

Also

  • A prose check, moondeck/check/check_prose.py: em-dashes and British spellings in ADDED
    lines. 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.
  • MIGRATING is exempt for MoonLive until it launches. Nobody runs scripts on a device yet, so
    an entry documents an upgrade path no user can take. The two pre-launch entries are removed and
    the policy is written down.
  • 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.
  • Catalog pages corrected: MoonLiveLayout.md and MoonLiveModifier.md still showed tick()
    as the entry point after the rename, so they taught scripts that do not work.
  • lessons.md gains the branch's methodological traps, the sharpest being 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.

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.mlv verified running on S3, S31, classic ESP32 and P4.

🤖 Generated with Claude Code

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>
@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 8f0f9c38-ed79-41cf-82d8-ad5410c17c8e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@MoonModules

Copy link
Copy Markdown
Owner

@coderabbitai, can you review 22ae7f5 and report your findings in copy pastable content for agents

ewowi and others added 2 commits August 17, 2026 20:10
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>
@ewowi ewowi changed the title One lowering for every backend, one vocabulary for every script A MoonLive script is a class, with functions it can call Aug 18, 2026
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>
@MoonModules
MoonModules merged commit 8be2dfb into main Aug 18, 2026
6 of 7 checks passed
@MoonModules
MoonModules deleted the next-iteration branch August 18, 2026 08:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants