Mercurial > touhou
changeset 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 | bdefd3e6d6f9 |
| children | |
| files | formats/src/th06/mod.rs formats/src/th06/t6rp.rs |
| diffstat | 2 files changed, 261 insertions(+), 0 deletions(-) [+] |
line wrap: on
line diff
--- a/formats/src/th06/mod.rs +++ b/formats/src/th06/mod.rs @@ -6,3 +6,4 @@ pub mod std; pub mod msg; pub mod pos; +pub mod t6rp;
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); + } +}
