Skip to main content

rkg_utils/footer/ctgp_footer/
mod.rs

1use std::fmt::Write;
2use crate::byte_handler::FromByteHandler;
3use crate::footer::ctgp_footer::{
4    category::Category, ctgp_version::CTGPVersion, exact_finish_time::ExactFinishTime,
5};
6use crate::header::in_game_time::InGameTime;
7use crate::{byte_handler::ByteHandler, input_data::yaz1_decompress};
8use crate::{compute_sha1_hex, datetime_from_timestamp, duration_from_ticks};
9use chrono::{TimeDelta, prelude::*};
10
11pub mod category;
12pub mod ctgp_version;
13pub mod exact_finish_time;
14
15/// Errors that can occur while parsing a [`CTGPFooter`].
16#[derive(thiserror::Error, Debug)]
17pub enum CTGPFooterError {
18    /// The ghost file does not contain the expected `CKGD` magic bytes.
19    #[error("Ghost is not CKGD")]
20    NotCKGD,
21    /// The footer version byte is not one of the supported values (1, 2, 3, 5, 6, 7).
22    #[error("Invalid CTGP footer version")]
23    InvalidFooterVersion,
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    /// A lap split index was out of range for the recorded lap count.
28    #[error("Lap split index not semantically valid")]
29    LapSplitIndexError,
30    /// The category bytes could not be mapped to a known [`Category`].
31    #[error("Category Error: {0}")]
32    CategoryError(#[from] category::CategoryError),
33    /// An in-game time field could not be parsed.
34    #[error("In Game Time Error: {0}")]
35    InGameTimeError(#[from] crate::header::in_game_time::InGameTimeError),
36    /// A numeric string could not be parsed as an integer.
37    #[error("Parse Int Error: {0}")]
38    ParseIntError(#[from] std::num::ParseIntError),
39}
40
41/// Parsed representation of the CTGP-specific footer appended to Mario Kart Wii ghost files.
42///
43/// The footer stores metadata written by CTGP-R at the end of each recorded ghost, including
44/// high-precision timing, version information, RTC timestamps, pause data, and various
45/// integrity/cheat-detection flags.
46pub struct CTGPFooter {
47    /// The raw bytes of the footer (excluding the trailing CRC32).
48    raw_data: Vec<u8>,
49    /// The security/signature portion of the footer used for verification.
50    security_data: Vec<u8>,
51    /// SHA-1 hash of the track file associated with this ghost.
52    track_sha1: [u8; 0x14],
53    /// SHA-1 hash of the full ghost file.
54    ghost_sha1: [u8; 0x14],
55    /// The player's unique CTGP player ID.
56    player_id: u64,
57    /// Sub-millisecond-accurate finish time derived from the in-game time and CTGP's correction factor.
58    exact_finish_time: ExactFinishTime,
59    /// The CTGP CORE version the ghost was driven on.
60    core_version: CTGPVersion,
61    /// One or more CTGP release versions consistent with the footer's version bytes.
62    /// `None` if the version bytes are unrecognised.
63    possible_ctgp_versions: Option<Vec<CTGPVersion>>,
64    /// Per-lap flags indicating whether CTGP detected a suspicious split-line intersection.
65    /// `None` for footer versions below 2.
66    lap_split_suspicious_intersections: Option<[bool; 10]>,
67    /// Sub-millisecond-accurate lap times, one per recorded lap.
68    exact_lap_times: [ExactFinishTime; 10],
69    /// Real-time clock timestamp recorded when the race ended.
70    rtc_race_end: NaiveDateTime,
71    /// Real-time clock timestamp recorded when the race began.
72    rtc_race_begins: NaiveDateTime,
73    /// Total wall-clock time the game was paused during the run.
74    rtc_time_paused: TimeDelta,
75    /// In-game timestamps (relative to race start) at which each pause occurred.
76    pause_times: Vec<InGameTime>,
77    /// Whether the player had My Stuff enabled during the run.
78    my_stuff_enabled: bool,
79    /// Whether any My Stuff content was actually used during the run.
80    my_stuff_used: bool,
81    /// Whether a USB GameCube adapter was enabled during the run.
82    usb_gamecube_enabled: bool,
83    /// Whether CTGP detected a suspicious split-line intersection on the final lap.
84    final_lap_suspicious_intersection: bool,
85    /// Per-lap mushroom usage counts (shroomstrat), indexed by lap number.
86    shroomstrat: [u8; 10],
87    /// Whether the player was launched by a cannon during the run.
88    cannoned: bool,
89    /// Whether the player went out of bounds during the run.
90    went_oob: bool,
91    /// Whether CTGP flagged a potential slowdown event during the run.
92    potential_slowdown: bool,
93    /// Whether CTGP flagged potential rapid-fire input during the run.
94    potential_rapidfire: bool,
95    /// Whether CTGP's heuristics flagged this ghost as potentially cheated.
96    potentially_cheated_ghost: bool,
97    /// Whether the Mii data in the ghost file has been replaced.
98    has_mii_data_replaced: bool,
99    /// Whether the Mii name in the ghost file has been replaced.
100    has_name_replaced: bool, // Hi Korben
101    /// Whether the player respawned at any point during the run.
102    respawns: bool,
103    /// The run category as determined by CTGP's metadata.
104    category: Category,
105    /// The footer format version, which determines the layout and size of the footer.
106    footer_version: u8,
107    /// Length of the footer in bytes, excluding the trailing CRC32.
108    len: usize,
109    /// Number of laps recorded in the ghost.
110    lap_count: u8,
111}
112
113impl CTGPFooter {
114    /// Parses a [`CTGPFooter`] from a complete RKG ghost file byte slice.
115    ///
116    /// Validates the `CKGD` magic and footer version, then reads all footer fields
117    /// including timing data, version info, RTC timestamps, pause events, and
118    /// cheat-detection flags.
119    ///
120    /// # Arguments
121    ///
122    /// * `data` - The full raw bytes of the RKG ghost file, including the CTGP footer.
123    ///
124    /// # Errors
125    ///
126    /// Returns a [`CTGPFooterError`] if:
127    /// - The `CKGD` magic bytes are absent ([`CTGPFooterError::NotCKGD`]).
128    /// - The footer version is not supported ([`CTGPFooterError::InvalidFooterVersion`]).
129    /// - Any byte slice conversion, integer parse, category parse, or time parse fails.
130    pub fn new(data: &[u8]) -> Result<Self, CTGPFooterError> {
131        if data[data.len() - 0x08..data.len() - 0x04] != *b"CKGD" {
132            return Err(CTGPFooterError::NotCKGD);
133        }
134
135        let footer_version = data[data.len() - 0x0D];
136
137        match footer_version {
138            1 | 2 | 3 | 5 | 6 | 7 => {}
139            _ => {
140                return Err(CTGPFooterError::InvalidFooterVersion);
141            }
142        }
143
144        let len = if footer_version < 7 { 0xD4 } else { 0xE4 };
145
146        let security_data_size = if footer_version < 7 { 0x48 } else { 0x58 };
147
148        let raw_data = Vec::from(&data[data.len() - len..data.len() - 0x04]);
149
150        let header_data = &data[..0x88];
151        let input_data = &data[0x88..data.len() - len];
152        let metadata = &data[data.len() - len..];
153        let mut current_offset = 0usize;
154
155        let security_data = Vec::from(&metadata[..security_data_size]);
156        current_offset += security_data_size;
157
158        let track_sha1 = metadata[current_offset..current_offset + 0x14]
159            .to_owned()
160            .try_into()
161            .unwrap();
162        current_offset += 0x14;
163
164        let ghost_sha1 = compute_sha1_hex(data);
165
166        let player_id =
167            u64::from_be_bytes(metadata[current_offset..current_offset + 0x08].try_into()?);
168        current_offset += 0x08;
169
170        let finish_time = InGameTime::from_byte_handler(&header_data[0x04..0x07])?;
171        let true_time_subtraction =
172            (f32::from_be_bytes(metadata[current_offset..current_offset + 0x04].try_into()?) as f64
173                * 1e+9)
174                .floor() as i64;
175        let exact_finish_time = ExactFinishTime::new(
176            finish_time.minutes(),
177            finish_time.seconds(),
178            (finish_time.milliseconds() as i64 * 1e+9 as i64 + true_time_subtraction) as u64,
179        );
180        current_offset += 0x04;
181
182        let possible_ctgp_versions;
183        let core_version;
184        let mut lap_split_suspicious_intersections = Some([false; 10]);
185
186        if footer_version >= 2 {
187            let version_bytes = &metadata[current_offset..current_offset + 0x04];
188            core_version = CTGPVersion::core_from(version_bytes)?;
189            possible_ctgp_versions = CTGPVersion::from(version_bytes);
190            current_offset += 0x04;
191
192            let laps_handler = ByteHandler::try_from(&metadata[current_offset..current_offset + 2])
193                .expect("ByteHandler try_from() failed");
194
195            if let Some(mut array) = lap_split_suspicious_intersections {
196                for (index, intersection) in array.iter_mut().enumerate() {
197                    *intersection = laps_handler.read_bool(index as u8 + 6);
198                }
199            }
200            current_offset -= 0x04;
201        } else {
202            // Infer that the core version is 1.03.0134, since the next batch of updates after TT release was CORE 1.03.0136
203            core_version = CTGPVersion::new(1, 3, 134, None);
204            // Metadata version 2 was introduced in between the 1.03.1044 and 1046 update, so it must be 1.03.1044
205            possible_ctgp_versions = Some(Vec::from([CTGPVersion::new(1, 3, 1044, None)]));
206            lap_split_suspicious_intersections = None;
207        }
208
209        current_offset += 0x3C;
210
211        // Exact lap split calculation
212        let mut previous_subtractions = 0i64;
213        let mut exact_lap_times = [ExactFinishTime::default(); 10];
214        let lap_count = header_data[0x10];
215        let mut in_game_time_offset = 0x11usize;
216        let mut subtraction_ps = 0i64;
217
218        for exact_lap_time in exact_lap_times.iter_mut().take(lap_count as usize) {
219            let mut true_time_subtraction =
220                ((f32::from_be_bytes(metadata[current_offset..current_offset + 0x04].try_into()?)
221                    as f64)
222                    * 1e+9)
223                    .floor() as i64;
224
225            let lap_time = InGameTime::from_byte_handler(
226                &header_data[in_game_time_offset..in_game_time_offset + 0x03],
227            )?;
228
229            // subtract the sum of the previous laps' difference because the lap differences add up to
230            // have its decimal portion be equal to the total time
231            true_time_subtraction -= previous_subtractions;
232
233            if true_time_subtraction > 1e+9 as i64 {
234                true_time_subtraction -= subtraction_ps;
235                subtraction_ps = if subtraction_ps == 0 { 1e+9 as i64 } else { 0 };
236            }
237            previous_subtractions += true_time_subtraction;
238            *exact_lap_time = ExactFinishTime::new(
239                lap_time.minutes(),
240                lap_time.seconds(),
241                (lap_time.milliseconds() as i64 * 1e+9 as i64 + true_time_subtraction) as u64,
242            );
243            in_game_time_offset += 0x03;
244            current_offset -= 0x04;
245        }
246
247        current_offset += 0x04 * (lap_count as usize + 1);
248
249        let rtc_race_end = datetime_from_timestamp(u64::from_be_bytes(
250            metadata[current_offset..current_offset + 0x08].try_into()?,
251        ));
252        current_offset += 0x08;
253
254        let rtc_race_begins = datetime_from_timestamp(u64::from_be_bytes(
255            metadata[current_offset..current_offset + 0x08].try_into()?,
256        ));
257        current_offset += 0x08;
258
259        let rtc_time_paused = duration_from_ticks(u64::from_be_bytes(
260            metadata[current_offset..current_offset + 0x08].try_into()?,
261        ));
262        current_offset += 0x08;
263
264        // Pause frame times
265        let mut pause_times = Vec::new();
266        let input_data = if input_data[4..8] == [0x59, 0x61, 0x7A, 0x31] {
267            // YAZ1 header, decompress
268            yaz1_decompress(&input_data[4..]).unwrap()
269        } else {
270            Vec::from(input_data)
271        };
272
273        let face_input_count = u16::from_be_bytes([input_data[0], input_data[1]]);
274
275        let mut current_input_byte = 8;
276        let mut elapsed_frames = 1u32;
277        while current_input_byte < 8 + face_input_count * 2 {
278            let idx = current_input_byte as usize;
279            let input = &input_data[idx..idx + 2];
280
281            if contains_ctgp_pause(input[0]) {
282                // Convert frame count to InGameTime
283                // Subtract 240 frames for countdown, another 2 frames because CTGP logs the pause 2 frames after it actually happens
284                let mut pause_timestamp_seconds = (elapsed_frames - 242) as f64 / 59.94;
285                let mut minutes = 0;
286                let mut seconds = 0;
287
288                while pause_timestamp_seconds >= 60.0 {
289                    minutes += 1;
290                    pause_timestamp_seconds -= 60.0;
291                }
292
293                while pause_timestamp_seconds >= 1.0 {
294                    seconds += 1;
295                    pause_timestamp_seconds -= 1.0;
296                }
297
298                let milliseconds = (pause_timestamp_seconds * 1000.0) as u16;
299
300                pause_times.push(InGameTime::new(minutes, seconds, milliseconds));
301            }
302
303            elapsed_frames += input[1] as u32;
304            current_input_byte += 2;
305        }
306
307        let bool_handler = ByteHandler::from(metadata[current_offset]);
308        let my_stuff_enabled = bool_handler.read_bool(3);
309        let my_stuff_used = bool_handler.read_bool(2);
310        let usb_gamecube_enabled = bool_handler.read_bool(1);
311        let final_lap_suspicious_intersection = bool_handler.read_bool(0);
312        current_offset += 0x01;
313
314        let mut shroomstrat: [u8; 10] = [0; 10];
315        for _ in 0..3 {
316            let lap = metadata[current_offset];
317            if lap != 0 {
318                shroomstrat[(lap - 1) as usize] += 1;
319            }
320            current_offset += 0x01;
321        }
322
323        let category = Category::try_from(metadata[current_offset + 2], metadata[current_offset])?;
324        current_offset += 0x01;
325        let bool_handler = ByteHandler::from(metadata[current_offset]);
326        let cannoned = bool_handler.read_bool(7);
327        let went_oob = bool_handler.read_bool(6);
328        let potential_slowdown = bool_handler.read_bool(5);
329        let potential_rapidfire = bool_handler.read_bool(4);
330        let potentially_cheated_ghost = bool_handler.read_bool(3);
331        let has_mii_data_replaced = bool_handler.read_bool(2);
332        let has_name_replaced = bool_handler.read_bool(1);
333        let respawns = bool_handler.read_bool(0);
334
335        Ok(Self {
336            raw_data,
337            security_data,
338            track_sha1,
339            ghost_sha1,
340            player_id,
341            exact_finish_time,
342            core_version,
343            possible_ctgp_versions,
344            lap_split_suspicious_intersections,
345            exact_lap_times,
346            rtc_race_end,
347            rtc_race_begins,
348            rtc_time_paused,
349            pause_times,
350            my_stuff_enabled,
351            my_stuff_used,
352            usb_gamecube_enabled,
353            final_lap_suspicious_intersection,
354            shroomstrat,
355            cannoned,
356            went_oob,
357            potential_slowdown,
358            potential_rapidfire,
359            potentially_cheated_ghost,
360            has_mii_data_replaced,
361            has_name_replaced,
362            respawns,
363            category,
364            footer_version,
365            len: len - 0x04,
366            lap_count,
367        })
368    }
369
370    /// Returns the raw bytes of the footer, excluding the trailing CRC32.
371    pub fn raw_data(&self) -> &[u8] {
372        &self.raw_data
373    }
374
375    /// Returns the security/signature portion of the footer.
376    pub fn security_data(&self) -> &[u8] {
377        &self.security_data
378    }
379
380    /// Returns the SHA-1 hash of the track file associated with this ghost.
381    pub fn track_sha1(&self) -> &[u8] {
382        &self.track_sha1
383    }
384
385    /// Returns the SHA-1 hash of the full ghost file.
386    pub fn ghost_sha1(&self) -> &[u8] {
387        &self.ghost_sha1
388    }
389
390    /// Overwrites the stored ghost SHA-1 hash.
391    ///
392    /// # Errors
393    ///
394    /// Returns [`CTGPFooterError::TryFromSliceError`] if `ghost_sha1` is not exactly 20 bytes.
395    pub(crate) fn set_ghost_sha1(&mut self, ghost_sha1: &[u8]) -> Result<(), CTGPFooterError> {
396        self.ghost_sha1 = ghost_sha1.try_into()?;
397        Ok(())
398    }
399
400    /// Returns the player's unique CTGP player ID.
401    pub fn player_id(&self) -> u64 {
402        self.player_id
403    }
404
405    /// Returns the sub-millisecond-accurate finish time for the run.
406    pub fn exact_finish_time(&self) -> ExactFinishTime {
407        self.exact_finish_time
408    }
409
410    /// Returns the CORE (base game) version the ghost was driven on.
411    pub fn core_version(&self) -> CTGPVersion {
412        self.core_version
413    }
414
415    /// Returns the possible CTGP release versions consistent with this ghost's footer bytes.
416    ///
417    /// Returns `None` if the version bytes did not match any known release.
418    pub fn possible_ctgp_versions(&self) -> Option<&Vec<CTGPVersion>> {
419        self.possible_ctgp_versions.as_ref()
420    }
421
422    /// Returns per-lap suspicious split-line intersection flags for the recorded laps.
423    ///
424    /// Returns `None` for ghosts recorded on footer version 1, which does not include this data.
425    pub fn lap_split_suspicious_intersections(&self) -> Option<&[bool]> {
426        if let Some(intersections) = &self.lap_split_suspicious_intersections {
427            return Some(&intersections[0..self.lap_count as usize]);
428        }
429        None
430    }
431
432    /// Returns the sub-millisecond-accurate lap times for all recorded laps.
433    pub fn exact_lap_times(&self) -> &[ExactFinishTime] {
434        &self.exact_lap_times[0..self.lap_count as usize]
435    }
436
437    /// Returns the sub-millisecond-accurate time for a single lap by index.
438    ///
439    /// # Errors
440    ///
441    /// Returns [`CTGPFooterError::LapSplitIndexError`] if `idx` is greater than or equal to
442    /// the number of recorded laps.
443    pub fn exact_lap_time(&self, idx: usize) -> Result<ExactFinishTime, CTGPFooterError> {
444        if idx >= self.lap_count as usize {
445            return Err(CTGPFooterError::LapSplitIndexError);
446        }
447        Ok(self.exact_lap_times[idx])
448    }
449
450    /// Returns the real-time clock timestamp recorded when the race ended.
451    pub fn rtc_race_end(&self) -> NaiveDateTime {
452        self.rtc_race_end
453    }
454
455    /// Returns the real-time clock timestamp recorded when the race began.
456    pub fn rtc_race_begins(&self) -> NaiveDateTime {
457        self.rtc_race_begins
458    }
459
460    /// Returns the total wall-clock duration the game was paused during the run.
461    pub fn rtc_time_paused(&self) -> TimeDelta {
462        self.rtc_time_paused
463    }
464
465    /// Returns the in-game timestamps at which each pause occurred during the run.
466    pub fn pause_times(&self) -> &Vec<InGameTime> {
467        &self.pause_times
468    }
469
470    /// Returns whether the player had My Stuff enabled during the run.
471    pub fn my_stuff_enabled(&self) -> bool {
472        self.my_stuff_enabled
473    }
474
475    /// Returns whether any My Stuff content was actually used during the run.
476    pub fn my_stuff_used(&self) -> bool {
477        self.my_stuff_used
478    }
479
480    /// Returns whether a USB GameCube adapter was enabled during the run.
481    pub fn usb_gamecube_enabled(&self) -> bool {
482        self.usb_gamecube_enabled
483    }
484
485    /// Returns whether CTGP detected a suspicious split-line intersection on the final lap.
486    pub fn final_lap_suspicious_intersection(&self) -> bool {
487        self.final_lap_suspicious_intersection
488    }
489
490    /// Returns the per-lap mushroom usage counts (shroomstrat) for the recorded laps.
491    pub fn shroomstrat(&self) -> &[u8] {
492        &self.shroomstrat[0..self.lap_count as usize]
493    }
494
495    /// Returns a dash-separated string representation of the per-lap mushroom usage counts.
496    ///
497    /// For example, a three-lap run with one mushroom on lap 1 and two on lap 3
498    /// would return `"1-0-2"`.
499    pub fn shroomstrat_string(&self) -> String {
500        let mut shroomstrat = self.shroomstrat().iter();
501
502        let mut s = shroomstrat.next().unwrap().to_string();
503        for lap_shrooms in shroomstrat {
504            write!(s, "-{lap_shrooms}").unwrap()
505        }
506
507        s
508    }
509
510    /// Returns whether the player was launched by a cannon during the run.
511    pub fn cannoned(&self) -> bool {
512        self.cannoned
513    }
514
515    /// Returns whether the player went out of bounds during the run.
516    pub fn went_oob(&self) -> bool {
517        self.went_oob
518    }
519
520    /// Returns whether CTGP flagged a potential slowdown event during the run.
521    pub fn potential_slowdown(&self) -> bool {
522        self.potential_slowdown
523    }
524
525    /// Returns whether CTGP flagged potential rapid-fire input during the run.
526    pub fn potential_rapidfire(&self) -> bool {
527        self.potential_rapidfire
528    }
529
530    /// Returns whether CTGP's heuristics flagged this ghost as potentially cheated.
531    pub fn potentially_cheated_ghost(&self) -> bool {
532        self.potentially_cheated_ghost
533    }
534
535    /// Returns whether the Mii data in the ghost file has been replaced.
536    pub fn has_mii_data_replaced(&self) -> bool {
537        self.has_mii_data_replaced
538    }
539
540    /// Returns whether the Mii name in the ghost file has been replaced.
541    pub fn has_name_replaced(&self) -> bool {
542        self.has_name_replaced
543    }
544
545    /// Returns whether the player respawned at any point during the run.
546    pub fn respawns(&self) -> bool {
547        self.respawns
548    }
549
550    /// Returns the run category as determined by CTGP's metadata.
551    pub fn category(&self) -> Category {
552        self.category
553    }
554
555    /// Returns the footer format version number.
556    pub fn footer_version(&self) -> u8 {
557        self.footer_version
558    }
559
560    /// Returns the length of the CTGP footer in bytes, excluding the trailing CRC32.
561    pub fn len(&self) -> usize {
562        self.len
563    }
564
565    /// Returns `true` if the footer has zero length.
566    pub fn is_empty(&self) -> bool {
567        self.len == 0
568    }
569}
570
571/// Returns `true` if the given face-button byte indicates a CTGP pause event.
572///
573/// CTGP encodes pauses in bit 6 (`0x40`) of the face-button input byte.
574fn contains_ctgp_pause(buttons: u8) -> bool {
575    buttons & 0x40 != 0
576}