Giter Club home page Giter Club logo

blogfm's People

Contributors

dustpg avatar

Stargazers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

Watchers

 avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar  avatar

blogfm's Issues

Re: 从零开始的红白机模拟 - [06]基础指令

STEP3: CPU 指令实现 - 基础指令

这节就详细谈谈基础指令, 所谓'基础指令'只是自己随便命名的, 避免一节过长, 请勿对号入座.

指令周期

不同指令需要消耗不同的周期, 这很好理解. 不过就算相同的指令环境不同也会消耗不同周期:

  • 页面边界交叉(Page Boundary Crossed)
    • 页面边界交叉是指6502将内存划分为256个页面(8位机但是拥有16位地址空间).
    • 当访问不同页面时, 需要额外的指令周期去读取.
  • 跳转到不同页面(Branch Occurs Different Page)
    • 意思和上面的差不多
  • 这两个会在这一步的最后一节详细谈谈

LDA - Load "A"

寻址模式 汇编格式 OP代码 指令字节 指令周期
立即 LDA #Oper A9 2 2
零页 LDA Oper A5 2 3
零页,X LDA Oper,X B5 2 4
绝对 LDA Oper AD 3 4
绝对,X LDA Oper,X BD 3 4*
绝对,Y LDA Oper,Y B9 3 4*
(间接,X) LDA (Oper,X) A1 2 6
(间接),Y LDA (Oper),Y B1 2 5*

* 在页面边界交叉时 +1s

由存储器取数送入累加器A, 影响FLAG: Z(ero),S(ign), 伪C代码:

A = READ(address);
CHECK_ZSFLAG(A);

LDX - Load 'X'

寻址模式 汇编格式 OP代码 指令字节 指令周期
立即 LDX #Oper A2 2 2
零页 LDX Oper A6 2 3
零页, Y LDX Oper,Y B6 2 4
绝对 LDX Oper AE 3 4
绝对, Y LDX Oper,Y BE 3 4*

* 在页面边界交叉时 +1s

由存储器取数送入变址寄存器X, 影响FLAG: Z(ero),S(ign), 伪C代码:

X = READ(address);
CHECK_ZSFLAG(X);

LDY - Load 'Y'

寻址模式 汇编格式 OP代码 指令字节 指令周期
立即 LDY #Oper A0 2 2
零页 LDY Oper A4 2 3
零页,X LDY Oper,X B4 2 4
绝对 LDY Oper AC 3 4
绝对,X LDY Oper,X BC 3 4*

* 在页面边界交叉时 +1s

由存储器取数送入变址寄存器Y, 影响FLAG: Z(ero),S(ign), 伪C代码:

Y = READ(address);
CHECK_ZSFLAG(Y);

STA - Store 'A'

寻址模式 汇编格式 OP代码 指令字节 指令周期
零页 STA Oper 85 2 3
零页,X STA Oper,X 95 2 4
绝对 STA Oper 80 3 4
绝对,X STA Oper,X 90 3 5
绝对,Y STA Oper, Y 99 3 5
(间接,X) STA (Oper,X) 81 2 6
(间接),Y STA (Oper),Y 91 2 6

将累加器A的数送入存储器, 影响FLAG:(无), 伪C代码:

WRTIE(address, A);

STX - Store 'X'

寻址模式 汇编格式 OP代码 指令字节 指令周期
零页 STX Oper 86 2 3
零页,Y STX Oper,Y 96 2 4
绝对 STX Oper 8E 3 4

将变址寄存器X的数送入存储器, 影响FLAG:(无), 伪C代码:

WRTIE(address, X);

STY - Store 'Y'

寻址模式 汇编格式 OP代码 指令字节 指令周期
零页 STY Oper 84 2 3
零页,X STY Oper,X 94 2 4
绝对 STY Oper 8C 3 4

将变址寄存器Y的数送入存储器, 影响FLAG:(无), 伪C代码:

WRTIE(address, Y);

ADC - Add with Carry

助记符号 A = A + M +C

寻址模式 汇编格式 OP代码 指令字节 指令周期
立即 ADC #Oper 69 2 2
零页 ADC Oper 65 2 3
零页,X ADC Oper,X 75 2 4
绝对 ADC Oper 60 3 4
绝对,X ADC Oper,X 70 3 4*
绝对,Y ADC Oper,Y 79 3 4*
(间接,X) ADC (Oper,X) 61 2 6
(间接),Y ADC (Oper),Y 71 2 5*

* 在页面边界交叉时 +1s

累加器,存储器,进位标志C相加,结果送累加器A.
影响FLAG: S(ign), Z(ero), C(arry), (o)V(erflow), 伪C代码:

src = READ(address);
uint16_t result16 = A + src + (CF ? 1 : 0);
CHECK_CFLAG(result16>>8);
uint8_t result8 = result16;
CHECK_VFLAG(!((A ^ src) & 0x80) && ((A ^ result8) & 0x80));
A = result8;
CHECK_ZSFLAG(A);

SBC - Subtract with Carry

助记符号 A = A - M - (1-C)

寻址模式 汇编格式 OP代码 指令字节 指令周期
立即 SBC #Oper E9 2 2
零页 SBC Oper E5 2 3
零页,X SBC Oper,X F5 2 4
绝对 SBC Oper ED 3 4
绝对,X SBC Oper,X FD 3 4*
绝对,Y SBC Oper,Y F9 3 4*
(间接,X) SBC (Oper,X) E1 2 6
(间接),Y SBC (Oper),Y F1 2 5

从累加器减去存储器和进位标志C,结果送累加器A.
影响FLAG: S(ign), Z(ero), C(arry), (o)V(erflow), 伪C代码:

src = READ(address);
uint16_t result16 = A - src - (CF ? 0 : 1);
CHECK_CFLAG(!(result16>>8));
uint8_t result8 = result16;
CHECK_VFLAG(((A ^ result8) & 0x80) && ((A ^ src) & 0x80));
A = result8;
CHECK_ZSFLAG(A);

INC - Increment memory

寻址模式 汇编格式 OP代码 指令字节 指令周期
零页 INC Oper E6 2 5
零页,X INC Oper,X F6 2 6
绝对 INC Oper EE 3 6
绝对,X INC Oper,X FE 3 7

存储器单元内容+1, 影响FLAG:Z(ero),S(ign), 伪C代码:

tmp = READ(address);
++tmp;
WRITE(address, tmp);
CHECK_ZSFLAG(tmp);

DEC - Decrement memory

寻址模式 汇编格式 OP代码 指令字节 指令周期
零页 DEC Oper C6 2 5
零页,X DEC Oper,X D6 2 6
绝对 DEC Oper CE 3 6
绝对,X DEC Oper,X DE 3 7

存储器单元内容-1, 影响FLAG:Z(ero),S(ign), 伪C代码:

tmp = READ(address);
--tmp;
WRITE(address, tmp);
CHECK_ZSFLAG(tmp);

AND - 'And' memory with A

寻址模式 汇编格式 OP代码 指令字节 指令周期
立即 AND #Oper 29 2 2
零页 AND Oper 25 2 3
零页,X AND Oper,X 35 2 4
绝对 AND Oper 2D 3 4
绝对,X AND Oper,X 3D 3 4*
绝对,Y AND Oper,Y 39 3 4*
(间接,X) AND (Oper,X) 21 2 6
(间接),Y AND (Oper),Y 31 2 5

* 在页面边界交叉时 +1s

存储器单元与累加器做与运算, 影响FLAG:Z(ero),S(ign), 伪C代码:

A &= READ(address);
CHECK_ZSFLAG(A);

ORA - 'Or' memory with A

寻址模式 汇编格式 OP代码 指令字节 指令周期
立即 ORA #Oper 09 2 2
零页 ORA Oper 05 2 3
零页,X ORA Oper,X 15 2 4
绝对 ORA Oper 0D 3 4
绝对,X ORA Oper,X 10 3 4*
绝对,Y ORA Oper,Y 19 3 4*
(间接,X) ORA (Oper,X) 01 2 6
(间接),Y ORA (Oper),Y 11 2 5

* 在页面边界交叉时 +1s

存储器单元与累加器做或运算, 影响FLAG:Z(ero),S(ign), 伪C代码:

A |= READ(address);
CHECK_ZSFLAG(A);

EOR - "Exclusive-Or" memory with A

寻址模式 汇编格式 OP代码 指令字节 指令周期
立即 EOR #Oper 49 2 2
零页 EOR Oper 45 2 3
零页,X EOR Oper,X 55 2 4
绝对 EOR Oper 40 3 4
绝对,X EOR Oper,X 50 3 4*
绝对,Y EOR Oper,Y 59 3 4*
(间接,X) EOR (Oper,X) 41 2 6
(间接),Y EOR (Oper),Y 51 2 5*

* 在页面边界交叉时 +1s

存储器单元与累加器做异或运算, 影响FLAG:Z(ero),S(ign), 伪C代码:

A ^= READ(address);
CHECK_ZSFLAG(A);

INX - Increment X

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 INX E8 1 2

变址寄存器X内容+1, 影响FLAG:Z(ero),S(ign), 伪C代码:

++X;
CHECK_ZSFLAG(X);

DEX - Decrement X

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 DEX CA 1 2

变址寄存器X内容-1, 影响FLAG:Z(ero),S(ign), 伪C代码:

--X;
CHECK_ZSFLAG(X);

INY - Increment Y

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 INY C8 1 2

变址寄存器Y内容+1, 影响FLAG:Z(ero),S(ign), 伪C代码:

++Y;
CHECK_ZSFLAG(Y);

DEY - Decrement Y

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 DEY 88 1 2

变址寄存器Y内容-1, 影响FLAG:Z(ero),S(ign), 伪C代码:

--Y;
CHECK_ZSFLAG(Y);

TAX - Transfer A to X

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 TAX AA 1 2

将累加器A的内容送入变址寄存器X, 影响FLAG:Z(ero),S(ign), 伪C代码:

X = A;
CHECK_ZSFLAG(X);

TXA - Transfer X to A

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 TXA 8A 1 2

将变址寄存器X的内容送入累加器A, 影响FLAG:Z(ero),S(ign), 伪C代码:

A = X;
CHECK_ZSFLAG(A);

TAY - Transfer A to Y

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 TAY A8 1 2

将累加器A的内容送入变址寄存器Y, 影响FLAG:Z(ero),S(ign), 伪C代码:

Y = A;
CHECK_ZSFLAG(Y);

TYA - Transfer Y to A

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 TYA 98 1 2

将变址寄存器Y的内容送入累加器A, 影响FLAG:Z(ero),S(ign), 伪C代码:

A = Y;
CHECK_ZSFLAG(A);

TSX - Transfer SP to X

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 TSX BA 1 2

将栈指针SP内容送入变址寄存器X, 影响FLAG:Z(ero),S(ign), 伪C代码:

X = SP;
CHECK_ZSFLAG(X);

TXS - Transfer X to SP

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 TXS 9A 1 2

将变址寄存器X内容送入栈指针SP, 影响FLAG:, 伪C代码:

SP = X;

CLC - Clear Carry

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 CLC 18 1 2

清除进位标志C, 影响FLAG: C(arry), 伪C代码:

CF = 0;

SEC - Set Carry

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 SEC 38 1 2

设置进位标志C, 影响FLAG: C(arry), 伪C代码:

CF = 1;

CLD - Clear Decimal

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 CLD D8 1 2

清除十进制模式标志D, 影响FLAG: D(Decimal), 伪C代码:

DF = 0;

SED - Clear Decimal

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 SED F8 1 2

设置十进制模式标志D, 影响FLAG: D(Decimal), 伪C代码:

DF = 1

CLV - Clear Overflow

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 CLV B8 1 2

清除溢出标志V, 影响FLAG: (o)V(erflow), 伪C代码:

VF = 0;

CLI - Clear Interrupt-disable

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 CLI 58 1 2

清除中断禁止标志I, 影响FLAG: I(nterrupt-disable), 伪C代码:

IF = 0;

SEI - Set Interrupt-disable

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 SEI 78 1 2

设置中断禁止标志I, 影响FLAG: I(nterrupt-disable), 伪C代码:

IF = 1;

CMP - Compare memory with A

寻址模式 汇编格式 OP代码 指令字节 指令周期
立即 CMP #Oper C9 2 2
零页 CMP Oper C5 2 3
零页,X CMP Oper,X D5 2 4
绝对 CMP Oper CD 3 4
绝对,X CMP Oper,X DD 3 4*
绝对,Y CMP Oper,Y D9 3 4*
(间接,X) CMP (Oper,X) C1 2 6
(间接),Y CMP (Oper),Y D1 2 5*

比较储存器值与累加器A.
影响FLAG: C(arry), S(ign), Z(ero). 伪C代码:

uint16_t result16 = (uint16_t)A - (uint16_t)READ(address);
CF = result16 < 0x100;
CHECK_ZSFLAG((uint8_t)result16);

CPX - Compare memory with X

寻址模式 汇编格式 OP代码 指令字节 指令周期
立即 CPX #Oper E0 2 2
零页 CPX Oper E4 2 3
绝对 CPX Oper EC 3 4

比较储存器值与变址寄存器X.
影响FLAG: C(arry), S(ign), Z(ero). 伪C代码:

uint16_t result16 = (uint16_t)X - (uint16_t)READ(address);
CF = result16 < 0x100;
CHECK_ZSFLAG((uint8_t)result16);

CPY - Compare memory with Y

寻址模式 汇编格式 OP代码 指令字节 指令周期
立即 CPY #Oper C0 2 2
零页 CPY Oper C4 2 3
绝对 CPY Oper CC 3 4

比较储存器值与变址寄存器Y.
影响FLAG: C(arry), S(ign), Z(ero). 伪C代码:

uint16_t result16 = (uint16_t)Y - (uint16_t)READ(address);
CF = result16 < 0x100;
CHECK_ZSFLAG((uint8_t)result16);

BIT - Bit test memory with A

寻址模式 汇编格式 OP代码 指令字节 指令周期
零页 BIT Oper 24 2 3
绝对 BIT Oper 2C 3 4

位测试

  • 若 A&M 结果 =0, 那么Z=1
  • 若 A&M 结果!=0, 那么Z=0
  • S = M的第7位
  • V = M的第6位

影响FLAG: (o)V(erflow), S(ign), Z(ero). 伪C代码:

tmp = READ(address);
VF = (tmp >> 6) & 1;
SF = (tmp >> 7) & 1;
ZF = A & tmp ? 0 : 1;

ASL - Arithmetic Shift Left

助记符号: C <- |7|6|5|4|3|2|1|0| <- 0

寻址模式 汇编格式 OP代码 指令字节 指令周期
累加器A ASL A 0A 1 2
零页 ASL Oper 06 2 5
零页,X ASL Oper,X 16 2 6
绝对 ASL Oper 0E 3 6
绝对, X ASL Oper,X 1E 3 7

累加器A, 或者存储器单元算术按位左移一位. 最高位移动到C, 最低位0. 影响FLAG: S(ign) Z(ero) C(arry), 伪C代码:

// ASL A:
CHECK_CFLAG(A>>7);
A <<= 1;
CHECK_ZSFLAG(A);

// 其他情况
tmp = READ(address);
CHECK_CFLAG(tmp>>7);
tmp <<= 1;
WRITE(address, tmp);
CHECK_ZSFLAG(tmp);

LSR - Logical Shift Right

助记符号: 0 -> |7|6|5|4|3|2|1|0| -> C

寻址模式 汇编格式 OP代码 指令字节 指令周期
累加器A LSR A 4A 1 2
零页 LSR Oper 46 2 5
零页,X LSR Oper,X 56 2 6
绝对 LSR Oper 4E 3 6
绝对,X LSR Oper,X 5E 3 7

累加器A, 或者存储器单元逻辑按位右移一位. 最低位回移进C, 最高位变0, 影响FLAG: S(ign) Z(ero) C(arry), 伪C代码:

// LSR A:
CHECK_CFLAG(A & 1);
A >>= 1;
CHECK_ZSFLAG(A);

// 其他情况
tmp = READ(address);
CHECK_CFLAG(tmp & 1);
tmp >>= 1;
WRITE(address, tmp);
CHECK_ZSFLAG(tmp);

ROL - Rotate Left

助记符号: ...|0| <- C <- |7|6|5|4|3|2|1|0| <- C <- |7|...

寻址模式 汇编格式 OP代码 指令字节 指令周期
累加器A ROL A 2A 1 2
零页 ROL Oper 26 2 5
零页,X ROL Oper,X 36 2 6
绝对 ROL Oper 2E 3 6
绝对,X ROL Oper,X 3E 3 7

累加器A, 或者储存器内容 连同C位 按位循环左移一位, 影响FLAG: S(ign) Z(ero) C(arry), 伪C代码:

// A_M 意思到了就行
uint16_t src = A_M;
src <<= 1;
if (CF) src |= 0x1;
CHECK_CFLAG(src > 0xff);
A_M = src;
CHECK_ZSFLAG(A_M);

ROR - Rotate Right

助记符号: ...|0| -> C -> |7|6|5|4|3|2|1|0| -> C -> |7|...

寻址模式 汇编格式 OP代码 指令字节 指令周期
累加器A ROR A 6A 1 2
零页 ROR Oper 66 2 5
零页,X ROR Oper,X 76 2 6
绝对 ROR Oper 6E 3 6
绝对,X ROR Oper,X 7E 3 7

累加器A, 或者储存器内容 连同C位 按位循环左移一位, 影响FLAG: S(ign) Z(ero) C(arry), 伪C代码:

// A_M 意思到了就行
uint16_t src = A_M;
if (CF) src |= 0x100;
CF = src & 1;
src >> 1;
A_M = src;
CHECK_ZSFLAG(A_M);

PHA - Push A

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 PHA 48 1 3

累加器A压入栈顶(栈指针SP-1). 影响FLAG:(无), 伪C代码:

PUSH(A);

PLA - Pull(Pop) A

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 PLA 68 1 4

push的反操作, 在6502汇编称为pull, 不过自己还是习惯称为pop.
6502_cn这篇文档提到没有FLAG修改, 可能是笔误, 其他文档(比如这个)提到会影响S(ign) Z(ero) , 伪C代码:

A = POP();
CHECK_ZSFLAG(A);

PHP - Push Processor-status

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 PHP 08 1 3

将状态FLAG压入栈顶, 影响FLAG:(无), 伪C代码:

PUSH(P | FLAG_B | FLAG_R);

PLP - Pull Processor-status

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 PLP 28 1 4

PHP逆操作, 影响FLAG: (是的), 伪C代码:

p = POP();
// 无视BIT4 BIT5
RF = 1;
BF = 0;

REF

Direct2D 直接与Direct3D 11 进行协同

进行协同

如果要在D3D11 上进行2D绘制, 使用Direct2D将是一个不错的选择,因为D2D就是由D3D实现的,可以实现几乎无缝地交互协同工作.
微软在Direct2D and Direct3D Interoperability Overview中提到了这两种协同:

  1. 直接在现有的交换链/RTV 上绘制, 就像一般正交投影绘制的UI一样
  2. 绘制到纹理上让模型展示出来. 这样可以干的事情就多了. 特别地, 如果不是专门设计的3D展示UI, 用一般2D的UI然后放在一个矩形里面充当一个伪3D UI也是不错的选择. 这也是LongUI的实现目标之一.
    division

直接协同

如同题目所述: 直接协同. 微软在文中是用一种"共享"的办法, 利用D3D 10作为中介让D3D11与D2D协同, 简直太麻烦了. 文中提到

With DirectX 11.1 or later, Direct2D supports interoperability with Direct3D 11 as well.

这里应该指的是版本号而非指特性等级.因为我在Win7的虚拟机(虚拟显卡只支持到10.1)也能够工作. 这里推荐的方法是(使用 Direct2D 1.1, 下面只包括D2D端必要数据)

                      +--------------+          +--------------+
                      |              |    QI    |              |
     +--------------> | ID3D11Device*+----------> IDXGIDevice1 |
     |                |              |          |              |
     |                +--------------+          +--+-----------+
     |                                             |
     |                                             |
     |                                             |      +-----------------+
+----+----+   +--------+     +----------------+    v      |                 |
|  D3D11  |   | D2D1.1 +-----> ID2D1Factory1  +----+----->+   ID2D1Device   |
+---+-----+   +--------+     +----------------+           |                 |
    |                                                     +-+---------------+
    |                                                       |
    |                                                       v
    |         +-------------------------+                  ++-------------------+
    +-------->+   ID3D11Texture2D**     |                  |                    |
              +--------------+----------+                  | ID2D1DeviceContext |
                             |                             |                    |
                             |                             +--------+-----------+
                             | QI  +---------------+                |
                             +---->+ IDXGISurface  +--------------->+
                                   +---------------+                |
                                                                    |
                                                                    v
                                                         +----------+-----------+
                                                         |     ID2D1Bitmap1***  |
                                                         +----------+-----------+
                                                                    |
                                                                    |
                                                                    v
                                              +---------------------+-----------+
                                              |                                 |
                                              | ID2D1DeviceContext::SetTarget   |
                                              |                                 |
                                              +---------------------------------+

*: 创建ID3D11Device: 记得D3D11_CREATE_DEVICE_BGRA_SUPPORT
**: 创建ID3D11Texture2D: 记得MipLevels=1
***: ID2D1DeviceContext::CreateBitmapFromDxgiSurface

这样全局可以共享一个ID2D1DeviceContext, 文本所使用的代码在NoteFL/D3D11/D3D11InteropWithD2D/main.cpp 这里,懒得渲染字体什么的了, 画个圈圈意思一下就行:
d3d

自己常用的C/C++小技巧[6]

自己常用的小技巧

这里列出了自己常用的一些c/c++小技巧, 有些会有不足, 可以简单探讨一下.

模板与源文件

分类: 隐藏实现

大家可能都会有一个情况, 希望模板的实现在源文件中, 头文件中实现感觉有点"不舒服". 这里就探讨几种实现方式.

试想有以下三个文件, "test.h", "test.cpp", "main.cpp":

// test.h
#include <cstdint>
template<typename T> int32_t MyAtoI(const T str[], uint32_t);


// test.cpp
#include "test.h"

template<typename T>
int32_t MyAtoI(const T str[], uint32_t) {
    // 具体实现
    return 0;
}

// main.cpp
#include "test.h"

int main() {
    return MyAtoI("1.0", 3);
}

这样结果如果呢? 当然是链接时错误: 找不到`int MyAtoI(char const*, unsigned int)'的符号. 解决方法自然是让链接器找到这个符号就行.

比如可以在实现的源文件增加一个函数调用:

// test.cpp 实现2

#include "test.h"

template<typename T>
int32_t MyAtoI(const T str[], uint32_t) {
    return 0;
}
void fake_func() {
    MyAtoI("", 0);
    MyAtoI(u"", 0);
    MyAtoI(U"", 0);
}

缺点自然就是必须有啥就得加一个, 'char'还好说就那几个, vector<T>这个就不好弄了, 不过vector<T>这种, 头文件实现更为方便. 这种适合比较长的函数然后模板参数是可以简单预测的.

看到这个是不是想到了什么? 对了模板特化! 这个感觉和模板特化很类似. 同样, 模板特化也能作为一个实现方法:

// test.cpp 实现3
#include "test.h"

template<>
int32_t MyAtoI<char>(const char str[], uint32_t) {
    return 0;
}

template<>
int32_t MyAtoI<char16_t>(const char16_t str[], uint32_t) {
    return 0;
}

template<>
int32_t MyAtoI<char32_t>(const char32_t str[], uint32_t) {
    return 0;
}

这样或许失去了模板的优点, 所以还能这样:

// test.cpp 实现4

#include "test.h"


template<typename T> static inline
int32_t MyAtoI_static(const T str[], uint32_t) {
    return 0;
}

template<>
int32_t MyAtoI<char>(const char str[], uint32_t l) {
    return MyAtoI_static(str, l);
}

template<>
int32_t MyAtoI<char16_t>(const char16_t str[], uint32_t l) {
    return MyAtoI_static(str, l);
}

template<>
int32_t MyAtoI<char32_t>(const char32_t str[], uint32_t l) {
    return MyAtoI_static(str, l);
}

所以, 只要找到了对应的符号, 想怎么弄就怎么弄.

extern 重解释

分类: 小技巧

上面提到符号, 而且说了这么多C++的, 该说一点C的了. 说到C大家都知道没有类型安全一说, 甚至调用函数都不用进行声明. 利用这一点甚至可能手动写函数机器码的实现.

重解释也是非常重要的一个概念, c里面就可以用extern进行简单粗暴的重解释现有数据:

// 源文件1
#include <stdint.h>

void foo(void);

int main(void) {
    foo();
    return 0;
}

// 源文件2
#include <stdio.h>

extern const unsigned char main[];

void foo(void) {
    for (int i = 0; i != 16; ++i) {
        printf("0x%p: 0x%02X\n", main +i, main[i]);
    }
}

这样可以简单输出main函数的机器码实现, 不过似乎标准并没有规定数据区和代码区在同一个地址空间所以是UB.

不过这里是‘extern 重解释’, 重解释的是已有的数据:

// 源文件1
#include <stdio.h>

extern const unsigned char data_01[];

int main(void) {
    for (int i = 0; i != 16; ++i) {
        printf("0x%p: 0x%02X\n", data_01 +i, data_01[i]);
    }
    return 0;
}

// 源文件2
const double data_01[]= { 0.1, 0.2, 0.3, 0.4};

const 癌晚期

分类: 强迫症福音

自从看到'cosnt anywhere'后, 真的是到处放const:

const auto bar = foo.bar();

不过有些get函数是放在参数里面的:

int bar = 0;
foo.get(&bar);

不过c++11引入的匿名表达式真的是功能强大的特性:

const auto bar = [&foo](){
    int bar = 0;
    foo.get(&bar);
    return bar;
}();

Re: 从零开始的红白机模拟 - [25] 2xSaI

Eagle

下面继续讨论一些关于像素风格缩放算法.

Eagle本身是一个比较初级的想法Eagle (idea), 称不上真正的算法, 但是有些后续的算法就是基于Eagle的. Eagle是放大至2倍, 这里称之为eagle2x.

Eagle想得很简单, 放大两倍后, 那个像素旁边原本3个像素颜色一样的话就设定为该颜色, 否则就是最邻的.

first:        |Then
. . . --\ CC  |S T U  --\ 1 2
. C . --/ CC  |V C W  --/ 3 4
. . .         |X Y Z
              | IF V==S==T => 1=S
              | IF T==U==W => 2=U
              | IF V==X==Y => 3=X
              | IF W==Z==Y => 4=Z

同样可以根据同样的**派生出eagle3x, 像素着色器可以简单实现为:

float4 eagle2x(uint2 pos) {
    const uint2 real_pos = pos / 2;
    float4 A, B, C;
    switch ((pos.x & 1) | ((pos.y & 1) << 1))
    {
    case 0:
        // AB
        // C
        A = InputTexture[real_pos - uint2(1, 1)];
        B = InputTexture[real_pos - uint2(0, 1)];
        C = InputTexture[real_pos - uint2(1, 0)];
        break;
    case 1:
        //  AB
        //   C
        A = InputTexture[real_pos - uint2(0, 1)];
        B = InputTexture[real_pos + int2(1, -1)];
        C = InputTexture[real_pos + uint2(1, 0)];
        break;
    case 2:
        // A
        // BC
        A = InputTexture[real_pos - uint2(1, 0)];
        B = InputTexture[real_pos + int2(-1, 1)];
        C = InputTexture[real_pos + uint2(0, 1)];
        break;
    case 3:
        //  A
        // BC
        A = InputTexture[real_pos + uint2(1, 0)];
        B = InputTexture[real_pos + uint2(0, 1)];
        C = InputTexture[real_pos + uint2(1, 1)];
        break;
    }
    return (eq(A, B) && eq(A, C)) ? A : InputTexture[real_pos];
}

来看看效果:

eagle2x

(eagle2x后4倍最邻插值)

eagle2x_vs

(与8倍最邻插值比较)

可以看出处理后很多东西变得粗细不一, 不适合缩放曲线条, 甚至可以看到一些单独的点被去掉了, 这个算法(想法)只能测试用, 没法实际使用.

2×SaI

全称2× Scale and Interpolation engine, 作者Derek Liauw Kie Fa(Kreed)看到Eagle后想到的. 这里称为sai2x. 作者以GPL形式发布, (作者的)是一个完整的实现, 并不能算是算法, 维基提到可以以完全重写的方式避免被GPL病毒传染.

通过阅读源代码, emmm, 上古代码, 还是16bit的颜色, 对于显卡用的float4感觉差了一个世纪.

sai2x有一个重要步骤: 插值, 会生成新的颜色. 作者实现的插值感觉很不错, 值得学习, 不过这里由于是float4直接取平均值.

I|E F|J
G|A B|K   A -\  A0 A1
H|C D|L     -/  A2 A3
M|N O|P

    A0 = A

    IF  A == D AND B != C, THEN
        IF (A == E AND B == L) OR (A == C AND A == F AND B != E AND B == J), THEN
            A1 = A
        ELSE
            A1 = INTERPOLATE(A, B)
        ENDIF
        IF (A == G AND C == O) OR (A == B AND A == H AND G != C AND C == M), THEN
            A2 = A
        ELSE
            A2 = INTERPOLATE(A, C)
        ENDIF
        A3 = A
    ELSIF B == C AND A != D, THEN
        IF (B == F AND A == H) OR (B == E AND B == D AND A != F AND A == I), THEN
            A1 = B
        ELSE
            A1 = INTERPOLATE(A, B)
        ENDIF
        IF (C == H AND A == F) OR (C == G AND C == D AND A != H AND A == I), THEN
            A2 = C
        ELSE
            A2 = INTERPOLATE(A, C)
        ENDIF
        A3 = B
    ELSIF A == D AND B == C, THEN
        IF A == B, THEN
            A1 = A2 = A3 = A
        ELSE
            A1 = INTERPOLATE(A, B)
            A2 = INTERPOLATE(A, C)
            A3 = A3_SP_PROC(A, B, C, D, ...)
        ENDIF
    ELSE
        IF A == C AND A == F AND B != E AND B == J, THEN
            A1 = A
        ELSIF B == E AND B == D AND A != F AND A == I, THEN
            A1 = B
        ELSE
            A1 = INTERPOLATE(A, B)
        ENDIF
        IF A == B AND A == H AND G != C AND C == M, THEN
            A2 = A
        ELSIF C == G AND C == D AND A != H AND A == I, THEN
            A2 = C
        ELSE
            A2 = INTERPOLATE(A, C)
        ENDIF
        A3 = INTERPOLATE(A, B, C, D);
    ENDIF

可以看出作者将图像分为几个情况分别处理, 比如最外面的4个分支分别对应, 这个点可能是'', '/', 'X', 以及其他情况, 导致分支非常多. 中间还有一个作者自己发明的插值公式, 在这简写为A3_SP_PROC. 由于分支实在太多, 自己有可能重写错了.

sai2x

(sai2x后4倍最邻插值)

sai2x_vs

(与8倍最邻插值, 以及2倍线性插值后4倍最邻插值比较)

可以看出因为A0点总是原来值, 所以放大后看起来像是往左上角偏了一个像素. 由于插值, 新加入了中间色, 实际上以100%比例看更合适, 所以像素风不再.

对于圆的处理效果很不错, 单独的点被处理成'星(+)'状, 要知道这是一个2倍的缩放, '+'是三个像素的. 效果最差的是'点状网', 主要是中间和边缘的效果不一致.

SuperEagle

作者同2xSaI的Kreed. 针对Eagle算法做出了改进.

原称呼:
   B1 B2
4  5  6  S2
1  2  3  S1
   A1 A2
改为:
   A B
 C D E F     D -\ D0 D1
 G H I J       -/ D2 D3
   K L

    IF H == E AND D != I, THEN
        D1 = D2 = H
        IF G == H OR E == B, THEN
            D0 = INTERPOLATE(H, H, D)
        ELSE
            D0 = INTERPOLATE(D, E)
        ENDIF

        IF E == F OR H == K, THEN
            D3 = INTERPOLATE(H, H, I)
        ELSE
            D3 = INTERPOLATE(H, I)
        ENDIF
    ELSIF D == I AND H != E, THEN
        D3 = D0 = D

        IF A == D OR F == J, THEN
            D1 = INTERPOLATE(D, D, E)
        ELSE
            D1 = INTERPOLATE(D, E)
        ENDIF

        IF I == L OR C == D, THEN
            D2 = INTERPOLATE(D, D, H)
        ELSE
            D2 = INTERPOLATE(H, I)
        ENDIF
    ELSIF D == I AND H == E, THEN
        D0D1D2D3_SP_POC(A, B, C, D, ...)
    ELSE 
        D3 = D0 = INTERPOLATE(H, E)
        D3 = INTERPOLATE(I, I, I, D3)
        D0 = INTERPOLATE(D, D, D, D0)

        D2 = D1 = INTERPOLATE(D, I)
        D2 = INTERPOLATE(H, H, H, D2)
        D1 = INTERPOLATE(E, E, E, D1)
    ENDIF

有两次插值的地方, float4的话可以直接A*0.75+B*0.25, 也有类似sai2x的地方, 作者自己发明的插值算法, 这里用D0D1D2D3_SP_POC代替.

supereagle2x

(supereagle后4倍最邻插值)

既然是Eagle的'升级版', 就和Eagle比较吧.

supereagle2x_vs

可以看出作者很喜欢插值, 把Eagle不会引入新颜色的特点去掉了. 不过顺带解决了Eagle的BUG, 也就是消失的单独点, 代替的是颜色浅了, 变成25%, 不过也变成4倍. 当然, 也有像素偏移的现象.

Super2xSaI

Super2xSaI, 这里称为supersai2x, 作者同2xSaI的Kreed, 针对2xSaI算法做出了改进(了吗?).

原称呼:
  B0 B1 B2 B3
  4  5  6  S2
  1  2  3  S1
  A0 A1 A2 A2
改为:
    A B C D               
    E F G H     F -\ F0 F1
    I J K L       -/ F2 F3
    M N O P



    IF J == G AND F != K, THEN
        F1 = F3 = J
    ELSIF F == K AND J != G, THEN
        F1 = F3 = F
    ELSIF F == K AND J == G, THEN
        F3 = F1 = F1_SP_PROC(A, B, C, D, ...)
    ELSE
        IF G == K AND K == N AND J != O AND K != M, THEN
            F3 = INTERPOLATE(K, K, K, J)
        ELSIF F == J AND J == O AND N != K AND J != P, THEN
            F3 = INTERPOLATE(J, J, J, K)
        ELSE
            F3 = INTERPOLATE(J, K)
        ENDIF

        IF G == K AND G == B AND F != C AND G != A, THEN
            F1 = INTERPOLATE(G, G, G, F)
        ELSIF (F == J AND F == C AND B != G AND F != D)
            F1 = INTERPOLATE(G, F, F, F)
        ELSE
            F1 = INTERPOLATE(F, G)
        ENDIF
    ENDIF

    ; ELSIF 这里可以看作 OR
    IF J == G AND F != K AND I == J AND J != C, THEN
        F0 = INTERPOLATE(J, F)
    ELSIF E == J AND K == J AND I != F AND J != A, THEN
        F0 = INTERPOLATE(J, F)
    ELSE
        F0 = F
    ENDIF

    ; ELSIF 这里可以看作 OR
    IF F == K AND J != G AND E == F AND F != O, THEN
        F2 = INTERPOLATE(J, F)
    ELSIF F == I AND G == F AND E != J AND F != M, THEN
        F2 = INTERPOLATE(J, F)
    ELSE
        F2 = J
    ENDIF

可能是是性能上的提升吧?

supersai2x

(supersai2x后4倍最邻插值)

可以看出依旧在100%下是最好的. BUG? 这个算法有BUG吧? 感觉像素变成竖线了. 和2xSaI对比一下:

supersai2x_vs

(与2xSaI对比, 均作了4倍最邻插值)

处理得比较好的是非坐标对齐的图像, 处理得不好的是坐标对齐的图像, 可能这是这个算法的偏向吧.

作者Kreed的这一系列大致是对点周围共计16点判断, 然后生成2x2的新数据, 会产生新的像素, 会产生像素偏移.

REF

Re: 从零开始的红白机模拟 - [26] HQx

HQx

进入HQx族了, HQx系列有一个特别的地方: 抗锯齿.

根据维基的介绍, HQx大致分为两个, 原版Maxim Stepin的HQx以及效果稍微差但是省空间的ScaleHQ.

前者, 作者形式发布的代码, 就算出现问题, 我们可以派生出一个LGPL实现分支, 然后直接使用预编译的二进制码即可. 后者, 则是由不同的作者实现, 然后被误以为是原版的HQ.

2xScaleHQ

那就先说说简单版的ScaleHQ吧, 似乎由guest(r)实现的. 根据libretro的文档, 有两个放大率的, 这里称为scalehq2x 和scalehq4x. 2x应该是在public domain, 而4x是GPL.

代码上根本看不出算法, 只有数学运算, 猜测是将像素转换成图形, 然后继续渲染. 不过懒得去研究, 直接简单改写scalehq2x用于支持本框架. 由于抗锯齿的原因, 这个效果在100%下效果最好, 这是效果:

scalehq2x

4xScaleHQ

这是guest(r)在GPL下发布的, 同2xScaleHQ, 只有数学运算:

scalehq4x

ScaleHQ

可以看出, ScaleHQ的优点是: ScaleHQ是专门为Shader编写的算法, 中间几乎没有分支, 全是运算.

缺点非常明显, 折线过渡不自然:

scalehq

图中两个地方理论上应该有像素过渡的三角.

原版HQx

原版Maxim Stepin的HQx(HQ2x, HQ3x, HQ4x)总的来说是依靠查表, 对于一个点周围8点在YUV空间下形成256次可能性, 然后分别处理. 不过还是难不倒大佬们, 移植到了着色器上. 也是以LGPL形式发布的, 建议直接用了, LGPL用二进制文件即可.

实际移植的时候发现图像出现问题, 找了几天终于发现原来是图片载入出现问题了(32bppBGRA vs 32bppPBGRA), 导致LUT的权重读取错了.

hq2x

(HQ2x-100%下效果)

hq3x

(HQ3x-100%下效果)

hq4x

(HQ4x-100%下效果)

hq4x-2xcubic

(HQ4x后再用双三次插值到原比例的8倍)

HQx处理的效果非常讨人喜, 之前ScaleHQ提到的折线转交处也处理得很不错.

hqx

缺点, 除了实现比较困难外, 效果上可能就是把那个笑脸变成相低多边形的圆了. 而实际上, HQx在文字显示上, 非常不错. 不过在游戏中, 由很多颜色组成的实际画面就稍微差了一点(感觉一块一块的).

REF

Re: 从零开始的红白机模拟 - [31] VRC7 吟唱

拉格朗日点

之所以将游戏名称作为小标题, 自然是说到可乐妹的VRC7, 就不得不说'拉格朗日点'了, 因为这是一款唯一使用了VRC7的游戏. 被不少人冠上'FC最强音乐'的帽子.

当然实际上还有一款'兔宝宝历险记2(日版)'也使用了VRC7, 不过'兔宝宝历险记2'没有使用到VRC7的扩展音源(实体卡带比前者小了不少, 可以看作前者使用了VRC7a, 后者使用了VRC7b).

BANK

CPU $8000-$9FFF: 8 KB switchable PRG ROM bank
CPU $A000-$BFFF: 8 KB switchable PRG ROM bank
CPU $C000-$DFFF: 8 KB switchable PRG ROM bank
CPU $E000-$FFFF: 8 KB PRG ROM bank, fixed to the last bank
CHR $0000-$03FF: 1 KB switchable CHR ROM bank
CHR $0400-$07FF: 1 KB switchable CHR ROM bank
CHR $0800-$0BFF: 1 KB switchable CHR ROM bank
CHR $0C00-$0FFF: 1 KB switchable CHR ROM bank
CHR $1000-$13FF: 1 KB switchable CHR ROM bank
CHR $1400-$17FF: 1 KB switchable CHR ROM bank
CHR $1800-$1BFF: 1 KB switchable CHR ROM bank
CHR $1C00-$1FFF: 1 KB switchable CHR ROM bank

感觉都还科学. 变种区别为

  • VRC7b 使用 A3 来选寄存器($x008)
  • VRC7a 使用 A4 来选寄存器($x010)

PRG Select 0 ($8000)

7  bit  0
---------
..PP PPPP
  || ||||
  ++-++++- Select 8 KB PRG ROM at $8000

64*8=512

PRG Select 1 ($8010, $8008)

7  bit  0
---------
..PP PPPP
  || ||||
  ++-++++- Select 8 KB PRG ROM at $A000

PRG Select 2 ($9000)

7  bit  0
---------
..PP PPPP
  || ||||
  ++-++++- Select 8 KB PRG ROM at $C000

CHR Select 0…7 ($A000…$DFFF)

Write to CPU address 1KB CHR bank affected
$A000 $0000-$03FF
$A008 or $A010 $0400-$07FF
$B000 $0800-$0BFF
$B008 or $B010 $0C00-$0FFF
$C000 $1000-$13FF
$C008 or $C010 $1400-$17FF
$D000 $1800-$1BFF
$D008 or $D010 $1C00-$1FFF

Mirroring Control ($E000)

7  bit  0
---------
RS.. ..MM
||     ||
||     ++- Mirroring (0: vertical; 1: horizontal;
||                        2: one-screen, lower bank; 3: one-screen, upper bank)
|+-------- Silence expansion sound if set
+--------- WRAM enable (1: enable WRAM, 0: protect)

IRQ Control ($E008 - $F010)

$E008, $E010:  IRQ Latch
       $F000:  IRQ Control
$F008, $F010:  IRQ Acknowledge

对比起VRC6起来, 简直不知道友好到哪里去! 根据地址线的规律, 可以使用:

    const uint16_t vrc7a = address >> 4;
    const uint16_t vrc7b = address >> 3;
    const uint16_t base = (((address >> 11) & 0xfffe) | ((vrc7a | vrc7b) & 1)) & 0xf;

将分散的数据聚集在一起方便switch编写.

兔宝宝历险记2 模拟出现的问题

bug1

一开始这个东西根本就不能运行, 到处出错. 一步一步反汇编后发现, 其实是IRQ的实现有问题. CLI后不久, 强行触发中断导致程序乱跑.

根本原因还是IRQ实现有问题(废话), APU禁用IRQ后依然触发了已经挂起的IRQ. 目前先将挂起的IRQ清除掉, 以后好好研究一下IRQ.

VRC7 扩展音源

VRC7拥有6个FM合成音源声道, 实现了Yamaha YM2413 OPLL的一个功能子集(阉割版). 似乎叫做'Yamaha DS1001', 下面为了方便描述, VRC7与'Yamaha DS1001'在描述上等价.

前面有一个寄存器Mirroring Control ($E000), 其中Silence位为1的话, 会让VRC7部分静音并清空相关数据状态.

Audio Register Select ($9010)

7......0
VVVVVVVV
++++++++- The 8-bit internal register to select for use with $9030

写入后, 程序不得在6个CPU周期(N制, 实际是12个内部周期)内写入$9030以及$9010, VRC7内部处理需要一点时间.

Audio Register Write ($9030)

7......0
VVVVVVVV
++++++++- The 8-bit value to write to the internal register selected with $9010

写入后, 程序不得在42个CPU周期(N制, 实际是84个内部周期)内写入$9030以及$9010, VRC7内部处理需要一点时间.

42周期可是比三分之一扫描行还多! 不过, 一般地, 作为模拟器不必担心.

内部寄存器

虽然$9010表明几乎有256个寄存器, 似乎内部只有26个:

  • $00-$07自定义Patch
  • $10-$15, $20-25, $30-35分别控制拥有的6个FM声道
  • 至于合成器中Patch的概念, 简单可以理解为音色的一种描述

频率调制

FM是使高频振荡波的频率按调制信号规律变化的一种调制方式. 采用不同调制波频率和调制指数, 就可以方便地合成具有不同频谱分布的波形, 再现某些乐器的音色.

翻开(已经不存在的)大学教材, 回想起被傅里叶老人家支配的痛苦, 不由得感慨万分, 于是合上(已经不存在的)这本教材.

频率调制(Frequency modulation)FM, 现在似乎变成了电台的代名词了. 简单来说, 这里就是通过调制振荡器(Modulator)与载波振荡器(Carrier)进行合成, 即双算子-FM.

在音乐合成时, 双算子-FM可以采用:


F(t) = A sin(ωt + I sin ωt )
             c          m


     Modulator              Carrier
     调制振荡器             载波振荡器
  +-----------------+  +-----------------+
  |                 |  |                 |
  | +-+    +------+ |  | +-+    +------+ |
+-->+X+--->+ Sin  +------+X+--->+  Sin +---> 
  | +++    +--+---+ |  | +++    +---+--+ |
  |  ^        ^     |  |  ^         ^    |
  |  |        | I   |  |  |         | A  |
  |  |        |     |  |  |         |    |
  | -|-    +--+---+ |  | -|-    +---+--+ |
  | P|G    |  EG  | |  | P|G    |  EG  | |
  | -|-    +------+ |  | -|-    +------+ |
  |  |              |  |  |              |
  +--|--------------+  +--|--------------+
     |                    |
     +                    +
     ωm                   ωc

EG: 包络发生器 Envelope Generator
PG: 相位发生器 Phase    Generator

ADSR包络提供一个比较自然的A(t), I(t)函数:


   ON            OFF
    ---------------
---|              |-----------
                   
       /\          
      /  \________ 
     /            \
    /              \

    AAAADDSSSSSSSSRR 

VRC7内部拥有15个预置好的乐器PATCH和1个自定义PATCH, 也就是每8字节一个乐器:

Register Bitfield Description
$00 TVSK MMMM Modulator tremolo (T), vibrato (V), sustain (S), key rate scaling (K), multiplier (M)
$01 TVSK MMMM Carrier tremolo (T), vibrato (V), sustain (S), key rate scaling (K), multiplier (M)
$02 KKOO OOOO Modulator key level scaling (K), output level (O)
$03 KK-Q WFFF Carrier key level scaling (K), unused (-), carrier waveform (Q), modulator waveform (W), feedback (F)
$04 AAAA DDDD Modulator attack (A), decay (D)
$05 AAAA DDDD Carrier attack (A), decay (D)
$06 SSSS RRRR Modulator sustain (S), release (R)
$07 SSSS RRRR Carrier sustain (S), release (R)
  • vibrato, 一般作抖音, 频率的改变(FM), 使能位
  • tremolo, 一般作颤音, 音量的改变(AM), 使能位
  • attack, 起音, 用于ADSR包络
  • decay, 衰减, 用于ADSR包络
  • sustain, 延音, 用于ADSR包络
  • release, 释音, 用于ADSR包络
  • $0$1: sustain, ASDR包络中, S阶段用
  • key rate scaling, 调整ADSR包络速度
  • multiplier, 倍乘因子(需要查表)
  • output level, 调整调制器的基础'音量(衰减值)'
  • waveform, 0: 正弦. 1: 半波整流正弦(小于0的被钳制到0)
  • $4-$7, ADSR包络用数据. 0:暂停. 1:最慢. F:最快
  • key level scaling, feedback 后面用的参数

声道

Register Bitfield Description
$10-$15 LLLL LLLL Channel low 8 bits of frequency
$20-$25 --ST OOOH Channel sustain (S), trigger (T), octave (O), high bit of frequency (H)
$30-$35 IIII VVVV Channel instrument (I), volume (V)
  • $AB中, A就是上面三种情况, B就是声道编号: 0-5共计6声道.
  • $3X:I, 就是乐器编号0-F
  • $3X:V, 声道音量0-F, 当然实际没有音量的概念
  • $2X:S, 为真的话, 会用$5替换掉PATCH中的'R'阶段原本的数据
  • $2X:T, 0->1我们可以认为是键盘按下, 1->0则可以认为是键盘弹起. 键盘按下就能弹出一个音符, 按键弹起则进入音符ADSR的'R'阶段. 如果键盘敲不响, 肯定是卡住了: 0->0, 1->1.
  • $1X的低8位, 与$2X的最低一位作为高位数据, 合成的9bit(freq). 以及$2X:O3bit的八度数据. 这两个合在一起定义了一个输出频率:
     49716 Hz * freq
F = -----------------
     2^(19 - octave)  

例如, A440, 记作A4, 八度(octave)为4. 这时freq为288.

49716Hz(49715.909Hz)是因为内部需要72周期处理全部声道, 所以可以反推VRC7的内部运行频率大致是3.579552MHz, 大约是N制CPU频率的两倍.

而且这个东西是硬件, 不会说插在P制红白机上就自动降频了(不过由于是VRC7内部驱动, 所以插在P制上也是发出的是同一个频率音符, 不像内部声部). 并且, 由于49716Hz>44100Hz, 所以我们可以认为, 最后输出是由各个声道通过混频得到的.

下面, 如果没有特殊说明, 说到VRC7的时钟周期, 是指49716Hz的周期.

VRC7的场合

'VRC7 Audio'里面并没有详细介绍细节, 不过下面论坛链接就有了.

相位计算:

每个算子拥有一个18bit的计数器用于确定当前相位, 每个时钟周期都会递增:


$00/$01 MMMM  $0  $1  $2  $3  $4  $5  $6  $7  $8  $9  $A  $B  $C  $D  $E  $F
Multiplier    1/2  1   2   3   4   5   6   7   8   9  10  10  12  12  15  15

phase += F * (1<<O) * M * V / 2

  • F = $2X:D0 $1X 组成的9bit数据
  • O = $2X:D1-D3 3bit的八度信息
  • M = $0/$1 经过查表的倍乘因子. 由于第一个是乘以二分之一, 可以考虑LUT预乘2, 最后再除以2
  • V = vibrato(FM)输出. 如果vibrato=0, V=1. 后面说明AM/FM的情况

实际使用中会使用'phase_secondary': phase_secondary = phase + adj

  • 对于载波器: adj是调制器的输出. 超过18bit的将会舍弃
  • 对于调制器, $03的3bit反馈值
  • F为0: adj=0
  • 其他情况, adj = previous_output_of_modulator >> (8 - F)
  • 由于这个是代表相位, phase_secondary对于调制器就是利用相位差, 定位指定的相位:
$03         FFF    $0     $1     $2     $3     $4    $5    $6    $7
Modulation Index    0    π/16    π/8    π/4    π/2    π    2π    4π

18bit的'phase_secondary'构造为: [RI IIII III. .... ....]

R和I都是之后需要使用的:

  • 其中'I'是(半)正弦表查表用的索引值. 具体使用多少位看具体实现, 最长可以用到17bit...太长了.
  • 'R'为1的话, 表示在正弦波的下半部分. 前面提到了, $3的waveform会决定是否钳制负数到0

输出计算:

每个周期每个算子输出一个衰减值:

TOTAL = half_sine_table[I] + base + key_scale + envelope + AM

其中half_sine_table并不是真正的正弦函数表, 而是衰减值:

sin(pi/2) = 1   ~~~>  I='0100 0000'  ~~~>  half_sine_table[ I ] = 0 dB
sin(0) = 0      ~~~>  I='0000 0000'  ~~~>  half_sine_table[ I ] = +inf dB

其中base:

  • 调制器: base = (0.75dB * L), L是$02的6bit基础衰减值
  • 载波器: base = (3.00dB * V), V是$3X的4bit基础衰减值
  • 后面会将浮点映射到整型(就是单位化而已)

其中key_scale是$02$03的2bit值K:

F: 9bit的频率数据
Oct: 八度

IF K==0, THEN
  key_scale = 0
ELSE
  A = table[ F >> 5 ] - 6 * (7-Oct)
  IF A < 0, THEN
    key_scale = 0
  ELSE
    key_scale = A >> (3-K)
  ENDIF
ENDIF

  
table:
   F:     $0     $1     $2     $3     $4     $5     $6     $7     $8     $9     $A     $B     $C     $D     $E     $F
   A:     0.00  18.00  24.00  27.75  30.00  32.25  33.75  35.25  36.00  37.50  38.25  39.00  39.75  40.50  41.25  42.00

后面的包络envelope, AM, 后面说明.

  1. 到这里, 每个算子的输出就计算好了. 不过这是一个衰减值, 需要转换成一个初步的线性输出值, 20bit.
  2. 根据'R'与'waveform'的值决定是否钳制为0.
  3. 还要通过一个滤波器, 不过很简单, 只需要和前一个输出(不是最终值)做平均就行
  4. 输出线性值
  5. 衰减值与线性值转换公式为:
dB     = -20 * log10( Linear ) * scale      (if Linear = 0, dB = +inf)
Linear = 10 ^ (dB / -20 / scale)

包络发生器

每个算子的包络发生器拥有一个23bit的计数器(记为EGC), 为输出添加衰减值(控制音量). 拥有ADSR阶段, 结束后进入空状态(Idle).

除开Attack阶段, EGC为零时输出0dB, EGC1<<23时, 输出48dB. 文中建议dB使用(1<<23)/48作为基础单位, 以做出最少的单位转换.

这里列出一些数据之后会用上:

  • OF: $2X的低四位, 由八度和频率最高位组成的4bit数据
  • K: $0$1的'key rate scaling'
  • KO: K ? OF : OF >> 2
  • R: 基础速率, 每个阶段不同, $04-$08的数据
  • RKS: R*4 + KO
  • RH: RKS >> 2, 超过15则被钳制到15
  • RL: RKS & 3
  • 注: 当R为0, EGC不会增加, 所以不用下面的计算了

Attack阶段:

  • R是对应的Attack速率
  • EGC += (12 * (RL+4)) << RH
  • EGC超过23bit范围, 归零, 进入Decay阶段.
  • Attack阶段输出是: AO(EGC) = 48 dB - (48 dB * ln(EGC) / ln(1<<23))

Decay阶段:

  • R是对应的Decay速率
  • EGC += (RL+4) << (RH-1)
  • 当EGC达到对应的Sustain值时, 进入Sustain阶段.
  • 具体是EGC >= (3 * Sustain * (1<<23) / 48)

Sustain阶段:

  • 如果 $0$1: sustain为0, R是对应的Release速率
  • 否则, R=0
  • EGC += (RL+4) << (RH-1)
  • 如果超过EGC本身范围, 进入Idle阶段

Release阶段:

  • $2X:S为1的话, R=5
  • 另外, $0$1: sustain为0的话, R是对应的Release速率
  • 否则, R=7
  • EGC += (RL+4) << (RH-1)
  • 如果超过EGC本身范围, 进入Idle阶段

Idle阶段:

  • 始终输出48dB

敲键盘

敲下和弹起后会处理一些事. 前面说了, 卡住的不算, 换一个键盘再来.

敲下:

  • EGC=0
  • 18bit的相位计数器归零
  • 进入Attack阶段

弹起

  • 如果当前为Attack阶段, EGC置为Attack输出函数(EGC=AO(EGC))
  • 进入Release阶段

AM/FM

前面提到的什么抖音颤音就在这了. 与前面不同的是, AM/FM的状态是全算子共用的(VRC7就一个AM/FM). AM/FM拥有一个20bit的计数器, 每个周期加上rate, 有一个共有的sinx = sin(2 * PI * counter / (1<<20)).

AM:

  • rate = 78
  • AM_output = (1.0 + sinx) * 0.6 dB (emu2413 使用的是 1.2 dB)

FM:

  • rate = 105
  • FM_output = 2 ^ (13.75 / 1200 * sinx)
  • '^'是指指数运算

也就是说, FM/AM的计算不受用户(这里是指程序猿)的影响, 用户能控制的是决定用不用AM/FM.

可以看出很多地方的计算相当复杂, 具体可以用查表实现. 涉及到精度的查表, 自己会用常量控制, 然后试试什么精度可以接受.

实际编写

实际编写中自然是遇到了大量问题:

  • AM冠FM戴. 经常有两个差不多的东西(这里就是AM/FM), 第二个就会直接复制, 但是没有完全修改.
  • 建立查找表时, 浮点的inf需要特别处理.
  • 最大问题还是:
  • op->key_scale = (uint32_t)a >> (3 - key_scale_bits)
  • 这句没有问题. 等等, 这句好像等价于:
  • op->key_scale = ((uint32_t)a << key_scale_bits) >> 3
  • 算了, 还是按照文档写的吧, 删掉:
  • op->key_scale = (uint32_t)a << (3 - key_scale_bits)
  • 于是右移变成左移了....花了半天才找到
  • 建议大家这种情况: 不要修改, 用ctrl+z
  • 几乎全是手贱

还一个架构上的问题:

之前提到了, 目前的处理模式是处理上一个状态. 所以外部有一个状态机, 保存了模拟器声部的上一次状态. 但是由于VRC7状态太复杂了, 不方便弄, 所以实现与其他不同:

  • 其实只有一点, 就是在修改状态前触发audio_changed事件
  • 这样内部本身就保存的上次状态, 就能用了
  • 等等...为什么之前不这么弄? 忘了, 可能有bug?

合并输出

  • 前面提到了, 由于频率太高, 所以认为是通过混频得到的. 这是一点.
  • 是的, 由于59716Hz比目前使用的44100Hz大, 所以涉及到一个'重采样'的步骤.
  • 问题: 为什么之前的不需要重采样? 知道的小伙伴可以回复评论!
  • 方案1: 简单地可以舍弃掉中间错位的样本
  • 方案2: 再稍微好一点的, 上步舍去的样本于下一次做平均
  • 方案3: 第三个自然就是, 进行比较系统地重采样.
  • 例如先通过一个22kHz的低通滤波, 然后针对每个样本进行权重分配
  • 这里简单地平均一下就好了(方案2)
  • 最后....
  • 怎么和2A03合并呢...这文中没有提到权重
  • 输出是一个20bit的数据, 不过可能带符号, 也就是21bit有符号.
  • 阉割版只有6个声道, 但是明显地, 应该有8个.
  • 也就是24bit有符号! 等等, 这不比CD音质还好?(内部全部使用双精度模拟的话肯定比CD音质好. 卡带上肯定不是, 不过, 还要什么自行车)
  • 以1.0为上限, 应该除以(double)(1<<23) (单精度浮点捉襟见肘了), 理论最大值为0.75(6声道)
  • 还是建议用户可调整

vrc7

PATCH表

预置PATCH似乎到目前还没有明确值, 自己使用的自然是wiki提供的:

// 内部PATCH表
const uint8_t sfc_vrc7_internal_patch_set[128] = {
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // Custom
    0x03, 0x21, 0x05, 0x06, 0xB8, 0x81, 0x42, 0x27, // Buzzy Bell
    0x13, 0x41, 0x13, 0x0D, 0xD8, 0xD6, 0x23, 0x12, // Guitar
    0x31, 0x11, 0x08, 0x08, 0xFA, 0x9A, 0x22, 0x02, // Wurly
    0x31, 0x61, 0x18, 0x07, 0x78, 0x64, 0x30, 0x27, // Flute
    0x22, 0x21, 0x1E, 0x06, 0xF0, 0x76, 0x08, 0x28, // Clarinet
    0x02, 0x01, 0x06, 0x00, 0xF0, 0xF2, 0x03, 0xF5, // Synth
    0x21, 0x61, 0x1D, 0x07, 0x82, 0x81, 0x16, 0x07, // Trumpet
    0x23, 0x21, 0x1A, 0x17, 0xCF, 0x72, 0x25, 0x17, // Organ
    0x15, 0x11, 0x25, 0x00, 0x4F, 0x71, 0x00, 0x11, // Bells
    0x85, 0x01, 0x12, 0x0F, 0x99, 0xA2, 0x40, 0x02, // Vibes
    0x07, 0xC1, 0x69, 0x07, 0xF3, 0xF5, 0xA7, 0x12, // Vibraphone
    0x71, 0x23, 0x0D, 0x06, 0x67, 0x75, 0x23, 0x16, // Tutti
    0x01, 0x02, 0xD3, 0x05, 0xA3, 0x92, 0xF7, 0x52, // Fretless
    0x61, 0x63, 0x0C, 0x00, 0x94, 0xAF, 0x34, 0x06, // Synth Bass
    0x21, 0x72, 0x0D, 0x00, 0xC1, 0xA0, 0x54, 0x16, // Sweep
};

这有128字节, 之前提到自定义BUS有256字节咩用到, 刚好可以用! 不过由于不在APU区, 储存状态时需要提供接口写入状态(这里是读取, 写入类似):

/// <summary>
/// VRC7: 从流读取至RAM
/// </summary>
/// <param name="famicom">The famicom.</param>
static void sfc_mapper_55_read_ram(sfc_famicom_t* famicom) {
    // 读取VRC7 PATCH表
    famicom->interfaces.sl_read_stream(
        famicom->argument,
        sfc_get_vrc7_patch(famicom),
        sizeof(sfc_vrc7_internal_patch_set)
    );
    // 流中读取至CHR-RAM[拉格朗日点使用了CHR-RAM]
    sfc_mapper_rrfs_defualt(famicom);
}

拉格朗日点 模拟出现的问题

音效: 开头start就出现问题了. 遂搜索论坛, 频率扫描的位移器, 如果为0则不进行扫描.

wtf

坑爹呢这是! wiki用0作为例子自己还以为是可以是0的! 这个问题已经在前面的博客修改并注明了(希望不要再出幺蛾子). 之前的代码自然是错的, 不过也不会去改了.

这游戏感觉完全在炫耀机能一般, 第一个BGM就在大量使用FM特性.

bug2

  • 场景突然消失
  • 猜测1: 目前处理'P'精灵有点问题, 会让后来的'P'精灵跑到之前的'P'精灵下面
  • 猜测2: 同之前的, 精灵渲染采用了错误的BANK
  • 没有去验证, 不过前者可能性较低

REF

  • VRC7
  • VRC7 Audio
  • VRC7 Sound
  • 多媒体技术基础及应用
  • 调频(FM)音乐合成原理与实现
  • 日本游戏音乐发展: Diggin'in the carts -- P2: The Outer reaches of 8-bit

附录: 各个表长的研究

FM

第一个说FM是因为有两个考虑的地方, 深度与宽度, 其中如果采用的是浮点的话, '深度'就无需考虑了. 但是代码就这一个地方使用了浮点也太奇怪了.

    for (int i = 0; i != SFC_VRC7_FM_LUTLEN; ++i) {
        const double sinx = sin(SFC_2PI * i / SFC_VRC7_FM_LUTLEN);
        const double out = pow(2.0, 13.75 / 1200.0 * sinx);
        sfc_vrc7_fmlut[i] = out;
    }

宽度同其他的, 这里谈谈深度. F * (1<<O) * M * V / 2, 转成(F * (1<<O) * M / 2) * V. 其中(F * (1<<O) * M / 2)播放一个音符中, 是一个固定值.

  • 最大值: 490560, 19bit
  • FM最大系数(约): 1.008
  • 要怎么用就能将一个490560 * 1.008用整型模拟?
  • 当然可以用: 490560 * 4128 / 4096之类的
  • 这里的深度其实就是指分母的精度
  • 对于32bit可以使用到8192
  • 不过精度还仅仅是可以一用:
/// <summary>
/// StepFC: VRC7 FM计算
/// </summary>
/// <param name="left">The left.</param>
/// <param name="right">The right.</param>
/// <returns></returns>
static inline uint32_t sfc_vrc7_fm_do(uint32_t left, sfc_vrc7_fm_t right) {
#ifdef SFC_FM_FLOAT
    return (uint32_t)((double)left * (double)right);
#else
    return (left * right) >> SFC_VRC7_INT_BITWIDTH;
#endif
}
  • 现在舍弃浮点的整数部分, 1.008中间可是很多位浪费了
  • 即: a * 1.008 -> a + a*0.008
  • 这样就有很多位可以用了(可以用到20), 但是小心负数方向
/// <summary>
/// StepFC: VRC7 FM计算
/// </summary>
/// <param name="left">The left.</param>
/// <param name="right">The right.</param>
/// <returns></returns>
static inline uint32_t sfc_vrc7_fm_do(uint32_t left, sfc_vrc7_fm_t right) {
#ifdef SFC_FM_FLOAT
    return (uint32_t)((double)left * (double)right);
#else
    const int32_t ileft = (int32_t)left;
    const int32_t extra = (ileft * right) / (1 << SFC_VRC7_INT_BITWIDTH);
    return (uint32_t)(ileft + extra);
#endif
}

实际实现中, 由于有除以4的操作, 这样会丢失精度. 所以上面函数uint32_t left, 实际是还没有除以4的uint32_t left_x4

宽度

  • FM/AM表宽度不必太大, 控制抖音/颤音的, 256足矣.
  • 半正弦表可以适当加大, 比如1024.
  • Attack输出表不必太大, 128都行, 自己用的256
  • 衰减转线性, 由于对数的原因, 这个表必须足够长不然精度不够
  • 评选标准大致是最后几个出现重复值

a2l1

精度10位时, 最后几个差距有点大.

a2l2

精度14位时, 精度大致介于1与2之间.

a2l3

精度16位时, 精度已经有0.5了.

所以选择: 15位, 这基本是最低要求, 可以使用16位

// 可修改
enum {
#if 1
    // VRC7 半正弦表位长
    SFC_VRC7_HALF_SINE_LUT_BIT = 10,
    // VRC7 AM 表位长
    SFC_VRC7_AM_LUT_BIT = 8,
    // VRC7 FM 表位长
    SFC_VRC7_FM_LUT_BIT = 8,
    // VRC7 Attack输出 表位长
    SFC_VRC7_ATKOUT_LUT_BIT = 8,
    // VRC7 衰减转线性查找表位长
    SFC_VRC7_A2L_LUT_BIT = 15,
#else
    // VRC7 半正弦表位长
    SFC_VRC7_HALF_SINE_LUT_BIT = 16,
    // VRC7 AM 表位长
    SFC_VRC7_AM_LUT_BIT = 16,
    // VRC7 FM 表位长
    SFC_VRC7_FM_LUT_BIT = 16,
    // VRC7 Attack输出 表位长
    SFC_VRC7_ATKOUT_LUT_BIT = 16,
    // VRC7 衰减转线性查找表位长
    SFC_VRC7_A2L_LUT_BIT = 16,
#endif
};

Re: 从零开始的红白机模拟 - [16]基础音频

STEP8: 基本音频播放

本文github备份地址

上节简单介绍了基本概念, 下面, 介绍相关寄存器

分频器(Divider)

这里特指时钟分频器(或者叫做'分时器'?), 分频器会利用一个值(P)输出时钟, 内部持有一个计数器, 计数器到0就输出然后重置为P值, 比如P为5:

  • 2 -> 1
  • 1 -> 0
  • 0 -> 5 =>输出
  • 5 -> 4
  • 4 -> 3
  • 3 -> 2
  • 2 -> 1
  • 1 -> 0
  • 0 -> 5 =>输出
  • 5 -> 4
  • 4 -> 3
  • 3 -> 2

可以看出这个分频器的周期是P+1.

可以强制让分频器重载P值, 不过就不会输出时钟了. P值修改时不会影响分频器当前的计数.

帧计数器(Frame Counter) / 帧序列器(Frame Sequencer)

地址 功能
$4017 M--- ---- 模式(0:4步 1:5步)
$4017 -I-- ---- IRQ禁止标志位

地址$4017, 问: 读取的话是用来干什么的?

写入的话就是用控制帧计数器(APU Frame Counter)的, 这个就是用来驱动方波、三角波以及噪声声道的包络、扫描和长度单元.

内部包含一个分频器和序列器.

帧计数器的分频器会以四倍于帧率的频率进行"计数"(NTSC下就是240Hz, 实际上会'稍微略'低于240). 4步模式4步就是刚好一帧, 5步模式5步就是一又四分之一帧了. 之后就根据序列器中的内容进行触发.

写入会使分频器与序列器复位

模式 序列 实际频率 说明
模式0 ---f 60Hz 设置中断标志
模式0 -l-l 120Hz 为长度计数器与扫描单元提供时钟信号
模式0 eeee 240Hz 为包络以及三角波的线性计数器提供时钟信号
模式1 ----- 0Hz 不会设置中断标志
模式1 l-l-- 96Hz 为长度计数器与扫描单提供时钟信号
模式1 eeee- 192Hz 为包络以及三角波的线性计数器提供时钟信号

这个帧计数器的频率和垂直空白不是完全同步的, 是独立运行的, 只能说几乎是4倍. 接下来的内容, 如果没有特殊说明, 则假定在模式0(四步模式)下.

IRQ:

When $4017 sets the IRQ flag, it will keep telling the CPU to cause an IRQ, until the CPU reads from $4015 (or writes $40 or $C0 to $4017) to tell it to stop requesting an IRQ. $4017 doesn't have any other way of knowing that the CPU handled the IRQ; it doesn't know that the CPU has entered the IRQ handler.

文档说是由于电平敏感, 任何时间中断标志被设置并且并且CPU的I标志为0则立即触发IRQ. 这里每次写入$4017就会重置状态, 感觉时机很难把握, IRQ先放一放.

(中精度模拟下)简单实现为一帧内每隔大概65~66扫描行触发一次.

包络发生器(Envelope Generator)

包络(APU Envelope)发生器生成一个固定的音量或者一个锯齿包络(还可以选择是否循环)来控制音量, 内部包括一个时钟分频器和一个计数器.

上面提到了, 分频器的周期是P+1, 即每次获得P+1次时钟信号则输出一次时钟信号.

  1. 当接收到帧计数器发出的时钟信号时
    • 之前写入了第四个寄存器?
    • 是: 计数器设为15, 重置分频器
    • 否: 为分频器提供一次时钟信号
  2. 当分频器输出了时钟信号时
    • 计数器>0
    • 是: 计数器-1
    • 否且设置了循环: 计数器=15
  3. 如果设置了固定音量
    • 是: 直接输出音量为P
    • 否: 输出音量为计数器的数据

例如:

  • 如果P=0, 即分频器的周期为1, 能够将音量在4帧内将声音从15降至0, 2帧内将音量从7降至0.
  • 如果P=15, 即分频器的周期为16, 能够在16*4, 即一又十五分之一秒内将声音从15降至0.
  • 可以看出这个包络可以让声音'渐出'
  • 实际上很多游戏的音乐使用的固定音量(如果是完整的ADSR包络, 估计就会用的很多了)

长度计数器(Length Counter)

长度可以理解为时长. 这东西用来自动控制持续的时间, 计数器可以被暂停. 暂停标志位是在D5位(方波、噪声), 或者D7位(三角波), 设置为0表示暂停. 暂停会让计数器...emmm...暂停.

内部计数器到0的话就会静音; 被提供时钟信号时, 计数器会-1, 除非:

  • 已经0了
  • 暂停中

长度计数器(APU Length Counter)是一个5bit的数据, 也就是说最高能到31, 前面提到120Hz的长度计数器触发频率, 换句话说最多15.5帧...太短了!

所以实际上5bit是一个索引值:


    iiii i---       length index
    
    bits  bit 3
    7-4   0   1
        -------
    0   $0A $FE
    1   $14 $02
    2   $28 $04
    3   $50 $06
    4   $A0 $08
    5   $3C $0A
    6   $0E $0C
    7   $1A $0E
    8   $0C $10
    9   $18 $12
    A   $30 $14
    B   $60 $16
    C   $C0 $18
    D   $48 $1A
    E   $10 $1C
    F   $20 $1E

长度计数器可以看出比较粗糙, 比如这种表都是偶数.

状态寄存器(Status Register)

$4015是APU相关寄存器唯一一个可读的寄存器.

:

作用
i--- ---- DMC的IRQ标志
-f-- ---- 帧中断标志, 如果为1, 会确认IRQ, 返回1置为0
---d ---- DMC还有剩余样本(剩余字节>0)
---- n--- 噪声长度计数器>0
---- -t-- 三角波长度计数器>0
---- --2- 方波2长度计数器>0
---- ---1 方波1长度计数器>0

:

作用
---d ---- DMC声道使能位
---- n--- 噪声声道使能位
---- -t-- 三角波声道使能位
---- --2- 方波2声道使能位
---- ---1 方波1声道使能位

写入0对应的声道就会静音, 同时长度计数器归零. 一定注意要归零, 自己之前忘记归零导致玩马里奥第二关才发现有音效问题, 找了很久才发现是这个原因

方波1/2 寄存器

地址 功能
$4000/$4004 DD-- ---- 占空比
$4000/$4004 --L- ---- 循环包络 / 暂停长度计数器
$4000/$4004 ---C ---- 固定音量标志位(1: 固定音量 0:使用包络的音量)
$4000/$4004 ---- VVVV 固定音量模式下的音量 或者作为包络的分频器输入P
$4001/$4005 E--- ---- 扫描单元: 使能位
$4001/$4005 -PPP ---- 扫描单元: 周期P
$4001/$4005 ---- N--- 扫描单元: 负向扫描
$4001/$4005 ---- -SSS 扫描单元: 位移次数
$4002/$4006 TTTT TTTT 声道周期低8位
$4003/$4007 ---- -TTT 声道周期高三位
$4003/$4007 LLLL L--- 长度计数器加载索引值

为了方便, 下面节省$4004-$4007的说明

频率

  • 方波声道的频率是CPU时钟的全境掉线, 不, 分频(division)
  • 计算方法是 f = CPU / (16 * (t + 1))
  • t是$4002-$4003的11位声道周期计时器
  • t<8则静音, 即阈值是CPU频率的128分之一
  • 1.789773MHz NTSC, 1.662607MHz PAL
  • 大概是13kHz, 换句话说, 输出采样频率最好是至少26kHz, 这里就直接用万能的44.1kHz

占空比

  • 占空比由$4000的高两位控制
  • [0] 0 1 0 0 0 0 0 0 [12.5%]
  • [1] 0 1 1 0 0 0 0 0 [25%]
  • [2] 0 1 1 1 1 0 0 0 [50%]
  • [3] 1 0 0 1 1 1 1 1 [25% 反相]

音量

  • 可以是固定音量, 由$4000:D4(C)位确定
  • 固定音量下, 具体音量就是$4000:D0-D3(V)
  • 不是固定音量, 换句话说就是采用包络进行音量控制, 即$4000:D4(C)位为0
  • $4000:D0-D3(V)这时候就是包络的周期
  • 如果包络的循环模式$4000:D5(L)没有打开, 则启动长度计数器, 计数器到0则会静音

扫描单元(Sweep Unit)

扫描单元(APU Sweep)位提供了周期性调整方波周期的手段, 包含一个分频器和一个移位器.

EPPP NSSS  使能标志, 分频器周期(需要+1), 负向标志位, 位移数量

同样, 扫描单元的分频器也是P+1, 不同的是一个3bit的了. 还有就是扫描单元是以120Hz的频率触发, 所以扫描的频率是15Hz-120Hz(0.5帧-4帧)

移位器连续地对频率进行扫描:

  • 将当前周期(11bit)右移s位, 作为结果值1
  • 将当前周期加上(N为1则减去)结果值1, 作为结果值2
  • 输出结果值2

方波1和2区别:

  • 在N=1时, 方波#1是加上结果的反码, 也就是需要额外-1
  • 在N=1时, 方波#2是加上结果的补码, 直接减就行
  • 这是BUG还是特性?

例子:

  • P = 0, N = 0, S = 0, 即每0.5帧将输出周期加倍, 即输出频率降低到一半, 低八度
  • 钢琴**A音高对应频率为440Hz, 低八度A音则为220Hz
  • P = 1, N = 1, S = 1, 即每帧将输出周期降至原来的一半, 即输出频率加倍, 高八度
  • 高八度A音对应频率为880Hz
  • 注意! 上面只是例子, 实际上为S=0的话不进行扫描

静音:

  • 可以看出这个可以'加倍', 所以一旦超过11bit的范围($800), 应该被静音
  • 同样会减少, 之前提到: 少于8静音.
  • 因为是连续扫描的, 所以在不干涉的情况下, 最后总会高于$7FF或者低于8. 这个时候不会对频率更新(比如降到7然后就不会更低了)
  • 假设一开始就是7, 然后准备增加, 因为已经低于8了, 实际不会增加.
  • 这个测试ROM[sweep_cutoff]有测试, 不过自己并没有实现

方波的周期与频率

之前说到方波频率的计算公式f = CPU / (16 * (t + 1)), 为什么呢?

  1. APU的频率是CPU频率的一半, 也就是说2个CPU周期等于1个APU周期
  2. 之前说到了方波的占空比, 是一个8个数, 组成的数列
  3. 比如[0 1 1 1 1 0 0 0]
    • 第一个APU周期方波输出0
    • 第二: 1
    • ...
    • 第八: 0
  4. 这样上限频率是CPU/16
  5. 之前说到了11bit的周期, 就像其他周期一样, N表示每N+1触发一次
  6. 每N+1次APU周期 -> 推进这8个数列的索引
  7. 每个APU周期 -> 输出这8个数列的当前索引表示的数据
  8. 这样就输出了方波

具体实现

前面提到了, 目前使用状态机实现, 目前的实现是60Hz(同刷新率). 如果一帧内修改了超过1下, 只会以某一时刻为准, 不过60Hz对于音乐是够用了, 至于音效么, 只能说总比没有的好(比如马里奥跳跃的音效, 目前听起来就有点呆板了). 以后可能会用更好的办法(一旦修改数据就通知渲染层(广义)).

  • 我是谁?
  • 方波
  • 频率?
  • 由当前周期通过公式计算, 当前周期可能会通过扫描单元进行修改
  • 周期太低(低于8)或者太高(超过11bit)都会导致静音
  • 音量?
  • 由包络单元输出, 当前音量可能是固定的也可能是通过包络衰减, 甚至还有循环
  • 0就是静音 15是最大
  • 占空比?
  • 就是那4个
  • 输出?
  • 置空状态寄存器对应的使能位能让声道静音
  • 长度计数器归零也会导致静音

三角波 寄存器

寄存器地址 说明
$4008 C--- ---- 暂停长度计数器/线性计数器控制
$4008 -RRR RRRR 线性计数器重载值
$4009 ---- ---- 居然没用
$400A TTTT TTTT 周期低8位
$400B ---- -TTT 周期高3位
$400B LLLL L--- 长度计数器载入索引

线性计数器

之前有一个长度计数器, 用于控制播放的时间, 由于120Hz和都是偶数, 所以精度是在一帧. 三角波增加了一个线性计数器能够更为精确地控制(那么代价是什么?).

$4008包括了一个控制标志位以及一个7位的重载值: crrr rrrr , 当然这个C位也被用于长度计数器.

之前可以看长度计数器是一回合2次(又由于是偶数, 所以...), 线性计数器则是4次:

  • 如果之前写入了$400B, 则标记暂停
  • 帧计数器提供了信号: 如果标记了暂停
    • 是: 将计数器设置为7bit重载值R
    • 否: 计数器非零则减1
  • 帧计数器提供了信号: 如控制标志C为0, 则清除暂停标志

7bit, 240Hz可以看出还是挺细腻的.

频率

同之前的方波, 三角波也有一个数列(32):

F E D C B A 9 8 7 6 5 4 3 2 1 0 0 1 2 3 4 5 6 7 8 9 A B C D E F

所以理论上最高频率是CPU/64? 实际上与方波不同的是, 三角波采用的是CPU的频率, 也就是最高CPU/32. 计算公式: f = CPU/(32*(t + 1))

等等, CPU/32太高了吧? 硬件上的确没有限制, 实现上我们可以让t小于一个阈值让它实现上'静音'(反正听不到), 这个阈值当然就是人耳上限20kHz, 再换算到t...emm... 2?

模拟器可以这么实现, 但是如果是游戏开发就不能这么干, 单独的确是静音(听不到), (实机或者高精度模拟器)混起来就可能产生爆裂声 -- 除非就想这么干(搞事).

具体实现

没有包络控制音量, 没有扫描器控制频率, 对于模拟器实现来说——爽. 同样由于这些问题, 三角波要么循环要么就短时间放一下.

NES的三角波没有音量设定, 但是有一种特殊的调节其音量的方法(DAC法)

  • 我是谁?
  • 三角波
  • 频率?
  • 通过周期换算, 如果频率过高可以实现为静音
  • 音量?
  • 没有直接控制
  • 输出?
  • 置空状态寄存器对应的使能位能让声道静音
  • 长度计数器归零也会导致静音
  • 线性计数器归零也会导致静音

噪声 寄存器

$400C   --le nnnn   loop env/disable length, env disable, vol/env period
$400E   s--- pppp   short mode, period index
$400F   llll l---   length index

线性反馈移位寄存器

$400E S--- ---- S: 短模式

噪声声道有一个15bit的LFSR, 每次输出最低的bit位.算法如下:

  1. 将D0位与D1做异或运算
  2. 如果是短模式则是D0位和D6位做异或运算
  3. LFSR右移一位, 并将之前运算结果作为最高位(D14)

如果是长模式(S=0), 这个LFSR的周期是32767(如果初始0则一直为0), 短模式的周期是93(还有可能是31?). 为了避免一直为0, 应该初始化为1.

周期索引

$400E ---- pppp 周期索引值

周期是一个4bit的值, 理所当然是一个索引值:

Rate  $0 $1  $2  $3  $4  $5   $6   $7   $8   $9   $A   $B   $C    $D    $E    $F
      --------------------------------------------------------------------------
NTSC   4, 8, 16, 32, 64, 96, 128, 160, 202, 254, 380, 508, 762, 1016, 2034, 4068
PAL    4, 8, 14, 30, 60, 88, 118, 148, 188, 236, 354, 472, 708,  944, 1890, 3778

制式的差异主要体现在CPU频率上:

  • PAL-CPU/NTSC-CPU = 0.929

理论频率计算公式应该是CPU / P, 或者APU / P. 不用加1, 是因为直接给出了周期. 不过因为是噪音, 不是真正的频率, 这个频率只是LFSR的更新频率.

至于是CPU / P还是APU / P, 感觉上有歧义:

APU REF

All channels use a timer which is a divider driven by the ~1.79 MHz clock.

The noise channel and DMC use lookup tables to set the timer's period. For the square and triangle channels, the third and fourth registers form an 11-bit value and the divider's period is set to this value plus one.

  • 这篇文档提到全部计时器是以CPU1.79MHz驱动

Timer with
divide-by-two on the output

  • 但是方波计时器会除以2

NESDEV APU

  • 这个当然是主要参考的

A timer is used in each of the five channels to control the sound frequency. It contains a divider which is clocked by the CPU clock. The triangle channel's timer is clocked on every CPU cycle, but the pulse, noise, and DMC timers are clocked only on every second CPU cycle and thus produce only even periods.

  • 这个文档就说只有三角波是特例, 是以CPU周期为单位, 其他都是每两个CPU周期计算一次

然后自己用马里奥反复对比了其他的模拟器, 发现计算公式感觉上应该是CPU/P(没有比较源代码, 毕竟世界上最痛苦的事情, 莫过于看别人写的代码还没有文档), 有一个比较低的$0C音, 区别较大. 这个也需要求证. 不排除自己的实现有问题.

也就是说噪音生成的逻辑:

  • 每P次APU(CPU?)周期更新一次LFSR
  • 每次采样时: 检测LFSR最低位
    • 0: 输出音量
    • 1: 输出0

包络归来

噪声声道也用到了包络, 方波已经说到了就不再累述

具体实现

EZ模式实现就比较困难了, 因为实际上有一个LFSR状态机. 但是回归本质, 这是一种白噪声的实现手段, 实际上只有$400E中的5bit控制音色, 即实际上只有32种音色, 16种频率乘上2种模式. 反正是噪音(EZ模式嘛, 听个响, 要啥自行车).

再简化, 频率可以通过音频API调整, 也就是说只需要实现两个模式即可. 不过, 即使是白噪声, 短时间播放一段时间还是有差别的, 记录当前播放状态反而实现了一个伪状态机. 换句话说, 也就是每次播放不要从头播放, 从上次的地方继续.

实现:

  1. 通过LFSR生成两段(两种模式)音频序列
  2. 根据模式切换这个两段序列
  3. 根据周期索引切换播放速度(调整频率)
    • 可以看出NTSC和PAL的LFSR修改频率, 理论上应该一致, 可以独立于CPU频率实现(方便但是会丢失精度, 理论一致但是实际上有误差)

...

  • 我是谁?
  • 噪音
  • 频率?
  • 没有, 滚
  • 音量?
  • 由包络单元输出, 当前音量可能是固定的也可能是通过包络衰减, 甚至还有循环
  • 0就是静音 15是最大
  • 输出?
  • 置空状态寄存器对应的使能位能让声道静音
  • 长度计数器归零也会导致静音

DMC 声道

DMC可以用来播放ΔPCM, 不过用的游戏很少, 原因当然是对于当时用kb作为单位的游戏来说太奢侈了. 2A03的DMC声道的采样深度是7bit的, 我们现在使用CD音质是16bit的, 差了不是一星半点. 甚至DEMO中自己是图方便, 直接用的32bit浮点.

寄存器地址 说明
$4010 I--- ---- IRQ 使能位
$4010 -L-- ---- 循环标志位
$4010 ---- PPPP 周期索引
$4011 -DDD DDDD DAC绑定计数器
$4012 AAAA AAAA 样本地址
$4013 LLLL LLLL 样本长度

不论是否被禁止(状态寄存器对应使能位), DMC都会向DAC输出数据, 一开始是0, 或者向$4011写入的7bit数据. 状态寄存器的使能位是用来控制样本播放的.

之前提到了三角波可以通过DAC方法调整音量, 想到了什么?

周期/频率

4bit想想还是索引:

Rate   $0   $1   $2   $3   $4   $5   $6   $7   $8   $9   $A   $B   $C   $D   $E   $F
      ------------------------------------------------------------------------------
NTSC  428, 380, 340, 320, 286, 254, 226, 214, 190, 160, 142, 128, 106,  84,  72,  54
PAL   398, 354, 316, 298, 276, 236, 210, 198, 176, 148, 132, 118,  98,  78,  66,  50

这个就没有歧义了, 两边都说明是以CPU周期为单位的, APU需要除以2. 同之前的制式区别, 当然也可以换算使用独立于CPU的频率(方便但是会丢失精度, 理论一致但是实际上有误差)

可以看出即使用最高频率播放, 也只有33kHz

样本

样本地址是一个8bit的数据, 实际地址是通过转换的:

  • 地址: [$4012] * $40 + $C000
  • 位操作: %11AAAAAA.AA000000

样本长度是一个8bit的数据, 实际长度是通过转换的:

  • 长度: [$4013] * $10 + 1
  • 位操作: LLLL.LLLL0001

ΔPCM输出

2A03的ΔPCM大致解码流程:

  • 一个字节为一次循环, 从D0到D7
  • 如果是1则Δ为+2, 否则Δ就是-2.
  • 初始状态为0或者$4011的写入值.
  • 是一个7bit的数据, 超过会被钳制.
  • 甚至能中途能够修改$4011(搞事).
  • 可以看出虽然输出7bit但是Δ是±2, 所以实际上最多能解码出6bit
  • 由于声音是由'震动'产生的, 所以初始值理论上无所谓, 这里完全是为7bit范围服务的
  • 1bit的ΔPCM相当粗糙, 导致DMC播放效果很差.

三个部分组成: DMA读取器->样本缓存->输出单元

  • 样本缓存
    • 8bit样本以及一个'空'状态
    • 由DMA读取器填充
    • 仅能被输出单元清空
  • DMA读取器
    • 样本缓存为'空'状态以及还有剩余样本可读则读取下一个样本, 地址+1, 剩余数量-1
    • 地址超过$FFFF则换至$8000
    • 读完了, 不过设置为循环, 则从头再来
    • 读完了, 不过设置为中断, 则标记中断

    Based on the following tests, DMC DMA adds 4 cycles normally, 3 if it lands on a CPU write, 2 if it lands on the $4014 write or during OAM DMA, 1 if on the next-to-next-to-last DMA cycle, 3 if on the last DMA cycle.

    • 访问CPU地址空间会让CPU暂停大约4周期
  • 输出单元
    • 内部输出循环开始后:
    • 内部计数器载入8
    • 样本缓存为空, 则标记静音状态
    • 否则清除静音状态, 将样本缓存的数据移动到一个移位器, 并将样本缓存置空
    • 当收到时钟信号:
      1. 静音状态为0? 那么移位器的D0位:
      • 0: 7bit输出减去2
      • 1: 7bit输出加上
      • 7bit输出($4011可以设置, 可以理解为同一个东西)默认是0
      • 钳制在[0, 127]区间内
      • 即: 0-2 -> 0, 127+2 -> 127
      1. 移位器右移一位
      2. 内部计数器递减, 到0后结束本次输出循环

2A03的ΔPCM是1bit的Δ, 0就-2, 1就+2. 每8次(一个字节)一个循环, 8次是一个单位, 不能从中间打断.

解码练习: 有爱好者将ΔPCM数据RIP出来了, 可以用来测试解码器

  • PS: 从这里可以看出恶魔城传说/恶魔城3中, 那段仅仅+1s恶魔笑声花了3kb

编码练习: 有兴趣的话可以将一段高品质的音频序列编码至2A03的ΔPCM序列

PCM输出

由于$4011的特点, 2A03可以以PCM方式工作, 这样会消耗大量的空间(ΔPCM x 8), 以及CPU时间. 音质甚至可以达到7bit/44kHz, 不过这个时候你就别想玩游戏了, CPU全部用来输出PCM了.

MMC5甚至带了一个类似的寄存器不过是8bit的了, 是鼓励用PCM吗?

具体实现

解码输出可以算是很简单, 但是对于EZ模式几乎是一个不可能的任务:

  • 允许直接写$4011, 可以让ΔPCM以PCM方式工作(搞事啊)
  • 读取CPU地址空间会消耗CPU周期, 失去同步

至于7bit的PCM, 很难实现也很容易实现:

  • 利用当前设置几乎不可能实现
  • 我们增加一个接口, 在PCM模式下(如何判断?), 这个接口就是用来记录当前CPU周期数与$4011的新值
  • 让接口可以用来生产自己需要的音频序列, 实现上因为是7bit的, 我们可以简单地重解释(或乘以2)为8bitPCM直接输出, 不用转换成32bit浮点
  • 如何判断当前是否是PCM模式?
  • 嗯......?

至于ΔPCM, 难点是同步

  • 存在多余的4周期(甚至还有其他情况!)
  • 想想, 一行才100来个周期
  • 在33kHz下, 也就是54周期输出一次
  • 也就是一条扫描线输出两次
  • 最高频率每4行会读取一次
  • EZ模式下, 把这些周期塞在哪里?
  • (一帧最多才浪费60+周期, 无视掉?)
  • 有些搞事的会利用DMC中断来设置'分割滚动'效果
  • 还有的会利用DMC中断实现锯齿波(仅仅需要17字节+中断函数, 很不错的锯齿波)
  • 如何同步?
  • 嗯......?

综上所述, 模拟DMC声道在状态机模式是比较困难的事情了(如果模拟精度精确到周期, 那就相当方便了, 真正的'仿真'). 我们先放置一下, 暂不实现.

download

混频器

托音频API的福, 自己没有实现混频器, 但是自带混频器算法如下:

    output = square_out + tnd_out
    
    
                          95.88
    square_out = -----------------------
                        8128
                 ----------------- + 100
                 square1 + square2


                          159.79
    tnd_out = ------------------------------
                          1
              ------------------------ + 100
              triangle   noise    dmc
              -------- + ----- + -----
                8227     12241   22638

当然除了2A03自带这些声道, 日版游戏还有卡带自带的额外声道, 原理上其实就是修改输出到DAC的值.

之前提到三角波可以利用DAC法修改音量, 想要模拟到这个程度必须自己混.

黑匣子

这次黑匣子作为状态机. 这次的文件有4个, 主要是MinGW-w64, 自己在N年前就给这个项目提出缺少XAudio2的头文件, 现在还是没有, 只好DIY以支持GCC(实际是g++)编译器(忘记说了, 项目中有CodeLite项目文件, 没有Visual Studio的可以用这个IDE, 配合GCC/MinGW-w64使用).

int xa2_init();
void xa2_clean();

void xa2_play_square1(float frequency, uint16_t duty, uint16_t volume);
void xa2_play_square2(float frequency, uint16_t duty, uint16_t volume);
void xa2_play_triangle(float frequency);
void xa2_play_noise(uint16_t data, uint16_t volume);

感觉都不用解释. 这次测试ROM同上次的拉罐, 是的这个拉罐ROM实际是带BGM的!

output

项目地址Github-StepFC-Step8

完整的马里奥

终于可以完整地游玩马里奥了, 就着这个劲儿准备把马里奥通关了!
mario-clear
然后自己加入了非常暴力膜幻的即时存档功能, 通关了.

作业

  • 基础: 文中提到了ΔPCM解码练习, 试试吧
  • 扩展: 文中提到了ΔPCM编码练习, 试试吧
  • 从零开始: 从零开始实现自己的模拟器吧

REF

Re: 从零开始的红白机模拟 - [29] NSF 再探

NSF 文件头

那么正式进入NSF文件的探索, 首先说的自然是文件头:

offset  # of bytes   Function
----------------------------
$000    5   STRING  'N','E','S','M',$1A (denotes an NES sound format file)
$005    1   BYTE    Version number (currently $01)
$006    1   BYTE    Total songs   (1=1 song, 2=2 songs, etc)
$007    1   BYTE    Starting song (1=1st song, 2=2nd song, etc)
$008    2   WORD    (lo, hi) load address of data ($8000-FFFF)
$00A    2   WORD    (lo, hi) init address of data ($8000-FFFF)
$00C    2   WORD    (lo, hi) play address of data ($8000-FFFF)
$00E    32  STRING  The name of the song, null terminated
$02E    32  STRING  The artist, if known, null terminated
$04E    32  STRING  The copyright holder, null terminated
$06E    2   WORD    (lo, hi) Play speed, in 1/1000000th sec ticks, NTSC (see text)
$070    8   BYTE    Bankswitch init values (see text, and FDS section)
$078    2   WORD    (lo, hi) Play speed, in 1/1000000th sec ticks, PAL (see text)
$07A    1   BYTE    PAL/NTSC bits
                bit 0: if clear, this is an NTSC tune
                bit 0: if set, this is a PAL tune
                bit 1: if set, this is a dual PAL/NTSC tune
                bits 2-7: not used. they *must* be 0
$07B    1   BYTE    Extra Sound Chip Support
                bit 0: if set, this song uses VRC6 audio
                bit 1: if set, this song uses VRC7 audio
                bit 2: if set, this song uses FDS audio
                bit 3: if set, this song uses MMC5 audio
                bit 4: if set, this song uses Namco 163 audio
                bit 5: if set, this song uses Sunsoft 5B audio
                bits 6,7: future expansion: they *must* be 0
$07C    4  ----    4 extra bytes for expansion (must be $00)
$080    nnn ----    The music program/data follows until end of file

转换为c的话就是:

/// <summary>
/// NSF 文件头
/// </summary>
typedef struct {
    // NESM
    char        nesm[4];
    // 1A
    uint8_t     u8_1a;
    // 版本号
    uint8_t     ver;
    // 歌曲数量
    uint8_t     count;
    // 起始数
    uint8_t     start;
    // 加载地址[小端]
    uint16_t    load_addr_le;
    // 初始化地址[小端]
    uint16_t    init_addr_le;
    // 播放地址[小端]
    uint16_t    play_addr_le;
    // 歌曲名称
    char        name[32];
    // 作者名称
    char        artist[32];
    // 版权
    char        copyright[32];
    // 播放速度 NTSC[小端]
    uint16_t    play_speed_ntsc_le;
    // Bankswitch 初始值
    uint8_t     bankswitch_init[8];
    // 播放速度 PAL[小端]
    uint16_t    play_speed__pal_le;
    // PAL/NTSC 位
    uint8_t     pal_ntsc_bits;
    // 扩展音源 位
    uint8_t     extra_sound;
    // 扩展位
    uint8_t     expansion[4];

} sfc_nsf_header_t;

其中有一些uint16_t数据, 文件中是以小端排列的. 大小端的问题就不再累述, 文件载入的话可以简单写为:

sfc_nsf_header_t header;
const size_t count = fread(&header, sizeof(header), 1, file);
// 交换大小端
if (this_is_be()) sfc_nsf_swap_endian(&header);

相关地址

查看文件头后, 有三个比较特殊的地址: 加载, 初始化, 以及播放.

加载地址, PRG-ROM自然是加载在高地址, 也就是$8000开始加载的, 某些ROM可能会从更低的地址加载(不过官方推荐范围是$8000-$FFFF).

一般地, 载入地址为$8000. 特殊地, 为$8000 + N * $1000. 这种情况应该是少于32kb的NSF. 更特殊地, 并不是以4kb对齐的地址.

例子: 如果一个NSF内容是32kb-1大小, 那么, 加载地址就可能是$8001. 为此, 核心部分数据精度再次提高, 之前是以16kb为单位储存的, 现在以字节为单位储存.

初始化地址, 自然是初始化每首曲子用的地址, 播放前需要初始化工作

播放地址, 初始完毕就可以正式播放曲子了.

相关字符串

拥有几个字符串的成员, 编码可以考虑为标准的ASCII或者使用了高位的'Windows-1252'编码.

播放速度

文件头拥有两个控制播放速度的, 分别对应N制式与P制式.

        1000000                  1000000
rate = ---------       period = ---------
        period                    speed
  • 60.002 Hz(16666) 推荐NSF频率
  • 60.099 Hz(16639) 实际N制式频率
  • 50.007 Hz(19997) 猜测的P制式频率

个人认为, 这个项目是在模拟器基础上追加的NSF功能, 所以按照60Hz或者50Hz即可. 除非本体是NSF播放器, 就得严格按照这个频率.

BANK切换

文件头拥有8个字节的数据分别对应了Mapper-031的BANK切换. 不过前提是这8字节非0: 只要有一个非零则表示使用了BANK切换. 应该在每次初始化前切换至默认BANK. 如果没有使用BANK切换, 则从加载地址一路加载至EOF.

值得注意的是, NSF的BANK数量是以4kb为窗口, 而NES的BANK数量是16kb为窗口的. 比如可能NSF文件是24kb大小, 之前的处理, 是针对16kb窗口大小进行的膜运算:

// 0101 .... .... .AAA  --    PPPP PPPP
const uint16_t count = famicom->rom_info.count_prgrom16kb * 4;
const uint16_t src = data;
sfc_load_prgrom_4k(famicom, addr & 0x07, src % count);

所以我们需要针膜的单位是4kb了! 虽然NSF的窗口大小是4kb, 但大小却不是一定以4kb对齐, 前面提到的比如大小是32kb-1时, 这里就需要额外处理:

// 0101 .... .... .AAA  --    PPPP PPPP
const uint16_t count = (famicom->rom_info.size_prgrom + 0x0fff)>>12;
const uint16_t src = data;
sfc_load_prgrom_4k(famicom, addr & 0x07, src%count);

初始化曲子

根据nesdev的介绍, 应该这么初始化曲子:

  • 清空$0000-$07FF, $6000-$7FFF. 这两大RAM区
  • 初始化APU: $4000-$4013写入$00, $4015先后写入$00, $0F
  • 帧计数器(帧序列器)设置为四步模式: $4017写入$40
  • 如果使用了BANK切换, 初始化
  • 累加器A设为需要播放的曲子的id(从零开始)
  • 变址器X表示是否为PAL值(PAL=1, NTSC=0)
  • 调用INIT程序

reset

文件头有一个表示NP制式的:

$07A    1   BYTE    PAL/NTSC bits
                bit 0: if clear, this is an NTSC tune
                bit 0: if set, this is a PAL tune
                bit 1: if set, this is a dual PAL/NTSC tune

一般地, 如果是同时支持PAL/NTSC制式的曲子($07A:D1为1)才会使用变址器X传递过来的参数. 所以具体什么频率还是外部控制.

初始化程序必须以RTS结束, 但是不要误解:

INIT:

xxx
xxx
   JSR
   xxx
   xxx
   RTS          <-   合法的RTS
xxx
RTS             <-   真正的RTS结束

播放曲子

初始化完毕, 按照目标频率调用. 一般地, 以RTS结束, 但是不是所有的都是. 所以播放曲子的流程是:

void play_nsf(int id) {
    load();
    init(id);
    while (1) play();
}

这里和前面提到会以RTS结束, 这样就会导致程序很有可能跳转到$0000执行$00BRK. 这自然不是我们想要的, 所以我们有两种做法:

  1. RTS指令做手脚, 检查到调用栈栈空了, 则停止全部CPU运转. 可能需要long_jmp()
  2. 调用INIT, PLAY程序前先执行我们自己写的一段小程序, 填充调用栈. 这样INIT就返回到自己写的一段代码中了.

如果是写NSF转成WAV之类的程序, 推荐使用1. 这里使用2就行. 不过值得注意的是: PLAY程序不一定调用RTS, 这样调用栈会越来越小, 最后自然是喜闻乐见的'栈溢出'. 应该怎么处理呢?

实际上可以不用处理, 因为只是一个8bit的栈指针, 爆了, 从零开始就行!

自定义程序

之前说到用自定义的程序调用INIT/PLAY防止RTS到未知的地方.可以这么写:

JSR ADDR                ; 调用指定程序

LOOP_POINT:
JMP LOOP_POINT          ; 无限循环

这是一个总共为6字节的程序, 形成一个无限循环, 等待下次调用. 那么问题来了, 这段地址放在哪里?

$0000-$07FF	$0800	2KB internal RAM
$0800-$0FFF	$0800	Mirrors of $0000-$07FF
$1000-$17FF	$0800   Mirrors of $0000-$07FF
$1800-$1FFF	$0800   Mirrors of $0000-$07FF
$2000-$2007	$0008	NES PPU registers
$2008-$3FFF	$1FF8	Mirrors of $2000-2007 (repeats every 8 bytes)
$4000-$4017	$0018	NES APU and I/O registers
$4018-$401F	$0008	APU and I/O functionality that is normally disabled. See CPU Test Mode.
$4020-$FFFF	$BFE0	Cartridge space: PRG ROM, PRG RAM, and mapper registers (See Note)

wiki为我们列出了NSF的情况:

$0000-$01EF
$01F0-$01FF (may be used internally by NSF player)
$0200-$07FF
$4015
$4040-$407F (if FDS is enabled)
$4090 (if FDS is enabled)
$4092 (if FDS is enabled)
$4800 (if Namco 163 is enabled)
$5205-$5206 (if MMC5 is enabled)
$5C00-$5FF5 (if MMC5 is enabled)
$6000-$FFF9

可选的第一个可以是$01F0-$01FF, 但是我们JSR实际就是使用了这部分区域, 还是不用的好.

再一个连续的地址就是大致为$4100, 为了方便, 将$4000-$4200这512字节定义为'自定义总线内存', 计入BANK映射器. 其中$4100开始的256字节是合法写入, 前面256字节仅仅是为了避免意外写入, 实际上可以去掉. 就算不去掉, 程序还是可以将这256字节用作其他地方, 比如保存什么数据文件头什么的:

// 初步BANK
famicom->prg_banks[0] = famicom->main_memory;
famicom->prg_banks[1] = famicom->main_memory;
famicom->prg_banks[4] = famicom->bus_memory;
famicom->prg_banks[6] = famicom->save_memory;
famicom->prg_banks[7] = famicom->save_memory + 4*1024;

所以跳转程序可以这么写:

/// <summary>
/// StepFC: NSF用长跳转
/// </summary>
/// <param name="address">The address.</param>
/// <param name="famicom">The famicom.</param>
void sfc_cpu_long_jmp(uint16_t address, sfc_famicom_t* famicom) {
famicom->registers.program_counter = 0x4100;
const uint32_t loop_point = 0x4103;
// JSR
famicom->bus_memory[0x100] = 0x20;
famicom->bus_memory[0x101] = (uint8_t)(address & 0xff);
famicom->bus_memory[0x102] = (uint8_t)(address >> 8);
// JMP $4103
famicom->bus_memory[0x103] = 0x4c;
famicom->bus_memory[0x104] = (uint8_t)(loop_point & 0xff);
famicom->bus_memory[0x105] = (uint8_t)(loop_point >> 8);
}

帧内处理

之前实现的'ez'模式是处理视频+音频的, 这里只有音频所以可以适当简化, 一般来说, 同步点就是垂直空白触发的NMI. 一般地, 程序会利用垂直同步播放音频保证同步. 而NSF播放'感觉'不用太精确:

播放一帧:
        运行1/4帧
        触发帧计数器

        运行1/4帧
        触发帧计数器

        运行1/4帧
        触发帧计数器

        运行1/8帧
        调用PLAY程序

        运行1/8帧
        触发帧计数器

        处理音频事件

好戏才刚刚开始呢

这样, 基础的NSF就可以播放了, 就直接'嫁接'在Mapper-031即可:

/// <summary>
/// StepFC: MAPPER 031 重置
/// </summary>
/// <param name="famicom">The famicom.</param>
/// <returns></returns>
extern sfc_ecode sfc_mapper_1F_reset(sfc_famicom_t* famicom) {
    const uint32_t size_prgrom = famicom->rom_info.size_prgrom;
    assert(size_prgrom && "bad size");
    // NSF的场合
    if (famicom->rom_info.song_count) {
        uint8_t* const bs_init = famicom->rom_info.bankswitch_init;
        uint64_t bankswi; memcpy(&bankswi, bs_init, sizeof(bankswi));
        // 使用切换
        if (bankswi) {
            assert(famicom->rom_info.load_addr == 0x8000 && "NOT IMPL");
            for (uint16_t i = 0; i != 8; ++i)
                sfc_nsf_switch(famicom, i, bs_init[i]);
        }
        // 直接载入
        else {
            assert(famicom->rom_info.load_addr >= 0x8000 && "NOT IMPL");
            // 起点
            uint16_t i = famicom->rom_info.load_addr >> 12;
            i = i < 8 ? 0 : i - 8;
            // 终点
            uint16_t count = ((size_prgrom + 0xfff) >> 12) + i;
            if (count > 8) count = 8;
            // 处理
            for (uint8_t data = 0; i != count; ++i, ++data)
                sfc_nsf_switch(famicom, i, data);
        }
    }
    // Mapper-031
    else {
        // PRG-ROM
        const int last = famicom->rom_info.size_prgrom >> 12;
        sfc_load_prgrom_4k(famicom, 7, last - 1);
    }
    // CHR-ROM
    for (int i = 0; i != 8; ++i)
        sfc_load_chrrom_1k(famicom, i, i);
    return SFC_ERROR_OK;
}

output

由于没有图像输出自然就是啥也没有.

.

但是通过wiki, 我们知道NSF自然是可以使用扩展音源的, 这不奇怪. 但是NSF允许混用不同的扩展音源! 例如2A03+VRC6+FDS+MMC5+FME7, 所以, 好戏才刚刚开始呢!

REF

Re: 从零开始的红白机模拟 - [03]CPU地址空间基础读写

STEP1: CPU地址空间: 基础读写 + Mapper000

让我们再跨一步吧.

6502汇编使用数字前面美元符号($)作为16进制的表示(8086则是在后面加‘h’)

CPU地址空间布局

先谈谈内存布局, 6502理论支持64KB的寻址空间, 但是小霸王服务器只有2kb的内存. 自然得说说内存布局

地址 大小 标记 描述
$0000 $800 RAM
$0800 $800 M RAM
$1000 $800 M RAM
$1800 $800 M RAM
$2000 8 Registers
$2008 $1FF8 R Registers
$4000 $20 Registers
$4020 $1FDF Expansion ROM
$6000 $2000 SRAM
$8000 $4000 PRG-ROM
$C000 $4000 PRG-ROM

M: 主内存2KB镜像, 比如读取$0800实际是读取$0000

R: PPU寄存器, 8字节步进镜像.

Registers: 一堆寄存器, 现在不用管.

Expansion ROM: 扩展ROM, 现在不用管

SRAM, PRG-ROM: 已经说过了

CPU中断

很遗憾, 自己在大学虽然是计算机系的, 但是不是计算机科学之类的学科, 而是靠近多媒体方向(比如计算机图形学, 音频之类的). 这就导致导致计算机硬件知识几乎没有, 只能靠脑补了.

6502有三种中断(按优先度排序, 越后面越优先):

  • IRQ/BRK
  • NMI
  • RESET

每一种中断都有一个向量. 向量是当中断触发时“转到”的指定位置的16位地址:

  • $FFFA-FFFB = NMI
  • $FFFC-FFFD = RESET
  • $FFFE-FFFF = IRQ/BRK
  1. IRQ - Interrupt Request 中断请求
    硬件中断请求被执(大致分为Mapper和APU两类)
  2. BRK - Break 中断指令
    当软件中断请求被执行(BRK指令)
  3. NMI - Non-Maskable Interrupt 不可屏蔽中断
    发生在每次垂直空白(VBlank)时, NMI在NTSC制式下刷新次数为 60次/秒, PAL为50次/秒
  4. RESET在(重新)启动时被触发. ROM被读入内存, 6502跳转至指定的RESET向量

也就是说程序一开始执行$FFFC-FFFD指向的地址

'低地址'在'低地址', '高地址'在'高地址'. 即低8位在$FFFC, 高8位在$FFFD.

Mapper000 - NROM

目前当然是实现Mapper000, 实际上也就是没有Mapper的意思:

  • 适用于16KB(NROM-128)或者32KB(NROM-256)的PRG-ROM
  • CPU $8000-$BFFF: ROM开始的16kb
  • CPU $C000-$FFFF: ROM最后的16kb

Mapper接口

就目前而且, 只需要一个重置接口:

/// <summary>
/// StepFC: Mapper接口
/// </summary>
typedef struct {
    // Mapper 重置
    sfc_ecode(*reset)(sfc_famicom_t*);

} sfc_mapper_t;

BANK

BANK是每个Mapper载入的单位, 在某种意义上也可以称为window. 根据内存布局, 可以把根据地址划分为每8KB一个BANK, 一共8个区块:

  • 0: [$0000, $2000) 系统主内存
  • 1: [$2000, $4000) PPU 寄存器
  • 2: [$4000, $6000) pAPU寄存器以及扩展区域
  • 3: [$6000, $8000) 存档用SRAM区
  • 剩下的全是 程序代码区 PRG-ROM

也就是:

uint8_t* prg_banks[0x10000 >> 13];

Mapper000 - Reset

根据所述内容, Mapper000可以实现为:

/// <summary>
/// 实用函数-StepFC: 载入8k PRG-ROM
/// </summary>
/// <param name="famicom">The famicom.</param>
/// <param name="des">The DES.</param>
/// <param name="src">The source.</param>
static inline void sfc_load_prgrom_8k(
    sfc_famicom_t* famicom, int des, int src) {
    famicom->prg_banks[4 + des] = famicom->rom_info.data_prgrom + 8 * 1024 * src;
}
/// <summary>
/// StepFC: MAPPER 000 - NROM 重置
/// </summary>
/// <param name="famicom">The famicom.</param>
/// <returns></returns>
static sfc_ecode sfc_mapper_00_reset(sfc_famicom_t* famicom) {
    assert(famicom->rom_info.count_prgrom16kb && "bad count");
    assert(famicom->rom_info.count_prgrom16kb <= 2 && "bad count");
    // 16KB -> 载入 $8000-$BFFF, $C000-$FFFF 为镜像
    const int id2 = famicom->rom_info.count_prgrom16kb & 2;
    // 32KB -> 载入 $8000-$FFFF
    sfc_load_prgrom_8k(famicom, 0, 0);
    sfc_load_prgrom_8k(famicom, 1, 1);
    sfc_load_prgrom_8k(famicom, 2, id2 + 0);
    sfc_load_prgrom_8k(famicom, 3, id2 + 1);
    return SFC_ERROR_OK;
}

这里使用到了C99的inline, 和C++的inline略有区别. 还有就是, 利用位操作减少分支判断也是编码技巧之一.

地址空间读写

根据BANK, 就可以非常简单地实现读:

    switch (address >> 13)
    {
    case 0:
        // 高三位为0: [$0000, $2000): 系统主内存, 4次镜像
        return famicom->main_memory[address & (uint16_t)0x07ff];
    case 1:
        // 高三位为1, [$2000, $4000): PPU寄存器, 8字节步进镜像
        assert(!"NOT IMPL");
        return 0;
    case 2:
        // 高三位为2, [$4000, $6000): pAPU寄存器 扩展ROM区
        assert(!"NOT IMPL");
        return 0;
    case 3:
        // 高三位为3, [$6000, $8000): 存档 SRAM区
        return famicom->save_memory[address & (uint16_t)0x1fff];
    case 4: case 5: case 6: case 7:
        // 高一位为1, [$8000, $10000) 程序PRG-ROM区
        return famicom->prg_banks[address >> 13][address & (uint16_t)0x1fff];
    }

和写:

    switch (address >> 13)
    {
    case 0:
        // 高三位为0: [$0000, $2000): 系统主内存, 4次镜像
        famicom->main_memory[address & (uint16_t)0x07ff] = data;
        return;
    case 1:
        // 高三位为1, [$2000, $4000): PPU寄存器, 8字节步进镜像
        assert(!"NOT IMPL");
        return;
    case 2:
        // 高三位为2, [$4000, $6000): pAPU寄存器 扩展ROM区
        assert(!"NOT IMPL");
        return;
    case 3:
        // 高三位为3, [$6000, $8000): 存档 SRAM区
        famicom->save_memory[address & (uint16_t)0x1fff] = data;
        return;
    case 4: case 5: case 6: case 7:
        // 高一位为1, [$8000, $10000) 程序PRG-ROM区
        assert(!"WARNING: PRG-ROM");
        famicom->prg_banks[address >> 13][address & (uint16_t)0x1fff] = data;
        return;
    }

还没实现的用断言即可. 写入也是同样的. 不过既然是PRG-ROM, 那么写入加个断言好了.

输出各个中断向量

output

可以看出RESET是跳转$C004.

项目地址Github-StepFC-Step1

作业

  • 基础: RESET跳转地址的那一个字节是啥? 即执行的第一个指令的操作码是?
  • 从零开始: 从零开始实现自己的模拟器吧

REF

Re: 从零开始的红白机模拟 - [24] ScaleNx

滤镜与滤波器

这一步就来讲讲滤镜与滤波器, 都可以叫做filter, 总的来说可以提升用户体验.

滤镜

滤镜有很多种, 比如模拟CRT显示器, 但是这里不是说的这么一类. 由于原生分辨率只有256x240, 对于现在来说太小了, 我们可以把它放大一点, 使用的算法称为缩放算法(scaling algorithms), 处理后效果就简单地称为滤镜了.

这里采用被维基标注为public domain测试用卡片:

test_nn

Filter

例如D3D11_FILTER有这么一个枚举D3D11_FILTER_MIN_MAG_POINT_MIP_LINEAR, 其中POINTLINEAR就是代表了两种最基础的过滤方式: 点过滤和线性过滤.

一般缩放算法会采用插值, 这两个过滤方式对应的就是最邻插值和线性插值了.

最邻插值

也是之前采用的插值算法, 下面是放大3倍后效果

point

效果中规中矩, 也没有什么特别之处, 优点就是实现简单, 图像API本身就会提供

线性插值

这个方法实现也比较简单, 但是放大三倍后

linear

就糊了.

当然, 还有很多插值方法. 比如D2D就还提供了什么各向异性过滤等高质量的插值算法, 我们所谓的"像素风格"图像是不适合用常规插值算法的, 因为这些算法是针对平滑像素.

像素风格缩放算法

维基列了相当多的算法, 这里就挑选几个看看.

通过粗略查看, 我们可以知道很多都是将原生数据放大至整数倍, 由于我们显示的话可能会显示成任意分辨率, 所以逻辑是:

原生->像素风格缩放算法->插值到任意分辨率

中间有两次缩放, 我们可以为用户提供选项, 最后一步可以考虑用线性插值或者最邻插值, 当然也是可以选的

后面还有4倍的一些算法, 4x4就是16倍, 用CPU处理自然略慢, 就用着色器处理吧, 这里就用比较熟悉的HLSL实现.

Scale2x

www.scale2x.it

看名字就知道这个算法是将分辨率提高至2倍

 A          提高至两倍      
CPB          P:12
 D             34
                
            1=P; 2=P; 3=P; 4=P;
            IF C==A AND C!=D AND A!=B => 1=A
            IF A==B AND A!=C AND B!=D => 2=B
            IF D==C AND D!=B AND C!=A => 3=C
            IF B==D AND B!=A AND D!=C => 4=D

可以看出这个算法并没有生成新的颜色, 例如P生成的左上角点, 可以认为是夹在AC**, 看情况变成AC.

效果:

scale2x

(进行scale2x后再进行了4倍最邻插值)

scale2x_vs

(与8倍最邻插值比较, 可以看到4个角落并没有处理)

可以看出对于转角处有所处理, 稍微平滑了一点, 由于没有增加新的颜色, 所以处理后还是像素风满满, 不认真观察的话, 就会以为本身就是这样. 效果最差的应该是左下角的, 像坐标轴的那个, 也被强制平滑了.

像素着色器的实现

float4 scale2x(uint2 pos) {
    const uint2 real_pos = pos / 2;
    const float4 a = InputTexture[real_pos - uint2(0, 1)];
    const float4 b = InputTexture[real_pos + uint2(1, 0)];
    const float4 c = InputTexture[real_pos - uint2(1, 0)];
    const float4 d = InputTexture[real_pos + uint2(0, 1)];
    const float4 p = InputTexture[real_pos];

    float4 color;
    switch ((pos.x & 1) | ((pos.y & 1) << 1))
    {
    case 0:
        // IF C==A AND C!=D AND A!=B => 1=A
        color = (c == a && c != d && a != b) ? a : p;
        break;
    case 1:
        // IF A==B AND A!=C AND B!=D => 2=B
        color = (a == b && a != c && b != d) ? b : p;
        break;
    case 2:
        // IF D==C AND D!=B AND C!=A => 3=C
        color = (d == c && d != b && c != a) ? c : p;
        break;
    case 3:
        // IF B==D AND B!=A AND D!=C => 4=D
        color = (b == d && b != a && d != c) ? d : p ;
        break;
    }
    return color;
}

着色器, 特别是像素着色器应该尽可能地去掉分支, 这个实现包含了大量的分支. 可以考虑使用DX11带来的计算着色器(D2D也能用), 以减少部分分支. (然后传入索引减少75%CPU-GPU传输时间, 随便还能优化分支判断.) 后面的先不说, 计算着色器可以一次输出多次像素, 实现为:

void scale2x(uint2 pos) {
    const uint2 pos0 = pos.xy * 2;
    const uint2 pos1 = pos0 + uint2(1, 0);
    const uint2 pos2 = pos0 + uint2(0, 1);
    const uint2 pos3 = pos0 + uint2(1, 1);

    // ABC
    // DEF
    // GHI

    const float4 E = InputTexture[pos];
    const float4 B = InputTexture[pos - uint2(0, 1)];
    const float4 D = InputTexture[pos - uint2(1, 0)];
    const float4 F = InputTexture[pos + uint2(1, 0)];
    const float4 H = InputTexture[pos + uint2(0, 1)];

    const float4 CC = float4(1, 1, 1, 1) - (B - H) * (D - F);
    

    float4 E0, E1, E2, E3;
    if (CC.x + CC.y + CC.z != 3.0) {
        E0 = D == B ? D : E;
        E1 = B == F ? F : E;
        E2 = D == H ? D : E;
        E3 = H == F ? F : E;
    }
    else {
        E0 = E;
        E1 = E;
        E2 = E;
        E3 = E;
    }

    OutputTexture[pos0] = E0;
    OutputTexture[pos1] = E1;
    OutputTexture[pos2] = E2;
    OutputTexture[pos3] = E3;
}

线程量用的是SM5.0支持的[32, 32, 1], 这次总体效率提升大致在, emmmm, -50%左右. 是的, 效率降低了, 也不知道是不是D2D自带框架有问题. 当然测试发现, CS更适合重计算(128x64比较小), 不然大头花在其他地方了.

When is a compute shader more efficient than a pixel shader for image filtering?

Scale3x

这就是Scale2x的三倍版本, 也有Scale4x不过其实就是连续两次Scale2x.

ABC           E0 E1 E2
DEF       E:  E3 E4 E5
GHI           E6 E7 E8

E0 = D == B && B != F && D != H ? D : E;
E1 = (D == B && B != F && D != H && E != C) || (B == F && B != D && F != H && E != A) ? B : E;
E2 = B == F && B != D && F != H ? F : E;
E3 = (D == B && B != F && D != H && E != G) || (D == H && D != B && H != F && E != A) ? D : E;
E4 = E
E5 = (B == F && B != D && F != H && E != I) || (H == F && D != H && B != F && E != C) ? F : E;
E6 = D == H && D != B && H != F ? D : E;
E7 = (D == H && D != B && H != F && E != I) || (H == F && D != H && B != F && E != G) ? H : E;
E8 = H == F && D != H && B != F ? F : E;

同样, 直接"拿来"未做针对着色器的优化. 效果如下...同样还是像素着色器效率更好, 不过差距小了一点, 256x240的话, 效率就差不多了.

scale3x

(scale3x后2倍最邻插值)

scale3x_vs

(与6倍最邻插值比较)

可也看出"圆"处理得比2x好, 其他的很一般了. 特别是中间的那个分割线, 还以为自己实现有问题, 找了很久, 然后用作者自己写的程序跑了一下还是一样的, 2X也有这种情况但是不明显, 估计放大倍数越大越明显.

ScaleNx

可以看出ScaleNx优点是: 容易实现, 不会引入新的颜色. 不会引入新的颜色这一点算是有得有失吧, 处理后还是像素风. 比如有一个很不错的算法只能放大到4倍, 现在要放大至8倍, 可以先用Scale2x处理再用那个算法.

还有有人(Sp00kyFox)根据ScaleNx改进(效果上)算法, 称为ScaleNxSFX, 增加了对于旁边点的判断范围, 以及基于Scale3x深度改进(It was originally intended as an improvement upon Scale3x but became a new filter in its own right)而形成的, 还有ScaleFX-rAA和ScaleFX-Hybrid两个额外处理. 这个算法比较新, 到最近(本文初稿于2018-09-24)作者似乎还在更新, 所以效果应该不错, 没准还有新的算法会在这个主题出现.

以及guest(r)基于Scale2x派生的Scale2xPlus, 这家伙在07年派生出这个算法, 但是居然也出现在了那个主题讨论中.

REF

Re: 从零开始的红白机模拟 - [15]音频简介

STEP8: 基本音频播放

目前为止, Mapper 0的游戏应该能够顺利玩了, 就差声音了. 所以接下来就是对于音频的模拟, 算是换一个口味轻松轻松(实际并不轻松)吧. 个人还是挺喜欢8Bit风格的音乐的!

本步骤中, 如果没有特殊说明则表示是NTSC制式

这里使用音频API是XAudio2.7, 需要DX的运行时. XAudio2.8是Win8自带的, 不过考虑到Win7还是使用2.7. 不过这样导致Win8以及后面的系统, 也需要下载DX的运行时库. 并且由于2.8接口上部分不兼容2.7, 但是还是用一个接口名, 这就很难受了, 只能说微软S那啥.

播放音频

同样因为可能读者拥有自己了解的音频API, 这里不对音频API做过多解释, 不过一般来说音频API就是读取样本缓存然后播放, 不考虑特效的话, 还是很简单的.

这一节简单介绍一下各个部分的特性.

APU

FC的CPU叫做2A03, 之前介绍了比起6502缺了点啥, 现在就是说多了点啥: pAPU - pseudo Audio Processing Unit

之所以有一个前缀p是因为没有专门处理音频的物理芯片 -- 这句话其实有歧义. 其实CPU和pAPU是一个芯片, 可以认为 pAPU + 6502 = 2A03

默认情况下, pAPU支持5个声道:

  1. 两个方波声道
  2. 一个三角波声道
  3. 一个利用线性反馈移位寄存器的噪声声道
  4. 一个用来播放DPCM的增量调制声道

还有就是Mapper额外搭载的, 这里不谈. 至于

  • PCM(脉冲编码调制)
  • APCM(自适应脉冲编码调制)
  • DPCM(差分脉冲编码调制)
  • ADPCM(自适应差分脉冲编码调制)

可以自行了解之间的区别, 不过这里为了方便, 样本格式采用IEEE单精度浮点表示.

方波

***   ***   ***   ***   ***   ***

---------------------------------

   ***   ***   ***   ***   ***

振幅浮点表示就是1.0和-1.0, 然后交错起来(也可以是0.0和1.0, 更为方便).

占空比

感觉和矩形/方形的有点联系, 50%才叫真正的"方波", 2A03中方波拥有4种占空比(Duty Cycle):

  1. 0 1 0 0 0 0 0 0 [12.5%]
  2. 0 1 1 0 0 0 0 0 [25%]
  3. 0 1 1 1 1 0 0 0 [50%]
  4. 1 0 0 1 1 1 1 1 [25% 反相]

反相的话, 单独听是听不出与不反相有啥区别的. 不过通过混音就可能会有点区别

音量

方波是有音量控制的, 最大15.

三角波澜T-Wave

*       *       *       *
 *     * *     * *     * *
--*---*---*---*---*---*---*---
   * *     * *     * *     * 
    *       *       *       *

振幅线性地在1.0和-1.0之间来回振荡(也可以是0.0和1.0, 更为方便)

三角波没有音量控制, 取而代之的是更为细腻的长度(时长)播放控制.

噪声

其实直接播放上面的就是噪声了(笑). 噪声声道通过一个伪随机的位发生器发出噪声. 由于是1bit随机以及最大音量15, 生成和方波类似, 只不过是从预设到随机了.

线性反馈移位寄存器(LFSR)

噪声声道有一个15bit的LFSR, 每次输出最低的bit位.算法如下:

  1. 将D0位与D1做异或运算
  2. 如果是短模式则是D0位和D6位做异或运算
  3. LFSR右移一位, 并将之前运算结果作为最高位(D14)

DMC

用于DPCM生成, 2A03的ΔPCM大致解码流程:

  • 一个字节为一次循环, 从D0到D7
  • 如果是1则Δ为+2, 否则Δ就是-2.
  • 是一个7bit的数据, 超过会被钳制.

PCM

由于DMC相关寄存器特性, DMC声道也能播放7bit的PCM数据. 不过, 如果说播放DPCM是硬件解码的话, 播放PCM就是软件解码了. 播放完全由CPU控制, 会消耗大量CPU资源.

状态机实现

模拟器对于音频的实现主流的方案是利用目前数据生成样本数据再传给音频API, 同时为了避免出现'饥饿'(starvation, 声部匮乏, 出现'卡音'现象)会缓存几帧的音频再播放. 不过这里嘛...

最初的音频实现, 我们实现简单点: 状态机. 将各个部分实现为状态的切换. 比如:

  • 方波#1, 频率500Hz, 音量12, 占空比50%

这样会一直地播放这个方波, 直到方波#1的状态被改变. 音量为0的话记为停止播放:

  • 方波#1, 频率500Hz, 音量0, 占空比50%

.

三角波有点特殊, 没有音量, 所以就频率0Hz定为静音(或者20Hz~20kHz有效). 还有一点由于三角波的特殊性, 我们需要完整地播完一个'三角波', 避免出现"爆音"(状态机特有)

细节部分就在下节介绍.

REF

Re: 从零开始的红白机模拟 - [22]规范存储

StepB: 规范存储

自己在前面即时存档中, 非常暴力地几乎把所有东西都存进去了, 这一节自然得规范一下!

SRAM

有一些ROM使用SRAM进行存档, 这是非常基本的存档方式, 首先自然是实现这个, 不过你得找一个支持SRAM的游戏(好在MM就是)!

保存

这个也没什么可说了, 如果检测到ROM会使用SRAM, 那么在程序退出或者说切换游戏ROM时, 进行保存. 我们可以针对ROM计算出HASH值, 比如CRC32(自己使用的crc32b, 是搜索到第一个public domain代码). 然后保存文件名就是CRC32值即可.

当然, 只需要计算PRG-ROM的CRC32即可, 不然稍微修改文件头就不一样了.

读取

然后在游戏加载时读取即可. 查找之前对应的文件即可.

数据布局

很简单的文件头+具体数据即可

16字节文件头
                -StepFC-
                SRAMWRAM
8KB实际数据
                01010101
                ........

文件头基本就是用来检测是不是合法的文件而已, 甚至可以连文件头都不需要.

接口

当然, 核心不处理文件读写, 全靠接口:

// 保存SRAM
void(*save_sram)(void*, const sfc_rom_info_t*, const uint8_t*);
// 读取SRAM
void(*load_sram)(void*, const sfc_rom_info_t*, uint8_t*);

很简单就实现了!

即时存档/读档

这就是主要内容了, 之前是将所有内容一股脑地存进去了, 现在就得规范一下, 这个东西越后面完成越简单, 因为核心部分不会再修改了.

换句话说, 很有可能随着模拟器的编写, 这部分可能会发生翻天覆地的变化.

同样核心部分不处理文件读写, 但是又实在需要写入流, 自然就交给接口完成啦!

// 状态保存 写入流
void(*sl_write_stream)(void*, const uint8_t*, uint32_t len);
// 状态读取 读取流
void(*sl_read_stream)(void*, uint8_t*, uint32_t len);

当然是流数据即可, 可以用类似std::vector<uint8_t>保存(甚至可以通过网络数据流!), 不用非得是文件流. 这就是接口的好处.

逻辑是:

  • 外部代码准备数据
  • 调用sfc_famicom_save/load_state
  • 外部代码对于数据的处理
// 保存状态
void sfc_famicom_save_state(sfc_famicom_t*);
// 读取状态
sfc_ecode sfc_famicom_load_state(sfc_famicom_t*);

需要存什么?

那么到底需要存什么东西? 简单地说就是"状态", 现在很多状态可以直接保存结构体数据就行, 但是, 但是很有可能修改数据布局, 导致这个版本的读取不了上一个版本的.

我们又不可能预知未来, 修改是必然的, 所以可以考虑使用版本号区分, 正式释出的时候可以兼容上一个(文件)版本的文件就非常完美了. 最完美的是兼容所有历史(文件)版本, 这个包袱有点重可以不实现.

大小端

又到异端审判的时间到了:

  • (吃鸡蛋)是大端派还是小端派?
  • 答曰: 混合序(middle-endian).
  • 烧了

大小端问题在文件储存上问题比较突出, 以什么字节序保存也是一个令人头疼的问题, x86采用的小端模式自己感觉还算科学, 比如char16_t如果是保存的是ascii码的话, char16_t*强转char*就行, 步进变成2而已. 什么C++模板走开点, 重解释才是王道! 无奈网络传输一般用大端传输(大概是数字文字表示是左边更大)

这里就直接采用不考虑大小端的情况, 直接保存, 以后再考虑大小端.

RAM

RAM自然是保存的重点对象, 下面列出需要保存的RAM数据:

  • CPU主RAM, 2KB的那个
  • CPU的SRAM/WRAM, 8KB的那个
  • PPU的VRAM, 2KB的
  • PPU的EX-VRAM, 如果是4屏幕模式的话, 卡带上自带了2KB的VRAM
  • PRG-RAM CHR-RAM

关于SRAM的说明, ROM会有SRAM的标志位(电池供电), 但是没有WRAM的标志位(FC供电). 比如超级马里奥3使用了WRAM, 但是ROM里面是没有SRAM标记的.

当然, WRAM可能不止8KB, 内部甚至允许切换, 所以SRAM/WRAM分开处理比较好.

再比如, 之前Mapper1是支持切换WRAM的, 不过因为是变种就没有实现, 这是以后的事情.

不过嘛, 无脑保存就行, 反正不缺8KB的磁盘空间, 甚至可以压缩.

PRG-RAM CHR-RAM

目前完成的4个Mapper寄存器都是基于对高位地址写入完成的, 也就是说这些Mapper在高地址是没有PRG-RAM的, 最多是WRAM视为PRG-RAM. 但是以后, 可能有(其实Mapper005-MMC5就支持)高地址的PRG-RAM. 准备好接口就行.

CHR-RAM这个就太常见了, 甚至Mapper0都有CHR-RAM的情况.

所以, PRG-RAM以及CHR-RAM应该是Mapper来处理的, 应该提供相应接口给Mapper:

// 写入RAM到流
void(*write_ram_to_stream)(sfc_famicom_t*);
// 从流读取RAM
void(*read_ream_from_stream)(sfc_famicom_t*);

例如大部分Mapper其实没有特殊的CHR-RAM处理, 可以公用一个接口, 例如写就可以这么干:

    // PRG-RAM 不考虑

    // 没有CHR-ROM则表明全是CHR-RAM
    if (!famicom->rom_info.count_chrrom_8kb) {
        famicom->interfaces.sl_write_stream(
            famicom->argument,
            famicom->rom_info.data_chrrom,
            8 * 1024
        );
    }

读也是类似的.

再例如Mapper 074, 国产膜改版的MMC3. CHR BANK的$08, $09号是CHR-RAM. Mapper读写只需要保存这部分即可.

文件头

大致布局, 当然最好是保证每块内容16字节对齐.:

判断用文件头
基础用文件头
RAM区
文件尾基础信息
文件尾详细信息

详细内容布局:

// 判断用文件头 16字节. 汉字是用UTF-16小端编码
                "-StepFC-SL大法好"


// 基础数据文件头 用于保存小几率修改的数据, 目前布局如下:

/// <summary>
/// 状态基本数据用文件头
/// </summary>
typedef struct {
    // 大小端检查用, 可以检测该位转换到其他平台
    uint32_t        endian_01020304;
    // 本结构体大小
    uint32_t        size_of_this;
    // 主 版本号
    uint8_t         major_version;
    // 副 版本号
    uint8_t         minor_version;
    // RAM 用掩码
    uint8_t         ram_mask;
    // 未使用
    uint8_t         unused;
    // 当前帧ID
    uint32_t        frame_counter;
    // CPU 周期数 低4字节
    uint32_t        cpu_cycle_lo;
    // CPU 周期数 高4字节
    uint32_t        cpu_cycle_hi;
    // APU 周期数 低4字节
    uint32_t        apu_cycle_lo;
    // APU 周期数 高4字节
    uint32_t        apu_cycle_hi;
    // PPU 周期数 低4字节
    uint32_t        ppu_cycle_lo;
    // PPU 周期数 高4字节
    uint32_t        ppu_cycle_hi;
    // 16字节对齐/ 保留用
    uint8_t         reserved1[8];
    // CPU BANK 高位偏移
    uint32_t        cpu_hi_banks_offset[4];
    // PPU BANK 低位偏移
    uint32_t        ppu_lo_banks_offset[8];
    // PPU BANK 高位偏移
    uint32_t        ppu_hi_banks_offset[8];

} sfc_state_header_basic_t;

// RAM MASK位如下
enum SFC_RAM_MASK {
    // 主RAM
    SL_CPU_MAIN_RAM_2KB = 1 << 0,
    // SRAM
    SL_CPU_SRAM_8KB = 1 << 1,
    // 主VRAM
    SL_PPU_VRAM_2KB = 1 << 2,
    // 额外VRAM
    SL_PPU_EXVRAM_2KB = 1 << 3,
};
// 从低到高表示随后的数据块
//                 D0为1时:    Main-RAM 2KB 
//                 D1为1时:    SRAM 8KB
//                 D2为1时:    VRAM 2KB
//                 D3为1时:    EX-VRAM 2KB

// 这4个RAM块结束后是Mapper的RAM块
//                 Mapper RAM块 大小布局由Mapper确定


// 最后是文件尾, 保存一些大概率修改的数据

// 文件尾段大小, 保存数据的大小, 版本不匹配时利用这个可以尝试强制读取.

/// <summary>
/// 文件尾 用于存放易变数据
/// </summary>
typedef struct {
    // CPU 数据段大小
    uint32_t        cpu_seg_len;
    // PPU 数据段大小
    uint32_t        ppu_seg_len;
    // APU 数据段大小
    uint32_t        apu_seg_len;
    // Mapper 数据段大小
    uint32_t        mapper_seg_len;
    // 输入设备 数据段大小
    uint32_t        input_seg_len;
    // 保留16字节对齐
    uint32_t        reserved1[3];

} sfc_state_tail_len_t;


// 文件尾 数据区, 这部分就是例如寄存器状态什么的, 布局容易修改的


/// <summary>
/// 文件尾 用于存放易变数据
/// </summary>
typedef struct {
    // CPU 寄存器
    sfc_cpu_register_t          cpu_data;
    // PPU 数据
    sfc_ppu_data_t              ppu_data;
    // APU 数据
    sfc_apu_register_t          apu_data;
    // Mapper 数据
    sfc_mapper_buffer_t         mapper_data;
    // 输入设备数据
    sfc_sl_input_data_t         input_data;

} sfc_state_tail_data_t;

存档就填写, 读档就读取, 将布局规划一下就简单了.

目前没有处理特殊WRAM的情况(交给Mapper后期完善), 不过测试了几个ROM都没问题, 说明这个情况比较罕见.

contra

魂斗罗没有CHR-ROM, 需要保存8KB的CHR-RAM. 不过, 魂斗罗的鼓声实在是一大亮点!

项目地址Github-StepFC-StepB

作业

  • 基础: 定义自己的文件格式和布局!
  • 从零开始: 从零开始自己的模拟器吧!

REF

Re: 从零开始的红白机模拟 - [32] FDS 婉转

FC磁碟机

项目地址

Family Computer Disk System(FDS)是一种基于FC的磁碟机系统, 其中部分探讨会在下一步《全部成为F》简述.

这里仅仅是完成扩展音源部分, 自己并没有完全模拟FDS的计划. 毕竟从未见过, 甚至都是接触过模拟器后才发现才有这玩意. 没有感情, 对, 我是一台没有感情的程序猿.

可以看出FDS与其他不同的地方是: 别的扩展音源是卡带上的硬件, FDS音源是磁碟机上的. 所以FDS的游戏自然是: 不用白不用, 很多FDS的游戏都用上了.

FDS

iNES为FDS分配的Mapper编号是20, 不过仅仅作为保留用, 让模拟器知道自己在模拟FDS而已.

FDS扩展音源只有一个声道. 简单地说, 就是将一个长度64的自定义波形信息, 安装预定的方式进行调制. 所以别说模拟乐器了, 完全可以用来模拟人声. 就是精度差点(6bit).

Master I/O enable ($4023)

7  bit  0
---------
xxxx xxSD
       ||
       |+- Enable disk I/O registers
       +-- Enable sound I/O registers

D1位写入1才能让音频相关寄存器启动(懒得去实现).

Wavetable RAM ($4040-$407F)

7  bit  0  (read/write)
---- ----
OOSS SSSS
|||| ||||
||++-++++- Sample
++-------- Returns 01 on read, likely from open bus

这里保存了64步长的波形数据, 这部分可读, 不过声音播放中不可写. 读取时高两位是01, 所以实现上可以写入01SS SSSS.

Volume envelope ($4080)

7  bit  0  (write; read through $4090)
---- ----
MDVV VVVV
|||| ||||
||++-++++- (M=0) Volume envelope speed
||         (M=1) Volume gain and envelope speed.
|+-------- Volume change direction (0: decrease; 1: increase)
+--------- Volume envelope mode (0: on; 1: off)
  • M=0: 音量包络速度
  • M=1: 音量增益包络速度

超过32是有效的, 但是输出前会被钳制到32(根据全文, 超过32的数据仅仅可能用于数据读取).

Frequency low ($4082)

7  bit  0  (write)
---- ----
FFFF FFFF
|||| ||||
++++-++++- Bits 0-7 of frequency

Frequency high ($4083)

7  bit  0  (write)
---- ----
MExx FFFF
||   ||||
||   ++++- Bits 8-11 of frequency
|+-------- Disable volume and sweep envelopes (but not modulation)
+--------- Halt waveform and reset phase to 0, disable envelopes

暂停波形的话, 会一直输出$4040的值, 也就是可以认为: 重置相位, 然后播发周期无穷大-频率为0Hz.

D6位仅仅会暂停包络而不是波形, 不过会重置这两个的计时器.

Mod envelope ($4084)

调制器(modulator), 或者调制(modulation)

7  bit  0  (write; read through $4092)
---- ----
MDSS SSSS
|||| ||||
||++-++++- (M=0) Mod envelope speed
||         (M=1) Mod gain and envelope speed.
|+-------- Mod envelope direction (0: decrease; 1: increase)
+--------- Mod envelope mode (0: on; 1: off)
  • M=0: 调制包络速度
  • M=1: 调制增益包络速度

Mod counter ($4085)

7  bit  0  (write)
---- ----
xBBB BBBB
 ||| ||||
 +++-++++- Mod counter (7-bit signed; minimum $40; maximum $3F)

这是一个7bit有符号的数据,

Mod frequency low ($4086)

7  bit  0  (write)
---- ----
FFFF FFFF
|||| ||||
++++-++++- Bits 0-7 of modulation unit frequency

Mod frequency high ($4087)

7  bit  0  (write)
---- ----
Dxxx FFFF
|    ||||
|    ++++- Bits 8-11 of modulation frequency
+--------- Disable modulation

最高的禁用位能够禁用调制, 同样如果12bit的频率为0也能禁用调制.

通过禁用位暂停调制后才能够写入调制表($4088)

Mod table write ($4088)

7  bit  0  (write)
---- ----
xxxx xMMM
      |||
      +++- Modulation input

必须通过禁用$4087的相关禁用位才能正常写入, 否则无效.

调制表是一个64长的环形缓冲区, 每次会写入表中相邻的两位, 也就是说连续写入32次就能完整地写入一次(当然每次写入会推进位置).

Wave write / master volume ($4089)

7  bit  0  (write)
---- ----
Wxxx xxVV
|      ||
|      ++- Master volume (0: full; 1: 2/3; 2: 2/4; 3: 2/5)
|          Output volume = current volume (see $4080 above) * master volume
+--------- Wavetable write enable
           (0: write protect RAM; 1: write enable RAM and hold channel)

1, 3, 4, 5最小公倍数为 30.

D7为1时, 波形会保持当前的输出, 直到D7=0(自己的实现是在推进波索引时检查是否输出).

Envelope speed ($408A)

7  bit  0  (write)
---- ----
SSSS SSSS
|||| ||||
++++-++++- Sets speed of volume envelope and sweep envelope
           (0: disable them)

为音量/调制包络设置时钟倍频, 很少会使用(不过不要小看NSF). BIOS将其初始化到$E8.

Volume gain ($4090)

7  bit  0  (read; write through $4080)
---- ----
OOVV VVVV
|||| ||||
||++-++++- Current volume gain level
++-------- Returns 01 on read, likely from open bus

Mod gain ($4092)

7  bit  0  (read; write through $4084)
---- ----
OOVV VVVV
|||| ||||
||++-++++- Current mod gain level
++-------- Returns 01 on read, likely from open bus

频率计算

包络, 在n个CPU周期后, 包络单元会tick一次:

n = CPU clocks per tick
e = envelope speed ($4080/4084)
m = master envelope speed ($408A)

n =  8 * (e + 1) * m

由于FDS只在日本发售, 自然是N制式. 3+6+8=17, 看来必须用32bit整数保存.

一般地, 写入相关寄存器重置计时器, 要到下一次Tick才能正常重置(貌似没有实现).

波形表, 波输出和调制器内部拥有一个12bit的频率值. 还有一个16bit的累加器, 通过每次CPU时钟累加频率值, 超过16bit范围时推进一次位置.

f = frequency of tick
n = CPU clock rate (≈1789773 Hz)
p = current pitch value ($4082/$4083 or $4086/$4087) plus modulation if wave output

f = n * p / 65536

f*: 对于波形表的频率需要再除以64

TICK

包络单元. 启用时, 会被自身计时器tick, 根据$4080/$4084:D6位:

  • 增: 如果增益小于32就+1
  • 减: 如果增益大于零就-1
  • 增益可以手动设置超过32

调制单元, 当调制单元被tick时, 会被根据调制计数器当前的调制表前进指定的次数:

0 = %000 -->  0
1 = %001 --> +1
2 = %010 --> +2
3 = %011 --> +4
4 = %100 --> reset to 0
5 = %101 --> -4
6 = %110 --> -2
7 = %111 --> -1

调制计数器($4085)是一个7bit有符号的数据, 于是就有-64 - 1 = 63之类的操作. 实际实现中我们可以利用8bit有符号int8_t实现: -128 - 2 = 126.

调制计数器实际使用中还是比较麻烦的, wiki都直接给出代码:

// pitch   = $4082/4083 (12-bit unsigned pitch value)
// counter = $4085 (7-bit signed mod counter)
// gain    = $4084 (6-bit unsigned mod gain)

// 1. multiply counter by gain, lose lowest 4 bits of result but "round" in a strange way
temp = counter * gain;
remainder = temp & 0xF;
temp >>= 4;
if ((remainder > 0) && ((temp & 0x80) == 0))
{
    if (counter < 0) temp -= 1;
    else temp += 2;
}

// 2. wrap if a certain range is exceeded
if (temp >= 192) temp -= 256;
else if (temp < -64) temp += 256;

// 3. multiply result by pitch, then round to nearest while dropping 6 bits
temp = pitch * temp;
remainder = temp & 0x3F;
temp >>= 6;
if (remainder >= 32) temp += 1;

// final mod result is in temp
wave_pitch = pitch + temp;

最终值上限可能超过12bit, 低于0的话会(presumably)被钳制到0.

大致过程

包络:

  1. 每经过8 * (e + 1) * mCPU周期会Tick一次音量/调制包络
  2. 目的是为了调制音量/调制增益
  3. 注意相关禁用位$4080 $4084 :D7才能进行增益处理
  4. 这里写出音量包络的处理, 调制包络也是非常相似的
/// <summary>
/// StepFC: FDS Tick一次音量包络
/// </summary>
/// <param name="famicom">The famicom.</param>
void sfc_fds_tick_volenv(sfc_famicom_t* famicom) {
    sfc_fds1_data_t* const fds = &famicom->apu.fds;
    assert(fds->flags_4083 == 0);
    assert((fds->modenv_4084 & SFC_FDS_4084_GainMode) == 0);
    // 增
    if (fds->modenv_4084 & SFC_FDS_4084_Increase) {
        if (fds->modenv_gain < 32) {
            fds->modenv_gain++;
            sfc_fds_update_modenv_gain(famicom);
        }
    }
    // 减
    else {
        if (fds->modenv_gain) {
            fds->modenv_gain--;
            sfc_fds_update_modenv_gain(famicom);
        }
    }
}

波输出与调制:

  1. 每次个CPU周期增加一个12bit的频率值, 增加到16bit就Tick一次波输出与调制.
  2. 波输出 每次个CPU周期还会额外增加一个增益数据
  3. 波输出被tick时, 输出当前数据, 然后往前推进一次索引
  4. 调制单元被tick时, 根据当前的调制表的信息增减调制计数器
  5. 然后根据前面wiki贴出的代码计算出为波输出提供的增益值
  6. 这里贴出调制单元Tick时的代码, 波输出还稍微简单点
/// <summary>
/// StepFC: FDS Tick一次调制单元
/// </summary>
/// <param name="famicom">The famicom.</param>
void sfc_fds_tick_modunit(sfc_famicom_t* famicom) {
    assert(famicom->apu.fds.mod_enabled);
    const uint8_t* const table = sfc_get_fds1modtbl(famicom);
    const int8_t value = table[famicom->apu.fds.modtbl_index++];
    sfc_fds1_data_t* const fds = &famicom->apu.fds;
    fds->modtbl_index &= 0x3f;
    fds->mod_counter_x2 += value;

    fds->freq_gained = sfc_fds_get_mod_pitch_gain(
        fds->freq,
        fds->mod_counter_x2 / 2,
        fds->modenv_gain
    );
}

合并输出

主要影响值: 波输出数据, 音量增益, 以及主音量.

  • 波输出数据(6bit波数据)
  • 音量增益(0-32)
  • 主音量(2/2, 2/3, 2/4, 2/5)
  • 最大音量大约是2A03方波的2.4倍
  • 输出6bit才2.4倍, 可以算出权重大致是0.0045(并不确定, 交给用户控制)
  • 最后通过一个滤波, 可近视为截止频率2kHz的低通滤波
  • 输出被视为线性的
  • 这里由于滤波器的存在, 可以暂时不用考虑重采样的问题

修改点

可以看出可以读取$4040-$4092(大致), 这部分刚好在前面定义的自定义BUS区. 上次刚好将VRC7数据转进来, 这里只好挪一下, 目前BUS布局:

  • (自定义BUS)$4000-$403F: 储存调制表
  • (自定义BUS)$4040-$407F: 储存波形数据(FDS可读)
  • (自定义BUS)$4080-$4082: FDS读取用(FDS可读)
  • (自定义BUS)$4100-$4105: NSF程序PALY用
  • (自定义BUS)$4106-$410C: NSF程序INIT用(以后谈)
  • (自定义BUS)$4180-$41FF: VRC7 PATCH表
static inline void sfc_fds_update_volenv_gain(sfc_famicom_t* famicom) {
    famicom->bus_memory[0x90] = famicom->apu.fds.volenv_gain | 0x40;
}
static inline void sfc_fds_update_modenv_gain(sfc_famicom_t* famicom) {
    famicom->bus_memory[0x92] = famicom->apu.fds.modenv_gain | 0x40;
}

编写FDS出现的问题

新的思路: 这一次尝试用新的思路处理audio_changed事件. 会在音频事件修改前处理(这个事件名称之后应该会继续修改), 然后用最小的临时'context'(上下文)数据而不是保存整个状态. 这个新思路可能会让全部的音频事件模仿.

禁止位: 有一点特别注意, 几乎所有相关bit位是禁止位, 而不是使能位. 实际编写中自己几乎全部弄反了.

调制增益: 调制的目的就是动态修改波输出频率, 不过可能会发生溢出的现象:

  • freq + gain < 0
  • 自己使用的是uint16_t这就让频率变得异常高
  • 根据wiki的说法应该(可能会)钳制到0
  • 也可以考虑使用int16_t

效果对比: 测试用ROM, 不对, 测试用NSF文件是一位叫做w7n的作者(四斋)cover的《初音ミクの消失》, 这是来自famitracker论坛. 标题提到是66Hz, 不过实际上60Hz还是可以听一下.

  • 这个是NSF. 之所以用这个文件, 是因为音源是2A03+FDS的, 没有其他扩展音源支持. 直接将NSF嫁接到FDS接口就行.
  • 仅仅将FDS声道打开就能听到FDS模拟的乐器, 以及最重要——模拟的ミク的声线
  • 与自己手上的VirtualNES作比较, 会发现声音大致正确但是杂音有点重
  • 其实说是杂音应该是之前提到的'跳跃点':
            **  
              *
               *       **
************    *     *  *
                 **  *
           ^       **
         跳跃点

这首曲子中, 几乎每次说完一个字就会出现. 这个东西通过低通滤波能够减弱, 但是还不能完全消除, 可能性:

  • 实现有问题
  • VirtuaNES的滤波器实现很不错
  • 66Hz/64Hz(NSF文件里写着是这个频率)
  • 频率的问题最好验证, 但是似乎没有什么效果
  • wiki整篇除了$4083会暂停波外似乎没有有效的处理, 但是又不像三角波暂停后继续, 这里是暂停是为了写入新的数据.
  • 这个问题先放着, 毕竟没有接触过FDS

REF

  • FDS audio
  • [FDS, 66Hz] Hatsune Miku no Shoushitsu (Project Famicaloid)

附录: 初音ミクの消失

《初音ミクの消失》是由cosMo@暴走P Official Channel创作的一首知名曲目.

title

https://www.youtube.com/watch?v=sMrY0KSPtuM

这首NSF曲子可以在https://www.bilibili.com/video/av3908758/?p=5在线听到.

作为对比, 曲子长度5分钟左右, 但是NSF文件不到300kb, 对比44.1kHz-8bit的wav文件大概需要13mb, 但是包含的信息感觉上却比wav还多.

Re: 从零开始的红白机模拟 - [21]基础混频

更好的音频播放

我们之前实现的是在音频API基础上实现的状态机, 精确度大致在60Hz. 由于内部的帧计数器是240Hz的, 所以精度远远不够.

现在就由60Hz一口气提升到44.1kHz吧, 精度即在样本, 这是听觉上的上限, 但是距离最高的1.79MHz还差了几十倍!

而且当然要实现DMC声道, 很多游戏利用DMC发出鼓声来带节奏!

注意, 本节并未处理PAL的场合, 这个, 以后再说!

44.1kHz

采用这个频率不光是主流方案, 还有就是44100除以60和50都可以除尽, 即每帧的样本数量是固定的. 这次核心部分依然不会计算生成样本, 是由接口完成的样本生成, 好处是可以自由地选择采样率, 不用向核心提供参数.

48kHz也是不错的选择.

事件

这次由核心部分提供事件, 然后由接口生成数据:

// 音频事件
void(*audio_changed)(void*, uint32_t, int);
  • 第一个参数自然是接口参数, 不过由于懒, 直接用全局数据
  • 第二个参数是该帧事件发生时CPU周期数, 用于确定事件发生的时机
  • 第三个就是事件类型了: 写入公共的寄存器事件0, 方波1,2以此类推, 6是帧计数器事件(240Hz的那个)

参数2范围: 44100 * 1789773 / 60 = 1315564005在32bit范围内, 也就是说即使是一帧最后一个CPU周期(大致为三万)也能通过32bit整数确定到是最后一个样本

所以, 实际上还是一个状态机, 不过精度高了.

核心 -> 接口事件 -> 生成样本到音频API

StepA: 基础混频

第A步, 基础混频. 之前了解到混频算法:

output = pulse_out + tnd_out

                            95.88
pulse_out = ------------------------------------
             (8128 / (pulse1 + pulse2)) + 100

                                       159.79
tnd_out = -------------------------------------------------------------
                                    1
           ----------------------------------------------------- + 100
            (triangle / 8227) + (noise / 12241) + (dmc / 22638)

文档还提到了可以使用查表或者线性逼近的方法混频.

这里因为使用的是桌面平台, 浮点计算能力不错, 加上为用户提供每个声道独立的音量调整还算比较重要, 这里就选择原生的算法.

如果目标平台的浮点计算能力弱的话, 可以考虑用查表的方式.

本篇中很多细节不再描述, 详细地可以参考前面的文章.

事件

超级马里奥跳跃的音效, 之前说到很呆板, 其实很大问题是因为状态机实现有问题, 导致切换状态很容易出现'爆音', 下面是马里奥跳跃时方波#1的状态切换.

注意的是, 好像事件记录有问题, 被延迟了一步, 比如第二次的状态实际在第一次, 不过用来测试足够了.

格式: <帧ID> 事件触发点 : 周期(已经+1) - 音量 - 占空比

< 508> 0.942 :  255 - 15 - 2
< 509> 0.252 :  255 - 14 - 2
< 509> 0.503 :  256 - 14 - 2
< 510> 0.251 :  256 - 13 - 2
< 510> 0.503 :  256 - 12 - 2
< 510> 0.942 :  257 - 12 - 2
< 510> 0.944 :  257 - 11 - 2
< 514> 0.252 :  257 - 15 - 1
< 515> 0.944 :  261 - 15 - 1
< 517> 0.942 :  261 -  9 - 1
< 519> 0.251 :  244 -  9 - 1
< 520> 0.949 :  228 -  8 - 1
< 521> 0.503 :  213 -  8 - 1
< 522> 0.503 :  213 -  7 - 1
< 523> 0.754 :  199 -  7 - 1
< 523> 0.942 :  199 -  6 - 1
< 525> 0.503 :  186 -  6 - 1
< 525> 0.754 :  174 -  6 - 1
< 526> 0.942 :  174 -  5 - 1
< 527> 0.942 :  163 -  5 - 1
< 528> 0.754 :  163 -  4 - 1
< 529> 0.995 :  152 -  4 - 1
< 531> 0.754 :  142 -  3 - 1
< 531> 0.995 :  133 -  3 - 1
< 532> 0.995 :  133 -  2 - 1
< 534> 0.251 :  124 -  2 - 1
< 534> 0.942 :  124 -  1 - 1
< 536> 0.252 :  116 -  1 - 1
< 536> 0.503 :  108 -  1 - 1
< 537> 0.942 :  108 -  0 - 1
< 539> 0.251 :  101 -  0 - 1
< 540> 0.942 :   94 -  0 - 1
< 542> 0.503 :   88 -  0 - 1
< 543> 0.942 :   82 -  0 - 1
< 545> 0.503 :   76 -  0 - 1
< 546> 0.942 :   71 -  0 - 1
< 546> 0.945 :   66 -  0 - 1

我们目前的逻辑: 有一串事件ABCD....当事件进行到F时, 处理E到F之间的样本, [E, F)区间采用E事件状态. 即: 永远处理上一个状态:

static void this_audio_event(void* arg, uint32_t cycle, int type) {

    const uint32_t old_index = g_states.last_cycle * SAMPLES_PER_SEC / NTSC_CPU_RATE;
    const uint32_t now_index = cycle * SAMPLES_PER_SEC / NTSC_CPU_RATE;
    // 将目前的状态覆盖 区间[old_index, now_index)
    make_samples(old_index, now_index);
    g_states.last_cycle = cycle;
    // 后略
}

然后用内建支持正则表达式的脚本语言(比如ruby)处理一下, 将数据转换生成C格式数组, 接下来大致就是方波#1的处理:

static const float sq_seq[] = {
    0, 1, 0, 0, 0, 0, 0, 0,
    0, 1, 1, 0, 0, 0, 0, 0,
    0, 1, 1, 1, 1, 0, 0, 0,
    1, 0, 0, 1, 1, 1, 1, 1,
};

const struct tmp_data {
    uint32_t    id;
    float       pos;
    uint16_t    p;
    uint8_t     v;
    uint8_t     d;
} DATA[] = {
    { 0, 0.000f,  255,  0, 2 },
    // 后略
};

void make_mario_jump() {
    float buffer[SAMPLES_PER_FRAME * 38];
    FILE* const file = fopen("record.flt", "wb");
    if (!file) return;
    memset(buffer, 0, sizeof(buffer));

    float apu_cycle = 0.f;
    const float cycle_per_sample = (float)NTSC_CPU_RATE / (float)SAMPLES_PER_SEC / 2.f;

    const struct tmp_data* data = DATA;
    uint8_t index = 0;
    for (int i = 0; i != sizeof(buffer) / sizeof(buffer[0]); ++i) {
        const float pos = (float)i / (float)SAMPLES_PER_FRAME;
        const float next = (float)(data[1].id) + data[1].pos;
        if (pos >= next) ++data;

        apu_cycle += cycle_per_sample;
        const float p = (float)data->p;
        if (apu_cycle >= p) {
            apu_cycle -= p;
            index++;
            index = index & 7;
        }
        
        float square1 = 0.f;
        if (data->v) {
            const float sq1 = sq_seq[index | (data->d << 3)];
            square1 = data->v * sq1;
        }
        const float sample = 95.88f / ((8128.f / (square1)) + 100.f);
        buffer[i] = sample;
    }

    fwrite(buffer, 1, sizeof(buffer), file);
    fclose(file);
}

因为目前使用的采样率是44.1kHz, 用CPU的1.789MHz除是除不尽的, 所以这里采用了浮点apu_cycle.

apu_cycle += cycle_per_sample;
const float p = (float)data->p;
if (apu_cycle >= p) {
    apu_cycle -= p;
    index++;
    index = index & 7;
}

这里是推进方波的8步索引, 注意的是, 如果周期足够小, 可能会在一次样本中推进多次. 稍微正确一点的是:

apu_cycle += cycle_per_sample;
const float p = (float)data->p;
const int count = (int)(apu_cycle / p);
apu_cycle -= (float)count * p;
index += (uint8_t)count;
index = index & 7;

接下来就是样本的生成了, 输出当前音量即可:

float square1 = 0.f;
if (data->v) {
    const float sq1 = sq_seq[index | (data->d << 3)];
    square1 = data->v * sq1;
}
const float sample = 95.88f / ((8128.f / (square1)) + 100.f);
buffer[i] = sample;

这样马里奥跳跃的音效就生成了, 我们可以利用类似audacity的软件导入裸数据:

audacity

就可以听到马里奥跳跃的声音了. 方波#2也是类似的. 在实现细节上值得注意的是:

  • 写入$4003会重置方波#1的8步序列索引
  • 写入$4007会重置方波#2的8步序列索引

三角波澜T-Wave

这个逻辑同样适合三角波, 值得注意的是, 我们之前实现最初状态机提到过因为三角波特性导致必须播放完一个三角波避免出现'爆音'. 这里解释本身为什么不会出现:

  • 线性计数器或者长度计数器为0的话, 三角波的32步序列索引不会再变
  • 之前提到会产生'爆音'是因为状态机播放的波形出现跳跃. 但是本身线性计数器或者长度计数器为0的话会暂停三角波的的索引, 以此达到静音的目的, 从而不会'爆'
BEFORE:

*     *                     *     *
 *   *  *     *           *  *   *
  * *    *  *                 * *
---*------*----***********-----*---
               ^         ^
               |         |
                 两次跳跃


                 没有跳跃
AFTER:        |           |
*     *       v           v *     *
 *   *  *     *************  *   *
  * *    *  *                 * *
---*------*--------------------*----

即:

triangle

  • 线性计数器或者长度计数器为0的话, 依然会继续输出, 除非禁用状态寄存器的三角波, 输出0, 本身可能会产生'爆音', nesdev提到:

To silence the wave, write %10000000 to $4008 and then $4017. Writing a raw period of 0 also silences the wave, but produces a pop, so it's not the preferred method.

.

还有就是是以CPU频率驱动的, 这个问题不大, 毕竟音调会变得奇怪.

噪声

这一次可以通过频率比较准确的模拟LFSR状态了, 只有一点比较反常识:

When bit 0 of the shift register is set, the DAC receives 0

即, LFSR最低位是1, DAC会接收到0. 最低位是0, DAC会接收音量.

之前状态机实现得无所谓, 反正声道是独立的, 现在由于自己混音就得注意点.

DMC

重头戏来了, 这次实现的重点, DMC声道. 由于目前是事件驱动的状态机, DMC状态改变时, 这时候DMC样本非常有可能已经播放完毕了. 我们目前是延迟一个事件处理, 永远处理上一个事件. 也就是说DMC播放完毕处理IRQ是不可能的了.

DMC声道目前来说有三种用法:

  • ΔPCM播放
  • PCM播放
  • 配合IRQ搞事

目前前两个都能处理, 最后一个就无能为力了, 好在最后一个很少见. 以后再说.

注意点: $4015的D4位实际上相当于DMC的播放/停止按钮

$4015 write	---D NT21	Enable DMC (D), noise (N), triangle (T), and pulse channels (2/1)
If the DMC bit is clear, the DMC bytes remaining will be set to 0 and the DMC will silence when it empties.
If the DMC bit is set, the DMC sample will be restarted only if its bytes remaining is 0. If there are bits remaining in the 1-byte sample buffer, these will finish playing before the next sample is fetched.

DMC声道最高频率是大致是33kHz, 就目前的采样率(44.1kHz)而言, 每次采样最多遇到一次DMC修改, 所以可以不用检测修改次数.

即时播放

现在就需要将音频即时播放, 那就处理一下实时播放的策略吧.

之前提到为了避免'饥饿'状态, 一般模拟器会缓存几帧数据再播放. 当然, 除了饥饿, 还有'吃撑'的情况.

个人觉得一个阈值太粗糙, 用两个阈值吧, 上下阈值, 针对不同情况进行处理.

饥饿状态

由于各种原因, 最大的原因可能是计时器的精度, 可能会导致音频播放剩余缓冲区过少(甚至到0), 我们可以这么处理:

  1. 跳过视频帧(音频优先)
  2. 插入空白音频帧(视频优先)
  3. 结合上面两种情况, 比如一开始几秒是视频优先, 后面就是音频优先

列出来主要是可以让用户自行选择, 这里的实现:

  • 图方便, 直接插入空白音频帧.
  • "认为过少", 是到0了, 即没有剩余的缓冲区, 当前播放的是最后一个.

吃撑状态

由于各种原因, 最大的原因可能是计时器的精度, 可能会导致音频播放剩余缓冲过多. 过多的话, 一, 是可能当前环境不够支持过多的缓冲区, 二, 是会导致音频延迟, 到一定程度就会浑~ 身~ 难~ 受~.

  1. 等待一两帧视频(音频优先)
  2. 跳过音频帧(视频优先)
  3. 结合上面两种情况

列出来主要是可以让用户自行选择, 这里的实现:

  • 这里图方便直接睡过去Sleep(30). 毕竟吃饱了就睡是常识!
  • 这样的话, 如果目前显示器明显超过60Hz, 会感觉比较难受. 还是那句话, 以后再说.
  • "认为过多", 是到超过4, 即5.

.

自己播放时, 几分钟就会触发一次'吃撑':

overflow

自己仅仅是等待垂直同步, 也就说可能自己的显示器可能实际是60.01Hz的

(居然发现是PRG-RPM! 请叫我错别字大王)

新的音频/图像接口

之前的xa2_interface.h就升级(或者说降级?)了: xa2_interface1.h

int xa2_init(uint32_t sample_persec) ;
void xa2_clean() ;

void xa2_submit_buffer(const float*, uint32_t len) ;
void xa2_flush_buffer() ;
unsigned xa2_buffer_left() ;

很简单:

  • xa2_buffer_left用来检测剩余缓存区数量.
  • xa2_flush_buffer 理论上是开始或者结束需要调用
  • xa2_submit_buffer 提交当前缓冲区, 由于XAudio2, 这部分缓存必须保证播放时, 引用有效
  • 也处理了设备丢失的问题(自己用的是蓝牙耳机)

同样也升级了图像接口, d2d_interface1.h, 修改点很少:

  • 交换了AB键, 玩双截龙2时, 突然意识到, A键在右边!
  • d2d_submit_wave 就是把样本再发给图像接口一端, 可以显示当前帧的波形图像

当然, 这些都是小接口, 未作优化.

DMC 跳跃

之前4MB的PCM播放的ROM(搞事者转录的寒蝉鸣泣之时-解)也可以正确播放了.

output

.

不过播放ΔPCM还是可能会出现跳跃:

dmc

这是一段鼓声, 不知道是不是BUG. 但是几乎听不出来是'爆音', 或者说, 以为'爆音'就是这段鼓声的特点.

项目地址Github-StepFC-StepA

作业

  • 基础: 自己考虑还有什么方法处理饥饿和吃撑的情况?
  • 扩展: 自己实现生成WAVE数据的逻辑!
  • 从零开始: 从零开始自己的模拟器吧!

REF

Re: 从零开始的红白机模拟 - [10]屏幕相关

屏幕相关

这一节将介绍屏幕相关知识

CRT显示器

CRT叫做"阴极射线显像管"(Cathode Ray Tube), 也就是所谓的"大屁股电视机".

以前的老电视都是这种形式, 现在自己老家应该还有用了几十年的老电视. 记得是在初中物理还是是什么地方应该介绍过原理, 这个受限于电力系统的频率, 我国是50HZ, 使用的PAL-D,K制式:
pal-ntsc

这里有一个视频叫:

可以通过高速摄像机直观地理解.

现在视频网站可能有720p, 1080p的视频. p就是表示逐行(progressive)扫描, 对应的就是i, 隔行(interlaced)扫描. 我记得PS3上有1080i的选项但是ps4没有看到(或者没注意到), 可能是时代不同了.

一般没有必要模拟隔行扫描(视频中的显示器貌似就是逐行扫描的CRT), 除非有强迫症. 但是扫描线的概念还是要有的:

  1. HBlank
    • 扫描线扫描完一行要返回下一行(隔行的话就是下面第二行)开头, 直接返回会覆盖掉之前辛苦的扫描, 所以电子枪会暂时关掉, 然后掉头.
    • 这段工作会消耗大概整个扫描行1/6的时间.这段空白时间称为水平空白间隙(Horizontal blanking interval), 或者统称水平空白(HBlank). 这个"空白"是指消除电子枪的显示, 也被翻译作"消隐".
  2. VBlank
    • 这个概念同样适用于垂直方向, 当整个画面(称为'场'Field, 逐行扫描则可以称为'帧'Frame)显示完了, 电子枪会回到屏幕的左上角. 就有了垂直空白间隙(Vertical Blank Interval), 常称VBlank这个东西.
    • 这段时间相当长, 可能有1毫秒. 拿NTSC举例, NTSC分辨率是720x480, 但是拥有525条扫描线, 有483条可见的扫描线. 剩下的不可见扫描线就是用来处理VBlank以及掉头. 为什么是1毫秒? 可以计算一下!
    • 现在虽然不怎么使用CRT显示器了, 但是这个概念还是一直保留.

计算题

NTSC 一秒显示60场, 每场平均262.5扫描线(隔行扫描). 也就是扫描线频率15.75KHz, 折合63.5微秒(μs), 262.5-241.5 = 21条扫描线. 也就是说VBlank花了1.33毫秒(ms), 标准制式是这样的.

FC的情况

根据NesDev提供的文档Nintendo Entertainment System documentation

+--------+ 0 ----+
|        |       |
|        |       |
| Screen |       +-- (0-239) 256x240 on-screen results
|        |       |
|        |       |
+--------+ 240 --+
|   ??   |       +-- (240-242) Unknown
+--------+ 243 --+
|        |       |
| VBlank |       +-- (243-262) VBlank
|        |       |
+--------+ 262 --+

FC是在243扫描线开始的时候触发VBlank, 240-242是在Unknown状态.

根据NesDev本身自己wiki的情况: PPU Rendering:

  • Pre-render scanline (-1, 261)
  • Visible scanlines (0-239)
  • Post-render scanline (240)
  • Vertical blanking lines (241-260)

细节上有些区别, 不过都是240行可见扫描线以及20行的VBlank线. 以wiki为主吧.

REF

星球大战——两种3D文本渲染方式比较

星球大战

说到星球大战可能不得不说开场字幕:
starwar
这种开场字幕可谓经典, 连我这种没看过的都知道, 可见一斑. 现在我们就通过3D图形API渲染出类似的结果, 自己对D3D11较(其他API)熟悉, 文本API则使用配套的DWrite+D2D. 当然这种情况请关闭文本API的子像素渲染功能(Windows上叫ClearType技术, 大致原理一样, 细节或许不同)

3D文本

通过3D图形API可以很简单地渲染出来类似的效果, 一般来说有两种方式:

  1. 离屏(offscreen)渲染
    先通过文本API在一张位图/纹理上渲染出文本
    a
    然后贴在一个矩形
    b
  2. 转换为三角面
    通过文本API获取(tessellate)出三角面
    c
    然后渲染三角面即可
    d
  3. 格式化为矩形串
    四大天王有5个可是常识!这种个人认为应该是最常用的, 其实就是上面两种的结合, 这里不实现仅仅讨论. 讨论最后面.

离屏渲染

上一节Direct2D 直接与Direct3D 11 进行协同中提到了D3D和D2D的协同工作, 这个协同最主要的当然还是文本渲染! D2D的文本是通过DWrite,简单说这里就是DWrite->D2D->D3D. 其中D2D->D3D已经知道了, 而第一步则非常简单:

  1. 创建DWrite工厂DWriteCreateFactory, 最基础的
  2. 创建文本格式(TextFormat)IDWriteFactory::CreateTextFormat, 文本格式可以认为是"字体", 相同格式的文本可以用同一个对象
  3. 创建文本布局(TextLayout) IDWriteFactory::CreateTextLayout, 官方的说法是:

采用字符串、文本格式和关联约束,并生成一个对象,该对象表示完全分析和格式化结果。

然后D2D就可以直接绘制这个文本布局了ID2D1RenderTarget::DrawTextLayout, 当然D2D对此有优化, 在此不谈.

如果是类似使用OpenGL+FreeType之类的, 可能需要使用一些优化手段了。

转换为三角面

这个需要使用的文本API支持, 好在DWrite是支持的, 大致是

  1. 创建一个自定义的文本渲染器, 利用IDWriteTextRenderer::DrawGlyphRun回调获取glyph run.
  2. 然后利用IDWriteFontFace::GetGlyphRunOutline获取glyph run的轮廓(outline)
  3. 然后实现ID2D1TessellationSink 接口获取(tessellate)三角形信息就好了

效果比较

测试文本节选自《三国演义》, 本想直接用星球大战的, 还是测试汉字效果才是王道:

默认情况的比较如下:
noaa
查看原图可以清楚地看出, 渲染小字体用三角面真是全是狗牙, 右侧的大字还能看得出, 左侧第三行三角面模式到第五个字"分(争)"才能辨识出来. 而离屏渲染中对应前面的"七国"也能分辨. 而右侧的大字用离屏渲染的又太模糊(不过这个是可以解决的! 后面会说). 感觉两边都不太行

4xMSAA
既然狗牙有点重, 打开抗锯齿怎么样, 这里用MSAA做测试:
msaax4
- dustpg使用了4xMSAA
- 效果显著
可以看出几乎所有字都能辨识了, 可见渲染三角面字体必须抗锯齿. 至于怎么抗锯齿就看各位了, 比如也可以从shader下手, 毕竟MSAA是影响全局的.

效率比较

  • 时间效率
    这里测试文本总共0x201c个也就是8000+个顶点, 虽然远多于矩形, 但是实际上重点是在像素着色器(PS)上. 1) 整个矩形都需要通过PS(有采样). 2) 只有三角面的部分通过PS(返回指定颜色)
    所以大致是: 顶点实在有点多->离屏渲染快; 渲染矩形小->离屏渲染快.
  • 空间效率
    顶点占了大概64kb空间,离屏渲染512x512大概1mb空间, 这个需要斟酌, 因为512x512有时浪费有时不够用. 所以有一个通用结论: 当字体足够大时, 使用三角面更为划算(D2D应该就是这么实现的)
  • 结论
    当字体足够大时, 使用三角面更为划算

本文使用的代码全在NoteFL/D3D11/D3D11Text3D/main.cpp里面, 有需要的可以查看

扩展阅读: 有向距离场(Signed Distance Fields)

对于这种文本(或者说灰度数据, 这里的灰度就是Alpha), V社(对, 就是不会数3的那个)发表了一篇Improved Alpha-Tested Magnification for Vector Textures and Special Effects的论文, 能够有效改善文本模糊的问题, 效果可谓是非常不错.

四大天王

OpenGL+FreeType的话可使用, 类似:
a
在渲染小字体时, D2D就是对于每个字符就是一个矩形, 对应的纹理坐标自然就是纹理缓存中的. (D2D渲染大字体则就是渲染三角面, 应该, 大概, 可能吧), 这就是第三种方法——四大天王! 不对, 格式化为矩形串.

这种情况需要自己实现一个字体纹理管理器, 毕竟汉字几万个. 然后实现一个简单的排版引擎. 再加上前面提到的SDF, 可谓是非常完美. (这个方法其实DWrite+D2D帮你实现了, 但是放在3D空间中就需要自己实现了)

这种情况下时间效率分析:
矩形位置需要通过PS, PS过程需要采样, 大致介于上面两种情况之间
这种情况下空间效率分析:
不用说, 不含缓存纹理的话(全局都可以用)是最划算的.
结论:
不嫌麻烦的话, 这个+SDF应该是最好的办法了

自己常用的C/C++小技巧[4]

自己常用的小技巧

这里列出了自己常用的一些c/c++小技巧, 有些会有不足, 可以简单探讨一下.

大小端

分类: 简单讨论

"大小端"这一名称来自很有意思. 不过, 在这里, 大致就是怎么处理"字节序"的. 我们用char, char16_t以及char32_t描述'A'的话就是这样的:

(按地址增长方向排列)

              小端序        大端序        混合序
char           'A'           'A'          'A'
char16_t      'A',0        0,'A'     (?) 'A',0
char32_t     'A',0,0,0    0,0,0,'A'  (?)0,0,'A',0

一般来说只需要考虑大小端, 这两个即可, 混合序的程序估计得定制.

如何判断大小端呢? 终于在c++20中有了std::endian. 同时对于类gcc编译器可以使用:

// is little endian
struct is_little_endian { enum : bool { value = __BYTE_ORDER__ == __ORDER_LITTLE_ENDIAN__ }; };
// is big endian
struct is_big_endian { enum : bool { value = __BYTE_ORDER__ == __ORDER_BIG_ENDIAN__ }; };
// is pdp endian
struct is_pdp_endian { enum : bool { value = __BYTE_ORDER__ == __ORDER_PDP_ENDIAN__ }; };

限制模板膨胀

分类: 体积优化

c++模板极大地方便了代码编写, 但是有一个重要的问题就是如果滥用, 模板膨胀会极大地增加程序体积. 可能不少人会不在意, 毕竟内存白菜价. 不过也会有像我这种比起时间复杂度, 也会在意空间的.

上一部分我们用的是char自然是引出这部分: 字符串处理中部分限制. 比如c++标准模板库中<string>中有:

类型                     定义
std::string              std::basic_string<char>
std::wstring             std::basic_string<wchar_t>
std::u16string (C++11)   std::basic_string<char16_t>
std::u32string (C++11)   std::basic_string<char32_t>

虽然有非常多的文字编码, 但是或多或小地会在低字节与ASCII兼容. 我们在处理纯英文数字时(比如字符串转成数字)会仅仅处理ASCII部分, 利用字符之间的间隔就能轻松地在一条函数实现char等三种.

例如现在实现了一条函数, 字符串转换整型:

int32_t my_atoi(const char* ptr, size_t len) {
    // 具体实现
    ++ptr;
    // 具体实现
}

然后把ptr++之类的操作换成ptr+=n即可:

int32_t my_atoi_ex(const char* ptr, size_t len, size_t n) {
    // 具体实现
    ptr += n;
    // 具体实现
}

然后就是大小端了. 最低位在char16_t, char32_t中偏移不一样. 偏移量在上面列出了, 然后用模板特化就能编译期获取偏移量, 这个就不累述了. 最后就能实现为:

extern "C" int32_t my_atoi_ex(const char* ptr, size_t len, size_t n);

namespace detail {
    // ascii offset
    template<unsigned SIZE> struct ascii_offset;
    // ascii offset for 1 [char]
    template<> struct ascii_offset<1> { enum { value = /*impl*/ }; };
    // ascii offset for 2 [char16_t]
    template<> struct ascii_offset<2> { enum { value = /*impl*/ }; };
    // ascii offset for 4 [char32_t]
    template<> struct ascii_offset<4> { enum { value = /*impl*/ }; };
}

template<typename T> inline
int32_t MyAtoI(const T* str, size_t len) {
    const auto ptr = reinterpret_cast<const char*>(str) + detail::ascii_offset<sizeof(T)>::value;
    return ::my_atoi_ex(ptr, len, sizeof(T));
}

这是关于字符串处理相关限制模板, 理论上节约了2/3的代码空间. 接下来就是关于容器方面的限制.

c++用模板封装容器就能非常方便地使用, 这里就是以类std::vector的实现讨论这一问题. 向量, 或者还是用数组比较顺口储存了连续的数据.

在实际实现上 std::vector<int32_t>::push_backstd::vector<float>::push_back由于int32_tfloat大小一致, 这两条函数可能会被优化合并为一条函数. 大致同理, 我们可以把这种没有构造/析构的对象单独拿出来实现一个专门储存这一类的容器, 经过内联后, 这一类调用的函数会完全指向同一个实现.

为了方便起见, 就称为"POD Vector", 我们可以通过std::is_pod 再配合static_assert防止程序猿作死. 自己在pod_vector.h, pod_vector.cpp 里实现了. 大致结构为:

class basic_vector {
    char*       data;
    uint32_t    length;
    uint32_t    capacity;
    uint32_t    size;
};

template<typename T>
class pod_vector : protected basic_vector {
    static_assert(std::is_pod<T>::value, "type T must be POD");
};

pod_vector在外层实现几乎兼容std::vector以方便使用. 这样随便用pod_vector也无需担心模板膨胀, 唯一低效率的情况就是例如pod_vector<char>::push_back效率较低. 说到char, 各位也会发现"字符串"也可以用这种方法实现!

特别地, 通过简单改造basic_vector, 字符串就能直接也能用pod_vector实现, LongUI中, 三种字符串char char16_t char32_t均是POD Vector的实现, 以实现轻量级目标.

可能大家会说, 只能用POD没啥用啊. 但是我们也可以实现不会模板膨胀的"非POD Vector". 自己在之前C++ 获取构造函数/析构函数的函数指针提到了, 我们可以使用类似于虚表的东西, 实现不会模板膨胀的"非POD Vector", 缺点就是用于 用于本身重点侧重于RAII的(例如智能指针之类构造析构比较短的), 用起来"不划算". 大型对象还是很划算的. 自己虽然实现了, 但是没用过所以应该有BUG(没用过的主要原因是因为标准库没有类似try_realloc的函数, 有的话效率在修改容量时大大提升速度).

常量折叠

分类: 空间优化

前面提到, std::vector<int32_t>::push_backstd::vector<float>::push_back可能会被"合并". 其根本原因其实是链接器的链接时优化, 把相同的常量折叠为一个, 从而减少程序体积. 这个就被称为"常量折叠".

我们当然可以进行扩展, 手动在运行时实现常量折叠. 这里先举一个特殊的例子. 之前说到工厂模式中链表使用头节点与尾节点方便处理:

struct Node {
    Node*   prev;
    Node*   next;
};
class Factory {
    Node    head;
    Node    tail;
};

其中, head的prev和tail的next会一直为nullptr. 所以, 我们可以把他们折叠在一起!

void* data[3]:

            tail     head     

[0] xxxx    prev
[1] null    next     prev
[2] yyyy             next

当然, 用于工厂模式没什么用, 就节约一个指针而已. 不过如果是树的一个实现, 那么每个节点都能节约一个指针就..."还行"...

虽然我们用"折叠"这个词汇, 核心**是"复用"数据. c++17中的string_view其**其实也是这个.

例如一个字符串PI=3.14159, 经过词法分析后可能被拆为"PI", "=", "3.14159"三部分. 如果每个部分都重新申请就会申请3个很短的字符串, 当然是非常不"划算". string_view就只需要在原来的字符串标记处起点与终点就能"复用"原来的字符串.

所以不要局限于"C风格(NUL结尾)"的字符串, 将自己所有字符串处理函数添加字符串的长度参数, 然后按照该长度处理也是极好的.(之前例子中的my_atoi拥有长度参数, 可以直接处理string_view!)

并且, "常量折叠"运行期可以升级为"同"量折叠. 这里修改了原字符串就能影响所有的string_view.

虚析构函数 虚释放函数

分类: 简单讨论

可能会在有些地方会建议"将所有析构函数声明为虚析构函数", 然后可能用着用着, 发现一个final关键字比较好. 不过这里讨论的不是该不该用, 而是怎么用: 虚析构函数, 还是虚释放函数:

struct Foo {
    virtual ~Foo(){}
};
struct Bar {
    virtual void Dispose() { delete this; }
};

释放一个继承于基础类的对象, 比较简单的就是这两种. 那么用那种呢? 这里先说说自己的建议:

  • 对称原则
  • 如果用new的, 就用delete
  • 如果用类似Bar::Create的, 就用#Dispose

这个结论也很简单, 那么有什么好讨论的呢? 那就是newdelete被称为"操作符", 还允许被重载:

struct Foo {
    static void operator delete (void* p) { ::delete p; }
    virtual ~Foo() {  }
};

struct Bar : Foo {
    static void operator delete (void* p) { ::delete p; }
    virtual ~Bar() {  }
};

int main() {
    Foo* foo = new(std::nothrow) Bar;
    delete foo;
}

哪个operator delete会被调用呢?

我们会一般用重载(overload)操作符, 而不是说重写(override)操作符(cppreference的说法: Class-specific overloads), 加上static的迷惑性会让代码产生一些歧义. 当然, 这很大程度上仅仅是自己对c++了解不够, 所以产生的迷惑.

这里所讨论的就是, 如果可能基继承类会修改了内存分配的情况, 这种情况很少见, 但是如果出现了建议使用虚释放函数. 这里一点, Windows上的COM对象就是使用了Release.

Re: 从零开始的红白机模拟 - [08]扩展指令

STEP3: CPU 指令实现 - 扩展指令

这节是指令介绍的最后一节.

扩展指令, 或者说非法/未被记录的指令: 一般是组合指令. 由于非官方记录, 所以指令名称可能不一致.

本节很多指令并没有在STEP3中实现, 原因是因为使用的测试ROM并没有测试这些指令, 所以'忘记'实现了, 这些指令直到测试了'blOperg's CPU test rom v5'才实现.

NOP - No OP

除了基本的NOP, 还有高级的NOP. 一般来讲6502指令是根据寻址方式排序的(偏移), 所以在其他寻址方式对应NOP的地方还有

详细的请到引用链接了解

ALR[ASR] - 'And' then Logical Shift Right - AND+LSR

助记符号: A &= M; C = A & 1; A >>= 1;

寻址模式 汇编格式 OP代码 指令字节 指令周期
立即 ASR #Oper 4B 2 2

只有两个指令周期(NOP你看看你), 应该是都消耗在了读取数据上

AND+LSR 的组合指令 - 逻辑与运算再右移一位:
影响FLAG: C(arry), S(ign), Z(ero), 伪C代码:

A &= READ(address);
CHEKC_CFLAG(A & 1);
A >>= 1;
CHECK_ZSFLAG(A);

例子: ALR #$FE 基本等价于 LSR A + CLC. 前者2字节2周期, 后者2字节4周期

ANC[AAC] - 'And' then copy N(S) to C

助记符号: A &= M; C = N(S);

寻址模式 汇编格式 OP代码 指令字节 指令周期
立即 ANC #Oper 0B 2 2

N(egative)或者说S(ign)位的值复制到C(arry)位,
影响FLAG: C(arry), S(ign), Z(ero), 伪C代码:

A &= READ(address);
CHECK_ZSFLAG(A);
CF = SF; 

例子: ANC #$FF 有符号扩展; ANC #$00 基本等价于 LDA #$00 + CLC.

ARR - 'AND' then Rotate Right - AND+ROR [类似]

助记符号: A &= M; A = (A>>1)|(C<<7); C = (A>>6)&1; V = ((A>>5)^(A>>6)&1);

寻址模式 汇编格式 OP代码 指令字节 指令周期
立即 ARR #Oper 6B 2 2

基本等价于 AND+ROR, 除了FALG设置.

C is bit 6 and, V is bit 6 xor bit 5.

影响FLAG: C(arry), S(ign), Z(ero), (o)V(erflow), 伪C代码:

A &= READ(address);
A = (A >> 1) | (CF << 7);
CHECK_ZSFLAG(A);
CHECK_CFLAG(A>>6);
CHECK_VFLAG(((A>>5)^(A>>6))&1);

AXS[SBX] - A 'And' X, then Subtract memory, to X

助记符号: X = (A&X) - M;

寻址模式 汇编格式 OP代码 指令字节 指令周期
立即 AXS #Oper CB 2 2

影响FLAG: C(arry), S(ign), Z(ero), 伪C代码:

uint16_t tmp = (SFC_A & SFC_X) - SFC_READ_PC(address);
X = (uint8_t)tmp;
CHECK_ZSFLAG(X);
SET_CF((tmp & 0x8000) == 0);

LAX - Load 'A' then Transfer X - LDA + TAX

助记符号: X = A = M;

寻址模式 汇编格式 OP代码 指令字节 指令周期
零页 LAX Oper $A7 2 3
零页 ,Y LAX Oper,Y $B7 2 4
绝对 LAX Oper $AF 3 4
绝对,Y LAX Oper,Y $BF 3 4 *
(间接,X) LAX (Oper,X) $A3 2 6
(间接),Y LAX (Oper),Y $B3 2 5 *

* 在页面边界交叉时 +1s

将储存器数据载入A, 然后传给X.
影响FLAG: S(ign), Z(ero), 伪C代码:

X = A = READ(address);
CHECK_ZSFLAG(X);

SAX - Store A 'And' X

助记符号: M = A & X;

寻址模式 汇编格式 OP代码 指令字节 指令周期
零页 AAX Oper $87 2 3
零页 ,Y AAX Oper,Y $97 2 4
(间接,X) AAX (Oper,X) $83 2 6
绝对 AAX Oper $8F 3 4

将累加器A和变址寄存器X '与' 的结果保留在储存器上. 影响FLAG: (无), 伪C代码:

WRITE(address, A & X);

DCP - Decrement memory then Compare with A - DEC + CMP

助记符号: M -= 1; A - M ? 0

寻址模式 汇编格式 OP代码 指令字节 指令周期
零页 DCP Oper $C7 2 5
零页 ,X DCP Oper,X $D7 2 6
绝对 DCP Oper $CF 3 6
绝对,X DCP Oper,X $DF 3 7
绝对,Y DCP Oper,Y $DB 3 7
(间接,X) DCP (Oper,X) $C3 2 8
(间接),Y DCP (Oper),Y $D3 2 8

读改写(RMW)指令, 存储器值-1再与累加器比较.
影响FLAG: C(arry), Z(ero), S(ign). 伪C代码:

tmp = READ(address);
--tmp;
WRITE(address, tmp);

uint16_t result16 = (uint16_t)A - (uint16_t)tmp;
CF = result16 < 0x100;
CHECK_ZSFLAG((uint8_t)result16);

ISC(ISB) - Increment memory then Subtract with Carry - INC + SBC

寻址模式 汇编格式 OP代码 指令字节 指令周期
零页 ISC Oper $E7 2 5
零页 ,X ISC Oper,X $F7 2 6
绝对 ISC Oper $EF 3 6
绝对,X ISC Oper,X $FF 3 7
绝对,Y ISC Oper,Y $FB 3 7
(间接,X) ISC (Oper,X) $E3 2 8
(间接),Y ISC (Oper),Y $F3 2 8

读改写(RMW)指令, 存储器值+1再用累加器减.
影响FLAG: C(arry), (o)V(erflow), Z(ero), S(ign). 伪C代码:

// INC
tmp = READ(address);
++tmp;
WRITE(address, tmp);

// SBC
uint16_t result16 = A - tmp - (CF ? 0 : 1);
CHECK_CFLAG(!(result16>>8));
uint8_t result8 = result16;
CHECK_VFLAG(((A ^ result8) & 0x80) && ((A ^ tmp) & 0x80));
A = result8;
CHECK_ZSFLAG(A);

RLA - Rotate Left then 'And' - ROL + AND

寻址模式 汇编格式 OP代码 指令字节 指令周期
零页 RLA Oper $27 2 5
零页 ,X RLA Oper,X $37 2 6
绝对 RLA Oper $2F 3 6
绝对,X RLA Oper,X $3F 3 7
绝对,Y RLA Oper,Y $3B 3 7
(间接,X) RLA (Oper,X) $23 2 8
(间接),Y RLA (Oper),Y $33 2 8

读改写(RMW)指令, 储存器数据循环左移再与累加器A做'与'运算.
影响FLAG: C(arry), Z(ero), S(ign). 伪C代码:

// ROL
uint16_t src = READ(address);
src <<= 1;
if (CF) src |= 0x1;
CHECK_CFLAG(src > 0xff);
uint8_t result8 = src;
WRITE(address, result8);
// AND
A &= result8;
CHECK_ZSFLAG(A);

RRA - Rotate Right then Add with Carry - ROR + ADC

寻址模式 汇编格式 OP代码 指令字节 指令周期
零页 RRA Oper $67 2 5
零页 ,X RRA Oper,X $77 2 6
绝对 RRA Oper $6F 3 6
绝对,X RRA Oper,X $7F 3 7
绝对,Y RRA Oper,Y $7B 3 7
(间接,X) RRA (Oper,X) $63 2 8
(间接),Y RRA (Oper),Y $73 2 8

读改写(RMW)指令, 储存器数据循环右移再加上累加器A和进位标记.
拿来用来计算 A+V/2, 其中V是9位(支持到512)数据.
影响FLAG: C(arry), S(ign), Z(ero), (o)V(erflow), 伪C代码:

// ROR
uint16_t src = READ(address);
if (CF) src |= 0x100;
CF = src & 1;
src >> 1;
WRITE((uint8_t)src);
// ADC
uint16_t result16 = A + src + (CF ? 1 : 0);
CHECK_CFLAG(result16>>8);
uint8_t result8 = result16;
CHECK_VFLAG(!((A ^ src) & 0x80) && ((A ^ result8) & 0x80));
A = result8;
CHECK_ZSFLAG(A);

值得注意的是ADC的第一行的 (CF? 1: 0) 就是继承于ROR的第三行CF = 操作.
所以可以实现为:

// ROR
// ...
tmp_CF = src & 1;
// ...
// ADC
uint16_t result16 = A + src + tmp_CF;
// ...

SLO - Shift Left then 'Or' - ASL + ORA

助记符号: A |= (M <<= 1)

寻址模式 汇编格式 OP代码 指令字节 指令周期
零页 SLO Oper $07 2 5
零页 ,X SLO Oper,X $17 2 6
绝对 SLO Oper $0F 3 6
绝对,X SLO Oper,X $1F 3 7
绝对,Y SLO Oper,Y $1B 3 7
(间接,X) SLO (Oper,X) $03 2 8
(间接),Y SLO (Oper),Y $13 2 8

读改写(RMW)指令, 储存器数据算术左移一位然后和累加器A做'或'运算,
由于要和累加器计算, 所以没有单字节指令SLO A, 即寻址方式为'累加器A'的.
影响FLAG: C(arry), Z(ero), S(ign). 伪C代码:

// ASL
tmp = READ(adress);
CHECK_CFLAG(tmp>>7);
tmp <<= 1;
WRITE(address, tmpdt);
// ORA
A |= tmp;
CHECK_ZSFLAG(A);

SRE - Shift Right then "Exclusive-Or" - LSR + EOR

寻址模式 汇编格式 OP代码 指令字节 指令周期
零页 SRE Oper $47 2 5
零页 ,X SRE Oper,X $57 2 6
绝对 SRE Oper $4F 3 6
绝对,X SRE Oper,X $5F 3 7
绝对,Y SRE Oper,Y $5B 3 7
(间接,X) SRE (Oper,X) $43 2 8
(间接),Y SRE (Oper),Y $53 2 8

读改写(RMW)指令, 储存器数据逻辑右移一位然后和累加器A做'异或'运算,
由于要和累加器计算, 所以没有单字节指令SRE A, 即寻址方式为'累加器A'的.
影响FLAG: C(arry), Z(ero), S(ign). 伪C代码:

// LSR
tmp = READ(address);
CHECK_CFLAG(tmp & 1);
tmp >>= 1;
WRITE(address, tmpdt);
// EOR
A ^= tmp;
CHECK_ZSFLAG(A);

SHX[SXA] - 行为可能不一致, 不做实现

SHX & SHY, 不过"blOperg's CPU test rom v5"中测试了该指令

SHY[SYA] - 行为可能不一致, 不做实现

SHX & SHY, 不过"blOperg's CPU test rom v5"中测试了该指令

blOperg's CPU test rom v5

测试ROM: "blOperg's CPU test rom v5"中, 仅仅不通过以上两个指令.

LAS - 行为可能不一致, 不做实现

XAA - 行为可能不一致, 不做实现

AHX[SHA] - 行为可能不一致, 不做实现

TAS - 行为可能不一致, 不做实现

KIL(STP) Kill Stop - 指令会导致CPU停下来, 不做实现

REF

Re: 从零开始的红白机模拟 - [27] 声部强化

更多的算法

像素风格缩放算法还有非常多, 光维基那种对照图就还有LQx和xBR系列, 有机会的话再介绍吧!

下面就是音频的部分了, 不过在介绍滤波器之前, 先讨论一个问题: 我们加入这些额外处理部分是为了提升用户体验程度. 与视频部分不同的是, 我们能拿到音频更基础的信息. 就像第一个音频播放状态机一样, 我们能知道音频在某一刻的信息.

那我们能不能根据这些提高音质呢?

方波

这个没什么好说的, 不是0就是1.

三角波

这个就有的说了, 从之前的实现我们知道: FC的三角波实际上是一个伪三角波, 从0到15, 再通过混频. 可以看作是一个深度为4bit的(伪)三角波, 我们可以通过额外的处理来加深三角波的深度, 或者调整算法逼近真正的三角波.

那么问题来了, "音质"提高了吗? 让我们简单测试一下!

根据原版的混频算法:159.79 / (1 / (triangle / 8227) + 100). 可以计算出最大的振幅是0.246412, 对应8bit的PCM值约63. 如果能线性增加, 这时候的频率大致是344Hz(44100/(64*2)), 63对应的是6bit, 那就对比344Hz下6bit和4bit的差距吧:

void make_triwave() {
    float tri4[(1 << 4) * 2];
    float tri6[(1 << 6) * 2];
    // 4bit T-WAVE - A
    for (int i = 0; i != (1 << 4); ++i)
        tri4[i] = (1 << 4) - 1 - i;
    // 4bit T-WAVE - B
    for (int i = 0; i != (1 << 4); ++i)
        tri4[i + (1 << 4)] = i;
    // 6bit T-WAVE - A
    for (int i = 0; i != (1 << 6); ++i)
        tri6[i] = (1 << 6) - 1 - i;
    // 6bit T-WAVE - B
    for (int i = 0; i != (1 << 6); ++i)
        tri6[i + (1 << 6)] = i;

    FILE* const file1 = fopen("out1.raw", "wb");
    FILE* const file2 = fopen("out2.raw", "wb");
    if (!file1 || !file2) exit(1);

    
    float buf[(1 << 6) * 2];
    // 6bit WAVE
    for (int i = 0; i != (1 << 6) * 2; ++i) 
        buf[i] = 159.79f / (1.f / (tri6[i] / 4.f / 8227.f) + 100.f);
    for (int i = 0; i != 44100 * 5 / ((1 << 6) * 2); ++i)
        fwrite(buf, sizeof(buf), 1, file1);
    // 4bit WAVE
    for (int i = 0; i != (1 << 6) * 2; ++i)
        buf[i] = 159.79f / (1.f / (tri4[i/4] / 8227.f) + 100.f);
    for (int i = 0; i != 44100 * 5 / ((1 << 6) * 2); ++i)
        fwrite(buf, sizeof(buf), 1, file2);
    fclose(file1);
    fclose(file2);
    exit(0);
}

这样生成了在44.1kHz下344Hz的6bit深度和4bit深度的三角波, 我们导入音频软件可以得到:

4bittri

听起来怎么样? emmmm...差不多...4bit版本听起来像是混了一段高频的噪音, 6bit版本混了一段稍微低频的噪音. 那就看看频谱分析:

4vs6

似乎印证了自己的主观判断. 不过注意了, 6bit是之前让其线性增加计算得到的深度, 频谱图可以看出很接近真正的三角波了.

那么, "音质"提高了吗? 是, 也不是. 理论上, 你完全可以把三角波替换成钢琴音, 让模拟器发出钢琴的声音:

piano

不过这完全是两个不同的东西! FC的伪三角波也一样, 和真正的三角波不是同一个东西, 我们可以调整算法让它逼近真的三角波, 也可以像上面那样提高深度. 但是, 有没有必要?

那么, 如果要做的话, 深度最高是多少? 344Hz下用6bit的, 序列长128. 算下来, 44100/1024 = 43.07, 43Hz应该足够低了吧, 9bit, 1024序列. 再不济22Hz, 加倍而已.

噪音

噪声声道可以提高"音质"吗? 理论上可行, 因为之前了解到噪音实际上只有32种音色, 我们完全可以去找32种音质更好的样本即可.

当然还有的方法是再发明一个伪随机算法, 不过难度很高.

DMC

DMC声道呢, DMC用来播放样本数据, 我们不可能无成本的增加信息, 能做的最多就是降降噪什么的. 不过由于ΔPCM的特殊性, 只有1bit的Δ编码, 自己猜测应该可能通过样本分析, 还原出更好的PCM样本.

其他

方波虽然只有0和1, 但是通过一些手段还是能生成很不错的声音. 包络生成器, 扫描单元.

我们可以通过对ADSR的模拟让声音听起来更加自然点, 即ADSR包络模拟. 还有就是更加平滑的频率扫描.

由于目前的实现是: 处理上一个事件. 那么, 反而因为这个特殊性, 实现上述"增强音质"的手段更加简单! 如果按照周期实现反而不方便"预测".

滤波器

有针对视频的滤镜自然也就有针对音频的滤波器, 不过都叫"filter"罢了.

根据nesdev的记载, NES本身就搭载了滤波器:

  • 90Hz的一阶高通滤波器
  • 440Hz的一阶高通滤波器
  • 14kHz的一阶低通滤波器

低通滤波

由于看不懂电路图, 这里就假定是RC滤波器, 网上资料较多.

根据低通RC滤波可以得到离散RC低通滤波器用的式子:

           T                RC
Y(n) = ---------- X(n) + ---------Y(n−1)
         T + RC           T + RC

T: 采样周期
RC: 滤波时间
例如: R = 47(千欧), C = 220(皮法).
    有滤波时间 RC = 10.34(微秒)

    

截止频率(cut-off frequency)计算公式为:

         1
f0 = ----------
        2πRC

10.34(微秒)的截止频率大致就是15kHz.

联立, 用C描述就是:

float input, output, prev_output;

float cutoff_frequency = 14000;
float sample_frequency = 44100;
float PI = 3.1415926535897932384626433832795;

float RC = 1.f/2.f/PI/cutoff_frequency;
float k1 = 1.f/(1.f+RC*sample_frequency);
float k2 = 1.f - k1;

output = prev_output = input * k1 + prev_output * k2;

高通滤波

同样假定为RC滤波器.

根据高通RC滤波可以得到离散高通滤波器用的式子:

                                  RC
Y(n) = (X(n) - X(n-1) + Y(n-1)) ------
                                 RC+T


截止频率同为:

         1
f0 = ----------
        2πRC

联立, 用C描述就是:

float input, output, prev_input, prev_output;

float cutoff_frequency = 90;
float sample_frequency = 44100;
float PI = 3.1415926535897932384626433832795;

float RC = 1.f/2.f/PI/cutoff_frequency;
float k = RC/(RC + 1.f/sample_frequency);

output = (input - prev_input + prev_output) * k;

prev_input = input;
prev_output = output;

疗效如何?

拿之前的4bit的伪三角波来说, 整体音量都减少了, 高频噪音还是能听到. 那就就行频谱分析:

filter

很明显低音部分几乎没了, 但是高频部分由于大部分在截止频率下面, 所以不大明显(14kHz太高了几乎听不见, 感觉就像是一个电路保险, 22kHz采样率还能听的情况下完全没用了).

NES是这样的, FC似乎就不同了, 详细的可以查看nesdev的说明, 所以感觉应该提供选项给用户关闭滤波.

StepD: 滤镜与滤波器

那么正式进入这一步, 由于几乎是在末端修改的, 同上一步核心部分没有修改.

滤镜部分由于是在最末端的着色器实现的, 所以又升级了图像接口, 音频滤波是用CPU实现的, 所以音频接口没有修改.

图像接口:

  • "d2d_draw2.cpp" "d2d_draw2.h"
  • 由于GPL的原因, 并没有实现之前介绍的全部算法
  • shader全部编译成二进制方便使用
  • 修改了main_cpp, 就是shader的文件地址
  • 终于实现了锁定60FPS! 实际游玩发现, 必须是60FPS的倍数(0.5倍, 2倍之类的)才能流程游玩
  • 75FPS(自己的显示器最高这么高了)反而有点难受
  • 如果'吃饱'选择音频优先的话, 音频视频之间存在耦合, 懒得改就跳过音频帧

程序一开始会要求输入shader的地址, 已经预编译至common文件夹, 可以随便选一个:

output

void main_cpp(
    const char* shader_bin_file_name, 
    const char* shader_res_file_name
);

.

而音频方面, 滤波器虽然实现了但是默认没有打开, 需要的同学们可以在submit_now_buffer函数中将注释去掉即可.

项目地址Github-StepFC-StepD

作业

  • 实现自己的滤镜/滤波器吧

REF

Re: 从零开始的红白机模拟 - [37] 音频可视化

StepE: 高级音频支持

数数这一步的内容:

  • Mapper031, NSF子集支持, 并在此基础上扩展用于播放NSF(NSF文件视为Mapper031的ROM)
  • NSF文件初步支持
  • 按顺序支持了VRC6, VRC7, FSD1, MMC5, N163以及FME7.
  • NSF文件进一步支持
  • 以及这一篇: 音频可视化

音频可视化

终于, 进入本步骤的最后一篇——音频可视化. 已经到E了, 16进制已经快装不下了, 预示着《Re: 从零开始的红白机模拟》也快结束了.

说到音频可视化, 可能就不得不提到傅里叶老人家. 不过在这里, 可能是见不到他老人家了——因为我们能拿到更基础的信息

数据可视化

可视化的目的就是为了直观的了解到相关'信息', 那么音频方面信息有哪些呢?

结合实际情况, 我们需要对音高、音色、音量等信息进行可视化.

音高

音高反映的是频率, 频率可视化自然就是映射到键盘上——

piano-keyboard-clipart

例如440Hz就是A4(A440标准), **C大致是262Hz. 键盘上是以'3白夹2黑 + 4白夹3黑'作为一节, 然后向两个方向扩展开来.

piano-keyboard-sec

白键从左到右依次叫做CDEFGAB, 黑键则是对应的两个白键之间的半音, 记作升(♯, sharp, 就是C#的那个), 或者降(♭, flat). 即C# = D-flat. (♯#或许有区别, 不过'#'比较容易输入, 这里就用#作为半音符号表示).

其中EF, BC其实也是半音的关系. 应该是为了方便钢琴家定位才这么排列的.

标准钢琴则是选取的: 以A0开始的88键这个一区间. 挑战! 回头找一下'**C'在哪!

键位计算

Equations for the Frequency Table给出了计算公式:

f(n)=  (\sqrt[12]2{})^{n-49} \times A_{440}

func

  • 由于一个'8度'就会让频率翻倍, 即'2'
  • 由于一个'8度'也就是一节共12键, 即十二次根号
  • A4在第49个键(88键情况下), 即-49
  • 现行是A4为440Hz

现在自然要做出逆运算:

/// <summary>
/// 以A4为基础计算键ID
/// </summary>
/// <param name="freq">The freq.</param>
/// <returns></returns>
float calc_key_id_a4(float freq) {
    /*
     换底公式
                    log(c, b)
        log(a, b) = ------------
                    log(c, a)
    */
    const float a4 = 440.f;
    return logf(freq / a4) * 17.3123404907f;
}

实际上, 因为一节是以C开始的所以实际实现中, 是以C4作为0. 再具体, 可能需要进行四舍五入转成整型方便查表, 注意:

  • 负数的膜运算(mod)
  • 负浮点的转整型(round, floor)
// 浮点转整型实现A
return static_cast<int>(floorf(code + 0.5f));

// 浮点转整型实现B
const float adj = code < 0.f ? -0.5f : 0.5f;
return static_cast<int>(code + adj);


// 负数膜运算实现A
const auto b = ((a % 12) + 12) % 12;
return lut[b];

// 负数膜运算实现B:
// 直接膜, 但是LUT是双向的
const int lut[24] = {1,2,3, ... };
return (lut+12)[a%12];

也就是负数需要特别注意.

具体处理

  • 声道区分: 每个声道独立用一个键盘不方便观看
    • 声道数量太多感觉也很乱, 不过还是用同一个键盘吧
    • 具体就是用颜色区分声道, 比如绿色表示2A03方波#1
    • 当然用户可调整
  • Key-On/Off事件: VRC7比较特殊的就是Key-On/Off事件, 我们可以采纳这个事件
    • VRC7, On就是按下, Off就是弹起.
    • 对于其他的声道, On就是出声, Off就是没有
    • 区别就是VRC7声道Off后还是可能会出声的(Release)

颜色生成

这里颜色生成是通过HSL色彩空间映射到RGB空间的方式, 生成的颜色. 需要一些随机颜色就可以随机'H'即可, 直接随机RGB生成的颜色不怎么好看, 这也算是一个小技巧.

目前需要的数据:

struct visualizers_data {
    // 键盘
    color_t     color;
    float       freq;
    bool        key_on;
};

当然, 音量可以也用不透明度表示——不过交给用户控制, 不然可能看不清. 目前简单乘以2再钳制到1.

static inline float vol2alpha(float vol) {
    vol *= 2.f; return vol > 1.f ? 1.f : vol;
}

自己(正式释出)打算用(类对数)曲线控制:

   ^
   |
   |                         XXXXXXXXXXX
   |                 XXXXXXXX
   |              XXX
   |            XX
   |          X
   |        X
   |      XX
   |     X
   |    X
   |   X
   |  X
   | X
   |X 
   |X
+---------------------------------------->
   |
   +

音色

我们知道, 音色是由其谐波决定的. 不过除开类似ΔPCM、FDS之类的基于波形信息的, 其余声部的音色其实已经预定好了, 我们需要的就是展现其内在信息.

例如方波就展示其'占空比'这一属性.

音量

不少声部都支持音量的调制.

sq1

例如看出橙色的方波#1占空比是25%, 音量大约是11. 绿色的方波#2占空比是75%, 音量约8.

各个声道计算

2A03

  • 方波: f = CPU / (16 * (t + 1))
  • 三角波: f = CPU/(32*(t + 1))
  • 噪声: 无
  • DMC: 无
  • 方波音量: 包络输出, 可以认为是线性值
  • 三角波音量: 不可调(或者DAC)

VRC6

  • 方波: f = CPU / (16 * (t + 1))
  • 锯齿波: f = CPU / (14 * (t + 1))
  • 方波音量: $9000, $A000相关4bit, 可能是线性值
  • 锯齿波音量: 其实叫做'rate', $B000, 6bit, 最大42(超过会失真). 但是最大输出是(rate*6)>>3, 5bit. 可能是线性值

VRC7

频率调制:

     49716 Hz * freq
F = -----------------
     2^(19 - octave)

注: 使用FM功能另算.

音量:

  • $30-$35, 低4位, 非线性值. 单位3.00dB.(衰减值, 15最小, 0最大)

FDS1

  • 波形表: f = CPU * current_picth / 65536 / 64
  • 音量:
  • master_volume=2/2, 2/3, 2/4, 2/5
  • volenv_gain 会被钳制到32

MMC5

  • 同2A03

N163

波形表频率:

f = wave frequency
l = wave length
c = number of channels
p = 18-bit frequency value
n = CPU clock rate (≈1789773 Hz)

f = (n * p) / (15 * 65536 * l * c)

相关参数:

* w[$80] = the 163's internal memory
* sample(x) = (w[x/2] >> ((x&1)*4)) & $0F
* phase = (w[$7D] << 16) + (w[$7B] << 8) + w[$79]
* freq = ((w[$7C] & $03) << 16) + (w[$7A] << 8) + w[$78]
* length = 256 - (w[$7C] & $FC)
* offset = w[$7E]
* volume = w[$7F] & $0F

FME7

  • Tone频率: f = CPU / (2 * 16 * Period)
  • Tone音量: $08-$0A 低4bit, 非线性值, 每个差距3dB.
  • 包络频率: f1 = CPU / (2 * 256 * Period)
  • 适用于: $08/$0C
  • 包络频率: f2 = CPU / (2 * 256 * Period * 2)
  • 适用于: $0A/$0E
  • 包络音量: 不可控

波形调制

多试了几个NSF, 在播放FDS时, 某些NSF音调上还是有点问题, 这个感觉找不到原因了, 这个BUG估计得留下来了.

N163, FDS1, 以及VRC7都可以算是通过给定波形进行指定频率输出. 当然还可以算上半个DMC. 对于这种我们可以显示原始波形在一旁作为参考.

N163和FDS1的波形是可以直接获取的. 但是VRC7的不能. DMC的这里暂时不考虑, 如果长度1kb的话就有8k个样本了.

n163

获取VRC7波形

我们再来回顾一下VRC7声道的控制位:

  • $10-$15 LLLL LLLL Channel low 8 bits of frequency
  • $20-$25 --ST OOOH Channel sustain (S), trigger (T), octave (O), high bit of frequency (H)
  • $30-$35 IIII VVVV Channel instrument (I), volume (V)

影响音色的只有 Sustain覆盖位, 以及 乐器索引. 后者是理所当然的, 前者是无关紧要的, 是用来控制衰减.

然后就是样本数量了, 参考FDS1的64个, N163的最大256个. 那就决定采样128个. 49716Hz / 128 = 388.4Hz, 比G4的392Hz略低, 即octave = 4.

freq = F * 2^(19-octave) / VRC7
freq = VRC7 / n * 2^(19-octave) / VRC7
freq = 2^(19-7-4)
freq = 256

ADSR包络

直接生成的话几乎没有声音, 因为正在处于'A'阶段. 最暴力的办法当然就是修改核心代码让这个情况返回0衰减. 这里就采用'ADSR'增加一个阶段, 这这个阶段直接返回0.

    case SFC_VRC7_Debug:
        rv = 0;
        break;

这样生成的波还是有点问题, 也就是粗糙模拟而已. 如果精确模拟的话可能太花时间了.

  • AM/FM 没有体现
  • ADSR包络中: 调制器会影响波形, 载波器会影响音量

vrc7

VRC7补漏

补充说明: 这里VRC7有一些不太影响的'小错误', 下面是后面补充的. 果然小细节就容易忘记! VRC7的音量是'衰减值', 15是最小音量, 0是最大音量. 所以单位变成负数了 -3.00dB x N.

然后自己又想到一个稍微精确的波形计算, 更简单但是需要一点额外的运算(也不多, 和上次一样128样本, 只不过是每帧都需要):

  • 仅仅将当前声道频率频率修改至和 上节所述频率一致(256/4)
  • 将载波器相位重置(更好的办法: 运行至起点, 缺点: 由于声道不同步所以计算量会乘以6)
/// <summary>
/// StepFC: VRC7生成波表
/// </summary>
/// <remarks>
/// out是 长度为128 x 6的缓冲区
/// </remarks>
/// <param name="famicom">The famicom.</param>
/// <param name="out">The out.</param>
/// <param name="instrument">The instrument.</param>
void sfc_vrc7_wavetable_update(sfc_famicom_t* famicom, float* const out) {
    // 获取当前VRC7状态
    sfc_vrc7_data_t vrc7_bk = famicom->apu.vrc7;
    const uint8_t* const vrc7_patch = sfc_get_vrc7_patch(famicom);
    // 仅仅重置频率
    for (int i = 0; i != 6; ++i) {
        sfc_vrc7_ch_t* const ch = vrc7_bk.ch + i;
        ch->freq = 256;
        ch->octave = 4;
        ch->carrier.phase = 0;
        sfc_vrc7_operator_changed(vrc7_patch, ch, &ch->modulator, 0);
        sfc_vrc7_operator_changed(vrc7_patch, ch, &ch->carrier, 1);
    }
    // 生成样本
    for (int i = 0; i != 128; ++i) {
        int32_t vrc7_output[6];
        sfc_vrc7_49716hz(&vrc7_bk, vrc7_patch, vrc7_output);
        for (int j = 0; j != 6; ++j) {
            const double v0 = (double)vrc7_output[j] / (double)(1 << 20);
            out[128 * j + i] = (float)v0;
        }
    }
}

这样生成的波形依然不准确, 但是可以体现ASDR的衰减和调制器!

vrc7

减少DrawCall

减少DrawCall是提示图形显示效率的最直接的办法. 这里, 如果相邻的两条线段斜率一致则可以进行合并.

实时版

这样称为'实时版', 不存在时间的问题, 可以在游戏的时候也能打开, 最后大致结果就是:

demo1

测试视频地址

框架优势

现在就要发挥目前框架优势了, 目前这个项目的优势为:

  • 提供接口
  • 事件驱动
  • 非单例

接口意味着允许'动态更换'.

事件驱动本来是自己对于音频的一种实现手段, 并且精度比不上基于周期的'次等手段', 不过本身就包含了音频事件信息, 并且没有真正处理音频.

非单例, 几乎每条函数都自带了一个sfc_famicom_t*参数, 这本来是'累赘'.

音频事件时间线

这是FT的界面

ft

我们可以看出有一条'时间线', 下方的就是未来的事件, 上方的就是之前的事件. 是时候发挥框架优点了: 变废为宝.

前面提到的优势就是: 我们可以同时运行两台虚拟机, 使用不同的接口. 一个跑在前面, 仅仅处理音频事件并且记录下来, 后面的才是真正的播放.

内容

时间线实际上可以表示很多(几乎所有)东西, 但是出于简单化, 考虑仅仅处理音量和音高.

其中音高值得注意的是, 作曲家可能会手动模拟FM——也就是'抖音'. 频率在一个音符范围内来回摆动, 这样在时间线是看不出区别的——都是一个音. 所以可以打上FM的标记, 键盘上的波浪线'~'就比较合适.

音量用线段表示就行. 而对应的AM暂时不考虑, 背景可以从折线看出来.

目前没有做优化, 每次都是从现有数据生成的. 这种最末端的代码, 自然是放在最后优化, 不然一改就傻了

预载版

这样称为'预载版', 只能在播放NSF时才能启用. 并且, 再配合'即时读档'功能时需要特别注意:

  • 每次读取时, 为两个虚拟机载入状态
  • 然后当记录者预运行N帧
  • 正常播放
  • 当然, 也可以干脆禁用这种情况的状态读写

demo2

测试视频地址

.

大致是这样的, 不过还是太简陋了, 不过反正仅仅是随便写的, 还没有正式释出.

还有一种比较典型的解决方案就是:

hist

这种也是一种解决方案, 比如可以用颜色表示声道, 宽度表示音量, 位置就是音高了. 不过自己还是希望看一下'谱子', 而不是'键位'.

StepE: 高级音频支持

这就本步的最后一节, 音频可视化. 这一步统称'高级音频支持'. 本步骤项目地址:

Github-StepFC-StepE

REF

附录: 负数MOD运算

((x % 12) + 12) % 12 微软编译器的实现:

mov         eax,2AAAAAABh  
imul        ecx  
push        esi  
sar         edx,1  
mov         eax,1  
mov         esi,edx  
shr         esi,1Fh  
add         esi,edx  
sub         eax,esi  
pop         esi  
lea         eax,[eax+eax*2]  
lea         eax,[ecx+eax*4]  
mov         ecx,0Ch  
cdq  
idiv        eax,ecx  
mov         eax,edx  
ret

将原本两次idiv优化成一次了. 所以, 膜还是很花时间的, 大家不要随便膜.

Re: 从零开始的红白机模拟 - [17]Mapper 001

STEP⑨: 实现部分Mapper

上一节很遗憾地没有实现DMC声道, 原因有一个: 使用Mapper0的游戏会用ΔPCM吗? 毕竟又没办法提前知道ROM到底用没用DMC

现在先实现:

  • MMC1 (1)
  • UxROM (2)
  • CNROM (3)
  • MMC3 (4)

Mapper001: MMC1 - SxROM

MMCx是任天堂自己开发的MMC, 任天堂自己开发的MMC是海外版(即NES)能够使用的MMC, 日本本土可以使用厂商自己生产的.

根据数据库, MMC1(在自己看来)比较有名的游戏, 比如:

其中当然是双截龙比较适合测试Mapper, 流程还行. 我才不知道什么修改$0505, $0506可以修改时间呢.

Banks

  • CPU $6000-$7FFF: 8 KB PRG RAM bank, fixed on all boards but SOROM and SXROM
  • CPU $8000-$BFFF: 16 KB PRG ROM bank, either switchable or fixed to the first bank
  • CPU $C000-$FFFF: 16 KB PRG ROM bank, either fixed to the last bank or switchable
  • PPU $0000-$0FFF: 4 KB switchable CHR bank
  • PPU $1000-$1FFF: 4 KB switchable CHR bank
  • ...
  • 居然搭载了额外的PRG-RAM
  • MMC1的PRG-ROM Bank单位是16KB
  • $8000-$BFFF初始化为第一个Bank
  • $0000-$0FFF初始化为最后一个Bank
  • MMC1的CHR-ROM Bank单位是4KB

寄存器

  • $8000-$FFFF连接了一个公共的5bit移位寄存器
  • 将最高位D7为1的值写入$8000-$FFFF会重置移位寄存器, 以及写入Control寄存器, 见后面
  • 而写入D7为0的值会将D0的数据依次写(移)入移位寄存器
  • 第五次写入后, 会根据第五次写入的地址把5bit数据写入内部的寄存器
  • $8000-$9FFF Control
  • $A000-$BFFF CHR bank 0
  • $C000-$DFFF CHR bank 1
  • $E000-$FFFF PRG bank

切换bank:

; Sets the switchable PRG ROM bank to the value of A.
;
              ;  A          MMC1_SR  MMC1_PB
setPRGBank:   ;  000edcba    10000             Start with an empty shift register (SR).  The 1 is used
  sta $E000   ;  000edcba -> a1000             to detect when the SR has become full.
  lsr a       ; >0000edcb    a1000
  sta $E000   ;  0000edcb -> ba100
  lsr a       ; >00000edc    ba100
  sta $E000   ;  00000edc -> cba10
  lsr a       ; >000000ed    cba10
  sta $E000   ;  000000ed -> dcba1             Once a 1 is shifted into the last position, the SR is full.
  lsr a       ; >0000000e    dcba1             
  sta $E000   ;  0000000e    dcba1 -> edcba    A write with the SR full copies D0 and the SR to a bank register
              ;              10000             ($E000-$FFFF means PRG bank number) and then clears the SR.
  rts

Load register ($8000-$FFFF)

7  bit  0
---- ----
Rxxx xxxD
|       |
|       +- Data bit to be shifted into shift register, LSB first
+--------- 1: Reset shift register and write Control with (Control OR $0C),
              locking PRG ROM at $C000-$FFFF to the last bank.

D7位写入1会让写入Control寄存器自己, 为之前的值'与'上$C, 让PRG-ROM切换模式至模式3(固定最后的bank至高地址, 切换16KB BANK至低地址)

Control ($8000-$9FFF)

4bit0
-----
CPPMM
|||||
|||++- Mirroring (0: one-screen, lower bank; 1: one-screen, upper bank;
|||               2: vertical; 3: horizontal)
|++--- PRG ROM bank mode (0, 1: switch 32 KB at $8000, ignoring low bit of bank number;
|                         2: fix first bank at $8000 and switch 16 KB bank at $C000;
|                         3: fix last bank at $C000 and switch 16 KB bank at $8000)
+----- CHR ROM bank mode (0: switch 8 KB at a time; 1: switch two separate 4 KB banks)

等等, ROM头说有4屏模式, 你这用一屏也太抠门了吧

CHR bank 0 ($A000-$BFFF)

4bit0
-----
CCCCC
|||||
+++++- Select 4 KB or 8 KB CHR bank at PPU $0000 (low bit ignored in 8 KB mode)

最高支持128KB的CHR-ROM, 5bit刚好32个4KB的bank. 8KB模式则只有16个.

4KB模式分成CHR bank 0和CHR bank 1. 8KB则会填充0和1.

CHR bank 1 ($C000-$DFFF)

4bit0
-----
CCCCC
|||||
+++++- Select 4 KB CHR bank at PPU $1000 (ignored in 8 KB mode)

8KB无视掉

PRG bank ($E000-$FFFF)

4bit0
-----
RPPPP
|||||
|++++- Select 16 KB PRG ROM bank (low bit ignored in 32 KB mode)
+----- PRG RAM chip enable (0: enabled; 1: disabled; ignored on MMC1A)

16KB为BANK, 可以支持16个, 也就是最多256KB的PRG-ROM?

变种

变种就不谈了, 懒得实现...

新的Mapper接口

可以看出这次Mapper会对高地址进行写入:

// 写入高地址
void (*write_high)(sfc_famicom_t* fc, uint16_t addr, uint8_t data);

由于Mapper也需要数据储存, C++实现可以delete掉之前的Mapper再new一个新的Mapper. 稍微复杂可以利用现有的缓冲空间进行Mapper的placement new. 这里的实现就是模拟placement new, 利用公共缓冲空间创建新的Mapper接口.

具体实现根据说明实现很简单: STEP9-MAPPER001.c

双截龙模拟中出现的问题

  • 有时会读取无效的地址($4XXX)
  • 不知道是实现有问题还是本身会读(倾向于实现有问题)
  • 下面的分数版显示有时会错位也就是'分割滚动'没有完全实现
  • dd-scroll
  • 上面这个问题实际是自己没有吃透滚动的的原理
  • 自己干脆按照PPU本身原理(就是vtxw寄存器, 香就一个字)
  • 然后就没有这个问题了
  • dd-clear
  • 单身狗(桌子下面)成吨的伤害! 游戏愉快!

REF

自己常用的C/C++小技巧[7]

自己常用的小技巧

这里列出了自己常用的一些c/c++小技巧, 有些会有不足, 可以简单探讨一下.

constexpr hash

分类: 小技巧

自从有了constexpr函数后, 感觉可以用在非常多的地方, 用在hash计算上是一个很不错的方案.

如果遇到很多字符串的话, 很多地方会采用hash的方式处理. 最大的原因就是如果hash值不同, 原字符串肯定不同.

不过, 在实际的简单应用中, 比如写一个简单的配置文件. 其中字符串可能会很多, 但是hash值如果相同, 就可以认为是字符串是匹配的. 即简单场合可以用hash代替字符串. 所以完全可以用hash值作为case.

这时候自然需要请出constexpr函数了, 不过还是建议配合'用户自定义字面量'生成hash, 这里用自己熟悉的‘BKDR’hash作为例子:

// detail namespace
namespace detail {
    template<uint32_t SEED>
    constexpr uint32_t const_bkdr(uint32_t hash, const char* str) noexcept {
        return *str ? const_bkdr<SEED>(
            static_cast<uint32_t>(static_cast<uint64_t>(hash) * SEED + (*str))
            , str + 1) : hash;
    }
}
// Typical BKDR hash function
constexpr uint32_t TypicalBKDR(const char* str) noexcept {
    return detail::const_bkdr<131>(0, str);
}
// BKDR
constexpr uint32_t operator ""_bkdr(const char* str, size_t) noexcept {
    return TypicalBKDR(str);
}

这里完成'BKDR'函数后, 然后就可以随意使用了:

// 前略

struct {
    int data1;
    int data2;
    int data3;
} g_data;

void set_datax(const char str[], int data){
    switch (TypicalBKDR(str))
    {
    case "data1"_bkdr:
        g_data.data1 = data;
        break;
    case "data2"_bkdr:
        g_data.data2 = data;
        break;
    case "data3"_bkdr:
        g_data.data3 = data;
        break;
    }
}


int main() {
    set_datax("data1", 45);
    set_datax("data1", 90);
    set_datax("data2", 11);
    set_datax("data3", 20);
    std::printf("%d - %d - %d\n", g_data.data1, g_data.data2, g_data.data3);
}

请注意的是, TypicalBKDR为了配合constexpr采用的递归实现, set_datax中直接调用了该函数. 在实际处理时, 请自己实现一个非递归版本的在运行期调用, 以提高效率.

用在case还有一个好处就是: 万一已存在的字符串发生了hash碰撞冲突, 在编译期就能检查出来.

case if

分类: 小技巧

前面提到了case, 这个和goto的标签很类似, C++使用时注意不要跳过对象的初始化. 但是不是每一个'case'标签都是独立的, 有可能会其他情况.

跳过已判断内容: 分支1可能会包含某个判断, 分支2隐含了该条件.

switch(foo)
{
case 0:
    if (bar && baz) {
case 1:
        // xxxxxxxxxxx
    }
    break;
}

当然, 还有可能是并列的情况: 共享部分代码, 可以使用if(0):

switch(foo)
{
    int bar;
case 0:
    bar = 123;
    if (0) {
case 1:
        bar = 321;
    }
    // 具体实现
    baz(bar, a,b,c);
    break;
}

由于共享部分代码, 而且会会使用其他数据, 使用函数代替不是很方便, 可以这么干. 不过由于c++有匿名表达式可以捕获变量所以这个小技巧在c++端自己没有用过.

这里简单介绍了两种case+if的用法, case当然还有很多其他用法, 有时间再介绍吧.

union转换

分类: 小技巧

C里面转换数据类型会使用'C风格转换', C++上自然不太安全, 所以增加了几种xxxx_cast. 由于职责不同, 有时会会让代码看起来很长. 这时候可能会使用'C风格转换', 但是有些时候用这个还是没有办法方便地进行转换.

比如前面提到用struct模拟的类型, 用传统的转换的话, 很有可能会出现'指针的指针的指针'什么的非常容易绕晕.

这时候可以考虑使用万能的union转换.

union {
    T1      type1;
    T2      type2;
} data;


data.type1 = foo;
return data.type2;

可谓是'万能转换'!

Re: 从零开始的红白机模拟 - [04]6502汇编

STEP2: 6502汇编

写模拟器当然要了解使用到的汇编, 不管是调试模拟器还是模拟器调试都需要.

前面知道了6502汇编用$表示十六进制, 那先讲讲6502机器码, 由一个操作码和0~2个地址码构成, 都是8位的:

/// <summary>
/// StepFC: 6502机器码
/// </summary>
typedef union {
    // 用32位保存数据
    uint32_t    data;
    // 4个8位数据
    struct {
        // 操作码 
        uint8_t op;
        // 地址码1
        uint8_t a1;
        // 地址码2
        uint8_t a2;
        // 显示控制
        uint8_t ctrl;
    };

} sfc_6502_code_t;

其中, 值得注意的是C11才支持的匿名struct/union. 请检查自己编译器支持的情况.

理论上, 6502拥有256条操作码, 这里是所有的指令表: 非官方OpCode

有一些被称为非法或者说未被文档记录的操作码, 但是文档提到

An accurate NES emulator must implement all instructions, not just the official ones. A small number of games use them (see below).

我们必须实现所有操作码.

指令

举一个简单的指令LDA, 就是"load 'A'(累加器)"的意思

寻址方式

指令LDA对应多个机器码, 区别是寻址方式的不同, 现在先列举所有的寻址方式以及格式, 详细的解释在下一节.

  1. 累加器A寻址 Accumulator
    单字节指令, 格式:
INS A
  1. 隐含寻址 Implied Addressing
    单字节指令, 格式:
INS
  1. 立即寻址 Immediate Addressing
    双字节指令, 格式
INS #$AB
  1. 绝对寻址 Absolute Addressing
    三字节指令, 格式
INS #$ABCD
  1. 绝对X变址 Absolute X Indexed Addressing
    三字节指令, 格式
INS #$ABCD, X
  1. 绝对Y变址 Absolute Y Indexed Addressing
    三字节指令, 格式
INS #$ABCD, Y
  1. 零页寻址 Zero-page Absolute Addressing
    双字节指令, 格式
INS #$A
  1. 零页X变址 Zero-page X Indexed Addressing
    双字节指令, 格式
INS #$A, X
  1. 零页Y变址 Zero-page Y Indexed Addressing
    双字节指令, 格式
INS #$A, Y
  1. 间接寻址 Indirect Addressing
    三字节指令, 格式
INS ($ABCD)
  1. 间接X变址: Pre-indexed Indirect Addressing
    双字节指令, 格式
INS (#$A, X)
  1. 间接Y变址: Post-indexed Indirect Addressing
    双字节指令, 格式
INS ($AB), Y
  1. 相对寻址: Relative Addressing
    双字节指令, 格式
INS $AB   - 16进制
INS +-abc - 有符号十进制
INS xxxxx - 跳转的目标地址的标签, 由汇编器自动计算
INS $ABCD - 跳转的目标地址, 由汇编器自动计算

反汇编

由于我们只需要反汇编而不需要汇编, 所以输出格式看自己喜好.比如除了最后一个, 看自己喜好实现吧.

现在就是根据操作码查找寻址方式和指令就能反汇编了:

/// <summary>
/// StepFC: 指定地方反汇编
/// </summary>
/// <param name="address">The address.</param>
/// <param name="famicom">The famicom.</param>
/// <param name="buf">The buf.</param>
void sfc_fc_disassembly(uint16_t address, const sfc_famicom_t* famicom, char buf[]) {
    // TODO: 根据操作码读取对应字节

    sfc_6502_code_t code;
    code.data = 0;
    // 暴力(NoMo)读取3字节
    code.op = sfc_read_cpu_address(address, famicom);
    code.a1 = sfc_read_cpu_address(address + 1, famicom);
    code.a2 = sfc_read_cpu_address(address + 2, famicom);
    // 反汇编
    sfc_6502_disassembly(code, buf);
}

目前暴力(?)读取3字节, 下次再实现读取指定字节数量.

建立一张表用于反汇编:

/// <summary>
/// 命令名称
/// </summary>
struct sfc_opname {
    // 3字名称
    char        name[3];
    // 寻址模式
    uint8_t     mode;
};

/// <summary>
/// 反汇编用数据
/// </summary>
static const struct sfc_opname s_opname_data[256] = {
    { 'B', 'R', 'K', SFC_AM_IMP },
    // 下略
};

就能反汇编了:

  • 00 - BRK
  • 等等等等
  • 这些全部要自己查表才行
  • 自己使用的微软编译器, 对C11支持不大行, 像_Alignas之类的明明C++那边实现了, C这边却没有
  • 对于查表用的数据可以 以CPU缓存行为单位对齐

反汇编

有了数据反汇编就太简单了, 核心部分应该是尽可能少使用外部函数. 这里可以用snprintf之类的格式化函数, 但是毕竟核心部分, 自己还是自己手写了像格式化位16进制字符串.

输出每个向量的第一个执行代码的汇编代码

output

这里, RESET执行的第一个指令是: SEI, 即 'Set I flag'

项目地址Github-StepFC-Step2

作业

  • 基础: 利用反汇编函数输出所有256个机器码的汇编代码
  • 扩展: 删掉反汇编实现函数, 自己实现反汇编函数.
  • 从零开始: 从零开始实现自己的模拟器吧

REF

自己常用的C/C++小技巧[2]

自己常用的小技巧

这里列出了自己常用的一些c/c++小技巧, 有些会有不足, 可以简单探讨一下.

32位/64位等 分类

分类: 小技巧

同理可以用于其他位, 比如16位什么的. 由于不同位的平台指针的大小可能是不同的, 所以导致一些逻辑必须分别讨论.

很多时候我们可能不会在意是移动平台还是桌面平台, 但是肯定会在意指针的大小. c++的话可以使用模板特化方便地处理, 模板特化也是c非常难以模拟的特性之一.

最简单的, 比如我们想在32位平台是用单精度浮点而64位平台使用双精度浮点:

template<int T>
struct float_helper_t{
};

template<>
struct float_helper_t<4> {
    using float_t = float;
};

template<>
struct float_helper_t<8> {
    using float_t = double;
};

using mfloat_t = float_helper_t<sizeof(void*)>::float_t;

零代价pimpl

分类: 隐藏实现, 零代价

pimpl很好用, 但是不是零代价的. 不过对象大小是在编译器是固定(c++), 我们可以利用c++11的std::aligned_storage创建一个零代价的pimpl. 同时针对不稳定API可以用static_assert进行编译期断言.

例如WinAPI有一个SRWLOCK, 表面上是一个指针. 虽然我们可以用指针重解释, 但是作为例子可以这么实现:

// 头文件

namespace detail {
    template<size_t> struct rwlocker_impl_info {};
    template<> struct rwlocker_impl_info<4> { enum { size = 4, align = 4 }; };
    template<> struct rwlocker_impl_info<8> { enum { size = 8, align = 8 }; };
}

class CRWLocker {
    enum { buf_size = detail::rwlocker_impl_info<sizeof(void*)>::size };
    enum { buf_align = detail::rwlocker_impl_info<sizeof(void*)>::align };
protected:
    std::aligned_storage<buf_size, buf_align>::type m_impl;
};


// 源文件

// 最好进行编译期断言

CRWLocker::CRWLocker() noexcept {
    // WinAPI 的SRWLOCK
    using ui_rwlocker_t = SRWLOCK; 
    static_assert(sizeof(ui_rwlocker_t) == buf_size, "must be same");
    static_assert(alignof(ui_rwlocker_t) == buf_align, "must be same");
    const auto locker = reinterpret_cast<ui_rwlocker_t*>(&m_impl);
    ::InitializeSRWLock(locker);
}

对于不稳定的API, static_assert是非常重要的.

链表多态

分类: 实现技巧

基础数据结构中, 链表由于是指针的重要体现, 可以非常方便地处理多态:

------       ------       ------
 node   -->   node   -->   node
------       ------       ------
data#1       data#2       data#3
------
             ------
                          ------

例如比较常用的"工厂模式"创建的各个对象可以用链表串起来:

struct Node {
    Node*   prev;
    Node*   next;
};

struct Factory {
    Factory();
    Node    head;
    Node    tail;
};


struct Obj1 : Node {
   int a;
};

struct Obj2 : Node {
   float a;
};

Factory::Factory() {
    head.prev = nullptr;
    head.next = &tail;
    tail.prev = &head;
    tail.next = nullptr;
}

每次添加节点可以在Factory::tail.prev处做文章. 删除节点由于有头节点与尾节点的存在非常简单:

node.prev->next = node.next;
node.next->prev = node.prev;

多态的实现, 一般来说就是c++使用的虚函数. 不过注意的是虚表指针会占用一个指针的空间, 所以和节点的布局可以有两种:

A {
    vtable*;
    node;
};

B {
    node;
    vtable*;
};

一般选用A模式, B比较难实现. A模式又有一个面向对象常有的问题: 包含, 还是继承?

c++有一些自己不喜欢的东西, 这些东西都是属于, 对程序猿隐藏. A模式使用继承的话, static_cast转换Node和继承类会隐含一个偏移判断, 这个隐藏没有问题. 问题是转换前会对指针进行判断, 如果是nullptr的话, 转换后还是nullptr, 这个很合理但是自己不喜欢, 添加了一个隐藏的分支.

所以自己可能会使用"包含"模式, 再使用offsetof进行手动转换, 虽然offsetof对于非标准布局是UB行为, 但是实际上不是offsetof是UB, 而是非标准布局.

Re: 从零开始的红白机模拟 - [33] MMC5 低语

任地狱MMC5

本文编写以及具体实现中, wiki的'MMC5'页面居然进行了修改(主要是针对BANK切换的说明进行了修正).

MMC5的Mapper编号就是005, 还是有不少的游戏(NesCartDb记录24款, 有美版日版之分). 不过用到MMC5扩展音源的只有:

其中值得一提的是, 金属之光被称为画面最好的FC游戏, 毕竟是可视小说(VN)类型. 其ROM大小是1MB, 在那时肯定是巨无霸水平的.

不过虽然只有少数用了扩展音源, 不过不像VRC7, MMC5没用上的游戏卡带还是拥有相应的硬件(至少FC版).

PRG-RAM

MMC5支持切换RAM-BANK, 这对于目前架构来说又是致命的, 只能再想想. 但是这不是最致命的, 最致命的是信息缺失.

例如《大航海時代》, 数据库中表明其拥有一个电池供电支持的16kb WRAM(SRAM). 一般地, 原始iNES文件头有:

  • 8: Size of PRG RAM in 8 KB units (Value 0 infers 8 KB for compatibility; see PRG RAM circuit)

亦或者NES 2.0的文件头:

  • Byte 10 (RAM size)

但是自己手上的大航海時代ROM这两个字节均是0. 也就是说ROM信息不够完整, wiki给出的解决方案是: 假定为64kb大小.

强大的MMC5

MMC5性能非常地强大:

  • 4种RPG-ROM切换模式
  • 4种CHR-ROM切换模式
  • 最高支持到128kb的WRAM, 除了WRAM的$6000-$7FFF, 甚至还能映射到$8000-$DFFF
  • 还有一个8bit-8bit到16bit的乘法器
  • 基于扫描线的IRQ计数器
  • 能够让8x16精灵和背景使用不同的CHR-BANK(黑科技啊)
  • "填充模式"的名称表, 用于场景转换很有用
  • 1024字节的'on-chip'内存, 用途还不少
  • MMC5扩展音源(自然)

BANK 切换

PRG mode 0:

  • CPU $6000-$7FFF: 8 KB switchable PRG RAM bank
  • CPU $8000-$FFFF: 32 KB switchable PRG ROM bank

PRG mode 1:

  • CPU $6000-$7FFF: 8 KB switchable PRG RAM bank
  • CPU $8000-$BFFF: 16 KB switchable PRG ROM/RAM bank
  • CPU $C000-$FFFF: 16 KB switchable PRG ROM bank

PRG mode 2:

  • CPU $6000-$7FFF: 8 KB switchable PRG RAM bank
  • CPU $8000-$BFFF: 16 KB switchable PRG ROM/RAM bank
  • CPU $C000-$DFFF: 8 KB switchable PRG ROM/RAM bank
  • CPU $E000-$FFFF: 8 KB switchable PRG ROM bank

PRG mode 3:

  • CPU $6000-$7FFF: 8 KB switchable PRG RAM bank
  • CPU $8000-$9FFF: 8 KB switchable PRG ROM/RAM bank
  • CPU $A000-$BFFF: 8 KB switchable PRG ROM/RAM bank
  • CPU $C000-$DFFF: 8 KB switchable PRG ROM/RAM bank
  • CPU $E000-$FFFF: 8 KB switchable PRG ROM bank

PRG-BANK 最后一个8kb BANK一定是ROM.

CHR mode 0:

  • PPU $0000-$1FFF: 8 KB switchable CHR bank

CHR mode 1:

  • PPU $0000-$0FFF: 4 KB switchable CHR bank
  • PPU $1000-$1FFF: 4 KB switchable CHR bank

CHR mode 2:

  • PPU $0000-$07FF: 2 KB switchable CHR bank
  • PPU $0800-$0FFF: 2 KB switchable CHR bank
  • PPU $1000-$17FF: 2 KB switchable CHR bank
  • PPU $1800-$1FFF: 2 KB switchable CHR bank

CHR mode 3:

  • PPU $0000-$03FF: 1 KB switchable CHR bank
  • PPU $0400-$07FF: 1 KB switchable CHR bank
  • PPU $0800-$0BFF: 1 KB switchable CHR bank
  • PPU $0C00-$0FFF: 1 KB switchable CHR bank
  • PPU $1000-$13FF: 1 KB switchable CHR bank
  • PPU $1400-$17FF: 1 KB switchable CHR bank
  • PPU $1800-$1BFF: 1 KB switchable CHR bank
  • PPU $1C00-$1FFF: 1 KB switchable CHR bank

CHR-BANK这里倒是没有什么.

PRG mode ($5100)

7  bit  0
---- ----
xxxx xxPP
       ||
       ++- Select PRG banking mode

大部分游戏使用的是模式3(除了恶魔城3-美版, 用了模式2). 暗荣的游戏从来不写入该寄存器, 可知默认是模式3.

CHR mode ($5101)

7  bit  0
---- ----
xxxx xxCC
       ||
       ++- Select CHR banking mode

金属之光使用的是模式1, 其他的使用的是模式3

PRG RAM Protect 1 ($5102)

7  bit  0
---- ----
xxxx xxWW
       ||
       ++- RAM protect 1

D1D0位必须是'10'(2)才能正常写入. 需要结合$5103.

PRG RAM Protect 2 ($5103)

7  bit  0
---- ----
xxxx xxWW
       ||
       ++- RAM protect 2

D1D0位必须是'01'(1)才能正常写入. 同样需要结合$5102.

Extended RAM mode ($5104)

7  bit  0
---- ----
xxxx xxXX
       ||
       ++- Specify extended RAM usage
  • 0 -Split模式, 作为额外的名称表使用
  • 1 -Split+ExGrafix模式, 作为扩展用属性表(自然也能用于名称表)
  • 2 -ExRAM模式, 作为一般的RAM
  • 3 -ExRAM模式, 作为一般的RAM, 写入保护

ExRAM@$000-$3BF:

  • ExGrafix模式: 用于强化背景显示
  • Split模式: 待补充
  • ExRAM模式: 扩展用

ExRAM@$3C0-$3FF:

  • ExGrafix模式: 未使用
  • Split模式: 待补充
  • ExRAM模式: 扩展用

Nametable mapping ($5105)

7  bit  0
---- ----
DDCC BBAA
|||| ||||
|||| ||++- Select nametable at PPU $2000-$23FF
|||| ++--- Select nametable at PPU $2400-$27FF
||++------ Select nametable at PPU $2800-$2BFF
++-------- Select nametable at PPU $2C00-$2FFF
  • 0 -自带的VRAM-前1kb
  • 1 -自带的VRAM-后1kb
  • 2 -内部扩展RAM, 不过$5104必须是模式00或者01, 否则全部读取到0
  • 3 -填充模式数据

MMC5内部实现应该是, 例如模式3, 就根据地址返回填充数据就行. 作为模拟器的话, 可以实现为:

    for (int i = 0; i != 4; ++i) {
        uint8_t* ptr = NULL;
        switch (value & 3)
        {
        case 0:
            ptr = famicom->video_memory + 1024 * 0;
            break;
        case 1:
            ptr = famicom->video_memory + 1024 * 1;
            break;
        case 2:
            //ptr = (mapper->exram_mode & 2) ? sfc_mmc5_zero_nt(famicom) : sfc_mmc5_exram(famicom);
            ptr = sfc_mmc5_exram(famicom);
            break;
        case 3:
            ptr = sfc_mmc5_fill_nt(famicom);
            break;
        }
        value >>= 2;
        base[i] = ptr;
    }

Fill-mode tile ($5106)

8位均用于'填充模式'的图块编号

Fill-mode color ($5107)

低2位用于'填充模式'的属性位, 实际填充的的是:

color = value & 3;
color = color | (color << 2) | (color << 4) | (color << 6);

可以具体平台使用位运算或者查表.

PRG Bank 0, 1, 2 ($5110-5112)

不在PRG空间内, 无效.

PRG Bank 3, RAM Only ($5113)

7  bit  0
---- ----
xxxx BBBB
     ||||
     ++++- PRG RAM bank number at $6000-$7FFF
      +--- Select PRG RAM chip 

这个就比较复杂了, 这就是前面提到的信息不完整. 就目前而言有以下几种情况:

  • 0KB: No chips
  • 8KB: 1x 8KB chip
  • 16KB: 2x 8KB chip
  • 32KB: 1x 32KB chip

D2位表示哪个'chip'. wiki建议始终假设为64kb, 然后根据这4bit(3bit)载入偏移数据, 因为2x8kb模式是写入'100'而不是'001'.

  • 实际上有能力最多支持到128kb, 但是实际上最多搭载了32kb额外RAM.
  • 自己打算使用32kb的实现, 但是将D2与D1做'异或'运算, 然后使用D1D0进行判断.
  • 这要求游戏使用非常标准思路的进行切换, 可能会有BUG(而且不支持64~128kb的额外RAM).
  • 这样内部本来WRAM有8kb就富余出来了, 用于MMC5内部其他的RAM.
  • 对, C/C++程序猿就是抠门.
    case 0x5104:
        // Extended RAM mode ($5104)
#ifndef NDEBUG
        printf("[%5d]MMC5: Extended RAM mode ($5104) = %02x\n", famicom->frame_counter, value & 3);
#endif
        mapper->exram_mode = value & 3;
        mapper->exram_write_mask_mmc5 = 0x00;
        famicom->ppu.data.ppu_mode = SFC_EZPPU_Normal;
        if (mapper->exram_mode == 1) {
            mapper->exram_write_mask_mmc5 = 0xff;
            famicom->ppu.data.ppu_mode = SFC_EXPPU_ExGrafix;
        }
        break;

PRG Bank 4, ROM/RAM ($5114)

7  bit  0
---- ----
RBBB BBBB
|||| ||||
|+++-++++- PRG ROM bank number
|    ++++- PRG RAM bank number
|     +--- Select PRG RAM chip 
+--------- RAM/ROM toggle (0: RAM; 1: ROM)
  • 模式 0 - 忽略
  • 模式 1 - 忽略
  • 模式 2 - 忽略
  • 模式 3 - 选择 8KB PRG bank @ $8000-$9FFF

PRG Bank 5, ROM/RAM ($5115)

7  bit  0
---- ----
RBBB BBBB
|||| ||||
|+++-++++- PRG ROM bank number
|    ++++- PRG RAM bank number
|     +--- Select PRG RAM chip 
+--------- RAM/ROM toggle (0: RAM; 1: ROM)
  • 模式 0 - 忽略
  • 模式 1 - 选择 16KB PRG bank @ $8000-$BFFF (忽略最低位)
  • 模式 2 - 选择 16KB PRG bank @ $8000-$BFFF (忽略最低位)
  • 模式 3 - 选择 8KB PRG bank @ $A000-$BFFF

PRG Bank 6, ROM/RAM ($5116)

7  bit  0
---- ----
RBBB BBBB
|||| ||||
|+++-++++- PRG ROM bank number
|    ++++- PRG RAM bank number
|     +--- Select PRG RAM chip 
+--------- RAM/ROM toggle (0: RAM; 1: ROM)
  • Mode 0 - 忽略
  • Mode 1 - 忽略
  • Mode 2 - 选择 8KB PRG bank @ $C000-$DFFF
  • Mode 3 - 选择 8KB PRG bank @ $C000-$DFFF

PRG Bank 7, ROM Only ($5117)

7  bit  0
---- ----
xBBB BBBB
 ||| ||||
 +++-++++- PRG ROM bank number
  • 模式 0 - 选择32KB PRG-ROM bank @ $8000-$FFFF (忽略低2位)
  • 模式 1 - 选择 16KB PRG-ROM bank @ $C000-$FFFF (忽略最低1位)
  • 模式 2 - 选择 8KB PRG-ROM bank @ $E000-$FFFF
  • 模式 3 - 选择 8KB PRG-ROM bank @ $E000-$FFFF

似乎启动时是往$5117写入$FF, 也就是最后8kb RPG-BANK载入最后一个BANK.

CHR Bankswitching ($5120-$5130)

前面提到了MMC5的'黑科技'——8x16精灵使用的图样表允许和背景使用的不同. 8x8模式只会使用$5120-$5127, 而8x16模式下$5120-$5127是针对精灵, $5128-$512B是针对背景.

并且, 最后一次写入的部分(前8, 后4), 会用于 PPUDATA ($2007)

wiki提到到目前未知还不清楚MMC5是怎么检测PPU处于哪种模式的, 真的'黑科技'.

写入地址 1 KiB 2 KiB 4 KiB 8 KiB
$5120 BANK0 - - -
$5121 BANK1 BANK0-1 - -
$5122 BANK2 - - -
$5123 BANK3 BANK2-3 BANK0-3 -
$5124 BANK4 - - -
$5125 BANK5 BANK4-5 - -
$5126 BANK6 - - -
$5127 BANK7 BANK5-7 BANK4-7 BANK0-7
$5128 BANK0, 4 - - -
$5129 BANK1, 5 01,45 - -
$512A BANK2, 6 - - -
$512B BANK3, 7 23,67 0-4,5-7 0-7

根据$5130的说法, 比如8kb模式就是选择的是8kb为窗口的BANK编号.

Upper CHR Bank bits ($5130)

7  bit  0
---- ----
xxxx xxBB
       ||
       ++- Upper bits for subsequent CHR bank writes

当使用1kb模式时, 最多只能访问256kb的CHR-ROM, 要访问整个1024kb就需要这两位了. 不过唯一一个超过256kb CHR-ROM的金属之光却使用的是4kb模式. 换句话说就是没有一个游戏使用了这个机能(甚至连初始化都没有).

Expansion RAM ($5C00-$5FFF, read/write)

  • 模式 0/1 - 不可读, 仅可在PPU渲染时可写(否则写入0)
  • 模式 2 - 可读可写
  • 模式 3 - 只读

模式1下( ExGrafix 模式), 就是MMC5实现的一个难点了: 扩展RAM区每个字节可以用来强化背景显示.

7  bit  0
---- ----
AACC CCCC
|||| ||||
||++-++++- Select 4 KB CHR bank to use with specified tile
++-------- Select palette to use with specified tile

4*64=256, 为了使用整个1024kb空间, 需要配合$5130的两位进行使用.

这种模式下基本可以确定使用的是单屏模式下. 举个栗子, 第一个图块$2000. 原本的模式下, 首先确定背景是用的哪个图样表, 然后利用[$2000]的数据, 获取图样数据.

现在ExGrafix模式下, 会在ExRAM:$000获取相应信息, 而本来的图样表几乎完全是为精灵服务的.

高精度的模拟器应该是模拟读取过程, 不过作为中精度的模拟器可以直接拿渲染开刀.

Split模式相关寄存器

待补充

目前只有宇宙警备队 SDF使用了该模式, 这个模式目前不想实现(懒), 等待以后实现吧.

IRQ Counter ($5203)

用于指定扫描线id来触发IRQ, 内部比如写入$04会在第5条可见扫描线开始时触发. 写入0应该是触发不了IRQ的. 由于是基于扫描线的, 所以应该只会在可见扫描线触发相关同步操作.

目前的Ez模式下, 本身自己是在每条扫描线最后触发水平同步的, 所以就是应该写入多少就在第几条触发.

IRQ Status ($5204, write)

7  bit  0
---- ----
Exxx xxxx
|
+--------- IRQ Enable flag (1=IRQs enabled)

写入仅仅用来启用/关闭IRQ功能, 即时关闭也能在本来可以触发IRQ情况将'Pending'置为1(当然不会触发IRQ).

IRQ Status ($5204, read)

7  bit  0
---- ----
SVxx xxxx  MMC5A default power-on value = $00
||
|+-------- "In Frame" signal
+--------- IRQ Pending flag

'In Frame'是当MMC5不再检测到扫描线信号时, 比如最后一根扫描线扫过, 或者说PPU没有渲染背景/精灵($2001相关位). 也就是说实际上如果中途关闭渲染, 会提前清除'In Frame'标志(懒得实现).

'Pending'标志会在MMC5的相关IRQ挂起时触发, 读取后清除(确认IRQ), 或者在'In Frame'0->1时也会清除.

Unsigned 8x8 to 16 Multiplier ($5205, $5206 read/write)

这就是那个16位乘法器了, 写入会进行乘法运算. 读取时, 低地址读取地址, 高地址读取高地址.

其他寄存器

其他还有一些就不介绍了

switch-case

由于地址部分连续, 部分离散, 所以只好直接用case了, 让编译器自己优化.

switch (address)
{
case 0x5100:
    // ...
case 0x5101:
    // ...
case 0x5102:
    // ...
    // ...
};

新接口

read_low, 读取[$4020, $6000), 这部分区域会调用该接口. 在正常情况下, 与之前的PRG段快速访问优化不冲突.

区别ROM RAM

目前是使用的32bit整型保存偏移量, 但是没有办法区别BANK是来自ROM还是RAM. 所以现在统一用最高位区别RAM与ROM.

/// <summary>
/// StepFC: 利用指针创建偏移量
/// </summary>
/// <param name="famicom">The famicom.</param>
/// <param name="ptr">The PTR.</param>
/// <returns></returns>
static inline uint32_t sfc_make_offset(sfc_famicom_t* famicom, const uint8_t* ptr) {
    const uint8_t* const fc0 = famicom->video_memory;
    const uint8_t* const fc1 = (const uint8_t*)(famicom + 1);
    // RAM
    if (ptr >= fc0 && ptr < fc1) {
        const uintptr_t rv = ptr - fc0;
        // 256 MiB够大了
        assert(rv < 0x10000000);
        return (uint32_t)rv;
    }
    // ROM
    else {
        const uintptr_t rv = ptr - famicom->rom_info.data_prgrom;
        // 256 MiB够大了
        assert(rv < 0x10000000);
        return (uint32_t)rv | (uint32_t)0x80000000;
    }
}

/// <summary>
/// StepFC: 利用偏移量创建指针
/// </summary>
/// <param name="famicom">The famicom.</param>
/// <param name="offset">The offset.</param>
/// <returns></returns>
static inline uint8_t* sfc_make_pointer(sfc_famicom_t* famicom, uint32_t offset) {
    // ROM
    if (offset & 0x80000000) return famicom->rom_info.data_prgrom + (offset & 0x7fffffff);
     // RAM
    else return famicom->video_memory + offset;
}

之前的文件头"-StepFC-SRAMWRAM"完全没有必要, 但是看着文件管理器显示9kb有点烦, 干脆去掉了. 完全根据大小信息判断:

  • 8kb: SRAM
  • 32kb: MMC5 PRG-RAM

模拟大航海时代出现的问题

bug

由于使用了MMC5-ExGrafix模式, 这个背景显示老是有问题. 以为是ExGrafix模式实现有问题, 结果发现是因为很久没碰渲染层导致逻辑忘了.

目前的实现每个像素有效位为低5位: [xxxA BCDE], 其中E是判断是否为全局背景: E = C | D, CD是图样的低两位, AB是属性位, 自己还以为是[xxxx ABCD]模式.

MMC5 扩展音源

MMC5的扩展音源相对其他来讲很简单——因为实际上已经实现过了: 和2A03的相关声道几乎一致. 对比起VRC6来, 没有锯齿波也罢了, 方波还没有什么特色.

所以在NSF创作者看来, MMC5扩展音源毫无特色. 用上MMC5基本上意味着已经没有其他扩展音源的可用了——MMC5仅仅是陪衬品.

Pulse 1 ($5000-$5003)

与2A03的方波区别:

  • $5001没有效果, MMC5方波没有扫描单元
  • 周期小于8并不会静音——会输出超声波
  • 似乎等于0(周期为1)时才会静音
  • 论坛上有人提出, 部分模拟器到7就静音是错误的实现. 但是就自己的观点, 模拟器发出超声波应该需要一个高质量的滤波器, 所以这个应该算'风格'而不是错误
  • 长度计数器速率会两倍于2A03的长度计数器运作(240Hz)
  • MMC5没有帧计数器(序列器), 包络(以及长度计数器)始终以240Hz运行(当然, 具体实现上, 可以借用已经实现了的帧序列器)

Pulse 2 ($5004-$5007)

第二个方波, 同上, 由于没有扫描单元, 完全一致(2A03的两个方波在扫描上有所区别——反码与补码).

PCM Mode/IRQ ($5010) Write

7  bit  0
---- ----
Ixxx xxxM
|       |
|       +- Mode select (0 = write mode. 1 = read mode.)
+--------- PCM IRQ enable (1 = enabled.)

PCM Mode/IRQ ($5010) Read

7  bit  0
---- ----
Ixxx xxxM  MMC5A default power-on read value = $01
|       |
|       +- In theory but not verified: Read back of mode select (0 = write mode. 1 = read mode.)
+--------- IRQ (0 = No IRQ triggered. 1 = IRQ was triggered.) Reading $5010 acknowledges the IRQ and clears this flag.

Raw PCM ($5011)

7  bit  0
---- ----
WWWW WWWW
|||| ||||
++++-++++- 8-bit PCM data

读取模式会忽略缩写内容, 写入$00不会影响输出(PCM值可能为0啊, 需不需要实现?)

PCM IRQ

有兴趣的可以自行了解.

Status ($5015, read/write)

类似$4015, 只有最低两位使用了.

模拟金属之光出现的问题

MMC5音源

一开始, 金属之光使用MMC5的二号方波, 用于播放文字'哔哔哔'的音效. 但是似乎希望, 通过往$5004写入$00来静音方波. 找了很久, 甚至一行一行对照其他模拟器的实现, 的确写入$00不能静音!

似乎陷入僵局, 又只好一步一步反汇编, 发现是通过读取$5DD5的数据写入$5004的, 所以结果是ExRAM实现有问题而不是方波!

之前不小心实现为:

mapper->exram_write_mask_mmc5 = 0x00;
famicom->ppu.data.ppu_mode = SFC_EZPPU_Normal;
if (mapper->exram_mode == 1) {
    famicom->ppu.data.ppu_mode = SFC_EXPPU_ExGrafix;
    mapper->exram_write_mask_mmc5 = 0xff;
}

金属之光使用的是模式2——ExRAM模式, 让所有数据写入0了, 应该实现为:

mapper->exram_write_mask_mmc5 = 0x00;
famicom->ppu.data.ppu_mode = SFC_EZPPU_Normal;
if (mapper->exram_mode == 1) 
    famicom->ppu.data.ppu_mode = SFC_EXPPU_ExGrafix;
else if (mapper->exram_mode == 2) 
    mapper->exram_write_mask_mmc5 = 0xff;

MMC5图像

金属之光虽然写入了两段CHR-BANK切换用寄存器, 也使用了8x16精灵模式, 也就是说使用了'黑科技'. 但是目前来看(时间原因, 主要是没有汉化玩不下去), 两段CHR-BANK切换写入的内容是一样的, 也就是实际上没有利用这一机能.

bug2

简单来说有两个问题:

  • 第一段, 令人吃惊的是, 金属之光在大致160行生成一个IRQ, 然后取消背景(与精灵)显示. 前面提到MMC5是检测扫描线的, 取消的话会让MMC5以为提前进入到了不可见阶段. 不过这不是问题, 第一段是灰色的, 原因是'烫烫烫': 没有渲染实际应该是渲染黑色, 实现时直接return了.
  • 第二段, 也让人吃惊的是, 金属之光触发IRQ后会修改调色板! 因为目前Ez模式是假定调色板不会在渲染时修改(所以金属之光在160行后开始取消渲染了), 背景最多同时使用12+1种颜色. 这样一换能使用的颜色就翻倍了, 不过这游戏仅仅用来处理字体颜色.

第一个BUG目前来说不可能修改, 原因是因为储存的是调色板索引, 不能表示'黑色'. 第二个BUG和之前的IRQ中切换BANK一样, 有两种解决方案:

  1. 储存调色板实际颜色信息, 这样其实两个BUG都能一起解决掉.
  2. 将画面分割成两部分——IRQ作为分界线. 保存独立的相关数据作为备份信息. 这样的话能够连带BANK切换的问题一起解决

目前来看, 这两种解决方案的选择应该是: 两个都要(我全都要)! 可以用来解决目前渲染的3个已知BUG, 交给后面完成吧(懒)!

简单汇总

MMC5近乎20款游戏中, 这里仅仅测试了两款——大航海时代金属之光.

大航海时代使用了ExGrafix模式, 但是仅仅是方便程序猿进行场景布置, 而没有渲染出令人信服的画面.

金属之光: MMC5的三个图像加强的功能, 金属之光实际都没用上(大概), MMC5的额外音源也仅仅用来放文字'哔哔'音效(大概). 可以看出这游戏似乎一开始可能并没有打算使用MMC5.

总的来说MMC5是一款非常强大的MMC, 导致模拟器大幅度地进行调整, 甚至在渲染层打洞来支持MMC5特性. 由于精力原因目前还没有完全模拟: Split模式与'黑科技'模式没有实现, 打上'TODO'等待以后慢慢补上.

(RAM写入保护也没有实现, 填充模式实现了, 但似乎没用过导致没法测试)

REF

Re: 从零开始的红白机模拟 - [12]基础输入

STEP5: 手柄输入

为了让这个测试ROM能够通过, 我们加入手柄输入试试吧.

一般来说最少得有1个手柄, 不然只能看着马大叔被板栗弄死.

最高可支持4个手柄输入, 比如这个款游戏:
油管 - 4 Player Madness - RC PRO-AM 2 (NES)

但是是通过特殊装置接驳上去的.

标准输入

先支持普通双手柄吧, 最后完善测试再说其他情况.

Controller port registers

根据文档, NES(欧美版) 和 Famicom(日版) 存在区别:

地址 比特位 功能
N: $4016 (写) ----, ---A 为全手柄写入选通(strobe)
N: $4016 (写) ----, -CBA 为扩展端口写入数据
N: $4016 (读) ---4, 3--0 从手柄端口#1读到的数据
N: $4016 (读) ---4, 3210 从扩展端口读到的数据
N: $4017 (读) ---4, 3--0 从手柄端口#2读到的数据
N: $4017 (读) ---4, 3210 从扩展端口读到的数据
F: $4016 (写) ----, ---A 为全手柄写入选通(strobe)
F: $4016 (写) ----, -CBA 为扩展端口写入数据
F: $4016 (读) ----, -M-0 从手柄端口#1读到的数据, 以及手柄端口2读取到的麦克风(M)数据
F: $4016 (读) ----, --1- 从扩展端口读到的数据
F: $4017 (读) ----, ---0 从手柄端口#2读到的数据
F: $4017 (读) ---4, 3210 从扩展端口读到的数据

同4人手柄, 扩展端口就直接...所以实现基本输入就行, 反正绝大数游戏都是支持的 —— 也就是最低位.

strobe(选通)乍一看! 还以为是string.h的一个函数... 对于这里输入来说, 是一种重置手段:

  1. 为0时表示按钮状态会被像字节流一样被连续读取.
  2. 为一表示重置按钮读取序列状态, 会被一直'重置'直到被设为0

也就是说读取按钮状态应该这样:

  • WRITE $4016, 1
  • WRITE $4016, 0
  • READ $4016
  • READ $4016
  • ....
  • READ重复8次(拥有8个按钮)

按钮序列如下:

  • A
  • B
  • Select
  • Start
  • Up
  • Down
  • Left
  • Right

读取大概就是这样:

case 0x16:
    // 手柄端口#1
    data = (famicom->button_states+0)[famicom->button_index_1 & famicom->button_index_mask];
    ++famicom->button_index_1;
    break;
case 0x17:
    // 手柄端口#2
    data = (famicom->button_states+8)[famicom->button_index_2 & famicom->button_index_mask];
    ++famicom->button_index_2;
    break;

写入大概这样:

case 0x16:
    // 手柄端口
    famicom->button_index_mask = (data & 1) ? 0x0 : 0x7;
    if (data & 1) {
        famicom->button_index_1 = 0;
        famicom->button_index_2 = 0;
    }
    break;

黑匣子

之前提到用户输入的黑匣子函数, 现在详细说说.

void user_input(int index, unsigned char data);

用户输入时调用, index是0-15, 前面8个是手柄1, 后面8个是手柄2. 不过, 我记得二号手柄没有select/start键?
键盘映射如下:

static const unsigned sc_key_map[16] = {
    // A, B, Select, Start, Up, Down, Left, Right
    'J', 'K', 'U', 'I', 'W', 'S', 'A', 'D',
    // A, B, Select, Start, Up, Down, Left, Right
    VK_NUMPAD2, VK_NUMPAD3, VK_NUMPAD5, VK_NUMPAD6, 
    VK_UP, VK_DOWN, VK_LEFT, VK_RIGHT,
};

输出效果

first-pass

按下'I'键(Start)就能测试了. 历时这么久, 终于把基础指令测试通过了! 即便没有实现BRK和CLI!

同时, 也宣告着这个ROM的使命完成了. 感谢ROM作者kevtris.

这一节很简单! 主要当然只是实现了基本的输入而已. 项目地址Github-StepFC-Step5

作业

  • 基础: 重新实现一次吧
  • 扩展: 如果熟悉Windows编程的话, 尝试实现连发键
  • 从零开始: 从零开始实现自己的模拟器吧

REF

自己常用的C/C++小技巧[1]

自己常用的小技巧

这里列出了自己常用的一些c/c++小技巧, 有些会有不足, 可以简单探讨一下.

接口

分类: 标准

这个是一个几乎为标准的一个东西, c++端将所有函数声明为纯虚函数, 而且不含成员变量, 如:

#if defined(_MSC_VER) && defined(__cplusplus)
#define NOVTABLE __declspec(novtable)
#else
#define NOVTABLE
#endif

struct NOVTABLE IObject {
    // 释放接口
    virtual void Dispose() = 0;
};

类微软编译器所实现的虚函数为虚表以兼容微软编译器(COM组件的实现), 同时微软编译提供了一个优化扩展语句__declspec(novtable)为不需要虚表的类, 不会创建虚表, 以减小程序体积.

c方面则没有标准, 可以像虚表一样实现以兼容:

typedef struct {
    // 释放接口
    void(*dispose)(void* user_ptr);

} object_vtable_t;

typedef struct {
    // 释放接口
    object_vtable_t*        vtable_ptr;

} object_t;

或者直接使用函数指针:

typedef struct {
    // 释放接口
    void(*dispose)(void* user_ptr);
    // 用户数据
    void*       user_ptr;
} object_t;

这样的目的, 用面向对象那一套说就是多态. 还有的, 用自己的话就是'自定义', '自定义'部分推荐使用虚函数实现, 因为编译器会发现只有一个实现而可能会直接调用(激进型优化).

更换接口

分类: 减少分支

常见于c库, 因为一般实现为直接使用函数指针:

typedef struct {
    // 输出数据
    void(*output)(void* user_ptr, const output_t* data);
    // 用户数据
    void*       user_ptr;
} object_t;

很多库会有, 或者说目的就是"输出数据". 比如音频解码之类的, 我们输入原始数据, 解码库输出数据.

但是有可能输出数据的格式每次会不一样, 我们可以在函数里面判断格式再跳转分支. 或者, 判断格式后直接更换接口, 以减少分支跳转.

混合接口

分类: 提高效率

这是自己"自定义"的东西了, 在接口中, 可能需要实现一些'get'操作, 如果'get'是一个简单的return的话, 自己称之为"不划算", 本来可以简单获得的东西没必要单独用虚函数, 浪费空间与时间. 例如:

struct IStream1 {
    // 读取流
    virtual int Read(void*, int) = 0;
    // 获取流长度
    virtual int GetLength() const = 0;
};

struct XStream2 {
    // 读取流
    virtual int Read(void*, int) = 0;
    // 获取流长度
    int GetLength() const { return m_length };
protected:
    // 流长度
    int         m_length;
};

如果是内部接口的话, 自己还会简单写为:

struct XStream3 {
    // 读取流
    virtual int Read(void*, int) = 0;
    // 流长度
    int         length;
};

一方面这多亏了c++允许多继承, 不过实际上多继承还是用的不多, 一般还是单继承.

当然, 这个只适合简单return的'get', 复杂的不算. 复杂的函数用虚函数就比较"划算"了.

pimpl

分类: 标准

pimpl对于c++来说是一个小技巧, 对于c来说几乎没必要或者说实际上处处都能用上. 即 pointer to implement, 自己会酌情使用pimpl:

// 头文件

struct CImpl;

class CPimpl {
    CImpl*       m_pImpl;
};


// 源文件


// 具体实现
struct Impl 
    int a;
};

这样目的就是隐藏实现, 合理使用可以变相地提高编译速度.

::Private

分类: 隐藏实现, 提高编译速度

C++中, 如果是一个比较大的类, 自己会看情况声明一个private, public或者其他权限的::Private结构体:

class CControl1 {
    struct Private;
};

class CControl2 {
public:
    struct Private;
};

这样主要是内层嵌套类允许访问外层的private部分, 自己会有两种用法, 一种就是和pimpl结合使用:

// 头文件
class CPimpl {
    struct Private;
    Private*       m_pImpl;
};

// 源文件
struct CPimpl::Private {
    // 具体实现
};

以及提高编译速度, 减少暴露细节:

试想一下, 如果要写一个大switch, 各个分支肯定是调用函数, 而不是直接写. 如果是这是一条类函数, 这时候就得修改该类的声明, 然后所有引用该头文件的源文件都必须重新编译一次.

// 头文件
class CControl1 {
    int x;
public:
    void OnCase0();
    void OnCase1();
    int GetX() const { return x; }
};
// 源文件
void CControl1::OnCase0() {

}
void CControl1::OnCase1() {

}

而这样实现则可以实现伪动态添加:

// 头文件

class CControl2 {
    int x;
public:
    struct Private;
};

// 源文件1
strtuct CControl2::Private{
    static void OnCase0(CControl2&) {

    }
};

// 源文件2
strtuct CControl2::Private{
    static void OnCase1(CControl2&) {

    }
    static int& GetX(CControl2& x) {
        return x.x;
    }
};

由于每个编译单元(源文件)是独立的, CControl2::Private允许在不同源文件声明地不一致. 这也极大地方便了我们.

private ::Super

分类: 编码技巧

如果自己的c++类存在真正的"继承"关系, 自己会在类声明的第一行(private区域)typedef/using一个Super类型指向基类(超类).

class A{

};

class B : public A {
    using Super = A;
};

在自己所谓真正的"继承关系", 主要是会重写(override)基类的一条或多条虚函数. 某些情况下, 我们派生类需要的是"强化"基类虚函数, 而不是完全的重写基类. 这时候我们会调用基类的该条函数:

void B::Update() {
    // xxxxxx
    A::Update();
}

如果使用了private ::Super的话, 可以直接无脑:

void B::Update() {
    // xxxxxx
    Super::Update();
}

这样, 如果重定向B的基类, 只需要修头文件紧贴的两行, 后面的源文件不用修改.

再特殊一点, 如果实现类有一条函数是跳过基类的实现而调用高阶基类的实现:

class A{

};

class B : public A {
    using Super = A;
};

class C : public B {
    using Super = B;
};

然后

void C::Call() {
    Super::Call();
}

void C::Update() {
    A::Update();
}

这样可以很明显知道C::Update是特殊实现, 提高了可读性.

自己常用的C/C++小技巧[3]

自己常用的小技巧

这里列出了自己常用的一些c/c++小技巧, 有些会有不足, 可以简单探讨一下.

颜色混合

分类: 小技巧

有一个可能很常用的操作, 或者说函数: 平均数. 通过基础数学我们可以知道(a+b)/2, 如果用c中无符号整型表示:

uint32_t avg(uint32_t a, uint32_t b) {
    return (a+b)/2;
}

一般情况下没问题, 但是在两个数足够大时可能会产生溢出, 由于乘法分配律可知, 我们可以实现为:

uint32_t avg(uint32_t a, uint32_t b) {
    return a/2 + b/2;
}

但是我们操作的是无符号整型, 两个奇数一操作最低位就被忽略了, 所以我们可以利用位操作, 再加上除以2可以化作右移一位, 我们可以实现为:

uint32_t avg(uint32_t a, uint32_t b) {
    return (a>>1) + (b>>1) + (a&b&1);
}

现在我们可以推广至颜色上, 我们现在常用的是R8G8B8X8格式, X表示忽略或者表示不透明度. 如果现在我们规定低到高位分别是RGB, 最高8位忽略, 并假设为0:

#define R8G8B8_MASK_HI ((uint32_t)0x00FEFEFE)
#define R8G8B8_MASK_LO ((uint32_t)0x00010101)

uint32_t avg_r8g8b8(uint32_t a, uint32_t b) {
    return ((a&R8G8B8_MASK_HI)>>1) + ((b&R8G8B8_MASK_HI)>>1) + (a&b&R8G8B8_MASK_LO);
}

再推广一下, 以后我们可能觉得24bit颜色太辣鸡, 推广到30bit. 我们现在使用R10G10B10X2格式, 这个就可以修改为:

#define R10G10B10_MASK_HI ((uint32_t)0x3FEFFBFE)
#define R10G10B10_MASK_LO ((uint32_t)0x00100401)

uint32_t avg_r10g10b10(uint32_t a, uint32_t b) {
    return ((a&R10G10B10_MASK_HI) >> 1) + ((b&R8G8B8_MASK_HI) >> 1) + (a&b&R8G8B8_MASK_LO);
}

不过实际上主要是不透明度的原因, 这个方法在用于包含Alpha信息的颜色时, 会由于Alpha权重问题出现问题. 这个问题引申出一个"预乘Alpha"的概念, 自己被这个东西坑过, 真是记忆犹新。

合并申请

分类: 优化技巧

这个技巧很简单, 将多次内存申请合并到一次, 优点为:

  • 提高效率, 内存申请一般来说是比较耗时的操作, 合并后效率提升是很自然的
  • 减少逻辑分支, 如果需要申请两块内存, 错误路径会有三个, 其中两个特殊路径还要去释放已经申请的内存. 合并后错误路径就只有一个.
void fx0(void) {
    void* a = malloc(100);
    void* b = malloc(200);
    if (a && b) {
        // XXXXX
    }
    // 虽然free接受空指针, 实际上可能会有类似析构的操作, 还是需要判断
    if (a) free(a);
    if (b) free(b);
}
void fx1(void) {
    void* a = malloc(100);
    if (a) {
        void* b = malloc(200);
        if (b) {
            // XXXXXXXXXXXXXX
            free(b);
        }
        free(a);
    }
}
void gx(void) {
    void* a = malloc(300);
    if (a) {
        void* b = (char*)a + 100;
        // XXXXXXXXXXXXXX
        free(a);
    }
}

缺点, 不适合处理大空间申请, 比如申请两个250MB的内存最好分别申请, 不然地址空间上可能找不到500MB连续空间, 但是能找到两个250MB的连续空间.

还有一个小缺点就是, 现在的运行库配合IDE能够在越界后进行报错. 假如合并, 前者越界会写入下一段空间, 这个非常危险, 并且比较难调试(所以越界检测是非常重要的, 建议使用大量的断言进行处理).

虽然这个技巧很简单, 但是实际上需要直接申请两个独立部分可能比较少见, 常见是"隐式"地多次申请:

struct A {
    B*  b = new(std::nothrow) B;
    C*  c = new(std::nothrow) C;
};

struct D {
    B*  b = new(std::nothrow) B;
};

int main() {
    A a;
    delete(new(std::nothrow) D);
}

这里列举两种情况, A情况就是上述比较明显的, 拥有两次显式的new, 而D的情况则是隐式的两次. 有一点就是我们可以通过replacement new和直接调用析构函数进行内存的再利用, 但是非常注意的是内存对齐的情况, c++目前并没有将c11的aligned_alloc纳入标准(即便纳入按照微软的脾气还是不可能在VS上实现, 主要是允许freealigned_alloc的), 申请对齐的内存需要自己处理又无形地增加了复杂度(好在一般按照8字节对齐就行, 除了特殊要求).

有时会我们会经常的使用"悬浮"状态的对象, 就像D这种, 完全手动控制生命周期的, 其中隐含的多次申请依然可以合并, 只需要找准偏移量即可.

柔性数组, 或者说零长数组(Zero-Length Array) 其实就是这个的一个体现:

struct array_t {
    size_t  length;
    char    data[0];
};

其中类c++扩展是[0], c99标准是[]. c++中, 有时候为了避免兼容问题, 会不写成员变量, 取而代之的是成员函数:

struct array_t {
    size_t  length;
    char* data() { return reinterpret_cast<char*>(this+1) }
};

数组多态

分类: 内部优化

数组一般来说就是储存同一类型的数据, 如果需要多态, 又要保持随机访问可能的就是储存指针数组. 不过内部使用或者强制规定的话, 我们可以规定一个上限, 各个类型的较大值.

enum : size_t { THIS_MAX_SIZE = 256, THIS_MAX_COUNT = 16 };

class Factory {
    template<typename T> T* get_at(size_t i) {
        assert(i < m_count && "out of range");
        return reinterpret_cast<T*>(m_buffer + THIS_MAX_SIZE * i);
    }
    size_t      m_count;
    uint8_t     m_buffer[THIS_MAX_SIZE*THIS_MAX_COUNT];
};

类似这样, 其实就是上一条: 合并申请. 然后手动控制对象构造(replacement new)与析构(调用析构函数), 对于"内部优化"来说, 是允许的.

Re: 从零开始的红白机模拟 - [01]FC/NES模拟器

任天堂 Family Computer

nintendo-famicom-console-set-fl

  在国内因为外观被称为红白机的任天堂FC(欧美版叫NES), 或者说Famicom(典型的日式英语缩写), 可谓是自己走向编程开发道路上不可缺少的一环. 可能有许多和我类似的因为玩上游戏, 就想开发一款自己的游戏, 而踏进这个圈子的(然后在开发的道路越走越远).

  虽然这么说, 但是实际上玩的是国内生产的兼容机. 作为童年不可忽略的一环, 后有诗赞曰

小霸王骑了吴琼

Re: FC模拟器

  这里, 就让我记录FC开发模拟器部分细节, 也就是Re: 从零开始的红白机模拟.

  其中在大学的时候, 用C++11做了一款FC的模拟器, 已经完整地模拟了CPU, 然后...就没有然后了. 不过这个项目了解到使用到的C++特性很少, 所以这里重新用C在做一次, 这一次当然是完整地模拟FC. 这也是标题Re: 的由来, 才不是Neta!

这也是自己第一个核心用C写项目

从零开始

  开局一款编译器, 代码全靠捡. 这系列博客属于笔记性质, 所以写得很详细方便以后查看. 也要求读者拥有基本的能力, 例如C, 多媒体之类的.

balloon

(笔者喜欢的一款FC(友尽)游戏-打气球)

硬件概述

直接摘选自wiki:

FC使用一颗理光制造的8位2A03 NMOS处理器(基于6502**处理器,但是缺乏BCD模式),PAL制式机型运行频率为1.773447MHz,NTSC制式机型运行频率为1.7897725MHz,主内存和显示内存为2KB。
FC使用理光开发的图像控制器(PPU),有 2KB 的视频内存,调色盘可显示 48 色及 5 个灰阶。一个画面可显示 64 个角色(sprites) ,角色格式为 8x8 或 8x16 个像素,一条扫描线最多显示 8 个角色,虽然可以超过此限制,但是会造成角色闪烁。背景仅能显示一个卷轴,画面分辨率为 256x240 ,但因为 NTSC 系统的限制,不能显示顶部及底部的 8 条扫描线,所以分辨率剩下 256x224。
从体系结构上来说,FC有一个伪声音处理器 (pseudo-Audiom Processing Unit,pAPU),在实际硬件中,这个处理器是集成在2A03 NMOS处理器中的。pAPU内置了2个几乎一样(nearly-identical)的矩形波通道、1个三角波通道、1个噪声通道和1个音频采样回放通道(DCM,增量调制方式。其中3个模拟声道用于演奏乐音,1个杂音声道表现特殊声效(爆炸声、枪炮声等),音频采样回放通道则可以用来表现连续的背景音。

评论

仅仅2kb内存和2kb显存就能演绎出童年的色彩!

Hello world!

开始在这里写博客了, 国内各种要求绑定手机, 我写个没人看的文章至于么Orz.

Re: 从零开始的红白机模拟 - [05]寻址模式

STEP3: CPU 指令实现 - 寻址模式

终于进入了所谓的"本番", 通过阅读文档, 可知CPU拥有下列的寄存器:

  • [8位]累加寄存器 Accumulator
  • [8位]X 变址寄存器 (X Index Register)
  • [8位]Y 变址寄存器 (Y Index Register)
  • [8位]状态寄存器 (Status Register)
  • [8位]栈指针 (Stack Pointer)
  • [16位]指令计数器 (Program Counter)
  • 其中栈是指"一页"($100-$1FF)
  • 栈指针初始化为$FD即指向$1FD
  • 不过由于内存太宝贵了, 把八分之一的内存作为栈实在太浪费. 程序猿一般会会把栈地址开始的一段作为其他用途, 比如这里

大致写为

 // 状态寄存器标记
enum sfc_status_flag {
    SFC_FLAG_C = 1 << 0,    // 进位标记(Carry flag)
    SFC_FLAG_Z = 1 << 1,    // 零标记 (Zero flag)
    SFC_FLAG_I = 1 << 2,    // 禁止中断(Irq disabled flag)
    SFC_FLAG_D = 1 << 3,    // 十进制模式(Decimal mode flag)
    SFC_FLAG_B = 1 << 4,    // 软件中断(BRK flag)
    SFC_FLAG_R = 1 << 5,    // 保留标记(Reserved) 一直为1
    SFC_FLAG_V = 1 << 6,    // 溢出标记(Overflow  flag)
    SFC_FLAG_S = 1 << 7,    // 符号标记(Sign flag)
    SFC_FLAG_N = SFC_FLAG_S,// 又叫(Negative Flag)
};
// CPU寄存器
typedef struct {
    // 指令计数器 Program Counter
    uint16_t    program_counter;
    // 状态寄存器 Status Register
    uint8_t     status;
    // 累加寄存器 Accumulator
    uint8_t     accumulator;
    // X 索引寄存器 X Index Register
    uint8_t     x_index;
    // Y 索引寄存器 Y Index Register
    uint8_t     y_index;
    // 栈指针  Stack Pointer
    uint8_t     stack_pointer;
    // 保留对齐用
    uint8_t     unused;
} sfc_cpu_register_t;

其中, 6502实际上只有6个FLAG可用, 其中一个就是FLAG_B, D4.
CPU status flag behavior

指令 D5 以及 D4 PUSH后影响
PHP 11 没有
BRK 11 IF 设为1
IRQ 10 IF 设为1
NMI 10 IF 设为1

换句话说FLAG_B唯一有效果的情况就是: PHP-BRK和IRQ-NMI行为不一样.

有两条指令(PLP 及 RTI) 会从栈上设置P状态, 这时候会无视D4 D5.

其中状态寄存器可以考虑实现为uint8_t[8].

CPU定制信息

NES的6502并不包括对decimal模式的支持. CLD和SED指令正常工作, 但是p中的 'd' bit在ADC和SBC中并未被使用. 在游戏中将CLD先执行于代码是普遍的行为,就像启动和RESET时的 'd' 状态并不为人知一样.
音频寄存器被放置于CPU内部; 所有波形的发生也都在CPU的内部.

简单地说就是原版6502支持10进制模式, 定制版去掉了10进制支持(, 并增加了音频支持, 暂且不谈). 状态寄存器中有一个标志位'D'用来标记是不是10进制, 清除(CLD, clear 'd')与设置(SED, set 'd')指令是正常工作的(其中十进制模式不必实现, 实现了也没有游戏给你跑).

寻址模式

寻址, 顾名思义寻找地址.

寻址方式就是处理器根据指令中给出的地址信息来寻找有效地址的方式,是确定本条指令的数据地址以及下一条要执行的指令地址的方法

获取操作码 伪C代码:

opcode = READ(pc);
pc++;
  1. 累加器寻址 Accumulator
    操作对象是累加器, 个人认为可以被划分至下面一条隐含寻址, 只是语法稍微不同

    $0A
    ASL A - (累加器A内容按位算术左移1位)
    

    指令伪C代码

    // 空
  2. 隐含寻址 Implied Addressing
    单字节指令, 指令已经隐含了操作地址

    $AA
    TAX - (将累加器中的值传给 X 寄存器, 即 X = A)
    

    指令伪C代码

    // 空
  3. 立即寻址 Immediate Addressing
    双字节指令, 指令的操作数部分给出的不是 操作数地址而是操作数本身,我们称为立即数(00-FF之间的任意数)
    在6502汇编中,这种模式以操作数(即立即数)前加 "#" 来标记.

    $A9 $0A
    LDA #$0A - (将内存值$0A载入累加器, 即 A = 0x0A)
    

    指令伪C代码

    // 指令计数器的地址即为当前需要读取的地址
    address = pc;
    // 双字节指令(获取操作码已经+1)
    pc++;
  4. 绝对寻址 Absolute Addressing 又称直接寻址
    三字节指令, 指令的操作数给出的是操作数, 在存储器中的有效地址

    $AD $F6 $31
    LDA $31F6 - (将地址为$31F6的值载入累加器, 即 A = [$31F6])
    

    指令伪C代码

    // PC指向的两个字节解释为地址
    address = READ(pc++);
    address |= READ(pc++) << 8;
  5. 零页寻址 全称绝对零页寻址 Zero-page Absolute Addressing
    双字节指令, 将地址$00-$FF称之为零页, 绝对寻址中如果高字节为0, 即可变为零页寻址, 直接能够节约一个字节, 速度较快, 所以经常使用的数据可以放在零页.

    $A5 $F4
    LDA $F4 - (将地址为$00F4的值载入累加器, 即 A = *0x00F4)
    

    指令伪C代码

    // PC指向的一个字节解释为地址
    address = READ(pc++);
  6. 绝对X变址 Absolute X Indexed Addressing
    三字节指令, 这种寻址方式是将一个16位的直接地址作为基地址, 然后和变址寄存器X的内容相加, 结果就是真正的有效地址

    $DD $F6 $31
    LDA $31F6, X - (将值$31F6加上X作为地址的值载入累加器, 即 A = 0x31F6[X])
    

    指令伪C代码

    // PC指向的两个字节解释为基地址
    address = READ(pc++);
    address |= READ(pc++) << 8;
    // 加上X变址寄存器
    address += X;
  7. 绝对Y变址 Absolute Y Indexed Addressing
    三字节指令, 同5, 就是把X换成Y而已

    指令伪C代码

    // PC指向的两个字节解释为基地址
    address = READ(pc++);
    address |= READ(pc++) << 8;
    // 加上Y变址寄存器
    address += Y;
  8. 零页X变址 Zero-page X Indexed Addressing
    双字节指令, 同5, 如果高地址是0, 可以节约一个字节.

    指令伪C代码

    // PC指向的一个字节解释为零页基地址
    address = READ(pc++);
    // 加上X变址寄存器
    address += X;
    // 结果在零页
    address = address & 0xFF;
  9. 零页Y变址 Zero-page Y Indexed Addressing
    双字节指令, 同7, 就是把X换成Y而已

    指令伪C代码

    // PC指向的一个字节解释为零页基地址
    address = READ(pc++);
    // 加上Y变址寄存器
    address += Y;
    // 结果在零页
    address = address & 0xFF;
  10. 间接寻址 Indirect Addressing
    三字节指令, 在 6502中,仅仅用于无条件跳转指令JMP这条指令该寻址方式中, 操作数给出的是间接地址, 间接地址是指存放操作数有效地址的地址

    $6C $5F $21
    JMP ($215F)  - 跳转至$215F地址开始两字节指向的地址
    

    有点拗口, 假如:

    地址
    $215F $76
    $2160 $30
    这个指令将获取 $215F, $2160 两个字节中的值,然后把它当作转到的地址 - 也就是跳转至$3076

    已知硬件BUG/缺陷

    这唯一一个用在一条指令的寻址方式有一个已知的BUG/缺陷: JMP ($xxFF)无法正常工作.

    例如JMP ($10FF), 理论上讲是读取$10FF和$1100这两个字节的数据, 但是实际上是读取的$10FF和$1000这两个字节的数据. 虽然很遗憾但是我们必须刻意实现这个BUG, 这其实算是实现FC模拟器中相当有趣的一环.

    指令伪C代码

    // PC指向的两个字节解释为间接地址
    tmp1 = READ(pc++);
    tmp1 |= READ(pc++) << 8;
    // 刻意实现6502的BUG
    tmp2 = (tmp1 & 0xFF00) | ((tmp1+1) & 0x00FF)
    // 读取间接地址
    address = READ(tmp1) | (READ(tmp2) << 8);
  11. 间接X变址(先变址X后间接寻址): Pre-indexed Indirect Addressing
    双字节指令, 比较麻烦的寻址方式

    $A1 $3E
    LDA ($3E, X)
    

    这种寻址方式是先以X作为变址寄存器和零页基地址IND(这里就是$3E)相加IND+X, 不过这个变址计算得到的只是一个间接地址,还必须经过两次间接寻址才得到有效地址:

    • 第一次对 IND + X 间址得到有效地址低 8 位
    • 第二次对 IND + X + 1 间址得到有效地址高 8 位
    • 然后把两次的结果合起来,就得到有效地址.

    例如:

    地址
    X $05
    $0043 $15
    $0044 $24
    $2415 $6E

    这条指令将被如下执行:

    • $3E + $05 = $0043
    • 获取 $0043, $0044 两字节中保存的地址 = $2415
    • 读取 $2415 中的内容 - $6E
    • 执行LDA, 把值$6E载入累加器

    指令伪C代码

    // PC指向的一个字节解释为零页基地址再加上X寄存器
    tmp = READ(pc++) + X;
    // 读取该零页地址指向的两个字节
    address = READ(tmp) | (READ(tmp + 1) << 8);
  12. 间接Y变址(后变址Y间接寻址): Post-indexed Indirect Addressing
    双字节指令, 比较麻烦的寻址方式

    $B1 $4C
    LDA ($4C), Y
    
    • 这种寻址方式是对IND(这里就是$4C)部分所指出的零页地址先做一次间接寻址, 得到一个低8位地址
    • 再对IND + 1 作一次间接寻址,得到一个高8位地址
    • 最后把这高,低两部分地址合起来作为16的基地址,和寄存器Y进行变址计算, 得到操作数的有效地址,注意的是这里IND是零页地址

    例如:

    地址
    $004C $00
    $004D $21
    Y $05
    $2105 $6D

    这条指令将被如下执行:

    • 读取字节 $4C, $4D 中的内容 = $2100
    • Y 寄存器中的内容相加 = $2105
    • 读取字节 $2105 中的内容 - $6D
    • 执行LDA, 把值$6D载入累加器

    指令伪C代码

    // PC指向的一个字节解释为零页地址
    tmp = READ(pc++);
    // 读取该零页地址指向的两个字节作为基地址
    address = READ(tmp) | (READ(tmp + 1) << 8);
    // 基地址再加上Y寄存器
    addres += Y;
  13. 相对寻址: Relative Addressing
    该寻址仅用于条件转移指令, 指令长度为2个字节.
    第1字节为操作码,第2字节为条件转移指令的跳转步长, 又叫偏移量D. 偏移量可正可负, D若为负用补码表示.

    $F0 $A7
    BEQ $A7 - (如果标志位中'Z'-被设置, 则向后跳转-39字节, 即前跳39字节)
    

    指令伪C代码

    // 读取操作数
    tmp = READ(pc++);
    // PC加上有符号数据即可
    address = pc + (int8_t)tmp;

    注意, 这里并没有进行条件判断

REF

Re: 从零开始的红白机模拟 - [28] NSF 初窥

NSF

红白机音乐格式 NES Sound Format (.nsf), 可以认为就是储存红白机上音乐的音乐格式, 也是第E步的主要实现目标.

在真正介绍NSF之前先介绍Mapper-031, 这个Mapper简单地说就是实现了NSF的一个子集.

Mapper 031

PRG ROM size: Up to 1024 kB
PRG ROM bank size: 4 kB
PRG RAM: None
CHR capacity: 8 kB RAM/ROM
CHR bank size: Not bankswitched
Nametable mirroring: horizontal or vertical, hard wired.
Subject to bus conflicts: No

由于音乐才是本体, 所以CHR-ROM(实际没有ROM, 而是RAM)就是Mapper-0的水平, 毕竟音乐是存储在PRG里面的. 值得注意的是, RPG-BANK的窗口大小是4kb! 目前代码的逻辑, PRG-BANK窗口大小是8kb. 这就导致没法处理了. 解决方法大致有两个:

  1. 将窗口大小降至4kb
  2. 依然是8kb, 不过mapper手动处理4kb的交换. 需要额外的储存空间与复制时间

自己权衡了大概3秒钟, 决定将窗口大小降到4kb. 由于BANK数量从8涨到16了, 下次如果遇到2kb的窗口, 估计就得选择2了. 这一个小改动会牵一发而动全身, 首当其冲的自然是状态储存. 所以升级到'1.1'版了, 而且由于没有正式释出, 所以懒得兼容'1.0'版本.

BUG 声明

由于这次修改了PRG的BANK窗口大小, 让自己找到了一个BUG, 这个BUG说大不大, 说小也不小:

bug

这样干会丢失D3, D4位的信息, 不过自带RAM刚好2kb, 对于自带的RAM反而是防止越界的好'操作', 所以连测试ROM都没能测试出来.

PRG bank select $5000-$5FFF

address              data
15      bit       0  7  bit  0
-------------------  ---------
0101 .... .... .AAA  PPPP PPPP
                |||  |||| ||||
                |||  ++++-++++- Select 4 kB PRG ROM bank at slot specified by write address.
                +++------------ Specify 4 kB bank slot at: $8000 + (AAA * $1000)

可以看出这次写入的地址位于低32kb的区域, 甚至低于SRAM区, 目前这部分的逻辑是:

reg

完全没有处理这部分, 所以只能再增加一个这区域的写接口: write_low().

初始是$5FFF地址写入$FF, 即载入最后的bank. 这个逻辑有点不太懂, 到底是膜一下还是当作有符号的? 毕竟支持到1024kb, 目前简单膜一下好了(当然初始还是载入最后一个).

/// <summary>
/// Mapper - 031 - 写入低地址($4020, $6000)
/// </summary>
/// <param name="famicom">The famicom.</param>
/// <param name="addr">The addr.</param>
/// <param name="data">The data.</param>
static void sfc_mapper_1F_write_low(sfc_famicom_t*famicom, uint16_t addr, uint8_t data) {
    // PRG bank select $5000-$5FFF
    if (addr >= 0x5000) {
        // 0101 .... .... .AAA  --    PPPP PPPP
        const uint16_t count = famicom->rom_info.count_prgrom16kb * 4;
        const uint16_t src = data;
        sfc_load_prgrom_4k(famicom, addr & 0x07, src % count);
    }
    // ???
    else {
        assert(!"NOT IMPL");
    }
}

结果

这个Mapper自然是通往NSF的一把小钥匙, 主要是理解NSF的BANK切换. 当然改动的主要是将BANK窗口大小降至4kb了. 这里使用pico的ROM作为结果演示:

output

同时带上了测试用ROM结果:

test

REF

Re: 从零开始的红白机模拟 - [30] VRC6 咆哮

可乐妹的VRC6

那就从NSF的扩展音源最低位开始吧, VRC6有两个变种:

当然所述为日版(FC), NES版由于老任的策略不能使用自己搭载扩展音源的卡带.

BANK

CPU $8000-$BFFF: 16 KB switchable PRG ROM bank
CPU $C000-$DFFF: 8 KB switchable PRG ROM bank
CPU $E000-$FFFF: 8 KB PRG ROM bank, fixed to the last bank
CHR $0000-$03FF: 1 KB switchable CHR ROM bank
CHR $0400-$07FF: 1 KB switchable CHR ROM bank
CHR $0800-$0BFF: 1 KB switchable CHR ROM bank
CHR $0C00-$0FFF: 1 KB switchable CHR ROM bank
CHR $1000-$13FF: 1 KB switchable CHR ROM bank
CHR $1400-$17FF: 1 KB switchable CHR ROM bank
CHR $1800-$1BFF: 1 KB switchable CHR ROM bank
CHR $1C00-$1FFF: 1 KB switchable CHR ROM bank

感觉设计很科学, PRG-ROM方面16kb切换, 8kb切换, 8kb固定. CHR-ROM全是1kb可切换

PRG-ROM支持到256kb, CHR-ROM也是.

寄存器

由于地址线只有0,1,12-15可用, 镜像地址可以用: 与$F003做与运算获得($DE6A -> $D002).

同时两个变种的区别就是: 地址线A0,A1是相反的.

    variant   lines     registers                       Mapper Number
    =================================================================
    VRC6a:    A0, A1    $x000, $x001, $x002, $x003      024
    VRC6b:    A1, A0    $x000, $x002, $x001, $x003      026

换句话说, MAPPER-026实现可以为:

void sfc_mapper_vrc6a_write_high(uint16_t addr, uint8_t data) {
    // xxxxx
}

void sfc_mapper_vrc6b_write_high(uint16_t addr, uint8_t data) {
    uint16_t new_addr = addr & 0xfffc;
    new_addr |= (addr & 1) << 1;
    new_addr |= (addr & 2) >> 1;
    sfc_mapper_vrc6a_write_high(new_addr, data);
}

16k PRG Select ($8000-$8003)

7  bit  0
---------
.... PPPP
     ||||
     ++++- Select 16 KB PRG ROM at $8000

16*16=256

8k PRG Select ($C000-$C003)

7  bit  0
---------
...P PPPP
   | ||||
   +-++++- Select 8 KB PRG ROM at $C000

8*32=256

PPU Banking Style ($B003)

7  bit  0
---------
W.PN MMDD
| || ||||
| || ||++- PPU banking mode; see below
| || ++--- Mirroring varies by banking mode, see below
| |+------ 1: Nametables come from CHRROM, 0: Nametables come from CIRAM
| +------- CHR A10 is 1: subject to further rules 0: according to the latched value
+--------- PRG RAM enable

CIRAM (Console-Internal RAM, FC内部自带的RAM). 是的! VRC6支持用ROM代替RAM.

现有的游戏只有这几种情况: $20, $24, $28, $2C, $A0, $A4, $A8, $AC.

CHR Select 0…7 ($Dxxx, $Exxx)

为了方便描述, 将$D000-$D003, $E000-$E003依次描述为R0-R7. $B003的第三位影响CHR-ROM的切换:

图样表:

[$B003] & $03 0 1 2 / 3
$0000-$03FF R0 R0 R0
$0400-$07FF R1 R0 R1
$0800-$0BFF R2 R1 R2
$0C00-$0FFF R3 R1 R3
$1000-$13FF R4 R2 R4
$1400-$17FF R5 R2 R4
$1800-$1BFF R6 R3 R5
$1C00-$1FFF R7 R3 R5

名称表:

[$B003] & $07 0/6/7 1/5 2/3/4
$2000-$23FF R6 R4 R6
$2400-$27FF R6 R5 R7
$2800-$2BFF R7 R6 R6
$2C00-$2FFF R7 R7 R7

之前提到名称表拥有大致三种模式: 水平镜像、垂直镜像以及四屏模式. 但是实际上还有更多的模式, 比如三屏、一屏. 详细的还是查看引用链接.

图样表还行, 名称表解释起来太麻烦了, 直接上代码(没有考虑切换名称表切换CHR-ROM):

    // 使用CIRAM 镜像模式
    if (!(ppu_style & SFC_18_B3_FROM_CHRROM)) {
        switch (ppu_style & 0x2F) {
        case 0x20:
        case 0x27: // (这个情况现有的游戏不存在)
            sfc_switch_nametable_mirroring(famicom, SFC_NT_MIR_Vertical);
            break;
        case 0x23: // (这个情况现有的游戏不存在)
        case 0x24: 
            sfc_switch_nametable_mirroring(famicom, SFC_NT_MIR_Horizontal);
            break;
        case 0x28:
        case 0x2F: // (这个情况现有的游戏不存在)
            sfc_switch_nametable_mirroring(famicom, SFC_NT_MIR_SingleLow);
            break;
        case 0x2B: // (这个情况现有的游戏不存在)
        case 0x2C:
            sfc_switch_nametable_mirroring(famicom, SFC_NT_MIR_SingleHigh);
            break;
        default:
            // (这个情况现有的游戏不存在)
            switch (ppu_style & 0x07) {
            case 0x00:
            case 0x06:
            case 0x07:
                famicom->ppu.banks[0x8] = famicom->video_memory + 0x400 * (mapper->registers[6] & 1);
                famicom->ppu.banks[0x9] = famicom->ppu.banks[0x8];
                famicom->ppu.banks[0xa] = famicom->video_memory + 0x400 * (mapper->registers[7] & 1);
                famicom->ppu.banks[0xb] = famicom->ppu.banks[0xa];
                break;
            case 0x01:
            case 0x05:
                famicom->ppu.banks[0x8] = famicom->video_memory + 0x400 * (mapper->registers[4] & 1);
                famicom->ppu.banks[0x9] = famicom->video_memory + 0x400 * (mapper->registers[5] & 1);
                famicom->ppu.banks[0xa] = famicom->video_memory + 0x400 * (mapper->registers[6] & 1);
                famicom->ppu.banks[0xb] = famicom->video_memory + 0x400 * (mapper->registers[7] & 1);
                break;
            case 0x02:
            case 0x03:
            case 0x04:
                famicom->ppu.banks[0x8] = famicom->video_memory + 0x400 * (mapper->registers[6] & 1);
                famicom->ppu.banks[0x9] = famicom->video_memory + 0x400 * (mapper->registers[7] & 1);
                famicom->ppu.banks[0xa] = famicom->ppu.banks[0x8];
                famicom->ppu.banks[0xb] = famicom->ppu.banks[0x9];
                break;
            }
            // 镜像
            famicom->ppu.banks[0xc] = famicom->ppu.banks[0x8];
            famicom->ppu.banks[0xd] = famicom->ppu.banks[0x9];
            famicom->ppu.banks[0xe] = famicom->ppu.banks[0xa];
            famicom->ppu.banks[0xf] = famicom->ppu.banks[0xb];
            break;
        }
    }
    // 使用CHR-ROM(现有的游戏没有)
    else {
        // TODO: 完成
        assert(!"NOT IMPL");
    }

IRQ control ($F00x)

VRC系列的IRQ是相同的逻辑, 与其他IRQ系统不同的是, VRC IRQ是基于CPU周期的. 自然, 即便是禁用PPU渲染, 或者VBlank时, 也能触发IRQ.

当然主要功能还是模拟扫描线(切换模式)

       7  bit  0
       ---------
$F000: LLLL LLLL - IRQ Latch
$F001: .... .MEA - IRQ Control
$F002: .... .... - IRQ Acknowledge

A: IRQ Enable after acknowledgement (see IRQ Acknowledge)
E: IRQ Enable (1 = enabled)
M: IRQ Mode (1 = cycle mode, 0 = scanline mode)

$F002确认IRQ中有一个重要的操作E=A 而不是E|=A, 千万不要像自己以为身经百战见得多了, 东边哪个IRQ没有见过? 找了非常长时间的BUG.

扫描线模式, 每根扫描线输出一个时钟信号. 通过将CPU周期除以[114, 114, 113], 然后重复序列. 所以可以认为是除以113.667(NTSC的场合, PAL需要调整至106又16分之5). 不过, 由于目前的架构是基于扫描线进行水平同步的, 所以是反过来的.

(CPU)周期模式, 每个(CPU)时钟周期输出一个信号. 感觉太夸张了, 毕竟一个指令最少也要花2周期, 不知道是不是自己理解错了.

当输出一个时钟信号时:

  • 如果IRQ计数器为$FF, 重载L值, 触发IRQ
  • 否则计数器+1

当然IRQ被禁止的话什么都不会干.

VRC6 扩展声部

这部分才是本篇的本体! 很多NSF作者很喜欢VRC6, 原因是增加两个方波和一个锯齿波, 得到了加强但是又不至于太过分.

Base +0 +1 +2 +3
$9000 方波1占空比-音量 方波1周期-低字节 方波1周期-高字节 频率缩放
$A000 方波2占空比-音量 方波2周期-低字节 方波2周期-高字节 ---
$B000 锯齿波音量 锯齿波周期-低字节 锯齿波周期-高字节 ---

由于NSF的存在, 自己将音频数据放在了核心部分, 而不是依靠Mapper保存.

Frequency Control ($9003)

7  bit  0
---- ----
.... .ABH
      |||
      ||+- Halt
      |+-- 16x frequency (4 octaves up)
      +--- 256x frequency (8 octaves up)

H - halts all oscillators, stopping them in their current state
B - 16x frequency, all oscillators (4 octave increase)
A - 256x frequency, all oscillators (8 octave increase)

那三个游戏都没有使用这个这个寄存器, 仅仅是简单=0. 考虑不用支持?

Pulse Control ($9000,$A000)

7  bit  0
---- ----
MDDD VVVV
|||| ||||
|||| ++++- Volume
|+++------ Duty Cycle
+--------- Mode (1: ignore duty)

Saw Accum Rate ($B000)

7  bit  0
---- ----
..AA AAAA
  ++-++++- Accumulator Rate (controls volume)

Freq Low ($9001,$A001,$B001)

7  bit  0
---- ----
FFFF FFFF
|||| ||||
++++-++++- Low 8 bits of frequency

Freq High ($9002,$A002,$B002)

7  bit  0
---- ----
E... FFFF
|    ||||
|    ++++- High 4 bits of frequency
+--------- Enable (0 = channel disabled)

方波

VRC6的方波与2A03自带的方波类似.

D value   Duty (percent)
0   1/16   6.25%
1   2/16   12.5%
2   3/16   18.75%
3   4/16   25%
4   5/16   31.25%
5   6/16   37.5%
6   7/16   43.75%
7   8/16   50%
M   16/16   100%

M=1时, 持续输出当前音量, 也就是可以认为是[1, 1, ...., 1]

通过E=0, 会复位占空比索引并停止, 输出0. 通过E=1, 会从头恢复.

虽然占空比序列长度是16, 为2A03的2倍. 但是由于是CPU频率驱动的, 所以计算公式还是一致的:

f = CPU / (16 * (t + 1))
t = (CPU / (16 * f)) - 1

具体实现可以考虑像2A03的矩形波一样进行查表, 不过太有规律了, 可以用 i <= d进行判断.

这里简单实现为:

squ

锯齿波

这算是一个新东西, 不过和三角波相似, 甚至在函数表述上比三角波还简单.

     *      *
    *      *
---*------*------
  *      *
 *      *
*      *

同样是CPU周期驱动的12bit周期数据. 每两次由周期输出时钟时, 内部的8bit累加器加上[$B000]:A, 每次则输出高5bit. 第七次重置归零:


Step   Accumulator   Output   Comment
0   $00   $00   
1   $00   $00   (odd step, do nothing)
2   $08   $01   (even step, add A to accumulator)
3   $08   $01   
4   $10   $02   
5   $10   $02   
6   $18   $03   
7   $18   $03   
8   $20   $04   
9   $20   $04   
10  $28   $05   
11  $28   $05   
12  $30   $06   
13  $30   $06   
0   $00   $00   (14th step, reset accumulator)
1   $00   $00   
2   $08   $01   

如果A大于42, 会导致8bit数据溢出从而让声音失真. 通过写入E=0累加器会复位到下次E=1. 频率计算公式为:

f = CPU / (14 * (t + 1))
t = (CPU / (14 * f)) - 1

这里简单实现为:

saw

输出

就连wiki上面介绍VRC6输出的口吻也是比较含糊的, 主要有两点:

  • VRC6方波最大音量和2A03方波最大音量差不多
  • VRC6的三个声部似乎是线性变化的

根据之前DAC中给出的线性逼近的公式: pulse_out = 0.00752 * (pulse1 + pulse2), 可知线性因子大概是0.00752, 由于不太确定, 可以根据情况上下浮动.

title

要点

  • 高周期有一个使能位, 可以简单实现为: 使能位E: 0 -> 1时, 重置索引/累加器状态
  • VRC6支持在名称表中使用ROM代替RAM(偷懒没有完成, 涉及到影响状态读写, 考虑最后一起完成)
  • VRC6的IRQ是基于CPU而不是PPU, 所以水平同步接口增加了一个参数(扫描行)
  • 两个变种区别在于地址线A0, A1反了
  • VRC6扩展声道加起来是一个6bit数据, 但是会除以大约128. 差不多最大音量是0.5
  • 与2A03的最大1.0加在一起会超过1.0, 可能会出现破音现象, 可以添加一个动态阈值来除

恶魔城传说模拟出现的问题

bug

(红框处应该有一个蝙蝠状敌人)

同之前的MetalMax, 由于目前精灵是在最后一起渲染的, 导致中途切换BANK会让精灵使用错误的图样表BANK渲染

还有一个小问题就是, 这个画面中, 背景会来回1像素上下跳动, 这个应该是IRQ精度问题

REF

Re: 从零开始的红白机模拟 - [09]指令实现

STEP3: CPU 指令实现 - 具体实现

这节是指令实现的最后一节, 前面介绍了几乎所有的指令, 现在就做具体实现

说说之前有提到的"页面边界交叉", 或者简单理解为"跨页"

条件转移

条件转移语句很简单:

  • 没有跳转: 花费2周期
  • 跳转至本页面: 花费3周期
  • 跳转至其他页面: 花费4周期

页面边界交叉

之前提到了"+1s", 与其说是"+1s", 不如说是"-1s". 会发生在下列寻址方式:

  • 绝对X变址
  • 绝对Y变址
  • 间接Y变址

如果指令不进行写操作的话, 会进行优化: 本页面读取就会少一周期, 也就是"不如说的缘由". 同样也是扩展指令中"读改写"没有额外周期的说法 —— 因为已经自带了. 基础指令的写操作STA之类的, 没有额外的周期也是同样的原因.

说了这么多, 但是在STEP3中没有实现 —— STEP3中没有意义. 但是在需要同步的时候, 意义就非常大了, 必须要实现.

具体实现

具体实现就一个大case然后从00写到FF? 图样! 把寻址和指令分开介绍的原因当然是:

g1

  • I have an addressing mode
  • I have an instruction

g2

  • ohn~

g3

  • OpCode

所以直接这么实现:
title

适当使用宏可以大幅度美化代码, OP宏实现如下:

// 指令实现
#define OP(n, a, o) \
case 0x##n:\
{           \
    const uint16_t address = sfc_addressing_##a(famicom);\
    sfc_operation_##o(address, famicom);\
    break;\
}

累加器寻址

由于累加器寻址, 并没有"地址", 所以单独实现了: 可以看到截图中有些指令是4个字母的, 这些是累加器寻址的单独实现.

nestest.nes

有请主角测试用ROM——"nestest.nes". 之前了解到这个ROM的RESET向量是$C004.

Emulator tests中提到:

Start execution at $C000 and compare execution with a log

也就是说, 我们把初始PC设为$C000, 就可以和现有的LOG就行比较:
log

比如执行JMP然后LDX......这就是一个非常棒的参考, 自己实现顺序也是这个:

  • 运行程序
  • 执行到一个没有实现的指令
  • 报错(断言)
  • 检测该指令并实现.
  • 重复这系列步骤

如果从00开始实现指令, 就感觉背单词从abandon开始一样, 很没劲儿.

tra

实用宏

可以利用宏大幅度简化代码:

// 寄存器
#define SFC_REG (famicom->registers)
#define SFC_PC (SFC_REG.program_counter)
#define SFC_SP (SFC_REG.stack_pointer)
#define SFC_A (SFC_REG.accumulator)
#define SFC_X (SFC_REG.x_index)
#define SFC_Y (SFC_REG.y_index)
#define SFC_P (SFC_REG.status)

// if中判断用FLAG
#define SFC_CF (SFC_P & (uint8_t)SFC_FLAG_C)
#define SFC_ZF (SFC_P & (uint8_t)SFC_FLAG_Z)
#define SFC_IF (SFC_P & (uint8_t)SFC_FLAG_I)
#define SFC_DF (SFC_P & (uint8_t)SFC_FLAG_D)
#define SFC_BF (SFC_P & (uint8_t)SFC_FLAG_B)
#define SFC_VF (SFC_P & (uint8_t)SFC_FLAG_V)
#define SFC_SF (SFC_P & (uint8_t)SFC_FLAG_S)
// 将FLAG将变为1
#define SFC_CF_SE (SFC_P |= (uint8_t)SFC_FLAG_C)
#define SFC_ZF_SE (SFC_P |= (uint8_t)SFC_FLAG_Z)
#define SFC_IF_SE (SFC_P |= (uint8_t)SFC_FLAG_I)
#define SFC_DF_SE (SFC_P |= (uint8_t)SFC_FLAG_D)
#define SFC_BF_SE (SFC_P |= (uint8_t)SFC_FLAG_B)
#define SFC_RF_SE (SFC_P |= (uint8_t)SFC_FLAG_R)
#define SFC_VF_SE (SFC_P |= (uint8_t)SFC_FLAG_V)
#define SFC_SF_SE (SFC_P |= (uint8_t)SFC_FLAG_S)
// 将FLAG将变为0
#define SFC_CF_CL (SFC_P &= ~(uint8_t)SFC_FLAG_C)
#define SFC_ZF_CL (SFC_P &= ~(uint8_t)SFC_FLAG_Z)
#define SFC_IF_CL (SFC_P &= ~(uint8_t)SFC_FLAG_I)
#define SFC_DF_CL (SFC_P &= ~(uint8_t)SFC_FLAG_D)
#define SFC_BF_CL (SFC_P &= ~(uint8_t)SFC_FLAG_B)
#define SFC_VF_CL (SFC_P &= ~(uint8_t)SFC_FLAG_V)
#define SFC_SF_CL (SFC_P &= ~(uint8_t)SFC_FLAG_S)
// 将FLAG将变为0或者1
#define SFC_CF_IF(x) (x ? SFC_CF_SE : SFC_CF_CL);
#define SFC_ZF_IF(x) (x ? SFC_ZF_SE : SFC_ZF_CL);
#define SFC_OF_IF(x) (x ? SFC_IF_SE : SFC_IF_CL);
#define SFC_DF_IF(x) (x ? SFC_DF_SE : SFC_DF_CL);
#define SFC_BF_IF(x) (x ? SFC_BF_SE : SFC_BF_CL);
#define SFC_VF_IF(x) (x ? SFC_VF_SE : SFC_VF_CL);
#define SFC_SF_IF(x) (x ? SFC_SF_SE : SFC_SF_CL);

// 实用函数
#define SFC_READ(a) sfc_read_cpu_address(a, famicom)
#define SFC_PUSH(a) (famicom->main_memory + 0x100)[SFC_SP--] = a;
#define SFC_POP() (famicom->main_memory + 0x100)[++SFC_SP];
#define SFC_WRITE(a, v) sfc_write_cpu_address(a, v, famicom)
#define CHECK_ZSFLAG(x) { SFC_SF_IF(x & (uint8_t)0x80); SFC_ZF_IF(x == 0); }

之前虽然说是"伪C代码", 实际加一个宏就变成C了. 然后一步一步实现吧!

输出

根据LOG文件, 最后可以看到写入"$4015"了 也就是可以完成这部分了. 虽然还有很多指令没有实现, 甚至BRKCLI也没实现!

output

项目地址Github-StepFC-Step3

作业

  • 基础: 之前提到可以用uint8_t[8]实现状态寄存器, 试试吧
  • 扩展: 删掉实现代码, 自己实现所有指令
  • 从零开始: 从零开始实现自己的模拟器吧

REF

Re: 从零开始的红白机模拟 - [19]Mapper 003

STEP⑨: 实现部分Mapper

接下来就是Mapper3.

Mapper003: CNROM

可以看出这个浓眉大眼的Mapper居然和Mapper000一样支持16KB或者32KB的RPG-ROM, 太丢脸了, 褪裙吧.

好在CHR-ROM支持得比较多.

根据数据库,CNROM(在自己看来)比较有名的游戏, 比如:

看来这次是一秒16发男(高桥名人)的主场. 冒险岛初代自然可以用来测试.

Banks

  • PRG-ROM和Mapper000一样
  • CHR bank单位是8kb
  • CHR-ROM一次性切换

Bank select ($8000-$FFFF)

7  bit  0
---- ----
cccc ccCC
|||| ||||
++++-++++- Select 8 KB CHR ROM bank for PPU $0000-$1FFF

和上一个一样的逻辑, 最多支持256*8, 2MB的CHR-ROM. 为了避免溢出, 可以进行一次膜运算: value % count_chrrom_8kb

实现

这次也是非常简单: STEP9-MAPPER003.c

高桥名人的冒险岛

  • 流程有点长
  • island
  • 被击败的笔者
  • 小时候就没通关了
  • 这个是每次场景切换时切换BANK, 很科学
  • 切换的D4D5位为1, 所以不膜的话会溢出
  • 游戏愉快!

REF

Memories Off -Innocent Fille- 通关纪念

一开始看成Innocent File算是职业病?


现在没有通关的游戏真的是堆成山了, 能完整通关的游戏越来越少了, 简单纪念一下.

a


好友列表一堆的大表哥2, 自己还真是一股清流.


感谢名单看到自己的id还是挺感动的, 原以为是印在本子上结果是在游戏结尾的感谢名单:

b


c

原来主角姓金田一.


a

过了!


5o 6n g whe14 1z x3xv

女装dalao惹不起惹不起


Re: 从零开始的红白机模拟 - [23]录制与回放

StepC: 录制与回放

现在有这么一个玩意, TAS, 差不多就是配合SL甚至放慢速度达到速通或者其他娱乐性目的. 这是一个有意思的东西, 这次来实现吧!

按键

TIME(frame):
      47 48 49 50 51
--------------------------------------->
               | 
               v
        A: 1
        B: 0
        L: 0           
        R: 0       -\   buffer[50]
        U: 0       -/       = 0x01
        D: 0
        s: 0
        S: 0

这东西的关键点就是将按键信息实时保存下来, 比如第50帧按下了A键什么的, 这个由于每个模拟器可能会存在报道偏差(当然, 如果都是高精度的模拟器自然没问题), 作为中等精度的模拟器, 几乎只能自产自销.

另外有意思的是, 这个东西完全是外部接口控制的, 核心部分根本不管是不是重播, 还是人在玩. 所以, 本步骤不会修改核心部分, 只有一个main.c.

可以从项目中看出, 非核心也就是接口实现的部分, 实现得很随意, 因为反正以后都会重写, 没必要认真写. 这部分也是!

数据量

一帧按键是8字节, 可以打包到1个字节. 这样记录1小时需要3600*60=216000, "仅仅"200kb, 双人再乘上2... 所以完全可以在内存处理然后一次性记录到文件中.

当然, 我们可以设定上限就是一小时什么的. 这里就直接申请1MB, 超过范围就从头改写.

SL

因为重点是处理SL, 好在SL中我们保存了当前的帧ID, 32bit可以存2年够用了.

不过值得注意的是录制中不能读取以前不在录制中的档案, 这个需要单独出来一下, 最简单的是用文件夹隔离. 用户如果手动复制过去, 自己作死就不管我们的事情了!(就连这个也没事现!)

插入点

很简单在sfc_render_frame_easy调用前处理即可:

static void ib_try_record_replay() {
    // 记录
    if (IB_IS_RECORD) {
        const uint8_t state = ib_pack_u8x8(g_famicom->button_states);
        ib_set_keys(g_famicom->frame_counter, state);
    }
    // 回放
    else if (IB_IS_REPLAY) {
        const uint8_t state = g_states.input_buffer_1mb[g_famicom->frame_counter];
        ib_unpack_u8x8(state, g_famicom->button_states);
    }
}

一般情况两者均为0, 要录制或者要回放就手动设置吧

enum {
    IB_IS_RECORD = 0,
    IB_IS_REPLAY = 0,
};

回放

来欣赏自己魂斗罗一命通(第一)关的流程吧! 实际上第一关太简单了, 不过没有连发键, 只有在前面断桥时用了几下SL大法.

replay

虽然很简单, 看是看着回放自动运行, 还是有点成就感!

项目地址Github-StepFC-StepC

TAS

作为TAS用模拟器, 还是太初级了, 首先应该记录TAS录制的次数, 最好还要显示时间, 还有有放慢功能方便微操, 以及追踪变量的功能方便随机变成必然!

作业

  • 太简单了, 重写吧!

REF

Re: 从零开始的红白机模拟 - [18]Mapper 002

STEP⑨: 实现部分Mapper

这一次继续吧: Mapper02.

Mapper002: UxROM

UxROM这就比较厉害了, 能够用到最高用到4MB的RPG-ROM.

但是没有CHR-ROM, 需要自己写入CHR-RAM.

根据数据库,UxROM(在自己看来)比较有名的游戏, 比如:

Banks

  • CPU $8000-$BFFF: 16 KB switchable PRG ROM bank
  • CPU $C000-$FFFF: 16 KB PRG ROM bank, fixed to the last bank

这个设计就很暴力了, 我喜欢

Bank select ($8000-$FFFF)

7  bit  0
---- ----
xxxx pPPP
     ||||
     ++++- Select 16 KB PRG ROM bank for CPU $8000-$BFFF
          (UNROM uses bits 2-0; UOROM uses bits 3-0)

完全可以实现为使用全部的8bit.

要使用全部8bit, 即4MB, 需要 NES 2.0的文件头, 为此自己特地更新了文件头让其支持4MB. 之前有一个搞事的就是用的4MB的Mapper002(自制ROM, 没有物理板子)

有些会有总线冲突, 需要使用副-Mapper来解决, 这里就不讨论了

这个Mapper简单暴力. 避免溢出, 可以膜一下: value % count_prgrom16kb

实现

这次感觉是Mapper上最简单的: STEP9-MAPPER002.c

魂斗罗模拟出现的问题

  • 魂斗罗使用的精灵是8x16模式
  • 魂斗罗使用了DMC声道增强音效
  • 上面决定了, 下次测试DMC就靠你了
  • BGM还行, SE表现力实在太差.
  • 主要就是声音播放有点问题
  • 上上下下左右左右BA!
  • 30life
  • 我就不信三十条命打不通关
  • 我才不会告诉你即时存档需要保存CHR-RAM呢
  • clear
  • 游戏愉快!

REF

C++ 获取构造函数/析构函数的函数指针

解决方法

先说解决方法, 需求后面再说. 构造函数可以有自定义的, 析构函数只有一个. 而且不能直接获取构造/析构函数的指针.

其中C++允许调用其析构函数, 可参考calling destructor explicitlyDestructors (C++).
所以这样就可能间接获取析构函数的函数地址了(T是模板):

// release the object
void delete_obj(void*p) noexcept { static_cast<T*>(p)->T::~T(); }

那么构造函数呢, 当然也可以直接调用, 不过这不是标准行为:

  • MSC: 允许直接调用构造函数
  • Clang: 允许直接调用构造函数, 不过需要定义MSC兼容扩展flag[-Wmicrosoft-explicit-constructor-call]
  • GCC: 不允许直接调用构造函数

对于不允许的我们可以使用c++ placement new, 不过由于直接调用构造函数这个功能实在太诱惑了(placement new同delete一样会判断this指针, 从而间接增加代码大小, 这个由于是模板, 可不能忽略), 所以大致这么实现:

// create the object
static void create_obj(void* ptr) noexcept {
#ifdef CANNOT_CALL_CONSTRUCTOR_DIRECTLY
    // gcc cannot call ctor directly
    new(ptr) T();
#else
    // msc/clang extended support
    static_cast<T*>(ptr)->T::T();
#endif
}

CANNOT_CALL_CONSTRUCTOR_DIRECTLY判断不是msvc或者clang但是定义了ms兼容扩展就可以取消定义.

对于自定义的(包括复制移动)构造函数就可以用c++11带来的完美转发:

// create
 template<typename ...Args>
static void create(void* ptr, Args&&... args) noexcept {
#ifdef CANNOT_CALL_CONSTRUCTOR_DIRECTLY
    // gcc cannot call ctor directly
     new(ptr) T(std::forward<Args>(args)...);
#else
    // msc/clang extended support
    static_cast<T*>(ptr)->T::T(std::forward<Args>(args)...);
#endif
}

错误处理

这样当然只是意思到了而已, 实际上会报错, 因为T是一个typename而不是一条函数(但是~T就是一条函数了, 神奇的C++或者说微软), 直接调用T::T会提示错误, 直接的解决方法当然是直接全部使用placement new, 但是我实在不想舍弃, 就再用了一层中转...

// func-vtable getter
template<typename T> struct ctor_dtor {
    // member
    T       m;
    // ctor
    ctor_dtor() noexcept {};
};

这样ctor_dtor<T>::ctor_dtor 就是一条函数了

代码优化

这样的话, 针对一些没有析构函数的对象会生成一堆没用的代码. 这个问题各大编译器厂商都有类似于ICF(等价代码折叠)的技术, 能够在链接时折叠相同的只读常量(包括函数), 不过细节不同. 对于取地址操作的函数, GCC流 会持保留态度, 也就是说这种情况ICF在GCC没用.
这种情况只能祭出神器<type_traits>了:

#include <type_traits>
#include <string>

#include <cstdio>
#include <cstdint>

template<typename T> bool get_td_lambda(T&&) noexcept {
    return std::is_trivially_destructible<T>::value;
}

int main(){
    std::string aa = "asd";
    const auto bool0 = get_td_lambda([]() {});
    const auto bool1 = get_td_lambda([aa]() {});
    const auto bool2 = get_td_lambda([&aa]() {});
    std::printf("%d, %d, %d\n", int(bool0), int(bool1), int(bool2));
}

GCC/MSC 当然都是输出"1, 0, 1", 现在对于空析构函数就可以重定向到同一条了. 不过当然还是没有完美解决, 比如 [aa]() {} 这类的有N个, 虽然都是析构一个std::string但是毕竟lambda不同, gcc还是不能折叠, 如果各位有解决方法的话请回复吧.

应用场景

目前LongUI大致有两种方法:

  1. 模拟std::function
  2. 实现不会模板膨胀的Vector.

第一个就懒得说了. 说说第二个, 滥用模板的话会造成模板膨胀这是自然的, 所以为了轻量化, LongUI实现了一个不会模板膨胀的Vector(但是目前还没用过, 所以暂时被冷藏了)
内部使用的是类似虚表的实现, 所以如果保存的是轻量级对象, 比如说是智能指针这种重点是RAII的, 添加删除的效率将会比较低, 主要体现在:

  • 多了一层(类似)虚函数调用, 这个的直接影响比较小
  • 上面一条会造成间接影响, 没法内联: 有可能本来内联会被编译器优化成1行的代码变成了10行
  • 对象强制保存在栈上, 轻量级对象有可能直接保存在寄存器上, 现在添加删除会强制保存在栈上, 这个也算是第二条中

当然这是轻量级的影响比较大, 重量级比如std::string重点是保存的数据这类, 基本没有太大影响. 当然由于LongUI目前使用的全是POD::Vector, 不用调用构造/析构. 这个类还没用过Orz

自己常用的C/C++小技巧[5]

自己常用的小技巧

打通了游戏赶紧再写一篇.

这里列出了自己常用的一些c/c++小技巧, 有些会有不足, 可以简单探讨一下.

函数指针类型

分类: 解决方案

c里面可以使用typedef的方式声明一个函数指针的‘类型’, 非常方便.但是c++11出现的noexcept修饰符就出现了不方便的地方.

#include <cstdio>
#include <type_traits>

using func1_t = void(*)(int) noexcept;
using func2_t = void(*)(int);

int main() {
    const bool same = std::is_same<func1_t, func2_t>::value;
    std::printf("%s\n", same ? "true" : "false");
    return 0;
}

这代码在gcc结果是"true", msc的结果是"异常规范不能出现在 typedef 声明中". 这感觉非常不方便, 在自己看来这两个的类型应该是不一样的, 可以使用struct模拟类型:

#include <cstdio>
#include <type_traits>

typedef struct { void(*func)(int) noexcept; }func1_t;
typedef struct { void(*func)(int); }func2_t;


int main() {
    const bool same = std::is_same<func1_t, func2_t>::value;
    std::printf("%s\n", same ? "true" : "false");
    return 0;
}

同理可以区别不同的'调用约定'

匿名表达式与函数指针

分类: 解决方案

前面提到'调用约定', 毕竟不属于标准, 不同编译器会有不同的处理方法. 而c++11引入的匿名表达式, 标准规定在没有捕获的情况可以隐式转换为函数指针. 但是没有说明'调用约定'的情况:

#include <cstdio>
#include <type_traits>

typedef struct { void(__stdcall*func)(int); }func1_t;
typedef struct { void(__cdecl*func)(int); }func2_t;


int main() {
    func1_t func1 = { [](int) {} };
    func2_t func2 = { [](int) {} };

    return 0;
}

比如msc就可以成功编译, gcc之类的就不能. 有什么解决方法呢? 这就得区分c++14和c++20了:

ClosureType::ClosureType()

ClosureType() = delete;
(C++14 前)

ClosureType() = default;
(C++20 起)(仅若不指定捕获)

ClosureType(const ClosureType& ) = default;
(C++14 起)

可以看出c++14的匿名表达式构造函数被delete修饰了, 但是复制构造函数却没有. 可以通过一个简单的UB: 复制一个空对象构造没有捕获的匿名表达式. c++20就完全可以直接构造了.

#include <cstdio>
#include <utility>
#include <type_traits>


#define UNICALL __stdcall


namespace detail {
    // struct: unicall_funcptr_t
    template<typename return_t, typename... args_t>
    struct unicall_funcptr_t {
        return_t (UNICALL* ptr)(args_t...) noexcept;
    };
    // struct unicall_funcptr_helper
    template<typename T, typename return_t, typename... args_t>
    struct unicall_funcptr_helper {
        static return_t UNICALL call(args_t... args) noexcept {
#if __cplusplus > 201703
            // C++20     : T lambda;
            T lambda;
#else
            // C++14(UB?): T lambda{ *static_cast<T*>(nullptr) };
            T lambda{ *static_cast<T*>(nullptr) };
#endif
            return lambda(std::forward<args_t>(args)...);
        }
    };
    // function get_unicall_funcptr
    template<typename return_t, typename... args_t, typename T>
    inline auto get_unicall_funcptr(T) noexcept ->unicall_funcptr_t<return_t, args_t...> {
        unicall_funcptr_t<return_t, args_t...> rv;
        rv.ptr = unicall_funcptr_helper<T, return_t, args_t...>::call;
        return rv;
    }
}

int main() {
    const auto call = detail::get_unicall_funcptr<void, int>([](int) {

    });
    return 0;
}

其中detail::get_unicall_funcptr需要按照返回值-参数的顺序填写模板表, 实际上可以通过模板的'奇技淫巧'获取模板列表参数, 不过还是太麻烦了.

同时, 我们可以看到代码里面全是'noexcept', 说明这里是可以处理'noexcept'的情况的, 而直接让匿名表达式隐式转换为函数指针会丢失noexcept信息(目前的标准貌似是这样的). 如果代码中不是像我自己这样'一刀切'全部使用noexcept的话, 可以使用noexcept()表达式进行判断.

小容器优化

分类: 优化

例如之前的'字符串容器', 自己实现的字符串容器实现了简单的'短字符串优化':

template<typename T, unsigned L>
class base_string {
    pod_vector<T>   vector;
    T               buffer[L];
};

这样在短字符串的情况可以就行优化, 比如默认情况是'8字节缓冲': UTF-8编码下可以缓冲7字节(+1 NUL). 同时, 由于自己实现的'限制模板', base_string<char, 256>, base_string<char, 8>是使用的一套代码而不用担心模板膨胀, 完全可以随时声明-使用不同长度的缓存. 比如后者适合放在堆上, 前者适合放在栈上.

...

同样, 如果一个容器的长度够短. 那么'数组'和'链表'之间的差距会变得非常小, 完全可以根据其最大的优点而决定使用的容器. 这里其实特指'链表', 因为链表最大的优点就是不会让之前的数据引用失效:

vector<T>在动态添加数据的时候, 由于可能会扩大容器的容积而导致之前的数据引用失效, 这个BUG对于初学者来说是比较重要的经验(至少自己就吃过很多亏).

比如在GUI应用中, 可以模拟类似addEventListener的添加回调的方式. 由于大多数是只有一条回调, 那么就应该使用list<function>的容器. 同时微软的function实现得太辣鸡(64字节坑爹啊), 所以自己用一个类LongUI::CUIFunction实现了list<function> + disconnect的封装, 推荐看一下怎么实现的.

这里的重点就是, 如果容器很大几率容量很小很小, 再加上可能会动态添加, 建议内嵌节点, 自己实现链表结构.

natvis调试: 指定精度浮点

分类: 调试技巧

微软的VS系列IDE提供.natvis的文件方便程序猿进行调试:

1

如果都像左边那样, 这个自己写的容器就没法用了. 不过可以通过添加自定义.natvis文件就能显示自己需要的数据.

不过这里是让大家'怎么显示指定精度浮点':

2

例如颜色用3位小数就足够了, 但是默认会显示很长很长, 很容易显示不完整.

.

实现起来简单但是比较麻烦, 就是用的转成整数再膜'10'(10进制):

natvis

float3

具体natvis怎么编写可以参考微软官方的介绍:

Re: 从零开始的红白机模拟 - [35] FME7 怒吼

Sunsoft FME-7

其实FME-7并不是NSF扩展音源所支持的芯片名称, 而是其变种——Sunsoft 5B. Sunsoft FMT-7, 5A以及5B共用一个Mapper编号——69. 由于格式原因, 程序中FME7代指Mapper069, 包括(甚至特指)Sunsoft 5B, 但是其实FME7是5B的子集.

Sun电子

比起其他厂商, 例如NSF就有提到的科乐美, 任天堂以及南梦宫. Sun电子似乎进入21世纪后就逐渐淡出游戏和行业了. 翻了一下发行游戏列表, 近10年就发行了几款, 不过似乎在吃老本——还有当初FC很好玩的《超惑星战记》的续作.

Sun电子在FC时代出了很多不错的作品, 但其实在这里都不重要, 重要的其编曲水平之高. 例如仅仅使用2A03的《RAF世界》, 这样一个高编曲水平的开发商的Sunsoft 5B又如何呢?

吉米克!

Gimmick!》, 感觉和 拉格朗日点 很相似——唯一一个使用了独立扩展音源的游戏, 一个是VRC7, 一个是5B.

但是其实 吉米克! 也没有完全了利用5B的机能——没有使用到噪音发生器和包络发生器.

FME7总览

wiki介绍FME7支持到512kb的PRG-RAM, 这是目前来看最多的PRG-RAM, 但是自己一个一个看了目前的游戏列表, 最多的也就是8kb的WRAM.

对于PRG-RAM来说, 目前还是直接声明在结构里面, 直接增加512k的PRG-RAM感觉没有什么必要. 如果真的需要实现, 这部分最好使用动态申请, 然后ROM-RAM区分需要从1bit提高到2bit用来区分这部分的RAM(并且, 目前没有一些游戏内运行时错误的处理, 可能需要使用long_jmp).

  • CPU $6000-$7FFF: 8 KB Bankable PRG ROM or PRG RAM
  • CPU $8000-$9FFF: 8 KB Bankable PRG ROM
  • CPU $A000-$BFFF: 8 KB Bankable PRG ROM
  • CPU $C000-$DFFF: 8 KB Bankable PRG ROM
  • CPU $E000-$FFFF: 8 KB PRG ROM, fixed to the last bank of ROM
  • PPU $0000-$03FF: 1 KB Bankable CHR ROM
  • PPU $0400-$07FF: 1 KB Bankable CHR ROM
  • PPU $0800-$0BFF: 1 KB Bankable CHR ROM
  • PPU $0C00-$0FFF: 1 KB Bankable CHR ROM
  • PPU $1000-$13FF: 1 KB Bankable CHR ROM
  • PPU $1400-$17FF: 1 KB Bankable CHR ROM
  • PPU $1800-$1BFF: 1 KB Bankable CHR ROM
  • PPU $1C00-$1FFF: 1 KB Bankable CHR ROM

寄存器

FME7与其他的MMC有所不同的是, 先将命令写入命令寄存器($8000-9FFF), 再把参数写入参数寄存器($A000-BFFF).

  • $0-7 控制CHR切换
  • $8-C 控制PRG切换
  • $C 控制名称表的镜像规则
  • $D-F IRQ控制

命令: CHR Bank 0-7 ($0-7)

7  bit  0
---- ----
BBBB BBBB
|||| ||||
++++-++++- The bank number to select for the specified bank.

8个就是1kb的BANK. 注意防止溢出的操作.

命令: PRG Bank 0 ($8)

7  bit  0
---- ----
ERbB BBBB
|||| ||||
||++-++++- The bank number to select at CPU $6000 - $7FFF
|+------- RAM / ROM Select Bit
|         0 = PRG ROM
|         1 = PRG RAM
+-------- RAM Enable Bit (6264 +CE line)
          0 = PRG RAM Disabled
          1 = PRG RAM Enabled

支持切换512kb的ROM/RAM. 之前提到了, 最多的就是使用了8kb的WRAM. ROM方面, 其实还是没有游戏真正利用可以切换ROM-BANK. 不过现在没有实现写入保护, 切换到ROM再写入的话有点危险.

命令: PRG Bank 1-3 ($9-B)

7  bit  0
---- ----
..bB BBBB
  || ||||
  ++-++++- The bank number to select for the specified bank.

用于切换$8000-$9FFF, $A000-$BFFF, $C000-$DFFF的BANK. 注意防止溢出的操作.

命令: Name Table Mirroring ($C)

7  bit  0
---- ----
.... ..MM
       ||
       ++- Mirroring Mode
            0 = Vertical
            1 = Horizontal
            2 = One Screen Mirroring from $2000 ("1ScA")
            3 = One Screen Mirroring from $2400 ("1ScB")

中规中矩没什么可以说, 自己目前的是[2,3,0,1]的顺序, 可以用[2,3,0,1][mode]查表外, 自然就是mode XOR 2就行了.

命令: IRQ Control ($D)

7  bit  0
---- ----
C... ...T
|       |
|       +- IRQ Enable
|           0 = Do not generate IRQs
|           1 = Do generate IRQs
+-------- IRQ Counter Enable
            0 = Disable Counter Decrement
            1 = Enable Counter Decrement

FME7的IRQ是通过一个16bit的计数器, D7启动时会在每个CPU周期递减. 当D0启动并且计数器从$0000变成$FFFF触发IRQ.

写入该命令以确认IRQ.

命令: IRQ Counter Low Byte ($E)

IRQ计数器的低8位

命令: IRQ Counter High Byte ($F)

IRQ计数器的高8位

模拟蝙蝠侠出现的问题

Sun电子出品的《蝙蝠侠》难度比较高, 反正小时候没通关就是了, 还是老问题, 让蝙蝠侠变成字母侠了:

bug1

游戏问题

有2个问题, 模拟出现问题(瑕疵), 但是使用其他模拟器也是同样的:

  • 标题BGM: Sunsoft出品感觉肯定没问题, 但是发现标题的BGM在播放时, 切换声道音高有点不自然. 其他模拟器也是这样的, 所以应该是本身问题.
  • 标题过场画面闪烁, 其他模拟器也是同样的.

5B扩展音源

FME7写入高地址有两个空缺的位置, 就是为音频准备的. 硬件方面是Yamaha YM2149F(出现了, 又是雅马哈)的一种.

Audio Register Select ($C000-$DFFF)

7......0
----RRRR
    ++++- The 4-bit internal register to select for use with $E000

Audio Register Write ($E000-$FFFF)

7......0
VVVVVVVV
++++++++- The 8-bit value to write to the internal register selected with $C000

YM2149F 内部寄存器

寄存器 位域 说明
$00 LLLL LLLL 声道A周期 低字节
$01 ---- HHHH 声道A周期 高4位
$02 LLLL LLLL 声道B周期 低字节
$03 ---- HHHH 声道B周期 高4位
$04 LLLL LLLL 声道C周期 低字节
$05 ---- HHHH 声道C周期 高4位
$06 ---P PPPP 噪音周期
$07 --CB A--- 声道A/B/C上禁用噪声(Noise)
--- ---- -cba 声道a/b/c上禁用声调(Tone )
$08 ---E VVVV 声道A包络使能(E) 音量(V)
$09 ---E VVVV 声道B包络使能(E) 音量(V)
$0A ---E VVVV 声道C包络使能(E) 音量(V)
$0B LLLL LLLL 包络周期 低字节
$0C HHHH HHHH 包络周期 高字节
$0D ---- CAaH 包络重置与形状
--- --- continue (C)
--- --- attack (A)
--- --- alternate (a)
--- --- hold (H)
$0E ---- ---- I/O端口A(未使用)
$0F ---- ---- I/O端口B(未使用)

$07音调/噪声禁用位:

  • 禁用噪音: 输出音调
  • 禁用音调: 输出噪音
  • 都禁用: 输出恒定音量
  • 都启动: 输出音调'逻辑与'噪音

声音

一共拥有3个声道输出方波, 当然还有一个噪音发生器与包络发生器, 允许这三个声道使用.

5B通过CPU驱动, 不过YM2149F和APU类似, 内部有一个分频器可以让频率降低一半. 而 吉米克! 正是使用了这个模式.

与其他芯片声道对比, 5B的周期就是真正的周期, 不用+1s, 周期0似乎同周期1等价.

声调

声调发生器用来产生方波:

  • 频率 Frequency = Clock / (2 * 16 * Period)
  • 周期 Period = Clock / (2 * 16 * Frequency)
  • 多除了2是因为使用了倍分频器

方波(square), 之前的2A03也提到, 真正的应该叫做脉冲波(pulse), 其中50%占空比的称为方波. 由于wiki整篇没有提到占空比, 所以5B发出的应该就是真正的方波. 其中, 方波的01的'交替频率'自然是上面的两倍.

如果包络使能位设为1, 音量由包络控制, 否则输出自身的音量.

噪音

噪音发生器利用$06通过的5bit周期生成一个1bit的随机波.

  • 频率 Frequency = Clock / (2 * 16 * Period)
  • 周期 Period = Clock / (2 * 16 * Frequency)
  • 多除了2是因为使用了倍分频器
  • 随机数发生器可能是一个17bit的LFSR, 抽头(taps)为D16, D13.
  • 由于抽头在高位, 应该是伽罗瓦的LFSR实现(one-to-many LFSR):
// LFSR FME7模式 - Galois LFSR
static inline uint32_t sfc_lfsr_fme7(uint32_t v) {
    // D16 D13
    const uint32_t bit = v & 1; v >>= 1;
    // 实现1
    //if (bit) v ^= (uint32_t)0x12000;
    // 实现2
    const uint32_t mask = (uint32_t)(-(int32_t)bit);
    v ^= mask & (uint32_t)0x12000;
    return v;
}

包络频率

包络的每一个斜面(ramp)的频率如下:

  • 频率Frequency = Clock / (2 * 256 * Period)
  • 周期Period = Clock / (2 * 256 * Frequency)
  • 多除了2是因为使用了倍分频器

每个Ramp又被细分成32步, 对应的就是'音量'的改变.

YM2149-fig1

左边部分就是对应的固定音量. 右边部分则是包络使用的, 可以看出:

  • [L15] = [R31]
  • [L14] = [R29]
  • ...
  • [L1] = [R3] (虽然看不清但是应该是)
  • [L0] = [R0] (虽然看不清但是应该是) = 0

也就是可以用两个LUT, 反正要用, 不差这一个.

包络形状

通过写入$0D能够重置包络, 然后从4个参数选择其形状:

  • 续 Continue, Continue指定包络在Attack后是否继续震荡. 如果为0(续不了), 后两个参数不起作用
  • 起 Attack, Attack指定是高到底(0), 还是低到高(1)
  • 换 Alternate, Alternate指定信号在Attack后是否上下交换
  • 持 Hold, Hold指定信号在Attack后的保持值.
  • Alternate+Hold时, 会在Attack后交换Hold值然后保持下去
  • 其实不用理解, 只需要查表就行
   值  |  续 |  起  | 换  |持|    形状
-------|-----|-----|-----|--|------------
$00-$03|  0  |  0  |  x  |x |   \_______
$04-$07|  0  |  1  |  x  |x |   /_______
$08    |  1  |  0  |  0  |0 |   \\\\\\\\
$09    |  1  |  0  |  0  |1 |   \_______
$0A    |  1  |  0  |  1  |0 |   \/\/\/\/
$0B    |  1  |  0  |  1  |1 |   \¯¯¯¯¯¯¯
$0C    |  1  |  1  |  0  |0 |   ////////
$0D    |  1  |  1  |  0  |1 |   /¯¯¯¯¯¯¯
$0E    |  1  |  1  |  1  |0 |   /\/\/\/\
$0F    |  1  |  1  |  1  |1 |   /_______

(注: 图表中斜面的'斜率'是1)

也就是可以利用$08或者$0C模拟锯齿波, $0A或者$0E模拟三角波(不过前面了解到, 其实是指数版的锯齿波与三角波).

具体实现中, 希望体现出'形状', 于是这样实现: 化为4步, 然后执行12343434...

/// <summary>
/// StepFC: YM2149F 触发包络
/// </summary>
/// <param name="famicom">The famicom.</param>
static inline void sfc_ym2149f_tick_evn_times(sfc_famicom_t* famicom, uint8_t times) {
    // 不能太大
    //assert(times < 64);
    sfc_fme7_data_t* const fme7 = &famicom->apu.fme7;
    fme7->evn_index += times;
    fme7->evn_repeat |= fme7->evn_index & 64;
    fme7->evn_index &= 63;
    fme7->evn_index |= fme7->evn_repeat;
}

形状则是:

// 5B用 包络形状
#define FME7_SHAPE(a, b, c, d, e, f, g, h) \
 (a<<7) | (b<<6) | (c<<5) | (d<<4) | (e<<3) | (f<<2) | (g<<1) | (h<<0)

/// <summary>
/// FME-7 包络形状查找表
/// </summary>
static const uint8_t sfc_fme7_evn_shape_lut[] = {
    // $00 \___
    FME7_SHAPE(1, 0, 0, 0, 0, 0, 0, 0),
    // $01 \___
    FME7_SHAPE(1, 0, 0, 0, 0, 0, 0, 0),
    // $02 \___
    FME7_SHAPE(1, 0, 0, 0, 0, 0, 0, 0),
    // $03 \___
    FME7_SHAPE(1, 0, 0, 0, 0, 0, 0, 0),
    // $04 /___
    FME7_SHAPE(0, 1, 0, 0, 0, 0, 0, 0),
    // $05 /___
    FME7_SHAPE(0, 1, 0, 0, 0, 0, 0, 0),
    // $06 /___
    FME7_SHAPE(0, 1, 0, 0, 0, 0, 0, 0),
    // $07 /___
    FME7_SHAPE(0, 1, 0, 0, 0, 0, 0, 0),
    // $08 \\\\ 
    FME7_SHAPE(1, 0, 1, 0, 1, 0, 1, 0),
    // $09 \___
    FME7_SHAPE(1, 0, 0, 0, 0, 0, 0, 0),
    // $0A \/\/
    FME7_SHAPE(1, 0, 0, 1, 1, 0, 0, 1),
    // $0B \¯¯¯
    FME7_SHAPE(1, 0, 1, 1, 1, 1, 1, 1),
    // $0C ////
    FME7_SHAPE(0, 1, 0, 1, 0, 1, 0, 1),
    // $0D /¯¯¯
    FME7_SHAPE(0, 1, 1, 1, 1, 1, 1, 1),
    // $0E /\/\ 
    FME7_SHAPE(0, 1, 1, 0, 0, 1, 1, 0),
    // $0F /__
    FME7_SHAPE(0, 1, 0, 0, 0, 0, 0, 0),
};

/// <summary>
/// StepFC: YM2149F 获取包络音量
/// </summary>
/// <param name="shape">The shape.</param>
/// <param name="index">The index.</param>
static inline uint16_t sfc_ym2149f_get_evn_value(uint8_t shape, uint8_t index) {
    const uint8_t shift = (index & 0x60) >> 4;
    shape <<= shift;
    shape &= 0xc0;
    index &= 0x1f;
    index <<= 1;
    const uint8_t* const lut = (const uint8_t*)sfc_fme7_env_lut;
    const uint8_t* const data = &lut[shape | index];
    return *(uint16_t*)data;
}

还可以使用大号的两级LUT(uint8_t[16*64] + uint16_t[32]), 或更大号的1级LUT(uint16_t[16*64]).

不过桌面平台应该是自己这种实现要快一点(缓存友好).

输出

可以看出难点仅仅在于包络发生器的模拟, 其他的非常简单.

  • 一个声道如果是Tone模式, 即模拟方波. 方波01两位中出现1时输出音量
  • 一个声道如果是Noise模式, 即混了噪音, 噪音LFSR最低位是1时输出音量
  • 都没有(disable)就认为是1, 都有就认为将两个(方波01和LFSR最低位)做'与', 输出音量
  • 如果采用了包络, 输出音量是指包络的输出音量, 否则就是声道本身的音量
// 输出
bool flag;
if (tone & noise) flag = square & lfsr & 1;
else flag = disable | (tone & square) | (noise & lfsr);
// 检测
if (flag) output += fme7->ch[i].env ? fme7->env_volume : fme7->ch[i].volume;

还能够简化, 但是不想动脑筋了.

音量

  • 包络音量每格表示是大约1.5dB, 就是4次根号2倍
  • 总共32格也就是至少用8bit(还是9bit?)表示
  • 避免误差, 那就我们用13bit+1(0x2000‬)表示一个声道的音量大小
  • wiki虽然提到音量增益, 不过并没有提到与原来的比较, 只好作: 3声道最大输出1.0(不过和2A03一样, 只有正数, 其实只有一半)

模拟器吉米克出现的问题

这个很恼火.

bug2

开始一切正常, 但是进入游戏就一上来就死. 一步一步追踪, 老是以为IRQ实现有问题(为什么自己老是以为是IRQ有问题???).

output

然后发现CPU$173储存了数据, 但是老为0. 一步一步发现读取了WRAM区域, 吉米克! 将ROM切换到这里了. 最后的最后发现是把 PRG Bank 0 ($8) 的RAM/ROM位看反了:

  • D6:0 = PRG ROM
  • D6:1 = PRG RAM

看成了

  • D6:0 = PRG RAM
  • D6:1 = PRG ROM

...

最后, 自然还有老BUG(游戏状态栏一部分用精灵拼的). 看来FC游戏都喜欢在IRQ中切换BANK.

REF

附录: 查询表生成

自己用的LUT生成如下:

static uint16_t sfc_fme7_env_lut[32*4];
static uint16_t sfc_fme7_vol_lut[16];


enum { SFC_FME7_CH_MAX = 0x2000, SFC_FME7_CH3_MAX = SFC_FME7_CH_MAX * 3 };

/// <summary>
/// 初始化FME-7用LUT
/// </summary>
extern void sfc_fme7_init_lut(void) {
    double table[32];
    // 1 / 2^(0.25)
    const double qqrt2 = 0.84089641525;
    table[0] = 0;
    table[31] = SFC_FME7_CH_MAX;
    for (int i = 0; i != 30; ++i)
        table[30 - i] = table[31 - i] * qqrt2;
    // 建立LUT-A
    sfc_fme7_vol_lut[0] = 0;
    for (int i = 1; i != 16; ++i) {
        sfc_fme7_vol_lut[i] = (uint16_t)(table[i * 2 + 1] + 0.5);
    }
    // 建立LUT-B 0->0
    for (int i = 0; i != 32; ++i) 
        sfc_fme7_env_lut[i] = 0;
    // 建立LUT-B 0->1
    for (int i = 0; i != 32; ++i)
        sfc_fme7_env_lut[i+32] = (uint16_t)(table[i] + 0.5);
    // 建立LUT-B 1->0
    for (int i = 0; i != 32; ++i)
        sfc_fme7_env_lut[i+64] = (uint16_t)(table[31 - i] + 0.5);
    // 建立LUT-B 1->1
    for (int i = 0; i != 32; ++i)
        sfc_fme7_env_lut[i+96] = SFC_FME7_CH_MAX;
}

附录: 相关音色简单探索

这里简单测试了一下 吉米克! 没有使用过的包络、噪音的音色.

噪音, 将噪音和方波的周期弄到最大.

    CMD  PARAM
    0x0B, 0x04,
    0x0C, 0x00,
    0x06, 0x1f,
    0x00, 0xff,
    0x01, 0x0f,
    0x07, 0x30,
    0x08, 0x0e,
    0x09, 0x00,
    0x0A, 0x00,
    0x0D, 0x0E,
  • 听起来很像直升机的声音(直升机的音效原来可以这么简单模拟)!
  • 频率适当加高后听起来像是枪械开火的声音.
  • 也就是这个噪声很适合音效.

包络, 由于使用包络后不能调整音量了, 只能调整频率, 和2A03的三角波差不多.

tri

saw

听起来像......噪音..? 不好意思, 声音开得太大了

Re: 从零开始的红白机模拟 - [36] NSF深入

NSF深入

蓦然回首, 已经可以几乎完全播放NSF了. 也快进入本步骤的尾声了, 这一步似乎过于长了.

项目地址

标准音源

'标准音源'在这里指的是最多有一个扩展音源的那种: exsound为0(2A03音源), 或者说有一位为1(有一个扩展音源), 可以用这个判断:

(exsound & (exsound - 1)) == 0;

当然这里只是指出这个位计算技巧而已, 实际上还是需要一个一个判断. 在这种情况下, 可以直接将写入操作'嫁接'到原来的写入就行(当然, 需要注意NSF的BANK切换寄存器).

多种混合

自然就是NSF支持的, 理论上, 在实机上也能播放(不然支持多音源就没有意义了!), 例如NicoNicoNi这一篇:

nico

从右往左分插了:

  • 吉米克!
  • 女神転生Ⅱ
  • 大航海时代
  • 魍魎戦記MADARA
  • 拉格朗日点

就可以在实机上播放NSF(同一个作者的另一个视频就另有FDS). 一般地, 对于'标准'(最多一个), 可以使用对应的镜像地址. 不过多种混合就支持最低地址(镜像地址重合).

VRC6

  • $9000-9003
  • $A000-A002
  • $B000-B002
  • 恶魔城3 为标准, ab两个变种的两条地址线反了

VRC7

  • $9010
  • $9030

FDS1

  • $4040-$4092
  • FDS情况很特殊, RAM布局什么的都可以不管, 但是BANK切换就不同了:
  • $5FF6,$5FF7能够控制$6000-6FFF, $7000-7FFF这一区域
  • NSF 文件头 $076-$077也有能控制. 详细的还是看wiki吧

MMC5

  • $5000-5015, 音频支持
  • $5205,$5206, 8x8的乘法器
  • $5C00-5FF5, ExRAM区, 最上面的和NSF的BANK切换寄存器冲突了导致不可用

N163

  • $4800
  • $F800

FME7

  • $C000
  • $E000

作为软件播放器, 支持最低地址就行(当然支持所有镜像地址也是不错的), 在实机上由于地址互相重叠, 可能会使用一些技巧.

权重分配

之前使用的权重标准是基于0.00752, 来至wiki的线性逼近公式:

output = pulse_out + tnd_out
pulse_out = 0.00752 * (pulse1 + pulse2)
tnd_out = 0.00851 * triangle + 0.00494 * noise + 0.00335 * dmc

自己理所当然地以为这些加起来等于1, 但是实际上是0.8XXX, 索性直接用Mixer的 95.88/(8128/15+100), 这个值大约0.1494. 所以就用这个值作为基础值, 双通道是0.2585.

  • VRC6, 方波声道(复数)最大音量和2A03差不多
    • [6bit]0.2583 / 30 * vrc6
  • VRC7, 无比较
  • FDS1, 最大音量似乎是方波的2.4倍
    • [6bit]0.1494 * 2.4 / 63 * fds1
  • MMC5, 与2A03一致, 8bit PCM则看自己吧
  • N163, 把0.00752/16换成0.1494/225即可
  • FME7, 无比较

当然, 除了wiki直接给出的, 还有一个参考: NSFe. 这个是NSF的扩展版本. 里面有相关的比较(单位是百分之一dB):

  • VRC6 - Default: 0
    • 这就是提到的'差不多'
  • VRC7 - Comparison: Pseudo-square - Default: 1100
    • 11dB大致为3.5倍, 也就是达到0.523, 考虑到是有符号的所有应该除以2. 6声道暴力加起来就是1.57(双向).
    • 不过自己是认为的是共有8声道, 所以权重为2.092(近似看作2也行).
  • FDS - Default: 700
    • 7dB和'2.4'倍差不多
  • MMC5 - Default: 0
    • 这就是提到的'差不多'
  • N163 - Comparison: 1-Channel mode - Default: 1100 or 1900
    • wiki提到是11dB~19.5dB
  • Sunsoft 5B - Comparison: Volume 12 ($C) - Default: -130
    • 差距是-1.3dB, 不过比较的'12', 和15差了9dB. 加起来7.7dB. 和FDS差不多2.4倍吧
    • 权重0.1494*2.4*3, 几乎和1.0相差无几, 索性看着1.0.

VRC7 产生的伪方波:

vrc7

NSFe虽然支持这个, 不过基本就是针对N163设计的, 其他的几乎就是用实体卡带简单测试一下, 不过作为参考足够了.

这样可以试试这个2A03+VRC6+FDS1+MMC5+FME7的NSF了!

output

FDS 补遗

之前 FDS篇 FDS实现有问题, 由于测试样本不足导致没有发现:

0 = %000 -->  0
1 = %001 --> +1
2 = %010 --> +2
3 = %011 --> +4
4 = %100 --> reset to 0
5 = %101 --> -4
6 = %110 --> -2
7 = %111 --> -1

'4'是'reset to 0', 自己还以为和0一样...导致调制出现问题.

const int8_t value = table[famicom->apu.fds.modtbl_index++];
fds->modtbl_index &= 0x3f;
//  0, 2, 4, 8, 1, -8, -4, -2
if (value & 1) fds->mod_counter_x2 = 0;
else  fds->mod_counter_x2 += value;

N163声道模式

之前 N163篇 提到自己的实现是'声道模式', 输出最后处理的几个历史输出. 使用的是'3', 其实本身就是一个滤波器, 适当延长数字能够更好一点(8声道杂音太重, 比如可以是声道数量-1, 但是不能低于3).

非标准速度支持

前面 FDS篇 提到了64Hz/60Hz, 如果将一个要求64Hz的用60Hz播放会发生什么事情?

自己想了想, 之前将PLAY频率和FPS想在一起了. 其实没有必要, 比如60Hz就是每隔大约3万CPU周期调用一次PLAY, 120Hz就每隔1.5万CPU周期调用一次PLAY就行.

// 播放速度提示
info->clock_per_play_n = (uint64_t)1789773 * (uint64_t)header.play_speed_ntsc_le / (uint64_t)1000000;
info->clock_per_play_p = (uint64_t)1662607 * (uint64_t)header.play_speed__pal_le / (uint64_t)1000000;
printf("NSF NTSC RATE: %.02fHz\n", 1000000.0 / header.play_speed_ntsc_le);

提示INIT结束, 之前INIT结束是没有通知的, 因为大致是到了垂直同步时, INIT应该完成了. 这显然是不准确的, 所以自己HACK了OpCode-02让其通知NSF初始化结束了:

/// <summary>
/// HK2: Hack $02 - 用于提示NSF初始化
/// </summary>
/// <param name="address">The address.</param>
/// <param name="famicom">The famicom.</param>
static inline void sfc_operation_HK2(uint16_t address, sfc_famicom_t* famicom, uint32_t* const cycle) {
    famicom->nsf.play_clock = famicom->rom_info.clock_per_play;
}

INIT和PLAY调用的程序也就不一样了:

    // PLAY时钟周期
    famicom->nsf.play_clock = 0xffffffff;
    // 调用INIT程序
    const uint16_t address = famicom->rom_info.init_addr;
    famicom->registers.program_counter = 0x4106;
    const uint32_t loop_point = 0x410A;
    // JSR
    famicom->bus_memory[0x106] = 0x20;
    famicom->bus_memory[0x107] = (uint8_t)(address & 0xff);
    famicom->bus_memory[0x108] = (uint8_t)(address >> 8);
    // (HACK) HK2
    famicom->bus_memory[0x109] = 0x02;
    // JMP $410A
    famicom->bus_memory[0x10A] = 0x4c;
    famicom->bus_memory[0x10B] = (uint8_t)(loop_point & 0xff);
    famicom->bus_memory[0x10C] = (uint8_t)(loop_point >> 8);

整个流程也简单起来了:

while (famicom->cpu_cycle_count < cpu_cycle_per_frame) {
    const uint32_t cycle = sfc_cpu_execute_one(famicom);
    // PLAY
    if (famicom->nsf.play_clock < cycle) {
        famicom->nsf.play_clock += famicom->rom_info.clock_per_play;
        sfc_famicom_nsf_play(famicom);
    }
    // FRAME COUNTER
    if (famicom->nsf.framecounter_clock < cycle) {
        famicom->nsf.framecounter_clock += cpu_cycle_per_frame_4;
        sfc_trigger_frame_counter(famicom);
    }
    famicom->nsf.play_clock -= cycle;
    famicom->nsf.framecounter_clock -= cycle;
}

现在回答开头的提问, 答案是'慢一点', 不过不是音乐的频率慢一点, 而是节奏或者说BPM慢一点, 频率还是一致. 就像用不同速率敲键盘.

长度计算

原本的NSF是没有曲子的长度信息的, 我们可以进行估计:

  1. 放完了, 部分曲子放完了, 就播放完毕. 我们可以设定一个阈值, 比如说1分钟. 超过阈值还没有改变就认为放完了. 将当前长度减去阈值就行.
  2. 循环. 这个就是常见的游戏BGM的实现了, 要怎么检查曲子是无限循环的呢?
  • 这里提供一个思路, 在每次PLAY后监视哪些变量进行修改了, 最后进行评估.
  • 或者比如超过5分钟还没完就认为是3分钟的曲子

随机访问

比如很想支持Seek功能, 这里提供一个思路:

  • 类似于S/L, 将数据尽可能简化(比如不用储存VRAM), 数据量可能在30kb左右(没简化的数据量, 其实NSF用不了多少RAM, 甚至能够简化到几kb).
  • 用1mb为上限大致能存30个'快照'
  • 找到最近的前快照然后加速到目标时间

辣鸡ROM支持

load_addr + length 没有对齐BANK. 之前弄到一个NSF文件, 15.3kb, 理所当然认为占用了4个BANK(4kb), 但是实际上用了5个BANK. 典型的辣鸡.


上面就是一些核心支持, 下面就是一些具体实现, 可以略过.

配合MMC5

// 将BANK3-WRAM使用扩展RAM代替
famicom->prg_banks[6] = famicom->expansion_ram32 + 4 * 1024 * 0;
famicom->prg_banks[7] = famicom->expansion_ram32 + 4 * 1024 * 4;

原来8kb的WRAM分配给MMC5, NSF播放使用多余的32kb扩展区的前面8kb作为新的WRAM, 再接下来的128字节是N163的扩展RAM.

音频事件

之前实现的是audio_changed事件, 目的是为了"增量更新". 关键在于'ed', 本意是修改了再触发, 没有修改就没有必要. 其实专门用来处理'帧计数器/序列器'的(这个判断很复杂). 现在先无视状态直接触发事件, 以后优化.

所以将audio_changed事件改成audio_change事件.

尘归尘, 浮点归定点

之前提到由于"每样本CPU周期数"由于除不尽(44.1kHz大致是40.58), 采用浮点避免误差. 但是这东西一会整型转浮点, 一会浮点转整型, 烦得很.

其实是为了方便描述而已, 这里就用定点小数模拟就行. 比如uint32_t高16位看着整数, 高16位看作小数, 由于每帧样本数大致在一千数量级, 所以误差数量级少于周期每帧.

// 目前使用16.16定点小数, 可以换成6.10~8.8的16bit
typedef uint32_t sfc_fixed_t;
// 创建
static inline sfc_fixed_t sfc_fixed_make(uint32_t a, uint32_t b) {
    //------- 防溢出操作
    // 整数部分
    const uint32_t part0 = a / b;
    const uint32_t hi16 = part0 << 16;
    // 小数部分
    const uint32_t part1 = a % b;
    const uint32_t lo16 = (part1 << 16) / b;
    return hi16 | lo16;
}
// 增加
static inline uint16_t sfc_fixed_add(sfc_fixed_t* a, sfc_fixed_t b) {
    *a += b; const uint16_t rv = *a >> 16; *a &= 0xffff; return rv;
}

由于全部从浮点小数重写为定点小数, 很容易出现自己的'ctrl+c/v'的BUG, 只能祈祷岩田聪了.

不过这样一来, 代码终于变得清爽了:

float output = 0.f;
// 2A03
{
    sfc_2a03_smi_ctx_t* const ctx = &g_states.ctx_2a03;
    const float* const weight_list = g_states.ch_weight + SFC_2A03_Square1;
    sfc_2a03_smi_sample(g_famicom, ctx, weight_list, cps_fixed);
    const float squ = sfc_mix_square(ctx->sq1_output, ctx->sq2_output);
    const float tnd = sfc_mix_tnd(ctx->tri_output, ctx->noi_output, ctx->dmc_output);
    output += squ + tnd;
}
// VRC6
if (extra_sound & SFC_NSF_EX_VCR6) {
    const float* const weight_list = g_states.ch_weight + SFC_VRC6_Square1;
    sfc_vrc6_smi_ctx_t* const ctx = &g_states.ctx_vrc6;
    sfc_vrc6_smi_sample(g_famicom, ctx, weight_list, cps_fixed);
    const float vrc6 = ctx->square1_output + ctx->square2_output + ctx->sawtooth_output;
    output += (0.2583f / 30.f) * vrc6;
}
// VRC7
if (extra_sound & SFC_NSF_EX_VCR7) {
    const float* const weight_list = g_states.ch_weight + SFC_VRC7_FM0;
    sfc_vrc7_smi_sample(g_famicom, &g_states.ctx_vrc7, weight_list, cps_fixed);
    const float weight = (float)(0.1494 * 3.5 * 8 * 0.5);
    output += g_states.ctx_vrc7.mixed * weight;
}
// FDS1
if (extra_sound & SFC_NSF_EX_FDS1) {
    const float* const weight_list = g_states.ch_weight + SFC_FDS1_Wavefrom;
    sfc_fds1_smi_sample(g_famicom, &g_states.ctx_fds1, weight_list, cps_fixed);
    float out = g_states.ctx_fds1.output * (2.4f * 0.1494f / 63.0f);
    out = sfc_filter_rclp(&g_states.fds_lp2k, out);
    output += out;
}
// MMC5
if (extra_sound & SFC_NSF_EX_MMC5) {
    sfc_mmc5_smi_ctx_t* const ctx = &g_states.ctx_mmc5;
    const float* const weight_list = g_states.ch_weight + SFC_MMC5_Square1;
    sfc_mmc5_smi_sample(g_famicom, ctx, weight_list, cps_fixed);
    const float squ = sfc_mix_square(ctx->sq1_output, ctx->sq2_output);
    const float pcm = 0.002f * ctx->pcm_output;
    output += squ + pcm;
}
// N163
if (extra_sound & SFC_NSF_EX_N163) {
    // N声道模式
    const uint8_t mode = 7;
    const float* const weight_list = g_states.ch_weight + SFC_N163_Wavefrom0;
    sfc_n163_smi_ctx_t* const ctx = &g_states.ctx_n163;
    sfc_n163_smi_sample(g_famicom, ctx, weight_list, cps_fixed, mode);
    output += ctx->output * ctx->subweight * (0.1494f / 225.f);
}
// FME7
if (extra_sound & SFC_NSF_EX_FME7) {
    sfc_fme7_smi_ctx_t* const ctx = &g_states.ctx_fme7;
    const float* const weight_list = g_states.ch_weight + SFC_FME7_ChannelA;
    sfc_fme7_smi_sample(g_famicom, ctx, weight_list, cps_fixed);
    // FME7 权重近似0.1494*2.4*3 近似 = 1
    output += ctx->output[0] + ctx->output[1] + ctx->output[2];
}

VRC6 锯齿波

锯齿波的强化除了之前提到的利用浮点进行插值, 还有一个:

VRC6的锯齿波之前提到了, 由于寄存器宽度问题, 音量(其实叫做rate, $B000)超过42就会导致失真. 所以可以考虑为寄存器增加宽度防止失真.

VRC7使用浮点模拟

双精度浮点可是可以将uint32_t也能完整地保存, 采用双精度浮点模拟VRC7的话音质应该是最高的了.

VRC7变频模拟

之前提到VRC7内部几乎以50kHz运行, 我们需要进行重采样. 这里就回答之前提到的问题: 为什么其他的没有进行重采样.

答案很简单, 实现困难(1.79MHz->44.1kHz), 收益很低(不会故意放出超声波, 只有较弱的高次谐波).

而VRC7的50kHz, 一是固定的, 二是和使用的采样率比较接近, 于是就特意指出了. 而最好的重采样算法自然就是"不用重采样", 我们将VRC7的运行频率变得和当前使用的采样率一样的话, 自然就不需要了.

如果直接暴力变频自然是, 声音音调不对. 这一点就需要内部部件和VRC7同时变频即可. 这一点需要双精度浮点支持, 不然误差太大反而弄巧成拙.

比如采样率是44.1kHz, VRC7降低了约十分之一, 内部声道的频率增加十分之一, FM/AM速度也增加十分之一就行.

N163变频模拟

这个和VRC7差不多, 由于声道多了, 1~2声道应该不错, 3声道和目前输出采样率差不多, 也行. 所以可在超过3声道时, 给N163'超频': 把声道频率降低, N163频率提高这样应该可以有效解决杂音问题.

同样'变频'这个概念可以推广在其他音源上.

REF

Re: 从零开始的红白机模拟 - [11]背景渲染

STEP4: 背景渲染

那么进入第四步, 背景的渲染. 终于进入关于图像渲染的步骤了, 这开始后就加快节奏了, 图形API的部分会略过, 因为可能读者拥有自己习惯的图形API, 这里采用的是D2D 1.1, 至少需要Win7的平台更新或者Win8.

黑匣子

如果没有自己熟悉的图形API的话, 这里介绍一下"黑匣子"函数, 就像printf那样, 不需要了解实现细节, 只需要知道它是干什么的就行. 这里, 自己将这些称为黑匣子函数:

  • 头文件 "common/d2d_interface.h"
  • 源文件 "common/d2d_draw.cpp"
  • 是的, 这个用C++实现的
  • void main_cpp() 入口函数, 这个函数内部会一直循环直到窗口被关闭
  • void main_render(void* rgba) 以RGBA字节序填充一个256x240像素的缓存, 实际上为了避免越界, 是256x256+256的缓冲空间. 由于RGBA序列刚好和uint32_t一样大, 所以这个缓存的类型是uint32_t. 结果会显示在窗口的左边
  • int sub_render(void* rgba) 同上, 不过在返回非零值才会显示. 会显示在稍微右边点
  • 不需要副渲染可以定义一次SFC_NO_SUBRENDER
  • void user_input(int index, unsigned char data) 用户输入, 很简单的接口
  • 不需要用户输入就定义一次SFC_NO_INPUT
  • void qsave() void qload()为了更为方便地调试, 即时存档/读档的接口
  • 不需要S/L就定义一次SFC_NO_SL
  • 这些可能随时修改, 不过如果大幅度修改可能会新开头文件

PPU 地址空间

进入正题, 关于PPU的地址空间. PPU拥有16kb的地址空间, 完全独立于CPU. 再高的地址会被镜像.

地址 大小 描述
$0000-$0FFF $1000 图样(非彼图样)表0
$1000-$1FFF $1000 图样(非彼图样)表1
$2000-$23FF $0400 名称表 0
$2400-$27FF $0400 名称表 1
$2800-$2BFF $0400 名称表 2
$2C00-$2FFF $0400 名称表 3
$3000-$3EFF $0F00 $2000-$2EFF 镜像
$3F00-$3F1F $0020 调色板内存索引
$3F20-$3FFF $00E0 $3F00-$3F1F 镜像
  • 图样表: pattern tables
  • 名称表: Nametables
  • 属性表: Attribute tables
  • 调色板: Palette

调色板

FC理论能显示64种颜色(使用某种技巧的话):

savtool-swatches

自己使用的RGBA序调色板:

/// <summary>
/// 调色板数据
/// </summary>
const union sfc_palette_data {
    struct { uint8_t r, g, b, a; };
    uint32_t    data;
} sfc_stdpalette[64] = {
    { 0x7F, 0x7F, 0x7F, 0xFF }, { 0x20, 0x00, 0xB0, 0xFF }, { 0x28, 0x00, 0xB8, 0xFF }, { 0x60, 0x10, 0xA0, 0xFF },
    { 0x98, 0x20, 0x78, 0xFF }, { 0xB0, 0x10, 0x30, 0xFF }, { 0xA0, 0x30, 0x00, 0xFF }, { 0x78, 0x40, 0x00, 0xFF },
    { 0x48, 0x58, 0x00, 0xFF }, { 0x38, 0x68, 0x00, 0xFF }, { 0x38, 0x6C, 0x00, 0xFF }, { 0x30, 0x60, 0x40, 0xFF },
    { 0x30, 0x50, 0x80, 0xFF }, { 0x00, 0x00, 0x00, 0xFF }, { 0x00, 0x00, 0x00, 0xFF }, { 0x00, 0x00, 0x00, 0xFF },

    { 0xBC, 0xBC, 0xBC, 0xFF }, { 0x40, 0x60, 0xF8, 0xFF }, { 0x40, 0x40, 0xFF, 0xFF }, { 0x90, 0x40, 0xF0, 0xFF },
    { 0xD8, 0x40, 0xC0, 0xFF }, { 0xD8, 0x40, 0x60, 0xFF }, { 0xE0, 0x50, 0x00, 0xFF }, { 0xC0, 0x70, 0x00, 0xFF },
    { 0x88, 0x88, 0x00, 0xFF }, { 0x50, 0xA0, 0x00, 0xFF }, { 0x48, 0xA8, 0x10, 0xFF }, { 0x48, 0xA0, 0x68, 0xFF },
    { 0x40, 0x90, 0xC0, 0xFF }, { 0x00, 0x00, 0x00, 0xFF }, { 0x00, 0x00, 0x00, 0xFF }, { 0x00, 0x00, 0x00, 0xFF },

    { 0xFF, 0xFF, 0xFF, 0xFF }, { 0x60, 0xA0, 0xFF, 0xFF }, { 0x50, 0x80, 0xFF, 0xFF }, { 0xA0, 0x70, 0xFF, 0xFF },
    { 0xF0, 0x60, 0xFF, 0xFF }, { 0xFF, 0x60, 0xB0, 0xFF }, { 0xFF, 0x78, 0x30, 0xFF }, { 0xFF, 0xA0, 0x00, 0xFF },
    { 0xE8, 0xD0, 0x20, 0xFF }, { 0x98, 0xE8, 0x00, 0xFF }, { 0x70, 0xF0, 0x40, 0xFF }, { 0x70, 0xE0, 0x90, 0xFF },
    { 0x60, 0xD0, 0xE0, 0xFF }, { 0x60, 0x60, 0x60, 0xFF }, { 0x00, 0x00, 0x00, 0xFF }, { 0x00, 0x00, 0x00, 0xFF },

    { 0xFF, 0xFF, 0xFF, 0xFF }, { 0x90, 0xD0, 0xFF, 0xFF }, { 0xA0, 0xB8, 0xFF, 0xFF }, { 0xC0, 0xB0, 0xFF, 0xFF },
    { 0xE0, 0xB0, 0xFF, 0xFF }, { 0xFF, 0xB8, 0xE8, 0xFF }, { 0xFF, 0xC8, 0xB8, 0xFF }, { 0xFF, 0xD8, 0xA0, 0xFF },
    { 0xFF, 0xF0, 0x90, 0xFF }, { 0xC8, 0xF0, 0x80, 0xFF }, { 0xA0, 0xF0, 0xA0, 0xFF }, { 0xA0, 0xFF, 0xC8, 0xFF },
    { 0xA0, 0xFF, 0xF0, 0xFF }, { 0xA0, 0xA0, 0xA0, 0xFF }, { 0x00, 0x00, 0x00, 0xFF }, { 0x00, 0x00, 0x00, 0xFF }
};

调色板索引是32字节的一段数据

  • 前面16字节是背景用
  • 后面16字节是精灵用

16个也就是需要4位, 换句话说一个像素仅仅需要4位来描述, 就目前而言:

4位数据 -> 调色板索引 -> 输出颜色

但是, 实际上当最低俩位是0的时候:

  • 背景: 使用全局背景色($3F00)
  • 精灵: 透明显示

也就是说虽然有16个, 但是有1/4的用不到:

  • 背景能用16 x 0.75 + 1 = 13个
  • 精灵则是16 x 0.75 = 12个

镜像

  • $3F10/$3F14/$3F18/$3F1C
  • 对应$3F00/$3F04/$3F08/$3F0C
  • 如果你没有实现的话, '超级马里奥' 背景色是黑的.... 自己找了一上午的原因, 原来是在这, 坑爹啊

名称表

名称表就是用来排列显示背景的. 背景每个图块是8x8像素. 而FC总共是256x240即有32x30个背景图块.每个图块用1字节表示所以一个背景差不多就需要1kb(32x30=960b). 为了实现1像素滚动, 两个背景连在一起然后用一个偏移量表示就行了:

mario

就像马里奥这样, 红框就是当前屏幕显示(不是**, 而是两侧两部分合起来).

虽然PPU支持4个名称表,不过FC只支持2个(FC自带的2kb显存), 另外两个被做了镜像. 当然, 如果卡带自带了显存那么就可以支持4个名称表了, 也就是ROM中提到的4屏模式.

.....

好像缺了点什么.每个表还有64字节没用? 那个时候的汇编程序猿怎么可能浪费RAM的字节, 被用在了属性表上.

属性表

64字节被瓜分成8x8, 也就是把背景分成8x8的区域:

      2xx0    2xx1    2xx2    2xx3    2xx4    2xx5    2xx6    2xx7
     ,-------+-------+-------+-------+-------+-------+-------+-------.
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
2xC0:| - + - | - + - | - + - | - + - | - + - | - + - | - + - | - + - |
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
2xC8:| - + - | - + - | - + - | - + - | - + - | - + - | - + - | - + - |
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
2xD0:| - + - | - + - | - + - | - + - | - + - | - + - | - + - | - + - |
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
2xD8:| - + - | - + - | - + - | - + - | - + - | - + - | - + - | - + - |
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
2xE0:| - + - | - + - | - + - | - + - | - + - | - + - | - + - | - + - |
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
2xE8:| - + - | - + - | - + - | - + - | - + - | - + - | - + - | - + - |
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
     +-------+-------+-------+-------+-------+-------+-------+-------+
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
2xF0:| - + - | - + - | - + - | - + - | - + - | - + - | - + - | - + - |
     |   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
     +-------+-------+-------+-------+-------+-------+-------+-------+
2xF8:|   .   |   .   |   .   |   .   |   .   |   .   |   .   |   .   |
     `-------+-------+-------+-------+-------+-------+-------+-------'

属性表自然是描述熟悉属性的, 描述该区域(32x32像素, 也就是4x4个图块)所使用的调色板.

其中又被瓜分成'田'字, 即16x16像素(2x2图块), 每部分分得2位(太抠门了)

,---+---+---+---.
|   |   |   |   |
+ D1-D0 + D3-D2 +
|   |   |   |   |
+---+---+---+---+
|   |   |   |   |
+ D5-D4 + D7-D6 +
|   |   |   |   |
`---+---+---+---'

换句话说, 16x16像素区域公用了一个2位的属性, 导致这16x16中, 只能显示最多4种颜色.

于是调色板中4位其中2位就在这里了.剩下的两位自然就是在图样(非彼图样)表里面了:

图样表

图样表一般来映射自ROM中的CHR-ROM或者卡带上的CHR-RAM.

每个'图样'使用16字节, 描述了一个8x8的图块.

       VRAM    Contents of                     Colour 
       Addr   Pattern Table                    Result
      ------ ---------------                  --------
      $0000: %00010000 = $10 --+              ...1.... Periods are used to
        ..   %00000000 = $00   |              ..2.2... represent colour 0.
        ..   %01000100 = $44   |              .3...3.. Numbers represent
        ..   %00000000 = $00   +-- Bit 0      2.....2. the actual palette
        ..   %11111110 = $FE   |              1111111. colour #.
        ..   %00000000 = $00   |              2.....2.
        ..   %10000010 = $82   |              3.....3.
      $0007: %00000000 = $00 --+              ........

      $0008: %00000000 = $00 --+
        ..   %00101000 = $28   |
        ..   %01000100 = $44   |
        ..   %10000010 = $82   +-- Bit 1
        ..   %00000000 = $00   |
        ..   %10000010 = $82   |
        ..   %10000010 = $82   |
      $000F: %00000000 = $00 --+

前面8字节(平面0)表示2位中的低位, 后面8字节(平面1)表示两位中的高位. 这两位合在一起是索引需要的4位中的低两位.

图样分为两个平面(plane), 多个平面组合起来才是最终的数据, 这个概念在多媒体中很常见.

名称表是一个字节, 也就是16*256 = 4kb, 刚好填充一个图样表. 那么如何确定是使用两个中的哪个? 答案是利用PPU寄存器状态位控制. 当然, 渲染精灵的话, 如果是8x16的精灵, 两个图样表都要用上了.

PPU寄存器

还记得大明湖畔, 呃不, 8字节步进镜像的PPU寄存器吗?

端口地址 读写/位 功能描述 解释
$2000 PPU控制寄存器 PPUCTRL
- D1 D0 确定当前使用的名称表 配合微调滚动使用
- D2 PPU读写显存增量 0(+1 列模式) 1(+32 行模式)
- D3 精灵用图样表地址 0($0000) 1($1000)
- D4 背景用图样表地址 0($0000) 1($1000)
- D5 精灵尺寸(高度) 0(8x8) 1(8x16)
- D6 PPU 主/从模式 FC没有用到
- D7 NMI生成使能标志位 1(在VBlank时触发NMI)
$2001 PPU掩码寄存器 PPUMASK
- D0 显示模式 0(彩色) 1(灰阶)
- D1 背景掩码 0(不显示最左边那列, 8像素)的背景
- D2 精灵掩码 0(不显示最左边那列, 8像素)的精灵
- D3 背景显示使能标志位 1(显示背景)
- D4 精灵显示使能标志位 1(显示精灵)
NTSC D5 D6 D7 颜色强调使能标志位 5-7分别是强调RGB
PAL D5 D6 D7 颜色强调使能标志位 5-7分别是强调GRB
$2002 PPU状态寄存器 PPUSTATUS
- D5 精灵溢出标志位 0(当前扫描线精灵个数小于8)
- D6 精灵命中测试标志位 1(#0精灵命中) VBlank之后置0
- D7 VBlank标志位 VBlank开始时置1, 结束或者读取该字节($2002)后置0
$2003 精灵RAM指针 设置精灵RAM的8位指针
$2004 读写 精灵RAM数据 读写精灵RAM数据, 访问后指针+1
$2005 写x2 屏幕滚动偏移 第一个写的值: 垂直滚动 第第一个写的值: 水平滚动
$2006 写x2 显存指针 第一个写指针的高6位 第二个写低8位
$2007 读写 访问显存数据 指针会在读写后+1或者+32
$4014 DMA访问精灵RAM 通过写一个值$xx, 将CPU内存地址为$xx00-$xxFF的数据复制到精灵内存

目前需要的:

  1. $2000: D4 背景使用的图样表地址
  2. $2000: D7 VBlank期间是否产生NMI.
    • 也就是说虽然叫做NMI, 但是在FC上还是可屏蔽的.
    • 如果需要做比较长的工作, 比如游戏开始(该位初始化为0也暗示了)需要比较长的处理, 就置0, 完了就置1
  3. $2002: D7 VBlank标记
    • 简单地说就是VBlank开始置1, 结束置0
    • 但是读取该数据后, VBlank标记也要置0

以及VRAM读写相关的寄存器

BANK

PPU地址空间也同CPU地址空间使用BANK处理, 之前在Mapper000中应该提到了. CPU的BANK目前是8KB, PPU则更为细腻一点: 1KB. 这1KB导致了一些BUG到最后才发现.

PPU缺陷

个人认为是因为PPU来不及在一个CPU周期内, 返回数据, 才不得已这么实现. PPU地址空间读取, 实际上返回的是内部的一个缓冲值, 也就是说第一次读取的[$0000, $3F00]显存值是无效:

    LDA #$20
    STA $2006
    LDA #$00
    STA $2006        ; VRAM address now set at $2000
    LDA $2007        ; A=??     VRAM Buffer=$AA
    LDA $2007        ; A=$AA    VRAM Buffer=$BB
    LDA $2007        ; A=$BB    VRAM Buffer=$CC
    LDA #$20
    STA $2006
    LDA #$00
    STA $2006        ; VRAM address now set at $2000
    LDA $2007        ; A=$CC    VRAM Buffer=$AA
    LDA $2007        ; A=$AA    VRAM Buffer=$BB

不过对于后面的调色板, 这个缺陷不存在(但是会更新缓冲值).

这里需要注意的是, 这里调色板更新缓冲值的实现是错误的, 但是没有关系, 除了测试ROM没人会利用调色板修改的缓冲值. 这个实现会在后面修改.

  // 调试板前数据
  if (real_address < (uint16_t)0x3F00) {
      const uint16_t index = real_address >> 10;
      const uint16_t offset = real_address & (uint16_t)0x3FF;
      assert(ppu->banks[index]);
      const uint8_t data = ppu->pseudo;
      ppu->pseudo = ppu->banks[index][offset];
      return data;
  }
  // 调色板索引
  else return ppu->pseudo = ppu->spindexes[real_address & (uint16_t)0x1f];

写入就很简单了, 注意一下镜像数据:

    const uint16_t real_address = address & (uint16_t)0x3FFF;
    // 使用BANK写入
    if (real_address < (uint16_t)0x3F00) {
        assert(real_address >= 0x2000);
        const uint16_t index = real_address >> 10;
        const uint16_t offset = real_address & (uint16_t)0x3FF;
        assert(ppu->banks[index]);
        ppu->banks[index][offset] = data;
    }
    // 调色板索引
    else {
        // 独立地址
        if (real_address & (uint16_t)0x03) {
            ppu->spindexes[real_address & (uint16_t)0x1f] = data;
        }
        // 镜像$3F00/$3F04/$3F08/$3F0C
        else {
            const uint16_t offset = real_address & (uint16_t)0x0f;
            ppu->spindexes[offset] = data;
            ppu->spindexes[offset | (uint16_t)0x10] = data;
        }
    }

PPU地址空间读写

有了读写函数, 实现起来就很简单了:

    case 6:
        // 0x2006: Address ($2006) >> write x2
        // PPU 地址寄存器 - 双写
        // 写入高字节
        if (ppu->writex2 & 1) {
            ppu->vramaddr = (ppu->vramaddr & (uint16_t)0xFF00) | (uint16_t)data;
        }
        // 写入低字节
        else {
            ppu->vramaddr = (ppu->vramaddr & (uint16_t)0x00FF) | ((uint16_t)data << 8);
        }
        ++ppu->writex2;
        break;
    case 7:
        // 0x2007: Data ($2007) <> read/write
        // PPU VRAM数据端
        sfc_write_ppu_address(ppu->vramaddr, data, ppu);
        ppu->vramaddr += (uint16_t)((ppu->ctrl & SFC_PPU2000_VINC32) ? 32 : 1);
        break;

读取也是类似的, 其他寄存器按照表格, 有些不着急实现.

绘制背景

现在我们就可以绘制背景了.

  1. ROM运行起来会自己填充名称表和属性表
  2. 根据Mapper将图样表载入对应地址(测试ROM就8KB CHR-ROM而已)
  3. 根据$2000:D4 位挑选图样表
  4. 合并图样表的两位与属性表的两位查找对应的背景调色板颜色
  5. 输出背景

从图样表中了解到, 一个字节对应8个像素, 所以最好的实现方式是一次性渲染8个或者8个整数倍像素. 逻辑和效率兼备.

这里就不走寻常路, 是以像素为单位渲染, 目的是为了方便以后像素着色器的编写:

/// <summary>
/// 获取坐标像素
/// </summary>
/// <param name="x">The x.</param>
/// <param name="y">The y.</param>
/// <param name="nt">The nt.</param>
/// <param name="bg">The bg.</param>
/// <returns></returns>
uint32_t get_pixel(unsigned x, unsigned y, const uint8_t* nt, const uint8_t* bg) {
    // 获取所在名称表
    const unsigned id = (x >> 3) + (y >> 3) * 32;
    const uint32_t name = nt[id];
    // 查找对应图样表
    const uint8_t* nowp0 = bg + name * 16;
    const uint8_t* nowp1 = nowp0 + 8;
    // Y坐标为平面内偏移
    const int offset = y & 0x7;
    const uint8_t p0 = nowp0[offset];
    const uint8_t p1 = nowp1[offset];
    // X坐标为字节内偏移
    const uint8_t shift = (~x) & 0x7;
    const uint8_t mask = 1 << shift;
    // 计算低二位
    const uint8_t low = ((p0 & mask) >> shift) | ((p1 & mask) >> shift << 1);
    // 计算所在属性表
    const unsigned aid = (x >> 5) + (y >> 5) * 8;
    const uint8_t attr = nt[aid + (32*30)];
    // 获取属性表内位偏移
    const uint8_t aoffset = ((x & 0x10) >> 3) | ((y & 0x10) >> 2);
    // 计算高两位
    const uint8_t high = (attr & (3 << aoffset)) >> aoffset << 2;
    // 合并作为颜色
    const uint8_t index = high | low;

    return palette_data[index];
}

C没有类型安全一说, 有时很方便, 有时很X疼. 直接extern就能重解释, 连警告都没有 —— 爽.

同步

由于现在没有任何同步手段, 但是一般来说游戏会等待VBlank, 所以我们目前渲染逻辑如下:

  • 执行足够多的指令(比如定一个小目标: 执行™一万次)
  • 触发VBlank(NMI)中断(需要检查$2000:D7)
  • 渲染图像
  • 回到第一步

窒息的操作

实际编码中, 不小心让NMI跳向RESET向量(Ctrl+C, V大法好), 图像老是有问题...一步一步反汇编发现是NMI实现错了, 差点弃坑.

编程中小问题总是会引发大麻烦.

输出测试ROM左上背景屏幕

output

这就是这ROM左上角屏幕的显示了, 正好是显示内容.

项目地址Github-StepFC-Step4

作业

  • 基础: 这次是输出左上角的屏幕, 试着输出其他位置的屏幕
  • 扩展: 重写渲染逻辑, 以8像素为单位渲染背景
  • 从零开始: 从零开始写自己的模拟器吧

REF

Re: 从零开始的红白机模拟 - [13]精灵渲染

STEP6: 精灵渲染

这一步就进入关于精灵的渲染, 精灵(Sprite)是2D游戏中一个通用概念, 一般来说是指一个单独的图块, 控制播放动画什么的. 很多2D游戏引擎甚至2D图形API都会提供叫做"精灵"的接口.

精灵显示

精灵拥有独立的256字节用内存, 这256字节不在PPU的地址空间内.

整个精灵RAM可以通过$4014的DMA(直接储存器访问)方式来写,写一个8位的数到$4014就将这个8位数所指定的内存页面整个拷贝到精灵RAM上。

端口地址 读写/位 功能描述 解释
$2003 精灵RAM指针 设置精灵RAM的8位指针
$2004 读写 精灵RAM数据 读写精灵RAM数据, 访问后指针+1
$4014 DMA访问精灵RAM 通过写一个值$xx, 将CPU内存地址为$xx00-$xxFF的数据复制到精灵内存

每个精灵共4字节的属性, 共计64个:

字节 描述
0 YYYY YYYY 精灵Y坐标-1
- - 以显示的角度来看则+1s. 换句话说, 不能显示在第一行
1 IIII IIII Tile索引号(类似于名称表)
2 v--- ---- v 1-垂直翻转
- -h-- ---- h 1-水平翻转
- --p- ---- p 优先级(0-在背景前 1-在背景后)
- ---- --pp pp 调色板高两位(类似于属性表)
3 XXXXXXXX 精灵X坐标

要点:

  1. 是否为8x16?
  2. 8x16的话, 偶数IIII IIII使用图样表是$0000,而奇数则为$1000, 然后读取32字节
  3. 加载的图样表地址?
  4. 0号优先度最高(最后被画),63号优先度最低(最先被画)
  5. 一条扫描线最多渲染8个精灵, 超过8个会设置精灵溢出标志位($2002:D5)
  6. 可以通过$2004或者$4014设置精灵RAM. 后者类似memcpy, 比手动实现的快
  7. 由于+1s, 所以(y)$EF-$FF的显示不了, 设置为这个值就相当于隐藏

对于第二点的额外说明:

  • $00: $0000-$001F
  • $01: $1000-$101F
  • $02: $0020-$003F
  • $03: $1020-$103F
  • $04: $0040-$005F
  • ...
  • $FE: $0FE0-$0FFF
  • $FF: $1FE0-$1FFF

渲染实现

同背景一样的渲染逻辑, 不过这次就以8个像素为单位渲染:

// 0 - D7
const uint8_t low0 = ((p0 & (uint8_t)0x80) >> 7) | ((p1 & (uint8_t)0x80) >> 6);
if (low0) output[0] = palette_data[high | low0];
// 下略

当然可以通过写入临时的palette_data避免分支

// 7 - D7
const uint8_t low0 = ((p0 & (uint8_t)0x80) >> 7) | ((p1 & (uint8_t)0x80) >> 6);
palette_data[high] = output[0];
output[0] = palette_data[high | low0];
// 下略

效率的话, 经过测试可以大致提高100%

黑匣子

利用黑匣子函数的副渲染, 渲染在主渲染(背景)的右侧

新的ROM

这一次使用的ROM叫做"color_test", 作者rainwarrior.

输出显示

有了背景渲染的经验, 这次同样很简单, 但是细节部分就比较困难了, 比如8x16, 两个方向的翻转. 这次DEMO工程里面就没有实现8x16和垂直翻转, 只实现了水平翻转.

output

当然, 这个ROM也测试了加强颜色位, 个人认为这个算是特殊效果应该由高层渲染实现, 而不是核心层实现, 所以这里没有去实现

项目地址Github-StepFC-Step6

作业

  • 基础: 尝试实现DEMO里面没有实现的垂直翻转
  • 扩展: 尝试实现DEMO里面没有实现的8x16模式
  • 从零开始: 从零开始实现自己的模拟器吧

REF

Re: 从零开始的红白机模拟 - [38完] 全部成为F

StepF: 全部成为F

项目地址: Github-StepFC-StepF.
不光如此, 本系列博客还有Markdown文件位于GitHub-StepFC-Blog供离线查看.

StepF的目的是成为一个真正的模拟器, 本文由于时间关系不能面面俱到. 但是很多功能可以参考其他的成熟的模拟器, 再进行实现.

C vs C++

这一个项目是自己第一次全部(核心部分)使用C来写, 原以为会异常繁琐, 没想到写得很顺畅(有些地方还很爽). 这就不难理解一些人用C++仅仅作为C with class了.

不过类型安全还是感觉挺重要的(经常忘记打上&, 就被警告寻址类型不同, 只能打开编译器的'警告视为错误'了).

写保护

如果写入ROM自然会被硬件忽略, 同时不少硬件拥有针对RAM的写保护, 这部分自己是还没有实现的, 这一点需要特别注意.

iNES vs NES 2.0

文件头的一般格式, 这两个文件头的魔法码(magic code)都是一样的, 这就比较麻烦了, wiki上有判断的方法.

同样适合NSF vs NSFe文件(不过就好多了), NSFe有时间信息, 可以知道曲子多长.

FDS模拟

之前提到的会在这里简单讨论一下, 但是看了一下, 没有必要进行技术上的讨论, 这里就简单讨论一下法律风险.

除开像我们大陆这边, 以'学习为目的'几乎可以随便弄而不用承担法律风险, 在例如美国之类的国家针对模拟器还是有相当的限制的.

例如PS2的模拟器PCSX2, 要求使用该模拟器时, 使用自己dump自己PS2的BIOS文件. 这一点完全是规避法律风险.

FDS同理, FDS模拟中有一些'BIOS call', 需要模拟BIOS. 这一点还是建议像其他的模拟器一样, 要求用户自己dump自己FDS的BIOS. (用户的BIOS怎么来的我们不用管)

扩展输入

NES/FC还是有很多的扩展输入的, 之前我们实现了基础的标准输出, 绝大部分游戏可以模拟了. 还有一些可以实现的输入方式, 这里简单列举一下:

更精确地模拟

精确模拟肯定是每个模拟器的目标, 这个被自己称为'中精度模拟器'的项目, 自己的打算是在此精度上尽可能地更为精确地模拟.

还有最高的基于'CPU周期'的精度, 这样精度是最高的, 可以完全按照周期进行硬件的可视化.

编辑工具

或者称为'汉化工具'就知道其具体用途了, 当然不一定是'语言本地化', 还可以进行简单的ROM-HACK.

比如所谓的'玛丽十代', 就是'成龙之龙'换了一个马里奥的'皮肤':

hack

(在网上看解说, 一群人提到这个'玛丽十代'. 不过自己小时候玩的是'原版', 感谢良心的盗版商)

这里就是用fceux的类似工具截图:

fceux

DMC IRQ

核心部分BUG先不谈, DMC的IRQ还没有实现. 其实即便是事件驱动也是可以实现了, 针对CPU周期进行计算即可:

dmc_irq_cycle += cycle;
if ((dmc_irq_cycle & 0x7fffffff) >= dmc_irq_counter){
    dmc_irq_cycle -= dmc_irq_counter;
    // xxxxxxxxxx
}

dmc_irq_counter不用IRQ是就是0xffffffff. 但是因为这个样和其他音频事件格格不入就没有这么实现.

2POW BANK

之前切换BANK时, 为了防止溢出是这么操作的:

load_bank(bank_id % bank_counter);

自己想了一下, 可以采用向上取整到最近的2次幂对齐数字, 然后减1作为掩码:

load_bank(bank_id & bank_mask);

虽然看似把MOD换成了AND, 实际上bank_counter可能是0, 每次都要判断是不是0也烦.

作弊器/金手指

这就是作弊了, 简单地说就是修改某地址的数据. '锁定'数据可以实现为高频率(比如就利用帧序列器的240Hz)写入同一个数据.

游戏特化

不知道大家有没有玩过'魂斗罗力量'(背景是讲雇佣兵的, force也可以译作'军队')没有:

force

这游戏的一大特点就是卡顿明显, 我们可以针对不同的游戏进行'特化'. 比如发现用户在游玩这款游戏时, 可以以非强制性地提醒用户'是否修复魂斗罗力量的卡顿问题'.

如何判断可以利用CRC校验, 如何修复简单地说就是'超频'. 具体在哪里超频可以具体玩游戏时进行检查(避免等待垂直空白时超频), 超频会带来相当多的副作用, 不过目前的实现是'中精度模拟器', 反而超频没有多大的影响(基于CPU的IRQ的Mapper其实还是基于扫描线实现的).

平台优化

之前提到自己用SSE重写了渲染逻辑, 这就是'平台优化'. 虽然C是跨平台的, 但是优化还是根据具体平台具体实现.

图形用户接口/GUI

这是自然的, 用户是一般用户, 自然是需要实现一个图形接口. 之后, 自己会使用自己的LongUI实现图形化界面. 可能会将步骤记录下来作为博客, 不过重点将是GUI程序的编写.

NES程序调试

现在依然有不少人会写一些简单的NES程序, 作为模拟器自然可以提供一个调试环境. 即便是没人用(╮( ̄▽ ̄)╭ ), 这样能够全方位地提高自己的技术水平

网络

与人一起玩自然是很不错的, FC本身就支持本地的多人游戏. 但是现在能凑在一起玩是很困难的, 于是这部分可以使用网络联机实现.

不过自己几乎没有任何网络联机程序的经验(只有学校里面的简单测试), 只好作罢.

更多的Mapper

这也是后期主要维护的内容, 更多的Mapper能够游玩更多的游戏, 并且如果遇到类似MMC5这样'黑科技'的芯片也能感叹: 还能这样玩?!

重启-全部成为F

FC上面有一个'重启键', 所以FC的重启分为软重启(按下重启键)和硬重启(插上电源). 还有一些, 比如在'网球'里面玩一会之后, 接着玩'马里奥'可能会出现错误关卡的BUG. 因为网球的某数据和马里奥的关卡数据使用的是同一个地址.

可以在CPU power up state, PPU power up state分别查看 CPU/PPU 上电状态.

本节标题'全部成为F'自然也是有原因的. 程序中一开始会将系统自带的RAM清零, 但是不少模拟器会使用$FF清空, 也就是全部成为'F'. FC里面按下'重启'键的目的往往是为了安全关闭游戏, 既是结束, 也是开始.

全文完

Re: 从零开始的红白机模拟 - [20]Mapper 004

STEP⑨: 实现部分Mapper

接下来进入本次的主角 Mapper004:

Mapper004: MMC3 - TxROM

MMC3 可谓是最开始几个Mapper中相当占分量的Mapper. MMC3是少数可以触发IRQ的Mapper之一.

MMC6也是用同一个mapper编号, 内部逻辑大致相同.

根据数据库,MMC3(在自己看来)比较有名的游戏, 比如:

MMC3的IRQ最大的用处, 就是处理的分割滚动效果(状态栏在下面)

IRQ 与 NMI区别

  • NESDEV-IRQ
  • IRQ需要在I标记为0(不要禁止中断)才能触发
  • 当然, IRQ NMI触发时都会自动标记I
  • IRQ一般输入为1, 表示不需要IRQ
  • 任何设备能使它为0, 表示不停地告诉CPU需要IRQ
  • 确认(acknowledge)IRQ能使它(输入)回到1(一般来说就是不需要IRQ)
  • IRQ处理程序必须确认IRQ, 否则会在返回主线代码时再次触发IRQ
  • 还有一些细节比如需要在CLI后再执行一个指令才会真正进入IRQ
  • 如果那一个指令恰好是RTI, 则balabala
  • 细节请参考测试ROM: cpu_interrupts_v2
  • test
  • 目前实现到第一个测试的测试12: "RTI RTI不允许执行主线代码" ... 我™... 这些测试ROM就是搞事
  • 本步骤自带的ROM就是这个"cpu_interrupts.nes"了

Banks

  • CPU $6000-$7FFF: 8 KB PRG RAM bank
  • CPU $8000-$9FFF (or $C000-$DFFF): 8 KB switchable PRG ROM bank
  • CPU $A000-$BFFF: 8 KB switchable PRG ROM bank
  • CPU $C000-$DFFF (or $8000-$9FFF): 8 KB PRG ROM bank, fixed to the second-last bank
  • CPU $E000-$FFFF: 8 KB PRG ROM bank, fixed to the last bank
  • PPU $0000-$07FF (or $1000-$17FF): 2 KB switchable CHR bank
  • PPU $0800-$0FFF (or $1800-$1FFF): 2 KB switchable CHR bank
  • PPU $1000-$13FF (or $0000-$03FF): 1 KB switchable CHR bank
  • PPU $1400-$17FF (or $0400-$07FF): 1 KB switchable CHR bank
  • PPU $1800-$1BFF (or $0800-$0BFF): 1 KB switchable CHR bank
  • PPU $1C00-$1FFF (or $0C00-$0FFF): 1 KB switchable CHR bank
  • PRG切换的窗口是8KB
  • CHR切换的窗口是1KB
  • 是目前切换的最小窗口了, 如果还有更小的就需要重写了

寄存器

MMC3根据地址拥有4对寄存器

Bank select ($8000-$9FFE, 偶数)

7  bit  0
---- ----
CPMx xRRR
|||   |||
|||   +++- Specify which bank register to update on next write to Bank Data register
|||        0: Select 2 KB CHR bank at PPU $0000-$07FF (or $1000-$17FF);
|||        1: Select 2 KB CHR bank at PPU $0800-$0FFF (or $1800-$1FFF);
|||        2: Select 1 KB CHR bank at PPU $1000-$13FF (or $0000-$03FF);
|||        3: Select 1 KB CHR bank at PPU $1400-$17FF (or $0400-$07FF);
|||        4: Select 1 KB CHR bank at PPU $1800-$1BFF (or $0800-$0BFF);
|||        5: Select 1 KB CHR bank at PPU $1C00-$1FFF (or $0C00-$0FFF);
|||        6: Select 8 KB PRG ROM bank at $8000-$9FFF (or $C000-$DFFF);
|||        7: Select 8 KB PRG ROM bank at $A000-$BFFF
||+------- Nothing on the MMC3, see MMC6
|+-------- PRG ROM bank mode (0: $8000-$9FFF swappable,
|                                $C000-$DFFF fixed to second-last bank;
|                             1: $C000-$DFFF swappable,
|                                $8000-$9FFF fixed to second-last bank)
+--------- CHR A12 inversion (0: two 2 KB banks at $0000-$0FFF,
                                 four 1 KB banks at $1000-$1FFF;
                              1: two 2 KB banks at $1000-$1FFF,
                                 four 1 KB banks at $0000-$0FFF)

MMC6 的M位是指是否使用PRG-RAM.

nesdev给出了详细的情况列表, 可以自行查看, 编码时直接抄就行了

Bank data ($8001-$9FFF, 奇数)

7  bit  0
---- ----
DDDD DDDD
|||| ||||
++++-++++- New bank value, based on last value written to Bank select register (mentioned above)

Mirroring ($A000-$BFFE, 偶数)

7  bit  0
---- ----
xxxx xxxM
        |
        +- Nametable mirroring (0: vertical; 1: horizontal)

这个就很简单了

PRG RAM protect ($A001-$BFFF, 奇数)

7  bit  0
---- ----
RWXX xxxx
||||
||++------ Nothing on the MMC3, see MMC6
|+-------- Write protection (0: allow writes; 1: deny writes)
+--------- PRG RAM chip enable (0: disable; 1: enable)

PRG RAM保护..感觉没必要实现

IRQ latch ($C000-$DFFE, 偶数)

IRQ了, 很重要的一环

7  bit  0
---- ----
DDDD DDDD
|||| ||||
++++-++++- IRQ latch value

IRQ闩锁, 用于指定一个重载值. 当计数器归零后, 计数器会重载这个值. 这个值会在每根扫描减少, 降至0就触发IRQ.

由于内部的原理是通过PPU巴拉巴拉, 所以要在背景精灵启动渲染才会触发倒计时, 否则运行超级马里奥3会程序会挂掉.

IRQ reload ($C001-$DFFF, 奇数)

IRQ重载, 写入任意值会让计数器归0

IRQ disable ($E000-$FFFE, 偶数)

IRQ禁止, 写入任意值标记禁止并且确认挂起的中断

IRQ enable ($E001-$FFFF, 奇数)

IRQ使能, 写入任意值标记使能

Mapper接口: 水平同步

这次功能就需要新的一个接口, 需要在每条扫描行进行一次同步, 我们就把它叫水平同步好了

// 水平同步
void(*hsync)(sfc_famicom_t*);

由于目前的是EZ模式, 我们在每次可见扫描线结束后进行一次同步, 即水平同步. 计数器减到0就触发一次IRQ.

关于IRQ触发更为详细的细节请查看原文.

实现

本次的实现比较重要, 也明显比前面几个代码量更大.

STEP9-MAPPER004.c

结束?

本次先介绍的4种mapper就介绍完毕

项目地址Github-StepFC-Step9

作业

  • 基础: 文中提到了4个Mapper, 重新实现吧
  • 扩展: 试试实现其他Mapper!
  • 从零开始: 从零开始实现自己的模拟器吧

双截龙2: 复仇 模拟出现的问题

  • 这次CHR窗口的单位是1KB, 之前是用4KB的逻辑, 导致背景/精灵渲染出现问题
  • dd2
  • 第一次出现的可不是你啊, 是尖头发的
  • 这样自己发现了: 同屏只能有一种敌人. 小时候居然没有注意到
  • 由于NTSC是隐藏上下8条扫描线的, 很多游戏利用这一点用来更新图块

重装机兵模拟出现的问题

  • MM会在显示对话框的时候触发IRQ切换CHR-ROM BANK
  • 因为目前精灵显示并不是和同步的(方便以后用着色器渲染)
  • 导致渲染时用的是切换后的
  • 解决方案: 延迟精灵渲染到所有工作结束后(即开始新的一帧, MM会在VBlank时把BANK换回来)
  • 这种方案只能应急用, 这样又会导致菜单里面的东西出现问题:
  • mm1
  • 手指和菜单选择项渲染
  • 这种情况可以写专门对付的代码, 触发IRQ记录BANK
  • 或者精灵和背景同步渲染
  • 游戏愉快!

REF

Re: 从零开始的红白机模拟 - [07]流程指令

STEP3: CPU 指令实现 - 流程指令

同样, '流程指令'是指为了和上节分开而自己随便取的名字.

JMP - Jump

寻址模式 汇编格式 OP代码 指令字节 指令周期
绝对 JMP Oper 4C 3 3
间接 JMP (Oper) 6C 3 5

无条件跳转, 影响FLAG: (无), 伪C代码:

PC = address;

BEQ - Branch if Equal

寻址模式 汇编格式 OP代码 指令字节 指令周期
相对 BEQ Oper F0 2 2*

* +1s 跳转同一页面
* +2s 跳转不同页面

当然, 如果没有实行跳转则花费2周期, 下同.

如果标志位Z(ero) = 1[即相同]则跳转,否则继续, 影响FLAG: (无). 伪C代码:

if (ZFLAG) PC = address;

BNE - Branch if Not Equal

寻址模式 汇编格式 OP代码 指令字节 指令周期
相对 BNE Oper D0 2 2*

* +1s 跳转同一页面
* +2s 跳转不同页面

如果标志位Z(ero) = 0[即不相同]则跳转,否则继续, 影响FLAG: (无). 伪C代码:

if (!ZFLAG) PC = address;

BCS - Branch if Carry Set

寻址模式 汇编格式 OP代码 指令字节 指令周期
相对 BCS Oper B0 2 2*

* +1s 跳转同一页面
* +2s 跳转不同页面

如果标志位C(arry) = 1[即进位了]则跳转,否则继续, 影响FLAG: (无). 伪C代码:

if (CFLAG) PC = address;

BCC - Branch if Carry Clear

寻址模式 汇编格式 OP代码 指令字节 指令周期
相对 BCC Oper 90 2 2*

* +1s 跳转同一页面
* +2s 跳转不同页面

如果标志位C(arry) = 0[即没进位]则跳转,否则继续, 影响FLAG: (无). 伪C代码:

if (!CFLAG) PC = address;

BMI - Branch if Minus

寻址模式 汇编格式 OP代码 指令字节 指令周期
相对 BMI Oper 30 2 2*

* +1s 跳转同一页面
* +2s 跳转不同页面

如果标志位S(ign) = 1[即负数]则跳转,否则继续, 影响FLAG: (无). 伪C代码:

if (SFLAG) PC = address;

BPL - Branch if Plus

寻址模式 汇编格式 OP代码 指令字节 指令周期
相对 BPL Oper 10 2 2*

* +1s 跳转同一页面
* +2s 跳转不同页面

如果标志位S(ign) = 1[即正数]则跳转,否则继续, 影响FLAG: (无). 伪C代码:

if (!SFLAG) PC = address;

BVS - Branch if Overflow Set

寻址模式 汇编格式 OP代码 指令字节 指令周期
相对 BVS Oper 70 2 2*

* +1s 跳转同一页面
* +2s 跳转不同页面

如果标志位(o)V(erflow) = 1[即溢出]则跳转,否则继续, 影响FLAG: (无). 伪C代码:

if (VFLAG) PC = address;

BVC - Branch if Overflow Clear

寻址模式 汇编格式 OP代码 指令字节 指令周期
相对 BVC Oper 50 2 2*

* +1s 跳转同一页面
* +2s 跳转不同页面

如果标志位(o)V(erflow) = 0[即没有溢出]则跳转,否则继续, 影响FLAG: (无). 伪C代码:

if (!VFLAG) PC = address;

JSR - Jump to Subroutine

寻址模式 汇编格式 OP代码 指令字节 指令周期
绝对 JSR Oper 20 3 6

跳转至子程序, 记录该条指令最后的地址(即当前PC-1, 或者说JSR代码$20所在地址+2), 影响FLAG: (无), 伪C代码:

--PC;
PUSH(PC >> 8);
PUSH(PC & 0xFF);
PC = address;

RTS - Return from Subroutine

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 RTS 60 1 6

JSR逆操作, 从子程序返回. 返回之前记录的位置+1(话说为什么不直接存+1的地址), 影响FLAG: (无), 伪C代码:

PC = POP();
PC |= POP() << 8;
++PC;

NOP - No Operation

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 NOP EA 1 2

啥都不干, 居然两个周期, 太丢NOP的脸了, 褪裙吧.

BRK - Force Break(Interrupt)

助记符号: PUSH (PC+1); PUSH (P); I = 1; PC = IRQ;

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 BRK 00 1 7
详细指令周期:
  1. 读取OP代码, PC+1
  2. 读取下一字节指令, 无视, PC+1
  3. 压入 PC-H, SP-1
  4. 压入 PC-L, SP-1
  5. 压入 P, SP-1
  6. 读取IRQ+0至PC-L
  7. 读取IRQ+1至PC-H

强制中断, 记录当前PC+1作为返回地址, 以及PS. 跳转到IRQ地址

由于大部分游戏都没有使用该指令, 所以有些模拟器的实现可能有些问题.

BRK虽然是单字节指令, 但是会让PC + 2, 所以干脆认为是双字节指令也不错.

影响FLAG: I(nterrupt), 伪C代码:

++PC;
PUSH(PC>>8);
PUSH(PC & 0xFF);
PUSH(P | FLAG_R | FLAG_B);
IF = 1;
PC = READ(IRQ);
PC |= READ(IRQ + 1) << 8;

RTI - Return from Interrupt

寻址模式 汇编格式 OP代码 指令字节 指令周期
隐含 RTI 4D 1 6

从中断返回, 影响FLAG: 是的, 伪C代码:

P = POP();
// 无视BIT4 BIT5
RF = 1;
BF = 0;

PC = POP();
PC |= POP() << 8;

REF

Re: 从零开始的红白机模拟 - [34] N163 呐喊

南梦宫163

不过说到男猛攻南梦宫, 南梦宫在05年和万代合并了, 发行的游戏感觉上比较偏向于宅系. 为N163分配的Mapper编号是019, 同一个编号的还有N129. 但是存在很多的submapper的情况——这个情况还是到wiki查看吧. 使用了了N163扩展音源的游戏有10个. 例如:

总览来看, 值得注意的是:

  • CHR window 1Kx8 (PT) + 1Kx4 (NT)
  • 这意味着支持使用ROM代替名称表, 好在上一节统一支持了这一特性
  • These chips contain 128 bytes of internal RAM that can be used either for expansion audio or, together with a battery, for 128 bytes of save RAM.
  • 可能有些游戏没有8kb的SRAM, 但是用电池支持这内部128字节的RAM
  • 也就是如果没有扩展音源可以用这128字节保存游戏进度(?)

目前的存档信息:

enum {
    // 需要储存进度SRAM-8KiB
    SFC_ROMINFO_SRAM_HasSRAM  = 0x01,
    // 该位为真的话, 储存的不是SRAM-8KiB, 而是扩展区的32KiB
    SFC_ROMINFO_SRAM_More8KiB = 0x02,
    // 该位为真的话, 储存SRAM-8KiB外, 还要储存扩展区的偏移8kiB后128字节
    SFC_ROMINFO_SRAM_M128_Of8 = 0x04,
};

所以又更新了保存接口, 暴力读写:

/// <summary>
/// 数据
/// </summary>
typedef struct {
    // 地址
    void*       address;
    // 长度
    uintptr_t   length;
} sfc_data_set_t;


// 保存SRAM
void(*save_sram)(void*, const sfc_rom_info_t*, const sfc_data_set_t*, uint32_t);
// 读取SRAM
void(*load_sram)(void*, const sfc_rom_info_t*, const sfc_data_set_t*, uint32_t);

Banks

  • CPU $6000-$7FFF: 8 KB PRG RAM bank, if WRAM is present
  • CPU $8000-$9FFF: 8 KB switchable PRG ROM bank
  • CPU $A000-$BFFF: 8 KB switchable PRG ROM bank
  • CPU $C000-$DFFF: 8 KB switchable PRG ROM bank
  • CPU $E000-$FFFF: 8 KB PRG ROM bank, fixed to the last bank
  • PPU $0000-$03FF: 1 KB switchable CHR bank
  • PPU $0400-$07FF: 1 KB switchable CHR bank
  • PPU $0800-$0BFF: 1 KB switchable CHR bank
  • PPU $0C00-$0FFF: 1 KB switchable CHR bank
  • PPU $1000-$13FF: 1 KB switchable CHR bank
  • PPU $1400-$17FF: 1 KB switchable CHR bank
  • PPU $1800-$1BFF: 1 KB switchable CHR bank
  • PPU $1C00-$1FFF: 1 KB switchable CHR bank
  • PPU $2000-$23FF: 1 KB switchable CHR bank
  • PPU $2400-$27FF: 1 KB switchable CHR bank
  • PPU $2800-$2BFF: 1 KB switchable CHR bank
  • PPU $2C00-$2FFF: 1 KB switchable CHR bank

CHR and NT Select ($8000-$DFFF) w

写入数据:

  • ①-$00-$DF: 选择1kb的CHR-ROM
  • ②-$E0-$FF: 根据$E800的情况, 决定是否选择内部VRAM(偶数A面, 奇数B面)
  • ②-如果没有选择则按照①的情况进行

写入地址:

  • $8000-$87FF: BANK-0, ②-[$E800.6 = 0]
  • $8800-$8FFF: BANK-1, ②-[$E800.6 = 0]
  • $9000-$97FF: BANK-2 ②-[$E800.6 = 0]
  • $9800-$9FFF: BANK-3 ②-[$E800.6 = 0]
  • $A000-$A7FF: BANK-4 ②-[$E800.7 = 0]
  • $A800-$AFFF: BANK-5 ②-[$E800.7 = 0]
  • $B000-$B7FF: BANK-6 ②-[$E800.7 = 0]
  • $B800-$BFFF: BANK-7 ②-[$E800.7 = 0]
  • $C000-$C7FF: BANK-8 ②-一直允许
  • $C800-$CFFF: BANK-9 ②-一直允许
  • $D000-$D7FF: BANK-a ②-一直允许
  • $D800-$DFFF: BANK-b ②-一直允许

令人吃惊的是允许将内置2kb的VRAM(CIRAM)接入图样表作为CHR-RAM.

PRG Select 1 ($E000-$E7FF) w

7  bit  0
---- ----
.MPP PPPP
 ||| ||||
 |++-++++- Select 8KB page of PRG-ROM at $8000
 +-------- Disable sound if set

64*8=512, D6是禁止位

PRG Select 2 / CHR-RAM Enable ($E800-$EFFF) w

7  bit  0
---- ----
HLPP PPPP
|||| ||||
||++-++++- Select 8KB page of PRG-ROM at $A000
|+-------- Disable CHR-RAM at $0000-$0FFF
|            0: Pages $E0-$FF use NT RAM as CHR-RAM
|            1: Pages $E0-$FF are the last $20 banks of CHR-ROM
+--------- Disable CHR-RAM at $1000-$1FFF
             0: Pages $E0-$FF use NT RAM as CHR-RAM
             1: Pages $E0-$FF are the last $20 banks of CHR-ROM

D6D7位很强

PRG Select 3 ($F000-$F7FF) w

7  bit  0
---- ----
..PP PPPP
  || ||||
  ++-++++- Select 8KB page of PRG-ROM at $C000

没什么好说的.

Write Protect for External RAM AND Chip RAM Address Port ($F800-$FFFF) w

7  bit  0
---- ----
KKKK DCBA
|||| ||||
|||| |||+- 1: Write-protect 2kB window of external RAM from $6000-$67FF (0: write enable)
|||| ||+-- 1: Write-protect 2kB window of external RAM from $6800-$6FFF (0: write enable)
|||| |+--- 1: Write-protect 2kB window of external RAM from $7000-$77FF (0: write enable)
|||| +---- 1: Write-protect 2kB window of external RAM from $7800-$7FFF (0: write enable)
++++------ Additionally the upper nybble must be equal to b0100 to enable writes

这个写入保护有点烦人, 或许到现在自己还没有实现或许还是一个好主意(为懒人正言).

IRQ Counter (low) ($5000-$57FF) r/w

7  bit  0
---- ----
IIII IIII
|||| ||||
++++-++++- Low 8 bits of IRQ counter

IRQ Counter (high) / IRQ Enable ($5800-$5FFF) r/w

7  bit  0
---- ----
EIII IIII
|||| ||||
|+++-++++- High 7 bits of IRQ counter
+--------- IRQ Enable: (0: disabled; 1: enabled)

IRQ是一个15bit的计数器, 读取$5000和$5800能够读取计数器的当前值, 可以实时读取.

IRQ这个计数器在每个CPU周期都会递增, 达到$7fff时就触发IRQ, 并停止计数. 写入这两个寄存器会确认IRQ.

一帧CPU周期大约是3万所以15bit还是足够了的.

N163音源

N163音源(如果有的话)使用的就是内部的128字节数据.

Address Port ($F800-$FFFF)

这个地址还有另一个用处: 为之后写入内部RAM的数据指定地址.

7  bit  0   (write only)
---- ----
IAAA AAAA
|||| ||||
|+++-++++- Address
+--------- Auto-increment

D7:I 为真的话, 写入或者读取'Data Port'地址会递增. 由于是在'Audio'里面的, 所以将储存在N163音源端, 而不是Mapper端.

Data Port ($4800-$4FFF)

为内部RAM写入或者读取数据, 并且根据$F800:D7是否自动+1.

Sound RAM $78 - Low Frequency

7  bit  0
---------
FFFF FFFF
|||| ||||
++++-++++- Low 8 bits of Frequency

Sound RAM $79 - Low Phase

7  bit  0
---------
PPPP PPPP
|||| ||||
++++-++++- Low 8 bits of Phase

Sound RAM $7A - Mid Frequency

7  bit  0
---------
FFFF FFFF
|||| ||||
++++-++++- Middle 8 bits of Frequency

吃惊, 别的芯片频率设置用11bit, 12bit, 这用16bit还不够.

Sound RAM $7B - Mid Phase

7  bit  0
---------
PPPP PPPP
|||| ||||
++++-++++- Middle 8 bits of Phase

Sound RAM $7C - High Frequency and Wave Length

7  bit  0
---------
LLLL LLFF
|||| ||||
|||| ||++- High 2 bits of Frequency
++++-++--- Length of waveform ((64-L)*4 4-bit samples)

18bit的频率与6bit的长度. 长度的单位是: 样本, 可以计算为:

length = 256 - (value & 0xFC);

Sound RAM $7D - High Phase

7  bit  0
---------
PPPP PPPP
|||| ||||
++++-++++- High 8 bits of Phase

24bit的相位数据决定了当前波的相位. 每次声道更新时, 18bit的频率数据就会添加到24bit的相位数据上.

写入这三个相位寄存器会立即重置相位. 对于开发者来说最好先将频率置0, 不然写入3个寄存器之间存在时间差(虽然很短就是了), 可能中途会更新(不禁想起了线程安全).

Sound RAM $7E - Wave Address

7  bit  0
---------
AAAA AAAA
|||| ||||
++++-++++- Address of waveform (in 4-bit samples)

Sound RAM $7F - Volume

7  bit  0
---------
.CCC VVVV
 ||| ||||
 ||| ++++- Linear Volume
 +++------ Enabled Channels (1+C)

'C'位只有$7F是有效的, 其他的对应地址是无效的.

  • 0: Ch7有效
  • 1: Ch76有效
  • ...
  • 7: Ch76543210都有效

其他声道

  • Channel 7: $78-$7F
  • Channel 6: $70-$77
  • Channel 5: $68-$6F
  • Channel 4: $60-$67
  • Channel 3: $58-$5F
  • Channel 2: $50-$57
  • Channel 1: $48-$4F
  • Channel 0: $40-$47
  • 可以考虑把编号掉一下头, 这样从零开始

波形信息

波形信息以一个样本深度为4bit的规则储存的, 一个字节可以保存2个样本, 小端形式保存:

/// <summary>
/// StepFC: N163 采样
/// </summary>
/// <param name="famicom">The famicom.</param>
/// <param name="addr">The addr.</param>
/// <returns></returns>
static inline int8_t sfc_n163_sample(sfc_famicom_t* famicom, uint8_t addr) {
    const uint8_t data = sfc_n163_internal_chip(famicom)[addr >> 1];
    return (data >> ((addr & 1) << 2)) & 0xf;
}

'Sound RAM $7E - Wave Address'储存的样本的地址, 本身内部RAM是128字节. 换句话说: 在不考虑重叠的情况下, 如果使用2个N163声道, 每个声道能用56个字节, 差不多100个样本. 而使用8个N163声道, 就只能独立使用8个字节了(当然这种情况, 只能互相重叠或者重新利用).

'Sound RAM $7C - High Frequency and Wave Length'储存了波表的长度, 单位是样本. 不过由于4个样本对齐, 所以直接除以2就是字节长度.

$00:    00 00 00 A8 DC EE FF FF EF DE AC 58 23 11 00 00
$10:    10 21 53 00 00 00 00 00 00 00 00 00 00 00 00 00

Wave Address = $06, 表示从RAM:$03低地址开始

Wave Length = $38(变成高6bit的话就是$E0)
表示长度是32样本


F -       *****
E -     **     **
D -    *         *
C -   *           *
B -
A -  *             *
9 - 
8 - *               *
7 - 
6 -
5 -                  *             *
4 -
3 -                   *           *
2 -                    *         *
1 -                     **     **
0 -                       *****

更新

与其他扩展音源不同的是, 8个声道不在一起更新, 而是一个一个更新的. 每更新一个声道需要15个CPU周期:

声道数量 更新频率
1 119318 Hz
2 59659 Hz
3 39773 Hz
4 29830 Hz
5 23864 Hz
6 19886 Hz
7 17045 Hz
8 14915 Hz

可以看出如果只是用两个的话效果还行(>44100Hz), 使用8个的话反而会差一点, 噪音会有点重.

具体过程, wiki也是直接贴出代码:

* w[$80] = the 163's internal memory
* sample(x) = (w[x/2] >> ((x&1)*4)) & $0F
* phase = (w[$7D] << 16) + (w[$7B] << 8) + w[$79]
* freq = ((w[$7C] & $03) << 16) + (w[$7A] << 8) + w[$78]
* length = 256 - (w[$7C] & $FC)
* offset = w[$7E]
* volume = w[$7F] & $0F

phase = (phase + freq) % (length << 16)
output = (sample(((phase >> 16) + offset) & $FF) - 8) * volume
  • phase信息会写回去.
  • output会减去8也就是说, 是有符号的.
  • 所以sfc_n163_sample函数返回的int8_t.

频率计算

f = wave frequency
l = wave length
c = number of channels
p = 18-bit frequency value
n = CPU clock rate (≈1789773 Hz)

f = (n * p) / (15 * 65536 * l * c)

合并输出

与VRC7不同的是, N163如果只有一个声道, 这个声道会以近120kHz更新. 有8个, 这个声道就以15kHz更新. 所以理论上一个声道的最大声音, 其实和8个声道一起放的(最大声音)是一样大的.

精确的高频模拟比较困难, wiki提到可以简单将数据加在一起再除以当前声道数量. 不过不同游戏可能音量有差异, 详细的可以查看wiki. 这里列出submapper:

  • 3: N163比2A03方波大了大约11.0-13.0 dB
  • 4: N163比2A03方波大了大约16.0-17.5 dB
  • 5: N163比2A03方波大了大约18.0-19.5 dB
  • 一般来说, 可以简单看作12dB-4倍, 18dB-8倍

但是声道数量在6以上时, 这个时候几乎已经达到了人类可听的频率了. 其中就算放出01方波, 频率会再除以2以至于完全可闻.

这里自己就用'3声道模式'. 这里说3声道模式, 并不是指仅仅模拟3声道, 而是每次将最后处理的3个声道(接近40kHz)进行输出, 当然, 这时候采样输出不能低于40kHz(所以作为参数放入N163, 假设支持22kHz就用'6声道模式').

  • 声道模式为n
  • 总倍数为N
  • 单声道输出8bit有符号, 大约是方波的16倍
  • 输出Sum(hist, n) / n / 16 * N
  • 适当增加n可以视为一个滤波器, n越高截止频率越低
  • 当然, 继续使用之前的context+per_sample模式
// N163
if (g_famicom->rom_info.extra_sound & SFC_NSF_EX_N163) {
    // 3声道模式
    const float out = sfc_n163_per_sample(g_famicom, &n163_ctx, cpu_cycle_per_sample, 3);
    output += out * (0.00752f / 16.f) * n163_ctx.subweight;
}

N163

N163看上去有8个声道简直逆天, 但是实际上拥有很大的限制:

  • 公用一个波形表地址空间
  • 如果声道少的话, 就可以用很长的波表了
  • 相反, 声道多就只能用短一点了
  • 每15CPU周期仅仅更新一个声道, 如果声道多了杂音会很重.
  • 上面这一点, 我们可以考虑进行声部强化
  • 总之, N163的特点就是用的声道越多, 限制就越大
  • 不过目前的游戏: 8个使用了4声道, 2个使用了8声道

编写中遇到的问题

N163的编写感觉一帆风顺, 除了一两个丢人的小问题, 比如Mapper编号19的16进制不小心弄成'15'.

模拟女神转生2出现的问题

都说了'一帆风顺'. 不过一开始....

01

一开始就花屏了, 由于只打开了N163声道, 还以为是IRQ触发有问题, 后来发现本来就是这样.

精度问题

进入游戏后, 人物上下移动瞬间, 背景错位(仅仅一帧). wiki提到修改是下一帧起效, 所以就把

void sfc_ppu_do_end_of_vblank(sfc_ppu_t* ppu) {
    // v: .....F.. ...EDCBA = t: .....F.. ...EDCBA
    ppu->data.v = (ppu->data.v & (uint16_t)0x841F) | (ppu->data.t & (uint16_t)0x7BE0);
}

这段调用放在最后. (具体是精度还是本来就是这样, 等待验证)

REF

附录: FamiTracker对N163的支持

FamiTracker可以粗暴地认为是NSF的创作软件.

FamiTracker allows waveforms between 4 and 32 samples long (in increments of four), though recent discoveries have shown the actual chip is capable of reading the entire channel/waveform memory space, for a total of 256 samples per wave.

应该是实现难度的问题, N163本身可以支持到256个样本(当然就包含声道7的寄存器数据了), 但是FT只支持4~32个样本. 手动的话, 自由度很大, 不仅能使用256个, 通道之间还能交叉使用部分.

*     *     ****
  * *  *****    
   *            *

[  CH0 ]
    [    CH1    ]
        [  CH2  ]

Re: 从零开始的红白机模拟 - [14]中精度同步

中精度同步

之前用的是非常暴力(?)的同步方法, 同步精度大致在帧, 自己称之为"低精度同步". 现在就要使用更加精确的同步了, 同步精度大致在扫描行, 自己称为"中精度同步". 还有最高的基于各个部件周期, 精度大致为次像素, 称为"高精度同步". 不过高精度同步会消耗较大的计算资源, 不在本博客范围内, 有兴趣的读者可以自行实现.

CPU周期

之前提到了每个指令需要消耗多少多少周期, 现在就需要用了. 固定周期的直接加就行:

// 指令实现
#define OP(n, a, o) \
case 0x##n:\
{           \
    cycle_add += (uint32_t)SFC_BAISC_CYCLE_##n;\
    // 后略
}

至于浮动的, 条件转移:

/// <summary>
/// StepFC: 执行分支跳转
/// </summary>
/// <param name="address">The address.</param>
/// <param name="famicom">The famicom.</param>
/// <param name="cycle">The cycle.</param>
static inline void sfc_branch(uint16_t address, sfc_famicom_t* famicom, uint32_t* const cycle) {
    const uint16_t saved = SFC_PC;
    SFC_PC = address;
    ++(*cycle);
    *cycle += (address ^ saved) >> 8 & 1;
}

让条件转移语句判断后调用即可.

对于像"绝对X变址", "绝对Y变址", "间接Y变址"这三个有些没有额外的一周期这里用大小写区分:

iny

(绝对X/Y变址也是同样的)

截图中可以看到有SFC_READ_PCSFC_READ, 为什么这么区分, 原因在后面

还有像精灵DMA会消耗相当的周期:

Not counting the OAMDMA write tick, the above procedure takes 513 CPU cycles (+1 on odd CPU cycles): first one (or two) idle cycles, and then 256 pairs of alternating read/write cycles. (For comparison, an unrolled LDA/STA loop would usually take four times as long.)

CHR-RAM

之前提到有些ROM没有CHA-ROM, 只有CHR-ROM, 解决方法很简单:

const size_t size1 = 16 * 1024 * nes_header.count_prgrom16kb;
// 允许没有CHR-ROM(使用CHR-RAM代替)
const size_t size2 = 8 * 1024 * (nes_header.count_chrrom_8kb | 1);
uint8_t* const ptr = (uint8_t*)malloc(size1 + size2);

在CHR-ROM的长度为0时多分配8KB即可, 这里偷懒直接'或'了一下.

主要是因为C没有std::max, 又太懒╮( ̄▽ ̄)╭

不过注意的是这部分是会被程序修改的...反正是malloc的无所谓了.

分割

在实现之前, 先看一下之前使用的一张图片:

mario

之前提到了红框是当前的屏幕偏移量, 那有没有什么奇怪的地方?

就是左上角的分数显示! 因为直接显示这样分数会错位的. 这就是FC程序的小技巧, 屏幕刷新时写入VRAM指针. 大致时机可以分成两部分:

  1. 屏幕刷新
  2. 垂直空白

其中VBlank期间访问VRAM是合法, 而刷新时访问是不合法的. 但是可以利用写入$2006(VRAM指针)这个制作出一些‘滚动’效果(或者$2005, 但是PPU内部实现表明其实是一回事), 而真正的访问VRAM - $2007(VRAM数据) 就太非法了, 模拟难度较高——需要高精度同步.

一般利用这个实现之前提到的滚动方式, 被称为'分割滚动'(split-scrolling)

精灵命中与溢出

有这么两个标志位, 会在渲染中途才会被设置, VBlank结束后会清空. 这两个标志位一般来作为'分割滚动'的动作的转折点.(不过实际上溢出很少有游戏会使用).
所以一些游戏(比如超级马里奥):

  1. 一直检测是不是命中了
  2. 命中了? 将水平滚动恢复正常, 让下面的游戏背景可以滚起来.
  3. VBlank期间再设置水平滚动为0让背景不滚

ss

超马1的那个金币下边就是精灵0, 等到触发#0命中, 然后程序猿掐到大概状态栏显示完了(还有一行), 就恢复滚动.

除了这两个还有通过硬件的IRQ(比如超级马里奥3利用Mapper触发IRQ, 状态栏在下面; 甚至还有的ROM是通过APU的IRQ), 后面再说

精灵0命中

简单地说就是精灵#0, 如果某像素不是透明的, 然后背景也不是全局背景色(即低2位都有效时)会触发, 详细的说明(细节)还是请到引用连接查看. 目前的实现, 很多细节是通过不了全部测试的.

不过一般游戏, 要么就是用来分割屏幕, 要么就不用, 实际上就懒得完全实现.

精灵溢出

简单地说一条扫描线需要渲染超过8个精灵时, 触发溢出. 详细的说明(细节)还是请到引用连接查看. 目前的实现, 很多细节是通过不了全部测试的

不过一般游戏, 基本就不用(就几个游戏在用), 实际上就懒得完全实现.

相关内部寄存器

  • v: 当前VRAM (15位)指针
  • t: 临时VRAM (15位)指针, 可以认为是屏幕左上角的图块
  • x: 水平方向滚动微调 (3位)
  • w: 标记双写寄存器的写入次数 (1位)

VRAM指针($2006)在渲染时可被描述为:

描述
0-4 水平偏移量, 以图块作为单位(8像素)
5-9 垂直偏移量, 以图块作为单位(8像素)
A-B 名称表的索引($2000, $2400, $2800, $2C00)
C-E 额外的(微调)垂直偏移量 像素(0-7)
F 忽略

PPU scrolling中提到的这几个内部寄存器, 应该是PPU本身的内部实现, 但是我们不用太拘束, 只要理解每个位表示什么就行:

  1. (渲染时)$2006的A-B位其实就是对应了$2000(控制)的低2位.
  2. (渲染时)$2006的0-4位对应$2005第一次写入的高5位, x寄存器对应$2005第一次写入的低三位
  3. 垂直偏移量同理
  4. 只要程序猿不搞大新闻, 比如先写一次$2005再写$2006这种操作, 目前的实现还行, 以后可能会向官方内部的实现靠拢
  5. 垂直滚动的修改仅对下帧有效(会在VBlank结束时复制部分位)
  6. (剧透: 后面因为有BUG, 还是按照vtxw实现了, 官方实现太香了)

修改点

  1. 反汇编时按指令长度读取(最开始提出, 终于在这填上了)
  2. 通过剖析, 发现READ操作消耗了近乎三分之一的CPU时间, 现在将读取指令区的操作单独实现了一份(目前是所有的寻址以及读取OpCode, 还有一个是立即寻址的操作, 有点难优化, 需要改一下OP宏的逻辑. 不过效率得到了明显的提升. )
  3. 指令模拟代码换成了强行内联, C99添加的inline效果不够给力, 只好用强行内联(__forceinline/__attribute__((always_inline))) 效率也得到了明显提升
  4. Address should not increment on $2004 read, ROM 测试提出的, 不加就不加咯.
  5. DMA copy should start at value in $2003 and wrap, ROM 测试提出的, DMA复制需要换'行'处理
  6. Palette read should also read "underneath" VRAM into read buffer, ROM 测试提出的, 读取调色板会将处于下方调色板'下方'的VRAM读入伪缓存

同步

我们先实现一个简单的模式来模拟一帧(场), 也就是"中精度同步". 并且基于'同时只能显示25色'的假设(虽然可以在渲染时修改调色板以达到超过25色的可能, 也就是说这种情况是不支持的), 也就是, 和之前的实现差不多.

根据说明, 我们可以这么实现:

  1. 开始前处理精灵溢出, 计算精灵会溢出的行数
  2. 0-239 行 可见扫描线 x240
    • 根据当前偏移渲染该行背景
    • 处理精灵#0的命中测试
    • 检测精灵溢出行
    • 让CPU执行一段时间
    • 240行结束后一次性渲染所有精灵
  3. 240 渲染后 x1
    • 让CPU执行一段时间
  4. 241-260行 垂直空白行 x20
    • 开始后: 设置VBlank标志
    • 开始后: 看情况执行NMI
    • 开始后: 执行CPU执行一段时间
    • 结束后, 清除status所有状态
  5. 261-262 空行 x2 (或者1.5)
    • 执行CPU一段时间

'执行CPU一段时间'是多久呢, 根据文档Clock rate, NTSC的Master Clock是'21.477272 MHz', 除以60, 再除以 262.5大概是1364周期.
CPU频率是Master的12分之一, 113.5. 为了避免小数, 我们用 Master Clock作为基准就行.

现在刷新的频率是和模拟器环境, 也就显示器, 的刷新频率是一致的. 好在自己电脑就是60Hz无所谓, 如果是144Hz显示器的话速度就会很快. 这个由于自己显示器就是60Hz所以一直没在意, 一直没改, 后面再说吧.

合在一起

现在就是把背景和精灵合在一起了, 如果我们把全局背景色理解为清除色的话, 就很简单地理解了:

mg

合成:

out

看起来很简单, 但是花的时间非常多, 大概花了一个星期. 完成倒是很早就完成了, 完成后就是去通过ROM测试, 发现通过不了就再修改, 反反复复, 直到测试通过到一定程度(没有完全通过), 非常花时间.

当然, 实现了精灵的8x16, 两个方向翻转, 但是在处理P精灵时存在小BUG, 这个因为打算后面用顶点去渲染, 有Z坐标的话就很方便了.

细节

有些细节依然没有实现:

  • 什么, NTSC不能显示最上最下8像素啦
  • 什么, $2001除了D3, D4显示背景/精灵的使能位都没有实现啦
  • 什么, 右边边界的精灵由于越界会写入一下行左边啦.
  • 溢出判断仅仅通过了第一个测试, 精力0命中测试仅仅通过了4个测试(但是自己觉得足够用了)
  • 太多了, 需要后期打磨

SSE指令

实现了之后, 自己用SSE处理了背景渲染的部分, 因为背景相对来说是对齐的, 就像数组那样, CPU使用明显降了不少.

不过精灵部分没有用SSE实现, 因为没有对齐. 通过对雪碧拉罐(spritecan)的ROM测试:

spritecans

再使用CPU剖析:
cpu

可以看出雪碧(sprite), 哦不, 精灵(sprite)的渲染占了整个核心的6%(1.28 / 20.37), 换句话说就算精灵渲染优化到0(效率提升无限倍), 也只有6%的核心提升, 全局甚至只有1%. 所以懒得优化了.

是的, 这次的测试ROM是spritecans, 这个ROM使用的是8x16精灵模式.

项目地址Github-StepFC-Step7

F-1 Race

被誉为天才程序猿的岩田聪在FC上开发了一款伪3D赛车游戏 - F-1 Race. 目前模拟效果如下:

f1

可以看出这个游戏是算准了CPU周期然后执行水平偏移实现的伪3D(大神就是能在处理游戏逻辑的同时掐准CPU周期写入偏移量). 也可以看出目前的同步率不够高(基于行, 一旦错过则该行错位), 看看以后能不能解决(比如一旦写入偏移则通知渲染层).

顺带一提这个游戏是分奇数帧和偶数帧的, 处理不当会导致画面闪烁.

岩田聪在奇数帧中更新转速(RPM), 偶数帧中更新距离(DIS)

作业

  • 基础: 使用vtxw寄存器实现相关处理
  • 扩展: 重写EZ模式渲染的所有代码
  • 从零开始: 从零开始实现自己的模拟器吧

REF

Re: 从零开始的红白机模拟 - [02]ROM

NesDev

本系列博客几乎全部资料来源是nesdev.com

根据本人习惯, 项目命名为StepFC, 简写SFC(感觉...和超任重名了). 当然, 读作模拟器, 写作仿真器(emulator).

然后就是很重要的声明: 由于精力和水平, 不能保证文章所述均为正确的

名词解释

本篇可能能遇到下列名词:

  • CPU: **处理器, 即2A03
  • PPU: 图形处理器, 用来控制/显示图形之类的
  • VRAM: 即Video-RAM, 俗称显存
  • PRG-ROM: 程序只读储存器: 存储程序代码的存储器. 放入CPU地址空间.
  • CHR-ROM: 角色只读储存器, 基本是用来显示图像, 放入PPU地址空间
  • VROM: 基本和CHR-ROM同义, 用于理解CHR-ROM
  • SRAM: 存档(SAVE)用RAM, 有些卡带额外带了用电池供电的RAM
  • WRAM: 工作(WORK)用RAM, 基本和SRAM一样, 不过不是用来存档, 就是拿来一般用的
  • Mapper: 由于地址空间最多64KB, 当游戏太大时, 会使用Mapper/MMC用来切换当前使用的'BANK'. 软件(模拟器)上的实现, Mapper会把类似的MMC放在一起实现
  • MMC: Memory-Management Controller 硬件(卡带)上的实现, 会有非常多的大类, 甚至还有变种. 在国内为了显示汉字还有魔改版
  • CHR-RAM: 基本同CHR-ROM, 只不过可以写
  • PRG-RAM: 基本同PRG-ROM, 只不过可以写

STEP0: 读取ROM

既然是从零开始, 那就'零'开始吧. 一开始当然步子不能跨得太大, 不然.....

  • 第零步: 读取简单的ROM.

FC游戏ROM

说到ROM, 目前流行的ROM格式是.nes格式的, 我参考的是叫做NES 2.0的ROM格式:

文件头:
 0-3: string    "NES"<EOF>
   4: byte      以16384(0x4000)字节作为单位的PRG-ROM大小数量
   5: byte      以 8192(0x2000)字节作为单位的CHR-ROM大小数量
   6: bitfield  Flags 6
   7: bitfield  Flags 7
8-15: byte      保留用, 应该为0. 其实有些在用了, 目前不管

CHR-ROM - 角色只读存储器(用于图像显示, 暂且不谈)

Flags 6:
7       0
---------
NNNN FTBM

N: Mapper编号低4位
F: 4屏标志位. (如果该位被设置, 则忽略M标志)
T: Trainer标志位.  1表示 $7000-$71FF加载 Trainer
B: SRAM标志位 $6000-$7FFF拥有电池供电的SRAM.
M: 镜像标志位.  0 = 水平, 1 = 垂直.

Byte 7 (Flags 7):
7       0
---------
NNNN xxPV

N: Mapper编号高4位
P: Playchoice 10标志位. 被设置则表示为PC-10游戏
V: Vs. Unisystem标志位. 被设置则表示为Vs.  游戏
x: 未使用

F: 由于FC的显存只有2kb, 只能支持2屏幕. 如果卡带自带了额外的显存就可以利用4屏幕了.

M: 同上, 这个标记为也暗示游戏是横版还是纵版.
可以看出很多其实不用忙着特地支持, 但是Trainer实现又很简单但是不急着实现的为了避免忘记 —— 打上TODO标记甚至FIXME是一个不错的选择.

ROM在哪里

由于你懂的原因, 本系列博客不会附送商业游戏ROM, 使用的全是爱好者自己写的, 测试用ROM

这里提供一个测试用的ROM, 这个ROM可以从一开始用到很后面.

现在我们利用文件头填写ROM信息吧:

// ROM 信息
typedef struct {
    // PRG-ROM 程序只读储存器 数据指针
    uint8_t*    data_prgrom;
    // CHR-ROM 角色只读存储器 数据指针
    uint8_t*    data_chrrom;
    // 16KB为单位 程序只读储存器 数据长度
    uint32_t    count_prgrom16kb;
    //  8KB为单位 角色只读存储器 数据长度
    uint32_t    count_chrrom_8kb;
    // Mapper 编号
    uint8_t     mapper_number;
    // 是否Vertical Mirroring(否即为水平)
    uint8_t     vmirroring;
    // 是否FourScreen
    uint8_t     four_screen;
    // 是否有SRAM(电池供电的)
    uint8_t     save_ram;
    // 保留以对齐
    uint8_t     reserved[4];
} sfc_rom_info_t;

基础框架

这次说到这次使用C, 是指核心部分用C实现. 核心部分应该实现得尽可能简单, 所以需要使用接口进行扩展.

C++拥有虚函数可以方便地扩展, 这里只有老实地手动写接口, 即使用函数指针, 如果忘记的话请去复习吧.

sfc_ecode: 错误码

/// <summary>
/// StepFC扩展接口
/// </summary>
typedef struct {
    // ROM 加载器读取信息
    sfc_ecode(*load_rom)(void*, sfc_rom_info_t*);
    // ROM 加载器卸载
    sfc_ecode(*free_rom)(void*, sfc_rom_info_t*);

} sfc_interface_t;

即ROM加载步骤为: ROM文件 -> ROM 加载器 -> ROM 信息

载入代码可以这么实现:

/// <summary>
/// 加载默认测试ROM
/// </summary>
/// <param name="arg">The argument.</param>
/// <param name="info">The information.</param>
/// <returns></returns>
sfc_ecode sfc_load_default_rom(void* arg, sfc_rom_info_t* info) {
    assert(info->data_prgrom == NULL && "FREE FIRST");
    FILE* const file = fopen("nestest.nes", "rb");
    // 文本未找到
    if (!file) return SFC_ERROR_FILE_NOT_FOUND;
    sfc_ecode code = SFC_ERROR_ILLEGAL_FILE;
    // 读取文件头
    sfc_nes_header_t nes_header;
    if (fread(&nes_header, sizeof(nes_header), 1, file)) {
        // 开头4字节
        union { uint32_t u32; uint8_t id[4]; } this_union;
        this_union.id[0] = 'N';
        this_union.id[1] = 'E';
        this_union.id[2] = 'S';
        this_union.id[3] = '\x1A';
        // 比较这四字节
        if (this_union.u32 == nes_header.id) {
            const size_t size1 = 16 * 1024 * nes_header.count_prgrom16kb;
            const size_t size2 =  8 * 1024 * nes_header.count_chrrom_8kb;
            uint8_t* const ptr = (uint8_t*)malloc(size1 + size2);
            // 内存申请成功
            if (ptr) {
                code = SFC_ERROR_OK;
                // TODO: 实现Trainer
                // 跳过Trainer数据
                if (nes_header.control1 & SFC_NES_TRAINER) fseek(file, 512, SEEK_CUR);
                // 这都错了就不关我的事情了
                fread(ptr, size1 + size2, 1, file);

                // 填写info数据表格
                info->data_prgrom = ptr;
                info->data_chrrom = ptr + size1;
                info->count_prgrom16kb = nes_header.count_prgrom16kb;
                info->count_chrrom_8kb = nes_header.count_chrrom_8kb;
                info->mapper_number 
                    = (nes_header.control1 >> 4) 
                    | (nes_header.control2 & 0xF0)
                    ;
                info->vmirroring    = (nes_header.control1 & SFC_NES_VMIRROR) > 0;
                info->four_screen   = (nes_header.control1 & SFC_NES_4SCREEN) > 0;
                info->save_ram      = (nes_header.control1 & SFC_NES_SAVERAM) > 0;
                assert(!(nes_header.control1 & SFC_NES_TRAINER) && "unsupported");
                assert(!(nes_header.control2 & SFC_NES_VS_UNISYSTEM) && "unsupported");
                assert(!(nes_header.control2 & SFC_NES_Playchoice10) && "unsupported");
            }
            // 内存不足
            else code = SFC_ERROR_OUT_OF_MEMORY;
        }
        // 非法文件
    }
    fclose(file);
    return code;
}

当然实现有问题, 不过对于这个nestest.nes足够了

  • 没有对于非法ROM的检查, 这个由接口实现
  • 有些ROM是没有CHR-ROM的, 只有CHR-RAM, 这个后面再解决

运行起来

output

项目地址Github-StepFC-Step0

作业

  • 基础: 下载这个项目, 在main.c中实现自己的接口(load_rom/free_rom).
  • 扩展: 下载这个项目, 在main.c中实现自己的接口(load_rom/free_rom), 改成利用main函数参数argc/argv载入指定文件
  • 从零开始: 从零开始实现自己的模拟器吧

Recommend Projects

  • React photo React

    A declarative, efficient, and flexible JavaScript library for building user interfaces.

  • Vue.js photo Vue.js

    🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.

  • Typescript photo Typescript

    TypeScript is a superset of JavaScript that compiles to clean JavaScript output.

  • TensorFlow photo TensorFlow

    An Open Source Machine Learning Framework for Everyone

  • Django photo Django

    The Web framework for perfectionists with deadlines.

  • D3 photo D3

    Bring data to life with SVG, Canvas and HTML. 📊📈🎉

Recommend Topics

  • javascript

    JavaScript (JS) is a lightweight interpreted programming language with first-class functions.

  • web

    Some thing interesting about web. New door for the world.

  • server

    A server is a program made to process requests and deliver data to clients.

  • Machine learning

    Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.

  • Game

    Some thing interesting about game, make everyone happy.

Recommend Org

  • Facebook photo Facebook

    We are working to build community through open source technology. NB: members must have two-factor auth.

  • Microsoft photo Microsoft

    Open source projects and samples from Microsoft.

  • Google photo Google

    Google ❤️ Open Source for everyone.

  • D3 photo D3

    Data-Driven Documents codes.