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

  1. Open and flattenupk_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.

  2. Read the tablesUPKPak::parse_upk() Names, exports, imports. UPKPak then supplies UE's naming rules on top — . between levels, : before a subobject.

  3. Index the game rootSchemaDb::new() One directory walk builds stem → path maps for packages and for .tfc files. No package is opened yet. The package being extracted is injected into the cache so it's never re-read.

  4. Walk the exportsextract_by_name() Per export: compute the full name and mirrored path, slice serial_offset..+serial_size, resolve the export's class to a ResolvedRef (positive index → same package, negative → import resolution through the database).

  5. Decodewrite_extracted_file() Three branches. Meta-objects render as UnrealScript declarations. Regular objects read a net index, then the tagged property stream up to its None terminator — everything after that is the native tail. The tail goes to the class's NativeSerializer if one is registered, else to a positional walk of the class's CPF_Native properties, else it's preserved verbatim as hex.

  6. Emitpseudo::write_uo_file() Header comments, property lines, the @native(...) block, one @sidecar line 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:

  1. Collect and parse every .uo; skip declaration-only files; group by the pkg= token.
  2. Per package: re-open the original, build a schema database, copy the name table.
  3. 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, None drops a property, and a property that exists only in the .uo is created from the schema.
  4. Re-inject sidecars via NativeSerializer::inject_external.
  5. 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.