From 6b8b169d10778d711a7e2b0a39eeaddbe3de7df8 Mon Sep 17 00:00:00 2001 From: lifegpc Date: Thu, 28 May 2026 14:54:10 +0800 Subject: [PATCH] Add support for YU-RIS compressed image file (.ydg) --- Cargo.lock | 10 ++ Cargo.toml | 4 +- README.md | 4 + src/scripts/mod.rs | 2 + src/scripts/yuris/img/mod.rs | 2 + src/scripts/yuris/img/ydg.rs | 281 +++++++++++++++++++++++++++++++++++ src/scripts/yuris/mod.rs | 2 + src/types.rs | 3 + src/utils/img.rs | 59 ++++++++ 9 files changed, 366 insertions(+), 1 deletion(-) create mode 100644 src/scripts/yuris/img/mod.rs create mode 100644 src/scripts/yuris/img/ydg.rs diff --git a/Cargo.lock b/Cargo.lock index fcf832b..189dc96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1566,6 +1566,7 @@ dependencies = [ "parse-size", "pelite", "png", + "qoi", "rand", "rust-ini", "serde", @@ -1934,6 +1935,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + [[package]] name = "quote" version = "1.0.45" diff --git a/Cargo.toml b/Cargo.toml index 7d90b2a..704177e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -50,6 +50,7 @@ overf = "0.1" parse-size = { version = "1.1", optional = true } pelite = { version = "0.10", optional = true } png = { version = "0.18", optional = true } +qoi = { version = "0.4", optional = true } rand = { version = "0.10", optional = true } rust-ini = { version = "0.21", optional = true } serde = { version = "1", features = ["derive"] } @@ -74,7 +75,7 @@ default = ["all-fmt", "image-jpg", "image-jxl", "image-webp", "audio-flac", "jie zig = ["all-fmt", "image-jpg", "image-webp", "audio-flac", "jieba"] all-fmt = ["all-script", "all-img", "all-arc", "all-audio"] all-script = ["artemis", "artemis-panmimisoft", "bgi", "cat-system", "circus", "entis-gls", "escude", "ex-hibit", "favorite", "hexen-haus", "kirikiri", "musica", "qlie", "silky", "softpal", "will-plus", "yaneurao", "yaneurao-itufuru", "yuris"] -all-img = ["bgi-img", "cat-system-img", "circus-img", "emote-img", "hexen-haus-img", "kirikiri-img", "qlie-img", "softpal-img", "will-plus-img"] +all-img = ["bgi-img", "cat-system-img", "circus-img", "emote-img", "hexen-haus-img", "kirikiri-img", "qlie-img", "softpal-img", "will-plus-img", "yuris-img"] all-arc = ["artemis-arc", "bgi-arc", "cat-system-arc", "circus-arc", "escude-arc", "ex-hibit-arc", "hexen-haus-arc", "kirikiri-arc", "musica-arc", "qlie-arc", "softpal-arc"] all-audio = ["bgi-audio", "circus-audio"] artemis = ["dep:stylua", "utils-escape"] @@ -118,6 +119,7 @@ will-plus-img = ["will-plus", "image"] yaneurao = [] yaneurao-itufuru = ["yaneurao", "utils-xored-stream"] yuris = ["dep:hex", "utils-xored-stream"] +yuris-img = ["yuris", "image", "qoi", "webp"] # basic feature image = ["dep:png"] image-jpg = ["mozjpeg"] diff --git a/README.md b/README.md index 609b871..c9270c2 100644 --- a/README.md +++ b/README.md @@ -271,3 +271,7 @@ msg-tool create -t | `yuris-yscfg` | `yuris` | Yu-Ris YSCFG(config) file (.ybn) | ❌ | ❌ | ❌ | ❌ | ✔️ | ✔️ | ✔️ | | | `yuris-ystb` | `yuris` | Yu-Ris YSTB(compiled script) file (.ybn) | ❌ | ❌ | ❌ | ❌ | ✔️ | ❌ | ❌ | | | `yuris-txt` | `yuris` | Yu-Ris scenario text file (.txt) | ✔️ | ✔️ | ❌ | ❌ | ❌ | ❌ | ❌ | | + +| Image Type | Feature Name | Name | Export | Import | Export Multiple | Import Multiple | Create | Remarks | +|---|---|---|---|---|---|---|---|---| +| `yuris-ydg` | `yuris-img` | YU-RIS compressed image file (.ydg) | ✔️ | ✔️ | ❌ | ❌ | ✔️ | | diff --git a/src/scripts/mod.rs b/src/scripts/mod.rs index 814042d..0dd45ef 100644 --- a/src/scripts/mod.rs +++ b/src/scripts/mod.rs @@ -186,6 +186,8 @@ lazy_static::lazy_static! { Box::new(yuris::ystb::YSTBBuilder::new()), #[cfg(feature = "yuris")] Box::new(yuris::txt::YurisTxtBuilder::new()), + #[cfg(feature = "yuris-img")] + Box::new(yuris::img::ydg::YDGImageBuilder::new()), ]; /// A list of all script extensions. pub static ref ALL_EXTS: Vec = diff --git a/src/scripts/yuris/img/mod.rs b/src/scripts/yuris/img/mod.rs new file mode 100644 index 0000000..53cc3b3 --- /dev/null +++ b/src/scripts/yuris/img/mod.rs @@ -0,0 +1,2 @@ +//! Image types for YuRis Engine +pub mod ydg; diff --git a/src/scripts/yuris/img/ydg.rs b/src/scripts/yuris/img/ydg.rs new file mode 100644 index 0000000..0f2638d --- /dev/null +++ b/src/scripts/yuris/img/ydg.rs @@ -0,0 +1,281 @@ +//! YU-RIS compressed image file (.ydg) +use crate::ext::io::*; +use crate::scripts::base::*; +use crate::types::*; +use crate::utils::img::*; +use crate::utils::struct_pack::*; +use anyhow::Result; +use msg_tool_macro::*; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::sync::{Arc, Mutex}; + +#[derive(StructPack, StructUnpack, Debug, Clone)] +struct YDGHeader { + /// YDG + magic: [u8; 4], + /// YU-RIS + yuris_magic: [u8; 8], + /// Seems always 0x64 + _unk: u32, + /// Header length + header_size: u32, + /// YDG file size + file_size: u32, + _unk1: u64, + /// Image width + width: u16, + /// Image height + height: u16, + #[pack_vec_len(self.header_size - 0x24)] + #[unpack_vec_len({ + if header_size < 0x24 { + anyhow::bail!("Header size at least need 0x24 bytes."); + } + header_size - 0x24 + })] + other: Vec, + #[pvec(u32)] + slices: Vec, +} + +#[derive(StructPack, StructUnpack, Debug, Clone)] +struct Slice { + /// Slice start offset + offset: u32, + /// Slice size + size: u32, + x: u16, + height: u16, + _unk: u32, +} + +#[derive(Debug)] +/// YU-RIS compressed image file (.ydg) builder +pub struct YDGImageBuilder {} + +impl YDGImageBuilder { + pub fn new() -> Self { + Self {} + } +} + +impl ScriptBuilder for YDGImageBuilder { + fn default_encoding(&self) -> Encoding { + Encoding::Utf8 + } + + fn build_script( + &self, + buf: Vec, + _filename: &str, + _encoding: Encoding, + _archive_encoding: Encoding, + config: &ExtraConfig, + _archive: Option<&Box>, + ) -> Result> { + Ok(Box::new(YDGImage::new(MemReader::new(buf), config)?)) + } + + fn extensions(&self) -> &'static [&'static str] { + &["ydg"] + } + + fn script_type(&self) -> &'static ScriptType { + &ScriptType::YurisYDG + } + + fn is_image(&self) -> bool { + true + } + + fn is_this_format(&self, _filename: &str, buf: &[u8], buf_len: usize) -> Option { + if buf_len >= 12 && buf.starts_with(b"YDG\0YU-RIS\0\0") { + return Some(50); + } + None + } + + fn can_create_image_file(&self) -> bool { + true + } + + fn create_image_file<'a>( + &'a self, + mut data: ImageData, + _filename: &str, + mut writer: Box, + _options: &ExtraConfig, + ) -> Result<()> { + let mut header = YDGHeader { + magic: *b"YDG\0", + yuris_magic: *b"YU-RIS\0\0", + _unk: 0x64, + header_size: 0x30, + file_size: 0, + _unk1: 0, + width: data.width as u16, + height: data.height as u16, + other: vec![0; 0xC], + slices: vec![Slice { + offset: 0, + size: 0, + x: 0, + height: data.height as u16, + _unk: 0, + }], + }; + header.pack(&mut writer, false, Encoding::Utf8, &None)?; + header.slices[0].offset = writer.stream_position()? as u32; + match data.color_type { + ImageColorType::Bgr => { + convert_bgr_to_rgb(&mut data)?; + } + ImageColorType::Bgra => { + convert_bgra_to_rgba(&mut data)?; + } + ImageColorType::Rgb | ImageColorType::Rgba => {} + ImageColorType::Grayscale => { + convert_grayscale_to_rgb(&mut data)?; + } + }; + let encoder = qoi::Encoder::new(&data.data, data.width, data.height)?; + encoder.encode_to_stream(&mut writer)?; + let file_size = writer.stream_position()? as u32; + header.slices[0].size = file_size - header.slices[0].offset; + header.file_size = file_size; + writer.seek(SeekFrom::Start(0))?; + header.pack(&mut writer, false, Encoding::Utf8, &None)?; + Ok(()) + } +} + +#[derive(Debug)] +pub struct YDGImage { + inner: Arc>, + header: YDGHeader, +} + +impl YDGImage { + pub fn new(mut data: T, _config: &ExtraConfig) -> Result { + let header = YDGHeader::unpack(&mut data, false, Encoding::Utf8, &None)?; + if &header.magic != b"YDG\0" { + anyhow::bail!("Unknown YDG magic: {:?}", header.magic); + } + if &header.yuris_magic != b"YU-RIS\0\0" { + anyhow::bail!("Unknown YU-RIS magic: {:?}", header.yuris_magic); + } + Ok(Self { + inner: Arc::new(Mutex::new(data)), + header, + }) + } + + fn load_slice(&self, slice: &Slice) -> Result { + let mut data = StreamRegion::with_size( + MutexWrapper::new(self.inner.clone(), slice.offset as u64), + slice.size as u64, + )?; + let mut buf = [0; 12]; + let readed = data.peek(&mut buf)?; + if readed == 12 && buf.starts_with(b"RIFF") && buf.ends_with(b"WEBP") { + load_webp(data) + } else { + load_qoi(data) + } + } +} + +impl Script for YDGImage { + fn default_output_script_type(&self) -> OutputScriptType { + OutputScriptType::Custom + } + + fn is_output_supported(&self, output: OutputScriptType) -> bool { + matches!(output, OutputScriptType::Custom) + } + + fn default_format_type(&self) -> FormatOptions { + FormatOptions::None + } + + fn is_image(&self) -> bool { + true + } + + fn export_image(&self) -> Result { + let slice = self + .header + .slices + .get(0) + .ok_or_else(|| anyhow::anyhow!("YDG image has no valid tiles."))?; + let mut y = 0; + let mut base = self.load_slice(slice)?; + convert_to_rgba(&mut base)?; + let mut base = draw_on_canvas( + base, + self.header.width as u32, + self.header.height as u32, + slice.x as u32, + y, + )?; + y += slice.height as u32; + for slice in &self.header.slices[1..] { + let mut diff = self.load_slice(slice)?; + convert_to_rgba(&mut diff)?; + draw_on_image(&mut base, &diff, slice.x as u32, y)?; + y += slice.height as u32; + } + Ok(base) + } + + fn import_image<'a>( + &'a self, + mut data: ImageData, + _filename: &str, + mut file: Box, + ) -> Result<()> { + if data.depth != 8 { + anyhow::bail!("Unsupported depth: {}", data.depth); + } + let mut header = self.header.clone(); + header.slices.clear(); + header.slices.push(Slice { + offset: 0, + size: 0, + x: 0, + height: data.height as u16, + _unk: 0, + }); + header.pack(&mut file, false, Encoding::Utf8, &None)?; + header.slices[0].offset = file.stream_position()? as u32; + if header.width != data.width as u16 || header.height != data.height as u16 { + eprintln!( + "WARNING: image size dismatched, expected {}x{}, actually {}x{}.", + header.width, header.height, data.width, data.height + ); + crate::COUNTER.inc_warning(); + header.width = data.width as u16; + header.height = data.height as u16; + } + match data.color_type { + ImageColorType::Bgr => { + convert_bgr_to_rgb(&mut data)?; + } + ImageColorType::Bgra => { + convert_bgra_to_rgba(&mut data)?; + } + ImageColorType::Rgb | ImageColorType::Rgba => {} + ImageColorType::Grayscale => { + convert_grayscale_to_rgb(&mut data)?; + } + }; + let encoder = qoi::Encoder::new(&data.data, data.width, data.height)?; + encoder.encode_to_stream(&mut file)?; + let file_size = file.stream_position()? as u32; + header.slices[0].size = file_size - header.slices[0].offset; + header.file_size = file_size; + file.seek(SeekFrom::Start(0))?; + header.pack(&mut file, false, Encoding::Utf8, &None)?; + Ok(()) + } +} diff --git a/src/scripts/yuris/mod.rs b/src/scripts/yuris/mod.rs index 3bb9b29..b8fcc48 100644 --- a/src/scripts/yuris/mod.rs +++ b/src/scripts/yuris/mod.rs @@ -1,4 +1,6 @@ //! Yu-Ris Engine Scripts +#[cfg(feature = "yuris-img")] +pub mod img; pub mod txt; mod types; pub mod yscfg; diff --git a/src/types.rs b/src/types.rs index 6e9b8ff..8139c73 100644 --- a/src/types.rs +++ b/src/types.rs @@ -926,6 +926,9 @@ pub enum ScriptType { #[cfg(feature = "yuris")] /// Yu-Ris scenario text file (.txt) YurisTxt, + #[cfg(feature = "yuris-img")] + /// YU-RIS compressed image file (.ydg) + YurisYDG, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/src/utils/img.rs b/src/utils/img.rs index 32943a4..b2ca2c9 100644 --- a/src/utils/img.rs +++ b/src/utils/img.rs @@ -525,6 +525,65 @@ pub fn load_jpg(data: R) -> Result { }) } +#[cfg(feature = "webp")] +pub fn load_webp(mut data: R) -> Result { + use std::io::Read; + let mut header = [0; 12]; + data.read_exact(&mut header)?; + if !header.starts_with(b"RIFF") || !header.ends_with(b"WEBP") { + anyhow::bail!("File is not a webp image."); + } + let file_size = u32::from_le_bytes([header[4], header[5], header[6], header[7]]); + let mut da = Vec::with_capacity(file_size as usize + 8); + da.extend_from_slice(&header); + data.take(file_size as u64 - 4).read_to_end(&mut da)?; + let decoder = webp::Decoder::new(&da); + let image = decoder + .decode() + .ok_or(anyhow::anyhow!("Failed to decode WebP image"))?; + let color_type = if image.is_alpha() { + ImageColorType::Rgba + } else { + ImageColorType::Rgb + }; + let width = image.width(); + let height = image.height(); + let stride = width as usize * color_type.bpp(8) as usize / 8; + let mut data = vec![0; stride * height as usize]; + if image.len() != data.len() { + return Err(anyhow::anyhow!( + "WebP image data size mismatch: expected {}, got {}", + data.len(), + image.len() + )); + } + data.copy_from_slice(&image); + Ok(ImageData { + width, + height, + depth: 8, + color_type, + data, + }) +} + +#[cfg(feature = "qoi")] +pub fn load_qoi(data: R) -> Result { + let mut decoder = qoi::Decoder::from_stream(data)?; + let data = decoder.decode_to_vec()?; + let header = decoder.header(); + Ok(ImageData { + width: header.width, + height: header.height, + color_type: match header.channels { + qoi::Channels::Rgb => ImageColorType::Rgb, + qoi::Channels::Rgba => ImageColorType::Rgba, + }, + depth: 8, + data, + }) +} + /// Decodes an image from the specified file path and returns its data. /// /// * `typ` - The type of the image to decode.