dustpg / blogfm Goto Github PK
View Code? Open in Web Editor NEWBlog for Me
License: MIT License
Blog for Me
License: MIT License
这节就详细谈谈基础指令, 所谓'基础指令'只是自己随便命名的, 避免一节过长, 请勿对号入座.
不同指令需要消耗不同的周期, 这很好理解. 不过就算相同的指令环境不同也会消耗不同周期:
寻址模式 | 汇编格式 | 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);
寻址模式 | 汇编格式 | 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);
寻址模式 | 汇编格式 | 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);
寻址模式 | 汇编格式 | 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);
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
零页 | STX Oper | 86 | 2 | 3 |
零页,Y | STX Oper,Y | 96 | 2 | 4 |
绝对 | STX Oper | 8E | 3 | 4 |
将变址寄存器X的数送入存储器, 影响FLAG:(无), 伪C代码:
WRTIE(address, X);
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
零页 | STY Oper | 84 | 2 | 3 |
零页,X | STY Oper,X | 94 | 2 | 4 |
绝对 | STY Oper | 8C | 3 | 4 |
将变址寄存器Y的数送入存储器, 影响FLAG:(无), 伪C代码:
WRTIE(address, Y);
助记符号 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);
助记符号 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);
寻址模式 | 汇编格式 | 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);
寻址模式 | 汇编格式 | 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);
寻址模式 | 汇编格式 | 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);
寻址模式 | 汇编格式 | 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);
寻址模式 | 汇编格式 | 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);
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | INX | E8 | 1 | 2 |
变址寄存器X内容+1, 影响FLAG:Z(ero),S(ign), 伪C代码:
++X;
CHECK_ZSFLAG(X);
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | DEX | CA | 1 | 2 |
变址寄存器X内容-1, 影响FLAG:Z(ero),S(ign), 伪C代码:
--X;
CHECK_ZSFLAG(X);
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | INY | C8 | 1 | 2 |
变址寄存器Y内容+1, 影响FLAG:Z(ero),S(ign), 伪C代码:
++Y;
CHECK_ZSFLAG(Y);
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | DEY | 88 | 1 | 2 |
变址寄存器Y内容-1, 影响FLAG:Z(ero),S(ign), 伪C代码:
--Y;
CHECK_ZSFLAG(Y);
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | TAX | AA | 1 | 2 |
将累加器A的内容送入变址寄存器X, 影响FLAG:Z(ero),S(ign), 伪C代码:
X = A;
CHECK_ZSFLAG(X);
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | TXA | 8A | 1 | 2 |
将变址寄存器X的内容送入累加器A, 影响FLAG:Z(ero),S(ign), 伪C代码:
A = X;
CHECK_ZSFLAG(A);
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | TAY | A8 | 1 | 2 |
将累加器A的内容送入变址寄存器Y, 影响FLAG:Z(ero),S(ign), 伪C代码:
Y = A;
CHECK_ZSFLAG(Y);
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | TYA | 98 | 1 | 2 |
将变址寄存器Y的内容送入累加器A, 影响FLAG:Z(ero),S(ign), 伪C代码:
A = Y;
CHECK_ZSFLAG(A);
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | TSX | BA | 1 | 2 |
将栈指针SP内容送入变址寄存器X, 影响FLAG:Z(ero),S(ign), 伪C代码:
X = SP;
CHECK_ZSFLAG(X);
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | TXS | 9A | 1 | 2 |
将变址寄存器X内容送入栈指针SP, 影响FLAG:无, 伪C代码:
SP = X;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | CLC | 18 | 1 | 2 |
清除进位标志C, 影响FLAG: C(arry), 伪C代码:
CF = 0;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | SEC | 38 | 1 | 2 |
设置进位标志C, 影响FLAG: C(arry), 伪C代码:
CF = 1;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | CLD | D8 | 1 | 2 |
清除十进制模式标志D, 影响FLAG: D(Decimal), 伪C代码:
DF = 0;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | SED | F8 | 1 | 2 |
设置十进制模式标志D, 影响FLAG: D(Decimal), 伪C代码:
DF = 1
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | CLV | B8 | 1 | 2 |
清除溢出标志V, 影响FLAG: (o)V(erflow), 伪C代码:
VF = 0;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | CLI | 58 | 1 | 2 |
清除中断禁止标志I, 影响FLAG: I(nterrupt-disable), 伪C代码:
IF = 0;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | SEI | 78 | 1 | 2 |
设置中断禁止标志I, 影响FLAG: I(nterrupt-disable), 伪C代码:
IF = 1;
寻址模式 | 汇编格式 | 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);
寻址模式 | 汇编格式 | 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);
寻址模式 | 汇编格式 | 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);
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
零页 | BIT Oper | 24 | 2 | 3 |
绝对 | BIT Oper | 2C | 3 | 4 |
位测试
影响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;
助记符号: 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);
助记符号: 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);
助记符号: ...|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);
助记符号: ...|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);
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | PHA | 48 | 1 | 3 |
累加器A压入栈顶(栈指针SP-1). 影响FLAG:(无), 伪C代码:
PUSH(A);
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | PLA | 68 | 1 | 4 |
push的反操作, 在6502汇编称为pull, 不过自己还是习惯称为pop.
6502_cn这篇文档提到没有FLAG修改, 可能是笔误, 其他文档(比如这个)提到会影响S(ign) Z(ero) , 伪C代码:
A = POP();
CHECK_ZSFLAG(A);
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | PHP | 08 | 1 | 3 |
将状态FLAG压入栈顶, 影响FLAG:(无), 伪C代码:
PUSH(P | FLAG_B | FLAG_R);
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | PLP | 28 | 1 | 4 |
PHP逆操作, 影响FLAG: (是的), 伪C代码:
p = POP();
// 无视BIT4 BIT5
RF = 1;
BF = 0;
如果要在D3D11 上进行2D绘制, 使用Direct2D将是一个不错的选择,因为D2D就是由D3D实现的,可以实现几乎无缝地交互协同工作.
微软在Direct2D and Direct3D Interoperability Overview中提到了这两种协同:
如同题目所述: 直接协同. 微软在文中是用一种"共享"的办法, 利用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 这里,懒得渲染字体什么的了, 画个圈圈意思一下就行:
这里列出了自己常用的一些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);
}
所以, 只要找到了对应的符号, 想怎么弄就怎么弄.
分类: 小技巧
上面提到符号, 而且说了这么多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};
分类: 强迫症福音
自从看到'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;
}();
下面继续讨论一些关于像素风格缩放算法.
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后4倍最邻插值)
(与8倍最邻插值比较)
可以看出处理后很多东西变得粗细不一, 不适合缩放曲线条, 甚至可以看到一些单独的点被去掉了, 这个算法(想法)只能测试用, 没法实际使用.
全称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后4倍最邻插值)
(与8倍最邻插值, 以及2倍线性插值后4倍最邻插值比较)
可以看出因为A0点总是原来值, 所以放大后看起来像是往左上角偏了一个像素. 由于插值, 新加入了中间色, 实际上以100%比例看更合适, 所以像素风不再.
对于圆的处理效果很不错, 单独的点被处理成'星(+)'状, 要知道这是一个2倍的缩放, '+'是三个像素的. 效果最差的是'点状网', 主要是中间和边缘的效果不一致.
作者同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
代替.
(supereagle后4倍最邻插值)
既然是Eagle的'升级版', 就和Eagle比较吧.
可以看出作者很喜欢插值, 把Eagle不会引入新颜色的特点去掉了. 不过顺带解决了Eagle的BUG, 也就是消失的单独点, 代替的是颜色浅了, 变成25%, 不过也变成4倍. 当然, 也有像素偏移的现象.
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后4倍最邻插值)
可以看出依旧在100%下是最好的. BUG? 这个算法有BUG吧? 感觉像素变成竖线了. 和2xSaI对比一下:
(与2xSaI对比, 均作了4倍最邻插值)
处理得比较好的是非坐标对齐的图像, 处理得不好的是坐标对齐的图像, 可能这是这个算法的偏向吧.
作者Kreed的这一系列大致是对点周围共计16点判断, 然后生成2x2的新数据, 会产生新的像素, 会产生像素偏移.
进入HQx族了, HQx系列有一个特别的地方: 抗锯齿.
根据维基的介绍, HQx大致分为两个, 原版Maxim Stepin的HQx以及效果稍微差但是省空间的ScaleHQ.
前者, 作者形式发布的代码, 就算出现问题, 我们可以派生出一个LGPL实现分支, 然后直接使用预编译的二进制码即可. 后者, 则是由不同的作者实现, 然后被误以为是原版的HQ.
那就先说说简单版的ScaleHQ吧, 似乎由guest(r)实现的. 根据libretro的文档, 有两个放大率的, 这里称为scalehq2x 和scalehq4x. 2x应该是在public domain, 而4x是GPL.
代码上根本看不出算法, 只有数学运算, 猜测是将像素转换成图形, 然后继续渲染. 不过懒得去研究, 直接简单改写scalehq2x用于支持本框架. 由于抗锯齿的原因, 这个效果在100%下效果最好, 这是效果:
这是guest(r)在GPL下发布的, 同2xScaleHQ, 只有数学运算:
可以看出, ScaleHQ的优点是: ScaleHQ是专门为Shader编写的算法, 中间几乎没有分支, 全是运算.
缺点非常明显, 折线过渡不自然:
图中两个地方理论上应该有像素过渡的三角.
原版Maxim Stepin的HQx(HQ2x, HQ3x, HQ4x)总的来说是依靠查表, 对于一个点周围8点在YUV空间下形成256次可能性, 然后分别处理. 不过还是难不倒大佬们, 移植到了着色器上. 也是以LGPL形式发布的, 建议直接用了, LGPL用二进制文件即可.
实际移植的时候发现图像出现问题, 找了几天终于发现原来是图片载入出现问题了(32bppBGRA vs 32bppPBGRA), 导致LUT的权重读取错了.
(HQ2x-100%下效果)
(HQ3x-100%下效果)
(HQ4x-100%下效果)
(HQ4x后再用双三次插值到原比例的8倍)
HQx处理的效果非常讨人喜, 之前ScaleHQ提到的折线转交处也处理得很不错.
缺点, 除了实现比较困难外, 效果上可能就是把那个笑脸变成相低多边形的圆了. 而实际上, HQx在文字显示上, 非常不错. 不过在游戏中, 由很多颜色组成的实际画面就稍微差了一点(感觉一块一块的).
之所以将游戏名称作为小标题, 自然是说到可乐妹的VRC7, 就不得不说'拉格朗日点'了, 因为这是一款唯一使用了VRC7的游戏. 被不少人冠上'FC最强音乐'的帽子.
当然实际上还有一款'兔宝宝历险记2(日版)'也使用了VRC7, 不过'兔宝宝历险记2'没有使用到VRC7的扩展音源(实体卡带比前者小了不少, 可以看作前者使用了VRC7a, 后者使用了VRC7b).
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
感觉都还科学. 变种区别为
7 bit 0
---------
..PP PPPP
|| ||||
++-++++- Select 8 KB PRG ROM at $8000
64*8=512
7 bit 0
---------
..PP PPPP
|| ||||
++-++++- Select 8 KB PRG ROM at $A000
7 bit 0
---------
..PP PPPP
|| ||||
++-++++- Select 8 KB PRG ROM at $C000
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 |
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)
$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
编写.
一开始这个东西根本就不能运行, 到处出错. 一步一步反汇编后发现, 其实是IRQ的实现有问题. CLI
后不久, 强行触发中断导致程序乱跑.
根本原因还是IRQ实现有问题(废话), APU禁用IRQ后依然触发了已经挂起的IRQ. 目前先将挂起的IRQ清除掉, 以后好好研究一下IRQ.
VRC7拥有6个FM合成音源声道, 实现了Yamaha YM2413 OPLL的一个功能子集(阉割版). 似乎叫做'Yamaha DS1001', 下面为了方便描述, VRC7与'Yamaha DS1001'在描述上等价.
前面有一个寄存器Mirroring Control ($E000)
, 其中Silence
位为1的话, 会让VRC7部分静音并清空相关数据状态.
7......0
VVVVVVVV
++++++++- The 8-bit internal register to select for use with $9030
写入后, 程序不得在6个CPU周期(N制, 实际是12个内部周期)内写入$9030
以及$9010
, VRC7内部处理需要一点时间.
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声道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) |
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) |
$5
替换掉PATCH中的'R'阶段原本的数据0->1
我们可以认为是键盘按下, 1->0
则可以认为是键盘弹起. 键盘按下就能弹出一个音符, 按键弹起则进入音符ADSR的'R'阶段. 如果键盘敲不响, 肯定是卡住了: 0->0, 1->1
.$2X:O
3bit的八度数据. 这两个合在一起定义了一个输出频率: 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 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
实际使用中会使用'phase_secondary': phase_secondary = phase + adj
adj
是调制器的输出. 超过18bit的将会舍弃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都是之后需要使用的:
输出计算:
每个周期每个算子输出一个衰减值:
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, 后面说明.
dB = -20 * log10( Linear ) * scale (if Linear = 0, dB = +inf)
Linear = 10 ^ (dB / -20 / scale)
每个算子的包络发生器拥有一个23bit的计数器(记为EGC), 为输出添加衰减值(控制音量). 拥有ADSR阶段, 结束后进入空状态(Idle).
除开Attack阶段, EGC
为零时输出0dB, EGC
为1<<23
时, 输出48dB. 文中建议dB使用(1<<23)/48
作为基础单位, 以做出最少的单位转换.
这里列出一些数据之后会用上:
K ? OF : OF >> 2
R*4 + KO
RKS >> 2
, 超过15则被钳制到15RKS & 3
Attack阶段:
R
是对应的Attack速率EGC += (12 * (RL+4)) << RH
EGC
超过23bit范围, 归零, 进入Decay阶段.AO(EGC) = 48 dB - (48 dB * ln(EGC) / ln(1<<23))
Decay阶段:
R
是对应的Decay速率EGC += (RL+4) << (RH-1)
EGC >= (3 * Sustain * (1<<23) / 48)
Sustain阶段:
$0$1: sustain
为0, R
是对应的Release速率R=0
EGC += (RL+4) << (RH-1)
Release阶段:
$2X:S
为1的话, R=5
$0$1: sustain
为0的话, R
是对应的Release速率R=7
EGC += (RL+4) << (RH-1)
Idle阶段:
敲下和弹起后会处理一些事. 前面说了, 卡住的不算, 换一个键盘再来.
敲下:
EGC=0
弹起:
EGC=AO(EGC)
)前面提到的什么抖音颤音就在这了. 与前面不同的是, 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.
可以看出很多地方的计算相当复杂, 具体可以用查表实现. 涉及到精度的查表, 自己会用常量控制, 然后试试什么精度可以接受.
实际编写中自然是遇到了大量问题:
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
事件(double)(1<<23)
(单精度浮点捉襟见肘了), 理论最大值为0.75(6声道)预置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则不进行扫描.
坑爹呢这是! wiki用0作为例子自己还以为是可以是0的! 这个问题已经在前面的博客修改并注明了(希望不要再出幺蛾子). 之前的代码自然是错的, 不过也不会去改了.
这游戏感觉完全在炫耀机能一般, 第一个BGM就在大量使用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 * 4128 / 4096
之类的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
}
a * 1.008
-> a + a*0.008
/// <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
精度10位时, 最后几个差距有点大.
精度14位时, 精度大致介于1与2之间.
精度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
};
本文github备份地址
上节简单介绍了基本概念, 下面, 介绍相关寄存器
这里特指时钟分频器(或者叫做'分时器'?), 分频器会利用一个值(P)输出时钟, 内部持有一个计数器, 计数器到0就输出然后重置为P值, 比如P为5:
可以看出这个分频器的周期是P+1.
可以强制让分频器重载P值, 不过就不会输出时钟了. P值修改时不会影响分频器当前的计数.
地址 | 位 | 功能 |
---|---|---|
$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扫描行触发一次.
包络(APU Envelope)发生器生成一个固定的音量或者一个锯齿包络(还可以选择是否循环)来控制音量, 内部包括一个时钟分频器和一个计数器.
上面提到了, 分频器的周期是P+1, 即每次获得P+1次时钟信号则输出一次时钟信号.
例如:
长度可以理解为时长. 这东西用来自动控制持续的时间, 计数器可以被暂停. 暂停标志位是在D5位(方波、噪声), 或者D7位(三角波), 设置为0表示暂停. 暂停会让计数器...emmm...暂停.
内部计数器到0的话就会静音; 被提供时钟信号时, 计数器会-1, 除非:
长度计数器(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
长度计数器可以看出比较粗糙, 比如这种表都是偶数.
$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对应的声道就会静音, 同时长度计数器归零. 一定注意要归零, 自己之前忘记归零导致玩马里奥第二关才发现有音效问题, 找了很久才发现是这个原因
地址 | 位 | 功能 |
---|---|---|
$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的说明
f = CPU / (16 * (t + 1))
扫描单元(APU Sweep)位提供了周期性调整方波周期的手段, 包含一个分频器和一个移位器.
EPPP NSSS 使能标志, 分频器周期(需要+1), 负向标志位, 位移数量
同样, 扫描单元的分频器也是P+1, 不同的是一个3bit的了. 还有就是扫描单元是以120Hz的频率触发, 所以扫描的频率是15Hz-120Hz(0.5帧-4帧)
移位器连续地对频率进行扫描:
方波1和2区别:
例子:
静音:
之前说到方波频率的计算公式f = CPU / (16 * (t + 1))
, 为什么呢?
前面提到了, 目前使用状态机实现, 目前的实现是60Hz(同刷新率). 如果一帧内修改了超过1下, 只会以某一时刻为准, 不过60Hz对于音乐是够用了, 至于音效么, 只能说总比没有的好(比如马里奥跳跃的音效, 目前听起来就有点呆板了). 以后可能会用更好的办法(一旦修改数据就通知渲染层(广义)).
寄存器地址 | 位 | 说明 |
---|---|---|
$4008 | C--- ---- | 暂停长度计数器/线性计数器控制 |
$4008 | -RRR RRRR | 线性计数器重载值 |
$4009 | ---- ---- | 居然没用 |
$400A | TTTT TTTT | 周期低8位 |
$400B | ---- -TTT | 周期高3位 |
$400B | LLLL L--- | 长度计数器载入索引 |
之前有一个长度计数器, 用于控制播放的时间, 由于120Hz和都是偶数, 所以精度是在一帧. 三角波增加了一个线性计数器能够更为精确地控制(那么代价是什么?).
$4008包括了一个控制标志位以及一个7位的重载值: crrr rrrr
, 当然这个C位也被用于长度计数器.
之前可以看长度计数器是一回合2次(又由于是偶数, 所以...), 线性计数器则是4次:
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位.算法如下:
如果是长模式(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频率上:
理论频率计算公式应该是CPU / P
, 或者APU / P
. 不用加1, 是因为直接给出了周期. 不过因为是噪音, 不是真正的频率, 这个频率只是LFSR的更新频率.
至于是CPU / P
还是APU / P
, 感觉上有歧义:
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.
Timer with
divide-by-two on the output
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/P
(没有比较源代码, 毕竟世界上最痛苦的事情, 莫过于看别人写的代码还没有文档), 有一个比较低的$0C音, 区别较大. 这个也需要求证. 不排除自己的实现有问题.
也就是说噪音生成的逻辑:
噪声声道也用到了包络, 方波已经说到了就不再累述
EZ模式实现就比较困难了, 因为实际上有一个LFSR状态机. 但是回归本质, 这是一种白噪声的实现手段, 实际上只有$400E中的5bit控制音色, 即实际上只有32种音色, 16种频率乘上2种模式. 反正是噪音(EZ模式嘛, 听个响, 要啥自行车).
再简化, 频率可以通过音频API调整, 也就是说只需要实现两个模式即可. 不过, 即使是白噪声, 短时间播放一段时间还是有差别的, 记录当前播放状态反而实现了一个伪状态机. 换句话说, 也就是每次播放不要从头播放, 从上次的地方继续.
实现:
...
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
2A03的ΔPCM大致解码流程:
三个部分组成: DMA读取器->样本缓存->输出单元
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.
2A03的ΔPCM是1bit的Δ, 0就-2, 1就+2. 每8次(一个字节)一个循环, 8次是一个单位, 不能从中间打断.
解码练习: 有爱好者将ΔPCM数据RIP出来了, 可以用来测试解码器
编码练习: 有兴趣的话可以将一段高品质的音频序列编码至2A03的ΔPCM序列
由于$4011的特点, 2A03可以以PCM方式工作, 这样会消耗大量的空间(ΔPCM x 8), 以及CPU时间. 音质甚至可以达到7bit/44kHz, 不过这个时候你就别想玩游戏了, CPU全部用来输出PCM了.
MMC5甚至带了一个类似的寄存器不过是8bit的了, 是鼓励用PCM吗?
解码输出可以算是很简单, 但是对于EZ模式几乎是一个不可能的任务:
至于7bit的PCM, 很难实现也很容易实现:
至于ΔPCM, 难点是同步
综上所述, 模拟DMC声道在状态机模式是比较困难的事情了(如果模拟精度精确到周期, 那就相当方便了, 真正的'仿真'). 我们先放置一下, 暂不实现.
托音频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的!
终于可以完整地游玩马里奥了, 就着这个劲儿准备把马里奥通关了!
然后自己加入了非常暴力膜幻的即时存档功能, 通关了.
那么正式进入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
个人认为, 这个项目是在模拟器基础上追加的NSF功能, 所以按照60Hz或者50Hz即可. 除非本体是NSF播放器, 就得严格按照这个频率.
文件头拥有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的介绍, 应该这么初始化曲子:
文件头有一个表示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
. 这自然不是我们想要的, 所以我们有两种做法:
long_jmp()
如果是写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;
}
由于没有图像输出自然就是啥也没有.
.
但是通过wiki, 我们知道NSF自然是可以使用扩展音源的, 这不奇怪. 但是NSF允许混用不同的扩展音源! 例如2A03+VRC6+FDS+MMC5+FME7
, 所以, 好戏才刚刚开始呢!
让我们再跨一步吧.
6502汇编使用数字前面美元符号($)作为16进制的表示(8086则是在后面加‘h’)
先谈谈内存布局, 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: 已经说过了
很遗憾, 自己在大学虽然是计算机系的, 但是不是计算机科学之类的学科, 而是靠近多媒体方向(比如计算机图形学, 音频之类的). 这就导致导致计算机硬件知识几乎没有, 只能靠脑补了.
6502有三种中断(按优先度排序, 越后面越优先):
每一种中断都有一个向量. 向量是当中断触发时“转到”的指定位置的16位地址:
也就是说程序一开始执行$FFFC-FFFD
指向的地址
'低地址'在'低地址', '高地址'在'高地址'. 即低8位在$FFFC, 高8位在$FFFD.
目前当然是实现Mapper000, 实际上也就是没有Mapper的意思:
$8000-$BFFF
: ROM开始的16kb$C000-$FFFF
: ROM最后的16kb就目前而且, 只需要一个重置接口:
/// <summary>
/// StepFC: Mapper接口
/// </summary>
typedef struct {
// Mapper 重置
sfc_ecode(*reset)(sfc_famicom_t*);
} sfc_mapper_t;
BANK是每个Mapper载入的单位, 在某种意义上也可以称为window. 根据内存布局, 可以把根据地址划分为每8KB一个BANK, 一共8个区块:
0: [$0000, $2000)
系统主内存1: [$2000, $4000)
PPU 寄存器2: [$4000, $6000)
pAPU寄存器以及扩展区域3: [$6000, $8000)
存档用SRAM区也就是:
uint8_t* prg_banks[0x10000 >> 13];
根据所述内容, 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, 那么写入加个断言好了.
可以看出RESET是跳转$C004.
这一步就来讲讲滤镜与滤波器, 都可以叫做filter, 总的来说可以提升用户体验.
滤镜有很多种, 比如模拟CRT显示器, 但是这里不是说的这么一类. 由于原生分辨率只有256x240, 对于现在来说太小了, 我们可以把它放大一点, 使用的算法称为缩放算法(scaling algorithms), 处理后效果就简单地称为滤镜了.
这里采用被维基标注为public domain测试用卡片:
例如D3D11_FILTER
有这么一个枚举D3D11_FILTER_MIN_MAG_POINT_MIP_LINEAR
, 其中POINT
和LINEAR
就是代表了两种最基础的过滤方式: 点过滤和线性过滤.
一般缩放算法会采用插值, 这两个过滤方式对应的就是最邻插值和线性插值了.
也是之前采用的插值算法, 下面是放大3倍后效果
效果中规中矩, 也没有什么特别之处, 优点就是实现简单, 图像API本身就会提供
这个方法实现也比较简单, 但是放大三倍后
就糊了.
当然, 还有很多插值方法. 比如D2D就还提供了什么各向异性过滤等高质量的插值算法, 我们所谓的"像素风格"图像是不适合用常规插值算法的, 因为这些算法是针对平滑像素.
维基列了相当多的算法, 这里就挑选几个看看.
通过粗略查看, 我们可以知道很多都是将原生数据放大至整数倍, 由于我们显示的话可能会显示成任意分辨率, 所以逻辑是:
原生->像素风格缩放算法->插值到任意分辨率
中间有两次缩放, 我们可以为用户提供选项, 最后一步可以考虑用线性插值或者最邻插值, 当然也是可以选的
后面还有4倍的一些算法, 4x4就是16倍, 用CPU处理自然略慢, 就用着色器处理吧, 这里就用比较熟悉的HLSL实现.
看名字就知道这个算法是将分辨率提高至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后再进行了4倍最邻插值)
(与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?
这就是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后2倍最邻插值)
(与6倍最邻插值比较)
可也看出"圆"处理得比2x好, 其他的很一般了. 特别是中间的那个分割线, 还以为自己实现有问题, 找了很久, 然后用作者自己写的程序跑了一下还是一样的, 2X也有这种情况但是不明显, 估计放大倍数越大越明显.
可以看出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年派生出这个算法, 但是居然也出现在了那个主题讨论中.
目前为止, Mapper 0的游戏应该能够顺利玩了, 就差声音了. 所以接下来就是对于音频的模拟, 算是换一个口味轻松轻松(实际并不轻松)吧. 个人还是挺喜欢8Bit风格的音乐的!
本步骤中, 如果没有特殊说明则表示是NTSC制式
这里使用音频API是XAudio2.7, 需要DX的运行时. XAudio2.8是Win8自带的, 不过考虑到Win7还是使用2.7. 不过这样导致Win8以及后面的系统, 也需要下载DX的运行时库. 并且由于2.8接口上部分不兼容2.7, 但是还是用一个接口名, 这就很难受了, 只能说微软S那啥.
同样因为可能读者拥有自己了解的音频API, 这里不对音频API做过多解释, 不过一般来说音频API就是读取样本缓存然后播放, 不考虑特效的话, 还是很简单的.
这一节简单介绍一下各个部分的特性.
FC的CPU叫做2A03, 之前介绍了比起6502缺了点啥, 现在就是说多了点啥: pAPU - pseudo Audio Processing Unit
之所以有一个前缀p是因为没有专门处理音频的物理芯片 -- 这句话其实有歧义. 其实CPU和pAPU是一个芯片, 可以认为 pAPU + 6502 = 2A03
默认情况下, pAPU支持5个声道:
还有就是Mapper额外搭载的, 这里不谈. 至于
可以自行了解之间的区别, 不过这里为了方便, 样本格式采用IEEE单精度浮点表示.
*** *** *** *** *** ***
---------------------------------
*** *** *** *** ***
振幅浮点表示就是1.0和-1.0, 然后交错起来(也可以是0.0和1.0, 更为方便).
感觉和矩形/方形的有点联系, 50%才叫真正的"方波", 2A03中方波拥有4种占空比(Duty Cycle):
反相的话, 单独听是听不出与不反相有啥区别的. 不过通过混音就可能会有点区别
方波是有音量控制的, 最大15.
* * * *
* * * * * * *
--*---*---*---*---*---*---*---
* * * * * * *
* * * *
振幅线性地在1.0和-1.0之间来回振荡(也可以是0.0和1.0, 更为方便)
三角波没有音量控制, 取而代之的是更为细腻的长度(时长)播放控制.
其实直接播放上面的就是噪声了(笑). 噪声声道通过一个伪随机的位发生器发出噪声. 由于是1bit随机以及最大音量15, 生成和方波类似, 只不过是从预设到随机了.
噪声声道有一个15bit的LFSR, 每次输出最低的bit位.算法如下:
用于DPCM生成, 2A03的ΔPCM大致解码流程:
由于DMC相关寄存器特性, DMC声道也能播放7bit的PCM数据. 不过, 如果说播放DPCM是硬件解码的话, 播放PCM就是软件解码了. 播放完全由CPU控制, 会消耗大量CPU资源.
模拟器对于音频的实现主流的方案是利用目前数据生成样本数据再传给音频API, 同时为了避免出现'饥饿'(starvation, 声部匮乏, 出现'卡音'现象)会缓存几帧的音频再播放. 不过这里嘛...
最初的音频实现, 我们实现简单点: 状态机. 将各个部分实现为状态的切换. 比如:
这样会一直地播放这个方波, 直到方波#1的状态被改变. 音量为0的话记为停止播放:
.
三角波有点特殊, 没有音量, 所以就频率0Hz定为静音(或者20Hz~20kHz有效). 还有一点由于三角波的特殊性, 我们需要完整地播完一个'三角波', 避免出现"爆音"(状态机特有)
细节部分就在下节介绍.
自己在前面即时存档中, 非常暴力地几乎把所有东西都存进去了, 这一节自然得规范一下!
有一些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*);
那么到底需要存什么东西? 简单地说就是"状态", 现在很多状态可以直接保存结构体数据就行, 但是, 但是很有可能修改数据布局, 导致这个版本的读取不了上一个版本的.
我们又不可能预知未来, 修改是必然的, 所以可以考虑使用版本号区分, 正式释出的时候可以兼容上一个(文件)版本的文件就非常完美了. 最完美的是兼容所有历史(文件)版本, 这个包袱有点重可以不实现.
又到异端审判的时间到了:
大小端问题在文件储存上问题比较突出, 以什么字节序保存也是一个令人头疼的问题, x86采用的小端模式自己感觉还算科学, 比如char16_t
如果是保存的是ascii码的话, char16_t*
强转char*
就行, 步进变成2而已. 什么C++模板走开点, 重解释才是王道! 无奈网络传输一般用大端传输(大概是数字文字表示是左边更大)
这里就直接采用不考虑大小端的情况, 直接保存, 以后再考虑大小端.
RAM自然是保存的重点对象, 下面列出需要保存的RAM数据:
关于SRAM的说明, ROM会有SRAM的标志位(电池供电), 但是没有WRAM的标志位(FC供电). 比如超级马里奥3使用了WRAM, 但是ROM里面是没有SRAM标记的.
当然, WRAM可能不止8KB, 内部甚至允许切换, 所以SRAM/WRAM分开处理比较好.
再比如, 之前Mapper1是支持切换WRAM的, 不过因为是变种就没有实现, 这是以后的事情.
不过嘛, 无脑保存就行, 反正不缺8KB的磁盘空间, 甚至可以压缩.
目前完成的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都没问题, 说明这个情况比较罕见.
魂斗罗没有CHR-ROM, 需要保存8KB的CHR-RAM. 不过, 魂斗罗的鼓声实在是一大亮点!
Family Computer Disk System(FDS)是一种基于FC的磁碟机系统, 其中部分探讨会在下一步《全部成为F》简述.
这里仅仅是完成扩展音源部分, 自己并没有完全模拟FDS的计划. 毕竟从未见过, 甚至都是接触过模拟器后才发现才有这玩意. 没有感情, 对, 我是一台没有感情的程序猿.
可以看出FDS与其他不同的地方是: 别的扩展音源是卡带上的硬件, FDS音源是磁碟机上的. 所以FDS的游戏自然是: 不用白不用, 很多FDS的游戏都用上了.
iNES为FDS分配的Mapper编号是20, 不过仅仅作为保留用, 让模拟器知道自己在模拟FDS而已.
FDS扩展音源只有一个声道. 简单地说, 就是将一个长度64的自定义波形信息, 安装预定的方式进行调制. 所以别说模拟乐器了, 完全可以用来模拟人声. 就是精度差点(6bit).
7 bit 0
---------
xxxx xxSD
||
|+- Enable disk I/O registers
+-- Enable sound I/O registers
D1位写入1才能让音频相关寄存器启动(懒得去实现).
7 bit 0 (read/write)
---- ----
OOSS SSSS
|||| ||||
||++-++++- Sample
++-------- Returns 01 on read, likely from open bus
这里保存了64步长的波形数据, 这部分可读, 不过声音播放中不可写. 读取时高两位是01, 所以实现上可以写入01SS SSSS
.
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)
超过32是有效的, 但是输出前会被钳制到32(根据全文, 超过32的数据仅仅可能用于数据读取).
7 bit 0 (write)
---- ----
FFFF FFFF
|||| ||||
++++-++++- Bits 0-7 of frequency
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位仅仅会暂停包络而不是波形, 不过会重置这两个的计时器.
调制器(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)
7 bit 0 (write)
---- ----
xBBB BBBB
||| ||||
+++-++++- Mod counter (7-bit signed; minimum $40; maximum $3F)
这是一个7bit有符号的数据,
7 bit 0 (write)
---- ----
FFFF FFFF
|||| ||||
++++-++++- Bits 0-7 of modulation unit frequency
7 bit 0 (write)
---- ----
Dxxx FFFF
| ||||
| ++++- Bits 8-11 of modulation frequency
+--------- Disable modulation
最高的禁用位能够禁用调制, 同样如果12bit的频率为0也能禁用调制.
通过禁用位暂停调制后才能够写入调制表($4088)
7 bit 0 (write)
---- ----
xxxx xMMM
|||
+++- Modulation input
必须通过禁用$4087的相关禁用位才能正常写入, 否则无效.
调制表是一个64长的环形缓冲区, 每次会写入表中相邻的两位, 也就是说连续写入32次就能完整地写入一次(当然每次写入会推进位置).
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(自己的实现是在推进波索引时检查是否输出).
7 bit 0 (write)
---- ----
SSSS SSSS
|||| ||||
++++-++++- Sets speed of volume envelope and sweep envelope
(0: disable them)
为音量/调制包络设置时钟倍频, 很少会使用(不过不要小看NSF). BIOS将其初始化到$E8
.
7 bit 0 (read; write through $4080)
---- ----
OOVV VVVV
|||| ||||
||++-++++- Current volume gain level
++-------- Returns 01 on read, likely from open bus
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, 根据$4080/$4084:D6
位:
调制单元, 当调制单元被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.
包络:
8 * (e + 1) * m
CPU周期会Tick一次音量/调制包络$4080 $4084 :D7
才能进行增益处理/// <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);
}
}
}
波输出与调制:
/// <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
);
}
主要影响值: 波输出数据, 音量增益, 以及主音量.
0.0045
(并不确定, 交给用户控制)可以看出可以读取$4040-$4092(大致), 这部分刚好在前面定义的自定义BUS区. 上次刚好将VRC7数据转进来, 这里只好挪一下, 目前BUS布局:
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;
}
新的思路: 这一次尝试用新的思路处理audio_changed
事件. 会在音频事件修改前处理(这个事件名称之后应该会继续修改), 然后用最小的临时'context'(上下文)数据而不是保存整个状态. 这个新思路可能会让全部的音频事件模仿.
禁止位: 有一点特别注意, 几乎所有相关bit位是禁止位, 而不是使能位. 实际编写中自己几乎全部弄反了.
调制增益: 调制的目的就是动态修改波输出频率, 不过可能会发生溢出的现象:
freq + gain < 0
uint16_t
这就让频率变得异常高int16_t
效果对比: 测试用ROM, 不对, 测试用NSF文件是一位叫做w7n的作者(四斋)cover的《初音ミクの消失》, 这是来自famitracker论坛. 标题提到是66Hz, 不过实际上60Hz还是可以听一下.
**
*
* **
************ * * *
** *
^ **
跳跃点
这首曲子中, 几乎每次说完一个字就会出现. 这个东西通过低通滤波能够减弱, 但是还不能完全消除, 可能性:
《初音ミクの消失》是由cosMo@暴走P Official Channel创作的一首知名曲目.
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还多.
我们之前实现的是在音频API基础上实现的状态机, 精确度大致在60Hz. 由于内部的帧计数器是240Hz的, 所以精度远远不够.
现在就由60Hz一口气提升到44.1kHz吧, 精度即在样本, 这是听觉上的上限, 但是距离最高的1.79MHz还差了几十倍!
而且当然要实现DMC声道, 很多游戏利用DMC发出鼓声来带节奏!
注意, 本节并未处理PAL的场合, 这个, 以后再说!
采用这个频率不光是主流方案, 还有就是44100除以60和50都可以除尽, 即每帧的样本数量是固定的. 这次核心部分依然不会计算生成样本, 是由接口完成的样本生成, 好处是可以自由地选择采样率, 不用向核心提供参数.
48kHz也是不错的选择.
这次由核心部分提供事件, 然后由接口生成数据:
// 音频事件
void(*audio_changed)(void*, uint32_t, int);
参数2范围: 44100 * 1789773 / 60 = 1315564005
在32bit范围内, 也就是说即使是一帧最后一个CPU周期(大致为三万)也能通过32bit整数确定到是最后一个样本
所以, 实际上还是一个状态机, 不过精度高了.
核心 -> 接口事件 -> 生成样本到音频API
第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
的软件导入裸数据:
就可以听到马里奥跳跃的声音了. 方波#2也是类似的. 在实现细节上值得注意的是:
这个逻辑同样适合三角波, 值得注意的是, 我们之前实现最初状态机提到过因为三角波特性导致必须播放完一个三角波避免出现'爆音'. 这里解释本身为什么不会出现:
BEFORE:
* * * *
* * * * * * *
* * * * * *
---*------*----***********-----*---
^ ^
| |
两次跳跃
没有跳跃
AFTER: | |
* * v v * *
* * * ************* * *
* * * * * *
---*------*--------------------*----
即:
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播放完毕处理IRQ是不可能的了.
DMC声道目前来说有三种用法:
目前前两个都能处理, 最后一个就无能为力了, 好在最后一个很少见. 以后再说.
注意点: $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), 我们可以这么处理:
列出来主要是可以让用户自行选择, 这里的实现:
由于各种原因, 最大的原因可能是计时器的精度, 可能会导致音频播放剩余缓冲过多. 过多的话, 一, 是可能当前环境不够支持过多的缓冲区, 二, 是会导致音频延迟, 到一定程度就会浑~ 身~ 难~ 受~.
列出来主要是可以让用户自行选择, 这里的实现:
Sleep(30)
. 毕竟吃饱了就睡是常识!.
自己播放时, 几分钟就会触发一次'吃撑':
自己仅仅是等待垂直同步, 也就说可能自己的显示器可能实际是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
, 修改点很少:
d2d_submit_wave
就是把样本再发给图像接口一端, 可以显示当前帧的波形图像当然, 这些都是小接口, 未作优化.
之前4MB的PCM播放的ROM(搞事者转录的寒蝉鸣泣之时-解)也可以正确播放了.
.
不过播放ΔPCM还是可能会出现跳跃:
这是一段鼓声, 不知道是不是BUG. 但是几乎听不出来是'爆音', 或者说, 以为'爆音'就是这段鼓声的特点.
这一节将介绍屏幕相关知识
CRT叫做"阴极射线显像管"(Cathode Ray Tube), 也就是所谓的"大屁股电视机".
以前的老电视都是这种形式, 现在自己老家应该还有用了几十年的老电视. 记得是在初中物理还是是什么地方应该介绍过原理, 这个受限于电力系统的频率, 我国是50HZ, 使用的PAL-D,K制式:
这里有一个视频叫:
可以通过高速摄像机直观地理解.
现在视频网站可能有720p, 1080p的视频. p就是表示逐行(progressive)扫描, 对应的就是i, 隔行(interlaced)扫描. 我记得PS3上有1080i的选项但是ps4没有看到(或者没注意到), 可能是时代不同了.
一般没有必要模拟隔行扫描(视频中的显示器貌似就是逐行扫描的CRT), 除非有强迫症. 但是扫描线的概念还是要有的:
NTSC 一秒显示60场, 每场平均262.5扫描线(隔行扫描). 也就是扫描线频率15.75KHz, 折合63.5微秒(μs), 262.5-241.5 = 21条扫描线. 也就是说VBlank花了1.33毫秒(ms), 标准制式是这样的.
根据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:
细节上有些区别, 不过都是240行可见扫描线以及20行的VBlank线. 以wiki为主吧.
说到星球大战可能不得不说开场字幕:
这种开场字幕可谓经典, 连我这种没看过的都知道, 可见一斑. 现在我们就通过3D图形API渲染出类似的结果, 自己对D3D11较(其他API)熟悉, 文本API则使用配套的DWrite+D2D. 当然这种情况请关闭文本API的子像素渲染功能(Windows上叫ClearType技术, 大致原理一样, 细节或许不同)
通过3D图形API可以很简单地渲染出来类似的效果, 一般来说有两种方式:
上一节Direct2D 直接与Direct3D 11 进行协同中提到了D3D和D2D的协同工作, 这个协同最主要的当然还是文本渲染! D2D的文本是通过DWrite,简单说这里就是DWrite->D2D->D3D. 其中D2D->D3D已经知道了, 而第一步则非常简单:
采用字符串、文本格式和关联约束,并生成一个对象,该对象表示完全分析和格式化结果。
然后D2D就可以直接绘制这个文本布局了ID2D1RenderTarget::DrawTextLayout, 当然D2D对此有优化, 在此不谈.
如果是类似使用OpenGL+FreeType之类的, 可能需要使用一些优化手段了。
这个需要使用的文本API支持, 好在DWrite是支持的, 大致是
测试文本节选自《三国演义》, 本想直接用星球大战的, 还是测试汉字效果才是王道:
默认情况的比较如下:
查看原图可以清楚地看出, 渲染小字体用三角面真是全是狗牙, 右侧的大字还能看得出, 左侧第三行三角面模式到第五个字"分(争)"才能辨识出来. 而离屏渲染中对应前面的"七国"也能分辨. 而右侧的大字用离屏渲染的又太模糊(不过这个是可以解决的! 后面会说). 感觉两边都不太行
4xMSAA
既然狗牙有点重, 打开抗锯齿怎么样, 这里用MSAA做测试:
- dustpg使用了4xMSAA
- 效果显著
可以看出几乎所有字都能辨识了, 可见渲染三角面字体必须抗锯齿. 至于怎么抗锯齿就看各位了, 比如也可以从shader下手, 毕竟MSAA是影响全局的.
本文使用的代码全在NoteFL/D3D11/D3D11Text3D/main.cpp里面, 有需要的可以查看
对于这种文本(或者说灰度数据, 这里的灰度就是Alpha), V社(对, 就是不会数3的那个)发表了一篇Improved Alpha-Tested Magnification for Vector Textures and Special Effects的论文, 能够有效改善文本模糊的问题, 效果可谓是非常不错.
OpenGL+FreeType的话可使用, 类似:
在渲染小字体时, D2D就是对于每个字符就是一个矩形, 对应的纹理坐标自然就是纹理缓存中的. (D2D渲染大字体则就是渲染三角面, 应该, 大概, 可能吧), 这就是第三种方法——四大天王! 不对, 格式化为矩形串.
这种情况需要自己实现一个字体纹理管理器, 毕竟汉字几万个. 然后实现一个简单的排版引擎. 再加上前面提到的SDF, 可谓是非常完美. (这个方法其实DWrite+D2D帮你实现了, 但是放在3D空间中就需要自己实现了)
这种情况下时间效率分析:
矩形位置需要通过PS, PS过程需要采样, 大致介于上面两种情况之间
这种情况下空间效率分析:
不用说, 不含缓存纹理的话(全局都可以用)是最划算的.
结论:
不嫌麻烦的话, 这个+SDF应该是最好的办法了
这里列出了自己常用的一些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_back
和std::vector<float>::push_back
由于int32_t
和float
大小一致, 这两条函数可能会被优化合并为一条函数. 大致同理, 我们可以把这种没有构造/析构的对象单独拿出来实现一个专门储存这一类的容器, 经过内联后, 这一类调用的函数会完全指向同一个实现.
为了方便起见, 就称为"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_back
和std::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
这个结论也很简单, 那么有什么好讨论的呢? 那就是new
和delete
被称为"操作符", 还允许被重载:
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
.
这节是指令介绍的最后一节.
扩展指令, 或者说非法/未被记录的指令: 一般是组合指令. 由于非官方记录, 所以指令名称可能不一致.
本节很多指令并没有在STEP3中实现, 原因是因为使用的测试ROM并没有测试这些指令, 所以'忘记'实现了, 这些指令直到测试了'blOperg's CPU test rom v5'才实现.
除了基本的NOP, 还有高级的NOP. 一般来讲6502指令是根据寻址方式排序的(偏移), 所以在其他寻址方式对应NOP的地方还有
详细的请到引用链接了解
助记符号: 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周期
助记符号: 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.
助记符号: 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);
助记符号: 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);
助记符号: 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);
助记符号: 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);
助记符号: 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);
寻址模式 | 汇编格式 | 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);
寻址模式 | 汇编格式 | 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);
寻址模式 | 汇编格式 | 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;
// ...
助记符号: 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);
寻址模式 | 汇编格式 | 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 & SHY, 不过"blOperg's CPU test rom v5"中测试了该指令
SHX & SHY, 不过"blOperg's CPU test rom v5"中测试了该指令
测试ROM: "blOperg's CPU test rom v5"中, 仅仅不通过以上两个指令.
像素风格缩放算法还有非常多, 光维基那种对照图就还有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深度的三角波, 我们导入音频软件可以得到:
听起来怎么样? emmmm...差不多...4bit版本听起来像是混了一段高频的噪音, 6bit版本混了一段稍微低频的噪音. 那就看看频谱分析:
似乎印证了自己的主观判断. 不过注意了, 6bit是之前让其线性增加计算得到的深度, 频谱图可以看出很接近真正的三角波了.
那么, "音质"提高了吗? 是, 也不是. 理论上, 你完全可以把三角波替换成钢琴音, 让模拟器发出钢琴的声音:
不过这完全是两个不同的东西! FC的伪三角波也一样, 和真正的三角波不是同一个东西, 我们可以调整算法让它逼近真的三角波, 也可以像上面那样提高深度. 但是, 有没有必要?
那么, 如果要做的话, 深度最高是多少? 344Hz下用6bit的, 序列长128. 算下来, 44100/1024 = 43.07
, 43Hz应该足够低了吧, 9bit, 1024序列. 再不济22Hz, 加倍而已.
噪声声道可以提高"音质"吗? 理论上可行, 因为之前了解到噪音实际上只有32种音色, 我们完全可以去找32种音质更好的样本即可.
当然还有的方法是再发明一个伪随机算法, 不过难度很高.
DMC声道呢, DMC用来播放样本数据, 我们不可能无成本的增加信息, 能做的最多就是降降噪什么的. 不过由于ΔPCM的特殊性, 只有1bit的Δ编码, 自己猜测应该可能通过样本分析, 还原出更好的PCM样本.
方波虽然只有0和1, 但是通过一些手段还是能生成很不错的声音. 包络生成器, 扫描单元.
我们可以通过对ADSR的模拟让声音听起来更加自然点, 即ADSR包络模拟. 还有就是更加平滑的频率扫描.
由于目前的实现是: 处理上一个事件. 那么, 反而因为这个特殊性, 实现上述"增强音质"的手段更加简单! 如果按照周期实现反而不方便"预测".
有针对视频的滤镜自然也就有针对音频的滤波器, 不过都叫"filter"罢了.
根据nesdev的记载, NES本身就搭载了滤波器:
由于看不懂电路图, 这里就假定是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的伪三角波来说, 整体音量都减少了, 高频噪音还是能听到. 那就就行频谱分析:
很明显低音部分几乎没了, 但是高频部分由于大部分在截止频率下面, 所以不大明显(14kHz太高了几乎听不见, 感觉就像是一个电路保险, 22kHz采样率还能听的情况下完全没用了).
NES是这样的, FC似乎就不同了, 详细的可以查看nesdev的说明, 所以感觉应该提供选项给用户关闭滤波.
那么正式进入这一步, 由于几乎是在末端修改的, 同上一步核心部分没有修改.
滤镜部分由于是在最末端的着色器实现的, 所以又升级了图像接口, 音频滤波是用CPU实现的, 所以音频接口没有修改.
图像接口:
main_cpp
, 就是shader的文件地址程序一开始会要求输入shader的地址, 已经预编译至common文件夹, 可以随便选一个:
void main_cpp(
const char* shader_bin_file_name,
const char* shader_res_file_name
);
.
而音频方面, 滤波器虽然实现了但是默认没有打开, 需要的同学们可以在submit_now_buffer
函数中将注释去掉即可.
数数这一步的内容:
终于, 进入本步骤的最后一篇——音频可视化. 已经到E了, 16进制已经快装不下了, 预示着《Re: 从零开始的红白机模拟》也快结束了.
说到音频可视化, 可能就不得不提到傅里叶老人家. 不过在这里, 可能是见不到他老人家了——因为我们能拿到更基础的信息
可视化的目的就是为了直观的了解到相关'信息', 那么音频方面信息有哪些呢?
结合实际情况, 我们需要对音高、音色、音量等信息进行可视化.
音高反映的是频率, 频率可视化自然就是映射到键盘上——
例如440Hz就是A4(A440标准), **C大致是262Hz. 键盘上是以'3白夹2黑 + 4白夹3黑'作为一节, 然后向两个方向扩展开来.
白键从左到右依次叫做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}
现在自然要做出逆运算:
/// <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. 再具体, 可能需要进行四舍五入转成整型方便查表, 注意:
// 浮点转整型实现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];
也就是负数需要特别注意.
颜色生成
这里颜色生成是通过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之类的基于波形信息的, 其余声部的音色其实已经预定好了, 我们需要的就是展现其内在信息.
例如方波就展示其'占空比'这一属性.
不少声部都支持音量的调制.
例如看出橙色的方波#1占空比是25%, 音量大约是11. 绿色的方波#2占空比是75%, 音量约8.
2A03
f = CPU / (16 * (t + 1))
f = CPU/(32*(t + 1))
VRC6
f = CPU / (16 * (t + 1))
f = CPU / (14 * (t + 1))
(rate*6)>>3
, 5bit. 可能是线性值VRC7
频率调制:
49716 Hz * freq
F = -----------------
2^(19 - octave)
注: 使用FM功能另算.
音量:
FDS1
f = CPU * current_picth / 65536 / 64
master_volume=2/2, 2/3, 2/4, 2/5
volenv_gain
会被钳制到32MMC5
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
f = CPU / (2 * 16 * Period)
f1 = CPU / (2 * 256 * Period)
f2 = CPU / (2 * 256 * Period * 2)
多试了几个NSF, 在播放FDS时, 某些NSF音调上还是有点问题, 这个感觉找不到原因了, 这个BUG估计得留下来了.
N163, FDS1, 以及VRC7都可以算是通过给定波形进行指定频率输出. 当然还可以算上半个DMC. 对于这种我们可以显示原始波形在一旁作为参考.
N163和FDS1的波形是可以直接获取的. 但是VRC7的不能. DMC的这里暂时不考虑, 如果长度1kb的话就有8k个样本了.
获取VRC7波形
我们再来回顾一下VRC7声道的控制位:
LLLL LLLL
Channel low 8 bits of frequency--ST OOOH
Channel sustain (S), trigger (T), octave (O), high bit of frequency (H)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;
这样生成的波还是有点问题, 也就是粗糙模拟而已. 如果精确模拟的话可能太花时间了.
补充说明: 这里VRC7有一些不太影响的'小错误', 下面是后面补充的. 果然小细节就容易忘记! VRC7的音量是'衰减值', 15是最小音量, 0是最大音量. 所以单位变成负数了 -3.00dB x N
.
然后自己又想到一个稍微精确的波形计算, 更简单但是需要一点额外的运算(也不多, 和上次一样128样本, 只不过是每帧都需要):
/// <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的衰减和调制器!
减少DrawCall是提示图形显示效率的最直接的办法. 这里, 如果相邻的两条线段斜率一致则可以进行合并.
这样称为'实时版', 不存在时间的问题, 可以在游戏的时候也能打开, 最后大致结果就是:
现在就要发挥目前框架优势了, 目前这个项目的优势为:
接口意味着允许'动态更换'.
事件驱动本来是自己对于音频的一种实现手段, 并且精度比不上基于周期的'次等手段', 不过本身就包含了音频事件信息, 并且没有真正处理音频.
非单例, 几乎每条函数都自带了一个sfc_famicom_t*
参数, 这本来是'累赘'.
这是FT的界面
我们可以看出有一条'时间线', 下方的就是未来的事件, 上方的就是之前的事件. 是时候发挥框架优点了: 变废为宝.
前面提到的优势就是: 我们可以同时运行两台虚拟机, 使用不同的接口. 一个跑在前面, 仅仅处理音频事件并且记录下来, 后面的才是真正的播放.
时间线实际上可以表示很多(几乎所有)东西, 但是出于简单化, 考虑仅仅处理音量和音高.
其中音高值得注意的是, 作曲家可能会手动模拟FM——也就是'抖音'. 频率在一个音符范围内来回摆动, 这样在时间线是看不出区别的——都是一个音. 所以可以打上FM的标记, 键盘上的波浪线'~'就比较合适.
音量用线段表示就行. 而对应的AM暂时不考虑, 背景可以从折线看出来.
目前没有做优化, 每次都是从现有数据生成的. 这种最末端的代码, 自然是放在最后优化, 不然一改就傻了
这样称为'预载版', 只能在播放NSF时才能启用. 并且, 再配合'即时读档'功能时需要特别注意:
.
大致是这样的, 不过还是太简陋了, 不过反正仅仅是随便写的, 还没有正式释出.
还有一种比较典型的解决方案就是:
这种也是一种解决方案, 比如可以用颜色表示声道, 宽度表示音量, 位置就是音高了. 不过自己还是希望看一下'谱子', 而不是'键位'.
这就本步的最后一节, 音频可视化. 这一步统称'高级音频支持'. 本步骤项目地址:
((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
优化成一次了. 所以, 膜还是很花时间的, 大家不要随便膜.
上一节很遗憾地没有实现DMC声道, 原因有一个: 使用Mapper0的游戏会用ΔPCM吗? 毕竟又没办法提前知道ROM到底用没用DMC
现在先实现:
MMCx是任天堂自己开发的MMC, 任天堂自己开发的MMC是海外版(即NES)能够使用的MMC, 日本本土可以使用厂商自己生产的.
根据数据库, MMC1(在自己看来)比较有名的游戏, 比如:
其中当然是双截龙比较适合测试Mapper, 流程还行. 我才不知道什么修改$0505, $0506可以修改时间呢.
切换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
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至低地址)
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屏模式, 你这用一屏也太抠门了吧
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.
4bit0
-----
CCCCC
|||||
+++++- Select 4 KB CHR bank at PPU $1000 (ignored in 8 KB mode)
8KB无视掉
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会对高地址进行写入:
// 写入高地址
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
这里列出了自己常用的一些c/c++小技巧, 有些会有不足, 可以简单探讨一下.
分类: 小技巧
自从有了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
, 这个和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
当然还有很多其他用法, 有时间再介绍吧.
分类: 小技巧
C里面转换数据类型会使用'C风格转换', C++上自然不太安全, 所以增加了几种xxxx_cast
. 由于职责不同, 有时会会让代码看起来很长. 这时候可能会使用'C风格转换', 但是有些时候用这个还是没有办法方便地进行转换.
比如前面提到用struct
模拟的类型, 用传统的转换的话, 很有可能会出现'指针的指针的指针'什么的非常容易绕晕.
这时候可以考虑使用万能的union
转换.
union {
T1 type1;
T2 type2;
} data;
data.type1 = foo;
return data.type2;
可谓是'万能转换'!
写模拟器当然要了解使用到的汇编, 不管是调试模拟器还是模拟器调试都需要.
前面知道了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
对应多个机器码, 区别是寻址方式的不同, 现在先列举所有的寻址方式以及格式, 详细的解释在下一节.
INS A
INS
INS #$AB
INS #$ABCD
INS #$ABCD, X
INS #$ABCD, Y
INS #$A
INS #$A, X
INS #$A, Y
INS ($ABCD)
INS (#$A, X)
INS ($AB), Y
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 },
// 下略
};
就能反汇编了:
_Alignas
之类的明明C++那边实现了, C这边却没有有了数据反汇编就太简单了, 核心部分应该是尽可能少使用外部函数. 这里可以用snprintf
之类的格式化函数, 但是毕竟核心部分, 自己还是自己手写了像格式化位16进制字符串.
这里, RESET执行的第一个指令是: SEI
, 即 'Set I flag'
这里列出了自己常用的一些c/c++小技巧, 有些会有不足, 可以简单探讨一下.
分类: 小技巧
同理可以用于其他位, 比如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很好用, 但是不是零代价的. 不过对象大小是在编译器是固定(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, 而是非标准布局.
本文编写以及具体实现中, wiki的'MMC5'页面居然进行了修改(主要是针对BANK切换的说明进行了修正).
MMC5的Mapper编号就是005, 还是有不少的游戏(NesCartDb记录24款, 有美版日版之分). 不过用到MMC5扩展音源的只有:
其中值得一提的是, 金属之光被称为画面最好的FC游戏, 毕竟是可视小说(VN)类型. 其ROM大小是1MB, 在那时肯定是巨无霸水平的.
不过虽然只有少数用了扩展音源, 不过不像VRC7, MMC5没用上的游戏卡带还是拥有相应的硬件(至少FC版).
MMC5支持切换RAM-BANK, 这对于目前架构来说又是致命的, 只能再想想. 但是这不是最致命的, 最致命的是信息缺失.
例如《大航海時代》, 数据库中表明其拥有一个电池供电支持的16kb WRAM(SRAM). 一般地, 原始iNES文件头有:
亦或者NES 2.0的文件头:
但是自己手上的大航海時代ROM这两个字节均是0. 也就是说ROM信息不够完整, wiki给出的解决方案是: 假定为64kb大小.
MMC5性能非常地强大:
PRG mode 0:
PRG mode 1:
PRG mode 2:
PRG mode 3:
PRG-BANK 最后一个8kb BANK一定是ROM.
CHR mode 0:
CHR mode 1:
CHR mode 2:
CHR mode 3:
CHR-BANK这里倒是没有什么.
7 bit 0
---- ----
xxxx xxPP
||
++- Select PRG banking mode
大部分游戏使用的是模式3(除了恶魔城3-美版, 用了模式2). 暗荣的游戏从来不写入该寄存器, 可知默认是模式3.
7 bit 0
---- ----
xxxx xxCC
||
++- Select CHR banking mode
金属之光使用的是模式1, 其他的使用的是模式3
7 bit 0
---- ----
xxxx xxWW
||
++- RAM protect 1
D1D0位必须是'10'(2)才能正常写入. 需要结合$5103.
7 bit 0
---- ----
xxxx xxWW
||
++- RAM protect 2
D1D0位必须是'01'(1)才能正常写入. 同样需要结合$5102.
7 bit 0
---- ----
xxxx xxXX
||
++- Specify extended RAM usage
ExRAM@$000-$3BF:
ExRAM@$3C0-$3FF:
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
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;
}
8位均用于'填充模式'的图块编号
低2位用于'填充模式'的属性位, 实际填充的的是:
color = value & 3;
color = color | (color << 2) | (color << 4) | (color << 6);
可以具体平台使用位运算或者查表.
不在PRG空间内, 无效.
7 bit 0
---- ----
xxxx BBBB
||||
++++- PRG RAM bank number at $6000-$7FFF
+--- Select PRG RAM chip
这个就比较复杂了, 这就是前面提到的信息不完整. 就目前而言有以下几种情况:
D2位表示哪个'chip'. wiki建议始终假设为64kb, 然后根据这4bit(3bit)载入偏移数据, 因为2x8kb模式是写入'100'而不是'001'.
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;
7 bit 0
---- ----
RBBB BBBB
|||| ||||
|+++-++++- PRG ROM bank number
| ++++- PRG RAM bank number
| +--- Select PRG RAM chip
+--------- RAM/ROM toggle (0: RAM; 1: ROM)
7 bit 0
---- ----
RBBB BBBB
|||| ||||
|+++-++++- PRG ROM bank number
| ++++- PRG RAM bank number
| +--- Select PRG RAM chip
+--------- RAM/ROM toggle (0: RAM; 1: ROM)
7 bit 0
---- ----
RBBB BBBB
|||| ||||
|+++-++++- PRG ROM bank number
| ++++- PRG RAM bank number
| +--- Select PRG RAM chip
+--------- RAM/ROM toggle (0: RAM; 1: ROM)
7 bit 0
---- ----
xBBB BBBB
||| ||||
+++-++++- PRG ROM bank number
似乎启动时是往$5117写入$FF, 也就是最后8kb RPG-BANK载入最后一个BANK.
前面提到了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编号.
7 bit 0
---- ----
xxxx xxBB
||
++- Upper bits for subsequent CHR bank writes
当使用1kb模式时, 最多只能访问256kb的CHR-ROM, 要访问整个1024kb就需要这两位了. 不过唯一一个超过256kb CHR-ROM的金属之光却使用的是4kb模式. 换句话说就是没有一个游戏使用了这个机能(甚至连初始化都没有).
模式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获取相应信息, 而本来的图样表几乎完全是为精灵服务的.
高精度的模拟器应该是模拟读取过程, 不过作为中精度的模拟器可以直接拿渲染开刀.
待补充
目前只有宇宙警备队 SDF使用了该模式, 这个模式目前不想实现(懒), 等待以后实现吧.
用于指定扫描线id来触发IRQ, 内部比如写入$04会在第5条可见扫描线开始时触发. 写入0应该是触发不了IRQ的. 由于是基于扫描线的, 所以应该只会在可见扫描线触发相关同步操作.
目前的Ez模式下, 本身自己是在每条扫描线最后触发水平同步的, 所以就是应该写入多少就在第几条触发.
7 bit 0
---- ----
Exxx xxxx
|
+--------- IRQ Enable flag (1=IRQs enabled)
写入仅仅用来启用/关闭IRQ功能, 即时关闭也能在本来可以触发IRQ情况将'Pending'置为1(当然不会触发IRQ).
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时也会清除.
这就是那个16位乘法器了, 写入会进行乘法运算. 读取时, 低地址读取地址, 高地址读取高地址.
其他还有一些就不介绍了
由于地址部分连续, 部分离散, 所以只好直接用case了, 让编译器自己优化.
switch (address)
{
case 0x5100:
// ...
case 0x5101:
// ...
case 0x5102:
// ...
// ...
};
read_low
, 读取[$4020, $6000), 这部分区域会调用该接口. 在正常情况下, 与之前的PRG段快速访问优化不冲突.
目前是使用的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有点烦, 干脆去掉了. 完全根据大小信息判断:
由于使用了MMC5-ExGrafix模式, 这个背景显示老是有问题. 以为是ExGrafix模式实现有问题, 结果发现是因为很久没碰渲染层导致逻辑忘了.
目前的实现每个像素有效位为低5位: [xxxA BCDE], 其中E是判断是否为全局背景: E = C | D
, CD是图样的低两位, AB是属性位, 自己还以为是[xxxx ABCD]模式.
MMC5的扩展音源相对其他来讲很简单——因为实际上已经实现过了: 和2A03的相关声道几乎一致. 对比起VRC6来, 没有锯齿波也罢了, 方波还没有什么特色.
所以在NSF创作者看来, MMC5扩展音源毫无特色. 用上MMC5基本上意味着已经没有其他扩展音源的可用了——MMC5仅仅是陪衬品.
与2A03的方波区别:
第二个方波, 同上, 由于没有扫描单元, 完全一致(2A03的两个方波在扫描上有所区别——反码与补码).
7 bit 0
---- ----
Ixxx xxxM
| |
| +- Mode select (0 = write mode. 1 = read mode.)
+--------- PCM IRQ enable (1 = enabled.)
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.
7 bit 0
---- ----
WWWW WWWW
|||| ||||
++++-++++- 8-bit PCM data
读取模式会忽略缩写内容, 写入$00不会影响输出(PCM值可能为0啊, 需不需要实现?)
有兴趣的可以自行了解.
类似$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切换写入的内容是一样的, 也就是实际上没有利用这一机能.
简单来说有两个问题:
return
了.第一个BUG目前来说不可能修改, 原因是因为储存的是调色板索引, 不能表示'黑色'. 第二个BUG和之前的IRQ中切换BANK一样, 有两种解决方案:
目前来看, 这两种解决方案的选择应该是: 两个都要(我全都要)! 可以用来解决目前渲染的3个已知BUG, 交给后面完成吧(懒)!
MMC5近乎20款游戏中, 这里仅仅测试了两款——大航海时代与金属之光.
大航海时代使用了ExGrafix模式, 但是仅仅是方便程序猿进行场景布置, 而没有渲染出令人信服的画面.
金属之光: MMC5的三个图像加强的功能, 金属之光实际都没用上(大概), MMC5的额外音源也仅仅用来放文字'哔哔'音效(大概). 可以看出这游戏似乎一开始可能并没有打算使用MMC5.
总的来说MMC5是一款非常强大的MMC, 导致模拟器大幅度地进行调整, 甚至在渲染层打洞来支持MMC5特性. 由于精力原因目前还没有完全模拟: Split模式与'黑科技'模式没有实现, 打上'TODO'等待以后慢慢补上.
(RAM写入保护也没有实现, 填充模式实现了, 但似乎没用过导致没法测试)
为了让这个测试ROM能够通过, 我们加入手柄输入试试吧.
一般来说最少得有1个手柄, 不然只能看着马大叔被板栗弄死.
最高可支持4个手柄输入, 比如这个款游戏:
油管 - 4 Player Madness - RC PRO-AM 2 (NES)
但是是通过特殊装置接驳上去的.
先支持普通双手柄吧, 最后完善测试再说其他情况.
根据文档, 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的一个函数... 对于这里输入来说, 是一种重置手段:
也就是说读取按钮状态应该这样:
按钮序列如下:
读取大概就是这样:
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,
};
按下'I'键(Start)就能测试了. 历时这么久, 终于把基础指令测试通过了! 即便没有实现BRK和CLI!
同时, 也宣告着这个ROM的使命完成了. 感谢ROM作者kevtris.
这一节很简单! 主要当然只是实现了基本的输入而已. 项目地址Github-StepFC-Step5
这里列出了自己常用的一些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对于c++来说是一个小技巧, 对于c来说几乎没必要或者说实际上处处都能用上. 即 pointer to implement
, 自己会酌情使用pimpl:
// 头文件
struct CImpl;
class CPimpl {
CImpl* m_pImpl;
};
// 源文件
// 具体实现
struct Impl
int a;
};
这样目的就是隐藏实现, 合理使用可以变相地提高编译速度.
分类: 隐藏实现, 提高编译速度
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
允许在不同源文件声明地不一致. 这也极大地方便了我们.
分类: 编码技巧
如果自己的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++小技巧, 有些会有不足, 可以简单探讨一下.
分类: 小技巧
有一个可能很常用的操作, 或者说函数: 平均数. 通过基础数学我们可以知道(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上实现, 主要是允许free
掉aligned_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
)与析构(调用析构函数
), 对于"内部优化"来说, 是允许的.
在国内因为外观被称为红白机的任天堂FC(欧美版叫NES), 或者说Famicom(典型的日式英语缩写), 可谓是自己走向编程开发道路上不可缺少的一环. 可能有许多和我类似的因为玩上游戏, 就想开发一款自己的游戏, 而踏进这个圈子的(然后在开发的道路越走越远).
虽然这么说, 但是实际上玩的是国内生产的兼容机. 作为童年不可忽略的一环, 后有诗赞曰
小霸王骑了吴琼
这里, 就让我记录FC开发模拟器部分细节, 也就是Re: 从零开始的红白机模拟.
其中在大学的时候, 用C++11做了一款FC的模拟器, 已经完整地模拟了CPU, 然后...就没有然后了. 不过这个项目了解到使用到的C++特性很少, 所以这里重新用C在做一次, 这一次当然是完整地模拟FC. 这也是标题Re: 的由来, 才不是Neta!
这也是自己第一个核心用C写项目
开局一款编译器, 代码全靠捡. 这系列博客属于笔记性质, 所以写得很详细方便以后查看. 也要求读者拥有基本的能力, 例如C, 多媒体之类的.
(笔者喜欢的一款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显存就能演绎出童年的色彩!
开始在这里写博客了, 国内各种要求绑定手机, 我写个没人看的文章至于么Orz.
终于进入了所谓的"本番", 通过阅读文档, 可知CPU拥有下列的寄存器:
大致写为
// 状态寄存器标记
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].
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++;
累加器寻址 Accumulator
操作对象是累加器, 个人认为可以被划分至下面一条隐含寻址, 只是语法稍微不同
$0A
ASL A - (累加器A内容按位算术左移1位)
指令伪C代码
// 空
隐含寻址 Implied Addressing
单字节指令, 指令已经隐含了操作地址
$AA
TAX - (将累加器中的值传给 X 寄存器, 即 X = A)
指令伪C代码
// 空
立即寻址 Immediate Addressing
双字节指令, 指令的操作数部分给出的不是 操作数地址而是操作数本身,我们称为立即数(00-FF之间的任意数)
在6502汇编中,这种模式以操作数(即立即数)前加 "#" 来标记.
$A9 $0A
LDA #$0A - (将内存值$0A载入累加器, 即 A = 0x0A)
指令伪C代码
// 指令计数器的地址即为当前需要读取的地址
address = pc;
// 双字节指令(获取操作码已经+1)
pc++;
绝对寻址 Absolute Addressing 又称直接寻址
三字节指令, 指令的操作数给出的是操作数, 在存储器中的有效地址
$AD $F6 $31
LDA $31F6 - (将地址为$31F6的值载入累加器, 即 A = [$31F6])
指令伪C代码
// PC指向的两个字节解释为地址
address = READ(pc++);
address |= READ(pc++) << 8;
零页寻址 全称绝对零页寻址 Zero-page Absolute Addressing
双字节指令, 将地址$00-$FF称之为零页, 绝对寻址中如果高字节为0, 即可变为零页寻址, 直接能够节约一个字节, 速度较快, 所以经常使用的数据可以放在零页.
$A5 $F4
LDA $F4 - (将地址为$00F4的值载入累加器, 即 A = *0x00F4)
指令伪C代码
// PC指向的一个字节解释为地址
address = READ(pc++);
绝对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;
绝对Y变址 Absolute Y Indexed Addressing
三字节指令, 同5, 就是把X换成Y而已
指令伪C代码
// PC指向的两个字节解释为基地址
address = READ(pc++);
address |= READ(pc++) << 8;
// 加上Y变址寄存器
address += Y;
零页X变址 Zero-page X Indexed Addressing
双字节指令, 同5, 如果高地址是0, 可以节约一个字节.
指令伪C代码
// PC指向的一个字节解释为零页基地址
address = READ(pc++);
// 加上X变址寄存器
address += X;
// 结果在零页
address = address & 0xFF;
零页Y变址 Zero-page Y Indexed Addressing
双字节指令, 同7, 就是把X换成Y而已
指令伪C代码
// PC指向的一个字节解释为零页基地址
address = READ(pc++);
// 加上Y变址寄存器
address += Y;
// 结果在零页
address = address & 0xFF;
间接寻址 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);
间接X变址(先变址X后间接寻址): Pre-indexed Indirect Addressing
双字节指令, 比较麻烦的寻址方式
$A1 $3E
LDA ($3E, X)
这种寻址方式是先以X作为变址寄存器和零页基地址IND(这里就是$3E)相加IND+X
, 不过这个变址计算得到的只是一个间接地址,还必须经过两次间接寻址才得到有效地址:
例如:
地址 | 值 |
---|---|
X | $05 |
$0043 | $15 |
$0044 | $24 |
$2415 | $6E |
这条指令将被如下执行:
指令伪C代码
// PC指向的一个字节解释为零页基地址再加上X寄存器
tmp = READ(pc++) + X;
// 读取该零页地址指向的两个字节
address = READ(tmp) | (READ(tmp + 1) << 8);
间接Y变址(后变址Y间接寻址): Post-indexed Indirect Addressing
双字节指令, 比较麻烦的寻址方式
$B1 $4C
LDA ($4C), Y
例如:
地址 | 值 |
---|---|
$004C | $00 |
$004D | $21 |
Y | $05 |
$2105 | $6D |
这条指令将被如下执行:
指令伪C代码
// PC指向的一个字节解释为零页地址
tmp = READ(pc++);
// 读取该零页地址指向的两个字节作为基地址
address = READ(tmp) | (READ(tmp + 1) << 8);
// 基地址再加上Y寄存器
addres += Y;
相对寻址: Relative Addressing
该寻址仅用于条件转移指令, 指令长度为2个字节.
第1字节为操作码,第2字节为条件转移指令的跳转步长, 又叫偏移量D. 偏移量可正可负, D若为负用补码表示.
$F0 $A7
BEQ $A7 - (如果标志位中'Z'-被设置, 则向后跳转-39字节, 即前跳39字节)
指令伪C代码
// 读取操作数
tmp = READ(pc++);
// PC加上有符号数据即可
address = pc + (int8_t)tmp;
注意, 这里并没有进行条件判断
红白机音乐格式 NES Sound Format (.nsf), 可以认为就是储存红白机上音乐的音乐格式, 也是第E步的主要实现目标.
在真正介绍NSF之前先介绍Mapper-031, 这个Mapper简单地说就是实现了NSF的一个子集.
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. 这就导致没法处理了. 解决方法大致有两个:
自己权衡了大概3秒钟, 决定将窗口大小降到4kb. 由于BANK数量从8涨到16了, 下次如果遇到2kb的窗口, 估计就得选择2了. 这一个小改动会牵一发而动全身, 首当其冲的自然是状态储存. 所以升级到'1.1'版了, 而且由于没有正式释出, 所以懒得兼容'1.0'版本.
由于这次修改了PRG的BANK窗口大小, 让自己找到了一个BUG, 这个BUG说大不大, 说小也不小:
这样干会丢失D3, D4位的信息, 不过自带RAM刚好2kb, 对于自带的RAM反而是防止越界的好'操作', 所以连测试ROM都没能测试出来.
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区, 目前这部分的逻辑是:
完全没有处理这部分, 所以只能再增加一个这区域的写接口: 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作为结果演示:
同时带上了测试用ROM结果:
那就从NSF的扩展音源最低位开始吧, VRC6有两个变种:
当然所述为日版(FC), NES版由于老任的策略不能使用自己搭载扩展音源的卡带.
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);
}
7 bit 0
---------
.... PPPP
||||
++++- Select 16 KB PRG ROM at $8000
16*16=256
7 bit 0
---------
...P PPPP
| ||||
+-++++- Select 8 KB PRG ROM at $C000
8*32=256
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
.
为了方便描述, 将$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");
}
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被禁止的话什么都不会干.
这部分才是本篇的本体! 很多NSF作者很喜欢VRC6, 原因是增加两个方波和一个锯齿波, 得到了加强但是又不至于太过分.
Base | +0 | +1 | +2 | +3 |
---|---|---|---|---|
$9000 | 方波1占空比-音量 | 方波1周期-低字节 | 方波1周期-高字节 | 频率缩放 |
$A000 | 方波2占空比-音量 | 方波2周期-低字节 | 方波2周期-高字节 | --- |
$B000 | 锯齿波音量 | 锯齿波周期-低字节 | 锯齿波周期-高字节 | --- |
由于NSF的存在, 自己将音频数据放在了核心部分, 而不是依靠Mapper保存.
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
. 考虑不用支持?
7 bit 0
---- ----
MDDD VVVV
|||| ||||
|||| ++++- Volume
|+++------ Duty Cycle
+--------- Mode (1: ignore duty)
7 bit 0
---- ----
..AA AAAA
++-++++- Accumulator Rate (controls volume)
7 bit 0
---- ----
FFFF FFFF
|||| ||||
++++-++++- Low 8 bits of frequency
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
进行判断.
这里简单实现为:
这算是一个新东西, 不过和三角波相似, 甚至在函数表述上比三角波还简单.
* *
* *
---*------*------
* *
* *
* *
同样是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
这里简单实现为:
就连wiki上面介绍VRC6输出的口吻也是比较含糊的, 主要有两点:
根据之前DAC中给出的线性逼近的公式: pulse_out = 0.00752 * (pulse1 + pulse2)
, 可知线性因子大概是0.00752
, 由于不太确定, 可以根据情况上下浮动.
E: 0 -> 1
时, 重置索引/累加器状态(红框处应该有一个蝙蝠状敌人)
同之前的MetalMax, 由于目前精灵是在最后一起渲染的, 导致中途切换BANK会让精灵使用错误的图样表BANK渲染
还有一个小问题就是, 这个画面中, 背景会来回1像素上下跳动, 这个应该是IRQ精度问题
这节是指令实现的最后一节, 前面介绍了几乎所有的指令, 现在就做具体实现
说说之前有提到的"页面边界交叉", 或者简单理解为"跨页"
条件转移语句很简单:
之前提到了"+1s", 与其说是"+1s", 不如说是"-1s". 会发生在下列寻址方式:
如果指令不进行写操作的话, 会进行优化: 本页面读取就会少一周期, 也就是"不如说的缘由". 同样也是扩展指令中"读改写"没有额外周期的说法 —— 因为已经自带了. 基础指令的写操作STA
之类的, 没有额外的周期也是同样的原因.
说了这么多, 但是在STEP3中没有实现 —— STEP3中没有意义. 但是在需要同步的时候, 意义就非常大了, 必须要实现.
具体实现就一个大case
然后从00写到FF? 图样! 把寻址和指令分开介绍的原因当然是:
适当使用宏可以大幅度美化代码, OP宏实现如下:
// 指令实现
#define OP(n, a, o) \
case 0x##n:\
{ \
const uint16_t address = sfc_addressing_##a(famicom);\
sfc_operation_##o(address, famicom);\
break;\
}
由于累加器寻址, 并没有"地址", 所以单独实现了: 可以看到截图中有些指令是4个字母的, 这些是累加器寻址的单独实现.
有请主角测试用ROM——"nestest.nes". 之前了解到这个ROM的RESET向量是$C004.
Emulator tests中提到:
Start execution at $C000 and compare execution with a log
也就是说, 我们把初始PC设为$C000, 就可以和现有的LOG就行比较:
比如执行JMP然后LDX......这就是一个非常棒的参考, 自己实现顺序也是这个:
如果从00开始实现指令, 就感觉背单词从abandon开始一样, 很没劲儿.
可以利用宏大幅度简化代码:
// 寄存器
#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"了 也就是可以完成这部分了. 虽然还有很多指令没有实现, 甚至BRK
和CLI
也没实现!
接下来就是Mapper3.
可以看出这个浓眉大眼的Mapper居然和Mapper000一样支持16KB或者32KB的RPG-ROM, 太丢脸了, 褪裙吧.
好在CHR-ROM支持得比较多.
根据数据库,CNROM(在自己看来)比较有名的游戏, 比如:
看来这次是一秒16发男(高桥名人)的主场. 冒险岛初代自然可以用来测试.
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
现在有这么一个玩意, 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中我们保存了当前的帧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大法.
虽然很简单, 看是看着回放自动运行, 还是有点成就感!
作为TAS用模拟器, 还是太初级了, 首先应该记录TAS录制的次数, 最好还要显示时间, 还有有放慢功能方便微操, 以及追踪变量的功能方便随机变成必然!
这一次继续吧: Mapper02.
UxROM这就比较厉害了, 能够用到最高用到4MB的RPG-ROM.
但是没有CHR-ROM, 需要自己写入CHR-RAM.
根据数据库,UxROM(在自己看来)比较有名的游戏, 比如:
这个设计就很暴力了, 我喜欢
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
先说解决方法, 需求后面再说. 构造函数可以有自定义的, 析构函数只有一个. 而且不能直接获取构造/析构函数的指针.
其中C++允许调用其析构函数, 可参考calling destructor explicitly和Destructors (C++).
所以这样就可能间接获取析构函数的函数地址了(T是模板):
// release the object
void delete_obj(void*p) noexcept { static_cast<T*>(p)->T::~T(); }
那么构造函数呢, 当然也可以直接调用, 不过这不是标准行为:
对于不允许的我们可以使用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大致有两种方法:
std::function
第一个就懒得说了. 说说第二个, 滥用模板的话会造成模板膨胀这是自然的, 所以为了轻量化, LongUI实现了一个不会模板膨胀的Vector(但是目前还没用过, 所以暂时被冷藏了)
内部使用的是类似虚表的实现, 所以如果保存的是轻量级对象, 比如说是智能指针这种重点是RAII的, 添加删除的效率将会比较低, 主要体现在:
当然这是轻量级的影响比较大, 重量级比如std::string重点是保存的数据这类, 基本没有太大影响. 当然由于LongUI目前使用的全是POD::Vector, 不用调用构造/析构. 这个类还没用过Orz
打通了游戏赶紧再写一篇.
这里列出了自己常用的一些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的封装
, 推荐看一下怎么实现的.
这里的重点就是, 如果容器很大几率容量很小很小, 再加上可能会动态添加, 建议内嵌节点, 自己实现链表结构.
分类: 调试技巧
微软的VS系列IDE提供.natvis的文件方便程序猿进行调试:
如果都像左边那样, 这个自己写的容器就没法用了. 不过可以通过添加自定义.natvis文件就能显示自己需要的数据.
不过这里是让大家'怎么显示指定精度浮点':
例如颜色用3位小数就足够了, 但是默认会显示很长很长, 很容易显示不完整.
.
实现起来简单但是比较麻烦, 就是用的转成整数再膜'10'(10进制):
具体natvis怎么编写可以参考微软官方的介绍:
其实FME-7并不是NSF扩展音源所支持的芯片名称, 而是其变种——Sunsoft 5B. Sunsoft FMT-7, 5A以及5B共用一个Mapper编号——69. 由于格式原因, 程序中FME7代指Mapper069, 包括(甚至特指)Sunsoft 5B, 但是其实FME7是5B的子集.
比起其他厂商, 例如NSF就有提到的科乐美, 任天堂以及南梦宫. Sun电子似乎进入21世纪后就逐渐淡出游戏和行业了. 翻了一下发行游戏列表, 近10年就发行了几款, 不过似乎在吃老本——还有当初FC很好玩的《超惑星战记》的续作.
Sun电子在FC时代出了很多不错的作品, 但其实在这里都不重要, 重要的其编曲水平之高. 例如仅仅使用2A03的《RAF世界》, 这样一个高编曲水平的开发商的Sunsoft 5B又如何呢?
《Gimmick!》, 感觉和 拉格朗日点 很相似——唯一一个使用了独立扩展音源的游戏, 一个是VRC7, 一个是5B.
但是其实 吉米克! 也没有完全了利用5B的机能——没有使用到噪音发生器和包络发生器.
wiki介绍FME7支持到512kb的PRG-RAM, 这是目前来看最多的PRG-RAM, 但是自己一个一个看了目前的游戏列表, 最多的也就是8kb的WRAM.
对于PRG-RAM来说, 目前还是直接声明在结构里面, 直接增加512k的PRG-RAM感觉没有什么必要. 如果真的需要实现, 这部分最好使用动态申请, 然后ROM-RAM区分需要从1bit提高到2bit用来区分这部分的RAM(并且, 目前没有一些游戏内运行时错误的处理, 可能需要使用long_jmp
).
FME7与其他的MMC有所不同的是, 先将命令写入命令寄存器($8000-9FFF), 再把参数写入参数寄存器($A000-BFFF).
7 bit 0
---- ----
BBBB BBBB
|||| ||||
++++-++++- The bank number to select for the specified bank.
8个就是1kb的BANK. 注意防止溢出的操作.
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再写入的话有点危险.
7 bit 0
---- ----
..bB BBBB
|| ||||
++-++++- The bank number to select for the specified bank.
用于切换$8000-$9FFF, $A000-$BFFF, $C000-$DFFF的BANK. 注意防止溢出的操作.
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
就行了.
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计数器的低8位
IRQ计数器的高8位
Sun电子出品的《蝙蝠侠》难度比较高, 反正小时候没通关就是了, 还是老问题, 让蝙蝠侠变成字母侠了:
游戏问题
有2个问题, 模拟出现问题(瑕疵), 但是使用其他模拟器也是同样的:
FME7写入高地址有两个空缺的位置, 就是为音频准备的. 硬件方面是Yamaha YM2149F(出现了, 又是雅马哈)的一种.
7......0
----RRRR
++++- The 4-bit internal register to select for use with $E000
7......0
VVVVVVVV
++++++++- The 8-bit value to write to the internal register selected with $C000
寄存器 | 位域 | 说明 |
---|---|---|
$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)
方波(square), 之前的2A03也提到, 真正的应该叫做脉冲波(pulse), 其中50%占空比的称为方波. 由于wiki整篇没有提到占空比, 所以5B发出的应该就是真正的方波. 其中, 方波的01的'交替频率'自然是上面的两倍.
如果包络使能位设为1, 音量由包络控制, 否则输出自身的音量.
噪音发生器利用$06通过的5bit周期生成一个1bit的随机波.
Frequency = Clock / (2 * 16 * Period)
Period = Clock / (2 * 16 * Frequency)
// 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)
每个Ramp又被细分成32步, 对应的就是'音量'的改变.
左边部分就是对应的固定音量. 右边部分则是包络使用的, 可以看出:
也就是可以用两个LUT, 反正要用, 不差这一个.
通过写入$0D能够重置包络, 然后从4个参数选择其形状:
值 | 续 | 起 | 换 |持| 形状
-------|-----|-----|-----|--|------------
$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]
).
不过桌面平台应该是自己这种实现要快一点(缓存友好).
可以看出难点仅仅在于包络发生器的模拟, 其他的非常简单.
// 输出
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;
还能够简化, 但是不想动脑筋了.
这个很恼火.
开始一切正常, 但是进入游戏就一上来就死. 一步一步追踪, 老是以为IRQ实现有问题(为什么自己老是以为是IRQ有问题???).
然后发现CPU$173储存了数据, 但是老为0. 一步一步发现读取了WRAM区域, 吉米克! 将ROM切换到这里了. 最后的最后发现是把 PRG Bank 0 ($8) 的RAM/ROM位看反了:
看成了
...
最后, 自然还有老BUG(游戏状态栏一部分用精灵拼的). 看来FC游戏都喜欢在IRQ中切换BANK.
自己用的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的三角波差不多.
听起来像......噪音..? 不好意思, 声音开得太大了
蓦然回首, 已经可以几乎完全播放NSF了. 也快进入本步骤的尾声了, 这一步似乎过于长了.
'标准音源'在这里指的是最多有一个扩展音源的那种: exsound
为0(2A03音源), 或者说有一位为1(有一个扩展音源), 可以用这个判断:
(exsound & (exsound - 1)) == 0;
当然这里只是指出这个位计算技巧而已, 实际上还是需要一个一个判断. 在这种情况下, 可以直接将写入操作'嫁接'到原来的写入就行(当然, 需要注意NSF的BANK切换寄存器).
自然就是NSF支持的, 理论上, 在实机上也能播放(不然支持多音源就没有意义了!), 例如NicoNicoNi的这一篇:
从右往左分插了:
就可以在实机上播放NSF(同一个作者的另一个视频就另有FDS). 一般地, 对于'标准'(最多一个), 可以使用对应的镜像地址. 不过多种混合就支持最低地址(镜像地址重合).
VRC6
VRC7
FDS1
MMC5
N163
FME7
作为软件播放器, 支持最低地址就行(当然支持所有镜像地址也是不错的), 在实机上由于地址互相重叠, 可能会使用一些技巧.
之前使用的权重标准是基于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
.
0.2583 / 30 * vrc6
0.1494 * 2.4 / 63 * fds1
0.00752/16
换成0.1494/225
即可当然, 除了wiki直接给出的, 还有一个参考: NSFe. 这个是NSF的扩展版本. 里面有相关的比较(单位是百分之一dB):
0.1494*2.4*3
, 几乎和1.0
相差无几, 索性看着1.0.VRC7 产生的伪方波:
NSFe虽然支持这个, 不过基本就是针对N163设计的, 其他的几乎就是用实体卡带简单测试一下, 不过作为参考足够了.
这样可以试试这个2A03+VRC6+FDS1+MMC5+FME7
的NSF了!
之前 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篇 提到自己的实现是'声道模式', 输出最后处理的几个历史输出. 使用的是'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是没有曲子的长度信息的, 我们可以进行估计:
比如很想支持Seek功能, 这里提供一个思路:
load_addr + length
没有对齐BANK. 之前弄到一个NSF文件, 15.3kb, 理所当然认为占用了4个BANK(4kb), 但是实际上用了5个BANK. 典型的辣鸡.
上面就是一些核心支持, 下面就是一些具体实现, 可以略过.
// 将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的锯齿波之前提到了, 由于寄存器宽度问题, 音量(其实叫做rate, $B000)超过42就会导致失真. 所以可以考虑为寄存器增加宽度防止失真.
双精度浮点可是可以将uint32_t
也能完整地保存, 采用双精度浮点模拟VRC7的话音质应该是最高的了.
之前提到VRC7内部几乎以50kHz运行, 我们需要进行重采样. 这里就回答之前提到的问题: 为什么其他的没有进行重采样.
答案很简单, 实现困难(1.79MHz->44.1kHz), 收益很低(不会故意放出超声波, 只有较弱的高次谐波).
而VRC7的50kHz, 一是固定的, 二是和使用的采样率比较接近, 于是就特意指出了. 而最好的重采样算法自然就是"不用重采样", 我们将VRC7的运行频率变得和当前使用的采样率一样的话, 自然就不需要了.
如果直接暴力变频自然是, 声音音调不对. 这一点就需要内部部件和VRC7同时变频即可. 这一点需要双精度浮点支持, 不然误差太大反而弄巧成拙.
比如采样率是44.1kHz, VRC7降低了约十分之一, 内部声道的频率增加十分之一, FM/AM速度也增加十分之一就行.
这个和VRC7差不多, 由于声道多了, 1~2声道应该不错, 3声道和目前输出采样率差不多, 也行. 所以可在超过3声道时, 给N163'超频': 把声道频率降低, N163频率提高这样应该可以有效解决杂音问题.
同样'变频'这个概念可以推广在其他音源上.
那么进入第四步, 背景的渲染. 终于进入关于图像渲染的步骤了, 这开始后就加快节奏了, 图形API的部分会略过, 因为可能读者拥有自己习惯的图形API, 这里采用的是D2D 1.1, 至少需要Win7的平台更新或者Win8.
如果没有自己熟悉的图形API的话, 这里介绍一下"黑匣子"函数, 就像printf
那样, 不需要了解实现细节, 只需要知道它是干什么的就行. 这里, 自己将这些称为黑匣子函数:
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()
为了更为方便地调试, 即时存档/读档的接口SFC_NO_SL
进入正题, 关于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 镜像 |
FC理论能显示64种颜色(使用某种技巧的话):
自己使用的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个也就是需要4位, 换句话说一个像素仅仅需要4位来描述, 就目前而言:
4位数据 -> 调色板索引 -> 输出颜色
但是, 实际上当最低俩位是0的时候:
也就是说虽然有16个, 但是有1/4的用不到:
名称表就是用来排列显示背景的. 背景每个图块是8x8像素. 而FC总共是256x240即有32x30个背景图块.每个图块用1字节表示所以一个背景差不多就需要1kb(32x30=960b). 为了实现1像素滚动, 两个背景连在一起然后用一个偏移量表示就行了:
就像马里奥这样, 红框就是当前屏幕显示(不是**, 而是两侧两部分合起来).
虽然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的精灵, 两个图样表都要用上了.
还记得大明湖畔, 呃不, 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的数据复制到精灵内存 |
目前需要的:
以及VRAM读写相关的寄存器
PPU地址空间也同CPU地址空间使用BANK处理, 之前在Mapper000中应该提到了. CPU的BANK目前是8KB, PPU则更为细腻一点: 1KB. 这1KB导致了一些BUG到最后才发现.
个人认为是因为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;
}
}
有了读写函数, 实现起来就很简单了:
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;
读取也是类似的, 其他寄存器按照表格, 有些不着急实现.
现在我们就可以绘制背景了.
从图样表中了解到, 一个字节对应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, 所以我们目前渲染逻辑如下:
实际编码中, 不小心让NMI跳向RESET向量(Ctrl+C, V大法好), 图像老是有问题...一步一步反汇编发现是NMI实现错了, 差点弃坑.
编程中小问题总是会引发大麻烦.
这就是这ROM左上角屏幕的显示了, 正好是显示内容.
这一步就进入关于精灵的渲染, 精灵(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坐标 |
要点:
IIII IIII
使用图样表是$0000,而奇数则为$1000, 然后读取32字节memcpy
, 比手动实现的快对于第二点的额外说明:
同背景一样的渲染逻辑, 不过这次就以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叫做"color_test", 作者rainwarrior.
有了背景渲染的经验, 这次同样很简单, 但是细节部分就比较困难了, 比如8x16, 两个方向的翻转. 这次DEMO工程里面就没有实现8x16和垂直翻转, 只实现了水平翻转.
当然, 这个ROM也测试了加强颜色位, 个人认为这个算是特殊效果应该由高层渲染实现, 而不是核心层实现, 所以这里没有去实现
项目地址: Github-StepFC-StepF.
不光如此, 本系列博客还有Markdown文件位于GitHub-StepFC-Blog供离线查看.
StepF的目的是成为一个真正的模拟器, 本文由于时间关系不能面面俱到. 但是很多功能可以参考其他的成熟的模拟器, 再进行实现.
这一个项目是自己第一次全部(核心部分)使用C来写, 原以为会异常繁琐, 没想到写得很顺畅(有些地方还很爽). 这就不难理解一些人用C++仅仅作为C with class了.
不过类型安全还是感觉挺重要的(经常忘记打上&, 就被警告寻址类型不同, 只能打开编译器的'警告视为错误'了).
如果写入ROM自然会被硬件忽略, 同时不少硬件拥有针对RAM的写保护, 这部分自己是还没有实现的, 这一点需要特别注意.
文件头的一般格式, 这两个文件头的魔法码(magic code)都是一样的, 这就比较麻烦了, wiki上有判断的方法.
同样适合NSF vs NSFe文件(不过就好多了), NSFe有时间信息, 可以知道曲子多长.
之前提到的会在这里简单讨论一下, 但是看了一下, 没有必要进行技术上的讨论, 这里就简单讨论一下法律风险.
除开像我们大陆这边, 以'学习为目的'几乎可以随便弄而不用承担法律风险, 在例如美国之类的国家针对模拟器还是有相当的限制的.
例如PS2的模拟器PCSX2, 要求使用该模拟器时, 使用自己dump自己PS2的BIOS文件. 这一点完全是规避法律风险.
FDS同理, FDS模拟中有一些'BIOS call', 需要模拟BIOS. 这一点还是建议像其他的模拟器一样, 要求用户自己dump自己FDS的BIOS. (用户的BIOS怎么来的我们不用管)
NES/FC还是有很多的扩展输入的, 之前我们实现了基础的标准输出, 绝大部分游戏可以模拟了. 还有一些可以实现的输入方式, 这里简单列举一下:
精确模拟肯定是每个模拟器的目标, 这个被自己称为'中精度模拟器'的项目, 自己的打算是在此精度上尽可能地更为精确地模拟.
还有最高的基于'CPU周期'的精度, 这样精度是最高的, 可以完全按照周期进行硬件的可视化.
或者称为'汉化工具'就知道其具体用途了, 当然不一定是'语言本地化', 还可以进行简单的ROM-HACK.
比如所谓的'玛丽十代', 就是'成龙之龙'换了一个马里奥的'皮肤':
(在网上看解说, 一群人提到这个'玛丽十代'. 不过自己小时候玩的是'原版', 感谢良心的盗版商)
这里就是用fceux的类似工具截图:
核心部分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
. 但是因为这个样和其他音频事件格格不入就没有这么实现.
之前切换BANK时, 为了防止溢出是这么操作的:
load_bank(bank_id % bank_counter);
自己想了一下, 可以采用向上取整到最近的2次幂对齐数字, 然后减1作为掩码:
load_bank(bank_id & bank_mask);
虽然看似把MOD换成了AND, 实际上bank_counter可能是0, 每次都要判断是不是0也烦.
这就是作弊了, 简单地说就是修改某地址的数据. '锁定'数据可以实现为高频率(比如就利用帧序列器的240Hz)写入同一个数据.
不知道大家有没有玩过'魂斗罗力量'(背景是讲雇佣兵的, force也可以译作'军队')没有:
这游戏的一大特点就是卡顿明显, 我们可以针对不同的游戏进行'特化'. 比如发现用户在游玩这款游戏时, 可以以非强制性地提醒用户'是否修复魂斗罗力量的卡顿问题'.
如何判断可以利用CRC校验, 如何修复简单地说就是'超频'. 具体在哪里超频可以具体玩游戏时进行检查(避免等待垂直空白时超频), 超频会带来相当多的副作用, 不过目前的实现是'中精度模拟器', 反而超频没有多大的影响(基于CPU的IRQ的Mapper其实还是基于扫描线实现的).
之前提到自己用SSE重写了渲染逻辑, 这就是'平台优化'. 虽然C是跨平台的, 但是优化还是根据具体平台具体实现.
这是自然的, 用户是一般用户, 自然是需要实现一个图形接口. 之后, 自己会使用自己的LongUI实现图形化界面. 可能会将步骤记录下来作为博客, 不过重点将是GUI程序的编写.
现在依然有不少人会写一些简单的NES程序, 作为模拟器自然可以提供一个调试环境. 即便是没人用(╮( ̄▽ ̄)╭ ), 这样能够全方位地提高自己的技术水平
与人一起玩自然是很不错的, FC本身就支持本地的多人游戏. 但是现在能凑在一起玩是很困难的, 于是这部分可以使用网络联机实现.
不过自己几乎没有任何网络联机程序的经验(只有学校里面的简单测试), 只好作罢.
这也是后期主要维护的内容, 更多的Mapper能够游玩更多的游戏, 并且如果遇到类似MMC5这样'黑科技'的芯片也能感叹: 还能这样玩?!
FC上面有一个'重启键', 所以FC的重启分为软重启(按下重启键)和硬重启(插上电源). 还有一些, 比如在'网球'里面玩一会之后, 接着玩'马里奥'可能会出现错误关卡的BUG. 因为网球的某数据和马里奥的关卡数据使用的是同一个地址.
可以在CPU power up state, PPU power up state分别查看 CPU/PPU 上电状态.
本节标题'全部成为F'自然也是有原因的. 程序中一开始会将系统自带的RAM清零, 但是不少模拟器会使用$FF
清空, 也就是全部成为'F'. FC里面按下'重启'键的目的往往是为了安全关闭游戏, 既是结束, 也是开始.
全文完
接下来进入本次的主角 Mapper004:
MMC3 可谓是最开始几个Mapper中相当占分量的Mapper. MMC3是少数可以触发IRQ的Mapper之一.
MMC6也是用同一个mapper编号, 内部逻辑大致相同.
根据数据库,MMC3(在自己看来)比较有名的游戏, 比如:
MMC3的IRQ最大的用处, 就是处理的分割滚动效果(状态栏在下面)
MMC3根据地址拥有4对寄存器
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给出了详细的情况列表, 可以自行查看, 编码时直接抄就行了
7 bit 0
---- ----
DDDD DDDD
|||| ||||
++++-++++- New bank value, based on last value written to Bank select register (mentioned above)
7 bit 0
---- ----
xxxx xxxM
|
+- Nametable mirroring (0: vertical; 1: horizontal)
这个就很简单了
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了, 很重要的一环
7 bit 0
---- ----
DDDD DDDD
|||| ||||
++++-++++- IRQ latch value
IRQ闩锁, 用于指定一个重载值. 当计数器归零后, 计数器会重载这个值. 这个值会在每根扫描减少, 降至0就触发IRQ.
由于内部的原理是通过PPU巴拉巴拉, 所以要在背景精灵启动渲染才会触发倒计时, 否则运行超级马里奥3会程序会挂掉.
IRQ重载, 写入任意值会让计数器归0
IRQ禁止, 写入任意值标记禁止并且确认挂起的中断
IRQ使能, 写入任意值标记使能
这次功能就需要新的一个接口, 需要在每条扫描行进行一次同步, 我们就把它叫水平同步好了
// 水平同步
void(*hsync)(sfc_famicom_t*);
由于目前的是EZ模式, 我们在每次可见扫描线结束后进行一次同步, 即水平同步. 计数器减到0就触发一次IRQ.
关于IRQ触发更为详细的细节请查看原文.
本次的实现比较重要, 也明显比前面几个代码量更大.
本次先介绍的4种mapper就介绍完毕
同样, '流程指令'是指为了和上节分开而自己随便取的名字.
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
绝对 | JMP Oper | 4C | 3 | 3 |
间接 | JMP (Oper) | 6C | 3 | 5 |
无条件跳转, 影响FLAG: (无), 伪C代码:
PC = address;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
相对 | BEQ Oper | F0 | 2 | 2* |
* +1s 跳转同一页面
* +2s 跳转不同页面
当然, 如果没有实行跳转则花费2周期, 下同.
如果标志位Z(ero) = 1[即相同]则跳转,否则继续, 影响FLAG: (无). 伪C代码:
if (ZFLAG) PC = address;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
相对 | BNE Oper | D0 | 2 | 2* |
* +1s 跳转同一页面
* +2s 跳转不同页面
如果标志位Z(ero) = 0[即不相同]则跳转,否则继续, 影响FLAG: (无). 伪C代码:
if (!ZFLAG) PC = address;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
相对 | BCS Oper | B0 | 2 | 2* |
* +1s 跳转同一页面
* +2s 跳转不同页面
如果标志位C(arry) = 1[即进位了]则跳转,否则继续, 影响FLAG: (无). 伪C代码:
if (CFLAG) PC = address;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
相对 | BCC Oper | 90 | 2 | 2* |
* +1s 跳转同一页面
* +2s 跳转不同页面
如果标志位C(arry) = 0[即没进位]则跳转,否则继续, 影响FLAG: (无). 伪C代码:
if (!CFLAG) PC = address;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
相对 | BMI Oper | 30 | 2 | 2* |
* +1s 跳转同一页面
* +2s 跳转不同页面
如果标志位S(ign) = 1[即负数]则跳转,否则继续, 影响FLAG: (无). 伪C代码:
if (SFLAG) PC = address;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
相对 | BPL Oper | 10 | 2 | 2* |
* +1s 跳转同一页面
* +2s 跳转不同页面
如果标志位S(ign) = 1[即正数]则跳转,否则继续, 影响FLAG: (无). 伪C代码:
if (!SFLAG) PC = address;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
相对 | BVS Oper | 70 | 2 | 2* |
* +1s 跳转同一页面
* +2s 跳转不同页面
如果标志位(o)V(erflow) = 1[即溢出]则跳转,否则继续, 影响FLAG: (无). 伪C代码:
if (VFLAG) PC = address;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
相对 | BVC Oper | 50 | 2 | 2* |
* +1s 跳转同一页面
* +2s 跳转不同页面
如果标志位(o)V(erflow) = 0[即没有溢出]则跳转,否则继续, 影响FLAG: (无). 伪C代码:
if (!VFLAG) PC = address;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
绝对 | JSR Oper | 20 | 3 | 6 |
跳转至子程序, 记录该条指令最后的地址(即当前PC-1, 或者说JSR代码$20所在地址+2), 影响FLAG: (无), 伪C代码:
--PC;
PUSH(PC >> 8);
PUSH(PC & 0xFF);
PC = address;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | RTS | 60 | 1 | 6 |
JSR逆操作, 从子程序返回. 返回之前记录的位置+1(话说为什么不直接存+1的地址), 影响FLAG: (无), 伪C代码:
PC = POP();
PC |= POP() << 8;
++PC;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | NOP | EA | 1 | 2 |
啥都不干, 居然两个周期, 太丢NOP的脸了, 褪裙吧.
助记符号: PUSH (PC+1); PUSH (P); I = 1; PC = IRQ;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | BRK | 00 | 1 | 7 |
详细指令周期: |
强制中断, 记录当前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;
寻址模式 | 汇编格式 | OP代码 | 指令字节 | 指令周期 |
---|---|---|---|---|
隐含 | RTI | 4D | 1 | 6 |
从中断返回, 影响FLAG: 是的, 伪C代码:
P = POP();
// 无视BIT4 BIT5
RF = 1;
BF = 0;
PC = POP();
PC |= POP() << 8;
不过说到男猛攻南梦宫, 南梦宫在05年和万代合并了, 发行的游戏感觉上比较偏向于宅系. 为N163分配的Mapper编号是019, 同一个编号的还有N129. 但是存在很多的submapper的情况——这个情况还是到wiki查看吧. 使用了了N163扩展音源的游戏有10个. 例如:
总览来看, 值得注意的是:
目前的存档信息:
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);
写入数据:
写入地址:
令人吃惊的是允许将内置2kb的VRAM(CIRAM)接入图样表作为CHR-RAM.
7 bit 0
---- ----
.MPP PPPP
||| ||||
|++-++++- Select 8KB page of PRG-ROM at $8000
+-------- Disable sound if set
64*8=512
, D6是禁止位
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位很强
7 bit 0
---- ----
..PP PPPP
|| ||||
++-++++- Select 8KB page of PRG-ROM at $C000
没什么好说的.
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
这个写入保护有点烦人, 或许到现在自己还没有实现或许还是一个好主意(为懒人正言).
7 bit 0
---- ----
IIII IIII
|||| ||||
++++-++++- Low 8 bits of IRQ counter
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音源(如果有的话)使用的就是内部的128字节数据.
这个地址还有另一个用处: 为之后写入内部RAM的数据指定地址.
7 bit 0 (write only)
---- ----
IAAA AAAA
|||| ||||
|+++-++++- Address
+--------- Auto-increment
D7:I 为真的话, 写入或者读取'Data Port'地址会递增. 由于是在'Audio'里面的, 所以将储存在N163音源端, 而不是Mapper端.
为内部RAM写入或者读取数据, 并且根据$F800:D7是否自动+1.
7 bit 0
---------
FFFF FFFF
|||| ||||
++++-++++- Low 8 bits of Frequency
7 bit 0
---------
PPPP PPPP
|||| ||||
++++-++++- Low 8 bits of Phase
7 bit 0
---------
FFFF FFFF
|||| ||||
++++-++++- Middle 8 bits of Frequency
吃惊, 别的芯片频率设置用11bit, 12bit, 这用16bit还不够.
7 bit 0
---------
PPPP PPPP
|||| ||||
++++-++++- Middle 8 bits of Phase
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);
7 bit 0
---------
PPPP PPPP
|||| ||||
++++-++++- High 8 bits of Phase
24bit的相位数据决定了当前波的相位. 每次声道更新时, 18bit的频率数据就会添加到24bit的相位数据上.
写入这三个相位寄存器会立即重置相位. 对于开发者来说最好先将频率置0, 不然写入3个寄存器之间存在时间差(虽然很短就是了), 可能中途会更新(不禁想起了线程安全).
7 bit 0
---------
AAAA AAAA
|||| ||||
++++-++++- Address of waveform (in 4-bit samples)
7 bit 0
---------
.CCC VVVV
||| ||||
||| ++++- Linear Volume
+++------ Enabled Channels (1+C)
'C'位只有$7F是有效的, 其他的对应地址是无效的.
波形信息以一个样本深度为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:
但是声道数量在6以上时, 这个时候几乎已经达到了人类可听的频率了. 其中就算放出01方波, 频率会再除以2以至于完全可闻.
这里自己就用'3声道模式'. 这里说3声道模式, 并不是指仅仅模拟3声道, 而是每次将最后处理的3个声道(接近40kHz)进行输出, 当然, 这时候采样输出不能低于40kHz(所以作为参数放入N163, 假设支持22kHz就用'6声道模式').
Sum(hist, n) / n / 16 * N
// 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看上去有8个声道简直逆天, 但是实际上拥有很大的限制:
N163的编写感觉一帆风顺, 除了一两个丢人的小问题, 比如Mapper编号19的16进制不小心弄成'15'.
都说了'一帆风顺'. 不过一开始....
一开始就花屏了, 由于只打开了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);
}
这段调用放在最后. (具体是精度还是本来就是这样, 等待验证)
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 ]
之前用的是非常暴力(?)的同步方法, 同步精度大致在帧, 自己称之为"低精度同步". 现在就要使用更加精确的同步了, 同步精度大致在扫描行, 自己称为"中精度同步". 还有最高的基于各个部件周期, 精度大致为次像素, 称为"高精度同步". 不过高精度同步会消耗较大的计算资源, 不在本博客范围内, 有兴趣的读者可以自行实现.
之前提到了每个指令需要消耗多少多少周期, 现在就需要用了. 固定周期的直接加就行:
// 指令实现
#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变址"这三个有些没有额外的一周期这里用大小写区分:
(绝对X/Y变址也是同样的)
截图中可以看到有SFC_READ_PC
和SFC_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.)
之前提到有些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
的无所谓了.
在实现之前, 先看一下之前使用的一张图片:
之前提到了红框是当前的屏幕偏移量, 那有没有什么奇怪的地方?
就是左上角的分数显示! 因为直接显示这样分数会错位的. 这就是FC程序的小技巧, 屏幕刷新时写入VRAM指针. 大致时机可以分成两部分:
其中VBlank期间访问VRAM是合法, 而刷新时访问是不合法的. 但是可以利用写入$2006(VRAM指针)这个制作出一些‘滚动’效果(或者$2005, 但是PPU内部实现表明其实是一回事), 而真正的访问VRAM - $2007(VRAM数据) 就太非法了, 模拟难度较高——需要高精度同步.
一般利用这个实现之前提到的滚动方式, 被称为'分割滚动'(split-scrolling)
有这么两个标志位, 会在渲染中途才会被设置, VBlank结束后会清空. 这两个标志位一般来作为'分割滚动'的动作的转折点.(不过实际上溢出很少有游戏会使用).
所以一些游戏(比如超级马里奥):
超马1的那个金币下边就是精灵0, 等到触发#0命中, 然后程序猿掐到大概状态栏显示完了(还有一行), 就恢复滚动.
除了这两个还有通过硬件的IRQ(比如超级马里奥3利用Mapper触发IRQ, 状态栏在下面; 甚至还有的ROM是通过APU的IRQ), 后面再说
简单地说就是精灵#0, 如果某像素不是透明的, 然后背景也不是全局背景色(即低2位都有效时)会触发, 详细的说明(细节)还是请到引用连接查看. 目前的实现, 很多细节是通过不了全部测试的.
不过一般游戏, 要么就是用来分割屏幕, 要么就不用, 实际上就懒得完全实现.
简单地说一条扫描线需要渲染超过8个精灵时, 触发溢出. 详细的说明(细节)还是请到引用连接查看. 目前的实现, 很多细节是通过不了全部测试的
不过一般游戏, 基本就不用(就几个游戏在用), 实际上就懒得完全实现.
VRAM指针($2006)在渲染时可被描述为:
位 | 描述 |
---|---|
0-4 | 水平偏移量, 以图块作为单位(8像素) |
5-9 | 垂直偏移量, 以图块作为单位(8像素) |
A-B | 名称表的索引($2000, $2400, $2800, $2C00) |
C-E | 额外的(微调)垂直偏移量 像素(0-7) |
F | 忽略 |
PPU scrolling中提到的这几个内部寄存器, 应该是PPU本身的内部实现, 但是我们不用太拘束, 只要理解每个位表示什么就行:
我们先实现一个简单的模式来模拟一帧(场), 也就是"中精度同步". 并且基于'同时只能显示25色'的假设(虽然可以在渲染时修改调色板以达到超过25色的可能, 也就是说这种情况是不支持的), 也就是, 和之前的实现差不多.
根据说明, 我们可以这么实现:
'执行CPU一段时间'是多久呢, 根据文档Clock rate, NTSC的Master Clock是'21.477272 MHz', 除以60, 再除以 262.5大概是1364周期.
CPU频率是Master的12分之一, 113.5. 为了避免小数, 我们用 Master Clock作为基准就行.
现在刷新的频率是和模拟器环境, 也就显示器, 的刷新频率是一致的. 好在自己电脑就是60Hz无所谓, 如果是144Hz显示器的话速度就会很快. 这个由于自己显示器就是60Hz所以一直没在意, 一直没改, 后面再说吧.
现在就是把背景和精灵合在一起了, 如果我们把全局背景色理解为清除色的话, 就很简单地理解了:
合成:
看起来很简单, 但是花的时间非常多, 大概花了一个星期. 完成倒是很早就完成了, 完成后就是去通过ROM测试, 发现通过不了就再修改, 反反复复, 直到测试通过到一定程度(没有完全通过), 非常花时间.
当然, 实现了精灵的8x16, 两个方向翻转, 但是在处理P精灵时存在小BUG, 这个因为打算后面用顶点去渲染, 有Z坐标的话就很方便了.
有些细节依然没有实现:
实现了之后, 自己用SSE处理了背景渲染的部分, 因为背景相对来说是对齐的, 就像数组那样, CPU使用明显降了不少.
不过精灵部分没有用SSE实现, 因为没有对齐. 通过对雪碧拉罐(spritecan)的ROM测试:
可以看出雪碧(sprite), 哦不, 精灵(sprite)的渲染占了整个核心的6%(1.28 / 20.37), 换句话说就算精灵渲染优化到0(效率提升无限倍), 也只有6%的核心提升, 全局甚至只有1%. 所以懒得优化了.
是的, 这次的测试ROM是spritecans, 这个ROM使用的是8x16精灵模式.
被誉为天才程序猿的岩田聪在FC上开发了一款伪3D赛车游戏 - F-1 Race. 目前模拟效果如下:
可以看出这个游戏是算准了CPU周期然后执行水平偏移实现的伪3D(大神就是能在处理游戏逻辑的同时掐准CPU周期写入偏移量). 也可以看出目前的同步率不够高(基于行, 一旦错过则该行错位), 看看以后能不能解决(比如一旦写入偏移则通知渲染层).
顺带一提这个游戏是分奇数帧和偶数帧的, 处理不当会导致画面闪烁.
岩田聪在奇数帧中更新转速(RPM), 偶数帧中更新距离(DIS)
本系列博客几乎全部资料来源是nesdev.com
根据本人习惯, 项目命名为StepFC, 简写SFC(感觉...和超任重名了). 当然, 读作模拟器, 写作仿真器(emulator).
然后就是很重要的声明: 由于精力和水平, 不能保证文章所述均为正确的
本篇可能能遇到下列名词:
既然是从零开始, 那就'零'开始吧. 一开始当然步子不能跨得太大, 不然.....
说到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 信息
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足够了
A declarative, efficient, and flexible JavaScript library for building user interfaces.
🖖 Vue.js is a progressive, incrementally-adoptable JavaScript framework for building UI on the web.
TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
An Open Source Machine Learning Framework for Everyone
The Web framework for perfectionists with deadlines.
A PHP framework for web artisans
Bring data to life with SVG, Canvas and HTML. 📊📈🎉
JavaScript (JS) is a lightweight interpreted programming language with first-class functions.
Some thing interesting about web. New door for the world.
A server is a program made to process requests and deliver data to clients.
Machine learning is a way of modeling and interpreting data that allows a piece of software to respond intelligently.
Some thing interesting about visualization, use data art
Some thing interesting about game, make everyone happy.
We are working to build community through open source technology. NB: members must have two-factor auth.
Open source projects and samples from Microsoft.
Google ❤️ Open Source for everyone.
Alibaba Open Source for everyone
Data-Driven Documents codes.
China tencent open source team.