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
| Step | Tool | Result |
|---|---|---|
| 1 | tftdl_bins.py, tftdl_all.py | The game binary and the pak files |
| 2 | make_usmap.py | A .usmap mappings file |
| 3 | CUE4Parse | Mesh, skeleton, material and texture data |
| 4 | inject_anims.py | A .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-globalThe 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}.bundleThe chunks use zstd compression. The platform of the file does not prevent a download. Windows files download correctly to macOS.
Download these files:
| File | Size | Use |
|---|---|---|
TFT/Binaries/Win64/TFTClient-Win64-Shipping.exe | 193 MB | Input for the usmap dumper |
pakchunk1-WindowsClient.ucas | 4.7 GB | The asset data |
pakchunk1-WindowsClient.utoc / .pak | 33 MB / 134 MB | The asset index |
pakchunk0-WindowsClient.* | 82 MB | Engine content |
global.utoc / .ucas | 4.8 MB | Global 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:
cd /Users/arthur/tft-unreal-tools
python3 make_usmap.py bin/TFTClient-Win64-Shipping.exe tft.usmapThe 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 ClassFlagsDANGER
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 StructFlagsFEnumParams — 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 CppTypeUTF8Each 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 enumThe 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 LargeWorldCoordinatesRealTests 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,OptionalandEnumeach use one inner property.Mapuses 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
- Remove the C++ prefix from a class name. Remove one leading
A,UorIif an uppercase letter comes after it. Do not change struct names. They have no prefix. - The
bHasVersioningfield is anint32. It is not one byte. The CUE4Parse functionReadBooleanreads 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.
git clone --recurse-submodules https://github.com/FabianFG/CUE4Parse.git
dotnet build -p:CUE4PARSE_SKIP_NATIVE=trueWARNING
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>.uassetThe 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:
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 ReleaseCopy 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.
| Data | Conversion |
|---|---|
| Vector | (X, Y, Z) becomes (X, Z, Y) |
| Quaternion | (X, Y, Z, W) becomes (X, Z, Y, -W) |
| Translation | Multiply 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 name | Class 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 clipsThe 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:
| Field | Use |
|---|---|
hash | The identifier in the show and hide lists |
visible | false 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:
| Champion | Mesh | Diffuse texture |
|---|---|---|
| Xayah | SK_Xayah_Skin08 | Xayah_Elderwood_variant01_TX_CM |
| Yorick | SK_Yorick_Skin30 | Yorick_SpiritBlossom_variant01_TX_CM |
| Morgana | SK_Morgana_Skin26 | Morgana_Coven_variant01_TX_CM |
| Warwick | SK_Warwick_Skin35 | Warwick_OldGod_TX_CM |
| Sivir | SK_Sivir_Skin61 | Sivir_PrimalAmbush_TX_CM |
| Zyra | SK_Zyra | Zyra_Base_TX_CM |
| Yunara | SK_Yunara_Base | T_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
.glbfiles 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. ETextureCompressionSettingsis 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_MatandRose_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,NunuWillumpandDebugAugmentChamp.
Commands
# 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.pngAlways test a model in the Khada engine. A model that looks correct in a different viewer can be black or incomplete in Khada.