少女祈祷中...

ACTGS 引擎归档容器格式规范

—— 以 WHITE CLARITY(ACTGS ver 2.33)为样本的参考文档

项 值
文档版本 1.0
编制日期 2026-10-06
适用引擎 ACTGS ver 2.33((c) 2003-2005 ACTRESS),Delphi/VCL,Win32
验证样本 WHITE CLARITY PC版,Windows 11 x64,系统 ANSI 代码页 936
证据来源 静态反编译(Ghidra 12.1.4,4351 函数全量反编译,失败 0)+ 运行时内存取证 + 逐字节回环实验
用途 本地化、格式研究、数据迁移

证据等级标注(本文所有结论均带标记):

  • [M] Measured — 由反编译代码或运行时实测直接确认
  • [I] Inferred — 由 [M] 事实推导,尚未独立验证
  • [U] Unverified — 观察性结论,样本外可能不成立

1. 术语与记号

记号 含义
KEY 5 字节重复密钥 11 05 22 33 41 [M]
N 归档条目数,位于文件头 uint32
rec[i] 第 i 条 32 字节索引记录(已解密形态)
off[i], len[i] 记录中的绝对偏移与原始长度
blob[i] 第 i 个数据块在归档中的密文字节
plain[i] 数据块解密后的字节
pad[i] 第 i 块末尾到下一块起点之间的原始字节(对齐填充,内容不保证为 00)
^ 按位异或
≡ 逐字节相等

2. 样本基准值

归档 大小 N 记录长度 混淆 备注
arc.scr 2,409,936 164 32 B 索引 + 数据块 纯文本脚本
arc.cg 942,221,792 1003 32 B 索引 + 数据块 图像/PSD
arc.wav 345,471,840 5779 24 B 无 明文容器,可作对照样本 [M]

arc.scr 派生量 [M] :

1
2
3
4
5
索引区      [16, 5264)          = 16 + 164×32
数据区起点 5264
末块结束 2409930
尾部残留 6 字节
解密后明文合计 2,403,410 字节;cp932 解码 U+FFFD 计数 = 0

arc.wav 未混淆且记录长度为 24 字节([off][len][name[16]]),
说明 ACTGS 家族存在多种记录布局。不得假设 §3 的 32 字节布局全局通用 [M] 。


3. 容器布局规范

1
2
3
4
5
6
偏移   长度        字段
0x00 4 uint32 N 条目数
0x04 12 0x00 × 12 保留
0x10 N×32 索引记录区(混淆态,见 §4.1)
… — 数据块区:blob[0] pad[0] blob[1] pad[1] …
… — 尾部残留(arc.scr 为 6 字节)

3.1 索引记录(32 字节,解密后)

偏移 长度 类型 语义 证据
+0x00 4 uint32 LE 该条目数据块的绝对文件偏移 [M]
+0x04 4 uint32 LE 该条目数据块的原始长度(不含对齐填充) [M]
+0x08 24 char[] 文件名,NUL 结尾,其余补 0;字符集 cp932 [M]

示例(解密后):00-001.scr 00-002.scr 00-end.scr 01-001.scr … k06-502a.scr open.scr

3.2 数据块排布规则

1
2
3
off[0]      = 16 + N×32
off[i+1] ≥ off[i] + len[i]
对齐要求 块尾 16 字节对齐: (off[i] + len[i]) 向上取整到 16 的倍数 = 下一块起点

实测间隙序列(arc.scr 前若干块):14, 1, 3, 0, 7 … 字节,与 16 字节对齐推导一致 [M] 。

回写注意:pad[i] 的实际字节内容不保证为 0x00。
若需”未修改即可逐字节还原”,必须原样保留 pad[i](见 §8.2)。


4. 混淆算法规范

4.1 索引区

作用于文件偏移 0x10 起的 N×32 字节,相位以缓冲区首为 0 [M] :

1
2
for (i = 0; i < N * 32; i++)
idx[i] ^= KEY[i % 5];

反变换与正变换相同(异或自反)。

4.2 数据块

逐块独立,相位在块内重置 [M] :

1
2
3
4
5
6
7
if (blob[0] == 0x58) {                       /* 'X' 加密标记 */
plain[0] = 0x4E; /* 'N' */
for (i = 1; i < len; i++)
plain[i] = blob[i] ^ KEY[(i - 1) % 5];
} else {
plain = blob; /* 未加密块,原样 */
}

规范性提示(高频错误点):下标为 (i - 1) % 5 而非 i % 5。
引擎源码写作 u = u % klen + 1 的滚动形式,其下标序列为 0,1,2,3,4,0,…,
配合循环 i 从 1 起始,等价于 (i-1) % 5。
误用 i % 5 将导致整块相位偏移 1,症状为”块首若干字节正常、其后全部错误”,
极易被误判为”存在第二层加密” [M] 。

4.3 密钥实体

KEY 以 NUL 结尾字符串形式存在于 .data,长度由 strlen 运行时求得。
样本内共 4 份副本,内容相同 [M] :

VA 引用方 作用对象
0x4C5460 FUN_00429568 归档索引
0x4BFEDF FUN_004124d0 脚本数据块
0x4C2A28 FUN_0042629c PSD 资源
0x4C5857 FUN_0042a32c DIBitmap 资源
1
字节: 11 05 22 33 41 00        strlen = 5

该密钥为混淆而非加密:无完整性校验、无密钥派生、明文可预测(大量 0 填充)。
不具备任何抗分析能力 [M] 。


5. 脚本正文语法约定(.scr 解密后)

5.1 行分隔与记录

  • 行分隔符:CRLF(0x0D 0x0A) [M]
  • 块首字节解密后被置为 0x4E('N'),实测为版本/类型标记 [M]
  • 注释行以 0x3B(;)起始 [M]

5.2 行分类规则(规范性)

引擎按行首字节决定该行语义 [M] :

行首 语义 可否本地化
A-Z / a-z 命令行(命令名 + 参数) 禁止翻译;参数中的资源标识符亦禁止
; 注释 可译可不动
双字节前导 显示正文 待翻译目标
0x00–0x20 等 控制/填充 禁止改动

验证依据:将一行正文替换为 ASCII 文本后,引擎立即报
SCRIPT ERROR / 未定義のコマンド <该行内容>,位置标注为 <文件名> (行号)。
即”字母开头 → 命令名查表” [M] 。

5.3 已观测命令表(arc.scr)

1
2
3
vo  msg  msg2  ret  sp  sp1_cf  bg  bg1_fi  bgm  bgm1  se  se2  ev  evnn  evrn
posi set_rgb set_alpha set_all_fade move_xyset shake flash zoom rotate
wait change select define movie title goto window auto menu kaisou sleep

命令名以明文 ASCII 存于脚本中,不在 exe 的字符串表内(已 grep 确认)[M] 。
因此命令集合的权威来源是脚本自身,而非 exe。

5.4 资源标识符警告

形如 sp1_cf "月"、bgm1 "bgm01"、bg1_fi 天井 的行,
其参数含日文但属于标识符,与 arc.cg 内素材名一一对应 [I] 。
本地化时若需替换,必须同步改名素材条目;否则运行期取不到资源。

5.5 语料规模(样本实测)

1
2
3
脚本文件      164        含正文的文件   151        无正文文件   13
正文行 31,455 正文字节 1,435,398
明文总字节 2,403,410 假名 431,191 汉字 158,337 U+FFFD 0

6. 字符编码与运行时前导判定

6.1 渲染路径

项 值 证据
文本绘制 ExtTextOutA(ANSI) [M]
导入跳板 0x4B6850 [M]
调用点 0x46305D、0x463115(全程序仅 2 处,均为 VCL TCanvas.TextOut/TextRect 包装) [M]
文本测量 GetTextExtentPoint32A,跳板 0x4B68C8,调用点 1 处 [M]
字体 CreateFontIndirectA,ACTGS.ini 指定 MS Pゴシック [M]

渲染入口极窄:2 个调用点覆盖全部游戏文字。任何运行时注入/hook 方案只需处理这两处。

6.2 双字节前导判定表

引擎按行扫描时查询一张 32 字节位图 [M] :

1
(DAT_004CE218[b >> 3] >> (b & 7)) & 1        /* 1 = 该字节为双字节前导 */
  • 该地址在镜像文件中全为 0x00,即运行时构建 [M]
  • 运行时实测内容(ReadProcessMemory,进程初始化后) [M] :
1
2
00×16 | FE FF FF FF FF FF FF FF FF FF FF FF FF FF FF 7F
置位数 = 126 范围 = 0x81–0xFE

6.3 规范性结论

判定 内容 证据
该表由系统 ANSI 代码页驱动 样本机 ACP = 936,实测表内容等于 GBK 前导全集 [M]
Shift-JIS 前导全集仅 46 个(0x81-0x9F + 0xE0-0xEA) 与实测 126 个不符 [M]
在 ACP=936 环境下运行该日文引擎,无需内码补丁即可渲染 GBK 中文正文 前导判定与 GDI 解码两侧同时为 GBK [M](表内容、调用点)+ [I](端到端渲染)
同一环境下日文原文会被画成乱码 同一事实的反面;可在错误弹窗截图中直接观测 [M]

适用条件:结论依赖宿主 ACP。若目标机 ACP = 932,该表将退化为 Shift-JIS 前导集,
GBK 中文的 0xA1–0xDF、0xF1–0xFE 段前导会被误判为单字节,导致逐字错位 [I] 。
跨环境部署前应重新读取该位图确认。


7. 回写规范

7.1 必办事项

  1. 保持条目顺序与 N 不变(改变条目数需同时改写文件头,本文未覆盖) [U]
  2. 每条 rec[i] 的 +0x00 写新偏移、+0x04 写新长度;+0x08.. 原样保留 [M]
  3. 数据块加密:首字节写 0x58,i≥1 用 KEY[(i-1)%5](若原块未加密则原样写出) [M]
  4. 块尾对齐到 16 字节 [M]
  5. 保留尾部残留字节 [M]

7.2 填充保真原则(规范性)

当某条目长度未变化时,必须逐字节复用其原始 pad[i],不得以 0x00 重写。

理由:pad[i] 内容不保证为 0。若统一填 0,则”未修改→重封”无法满足 ≡ 原文件,
从而丧失 §8.1 这条最强的自检手段 [M] 。

7.3 文本替换的安全边界

  • 仅替换 §5.2 中”显示正文”类行;其余字节一律从基准二进制原样拷回 [M]
  • 译文按 GBK 编码;不可 GBK 表示的字符(日文假名、生僻字、emoji)必须显式报错或降级,
    禁止静默替换为 ? [I]
  • 译文行数与 seq 数不一致时,整份沿用原文并告警,不得部分写入 [M]

8. 验证协议

三条判据,各自适用范围与局限均予明示。

8.1 无损回环(round-trip)

1
2
3
4
操作:不放任何译文 → unpack → repack
判据:结果 ≡ 原始归档(逐字节)
证明:重封器未丢失任何信息(对齐、填充、尾部、索引边角字段)
不证明:解密算法与引擎一致(任何可逆变换都满足此条)

样本实测:orig=2409936 rebuilt=2409936 一致=True [M]

8.2 变长回读

1
2
3
操作:替换 ≥1 条正文为不同长度的中文 → repack → 再解出该块
判据:中文字节原样读回;N 条偏移仍单调;块尾仍 16 字节对齐
证明:偏移重排与长度字段更新正确

样本实测:3 条替换,产物 2409872,偏移单调 = True [M]

8.3 幂等

1
2
3
操作:产物 → unpack → repack
判据:两次产物逐字节相等
证明:流程可反复迭代而不退化

样本实测:True [M]

8.4 算法正确性的唯一有效判据

解密产物必须是”零非法字节的合法编码文本”。

对 .scr:以 cp932 解码全明文,U+FFFD 计数应为 0;
“最长连续合法双字节串”应达到自然语言量级(数十至上千字符)。

样本实测:U+FFFD = 0,总明文 2,403,410 字节 [M]

反例警示(本项目真实经历):一个错误的差分模型
D[i] = P[i] ^ P[i-5] 在零填充区会碰巧输出正确字节
(P[i-5]==0 ⇒ D[i]==P[i]),因而能同时满足”解出文件名””偏移单调””可逆回环”,
却仍与引擎算法无关。此时明文的最长合法 SJIS 串仅 11 字符——这才是决定性反证 [M] 。


9. 静态定位索引

VA / 地址 作用 关键代码特征
0x400000 ImageBase —
.text VA 0x1000 / Raw 0x600 / 0xB6000 代码段(内嵌字符串,线性反汇编会失步) —
.data VA 0xB7000 / Raw 0xB6600 / 0x1B400(VSize 0xE5000,后段 BSS) 数据段 —
FUN_00406148 启动流程,引用 "arc.scr" FUN_00429568(h, "arc.scr", 1, 0)
FUN_00429568 归档打开 + 索引解密 buf[i] ^= KEY[u % klen]
FUN_004124d0 脚本读取 + 数据块解密 if (*buf=='X') { … *buf='N'; }
FUN_0042629c / FUN_0042a32c PSD / DIBitmap 加载 同型异或环,密钥副本 0x4C2A28/0x4C5857
FUN_0049ede0 按行切分脚本 \r\n 判定 + 前导位图查询
FUN_004a02f8 全角/半角与 ASCII 大小写折叠 0x41-0x5A ↔ 0x61-0x7A 的 ±0x20
0x4B6850 ExtTextOutA 跳板(jmp [0x59E8E4]) 调用点 2 处
0x4B68C8 GetTextExtentPoint32A 跳板 调用点 1 处
0x4CE218 运行时前导位图(32 B) 镜像内全 0
.text 尾 0x4B6EBF 321 字节全零代码洞(可放桩) 实测可用

地址→文件偏移换算:fileOff = 0x600 + (VA - 0x401000)(.text)。
本项目曾因混淆”节内索引”与”文件偏移”导致所有手算地址偏低 0x600,见附录 B-5。


10. 复现步骤

10.1 最小复现(仅需 Python,验证 §3–§4)

1
2
3
4
5
6
7
8
9
10
11
12
KEY = bytes.fromhex("1105223341")
p = open("image/arc.scr","rb").read()
n = int.from_bytes(p[:4],"little")
idx = p[16:16+n*32]
idx = bytes(idx[i] ^ KEY[i % 5] for i in range(len(idx)))
for i in range(n):
r = idx[i*32:(i+1)*32]
off = int.from_bytes(r[0:4],"little"); size = int.from_bytes(r[4:8],"little")
name = r[8:32].split(b"\x00")[0].decode("cp932")
blob = p[off:off+size]
plain = (b"\x4e" + bytes(blob[j] ^ KEY[(j-1)%5] for j in range(1,len(blob)))) if blob[:1]==b"X" else blob
print(i, name, off, size, len(plain))

预期:164 行,首行 0 00-001.scr 5264 5986 5986,末行 163 open.scr 2396128 13802 13802。

10.2 完整复现(Ghidra headless)

1
2
3
4
5
6
# 1) 下载(企业代理需绕过吊销检查)
curl --ssl-no-revoke -L -o ghidra.zip <Ghidra_12.1.4 官方 zip>
# 2) 必须用系统 bsdtar;Git 自带 GNU tar 不认 zip
/c/Windows/System32/tar.exe -xf ghidra.zip
# 3) Jython 扩展:从“扩展池”解压到“模块目录”
/c/Windows/System32/tar.exe -xf <根>/Extensions/Ghidra/*_Jython.zip -C <根>/Ghidra/Extensions/
1
2
3
4
5
6
7
:: headless 启动需显式提供用户环境变量
set "USERPROFILE=C:\Users\<user>"
set "APPDATA=C:\Users\<user>\AppData\Roaming"
set "LOCALAPPDATA=C:\Users\<user>\AppData\Local"
set "JAVA_HOME=C:\Program Files\Java\jdk-21"
call <GHIDRA>\support\analyzeHeadless.bat D:\tools\proj PRJ -import ACTGS.exe ^
-postScript DecompAll.py -scriptPath D:\tools\scripts

Ghidra 脚本 API 注意:方法名是 decompileFunction(f, timeoutSecs, monitor),不是 decompile;
Ghidra 12 不再动态编译 .java 脚本 [M] 。

10.3 前导位图取证

1
2
CreateProcessA(CREATE_SUSPENDED=4) → ResumeThread → 等待初始化
→ ReadProcessMemory(0x4CE218, 32) → 统计置位

预期(ACP=936):126 位,0x81–0xFE。


11. 已知限制与未决项

# 项 状态
1 端到端”中文上屏”实机验收 未完成。一次 SCRIPT ERROR 已定位为旧实例窗口/未生效包所致,非格式问题
2 改变条目数 N 的回写 未覆盖(需同步改文件头与所有偏移)
3 arc.cg 图像资源回写 容器与密钥同族 [M],但 PSD/DIB 内部结构未解析;界面文字为位图,需另立改图流程
4 影像文件 Ending1-3.dat、WCOp.mpg 为 MPEG-1,字幕已压入画面,文本替换无效 [M]
5 13 个无正文文件 语义未细读(疑为过场/纯指令)
6 跨 ACP 环境 §6.3 结论依赖宿主 ACP,换机须重新取证
7 版本普适性 本文所有结论仅对 ACTGS ver 2.33 + 该样本验证,未在其他 ACTGS 游戏上复现 [U]

附录 A 一致性测试向量

项 期望值
arc.scr 大小 2,409,936
arc.scr md5 8c884f90613f6f84f747a4856183cb40
N 164
索引区范围 [16, 5264)
rec[0] 解密后前 8 字节 0x1490 = 5264,0x1762 = 5986
rec[0] 文件名 00-001.scr
rec[163] 文件名 / 偏移 / 长度 open.scr / 2396128 / 13802
明文总字节 2,403,410
明文 cp932 解码 U+FFFD 0
正文行数 31,455
未修改重封 ≡ 原文件 = True
运行时前导位图 126 位,0x81–0xFE

附录 B 陷阱登记表

# 现象 误判后果 正解 证据
B-1 密文出现 5 字节周期重复 误推为”反馈链加密”,在错误明文上继续分析并虚构”置换表” 明文零区使密文等于密钥;变换须由反编译确认 [M]
B-2 用”解密→再加密一致”验证算法 恒真,无鉴别力 该判据仅用于验证回写不丢信息 [M]
B-3 差分变换在零区”碰巧正确” 文件名、偏移字段看似全对 以”零非法字节的合法文本”为唯一判据 [M]
B-4 capstone 一次扫整段 .text 内嵌数据处失步 → 漏指令 → 假阴性 字节模式搜索,或先建函数图 [M]
B-5 VA/文件偏移换算差 0x600 函数头找不到、call 站点校验失败 以 Ghidra 符号为准 [M]
B-6 SIB 表达式分词正则漏 [esi+ecx-5] 得出”无读改写循环”的假阴性 re.findall(r"[+-]?[A-Za-z0-9_]+", inner) [M]
B-7 改 IAT 使 API 指向自桩 无效:磁盘 IAT 槽存 thunk RVA,加载时被系统覆盖 改调用点 call rel32,桩尾 jmp [IAT] [M]
B-8 认为 lstrcpynA 返回长度 它返回目标指针,用作长度会写飞 拷后调 lstrlenA(buf) [M]
B-9 正文替换为 ASCII 文本 SCRIPT ERROR / 未定義のコマンド 仅替换双字节开头的正文行 [M]
B-10 bytearray 切片改长度后沿用旧偏移 后续偏移整体错位 按行 split/join 重建,或倒序替换 [M]
B-11 Ghidra 12 写 .java 脚本 ClassNotFoundException 装 Jython 扩展写 .py [M]
B-12 Jython 扩展解压到顶层 Extensions/ 静默不生效 解压到 <根>/Ghidra/Extensions/ [M]
B-13 .ps1 含中文且无 BOM 按 GBK 解析,括号引号错乱 存 UTF-8 带 BOM,或正文纯 ASCII [M]
B-14 curl CRYPT_E_NO_REVOCATION_CHECK TLS 失败 --ssl-no-revoke [M]
B-15 Git 自带 tar 解 zip does not look like a tar archive C:\Windows\System32\tar.exe [M]
B-16 中文路径目录下用 py 当前目录被生成 Python\ 用绝对解释器路径 [M]
B-17 用 8.3 短路径绕中文 首层无短名 → CreateProcess err=267 直接写全路径 + BOM [M]
B-18 在替换前调用 unpack() 从已被上一轮污染的归档重解,替换”莫名失效” 每轮从原始备份重新解包 [M]