26 / 08 / 04
这次主要解决两个问题:怎样从游戏的 PAC 文件中完整提取 MIDI、WAV 等资源,以及为什么部分声部必须等玩家按键才会发声,并将其处理成能在普通 MIDI 播放器中完整播放的版本。
游戏目录中共有 13 个 PAC 文件:
Data.pac Sound.pac Image.pac main.pac main2p.pac cont_01.pac html_base.pac kekka_01.pac kekka_02.pac md_sel.pac mu_title.pac staff_pc.pac title.pac
其中,Sound.pac 主要包含系统声音,Data.pac 保存歌曲数据。直接在文件中搜索 MThd、RIFF 等签名无法提取资源,因为 PAC 并不是 ZIP、LZSS 等常见格式的简单封装。
检查游戏目录中的 13 个 PAC 文件后,可以确认其前 16 字节为文件头。偏移 0x00 处固定为 kzpack2^。从 0x08 开始是一个 8 字节整数,其中后 4 字节在这 13 个 PAC 文件中均为 3,前 4 字节各不相同,因此暂时将前 4 字节视为文件记录数。
例如,Data.pac 在 0x08 处的内容为:
18 00 00 00
按小端序解释为十进制 24,表示该 PAC 中有 24 条文件记录。
文件头之后是文件表,可以看出 PAC 中的每个内部文件对应一条固定长度为 536 字节的记录。
由此可以暂时得出:PAC 文件头长度为 16 字节,Data.pac 的记录数为 24;文件表每条记录占 536 字节,总长度为 24 × 536 = 12864 字节;数据区从 16 + 12864 = 12880 字节处开始,即偏移 0x3250。任意记录在文件表中的位置可按 16 + 记录编号 x 536 计算得出。
从 data_offset 读取 packed_size 字节后,首先对每个字节执行:
value = value ^ 0xFE
这是一种可逆混淆,不具备实际的加密能力,还原后得到的是 BINA 压缩数据。
BINA 数据以块为单位保存二叉展开表,每个块最多包含 256 个节点。解码器分别使用 left[256] 和 right[256] 两个数组记录节点的左右分支。
程序首先检查 left[node]。如果它与当前节点编号 node 相同,就将该节点视为叶子节点,节点编号即最终输出的字节值;否则,该节点属于中间节点,程序继续沿 left[node] 和 right[node] 指向的子节点向下展开。
解码器以达到预期输出长度为停止条件:
len(output) == original_size
实际测试中,Sound.pac 成功还原出 16 个 MIDI 文件和 49 个 WAV 文件。对目录中的另外 12 个 PAC 文件使用同一套算法,也都能正常解包。
提取 MIDI 文件后播放异常提取出的 MID 文件可以直接播放,但会出现音符音量不一致、重复触发和长音提前结束等问题。
进一步检查发现,同一 MIDI 文件中包含多个 Part,对应游戏开始演奏前的 Part 选择。以 Ride on the Light 为例,P1 和 P2 轨道成对使用相同的 MIDI Channel:
| P1 轨道 | P2 轨道 | MIDI Channel | 声部 |
|---|---|---|---|
| 3 | 4 | 1 | A-Melo Lead |
| 5 | 6 | 2 | B-Melo Lead |
| 7 | 8 | 3, 4 | Dist.Gtr / C-Melo Lead |
| 11 | 12 | 5 | Orch.Hit |
比较这些 Part 后,共发现 389 组通道、音高相同且时间区间重叠的音符。重复的 Note On 可能建立多个发声实例,使音量增大;较早出现的 Note Off 也可能截断另一个仍在持续的同音高音符。
只要多条轨道共用同一个 MIDI Channel,这些事件就会同时影响该 Channel 下的所有轨道。因此,要得到能够正常播放的版本,还需要结合游戏谱面分析各轨道之间的重叠关系。
提取出的歌曲文件采用标准 MIDI Format 1。逐事件解析 MTrk 后,可以看到所有 KMPC 字符串都保存在 Meta Text Event 中。

解析内置的 20 首歌曲后,共得到以下几类包含 KMPC 的文本:
| 类型 | 数量 | 示例 |
|---|---|---|
| 格式版本 | 20 | KMPC1.00 |
| 结束标记 | 20 | KMPC:END |
| 普通按键 | 12198 | KMPC:C#2 |
| 长按按键 | 511 | KMPC:LC#2 |
| Autoplay | 157 | KMPC:AUTO |
| 常规 Part | 107 | KMPC:P01,P00 |
| 其他 Part | 11 | KMPC:P03; KMPC:P01,P02,P03 |
KMPC:<音名> 所在的 tick 可以与同一轨道中的 Note On 对齐,表示玩家应当按下的键;Note On 则表示合成器实际发出的音。
由于这是游戏谱面,玩家按下的键位和同一时刻的 MIDI Note On 并不一定完全对应。一次按键可能触发多个 Note On,例如一个管弦乐重击可能同时发出跨八度和弦。因此,不能简单地把 KMPC:C#2 转换成一个 C#2 音符,再删除原轨道中的音符事件。
带 L 的标记与 MIDIInfo.cd 中的 long_flag = 1 逐项对应,普通标记则对应 long_flag = 0。以 Ride 为例,P1 和 P2 各有 15 个带 L 的标记,数据库中也各有 15 个长音事件。
KMPC:AUTO 所在的区段包含不要求玩家输入、但仍由 MIDI 发声的事件。
MIDIInfo.cd 主要记录歌曲和谱面信息。
文件从偏移 0x00 开始的内容为:
14 00 00 00
按小端序转换为十进制后是 20,与文件中解析出的 20 首内置歌曲数量一致。通过内置的 KMImporter 导入外部 MIDI 音源后,这个数字也会增加,因此可以判断,文件开头的 4 字节表示歌曲记录数量。
继续向后读取,可以反复看到“4 字节整数、CP932 字符串、4 字节整数、字符串”这样的结构,例如:
Data\____4488.mid
后续也能按同样的规律找到 Data\____ride.mid、Data\____sens.mid 等路径,其名称和顺序均与游戏中的歌曲数据相符。
Ride 的路径字符串从 0x31B90 开始,长度字段也位于 0x31B90。下一条 Data\____sens.mid 的路径字符串从 0x33D61 开始,因此 Ride 的记录范围为:
[203660, 212321)
总长度为 8661 字节。
KMImporter 的读取逻辑也能用于交叉验证。该程序静态链接了部分 C 运行库,因此反汇编中没有直接保留 fread 名称。不过,可以找到一个行为与 fread 一致的包装函数:它接收缓冲区、单项长度、项数和文件流,先执行流加锁,再将相同参数传给底层读取函数。其逻辑大致如下:
read(&record_count, 4, 1, fp); for (int i = 0; i < record_count; i++) { read(&path_length, 4, 1, fp); read(path, 1, path_length, fp); read(&midi_crc32, 4, 1, fp); read(&title_length, 4, 1, fp); read(title, 1, title_length, fp); }
循环末尾会将索引加一,并在索引小于 record_count 时返回记录读取入口。这说明文件开头的整数直接控制记录的读取次数。
对于后续数据,程序会循环 16 次,每次读取四个 4 字节整数,随后再读取事件数量和对应事件。按照这一顺序编写解析器后,Ride 的记录可以完整读完,没有剩余字节,后续记录也能连续解析。
事件结构如下:
struct ChartEventDisk { uint32_t key_index; uint8_t long_flag; int32_t scroll_coord; int32_t long_coord_a; int32_t long_coord_b; }
继续以 Ride on the Light 为例,记录开头依次为:
uint32 17 char "Data\____ride.mid"[17] uint32 0xE23135F8 uint32 17 char "Ride on the Light"[17]
路径和标题前都有明确的长度字段。对 PAC 中的源 MIDI 文件计算 CRC32,结果一致,可以确认中间的 32 位数值是源 MIDI 的 CRC32。
Ride 的 Part 统计数组从偏移 0x76 开始。程序循环 16 次,每次读取四个 32 位整数,因此共有 16 个槽位,每个槽位占 16 字节。
将前四个槽位按小端有符号整数解释,结果如下:
slot1:[ 1, 161, 146, 15] slot2:[ 1, 282, 267, 15] slot3:[-1, 0, 0, 0] slot4:[-1, 0, 0, 0]
槽位 5 至 16 也都是:
[-1, 0, 0, 0]
与原 MIDI 对照后,可以确定后三个值依次表示总事件数、普通事件数和长音数。第一个值应该与槽位状态有关;当它为 1 时,游戏中会显示对应的 Part。
Ride 的 P1 和 P2 数据分别为:
P1:161 = 146 KMPC:C#2 + 15 KMPC:LC#2 P2:282 = 267 KMPC:C#2 + 15 KMPC:LC#2
这些数据也能说明 Part 1 和 Part 2 的具体差别。以 Ride on the Light 为例,P1 和 P2 经常使用相同的 MIDI Channel 和音色,但包含的 KMPC 判定标记数量不同。Part 1 有 161 个判定键,Part 2 有 282 个判定键。综合来看,P1 会由游戏自动处理更多音符,P2 则要求玩家演奏更多内容,因此两者代表不同难度的谱面。
事件数量、事件顺序、Part 和长音标志均能逐项对应到原 MIDI。这些结果共同表明,MIDIInfo.cd 保存的是预处理后的谱面数据。玩家没有按键时,游戏会在运行时控制相应的玩家声部,因此这些声部不会像普通伴奏一样自动发声。
了解这些结构后,就可以生成普通 MIDI 播放器能够完整播放的版本。
首先解析每条轨道中的 Part 和有效按键标记,再按逗号拆分其中的所有 Part 编号,统计各 Part 的有效按键数,并选择数量最多的 Part。如果数量相同,则以 Part 编号作为稳定的次级排序条件。
用于匹配常规 Part 声明的正则表达式为:
^KMPC:P\d+(?:,P\d+)*$
在 20 首歌曲中,Carezza、For Elise、Henry Henry、Mr.CC 和 STEAM AND DREAM 的完整版本位于 Part 3,其余歌曲的完整版本位于 Part 2。
按照游戏本身的结构和使用惯例,没有 Part 声明的轨道通常包含 Conductor、SysEx、Bass、Piano、Drums、Strings 等公共内容,需要保留;选中 Part 对应的轨道则保留全部 Note On、Note Off、Program Change、Control Change、Pitch Bend 和 SysEx 事件。
处理时还需要删除保留轨道中的 KMPC: 判定文本,并将玩家轨道名称改为普通自动播放轨道的名称。删除 Meta Event 时,必须累加其 Delta Time,并将其转移给下一个保留事件,否则后续音乐会提前。
最后,重新计算每个 MTrk 的长度以及 MThd 中记录的轨道数量,同时正确处理 VLQ、Running Status 和 SysEx,即可生成能够正常播放的 MIDI 文件。