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 <jujuforce@gmail.com>
Reviewed-on: https://gitea.tendokyu.moe/beerpsi/icf-reader/pulls/1
Co-authored-by: jujuforce <jujuforce@noreply.gitea.tendokyu.moe>
Co-committed-by: jujuforce <jujuforce@noreply.gitea.tendokyu.moe>
This commit is contained in:
jujuforce
2026-03-08 09:23:03 +00:00
committed by beerpsi
co-authored by Jujuforce
parent 70944065e3
commit 394924b3da
3 changed files with 259 additions and 0 deletions
+205
View File
@@ -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> {
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<IcfData>, 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<IcfInnerData> = None;
let mut patches: Vec<IcfPatchData> = 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<String, IcfOptionData> = 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<IcfData> = 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))
}
+21
View File
@@ -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<Self, Self::Err> {
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::<u16>()
.map_err(|_| anyhow::anyhow!("Major version must be a 16-bit unsigned integer: {}", parts[0]))?;
let minor = parts[1].parse::<u8>()
.map_err(|_| anyhow::anyhow!("Minor version must be an 8-bit unsigned integer: {}", parts[1]))?;
let build = parts[2].parse::<u8>()
.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<S>(&self, serializer: S) -> Result<S::Ok, S::Error> where S: Serializer {
serializer.serialize_str(&self.to_string())
+33
View File
@@ -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(())