From 394924b3da8a6610d13127f1411185e37c6c4cad Mon Sep 17 00:00:00 2001 From: jujuforce Date: Sun, 8 Mar 2026 09:23:03 +0000 Subject: [PATCH] feat: add generate command to create ICF1 from a folder of game files (#1) Scans a folder for .pack, .app, and .opt files, parses their filenames to extract version/datetime metadata, deduplicates options and patches (keeping latest), sorts chronologically, and outputs an encrypted ICF1 file named after the highest option ID (e.g. ICF1_A052). Co-authored-by: Jujuforce Reviewed-on: https://gitea.tendokyu.moe/beerpsi/icf-reader/pulls/1 Co-authored-by: jujuforce Co-committed-by: jujuforce --- src/generate.rs | 205 ++++++++++++++++++++++++++++++++++++++++++++++ src/icf/models.rs | 21 +++++ src/main.rs | 33 ++++++++ 3 files changed, 259 insertions(+) create mode 100644 src/generate.rs diff --git a/src/generate.rs b/src/generate.rs new file mode 100644 index 0000000..a558c11 --- /dev/null +++ b/src/generate.rs @@ -0,0 +1,205 @@ +use std::collections::HashMap; +use std::path::Path; + +use anyhow::{anyhow, bail, Result}; +use chrono::NaiveDateTime; + +use crate::icf::models::{IcfData, IcfInnerData, IcfOptionData, IcfPatchData, Version}; + +fn parse_datetime(s: &str) -> Result { + NaiveDateTime::parse_from_str(s, "%Y%m%d%H%M%S") + .map_err(|e| anyhow!("Failed to parse datetime '{s}': {e}")) +} + +fn entry_datetime(entry: &IcfData) -> NaiveDateTime { + match entry { + IcfData::System(d) | IcfData::App(d) => d.datetime, + IcfData::Option(d) => d.datetime, + IcfData::Patch(d) => d.target_datetime, + } +} + +/// Scans a folder for .pack, .app, .opt files and produces ICF entries + output filename. +pub fn generate_icf_entries(folder: &Path) -> Result<(Vec, String)> { + let mut pack_files = Vec::new(); + let mut app_files = Vec::new(); + let mut opt_files = Vec::new(); + + for entry in std::fs::read_dir(folder)? { + let entry = entry?; + let path = entry.path(); + if !path.is_file() { + continue; + } + + match path.extension().and_then(|e| e.to_str()) { + Some("pack") => pack_files.push(path), + Some("app") => app_files.push(path), + Some("opt") => opt_files.push(path), + _ => {} + } + } + + // Parse .pack file -> System entry + if pack_files.is_empty() { + bail!("No .pack file found in {}", folder.display()); + } + if pack_files.len() > 1 { + bail!("Expected exactly one .pack file, found {}", pack_files.len()); + } + + let pack_stem = pack_files[0] + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow!("Invalid .pack filename"))?; + let pack_parts: Vec<&str> = pack_stem.split('_').collect(); + if pack_parts.len() != 4 { + bail!("Invalid .pack filename format: {pack_stem}"); + } + + let system_id = pack_parts[0].to_string(); + let system_version: Version = pack_parts[1].parse()?; + let system_datetime = parse_datetime(pack_parts[2])?; + + let system_entry = IcfData::System(IcfInnerData { + id: system_id, + version: system_version, + required_system_version: system_version, + datetime: system_datetime, + is_prerelease: false, + }); + + // Parse .app files -> App (base) and Patch entries + let mut base_app: Option = None; + let mut patches: Vec = Vec::new(); + + for app_path in &app_files { + let stem = app_path + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow!("Invalid .app filename"))?; + let parts: Vec<&str> = stem.split('_').collect(); + + match parts.len() { + 4 => { + // Base app: [id, version, timestamp, "0"] + let id = parts[0].to_string(); + let version: Version = parts[1].parse()?; + let datetime = parse_datetime(parts[2])?; + + if base_app.is_some() { + bail!("Multiple base .app files found"); + } + + base_app = Some(IcfInnerData { + id, + version, + datetime, + required_system_version: system_version, + is_prerelease: false, + }); + } + 5 => { + // Patch: [id, target_version, timestamp, seq_number, source_version] + let id = parts[0].to_string(); + let target_version: Version = parts[1].parse()?; + let target_datetime = parse_datetime(parts[2])?; + let sequence_number: u8 = parts[3] + .parse() + .map_err(|_| anyhow!("Invalid sequence number: {}", parts[3]))?; + let source_version: Version = parts[4].parse()?; + + patches.push(IcfPatchData { + id, + sequence_number, + source_version, + source_datetime: NaiveDateTime::default(), // filled below + source_required_system_version: system_version, + target_version, + target_datetime, + target_required_system_version: system_version, + is_prerelease: false, + }); + } + _ => bail!("Invalid .app filename format: {stem}"), + } + } + + let base_app = base_app.ok_or_else(|| anyhow!("No base .app file found in {}", folder.display()))?; + + // Fill source_datetime from base app, keep only latest patch + let latest_patch = patches + .into_iter() + .max_by_key(|p| p.target_version) + .map(|mut p| { + p.source_datetime = base_app.datetime; + p + }); + + // Parse .opt files -> Option entries, dedup by option_id (keep latest) + let mut option_map: HashMap = HashMap::new(); + + for opt_path in &opt_files { + let stem = opt_path + .file_stem() + .and_then(|s| s.to_str()) + .ok_or_else(|| anyhow!("Invalid .opt filename"))?; + let parts: Vec<&str> = stem.split('_').collect(); + + if parts.len() != 4 { + bail!("Invalid .opt filename format: {stem}"); + } + + let app_id = parts[0].to_string(); + let option_id = parts[1].to_string(); + let datetime = parse_datetime(parts[2])?; + + match option_map.get(&option_id) { + Some(existing) if existing.datetime >= datetime => {} + _ => { + option_map.insert( + option_id.clone(), + IcfOptionData { + app_id, + option_id, + datetime, + required_system_version: system_version, + is_prerelease: false, + }, + ); + } + } + } + + // Assemble all entries + let mut entries: Vec = Vec::new(); + entries.push(system_entry); + entries.push(IcfData::App(base_app)); + + if let Some(patch) = latest_patch { + entries.push(IcfData::Patch(patch)); + } + + for opt in option_map.into_values() { + entries.push(IcfData::Option(opt)); + } + + // Sort chronologically + entries.sort_by_key(|e| entry_datetime(e)); + + // Determine output filename + let highest_option_id = entries + .iter() + .filter_map(|e| match e { + IcfData::Option(o) => Some(o.option_id.as_str()), + _ => None, + }) + .max(); + + let output_filename = match highest_option_id { + Some(id) => format!("ICF1_{id}"), + None => "ICF1".to_string(), + }; + + Ok((entries, output_filename)) +} diff --git a/src/icf/models.rs b/src/icf/models.rs index 59025c5..e598007 100644 --- a/src/icf/models.rs +++ b/src/icf/models.rs @@ -1,4 +1,5 @@ use std::fmt::Display; +use std::str::FromStr; use chrono::NaiveDateTime; use serde::{de, Deserialize, Serialize, Serializer}; @@ -16,6 +17,26 @@ impl Display for Version { } } +impl FromStr for Version { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let parts: Vec<&str> = s.split('.').collect(); + if parts.len() != 3 { + return Err(anyhow::anyhow!("A version must have exactly three components, got: {s}")); + } + + let major = parts[0].parse::() + .map_err(|_| anyhow::anyhow!("Major version must be a 16-bit unsigned integer: {}", parts[0]))?; + let minor = parts[1].parse::() + .map_err(|_| anyhow::anyhow!("Minor version must be an 8-bit unsigned integer: {}", parts[1]))?; + let build = parts[2].parse::() + .map_err(|_| anyhow::anyhow!("Build version must be an 8-bit unsigned integer: {}", parts[2]))?; + + Ok(Version { major, minor, build }) + } +} + impl Serialize for Version { fn serialize(&self, serializer: S) -> Result where S: Serializer { serializer.serialize_str(&self.to_string()) diff --git a/src/main.rs b/src/main.rs index d121f8c..e7fcbc9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,4 @@ +mod generate; mod icf; use std::fs::File; @@ -37,6 +38,12 @@ enum Commands { json_input: String, output: String, }, + + #[command(about = "Generates an ICF1 file from a folder of .pack, .app, and .opt files")] + Generate { + /// Path to the folder containing game files + folder: String, + }, } fn main() -> Result<(), anyhow::Error> { @@ -96,6 +103,32 @@ fn main() -> Result<(), anyhow::Error> { std::fs::write(output, encrypted)?; } + Commands::Generate { folder } => { + let folder_path = std::path::Path::new(folder); + if !folder_path.is_dir() { + return Err(anyhow::anyhow!("'{}' is not a directory", folder)); + } + + let (entries, output_name) = generate::generate_icf_entries(folder_path)?; + + for entry in &entries { + print!("{}", entry.filename()); + + if entry.is_prerelease() { + print!(" (PRERELEASE)"); + } + + println!(); + } + + let serialized = serialize_icf(&entries)?; + let encrypted = encrypt_icf(&serialized, ICF_KEY, ICF_IV)?; + + let output_path = folder_path.join(&output_name); + std::fs::write(&output_path, encrypted)?; + + println!("\nWrote {} entries to {}", entries.len(), output_path.display()); + } } Ok(())