diff --git a/Cargo.lock b/Cargo.lock index 4ec513984..9dfc4ee77 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7995,7 +7995,7 @@ dependencies = [ "tokio-stream", "tracing", "url", - "uuid 1.16.0", + "uuid 1.17.0", "webpki-roots 0.26.11", ] @@ -8078,7 +8078,7 @@ dependencies = [ "stringprep", "thiserror 2.0.12", "tracing", - "uuid 1.16.0", + "uuid 1.17.0", "whoami", ] @@ -8118,7 +8118,7 @@ dependencies = [ "stringprep", "thiserror 2.0.12", "tracing", - "uuid 1.16.0", + "uuid 1.17.0", "whoami", ] @@ -8145,7 +8145,7 @@ dependencies = [ "thiserror 2.0.12", "tracing", "url", - "uuid 1.16.0", + "uuid 1.17.0", ] [[package]] @@ -8885,6 +8885,7 @@ dependencies = [ "async-walkdir", "async_zip", "base64 0.22.1", + "bytemuck", "bytes", "chardetng", "chrono", @@ -8907,11 +8908,13 @@ dependencies = [ "notify-debouncer-mini", "p256", "paste", + "png", "quartz_nbt", "quick-xml 0.37.5", "rand 0.8.5", "regex", "reqwest", + "rgb", "serde", "serde_ini", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 51655fb4e..b37e3da69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,6 +37,7 @@ async-tungstenite = { version = "0.29.1", default-features = false, features = [ async-walkdir = "2.1.0" base64 = "0.22.1" bitflags = "2.9.1" +bytemuck = "1.23.0" bytes = "1.10.1" censor = "0.3.0" chardetng = "0.1.17" @@ -91,6 +92,7 @@ notify = { version = "8.0.0", default-features = false } notify-debouncer-mini = { version = "0.6.0", default-features = false } p256 = "0.13.2" paste = "1.0.15" +png = "0.17.16" prometheus = "0.14.0" quartz_nbt = "0.2.9" quick-xml = "0.37.5" @@ -99,6 +101,7 @@ rand_chacha = "=0.3.1" # Locked on 0.3 until we can update rand to 0.9 redis = "=0.31.0" # Locked on 0.31 until deadpool-redis updates to 0.32 regex = "1.11.1" reqwest = { version = "0.12.19", default-features = false } +rgb = "0.8.50" rust_decimal = { version = "1.37.1", features = [ "serde-with-float", "serde-with-str", diff --git a/apps/app/src/api/minecraft_skins.rs b/apps/app/src/api/minecraft_skins.rs index 42ff5ff34..bf0e68ca5 100644 --- a/apps/app/src/api/minecraft_skins.rs +++ b/apps/app/src/api/minecraft_skins.rs @@ -12,6 +12,7 @@ pub fn init() -> tauri::plugin::TauriPlugin { equip_skin, remove_custom_skin, unequip_skin, + normalize_skin_texture, ]) .build() } @@ -80,3 +81,11 @@ pub async fn remove_custom_skin(skin: Skin) -> Result<()> { pub async fn unequip_skin() -> Result<()> { Ok(minecraft_skins::unequip_skin().await?) } + +/// `invoke('plugin:minecraft-skins|normalize_skin_texture')` +/// +/// See also: [minecraft_skins::normalize_skin_texture] +#[tauri::command] +pub async fn normalize_skin_texture(skin: &Skin) -> Result { + Ok(minecraft_skins::normalize_skin_texture(skin).await?) +} diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index 8efa68d84..4b53db978 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -23,6 +23,9 @@ quick-xml = { workspace = true, features = ["async-tokio"] } enumset.workspace = true chardetng.workspace = true encoding_rs.workspace = true +png.workspace = true +bytemuck.workspace = true +rgb.workspace = true chrono = { workspace = true, features = ["serde"] } daedalus.workspace = true @@ -48,7 +51,7 @@ async-tungstenite = { workspace = true, features = ["tokio-runtime", "tokio-rust futures = { workspace = true, features = ["async-await", "alloc"] } reqwest = { workspace = true, features = ["json", "stream", "deflate", "gzip", "brotli", "rustls-tls-webpki-roots", "charset", "http2", "macos-system-configuration", "multipart"] } tokio = { workspace = true, features = ["time", "io-util", "net", "sync", "fs", "macros", "process"] } -tokio-util = { workspace = true, features = ["compat"] } +tokio-util = { workspace = true, features = ["compat", "io", "io-util"] } async-recursion.workspace = true fs4 = { workspace = true, features = ["tokio"] } async-walkdir.workspace = true diff --git a/packages/app-lib/src/api/minecraft_skins.rs b/packages/app-lib/src/api/minecraft_skins.rs index dc1a3a922..08f0341c5 100644 --- a/packages/app-lib/src/api/minecraft_skins.rs +++ b/packages/app-lib/src/api/minecraft_skins.rs @@ -1,14 +1,10 @@ //! Theseus skin management interface -use std::{ - borrow::Cow, - sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - }, +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, }; -use base64::Engine; pub use bytes::Bytes; use data_url::DataUrl; use futures::{Stream, StreamExt, TryStreamExt, future::Either, stream}; @@ -38,6 +34,8 @@ mod assets { pub use default::DEFAULT_SKINS; } +mod png_util; + #[derive(Deserialize, Serialize, Debug)] pub struct Cape { /// An identifier for this cape, potentially unique to the owning player. @@ -202,9 +200,16 @@ pub async fn get_available_skins() -> crate::Result> { name: None, variant: custom_skin.variant, cape_id: custom_skin.cape_id, - texture: texture_blob_to_data_url( + texture: png_util::blob_to_data_url( custom_skin.texture_blob(&state.pool).await?, - ), + ) + .or_else(|| { + // Fall back to a placeholder texture if the DB somehow contains corrupt data + png_util::blob_to_data_url(include_bytes!( + "minecraft_skins/assets/default/MissingNo.png" + )) + }) + .unwrap(), source: SkinSource::Custom, is_equipped, texture_key: custom_skin.texture_key.into(), @@ -265,7 +270,7 @@ pub async fn add_and_equip_custom_skin( variant: MinecraftSkinVariant, cape_override: Option, ) -> crate::Result<()> { - let (skin_width, skin_height) = png_dimensions(&texture_blob)?; + let (skin_width, skin_height) = png_util::dimensions(&texture_blob)?; if skin_width != 64 || ![32, 64].contains(&skin_height) { return Err(ErrorKind::InvalidSkinTexture)?; } @@ -457,6 +462,17 @@ pub async fn unequip_skin() -> crate::Result<()> { Ok(()) } +/// Normalizes the texture of a Minecraft skin to the modern 64x64 format, handling +/// legacy 64x32 skins as the vanilla game client does. This function prioritizes +/// PNG encoding speed over compression density, so the resulting textures are better +/// suited for display purposes, not persistent storage or transmission. +/// +/// Returns the normalized, processed texture as a byte array in PNG format. +#[tracing::instrument] +pub async fn normalize_skin_texture(skin: &Skin) -> crate::Result { + png_util::normalize_skin_texture(skin).await +} + /// Synchronizes the equipped cape with the selected cape if necessary, taking into /// account the currently equipped cape, the default cape for the player, and if a /// cape override is provided. @@ -527,57 +543,3 @@ async fn save_current_custom_external_skin( Ok(()) } - -fn texture_blob_to_data_url(texture_blob: Vec) -> Arc { - let data = if is_png(&texture_blob) { - Cow::Owned(texture_blob) - } else { - // Fall back to a placeholder texture if the DB somehow contains corrupt data - Cow::Borrowed( - &include_bytes!("minecraft_skins/assets/default/MissingNo.png")[..], - ) - }; - - Url::parse(&format!( - "data:image/png;base64,{}", - base64::engine::general_purpose::STANDARD.encode(data) - )) - .unwrap() - .into() -} - -fn is_png(data: &[u8]) -> bool { - /// The initial 8 bytes of a PNG file, used to identify it as such. - /// - /// Reference: - const PNG_SIGNATURE: &[u8] = - &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; - - data.starts_with(PNG_SIGNATURE) -} - -fn png_dimensions(data: &[u8]) -> crate::Result<(u32, u32)> { - if !is_png(data) { - Err(ErrorKind::InvalidPng)?; - } - - // Read the width and height fields from the IHDR chunk, which the - // PNG specification mandates to be the first in the file, just after - // the 8 signature bytes. See: - // https://www.w3.org/TR/png-3/#5DataRep - // https://www.w3.org/TR/png-3/#11IHDR - let width = u32::from_be_bytes( - data.get(16..20) - .ok_or(ErrorKind::InvalidPng)? - .try_into() - .unwrap(), - ); - let height = u32::from_be_bytes( - data.get(20..24) - .ok_or(ErrorKind::InvalidPng)? - .try_into() - .unwrap(), - ); - - Ok((width, height)) -} diff --git a/packages/app-lib/src/api/minecraft_skins/assets/test/MissingNo_normalized.png b/packages/app-lib/src/api/minecraft_skins/assets/test/MissingNo_normalized.png new file mode 100644 index 000000000..639b3fe15 Binary files /dev/null and b/packages/app-lib/src/api/minecraft_skins/assets/test/MissingNo_normalized.png differ diff --git a/packages/app-lib/src/api/minecraft_skins/png_util.rs b/packages/app-lib/src/api/minecraft_skins/png_util.rs new file mode 100644 index 000000000..b4ffbac41 --- /dev/null +++ b/packages/app-lib/src/api/minecraft_skins/png_util.rs @@ -0,0 +1,298 @@ +//! Miscellaneous PNG utilities for Minecraft skins. + +use std::sync::Arc; + +use base64::Engine; +use bytemuck::{AnyBitPattern, NoUninit}; +use bytes::Bytes; +use futures::TryStreamExt; +use tokio_util::{compat::FuturesAsyncReadCompatExt, io::SyncIoBridge}; +use url::Url; + +use crate::ErrorKind; + +use super::Skin; + +pub fn blob_to_data_url(png_data: impl AsRef<[u8]>) -> Option> { + let png_data = png_data.as_ref(); + + is_png(png_data).then(|| { + Url::parse(&format!( + "data:image/png;base64,{}", + base64::engine::general_purpose::STANDARD.encode(png_data) + )) + .unwrap() + .into() + }) +} + +pub fn is_png(png_data: &[u8]) -> bool { + /// The initial 8 bytes of a PNG file, used to identify it as such. + /// + /// Reference: + const PNG_SIGNATURE: &[u8] = + &[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; + + png_data.starts_with(PNG_SIGNATURE) +} + +pub fn dimensions(png_data: &[u8]) -> crate::Result<(u32, u32)> { + if !is_png(png_data) { + Err(ErrorKind::InvalidPng)?; + } + + // Read the width and height fields from the IHDR chunk, which the + // PNG specification mandates to be the first in the file, just after + // the 8 signature bytes. See: + // https://www.w3.org/TR/png-3/#5DataRep + // https://www.w3.org/TR/png-3/#11IHDR + let width = u32::from_be_bytes( + png_data + .get(16..20) + .ok_or(ErrorKind::InvalidPng)? + .try_into() + .unwrap(), + ); + let height = u32::from_be_bytes( + png_data + .get(20..24) + .ok_or(ErrorKind::InvalidPng)? + .try_into() + .unwrap(), + ); + + Ok((width, height)) +} + +/// Normalizes the texture of a Minecraft skin to the modern 64x64 format, handling +/// legacy 64x32 skins as the vanilla game client does. This function prioritizes +/// PNG encoding speed over compression density, so the resulting textures are better +/// suited for display purposes, not persistent storage or transmission. +/// +/// Returns the normalized, processed texture as a byte array in PNG format. +pub async fn normalize_skin_texture(skin: &Skin) -> crate::Result { + let texture_stream = SyncIoBridge::new(Box::pin( + skin.resolve_texture() + .await? + .map_err(std::io::Error::other) + .into_async_read() + .compat(), + )); + + tokio::task::spawn_blocking(|| { + let mut png_reader = { + let mut decoder = png::Decoder::new(texture_stream); + decoder.set_transformations( + png::Transformations::normalize_to_color8(), + ); + decoder.read_info() + }?; + + // The code below assumes that the skin texture has valid dimensions. + // This also serves as a way to bail out early for obviously invalid or + // adversarial textures + if png_reader.info().width != 64 + || ![64, 32].contains(&png_reader.info().height) + { + Err(ErrorKind::InvalidSkinTexture)?; + } + + let is_legacy_skin = png_reader.info().height == 32; + + let mut texture_buf = if is_legacy_skin { + // Legacy skins have half the height, so duplicate the rows to + // turn them into a 64x64 texture + vec![0; png_reader.output_buffer_size() * 2] + } else { + // Modern skins are left as-is + vec![0; png_reader.output_buffer_size()] + }; + + let texture_buf_color_type = png_reader.output_color_type().0; + png_reader.next_frame(&mut texture_buf)?; + + if is_legacy_skin { + convert_legacy_skin_texture( + &mut texture_buf, + texture_buf_color_type, + png_reader.info(), + )?; + } + + let mut encoded_png = vec![]; + + let mut png_encoder = png::Encoder::new(&mut encoded_png, 64, 64); + png_encoder.set_color(texture_buf_color_type); + png_encoder.set_depth(png::BitDepth::Eight); + png_encoder.set_filter(png::FilterType::NoFilter); + png_encoder.set_compression(png::Compression::Fast); + + // Keeping color space information properly set, to handle the occasional + // strange PNG with non-sRGB chromacities and/or different grayscale spaces + // that keeps most people wondering, is what sets a carefully crafted image + // manipulation routine apart :) + if let Some(source_chromacities) = + png_reader.info().source_chromaticities.as_ref().cloned() + { + png_encoder.set_source_chromaticities(source_chromacities); + } + if let Some(source_gamma) = + png_reader.info().source_gamma.as_ref().cloned() + { + png_encoder.set_source_gamma(source_gamma); + } + if let Some(source_srgb) = png_reader.info().srgb.as_ref().cloned() { + png_encoder.set_source_srgb(source_srgb); + } + + let mut png_writer = png_encoder.write_header()?; + png_writer.write_image_data(&texture_buf)?; + png_writer.finish()?; + + Ok(encoded_png.into()) + }) + .await? +} + +/// Converts a legacy skin texture (32x64 pixels) within a 64x64 buffer to the +/// native 64x64 format used by modern Minecraft clients. +/// +/// See also 25w16a's `SkinTextureDownloader#processLegacySkin` method. +#[inline] +fn convert_legacy_skin_texture( + texture_buf: &mut [u8], + texture_color_type: png::ColorType, + texture_info: &png::Info, +) -> crate::Result<()> { + /// The skin faces the game client copies around, in order, when converting a + /// legacy skin to the native 64x64 format. + const FACE_COPY_PARAMETERS: &[( + usize, + usize, + isize, + isize, + usize, + usize, + )] = &[ + (4, 16, 16, 32, 4, 4), + (8, 16, 16, 32, 4, 4), + (0, 20, 24, 32, 4, 12), + (4, 20, 16, 32, 4, 12), + (8, 20, 8, 32, 4, 12), + (12, 20, 16, 32, 4, 12), + (44, 16, -8, 32, 4, 4), + (48, 16, -8, 32, 4, 4), + (40, 20, 0, 32, 4, 12), + (44, 20, -8, 32, 4, 12), + (48, 20, -16, 32, 4, 12), + (52, 20, -8, 32, 4, 12), + ]; + + for (x, y, off_x, off_y, width, height) in FACE_COPY_PARAMETERS { + macro_rules! do_copy { + ($pixel_type:ty) => { + copy_rect_mirror_horizontally::<$pixel_type>( + // This cast should never fail because all pixels have a depth of 8 bits + // after the transformations applied during decoding + ::bytemuck::try_cast_slice_mut(texture_buf).map_err(|_| ErrorKind::InvalidPng)?, + &texture_info, + *x, + *y, + *off_x, + *off_y, + *width, + *height, + ) + }; + } + + match texture_color_type.samples() { + 1 => do_copy!(rgb::Gray), + 2 => do_copy!(rgb::GrayAlpha), + 3 => do_copy!(rgb::Rgb), + 4 => do_copy!(rgb::Rgba), + _ => Err(ErrorKind::InvalidPng)?, // Cannot happen by PNG spec after transformations + }; + } + + Ok(()) +} + +/// Copies a `width` pixels wide, `height` pixels tall rectangle of pixels within `texture_buf` +/// whose top-left corner is at coordinates `(x, y)` to a destination rectangle whose top-left +/// corner is at coordinates `(x + off_x, y + off_y)`, while mirroring (i.e., flipping) the +/// pixels horizontally. +/// +/// Equivalent to Mojang's Blaze3D `NativeImage#copyRect(int, int, int, int, int, int, +/// boolean, boolean)` method, but with the last two parameters fixed to `true` and `false`, +/// respectively. +fn copy_rect_mirror_horizontally( + texture_buf: &mut [PixelType], + texture_info: &png::Info, + x: usize, + y: usize, + off_x: isize, + off_y: isize, + width: usize, + height: usize, +) { + for row in 0..height { + for col in 0..width { + let src_x = x + col; + let src_y = y + row; + let dst_x = (x as isize + off_x) as usize + (width - 1 - col); + let dst_y = (y as isize + off_y) as usize + row; + + texture_buf[dst_x + dst_y * texture_info.width as usize] = + texture_buf[src_x + src_y * texture_info.width as usize]; + } + } +} + +#[cfg(test)] +#[tokio::test] +async fn normalize_skin_texture_works() { + use crate::{minecraft_skins::SkinSource, state::MinecraftSkinVariant}; + + let legacy_png_data = &include_bytes!("assets/default/MissingNo.png")[..]; + let expected_normalized_png_data = + &include_bytes!("assets/test/MissingNo_normalized.png")[..]; + + let normalized_png_data = normalize_skin_texture(&Skin { + texture_key: "missingno".into(), + name: None, + variant: MinecraftSkinVariant::Classic, + cape_id: None, + texture: blob_to_data_url(legacy_png_data).unwrap(), + source: SkinSource::Default, + is_equipped: false, + }) + .await + .expect("Failed to normalize skin texture"); + + let decode_to_pixels = |png_data: &[u8]| { + let decoder = png::Decoder::new(png_data); + let mut reader = decoder.read_info().expect("Failed to read PNG info"); + let mut buffer = vec![0; reader.output_buffer_size()]; + reader + .next_frame(&mut buffer) + .expect("Failed to decode PNG"); + (buffer, reader.info().clone()) + }; + + let (normalized_pixels, normalized_info) = + decode_to_pixels(&normalized_png_data); + let (expected_pixels, expected_info) = + decode_to_pixels(expected_normalized_png_data); + + // Check that dimensions match + assert_eq!(normalized_info.width, expected_info.width); + assert_eq!(normalized_info.height, expected_info.height); + assert_eq!(normalized_info.color_type, expected_info.color_type); + + // Check that pixel data matches + assert_eq!( + normalized_pixels, expected_pixels, + "Pixel data doesn't match" + ); +} diff --git a/packages/app-lib/src/error.rs b/packages/app-lib/src/error.rs index d38c10c3f..75c144f55 100644 --- a/packages/app-lib/src/error.rs +++ b/packages/app-lib/src/error.rs @@ -141,6 +141,12 @@ pub enum ErrorKind { #[error("Invalid PNG")] InvalidPng, + #[error("Invalid PNG: {0}")] + PngDecodingError(#[from] png::DecodingError), + + #[error("PNG encoding error: {0}")] + PngEncodingError(#[from] png::EncodingError), + #[error( "A skin texture must have a dimension of either 64x64 or 64x32 pixels" )]