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 #[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 #[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 #[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 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 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 #[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 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 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 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 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 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 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 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
580fn 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}