[{"id":"M585ffa","fact":"Asset lifecycle endpoints are /Game/-only; delete_asset and rename_asset require confirm=true while duplicate_asset does not, since it never destroys anything. delete_asset takes NO backup - there are zero BackupPackage calls in that file - and Game/Content is ~8.7 GB and unversioned (the only git repo in play covers the plugin's own source), so a bad write there is recoverable only from whatever backup the endpoint happened to take.","source":"MifBridgeAssetOps.cpp - `Fail(Out, TEXT(\"delete_asset requires confirm=true\"))` at :135 and the /Game/ prefix check at :139 (rename's two at :657 and :664); `grep -c BackupPackage` over that file returns 0, measured this pass. LINE CORRECTED: docs/02_GOTCHAS.md §10 cites :78-80, which is now generic path handling. docs","engine":"","ue":[],"bl":[],"section":"MifBridge asset lifecycle"},{"id":"M6eb37b","fact":"NSLOCTEXT(...) in a DataTable read is the engine's lossless FText export, not corruption - the display string is the third argument. UDataTable::GetTableAsJSON defaults to EDataTableExportFlags::None; the readable form is gated on UseSimpleText, which drops namespace and key and is therefore lossy.","source":"docs/02_GOTCHAS.md §5e; engine 5.3.2 source  all four citations verified verbatim - Engine/Classes/Engine/DataTable.h:328 (`GetTableAsJSON(const EDataTableExportFlags InDTExportFlags = EDataTableExportFlags::None)`), Public/DataTableUtils.h:21 (UseSimpleText with the 'display string, rather than their complex lossless form'","engine":"5.3.2","ue":["5.3"],"bl":[],"section":"MifBridge DataTables","value":"`read_datatable/get_datatable_row take textFormat: 'export' (default, round-trip safe) | 'simple' (lossy); aliases textMode, simpleText:true`"},{"id":"M1beff7","fact":"write_datatable_rows merge mode (replace:false, the default) makes a plain string an UNLOCALIZED FText and round-trips NSLOCTEXT input exactly; replace mode (replace:true) re-imports the whole table and makes a plain string a LOCALIZED FText with generated namespace '<TableName> [<guid>]' and key '<RowName>_<ColumnName>'. It wraps once and is then stable.","source":"docs/02_GOTCHAS.md §5e § The asymmetry that caused the bug report; engine 5.3.2 source  DataTableJSON.cpp:753 and :772 both verified as AssignStringToProperty call sites. Doc records the stability as verified live over three write/read cycles, 2 and 3 byte-identical.","engine":"5.3.2","ue":["5.3"],"bl":[],"section":"MifBridge DataTables","value":"`merge -> FJsonObjectConverter::JsonObjectToUStruct; replace -> CreateTableFromJSONString -> DataTableUtils::AssignStringToProperty`"},{"id":"M708566","fact":"Verifying that a write LANDED does not verify that the VALUE was understood: FNumericProperty::ImportText for a floating-point property accepts only [+-.0-9], stops at the first unreadable character, has no 'nothing consumed' guard, and returns success - so 'not-a-float' wrote 0.0 and the post-write re-export comparison agreed with itself because both sides derive from the same misparse.","source":"docs/01_POSTMORTEMS.md PM-006 (:1418); engine 5.3.2 source  PropertyNumeric.cpp - the floating-point branch is the bare `while( *Buffer == '+' || '-' || '.' || ('0'..'9') ) Buffer++;` at ~:127-137 with no consumed-anything check, verified by reading it. Sibling shapes at PropertyBool.cpp:384-397 (unrecognised word ->","engine":"5.3.2","ue":["5.3"],"bl":[],"section":"MifBridge input validation","value":"`wanted = export(scratch after import) -> \"0.000000\"; after = export(live value) -> \"0.000000\"; wanted == after -> \"verified\"`"},{"id":"M665c27","fact":"set_property and friends now type-check caller text before importing: numerics must parse WHOLE (exponent form '1e5' is refused because UE's parser reads only the mantissa and stores 1), bools must be True/False/Yes/No/On/Off/1/0, enums must be a real entry with the valid ones listed in the error (a wrong name used to import as index 0), and hard object refs must resolve. Structs/containers passed as export text cannot be pre-checked and the response says typeValidated:false plus typeValidationNote.","source":"docs/02_GOTCHAS.md §9:1137-1156 (the per-kind refusal table); docs/01_POSTMORTEMS.md PM-006 § Fix, which names the four converters it is reached from: CanonicaliseLeaf, set_property's string fast-path, override_inherited_component's string fast-path, and the material-expression writer","engine":"","ue":[],"bl":[],"section":"MifBridge input validation","value":"`MifBridge::ValidatePropertyText (declared MifBridgeHandlers.h, defined MifBridgeNodes5.cpp); MifBridge::ParseWholeNumber`"},{"id":"M5ae020","fact":"Never hand a live property address to a parser that can fail: FProperty::ImportText_Direct parses in place and can consume and zero the destination before deciding the text is invalid, returning nullptr after already damaging the target. The fix is to import into a scratch buffer seeded from the current value and publish only on success - seeding from the current value also preserves partial-struct-literal semantics.","source":"docs/01_POSTMORTEMS.md PM-003 (:1538) - formula verified verbatim there, including the second bug in the same bracket (PostEditChangeProperty firing unconditionally on a failed import)","engine":"","ue":[],"bl":[],"section":"MifBridge input validation","value":"`void* Scratch = FMemory::Malloc(Leaf->GetSize(), Leaf->GetMinAlignment()); Leaf->InitializeValue(Scratch); Leaf->CopyCompleteValue(Scratch, LeafAddr); R = Leaf->ImportText_Direct(*ImportStr, Scratch, LeafOwner, PPF_None, &ErrText);`"},{"id":"U82","fact":"**Nanite survives a re-import**, with `replaceExistingSettings` either true or false. Authoring Nanite on the UE side and then re-importing geometry from Blender is safe. This is ONE property - it does not license the same assumption for LOD settings.","source":"measured on UE 5.8 by the MifBridge-owning session, 2026-09-18: `NaniteSettings.bEnabled` set true via `set_property`, then the same FBX re-imported twice, once per arm; Nanite read back true both times. **The control matters and they ran it**: agreement between arms proves nothing if the fixture never touched the asset, so they checked the re-import response, which listed 980 imported assets including the mesh under test. Complements U78 (material assignments) measured here.","engine":"5.8","ue":["5.8"],"bl":[],"section":"UE materials, actor identity and PIE"},{"id":"U98","fact":"**A UE plugin whose binary statically imports a module from an OPTIONAL plugin dependency fails to load entirely when that plugin is not enabled, with an OS-level error that names nothing useful.** A plugin's `Binaries` directory joins the DLL search path only when that plugin is ENABLED, and `PluginManager.cpp` skips enabling a PRESENT plugin for at least five reasons besides absence - the platform disables it, its descriptor does not support the target platform, it is Enterprise in a non-Enterprise project, a specific version is pinned, or the reference is optional and was not found. So `\"Optional\": true` on a dependency the binary LINKS is not a safety margin, it is a false statement about what the binary needs.","source":"measured 2026-09-19 on stock `UE 5.3` after Epic's Technical Review returned MifBridge with one failure and this dialog: *\"Plugin 'MifBridge' failed to load because module 'MifBridge' could not be loaded. There may be an operating system error or the module may not be properly set up.\"* Reading `UnrealEditor-MifBridge.dll`'s import table showed **14 modules from 11 optional plugins** - Water, PCG, Niagara, GameplayAbilities, GameFeatures, ModularGameplay, ModelViewViewModel, LevelSnapshots, StateTree, GeometryScripting, IKRig. **Reproduced word for word**: removing the single `Water` reference from a packaged descriptor and setting `\"Water\": {\"Enabled\": false}` in the host project produced that exact string, as a message dialog, at `LogPluginManager: Error`. Three controls make the attribution clean - the plugin COMPILES against stock 5.3 (`RunUAT BuildPlugin`, 2m52s, ExitCode 0), it LOADS and binds all 458 routes in an unmodified clean project, and it still loads when the project disables four of those plugins (its own dependency list re-enables them). **A separate control settled a false lead**: the `Assertion failed: SynchObject` crash on `-ExecCmds=\"quit\"` under `-nullrhi -unattended` reproduces identically in a project with NO MifBridge, so it is the harness.","engine":"5.3","ue":["5.3"],"bl":[],"section":"UE materials, actor identity and PIE"},{"id":"U103","fact":"**A REQUIRED plugin dependency that is missing produces a NAMED, actionable message; the same dependency marked `\"Optional\": true` produces an OS-level error that names nothing.** This is the practical payoff of U98 and the reason to declare a linked plugin required rather than optional. Required and absent gives *\"This project requires the 'X' plugin. Install it and try again, or remove it from the project's required plugin list.\"* at `LogWindows: Error`, before the module is reached at all. Optional and absent lets the engine carry on, the OS loader then fails to resolve the import, and the user gets *\"Plugin 'MifBridge' failed to load because module 'MifBridge' could not be loaded. There may be an operating system error or the module may not be properly set up.\"* - which names the wrong plugin and gives no way to find the real one.","source":"measured 2026-09-19 on stock `UE 5.3`, headless, by taking the packaged descriptor and removing `\"Optional\": true` from its **MetaHumanCharacter** entry - a plugin that genuinely does not exist before UE 5.6, so it is absent on 5.3 without anything being faked. Opening a clean Blueprint-only project then logged the `This project requires the 'MetaHumanCharacter' plugin` line and stopped there. The Optional half of the contrast is U98's own reproduction, taken the same day on the same engine with the same harness. **Consequence for the shipped descriptor**: MetaHumanCharacter, Metasound, MassEntity, LiveLink and GameplayTagsEditor stay optional precisely because nothing links them - for those the engine really can carry on - while the ten the binary imports are required. **[CORRECTED 2026-09-22 by U270: \"nothing links them\" was read off the 5.3 binary, where MetaHuman does not exist. On 5.6-5.8 the binary statically imported MetaHumanCharacter and MetaHumanCharacterEditor, and a Blueprint-only project never enables that optional reference - the TRC dialog again. MetaHuman is now DELAY-LOADED (U272), which makes the optional declaration true; the other four are still not imported on 5.6-5.8.]**","engine":"5.3","ue":["5.3"],"bl":[],"section":"UE materials, actor identity and PIE"},{"id":"B140","fact":"**`export_mesh` writes FBX only - it refuses `.obj` and `.gltf`/`.glb` by design**, and says why: FBX carries its own axis and unit metadata so the round trip is verifiable, whereas UE's OBJ exporter swaps Y and Z, de-indexes to three vertices per triangle and writes no normals, with nothing in the file to say so. `import_mesh` DOES additionally take glTF and GLB; the asymmetry is deliberate.","source":"measured 2026-09-19 on Blender 5.0.1: `export_mesh{objects:[...355 names...], filepath:'...obj'}` refuses with *\"export_mesh: supported formats are FBX (got '.obj')\"* and the reasoning quoted above. `export_scene` is the verb that writes glTF, and `build_10_export.py` uses it for the .glb.","engine":"5.0","ue":[],"bl":[],"section":"UE materials, actor identity and PIE"},{"id":"U539","fact":"**On 5.8 an FBX import task that replaces an existing UStaticMesh is a re-import through UReimportFbxStaticMeshFactory, whatever options the task carries. When the mesh holds no UFbxStaticMeshImportData (for example InterchangeAssetImportData left by an earlier re-import through Interchange), the handler makes new import data from its ImportUI template, whose bCombineMeshes is off, so a multi-mesh FBX re-imports as its FIRST mesh only.** Measured: four objects sent three times to one path - sends 1 and 2 whole (2216 triangles, 4 sections), send 3 after the asset carried Interchange data: 960 triangles, 1 section, and the import answered ok. Give the asset FBX import data with the wanted options before replacing. 5.3.2 stock does not hit it through an FBX hand-off (its asset keeps FbxStaticMeshImportData).","source":"5.8: Editor/UnrealEd/Private/Fbx/FbxFactory.cpp:221-231 (existing UStaticMesh -> UReimportFbxStaticMeshFactory as the reimport handler); 5.8: Editor/UnrealEd/Private/Factories/EditorFactories.cpp:5897-5903 (ImportData == nullptr -> GetImportDataForStaticMesh(Mesh, ImportUI->StaticMeshImportData)); 5.8: Editor/UnrealEd/Private/Fbx/FbxStaticMeshImportData.cpp:8-22 (constructor never sets bCombineMeshes); 5.8: Editor/UnrealEd/Private/Fbx/FbxStaticMeshImportData.cpp:24-40 (new data copies the template); measured 2026-10-12 on 5.8.2 and 5.3.2 (tools/test_hand_off.py SU135, fix 597f0257)","engine":"5.8","ue":["5.8"],"bl":[],"section":"UE generic routes settled live - create_asset, set_property, edit_container, call_function, evaluate_sequence (UE 5.8.2 scratch editor, 2026-10-04)"},{"id":"U629","fact":"**Unreal never reads an FBX shape's normals and computes a morph target's normal deltas itself, ONE PER POINT: each point takes the normal change of the last face corner listed at it, and every render vertex at that point gets that one change. So a hard corner gets one face's change on all its faces. Measured with a flat-shaded cube whose shape key turns it 30 degrees about Z: exported with 8 shared points (Blender lists the top and bottom faces last) Unreal's 24 render-vertex deltas each move 14.64 cm and none turns - under the normals compute, import and importWithTangents options and through Interchange alike; exported with its hard corners split into 24 points, the 16 side-face corners turn by 2 sin 15 = 0.5176 and the 8 top and bottom ones by 0. Blender's exporter writes the shape's Normals array as zeros, which nothing reads.**","source":"Live on stock UE 5.3.2 and 5.8.3, 2026-10-19: tools/fixtures/SKM_ProbeMorphTurnUnsplit.fbx and SKM_ProbeMorphTurn.fbx (tools/make_morph_fixture.py --turn [--unsplit], Blender 4.4) through import_asset and list_morph_targets deltas (tools/test_lane13_x2b.py MD101, MD103). Source: 5.8 LODUtilities.cpp :3258-3263 (WedgePointToVertexIndexMap.Add per corner) and :3133; 5.3 LODUtilities.cpp :3209; 5.3 FbxSkeletalMeshImport.cpp :1510; 5.8 FbxMesh.cpp :518; 5.8 UfbxMesh.cpp :373.","engine":"5.3-5.8","ue":["5.3","5.4","5.5","5.6","5.7","5.8"],"bl":[],"section":"UE generic routes settled live - create_asset, set_property, edit_container, call_function, evaluate_sequence (UE 5.8.2 scratch editor, 2026-10-04)"},{"id":"U633","fact":"**A Python `unreal.AssetImportTask` of an .fbx with `task.factory = unreal.FbxFactory()` and `task.options = unreal.FbxImportUI()` still imports through Interchange while `Interchange.FeatureFlags.Import.FBX` is 1 (the default): the new StaticMesh carries `InterchangeAssetImportData`, and `options.static_mesh_import_data.import_uniform_scale = 100` is ignored (a Blender FBX in metres came in 100x small: 1896 cm -> 19 cm). With `Interchange.FeatureFlags.Import.FBX 0` set around the call the legacy factory runs, the asset gets `FbxStaticMeshImportData`, and the scale holds (946 x 1436 x 826 cm, as built).**","source":"Live, 2026-10-19: UE 5.8.3 editor Python on 126 FBX files; bounding boxes and the asset_import_data class read back after each import; MifBridge import_asset's reply on a re-import named the asset's data \"was InterchangeAssetImportData\".","engine":"5.8","ue":["5.8"],"bl":[],"section":"UE generic routes settled live - create_asset, set_property, edit_container, call_function, evaluate_sequence (UE 5.8.2 scratch editor, 2026-10-04)"},{"id":"U634","fact":"**A replace import (`replace_existing = True`) of a StaticMesh through the legacy FBX factory uses the asset's STORED `FbxStaticMeshImportData` (its `import_uniform_scale` stayed 1.0 and the mesh stayed 100x small) rather than the task's FbxImportUI values. Setting the stored data's `import_uniform_scale` to 100 and importing again gave the right size. A `FbxStaticMeshImportData` made in Python (`FbxStaticMeshImportData(outer=mesh)`, no source file) makes that replace import open a modal \"Select Source File Index 0 for: <mesh>\" file dialog on the game thread, even with `automated = True`; `asset_import_data.scripted_add_filename(path, 0, \"\")` before importing avoids it.**","source":"Live, 2026-10-19: UE 5.8.3, read back `asset_import_data.import_uniform_scale` and mesh bounds before and after; the two dialogs observed by window title (Win32 EnumWindows) and closed with WM_COMMAND IDCANCEL.","engine":"5.8","ue":["5.8"],"bl":[],"section":"UE generic routes settled live - create_asset, set_property, edit_container, call_function, evaluate_sequence (UE 5.8.2 scratch editor, 2026-10-04)"},{"id":"U661","fact":"**`PKG_InMemoryOnly` is `PKG_CompiledIn | PKG_NewlyCreated`, so a new asset's package that has not been saved yet tests true for it - a filter meant to skip script packages with `PKG_InMemoryOnly` also skips every unsaved new asset.**","source":"5.8: Engine/Source/Runtime/CoreUObject/Public/UObject/ObjectMacros.h:175; live 2026-10-25 stock 5.8.3: a material made by create_material and edited, listed dirty by list_dirty_packages, was skipped by a scope filter on PKG_InMemoryOnly (GIT107 red) and caught with PKG_CompiledIn.","engine":"5.8","ue":["5.8"],"bl":[],"section":"UE generic routes settled live - create_asset, set_property, edit_container, call_function, evaluate_sequence (UE 5.8.2 scratch editor, 2026-10-04)"},{"id":"U674","fact":"**Stock 5.6.1's Lidar factory lists TXT, XYZ and PTS only, and a .las import cancels: RegisterHandler drops a handler that registers while the file IO's Instance is null.** A new ULidarPointCloudFileIO_LAS object runs the same constructor and registers; a new factory object then lists LAS.","source":"5.6 `Engine/Plugins/Enterprise/LidarPointCloud/Source/LidarPointCloudRuntime/Private/IO/LidarPointCloudFileIO.cpp:253`; 5.6 `Engine/Plugins/Enterprise/LidarPointCloud/Source/LidarPointCloudEditor/Private/LidarPointCloudFactory.cpp:26`","engine":"5.6","ue":["5.6"],"bl":[],"section":"UE generic routes settled live - create_asset, set_property, edit_container, call_function, evaluate_sequence (UE 5.8.2 scratch editor, 2026-10-04)","value":"Formats read live: TXT, XYZ, PTS; test_lane13_h2d1 PC 0/7 before, 7/7 after"},{"id":"U678","fact":"**FGroomConversionSettings defaults to no conversion, so a scripted groom import lays Blender's Y-up Alembic on its side; rotation X 90 with scale Y -1 lands it at (100x, -100y, 100z).**","source":"UE 5.3 Engine/Plugins/Runtime/HairStrands/Source/HairStrandsCore/Public/GroomSettings.h:15; UE 5.8 Engine/Plugins/Runtime/HairStrands/Source/HairStrandsCore/Public/GroomSettings.h:15; measured tools/test_wave3_w4d.py GR102","engine":"5.3-5.8","ue":["5.3","5.4","5.5","5.6","5.7","5.8"],"bl":[],"section":"UE generic routes settled live - create_asset, set_property, edit_container, call_function, evaluate_sequence (UE 5.8.2 scratch editor, 2026-10-04)","value":"no conversion: Unreal box (x, z, -y); rotation (90, 0, 0), scale (1, -1, 1): (x, -y, z), within 0.502 cm"},{"id":"U680","fact":"**An Alembic holding a mesh beside its curves is refused by the groom importer with the words a missing plugin gets, and Blender's export_hair writes the mesh too.**","source":"UE 5.8 Engine/Plugins/Importers/AlembicHairImporter/Source/AlembicHairTranslator/Private/AlembicHairTranslator.cpp:1139; measured 2026-11-01","engine":"5.8","ue":["5.8"],"bl":[],"section":"UE generic routes settled live - create_asset, set_property, edit_container, call_function, evaluate_sequence (UE 5.8.2 scratch editor, 2026-10-04)","value":"LogAssetTools: Unknown extension 'abc', with HairStrands and AlembicHairImporter both on"},{"id":"U681","fact":"**A GeometryCache imported from a Blender Alembic never reaches its last index, and the importer's frame k is Blender's frame k + 1, so Blender's frames f0 to f1 import as f0 - 1 to f1.**","source":"measured 2026-11-01, tools/test_handoff_gcache13b.py GC101-GC102","engine":"5.3-5.8","ue":["5.3","5.4","5.5","5.6","5.7","5.8"],"bl":[],"section":"UE generic routes settled live - create_asset, set_property, edit_container, call_function, evaluate_sequence (UE 5.8.2 scratch editor, 2026-10-04)","value":"frames 1-10: as 0-9 the last index reads 293 uu where Blender has 300; as 1-10 the first is lost; as 0-10 all match within 0.0001 uu"},{"id":"U688","fact":"**A delete leaves a static mesh in memory when a Python name holds its BodySetup, and an import into it keeps no registry row: the numbers read right, the file saves, and it opens neither then nor after a restart.** A name on the mesh itself did not hold it through the delete. describe_package after the delete: origin memory, no file, no row; after the import: file, no row; unreal.load_asset None. Restarted: the same, get_collision empty. With residentMovedAside the same send answers ok, one convex, a row, and load_asset gives StaticMesh, after a restart too.","source":"Rerun: python tools/test_hand_off.py, rows SU139 (5718ef36a); the restart read 2026-11-02 on 5.8.3","engine":"5.3-5.8","ue":["5.3","5.4","5.5","5.6","5.7","5.8"],"bl":[],"section":"UE generic routes settled live - create_asset, set_property, edit_container, call_function, evaluate_sequence (UE 5.8.2 scratch editor, 2026-10-04)"}]