Back
Featured image of post 用树莓派模拟 Game Boy 打印机及相机外设

用树莓派模拟 Game Boy 打印机及相机外设

用树莓派驱动打印机,以及将 Pocket Camera 所拍摄的照片数字化

前言

Game Boy最初是任天堂在1989年推出的8位掌机,其后又推出了GBP、GBL、GBC、GBA等衍生机型,以1.2亿台的总销量成为世界上最畅销的掌机系列。很多第三方厂商也抓住机会,针对Game Boy生产了各种神仙级别的外设(具体可参考AVGN第147集)。个人最感兴趣的是其中相机和打印机两款外设。要知道,在那个手机都不普及的90年代,一台能随时拍照还能随时打印的游戏机是多么黑科技般的存在!

Pocket Printer 与 Pocket Camera

大概一个月前,博主有幸搞到了两台日版相机外设(Pocket Camera)和全新的打印机外设(Pocket Printer)。

Pocket Printer和Pocket Camera

由于初代Game Boy屏幕分辨率为160X144且只有四阶灰度,所以这相机的成像质量仅仅是能看的水平。不过20年前60刀的价格真的不能再期待什么啦。

用Pocket Camera拍摄的宿舍楼

相机卡带自带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端口输出时钟信号,主从机通信时会共用这一个信号。

通信数据以字节为单位从高位到低位传输。可以想象主从设备上各有一个八位移位寄存器,每接受到一位就将其从低位移入。SINSOUT可同时发送和接受(但在本次模拟的情况下不会出现这种情况)。比如,主机要通过SCK向从机发送0x33,这个过程SCKSOUT的波形图可以大致表示为如下:

GB通信波形

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:

Web UI
Web UI

参考链接