模拟 YM2612:第一部分 - 接口
模拟 YM2612:第一部分 - 接口
2025.3.24 2025.3.25 模拟 3301 16 mins
目录
- 主要来源
- 四算子 FM 合成
- 时钟
- 接口 1. 写端口 2. 读端口 3. 读端口镜像:离散 YM2612 vs. YM3438
- 首个音频输出:DAC 通道
- 未完待续
这是关于模拟世嘉 Genesis 主声音芯片 Yamaha YM2612 FM 合成芯片(也称为 OPN2)系列文章的第一篇。
到目前为止,YM2612 绝对是我所接触过的最难模拟的声音芯片。它的概念并不极其复杂,但在它的具体工作方式中,存在着大量特定的细节和怪癖,并且需要精确地模拟其中许多细节,才能使游戏音频听起来正确。由于所有的调制和反馈,调试错误也非常非常困难,例如,包络模拟中的一个小错误可能会表现为某些乐器听起来完全错误。
这些文章不会描述如何实现周期精确的 YM2612 模拟器(我的不是),但我会尽力从一个首次使用现代文档和资源模拟它的人的角度,描述该芯片如何在低级别工作。
在编写这些文章时,我发现了我自己实现中的一些小错误和疏忽,因此,如果没有其他作用,编写它们对解决这些问题很有用!
第一篇文章将主要介绍 YM2612 如何集成到 Genesis 中,以及 CPU 如何与其接口。
主要来源
首先,我应该声明,我关于 YM2612 的几乎所有信息都来自这个很长的帖子以及从中链接的资源:https://gendev.spritesmind.net/forum/viewtopic.php?t=386
特别是,Nemesis (Exodus 作者) 在该芯片的确切工作方式上发布了大量出色的信息。 我不相信如果没有该帖子中的信息,我就能够拼凑出自己的 YM2612 实现。
请注意,该帖子中许多较早的帖子包含不准确的信息,这些信息在后面的帖子中得到了更正。 作为一个更明显的例子,关于 ADSR 包络生成器如何工作的第一个帖子有一些主要的错误,这些错误在很多页之后才得到纠正。
Mask of Destiny (BlastEm 作者) 发布了一个帖子,其中链接到该帖子中一些最有用的页面,以及许多其他通常有用的资源,供任何编写 Genesis 模拟器的人使用:https://gendev.spritesmind.net/forum/viewtopic.php?f=2&t=2227
一个链接的来源是 YM2608 芯片的翻译手册,它与 YM2612 非常相关。 这是一个特别有用的来源,但请注意,YM2608 与 YM2612 并不 完全 相同。 我会尽量在相关的地方指出一些最大的差异。
世嘉官方关于 YM2612 的 Genesis 文档几乎毫无用处。 存在许多重大不准确之处,并且省略了许多重要信息。
四算子 FM 合成
我曾尝试在之前一篇关于 Famicom 的 Konami VRC7 mapper 的文章的某个部分中,非常概括地概述 Yamaha FM 合成的工作原理。 YM2612 在概念上类似,但细节与 VRC7 和其他 OPL 芯片有很大不同。
虽然这些芯片被称为“FM”(调频),但硬件实现实际上是相位调制:一些正弦波发生器用于动态调整其他正弦波发生器的相位。 这对于 VRC7 和 YM2612 都是正确的。
YM2612 具有 6 个音频通道,每个通道有 4 个称为算子的正弦波发生器。 拥有 4 个算子是与 OPL2 和 OPLL/VRC7 的 2 算子 FM 合成相比的非常重要的变化,它使芯片能够产生更大种类的声音。
一个通道的 4 个算子可以排列成 8 种不同的配置之一,称为“算法”。 该算法确定任何特定算子是载波(直接贡献于通道输出)还是调制器(对另一个算子进行相位调制)。 调制器可以根据算法对多个其他算子进行相位调制,但是没有算法可以使任何算子同时作为调制器和载波。
每个通道的算子 1(从 1 开始计数)是独一无二的,因为它支持自反馈,就像 OPLL/VRC7 的调制器一样。 这意味着它可以选择性地使用其最后两个算子输出的总和来对 自身 进行相位调制。
前 4 个算法选项使用前 3 个算子作为调制器,而第 4 个算子作为载波。 后 4 个算法选项中的每一个都有多个载波,最后一个算法使所有 4 个算子都成为载波。 对于具有多个载波的算法,通道输出是所有载波输出的总和。
YM2612/YM2608 算法,来自 YM2608 手册
该芯片还包括许多超出 FM 合成算子本身的功能:一个低频振荡器 (LFO),用于驱动颤音和震音效果、两个供软件使用的硬件定时器,以及一个 DAC 通道,如果启用,则输出原始 8 位 PCM 样本。
YM2608 手册提到了对 ADPCM 样本回放和一些“节奏”功能的支持,但这些功能都不存在于 YM2612 中。
首先,让我们介绍一下 YM2612 如何集成到 Genesis 中。
时钟
Genesis 使用与驱动 68000 CPU 完全相同的时钟信号驱动 YM2612,大约为 7.67 MHz 时钟 (NTSC)。 确切的频率对于 NTSC 游戏机为 53693175 Hz / 7,对于 PAL 游戏机为 53203424 Hz / 7,这些 8 位数字分别是 Genesis 主时钟频率。
YM2612 在内部将其主时钟除以 6,从而导致在 NTSC Genesis 中有效时钟速率为 ~7.67 MHz / 6 = ~1.28 MHz。
在模拟器中,重要的是 YM2612 的主时钟是 68000 CPU 时钟,因此它应该每 6 个 68000 CPU 时钟周期获得 1 个内部 YM2612 周期。 如果您正在跟踪 Genesis 主时钟周期的时序,则 YM2612 应每 42 个 mclk 周期获得 1 个内部周期。
YM2612 每 24 个内部时钟周期生成一个完整的输出样本……某种程度上。 在实际硬件中,它以每 4 个周期 1 个通道的速率重复循环通过其 6 个通道,并通过其 DAC 多路复用通道输出,类似于 Famicom Namco 163 扩展音频芯片 (尽管没有因该芯片的低循环速率而引起的音频混叠)。 模拟器通常不模拟这种多路复用 - 它们通常混合通道而不是多路复用它们,并且它们每 24 个时钟周期生成一个混合样本。
这导致 NTSC 的有效采样率为约 53267 Hz (53693175 Hz / 7 / 6 / 24),PAL 的采样率略低。
YM2612 内部的几乎所有内容都以采样时钟速率或采样时钟速率的某个除数进行更新,但是不同的组件在整个 24 周期采样期间的不同时间更新。 模拟器通常不模拟这种非常精确的时序 - 它们通常每 24 个内部周期一次性更新所有组件。
接口
Genesis 具有一个分离的总线,68000 CPU 在一侧,Z80 CPU 在另一侧。 它有一个总线仲裁器,用于管理从一侧到另一侧的访问,以及允许 68000 通过设置其 BUSREQ 和 RESET 线来控制 Z80。 BUSREQ 从总线上移除 Z80,以便 68000 可以自由访问总线 Z80 侧的硬件。
Z80 可以随时访问总线的 68000 侧,但是每次访问都会给两个 CPU 引入可变延迟,因为总线仲裁器会插入等待状态以避免总线冲突。
Z80 旨在成为专用的音频处理器。 为了支持这一点,YM2612 位于总线的 Z80 侧,以便 Z80 可以访问它,而无需跨越到 68000 侧并承受来自总线仲裁器的难以预测的延迟。
(有趣的是,SN76489 PSG 芯片位于总线的 68000 侧,但是与驱动 YM2612 的 DAC 通道相比,SN76489 交互通常对时序的敏感度 显着 降低。)
只要 68000 首先通过使用总线仲裁器设置其 BUSREQ 线来从总线中移除 Z80,68000 就可以通过其在 $A00000-$A0FFFF 的 Z80 存储器映射窗口访问 YM2612。 一些游戏主要使用 68000 驱动音频,例如 Sonic 1 - 它实际上仅使用 Z80 通过 YM2612 的 DAC 通道播放样本。 Sonic 1 使用 68000 控制 YM2612 FM 合成通道和 PSG。
实际上,Sonic 1 几乎可以在完全不模拟 Z80 的情况下完全可玩! 您需要在 $A11100 和 $A11200 处模拟总线仲裁器寄存器,并且您不会获得它通过 YM2612 的 DAC 通道播放的任何音频样本,但是您会从 FM 合成通道和 PSG 获得音频输出。
没有 Z80 的 Green Hill Zone
许多 Genesis 游戏都可以在不模拟 Z80 的情况下播放(这不像 SNES 具有极其有限的 65816/SPC700 通信接口),但是音频完全丢失而没有 Z80 模拟非常常见。 Sonic 1 在多大程度上从 68000 驱动音频有点反常。
写端口
YM2612 有四个 8 位写端口,映射到 Z80 存储器映射中的 $4000-$4003。 这些端口在整个地址范围 $4000-$5FFF 中重复镜像。
根据官方文档,这些是:
- $4000:地址端口,组 1(通道 1-3 + 全局寄存器)
- $4001:数据端口,组 1
- $4002:地址端口,组 2(通道 4-6)
- $4003:数据端口,组 2
当软件想要写入通道 1-3 寄存器或全局寄存器时,它首先将 8 位寄存器地址写入 $4000,然后将 8 位寄存器值写入 $4001。 对于通道 4-6 寄存器,它将寄存器地址写入 $4002,然后将寄存器值写入 $4003。
……但这实际上并不是它的工作方式。 实际上,该芯片只有一个数据端口,该端口映射到 $4001 和 $4003:
- $4000:地址端口 + 设置组 1 标志
- $4002:地址端口 + 设置组 2 标志
- $4001 / $4003:数据端口
该芯片会记住上次写入的是 $4000 还是 $4002,并且数据端口写入将基于上次写入的地址端口转到组 1 或组 2。 Titan 的 Overdrive 2 demo 依赖于此,因为它通过 $4001 执行所有数据端口写入,但我不知道是否有任何游戏依赖于此行为。
查看 8 位 YM2612 寄存器地址,它们都分为三个地址范围:
- $20-$2F:全局寄存器
- $30-$9F:算子控制寄存器
- $A0-$BF:通道控制寄存器
对于 $30-$9F 寄存器和 $A0-$BF 寄存器,寄存器地址的最低 2 位用作通道索引。 例如:
示例寄存器地址 | 组 1 通道 | 组 2 通道 ---|---|--- $30, $34, $A0, $A4 | 1 | 4 $31, $35, $A1, $A5 | 2 | 5 $32, $36, $A2, $A6 | 3 | 6 $33, $37, $A3, $A7 | 无 | 无
对于 $30-$9F 寄存器,位 2 和 3 用作算子索引,但位交换了! 具体来说:
- 00 = 算子 1
- 01 = 算子 3
- 10 = 算子 2
- 11 = 算子 4
YM2608 手册正确地记录了此位交换。 世嘉官方关于 YM2612 的 Genesis 文档在这方面是错误的。
例子:
示例寄存器地址 | 算子 ---|--- $30, $31, $32, $40, $41, $42 | 1 $34, $35, $36, $44, $45, $46 | 3 $38, $39, $3A, $48, $49, $4A | 2 $3C, $3D, $3E, $4C, $4D, $4E | 4 您可以像这样从地址中解析出这些:
1
2
3
4
5
6
7
8
9
10
| ``` letmutchannel_idx=register_addr&3;ifchannel_idx==3{// Invalid return;}ifgroup==Group::Two{channel_idx+=3;}letoperator_idx=((register_addr>>3)&1)|((register_addr>>1)&2);
---|---
复制
### [读端口](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/<#contents:read-port>)
YM2612 有一个读端口,该端口映射到 Z80 存储器映射中的 $4000。 它包含 3 个有意义的位:
* 位 7:忙标志
* 位 1:定时器 B 溢出标志
* 位 0:定时器 A 溢出标志
就是这样。 其他 5 位未定义,并且 YM2612 不通过读取公开任何其他信息。
最常用的是忙标志。 它指示 YM2612 当前是否正在处理寄存器写入,在这种情况下,软件应等待执行任何其他 YM2612 寄存器写入。 当游戏需要写入多个寄存器时,它们通常会有一个循环,在该循环中,它们写入第一个寄存器,轮询忙标志直到它为 0,写入下一个寄存器,轮询忙标志直到它为 0,并重复直到所有寄存器都已写入。
这两个定时器溢出位是软件从两个 YM2612 定时器获取反馈的唯一方法,因为它们未连接到任何 Z80 的中断线。 使用定时器的软件需要轮询这些位以了解何时经过了定时器间隔。
在不同的型号游戏机之间,忙标志行为似乎并不完全一致,并且可能还取决于寄存器写入在芯片的通道和算子之间的内部循环中发生的准确时间。 在数据端口写入之后,我将忙标志 1 保留 32 个内部 YM2612 周期,并且它似乎可以与我测试过的所有内容正常工作。 ([该数字的来源](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/<https:/gendev.spritesmind.net/forum/viewtopic.php?p=31026#p31026>))
至少在较早的游戏机中,忙标志读取为 1 的时间比 YM2612 处理写入所需的时间长得多,它始终会在 24 个 YM2612 周期内完成写入,有时会在更少的周期内完成写入 - 这取决于写入的寄存器。 这使得忙标志不如简单地在写入之间计数 Z80 周期有用,尽管在 80 年代和 90 年代,大多数开发人员可能不知道这一点。
### [读端口镜像:离散 YM2612 vs. YM3438](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/<#contents:read-port-mirroring-discrete-ym2612-vs-ym3438>)
读端口在 $4001-$4003 处镜像……在某些游戏机上。 至少有两个游戏对读端口镜像行为高度敏感:Earthworm Jim 和 Hellfire。
此行为差异基于游戏机是否包含离散 YM2612 芯片或 [YM3438](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/<https:/en.wikipedia.org/wiki/Yamaha_YM2612#Yamaha_YM3438>),它是 YM2612 的略微修改的 CMOS 版本。
对于 YM3438,$4001-$4003 镜像 $4000。 从任何这些地址读取都会返回忙标志和定时器溢出位。 该芯片用于 Model 1 VA7、Model 2 VA0-VA1 和 Model 3 游戏机。
对于离散 YM2612,从 $4001-$4003 读取是正式未定义的。 似乎在实际硬件上发生的事情是,从 $4001-$4003 读取会返回上次从 $4000 读取的值,但自上次 $4000 读取以来经过一定时间后,它会衰减为 0。 该芯片用于 Model 1 VA0-VA6 和 Model 2 VA2 游戏机。
Earthworm Jim 偶尔会从 $4002 而不是 $4000 读取忙标志,并且在具有离散 YM2612 的型号上,这会导致非常明显的音频卡顿。 发生这种情况是因为先前的 $4000 读取有时只是为了轮询其中一个定时器溢出位,并且如果在该读取期间设置了忙标志,则游戏的音频驱动程序将循环轮询 $4002,直到状态值衰减为 0。
Earthworm Jim - 离散 YM2612 行为 Earthworm Jim - YM3438 行为
离散 YM2612 录音使用了大约四分之一秒的周期的衰减周期。 这产生了与在具有离散 YM2612 的游戏机上录制此游戏的硬件录音相似的结果。
Hellfire 有相反的问题:它经常从 $4001 和 $4003 而不是 $4000 读取忙标志,并且如果它可以从这些地址实际读取忙标志,则音乐将比预期播放 _慢得多_。 这意味着音乐仅在具有离散 YM2612 的游戏机上才能正确播放。
Hellfire - 离散 YM2612 行为 Hellfire - YM3438 行为
游戏碰巧以这样一种方式写入 YM2612 寄存器,即使在实际硬件上,也不会丢弃任何写入,即使它没有正确读取忙标志。
如何在模拟器中处理此问题是一个实现决策。 使 $4001-$4003 读取始终返回 0 将使 Earthworm Jim 和 Hellfire 的声音都正确,但这不准确于任何实际硬件,并且可能会破坏其他游戏。 始终使用离散 YM2612 行为会破坏 Earthworm Jim,而始终使用 YM3438 行为会破坏 Hellfire。
最合理的解决方案可能是提供一个选项来模拟什么行为,也许带有一些类似于自动检测选项的东西,该选项会自动使用这两个游戏的理想行为。 其他游戏通常仅尝试在 $4000 处访问读端口。
这两种行为都准确于实际硬件 - 它们只是准确于硬件的不同版本。
## [首个音频输出:DAC 通道](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/<#contents:first-audio-output-dac-channel>)
DAC 通道是 YM2612 内部最容易模拟的东西,因此让我们从它开始。
DAC 通道完全由 3 个寄存器控制:
* $2A:DAC 通道 PCM 样本(无符号 8 位)
* $2B:DAC 通道已启用(位 7)
* $B6:通道 3(组 1)和 6(组 2)的 L/R 声像标志和 LFO 灵敏度
通过寄存器 $2B 启用后,DAC 通道会将通道 6 的输出替换为上次写入寄存器 $2A 的 8 位 PCM 样本值。 它尊重寄存器 $B6(组 2)中的通道 6 L/R 声像标志,但通道 6 配置不会影响 DAC 通道。
PCM 样本被解释为无符号 8 位值 (0-255),但对于输出,它们通过应用 -128 的偏差转换为有符号 8 位。 然后对该有符号 8 位样本进行位移,以匹配 FM 合成通道输出的比例。
在稍后之前,这并不是真正相关的,但 FM 通道输出是有符号的 14 位,因此 DAC 通道实现非常简单:
1 2 3
| ```
fn dac_channel(sample: u8)-> i16 {(i16::from(sample)-128)<<6}
---|---
复制
YM2612 的 DAC 仅具有 9 位数字输入,但无法使用 DAC 通道设置截断的较低 5 位,因此现在这并不重要。
对于生成输出样本,您可以假装您正在混合 6 个通道,它们的输出都在 i14 比例上,但现在仅模拟 1 个通道:
1
2
3
4
5
6
7
| ``` fn output_sample(dac_channel_out: i16)-> f64 {// Convert from i14 scale to [-1, +1) letsample=f64::from(dac_channel_out)/f64::from(1<<13);// Divide by 6 because this is only 1 of 6 channels being mixed sample/6.0}
---|---
复制
您也可以将通道输出累积到 i32 中,而不是转换为浮点数,但是您可能最终想要转换为浮点数,以确保音量正确缩放。
将其连接到音频输出,并且只要您的模拟 Z80 时序相当准确,这足以(从技术上讲)在游戏中获得一些 YM2612 音频! 包括 Sonic 游戏中的标志性“SEGA”介绍声音:
刺猬索尼克 2 - SEGA 声音 刺猬索尼克 2 - Emerald Hill Zone(打击乐)
这是一个开始!
DAC 通道输出听起来往往非常刺耳和嘈杂。 这部分是因为没有 FIFO 或任何东西可以缓冲传入的样本 - 游戏必须使用非常精确的定时代码以所需的采样率不断将样本发送到 YM2612。
以高采样率播放不会给 Z80 留下太多时间来执行其他操作,此外,如果 Z80 需要访问总线的 68000 侧(例如,从卡带 ROM 读取),则时序会被总线仲裁器延迟所干扰。 此外,68000 需要从总线中移除 Z80 才能安全地从控制器端口读取,这几乎每个游戏每帧至少执行一次。
芯片有效地最近邻重采样到高达 53267 Hz 会进一步降低音频质量。 这引入了许多额外的音频混叠和噪音,尤其是在源数据以非常低的采样率的情况下。
## [未完待续](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/<#contents:to-be-continued>)
下一篇文章将介绍一个可能更有趣的主题:FM 合成通道中的相位发生器如何工作。
[第 2 部分 - 相位](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/</blog/posts/emulating-ym2612-part-2/>)
* 作者:[jsgroth](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/<https:/jsgroth.dev/>)
* 链接:[https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/</blog/posts/emulating-ym2612-part-1/>)
* 许可证:[CC BY-NC-SA 4.0](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/<https:/creativecommons.org/licenses/by-nc-sa/4.0/deed.en>)
updatedupdated2025-03-252025-03-25
## 也可以看看:
* [模拟 YM2612:第 3 部分 - 包络](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/</blog/posts/emulating-ym2612-part-3/>)
* [模拟 YM2612:第 2 部分 - 相位](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/</blog/posts/emulating-ym2612-part-2/>)
* [Sega CD PCM 芯片插值](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/</blog/posts/sega-cd-pcm-interpolation/>)
* [Genesis & Sega CD - 音频过滤](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/</blog/posts/genesis-sega-cd-audio-filtering/>)
* [Sega CD PCM 芯片 - 概述](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/</blog/posts/sega-cd-pcm-overview/>)
[Genesis](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/</blog/tags/genesis/>) [Mega Drive](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/</blog/tags/mega-drive/>)
* [< 模拟 YM2612:第 2 部分 - 相位](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/</blog/posts/emulating-ym2612-part-2/>)
* [Famicom 扩展音频 >](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/</blog/posts/famicom-expansion-audio/>)
© 2025 jsgroth
由 [Hugo](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/<https:/github.com/gohugoio/hugo>) 提供支持 | 主题是 [MemE](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/<https:/github.com/reuixiy/hugo-theme-meme>)
[CC BY-NC-SA 4.0](https://jsgroth.dev/blog/posts/emulating-ym2612-part-1/<https:/creativecommons.org/licenses/by-nc-sa/4.0/deed.en>)