Skip to content

Luau: run effect and monster scripts as Luau modules (M3) - #132

Merged
Adubbz merged 6 commits into
TheMoonPeople:masterfrom
carrdylan52:pr/luau-m3-effects-monsters
Oct 20, 2026
Merged

Adubbz merged 6 commits into
TheMoonPeople:masterfrom
carrdylan52:pr/luau-m3-effects-monsters

Conversation

@carrdylan52

Copy link
Copy Markdown
Contributor

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:

    • 73 monsters pass CopyVector a reference through one shared movement helper, and the handler reads that slot as a value: the interpreter reads the low bits of the variable's address, which change from run to run.
    • The same pattern with GetEventInfo (b02a2–b02a7, 6), SetStatus (3), ProgSet (c01win, f204a), SetPos (Dark Element b11a) and CheckPause (1).
    • GetRand outputs that the script reads both as an integer and as a float (e110pol1, e732_snow).
    • Monster Gaspard (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.

  • robo 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).
  • f114a / f213a 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.
  • Counters. One counter each in mainchr/c01 and robo wraps at 32 bits.
  • Floats. Float constants and arithmetic are float32, and SIN/COS use the C library's sinf/cosf as 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.
  • c01win and f204a move back to the interpreter: they pass a reference where ProgSet reads a value.

How

Five commits:

  1. Translation semantics.
    • References: a *p parameter is copied in and assigned back.
    • Computed array indices become tables, and vm.index guards the frame.
    • Integer arithmetic that may overflow wraps (vm.i32, vm.wrap).
    • The reference VM gets retail's unchecked frame accesses and each kind's stack and call depth.
    • The harness gains cuts and coverage, and transpile.py --check --harness now runs both luau-check and the differential harness.
  2. Native ABI fidelity.
    • Catalogue directions and output types are corrected against the SetStack overload each handler resolves to.
    • Tag-reading parameters get Int/Float variants; vector commands get in-out parameters.
    • Float32 arithmetic and C-library trig (cmath).
    • Tag analysis of native outputs (stb2luau/tags.py).
    • fallback.json, and the action regeneration with the Shot/EsmSetValue corrections above.
  3. Effect and monster runtime and corpus.
    • Kind prefixes in ModuleForScript.
    • Module handles from the port's EffectScript and the asset directory behind GetPackFile.
    • The port enum EffectScriptProgram for effect programs 100/200.
    • Fallback enforcement through bindings.py --fallback.
    • The generated modules, helpers, declarations and manifest.
  4. Build and install pruning. port/cmake/scripts/luau_prune.cmake removes only the module copies a build or install recorded making, unchanged since, whose sources are gone. Other files under scripts/ are left alone. It is covered by CTest Luau.Prune and works on CMake 3.31 and 4.4.
  5. Differential qualification.
    • Native inputs are compared as the slots their handlers read: tag and payload, or the GetStackInt/GetStackFloat value.
    • Native float inputs compare the float32 bits their handlers observe, including signed zero and NaN sign/payload. General trace-value comparison still accepts NaN as NaN.
    • The mutation sweep stb2luau/mutate.py (CTest Luau.Mutate).

Unchanged:

  • No new dependency or version pin. Luau 0.740 and StyLua 2.5.2 were pinned before this change; the trig wrappers use the C library and Python's ctypes.
  • ps2/ is unchanged: the same tree ID as master.
  • .stb files are still published, because event scripts (M4) still run on the interpreter.

Limits

  • Prefix-only scenarios. 232 effect scenarios exceed the harness's step budget and are compared only up to their last event.
  • Unsupported scenarios. 70 scenarios of dungeon/monster/b02a1 are compared only up to an index that leaves its array: an index from VPop that the mock makes out of range, where retail would read or write past the frame. In the game, VPop returns what the script pushed, which presumably stays in range; that has not been checked.
  • vm.wrap wraps an integral float outside int32 in an operation typed any, where retail keeps the float. vm.i32 loses the low bits of an integer product beyond 2^53. Neither is known to occur with real game values.
  • Coverage. Harness coverage averages 92% of reference instructions: 87% action, 96% effect, 77% monster.
  • Open question: the 73 CopyVector monsters. Should Luau reproduce the original pointer-derived reads, or deliberately pass the referenced coordinate values the scripts appear to intend? The current PC interpreter reads run-dependent PC address bits, which do not establish the PS2's numerical results. These scripts stay on the interpreter pending that decision.

Testing

  • Python: python -m unittest discover -s tools/luau -p "test_*.py": 329 tests, OK (1 skipped).
  • Corpus: transpile.py --check --harness --seeds 32 --kind action --kind effect --kind monster, exit 0.
    • 336 transpiled, 89 kept on the interpreter, 0 failed; luau-check 0 errors.
    • Harness: 120,274 passed, 232 prefix only, 70 unsupported, 0 failed.
    • Mean coverage: 92.0% reference, 92.0% compared.
  • Mutation sweep (stb2luau/mutate.py, 4 seeds, eight shipped scripts across the three kinds): 2,794 of 2,794 reached argument mutations killed.
  • Each of the five commits was checked on its own:
    • Python suite.
    • transpile.py --check --harness (action only for 1 and 2, all three kinds from 3).
    • Windows test build.
    • LuauRuntime.*, NativeEffectBase.* and non-data ScriptTiming* gtests; the 19 ScriptTimingRealData tests were skipped in those runs. Luau.Tools ran at every stage, and Luau.Prune ran from commit 4 onward.
  • Formatting and lint: clang-format --dry-run --Werror passes on every changed C++ file, and clang-tidy (port config) reports nothing on changed lines.
  • Real-data timing tests: DC2_DATA=<extracted scripts> ctest -R "^ScriptTimingRealData\." with the build's normal scripts/ 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.Other fails 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.
  • CTest with game data on the final commit: Luau.Tools, Luau.Survey, Luau.Transpile, Luau.Prune and Luau.Mutate pass.
  • Release build (Windows, llvm-mingw): exit 0. It and the replays below were run on the previous revision of the series, whose game and runtime code is identical; the rewrite only changed the timing test, documentation and commit messages.
  • Fresh headless replays on that build, interpreter (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.
    • Covered: jump-dungeon-floor1, area-dungeon-combat (3000-5100), and the boss-event jumps 12570 (Rainbow Butterfly), 17160 (Emperor Griffon) and 17280 (Dark Element).
    • Also the area-boss04, -18 and -24 sweeps, to frame 9000.
    • The 103-checkpoint set recorded in the handoff predates the later review fixes and was not rerun.

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.
@Adubbz
Adubbz merged commit 951a958 into TheMoonPeople:master Oct 20, 2026
4 of 7 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.

2 participants