view 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 source

//! 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);
    }
}