ZScript / ZASM / JIT
ZScript source → (parser/) ZASM bytecode → run by the interpreter (components/zasm/eval.cpp)
or compiled by the JIT (zc/scripting/jit/: jit_x64.cpp, jit_a64.cpp, jit_wasm.cpp;
jit_shared.cpp is the shared native driver). Before either runs, the ZASM optimizer
(zc/zasm_optimize.cpp) may rewrite the bytecode.
Pipeline: src/parser (lex/grammar/AST/codegen) → linker → zc/zasm_optimize.cpp
(optional) → JIT backend or components/zasm/eval.cpp (interpreter). On an
Apple Silicon mac the native backend is jit_a64.cpp, not x64.
Building
cmake -B build -S . # once (build dir is build/, outputs to build/Release)
cmake --build build --config Release -t zplayer zeditor zscript base_test_runner copy_resources
- Binaries land in
build/Release/. Incremental builds only recompile what changed; a
single .cpp edit + relink is ~30s, but ffscript.cpp/zelda.cpp are huge and slow.
scripts/run_target.py picks the build folder from BUILD_FOLDER env, else the most
recently built of build/RelWithDebInfo, build/Release, build/Debug. Most test
scripts honor BUILD_FOLDER=build/Release.
- If ninja says only
copy_resources ran but you edited a file, the .o looked up-to-date —
touch the source and rebuild.
Compiling a script standalone & seeing its ZASM
inc="$PWD/tests/scripts;$PWD/resources/include;$PWD/resources/headers;$PWD/tests/scripts/playground"
build/Release/zscript -input <file.zs> -zasm /tmp/out.zasm -commented -include "$inc" -unlinked -json
-commented annotates each ZASM op with source line; -json prints diagnostics as JSON
(look for "success": true).
-unlinked shows the PRE-LINK form. The linker rewrites some ops, so you will NOT
see LOADD/SP here (you see LOAD/SP2). To see the real ops the JIT compiles, dump
the linked/JIT form instead (below).
- This is the compiler's ZASM, before the zc-side
zasm_optimize pass.
Dumping the linked + JIT-compiled ZASM and emitted x64
The most reliable way to see exactly what the JIT compiles (with ; NN OPCODE args
comments interleaved with the emitted x86-64):
build/Release/zplayer -headless -replay <file.zplay> -frame 0 \
-jit -jit-precompile -jit-threads 0 -jit-print-asm \
-script-runtime-debug-folder /tmp/jitdump -replay-exit-when-done -no_console
# → /tmp/jitdump/zasm/<qst>/zasm-<script>.txt
- Add
-no-optimize-zasm to see the unoptimized form the JIT compiles.
-frame 0 makes it compile (precompile) and exit fast without running the whole replay.
-extract-zasm <qst> also dumps ZASM, but has a flush bug where only the last script
is written — prefer the -jit-print-asm route above.
JIT / optimizer flags (zplayer)
| Flag |
Meaning |
-jit / -no-jit |
enable/disable JIT (default on; config ZSCRIPT.jit) |
-jit-precompile |
compile ALL scripts synchronously at load (blocks in zasm_pipeline_init, so compile time is before the first frame) |
-jit-threads 0 |
compile synchronously, no worker pool |
-jit-print-asm |
dump emitted asm to the script-runtime-debug-folder |
-jit-log |
JIT logging to stdout |
-jit-cache-registers / -no-... |
D-register caching (default on) |
-optimize-zasm / -no-optimize-zasm |
ZASM optimizer (default on) |
-optimize-zasm-experimental |
extra passes (spurious-branches, etc.) |
-jit-fatal-compile-errors |
abort on a compile error instead of falling back |
-jit-hot-function-loop-count N / -jit-hot-function-call-count N |
hot thresholds (when not precompiling) |
-test-jit-bisect N |
(native x64/a64) skip compiling the first N functions — needs ENABLE_BISECT_TOOL in jit_shared.cpp, see "Localizing a JIT miscompilation (native)". For the wasm/web JIT use a per-instance bisect instead, see "Debugging the wasm (web) JIT" |
Running replay tests
# Quick set (fast):
python tests/run_replay_tests.py --filter playground --filter z3 --filter misc --build_folder build/Release
# A specific replay (pass an ABSOLUTE path; relative paths fail to load):
python tests/run_replay_tests.py --build_folder build/Release "$PWD/tests/replays/playground/auto_bug_mod_int_min.zplay"
Useful flags: --jit/--no-jit (default jit on), --optimize-zasm/--no-optimize-zasm
(default on), --update (re-record/update in place), --test_results <dir>,
--retries N, --filter <substr> (repeatable), --no-headless.
- With JIT, the harness uses
-jit-precompile for all replays except yuurand* and
freedom* (those test hot-compilation).
- Other replays take a while; stick to the quick set unless you need a specific one.
Auto script tests (the easy way to add a scripted test)
Each tests/scripts/playground/auto/<name>.zs is a generic script <name> that
#include "auto/test_runner.zs", calls Test::Init() first and Test::End() last.
Helpers: Test::Init(), Test::End(), Test::AssertEqual(actual, expected),
Test::Assert(cond), Test::Fail(msg). Assertion failures print and Game->Exit().
python tests/update_auto_script_tests.py # regenerate auto.zs, recompile playground.qst, record/update replays
python tests/update_auto_script_tests.py --test # just run them, no update
- This recompiles
playground.qst, so all auto replays churn (and playground.qst,
auto.zs). That's expected when adding/changing a script — commit them together.
- After adding a script you also need its compiler snapshot: run
python tests/test_zscript.py --update TestZScript.test_zscript_compiler_expected_zasm
(creates <name>_expected.txt), and python tests/test_optimize_zasm.py --update
(optimizer stats in tests/snapshots/optimize_zasm/playground.txt shift when a script
is added).
- See
tests/scripts/README.md.
Snapshot tests
python tests/test_zscript.py --update # compiler output per script (*_expected.txt) + more
python tests/test_optimize_zasm.py --update # optimizer stats (tests/snapshots/optimize_zasm/)
python tests/test_jit.py --update # JIT asm snapshots (tests/snapshots/jit/) — currently SKIPPED
python tests/update_snapshots.py # runs the snapshot updaters
Recording a replay manually
build/Release/zplayer -headless -test <qst> <dmap> <scr> \
-record /tmp/out.zplay -replay-name <name> -frame <N> -replay-exit-when-done -no_console
-frame N stops recording at frame N. For an auto/generic test, -replay-name <name>
selects which generic script runs (on the test dmap).
- A recording with no per-frame input (e.g. a script that needs no keys) produces a
step-less replay that, on playback, stops at frame 0 (replay ends when recorded
steps are exhausted). To make such a replay run N frames, append a terminal step
Q <N> 3 and set M frames <N>. -frame N caps normal (step-ful) replays but BREAKS
step-less ones (they run 0 frames).
ZASM facts & JIT gotchas
- ZASM numbers are fixed-point ×10000 (so
5 is 50000). div_10000 converts a
fixed-point stack offset to an index.
- The
int type is a lie - it's the fixed-point type described above.
- The
float type is a lie - there is no float type, it's just an int.
LOAD vs LOADD: LOAD uses a raw integer offset; LOADD/STORED use a
fixed-point offset (/10000). LOADD/STORED appear mostly in older-compiled
quests (2.55-era, e.g. keys.qst); modern compiles emit LOAD. LOADI/STOREI
(stack access by a register offset) are still common (e.g. eiyuu.qst).
- D-register cache: the JIT caches
D0..D7 in virtual registers; get_z_register
returns the cached reg. Mutating that reg in place corrupts the cache unless you
write it back via set_z_register or copy it first. This is a recurring bug class
(fixed by compute_stack_offset for LOAD/STORE-family ops and immutable_cast_bool
for comparisons). When adding codegen, never run an in-place mutator (div_10000,
cast_bool, arithmetic) directly on a get_z_register result you don't store back.
- The
-no-optimize-zasm CI pass exists to catch JIT bugs the optimizer hides — the
optimizer often rewrites away the exact bytecode pattern that triggers a JIT bug, so the
default suite (optimizer on) misses them. ci.py replay-tests --no-optimize-zasm /
test.yml run the playground subset with the optimizer off.
- The
const type modifier only makes that variable unassignable - it does not prevent
modifying the variable's fields.
Localizing a JIT miscompilation (native)
Works for both native backends (x64 and a64). When a replay fails only under JIT
(esp. only with -no-optimize-zasm):
- Enable
#define ENABLE_BISECT_TOOL in src/zc/scripting/jit/jit_shared.cpp (the
comment above it documents an alternate find-first-fail workflow), rebuild zplayer.
- Binary-search
N:python tests/run_replay_tests.py --filter <replay> --no-optimize-zasm \
--extra_args="-jit-threads 0 -test-jit-bisect N"
-test-jit-bisect N runs the first N functions interpreted and JIT-compiles the rest.
The flip point (fail at N-1, pass at N) means function index N-1 is miscompiled.
- Map index → function name with a temporary trace in
compile_and_queue_function, then
dump that function's asm with -jit-print-asm and read it.
- Revert the
#define and any trace when done.
Debugging the wasm (web) JIT
The wasm JIT (src/zc/scripting/jit/jit_wasm.cpp) only runs in the web build, so a bug that
reproduces only there needs a different workflow than the x64 tool above. This is hard,
slow (~8 min/build), and some bug classes defeat bisection — read this before diving in.
Reproduce & read the result
# build the web engine (repackages packages/web; -t zplayer does NOT — stale-wasm trap)
source ~/tools/emsdk/emsdk_env.sh && cmake --build build_emscripten --config Release -t web
# run a replay (ABSOLUTE path required — /host/ maps to the real fs)
python tests/run_replay_tests.py --build_folder build_emscripten/Release --frame 4000 "$ABS.zplay"
--no-jit is the correctness oracle. If the replay passes with --no-jit (it almost
always does — the interpreter is correct on every platform) but fails with the JIT, the
bug is 100% JIT codegen. Confirm this first.
- Web exit-code semantics (from
web/tests/run_replay.js): 0 = pass; 120
(ASSERT_FAILED_EXIT_CODE) = replay/graphics/rng divergence (a value miscompile — the
script produced different state); 1 = the tab renderer crashed or an uncaught
pageerror/RuntimeError fired. Exit 1 with empty stderr and stdout ending mid-frame =
the renderer process died = severe memory corruption, not a wasm trap or abort()
(those print a message). run_replay_tests reports 1/120 as "failed w/ exit code N".
al_trace reaches captured stdout during execution, but the last lines right before
zc_exit are dropped — fflush(stdout) after a diagnostic print if you need it to survive.
- COEP
ERR_BLOCKED_BY_RESPONSE (1–2 line stdout, empty roundtrip) is a load flake under
build load — just re-run. Clean up the server with lsof -ti tcp:8000 | xargs kill
(never pkill -f webserver.mjs — it matches the orchestrating command line).
Bisect: sound vs layout-contaminated (the key lesson)
- Per-instance bisect is Heisenbug-STABLE and reliably names the culprit script. Add a
temporary flag in
run_script (src/zc/ffscript.cpp) that JIT-compiles only the first N
distinct (type, i) script instances (first-seen order) and interprets the rest — make
the decision sticky per instance so a yielding script never switches backend mid-run.
N=0 all-interp passes; binary-search the smallest N that fails; instance N-1 is the culprit.
This is stable across re-runs because it's a coarse behavioral switch, not a codegen change.
- Codegen-toggle bisects are LAYOUT-CONTAMINATED for memory-corruption bugs — do not trust
them. Forcing a pc-range or a command type to interpret (via
command_is_compiled —
it has a documented list of "optional" commands you can comment out, or compile_command_interpreter)
changes the wasm module's size/layout, which moves a layout-sensitive corruption. This
produces stable-but-FALSE pinpoints (e.g. it will finger the highest-volume op like
PUSHR D, or a command in a function that never even executes). Use these only to
narrow classes interpreted-in-bulk, and corroborate every pinpoint against the
interpreter — never conclude "command X is buggy" from a codegen toggle alone.
Localize a value miscompile (exit 120 / rng-desync)
Use scripts/jit_runtime_debug.py — it collects a --no-jit baseline and the JIT run with
-script-runtime-debug 2 -script-runtime-debug-frame N and diffs the per-command
pc: N OP | D:<regs> stack:<...> lines to find the first divergent instruction. Caveats
for web replays (fix before relying on it): its RunResult dataclass rejects the current
rng_desync result field; it needs a recorded failing_frame (a hard crash reports frame
0 → it trims the replay to nothing); and the full-runtime-debug pass stalls on long (100s of
frames) replays. Manual fallback: run a native --no-jit baseline and the web JIT both with
-script-runtime-debug 2 -script-runtime-debug-frame N, route the trace to stdout, and diff.
Note: this only catches divergences visible in script registers/stack — an in-bounds
wild write into engine memory (renderer crash, exit 1) shows nothing here.
wasm JIT architecture (differs fundamentally from x64 — scopes the bug)
@single (all generic/ffc scripts) is compiled as one module. Removing the
per-chunk RUNGENFRZSCR/STACKWRITEATVV_IF bail in jit_compile_script JITs the whole
chunk — that's how you enable generic content, but it exposes every codegen bug the bail
was shielding.
- Yielding functions (anything that transitively
WaitXes) are inlined into one
"yielder" wasm function with a loop-switch (br_table over block ids) + a per-instance
call_stack_rets return stack; yield/resume saves/restores g_idx_sp,
call_stack_ret_index, wait_index in the run-wrapper. Non-yielding functions are
separate wasm functions called with native emitCall.
- Contrast: the x64 JIT returns to
run_script_int for CALLFUNC, and the interpreter uses a
ret_stack array — so the yielder/call machinery has no x64 equivalent to diff against,
and native-emitCall recursion depth is a wasm-only concern (though STACK_SIZE is large;
a 5MB→64MB bump is a quick way to rule stack exhaustion in/out).
- Registers:
get_z_register has distinct paths for D/GD/SP/SP2/
does_register_use_stack (only MAPDATAEXDOOR/SCREENDATAEXDOOR)/other (C++ call). SP/SP2
read g_idx_sp inline; evaluate a pushed operand before add_sp (matching interp/x64).
Fast, execution-free codegen validation (no web build)
Configure a native -DJIT_BACKEND=wasm build (build_wasm_backend): it generates the wasm
module natively (runs interpreted, can't execute it). With -jit-save-wasm it writes
<exe-dir>/wasm/<qst>/<module>.wasm; validate structure with
wasm2wat <f> --enable-threads. A Debug build here also aborts at an unexpected opcode
("unexpected command …"), pinpointing a fused/dropped op instantly. Use this to check every
codegen edit compiles to valid wasm before paying for the 8-min web build.
SAFE_HEAP=1 does not catch this bug class: it only flags out-of-bounds/misaligned
accesses in the main module (not the runtime-compiled JIT module), so a wrong-but-in-bounds
wild write is invisible; it also breaks the zscript.mjs link (wasm-opt SAFE_HEAP+asyncify).
Benchmarking script/JIT performance
- Use
-script-timings (see src/zc/script_timings.h): accumulates wall-clock time
spent inside run_script across the session and prints a summary (total + per-script
attribution + the N slowest script-frames; -script-timings-top N, default 20) when the
game/replay ends. Reentrancy-aware (nested frozen-generic runs aren't double counted).
This isolates scripting cost from engine/render time when comparing JIT vs interpreter.
- For error-spammy scripts (maths divides by zero every frame), set
suppress_script_error_logging so you measure compute, not I/O.
- Good benchmark:
yuurand_riviere.zplay — genuinely script-heavy. JIT is ~2.6× over
the interpreter there; the ZASM optimizer adds ~1.16× to the interpreter but ~nothing on
top of JIT.
- Poor benchmark:
maths.zplay — engine/function-call-bound (~1260 calls/frame) and
error-I/O-heavy, so JIT only shows ~1.1× and it doesn't reflect script-engine speed.
keys.qst and maths.qst are old (2.55-era) quests; useful for LOADD-path coverage.
Where things live
src/parser/ — lexer/grammar (ffscript.lpp/.ypp), AST, BuildVisitors.cpp (codegen).
src/components/zasm/ — interpreter (eval.cpp), opcode table (table.cpp).
src/zc/scripting/jit/ — JIT backends (jit_x64.cpp, jit_a64.cpp, jit_wasm.cpp,
jit_none.cpp; jit_shared.cpp native driver, jit.cpp enable/precompile control);
zc/zasm_optimize.cpp — optimizer; zc/zasm_pipeline.cpp — orchestration.
src/zc/ffscript.cpp — interpreter command impls (do_div, do_mod, etc.) and
scripting_log_error_with_context / suppress_script_error_logging.
tests/run_replay_tests.py, tests/replays.py, tests/update_auto_script_tests.py,
tests/test_zscript.py, tests/test_optimize_zasm.py, tests/test_jit.py.
1---2name: zscript3description: Working with ZScript and ZASM in ZQuest Classic — building zplayer/zeditor/zscript, compiling scripts, the JIT and ZASM optimizer, dumping ZASM/emitted x64, running & updating replay and snapshot tests, recording replays, debugging JIT miscompilations, and benchmarking. Use for anything touching src/parser, src/components/zasm, src/zc/scripting/jit/jit_*, src/zc/zasm_*, ffscript, or tests/replays.4---56# ZScript / ZASM / JIT78ZScript source → (parser/) ZASM bytecode → run by the interpreter (`components/zasm/eval.cpp`)9or compiled by the JIT (`zc/scripting/jit/`: `jit_x64.cpp`, `jit_a64.cpp`, `jit_wasm.cpp`;10`jit_shared.cpp` is the shared native driver). Before either runs, the ZASM optimizer11(`zc/zasm_optimize.cpp`) may rewrite the bytecode.1213Pipeline: `src/parser` (lex/grammar/AST/codegen) → linker → `zc/zasm_optimize.cpp`14(optional) → JIT backend or `components/zasm/eval.cpp` (interpreter). On an15Apple Silicon mac the native backend is `jit_a64.cpp`, not x64.1617## Building1819```bash20cmake -B build -S . # once (build dir is build/, outputs to build/Release)21cmake --build build --config Release -t zplayer zeditor zscript base_test_runner copy_resources22```2324- Binaries land in `build/Release/`. Incremental builds only recompile what changed; a25 single `.cpp` edit + relink is ~30s, but `ffscript.cpp`/`zelda.cpp` are huge and slow.26- `scripts/run_target.py` picks the build folder from `BUILD_FOLDER` env, else the most27 recently built of `build/RelWithDebInfo`, `build/Release`, `build/Debug`. Most test28 scripts honor `BUILD_FOLDER=build/Release`.29- If ninja says only `copy_resources` ran but you edited a file, the .o looked up-to-date —30 `touch` the source and rebuild.3132## Compiling a script standalone & seeing its ZASM3334```bash35inc="$PWD/tests/scripts;$PWD/resources/include;$PWD/resources/headers;$PWD/tests/scripts/playground"36build/Release/zscript -input <file.zs> -zasm /tmp/out.zasm -commented -include "$inc" -unlinked -json37```3839- `-commented` annotates each ZASM op with source line; `-json` prints diagnostics as JSON40 (look for `"success": true`).41- **`-unlinked` shows the PRE-LINK form.** The linker rewrites some ops, so you will NOT42 see `LOADD`/`SP` here (you see `LOAD`/`SP2`). To see the real ops the JIT compiles, dump43 the linked/JIT form instead (below).44- This is the compiler's ZASM, *before* the zc-side `zasm_optimize` pass.4546## Dumping the linked + JIT-compiled ZASM and emitted x644748The most reliable way to see exactly what the JIT compiles (with `; NN OPCODE args`49comments interleaved with the emitted x86-64):5051```bash52build/Release/zplayer -headless -replay <file.zplay> -frame 0 \53 -jit -jit-precompile -jit-threads 0 -jit-print-asm \54 -script-runtime-debug-folder /tmp/jitdump -replay-exit-when-done -no_console55# → /tmp/jitdump/zasm/<qst>/zasm-<script>.txt56```5758- Add `-no-optimize-zasm` to see the *unoptimized* form the JIT compiles.59- `-frame 0` makes it compile (precompile) and exit fast without running the whole replay.60- `-extract-zasm <qst>` also dumps ZASM, but has a flush bug where only the **last** script61 is written — prefer the `-jit-print-asm` route above.6263## JIT / optimizer flags (zplayer)6465| Flag | Meaning |66|---|---|67| `-jit` / `-no-jit` | enable/disable JIT (default on; config `ZSCRIPT.jit`) |68| `-jit-precompile` | compile ALL scripts synchronously at load (blocks in `zasm_pipeline_init`, so compile time is before the first frame) |69| `-jit-threads 0` | compile synchronously, no worker pool |70| `-jit-print-asm` | dump emitted asm to the script-runtime-debug-folder |71| `-jit-log` | JIT logging to stdout |72| `-jit-cache-registers` / `-no-...` | D-register caching (default on) |73| `-optimize-zasm` / `-no-optimize-zasm` | ZASM optimizer (default on) |74| `-optimize-zasm-experimental` | extra passes (spurious-branches, etc.) |75| `-jit-fatal-compile-errors` | abort on a compile error instead of falling back |76| `-jit-hot-function-loop-count N` / `-jit-hot-function-call-count N` | hot thresholds (when not precompiling) |77| `-test-jit-bisect N` | (native x64/a64) skip compiling the first N functions — needs `ENABLE_BISECT_TOOL` in `jit_shared.cpp`, see "Localizing a JIT miscompilation (native)". For the wasm/web JIT use a *per-instance* bisect instead, see "Debugging the wasm (web) JIT" |7879## Running replay tests8081```bash82# Quick set (fast):83python tests/run_replay_tests.py --filter playground --filter z3 --filter misc --build_folder build/Release8485# A specific replay (pass an ABSOLUTE path; relative paths fail to load):86python tests/run_replay_tests.py --build_folder build/Release "$PWD/tests/replays/playground/auto_bug_mod_int_min.zplay"87```8889Useful flags: `--jit`/`--no-jit` (default jit on), `--optimize-zasm`/`--no-optimize-zasm`90(default on), `--update` (re-record/update in place), `--test_results <dir>`,91`--retries N`, `--filter <substr>` (repeatable), `--no-headless`.9293- With JIT, the harness uses `-jit-precompile` for all replays **except** `yuurand*` and94 `freedom*` (those test hot-compilation).95- Other replays take a while; stick to the quick set unless you need a specific one.9697## Auto script tests (the easy way to add a scripted test)9899Each `tests/scripts/playground/auto/<name>.zs` is a `generic script <name>` that100`#include "auto/test_runner.zs"`, calls `Test::Init()` first and `Test::End()` last.101Helpers: `Test::Init()`, `Test::End()`, `Test::AssertEqual(actual, expected)`,102`Test::Assert(cond)`, `Test::Fail(msg)`. Assertion failures print and `Game->Exit()`.103104```bash105python tests/update_auto_script_tests.py # regenerate auto.zs, recompile playground.qst, record/update replays106python tests/update_auto_script_tests.py --test # just run them, no update107```108109- This recompiles `playground.qst`, so **all** auto replays churn (and `playground.qst`,110 `auto.zs`). That's expected when adding/changing a script — commit them together.111- After adding a script you also need its compiler snapshot: run112 `python tests/test_zscript.py --update TestZScript.test_zscript_compiler_expected_zasm`113 (creates `<name>_expected.txt`), and `python tests/test_optimize_zasm.py --update`114 (optimizer stats in `tests/snapshots/optimize_zasm/playground.txt` shift when a script115 is added).116- See `tests/scripts/README.md`.117118## Snapshot tests119120```bash121python tests/test_zscript.py --update # compiler output per script (*_expected.txt) + more122python tests/test_optimize_zasm.py --update # optimizer stats (tests/snapshots/optimize_zasm/)123python tests/test_jit.py --update # JIT asm snapshots (tests/snapshots/jit/) — currently SKIPPED124python tests/update_snapshots.py # runs the snapshot updaters125```126127## Recording a replay manually128129```bash130build/Release/zplayer -headless -test <qst> <dmap> <scr> \131 -record /tmp/out.zplay -replay-name <name> -frame <N> -replay-exit-when-done -no_console132```133134- `-frame N` stops recording at frame N. For an auto/generic test, `-replay-name <name>`135 selects which `generic script` runs (on the test dmap).136- A recording with no per-frame input (e.g. a script that needs no keys) produces a137 **step-less** replay that, on playback, **stops at frame 0** (replay ends when recorded138 steps are exhausted). To make such a replay run N frames, append a terminal step139 `Q <N> 3` and set `M frames <N>`. `-frame N` caps normal (step-ful) replays but BREAKS140 step-less ones (they run 0 frames).141142## ZASM facts & JIT gotchas143144- ZASM numbers are **fixed-point ×10000** (so `5` is `50000`). `div_10000` converts a145 fixed-point stack offset to an index.146- The `int` type is a lie - it's the fixed-point type described above.147- The `float` type is a lie - there is no float type, it's just an int.148- **`LOAD` vs `LOADD`:** `LOAD` uses a raw integer offset; `LOADD`/`STORED` use a149 fixed-point offset (`/10000`). `LOADD`/`STORED` appear mostly in **older-compiled150 quests** (2.55-era, e.g. `keys.qst`); modern compiles emit `LOAD`. `LOADI`/`STOREI`151 (stack access by a register offset) are still common (e.g. `eiyuu.qst`).152- **D-register cache:** the JIT caches `D0..D7` in virtual registers; `get_z_register`153 returns the cached reg. **Mutating that reg in place corrupts the cache** unless you154 write it back via `set_z_register` or copy it first. This is a recurring bug class155 (fixed by `compute_stack_offset` for LOAD/STORE-family ops and `immutable_cast_bool`156 for comparisons). When adding codegen, never run an in-place mutator (`div_10000`,157 `cast_bool`, arithmetic) directly on a `get_z_register` result you don't store back.158- **The `-no-optimize-zasm` CI pass exists to catch JIT bugs the optimizer hides** — the159 optimizer often rewrites away the exact bytecode pattern that triggers a JIT bug, so the160 default suite (optimizer on) misses them. `ci.py replay-tests --no-optimize-zasm` /161 `test.yml` run the playground subset with the optimizer off.162- The `const` type modifier only makes that variable unassignable - it does not prevent163 modifying the variable's fields.164165## Localizing a JIT miscompilation (native)166167Works for both native backends (x64 and a64). When a replay fails only under JIT168(esp. only with `-no-optimize-zasm`):1691701. Enable `#define ENABLE_BISECT_TOOL` in `src/zc/scripting/jit/jit_shared.cpp` (the171 comment above it documents an alternate find-first-fail workflow), rebuild zplayer.1722. Binary-search `N`:173 ```bash174 python tests/run_replay_tests.py --filter <replay> --no-optimize-zasm \175 --extra_args="-jit-threads 0 -test-jit-bisect N"176 ```177 `-test-jit-bisect N` runs the first N functions interpreted and JIT-compiles the rest.178 The flip point (fail at N-1, pass at N) means **function index N-1** is miscompiled.1793. Map index → function name with a temporary trace in `compile_and_queue_function`, then180 dump that function's asm with `-jit-print-asm` and read it.1814. Revert the `#define` and any trace when done.182183## Debugging the wasm (web) JIT184185The wasm JIT (`src/zc/scripting/jit/jit_wasm.cpp`) only runs in the **web build**, so a bug that186reproduces only there needs a different workflow than the x64 tool above. This is hard,187slow (~8 min/build), and some bug classes defeat bisection — read this before diving in.188189### Reproduce & read the result190191```bash192# build the web engine (repackages packages/web; -t zplayer does NOT — stale-wasm trap)193source ~/tools/emsdk/emsdk_env.sh && cmake --build build_emscripten --config Release -t web194# run a replay (ABSOLUTE path required — /host/ maps to the real fs)195python tests/run_replay_tests.py --build_folder build_emscripten/Release --frame 4000 "$ABS.zplay"196```197198- **`--no-jit` is the correctness oracle.** If the replay passes with `--no-jit` (it almost199 always does — the interpreter is correct on every platform) but fails with the JIT, the200 bug is 100% JIT codegen. Confirm this first.201- **Web exit-code semantics** (from `web/tests/run_replay.js`): **0** = pass; **120**202 (`ASSERT_FAILED_EXIT_CODE`) = replay/graphics/rng divergence (a *value* miscompile — the203 script produced different state); **1** = the tab **renderer crashed** or an uncaught204 `pageerror`/`RuntimeError` fired. Exit 1 with empty stderr and stdout ending mid-frame =205 the renderer process died = severe **memory corruption**, *not* a wasm trap or `abort()`206 (those print a message). `run_replay_tests` reports 1/120 as "failed w/ exit code N".207- `al_trace` reaches captured stdout **during** execution, but the last lines right before208 `zc_exit` are dropped — `fflush(stdout)` after a diagnostic print if you need it to survive.209- COEP `ERR_BLOCKED_BY_RESPONSE` (1–2 line stdout, empty roundtrip) is a load flake under210 build load — just re-run. Clean up the server with `lsof -ti tcp:8000 | xargs kill`211 (never `pkill -f webserver.mjs` — it matches the orchestrating command line).212213### Bisect: sound vs layout-contaminated (the key lesson)214215- **Per-instance bisect is Heisenbug-STABLE and reliably names the culprit *script*.** Add a216 temporary flag in `run_script` (`src/zc/ffscript.cpp`) that JIT-compiles only the first N217 *distinct* `(type, i)` script instances (first-seen order) and interprets the rest — make218 the decision **sticky per instance** so a yielding script never switches backend mid-run.219 N=0 all-interp passes; binary-search the smallest N that fails; instance N-1 is the culprit.220 This is stable across re-runs because it's a coarse behavioral switch, not a codegen change.221- **Codegen-toggle bisects are LAYOUT-CONTAMINATED for memory-corruption bugs — do not trust222 them.** Forcing a pc-range or a command *type* to interpret (via `command_is_compiled` —223 it has a documented list of "optional" commands you can comment out, or `compile_command_interpreter`)224 changes the wasm module's size/layout, which *moves* a layout-sensitive corruption. This225 produces stable-but-FALSE pinpoints (e.g. it will finger the highest-volume op like226 `PUSHR D`, or a command in a function that never even executes). Use these only to227 narrow *classes* interpreted-in-bulk, and corroborate every pinpoint against the228 interpreter — never conclude "command X is buggy" from a codegen toggle alone.229230### Localize a *value* miscompile (exit 120 / rng-desync)231232Use `scripts/jit_runtime_debug.py` — it collects a `--no-jit` baseline and the JIT run with233`-script-runtime-debug 2 -script-runtime-debug-frame N` and diffs the per-command234`pc: N OP | D:<regs> stack:<...>` lines to find the first divergent instruction. **Caveats235for web replays** (fix before relying on it): its `RunResult` dataclass rejects the current236`rng_desync` result field; it needs a recorded `failing_frame` (a *hard crash* reports frame2370 → it trims the replay to nothing); and the full-runtime-debug pass stalls on long (100s of238frames) replays. Manual fallback: run a native `--no-jit` baseline and the web JIT both with239`-script-runtime-debug 2 -script-runtime-debug-frame N`, route the trace to stdout, and diff.240Note: this only catches divergences visible in **script registers/stack** — an in-bounds241wild write into engine memory (renderer crash, exit 1) shows nothing here.242243### wasm JIT architecture (differs fundamentally from x64 — scopes the bug)244245- `@single` (all generic/ffc scripts) is compiled as **one module**. Removing the246 per-chunk `RUNGENFRZSCR`/`STACKWRITEATVV_IF` bail in `jit_compile_script` JITs the whole247 chunk — that's how you enable generic content, but it exposes every codegen bug the bail248 was shielding.249- **Yielding** functions (anything that transitively `WaitX`es) are **inlined into one250 "yielder"** wasm function with a loop-switch (`br_table` over block ids) + a per-instance251 `call_stack_rets` return stack; yield/resume saves/restores `g_idx_sp`,252 `call_stack_ret_index`, `wait_index` in the run-wrapper. **Non-yielding** functions are253 separate wasm functions called with native `emitCall`.254- Contrast: the x64 JIT returns to `run_script_int` for CALLFUNC, and the interpreter uses a255 `ret_stack` array — so **the yielder/call machinery has no x64 equivalent to diff against**,256 and native-`emitCall` recursion depth is a wasm-only concern (though `STACK_SIZE` is large;257 a 5MB→64MB bump is a quick way to rule stack exhaustion in/out).258- Registers: `get_z_register` has distinct paths for `D`/`GD`/`SP`/`SP2`/259 `does_register_use_stack` (only `MAPDATAEXDOOR`/`SCREENDATAEXDOOR`)/other (C++ call). SP/SP2260 read `g_idx_sp` inline; evaluate a pushed operand **before** `add_sp` (matching interp/x64).261262### Fast, execution-free codegen validation (no web build)263264Configure a native `-DJIT_BACKEND=wasm` build (`build_wasm_backend`): it *generates* the wasm265module natively (runs interpreted, can't execute it). With `-jit-save-wasm` it writes266`<exe-dir>/wasm/<qst>/<module>.wasm`; validate structure with267`wasm2wat <f> --enable-threads`. A Debug build here also aborts at an unexpected opcode268("unexpected command …"), pinpointing a fused/dropped op instantly. Use this to check every269codegen edit compiles to valid wasm before paying for the 8-min web build.270271- `SAFE_HEAP=1` does **not** catch this bug class: it only flags out-of-bounds/misaligned272 accesses in the *main* module (not the runtime-compiled JIT module), so a wrong-but-in-bounds273 wild write is invisible; it also breaks the `zscript.mjs` link (wasm-opt SAFE_HEAP+asyncify).274275## Benchmarking script/JIT performance276277- **Use `-script-timings`** (see `src/zc/script_timings.h`): accumulates wall-clock time278 spent inside `run_script` across the session and prints a summary (total + per-script279 attribution + the N slowest script-frames; `-script-timings-top N`, default 20) when the280 game/replay ends. Reentrancy-aware (nested frozen-generic runs aren't double counted).281 This isolates scripting cost from engine/render time when comparing JIT vs interpreter.282- For error-spammy scripts (maths divides by zero every frame), set283 `suppress_script_error_logging` so you measure compute, not I/O.284- **Good benchmark: `yuurand_riviere.zplay`** — genuinely script-heavy. JIT is ~2.6× over285 the interpreter there; the ZASM optimizer adds ~1.16× to the interpreter but ~nothing on286 top of JIT.287- **Poor benchmark: `maths.zplay`** — engine/function-call-bound (~1260 calls/frame) and288 error-I/O-heavy, so JIT only shows ~1.1× and it doesn't reflect script-engine speed.289- `keys.qst` and `maths.qst` are old (2.55-era) quests; useful for `LOADD`-path coverage.290291## Where things live292293- `src/parser/` — lexer/grammar (`ffscript.lpp`/`.ypp`), AST, `BuildVisitors.cpp` (codegen).294- `src/components/zasm/` — interpreter (`eval.cpp`), opcode table (`table.cpp`).295- `src/zc/scripting/jit/` — JIT backends (`jit_x64.cpp`, `jit_a64.cpp`, `jit_wasm.cpp`,296 `jit_none.cpp`; `jit_shared.cpp` native driver, `jit.cpp` enable/precompile control);297 `zc/zasm_optimize.cpp` — optimizer; `zc/zasm_pipeline.cpp` — orchestration.298- `src/zc/ffscript.cpp` — interpreter command impls (`do_div`, `do_mod`, etc.) and299 `scripting_log_error_with_context` / `suppress_script_error_logging`.300- `tests/run_replay_tests.py`, `tests/replays.py`, `tests/update_auto_script_tests.py`,301 `tests/test_zscript.py`, `tests/test_optimize_zasm.py`, `tests/test_jit.py`.