Skip to main content

rkg_utils/ctgp_footer/
mod.rs

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