Files
UnheardCity/docs/formats/upk.md

2.5 KiB

UE3 packages (*.upk)

Arkham City's 2,650 packages in BmGame\CookedPCConsole are Unreal Engine 3 packages, version 805, licensee 101. 2,647 are fully LZO-compressed, 2 are stored uncompressed, and UnrealX.upk (512 bytes) is not a readable package. ackit reads and patches them with ackit/upk.py and ackit/lzo.py (no external tools). Soundbanks inside them: banks.md.

Summary (file start, little-endian)

field notes
u32 tag 0x9E2A83C1, u16 version 805, u16 licensee 101, i32 TotalHeaderSize
FString FolderName i32 length incl. NUL, then bytes (None)
12 x i32 PackageFlags, NameCount, NameOffset, ExportCount, ExportOffset, ImportCount, ImportOffset, DependsOffset, ImportExportGuidsOffset, ImportGuidsCount, ExportGuidsCount, ThumbnailTableOffset
16 bytes Guid
i32 N + N x {ExportCount, NameCount, NetObjectCount} generations (N = 1)
u32 EngineVersion 7748, u32 CookerVersion, u32 CompressionFlags 2 = LZO, 0 = none
i32 C + C x {i32 UncompressedOffset, UncompressedSize, CompressedOffset, CompressedSize} chunk table
u32 PackageSource, ... not used by ackit

All offsets in the summary (names, exports, ...) refer to the uncompressed package. The uncompressed package = the summary without the chunk table, followed by the chunks' data at their UncompressedOffset (the first chunk starts right after the summary, at NameOffset).

Compressed chunks

Each chunk (at CompressedOffset): u32 tag, u32 block size (0x20000), u32 compressed sum, u32 uncompressed sum, then one {u32 compressed size, u32 uncompressed size} per block, then the blocks. Every block is an independent LZO1X stream. In all game packages the chunks follow each other without gaps, start right after the summary and end at the end of the file (checked for every package).

How ackit reads and writes them

  • Positions of sounds are offsets in the uncompressed package, the same as in a decompressed copy (e.g. Gildor's decompress.exe output). Reading decompresses only the blocks that are needed.
  • Writing (Package.patched): every changed block is stored again as a single LZO literal run (a valid LZO1X stream without matches, lzo.compress_stored), the chunk headers of the changed chunks and the CompressedOffset/CompressedSize of all following chunks are updated; every other byte is copied, so a package without changes is rebuilt byte-for-byte. Replacements never change the uncompressed layout (sounds are replaced in place), so names, exports and their offsets stay valid.