General MotorTown modding — PAK architecture, DataTable mechanics, mod compatibility, and the mt-pak-extract toolchain
This skill covers the foundational architecture of MotorTown modding — how PAKs work, how DataTables override, how to build compatibility with other mods, and the available toolchain. For specific mod types, see the cargo-mod and tire-mod skills.
MotorTown runs on Unreal Engine 5.5. Mods are shipped as _P.pak files (the _P suffix is critical — UE loads these as "patch" PAKs that override base game assets).
MotorTown/
├── Content/
│ └── Paks/
│ ├── MotorTown-Windows.pak ← base game (2.9 GB)
│ └── Mods/
│ ├── qxZap_MoreTuning_P.pak ← community mod
│ └── ASEAN_PoliceTyres_P.pak ← our mod
Load order is alphabetical. If two PAKs contain the same file (e.g., VehicleParts0.uasset), the . This is how compatibility conflicts happen.
| Scenario | Result |
|---|---|
| Only base game has the file | Base game version loads |
| One mod has the file | Mod version loads (overrides base) |
| Multiple mods have the same file | Last alphabetically wins, others are ignored |
| Mod has a NEW file (not in base) | File is added to the virtual filesystem |
[!CAUTION] There is no merging. If your mod includes
VehicleParts0.uassetwith 54 rows, and another mod includes it with 320 rows, one wins completely and the other is discarded.
Every PAK file mirrors the base game directory layout:
MotorTown/Content/
├── Cars/Parts/Tire/ ← tire physics assets
├── DataAsset/VehicleParts/ ← VehicleParts, VehicleParts0, Engines, etc.
├── Objects/Mission/Delivery/ ← cargo blueprints
├── Materials/Decal/ ← decal textures
└── ... ← any other game path
The mount point is ../../../ (three levels up from the PAK file location), which resolves to the game's root Content/ directory.
DataTables are the backbone of MotorTown's data-driven design. Most mod types work by adding rows to or overriding these tables.
| DataTable | Location | Rows (base) | Purpose |
|---|---|---|---|
VehicleParts | DataAsset/VehicleParts/ | 713 | Full vehicle parts catalog (engines, transmissions, tires, aero, etc.) |
VehicleParts0 | DataAsset/VehicleParts/ | 50 | Override/addon table — supersedes VehicleParts for shared categories |
Engines | DataAsset/VehicleParts/ | varies | Engine DataAsset refs |
Transmissions | DataAsset/VehicleParts/ | varies | Transmission DataAsset refs |
AeroParts | DataAsset/VehicleParts/ | varies | Aero body kits |
LSD | DataAsset/VehicleParts/ | varies | Limited-slip differential configs |
Cargos | DataAsset/ | ~100 | Cargo type definitions (CompositeDataTable — see below) |
Cargos_01 | DataAsset/ | ~100 | Child of Cargos — actual cargo rows |
Cargos_Deprecated | DataAsset/ | 1 | Child of Cargos — deprecated cargo rows |
Vehicles | DataAsset/ | ~80 | Vehicle definitions, types, flags |
Decals | Materials/Decal/ | ~423 | Decal texture catalog |
[!IMPORTANT] The game loads both
VehicleParts.uassetandVehicleParts0.uasset. For any part type (e.g., "Tire") that exists in both tables,VehicleParts0entries take precedence.
This means:
VehicleParts has 713 rows covering ALL part typesVehicleParts0 has 50 rows that override specific categoriesVehicleParts but VehicleParts0 has its own tire list, yours won't appearStrategy: For tire mods, only modify VehicleParts0 (50 rows). This avoids touching the massive 713-row VehicleParts table, which other mods also modify.
Cargos.uasset is a CompositeDataTable — a special UE5 DataTable subclass that merges rows from child DataTables via its ParentTables array property. The base game has:
Cargos (CompositeDataTable)
├── ParentTables[0] → Cargos_01 (DataTable, ~100 rows)
└── ParentTables[1] → Cargos_Deprecated (DataTable, 1 row)
At runtime, the engine loads Cargos and recursively loads + merges all child tables in ParentTables. Directly adding rows to the parent Cargos.uasset does NOT work — the engine re-merges from children and discards any rows not in a child table.
To add new rows, you must:
Cargos_ScheduleI) with your rowsCargos.uasset) to append your child to ParentTables[!CAUTION] The child DataTable MUST be clone-renamed from an existing DataTable using
--clone-asset. Simply renaming the output file viaoutput_filenamedoes NOT change the internal package path (NameMap[0]). The engine resolves assets by internal path, not filename — if the internal path says/Game/DataAsset/Cargos_Deprecatedbut the file is atCargos_ScheduleI.uasset, the engine cannot find it.
Correct 3-step flow:
# Step 1: Clone-rename to fix internal package path
clone_config = {
"assets": [{
"new_name": "Cargos_ScheduleI",
"old_name": "Cargos_Deprecated",
"new_path": "/Game/DataAsset/Cargos_ScheduleI",
"rename_exports": True,
"rename_imports": True,
"patch_namemap_0": True, # Critical: updates internal path
}]
}
run_generic("--clone-asset", clone_config, "Cargos_Deprecated.uasset", clone_dir)
# Step 2: Add rows to the clone
rows_config = {"output_filename": "Cargos_ScheduleI", "rows": [...]}
run_generic("--add-rows", rows_config, cloned_child, output_dir)
# Step 3: Patch parent to register child in ParentTables
parent_config = {
"patches": [{
"path": "ParentTables",
"op": "append_import_to_array",
"class_package": "/Script/Engine",
"class_name": "DataTable",
"package_path": "/Game/DataAsset/Cargos_ScheduleI",
"asset_name": "Cargos_ScheduleI",
}]
}
run_generic("--patch-export-props", parent_config, "Cargos.uasset", cargos_dir)
Both the parent Cargos.uasset and child Cargos_ScheduleI.uasset must be staged in the PAK at DataAsset/.
Use UE4SS Lua scripts to verify DataTable loading at runtime:
-- Check if objects are loaded
local obj = StaticFindObject("/Game/DataAsset/Cargos_ScheduleI.Cargos_ScheduleI")
if obj ~= nil and obj:IsValid() then
print("FOUND: " .. obj:GetClass():GetFName():ToString())
end
-- Force-load an asset (must run in game thread)
ExecuteInGameThread(function()
local asset = LoadAsset("/Game/DataAsset/Cargos_ScheduleI")
end)
Deploy UE4SS Lua scripts via SCP to Mods/<ModName>/Scripts/main.lua on the Windows machine.
Every VehicleParts row contains ALL part fields (tire, engine, aero, suspension, etc.), with PartType determining which fields are active:
PartType: Tire
├── Tire.TirePhysicsDataAsset → import reference to tire physics
├── VehicleTypes: [Small, Medium]
├── VehicleKeys: [Elisa_Police, Muhan_Police] ← vehicle restriction
├── LevelRequirementToBuy: {CL_Police: 10} ← career level gate
├── Cost: 2000
├── MassKg: 10
├── Name2.Texts: ["AMC Police 78"] ← display name
└── ... (hundreds of other fields, inactive for tires)
VehicleKeys is an array of vehicle key strings. If non-empty, only those vehicles can equip the part.
"vehicle_keys": ["Elisa_Police", "Muhan_Police", "Zydro_Police", "Nuke_Police", "Police_01", "PoliceInterceptor_01"]
| Vehicle | Key | Type |
|---|---|---|
| Elisa Police | Elisa_Police | Small |
| Muhan Police | Muhan_Police | Small |
| Zydro Police | Zydro_Police | Small |
| Nuke Police | Nuke_Police | Small |
| Police 01 | Police_01 | Small |
| Police Interceptor | PoliceInterceptor_01 | Small |
| Gunthoo Police | Gunthoo_Police | Bike |
Map of {CareerLine: MinLevel}. Player must reach the specified level to purchase.
"level_requirement": {"CL_Police": 10}
Career lines: CL_Driver, CL_Truck, CL_Police, CL_Racer, CL_Bus, CL_Taxi.
Array of vehicle size classes. Part only appears for vehicles of matching type.
"vehicle_types": ["Small"]
Values: Small, Medium, Large, HeavyMachine, MotorCycle.
Lets a part appear on vehicles it normally wouldn't fit (bypasses VehicleType restrictions).
Vehicles can equip Bus/Taxi licenses via the Parts map. This is entirely data-table driven — no blueprint changes needed.
| Field | Purpose | Example |
|---|---|---|
bIsBusable | Enables bus license slot in garage | true |
bIsTaxiable | Enables taxi license slot in garage | true |
VehicleTypeFlags | Vehicle type bitmask (0=normal, 16=bus) | 0 |
GameplayTags | Must include Vehicle.Bus for bus functionality | ["Vehicle.Bus"] |
Parts map | Must include EMTVehiclePartSlot::BusLicense → BusLicense0 | See below |
| Part RowName | PartType | Cost |
|---|---|---|
BusLicense0 | BusLicense | 20,000 |
TaxiLicense0 | TaxiLicense | 10,000 |
TaxiLicense1 | TaxiLicense | 12,000 |
TaxiLicense_Bike | TaxiLicense | 10,000 |
Use --patch-rows to modify an existing vehicle row:
{
"output_filename": "Vehicles_Ambulance",
"patches": [
{
"row_name": "Brutus_Ambulance",
"patches": [
{ "path": "bIsBusable", "op": "set", "value": true },
{ "path": "bIsTaxiable", "op": "set", "value": true },
{ "path": "GameplayTags", "op": "add_gameplay_tags", "tags": ["Vehicle.Bus"] },
{ "path": "Parts", "op": "add_map_entry", "key": "EMTVehiclePartSlot::BusLicense", "value": "BusLicense0" }
]
}
]
}
[!WARNING] Keep
VehicleTypeFlags: 0unless the vehicle is a dedicated bus variant. SettingVehicleTypeFlags: 16on a non-bus vehicle may cause unintended NPC spawning or delivery filtering. The base Bongo van hasbIsBusable: truewithVehicleTypeFlags: 0— the boolean + part slot is sufficient.
| DataTable | Vehicles | Path |
|---|---|---|
Vehicles | All vehicle types | DataAsset/Vehicles/Vehicles |
Vehicles_Ambulance | Ambi, Tavan_Ambulance, Brutus_Ambulance | DataAsset/Vehicles/Vehicles_Ambulance |
Vehicles_Bus | Bongo, Bongo_Bus, Roadmaster | DataAsset/Vehicles/Vehicles_Bus |
Vehicles_Truck | Brutus, Brutus_Wrecker, Brutus_Tanker, etc. | DataAsset/Vehicles/Vehicles_Truck |
Beyond DataTable mods, you can modify individual vehicle/actor blueprints by patching their Class Default Object (CDO) — the Default__X_C export in the .uasset.
When UE5 loads a blueprint class, CDO properties are initialized in this order:
AMTVehicle sets HornFadeInSeconds = 0.1)MTVehicleBaseBP sets HornSound = Horn).uasset file take precedenceThis means a mod PAK that overrides a blueprint's .uasset with new CDO properties will win over the parent defaults — exactly how the game's own truck blueprints override HornSound to TruckAirHorn_01.
# Patch a vehicle blueprint CDO property
cd csharp/UAssetTool
dotnet run -- --patch-cdo-arrays config.json template.uasset output_dir/
The --patch-cdo-arrays command:
Default__X_C CDO export (may need to reparse from RawExport)cdo_patches — property-level modifications using the patch enginearrays — array-level additions/replacementsBlueprint CDOs use unversioned property serialization. UAssetAPI needs the full class schema chain to parse them. For blueprint-generated classes (anything ending in _C):
ClassExport.LoadedProperties.uasset files in the same directory[!IMPORTANT] Copy the parent blueprint (e.g.
MTVehicleBaseBP.uasset+.uexp) alongside the target before patching. Without it, CDO reparse fails with"Failed to find a valid property for schema index N".
CDOs only serialize properties that differ from the parent default. If a property value matches the parent, it's not stored — it's inherited at runtime.
This has a critical implication for patching: if you want to change an inherited property (e.g. HornSound on a vehicle that uses the default horn), the property doesn't exist in the CDO data. Use set_or_create_import_ref instead of set_import_ref to handle this:
{
"cdo_patches": [
{
"path": "HornSound",
"op": "set_or_create_import_ref",
"class_package": "/Script/Engine",
"class_name": "SoundWave",
"package_path": "/Game/Sounds/Vehicle/Horn/TruckAirHorn_01",
"asset_name": "TruckAirHorn_01"
}
]
}
Change Jemusi's horn from default car horn to truck air horn:
nix develop --command bash -c '
# 1. Ensure parent blueprint is accessible for schema resolution
cp MTVehicleBaseBP.uasset out/MTVehicleBaseBP.uasset
cp MTVehicleBaseBP.uexp out/MTVehicleBaseBP.uexp
# 2. Patch CDO
cd csharp/UAssetTool && dotnet run --configuration Release --verbosity quiet -- \
--patch-cdo-arrays mods/truck-horn/horn_patch.json ../../out/Jemusi.uasset /tmp/horn_out
cd ../..
# 3. Build PAK
mkdir -p /tmp/horn_staging/MotorTown/Content/Cars/Models/Jemusi
cp /tmp/horn_out/Jemusi.{uasset,uexp} /tmp/horn_staging/MotorTown/Content/Cars/Models/Jemusi/
cargo run --release --quiet --bin mod_pack -- /tmp/horn_staging ASEAN_JemusiTruckHorn_P.pak
# 4. Cleanup
rm -f out/MTVehicleBaseBP.{uasset,uexp}
'
| Sound | Asset Path | Used By |
|---|---|---|
| Car horn | /Game/Sounds/Vehicle/Horn/Horn | Most cars (default via MTVehicleBaseBP) |
| Truck air horn | /Game/Sounds/Vehicle/Horn/TruckAirHorn_01 | All trucks |
| Bike horn | /Game/Sounds/Vehicle/Horn/Bike_01 | All motorcycles |
When two mods modify the same DataTable (e.g., VehicleParts0.uasset), the alphabetically-last PAK wins and the other's changes are completely lost.
Example conflict:
qxZap_MoreTuning_P.pak → VehicleParts0 with 320 rows (engines, tires, LSD, etc.)
ASEAN_PoliceTyres_P.pak → VehicleParts0 with 54 rows (base 50 + 4 tires)
Result: Our 54-row version loads (alphabetically last), MoreTuning's 320 rows are lost.
--compat-modThe build tools support a --compat-mod flag that extracts a DataTable from another mod's PAK and uses it as the base template. Your additions are layered on top.
# Build standalone (base game only)
python3 scripts/mods.py build police-tyres
# Or directly with script:
python3 scripts/create_tirepack.py \
--config mods/police-tyres/tire_entries.json \
--output ASEAN_PoliceTyres_P.pak
# Build compatible with MoreTuning
python3 scripts/create_tirepack.py \
--config mods/police-tyres/tire_entries.json \
--output ASEAN_PoliceTyres_MoreTuningCompat_P.pak \
--compat-mod path/to/qxZap_MoreTuning_P.pak
How it works internally:
mod_explore extracts VehicleParts0.uasset from the compat mod PAKYou can chain multiple --compat-mod flags. They're processed in order — the last one that contains VehicleParts0 wins as the base template:
python3 scripts/create_tirepack.py \
--config mods/police-tyres/tire_entries.json \
--output output.pak \
--compat-mod MoreTuning_P.pak \
--compat-mod NoLimits_P.pak
If NoLimits_P.pak doesn't contain VehicleParts0, it's skipped (with a warning) and MoreTuning_P.pak's version is used.
[!TIP] Only include the DataTables you actually modify. Every DataTable in your PAK is a potential conflict point.
| Mod Goal | Only touch | Don't touch |
|---|---|---|
| Add tires | VehicleParts0 | VehicleParts, Engines, Transmissions, AeroParts |
| Add cargo | Cargos, delivery point assets | VehicleParts* |
| Add decals | Decals, texture assets | VehicleParts*, Cargos |
MoreTuning v2.2 modifies: VehicleParts0, AeroParts, Engines, LSD, License*, Transmissions.
NoLimits v2.2 modifies: AeroParts, Headlights, Wheels (no VehicleParts0 conflict).
The recommended naming pattern includes mod source, version, and compatibility variant:
{Studio}_{ModName}_v{Version}[_CompatMod]_P.pak
Examples:
ASEAN_PoliceTyres_v0.1.5_P.pak ← standalone
ASEAN_PoliceTyres_v0.1.5_MoreTuningCompat_P.pak ← MoreTuning compat
ASEAN_PoliceTyres_v0.1.5_MoreTuningNoLimitsCompat_P.pak ← MoreTuning + NoLimits
[!WARNING] Users should install only one variant of a mod. Installing both standalone and compat versions causes a double-override conflict.
Before building a compat version, analyze what DataTables the other mod modifies:
# List all files in a mod PAK
cargo run --release --bin mod_explore --quiet -- OtherMod_P.pak --list
# Search for specific DataAsset types
cargo run --release --bin mod_explore --quiet -- OtherMod_P.pak --list | grep DataAsset
# Extract a specific file for inspection
cargo run --release --bin mod_explore --quiet -- OtherMod_P.pak --extract \
MotorTown/Content/DataAsset/VehicleParts/VehicleParts0.uasset
# → extracts to mod_out/VehicleParts0.uasset
# Parse and count rows
cd csharp/UAssetTool
dotnet run --configuration Release --verbosity quiet -- /path/to/mod_out/VehicleParts0.uasset
When a new Motor Town update drops, the game PAK changes and extracted data (out/, motortown.db) must be refreshed. The versioning system uses git tags + worktrees + a data archive to manage multiple versions in parallel.
# Check current version
scripts/mt-version.sh status
# Archive current data after extraction
scripts/mt-version.sh archive v0.7.18
# Switch to a different version for building
scripts/mt-version.sh switch v0.7.17
# Create parallel worktree for old version
scripts/mt-version.sh worktree v0.7.17
See AGENTS.md "Game Versioning" section for full workflow.
| Binary | Command | Purpose |
|---|---|---|
mt-pak-extract | cargo run --release --quiet -- | Base game PAK extraction (AES decrypt + Oodle decompress) |
mod_explore | cargo run --release --quiet --bin mod_explore -- | List, search, and extract from mod PAKs |
mod_pack | cargo run --release --quiet --bin mod_pack -- | Pack a directory into a mod PAK |
Located in csharp/UAssetTool/. Run via:
cd csharp/UAssetTool
dotnet run --configuration Release --verbosity quiet -- <command> [args]
| Command | Purpose |
|---|---|
--batch | Parse all extracted .uasset files |
--dump <file> | Debug dump of a .uasset file |
--add-rows <config> <template> <outdir> | Add rows to any DataTable (clone-based) |
--patch-rows <config> <template> <outdir> | Modify existing DataTable rows by RowName |
--clone-asset <config> <template> <outdir> | Clone and rename any asset with property patches |
--patch-cdo-arrays <config> <template> <outdir> | Patch CDO properties and arrays in blueprint exports |
--patch-export-props <config> <template> <outdir> | Patch properties on the main export (e.g., CompositeDataTable ParentTables) |
| Script | Purpose |
|---|---|
scripts/modbase.py | Shared base module — ModBuilder class with common build infrastructure |
scripts/create_tirepack.py | Tire mod PAK builder (subclasses ModBuilder) |
scripts/create_cargopack.py | Cargo mod PAK builder (subclasses ModBuilder) |
scripts/create_decal_pack.py | Decal mod PAK builder (subclasses ModBuilder) |
scripts/aggregate_to_sqlite.py | Parsed JSON → SQLite database |
All mod-type scripts inherit from ModBuilder in scripts/modbase.py. The base class provides:
| Method | Purpose |
|---|---|
run_dotnet(args, label) | Run C# UAssetTool commands |
build_pak(staging_dir) | Build mod PAK via mod_pack binary |
verify_pak() | List PAK contents via mod_explore |
extract_from_compat_mod(pak, asset_path, dest) | Extract a single asset from another mod's PAK |
resolve_template_with_compat(base, pak_path) | Resolve DataTable template (base game or compat mod) |
stage_asset(src, pak_dir, name) | Copy .uasset/.uexp pair to PAK staging |
stage_datatable(src, name, subdir) | Stage a DataTable asset |
Build flow (template method pattern):
build() → transform_assets() → register_in_tables() → assemble_pak() → build_pak() → verify_pak()
Subclasses implement the three hooks to define their mod-type-specific logic.
The C# tool (Program.cs) provides shared helpers used across all DataTable commands:
| Helper | Used By | Purpose |
|---|---|---|
FindDataTable(asset) | cargos, tires, decals | Find first DataTableExport in an asset |
SetLocalizationGuid(prop) | cargos, tires | Set Name to random GUID for localization |
SetDisplayName(prop, json, asset) | cargos, tires | Set Name2.Texts display name array |
SetDescriptionFallback(prop, text) | tires | Set Desciption text fallback |
AddImportChain(asset, pkg, class, path, name) | cargos, tires, CDO patches | Add Package + Asset import pair |
SetEnumArray(arr, json, asset, enumType) | cargos, tires | Set enum array from JSON values |
The --patch-cdo-arrays, --patch-rows, and --patch-export-props commands use a JSON-driven patch engine. Key operations:
| Operation | Purpose | Creates if missing? |
|---|---|---|
set | Set numeric, bool, or string property | No |
set_enum | Set enum property | No |
set_import_ref | Set ObjectProperty to import reference | No |
set_or_create_import_ref | Set or create ObjectProperty with import reference | Yes |
set_or_add_float | Set or create float property | Yes |
set_or_create_name | Set or create name/string property | Yes |
set_or_create_int | Set or create integer property | Yes |
null_ref | Set ObjectProperty/SoftObjectProperty to null | No |
set_soft_object | Set SoftObjectProperty path | No |
clear_array | Empty an array property | No |
clear_map | Empty a map property | No |
add_gameplay_tags | Add tags to existing GameplayTagContainer | No |
add_map_entry | Add entry to existing map (clones key/value from existing entries) | No |
append_import_to_array | Append new import reference to array (e.g., ParentTables) | No |
The set_or_create_* variants are essential for patching inherited CDO properties that aren't serialized in the child blueprint.
Modifies existing DataTable rows by RowName without adding new rows. Uses the same patch engine as --patch-cdo-arrays.
cd csharp/UAssetTool
dotnet run -- --patch-rows config.json template.uasset output_dir/
Config format:
{
"output_filename": "Vehicles_Ambulance",
"patches": [
{
"row_name": "Brutus_Ambulance",
"patches": [
{ "path": "bIsBusable", "op": "set", "value": true },
{ "path": "GameplayTags", "op": "add_gameplay_tags", "tags": ["Vehicle.Bus"] },
{ "path": "Parts", "op": "add_map_entry", "key": "EMTVehiclePartSlot::BusLicense", "value": "BusLicense0" }
]
}
]
}
[!IMPORTANT] Unlike
--add-rowswhich clones a row and adds a new row,--patch-rowsfinds an existing row by name and applies patches in-place. This is essential when you need to modify a vehicle's properties without duplicating it.
To add a new mod type (e.g., engine mods, wheel mods):
Create a JSON format for the new mod type. Follow existing patterns:
mods/police-tyres/tire_entries.json with tire_physics + tire_part sectionsmods/schedule-i/cargo_entries.json with entries array + mods/schedule-i/recipe_entries.jsonProgram.csReuse shared helpers to minimize new code:
// Add command dispatch in Main()
else if (addEnginePartsMode)
{
// --add-engine-parts config.json template.uasset output_dir
var idx = Array.IndexOf(args, "--add-engine-parts");
// ...
AddEngineParts(configPath, templatePath, outputDir);
}
// Command handler — uses shared helpers
static void AddEngineParts(string configPath, string templatePath, string outputDir)
{
var asset = new UAsset(templatePath, EngineVersion.VER_UE5_5, Mappings);
var dtExport = FindDataTable(asset); // shared
if (dtExport == null) return;
var templateRow = dtExport.Table.Data[^1];
var newRow = (StructPropertyData)templateRow.Clone(); // deep-clone
SetLocalizationGuid(/* Name prop */); // shared
SetDisplayName(/* Name2 prop */, ...); // shared
var (_, importIdx) = AddImportChain(...); // shared
dtExport.Table.Data.Add(newRow);
asset.Write(outputPath);
}
Subclass ModBuilder:
# scripts/create_enginepack.py
from modbase import ModBuilder, add_common_args
class EngineModBuilder(ModBuilder):
def transform_assets(self):
# Create engine DataAsset files
self.run_dotnet(["--patch-engine", ...], "patch engine")
def register_in_tables(self):
# Add to Engines DataTable
template = self.resolve_template_with_compat(
base_template, "MotorTown/Content/DataAsset/VehicleParts/Engines.uasset")
self.run_dotnet(["--add-engine-parts", ...], "add engine parts")
def assemble_pak(self):
self.stage_asset(engine_asset, "Cars/Parts/Engine", name=engine_name)
self.stage_datatable(engines_dt, "Engines", "DataAsset/VehicleParts")
def print_summary(self):
self.log(f" Engines: {', '.join(...)}")
Add .agents/skills/{type}-mod/SKILL.md following the cargo/tire skill structure:
motortown.db is generated by aggregate_to_sqlite.py and contains normalized game data:
-- Find all police vehicles
SELECT id, name, vehicle_type FROM vehicles
WHERE id IN (SELECT vehicle_id FROM vehicle_tags WHERE tag LIKE '%Police%');
-- Find all tire parts
SELECT name, cost, mass_kg FROM vehicle_parts WHERE part_type = 'Tire';
-- Find delivery points
SELECT * FROM delivery_points;
Key tables: vehicles, vehicle_parts, vehicle_default_parts, vehicle_tags, cargos, cargo_weights, delivery_points.
These apply to ALL mod types when working with UAssetAPI in the C# tool.
[!CAUTION] When creating a new asset derived from an existing template (e.g., a new child DataTable), you MUST use
--clone-assetwithpatch_namemap_0: trueto rename the internal package path. Simply writing the file with a new filename (e.g., viaoutput_filenamein--add-rows) does NOT updateNameMap[0], which is the internal package path. The engine resolves assets by this internal path — if it doesn't match the file path,LoadAsset()andStaticFindObject()fail silently.
UE's FName system parses trailing _NN as an instance Number. BasicTire_45 is stored as FName("BasicTire", Number=46).
// ✅ Correct — explicit Number=0
export.ObjectName = new FName(asset, "APF_78", 0);
// ❌ Wrong — may parse _78 as Number=79
export.ObjectName = FName.FromString(asset, "APF_78");
Never construct DataTable rows from scratch. Always clone from an existing template row:
var newRow = (StructPropertyData)templateRow.Clone();
// Then modify properties in-place
Constructing properties manually corrupts unversioned header serialization.
When adding entries to DataTable maps (e.g. Parts in vehicle rows), the parsed JSON shows values as plain strings, but UAssetAPI internally stores them as different C# types depending on the map.
The Parts map uses:
EnumPropertyData (e.g. EMTVehiclePartSlot::BusLicense)NamePropertyData (NOT StrPropertyData)Constructing StrPropertyData values from scratch for a NamePropertyData map corrupts unversioned header serialization and causes runtime serialization errors.
// ✅ Correct — clone existing entry and modify
var firstVal = mapProp.Value.Values.OfType<NamePropertyData>().FirstOrDefault();
var valProp = (NamePropertyData)firstVal.Clone();
valProp.Value = FName.FromString(asset, "BusLicense0");
// ❌ Wrong — StrPropertyData doesn't match NamePropertyData map
var valProp = new StrPropertyData(...) { Value = FString.FromString("BusLicense0") };
Always debug-log the actual C# types before cloning:
foreach (var kvp in mapProp.Value)
Console.WriteLine($"Key: {kvp.Key.GetType().Name}, Val: {kvp.Value.GetType().Name}");
When adding new assets to a DataTable, you must add corresponding Import entries:
// 1. Package import
var pkgImport = new Import("/Script/CoreUObject", "Package",
FPackageIndex.FromRawIndex(0), "/Game/Cars/Parts/Tire/APF_78_Tire", false, asset);
asset.Imports.Add(pkgImport);
// 2. Asset import (outer = package)
var assetImport = new Import("/Script/MotorTown", "MTTirePhysicsDataAsset",
FPackageIndex.FromImport(pkgImportIdx - 1), "APF_78_Tire", false, asset);
asset.Imports.Add(assetImport);
_P.pakmod_explore --list (should be ../../../)mod_explore --list | grep DataAsset to identify conflicts--compat-mod pointing to the conflicting modVehicleTypes matches the vehicle classVehicleKeys includes the specific vehicleLevelRequirementToBuy — player may not have required levelbIsHidden is falseVehicleParts0 contains the row (override table takes precedence)_NN suffixpython3 -c "
with open('asset.uasset', 'rb') as f:
data = f.read()
import re
for m in re.finditer(rb'BasicTire|SmallBox|OldTemplate', data):
print(f'Stale ref at offset {m.start()}: {m.group()}')
"
MTVehicleBaseBP.uasset) was in same directory during patching--dump to verify the import was added (look for the asset name in imports)--dump to verify CDO size changed (compare with original)set_or_create_import_ref (not set_import_ref)RawExport — if reparse failed, the property was NOT added| File | Purpose |
|---|---|
AGENTS.md | Build commands, Nix dev shell, full pipeline docs |
src/bin/mod_pack.rs | PAK creator (V11, mount ../../../) |
src/bin/mod_explore.rs | PAK reader/extractor for mod analysis |
csharp/UAssetTool/Program.cs | Core UAsset manipulation tool |
motortown.db | SQLite database of parsed game data |
out/ | Extracted base game assets (templates) |
scripts/ | Python build pipeline scripts |