# 如何解出 rUGP(âge 引擎)的角色语音与对应台词
> 适用对象:想从 âge 的 rUGP 游戏(Muv-Luv 系列等)里提取****按角色分类的语音 + 台词文本****的人。
本文以 **Muv-Luv photonmelodies♮(Steam, appid 889710)** 为实例,所有数字都是实测结果。
> 难度:中等偏高(需要跑 Python + 命令行,但不需要写反汇编)。
> 时间:第一次做,工具准备好了大约 1–2 小时(大头是 8 GB 数据的扫描与解码)。
---
## 0. 先搞清楚要对付什么
rUGP 游戏的安装目录通常长这样:
```
photonmelodies11.rio # 主卷
photonmelodies11.rio.002 # 续卷
photonmelodies11.rio.003
photonmelodies11.rio.004
photonmelodies11.rio.ici # 索引(很小,但必须有)
Muv-Luv_PM.exe # Windows 程序
```
三个关键事实(很多旧教程错在这里):
- **四个 `.rio` 是一个连续地址空间**。`.002/.003/.004` 不是独立包,是同一个归档的续卷。
逻辑偏移 = 前面所有卷的大小之和 + 本卷内偏移。
- **目录(ICI + TOC)里没有音频**。TOC 只登记了约 1.7 GB 的 UI/脚本/图片/影片对象,
约 1 GB 的音频流(`.oor`)在目录之外,只能按文件头扫描定位。
- **语音和台词是两套数据**:语音是 `.oor` 流(自定义 Vorbis),台词在加密的 CRsa 脚本里。
两边靠脚本末尾的「资源引用」对上——这是本文的核心。
---
## 1. 准备工具
| 工具 | 用途 | 来源 |
|---|---|---|
| `oor-extractor` | 从 `.rio` 里扫出 `.oor` 音频流 | bnnm / vgm-tools(有 Linux 预编译) |
| `vgmstream-cli` | 把 `.oor` 解码成 wav | [vgmstream](https://github.com/vgmstream/vgmstream) |
| Python 3.10+ | 跑脚本 | — |
| [AgeReVerse](https://github.com/RepressedAlliance/AgeReVerse) | CRsa 解密 + VM 解析(`rUGP/formats/rio/`) | MIT 风格的公开实现 |
| [muvluv-voice-tools](https://github.com/KaramachiA217/muvluv-voice-tools) | 打包好的 `oor-extractor` / `vgmstream-cli` 二进制 | 同好整理 |
| GARbro(可选) | 参考 `ArcRIO.cs`,理解目录结构 | morkt/GARbro |
---
## 2. 第一步:把音频流切出来
```bash
# 四个卷必须按顺序一起给,oor-extractor 会把它们当成一个文件
./oor-extractor -s 0x100 -b out/oor/ \
game/photonmelodies11.rio \
game/photonmelodies11.rio.002 \
game/photonmelodies11.rio.003 \
game/photonmelodies11.rio.004
```
- `-s 0x100` 是「小于 256 字节的片段不要」,按需调整。
- 加 `-t -v` 可以先只看不写,并打印每一段的****偏移和大小****:
```
.oor 00007 found at offset 0x5bbc2300 (size 0x134ac5)
.oor 00007 metadata found at offset 0x5bbc22ac (size 0x54)
```
**把 `-v` 的输出保存下来**,后面处理台词映射时要用它做「流号 ↔ 卷内偏移」的表。
然后解码:
```bash
ls out/oor/*.oor | xargs -P 8 -I{} \
vgmstream-cli -o "out/wav/$(basename {} .oor).wav" "{}"
```
产物:一堆 44.1 kHz 的 wav。
**怎么区分语音和 BGM?** 实测:**单声道 = 语音,立体声 = BGM/音效**。
用 Python 读 wav 头里的声道数即可分类,不要靠时长猜。
photonmelodies 的实测数字:
| 项 | 数量 |
|---|---:|
| `.oor` 总数 | 20,232 |
| 单声道(语音) | 19,913 |
| 立体声(BGM/SE) | 319 |
| 解码失败 | 0 |
> 注意:极少数语音(立体声录制/特效)会被分到 BGM 里,比如本作的 `_00887`。
> 后面做角色映射时如果发现某句找不到音频,去立体声目录里找一下。
---
## 3. 第二步:解开 CRsa 脚本
### 3.1 先找脚本
每条 CRsa 记录在归档里是:
```
00 05 6e 03 01 00 00 00 00 00 00 <- 11 字节明文头
<加密流> <- 紧跟着
```
所以直接按 `00 05 6e 03 01 00` 扫整个归档就能枚举出来(本作 265 条)。
### 3.2 加密流格式(rUGP 通用)
```
头 8 字节:
size1 = u32le(data[0:4]) ^ 0xC92E568B
size1 = ~size1 & 0xFFFFFFFF
size2 = (u32le(data[4:8]) ^ 0xC92E568F) >> 3
必须 size1 == size2,否则不是加密流
正文:按 0x20 字节一块
b = enc ^ (key & 0xFF)
checksum += b * (0x20 - i) # i 从 0 开始
bit = (key >> 15) & 1
key = (~(bit + key*2 + 0xA3B376C9)) & 0xFFFFFFFF
每块后面跟 2 字节小端 checksum
```
常量:
| 名字 | 值 | 用途 |
|---|---|---|
| `RIO_KEY` | `0x7E6B8CE2` | `.rio` 里的对象/脚本流 |
| `ICI_KEY` | `0xB29D5A0C` | `.ici` |
**性能技巧**:密钥流只跟 key 和字节位置有关,跟数据无关,所以****所有同 key 的流共享同一条密钥流****。
可以先算一次 4 MB 的密钥流,再用 numpy 批量 XOR,速度差两个数量级。
### 3.3 解析成「台词 + 说话人」
最省事的办法是直接用 AgeReVerse 的实现:
```python
import sys
sys.path.insert(0, 'AgeReVerse')
from rUGP.formats.rio.crsa import read_crsa_record
from rUGP.formats.rio.crsa_vm_stream import parse_crsa_vm_stream
rec = read_crsa_record(Path('game/photonmelodies11.rio.002'), 1214082412)
res = parse_crsa_vm_stream(rec.plaintext, 'pm') # 'pm' 或 'pf'
```
> `crsa_vm_schema.json` 里的 VM 字段描述是****按 exe 的 SHA256 绑定****的,换游戏/换版本要重新生成,
> 不能直接套用。本作 PM 有 125 个 VM 操作描述,能解析全部 265 条脚本。
解析结果里你需要的是:
res['commands']:全部 VM 指令。台词指令是 CVmMsg3,本作共 **44,848** 条。
- `res['pool_base']`:台词文本池的起点。
取文本:
```python
pool = rec.plaintext
base = res['pool_base']
for c in res['commands']:
if c['name'] != 'CVmMsg3':
continue
index = c['fields']['first_records'][0][0]
off = base + index * 2
end = off
while pool[end:end+2] != b'\x00\x00':
end += 2
text = pool[off:end].decode('utf-16-le')
```
文本长这样(日英成对存放,取第一条就是日文):
```
【純夏】「タケルちゃーん朝だ………よ~………」\x01
【Professor】「...Increase output to 75%.」\x01
```
**说话人**:行首 `【名字】`,正则 `^【(.+?)】` 即可。没有 `【】` 的是旁白/主角内心。
原始文本里的控制符:
| 符号 | 含义 |
|---|---|
| `\x01` | 该条消息结束 |
| `\x03` | 句中的停顿/断句/注音分隔(未最终确认,清洗时再决定) |
| `\n` | 同一条消息里换了说话人 |
---
## 4. 第三步(核心):把台词和音频对上
### 4.1 关键发现:资源引用的 key 就是归档偏移
每条脚本的末尾有一串 `COptimizedObs` 对象引用(就是 `.oor` 音频对象),每个引用带一个 32 位 key。
**这个 key 是加密过的归档偏移**:
```python
archive_offset = ((key - 0xA2FB6AD1) & 0xFFFFFFFF) << 2
```
验证方法:拿几个已知的 `.oor` 流偏移去对,解码结果****逐字节完全相等****。
本作 22,770 个引用全部命中,0 失配。
有了这个公式,任何一个引用都能直接换成「第几个 `.oor`」。
### 4.2 哪些引用是语音?
脚本里的引用分两类:
- 被****任何 VM 指令****引用过的(`CVmSound`、`CVmCall` 的参数等)→ BGM/音效;
做法:解析出所有指令里出现过的对象 key,然后从脚本末尾的引用列表里减掉它们。
实测(photonmelodies):
| 脚本 | 末尾引用 | 被指令用掉 | 剩下(语音) | 有配音的台词 |
|---|---:|---:|---:|---:|
| A | 28 | 4 | 26 | 26 |
| B | 611 | 2 | 609 | 604 |
| C | 354 | 2 | 352 | 362 |
### 4.3 台词 → 第几个语音
有配音的 `CVmMsg3` 会带一个计数器:
```python
counter = c['fields']['second_records'][0][0] # 通常从 2 开始
voice_index = counter - min_counter # 即 counter - 2
```
`voice_index` 就是「剩下的引用」列表里的下标。
### 4.4 完整链路
```
台词(CVmMsg3) → counter-2 → 未被引用的 COptimizedObs 引用
→ key → ((key-0xA2FB6AD1)<<2) → 归档偏移
→ 在 oor-extractor 的偏移表里查 → 流号 → wav 文件
```
本作结果:**20,463 句台词映射到 18,615 条语音**。
### 4.5 完整伪代码
```python
streams = {} # 归档全局偏移 -> 流号(从 oor-extractor -v 的日志建)
for vol, block in all_crsa_records:
plain, res, suffix_refs = parse_script(vol, block)
used = set()
for cmd in res['commands']:
collect_object_keys(cmd, used) # 递归收集指令里所有引用 key
voices = [r for r in suffix_refs
if r['name'] == 'COptimizedObs' and r['key'] not in used]
msgs = [c for c in res['commands']
if c['name'] == 'CVmMsg3' and c['fields']['second_records']]
base = min(c['fields']['second_records'][0][0] for c in msgs)
for c in msgs:
j = c['fields']['second_records'][0][0] - base
key = voices[j]['key']
offset = ((key - 0xA2FB6AD1) & 0xFFFFFFFF) << 2
yield (vol, block, c['order'], streams[offset]) # -> wav
```
### 4.6 怎么验证你解对了(重要)
不要靠肉眼。用统计验证:
| 指标 | 正确映射 | 随机对照 |
|---|---:|---:|
| 台词字数 vs 音频时长 相关系数 | **0.55** | ≈ 0 |
| 「同一音频对应不同说话人」的比例 | **0.9%** | 会很高 |
| 同一脚本内流号是否随台词递增 | 大多数单调 | 随机 |
如果相关系数低于 0.2,说明 counter 的基准或引用顺序搞错了(先确认 `min_counter` 和引用列表是否按序列化顺序取)。
---
## 5. 第四步:按角色导出
到这里每条语音已经有了说话人,按 `speaker` 分组复制即可:
```
by_character/
├── 純夏/
│ ├── pm_00602.wav
│ └── ...
├── 冥夜/
└── ...
```
注意:
- 同一段音频可能被多句台词复用(本作 1,848 处),**按文件去重、按行保留**。
- 文本里出现两个 `【】` 的行(`【純夏】…\n【武】…`)是多人台词,音频可能只含第一个人,
建议单独放一个 `mixed/` 目录,别混进训练集。
- 主角的台词通常****没有配音****(原作设定),所以主角目录会很小,这是正常的。
---
## 6. 换一个游戏要改什么
| 位置 | 是否通用 | 说明 |
|---|---|---|
| 加密流算法(LCG + 0x20 块 + checksum) | ✅ 通用 | 常数 `0xA3B376C9`、`0xC92E568B/F` |
| `RIO_KEY` / `ICI_KEY` | ⚠️ 通常是这两个 | 个别移植版会换,先在 `.ici` 上试 |
| key → 偏移公式 `-0xA2FB6AD1 << 2` | ✅ 通用 | 即 TOC 里的 extent offset 编码 |
| `.oor` 头(0x48 / 0x08 开头) | ✅ 通用 | vgmstream 已支持 |
| CRsa 11 字节头 `00 05 6e 03 01 00` | ✅ 通用 | |
| VM 字段 schema(`crsa_vm_schema.json`) | ❌ **按 exe 哈希绑定** | 换游戏必须重新生成,否则解析会 fail-closed |
| 计数器 = 引用下标 + 2 | ⚠️ 经验规律 | 不同作品/版本要重新核对 |
换游戏的推荐顺序:
1. 用 `oor-extractor -t -v` 确认能不能扫出音频;
2. 用 AgeReVerse 的 `rio_inventory` 看 ICI/TOC 能不能解;
3. 扫 CRsa 头,试着解析一两条脚本;
4. 用「引用 key 解码 = 流偏移」这个公式验证 3–5 个引用(这步最容易确认对错);
5. 再跑全量。
---
## 7. 常见坑
**把 `.002` 当独立包** → 偏移全错。必须按卷顺序拼成一个地址空间。
**只解目录** → 拿不到音频。音频不在 TOC 里,必须扫头。
**用 `strings` 找文件名** → 音频没有文件名,脚本里也没有 `.oor` 字样。
**以为 key 是哈希** → 它其实是偏移,直接解码就能定位。
**忘记每块后面的 2 字节 checksum** → 解出来的数据整体错位。
**用逐字节 Python 解密 20 GB** → 太慢;同 key 的流共享密钥流,用 numpy 向量化。
**混入多人台词** → 影响训练质量,单独隔离。
---
## 8. 实测数据(可以用来对照你的结果)
| 项 | photonmelodies♮ |
|---|---:|
| `.oor` 流 | 20,232 |
| 单声道语音 | 19,913 |
| CRsa 脚本 | 265 |
| VM 指令 | 186,832 |
| 台词(CVmMsg3) | 44,848 |
| 带说话人的台词 | 33,716 |
| 成功映射的台词 | 20,463 |
| 覆盖的语音流 | 18,615 |
| 角色数 | 200 |
| 语音总时长 | 23.3 小时 |
---
## 9. 版权与用途
- 音频是****声优的表演**、文本是**剧本****,版权归原厂与声优。
- 自己研究、做字幕、训练个人模型可以,****不要重新分发****原始素材或可还原的模型权重。
- 公开发布工具/脚本没问题,但不要把解出来的素材一起打包。
---
## 10. 参考
- vgmstream(`.oor` 解码):https://github.com/vgmstream/vgmstream
- bnnm 的 `oor-extractor`:包含在 https://github.com/KaramachiA217/muvluv-voice-tools
- AgeReVerse(CRsa 解析、格式文档):https://github.com/RepressedAlliance/AgeReVerse
- GARbro(`ArcFormats/rUGP/ArcRIO.cs`,目录结构参考):https://github.com/morkt/GARbro
- 同引擎 ML/MLA 的语音提取记录(思路来源):https://github.com/KaramachiA217/muvluv-voice-tools
1 条回复