source2rosetta/src/taxonomy.rs
Kamal Tufekcic a2922b8bad
All checks were successful
CI / fuzz (push) Successful in 1m41s
CI / lint (push) Successful in 16s
CI / test (push) Successful in 22s
initial commit
2026-07-27 10:12:04 +03:00

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"));
}
}