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