//! Offline Source-2 **SchemaSystem** reader: recover class instance sizes, field offsets and base //! classes straight from Valve's own reflection tables in a stripped `.so` — making the field-offset //! half of gamedata *deterministic* (no fingerprint carry-forward, no "verify this guess"). //! //! Source 2 emits, as static data, a `SchemaClassInfoData_t` per registered class (its name, size, //! field array, base array) plus a `SchemaClassFieldData_t` per field (name, type, offset). The //! struct layouts here are the authoritative LP64 layouts from hl2sdk-cs2 //! `public/schemasystem/schematypes.h`. //! //! Root discovery is reloc-driven, mirroring `rtti::enumerate_vtables`: every class name pointer is //! a relocation, so we treat each reloc slot as a candidate `m_pszName` field, read the struct just //! below it, and validate (sane size + field count, a real fields pointer, and a first field named //! `m_…` — Source 2's universal member-prefix, which alone rejects essentially all false positives). //! Every pointer is read through `CodeImage::read_ptr`, so `.data.rel.ro` slots (zero on disk) come //! back as their true as-loaded values. use crate::elf::CodeImage; use crate::profile::GameProfile; use crate::{live, model}; use anyhow::Result; use std::collections::{BTreeMap, HashSet}; use std::path::Path; /// Byte offsets of the SchemaSystem reflection structs (SchemaClassInfoData_t / SchemaClassFieldData_t / /// SchemaBaseClassInfoData_t, LP64 — hl2sdk-cs2 public/schemasystem/schematypes.h). Grouped into one /// swappable value because this layout tracks the engine BUILD ERA (Valve reshapes these structs across /// engine updates), NOT the game. That makes it orthogonal to `GameProfile`: a future per-era detector /// ships several `SchemaLayout`s and picks one per binary. Today there is exactly one — `CURRENT_LAYOUT`, /// the source of truth the rest of this module and the live oracle read through. pub struct SchemaLayout { pub ci_binding: u64, // CSchemaClassInfo* m_pSchemaBinding (0 on disk, populated at runtime) pub ci_name: u64, // const char* m_pszName pub ci_size: u64, // int m_nSize pub ci_field_count: u64, // uint16 m_nFieldCount pub ci_base_count: u64, // uint8 m_nBaseClassCount pub ci_fields: u64, // SchemaClassFieldData_t* m_pFields pub ci_bases: u64, // SchemaBaseClassInfoData_t* m_pBaseClasses pub f_name: u64, // SchemaClassFieldData_t::m_pszName pub f_offset: u64, // SchemaClassFieldData_t::m_nSingleInheritanceOffset pub f_stride: u64, // sizeof(SchemaClassFieldData_t) pub b_offset: u64, // SchemaBaseClassInfoData_t::m_nOffset pub b_class: u64, // SchemaBaseClassInfoData_t::m_pClass pub b_stride: u64, // sizeof(SchemaBaseClassInfoData_t) // ---- CSchemaType: a SECOND runtime struct, reachable only from a live process ---- // `SchemaClassFieldData_t::m_pType` points at it, and the typed-netvars walk reads the type's name and // category through it. It belongs here for the same reason the rest does: this is engine-ERA layout // Valve reshapes across builds — kept beside the offline offsets so a reshape can't pass every offline // check and still ship a netvars file full of empty types. pub f_type: u64, // SchemaClassFieldData_t::m_pType pub ty_name: u64, // CSchemaType::m_pszName pub ty_category: u64, // CSchemaType::m_eTypeCategory (low byte) } /// The one layout in service — current CS2/Source-2 engine era. pub const CURRENT_LAYOUT: SchemaLayout = SchemaLayout { ci_binding: 0, ci_name: 8, ci_size: 32, ci_field_count: 36, ci_base_count: 41, ci_fields: 48, ci_bases: 56, f_name: 0, f_offset: 16, f_stride: 32, b_offset: 0, b_class: 8, b_stride: 16, f_type: 8, ty_name: 8, ty_category: 24, }; // The offsets projected as module consts — the stable interface the parser (below) and the runtime // oracle (`produce::verify_live_cmd` via `schema::CI_*` / `F_*`) read. Sourced from `CURRENT_LAYOUT` so it stays the // single source of truth; a per-era swap changes only the const above. pub const CI_BINDING: u64 = CURRENT_LAYOUT.ci_binding; pub const CI_NAME: u64 = CURRENT_LAYOUT.ci_name; pub const CI_SIZE: u64 = CURRENT_LAYOUT.ci_size; pub const CI_FIELD_COUNT: u64 = CURRENT_LAYOUT.ci_field_count; const CI_BASE_COUNT: u64 = CURRENT_LAYOUT.ci_base_count; pub const CI_FIELDS: u64 = CURRENT_LAYOUT.ci_fields; const CI_BASES: u64 = CURRENT_LAYOUT.ci_bases; const F_NAME: u64 = CURRENT_LAYOUT.f_name; pub const F_OFFSET: u64 = CURRENT_LAYOUT.f_offset; pub const F_STRIDE: u64 = CURRENT_LAYOUT.f_stride; pub const F_TYPE: u64 = CURRENT_LAYOUT.f_type; pub const TY_NAME: u64 = CURRENT_LAYOUT.ty_name; pub const TY_CATEGORY: u64 = CURRENT_LAYOUT.ty_category; const B_OFFSET: u64 = CURRENT_LAYOUT.b_offset; const B_CLASS: u64 = CURRENT_LAYOUT.b_class; const B_STRIDE: u64 = CURRENT_LAYOUT.b_stride; pub struct SchemaField { pub name: String, pub offset: i32, } pub struct SchemaBase { pub name: String, pub offset: u32, } /// One registered Source-2 class recovered from the schema tables. pub struct SchemaClass { pub name: String, pub class_info: u64, // vaddr of the SchemaClassInfoData_t pub name_ptr: u64, // reloc-resolved vaddr of the name string (for live cross-check) pub size: i32, // instance size in bytes pub bases: Vec, pub fields: Vec, } impl SchemaClass { /// The primary (offset-0) base class name, if any — for cross-checking against the RTTI chain. pub fn primary_base(&self) -> Option<&str> { self.bases .iter() .find(|b| b.offset == 0) .map(|b| b.name.as_str()) } } /// A schema type name: an identifier plus the template/namespace punctuation Source 2 uses. fn is_type_name(s: &str) -> bool { let b = s.as_bytes(); if b.is_empty() || b.len() >= 256 { return false; } if !(b[0].is_ascii_alphabetic() || b[0] == b'_') { return false; } s.chars().all(|c| { c.is_ascii_alphanumeric() || matches!(c, '_' | ':' | '<' | '>' | ',' | ' ' | '*' | '&' | '[' | ']') }) } /// Enumerate every registered class in `img` via the SchemaSystem tables — the whole-binary schema /// inventory. Sorted by class name. pub fn enumerate_schema(img: &CodeImage) -> Vec { let mut out = Vec::new(); let mut seen = HashSet::new(); for (slot, val) in img.reloc_slots() { if slot < 8 { continue; } // Candidate: `slot` is a class's m_pszName field, so `val` -> the class name string. let Some(name) = img.read_c_string(val) else { continue; }; if !is_type_name(&name) { continue; } let base = slot - 8; if !seen.insert(base) { continue; } if let Some(cls) = parse_class(img, base, &name, val) { out.push(cls); } } out.sort_by(|a, b| a.name.cmp(&b.name)); out } fn parse_class(img: &CodeImage, base: u64, name: &str, name_ptr: u64) -> Option { let size = img.read_i32(base.wrapping_add(CI_SIZE))?; if size <= 0 || size >= (1 << 23) { return None; } let field_count = img.read_u16(base.wrapping_add(CI_FIELD_COUNT))?; if field_count == 0 || field_count >= 6000 { return None; } let fields_ptr = img.read_ptr(base.wrapping_add(CI_FIELDS))?; if fields_ptr == 0 { return None; } // Discriminator: a real schema class's first field is `m_…`. This alone rejects the stray reloc // slots that happen to point at an identifier-shaped string but aren't class bindings. let first = img .read_ptr(fields_ptr) .and_then(|p| img.read_c_string(p))?; if !first.starts_with("m_") { return None; } let mut fields = Vec::with_capacity(field_count as usize); for i in 0..field_count as u64 { let fe = fields_ptr.wrapping_add(i.wrapping_mul(F_STRIDE)); let Some(fname) = img.read_ptr(fe + F_NAME).and_then(|p| img.read_c_string(p)) else { break; }; let offset = img.read_i32(fe + F_OFFSET).unwrap_or(0); fields.push(SchemaField { name: fname, offset, }); } let base_count = img.read_u8(base.wrapping_add(CI_BASE_COUNT)).unwrap_or(0); let bases_ptr = img.read_ptr(base.wrapping_add(CI_BASES)).unwrap_or(0); let mut bases = Vec::new(); if bases_ptr != 0 { for i in 0..base_count as u64 { let be = bases_ptr.wrapping_add(i.wrapping_mul(B_STRIDE)); let offset = img.read_u32(be + B_OFFSET).unwrap_or(0); let bcls = img.read_ptr(be + B_CLASS).unwrap_or(0); if bcls == 0 { continue; } if let Some(bn) = img .read_ptr(bcls + CI_NAME) .and_then(|p| img.read_c_string(p)) { bases.push(SchemaBase { name: bn, offset }); } } } Some(SchemaClass { name: name.to_string(), class_info: base, name_ptr, size, bases, fields, }) } // ─── Live type walk (the typed schema) ────────────────────────────────────────────────────────── // The offline reader above recovers class layouts (names + field offsets) from the static reflection // tables. Field *types* are runtime-resolved (each record's `m_pType` is a null pointer on disk), so // `live_schema` attaches to a running process, reads the types back, and builds the typed // `netvars-.json` (`model::Schema`) directly — no `sdk.json` intermediate. /// FNV-1a (32-bit). The Source-2 schema field/class name hash: a field's runtime lookup key is /// `(fnv1a32(class_name) << 32) | fnv1a32(field_name)` (field name keeps its `m_` prefix). Confirmed /// against swiftlys2's own generated hashes. fn fnv1a32(s: &str) -> u32 { let mut h: u32 = 0x811c9dc5; for b in s.bytes() { h = (h ^ b as u32).wrapping_mul(0x0100_0193); } h } /// Best-effort byte size of a builtin schema type (codegen doesn't require it, but it's cheap). fn builtin_size(t: &str) -> i32 { match t { "int8" | "uint8" | "char" | "bool" => 1, "int16" | "uint16" => 2, "int32" | "uint32" | "float32" => 4, "int64" | "uint64" | "float64" | "double" => 8, _ if t.ends_with('*') => 8, _ => 0, } } /// Walk a running process's schema across every server-mapped library (`profile.libs`) and build the typed /// netvars (`model::Schema`) DIRECTLY — no `sdk.json` round-trip: field layout is read offline from each /// image, the runtime `m_pType` from the live process. Shared classes (compiled into many libs) de-dupe /// precedence-first (the earlier lib in `libs` wins). This is `netvars-.json` — the shipped SDK /// material (`source2rosetta-gen` renders it on demand). pub(crate) fn live_schema( prof: &GameProfile, pid: u32, dir: &Path, source_build: &str, ) -> Result { use model::{Field, Schema, SchemaMeta}; let live = live::LiveProcess::attach(pid)?; let mut classes: BTreeMap> = BTreeMap::new(); let (mut typed, mut untyped) = (0usize, 0usize); let mut seen: HashSet = HashSet::new(); let mut nlibs = 0usize; for &lib in prof.libs { let Ok(img) = crate::locate::load_lib(dir, lib) else { continue; }; let Some(base) = live.base(lib) else { continue }; // lib not mapped in the process -> skip nlibs += 1; for c in &enumerate_schema(&img) { // a shared class already taken from an earlier (higher-precedence) lib — identical layout, skip if !seen.insert(c.name.clone()) { continue; } // fields array is static; each record's m_pType is runtime-resolved -> read it from the process let fields_ptr = img.read_ptr(c.class_info + CI_FIELDS).unwrap_or(0); let mut fmap: BTreeMap = BTreeMap::new(); for (i, f) in c.fields.iter().enumerate() { let rec = base .wrapping_add(fields_ptr) .wrapping_add((i as u64).wrapping_mul(F_STRIDE)); let mptype = live.read_u64(rec.wrapping_add(F_TYPE)).unwrap_or(0); // a resolved type is a real pointer; on-disk placeholders are tiny/tagged values let (type_name, cat) = if mptype > 0x10000 { let name = live .read_u64(mptype.wrapping_add(TY_NAME)) .ok() .and_then(|q| live.read_cstr(q).ok()) .unwrap_or_default(); let cat = live .read_u16(mptype.wrapping_add(TY_CATEGORY)) .map(|v| (v & 0xff) as u8) .unwrap_or(0xff); (name, cat) } else { (String::new(), 0xffu8) }; let ty = type_name.replace(' ', ""); // codegen strips spaces anyway // count typed/untyped on the SPACE-STRIPPED type (what the netvars meta reflects), so a // whitespace-only runtime name counts as untyped. if ty.is_empty() { untyped += 1; } else { typed += 1; } let kind = match cat { 1 => model::FieldKind::Ptr, 3 => model::FieldKind::FixedArray, _ => model::FieldKind::Ref, // builtin / atomic / declared class / declared enum }; let name_hash = ((fnv1a32(&c.name) as u64) << 32) | fnv1a32(&f.name) as u64; fmap.insert( f.name.clone(), Field { offset: f.offset, ty: ty.clone(), kind, size: builtin_size(&ty) as usize, name_hash, }, ); } classes.insert(c.name.clone(), fmap); } } eprintln!( "typed netvars: {} classes across {nlibs} libs, {typed} typed fields, {untyped} unresolved", classes.len() ); Ok(Schema { meta: SchemaMeta { game_key: prof.game_key.to_string(), source_build: source_build.to_string(), typed, untyped, }, classes, }) }