26 / 08 / 04

KMYE 游戏资源与谱面格式分析

记录对 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 则保存歌曲数据。只在文件中搜索 MThdRIFF 等签名并不能直接提取资源,因为 PAC 不是 ZIP、LZSS 等常见格式的简单封装。

PAC 容器结构

检查了游戏目录中的 13 个 PAC 文件,发现前 16 个字节是文件头,0x00 固定为 kzpack2^0x08 开始出现了 8 个字节的整数,其中后 4 个在这 13 个 PAC 文件中均为 3,前 4 个则不一样,所以暂且将其视为文件记录数。如 Data.pac 在 0x08 处出现 18 00 00 00,表示该 PAC 含有 24 条文件记录。

文件头之后是文件表,可以看出 PAC 中的每个内部文件对应一条固定长度为 536 字节的记录。

有了上面这些发现,可以暂时认为,

  1. PAC 的文件头占 16 字节,其中记录数量为 24;

  2. 文件表每项 536 字节,因此文件表总长度为 24 x 536 = 12864 字节;

  3. 加上开头的 16 字节文件头,数据区应该从 16 + 12864 = 12880 字节开始,也就是 0x3250

  4. 同理,任意一条记录在文件表中的位置,可以直接按“记录位置 = 16 + 记录编号 x 536”计算得出。

XOR、BINA 编码

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声部
341A-Melo Lead
562B-Melo Lead
783, 4Dist.Gtr / C-Melo Lead
11125Orch.Hit

对这些 Part 进行比较后,共发现 389 组同通道、同音高且时间区间重叠的音符。也就是说,重复 Note On 可能建立多个发声实例使音量增大,并且较早的 Note Off 可能截断另一个仍在延续的同音高音符。只要多个轨道使用同一个 MIDI Channel,这些事件就会同时影响该 Channel 下的全部轨道。所以,想要提取出正常的版本,需要结合分析游戏谱面,找出它们之间的重叠关系。

KMPC 文本

提取出的歌曲是标准 MIDI Format 1。对 MTrk 逐事件解析后,发现所有 KMPC 字符串都位于 Meta Text Event 中:

对内置的 20 首歌曲解析后,共得到如下包含了 KMPC 的文本类型:

类型数量示例
格式版本20KMPC1.00
结束标记20KMPC:END
普通按键12198KMPC:C#2
长按按键511KMPC:LC#2
Autoplay157KMPC:AUTO
常规 Part107KMPC:P01,P00
其他 Part11KMPC: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

MIDIInfo 主要用于记录歌曲和谱面相关的信息。

(一)数量和区间

打开 MIDIInfo.cd,发现从 0x00 开始记录了 14 00 00 00,转换为十进制后为 20,与文件中解析出的 20 首内置歌曲数量一致;如果通过内置的 KMImporter(用于导入外部 MIDI 音源进行游戏)导入外部文件,这个数字也会进行增加,因此可将文件前 4 字节判定为歌曲记录数量。

再往后,可以反复看到“4 字节字符串 + CP932 字符串 + 字符串”这样的构成,例如 Data\____4488.mid

往后都能按照这样的规则找到 Data\____ride.midData\____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。

游戏中的 Part

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 保存了预处理后的谱面数据。玩家未按键时,游戏会对相应玩家声部进行运行时控制,因此这些声部不会按普通伴奏方式自动发声。

生成正确的 MIDI 文件

了解了构成后就好办了,只要对每条轨道解析 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。