Skip to content

conformance: coverage semantics, and nine more languages measured - #3092

Merged
nesbox merged 15 commits into
mainfrom
conformance-all-languages
Oct 15, 2026
Merged

nesbox merged 15 commits into
mainfrom
conformance-all-languages

Conversation

@nesbox

@nesbox nesbox commented Oct 15, 2026 •

Copy link
Copy Markdown
Owner

The conformance table watched five of the fifteen script languages. This measures eight more — and prints what each one found.

lua is the reference, js, moon, yue and fennel were already in; added: squirrel, ruby, python, janet, scheme, miniscript, wren, gravity — 13 columns, each a probe file in that language's own form with its own prelude. Still on the not-measured line: wasm (its cart is a compiled module, not text) and forth (moved to its own change once the porting showed it is a hand table of 61 stack shapes, not a translation).

The table itself is in the Checks run — Actions → this PR's Checks workflow → the tests job → Summary (the runner prints markdown to the step summary). It lists every api × language, the divergences in full, and a coverage n/127 per language.

The one runner change: a section a language cannot express is coverage

gravity has no try/catch, so the eighteen calls the reference records as !error would end the script and fail the measurement; its file omits them and reads 109/127, with no red marks for what it cannot run. A section a language lacks is coverage now, not a divergence; a section only a language traced stays a divergence. The five languages that existed are untouched (127/127 each, same marks).

What the ports found

  • squirrel — 14 wrappers call sq_throwerror without returning it: fset(0,1) silently does nothing instead of refusing.
  • ruby — spr/map read an uninitialized mrb_value when no colour is given: a garbage colorkey, and a garbage type tag that passes mrb_array_p would read a bogus pointer. Fixed here, as are its trace and exit, which both bypassed the API table.
  • python — paint is not bound at all; poke ignores its bits argument; clip() cannot reset; pmem(index, value) does not return the old value; typed signatures refuse what lua coerces.
  • janet — ffts calls the plain fft: the smoothed spectrum is not smoothed.
  • scheme — map() with fewer than six arguments crashed the process (SIGSEGV, fixed here); clip() is refused; floats become 0.
  • miniscript — keyp is btnp (both branches call core->api.btnp); poke1 masks with & 0x1 and poke2 with & 0x3 — the bit count where the field mask belongs; paint and the fft/vqt families are absent.
  • wren — the deprecated-OVR shim ran after every tick (vbank(1), cls(0), vbank(back)), clearing VRAM bank 1 on every frame for carts that never asked for an overlay. Fixed here: the shim is now gated on the cart actually defining OVR. Also found: mset(x, y) writes a stale third argument; key(), keyp(), fft() with no arguments are declared in the dispatcher but not in the class.
  • gravity — the arity split below, and the [255]-against-[] colorkey encoding.

Cross-cutting, all re-measured per language: lua refuses a five-argument rrect/rrectb while js, ruby, python, scheme, miniscript and gravity take the documented radius=-1 exponent=2; lua's music(track) makes a stop call first and its reset bypasses the API table; the "unknown key" sentinel is 255 in ruby, janet and scheme against lua's tic_key_unknown.

Fixes included (each named in its commit)

Five binding fixes, all crash- or measurement-class: mruby's trace and exit did not go through the API table (the first made ruby unmeasurable); scheme's map() crashed the runner; mruby's spr/map read uninitialized values; wren's overlay shim cleared bank 1 every frame for carts without an overlay. Two probe-file fixes came out of the review of the ports (squirrel's !missing check could not fire; scheme's header now says it has no existence check). Everything else is a finding, not a fix — the change measures.

Checks

  • api-conformance tests/conformance/probes: exit 0, 13 languages, js 🔴 25 (unchanged), moon/yue/fennel clean.
  • ctest: 15/15.
  • A fresh-agent review of the nine ports and their preludes ran against the diff.

The planning artifacts live in the gitignored openspec/changes/conformance-all-languages/ (coverage semantics, per-language findings, porting notes).

🤖 Generated with Claude Code

nesbox and others added 12 commits October 14, 2026 21:21
…ivergence

The runner compares only the sections the reference and the language both
traced, counts the rest toward a per-language coverage printed beside its
totals, and prints a dash in a cell whose api the language ran none of — a
green mark would say it agrees about something it never ran. The five
languages measured today are untouched: js still 🔴 25, moon, yue and fennel
clean, each now reading coverage 127/127.

Ten languages are coming, and two of them (gravity, miniscript) have no error
handling: the 19 refused-call probes cannot be written there at all — a refused
call ends the script and fails the measurement, exit 2 — so without this they
would carry 19 permanent red marks burying whatever they actually disagree
about. A section only the language traced stays a divergence: that is the port
out of sync with the reference.

Verified both ways: with every file complete the marks are unchanged, and with
the two rrect sections removed from js.js it reads 119/127 with dashes in those
cells instead of two red marks.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The first of the ten remaining languages. The port is the js file in squirrel's
clothes: the same sections in the same order, every literal double-quoted
(squirrel's 'c' is a character constant — 'ab' does not compile, which the
first draft learned the hard way), and a prelude that asks the root table
whether the function exists and runs each probe under try/catch.

It measures 127/127 — every refusal the reference records, squirrel records
too — and disagrees three times:

- `fset/missing-bool`: lua refuses the call, squirrel records nothing at all.
  Fourteen wrappers (fget, fset, the vqt family and the fft family) call
  sq_throwerror without returning it, so the error is set in the VM and the
  closure still reports success: the refused call silently does nothing.
- `pix/string-arg`: lua converts the numeric string "1" to 1 and writes at
  (1,2); squirrel's number reader converts any non-number to 0 and writes at
  (0,2).
- `ttri/basic`: lua passes no chromakey array; squirrel passes a one-element
  [0], so color 0 is masked — the same divergence js has.

Findings, not fixes: the change that measures does not change the binding.

Co-Authored-By: Claude Code <noreply@anthropic.com>
mrb_trace called core->data->trace directly, skipping core->api — the only
binding whose trace does (wren's other site is the System.print hook, not
TIC.trace). Behavior is identical for a cart, tic_api_trace being the same
call with a null guard, but nothing wrapped around the API table sees ruby's
trace, the conformance record among them: every one of a ruby probe's markers
vanished, and the language could not be measured at all.

Found while porting ruby (squirrel landed first): trace is in TIC_API_LIST, so
this wrapper was the one place not calling through it.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The second of the ten. The port is the js file in ruby's clothes: the same
sections, `probe(id) do ... end`, a prelude that asks `respond_to?(name, true)`
and runs each probe under begin/rescue. It needed one fix before it could be
measured at all: mruby's trace bypassed the API table, so the markers never
arrived (e86f941, the commit before this one).

What it found, in four kinds:

- `rrect/missing-radius` and `rrectb/missing-radius`: ruby takes the documented
  five arguments ("iiiii|if" with explicit defaults) where lua refuses — the
  arity split the rrect change recorded, now measured on a second language.
- Ruby refuses where lua coerces: cls('x'), pix('1', 2, 4), mget('x', 0) and
  print() all raise in mruby and are coerced or accepted in lua — lua turns the
  string "1" into 1, ruby raises.
- `spr/basic`, `map/defaults` and `map/size`: `mrb_spr` and `mrb_map` read an
  uninitialized `mrb_value` when the color argument is absent, so the call
  arrives with a garbage one-element chroma array (128 and 65 in this run, and
  not the same values next run). That is undefined behavior, not just a
  divergence: a garbage type tag that passes mrb_array_p reads a bogus pointer.
  `mrb_ttri` initializes its chroma (0xff) and passes the same `1:[255]` js
  passes.
- `keyp/no-key`: ruby passes (tic_key)-1 where lua passes tic_key_unknown; the
  core reads both as "any key", so the behavior matches and only the recorded
  argument differs. `exit/call`: ruby's exit goes to the data callback, not the
  API table (miniscript has the same two sites), so the call is invisible to
  the record.

Findings, not fixes: the change that measures does not change the binding.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The third of wave 1, and the loudest so far. PocketPy's lambda cannot hold
statements, so every probe body is a def and the call follows it; the prelude
asks globals() and runs each body under try/except. Coverage is complete.

What it found:

- `paint` is not bound at all: both paint probes read `!missing` — the API
  simply does not exist for a python cart.
- `poke(addr, value, bits)` ignores `bits`: py_pokebits never reads its third
  argument, so `poke(32, 9, 4)` writes a whole byte and the nibble reads back 0
  where lua reads 9.
- `pmem(index, value)` does not return the old value: lua's wrapper reads and
  then writes, so the call returns the previous value and leaves two records;
  python's makes one call and returns None.
- Python refuses what lua coerces or defaults: cls('x'), pix('1', 2, 4),
  mget('x', 0), btn() with no arguments, pix(10.5, 10.5) — a float for a
  declared int — and rrect/rrectb(…, 4) — an int for the declared
  `exponent: float`. PocketPy's typed signatures raise where lua's reader
  truncates.
- `clip()` raises: py_clip reads and type-checks four arguments
  unconditionally, so the documented reset is not callable at all.
- `keyp()`'s default period is -17, as declared in the binding, where lua
  passes -1.
- An explicit `-1` colorkey is `[]` here and `[255]` in lua — the same "no
  transparent color", encoded differently (spr/scale, spr/flip-rotate,
  map/scale).
- `reset()` is recorded here and not in lua, whose reset sets
  `core->state.initialized = false` directly instead of calling the API — a
  bypass in the reference this time.
- The arity split arrives as expected: rrect/rrectb take the documented five
  arguments.

Findings, not fixes: the change that measures does not change the binding.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The first namespaced language: the api is a module, so every call is
tic80/name and the prelude runs each body under try/catch. Janet resolves
symbols at compile time and sandboxes the introspection the other languages
use, so a body can only name functions that exist — this binding has all 61,
and a language that lacked one would write !missing where the call would go.

What it found:

- `ffts` calls the plain `fft`: janet_ffts returns core->api.fft, so the
  smoothed spectrum is not smoothed. The rest of the family — fftr, fftrs and
  the eight vqt variants — calls what it names; this one is the wasm-rrect
  copy-paste class, caught by the port.
- `music`'s default sustain is true (janet_optboolean(argv, argc, 4, true))
  where the documented default, and lua's, is false.
- lua's `music(track)` stops the current track first — music(-1,0,0,false,
  false,-1,-1) — before the call the probe asked for, so its sections carry two
  records against janet's one. The extra call is the reference's, not a janet
  behavior.
- Janet refuses what lua coerces: cls('x'), pix('1', 2, 4), mget('x', 0),
  btn(), pix(10.5, 10.5) — the typed argv raises on a string, a float for an
  int, an argument that is not there.
- `key()` and `keyp()` with no arguments pass 255 as the unknown-key sentinel
  where lua passes tic_key_unknown; the core reads both as "any key", so the
  behavior matches and the recorded argument does not (ruby's finding again).
- `vbank()` — the read form lua answers with the current bank — raises.

Findings, not fixes: the change that measures does not change the binding.

Co-Authored-By: Claude Code <noreply@anthropic.com>
mrb_spr and mrb_map declared their optional color as a bare mrb_value and
tested it with mrb_array_p/mrb_fixnum_p even when the caller passed none, so
the calls arrived with whatever the stack held: the conformance record showed
garbage one-element chroma arrays (128 and 65 in one run, other values the
next), and a garbage type tag that passes mrb_array_p would read a bogus
pointer — undefined behavior, not just a divergence.

Both now start from mrb_nil_value() and treat a missing color as none: spr
passes count 0, map passes NULL — what lua passes for the same calls, so
ruby's three divergences there are gone (12 → 9) and coverage stays 127/127.

Recorded, not fixed: mrb_spr's array branch does count++ after a loop that
already counted the entries, so an array colorkey masks one palette index the
caller never listed. No probe passes an array there; found by reading.

Co-Authored-By: Claude Code <noreply@anthropic.com>
scheme_map read x, y, w, h, sx and sy with s7_car/s7_cadr/s7_list_ref before
it looked at the argument count, and every one of them is optional: a bare
map() dereferenced the empty list and took the conformance runner down with it
— SIGSEGV in scheme_map, the whole table lost, not just scheme's column.
map(0, 0, 4, 4) had the same hole in sx and sy.

Six guarded reads with the documented defaults (w=30, h=17, the rest 0), the
shape its neighbours already use. Found by the scheme port: a zero-argument
sweep of all 61 APIs showed map is the only one that crashes.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The second namespaced language: the api lives under t80::, so every call is
(t80::name ...) and the prelude runs each body under (catch #t ...), which
takes whatever the binding raises. It needed one fix before it could be
measured at all — map() crashed the runner (previous commit).

What it found:

- Scheme refuses what lua coerces: s7_integer on a boolean raises, so
  print(..., false, ...), music(..., true, ...), fset(0, 0, true) and
  sync(..., false) are refusals; on a float it answers 0, so pix(10.5, 11.9, 4)
  writes at (0,0) where lua writes at (10,11) — and line(0.4, 1.6, ...) draws
  at (0,0). The string argument lua turns into 1 arrives as 0 here too.
- `clip()` — the reset form — is refused, so the clip region cannot be reset:
  the clip/set probe ends in a refusal where lua passes the full screen.
- `pmem(1000)` is performed instead of refused: the index is not checked, and
  the out-of-range probe records a read where lua raises.
- `pmem(index, value)` does not return the old value, like python's.
- The arity split (rrect/rrectb take the documented five), the unknown-key
  sentinel (255 against lua's tic_key_unknown), lua's extra music stop call
  and lua's own reset bypass — the same shapes ruby, janet and python showed.

Findings, not fixes: the change that measures does not change the binding.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The first language with no try/catch, and the coverage change's test from the
other side — which it turned out not to need: nothing among the reference's
127 calls is refused by this binding. It coerces what lua coerces and defaults
what lua defaults, so the file carries every section and the coverage reads
127/127; the prelude answers !missing from the module's own hasIndex for the
fourteen APIs the module does not carry.

What it found:

- `keyp` is `btnp`: the function registered under "keyp" calls core->api.btnp
  in both of its branches, so a MiniScript cart asking about the keyboard is
  asking about the gamepad.
- `poke1` masks its value with & 0x1 and `poke2` with & 0x3 — the bit count
  where the field mask belongs (poke4's & 0xF is the nibble mask, which is why
  only the first two are wrong): poke1(16, 7) stores 1, poke2(24, 44) stores 0.
- Fourteen APIs are absent from the module: paint, the four fft variants and
  the eight vqt ones — their sections read !missing.
- The defaults drift from lua's: sfx note and duration default to -1 where lua
  passes 0, music's loop is false where lua's is true, print() of nothing
  prints "null" (js prints "undefined"), cls(99) wraps to 3.
- `pmem(1000)` is performed instead of refused, and `pmem(index, value)` does
  not return the old value — python's finding and scheme's again.
- The refusal family is the js shape: a call lua refuses has its missing
  arguments defaulted here, silently.

Findings, not fixes: the change that measures does not change the binding.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The first structural language: a wren cart is a class, so the probes live in
Game's constructor and the prelude is a method of it. Each body runs in a
fiber whose try() catches what the binding refuses — wren has no reflection,
so !missing has no meaning here: the class declares the whole API and a name
it lacked would not compile.

What it found:

- The deprecated-OVR shim runs after every tick: vbank(1), cls(0), vbank(back)
  — bank 1's VRAM is cleared every frame for carts that never asked for OVR.
  lua's shim is guarded by lua_isfunction, wren's by if(overline_handle),
  which is always true because the prelude defines OVR(){}. The port gives it
  a section of its own (#wren-bind/overlay-shim) so the three calls sit in a
  named row instead of inside reset/call.
- `mset(x, y)` writes a stale third argument: wren_mset reads slot 3
  unconditionally and the dispatcher maps the two-argument overload to it, so
  the tile written is whatever the previous call left in the slot — mset(6, 6)
  after mset(6, 6, 7) writes 7.
- `key()`, `keyp()`, `fft()` and `ffts()` with no arguments are refused: the
  dispatcher carries the no-argument entries, the class's foreign
  declarations do not — the dangling-entry shape rrect's five-argument
  overload has here too.
- `pix('1', 2, 4)` writes at (0,2): the string coerces to 0, as in squirrel.
- lua's own extra music stop call, the reference's shape again.

Findings, not fixes: the change that measures does not change the binding.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The first language the coverage change was built for. Gravity's binding
refuses (RETURN_ERROR) instead of coercing, and there is no try/catch, so the
eighteen calls the reference records as !error would end the script and fail
the measurement; the port carries the other 109 sections and lets the
coverage line say so — 109/127, with no red marks for what it cannot run.

What it found, in the six it can report:

- The arity split: rrect and rrectb take the documented five arguments, so
  gravity joins js, python, scheme, miniscript and ruby against lua's refusal.
- An explicit -1 colorkey arrives as [] here and [255] in lua — the same "no
  transparent color", encoded differently (spr/scale, spr/flip-rotate,
  map/scale); python's shape.
- `reset()` is recorded here and not in lua, whose reset sets
  `core->state.initialized` directly — the reference's own bypass again.

Findings, not fixes: the change that measures does not change the binding.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@nesbox nesbox self-assigned this Oct 15, 2026
nesbox and others added 3 commits October 15, 2026 21:24
The shim — vbank(1), cls(0), vbank(back) — wraps the OVR callback so a cart
written for the deprecated API can draw its overlay on bank 1. It was gated on
`if(overline_handle)`, and the prelude gives every cart an empty OVR(), so the
handle always existed: every wren cart switched to bank 1 and cleared it on
every frame, whether or not it had an overlay to draw. lua gates on
lua_isfunction, js on JS_IsFunction, squirrel on the root table; wren has no
reflection over methods, so the cart's source answers — a method the "OVR()"
call handle can resolve cannot be defined without spelling `OVR(`, so a missed
match means the shim runs when it need not (the old behavior), never that a
cart's overlay goes uncalled.

The conformance probe file drops the section it kept for the shim's three
calls, and the artificial wren-bind row leaves the table: 61 api rows again,
wren 10 → 9 divergences.

Co-Authored-By: Claude Code <noreply@anthropic.com>
…s why it has none

Both from the review of the nine ports. squirrel's check called
getroottable().rawget(name), which *throws* when the key is absent — and the
try sat below it, so a name the root table lacked would have killed the cart
instead of reading !missing. An `in` test with an index replaces it; verified
with a probe of a name that does not exist, which now traces !missing and
lets the cart live on.

scheme has no existence check at all — a name the binding lacked raises inside
the catch and reads as a refusal, not as a gap, which is janet's situation —
and its header now says so, as janet's does.

Co-Authored-By: Claude Code <noreply@anthropic.com>
mrb_exit called core->data->exit directly, skipping core->api — the same bypass
trace had (fixed in e86f941) and the last one in the binding: a ruby cart's
exit() never reached the API table, so nothing wrapped around it saw the call,
the conformance record among them. tic_api_exit is the same callback call, so
behavior is unchanged; ruby's exit/call divergence is gone (9 → 8).

miniscript's two data->exit sites stay: they are the binding's own "the
interpreter is done" signal after a tick, not the exit() API.

Co-Authored-By: Claude Code <noreply@anthropic.com>
@nesbox
nesbox merged commit 05c5f4d into main Oct 15, 2026
16 checks passed
@nesbox
nesbox deleted the conformance-all-languages branch October 15, 2026 19:47
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.

1 participant