Skip to main content

n3_lib/
textures.rs

1//! NTF/DXT, GTT and TCT storage and native RGBA texture encoding.
2
3use crate::binary::{Error, Reader, Result, Writer};
4use crate::records::{hex, unhex};
5use serde::{Deserialize, Serialize};
6
7/// DXT1/BC1 fourCC in little-endian order.
8pub const DXT1: u32 = 0x31545844;
9/// DXT3/BC2 fourCC in little-endian order.
10pub const DXT3: u32 = 0x33545844;
11/// DXT5/BC3 fourCC in little-endian order.
12pub const DXT5: u32 = 0x35545844;
13
14/// A primary, fallback or legacy compatibility mip level.
15#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
16pub struct TextureLevel {
17    /// Pixel width.
18    pub width: u32,
19    /// Pixel height.
20    pub height: u32,
21    /// D3DFMT/fourCC code; zero denotes opaque legacy 16-bit pixels.
22    pub format: u32,
23    /// Original encoded pixels as lowercase hex.
24    pub data: String,
25}
26/// Editable NTF storage, preserving the original encoded pixel payload.
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[serde(rename_all = "camelCase")]
29pub struct Texture {
30    /// Original length-prefixed name bytes as hex.
31    pub name: String,
32    /// NTF version byte.
33    pub version: u8,
34    /// Primary pixel width.
35    pub width: u32,
36    /// Primary pixel height.
37    pub height: u32,
38    /// Primary D3DFMT/fourCC code.
39    pub format: u32,
40    /// Mip flag, either zero or one.
41    pub mips: u32,
42    /// Primary levels, in file order.
43    pub levels: Vec<TextureLevel>,
44    /// Hardware fallback and legacy compatibility levels.
45    pub fallback_levels: Vec<TextureLevel>,
46    /// Original unexplained DXT suffix as hex.
47    pub trailing: String,
48}
49/// Square terrain colour-map collection in X-major/Z-minor order.
50#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
51#[serde(rename_all = "camelCase")]
52pub struct TerrainColors {
53    /// Number of textures along each axis.
54    pub grid_size: usize,
55    /// Texture records in engine load order.
56    pub textures: Vec<Texture>,
57}
58fn error(message: &str) -> Error {
59    Error {
60        offset: 0,
61        message: message.into(),
62    }
63}
64fn compressed(format: u32) -> bool {
65    matches!(format, DXT1 | DXT3 | DXT5)
66}
67fn bc(format: u32) -> Result<texpresso::Format> {
68    match format {
69        DXT1 => Ok(texpresso::Format::Bc1),
70        DXT3 => Ok(texpresso::Format::Bc2),
71        DXT5 => Ok(texpresso::Format::Bc3),
72        _ => Err(error("Unsupported block format")),
73    }
74}
75/// Compute a level's encoded size with format and dimension validation.
76pub fn mip_size(format: u32, width: u32, height: u32) -> Result<usize> {
77    if width == 0 || height == 0 || width > 16384 || height > 16384 {
78        return Err(error("Invalid texture dimensions"));
79    }
80    let size = if compressed(format) {
81        width.div_ceil(4) as usize
82            * height.div_ceil(4) as usize
83            * if format == DXT1 { 8 } else { 16 }
84    } else {
85        width as usize
86            * height as usize
87            * match format {
88                0 | 23..=26 => 2,
89                20 => 3,
90                21 | 22 => 4,
91                _ => return Err(error("Unsupported texture format")),
92            }
93    };
94    if size > 256 * 1024 * 1024 {
95        return Err(error("Texture byte budget exceeded"));
96    }
97    Ok(size)
98}
99fn plan(
100    format: u32,
101    width: u32,
102    height: u32,
103    mips: u32,
104) -> Result<(Vec<TextureLevel>, Vec<TextureLevel>)> {
105    mip_size(format, width, height)?;
106    if mips > 1 {
107        return Err(error("Invalid mip flag"));
108    }
109    let level = |wide, high, kind| TextureLevel {
110        width: wide,
111        height: high,
112        format: kind,
113        data: String::new(),
114    };
115    let mut primary = vec![level(width, height, format)];
116    let mut fallback = Vec::new();
117    if mips == 1 {
118        let (mut wide, mut high) = (width / 2, height / 2);
119        while wide >= 4 && high >= 4 {
120            primary.push(level(wide, high, format));
121            wide /= 2;
122            high /= 2;
123        }
124    }
125    if compressed(format) {
126        let kind = if format == DXT1 { 25 } else { 26 };
127        if mips == 1 {
128            let (mut wide, mut high) = (width / 2, height / 2);
129            while wide >= 4 && high >= 4 {
130                fallback.push(level(wide, high, kind));
131                wide /= 2;
132                high /= 2;
133            }
134        } else {
135            fallback.push(level((width / 2).max(1), (height / 2).max(1), kind));
136            if width >= 1024 {
137                fallback.push(level(256, 256, kind));
138            }
139        }
140    } else if mips == 0 && width >= 512 {
141        fallback.push(level(256, 256, 0));
142    }
143    Ok((primary, fallback))
144}
145/// Read a self-delimiting texture embedded inside another record.
146pub fn read_texture(reader: &mut Reader<'_>) -> Result<Texture> {
147    let name = hex(&reader.string(512)?);
148    if reader.raw(3)? != b"NTF" {
149        return reader.fail("Missing NTF signature");
150    }
151    let version = reader.u8()?;
152    let width = reader.u32()?;
153    let height = reader.u32()?;
154    let format = reader.u32()?;
155    let mips = reader.u32()?;
156    let (mut levels, mut fallback_levels) = plan(format, width, height, mips)?;
157    for level in levels.iter_mut().chain(fallback_levels.iter_mut()) {
158        level.data = hex(reader.raw(mip_size(level.format, level.width, level.height)?)?);
159    }
160    Ok(Texture {
161        name,
162        version,
163        width,
164        height,
165        format,
166        mips,
167        levels,
168        fallback_levels,
169        trailing: String::new(),
170    })
171}
172/// Parse a named DXT file and preserve its unexplained suffix.
173pub fn parse_texture(bytes: &[u8]) -> Result<Texture> {
174    if bytes.len() > 256 * 1024 * 1024 {
175        return Err(error("Texture input budget exceeded"));
176    }
177    let mut reader = Reader::new(bytes);
178    let mut texture = read_texture(&mut reader)?;
179    texture.trailing = hex(reader.raw(reader.remaining())?);
180    Ok(texture)
181}
182/// Append a validated texture to a surrounding binary record.
183pub fn append_texture(writer: &mut Writer, texture: &Texture) -> Result<()> {
184    let (primary, fallback) = plan(texture.format, texture.width, texture.height, texture.mips)?;
185    let name = unhex(&texture.name)?;
186    if name.len() > 512 {
187        return Err(error("Texture name too long"));
188    }
189    writer.string(&name)?;
190    writer.raw(b"NTF")?;
191    writer.u8(texture.version)?;
192    writer.u32(texture.width)?;
193    writer.u32(texture.height)?;
194    writer.u32(texture.format)?;
195    writer.u32(texture.mips)?;
196    for (expected, supplied) in [
197        (&primary, &texture.levels),
198        (&fallback, &texture.fallback_levels),
199    ] {
200        if expected.len() != supplied.len() {
201            return Err(error("Texture mip count mismatch"));
202        }
203        for (level, stored) in expected.iter().zip(supplied) {
204            let bytes = unhex(&stored.data)?;
205            if level.width != stored.width
206                || level.height != stored.height
207                || level.format != stored.format
208                || bytes.len() != mip_size(level.format, level.width, level.height)?
209            {
210                return Err(error("Texture level layout mismatch"));
211            }
212            writer.raw(&bytes)?;
213        }
214    }
215    writer.raw(&unhex(&texture.trailing)?)
216}
217/// Serialize a new or edited DXT without recompressing original payloads.
218pub fn write_texture(texture: &Texture) -> Result<Vec<u8>> {
219    let mut writer = Writer::default();
220    append_texture(&mut writer, texture)?;
221    Ok(writer.finish())
222}
223/// Parse sequential GTT records from their sizes rather than pixel signature scans.
224pub fn parse_gtt(bytes: &[u8]) -> Result<Vec<Texture>> {
225    let mut reader = Reader::new(bytes);
226    let mut textures = Vec::new();
227    while reader.remaining() > 0 {
228        if textures.len() >= 65536 {
229            return Err(error("Texture collection limit exceeded"));
230        }
231        textures.push(read_texture(&mut reader)?);
232    }
233    if textures.is_empty() {
234        return Err(error("Empty texture collection"));
235    }
236    Ok(textures)
237}
238/// Write a collection of unambiguously framed NTF textures.
239pub fn write_gtt(textures: &[Texture]) -> Result<Vec<u8>> {
240    if textures.is_empty() || textures.len() > 65536 {
241        return Err(error("Invalid texture collection length"));
242    }
243    let mut writer = Writer::default();
244    for texture in textures {
245        if !texture.trailing.is_empty() {
246            return Err(error("Embedded texture cannot carry a suffix"));
247        }
248        append_texture(&mut writer, texture)?;
249    }
250    Ok(writer.finish())
251}
252/// Parse a square TCT terrain colour-map collection.
253pub fn parse_tct(bytes: &[u8]) -> Result<TerrainColors> {
254    let textures = parse_gtt(bytes)?;
255    let grid_size = (textures.len() as f64).sqrt() as usize;
256    if grid_size * grid_size != textures.len() {
257        return Err(error("TCT texture count must be square"));
258    }
259    Ok(TerrainColors {
260        grid_size,
261        textures,
262    })
263}
264/// Write a square TCT terrain colour-map collection.
265pub fn write_tct(colors: &TerrainColors) -> Result<Vec<u8>> {
266    if colors.grid_size == 0
267        || colors.grid_size > 256
268        || colors.grid_size * colors.grid_size != colors.textures.len()
269    {
270        return Err(error("TCT grid mismatch"));
271    }
272    write_gtt(&colors.textures)
273}
274fn expand(value: u16, bits: u32) -> u8 {
275    ((u32::from(value) * 255 + ((1 << bits) - 1) / 2) / ((1 << bits) - 1)) as u8
276}
277/// Decode a primary/fallback mip into top-row-first RGBA, rejecting opaque legacy pixels.
278pub fn decode_level(level: &TextureLevel) -> Result<Vec<u8>> {
279    let size = mip_size(level.format, level.width, level.height)?;
280    let bytes = unhex(&level.data)?;
281    if bytes.len() != size
282        || level.width as usize * level.height as usize > 16_777_216
283        || level.format == 0
284    {
285        return Err(error(
286            "Invalid payload, legacy pixels or decoded pixel budget",
287        ));
288    }
289    let mut rgba = vec![0; level.width as usize * level.height as usize * 4];
290    if compressed(level.format) {
291        bc(level.format)?.decompress(
292            &bytes,
293            level.width as usize,
294            level.height as usize,
295            &mut rgba,
296        );
297        return Ok(rgba);
298    }
299    let mut reader = Reader::new(&bytes);
300    for pixel in rgba.as_chunks_mut::<4>().0 {
301        if level.format <= 22 {
302            let blue = reader.u8()?;
303            let green = reader.u8()?;
304            let red = reader.u8()?;
305            let alpha = if level.format == 21 {
306                reader.u8()?
307            } else {
308                if level.format == 22 {
309                    reader.u8()?;
310                }
311                255
312            };
313            pixel.copy_from_slice(&[red, green, blue, alpha]);
314        } else {
315            let word = reader.u16()?;
316            let color = match level.format {
317                23 => [
318                    expand(word >> 11, 5),
319                    expand((word >> 5) & 63, 6),
320                    expand(word & 31, 5),
321                    255,
322                ],
323                26 => [
324                    expand((word >> 8) & 15, 4),
325                    expand((word >> 4) & 15, 4),
326                    expand(word & 15, 4),
327                    expand(word >> 12, 4),
328                ],
329                _ => [
330                    expand((word >> 10) & 31, 5),
331                    expand((word >> 5) & 31, 5),
332                    expand(word & 31, 5),
333                    if level.format == 25 && word & 0x8000 == 0 {
334                        0
335                    } else {
336                        255
337                    },
338                ],
339            };
340            pixel.copy_from_slice(&color);
341        }
342    }
343    Ok(rgba)
344}
345/// Encode RGBA with a vetted native BC compressor or explicit linear packing.
346pub fn encode_level(format: u32, width: u32, height: u32, rgba: &[u8]) -> Result<TextureLevel> {
347    let size = mip_size(format, width, height)?;
348    if width as usize * height as usize > 16_777_216
349        || rgba.len() != width as usize * height as usize * 4
350        || format == 0
351    {
352        return Err(error("RGBA dimensions or pixel budget mismatch"));
353    }
354    let bytes = if compressed(format) {
355        let mut bytes = vec![0; size];
356        bc(format)?.compress(
357            rgba,
358            width as usize,
359            height as usize,
360            texpresso::Params::default(),
361            &mut bytes,
362        );
363        bytes
364    } else {
365        let mut writer = Writer::default();
366        let quantize = |value: u8, bits: u32| -> u16 {
367            ((u32::from(value) * ((1 << bits) - 1) + 127) / 255) as u16
368        };
369        for pixel in rgba.as_chunks::<4>().0 {
370            let (red, green, blue, alpha) = (pixel[0], pixel[1], pixel[2], pixel[3]);
371            if format <= 22 {
372                writer.raw(&[blue, green, red])?;
373                if format != 20 {
374                    writer.u8(if format == 21 { alpha } else { 255 })?;
375                }
376            } else {
377                let word = match format {
378                    23 => (quantize(red, 5) << 11) | (quantize(green, 6) << 5) | quantize(blue, 5),
379                    26 => {
380                        (quantize(alpha, 4) << 12)
381                            | (quantize(red, 4) << 8)
382                            | (quantize(green, 4) << 4)
383                            | quantize(blue, 4)
384                    }
385                    _ => {
386                        (u16::from(format == 24 || alpha >= 128) << 15)
387                            | (quantize(red, 5) << 10)
388                            | (quantize(green, 5) << 5)
389                            | quantize(blue, 5)
390                    }
391                };
392                writer.u16(word)?;
393            }
394        }
395        writer.finish()
396    };
397    Ok(TextureLevel {
398        width,
399        height,
400        format,
401        data: hex(&bytes),
402    })
403}
404/// Create a new NTF, including the engine's primary and fallback level chains.
405pub fn create_texture(
406    width: u32,
407    height: u32,
408    rgba: &[u8],
409    format: u32,
410    mips: bool,
411) -> Result<Texture> {
412    let (mut levels, mut fallback_levels) = plan(format, width, height, u32::from(mips))?;
413    if width as usize * height as usize > 16_777_216
414        || rgba.len() != width as usize * height as usize * 4
415    {
416        return Err(error("RGBA byte count mismatch"));
417    }
418    for level in levels.iter_mut().chain(fallback_levels.iter_mut()) {
419        if level.format == 0 {
420            return Err(error(
421                "Opaque legacy encoding requires explicit known format; use mips",
422            ));
423        }
424        let mut pixels = vec![0; level.width as usize * level.height as usize * 4];
425        for y in 0..level.height {
426            for x in 0..level.width {
427                let source = (((y as u64 * height as u64 / level.height as u64) as usize
428                    * width as usize)
429                    + (x as u64 * width as u64 / level.width as u64) as usize)
430                    * 4;
431                let target = (y as usize * level.width as usize + x as usize) * 4;
432                pixels[target..target + 4].copy_from_slice(&rgba[source..source + 4]);
433            }
434        }
435        *level = encode_level(level.format, level.width, level.height, &pixels)?;
436    }
437    Ok(Texture {
438        name: String::new(),
439        version: 3,
440        width,
441        height,
442        format,
443        mips: u32::from(mips),
444        levels,
445        fallback_levels,
446        trailing: String::new(),
447    })
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    #[test]
454    fn texture_containers_and_bc_alpha() -> Result<()> {
455        for format in [DXT1, DXT3, DXT5] {
456            let pixels = [255_u8; 8 * 8 * 4];
457            let texture = create_texture(8, 8, &pixels, format, true)?;
458            let bytes = write_texture(&texture)?;
459            assert_eq!(write_texture(&parse_texture(&bytes)?)?, bytes);
460            let decoded = decode_level(&texture.levels[0])?;
461            assert!(decoded.iter().all(|&channel| channel >= 248));
462        }
463        Ok(())
464    }
465}