use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct WorldEntry {
pub index: u32,
pub map: String,
pub world: Option<String>,
pub palette: String,
}
#[derive(Debug, Clone)]
pub struct WorldIni {
pub entries: Vec<WorldEntry>,
}
#[must_use]
fn extract_filename(ini_path: &str) -> &str {
let normalized = ini_path.trim();
normalized.rsplit(['\\', '/']).next().unwrap_or(normalized)
}
#[must_use]
fn stem_lower(ini_path: &str) -> String {
let filename = extract_filename(ini_path);
match filename.rsplit_once('.') {
Some((stem, _)) => stem.to_ascii_lowercase(),
None => filename.to_ascii_lowercase(),
}
}
impl WorldIni {
#[must_use]
pub fn parse(content: &str) -> Self {
let mut maps: HashMap<u32, String> = HashMap::new();
let mut worlds: HashMap<u32, String> = HashMap::new();
let mut palettes: HashMap<u32, String> = HashMap::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
continue;
}
if line.starts_with('[') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
let key = key.trim().to_ascii_lowercase();
let value = value.trim().to_string();
if let Some(idx) = parse_indexed_key(&key, "world_map") {
maps.insert(idx, value);
} else if let Some(idx) = parse_indexed_key(&key, "world_world") {
worlds.insert(idx, value);
} else if let Some(idx) = parse_indexed_key(&key, "world_palette") {
palettes.insert(idx, value);
}
}
let mut entries: Vec<WorldEntry> = maps
.into_iter()
.filter_map(|(idx, map)| {
let palette = palettes.remove(&idx)?;
Some(WorldEntry {
index: idx,
map,
world: worlds.remove(&idx),
palette,
})
})
.collect();
entries.sort_by_key(|e| e.index);
Self { entries }
}
#[must_use]
pub fn find_by_map_stem(&self, file_stem: &str) -> Vec<&WorldEntry> {
let needle = file_stem.to_ascii_lowercase();
self.entries
.iter()
.filter(|e| stem_lower(&e.map) == needle)
.collect()
}
#[must_use]
pub fn find_by_world_stem(&self, file_stem: &str) -> Vec<&WorldEntry> {
let needle = file_stem.to_ascii_lowercase();
self.entries
.iter()
.filter(|e| e.world.as_ref().is_some_and(|w| stem_lower(w) == needle))
.collect()
}
}
pub const WORLD_INI_NAMES: [&str; 2] = ["WORLD.INI", "world.ini"];
#[must_use]
pub fn find_world_ini(asset_root: &Path) -> Option<PathBuf> {
for name in &WORLD_INI_NAMES {
let path = asset_root.join(name);
if path.is_file() {
return Some(path);
}
}
None
}
#[must_use]
pub fn find_palette_on_disk(asset_root: &Path, ini_palette_path: &str) -> Option<PathBuf> {
let filename = ini_palette_path
.trim()
.rsplit(['\\', '/'])
.next()
.unwrap_or_else(|| ini_palette_path.trim());
let filename_lower = filename.to_ascii_lowercase();
for dir_name in &["fxart", "3dart", "FXART", "3DART"] {
let dir = asset_root.join(dir_name);
if !dir.is_dir() {
continue;
}
let exact = dir.join(filename);
if exact.is_file() {
return Some(exact);
}
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.filter_map(Result::ok) {
if entry.file_name().to_string_lossy().to_ascii_lowercase() == filename_lower {
return Some(entry.path());
}
}
}
None
}
#[must_use]
fn parse_indexed_key(key: &str, prefix: &str) -> Option<u32> {
let rest = key.strip_prefix(prefix)?;
let rest = rest.strip_prefix('[')?;
let rest = rest.strip_suffix(']')?;
rest.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
const MINIMAL_INI: &str = "\
[world]
start_world=0
world_map[0]=MAPS\\start.rgm
world_world[0]=MAPS\\hideout.WLD
world_palette[0]=3DART\\sunset.COL
";
#[test]
fn parse_single_entry() {
let ini = WorldIni::parse(MINIMAL_INI);
assert_eq!(ini.entries.len(), 1);
let e = &ini.entries[0];
assert_eq!(e.index, 0);
assert_eq!(e.map, "MAPS\\start.rgm");
assert_eq!(e.world.as_deref(), Some("MAPS\\hideout.WLD"));
assert_eq!(e.palette, "3DART\\sunset.COL");
}
const MULTI_INI: &str = "\
[world]
world_map[0]=MAPS\\start.rgm
world_world[0]=MAPS\\hideout.WLD
world_palette[0]=3DART\\sunset.COL
world_map[1]=MAPS\\ISLAND.rgm
world_world[1]=MAPS\\ISLAND.WLD
world_palette[1]=3DART\\island.COL
world_map[27]=MAPS\\island.rgm
world_world[27]=MAPS\\ISLand.WLD
world_palette[27]=3DART\\nightsky.COL
world_map[28]=MAPS\\ISLAND.rgm
world_world[28]=MAPS\\ISLAND.WLD
world_palette[28]=3DART\\sunset.COL
";
#[test]
fn parse_multiple_entries_sorted() {
let ini = WorldIni::parse(MULTI_INI);
assert_eq!(ini.entries.len(), 4);
assert_eq!(ini.entries[0].index, 0);
assert_eq!(ini.entries[1].index, 1);
assert_eq!(ini.entries[2].index, 27);
assert_eq!(ini.entries[3].index, 28);
}
#[test]
fn find_by_map_stem_case_insensitive() {
let ini = WorldIni::parse(MULTI_INI);
let matches = ini.find_by_map_stem("island");
assert_eq!(matches.len(), 3, "ISLAND.rgm appears in worlds 1, 27, 28");
assert_eq!(matches[0].index, 1);
assert_eq!(matches[1].index, 27);
assert_eq!(matches[2].index, 28);
}
#[test]
fn find_by_map_stem_unique() {
let ini = WorldIni::parse(MULTI_INI);
let matches = ini.find_by_map_stem("start");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].palette, "3DART\\sunset.COL");
}
#[test]
fn find_by_world_stem() {
let ini = WorldIni::parse(MULTI_INI);
let matches = ini.find_by_world_stem("island");
assert_eq!(matches.len(), 3, "ISLAND.WLD appears in worlds 1, 27, 28");
}
#[test]
fn find_by_world_stem_hideout() {
let ini = WorldIni::parse(MULTI_INI);
let matches = ini.find_by_world_stem("hideout");
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].index, 0);
}
#[test]
fn missing_palette_skips_entry() {
let ini = WorldIni::parse("world_map[5]=MAPS\\observe.rgm\n");
assert!(ini.entries.is_empty(), "no palette → no entry");
}
#[test]
fn comments_and_blanks_ignored() {
let content = "\
; this is a comment
# so is this
[world]
world_map[2]=MAPS\\catacomb.rgm
world_palette[2]=3DART\\catacomb.COL
";
let ini = WorldIni::parse(content);
assert_eq!(ini.entries.len(), 1);
assert_eq!(ini.entries[0].index, 2);
}
#[test]
fn extract_filename_handles_backslash() {
assert_eq!(extract_filename("3DART\\island.COL"), "island.COL");
assert_eq!(extract_filename("MAPS\\ISLAND.rgm"), "ISLAND.rgm");
assert_eq!(extract_filename("island.COL"), "island.COL");
}
#[test]
fn stem_lower_normalizes() {
assert_eq!(stem_lower("3DART\\ISLAND.COL"), "island");
assert_eq!(stem_lower("MAPS\\start.rgm"), "start");
}
#[test]
fn no_world_field_still_parses() {
let content = "\
world_map[3]=MAPS\\PALACE.rgm
world_palette[3]=3DART\\palace00.COL
";
let ini = WorldIni::parse(content);
assert_eq!(ini.entries.len(), 1);
assert!(ini.entries[0].world.is_none());
}
}