Skip to main content

rkg_utils/sp_footer/
mod.rs

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