원클릭으로
general-modding
General MotorTown modding — PAK architecture, DataTable mechanics, mod compatibility, and the mt-pak-extract toolchain
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
메뉴
General MotorTown modding — PAK architecture, DataTable mechanics, mod compatibility, and the mt-pak-extract toolchain
Codex 또는 Claude로 설치 이 Prompt를 복사해 Codex, Claude 또는 다른 어시스턴트에 붙여 넣으면 Skill 페이지를 검토하고 설치를 진행할 수 있습니다.
SOC 직업 분류 기준
Create MotorTown vehicle part mods — tires (with custom physics DataAssets) and intakes (inline sub-struct tuning). Covers VehicleParts DataTable mechanics, PartType sub-structs, dot-path patching, and compat-mod support.
Create MotorTown cargo mods — new cargo types with blueprints, DataTable rows, and delivery point recipes
| name | general-modding |
| description | 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 alphabetically-last PAK wins. 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.
UE5 config .ini files can also be shipped inside PAK mods. The game reads config from the PAK virtual filesystem at:
MotorTown/Config/
├── DefaultEngine.ini ← base engine config
├── UserEngine.ini ← user overrides (higher priority)
├── DefaultGame.ini
└── ...
PAK path for config overrides: MotorTown/Config/UserEngine.ini
To ship a config-only mod, create the INI file in the staging directory at MotorTown/Config/ and pack it with mod_pack:
mkdir -p /tmp/staging/MotorTown/Config
cat > /tmp/staging/MotorTown/Config/UserEngine.ini << 'EOF'
[Audio]
UnfocusedVolumeMultiplier=1.0
EOF
cargo run --release --quiet --bin mod_pack -- /tmp/staging MyConfigMod_P.pak
[!NOTE] The
MotorTown/Config/path is relative to the mount point (../../../), resolving fromContent/Paks/up to the game root then intoMotorTown/Config/. Do NOT useMotorTown/Saved/Config/Windows/— that path is for runtime user config on disk, not PAK-based config overrides.
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)
PartType: Intake
├── Intake.Slope → torque curve slope (higher = more torque bias)
├── Intake.BaseRPMRatio → RPM ratio where effect begins (lower = earlier response)
├── Intake.IntakeSpeedEfficencyMultiplier → overall HP multiplier
├── VehicleTypes: [Small]
├── VehicleKeys: [...]
└── ... (common fields same as above)
PartType: Turbocharger
├── Turbocharger.bIsValid → must be true for turbo to activate
├── Turbocharger.TorqueMultiplier → peak torque multiplier
├── Turbocharger.BaseTorqueMultiplier → base torque at all RPMs
├── Turbocharger.IntakePressureMultiplier → boost pressure
├── Turbocharger.TurbineWeight → rotational inertia (spool time)
├── Turbocharger.TurbineAspectRatio → turbine A/R ratio
├── Turbocharger.HeatingMultiplier → engine heat generation
├── Turbocharger.FuelConsumptionMultiplier → fuel usage
└── ... (common fields same as above)
Intake parts can simulate superchargers: low BaseRPMRatio (instant response), positive Slope (torque), high EfficiencyMult (HP). Unlike turbochargers, intakes have no spool lag (no TurbineWeight). See the intake-mod skill for full details.
Vanilla values (v0.7.18+1):
| Type | Row | Key Values |
|---|---|---|
| Intake | Row 201 | Slope=0.1, BaseRPMRatio=0.7, EfficiencyMult=1.5 |
| Intake | Row 202 | Slope=-0.1, BaseRPMRatio=0.8, EfficiencyMult=0.7 |
| Turbocharger | Stock | TorqueMult=1.1, BaseTorqueMult=0.98, TurbineWeight=30 |
| Turbocharger | Stage1 | TorqueMult=1.2, BaseTorqueMult=0.95, TurbineWeight=100 |
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.
--compat-mod Is Not Enough)The --compat-mod flag works when your mod only adds rows to a DataTable that the other mod also modifies. But if you need to combine mods that each bring their own assets (e.g., tire physics .uasset files), --compat-mod only extracts the DataTable — the other mod's assets are lost.
Example: Combining PD Parts (6 car tire physics assets + VehicleParts0 with intakes) with Gunthoo bike tires (6 bike tire physics assets + VehicleParts0 with bike tires).
The problem:
create_tirepack.py --compat-mod PDParts.pak → extracts PD Parts' VehicleParts0 as template, adds bike tire rows ✅Solution — Manual merge:
# 1. Extract both PAKs to a staging directory
# PD Parts → staging/MotorTown/Content/...
# Bike tires → staging/MotorTown/Content/... (overwrites VehicleParts0)
# 2. Patch cross-mod changes (e.g., add Gunthoo_Police to SC intake VehicleKeys)
# Use --patch-rows on the merged VehicleParts0
# 3. Build final PAK from the merged staging directory
# mod_pack staging/ output.pak
Key rule: When combining mods that each have .uasset assets (not just DataTables), you MUST manually merge the asset directories before repacking.
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_intakepack.py | Intake 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.
The C# ResolvePropertyWithContainer function resolves dot-separated paths through StructPropertyData containers. This means Intake.Slope correctly traverses into the Intake struct and finds the Slope float inside it:
// "Intake.Slope" resolves as:
// 1. Find "Intake" in row properties → StructPropertyData
// 2. Traverse into struct.Value (its child properties)
// 3. Find "Slope" in those children → FloatPropertyData
This works with set_or_add_float, set_or_create_name, set_or_create_int, etc. If the property doesn't exist, the set_or_add_* variants clone the first same-type property in the container as a template and add it.
[!IMPORTANT] Only ONE level of dot-path nesting is needed for VehicleParts sub-structs (e.g.,
Intake.Slope,Turbocharger.bIsValid). The sub-structs themselves are direct children of the row.
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)VehicleKeys includes the target vehicle — intakes with non-empty VehicleKeys are restricted to ONLY those vehicles_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()}')
"
.uasset from the built .pak file, not from intermediate temp directories/tmp/nix-shell.*/modpack_*/) persist across builds inside nix develop sessions. These contain configs from previous builds and are NOT reliable indicators of current PAK contents# Extract the specific asset from the built PAK
cargo run --release --quiet --bin mod_explore -- \
mods/<name>/builds/<pak_file> --extract \
MotorTown/Content/<path>/<Asset>.uasset mod_out/
cargo run --release --quiet --bin mod_explore -- \
mods/<name>/builds/<pak_file> --extract \
MotorTown/Content/<path>/<Asset>.uexp mod_out/
# Dump and verify values
cd csharp/UAssetTool && dotnet run --configuration Release --verbosity quiet -- \
--dump /path/to/repo/mod_out/<Asset>.uasset
.uexp:
import struct
with open("mod_out/Asset.uexp", "rb") as f:
data = f.read()
for val in [100, 20, 15, 10]:
encoded = struct.pack('<i', val)
offsets = [i for i in range(len(data)-3) if data[i:i+4] == encoded]
print(f"Value {val}: offsets {offsets}")
MTVehicleBaseBP.uasset + .uexp must be in the same directory as the target blueprint during patching. Without it, CDO reparse fails with schema resolution errorsMappings.usmap — Some structs like MTVehicleColorSlot have incomplete schemas. The patcher will fail with FormatException unless MainSerializer.cs is modified to skip unknown properties (return null with a warning instead of throwing)--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 addedset_or_add_float for new float properties — set_float is not a valid op. For adding properties like HornFadeInSeconds, use set_or_add_float which creates the property if it doesn't existHornSound in their schema. These will fail even with MTVehicleBaseBP present| 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 |