从 14 GB 游戏目录到 372 条登录语音:SDDT DailyBonus 语音机制分析
在 O.N.G.E.K.I. 中,每天第一次登录并结算 DailyBonus 时,导航角色播放的语音并不固定。换一个角色、切换导航语音版本,或者在不同月份登录,都可能听到不同内容。
非官方 Wiki 对这部分机制记录得不多,所以我直接从 SDDT 入手,试着回答三个问题:
- DailyBonus 语音保存在哪里?
- 游戏如何从 17 个角色、两套导航语音和 12 个月份中选出正确的一条?
- 客户端代码、ACB 播放结构与 AWB 音频条目之间是怎样对应的?
最后,我在约 14.4 GiB、包含 48,332 个文件的SDDT中,定位到了 8 个登录相关 Cue,并将它们完整映射到 372 条实际音频。
本文只讨论资源结构、选择逻辑与研究方法,不提供、分发或嵌入提取出的游戏音频。
一、先看结论:DailyBonus 一共有多少条语音?
| 语音类型 | 每个角色数量 | 总数 |
|---|---|---|
| 普通登录语音 | 3 组 × 2 种导航语音 = 6 | 102 |
| 月度登录语音 | 12 个月 | 204 |
| 有限期登录奖励语音 | 1 | 17 |
| 2 周年语音 | 前 16 个角色各 1 条 | 16 |
| 3 周年语音 | 前 16 个角色各 1 条 | 16 |
| 4 周年语音 | 17 个角色各 1 条 | 17 |
| 合计 | 前 16 人各 22 条,第 17 人 20 条 | 372 |
17 个角色按音频 Selector 的顺序排列如下:
- 星咲 あかり
- 藤沢 柚子
- 三角 葵
- 高瀬 梨緒
- 結城 莉玖
- 藍原 椿
- 早乙女 彩華
- 桜井 春菜
- 九條 楓
- 柏木 咲姫
- 井之原 小星
- 逢坂 茜
- 珠洲島 有栖
- 柏木 美亜
- 日向 千夏
- 東雲 つむぎ
- 皇城 セツナ
在资源层面,2 周年和 3 周年 Cue 只包含前 16 个角色的分支,皇城 セツナ对应的第 17 个分支直到 4 周年 Cue 中才出现。因此她在当前资源中共有 20 条登录语音,而其他 16 人各有 22 条。
这一差异与角色正式加入游戏的时间线相符,毕竟剧情里セツナ是在第四章登场的,对应的时间是 RED 和 RED PLUS 。
二、先确认容器,而不是直接搜索 WAV
如果直接在游戏目录中搜索:
login.wav
voice.ogg
character.mp3基本不会得到有用结果。SDDT 使用的是 CRIWARE Atom 音频系统,实际资源主要由 ACB 与 AWB 组成。
对客户端目录进行扩展名和文件头扫描后,我得到以下统计:
| 类型 | 数量 |
|---|---|
| ACB | 960 |
| AWB | 960 |
| 独立 HCA | 0 |
| CPK | 0 |
| UnityFS | 20,050 |
其中 960 个 ACB 与 960 个 AWB 可以按文件名精确配对,例如:
MU3.acb
MU3.awb这两类文件的职责并不相同:
ACB:Cue、Sequence、Track、Selector 与 Waveform 引用
AWB:实际压缩音频数据换句话说,ACB 更像一张“如何播放”的关系表,AWB 才保存真正的声音数据。一个 Cue 可以经过多层 Sequence 和 Track,最终指向一个或多个 AWB 条目。
在这份客户端中:
MU3.acb
├─ 471 个 Cue
├─ 2,813 个 Waveform
├─ 201 条内嵌 MemoryAwb
└─ 引用外部 MU3.awb
MU3.awb
└─ 2,612 条 StreamAwb三、为什么目标是 MU3.acb?
面对 960 组 ACB/AWB,逐个解包并试听显然不现实。更有效的办法是先搜索 ACB 中的 Cue 名称和 Selector 字符串。
在 MU3.acb 中可以找到完整的登录语音 Cue:
vo_entry_login
vo_entry_login_02
vo_entry_login_03
vo_entry_login_month
vo_entry_login_special
vo_entry_login_2anniversary
vo_entry_login_3anniversary
vo_entry_login_4anniversary同一个文件中还出现了三组关键 Selector:
Voice_Character
Voice_Navi
Voice_Monthly到这里,结构已经比较清楚了:
- Cue 决定当前播放哪一类登录语音;
Voice_Character选择角色;Voice_Navi选择该角色的两种普通导航语音;Voice_Monthly选择 1 月至 12 月的月度语音。
相比之下,MU3_Story.acb 主要包含 vo_story_* Cue,属于剧情对白资源,不是 DailyBonus 的主入口。
四、找到 Cue 之后,为什么还不能直接导出?
ACB 中的 Cue 并不直接等同于某一个音频文件。以普通登录语音为例,一条引用链可能经过:
Cue
→ Sequence
→ Track
→ TrackEvent
→ Sequence
→ Track
→ TrackEvent
→ Synth
→ Waveform
→ AWB Entry其中一条实际路径是:
CueTable[96]
→ SequenceTable[114]
→ TrackTable[213]
→ TrackEventTable[208]
→ SequenceTable[115]
→ TrackTable[230]
→ TrackEventTable[225]
→ SynthTable[189]
→ WaveformTable[132]如果只知道 Cue ID,却没有继续追踪这些引用,就无法确定:
- 最终使用的是哪条 Waveform;
- 音频位于内嵌 MemoryAwb 还是外部
MU3.awb; - 对应哪个 AFS2 Entry;
- 播放工具需要使用哪个 subsong 编号。
@UTF 不是文本编码
ACB 外层以 @UTF 开头。这里的 UTF 并不是 UTF-8 或 UTF-16,而是 CRI 的二进制表结构。
解析时需要读取列定义、常量列、行数据、字符串表、二进制数据区以及嵌套表。完成解析后,MU3.acb 中与本次研究关系最密切的表如下:
| 表 | 行数 |
|---|---|
| Cue | 471 |
| CueName | 471 |
| Waveform | 2,813 |
| Synth | 2,939 |
| Track | 3,170 |
| Sequence | 677 |
| TrackEvent | 3,146 |
| TrackCommand | 113 |
解析时不能依赖固定偏移或固定列序。更稳妥的做法是先读取实际 Schema,再根据表名、列名和数据类型还原引用关系。
五、AWB ID 与 subsong 是两回事
AFS2 容器里至少有两个容易混淆的编号:
entry_id
entry_ordinalentry_id是 AFS2 索引表中保存的条目 ID;entry_ordinal是它在容器中的排列位置;- vgmstream 的
-s参数使用的是从 1 开始计算的 subsong 序号。
例如某条语音可能表现为:
AWB ID = 225
subsong = 226但这并不意味着所有文件都可以简单地用“AWB ID + 1”换算。正确做法是解析 AFS2 的 ID 表,同时记录条目 ID、排列顺序与一基 subsong。
本次研究得到的 372 条终端 Waveform 全部位于外部 MU3.awb 中,并且每一条 Waveform 的 AWB ID 都能精确匹配对应的 AFS2 Entry。
六、为什么会出现 34、204 和 17?
完成 Cue 引用图后,各登录 Cue 对应的终端 Waveform 数量如下:
| Cue | Waveform 数 |
|---|---|
vo_entry_login | 34 |
vo_entry_login_02 | 34 |
vo_entry_login_03 | 34 |
vo_entry_login_month | 204 |
vo_entry_login_special | 17 |
vo_entry_login_2anniversary | 16 |
vo_entry_login_3anniversary | 16 |
vo_entry_login_4anniversary | 17 |
其中:
34 = 17 × 2
204 = 17 × 12从数量上很容易猜到“17 是角色、2 是导航语音版本、12 是月份”。不过数量吻合只能提供线索,不能直接当作结论。真正确认这些分支含义的,是后续静态代码分析和 ACB Selector 条件。
七、登录语音的入口在哪里?
SDDT 使用 Unity Mono,核心逻辑保存在:
mu3_Data/Managed/Assembly-CSharp.dll因此可以先用 ILSpy 和 Mono.Cecil 分析托管代码,而不必从原生 mu3.exe 开始。
最终定位到的 DailyBonus 语音入口是:
MU3.DailyBonus::initialize从用户数据加载到 CRI Atom 开始播放,主要调用链如下:
服务器用户数据
→ Scene_25_Login.invokeOnFinish
→ UserManager.initializeOnLogin
→ AvatarVoice.changeChara
→ Scene_30_NoticeReward.DailyBonus_Init
→ DailyBonus.initialize
→ AvatarVoice.play
→ CharacterSound.play
→ SoundManager.playCharaVoice
→ SoundPlayer.Builder.setSelector
→ CriAtomExPlayer.SetSelectorLabel
→ CriAtomExPlayer.StartDailyBonus_Init 是由场景状态机按状态名绑定的回调,而不是一条普通的静态方法调用。因此如果只做直接调用关系搜索,调用图会在这里看似“断掉”。
八、DailyBonus 语音究竟怎样被选中?
下面这张流程图概括了当前版本中已经确认的选择逻辑:
图:DailyBonus 登录语音选择及 CRI Track 选择流程。
这套机制可以拆成两层:
DailyBonus.initialize先选择 Cue;- CRI Selector 再在 Cue 内选择角色、导航语音版本或月份对应的 Track。
普通登录 Cue 的概率并不相同
永久登录奖励只读取一次 UnityEngine.Random.value,然后按固定区间选择:
| 随机区间 | Cue | 概率 |
|---|---|---|
[0.0, 0.4) | vo_entry_login | 40% |
[0.4, 0.7) | vo_entry_login_02 | 30% |
[0.7, 0.8) | vo_entry_login_03 | 10% |
[0.8, 1.0) | vo_entry_login_month | 20% |
因此,“月度语音”并不是每个月必定播放一次,而是永久 DailyBonus 中一个概率为 20% 的分支。只有随机结果落到 vo_entry_login_month 后,月份 Selector 才会决定具体播放哪一个月的台词。
九、special 不是硬编码的特殊日期
静态代码中的逻辑很直接:
if (bonusData.isPermanent)
avatarVoice.play(selectLoginVoiceRandomly());
else
avatarVoice.play(1553);Cue ID 1553 对应:
vo_entry_login_special所以这里的 special 并不是某个固定节日或纪念日,而是:
DailyBonusData.isPermanent == false也就是有限期登录奖励使用的通用语音。
至于:
vo_entry_login_2anniversary
vo_entry_login_3anniversary
vo_entry_login_4anniversary当前 Assembly-CSharp.dll 中能找到它们的 SoundID 常量,却没有找到实际执行路径。因此可以确认周年音频存在,但无法仅凭当前版本的静态代码说明它们何时、由谁触发。
十、Voice_Character 如何选中 17 个角色?
服务器用户数据中包含:
characterId = 1000 ... 1016客户端把它格式化成六位字符串:
1000 → "001000"
1001 → "001001"
...
1016 → "001016"随后设置:
Voice_Character = "001000" ... "001016"而 ACB 外层 17 条 Track 的条件正好与之逐一对应:
Track 0 → Voice_Character = 001000
Track 1 → Voice_Character = 001001
...
Track 16 → Voice_Character = 001016角色与外层 Track 的关系因此得到了多重证据支持:托管代码提供 Selector 值,ACB 的 TrackCommand 保存筛选条件,ACF 中则定义了同名 Selector。
十一、两种普通导航语音由谁控制?
每个普通登录 Cue 都包含:
17 个角色 × 2 种 Voice_Navi = 34 条音频决定内层两个分支的不是随机数,而是服务器用户数据中的:
characterVoiceNo映射关系为:
| characterVoiceNo | Selector | Track |
|---|---|---|
| 0 | Voice_Navi=01 | 0 |
| 1 | Voice_Navi=02 | 1 |
客户端内部先计算 VoiceNo + 1,再格式化为两位字符串。所以两种导航语音并不是每次登录随机切换,也不是按日期轮换,而是由当前用户设置精确决定。
十二、月份 Selector 与 07:00 营业日边界
vo_entry_login_month 的结构是:
17 个角色 × 12 个月 = 204 条音频客户端直接读取:
CustomDateTime.Now.Month然后设置:
1 月 → Voice_Monthly=01 → Track 0
2 月 → Voice_Monthly=02 → Track 1
...
12 月 → Voice_Monthly=12 → Track 11这里还有一个容易忽略的细节:DailyBonus 判断“是否进入新的一天”时,以 07:00 作为营业日边界。
00:00–06:59 → 仍计入前一个营业日
07:00 以后 → 进入新的营业日但 Voice_Monthly 并不使用这套偏移,而是直接读取 Windows 本地月份。因此在每月 1 日的 00:00 至 06:59 之间,理论上可能出现一种边界情况:
- DailyBonus 资格仍按上一个营业日计算;
- 月度语音 Selector 已经切换到新月份。
这个结论来自静态代码路径,目前没有通过修改系统时间进行动态验证。
十三、如何从 AWB 中取出对应音频?
确定 Cue、Track、Waveform 与 AWB Entry 的对应关系之后,实际解码并不复杂。vgmstream 可以读取 MU3.awb 中的指定 subsong,并转换为 WAV:
vgmstream-cli.exe `
-s 226 `
-o "target.wav" `
"MU3.awb"需要注意的是,-s 接收的是一基 subsong,而不是 AFS2 Entry ID。真正重要的工作发生在导出之前:先沿 ACB 引用链确定目标 Waveform,再将 AWB ID 映射到正确的 subsong。
换句话说,技术重点不是“怎样把 AWB 转成 WAV”,而是“怎样知道应该转出 AWB 中的哪一条”。如果跳过映射直接全量导出,虽然也能得到大量声音文件,却会丢失 Cue、角色、月份和用户设置之间的关系。
十四、调查过程中最容易误判的几件事
1. 把 ACB 当成普通压缩包
ACB 保存的是播放图、控制条件和波形引用,不是简单的音频文件列表。
2. 看到多个 Waveform 就认定是随机播放
这些分支也可能由 Selector、角色、语言、月份或用户设置控制。普通 Cue 的 34 个分支就是由 Voice_Character 和 Voice_Navi 两级 Selector 选出的。
3. 把 AWB ID 直接传给 vgmstream
-s 使用的是一基 subsong。AFS2 Entry ID 与条目顺序必须分别解析和保存。
4. 只凭 17 × 12 就确认月份结构
数量只提供猜想。最终还需要客户端中的 CustomDateTime.Now.Month 和 ACB 中的 Voice_Monthly=01...12 共同确认。
5. 把 special 理解成特殊日期
当前版本中,它对应的是非永久 DailyBonus,而不是某个硬编码节日。
十五、这套方法还能用在哪里?
对其他采用 CRI Atom 的游戏,也可以沿用类似思路:
扫描 ACB / AWB / ACF
→ 搜索目标 Cue
→ 解析 @UTF 表结构
→ 建立 Cue 到 Waveform 的引用图
→ 解析 AFS2 Entry 与 subsong
→ 检查 Selector / AISAC / Game Variable
→ 静态分析客户端播放调用
→ 有选择地解码目标音频真正需要闭合的是下面这条关系链:
客户端条件
→ Cue
→ Sequence / Track
→ Selector
→ Synth / Waveform
→ AWB Entry
→ subsong
→ 实际音频只要这条链能够被逐层验证,就不必依赖盲目试听或全量解包,也能解释每一条声音为什么会在特定条件下播放。
十六、结语
最初的问题只是:为什么每天登录时听到的语音会变化?
分析完成后,可以把当前版本的 DailyBonus 语音机制概括为:
- 永久 DailyBonus 先按 40%、30%、10%、20% 的权重选择普通或月度 Cue;
- 非永久 DailyBonus 使用独立的
vo_entry_login_special; Voice_Character根据服务器返回的characterId选择角色;Voice_Navi根据characterVoiceNo选择两种普通导航语音之一;Voice_Monthly根据 Windows 本地月份选择月度台词;- DailyBonus 日期判断使用 07:00 营业日边界,但月份 Selector 不使用该偏移;
- 2、3、4 周年音频存在,不过当前程序集没有发现它们的执行调用方。
从资源角度看,372 条登录语音并不是 372 个独立文件,而是 471 个 Cue、2,813 个 Waveform、2,612 条 StreamAwb、多层 Sequence 与 Track,以及三组 Selector 共同组织起来的结果。
这次调查真正有价值的部分,不是单纯把声音导出成 WAV,而是把“服务器数据、客户端判断、CRI Selector 与最终音频条目”连成了一条可以复查的完整链路。