//! 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, ) -> HashSet { 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, ) -> 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"] { 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 = soft.iter().take(2).map(|m| format!("CTwo::{m}")).collect(); let three: Vec = 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::Get"), "CHandle" ); // 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")); } }