saturn_patch/
lib.rs

1use std::cmp::min;
2use std::convert::TryInto;
3use std::ffi::OsString;
4use std::fs::File;
5use std::io::{Read, Write};
6use std::path::Path;
7
8use anyhow::{bail, Result};
9
10use hmac_sha256::Hash;
11
12mod cdrom;
13
14use crate::cdrom::CDRomImage;
15
16// you may change this if you wish
17pub const DESIRED_SATURN_DISC: SaturnDisc = SaturnDisc {
18    // desired regions in order of preference (some disks only support 1 region, in which case first would be picked)
19    desired_region_bytes: *br"JUBLKTEA",
20    // will replace manufacturer bytes, so you can boot with KD02 black boot disc, requires 16 bytes exactly
21    desired_mfr_bytes: *br"SEGA TP T-81    ",
22    // this is what https://madroms.satakore.com/ uses
23    //desired_mfr_bytes: *br"SEGA TP ERPRISES"
24};
25
26// don't change below here
27const SEGA_SATURN_BYTES: &[u8; 16] = br"SEGA SEGASATURN ";
28
29const BACKUP_FILE_EXT: &str = ".saturnpatchbak";
30
31// the order here has to match the order in REGION_STRINGS
32const REGION_CODES: &[u8; 8] = br"JTUBKAEL";
33
34const REGION_STRING_LEN: usize = 32;
35
36const REGION_STRINGS: &[&[u8; REGION_STRING_LEN]] = &[
37    b"\xA0\x0E\x00\x09For JAPAN.                  ",
38    b"\xA0\x0E\x00\x09For TAIWAN and PHILIPINES.  ",
39    b"\xA0\x0E\x00\x09For USA and CANADA.         ",
40    b"\xA0\x0E\x00\x09For BRAZIL.                 ",
41    b"\xA0\x0E\x00\x09For KOREA.                  ",
42    b"\xA0\x0E\x00\x09For ASIA PAL area.          ",
43    b"\xA0\x0E\x00\x09For EUROPE.                 ",
44    b"\xA0\x0E\x00\x09For LATIN AMERICA.          ",
45];
46
47// 1 byte version, 32 byte header hash, and more bytes after but that's verified with a hash so it's fine
48const MIN_BACKUP_SIZE: usize = 1 + 32 + 1;
49
50fn region_index(region: &u8) -> Result<usize> {
51    let mut i = 0;
52    for c in REGION_CODES {
53        if c == region {
54            return Ok(i);
55        }
56        i += 1;
57    }
58    bail!("invalid region: {}", *region as char);
59}
60
61fn region_copy_sort_pad(regions: &[u8]) -> Vec<u8> {
62    let mut ret = regions.to_vec();
63    ret.sort_by(|a, b| region_index(a).unwrap_or(10).cmp(&region_index(b).unwrap_or(10)));
64    while ret.len() < 16 {
65        ret.push(b' ');
66    }
67    ret
68}
69
70fn region_count(regions: &[u8]) -> usize {
71    let mut ret = 0;
72    while ret < regions.len() {
73        if regions[ret] == b' ' {
74            return ret;
75        }
76        ret += 1;
77    }
78    ret
79}
80
81fn first_index_of(file_name: &OsString, haystack: &[u8], needle: &[u8]) -> Result<usize> {
82    for i in 0..haystack.len() - needle.len() + 1 {
83        if haystack[i..i + needle.len()] == needle[..] {
84            return Ok(i);
85        }
86    }
87    bail!("not saturn image? {:?}", file_name);
88}
89
90pub struct SaturnDisc {
91    pub desired_region_bytes: [u8; 8],
92    pub desired_mfr_bytes: [u8; 16],
93}
94
95impl SaturnDisc {
96    pub fn from_env_args() -> Result<SaturnDisc> {
97        let desired_mfr_bytes = match std::env::var("SATURN_MANUFACTURER") {
98            Ok(k) => {
99                let mfr_bytes = k.as_bytes();
100                if mfr_bytes.len() == 0 {
101                    DESIRED_SATURN_DISC.desired_mfr_bytes
102                } else {
103                    if mfr_bytes.len() > 16 {
104                        bail!("SATURN_MANUFACTURER length {} exceeds max length of 16", mfr_bytes.len());
105                    }
106                    let mut ret = [20u8; 16];
107                    ret[0..mfr_bytes.len()].copy_from_slice(mfr_bytes);
108                    ret
109                }
110            }
111            Err(_) => DESIRED_SATURN_DISC.desired_mfr_bytes,
112        };
113
114        let desired_region_bytes = match std::env::var("SATURN_REGION") {
115            Ok(k) => {
116                let k = k.to_ascii_uppercase();
117                let region_bytes = k.as_bytes();
118                if region_bytes.len() == 0 {
119                    DESIRED_SATURN_DISC.desired_region_bytes
120                } else {
121                    if region_bytes.len() > 8 {
122                        bail!("SATURN_REGION length {} exceeds max length of 8", region_bytes.len());
123                    }
124                    // validate each character is a supported region
125                    for region in region_bytes {
126                        region_index(region)?;
127                    }
128                    {
129                        // ensure no duplicates exist
130                        let mut region_vec = region_bytes.to_vec();
131                        region_vec.sort();
132                        region_vec.dedup();
133                        if region_bytes.len() != region_bytes.len() {
134                            bail!("SATURN_REGION must not have duplicate regions");
135                        }
136                    }
137                    let mut empty = [20u8; 8];
138                    empty[0..region_bytes.len()].copy_from_slice(region_bytes);
139                    if region_bytes.len() != 8 {
140                        // pad it out, we always want all possible regions so we can always overwrite entire region string
141                        let mut x = region_bytes.len();
142                        REGION_CODES.iter().filter(|r| !region_bytes.contains(r)).for_each(|r| {
143                            empty[x] = *r;
144                            x += 1;
145                        });
146                    }
147                    println!("SATURN_REGION: {}", String::from_utf8_lossy(&empty));
148                    empty
149                }
150            }
151            Err(_) => DESIRED_SATURN_DISC.desired_region_bytes,
152        };
153
154        Ok(SaturnDisc {
155            desired_region_bytes,
156            desired_mfr_bytes,
157        })
158    }
159
160    pub fn patch(&self, file_name: &OsString) -> Result<()> {
161        let path = Path::new(file_name);
162        if !path.is_file() {
163            bail!("file does not exist: {:?}", file_name);
164        }
165        let mut file = File::open(file_name)?;
166        let mut bytes = Vec::new();
167        file.read_to_end(&mut bytes)?;
168
169        // only look in first 256 bytes
170        let header_offset = first_index_of(file_name, &bytes[0..min(bytes.len(), 256)], SEGA_SATURN_BYTES)?;
171
172        let cdrom = CDRomImage::new(&bytes)?;
173
174        let orig_hash = Hash::hash(&bytes);
175        // now update the sectors which shouldn't do anything, and ensure it didn't do anything, if it did, bail
176        cdrom.update_sectors(&mut bytes);
177        if orig_hash != Hash::hash(&bytes) {
178            bail!("existing CD has bad sectors? refusing to update because won't be able to restore original file: {:?}", file_name);
179        }
180
181        let change_mfr = self.desired_mfr_bytes != &bytes[(header_offset + 16)..(header_offset + 32)];
182        let region_bytes = &bytes[(header_offset + 64)..(header_offset + 80)];
183        let region_count = region_count(&region_bytes);
184        let new_region = region_copy_sort_pad(&self.desired_region_bytes[0..region_count]);
185        let change_region = new_region != region_bytes;
186        // copy this for use later replacing strings
187        let region_bytes = &region_bytes[0..region_count].to_vec();
188
189        if change_mfr || change_region {
190            // first we need to find first region string index
191            let mut first_region_string_index = bytes.len();
192            let mut region_string_indices = Vec::with_capacity(region_count);
193            let mut string_begin = header_offset + 80 + 16; // end of header
194            let mut string_end = bytes.len();
195            // replace strings
196            for i in 0..region_count {
197                // these do NOT appear to be in the same order as the characters in the header, so just look anywhere, I guess...
198                let region_string = REGION_STRINGS[region_index(&region_bytes[i])?];
199                // have to add string_begin back in because first_index_of returns based on 0, and we are sending in a slice
200                let string_offset = first_index_of(file_name, &bytes[string_begin..string_end], region_string)? + string_begin;
201                if i == 0 && region_count > 1 {
202                    // calculate max/min for future searches
203                    // we might have found the last, so first would be this far back exactly
204                    string_begin = string_offset - ((region_count - 1) * REGION_STRING_LEN);
205                    // and if we found the first, then we might be twice the length back
206                    string_end = string_begin + (region_count * REGION_STRING_LEN * 2);
207                }
208                if string_offset < first_region_string_index {
209                    first_region_string_index = string_offset;
210                }
211                region_string_indices.push(string_offset);
212            }
213
214            let mut backup_file_path = file_name.clone();
215            backup_file_path.push(BACKUP_FILE_EXT);
216            let backup_file_path = Path::new(&backup_file_path);
217            let write_header = !backup_file_path.is_file();
218            let mut backup_vec = Vec::new();
219            if write_header {
220                // only write a header backup if one doesn't exist
221                // first write original file sha256 hash, exactly 32 bytes
222                backup_vec.write_all(&orig_hash)?;
223                // next write region_count as a u8
224                backup_vec.push(region_count as u8);
225                // next write first_region_string_index as a u32 in be/network byte order
226                backup_vec.write_all(&(first_region_string_index as u32).to_be_bytes())?;
227                // next write original manufacturer, always 16 bytes
228                backup_vec.write_all(&bytes[(header_offset + 16)..(header_offset + 32)])?;
229                // next write original regions, always 16 bytes
230                backup_vec.write_all(&bytes[(header_offset + 64)..(header_offset + 80)])?;
231                // next write original region strings, length depends on region_count
232                backup_vec.write_all(&bytes[first_region_string_index..(first_region_string_index + (region_count * REGION_STRING_LEN))])?;
233            }
234
235            if change_mfr {
236                &bytes[(header_offset + 16)..(header_offset + 32)].copy_from_slice(&self.desired_mfr_bytes);
237            }
238
239            if change_region {
240                &bytes[(header_offset + 64)..(header_offset + 80)].copy_from_slice(&new_region);
241                // this way does it in order vs using region_string_indices which replaces them in the order
242                // the disc already had them in, is one more right than the other?
243                //let mut string_offset = first_region_string_index;
244                // replace strings
245                for i in 0..region_count {
246                    let new_region_string = REGION_STRINGS[region_index(&new_region[i])?];
247                    let string_offset = region_string_indices[i];
248                    &bytes[string_offset..(string_offset + REGION_STRING_LEN)].copy_from_slice(new_region_string);
249                    //string_offset += REGION_STRING_LEN;
250                }
251            }
252
253            cdrom.update_sectors(&mut bytes);
254
255            if write_header {
256                // only write a header backup if one doesn't exist
257                let mut backup_file = File::create(backup_file_path)?;
258                // first write 1 byte for a version number in case this format changes, 0 for now
259                backup_file.write_all(&[0])?;
260                // next write the sha256 hash of the rest of this backup file so we can verify it's good when we read it in, exactly 32 bytes
261                backup_file.write_all(&Hash::hash(&backup_vec))?;
262                // then write the rest of the file
263                backup_file.write_all(&backup_vec)?;
264                drop(backup_vec); // we don't need this anymore
265            }
266
267            let mut file = File::create(file_name)?;
268            file.write_all(&bytes)?;
269
270            print!("SUCCESS: ");
271            if write_header {
272                print!("wrote header backup, ");
273            }
274            if change_mfr {
275                print!("changed manufacturer, ");
276            }
277            if change_region {
278                print!("changed regions, ");
279            }
280
281            println!("patched: {:?}", file_name);
282        } else {
283            println!("SUCCESS: already desired manufacturer and regions {:?}", file_name);
284        }
285
286        Ok(())
287    }
288
289    pub fn unpatch(file_name: &OsString) -> Result<()> {
290        let path = Path::new(file_name);
291        if !path.is_file() {
292            bail!("file does not exist: {:?}", file_name);
293        }
294
295        let (file_name, header_backup_str) = if path.extension().map_or(false, |ext| ext.eq(&BACKUP_FILE_EXT[1..])) {
296            // let's support sending in this too, in which case we'll patch the corresponding non-backup file
297            let mut new_path = path.to_path_buf();
298            new_path.set_file_name(path.file_stem().expect("no file name? impossible without extension I think..."));
299            (new_path.into_os_string(), file_name.to_owned())
300        } else {
301            let mut header_backup_str = file_name.clone();
302            header_backup_str.push(BACKUP_FILE_EXT);
303            (file_name.to_owned(), header_backup_str)
304        };
305
306        let path = Path::new(&file_name);
307        if !path.is_file() {
308            bail!("file {:?} does not exist for backup file {:?}", file_name, header_backup_str);
309        }
310
311        let header_backup = Path::new(&header_backup_str);
312        if !header_backup.is_file() {
313            bail!("backup file {:?} does not exist for file {:?}", header_backup_str, file_name);
314        }
315
316        let mut file = File::open(path)?;
317        let mut bytes = Vec::new();
318        file.read_to_end(&mut bytes)?;
319
320        let mut header_backup = File::open(header_backup)?;
321        let mut header_bytes = Vec::new();
322        header_backup.read_to_end(&mut header_bytes)?;
323
324        if header_bytes.len() < MIN_BACKUP_SIZE {
325            bail!(
326                "backup file {:?} length {} is less than minimum length of {} for file {:?}",
327                header_backup_str,
328                header_bytes.len(),
329                MIN_BACKUP_SIZE,
330                file_name
331            );
332        }
333        if header_bytes[0] != 0 {
334            bail!("corrupt backup file or unsupported version {}, only version 0 supported: {:?}", header_bytes[0], header_backup_str);
335        }
336        if header_bytes[1..33] != Hash::hash(&header_bytes[33..]) {
337            bail!("corrupt backup file, hash mismatch: {:?}", header_backup_str);
338        }
339        // let's cut the cruft we won't need off header_bytes for easier math
340        let header_bytes = &header_bytes[33..];
341
342        // only look in first 256 bytes
343        let header_offset = first_index_of(&file_name, &bytes[0..min(bytes.len(), 256)], SEGA_SATURN_BYTES)?;
344
345        let cdrom = CDRomImage::new(&bytes)?;
346
347        // let's slice up some views into header_bytes
348        let orig_hash = &header_bytes[0..32];
349        let region_count = header_bytes[32] as usize;
350        let first_region_string_index = u32::from_be_bytes(header_bytes[33..37].try_into()?) as usize;
351        // and again to make math more comfortable
352        let header_bytes = &header_bytes[37..];
353        let manufacturer = &header_bytes[0..16];
354        let regions = &header_bytes[16..32];
355        let region_strings = &header_bytes[32..];
356
357        if &bytes[(header_offset + 16)..(header_offset + 32)] != manufacturer
358            || &bytes[(header_offset + 64)..(header_offset + 80)] != regions
359            || &bytes[first_region_string_index..(first_region_string_index + (region_count * REGION_STRING_LEN))] != region_strings
360        {
361            &bytes[(header_offset + 16)..(header_offset + 32)].copy_from_slice(manufacturer);
362            &bytes[(header_offset + 64)..(header_offset + 80)].copy_from_slice(regions);
363            &bytes[first_region_string_index..(first_region_string_index + (region_count * REGION_STRING_LEN))].copy_from_slice(region_strings);
364
365            cdrom.update_sectors(&mut bytes);
366
367            if orig_hash != Hash::hash(&bytes) {
368                bail!("restore failed, hash mismatch: {:?}", file_name);
369            }
370
371            let mut file = File::create(&file_name)?;
372            file.write_all(&bytes)?;
373            file.flush()?;
374            drop(file);
375
376            // we've successfully written the restored file to disk, might as well delete the backup file
377            // but we don't want to fail based on this so ignore any errors
378            std::fs::remove_file(header_backup_str).ok();
379
380            println!("SUCCESS: unpatched: {:?}", file_name);
381        } else if orig_hash == Hash::hash(&bytes) {
382            // let's go ahead and try to delete un-needed backup file, but again, ignore errors
383            std::fs::remove_file(header_backup_str).ok();
384
385            println!("SUCCESS: already unpatched: {:?}", file_name);
386        } else {
387            bail!("restore failed, unknown problem: {:?}", file_name);
388        }
389
390        Ok(())
391    }
392}