cdd.rsannotatedcdd.rssource1018 lines · 35.4 KB · raw

Sega CD's physical drive, which documentation refers to as the CDD

3use crate::api::SegaCdLoadResult;
4use crate::cddrive::cdc::{Rchip, RchipDmaArgs};
5use bincode::{Decode, Encode};
6use cdrom::cdtime::CdTime;
7use cdrom::cue::{Track, TrackType};
8use cdrom::reader::CdRom;
9use genesis_config::{GenesisRegion, SegaCdEmulatorConfig};
10use jgenesis_proc_macros::PartialClone;
11use regex::Regex;
12use std::cmp::Ordering;
13use std::sync::LazyLock;
14use std::{array, cmp};
16const INITIAL_STATUS: [u8; 10] = [0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0F];

The BIOS freezes if this delay is too short or too long Time Gal freezes at boot if this delay is too short

20const PLAY_DELAY_CLOCKS: u8 = 6;

2x signed 16-bit PCM samples, one per stereo channel

23const BYTES_PER_AUDIO_SAMPLE: u16 = 4;
25const MAX_FADER_VOLUME: u16 = 1 << 10;

Fast-forward / rewind should skip at roughly 100x playback speed

28const FAST_FORWARD_SECONDS: u8 = 1;
29const FAST_FORWARD_FRAMES: u8 = 25;
31const DIVIDER_75HZ: u16 = 44100 / 75;
32
33#[repr(u8)]
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35enum Status {
36    Stopped = 0x00,
37    Playing = 0x01,
38    Seeking = 0x02,
39    Scanning = 0x03,
40    Paused = 0x04,
41    TrayOpen = 0x05,
42    InvalidCommand = 0x07,
43    ReadingToc = 0x09,
44    TrackSkipping = 0x0A,
45    NoDisc = 0x0B,
46    DiscEnd = 0x0C,
47    DiscStart = 0x0D,
48    TrayMoving = 0x0E,
49}
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Encode, Decode)]
52enum ReportType {
53    #[default]
54    AbsoluteTime,
55    RelativeTime,
56    CurrentTrack,
57    DiscLength,
58    StartAndEndTracks,
59    TrackNStartTime(u8),
60}
61
62impl ReportType {
63    fn to_byte(self) -> u8 {
64        match self {
65            Self::AbsoluteTime => 0x00,
66            Self::RelativeTime => 0x01,
67            Self::CurrentTrack => 0x02,
68            Self::DiscLength => 0x03,
69            Self::StartAndEndTracks => 0x04,
70            Self::TrackNStartTime(..) => 0x05,
71        }
72    }
73
74    fn from_command(command: [u8; 10]) -> Self {
75        // Report type is always stored in Command 3 for Read TOC commands
76        match command[3] {
77            0x00 => Self::AbsoluteTime,
78            0x01 => Self::RelativeTime,
79            0x02 => Self::CurrentTrack,
80            0x03 => Self::DiscLength,
81            0x04 => Self::StartAndEndTracks,
82            0x05 => {
83                // Track number (BCD) is at Command 4-5
84                let track_number = 10 * command[4] + command[5];
85                Self::TrackNStartTime(track_number)
86            }
87            _ => {
88                log::warn!("Invalid CDD report type byte: {}; defaulting to absolute", command[3]);
89                Self::AbsoluteTime
90            }
91        }
92    }
93}
94
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)]
96enum ReaderStatus {
97    Playing,
98    Paused,
99}
100
101#[derive(Debug, Clone, Copy, Default, Encode, Decode)]
102enum State {
103    #[default]
104    MotorStopped,
105    NoDisc,
106    PreparingToPlay {
107        time: CdTime,
108        clocks_remaining: u8,
109    },
110    Playing(CdTime),
111    Paused(CdTime),
112    Seeking {
113        current_time: CdTime,
114        seek_time: CdTime,
115        next_status: ReaderStatus,
116        clocks_remaining: u8,
117    },
118    TrackSkipping {
119        current_time: CdTime,
120        skip_time: CdTime,
121        clocks_remaining: u8,
122    },
123    FastForwarding(CdTime),
124    Rewinding(CdTime),
125    DiscStart,
126    DiscEnd(CdTime),
127    TrayOpening {
128        auto_close: bool,
129    },
130    TrayOpen {
131        auto_close: bool,
132    },
133    TrayClosing,
134    InvalidCommand(CdTime),
135    ReadingToc,
136}
137
138impl State {
139    fn current_time(self) -> CdTime {
140        match self {
141            Self::MotorStopped
142            | Self::NoDisc
143            | Self::DiscStart
144            | Self::ReadingToc
145            | Self::TrayOpening { .. }
146            | Self::TrayOpen { .. }
147            | Self::TrayClosing => CdTime::ZERO,
148            Self::PreparingToPlay { time, .. }
149            | Self::Playing(time)
150            | Self::Paused(time)
151            | Self::FastForwarding(time)
152            | Self::Rewinding(time)
153            | Self::DiscEnd(time)
154            | Self::InvalidCommand(time) => time,
155            Self::Seeking { current_time, .. } | Self::TrackSkipping { current_time, .. } => {
156                current_time
157            }
158        }
159    }
160}
161
162#[derive(Debug, Clone, Copy, PartialEq, Eq, Encode, Decode)]
163pub enum CdModel {
164    One,
165    Two,
166}
167
168#[derive(Debug, Encode, Decode, PartialClone)]
169pub struct CdDrive {
170    #[partial_clone(default)]
171    disc: Option<CdRom>,
172    model: CdModel,
173    sector_buffer: Box<[u8; cdrom::BYTES_PER_SECTOR as usize]>,
174    state: State,
175    report_type: ReportType,
176    // If a command is sent while the drive is playing, it needs to read at least 1 more sector
177    // before it responds to the new command. Radical Rex depends on this or it will crash shortly
178    // into the intro
179    next_clock_play: Option<CdTime>,
180    interrupt_pending: bool,
181    status: [u8; 10],
182    audio_sample_idx: u16,
183    loaded_audio_sector: bool,
184    fader_volume: u16,
185    current_volume: u16,
186    divider_75hz: u16,
187    data_speed: u16,
188}
189
190impl CdDrive {
191    pub(super) fn new(disc: Option<CdRom>, model: CdModel, config: &SegaCdEmulatorConfig) -> Self {
192        Self {
193            disc,
194            model,
195            sector_buffer: Box::new(array::from_fn(|_| 0)),
196            state: State::default(),
197            report_type: ReportType::default(),
198            interrupt_pending: false,
199            next_clock_play: None,
200            status: INITIAL_STATUS,
201            audio_sample_idx: 0,
202            loaded_audio_sector: false,
203            fader_volume: 0,
204            current_volume: 0,
205            divider_75hz: DIVIDER_75HZ,
206            data_speed: config.disc_drive_speed.get(),
207        }
208    }
209
210    #[allow(clippy::match_same_arms)]
211    pub fn send_command(&mut self, command: [u8; 10]) {
212        log::trace!("CDD command: {command:02X?}");
213
214        let prev_playing_time = match self.state {
215            State::Playing(time) => Some(time),
216            _ => None,
217        };
218
219        match command[0] {
220            0x00 => {
221                // No-op; return current status
222                log::trace!("  Command: No-op");
223            }
224            0x01 => {
225                // Stop motor
226                log::trace!("  Command: Stop motor");
227
228                self.state = State::MotorStopped;
229                self.report_type = ReportType::AbsoluteTime;
230            }
231            0x02 => {
232                // Read TOC
233                log::trace!("  Command: Read TOC");
234
235                self.execute_read_toc(command);
236            }
237            0x03 => {
238                // Seek and play
239                log::trace!("  Command: Seek and play");
240
241                self.execute_seek(command, ReaderStatus::Playing);
242            }
243            0x04 => {
244                // Seek
245                log::trace!("  Command: Seek");
246
247                self.execute_seek(command, ReaderStatus::Paused);
248            }
249            0x06 => {
250                // Pause
251                log::trace!("  Command: Pause");
252
253                match &self.disc {
254                    Some(_) => {
255                        self.state = State::Paused(self.state.current_time());
256                    }
257                    None => {
258                        self.state = State::NoDisc;
259                    }
260                }
261            }
262            0x07 => {
263                // Play
264                log::trace!("  Command: Play");
265
266                match self.state {
267                    State::Paused(time) | State::FastForwarding(time) | State::Rewinding(time) => {
268                        self.state =
269                            State::PreparingToPlay { time, clocks_remaining: PLAY_DELAY_CLOCKS };
270                    }
271                    _ => {}
272                }
273            }
274            0x08 => {
275                // Fast-forward
276                log::trace!("  Command: Fast-forward");
277
278                if self.disc.is_some() {
279                    self.state = State::FastForwarding(self.state.current_time());
280                }
281            }
282            0x09 => {
283                // Rewind
284                log::trace!("  Command: Rewind");
285
286                if self.disc.is_some() {
287                    self.state = State::Rewinding(self.state.current_time());
288                }
289            }
290            0x0A => {
291                // Start track skipping
292                log::trace!("  Command: Track Skip");
293
294                self.execute_track_skip(command);
295            }
296            0x0B => {
297                // Start track cueing
298                // This command does not appear to be used by any official BIOS version
299                log::error!("Ignoring unimplemented CDD command: Cue Track");
300            }
301            0x0C => {
302                // Close tray
303                log::trace!("  Command: Close Tray");
304                if matches!(self.state, State::TrayOpening { .. } | State::TrayOpen { .. }) {
305                    self.state = State::TrayClosing;
306                }
307            }
308            0x0D => {
309                // Open tray
310                log::trace!("  Command: Open Tray");
311                if !matches!(self.state, State::TrayOpening { .. }) {
312                    self.state = State::TrayOpening { auto_close: false };
313                }
314            }
315            _ => {}
316        }
317
318        // Executing any command other than No-op or Read TOC cancels the TOCN report; default to absolute time
319        if command[0] != 0x00
320            && command[0] != 0x02
321            && matches!(self.report_type, ReportType::TrackNStartTime(..))
322        {
323            self.report_type = ReportType::AbsoluteTime;
324        }
325
326        self.update_status();
327
328        if prev_playing_time.is_some() && !matches!(self.state, State::Playing(..)) {
329            // If the drive was playing and a command just changed the state, have it read one more
330            // sector on the next clock before it handles the new state (e.g. seeking).
331            // Per tests by krikzz, the CDD status changes immediately in response to a command
332            // despite this behavior, so changing the state immediately is correct
333            self.next_clock_play = prev_playing_time;
334        }
335
336        log::trace!("CDD status: {:02X?}", self.status);
337    }
338
339    fn update_status(&mut self) {
340        self.status.fill(0);
341
342        // Status 0 is always drive status
343        self.status[0] = self.current_cdd_status() as u8;
344
345        // If drive is stopped, leave status as all 0s
346        if matches!(self.state, State::MotorStopped) {
347            update_cdd_checksum(&mut self.status);
348            return;
349        }
350
351        // If seeking/skipping, return "not ready" status; not doing this causes Lunar to randomly freeze
352        if matches!(
353            self.state,
354            State::Seeking { .. }
355                | State::TrackSkipping { .. }
356                | State::TrayOpening { .. }
357                | State::TrayOpen { .. }
358                | State::TrayClosing
359                | State::NoDisc
360        ) {
361            self.status[1] = 0x0F;
362            update_cdd_checksum(&mut self.status);
363            return;
364        }
365
366        // Status 1 is always report type
367        self.status[1] = self.report_type.to_byte();
368
369        // Only update other status bytes if there is a disc
370        if let Some(disc) = &self.disc {
371            // Status 2-8 depend on report type
372            match self.report_type {
373                ReportType::AbsoluteTime => {
374                    // Write current absolute time in minutes/seconds/frames (BCD) to Status 2-7
375                    let current_time = self.state.current_time();
376                    write_time_to_status(current_time, &mut self.status);
377                    self.status[8] = self.status_flags();
378                }
379                ReportType::RelativeTime => {
380                    // Write current relative time in minutes/seconds/frames (BCD) to Status 2-7
381                    let current_time = self.state.current_time();
382                    let track_start_time = disc
383                        .cue()
384                        .find_track_by_time(current_time)
385                        .map_or(CdTime::ZERO, Track::effective_start_time);
386                    write_time_to_status(
387                        current_time.saturating_sub(track_start_time),
388                        &mut self.status,
389                    );
390                    self.status[8] = self.status_flags();
391                }
392                ReportType::CurrentTrack => {
393                    // Write current track number (BCD) to Status 2-3
394                    let current_time = self.state.current_time();
395                    let track_number =
396                        disc.cue().find_track_by_time(current_time).map_or(0, |track| track.number);
397                    self.status[2] = track_number / 10;
398                    self.status[3] = track_number % 10;
399
400                    self.status[8] = self.status_flags();
401                }
402                ReportType::DiscLength => {
403                    // Write disc length in minutes/seconds/frames (BCD) to Status 2-7
404                    let disc_end_time = disc.cue().last_track().end_time;
405                    write_time_to_status(disc_end_time, &mut self.status);
406                    self.status[8] = self.status_flags();
407                }
408                ReportType::StartAndEndTracks => {
409                    // Write start track number to Status 2-3 and end track number to Status 4-5, both in BCD
410                    // Assume start track number is always 1
411                    self.status[2] = 0x00;
412                    self.status[3] = 0x01;
413
414                    let end_track_number = disc.cue().last_track().number;
415                    self.status[4] = end_track_number / 10;
416                    self.status[5] = end_track_number % 10;
417
418                    self.status[8] = self.status_flags();
419                }
420                ReportType::TrackNStartTime(track_number) => {
421                    let track = if track_number <= disc.cue().last_track().number {
422                        disc.cue().track(track_number)
423                    } else {
424                        // ??? Invalid track number
425                        disc.cue().last_track()
426                    };
427
428                    let track_start_time = track.effective_start_time();
429                    let track_type = track.track_type;
430
431                    // Write track start time in minutes/seconds/frames (BCD) to Status 2-7
432                    write_time_to_status(track_start_time, &mut self.status);
433
434                    // If this is a data track, OR Status 6 with $08
435                    if track_type == TrackType::Data {
436                        self.status[6] |= 0x08;
437                    }
438
439                    // Write the lower digit of track number to Status 8
440                    self.status[8] = track_number % 10;
441                }
442            }
443        }
444
445        // Update checksum in Status 9
446        update_cdd_checksum(&mut self.status);
447    }
448
449    fn status_flags(&self) -> u8 {
450        // $04 if playing a data track, $00 otherwise
451        let playing_data_track = match self.state {
452            State::Playing(time) | State::PreparingToPlay { time, .. } => {
453                self.disc.as_ref().is_some_and(|disc| {
454                    disc.cue()
455                        .find_track_by_time(time)
456                        .is_some_and(|track| track.track_type == TrackType::Data)
457                })
458            }
459            _ => false,
460        };
461
462        if playing_data_track { 0x04 } else { 0x00 }
463    }
464
465    fn current_cdd_status(&self) -> Status {
466        match self.state {
467            State::MotorStopped => Status::Stopped,
468            State::NoDisc => Status::NoDisc,
469            State::Paused(..) => Status::Paused,
470            State::PreparingToPlay { .. } | State::Playing(..) => Status::Playing,
471            State::Seeking { .. } => Status::Seeking,
472            State::TrackSkipping { .. } => Status::TrackSkipping,
473            State::FastForwarding(..) | State::Rewinding(..) => Status::Scanning,
474            State::DiscStart => Status::DiscStart,
475            State::DiscEnd(..) => Status::DiscEnd,
476            State::TrayOpening { .. } | State::TrayClosing => Status::TrayMoving,
477            State::TrayOpen { .. } => Status::TrayOpen,
478            State::InvalidCommand(..) => Status::InvalidCommand,
479            State::ReadingToc => Status::ReadingToc,
480        }
481    }
482
483    fn execute_read_toc(&mut self, command: [u8; 10]) {
484        let report_type = ReportType::from_command(command);
485        self.report_type = report_type;
486
487        log::trace!("  Report type changed to {report_type:?}");
488
489        self.state = match (self.state, &self.disc, report_type) {
490            (State::MotorStopped, None, _) => State::NoDisc,
491            (State::MotorStopped, Some(_), _) => State::Paused(CdTime::ZERO),
492            (_, Some(_), ReportType::TrackNStartTime(..)) => {
493                // TOCN reports require reading the TOC; move back to start of disc
494                State::ReadingToc
495            }
496            _ => self.state,
497        };
498    }
499
500    fn execute_seek(&mut self, command: [u8; 10], next_status: ReaderStatus) {
501        if self.disc.is_none() {
502            self.state = State::NoDisc;
503            return;
504        }
505
506        let Some(seek_time) = read_time_from_command(command) else {
507            self.state = State::InvalidCommand(self.state.current_time());
508            return;
509        };
510
511        let current_time = self.state.current_time();
512
513        if seek_time == current_time {
514            log::trace!(
515                "Already at desired seek time {seek_time}; changing status to {next_status:?}"
516            );
517            self.state = match next_status {
518                ReaderStatus::Paused => State::Paused(seek_time),
519                ReaderStatus::Playing => {
520                    State::PreparingToPlay { time: seek_time, clocks_remaining: PLAY_DELAY_CLOCKS }
521                }
522            };
523            return;
524        }
525
526        // Enforce a minimum seek time of 7 cycles at 75 Hz
527        // At least one game depends on this, the Japanese version of Cobra Command / Thunder Storm FX
528        let seek_clocks = cmp::max(7, estimate_seek_clocks(current_time, seek_time));
529
530        log::trace!(
531            "Seeking from {current_time} to {seek_time}; estimated time {seek_clocks} 75Hz clocks"
532        );
533
534        self.state =
535            State::Seeking { current_time, seek_time, next_status, clocks_remaining: seek_clocks };
536    }
537
538    fn execute_track_skip(&mut self, command: [u8; 10]) {
539        let Some(disc) = &self.disc else {
540            self.state = State::NoDisc;
541            return;
542        };
543
544        // Number of "tracks" to skip is in Command 4-7, as a 16-bit value stored across 4 nibbles
545        let skip_tracks = (u32::from(command[4] & 0x0F) << 12)
546            | (u32::from(command[5] & 0x0F) << 8)
547            | (u32::from(command[6] & 0x0F) << 4)
548            | u32::from(command[7] & 0x0F);
549
550        // Treat a "track" as 15 blocks. This isn't completely accurate but it doesn't need to be.
551        // The BIOS will often issue a Track Skip command before a Seek or Read command.
552        let skip_blocks = 15 * skip_tracks;
553
554        let current_time = self.state.current_time();
555
556        // Command 3 holds direction; treat 0 as positive, non-0 as negative
557        let skip_time = if command[3] == 0 {
558            // Skip forwards
559            let skip_sector = current_time.to_sector_number() + skip_blocks;
560            let disc_end_time = disc.cue().last_track().end_time;
561
562            if skip_sector >= disc_end_time.to_sector_number() {
563                disc_end_time
564            } else {
565                CdTime::from_sector_number(skip_sector)
566            }
567        } else {
568            // Skip backwards
569            let skip_sector = current_time.to_sector_number().saturating_sub(skip_blocks);
570            CdTime::from_sector_number(skip_sector)
571        };
572
573        let clocks_required = estimate_seek_clocks(current_time, skip_time);
574
575        log::trace!(
576            "Skipping from {current_time} to {skip_time}; estimated {clocks_required} 75Hz cycles"
577        );
578
579        self.state =
580            State::TrackSkipping { current_time, skip_time, clocks_remaining: clocks_required };
581    }
582
583    pub fn status(&self) -> [u8; 10] {
584        self.status
585    }
586
587    fn reading_data_track(&self) -> bool {
588        match self.state {
589            State::Playing(current_time) => self.disc.as_ref().is_some_and(|disc| {
590                disc.cue()
591                    .find_track_by_time(current_time)
592                    .is_some_and(|track| track.track_type == TrackType::Data)
593            }),
594            _ => false,
595        }
596    }
597
598    pub fn playing_audio(&self) -> bool {
599        match self.state {
600            State::Playing(current_time) => {
601                let is_audio_track = self.disc.as_ref().is_some_and(|disc| {
602                    disc.cue()
603                        .find_track_by_time(current_time)
604                        .is_some_and(|track| track.track_type == TrackType::Audio)
605                });
606                is_audio_track && self.loaded_audio_sector
607            }
608            _ => false,
609        }
610    }
611
612    pub fn set_fader_volume(&mut self, fader_volume: u16) {
613        self.fader_volume = cmp::min(MAX_FADER_VOLUME, fader_volume);
614
615        log::trace!("Fader volume set to {:03X}", self.fader_volume);
616    }
617
618    pub fn clock_44100hz(
619        &mut self,
620        rchip: &mut Rchip,
621        mut rchip_dma_args: RchipDmaArgs<'_>,
622    ) -> SegaCdLoadResult<(f64, f64)> {
623        self.adjust_fader_volume();
624
625        let audio_sample = self.sample_audio().unwrap_or((0.0, 0.0));
626
627        let divider_decrement = if self.reading_data_track() { self.data_speed } else { 1 };
628        for _ in 0..divider_decrement {
629            self.divider_75hz -= 1;
630            if self.divider_75hz == 0 {
631                self.divider_75hz = DIVIDER_75HZ;
632                self.clock_75hz(rchip)?;
633                rchip.clock_75hz();
634            } else {
635                rchip.clock_44100hz(rchip_dma_args.reborrow());
636            }
637        }
638
639        Ok(audio_sample)
640    }
641
642    fn adjust_fader_volume(&mut self) {
643        match self.current_volume.cmp(&self.fader_volume) {
644            Ordering::Less => self.current_volume += 1,
645            Ordering::Greater => self.current_volume -= 1,
646            Ordering::Equal => {}
647        }
648    }
649
650    fn sample_audio(&mut self) -> Option<(f64, f64)> {
651        if !self.playing_audio() {
652            self.audio_sample_idx = 0;
653            return None;
654        }
655
656        let idx = self.audio_sample_idx as usize;
657
658        let sample_l = i16::from_le_bytes([self.sector_buffer[idx], self.sector_buffer[idx + 1]]);
659        let sample_r =
660            i16::from_le_bytes([self.sector_buffer[idx + 2], self.sector_buffer[idx + 3]]);
661
662        let fader_multiplier = fader_volume_multiplier(self.current_volume);
663
664        let sample_l = fader_multiplier * f64::from(sample_l) / -f64::from(i16::MIN);
665        let sample_r = fader_multiplier * f64::from(sample_r) / -f64::from(i16::MIN);
666
667        self.audio_sample_idx =
668            (self.audio_sample_idx + BYTES_PER_AUDIO_SAMPLE) % cdrom::BYTES_PER_SECTOR as u16;
669
670        Some((sample_l, sample_r))
671    }
672
673    fn clock_75hz(&mut self, rchip: &mut Rchip) -> SegaCdLoadResult<()> {
674        // It is a bug if clock_75hz() is called when audio index is not 0; sample_audio() must
675        // be called before clock_75hz() on the cycle when both are called
676        assert_eq!(self.audio_sample_idx, 0);
677
678        // CDD interrupt fires once every 1/75 of a second
679        self.interrupt_pending = true;
680
681        if let Some(time) = self.next_clock_play.take() {
682            // If the state was just changed from playing, read one more sector before handling the
683            // new state
684            log::trace!("Reading sector at {time} before handling new state of {:?}", self.state);
685            return self.handle_playing(time, false, rchip);
686        }
687
688        match self.state {
689            State::Seeking { current_time, seek_time, next_status, clocks_remaining } => {
690                if clocks_remaining == 1 {
691                    log::trace!("Seek to {seek_time} complete");
692
693                    self.state = match next_status {
694                        ReaderStatus::Paused => State::Paused(seek_time),
695                        ReaderStatus::Playing => State::PreparingToPlay {
696                            time: seek_time,
697                            clocks_remaining: PLAY_DELAY_CLOCKS,
698                        },
699                    };
700                } else {
701                    // 113 clocks to seek across the entire disc
702                    let new_time = estimate_intermediate_seek_time(
703                        current_time,
704                        seek_time,
705                        clocks_remaining - 1,
706                    );
707
708                    log::trace!(
709                        "Current seek status: prev_time={current_time}, new_time={new_time}, seek_time={seek_time}, clocks_remaining={}",
710                        clocks_remaining - 1
711                    );
712
713                    self.state = State::Seeking {
714                        current_time: new_time,
715                        seek_time,
716                        next_status,
717                        clocks_remaining: clocks_remaining - 1,
718                    };
719                }
720            }
721            State::TrackSkipping { current_time, skip_time, clocks_remaining } => {
722                if clocks_remaining == 1 {
723                    log::trace!("Skip to {skip_time} complete");
724
725                    self.state = State::Paused(skip_time);
726                } else {
727                    // 56 clocks to skip across the entire desc
728                    let new_time = estimate_intermediate_seek_time(
729                        current_time,
730                        skip_time,
731                        clocks_remaining - 1,
732                    );
733
734                    log::trace!(
735                        "Current skip status: prev_time={current_time}, new_time={new_time}, skip_time={skip_time}, clocks_remaining={}",
736                        clocks_remaining - 1
737                    );
738
739                    self.state = State::TrackSkipping {
740                        current_time: new_time,
741                        skip_time,
742                        clocks_remaining: clocks_remaining - 1,
743                    };
744                }
745            }
746            State::FastForwarding(time) => {
747                let new_time = time + CdTime::new(0, FAST_FORWARD_SECONDS, FAST_FORWARD_FRAMES);
748                self.state = if let Some(disc) = &self.disc {
749                    let disc_end_time = disc.cue().last_track().end_time;
750                    if new_time >= disc_end_time {
751                        log::trace!("Fast-forwarded to end of disc");
752                        State::DiscEnd(disc_end_time)
753                    } else {
754                        log::trace!("Fast-forwarded to {new_time}");
755                        State::FastForwarding(new_time)
756                    }
757                } else {
758                    log::trace!("Fast-forwarded to {new_time}");
759                    State::FastForwarding(new_time)
760                };
761            }
762            State::Rewinding(time) => {
763                let new_time =
764                    time.saturating_sub(CdTime::new(0, FAST_FORWARD_SECONDS, FAST_FORWARD_FRAMES));
765                if new_time == CdTime::ZERO {
766                    log::trace!("Rewound to beginning of disc");
767                    self.state = State::DiscStart;
768                } else {
769                    log::trace!("Rewound to {new_time}");
770                    self.state = State::Rewinding(new_time);
771                }
772            }
773            State::PreparingToPlay { time, clocks_remaining } => {
774                if clocks_remaining == 1 {
775                    log::trace!("Beginning to play at {time}");
776
777                    self.state = State::Playing(time);
778
779                    // Ensure that leftover data in the buffer doesn't get played until the buffer is refilled
780                    self.loaded_audio_sector = false;
781                } else {
782                    self.state =
783                        State::PreparingToPlay { time, clocks_remaining: clocks_remaining - 1 };
784                }
785            }
786            State::Playing(time) => {
787                log::trace!("Playing at {time}");
788                self.handle_playing(time, true, rchip)?;
789            }
790            State::MotorStopped => {
791                match &self.disc {
792                    Some(_) => {
793                        // Always transition to Reading TOC one clock after the motor is stopped; this fixes
794                        // the EU BIOS freezing after leaving the options menu
795                        self.state = State::ReadingToc;
796                    }
797                    None => {
798                        self.state = State::NoDisc;
799                    }
800                }
801            }
802            State::TrayOpening { auto_close } => {
803                self.state = State::TrayOpen { auto_close };
804            }
805            State::TrayClosing => {
806                self.state = State::MotorStopped;
807                self.report_type = ReportType::AbsoluteTime;
808            }
809            State::TrayOpen { auto_close: true } => {
810                self.state = State::TrayClosing;
811            }
812            _ => {}
813        }
814
815        Ok(())
816    }
817
818    fn handle_playing(
819        &mut self,
820        time: CdTime,
821        change_state: bool,
822        rchip: &mut Rchip,
823    ) -> SegaCdLoadResult<()> {
824        let Some(disc) = &mut self.disc else {
825            if change_state {
826                self.state = State::NoDisc;
827            }
828            return Ok(());
829        };
830
831        let Some(track) = disc.cue().find_track_by_time(time) else {
832            if change_state {
833                self.state = State::DiscEnd(disc.cue().last_track().end_time);
834            }
835            return Ok(());
836        };
837
838        let relative_time = time - track.start_time;
839        let track_type = track.track_type;
840        disc.read_sector(track.number, relative_time, self.sector_buffer.as_mut())?;
841
842        self.loaded_audio_sector = track_type == TrackType::Audio;
843
844        rchip.decode_block(&self.sector_buffer);
845
846        if change_state {
847            self.state = State::Playing(time + CdTime::new(0, 0, 1));
848        }
849
850        Ok(())
851    }
852
853    pub fn interrupt_pending(&self) -> bool {
854        self.interrupt_pending
855    }
856
857    pub fn acknowledge_interrupt(&mut self) {
858        self.interrupt_pending = false;
859    }
860
861    #[allow(clippy::items_after_statements)]
862    pub fn disc_title(&mut self, region: GenesisRegion) -> SegaCdLoadResult<Option<String>> {
863        let Some(disc) = &mut self.disc else { return Ok(None) };
864
865        // Title information is always stored in the first sector of track 1
866        disc.read_sector(1, CdTime::SECTOR_0_START, self.sector_buffer.as_mut())?;
867
868        let title_bytes = match region {
869            GenesisRegion::Japan => &self.sector_buffer[0x130..0x160],
870            GenesisRegion::Americas | GenesisRegion::Europe => &self.sector_buffer[0x160..0x190],
871        };
872        let title: String = title_bytes
873            .iter()
874            .copied()
875            .filter_map(|b| {
876                let c = b as char;
877                (c.is_ascii_alphanumeric() || c.is_ascii_whitespace() || c.is_ascii_punctuation())
878                    .then_some(c)
879            })
880            .collect();
881
882        static WHITESPACE_RE: LazyLock<Regex> = LazyLock::new(|| Regex::new(r" +").unwrap());
883
884        Ok(Some(WHITESPACE_RE.replace_all(title.trim(), " ").to_string()))
885    }
886
887    pub fn has_six_button_incompatible_game(&mut self) -> SegaCdLoadResult<bool> {
888        let Some(disc) = &mut self.disc else { return Ok(false) };
889
890        disc.read_sector(1, CdTime::SECTOR_0_START, self.sector_buffer.as_mut())?;
891
892        // Skip sync bytes and sector header
893        let first_data_sector = &self.sector_buffer[16..];
894        Ok(genesis_components::cartridge::is_six_button_incompatible(first_data_sector))
895    }
896
897    pub fn take_disc(&mut self) -> Option<CdRom> {
898        self.disc.take()
899    }
900
901    pub fn take_disc_from(&mut self, other: &mut Self) {
902        self.disc = other.disc.take();
903    }
904
905    pub fn reset(&mut self) {
906        self.state = State::default();
907        self.report_type = ReportType::default();
908        self.status = INITIAL_STATUS;
909        self.interrupt_pending = false;
910    }
911
912    pub fn remove_disc(&mut self) {
913        log::info!("Removing disc");
914
915        self.disc = None;
916        self.state = State::TrayOpening { auto_close: self.model == CdModel::Two };
917    }
918
919    pub fn change_disc(&mut self, disc: CdRom) {
920        log::info!("Changing disc");
921
922        self.disc = Some(disc);
923
924        // Only open the tray if running a Model 2 BIOS version.
925        // Model 1 BIOS versions will usually crash if the tray opens without the BIOS first sending
926        // an "open tray" command
927        if self.model == CdModel::Two {
928            self.state = State::TrayOpening { auto_close: true };
929        }
930    }
931
932    pub fn reload_config(&mut self, config: &SegaCdEmulatorConfig) {
933        self.data_speed = config.disc_drive_speed.get();
934    }
935}
936
937fn fader_volume_multiplier(volume: u16) -> f64 {
938    // Yes, 1025; fader volumes range from 0 to 1024 inclusive
939    static LOOKUP_TABLE: LazyLock<[f64; 1025]> = LazyLock::new(|| {
940        let mut lookup_table = [0.0; 1025];
941
942        // The fader appears to use a linear scale, not logarithmic.
943        //
944        // Based on LC7883 documentation:
945        //   For every volume >3, multiplier is (Bits 9-2)/256
946        //   For volumes 1-3, multiplier is (Bit 1)/512 + (Bit 0)/1024. This matches the dB values in LC7883 docs
947        //   For volume 0, multiplier is 0 (i.e. attenuation -inf)
948        for (i, value) in lookup_table.iter_mut().enumerate() {
949            *value = match i {
950                0..=3 => i as f64 / 1024.0,
951                4..=1024 => (i >> 2) as f64 / 256.0,
952                _ => unreachable!("array len is 1025"),
953            };
954        }
955
956        lookup_table
957    });
958
959    LOOKUP_TABLE[volume as usize]
960}

Checksum is the first 9 nibbles summed and then inverted

963fn update_cdd_checksum(cdd_status: &mut [u8; 10]) {
964    let sum = cdd_status[0..9].iter().copied().sum::<u8>();
965    cdd_status[9] = !sum & 0x0F;
966}
968fn read_time_from_command(command: [u8; 10]) -> Option<CdTime> {
969    // Minutes stored in Command 2-3
970    let minutes = 10 * command[2] + command[3];
971
972    // Seconds stored in Command 4-5
973    let seconds = 10 * command[4] + command[5];
974
975    // Frames stored in Command 6-7
976    let frames = 10 * command[6] + command[7];
977
978    CdTime::new_checked(minutes, seconds, frames)
979}
980
981fn write_time_to_status(time: CdTime, status: &mut [u8; 10]) {
982    // Minutes stored in Status 2-3
983    status[2] = time.minutes / 10;
984    status[3] = time.minutes % 10;
985
986    // Seconds stored in Status 4-5
987    status[4] = time.seconds / 10;
988    status[5] = time.seconds % 10;
989
990    // Frames stored in Status 6-7
991    status[6] = time.frames / 10;
992    status[7] = time.frames % 10;
993}
994
995fn estimate_seek_clocks(current_time: CdTime, seek_time: CdTime) -> u8 {
996    let diff =
997        if current_time >= seek_time { current_time - seek_time } else { seek_time - current_time };
998
999    // It supposedly takes roughly 1.5 seconds / 113 frames to seek from one end of the disc to the
1000    // other, so scale based on that
1001    let seek_cycles = (113.0 * f64::from(diff.to_frames())
1002        / f64::from(CdTime::DISC_END.to_frames()))
1003    .round() as u8;
1004
1005    // Require seek to always take at least 1 cycle
1006    cmp::max(1, seek_cycles)
1007}
1008
1009fn estimate_intermediate_seek_time(
1010    current_time: CdTime,
1011    seek_time: CdTime,
1012    clocks_remaining: u8,
1013) -> CdTime {
1014    let diff_frames = f64::from(clocks_remaining) / 113.0 * f64::from(CdTime::DISC_END.to_frames());
1015    let diff = CdTime::from_frames(diff_frames.round() as u32);
1016
1017    if current_time < seek_time { seek_time - diff } else { seek_time + diff }
1018}