Cosmetics: the item format, bones and animations (horizon-cosmetics)

A new crate reads cosmetic item files: boxes with Minecraft's box UV, a
texture of at most 64x64, the player model bone the item follows, its
attachment and simple looping animations (swing, bob, spin). Items past
the vertex budget (512), the part count or a reach of 24 pixels from
the bone are refused. Nothing draws them yet; docs/cosmetics.md.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-02 20:43:03 +01:00
1 parent ebb0b9fe97
commit 2111ead65d
8 files changed
+874

No files matched your search

+1
View File
@@ -55,6 +55,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
| `horizon-server` | `horizon-server`: `server.toml`, CLI, supervisor of the headless game ([docs/hosting.md](docs/hosting.md)) |
| `horizon-server-files` | `server.properties`, eula, ops/whitelist/bans, usercache |
| `horizon-anticheat` | The dedicated server's anticheat, off by default: `[anticheat]` settings, violation levels, the checks' rules; the hub runs them (`lan/anticheat.rs`) ([docs/anticheat.md](docs/anticheat.md)) |
| `horizon-cosmetics` | Cosmetic items Horizon clients draw on players (off by default): the item format, bones and animations ([docs/cosmetics.md](docs/cosmetics.md)) |
| `horizon-friends`, `horizon-relay` | Play with Friends: invite codes, encrypted tunnel, the relay ([docs/play-with-friends.md](docs/play-with-friends.md)) |
| `horizon-mods`, `cargo-horizon-mod` | Content mods (`.hmod`: manifest, content fence, no code), the registry index and `.hpack`, and `cargo horizon-mod` (`new`, `build`, `run`, `check`, `registry`) ([docs/mods.md](docs/mods.md)). Code mods are native, built by the launcher ([docs/native-mods.md](docs/native-mods.md)) |
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
Generated
+11
View File
@@ -1497,6 +1497,17 @@ dependencies = [
"libm",
]
[[package]]
name = "horizon-cosmetics"
version = "0.1.0"
dependencies = [
"base64",
"glam",
"png",
"serde",
"serde_json",
]
[[package]]
name = "horizon-dualsense"
version = "0.1.0"
+1
View File
@@ -12,6 +12,7 @@ members = [
"crates/horizon-client-core",
"crates/horizon-client-input",
"crates/horizon-client-render",
"crates/horizon-cosmetics",
"crates/horizon-command",
"crates/horizon-items",
"crates/horizon-server-files",
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "horizon-cosmetics"
version = "0.1.0"
edition = "2024"
license = "MIT"
description = "Cosmetic items drawn on players by Horizon clients: the item format, attachment to the player model's bones, simple animations"
[dependencies]
base64 = "0.22"
glam = "0.30"
png = "0.18"
serde = { version = "1", features = ["derive"] }
serde_json = "1.0.151"
[lints]
workspace = true
+553
View File
@@ -0,0 +1,553 @@
//! The item file: JSON with the model, its attachment and its texture (a
//! base64 PNG), checked against the limits below when read.
use crate::pose::{Pose, pose_matrix};
use serde::Deserialize;
/// The item file's version.
pub const FORMAT: u32 = 1;
/// Vertices one item may draw (4 per face): 21 boxes. A player's own
/// model and its outer layer are 288.
pub const MAX_VERTICES: usize = 512;
/// The largest texture side: an item's texture fits one skin slot of the
/// game's atlas.
pub const MAX_TEXTURE: u32 = 64;
/// Parts per item, the attachment's root not counted.
pub const MAX_PARTS: usize = 16;
/// How far (pixels, 1/16 block) any vertex may reach from the bone's pivot
/// at rest, animations included: an item stays near its wearer.
pub const MAX_REACH: f32 = 24.0;
/// Where an item is worn: one item per slot.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Slot {
Head,
Back,
Shoulder,
}
/// The part of the player model an item follows.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Bone {
Head,
Body,
LeftArm,
RightArm,
LeftLeg,
RightLeg,
}
impl Bone {
/// The part's name in the player model.
pub fn part(self) -> &'static str {
match self {
Bone::Head => "head",
Bone::Body => "body",
Bone::LeftArm => "left_arm",
Bone::RightArm => "right_arm",
Bone::LeftLeg => "left_leg",
Bone::RightLeg => "right_leg",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Axis {
X,
Y,
Z,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Motion {
/// Turns back and forth: `amplitude` degrees about the axis.
Swing,
/// Moves back and forth: `amplitude` pixels along the axis.
Bob,
/// Turns all the way round once per period.
Spin,
}
/// A looping animation of one part, on top of its rest pose: a sine of
/// `period` ticks starting `phase` (a fraction of the period) in.
#[derive(Clone, Debug, PartialEq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Animation {
pub part: String,
pub motion: Motion,
pub axis: Axis,
#[serde(default)]
pub amplitude: f32,
pub period: f32,
#[serde(default)]
pub phase: f32,
}
/// One quad: the game's `Polygon` (model pixels, then UV in 0..1 of the
/// item's texture).
#[derive(Clone, Debug, PartialEq)]
pub struct Face {
pub normal: [f32; 3],
pub vertices: [[f32; 5]; 4],
}
/// A part: its pose (as the game's: x, y, z in pixels, rotations in
/// radians, scales), its faces and its children.
#[derive(Clone, Debug, PartialEq)]
pub struct Part {
pub name: String,
pub pose: Pose,
pub faces: Vec<Face>,
pub children: Vec<Part>,
}
/// RGBA pixels, row by row.
#[derive(Clone, Debug, PartialEq)]
pub struct Texture {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// A checked item. `root` is named `cosmetic` and holds the attachment as
/// its pose; the item's own parts are its children.
#[derive(Clone, Debug, PartialEq)]
pub struct Item {
pub id: String,
pub slot: Slot,
pub bone: Bone,
pub root: Part,
pub texture: Texture,
pub animations: Vec<Animation>,
pub vertices: usize,
}
#[derive(Debug, PartialEq)]
pub enum Error {
Json(String),
Format(u32),
Texture(String),
TooManyVertices(usize),
TooManyParts(usize),
TooFar(f32),
Invalid(String),
}
impl std::fmt::Display for Error {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Error::Json(e) => write!(f, "not an item file: {e}"),
Error::Format(v) => write!(f, "item format {v}, this game reads {FORMAT}"),
Error::Texture(e) => write!(f, "texture: {e}"),
Error::TooManyVertices(n) => write!(f, "{n} vertices, at most {MAX_VERTICES}"),
Error::TooManyParts(n) => write!(f, "{n} parts, at most {MAX_PARTS}"),
Error::TooFar(d) => write!(
f,
"reaches {d:.1} pixels from its bone, at most {MAX_REACH}"
),
Error::Invalid(e) => f.write_str(e),
}
}
}
impl std::error::Error for Error {}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ItemFile {
format: u32,
id: String,
slot: Slot,
bone: Bone,
#[serde(default)]
attach: AttachFile,
texture: TextureFile,
parts: Vec<PartFile>,
#[serde(default)]
animations: Vec<Animation>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AttachFile {
#[serde(default)]
offset: [f32; 3],
#[serde(default)]
rotation: [f32; 3],
#[serde(default = "one")]
scale: f32,
}
impl Default for AttachFile {
fn default() -> Self {
Self {
offset: [0.0; 3],
rotation: [0.0; 3],
scale: 1.0,
}
}
}
fn one() -> f32 {
1.0
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct TextureFile {
png: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PartFile {
name: String,
#[serde(default)]
pivot: [f32; 3],
/// Degrees.
#[serde(default)]
rotation: [f32; 3],
#[serde(default)]
cubes: Vec<CubeFile>,
#[serde(default)]
children: Vec<PartFile>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct CubeFile {
origin: [f32; 3],
size: [f32; 3],
uv: [f32; 2],
#[serde(default)]
inflate: f32,
#[serde(default)]
mirror: bool,
}
fn invalid(text: impl Into<String>) -> Error {
Error::Invalid(text.into())
}
fn finite(values: &[f32], what: &str) -> Result<(), Error> {
if values.iter().all(|v| v.is_finite()) {
Ok(())
} else {
Err(invalid(format!("{what} is not a number")))
}
}
impl Item {
/// Reads and checks an item file.
pub fn parse(bytes: &[u8]) -> Result<Item, Error> {
let file: ItemFile =
serde_json::from_slice(bytes).map_err(|e| Error::Json(e.to_string()))?;
if file.format != FORMAT {
return Err(Error::Format(file.format));
}
if file.id.is_empty()
|| file.id.len() > 64
|| !file
.id
.bytes()
.all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_')
{
return Err(invalid("the id is 1 to 64 of a-z, 0-9 and _"));
}
let texture = decode_png(&file.texture.png)?;
let a = &file.attach;
finite(&a.offset, "attach.offset")?;
finite(&a.rotation, "attach.rotation")?;
if !(a.scale > 0.0 && a.scale <= 2.0) {
return Err(invalid("attach.scale is above 0 and at most 2"));
}
let [rx, ry, rz] = a.rotation.map(f32::to_radians);
let pose = [
a.offset[0],
a.offset[1],
a.offset[2],
rx,
ry,
rz,
a.scale,
a.scale,
a.scale,
];
let size = [texture.width as f32, texture.height as f32];
let mut names = Vec::new();
let children = file
.parts
.iter()
.map(|p| bake(p, size, &mut names))
.collect::<Result<Vec<_>, _>>()?;
if names.len() > MAX_PARTS {
return Err(Error::TooManyParts(names.len()));
}
let root = Part {
name: "cosmetic".into(),
pose,
faces: Vec::new(),
children,
};
let vertices = count(&root);
if vertices > MAX_VERTICES {
return Err(Error::TooManyVertices(vertices));
}
for anim in &file.animations {
finite(&[anim.amplitude, anim.period, anim.phase], "an animation")?;
if !names.contains(&anim.part) {
return Err(invalid(format!(
"an animation moves no part {:?}",
anim.part
)));
}
let limit = match anim.motion {
Motion::Swing => 45.0,
Motion::Bob => 4.0,
Motion::Spin => 0.0,
};
if anim.amplitude.abs() > limit || anim.period < 4.0 || anim.period > 1200.0 {
return Err(invalid(format!(
"the animation of {:?} is out of bounds",
anim.part
)));
}
}
let item = Item {
id: file.id,
slot: file.slot,
bone: file.bone,
root,
texture,
animations: file.animations,
vertices,
};
let reach = item.reach();
if reach > MAX_REACH {
return Err(Error::TooFar(reach));
}
Ok(item)
}
/// The farthest any vertex gets from the bone's pivot (pixels): at rest,
/// and at 32 points of each animation's period.
fn reach(&self) -> f32 {
let mut times = vec![0.0];
for anim in &self.animations {
times.extend((0..32).map(|k| anim.period * k as f32 / 32.0));
}
let mut far = 0.0_f32;
let mut poses = std::collections::HashMap::new();
for ticks in times {
poses.clear();
self.animate(ticks, |name, pose| {
poses.insert(name.to_owned(), pose);
});
walk(&self.root, glam::Mat4::IDENTITY, &poses, &mut |p| {
far = far.max(p.length() * 16.0);
});
}
far
}
/// The animated parts' poses at `ticks` (game ticks, any start): each
/// is the part's rest pose with its animations added.
pub fn animate(&self, ticks: f32, mut set: impl FnMut(&str, Pose)) {
let mut done: Vec<&str> = Vec::new();
for anim in &self.animations {
if done.contains(&anim.part.as_str()) {
continue;
}
done.push(&anim.part);
let Some(part) = find(&self.root, &anim.part) else {
continue;
};
let mut pose = part.pose;
for a in self.animations.iter().filter(|a| a.part == anim.part) {
let turns = ticks / a.period + a.phase;
let axis = a.axis as usize;
match a.motion {
Motion::Swing => {
let wave = (turns * std::f32::consts::TAU).sin();
pose[3 + axis] += (a.amplitude * wave).to_radians();
}
Motion::Bob => {
pose[axis] += a.amplitude * (turns * std::f32::consts::TAU).sin();
}
Motion::Spin => {
pose[3 + axis] += turns.rem_euclid(1.0) * std::f32::consts::TAU;
}
}
}
set(&anim.part, pose);
}
}
}
/// Every vertex of `part`'s subtree under `parent`, as posed by `poses`
/// (else the parts' rest poses), in blocks.
fn walk(
part: &Part,
parent: glam::Mat4,
poses: &std::collections::HashMap<String, Pose>,
each: &mut impl FnMut(glam::Vec3),
) {
let m = parent * pose_matrix(*poses.get(&part.name).unwrap_or(&part.pose));
for face in &part.faces {
for v in face.vertices {
each(m.transform_point3(glam::Vec3::new(v[0], v[1], v[2]) / 16.0));
}
}
for child in &part.children {
walk(child, m, poses, each);
}
}
fn find<'a>(part: &'a Part, name: &str) -> Option<&'a Part> {
if part.name == name {
return Some(part);
}
part.children.iter().find_map(|c| find(c, name))
}
fn count(part: &Part) -> usize {
part.faces.len() * 4 + part.children.iter().map(count).sum::<usize>()
}
fn bake(file: &PartFile, size: [f32; 2], names: &mut Vec<String>) -> Result<Part, Error> {
if file.name.is_empty() || file.name == "cosmetic" || names.contains(&file.name) {
return Err(invalid(format!("part names are unique: {:?}", file.name)));
}
names.push(file.name.clone());
finite(&file.pivot, "a pivot")?;
finite(&file.rotation, "a rotation")?;
let mut faces = Vec::new();
for cube in &file.cubes {
finite(&cube.origin, "a cube")?;
finite(&cube.size, "a cube")?;
finite(&cube.uv, "a cube")?;
if cube.size.iter().any(|s| *s < 0.0) || !(0.0..=4.0).contains(&cube.inflate) {
return Err(invalid("a cube's size is negative"));
}
let [w, h, d] = cube.size;
// Box UV: the unfolded box spans 2(d + w) by d + h texels.
if cube.uv[0] < 0.0
|| cube.uv[1] < 0.0
|| cube.uv[0] + 2.0 * (d + w) > size[0]
|| cube.uv[1] + d + h > size[1]
{
return Err(invalid("a cube's UV leaves the texture"));
}
faces.extend(box_faces(cube, size));
}
let [rx, ry, rz] = file.rotation.map(f32::to_radians);
let [x, y, z] = file.pivot;
let children = file
.children
.iter()
.map(|c| bake(c, size, names))
.collect::<Result<Vec<_>, _>>()?;
Ok(Part {
name: file.name.clone(),
pose: [x, y, z, rx, ry, rz, 1.0, 1.0, 1.0],
faces,
children,
})
}
/// `ModelPart.Cube`: six faces with box UV, in vanilla's order (down, up,
/// west, north, east, south), grown by `inflate`, mirrored in x on request.
fn box_faces(cube: &CubeFile, [tw, th]: [f32; 2]) -> Vec<Face> {
let [w, h, d] = cube.size;
let g = cube.inflate;
let (mut x0, y0, z0) = (cube.origin[0] - g, cube.origin[1] - g, cube.origin[2] - g);
let (mut x1, y1, z1) = (
cube.origin[0] + w + g,
cube.origin[1] + h + g,
cube.origin[2] + d + g,
);
if cube.mirror {
std::mem::swap(&mut x0, &mut x1);
}
let t0 = [x0, y0, z0];
let t1 = [x1, y0, z0];
let t2 = [x1, y1, z0];
let t3 = [x0, y1, z0];
let l0 = [x0, y0, z1];
let l1 = [x1, y0, z1];
let l2 = [x1, y1, z1];
let l3 = [x0, y1, z1];
let [u, v] = cube.uv;
let (u1, u2, u22) = (u + d, u + d + w, u + d + w + w);
let (u3, u4) = (u + d + w + d, u + d + w + d + w);
let (v1, v2) = (v + d, v + d + h);
let faces: [([[f32; 3]; 4], [f32; 4], [f32; 3]); 6] = [
([l1, l0, t0, t1], [u1, v, u2, v1], [0.0, -1.0, 0.0]),
([t2, t3, l3, l2], [u2, v1, u22, v], [0.0, 1.0, 0.0]),
([t0, l0, l3, t3], [u, v1, u1, v2], [-1.0, 0.0, 0.0]),
([t1, t0, t3, t2], [u1, v1, u2, v2], [0.0, 0.0, -1.0]),
([l1, t1, t2, l2], [u2, v1, u3, v2], [1.0, 0.0, 0.0]),
([l0, l1, l2, l3], [u3, v1, u4, v2], [0.0, 0.0, 1.0]),
];
faces
.into_iter()
.map(|(corners, [a, b, c, e], mut normal)| {
// Polygon's remap: (u1, v0), (u0, v0), (u0, v1), (u1, v1).
let uvs = [[c, b], [a, b], [a, e], [c, e]];
let mut vertices: [[f32; 5]; 4] = std::array::from_fn(|i| {
let [x, y, z] = corners[i];
[x, y, z, uvs[i][0] / tw, uvs[i][1] / th]
});
if cube.mirror {
vertices.reverse();
normal[0] = -normal[0];
}
Face { normal, vertices }
})
.collect()
}
fn decode_png(text: &str) -> Result<Texture, Error> {
use base64::Engine;
let bytes = base64::engine::general_purpose::STANDARD
.decode(text)
.map_err(|e| Error::Texture(e.to_string()))?;
let mut decoder = png::Decoder::new(std::io::Cursor::new(bytes));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder
.read_info()
.map_err(|e| Error::Texture(e.to_string()))?;
let (width, height) = (reader.info().width, reader.info().height);
if width == 0 || height == 0 || width > MAX_TEXTURE || height > MAX_TEXTURE {
return Err(Error::Texture(format!(
"{width}x{height}, at most {MAX_TEXTURE}x{MAX_TEXTURE}"
)));
}
let mut buf = vec![0; reader.output_buffer_size().unwrap_or(0)];
let frame = reader
.next_frame(&mut buf)
.map_err(|e| Error::Texture(e.to_string()))?;
let pixels = &buf[..frame.buffer_size()];
let rgba = match frame.color_type {
png::ColorType::Rgba => pixels.to_vec(),
png::ColorType::Rgb => pixels
.chunks(3)
.flat_map(|p| [p[0], p[1], p[2], 255])
.collect(),
png::ColorType::GrayscaleAlpha => pixels
.chunks(2)
.flat_map(|p| [p[0], p[0], p[0], p[1]])
.collect(),
png::ColorType::Grayscale => pixels.iter().flat_map(|&g| [g, g, g, 255]).collect(),
png::ColorType::Indexed => return Err(Error::Texture("unexpanded palette".into())),
};
Ok(Texture {
width,
height,
rgba,
})
}
+17
View File
@@ -0,0 +1,17 @@
//! Cosmetic items that Horizon clients draw on players: hats, back items,
//! shoulder pets. Only Horizon clients see them; the game protocol carries
//! nothing about them ([docs/cosmetics.md](../../../docs/cosmetics.md)).
//!
//! An item is a small model of boxes (Minecraft's box UV, as Blockbench's
//! "Modded Entity" format), one texture of at most 64×64, the bone of the
//! player model it rides on and a few simple looping animations. This crate
//! reads and checks items and poses them; the game draws them.
mod item;
mod pose;
pub use item::{
Animation, Axis, Bone, Error, FORMAT, Face, Item, MAX_PARTS, MAX_REACH, MAX_TEXTURE,
MAX_VERTICES, Motion, Part, Slot, Texture,
};
pub use pose::{Pose, pose_matrix};
+214
View File
@@ -0,0 +1,214 @@
//! Poses as the game's model parts have them.
/// x, y, z (pixels), x, y, z rotations (radians), x, y, z scales: the
/// game's `Part::pose`.
pub type Pose = [f32; 9];
/// `ModelPart.translateAndRotate`: the matrix a part's pose applies under
/// its parent's (the game's `mobs::render::posed`). An item's root pose is
/// its attachment, so the item sits at `bone * pose_matrix(root.pose)`.
pub fn pose_matrix(p: Pose) -> glam::Mat4 {
glam::Mat4::from_translation(glam::Vec3::new(p[0], p[1], p[2]) / 16.0)
* glam::Mat4::from_quat(glam::Quat::from_euler(
glam::EulerRot::ZYX,
p[5],
p[4],
p[3],
))
* glam::Mat4::from_scale(glam::Vec3::new(p[6], p[7], p[8]))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Bone, Error, Item, MAX_VERTICES, Motion, Slot};
use glam::{Mat4, Vec3};
/// A `width`×`height` PNG, base64.
pub(crate) fn png(width: u32, height: u32) -> String {
use base64::Engine;
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, width, height);
encoder.set_color(png::ColorType::Rgba);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&vec![200; (width * height * 4) as usize])
.unwrap();
writer.finish().unwrap();
base64::engine::general_purpose::STANDARD.encode(bytes)
}
/// A hat: an 8×2×8 brim 8 pixels above the head's pivot, and a crown
/// on it that swings 10° about z over 40 ticks.
fn hat(extra: &str) -> String {
format!(
r#"{{"format": 1, "id": "hat", "slot": "head", "bone": "head",
"attach": {{"offset": [0, -8, 0]}},
"texture": {{"png": "{}"}},
"parts": [{{"name": "brim", "cubes": [{{"origin": [-4, -2, -4], "size": [8, 2, 8], "uv": [0, 0]}}],
"children": [{{"name": "crown", "pivot": [0, -2, 0],
"cubes": [{{"origin": [-2, -4, -2], "size": [4, 4, 4], "uv": [0, 10]}}]}}]}}],
"animations": [{{"part": "crown", "motion": "swing", "axis": "z", "amplitude": 10, "period": 40}}]
{extra}}}"#,
png(32, 32)
)
}
/// The posed position of a part's first face's vertex `i`.
fn corner(item: &Item, bone: Mat4, path: &[&str], face: usize, i: usize, ticks: f32) -> Vec3 {
let mut poses = std::collections::HashMap::new();
item.animate(ticks, |n, p| {
poses.insert(n.to_owned(), p);
});
let mut m = bone * pose_matrix(item.root.pose);
let mut part = &item.root;
for name in path {
part = part.children.iter().find(|c| c.name == *name).unwrap();
m *= pose_matrix(*poses.get(*name).unwrap_or(&part.pose));
}
let v = part.faces[face].vertices[i];
m.transform_point3(Vec3::new(v[0], v[1], v[2]) / 16.0)
}
fn close(a: Vec3, b: Vec3) {
assert!((a - b).abs().max_element() < 1e-5, "{a} != {b}");
}
#[test]
fn reads_the_item_and_bakes_box_uv() {
let item = Item::parse(hat("").as_bytes()).unwrap();
assert_eq!((item.slot, item.bone), (Slot::Head, Bone::Head));
assert_eq!(item.vertices, 2 * 6 * 4);
assert_eq!(
(item.texture.width, item.texture.rgba.len()),
(32, 32 * 32 * 4)
);
// The brim's down face: corners l1, l0, t0, t1 with UV (u+d, v) to
// (u+d+w, v+d) remapped as (u1,v0) (u0,v0) (u0,v1) (u1,v1).
let down = &item.root.children[0].faces[0];
assert_eq!(down.normal, [0.0, -1.0, 0.0]);
assert_eq!(
down.vertices,
[
[4.0, -2.0, 4.0, 16.0 / 32.0, 0.0],
[-4.0, -2.0, 4.0, 8.0 / 32.0, 0.0],
[-4.0, -2.0, -4.0, 8.0 / 32.0, 8.0 / 32.0],
[4.0, -2.0, -4.0, 16.0 / 32.0, 8.0 / 32.0],
]
);
}
#[test]
fn the_item_follows_its_bone() {
let item = Item::parse(hat("").as_bytes()).unwrap();
// Brim face 0, vertex 2 is (-4, -2, -4) px: 8 px up from the
// attachment, (-0.25, -0.625, -0.25) blocks from the bone's pivot.
close(
corner(&item, Mat4::IDENTITY, &["brim"], 0, 2, 0.0),
Vec3::new(-0.25, -0.625, -0.25),
);
// The head turned 90° about y: (x, y, z) -> (z, y, -x).
let turned = Mat4::from_rotation_y(90f32.to_radians());
close(
corner(&item, turned, &["brim"], 0, 2, 0.0),
Vec3::new(-0.25, -0.625, 0.25),
);
// The head 1.5 blocks up and nodding 90° about x: (x, y, z) -> (x, -z, y).
let nod = Mat4::from_translation(Vec3::new(0.0, 1.5, 0.0))
* Mat4::from_rotation_x(90f32.to_radians());
close(
corner(&item, nod, &["brim"], 0, 2, 0.0),
Vec3::new(-0.25, 1.5 + 0.25, -0.625),
);
}
#[test]
fn swings_on_its_period() {
let item = Item::parse(hat("").as_bytes()).unwrap();
// The crown's down face, vertex 2: (-2, -4, -2) px from its pivot,
// which is 10 px above the bone's.
let rest = Vec3::new(-2.0, -14.0, -2.0) / 16.0;
close(
corner(&item, Mat4::IDENTITY, &["brim", "crown"], 0, 2, 0.0),
rest,
);
close(
corner(&item, Mat4::IDENTITY, &["brim", "crown"], 0, 2, 20.0),
rest,
);
// A quarter period: 10° about z at the crown's pivot (0, -10, 0) px:
// (x, y) -> (x cos - y sin, x sin + y cos) about it.
let (s, c) = 10f32.to_radians().sin_cos();
let (x, y) = (-2.0, -4.0);
let swung = Vec3::new(x * c - y * s, -10.0 + x * s + y * c, -2.0) / 16.0;
close(
corner(&item, Mat4::IDENTITY, &["brim", "crown"], 0, 2, 10.0),
swung,
);
// The same pose a period later.
close(
corner(&item, Mat4::IDENTITY, &["brim", "crown"], 0, 2, 50.0),
swung,
);
}
#[test]
fn spins_once_per_period_and_bobs() {
let spin = hat("").replace(
r#""motion": "swing", "axis": "z", "amplitude": 10"#,
r#""motion": "spin", "axis": "y""#,
);
let item = Item::parse(spin.as_bytes()).unwrap();
assert_eq!(item.animations[0].motion, Motion::Spin);
let mut got = None;
item.animate(10.0, |_, p| got = Some(p));
assert!((got.unwrap()[4] - std::f32::consts::FRAC_PI_2).abs() < 1e-6);
let bob = hat("").replace(
r#""motion": "swing", "axis": "z", "amplitude": 10"#,
r#""motion": "bob", "axis": "y", "amplitude": 1.5"#,
);
let item = Item::parse(bob.as_bytes()).unwrap();
item.animate(10.0, |_, p| got = Some(p));
// The crown's pivot y is -2; a quarter period adds the amplitude.
assert!((got.unwrap()[1] - (-2.0 + 1.5)).abs() < 1e-6);
}
#[test]
fn refuses_items_past_the_limits() {
let big = hat("").replace(r#""size": [4, 4, 4]"#, r#""size": [4, 4, 4], "inflate": 0"#);
assert!(Item::parse(big.as_bytes()).is_ok());
// 22 boxes of 24 vertices are past the budget.
let cube = r#"{"origin": [0, 0, 0], "size": [1, 1, 1], "uv": [0, 0]}"#;
let many = vec![cube; MAX_VERTICES / 24 + 1].join(",");
let file = hat("").replace(
r#""cubes": [{"origin": [-4, -2, -4], "size": [8, 2, 8], "uv": [0, 0]}]"#,
&format!(r#""cubes": [{many}]"#),
);
assert_eq!(
Item::parse(file.as_bytes()),
Err(Error::TooManyVertices(23 * 24))
);
// Far from the head.
let far = hat("").replace(r#""offset": [0, -8, 0]"#, r#""offset": [0, -30, 0]"#);
assert!(matches!(Item::parse(far.as_bytes()), Err(Error::TooFar(_))));
// A texture past 64x64.
let wide = hat("").replace(&png(32, 32), &png(128, 32));
assert!(matches!(
Item::parse(wide.as_bytes()),
Err(Error::Texture(_))
));
// UV off the texture, an animation of no part, a wild swing.
for (from, to) in [
(r#""uv": [0, 10]"#, r#""uv": [20, 10]"#),
(r#""part": "crown""#, r#""part": "tail""#),
(r#""amplitude": 10"#, r#""amplitude": 90"#),
(r#""id": "hat""#, r#""id": "Hat!""#),
] {
assert!(
Item::parse(hat("").replace(from, to).as_bytes()).is_err(),
"{to}"
);
}
assert!(Item::parse(hat(r#", "glow": true"#).as_bytes()).is_err());
}
}
+61
View File
@@ -0,0 +1,61 @@
# Cosmetics
Small items Horizon players wear: a hat, something on the back, a pet on the shoulder. They are
drawn by Horizon clients only and change nothing in the game. **Off by default**: nothing below
runs until the feature flag is turned on.
## Rules
- **Only Horizon clients see them.** Other Horizon players learn what someone wears from Horizon's
cosmetics service, keyed by the Minecraft UUID. The game protocol doesn't change: servers, vanilla
clients and anticheats receive nothing.
- **No gameplay effect of any kind**, no capes or anything that looks or acts like one, no random
draws.
- **A setting hides them**: other players' cosmetics, and your own.
- **Zero cost when none is in view**, and a vertex budget per item.
## The item format (`crates/horizon-cosmetics`)
One JSON file per item. Coordinates are model pixels (1/16 block) in the player model's space:
y points down, as in vanilla's `ModelPart`s and Blockbench's "Modded Entity" models.
```json
{
"format": 1,
"id": "example_hat",
"slot": "head",
"bone": "head",
"attach": { "offset": [0, -8, 0], "rotation": [0, 0, 0], "scale": 1 },
"texture": { "png": "<base64 PNG>" },
"parts": [
{ "name": "brim", "pivot": [0, 0, 0], "rotation": [0, 0, 0],
"cubes": [{ "origin": [-4, -2, -4], "size": [8, 2, 8], "uv": [0, 0], "inflate": 0, "mirror": false }],
"children": [] }
],
"animations": [
{ "part": "brim", "motion": "swing", "axis": "z", "amplitude": 10, "period": 40, "phase": 0 }
]
}
```
- `slot`: `head`, `back` or `shoulder`; a player wears at most one item per slot.
- `bone`: the player model part the item follows (`head`, `body`, `left_arm`, `right_arm`,
`left_leg`, `right_leg`). The item sits at the bone's matrix times `attach` (offset in pixels,
rotation in degrees, a uniform scale in (0, 2]).
- `parts`: a tree of parts, each with a pivot, a rotation in degrees and boxes with Minecraft's box
UV (`ModelPart.Cube`'s faces and order), grown by `inflate`, mirrored in x on request. Names are
unique.
- `animations`: loops on top of a part's rest pose, a sine of `period` ticks (4 to 1200) started
`phase` periods in. `swing` turns the part `amplitude` degrees (at most 45) about the axis, `bob`
moves it `amplitude` pixels (at most 4), `spin` turns it all the way round once per period.
Limits, checked when an item is read (`Item::parse`); an item past one is refused, never drawn:
| Limit | Value | Why |
|---|---|---|
| Vertices | 512 (21 boxes) | the player's own model and outer layer are 288 |
| Texture | 64×64 at most | it fits one of the atlas's skin slots |
| Parts | 16 | |
| Reach | 24 pixels from the bone's pivot, animations included | items stay near their wearer |
Unknown fields are refused, so a file never carries anything the game would silently ignore.