use crate::import::png::save_png;
use crate::{Result, error::Error};
use image::{DynamicImage, GrayImage, Luma};
use std::path::{Path, PathBuf};
pub const WLD_HEADER_BYTES: usize = 1184;
pub const WLD_HEADER_DWORDS: usize = WLD_HEADER_BYTES / 4;
pub const WLD_SECTION_COUNT: usize = 4;
pub const WLD_SECTION_HEADER_BYTES: usize = 22;
pub const WLD_MAP_SIDE: usize = 128;
pub const WLD_MAP_BYTES: usize = WLD_MAP_SIDE * WLD_MAP_SIDE;
pub const WLD_SECTION_BYTES: usize = WLD_SECTION_HEADER_BYTES + (4 * WLD_MAP_BYTES);
pub const WLD_FOOTER_BYTES: usize = 16;
#[derive(Debug, Clone)]
pub struct WldHeader {
pub fields: [u32; WLD_HEADER_DWORDS],
}
impl WldHeader {
#[must_use]
pub const fn section_offsets(&self) -> [u32; WLD_SECTION_COUNT] {
[
self.fields[36],
self.fields[37],
self.fields[38],
self.fields[39],
]
}
}
#[derive(Debug, Clone)]
pub struct WldSection {
pub header: [u8; WLD_SECTION_HEADER_BYTES],
pub maps: [[u8; WLD_MAP_BYTES]; 4],
}
#[derive(Debug, Clone)]
pub struct WldFile {
pub header: WldHeader,
pub sections: [WldSection; WLD_SECTION_COUNT],
pub footer: [u8; WLD_FOOTER_BYTES],
}
fn read_u32_le(data: &[u8], offset: usize) -> Result<u32> {
if offset + 4 > data.len() {
return Err(Error::Parse(format!(
"WLD read out of bounds at offset {offset}"
)));
}
Ok(u32::from_le_bytes(
data[offset..offset + 4]
.try_into()
.map_err(|_| Error::Parse(format!("WLD failed to parse LE u32 at offset {offset}")))?,
))
}
fn read_fixed<const N: usize>(data: &[u8], offset: usize) -> Result<[u8; N]> {
if offset + N > data.len() {
return Err(Error::Parse(format!(
"WLD read out of bounds at offset {offset} for {N} bytes"
)));
}
data[offset..offset + N].try_into().map_err(|_| {
Error::Parse(format!(
"WLD failed to read fixed buffer at offset {offset}"
))
})
}
pub fn parse_wld_file(data: &[u8]) -> Result<WldFile> {
let minimum_size = WLD_HEADER_BYTES + WLD_FOOTER_BYTES;
if data.len() < minimum_size {
return Err(Error::Parse(format!(
"WLD file too small: {} bytes (minimum {minimum_size})",
data.len()
)));
}
let mut fields = [0_u32; WLD_HEADER_DWORDS];
for (idx, field) in fields.iter_mut().enumerate() {
*field = read_u32_le(data, idx * 4)?;
}
let header = WldHeader { fields };
let offsets = header.section_offsets();
if usize::try_from(offsets[0]).unwrap_or(usize::MAX) != WLD_HEADER_BYTES {
return Err(Error::Parse(format!(
"WLD section0 offset mismatch: expected {WLD_HEADER_BYTES}, found {}",
offsets[0]
)));
}
if usize::try_from(header.fields[6]).unwrap_or(usize::MAX) != WLD_SECTION_HEADER_BYTES {
return Err(Error::Parse(format!(
"WLD section-header-size mismatch: expected {WLD_SECTION_HEADER_BYTES}, found {}",
header.fields[6]
)));
}
for (idx, off) in offsets.iter().enumerate() {
let next_off = if idx + 1 < WLD_SECTION_COUNT {
usize::try_from(offsets[idx + 1]).unwrap_or(usize::MAX)
} else {
data.len().saturating_sub(WLD_FOOTER_BYTES)
};
let current = usize::try_from(*off).unwrap_or(usize::MAX);
if current >= data.len() {
return Err(Error::Parse(format!(
"WLD section {idx} offset out of range: {current}"
)));
}
if current >= next_off {
return Err(Error::Parse(format!(
"WLD section offsets are not strictly increasing at index {idx}: {current} >= {next_off}"
)));
}
let span = next_off - current;
if span < WLD_SECTION_BYTES {
return Err(Error::Parse(format!(
"WLD section {idx} too small: {span} bytes (minimum {WLD_SECTION_BYTES})"
)));
}
}
let mut parsed_sections = Vec::with_capacity(WLD_SECTION_COUNT);
for off in offsets {
let start = usize::try_from(off)
.map_err(|_| Error::Parse("WLD section offset does not fit usize".to_string()))?;
let header_bytes = read_fixed::<WLD_SECTION_HEADER_BYTES>(data, start)?;
let mut cursor = start + WLD_SECTION_HEADER_BYTES;
let mut maps = [[0_u8; WLD_MAP_BYTES]; 4];
for map in &mut maps {
*map = read_fixed::<WLD_MAP_BYTES>(data, cursor)?;
cursor += WLD_MAP_BYTES;
}
parsed_sections.push(WldSection {
header: header_bytes,
maps,
});
}
let sections: [WldSection; WLD_SECTION_COUNT] = parsed_sections.try_into().map_err(|_| {
Error::Parse("WLD internal section conversion failed (expected 4 sections)".to_string())
})?;
let footer = read_fixed::<WLD_FOOTER_BYTES>(data, data.len() - WLD_FOOTER_BYTES)?;
Ok(WldFile {
header,
sections,
footer,
})
}
impl WldFile {
pub fn combined_map(&self, map_index: usize) -> Result<Vec<u8>> {
if map_index >= 4 {
return Err(Error::Parse(format!(
"WLD map index out of range: {map_index} (expected 0..3)"
)));
}
let out_side = WLD_MAP_SIDE * 2;
let mut out = vec![0_u8; out_side * out_side];
for y in 0..WLD_MAP_SIDE {
let src_row = y * WLD_MAP_SIDE;
let dst_top = y * out_side;
let dst_bottom = (y + WLD_MAP_SIDE) * out_side;
out[dst_top..dst_top + WLD_MAP_SIDE].copy_from_slice(
&self.sections[0].maps[map_index][src_row..src_row + WLD_MAP_SIDE],
);
out[dst_top + WLD_MAP_SIDE..dst_top + out_side].copy_from_slice(
&self.sections[1].maps[map_index][src_row..src_row + WLD_MAP_SIDE],
);
out[dst_bottom..dst_bottom + WLD_MAP_SIDE].copy_from_slice(
&self.sections[2].maps[map_index][src_row..src_row + WLD_MAP_SIDE],
);
out[dst_bottom + WLD_MAP_SIDE..dst_bottom + out_side].copy_from_slice(
&self.sections[3].maps[map_index][src_row..src_row + WLD_MAP_SIDE],
);
}
Ok(out)
}
pub fn combined_heightmap_luma(&self) -> Result<Vec<u8>> {
let map1 = self.combined_map(0)?;
Ok(map1.into_iter().map(|value| value & 0x7F).collect())
}
}
fn ensure_parent_dir(path: &Path) -> Result<()> {
if let Some(parent) = path.parent()
&& !parent.as_os_str().is_empty()
{
std::fs::create_dir_all(parent).map_err(|e| Error::File {
path: parent.to_path_buf(),
message: format!("failed to create directory: {e}"),
})?;
}
Ok(())
}
pub fn export_wld_heightmap_png(
input_path: &Path,
output_path: &Path,
compress: bool,
) -> Result<()> {
let paths = export_wld_maps_pngs(input_path, output_path, compress)?;
if paths.map1_path != output_path {
std::fs::copy(&paths.map1_path, output_path).map_err(|e| Error::File {
path: output_path.to_path_buf(),
message: format!("failed to copy from '{}': {e}", paths.map1_path.display()),
})?;
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct WldMapExportPaths {
pub map1_path: PathBuf,
pub map2_path: PathBuf,
pub map3_path: PathBuf,
pub map4_path: PathBuf,
}
fn write_luma_png(output_path: &Path, luma: &[u8], compress: bool) -> Result<()> {
let expected = (WLD_MAP_SIDE * 2) * (WLD_MAP_SIDE * 2);
if luma.len() != expected {
return Err(Error::Conversion(format!(
"invalid luma buffer size: got {}, expected {}",
luma.len(),
expected
)));
}
let image_side = u32::try_from(WLD_MAP_SIDE * 2)
.map_err(|_| Error::Conversion("WLD image side does not fit u32".to_string()))?;
let mut image = GrayImage::new(image_side, image_side);
for (idx, px) in luma.iter().enumerate() {
let x = u32::try_from(idx % (WLD_MAP_SIDE * 2))
.map_err(|_| Error::Conversion("WLD x coordinate does not fit u32".to_string()))?;
let y = u32::try_from(idx / (WLD_MAP_SIDE * 2))
.map_err(|_| Error::Conversion("WLD y coordinate does not fit u32".to_string()))?;
image.put_pixel(x, y, Luma([*px]));
}
ensure_parent_dir(output_path)?;
save_png(&DynamicImage::ImageLuma8(image), output_path, compress).map_err(|e| {
Error::Conversion(format!(
"failed to write PNG '{}': {e}",
output_path.display()
))
})
}
fn map_output_paths(output_path: &Path) -> WldMapExportPaths {
let mut base = output_path.to_path_buf();
base.set_extension("");
let stem = base
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("wld_map");
let parent = output_path.parent().unwrap_or_else(|| Path::new(""));
WldMapExportPaths {
map1_path: parent.join(format!("{stem}_map1.png")),
map2_path: parent.join(format!("{stem}_map2.png")),
map3_path: parent.join(format!("{stem}_map3.png")),
map4_path: parent.join(format!("{stem}_map4.png")),
}
}
pub fn export_wld_maps_pngs(
input_path: &Path,
output_path: &Path,
compress: bool,
) -> Result<WldMapExportPaths> {
let bytes = std::fs::read(input_path)?;
let wld = parse_wld_file(&bytes)?;
let paths = map_output_paths(output_path);
write_luma_png(&paths.map1_path, &wld.combined_heightmap_luma()?, compress)?;
write_luma_png(&paths.map2_path, &wld.combined_map(1)?, compress)?;
write_luma_png(&paths.map3_path, &wld.combined_map(2)?, compress)?;
write_luma_png(&paths.map4_path, &wld.combined_map(3)?, compress)?;
Ok(paths)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn combine_map_stitches_sections_as_2x2_grid() {
let section = |fill: u8| WldSection {
header: [0_u8; WLD_SECTION_HEADER_BYTES],
maps: [
[fill; WLD_MAP_BYTES],
[0; WLD_MAP_BYTES],
[0; WLD_MAP_BYTES],
[0; WLD_MAP_BYTES],
],
};
let file = WldFile {
header: WldHeader {
fields: [0; WLD_HEADER_DWORDS],
},
sections: [section(10), section(20), section(30), section(40)],
footer: [0; WLD_FOOTER_BYTES],
};
let stitched = file.combined_map(0).expect("combined map");
let side = WLD_MAP_SIDE * 2;
assert_eq!(stitched.len(), side * side);
assert_eq!(stitched[0], 10);
assert_eq!(stitched[WLD_MAP_SIDE], 20);
assert_eq!(stitched[WLD_MAP_SIDE * side], 30);
assert_eq!(stitched[(WLD_MAP_SIDE * side) + WLD_MAP_SIDE], 40);
}
#[test]
fn parse_rejects_too_small_file() {
let bytes = vec![0_u8; 32];
let err = parse_wld_file(&bytes).expect_err("expected parse error");
assert!(matches!(err, Error::Parse(_)));
}
#[test]
fn combined_heightmap_strips_high_bit_without_scaling() {
let mut section = WldSection {
header: [0_u8; WLD_SECTION_HEADER_BYTES],
maps: [[0; WLD_MAP_BYTES]; 4],
};
section.maps[0][0] = 0xFF;
let file = WldFile {
header: WldHeader {
fields: [0; WLD_HEADER_DWORDS],
},
sections: [section.clone(), section.clone(), section.clone(), section],
footer: [0; WLD_FOOTER_BYTES],
};
let luma = file
.combined_heightmap_luma()
.expect("combined heightmap luma");
assert_eq!(luma[0], 0x7F);
}
#[test]
fn map_output_paths_append_map_suffixes() {
let paths = map_output_paths(Path::new("out/island_height.png"));
assert_eq!(paths.map1_path, PathBuf::from("out/island_height_map1.png"));
assert_eq!(paths.map2_path, PathBuf::from("out/island_height_map2.png"));
assert_eq!(paths.map3_path, PathBuf::from("out/island_height_map3.png"));
assert_eq!(paths.map4_path, PathBuf::from("out/island_height_map4.png"));
}
}