Muv-Luv Steam版全语音已提取

开发技术
305
Karamachi
话题封面

Steam 版 Muv-Luv(无印) 全部 `25k+` 条语音,以及 Muv-Luv Alternative 全部 `23k+` 条语音,已经完整从封包中提取出来了,按封包物理顺序编号 ≈ 游戏剧情推进顺序。(还没试过muv系列的其他游戏)。

现在正在按角色分类音频,想找人一起标,做完会公开数据集。
提取出来的音频是按游戏剧情物理顺序编号的,纯脚本无法稳定自动归类。
所以最可靠的方式还是靠人耳。

GitHub 仓库(含全部提取脚本、格式文档及部分未知谜题): https://github.com/KaramachiA217/muvluv-voice-tools

按照编号顺序听,遇到连续同一角色的段落,就记录成:“起始编号-结束编号 角色名”
比如开场已经确认好的部分:
(muvluv开篇是ul的语音,很神秘)

编号角色
8,10香月夕呼(Unlimited)
9御剣冥夜(Unlimited)
11珠瀬壬姫(Unlimited)
12榊千鶴(Unlimited)
13彩峰慧(Unlimited)
80鑑純夏
81白銀武母
82-227鑑純夏 (没细听,考虑到剧情可能还会有武爷那句“饶不了那臭狗”)



标好后直接提交 PR 到仓库 `data/muvluv_runs.csv`,或者直接回复在帖子里都可以。
角色完整列表见仓库里的 `characters/characters.json`(22 人)和 `characters_mla.json`(30 人)。

有 Steam 版游戏的朋友,可以尝试对游戏目录跑一遍脚本,就能得到和目前完全一致的音频编号。Windows 一键脚本已内置,详细步骤见 README。

所有标注汇总后,会在发布逐文件角色归属总表
遇到 BGM 或音效段落,直接标 `音效` 跳过即可

本文版权遵循 CC BY-NC 协议 和 本站版权政策

1 条回复

Okusama-daze
发布于

# 如何解出 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 程序

```

三个关键事实(很多旧教程错在这里):

  1. **四个 `.rio` 是一个连续地址空间**。`.002/.003/.004` 不是独立包,是同一个归档的续卷。

   逻辑偏移 = 前面所有卷的大小之和 + 本卷内偏移。

  1. **目录(ICI + TOC)里没有音频**。TOC 只登记了约 1.7 GB 的 UI/脚本/图片/影片对象,

   约 1 GB 的音频流(`.oor`)在目录之外,只能按文件头扫描定位。

  1. **语音和台词是两套数据**:语音是 `.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. 常见坑

  1. **把 `.002` 当独立包** → 偏移全错。必须按卷顺序拼成一个地址空间。

  2. **只解目录** → 拿不到音频。音频不在 TOC 里,必须扫头。

  3. **用 `strings` 找文件名** → 音频没有文件名,脚本里也没有 `.oor` 字样。

  4. **以为 key 是哈希** → 它其实是偏移,直接解码就能定位。

  5. **忘记每块后面的 2 字节 checksum** → 解出来的数据整体错位。

  6. **用逐字节 Python 解密 20 GB** → 太慢;同 key 的流共享密钥流,用 numpy 向量化。

  7. **混入多人台词** → 影响训练质量,单独隔离。

---

## 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

(。>︿<。) 已经一滴回复都不剩了哦~