name: zscript description: 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.
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.cppedit + relink is ~30s, butffscript.cpp/zelda.cppare huge and slow. scripts/run_target.pypicks the build folder fromBUILD_FOLDERenv, else the most recently built ofbuild/RelWithDebInfo,build/Release,build/Debug. Most test scripts honorBUILD_FOLDER=build/Release.- If ninja says only
copy_resourcesran but you edited a file, the .o looked up-to-date —touchthe 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
-commentedannotates each ZASM op with source line;-jsonprints diagnostics as JSON (look for"success": true).-unlinkedshows the PRE-LINK form. The linker rewrites some ops, so you will NOT seeLOADD/SPhere (you seeLOAD/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_optimizepass.
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-zasmto see the unoptimized form the JIT compiles. -frame 0makes 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-asmroute 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-precompilefor all replays exceptyuurand*andfreedom*(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 (andplayground.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), andpython tests/test_optimize_zasm.py --update(optimizer stats intests/snapshots/optimize_zasm/playground.txtshift 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 Nstops recording at frame N. For an auto/generic test,-replay-name <name>selects whichgeneric scriptruns (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> 3and setM frames <N>.-frame Ncaps normal (step-ful) replays but BREAKS step-less ones (they run 0 frames).
ZASM facts & JIT gotchas
- ZASM numbers are fixed-point ×10000 (so
5is50000).div_10000converts a fixed-point stack offset to an index. - The
inttype is a lie - it's the fixed-point type described above. - The
floattype is a lie - there is no float type, it's just an int. LOADvsLOADD:LOADuses a raw integer offset;LOADD/STOREDuse a fixed-point offset (/10000).LOADD/STOREDappear mostly in older-compiled quests (2.55-era, e.g.keys.qst); modern compiles emitLOAD.LOADI/STOREI(stack access by a register offset) are still common (e.g.eiyuu.qst).- D-register cache: the JIT caches
D0..D7in virtual registers;get_z_registerreturns the cached reg. Mutating that reg in place corrupts the cache unless you write it back viaset_z_registeror copy it first. This is a recurring bug class (fixed bycompute_stack_offsetfor LOAD/STORE-family ops andimmutable_cast_boolfor comparisons). When adding codegen, never run an in-place mutator (div_10000,cast_bool, arithmetic) directly on aget_z_registerresult you don't store back. - The
-no-optimize-zasmCI 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.ymlrun the playground subset with the optimizer off. - The
consttype 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_TOOLinsrc/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 Nruns 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-asmand read it. - Revert the
#defineand 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-jitis 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 uncaughtpageerror/RuntimeErrorfired. Exit 1 with empty stderr and stdout ending mid-frame = the renderer process died = severe memory corruption, not a wasm trap orabort()(those print a message).run_replay_testsreports 1/120 as "failed w/ exit code N". al_tracereaches captured stdout during execution, but the last lines right beforezc_exitare 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 withlsof -ti tcp:8000 | xargs kill(neverpkill -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, orcompile_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 likePUSHR 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-chunkRUNGENFRZSCR/STACKWRITEATVV_IFbail injit_compile_scriptJITs 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_tableover block ids) + a per-instancecall_stack_retsreturn stack; yield/resume saves/restoresg_idx_sp,call_stack_ret_index,wait_indexin the run-wrapper. Non-yielding functions are separate wasm functions called with nativeemitCall. - Contrast: the x64 JIT returns to
run_script_intfor CALLFUNC, and the interpreter uses aret_stackarray — so the yielder/call machinery has no x64 equivalent to diff against, and native-emitCallrecursion depth is a wasm-only concern (thoughSTACK_SIZEis large; a 5MB→64MB bump is a quick way to rule stack exhaustion in/out). - Registers:
get_z_registerhas distinct paths forD/GD/SP/SP2/does_register_use_stack(onlyMAPDATAEXDOOR/SCREENDATAEXDOOR)/other (C++ call). SP/SP2 readg_idx_spinline; evaluate a pushed operand beforeadd_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=1does 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 thezscript.mjslink (wasm-opt SAFE_HEAP+asyncify).
Benchmarking script/JIT performance
- Use
-script-timings(seesrc/zc/script_timings.h): accumulates wall-clock time spent insiderun_scriptacross 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_loggingso 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.qstandmaths.qstare old (2.55-era) quests; useful forLOADD-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.cppnative driver,jit.cppenable/precompile control);zc/zasm_optimize.cpp— optimizer;zc/zasm_pipeline.cpp— orchestration.src/zc/ffscript.cpp— interpreter command impls (do_div,do_mod, etc.) andscripting_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.
Expert Next.js App Router
Developpement
Un skill qui transforme Claude en expert Next.js App Router.
Générateur de README
Developpement
Crée des README.md professionnels et complets pour vos projets.
Rédacteur de Documentation API
Developpement
Génère de la documentation API complète au format OpenAPI/Swagger.