I recently started on an emulator for the CHIP-8. I began the project in Go a few days ago, but switched to Odin because goroutines are too easy and I haven't done much work in Odin lately.

Disclaimer: This post is not a tutorial on building an emulator. Instead, consider it a brain dump. We'll take a look at how the CHIP-8 works, some implementation choices, and an undocumented Odin core package.

The information in this post and my understanding of the CHIP-8 are largely derived from this excellent guide by Tobias V. Langhoff. If you want to build your own, it's a great place to start.

The repo for my version can be found here.

fake console?

According to Wikipedia, "CHIP-8 is an interpreted programming language".

While the CHIP-8 never existed as hardware, for all intents and purposes we will be treating it as if it did. This means we'll be dealing with registers, a stack, memory, a display, instructions, etc.

The CHIP-8 specification is pretty straightforward:

  • 4096 bytes of memory with direct access
  • 64x32 pixel monochrome display
  • 16 bit program counter
  • 16 bit index register for pointing to memory
  • a stack for 16 bit addresses
  • two 8 bit timers for delay and sound
  • 16 8-bit general purpose "variable" registers

Interestingly, it uses 16 bit opcodes that are interpreted as four nibbles. A great example of this is the drawing instruction DXYN. The entire opcode is 16 bits with the first 4 specifying the type of instruction (D). The second and third sets of 4 bits (X and Y) specify which of the V registers contain the coordinates at which to draw. The final nibble (N) specifies the height of the sprite in pixels.

As of the time of writing this post, I've only implemented the bare minimum instruction set required to run the IBM logo test program. Those instructions are:

  • 00E0 clear screen
  • 1NNN jump to NNN
  • 6XNN set VX to NN
  • 7XNN add NN to VX
  • ANNN set index register to NNN
  • DXYN draw

If you'd like to dive deeper into the workings of the CHIP-8, I very much recommend the Tobias Langhoff post linked above.


implementation

I'm using Raylib for the windowing/drawing parts of the project. Rather than calling into Raylib while executing the draw instruction, I decided to slightly overengineer the problem.

I've been wanting to do more with threads as it's a weak point in my skillset and this is a place where it makes sense (at least in theory). By spinning the emulator execution off into its own thread, I can keep the framerate at 60 while the emulator runs as fast as it is able. I haven't done any profiling to see if this actually matters performance-wise, but that wasn't the point.

The pixel buffer is represented as a two-dimensional array of booleans in row-col (arr[y][x]) order internally. When the draw instruction is finished it sends the entire buffer to the render thread. Here, we iterate over the buffer and draw each true (or "on" pixel) as a 16x16 white square. This effectively upscales the 64x32 native resolution of the CHIP-8 to a friendlier 1024x512.


undocumented fun

That begs the question then, how do we get the pixel data from the emulator thread to the render thread?

This is where Go was really nice because it was as simple as creating a channel and sending the data across it at the end of the draw instruction. But we're not in Go anymore. If you search on Odin's package docs you won't find channels anywhere.

I considered for a bit using some good ol' shared mutable state behind a mutex before I remembered that some of Odin's packages aren't documented yet (and thus don't show on the site). So I did some digging in the repo and...

core/sync/chan/chan.odin
Chan :: struct($T: typeid, $D: Direction = Direction.Both) {
    #subtype impl: ^Raw_Chan `fmt:"-"`,
}

Welp. There ya go. Odin has channels too.

Looking through the source for the package, it was pretty easy to figure out how to use them.

import "core:sync/chan"

PixBuf :: distinct [32][64]bool

channel, err := chan.create_buffered(
    chan.Chan(PixBuf),
    16,
    context.allocator,
)

chan.send(channel, pixels)

This creates a buffered channel which passes a PixBuf type and can buffer up to 16 messages. The choice of a 16 wide buffered channel was arbitrary. It seems to run the IBM logo test just fine with an unbuffered channel. ¯\_(ツ)_/¯

Note: the send() and recv() procs in sync/chan are blocking. If you need non-blocking behavior, you can use can_send() and can_recv() to check if the call will block.

That's all I have right now. I may write up a second part to this when/if I get farther along with the project.