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"), }
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 #[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 pub fn apply_skin(&mut self, skin_config: &CharacterConfig) {
129 for skin_mat in &skin_config.mat_list.material {
130 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#[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 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 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#[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
221pub 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 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 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 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 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 if let Some(skin_tex) = skin_mat
296 .get_key("texture_base")
297 .or_else(|| skin_mat.get_key("texture"))
298 {
299 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 base_mat.set_key("texture", skin_tex);
307 }
308 }
309 }
310 }
311}
312
313#[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 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#[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
385pub 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#[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 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 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 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 for (path, config) in configs {
507 let mesh_path = PathBuf::from(&config.skin.mesh_file);
508
509 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 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 #[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 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 assert!(output.contains("texture\t= \"blue_face.tga\";"));
587 assert!(output.contains("texture_base\t= \"akiko_face.tga\";"));
588 }
589}