UE4SS 中的 DataTable

UE4SS 中的 DataTable

背景

DataTable 是 Unreal Engine 中的一种数据结构,允许在运行时加载经过哈希处理的键值对。常见用途包括存储掉落表、升级所需经验值、Actor 的基础生命值/护甲等。

DataTable 原本设计为在游戏编译过程中填充,严格来说并不打算在运行时修改。Unreal 文档有时又与此说法矛盾,因此很难区分“设计意图”和“实际可行”。我的目标是在运行时从 C++ 环境完整执行读取、写入、更新、删除和遍历,而不使用蓝图。

为什么不直接创建替换 DataTable 的蓝图 Mod?

从技术上说,这可行。问题是你的 Mod 会成为唯一能够修改该 DataTable 的 Mod。对于希望同时使用多个修改同一 DataTable 的 Mod 的用户,这显然不理想。从可扩展性看,我给这个方案约 2/10 分。

DataTable 的结构是什么?

DataTable 由原生 Unreal 的 TMap 和 TSet 构建。如果熟悉 Java 的 HashMap 或 C# 的 Dictionary,就能理解其基本约定和用法。Unreal 的 DataTable 使用 FName 作为键,值则是继承自 FTableRowBase 的结构体。后面会进一步说明。

需要做哪些工作?

下面列出修改 DataTable 的几种可能方案,并评估各方案的可行性与稳定性,供大家参考。

方案一:实现 TMap

Unreal Engine 的 DataTable 公开了可访问的 RowMap 属性:

// DataTable.h
virtual const TMap< FName, uint8 * > & GetRowMap() const 
virtual const TMap< FName, uint8 * > & GetRowMap()  

GetRowMap() 是反射函数,使用 UVTD 文件即可方便地调用。问题在于 UE4SS 的 TMap 实现非常基础。当前可以这样使用:

// DataTable row format is <FName, CoolStruct>
struct CoolStruct : FTableRowBase
{
    FString SomeString;
    int_32 SomeNumber;
    bool SomeBoolean;
}

TMap<FName, unsigned char*> rowMap = dataTable->GetRowMap();
auto ptrElem = rowMap.GetElementsPtr();
for(int32_t i = 0; i < rowMap.Num(); i++)
{
    auto pair = &ptrElem[i];
    pair->Key;
    pair->Value;
    CoolStruct* row = reinterpret_cast<CoolStruct*>(pair->Value);
}

示例的行格式是 <FName, CoolStruct>,通过元素指针逐项取得键和值,再将值转换为对应行结构体指针。

问题在哪里?

UE4SS 的 TMap 无法很好地处理底层数据变化。以上方式用于读取和遍历 DataTable 尚可,但调用 dt->AddRow() 或 dt->RemoveRow() 后,底层 .GetElementsPtr() 就不再准确。查看 UE4SS 的 TMap 实现可以发现,除非只读,否则这种用法相当脆弱。

另外,当前 UE4SS TMap 的 .Num() 并不会实际计算 TMap 的大小。Num 属性仅在构造 UE4SS TMap 时设置,底层大小变化后不会更新。

如果只想读取 DataTable,这个方案或许合理。

如何让它支持修改?理论上可以在 UE4SS 中实现与 UE 原生行为一致的 TMap;TArray 已采用过类似方式。潜在问题是,如果不同 UE 版本的 TMap 底层逻辑或结构发生变化,就需要分别实现各版本的状态。另一种做法是在同一个 TMap.hpp/cpp 中通过 #if UE5_1 等条件编译集中处理。

实现 UE4SS TMap 能否用于修改 DataTable?我尚未完成全面调查,直觉是……也许可以?

为什么不能在 DataTable 对象上使用 FindRow/GetRow?

从 UDataTable 转储中获得的有用反射函数只有 GetRowMap()、RemoveRow() 和 AddRow()。这些已经不错,但遗憾的是,既不能直接取行,也无法借助 UE4SS 的 TMap 取行。

方案二:Kismet DataTable 辅助库

该方案利用 Unreal Engine 内置的蓝图 DataTable 辅助类。它提供以下反射函数:

static bool DoesDataTableRowExist
(
    UDataTable * Table,
    FName RowName
) 

static void GetDataTableRowNames
(
    UDataTable * Table,
    TArray< FName > & OutRowNames
) 

static bool GetDataTableRowFromName
(
    UDataTable * Table,
    FName RowName,
    FTableRowBase & OutRow
) 

看到这里,你也许已经想到:把下面这些函数组合起来,似乎就能完整支持 DataTable:

// DataTable reflected functions
AddRow();
RemoveRow();
Empty();

// DataTableFunctionLibrary reflected functions
DoesDataTableRowExist();
GetDataTableRowNames();
GetDataTableRowFromName();

DataTable 自身负责添加、移除和清空;DataTableFunctionLibrary 负责检查行是否存在、获取所有行名和按名取行。

但事情总有难点……

GetDataTableRowFromName() 特别棘手。简而言之,它可能能用,但还需要进一步实验。

理解下面内容最好先熟悉 Kismet、蓝图、FFrame 和蓝图脚本栈的工作方式。可以先阅读:

GetDataTableRowFromName() 使用了 CustomThunk 和 CustomStructureParam 这两个说明符。

CustomThunk:
The UnrealHeaderTool code generator will not produce a thunk for this function; it is up to the user to provide one with the DECLARE_FUNCTION or DEFINE_FUNCTION macros.

CustomStructureParam:
The listed parameters are all treated as wildcards. This specifier requires the UFUNCTION-level specifier, CustomThunk, which will require the user to provide a custom exec function. In this function, the parameter types can be checked and the appropriate function calls can be made based on those parameter types. The base UFUNCTION should never be called, and should assert or log an error if it is. 

上述说明符的含义是:

  • CustomThunk: UnrealHeaderTool 代码生成器不为该函数生成 thunk;用户必须通过 DECLARE_FUNCTION 或 DEFINE_FUNCTION 宏自行提供。
  • CustomStructureParam: 列出的参数都视为通配参数。该说明符要求 UFUNCTION 同时指定 CustomThunk,由用户提供自定义 exec 函数。在该函数中检查参数类型,并据此调用适当函数。基础 UFUNCTION 本身绝不应直接调用;若被调用,应触发断言或记录错误。

底层的 GetDataTableRowFromName() UFunction 只是一个桩。DataTableFunctionLibrary 用 DEFINE_FUNCTION(execGetDataTableRowFromName) 宏提供实际行为。来看其实现:

// DataTableFunctionLibrary.h
 /** Based on UDataTableFunctionLibrary::GetDataTableRow */
 DECLARE_FUNCTION(execGetDataTableRowFromName)
 {
     P_GET_OBJECT(UDataTable, Table);
     P_GET_PROPERTY(FNameProperty, RowName);
     
     Stack.StepCompiledIn<FStructProperty>(NULL);
     void* OutRowPtr = Stack.MostRecentPropertyAddress;

		P_FINISH;
		bool bSuccess = false;
		// The following line fails to find the StructProp. See notes below this code block for the specifics.
		FStructProperty* StructProp = CastField<FStructProperty>(Stack.MostRecentProperty);
		if (!Table)
		{
FBlueprintExceptionInfo ExceptionInfo(
	EBlueprintExceptionType::AccessViolation,
	NSLOCTEXT("GetDataTableRow", "MissingTableInput", "Failed to resolve the table input. Be sure the DataTable is valid.")
);
FBlueprintCoreDelegates::ThrowScriptException(P_THIS, Stack, ExceptionInfo);
		}
		else if(StructProp && OutRowPtr)
		{
UScriptStruct* OutputType = StructProp->Struct;
const UScriptStruct* TableType  = Table->GetRowStruct();
		
const bool bCompatible = (OutputType == TableType) || 
	(OutputType->IsChildOf(TableType) && FStructUtils::TheSameLayout(OutputType, TableType));
if (bCompatible)
{
	P_NATIVE_BEGIN;
	bSuccess = Generic_GetDataTableRowFromName(Table, RowName, OutRowPtr);
	P_NATIVE_END;
}
else
{
	FBlueprintExceptionInfo ExceptionInfo(
		EBlueprintExceptionType::AccessViolation,
		NSLOCTEXT("GetDataTableRow", "IncompatibleProperty", "Incompatible output parameter; the data table's type is not the same as the return type.")
		);
	FBlueprintCoreDelegates::ThrowScriptException(P_THIS, Stack, ExceptionInfo);
}
		}
		else
		{
FBlueprintExceptionInfo ExceptionInfo(
	EBlueprintExceptionType::AccessViolation,
	NSLOCTEXT("GetDataTableRow", "MissingOutputProperty", "Failed to resolve the output parameter for GetDataTableRow.")
);
FBlueprintCoreDelegates::ThrowScriptException(P_THIS, Stack, ExceptionInfo);
		}
		*(bool*)RESULT_PARAM = bSuccess;
 }

问题在于,从 C++ 调用 GetDataTableRowFromName() 时,Stack.MostRecentProperty 没有被填充。具体情况记录在以下 GitHub Issue 中:

其内部调用关系如下:

static bool GetDataTableRowFromName
(
    UDataTable * Table,
    FName RowName,
    FTableRowBase & OutRow
)

// Does some property reading, type checking, etc,
// Then internally it calls

static bool Generic_GetDataTableRowFromName
(
    const UDataTable * Table,
    FName RowName,
    void * OutRowPtr
) 

对我们的用途来说,把 OutRow 从 FTableRowBase 引用改为 void* 会更合适,但 Generic_GetDataTableRowFromName() 恰好无法通过反射访问。

核心问题是 execGetDataTableRowFromName() 会严格检查类型,确保函数正常执行,或者安全退出。作为蓝图节点,这种防护是合理的,它需要在蓝图框架中可靠工作。Stack.MostRecentProperty 用于判断我们希望从 DataTable 中取得的目标结构体类型。在蓝图调用环境中,Kismet 的 FFrame/Stack 流程会填充此属性。

还有什么办法?

我正在尝试手动设置 Stack.MostRecentProperty,让 GetDataTableRowFromName() 以为自己正作为合法蓝图调用的一部分执行,而非直接从 C++ 调用。和方案一一样,就功能可行性而言,我的评价是也许可以?

还有最后一个难点

仍需研究 DataTable 行结构体在内存中的存储方式。一些游戏似乎使用编译器打包,但具体范围尚不清楚。此外,有些游戏为减少内存占用、满足对齐和填充需求,合理安排了结构体成员;另一些则按便于阅读的顺序排列,未必有利于内存优化。

// NameTypes.hpp (UE4SS)

// TODO:   Figure out what's going on here
//         It shouldn't be required to use 'alignas' here to make sure it's aligned properly in containers (like TArray)
//         I've never seen an FName not be 8-byte aligned in memory,
//         but it is 4-byte aligned in the source so hopefully this doesn't cause any problems
// UPDATE: This matters in the UE VM, when ElementSize is 0xC in memory for case-preserving games, it must be aligned by 0x4 in that case
#pragma warning(disable: 4324) // Suppressing warning about struct alignment
#ifdef WITH_CASE_PRESERVING_NAME
    struct alignas(4) RC_UE_API FName
#else
    struct alignas(8) RC_UE_API FName // FNames in DataTable rows seem to only work with alignas(4)

这段代码中的 TODO 至今仍在 UE4SS 中。注释指出:为保证容器(如 TArray)内的对齐,不应需要额外使用 alignas;作者通常看到内存中的 FName 是 8 字节对齐,而源码为 4 字节对齐。后续更新又指出,在保留大小写的游戏中,UE VM 内存里的 ElementSize 为 0xC 时,必须按 0x4 对齐。DataTable 行中的 FName 似乎也只有 alignas(4) 才能正常工作。

对齐研究可能也会改善 DataTable 以外的其他部分。无论最终采用 TMap、蓝图辅助库还是其他方案,都必须完整理解对齐和填充。

说明

虽然我认为自己理解了主要影响因素,但无疑仍遗漏了一些细节,也可能误解了部分底层系统。如果你认为实际行为与文档不同,请告诉我;非常感谢帮助!

有其他想法?

欢迎到 UE4SS Discord 一起讨论、分享想法。虽然我目前负责 DataTable 功能,但所有帮助都非常宝贵。

其他资源

致谢

特别感谢 localcc 的指导,也感谢所有 DataTable 分支的早期试用者,尤其是第一位试用者 El。

感谢大家一直耐心等待。

—— bitonality


原文:docs/devlogs/datatables-in-ue4ss.md · 固定提交 560411f18512 · 来源与 MIT 许可