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#[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 #[serde(rename = "Tags", default, skip_serializing_if = "Vec::is_empty")]
32 pub tags: Vec<String>,
33 #[serde(rename = "Champions", default, skip_serializing_if = "Vec::is_empty")]
35 pub champions: Vec<String>,
36 #[serde(rename = "Maps", default, skip_serializing_if = "Vec::is_empty")]
38 pub maps: Vec<String>,
39 #[serde(rename = "Layers", default, skip_serializing_if = "HashMap::is_empty")]
41 pub layers: HashMap<String, FantomeLayerInfo>,
42}
43
44#[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 #[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
62pub 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
81pub 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
93pub fn has_unsupported_layers(mod_project: &ModProject) -> bool {
95 mod_project.layers.iter().any(|layer| layer.name != "base")
96}
97
98pub 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(&mut zip, project_root, &options)?;
111
112 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 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 let layers = build_fantome_layers(mod_project);
188
189 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 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 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 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}