Schema System

Two halves. schema.rs deserializes UE3's reflection objects out of raw export bytes; schemadb.rs decides which export to deserialize, resolves imports across packages, and caches.

Metatype bootstrap

parse_export_schema(blob, class_name, pak, ctx) dispatches on the export's class name and returns None for anything that isn't a meta-class — which is how the caller knows to fall through to ordinary property decoding.

Recognised classes:

Struct  ScriptStruct  Function  State  Class  Enum  Const
ByteProperty  IntProperty  BoolProperty  FloatProperty  ObjectProperty
ClassProperty  ComponentProperty  InterfaceProperty  NameProperty
StrProperty  DelegateProperty  ArrayProperty  MapProperty  StructProperty

Why these can't be data-driven

Reading UClass from a package requires already knowing UClass's binary layout. The bootstrap is circular by nature, so these deserializers are transcribed by hand and are the one part of the tool that isn't schema-driven.

Object prefix

Before the field payload, UObject::Serialize wrote a net index (if p_ver ≥ 684) and the object's own tagged property stream. Both are skipped. Class is special-cased and returns right after the net index.

UField / UStruct header

i32  super field        only if p_ver < 756
i32  next               sibling pointer — forms the children linked list
i32  super struct       only if p_ver >= 756
i32  script text        unless cooked for console
i32  children           head of the children list
i32  cpp text           ┐
i32  line               ├ unless cooked for console
i32  text pos           ┘
i32  bytecode size
i32  on-disk script size   if p_ver >= 639, else reuse bytecode size
...  bytecode              skipped, its blob offset is recorded

Recording script_offset_in_blob means bytecode can be revisited later without re-parsing. If the declared script size would overrun the export blob, parsing fails with an explicit error rather than reading garbage.

Per-class extras

Class Extra fields
Function iNative: u16, operator precedence u8, function flags u32, replication offset u16 if FUNC_NET, friendly name FName unless cooked
State probe mask u32, label table offset u16, state flags u32, TMap<FName, Object*> function map
Class struct header → full state block → class flags, class-within, config name, component→default-object map, implemented interfaces, editor-only category arrays (DontSortCategories if ≥603, HideCategories, AutoExpandCategories, AutoCollapseCategories, bForceScriptOrder if ≥749, class groups if ≥789, header filename if ≥800), DLL bind name if ≥655, class default object index
ScriptStruct struct flags u32; the offset of the struct's default properties is recorded for later decoding
Enum count-prefixed FName array of value labels
Const an FString holding the constant's literal source text

Property common block

Shared by all fourteen property kinds:

i32   super field       only if p_ver < 756
i32   next
i32   array dim
u64   property flags
FName category          ┐ unless cooked for console
i32   array size enum   ┘
u16   rep offset        only if CPF_Net (0x20)

Then a type-specific tail:

Kind Tail
Byte enum_obj
Object, Component property_class
Class property_class, meta_class
Interface interface_class
Delegate function, source_delegate
Array inner
Map key, value
Struct struct_obj
Int, Bool, Float, Name, Str none

Every one of those is a package index into the owning package — which is why a reference is always a (package stem, export index) pair, never a bare integer.

The database

Indexing

SchemaDb::new(game_root) walks the tree once, recording lowercased stem → path for .upk / .u / .umap, and separately for .tfc. Nothing is opened. Packages open lazily, decompress fully, and cache by stem. A caller can seed the cache with a package it already has open.

Import resolution

Mirrors the engine's linker:

  1. Climb the import's outer chain to the top-level package name.
  2. Open that package.
  3. Find an export whose object name, class name, class package and outer chain all match. The outer-chain comparison is what disambiguates same-named objects in different groups.
  4. If nothing matches, look for a Core.ObjectRedirector with the same name; if found, parse it and follow its destination index.
  5. Otherwise record a miss and return nothing.

Caching and cycles

Entries are memoised by (stem, export index) and handed out as Rc<SchemaEntry>. A visiting set is maintained across the recursion — re-entering a reference already in flight yields a schema cycle error instead of recursing forever.

When an export's class isn't a recognised meta-class, its entry becomes OpaqueChild { class_name, next }. The next pointer is still recovered, so a children walk steps over it instead of stopping dead.

Graph queries

Query Returns
list_children(ref) Walks the children head, following each entry's next. Bounded at 8192.
class_chain(ref) Follows super_struct upward; stops on repeat or after 64 hops.
find_property(class, name) First match across the whole chain.
array_inner_for(class, prop) An ArrayProperty's element type.
struct_for(class, prop) A StructProperty's struct object.
enum_names_for(class, prop) A ByteProperty's enum labels as strings.
lookup_struct_by_name(start, name) Fallback by name across the starting package, then engine, then core.
resolve_full_path(start, path) Resolves "Class Pkg.Group.Object" segment by segment, matching each segment's outer to the previous result.

Diagnostics

Every resolution failure appends a readable line to the miss log, printed by schema-resolve on failure and streamed live under --verbose:

open 'someforgottenpkg' failed (importing Class SomePkg.SomeClass): package not in --game-root
no export FooBar (class Core.Class) in engine
opaque child engine::#4211: class 'DistributionFloat' (next=4212, walk continues)

The first form is the one that matters in practice — it means --game-root doesn't cover every package your target depends on.