Resolve item models exactly as the vanilla client does
The item model harness ran out of memory, so it now streams both outputs and looks Horizon's rows up by offset. With the vanilla side no longer crashing on swap-animation lookups (properties now come from the baked set), on armour (trim sprites are generated by the items atlas's paletted_permutations source once SpriteSources is bootstrapped) or on compasses (item tags are bound from the data pack), 197,000 more stacks were compared and 121,841 differed. Horizon now composes transformations as JOML 1.10.9 does: rotationXYZ and axis-angle quaternions with cosFromSin, and Matrix4f operations that take the identity, translation, affine or generic path by matrix properties, with unfused a * b + c. Float RGB tint constants are scaled by 255, the shulker box renderer always encodes its texture, and a new palette module ports PalettedPermutations and PaletteMapping so trim sprites exist on the Horizon side too. 196,290 resolved stacks match; 2,582 cases are still rejected by vanilla's stand-in client and not compared. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ use horizon_assets::{
|
||||
item_model::*,
|
||||
math::Mat4,
|
||||
model::{CuboidModel, Direction},
|
||||
palette::{PalettedPermutations, apply_palette},
|
||||
resolve::{MISSING_MODEL, ModelLibrary},
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
@@ -23,6 +24,8 @@ fn bits(v: f32) -> u32 {
|
||||
|
||||
struct Sprites {
|
||||
root: PathBuf,
|
||||
/// The items atlas's paletted permutations (armour trim sprites).
|
||||
permutations: Vec<PalettedPermutations>,
|
||||
cache: RefCell<BTreeMap<String, Option<SpriteMask>>>,
|
||||
}
|
||||
impl SpriteLookup for Sprites {
|
||||
@@ -38,27 +41,48 @@ impl SpriteLookup for Sprites {
|
||||
}
|
||||
}
|
||||
impl Sprites {
|
||||
fn load(&self, sprite: &str) -> Option<SpriteMask> {
|
||||
let (namespace, path) = sprite.split_once(':')?;
|
||||
let png = self
|
||||
.root
|
||||
.join(format!("assets/{namespace}/textures/{path}.png"));
|
||||
let mut decoder = png::Decoder::new(BufReader::new(File::open(&png).ok()?));
|
||||
fn png(&self, sprite: &str, directory: &str) -> PathBuf {
|
||||
let (namespace, path) = sprite.split_once(':').unwrap_or(("minecraft", sprite));
|
||||
self.root
|
||||
.join(format!("assets/{namespace}/{directory}/{path}.png"))
|
||||
}
|
||||
/// The image as ARGB pixels in reading order, with its size.
|
||||
fn argb(png: &Path) -> Option<(usize, usize, Vec<u32>)> {
|
||||
let mut decoder = png::Decoder::new(BufReader::new(File::open(png).ok()?));
|
||||
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
|
||||
let mut reader = decoder.read_info().ok()?;
|
||||
let mut bytes = vec![0; reader.output_buffer_size()?];
|
||||
let info = reader.next_frame(&mut bytes).ok()?;
|
||||
let channels = info.color_type.samples();
|
||||
let alpha = |pixel: &[u8]| match info.color_type {
|
||||
png::ColorType::Rgba => pixel[3],
|
||||
png::ColorType::GrayscaleAlpha => pixel[1],
|
||||
_ => 255,
|
||||
};
|
||||
let (iw, ih) = (info.width as usize, info.height as usize);
|
||||
let pixels: Vec<u8> = bytes[..info.buffer_size()]
|
||||
let pixels = bytes[..info.buffer_size()]
|
||||
.chunks_exact(channels)
|
||||
.map(alpha)
|
||||
.map(|p| {
|
||||
let (r, g, b, a) = match info.color_type {
|
||||
png::ColorType::Rgba => (p[0], p[1], p[2], p[3]),
|
||||
png::ColorType::Rgb => (p[0], p[1], p[2], 255),
|
||||
png::ColorType::GrayscaleAlpha => (p[0], p[0], p[0], p[1]),
|
||||
_ => (p[0], p[0], p[0], 255),
|
||||
};
|
||||
u32::from_be_bytes([a, r, g, b])
|
||||
})
|
||||
.collect();
|
||||
Some((info.width as usize, info.height as usize, pixels))
|
||||
}
|
||||
fn load(&self, sprite: &str) -> Option<SpriteMask> {
|
||||
let png = self.png(sprite, "textures");
|
||||
let (iw, ih, argb) = match Self::argb(&png) {
|
||||
Some(image) => image,
|
||||
None => {
|
||||
// PalettedPermutations: the base texture through PaletteMapping.
|
||||
let p = self.permutations.iter().find_map(|s| s.find(sprite))?;
|
||||
let (w, h, mut pixels) = Self::argb(&self.png(p.base, "textures"))?;
|
||||
let (_, _, key) = Self::argb(&self.png(p.palette_key, "textures/palettes"))?;
|
||||
let (_, _, target) = Self::argb(&self.png(p.palette, "textures/palettes"))?;
|
||||
apply_palette(&mut pixels, &key, &target)?;
|
||||
(w, h, pixels)
|
||||
}
|
||||
};
|
||||
let pixels: Vec<u8> = argb.iter().map(|p| (p >> 24) as u8).collect();
|
||||
let meta = fs::read_to_string(png.with_extension("png.mcmeta")).ok();
|
||||
let animation = meta
|
||||
.and_then(|m| serde_json::from_str::<Value>(&m).ok())
|
||||
@@ -215,6 +239,9 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let library = ModelLibrary::with_item_generator(cuboids)?;
|
||||
let sprites = Sprites {
|
||||
root: root.clone(),
|
||||
permutations: PalettedPermutations::from_atlas(
|
||||
&fs::read_to_string(root.join("assets/minecraft/atlases/items.json")).unwrap_or_default(),
|
||||
),
|
||||
cache: RefCell::default(),
|
||||
};
|
||||
let grass = {
|
||||
|
||||
@@ -342,9 +342,11 @@ fn optional_transformation(m: &Map<String, Value>) -> Result<Option<Mat4>> {
|
||||
get(m, "transformation").map(transformation).transpose()
|
||||
}
|
||||
/// `Mth.floor(float)` as `ARGB.as8BitChannel` uses it.
|
||||
/// `ARGB.as8BitChannel`: `Mth.floor(value * 255.0F)`.
|
||||
fn channel(v: f32) -> i32 {
|
||||
let i = v as i32;
|
||||
if (i as f32) > v { i - 1 } else { i }
|
||||
let scaled = v * 255.0;
|
||||
let i = scaled as i32;
|
||||
if (i as f32) > scaled { i - 1 } else { i }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -207,9 +207,7 @@ impl SpecialModel {
|
||||
}
|
||||
SpecialKind::PlayerHead => "player_head",
|
||||
SpecialKind::ShulkerBox { texture, openness } => {
|
||||
if texture != "minecraft:shulker" {
|
||||
put("texture", json!(texture));
|
||||
}
|
||||
put("texture", json!(texture));
|
||||
if *openness != 0.0 {
|
||||
put("openness", json!(openness));
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub mod item_model;
|
||||
mod json;
|
||||
pub mod math;
|
||||
pub mod model;
|
||||
pub mod palette;
|
||||
pub mod resolve;
|
||||
|
||||
pub use json::DecodeError;
|
||||
|
||||
@@ -1,31 +1,88 @@
|
||||
//! Column-major `Matrix4f` arithmetic with the operand order of JOML's
|
||||
//! default (non-FMA) code paths, for composing item-model transformations.
|
||||
//! Column-major `Matrix4f` arithmetic as JOML 1.10.9 computes it, for composing
|
||||
//! item-model transformations: each operation takes the path JOML picks from
|
||||
//! the matrix properties (identity, translation, affine...), and `Math.fma` is
|
||||
//! `a * b + c` since Minecraft leaves `joml.useMathFma` off.
|
||||
|
||||
/// A JOML `Matrix4f`: `m[column][row]`.
|
||||
/// `Matrix4fc.PROPERTY_*` bits.
|
||||
const PERSPECTIVE: u8 = 1;
|
||||
const AFFINE: u8 = 2;
|
||||
const IDENTITY: u8 = 4;
|
||||
const TRANSLATION: u8 = 8;
|
||||
const ORTHONORMAL: u8 = 16;
|
||||
|
||||
/// JOML `Math.fma` without `joml.useMathFma`.
|
||||
#[inline]
|
||||
fn fma(a: f32, b: f32, c: f32) -> f32 {
|
||||
a * b + c
|
||||
}
|
||||
/// JOML `Math.absEqualsOne`.
|
||||
fn abs_equals_one(r: f32) -> bool {
|
||||
r.to_bits() & 0x7FFF_FFFF == 0x3F80_0000
|
||||
}
|
||||
|
||||
/// A JOML `Matrix4f`: `m[column][row]`, with its property bits.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Mat4(pub [[f32; 4]; 4]);
|
||||
pub struct Mat4(pub [[f32; 4]; 4], pub u8);
|
||||
|
||||
impl Mat4 {
|
||||
pub const IDENTITY: Self = Self([
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
]);
|
||||
/// `new Matrix4f()`.
|
||||
pub const IDENTITY: Self = Self(
|
||||
[
|
||||
[1.0, 0.0, 0.0, 0.0],
|
||||
[0.0, 1.0, 0.0, 0.0],
|
||||
[0.0, 0.0, 1.0, 0.0],
|
||||
[0.0, 0.0, 0.0, 1.0],
|
||||
],
|
||||
IDENTITY | AFFINE | TRANSLATION | ORTHONORMAL,
|
||||
);
|
||||
/// `Matrix4f.translation(x, y, z)`.
|
||||
pub fn translation(t: [f32; 3]) -> Self {
|
||||
let mut m = Self::IDENTITY;
|
||||
m.0[3][..3].copy_from_slice(&t);
|
||||
m.1 = AFFINE | TRANSLATION | ORTHONORMAL;
|
||||
m
|
||||
}
|
||||
/// `ExtraCodecs.MATRIX4F`: sixteen values in row-major order.
|
||||
/// `ExtraCodecs.MATRIX4F`: sixteen values in row-major order, then
|
||||
/// `determineProperties`.
|
||||
pub fn from_row_major(values: [f32; 16]) -> Self {
|
||||
let mut m = Self([[0.0; 4]; 4]);
|
||||
let mut m = Self([[0.0; 4]; 4], 0);
|
||||
for (i, v) in values.into_iter().enumerate() {
|
||||
m.0[i & 3][i >> 2] = v;
|
||||
}
|
||||
m.1 = m.determine_properties();
|
||||
m
|
||||
}
|
||||
/// `Matrix4f.determineProperties`.
|
||||
fn determine_properties(&self) -> u8 {
|
||||
let m = &self.0;
|
||||
let mut properties = 0;
|
||||
if m[0][3] == 0.0 && m[1][3] == 0.0 {
|
||||
if m[2][3] == 0.0 && m[3][3] == 1.0 {
|
||||
properties |= AFFINE;
|
||||
if m[0] == [1.0, 0.0, 0.0, 0.0] && m[1] == [0.0, 1.0, 0.0, 0.0] && m[2] == [0.0, 0.0, 1.0, 0.0] {
|
||||
properties |= TRANSLATION | ORTHONORMAL;
|
||||
if m[3][0] == 0.0 && m[3][1] == 0.0 && m[3][2] == 0.0 {
|
||||
properties |= IDENTITY;
|
||||
}
|
||||
}
|
||||
} else if m[0][1] == 0.0
|
||||
&& m[0][2] == 0.0
|
||||
&& m[1][0] == 0.0
|
||||
&& m[1][2] == 0.0
|
||||
&& m[2][0] == 0.0
|
||||
&& m[2][1] == 0.0
|
||||
&& m[3][0] == 0.0
|
||||
&& m[3][1] == 0.0
|
||||
&& m[3][3] == 0.0
|
||||
{
|
||||
properties |= PERSPECTIVE;
|
||||
}
|
||||
}
|
||||
properties
|
||||
}
|
||||
fn has(&self, property: u8) -> bool {
|
||||
self.1 & property != 0
|
||||
}
|
||||
/// The matrix as `[row][column]` entries, the order `getRowColumn` reads.
|
||||
pub fn rows(&self) -> [[f32; 4]; 4] {
|
||||
std::array::from_fn(|r| std::array::from_fn(|c| self.0[c][r]))
|
||||
@@ -33,37 +90,106 @@ impl Mat4 {
|
||||
/// `this * right` (`Matrix4f.mul`).
|
||||
pub fn mul(&self, right: &Self) -> Self {
|
||||
let (m, r) = (&self.0, &right.0);
|
||||
if self.has(IDENTITY) {
|
||||
return *right;
|
||||
}
|
||||
if right.has(IDENTITY) {
|
||||
return *self;
|
||||
}
|
||||
let mut out = [[0.0; 4]; 4];
|
||||
if self.has(TRANSLATION) && right.has(AFFINE) {
|
||||
// mulTranslationAffine
|
||||
for c in 0..4 {
|
||||
out[c] = [r[c][0], r[c][1], r[c][2], m[c][3]];
|
||||
}
|
||||
for row in 0..3 {
|
||||
out[3][row] = r[3][row] + m[3][row];
|
||||
}
|
||||
return Self(out, AFFINE | right.1 & ORTHONORMAL);
|
||||
}
|
||||
if self.has(AFFINE) && right.has(AFFINE) {
|
||||
// mulAffine
|
||||
for c in 0..3 {
|
||||
for row in 0..3 {
|
||||
out[c][row] = fma(m[0][row], r[c][0], fma(m[1][row], r[c][1], m[2][row] * r[c][2]));
|
||||
}
|
||||
out[c][3] = m[c][3];
|
||||
}
|
||||
for row in 0..3 {
|
||||
out[3][row] = fma(m[0][row], r[3][0], fma(m[1][row], r[3][1], fma(m[2][row], r[3][2], m[3][row])));
|
||||
}
|
||||
out[3][3] = m[3][3];
|
||||
return Self(out, AFFINE | self.1 & right.1 & ORTHONORMAL);
|
||||
}
|
||||
if self.has(PERSPECTIVE) && right.has(AFFINE) {
|
||||
// mulPerspectiveAffine
|
||||
for c in 0..4 {
|
||||
out[c] = [m[0][0] * r[c][0], m[1][1] * r[c][1], m[2][2] * r[c][2], m[2][3] * r[c][2]];
|
||||
}
|
||||
out[3][2] = m[2][2] * r[3][2] + m[3][2];
|
||||
out[3][3] = m[2][3] * r[3][2];
|
||||
return Self(out, 0);
|
||||
}
|
||||
if right.has(AFFINE) {
|
||||
// mulAffineR
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out[c][row] = fma(m[0][row], r[c][0], fma(m[1][row], r[c][1], m[2][row] * r[c][2]));
|
||||
}
|
||||
}
|
||||
for row in 0..4 {
|
||||
out[3][row] = fma(m[0][row], r[3][0], fma(m[1][row], r[3][1], fma(m[2][row], r[3][2], m[3][row])));
|
||||
}
|
||||
return Self(out, self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION | ORTHONORMAL));
|
||||
}
|
||||
// mul0
|
||||
for c in 0..4 {
|
||||
for row in 0..4 {
|
||||
out[c][row] = m[0][row] * r[c][0]
|
||||
+ m[1][row] * r[c][1]
|
||||
+ m[2][row] * r[c][2]
|
||||
+ m[3][row] * r[c][3];
|
||||
out[c][row] = fma(
|
||||
m[0][row],
|
||||
r[c][0],
|
||||
fma(m[1][row], r[c][1], fma(m[2][row], r[c][2], m[3][row] * r[c][3])),
|
||||
);
|
||||
}
|
||||
}
|
||||
Self(out)
|
||||
Self(out, 0)
|
||||
}
|
||||
/// `Matrix4f.translate(x, y, z)` (`translateGeneric`).
|
||||
/// `Matrix4f.translate(x, y, z)`.
|
||||
pub fn translate(&self, t: [f32; 3]) -> Self {
|
||||
if self.has(IDENTITY) {
|
||||
return Self::translation(t);
|
||||
}
|
||||
// translateGeneric
|
||||
let mut out = *self;
|
||||
for row in 0..4 {
|
||||
out.0[3][row] = self.0[0][row] * t[0]
|
||||
+ (self.0[1][row] * t[1] + (self.0[2][row] * t[2] + self.0[3][row]));
|
||||
out.0[3][row] = fma(self.0[0][row], t[0], fma(self.0[1][row], t[1], fma(self.0[2][row], t[2], self.0[3][row])));
|
||||
}
|
||||
out.1 = self.1 & !(PERSPECTIVE | IDENTITY);
|
||||
out
|
||||
}
|
||||
/// `Matrix4f.scale(x, y, z)`: the first three columns are scaled.
|
||||
/// `Matrix4f.scale(x, y, z)`.
|
||||
pub fn scale(&self, s: [f32; 3]) -> Self {
|
||||
let one = s.iter().all(|&v| abs_equals_one(v));
|
||||
if self.has(IDENTITY) {
|
||||
// scaling
|
||||
let mut out = Self::IDENTITY;
|
||||
for (c, v) in s.into_iter().enumerate() {
|
||||
out.0[c][c] = v;
|
||||
}
|
||||
out.1 = AFFINE | if one { ORTHONORMAL } else { 0 };
|
||||
return out;
|
||||
}
|
||||
// scaleGeneric: the first three columns are scaled.
|
||||
let mut out = *self;
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out.0[c][row] = self.0[c][row] * s[c];
|
||||
}
|
||||
}
|
||||
out.1 = self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION | if one { 0 } else { ORTHONORMAL });
|
||||
out
|
||||
}
|
||||
/// `Matrix4f.rotate(Quaternionfc)` (`rotateGeneric`).
|
||||
/// `Matrix4f.rotate(Quaternionfc)`.
|
||||
pub fn rotate(&self, q: [f32; 4]) -> Self {
|
||||
let [x, y, z, w] = q;
|
||||
let (w2, x2, y2, z2) = (w * w, x * x, y * y, z * z);
|
||||
@@ -71,14 +197,39 @@ impl Mat4 {
|
||||
let (dzw, dxy, dxz, dyw, dyz, dxw) = (zw + zw, xy + xy, xz + xz, yw + yw, yz + yz, xw + xw);
|
||||
let r = [
|
||||
[w2 + x2 - z2 - y2, dxy + dzw, dxz - dyw],
|
||||
[dxy - dzw, y2 - z2 + w2 - x2, dyz + dxw],
|
||||
[-dzw + dxy, y2 - z2 + w2 - x2, dyz + dxw],
|
||||
[dyw + dxz, dyz - dxw, z2 - y2 - x2 + w2],
|
||||
];
|
||||
let m = &self.0;
|
||||
let mut out = *self;
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out.0[c][row] = m[0][row] * r[c][0] + m[1][row] * r[c][1] + m[2][row] * r[c][2];
|
||||
if self.has(IDENTITY) {
|
||||
// rotation
|
||||
for c in 0..3 {
|
||||
out.0[c][..3].copy_from_slice(&r[c]);
|
||||
}
|
||||
out.1 = AFFINE | ORTHONORMAL;
|
||||
return out;
|
||||
}
|
||||
out.1 = self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION);
|
||||
if self.has(TRANSLATION) {
|
||||
// rotateTranslation
|
||||
for c in 0..3 {
|
||||
out.0[c] = [r[c][0], r[c][1], r[c][2], 0.0];
|
||||
}
|
||||
} else if self.has(AFFINE) {
|
||||
// rotateAffine
|
||||
for c in 0..3 {
|
||||
for row in 0..3 {
|
||||
out.0[c][row] = m[0][row] * r[c][0] + m[1][row] * r[c][1] + m[2][row] * r[c][2];
|
||||
}
|
||||
out.0[c][3] = 0.0;
|
||||
}
|
||||
} else {
|
||||
// rotateGeneric
|
||||
for c in 0..3 {
|
||||
for row in 0..4 {
|
||||
out.0[c][row] = fma(m[0][row], r[c][0], fma(m[1][row], r[c][1], m[2][row] * r[c][2]));
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -94,28 +245,46 @@ pub fn normalize_quaternion(q: [f32; 4]) -> [f32; 4] {
|
||||
let inv = 1.0 / (x * x + (y * y + (z * z + w * w))).sqrt();
|
||||
[x * inv, y * inv, z * inv, w * inv]
|
||||
}
|
||||
/// JOML `Math.sin(float)` without fast math.
|
||||
fn joml_sin(rad: f32) -> f32 {
|
||||
f64::from(rad).sin() as f32
|
||||
}
|
||||
/// JOML `Math.cosFromSin(float, float)` without fast math: the magnitude from
|
||||
/// the sine, the sign from the angle's quadrant (exact zeros at right angles).
|
||||
fn cos_from_sin(sin: f32, angle: f32) -> f32 {
|
||||
let cos = (1.0 - sin * sin).sqrt();
|
||||
let tau = (std::f64::consts::PI * 2.0) as f32;
|
||||
let a = angle + std::f32::consts::FRAC_PI_2;
|
||||
let mut b = a - (a / tau) as i32 as f32 * tau;
|
||||
if b < 0.0 {
|
||||
b += tau;
|
||||
}
|
||||
if b >= std::f32::consts::PI { -cos } else { cos }
|
||||
}
|
||||
/// `Quaternionf.rotationXYZ` from angles in radians.
|
||||
pub fn quaternion_xyz(x: f32, y: f32, z: f32) -> [f32; 4] {
|
||||
let half = |a: f32| -> (f32, f32) {
|
||||
let h = a * 0.5;
|
||||
((f64::from(h).sin()) as f32, (f64::from(h).cos()) as f32)
|
||||
let s = joml_sin(h);
|
||||
(s, cos_from_sin(s, h))
|
||||
};
|
||||
let ((sx, cx), (sy, cy), (sz, cz)) = (half(x), half(y), half(z));
|
||||
let (cx_cy, sx_cy, cx_sy, sx_sy) = (cx * cy, sx * cy, cx * sy, sx * sy);
|
||||
let (cycz, sysz, sycz, cysz) = (cy * cz, sy * sz, sy * cz, cy * sz);
|
||||
[
|
||||
sx_cy * cz + cx_sy * sz,
|
||||
cx_sy * cz - sx_cy * sz,
|
||||
cx_cy * sz + sx_sy * cz,
|
||||
cx_cy * cz - sx_sy * sz,
|
||||
sx * cycz + cx * sysz,
|
||||
cx * sycz - sx * cysz,
|
||||
cx * cysz + sx * sycz,
|
||||
cx * cycz - sx * sysz,
|
||||
]
|
||||
}
|
||||
/// `new Quaternionf(new AxisAngle4f(angle, axis))`.
|
||||
/// `new Quaternionf(new AxisAngle4f(angle, axis))`: the angle is wrapped into
|
||||
/// [0, 2π) and the axis is used as given.
|
||||
pub fn quaternion_from_axis_angle(angle: f32, axis: [f32; 3]) -> [f32; 4] {
|
||||
let half = angle * 0.5;
|
||||
let (sin, cos) = (half.sin(), half.cos());
|
||||
let norm = 1.0 / (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
|
||||
let s = sin * norm;
|
||||
[axis[0] * s, axis[1] * s, axis[2] * s, cos]
|
||||
let tau = (std::f64::consts::PI * 2.0) as f32;
|
||||
let angle = if angle < 0.0 { tau + angle % tau } else { angle } % tau;
|
||||
let sin = joml_sin(angle * 0.5);
|
||||
let cos = cos_from_sin(sin, angle * 0.5);
|
||||
[axis[0] * sin, axis[1] * sin, axis[2] * sin, cos]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -137,4 +306,9 @@ mod tests {
|
||||
let m = Mat4::IDENTITY.rotate(q);
|
||||
assert!((m.0[0][1] - 1.0).abs() < 1e-6 && m.0[0][0].abs() < 1e-6);
|
||||
}
|
||||
#[test]
|
||||
fn half_turn_has_exact_zeros_like_joml() {
|
||||
let half_turn = 180.0 * (std::f64::consts::PI / 180.0) as f32;
|
||||
assert_eq!(quaternion_xyz(0.0, half_turn, 0.0), [0.0, 1.0, 0.0, 0.0]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! `PalettedPermutations` atlas sources: sprites such as
|
||||
//! `trims/items/boots_trim_quartz` are the base texture with its colours
|
||||
//! swapped through a palette pair (`PaletteMapping`), not files of their own.
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// One `minecraft:paletted_permutations` source of an atlas definition.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PalettedPermutations {
|
||||
/// Base sprite names (`minecraft:trims/items/boots_trim`).
|
||||
pub textures: Vec<String>,
|
||||
/// The key palette (`minecraft:trim_base`).
|
||||
pub palette_key: String,
|
||||
/// Suffix to target palette (`quartz` → `minecraft:trim/quartz`).
|
||||
pub permutations: BTreeMap<String, String>,
|
||||
pub separator: String,
|
||||
}
|
||||
|
||||
/// A permuted sprite: its base texture and the palette pair to apply.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Permutation<'a> {
|
||||
pub base: &'a str,
|
||||
pub palette_key: &'a str,
|
||||
pub palette: &'a str,
|
||||
}
|
||||
|
||||
fn qualified(id: &str) -> String {
|
||||
if id.contains(':') { id.to_owned() } else { format!("minecraft:{id}") }
|
||||
}
|
||||
|
||||
impl PalettedPermutations {
|
||||
/// The `paletted_permutations` sources of an atlas JSON (`atlases/items.json`).
|
||||
pub fn from_atlas(atlas: &str) -> Vec<Self> {
|
||||
let Ok(json) = serde_json::from_str::<Value>(atlas) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let sources = json.get("sources").and_then(Value::as_array).cloned().unwrap_or_default();
|
||||
sources
|
||||
.iter()
|
||||
.filter(|s| s.get("type").and_then(Value::as_str).map(qualified).as_deref() == Some("minecraft:paletted_permutations"))
|
||||
.filter_map(|s| {
|
||||
Some(Self {
|
||||
textures: s.get("textures")?.as_array()?.iter().filter_map(Value::as_str).map(qualified).collect(),
|
||||
palette_key: qualified(s.get("palette_key")?.as_str()?),
|
||||
permutations: s
|
||||
.get("permutations")?
|
||||
.as_object()?
|
||||
.iter()
|
||||
.filter_map(|(k, v)| Some((k.clone(), qualified(v.as_str()?))))
|
||||
.collect(),
|
||||
separator: s.get("separator").and_then(Value::as_str).unwrap_or("_").to_owned(),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
/// The permutation that produces `sprite` (`withSuffix(separator + key)`).
|
||||
pub fn find(&self, sprite: &str) -> Option<Permutation<'_>> {
|
||||
let sprite = qualified(sprite);
|
||||
self.textures.iter().find_map(|base| {
|
||||
let suffix = sprite.strip_prefix(base.as_str())?.strip_prefix(self.separator.as_str())?;
|
||||
Some(Permutation { base, palette_key: &self.palette_key, palette: self.permutations.get(suffix)? })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// `PaletteMapping.create(key, target).apply` over ARGB pixels; the palettes
|
||||
/// are the pixels of their images in reading order (`Palette.from`).
|
||||
pub fn apply_palette(pixels: &mut [u32], key: &[u32], target: &[u32]) -> Option<()> {
|
||||
if key.len() != target.len() {
|
||||
return None;
|
||||
}
|
||||
let mut map = BTreeMap::new();
|
||||
for (&k, &t) in key.iter().zip(target) {
|
||||
if k >> 24 != 0 {
|
||||
map.insert(k | 0xFF00_0000, t);
|
||||
}
|
||||
}
|
||||
for pixel in pixels {
|
||||
let alpha = *pixel >> 24;
|
||||
if alpha == 0 {
|
||||
continue;
|
||||
}
|
||||
let rgb = *pixel | 0xFF00_0000;
|
||||
let target = map.get(&rgb).copied().unwrap_or(rgb);
|
||||
*pixel = (alpha * (target >> 24) / 255) << 24 | target & 0x00FF_FFFF;
|
||||
}
|
||||
Some(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn finds_trim_permutations_and_maps_colours() {
|
||||
let atlas = r#"{"sources":[{"type":"minecraft:paletted_permutations","palette_key":"minecraft:trim_base",
|
||||
"permutations":{"quartz":"minecraft:trim/quartz"},"textures":["minecraft:trims/items/boots_trim"]}]}"#;
|
||||
let sources = PalettedPermutations::from_atlas(atlas);
|
||||
let p = sources[0].find("minecraft:trims/items/boots_trim_quartz").unwrap();
|
||||
assert_eq!((p.base, p.palette), ("minecraft:trims/items/boots_trim", "minecraft:trim/quartz"));
|
||||
assert!(sources[0].find("minecraft:trims/items/boots_trim_gold").is_none());
|
||||
let mut pixels = [0xFF11_2233, 0x8011_2233, 0x0011_2233, 0xFF44_5566];
|
||||
apply_palette(&mut pixels, &[0xFF11_2233], &[0xFFAA_BBCC]).unwrap();
|
||||
assert_eq!(pixels, [0xFFAA_BBCC, 0x80AA_BBCC, 0x0011_2233, 0xFF44_5566]);
|
||||
}
|
||||
}
|
||||
@@ -84,7 +84,11 @@ public final class VanillaItemModelOracle {
|
||||
static TextureAtlasSprite load(Identifier id, Identifier atlas) {
|
||||
try {
|
||||
var png = root.resolve("assets").resolve(id.getNamespace()).resolve("textures").resolve(id.getPath() + ".png");
|
||||
if (!Files.isRegularFile(png)) return null;
|
||||
if (!Files.isRegularFile(png)) {
|
||||
// Armour trim textures come from the atlas's paletted_permutations source.
|
||||
var generated = generatedSprites(atlas).get(id);
|
||||
return generated == null ? null : new Sprite(atlas, generated.get(null));
|
||||
}
|
||||
NativeImage image;
|
||||
try (var in = Files.newInputStream(png)) { image = NativeImage.read(in); }
|
||||
var mcmeta = png.resolveSibling(png.getFileName() + ".mcmeta");
|
||||
@@ -100,6 +104,40 @@ public final class VanillaItemModelOracle {
|
||||
} catch (IOException e) { throw new UncheckedIOException(e); }
|
||||
}
|
||||
|
||||
static final Map<Identifier, Map<Identifier, net.minecraft.client.renderer.texture.atlas.SpriteSource.DiscardableLoader>> GENERATED = new HashMap<>();
|
||||
/** The atlas's paletted_permutations sprites, run by vanilla's own source over the extracted resources. */
|
||||
static Map<Identifier, net.minecraft.client.renderer.texture.atlas.SpriteSource.DiscardableLoader> generatedSprites(Identifier atlas) {
|
||||
return GENERATED.computeIfAbsent(atlas, a -> {
|
||||
Map<Identifier, net.minecraft.client.renderer.texture.atlas.SpriteSource.DiscardableLoader> out = new HashMap<>();
|
||||
var file = "/assets/" + a.getNamespace() + "/atlases/" + a.getPath().replaceFirst("^textures/atlas/", "").replaceFirst("\\.png$", "") + ".json";
|
||||
try (var in = VanillaItemModelOracle.class.getResourceAsStream(file)) {
|
||||
if (in == null) return out;
|
||||
var json = JsonParser.parseString(new String(in.readAllBytes(), java.nio.charset.StandardCharsets.UTF_8));
|
||||
var sources = net.minecraft.client.renderer.texture.atlas.SpriteSources.FILE_CODEC.parse(JsonOps.INSTANCE, json).getOrThrow();
|
||||
var output = new net.minecraft.client.renderer.texture.atlas.SpriteSource.Output() {
|
||||
@Override public void add(Identifier id, net.minecraft.client.renderer.texture.atlas.SpriteSource.DiscardableLoader sprite) { out.put(id, sprite); }
|
||||
@Override public void removeAll(java.util.function.Predicate<Identifier> predicate) { out.keySet().removeIf(predicate); }
|
||||
};
|
||||
for (var source : sources)
|
||||
if (source instanceof net.minecraft.client.renderer.texture.atlas.sources.PalettedPermutations) source.run(FILES, output);
|
||||
} catch (IOException e) { throw new UncheckedIOException(e); }
|
||||
return out;
|
||||
});
|
||||
}
|
||||
/** The extracted resources as a single-pack resource manager. */
|
||||
static final net.minecraft.server.packs.resources.ResourceManager FILES = new net.minecraft.server.packs.resources.ResourceManager() {
|
||||
Path path(Identifier id) { return root.resolve("assets").resolve(id.getNamespace()).resolve(id.getPath()); }
|
||||
@Override public Optional<net.minecraft.server.packs.resources.Resource> getResource(Identifier id) {
|
||||
var file = path(id);
|
||||
return Files.isRegularFile(file) ? Optional.of(new net.minecraft.server.packs.resources.Resource(null, () -> Files.newInputStream(file))) : Optional.empty();
|
||||
}
|
||||
@Override public java.util.Set<String> getNamespaces() { return java.util.Set.of("minecraft"); }
|
||||
@Override public List<net.minecraft.server.packs.resources.Resource> getResourceStack(Identifier id) { return getResource(id).stream().toList(); }
|
||||
@Override public Map<Identifier, net.minecraft.server.packs.resources.Resource> listResources(String directory, net.minecraft.server.packs.resources.ResourceManager.Selector selector) { throw new UnsupportedOperationException(); }
|
||||
@Override public Map<Identifier, List<net.minecraft.server.packs.resources.Resource>> listResourceStacks(String directory, net.minecraft.server.packs.resources.ResourceManager.Selector selector) { throw new UnsupportedOperationException(); }
|
||||
@Override public java.util.stream.Stream<net.minecraft.server.packs.PackResources> listPacks() { return java.util.stream.Stream.empty(); }
|
||||
};
|
||||
|
||||
// --- Tagging of special renderers so the layer carries its codec form. ---
|
||||
static final class Tagged implements SpecialModelRenderer.Unbaked<Object> {
|
||||
final SpecialModelRenderer.Unbaked<?> delegate;
|
||||
@@ -196,6 +234,15 @@ public final class VanillaItemModelOracle {
|
||||
models.getOrDefault(id, missing).update(output, item, this, context, level instanceof ClientLevel c ? c : null, owner, seed);
|
||||
}
|
||||
}
|
||||
// ModelManager.getItemProperties, from the same baked properties.
|
||||
@Override public boolean shouldPlaySwapAnimation(ItemStack stack) {
|
||||
Identifier id = stack.get(DataComponents.ITEM_MODEL);
|
||||
return id == null || properties.getOrDefault(id, ClientItem.Properties.DEFAULT).handAnimationOnSwap();
|
||||
}
|
||||
@Override public float swapAnimationScale(ItemStack stack) {
|
||||
Identifier id = stack.get(DataComponents.ITEM_MODEL);
|
||||
return id == null ? 1.0F : properties.getOrDefault(id, ClientItem.Properties.DEFAULT).swapAnimationScale();
|
||||
}
|
||||
}
|
||||
|
||||
static Object field(Object target, Class<?> type, String name) throws Exception {
|
||||
@@ -251,12 +298,22 @@ public final class VanillaItemModelOracle {
|
||||
public static void main(String[] args) throws Exception {
|
||||
net.minecraft.SharedConstants.tryDetectVersion();
|
||||
net.minecraft.server.Bootstrap.bootStrap();
|
||||
// Item tags (compasses get the special foil) come from the vanilla data pack.
|
||||
var repo = net.minecraft.server.packs.repository.ServerPacksSource.createVanillaTrustedRepository();
|
||||
repo.reload();
|
||||
repo.setSelected(List.of("vanilla"));
|
||||
try (var data = new net.minecraft.server.packs.resources.MultiPackResourceManager(net.minecraft.server.packs.PackType.SERVER_DATA, repo.openAllSelected())) {
|
||||
var layers = net.minecraft.server.RegistryLayer.createRegistryAccess();
|
||||
net.minecraft.tags.TagLoader.loadTagsForExistingRegistries(data, layers.getLayer(net.minecraft.server.RegistryLayer.STATIC))
|
||||
.forEach(net.minecraft.core.Registry.PendingTags::apply);
|
||||
}
|
||||
ItemModels.bootstrap();
|
||||
SpecialModelRenderers.bootstrap();
|
||||
net.minecraft.client.color.item.ItemTintSources.bootstrap();
|
||||
net.minecraft.client.renderer.item.properties.select.SelectItemModelProperties.bootstrap();
|
||||
net.minecraft.client.renderer.item.properties.conditional.ConditionalItemModelProperties.bootstrap();
|
||||
net.minecraft.client.renderer.item.properties.numeric.RangeSelectItemModelProperties.bootstrap();
|
||||
net.minecraft.client.renderer.texture.atlas.SpriteSources.bootstrap();
|
||||
var u = Class.forName("sun.misc.Unsafe");
|
||||
var uf = u.getDeclaredField("theUnsafe"); uf.setAccessible(true); unsafe = uf.get(null);
|
||||
allocate = u.getMethod("allocateInstance", Class.class);
|
||||
|
||||
@@ -26,7 +26,8 @@ TRIM_MATERIALS = ['quartz', 'iron', 'netherite', 'redstone', 'copper', 'gold', '
|
||||
'amethyst', 'resin']
|
||||
NEEDED = ('assets/minecraft/items/', 'assets/minecraft/models/', 'assets/minecraft/textures/item/',
|
||||
'assets/minecraft/textures/block/', 'assets/minecraft/textures/trims/',
|
||||
'assets/minecraft/textures/colormap/')
|
||||
'assets/minecraft/textures/colormap/', 'assets/minecraft/textures/palettes/',
|
||||
'assets/minecraft/atlases/')
|
||||
|
||||
|
||||
def extract(jar, destination):
|
||||
@@ -293,6 +294,12 @@ def matrix(bits):
|
||||
return [0 if b == 0x80000000 else b for b in bits]
|
||||
|
||||
|
||||
def rows_of(path):
|
||||
with path.open('rb') as rows:
|
||||
for line in rows:
|
||||
yield json.loads(line)
|
||||
|
||||
|
||||
def main():
|
||||
lock, jars, libraries = base.verify_artifacts()
|
||||
build = lab.LOCAL / 'item-model-parity'; build.mkdir(parents=True, exist_ok=True)
|
||||
@@ -324,8 +331,11 @@ def main():
|
||||
run(['java', '-Xmx2G', '-cp', str(classes) + os.pathsep + dependencies, 'dev.horizonlab.parity.VanillaItemModelOracle',
|
||||
resources, inputs, build / 'client.jsonl', prototypes_path], 'client')
|
||||
# The host inputs vanilla derives from the stack (use duration, charge time).
|
||||
client = [json.loads(line) for line in (build / 'client.jsonl').read_text().splitlines()]
|
||||
derived = {r['id']: r['derived'] for r in client if r.get('row') == 'case' and 'derived' in r}
|
||||
# Both outputs are gigabytes of quads, so rows are streamed one at a time.
|
||||
derived = {}
|
||||
for r in rows_of(build / 'client.jsonl'):
|
||||
if r.get('row') == 'case' and 'derived' in r:
|
||||
derived[r['id']] = r['derived']
|
||||
rust_cases = []
|
||||
for case in cases:
|
||||
case = dict(case)
|
||||
@@ -335,35 +345,60 @@ def main():
|
||||
rust_inputs.write_text(''.join(json.dumps(c) + '\n' for c in rust_cases))
|
||||
(build / 'rust-cases.jsonl.definitions').write_text((build / 'cases.jsonl.definitions').read_text())
|
||||
run([binary, resources, prototypes_path, rust_inputs, build / 'rust.jsonl'], 'rust')
|
||||
rust = [json.loads(line) for line in (build / 'rust.jsonl').read_text().splitlines()]
|
||||
mismatches, compared, skipped, decoded = [], 0, 0, 0
|
||||
vanilla_by_key = {(r['row'], r.get('id') or r.get('name')): r for r in client if r['row'] in ('case', 'decode', 'components')}
|
||||
rust_by_key = {(r['row'], r.get('id') or r.get('name')): r for r in rust}
|
||||
assert vanilla_by_key.keys() == rust_by_key.keys(), 'The two sides produced different rows'
|
||||
assert vanilla_by_key[('components', None)]['names'] == rust_by_key[('components', None)]['names'], 'Data component registry differs'
|
||||
unbaked = [r['name'] for r in client if r['row'] == 'unbaked']
|
||||
for key, left in vanilla_by_key.items():
|
||||
right = rust_by_key[key]
|
||||
if key[0] == 'components': continue
|
||||
mismatches, mismatch_count, compared, skipped, decoded = [], 0, 0, 0, 0
|
||||
rust_offsets = {}
|
||||
with (build / 'rust.jsonl').open('rb') as rust_file:
|
||||
offset = 0
|
||||
for line in rust_file:
|
||||
r = json.loads(line)
|
||||
rust_offsets[(r['row'], r.get('id') or r.get('name'))] = offset
|
||||
offset += len(line)
|
||||
vanilla_keys, unbaked = set(), []
|
||||
rust_file = (build / 'rust.jsonl').open('rb')
|
||||
|
||||
def rust_row(key):
|
||||
rust_file.seek(rust_offsets[key])
|
||||
return json.loads(rust_file.readline())
|
||||
|
||||
for left in rows_of(build / 'client.jsonl'):
|
||||
if left['row'] == 'unbaked':
|
||||
unbaked.append(left['name'])
|
||||
if left['row'] not in ('case', 'decode', 'components'):
|
||||
continue
|
||||
key = (left['row'], left.get('id') or left.get('name'))
|
||||
vanilla_keys.add(key)
|
||||
assert key in rust_offsets, f'Horizon produced no row for {key}'
|
||||
right = rust_row(key)
|
||||
if key[0] == 'components':
|
||||
assert left['names'] == right['names'], 'Data component registry differs'
|
||||
continue
|
||||
if key[0] == 'decode':
|
||||
decoded += 1
|
||||
if left['ok'] != right['ok']: mismatches.append(('decode', key[1], left, right))
|
||||
if left['ok'] != right['ok']:
|
||||
mismatch_count += 1
|
||||
if len(mismatches) < 5:
|
||||
mismatches.append(('decode', key[1], left, right))
|
||||
continue
|
||||
if 'error' in left:
|
||||
skipped += 1
|
||||
continue
|
||||
compared += 1
|
||||
left.pop('derived', None) # an input Horizon received, not an output
|
||||
for side in (left, right):
|
||||
for layer in side['layers']:
|
||||
layer['matrix'] = matrix(layer['matrix'])
|
||||
layer.pop('has_transform', None)
|
||||
if layer.get('quads') is not None: layer['quads'] = canonical_quads(layer['quads'])
|
||||
if left != right:
|
||||
mismatches.append(('case', key[1], left, right))
|
||||
if mismatches:
|
||||
for kind, name, left, right in mismatches[:5]:
|
||||
mismatch_count += 1
|
||||
if len(mismatches) < 5:
|
||||
mismatches.append(('case', key[1], left, right))
|
||||
rust_file.close()
|
||||
assert vanilla_keys == rust_offsets.keys(), 'The two sides produced different rows'
|
||||
if mismatch_count:
|
||||
for kind, name, left, right in mismatches:
|
||||
print(kind, name, '\nvanilla:', json.dumps(left)[:1500], '\nrust: ', json.dumps(right)[:1500])
|
||||
raise AssertionError(f'{len(mismatches)} item model mismatches')
|
||||
raise AssertionError(f'{mismatch_count} item model mismatches')
|
||||
base.ensure_sources_unchanged(sources, tested)
|
||||
assert compared > 1000, 'Too few cases compared'
|
||||
assert skipped * 20 < len(cases), f'Too many cases rejected by vanilla: {skipped}'
|
||||
|
||||
Reference in New Issue
Block a user