Repository navigation
Luau: run effect and monster scripts as Luau modules (M3) - #132
Merged
Adubbz merged 6 commits intoOct 20, 2026
Merged
Conversation
The transpiler now handles what the effect and monster scripts need from the bytecode, besides what the action scripts already used: - A reference parameter (*p) is a plain parameter whose final value the callee returns and the caller assigns back. An integer variable passed where the callee loads a float, or a native output that would overwrite a live operand-stack slot, is refused rather than guessed at. - A local array indexed by a computed index becomes a table. Where an index may leave its array, the frame is one table and vm.index raises an error outside it. - Integer arithmetic that may overflow 32 bits wraps (vm.i32, vm.wrap), and an integer constant a script reads both as an integer and as a float is refused. In the checked-in action modules this wraps one counter each in mainchr/c01 and robo. The reference machine (refvm) restores retail's unchecked frame accesses and gets each kind's stack size and call depth. The differential harness compares integers exactly, matches infinities and NaN only to themselves, and compares a trace only up to a cut: a step-budget cut counts as a compared prefix, and an access outside the frame as unsupported, accepted only where the module raises vm.index's error. transpile.py --check runs luau-check and the harness over the generated tree.
The native command boundary now passes what the PC interpreter passes: - Parameters whose handler reads the slot's integer or float tag are marked tagged in the catalogue and get Int and Float variants, which the runtime marshals as that type (an Int variant still passes a number that does not fit an integer as a FLOAT). A tagged output whose handler copies the same bits either way (VPop, VPop2, GetValue) is marked copies. The vector commands' read-and-written parameters are in-out: the module passes the value in and assigns the result back. - Output types follow the SetStack overload each handler resolves to (GetNoDamageCnt, GetGekirin, action CheckMosEnd and six unknown outputs), and arguments a handler reads as values are inputs (monster CheckPause, GetEventInfo, MonsSeLoop, GetIndexPos, SetNextPos, SetStatus; action Shot). - Script floats are float32 (vm.f32), and SIN and COS call the C library's sinf and cosf, which the runtime and the harness host register as cmath.sinf and cmath.cosf. - stb2luau/tags.py follows the tag each slot may hold, so a native output is read as the script reads the bits the handler wrote (vm.bits_float, vm.float_bits) and a tagged call uses the variant of the tag its handler branches on. A script the translation cannot match is refused and listed with its reason in tools/luau/fallback.json, so it runs on the interpreter. The binding tables now cover the effect and monster kinds too; nothing loads their modules yet. This changes what some shipped action scripts do, to match the interpreter: robo passes Shot the value it reads (global0 or 0) where the module passed an output reference, and the handler picks its muzzle by that value; f114a and f213a pass EsmSetValue 12.0 and 10.0 as floats, which the handler stores by tag; float arithmetic and constants are float32; CheckMosEnd's output is read as the float its handler writes. c01win and f204a, which pass a reference where ProgSet reads a value, move back to the interpreter.
The runtime maps dungeon/eff_script, dungeon/articles, dungeon/sphida and the pack members under event/tbox to the effect kind, and dungeon/monster to the monster kind. Monster scripts and the effect scripts the menus load by name already go through ResolveFile; the port's EffectScript, which BuildPack and LoadBaseEffSpt read, and the asset directory behind GetPackFile now ask ModuleForScript as well and return a module handle in place of the bytes. Effect programs 100 and 200, which EFFECT_SCRIPT_PROG leaves unnamed, are named in the port enum EffectScriptProgram (init and on_start); ps2/ is unchanged. The generated modules are checked in: 252 effect and 59 monster scripts, with their shared helpers, declarations and manifest. The 89 scripts tools/luau/fallback.json lists (85 monster, 2 effect, 2 action) have no module and run on the interpreter, as before. The build passes the list to the binding generator, and ModuleForScript never returns a module for a listed script, so an old copy of its module cannot bring it back. The effect base and script timing tests check interpreter programs, so they point the runtime at a scripts directory without modules while they run, the real-data shards included. That also fixes ScriptTimingRealData.Other, which fails on master when the build's action modules are present.
A module the transpiler stops generating, such as a script moved to the interpreter fallback list, left its old copy in the build's scripts directory and in an installed one. port/cmake/scripts/luau_prune.cmake now runs before the copies on every build, and after the install: it records each copy it makes with its hash, and removes a recorded copy whose source is gone if it still holds the bytes it was copied with. A file it did not copy, or one changed since, is left alone, so hand-written modules and other files under scripts/ survive. The script sets its own minimum CMake version, since a cmake -P run does not inherit the project's policies. CTest Luau.Prune checks it on a scratch tree.
The differential harness now checks a module's native calls exactly: - Every number compares exactly. Both machines compute floats in float32, so a value one ulp off fails; a test checks that the harness host's cmath.sinf and cmath.cosf give the reference machine's bits. - A native call's inputs compare as the slots the handler reads. The Luau side is the slot the runtime's MarshalValue makes (an integer type takes a number that fits an integer as an INT, any other number as a FLOAT; a float type always a FLOAT); the reference side is the slot retail pushed. An untagged float or integer parameter compares what GetStackFloat or GetStackInt return, a tagged or unknown-typed parameter its tag and payload, an in-out parameter the float bits read through the reference. The arguments of an Ext call nothing reads (an unregistered command or a refused arity) compare as numbers only. - Float bits include a zero's sign and a NaN's sign and payload, which a handler that stores the bits passes on; the trace and the emitter spell a NaN by its bits rather than as 0 / 0. stb2luau/mutate.py checks the harness itself: for a sample of shipped scripts it changes each native argument in turn (the neighbouring float32, the other zero, an integer turned into a FLOAT slot, another NaN payload or sign, the number plus one, the other Int or Float variant), keeps the changes the handler would see that a compared scenario reaches, and requires the harness to fail every one. CTest runs it as Luau.Mutate.
The Windows CI build folded std::sin(0.779f) at compile time with a correctly rounded sine (0.702568114), while the runtime and the interpreter both call the C library's sinf (0.702568173), so the test failed although both paths agree. Read the angle through a volatile so the expected value is a library call as well.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Enable 252 effect and 59 monster scripts as Luau modules, regenerate 25 retained action modules, and deliberately keep 89 scripts on the interpreter.
Before
Only the action scripts (Max, Monica, the Ridepod and monster transformations: 27 scripts) ran as Luau modules. Every effect and monster script ran on the bytecode interpreter. The transpiler could not translate what those kinds use (references, computed array indices, 32-bit wraparound), and the native command boundary passed some arguments with other types or bits than the interpreter does.
After
252 effect and 59 monster scripts run as Luau modules, alongside 25 regenerated action modules.
89 scripts deliberately stay on the interpreter, listed with their reason in
tools/luau/fallback.json(85 monster, 2 effect, 2 action). In each case the translation cannot match what the PC interpreter does:c01win,f204a), SetPos (Dark Elementb11a) and CheckPause (1).e110pol1,e732_snow).p00_23a), whose GetMonsterLife destination holds an integer tag on the loop's first pass and a float afterwards.The runtime never loads a module for a listed script, even when an old copy is present.
Action behaviour corrections: maintainer approval needed
The action modules were regenerated, and some of what they do changes to correct differences from the PC interpreter. These corrections are gameplay-visible, so please approve them explicitly before merging. The verification below compares the PC paths; it does not establish full PS2 runtime equivalence.
Shot. The module now passes the value the script reads (global0, or 0). Before, it passed an output reference. The handler reads that integer to pick the muzzle behaviour (ps2/src/actscript.cpp,_SHOT).EsmSetValue. They pass 12.0 and 10.0 as floats, and the handler stores them by tag (_ESM_SET_VALUE). Before, they were passed as integers, so different bits were stored.mainchr/c01androbowraps at 32 bits.sinf/cosfas the interpreter does. In double precision, the first floor's frog turned differently within a frame.CheckMosEnd. Its output is read as the float its handler writes.c01winandf204amove back to the interpreter: they pass a reference whereProgSetreads a value.How
Five commits:
*pparameter is copied in and assigned back.vm.indexguards the frame.vm.i32,vm.wrap).transpile.py --check --harnessnow runs bothluau-checkand the differential harness.SetStackoverload each handler resolves to.cmath).stb2luau/tags.py).fallback.json, and the action regeneration with the Shot/EsmSetValue corrections above.ModuleForScript.EffectScriptand the asset directory behindGetPackFile.EffectScriptProgramfor effect programs 100/200.bindings.py --fallback.port/cmake/scripts/luau_prune.cmakeremoves only the module copies a build or install recorded making, unchanged since, whose sources are gone. Other files underscripts/are left alone. It is covered by CTestLuau.Pruneand works on CMake 3.31 and 4.4.GetStackInt/GetStackFloatvalue.stb2luau/mutate.py(CTestLuau.Mutate).Unchanged:
ctypes.ps2/is unchanged: the same tree ID as master..stbfiles are still published, because event scripts (M4) still run on the interpreter.Limits
dungeon/monster/b02a1are compared only up to an index that leaves its array: an index fromVPopthat the mock makes out of range, where retail would read or write past the frame. In the game,VPopreturns what the script pushed, which presumably stays in range; that has not been checked.vm.wrapwraps an integral float outside int32 in an operation typedany, where retail keeps the float.vm.i32loses the low bits of an integer product beyond 2^53. Neither is known to occur with real game values.Testing
python -m unittest discover -s tools/luau -p "test_*.py": 329 tests, OK (1 skipped).transpile.py --check --harness --seeds 32 --kind action --kind effect --kind monster, exit 0.luau-check0 errors.stb2luau/mutate.py, 4 seeds, eight shipped scripts across the three kinds): 2,794 of 2,794 reached argument mutations killed.transpile.py --check --harness(action only for 1 and 2, all three kinds from 3).LuauRuntime.*,NativeEffectBase.*and non-dataScriptTiming*gtests; the 19ScriptTimingRealDatatests were skipped in those runs.Luau.Toolsran at every stage, andLuau.Pruneran from commit 4 onward.clang-format --dry-run --Werrorpasses on every changed C++ file, andclang-tidy(port config) reports nothing on changed lines.DC2_DATA=<extracted scripts> ctest -R "^ScriptTimingRealData\."with the build's normalscripts/directory and the default VM: all 19 pass at commit 3 and at the final commit. These tests check interpreter bytecode, so commit 3 points the runtime at an empty scripts directory while they run.ScriptTimingRealData.Otherfails on master (423aff9) and at commits 1 and 2, because the action modules replace those scripts' bytes; the same isolation fixes it from commit 3 on.Luau.Tools,Luau.Survey,Luau.Transpile,Luau.PruneandLuau.Mutatepass.DC2_SCRIPT_VM=interpreter) against Luau, two instances at a time: 35 checkpoints, every screenshot byte-identical and the filtered logs matching, with no Luau errors.jump-dungeon-floor1,area-dungeon-combat(3000-5100), and the boss-event jumps 12570 (Rainbow Butterfly), 17160 (Emperor Griffon) and 17280 (Dark Element).area-boss04,-18and-24sweeps, to frame 9000.