Skip to main content

rkg_utils/footer/sp_footer/
mod.rs

1use crate::{
2    byte_handler::{ByteHandler, FromByteHandler},
3    footer::{ctgp_footer::exact_finish_time::ExactFinishTime, sp_footer::sp_version::SPVersion},
4    header::in_game_time::{InGameTime, InGameTimeError},
5};
6
7pub mod sp_version;
8
9/// Errors that can occur while parsing an [`SPFooter`].
10#[derive(thiserror::Error, Debug)]
11pub enum SPFooterError {
12    /// The ghost file does not contain the expected `SPGD` magic bytes.
13    #[error("Ghost is not SPGD")]
14    NotSPGD,
15    /// The file is not an RKG file.
16    #[error("File is not an RKG")]
17    NotRKGD,
18    /// Data passed is impossibly too short.
19    #[error("Data length is too short")]
20    DataLengthTooShort,
21    /// The footer version number exceeds the maximum supported value (5).
22    #[error("Invalid MKW-SP footer version")]
23    InvalidFooterVersion,
24    /// An in-game time field could not be parsed.
25    #[error("In Game Time Error: {0}")]
26    InGameTimeError(#[from] InGameTimeError),
27    /// A lap split index was out of range for the recorded lap count.
28    #[error("Lap split index not semantically valid")]
29    LapSplitIndexError,
30    /// A slice-to-array conversion failed while reading footer data.
31    #[error("Try From Slice Error: {0}")]
32    TryFromSliceError(#[from] std::array::TryFromSliceError),
33}
34
35/// Parsed representation of the MKW-SP footer appended to Mario Kart Wii ghost files.
36///
37/// The footer stores metadata written by MKW Service Pack at the end of each recorded ghost,
38/// including high-precision timing, version information, track hash, mushroom strategy,
39/// and various gameplay flags. Some fields are only present in later footer versions.
40pub struct SPFooter {
41    /// The raw bytes of the footer (excluding the trailing CRC32).
42    raw_data: Vec<u8>,
43    /// The footer format version, which determines available fields and layout.
44    footer_version: u32,
45    /// One or more MKW-SP release versions consistent with the footer version number.
46    /// `None` if the footer version is unrecognised.
47    possible_sp_versions: Option<Vec<SPVersion>>,
48    /// SHA-1 hash of the track file associated with this ghost.
49    track_sha1: [u8; 0x14],
50    /// Sub-millisecond-accurate finish time, computed as the sum of all exact lap times.
51    exact_finish_time: ExactFinishTime,
52    /// Sub-millisecond-accurate lap times, one per recorded lap (up to 11).
53    exact_lap_times: [ExactFinishTime; 11],
54    /// Whether a speed modifier was active during the run.
55    has_speed_mod: bool,
56    /// Whether an ultra shortcut was performed during the run.
57    has_ultra_shortcut: bool,
58    /// Whether a horizontal wall glitch was performed during the run.
59    has_horizontal_wall_glitch: bool,
60    /// Whether a wallride was performed during the run.
61    has_wallride: bool,
62    /// Per-lap mushroom usage counts. `None` for footer version 0, which lacks this data.
63    shroomstrat: Option<[u8; 11]>,
64    /// Whether vanilla mode was enabled during the run. `None` for footer versions below 3.
65    is_vanilla_mode_enabled: Option<bool>,
66    /// Whether simplified controls were enabled during the run. `None` for footer versions below 4.
67    has_simplified_controls: Option<bool>,
68    /// Whether the run was set in mirror mode. `None` for footer versions below 5.
69    set_in_mirror: Option<bool>,
70    /// Length of the footer in bytes, excluding the trailing CRC32.
71    len: u32,
72    /// Number of laps recorded in the ghost.
73    lap_count: u8,
74}
75
76impl SPFooter {
77    /// Parses an [`SPFooter`] from a complete RKG ghost file byte slice.
78    ///
79    /// Validates the `SPGD` magic and footer version, then reads all footer fields
80    /// including high-precision lap times, track SHA-1, shroomstrat, and gameplay flags.
81    /// Fields introduced in later footer versions are stored as `Option`, and will be
82    /// `None` when the footer version does not include them.
83    ///
84    /// # Arguments
85    ///
86    /// * `data` - The full raw bytes of the RKG ghost file, including the MKW-SP footer.
87    ///
88    /// # Errors
89    ///
90    /// Returns an [`SPFooterError`] if:
91    /// - The `SPGD` magic bytes are absent ([`SPFooterError::NotSPGD`]).
92    /// - The footer version exceeds 5 ([`SPFooterError::InvalidFooterVersion`]).
93    /// - Any byte slice conversion, integer parse, or time parse fails.
94    pub fn new(data: &[u8]) -> Result<Self, SPFooterError> {
95        if data.len() < 0x04 {
96            return Err(SPFooterError::DataLengthTooShort);
97        }
98
99        if data[..0x04] != *b"RKGD" {
100            return Err(SPFooterError::NotRKGD);
101        }
102
103        if data.len() < 0x08 {
104            return Err(SPFooterError::DataLengthTooShort);
105        }
106
107        if data[data.len() - 0x08..data.len() - 0x04] != *b"SPGD" {
108            return Err(SPFooterError::NotSPGD);
109        }
110
111        if data.len() < 0x0C {
112            return Err(SPFooterError::DataLengthTooShort);
113        }
114
115        let footer_len = (u32::from_be_bytes(
116            data[data.len() - 0x0C..data.len() - 0x08]
117                .try_into()
118                .unwrap(),
119        ) + 0x08) as usize;
120
121        if data.len() < footer_len {
122            return Err(SPFooterError::DataLengthTooShort);
123        }
124
125        let lap_count = data[0x10];
126        let laps_data = &data[0x11..0x32];
127
128        let footer_data = &data[data.len() - footer_len - 0x04..data.len() - 0x04];
129
130        let footer_version = u32::from_be_bytes(footer_data[..0x04].try_into().unwrap());
131
132        if footer_version > 5 {
133            return Err(SPFooterError::InvalidFooterVersion);
134        }
135
136        let possible_sp_versions = SPVersion::from(footer_version);
137
138        let mut current_offset = 0x04;
139
140        let track_sha1 = footer_data[current_offset..current_offset + 0x14]
141            .to_owned()
142            .try_into()
143            .unwrap();
144        current_offset += 0x14;
145
146        // Exact lap split calculation
147        let mut previous_subtractions = 0i64;
148        let mut exact_lap_times = [ExactFinishTime::default(); 11];
149        let mut in_game_time_offset = 0x00usize;
150        let mut subtraction_ps = 0i64;
151
152        for exact_lap_time in exact_lap_times.iter_mut().take(lap_count as usize) {
153            let mut true_time_subtraction = ((f32::from_be_bytes(
154                footer_data[current_offset..current_offset + 0x04].try_into()?,
155            ) as f64)
156                * 1e+9)
157                .floor() as i64;
158
159            let lap_time = InGameTime::from_byte_handler(
160                &laps_data[in_game_time_offset..=in_game_time_offset + 0x02],
161            )?;
162
163            // subtract the sum of the previous laps' difference because the lap differences add up to
164            // have its decimal portion be equal to the total time
165            true_time_subtraction -= previous_subtractions;
166
167            if true_time_subtraction > 1e+9 as i64 {
168                true_time_subtraction -= subtraction_ps;
169                subtraction_ps = if subtraction_ps == 0 { 1e+9 as i64 } else { 0 };
170            }
171            previous_subtractions += true_time_subtraction;
172            *exact_lap_time = ExactFinishTime::new(
173                lap_time.minutes(),
174                lap_time.seconds(),
175                (lap_time.milliseconds() as i64 * 1e+9 as i64 + true_time_subtraction) as u64,
176            );
177            in_game_time_offset += 0x03;
178            current_offset += 0x04;
179        }
180
181        let exact_finish_time = exact_lap_times[..lap_count as usize].iter().copied().sum();
182
183        current_offset += (11 - lap_count as usize) * 0x04;
184
185        let bools = ByteHandler::from(footer_data[current_offset]);
186        let has_speed_mod = bools.read_bool(7);
187        let has_ultra_shortcut = bools.read_bool(6);
188        let has_horizontal_wall_glitch = bools.read_bool(5);
189        let has_wallride = bools.read_bool(4);
190
191        let shroomstrat;
192
193        if footer_version >= 1 {
194            let shroom_data: [u8; 3] = footer_data[current_offset..current_offset + 0x03]
195                .try_into()
196                .unwrap();
197
198            let mut shroom_arr = [0u8; 11];
199            let mut shrooms = [0u8; 3];
200
201            let raw = u32::from_be_bytes([0, shroom_data[0], shroom_data[1], shroom_data[2]]);
202            shrooms[0] = ((raw >> 15) & 0x1F) as u8;
203            shrooms[1] = ((raw >> 10) & 0x1F) as u8;
204            shrooms[2] = ((raw >> 5) & 0x1F) as u8;
205
206            for shroom in shrooms.iter() {
207                if *shroom != 0 {
208                    shroom_arr[*shroom as usize - 1] += 1;
209                }
210            }
211            shroomstrat = Some(shroom_arr);
212        } else {
213            shroomstrat = None;
214        }
215
216        current_offset += 0x02;
217
218        let bools = ByteHandler::from(footer_data[current_offset]);
219
220        let is_vanilla_mode_enabled = if footer_version >= 3 {
221            Some(bools.read_bool(4))
222        } else {
223            None
224        };
225
226        let has_simplified_controls = if footer_version >= 4 {
227            Some(bools.read_bool(3))
228        } else {
229            None
230        };
231
232        let set_in_mirror = if footer_version >= 5 {
233            Some(bools.read_bool(2))
234        } else {
235            None
236        };
237
238        Ok(Self {
239            raw_data: footer_data.to_owned(),
240            footer_version,
241            possible_sp_versions,
242            track_sha1,
243            exact_finish_time,
244            exact_lap_times,
245            has_speed_mod,
246            has_ultra_shortcut,
247            has_horizontal_wall_glitch,
248            has_wallride,
249            shroomstrat,
250            is_vanilla_mode_enabled,
251            has_simplified_controls,
252            set_in_mirror,
253            len: footer_len as u32,
254            lap_count,
255        })
256    }
257
258    /// Returns the raw bytes of the footer, excluding the trailing CRC32.
259    pub fn raw_data(&self) -> &[u8] {
260        &self.raw_data
261    }
262
263    /// Returns the footer format version number.
264    pub fn footer_version(&self) -> u32 {
265        self.footer_version
266    }
267
268    /// Returns the possible MKW-SP release versions consistent with this footer's version number.
269    ///
270    /// Returns `None` if the footer version did not match any known release.
271    pub fn possible_sp_versions(&self) -> Option<&Vec<SPVersion>> {
272        self.possible_sp_versions.as_ref()
273    }
274
275    /// Returns the SHA-1 hash of the track file associated with this ghost.
276    pub fn track_sha1(&self) -> &[u8; 0x14] {
277        &self.track_sha1
278    }
279
280    /// Returns the sub-millisecond-accurate finish time, computed as the sum of all exact lap times.
281    pub fn exact_finish_time(&self) -> ExactFinishTime {
282        self.exact_finish_time
283    }
284
285    /// Returns the sub-millisecond-accurate lap times for all recorded laps.
286    pub fn exact_lap_times(&self) -> &[ExactFinishTime] {
287        &self.exact_lap_times[..self.lap_count as usize]
288    }
289
290    /// Returns the sub-millisecond-accurate time for a single lap by index.
291    ///
292    /// # Errors
293    ///
294    /// Returns [`SPFooterError::LapSplitIndexError`] if `idx` is greater than or equal to
295    /// the number of recorded laps.
296    pub fn exact_lap_time(&self, idx: usize) -> Result<ExactFinishTime, SPFooterError> {
297        if idx >= self.lap_count as usize {
298            return Err(SPFooterError::LapSplitIndexError);
299        }
300        Ok(self.exact_lap_times[idx])
301    }
302
303    /// Returns whether a speed modifier was active during the run.
304    pub fn has_speed_mod(&self) -> bool {
305        self.has_speed_mod
306    }
307
308    /// Returns whether an ultra shortcut was performed during the run.
309    pub fn has_ultra_shortcut(&self) -> bool {
310        self.has_ultra_shortcut
311    }
312
313    /// Returns whether a horizontal wall glitch was performed during the run.
314    pub fn has_horizontal_wall_glitch(&self) -> bool {
315        self.has_horizontal_wall_glitch
316    }
317
318    /// Returns whether a wallride was performed during the run.
319    pub fn has_wallride(&self) -> bool {
320        self.has_wallride
321    }
322
323    /// Returns the per-lap mushroom usage counts (shroomstrat) for the recorded laps.
324    ///
325    /// Returns `None` for footer version 0, which does not include shroomstrat data.
326    pub fn shroomstrat(&self) -> Option<&[u8]> {
327        self.shroomstrat
328            .as_ref()
329            .map(|s| &s[..self.lap_count as usize])
330    }
331
332    /// Returns a dash-separated string representation of the per-lap mushroom usage counts.
333    ///
334    /// Returns `None` for footer version 0, which does not include shroomstrat data.
335    ///
336    /// For example, a three-lap run with one mushroom on lap 2 would return `"0-1-0"`.
337    pub fn shroomstrat_string(&self) -> Option<String> {
338        if let Some(shroomstrat) = self.shroomstrat() {
339            let mut s = String::new();
340
341            for (idx, lap) in shroomstrat.iter().enumerate() {
342                s += lap.to_string().as_str();
343
344                if idx + 1 < self.lap_count as usize {
345                    s += "-";
346                }
347            }
348            Some(s)
349        } else {
350            None
351        }
352    }
353
354    /// Returns whether vanilla mode was enabled during the run.
355    ///
356    /// Returns `None` for footer versions below 3, which do not include this field.
357    pub fn is_vanilla_mode_enabled(&self) -> Option<bool> {
358        self.is_vanilla_mode_enabled
359    }
360
361    /// Returns whether simplified controls were enabled during the run.
362    ///
363    /// Returns `None` for footer versions below 4, which do not include this field.
364    pub fn has_simplified_controls(&self) -> Option<bool> {
365        self.has_simplified_controls
366    }
367
368    /// Returns whether the run was set in mirror mode.
369    ///
370    /// Returns `None` for footer versions below 5, which do not include this field.
371    pub fn set_in_mirror(&self) -> Option<bool> {
372        self.set_in_mirror
373    }
374
375    /// Returns the length of the footer in bytes, excluding the trailing CRC32.
376    pub fn len(&self) -> usize {
377        self.len as usize
378    }
379
380    /// Returns `true` if the footer has zero length.
381    pub fn is_empty(&self) -> bool {
382        self.len == 0
383    }
384}