Skip to main content
← PSoXide SDK guides and examples · SDK f2fec1c301f1 · All Cargo features enabled; check feature gates before use.

psx_pack/
cd.rs

1// SPDX-License-Identifier: GPL-2.0-or-later
2//! The pack-table scan and chunk streaming, on top of the polled sector
3//! reader.
4//!
5//! The CD controller has one driver, `psx_io::cd`: the `Cd` token, its
6//! register steps, and [`SectorReader`], the polled reader that sequences
7//! SetMode, Setloc, ReadN and Pause the way the pack loader has run on
8//! silicon. This module holds what the pack format adds: finding a chunk's
9//! table entry and copying its sectors into a caller's buffer.
10//!
11//! Everything that touches the drive is `cfg(target_arch = "mips")`; on the
12//! host only the constants and the [`SectorReader`] re-export compile, so
13//! `cargo test` keeps covering the pure pieces.
14//!
15//! # What loading does to the machine
16//!
17//! `load_chunk` and `find_entry` (guest only) bracket each read with
18//! [`SectorReader::prepare`] and [`SectorReader::stop`]. Between the two,
19//! `I_MASK` is VBlank-only (the reader polls the controller's own IRQ flags,
20//! so a CD-ROM CPU interrupt with no handler cannot storm). `stop` puts the
21//! previous mask back, so a caller's timer or controller interrupts survive a
22//! chunk load. The reader needs the `Cd` token: `SectorReader::with_cd`.
23//!
24//! No caching happens at this layer. hl-psx's 512-entry table cache (skip
25//! re-scanning the header on every chunk load) is a game-side optimization:
26//! keep it in the game, keyed to its own pack, where the entry count and the
27//! RAM budget are known.
28
29use crate::SECTOR_BYTES;
30#[cfg(target_arch = "mips")]
31use crate::{entry_location, parse_entry_at, parse_header, PackEntry, ENTRY_BYTES};
32
33/// Moved to `psx_io::cd::reader`. Kept at this path for one stage so
34/// `psx_pack::cd::SectorReader` still resolves; a re-export cannot carry a
35/// deprecation.
36pub use psx_io::cd::reader::{
37    SectorReader, DIAG_CD_ERROR, DIAG_PARAM_STUCK, DIAG_SITE_READ, DIAG_TIMEOUT,
38};
39
40/// One CD sector's user data in 32-bit words (`SECTOR_BYTES / 4`).
41pub const SECTOR_WORDS: usize = SECTOR_BYTES / 4;
42
43const _: () = assert!(SECTOR_WORDS == psx_io::cd::reader::SECTOR_WORDS);
44
45/// Where `mkisopsx` / the editor's embedded Play place `WORLD.PAK`: the pack's
46/// first sector, as an absolute data-track LBA. Mirrors
47/// `psx_iso::WORLD_PACK_DEFAULT_START_LBA` (the writer reserves a fixed boot
48/// area so runtime LBAs never depend on the boot EXE size); a host test in
49/// this crate asserts the two constants stay equal.
50pub const WORLD_PACK_DEFAULT_LBA: u32 = 1024;
51
52/// The sector currently in `scratch`, as bytes (little-endian DMA words are
53/// exactly the on-disc byte order).
54#[cfg(target_arch = "mips")]
55fn scratch_bytes(scratch: &[u32; SECTOR_WORDS]) -> &[u8] {
56    // SAFETY: [u32; N] reinterpreted as its own bytes; alignment shrinks.
57    unsafe { core::slice::from_raw_parts(scratch.as_ptr() as *const u8, SECTOR_BYTES) }
58}
59
60/// Read pack header sector `sector` (relative to `pack_lba`) into `scratch`,
61/// unless `*loaded` says it is already there. Each miss is a full
62/// prepare/seek/read/stop cycle, which is why sequential entry scans track
63/// `loaded` (ports hl-psx's `load_pack_header_sector`).
64#[cfg(target_arch = "mips")]
65fn load_header_sector(
66    rd: &mut SectorReader,
67    pack_lba: u32,
68    scratch: &mut [u32; SECTOR_WORDS],
69    loaded: &mut u32,
70    sector: u32,
71) -> bool {
72    if *loaded == sector {
73        return true;
74    }
75    if !rd.prepare() || !rd.start_read(pack_lba + sector) {
76        rd.stop();
77        return false;
78    }
79    let ok = rd.read_sector(scratch);
80    rd.stop();
81    if ok {
82        *loaded = sector;
83    }
84    ok
85}
86
87/// Read table entry `index` while scanning, stitching an entry that straddles
88/// two header sectors (ports hl-psx's `read_pack_entry`; the sector math and
89/// byte parsing are the crate's host-tested [`entry_location`] /
90/// [`parse_entry_at`]).
91#[cfg(target_arch = "mips")]
92fn read_entry(
93    rd: &mut SectorReader,
94    pack_lba: u32,
95    scratch: &mut [u32; SECTOR_WORDS],
96    loaded: &mut u32,
97    header_sectors: u32,
98    index: u32,
99) -> Option<PackEntry> {
100    let (sector, within) = entry_location(index);
101    if sector >= header_sectors {
102        return None;
103    }
104    if !load_header_sector(rd, pack_lba, scratch, loaded, sector) {
105        return None;
106    }
107    if within + ENTRY_BYTES <= SECTOR_BYTES {
108        parse_entry_at(scratch_bytes(scratch), within)
109    } else {
110        // Entry spans this sector and the next; stitch the 24 bytes together.
111        if sector + 1 >= header_sectors {
112            return None;
113        }
114        let first = SECTOR_BYTES - within;
115        let mut stitched = [0u8; ENTRY_BYTES];
116        stitched[..first].copy_from_slice(&scratch_bytes(scratch)[within..]);
117        if !load_header_sector(rd, pack_lba, scratch, loaded, sector + 1) {
118            return None;
119        }
120        stitched[first..].copy_from_slice(&scratch_bytes(scratch)[..ENTRY_BYTES - first]);
121        parse_entry_at(&stitched, 0)
122    }
123}
124
125/// Scan the pack table at `pack_lba` for `chunk_id` and return its entry
126/// (including `byte_size` and the FNV `checksum` the writer stored).
127///
128/// Reads the header/table sectors through `scratch`, one at a time. `None`
129/// on read failure, bad magic/version, or id not present. `I_MASK` is
130/// VBlank-only while it reads and restored when it returns.
131#[cfg(target_arch = "mips")]
132pub fn find_entry(
133    rd: &mut SectorReader,
134    pack_lba: u32,
135    chunk_id: u32,
136    scratch: &mut [u32; SECTOR_WORDS],
137) -> Option<PackEntry> {
138    let mut loaded = u32::MAX;
139    if !load_header_sector(rd, pack_lba, scratch, &mut loaded, 0) {
140        return None;
141    }
142    let header = parse_header(scratch_bytes(scratch))?;
143    let mut index = 0u32;
144    while index < header.chunk_count {
145        let entry = read_entry(
146            rd,
147            pack_lba,
148            scratch,
149            &mut loaded,
150            header.header_sectors,
151            index,
152        )?;
153        if entry.chunk_id == chunk_id {
154            return Some(entry);
155        }
156        index += 1;
157    }
158    None
159}
160
161/// Stream chunk `chunk_id` from the pack at `pack_lba` into `dst`.
162///
163/// Scans the table for the entry (sector-by-sector through `scratch`,
164/// straddle-safe), verifies the payload fits `dst`, then seeks to the payload
165/// and copies it in whole sectors via `scratch`. Returns the chunk's exact
166/// byte size, or `None` (no disc / not found / too big / read error). The
167/// payload lands at `dst[0]` still compressed if the writer compressed it;
168/// see [`load_chunk_decompressed`].
169///
170/// Reads only as many sectors as `byte_size` needs: the table's padded
171/// `sector_count` could be garbage and looping on it would hang the loader.
172///
173/// `I_MASK` is VBlank-only while the reads run and back to what it was when
174/// this returns. No caching; scan cost is linear in the table, so games
175/// loading many chunks should keep their own id table (see module doc).
176#[cfg(target_arch = "mips")]
177pub fn load_chunk(
178    rd: &mut SectorReader,
179    pack_lba: u32,
180    chunk_id: u32,
181    scratch: &mut [u32; SECTOR_WORDS],
182    dst: &mut [u32],
183) -> Option<usize> {
184    let entry = find_entry(rd, pack_lba, chunk_id, scratch)?;
185    let byte_size = entry.byte_size as usize;
186    if byte_size > dst.len() * 4 {
187        return None;
188    }
189    if !rd.prepare() || !rd.start_read(pack_lba + entry.sector_offset) {
190        rd.stop();
191        return None;
192    }
193    let dst_ptr = dst.as_mut_ptr() as *mut u8;
194    let needed = byte_size.div_ceil(SECTOR_BYTES);
195    let mut s = 0usize;
196    while s < needed {
197        if !rd.read_sector(scratch) {
198            rd.stop();
199            return None;
200        }
201        let off = s * SECTOR_BYTES;
202        let copy = byte_size.saturating_sub(off).min(SECTOR_BYTES);
203        if copy > 0 {
204            // SAFETY: `off + copy <= byte_size <= dst.len() * 4` (checked
205            // above), so the write stays inside `dst`; `scratch` is a whole
206            // sector and `copy <= SECTOR_BYTES`; the two are distinct borrows.
207            unsafe {
208                core::ptr::copy_nonoverlapping(
209                    scratch.as_ptr() as *const u8,
210                    dst_ptr.add(off),
211                    copy,
212                );
213            }
214        }
215        s += 1;
216    }
217    rd.stop();
218    Some(byte_size)
219}
220
221/// [`load_chunk`], then [`crate::decompress_hlzc_in_place`] on the result.
222///
223/// Returns the chunk's RAW byte length: the decompressed size for an `HLZC`
224/// chunk, the stored size for an uncompressed one (raw passthrough). `None`
225/// on any load or decode failure. `dst` must hold the raw payload plus the
226/// in-place LZ4 margin (see the decompressor's docs); a too-small buffer
227/// fails cleanly.
228#[cfg(target_arch = "mips")]
229pub fn load_chunk_decompressed(
230    rd: &mut SectorReader,
231    pack_lba: u32,
232    chunk_id: u32,
233    scratch: &mut [u32; SECTOR_WORDS],
234    dst: &mut [u32],
235) -> Option<usize> {
236    let loaded = load_chunk(rd, pack_lba, chunk_id, scratch, dst)?;
237    // SAFETY: [u32] viewed as its own bytes for the in-place decoder.
238    let bytes =
239        unsafe { core::slice::from_raw_parts_mut(dst.as_mut_ptr() as *mut u8, dst.len() * 4) };
240    crate::decompress_hlzc_in_place(bytes, loaded)
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246
247    #[test]
248    fn the_default_pack_lba_seeks_where_the_disc_math_says() {
249        // The default pack LBA: 1024 + 150 lead-in sectors = 1174 = 15 * 75 + 49,
250        // so Setloc gets 00:15:49. (The BCD arithmetic itself is psx-io's, and
251        // tested there.)
252        assert_eq!(
253            psx_io::cd::lba_to_bcd_msf(WORLD_PACK_DEFAULT_LBA),
254            [0x00, 0x15, 0x49]
255        );
256    }
257
258    #[test]
259    fn default_pack_lba_matches_the_writer() {
260        // psx-iso (the pack writer) is a host-only crate, so the guest-side
261        // constant is mirrored here; this pins them together.
262        assert_eq!(
263            WORLD_PACK_DEFAULT_LBA,
264            psx_iso::WORLD_PACK_DEFAULT_START_LBA
265        );
266    }
267}