Skip to main content

supreme_characters/
lib.rs

1use serde::{Deserialize, Serialize};
2use std::fmt;
3use std::path::{Path, PathBuf};
4use supreme_parsing::{self, Node, Value};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)]
7pub enum CharacterMaterial {
8    Face,
9    Body,
10    Jacket,
11    Pants,
12}
13
14impl CharacterMaterial {
15    #[must_use]
16    pub fn all() -> [Self; 4] {
17        [Self::Face, Self::Body, Self::Jacket, Self::Pants]
18    }
19
20    #[must_use]
21    pub fn matches(&self, material_name: &str) -> bool {
22        let name = material_name.to_lowercase();
23        match self {
24            Self::Face => name.contains("face"),
25            Self::Body => name.contains("body"),
26            Self::Jacket => name.contains("jacket") || name.contains("bfgj"),
27            Self::Pants => name.contains("pant") || name.contains("bfgp"), // Akiko's bulk has a single pant.
28        }
29    }
30}
31
32impl fmt::Display for CharacterMaterial {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        let s = match self {
35            Self::Face => "Face",
36            Self::Body => "Body",
37            Self::Jacket => "Jacket",
38            Self::Pants => "Pants",
39        };
40        write!(f, "{s}")
41    }
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize)]
45pub struct CharacterConfig {
46    pub skin: SkinBlock,
47    pub mat_list: MaterialList,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct SkinBlock {
52    pub mesh_file: String,
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56pub struct CharacterMaterials {
57    pub face: PathBuf,
58    pub body: PathBuf,
59    pub jacket: PathBuf,
60    pub pants: PathBuf,
61}
62
63impl CharacterConfig {
64    #[must_use]
65    pub fn resolve_materials(&self) -> Option<CharacterMaterials> {
66        let mut face = None;
67        let mut body = None;
68        let mut jacket = None;
69        let mut pants = None;
70
71        for mat in &self.mat_list.material {
72            let name = &mat.material_name;
73            let texture = PathBuf::from(mat.texture_base.as_ref().unwrap_or(&mat.texture));
74
75            if CharacterMaterial::Face.matches(name) {
76                face = Some(texture);
77            } else if CharacterMaterial::Body.matches(name) {
78                body = Some(texture);
79            } else if CharacterMaterial::Jacket.matches(name) {
80                jacket = Some(texture);
81            } else if CharacterMaterial::Pants.matches(name) {
82                pants = Some(texture);
83            }
84        }
85
86        Some(CharacterMaterials {
87            face: face?,
88            body: body?,
89            jacket: jacket?,
90            pants: pants?,
91        })
92    }
93
94    /// Resolves the textures currently pointed to by the game (ignoring backups).
95    #[must_use]
96    pub fn resolve_active_materials(&self) -> Option<CharacterMaterials> {
97        let mut face = None;
98        let mut body = None;
99        let mut jacket = None;
100        let mut pants = None;
101
102        for mat in &self.mat_list.material {
103            let name = &mat.material_name;
104            let texture = PathBuf::from(&mat.texture);
105
106            if CharacterMaterial::Face.matches(name) {
107                face = Some(texture);
108            } else if CharacterMaterial::Body.matches(name) {
109                body = Some(texture);
110            } else if CharacterMaterial::Jacket.matches(name) {
111                jacket = Some(texture);
112            } else if CharacterMaterial::Pants.matches(name) {
113                pants = Some(texture);
114            }
115        }
116
117        Some(CharacterMaterials {
118            face: face?,
119            body: body?,
120            jacket: jacket?,
121            pants: pants?,
122        })
123    }
124
125    /// Updates this config with materials from another config (a skin).
126    /// If a material in this config is updated and doesn't have a `texture_base`,
127    /// the current texture is preserved in `texture_base` as a backup.
128    pub fn apply_skin(&mut self, skin_config: &CharacterConfig) {
129        for skin_mat in &skin_config.mat_list.material {
130            // Find a matching material in self by name
131            if let Some(base_mat) = self
132                .mat_list
133                .material
134                .iter_mut()
135                .find(|m| m.material_name == skin_mat.material_name)
136            {
137                if base_mat.texture_base.is_none() {
138                    base_mat.texture_base = Some(base_mat.texture.clone());
139                }
140                base_mat.texture = skin_mat.texture.clone();
141            }
142        }
143    }
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize)]
147pub struct MaterialList {
148    #[serde(rename = "material", default)]
149    pub material: Vec<MaterialEntry>,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct MaterialEntry {
154    pub material_name: String,
155    pub texture: String,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub texture_base: Option<String>,
158}
159
160/// Returns a list of valid character directories.
161/// A directory is valid if it contains plrmshcnf.txt AND that config specifies
162/// at least one material for each category: face, body, jacket, pants.
163#[must_use]
164pub fn get_valid_character_directories(characters_dir: &Path) -> Vec<PathBuf> {
165    let mut dirs = Vec::new();
166
167    if let Ok(entries) = std::fs::read_dir(characters_dir) {
168        for entry in entries.flatten() {
169            let path = entry.path();
170            let config_path = path.join("plrmshcnf.txt");
171            if path.is_dir() && config_path.exists() {
172                // Parse it to check for all 4 required materials
173                if let Ok(config) = supreme_parsing::from_path::<CharacterConfig>(&config_path)
174                    && config.resolve_materials().is_some()
175                {
176                    dirs.push(path);
177                }
178            }
179        }
180    }
181
182    // Sort character directories alphabetically
183    dirs.sort_by(|a, b| {
184        let a_name = a
185            .file_name()
186            .and_then(|n| n.to_str())
187            .unwrap_or_default()
188            .to_lowercase();
189        let b_name = b
190            .file_name()
191            .and_then(|n| n.to_str())
192            .unwrap_or_default()
193            .to_lowercase();
194        a_name.cmp(&b_name)
195    });
196
197    dirs
198}
199
200/// Resolves the texture path for a specific material category.
201#[must_use]
202pub fn get_character_texture_path(
203    character_dir: &Path,
204    material_type: CharacterMaterial,
205) -> Option<PathBuf> {
206    let config_path = character_dir.join("plrmshcnf.txt");
207
208    let config: CharacterConfig = supreme_parsing::from_path(&config_path).ok()?;
209
210    for mat in &config.mat_list.material {
211        if material_type.matches(&mat.material_name) {
212            return Some(PathBuf::from(
213                mat.texture_base.as_ref().unwrap_or(&mat.texture),
214            ));
215        }
216    }
217
218    None
219}
220
221/// Updates the base character's `plrmshcnf.txt` with materials from a skin.
222/// This will create a `texture_base` backup for each material if it doesn't already exist.
223/// This version use AST patching to preserve unknown fields and comments.
224pub fn set_character_skin(
225    base_dir: &Path,
226    skin_dir: &Path,
227) -> Result<(), Box<dyn std::error::Error>> {
228    let base_config_path = base_dir.join("plrmshcnf.txt");
229    let skin_config_path = skin_dir.join("plrmshcnf.txt");
230
231    let base_content = std::fs::read_to_string(&base_config_path)?;
232    let skin_content = std::fs::read_to_string(&skin_config_path)?;
233
234    let mut base_ast = supreme_parsing::parse::parse(&base_content)?;
235    let skin_ast = supreme_parsing::parse::parse(&skin_content)?;
236
237    patch_config_ast(&mut base_ast, &skin_ast);
238
239    let updated_content = supreme_parsing::to_string_ast(&base_ast);
240    std::fs::write(&base_config_path, updated_content)?;
241
242    Ok(())
243}
244
245fn patch_config_ast(base: &mut Value, skin: &Value) {
246    if let (Value::Block(base_nodes), Value::Block(skin_nodes)) = (base, skin) {
247        // Find mat_list in skin
248        if let Some(skin_mat_list) = skin_nodes.iter().find_map(|n| {
249            if let Node::Entry(k, v) = n
250                && k == "mat_list"
251            {
252                Some(v)
253            } else {
254                None
255            }
256        }) {
257            // Find mat_list in base
258            if let Some(base_mat_list) = base_nodes.iter_mut().find_map(|n| {
259                if let Node::Entry(k, v) = n
260                    && k == "mat_list"
261                {
262                    Some(v)
263                } else {
264                    None
265                }
266            }) {
267                patch_mat_list(base_mat_list, skin_mat_list);
268            }
269        }
270    }
271}
272
273fn patch_mat_list(base: &mut Value, skin: &Value) {
274    if let (Value::Block(base_nodes), Value::Block(skin_nodes)) = (base, skin) {
275        // For each material in skin...
276        for skin_node in skin_nodes {
277            if let Node::Entry(k, skin_mat) = skin_node
278                && k == "material"
279                && let Some(skin_name) = skin_mat.get_key("material_name")
280                && let Some(base_mat) = base_nodes.iter_mut().find_map(|n| {
281                    // Find matching material in base
282                    if let Node::Entry(k, v) = n
283                        && k == "material"
284                        && let Some(base_name) = v.get_key("material_name")
285                        && base_name.to_lowercase() == skin_name.to_lowercase()
286                    {
287                        return Some(v);
288                    }
289                    None
290                })
291            {
292                // Patch the material
293                // If the skin source itself has a backup (e.g. we are restoring from base folder),
294                // use that backup as the source. Otherwise use the skin's texture.
295                if let Some(skin_tex) = skin_mat
296                    .get_key("texture_base")
297                    .or_else(|| skin_mat.get_key("texture"))
298                {
299                    // Backup current base if needed
300                    if base_mat.get_key("texture_base").is_none()
301                        && let Some(base_tex) = base_mat.get_key("texture")
302                    {
303                        base_mat.set_key("texture_base", base_tex);
304                    }
305                    // Update texture
306                    base_mat.set_key("texture", skin_tex);
307                }
308            }
309        }
310    }
311}
312
313/// Helper to identify which skin is currently active in a base character's directory.
314/// Returns the name of the matching skin, or "(Custom)" if no match found.
315#[must_use]
316pub fn identify_active_skin(base_dir: &Path, skins: &[CharacterSkin]) -> String {
317    let config_path = base_dir.join("plrmshcnf.txt");
318    let base_config: CharacterConfig = match supreme_parsing::from_path(&config_path) {
319        Ok(c) => c,
320        Err(_) => return "(Custom)".to_string(),
321    };
322
323    let active_mats = match base_config.resolve_active_materials() {
324        Some(m) => m,
325        None => return "(Custom)".to_string(),
326    };
327
328    for skin in skins {
329        let skin_config_path = skin.dir.join("plrmshcnf.txt");
330        if let Ok(skin_config) = supreme_parsing::from_path::<CharacterConfig>(&skin_config_path) {
331            // Use resolve_materials for the candidate skin to prefer the original texture paths (texture_base)
332            if let Some(skin_mats) = skin_config.resolve_materials()
333                && skin_mats == active_mats
334            {
335                return skin.name.clone();
336            }
337        }
338    }
339
340    "(Custom)".to_string()
341}
342
343#[derive(Debug, Clone)]
344pub struct BaseCharacter {
345    pub name: String,
346    pub base_dir: PathBuf,
347    pub mesh_file: PathBuf,
348    pub skins: Vec<CharacterSkin>,
349}
350
351#[derive(Debug, Clone)]
352pub struct CharacterSkin {
353    pub name: String,
354    pub dir: PathBuf,
355}
356
357/// Returns the name of the folder of the current ghost character (the first one in `player_types.txt`).
358#[must_use]
359pub fn get_ghost_character(characters_dir: &Path) -> Option<String> {
360    let path = characters_dir.join("player_types.txt");
361    let bytes = std::fs::read(&path).ok()?;
362    let content = supreme_parsing::encoding::decode_bytes(&bytes);
363    let root = supreme_parsing::parse::parse(&content).ok()?;
364
365    if let supreme_parsing::Value::Block(nodes) = root {
366        for node in nodes {
367            if let supreme_parsing::Node::Entry(key, val) = node
368                && key == "player"
369                && let Some(mesh_path) = val.get_key("mesh_config_file")
370            {
371                let path = mesh_path.replace('\\', "/");
372                let folder = Path::new(&path)
373                    .parent()?
374                    .file_name()?
375                    .to_str()
376                    .map(ToString::to_string);
377
378                return folder;
379            }
380        }
381    }
382    None
383}
384
385/// Sets the ghost character by reordering `player_types.txt` so that all entries
386/// for the given character name are moved to the top of the list.
387/// This implementation uses AST manipulation to preserve comments and unknown fields.
388pub fn set_ghost_character(
389    characters_dir: &Path,
390    character_name: &str,
391) -> Result<(), Box<dyn std::error::Error>> {
392    let path = characters_dir.join("player_types.txt");
393    let bytes = std::fs::read(&path)?;
394    let content = supreme_parsing::encoding::decode_bytes(&bytes);
395    let root = supreme_parsing::parse::parse(&content)?;
396
397    let char_low = character_name.to_lowercase();
398
399    if let supreme_parsing::Value::Block(nodes) = root {
400        let mut matching_nodes = Vec::new();
401        let mut other_nodes = Vec::new();
402
403        for node in nodes {
404            let is_match = if let supreme_parsing::Node::Entry(key, val) = &node
405                && key == "player"
406            {
407                let folder = val.get_key("mesh_config_file").and_then(|p| {
408                    let p = p.replace('\\', "/");
409                    Path::new(&p)
410                        .parent()?
411                        .file_name()?
412                        .to_str()
413                        .map(std::string::ToString::to_string)
414                });
415
416                match &folder {
417                    Some(f) => f.to_lowercase() == char_low,
418                    None => false,
419                }
420            } else {
421                false
422            };
423
424            if is_match {
425                matching_nodes.push(node);
426            } else {
427                other_nodes.push(node);
428            }
429        }
430
431        if matching_nodes.is_empty() {
432            println!(
433                "[supreme_characters] ERROR: Character '{character_name}' (low='{char_low}') not found in player_types.txt"
434            );
435            return Err(
436                format!("Character '{character_name}' not found in player_types.txt").into(),
437            );
438        }
439
440        let mut new_nodes = matching_nodes;
441        new_nodes.extend(other_nodes);
442        let new_root = supreme_parsing::Value::Block(new_nodes);
443
444        let updated_content = supreme_parsing::to_string_ast(&new_root);
445        let updated_bytes = supreme_parsing::encoding::encode_string(&updated_content);
446        std::fs::write(&path, updated_bytes)?;
447        println!("[supreme_characters] Successfully updated player_types.txt");
448    }
449
450    Ok(())
451}
452
453/// Returns a list of base characters, each with its associated skins.
454#[must_use]
455pub fn get_grouped_characters(characters_dir: &Path) -> Vec<BaseCharacter> {
456    let mut base_chars: Vec<BaseCharacter> = Vec::new();
457
458    if let Ok(entries) = std::fs::read_dir(characters_dir) {
459        let mut configs = Vec::new();
460
461        // Pass 1: Collect all valid character configs
462        for entry in entries.flatten() {
463            let path = entry.path();
464            let config_path = path.join("plrmshcnf.txt");
465            if path.is_dir()
466                && config_path.exists()
467                && let Ok(config) = supreme_parsing::from_path::<CharacterConfig>(&config_path)
468                && config.resolve_materials().is_some()
469            {
470                configs.push((path, config));
471            }
472        }
473
474        // Pass 2: Identify base characters
475        for (path, config) in &configs {
476            let config_dir = path.clone();
477            let mesh_path = PathBuf::from(&config.skin.mesh_file);
478            let mesh_dir = mesh_path.parent().unwrap_or(Path::new(""));
479
480            // Check if this is a base character (mesh is in its own directory)
481            // We compare the end of the config_dir with the mesh_dir (case-insensitive)
482            let is_base = match (config_dir.to_str(), mesh_dir.to_str()) {
483                (Some(cd), Some(md)) => {
484                    let cd_low = cd.to_lowercase().replace('\\', "/");
485                    let md_low = md.to_lowercase().replace('\\', "/");
486                    cd_low.ends_with(&md_low)
487                }
488                _ => false,
489            };
490
491            if is_base {
492                base_chars.push(BaseCharacter {
493                    name: path
494                        .file_name()
495                        .unwrap_or_default()
496                        .to_string_lossy()
497                        .to_string(),
498                    base_dir: path.clone(),
499                    mesh_file: mesh_path,
500                    skins: Vec::new(),
501                });
502            }
503        }
504
505        // Pass 3: Assign skins to base characters
506        for (path, config) in configs {
507            let mesh_path = PathBuf::from(&config.skin.mesh_file);
508
509            // Find the base character that uses this mesh
510            if let Some(base) = base_chars.iter_mut().find(|b| {
511                let b_mesh = b
512                    .mesh_file
513                    .to_string_lossy()
514                    .to_lowercase()
515                    .replace('\\', "/");
516                let c_mesh = mesh_path
517                    .to_string_lossy()
518                    .to_lowercase()
519                    .replace('\\', "/");
520                b_mesh == c_mesh
521            }) {
522                base.skins.push(CharacterSkin {
523                    name: path
524                        .file_name()
525                        .unwrap_or_default()
526                        .to_string_lossy()
527                        .to_string(),
528                    dir: path,
529                });
530            }
531        }
532    }
533
534    // Sort by name
535    base_chars.sort_by_key(|a| a.name.to_lowercase());
536    for base in &mut base_chars {
537        base.skins.sort_by_key(|a| a.name.to_lowercase());
538    }
539
540    base_chars
541}
542
543#[cfg(test)]
544mod tests {
545    use super::*;
546
547    /// `patch_config_ast` is a private function; this test must live here to access it.
548    #[test]
549    fn test_skin_persistence_lossless() {
550        let base_content = r#"
551# Character configuration
552skin {
553    mesh_file = "akiko.hx";
554    # LOD info
555    lod_dist = 100;
556};
557mat_list {
558    material { 
559        material_name = "face";
560        texture = "akiko_face.tga";
561    };
562}
563"#;
564        let skin_content = r#"
565mat_list {
566    material {
567        material_name = "face";
568        texture = "blue_face.tga";
569    };
570}
571"#;
572        let mut base_ast = supreme_parsing::parse::parse(base_content).unwrap();
573        let skin_ast = supreme_parsing::parse::parse(skin_content).unwrap();
574
575        patch_config_ast(&mut base_ast, &skin_ast);
576
577        let output = supreme_parsing::to_string_ast(&base_ast);
578
579        // Check if unknown fields and comments are preserved
580        assert!(output.contains("# Character configuration"));
581        assert!(output.contains("# LOD info"));
582        assert!(output.contains("lod_dist\t= 100;"));
583        assert!(output.contains("mesh_file\t= \"akiko.hx\";"));
584
585        // Check if material is updated and backed up
586        assert!(output.contains("texture\t= \"blue_face.tga\";"));
587        assert!(output.contains("texture_base\t= \"akiko_face.tga\";"));
588    }
589}