353 lines
15 KiB
Rust
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,
|
|
})
|
|
}
|