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}