mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge upstream main into build-time native API generation
This commit is contained in:
commit
5e6d499849
96 files changed
+5390
-207
No files matched your search
Generated
+40
@@ -329,6 +329,12 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "byteorder-lite"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
@@ -1473,6 +1479,7 @@ dependencies = [
|
||||
"horizon-theme",
|
||||
"horizon-threads",
|
||||
"horizon-world",
|
||||
"image-webp",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
"miniz_oxide 0.8.9",
|
||||
@@ -1491,6 +1498,7 @@ dependencies = [
|
||||
"wgpu",
|
||||
"winit",
|
||||
"zip",
|
||||
"zune-jpeg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1667,6 +1675,7 @@ dependencies = [
|
||||
"horizon-core",
|
||||
"horizon-world",
|
||||
"ipconfig",
|
||||
"log",
|
||||
"serde_json",
|
||||
"sha1",
|
||||
"sha2",
|
||||
@@ -1936,6 +1945,16 @@ dependencies = [
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "image-webp"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
|
||||
dependencies = [
|
||||
"byteorder-lite",
|
||||
"quick-error",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
@@ -3157,6 +3176,12 @@ version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5"
|
||||
|
||||
[[package]]
|
||||
name = "quick-error"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.41.0"
|
||||
@@ -5337,3 +5362,18 @@ dependencies = [
|
||||
"log",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zune-core"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a"
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.4.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
@@ -43,6 +43,10 @@ miniz_oxide = "0.8"
|
||||
naga = { version = "30.0.1", features = ["wgsl-in", "glsl-in", "hlsl-out"] }
|
||||
half = "2"
|
||||
png = "0.18"
|
||||
# Project icons served by Modrinth are often WebP or JPEG rather than PNG, and
|
||||
# the pack browser showed nothing for those. Both are small pure-Rust decoders.
|
||||
image-webp = { version = "0.2", default-features = false }
|
||||
zune-jpeg = { version = "0.4", default-features = false, features = ["std"] }
|
||||
pollster = "0.4"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = { version = "1.0.151", features = ["raw_value"] }
|
||||
|
||||
@@ -60,6 +60,10 @@ impl Corner {
|
||||
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Extras {
|
||||
/// The Select Resource Packs screen's Add button, which browses
|
||||
/// Modrinth. Off by default: it is Horizon's own and the screen is
|
||||
/// otherwise vanilla's (tools/test_pack_screen_parity.py).
|
||||
pub pack_browser: bool,
|
||||
pub fullbright: bool,
|
||||
/// Held, dropped and burning light sources light their surroundings (`dynamic_light`).
|
||||
pub dynamic_lights: bool,
|
||||
@@ -154,6 +158,7 @@ pub struct Extras {
|
||||
impl Default for Extras {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
pack_browser: false,
|
||||
fullbright: false,
|
||||
dynamic_lights: false,
|
||||
zoom: false,
|
||||
@@ -222,6 +227,7 @@ impl Extras {
|
||||
_ => d,
|
||||
};
|
||||
match key.trim() {
|
||||
"packBrowser" => e.pack_browser = flag(e.pack_browser),
|
||||
"fullbright" => e.fullbright = flag(e.fullbright),
|
||||
"dynamicLights" => e.dynamic_lights = flag(e.dynamic_lights),
|
||||
"zoom" => e.zoom = flag(e.zoom),
|
||||
@@ -300,7 +306,8 @@ impl Extras {
|
||||
}
|
||||
pub fn serialize(&self) -> String {
|
||||
format!(
|
||||
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\n",
|
||||
"packBrowser:{}\nfullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nuiSoundReserve:{}\nownNameTag:{}\nhideBlockParticles:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\nchatAnimation:{}\nchatIndicator:{}\n{}:{}\nagentControl:{}\nreproKeys:{}\ndiscordPresence:{}\nsplitscreen:{}\nsplitscreenTwo:{}\nsplitscreenThree:{}\nsplitscreenNames:{}\nwheelCyclesSettings:{}\nmusicOff:{}\nmusicPage:{}\ncosmeticsMine:{}\ncosmeticsOthers:{}\n",
|
||||
bool_text(self.pack_browser),
|
||||
bool_text(self.fullbright),
|
||||
bool_text(self.dynamic_lights),
|
||||
bool_text(self.zoom),
|
||||
@@ -496,6 +503,8 @@ pub enum Item {
|
||||
/// On the Splitscreen settings page.
|
||||
Splitscreen,
|
||||
WheelCyclesSettings,
|
||||
/// The Select Resource Packs screen's Add button (Modrinth).
|
||||
PackBrowser,
|
||||
/// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`).
|
||||
CosmeticsMine,
|
||||
CosmeticsOthers,
|
||||
@@ -526,6 +535,7 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
|
||||
Ok(MusicPage),
|
||||
Err("horizon.extras.header.menus"),
|
||||
Ok(WheelCyclesSettings),
|
||||
Ok(PackBrowser),
|
||||
];
|
||||
if horizon_cosmetics::enabled() {
|
||||
rows.extend([
|
||||
@@ -559,6 +569,7 @@ impl Item {
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator",
|
||||
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
|
||||
Item::PackBrowser => "horizon.extras.pack_browser",
|
||||
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
|
||||
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
|
||||
}
|
||||
@@ -585,6 +596,7 @@ impl Item {
|
||||
Item::ChatAnimation => "horizon.extras.chat_animation.tooltip",
|
||||
Item::ChatIndicator => "horizon.extras.chat_indicator.tooltip",
|
||||
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings.tooltip",
|
||||
Item::PackBrowser => "horizon.extras.pack_browser.tooltip",
|
||||
Item::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
|
||||
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
|
||||
}
|
||||
@@ -620,6 +632,7 @@ impl Item {
|
||||
Item::DiscordPresence => "Discord Rich Presence",
|
||||
Item::Splitscreen => "Splitscreen",
|
||||
Item::WheelCyclesSettings => "Scroll wheel changes settings",
|
||||
Item::PackBrowser => "Resource pack browser",
|
||||
Item::CosmeticsMine => "My cosmetics",
|
||||
Item::CosmeticsOthers => "Other players' cosmetics",
|
||||
}
|
||||
@@ -634,7 +647,8 @@ impl Item {
|
||||
ChatIndicator => ChatAnimation,
|
||||
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
|
||||
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
|
||||
| ChatAnimation | WheelCyclesSettings | MusicPage | CosmeticsMine | CosmeticsOthers => {
|
||||
| ChatAnimation | WheelCyclesSettings | MusicPage | PackBrowser | CosmeticsMine
|
||||
| CosmeticsOthers => {
|
||||
return None;
|
||||
}
|
||||
})
|
||||
@@ -701,6 +715,9 @@ impl Item {
|
||||
Item::WheelCyclesSettings => {
|
||||
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
|
||||
}
|
||||
Item::PackBrowser => {
|
||||
"Select Resource Packs gets an Add button that searches Modrinth and installs a pack into your packs folder. Off, the game never contacts Modrinth."
|
||||
}
|
||||
}
|
||||
}
|
||||
/// The frame-time cost the main switch of an extra shows.
|
||||
@@ -718,7 +735,8 @@ impl Item {
|
||||
| Item::MouseTweaks
|
||||
| Item::Sort
|
||||
| Item::Splitscreen
|
||||
| Item::WheelCyclesSettings => Some(Cost::None),
|
||||
| Item::WheelCyclesSettings
|
||||
| Item::PackBrowser => Some(Cost::None),
|
||||
Item::CosmeticsMine | Item::CosmeticsOthers => Some(Cost::Low),
|
||||
_ => None,
|
||||
}
|
||||
@@ -744,6 +762,7 @@ impl Item {
|
||||
Item::DiscordPresence => e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings,
|
||||
Item::PackBrowser => e.pack_browser,
|
||||
Item::CosmeticsMine => e.cosmetics_mine,
|
||||
Item::CosmeticsOthers => e.cosmetics_others,
|
||||
_ => return None,
|
||||
@@ -781,6 +800,7 @@ impl Item {
|
||||
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
|
||||
Item::Splitscreen => e.splitscreen = !e.splitscreen,
|
||||
Item::WheelCyclesSettings => e.wheel_cycles_settings = !e.wheel_cycles_settings,
|
||||
Item::PackBrowser => e.pack_browser = !e.pack_browser,
|
||||
Item::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
|
||||
Item::CosmeticsOthers => e.cosmetics_others = !e.cosmetics_others,
|
||||
Item::SortKey => {}
|
||||
|
||||
@@ -947,6 +947,218 @@ mod initial_history_tests {
|
||||
assert!(float(&u, "wetness")[0] > 0.0);
|
||||
assert!(u.eye_smooth.iter().all(|v| *v > 0.0));
|
||||
}
|
||||
/// Actual provider bytes through translated GLSL and a GPU pixel, with a nonidentity
|
||||
/// model-view/projection and a distant camera. Geometry is already camera-relative.
|
||||
#[test]
|
||||
#[ignore = "requires a host GPU; run with --ignored --nocapture"]
|
||||
fn core_matrix_inputs_render_provider_values() {
|
||||
use horizon_shaders::{
|
||||
GbuffersInputs, Macros, Pack, Target, preprocess, translate_gbuffers,
|
||||
};
|
||||
use std::io::Write;
|
||||
use wgpu::util::DeviceExt;
|
||||
let instance = wgpu::Instance::new(wgpu::InstanceDescriptor::new_without_display_handle());
|
||||
let adapter = pollster::block_on(instance.request_adapter(&Default::default())).unwrap();
|
||||
eprintln!("Core matrix GPU: {:?}", adapter.get_info());
|
||||
let (device, queue) =
|
||||
pollster::block_on(adapter.request_device(&Default::default())).unwrap();
|
||||
let hud = HudBatch::new([800, 400]);
|
||||
let mut f = frame(&hud);
|
||||
f.camera = DVec3::new(1024.25, 100.5, -2048.75);
|
||||
f.yaw = 180.0;
|
||||
f.pitch = 0.0;
|
||||
f.fov_degrees = 90.0;
|
||||
f.bob = Mat4::from_translation(Vec3::new(0.5, -0.25, 0.0));
|
||||
let mut values = FrameUniforms::default();
|
||||
values.update(&f, &PackInputs::default(), [800, 400]);
|
||||
for (declaration, setup, projection, view, expected) in [
|
||||
(
|
||||
"",
|
||||
"",
|
||||
"projectionMatrix",
|
||||
"modelViewMatrix",
|
||||
[64u8, 64, 128, 255],
|
||||
),
|
||||
(
|
||||
"uniform mat4 projectionMatrix, modelViewMatrix;",
|
||||
"",
|
||||
"projectionMatrix",
|
||||
"modelViewMatrix",
|
||||
[64, 64, 128, 255],
|
||||
),
|
||||
(
|
||||
"",
|
||||
"",
|
||||
"gl_ProjectionMatrix",
|
||||
"gl_ModelViewMatrix",
|
||||
[64, 64, 128, 255],
|
||||
),
|
||||
(
|
||||
"",
|
||||
"mat4 projectionMatrix = mat4(2.0); mat4 modelViewMatrix = mat4(3.0);",
|
||||
"projectionMatrix",
|
||||
"modelViewMatrix",
|
||||
[128, 191, 255, 255],
|
||||
),
|
||||
(
|
||||
"mat4 projectionMatrix = mat4(2.0), modelViewMatrix = mat4(3.0);",
|
||||
"",
|
||||
"projectionMatrix",
|
||||
"modelViewMatrix",
|
||||
[128, 191, 255, 255],
|
||||
),
|
||||
] {
|
||||
let vertex = format!(
|
||||
"#version 330 compatibility\n{declaration}\nout vec3 result;\n\
|
||||
void main() {{\n{setup}\n\
|
||||
vec4 q = {projection} * {view} * vec4(0.5, 0.75, -2.0, 1.0);\n\
|
||||
result = vec3(q.xy / q.w, q.w / 4.0);\n\
|
||||
vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);\n\
|
||||
gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);\n}}"
|
||||
);
|
||||
let mut zip = zip::ZipWriter::new(std::io::Cursor::new(Vec::new()));
|
||||
for (path, source) in [
|
||||
("shaders/gbuffers_line.vsh", vertex.as_str()),
|
||||
(
|
||||
"shaders/gbuffers_line.fsh",
|
||||
"#version 330\nin vec3 result; out vec4 color; void main() { color = vec4(result, 1.0); }",
|
||||
),
|
||||
] {
|
||||
zip.start_file(path, zip::write::SimpleFileOptions::default())
|
||||
.unwrap();
|
||||
zip.write_all(source.as_bytes()).unwrap();
|
||||
}
|
||||
let pack = Pack::from_zip_bytes("matrix-probe.zip", zip.finish().unwrap().into_inner())
|
||||
.unwrap();
|
||||
let macros = Macros::standard();
|
||||
let program = translate_gbuffers(
|
||||
&preprocess(&pack, "gbuffers_line.vsh", ¯os),
|
||||
&preprocess(&pack, "gbuffers_line.fsh", ¯os),
|
||||
Target::Buffers,
|
||||
&GbuffersInputs::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut bytes = vec![0; program.interface.uniform_size as usize];
|
||||
for uniform in &program.interface.uniforms {
|
||||
if let Some(i) = index(&uniform.name) {
|
||||
values.values[i].write(&uniform.ty, &mut bytes, uniform.offset as usize);
|
||||
}
|
||||
}
|
||||
let uniform = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
||||
label: None,
|
||||
contents: &bytes,
|
||||
usage: wgpu::BufferUsages::UNIFORM,
|
||||
});
|
||||
let module = |module| {
|
||||
device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: None,
|
||||
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
|
||||
})
|
||||
};
|
||||
let vs = module(program.vertex);
|
||||
let fs = module(program.fragment);
|
||||
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: None,
|
||||
layout: None,
|
||||
vertex: wgpu::VertexState {
|
||||
module: &vs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
buffers: &[],
|
||||
},
|
||||
primitive: Default::default(),
|
||||
depth_stencil: None,
|
||||
multisample: Default::default(),
|
||||
fragment: Some(wgpu::FragmentState {
|
||||
module: &fs,
|
||||
entry_point: Some("main"),
|
||||
compilation_options: Default::default(),
|
||||
targets: &[Some(wgpu::ColorTargetState {
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
blend: None,
|
||||
write_mask: wgpu::ColorWrites::ALL,
|
||||
})],
|
||||
}),
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||
label: None,
|
||||
layout: &pipeline.get_bind_group_layout(0),
|
||||
entries: &[wgpu::BindGroupEntry {
|
||||
binding: 0,
|
||||
resource: uniform.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||
label: None,
|
||||
size: wgpu::Extent3d {
|
||||
width: 1,
|
||||
height: 1,
|
||||
depth_or_array_layers: 1,
|
||||
},
|
||||
mip_level_count: 1,
|
||||
sample_count: 1,
|
||||
dimension: wgpu::TextureDimension::D2,
|
||||
format: wgpu::TextureFormat::Rgba8Unorm,
|
||||
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
|
||||
view_formats: &[],
|
||||
});
|
||||
let view = texture.create_view(&Default::default());
|
||||
let read = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: None,
|
||||
size: 256,
|
||||
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let mut encoder = device.create_command_encoder(&Default::default());
|
||||
{
|
||||
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
|
||||
label: None,
|
||||
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
|
||||
view: &view,
|
||||
resolve_target: None,
|
||||
depth_slice: None,
|
||||
ops: wgpu::Operations {
|
||||
load: wgpu::LoadOp::Clear(wgpu::Color::BLACK),
|
||||
store: wgpu::StoreOp::Store,
|
||||
},
|
||||
})],
|
||||
..Default::default()
|
||||
});
|
||||
pass.set_pipeline(&pipeline);
|
||||
pass.set_bind_group(0, &group, &[]);
|
||||
pass.draw(0..3, 0..1);
|
||||
}
|
||||
encoder.copy_texture_to_buffer(
|
||||
texture.as_image_copy(),
|
||||
wgpu::TexelCopyBufferInfo {
|
||||
buffer: &read,
|
||||
layout: wgpu::TexelCopyBufferLayout {
|
||||
offset: 0,
|
||||
bytes_per_row: Some(256),
|
||||
rows_per_image: None,
|
||||
},
|
||||
},
|
||||
texture.size(),
|
||||
);
|
||||
queue.submit([encoder.finish()]);
|
||||
read.slice(..)
|
||||
.map_async(wgpu::MapMode::Read, |result| result.unwrap());
|
||||
device.poll(wgpu::PollType::wait_indefinitely()).unwrap();
|
||||
let mapped = read.slice(..).get_mapped_range().unwrap();
|
||||
// Independent result: translated point (1, .5, -2), perspective (aspect 2,
|
||||
// vertical FOV 90): clip (.5, .5, z, 2), hence (.25, .25, .5, 1).
|
||||
for (actual, expected) in mapped[..4].iter().zip(expected) {
|
||||
assert!(
|
||||
actual.abs_diff(expected) <= 1,
|
||||
"{projection}: {:?}",
|
||||
&mapped[..4]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn frame(hud: &HudBatch) -> Frame<'_> {
|
||||
Frame {
|
||||
eye: glam::DVec3::ZERO,
|
||||
|
||||
@@ -145,6 +145,118 @@ pub const TRIM_SLOTS: usize = 32;
|
||||
pub fn decode(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
|
||||
decode_bytes(&horizon_core::prepared_files::read(path)?)
|
||||
}
|
||||
/// The image formats [`decode_image_bytes`] reads, told apart by their own
|
||||
/// leading bytes rather than by a file name: what a server sends has no name,
|
||||
/// and a name would not be trustworthy anyway.
|
||||
///
|
||||
/// The game's own resources are PNG throughout, and so are a resource pack's
|
||||
/// (vanilla's `NativeImage.read` takes PNG only), so [`decode_bytes`] stays
|
||||
/// PNG-only. WebP and JPEG are for images fetched from the network (Modrinth
|
||||
/// project icons in the resource pack browser), which are commonly either.
|
||||
enum ImageFormat {
|
||||
Png,
|
||||
/// `RIFF....WEBP`.
|
||||
WebP,
|
||||
/// Starts with the SOI marker `FF D8`.
|
||||
Jpeg,
|
||||
}
|
||||
|
||||
impl ImageFormat {
|
||||
/// The format `data` begins with, or `None` for anything else.
|
||||
fn sniff(data: &[u8]) -> Option<Self> {
|
||||
const PNG_MAGIC: [u8; 8] = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A];
|
||||
if data.starts_with(&PNG_MAGIC) {
|
||||
return Some(Self::Png);
|
||||
}
|
||||
if data.len() >= 12 && data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" {
|
||||
return Some(Self::WebP);
|
||||
}
|
||||
if data.starts_with(&[0xFF, 0xD8]) {
|
||||
return Some(Self::Jpeg);
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// The most pixels a fetched image may claim: its header alone sizes the
|
||||
/// buffer, and a hostile one could ask for gigabytes. 4096 x 4096 RGBA is 64 MiB.
|
||||
const MAX_FETCHED_PIXELS: u64 = 4096 * 4096;
|
||||
|
||||
fn check_fetched_size(width: u64, height: u64) -> Result<()> {
|
||||
if width * height > MAX_FETCHED_PIXELS {
|
||||
return Err(format!("a {width}x{height} image is too large to decode").into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A WebP image as RGBA8. Still frames and animations both decode; an
|
||||
/// animation gives its first frame, as an animated PNG would.
|
||||
fn decode_webp(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
|
||||
let mut decoder =
|
||||
image_webp::WebPDecoder::new(std::io::Cursor::new(data)).map_err(|e| e.to_string())?;
|
||||
let (width, height) = decoder.dimensions();
|
||||
check_fetched_size(u64::from(width), u64::from(height))?;
|
||||
// Four bytes a pixel whether or not the image carries alpha: the caller
|
||||
// always wants RGBA.
|
||||
let mut rgba = vec![0u8; (width as usize) * (height as usize) * 4];
|
||||
if decoder.has_alpha() {
|
||||
decoder.read_image(&mut rgba).map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
let mut rgb = vec![0u8; (width as usize) * (height as usize) * 3];
|
||||
decoder.read_image(&mut rgb).map_err(|e| e.to_string())?;
|
||||
for (out, pixel) in rgba
|
||||
.as_chunks_mut::<4>()
|
||||
.0
|
||||
.iter_mut()
|
||||
.zip(rgb.as_chunks::<3>().0)
|
||||
{
|
||||
out.copy_from_slice(&[pixel[0], pixel[1], pixel[2], 255]);
|
||||
}
|
||||
}
|
||||
Ok((width, height, rgba))
|
||||
}
|
||||
|
||||
/// A JPEG image as RGBA8. JPEG has no alpha, so every pixel is opaque.
|
||||
fn decode_jpeg(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
|
||||
let mut decoder = zune_jpeg::JpegDecoder::new(data);
|
||||
// The size comes from the headers, before any pixel buffer is made.
|
||||
decoder.decode_headers().map_err(|e| e.to_string())?;
|
||||
let info = decoder.info().ok_or("the JPEG states no size")?;
|
||||
let (width, height) = (u32::from(info.width), u32::from(info.height));
|
||||
check_fetched_size(u64::from(width), u64::from(height))?;
|
||||
let pixels = decoder.decode().map_err(|e| e.to_string())?;
|
||||
let count = (width as usize) * (height as usize);
|
||||
// The decoder gives RGB for a colour image and one byte a pixel for a
|
||||
// greyscale one; both become RGBA here.
|
||||
let channels = pixels.len().checked_div(count).unwrap_or(0);
|
||||
let mut rgba = Vec::with_capacity(count * 4);
|
||||
match channels {
|
||||
3 => {
|
||||
for p in pixels.as_chunks::<3>().0 {
|
||||
rgba.extend_from_slice(&[p[0], p[1], p[2], 255]);
|
||||
}
|
||||
}
|
||||
1 => {
|
||||
for p in &pixels {
|
||||
rgba.extend_from_slice(&[*p, *p, *p, 255]);
|
||||
}
|
||||
}
|
||||
_ => return Err("the JPEG decoded to an unexpected channel count".into()),
|
||||
}
|
||||
Ok((width, height, rgba))
|
||||
}
|
||||
|
||||
/// An image fetched from the network (PNG, WebP or JPEG) as RGBA pixels. WebP
|
||||
/// and JPEG go to their own decoders; anything else goes to the PNG reader, so
|
||||
/// its error is the one reported.
|
||||
pub fn decode_image_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
|
||||
match ImageFormat::sniff(data) {
|
||||
Some(ImageFormat::WebP) => decode_webp(data),
|
||||
Some(ImageFormat::Jpeg) => decode_jpeg(data),
|
||||
Some(ImageFormat::Png) | None => decode_bytes(data),
|
||||
}
|
||||
}
|
||||
|
||||
/// A PNG in memory as RGBA pixels.
|
||||
pub fn decode_bytes(data: &[u8]) -> Result<(u32, u32, Vec<u8>)> {
|
||||
let mut decoder = png::Decoder::new(std::io::Cursor::new(data));
|
||||
@@ -2722,6 +2834,43 @@ mod tests {
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn image_formats_are_told_apart_by_their_leading_bytes() {
|
||||
let png = [0x89, b'P', b'N', b'G', 0x0D, 0x0A, 0x1A, 0x0A, 0, 0];
|
||||
assert!(matches!(ImageFormat::sniff(&png), Some(ImageFormat::Png)));
|
||||
// RIFF with a WEBP tag at byte 8; RIFF alone is not enough (WAV is
|
||||
// RIFF too).
|
||||
let mut webp = b"RIFF | ||||