Architecture
Why a schema database instead of hardcoded layouts
A UE3 tagged property carries its name, type name, size and array index — and nothing else. The
element type of a TArray, the field layout of an immutable struct, the labels of an enum, and the
entire native serialization after the property stream are all absent from the tag. That information
lives in the engine's reflection objects, which are themselves just exports in Core.u, Engine.u
and friends.
So rather than shipping a type table per game, UE3Tools reads those exports. Type recovery follows the import graph on demand and caches as it goes; nothing is game-specific.
Extraction pipeline
bytes → .uo + sidecars
-
Open and flatten —
upk_header_cursor()Reads the header. If compressed, every chunk is LZO-inflated and a flat in-memory image is rebuilt with a rewritten header, preserving inter-chunk gaps and the trailing bytes. Everything downstream sees one contiguous buffer, so compressed and plain packages take identical paths. -
Read the tables —
UPKPak::parse_upk()Names, exports, imports.UPKPakthen supplies UE's naming rules on top —.between levels,:before a subobject. -
Index the game root —
SchemaDb::new()One directory walk builds stem → path maps for packages and for.tfcfiles. No package is opened yet. The package being extracted is injected into the cache so it's never re-read. -
Walk the exports —
extract_by_name()Per export: compute the full name and mirrored path, sliceserial_offset..+serial_size, resolve the export's class to aResolvedRef(positive index → same package, negative → import resolution through the database). -
Decode —
write_extracted_file()Three branches. Meta-objects render as UnrealScript declarations. Regular objects read a net index, then the tagged property stream up to itsNoneterminator — everything after that is the native tail. The tail goes to the class'sNativeSerializerif one is registered, else to a positional walk of the class'sCPF_Nativeproperties, else it's preserved verbatim as hex. -
Emit —
pseudo::write_uo_file()Header comments, property lines, the@native(...)block, one@sidecarline per file written.
Packing pipeline
pack-mod is an overlay, not a regeneration. It starts from the decoded original and applies
only what the .uo changed:
- Collect and parse every
.uo; skip declaration-only files; group by thepkg=token. - Per package: re-open the original, build a schema database, copy the name table.
- Per object: locate the original export (full path first, then
export=#), re-decode its original properties, and merge — matched properties are overlaid value-by-value with the original's type as authority,Nonedrops a property, and a property that exists only in the.uois created from the schema. - Re-inject sidecars via
NativeSerializer::inject_external. - Serialize net index + property tags (sizes back-patched) + native tail →
<Dotted.Path>.bin. Write the grown name table as.namemap, and any invented export slots as.newexports.
Why the directions are asymmetric
Reading must be tolerant — cooked packages contain classes the tool has never seen, and there's
no length field between tagged properties and native data. Writing must be conservative —
producing byte-perfect output for an unknown native layout is impossible, so everything not
explicitly edited keeps its original bytes. That's why a type mismatch in a .uo is a hard
error rather than a coercion.
Module breakdown
src/ — 17 files, ~370 KB.
| Module | Responsibility |
|---|---|
main |
CLI dispatch (clap), the shared open-and-decompress helper, schema-dump / schema-resolve drivers |
upkreader |
Package format types (UpkHeader, UPKPak, Export, Import, FName) plus the extraction driver extract_by_name / write_extracted_file |
schema |
Hand-coded binary deserializers for every UE3 meta-class; returns None for anything not a meta-class |
schemadb |
Stem→path index, lazy package cache, import resolution (with ObjectRedirector fallback), and the graph queries: entry, list_children, class_chain, find_property, struct_for, array_inner_for, enum_names_for |
upkprops |
The tagged-property state machine, array/struct/immutable-struct decoding, CPF_Native positional decoding, and the property write side with back-patched sizes |
pseudo |
.uo emitter — values, class/struct/enum/const declarations, native blocks, sidecar references |
pseudo_parse |
Recursive-descent .uo parser producing PseudoFile / PseudoValue |
upkpacker |
Overlay-based recompilation, name-table growth, PendingExports slot reservation, reference resolution |
versions |
Every VER_*, PKG_*, FUNC_*, CPF_*, BULKDATA_* constant — the single point of version knowledge |
native/mod |
BulkBlock, NativePayload, the NativeSerializer trait, and the class-name registry |
native/texture2d |
Indirect mip arrays, TFC payload resolution, DDS emit and re-inject |
native/soundnodewave |
Four bulk blocks (raw, PC, Xbox 360, PS3), format sniffing, PCM→WAV wrapping |
native/swfmovie |
RawData byte array ↔ .gfx |
types/font |
TrueType rasterization into UE3 font pages + backing textures |
utils/decompress |
Two-level chunked decompression with byte-swap detection |
utils/dds |
Self-contained DDS reader/writer including the DX10 header for BC5 / FloatRGBA |
ui |
egui inspector — workspace, class tree, tabs, schema view, hex view, log |
Execution model
Single-threaded and synchronous throughout. SchemaDb is interior-mutable (RefCell caches,
Rc<SchemaEntry> handles) and passed by reference down the whole call tree. Packages are held
whole in memory — there is no streaming or mmap.
Failure model
Three non-fatal strategies, used deliberately:
| Strategy | When |
|---|---|
| Stop at the boundary | Any error in the property stream ends it — rewind to the last good position, the remainder becomes native tail |
| Degrade to raw | An array or struct that doesn't consume exactly its declared tag size is discarded and re-emitted as @bytes(...) with a stderr warning |
| Record the miss | Unresolvable imports append to SchemaDb::misses instead of aborting |
Type-graph cycles are broken by a visiting set. Every linked-list walk is bounded — 8192 children,
64 superclasses, 256 outer hops.