Skip to main content

rkg_utils/footer/ctgp_footer/
mod.rs

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