源码仓库: https://github.com/awe0w0/Palworld-UE5-Mechanics-Lab

最近整理了一套 PalworldAttackLab v4 的架构资料。它表面上是一个单机机制测试器,底层实际跨过了 .NET 桌面端、Windows 进程内存、UE5 反射系统、原生桥 DLL、命名管道和游戏线程执行几个层级。
这篇不准备逐页介绍 UI,也不把它写成一份“偏移合集”。我更想记录的是:面对一个固定 Shipping 构建,怎样先证明对象身份,再选择最小的修改路径,并且让每次修改都能回读、恢复和解释。
先划清边界。本文对应 Steam build 24575825,引擎标识为 5.1.1-0+++UE5+Release-5.1-Pal,只讨论指定 EXE 哈希下的本地单机进程。程序会主动拒绝多人和 PvP 环境,也没有网络访问、反作弊绕过、驱动、常驻服务或资源文件修改。游戏更新后,文中的 RVA、对象索引和字段布局全部应视为失效。
先说结论:Palworld 没有抛弃 UE5 的骨架
分析过程中最重要的发现,不是某个字段的偏移,而是 Palworld 的 Shipping 包仍然保留了相当完整的 UE5 元对象关系:
FUObjectArray / GObjects
-> UObject
-> UClass / UStruct
-> UFunction
-> UObject::ProcessEvent(Object, Function, Params)
GWorld
-> UGameInstance
-> ULocalPlayer
-> APlayerController
-> Character / Pawn
Palworld 的业务对象是在这套骨架上继续生长的。UPalOptionSubsystem、UPalCharacterMovementComponent、UPalIndividualCharacterParameter、UPalTechnologyData、UPalDebugSetting 和 UPalCheatManager 等类型,都仍然遵循 UObject、UClass、Outer、CDO 和 UFunction 的基本关系。
这让程序可以把“一个看起来像地址的数字”逐级证明成“当前世界里、属于预期类、仍然有效的那个对象”,而不是拿着一次 dump 的地址盲写。
架构被刻意拆成两条路径
整个程序没有把所有功能都塞进同一种技术里。它根据修改是否依赖 UE 线程语义,分成两条路径:
WinForms / CLI
|
v
AttackSession --------------------------+
| |
v v
只读反射扫描器 BridgeClient / Pipe
| |
| 单个已验证 float | 固定协议请求
v v
短期外部写句柄 PalworldMechanicsBridge.dll
|
v
游戏线程 ProcessEvent
|
v
Pal / UE 业务对象
攻击倍率是一个例外。它位于活动 Option 子系统中的单个 float,在路径、哈希、UClass、Outer 世界链、单机标志和字段合理性全部通过后,外部程序才短暂打开 VmWrite | VmOperation,写入四字节,立即关闭写句柄,再用只读句柄逐位回读。
其余机制会触发 Setter、Getter、RPC 包装、OnRep、容器逻辑或 World Context,默认都依赖游戏线程。这部分不从外部直接调用裸函数地址,而是交给临时加载的原生桥,在游戏线程通过 UE 反射执行。
这种分法看起来麻烦,但职责很干净:能被严格证明为单字段事务的,就保持外部写入最小化;需要引擎语义的,就回到引擎自己的调用路径。
GObjects 不是地址簿,而是一套身份校验链
当前构建的 GObjects 是分块 TUObjectArray。每块最多 0x10000 个 FUObjectItem,每项大小 0x18。索引解析本身并不复杂:
chunkIndex = objectIndex / 0x10000
inChunk = objectIndex % 0x10000
item = Objects[chunkIndex] + inChunk * 0x18
uobject = item->Object
真正关键的是解析之后的验证。一个活动 UObject 至少要通过这些检查:
- 地址和虚表位于合理的用户态与目标模块范围。
UObject::Index没有越过GObjects.NumElements。GObjects[Index].Object能反向解析到同一个地址。UObject::Class等于预期 UClass,或沿SuperStruct继承于预期类。- CDO 同时满足
UClass::ClassDefaultObject、Class 回指和RF_ClassDefaultObject标志。 - UFunction 的
Outer精确等于拥有它的 UClass,原生入口仍落在主模块内。 - 活动业务对象的 Outer 链能够回到当前
GWorld。
bool IsExpectedObject(void* object, void* expectedClass)
{
return IsReadable(object)
&& IsModuleVTable(*(void**)object)
&& GObjects[object->Index].Object == object
&& IsA(object->Class, expectedClass)
&& OuterChainContainsCurrentWorld(object);
}
上面是概念化后的代码,实际实现还会检查结构大小、字段范围、CDO 标志和进程会话身份。这里的设计倾向很明确:索引只是候选入口,结构关系才是证据。
ASLR 之后,稳定的是相对关系
程序里保存的是“主模块基址 + 固定构建 RVA”,而不是某次运行时看到的绝对地址:
absolute = MainModule.BaseAddress + RVA
GObjects、GWorld 和 ProcessEvent 都按这个方式定位。进程每次启动后,模块基址会随 ASLR 改变;dump 里那些 0x000002... 形式的地址只属于生成 dump 的那次快照。
同理,活动 BP_PalOptionSubsystem_C 也不会永久绑定某个实例地址。程序先尝试固定构建的对象索引种子,失败后再按块扫描 GObjects,寻找精确 UClass 的非 CDO 实例,并要求它的 Outer 链包含当前 World、设置字段处于合理范围、多人/PvP 标志可读。
这不是让旧偏移“自动兼容新版本”,而是在同一固定构建里抵抗对象装载顺序和实例地址变化。版本边界依然不能省略。
为什么反射调用必须回到游戏线程
一次 UE 反射调用可以抽象成三个输入:
ProcessEvent(TargetObject, UFunctionObject, ParametersBuffer);
成员函数的 Target 是真实实例,静态 Blueprint 函数通常使用所属类的 CDO;UFunction 从已验证的 GObjects 项取得;参数缓冲必须与 dump 中的字段、返回值和 padding 精确一致。
例如移动倍率 Setter 的参数只有一个 FName 和一个 float:
struct SetMultiplierParams
{
uint64_t FlagName; // ComparisonIndex + Number
float Value;
};
但“参数拼对了”不等于“可以在任意线程调用”。UObject 操作、RPC、OnRep、World Context 和部分容器更新都默认依赖游戏线程。桥因此没有让管道工作线程直接执行命令,而是建立了一个小型状态机:
idle -> pending -> processing -> complete -> idle
工作线程只负责接收请求并设为 pending。临时 ProcessEvent 钩子在原始反射分发正常返回后,确认当前调用发生在目标游戏线程,再消费请求。桥自己的反射调用通过 thread-local 标记避免递归消费下一条请求,执行结果写入响应后再唤醒管道线程。
这条路径的本质不是“Hook 方便调用函数”,而是借用游戏本来持续发生的反射分发,把外部请求安全地搭到正确线程上。
桥协议尽量无聊,反而更可靠
外部 C# 与原生 C++ 之间使用固定大小的命名管道协议 v3:
- Magic 为小端
PLMB; - Request 固定
44字节; - Response 固定
200字节; - C# 使用显式布局,C++ 使用
Pack=1和静态断言; - 每个包都检查 Magic、Version 和 Size;
- 外部扫描得到的 World、Controller、Movement 指针必须与桥返回结果一致。
协议没有引入复杂序列化库,代价是结构升级必须同步修改两端;好处是 ABI 是否一致可以在程序启动时直接证明。对于一个只服务固定桥版本的小协议,“无聊”比灵活更重要。
桥的完整生命周期也被纳入会话管理:验证 DLL 路径、加载模块、连接按 PID 命名的管道、Ping、执行、恢复、卸钩,最后才允许卸载 DLL。任何一环失败,UI 都不会把会话标成已安全连接。
Shipping 里有 UFunction,不代表功能还活着
分析 UPalCheatManager 时遇到一个很有代表性的坑:反射表中能找到函数名,也能确认 Owner 和 UFunction,但 Shipping 原生实现可能已经被裁成桩。
CaptureSuccessAlways() 的实现是立即返回,IsCaptureSuccessAlways() 则是恒真返回。二者都保留了反射外壳,却不能组成一个真实可控的“捕获必中”状态。
因此程序没有因为函数名看起来正确就调用它,而是继续追踪捕获结果的真实分发路径。当前实现只在 UFunction 指针精确等于 APalCaptureJudgeObject::CaptureResult_ToALL 时,在原始 ProcessEvent 分发前修改对应参数结构。
if (captureOverrideEnabled && function == CaptureResultToAllFunction)
{
params->Result.IsSuccess = true;
params->Result.TestSuccessCount = 3;
params->Result.FailedCaptureType = 0;
}
这里的重点仍然是证据边界:函数指针和参数布局已经验证,开关与拦截结构可以工作;验证期间没有真正投球,所以不能把“实际捕获事件命中”写成已完成的端到端结论。
可逆不是一个按钮,而是一套事务模型
这套程序最花心思的不是“改进去”,而是“怎么回来”。不同状态的恢复方式并不相同:
- 攻击倍率保存连接时原 float,关闭短期写句柄后再回读。
- 飞行保存连接时的 MovementMode,恢复时调用对应 Start/End UFunction。
- 移动倍率使用桥自己的 FName 键,恢复为中性值
1.0,不触碰其他系统的倍率键。 - 摔落伤害、属性上限和全局时间倍率在首次修改前捕获原值。
- 属性点和科技点属于可能进入存档的进度数据,由外部会话保存原值,默认在退出前写回并触发 OnRep。
- 捕获覆盖在卸钩后自然失效,同时仍保存桥内部开关的初始状态。
“临时机制”和“进度数据”必须区分。前者往往随 DLL 卸载或反射恢复消失,后者可能在游戏保存时持久化。如果进程在恢复前异常退出,不能假装事务一定完成。
Shutdown 的优先级因此是先尽力恢复,再保证能够到达卸钩路径。即使世界正在切换、玩家对象暂时不可达,也要先保护 ProcessEvent 原始代码不被遗留补丁占住。直接卸钩能恢复代码完整性,但不能替代业务字段的成功回写,这两种“恢复”需要分别报告。
验证结果也分等级
这次文档把结论分成三种状态:运行时已验证、反射/静态已验证、未验证。没有触发条件的功能不会因为代码写完就被标成成功。
2026-08-22 的固定构建验证中:
- C# 与原生构建通过,0 警告、0 错误;
- Request
44、Response200的协议 ABI 通过; - 只读扫描取得
578184个 GObjects,并验证 40 个关键 UFunction 的 Owner/UClass; - 单机守卫确认
bIsMultiplay=false、bIsPvP=false; - 跳跃/移动、飞行、摔落伤害、属性点、属性上限、科技点、全局时间倍率均完成“修改 -> 独立回读 -> 恢复 -> 再回读”;
- 每轮结束后桥 DLL 均已卸载,游戏进程保持响应;
- 选中帕鲁复活缺少有效槽位,真实行为未验证;
- 捕获覆盖计数仍为 0,不能声称真实投球事件已经命中。
这种写法可能不如“全部功能完成”好看,但技术文档最重要的不是气势,而是读者能分辨:什么已经在真实进程闭环,什么只验证了结构,什么还没有证据。
版本升级时,旧配置应该直接判死
固定构建工具最危险的错觉,是把“同一版本内能动态找实例”误解为“游戏更新后还能自动适配”。正确的升级流程应该从新证据开始:
- 重新记录 EXE 哈希和 Steam build。
- 重新生成 GObjects、属性 dump、CppSDK、usmap 和反汇编映射。
- 重新定位 GObjects、GWorld、ProcessEvent,并复核 UObject/UClass/UFunction 基础布局。
- 重找 Pal 类、CDO、UFunction 索引与参数尺寸。
- 重新检查活动世界链、业务字段和 ProcessEvent 入口。
- 先只读 probe,再做最小幅度的可逆写入闭环。
- 最后检查原始代码字节、桥模块卸载状态和游戏响应。
任何一步不一致都应 fail closed。猜一个“附近看起来差不多”的偏移,也许能让 UI 显示成功,却会把整套身份验证设计变得毫无意义。
暂时的收尾
从结果看,PalworldAttackLab 不是一个把 WriteProcessMemory 包上 WinForms 的简单工具。它更像一个小型 UE5 运行时实验室:外部扫描器负责证明对象和版本,桥负责把必要操作送回游戏线程,协议负责固定边界,会话层负责恢复和交叉验证。
其中最值得留下的经验并不是某个具体 RVA,而是几个原则:地址要有身份链,反射要尊重线程语义,Shipping 函数要检查真实实现,修改要有独立回读,恢复要按数据类型设计,未触发的行为永远不能算验证成功。
这些原则以后换到别的 UE5 固定构建里仍然有用。真正需要重做的,是每一次版本更新后的全部证据。
