mirror of
https://gitea.tendokyu.moe/beerpsi/icf-reader.git
synced 2026-09-22 22:38:03 +03:00
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:
committed by
beerpsi
co-authored by
Jujuforce
parent
70944065e3
commit
394924b3da
+205
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
|
||||
Reference in New Issue
Block a user