minor tweaks
All checks were successful
CI / lint (push) Successful in 16s
CI / fuzz (push) Successful in 2m3s
CI / test (push) Successful in 25s

This commit is contained in:
Kamal Tufekcic 2026-08-03 03:59:53 +03:00
commit 22ab973f0c
13 changed files with 223 additions and 144 deletions

View file

@ -609,9 +609,12 @@ pub(crate) fn gp_slot(r: Register) -> Option<usize> {
/// 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<u64> {
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.)

View file

@ -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<u32> = 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() {

View file

@ -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};

View file

@ -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-<game>.json` + `model-<game>.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-<game>.json` + `manifest.json`, plus `model-<game>.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<String>,
/// 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<PathBuf>,
/// Function catalogue (loose form; omit when using --seed).
#[arg(long)]
#[arg(long, conflicts_with = "seed")]
catalogue: Option<PathBuf>,
/// 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<PathBuf>,
/// Optional: prefiltered per-address context for those names (`{"candidates": [...]}`). Omit for none.
#[arg(long)]
#[arg(long, conflicts_with = "seed")]
candidates: Option<PathBuf>,
/// 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<PathBuf>,
/// 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<PathBuf>,
/// 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<PathBuf>,
/// 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<PathBuf>,
/// Function catalogue (loose form; omit when using --seed).
#[arg(long)]
#[arg(long, conflicts_with = "seed")]
catalogue: Option<PathBuf>,
#[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<PathBuf>,
/// Function catalogue (loose form; omit when using --seed). Must match the model's distill catalogue.
#[arg(long)]
#[arg(long, conflicts_with = "seed")]
catalogue: Option<PathBuf>,
/// 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>) -> 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<PathBuf>,
@ -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());
}
}

View file

@ -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;
}

View file

@ -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<Record> {
})
}
/// 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<ShimReads> {
{
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

View file

@ -188,9 +188,6 @@ fn xmm(r: Register) -> Option<u8> {
.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<VScriptFunc> {
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;

View file

@ -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<u64>, // sorted, de-duped function entry addresses
refs: HashMap<u64, Vec<u64>>, // referenced VA -> source instruction VAs
call_targets: Vec<u64>, // 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<u64>);
// 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<usize> = (0..entries.len()).collect();
let per_entry: Vec<EntryData> =
let per_entry: Vec<Vec<(u64, u64)>> =
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<u64> = 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<u64, Vec<u64>> = 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
}
}