不知道上面这几张图片有没有勾起大家的童年记忆。在那段游戏机被禁售的年代,不知是哪位大能造出了一款可以在背后插手柄的 VCD,再搭配一张写着"中文游戏 300 合 1"的光盘,让小时候的我如痴如醉。后来我才知道,这些盗版游戏甚至漂洋过海,远销东欧,壮哉我大天朝……

但这段独特的记忆里,也伴随着一些挥之不去的"影子"。下面有两段音乐,大家可以先听一下,看是否能秒懂我的意思:

还有一段——到结尾处会稍微"正常"一点的版本:

这就是(我不知道它是否著名,反正你小时候要是听过,这辈子都很难忘记的)盗版超级马里奥城堡关底的胜利音乐。不知道你听到的是哪个版本?我听的是第一种。

这也让我很长一段时间里都以为,超级马里奥城堡关底的音乐本来就长这样——阴森、诡异,倒也莫名契合城堡关卡与之前关卡截然不同的独特氛围。直到有一天,我偶然听到了真正的关底音乐,当时我的第一反应竟然是:"这个人玩的版本怎么这么正常、这么好听?"这件事也成了我少年时代的未解之谜之一。不管怎么说,这段诡异的音乐有没有给我留下童年阴影暂且不论,它肯定已经成为我童年记忆中独特的一部分。

那么,这段怪异音乐到底是怎么来的呢?今天我们就来浅浅地探究一下。

ROM比对

我们可以直接把盗版和正版的超级马里奥一代 ROM 拿来比对:

比对下来,大致能发现上面四处差异。前三处位于 PRGROM,也就是代码和数据部分;第四处位于 CHRROM,也就是素材部分。本文只关注 PRGROM 的差异,我们逐一来看这些修改都造成了什么影响。

反编译

光盯着这些十六进制数据肯定看不出门道,我们需要把它们翻译成对应的汇编代码或数据结构。这些工作前人已经做过,我们可以直接参考:

https://6502disassembly.com/nes-smb/SuperMarioBros.html

来看到这些数据代码部分的反编译结果。

需要注意的是,PRGROM 会被加载到内存 80008000–8000–FFFF 区域,上面的反汇编也是按照这个加载地址来标注的,所以要小心 ROM 文件偏移和内存地址之间的映射关系。这里直接给出结论:

加载地址 = ROM 文件偏移 + 0x8000 − 0x10

其中 0x10 是 ROM 文件头的大小,这部分不会被加载进内存。

第一段

先来看第一处差异,直接对照反汇编代码:

先对上面的图片做下说明,左边的是正版的反编译代码,右边的是盗版的。

上面高亮的两处修改都位于 NMI 处理程序内部。NMI 处理程序可以理解为定时中断处理例程,更通俗地说,就是一个每隔一段固定周期就会被执行一次的函数。它通常用来刷新屏幕、管理缓冲区、维护定时器等等。

这两处修改具体做了什么呢?

第一处,把"将 PPUMASK 对应的值存入 A 寄存器"的指令替换成了"跳转到 $FE5D 并执行那里的代码"。而 $FE5D 正好落在被修改过的那段 ROM 数据范围内——我们先把这个地址记下来,后面再谈。

第二处,把跳转的目标地址从原本处理"声音开关"的逻辑,偏移到了原地址 +1 的位置。细节不展开,结果就是:原本在标题画面被禁用的游戏音效,被强行启用了。

第三处修改对应下面这个标签:

也就是说,原本指向 GameMenuRoutine 的调用,全部被 HOOK 到了 $FE00。GameMenuRoutine 本来负责处理游戏开始菜单的逻辑,而盗版厂商就是在这里"偷天换日",把选关的逻辑塞了进去。$FE00 同样落在被修改过的 ROM 数据区域内,我们也先记住它。

第二段

第二处其实只是把游戏画面顶部分数上方显示的 "MARIO" 换成了 "SCORE" 这几个字符:

第三段

第三处差异,才是破坏水下关卡和城堡胜利音乐的罪魁祸首。我们可以看到,这里修改了 7E10–7ED3 的数据,对应内存加载地址 $FE00–$FEC3:

上图两个红框分别对应被破坏区域的起点和终点。可以看到,这段被覆盖的范围,正好吃掉了水下关卡音乐的下半部分,以及城堡胜利音乐的大部分。

还有一个有趣的发现:一部分盗版厂商的卡带,从加载地址 $FEB1 开始会多出一段"根本不会被执行"的代码(因为函数在执行到这里之前就已经返回了)。我猜测是有的厂商发现了这段冗余代码,于是把它还原了回去(对应上图中的绿框)。而被还原的这部分,恰好是城堡关卡其中一个声部的一半音乐数据。

这就解释了,为什么这段诡异的胜利音乐会有两个版本,以及为什么其中一个版本听起来会有"某一个声部的一小段是正常的"。

题外话:声部

如果你仔细观察,会发现 EndOfCastleMusData 被分成了三部分,分别对应方波通道 2(主音)、三角波通道(低音)和方波通道 1(辅音,用来构成简单的和弦)。被"还原"回来的那部分属于辅音,所以即便数据是正常的,由于它不是主旋律,听起来依然怪怪的。

对于上面提到的两段 D 商插入的例程,我不太熟悉 6502 汇编,就交给 AI 帮我们加上注释,方便理解:

FE00-FE5C: 通过手柄控制选关

LDA $06FC       ; 读取本帧手柄1的按键状态(SavedJoypad1Bits)
              AND #$C0        ; 只保留 A(bit7) 和 B(bit6) 两个键
              BEQ NoABPress   ; A、B 都没按下 → 跳过递增逻辑
              LDX $01E6       ; 检查防连发锁
              BNE SetLock     ; 锁已生效(上一帧已处理)→ 不重复递增

              AND #$80        ; 单独检测 A 键
              BEQ CheckBBtn   ; 没按 A → 去检测 B
              INC $075C       ; 按了 A:世界选择计数器 +1
              LDA $075C       ;
              AND #$03        ; 限定在 0~3 范围内循环(共 4 档)
              STA $075C       ;

CheckBBtn:    LDA $06FC       ; 重新读原始按键状态
              AND #$40        ; 单独检测 B 键
              BEQ SetLock     ; 没按 B → 直接去加锁
              INC $075F       ; 按了 B:关卡选择计数器 +1
              LDA $075F       ;
              AND #$07        ; 限定在 0~7 范围内循环(共 8 档)
              STA $075F       ;

SetLock:      LDA #$FF        ;
              STA $01E6       ; 置防连发锁,按键松开前不再响应

NoABPress:    LDY $075C       ; 取出世界选择计数器到 Y
              BNE ChkLevelSel ; 非 0 → 进入关卡号白名单过滤
              STY $0760       ; 为 0 → 清零 WorldNumber(默认 World 1)
              JMP $8245       ; 跳回被劫持的原初始化入口

ChkLevelSel:  LDA $075F       ; 取关卡选择计数器
              CMP #$01        ;
              NOP             ; ← 补丁抹掉(原本是 BEQ UseLevelSel)
              NOP             ;   值 1 档位已被屏蔽
              CMP #$02        ;
              BEQ UseLevelSel ; 2 号档位合法
              CMP #$04        ;
              BEQ UseLevelSel ; 4 号档位合法
              CMP #$05        ;
              BEQ UseLevelSel ; 5 号档位合法
              CMP #$07        ;
              BEQ UseLevelSel ; 7 号档位合法

              INY             ; 此处 Y 原本是 $075C(1~3),INY 后必然非 0
              BNE UseLevelSel ; → 实际是一条无条件跳转
              AND #$02        ; —— 以下为死代码 ——
              BNE UseLevelSel ;
              BEQ RejectSel   ; (永远执行不到,补丁前版本遗留残骸)

FE5D-FEB0: 选关屏幕反馈

PHA             ; 先保存调用方准备写入 PpuMask_2001 的值
              LDA $01E6       ; 选关菜单激活标志(由输入钩子置位)
              BEQ Exit        ; 未激活 → 跳过 HUD 绘制,直接返回

              LDA $0314       ; 备份 VRAM_Buffer1+0(原值暂存到栈)
              PHA             ;
              LDA $0316       ; 备份 VRAM_Buffer1+2
              PHA             ;

              LDY $075F       ; 取关卡计数器(0~7)
              INY             ; 显示时 +1,变为 1~8
              STY $0314       ; 暂存关卡数字
              LDY $075C       ; 取世界计数器(0~3)
              INY             ; 显示时 +1,变为 1~4
              STY $0316       ; 暂存世界数字

              LDA #$10        ;
              STA PpuControl_2000 ; 关 NMI,背景图案表选 $1000,VRAM 自增 +1
              LDA #$00        ;
              STA PpuMask_2001    ; 强制消隐(熄屏后才能安全直写 VRAM)
              LDX PpuStatus_2002  ; 读 $2002 复位 PPU 地址锁存器

              LDA #$20        ;
              STA PpuAddr_2006 ; 写 VRAM 目标地址高字节
              LDA #$73        ;
              STA PpuAddr_2006 ; 写低字节 → $2073(状态栏顶部区域)

              LDA $0314       ; 取出关卡数字(1~8)
              STA PpuData_2007 ; 写入第一个 tile
              LDA #$28        ;
              STA PpuData_2007 ; 写入分隔符 tile(CHR 中的 "-" 字形)

              NOP             ; ← 补丁抹掉(原本应是 STA PpuData_2007,
              NOP             ;   多写一个 tile,例如前导的 "W" 字符,
              NOP             ;   现在被砍掉,HUD 少显示一位)

              LDA $0316       ; 取出世界数字(1~4)
              STA PpuData_2007 ; 写入最后一个 tile

              PLA             ;
              STA $0316       ; 还原 VRAM_Buffer1+2
              PLA             ;
              STA $0314       ; 还原 VRAM_Buffer1+0

Exit:         PLA             ; 取回最初保存的 PpuMask_2001 值
              STA PpuMask_2001 ; 按调用方原本的意图恢复渲染
              RTS

一句话总结

盗版超玛比正版多了一段"选小关"的逻辑,而当年写这段补丁的程序员在挑选代码存放位置时,不偏不倚地砸在了存放音乐数据的那段内存上。结果就是:APU 在播放音乐时,把这段程序代码当成了乐谱,于是便有了那段让无数人童年难忘的诡异关底音乐。