洛克王国:世界 — 完整逆向记录:从解包到自定义 PAK

本文记录了针对《洛克王国:世界》(Roco Kingdom: World)的完整逆向工程过程,包括 PAK 解包、ACE 反作弊绕过、自定义 ReShade addon 开发、PAK 格式逆向与生成,最终实现游戏内实时隐藏角色鞋子的效果。


目录

  1. 基本信息
  2. PAK 解包
  3. ACE 反作弊分析
  4. DLL 注入尝试(8 种方法全部被拦)
  5. 突破:d3d12.dll 绕过 ACE
  6. ShoeHide:自定义 ReShade Addon
  7. PAK 格式逆向与 PakMaker
  8. 总结

一、基本信息

项目
游戏 洛克王国:世界 (Roco Kingdom: World)
引擎 Unreal Engine 4.18
渲染 DirectX 12
平台 WeGame
反作弊 ACE (Tencent Anti-Cheat Expert) 内核级
版本 v1.0.0.3 (内部 0.0.0.493)
代号 NRC

二、PAK 解包

2.1 AES 密钥

游戏使用 AES-256 加密 PAK 文件。密钥来自 cs.rin.ru 论坛的 UE4/5 密钥收集帖:

0x34254D23E47299B3B7F6C4CFDE9BD0688703446D9D8F37B2EBDDDE5B06ED5ADF

2.2 解包工具链

游戏使用自定义 PAK 格式,需要专用 QuickBMS 脚本:

quickbms_4gb_files.exe \
  unreal_tournament_4_0.4.27e_roco_kingdom_world.bms \
  "<game>/Win64/NRC/Content/Paks" \
  "<output>"

解出 9057 个文件,包括骨骼网格体、动画序列、蓝图、贴图、材质等。

2.3 换装系统

游戏使用组件化换装系统:

Avatar 槽位:
├── Hair (发型)
├── Eyes (眼睛)
├── Skin (皮肤)
├── HeadWear (头饰)
├── Pendant (挂件)
├── Shoes (鞋子) — BP_AvatarShoes, DefaultHeight: 8.0
└── Wand (魔杖)

三、ACE 反作弊分析

3.1 驱动清单

驱动名 类型 位置
ACE-BASE.sys 内核驱动 System32\drivers\
ACE-ADVT.sys 内核驱动 System32\drivers\
ACE-BOOT.sys 内核驱动 游戏目录
ACE-CORE*.sys 内核驱动 游戏目录

5 个内核驱动,互相交叉校验,有自修复能力。

3.2 保护层面

┌────────────────────────────────┐
│  文件扫描层                     │
│  d3d11.dll, dxgi.dll 被拦截     │
│  xinput1_3.dll, version.dll 被拦│
├────────────────────────────────┤
│  进程保护层                     │
│  CreateRemoteThread → error 5  │
│  OpenProcess → 拒绝             │
├────────────────────────────────┤
│  内核驱动层                     │
│  自修复 + 反禁用                │
└────────────────────────────────┘

四、DLL 注入尝试

方法 结果
直接改 PAK 文件 .sig 签名校验
3DMigoto d3d11.dll ACE 文件扫描拦截
3DMigoto Loader 外部注入 error 5 (ACCESS_DENIED)
ReShade dxgi.dll + RockDLL FAILED
UE4SS xinput1_3.dll ACE 文件扫描拦截
UE4SS version.dll 破坏 ACE 自身加载
注册表禁用 ACE 驱动 自修复,重启后恢复
运行时 sc stop STOP_PENDING 挂起

全部 8 种方法被拦。


五、突破:d3d12.dll 绕过 ACE

5.1 关键发现

B 站画质增强包 (BV1X8XfBHEma) 直接将 d3d12.dll 放在游戏 exe 目录下就能使用。

核心发现:ACE 的文件扫描不拦截 d3d12.dll

✅ d3d12.dll → ACE 不扫
❌ d3d11.dll → 被拦
❌ dxgi.dll  → 被拦

d3d12.dll 实质是 ReShade 6.7.1,编译为 d3d12.dll 作为代理 DLL。游戏使用 DirectX 12,启动时自动加载此 DLL,而 ACE 的白名单未覆盖它。

5.2 ReShade Addon 系统

ReShade 6.7.1 支持 addon 插件系统。任何 .addon64 文件放在同目录下会被自动加载。addon 可以注册 draw_indexed 事件,在每个 3D 绘制调用前拦截。


六、ShoeHide:自定义 ReShade Addon

6.1 核心原理

ReShade API v18 的 draw_indexed 事件:

bool on_draw_indexed(
    api::command_list *cmd,
    uint32_t index_count,      // 索引数
    uint32_t instance_count,
    uint32_t first_index,      // 起始索引
    int32_t  vertex_offset,
    uint32_t first_instance
);
// 返回 true  → 跳过此绘制调用
// 返回 false → 正常绘制

每个 3D 模型在渲染时产生固定的 (index_count, first_index) 组合,可作为"指纹":

static uint64_t make_hash(uint32_t index_count, uint32_t first_index) {
    return ((uint64_t)index_count << 20) | (first_index & 0xFFFFF);
}

6.2 热键操作

热键 功能
F6 切换追踪模式(收集所有出现过的 draw call 指纹)
F7 轮流临时隐藏已收集的 draw call(实时预览)
F8 将当前选中的指纹永久保存到 ShoeHide.ini

6.3 效果

场景 控制精度 效果
衣柜/预览 单部件级别 只遮鞋子 ✓
大世界 全身级别 衣服+身体+鞋子全部消失

原因:大世界中 UE4 引擎为减少 draw call 将角色部件预合并为单个网格体,所有部件共享一个绘制调用。衣柜中每个部件是独立 draw call。

6.4 完整源码

#include <Windows.h>
#include <cstdio>
#include <io.h>
#include <fcntl.h>
#include "../reshade/reshade.hpp"

#define MAX_SKIP 256
#define MAX_TRACKED 1024

static uint64_t g_skip_hashes[MAX_SKIP];
static int g_skip_num = 0;
static uint64_t g_tracked_hashes[MAX_TRACKED];
static uint32_t g_tracked_counts[MAX_TRACKED];
static int g_tracked_num = 0;
static volatile int g_test_idx = -1;
static volatile bool g_tracking = false;
static WCHAR g_config_path[MAX_PATH] = {};

static uint64_t make_hash(uint32_t ic, uint32_t fi) {
    return ((uint64_t)ic << 20) | (fi & 0xFFFFF);
}

bool on_draw_indexed(reshade::api::command_list *, uint32_t index_count,
        uint32_t instance_count, uint32_t first_index, int32_t, uint32_t) {
    if (index_count == 0 || instance_count == 0) return false;
    uint64_t hash = make_hash(index_count, first_index);
    for (int i = 0; i < g_skip_num; i++)
        if (g_skip_hashes[i] == hash) return true;
    int ti = g_test_idx;
    if (ti >= 0 && ti < g_tracked_num && g_tracked_hashes[ti] == hash)
        return true;
    if (g_tracking) {
        for (int i = 0; i < g_tracked_num; i++) {
            if (g_tracked_hashes[i] == hash) { g_tracked_counts[i]++; return false; }
        }
        if (g_tracked_num < MAX_TRACKED) {
            int idx = g_tracked_num++;
            g_tracked_hashes[idx] = hash;
            g_tracked_counts[idx] = 1;
        }
    }
    return false;
}

// [File I/O and hotkey thread omitted for brevity]
// Full source: E:\Project\C_Project\ShoeHide\src\main.cpp

extern "C" __declspec(dllexport) bool AddonInit(HMODULE addon, HMODULE reshade) {
    if (!reshade::register_addon(addon, reshade)) return false;
    reshade::register_event<reshade::addon_event::draw_indexed>(on_draw_indexed);
    CreateThread(nullptr, 0, hotkey_thread, nullptr, 0, nullptr);
    return true;
}

完整源码约 170 行,编译产物 ShoeHide.addon64(约 80KB)。


七、PAK 格式逆向与 PakMaker

7.1 PAK 文件结构

通过逆向 QuickBMS 脚本 unreal_tournament_4_0.4.27e_roco_kingdom_world.bms 得到完整格式:

PAK 文件结构(从文件头到文件尾):

[0x0000]  文件数据 (按 0x800 对齐)
[offset]  AES-256 加密索引
[-0xCD]   uint8  ENCRYPTED (1 = 是)
[-0xCC]   uint32 MAGIC (0x5A6F12E1)
[-0xC8]   uint32 VERSION (11)
[-0xC0]   uint64 INDEX_OFFSET
[-0xB8]   uint64 INDEX_SIZE
[-0xB0]   uint8[20] INDEX_HASH (SHA1)
[-0x9C]   uint8 CHECK
[-0x9B]   char[32] COMP1 ("Zlib")
[-0x7B]   char[32] COMP2 ("")
[-0x5B]   [padding to fill 0xCD bytes total]
[EOF]

7.2 自定义 AES 实现

QuickBMS 脚本内嵌了 TinyCrypt C 代码。其 AES 实现不是标准 ECB,有额外的字节/位反转操作:

解密流程

1. byte_reverse(key)     — 交换密钥字节对
2. aes_key_expansion     — 标准 AES 密钥扩展
3. 对每个 16 字节块:
   a. bit_reverse(block) — 反转每个字节的位序
   b. AES-ECB decrypt

加密流程(反向):

1. byte_reverse(key)
2. aes_key_expansion
3. 对每个 16 字节块:
   a. AES-ECB encrypt
   b. bit_reverse(block)

7.3 PakMaker

使用 Python + PyCryptodome 实现完整的 PAK 生成器,约 295 行。完整源码:

#!/usr/bin/env python3
"""
PakMaker for Roco Kingdom: World (UE4 custom PAK format)
Reverse-engineered from QuickBMS script unreal_tournament_4_0.4.27e_roco_kingdom_world.bms

PAK file structure (from EOF backward):
  [... file data ...]
  [... AES-256 ECB encrypted index ...]
  -1:     uint8  ENCRYPTED (1 = yes)
  0:      uint32 MAGIC (0x5A6F12E1)
  4:      uint32 VERSION (11)
  8:      uint64 INDEX_OFFSET
  16:     uint64 INDEX_SIZE
  24:     uint8[20] INDEX_HASH (SHA1 of encrypted index)
  44:     uint8  CHECK (0 or skipped)
  45:     char[32] COMP1 ("Zlib")
  77:     char[32] COMP2 ("")
  109:    [padding to fill 0xCC bytes]
  0xCC:   [EOF]
"""

import struct
import hashlib
import os
import sys
from Crypto.Cipher import AES

AES_KEY = bytes.fromhex("34254D23E47299B3B7F6C4CFDE9BD0688703446D9D8F37B2EBDDDE5B06ED5ADF")
PAK_MAGIC = 0x5A6F12E1
PAK_VERSION = 11
FOOTER_SIZE = 0xCC


def bit_reverse(data: bytearray):
    """Reverse bits in each byte (matches game's TinyCrypt implementation)"""
    for i in range(16):
        x = data[i]
        x = ((x >> 1) & 0x55) | ((x << 1) & 0xAA)
        x = ((x >> 2) & 0x33) | ((x << 2) & 0xCC)
        x = (x >> 4) | (x << 4)
        data[i] = x & 0xFF

def byte_reverse(key: bytearray):
    """Reverse key bytes in pairs (matches game's TinyCrypt implementation)"""
    for i in range(15):
        tmp = key[i]
        key[i] = key[30 - i]
        key[30 - i] = tmp

def aes_encrypt(data: bytes) -> bytes:
    """AES-256 ECB encrypt using game's custom algorithm"""
    # Pad to 16-byte boundary with zeros
    pad = (16 - len(data) % 16) % 16
    data = data + b'\x00' * pad

    # Prepare key: byte_reverse then expand
    key = bytearray(AES_KEY)
    byte_reverse(key)
    cipher = AES.new(bytes(key), AES.MODE_ECB)

    # Encrypt each block with bit_reverse after encryption
    result = bytearray()
    for i in range(0, len(data), 16):
        block = data[i:i+16]
        encrypted = cipher.encrypt(bytes(block))
        rev = bytearray(encrypted)
        bit_reverse(rev)
        result += rev
    return bytes(result)


def sha1(data: bytes) -> bytes:
    return hashlib.sha1(data).digest()


def collect_files(root_dir: str):
    """Collect all files with relative paths from root_dir"""
    result = []
    for dirpath, _, filenames in os.walk(root_dir):
        for fn in filenames:
            full = os.path.join(dirpath, fn)
            rel = os.path.relpath(full, root_dir).replace('\\', '/')
            result.append((rel, full))
    return result


def build_pak(file_list, output_path, mount_point="../../../NRC/Content/"):
    """Build a custom-format PAK file"""

    files = []
    for rel, full in file_list:
        size = os.path.getsize(full)
        with open(full, 'rb') as f:
            fhash = sha1(f.read())
        files.append({'rel': rel, 'full': full, 'size': size, 'hash': fhash})

    if not files:
        print("No files to pack!")
        return False

    # Group files by directory
    dirs = {}
    for f in files:
        rel = f['rel']
        if '/' in rel:
            d, fn = rel.rsplit('/', 1)
        else:
            d, fn = '', rel
        if d not in dirs:
            dirs[d] = []
        dirs[d].append((fn, f))

    # Phase 1: Build index binary with placeholders
    idx_data = bytearray()

    # Mount point / base path (part of encrypted index, first entry)
    mp = mount_point
    if mp.endswith('\x00'):
        mp = mp[:-1]
    mp_bytes = mp.encode('ascii')
    idx_data += struct.pack('<I', len(mp_bytes) + 1)  # NAMESZ
    idx_data += mp_bytes + b'\x00'                    # BASE_PATH

    # Index header
    idx_data += struct.pack('<I', len(files))  # file count
    idx_data += b'\x00' * 12                   # dummy
    hashes_off_pos = len(idx_data)
    idx_data += struct.pack('<Q', 0)  # hashes_offset placeholder
    idx_data += struct.pack('<Q', 0)  # hashes_size placeholder
    idx_data += b'\x00' * 24          # dummy
    names_off_pos = len(idx_data)
    idx_data += struct.pack('<Q', 0)  # names_offset placeholder
    idx_data += struct.pack('<Q', 0)  # names_size placeholder
    idx_data += b'\x00' * 24          # dummy

    # Write directory entries with placeholder file offsets
    dir_list = list(dirs.items())
    idx_data += struct.pack('<I', len(dir_list))

    file_offset_places = []  # (pos_in_idx, file_obj)

    for dir_name, dir_files in dir_list:
        dname = dir_name + '\x00'
        idx_data += struct.pack('<i', len(dname))
        idx_data += dname.encode('ascii', errors='replace')
        idx_data += struct.pack('<I', len(dir_files))

        for file_name, fobj in dir_files:
            fname = file_name + '\x00'
            idx_data += struct.pack('<i', len(fname))
            idx_data += fname.encode('ascii', errors='replace')
            file_offset_places.append((len(idx_data), fobj))
            idx_data += struct.pack('<I', 0xFFFFFFFF)

    dir_start = names_off_pos + 8 + 8 + 24

    # Phase 2: Write file entry data after directory entries
    file_data_entries = {}
    file_entry_bin = bytearray()

    for _, fobj in file_offset_places:
        if fobj['rel'] in file_data_entries:
            continue
        entry_offset = len(idx_data) + len(file_entry_bin)
        entry_bin = bytearray()
        entry_bin += struct.pack('<Q', 0)         # OFFSET placeholder
        entry_bin += struct.pack('<Q', 0)         # ZSIZE = 0 (uncompressed)
        entry_bin += struct.pack('<Q', fobj['size'])  # SIZE
        entry_bin += struct.pack('<I', 0)         # ZIP = 0 (no compression)
        entry_bin += fobj['hash']                 # SHA1 (20 bytes)
        entry_bin += struct.pack('<I', 0)         # CHUNKS = 0
        entry_bin += b'\x00'                      # ENCRYPTED = 0
        entry_bin += struct.pack('<I', 0x10000)   # CHUNK_SIZE (default)
        file_data_entries[fobj['rel']] = (entry_offset, entry_bin)
        file_entry_bin += entry_bin

    # Patch placeholder offsets in directory (relative to BASE_INDEX_OFF)
    for placeholder_pos, fobj in file_offset_places:
        entry_off, _ = file_data_entries[fobj['rel']]
        rel_off = entry_off - dir_start
        struct.pack_into('<I', idx_data, placeholder_pos, rel_off)

    # Append file entry binary
    idx_data += file_entry_bin

    # Phase 3: Write PAK file
    with open(output_path, 'wb') as out:
        written_files = set()

        for _, fobj in file_offset_places:
            fkey = fobj['rel']
            if fkey in written_files:
                continue
            written_files.add(fkey)

            # Align to 0x800
            cur = out.tell()
            pad = (0x800 - (cur % 0x800)) % 0x800
            if pad:
                out.write(b'\x00' * pad)

            abs_offset = out.tell()
            entry_off, entry_bin = file_data_entries[fobj['rel']]
            struct.pack_into('<Q', entry_bin, 0, abs_offset)
            struct.pack_into('<Q', idx_data, entry_off, abs_offset)

            with open(fobj['full'], 'rb') as fin:
                out.write(fin.read())

        # Patch hashes_offset and names_offset with absolute positions
        index_pos = out.tell()
        struct.pack_into('<Q', idx_data, hashes_off_pos, index_pos + dir_start)
        struct.pack_into('<Q', idx_data, names_off_pos, index_pos + dir_start)

        # Write encrypted index
        idx_data_bytes = bytes(idx_data)
        idx_encrypted = aes_encrypt(idx_data_bytes)
        out.write(idx_encrypted)
        index_size = len(idx_encrypted)

        # Compute SHA1 of encrypted index
        index_hash = sha1(idx_encrypted)

        # Write footer: ENCRYPTED at EOF-0xCD, MAGIC at EOF-0xCC
        encrypted_pos = out.tell()
        out.write(b'\x01')  # ENCRYPTED = true

        out.write(struct.pack('<I', PAK_MAGIC))
        out.write(struct.pack('<I', PAK_VERSION))
        out.write(struct.pack('<Q', index_pos))
        out.write(struct.pack('<Q', index_size))
        out.write(index_hash)
        out.write(b'\x00')                    # CHECK byte
        out.write(b'Zlib' + b'\x00' * 28)    # COMP1 (32 bytes)
        out.write(b'\x00' * 32)              # COMP2 (32 bytes)

        # Pad: total from ENCRYPTED to EOF must be 0xCD bytes
        cur = out.tell()
        target = encrypted_pos + 0xCD
        if cur < target:
            out.write(b'\x00' * (target - cur))

        total = out.tell()
        print(f"PAK created: {output_path}")
        print(f"  Files: {len(files)}")
        print(f"  Index offset: {index_pos}")
        print(f"  Index size: {index_size}")
        print(f"  Total: {total} bytes")
        print(f"  Footer check: MAGIC at EOF-{total - (encrypted_pos + 1)} (expect 0xCC)")

    return True


def main():
    if len(sys.argv) < 3:
        print("Usage: python pakmaker.py <input_dir> <output.pak> [mount_point]")
        sys.exit(1)

    input_dir = sys.argv[1]
    output_file = sys.argv[2]
    mount_point = sys.argv[3] if len(sys.argv) > 3 else "../../../NRC/Content/"

    if not os.path.isdir(input_dir):
        print(f"Error: {input_dir} is not a directory")
        sys.exit(1)

    print(f"Scanning: {input_dir}")
    flist = collect_files(input_dir)
    print(f"Found {len(flist)} files")
    for rel, full in flist:
        print(f"  {rel} ({os.path.getsize(full)} bytes)")

    if not flist:
        print("No files found!")
        sys.exit(1)

    if not build_pak(flist, output_file, mount_point):
        sys.exit(1)


if __name__ == '__main__':
    main()

7.4 QuickBMS 验证通过

$ quickbms_4gb_files.exe -l script.bms mypak.pak

  offset     filesize   filename
--------------------------------------
  ...        1113       NRC\Content\...\BP_AvatarShoes.uasset
  ...        121        NRC\Content\...\BP_AvatarShoes.uexp

- 2 files found in 0 seconds

7.5 游戏端校验

虽然 PAK 格式和加密完全正确(QuickBMS 验证通过),但游戏启动时检测到非官方 PAK 内容并拒绝启动:

错误代码 (3504003):游戏客户端非官方版本或游戏客户端损坏

结论:游戏对 PAK 文件有额外的完整性校验(哈希白名单),替换 PAK 需要同时绕过该机制。


八、总结

技术突破

里程碑 状态 关键
PAK 解包 QuickBMS + 自定义脚本 + AES 密钥
d3d12.dll 绕过 ACE ACE 不扫描此文件名
ShoeHide addon ReShade API draw_indexed 事件
PAK 格式逆向 完整 AES 加密/解密实现
PakMaker PAK 生成 QuickBMS 验证通过
自定义 PAK 加载 游戏有完整性白名单校验

可用方案

目前最好的效果来自 ShoeHide addon

  • 衣柜/预览界面:精确隐藏单个鞋子
  • 大世界:全身隐藏(UE4 合批限制)

受限点的原因

大世界中 UE4 将角色部件预合并为单个网格体(static batching),所有渲染相关的 draw call 控制都无法在部件级别分离。要突破此限制需要:

  • 禁止引擎合批(需要修改引擎配置或 Hook 合批函数)
  • 或通过资源替换(需要绕过 PAK 完整性校验)

文件清单

文件 描述
ShoeHide.addon64 ReShade 鞋子隐藏插件
pakmaker.py PAK 生成器
shoe_hide_main.cpp ShoeHide 完整源码
decrypt.c 从 BMS 脚本提取的 AES 实现
unreal_tournament_4_0.4.27e_roco_kingdom_world.bms QuickBMS 解包脚本

参考资源

  • QuickBMS: https://aluigi.altervista.org/quickbms.htm
  • ReShade: https://reshade.me/
  • ReShade API: https://github.com/crosire/reshade
  • UE4 AES 密钥帖: https://cs.rin.ru/forum/viewtopic.php?t=100672
  • 画质增强包: B站 BV1X8XfBHEma
  • 3DMigoto: https://github.com/bo3b/3Dmigoto
  • RockDLL: https://github.com/Goldppx/RockDLL

Disclaimer: 本文为技术研究记录。修改游戏客户端可能违反用户协议。本文内容仅用于学习和研究目的。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐