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. Serializers may write sidecar files (.dds,.gfx,.ogg,.glb, ...). A generic pass (native/rawdata) also externalizes anyRawDataproperty that a serializer didn't claim. -
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, then let the serializer
patch any bulk-data offsets (
patch_serialized) →<Dotted.Path>.bin. Write the names appended to the table as<pkg>.newnames(only the new entries, with their indices), and any invented export slots as<pkg>.newexports.
The same entry point, pack_mod with PackOptions, is what the GUI's mod builder calls.
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/ — 29 files, ~620 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, plus default-value lookups |
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 |
errctx |
Two Result extensions: .ctx(...) prefixes an error with context, .soft(...) turns a failure into a stderr warning and None |
progress |
Terminal spinner and progress bar for extraction (only when stdout is a terminal; width detection via libc / windows-sys) |
native/mod |
BulkBlock, NativePayload, the NativeSerializer trait, and the class-name registry |
native/texture2d |
Indirect mip arrays, TFC payload resolution (including LZO inflation), DDS emit and re-inject |
native/soundnodewave |
Four bulk blocks (raw, PC, Xbox 360, PS3), format sniffing, PCM→WAV wrapping, audio injection |
native/swfmovie |
RawData byte array ↔ .gfx |
native/rawdata |
Generic RawData externalization (<name>.RawData.<ext>) for classes without a dedicated serializer |
native/staticmesh, native/skeletalmesh |
Mesh tail decoding and .glb export (extract only) |
native/material |
Material, MaterialInstanceConstant, MaterialInstanceTimeVarying — native tail preserved |
native/font |
Font / MultiFont native tail, CharRemap round-trip |
types/font |
TrueType rasterization into UE3 font pages + backing textures (create-font) |
utils/decompress |
Two-level chunked decompression with byte-swap detection |
utils/dds |
Self-contained DDS reader/writer including the DX10 header for BC5 / FloatRGBA |
utils/gltf |
glTF binary (.glb) builder used by the mesh serializers |
utils/half |
Half-precision float helpers for packed mesh data |
ui/mod |
egui inspector — workspace, class tree, tabs, schema view, hex view, log — plus the mod workspace that builds mods via pack_mod |
ui/theme |
GUI theme |
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.