1use crate::import::png::save_png;
2use crate::{Result, error::Error};
3use image::{DynamicImage, GrayImage, Luma};
4use std::path::{Path, PathBuf};
5
6pub const WLD_HEADER_BYTES: usize = 1184;
7pub const WLD_HEADER_DWORDS: usize = WLD_HEADER_BYTES / 4;
8pub const WLD_SECTION_COUNT: usize = 4;
9pub const WLD_SECTION_HEADER_BYTES: usize = 22;
10pub const WLD_MAP_SIDE: usize = 128;
11pub const WLD_MAP_BYTES: usize = WLD_MAP_SIDE * WLD_MAP_SIDE;
12pub const WLD_SECTION_BYTES: usize = WLD_SECTION_HEADER_BYTES + (4 * WLD_MAP_BYTES);
13pub const WLD_FOOTER_BYTES: usize = 16;
14
15#[derive(Debug, Clone)]
16pub struct WldHeader {
18 pub fields: [u32; WLD_HEADER_DWORDS],
19}
20
21impl WldHeader {
22 #[must_use]
24 pub const fn section_offsets(&self) -> [u32; WLD_SECTION_COUNT] {
25 [
26 self.fields[36],
27 self.fields[37],
28 self.fields[38],
29 self.fields[39],
30 ]
31 }
32}
33
34#[derive(Debug, Clone)]
35pub struct WldSection {
37 pub header: [u8; WLD_SECTION_HEADER_BYTES],
38 pub maps: [[u8; WLD_MAP_BYTES]; 4],
39}
40
41#[derive(Debug, Clone)]
42pub struct WldFile {
44 pub header: WldHeader,
45 pub sections: Box<[WldSection; WLD_SECTION_COUNT]>,
47 pub footer: [u8; WLD_FOOTER_BYTES],
48}
49
50fn read_u32_le(data: &[u8], offset: usize) -> Result<u32> {
51 if offset + 4 > data.len() {
52 return Err(Error::Parse(format!(
53 "WLD read out of bounds at offset {offset}"
54 )));
55 }
56 Ok(u32::from_le_bytes(
57 data[offset..offset + 4]
58 .try_into()
59 .map_err(|_| Error::Parse(format!("WLD failed to parse LE u32 at offset {offset}")))?,
60 ))
61}
62
63fn read_fixed<const N: usize>(data: &[u8], offset: usize) -> Result<[u8; N]> {
64 if offset + N > data.len() {
65 return Err(Error::Parse(format!(
66 "WLD read out of bounds at offset {offset} for {N} bytes"
67 )));
68 }
69
70 data[offset..offset + N].try_into().map_err(|_| {
71 Error::Parse(format!(
72 "WLD failed to read fixed buffer at offset {offset}"
73 ))
74 })
75}
76
77pub fn parse_wld_file(data: &[u8]) -> Result<WldFile> {
79 let minimum_size = WLD_HEADER_BYTES + WLD_FOOTER_BYTES;
80 if data.len() < minimum_size {
81 return Err(Error::Parse(format!(
82 "WLD file too small: {} bytes (minimum {minimum_size})",
83 data.len()
84 )));
85 }
86
87 let mut fields = [0_u32; WLD_HEADER_DWORDS];
88 for (idx, field) in fields.iter_mut().enumerate() {
89 *field = read_u32_le(data, idx * 4)?;
90 }
91 let header = WldHeader { fields };
92
93 let offsets = header.section_offsets();
94 if usize::try_from(offsets[0]).unwrap_or(usize::MAX) != WLD_HEADER_BYTES {
95 return Err(Error::Parse(format!(
96 "WLD section0 offset mismatch: expected {WLD_HEADER_BYTES}, found {}",
97 offsets[0]
98 )));
99 }
100 if usize::try_from(header.fields[6]).unwrap_or(usize::MAX) != WLD_SECTION_HEADER_BYTES {
101 return Err(Error::Parse(format!(
102 "WLD section-header-size mismatch: expected {WLD_SECTION_HEADER_BYTES}, found {}",
103 header.fields[6]
104 )));
105 }
106 for (idx, off) in offsets.iter().enumerate() {
107 let next_off = if idx + 1 < WLD_SECTION_COUNT {
108 usize::try_from(offsets[idx + 1]).unwrap_or(usize::MAX)
109 } else {
110 data.len().saturating_sub(WLD_FOOTER_BYTES)
111 };
112
113 let current = usize::try_from(*off).unwrap_or(usize::MAX);
114 if current >= data.len() {
115 return Err(Error::Parse(format!(
116 "WLD section {idx} offset out of range: {current}"
117 )));
118 }
119 if current >= next_off {
120 return Err(Error::Parse(format!(
121 "WLD section offsets are not strictly increasing at index {idx}: {current} >= {next_off}"
122 )));
123 }
124 let span = next_off - current;
125 if span < WLD_SECTION_BYTES {
126 return Err(Error::Parse(format!(
127 "WLD section {idx} too small: {span} bytes (minimum {WLD_SECTION_BYTES})"
128 )));
129 }
130 }
131
132 let mut parsed_sections = Vec::with_capacity(WLD_SECTION_COUNT);
133 for off in offsets {
134 let start = usize::try_from(off)
135 .map_err(|_| Error::Parse("WLD section offset does not fit usize".to_string()))?;
136 let header_bytes = read_fixed::<WLD_SECTION_HEADER_BYTES>(data, start)?;
137 let mut cursor = start + WLD_SECTION_HEADER_BYTES;
138 let mut maps = [[0_u8; WLD_MAP_BYTES]; 4];
139 for map in &mut maps {
140 *map = read_fixed::<WLD_MAP_BYTES>(data, cursor)?;
141 cursor += WLD_MAP_BYTES;
142 }
143 parsed_sections.push(WldSection {
144 header: header_bytes,
145 maps,
146 });
147 }
148
149 let sections: Box<[WldSection; WLD_SECTION_COUNT]> =
150 parsed_sections.into_boxed_slice().try_into().map_err(|_| {
151 Error::Parse("WLD internal section conversion failed (expected 4 sections)".to_string())
152 })?;
153
154 let footer = read_fixed::<WLD_FOOTER_BYTES>(data, data.len() - WLD_FOOTER_BYTES)?;
155
156 Ok(WldFile {
157 header,
158 sections,
159 footer,
160 })
161}
162
163impl WldFile {
164 pub fn combined_map(&self, map_index: usize) -> Result<Vec<u8>> {
166 if map_index >= 4 {
167 return Err(Error::Parse(format!(
168 "WLD map index out of range: {map_index} (expected 0..3)"
169 )));
170 }
171
172 let out_side = WLD_MAP_SIDE * 2;
173 let mut out = vec![0_u8; out_side * out_side];
174
175 for y in 0..WLD_MAP_SIDE {
176 let src_row = y * WLD_MAP_SIDE;
177 let dst_top = y * out_side;
178 let dst_bottom = (y + WLD_MAP_SIDE) * out_side;
179
180 out[dst_top..dst_top + WLD_MAP_SIDE].copy_from_slice(
181 &self.sections[0].maps[map_index][src_row..src_row + WLD_MAP_SIDE],
182 );
183 out[dst_top + WLD_MAP_SIDE..dst_top + out_side].copy_from_slice(
184 &self.sections[1].maps[map_index][src_row..src_row + WLD_MAP_SIDE],
185 );
186 out[dst_bottom..dst_bottom + WLD_MAP_SIDE].copy_from_slice(
187 &self.sections[2].maps[map_index][src_row..src_row + WLD_MAP_SIDE],
188 );
189 out[dst_bottom + WLD_MAP_SIDE..dst_bottom + out_side].copy_from_slice(
190 &self.sections[3].maps[map_index][src_row..src_row + WLD_MAP_SIDE],
191 );
192 }
193
194 Ok(out)
195 }
196
197 pub fn combined_heightmap_luma(&self) -> Result<Vec<u8>> {
199 let map1 = self.combined_map(0)?;
200 Ok(map1.into_iter().map(|value| value & 0x7F).collect())
201 }
202}
203
204fn ensure_parent_dir(path: &Path) -> Result<()> {
205 if let Some(parent) = path.parent()
206 && !parent.as_os_str().is_empty()
207 {
208 std::fs::create_dir_all(parent).map_err(|e| Error::File {
209 path: parent.to_path_buf(),
210 message: format!("failed to create directory: {e}"),
211 })?;
212 }
213 Ok(())
214}
215
216pub fn export_wld_heightmap_png(
218 input_path: &Path,
219 output_path: &Path,
220 compress: bool,
221) -> Result<()> {
222 let paths = export_wld_maps_pngs(input_path, output_path, compress)?;
223 if paths.map1_path != output_path {
224 std::fs::copy(&paths.map1_path, output_path).map_err(|e| Error::File {
225 path: output_path.to_path_buf(),
226 message: format!("failed to copy from '{}': {e}", paths.map1_path.display()),
227 })?;
228 }
229 Ok(())
230}
231
232#[derive(Debug, Clone)]
233pub struct WldMapExportPaths {
235 pub map1_path: PathBuf,
236 pub map2_path: PathBuf,
237 pub map3_path: PathBuf,
238 pub map4_path: PathBuf,
239}
240
241fn write_luma_png(output_path: &Path, luma: &[u8], compress: bool) -> Result<()> {
242 let expected = (WLD_MAP_SIDE * 2) * (WLD_MAP_SIDE * 2);
243 if luma.len() != expected {
244 return Err(Error::Conversion(format!(
245 "invalid luma buffer size: got {}, expected {}",
246 luma.len(),
247 expected
248 )));
249 }
250
251 let image_side = u32::try_from(WLD_MAP_SIDE * 2)
252 .map_err(|_| Error::Conversion("WLD image side does not fit u32".to_string()))?;
253 let mut image = GrayImage::new(image_side, image_side);
254 for (idx, px) in luma.iter().enumerate() {
255 let x = u32::try_from(idx % (WLD_MAP_SIDE * 2))
256 .map_err(|_| Error::Conversion("WLD x coordinate does not fit u32".to_string()))?;
257 let y = u32::try_from(idx / (WLD_MAP_SIDE * 2))
258 .map_err(|_| Error::Conversion("WLD y coordinate does not fit u32".to_string()))?;
259 image.put_pixel(x, y, Luma([*px]));
260 }
261
262 ensure_parent_dir(output_path)?;
263 save_png(&DynamicImage::ImageLuma8(image), output_path, compress).map_err(|e| {
264 Error::Conversion(format!(
265 "failed to write PNG '{}': {e}",
266 output_path.display()
267 ))
268 })
269}
270
271fn map_output_paths(output_path: &Path) -> WldMapExportPaths {
272 let mut base = output_path.to_path_buf();
273 base.set_extension("");
274
275 let stem = base
276 .file_name()
277 .and_then(|s| s.to_str())
278 .unwrap_or("wld_map");
279 let parent = output_path.parent().unwrap_or_else(|| Path::new(""));
280
281 WldMapExportPaths {
282 map1_path: parent.join(format!("{stem}_map1.png")),
283 map2_path: parent.join(format!("{stem}_map2.png")),
284 map3_path: parent.join(format!("{stem}_map3.png")),
285 map4_path: parent.join(format!("{stem}_map4.png")),
286 }
287}
288
289pub fn export_wld_maps_pngs(
291 input_path: &Path,
292 output_path: &Path,
293 compress: bool,
294) -> Result<WldMapExportPaths> {
295 let bytes = std::fs::read(input_path)?;
296 let wld = parse_wld_file(&bytes)?;
297 let paths = map_output_paths(output_path);
298
299 write_luma_png(&paths.map1_path, &wld.combined_heightmap_luma()?, compress)?;
300 write_luma_png(&paths.map2_path, &wld.combined_map(1)?, compress)?;
301 write_luma_png(&paths.map3_path, &wld.combined_map(2)?, compress)?;
302 write_luma_png(&paths.map4_path, &wld.combined_map(3)?, compress)?;
303
304 Ok(paths)
305}
306
307#[cfg(test)]
308mod tests {
309 use super::*;
310
311 #[test]
312 fn combine_map_stitches_sections_as_2x2_grid() {
313 let section = |fill: u8| WldSection {
314 header: [0_u8; WLD_SECTION_HEADER_BYTES],
315 maps: [
316 [fill; WLD_MAP_BYTES],
317 [0; WLD_MAP_BYTES],
318 [0; WLD_MAP_BYTES],
319 [0; WLD_MAP_BYTES],
320 ],
321 };
322
323 let file = WldFile {
324 header: WldHeader {
325 fields: [0; WLD_HEADER_DWORDS],
326 },
327 sections: Box::new([section(10), section(20), section(30), section(40)]),
328 footer: [0; WLD_FOOTER_BYTES],
329 };
330
331 assert_eq!(
332 crate::gltf::wld_grid_position(&file, 128, 0).unwrap(),
333 [-1638.4, 14.0, -0.0]
334 );
335 assert!(crate::gltf::wld_grid_position(&file, 256, 0).is_err());
336 let metadata = export_wld_metadata(&file).expect("metadata");
337 assert_eq!(metadata["sections"][1]["maps"][0][0], 20);
338 assert_eq!(metadata["combined"]["height_engine_units"][0], -160);
339 assert_eq!(metadata["combined"]["texture_indices"][0], 0);
340 assert_eq!(metadata["combined"]["quarter_turns"][0], 0);
341 let stitched = file.combined_map(0).expect("combined map");
342 let side = WLD_MAP_SIDE * 2;
343 assert_eq!(stitched.len(), side * side);
344
345 assert_eq!(stitched[0], 10);
346 assert_eq!(stitched[WLD_MAP_SIDE], 20);
347 assert_eq!(stitched[WLD_MAP_SIDE * side], 30);
348 assert_eq!(stitched[(WLD_MAP_SIDE * side) + WLD_MAP_SIDE], 40);
349 }
350
351 #[test]
352 fn parse_keeps_terrain_maps_on_heap() {
353 assert!(std::mem::size_of::<WldFile>() < 2048);
354 let mut bytes =
355 vec![0; WLD_HEADER_BYTES + WLD_SECTION_COUNT * WLD_SECTION_BYTES + WLD_FOOTER_BYTES];
356 bytes[24..28].copy_from_slice(&(WLD_SECTION_HEADER_BYTES as u32).to_le_bytes());
357 for index in 0..WLD_SECTION_COUNT {
358 let start = WLD_HEADER_BYTES + index * WLD_SECTION_BYTES;
359 let offset = (36 + index) * 4;
360 bytes[offset..offset + 4].copy_from_slice(&(start as u32).to_le_bytes());
361 bytes[start + WLD_SECTION_HEADER_BYTES] = (index + 1) as u8;
362 }
363 let file = parse_wld_file(&bytes).expect("valid terrain");
364 assert_eq!(
365 file.sections.each_ref().map(|section| section.maps[0][0]),
366 [1, 2, 3, 4]
367 );
368 }
369
370 #[test]
371 fn parse_rejects_too_small_file() {
372 let bytes = vec![0_u8; 32];
373 let err = parse_wld_file(&bytes).expect_err("expected parse error");
374 assert!(matches!(err, Error::Parse(_)));
375 }
376
377 #[test]
378 fn combined_heightmap_strips_high_bit_without_scaling() {
379 let mut section = WldSection {
380 header: [0_u8; WLD_SECTION_HEADER_BYTES],
381 maps: [[0; WLD_MAP_BYTES]; 4],
382 };
383 section.maps[0][0] = 0xFF;
384
385 let file = WldFile {
386 header: WldHeader {
387 fields: [0; WLD_HEADER_DWORDS],
388 },
389 sections: Box::new([section.clone(), section.clone(), section.clone(), section]),
390 footer: [0; WLD_FOOTER_BYTES],
391 };
392
393 let luma = file
394 .combined_heightmap_luma()
395 .expect("combined heightmap luma");
396 assert_eq!(luma[0], 0x7F);
397 }
398
399 #[test]
400 fn map_output_paths_append_map_suffixes() {
401 let paths = map_output_paths(Path::new("out/island_height.png"));
402 assert_eq!(paths.map1_path, PathBuf::from("out/island_height_map1.png"));
403 assert_eq!(paths.map2_path, PathBuf::from("out/island_height_map2.png"));
404 assert_eq!(paths.map3_path, PathBuf::from("out/island_height_map3.png"));
405 assert_eq!(paths.map4_path, PathBuf::from("out/island_height_map4.png"));
406 }
407}
408
409const WLD_HEIGHT_TABLE: [u16; 128] = [
410 0, 40, 40, 40, 80, 80, 80, 120, 120, 120, 160, 160, 160, 200, 200, 200, 240, 240, 240, 280,
411 280, 320, 320, 320, 360, 360, 400, 400, 400, 440, 440, 480, 480, 480, 520, 520, 560, 560, 600,
412 600, 600, 640, 640, 680, 680, 720, 720, 760, 760, 800, 800, 840, 840, 880, 880, 920, 920, 960,
413 1000, 1000, 1040, 1040, 1080, 1120, 1120, 1160, 1160, 1200, 1240, 1240, 1280, 1320, 1320, 1360,
414 1400, 1440, 1440, 1480, 1520, 1560, 1600, 1600, 1640, 1680, 1720, 1760, 1800, 1840, 1880, 1920,
415 1960, 2000, 2040, 2080, 2120, 2200, 2240, 2280, 2320, 2400, 2440, 2520, 2560, 2640, 2680, 2760,
416 2840, 2920, 3000, 3080, 3160, 3240, 3360, 3440, 3560, 3680, 3800, 3960, 4080, 4280, 4440, 4680,
417 4920, 5200, 5560, 6040, 6680, 7760,
418];
419
420#[must_use]
423pub fn decode_height(value: u8) -> i16 {
424 -(WLD_HEIGHT_TABLE[usize::from(value & 0x7f)] as i16)
425}
426
427#[cfg(test)]
428mod height_tests {
429 #[test]
430 fn decoded_heights_retain_engine_sign_and_strip_build_flag() {
431 assert_eq!(super::decode_height(0), 0);
432 assert_eq!(super::decode_height(43), -680);
433 assert_eq!(super::decode_height(127), -7760);
434 for value in 0..128 {
435 assert_eq!(
436 super::decode_height(value),
437 super::decode_height(value | 128)
438 );
439 }
440 }
441}
442
443pub fn export_wld_metadata(wld: &WldFile) -> Result<serde_json::Value> {
448 let heights = wld.combined_map(0)?;
449 let materials = wld.combined_map(2)?;
450 Ok(serde_json::json!({
451 "header_fields": wld.header.fields.as_slice(),
452 "section_offsets": wld.header.section_offsets(),
453 "sections": wld.sections.iter().map(|section| serde_json::json!({
454 "header": section.header.as_slice(),
455 "maps": section.maps.iter().map(|map| map.as_slice()).collect::<Vec<_>>(),
456 })).collect::<Vec<_>>(),
457 "footer": wld.footer,
458 "combined": {
459 "width": WLD_MAP_SIDE * 2,
460 "height": WLD_MAP_SIDE * 2,
461 "height_engine_units": heights.iter().copied().map(decode_height).collect::<Vec<_>>(),
462 "raw_height": heights,
463 "height_build_flags": heights.iter().map(|value| value & 128 != 0).collect::<Vec<_>>(),
464 "texture_indices": materials.iter().map(|value| value & 63).collect::<Vec<_>>(),
465 "quarter_turns": materials.iter().map(|value| value >> 6).collect::<Vec<_>>(),
466 },
467 }))
468}