Emulator Bugs: Sega CD

Here are two bugs that I ran into while adding Sega CD support to my Genesis emulator, both related to emulating the Sega CD’s CD-ROM hardware. These are from a while ago but I think they’re still interesting enough to write about.

While the Sega CD added much more hardware than just a CD-ROM drive, the CD-ROM hardware is probably 90-95% of the difficulty in emulating Sega CD. (Though it’s definitely not the only place that things can go wrong!)

Freezing

Lunar: The Silver Star froze right here, a minute or two into the intro sequence:

Lunar Freeze

Also, there was no music after the short voice-acted scene where Nall speaks to Alex.

The CDD

There are two major components to the Sega CD’s CD-ROM hardware: the CDD (CD Drive) and the CDC (CD Data Controller). This bug was caused by bad CDD emulation.

The CDD is the physical disc drive, the piece of hardware responsible for reading data from the CD-ROM as well as playing CD audio tracks (CD-DA).

Software interacts with the CDD using a fairly simple (though poorly documented) command protocol. Each command consists of ten 4-bit nibbles, and the CDD exposes its current status through another set of ten 4-bit nibbles. Command verbs are things like “seek”, “read”, “pause”, etc. “Read” is used both for data track reads and to start audio track playback.

Being a fairly early CD-ROM drive, the CDD only supports reading at 1x speed: 75 sectors per second, or equivalently 150 KB of data per second (after discarding sector headers and error detection/correction bytes). That is very slow, and non-sequential reads are much much worse due to seek latency. Although for perspective it’s worth noting that the Genesis and Sega CD combined have less than 1 MB of RAM.

The CDD processes commands at a rate of exactly 75 Hz. Internally it has some sort of microcontroller that does the following, 75 times per second: 1. read the current CDD command, 2. do whatever processing is necessary for that command (usually involves communicating with the physical drive), and 3. update the CDD status for software to read.

Because CDD interactions must be carefully timed, games interact with the CDD using Sega CD BIOS functions rather than directly accessing the CDD command and status registers. BIOS command functions queue CDD commands in RAM, and BIOS status functions read from a copy in RAM rather than directly accessing CDD registers.

The BIOS relies on a 75 Hz interrupt from the CDD (sub CPU INT4) to carefully time CDD register accesses. Each time it receives a CDD interrupt, it copies the current CDD status into RAM, then it writes whatever CDD command is queued. If no command is queued then it writes either a no-op command or a “TOC report” command instead, the latter changing what exactly the CDD returns for the drive position part of its status response (e.g. absolute time or track-relative time).

The CDD is by far the least well-documented part of the Sega CD hardware because Sega expected developers to interact with it exclusively using BIOS functions rather than directly poking at CDD registers using the raw command/status protocol. Quite unfortunately, a number of games are extremely sensitive to both its behavior and its timing, which makes it rather painful to emulate.

Infinite Command Loop

So, back to Lunar. When it freezes, it’s stuck in a CDD command loop trying to start reading at 05:50:49. In this particular image that’s the beginning of track 6, an audio track.

The CDD exclusively uses MSF format (MM:SS:FF) for sector addresses in its commands and statuses, with all values encoded in BCD (binary-coded decimal). M is minutes (0-99), S is seconds (0-59), and F is frames (0-74 for 75 frames per second). Hours are not necessary because no CD-ROM can hold more than 99 minutes’ worth of sectors (the format does not officially support more than 80 minutes / 703 MB).

First, the commands/statuses leading up to the infinite loop. The commands here are what the BIOS sends to the CDD on each 75 Hz tick, and the statuses are what the emulated CDD sends back to the BIOS in response to each command:

CDD status:  4 0 0 8 1 0 4 2 0 C (Paused, at 08:10:42)
CDD command: 3 0 0 5 5 0 4 9 0 5 (Read 05:50:49)
CDD status:  2 0 0 8 1 0 4 2 0 E (Seeking, at 08:10:42)
CDD command: 0 0 0 0 0 0 0 0 0 F (No-op)
CDD status:  2 0 0 8 1 0 4 2 0 E (Seeking, at 08:10:42)
CDD command: 0 0 0 0 0 0 0 0 0 F (No-op)
CDD status:  2 0 0 8 1 0 4 2 0 E (Seeking, at 08:10:42)
CDD command: 0 0 0 0 0 0 0 0 0 F (No-op)
CDD status:  2 0 0 7 5 8 1 7 0 1 (Seeking, at 07:58:17)
  <snip>
CDD status:  2 0 0 6 2 2 4 1 0 E (Seeking, at 06:22:41)
CDD command: 0 0 0 0 0 0 0 0 0 F (No-op)
CDD status:  1 0 0 5 5 0 4 9 0 7 (Playing, at 05:50:49)
CDD command: 0 0 0 0 0 0 0 0 0 F (No-op)
CDD status:  1 0 0 5 5 0 4 9 0 7 (Playing, at 05:50:49)
CDD command: 0 0 0 0 0 0 0 0 0 F (No-op)
CDD status:  1 0 0 5 5 0 4 9 0 7 (Playing, at 05:50:49)
CDD command: 2 0 0 0 0 0 0 0 0 D (TOC report, absolute time)
CDD status:  1 0 0 5 5 0 4 9 0 7 (Playing, at 05:50:49)

Immediately after this, it restarts the read, then gets stuck repeating the same 4 commands over and over again:

CDD command: 3 0 0 5 5 0 4 9 0 5 (Read 05:50:49)
CDD status:  1 0 0 5 5 0 4 9 0 7 (Playing, at 05:50:49)
CDD command: 0 0 0 0 0 0 0 0 0 F (No-op)
CDD status:  1 0 0 5 5 0 4 9 0 7 (Playing, at 05:50:49)
CDD command: 0 0 0 0 0 0 0 0 0 F (No-op)
CDD status:  1 0 0 5 5 0 4 9 0 7 (Playing, at 05:50:49)
CDD command: 2 0 0 0 0 0 0 0 0 D (TOC report, absolute time)
CDD status:  1 0 0 5 5 0 4 9 0 7 (Playing, at 05:50:49)
CDD command: 3 0 0 5 5 0 4 9 0 5 (Read 05:50:49)
CDD status:  1 0 0 5 5 0 4 9 0 7 (Playing, at 05:50:49)
CDD command: 0 0 0 0 0 0 0 0 0 F (No-op)
CDD status:  1 0 0 5 5 0 4 9 0 7 (Playing, at 05:50:49)
CDD command: 0 0 0 0 0 0 0 0 0 F (No-op)
CDD status:  1 0 0 5 5 0 4 9 0 7 (Playing, at 05:50:49)
CDD command: 2 0 0 0 0 0 0 0 0 D (TOC report, absolute time)
CDD status:  1 0 0 5 5 0 4 9 0 7 (Playing, at 05:50:49)
CDD command: 3 0 0 5 5 0 4 9 0 5 (Read 05:50:49)
CDD status:  1 0 0 5 5 0 4 9 0 7 (Playing, at 05:50:49)
...

The drive never reads a single sector from disc because the game keeps restarting the read. (Once the drive begins playing at the target position, there’s a short delay before it reads the first sector. I don’t think any games will boot if you don’t emulate this.)

It actually gets stuck here right after the voice-acted Nall scene, when it tries to start playing the background music. Presumably the game freezes a bit later because it needs to read data from the disc, but the game’s CD-ROM logic won’t let it start that data read until it finishes starting this audio track read, which never happens.

The more robust way to figure this out would be to trace through the disassembled BIOS and game code to figure out why it’s deciding to send a new read command every four 75 Hz cycles, but there’s quite a lot of code to look through. I instead assumed there was something the game didn’t like about the CDD status responses to its read commands and I tried to figure out what that could be.

The CDD status response consists of 10 nibbles, defined as following:

  • Nibble 0: Drive status (e.g. 1 = playing, 2 = seeking, 4 = paused)
  • Nibble 1: TOC report type (e.g. 0 = absolute time, 1 = relative time, 2 = track number)
  • Nibbles 2-7: TOC report info (current drive position in BCD)
  • Nibble 8: Flags (e.g. audio vs. data track)
  • Nibble 9: Checksum (sum of the first 9 nibbles, XORed with 0xF)

Messing with the drive status nibble didn’t help at all; it only broke the game more.

The TOC report, however… I had the CDD always returning the drive’s current position, even while seeking. That seemed suspicious after thinking about it more - can the drive actually return its current position mid-seek? If not, what does it return instead?

This motivated me to go looking harder for more complete documentation on how exactly the CDD works. I didn’t find that exactly, but I did find this upload by Nemesis in 2019 (not that long ago!): https://gendev.spritesmind.net/forum/viewtopic.php?p=35297#p35297

This contains (partial?) source code for the software that runs on the microcontroller in the CTrac, provided by the original creators. The CTrac being a device that plugs into a PC and emulates the Sega CD’s CD-ROM hardware for development purposes.

This is an extremely useful resource! Even if it doesn’t exactly mimic how actual hardware works, it’s an implementation that worked well enough for game developers to test with back in the 90s.

The code is thousands of lines of 6303 assembly (8-bit processor based on the Motorola 6800), but it’s quite well-commented at least. I wouldn’t say that it’s easy to read but it could be a whole lot harder to read.

Looking through this, I found the information I was looking for: when updating the CDD status, if the drive is “not ready”, it sets nibble 1 to 0xF (invalid / not ready) and nibbles 2-8 all to 0. I had a hard time figuring out precisely what “not ready” means, but I tested emulating this behavior whenever the drive is seeking or track skipping, and…the game no longer freezes!

Lunar Past Freeze

That makes the initial command/status exchange look like this:

CDD status:  4 0 0 8 1 0 4 2 0 C (Paused, at 08:10:42)
CDD command: 3 0 0 5 5 0 4 9 0 5 (Read 05:50:49)
CDD status:  2 F 0 0 0 0 0 0 0 E (Seeking, no TOC report)
CDD command: 0 0 0 0 0 0 0 0 0 F (No-op)
CDD status:  2 F 0 0 0 0 0 0 0 E (Seeking, no TOC report)
CDD command: 0 0 0 0 0 0 0 0 0 F (No-op)
CDD status:  2 F 0 0 0 0 0 0 0 E (Seeking, no TOC report)
  <snip>
CDD status:  2 F 0 0 0 0 0 0 0 E (Seeking, no TOC report)
CDD command: 0 0 0 0 0 0 0 0 0 F (No-op)
CDD status:  1 0 0 5 5 0 4 9 0 7 (Playing, at 05:50:49)
CDD command: 2 0 0 1 0 0 0 0 0 C (TOC report, relative time)
CDD status:  1 1 0 0 0 0 0 0 0 D (Playing, at 00:00:00 relative)
CDD command: 2 0 0 2 0 0 0 0 0 B (TOC report, track number)
CDD status:  1 2 0 6 0 0 0 0 0 6 (Playing, at track 6)
CDD command: 2 0 0 0 0 0 0 0 0 D (TOC report, absolute time)
CDD status:  1 0 0 5 5 0 4 9 0 7 (Playing, at 05:50:49)
CDD command: 2 0 0 1 0 0 0 0 0 C (TOC report, relative time)
CDD status:  1 1 0 0 0 0 0 0 0 D (Playing, at 00:00:00 relative)
CDD: Finished reading sector at 05:50:49
CDD command: 2 0 0 2 0 0 0 0 0 B (TOC report, track number)
CDD status:  1 2 0 6 0 0 0 0 0 6 (Playing, at track 6)
CDD: Finished reading sector at 05:50:50
CDD command: 2 0 0 0 0 0 0 0 0 D (TOC report, absolute time)
CDD status:  1 0 0 5 5 0 5 1 0 E (Playing, at 05:50:51)
CDD: Finished reading sector at 05:50:51
CDD command: 2 0 0 1 0 0 0 0 0 C (TOC report, relative time)
CDD status:  1 1 0 0 0 0 0 3 0 A (Playing, at 00:00:03 relative)
CDD: Finished reading sector at 05:50:52
...

(Cycling through the 3 main TOC report types is typical “idle” behavior from the BIOS when it’s not in the middle of a CDD command sequence.)

This change also fixed a similar freezing bug in the sequel, Lunar: Eternal Blue.

So no, the CDD does not return a normal TOC report while it is seeking, and at least two games depend on this. I haven’t dug into the game code myself, but I’d guess there’s some state machine that gets very confused if it doesn’t see a “not ready” status response between issuing the read command and the drive beginning to play.

More Freezing

That was a CDD-related freezing bug, so here’s a CDC-related freezing bug.

This one affected three games that I tested: Snatcher, Robo Aleste, and Batman Returns. All three froze at a black screen very early on, usually immediately after booting, but Snatcher at least displayed the Konami logo before it froze! Snatcher Konami Logo

The CDC

Where the CDD is the physical drive and the microcontroller inside it, the CDC is the chip that’s responsible for persisting and decoding the data that the CDD reads from disc.

Thankfully, the CDC is much more well-documented than the CDD. It’s a Sanyo LC8951 chip, also referred to as RCHIP in its manual (Real-Time Error Correction and Host Interface Processor).

According to the LC8950/LC8951 manual, a typical hardware configuration looks like this:

LC8951 ConfigurationLC8950 & LC8951 manual

Of course, the Sega CD is not a typical configuration. The software running on the Sega CD sub CPU functions as both the host computer and the system controller in this diagram. This is the main reason that you kind of have to emulate both the CDD and the CDC directly, as opposed to something like the PlayStation where you can emulate the entire CD-ROM subsystem as a single logical component without directly emulating the individual pieces of hardware inside of it. (Though PS1 CD-ROM emulation is also extremely painful, just in slightly different ways).

The CDC is connected to 16 KB of sector buffer RAM where it stores both the raw CD-ROM sectors received from the CDD and the decoded sectors for software. It has a host interface where either 68000 CPU can read data out of buffer RAM word-by-word, and it also supports automatically copying data into Sega CD RAM via DMA transfer.

Whenever the CDC finishes decoding a sector, it triggers an interrupt for the sub CPU (INT5). The game can then read the sector out of CDC buffer RAM and do whatever it needs to do with the data. Or it could decide that it doesn’t care about this sector and do nothing until the next decoder interrupt.

Reading a single CD-ROM sector goes roughly like this:

  1. CDD reads the sector from the physical disc, streaming it to the CDC
  2. CDC persists the raw sector to buffer RAM
  3. CDC decodes the sector into buffer RAM (at a different address), triggers a decoder interrupt upon completion
  4. Software reads the decoded sector out of buffer RAM via the CDC host interface or CDC DMA

While the drive is playing this all happens 75 times per second, bottlenecked by the CDD reading raw sectors from disc.

The CDC also performs CD-ROM error detection and correction as part of decoding, but I don’t think emulators commonly emulate this. Error detection is easy enough since it’s just a CRC32 checksum comparison, but error correction is very complicated. In theory any digital CD-ROM image should already be error-corrected anyway.

Edge-Triggered Interrupt

Back to Snatcher and company, they all froze in a similar way: with the sub CPU stuck in a loop repeatedly handling INT5 (CDC interrupt). The INT5 handler didn’t acknowledge the CDC interrupt, so the game immediately handled INT5 again after returning from its INT5 handler. Huh?

The CDC actually has two interrupts: a decoder interrupt that triggers whenever a sector is decoded (the one described above), and a transfer end interrupt that triggers whenever a host transfer or DMA transfer completes. These can be enabled or disabled separately via CDC registers.

(There’s technically a third interrupt, the command interrupt, but Sega CD never uses this. It’s meant for communication between the host computer and system controller, but for Sega CD those are the same thing.)

These two interrupts are also acknowledged separately. Software needs to acknowledge decoder interrupts by reading from the CDC’s STAT3 register, and it needs to acknowledge transfer end interrupts by writing to the CDC’s DTACK register. When handling INT5, software can read interrupt flags from the CDC’s IFSTAT register to know which interrupt(s) triggered.

My original implementation was to implement INT5 as simply the union of these two interrupt lines, i.e.:

1
2
3
4
5
6
impl Cdc {
    fn int5(&self) -> bool {
        (self.decoder_int_enabled && self.decoder_int_pending)
            || (self.transfer_end_int_enabled && self.transfer_end_int_pending)
    }
}

This worked ok for some games but not these three. These games seem to expect that whenever the sub CPU handles INT5, the interrupt is always acknowledged, even if the interrupt handler doesn’t touch the CDC’s STAT3 or DTACK registers. Implementing INT5 as above causes them all to get stuck in an infinite loop of repeatedly handling INT5.

As far as I can tell, INT5 is actually edge-triggered based on the CDC interrupt line. This means that INT5 gets set whenever the CDC interrupt line changes from high (false) to low (true), rather than always being equal to the CDC interrupt line. Then it gets cleared whenever the sub CPU handles the interrupt, specifically by the 68000 hardware interrupt acknowledge.

Assuming that’s remotely correct, a more accurate implementation would look something like this:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
impl Cdc {
    // Called whenever any interrupt enabled/pending flag changes
    fn update_interrupt_line(&mut self) {
        let cdc_int_line = (self.decoder_int_enabled && self.decoder_int_pending)
            || (self.transfer_end_int_enabled && self.transfer_end_int_pending);

        // INT5 triggers on edge (false -> true)
        self.int5_pending |= cdc_int_line && !self.prev_int_line;
        self.prev_int_line = cdc_int_line;
    }

    // Called to check whether INT5 is asserted
    fn int5(&self) -> bool {
        self.int5_pending
    }

    // Called when the sub CPU handles INT5
    fn acknowledge_int5(&mut self) {
        self.int5_pending = false;
    }
}

With that, no more freezing!

Snatcher No Freeze

I should also mention that tests in krikzz’s mcd-verificator test suite demonstrate that the decoder interrupt is automatically cleared about 40% of the way through a 75 Hz cycle if software does not explicitly acknowledge it by reading STAT3. However based on other tests in this suite I’m still pretty sure that INT5 is edge-triggered rather than being equal to the CDC interrupt line. (Also because emulating this auto-clear behavior by itself isn’t enough to fix Batman Returns, though it does fix Snatcher and Robo Aleste.)

To Be Continued?

These are only the tip of the iceberg of Sega CD issues that I ran into. For starters, Snatcher and Batman Returns both had major bugs once I fixed the above CDC interrupt bug and got them booting. For example: Batman Eyes

Probably obvious, but that one was not caused by anything related to CD-ROM emulation!

I will probably do a follow-up post (or posts) with more of these.

Useful References

updatedupdated2025-11-182025-11-18