265 lines
12 KiB
Rust
265 lines
12 KiB
Rust
//! Name / dead-weight taxonomy — the classification predicates that decide which resolved names are
|
|
//! real gameplay functions vs. generated plumbing, and how much to trust a name guess. Every item here is
|
|
//! a pure `&str`-in / verdict-out predicate over the name/class vocabulary, with no engine or IO
|
|
//! dependency — each takes the game's `&GameProfile` for its retunable vocabulary (dead-weight namespaces,
|
|
//! serializer method names, query prefixes). This is the primary knob a fork retunes for a different game
|
|
//! (the vocabulary is data on the profile). Shared by the fold (`build_gamedata_cmd`), the experimental band
|
|
//! (`emit_experimental_band`), the live semantic sweep, and the corpus-model class scope.
|
|
|
|
use crate::model::Tier;
|
|
use crate::profile::GameProfile;
|
|
use serde::Deserialize;
|
|
use std::collections::{HashMap, HashSet};
|
|
|
|
/// A candidate whose RTTI class is not CS2 gameplay at all — foreign runtime/library code that leaked
|
|
/// into `libserver.so`, or a protobuf-generated message type whose whole vtable is serializer boilerplate
|
|
/// (`GetMetadata`/`New`/`Clear`/`MergeFrom`/…, zero hook value). Excluded at dump time so naming agents
|
|
/// never spend time (~64% of the CS2 candidate pool) on functions we already know are dead
|
|
/// weight — and so the same junk never enters a per-game run for Dota2/Deadlock.
|
|
pub(crate) fn is_dead_weight_class(prof: &GameProfile, class: &str) -> bool {
|
|
// foreign namespaces (C++ runtime, libstdc++, Steam GC SDK, Valve container templates, the V8 vscript
|
|
// backend whose `v8::` classes leak into libvscript's RTTI) — all retunable per game on the profile.
|
|
if prof.foreign_namespaces.iter().any(|p| class.starts_with(p)) {
|
|
return true;
|
|
}
|
|
// protobuf RPC message shape — a `_Response`/`_Request` class is always a wire message.
|
|
if class.contains("_Response") || class.contains("_Request") {
|
|
return true;
|
|
}
|
|
// protobuf-generated message classes (the wire/GC protocol) — every method is serializer plumbing. The
|
|
// per-game user-message prefix (CS2: CCSUsrMsg) plus the shared Source-2 / Steam-GC message prefixes.
|
|
let leaf = class.rsplit("::").next().unwrap_or(class);
|
|
leaf.starts_with(prof.usermsg_prefix) || prof.proto_prefixes.iter().any(|p| leaf.starts_with(p))
|
|
}
|
|
|
|
/// A resolved NAME that is not CS2 gameplay — its owning class (or the whole name) is foreign/protobuf.
|
|
/// Complements the class-based dump-candidates prefilter for NON-virtual dead weight that has no RTTI
|
|
/// vtable class to filter on at dump time (free `GCSDK::*` / `google::protobuf::*` functions), caught
|
|
/// here once naming has resolved the class.
|
|
pub(crate) fn is_dead_weight_name(prof: &GameProfile, name: &str) -> bool {
|
|
let cls = name.rsplit_once("::").map(|(c, _)| c).unwrap_or(name);
|
|
is_dead_weight_class(prof, cls) || is_dead_weight_class(prof, name)
|
|
}
|
|
|
|
/// Classes with ≥3 serializer methods among `names` — protobuf message types whose class name matches no
|
|
/// foreign/CMsg prefix (e.g. `AccountActivity`, `CGCToGCMsgMasterAck`), detectable ONLY by their generated
|
|
/// method surface. The shared cluster detector both the fold and the experimental band flag plumbing with.
|
|
/// A HARD serializer method (`GetMetadata`/…) is decisive on its own; SOFT ones (`New`/`Clear`/…) can be
|
|
/// legit game methods, so both count toward the ≥3 cluster here but only HARD is a lone verdict elsewhere
|
|
/// (see [`is_serializer_plumbing`]). Both sets ride the `GameProfile` passed in, so a fork retunes them.
|
|
pub(crate) fn protobuf_message_classes<'a>(
|
|
prof: &GameProfile,
|
|
names: impl Iterator<Item = &'a str>,
|
|
) -> HashSet<String> {
|
|
let mut ser_count: HashMap<&str, usize> = HashMap::new();
|
|
for name in names {
|
|
if let Some((cls, leaf)) = name.rsplit_once("::")
|
|
&& (prof.hard_serializer.contains(&leaf) || prof.soft_serializer.contains(&leaf))
|
|
{
|
|
*ser_count.entry(cls).or_default() += 1;
|
|
}
|
|
}
|
|
ser_count
|
|
.into_iter()
|
|
.filter(|(_, c)| *c >= 3)
|
|
.map(|(k, _)| k.to_string())
|
|
.collect()
|
|
}
|
|
|
|
/// Is `name` protobuf serializer plumbing — a lone HARD serializer method, or a member of a class flagged
|
|
/// as a protobuf message by [`protobuf_message_classes`]? Complements the prefix/namespace test in
|
|
/// [`is_dead_weight_name`], which can't see method-name-only protobuf classes.
|
|
pub(crate) fn is_serializer_plumbing(
|
|
prof: &GameProfile,
|
|
name: &str,
|
|
pb_classes: &HashSet<String>,
|
|
) -> bool {
|
|
let hard = prof.hard_serializer;
|
|
name.rsplit_once("::")
|
|
.is_some_and(|(cls, leaf)| hard.contains(&leaf) || pb_classes.contains(cls))
|
|
}
|
|
|
|
/// A class clean enough to key a vtable-OFFSET entry: a real gameplay class (not dead weight), not a
|
|
/// `NetworkVar_`/template/alias chainer, and a bare name (the RTTI ground-truth class, no `::`).
|
|
pub(crate) fn clean_offset_class(prof: &GameProfile, cls: &str) -> bool {
|
|
!is_dead_weight_class(prof, cls)
|
|
&& !cls.contains("NetworkVar_")
|
|
&& !cls.contains('<')
|
|
&& !cls.contains("Alias_")
|
|
&& !cls.contains("::")
|
|
}
|
|
|
|
/// The class portion of a fully-qualified function name — everything before the last `::`, or before the
|
|
/// last `_` for the flat `Class_Method` form, or the whole name if neither. The one name-vocabulary splitter
|
|
/// the gamedata offsets and the live sweep share, so callers don't re-derive the class inline.
|
|
pub(crate) fn class_of(name: &str) -> &str {
|
|
if let Some(i) = name.rfind("::") {
|
|
&name[..i]
|
|
} else if let Some(i) = name.rfind('_') {
|
|
&name[..i]
|
|
} else {
|
|
name
|
|
}
|
|
}
|
|
|
|
/// The `ret=` class word out of an abi describe string ("int=1 float=0 ret=int" -> "int").
|
|
pub(crate) fn parse_ret(abi: &str) -> Option<&str> {
|
|
abi.split_whitespace().find_map(|t| t.strip_prefix("ret="))
|
|
}
|
|
|
|
/// One row of the full-slice name universe (`candidates-names-cs2-full.json`): an address + the
|
|
/// AI/heuristic name guess for it, plus the signals that grade the guess. Distinct from `PromoName` —
|
|
/// this reads the UN-filtered set (promoted AND un-promoted), the raw material of the experimental band.
|
|
#[derive(Deserialize)]
|
|
pub(crate) struct FullName {
|
|
pub(crate) addr: String,
|
|
pub(crate) name: String,
|
|
#[serde(default)]
|
|
pub(crate) confidence: String,
|
|
#[serde(default)]
|
|
pub(crate) corroboration: String,
|
|
#[serde(default)]
|
|
pub(crate) self_named: bool,
|
|
#[serde(default)]
|
|
pub(crate) promote: bool,
|
|
}
|
|
|
|
/// The confidence LADDER for a name guess — a composite honesty tier, stronger than the model's own
|
|
/// confidence word: a name literally present in the function's bytes (`self-named`) is near-certain; a
|
|
/// dictionary-corroborated leaf is next; then the model's own high/medium/low. Returns `(rank, label)`,
|
|
/// lower rank = more trustworthy. This is the primary grouping key of the experimental band.
|
|
pub(crate) fn guess_tier(r: &FullName) -> (u8, Tier) {
|
|
if r.self_named {
|
|
(0, Tier::SelfNamed)
|
|
} else if matches!(r.corroboration.as_str(), "exact" | "exact-free") {
|
|
(1, Tier::Corroborated)
|
|
} else if r.confidence == "high" {
|
|
(2, Tier::High)
|
|
} else if r.confidence == "medium" {
|
|
(3, Tier::Medium)
|
|
} else {
|
|
(4, Tier::Low)
|
|
}
|
|
}
|
|
|
|
/// A method name safe to blind-CALL with only `this` — a boolean predicate that returns a bool in RAX.
|
|
/// Deliberately EXCLUDES `Get*`: a getter can return a value BY VALUE (a string/struct), whose ABI
|
|
/// hides an output-buffer pointer in RDI (RVO) with `this` shifted to RSI — so calling it with the
|
|
/// object in RDI makes it WRITE the return value into the object. That is memory CORRUPTION, not a
|
|
/// faulting read, so `call_remote`'s signal-suppression can't catch it and the server dies later. The ABI-shape
|
|
/// lower bound can't distinguish this (a constant-returner reads no args and shows `int=0`), so the
|
|
/// gate is name-based: only the boolean predicates, which by convention return a bool and take no
|
|
/// output parameter. Fewer methods get the call-smoke-test, but the harness never corrupts the server.
|
|
pub(crate) fn is_query_method(prof: &GameProfile, name: &str) -> bool {
|
|
let leaf = name.rsplit("::").next().unwrap_or(name);
|
|
prof.query_prefixes.iter().any(|p| leaf.starts_with(p))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::profile::{CS2, DOTA};
|
|
|
|
// This module is documented as "the primary knob a fork retunes for a different game", and nothing
|
|
// else gates a retune: live validation only ever sees entries that SURVIVED classification, so an
|
|
// over-broad predicate silently shrinks the output with no count to compare against. These pin the
|
|
// decisions against both shipped profiles.
|
|
|
|
#[test]
|
|
fn real_gameplay_classes_are_not_dead_weight() {
|
|
for prof in [&CS2, &DOTA] {
|
|
for cls in [
|
|
"CBaseEntity",
|
|
"CCSPlayerPawn",
|
|
"CGameRules",
|
|
"CDOTA_BaseNPC",
|
|
] {
|
|
assert!(
|
|
!is_dead_weight_class(prof, cls),
|
|
"{cls} misclassified as dead weight"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn protobuf_and_foreign_namespaces_are_dead_weight() {
|
|
for prof in [&CS2, &DOTA] {
|
|
for cls in ["CMsgVector", "v8::internal::Object", "std::vector<int>"] {
|
|
assert!(
|
|
is_dead_weight_class(prof, cls),
|
|
"{cls} should be dead weight"
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn one_hard_serializer_marks_plumbing_but_one_soft_does_not() {
|
|
let prof = &CS2;
|
|
let none = HashSet::new();
|
|
let hard = format!("CFoo::{}", prof.hard_serializer[0]);
|
|
let soft = format!("CFoo::{}", prof.soft_serializer[0]);
|
|
// a lone HARD serializer method is decisive on its own
|
|
assert!(is_serializer_plumbing(prof, &hard, &none));
|
|
// a lone SOFT one is not — those names also occur on legitimate game classes
|
|
assert!(!is_serializer_plumbing(prof, &soft, &none));
|
|
}
|
|
|
|
#[test]
|
|
fn protobuf_clustering_needs_three_serializer_methods() {
|
|
let prof = &CS2;
|
|
let soft = prof.soft_serializer;
|
|
assert!(
|
|
soft.len() >= 3,
|
|
"profile needs >=3 soft serializers for this rule to be reachable"
|
|
);
|
|
let two: Vec<String> = soft.iter().take(2).map(|m| format!("CTwo::{m}")).collect();
|
|
let three: Vec<String> = soft
|
|
.iter()
|
|
.take(3)
|
|
.map(|m| format!("CThree::{m}"))
|
|
.collect();
|
|
let all: Vec<&str> = two.iter().chain(three.iter()).map(String::as_str).collect();
|
|
let flagged = protobuf_message_classes(prof, all.into_iter());
|
|
assert!(
|
|
flagged.contains("CThree"),
|
|
"3 serializer methods should cluster as protobuf"
|
|
);
|
|
assert!(
|
|
!flagged.contains("CTwo"),
|
|
"2 methods is below the >=3 cluster threshold"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn class_of_prefers_scope_then_underscore_then_whole_name() {
|
|
assert_eq!(class_of("CBaseEntity::TakeDamage"), "CBaseEntity");
|
|
// a templated class keeps its template arguments
|
|
assert_eq!(
|
|
class_of("CHandle<CBaseEntity>::Get"),
|
|
"CHandle<CBaseEntity>"
|
|
);
|
|
// no `::` falls back to the last underscore — this is how the ecosystem's flat
|
|
// `CClass_Method` names still key a class
|
|
assert_eq!(class_of("CCSPlayerPawn_Respawn"), "CCSPlayerPawn");
|
|
// and with neither separator the whole name IS the class key
|
|
assert_eq!(class_of("FreeFunction"), "FreeFunction");
|
|
}
|
|
|
|
#[test]
|
|
fn query_methods_need_a_profile_prefix_not_just_get() {
|
|
let prof = &CS2;
|
|
// `is_query_method` gates the live CALL sweep — a false positive means blind-calling a method
|
|
// that really takes arguments, so it must not fire on every `Get*`.
|
|
let any_prefix_hit = prof
|
|
.query_prefixes
|
|
.iter()
|
|
.any(|p| is_query_method(prof, &format!("CBaseEntity::{p}Something")));
|
|
assert!(
|
|
any_prefix_hit,
|
|
"no profile query prefix matched its own pattern"
|
|
);
|
|
assert!(!is_query_method(prof, "CBaseEntity::Teleport"));
|
|
}
|
|
}
|