Skip to main content

rgpre/import/
wld.rs

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)]
16/// Parsed WLD header stored as raw 32-bit fields.
17pub struct WldHeader {
18    pub fields: [u32; WLD_HEADER_DWORDS],
19}
20
21impl WldHeader {
22    /// Returns the four section offsets from header fields 36..39.
23    #[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)]
35/// Parsed WLD section containing a header blob and four 128x128 maps.
36pub struct WldSection {
37    pub header: [u8; WLD_SECTION_HEADER_BYTES],
38    pub maps: [[u8; WLD_MAP_BYTES]; 4],
39}
40
41#[derive(Debug, Clone)]
42/// Parsed WLD file with header, four sections, and footer bytes.
43pub struct WldFile {
44    pub header: WldHeader,
45    // Keep terrain maps off the call stack, including parser return temporaries in WASM.
46    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
77/// Parses a WLD file from bytes and validates core section layout.
78pub 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    /// Combines one map layer across the four WLD sections into a 2x2 grid.
165    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    /// Returns combined map 1 with the high bit cleared for heightmap luminance.
198    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
216/// Exports combined WLD heightmap data to a PNG file.
217pub 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)]
233/// Output PNG paths generated when exporting all four WLD maps.
234pub 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
289/// Exports all four combined WLD maps as grayscale PNG files.
290pub 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/// Decodes a WLD height byte to unshifted engine Y in map units, positive down.
421/// The build-time high bit is discarded. See `docs/formats/WLD.md`.
422#[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
443/// Exports every stored WLD header, map plane and footer, plus decoded combined grids.
444/// Combined arrays are row-major, 256 by 256; heights use positive-down engine units.
445/// Texture rotations are counter-clockwise quarter turns. Normals are derived geometry,
446/// not stored WLD data, and are therefore not included in this file-data export.
447pub 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}