Skip to main content

rgpre/gltf/
texture_cache.rs

1use crate::import::{
2    bsi::{self, BsiFile, BsiImage},
3    palette::Palette,
4};
5use log::warn;
6use std::collections::{BTreeMap, HashMap, HashSet};
7use std::path::PathBuf;
8use walkdir::WalkDir;
9
10pub struct AllFramesInfo {
11    pub width: u16,
12    pub height: u16,
13    pub frame_count: u16,
14    pub frames: Vec<Option<Vec<u8>>>,
15}
16
17pub(super) struct AnimatedTexture {
18    pub name: String,
19    pub delay_ms: i16,
20    pub frames: Vec<Vec<u8>>,
21    pub has_alpha: bool,
22}
23
24pub struct TextureCache {
25    palette: Option<Palette>,
26    bsi_files: HashMap<u16, BsiFile>,
27    texbsi_paths: HashMap<u16, PathBuf>,
28    software: bool,
29    opaque_images: HashSet<(u16, u16)>,
30    missing_texture_requests: BTreeMap<(u16, u8), String>,
31    remapped_texture_requests: HashSet<(u16, u16)>,
32}
33
34impl TextureCache {
35    /// Builds a texture cache by indexing `TEXBSI.*` files under `asset_dir`.
36    #[must_use]
37    pub fn new(asset_dir: PathBuf, palette: Option<Palette>) -> Self {
38        let software = Self::detect_software(&asset_dir);
39        Self::new_for_edition(asset_dir, palette, software)
40    }
41
42    pub(crate) fn detect_software(asset_dir: &std::path::Path) -> bool {
43        crate::import::registry::detect_software_assets(asset_dir)
44    }
45
46    /// Selects software TEXTURE banks or Glide TEXBSI banks explicitly.
47    #[must_use]
48    pub fn new_for_edition(asset_dir: PathBuf, palette: Option<Palette>, software: bool) -> Self {
49        let mut texbsi_paths = HashMap::new();
50        for entry in WalkDir::new(asset_dir)
51            .into_iter()
52            .filter_map(std::result::Result::ok)
53        {
54            if !entry.file_type().is_file() {
55                continue;
56            }
57
58            let Some(file_name) = entry.path().file_name().and_then(|n| n.to_str()) else {
59                continue;
60            };
61
62            let upper = file_name.to_ascii_uppercase();
63            let prefix = if software { "TEXTURE." } else { "TEXBSI." };
64            if !upper.starts_with(prefix) {
65                continue;
66            }
67
68            let Some(id_part) = upper.strip_prefix(prefix) else {
69                continue;
70            };
71
72            if software && id_part.len() != 3 {
73                continue;
74            }
75            if let Ok(texture_id) = id_part.parse::<u16>() {
76                texbsi_paths
77                    .entry(texture_id)
78                    .or_insert_with(|| entry.path().to_path_buf());
79            }
80        }
81
82        Self {
83            palette,
84            bsi_files: HashMap::new(),
85            texbsi_paths,
86            software,
87            opaque_images: HashSet::new(),
88            missing_texture_requests: BTreeMap::new(),
89            remapped_texture_requests: HashSet::new(),
90        }
91    }
92
93    /// Builds a texture cache from pre-loaded TEXBSI data (no filesystem access).
94    ///
95    /// `texbsi_data` maps TEXBSI IDs (e.g. 302) to raw file bytes.
96    /// Used by the FFI layer where the caller provides all data in memory.
97    #[must_use]
98    pub fn from_data(texbsi_data: HashMap<u16, Vec<u8>>, palette: Option<Palette>) -> Self {
99        let mut bsi_files = HashMap::new();
100        let mut texbsi_paths = HashMap::new();
101
102        for (id, data) in texbsi_data {
103            match bsi::parse_bsi_file(&data) {
104                Ok(parsed) => {
105                    bsi_files.insert(id, parsed);
106                    // Populate texbsi_paths so resolve_texture_id() can remap other IDs
107                    // to this one. The PathBuf is never used for I/O since bsi_files
108                    // is already populated.
109                    texbsi_paths.insert(id, PathBuf::new());
110                }
111                Err(e) => {
112                    warn!("Failed to parse pre-loaded TEXBSI.{id:03}: {e}");
113                }
114            }
115        }
116
117        Self {
118            palette,
119            bsi_files,
120            texbsi_paths,
121            software: false,
122            opaque_images: HashSet::new(),
123            missing_texture_requests: BTreeMap::new(),
124            remapped_texture_requests: HashSet::new(),
125        }
126    }
127
128    /// Builds a software cache from bank bytes without filesystem access.
129    pub fn from_software_data(
130        data: HashMap<u16, Vec<u8>>,
131        palette: Option<Palette>,
132    ) -> crate::Result<Self> {
133        let mut cache = Self {
134            palette,
135            bsi_files: HashMap::new(),
136            texbsi_paths: HashMap::new(),
137            software: true,
138            opaque_images: HashSet::new(),
139            missing_texture_requests: BTreeMap::new(),
140            remapped_texture_requests: HashSet::new(),
141        };
142        for (id, bytes) in data {
143            let bank = crate::import::software_texture::parse_texture_bank(&bytes)
144                .map_err(|e| crate::error::Error::Parse(format!("TEXTURE.{id:03}: {e}")))?;
145            cache.insert_software_bank(id, bank);
146            cache.texbsi_paths.insert(id, PathBuf::new());
147        }
148        Ok(cache)
149    }
150
151    fn insert_software_bank(
152        &mut self,
153        id: u16,
154        bank: crate::import::software_texture::SoftwareTextureBank,
155    ) {
156        use crate::import::{bsi::BsiImage, software_texture::SoftwareTextureEntry};
157        let images = bank
158            .entries
159            .into_iter()
160            .enumerate()
161            .filter_map(|(index, entry)| {
162                let image_index = u16::try_from(index).ok()?;
163                let (width, height, x_offset, y_offset, delay, flags, mut frames) = match entry {
164                    SoftwareTextureEntry::SolidColor(color) => {
165                        (1, 1, 0, 0, 0, 0, vec![vec![color]])
166                    }
167                    SoftwareTextureEntry::Image(image) => (
168                        image.width,
169                        image.height,
170                        image.x_offset,
171                        image.y_offset,
172                        image.frame_delay,
173                        image.flags,
174                        image.frames,
175                    ),
176                };
177                if frames.is_empty() {
178                    return None;
179                }
180                if flags & 0x100 == 0 {
181                    self.opaque_images.insert((id, image_index));
182                }
183                Some(BsiImage {
184                    name: format!("{index:03}"),
185                    image_index,
186                    width,
187                    height,
188                    x_offset,
189                    y_offset,
190                    frame_count: u16::try_from(frames.len()).ok()?,
191                    anim_delay: delay as i16,
192                    tex_scale: 256,
193                    data_encoding: 0,
194                    is_animated: frames.len() > 1,
195                    palette: None,
196                    pixel_data: frames.remove(0),
197                    extra_frames: frames,
198                })
199            })
200            .collect();
201        self.bsi_files.insert(id, BsiFile { images });
202    }
203
204    #[must_use]
205    pub const fn palette(&self) -> Option<&Palette> {
206        self.palette.as_ref()
207    }
208
209    /// Referenced texture/image failures retained by the existing fallback path.
210    #[must_use]
211    pub const fn missing_requests(&self) -> &BTreeMap<(u16, u8), String> {
212        &self.missing_texture_requests
213    }
214
215    fn warn_missing_once(&mut self, texture_id: u16, image_id: u8, reason: &str) {
216        if let std::collections::btree_map::Entry::Vacant(entry) =
217            self.missing_texture_requests.entry((texture_id, image_id))
218        {
219            entry.insert(reason.into());
220            let prefix = if self.software { "TEXTURE" } else { "TEXBSI" };
221            warn!(
222                "Texture lookup failed for {prefix}.{texture_id:03} image {image_id}: {reason}; using white fallback material"
223            );
224        }
225    }
226
227    /// Resolves an authored texture bank against available IDs using the exporter's remapping rules.
228    pub fn resolve_texture_id(texture_id: u16, contains: impl Fn(u16) -> bool) -> u16 {
229        if contains(texture_id) {
230            return texture_id;
231        }
232
233        let folded_9bit = texture_id & 0x01FF;
234        if contains(folded_9bit) {
235            return folded_9bit;
236        }
237
238        if texture_id > 500 {
239            let wrapped = texture_id % 500;
240            if contains(wrapped) {
241                return wrapped;
242            }
243        }
244
245        texture_id
246    }
247
248    fn find_texbsi_path(&self, texture_id: u16) -> Option<(u16, PathBuf)> {
249        let resolved_id = if self.software {
250            texture_id
251        } else {
252            Self::resolve_texture_id(texture_id, |id| self.texbsi_paths.contains_key(&id))
253        };
254        self.texbsi_paths
255            .get(&resolved_id)
256            .cloned()
257            .map(|path| (resolved_id, path))
258    }
259
260    fn warn_remapped_once(&mut self, requested_texture_id: u16, resolved_texture_id: u16) {
261        if requested_texture_id == resolved_texture_id {
262            return;
263        }
264
265        if self
266            .remapped_texture_requests
267            .insert((requested_texture_id, resolved_texture_id))
268        {
269            warn!(
270                "Remapped TEXBSI id {requested_texture_id} -> {resolved_texture_id} for texture lookup"
271            );
272        }
273    }
274
275    fn ensure_bsi_loaded(&mut self, texture_id: u16, image_id: u8) -> bool {
276        if self.bsi_files.contains_key(&texture_id) {
277            return true;
278        }
279
280        // Resolve remapped ID and check if it's already loaded (covers from_data pre-loads)
281        let resolved_id = if self.software {
282            texture_id
283        } else {
284            Self::resolve_texture_id(texture_id, |id| self.texbsi_paths.contains_key(&id))
285        };
286        if resolved_id != texture_id
287            && let Some(bsi) = self.bsi_files.get(&resolved_id).cloned()
288        {
289            self.warn_remapped_once(texture_id, resolved_id);
290            self.bsi_files.insert(texture_id, bsi);
291            return true;
292        }
293
294        let Some((resolved_texture_id, path)) = self.find_texbsi_path(texture_id) else {
295            self.warn_missing_once(texture_id, image_id, "file not found or unreadable");
296            return false;
297        };
298
299        self.warn_remapped_once(texture_id, resolved_texture_id);
300
301        let Ok(data) = std::fs::read(&path) else {
302            self.warn_missing_once(texture_id, image_id, "file not found or unreadable");
303            return false;
304        };
305
306        if self.software {
307            match crate::import::software_texture::parse_texture_bank(&data) {
308                Ok(bank) => {
309                    self.insert_software_bank(texture_id, bank);
310                    return true;
311                }
312                Err(_) => {
313                    self.warn_missing_once(texture_id, image_id, "failed to parse TEXTURE bank");
314                    return false;
315                }
316            }
317        }
318        let Ok(bsi) = bsi::parse_bsi_file(&data) else {
319            self.warn_missing_once(texture_id, image_id, "failed to parse TEXBSI file");
320            return false;
321        };
322
323        self.bsi_files.insert(texture_id, bsi.clone());
324        self.bsi_files.entry(resolved_texture_id).or_insert(bsi);
325        true
326    }
327
328    pub fn ensure_bsi_available(&mut self, texture_id: u16) {
329        self.ensure_bsi_loaded(texture_id, 0);
330    }
331
332    #[must_use]
333    pub fn image_count(&self, texture_id: u16) -> Option<usize> {
334        self.bsi_files.get(&texture_id).map(|bsi| bsi.images.len())
335    }
336
337    fn image_index(&mut self, texture_id: u16, image_id: u8) -> Option<usize> {
338        if !self.ensure_bsi_loaded(texture_id, image_id) {
339            return None;
340        }
341        let index = self
342            .bsi_files
343            .get(&texture_id)?
344            .images
345            .iter()
346            .position(|entry| entry.image_index == u16::from(image_id));
347        if index.is_none() {
348            self.warn_missing_once(texture_id, image_id, "image id not present in texture bank");
349        }
350        index
351    }
352
353    fn decode_cached_frame(
354        &self,
355        texture_id: u16,
356        image: &BsiImage,
357        frame: usize,
358    ) -> Option<Vec<u8>> {
359        let mut rgba = flip_rows_vertical(
360            image.decode_frame_rgba(frame, self.palette.as_ref())?,
361            image.width,
362        );
363        if self
364            .opaque_images
365            .contains(&(texture_id, image.image_index))
366        {
367            for pixel in rgba.as_chunks_mut::<4>().0 {
368                if pixel[3] == 0 {
369                    pixel[..3]
370                        .copy_from_slice(&self.palette.as_ref().map_or([0; 3], |p| p.colors[0]));
371                }
372                pixel[3] = 255;
373            }
374        }
375        Some(rgba)
376    }
377
378    pub(super) fn material_name(&mut self, texture_id: u16, image_id: u8) -> Option<String> {
379        let index = self.image_index(texture_id, image_id)?;
380        let image = &self.bsi_files.get(&texture_id)?.images[index];
381        let bank = Self::resolve_texture_id(texture_id, |id| self.texbsi_paths.contains_key(&id));
382        let prefix = if self.software { "TEXTURE" } else { "TEXBSI" };
383        Some(format!(
384            "{prefix}.{bank:03} / {} (image {image_id})",
385            image.name
386        ))
387    }
388
389    pub(super) fn get_image_chroma_key(
390        &mut self,
391        texture_id: u16,
392        image_id: u8,
393    ) -> Option<[u8; 3]> {
394        let index = self.image_index(texture_id, image_id)?;
395        Some(self.bsi_files.get(&texture_id)?.images[index].palette_rgb(0, self.palette.as_ref()))
396    }
397
398    pub fn get_image_rgba(&mut self, texture_id: u16, image_id: u8) -> Option<(Vec<u8>, u16, u16)> {
399        let index = self.image_index(texture_id, image_id)?;
400        let image = &self.bsi_files.get(&texture_id)?.images[index];
401        Some((
402            self.decode_cached_frame(texture_id, image, 0)?,
403            image.width,
404            image.height,
405        ))
406    }
407
408    pub fn get_image_rgba_with_frame_count(
409        &mut self,
410        texture_id: u16,
411        image_id: u8,
412    ) -> Option<(Vec<u8>, u16, u16, u16)> {
413        let index = self.image_index(texture_id, image_id)?;
414        let image = &self.bsi_files.get(&texture_id)?.images[index];
415        Some((
416            self.decode_cached_frame(texture_id, image, 0)?,
417            image.width,
418            image.height,
419            image.frame_count,
420        ))
421    }
422
423    pub fn get_all_frames_by_image_id(
424        &mut self,
425        texture_id: u16,
426        image_id: u8,
427    ) -> Option<AllFramesInfo> {
428        let index = self.image_index(texture_id, image_id)?;
429        let image = &self.bsi_files.get(&texture_id)?.images[index];
430        let frames = (0..usize::from(image.frame_count))
431            .map(|frame| self.decode_cached_frame(texture_id, image, frame))
432            .collect();
433        Some(AllFramesInfo {
434            width: image.width,
435            height: image.height,
436            frame_count: image.frame_count,
437            frames,
438        })
439    }
440
441    fn terrain_frame_rgba(&self, image: &BsiImage, frame: usize) -> Option<Vec<u8>> {
442        let mut rgba = flip_rows_vertical(
443            image.decode_frame_rgba(frame, self.palette.as_ref())?,
444            image.width,
445        );
446        let zero = self
447            .palette
448            .as_ref()
449            .map(|palette| palette.colors[0])
450            .or_else(|| image.palette.as_ref().map(|palette| palette[0]))
451            .unwrap_or([0; 3]);
452        for pixel in rgba.as_chunks_mut::<4>().0 {
453            if pixel[3] == 0 {
454                pixel[..3].copy_from_slice(&zero);
455            }
456            pixel[3] = 255;
457        }
458        Some(rgba)
459    }
460
461    /// Terrain disables chroma rejection, retaining palette-zero RGB and opaque alpha.
462    pub(super) fn terrain_image_png(
463        &mut self,
464        texture_id: u16,
465        image_id: u8,
466        compress: bool,
467    ) -> Option<(Vec<u8>, u16, u16, bool)> {
468        let index = self.image_index(texture_id, image_id)?;
469        let image = &self.bsi_files.get(&texture_id)?.images[index];
470        let rgba = self.terrain_frame_rgba(image, 0)?;
471        let png = encode_rgba_png(image.width.into(), image.height.into(), &rgba, compress)?;
472        Some((png, image.width, image.height, false))
473    }
474
475    /// Authored animated image identity, delay in milliseconds, and PNG frames after frame zero.
476    pub(super) fn animation_pngs(
477        &mut self,
478        texture_id: u16,
479        image_id: u8,
480        compress: bool,
481        terrain: bool,
482    ) -> Option<AnimatedTexture> {
483        let index = self.image_index(texture_id, image_id)?;
484        let image = &self.bsi_files.get(&texture_id)?.images[index];
485        if image.frame_count <= 1 {
486            return None;
487        }
488        let frames = (1..usize::from(image.frame_count))
489            .map(|frame| {
490                let rgba = if terrain {
491                    self.terrain_frame_rgba(image, frame)?
492                } else {
493                    flip_rows_vertical(
494                        image.decode_frame_rgba(frame, self.palette.as_ref())?,
495                        image.width,
496                    )
497                };
498                encode_rgba_png(image.width.into(), image.height.into(), &rgba, compress)
499            })
500            .collect::<Option<Vec<_>>>()?;
501        Some(AnimatedTexture {
502            name: image.name.clone(),
503            delay_ms: image.anim_delay,
504            frames,
505            has_alpha: !terrain && image.extra_frames.iter().any(|pixels| pixels.contains(&0)),
506        })
507    }
508
509    /// Returns dimensions for a texture/image pair without decoding PNG bytes.
510    pub fn get_image_dimensions(&mut self, texture_id: u16, image_id: u8) -> Option<(u16, u16)> {
511        let index = self.image_index(texture_id, image_id)?;
512        let image = &self.bsi_files.get(&texture_id)?.images[index];
513
514        Some((image.width, image.height))
515    }
516
517    /// Returns the effective `tex_scale` factor for a texture/image pair.
518    /// The raw BHDR `tex_scale` is 8.8 fixed-point; `0` is treated as `0x0100` (1.0).
519    pub fn get_image_tex_scale(&mut self, texture_id: u16, image_id: u8) -> Option<f32> {
520        let index = self.image_index(texture_id, image_id)?;
521        let image = &self.bsi_files.get(&texture_id)?.images[index];
522
523        let raw = if image.tex_scale == 0 {
524            0x0100
525        } else {
526            image.tex_scale
527        };
528        Some(f32::from(raw) / 256.0)
529    }
530
531    /// Returns PNG-encoded image bytes, dimensions, and whether the image
532    /// contains any transparent pixels (palette index 0) for a texture/image pair.
533    pub fn get_image_png(
534        &mut self,
535        texture_id: u16,
536        image_id: u8,
537        compress: bool,
538    ) -> Option<(Vec<u8>, u16, u16, bool)> {
539        let (rgba, width, height) = self.get_image_rgba(texture_id, image_id)?;
540        let has_alpha = rgba.as_chunks::<4>().0.iter().any(|pixel| pixel[3] == 0);
541        let Some(png_bytes) = encode_rgba_png(u32::from(width), u32::from(height), &rgba, compress)
542        else {
543            self.warn_missing_once(
544                texture_id,
545                image_id,
546                "failed to encode PNG (invalid image dimensions or pixel data)",
547            );
548            return None;
549        };
550        Some((png_bytes, width, height, has_alpha))
551    }
552}
553
554fn encode_rgba_png(width: u32, height: u32, rgba: &[u8], compress: bool) -> Option<Vec<u8>> {
555    use image::ImageEncoder;
556    use image::codecs::png::{CompressionType, FilterType, PngEncoder};
557
558    if width == 0 || height == 0 {
559        return None;
560    }
561
562    let expected = width as usize * height as usize * 4;
563    if rgba.len() != expected {
564        return None;
565    }
566
567    let mut buf = Vec::new();
568    let (compression, filter) = if compress {
569        (CompressionType::Default, FilterType::Adaptive)
570    } else {
571        (CompressionType::Fast, FilterType::NoFilter)
572    };
573    let encoder = PngEncoder::new_with_quality(&mut buf, compression, filter);
574    encoder
575        .write_image(rgba, width, height, image::ExtendedColorType::Rgba8)
576        .ok()?;
577    Some(buf)
578}
579
580/// Flips RGBA pixel rows vertically. BSI pixel data is stored top-to-bottom
581/// but the engine's texture origin is bottom-left; flipping aligns the image
582/// with glTF/Unity's top-left UV origin after the V-flip (1 − v).
583fn flip_rows_vertical(rgba: Vec<u8>, width: u16) -> Vec<u8> {
584    let stride = usize::from(width) * 4;
585    if stride == 0 {
586        return rgba;
587    }
588    let mut flipped = Vec::with_capacity(rgba.len());
589    for row in rgba.chunks_exact(stride).rev() {
590        flipped.extend_from_slice(row);
591    }
592    flipped
593}
594
595pub(super) fn create_palette_color_png(rgb: [u8; 3], compress: bool) -> Option<Vec<u8>> {
596    let width = 8u32;
597    let height = 8u32;
598    let mut rgba = Vec::with_capacity((width * height * 4) as usize);
599    for _ in 0..(width * height) {
600        rgba.extend_from_slice(&[rgb[0], rgb[1], rgb[2], 255]);
601    }
602    encode_rgba_png(width, height, &rgba, compress)
603}
604
605#[cfg(test)]
606mod resolution_tests {
607    use super::TextureCache;
608
609    #[test]
610    fn bank_resolution_preserves_exact_then_folded_then_wrapped_priority() {
611        let resolve =
612            |available: &[u16]| TextureCache::resolve_texture_id(700, |id| available.contains(&id));
613        assert_eq!(resolve(&[700, 188, 200]), 700);
614        assert_eq!(resolve(&[188, 200]), 188);
615        assert_eq!(resolve(&[200]), 200);
616        assert_eq!(resolve(&[]), 700);
617    }
618}