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