Skip to content

Performance test - #90

Merged
techomancer merged 15 commits into
techomancer:mainfrom
danifunker:performance-test
Aug 22, 2026
Merged

Performance test#90
techomancer merged 15 commits into
techomancer:mainfrom
danifunker:performance-test

Conversation

@danifunker

Copy link
Copy Markdown
Contributor
  • Add performance test (User accessible via GUI)
  • Fixed Mac Pathing bugs
  • consolidated CPU tests with performance test (testing for accuracy)
  • Performance test suite is written in C and is committed to the repo.

danifunker and others added 15 commits August 21, 2026 10:40
Benchmarking the emulator needs a time base the emulator cannot distort. CP0
Count is a *virtual* clock — mips_core.rs materializes it from a wall-clock
anchor at a `count_hz` inferred from the guest's own Compare writes — so timing
a workload with it measures the timer model as much as the workload.

Adds two 64-bit registers to the test device, plus a capability word:

  0x10/0x14  HOST_NS   host monotonic nanoseconds
  0x18/0x1C  ICOUNT    guest instructions retired (MipsCore::hot.cycles)
  0x20       CAPS      feature bits

Reading the LO half latches the whole 64-bit value and HI returns the high word
of that same latch, so a guest doing LO-then-HI cannot see a torn count.

ICOUNT is the interesting one: hot.cycles advances once per retired instruction
in both the interpreter and jitv2 (emit_increment_cycles), so "guest
instructions per host second" is directly comparable between engines — which is
the single most useful number a benchmark can produce here.

The register window widens from 16 to 64 bytes. It used to repeat every 16, and
the signature test asserted that; it now repeats every REG_WINDOW. Consequence
worth knowing for any guest built against the new header: on an emulator that
predates these registers, 0x20 aliases back onto SIGNATURE — whose low bit is
set — so a naive `caps & CAP_TIMEBASE` says yes and every timing comes back from
a frozen clock. A guest must probe, and must never *write* an unprobed offset
(0x1C aliases onto EXIT).

Also lists r5k/jitv2/mips4/opcodefusion/idle-pause in print_build_features. They
were missing, so an R5000 build announced itself as "build features: tlbvmap"
and a result recorded from it was indistinguishable from an R4400 one —
the same confusion cpu-tests/run/matrix.sh already guards against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mechanical: the base types, the SCC console API and the test-device API move
from testlib.h into a new console.h, which testlib.h then includes. console.c
and cp0.h include console.h instead. No behaviour change — `make -C cpu-tests`
produces the same binary and the same results.

The point is that bench/ (next commit) can compile this same console.c against
its own harness without dragging in the CHECK macros and the exception-record
plumbing, which belong to a *test* suite and mean nothing to a benchmark. One
SCC driver, one test-device probe, one copy.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sibling to cpu-tests/, sharing its toolchain probe, SCC console and exception
dispatcher. Where cpu-tests asks "is this instruction correct", one instruction
at a time with clean state, this asks "is it still correct after ten million of
them, and how long did that take".

46 kernels in six groups, all deterministic and autoscaled to ~250 ms per timed
run so the suite finishes in a couple of minutes:

  int    alu, alu_ilp, alu64, muldiv, branch, bitops, Dhrystone 2.1
  fpu    scalar s/d, divsqrt, transcendentals, Whetstone, LINPACK 100x100, matmul
  mem    L1/L2/DRAM pointer-chase latency, copy, fill, stream x3, unaligned, random
  img    rgb2ycbcr, convolve3x3, sharpen5x5, dct8x8, resize, rotate90, composite,
         dither, histogram
  vid    motion_est, yuv2rgb
  codec  crc32, adler32, rle, lz, huffman
  sys    tlb_hit, tlb_miss, exception, cache_flush, uncached, llsc

The imaging group is the point: an Indy shipped with a camera on the monitor
and Photoshop in the catalogue, and a 3x3 convolution stresses the cache model
in ways an ALU chain never will. The sys group is the other point — TLB refills,
exception round trips and uncached device reads are where an emulator can be
catastrophically slower than the hardware it replaces, and they are invisible to
every conventional benchmark.

Every kernel checksums its result against a golden value computed by building
the same C natively (gen/golden.c), so each run reports an accuracy percentage
next to its throughput. The oracle is an independent IEEE-754 implementation
rather than a recording of a previous emulator run, which would agree with the
emulator by construction including everywhere the emulator is wrong.

The same sources also build for the host (`make hostbench`) using the same
runner, so the native comparison is the identical kernels rather than two
benchmarks pretending to be comparable.

Two things the harness had to grow after being bitten:

- exception counting per timed run. The shared dispatcher steps over a faulting
  instruction and carries on, so a kernel that faults still reports a throughput
  — for doing something other than what it claims. mem/unaligned scored a
  plausible 871k accesses/s while taking an address error on three loads in
  four, and only the checksum gave it away.
- a real timebase probe. An emulator without the new test-device registers
  aliases CAPS onto SIGNATURE, whose low bit is set, so a naive capability check
  passes and every timing comes back frozen.

Whetstone is reported in passes per second, not MWIPS: converting needs the
"Whetstone instructions per loop" constant from a reference implementation and
that is not something this suite can verify. Dhrystone/1757 and LINPACK's
2/3n^3+2n^2 are unambiguous, so those two are in their standard units.

CI gates accuracy only. A shared runner's throughput varies by more than most
real regressions; whether the emulator computed the right answer does not.

rules/testing/benchmark-suite-gotchas.md collects what the accuracy check caught
during development — endianness, uninitialised memory, an out-of-bounds read, a
bump allocator called inside an iteration loop. Read it before adding a kernel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The CPU model and the JIT are compile-time cargo features, so comparing them
means comparing binaries. `matrix` builds a separate emulator per cell, copies
each aside before the next build overwrites target/release/iris, runs the same
guest binary on all of them, and cross-checks each result against the guest's
own PRId-derived banner — the same guard cpu-tests/run/matrix.sh has, for the
same reason.

  iris-bench run       one cell against a given emulator
  iris-bench host      the same kernels natively, for the ratio
  iris-bench matrix    every CPU x engine cell, then a comparison report
  iris-bench report    markdown / json / text
  iris-bench irix      the guest-OS suite over iris-ci (written, not yet run)
  iris-bench reference a row for data/bench_reference.json

The report answers the two questions a benchmark is actually asked, and keeps
them as two lists: where the wall clock went, and where the emulator is least
efficient per guest instruction. A kernel can dominate a run simply by being
long, and a kernel can be terrible per instruction while barely registering.

data/bench_reference.json is what the GUI will compare a user's result against.
It ships empty and empty is a normal state — a machine with no row gets
"reference statistics not gathered for this platform" rather than an invented
comparison. Deliberately a static file updated by hand: no upload, no download,
no user-writable override. Rows arrive as pull requests.

Every result now carries a suite_id (blake3 of the guest binary). Reference
figures mean something only against the exact suite that produced them: add or
change a kernel and every stored number silently becomes a comparison between
two different workloads. So the merge refuses on a mismatch, and a consumer can
treat a mismatch exactly like an empty table.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Runs iris-bench from the GUI — one run, this host's baseline, or the full
matrix — with the child's output streamed line by line into the panel. A matrix
takes tens of minutes and rebuilds the emulator once per cell, so a
progress-free spinner would be useless.

Nothing here touches a running machine: iris-bench spawns its own headless
emulator with its own bare-metal config, so this is safe to use while an IRIX
session is up.

Hidden under `appstore` alongside the CI tab, for the same reason: it spawns
cargo and a second process, and a sandboxed build can do neither. Making this
work for App Store users is a different design — see docs/gui-benchmark-plan.md,
which maps out the in-process path (iris-gui already links the emulator as a
library, so there is no subprocess to sandbox once the suite is embedded).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rules/perf/bench-first-numbers.md — the first complete matrix run, kept as a
baseline. 100% accuracy on every cell; 51.0 guest MIPS / 70.9 DMIPS on the
R4400 interpreter, 202.9 / 213.0 with jitv2. Run-to-run variation is under 1.1%,
so a 5% change between commits is real and anything under 2% is noise.

docs/jitv2_performance_analysis.md — where to spend effort next, each
recommendation corroborated against the code and carrying a confidence figure.
The load-bearing measurement is jitv2-vs-interpreter with build flags held
constant (both cells `lightning`), which isolates what the JIT itself
contributes: 5.2-6.7x on integer ALU, 2.6-5.1x on imaging and codec, but
0.98-1.01x on FP arithmetic and 0.67-0.86x on memory streaming and the
region-boundary kernels — two classes where turning the JIT on makes the guest
slower. Ends with a resume prompt naming the exact functions and line numbers,
the order to attack them in, and the three guardrails.

docs/gui-benchmark-plan.md — mapping the benchmark behind one button for App
Store users. The feature is smaller than it looks because iris-gui already runs
the emulator in-process; four things in the iris crate stand in the way, and
TestDevice::exit calling std::process::exit is the delicate one. Also settles a
question the analysis left open: the store build forces IRIS_NO_JIT=1 because
the sandbox only permits MAP_JIT pages, so it has no JIT at all — not jitv2, not
the REX3 draw-shader one — and its reference numbers must be interpreter runs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The SCC transmitter is enabled by WR5, which the PROM programs — so an image
loaded with --load-elf, which never runs the PROM, leaves it disabled. The
four-byte holding queue fills, RR0.TX_BUFFER_EMPTY goes low and never comes
back, and every character after the fourth burns the whole 100,000-iteration
spin limit in scc_putc.

Both suites print a line per test or per kernel, so this was not a rounding
error. A full bench run, r4400 lightning:

    before   117 s wall, 12.5 s in timed regions, 40/40 accuracy
    after     46 s wall, 12.4 s in timed regions, 40/40 accuracy

The entire 71-second difference was the guest waiting on a port with nothing
on the other end. cpu-tests was paying the same tax; its results are unchanged
(2160 checks passed, 2 failed, the same two pre-existing fpu/cvt_* findings).

Latch the port off the first time the spin limit is exceeded, but only when a
test device is present: that is the question that actually matters — is anything
else reading this? With a test device the host reads that instead and serial is
redundant; without one serial is the only sink there is, and a slow port beats
no output. A PROM-booted run has a working SCC, never trips the limit, and is
unaffected either way, which matters because run-prom.sh decides pass or fail by
grepping the serial log.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three changes to the guest, plus the checked-in binary that lets the emulator
run it without a cross toolchain.

**A run configuration the host can set.** A bare-metal image loaded with
--load-elf has no argv and no environment, so a new test-device register
(TESTDEV_RUN_CONFIG) carries a group mask, a target-time percentage and a
repeat count. Every field means "unrestricted" when zero — which is what an
emulator predating the register returns — so the guest reads it unconditionally.
Verified both ways: this image runs correctly on an emulator built before the
register existed. Quick mode is time_pct=30, repeats=1: about half the wall
clock for the same figures to within a couple of percent. It deliberately runs
no fewer kernels — accuracy is what this suite exists to report, and a short run
that quietly checked less would read the same 100% over less ground. The
effective configuration is echoed back on a #run line so a shortened result can
never be mistaken for a full one.

**The machine, read out of the machine.** CPU identity and revision and the
L1/L2 geometry from CP0 Config, the RAM banks from the memory controller's
MEMCFG registers — which are valid with no PROM and no POST, since
post_map_banks programs them exactly as POST would. Printed as a header and as
#cache / #memory lines. This is provenance that matters: the mem/ kernels are a
direct readout of the cache hierarchy, so two results with different L1 sizes
are not measuring the same thing, and nothing in a stored result used to say so.
The L2 *size* is reported as unknown rather than guessed — only a Triton encodes
it, nothing distinguishes a Triton from a plain R5000 at runtime, and on an
R4400 those bits mean something else entirely.

**Any MIPS CPU is named, and runs.** The suite named only the R4400 and R5000
and refused everything else, on the stated grounds that "the golden checksums
are selected by PRId". That was not true — golden.h is one flat CPU-independent
table and no kernel is CPU-gated; the real mechanism was cpu_kind == 0 matching
no kernel's CPU mask. It now names R4000/R4400 (split on revision, the standard
rule), R4600, R4700, R4650, R4300, R5000, R8000, R10000, R12000, R14000, RM5200
and RM7000 from PRId, prints anything else as MIPS-imp-0xNN, and runs either
way. Verified by presenting an R10000 and an unknown implementation to the
guest: both identified correctly and scored 40/40. cpu-tests keeps its
two-value CPU_* and its refusal, correctly — its tests are CPU-specific by
construction.

Also: an IRIS-BENCH-PLAN line announcing how many kernels will run, so a host
driving the suite can size a progress bar without growing its own denominator;
`make -C bench prebuilt` and the checked-in guest binary the emulator links in;
and the platform assumptions of the shared harness written up once, marking
which claims were tested and which are reasoning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The developer path spawns two processes — iris-bench spawns iris, which loads a
guest binary off disk and prints to a pipe. None of that survives an application
sandbox, and none of it was necessary: iris is a library, the guest image is now
linked into it, and the test device can deliver the guest's console and exit
code to a caller instead of to stdout and process::exit. The whole run becomes a
Machine on a worker thread, identical on macOS, Windows and Linux because
nothing platform-specific is left in it.

Making the emulator embeddable:

  - TestDevice::new_embedded — console into a Vec<u8> sink, EXIT into a hook,
    no dump file. The standalone --test-device path is unchanged.
  - Machine::load_elf_bytes / MipsCpu::load_elf_bytes alongside the path
    versions, so a bare-metal image can be loaded from memory.
  - Machine::new_with_testdev, and the ultra64 slot-clash process::exit(1)
    becomes a panic, which iris-gui already catches. Killing the application
    over a configuration mistake is not a choice a library gets to make.

EXIT returning is safe, and that is the whole trick. The store lands on the CPU
thread mid-instruction, which is why this looked like the delicate part. It is
not: every guest reaches EXIT through testdev_exit(), which spins forever
afterwards because a bare-metal image has nowhere to return to. So the hook
fires, the store completes, the CPU thread loops harmlessly in guest code, and
the runner stops the machine from its own thread. A hook that blocked would
deadlock against Machine::stop's join. That and the three other things that bite
are in rules/testing/embedding-the-emulator-in-process.md.

New modules:

  - benchsuite  — the guest image via include_bytes!, and its blake3 suite id.
  - bench_report — the report parser, data model and reference table, moved out
    of src/bin/iris_bench.rs. Three callers need them and only one is that
    binary; "accuracy" should have one definition.
  - bench_runner — the runner itself, with progress events, a cancel flag and a
    timeout. Also backs `iris-bench run`, so there is one implementation of
    "run the suite and parse the answer" rather than two.

iris-bench run is now in-process by default; --iris measures a different binary
in a subprocess, which is what matrix needs and what CI now passes explicitly.
Flags that only apply to the subprocess path are refused rather than silently
ignored, and `reference` refuses a --quick result: a shortened run is accurate
but imprecise, and the table is what every other machine is compared against.

cpu_model() on macOS uses sysctlbyname rather than spawning sysctl — that was
the last subprocess anywhere in the benchmark path. Not compiled for macOS
here; type-checked against the libc signature.

CI gains a prebuilt-drift check (a stale guest image against fresh goldens would
report accuracy failures to users that are not real) and a run of the embedded
path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One button, one number, on every platform including inside the App Store
sandbox. The tab used to spawn iris-bench, which spawned iris, which read an ELF
off disk — three steps, none of which survive a sandbox, which is why it was
hidden from distributed builds. It now runs iris::bench_runner in-process and is
visible everywhere; its developer half (the matrix runner, which builds one
emulator per cell because the CPU and the JIT are cargo features) folds into a
disclosure that is compiled out under feature = "appstore".

The screen leads with the three figures that stand on their own — DMIPS, guest
MIPS, and accuracy — because that is what makes an empty reference table cost
the reader almost nothing. Comparison is an enhancement, never a dependency.
Accuracy is as prominent as speed: no other emulator reports whether it computed
the right answer, so 100% tells a user something real and 97% has found a bug
worth reporting.

Caveats are stated rather than left to be discovered. A quick run says it was
one. An interpreter build says a JIT build scores roughly four times higher and
is not comparable — which matters, because the App Store build forces
IRIS_NO_JIT=1 and Cranelift's mmap+mprotect is not MAP_JIT. A kernel that took
an unexpected exception is named, because the harness steps over faults and the
kernel still reports a throughput for doing something other than what it claims.

Progress is honest: the guest announces how many kernels it will run, and the
time estimate refuses to extrapolate before four rows have finished — the first
rows are the cheap integer kernels and the last are the expensive codec ones, so
an estimate from row two is confidently wrong. The console is one click down; a
wall of monospace as the primary surface reads as "something went wrong" to a
reader who did not ask for a log.

A run is refused while a machine is running: two emulators sharing the host
would measure whatever IRIX happened to be doing, and refusing is simpler than
explaining the result afterwards. Export is an explicit rfd save panel; nothing
is uploaded.

Quick is the default. On a plain release interpreter it is 33 s against 58 s for
a full run and reports the same figures to within a couple of percent, while
giving up no accuracy at all.

docs/gui-benchmark-plan.md becomes a design-and-status record rather than a
plan, including the two things deliberately not built: the in-process host
baseline, which needs a build-system decision about making a C compiler a
requirement of the iris crate, and a measurement-spread warning for thermally
throttled laptops.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…wsed

Clicking 📁 on a disk under Disks → scsi1 opened at whatever folder was last
used, not at the folder the image lives in or is headed for.

Every picker in the app had the same shape of bug: seed the panel at the
current path's folder *if that folder happens to exist*, otherwise set nothing.
Setting nothing is not neutral — NSOpenPanel and NSSavePanel restore their own
last-used location when setDirectoryURL: is not given one, and the Windows and
GTK/portal backends have equivalent remembered defaults. So "do nothing" means
"open somewhere unrelated", and it did so in exactly the cases where it is most
annoying:

  - a disk image that does not exist yet has no existing parent folder, so
    browsing for the thing you are about to create opened anywhere but where it
    is going;
  - a bare or relative path (`scsi1.raw`) has no parent at all;
  - the managed <data_dir>/disks folder is only created when a disk is created
    there, so on a fresh install the fallback did not exist either and was
    skipped too.

Four of the pickers — every one in the New Machine dialog, which is the first
thing a new user touches — never set a directory at all.

Adds `filedialog`, which all of them now go through. It resolves the folder a
file lives in or is destined for, resolving relative names against the managed
directory rather than the process working directory (which differs between
`cargo run` and a bundled .app); walks *up* to the nearest ancestor that exists
rather than giving up, so a disk bound for ~/VMs/indy/disks/root.raw opens at
~/VMs/indy; and falls back to the app's own managed folder, creating that one,
since it is ours and it is where the UI says disks go. The invariant is that it
always returns a directory that exists, so the panel is never handed nothing —
there is a test asserting exactly that, and it caught a hole in the first draft.

Disk images, CD images and every disc in the changer anchor on the managed
disks folder; PROM, NVRAM, logs and exports anchor on the data folder.
`dialog_at_dir` opens *at* a folder rather than its parent, for the sandbox
grant flow, whose whole point is that the user just confirms the folder we
already named.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
cpu-tests.yml and bench.yml were the same machine twice over: install the MIPS
toolchain, build a guest binary, then a 2x2 CPU x engine matrix that builds an
emulator per cell and runs the suite. Both trigger on src/mips_*.rs, so any
change to the executor built *eight* emulators per push. The release profile is
fat-LTO with codegen-units=1, which makes that the dominant cost in this repo's
CI, and merging halves it — one toolchain install, one guest-build job, one
emulator per cell running both suites.

The trade is that a bench-only change now also runs cpu-tests and vice versa,
but those are the cheap halves; the emulator builds are unchanged at four, and
for the common case (an executor change, which fired both workflows anyway) it
is a straight halving.

Semantics are preserved, deliberately. In particular the two suites both run in
every cell even when the first one fails: cpu-tests exits with its failure count
and has known findings, so chaining them naively would abort before the
benchmark ran and bury its accuracy result behind an unrelated red. cpu-tests
now records its count and a single Result step turns it red, after the benchmark
has had its turn. Structural failures — a timeout, a truncated run, a cell whose
cargo features did not take and is running the wrong CPU — still fail on the
spot, because those are the harness being broken rather than the emulator being
wrong.

Worth knowing when reading a red run: cpu-tests gates on *zero* failures and
there are two outstanding, so this workflow is red until they are fixed. That is
not new — cpu-tests.yml is red today for the same reason — but it is now more
visible, and it means the gate catches nothing, since a permanently-red check
cannot go redder. Gating on "no worse than a recorded baseline" would restore
the signal. Left alone here rather than changed on the way past.

Also: iris-bench is now built with the cell's features. It embeds the guest
image and can run the suite in-process, so a default-feature build was quietly a
different machine from the cell it was labelled as.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ectory on macOS

The previous commit seeded every dialog with a starting directory and the Open
button still opened at Documents. Seeding it was not the problem.

rfd's AppKit backend joins the two settings into one path and hands the result
to setDirectoryURL: with isDirectory: YES
(backend/macos/file_dialog/panel_ffi.rs, set_path):

    set_directory("~/disks") + set_file_name("scsi1.raw")
      -> NSURL fileURLWithPath:"~/disks/scsi1.raw" isDirectory:YES
      -> file:///Users/…/disks/scsi1.raw/           <- not a directory
      -> AppKit discards it; the panel opens at its own default

So the directory was being thrown away by the file name sitting next to it, in
the old code and in the new. The Linux portal backend keeps current_folder and
current_name as separate fields and never had the problem, which is why this
only ever showed up on the Mac.

The two are mutually exclusive on macOS and the directory is what matters, so
dialogs now say which kind they are. An open panel never pre-fills a name — it
has no field to show one in, so it was pure downside — and a save panel fills it
only where that does not cost the directory. The macOS trade is a save panel
with an empty name field; getting both would mean driving NSSavePanel directly
rather than through rfd.

The decision is a plain function taking the platform behaviour as an argument,
so both branches are tested from either host rather than only from a Mac, and
the platform split is a `cfg!` value rather than a `#[cfg]` block — macOS
compiles the same code, so this cannot rot on a platform CI does not build.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Under the App Sandbox there are three kinds of location and they behave
differently. The managed folder is inside the container, so it is ours and
always readable. A folder the user has granted is fine too — macos_sandbox
::restore asserts every stored bookmark at startup and holds it for the process
lifetime, so by the time a picker opens we can stat it like anything else.

The third kind is an absolute path we hold no bookmark for: typed in, or carried
over in gui.json from another machine. Statting it fails — but with
PermissionDenied, not NotFound. The folder is very likely right there; we are
just not allowed to look. `nearest_existing` treated that the same as absent and
walked past it, sending the panel somewhere unrelated in precisely the case
where the user is browsing *because* they need to re-grant access to that
folder.

The panel is not subject to our sandbox. It is the powerbox, out of process, and
showing folders the app cannot read is the whole reason it exists — so a denial
now means "point at it and let the panel decide" rather than "pretend it is not
there". Distinguishing the two is just metadata()'s error kind; is_dir() throws
that away, which is how it went unnoticed.

Tested by stripping +x from a parent directory, which produces the same
PermissionDenied a sandbox denial does. The test asserts its own precondition
and reports a skip instead of passing vacuously if it cannot produce one (as
root, which bypasses the check). Unix-only, since it manufactures the denial
with directory permissions and Windows has no App Sandbox to imitate.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two failures on 4648a3b, two different root causes, both mine.

**Rust / Run tests.** Moving the report model and parser into
iris::bench_report left src/bin/iris_bench.rs's #[cfg(test)] module referencing
the names that moved. Nothing I ran locally compiled it: cargo build,
cargo check --bin and cargo test --lib all skip test code in a binary target.
It failed in ten seconds, before the test binaries had even finished building.

The parser tests now live with the parser. Three of them duplicated coverage
bench_report already had; two did not, and are ported rather than dropped —
parse_features, and the per-row rate/mips arithmetic including the
divide-by-zero guards. What stays in the binary is the one test about data the
binary owns, its CELLS table.

**Bare-metal suites / prebuilt check.** My drift check rebuilt the guest ELF in
CI and byte-compared it against the checked-in copy. That can never pass: the
image is compiled with -g, so DWARF records the build directory, and a runner's
/home/runner/work/iris/iris is never a developer's ~/repos/iris. Toolchain
versions differ on top of that. Two correct builds of identical source have
different bytes, so the check was testing reproducibility rather than staleness.

Replaced with a digest of the sources the image is built from — kernels,
harness, the shared cpu-tests harness, golden.h, the Makefile that carries
CFLAGS — recorded by `make prebuilt` and verified by `make check-prebuilt`.
Toolchain-independent, needs no compile, and it is exact about the thing that
must not drift: the pairing of image and source. Verified that it passes on a
clean tree and fails on a kernel edit and on a shared-harness edit.

A hash cannot tell you the image *works*; the embedded-runner test does that by
running the suite out of the binary and requiring 100% accuracy against the
golden checksums compiled into it. The two checks are complementary.

**And the gap underneath both.** rust.yml ran plain `cargo build` / `cargo test`,
which cover only the root package — iris-gui was never compiled in CI, so its 47
tests never ran and a GUI-only breakage could not turn anything red. Now
--workspace. It needs no extra system packages: wayland, X11 and GL are dlopen'd
at runtime, and the only thing iris-gui links is libasound, already installed.

Full `cargo test --workspace` now passes locally: 455 + 47.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@techomancer
techomancer merged commit 3909561 into techomancer:main Aug 22, 2026
4 of 8 checks passed
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.

2 participants