Skip to main content

siglus_scene_vm/
image_manager.rs

1use std::collections::{HashMap, HashSet};
2use std::hash::{Hash, Hasher};
3use std::path::{Path, PathBuf};
4use std::sync::{Arc, RwLock, Weak};
5
6use crate::assets::{RgbaImage, load_image_any};
7use anyhow::{Context, Result, bail};
8
9/// A manager-local texture identity. Copying a key does not retain pixels.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
11pub struct ImageKey(pub u32);
12
13impl ImageKey {
14    pub fn index(self) -> usize {
15        self.0 as usize
16    }
17}
18
19impl std::fmt::Display for ImageKey {
20    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21        self.0.fmt(f)
22    }
23}
24
25/// An owning texture handle. Cloning it keeps the whole G00 album alive,
26/// just like C_elm_object's BSP<C_d3d_album> in the original engine.
27#[derive(Clone)]
28pub struct ImageHandle {
29    key: ImageKey,
30    album: Arc<ImageAlbum>,
31    cut: usize,
32}
33
34impl std::fmt::Debug for ImageHandle {
35    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36        f.debug_tuple("ImageHandle").field(&self.key).finish()
37    }
38}
39
40impl PartialEq for ImageHandle {
41    fn eq(&self, other: &Self) -> bool {
42        self.key == other.key && Arc::ptr_eq(&self.album, &other.album)
43    }
44}
45
46impl Eq for ImageHandle {}
47
48impl Hash for ImageHandle {
49    fn hash<H: Hasher>(&self, state: &mut H) {
50        self.key.hash(state);
51        Arc::as_ptr(&self.album).hash(state);
52    }
53}
54
55#[derive(Debug)]
56struct ImageAlbum {
57    first_id: u32,
58    frames: RwLock<Vec<ImageEntry>>,
59}
60
61#[derive(Debug, Clone)]
62struct WeakImageHandle {
63    id: ImageKey,
64    album: Weak<ImageAlbum>,
65    cut: usize,
66}
67
68impl WeakImageHandle {
69    fn upgrade(&self) -> Option<ImageHandle> {
70        Some(ImageHandle::new(self.album.upgrade()?, self.cut))
71    }
72}
73
74impl ImageHandle {
75    fn new(album: Arc<ImageAlbum>, cut: usize) -> Self {
76        Self {
77            key: ImageKey(album.first_id + cut as u32),
78            album,
79            cut,
80        }
81    }
82
83    pub fn key(&self) -> ImageKey {
84        self.key
85    }
86
87    pub fn index(&self) -> usize {
88        self.key.index()
89    }
90
91    /// Return another cut from the same already-loaded album.
92    ///
93    /// Original Siglus keeps a `C_d3d_album` on each PCT object and PATNO only
94    /// selects a texture from that album. It does not resolve the resource path
95    /// or reload the G00 when an animated property changes.
96    pub fn album_cut(&self, cut: usize) -> Option<Self> {
97        let count = self
98            .album
99            .frames
100            .read()
101            .expect("image album lock poisoned")
102            .len();
103        (cut < count).then(|| Self::new(self.album.clone(), cut))
104    }
105
106    fn downgrade(&self) -> WeakImageHandle {
107        WeakImageHandle {
108            id: self.key,
109            album: Arc::downgrade(&self.album),
110            cut: self.cut,
111        }
112    }
113}
114
115#[derive(Debug, Clone)]
116struct ImageSourceKey {
117    path: PathBuf,
118    frame_index: usize,
119}
120
121impl PartialEq for ImageSourceKey {
122    fn eq(&self, other: &Self) -> bool {
123        self.path == other.path && self.frame_index == other.frame_index
124    }
125}
126
127impl Eq for ImageSourceKey {}
128
129impl Hash for ImageSourceKey {
130    fn hash<H: Hasher>(&self, state: &mut H) {
131        self.path.hash(state);
132        self.frame_index.hash(state);
133    }
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
137struct G00ComposePart {
138    file_name: String,
139    x: i32,
140    y: i32,
141    cut_no: i32,
142    blend_type: i32,
143}
144
145fn normalized_g00_composite_descriptor(raw: &str) -> String {
146    // Original tnm_load_pct_d3d_sub_split_file_name() removes every ASCII
147    // space before parsing and before the composed resource is cached.
148    raw.chars().filter(|&ch| ch != ' ').collect()
149}
150
151fn parse_g00_composite_descriptor(raw: &str) -> Result<Vec<G00ComposePart>> {
152    if !raw.contains('|') {
153        bail!("not a composed g00 descriptor: {raw}");
154    }
155
156    let compact = normalized_g00_composite_descriptor(raw);
157    let bytes = compact.as_bytes();
158    let mut pos = 0usize;
159    let mut parts = Vec::new();
160
161    loop {
162        let name_start = pos;
163        while pos < bytes.len() && bytes[pos] != b'(' && bytes[pos] != b'|' {
164            pos += 1;
165        }
166
167        let mut part = G00ComposePart {
168            file_name: compact[name_start..pos].to_string(),
169            x: 0,
170            y: 0,
171            cut_no: 0,
172            blend_type: 0,
173        };
174
175        if pos < bytes.len() && bytes[pos] == b'(' {
176            let param_start = pos + 1;
177            let Some(rel_close) = compact[param_start..].find(')') else {
178                bail!("unterminated composed g00 parameters: {raw}");
179            };
180            let close = param_start + rel_close;
181            let params: Vec<&str> = compact[param_start..close].split(',').collect();
182            if params.len() < 2 {
183                bail!("composed g00 parameters require x,y: {raw}");
184            }
185            part.x = params[0]
186                .parse::<i32>()
187                .with_context(|| format!("invalid composed g00 x in {raw}"))?;
188            part.y = params[1]
189                .parse::<i32>()
190                .with_context(|| format!("invalid composed g00 y in {raw}"))?;
191            for param in params.iter().skip(2) {
192                if let Some(value) = param.strip_prefix("blend=") {
193                    part.blend_type = value
194                        .parse::<i32>()
195                        .with_context(|| format!("invalid composed g00 blend in {raw}"))?;
196                } else {
197                    part.cut_no = param
198                        .parse::<i32>()
199                        .with_context(|| format!("invalid composed g00 cut in {raw}"))?;
200                }
201            }
202            pos = close + 1;
203        }
204
205        parts.push(part);
206        if pos == bytes.len() {
207            break;
208        }
209        if bytes[pos] != b'|' {
210            bail!("unexpected character in composed g00 descriptor: {raw}");
211        }
212        pos += 1;
213        if pos == bytes.len() {
214            // The original parser produces an empty final entry, which is then
215            // rejected because only the first composed entry may omit a file.
216            parts.push(G00ComposePart {
217                file_name: String::new(),
218                x: 0,
219                y: 0,
220                cut_no: 0,
221                blend_type: 0,
222            });
223            break;
224        }
225    }
226
227    if parts.is_empty() {
228        bail!("empty composed g00 descriptor");
229    }
230    for (index, part) in parts.iter().enumerate().skip(1) {
231        if part.file_name.is_empty() {
232            bail!("composed g00 entry {index} has no file name");
233        }
234    }
235    Ok(parts)
236}
237
238pub(crate) fn g00_composite_component_names(raw: &str) -> Option<Vec<String>> {
239    if !raw.contains('|') {
240        return None;
241    }
242    parse_g00_composite_descriptor(raw).ok().map(|parts| {
243        parts
244            .into_iter()
245            .filter_map(|part| (!part.file_name.is_empty()).then_some(part.file_name))
246            .collect()
247    })
248}
249
250#[derive(Debug)]
251pub struct ImageManager {
252    project_dir: PathBuf,
253    current_append_dir: String,
254    key_to_id: HashMap<ImageSourceKey, WeakImageHandle>,
255    /// Original Tona3 keeps one C_d3d_album per resolved G00 resource.  Keep
256    /// the complete cut -> ImageHandle table alive for the same resource lifetime
257    /// so PATNO/GAN changes never decode the file again.
258    g00_album_to_ids: HashMap<PathBuf, Weak<ImageAlbum>>,
259    /// Siglus/Tona3 keeps the resolved PCT album on the object/resource manager.
260    /// Cache script resource names as well as resolved paths so repeated
261    /// OBJECT.CREATE of the same file does not hit the filesystem each time.
262    g00_name_to_album: HashMap<(String, String), Weak<ImageAlbum>>,
263    composite_to_id: HashMap<(String, String), WeakImageHandle>,
264    solid_to_id: HashMap<(u8, u8, u8, u8), WeakImageHandle>,
265    images: HashMap<ImageKey, WeakImageHandle>,
266    next_id: u32,
267    /// Recently used G00 albums, kept alive after their last handle drops.
268    /// Scripts free and recreate objects with the same image (GameData's
269    /// 1080p text overlay, once per line), which decoded the file again
270    /// every time: over 100 ms on PS Vita.
271    recent_albums: std::collections::VecDeque<(Arc<ImageAlbum>, usize)>,
272}
273
274/// Bounds of `ImageManager::recent_albums`.
275const RECENT_ALBUMS: usize = 64;
276#[cfg(any(target_os = "vita", target_os = "horizon"))]
277const RECENT_ALBUM_BYTES: usize = 24 * 1024 * 1024;
278#[cfg(not(any(target_os = "vita", target_os = "horizon")))]
279const RECENT_ALBUM_BYTES: usize = 64 * 1024 * 1024;
280
281#[derive(Debug, Default, Clone, Copy)]
282pub struct ImageMemoryStats {
283    pub albums: usize,
284    pub images: usize,
285    pub rgba_bytes: usize,
286}
287
288#[derive(Debug, Clone)]
289struct ImageEntry {
290    img: Arc<RgbaImage>,
291    version: u64,
292    original_size: (u32, u32),
293}
294
295#[derive(Debug, Clone)]
296pub struct DebugImageInfo {
297    pub id: ImageKey,
298    pub width: u32,
299    pub height: u32,
300    pub version: u64,
301    pub source_path: Option<PathBuf>,
302    pub frame_index: Option<usize>,
303    pub composite_append_dir: Option<String>,
304    pub composite_descriptor: Option<String>,
305}
306
307fn compose_g00_cut(dst: &mut RgbaImage, src: &RgbaImage, x: i32, y: i32, blend_type: i32) {
308    if dst.width == 0 || dst.height == 0 || src.width == 0 || src.height == 0 {
309        return;
310    }
311
312    let dst_left = x.max(0) as u32;
313    let dst_top = y.max(0) as u32;
314    let src_left = x.saturating_neg().max(0) as u32;
315    let src_top = y.saturating_neg().max(0) as u32;
316    if dst_left >= dst.width
317        || dst_top >= dst.height
318        || src_left >= src.width
319        || src_top >= src.height
320    {
321        return;
322    }
323    let width = (src.width - src_left).min(dst.width - dst_left);
324    let height = (src.height - src_top).min(dst.height - dst_top);
325
326    for row in 0..height {
327        for col in 0..width {
328            let si = (((src_top + row) * src.width + src_left + col) * 4) as usize;
329            let di = (((dst_top + row) * dst.width + dst_left + col) * 4) as usize;
330            let sr = src.rgba[si] as i64;
331            let sg = src.rgba[si + 1] as i64;
332            let sb = src.rgba[si + 2] as i64;
333            let sa = src.rgba[si + 3] as i64;
334            if sa == 0 {
335                continue;
336            }
337
338            let dr = dst.rgba[di] as i64;
339            let dg = dst.rgba[di + 1] as i64;
340            let db = dst.rgba[di + 2] as i64;
341            let da = dst.rgba[di + 3] as i64;
342            // Tona3 only has the opaque-source memcpy fast path in the
343            // normal-alpha branch. Add/multiply must still combine an opaque
344            // source with the destination color. A transparent destination can
345            // be copied for every blend mode because each equation reduces to
346            // the source pixel in that case.
347            if da == 0 || (sa == 255 && !matches!(blend_type, 1 | 3)) {
348                dst.rgba[di..di + 4].copy_from_slice(&src.rgba[si..si + 4]);
349                continue;
350            }
351
352            let ra = sa + da - (sa * da / 255);
353            if ra <= 0 {
354                continue;
355            }
356            let blend_channel = |sc: i64, dc: i64| -> i64 {
357                match blend_type {
358                    // Tona3's composed texture path has dedicated add and
359                    // multiply equations. Every other enum value follows the
360                    // normal alpha path in f_draw_alphablend().
361                    1 => {
362                        let mixed = (sc + dc).min(255);
363                        (sa * da * mixed + sa * (255 - da) * sc + (255 - sa) * da * dc) / ra / 255
364                    }
365                    3 => {
366                        let mixed = sc * dc / 255;
367                        (sa * da * mixed + sa * (255 - da) * sc + (255 - sa) * da * dc) / ra / 255
368                    }
369                    _ => {
370                        let work1 = (255 - sa) * da;
371                        let work2 = 255 * sa * sc;
372                        ((work2 + work1 * dc) >> 8) / ra
373                    }
374                }
375            };
376
377            dst.rgba[di] = blend_channel(sr, dr).clamp(0, 255) as u8;
378            dst.rgba[di + 1] = blend_channel(sg, dg).clamp(0, 255) as u8;
379            dst.rgba[di + 2] = blend_channel(sb, db).clamp(0, 255) as u8;
380            dst.rgba[di + 3] = ra.clamp(0, 255) as u8;
381        }
382    }
383}
384
385impl ImageManager {
386    /// Count live decoded pixels, deduplicating albums and shared frame images.
387    /// This excludes allocator metadata and temporary decode buffers.
388    pub fn debug_live_image_memory_stats(&self) -> ImageMemoryStats {
389        let mut stats = ImageMemoryStats::default();
390        let mut seen_albums = HashSet::new();
391        let mut seen_images = HashSet::new();
392        for weak in self.images.values() {
393            let Some(album) = weak.album.upgrade() else {
394                continue;
395            };
396            if !seen_albums.insert(Arc::as_ptr(&album)) {
397                continue;
398            }
399            stats.albums += 1;
400            let frames = album.frames.read().expect("image album lock poisoned");
401            for frame in frames.iter() {
402                if seen_images.insert(Arc::as_ptr(&frame.img)) {
403                    stats.images += 1;
404                    stats.rgba_bytes = stats.rgba_bytes.saturating_add(frame.img.rgba.capacity());
405                }
406            }
407        }
408        stats
409    }
410
411    pub fn new(project_dir: PathBuf) -> Self {
412        Self {
413            project_dir,
414            current_append_dir: String::new(),
415            key_to_id: HashMap::new(),
416            g00_album_to_ids: HashMap::new(),
417            g00_name_to_album: HashMap::new(),
418            composite_to_id: HashMap::new(),
419            solid_to_id: HashMap::new(),
420            images: HashMap::new(),
421            next_id: 0,
422            recent_albums: std::collections::VecDeque::new(),
423        }
424    }
425
426    /// Lets the recently used albums go once nothing else holds them.
427    pub fn release_recent_albums(&mut self) {
428        self.recent_albums.clear();
429    }
430
431    /// Marks an album as just used (see `recent_albums`).
432    fn keep_recent_album(&mut self, album: &Arc<ImageAlbum>) {
433        if let Some(at) = self
434            .recent_albums
435            .iter()
436            .position(|(kept, _)| Arc::ptr_eq(kept, album))
437        {
438            if at != 0 {
439                let entry = self.recent_albums.remove(at).expect("recent album");
440                self.recent_albums.push_front(entry);
441            }
442            return;
443        }
444        let bytes = album
445            .frames
446            .read()
447            .expect("image album lock poisoned")
448            .iter()
449            .map(|entry| entry.img.rgba.len())
450            .sum::<usize>();
451        if bytes > RECENT_ALBUM_BYTES {
452            return;
453        }
454        self.recent_albums.push_front((album.clone(), bytes));
455        let mut total: usize = self.recent_albums.iter().map(|(_, bytes)| bytes).sum();
456        while self.recent_albums.len() > RECENT_ALBUMS || total > RECENT_ALBUM_BYTES {
457            let Some((_, bytes)) = self.recent_albums.pop_back() else {
458                break;
459            };
460            total -= bytes;
461        }
462    }
463
464    pub fn project_dir(&self) -> &Path {
465        &self.project_dir
466    }
467
468    pub fn current_append_dir(&self) -> &str {
469        &self.current_append_dir
470    }
471
472    pub fn set_current_append_dir(&mut self, append_dir: impl Into<String>) {
473        let append_dir = append_dir.into();
474        if self.current_append_dir != append_dir {
475            self.current_append_dir = append_dir;
476        }
477    }
478
479    pub fn set_current_append_dir_ref(&mut self, append_dir: &str) {
480        if self.current_append_dir != append_dir {
481            self.current_append_dir.clear();
482            self.current_append_dir.push_str(append_dir);
483        }
484    }
485
486    /// Borrow the resource handle and share its current pixel buffer.
487    /// The returned pixels can outlive a later in-place image update.
488    pub fn get(&self, id: &ImageHandle) -> Option<Arc<RgbaImage>> {
489        self.get_entry(id).map(|(img, _)| img)
490    }
491
492    pub fn get_entry(&self, id: &ImageHandle) -> Option<(Arc<RgbaImage>, u64)> {
493        let registered = self.images.get(&id.key)?;
494        if registered.album.as_ptr() != Arc::as_ptr(&id.album) {
495            return None;
496        }
497        let frames = id.album.frames.read().expect("image album lock poisoned");
498        let entry = frames.get(id.cut)?;
499        Some((entry.img.clone(), entry.version))
500    }
501
502    /// Script-visible canvas size, independent of the cropped GPU texture.
503    /// The number of cuts in the album an image belongs to (a G00's
504    /// pattern count).
505    pub fn album_len(&self, id: &ImageHandle) -> usize {
506        id.album
507            .frames
508            .read()
509            .expect("image album lock poisoned")
510            .len()
511    }
512
513    pub fn original_size(&self, id: &ImageHandle) -> Option<(u32, u32)> {
514        let registered = self.images.get(&id.key)?;
515        if registered.album.as_ptr() != Arc::as_ptr(&id.album) {
516            return None;
517        }
518        let frames = id.album.frames.read().expect("image album lock poisoned");
519        Some(frames.get(id.cut)?.original_size)
520    }
521
522    /// Upgrade a non-owning key while the resource is still alive.
523    /// Returns None after the last handle is dropped; a key cannot resurrect it.
524    pub fn image_handle(&self, index: ImageKey) -> Option<ImageHandle> {
525        self.images.get(&index)?.upgrade()
526    }
527
528    pub fn contains(&self, index: ImageKey) -> bool {
529        self.images
530            .get(&index)
531            .is_some_and(|id| id.album.strong_count() != 0)
532    }
533
534    /// Create a 1x1 solid RGBA image and return its image id.
535    ///
536    /// This is used for UI placeholders (e.g. message window background) until
537    /// full UI skinning is implemented.
538    pub fn solid_rgba(&mut self, rgba: (u8, u8, u8, u8)) -> ImageHandle {
539        if let Some(id) = self
540            .solid_to_id
541            .get(&rgba)
542            .and_then(WeakImageHandle::upgrade)
543        {
544            return id;
545        }
546        let img = RgbaImage {
547            width: 1,
548            height: 1,
549            center_x: 0,
550            center_y: 0,
551            rgba: vec![rgba.0, rgba.1, rgba.2, rgba.3],
552        };
553        let id = self.insert_image(img);
554        self.solid_to_id.insert(rgba, id.downgrade());
555        id
556    }
557
558    /// Load a BG resource by name (Siglus policy: g00/ then bg/, with extension fallback).
559    ///
560    /// BG is not animated in our current bring-up, so frame index is always 0.
561    pub fn load_bg(&mut self, name: &str) -> Result<ImageHandle> {
562        let (path, _ty) = crate::resource::find_bg_image_with_append_dir(
563            &self.project_dir,
564            &self.current_append_dir,
565            name,
566        )
567        .with_context(|| format!("find bg resource {name}"))?;
568        self.load_file(&path, 0)
569    }
570
571    /// Load a BG resource with an explicit frame index (kept for compatibility).
572    pub fn load_bg_frame(&mut self, name: &str, frame_index: usize) -> Result<ImageHandle> {
573        let (path, _ty) = crate::resource::find_bg_image_with_append_dir(
574            &self.project_dir,
575            &self.current_append_dir,
576            name,
577        )
578        .with_context(|| format!("find bg resource {name}"))?;
579        self.load_file(&path, frame_index)
580    }
581
582    /// Load an image restricted to the `g00/` directory (with extension fallback).
583    ///
584    /// Used for CHR / sprite image loading.
585    pub fn load_g00(&mut self, name: &str, frame_index: u32) -> Result<ImageHandle> {
586        if name.contains('|') {
587            if frame_index != 0 {
588                bail!("composed g00 has one texture; invalid frame index {frame_index}");
589            }
590            return self.load_g00_composed(name);
591        }
592
593        let resource_key = (
594            self.current_append_dir.clone(),
595            name.replace('\\', "/").to_ascii_lowercase(),
596        );
597        if let Some(album) = self
598            .g00_name_to_album
599            .get(&resource_key)
600            .and_then(Weak::upgrade)
601        {
602            let cut = frame_index as usize;
603            let count = album
604                .frames
605                .read()
606                .expect("image album lock poisoned")
607                .len();
608            if cut >= count {
609                bail!(
610                    "g00 frame index out of range: resource={} index={} count={}",
611                    name,
612                    cut,
613                    count
614                );
615            }
616            self.keep_recent_album(&album);
617            return Ok(ImageHandle::new(album, cut));
618        }
619
620        let (path, _ty) = crate::resource::find_g00_image_with_append_dir(
621            &self.project_dir,
622            &self.current_append_dir,
623            name,
624        )
625        .with_context(|| format!("find g00 resource {name}"))?;
626        let id = self.load_file(&path, frame_index as usize)?;
627        self.g00_name_to_album
628            .insert(resource_key, Arc::downgrade(&id.album));
629        Ok(id)
630    }
631
632    fn decode_composed_g00_part(&mut self, part: &G00ComposePart) -> Result<RgbaImage> {
633        let (path, ty) = crate::resource::find_g00_image_with_append_dir(
634            &self.project_dir,
635            &self.current_append_dir,
636            &part.file_name,
637        )
638        .with_context(|| format!("find composed g00 resource {}", part.file_name))?;
639        if ty != crate::resource::PctType::G00 {
640            bail!(
641                "composed texture accepts g00 only: {} resolved as {}",
642                part.file_name,
643                ty.ext()
644            );
645        }
646
647        let requested =
648            if path.is_absolute() || crate::resource::resolve_game_file(&path)?.is_some() {
649                path
650            } else {
651                self.project_dir.join(path)
652            };
653        let resolved = crate::resource::resolve_game_file(&requested)?.unwrap_or(requested);
654
655        // Tona3 composes cuts from an already-loaded C_d3d_album. Preserve the
656        // original clamp-to-last-cut behavior while reusing that same album
657        // cache instead of decoding the G00 again for every component.
658        let album = self.ensure_g00_album(&resolved)?;
659        let max_index = album
660            .frames
661            .read()
662            .expect("image album lock poisoned")
663            .len()
664            - 1;
665        let cut_no = part.cut_no.clamp(0, max_index as i32) as usize;
666        let id = ImageHandle::new(album, cut_no);
667        self.get(&id)
668            .map(|img| (*img).clone())
669            .with_context(|| format!("missing cached composed g00 image id={}", id.index()))
670    }
671
672    /// Load Siglus/Tona3's composed-G00 descriptor syntax:
673    /// `base(x,y,cut,blend=n)|overlay(x,y,cut,blend=n)|...`.
674    ///
675    /// Tona3 creates one texture from the first cut and draws every later cut
676    /// into that fixed-size texture. Coordinates are anchor-relative: each
677    /// overlay is shifted by the base cut center minus the overlay cut center.
678    pub fn load_g00_composed(&mut self, descriptor: &str) -> Result<ImageHandle> {
679        let normalized = normalized_g00_composite_descriptor(descriptor);
680        let cache_key = (self.current_append_dir.clone(), normalized.clone());
681        if let Some(id) = self
682            .composite_to_id
683            .get(&cache_key)
684            .and_then(WeakImageHandle::upgrade)
685        {
686            return Ok(id);
687        }
688
689        let parts = parse_g00_composite_descriptor(&normalized)?;
690        let first = parts.first().context("composed g00 has no first entry")?;
691        let mut composed = if first.file_name.is_empty() {
692            if first.x <= 0 || first.y <= 0 {
693                bail!("blank composed g00 base requires positive width,height");
694            }
695            let pixel_len = (first.x as usize)
696                .checked_mul(first.y as usize)
697                .and_then(|len| len.checked_mul(4))
698                .context("blank composed g00 size overflow")?;
699            RgbaImage {
700                width: first.x as u32,
701                height: first.y as u32,
702                center_x: 0,
703                center_y: 0,
704                rgba: vec![0; pixel_len],
705            }
706        } else {
707            self.decode_composed_g00_part(first)?
708        };
709
710        let base_center_x = composed.center_x;
711        let base_center_y = composed.center_y;
712        for part in parts.iter().skip(1) {
713            let overlay = self.decode_composed_g00_part(part)?;
714            let dst_x = part
715                .x
716                .saturating_add(base_center_x)
717                .saturating_sub(overlay.center_x);
718            let dst_y = part
719                .y
720                .saturating_add(base_center_y)
721                .saturating_sub(overlay.center_y);
722            compose_g00_cut(&mut composed, &overlay, dst_x, dst_y, part.blend_type);
723        }
724
725        let id = self.insert_image(composed);
726        self.composite_to_id.insert(cache_key, id.downgrade());
727        Ok(id)
728    }
729
730    fn ensure_g00_album(&mut self, resolved: &Path) -> Result<Arc<ImageAlbum>> {
731        if let Some(album) = self.g00_album_to_ids.get(resolved).and_then(Weak::upgrade) {
732            self.keep_recent_album(&album);
733            return Ok(album);
734        }
735        let bytes = crate::resource::read_file_bytes(resolved)
736            .with_context(|| format!("read g00 album {:?}", resolved))?;
737        let decoded = crate::assets::g00::decode_g00(&bytes)
738            .with_context(|| format!("decode g00 album {:?}", resolved))?;
739        if decoded.frames.is_empty() {
740            bail!("g00 has no frames: {:?}", resolved);
741        }
742
743        let album = self.insert_album(decoded.frames.into_iter().map(Arc::new).collect());
744        // load_g00_cut() stores cut_info.width/height after creating a texture
745        // sized to disp_rect. Keep both sizes so face/body animation scripts
746        // calculate matching positions even when their opaque bounds differ.
747        for (entry, size) in album
748            .frames
749            .write()
750            .expect("image album lock poisoned")
751            .iter_mut()
752            .zip(decoded.original_sizes)
753        {
754            entry.original_size = size;
755        }
756        let count = album
757            .frames
758            .read()
759            .expect("image album lock poisoned")
760            .len();
761        for cut_index in 0..count {
762            let id = ImageHandle::new(album.clone(), cut_index);
763            self.key_to_id.insert(
764                ImageSourceKey {
765                    path: resolved.to_path_buf(),
766                    frame_index: cut_index,
767                },
768                id.downgrade(),
769            );
770        }
771        self.g00_album_to_ids
772            .insert(resolved.to_path_buf(), Arc::downgrade(&album));
773        self.keep_recent_album(&album);
774        Ok(album)
775    }
776
777    /// Load an image from an explicit path (relative to project_dir if not absolute).
778    pub fn load_file(&mut self, path: &Path, frame_index: usize) -> Result<ImageHandle> {
779        let requested = if path.is_absolute() {
780            path.to_path_buf()
781        } else if crate::resource::resolve_game_file(path)?.is_some() {
782            // Resource lookup helpers can return a project-rooted relative path
783            // (for example `testcase/g00/foo.g00`). Resolve it before deciding
784            // to join project_dir again, including Windows-style case folding.
785            path.to_path_buf()
786        } else {
787            self.project_dir.join(path)
788        };
789        let resolved = crate::resource::resolve_game_file(&requested)?.unwrap_or(requested);
790
791        let key = ImageSourceKey {
792            path: resolved.clone(),
793            frame_index,
794        };
795
796        if let Some(id) = self.key_to_id.get(&key).and_then(WeakImageHandle::upgrade) {
797            return Ok(id);
798        }
799
800        let ext = resolved
801            .extension()
802            .and_then(|value| value.to_str())
803            .unwrap_or("")
804            .to_ascii_lowercase();
805        if ext == "g00" {
806            // C_tnm_d3d_resource_manager::create_album_from_g00() loads and
807            // caches the complete album once. GAN then only changes PATNO.
808            // Do the same here: the first requested cut decodes the G00 once
809            // and registers every cut; all later PATNO changes are O(1).
810            let album = self.ensure_g00_album(&resolved)?;
811            let count = album
812                .frames
813                .read()
814                .expect("image album lock poisoned")
815                .len();
816            if frame_index >= count {
817                bail!(
818                    "g00 frame index out of range: {:?} index={} count={}",
819                    resolved,
820                    frame_index,
821                    count
822                );
823            }
824            return Ok(ImageHandle::new(album, frame_index));
825        }
826
827        let img = load_image_any(&resolved, frame_index)
828            .with_context(|| format!("load image {:?}", resolved))?;
829        let id = self.insert_image(img);
830        self.key_to_id.insert(key, id.downgrade());
831        Ok(id)
832    }
833
834    /// Insert an already-decoded image into the manager and return a new ImageHandle.
835    pub fn insert_image(&mut self, img: RgbaImage) -> ImageHandle {
836        self.insert_image_arc(Arc::new(img))
837    }
838
839    pub fn insert_image_arc(&mut self, img: Arc<RgbaImage>) -> ImageHandle {
840        let album = self.insert_album(vec![img]);
841        ImageHandle::new(album, 0)
842    }
843
844    fn insert_album(&mut self, frames: Vec<Arc<RgbaImage>>) -> Arc<ImageAlbum> {
845        let first_id = self.next_id;
846        self.next_id = self
847            .next_id
848            .checked_add(u32::try_from(frames.len()).expect("too many image frames"))
849            .expect("ImageHandle space exhausted");
850        let album = Arc::new(ImageAlbum {
851            first_id,
852            frames: RwLock::new(
853                frames
854                    .into_iter()
855                    .map(|img| ImageEntry {
856                        original_size: (img.width, img.height),
857                        img,
858                        version: 0,
859                    })
860                    .collect(),
861            ),
862        });
863        for index in first_id..self.next_id {
864            self.images.insert(
865                ImageKey(index),
866                WeakImageHandle {
867                    id: ImageKey(index),
868                    album: Arc::downgrade(&album),
869                    cut: (index - first_id) as usize,
870                },
871            );
872        }
873        album
874    }
875
876    /// Replace an existing image in-place and bump its version.
877    ///
878    /// This allows the renderer to update the GPU texture without changing the ImageHandle.
879    pub fn replace_image(&mut self, id: &ImageHandle, img: RgbaImage) -> Result<()> {
880        self.replace_image_arc(id, Arc::new(img))
881    }
882
883    pub fn replace_image_arc(&mut self, id: &ImageHandle, img: Arc<RgbaImage>) -> Result<()> {
884        let Some(registered) = self.images.get(&id.key) else {
885            anyhow::bail!("replace_image_arc: invalid ImageHandle {}", id.index());
886        };
887        anyhow::ensure!(
888            registered.album.as_ptr() == Arc::as_ptr(&id.album),
889            "image belongs to another manager"
890        );
891        let mut frames = id.album.frames.write().expect("image album lock poisoned");
892        let entry = &mut frames[id.cut];
893        entry.original_size = (img.width, img.height);
894        entry.img = img;
895        entry.version = entry.version.wrapping_add(1);
896        Ok(())
897    }
898
899    pub fn debug_image_info(&self, id: &ImageHandle) -> Option<DebugImageInfo> {
900        let (img, version) = self.get_entry(id)?;
901        let mut source_path = None;
902        let mut frame_index = None;
903        for (key, key_id) in &self.key_to_id {
904            if key_id.id == id.key {
905                source_path = Some(key.path.clone());
906                frame_index = Some(key.frame_index);
907                break;
908            }
909        }
910
911        // Composed G00 textures are synthetic ImageHandles and therefore do not
912        // appear in key_to_id. Keep their original descriptor visible to the
913        // renderer HUD so a bad composed texture can be distinguished from a
914        // correctly decoded face/eye difference layer.
915        let mut composite_append_dir = None;
916        let mut composite_descriptor = None;
917        for ((append_dir, descriptor), composite_id) in &self.composite_to_id {
918            if composite_id.id == id.key {
919                composite_append_dir = Some(append_dir.clone());
920                composite_descriptor = Some(descriptor.clone());
921                break;
922            }
923        }
924
925        Some(DebugImageInfo {
926            id: id.key(),
927            width: img.width,
928            height: img.height,
929            version,
930            source_path,
931            frame_index,
932            composite_append_dir,
933            composite_descriptor,
934        })
935    }
936
937    pub fn resident_bytes(&self) -> usize {
938        self.images
939            .values()
940            .filter_map(|weak| {
941                let id = weak.upgrade()?;
942                let frames = id.album.frames.read().expect("image album lock poisoned");
943                Some(frames[id.cut].img.rgba.len())
944            })
945            .sum()
946    }
947
948    /// Like C_tnm_d3d_resource_manager::organize(), remove expired weak
949    /// registrations. Pixel storage is already freed when its last owner drops.
950    pub fn organize(&mut self) {
951        self.images.retain(|_, id| id.album.strong_count() != 0);
952        self.key_to_id.retain(|_, id| id.album.strong_count() != 0);
953        self.g00_album_to_ids
954            .retain(|_, album| album.strong_count() != 0);
955        self.g00_name_to_album
956            .retain(|_, album| album.strong_count() != 0);
957        self.composite_to_id
958            .retain(|_, id| id.album.strong_count() != 0);
959        self.solid_to_id
960            .retain(|_, id| id.album.strong_count() != 0);
961    }
962}
963
964#[cfg(test)]
965mod composed_g00_tests {
966    use super::*;
967
968    fn pixel() -> RgbaImage {
969        RgbaImage {
970            width: 1,
971            height: 1,
972            center_x: 0,
973            center_y: 0,
974            rgba: vec![255; 4],
975        }
976    }
977
978    #[test]
979    fn keys_and_debug_metadata_do_not_own_pixels() {
980        let mut images = ImageManager::new(PathBuf::from("."));
981        let handle = images.insert_image(pixel());
982        let key = handle.key();
983        let copied_key = key;
984        let debug_info = images.debug_image_info(&handle).unwrap();
985        let pixels = Arc::downgrade(&images.get(&handle).unwrap());
986        assert_eq!(debug_info.id, copied_key);
987        assert_eq!(Arc::strong_count(&handle.album), 1);
988
989        // Updating through a borrow leaves the caller's handle usable.
990        images.replace_image(&handle, pixel()).unwrap();
991        assert_eq!(images.get_entry(&handle).unwrap().1, 1);
992        assert!(pixels.upgrade().is_none());
993        drop(handle);
994        assert!(!images.contains(copied_key));
995        assert!(images.image_handle(debug_info.id).is_none());
996    }
997
998    #[test]
999    fn image_registry_does_not_keep_unused_pixels_alive() {
1000        let mut images = ImageManager::new(PathBuf::from("."));
1001        let id = images.insert_image(pixel());
1002        let index = id.key();
1003        let pixels = Arc::downgrade(&images.get(&id).unwrap());
1004        let other_owner = id.clone();
1005        drop(id);
1006        images.organize();
1007        assert!(pixels.upgrade().is_some());
1008        assert!(images.image_handle(index).is_some());
1009        drop(other_owner);
1010        // Destruction does not depend on a memory threshold or another frame.
1011        assert!(pixels.upgrade().is_none());
1012        assert!(!images.contains(index));
1013        images.organize();
1014        assert!(images.images.is_empty());
1015        assert_eq!(images.resident_bytes(), 0);
1016        assert!(images.insert_image(pixel()).key() > index);
1017    }
1018
1019    #[test]
1020    fn any_cut_owns_the_complete_animation_album() {
1021        let mut images = ImageManager::new(PathBuf::from("."));
1022        let album = images.insert_album(vec![Arc::new(pixel()), Arc::new(pixel())]);
1023        let first = album.first_id;
1024        let frame = images.image_handle(ImageKey(first + 1)).unwrap();
1025        let pixels = Arc::downgrade(&images.get(&frame).unwrap());
1026        images
1027            .g00_album_to_ids
1028            .insert(PathBuf::from("animation.g00"), Arc::downgrade(&album));
1029        drop(album);
1030        images.organize();
1031        assert!(images.contains(ImageKey(first)));
1032        assert!(images.contains(ImageKey(first + 1)));
1033        assert_eq!(images.resident_bytes(), 8);
1034        drop(frame);
1035        assert!(pixels.upgrade().is_none());
1036        images.organize();
1037        assert!(images.images.is_empty());
1038        assert!(images.g00_album_to_ids.is_empty());
1039    }
1040
1041    #[test]
1042    fn solid_cache_is_weak_and_external_pixel_owners_survive() {
1043        let mut images = ImageManager::new(PathBuf::from("."));
1044        let id = images.solid_rgba((255, 255, 255, 255));
1045        assert_eq!(images.solid_rgba((255, 255, 255, 255)), id);
1046        let index = id.key();
1047        let pixels = images.get(&id).unwrap();
1048        drop(id);
1049        images.organize();
1050        assert!(images.solid_to_id.is_empty());
1051        assert_eq!(pixels.rgba, vec![255; 4]);
1052        assert!(images.solid_rgba((255, 255, 255, 255)).key() > index);
1053    }
1054
1055    #[test]
1056    fn cloned_hidden_sprite_keeps_its_images_alive() {
1057        let mut images = ImageManager::new(PathBuf::from("."));
1058        let image = images.insert_image(pixel());
1059        let mask = images.insert_image(pixel());
1060        let image_index = image.key();
1061        let mask_index = mask.key();
1062        let sprite = crate::layer::Sprite {
1063            visible: false,
1064            image_id: Some(image),
1065            mask_image_id: Some(mask),
1066            ..Default::default()
1067        };
1068        let copied_sprite = sprite.clone();
1069        drop(sprite);
1070        images.organize();
1071        assert!(images.contains(image_index));
1072        assert!(images.contains(mask_index));
1073        drop(copied_sprite);
1074        assert_eq!(images.resident_bytes(), 0);
1075    }
1076
1077    #[test]
1078    fn replacement_updates_all_handles_and_releases_old_pixels() {
1079        let mut images = ImageManager::new(PathBuf::from("."));
1080        let id = images.insert_image(pixel());
1081        let copy = id.clone();
1082        let old_pixels = Arc::downgrade(&images.get(&id).unwrap());
1083        let mut replacement = pixel();
1084        replacement.rgba = vec![0; 4];
1085        images.replace_image(&id, replacement).unwrap();
1086        assert!(old_pixels.upgrade().is_none());
1087        let (pixels, version) = images.get_entry(&copy).unwrap();
1088        assert_eq!(pixels.rgba, vec![0; 4]);
1089        assert_eq!(version, 1);
1090
1091        let mut other_manager = ImageManager::new(PathBuf::from("."));
1092        let other = other_manager.insert_image(pixel());
1093        assert_eq!(other.key(), copy.key());
1094        assert_ne!(other, copy);
1095        assert!(other_manager.get(&copy).is_none());
1096        assert!(other_manager.replace_image(&copy, pixel()).is_err());
1097    }
1098
1099    #[test]
1100    fn g00_cache_reuses_recent_album_and_reloads_after_release() {
1101        struct TempFile(PathBuf);
1102        impl Drop for TempFile {
1103            fn drop(&mut self) {
1104                let _ = std::fs::remove_file(&self.0);
1105            }
1106        }
1107        let stamp = std::time::SystemTime::now()
1108            .duration_since(std::time::UNIX_EPOCH)
1109            .unwrap()
1110            .as_nanos();
1111        let file = TempFile(std::env::temp_dir().join(format!(
1112            "siglus-image-lifetime-{}-{stamp}.g00",
1113            std::process::id()
1114        )));
1115        // Type 0, 1x1, one literal BGR pixel, four decoded RGBA bytes.
1116        std::fs::write(
1117            &file.0,
1118            [0, 1, 0, 1, 0, 12, 0, 0, 0, 4, 0, 0, 0, 1, 10, 20, 30],
1119        )
1120        .unwrap();
1121        let mut images = ImageManager::new(PathBuf::from("."));
1122        let id = images.load_file(&file.0, 0).unwrap();
1123        let index = id.key();
1124        let second = images.load_file(&file.0, 0).unwrap();
1125        assert_eq!(id, second);
1126        assert_eq!(images.get(&id).unwrap().rgba, vec![30, 20, 10, 255]);
1127        drop(id);
1128        images.organize();
1129        assert_eq!(images.load_file(&file.0, 0).unwrap(), second);
1130        drop(second);
1131        // A recently used album outlives its last handle and is reused.
1132        assert_eq!(images.resident_bytes(), 4);
1133        let kept = images.load_file(&file.0, 0).unwrap();
1134        assert_eq!(kept.key(), index);
1135        drop(kept);
1136        images.release_recent_albums();
1137        assert_eq!(images.resident_bytes(), 0);
1138        // Reload must also handle expired entries before organize runs.
1139        let reloaded = images.load_file(&file.0, 0).unwrap();
1140        assert!(reloaded.key() > index);
1141        drop(reloaded);
1142        images.release_recent_albums();
1143        images.organize();
1144        assert!(images.key_to_id.is_empty());
1145        assert!(images.g00_album_to_ids.is_empty());
1146        assert!(images.images.is_empty());
1147    }
1148
1149    #[test]
1150    fn repeated_generated_images_leave_no_registry_entries() {
1151        let mut images = ImageManager::new(PathBuf::from("."));
1152        for _ in 0..1024 {
1153            let image = images.insert_image(pixel());
1154            assert_eq!(images.resident_bytes(), 4);
1155            drop(image);
1156            images.organize();
1157            assert!(images.images.is_empty());
1158        }
1159        assert_eq!(images.resident_bytes(), 0);
1160    }
1161
1162    #[test]
1163    fn debug_info_reports_composed_descriptor_origin() {
1164        let mut images = ImageManager::new(PathBuf::from("."));
1165        let id = images.insert_image(RgbaImage {
1166            width: 1,
1167            height: 1,
1168            center_x: 0,
1169            center_y: 0,
1170            rgba: vec![255, 255, 255, 255],
1171        });
1172        images.composite_to_id.insert(
1173            ("PDT".to_string(), "base(0,0,0)|face(0,0,2)".to_string()),
1174            id.downgrade(),
1175        );
1176
1177        let info = images.debug_image_info(&id).expect("debug image info");
1178        assert_eq!(info.composite_append_dir.as_deref(), Some("PDT"));
1179        assert_eq!(
1180            info.composite_descriptor.as_deref(),
1181            Some("base(0,0,0)|face(0,0,2)")
1182        );
1183        assert!(info.source_path.is_none());
1184        assert!(info.frame_index.is_none());
1185    }
1186
1187    #[test]
1188    fn parses_siglus_composed_descriptor() {
1189        let parts = parse_g00_composite_descriptor(
1190            " bs3_rk2_base41(0, 0, 0) | bs3_rk2_face001(12, -3, 4, blend=1) ",
1191        )
1192        .expect("composed descriptor");
1193        assert_eq!(
1194            parts,
1195            vec![
1196                G00ComposePart {
1197                    file_name: "bs3_rk2_base41".to_string(),
1198                    x: 0,
1199                    y: 0,
1200                    cut_no: 0,
1201                    blend_type: 0,
1202                },
1203                G00ComposePart {
1204                    file_name: "bs3_rk2_face001".to_string(),
1205                    x: 12,
1206                    y: -3,
1207                    cut_no: 4,
1208                    blend_type: 1,
1209                },
1210            ]
1211        );
1212    }
1213
1214    #[test]
1215    fn composed_cut_uses_base_and_overlay_centers() {
1216        let mut base = RgbaImage {
1217            width: 4,
1218            height: 4,
1219            center_x: 2,
1220            center_y: 2,
1221            rgba: vec![0; 4 * 4 * 4],
1222        };
1223        let overlay = RgbaImage {
1224            width: 2,
1225            height: 2,
1226            center_x: 1,
1227            center_y: 1,
1228            rgba: vec![255; 2 * 2 * 4],
1229        };
1230
1231        // Tona3 draw position for (0,0) is base_center-overlay_center=(1,1).
1232        compose_g00_cut(&mut base, &overlay, 1, 1, 0);
1233        for y in 0..4usize {
1234            for x in 0..4usize {
1235                let alpha = base.rgba[(y * 4 + x) * 4 + 3];
1236                assert_eq!(
1237                    alpha,
1238                    if (1..3).contains(&x) && (1..3).contains(&y) {
1239                        255
1240                    } else {
1241                        0
1242                    }
1243                );
1244            }
1245        }
1246        assert_eq!((base.center_x, base.center_y), (2, 2));
1247    }
1248
1249    #[test]
1250    fn opaque_add_source_still_uses_tona_add_equation() {
1251        let mut base = RgbaImage {
1252            width: 1,
1253            height: 1,
1254            center_x: 0,
1255            center_y: 0,
1256            rgba: vec![20, 40, 60, 128],
1257        };
1258        let overlay = RgbaImage {
1259            width: 1,
1260            height: 1,
1261            center_x: 0,
1262            center_y: 0,
1263            rgba: vec![200, 100, 50, 255],
1264        };
1265        compose_g00_cut(&mut base, &overlay, 0, 0, 1);
1266
1267        let expected = |sc: i64, dc: i64| {
1268            let sa = 255i64;
1269            let da = 128i64;
1270            let ra = 255i64;
1271            ((sa * da * (sc + dc).min(255) + sa * (255 - da) * sc + (255 - sa) * da * dc)
1272                / ra
1273                / 255) as u8
1274        };
1275        assert_eq!(
1276            base.rgba,
1277            vec![expected(200, 20), expected(100, 40), expected(50, 60), 255,]
1278        );
1279    }
1280
1281    #[test]
1282    fn composed_alpha_matches_tona_integer_equation() {
1283        let mut base = RgbaImage {
1284            width: 1,
1285            height: 1,
1286            center_x: 0,
1287            center_y: 0,
1288            rgba: vec![20, 40, 60, 128],
1289        };
1290        let overlay = RgbaImage {
1291            width: 1,
1292            height: 1,
1293            center_x: 0,
1294            center_y: 0,
1295            rgba: vec![200, 100, 50, 128],
1296        };
1297        compose_g00_cut(&mut base, &overlay, 0, 0, 0);
1298
1299        let sa = 128i64;
1300        let da = 128i64;
1301        let ra = sa + da - sa * da / 255;
1302        let expected =
1303            |sc: i64, dc: i64| ((((255 * sa * sc) + ((255 - sa) * da * dc)) >> 8) / ra) as u8;
1304        assert_eq!(
1305            base.rgba,
1306            vec![
1307                expected(200, 20),
1308                expected(100, 40),
1309                expected(50, 60),
1310                ra as u8,
1311            ]
1312        );
1313    }
1314}