⬆️ ⬇️

Go language. We write the CHIP-8 emulator

Go recently celebrated the first year of his life. The CHIP-8 interpreter has already turned forty.

This post is dedicated to lovers of new languages ​​and old iron - in it we will write the CHIP-8 virtual machine emulator in the Go language.



How to set up the environment to work with Go wrote more than once . Recently, little has changed, except that the Windows version has become more stable.

Having installed everything according to the instructions, proceed to the study of the interior of CHIP-8.



Story



CHIP-8-based game consoles are notable for being among the first virtual machines in history.

Programs for CHIP-8 are not executed on a real processor, but are interpreted. Moreover, the original interpreter occupied only 512 bytes.

The characteristics of CHIP-8 are impressively modest: an 8-bit processor with a frequency of a couple of megahertz, 4 KB of RAM (the program code is also stored in RAM), a 32x64 monochrome screen, two timers - one for timing, another for playing sound (“tweeters” ).

Despite all the flaws, the CHIP-8 has enough power to launch Space Invaders, Pong and other old school games.

Programs for CHIP-8 are written on a specific assembler. The whole language consists of 35 commands - arithmetic, conditional / unconditional jumps, input / output (working with the display, keyboard, sound).

')

Project structure



Our emulator will consist of a single c8emu.go file ...

 //   ""   Go package main func main() { } 


... and Makefile:

 #  Makefile   Go include $(GOROOT)/src/Make.inc TARG=c8emu GOFILES=c8emu.go include $(GOROOT)/src/Make.cmd 


To make it easier to understand the source below, I remind you that:



Of course, the language features are much more, and they all seem unusual, but do not judge strictly. The language is just different.



So, to emulate CHIP-8, we need the appropriate class (there are no classes in Go, but there are structures with fields and methods for working with the structure):

 type Chip8 struct { memory []byte // memory address is in range: 0x200...0xfff regs [16]byte // CHIP-8 has 16 8-bit registers ireg uint16 // I-reg was a 16-bit register for memory operations stack [16]uint16 // Stack up to 16 levels of nesting sp int // Stack pointer pc uint16 // Program counter } 


This structure describes the interiors of CHIP-8: a memory block, 16 eight-bit registers, register-pointer I, a stack of 16 nesting levels, as well as a command counter and a stack pointer.



Emulator Initialization



To create our CHIP-8 object, write the NewChip8() function:

 func NewChip8() (c *Chip8) { c = new(Chip8); //     c.memory = make([]byte, 0xfff) //  "" ( )   c.sp = 0 c.pc = 0x200 //   CHIP-8    0x200 c.ireg = 0 //        return c } 


To load the program code into the CHIP-8 interpreter, write the Load () method. Methods in Go are described in the same way as functions:

 func (c *Chip8) Load(rom []byte) (err os.Error) { if len(rom) > len(c.memory) - 0x200 { //  len    (- ) err = os.NewError("ROM is too large!") // -    return } copy(c.memory[0x200:], rom) //     0200 err = nil return } 


Processing instructions



The Step () method allows you to perform a separate CHIP-8 instruction. All instructions are 2-byte. Opcodes are more or less structured, although certainly not as in ARM ... Mostly you can navigate by the older 4 bits of the code.

 func (c *Chip8) Step() (err os.Error) { var op uint16 //      if (c.pc >= uint16(len(c.memory))) { err = os.EOF return } //     op = (uint16(c.memory[c.pc]) << 8) | uint16(c.memory[c.pc + 1]) switch (op & 0xf000) >> 12 { case ... /*       */ default: return os.NewError("Illelal instruction") } c.pc += 2 return } 


Most of the instructions are rather trivial:

 // JMP addr - jump to address case 0x1: c.pc = op & 0xfff return // SKEQ reg, value - skip if register equals value case 0x3: if c.regs[(op & 0x0f00) >> 8] == byte(op & 0xff) { c.pc += 2 // skip one instruction } // SKNE reg, value - skip if not equal case 0x4: if c.regs[(op & 0x0f00) >> 8] != byte(op & 0xff) { c.pc += 2 // skip one instruction } // MOV reg, value case 0x6: c.regs[(op & 0x0f00) >> 8] = byte(op & 0xff) // MVI addr -   - I case 0xa: c.ireg = op & 0xfff // RAND reg, max -      0..max case 0xc: c.regs[(op & 0x0f00) >> 8] = byte(rand.Intn(int(op & 0xff))) 


Arithmetic and logical instructions are handled similarly.

Handling the procedure call and exit is also pretty easy:

 // RET -    ( 00EE) case 0x0: switch op & 0xff { case 0xee: c.sp-- c.pc = c.stack[c.sp] return } // CALL addr -     case 0x2: c.stack[c.sp] = c.pc + 2 c.sp++ c.pc = op & 0xfff return 


I will not describe all instructions here. I will dwell on the three most important ones (they are used in almost any game) - working with a timer and displaying images on the screen.



Timer



The timer runs at 60 Hz, you can enter a number (one byte), and it will decrease by 1 with each “tick”.

The timer can be periodically read and see how many “ticks” have passed since its launch. Below zero the timer value cannot go away.

This is what happens:

 type Timer struct { value byte //      start int64 //   period int64 //    "" } //    func NewTimer(hz int) (t *Timer) { t = new(Timer) t.period = int64(1000000000/hz) t.value = 0 return t } //  func (t *Timer) Set(value byte) { t.value = value t.start = time.Nanoseconds() } //   func (t *Timer) Get() byte { delta := (time.Nanoseconds() - t.start) / t.period if int64(t.value) > delta { return t.value - byte(delta) } return 0 } 


Add a timer field to the Chip8 structure and initialize it when creating a Chip8 object:

 type Chip8 struct { ... timer *Timer ... } func NewChip8() (c *Chip8) { ... c.timer = NewTimer(60) .. } case 0xf: switch (op & 0xff) { case 0x07: //    c.regs[(op & 0x0f00) >> 8] = c.timer.Get() case 0x15: //   c.timer.Set(c.regs[(op & 0x0f00) >> 8]) } 


Display



There is only one instruction to display the image on the screen. The CHIP-8 graphics are based on the concept of the sprite. All sprites of the same width - 8 pixels, differ only in height.

For example, a sprite that draws a cross looks like a sequence of five bytes:

 0x88 ; 10001000 0x50 ; 01010000 0x20 ; 00100000 0x50 ; 01010000 0x88 ; 10001000 


Before output, you must specify the address of the beginning of the sprite, setting the value of register I accordingly.

To display the sprite on the screen, you need to enter the coordinates in any two registers and call the draw statement, in which you specify the height of the sprite:

 mvi x_sprite mov v0, 10 mov v1, 15 draw v0, v1, 5 ;    5    (10,15) x_sprite: db 0x88, 0x50, 0x20, 0x50, 0x88 


But draw doesn't just draw, it does XOR existing pixels with sprite pixels. This is convenient - to erase the sprite it can be displayed again in the same coordinates.

In addition, if one of the pixels was reset to 0, draw sets the value of the vf register (usually used as a flag register) to one.

Add a video buffer array to the Chip8 structure: screen [64 * 32] bool and write a function for drawing:

 c.regs[0xf] = 0 for col:=0; col<8; col++ { for row:=0; row<int(size); row++ { px := int(x) + col py := int(y) + row bit := (c.memory[c.ireg + uint16(row)] & (1 << uint(col))) != 0 if (px < 64 && py < 32 && px >= 0 && py >= 0) { src := c.screen[py*64 + px] dst := (bit != src) // ,  XOR      c.screen[py*64 + px] = dst if (src && !dst) { c.regs[0xf] = 1 } } } } 


In order to somehow test the resulting emulator, I output the contents of the video buffer directly to the terminal. This program was used. To my surprise, she earned and started drawing tic-tac-toe treadmills:

image



What's next?



Actually, I really like the CHIP-8 platform. No practical use, but brains can be trained on it. I started the c8kit project - I plan to include an emulator, an assembler and a disassembler in it.

I think I should fasten the graphics and keyboard using SDL (Go successfully supports it). Synchronizing the interface module and the CHIP-8 core would be convenient using the Go channels.

I hope it will be interesting!

Source: https://habr.com/ru/post/109862/



All Articles