FTransaction seems to discard changes to a UPackage's MetaData preventing Undo/Redo implementation

I’m trying to implement tooling to modify an asset’s MetaData to store DEPRECATED flags. I have it all functional, saving, and loading appropriately. However now I’m trying to integrate it into the Undo/Redo system and I’m hitting a snag. It seems the FTransaction class isn’t detecting there’s a difference and is discarding the change instead of adding it to the transaction history so it can be undone.

Is there a known issue with MetaData changes not getting detected by this system?

Is there some other way to work around this?

Currently my code is something like this to add a DEPRECATED=True tag to the MetaData for the given `TArray<FAssetData> assetList`

FScopedTransaction Transaction(LOCTEXT("OnSetTagDeprecated", "Set Tag Deprecated"));
for (const FAssetData& assetData : assetList)
{
	if (UObject* curAsset = assetData.GetAsset())
	{
		UPackage* curPackage = curAsset->GetOutermost();
		check(curPackage != nullptr);

		const FString* existingTagValue = curPackage->GetMetaData().FindValue(curAsset, FName(TEXT("DEPRECATED"));
		if ((existingTagValue == nullptr) || (existingTagValue->Compare(TEXT("True")) != 0))
		{
			// Modify first so this asset gets snapshotted into the Undo Transaction buffer
			curPackage->Modify();
			curPackage->GetMetaData().SetValue(curAsset, FName(TEXT("DEPRECATED"), TEXT("True"));
		}
	}
}

Steps to Reproduce

This is expected the member that store those values aren’t part of the reflection system as such those modification are not captured by the transaction system.

You can still store that change into a transaction via a custom FCommandChange that would define the undo and redo behavior via some function instead.

To add the Command change to your current transaction simply do something like this.

// GUndo is a pointer on the current Transaction.
if (GUndo)
{
	GUndo->StoreUndo(curPackage, MoveTemp(MyCustomCommandChange));
}

I hope this can unblock you.

Just a quick note we don’t want track those in the engine out of box so no need to worry about that.

So for your first point, I think you can still use the modify function on the package to capture the flag change.

For the second point, the transactions in unreal can be stacked. Meaning you can have a active while creating another transaction. This result in doing only one transaction that capture all the sub transaction into one do/undo.

Pleasure,

Julien.

I appreciate the help. I found a reasonable example in SReferenceSkeletonTree with FSkeletonModifierChange. Here’s what might be a generic enough implementation you could consider.

The two remaining issues are:

  • It doesn’t undo the dirty flag like normal undo/redo will in FTransaction::Apply. I have no idea how to get this to function in the PackageRecordMap properly. Instead I just make sure SetDirtyFlag is called in Apply/Revert here. It’s a shame an Undo leaves it dirty but it’s better than no Undo.
  • If the MetaData is changed on a bunch of assets at once, it does them all individually and not in bulk. So you have to Undo once for every asset. I’m not sure how to bundle multiple asset’s changing MetaData into a single transaction properly.

Anyways, if you have any clues on how to improve this to fix the above two issue, great. Otherwise, thanks again for the help.

/**
 * FAssetMetadataChange
 *	A change given to the transaction system to assist with undo/redo of MetaData for an asset.
 * The constructor records the PreChange state. Call RecordPostChangeState to record/update the PostChange state.
 * Apply/Revert will swap between these two states as Redo/Undo get called on the given asset.
 */
class FAssetMetadataChange : public FCommandChange
{
public:
	FAssetMetadataChange(const FText& InDesc, const FAssetData& InAssetData)
		: FCommandChange()
		, Description(InDesc)
		, AssetData(InAssetData)
	{
		// Record AssetData's Metadata map before the change
		UObject* AssetObject = AssetData.GetAsset();
		check(AssetObject != nullptr);
		if (TMetaDataMap* MetaDataMap = FMetaData::GetMapForObject(AssetObject))
		{
			PreChangeMetaData = *MetaDataMap;
			PostChangeMetaData = *MetaDataMap;
		}
	}
 
	void RecordPostChangeState()
	{
		// Record the final MetaData state for the asset
		UObject* AssetObject = AssetData.GetAsset();
		check(AssetObject != nullptr);
		if (TMetaDataMap* MetaDataMap = FMetaData::GetMapForObject(AssetObject))
		{
			PostChangeMetaData = *MetaDataMap;
		}
	}
 
	const FText& GetDescription() const { return Description; }
	const FAssetData& GetAssetData() const { return AssetData; }
	
	/* Begin Implement FCommandChange */
	virtual FString ToString() const override
	{
		return FString::Printf(TEXT("'%s' for '%s'"), *Description.ToString(), *AssetData.GetSoftObjectPath().ToString());
	}
 
	// Redo the changes
	virtual void Apply(UObject* Object) override
	{
		if (UObject* Asset = AssetData.GetAsset())
		{
			UPackage* Package = Asset->GetPackage();
			check(Package != nullptr);
 
			if (TMetaDataMap* CurMetaDataMap = FMetaData::GetMapForObject(Asset))
			{
				*CurMetaDataMap = PostChangeMetaData;
				Package->SetDirtyFlag(true);
			}
			else
			{
				// We'll only set values if we have entries so we don't add a map where none is needed
				if (PostChangeMetaData.Num())
				{
					Package->GetMetaData().SetObjectValues(Asset, PostChangeMetaData);
					Package->SetDirtyFlag(true);
				}
			}
		}
		else
		{
			// LOG ERROR: Failed to load
		}
	}
 
	// Undo the changes
	virtual void Revert(UObject* Object) override
	{
		if (UObject* Asset = AssetData.GetAsset())
		{
			UPackage* Package = Asset->GetPackage();
			check(Package != nullptr);
 
			if (TMetaDataMap* CurMetaDataMap = FMetaData::GetMapForObject(Asset))
			{
				*CurMetaDataMap = PreChangeMetaData;
				Package->SetDirtyFlag(true);
			}
			else
			{
				// We'll only set values if we have entries so we don't add a map where none is needed
				if (PreChangeMetaData.Num())
				{
					Package->GetMetaData().SetObjectValues(Asset, PreChangeMetaData);
					Package->SetDirtyFlag(true);
				}
			}
		}
		else
		{
			// LOG ERROR: Failed to load
		}
	}
	/* End Implement FCommandChange */
 
private:
	typedef TMap<FName, FString> TMetaDataMap;
 
	FText Description;
	FAssetData AssetData;
	TMetaDataMap PreChangeMetaData;
	TMetaDataMap PostChangeMetaData;
};
 
/**
 * FScopedAssetMetadataChange
 *	Helper for recording and submitting FAssetMetadataChange and ensuring there's only one active at a time.
 */
class FScopedAssetMetadataChange
{
public:
	FScopedAssetMetadataChange(const FText& InDesc, const FAssetData& InAssetData)
	{
		check(ActiveChange == nullptr); // don't want to overwrite an existing ActiveChange
		ActiveChange = MakeUnique<FAssetMetadataChange>(InDesc, InAssetData);
	}
 
	~FScopedAssetMetadataChange()
	{
		if (!ActiveChange.IsValid())
		{
			return;
		}
 
		// Finalize the data in the change
		ActiveChange->RecordPostChangeState();
 
		UObject* ActiveChangeAsset = ActiveChange->GetAssetData().GetAsset();
		check(ActiveChangeAsset != nullptr);
 
		// Submit the transaction
		GEditor->BeginTransaction(ActiveChange->GetDescription());
		GUndo->StoreUndo(ActiveChangeAsset, MoveTemp(ActiveChange));
		GEditor->EndTransaction();
	}
	
	// Call cancel to disable the change so it won't finish recording on end
	void Cancel()
	{
		ActiveChange.Reset();
	}
private:
	FScopedAssetMetadataChange();
	FScopedAssetMetadataChange(const FScopedAssetMetadataChange&);
 
	// We only want to allow one FAssetMetadataChange at a time
	static TUniquePtr<FAssetMetadataChange> ActiveChange;
};
 
TUniquePtr<FAssetMetadataChange> FScopedAssetMetadataChange::ActiveChange;