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#[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#[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 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 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 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 g00_album_to_ids: HashMap<PathBuf, Weak<ImageAlbum>>,
259 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 recent_albums: std::collections::VecDeque<(Arc<ImageAlbum>, usize)>,
272}
273
274const 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 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 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 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 pub fn release_recent_albums(&mut self) {
428 self.recent_albums.clear();
429 }
430
431 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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(©).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(©).is_none());
1096 assert!(other_manager.replace_image(©, 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 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 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 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 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}