diff formats/src/th06/t6rp.rs @ 794:8c2ef2d503c9 default tip

formats: Implement T6RP in Rust This has been tested against demo00.rpy shipped in CM.DAT.
author Link Mauve <linkmauve@linkmauve.fr>
date Tue, 02 Jun 2026 16:39:21 +0200
parents
children
line wrap: on
line diff
new file mode 100644
--- /dev/null
+++ b/formats/src/th06/t6rp.rs
@@ -0,0 +1,260 @@
+//! Touhou 6 Replay (T6RP) format support.
+//!
+//! This module provides classes for handling the Touhou 6 Replay file format.
+//! The T6RP file format is an encrypted format describing different aspects of
+//! a game of EoSD. Since the EoSD engine is entirely deterministic, a small
+//! replay file is sufficient to unfold a full game.
+
+use std::convert::TryInto;
+
+use nom::{
+    bytes::complete::tag,
+    multi::count,
+    number::complete::{le_f32, le_i8, le_u16, le_u32, le_u8},
+    IResult, Parser,
+};
+
+/// A single level of a replay.
+#[derive(Debug, Clone, Default)]
+pub struct Level {
+    /// The score before starting the level.
+    pub score: u32,
+
+    /// The seed initializing the PRNG.
+    pub random_seed: u16,
+
+    /// The amount of point items collected before starting this level.
+    pub point_items: u16,
+
+    /// The power level at the beginning of the level.
+    pub power: u8,
+
+    /// How many lives are remaining before starting this level.
+    pub lives: i8,
+
+    /// How many bombs are remaining before starting this level.
+    pub bombs: i8,
+
+    /// The hidden difficulty at the beginning of this level.
+    pub difficulty: u8,
+
+    unknown: u32,
+
+    /// The list of keys pressed during this level.
+    pub keys: Vec<(u32, u16, u16)>,
+}
+
+/// The unencrypted part of a replay.
+#[derive(Debug, Clone)]
+pub struct Header {
+    /// The version, only 1.02 is supported so far.
+    pub version: u16,
+
+    /// The character picked by the player, 0 = Reimu A, 3 = Marisa B.
+    pub character: u8,
+
+    /// The rank picked by the player, 0 = Easy, 4 = Extra.
+    pub rank: u8,
+
+    /// The checksum of the encrypted part of this replay.
+    pub checksum: u32,
+
+    unknown1: u8,
+
+    unknown2: u8,
+
+    /// The encryption key for the rest of this replay.
+    pub key: u8,
+}
+
+/// The encrypted part of a replay.
+#[derive(Debug, Clone)]
+pub struct T6rp {
+    unknown3: u8,
+
+    /// The date at which this replay got recorded.
+    pub date: [u8; 9],
+
+    /// The name of the player.
+    pub name: [u8; 9],
+
+    unknown4: u16,
+
+    /// The score at the end of this replay.
+    pub score: u32,
+
+    unknown5: u32,
+
+    /// How much slowdown has been experienced while recording this replay.
+    pub slowdown: f32,
+
+    unknown6: u32,
+
+    /// Each of the stages that got recorded, only 0..=6 or 7 can be set.
+    pub levels: [Option<Level>; 7],
+}
+
+// TODO: Add a newtype for `encrypted`, to provide the decrypt() method on it and then another
+// newtype for the decrypted data to have verify() and parse().
+impl T6rp {
+    /// Parse a slice of bytes into a `Header` struct, which is the unencrypted part of T6RP.
+    pub fn parse_header(input: &[u8]) -> IResult<&[u8], Header> {
+        let (encrypted, (_, version, character, rank, checksum, unknown1, unknown2, key)) = (
+            tag(&b"T6RP"[..]),
+            le_u16,
+            le_u8,
+            le_u8,
+            le_u32,
+            le_u8,
+            le_u8,
+            le_u8,
+        )
+            .parse(input)?;
+        Ok((
+            encrypted,
+            Header {
+                version,
+                character,
+                rank,
+                checksum,
+                unknown1,
+                unknown2,
+                key,
+            },
+        ))
+    }
+
+    /// Decrypt the contents of the replay with the header’s key.
+    pub fn decrypt(key: u8, encrypted: &[u8]) -> Vec<u8> {
+        let mut decrypted = Vec::with_capacity(encrypted.len());
+        for (i, c) in encrypted.iter().enumerate() {
+            decrypted.push(c.wrapping_sub(key).wrapping_sub((7 * i) as u8));
+        }
+        decrypted
+    }
+
+    /// Verify decrypted data against the header’s checksum.
+    pub fn verify(key: u8, checksum: u32, data: &[u8]) -> bool {
+        let real_sum = data
+            .iter()
+            .map(|c| *c as u32)
+            .sum::<u32>()
+            .wrapping_add(0x3f000318)
+            .wrapping_add(key as u32);
+        checksum == real_sum
+    }
+
+    /// Parse a slice of bytes into a `T6rp` struct.
+    pub fn from_slice(input: &[u8]) -> IResult<&[u8], T6rp> {
+        let (
+            _,
+            (unknown3, date, name, unknown4, score, unknown5, slowdown, unknown6, stage_offsets),
+        ) = (
+            le_u8,
+            count(le_u8, 9),
+            count(le_u8, 9),
+            le_u16,
+            le_u32,
+            le_u32,
+            le_f32,
+            le_u32,
+            count(le_u32, 7),
+        )
+            .parse(input)?;
+        let date = date.try_into().unwrap();
+        let name = name.try_into().unwrap();
+
+        let mut levels = [const { None }; 7];
+        for (index, offset) in stage_offsets.into_iter().enumerate() {
+            if offset == 0 {
+                continue;
+            }
+
+            let data = &input[offset as usize - 15..];
+            let (
+                mut data,
+                (score, random_seed, point_items, power, lives, bombs, difficulty, unknown),
+            ) = (le_u32, le_u16, le_u16, le_u8, le_i8, le_i8, le_u8, le_u32).parse(data)?;
+
+            let mut keys = Vec::new();
+            loop {
+                let (data2, (time, keystate, unknown)) = (le_u32, le_u16, le_u16).parse(data)?;
+                data = data2;
+
+                if time == 9999999 {
+                    break;
+                }
+                keys.push((time, keystate, unknown));
+            }
+
+            levels[index] = Some(Level {
+                score,
+                random_seed,
+                point_items,
+                power,
+                lives,
+                bombs,
+                difficulty,
+                unknown,
+                keys,
+            });
+        }
+
+        Ok((
+            b"",
+            T6rp {
+                unknown3,
+                date,
+                name,
+                unknown4,
+                score,
+                unknown5,
+                slowdown,
+                unknown6,
+                levels,
+            },
+        ))
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use std::fs::File;
+    use std::io::{self, Read};
+
+    #[test]
+    fn t6rp() {
+        let file = File::open("EoSD/CM.bak/demo00.rpy").unwrap();
+        let mut file = io::BufReader::new(file);
+        let mut buf = Vec::new();
+        file.read_to_end(&mut buf).unwrap();
+
+        let (encrypted, header) = T6rp::parse_header(&buf).unwrap();
+        assert_eq!(header.version, 0x0102);
+        assert_eq!(header.character, 0);
+        assert_eq!(header.rank, 3);
+        assert_eq!(header.checksum, 0x3f03188d);
+        assert_eq!(header.key, 0x9d);
+
+        let data = T6rp::decrypt(header.key, &encrypted);
+
+        assert!(T6rp::verify(header.key, header.checksum, &data));
+
+        let (_, t6rp) = T6rp::from_slice(&data).unwrap();
+        assert_eq!(t6rp.date, *b"08/21/02\0");
+        assert_eq!(t6rp.name, *b"A       \0");
+        assert_eq!(t6rp.score, 7837690);
+        assert_eq!(t6rp.slowdown, 0.62111616);
+
+        let level4 = t6rp.levels[4 - 1].as_ref().unwrap();
+        assert_eq!(level4.score, 7837690);
+        assert_eq!(level4.random_seed, 0xbf81);
+        assert_eq!(level4.point_items, 0);
+        assert_eq!(level4.power, 128);
+        assert_eq!(level4.lives, 2);
+        assert_eq!(level4.bombs, 3);
+        assert_eq!(level4.difficulty, 16);
+        assert_eq!(level4.keys.len(), 322);
+    }
+}