act on what the binary declares: callable Pulse shims, ConVars, string anchors; gen v2.1
This commit is contained in:
parent
3de955c4ff
commit
71ce34edd2
14 changed files with 1507 additions and 54 deletions
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue