Devlog 0x5 - Debugging Magic Bitboard Issues for Rooks

Devlog 0x5. There were discrepancies between the expect output when generating rook attack tables and the value generated. Here is the journey through debugging this issue.

Noticing the Discrepancy

I started out by running some tests on the rook’s magic bitboard lookups. Immediately, I noticed something off: the on‑the‑fly (rookAttacksOTF) attack generation was returning a full range of squares, but the precomputed rookAttacks[square][magicIndex] result was missing some squares—especially along the edges.

I printed out the intermediate debug statements and discovered:

  • The rook mask seemed correct (i.e., the bits for the sliding moves except the outer ranks/files).
  • The occupancy was properly masked and shifted.
  • The final lookup in rookAttacks[square][occupancyCopy] gave a truncated result.

Here's an example for a rook on square a1:

Getting rook attacks for square 56
Initial occupancy: 0000000000000000000000000000000000000000000000000000000000000000
On-the-fly attacks: 1111111000000001000000010000000100000001000000010000000100000001
Rook mask for square: 0111111000000001000000010000000100000001000000010000000100000000
After masking: 0000000000000000000000000000000000000000000000000000000000000000
Magic number: 8100800880048022
After magic multiply: 0000000000000000000000000000000000000000000000000000000000000000
Shift amount: 52
After right shift (index): 0
Final lookup result: 0111111000000001000000010000000100000000000000000000000000000000
  8   0  0  0  0  0  0  0  0
  7   0  0  0  0  0  0  0  0
  6   0  0  0  0  0  0  0  0
  5   0  0  0  0  0  0  0  0
  4   1  0  0  0  0  0  0  0
  3   1  0  0  0  0  0  0  0
  2   1  0  0  0  0  0  0  0
  1   0  1  1  1  1  1  1  0
      a  b  c  d  e  f  g  h
      Bitboard: 9079539427562225664

A little difficult to follow, but the final lookup result should be identical to the rook mask for the square given this position with an initial occupancy of 0 and a rook on a1.

At first, I suspected some kind of 32‑bit vs. 64‑bit mismatch, or that I was losing the high 32 bits in a cast. However, printing in binary showed that it was storing all 64 bits. That’s when I realized it was more subtle: the final result simply didn’t have the squares I wanted.

Diving into Magic Bitboard Generation

Next, I dove into the code that generated and initialized the magic bitboards. Specifically:

  • initSliderAttacks(bishop: bool) was generating the relevant occupancy mask, iterating over all occupancy possibilities, then setting the table.
  • The array rookAttacks[square][index] was being filled by the return value of rookAttacksOTF(square, occupancy).

But everything looked correct there, at least at first glance.

The real “Aha!” moment came when I realized the actual rook magic numbers might be at fault. If the magic numbers themselves were off—if they didn’t correctly scramble the occupancy bits into an index—then the final index used to retrieve rookAttacks[square][magicIndex] would end up pointing to a partial or incorrect bitboard.

The Smoking Gun: Checking initMagicNumbers()

I decided to look at the function that originally computed or assigned bitboard.rookMagicNumbers[square]. Sure enough, I saw that the values for certain squares (especially corners) were suspiciously small or repeated. This is a red flag because valid magic numbers tend to be fairly large, seemingly “random” 64-bit values.

What happened was that during the generation phase, there was a small logic oversight:

fn initMagicNumbers() void {
    std.debug.print("bishop:\n", .{});
    for (0..64) |square| {
        const result: u64 = findMagicNumber(@intCast(square), bitboard.bishopRelevantBits[square], true);
        std.debug.print("0x{x},\n", .{result});
    }
    std.debug.print("rook:\n", .{});
    for (0..64) |square| {
        const result: u64 = findMagicNumber(@intCast(square), bitboard.rookRelevantBits[square], true);
        std.debug.print(" 0x{x},\n", .{result});
    }
}

Can you see the issue? When making this function, I copied the implementation for bishops directly above it as they were correct and working, and forgot to change the bishop:bool parameter in the findMagicNumber() function from true to false when generating magic numbers for the rooks. A simple mistake, but one that took a few hours of debugging to solve. Simply changing this argument from true to false, generating our magic numbers again, and then testing the a1 square again showed me that this solved the problem. a

Chess Debugging Takeaways

Lessons Learned

  • Suspect Magic Numbers

    • If the “basic” masking, shifting, and table indexing logic looks correct, it may be that the underlying magic numbers themselves are invalid or incorrectly generated.
  • Use Debug Prints Liberally

    • Having a thorough printout of the occupancy, mask, magic multiply, and final shift is extremely helpful to pinpoint exactly where things diverge.
  • Edge Cases, Literally

    • Corner squares (like a8 or h1) often are the first place to detect magic number mishaps because they have more “redundant” or “empty” occupancy patterns when edges are excluded.

After applying the fix in initMagicNumbers, rooks now have correct magic lookups for all squares. The final lookup result fully matches the expected output, verifying that the magic indexing is finally correct. Here is the same bitboard from the attack table for a rook on a1:

  8   1  0  0  0  0  0  0  0
  7   1  0  0  0  0  0  0  0
  6   1  0  0  0  0  0  0  0
  5   1  0  0  0  0  0  0  0
  4   1  0  0  0  0  0  0  0
  3   1  0  0  0  0  0  0  0
  2   1  0  0  0  0  0  0  0
  1   0  1  1  1  1  1  1  1

      a  b  c  d  e  f  g  h

      Bitboard: 18302911464433844481

If you want to view this issue on Github, here's the link to do that.

A single‐line fix in initMagicNumbers to ensure rook magic numbers are valid solved the mysterious truncation/omission of the rook edges. Debugging magic bitboards can be a puzzle, but that's part of the joy of programming. Thanks for reading, and I'll see you tomorrow :)