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:

  1. Find an existing export of the same class in the package to mirror class index, object flags, export flags and archetype from.
  2. Reserve the next free export index.
  3. Register the leaf name in the .newnames file.
  4. Resolve the outer from the dotted path if possible, else inherit the template's.
  5. Write the .bin and 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-root you 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, const and var .uo files are reference material; packing ignores them silently. The summary line reports how many.
  • Pack the smallest set you can. Point pack-mod at just the subtree you edited. Every .uo it 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.