IR JSONL emulator + debugger for ZASM IR v1.1.
zem is a self-contained compiled tool. Using it does not require Python or any scripting runtime.
zem executes ZASM IR JSONL directly (the output of zas, and an input to tools like zld/zir). It is also used as a developer-experience tool: it can trace execution, expose a CLI debugger, and emit machine-readable debugger stop events suitable for feeding into external tooling (e.g. a DAP adapter).
bin/zem /tmp/program.jsonlInputs are one or more IR JSONL files:
bin/zem file1.jsonl file2.jsonlOr stdin (useful for pipes):
compiler | bin/zem --source-name program.jsonlNotes:
- If you omit input files,
zemreads program IR JSONL from stdin (stream mode). -explicitly means “read program IR JSONL from stdin” (same as stream mode).--source-namecontrols thesource.namereported indbg_stopevents for stdin inputs.--debug-script -reads debugger commands from stdin, so it cannot be combined with reading program IR from stdin (either stream mode or-).
zem is designed to be easy to embed in pipelines. The key is to keep the streams straight:
- stdout: the guest program’s stdout (what the emulated program writes via
_outand similar). - stderr:
zemdiagnostics and (optionally) JSONL event streams likedbg_stop,trace,mem_read,mem_write.
If you want to consume machine-readable events, use --debug-events-only so stderr is clean JSONL.
Use this when you want the guest program to read runtime stdin (e.g. echo/cat programs):
printf 'hello\n' | bin/zem examples/echo.jsonl > /tmp/program.out- Read the emulated program’s output from stdout (
/tmp/program.outabove). - Unless you enable tracing/debug events, stderr is just
zemdiagnostics.
Use this when another tool produces IR JSONL and you want to run it immediately:
compiler | bin/zem --source-name program.jsonlNotes:
- When you use stream mode (or
-),zemconsumes stdin to read the program IR JSONL. That means the guest program effectively has no runtime stdin available. - If the guest needs runtime stdin, prefer Recipe 1 (program from a file) so stdin can be used for program input.
Use this when you want a pipeline-friendly run that produces only JSONL debugger stop events on stderr.
Because stdin is used for the program IR (-), drive the debugger from a script file:
cat > /tmp/zem.script <<'EOF'
blabel main
c
quit
EOF
compiler | bin/zem --debug-events-only --source-name program.jsonl --debug-script /tmp/zem.script - \
2> /tmp/zem.events.jsonl \
> /tmp/program.out- Read debugger events from stderr (
/tmp/zem.events.jsonl). - Read the guest program output from stdout (
/tmp/program.out).
These are not separate binaries. They are built into zem.
These flags select a different top-level action than “run the emulator”. In these modes, zem is operating on IR files and/or running external commands, not executing an IR program.
Which one should you use?
-
Use
--irdiffwhen you have two IR files and you want to know whether they match (and why not). -
Use
--min-irwhen you have one “bad” IR file and you want the smallest IR that still triggers a failure in some command. -
Use
--triagewhen you have many IR files and you want to run a command over all of them and group failures by signature. -
Use
--duelwhen you want to compare two commands (A vs B) across inputs and find cases where they diverge (optionally minimizing a divergent case). -
zem --irdiff ...— compare two IR JSONL files -
zem --min-ir ...— delta-minimize an IR JSONL file against a predicate -
zem --triage ...— run a command across many inputs and group failures -
zem --duel ...— differential runner (A/B) with optional minimization
Examples:
bin/zem --irdiff a.ir.jsonl b.ir.jsonl
bin/zem --min-ir input.ir.jsonl -- bin/zir --canon {}
bin/zem --triage --summary corpus/*.jsonl -- bin/zld {}
bin/zem --duel --corpus corpus --a bin/zir --canon --b bin/zir --canon --assign-idsCompares two IR JSONL files for semantic-ish equality (record-by-record after filtering).
- Inputs: exactly two
.jsonlfiles. - Output: no output on success; prints a short mismatch report on stderr when different.
- Exit codes:
0equal,1different,2error.
Useful options:
--include-idsinclude v1.1 stable record ids in comparison.--include-srcinclude v1.1src_refin comparison.--include-locincludeloc.linein comparison.
Example:
bin/zem --irdiff --include-ids a.ir.jsonl b.ir.jsonlDelta-minimizes a single IR JSONL file against a predicate command.
You provide:
- an input IR JSONL file, and
- a predicate command (after
--) that is run repeatedly.
Command templating:
- If any predicate arg is exactly
{}, it is replaced with the candidate path. - Otherwise the candidate path is appended as the last arg.
Predicate selection:
--want-exit Npredicate is satisfied iff the command exits with codeN.--want-nonzeropredicate is satisfied iff the command exits nonzero (default).
Example (minimize while still failing zir --canon):
bin/zem --min-ir --want-nonzero -o /tmp/min.jsonl input.ir.jsonl -- bin/zir --canon {}Runs an external command across many input IR files and groups failures by a stderr signature.
- Inputs: one or more
.jsonlfiles, followed by-- <cmd> .... - Output:
- A per-input JSONL stream (to stdout, or
--jsonl PATH). - Optional human summary to stderr (
--summary).
- A per-input JSONL stream (to stdout, or
The output JSONL records include (at minimum):
k: "triage"path: input pathexit: command exit codefail: booleansig: captured stderr prefix (see--max-stderr)
Example:
bin/zem --triage --want-nonzero --summary --jsonl /tmp/triage.jsonl corpus/*.jsonl -- bin/zld {}Runs two external commands (A and B) over inputs and checks whether they diverge.
Two input styles are supported:
- Explicit inputs:
bin/zem --duel --a <cmd...> --b <cmd...> -- <inputs...> - Corpus directory:
bin/zem --duel --corpus <dir> --a <cmd...> --b <cmd...>(runs over*.jsonl)
Comparison modes:
--compare exit|stdout|stderr|both(default:both)
Common workflows:
--checkis for a single input: exit1iff divergent.--out DIRwrites per-case artifacts (captured stdout/stderr/metadata).--minimizeminimizes a divergent case by invokingzem --min-ir.--zem PATHcan override whichzemexecutable is used for that re-run.
Example (A/B compare zir --canon):
bin/zem --duel --corpus corpus --compare stdout --a bin/zir --canon --b bin/zir --canon --assign-ids--traceemits per-instruction JSONL events to stderr.--trace-memaddsmem_read/mem_writeJSONL events to stderr.
zem can provide a process-like snapshot (guest argv + environment variables) to the emulated program via the zi_* proc/env syscalls.
Guest argv:
- Use
--paramsto stop option parsing; remaining args become guest argv.--is accepted as an alias for--params.
bin/zem /tmp/program.jsonl --params hello worldEnvironment snapshot:
- Default is an empty environment.
--inherit-envsnapshots the host environment (best effort) forzi_env_get_*.--clear-envclears the snapshot.--env KEY=VALadds/overrides a single entry (repeatable).
bin/zem --clear-env --env MODE=debug --env USER=alice /tmp/program.jsonlSupported proc/env syscalls:
CALL zi_argc→HL=argcCALL zi_argv_len→HL=i, HL=len_or_errCALL zi_argv_copy→HL=i, DE=out_ptr64, BC=cap, HL=written_or_errCALL zi_env_get_len→HL=key_ptr64, DE=key_len, HL=len_or_errCALL zi_env_get_copy→HL=key_ptr64, DE=key_len, BC=out_ptr64, IX=cap, HL=written_or_err
zem can record per-PC instruction hit counts and write them as JSONL.
bin/zem --coverage --coverage-out /tmp/zem.coverage.jsonl /tmp/program.jsonlPrint a quick “black holes” summary (labels with uncovered instructions):
bin/zem --coverage --coverage-blackholes 20 --coverage-out /tmp/zem.coverage.jsonl /tmp/program.jsonlMerge multiple runs (useful for CI shards or multi-phase pipelines):
bin/zem --coverage --coverage-merge /tmp/zem.coverage.jsonl \
--coverage-out /tmp/zem.coverage.merged.jsonl \
/tmp/program.jsonlNotes:
- Coverage is per IR record index (
pc). Only instruction records (kind == instr) are reported. - If the IR provides stable record ids (
idin v1.1), coverage records also includeir_idfor cross-tool correlation. - The JSONL report also includes per-label aggregates (
k == "zem_cov_label") to support black-hole analysis. - When
--debug-events-onlyis used,--coveragerequires--coverage-out(to keep stderr clean JSONL). - When
--debug-events-onlyis used,--coverage-blackholesis rejected (since it prints a human summary to stderr).
If you want a practical end-to-end prototype of a zem-guided pre-pass for native codegen, use:
bash scripts/chew_lower.sh --input build/prog.ir.jsonlWhat it does:
- Runs
zem --coverageto produce a coverage JSONL profile - Runs
zem --strip uncovered-retusing that profile to produce a stripped IR JSONL - Builds both baseline and stripped artifacts via
lower, links them with the zABI 2.5 hostlib runner, and (by default) runs both and compares stdout
Common flags:
--stdin PATHto feed guest stdin--mode uncovered-deletefor aggressive stripping--no-runto only produce build artifacts and size deltas-- --arg1 arg2to pass guest argv (forwarded to bothzemand the native runner)
zem can emit a small “value profile” focused on bulk memory ops that are good candidates for specialization.
Currently this records, per executed site, a histogram of observed BC lengths for:
FILL(HL=dst, A=byte, BC=len)LDIR(DE=dst, HL=src, BC=len)
Write a JSONL profile while running a program:
bin/zem --pgo-len-out /tmp/zem.pgo_len.jsonl /tmp/program.jsonlThe JSONL contains:
- a header record (
k == "zem_pgo_len") including a module hash - one record per hot site (
k == "zem_pgo_len_rec") with fields likem,pc,hot_len,hot_hits,total_hits,other_hits
This is intended to be consumed by lower for size/perf experiments (e.g. out-of-line helpers or guarded fast paths).
--debugstarts the interactive CLI debugger (starts paused).--break-pc Nbreaks whenpc == N(wherepcis the IR record index).--break-label Lbreaks at labelL.--break FILE:LINEbreaks at the first instruction mapped toFILE:LINEvia v1.1src/src_ref.--debug-script PATHruns debugger commands from PATH (no prompt; exit on EOF). Use-for stdin.
If you want to run a piped program and drive the debugger with a script, put the script in a file:
printf 'blabel main\nc\nquit\n' > /tmp/zem.script
compiler | bin/zem --debug-events-only --source-name program.jsonl --debug-script /tmp/zem.script ---debug-eventsemits JSONLdbg_stopevents to stderr on each debugger stop.--debug-events-onlylike--debug-events, but suppresses human-oriented debugger output (prompt/help/regs/disasm/etc) and suppresses zem lifecycle telemetry.
When zem fails (e.g. out-of-bounds memory access), it prints a human-oriented trap report to stderr including:
- the failing IR record (
pc, label/line if present) - stable record identity (
ir_id/src_ref) when present in the IR - register dump + backtrace
- a short “recent instruction” history
- for instructions that dereference memory, the base register value and its provenance (where that register was last written)
In some cases zem can also emit a targeted diagnosis if it recognizes a high-signal signature (e.g. return-slot loaded with LD32, sign-extended, then used as an address and traps out-of-bounds).
This is intended to make “trap, identify, explain” workflows fast when debugging compiler lowering bugs.
--sniffenables heuristic warnings for high-signal bug signatures (currently: return-slot pointer truncation patterns).--sniff-fatallike--sniff, but turns a warning into a failing trap.
In addition, --sniff warns when common ABI v2 (zi_*) call arguments look invalid at the boundary (e.g. a sign-extended i32 used where the ABI expects a u32 pointer/length, or obvious out-of-bounds spans). This is meant to surface “will crash later” issues early, with provenance.
The warning includes the detected pattern PCs, the suspect register’s current value, and its provenance.
--shake runs the program multiple times with small, deterministic perturbations to help surface latent bugs (e.g. uninitialized reads, reliance on a stable heap layout).
Typical usage:
bin/zem --shake --shake-iters 50 /tmp/program.jsonlTo replay a specific failing run (use the values printed in the shake header):
bin/zem --shake --shake-seed 123 --shake-start 17 --shake-iters 1 /tmp/program.jsonlUseful flags:
--shake-heap-pad N/--shake-heap-pad-max Nvary the base heap address (alignment-safe padding).--shake-poison-heapfills newly allocated heap bytes with deterministic non-zero data to surface zero-init assumptions.
zem includes a simple in-process, coverage-guided fuzzer for programs that read from guest stdin.
Key properties:
- Single binary (no external fuzzers/tools).
- Deterministic by default (seeded RNG).
- Mutates only guest stdin bytes.
Basic usage:
bin/zem --fuzz --fuzz-iters 1000 --fuzz-len 64 --fuzz-mutations 4 --fuzz-seed 1 \
--fuzz-out /tmp/zem.best.bin \
--fuzz-crash-out /tmp/zem.crash.bin \
/tmp/program.jsonlNotes:
--fuzzrequires exactly one program input file (not stream mode /-).- If you pass
--stdin PATH, that file is used as the seed input (and also makes replay easy). - If you omit
--fuzz-len, it defaults to the--stdinfile size when provided, otherwise64. --fuzz-outwrites the best coverage input found so far.--fuzz-crash-outwrites the first failing input found (if any).
Replay workflow:
# Fuzz:
bin/zem --fuzz --fuzz-iters 10000 --fuzz-crash-out /tmp/crash.bin /tmp/program.jsonl
# Replay the failure:
bin/zem --stdin /tmp/crash.bin /tmp/program.jsonlWhen a crash is found and --fuzz-crash-out is set, zem prints a one-line repro command.
For some programs, random mutation stalls on hard-to-guess branches. Enable the unlocker:
bin/zem --fuzz --fuzz-unlock --fuzz-unlock-tries 4 --fuzz-mutations 4 /tmp/program.jsonlTo see one-line predicate traces when the unlocker emits suggestions, add:
bin/zem --fuzz --fuzz-unlock-trace /tmp/program.jsonlThis prints lines like zem: unlock: pc=... cond=... take=... stdin_off=... rhs=... suggest=... to stderr.
The unlocker is best-effort and currently strongest for branches that compare a single stdin byte against an immediate constant (e.g. CP A, #66 followed by JR eq,label).
To test the unlocker in isolation (no random mutation), set:
bin/zem --fuzz --fuzz-mutations 0 --fuzz-unlock /tmp/program.jsonlRun, then interact:
bin/zem --debug /tmp/program.jsonlTypical commands:
helplist commandscontinue/crun until breakpoint/exitstep/sexecute one instructionnext/nstep over CALL (best-effort)finishrun until returning from the current frameregsshow registersbtshow call stackpcshow current pc/labelbpc Nadd breakpoint at pc Nblabel NAMEadd breakpoint at label NAMEbplist breakpoints
Scripted debugger (useful for automation):
printf 'bp\ncontinue\n' | bin/zem --debug-script - /tmp/program.jsonlWhen --debug-events (or --debug-events-only) is enabled, zem writes one JSON object per line to stderr.
Stop events have k == "dbg_stop" and include:
k: always"dbg_stop"v: schema version number (currently1)reason: stop reason string (e.g."paused","breakpoint","step","next","finish")frame: stable frame objectpc: IR record index (0-based)id: frame id (0 is the current frame)ir_id: stable IR record id from v1.1id(ornull)label: label atpc(ornull)line: source line (ornullif unavailable)col: source column (currently always1)kind: record kind ("instr","dir","label", ...)- plus one of:
m(mnemonic),d(directive),name(label/dir name) when applicable source: source identity objectname: display name (filename or"<stdin>")path: path if known (null for stdin)
src_ref: v1.1 source mapping (ornull)ref: numericsrc_reffrom the current recordsrc: resolved source record (ornullif not found)id: source record idfile: source file path (ornull)line: source line (ornull)col: source column (ornull)text: source line text (ornull)
sp: call stack depthbp: matched breakpoint metadata (ornull)pc: breakpoint pccond: breakpoint condition expression (ornull)cond_ok: condition parsed/evaluated successfullyresult: condition result (true/false)
bps: array of active breakpoint PCs (numbers)frames: call stack frames, for DAP/toolingid: frame id (stable within a stop event)pc: frame pc (current frame first)ir_id: stable IR record id from v1.1id(ornull)name: nearest label at-or-beforepc(ornull)label: label exactly atpc(ornull)line: source line (ornull)col: source column (currently always1)m: mnemonic atpcifpcpoints to an instruction (ornull)source:{name,path}as abovesrc_ref: same structure asframe.src_ref
regs: register snapshot (HL,DE,BC,IX,A)regprov: register provenance map (register -> provenance object ornull)watches: watch values (empty unless watches are configured)- each watch may include
written_bywith{pc,label,line,op}
- each watch may include
Notes:
- Fields under
frameand the top-levelk/reason/pc/label/sp/bps/regsare intended to be stable for DAP/tooling. recis included as a best-effort mirror of the current IR record and may evolve.
Example (pretty-printed; actual output is one line):
{
"k": "dbg_stop",
"v": 1,
"reason": "paused",
"frame": {"pc": 0, "id": 0, "ir_id": null, "label": null, "line": null, "col": 1, "kind": "dir", "d": "EXTERN"},
"pc": 0,
"label": null,
"sp": 0,
"bp": null,
"bps": [0],
"frames": [{"id": 0, "pc": 0, "ir_id": null, "name": null, "label": null, "line": null, "col": 1, "m": null}],
"regs": {"HL": 0, "DE": 0, "BC": 0, "IX": 0, "A": 0},
"regprov": {"HL": null, "DE": null, "BC": null, "IX": null, "A": null},
"watches": []
}