act on what the binary declares: callable Pulse shims, ConVars, string anchors; gen v2.1
All checks were successful
CI / lint (push) Successful in 17s
CI / fuzz (push) Successful in 2m6s
CI / test (push) Successful in 25s

This commit is contained in:
Kamal Tufekcic 2026-07-30 17:36:40 +03:00
commit 71ce34edd2
14 changed files with 1507 additions and 54 deletions

View file

@ -1,6 +1,6 @@
[package]
name = "source2rosetta-core"
version = "2.0.0"
version = "2.1.0"
edition = "2024"
description = "source2rosetta's deriver-free core: canonical gamedata model + format emitters (serde-only)"
license = "AGPL-3.0-only"

View file

@ -12,13 +12,35 @@ pub struct Sig {
pub linux: String, // space-hex pattern with `?` wildcards, e.g. "55 48 89 ? E5"
}
/// One gamedata function: a vtable-method offset, a scan signature, or (rarely) both.
/// One gamedata function: a vtable-method offset, a scan signature, or (rarely) both — plus, where we
/// have them, string ANCHORS that locate the same function a different way.
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct Entry {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub signature: Option<Sig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub offset: Option<i64>, // vtable slot index (or a carried member offset)
/// 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.
#[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
/// 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,
/// which is exactly when a byte pattern is most likely to have drifted.
///
/// Emitted alongside the signature, never instead of it: the two are independent, and a consumer
/// choosing between them is better served by having both than by our picking one.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub anchors: Vec<String>,
}
impl Entry {
@ -30,6 +52,8 @@ impl Entry {
linux: linux.into(),
}),
offset: None,
class: None,
anchors: Vec::new(),
}
}
@ -38,6 +62,8 @@ impl Entry {
Entry {
signature: None,
offset: Some(linux),
class: None,
anchors: Vec::new(),
}
}
}
@ -136,6 +162,30 @@ impl Gamedata {
self.entries.entry(name.into()).or_default().offset = Some(linux);
}
/// Record the class a vtable-offset locator is relative to.
pub fn set_class(&mut self, name: impl Into<String>, class: impl Into<String>) {
self.entries.entry(name.into()).or_default().class = Some(class.into());
}
/// Attach string anchors to `name`, creating the entry if the derivation reached it by no other route.
///
/// Deduplicated and order-preserving: the catalogue can carry the same anchor twice across variants,
/// and the emitted list is part of a byte-reproducible artifact, so it must not depend on how many
/// times a source repeated itself.
pub fn add_anchors<S: Into<String>>(
&mut self,
name: impl Into<String>,
anchors: impl IntoIterator<Item = S>,
) {
let e = self.entries.entry(name.into()).or_default();
for a in anchors {
let a = a.into();
if !a.is_empty() && !e.anchors.contains(&a) {
e.anchors.push(a);
}
}
}
pub fn len(&self) -> usize {
self.entries.len()
}
@ -297,9 +347,6 @@ impl Provenance {
pub struct MonoEntry {
#[serde(flatten)]
pub locator: Entry,
/// experimental offsets only: the vtable class the slot lives on (a reader's eyeball check).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
/// The argument footprint read out of THIS build's machine code — see [`AbiShape`]. Absent when the
/// function's address wasn't resolvable offline.
#[serde(default, skip_serializing_if = "Option::is_none")]
@ -499,9 +546,48 @@ pub struct Binding {
pub typed: bool,
/// Address of the binding's DESCRIPTOR ACCESSOR in this build — a lazy-init singleton returning the
/// static descriptor, not the bound function. It is the anchor a runtime walks to reach the
/// descriptor (and, through it, the real entry point); it is NOT a locator for the named method, and
/// no shipped gamedata entry points at it.
/// descriptor; it is NOT a locator for the named method, and no shipped gamedata entry points at it.
/// For an address that IS callable, see [`Binding::shim`].
pub descriptor: String,
/// Address of the binding's INVOCATION SHIM in this build — the record's third code pointer, and
/// unlike `descriptor` a real entry point. One per binding, never shared.
///
/// Calling it dispatches through Valve's own marshalling, which honours the DECLARED parameter types
/// in `params`: a value written into the argument blob is consumed according to its `PulseValueType_t`,
/// so a caller cannot smuggle a mistyped argument past it. Absent when the record's slot holds no
/// executable code.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub shim: Option<String>,
/// How to call [`Binding::shim`], and what a host must supply. Absent when there is no shim.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub call: Option<ShimCall>,
}
/// The invocation shim's calling contract: fixed across every binding, with a per-binding statement of
/// which slots that particular shim reads.
///
/// The signature is **seven integer arguments returning int**. Slot 5 (`r8`) is an array of POINTERS to the
/// argument values, element *k* at `+8+8k`. Slot 7 (the first stack slot) is the output sink. Slot 4
/// (`rcx`) is a Pulse host-service context, which is VM-owned. The return is `0` on dispatch and `-2` when
/// an entity-handle argument fails to resolve.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct ShimCall {
/// What a host must supply beyond the argument array, decoded from `reads`:
///
/// * `args-only` — nothing else. The remaining slots may be null; **validated by calling every
/// eligible binding in both games.** This is the callable tier.
/// * `output-sink` — it returns a value, so it writes through a register-file object a host does not
/// have. Read the state through `netvars-<game>.json` instead; the Pulse getters are redundant
/// with schema fields.
/// * `pulse-context` — needs a live `CPulseExecCursor` / graph instance. Not host-callable.
/// * `other-slots` — reads an argument slot whose role is not established (the `CPulseCell_*`
/// family, which are graph NODE implementations rather than API bindings). Not host-callable.
pub needs: String,
/// The argument slots this shim was measured to read, named in SysV order — the raw fact `needs` is
/// decoded from, kept beside it so a build that changes the contract can be re-read rather than
/// silently mis-labelled. The same rule `flags_raw` follows.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub reads: Vec<String>,
}
/// One Pulse parameter or return value, as the binding declares it.
@ -587,6 +673,30 @@ pub struct ConsoleCommand {
pub addr: String,
}
/// One ConVar the module registers — the configuration half of the console surface.
///
/// Emitted for the METADATA, not as a locator: a consumer finds a convar by name at runtime
/// (`ICvar::FindConVar`) with no gamedata at all, so the name alone would add nothing. The flags are the
/// payload — `cheat`, `replicated`, `release` are engine-DECLARED authority, and a host deciding what a
/// module may change is better served by what the engine says than by a hand-maintained allowlist.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct ConVar {
pub name: String,
pub library: String,
#[serde(default, skip_serializing_if = "String::is_empty")]
pub description: String,
/// FCVAR bits with a measured meaning — the same space console commands use.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub flags: Vec<String>,
/// The raw flags word. Empty when this registrar had no identifiable flags argument, which is honest
/// about the gap rather than reporting a zero that would read as "no flags set".
#[serde(default, skip_serializing_if = "String::is_empty")]
pub flags_raw: String,
/// Address of the ConVar object. It lives in `.bss`, so it holds nothing on disk — it is the anchor a
/// runtime walks to the live value, and what tells two registrations of one name apart.
pub addr: String,
}
/// One entity-IO output: an event an entity fires, and where its subscriber list lives on the instance.
#[derive(Clone, Debug, serde::Serialize, serde::Deserialize)]
pub struct EntityOutput {
@ -633,6 +743,9 @@ pub struct Bindings {
/// there is no honest key to map them by either.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub commands: Vec<ConsoleCommand>,
/// ConVars, a LIST for the same reason: one name can be registered by more than one library.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub convars: Vec<ConVar>,
}
/// The binding registry's intrinsic identity (no wall-clock field, same rationale as [`MonoMeta`]).
@ -644,6 +757,9 @@ pub struct BindingsMeta {
/// Of those, how many carry a recovered typed signature.
#[serde(default)]
pub pulse_typed: usize,
/// Of those, how many carry a HOST-CALLABLE invocation shim (`call.needs == "args-only"`).
#[serde(default)]
pub pulse_callable: usize,
pub entity_inputs: usize,
#[serde(default)]
pub entity_outputs: usize,
@ -651,6 +767,8 @@ pub struct BindingsMeta {
pub entity_classes: usize,
#[serde(default)]
pub commands: usize,
#[serde(default)]
pub convars: usize,
}
impl Bindings {
@ -949,8 +1067,9 @@ mod monolith_tests {
locator: Entry {
signature: None,
offset: Some(158),
class: None,
anchors: Vec::new(),
},
class: None,
abi: None,
provenance: Provenance {
source: Some("catalogue".into()),
@ -973,8 +1092,9 @@ mod monolith_tests {
locator: Entry {
signature: None,
offset: Some(40),
class: Some("CFoo".into()),
anchors: Vec::new(),
},
class: Some("CFoo".into()),
abi: None,
provenance: Provenance {
confidence: Some("low".into()),
@ -1021,8 +1141,9 @@ mod monolith_tests {
linux: "55 48 89 E5".into(),
}),
offset: None,
class: None,
anchors: Vec::new(),
},
class: None,
abi: None,
provenance: Provenance {
source: Some("catalogue".into()),

View file

@ -153,7 +153,15 @@ pub fn entry_from_value(v: &Value) -> Entry {
.get("offsets")
.and_then(|o| o.get("linux"))
.and_then(Value::as_i64);
Entry { signature, offset }
// The cssharp locator shape has no anchor field, so a round-tripped entry carries none. That is a
// boundary, not a loss: every caller of this function reads `.offset` off the result, and the anchor
// path to an emitter runs through `Monolith::select`, which copies the whole `Entry`.
Entry {
signature,
offset,
class: None,
anchors: Vec::new(),
}
}
/// Look up an emitter by its `--format` id.
@ -275,13 +283,32 @@ impl GamedataEmitter for ModSharp {
let (mut addresses, mut vfuncs) = (Map::new(), Map::new());
for (name, e) in &gd.entries {
if let Some(s) = &e.signature {
addresses.insert(
name.clone(),
json!({ "library": s.library, "linux": s.linux }),
);
let mut row = Map::new();
row.insert("library".into(), Value::String(s.library.clone()));
row.insert("linux".into(), Value::String(s.linux.clone()));
// ModSharp's own `refs` feature: a string this function references, which its loader
// resolves per build. Emitted BESIDE `linux` exactly as their hand-written gamedata does,
// so a build whose byte pattern drifted can still be located.
if !e.anchors.is_empty() {
row.insert("refs".into(), json!({ "strings": e.anchors }));
}
addresses.insert(name.clone(), Value::Object(row));
}
if let Some(o) = e.offset {
vfuncs.insert(name.clone(), json!({ "linux": o }));
let mut row = Map::new();
row.insert("linux".into(), json!(o));
// `refs.vtable` is ModSharp's own key for "the class whose vtable holds this slot", and a
// slot index without it is not a locator at all.
if let Some(c) = &e.class {
row.insert("refs".into(), json!({ "vtable": c }));
}
vfuncs.insert(name.clone(), Value::Object(row));
}
// An anchor with no signature is still a usable locator for ModSharp — `refs` alone is how
// several of their own entries are written. Dropping these would discard the only thing we
// know about a function whose byte pattern did not resolve.
if e.signature.is_none() && !e.anchors.is_empty() {
addresses.insert(name.clone(), json!({ "refs": { "strings": e.anchors } }));
}
}
let doc = json!({ "Addresses": addresses, "VFuncs": vfuncs });
@ -1635,10 +1662,14 @@ mod tests {
linux: "55 48 ? E5".into(),
}),
offset: None,
class: None,
anchors: Vec::new(),
};
let off = Entry {
signature: None,
offset: Some(158),
class: None,
anchors: Vec::new(),
};
assert_eq!(entry_from_value(&locator_value(&sig)), sig);
assert_eq!(entry_from_value(&locator_value(&off)), off);