source2rosetta/src/schema.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

353 lines
15 KiB
Rust

//! 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<SchemaBase>,
pub fields: Vec<SchemaField>,
}
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<SchemaClass> {
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<SchemaClass> {
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-<game>.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-<game>.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<model::Schema> {
use model::{Field, Schema, SchemaMeta};
let live = live::LiveProcess::attach(pid)?;
let mut classes: BTreeMap<String, BTreeMap<String, Field>> = BTreeMap::new();
let (mut typed, mut untyped) = (0usize, 0usize);
let mut seen: HashSet<String> = 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<String, Field> = 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,
})
}