From 22ab973f0cf94f7ad23ea6bc60a659fbf4a55127 Mon Sep 17 00:00:00 2001 From: Kamal Tufekcic Date: Mon, 3 Aug 2026 03:59:53 +0300 Subject: [PATCH] minor tweaks --- Cargo.lock | 2 +- README.md | 15 ++-- crates/source2rosetta-core/Cargo.toml | 2 +- crates/source2rosetta-core/src/model.rs | 17 +++-- fuzz/fuzz_targets/fuzz_xref.rs | 4 +- src/abi.rs | 35 ++++++++- src/concmd.rs | 25 ++----- src/lib.rs | 19 +++-- src/main.rs | 94 ++++++++++++++++++++----- src/pipeline.rs | 8 ++- src/pulse.rs | 93 ++++++++++++------------ src/vscript.rs | 8 +-- src/xref.rs | 45 +++++------- 13 files changed, 223 insertions(+), 144 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c72ea83..8fe48f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -248,7 +248,7 @@ dependencies = [ [[package]] name = "source2rosetta-core" -version = "2.1.0" +version = "3.0.2" dependencies = [ "anyhow", "clap", diff --git a/README.md b/README.md index 6e5c809..fcddbe9 100644 --- a/README.md +++ b/README.md @@ -546,7 +546,7 @@ Releases live on the **[releases page](https://git.lo.sh/kamal/source2rosetta/re - `--target ` — the build **directory** to derive from; `produce` requires a directory and its libraries are searched by name. (Other subcommands accept a bare `.so` as well, which is how `classify-change --prev` is used.) - `--game-dir ` — must be the **`game/` subtree** of the install, the same directory layout the dedicated server is launched from. -- `--seed ` — one file bundling every derive input. The loose equivalent is `--catalogue ` plus the optional `--promotable` / `--candidates` / `--full-names` / `--extra-offsets` / `--extra-sigs`, all defaulting to empty — **so a brand-new game needs only a catalogue to start deriving.** +- `--seed ` — one file bundling every derive input. The loose equivalent is `--catalogue ` plus the optional `--promotable` / `--candidates` / `--full-names` / `--extra-offsets` / `--extra-sigs`, all defaulting to empty — **so a brand-new game needs only a catalogue to start deriving.** The two forms are mutually exclusive and the CLI says so: `--seed` supplies all six, so passing one alongside it is a usage error rather than an input silently dropped. - Corpus signal — exactly one of `--corpus-model ` (the normal path: forward-derive from the model + target binary, rolling the model N→N+1 as a sidecar) or `--corpus ` (fingerprint raw build binaries on the fly). Model-based derives are **forward-only**: the model describes history up to its newest build, so pointing one at an *older* target is not supported. @@ -669,16 +669,19 @@ configuration rather than code. A **`FunctionRecord`** opens with its `tier` (`core` / `high_confidence` / `experimental`) and the locator flattened to the top level. A virtual method ships as a bare integer `offset` (its RTTI vtable slot index); a non-virtual function as a `signature` object with the `library` it scans and a space-hex `linux` pattern with -`?` wildcards. By deriver convention an entry carries one or the other. +`?` wildcards. Usually one or the other — but **a record may carry both**, and a handful of `core` CS2 records +do, so a consumer (and the live oracle) has to judge every locator a record holds rather than the first it +finds. Two further locator keys appear where they were established: - **`class`** — for an `offset` entry, the class whose vtable the slot was measured on. A slot index alone - locates nothing, since it only means anything relative to a particular vtable. Taken from the class the - derivation actually chained the offset through, never parsed out of the entry name: a base-declared method - routinely sits in a derived class's vtable, so those are different facts and only the measured one locates. + locates nothing, since it only means anything relative to a particular vtable. It is the record's own name + class, stated explicitly: the deriver keys its slot timelines and alignment hops by that class and chains + through it, so "the class chained through" and "the class in the name" are one fact. Directly folded + offsets (the multilib ground-truth path) omit it and leave the consumer to split the name. - **`anchors`** — distinctive string literals the function references, each unique to it within its library. - Not a third locator competing with the sig-XOR-offset pair, but a supplement with a *different failure + Not a locator competing with the signature and the offset, but a supplement with a *different failure mode*: a byte signature is a snapshot of one build's codegen, while a string survives a recompile that moves instructions. Emitted alongside the signature, never instead of it. diff --git a/crates/source2rosetta-core/Cargo.toml b/crates/source2rosetta-core/Cargo.toml index 7068842..61a47d4 100644 --- a/crates/source2rosetta-core/Cargo.toml +++ b/crates/source2rosetta-core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "source2rosetta-core" -version = "2.1.0" +version = "3.0.2" edition = "2024" description = "source2rosetta's deriver-free core: canonical gamedata model + format emitters (serde-only)" license = "AGPL-3.0-only" diff --git a/crates/source2rosetta-core/src/model.rs b/crates/source2rosetta-core/src/model.rs index 6c40033..587584c 100644 --- a/crates/source2rosetta-core/src/model.rs +++ b/crates/source2rosetta-core/src/model.rs @@ -23,15 +23,18 @@ pub struct Entry { /// For a vtable-OFFSET locator: the class whose vtable the slot was measured on. /// /// Part of the locator, not decoration — a slot index alone locates nothing, since it is only meaningful - /// relative to a particular class's vtable. Taken from the class whose vtable the derivation actually - /// chained the offset through, never parsed out of the entry name: a method declared on a base class - /// routinely sits in a derived class's vtable, so the name's class and the measured class are different - /// facts and only the second one locates anything. + /// relative to a particular class's vtable. + /// + /// It is the entry name's own class, stated explicitly: the deriver keys its slot timelines and + /// alignment hops by the name's class and chains through that one, so "the class the offset was + /// chained through" and "the class in the name" are one fact rather than two. Directly folded offsets + /// (the multilib ground-truth path) carry no class at all and leave the consumer to split the name. + /// So a reader may treat this as a convenience copy — never as a second, independent attribution. #[serde(default, skip_serializing_if = "Option::is_none")] pub class: Option, /// Distinctive string literals this function references, each unique to it within its library. /// - /// NOT a third locator competing with the sig-XOR-offset pair — a supplement with a DIFFERENT failure + /// NOT a locator competing with the signature and the offset — a supplement with a DIFFERENT failure /// mode. A byte signature is a snapshot of one build's codegen; a string survives a recompile that /// moves instructions. So a consumer that can resolve anchors (ModSharp's `refs.strings`) has a /// locator that keeps working across the window between Valve shipping a build and us republishing, @@ -44,7 +47,9 @@ pub struct Entry { } impl Entry { - /// A signature-only locator (the deriver's sig-XOR-offset invariant as a constructor). + /// A signature-only locator. A convenience for the common shape, NOT an invariant — an entry may carry + /// a signature and an offset at once (the struct doc says so, and real `core` entries do), so anything + /// judging an entry must check every locator it holds rather than the first one it finds. pub fn signature(library: impl Into, linux: impl Into) -> Entry { Entry { signature: Some(Sig { diff --git a/fuzz/fuzz_targets/fuzz_xref.rs b/fuzz/fuzz_targets/fuzz_xref.rs index 9d3b157..f96e515 100644 --- a/fuzz/fuzz_targets/fuzz_xref.rs +++ b/fuzz/fuzz_targets/fuzz_xref.rs @@ -11,8 +11,8 @@ fuzz_target!(|data: &[u8]| { return; }; let xr = xref::XrefIndex::build(&img); - // Exercise the lookups over a bounded set of the discovered call targets — none may panic. - for &t in xr.call_targets().iter().take(64) { + // Exercise the lookups over a bounded set of the discovered function entries — none may panic. + for &t in xr.entries().iter().take(64) { let _ = xr.referrers(t); let _ = xr.refs_to(t); let _ = xr.containing_func(t); diff --git a/src/abi.rs b/src/abi.rs index 93709c0..fe71d5b 100644 --- a/src/abi.rs +++ b/src/abi.rs @@ -609,9 +609,12 @@ pub(crate) fn gp_slot(r: Register) -> Option { /// Registers a `call` destroys — every caller-saved GPR. A pointer that SURVIVES a call is in a /// callee-saved register, which is exactly how a real `this` is kept across one. /// -/// One list, three shapes: this array, [`caller_saved_mask`]'s bitmask, and the slot indices `concmd` -/// clears after a call. They must agree — a register missing from one and present in another is a -/// tracker that forgets a value the machine kept, or keeps one the machine destroyed. +/// ONE list, and every shape of it is derived from this array: [`caller_saved_mask`]'s bitmask, the slot +/// indices [`caller_saved_slots`] hands the `concmd` and `vscript` value trackers, and `pulse`'s two +/// invalidation loops, which read it directly. Nothing transcribes it, because a register present in one +/// copy and missing from another is a tracker that forgets a value the machine kept, or keeps one the +/// machine destroyed — and a fork retargeting this (Windows/MSVC makes RSI and RDI callee-saved) has to +/// change exactly one place. pub(crate) const CALLER_SAVED: [Register; 9] = [ Register::RAX, Register::RCX, @@ -631,6 +634,16 @@ fn caller_saved_mask() -> u32 { .fold(0u32, |m, s| m | (1 << s)) } +/// [`CALLER_SAVED`] as the `[_; 16]` slot indices the instruction readers clear after a call — the shape +/// `concmd` and `vscript` need, derived once here instead of transcribed into each. +pub(crate) fn caller_saved_slots() -> [usize; 9] { + let mut out = [0usize; 9]; + for (i, &r) in CALLER_SAVED.iter().enumerate() { + out[i] = gp_slot(r).expect("every caller-saved register is a GPR"); + } + out +} + /// The largest displacement the function reaches through the pointer it was handed in RDI — for a /// member function, how far into `this` it touches. /// @@ -766,6 +779,22 @@ pub fn this_reach(img: &CodeImage, entry: u64) -> Option { mod tests { use super::*; + #[test] + fn every_shape_of_the_caller_saved_list_agrees_with_the_array() { + // The invariant `CALLER_SAVED` documents, checked rather than asserted. Both derived shapes are + // computed from the array here, so this can only fail if someone reintroduces a hand-written + // copy — which is exactly the drift that put a raw index list in `vscript` and a second register + // array in `pulse`. + let mask = caller_saved_mask(); + let slots = caller_saved_slots(); + assert_eq!(mask.count_ones() as usize, CALLER_SAVED.len()); + assert_eq!(slots.len(), CALLER_SAVED.len()); + for (&r, &s) in CALLER_SAVED.iter().zip(slots.iter()) { + assert_eq!(gp_slot(r), Some(s), "{r:?} lost its slot index"); + assert_ne!(mask & (1 << s), 0, "{r:?} is missing from the bitmask"); + } + } + // Decode a tiny hand-assembled straight-line function and recover its shape through the REAL // per-instruction helper (`insn_effect`) + the real liveness formula — so a test can't pass while // the production path is wrong. (A single-successor chain; the fixpoint isn't exercised here.) diff --git a/src/concmd.rs b/src/concmd.rs index 52d927e..bcf4083 100644 --- a/src/concmd.rs +++ b/src/concmd.rs @@ -71,21 +71,6 @@ const RSI: usize = 6; const RDI: usize = 7; const R8: usize = 8; const R9: usize = 9; -/// Caller-saved under SysV: a call destroys any constant we were tracking in these. The `this` a -/// constructor threads through its registrations is callee-saved (rbx, r12-r15), so it survives — which -/// is what makes the member-callback form readable at all. -/// -/// DERIVED from `abi::CALLER_SAVED` rather than re-listed. It is a fixed SysV fact and was spelled out -/// three times across two readers and the ABI measurer; a register present in one list and missing from -/// another is a tracker that either forgets a value the machine kept or keeps one it destroyed. -fn clobbered() -> [usize; 9] { - let mut out = [0usize; 9]; - for (i, &r) in crate::abi::CALLER_SAVED.iter().enumerate() { - out[i] = crate::abi::gp_slot(r).expect("every caller-saved register is a GPR"); - } - out -} - /// Longest string accepted as a command name. Names are identifiers; anything longer is not one, so the /// cap doubles as a validity gate. const MAX_NAME: usize = 64; @@ -386,7 +371,11 @@ fn collect_sites( // symbolic base for any unknown-valued register, and the store arm keys that base as // `(reg, epoch, disp)`: without the bump, `rax` after two successive calls is ONE key // space shared by two objects, where same-displacement stores overwrite each other. - for c in clobbered() { + // + // Only the caller-saved nine. The `this` a constructor threads through its registrations + // is callee-saved (rbx, r12-r15) and SURVIVES, which is what makes the member-callback + // form readable at all — so the list comes from `abi`, never from a local transcription. + for c in crate::abi::caller_saved_slots() { end_life(&mut epoch, c as u8); val[c] = V::Unknown; } @@ -883,9 +872,9 @@ mod tests { // made through the FIRST could be read back as a slot of the SECOND — and the member-callback // recovery ships whatever executable pointer that merged window holds. let mut epoch = [0u32; 16]; - let clobber = clobbered(); + let clobber = crate::abi::caller_saved_slots(); let before: Vec = clobber.iter().map(|&c| epoch[c]).collect(); - for c in clobbered() { + for c in clobber { end_life(&mut epoch, c as u8); } for (i, &c) in clobber.iter().enumerate() { diff --git a/src/lib.rs b/src/lib.rs index f0fda82..82f07c7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -11,9 +11,10 @@ //! compares two builds of one named library and reads nothing game-specific.) //! - [`pipeline`] — the pure OFFLINE derivation engine (nothing here attaches to a running server): //! `corpus_model_cmd` (distill the corpus model, taking a [`pipeline::ClassScope`]), `fold_model_cmd` -//! (roll model N → N+1), `backfill_cmd` (cross-build name/offset timelines). The derive that consumes -//! a corpus source is reached through `produce::produce_cmd`, which builds one internally from its -//! `--corpus` / `--corpus-model` arguments — `CorpusSource` itself is crate-private. +//! (roll model N → N+1, over a [`pipeline::CorpusModel`] that [`pipeline::load_model`] reads off disk — +//! the only way to build its first argument), `backfill_cmd` (cross-build name/offset timelines). The +//! derive that consumes a corpus source is reached through `produce::produce_cmd`, which builds one +//! internally from its `--corpus` / `--corpus-model` arguments — `CorpusSource` itself is crate-private. //! - [`produce`] — CI orchestration + the LIVE half (everything that drives a running server): `produce_cmd` //! (the whole per-game build — boots its own bots server for validate-live + typed netvars when a game is //! given), `integration_test_cmd` (the standalone live oracle), `classify_change_cmd` / `filter_corpus_cmd` @@ -24,8 +25,10 @@ //! //! # Low-level engine (implementation detail) //! The modules below are the building blocks the API composes (ELF/RTTI/SchemaSystem readers, the fingerprint -//! metric, the sig/abi machinery, the data-parallel primitive, the name taxonomy). They stay `pub` for the fuzz -//! harness and advanced embedders, but carry NO stability promise — treat them as internal. +//! metric, the sig/abi machinery, the data-parallel primitive). They stay `pub` for the fuzz harness and +//! advanced embedders, but carry NO stability promise — treat them as internal. The name taxonomy is NOT +//! among them: it is crate-private, because the knob a fork retunes is the `GameProfile` vocabulary block +//! those predicates read, not the predicates. // ---- supported API ---- pub mod pipeline; @@ -46,11 +49,15 @@ pub mod pulse; pub mod rtti; pub mod schema; pub mod sig; -pub mod taxonomy; pub mod valvetab; pub mod vscript; pub mod xref; +// ---- crate-private ---- +// The name taxonomy: every item is `pub(crate)`, so publishing the module published an empty page. The +// per-game vocabulary it reads is the fork-retunable part, and that is already `pub` on `GameProfile`. +mod taxonomy; + // The canonical model + emitters live in the deriver-free `source2rosetta-core` crate; re-export them so // existing `source2rosetta::{model, render}` paths keep resolving. pub use source2rosetta_core::{model, render}; diff --git a/src/main.rs b/src/main.rs index c5ce3e8..24d9da0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -84,9 +84,11 @@ enum Cmd { }, /// The whole per-game build in ONE in-memory command: derive → fold → (if `--game-dir` is given) /// validate-live + typed netvars → merge → fold model, writing the release set - /// (`rosetta-.json` + `model-.json` + `manifest.json`) into --out-dir. No per-stage - /// intermediate files. **Pass `--game-dir` for a full, live-validated build; omit it for a fast - /// OFFLINE build (no server, so no live validation and a `null` schema).** + /// (`rosetta-.json` + `manifest.json`, plus `model-.json` when `--corpus-model` was the + /// source — the sidecar is that model rolled N → N+1, so a `--corpus` genesis run writes two files, + /// not three) into --out-dir. No per-stage intermediate files. **Pass `--game-dir` for a full, + /// live-validated build; omit it for a fast OFFLINE build (no server, so no live validation and a + /// `null` schema).** Produce { /// A launchable game install → the FULL build (boots a server for validate-live + typed netvars). /// OMIT for an offline build. The offline/full switch — no separate flag. @@ -98,12 +100,13 @@ enum Cmd { /// Server library to derive from; defaults to the active game's server lib. #[arg(long)] lib: Option, - /// One bundled seed (catalogue + naming sections) — the release form. Replaces the loose - /// --catalogue/--promotable/--candidates/--full-names/--extra-offsets/--extra-sigs flags. + /// One bundled seed (catalogue + naming sections) — the release form. Carries everything the loose + /// --catalogue/--promotable/--candidates/--full-names/--extra-offsets/--extra-sigs flags carry, and + /// CONFLICTS with each of them: pass one form or the other, never a mix. #[arg(long)] seed: Option, /// Function catalogue (loose form; omit when using --seed). - #[arg(long)] + #[arg(long, conflicts_with = "seed")] catalogue: Option, /// Corpus-signal source A: the raw build binaries to fingerprint on the fly. Exactly ONE of /// --corpus / --corpus-model is required (--corpus-model is the production forward-derive path). @@ -119,22 +122,22 @@ enum Cmd { target: PathBuf, /// Optional: names eligible for promotion into high_confidence (from the naming producer flow). /// Omit to promote nothing — the catalogue still derives in full. - #[arg(long)] + #[arg(long, conflicts_with = "seed")] promotable: Option, /// Optional: prefiltered per-address context for those names (`{"candidates": [...]}`). Omit for none. - #[arg(long)] + #[arg(long, conflicts_with = "seed")] candidates: Option, /// Optional: the full-slice name universe. When set, the monolith also carries an `experimental` /// tier — the least-filtered inclusion band (every name guess, graded, each with a resolvable /// locator but an UNVERIFIED name). - #[arg(long)] + #[arg(long, conflicts_with = "seed")] full_names: Option, /// Multilib ground-truth vtable offsets to fold as high_confidence — `{lib: [{name,class,slot}]}` /// (e.g. the macOS symbol transfer). Folded directly, bypassing the candidate gate. - #[arg(long)] + #[arg(long, conflicts_with = "seed")] extra_offsets: Option, /// Multilib non-virtual names to fold as sigs — `{lib: [{name,addr}]}`; `make_sig` runs per lib. - #[arg(long)] + #[arg(long, conflicts_with = "seed")] extra_sigs: Option, /// Declared C++ prototypes (`mappings/prototypes.json`) to judge against this build's measured /// register footprints. Static repo input — omit and no function carries a declared prototype. @@ -171,12 +174,12 @@ enum Cmd { /// Distill the whole corpus into a shippable model (vtable-alignment hops + reference fingerprints /// + slot timelines) so derivation needs only the model + the target binary, not the 86 GB corpus. CorpusModel { - /// One bundled seed — the release form; its catalogue section is what gets distilled. Replaces the - /// loose --catalogue (naming sections are ignored here — the model tracks catalogue names only). + /// One bundled seed — the release form; its catalogue section is what gets distilled. CONFLICTS with + /// the loose --catalogue (naming sections are ignored here — the model tracks catalogue names only). #[arg(long)] seed: Option, /// Function catalogue (loose form; omit when using --seed). - #[arg(long)] + #[arg(long, conflicts_with = "seed")] catalogue: Option, #[arg(long)] corpus: PathBuf, @@ -195,11 +198,12 @@ enum Cmd { /// The existing model N (carries the `abi_obs` window the fold re-windows). #[arg(long)] model: PathBuf, - /// One bundled seed — the release form; its catalogue section is folded. Replaces the loose --catalogue. + /// One bundled seed — the release form; its catalogue section is folded. CONFLICTS with the loose + /// --catalogue. #[arg(long)] seed: Option, /// Function catalogue (loose form; omit when using --seed). Must match the model's distill catalogue. - #[arg(long)] + #[arg(long, conflicts_with = "seed")] catalogue: Option, /// The one new build dir to fold in (holds the just-updated libserver.so etc.). #[arg(long)] @@ -309,9 +313,11 @@ fn lib_or_default(prof: &profile::GameProfile, lib: Option) -> String { } /// Resolve the catalogue for the model commands (`corpus-model`/`fold-model`) from either a `--seed` bundle -/// (release form) or a loose `--catalogue` file. The seed's catalogue section parses to the same functions as -/// the loose `needed-functions.json`, so the distilled/folded model is identical either way. When a seed is -/// given, its sections unpack under a `.seed` dir beside `out` (as `produce` does beside its out-dir). +/// (release form) or a loose `--catalogue` file — never both; `catalogue` declares the conflict, so the +/// `None` arm here means the flag was genuinely absent. The seed's catalogue section parses to the same +/// functions as the loose `needed-functions.json`, so the distilled/folded model is identical either way. +/// When a seed is given, its sections unpack under a `.seed` dir beside `out` (as `produce` does beside +/// its out-dir). fn model_catalogue( prof: &profile::GameProfile, seed: Option, @@ -393,6 +399,9 @@ fn main() -> Result<()> { bots, } => { // derive inputs come from a single --seed bundle (release form) or the loose flags (dev/verify). + // The bundle arm reads NONE of the loose bindings, which is only honest because each of them + // declares `conflicts_with = "seed"` — clap rejects the mix before dispatch rather than letting + // this arm drop an explicitly passed input on the floor. let inputs = match seed { Some(s) => unpack_seed(profile, &s, &out_dir.join(".seed"))?, None => SeedInputs { @@ -511,3 +520,50 @@ fn main() -> Result<()> { } } } + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + /// A dropped input is a silent skip, and this is the one place the CLI could produce one: the `--seed` + /// arms unpack every loose input themselves and never read the loose bindings, so an undeclared + /// conflict means `produce --seed s.json --full-names f.json` runs to exit 0 with `--full-names` + /// ignored — and a monolith with no experimental tier is exactly what a game with no naming harvest + /// legitimately ships, so no collapse floor downstream can tell the two apart. + #[test] + fn a_seed_bundle_refuses_the_loose_inputs_rather_than_ignoring_them() { + Cli::command().debug_assert(); + let parse = |argv: &[&str]| { + let full: Vec<&str> = std::iter::once("source2rosetta") + .chain(argv.iter().copied()) + .collect(); + Cli::try_parse_from(&full) + }; + let refused = |argv: &[&str]| { + assert!( + parse(argv).is_err(), + "accepted, so one of these inputs is silently dropped: {argv:?}" + ); + }; + let produce = ["produce", "--target", "t", "--out-dir", "o", "--seed", "s"]; + for flag in [ + "--catalogue", + "--promotable", + "--candidates", + "--full-names", + "--extra-offsets", + "--extra-sigs", + ] { + refused(&[&produce[..], &[flag, "x"]].concat()); + } + let model = ["--out", "o", "--seed", "s", "--catalogue", "c"]; + refused(&[&["corpus-model", "--corpus", "c"][..], &model].concat()); + refused(&[&["fold-model", "--model", "m", "--build", "b"][..], &model].concat()); + // Either form ALONE still parses — the conflict must not have made the loose form unusable. + assert!(parse(&produce).is_ok()); + let loose = ["--catalogue", "c", "--full-names", "f"]; + let bare = &produce[..produce.len() - 2]; // the same command minus `--seed s` + assert!(parse(&[bare, &loose[..]].concat()).is_ok()); + } +} diff --git a/src/pipeline.rs b/src/pipeline.rs index 17c516b..48659bd 100644 --- a/src/pipeline.rs +++ b/src/pipeline.rs @@ -4850,9 +4850,11 @@ fn derive_offsets( match chain_and_vote(&anchors, hv, target_idx) { Some((pred, conf)) if conf >= 80 => { gd.set_offset(f.name.clone(), pred as i64); - // The class the slot was chained THROUGH — the only one that makes the index meaningful. - // Recorded here rather than reconstructed later from the name, which would be a different - // (and sometimes wrong) fact: a base-declared method sits in a derived class's vtable. + // The class whose vtable this slot indexes — half the locator, since an index alone locates + // nothing. It is `class_of(f.name)`: `vtable_offset_timelines` builds `VtFunc::class` that + // way and both `hops` and `bv.fps` are keyed by it, so the class the chain walked and the + // class in the name are one fact, not two. Emitting it saves the consumer a name split; it + // does not add information the name lacks. gd.set_class(f.name.clone(), f.class.clone()); off_ok += 1; } diff --git a/src/pulse.rs b/src/pulse.rs index a1b540a..bbb500c 100644 --- a/src/pulse.rs +++ b/src/pulse.rs @@ -24,6 +24,9 @@ //! declares. A layout change yields FEWER signatures, never wrong ones, and the profile floor turns //! "fewer" into a failed release. +// Registers whose value a call destroys. The ONE list in `abi`, not a second copy of it — both loops +// below that invalidate across a call read it directly. +use crate::abi::CALLER_SAVED; use crate::elf::CodeImage; use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, Mnemonic, OpKind, Register}; use std::collections::{BTreeMap, HashMap, HashSet}; @@ -83,20 +86,6 @@ struct Trace { ret: Option<(u64, u64)>, } -/// Registers whose value a call destroys. Anything else the pass cannot evaluate is invalidated as the -/// instruction that writes it is seen, so the default is always "unknown" rather than "stale". -const CALLER_SAVED: [Register; 9] = [ - Register::RAX, - Register::RCX, - Register::RDX, - Register::RSI, - Register::RDI, - Register::R8, - Register::R9, - Register::R10, - Register::R11, -]; - fn full(r: Register) -> Register { if r.is_gpr() { r.full_register() } else { r } } @@ -362,26 +351,6 @@ fn record(img: &CodeImage, accessor: u64) -> Option { }) } -/// Every CODE pointer an accessor's initializer stores into its record region, with the region base: -/// `(base, [(address written, code address written)])`. -/// -/// A DIAGNOSTIC, and deliberately not part of any shipped artifact. The parameter records carry a -/// function pointer whose ROLE is not established — the record reader already has to look at these in -/// order to reject them as parameter names, so exposing them costs nothing and lets that question be -/// settled against evidence collected elsewhere (a runtime call-edge trace) rather than guessed. Nothing -/// here interprets them; they are raw measurements. -pub fn code_stores(img: &CodeImage, accessor: u64) -> Option<(u64, Vec<(u64, u64)>)> { - let r = record(img, accessor)?; - let stores = - r.t.writes - .iter() - .filter(|&(a, _)| *a >= r.base) - .filter(|&(_, p)| img.is_code(*p)) - .map(|(&a, &p)| (a, p)) - .collect(); - Some((r.base, stores)) -} - /// The spacings at which this record's `count` names could sit, given that element 0's name is at /// `base + 8` and the array is contiguous. Usually one; a record carrying a second identifier-shaped /// string of its own offers more, which is why the stride is settled per IMAGE and not per record. @@ -582,10 +551,10 @@ const SHIM_SLOTS: [Register; 6] = [ /// What an invocation shim was measured to read, and therefore what a caller has to supply. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ShimReads { - /// The argument slots actually read, named — `rcx`, `r8`, `stack0`. + /// The argument slots actually read, named — `rcx`, `r8`, `stack0`. The argument array (`r8`) is + /// stated here and nowhere else: reading it is the ordinary case and constrains a caller in no way, + /// so it needs no flag of its own beside the three that do. pub reads: Vec<&'static str>, - /// Does it read the argument array (`r8`)? - pub args: bool, /// Does it read the output sink (the first stack slot)? True for exactly the bindings that declare a /// return, measured across both games with no exceptions. pub sink: bool, @@ -634,6 +603,24 @@ pub fn record_region(img: &CodeImage, accessor: u64) -> Option<(u64, u64)> { (r.base != 0).then_some((r.base, r.count)) } +/// What a read of one argument slot demands of a HOST caller. +/// +/// The argument array (`r8`) demands nothing — the caller builds it, so reading it is the ordinary case +/// and `reads` already states it. The Pulse context (`rcx`) is VM-owned and cannot be supplied at all. +/// Everything else is a slot the caller would otherwise pass null. +/// +/// A named arm rather than a fall-through for `r8` specifically: dropping it into the `_` catch-all would +/// mark every ordinary binding as needing a slot no host can fill, retiring the entire `args-only` +/// callable tier — a collapse that reads as "this build has no callable bindings", which is a legitimate +/// answer for a game and therefore invisible. +fn slot_need(r: Register, out: &mut ShimReads) { + match r { + Register::RCX => out.context = true, + Register::R8 => {} + _ => out.other = true, + } +} + /// Measure which of a shim's seven arguments it reads. /// /// Reachable instructions in ADDRESS order, which needs two guards that cost real time to find: @@ -725,11 +712,7 @@ pub fn shim_reads(img: &CodeImage, entry: u64) -> Option { { if live.contains_key(r) { out.reads.push(name); - match *r { - Register::RCX => out.context = true, - Register::R8 => out.args = true, - _ => out.other = true, - } + slot_need(*r, &mut out); } } if sink { @@ -802,26 +785,26 @@ mod tests { let ctx = ShimReads { context: true, sink: true, - args: true, + reads: vec!["rcx", "r8", "stack0"], ..Default::default() }; assert_eq!(ctx.needs(), "pulse-context"); let other = ShimReads { other: true, sink: true, - args: true, + reads: vec!["rdi", "r8", "stack0"], ..Default::default() }; assert_eq!(other.needs(), "other-slots"); let sink = ShimReads { sink: true, - args: true, + reads: vec!["r8", "stack0"], ..Default::default() }; assert_eq!(sink.needs(), "output-sink"); // The callable tier: the argument array and nothing else. let only = ShimReads { - args: true, + reads: vec!["r8"], ..Default::default() }; assert_eq!(only.needs(), "args-only"); @@ -829,6 +812,24 @@ mod tests { assert_eq!(ShimReads::default().needs(), "args-only"); } + #[test] + fn reading_the_argument_array_leaves_a_shim_host_callable() { + // Asserted against the shipped rule rather than a copy of it. `r8` is the argument array the + // CALLER builds, so a read of it must impose nothing; the arm exists only to keep it out of the + // catch-all, where it would mark every ordinary binding uncallable at once. + let mut r8 = ShimReads::default(); + slot_need(Register::R8, &mut r8); + assert_eq!(r8.needs(), "args-only"); + let mut rcx = ShimReads::default(); + slot_need(Register::RCX, &mut rcx); + assert_eq!(rcx.needs(), "pulse-context"); + for r in [Register::RDI, Register::RSI, Register::RDX, Register::R9] { + let mut o = ShimReads::default(); + slot_need(r, &mut o); + assert_eq!(o.needs(), "other-slots", "{r:?} is a slot a host must fill"); + } + } + #[test] fn pval_void_is_negative_one_and_still_a_type() { assert!(valid_pval(0)); // PVAL_BOOL diff --git a/src/vscript.rs b/src/vscript.rs index 82a2ad5..50d3793 100644 --- a/src/vscript.rs +++ b/src/vscript.rs @@ -188,9 +188,6 @@ fn xmm(r: Register) -> Option { .filter(|i| *i < 16) } -/// Registers a call clobbers, so a value cannot survive across one and be attributed to the wrong record. -const CLOBBER: [usize; 9] = [0, 1, 2, 6, 7, 8, 9, 10, 11]; - /// A field of a particular record: which record, and the displacement within it. type Slot = (u32, i64); @@ -278,7 +275,10 @@ pub fn vscript_functions(img: &CodeImage) -> Vec { dec.decode_out(&mut insn); if insn.flow_control() == FlowControl::Call { - for c in CLOBBER { + // Registers a call clobbers, so a value cannot survive one and be attributed to the wrong + // record. Taken from `abi`, not transcribed as raw GPR indices — a second copy of a fixed + // SysV fact is a copy that can drift. + for c in crate::abi::caller_saved_slots() { val[c] = V::Unknown; scaled[c] = false; recid[c] = None; diff --git a/src/xref.rs b/src/xref.rs index c7fdf0e..66a6983 100644 --- a/src/xref.rs +++ b/src/xref.rs @@ -13,13 +13,12 @@ //! the next avoids the misalignment a blind section-wide linear sweep suffers on data/padding. use crate::elf::CodeImage; -use iced_x86::{Decoder, DecoderOptions, FlowControl, Instruction, OpKind}; +use iced_x86::{Decoder, DecoderOptions, Instruction, OpKind}; use std::collections::HashMap; pub struct XrefIndex { entries: Vec, // sorted, de-duped function entry addresses refs: HashMap>, // referenced VA -> source instruction VAs - call_targets: Vec, // sorted, de-duped near-call targets } impl XrefIndex { @@ -30,36 +29,29 @@ impl XrefIndex { // Disassemble each function's [start, next) range independently across threads — this is the // single biggest decode in the tool and the ranges vary wildly in size, so the atomic work - // scheduler load-balances them. Each task returns its (ref-pair, call-target) deltas; merging - // them in entry order (parallel_map preserves input order) reproduces the serial build - // byte-for-byte: refs[t] receives its srcs in the same (ascending entry, then instruction) - // order and call_targets is sorted afterwards. - type EntryData = (Vec<(u64, u64)>, Vec); + // scheduler load-balances them. Each task returns its ref-pair deltas; merging them in entry + // order (parallel_map preserves input order) reproduces the serial build byte-for-byte, because + // refs[t] receives its srcs in the same (ascending entry, then instruction) order. let idxs: Vec = (0..entries.len()).collect(); - let per_entry: Vec = + let per_entry: Vec> = crate::par::parallel_map(&idxs, crate::par::default_threads(None), |&i| { let start = entries[i]; let end = entries.get(i + 1).copied().unwrap_or(u64::MAX); let Some(code) = img.code_range(start, end) else { - return (Vec::new(), Vec::new()); + return Vec::new(); }; let mut ref_pairs: Vec<(u64, u64)> = Vec::new(); - let mut call_targets: Vec = Vec::new(); let mut insn = Instruction::default(); let mut dec = Decoder::with_ip(64, code, start, DecoderOptions::NONE); while dec.can_decode() { dec.decode_out(&mut insn); let src = insn.ip(); - // Near call/jmp: the target is code; call targets double as function entries. + // Near call/jmp: the target is code. if matches!( insn.op0_kind(), OpKind::NearBranch16 | OpKind::NearBranch32 | OpKind::NearBranch64 ) { - let t = insn.near_branch_target(); - ref_pairs.push((t, src)); - if insn.flow_control() == FlowControl::Call { - call_targets.push(t); - } + ref_pairs.push((insn.near_branch_target(), src)); } // RIP-relative memory operand: a reference to a string / global / code pointer. if insn.is_ip_rel_memory_operand() { @@ -67,24 +59,16 @@ impl XrefIndex { ref_pairs.push((t, src)); } } - (ref_pairs, call_targets) + ref_pairs }); let mut refs: HashMap> = HashMap::new(); - let mut call_targets = Vec::new(); - for (ref_pairs, cts) in per_entry { + for ref_pairs in per_entry { for (t, src) in ref_pairs { refs.entry(t).or_default().push(src); } - call_targets.extend(cts); - } - call_targets.sort_unstable(); - call_targets.dedup(); - Self { - entries, - refs, - call_targets, } + Self { entries, refs } } /// The entry (function start) that contains `va`: the nearest entry at or below `va`. @@ -111,8 +95,11 @@ impl XrefIndex { fs } - pub fn call_targets(&self) -> &[u64] { - &self.call_targets + /// The function entries this index was built over, ascending — the union `locate::function_entries` + /// computes. Exposed because it is the domain of `containing_func`: a caller enumerating functions + /// should read it here rather than recompute the union and risk a different one. + pub fn entries(&self) -> &[u64] { + &self.entries } }