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

2
Cargo.lock generated
View file

@ -248,7 +248,7 @@ dependencies = [
[[package]]
name = "source2rosetta-core"
version = "2.1.0"
version = "3.0.2"
dependencies = [
"anyhow",
"clap",

View file

@ -546,7 +546,7 @@ Releases live on the **[releases page](https://git.lo.sh/kamal/source2rosetta/re
- `--target <dir>` — 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 <install>` — must be the **`game/` subtree** of the install, the same directory layout the dedicated server is launched from.
- `--seed <bundle>` — one file bundling every derive input. The loose equivalent is `--catalogue <file>` 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 <bundle>` — one file bundling every derive input. The loose equivalent is `--catalogue <file>` 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 <model.json>` (the normal path: forward-derive from the model + target binary, rolling the model N→N+1 as a sidecar) or `--corpus <dir>` (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.

View file

@ -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"

View file

@ -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<String>,
/// 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<String>, linux: impl Into<String>) -> Entry {
Entry {
signature: Some(Sig {

View file

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

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