Merge commit '7fa442fb28a2b9156690ff147206275163e7aec8' into beta

This commit is contained in:
2025-10-19 06:50:50 +03:00
1007 changed files with 143497 additions and 11362 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 435 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 409 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 934 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -1,12 +1,14 @@
//! Miscellaneous PNG utilities for Minecraft skins.
use std::io::Read;
use std::sync::Arc;
use base64::Engine;
use bytemuck::{AnyBitPattern, NoUninit};
use bytes::Bytes;
use data_url::DataUrl;
use futures::{Stream, TryStreamExt, future::Either, stream};
use itertools::Itertools;
use rgb::Rgba;
use tokio_util::{compat::FuturesAsyncReadCompatExt, io::SyncIoBridge};
use url::Url;
@@ -84,10 +86,10 @@ pub fn dimensions(png_data: &[u8]) -> crate::Result<(u32, u32)> {
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.
/// Normalizes the texture of a Minecraft skin to the modern 64x64 format, handling legacy 64x32
/// skins, doing "Notch transparency hack" and making inner parts opaque 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.
///
/// The normalized, processed is returned texture as a byte array in PNG format.
pub async fn normalize_skin_texture(
@@ -131,43 +133,30 @@ pub async fn normalize_skin_texture(
}
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)?;
let mut texture_buf =
get_skin_texture_buffer(&mut png_reader, is_legacy_skin)?;
if is_legacy_skin {
convert_legacy_skin_texture(
&mut texture_buf,
texture_buf_color_type,
png_reader.info(),
)?;
convert_legacy_skin_texture(&mut texture_buf, png_reader.info());
do_notch_transparency_hack(&mut texture_buf, png_reader.info());
}
make_inner_parts_opaque(&mut texture_buf, 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_color(png::ColorType::Rgba);
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
// strange PNG with non-sRGB chromaticities 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) =
if let Some(source_chromaticities) =
png_reader.info().source_chromaticities.as_ref().copied()
{
png_encoder.set_source_chromaticities(source_chromacities);
png_encoder.set_source_chromaticities(source_chromaticities);
}
if let Some(source_gamma) =
png_reader.info().source_gamma.as_ref().copied()
@@ -178,8 +167,10 @@ pub async fn normalize_skin_texture(
png_encoder.set_source_srgb(source_srgb);
}
let png_buf = bytemuck::try_cast_slice(&texture_buf)
.map_err(|_| ErrorKind::InvalidPng)?;
let mut png_writer = png_encoder.write_header()?;
png_writer.write_image_data(&texture_buf)?;
png_writer.write_image_data(png_buf)?;
png_writer.finish()?;
Ok(encoded_png.into())
@@ -187,16 +178,71 @@ pub async fn normalize_skin_texture(
.await?
}
/// Reads a skin texture and returns a 64x64 buffer in RGBA format.
fn get_skin_texture_buffer<R: Read>(
png_reader: &mut png::Reader<R>,
is_legacy_skin: bool,
) -> crate::Result<Vec<Rgba<u8>>> {
let mut png_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()]
};
png_reader.next_frame(&mut png_buf)?;
let mut texture_buf = match png_reader.output_color_type().0 {
png::ColorType::Grayscale => png_buf
.iter()
.map(|&value| Rgba {
r: value,
g: value,
b: value,
a: 255,
})
.collect_vec(),
png::ColorType::GrayscaleAlpha => png_buf
.chunks_exact(2)
.map(|chunk| Rgba {
r: chunk[0],
g: chunk[0],
b: chunk[0],
a: chunk[1],
})
.collect_vec(),
png::ColorType::Rgb => png_buf
.chunks_exact(3)
.map(|chunk| Rgba {
r: chunk[0],
g: chunk[1],
b: chunk[2],
a: 255,
})
.collect_vec(),
png::ColorType::Rgba => bytemuck::try_cast_vec(png_buf)
.map_err(|_| ErrorKind::InvalidPng)?,
_ => Err(ErrorKind::InvalidPng)?, // Cannot happen by PNG spec after transformations
};
// Make the added bottom half of the expanded legacy skin buffer transparent
if is_legacy_skin {
set_alpha(&mut texture_buf, png_reader.info(), 0, 32, 64, 64, 0);
}
Ok(texture_buf)
}
/// 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_buf: &mut [Rgba<u8, u8>],
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: &[(
@@ -222,33 +268,55 @@ fn convert_legacy_skin_texture(
];
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,
)
};
}
copy_rect_mirror_horizontally(
texture_buf,
texture_info,
*x,
*y,
*off_x,
*off_y,
*width,
*height,
)
}
}
match texture_color_type.samples() {
1 => do_copy!(rgb::Gray<u8>),
2 => do_copy!(rgb::GrayAlpha<u8>),
3 => do_copy!(rgb::Rgb<u8>),
4 => do_copy!(rgb::Rgba<u8>),
_ => Err(ErrorKind::InvalidPng)?, // Cannot happen by PNG spec after transformations
};
/// Makes outer head layer transparent if every pixel has alpha greater or equal to 128.
///
/// See also 25w16a's `SkinTextureDownloader#doNotchTransparencyHack` method.
fn do_notch_transparency_hack(
texture_buf: &mut [Rgba<u8, u8>],
texture_info: &png::Info,
) {
// The skin part the game client makes transparent
let (x1, y1, x2, y2) = (32, 0, 64, 32);
for y in y1..y2 {
for x in x1..x2 {
if texture_buf[x + y * texture_info.width as usize].a < 128 {
return;
}
}
}
Ok(())
set_alpha(texture_buf, texture_info, x1, y1, x2, y2, 0);
}
/// Makes inner parts of a skin texture opaque.
///
/// See also 25w16a's `SkinTextureDownloader#processLegacySkin` method.
#[inline]
fn make_inner_parts_opaque(
texture_buf: &mut [Rgba<u8, u8>],
texture_info: &png::Info,
) {
/// The skin parts the game client makes opaque.
const OPAQUE_PART_PARAMETERS: &[(usize, usize, usize, usize)] =
&[(0, 0, 32, 16), (0, 16, 64, 32), (16, 48, 48, 64)];
for (x1, y1, x2, y2) in OPAQUE_PART_PARAMETERS {
set_alpha(texture_buf, texture_info, *x1, *y1, *x2, *y2, 255);
}
}
/// Copies a `width` pixels wide, `height` pixels tall rectangle of pixels within `texture_buf`
@@ -260,8 +328,8 @@ fn convert_legacy_skin_texture(
/// boolean, boolean)` method, but with the last two parameters fixed to `true` and `false`,
/// respectively.
#[allow(clippy::too_many_arguments)]
fn copy_rect_mirror_horizontally<PixelType: NoUninit + AnyBitPattern>(
texture_buf: &mut [PixelType],
fn copy_rect_mirror_horizontally(
texture_buf: &mut [Rgba<u8, u8>],
texture_info: &png::Info,
x: usize,
y: usize,
@@ -283,18 +351,27 @@ fn copy_rect_mirror_horizontally<PixelType: NoUninit + AnyBitPattern>(
}
}
/// Sets alpha for every pixel of a rectangle within `texture_buf`
/// whose top-left corner is at `(x1, y1)` and bottom-right corner is at `(x2 - 1, y2 - 1)`.
fn set_alpha(
texture_buf: &mut [Rgba<u8, u8>],
texture_info: &png::Info,
x1: usize,
y1: usize,
x2: usize,
y2: usize,
alpha: u8,
) {
for y in y1..y2 {
for x in x1..x2 {
texture_buf[x + y * texture_info.width as usize].a = alpha;
}
}
}
#[cfg(test)]
#[tokio::test]
async fn normalize_skin_texture_works() {
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(&UrlOrBlob::Blob(legacy_png_data.into()))
.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");
@@ -305,19 +382,55 @@ async fn normalize_skin_texture_works() {
(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);
let test_data = [
(
"legacy",
&include_bytes!("assets/test/legacy.png")[..],
&include_bytes!("assets/test/legacy_normalized.png")[..],
),
(
"notch",
&include_bytes!("assets/test/notch.png")[..],
&include_bytes!("assets/test/notch_normalized.png")[..],
),
(
"transparent",
&include_bytes!("assets/test/transparent.png")[..],
&include_bytes!("assets/test/transparent_normalized.png")[..],
),
];
// 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);
for (skin_name, original_png_data, expected_normalized_png_data) in
test_data
{
let normalized_png_data =
normalize_skin_texture(&UrlOrBlob::Blob(original_png_data.into()))
.await
.expect("Failed to normalize skin texture");
// Check that pixel data matches
assert_eq!(
normalized_pixels, expected_pixels,
"Pixel data doesn't match"
);
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,
"Widths don't match for {skin_name}"
);
assert_eq!(
normalized_info.height, expected_info.height,
"Heights don't match for {skin_name}"
);
assert_eq!(
normalized_info.color_type, expected_info.color_type,
"Color types don't match for {skin_name}"
);
// Check that pixel data matches
assert_eq!(
normalized_pixels, expected_pixels,
"Pixel data doesn't match for {skin_name}"
);
}
}

View File

@@ -36,6 +36,9 @@ pub mod prelude {
jre, metadata, minecraft_auth, mr_auth, pack, process,
profile::{self, Profile, create},
settings,
util::io::{IOError, canonicalize},
util::{
io::{IOError, canonicalize},
network::{is_network_metered, tcp_listen_any_loopback},
},
};
}

View File

@@ -6,6 +6,7 @@ use crate::state::{CachedEntry, LinkedData, ProfileInstallStage, SideType};
use crate::util::fetch::{fetch, fetch_advanced, write_cached_icon};
use crate::util::io;
use path_util::SafeRelativeUtf8UnixPathBuf;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -27,7 +28,7 @@ pub struct PackFormat {
#[derive(Serialize, Deserialize, Eq, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct PackFile {
pub path: String,
pub path: SafeRelativeUtf8UnixPathBuf,
pub hashes: HashMap<PackFileHash, String>,
pub env: Option<HashMap<EnvType, SideType>>,
pub downloads: Vec<String>,

View File

@@ -18,8 +18,8 @@ use super::install_from::{
generate_pack_from_version_id,
};
use crate::data::ProjectType;
use std::io::Cursor;
use std::path::{Component, PathBuf};
use std::io::{Cursor, ErrorKind};
use std::path::PathBuf;
/// Install a pack
/// Wrapper around install_pack_files that generates a pack creation description, and
@@ -169,31 +169,22 @@ pub async fn install_zipped_mrpack_files(
)
.await?;
let project_path = project.path.to_string();
let path = profile::get_full_path(&profile_path)
.await?
.join(project.path.as_str());
let path =
std::path::Path::new(&project_path).components().next();
if let Some(Component::CurDir | Component::Normal(_)) = path
{
let path = profile::get_full_path(&profile_path)
.await?
.join(&project_path);
cache_file_hash(
file.clone(),
&profile_path,
project.path.as_str(),
project.hashes.get(&PackFileHash::Sha1).map(|x| &**x),
ProjectType::get_from_parent_folder(&path),
&state.pool,
)
.await?;
cache_file_hash(
file.clone(),
&profile_path,
&project_path,
project
.hashes
.get(&PackFileHash::Sha1)
.map(|x| &**x),
ProjectType::get_from_parent_folder(&path),
&state.pool,
)
.await?;
write(&path, &file, &state.io_semaphore).await?;
write(&path, &file, &state.io_semaphore).await?;
}
Ok(())
}
},
@@ -377,9 +368,10 @@ pub async fn remove_all_related_files(
if let Some(metadata) = &project.metadata
&& to_remove.contains(&metadata.project_id)
{
let path = profile_full_path.join(file_path);
if path.exists() {
io::remove_file(&path).await?;
match io::remove_file(profile_full_path.join(file_path)).await {
Ok(_) => (),
Err(err) if err.kind() == ErrorKind::NotFound => (),
Err(err) => return Err(err.into()),
}
}
}
@@ -387,9 +379,12 @@ pub async fn remove_all_related_files(
// Iterate over all Modrinth project file paths in the json, and remove them
// (There should be few, but this removes any files the .mrpack intended as Modrinth projects but were unrecognized)
for file in pack.files {
let path: PathBuf = profile_full_path.join(file.path);
if path.exists() {
io::remove_file(&path).await?;
match io::remove_file(profile_full_path.join(file.path.as_str()))
.await
{
Ok(_) => (),
Err(err) if err.kind() == ErrorKind::NotFound => (),
Err(err) => return Err(err.into()),
}
}
@@ -412,11 +407,16 @@ pub async fn remove_all_related_files(
}
// Remove this file if a corresponding one exists in the filesystem
let existing_file = profile::get_full_path(&profile_path)
.await?
.join(&new_path);
if existing_file.exists() {
io::remove_file(&existing_file).await?;
match io::remove_file(
profile::get_full_path(&profile_path)
.await?
.join(&new_path),
)
.await
{
Ok(_) => (),
Err(err) if err.kind() == ErrorKind::NotFound => (),
Err(err) => return Err(err.into()),
}
}
}

View File

@@ -18,6 +18,7 @@ use crate::util::io::{self, IOError};
pub use crate::{State, state::Profile};
use async_zip::tokio::write::ZipFileWriter;
use async_zip::{Compression, ZipEntryBuilder};
use path_util::SafeRelativeUtf8UnixPathBuf;
use serde_json::json;
use std::collections::{HashMap, HashSet};
@@ -497,11 +498,12 @@ pub async fn export_mrpack(
let version_id = version_id.unwrap_or("1.0.0".to_string());
let mut packfile =
create_mrpack_json(&profile, version_id, description).await?;
let included_candidates_set =
HashSet::<_>::from_iter(included_export_candidates.iter());
let included_candidates_set = HashSet::<_>::from_iter(
included_export_candidates.iter().map(|x| x.as_str()),
);
packfile
.files
.retain(|f| included_candidates_set.contains(&f.path));
.retain(|f| included_candidates_set.contains(f.path.as_str()));
// Build vec of all files in the folder
let mut path_list = Vec::new();
@@ -575,8 +577,8 @@ pub async fn export_mrpack(
#[tracing::instrument]
pub async fn get_pack_export_candidates(
profile_path: &str,
) -> crate::Result<Vec<String>> {
let mut path_list: Vec<String> = Vec::new();
) -> crate::Result<Vec<SafeRelativeUtf8UnixPathBuf>> {
let mut path_list = Vec::new();
let profile_base_dir = get_full_path(profile_path).await?;
let mut read_dir = io::read_dir(&profile_base_dir).await?;
@@ -610,18 +612,19 @@ pub async fn get_pack_export_candidates(
fn pack_get_relative_path(
profile_path: &PathBuf,
path: &PathBuf,
) -> crate::Result<String> {
Ok(path
.strip_prefix(profile_path)
.map_err(|_| {
crate::ErrorKind::FSError(format!(
"Path {path:?} does not correspond to a profile"
))
})?
.components()
.map(|c| c.as_os_str().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join("/"))
) -> crate::Result<SafeRelativeUtf8UnixPathBuf> {
Ok(SafeRelativeUtf8UnixPathBuf::try_from(
path.strip_prefix(profile_path)
.map_err(|_| {
crate::ErrorKind::FSError(format!(
"Path {path:?} does not correspond to a profile"
))
})?
.components()
.map(|c| c.as_os_str().to_string_lossy())
.collect::<Vec<_>>()
.join("/"),
)?)
}
/// Run Minecraft using a profile and the default credentials, logged in credentials,
@@ -896,7 +899,15 @@ pub async fn create_mrpack_json(
.collect();
Some(Ok(PackFile {
path,
path: match path.try_into() {
Ok(path) => path,
Err(_) => {
return Some(Err(crate::ErrorKind::OtherError(
"Invalid file path in project".into(),
)
.as_error()));
}
},
hashes,
env: Some(env),
downloads,

View File

@@ -120,11 +120,11 @@ fn parse_server_address_inner(
let mut port = None;
if !port_str.is_empty() {
if port_str.starts_with('+') {
return Err(format!("Unparseable port number: {port_str}"));
return Err(format!("Unparsable port number: {port_str}"));
}
port = port_str.parse::<u16>().ok();
if port.is_none() {
return Err(format!("Unparseable port number: {port_str}"));
return Err(format!("Unparsable port number: {port_str}"));
}
}