Extending the interpreter
The repo is built to be picked up mid-stream — by a person or by a coding agent. Start with HANDOFF.md (orientation and gotchas), then AGENTS.md (working conventions). Contribute lists the rest.
Ghostscript is the oracle
Every semantics question gets settled the same way: write a
minimal snippet, run it in gs, pin the answer in a
test comment, then implement. This workflow caught real
design errors more than once — glyph caching by encoded name, the
token-delimiter rule, rectclip clearing the current
path. When pscat and gs disagree, the disagreement gets a minimal
repro in tests/ before it gets a fix.
gs -dNODISPLAY -q <<'EOF'
newpath 100 100 moveto 5 5 10 20 rectfill
{currentpoint} stopped { (path lost) = } { (path kept) = pop pop } ifelse
EOF
Adding an operator
The src/ops/ modules are deliberately formulaic —
copying the shape of ops/arith.rs is most of the job:
- Pick the module matching the PLRM's operator grouping (or add
one); register in its
installwithop(dict, "name", func). - Operators are
fn(&mut Interp) -> Result<(), PsError>; pop operands (pop_f64,pop_int,pop+ match onValue), do the work, push results. Errors use the standard PostScript names. - If the operator mutates an array or dict, call
journal_array/journal_dictfirst sorestorecan roll it back (strings exempt by spec — read docs/dev/VM.md before touching mutators). - If it needs to run a PostScript procedure mid-operator
(callbacks, tint transforms), do not call the machine
recursively — push a
Frame::PostOpcontinuation. The Separation color implementation is the pattern. - Tests:
tests/eval.rs-style operand-stack cases for language ops, pixel tests for rendering, and a gs pin for anything with semantic latitude.
The gates
Before calling anything done:
cargo test # all suites, including gs cross-checks where installed
cargo clippy --all-targets # clean, no exceptions
cargo fmt --check
Rendering changes get pixel or golden tests
(tests/golden.rs renders the examples in both pscat
and gs and compares block-downsampled output). Commit per coherent
chunk, keep the README and docs/dev/HANDOFF.md in step with the change, and
leave a short note in notes/ for whoever picks it up next.
Which model for which task (agent sessions)
Tasks are labelled with the least-capable model expected to do them
well: Haiku for mechanical, well-specified work; Sonnet for the workhorse
middle; Opus for anything touching the object model, the machine's
control flow, or profiling-guided rewrites. The escalation rule: if a
smaller-tagged task turns out to touch object.rs or the
frame machinery, stop and record the finding in a note under
notes/ rather than improvising.
Gotchas that will bite you
- Don't reintroduce
Drop for Object— profiling showed it taxing every popped operand (~18% of a fib run). The iterative teardown lives onPsArray. - Frames holding external state must clean up in three
unwind paths: normal pop,
stop, and abort.ShowCtx::cleanupis the pattern. - Ghostscript makes font dicts read-only at
definefont— Type 3 BuildChar working storage goes in a scratch dict (every font inlib/fonts/shows the idiom). rand's low bits correlate across successive draws; art that samples small moduli should draw from the high bits (see thechancehelper in the marquee font).