Building Mods
pack-mod compiles edited .uo files into per-export override blobs. It never rewrites a .upk —
the output is a set of replacement payloads for a loader to serve at runtime.
Workflow
# 1. extract
ue3-tools --game-root /path/to/game extract Game/CookedPCConsole/Interface.upk
# 2. edit output/Interface/**/*.uo, or swap a sidecar (.dds, .gfx, .ogg, ...)
# 3. pack
ue3-tools --game-root /path/to/game pack-mod output/Interface --out mymod/
Output:
mymod/
└── Interface/
├── UI_Menu.Backgrounds.MainMenu.bin
├── UI_Menu.Movies.hud.bin
├── Interface.newnames
└── Interface.newexports ← only when new objects were referenced
The run ends with a summary line:
pack-mod: 2 override(s) written to mymod/ (0 failed, 14 definition(s) skipped)
Building from the GUI
The GUI's mod workspace does the same job without the command line. Select objects across the open
packages, fill in a mod key, name, version, author and description (plus optional Lua source), pick
an output folder and press Build. It extracts the selected objects into a temporary directory,
then runs pack-mod over them. The result is:
<output>/<key>/
├── mod.toml name, version, author, description
├── main.lua your Lua source, or a stub comment
└── overrides/
└── Interface/ the same .bin / .newnames / .newexports layout as above
Output contract
<Dotted.Export.Path>.bin
The complete replacement serialized bytes for one export:
i32 net index if p_ver >= 322
... tagged property tags, terminated by "None"
... the native tail
The filename is the export's dotted outer chain — the same key a loader matches against when the game preloads that object.
Classes that keep inline bulk data (Texture2D mips, SoundNodeWave blocks) have the offset field
of each bulk-data header rewritten to an absolute position computed from the original export's
serial offset, so the blob is only valid as a replacement for that export.
<package>.newnames
Tab-separated list of the names the packer had to append to the package's name table:
# ue3-tools newnames v1
# index name
8123 MyNewLabel
8124 MyNewLabel_Alt
index is the position in the name table. The original table is untouched; appended names continue
from its original length. A loader must append these entries at those indices before deserializing
the .bin, because the property tags inside reference names by index.
The file is written for every package that had at least one successful override. It contains only the two header lines when nothing was appended.
The newnames file is not optional
Even a purely numeric edit can grow the table — a new enum label, a new FName value, or a
property tag that wasn't in the original all append entries. Always ship the .newnames
alongside the .bin files from the same run.
<package>.newexports
Written only when the packer had to invent export slots. Tab-separated, with a header:
# ue3-tools newexports v1
# index path class class_index outer_index super_index archetype object_flags export_flags serial_size bin
2317 UI_Menu.Backgrounds.NewImage Texture2D -42 310 0 0 0x0000000000000001 0x00000000 4096 UI_Menu.Backgrounds.NewImage.bin
Rows whose class is ? are objects that were referenced by something you edited but never
defined by a .uo — the packer warns about each one. Either add a .uo defining it, or fix the
reference.
How merging works
The packer starts from the decoded original and applies only what changed:
| Situation | Result |
|---|---|
| Property in both | Overlaid value-by-value; the original's type is authoritative |
Property set to None |
Dropped from the output |
Property only in the .uo |
Created from the schema — requires --game-root |
| Struct field omitted from the original | Created from the schema when you set it |
| Property only in the original | Kept unchanged |
@native(...) block |
Skipped; the original native tail bytes are reused (Font / MultiFont read their character remap back from it) |
@sidecar listed |
Re-injected by the class's serializer |
Types are never coerced. Writing a string where an int was is an error naming the field:
FAIL output/Interface/UI_Menu/Backgrounds/MainMenu.uo — SizeX: type mismatch: original is Int but edit is string
Adding new objects
If a .uo's path matches no existing export, the packer creates one:
- Find an existing export of the same class in the package to mirror class index, object flags, export flags and archetype from.
- Reserve the next free export index.
- Register the leaf name in the
.newnamesfile. - Resolve the outer from the dotted path if possible, else inherit the template's.
- Write the
.binand declare the slot in.newexports.
Object references to objects that don't exist yet get slots reserved the same way, so you can point at a new object from an edited one in the same run.
Requires loader support
New exports only work if the loader can extend the export table at runtime. If it can only replace existing exports, restrict edits to objects that already exist.
Practical notes
- Extract with the same
--game-rootyou pack with. The schema must agree, and a missing package turns a decoded array into@bytes(...)— which then round-trips as opaque bytes rather than as the value you wanted to edit. - Declaration files are skipped.
class,struct,enum,constandvar.uofiles are reference material; packing ignores them silently. The summary line reports how many. - Pack the smallest set you can. Point
pack-modat just the subtree you edited. Every.uoit finds is repacked, whether you touched it or not. - Check the summary. It ends with counts of overrides written, failures, and skipped definitions. A non-zero failure count means those objects were left out entirely. A package that can't be opened counts all of its objects as failed.
Consuming the output
The .bin + .newnames (+ .newexports) layout is what a runtime loader such as
CU3ML reads: it indexes each mod's overrides, hooks the
engine's Preload, and substitutes the mod's serialized bytes — with the appended names applied —
before the object deserializes. The original package on disk is never modified.