前言
Game Boy最初是任天堂在1989年推出的8位掌机,其后又推出了GBP、GBL、GBC、GBA等衍生机型,以1.2亿台的总销量成为世界上最畅销的掌机系列。很多第三方厂商也抓住机会,针对Game Boy生产了各种神仙级别的外设(具体可参考AVGN第147集)。个人最感兴趣的是其中相机和打印机两款外设。要知道,在那个手机都不普及的90年代,一台能随时拍照还能随时打印的游戏机是多么黑科技般的存在!
Pocket Printer 与 Pocket Camera
大概一个月前,博主有幸搞到了两台日版相机外设(Pocket Camera)和全新的打印机外设(Pocket Printer)。
由于初代Game Boy屏幕分辨率为160X144且只有四阶灰度,所以这相机的成像质量仅仅是能看的水平。不过20年前60刀的价格真的不能再期待什么啦。
相机卡带自带RAM,可存储几十张照片。将Game Boy主机与Pocket Printer通过通信线连接后就可以打印照片了。这台打印机就是普通的热敏打印机,随机附带的热敏纸已经过期了,打印出来惨不忍睹,而且我找遍了淘宝也没找到尺寸相符的热敏纸。这倒也不是什么大问题,将咕咕机的热敏纸手动裁成3.7cm左右的宽度就能塞进去了。注意这个宽度不能多也不能太少,否则根本吐不出来,甚至卡死在里面。一切就绪后,下图是我用相机拍摄电脑屏幕后打印出来的效果:
除了相机之外,这台打印机还支持不少游戏。比如你可以将《精灵宝可梦 金/银》中的图鉴打印出来。
基本功能都测试过后,下面是搞事情环节了。打印机与主机之间使用Game Boy的通信线缆交换数据,那是不是可以用诸如树莓派的硬件模拟这个通信的过程,实现捕获图像数据(要知道初代GB实机截图是很麻烦的)或者自由打印图像?
模拟打印机接受图像
开干之前,首先要解决的是解析打印机与主机间的通信方式。
Game Boy 通信
前面提到过打印机通过通信线缆连接到主机,好在这种线缆还没成古董,淘宝上仍能以较低价格买到。从线缆插头可以看出其共有6个Pin
按照下图的方式依次编号:
___________
| 6 4 2 |
\_5__3__1_/
各个引脚的功能可归纳为:
Pin编号 | 功能 | 线缆颜色 |
---|---|---|
1 | VCC | Orange |
2 | SOUT | Purple |
3 | SIN | Green |
4 | SD | Yellow |
5 | SCK | White |
6 | GND | Red |
(注意这里的线缆颜色是我手动测出的,不同厂家生产的规格不同,推荐自己用万用表测一遍以便备用)
Pocket Printer并没有使用到Game Boy的所有通信特性,所以本次模拟不需要使用全部的引脚,只需要2/3/5/6号就行了。
Game Boy通信的双方分为主从机两方,本次实验中GB为主机,打印机为从机。主机通过SOUT
端口向从机发送数据,并通过SIN
端口接受从机的响应。除此之外,主机还需要向SCK
端口输出时钟信号,主从机通信时会共用这一个信号。
通信数据以字节为单位从高位到低位传输。可以想象主从设备上各有一个八位移位寄存器,每接受到一位就将其从低位移入。SIN
和SOUT
可同时发送和接受(但在本次模拟的情况下不会出现这种情况)。比如,主机要通过SCK
向从机发送0x33
,这个过程SCK
和SOUT
的波形图可以大致表示为如下:
SCK
每次的下降和上升代表着一个时钟周期,从机在这个周期内读取SOUT
的值,以此进行下去就可以得到完整的字节。这也为我们的模拟提供了大致思路:以SCK
作为外部中断的信号,每次中断时都读取一次SOUT
的电平。
Pocket Printer通信协议
打印机与主机的通信以不定长的数据包为单位,并且大部分时间都是主机向打印机发送数据,打印机只会在每个数据包的最后两个字节的时钟内向主机发送ACK及自身状态码。
一个典型数据包的结构如下:
Byte 位置 | 0 | 1 | 2 | 3 | 4 | 5 | 6+X | 6+X+1 | 6+X+2 | 6+X+3 | 6+X+4 |
---|---|---|---|---|---|---|---|---|---|---|---|
大小 | 2 bytes | 2 bytes | 1 byte | 1 byte | 1 byte | 1 byte | 变长 | 2 bytes | 2 bytes | 1 byte | 1 byte |
描述 | 标识字节1 | 标识字节2 | 命令 | 压缩 | 数据长度(X) | 数据长度(X) | 正文 | 校验码 | 校验码 | ACK | 状态 |
GB发送 | 0x88 | 0x33 | 命令标志 | 压缩标志 | Low Byte | High Byte | |||||
打印机发送 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x00 | 0x81 | 状态标志 |
每个数据包的第一和第二个字节为固定的Magic Byte
用于标识数据包的开始;
第三个字节为命令标识符,具体可以分为:Initialize (0x01
), Data (0x04
), Print (0x02
), Inquiry (0x0F
) 四种。它们的具体含义为:
-
Initialize (
0x01
) 初始化指令每次打印任务开始时发送的以一个指令,一般数据长度为0。
-
Data (
0x04
) 数据传输指令传输打印页面数据的指令。一般情况下,每次打印的页面大小与GB主机分辨率相同,而打印数据会通过包含此指令的数据包传输,每个数据包的正文大小为640字节,共9个包。所有数据传输完毕后,GB主机还会发送一个正文长度为0的空数据指令,但其用途未知。
-
Print (
0x02
) 打印指令所有打印数据传输完毕后,发送此指令开始打印。正文长度一般为4字节,第一个字节固定为
0x01
,用途未知;第二个字节定义了不同打印页面间距;第三个字节定义了打印使用的色板;第四个字节为打印色彩浓度。 -
Inquiry (
0x0F
) 查询指令此指令的预期发送时间段不固定,Game Boy主机可能会在任何时间段发送此指令查询打印机状态。可以确定的是,在打印开始后,Game Boy主机会发送一连串查询指令查询是否打印完成。
第四个字节标志着数据正文是否被压缩,可取值为0x00
(未压缩)和0x01
(压缩),压缩算法为简单的游长压缩;
第四和第五个字节组合可表示16位的正文长度;
第六位字节开始为数据正文,其长度就是上两位指出的数据长度
第6+X+1和6+X+2位为16位的校验码,用于验证数据传输是否出错。其值为从命令字节开始所有字节的总和,每个字节视为8位无符号整形;
第6+X+3个字节为ACK共识。严格来说,校验码发送完毕后,一个数据包的发送就结束了,因为后面两个自己的内容是由打印机发给Game Boy的。发送ACK为模拟打印机的关键,如果Game Boy没能在第6+X+3个字节的周期内及时收到ACK,就会视为超时并在屏幕上显示“错误#2”。
第6+X+4个字节是由打印机发送给Game Boy的,用于标识自身状态,一般发送0x00
即可。
数据处理
前面提到过,每次打印的图像数据会装载9个640字节的数据包中传输,合计是5760个字节。拿到数据后要做的就是把它复原为图像。好在Game Boy发送的数据格式和其显存中的基本一致。Game Boy使用8x8的像素阵呈现图像,每个像素占用2位,可表示四阶灰度。在打印传输的数据包中,每次传输的640字节数据可以表示一大行图像,即160x14像素;这一大行又可分为两个小行,每行由20个8x8的像素阵组成。每个字节连通其后一个字节可表示像素阵中的一行像素(8个)。
像素阵: 图像:
.33333.. .33333.. -> 01111100 -> $7C
22...22. 01111100 -> $7C
11...11. 22...22. -> 00000000 -> $00
2222222. <-- 数字表示 11000110 -> $C6
33...33. 灰度值 11...11. -> 11000110 -> $C6
22...22. 00000000 -> $00
11...11. 2222222. -> 00000000 -> $00
........ 11111110 -> $FE
33...33. -> 11000110 -> $C6
11000110 -> $C6
22...22. -> 00000000 -> $00
11000110 -> $C6
11...11. -> 11000110 -> $C6
00000000 -> $00
........ -> 00000000 -> $00
00000000 -> $00
得到每个像素的灰度值后,一个可行操作是使用░
▒
▓
这种字符在命令行中将图像展示出来。
硬件连线
理解了通信协议后,就可以开始模拟了。我使用的方案是将Game Boy通信线缆剪断后,把其中各个Pin的导线焊接到公母口杜邦线上,这样就能很容易的连接到树莓派的GPIO引脚上了:
Pocket Printer的通信不需要使用全部引脚,只需要连接SIN
SOUT
SCK
GND
四个就够了。我的连接方式如下:
+------------+ +----------------------+
| | | |
| SIN +------------------>GPIO.2 (BOARD #13) |
| | | |
| SOUT+------------------>GPIO.7 (BOARD #7) |
| | | |
| SCK +------------------>GPIO.0 (BOARD #11) |
| | | |
| GND +------------------>GND (BOARD #9) |
| | | |
+------------+ +----------------------+
Link Cable Rspberry Pi
代码
我使用了C++下的wiringPi
库来实现模拟,因为Python的执行速度可能无法达到SCK
频率的要求而导致误码率增加。
代码及详细注释如下:
#include <stdio.h>
#include <errno.h>
#include <stdlib.h>
#include <wiringPi.h>
#include <iostream>
using namespace std;
#define SCK 0
#define SOUT 7
#define SIN 2
//存放收到的Byte
unsigned char b = 0;
//前八位为ACK,后八位表示一切OK的状态,每次数据包结束时发送
int ack[16] = {1, 0, 0, 0, 0, 0, 0, 1, 0,0,0,0,0,0,0,0};
//数据表序号
int packIndex=0;
//GameBoy packet基本信息
int dataLength = 0;
int byteIndex = 0;
int bitIndex = 0;
int checksum = 0;
bool waitForMagicByte = true;
int command;
int firstPrint=1;
int dataIndex = -1;
//Game Boy显示的行、块及坐标
int row,block,x,y=0;
//模拟Game Boy显存
string VRAM[160][320];
//四阶灰度的代替字符
string stringList[4] = {" ","░","▒","▓"};
//将像素表示写入模拟显存
void toVRAM(int sign){
VRAM[row*8+y][block*16+x] = stringList[sign];
VRAM[row*8+y][block*16+x+1] = stringList[sign];
x+=2;
if(x>=16){
x=0;
y++;
if(y>=8){
y=0;
block++;
if(block>=20){
block=0;
row++;
}
}
}
}
//将模拟显存转化为字符图像输出到终端
void outputImage(){
int mark1,mark2;
int subList[8]={128,64,32,16,8,4,2,1};
for(int i = 0; i < 9; i ++)
{
for(int j = 0; j < 640; j +=2)
{
for(int k=0;k<8;k++){
mark1=data[i][j]&subList[k];
mark2=data[i][j+1]&subList[k];
if(mark1==0&&mark2==0){
toVRAM(0);
}else if(mark1==subList[k]&&mark2==0){
toVRAM(1);
}else if(mark1==0&&mark2==subList[k]){
toVRAM(2);
}else{
toVRAM(3);
}
}
}
}
}
//处理每个字节
void handleByte(unsigned char b){
//高字节长度位
if(byteIndex==5){
if(b>0){
dataLength+=b*256;
}
return;
}
//低字节长度位
if(byteIndex==4){
if(b>0){
dataLength+=b;
}
return;
}
if(byteIndex==7+dataLength){
if(command==2&&b!=15){
ack[16]=1;
}
}
//命令位
if(byteIndex==2){
switch (b)
{
case 1:
cout<<"Initialize "<<endl;
break;
case 4:
command=4;
dataIndex++;
cout<<"Data "<<endl;
break;
case 2:
//如果发送可打印指令,就把图像输出在命令行中
//某些情况下Game Boy初始化前会发送一个打印指令,这里手动忽略掉
if(firstPrint!=1){
outputImage();
for(int i = 1; i < 160; i ++)
{
for(int j = 0; j < 320; j ++)
{
cout<<VRAM[i][j];
}
cout << endl;
}
}else{
firstPrint=0;
}
cout<<"Print "<<endl;
break;
case 15:
cout<<"Inquiry "<<endl;
break;
default:
cout<<"Unknown "<<(int)b<<endl;
break;
}
}
//记录数据正文
if(byteIndex>=6&&byteIndex<=6+dataLength&&command==4){
data[dataIndex][byteIndex-6] = b;
return;
}
}
//终端处理函数
void myInterrupt(void){
b = b << 1;
if (digitalRead(7)){
b = b | 1;
cout<<1;
}else{
cout<<0;
}
if(waitForMagicByte){
//判断是否读到Magic Byte
if((int)b==51){
byteIndex = 2;
bitIndex = 16;
dataLength=0;
ack[16]=0;
command=0;
waitForMagicByte=false;
cout<<"found magic byte"<<endl;
}
return;
}
//发送ACK及自身状态
if (bitIndex >= 63+dataLength*8 && bitIndex < 72+dataLength*8){
digitalWrite(2, ack[(bitIndex - (63+dataLength*8))]);
}
//是否产生了新的Byte
if((bitIndex+1)%8==0){
handleByte(b);
byteIndex++;
}
bitIndex++;
//清空计数器,准备下一个新包
if(byteIndex>=10+dataLength){
byteIndex = 0;
bitIndex = 0;
dataLength=0;
waitForMagicByte=true;
checksum = 0;
cout<<"Packet:"<<++packIndex<<endl;
}
}
int main(void){
if (wiringPiSetup() < 0)
{
fprintf(stderr, "Unable to setup wiringPi: %s\n", strerror(errno));
return 1;
}
pinMode(7, INPUT);
pinMode(0, INPUT);
digitalWrite(2, HIGH);
digitalWrite(7, HIGH);
pullUpDnControl (BUTTON_PIN, PUD_DOWN);
if (wiringPiISR(BUTTON_PIN, INT_EDGE_RISING, &myInterrupt) < 0)
{
fprintf(stderr, "Unable to setup ISR: %s\n", strerror(errno));
return 1;
}
while (1){
cout<<flush;
delay(20000);
}
return 0;
}
最终效果
为了更好的效果,我调小了终端字体大小和颜色。抽了一个周末,拿着相机在学校里随便拍了怕,把他们恢复为完整的四阶灰度,效果如下:
已知问题
这次的模拟不能算是完美,还有很多的问题,其中最严重的就是误码率。不知道是因为树莓派本身的问题、还是线缆焊接的问题,数据传输的误码率出其的高,甚至有时没法完整传输两个Magic Bytes。所以程序里判断数据包开始时只用了第二个Magic Byte。及时成功传输了一个包,要保证全部9个数据包都被识别到还是要拼运气的,平均下来打印五六次才能成功展示出一次图像。而且,展示出的图像中还是有不少的像素是有问题的。这个问题困扰了我好久,目前还没找到具体原因和解决办法,欢迎各位大佬在评论区赐教。
模拟Game Boy发送打印数据
有了上面的经验,就可以做的更深入一点了。之前是模拟打印机 接受图像,现在反过来,模拟Game Boy向打印机发送图像和指令,把Pocket Printer变成通用打印机。
硬件连线
连线部分和上面的基本一致,只是SIN和SOUT调换了一下,插头查到了打印机上:
+------------+ +----------------------+
| | | |
| SOUT+------------------>GPIO.2 (BOARD #13) |
| | | |
| SIN +------------------>GPIO.7 (BOARD #7) |
| | | |
| SCK +------------------>GPIO.0 (BOARD #11) |
| | | |
| GND +------------------>GND (BOARD #9) |
| | | |
+------------+ +----------------------+
Link Cable Rspberry Pi
代码
代码部分比之前模拟打印机的要简单,大部分情况下只需要发送数据即可。使用下面的代码,需要先将要打印的图像数据存在pic.bin
中。
#include <stdio.h>
#include <errno.h>
#include <stdlib.h>
#include <wiringPi.h>
#include <fstream>
#include <iostream>
using namespace std;
#define GBInitialize 0x01
#define GBData 0x04
#define GBPrint 0x02
#define GBInquiry 0x0f
#define DELAY_MS 40
#define GBClock 0
#define GBIn 7
#define GBOut 2
#define lowByte(w) ((uint8_t) ((w) & 0xff))
#define highByte(w) ((uint8_t) ((w) >> 8))
//发送一个字节
uint8_t GBSendByte(unsigned char b){
unsigned char reply=0;
for (uint8_t bit_pos = 0; bit_pos < 8; ++bit_pos) {
reply <<= 1;
digitalWrite(GBClock, 0); // Send clock signal
if ((b << bit_pos) & 0x80) {
digitalWrite(GBOut, 1); // Write out to printer
}else {
digitalWrite(GBOut, 0);
}
delayMicroseconds (DELAY_MS);
digitalWrite(GBClock, 1);
if (digitalRead(GBIn)){
reply |= 1; // Fetch printer reply
}
delayMicroseconds (DELAY_MS);
}
return reply;
}
//发送一个包
uint16_t GBSendPacket(uint8_t command, uint16_t size,uint8_t data[],int start=0) {
uint16_t status, checksum = 0x0000;
// Send magic bytes
GBSendByte(0x88);
GBSendByte(0x33);
// Send command
GBSendByte(command);
checksum += command;
// Send compression
GBSendByte(0x00);
// Send size
GBSendByte(lowByte(size));
GBSendByte(highByte(size));
checksum += lowByte(size);
checksum += highByte(size);
// Send data
uint8_t b;
for (uint16_t i = 0; i < size; ++i) {
b = data[i+start];
checksum += b;
GBSendByte(b);
}
// Send checksum
GBSendByte(lowByte(checksum));
GBSendByte(highByte(checksum));
// Read status
status = GBSendByte(0x00);
status = status << 8;
status |= GBSendByte(0x00);
return status;
}
//从文件读取要发送的数据
void readPicData(uint8_t* data){
char rawData[5760];
std::ifstream infile("pic.bin");
infile.seekg(0, infile.end);
size_t length = infile.tellg();
infile.seekg(0, infile.beg);
infile.read(rawData, length);
memcpy( data, rawData, length);
}
int main(void)
{
if (wiringPiSetup() < 0)
{
fprintf(stderr, "Unable to setup wiringPi: %s\n", strerror(errno));
return 1;
}
pinMode(GBIn, INPUT);
pinMode(GBClock, OUTPUT);
pinMode(GBOut, OUTPUT);
pullUpDnControl(GBIn,PUD_UP); // turn on pullup resistors
pullUpDnControl(GBOut,PUD_UP); // turn on pullup resistors
uint8_t data[8]={0,0,0,0,0,0,0,0};
cout<<(int)GBSendPacket(GBInitialize,0,data)<<endl;
uint8_t printData[8]={0x01,0x00,0xE4,0x40};
uint8_t Data[5760];
readPicData(Data);
for(int i=0;i<9;i++){
cout<<(int)GBSendPacket(GBData,640,Data,i*640)<<endl;
}
cout<<(int)GBSendPacket(GBData,0,printData)<<endl;
cout<<(int)GBSendPacket(GBPrint,4,printData)<<endl;
return 0;
}
效果展示
打印个Hello World:
打印彩色图像要麻烦一点,要把图像转换为四阶灰度,我自己写的算法转换出来效果很差。不过感谢推特上大佬提醒,使用抖动算法处理后效果就好很多了。
如果解决供电问题的话,把这个打印机联网,就是个简陋版的咕咕机了!
最后用 NES.css 写了个简单的 Web UI: