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:
- Climb the import's outer chain to the top-level package name.
- Open that package.
- 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.
- If nothing matches, look for a
Core.ObjectRedirectorwith the same name; if found, parse it and follow its destination index. - 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.