Skip to main content

ltk_fantome/
lib.rs

1use eyre::Result;
2use image::ImageFormat;
3use ltk_mod_project::{ModProject, ModProjectAuthor, ModProjectLayer};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6use std::fs::{File, read_dir};
7use std::io::Write;
8use std::path::Path;
9use zip::{ZipWriter, write::SimpleFileOptions};
10
11pub mod error;
12mod extractor;
13mod hashtable;
14
15pub use error::FantomeExtractError;
16pub use extractor::{FantomeExtractResult, FantomeExtractor};
17pub use hashtable::{WadHashtable, format_chunk_path_hash};
18
19/// Fantome metadata structure that goes into info.json
20#[derive(Serialize, Deserialize, Debug)]
21pub struct FantomeInfo {
22    #[serde(rename = "Name")]
23    pub name: String,
24    #[serde(rename = "Author")]
25    pub author: String,
26    #[serde(rename = "Version")]
27    pub version: String,
28    #[serde(rename = "Description")]
29    pub description: String,
30    /// Tags/categories for the mod (e.g., "champion-skin", "sfx").
31    #[serde(rename = "Tags", default, skip_serializing_if = "Vec::is_empty")]
32    pub tags: Vec<String>,
33    /// Champions this mod targets (e.g., "Aatrox", "Ahri").
34    #[serde(rename = "Champions", default, skip_serializing_if = "Vec::is_empty")]
35    pub champions: Vec<String>,
36    /// Maps this mod targets (e.g., "Summoner's Rift", "Howling Abyss").
37    #[serde(rename = "Maps", default, skip_serializing_if = "Vec::is_empty")]
38    pub maps: Vec<String>,
39    /// Per-layer metadata including string overrides.
40    #[serde(rename = "Layers", default, skip_serializing_if = "HashMap::is_empty")]
41    pub layers: HashMap<String, FantomeLayerInfo>,
42}
43
44/// Per-layer metadata in a Fantome info.json.
45#[derive(Serialize, Deserialize, Debug, Clone)]
46pub struct FantomeLayerInfo {
47    #[serde(rename = "Name")]
48    pub name: String,
49    #[serde(rename = "Priority")]
50    pub priority: i32,
51    /// String overrides for this layer, organized by locale.
52    /// Outer key: locale (e.g., "en_us", "ko_kr", or "default")
53    /// Inner map: field name -> replacement string
54    #[serde(
55        rename = "StringOverrides",
56        default,
57        skip_serializing_if = "HashMap::is_empty"
58    )]
59    pub string_overrides: HashMap<String, HashMap<String, String>>,
60}
61
62/// Create a standard Fantome file name from a mod project.
63///
64/// If `custom_name` is provided, it will be used (with `.fantome` extension added if missing).
65/// Otherwise, generates `{name}_{version}.fantome`.
66pub fn create_file_name(mod_project: &ModProject, custom_name: Option<String>) -> String {
67    match custom_name {
68        Some(name) => {
69            if name.ends_with(".fantome") {
70                name
71            } else {
72                format!("{}.fantome", name)
73            }
74        }
75        None => {
76            format!("{}_{}.fantome", mod_project.name, mod_project.version)
77        }
78    }
79}
80
81/// Get layers that are not supported by the Fantome format.
82///
83/// Fantome only supports the base layer. This returns all non-base layers
84/// from the project, which can be used to warn users about data loss.
85pub fn get_unsupported_layers(mod_project: &ModProject) -> Vec<&ModProjectLayer> {
86    mod_project
87        .layers
88        .iter()
89        .filter(|layer| layer.name != "base")
90        .collect()
91}
92
93/// Check if the mod project has layers that won't be included in Fantome format.
94pub fn has_unsupported_layers(mod_project: &ModProject) -> bool {
95    mod_project.layers.iter().any(|layer| layer.name != "base")
96}
97
98/// Pack a mod project into a Fantome .zip format
99pub fn pack_to_fantome<W: Write + std::io::Seek>(
100    writer: W,
101    mod_project: &ModProject,
102    project_root: &Path,
103) -> Result<()> {
104    let mut zip = ZipWriter::new(writer);
105    let options = SimpleFileOptions::default()
106        .compression_method(zip::CompressionMethod::Deflated)
107        .unix_permissions(0o755);
108
109    // Pack base layer WAD files
110    pack_base_layer(&mut zip, project_root, &options)?;
111
112    // Pack metadata
113    pack_metadata(&mut zip, mod_project, project_root, &options)?;
114
115    zip.finish()?;
116    Ok(())
117}
118
119fn pack_base_layer<W: Write + std::io::Seek>(
120    zip: &mut ZipWriter<W>,
121    project_root: &Path,
122    options: &SimpleFileOptions,
123) -> Result<()> {
124    let base_layer_path = project_root.join("content").join("base");
125
126    if !base_layer_path.exists() {
127        return Err(eyre::eyre!(
128            "Base layer directory does not exist: {}",
129            base_layer_path.display()
130        ));
131    }
132
133    // Iterate through all .wad.client directories in the base layer
134    for entry in read_dir(&base_layer_path)? {
135        let entry = entry?;
136        let path = entry.path();
137
138        if path.is_dir()
139            && path
140                .file_name()
141                .unwrap()
142                .to_string_lossy()
143                .ends_with(".wad.client")
144        {
145            let wad_name = path.file_name().unwrap().to_string_lossy();
146            pack_wad_directory(zip, &path, &format!("WAD/{}", wad_name), options)?;
147        }
148    }
149
150    Ok(())
151}
152
153fn pack_wad_directory<W: Write + std::io::Seek>(
154    zip: &mut ZipWriter<W>,
155    wad_dir: &Path,
156    zip_prefix: &str,
157    options: &SimpleFileOptions,
158) -> Result<()> {
159    for entry in walkdir::WalkDir::new(wad_dir).into_iter() {
160        let entry = entry.map_err(|e| eyre::eyre!("Failed to walk directory: {}", e))?;
161        let path = entry.path();
162
163        if path.is_file() {
164            let relative_path = path.strip_prefix(wad_dir)?;
165            let zip_path = format!(
166                "{}/{}",
167                zip_prefix,
168                relative_path.to_string_lossy().replace('\\', "/")
169            );
170
171            zip.start_file(zip_path, *options)?;
172            let mut file = File::open(path)?;
173            std::io::copy(&mut file, zip)?;
174        }
175    }
176
177    Ok(())
178}
179
180fn pack_metadata<W: Write + std::io::Seek>(
181    zip: &mut ZipWriter<W>,
182    mod_project: &ModProject,
183    project_root: &Path,
184    options: &SimpleFileOptions,
185) -> Result<()> {
186    // Build layers map with string overrides
187    let layers = build_fantome_layers(mod_project);
188
189    // Create info.json
190    let info = FantomeInfo {
191        name: mod_project.display_name.clone(),
192        author: format_authors(&mod_project.authors),
193        version: mod_project.version.clone(),
194        description: mod_project.description.clone(),
195        tags: mod_project.tags.iter().map(|t| t.to_string()).collect(),
196        champions: mod_project.champions.clone(),
197        maps: mod_project.maps.iter().map(|m| m.to_string()).collect(),
198        layers,
199    };
200
201    zip.start_file("META/info.json", *options)?;
202    zip.write_all(&serde_json::to_string_pretty(&info)?.into_bytes())?;
203
204    // Add README.md if it exists
205    let readme_path = project_root.join("README.md");
206    if readme_path.exists() {
207        zip.start_file("META/README.md", *options)?;
208        let mut readme_file = File::open(readme_path)?;
209        std::io::copy(&mut readme_file, zip)?;
210    }
211
212    // Add image.png if thumbnail exists
213    if let Some(thumbnail_path) = &mod_project.thumbnail {
214        let full_thumbnail_path = project_root.join(thumbnail_path);
215        if full_thumbnail_path.exists() {
216            pack_image(zip, &full_thumbnail_path, options)?;
217        }
218    }
219
220    Ok(())
221}
222
223fn pack_image<W: Write + std::io::Seek>(
224    zip: &mut ZipWriter<W>,
225    image_path: &Path,
226    options: &SimpleFileOptions,
227) -> Result<()> {
228    let img = image::open(image_path)?;
229
230    let mut png_buffer = Vec::new();
231    img.write_to(&mut std::io::Cursor::new(&mut png_buffer), ImageFormat::Png)?;
232
233    zip.start_file("META/image.png", *options)?;
234    zip.write_all(&png_buffer)?;
235
236    Ok(())
237}
238
239fn build_fantome_layers(mod_project: &ModProject) -> HashMap<String, FantomeLayerInfo> {
240    let mut layers = HashMap::new();
241    for layer in &mod_project.layers {
242        // Only include layers that have string overrides
243        if !layer.string_overrides.is_empty() {
244            layers.insert(
245                layer.name.clone(),
246                FantomeLayerInfo {
247                    name: layer.name.clone(),
248                    priority: layer.priority,
249                    string_overrides: layer.string_overrides.clone(),
250                },
251            );
252        }
253    }
254    layers
255}
256
257fn format_authors(authors: &[ModProjectAuthor]) -> String {
258    if authors.is_empty() {
259        return "Unknown".to_string();
260    }
261
262    let author_names: Vec<String> = authors
263        .iter()
264        .map(|author| match author {
265            ModProjectAuthor::Name(name) => name.clone(),
266            ModProjectAuthor::Role { name, role: _ } => name.clone(),
267        })
268        .collect();
269
270    author_names.join(", ")
271}