Skip to content

fix(milkdrop): each block keeps MilkDrop 2's own variables - #620

Merged
CaYatur merged 6 commits into
mainfrom
milkdrop/scope-580
Sep 28, 2026
Merged

CaYatur merged 6 commits into
mainfrom
milkdrop/scope-580

Conversation

@CaYatur

@CaYatur CaYatur commented Sep 28, 2026

Copy link
Copy Markdown
Owner

With fidelity on, every expression block now has MilkDrop 2's own variables. MilkDrop runs each block in a virtual machine of its own (state.cpp:213-225); the engine used one pool for init, per-frame and per-pixel, and one per wave for per-frame and per-point. With fidelity off nothing changes — every measured group is byte-identical.

Source checked in Nullsoft's ns-eel2 (BeatDrop 53d83ee): variables live in each VM's own table (ctx->varTable); only reg00..reg99 (nseel_globalregs[100]) and gmegabuf are shared.

Commits

Commit Rule
global registers reg00..reg99 are one array for every block, wave, shape and both presets of a transition; Preset.get/set follow; resetGlobals() clears registers and gmegabuf
per-pixel VM gets time, fps, frame, progress, audio, mesh/pixel sizes and aspect before per-frame runs, q1..q32 after, zoom..sy per vertex; own variables and megabuf; nothing leaks back to per-frame or drawing
wave per-point VM gets time/audio before the wave's per-frame, q1..q32 and t1..t8 after; own variables and megabuf
sub-block inputs waves and shapes read time and audio from before per-frame, not values per-frame rewrote
loop cap loop/while stop at 1,048,576 turns a call; budgets widened where MilkDrop allows (init 4,194,304, per-frame 1,048,576, one shape ≤ 4,194,304 a frame over its instances); per-pixel/per-point keep 1,024 a vertex

Per-pixel and per-point code is compiled twice (one per pool) so fidelity can still flip at run time. Thumbnails now call resetGlobals() before each render, so a thumbnail depends only on its key; video export already opens a fresh page per job.

Measured

Against the previous commit, last 10 of 60 frames, each preset in a fresh page (no carry-over of registers, gmegabuf or feedback):

  • Registers: 120 of 192 register presets — 40 change (38 > 1%). Typical: init writes reg01..03 = rand(..), shapes read them; before, shapes saw 0 and stacked all instances on one point (Martin – Pixies Party, city lights).
  • Per-pixel: 120 of 696 presets whose per-pixel reads/writes something per-frame uses — 46 change, median 94%. The 70 that read a per-frame variable they never set: 31 change. One control preset changed because per-frame keeps its own x/y, which the last vertex used to overwrite.
  • Per-point: 120 wave presets — 6 change.
  • Sub-block inputs: exactly the 3 presets the scan predicts (5–28%).
  • Loop cap: 4 of the 6 presets that ran out of budget change.
  • Every group identical with fidelity off; control groups identical with it on.
  • Cost: +~0.03 ms to build a preset; a 1,728-vertex frame costs the same.
  • 900-preset sample (sequential, one page): 899/900 keep their class. The one that moves, LuxXx – Benefiscient Prescience (clean → blown), keeps its own x/y in per-frame and hands them to per-pixel as the zoom centre; the last vertex used to overwrite them every frame. Alone in a fresh page it differs by 61% and is not blown (brightness 0.62), so its class there also depends on run order.

Thumbnails and exports of register, per-pixel and per-point presets change as expected; existing thumbnails regenerate because the engine files change.

Tests

24 new in tests/milkdrop-md2-scope.test.js; thumbnail page test checks the reset; two wave tests pass a per-frame counter through t1/t2 as a MilkDrop preset must; compile guard counts 13 calls. 2251/2251 pass. 51 of 52 mutations caught (the one left caps loops with fidelity off too — equivalent, since that budget never exceeds the cap). A while off-by-one under the new cap was found by a test and fixed.

npm run smoke: only a context-loss phase fails, the same environmental failure main (47935d2) and 7050cc9 show on this machine today (throttled frames); the MilkDrop compile line is unchanged (5,25,5,7,1,8,0) and userData checksums are unchanged.

Not done (documented in ROADMAP)

  • Division by zero and non-finite results: MilkDrop gives ±inf/NaN and keeps it; the engine gives 0. With a realistic clock 910 presets (8.8%) divide by zero at least once in 40 frames. Matching needs NaN carried through the drawing path as Direct3D 9 does — only checkable against the reference renderer (needs approval). Same for guarded functions (log(≤0), pow/exp/tan overflow → 0; asin/acos clamp).
  • 16-character variable names (NSEEL_MAX_VARIABLE_NAMELEN): 24 corpus presets use names that long, none collide.

Refs #580, #560

ns-eel2 keeps reg00..reg99 in one process-wide array (nseel_globalregs):
per-frame, per-pixel, every wave and shape and both presets of a
transition see the same hundred values, and nothing resets them. With
fidelity on the engine now does the same; with it off each pool keeps
its own registers. The switch is read at run time, so flipping fidelity
moves the storage in the same frame, and Preset.get/set follow it.

The common idiom is init writing random values to reg01..reg03 and
shapes reading them; the shapes saw 0 and piled their instances on one
point. 192 corpus presets use registers.

resetGlobals() clears gmegabuf and the registers. MilkDrop never does;
thumbnails call it before each render, so a thumbnail depends on its key
and not on the preset drawn before it, and tests use it for isolation.

Refs #580, #560
…op 2

MilkDrop runs per-pixel code in a VM of its own (state.cpp m_pv_eel).
It does not see per-frame's variables; it gets time, fps, frame,
progress, the six audio values, the mesh and pixel sizes and the aspect
once a frame before per-frame runs, q1..q32 after it, and zoom..sy per
vertex from what per-frame left. Its own variables and megabuf persist
across vertices and frames, and what it writes never reaches per-frame
or the drawing. With fidelity on the engine now does the same; with it
off per-pixel shares the main pool as before.

Per-pixel code is compiled twice, once per pool, because closures bind
the pool at compile time and fidelity can flip at run time. The old
zoom_base escape is legacy only (no corpus use). Preset construction
costs about 0.03 ms more; a 1,728-vertex frame costs the same.

A static scan finds 696 presets whose per-pixel code reads or writes
something per-frame also uses: per-pixel reading a per-frame variable
(453), writing one per-frame reads (285), writing per-frame-only names
such as decay or wave_r (91), writing q (146), megabuf in both (10).
Per-frame code that keeps its own x or y was also overwritten by the
last vertex.

Refs #580, #560
…ilkDrop 2

Each custom wave has two VMs in MilkDrop (state.cpp m_pf_eel and
m_pp_eel). Per-point code does not see the wave's per-frame variables;
it gets time, fps, frame, progress and the six audio values before the
wave's per-frame code runs, q1..q32 and t1..t8 after it, and sample,
value1, value2, x, y and the colour per point. Its own variables and
megabuf persist across points and frames; t1..t8 it writes last only
until the next frame. With fidelity on the engine now does the same;
with it off per-point shares the wave's pool as before.

Two wave tests read a per-frame counter from per-point; they now pass
it through t1 and t2, the way a MilkDrop preset has to.

Refs #580, #560
…changes them

MilkDrop loads time, fps, frame, progress and the six audio values into
each wave and shape from its own sources (LoadCustomWavePerFrameEvallibVars,
LoadCustomShapePerFrameEvallibVars), not from the main per-frame VM. The
engine copied them from the main pool after per-frame ran, so a preset
that rewrites bass or time in per-frame changed what its waves and
shapes saw. With fidelity on they now read the values from before
per-frame, the snapshot per-pixel already takes; three corpus presets
are affected. With fidelity off nothing changes.

Refs #580, #560
ns-eel2 caps each loop and while call at 1,048,576 turns
(NSEEL_LOOPFUNC_SUPPORT_MAXLEN) and has no budget per block. The engine
has a budget per run so that a preset cannot freeze the app; with
fidelity on each call is now capped like MilkDrop's and the budget is
wider where MilkDrop allows it: 4,194,304 for init code and 1,048,576
for per-frame code of the preset, its waves and its shapes. Per-pixel
and per-point keep 1,024 a vertex, where MilkDrop's cap would freeze
the app. A shape runs its per-frame code once per instance, so one
shape may spend at most 4,194,304 turns a frame over all instances.

Five corpus presets need about 1.07 million turns in init and ran out
at 1,048,576; one shape needs 103,424 in a frame and ran out at 65,536.
While also stopped one turn early under the new cap, found by a test.
With fidelity off nothing changes.

Refs #580, #560
ROADMAP (#580) describes the variable scope with its sources, the
measurements per step and the 900-preset sample, and moves scope and
the loop cap out of the not-done list. Division by zero and the guarded
functions stay there with the corpus count and the reason; 16-character
variable names are left out with the reason. Both READMEs gain a
bullet.

Refs #580, #560
@CaYatur
CaYatur merged commit 732ef32 into main Sep 28, 2026
5 checks passed
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.

1 participant