A multi-emulator Gameboy tracer

November 21, 2024

I’ve been developing Gameboy emulators off and on for some years. They’re all broken in one way or another. Part of my debugging process is to run a test ROM like the blargg suite. That gives me some positive signal—if the tests pass, I’ve done something right—but it doesn’t help me narrow down a bug if they fail.

For this, I’ve added a dump() function to my emulators that logs the state in some well-known format and then done a side-by-side comparison against a known-correct log. That’s all fine and good but:

I figured this would be a good opportunity to write sidecar program, gbtracer. This program dlopens an emulator and then runs it, logging the state to a tempfile. How does it know the state? The emulator calls some well-known functions:

diff --git a/main.cpp b/main.cpp
index 92774d7..4c2c420 100644
--- a/main.cpp
+++ b/main.cpp
@@ -8,6 +8,22 @@
 #include "fenster.h"
 #include "rom.h"
 
+extern "C" {
+void gbtracer_open(){}
+void gbtracer_set_h(uint8_t){}
+void gbtracer_set_l(uint8_t){}
+void gbtracer_set_a(uint8_t){}
+void gbtracer_set_b(uint8_t){}
+void gbtracer_set_c(uint8_t){}
+void gbtracer_set_d(uint8_t){}
+void gbtracer_set_e(uint8_t){}
+void gbtracer_set_f(uint8_t){}
+void gbtracer_set_sp(uint16_t){}
+void gbtracer_set_pc(uint16_t){}
+void gbtracer_set_memory(uint8_t*, size_t){}
+void gbtracer_close(){}
+}
+
 typedef uint8_t byte;
 typedef uint16_t word;
 typedef int16_t sword;
@@ -54,6 +70,7 @@ class CPU {
     DCHECK(rom_bin_len == 0x100, "ROM too big");
     std::memcpy(memory, rom_bin, rom_bin_len);
     set_lcd_status(OAM);
+    gbtracer_set_memory(memory, sizeof(memory));
    }
 
   void loadCart(const byte* cart, size_t size) {
@@ -628,6 +645,16 @@ class CPU {
   void tick() {
     word t_before = t_cycles;
     byte opcode = imm8();
+    gbtracer_set_a(regs[A]);
+    gbtracer_set_b(regs[B]);
+    gbtracer_set_c(regs[C]);
+    gbtracer_set_d(regs[D]);
+    gbtracer_set_e(regs[E]);
+    gbtracer_set_f(regs[F]);
+    gbtracer_set_h(regs[H]);
+    gbtracer_set_l(regs[L]);
+    gbtracer_set_sp(sp);
+    gbtracer_set_pc(pc);
     execute(opcode);
     m_cycles++;
     timer();
@@ -1142,6 +1169,7 @@ inline std::vector<uint8_t> read_vector_from_disk(std::string file_path) {
 }
 
 int main(int argc, char **argv) {
+  gbtracer_open();
   CPU cpu;
   if (argc == 2) {
     const char* filename = argv[1];
@@ -1184,4 +1212,5 @@ int main(int argc, char **argv) {
       before_ms = fenster_time();
     }
   }
+  gbtracer_close();
 }

These functions are empty in the emulator but have definitions in the tracer. This feels like it takes advantage of something broken or undefined, so if you know more about this, please let me know.

// In gbtracer.c
#define WRITER(type, name)                                                     \
  void gbtracer_set_##name(type v) { gbtracer_##name = v; }
WRITER(uint8_t, h);
WRITER(uint8_t, l);
WRITER(uint8_t, a);
WRITER(uint8_t, b);
WRITER(uint8_t, c);
WRITER(uint8_t, d);
WRITER(uint8_t, e);
WRITER(uint8_t, f);
WRITER(uint16_t, sp);
#undef WRITER

And when the emulator calls gbtracer_set_pc, the tracer writes to a tempfile:

// In gbtracer.c
static void flush() {
  uint16_t pc = gbtracer_pc;
  char flags[4] = "----";
  if (gbtracer_f & 0x80) {
    flags[0] = 'Z';
  }
  if (gbtracer_f & 0x40) {
    flags[1] = 'N';
  }
  if (gbtracer_f & 0x20) {
    flags[2] = 'H';
  }
  if (gbtracer_f & 0x10) {
    flags[3] = 'C';
  }
  fprintf(logfile_fp,
          "A: %02X B: %02X C: %02X D: %02X E: %02X F: %s H: %02X L: %02X SP: "
          "%04X PC: %04X PCMEM: %02X,%02X,%02X,%02X\n",
          gbtracer_a, gbtracer_b, gbtracer_c, gbtracer_d, gbtracer_e, flags,
          gbtracer_h, gbtracer_l, gbtracer_sp, pc, gbtracer_memory[pc + 0],
          gbtracer_memory[pc + 1], gbtracer_memory[pc + 2],
          gbtracer_memory[pc + 3]);
}

This would be a good cut point where the output format could be made configurable without recompiling the emulator.

This is what it looks like in action:

$ ./gbtracer ./main.so 01-special.gb
01-special


Passed
gbtracer: /tmp/gbtracer.log.ysINd0
$ head /tmp/gbtracer.log.ysINd0
A: 00 B: 00 C: 00 D: 00 E: 00 F: ---- H: 00 L: 00 SP: 0000 PC: 0000 PCMEM: 31,FE,FF,AF
A: 00 B: 00 C: 00 D: 00 E: 00 F: ---- H: 00 L: 00 SP: FFFE PC: 0001 PCMEM: FE,FF,AF,21
A: 00 B: 00 C: 00 D: 00 E: 00 F: Z--- H: 00 L: 00 SP: FFFE PC: 0004 PCMEM: 21,FF,9F,32
A: 00 B: 00 C: 00 D: 00 E: 00 F: Z--- H: 9F L: FF SP: FFFE PC: 0005 PCMEM: FF,9F,32,CB
A: 00 B: 00 C: 00 D: 00 E: 00 F: Z--- H: 9F L: FE SP: FFFE PC: 0008 PCMEM: CB,7C,20,FB
A: 00 B: 00 C: 00 D: 00 E: 00 F: --H- H: 9F L: FE SP: FFFE PC: 0009 PCMEM: 7C,20,FB,21
A: 00 B: 00 C: 00 D: 00 E: 00 F: --H- H: 9F L: FE SP: FFFE PC: 000B PCMEM: FB,21,26,FF
A: 00 B: 00 C: 00 D: 00 E: 00 F: --H- H: 9F L: FD SP: FFFE PC: 0008 PCMEM: CB,7C,20,FB
A: 00 B: 00 C: 00 D: 00 E: 00 F: --H- H: 9F L: FD SP: FFFE PC: 0009 PCMEM: 7C,20,FB,21
A: 00 B: 00 C: 00 D: 00 E: 00 F: --H- H: 9F L: FD SP: FFFE PC: 000B PCMEM: FB,21,26,FF
$

The catch is that you have to make a second build of your emulator as a shared object with -fpie -fPIC flags.

See the full code.

See also