From 8f29d05596ac4530daf485a1a09dbc4d396114fc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 26 Aug 2026 07:11:54 +0000 Subject: [PATCH] ruff_r2il: emit the 6502 partition as an MGRA v3 graph MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reachability probe measures a partition and prints a table. The partition is a graph, and the tier that draws this exact wire already ships: medcare-rs emits MGRA v3 from graph_abi.rs and a2ui-graph renders it over wgpu with a WebGL2 fallback. What was missing was the emitter, so that is what this adds — no new renderer, no new format. src/mgra.rs is the encoder: a pure writer plus a GraphBuilder that holds the address-to-index mapping the renderer needs, since the wire carries node ordinals and a walk produces addresses. It refuses three things the wire cannot express — an endpoint past the node lane, a label over its u16 prefix, a count over the header u32. The consumer is not defenceless (its edge_pairs() drops ghost ordinals so they cannot index a GPU buffer) but a dropped edge is silent data loss: the field renders slightly wrong and nothing says so. The round-trip test decodes with a2ui-graph's own parser rather than a second reader written here, added as a dev-dependency with default-features = false. Zero wgpu crates enter the lock; 30 transitive ones do. An encoder checked against your own re-read of your own encoder proves only that you were consistent. Two mistakes the tests and the run caught, both recorded where they happened: - edge()'s third element is the predicate at @11; the line style is a separate byte at @8 behind edge_kind(). The first version wrote the relation into @8 alone. A call and a data reference differ both in meaning and in how they should be drawn, so both fields carry it. - The first walk observed a node per decoded instruction, which made nodes == instrs exactly (1349/1349, 7347/7347) — an instruction dump, not a call graph. And one measurement that changed the design. The data arm fired zero times, so rather than assume why, the address operands were counted by space: of 1777, 1257 land in Unique, 354 in Register, 166 in Const — and not one Const operand is inside the image. 6502 absolute addressing lifts its address into a computed temp, so recovering an in-image data address needs constant propagation through the p-code temps, which this walk does not do and does not fake. What Const does catch is the out-of-image absolute, and the emitted addresses are exactly what that predicts: $0007, $0057, $0059, $005b — zero page. A guard now warns when either arm reports zero, because that column read 0 and looked like a measurement. This also means reachability_probe's own data_refs set is structurally empty on these images for the same reason. Its unreached+ref column is not measuring what its name says. Not touched here: its numbers are pinned in a merged PR, and quietly rewriting a measured result is how one moves. Measured: LOCODE 79 entries / 4 referenced / 40 calls, HICODE 199 / 10 / 526. Artifacts are gitignored on the same footing as the ore — a build product from a commercial game's image. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DCfrD5y19cvFc4AoyydXYv --- .gitignore | 4 + crates/ruff_r2il/Cargo.lock | 274 +++++++++++- crates/ruff_r2il/Cargo.toml | 14 + crates/ruff_r2il/examples/mgra_emit.rs | 346 +++++++++++++++ crates/ruff_r2il/src/lib.rs | 1 + crates/ruff_r2il/src/mgra.rs | 560 +++++++++++++++++++++++++ 6 files changed, 1194 insertions(+), 5 deletions(-) create mode 100644 crates/ruff_r2il/examples/mgra_emit.rs create mode 100644 crates/ruff_r2il/src/mgra.rs diff --git a/.gitignore b/.gitignore index f36a4c46916657..448c25b888e422 100644 --- a/.gitignore +++ b/.gitignore @@ -243,3 +243,7 @@ cython_debug/ # source. The CENSUS, the CONVENTION and the SLAG ledger stay tracked: those are the # codebook side (what kinds, what registers, what stayed unclassified), and they are small. .claude/harvest/r2il-6502/*.ore.tsv +# The MGRA v3 streams from `mgra_emit` — same footing as the ore above: a build product +# derived from a commercial game's image, regenerated by +# `cargo run -p ruff_r2il --features lift --example mgra_emit`. +.claude/harvest/r2il-6502/*.abi diff --git a/crates/ruff_r2il/Cargo.lock b/crates/ruff_r2il/Cargo.lock index 0b9cf8fc0983b0..e8d146d16819ac 100644 --- a/crates/ruff_r2il/Cargo.lock +++ b/crates/ruff_r2il/Cargo.lock @@ -2,6 +2,17 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "a2ui-graph" +version = "0.1.0" +dependencies = [ + "bytemuck", + "ndarray", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "anstyle" version = "1.0.14" @@ -17,6 +28,38 @@ dependencies = [ "critical-section", ] +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" +dependencies = [ + "bytemuck_derive", +] + +[[package]] +name = "bytemuck_derive" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0e56a716f1e132ff6bf4bdac1c944a3fcdc1cae65f70a4a2a1ac3b401d2d1f" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "byteorder" version = "1.5.0" @@ -33,6 +76,12 @@ dependencies = [ "shlex", ] +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + [[package]] name = "clap" version = "4.6.6" @@ -112,7 +161,7 @@ dependencies = [ "proc-macro2", "quote", "scratch", - "syn", + "syn 3.0.3", ] [[package]] @@ -126,7 +175,7 @@ dependencies = [ "indexmap", "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -144,7 +193,7 @@ dependencies = [ "indexmap", "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -189,6 +238,30 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "hash32" version = "0.2.1" @@ -243,6 +316,17 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "js-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "libsla" version = "1.2.0" @@ -281,6 +365,70 @@ dependencies = [ "scopeguard", ] +[[package]] +name = "matrixmultiply" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f607c237553f086e7043417a51df26b2eb899d3caff94e6a67592ff992fedc7" +dependencies = [ + "autocfg", + "rawpointer", +] + +[[package]] +name = "ndarray" +version = "0.17.2" +source = "git+https://github.com/AdaWorldAPI/ndarray?branch=master#c6262d63ec01d92490343b5698c3cd2727dfea7e" +dependencies = [ + "matrixmultiply", + "num-complex", + "num-integer", + "num-traits", + "paste", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + [[package]] name = "petgraph" version = "0.8.3" @@ -293,6 +441,27 @@ dependencies = [ "serde", ] +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "postcard" version = "1.1.3" @@ -354,10 +523,17 @@ dependencies = [ "thiserror", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "ruff_r2il" version = "0.1.0" dependencies = [ + "a2ui-graph", "r2il", "r2sleigh-lift", "r2ssa", @@ -374,6 +550,12 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -419,7 +601,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -428,6 +610,12 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "sleigh-compiler" version = "2.0.2" @@ -471,6 +659,17 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "syn" version = "3.0.3" @@ -508,7 +707,7 @@ checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", - "syn", + "syn 3.0.3", ] [[package]] @@ -523,6 +722,71 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "wasm-bindgen" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.77" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.127" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "winapi-util" version = "0.1.11" diff --git a/crates/ruff_r2il/Cargo.toml b/crates/ruff_r2il/Cargo.toml index 33a76b0ac5bd22..d89bc483134489 100644 --- a/crates/ruff_r2il/Cargo.toml +++ b/crates/ruff_r2il/Cargo.toml @@ -30,6 +30,16 @@ sleigh-config = { version = "1.0", optional = true, features = ["x86"] } # the same crate C64-rs uses at build time. Dev-only: the library never compiles specs. sleigh-compiler = "2.0" +# The MGRA v3 CONSUMER, used as the decoder in `mgra`'s round-trip test. Testing an encoder +# against a decoder written in the same module proves only self-consistency, which is why +# the crate that actually renders this wire is the one that reads it back. +# +# `default-features = false` drops the `wgpu` feature: measured, zero wgpu crates enter the +# lock. It is not free, though — 30 transitive crates arrive (ndarray, and the wasm-bindgen +# / web-sys family behind the consumer's browser module). Dev-only, so nothing reaches the +# library, and worth it for a falsifier that cannot agree with itself by construction. +a2ui-graph = { path = "../../../a2ui-rs/crates/a2ui-graph", default-features = false } + [features] default = [] lift = ["dep:r2sleigh-lift", "dep:sleigh-config"] @@ -58,6 +68,10 @@ required-features = ["lift"] name = "reachability_probe" required-features = ["lift"] +[[example]] +name = "mgra_emit" +required-features = ["lift"] + [lints.rust] unsafe_code = "forbid" unreachable_pub = "warn" diff --git a/crates/ruff_r2il/examples/mgra_emit.rs b/crates/ruff_r2il/examples/mgra_emit.rs new file mode 100644 index 00000000000000..6f3ff71fb67d19 --- /dev/null +++ b/crates/ruff_r2il/examples/mgra_emit.rs @@ -0,0 +1,346 @@ +//! Emit the 6502 reachability partition as an MGRA v3 graph the `a2ui-graph` field +//! renderer can draw. +//! +//! `reachability_probe` measures the partition and prints a table: `CODE [29.9 .. 59.9]` +//! reached against `DATA [0.0 .. 0.9]`, disjoint, a 33x gap. That partition is a GRAPH — +//! addresses are nodes, calls and data references are edges, reached/unreached is a colour +//! — and the display tier for exactly this wire already exists and already ships +//! (`medcare-rs` emits it from `graph_abi.rs`, `a2ui-graph` draws it over wgpu with a +//! WebGL2 fallback). This example is the missing emitter, not a new renderer. +//! +//! # Why the walk is here and not shared with the probe +//! +//! The probe's `walk` records call TARGETS (`BTreeSet`); a graph needs call EDGES — +//! which function the call came from. That is one extra piece of state threaded through the +//! queue, and the two walks are otherwise the same descent. They are kept separate +//! deliberately: the probe's numbers are pinned in `CLAUDE.md` and in a merged PR, and +//! rewriting its walk to serve a second consumer is how a measured result quietly moves. +//! If a third consumer appears, extract then — with the probe's numbers re-run as the +//! falsifier that the extraction changed nothing. +//! +//! # What "origin" means, precisely +//! +//! Every queue entry carries the address the descent STARTED from, and every call or data +//! reference found while walking is attributed to that origin. So an origin is a +//! *discovered entry point*, not a function in any linker's sense — the 6502 image has no +//! symbol table, and `collect_seeds` finds entries by linear sweep for call targets. A +//! block reached only by falling through from another entry is attributed to whichever +//! entry reached it first. That is a real limitation of descent without a symbol table, not +//! a modelling choice, and it is why the emitted graph is a call graph over discovered +//! entries rather than over functions. +//! +//! ```sh +//! cargo run -p ruff_r2il --features lift --example mgra_emit +//! ``` + +#![expect( + clippy::disallowed_methods, + reason = "not a ty crate: `System` is unavailable to this workspace-excluded crate; these are the emitter's env overrides and corpus reads" +)] + +use std::collections::{BTreeSet, VecDeque}; +use std::env; +use std::fs; +use std::path::PathBuf; + +use r2il::{R2ILOp, SpaceId}; +use r2sleigh_lift::{Disassembler, build_arch_spec, userop_map_for_arch}; +use ruff_r2il::mgra::{EdgeKind, GraphBuilder, Palette, encode}; +use sleigh_compiler::{SleighCompiler, SleighCompilerOptions}; + +const LIFT_MIN_BYTES: usize = 16; + +struct Subject { + file: &'static str, + load: u64, + out: &'static str, +} + +/// The two DECRYPTED code images. The data controls the probe uses are deliberately absent: +/// a standalone data file has no callers, so its graph is N isolated nodes and no edges — +/// nothing to look at, and nothing the emitter would be tested by. +const SUBJECTS: &[Subject] = &[ + Subject { + file: "LOCODE.unprot.bin", + load: 0x1d00, + out: "locode.abi", + }, + Subject { + file: "HICODE.unprot.bin", + load: 0x6a00, + out: "hicode.abi", + }, +]; + +fn pad(bytes: &[u8], want: usize) -> Vec { + let mut v = bytes.to_vec(); + if v.len() < want { + v.resize(want, 0); + } + v +} + +/// Linear sweep for call targets — the 6502 stand-in for a symbol table. +/// +/// The load address is NOT an entry point on these images (measured: `$00` = `BRK` on +/// several), so seeding descent with it alone reaches nothing. Every seed here is an address +/// some instruction somewhere calls. +fn collect_seeds(disasm: &Disassembler, code: &[u8], load: u64) -> BTreeSet { + let end = load + code.len() as u64; + let mut seeds = BTreeSet::new(); + let mut off = 0usize; + while off < code.len() { + let addr = load + off as u64; + match disasm.lift(&pad(&code[off..], LIFT_MIN_BYTES), addr) { + Ok(block) => { + for op in &block.ops { + if let R2ILOp::Call { target } = op + && matches!(target.space, SpaceId::Const | SpaceId::Ram) + && target.offset >= load + && target.offset < end + { + seeds.insert(target.offset); + } + } + off += (block.size as usize).max(1); + } + Err(_) => off += 1, + } + } + seeds +} + +/// Recursive descent, attributing every call and data reference to the entry it was reached +/// from. Follows fall-through, both arms of a conditional branch, and the target of an +/// unconditional branch; stops a path at a return, an indirect transfer, or a decode failure. +fn walk_into( + disasm: &Disassembler, + code: &[u8], + load: u64, + entries: &BTreeSet, + g: &mut GraphBuilder, +) -> usize { + let end = load + code.len() as u64; + let in_image = |a: u64| a >= load && a < end; + + let mut instructions = 0usize; + let mut seen: BTreeSet = BTreeSet::new(); + // `(address to decode, the entry this path started from)`. + let mut queue: VecDeque<(u64, u64)> = VecDeque::new(); + for &e in entries { + g.observe(e, Palette::Entry); + queue.push_back((e, e)); + } + + while let Some((start, origin)) = queue.pop_front() { + let mut addr = start; + loop { + if !in_image(addr) || !seen.insert(addr) { + break; + } + let off = (addr - load) as usize; + let Ok(block) = disasm.lift(&pad(&code[off..], LIFT_MIN_BYTES), addr) else { + break; + }; + let size = (block.size as usize).max(1); + instructions += 1; + + let mut stop = false; + let mut redirect: Option = None; + for op in &block.ops { + match op { + // Direct 6502 branch/call targets lift into Ram space, not Const — + // measured in harvest_6502, where a Const-only filter silently + // discarded every one. + R2ILOp::Call { target } + if matches!(target.space, SpaceId::Const | SpaceId::Ram) + && in_image(target.offset) => + { + g.edge(origin, target.offset, EdgeKind::Call); + queue.push_back((target.offset, target.offset)); + } + R2ILOp::CBranch { target, .. } + if matches!(target.space, SpaceId::Const | SpaceId::Ram) + && in_image(target.offset) => + { + // A branch stays inside its own entry: it is control flow, not a + // call, so it must not mint a node or an edge. + queue.push_back((target.offset, origin)); + } + R2ILOp::Branch { target } => { + if matches!(target.space, SpaceId::Const | SpaceId::Ram) + && in_image(target.offset) + { + redirect = Some(target.offset); + } + stop = true; + } + R2ILOp::Return { .. } | R2ILOp::BranchInd { .. } | R2ILOp::CallInd { .. } => { + stop = true; + } + // A constant address operand is a static reference. Measured on these + // images (1777 load/store address operands): 1257 land in `Unique`, + // 354 in `Register`, 166 in `Const` — and NOT ONE `Const` one is inside + // the image. 6502 absolute addressing lifts its address into a computed + // temp, so recovering an in-image data address needs constant + // propagation through the p-code temps, which this walk does not do and + // does not fake. What `Const` DOES catch is the out-of-image absolute: + // zero page, the stack page, a hardware register. Those are real + // references and they are what this arm emits. + // + // `in_image` is deliberately NOT applied. It would also be meaningless + // against a `Unique` operand, whose offset is a temp slot number rather + // than an address — 92 of them compare "inside the image" by pure + // coincidence of numbering. + R2ILOp::Load { addr: a, .. } | R2ILOp::Store { addr: a, .. } + if a.space == SpaceId::Const => + { + g.edge(origin, a.offset, EdgeKind::AbsRef); + } + _ => {} + } + } + if let Some(t) = redirect { + addr = t; + continue; + } + if stop { + break; + } + addr += size as u64; + } + } + instructions +} + +fn slaspec_path() -> PathBuf { + env::var("C64_SLASPEC") + .map(PathBuf::from) + .unwrap_or_else(|_| { + PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../c64-rs/vendor/ghidra-6502/data/languages/6502.slaspec" + )) + }) +} + +fn corpus_dir() -> PathBuf { + env::var("C64_CORPUS").map(PathBuf::from).unwrap_or_else(|_| { + PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../../adaworldapi/elite-source-code-commodore-64/4-reference-binaries/gma86-pal" + )) + }) +} + +/// Where the `.abi` streams land. Sibling of the harvest artifacts, and gitignored for the +/// same reason the ore is: a build product derived from a commercial game's image. +fn out_dir() -> PathBuf { + env::var("R2IL_MGRA_OUT") + .map(PathBuf::from) + .unwrap_or_else(|_| { + PathBuf::from(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../.claude/harvest/r2il-6502" + )) + }) +} + +fn compile_slaspec(input: &PathBuf) -> Result, String> { + let out = env::temp_dir().join(format!("ruff-r2il-mgra-{}", std::process::id())); + fs::create_dir_all(&out).map_err(|e| format!("scratch: {e}"))?; + let output = out.join("6502.sla"); + let mut c = SleighCompiler::new(SleighCompilerOptions::default()); + c.compile(input, &output) + .map_err(|e| format!("sleigh-compiler: {e}"))?; + fs::read(&output).map_err(|e| format!("read sla: {e}")) +} + +fn main() -> Result<(), Box> { + let spec_path = slaspec_path(); + if !spec_path.exists() { + eprintln!( + "SKIP: no 6502.slaspec at {} (set C64_SLASPEC)", + spec_path.display() + ); + return Ok(()); + } + let sla = compile_slaspec(&spec_path)?; + let pspec = fs::read_to_string(spec_path.with_extension("pspec"))?; + let _spec = build_arch_spec(&sla, &pspec, "6502")?; + let mut disasm = Disassembler::from_sla(&sla, &pspec, "6502")?; + disasm.set_userop_map(userop_map_for_arch("6502")); + + let dir = corpus_dir(); + let out = out_dir(); + fs::create_dir_all(&out)?; + + println!("\n=== MGRA v3 emit — the reachability partition as a graph ==="); + println!( + "{:<20} {:>8} {:>7} {:>7} {:>7} {:>8} {:>9}", + "file", "instrs", "entries", "refd", "calls", "bytes", "artifact" + ); + + for s in SUBJECTS { + let Ok(code) = fs::read(dir.join(s.file)) else { + eprintln!("SKIP {}: not in {}", s.file, dir.display()); + continue; + }; + + let seeds = collect_seeds(&disasm, &code, s.load); + let mut g = GraphBuilder::new(); + let instructions = walk_into(&disasm, &code, s.load, &seeds, &mut g); + + let (nodes, edges) = g.finish(); + // Counted per class rather than as one total: a single "edges" column cannot show + // that one of the two arms found nothing, which is exactly the failure a graph of + // only-calls or only-data-refs would be. + let entries = nodes + .iter() + .filter(|n| n.domain == Palette::Entry.byte()) + .count(); + let referenced = nodes.len() - entries; + let calls = edges + .iter() + .filter(|e| e.kind == EdgeKind::Call.byte()) + .count(); + // A refusal here is a defect in this emitter, never a property of the image, so it + // stops the run loudly rather than writing a stream the renderer would misread. + let bytes = encode(&nodes, &edges)?; + let path = out.join(s.out); + fs::write(&path, &bytes)?; + + println!( + "{:<20} {:>8} {:>7} {:>7} {:>7} {:>8} {:>9}", + s.file, + instructions, + entries, + referenced, + calls, + bytes.len(), + s.out + ); + // Both arms must fire or the graph is half a graph. This warning caught the + // first version of the walk, whose `Const && in_image` filter could not match + // anything on these images — a column that read 0 and looked like a measurement. + if referenced == 0 || calls == 0 { + eprintln!( + " WARNING {}: entries={entries} referenced={referenced} calls={calls} — an \ + arm of the walk fired zero times, so this graph is one-sided", + s.file + ); + } + } + + println!( + "\nServe a stream at a URL the field client fetches; `a2ui-graph` parses it directly.\n\ + Node colour is the `domain` byte: {} referenced · {} entry.\n\ + Edge predicate: {} call · {} absolute reference.", + Palette::Referenced.byte(), + Palette::Entry.byte(), + EdgeKind::Call.byte(), + EdgeKind::AbsRef.byte(), + ); + + Ok(()) +} diff --git a/crates/ruff_r2il/src/lib.rs b/crates/ruff_r2il/src/lib.rs index f30427f219f496..6b6491cc62d821 100644 --- a/crates/ruff_r2il/src/lib.rs +++ b/crates/ruff_r2il/src/lib.rs @@ -67,6 +67,7 @@ pub mod behavior; pub mod convention; pub mod facet; pub mod furnace; +pub mod mgra; pub mod oracle; pub mod ore; pub mod sink; diff --git a/crates/ruff_r2il/src/mgra.rs b/crates/ruff_r2il/src/mgra.rs new file mode 100644 index 00000000000000..3d6835cfa1fa06 --- /dev/null +++ b/crates/ruff_r2il/src/mgra.rs @@ -0,0 +1,560 @@ +//! MGRA v3 encoder — the graph-ABI wire the `a2ui-graph` field renderer reads. +//! +//! This module is the WRITE side of a wire whose only normative statement lives in the +//! consumer: `AdaWorldAPI/a2ui-rs`, `crates/a2ui-graph/src/abi.rs`. Nothing here re-derives +//! the layout from prose — the offsets below are transcribed from that file, and the +//! round-trip test decodes with that crate's own parser rather than with a second reader +//! written here. Diffing an encoder against your own re-read of your own encoder proves +//! only that you were consistent. +//! +//! # Why this exists at all +//! +//! The reachability probe measures a partition — `CODE [29.9 .. 59.9]` reached against +//! `DATA [0.0 .. 0.9]` — and prints it as a table. The partition is a *graph*: addresses +//! are nodes, calls and data references are edges, and the reached/unreached split is a +//! per-node colour. `medcare-rs` already emits this wire for a different substrate +//! (`crates/medcare-server/src/views/graph_abi.rs`) and already renders it through +//! `a2ui-graph`, so the display tier is not being built here — only the emitter that +//! lets a 6502 image use it. +//! +//! # The lane layout (v3), transcribed +//! +//! ```text +//! header 16 B : magic "MGRA" | version u16 | flags u16 | node_count u32 | edge_count u32 +//! node 16 B : classid u32 @0 | identity u32 @4 | vocab u8 @8 | role u8 @9 +//! | flags u8 @10 | (reserved @11) | domain u8 @12 | evidence u8 @13 +//! | reserved u16 @14 +//! edge 12 B : from u32 @0 | to u32 @4 | kind u8 @8 | role u8 @9 +//! | flags u8 @10 | predicate u8 @11 +//! tail : "MGL1" labels — 4 B magic, then per node: u16 len | len bytes UTF-8 +//! ``` +//! +//! Header flags stay **0**: labels are unconditional, and titles (bit 1), rails (bit 2) and +//! curies (bit 3) are lanes this emitter does not write. Announcing a lane that is not on +//! the wire is not a cosmetic error — the reader dispatches tails by magic and would fail +//! on whatever byte followed. +//! +//! # What the bytes mean here +//! +//! `classid` is **0** for every node, and that is the canon-sanctioned value rather than a +//! placeholder. The zero-fallback ladder reads a zero classid as *default class, no prefix +//! routing (dormant)*, which leaves `identity` alone as the discriminator — exactly true of +//! a 6502 image, where an address IS the identity and no concept has been minted for +//! "function at $1d00". Minting one to fill the field would be inventing vocabulary to +//! satisfy a struct. +//! +//! `domain` and `vocab` are opaque palette indices by the consumer's own charter — it +//! "colors by the byte and never interprets it". [`Palette`] is therefore this emitter's +//! private codebook, meaningful to whoever reads the legend and to nothing else. +//! +//! `evidence` carries in-degree, clamped at 255: how many call sites point at a node. That +//! is a measurement the walk already produces, and it is the evidence that an address is a +//! function rather than a byte someone jumped into once. + +use std::collections::BTreeMap; +use std::fmt; + +/// The 4-byte stream magic. +pub const MAGIC: [u8; 4] = *b"MGRA"; +/// The one wire version the consumer accepts. It refuses every other value rather than +/// reinterpreting offsets, so emitting anything else is emitting garbage loudly. +pub const WIRE_VERSION: u16 = 3; +/// Header length in bytes. +pub const HEADER_LEN: usize = 16; +/// Node record length in bytes. +pub const NODE_LEN: usize = 16; +/// Edge record length in bytes. +pub const EDGE_LEN: usize = 12; + +const LABEL_MAGIC: [u8; 4] = *b"MGL1"; + +/// This emitter's private palette, written into the node `domain` byte. +/// +/// The renderer colours by the raw byte and attaches no meaning to it, so these values are +/// a legend for humans reading the field, not a shared vocabulary. +/// +/// There are exactly two, because a 6502 image without a symbol table supports exactly two +/// claims: an address something CALLS, and an address something names as a constant operand +/// of a load or a store. A variant for "reached" is deliberately absent — reachedness is a +/// property of a byte, and a node per decoded instruction is an instruction dump, not a call +/// graph. The byte-level reached percentage stays what it already is: a statistic +/// `reachability_probe` prints. +/// +/// The ordering is load-bearing: [`GraphBuilder::observe`] keeps the MAXIMUM seen, so an +/// address that is both referenced and called resolves to [`Palette::Entry`]. Jump tables +/// make that a real case rather than a hypothetical one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum Palette { + /// A `Load`/`Store` names this address as a constant, and nothing calls it. + /// + /// On the Elite images every such address lands OUTSIDE the code image — zero page, + /// the stack page, or a hardware register. Which of those it is depends on the C64's + /// bank switching, which this walk does not model, so the node is not labelled with a + /// guess about it. + Referenced = 0, + /// The target of at least one `Call` — a discovered entry point. + Entry = 1, +} + +impl Palette { + /// The byte written to the wire. + #[must_use] + pub fn byte(self) -> u8 { + self as u8 + } +} + +/// This emitter's edge `kind` codebook. Same status as [`Palette`]: a legend, not a +/// contract with the renderer. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum EdgeKind { + /// A `Call` op whose target resolved inside the image. + Call = 0, + /// A `Load`/`Store` naming a constant address. + AbsRef = 1, +} + +impl EdgeKind { + /// The byte written to the wire. + #[must_use] + pub fn byte(self) -> u8 { + self as u8 + } +} + +/// One node record plus its label. +#[derive(Debug, Clone)] +pub struct Node { + /// Zero for every node this emitter writes — see the module docs on the fallback ladder. + pub classid: u32, + /// The 6502 address. The whole discriminator while `classid` is zero. + pub identity: u32, + /// Opaque codebook index; unassigned here. + pub vocab: u8, + /// Opaque role index; unassigned here. + pub role: u8, + /// Per-node flags; unassigned here. + pub flags: u8, + /// The [`Palette`] byte. + pub domain: u8, + /// In-degree, clamped at 255. + pub evidence: u8, + /// The label lane entry. Written for every node — the lane is unconditional and the + /// reader expects exactly `node_count` entries. + pub label: String, +} + +/// One edge record. `from` and `to` are **node indices**, never addresses: the renderer +/// uses them to index its own instance buffers directly. +#[derive(Debug, Clone, Copy)] +pub struct Edge { + /// Index into the node lane. + pub from: u32, + /// Index into the node lane. + pub to: u32, + /// The renderer's line style, read by the consumer's `edge_kind()`. + pub kind: u8, + /// Opaque role index; unassigned here. + pub role: u8, + /// Per-edge flags; unassigned here. + pub flags: u8, + /// The semantic relation, read by the consumer's `edge()`. + /// + /// Distinct from `kind` in the consumer — "the edge's renderer class (line style), + /// distinct from its predicate" — even though this emitter derives both from the same + /// [`EdgeKind`]. A call and a data reference differ in what they MEAN and in how they + /// should be drawn, so both fields carry it rather than one being left zero. + pub predicate: u8, +} + +/// Why a graph was refused rather than encoded. +/// +/// These are conditions worth refusing at the point of writing rather than discovering at +/// the point of drawing. The consumer is not defenceless — its `edge_pairs()` drops any +/// edge whose ordinal passes `node_count`, precisely so a ghost cannot index a GPU buffer +/// out of bounds — but a dropped edge is silent data loss: the field renders, slightly +/// wrong, and nothing says so. Refusing the stream names the offending edge instead. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum EncodeError { + /// The node or edge count does not fit the header's `u32`. + TooManyRecords { + /// Which lane overflowed. + lane: &'static str, + /// How many records were offered. + count: usize, + }, + /// An edge names a node index the node lane does not contain. + EdgeEndpointOutOfRange { + /// Position of the offending edge. + edge: usize, + /// The index it named. + endpoint: u32, + /// How many nodes exist. + node_count: usize, + }, + /// A label's UTF-8 length does not fit the lane's `u16` prefix. + LabelTooLong { + /// Position of the offending node. + node: usize, + /// The label's length in bytes. + len: usize, + }, +} + +impl fmt::Display for EncodeError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + EncodeError::TooManyRecords { lane, count } => { + write!(f, "{count} {lane} records overflow the header u32") + } + EncodeError::EdgeEndpointOutOfRange { + edge, + endpoint, + node_count, + } => write!( + f, + "edge {edge} names node {endpoint}, but the graph has {node_count} nodes" + ), + EncodeError::LabelTooLong { node, len } => { + write!( + f, + "label for node {node} is {len} bytes, over the u16 prefix" + ) + } + } + } +} + +impl std::error::Error for EncodeError {} + +/// Encode nodes and edges into one MGRA v3 byte stream. +/// +/// # Errors +/// +/// Returns [`EncodeError`] when a count overflows the header, an edge names a node index +/// outside the node lane, or a label overruns its `u16` length prefix. Each of those would +/// otherwise produce a stream the consumer accepts and then misreads. +pub fn encode(nodes: &[Node], edges: &[Edge]) -> Result, EncodeError> { + let node_count = u32::try_from(nodes.len()).map_err(|_| EncodeError::TooManyRecords { + lane: "node", + count: nodes.len(), + })?; + let edge_count = u32::try_from(edges.len()).map_err(|_| EncodeError::TooManyRecords { + lane: "edge", + count: edges.len(), + })?; + + for (i, e) in edges.iter().enumerate() { + for endpoint in [e.from, e.to] { + if endpoint >= node_count { + return Err(EncodeError::EdgeEndpointOutOfRange { + edge: i, + endpoint, + node_count: nodes.len(), + }); + } + } + } + for (i, n) in nodes.iter().enumerate() { + if u16::try_from(n.label.len()).is_err() { + return Err(EncodeError::LabelTooLong { + node: i, + len: n.label.len(), + }); + } + } + + let labels_len: usize = 4 + nodes.iter().map(|n| 2 + n.label.len()).sum::(); + let mut b = Vec::with_capacity( + HEADER_LEN + nodes.len() * NODE_LEN + edges.len() * EDGE_LEN + labels_len, + ); + + b.extend_from_slice(&MAGIC); + b.extend_from_slice(&WIRE_VERSION.to_le_bytes()); + // Labels only. Bits 1-3 would announce tails this emitter does not write. + b.extend_from_slice(&0u16.to_le_bytes()); + b.extend_from_slice(&node_count.to_le_bytes()); + b.extend_from_slice(&edge_count.to_le_bytes()); + + for n in nodes { + b.extend_from_slice(&n.classid.to_le_bytes()); + b.extend_from_slice(&n.identity.to_le_bytes()); + b.push(n.vocab); + b.push(n.role); + b.push(n.flags); + b.push(0); // reserved @11 + b.push(n.domain); + b.push(n.evidence); + b.extend_from_slice(&0u16.to_le_bytes()); // reserved @14 + } + + for e in edges { + b.extend_from_slice(&e.from.to_le_bytes()); + b.extend_from_slice(&e.to.to_le_bytes()); + b.push(e.kind); + b.push(e.role); + b.push(e.flags); + b.push(e.predicate); + } + + b.extend_from_slice(&LABEL_MAGIC); + for n in nodes { + // The length was proven to fit above; this cast cannot truncate. + let len = n.label.len() as u16; + b.extend_from_slice(&len.to_le_bytes()); + b.extend_from_slice(n.label.as_bytes()); + } + + Ok(b) +} + +/// A graph under construction, addressed by 6502 address rather than by node index. +/// +/// The renderer wants indices and the walk produces addresses, so something has to hold +/// the mapping. Doing it here keeps every caller from re-deriving it — and from the +/// off-by-one that an edge referring to an address the node lane never received would +/// otherwise become. +#[derive(Debug, Default)] +pub struct GraphBuilder { + order: Vec, + index: BTreeMap, + palette: BTreeMap, + in_degree: BTreeMap, + edges: Vec<(u64, u64, EdgeKind)>, +} + +impl GraphBuilder { + /// A builder with no nodes. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Record an address, or raise its palette if this sighting is stronger evidence. + /// + /// Monotonic by design: the same address can be seen as reached and later as a call + /// target, and the stronger reading must win regardless of arrival order. Without that, + /// a node's colour would depend on walk order, which is not a property of the program. + pub fn observe(&mut self, addr: u64, palette: Palette) { + if !self.index.contains_key(&addr) { + // A `u32` index is what the wire carries; the guard in `encode` catches an + // overflow before it can be written, so a saturating cast here is not a place + // a defect could hide. + let idx = u32::try_from(self.order.len()).unwrap_or(u32::MAX); + self.index.insert(addr, idx); + self.order.push(addr); + } + let slot = self.palette.entry(addr).or_insert(0); + *slot = (*slot).max(palette.byte()); + } + + /// Record an edge, observing both endpoints first so an edge can never name an address + /// the node lane lacks. + pub fn edge(&mut self, from: u64, to: u64, kind: EdgeKind) { + let to_palette = match kind { + EdgeKind::Call => Palette::Entry, + EdgeKind::AbsRef => Palette::Referenced, + }; + // Anything an edge leaves FROM is an entry by construction: the walk attributes + // every call and reference to the entry its descent started at. + self.observe(from, Palette::Entry); + self.observe(to, to_palette); + if kind == EdgeKind::Call { + *self.in_degree.entry(to).or_insert(0) += 1; + } + self.edges.push((from, to, kind)); + } + + /// How many distinct addresses have been observed. + #[must_use] + pub fn node_count(&self) -> usize { + self.order.len() + } + + /// How many edges have been recorded. + #[must_use] + pub fn edge_count(&self) -> usize { + self.edges.len() + } + + /// Freeze into the wire's node and edge lanes, labelling each node with its address. + #[must_use] + pub fn finish(&self) -> (Vec, Vec) { + let nodes = self + .order + .iter() + .map(|&addr| { + let domain = self.palette.get(&addr).copied().unwrap_or(0); + let deg = self.in_degree.get(&addr).copied().unwrap_or(0); + Node { + classid: 0, + identity: u32::try_from(addr).unwrap_or(u32::MAX), + vocab: 0, + role: 0, + flags: 0, + domain, + evidence: u8::try_from(deg).unwrap_or(u8::MAX), + label: format!("${addr:04x}"), + } + }) + .collect(); + let edges = self + .edges + .iter() + .filter_map(|&(from, to, kind)| { + Some(Edge { + from: *self.index.get(&from)?, + to: *self.index.get(&to)?, + kind: kind.byte(), + role: 0, + flags: 0, + predicate: kind.byte(), + }) + }) + .collect(); + (nodes, edges) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use a2ui_graph::abi::{AbiError, GraphAbi}; + + /// Three nodes, two edges, one of each kind — small enough to assert every field by + /// hand, wide enough that a transposed lane or a dropped byte moves something. + fn sample() -> (Vec, Vec) { + let mut g = GraphBuilder::new(); + g.observe(0x1d00, Palette::Entry); + g.edge(0x1d00, 0x1f2a, EdgeKind::Call); + g.edge(0x1d00, 0xd020, EdgeKind::AbsRef); + g.finish() + } + + #[test] + fn the_consumers_own_parser_reads_back_every_field() { + let (nodes, edges) = sample(); + let buf = encode(&nodes, &edges).expect("encodes"); + // Decoded by `a2ui-graph`, the crate that actually renders this wire. A second + // reader written here would only prove this module agrees with itself. + let g = GraphAbi::parse(&buf).expect("the real consumer parses it"); + + assert_eq!(g.node_count(), 3); + assert_eq!(g.edge_count(), 2); + assert_eq!(g.flags(), 0, "no optional tail is announced"); + + assert_eq!(g.address(0), (0, 0x1d00)); + assert_eq!(g.address(1), (0, 0x1f2a)); + assert_eq!(g.address(2), (0, 0xd020)); + + assert_eq!(g.domain(0), Palette::Entry.byte()); + assert_eq!(g.domain(1), Palette::Entry.byte()); + assert_eq!(g.domain(2), Palette::Referenced.byte()); + + assert_eq!(g.evidence(1), 1, "one call site points at $1f2a"); + assert_eq!(g.evidence(2), 0, "an absolute reference is not a call"); + + assert_eq!(g.label(0), "$1d00"); + assert_eq!(g.label(2), "$d020"); + + // `edge()`'s third element is the PREDICATE at @11; the line style is a separate + // byte at @8 behind `edge_kind()`. Asserting both is what caught them being + // conflated when this test was first written. + assert_eq!(g.edge(0), (0, 1, EdgeKind::Call.byte())); + assert_eq!(g.edge(1), (0, 2, EdgeKind::AbsRef.byte())); + assert_eq!(g.edge_kind(0), EdgeKind::Call.byte()); + assert_eq!(g.edge_kind(1), EdgeKind::AbsRef.byte()); + } + + #[test] + fn an_edge_naming_a_node_the_lane_lacks_is_refused() { + let (nodes, mut edges) = sample(); + // The graph is valid until this line, so the assertion below cannot pass for the + // trivial reason that the fixture was broken all along. + assert!(encode(&nodes, &edges).is_ok(), "the fixture starts valid"); + + edges[0].to = 99; + let err = encode(&nodes, &edges).expect_err("an out-of-range endpoint must be refused"); + assert_eq!( + err, + EncodeError::EdgeEndpointOutOfRange { + edge: 0, + endpoint: 99, + node_count: 3, + } + ); + } + + #[test] + fn a_label_over_the_length_prefix_is_refused() { + let (mut nodes, edges) = sample(); + nodes[0].label = "x".repeat(usize::from(u16::MAX)); + assert!( + encode(&nodes, &edges).is_ok(), + "exactly u16::MAX still fits the prefix" + ); + + nodes[0].label.push('x'); + let err = encode(&nodes, &edges).expect_err("one byte past the prefix must be refused"); + assert_eq!( + err, + EncodeError::LabelTooLong { + node: 0, + len: usize::from(u16::MAX) + 1, + } + ); + } + + #[test] + fn the_palette_rises_with_evidence_and_never_falls() { + // Walk order is not a property of the program, so the colour must not depend on it. + // A jump table's slot is loaded as data AND called; whichever the walk sees first, + // the node must come out an entry. + let mut a = GraphBuilder::new(); + a.observe(0x1d00, Palette::Referenced); + a.observe(0x1d00, Palette::Entry); + + let mut b = GraphBuilder::new(); + b.observe(0x1d00, Palette::Entry); + b.observe(0x1d00, Palette::Referenced); + + let (na, _) = a.finish(); + let (nb, _) = b.finish(); + assert_eq!(na[0].domain, Palette::Entry.byte()); + assert_eq!( + nb[0].domain, na[0].domain, + "order must not decide the colour" + ); + assert_eq!(a.node_count(), 1, "one address is one node"); + } + + #[test] + fn edges_carry_lane_indices_not_addresses() { + let (_, edges) = sample(); + // $1f2a would be 7978 as an index; the node lane has three entries. Emitting the + // address here is the mistake this asserts against, and it would sail past the + // consumer's parser into an out-of-bounds instance lookup. + assert!( + edges.iter().all(|e| e.from < 3 && e.to < 3), + "endpoints must index the node lane" + ); + assert_eq!(edges[0].to, 1); + } + + #[test] + fn a_wrong_version_byte_is_refused_by_the_consumer() { + // Guards the one constant that decides whether every offset above means anything. + let (nodes, edges) = sample(); + let mut buf = encode(&nodes, &edges).expect("encodes"); + assert!(GraphAbi::parse(&buf).is_ok(), "v3 parses"); + + buf[4] = 2; + match GraphAbi::parse(&buf) { + Err(AbiError::WrongVersion(v)) => assert_eq!(v, 2), + Err(other) => panic!("wrong refusal: {other}"), + Ok(_) => panic!("a v2 stream must not parse under a v3 reader"), + } + } +}