26 / 08 / 04
记录对 KEYBOARDMANIA Yamaha Edition (KMYE) 的游戏资源与谱面格式分析。
这回我想解决的问题有两个,一是怎样从游戏的 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 个则不一样,所以暂且将其视为文件记录数。如 Data.pac 在 0x08 处出现 18 00 00 00,表示该 PAC 含有 24 条文件记录。
文件头之后是文件表,可以看出 PAC 中的每个内部文件对应一条固定长度为 536 字节的记录。
有了上面这些发现,可以暂时认为,
PAC 的文件头占 16 字节,其中记录数量为 24;
文件表每项 536 字节,因此文件表总长度为 24 x 536 = 12864 字节;
加上开头的 16 字节文件头,数据区应该从 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 主要用于记录歌曲和谱面相关的信息。
(一)数量和区间
打开 MIDIInfo.cd,发现从 0x00 开始记录了 14 00 00 00,转换为十进制后为 20,与文件中解析出的 20 首内置歌曲数量一致;如果通过内置的 KMImporter(用于导入外部 MIDI 音源进行游戏)导入外部文件,这个数字也会进行增加,因此可将文件前 4 字节判定为歌曲记录数量。
再往后,可以反复看到“4 字节字符串 + CP932 字符串 + 字符串”这样的构成,例如 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 结果也是 0xE23135F8,可确认中间的 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。
P1:161 = 146 KMPC:C#2 + 15 KMPC:LC#2 P2:282 = 267 KMPC:C#2 + 15 KMPC:LC#2
另外还能得到 Part1/2 的具体含义。以 Ride on the Light 为例,P1 和 P2 经常使用相同的 MIDI Channel 和音色,但具有不同数量的 KMPC 判定标记。Part 1 有 161 个判定键,Part 2 有 282 个判定键,综合来看,P1 会让游戏自动处理更多音符,P2 则要求玩家演奏更多内容,代表的是不同难度的谱面。
事件数量、顺序、Part 和长音标志均可逐项对应回原 MIDI,共同表明 MIDIInfo.cd 保存了预处理后的谱面数据。玩家未按键时,游戏会对相应玩家声部进行运行时控制,因此这些声部不会按普通伴奏方式自动发声。
了解了构成后就好办了,只要对每条轨道解析 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/Off、Program Change、Control Change、Pitch Bend 和 SysEx。因此,在保留轨道中删除 KMPC: 判定文本,并把玩家轨道名称改为普通自动轨道名称,删除 Meta Event 时必须累加其 Delta Time,并转移给下一个保留事件,否则后续音乐会提前。最后重新计算每个 MTrk 长度及 MThd 中的轨道数量,同时处理 VLQ、Running Status 和 SysEx。