Skip to content

TFT Unreal Extraction

TFT Set 18 runs on Unreal Engine 5.7. The WAD pipeline does not read Unreal assets. This page tells you how to get the Set 18 models from the Unreal build.

Read this page before you change the Set 18 catalog. The WAD models for Set 18 are not correct. See Why the WAD models are wrong.

All steps in this page operate on macOS. You do not need Windows. You do not need a Riot account. You do not need to start the game.

Summary of the chain

StepToolResult
1tftdl_bins.py, tftdl_all.pyThe game binary and the pak files
2make_usmap.pyA .usmap mappings file
3CUE4ParseMesh, skeleton, material and texture data
4inject_anims.pyA .glb file for Khada

The tools are in /Users/arthur/tft-unreal-tools/. They are not in this repository.

1. Download the Unreal build

The Unreal build is a different product from League. It is not in a League manifest.

Get the release list from the sieve endpoint:

https://sieve.services.riotcdn.net/api/v1/products/teamfighttactics/version-sets/pbe-global

The endpoint gives approximately 21 releases. Select the newest release that has the label riot:platform with the value windows.

WARNING

The platform label is at release.labels['riot:platform']. It is not at labels['riot:platform']. A query of the wrong path gives zero results.

The download.url field gives a manifest. The manifest is a standard RMAN file, and cdtb.patcher.PatcherManifest reads it. Get the chunks from this address:

https://teamfighttactics.dyn.riotcdn.net/channels/public/bundles/{bundle_id:016X}.bundle

The chunks use zstd compression. The platform of the file does not prevent a download. Windows files download correctly to macOS.

Download these files:

FileSizeUse
TFT/Binaries/Win64/TFTClient-Win64-Shipping.exe193 MBInput for the usmap dumper
pakchunk1-WindowsClient.ucas4.7 GBThe asset data
pakchunk1-WindowsClient.utoc / .pak33 MB / 134 MBThe asset index
pakchunk0-WindowsClient.*82 MBEngine content
global.utoc / .ucas4.8 MBGlobal data

Do not download pakchunk2-videos or the 25 locale chunks. The pak files do not use encryption. The encrypted-index byte in the pak footer is 0.

2. Make the usmap file

A shipping build removes the property names from each asset. This is unversioned property serialisation. A reader needs a .usmap mappings file to get the names back.

The usual tools (Dumper-7, UE4SS, jmap) read the memory of a running game. That approach does not work here. The Riot Client user interface does not start under Wine. Vanguard blocks virtual machines and has no ARM64 driver.

make_usmap.py reads the mappings from the executable file instead. Unreal writes the reflection data into static tables at compile time. The tables stay in the file.

Run the dumper:

bash
cd /Users/arthur/tft-unreal-tools
python3 make_usmap.py bin/TFTClient-Win64-Shipping.exe tft.usmap

The dumper needs approximately 1 minute. Run it again after each PBE patch.

The tables in the executable file

The image base is 0x140000000. The sections are .text (134 MB), .rdata (44 MB) and .data (6.9 MB).

Registration table — in .rdata, 40 bytes for each entry. Each entry has this signature: a .text pointer, a second .text pointer, an .rdata pointer to a UTF-16 name, and a .data pointer. Class names keep the C++ prefix, for example AActor. Script struct names do not keep the prefix, for example ARFilter.

FClassParams — in .rdata, 64 bytes. To find the block, read the Z_Construct function of the class. Look for RIP-relative LEA instructions (48 8D /05 disp32 or 4C 8D /05 disp32). Keep the target whose first word is equal to the StaticClass pointer of the registration entry.

+0  ClassNoRegisterFunc          +32 FunctionLinkArray
+8  ClassConfigNameUTF8          +40 PropertyArray
+16 CppClassInfo                 +48 ImplementedInterfaceArray
+24 DependencySingletonFuncArray +56 packed counts
                                 +60 ClassFlags

DANGER

Get the property count from counts >> 15. Do not count the array elements. The array has no terminator, and a walk goes past the end. USkeletalMesh walks to 45 elements, but the true count is 36.

DependencySingletonFuncArray[0] gives the super class. This is correct when the low nibble of the counts word is 2 or more. This method finds 8,797 of 8,798 super classes with no failures.

FStructParams — in .data, 56 bytes. Script structs use a GetStaticStruct thunk, so the LEA method above does not find them. Scan .data for the signature instead.

+0  OuterFunc      +24 NameUTF8            +42 uint16 SizeOf
+8  SuperFunc      +32 PropertyArray       +44 uint32 AlignOf
+16 StructOpsFunc  +40 uint16 NumProperties +48 uint32 StructFlags

FEnumParams — in .data. A thunk points to the true constructor function, so you must follow two functions.

+0  OuterFunc        +32 FEnumeratorParam array
+8  DisplayNameFunc  +40 uint32 ObjectFlags (0x45)
+16 NameUTF8         +44 uint16 NumEnumerators
+24 CppTypeUTF8

Each FEnumeratorParam is 16 bytes: a const char* name and an int64 value.

FPropertyParamsBase — in .rdata, 56 bytes for the simple numeric types.

+0  NameUTF8           +32 SetterFunc
+8  RepNotifyFuncUTF8  +40 GetterFunc
+16 uint64 PropertyFlags +48 uint16 ArrayDim
+24 uint32 GenFlags    +50 uint16 Offset
+28 uint32 ObjectFlags +56 reference to a struct, class or enum

The type is in the low 6 bits of the GenFlags field.

The property type values

These values come from tests against the binary file. They are not the same as the order in the public headers.

0  Byte     8  UnsizedInt   16 SoftObject  24 Set
1  Int8     9  UnsizedUInt  17 Class       25 Struct
2  Int16    10 Float        18 Object      26 Delegate
3  Int      11 Double       19 Interface   27 InlineMulticastDelegate
4  Int64    12 Bool         20 Name        28 SparseMulticastDelegate
5  UInt16   13 SoftClass    21 Str         29 Text
6  UInt32   14 WeakObject   22 Array       30 Enum
7  UInt64   15 LazyObject   23 Map         31 FieldPath
                                           32 LargeWorldCoordinatesReal

Tests confirm these values. FVector has three doubles at the offsets 0, 8 and 16. FGuid has four int32 values at the offsets 0, 4, 8 and 12. FTransform has the size 96 and the alignment 16.

The property array structure

The property array is flat. The order goes up, and it is the same as the declaration order. An inner property comes immediately before its container.

  • Array, Set, Optional and Enum each use one inner property.
  • Map uses two inner properties. The order is value, then key, then the map.

To read the array, use a stack. Push each property. When you find a container, remove the necessary number of inner properties from the stack.

TIP

The underlying integer of an enum property is a separate entry before the enum property. This is the same structure as EnumProperty { innerType, enumName } in the usmap format. No special code is necessary.

Two rules for the usmap file

  1. Remove the C++ prefix from a class name. Remove one leading A, U or I if an uppercase letter comes after it. Do not change struct names. They have no prefix.
  2. The bHasVersioning field is an int32. It is not one byte. The CUE4Parse function ReadBoolean reads four bytes.

Result

The dumper gives 16,696 types (8,798 classes and 7,898 structs) and 2,781 enums with 14,965 enumerators. All 16,678 registration entries resolve. 2,731 types are specific to TFT.

3. Open the assets with CUE4Parse

FModel is only for Windows. CUE4Parse is the library below FModel, and it is plain .NET. It operates on macOS.

bash
git clone --recurse-submodules https://github.com/FabianFG/CUE4Parse.git
dotnet build -p:CUE4PARSE_SKIP_NATIVE=true

WARNING

Use EGame.GAME_UE5_7. A different version gives this error: VersionException: Read size is bigger than remaining archive length. This error is the same error that a missing usmap gives. Do not think that this error always shows a mappings problem.

Test result: 3,000 of 3,000 assets load with no failures.

The layout of the assets

The Set 18 champion list is in this directory:

TFT/Plugins/GameFeatures/Set_18/Content/Champions/<Name>/

There are 67 champions. These directories contain gameplay, ability and audio data. They do not contain the models.

The models are in the character plugins:

TFT/Plugins/GameFeatures/Character_<Champion>_<Skin>/Content/SK_<Name>.uasset

The mesh name gives the skin identifier. For example, da_18_xayah uses SK_Xayah_Skin08 in the plugin Character_Xayah_Elderwood.

To find the texture of a material slot, read the TextureParameterValues of the material instance. Use this order of parameter names: Diffuse_Texture, PM_Diffuse, BaseColor, Diffuse, MainTex.

4. Animations

TFT clips use ACL compression. The codec class is FACLCompressedAnimData. CUE4Parse needs its native library to decompress ACL data.

The native library builds on macOS. Apple clang 21 makes one warning into an error. Disable that one warning:

bash
cd CUE4Parse/CUE4Parse-Natives && mkdir builddir && cd builddir
cmake .. -DCMAKE_BUILD_TYPE=Release \
  -DCMAKE_CXX_FLAGS="-Wno-missing-template-arg-list-after-template-kw"
cmake --build . --config Release

Copy CUE4Parse-Natives.dylib into the output directory of the application. The project file copies this library only on Windows. Without the file, .NET gives a DllNotFoundException.

DANGER

CUE4Parse cannot write animations to glTF. It supports only ActorX, UEFormat and USD. Write the keyframes to JSON, then add them to the .glb file with inject_anims.py.

The coordinate conversion

Use the same conversion that the mesh exporter uses. A different conversion moves the clips away from the bind pose.

DataConversion
Vector(X, Y, Z) becomes (X, Z, Y)
Quaternion(X, Y, Z, W) becomes (X, Z, Y, -W)
TranslationMultiply by 0.01

The source of these rules is CUE4Parse-Conversion/Writers/Gltf/Gltf.cs.

The track index

A track index points to a bone of the skeleton asset. The mesh has a different bone list. For Xayah, the skeleton has 234 bones and the mesh has 127 bones. Match the tracks to the nodes by bone name. Do not match them by index.

The bone names use the League convention, for example L_Foot, C_Weapon_1_Root and Wing4_SKN.

5. Submesh visibility events

Unreal keeps this data in AnimNotifyStates. This data is equivalent to the submeshVisibilityEvents of the WAD pipeline.

The data has three parts:

The times are in UAnimSequence.Notifies[]. Each entry has NotifyName, Duration, TriggerTimeOffset, TrackIndex and a reference to a notify state class. The start time is LinkValue plus TriggerTimeOffset. SegmentBeginTime is always 0, so do not use it.

The parameters are in Blueprint assets. There are approximately 140 of these assets. They are near the clips, in Character_<X>/Content/Animations/. The name gives the contract:

Asset nameClass default property
ANS_Hide_Mesh_<Slot>Material_Slot_Name: "<Slot>_Mat"
ANS_Show_Mesh_<Slot>Material_Slot_Name: "<Slot>_Mat"
ANS_Initial_Hide_Mesh_<Character>Material_Slot_Names: [...]

WARNING

Read the parameters from the Blueprint class default object (Default__ANS_..._C). The notify instances inside the animation package have zero properties.

The runtime system is the Riot Chrono system. The reflection data shows TFTChronoAnimNotify with the fields EventStartTime and EventDuration, and UTFTChronoModuleSubmeshVisibility with the fields Mesh, MaterialSlotNames and bVisible.

To join the data to the mesh, remove the _Mat suffix from the slot name. The result is equal to USkeletalMesh.SkeletalMaterials[].MaterialSlotName.

Remove duplicate events. The same notify occurs one time for each TrackIndex. Use the key (class, LinkValue, Duration).

Example: Yunara

The mesh SK_Yunara_Base has 12 material slots. Only Body and Pants are always visible. The animations control the other 10 slots.

Bead_1 to Bead_5      hidden in 2 to 7 clips
Bead_6                hidden and shown, all 15 clips
Bead_7, Bead_8        hidden in all 15 clips
Dragon_Head, Tail     hidden in all 15 clips

The WAD extraction gave all 12 slots with no visibility data. The result was incorrect. The changeMeshTexture fix for Bead_2 to Bead_8 treated a visibility problem as a texture problem. The bead textures were always correct.

6. Requirements of the Khada engine

A model can load and animate and still be incorrect. The engine has three requirements. Each requirement caused a failure during this work.

6.1 Unlit materials

Add the KHR_materials_unlit extension to each material. Add KHR_materials_unlit to extensionsUsed.

The engine has one SpotLight with the intensity 0.01. League models are unlit. Without the extension, three.js makes a MeshStandardMaterial, and the model is fully black.

6.2 A node with the name Skeleton

The engine calls scene.getObjectByName("Skeleton"). Give this name to the armature root node, which is the parent of the first joint.

The extras of this node hold submeshVisibilityEvents, flipbookEvents, flipbookKeyframes, vfxEvents, animationSequences and initialAnimation.

DANGER

Add the Skeleton node to every model, not only to the models that have visibility events. standardizeModelStructure in the processor rebuilds any model that has no Skeleton node or no Meshes node. The rebuild applies the scale [-0.009, 0.009, 0.009], because WAD models use League units. Our models use metres, so the model becomes approximately 100 times too small. The model still loads and animates, but it is a small point in the viewer. This error occurred on 61 of 64 champions, because only 3 champions have visibility events.

WARNING

The League scale has a negative X value, which mirrors the model. Our models do not have this mirror. A Unreal model can be a mirror image of the equivalent WAD model. This is not confirmed.

6.3 A node with the name Meshes

The Meshes node must have one SkinnedMesh child for each submesh.

DANGER

The engine reads object.material for each child. This property is singular. CUE4Parse writes one mesh with N primitives, and three.js makes one group from it. Then the engine finds only the first material. The visibility data has no effect, and no error occurs. You must divide the primitives into separate mesh nodes.

Material extras

Give each material these extras:

FieldUse
hashThe identifier in the show and hide lists
visiblefalse if an ANS_Initial_Hide_* notify hides the slot

The engine function handleInitialMeshVisibility reads visible. The event list does not control the initial state.

Textures

CUE4Parse writes the material data to a MI_*.json file and the textures to PNG files. It does not put the textures into the .glb file. Add each diffuse texture to the file as a buffer view, an image and a texture. Then set pbrMetallicRoughness.baseColorTexture for each material.

Why the WAD models are wrong

Riot moved TFT to Unreal with Set 18. The WAD assets are the remains of the previous engine. Riot does not keep them correct.

Correct data from the Unreal build:

ChampionMeshDiffuse texture
XayahSK_Xayah_Skin08Xayah_Elderwood_variant01_TX_CM
YorickSK_Yorick_Skin30Yorick_SpiritBlossom_variant01_TX_CM
MorganaSK_Morgana_Skin26Morgana_Coven_variant01_TX_CM
WarwickSK_Warwick_Skin35Warwick_OldGod_TX_CM
SivirSK_Sivir_Skin61Sivir_PrimalAmbush_TX_CM
ZyraSK_ZyraZyra_Base_TX_CM
YunaraSK_Yunara_BaseT_Yunara_Base_Variant01

The Xayah texture is a variant01 file. This file is only in the Unreal build. It is not the _Base_ file that the live game shows, and it is not a simple _Skin08_ file.

A texture name that ends with _FX_Mask_TFT_Set18_ImportsOnly shows an asset that is specific to Set 18.

WARNING

The Lux exclusion in extra-models-excludes.yml is not correct. We removed 9 Lux forms because the WAD textures were identical. The Unreal build has 9 different Lux meshes: SK_Lux_Skin07, _Fire, _Magma, _Mystic, _Nature, _Storm, _Variant02, SK_Lux_Air_Skin07 and SK_LuxWater_Skin07. The forms are real.

Known problems

  • CUE4Parse writes Windows path separators. On macOS it makes one file whose name contains backslash characters. The file goes to the current directory, not to the directory that you give. Move the files and correct the names.
  • The .glb files are approximately two times larger than the WAD files. They contain 8 UV sets and no compression. Use the standard compression pipeline before you upload them.
  • ETextureCompressionSettings is not in the usmap file. This enum is only for the editor, and the shipping build removes it. This is correct behaviour.
  • Xayah has an initial-hide list with the names Recall_Mat and Rose_Mat. These slots are not in the mesh. The cook step possibly removes sections that are always hidden. This is not confirmed.
  • Three champions have no character plugin: CrimsonRaptor, NunuWillump and DebugAugmentChamp.

Commands

bash
# Make the usmap file
python3 make_usmap.py bin/TFTClient-Win64-Shipping.exe tft.usmap

# Make one champion: mesh, textures, animations and visibility events
python3 build_champion.py Character_Yunara_Base /tmp/out

# Show the materials and textures of one mesh
dotnet run -- --mats ../Paks ../tft.usmap "SK_Yunara_Base"

# Show the properties of one type from the usmap file
dotnet run -- --dump ../tft.usmap SkeletalMesh Actor

# Test a .glb file in the Khada engine
pnpm run web
node render/khada_shot.mjs <directory> <file.glb> out.png

Always test a model in the Khada engine. A model that looks correct in a different viewer can be black or incomplete in Khada.

Built for engineers and AI assistants working on Khada.