Skip to content

fix(milkdrop): equations run by MilkDrop 2's compiler rules - #619

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

CaYatur merged 6 commits into
mainfrom
milkdrop/eel-580

Conversation

@CaYatur

@CaYatur CaYatur commented Sep 28, 2026

Copy link
Copy Markdown
Owner

With fidelity on, preset equations now run by the rules of MilkDrop 2's expression compiler (Nullsoft's ns-eel2, as BeatDrop 53d83ee carries it). The D3D11 fork evaluates expressions with projectM's re-implementation by default, which differs in places (% keeps the sign, the memory index adds 0.0001), so the reference here is Nullsoft's compiler. With fidelity off nothing changes.

What changes

Commit Rule
truth tests and internal names if, !, &&, ||, while, bnot count |x| ≥ 0.00001 as true; band/bor need > 0.00001; ==, !=, equal compare with that tolerance; <, >, above, below stay exact; NaN is false and equal. The compiler's operator names compile (_aboeq, _if, _and, _or, _equal, _set, _addop…, _mem, _gmem); _and/_or short-circuit.
remainder % truncates both sides to unsigned integers (-7 % 3 = 1, 5 % 0.5 = 0); values outside the signed 32-bit range become 2^31.
bitwise & and | on 64-bit integers.
memory megabuf index is floor(x + 0.00001), 8,388,608 cells, 0 outside; gmegabuf has 2^20 cells and wraps.
rand rand(n) returns a real number in [0, n].

All blocks of a preset and its sprites read one mode object, so flipping fidelity switches them in the same frame; presets that use the internal names are rebuilt on a flip (readingsDiffer).

Measured

Against the previous step, last 10 of 60 frames, 120 presets per group:

  • Truth tests: 119/120 identical; one moves 4% (equal(sin(ang), 0), sin(π) = 1.2e-16). The three _aboeq presets change by up to 29% — their code did not run before.
  • %: 118/120 identical; one with negative samples (6%), one hashing past 2^31 (48%).
  • &/|: 120/120. Memory: 119/120 (one computed gmegabuf index, 3%).
  • rand (5,361 corpus presets): 92/120 identical, 14 change > 1%, max 18%, median of changed 1.07%. Thumbnails and exports of rand presets change as expected.
  • Control groups without the feature, and every group with fidelity off: identical.
  • Cost: 0.62–0.64 ms per 1,728-point per-pixel frame (0.71–0.75 ms before).
  • 900-preset sample: 899/900 keep their class. The one that moves, Goody's Molten Wavepool (blown → clean), uses none of these features and renders identically alone on both (clean, brightness 0.038); main's own sample run classed it blown, so that class depends on run order.

Tests

25 new in tests/milkdrop-md2-eel.test.js; 2227/2227 pass. 71/71 mutations caught across the five commits. The self-test's MilkDrop compile keeps its expected values (5,25,5,7,1,8,0).

npm run smoke fails the context-loss "new-canvas" phase on this machine today — and fails the same way (or worse) on main (47935d2) and on 7050cc9, where it passed before; frames are throttled (one run: 20 frames in 2 s). It is environmental, not this change. userData checksums unchanged across all runs.

Not done (documented in ROADMAP)

Variable scope per VM (next), division by zero (inf vs 0), the loop cap (six presets reach the engine's budget), newer syntax and unused functions (?:, &=, invsqrt, memcpy…, no corpus use), the old grammar's % mapping (not followed; the corpus writes % as a remainder).

Refs #580, #560

…them

With fidelity on, truth and equality tests use the closeness factor of
MilkDrop 2's expression compiler (NSEEL_CLOSEFACTOR, 0.00001, in
Nullsoft's ns-eel2):
- if, !, &&, ||, while and bnot treat |x| >= 0.00001 as true;
- the band and bor functions need |x| > 0.00001;
- ==, != and equal compare |a - b| with it;
- <, >, <=, >=, above and below stay exact;
- NaN counts as false, and as equal, like the x87 compare.

The compiler's operator functions compile too (_aboeq, _if, _and, _or,
_not, _equal, _noteq, _below, _above, _beleq, _mod, _set, _addop and the
other assignment forms, _mem, _gmem). _and and _or are && and ||, which
skip the right side; band and bor evaluate both. Three corpus presets
call _aboeq.

Compiled code reads a mode object that all blocks of a preset share, so
flipping fidelity switches the tests in the same frame. Internal names
are decided when the code is parsed, so readingsDiffer reports presets
that use them and the visualizer rebuilds those on a flip. Sprites
follow the same switch, and the self-test compiles in the default mode.
With fidelity off nothing changes.

Refs #580, #560
With fidelity on, `%`, `%=` and _mod work the way MilkDrop 2's compiler
does (nseel_asm_mod). Both sides become the integer part of their
absolute value, and the division is unsigned, so the result is never
negative: -7 % 3 is 1 in MilkDrop and was -1 here. MilkDrop runs its
code with the FPU set to truncate, and a value that does not fit in 32
bits becomes the x87 integer indefinite, -2^31, where JavaScript's |0
wrapped it modulo 2^32. A divisor of 0 still gives 0.

With fidelity off the old signed remainder stays.

Refs #580, #560
With fidelity on, `&` and `|` (and _andop, _orop) convert both sides to
64-bit integers the way MilkDrop 2's compiler does (nseel_asm_and/or,
fistp qword under truncation), where the engine used JavaScript's 32-bit
|0. Values that fit in 32 bits give the same result and keep the fast
path; beyond that the 64-bit value is used, and one that does not fit
in 64 bits becomes -2^63. No corpus preset reaches those values: the 319
that use the operators render the same.

Refs #580, #560
With fidelity on, memory access follows MilkDrop 2's compiler
(_asm_megabuf, nseel-ram.c):
- an index is trunc(x + 0.00001), so a computed 2.9999999 finds cell 3
  and -1 finds cell 0;
- megabuf holds 8,388,608 cells (128 blocks of 65,536; it was
  1,048,576), and cells outside read 0 and drop writes;
- gmegabuf is a single array of 2^20 cells that indices wrap around,
  negative ones included, since MilkDrop gives its compilers no
  separate global block.

Memory is now kept in 4096-cell blocks allocated on first write, so the
larger limit costs nothing until a preset uses it. With fidelity off the
old index and limit stay.

Refs #580, #560
With fidelity on, rand(n) returns a real number between 0 and
max(1, floor(n)), as MilkDrop 2's compiler does (nseel_int_rand:
genrand_int32() * (1.0 / 0xFFFFFFFF) * x). The integer branch belongs to
NSEEL_EEL1_COMPAT_MODE, which neither MilkDrop codebase turns on; the
int(rand(4)) that is common in the corpus is how presets ask for an
integer. The engine returned an integer below n, so rand(1) was always
0. The generator stays our seeded one, so exports and thumbnails remain
reproducible; only the range and type change.

5,361 corpus presets call rand. With fidelity off it still returns an
integer.

Refs #580, #560
ROADMAP (#580) describes the expression rules, the measurements against
the previous step and the 900-preset sample, and rewrites the list of
what is not done yet: variable scope, division by zero and the loop cap
are next; newer syntax and unused functions are left out with reasons.
The since-tag count follows the test total. Both READMEs gain a bullet.

Refs #580, #560
@CaYatur
CaYatur merged commit 8d7ecc3 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