Merge main into #7479

Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/extras.rs
#	docs/extras.md
This commit is contained in:
Ludovic Marie committed 2026-11-02 23:44:13 +01:00
commit dbf5ea1396
77 files changed
+2855 -273

No files matched your search

Generated
+2
View File
@@ -1319,6 +1319,7 @@ dependencies = [
"horizon-client-render",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-dualsense",
"horizon-friends",
"horizon-hdr",
@@ -1451,6 +1452,7 @@ dependencies = [
"horizon-client-input",
"horizon-command",
"horizon-core",
"horizon-cosmetics",
"horizon-hdr",
"horizon-items",
"horizon-narrator",
+35
View File
@@ -17,6 +17,7 @@ use std::{
};
mod component_predicate;
mod foreign;
mod special;
pub use special::{SpecialKind, SpecialModel};
@@ -451,12 +452,21 @@ impl ItemModelNode {
.collect::<Result<_>>()?,
transformation: optional_transformation(m)?,
},
"minecraft:condition" if foreign::property_is_foreign(m)? => foreign::wrap(
Self::decode(required(m, "on_false")?)?,
optional_transformation(m)?,
),
"minecraft:condition" => Self::Condition {
transformation: optional_transformation(m)?,
property: ConditionProperty::decode(m)?,
on_true: Box::new(Self::decode(required(m, "on_true")?)?),
on_false: Box::new(Self::decode(required(m, "on_false")?)?),
},
"minecraft:select" if foreign::property_is_foreign(m)? => {
// No case can match a property Horizon cannot read.
let fallback = get(m, "fallback").map(Self::decode).transpose()?;
foreign::wrap(fallback.unwrap_or(Self::Empty), optional_transformation(m)?)
}
"minecraft:select" => {
let transformation = optional_transformation(m)?;
let property = SelectProperty::decode(m)?;
@@ -484,6 +494,7 @@ impl ItemModelNode {
}
}
"minecraft:range_dispatch" => {
let foreign_property = foreign::property_is_foreign(m)?;
let mut entries = list(required(m, "entries")?)?
.iter()
.map(|e| {
@@ -496,6 +507,17 @@ impl ItemModelNode {
.collect::<Result<Vec<_>>>()?;
// `Comparator.comparingDouble`: Double.compare, a total order.
entries.sort_by(|a, b| f64::from(a.0).total_cmp(&f64::from(b.0)));
if foreign_property {
// A property Horizon cannot read is 0.
let fallback = get(m, "fallback").map(Self::decode).transpose()?;
let chosen = last_index_less_or_equal(&entries, 0.0)
.map(|i| entries.swap_remove(i).1)
.or(fallback);
return Ok(foreign::wrap(
chosen.unwrap_or(Self::Empty),
optional_transformation(m)?,
));
}
Self::RangeDispatch {
transformation: optional_transformation(m)?,
property: NumericProperty::decode(m)?,
@@ -506,12 +528,21 @@ impl ItemModelNode {
.transpose()?,
}
}
"minecraft:special" if SpecialModel::is_foreign(required(m, "model")?) => {
// The mod's own renderer is not run: the base model stands in.
Self::Model(ModelLeaf {
model: ident(required(m, "base")?)?,
transformation: optional_transformation(m)?,
tints: Vec::new(),
})
}
"minecraft:special" => Self::Special {
base: ident(required(m, "base")?)?,
transformation: optional_transformation(m)?,
model: SpecialModel::decode(required(m, "model")?)?,
},
"minecraft:bundle/selected_item" => Self::BundleSelectedItem,
other if foreign::is_foreign(other) => foreign::decode_node(other, m),
_ => return Err(err("unknown item model type")),
})
}
@@ -603,6 +634,8 @@ impl TintSource {
"minecraft:firework" => Self::Firework(default()?),
"minecraft:potion" => Self::Potion(default()?),
"minecraft:team" => Self::Team(default()?),
// A mod's own tint source, which its Java code computes: untinted here.
other if foreign::is_foreign(other) => Self::Constant(-1),
_ => return Err(err("unknown item tint source")),
})
}
@@ -1717,5 +1750,7 @@ fn binary_search(a: &[f32], key: f32) -> std::result::Result<usize, usize> {
Err(low as usize)
}
#[cfg(test)]
mod foreign_tests;
#[cfg(test)]
mod tests;
@@ -0,0 +1,114 @@
//! Nodes and properties of other namespaces (a mod's, NeoForge's) decode to the closest vanilla behaviour.
use super::{
ItemModelNode, ModelLeaf, Result, TintSource, get, ident, identifier, list, object,
optional_transformation,
};
use crate::{json::error as err, math::Mat4};
use serde_json::{Map, Value};
/// Keys a mod's node usually keeps its real model under, in order of preference.
const MODEL_KEYS: [&str; 4] = ["model", "base_model", "base", "fallback"];
pub fn is_foreign(id: &str) -> bool {
!id.starts_with("minecraft:")
}
/// A composite carries the transformation a collapsed node had.
pub fn wrap(node: ItemModelNode, transformation: Option<Mat4>) -> ItemModelNode {
match transformation {
None => node,
Some(_) => ItemModelNode::Composite {
models: vec![node],
transformation,
},
}
}
pub fn property_is_foreign(m: &Map<String, Value>) -> Result<bool> {
let property = m
.get("property")
.ok_or_else(|| err("missing property"))
.and_then(ident)?;
Ok(is_foreign(&property))
}
fn leaf(model: String, m: &Map<String, Value>) -> ItemModelNode {
let tints = get(m, "tints")
.and_then(|t| list(t).ok())
.map(|t| {
t.iter()
.filter_map(|t| TintSource::decode(t).ok())
.collect()
})
.unwrap_or_default();
ItemModelNode::Model(ModelLeaf {
model,
transformation: optional_transformation(m).ok().flatten(),
tints,
})
}
/// A node of an unknown non-vanilla type: its nested node, else its model id, else nothing.
pub fn decode_node(kind: &str, m: &Map<String, Value>) -> ItemModelNode {
if kind == "neoforge:fluid_container" {
return fluid_container(m);
}
for key in MODEL_KEYS {
match m.get(key) {
Some(nested @ Value::Object(_)) => {
if let Ok(node) = ItemModelNode::decode(nested) {
return wrap(node, optional_transformation(m).ok().flatten());
}
}
Some(Value::String(id)) => {
if let Ok(id) = identifier(id) {
return leaf(id, m);
}
}
_ => {}
}
}
let by_suffix = m
.iter()
.filter(|(k, _)| k.ends_with("_model"))
.find_map(|(_, v)| v.as_str().and_then(|id| identifier(id).ok()));
match by_suffix {
Some(id) => leaf(id, m),
None => ItemModelNode::Empty,
}
}
// The fluid is not drawn: only the base (and an unmasked cover) texture, taken as a model id (a bucket's).
fn fluid_container(m: &Map<String, Value>) -> ItemModelNode {
let textures = m.get("textures").and_then(|t| object(t).ok());
let texture = |key: &str| {
textures
.and_then(|t| t.get(key))
.and_then(Value::as_str)
.and_then(|id| identifier(id).ok())
};
let mut layers = vec![texture("base")];
if m.get("cover_is_mask") == Some(&Value::Bool(false)) {
layers.push(texture("cover"));
}
let mut models: Vec<ItemModelNode> = layers
.into_iter()
.flatten()
.map(|model| {
ItemModelNode::Model(ModelLeaf {
model,
transformation: None,
tints: Vec::new(),
})
})
.collect();
let transformation = optional_transformation(m).ok().flatten();
match models.len() {
0 => ItemModelNode::Empty,
1 => wrap(models.remove(0), transformation),
_ => ItemModelNode::Composite {
models,
transformation,
},
}
}
@@ -0,0 +1,174 @@
use super::*;
fn node(json: &str) -> Result<ItemModelNode> {
ItemModelNode::decode(&serde_json::from_str(json).unwrap())
}
fn model_id(n: &ItemModelNode) -> &str {
match n {
ItemModelNode::Model(leaf) => &leaf.model,
other => panic!("not a model: {other:?}"),
}
}
#[test]
fn node_with_a_model_id_draws_that_model() {
let n = node(r#"{"type":"mod:thing","model":"mod:item/thing","tints":[{"type":"mod:tint"}]}"#)
.unwrap();
assert_eq!(model_id(&n), "mod:item/thing");
let ItemModelNode::Model(leaf) = n else {
unreachable!()
};
assert_eq!(leaf.tints, vec![TintSource::Constant(-1)]);
let n = node(r#"{"type":"mod:thing","base_model":"mod:item/base"}"#).unwrap();
assert_eq!(model_id(&n), "mod:item/base");
let n = node(r#"{"type":"mod:thing","on_model":"mod:item/a"}"#).unwrap();
assert_eq!(model_id(&n), "mod:item/a");
}
#[test]
fn node_with_a_nested_node_decodes_it() {
let n = node(
r#"{"type":"neoforge:trimmed_armor","base_model":{"type":"minecraft:model","model":"mod:item/boots"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "mod:item/boots");
let n = node(
r#"{"type":"mod:x","model":{"type":"mod:y","model":"mod:item/inner"},"transformation":[1,0,0,0,0,1,0,0,0,0,1,0,0,0,0,1]}"#,
)
.unwrap();
let ItemModelNode::Composite {
models,
transformation,
} = n
else {
panic!("a transformation wraps the nested node")
};
assert!(transformation.is_some());
assert_eq!(model_id(&models[0]), "mod:item/inner");
}
#[test]
fn node_with_nothing_usable_is_empty() {
assert!(matches!(
node(r#"{"type":"mod:facade"}"#).unwrap(),
ItemModelNode::Empty
));
assert!(matches!(
node(r#"{"type":"mod:x","model":7}"#).unwrap(),
ItemModelNode::Empty
));
assert!(ClientItem::from_json(r#"{"model":{"type":"ae2:facade"}}"#).is_ok());
}
#[test]
fn unknown_vanilla_names_still_fail() {
assert!(node(r#"{"type":"minecraft:nope"}"#).is_err());
assert!(node(r#"{"type":"nope","model":"a:b"}"#).is_err());
let leaf = r#"{"type":"minecraft:model","model":"a:b"}"#;
for json in [
format!(
r#"{{"type":"minecraft:condition","property":"minecraft:nope","on_true":{leaf},"on_false":{leaf}}}"#
),
format!(
r#"{{"type":"minecraft:select","property":"minecraft:nope","cases":[],"fallback":{leaf}}}"#
),
format!(
r#"{{"type":"minecraft:range_dispatch","property":"minecraft:nope","entries":[],"fallback":{leaf}}}"#
),
] {
assert!(node(&json).is_err(), "{json}");
}
assert!(
node(r#"{"type":"minecraft:special","base":"a:b","model":{"type":"minecraft:nope"}}"#)
.is_err()
);
assert!(ClientItem::from_json(r#"{"model":{"type":"minecraft:nope"}}"#).is_err());
}
#[test]
fn condition_property_is_false() {
let n = node(
r#"{"type":"minecraft:condition","property":"mod:flag",
"on_true":{"type":"minecraft:model","model":"a:yes"},
"on_false":{"type":"minecraft:model","model":"a:no"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "a:no");
}
#[test]
fn select_property_takes_the_fallback_or_nothing() {
let n = node(
r#"{"type":"minecraft:select","property":"mod:level",
"cases":[{"when":"low","model":{"type":"minecraft:model","model":"a:low"}}],
"fallback":{"type":"minecraft:model","model":"a:other"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "a:other");
let n = node(r#"{"type":"minecraft:select","property":"mod:level","cases":[]}"#).unwrap();
assert!(matches!(n, ItemModelNode::Empty));
}
#[test]
fn range_property_reads_zero() {
let entry = |t: f32, m: &str| {
format!(r#"{{"threshold":{t},"model":{{"type":"minecraft:model","model":"{m}"}}}}"#)
};
let fallback = r#","fallback":{"type":"minecraft:model","model":"a:fb"}"#;
let make = |entries: &[String], fallback: &str| {
node(&format!(
r#"{{"type":"minecraft:range_dispatch","property":"mod:fill","entries":[{}]{fallback}}}"#,
entries.join(",")
))
.unwrap()
};
let n = make(&[entry(0.2, "a:one"), entry(0.4, "a:two")], fallback);
assert_eq!(model_id(&n), "a:fb");
let n = make(
&[
entry(0.0, "a:zero"),
entry(-1.0, "a:neg"),
entry(0.5, "a:half"),
],
fallback,
);
assert_eq!(model_id(&n), "a:zero");
let n = make(&[entry(0.2, "a:one")], "");
assert!(matches!(n, ItemModelNode::Empty));
}
#[test]
fn special_type_keeps_its_base_model() {
let n = node(
r#"{"type":"minecraft:special","base":"mod:item/cube","model":{"type":"mod:energy_cube"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "mod:item/cube");
let n =
node(r#"{"type":"minecraft:special","base":"a:b","model":{"type":"minecraft:shield"}}"#)
.unwrap();
assert!(matches!(n, ItemModelNode::Special { .. }));
}
#[test]
fn fluid_container_draws_its_base_without_the_fluid() {
let n = node(
r#"{"type":"neoforge:fluid_container","fluid":"mod:gas","cover_is_mask":false,"flip_gas":true,
"textures":{"base":"minecraft:item/bucket","fluid":"neoforge:item/mask/bucket_fluid"}}"#,
)
.unwrap();
assert_eq!(model_id(&n), "minecraft:item/bucket");
let n = node(
r#"{"type":"neoforge:fluid_container","cover_is_mask":false,
"textures":{"base":"a:item/flask","cover":"a:item/flask_cover"}}"#,
)
.unwrap();
let ItemModelNode::Composite { models, .. } = n else {
panic!("base and cover")
};
assert_eq!(models.len(), 2);
assert!(matches!(
node(r#"{"type":"neoforge:fluid_container"}"#).unwrap(),
ItemModelNode::Empty
));
}
@@ -77,6 +77,13 @@ fn one_of(v: &Value, names: &[&str]) -> Result<String> {
}
impl SpecialModel {
/// A special model type of another namespace, which Horizon has no renderer for.
pub fn is_foreign(v: &Value) -> bool {
v.get("type")
.and_then(Value::as_str)
.and_then(|t| super::identifier(t).ok())
.is_some_and(|t| super::foreign::is_foreign(&t))
}
pub fn decode(v: &Value) -> Result<Self> {
let m = object(v)?;
let float_or = |key: &str, default: f32| get(m, key).map_or(Ok(default), float);
@@ -18,3 +18,13 @@ fn display_context_names_round_trip() {
assert_eq!(DisplayContext::by_name(name).unwrap().name(), name);
}
}
#[test]
fn a_mods_tint_source_is_untinted_and_an_unknown_vanilla_one_fails() {
let tint = |json: &str| TintSource::decode(&serde_json::from_str(json).unwrap());
assert_eq!(
tint(r#"{"type":"ae2:storage_cell_state"}"#).unwrap(),
TintSource::Constant(-1)
);
assert!(tint(r#"{"type":"minecraft:nonsense"}"#).is_err());
}
+1
View File
@@ -7,6 +7,7 @@ rust-version = "1.90"
description = "The client's world data, rendering and screens: chunks, meshing, light, streaming, the renderer, the HUD, menus and settings; no gameplay"
[dependencies]
horizon-cosmetics = { path = "../horizon-cosmetics" }
# The GUI atlas's pixels, kept compressed once uploaded (hud.rs `pack_pixels`).
lz4_flex = { version = "0.14", default-features = false, features = ["std", "safe-encode", "safe-decode", "checked-decode"] }
horizon-assets = { path = "../horizon-assets" }
+19
View File
@@ -434,6 +434,25 @@ impl Biomes {
}
changed.then_some(out)
}
/// This table (the biome files') with its colours passed through `f`, when
/// they differ from `current`'s: what a refresh of a table already
/// coloured by `f` once must not compound.
pub fn recolored_from(
&self,
current: &Self,
f: impl FnMut(&str, &mut BiomeColors),
) -> Option<Self> {
let out = self.with_colors(f).unwrap_or_else(|| self.clone());
let tints = |b: &BiomeInfo| (b.grass, b.foliage, b.dry_foliage, b.water);
let same = out.list.len() == current.list.len()
&& out
.list
.iter()
.zip(&current.list)
.all(|(a, b)| tints(a) == tints(b));
(!same).then_some(out)
}
}
/// A 24-bit colour as sRGB components in 0..1.
+42 -4
View File
@@ -146,6 +146,10 @@ pub struct Extras {
/// built-in track and leaving the off ones out (`musicPage`, off by
/// default: vanilla plays exactly as before).
pub music_page: bool,
/// Cosmetics (`horizon_cosmetics`, off unless enabled): the player's own, and the other
/// players' (`cosmeticsMine`, `cosmeticsOthers`; both on by default).
pub cosmetics_mine: bool,
pub cosmetics_others: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -191,6 +195,8 @@ impl Default for Extras {
wheel_cycles_settings: true,
music_off: Vec::new(),
music_page: false,
cosmetics_mine: true,
cosmetics_others: true,
}
}
}
@@ -285,6 +291,8 @@ impl Extras {
.collect()
}
"musicPage" => e.music_page = flag(e.music_page),
"cosmeticsMine" => e.cosmetics_mine = flag(e.cosmetics_mine),
"cosmeticsOthers" => e.cosmetics_others = flag(e.cosmetics_others),
_ => {}
}
}
@@ -292,7 +300,7 @@ 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:{}\n",
"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",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -339,6 +347,8 @@ impl Extras {
bool_text(self.wheel_cycles_settings),
self.music_off.join(","),
bool_text(self.music_page),
bool_text(self.cosmetics_mine),
bool_text(self.cosmetics_others),
)
}
/// Binds the extra key `name` (`SORT_KEY`); false for
@@ -486,11 +496,14 @@ pub enum Item {
/// On the Splitscreen settings page.
Splitscreen,
WheelCyclesSettings,
/// Shown only while cosmetics are enabled (`horizon_cosmetics::enabled`).
CosmeticsMine,
CosmeticsOthers,
}
/// The Extras page: headers (language keys) and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
use Item::*;
vec![
let mut rows = vec![
Err("horizon.extras.header.lighting"),
Ok(Fullbright),
Ok(DynamicLights),
@@ -513,7 +526,15 @@ pub fn rows() -> Vec<Result<Item, &'static str>> {
Ok(MusicPage),
Err("horizon.extras.header.menus"),
Ok(WheelCyclesSettings),
]
];
if horizon_cosmetics::enabled() {
rows.extend([
Err("horizon.extras.header.cosmetics"),
Ok(CosmeticsMine),
Ok(CosmeticsOthers),
]);
}
rows
}
impl Item {
/// The language key of the caption (`horizon.*`, assets/horizon/lang).
@@ -538,6 +559,8 @@ impl Item {
Item::ChatAnimation => "horizon.extras.chat_animation",
Item::ChatIndicator => "horizon.extras.chat_indicator",
Item::WheelCyclesSettings => "horizon.extras.wheel_cycles_settings",
Item::CosmeticsMine => "horizon.extras.cosmetics_mine",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others",
}
}
/// The language key of the description shown as the row's tooltip.
@@ -562,6 +585,8 @@ 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::CosmeticsMine => "horizon.extras.cosmetics_mine.tooltip",
Item::CosmeticsOthers => "horizon.extras.cosmetics_others.tooltip",
}
}
/// The caption in the current language (English when the key is missing).
@@ -595,6 +620,8 @@ impl Item {
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
Item::WheelCyclesSettings => "Scroll wheel changes settings",
Item::CosmeticsMine => "My cosmetics",
Item::CosmeticsOthers => "Other players' cosmetics",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
@@ -607,7 +634,7 @@ impl Item {
ChatIndicator => ChatAnimation,
Fullbright | DynamicLights | OwnNameTag | HideBlockParticles | MouseTweaks | Sort
| AutoReconnect | NoAi | AgentControl | ReproKeys | DiscordPresence | Splitscreen
| ChatAnimation | WheelCyclesSettings | MusicPage => {
| ChatAnimation | WheelCyclesSettings | MusicPage | CosmeticsMine | CosmeticsOthers => {
return None;
}
})
@@ -665,6 +692,12 @@ impl Item {
Item::ReproKeys => {
"F3+R starts or stops a repro recording and F3+E writes its bundle. Off by default: a recording copies the world."
}
Item::CosmeticsMine => {
"Shows the cosmetics you wear on your own player, in third person and the inventory."
}
Item::CosmeticsOthers => {
"Shows the cosmetics other Horizon players wear. Off, the game never asks what they wear."
}
Item::WheelCyclesSettings => {
"The scroll wheel over a button in the settings changes its value, as in vanilla. Off, the wheel only scrolls the list."
}
@@ -686,6 +719,7 @@ impl Item {
| Item::Sort
| Item::Splitscreen
| Item::WheelCyclesSettings => Some(Cost::None),
Item::CosmeticsMine | Item::CosmeticsOthers => Some(Cost::Low),
_ => None,
}
}
@@ -710,6 +744,8 @@ impl Item {
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
Item::WheelCyclesSettings => e.wheel_cycles_settings,
Item::CosmeticsMine => e.cosmetics_mine,
Item::CosmeticsOthers => e.cosmetics_others,
_ => return None,
})
}
@@ -745,6 +781,8 @@ 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::CosmeticsMine => e.cosmetics_mine = !e.cosmetics_mine,
Item::CosmeticsOthers => e.cosmetics_others = !e.cosmetics_others,
Item::SortKey => {}
}
}
+138 -58
View File
@@ -1085,6 +1085,8 @@ pub struct ParticleEngine {
pub particle_level: u8,
/// FireworkParticles.Starter instances.
starters: Vec<Starter>,
/// `ClientExplosionTracker` tracked explosions pending debris generation.
tracked_explosions: Vec<TrackedExplosion>,
/// Local sounds requested by particles (dripstone drips, firework bursts):
/// (event, position, volume, pitch, distanceDelay).
pub sounds: Vec<(&'static str, [f64; 3], f32, f32, bool)>,
@@ -1135,6 +1137,13 @@ impl ParticleLightCache {
}
}
/// `ClientExplosionTracker.ExplosionInfo`.
#[derive(Clone, Copy, Debug)]
struct TrackedExplosion {
center: [f64; 3],
radius: f32,
block_count: usize,
}
impl ParticleEngine {
pub fn new(root: &Path, atlas: &HashMap<String, Rect>, translucent: &HashSet<String>) -> Self {
let mut engine = Self {
@@ -1154,6 +1163,7 @@ impl ParticleEngine {
set_sprites: Vec::new(),
emitters: Vec::new(),
starters: Vec::new(),
tracked_explosions: Vec::new(),
camera: [0.0; 3],
particle_level: 0,
sounds: Vec::new(),
@@ -1440,16 +1450,27 @@ impl ParticleEngine {
}
}
}
/// `ClientExplosionTracker.track`: queue an explosion's block count and position
/// for debris generation during `ClientLevel.tick`.
pub fn track_explosion(&mut self, center: [f64; 3], radius: f32, block_count: usize) {
if block_count > 0 {
self.tracked_explosions.push(TrackedExplosion {
center,
radius,
block_count,
});
}
}
/// `ClientPacketListener.handleExplosion` + `ClientExplosionTracker`: the
/// explosion particle (the emitter unless `ServerExplosion.isSmall`) and up
/// to 512 poof/smoke particles thrown from air inside the radius.
/// explosion particle (the emitter unless `ServerExplosion.isSmall`) and
/// debris tracked for `tick`.
pub fn explosion(
&mut self,
center: [f64; 3],
small: bool,
block_count: usize,
radius: f32,
is_air: &dyn Fn([i32; 3]) -> bool,
_is_air: &dyn Fn([i32; 3]) -> bool,
) {
let ty = if small {
ParticleType::Explosion
@@ -1457,7 +1478,7 @@ impl ParticleEngine {
ParticleType::ExplosionEmitter
};
self.add(&ParticleOptions::simple(ty), center, [1.0, 0.0, 0.0]);
self.explosion_blocks(center, radius, block_count, is_air);
self.track_explosion(center, radius, block_count);
}
/// `LlamaSpit.recreateFromPacket`: seven spit particles thrown ahead of a
/// new spit, each faster than the last along x and z.
@@ -1471,60 +1492,6 @@ impl ParticleEngine {
);
}
}
/// `ClientExplosionTracker` with `Level.DEFAULT_EXPLOSION_BLOCK_PARTICLES`.
pub fn explosion_blocks(
&mut self,
center: [f64; 3],
radius: f32,
block_count: usize,
is_air: &dyn Fn([i32; 3]) -> bool,
) {
// ClientExplosionTracker.tick: only the All option throws the debris.
if self.particle_level != 0 {
return;
}
for _ in 0..block_count.min(512) {
let r = &mut self.random;
let d = Vec3::new(
f64::from(r.next_float() * 2.0 - 1.0),
f64::from(r.next_float() * 2.0 - 1.0),
f64::from(r.next_float() * 2.0 - 1.0),
)
.normalize();
let distance = r.next_float().cbrt() * radius;
let local = [
d.x * f64::from(distance),
d.y * f64::from(distance),
d.z * f64::from(distance),
];
let at = [
center[0] + local[0],
center[1] + local[1],
center[2] + local[2],
];
if !is_air(ParticleLevel::block_at(at)) {
continue;
}
let speed = 0.5 / (distance / radius + 0.1) * r.next_float() * r.next_float() + 0.3;
// Level.DEFAULT_EXPLOSION_BLOCK_PARTICLES: poof (scaling 0.5) or smoke, equally weighted.
let (ty, scaling) = if r.next_int_bound(2).unwrap_or(0) == 0 {
(ParticleType::Poof, 0.5)
} else {
(ParticleType::Smoke, 1.0)
};
let p = [
center[0] + local[0] * scaling,
center[1] + local[1] * scaling,
center[2] + local[2] * scaling,
];
let v = [
d.x * f64::from(speed),
d.y * f64::from(speed),
d.z * f64::from(speed),
];
self.add(&ParticleOptions::simple(ty), p, v);
}
}
/// `ParticleEngine.createTrackingEmitter` (crits): 16 tries per tick for
/// three ticks inside a quarter of the entity's box, ticked once at creation.
pub fn tracking_emitter(
@@ -3355,6 +3322,7 @@ impl ParticleEngine {
}
pub fn tick(&mut self, level: &ParticleLevel) {
self.tick_starters();
self.tick_explosions(level);
for (options, feet, width, height, life) in std::mem::take(&mut self.emitters) {
self.emit(&options, feet, width, height);
if life > 1 {
@@ -3388,6 +3356,81 @@ impl ParticleEngine {
self.particles.push(p);
}
}
/// `ClientExplosionTracker.tick`: throws up to 512 debris particles across all
/// explosions tracked in this tick, weighted by each explosion's block count.
fn tick_explosions(&mut self, level: &ParticleLevel) {
self.tick_explosions_with(&|b| level.facts.is_air(level.world.block(b[0], b[1], b[2])));
}
fn tick_explosions_with(&mut self, is_air: &dyn Fn([i32; 3]) -> bool) {
if self.tracked_explosions.is_empty() {
return;
}
let explosions = std::mem::take(&mut self.tracked_explosions);
if self.particle_level != 0 {
return;
}
let total_blocks: usize = explosions.iter().map(|e| e.block_count).sum();
if total_blocks == 0 {
return;
}
let total_particles = total_blocks.min(512);
for _ in 0..total_particles {
// `WeightedRandom.getRandomItem` draws its `nextInt(totalWeight)`
// even when one explosion is tracked.
let mut pick = self.random.next_int_bound(total_blocks as i32).unwrap_or(0) as usize;
let mut explosion = &explosions[0];
for exp in &explosions {
if pick < exp.block_count {
explosion = exp;
break;
}
pick -= exp.block_count;
}
let r = &mut self.random;
let d = Vec3::new(
f64::from(r.next_float() * 2.0 - 1.0),
f64::from(r.next_float() * 2.0 - 1.0),
f64::from(r.next_float() * 2.0 - 1.0),
)
.normalize();
let distance = r.next_float().cbrt() * explosion.radius;
let local = [
d.x * f64::from(distance),
d.y * f64::from(distance),
d.z * f64::from(distance),
];
let at = [
explosion.center[0] + local[0],
explosion.center[1] + local[1],
explosion.center[2] + local[2],
];
if !is_air(ParticleLevel::block_at(at)) {
continue;
}
let speed =
0.5 / (distance / explosion.radius + 0.1) * r.next_float() * r.next_float() + 0.3;
// Level.DEFAULT_EXPLOSION_BLOCK_PARTICLES: poof (scaling 0.5) or smoke, equally weighted.
let (ty, scaling) = if r.next_int_bound(2).unwrap_or(0) == 0 {
(ParticleType::Poof, 0.5)
} else {
(ParticleType::Smoke, 1.0)
};
let p = [
explosion.center[0] + local[0] * scaling,
explosion.center[1] + local[1] * scaling,
explosion.center[2] + local[2] * scaling,
];
self.add(
&ParticleOptions::simple(ty),
p,
[
d.x * f64::from(speed),
d.y * f64::from(speed),
d.z * f64::from(speed),
],
);
}
}
#[allow(clippy::too_many_lines)]
fn tick_particle(&mut self, p: &mut Particle, level: &ParticleLevel) {
match p.behaviour {
@@ -4668,4 +4711,41 @@ mod tests {
assert_eq!(lifetime_alpha(a, 0, 40, 0.0), 0.0);
assert!((lifetime_alpha(a, 40, 40, 0.0) - 0.6).abs() < 1e-6);
}
#[test]
fn explosion_tracker_tracks_and_ignores_zero_blocks() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
assert!(engine.tracked_explosions.is_empty());
engine.track_explosion([1.0, 2.0, 3.0], 4.0, 100);
engine.track_explosion([5.0, 6.0, 7.0], 2.0, 50);
assert_eq!(engine.tracked_explosions.len(), 2);
assert_eq!(engine.tracked_explosions[0].block_count, 100);
assert_eq!(engine.tracked_explosions[1].block_count, 50);
// Zero block count explosions are not queued.
engine.track_explosion([0.0; 3], 1.0, 0);
assert_eq!(engine.tracked_explosions.len(), 2);
}
#[test]
fn explosion_tracker_caps_debris_at_512_across_multiple_explosions() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
// Queue two explosions near camera: 400 blocks and 300 blocks (total 700).
engine.track_explosion([0.0, 0.0, 0.0], 4.0, 400);
engine.track_explosion([2.0, 0.0, 2.0], 5.0, 300);
assert_eq!(engine.tracked_explosions.len(), 2);
// In pure air, total particles spawned must be capped at 512.
engine.tick_explosions_with(&|_| true);
assert_eq!(engine.pending.len(), 512);
assert!(engine.tracked_explosions.is_empty());
}
#[test]
fn explosion_tracker_emits_debris_only_in_air() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.track_explosion([0.0, 64.0, 0.0], 4.0, 100);
// When no blocks are air, zero debris particles are emitted.
engine.tick_explosions_with(&|_| false);
assert_eq!(engine.pending.len(), 0);
assert!(engine.tracked_explosions.is_empty());
}
}
+5
View File
@@ -11,6 +11,7 @@ autoexamples = false
autotests = false
[dependencies]
horizon-cosmetics = { path = "../horizon-cosmetics" }
horizon-client-lib = { path = "../horizon-client-lib", default-features = false }
horizon-assets = { path = "../horizon-assets" }
horizon-client-core = { path = "../horizon-client-core" }
@@ -129,6 +130,10 @@ windows-segment-heap = []
name = "horizon-client"
path = "src/main.rs"
[[test]]
name = "datapack_biome_sync"
path = "tests/datapack_biome_sync.rs"
[[test]]
name = "launch_api"
path = "tests/launch_api.rs"
@@ -331,5 +331,10 @@
"horizon.pages.open.split_setup": "Splitscreen …",
"horizon.pages.split_settings": "Splitscreen-Einstellungen",
"horizon.pages.split_setup": "Splitscreen",
"horizon.pages.split_setup.tooltip": "Spieler an diesem Computer: Controller können deiner Welt beitreten."
"horizon.pages.split_setup.tooltip": "Spieler an diesem Computer: Controller können deiner Welt beitreten.",
"horizon.extras.header.cosmetics": "Kosmetik",
"horizon.extras.cosmetics_mine": "Meine Kosmetik",
"horizon.extras.cosmetics_mine.tooltip": "Zeigt die Kosmetik, die du trägst, an deinem eigenen Spieler, in der dritten Person und im Inventar.",
"horizon.extras.cosmetics_others": "Kosmetik anderer Spieler",
"horizon.extras.cosmetics_others.tooltip": "Zeigt die Kosmetik anderer Horizon-Spieler. Aus fragt das Spiel nie, was sie tragen."
}
@@ -473,5 +473,10 @@
"horizon.repro.bundle": "Repro bundle: %s",
"horizon.repro.error": "Repro: %s",
"horizon.repro.no_recording": "There is no recording to export yet.",
"horizon.repro.replay_started": "Replay started: %s"
"horizon.repro.replay_started": "Replay started: %s",
"horizon.extras.header.cosmetics": "Cosmetics",
"horizon.extras.cosmetics_mine": "My cosmetics",
"horizon.extras.cosmetics_mine.tooltip": "Shows the cosmetics you wear on your own player, in third person and the inventory.",
"horizon.extras.cosmetics_others": "Other players' cosmetics",
"horizon.extras.cosmetics_others.tooltip": "Shows the cosmetics other Horizon players wear. Off, the game never asks what they wear."
}
@@ -399,5 +399,10 @@
"horizon.extras.hide_block_particles.tooltip": "Masque les débris qui jaillissent d'un bloc cassé et s'effritent de celui que vous minez. Les autres particules restent.",
"horizon.extras.splitscreen.tooltip": "Dans un monde, une autre manette peut rejoindre en tant que nouveau joueur : appuyez sur A quand c'est demandé, puis choisissez un profil et où jouer.",
"horizon.extras.ui_sound_reserve": "Canaux pour sons UI",
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu."
"horizon.extras.ui_sound_reserve.tooltip": "Les sons du monde laissent 16 des 247 canaux sonores libres, pour qu'un déluge de sons (une grosse explosion) ne couvre pas les clics de menu.",
"horizon.extras.header.cosmetics": "Cosmétiques",
"horizon.extras.cosmetics_mine": "Mes cosmétiques",
"horizon.extras.cosmetics_mine.tooltip": "Affiche les cosmétiques que vous portez sur votre joueur, à la troisième personne et dans l'inventaire.",
"horizon.extras.cosmetics_others": "Cosmétiques des autres",
"horizon.extras.cosmetics_others.tooltip": "Affiche les cosmétiques que portent les autres joueurs Horizon. Désactivé, le jeu ne demande jamais ce qu'ils portent."
}
@@ -270,13 +270,13 @@ fn main() -> Result<()> {
let row = match recipe {
Recipe::Shaped { cells, result, .. } => json!({
"result": result.item.as_deref(), "count": result.count,
"ingredients": cells.iter().flatten().map(|i| sorted(&i.1)).collect::<Vec<_>>()}),
"ingredients": cells.iter().flatten().map(|i| sorted(&i.order)).collect::<Vec<_>>()}),
Recipe::Shapeless {
ingredients,
result,
} => json!({
"result": result.item.as_deref(), "count": result.count,
"ingredients": ingredients.iter().map(|i| sorted(&i.1)).collect::<Vec<_>>()}),
"ingredients": ingredients.iter().map(|i| sorted(&i.order)).collect::<Vec<_>>()}),
Recipe::Special(_) => json!({"special": true}),
};
recipe_rows.insert(name.clone(), row);
@@ -285,14 +285,14 @@ fn main() -> Result<()> {
recipe_rows.insert(
c.id.clone(),
json!({"result": &*c.result.item, "count": c.result.count,
"ingredients": [sorted(&c.ingredient.1)]}),
"ingredients": [sorted(&c.ingredient.order)]}),
);
}
for s in stations.stonecutting.iter().filter(|s| ours(&s.id)) {
recipe_rows.insert(
s.id.clone(),
json!({"result": &*s.result.item, "count": s.result.count,
"ingredients": [sorted(&s.input.1)]}),
"ingredients": [sorted(&s.input.order)]}),
);
}
@@ -287,7 +287,9 @@ pub fn main() -> horizon_client::Result<()> {
mob_drops: true,
world_seed,
};
let (items, _) = drops::die(&mut m, &death, &mut sequences);
// Vanilla's entity tables all name a random sequence: no level random.
let mut level = horizon_core::random::LegacyRandom::new(0);
let (items, _) = drops::die(&mut m, &death, &mut sequences, &mut level);
for d in items {
let _ = match d {
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
+148 -22
View File
@@ -36,6 +36,22 @@ pub fn native_ingredient(
crate::native_client_hooks::ingredient(kind, data, items);
}
/// A mod's own ingredient kind, matching a concrete stack at craft time:
/// the ingredient's `test` calls here with the kind, its JSON and the
/// stack being matched, `accept` already true (the load-time list named
/// the item). Set `accept` to false to refuse the stack — its components
/// are the usual reason. The hook may be called many times a craft; the
/// answer must be the same for the same stack. API @API:ingredient_test@.
#[inline(never)]
pub fn native_ingredient_test(
kind: &str,
data: &serde_json::Map<String, Value>,
stack: &ItemStack,
accept: &mut bool,
) {
crate::native_client_hooks::ingredient_test(kind, data, stack, accept);
}
/// Keep first-seen order, drop the rest.
fn dedup_keep_order(items: &mut Vec<String>) {
let mut seen = std::collections::HashSet::new();
@@ -45,11 +61,39 @@ fn dedup_keep_order(items: &mut Vec<String>) {
/// A set of accepted items (expanded from an item, list or tag), and the
/// items in its `HolderSet` order (`Ingredient.display`'s `TagSlotDisplay`).
#[derive(Clone, Debug)]
pub struct Ingredient(pub HashSet<String>, pub Vec<String>);
pub struct Ingredient {
/// The items it accepts, however the source shape named them.
pub items: HashSet<String>,
/// The same items in `HolderSet` order (`display`).
pub order: Vec<String>,
/// A mod's own kind, for matching at craft time: the kind and its
/// JSON, as the load-time hook saw them (`native_ingredient_test`).
pub custom: Option<(String, serde_json::Map<String, Value>)>,
/// NeoForge's shapes, with their children parsed: matching a concrete
/// stack asks each child its own `test` (a mod's kind among them),
/// as on NeoForge — the flat list above only names items.
pub children: Option<(Shape, Vec<Self>)>,
}
/// How a shape's children combine at match time.
#[derive(Clone, Debug)]
pub enum Shape {
/// Any child matching the stack is enough.
Compound,
/// Every child must match it.
Intersection,
/// The first child must, and the second must not.
Difference,
}
impl Ingredient {
/// The items of a list, in order.
pub fn of(items: Vec<String>) -> Self {
Self(items.iter().cloned().collect(), items)
Self {
items: items.iter().cloned().collect(),
order: items,
custom: None,
children: None,
}
}
pub fn parse(v: &Value, items: &Items) -> Option<Self> {
let mut list: Vec<String> = Vec::new();
@@ -88,6 +132,9 @@ impl Ingredient {
.or_else(|| o.get("ingredients"))
.and_then(Value::as_array)
};
// The shapes' parsed children, for matching at craft time: every
// arm either fills it or returns.
let nested;
let kept = match kind {
// `CompoundIngredient`: any of its children matching the stack
// is enough — the union of their items.
@@ -98,12 +145,13 @@ impl Ingredient {
.collect::<Option<_>>()?;
let mut seen = Vec::new();
for sub in &subs {
for item in &sub.1 {
for item in &sub.order {
if !seen.contains(item) {
seen.push(item.clone());
}
}
}
nested = Some((Shape::Compound, subs));
seen
}
// `IntersectionIngredient`: every child must match the same
@@ -113,10 +161,11 @@ impl Ingredient {
.iter()
.map(|s| Self::parse(s, items))
.collect::<Option<_>>()?;
nested = Some((Shape::Intersection, subs.clone()));
subs.first()?
.1
.order
.iter()
.filter(|i| subs[1..].iter().all(|s| s.0.contains(*i)))
.filter(|i| subs[1..].iter().all(|s| s.items.contains(*i)))
.cloned()
.collect()
}
@@ -124,9 +173,10 @@ impl Ingredient {
"neoforge:difference" => {
let base = Self::parse(o.get("base")?, items)?;
let gone = Self::parse(o.get("subtracted")?, items)?;
base.1
nested = Some((Shape::Difference, vec![base.clone(), gone.clone()]));
base.order
.iter()
.filter(|i| !gone.0.contains(*i))
.filter(|i| !gone.items.contains(*i))
.cloned()
.collect()
}
@@ -136,14 +186,49 @@ impl Ingredient {
let mut list = Vec::new();
native_ingredient(k, o, &mut list);
dedup_keep_order(&mut list);
return (!list.is_empty()).then(|| Self::of(list));
// The kind and its JSON stay with the ingredient: the
// load-time list only names items, and matching a concrete
// stack (its components) is the match-time hook's.
return (!list.is_empty()).then(|| Self {
order: list.clone(),
items: list.iter().cloned().collect(),
custom: Some((k.to_owned(), o.clone())),
children: None,
});
}
_ => return None,
};
(!kept.is_empty()).then(|| Self::of(kept))
(!kept.is_empty()).then(|| Self {
items: kept.iter().cloned().collect(),
order: kept,
custom: None,
children: nested,
})
}
/// `Ingredient.test`: a stack the list names — and for a mod's own
/// kind, one the match-time hook accepts (`ICustomIngredient.test`).
pub fn test(&self, stack: &ItemStack) -> bool {
self.0.contains(&*stack.item)
match &self.children {
// Each child its own `test`, as on NeoForge — a mod's kind
// among them answers through its hook.
Some((Shape::Compound, kids)) => kids.iter().any(|k| k.test(stack)),
Some((Shape::Intersection, kids)) => kids.iter().all(|k| k.test(stack)),
Some((Shape::Difference, kids)) => {
kids.first().is_some_and(|k| k.test(stack))
&& kids.get(1).is_none_or(|k| !k.test(stack))
}
None => {
if !self.items.contains(&*stack.item) {
return false;
}
if let Some((kind, data)) = &self.custom {
let mut accept = true;
native_ingredient_test(kind, data, stack, &mut accept);
return accept;
}
true
}
}
}
}
/// `PlacementInfo`: the ingredients to find and, per recipe slot, the index
@@ -165,7 +250,7 @@ impl Placement {
};
for cell in cells {
match cell {
Some(i) if i.0.is_empty() => return Self::default(),
Some(i) if i.items.is_empty() => return Self::default(),
Some(i) => {
out.slots.push(out.ingredients.len() as i32);
out.ingredients.push(i);
@@ -423,7 +508,7 @@ impl Special {
// The result keeps the input's item: any item of the ingredient.
let all: Vec<ItemStack> = if result.item.is_none() {
input
.1
.order
.iter()
.map(|item| {
let mut stack = made.clone();
@@ -443,7 +528,7 @@ impl Special {
result,
} => {
// DyedSlotDemo: the targets cycle fastest, 16 stacks at most.
let (targets, dyes) = (&target.1, &dye.1);
let (targets, dyes) = (&target.order, &dye.order);
let all: Vec<ItemStack> = (0..targets.len() * dyes.len())
.map(|i| {
let mut made = result.create();
@@ -483,7 +568,7 @@ impl Special {
let all = potions(result.create());
// The source ingredient is shown as a lingering potion of each kind.
let lingering = source
.1
.order
.iter()
.flat_map(|item| potions(ItemStack::new(item, 1)))
.collect();
@@ -1236,7 +1321,7 @@ pub struct Recipes {
/// first lookup (furnaces look theirs up every tick).
cook_index: std::sync::OnceLock<CookIndex>,
}
type CookIndex = HashMap<String, crate::fast_hash::FxHashMap<String, usize>>;
type CookIndex = HashMap<String, crate::fast_hash::FxHashMap<String, Vec<usize>>>;
/// `MinMaxBounds.Ints` as (min, max) with the given defaults.
fn bounds(v: Option<&Value>, default: (i64, i64), open: (i64, i64)) -> (i64, i64) {
match v {
@@ -1830,18 +1915,23 @@ impl Recipes {
.map_or(0.0, |c| c.experience)
}
pub fn cook(&self, kind: &str, input: &ItemStack) -> Option<&Cooking> {
// Every recipe an item names, not just the first: a mod's match-time
// hook may refuse the one the first recipe would take (`Ingredient::test`).
let index = self.cook_index.get_or_init(|| {
let mut index = CookIndex::new();
for (i, c) in self.cooking.iter().enumerate() {
let of_kind = index.entry(c.kind.clone()).or_default();
for item in &c.ingredient.0 {
of_kind.entry(item.clone()).or_insert(i);
for item in &c.ingredient.items {
of_kind.entry(item.clone()).or_default().push(i);
}
}
index
});
let i = *index.get(kind)?.get(&*input.item)?;
self.cooking.get(i)
index
.get(kind)?
.get(&*input.item)?
.iter()
.find_map(|i| self.cooking.get(*i).filter(|c| c.ingredient.test(input)))
}
/// `cook` by scanning every recipe, as it was before the index.
#[cfg(test)]
@@ -1932,6 +2022,42 @@ mod tests {
assert_eq!(list, vec!["b".to_string(), "a".to_string()]);
}
/// NeoForge's shapes keep their children, and matching a concrete
/// stack asks each child its own way — any for compound, all for
/// intersection, base-but-not-subtracted for difference. A mod's own
/// kind among the children answers through its hook the same way
/// (proven on a staged build).
#[test]
fn nested_shapes_test_each_child_their_own_way() {
let items = Items::for_tests(HashMap::new());
let parse = |s: &str| {
Ingredient::parse(&serde_json::from_str::<Value>(s).unwrap(), &items).unwrap()
};
let stack = |n: &str| ItemStack {
item: n.into(),
count: 1,
damage: 0,
extra: None,
};
let compound = parse(
r#"{"type":"neoforge:compound","children":["minecraft:oak_planks","minecraft:birch_planks"]}"#,
);
assert!(compound.children.is_some(), "the children stay with it");
assert!(compound.test(&stack("minecraft:oak_planks")));
assert!(compound.test(&stack("minecraft:birch_planks")));
assert!(!compound.test(&stack("minecraft:stone")));
let inter = parse(
r#"{"type":"neoforge:intersection","ingredients":["minecraft:oak_planks",["minecraft:oak_planks","minecraft:birch_planks"]]}"#,
);
assert!(inter.test(&stack("minecraft:oak_planks")));
assert!(!inter.test(&stack("minecraft:birch_planks")));
let diff = parse(
r#"{"type":"neoforge:difference","base":["minecraft:oak_planks","minecraft:birch_planks"],"subtracted":"minecraft:birch_planks"}"#,
);
assert!(diff.test(&stack("minecraft:oak_planks")));
assert!(!diff.test(&stack("minecraft:birch_planks")));
}
#[test]
fn neoforge_ingredient_shapes_parse() {
let mut tags = HashMap::new();
@@ -1952,7 +2078,7 @@ mod tests {
)
.unwrap();
// The tag's items come from a HashSet: their order is not fixed.
let mut all = union.1.clone();
let mut all = union.order.clone();
all.sort();
assert_eq!(
all,
@@ -1968,14 +2094,14 @@ mod tests {
&items,
)
.unwrap();
assert_eq!(both.1, ["minecraft:oak_planks"]);
assert_eq!(both.order, ["minecraft:oak_planks"]);
// Difference: the base without the subtracted.
let no_oak = Ingredient::parse(
&json(r##"{"type":"neoforge:difference","base":"#minecraft:planks","subtracted":"minecraft:oak_planks"}"##),
&items,
)
.unwrap();
assert_eq!(no_oak.1, ["minecraft:birch_planks"]);
assert_eq!(no_oak.order, ["minecraft:birch_planks"]);
// An empty intersection and an unknown kind fail the file, as today.
let empty = Ingredient::parse(
&json(
+12 -1
View File
@@ -859,9 +859,11 @@ impl ExperienceOrb {
server: bool,
next_float: &mut dyn FnMut() -> f32,
border: Option<&BorderGeometry>,
fluids: &crate::motion::FluidResources,
) -> bool {
self.ticks += 1;
self.previous = self.position;
self.update_fluids(world, fluids);
let block = world.block(
self.position.x.floor() as i32,
self.position.y.floor() as i32,
@@ -2040,7 +2042,16 @@ mod world_border_tests {
ticks: 0,
health: 5,
};
orb.tick(&world, &facts, &collision, None, true, &mut || 0.0, border);
orb.tick(
&world,
&facts,
&collision,
None,
true,
&mut || 0.0,
border,
&fluids,
);
for (what, east) in [
("item", item.bounds().max_position().x),
("orb", orb.bounds().max_position().x),
+103 -3
View File
@@ -1,5 +1,5 @@
//! Dropped-item and armor-stand immersion and currents through the shared vanilla fluid helpers.
use super::{ArmorStand, ItemEntity};
//! Dropped-item, experience-orb and armor-stand immersion and currents through the shared vanilla fluid helpers.
use super::{ArmorStand, ExperienceOrb, ItemEntity};
use crate::{
motion::{FluidResources, FluidTags, LAVA, WATER},
world::World,
@@ -29,7 +29,7 @@ struct EntityFluidWorld<'a> {
/// `Entity.baseTick`'s fluid step for an entity that is pushed by fluid: its
/// immersion heights, then the current of each fluid it sits in, added to
/// `velocity` with vanilla's scales (water 0.014, lava 0.007 fast or
/// 0.0023333 slow). Items and armour stands call it for themselves; a boat
/// 0.0023333 slow). Items, experience orbs and armour stands call it for themselves; a boat
/// calls it from its own tick, before `floatBoat` scales the velocity.
pub fn update_entity_fluids(
bounds: Aabb,
@@ -77,6 +77,20 @@ impl ItemEntity {
)
}
}
impl ExperienceOrb {
/// `Entity.updateFluidInteraction`: refreshes fluid immersion over the
/// orb's box and pushes its velocity by any water or lava current.
pub fn update_fluids(&mut self, world: &World, fluids: &FluidResources) -> (f64, f64) {
update_entity_fluids(
self.bounds(),
self.position,
self.position.y + f64::from(0.425f32),
&mut self.velocity,
world,
fluids,
)
}
}
impl ArmorStand {
/// `Entity.updateFluidHeightAndDoFluidPushing`: refreshes water and lava
/// immersion over the stand's deflated box and pushes its velocity by any
@@ -297,6 +311,92 @@ mod tests {
assert!(carried.position.x > at.x + 0.5);
}
/// `Entity.baseTick` pushes an experience orb by the current it sits in: an
/// orb in flowing water gains velocity along the flow and moves, while an orb
/// in still source water does not gain horizontal velocity (#7442).
#[test]
fn experience_orbs_follow_flowing_water_but_still_water_has_no_horizontal_push() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("movement-properties.nbt").exists() {
return;
}
let assets = crate::assets::Assets::load(&root).unwrap();
let render = assets.reload(&root).unwrap();
let facts = crate::blocks::BlockFacts::load(&root, assets.registry).unwrap();
let fluids = FluidResources::new(&assets, &facts).unwrap();
let collision = crate::collisions::load(&root, assets.registry).unwrap();
let mut world = World::test_scene(&assets, &render, 3).unwrap();
let stone = assets.registry.default_state("minecraft:stone").unwrap();
let water = assets.registry.default_state("minecraft:water").unwrap();
let mut state = assets.registry.state(water).unwrap().clone();
state.properties.insert("level".into(), "1".into());
let flowing = assets.registry.resolve(&state).unwrap();
for x in 19..=21 {
for z in 19..=21 {
world.set(x, 3, z, stone).unwrap();
world.set(x, 4, z, water).unwrap();
}
}
let at = Vec3::new(20.5, 4.0, 20.5);
let orb = ExperienceOrb {
no_gravity: false,
id: 1,
position: at,
previous: at,
velocity: Vec3::ZERO,
value: 1,
age: 0,
on_ground: false,
count: 1,
ticks: 0,
health: 5,
};
let mut still = orb.clone();
assert!(still.tick(
&world,
&facts,
&collision,
None,
true,
&mut || 0.0,
None,
&fluids,
));
assert_eq!(still.position.x, at.x);
assert_eq!(still.velocity.x, 0.0);
world.set(21, 4, 20, flowing).unwrap();
let mut carried = orb;
assert!(carried.tick(
&world,
&facts,
&collision,
None,
true,
&mut || 0.0,
None,
&fluids,
));
// Current pushes the orb, underwater drag damps before motion, then air
// drag: `ExperienceOrb.tick`'s `0.98F`, a float widened to double.
assert!((carried.position.x - at.x - 0.014 * 0.99).abs() < 1e-12);
assert!((carried.velocity.x - 0.014 * 0.99 * f64::from(0.98_f32)).abs() < 1e-12);
for _ in 0..20 {
carried.tick(
&world,
&facts,
&collision,
None,
true,
&mut || 0.0,
None,
&fluids,
);
}
assert!(carried.position.x > at.x + 0.5);
}
/// `Entity.baseTick` pushes a boat by the current it sits in: a boat box
/// in water[level=1..7] gains velocity along the flow, and the same box in
/// still source water does not. Vanilla's boat tick runs this before
+58
View File
@@ -368,6 +368,17 @@ pub fn mob_to_nbt(
] {
put(&mut tag, key, value);
}
// Mob.addAdditionalSaveData: the death loot table, and its seed when set.
if let Some(table) = &m.extra.death_loot_table {
put(&mut tag, "DeathLootTable", string(table));
}
if m.extra.death_loot_seed != 0 {
put(
&mut tag,
"DeathLootTableSeed",
Tag::Long(m.extra.death_loot_seed),
);
}
// `NeutralMob.addPersistentAngerSaveData`: the anger ends at an
// absolute game time, and the target is the entity the mob is angry at.
if persistent_anger(m.species()) {
@@ -1497,6 +1508,9 @@ pub fn mob_from_nbt(
// Mob.readAdditionalSaveData overrides what the constructor set (copper
// golems and elder guardians start persistent).
m.persistent = flag(tag, "PersistenceRequired");
m.extra.death_loot_table = text(tag.get("DeathLootTable"))
.and_then(|t| horizon_client_core::identifier::normalize_id(&t));
m.extra.death_loot_seed = number(tag.get("DeathLootTableSeed")).unwrap_or(0);
// `NeutralMob.readPersistentAngerSaveData`: the anger end time and the
// target come back with the mob.
if persistent_anger(m.species()) {
@@ -4288,6 +4302,50 @@ mod sheep_nbt_tests {
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
/// `Mob.addAdditionalSaveData` and `readAdditionalSaveData`:
/// `DeathLootTable` and a set `DeathLootTableSeed` stay with the mob.
#[test]
fn a_death_loot_table_round_trips() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let summoned = Tag::compound([
("id", string("minecraft:zombie")),
("Pos", doubles(&[0.0, 64.0, 0.0])),
("DeathLootTable", string("mymod:entities/boss")),
("DeathLootTableSeed", Tag::Long(42)),
]);
let m = mob_from_nbt(&summoned, &types, 2, None).unwrap();
assert_eq!(
m.extra.death_loot_table.as_deref(),
Some("mymod:entities/boss")
);
assert_eq!(m.extra.death_loot_seed, 42);
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
assert_eq!(
text(tag.get("DeathLootTable")).as_deref(),
Some("mymod:entities/boss")
);
assert_eq!(number(tag.get("DeathLootTableSeed")), Some(42));
// None set: neither is written.
let plain = Tag::compound([
("id", string("minecraft:zombie")),
("Pos", doubles(&[0.0, 64.0, 0.0])),
]);
let plain = mob_from_nbt(&plain, &types, 3, None).unwrap();
let tag = mob_to_nbt(&plain, [1, 2, 3, 4], None, None);
assert!(tag.get("DeathLootTable").is_none() && tag.get("DeathLootTableSeed").is_none());
}
/// `Rabbit.addAdditionalSaveData` and `readAdditionalSaveData`: `MoreCarrotTicks`.
#[test]
fn a_rabbit_keeps_how_full_of_carrots_it_is() {
+9
View File
@@ -149,6 +149,15 @@ pub fn main_with(mods: crate::mod_api::Mods) {
ingredient: |kind, data, items| {
crate::mod_api::dispatch_ingredient(crate::mod_api::mods(), kind, data, items);
},
ingredient_test: |kind, data, stack, accept| {
crate::mod_api::dispatch_ingredient_test(
crate::mod_api::mods(),
kind,
data,
stack,
accept,
);
},
})
.is_err()
{
+36 -6
View File
@@ -95,6 +95,7 @@ mod composter;
mod consume;
mod control;
mod copper_golems;
mod cosmetics;
mod creaking;
mod creative_hotbar;
mod creatures;
@@ -2081,6 +2082,21 @@ impl PackResources {
light,
))
}
/// The workers' context over `biomes`: this pack's own when `biomes` is
/// the table it was built with, else the same parts with that table.
fn context_with(&self, biomes: Arc<Biomes>) -> Arc<WorkerContext> {
let old = &self.context;
if Arc::ptr_eq(&old.biomes, &biomes) {
return old.clone();
}
Arc::new(WorkerContext {
assets: old.assets.clone(),
render: old.render.clone(),
tables: old.tables.clone(),
biomes,
light: old.light.clone(),
})
}
/// The pack tables over their parts, which `load_resources` builds on
/// separate workers.
fn assemble(
@@ -2280,6 +2296,8 @@ struct Game<'a> {
/// The biome table the world plays with: the game's own with the world's
/// packs over it (`Biomes::with_packs`), `None` before a world opens.
world_biomes: Option<Arc<Biomes>>,
/// `world_biomes` as the biome files made it, before `native_biome_colors`.
world_biomes_base: Option<Arc<Biomes>>,
streaming: Streaming,
lod: Option<horizon_client::lod::Lod>,
lod_source_attempted: bool,
@@ -2761,6 +2779,8 @@ struct Game<'a> {
/// and mannequins) resolve through the services in the background.
skin_profiles: RefCell<horizon_client::skins::Resolver>,
skin_slots: horizon_client::skins::SkinSlots,
/// Cosmetics on players (game/cosmetics.rs), off by default.
cosmetics: cosmetics::Cosmetics,
/// Atlas trim slots in use: (sprite name, slot), least recently drawn first.
trim_slots: Vec<(String, usize)>,
cart_models: minecarts::CartModels,
@@ -11828,6 +11848,7 @@ impl Game<'_> {
// The world's packs over the game's biomes (`Biomes::with_packs`),
// the table its chunks were built with.
self.world_biomes = self.packed_biomes();
self.world_biomes_base = self.world_biomes.clone();
horizon_client::crash::set_detail(
"World",
format!("{} ({})", level.name, save_root.display()),
@@ -13336,6 +13357,7 @@ impl Game<'_> {
self.remote.is_none(),
&mut next_float,
Some(self.border.geometry()),
&self.res.motion().fluids,
)
});
self.orbs = orbs;
@@ -13852,8 +13874,8 @@ impl Game<'_> {
horizon_client::ui_theme::reset_atlas();
self.clear_lod();
self.pending_uploads.clear();
self.streaming
.reload_resources(&mut self.world, self.pack.context.clone());
let context = self.worker_context();
self.streaming.reload_resources(&mut self.world, context);
self.native_shaders = native_shaders::NativeShaders::picking(&std::mem::take(
&mut self.native_shaders.picked,
));
@@ -16293,10 +16315,7 @@ impl Game<'_> {
_ => Some(self.streaming.lod_source()),
};
if let Some(source) = source {
self.lod = Some(horizon_client::lod::Lod::new(
source,
self.pack.context.clone(),
));
self.lod = Some(horizon_client::lod::Lod::new(source, self.worker_context()));
self.lod_observe.extend(self.world.chunks.keys().copied());
}
}
@@ -18288,6 +18307,7 @@ impl<'a> Game<'a> {
);
Ok(Game {
world_biomes: None,
world_biomes_base: None,
res: res.clone(),
rest_loader: None,
pending_entry: None,
@@ -18604,6 +18624,7 @@ impl<'a> Game<'a> {
}),
skin_profiles: RefCell::new(horizon_client::skins::Resolver::new()),
skin_slots: horizon_client::skins::SkinSlots::new(horizon_client::hud::SKIN_SLOTS),
cosmetics: Default::default(),
trim_slots: Vec::new(),
cart_models: prepared.cart_models,
riding: None,
@@ -19093,6 +19114,14 @@ impl Game<'_> {
);
(!Arc::ptr_eq(&table, self.res.biomes())).then_some(table)
}
/// What the chunk and far-terrain workers mesh with: the pack's context,
/// over the world's biomes when it has its own.
fn worker_context(&self) -> Arc<WorkerContext> {
match &self.world_biomes {
Some(table) => self.pack.context_with(table.clone()),
None => self.pack.context.clone(),
}
}
fn pack_files(&self) -> horizon_world::loot::PackOverlay<'_> {
pack_files(&self.world_tables)
}
@@ -19157,6 +19186,7 @@ impl Game<'_> {
return;
}
self.world_tables = self.build_world_tables();
self.recipe_tables.categories = None;
horizon_client::structure_blocks::forget_loot_data();
// Before the players are sent the reloaded data below.
self.sync_recipe_content();
+120 -6
View File
@@ -15,17 +15,72 @@ use horizon_client::{
};
use horizon_core::random::RandomSource;
use horizon_protocol::packets::play::{self, BossAction, Clientbound as C};
use horizon_world::nbt::Tag;
use horizon_world::{identifier::Identifier, java_hash_map::JavaHashMap, nbt::Tag};
use std::collections::{HashMap, HashSet};
#[derive(Default)]
pub(super) struct BossState {
pub bars: BossBars,
/// CustomBossEvents' default HashMap order, used only by the list reply.
listing: JavaHashMap<Identifier, usize>,
loaded: bool,
/// What each viewer was last sent, so a change becomes one packet.
sent: HashMap<u128, HashMap<u128, Offer>>,
}
impl BossState {
fn add_bar(&mut self, id: &str, name: Comp) -> bool {
if !self.bars.add(id, name) {
return false;
}
if let Ok(key) = Identifier::parse(id) {
self.listing.put(key, self.bars.bars.len() - 1);
}
true
}
fn remove_bar(&mut self, id: &str) {
self.bars.remove(id);
if let Ok(key) = Identifier::parse(id) {
self.listing.remove(&key);
}
// Vec removal shifts indices. Replacing existing map values keeps
// its table and bucket chains intact, unlike reconstructing the map.
for (index, bar) in self.bars.bars.iter().enumerate() {
if let Ok(key) = Identifier::parse(&bar.id) {
self.listing.put(key, index);
}
}
}
fn rebuild_listing(&mut self) {
self.listing = JavaHashMap::default();
for (index, bar) in self.bars.bars.iter().enumerate() {
if let Ok(key) = Identifier::parse(&bar.id) {
self.listing.put(key, index);
}
}
}
fn listed_bars(&self) -> Vec<&bossbar::BossBar> {
let mut bars: Vec<_> = self
.listing
.entries()
.filter_map(|(_, index)| self.bars.bars.get(*index))
.collect();
// The existing permissive loader can retain invalid legacy IDs.
// Preserve those entries rather than dropping them from the reply.
if bars.len() != self.bars.bars.len() {
for bar in &self.bars.bars {
if !bars.iter().any(|listed| listed.id == bar.id) {
bars.push(bar);
}
}
}
bars
}
}
fn num(n: impl std::fmt::Display) -> Text {
Text::literal(n.to_string())
}
@@ -45,6 +100,7 @@ impl Game<'_> {
{
self.bosses.bars = BossBars::unpack(data);
}
self.bosses.rebuild_listing();
self.ui.registries.bossbars = self.bosses.bars.ids();
}
pub(super) fn save_bossbars(&self) {
@@ -107,7 +163,7 @@ impl Game<'_> {
return Err(err("commands.bossbar.create.failed", &[&bar]));
}
let name = self.component_json(ctx, "name")?;
self.bosses.bars.add(&bar, name);
self.bosses.add_bar(&bar, name);
if let Some(b) = self.bosses.bars.get_mut(&bar) {
// CustomBossEvents.create: Mth.createInsecureUUID(level.random).
b.uuid = insecure_uuid(&mut self.world.random);
@@ -124,9 +180,8 @@ impl Game<'_> {
"bossbar.list" => {
let names: Vec<Text> = self
.bosses
.bars
.bars
.iter()
.listed_bars()
.into_iter()
.map(horizon_client::bossbar::BossBar::display_name)
.collect();
let n = names.len();
@@ -154,7 +209,7 @@ impl Game<'_> {
match id {
"bossbar.remove" => {
let s = shown(self);
self.bosses.bars.remove(&bar);
self.bosses.remove_bar(&bar);
self.feedback(Text::translate("commands.bossbar.remove.success", &[s]));
Ok(self.bosses.bars.bars.len() as i32)
}
@@ -677,3 +732,62 @@ struct Offer {
fog: bool,
scope: Scope,
}
#[cfg(test)]
mod listing_tests {
use super::*;
fn ids(state: &BossState) -> Vec<&str> {
state
.listed_bars()
.into_iter()
.map(|bar| bar.id.as_str())
.collect()
}
#[test]
fn bossbar_listing_uses_identifier_hash_order_and_current_names() {
let mut state = BossState::default();
state.add_bar("audit:test", sb::literal("Title"));
state.bars.get_mut("audit:test").unwrap().name = sb::literal("Other");
state.add_bar("audit:second", sb::literal("Second"));
assert_eq!(ids(&state), ["audit:second", "audit:test"]);
let names: Vec<_> = state
.listed_bars()
.into_iter()
.map(|b| b.name.clone())
.collect();
assert_eq!(names, [sb::literal("Second"), sb::literal("Other")]);
}
#[test]
fn bossbar_listing_retains_resize_history_after_removals() {
let mut state = BossState::default();
state.add_bar("audit:test", sb::literal("Title"));
state.add_bar("audit:second", sb::literal("Second"));
for i in 0..11 {
state.add_bar(&format!("audit:bar{i}"), sb::literal("Extra"));
}
for i in 0..11 {
state.remove_bar(&format!("audit:bar{i}"));
}
// After the 13th put, HashMap keeps its 32-slot table. These keys
// occupy buckets 11 and 24, rather than buckets 11 and 8 at size 16.
assert_eq!(ids(&state), ["audit:test", "audit:second"]);
state.remove_bar("audit:test");
state.remove_bar("audit:second");
assert!(ids(&state).is_empty());
state.add_bar("audit:test", sb::literal("Again"));
state.add_bar("audit:second", sb::literal("Second"));
assert_eq!(ids(&state), ["audit:test", "audit:second"]);
}
#[test]
fn bossbar_listing_rebuilds_for_the_loaded_store() {
let mut state = BossState::default();
state.bars.add("audit:test", sb::literal("Title"));
state.bars.add("audit:second", sb::literal("Second"));
state.rebuild_listing();
assert_eq!(ids(&state), ["audit:second", "audit:test"]);
}
}
+4 -4
View File
@@ -2989,10 +2989,10 @@ impl Game<'_> {
let old_level = self.experience.level;
if add {
if levels {
self.experience.level = (self.experience.level + amount).max(0);
if self.experience.level == 0 && amount < 0 {
self.experience.progress = 0.0;
}
// Player.giveExperienceLevels handles the strict negative
// boundary and emits the level-change hook itself.
self.give_levels(amount);
return true;
} else {
self.give_points(amount);
}
@@ -737,8 +737,9 @@ impl Game<'_> {
let mut tracker = Tracker::default();
for u in &used {
let name = self.item_display_name(&u.item);
tracker.track(&holder(name), u.count as i32);
let _ = u;
// CommandResponseTracker.track(drop): one per stack, so the
// command returns how many stacks it used, not how many items.
tracker.track(&holder(name), 1);
}
let counts: Vec<u32> = used.iter().map(|u| u.count).collect();
let (message, total) = tracker.message(
+255
View File
@@ -0,0 +1,255 @@
//! Cosmetics on players ([docs/cosmetics.md](../../../../docs/cosmetics.md)): only Horizon
//! clients draw them, off unless `horizon_cosmetics::enabled()`. What another player wears comes
//! from the cosmetics service by UUID (`horizon_cosmetics::Cosmetics`, its own thread); this file
//! puts the items on the avatar's bones, on the GPU skin path when the renderer takes it.
use super::Game;
use horizon_client::fast_hash::FxHashMap;
use horizon_client::mobs::anim::Poses;
use horizon_client::mobs::{Mob, Part, Polygon};
use horizon_cosmetics::{Item, Slot};
use std::cell::{Cell, RefCell};
use std::sync::Arc;
/// An item as the renderer takes it.
struct Baked {
item: Arc<Item>,
model: Arc<Part>,
/// Its texture's sprite (a 64×64 skin slot of the atlas).
location: String,
/// The frame it was last marked drawn in (its slot is kept).
marked: Cell<u32>,
}
#[derive(Default)]
pub(super) struct Cosmetics {
client: Option<horizon_cosmetics::Cosmetics>,
/// By the item's address; each entry holds its item, so the address isn't reused.
baked: RefCell<FxHashMap<usize, Baked>>,
/// Items drawn whose texture isn't in the atlas yet.
uploads: RefCell<Vec<Arc<Item>>>,
frame: Cell<u32>,
/// Kept between players so a frame of avatars allocates none: the wearer's body, an item.
poses: RefCell<(Poses, Poses)>,
/// Item files from a folder (`HORIZON_COSMETICS_LOCAL`): mannequins wear them too.
local: bool,
}
impl Cosmetics {
fn client() -> (horizon_cosmetics::Cosmetics, bool) {
if let Some(dir) = std::env::var_os("HORIZON_COSMETICS_LOCAL") {
let local = horizon_cosmetics::Local::new(dir.into());
return (horizon_cosmetics::Cosmetics::new(local, None), true);
}
let api = std::env::var("HORIZON_COSMETICS_API")
.unwrap_or_else(|_| horizon_cosmetics::API.to_owned());
let agent = format!("Horizon/{}", env!("CARGO_PKG_VERSION"));
// Beside the downloaded skins (`assets/skins` in the game folder).
let cache = std::env::current_dir()
.unwrap_or_default()
.join("assets/cosmetics");
let http = horizon_cosmetics::Http::new(&api, &agent);
(horizon_cosmetics::Cosmetics::new(http, Some(cache)), false)
}
}
/// The game's model of an item's part.
fn part(p: &horizon_cosmetics::Part) -> Part {
Part {
name: p.name.clone(),
pose: p.pose,
polygons: p
.faces
.iter()
.map(|f| Polygon {
normal: f.normal,
vertices: f.vertices,
cull_back: false,
})
.collect(),
children: p.children.iter().map(part).collect(),
}
}
/// The texture in the top-left corner of a skin slot's 64×64 RGBA.
fn slot_image(item: &Item) -> Vec<u8> {
let t = &item.texture;
let mut rgba = vec![0; 64 * 64 * 4];
for y in 0..t.height as usize {
let row = &t.rgba[y * t.width as usize * 4..(y + 1) * t.width as usize * 4];
rgba[y * 64 * 4..y * 64 * 4 + row.len()].copy_from_slice(row);
}
rgba
}
/// Which slots an avatar's gear covers: a helmet or head item hides a hat, an elytra or a cape
/// what is worn on the back.
fn covered(m: &Mob, slot: Slot) -> bool {
match slot {
Slot::Head => m.extra.head.is_some(),
Slot::Back => m.cape.is_some() || m.extra.chest.as_deref() == Some("minecraft:elytra"),
Slot::Shoulder => false,
}
}
impl Game<'_> {
/// Once a frame, before drawing: starts the service's client on first use and puts the
/// textures the last frame wanted into the atlas.
pub(super) fn cosmetics_frame(&mut self) {
if !horizon_cosmetics::enabled() {
return;
}
let c = &mut self.cosmetics;
if c.client.is_none() {
let (client, local) = Cosmetics::client();
c.client = Some(client);
c.local = local;
}
c.frame.set(c.frame.get().wrapping_add(1));
let uploads = std::mem::take(&mut *c.uploads.borrow_mut());
for item in uploads {
let location = format!("cosmetics/{:x}", Arc::as_ptr(&item) as usize);
if !self.gui.atlas.sprites.contains_key(&location) {
self.store_skin(location, slot_image(&item));
}
}
// Items no player wears any more.
self.cosmetics
.baked
.borrow_mut()
.retain(|_, b| Arc::strong_count(&b.item) > 1);
}
/// What `m` wears, if cosmetics are shown for it: `own` for the player's own model.
fn worn(&self, m: &Mob, own: bool) -> Option<horizon_cosmetics::Worn> {
let client = self.cosmetics.client.as_ref()?;
let extras = &self.settings.extras;
if !(if own {
extras.cosmetics_mine
} else {
extras.cosmetics_others
}) {
return None;
}
let uuid = if own {
self.remote.as_ref().map(|r| r.profile())?
} else if &*m.kind.id == "minecraft:player" {
self.remote_player_uuid(m.id)?
} else if self.cosmetics.local && &*m.kind.id == "minecraft:mannequin" {
u128::from(m.id)
} else {
return None;
};
client.frame().worn(uuid)
}
/// The cosmetics `m` wears, on its bones. `gpu`: posed on the GPU (the mob pass's skin
/// frame, begun); the bounds of what went there come back, for culling. Else the vertices
/// go to `out`.
#[allow(clippy::too_many_arguments)]
pub(super) fn cosmetics_geometry(
&self,
m: &Mob,
own: bool,
base: glam::Mat4,
alpha: f32,
light: u32,
gpu: bool,
out: &mut Vec<horizon_client::mesher::Vertex>,
) -> Option<(glam::Vec3, glam::Vec3)> {
use horizon_client::mobs::{anim, render};
let worn = self.worn(m, own)?;
if horizon_client::mobs::trader::is_invisible(m) {
return None;
}
// The avatar's body, posed as it is drawn.
let (key, _) = render::body(m);
let body = m.kind.layer(&key).unwrap_or(&m.kind.model);
let mut scratch = self.cosmetics.poses.borrow_mut();
let (body_poses, poses) = &mut *scratch;
body_poses.reset(body);
let data = self.res.mob_data();
anim::setup(m, &anim::state(m, alpha), body_poses, &data.animations);
let ticks = m.age as f32 + alpha;
let frame = self.cosmetics.frame.get();
let mut bounds = None;
// Items on one bone (a back item and a shoulder pet both ride the body) share its matrix.
let mut bones: [Option<(horizon_cosmetics::Bone, glam::Mat4)>; 3] = [None; 3];
for item in worn.iter() {
if covered(m, item.slot) {
continue;
}
let mut baked = self.cosmetics.baked.borrow_mut();
let b = baked
.entry(Arc::as_ptr(item) as usize)
.or_insert_with(|| Baked {
item: item.clone(),
model: Arc::new(part(&item.root)),
location: format!("cosmetics/{:x}", Arc::as_ptr(item) as usize),
marked: Cell::new(frame.wrapping_sub(1)),
});
let Some(rect) = self.gui.atlas.sprites.get(&b.location) else {
self.cosmetics.uploads.borrow_mut().push(item.clone());
continue;
};
if b.marked.replace(frame) != frame {
self.skin_downloads.borrow_mut().drawn(&b.location);
}
let known = bones.iter().flatten().find(|(b, _)| *b == item.bone);
let bone = match known {
Some(&(_, matrix)) => matrix,
None => {
let Some(matrix) =
render::part_matrix(body, base, body_poses, item.bone.part(), 0.0)
else {
continue;
};
if let Some(free) = bones.iter_mut().find(|b| b.is_none()) {
*free = Some((item.bone, matrix));
}
matrix
}
};
// The texture fills the top-left of its 64×64 slot.
let (w, h) = (
item.texture.width as f32 / 64.0,
item.texture.height as f32 / 64.0,
);
let uv = [
rect.u0,
rect.v0,
rect.u0 + (rect.u1 - rect.u0) * w,
rect.v0 + (rect.v1 - rect.v0) * h,
];
poses.reset(&b.model);
item.animate(ticks, |name, pose| *poses.get(name) = pose);
if gpu {
let mut models = self.skin_models.borrow_mut();
let mut frame = self.skin_frame.borrow_mut();
let mut skin = horizon_client::mobs::skin::Skin::new(&mut models, &mut frame);
skin.bounds = bounds;
let paint = horizon_client::mobs::skin::Paint {
uv,
scroll: 0.0,
color: 0xFFFF_FFFF,
light,
overlay: 0,
};
if skin.draw(&b.model, bone, poses, paint) {
bounds = skin.bounds;
continue;
}
}
horizon_client::mobs::render_model(
&b.model,
bone,
&|p: &Part| poses.pose(p),
uv,
light,
0,
out,
);
}
bounds
}
}
+20 -1
View File
@@ -1489,7 +1489,7 @@ impl Game<'_> {
// LootContextParams.ORIGIN of a death: the mob's position.
let place = self.loot_place_at([m.position.x, m.position.y, m.position.z], None);
let (items, skulls) = horizon_world::loot::with_native_loot_place(place, || {
drops::die(m, &death, &mut self.rand_sequences)
drops::die(m, &death, &mut self.rand_sequences, &mut self.world.random)
});
if skulls && let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker) {
// Creeper.droppedSkulls: one head per charged explosion.
@@ -6651,6 +6651,25 @@ impl Game<'_> {
self.spin_attack_geometry(mob, camera, alpha as f32, packed, out);
self.stuck_in_body_geometry(mob, mob.id as i32, camera, alpha as f32, packed, out);
}
// Cosmetics (game/cosmetics.rs), posed on the GPU with the mob's own jobs: culled
// with it.
if !layers_only
&& horizon_cosmetics::enabled()
&& (is_player || &*mob.kind.id == "minecraft:mannequin")
{
let mark = self.skin_frame.borrow().mark();
let base = horizon_client::mobs::render::base_transform(mob, alpha as f32, camera);
let gpu = skinning;
if let Some((lo, hi)) =
self.cosmetics_geometry(mob, false, base, alpha as f32, packed, gpu, out)
{
skinned = Some(match skinned {
Some((first, Some((l, h)))) => (first, Some((l.min(lo), h.max(hi)))),
Some((first, None)) => (first, Some((lo, hi))),
None => (mark, Some((lo, hi))),
});
}
}
// CustomHeadLayer (a pumpkin, skull or head item) included.
if layers {
// Held items join the GPU path's draws, their silhouette too.
@@ -405,6 +405,7 @@ impl Game<'_> {
self.res.biomes(),
&enabled_features,
));
self.world_biomes_base = self.world_biomes.clone();
let (res, pack, settings) = (self.res.clone(), self.pack.clone(), self.settings.clone());
let flags = Arc::new(Flags::default());
flags
+14 -6
View File
@@ -8,7 +8,7 @@ use horizon_client::{
lan::{
self, Lan, NetConfig,
chunks::ChunkEncoder,
data::SyncData,
data::{BIOME_REGISTRY, SyncData},
entity::{EntityState, States},
hub::{HostEvent, Hub, HubConfig, TickCtx},
playerdata,
@@ -1253,12 +1253,20 @@ impl Game<'_> {
} else {
self.level.enabled_features.clone()
};
if sync.features.iter().any(|f| !features.contains(f))
// A world whose packs change biomes sends its own biome registry.
let packed = self.world_biomes.clone().filter(|b| b.has_packs());
if packed.is_some()
|| sync.features.iter().any(|f| !features.contains(f))
|| features.iter().any(|f| !sync.features.contains(f))
{
// The shared resource preload has vanilla flags. Feature flags belong
// to this world, not to the cached resources or the next world opened.
let mut world_sync = SyncData::load(&self.options.resources, &self.pack_files())?;
// The shared resource preload has vanilla flags and vanilla biomes.
// Feature flags and biomes belong to this world, not to the cached
// resources or the next world opened.
let mut world_sync = SyncData::load_with(
&self.options.resources,
&self.pack_files(),
packed.as_deref(),
)?;
world_sync.features = features;
sync = Arc::new(world_sync);
}
@@ -1274,7 +1282,7 @@ impl Game<'_> {
let registry: Vec<String> = sync
.dynamic
.registries
.get("minecraft:worldgen/biome")
.get(BIOME_REGISTRY)
.map(|v| v.iter().map(|(n, _)| n.clone()).collect())
.unwrap_or_default();
let mut encoder = ChunkEncoder::with_signs(
@@ -465,13 +465,8 @@ impl Game<'_> {
}
// A wind charge's burst throws none.
if !p.block_particles.is_empty() {
let (world, facts) = (&self.world, self.res.facts());
self.particles.explosion_blocks(
p.center,
p.radius,
p.block_count.max(0) as usize,
&|b| facts.is_air(world.block(b[0], b[1], b[2])),
);
self.particles
.track_explosion(p.center, p.radius, p.block_count.max(0) as usize);
}
// Entity.pushFromExplosion.
if let (Some(push), Some(player)) = (p.knockback, self.player.as_mut()) {
@@ -537,4 +537,7 @@ impl Host for super::Game<'_> {
) -> Option<(Option<ItemStack>, ItemStack)> {
self.native_item_container_extract(stack, slot, max)
}
fn recipe_book_category(&mut self, kind: &str) -> Option<RecipeBookCategory> {
self.native_recipe_book_category(kind)
}
}
@@ -4,7 +4,6 @@
//! game runs.
use super::Game;
use horizon_client::streaming::WorkerContext;
use std::sync::Arc;
// Native mods call it; nothing in Horizon does.
@@ -17,22 +16,26 @@ impl Game<'_> {
/// slow fade) and once when the player joins, not every frame: a rebuild
/// takes the workers a few seconds. API 107.
pub(super) fn native_refresh_biome_colors(&mut self) {
let base = self.res.biomes().clone();
let biomes = base
.with_colors(|biome, colors| self.native_biome_colors(biome, colors))
.map_or(base, Arc::new);
if Arc::ptr_eq(&biomes, &self.pack.context.biomes) {
return;
// A world's own table is asked from the biome files' colours each
// time, so a mod's colours do not stack; a plain world's is the pack's.
if let (Some(base), Some(current)) = (&self.world_biomes_base, &self.world_biomes) {
let Some(biomes) = base.recolored_from(current, |biome, colors| {
self.native_biome_colors(biome, colors)
}) else {
return;
};
self.world_biomes = Some(Arc::new(biomes));
} else {
let base = self.res.biomes().clone();
let biomes = base
.with_colors(|biome, colors| self.native_biome_colors(biome, colors))
.map_or_else(|| base.clone(), Arc::new);
if Arc::ptr_eq(&biomes, &self.pack.context.biomes) {
return;
}
self.pack.context = self.pack.context_with(biomes);
}
let old = &self.pack.context;
let context = Arc::new(WorkerContext {
assets: old.assets.clone(),
render: old.render.clone(),
tables: old.tables.clone(),
biomes,
light: old.light.clone(),
});
self.pack.context = context.clone();
let context = self.worker_context();
self.clear_lod();
self.streaming.reload_resources(&mut self.world, context);
}
+18 -2
View File
@@ -882,7 +882,7 @@ use super::{
};
use horizon_client::mod_api::{
self, NativeCamera, NativeCommand, NativeDispensed, NativeLifecycleContext, NativeServerPlayer,
ServerTickPhase, WorldPhase,
RecipeBookCategory, ServerTickPhase, WorldPhase,
};
use horizon_client::mod_api::{AttachmentResult, Attachments, Holder, Suggestion};
pub(crate) use horizon_client::mod_api::{
@@ -2024,6 +2024,13 @@ fn chars_to_type(text: &str, canceled: impl FnMut(char) -> bool) -> Option<Strin
/// The hook points. Each does nothing in Horizon.
impl Game<'_> {
/// Declare categories for custom recipe types, with registered item icons.
/// Asked lazily once per recipe table by `native_recipe_book_category`.
/// Invalid declarations are logged and skipped; the first valid category
/// owns its id and type. This does not yet add tabs or book displays.
pub(super) fn native_recipe_book_categories(&self, categories: &mut Vec<RecipeBookCategory>) {
mod_api::dispatch_recipe_book_categories(self, mod_api::mods(), categories);
}
/// Once a frame, in a world or not, after Horizon's extras.
pub(super) fn native_frame(&mut self, elapsed: Duration) {
mod_api::dispatch_frame(self, mod_api::mods(), elapsed);
@@ -8523,6 +8530,15 @@ impl Game<'_> {
}
self.recipes().custom.get(kind).map_or(&[], Vec::as_slice)
}
/// The validated category and icon of a custom recipe type, or None for
/// an undeclared/invalid type, before resources load, or outside a world.
/// Rebuilt when the recipe table changes. Does not unlock any recipes.
pub(super) fn native_recipe_book_category(&mut self, kind: &str) -> Option<RecipeBookCategory> {
if self.outside_world() || self.res.rest.get().is_none() {
return None;
}
self.registered_recipe_book_category(kind)
}
/// An ingredient as a recipe's JSON names it: an item, a `#tag`, or a list
/// of them, a bare id as `minecraft:` (as vanilla reads it); `test` says
/// whether a stack fits. Items the game doesn't know are left out; None
@@ -8548,7 +8564,7 @@ impl Game<'_> {
};
let items = self.items();
let known: Vec<String> = Ingredient::parse(&json, items)?
.1
.order
.into_iter()
.filter(|item| items.get(item).is_some())
.collect();
@@ -28,6 +28,7 @@ const MAX_SYNCED_RECIPES: usize = 1_000_000;
#[derive(Default)]
pub(super) struct RecipeTables {
tables: Option<Arc<Recipes>>,
pub(super) categories: Option<HashMap<String, horizon_client::recipe_book::RecipeBookCategory>>,
known: (Option<u64>, Arc<[i32]>),
/// The server's recipes of the types its native mods sent (`horizon:
/// recipe_content`) over the window's own: what `Game::recipes` reads on
@@ -54,7 +55,8 @@ impl Index {
self.stack(&s.item, s.count, components(s))
}
fn any(&self, i: &Ingredient) -> Vec<RecipeStack> {
i.1.iter()
i.order
.iter()
.filter_map(|id| self.stack(id, 1, String::new()))
.collect()
}
@@ -97,6 +99,21 @@ fn recipe(id: String, kind: RecipeKind) -> Recipe {
}
impl Game<'_> {
pub(super) fn registered_recipe_book_category(
&mut self,
kind: &str,
) -> Option<horizon_client::recipe_book::RecipeBookCategory> {
if self.recipe_tables.categories.is_none() {
let mut declarations = Vec::new();
self.native_recipe_book_categories(&mut declarations);
self.recipe_tables.categories = Some(horizon_client::recipe_book::mod_categories(
declarations,
self.recipes(),
self.items(),
));
}
self.recipe_tables.categories.as_ref()?.get(kind).cloned()
}
/// `native_give`: `count` of `item`, refused outside a world, unless
/// creative and for an item the game doesn't have.
pub(super) fn recipe_give(&mut self, item: &str, count: u32) -> Result<(), String> {
+1 -1
View File
@@ -341,7 +341,6 @@ impl Game<'_> {
self.random_tick_sections.clear();
self.portals.sections.clear();
self.options.resources = root.clone();
let context = pack.context.clone();
self.pack = pack;
self.gui = gui;
// The new atlas's skin and map slots are blank: players and maps
@@ -413,6 +412,7 @@ impl Game<'_> {
// A pack that may replace leaf models falls back to `Off` before the
// reload marks every section dirty.
self.streaming.leaf_culling = self.effective_leaf_culling();
let context = self.worker_context();
self.streaming.reload_resources(&mut self.world, context);
self.pending_uploads.clear();
// New GPU textures: a renderer built from the reloaded resources; the
@@ -687,6 +687,10 @@ impl Game<'_> {
packed,
out,
);
if horizon_cosmetics::enabled() {
let base = horizon_client::mobs::render::base_transform(m, alpha as f32, camera);
self.cosmetics_geometry(m, true, base, alpha as f32, packed, false, out);
}
self.player_elytra_geometry(m, chest, camera, alpha as f32, packed, out);
self.spin_attack_geometry(m, camera, alpha as f32, packed, out);
self.shoulder_parrots_geometry(m, camera, alpha as f32, packed, out);
@@ -795,6 +799,10 @@ impl Game<'_> {
240 | 240 << 8,
&mut verts,
);
if horizon_cosmetics::enabled() {
let base = horizon_client::mobs::render::base_transform(&m, 1.0, camera);
self.cosmetics_geometry(&m, true, base, 1.0, 240 | 240 << 8, false, &mut verts);
}
// `WingsLayer` draws through `RenderTypes.armorCutoutNoCull`: the
// wings' faces show from both sides (the elytra texture is empty on
// the face that looks at the body).
@@ -373,6 +373,7 @@ impl Game<'_> {
/// the server's recipes arrived, or a new session began.
pub(super) fn forget_book(&mut self) {
self.recipe_ui.book = None;
self.recipe_tables.categories = None;
}
fn ensure_book(&mut self) {
if self.recipe_ui.book.is_none() {
+41 -7
View File
@@ -17,6 +17,7 @@ use horizon_client::{
entities::{Experience, ExperienceOrb, ItemEntity},
hud::HudBatch,
items::ItemStack,
lan::data::{BIOME_REGISTRY, client_biomes},
menu::Input,
mobs::{Mob, MobType, Who},
motion::{MotionWorld, PlayerMotion},
@@ -832,8 +833,12 @@ impl Game<'_> {
self.reset_tutorial();
self.perf.world_opening();
// A server's own biomes come over the connection; the last local
// world's packed table must not name its ids.
self.world_biomes = None;
// world's packed table must not name its ids. The window's own
// server thread keeps the one it just set.
if local.is_none() {
self.world_biomes = None;
self.world_biomes_base = None;
}
let root = self.options.resources.clone();
let builtin = builtin_registries(&root).ok_or("Resources are incomplete: registries.json is missing or invalid. Click \"Prepare again\" in the launcher (or run horizon-prepare), then try again.")?;
let lang = Arc::new(horizon_client::net::load_language(&root));
@@ -898,7 +903,7 @@ impl Game<'_> {
let player_type = super::perspective::player_type(&root);
let mut streaming = Streaming::new(
Arc::new(NoTerrain),
self.pack.context.clone(),
self.worker_context(),
self.res.threads(),
);
streaming.remote = true;
@@ -1163,7 +1168,7 @@ impl Game<'_> {
self.adv = Default::default();
self.stats = Default::default();
self.score.board = Default::default();
self.streaming = Streaming::new(Arc::new(NoTerrain), self.pack.context.clone(), 1);
self.streaming = Streaming::new(Arc::new(NoTerrain), self.worker_context(), 1);
self.streaming.paused = true;
self.world = World::new(self.res.facts().air, 0);
self.player = None;
@@ -2030,9 +2035,26 @@ impl Game<'_> {
}
}
fn set_registries(&mut self, registries: Arc<Dynamic>) {
// The server's biomes the game lacks (a world data pack's) join the
// game's table, from the data it sent: colours, meshing and the
// chunk format all go through that table. The window's own server
// thread set the world's table already.
if self.integrated.is_none() {
let table = registries
.registries
.get(BIOME_REGISTRY)
.and_then(|list| client_biomes(self.res.biomes(), list))
.map(Arc::new);
if table.is_some() || self.world_biomes.is_some() {
self.world_biomes_base = table.clone();
self.world_biomes = table;
let context = self.worker_context();
self.streaming.reload_resources(&mut self.world, context);
}
}
let biomes: Vec<u16> = registries
.registries
.get("minecraft:worldgen/biome")
.get(BIOME_REGISTRY)
.map(|list| {
list.iter()
.map(|(name, _)| world_biomes!(self).id(name))
@@ -2110,7 +2132,7 @@ impl Game<'_> {
self.stop_sounds_for_new_level();
let mut streaming = Streaming::new(
Arc::new(NoTerrain),
self.pack.context.clone(),
self.worker_context(),
self.res.threads(),
);
streaming.remote = true;
@@ -6374,7 +6396,12 @@ impl Game<'_> {
illager::cast_particles(m, &mut mob_fx);
}
S::Evoker => illager::cast_particles(m, &mut mob_fx),
S::Wither => wither::client_smoke(m, &mut mob_fx),
S::Wither => {
wither::client_smoke(m, &mut mob_fx);
// `getInvulnerableTicks() > 0`: the charge's cloud,
// after the heads' smoke as in `aiStep`.
wither::charging_particles(m, &mut mob_fx);
}
_ => {}
}
}
@@ -6563,6 +6590,7 @@ impl Game<'_> {
false,
&mut next_float,
border,
&self.res.motion().fluids,
);
// `tick` set `previous` to the stepped position: keep the
// pre-step one, as `xo` is in vanilla.
@@ -9563,6 +9591,12 @@ impl Game<'_> {
Some(r.glowing.contains(&(id as i32)))
}
/// The profile name of another player the server sent, by entity id.
/// The UUID of another player's entity in the level.
pub(super) fn remote_player_uuid(&self, id: u32) -> Option<u128> {
let r = self.remote.as_ref()?;
let uuid = *r.level_players.get(&(id as i32))?;
(uuid != r.profile).then_some(uuid)
}
pub(super) fn remote_player_name(&self, id: u32) -> Option<&str> {
let r = self.remote.as_ref()?;
let uuid = r.level_players.get(&(id as i32))?;
@@ -756,6 +756,7 @@ impl Game<'_> {
for (location, rgba) in ready {
self.store_skin(location, rgba);
}
self.cosmetics_frame();
}
/// `Hud.extractTabList`: the player list while the player-list key is
/// held. A local world shows it with a joined LAN guest or a `list`
@@ -739,7 +739,7 @@ pub(super) fn make_streaming(
registry: pack.assets.clone(),
biomes: biomes.clone(),
}),
pack.context.clone(),
pack.context_with(biomes.clone()),
res.threads(),
)
} else if let Some(inner) = flat_vanilla {
@@ -762,7 +762,7 @@ pub(super) fn make_streaming(
registry: pack.assets.clone(),
biomes: biomes.clone(),
}),
pack.context.clone(),
pack.context_with(biomes.clone()),
res.threads(),
)
} else if let Some(inner) = flat {
@@ -773,7 +773,7 @@ pub(super) fn make_streaming(
registry: pack.assets.clone(),
biomes: biomes.clone(),
}),
pack.context.clone(),
pack.context_with(biomes.clone()),
res.threads(),
)
} else if options.test_terrain && dim == Dim::Overworld {
@@ -785,7 +785,7 @@ pub(super) fn make_streaming(
registry: pack.assets.clone(),
biomes: biomes.clone(),
}),
pack.context.clone(),
pack.context_with(biomes.clone()),
res.threads(),
)
} else {
@@ -834,7 +834,7 @@ pub(super) fn make_streaming(
registry: pack.assets.clone(),
biomes: biomes.clone(),
}),
pack.context.clone(),
pack.context_with(biomes.clone()),
res.threads(),
)
};
+11 -7
View File
@@ -646,13 +646,17 @@ fn load_definitions(root: &Path) -> BTreeMap<String, ClientItem> {
};
let namespace = ns.file_name().to_string_lossy().into_owned();
for file in &folder.files {
if let Some(item) = file
.bytes()
.ok()
.and_then(|b| std::str::from_utf8(b).ok())
.and_then(|t| ClientItem::from_json(t).ok())
{
out.insert(format!("{namespace}:{}", file.name), item);
let Some(text) = file.bytes().ok().and_then(|b| std::str::from_utf8(b).ok()) else {
continue;
};
match ClientItem::from_json(text) {
Ok(item) => {
out.insert(format!("{namespace}:{}", file.name), item);
}
Err(e) => log::warn!(
"Horizon: item definition {namespace}:{} not loaded: {e}",
file.name
),
}
}
}
+53
View File
@@ -49,6 +49,33 @@ pub const SYNCHRONIZED: &[&str] = &[
"minecraft:block_transformer",
"minecraft:worldgen/block_state_provider",
];
pub const BIOME_REGISTRY: &str = "minecraft:worldgen/biome";
/// The table a client plays a server with: `known` and the biomes of the
/// server's biome registry that it lacks, built from their data; None when
/// there are none.
pub fn client_biomes(
known: &crate::biome::Biomes,
registry: &[(String, Option<Tag>)],
) -> Option<crate::biome::Biomes> {
let files: Vec<(&str, Value)> = registry
.iter()
.filter(|(name, _)| !known.contains(name))
.filter_map(|(name, data)| {
Some((
name.as_str(),
crate::scoreboard::tag_to_json(data.as_ref()?),
))
})
.collect();
if files.is_empty() {
return None;
}
log::info!(
"Horizon: the server has {} biomes the game lacks",
files.len()
);
Some(known.with_packs(files.iter().map(|(n, v)| (*n, v))))
}
/// KnownPack.vanilla("core") for this version.
pub const CORE_PACK: (&str, &str, &str) = ("minecraft", "core", horizon_protocol::GAME_VERSION);
@@ -194,6 +221,16 @@ impl SyncData {
pub fn load(
resources: &Path,
overlay: &horizon_world::loot::PackOverlay,
) -> Result<Self, String> {
Self::load_with(resources, overlay, None)
}
/// `load` with the open world's biomes: when its data packs added or
/// replaced some, the biome registry is that table's, in its order.
pub fn load_with(
resources: &Path,
overlay: &horizon_world::loot::PackOverlay,
biomes: Option<&crate::biome::Biomes>,
) -> Result<Self, String> {
use crate::spans::startup;
// The recipe book, the recipe sync and the advancements read the
@@ -222,6 +259,22 @@ impl SyncData {
if PACK_REGISTRIES.contains(key) {
pack_entries(path, overlay, &mut entries);
}
if *key == BIOME_REGISTRY
&& let Some(table) = biomes.filter(|b| b.has_packs())
{
// A pack's biome goes with its data: no client knows it.
let found: HashMap<_, _> = entries
.drain(..)
.map(|((ns, p), v, _)| (format!("{ns}:{p}"), v))
.collect();
for name in table.registry_order() {
let (ns, p) = name.split_once(':').unwrap_or(("minecraft", name));
let pack = table.pack_file(name);
let json = pack.or_else(|| found.get(name)).cloned();
let json = json.unwrap_or_else(|| Value::Object(Default::default()));
entries.push(((ns.to_owned(), p.to_owned()), json, pack.is_some()));
}
}
file_order(&mut entries);
let mut e = Entries {
names: Vec::new(),
+5 -1
View File
@@ -138,7 +138,11 @@ fn flat(ing: &Value, items: &horizon_client::items::Items) -> Option<Value> {
.and_then(|v| horizon_client::crafting::Ingredient::parse(v, items))
})?;
Some(Value::Array(
parsed.1.iter().map(|i| Value::String(i.clone())).collect(),
parsed
.order
.iter()
.map(|i| Value::String(i.clone()))
.collect(),
))
}
+1
View File
@@ -3546,6 +3546,7 @@ pub(crate) mod tests {
living_fall: |_, _, _, _, _| {},
living_jump: |_, _| {},
ingredient: |_, _, _| {},
ingredient_test: |_, _, _, _| {},
});
assert!(installed.is_ok());
let kind = Kind::Mod(Box::leak(Box::new(crate::containers::ModMenuType {
+5 -3
View File
@@ -91,7 +91,7 @@ impl Stacked {
let mut max = 0;
for (key, &count) in self.keys.iter().zip(&self.amounts) {
if count > max {
if ing.0.contains(&**key) {
if ing.items.contains(&**key) {
max = count;
}
if max >= min {
@@ -146,14 +146,16 @@ impl<'a> Picker<'a> {
let items: Vec<usize> = (0..stacked.keys.len())
.filter(|&i| {
stacked.amounts[i] > 0
&& ingredients.iter().any(|g| g.0.contains(&*stacked.keys[i]))
&& ingredients
.iter()
.any(|g| g.items.contains(&*stacked.keys[i]))
})
.collect();
let (ni, nt) = (ingredients.len(), items.len());
let mut connection = vec![false; ni * nt];
for (g, ing) in ingredients.iter().enumerate() {
for (t, &key) in items.iter().enumerate() {
if ing.0.contains(&*stacked.keys[key]) {
if ing.items.contains(&*stacked.keys[key]) {
connection[t * ni + g] = true;
}
}
+5
View File
@@ -830,6 +830,11 @@ pub fn anger_left(anger_end: i64, game_time: i64) -> i32 {
/// Species-specific persistent state (variants, colours, taming, ageing...).
#[derive(Clone, Debug, Default)]
pub struct Extra {
/// `Mob.lootTable`: the table its death rolls instead of its type's
/// (saved as `DeathLootTable`).
pub death_loot_table: Option<String>,
/// `Mob.lootTableSeed` (`DeathLootTableSeed`): 0 for none.
pub death_loot_seed: i64,
/// `Rabbit.moreCarrotTicks`: how long it is full of carrots (saved as `MoreCarrotTicks`).
pub more_carrot_ticks: i32,
/// `Ocelot.DATA_TRUSTING`: it no longer runs from players.
+95 -9
View File
@@ -6,7 +6,7 @@
//! the experience orbs.
use super::{Mob, Species, equine, gear, raider, render::DYE_NAMES, species};
use crate::random_sequences::RandomSequences;
use horizon_core::random::RandomSource;
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::{
loot::{EntityLootParams, LootDamage, LootData, LootEntity, LootStack},
registry::BlockStateId,
@@ -435,7 +435,14 @@ pub fn orb_value(amount: i32) -> i32 {
/// `LivingEntity.die`'s drops for `m` (equipment it loses is removed from
/// it). Returns the drops in spawn order and whether a charged creeper
/// killer dropped a head (`Creeper.droppedSkulls`).
pub fn die(m: &mut Mob, d: &Death, sequences: &mut RandomSequences) -> (Vec<Drop>, bool) {
/// `level_random` is the level's random: what a table rolls with when it has
/// neither a seed nor a random sequence (`LootContext.Builder.create`).
pub fn die(
m: &mut Mob,
d: &Death,
sequences: &mut RandomSequences,
level_random: &mut LegacyRandom,
) -> (Vec<Drop>, bool) {
let mut out = Vec::new();
let this = loot_entity(m, d.vehicle.clone());
let mut skulls = false;
@@ -451,7 +458,14 @@ pub fn die(m: &mut Mob, d: &Death, sequences: &mut RandomSequences) -> (Vec<Drop
player: None,
luck: 0.0,
};
let items = roll(d, "minecraft:charged_creeper/root", &params, sequences);
let items = roll(
d,
"minecraft:charged_creeper/root",
&params,
sequences,
0,
level_random,
);
skulls = items.iter().any(|i| !i.is_empty());
out.extend(items.into_iter().filter(|i| !i.is_empty()).map(Drop::Item));
}
@@ -480,7 +494,14 @@ pub fn die(m: &mut Mob, d: &Death, sequences: &mut RandomSequences) -> (Vec<Drop
player: d.player.clone().filter(|_| killed_by_player),
luck: if killed_by_player { d.luck } else { 0.0 },
};
let items = roll(d, &table, &params, sequences);
let items = roll(
d,
&table,
&params,
sequences,
m.extra.death_loot_seed,
level_random,
);
out.extend(items.into_iter().filter(|i| !i.is_empty()).map(Drop::Item));
// Mob.dropCustomDeathLoot.
let damage_params = EntityLootParams {
@@ -579,25 +600,37 @@ pub fn die(m: &mut Mob, d: &Death, sequences: &mut RandomSequences) -> (Vec<Drop
/// The loot table `die` rolls for `m` (`LivingEntity.getLootTable`), when it
/// rolls one: none with `doMobLoot` off, nor for a baby that isn't a monster.
pub fn death_loot_table(m: &Mob, mob_drops: bool) -> Option<String> {
// Mob.getLootTable: the mob's own `DeathLootTable`, else its type's.
(mob_drops && (is_monster(m.species()) || !m.is_baby())).then(|| {
format!(
"minecraft:entities/{}",
m.kind.id.trim_start_matches("minecraft:")
)
m.extra.death_loot_table.clone().unwrap_or_else(|| {
format!(
"minecraft:entities/{}",
m.kind.id.trim_start_matches("minecraft:")
)
})
})
}
/// `LootTable.getRandomItems(params, seed, ...)`: a seed's own random, else
/// the table's random sequence, else the level's random.
fn roll(
d: &Death,
table: &str,
params: &EntityLootParams,
sequences: &mut RandomSequences,
seed: i64,
level_random: &mut LegacyRandom,
) -> Vec<LootStack> {
if seed != 0 {
return d
.loot
.roll_entity(table, &mut LegacyRandom::new(seed), params);
}
match d.loot.random_sequence(table) {
Some(id) => {
let random = sequences.get(id, d.world_seed);
d.loot.roll_entity(table, random, params)
}
None => Vec::new(),
None => d.loot.roll_entity(table, level_random, params),
}
}
/// `LivingEntity.shouldDropExperience`: babies drop none, except monsters and
@@ -646,3 +679,56 @@ pub fn base_experience(m: &mut Mob) -> i32 {
}
}
}
#[cfg(test)]
mod death_loot_table_tests {
use super::*;
use horizon_world::loot::{LootData, PackOverlay};
/// Mob.getLootTable and LootTable.getRandomItems: a mob's own
/// `DeathLootTable` is rolled instead of its type's, with the level's
/// random when the table has no random sequence (as a pack's tables
/// mostly don't), and with its `DeathLootTableSeed` when set.
#[test]
fn a_mob_drops_its_own_death_loot_table() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("data").exists() {
return;
}
let table = br#"{"type":"minecraft:entity","pools":[{"rolls":1,
"entries":[{"type":"minecraft:item","name":"minecraft:diamond"}]}]}"#;
let packs = [("loot_table/pack:boss".to_owned(), vec![table.to_vec()])];
let loot = LootData::load(&root, &PackOverlay { files: &packs }).unwrap();
let death = Death {
loot: &loot,
damage: None,
player: None,
luck: 0.0,
vehicle: None,
mob_drops: true,
world_seed: 0,
};
let items = |seed: i64, own: bool| {
let kind = crate::mobs::sensor_scan_tests::kind();
let mut m = Mob::new(1, kind, horizon_core::geometry::Vec3::ZERO, 0.0);
if own {
m.extra.death_loot_table = Some("pack:boss".into());
}
m.extra.death_loot_seed = seed;
let mut sequences = RandomSequences::default();
let (out, _) = die(&mut m, &death, &mut sequences, &mut LegacyRandom::new(7));
out.into_iter()
.filter_map(|d| match d {
Drop::Item(s) => Some(s.item),
_ => None,
})
.collect::<Vec<_>>()
};
assert_eq!(items(0, true), ["minecraft:diamond"]);
assert_eq!(items(42, true), ["minecraft:diamond"]);
// Without one, the type's table (a cow's beef and leather).
assert!(items(0, false).iter().all(|i| i != "minecraft:diamond"));
}
}
+12 -2
View File
@@ -679,7 +679,12 @@ mod tests {
] {
let mut m = enderman();
m.extra.carried = registry.default_state(block);
let (out, _) = drops::die(&mut m, &death(true), &mut sequences);
let (out, _) = drops::die(
&mut m,
&death(true),
&mut sequences,
&mut horizon_core::random::LegacyRandom::new(0),
);
let dropped: Vec<String> = out
.into_iter()
.flat_map(|d| match d {
@@ -699,7 +704,12 @@ mod tests {
// doMobLoot off: no dropCustomDeathLoot, so no block either.
let mut m = enderman();
m.extra.carried = registry.default_state("minecraft:grass_block");
let (out, _) = drops::die(&mut m, &death(false), &mut sequences);
let (out, _) = drops::die(
&mut m,
&death(false),
&mut sequences,
&mut horizon_core::random::LegacyRandom::new(0),
);
assert!(
!out.iter()
.any(|d| matches!(d, drops::Drop::CarriedBlock(_))),
+82 -5
View File
@@ -236,11 +236,29 @@ pub fn ai_step(m: &mut Mob, w: &MobWorld, events: &mut Vec<MobEvent>) {
let _ = powered;
}
if m.extra.counter > 0 {
for _ in 0..3 {
m.random.next_gaussian();
m.random.next_float();
m.random.next_gaussian();
}
charging_particles(m, events);
}
}
/// `WitherBoss.aiStep`'s entity-effect particles while the wither is still
/// charging (`getInvulnerableTicks() > 0`): three a tick in the
/// (0.7, 0.7, 0.9) colour, a gaussian away from the body on x and z and up to
/// `3.3 * getScale()` above its feet on y, all from the wither's own random.
pub fn charging_particles(m: &mut Mob, events: &mut Vec<MobEvent>) {
if m.extra.counter <= 0 {
return;
}
let spread = 3.3 * super::riders::scale(m);
for _ in 0..3 {
// `this.random`: a gaussian for x, a float for y, a gaussian for z.
let at = [
m.position.x + m.random.next_gaussian(),
m.position.y + f64::from(m.random.next_float()) * spread,
m.position.z + m.random.next_gaussian(),
];
events.push(MobEvent::ParticleColored {
at,
color: [0.7, 0.7, 0.9],
});
}
}
/// `WitherBoss.customServerAiStep`: charging, then the side heads' targets
@@ -474,4 +492,63 @@ mod tests {
assert!(!shielded_from(Species::Wither, Species::Creeper));
assert!(!shielded_from(Species::Zombie, Species::Wither));
}
/// A spawning wither rolled the three particles of `aiStep`'s
/// `getInvulnerableTicks() > 0` block and dropped them on the floor: the
/// potion-coloured (`0.7, 0.7, 0.9`) cloud around it never appeared (#7486).
#[test]
fn a_charging_wither_sheds_three_potion_coloured_particles_a_tick() {
use crate::mobs::Species;
use crate::mobs::sensor_scan_tests::kind;
use std::sync::Arc;
let mut kind = kind();
let k = Arc::make_mut(&mut kind);
k.id = "minecraft:wither".into();
k.species = Species::Wither;
let wither = |model_scale: f32| {
let mut m = Mob::new(1, kind.clone(), Vec3::new(4.0, 64.0, -2.0), 0.0);
m.extra.counter = INVULNERABLE;
m.extra.model_scale = model_scale;
m
};
for (model_scale, spread) in [(1.0_f32, 3.3_f64), (2.0, 6.6)] {
let mut m = wither(model_scale);
// The wither's own random, advanced the way vanilla's `aiStep`
// does it, for the expected offsets.
let mut probe = m.random.clone();
let mut events = Vec::new();
charging_particles(&mut m, &mut events);
let mut want = Vec::new();
for _ in 0..3 {
let (dx, dy, dz) = (
probe.next_gaussian(),
f64::from(probe.next_float()) * spread,
probe.next_gaussian(),
);
want.push([4.0 + dx, 64.0 + dy, -2.0 + dz]);
}
let got: Vec<([f64; 3], [f32; 3])> = events
.iter()
.map(|e| match e {
MobEvent::ParticleColored { at, color } => (*at, *color),
other => panic!("not an entity-effect particle: {other:?}"),
})
.collect();
assert_eq!(got.len(), 3, "three a tick, as many as the heads' smoke");
for (at, color) in &got {
assert_eq!(*color, [0.7, 0.7, 0.9]);
// Measured against the body, gaussians on x and z and a float
// over [0, 3.3 * getScale()) on y.
assert!((0.0..spread).contains(&(at[1] - 64.0)), "{at:?}");
}
let flat: Vec<[f64; 3]> = got.iter().map(|(at, _)| *at).collect();
assert_eq!(flat, want, "scale {model_scale}");
}
// Once the charge ends (`getInvulnerableTicks` back to 0) the cloud
// stops: the block is guarded, not unconditional.
let mut m = wither(1.0);
m.extra.counter = 0;
let mut events = Vec::new();
charging_particles(&mut m, &mut events);
assert!(events.is_empty());
}
}
+1
View File
@@ -30,6 +30,7 @@ pub use crate::items::ItemStack;
pub use crate::merchant::MerchantOffer;
pub use crate::music::{Music, MusicLayer};
pub use crate::particles::{ParticleOptions, ParticleType};
pub use crate::recipe_book::RecipeBookCategory;
pub use crate::text::Text;
pub use horizon_assets::item_model::DisplayContext;
pub use horizon_core::{
+58
View File
@@ -4542,6 +4542,15 @@ pub trait Hooks: Send + Sync {
let _ = host;
let _ = event;
}
/// A mod's own ingredient kind, matching a concrete stack at craft time:
/// the ingredient's `test` calls here with the kind, its JSON and the
/// stack being matched, `accept` already true (the load-time list named
/// the item). Set `accept` to false to refuse the stack — its components
/// are the usual reason. The hook may be called many times a craft; the
/// answer must be the same for the same stack. API @API:ingredient_test@.
fn ingredient_test(&self, event: &mut IngredientTestEvent<'_>) {
let _ = event;
}
/// Item `item` (`mod_id:name`) holds items (`Capabilities.Item.ITEM`): set
/// `slots` when the item is yours, for a backpack, a storage cell or a
/// toolbox. Clamped to 1..=256, `ItemContainerContents`' limit; 0 leaves
@@ -4575,6 +4584,14 @@ pub trait Hooks: Send + Sync {
let _ = host;
let _ = event;
}
/// Declare categories for custom recipe types, with registered item icons.
/// Asked lazily once per recipe table by `native_recipe_book_category`.
/// Invalid declarations are logged and skipped; the first valid category
/// owns its id and type. This does not yet add tabs or book displays.
fn recipe_book_categories(&self, host: &dyn Host, event: &mut RecipeBookCategoriesEvent<'_>) {
let _ = host;
let _ = event;
}
/// Additional first-person hand and item pose, including empty arms and maps.
/// Add offsets in blocks and pitch, yaw and roll in degrees; multiply scale.
/// API @API:render_hand_pose@.
@@ -13996,6 +14013,32 @@ pub fn dispatch_explosion_knockback(
mod_hook.explosion_knockback(host, &mut event);
}
}
/// What [`Hooks::ingredient_test`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct IngredientTestEvent<'a> {
pub kind: &'a str,
pub data: &'a serde_json::Map<String, Value>,
pub stack: &'a ItemStack,
pub accept: bool,
}
pub fn dispatch_ingredient_test(
mods: &[Box<dyn Hooks>],
kind: &str,
data: &serde_json::Map<String, Value>,
stack: &ItemStack,
accept: &mut bool,
) {
let mut event = IngredientTestEvent {
kind,
data,
stack,
accept: *accept,
};
for mod_hook in super::entered(mods, "ingredient_test") {
mod_hook.ingredient_test(&mut event);
}
*accept = event.accept;
}
/// What [`Hooks::item_container`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct ItemContainerEvent<'a> {
@@ -14080,6 +14123,21 @@ pub fn dispatch_movement_input(
*jump = event.jump;
*sneak = event.sneak;
}
/// What [`Hooks::recipe_book_categories`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct RecipeBookCategoriesEvent<'a> {
pub categories: &'a mut Vec<RecipeBookCategory>,
}
pub fn dispatch_recipe_book_categories(
host: &dyn Host,
mods: &[Box<dyn Hooks>],
categories: &mut Vec<RecipeBookCategory>,
) {
let mut event = RecipeBookCategoriesEvent { categories };
for mod_hook in super::entered(mods, "recipe_book_categories") {
mod_hook.recipe_book_categories(host, &mut event);
}
}
/// What [`Hooks::render_hand_pose`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct RenderHandPoseEvent<'a> {
+7
View File
@@ -1303,6 +1303,13 @@ pub trait Host {
let _ = max;
unimplemented!("Host::item_container_extract")
}
/// The validated category and icon of a custom recipe type, or None for
/// an undeclared/invalid type, before resources load, or outside a world.
/// Rebuilt when the recipe table changes. Does not unlock any recipes.
fn recipe_book_category(&mut self, kind: &str) -> Option<RecipeBookCategory> {
let _ = kind;
unimplemented!("Host::recipe_book_category")
}
}
impl EntityRef {
/// The entity's position (`Entity.position`): its feet, the middle of
+100 -61
View File
@@ -17,7 +17,8 @@
use crate::{Result, blocks::MAP_COLORS};
use horizon_core::geometry::{Aabb, Axis, ShapeOp, Vec3, VoxelShape};
use horizon_mods::content::{
Block, Content, CreativeTab, EntityType, MenuType, MobEffect, Rarity, StateProperty,
Block, BlockShapes, Content, CreativeTab, EntityType, MenuType, MobEffect, Rarity,
StateProperty,
};
use horizon_world::nbt::{self, Compound, JavaString, Limits, Tag};
use serde::{Deserialize, Serialize, de};
@@ -568,6 +569,61 @@ fn encode_shape(shape: &VoxelShape) -> Tag {
Tag::Compound(c)
}
fn resolve_shapes<'a>(
spec: &BlockShapes,
block: &str,
states: &Range<u32>,
order: &[(u32, u32)],
combos: &[Vec<(String, String)>],
properties: impl Fn(u32) -> Option<&'a J>,
) -> Result<Vec<VoxelShape>> {
let names = spec.property_names();
let mut used = BTreeSet::new();
let mut shapes = Vec::with_capacity(order.len());
for &(state, combo) in order {
let values = names
.iter()
.map(|name| {
combos[combo as usize]
.iter()
.find(|(key, _)| key == name)
.map(|(_, value)| value.as_str())
.or_else(|| {
match properties(states.start + state).and_then(|props| props.get(name)) {
Some(J::Str(value)) => Some(value.as_str()),
_ => None,
}
})
.ok_or_else(|| format!("{block}: state lacks shape property {name}"))
})
.collect::<std::result::Result<Vec<_>, _>>()?;
let key = values.join(",");
let boxes = if let Some(boxes) = spec.values.get(&key) {
used.insert(key);
boxes
} else {
spec.default
.as_ref()
.ok_or_else(|| format!("{block}: no shape for {key}"))?
};
let mut shape = VoxelShape::empty();
for bounds in boxes {
let part = VoxelShape::from_box(Aabb::new(
Vec3::new(bounds[0], bounds[1], bounds[2]),
Vec3::new(bounds[3], bounds[4], bounds[5]),
))?;
shape = shape.join_unoptimized(&part, ShapeOp::OR)?;
}
shapes.push(shape);
}
if used.len() != spec.values.len() {
return Err(
format!("{block}: shape values include an unknown property value tuple").into(),
);
}
Ok(shapes)
}
/// A block being added: its declaration and where its states go.
struct NewBlock<'a> {
decl: &'a Block,
@@ -575,6 +631,7 @@ struct NewBlock<'a> {
/// The template's item, if it has one.
item: Option<String>,
shapes: Option<Vec<VoxelShape>>,
collision_shapes: Option<Vec<VoxelShape>>,
default: u32,
/// The declared state properties' combinations, every property at its
/// default first (one empty combination when the block declares none):
@@ -885,50 +942,7 @@ impl<'a> Plan<'a> {
let states = range(&decl.template).ok_or_else(|| {
format!("{}: template {} is not a block", decl.id, decl.template)
})?;
let shapes = if let Some(spec) = &decl.shapes {
let Some(J::Arr(rows)) = facts.get("states") else {
unreachable!()
};
let mut used = BTreeSet::new();
let mut out = Vec::new();
for id in states.clone() {
let J::Arr(row) = &rows[id as usize] else {
unreachable!()
};
let Some(J::Str(value)) = row
.get(4)
.and_then(|x| x.get("properties"))
.and_then(|x| x.get(&spec.property))
else {
return Err(format!(
"{}: template state lacks property {}",
decl.id, spec.property
)
.into());
};
let boxes = spec.values.get(value).ok_or_else(|| {
format!("{}: no shape for {}={value}", decl.id, spec.property)
})?;
used.insert(value.as_str());
let mut shape = VoxelShape::empty();
for b in boxes {
let part = VoxelShape::from_box(Aabb::new(
Vec3::new(b[0], b[1], b[2]),
Vec3::new(b[3], b[4], b[5]),
))?;
shape = shape.join_unoptimized(&part, ShapeOp::OR)?;
}
out.push(shape);
}
if used.len() != spec.values.len() {
return Err(format!(
"{}: shape values include an unknown {} value",
decl.id, spec.property
)
.into());
}
Some(out)
} else if let Some(size) = decl.thickness {
let pipe_shapes = if let Some(size) = decl.thickness {
let Some(J::Arr(rows)) = facts.get("states") else {
unreachable!()
};
@@ -1032,6 +1046,20 @@ impl<'a> Plan<'a> {
.map(|(name, property)| (name.clone(), property.values().len() as u32))
.collect();
let order = block_order(&states, properties, &declared, combos.len() as u32);
let resolve = |spec: &BlockShapes| {
resolve_shapes(spec, &decl.id, &states, &order, &combos, properties)
};
let shapes = if let Some(spec) = &decl.shapes {
Some(resolve(spec)?)
} else {
pipe_shapes.map(|shapes: Vec<VoxelShape>| {
order
.iter()
.map(|&(state, _)| shapes[state as usize].clone())
.collect()
})
};
let collision_shapes = decl.collision_shapes.as_ref().map(resolve).transpose()?;
// The lookup from (template state, combination) to the state's row.
let mut rows = vec![0; order.len()];
for (position, &(state, combo)) in order.iter().enumerate() {
@@ -1056,6 +1084,7 @@ impl<'a> Plan<'a> {
rename,
item: None,
shapes,
collision_shapes,
default,
combos,
order,
@@ -1190,7 +1219,7 @@ impl<'a> Plan<'a> {
// One row per template state per combination the block declares,
// so its states stay one run in registry order.
for (next, (id, combo)) in (b.rename.first..).zip(b.state_combinations()) {
let t = (id - b.rename.states.start) as usize;
let state_index = (next - b.rename.first) as usize;
let mut row = list[id as usize].clone();
for r in &renames {
row.rename(r, ids);
@@ -1205,7 +1234,7 @@ impl<'a> Plan<'a> {
}
self.override_gameplay(b, combo, &mut row);
if let Some(shapes) = &b.shapes {
let shape = &shapes[t];
let shape = &shapes[state_index];
let boxes = shape
.boxes()
.iter()
@@ -1227,6 +1256,9 @@ impl<'a> Plan<'a> {
.collect();
row.set("outline", J::Arr(boxes));
row.set("outline_edges", J::Arr(shape_edges(shape)));
}
if let Some(shapes) = b.collision_shapes.as_ref().or(b.shapes.as_ref()) {
let shape = &shapes[state_index];
row.set(
"full_collision",
J::Bool(!shape.join_is_not_empty(&VoxelShape::block(), ShapeOp::XOR)?),
@@ -1567,22 +1599,29 @@ impl<'a> Plan<'a> {
return Err("collision table has no shapes".into());
};
for block in &self.blocks {
if let Some(shapes) = &block.shapes {
// One shape per template state, shared by every combination
// of it: the geometry is the template's, the properties are
// the block's.
let ids: Vec<i32> = shapes
.iter()
.map(|shape| {
if let Some(shapes) = block.collision_shapes.as_ref().or(block.shapes.as_ref()) {
let mut encoded = BTreeMap::new();
for (offset, shape) in shapes.iter().enumerate() {
let key: Vec<_> = shape
.boxes()
.iter()
.map(|bounds| {
[
bounds.min(Axis::X),
bounds.min(Axis::Y),
bounds.min(Axis::Z),
bounds.max(Axis::X),
bounds.max(Axis::Y),
bounds.max(Axis::Z),
]
.map(f64::to_bits)
})
.collect();
let id = *encoded.entry(key).or_insert_with(|| {
entries.push(encode_shape(shape));
entries.len() as i32 - 1
})
.collect();
for (at, &shape) in ids.iter().enumerate() {
for combination in 0..block.rename.fanout {
let row = block.rename.row(at as u32, combination);
indices.push((block.rename.first as usize + row as usize, shape));
}
});
indices.push((block.rename.first as usize + offset, id));
}
}
}
@@ -2543,6 +2543,112 @@ fn block_base_of(tag: &str, declarations: &[&str], facts: &str) -> (PathBuf, Vec
(base, mods)
}
#[test]
fn tuple_shapes_follow_final_state_order_and_separate_collision() {
let declaration = r#"{"template":"minecraft:test","item":false,
"state_properties":{"active":{"type":"boolean"}},
"shapes":{"properties":["axis","active"],
"values":{"x,true":[[0,0,0,0.5,1,1]]},"default":[[0,0,0,1,1,1]]},
"collision_shapes":{"properties":["active","axis"],
"values":{"true,x":[[0,0,0,0.25,1,1]]},"default":[]}}"#;
let (base, mods) = block_base("tuple-shapes", &[declaration]);
let plan = Plan::new(&base, &mods).unwrap();
let block = &plan.blocks[0];
let shapes = block.shapes.as_ref().unwrap();
let collisions = block.collision_shapes.as_ref().unwrap();
assert_eq!(shapes.len(), 4);
assert_eq!(collisions.len(), 4);
for (index, (template, combo)) in block.state_combinations().enumerate() {
let special = template == 0
&& combo
.iter()
.any(|(name, value)| name == "active" && value == "true");
assert_eq!(
shapes[index].bounds().unwrap().max(Axis::X),
if special { 0.5 } else { 1.0 }
);
assert_eq!(collisions[index].is_empty(), !special);
if special {
assert_eq!(collisions[index].bounds().unwrap().max(Axis::X), 0.25);
}
}
let mut gameplay: J =
serde_json::from_slice(&fs::read(base.join("gameplay-facts.json")).unwrap()).unwrap();
plan.json("gameplay-facts.json", &mut gameplay).unwrap();
let Some(J::Arr(rows)) = gameplay.get("states") else {
panic!("states")
};
for (index, row) in rows[2..].iter().enumerate() {
assert_eq!(row.get("full_collision"), Some(&J::Bool(false)));
let Some(J::Arr(outline)) = row.get("outline") else {
panic!("outline")
};
assert_eq!(outline.len(), shapes[index].boxes().len());
}
let mut tag = Tag::Compound(Compound::from([
(
JavaString::from("states"),
Tag::List(vec![test_state_row("x"), test_state_row("y")]),
),
(JavaString::from("shapes"), Tag::List(Vec::new())),
(
JavaString::from("cached_shapes"),
Tag::IntArray(vec![-1, -1]),
),
]));
plan.nbt(&mut tag);
plan.collision_shapes(&mut tag).unwrap();
let Some(Tag::List(entries)) = tag.get("shapes") else {
panic!("shapes")
};
let Some(Tag::IntArray(mapping)) = tag.get("cached_shapes") else {
panic!("mapping")
};
for (index, shape) in collisions.iter().enumerate() {
assert_eq!(entries[mapping[2 + index] as usize], encode_shape(shape));
}
fs::remove_dir_all(base.parent().unwrap()).unwrap();
}
#[test]
fn collision_only_preserves_outline_and_shape_typos_are_errors() {
let declaration = r#"{"template":"minecraft:test","collision_shapes":{"default":[]}}"#;
let (base, mods) = block_base("collision-only", &[declaration]);
let plan = Plan::new(&base, &mods).unwrap();
assert!(plan.blocks[0].shapes.is_none());
assert!(
plan.blocks[0]
.collision_shapes
.as_ref()
.unwrap()
.iter()
.all(VoxelShape::is_empty)
);
let mut gameplay: J =
serde_json::from_str(r#"{"states":[{"outline":[[0,0,0,1,1,1]]},{"outline":[]}]}"#).unwrap();
plan.json("gameplay-facts.json", &mut gameplay).unwrap();
let Some(J::Arr(rows)) = gameplay.get("states") else {
panic!("states")
};
assert_eq!(rows[0].get("outline"), rows[2].get("outline"));
assert_eq!(rows[1].get("outline"), rows[3].get("outline"));
assert_eq!(rows[2].get("full_collision"), Some(&J::Bool(false)));
fs::remove_dir_all(base.parent().unwrap()).unwrap();
for (index, table) in [
r#"{"properties":["axis","typo"],"default":[]}"#,
r#"{"properties":["axis"],"values":{"bad":[]},"default":[]}"#,
r#"{"properties":["axis"],"values":{"x":[]}}"#,
]
.iter()
.enumerate()
{
let declaration = format!(r#"{{"template":"minecraft:test","collision_shapes":{table}}}"#);
let (base, mods) = block_base(&format!("collision-typo-{index}"), &[&declaration]);
assert!(Plan::new(&base, &mods).is_err(), "{table}");
fs::remove_dir_all(base.parent().unwrap()).unwrap();
}
}
/// A block's states are its template's times the combinations of the state
/// properties it declares, in a fixed order with every default first.
#[test]
@@ -12,6 +12,7 @@ pub struct NativeClientHooks {
pub living_fall: fn(u32, &str, &mut f64, &mut f32, &mut bool),
pub living_jump: fn(u32, &str),
pub ingredient: fn(&str, &Map<String, Value>, &mut Vec<String>),
pub ingredient_test: fn(&str, &Map<String, Value>, &ItemStack, &mut bool),
}
static HOOKS: OnceLock<NativeClientHooks> = OnceLock::new();
@@ -94,3 +95,14 @@ pub(crate) fn ingredient(kind: &str, data: &Map<String, Value>, items: &mut Vec<
(hooks.ingredient)(kind, data, items);
}
}
pub(crate) fn ingredient_test(
kind: &str,
data: &Map<String, Value>,
stack: &ItemStack,
accept: &mut bool,
) {
if let Some(hooks) = HOOKS.get() {
(hooks.ingredient_test)(kind, data, stack, accept);
}
}
+138 -1
View File
@@ -11,6 +11,66 @@ use horizon_world::loot::PackOverlay;
use horizon_world::nbt::Tag;
use std::collections::{BTreeSet, HashMap};
/// A native mod's book category for one custom recipe type.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RecipeBookCategory {
/// A namespaced category id outside `minecraft:`.
pub id: String,
/// A custom type present in the current recipe table.
pub recipe_type: String,
/// A registered item id used as the category's icon.
pub icon: String,
}
/// Validate declarations once per recipe table. The first valid declaration
/// owns its category id and recipe type; vanilla categories are never replaced.
pub fn mod_categories(
declarations: Vec<RecipeBookCategory>,
recipes: &Recipes,
items: &Items,
) -> HashMap<String, RecipeBookCategory> {
let valid_id = |id: &str| {
id.split_once(':').is_some_and(|(namespace, path)| {
!namespace.is_empty()
&& !path.is_empty()
&& namespace.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"_.-".contains(&byte)
})
&& path.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || b"/_.-".contains(&byte)
})
})
};
let mut categories = HashMap::new();
let mut ids = BTreeSet::new();
for category in declarations {
let reason = if !valid_id(&category.id) || category.id.starts_with("minecraft:") {
Some("invalid or reserved category id")
} else if !valid_id(&category.recipe_type)
|| category.recipe_type.starts_with("minecraft:")
|| !recipes.custom.contains_key(&category.recipe_type)
{
Some("unknown or vanilla recipe type")
} else if !valid_id(&category.icon)
|| category.icon == "minecraft:air"
|| items.get(&category.icon).is_none()
{
Some("missing or unknown icon item")
} else if ids.contains(&category.id) || categories.contains_key(&category.recipe_type) {
Some("duplicate category id or recipe type")
} else {
None
};
if let Some(reason) = reason {
log::warn!("recipe book category {:?} skipped: {reason}", category);
continue;
}
ids.insert(category.id.clone());
categories.insert(category.recipe_type.clone(), category);
}
categories
}
/// `RecipeBookType`: the menus with a recipe book and their own settings.
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum BookType {
@@ -415,7 +475,7 @@ impl Book {
/// `ingredient`'s items as its display resolves them (the cycling order
/// of ghost slots and the overlay).
pub fn items_of(&self, ingredient: &Ingredient) -> Vec<String> {
ingredient.1.clone()
ingredient.order.clone()
}
/// `RecipeBookComponent.fillGhostRecipe` of a book type: the result and the
/// ingredients faded into the menu's slots, in the order the component
@@ -984,6 +1044,83 @@ mod tests {
use super::*;
#[test]
fn mod_category_mapping_validates_ids_icons_and_conflicts() {
let root =
std::env::temp_dir().join(format!("horizon-book-categories-{}", std::process::id()));
std::fs::create_dir_all(&root).unwrap();
std::fs::write(
root.join("items.json"),
r#"{"minecraft:stone":{},"mymod:crusher":{},"minecraft:air":{}}"#,
)
.unwrap();
let items = Items::catalog(&root, &PackOverlay::NONE).unwrap();
let recipes = Recipes::from_sources(
&root,
&items,
&PackOverlay::NONE,
vec![
(
"mymod:crush".into(),
serde_json::json!({"type":"mymod:crusher"}),
),
(
"mymod:mix".into(),
serde_json::json!({"type":"mymod:mixer"}),
),
],
)
.unwrap();
let declare = |id: &str, recipe_type: &str, icon: &str| RecipeBookCategory {
id: id.into(),
recipe_type: recipe_type.into(),
icon: icon.into(),
};
let crusher = declare("mymod:crushing", "mymod:crusher", "mymod:crusher");
let mixer = declare("mymod:mixing", "mymod:mixer", "minecraft:stone");
let mapped = mod_categories(
vec![
declare("mymod:crushing", "mymod:crusher", ""),
crusher.clone(),
declare("mymod:duplicate", "mymod:crusher", "minecraft:stone"),
declare("mymod:crushing", "mymod:mixer", "minecraft:stone"),
mixer.clone(),
],
&recipes,
&items,
);
assert_eq!(mapped.len(), 2);
assert_eq!(mapped.get("mymod:crusher"), Some(&crusher));
assert_eq!(mapped.get("mymod:mixer"), Some(&mixer));
assert!(!mapped.contains_key("minecraft:crafting"));
for invalid in [
declare(
"minecraft:crafting_misc",
"mymod:crusher",
"minecraft:stone",
),
declare("bare", "mymod:crusher", "minecraft:stone"),
declare("mymod:", "mymod:crusher", "minecraft:stone"),
declare(":category", "mymod:crusher", "minecraft:stone"),
declare("mymod:Bad", "mymod:crusher", "minecraft:stone"),
declare("mymod:bad:id", "mymod:crusher", "minecraft:stone"),
declare("mymod:crushing", "mymod:absent", "minecraft:stone"),
declare("mymod:crushing", "minecraft:smelting", "minecraft:stone"),
declare("mymod:crushing", "mymod:crusher", ""),
declare("mymod:crushing", "mymod:crusher", "minecraft:absent"),
declare("mymod:crushing", "mymod:crusher", "minecraft:air"),
] {
assert!(
mod_categories(vec![invalid.clone()], &recipes, &items).is_empty(),
"{invalid:?}"
);
}
assert!(mod_categories(Vec::new(), &recipes, &items).is_empty());
let vanilla = Recipes::from_sources(&root, &items, &PackOverlay::NONE, Vec::new()).unwrap();
assert!(mod_categories(vec![crusher], &vanilla, &items).is_empty());
std::fs::remove_dir_all(root).unwrap();
}
fn ing(items: &[&str]) -> Ingredient {
Ingredient::of(items.iter().map(|s| (*s).to_owned()).collect())
}
+3 -3
View File
@@ -708,7 +708,7 @@ impl StationRecipes {
addition,
pattern,
..
} if i == id => Some((&base.1, &addition.1, pattern)),
} if i == id => Some((&base.order, &addition.order, pattern)),
_ => None,
})?;
if base.is_empty() || addition.is_empty() {
@@ -1041,13 +1041,13 @@ mod tests {
fn smithing_ingredient_parses_holders() {
let items = sample_items();
let a = ingredient(&serde_json::json!("minecraft:diamond_sword"), &items);
assert_eq!(a.1, vec!["minecraft:diamond_sword"]);
assert_eq!(a.order, vec!["minecraft:diamond_sword"]);
let b = ingredient(
&serde_json::json!(["minecraft:diamond_sword", "minecraft:diamond_axe"]),
&items,
);
assert_eq!(
b.1,
b.order,
vec!["minecraft:diamond_sword", "minecraft:diamond_axe"]
);
}
@@ -0,0 +1,121 @@
//! A data-pack biome is in the registry a server sends, with its data, at the
//! place the chunk encoder numbers it by, and a client rebuilds it from that
//! data.
use horizon_client::{
biome::{BiomeColors, Biomes},
chunk::LevelShapes,
lan::data::{BIOME_REGISTRY, SyncData, client_biomes},
scoreboard::json_to_tag,
};
use serde_json::Value;
use std::path::PathBuf;
/// Removes the folder when the test ends, passed or not.
struct Scratch(PathBuf);
impl Drop for Scratch {
fn drop(&mut self) {
std::fs::remove_dir_all(&self.0).ok();
}
}
/// A resource folder with two biomes and 1x1 colormaps, loaded.
fn write_base(root: &std::path::Path) -> Biomes {
let maps = root.join("assets/minecraft/textures/colormap");
let biome_dir = root.join("data/minecraft/worldgen/biome");
std::fs::create_dir_all(&maps).unwrap();
std::fs::create_dir_all(&biome_dir).unwrap();
std::fs::write(root.join("registries.json"), "{}").unwrap();
for name in ["grass", "foliage", "dry_foliage"] {
let file = std::fs::File::create(maps.join(format!("{name}.png"))).unwrap();
let mut png = png::Encoder::new(file, 1, 1);
png.set_color(png::ColorType::Rgb);
png.set_depth(png::BitDepth::Eight);
let mut writer = png.write_header().unwrap();
writer.write_image_data(&[1, 2, 3]).unwrap();
}
for name in ["plains", "river"] {
std::fs::write(biome_dir.join(format!("{name}.json")), "{}").unwrap();
}
Biomes::load(root).unwrap()
}
#[test]
fn a_pack_biome_is_sent_with_its_data_and_read_back_by_a_client() {
let root = std::env::temp_dir().join(format!("horizon-sync-biomes-{}", std::process::id()));
let _scratch = Scratch(root.clone());
let base = write_base(&root);
let pack: Value = serde_json::from_str(r##"{"effects":{"grass_color":"#123456"}}"##).unwrap();
let world = base.with_packs([("minecraft:tundra_x", &pack)]);
let sync =
SyncData::load_with(&root, &horizon_world::loot::PackOverlay::NONE, Some(&world)).unwrap();
// The encoder numbers a biome by its place in this list: none may be
// missing, and the pack's is not the first.
let registry = &sync.dynamic.registries[BIOME_REGISTRY];
let names: Vec<_> = registry.iter().map(|(n, _)| n.as_str()).collect();
assert_eq!(
names,
["minecraft:plains", "minecraft:river", "minecraft:tundra_x"]
);
assert!(world.list.iter().all(|b| names.contains(&b.name.as_str())));
// A client that knows the vanilla pack still gets the pack biome's data.
let sent = sync.registry_packets(true, &LevelShapes::VANILLA);
let biomes = sent.iter().find(|r| r.registry == BIOME_REGISTRY).unwrap();
assert!(biomes.entries[0].data.is_none(), "a known one is not sent");
assert!(biomes.entries[2].data.is_some(), "the pack's biome is sent");
// The client builds its table from what arrived: the server's id for
// the name, and the pack's colour.
let received: Vec<_> = biomes
.entries
.iter()
.map(|e| (e.id.clone(), e.data.clone()))
.collect();
let client = client_biomes(&base, &received).expect("the pack's biome is new to the client");
let id = client.id("minecraft:tundra_x");
assert_eq!(id, world.id("minecraft:tundra_x"));
assert_eq!(client.get(id).grass, 0x123456);
assert_ne!(
client.get(id).grass,
client.get(client.id("minecraft:plains")).grass
);
// Nothing new to a client that knows them all: it keeps its own table.
// even when the server sends data for them (another grass colour here).
let other: Value = serde_json::from_str(r##"{"effects":{"grass_color":"#654321"}}"##).unwrap();
let other = json_to_tag(&other);
let known: Vec<_> = received
.iter()
.filter(|(n, _)| base.contains(n))
.map(|(n, _)| (n.clone(), Some(other.clone())))
.collect();
assert!(client_biomes(&base, &known).is_none());
// Beside a new biome, a known one keeps the game's colour.
let mut mixed = known.clone();
mixed.extend(received.iter().filter(|(n, _)| !base.contains(n)).cloned());
let client = client_biomes(&base, &mixed).unwrap();
let plains = base.get(base.id("minecraft:plains")).grass;
assert_ne!(plains, 0x654321);
assert_eq!(client.get(client.id("minecraft:plains")).grass, plains);
assert_eq!(client.get(client.id("minecraft:tundra_x")).grass, 0x123456);
}
#[test]
fn recolouring_twice_starts_from_the_biome_files_each_time() {
let root = std::env::temp_dir().join(format!("horizon-recolor-biomes-{}", std::process::id()));
let _scratch = Scratch(root.clone());
let base = write_base(&root);
let grass = |b: &Biomes| b.get(b.id("minecraft:plains")).grass;
let darker = |_: &str, c: &mut BiomeColors| c.grass -= 0x10;
let once = base.recolored_from(&base, darker).expect("colours changed");
assert_eq!(grass(&once), grass(&base) - 0x10);
// The same colours again, asked from the base: nothing to rebuild.
assert!(base.recolored_from(&once, darker).is_none());
// Other colours replace the first ones rather than add to them.
let lighter = |_: &str, c: &mut BiomeColors| c.grass += 0x10;
let next = base.recolored_from(&once, lighter).unwrap();
assert_eq!(grass(&next), grass(&base) + 0x10);
}
@@ -46,3 +46,35 @@ fn movement_input_writes_back_all_fields() {
);
assert_eq!((forward, strafe, jump, sneak), (-0.5, 0.25, true, true));
}
#[test]
fn recipe_book_categories_accept_mod_declarations() {
use horizon_client::mod_api::{
RecipeBookCategoriesEvent, RecipeBookCategory, dispatch_recipe_book_categories,
};
struct TestHost;
impl Host for TestHost {}
struct Categories;
impl Hooks for Categories {
fn recipe_book_categories(
&self,
_host: &dyn Host,
event: &mut RecipeBookCategoriesEvent<'_>,
) {
event.categories.push(RecipeBookCategory {
id: "mymod:crushing".into(),
recipe_type: "mymod:crusher".into(),
icon: "minecraft:stone".into(),
});
}
}
let mut declarations = Vec::new();
dispatch_recipe_book_categories(&TestHost, &[], &mut declarations);
assert!(declarations.is_empty());
let mods: Vec<Box<dyn Hooks>> = vec![Box::new(Categories)];
dispatch_recipe_book_categories(&TestHost, &mods, &mut declarations);
assert_eq!(declarations.len(), 1);
assert_eq!(declarations[0].id, "mymod:crushing");
assert_eq!(declarations[0].recipe_type, "mymod:crusher");
assert_eq!(declarations[0].icon, "minecraft:stone");
}
+68
View File
@@ -396,6 +396,74 @@ impl Transport for Http {
}
}
/// Item files in a folder instead of the service (`HORIZON_COSMETICS_LOCAL`): every player asked
/// for wears the first item of each slot found there, by file name. For artists and the bench.
pub struct Local {
dir: PathBuf,
}
impl Local {
pub fn new(dir: PathBuf) -> Self {
Self { dir }
}
/// The first item file of each slot, by file name.
fn worn(&self) -> Vec<String> {
let mut files: Vec<PathBuf> = std::fs::read_dir(&self.dir)
.into_iter()
.flatten()
.flatten()
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|e| e == "json"))
.collect();
files.sort();
let mut slots = Vec::new();
let mut ids = Vec::new();
for file in files {
if let Some(item) = std::fs::read(&file).ok().and_then(|b| Item::parse(&b).ok())
&& !slots.contains(&item.slot)
&& file.file_stem().is_some_and(|s| *s == *item.id)
{
slots.push(item.slot);
ids.push(item.id);
}
}
ids
}
}
impl Transport for Local {
fn post(&self, _: &str, body: &[u8]) -> Result<Vec<u8>, String> {
#[derive(serde::Deserialize)]
struct Asked {
players: Vec<String>,
}
let asked: Asked = serde_json::from_slice(body).map_err(|e| e.to_string())?;
let refs: Vec<_> = self
.worn()
.into_iter()
.map(|id| serde_json::json!({"id": id, "hash": "local"}))
.collect();
let players: serde_json::Map<String, serde_json::Value> = asked
.players
.into_iter()
.map(|p| (p, serde_json::Value::Array(refs.clone())))
.collect();
Ok(serde_json::json!({"ttl": 60, "players": players})
.to_string()
.into_bytes())
}
fn get(&self, path: &str) -> Result<Vec<u8>, String> {
let id = path
.trim_start_matches("/v1/items/")
.split('?')
.next()
.unwrap_or_default();
std::fs::read(self.dir.join(format!("{id}.json"))).map_err(|e| e.to_string())
}
}
#[cfg(test)]
#[path = "fetch_tests.rs"]
mod tests;
+18 -1
View File
@@ -11,9 +11,26 @@ mod fetch;
mod item;
mod pose;
pub use fetch::{BATCH, Clock, Cosmetics, Frame, Http, Transport, Worn};
pub use fetch::{BATCH, Clock, Cosmetics, Frame, Http, Local, Transport, Worn};
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};
/// Cosmetics stay off until this is true: no request, no drawing, no settings.
pub const ON_BY_DEFAULT: bool = false;
/// The cosmetics service (`HORIZON_COSMETICS_API` overrides it).
pub const API: &str = "https://cosmetics.playhorizon.dev";
/// Whether cosmetics run: `ON_BY_DEFAULT`, or `HORIZON_COSMETICS=1` for one run (development,
/// the bench). `HORIZON_COSMETICS=0` keeps them off.
pub fn enabled() -> bool {
static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
*ON.get_or_init(|| match std::env::var("HORIZON_COSMETICS").as_deref() {
Ok("1") => true,
Ok("0") => false,
_ => ON_BY_DEFAULT,
})
}
+99 -1
View File
@@ -362,6 +362,14 @@ fn registry_order(root: &Path, order: &[Arc<str>]) -> Vec<Arc<str>> {
}
/// Item ids standing for a loot table's `minecraft:dynamic` entries.
const DYNAMIC: &str = "#dynamic:";
/// The item id standing for a chosen `minecraft:loot_table` entry, whose
/// table then rolls in full (`NestedLootTable`).
const NESTED: &str = "#loot_table";
thread_local! {
/// The nested tables rolling now, outermost first: a table never rolls
/// inside itself (`LootContext.pushVisitedElement`).
static NESTED_ROLLS: std::cell::RefCell<Vec<String>> = const { std::cell::RefCell::new(Vec::new()) };
}
pub struct Items {
pub items: HashMap<Arc<str>, ItemInfo>,
pub order: Vec<Arc<str>>,
@@ -1231,7 +1239,11 @@ impl Items {
explosion_radius: None,
this_entity: false,
};
ctx.evaluate(table, random)
// LootTable.getRandomItemsRaw visits the table itself first.
NESTED_ROLLS.with(|v| v.borrow_mut().push(id.to_owned()));
let out = ctx.evaluate(table, random);
NESTED_ROLLS.with(|v| v.borrow_mut().pop());
out
}
/// The server random sequence a loot table draws from (`random_sequence`):
/// vanilla's `LootContext.Builder.create` uses it instead of the level random.
@@ -1646,6 +1658,21 @@ impl LootContext<'_> {
));
true
}
// NestedLootTable: chosen by its weight like an item; `choose`
// then rolls its table (an id or inline) and applies its functions.
"minecraft:loot_table" => {
out.push((
e["weight"].as_i64().unwrap_or(1) as i32,
ItemStack {
item: Arc::from(NESTED),
count: 1,
damage: 0,
extra: None,
},
e.clone(),
));
true
}
"minecraft:empty" => {
out.push((
e["weight"].as_i64().unwrap_or(1) as i32,
@@ -1691,9 +1718,46 @@ impl LootContext<'_> {
chosen
};
let (_, mut stack, modifiers) = candidates.swap_remove(pick);
if &*stack.item == NESTED {
self.nested(&modifiers, random, produced);
return;
}
self.modifiers(&modifiers, &mut stack, random);
produced.push(stack);
}
/// `NestedLootTable.createItemStack`: the entry's table rolls in full
/// (its pools and its own functions), then the entry's functions apply to
/// each stack. A table already rolling further out adds nothing.
fn nested<R: RandomSource>(
&self,
entry: &Value,
random: &mut R,
produced: &mut Vec<ItemStack>,
) {
let (name, table) = match &entry["value"] {
Value::String(name) => (name.as_str(), self.items.loot.get(name.as_str())),
inline @ Value::Object(_) => ("<inline>", Some(inline)),
_ => return,
};
let Some(table) = table else { return };
let entered = NESTED_ROLLS.with(|v| {
let mut v = v.borrow_mut();
let fresh = !v.iter().any(|n| n == name);
if fresh {
v.push(name.to_owned());
}
fresh
});
if !entered {
return;
}
let stacks = self.evaluate(table, random);
NESTED_ROLLS.with(|v| v.borrow_mut().pop());
for mut stack in stacks {
self.modifiers(&entry["modifier"], &mut stack, random);
produced.push(stack);
}
}
/// An enchantment function's `options` (`HolderSet`): a tag, a list or
/// one id; every enchantment when absent (`registryAccess().listElements`).
fn enchant_pool(e: &crate::enchant::Enchantments, options: &Value) -> Vec<Arc<str>> {
@@ -2206,6 +2270,40 @@ mod tests {
assert!(!items.item_in_tag("test:missing", "test:apple"));
}
/// `NestedLootTable`: a `loot_table` entry, by id or inline, rolls that
/// table in full and applies its own functions to each stack; a table
/// never rolls inside itself.
#[test]
fn loot_table_entries_roll_their_table() {
let mut items = Items::for_tests(HashMap::new());
let diamond = json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:diamond"}]}]});
items.loot = Arc::new(
[
("test:inner", diamond.clone()),
("test:outer", json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:loot_table",
"value":"test:inner","modifier":[{"type":"minecraft:set_count","count":3}]}]}]})),
("test:inline", json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:loot_table",
"value":diamond}]}]})),
("test:loop", json!({"pools":[{"rolls":1,"entries":[{"type":"minecraft:loot_table",
"value":"test:loop"}]},{"rolls":1,"entries":[{"type":"minecraft:item","name":"minecraft:stick"}]}]})),
]
.map(|(id, t)| (id.to_owned(), t))
.into_iter()
.collect(),
);
let roll = |id: &str| {
items
.loot_table(id, false, None, &mut LegacyRandom::new(1))
.into_iter()
.map(|s| format!("{} {}", s.item, s.count))
.collect::<Vec<_>>()
};
assert_eq!(roll("test:outer"), ["minecraft:diamond 3"]);
assert_eq!(roll("test:inline"), ["minecraft:diamond 1"]);
// `test:loop` is already rolling: its own entry adds nothing.
assert_eq!(roll("test:loop"), ["minecraft:stick 1"]);
}
/// `Blocks.wallVariant`: a wall block without a table of its own drops
/// from its standing block's (vanilla's tables: `blocks/torch` for
/// `wall_torch`, `blocks/oak_sign` for `oak_wall_sign`...).
+101 -14
View File
@@ -117,12 +117,56 @@ impl RandomTicks {
}
}
/// Per-state outline and collision boxes, selected by one state property.
/// Boxes selected by one property (legacy) or an ordered tuple of properties.
#[derive(Clone, Debug, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct BlockShapes {
#[serde(default)]
pub property: String,
#[serde(default)]
pub properties: Vec<String>,
#[serde(default)]
pub values: BTreeMap<String, Vec<[f64; 6]>>,
pub default: Option<Vec<[f64; 6]>>,
}
impl BlockShapes {
pub fn property_names(&self) -> Vec<&str> {
if self.property.is_empty() {
self.properties.iter().map(String::as_str).collect()
} else {
vec![self.property.as_str()]
}
}
fn check(&self) -> Result<(), String> {
let names = self.property_names();
if (!self.property.is_empty() && !self.properties.is_empty())
|| names.iter().any(|name| !state_name(name))
|| names.iter().collect::<BTreeSet<_>>().len() != names.len()
|| (names.is_empty() && !self.values.is_empty())
|| (self.values.is_empty() && self.default.is_none())
{
return Err("shapes needs distinct properties and values, or a default".into());
}
for key in self.values.keys() {
let values: Vec<_> = key.split(',').collect();
if values.len() != names.len() || values.iter().any(|value| !state_name(value)) {
return Err(format!("invalid shape key {key:?}: one value per property"));
}
}
for boxes in self.values.values().chain(self.default.iter()) {
if boxes.len() > 16
|| boxes.iter().any(|bounds| {
bounds.iter().any(|value| !value.is_finite())
|| (0..3).any(|axis| bounds[axis] > bounds[axis + 3])
})
{
return Err("shape needs at most 16 finite boxes with ordered bounds".into());
}
}
Ok(())
}
}
/// A state property a block declares on its own, beside its template's.
@@ -163,6 +207,7 @@ struct BlockFile {
#[serde(default)]
properties: BlockProperties,
shapes: Option<BlockShapes>,
collision_shapes: Option<BlockShapes>,
#[serde(default)]
state_properties: BTreeMap<String, StateProperty>,
#[serde(default)]
@@ -196,6 +241,7 @@ pub struct Block {
pub template: String,
pub properties: BlockProperties,
pub shapes: Option<BlockShapes>,
pub collision_shapes: Option<BlockShapes>,
/// State properties of its own, which its template does not have.
pub state_properties: BTreeMap<String, StateProperty>,
/// State properties overlaid on the template default.
@@ -673,6 +719,7 @@ impl Content {
template: file.template,
properties: file.properties,
shapes: file.shapes,
collision_shapes: file.collision_shapes,
state_properties: file.state_properties,
default_state: file.default_state,
item: file.item,
@@ -856,19 +903,8 @@ fn check_block(file: &Block) -> Result<(), String> {
file.template
));
}
if let Some(shapes) = &file.shapes {
if shapes.property.is_empty() || shapes.values.is_empty() {
return Err("shapes needs a property and values".into());
}
for boxes in shapes.values.values() {
if boxes.len() > 16
|| boxes
.iter()
.any(|b| b.iter().any(|n| !n.is_finite()) || (0..3).any(|i| b[i] > b[i + 3]))
{
return Err("shape needs at most 16 finite boxes with ordered bounds".into());
}
}
for shapes in file.shapes.iter().chain(file.collision_shapes.iter()) {
shapes.check()?;
}
for (name, property) in &file.state_properties {
if !state_name(name) {
@@ -1096,6 +1132,56 @@ capabilities = ["registries"]
std::fs::create_dir_all(&dir).unwrap();
dir
}
#[test]
fn shape_tables_validate_tuples_defaults_and_collision() {
let content = hmod("shape-tuples", MANIFEST_TEXT, &[(
"data/ruby/horizon/block/a.json",
br#"{"template":"minecraft:stone","shapes":{"properties":["facing","count"],"values":{"north,2":[[0,0,0,1,0.5,1]]},"default":[[0,0,0,1,1,1]]},"collision_shapes":{"default":[]}}"#,
)]).unwrap();
let block = &content.blocks[0];
assert_eq!(
block.shapes.as_ref().unwrap().property_names(),
["facing", "count"]
);
assert_eq!(
block.collision_shapes.as_ref().unwrap().default,
Some(vec![])
);
for value in [
r#"{"property":"axis","properties":["count"],"default":[]}"#,
r#"{"properties":["axis","axis"],"default":[]}"#,
r#"{"properties":["Axis"],"default":[]}"#,
r#"{"properties":["axis","count"],"values":{"x":[]}}"#,
r#"{"properties":["axis"],"values":{"":[]}}"#,
r#"{"values":{"x":[]},"default":[]}"#,
r#"{"default":[[1,0,0,0,1,1]]}"#,
] {
let shapes: BlockShapes = serde_json::from_str(value).unwrap();
assert!(shapes.check().is_err(), "{value}");
let declaration =
format!(r#"{{"template":"minecraft:stone","collision_shapes":{value}}}"#);
assert!(
hmod(
"bad-collision",
MANIFEST_TEXT,
&[("data/ruby/horizon/block/a.json", declaration.as_bytes(),)]
)
.is_err(),
"{value}"
);
}
let oversized = BlockShapes {
property: String::new(),
properties: vec![],
values: BTreeMap::new(),
default: Some(vec![[0.0, 0.0, 0.0, 1.0, 1.0, 1.0]; 17]),
};
assert!(oversized.check().is_err());
let mut nonfinite = oversized;
nonfinite.default = Some(vec![[f64::NAN; 6]]);
assert!(nonfinite.check().is_err());
}
fn hmod(name: &str, manifest: &str, files: &[(&str, &[u8])]) -> Result<Content, String> {
let dir = scratch(name);
let path = dir.join("m.hmod");
@@ -1201,6 +1287,7 @@ capabilities = ["registries"]
id: "ruby:ruby_block".into(),
template: "minecraft:iron_block".into(),
shapes: None,
collision_shapes: None,
state_properties: BTreeMap::new(),
default_state: BTreeMap::new(),
properties: BlockProperties {
+18
View File
@@ -76,3 +76,21 @@ does the asking.
hash (they never change for one); a file past the limits is left out and not asked for again.
- **Offline**: a request that fails leaves every answer as it was, and the next one waits 15 s, then
30 s, doubling up to 10 minutes. A player whose item didn't arrive is asked for again after 15 s.
## In the game
- **The flag**: `horizon_cosmetics::ON_BY_DEFAULT` is `false`. Until it is turned on nothing runs:
no thread, no request, no setting, no drawing. `HORIZON_COSMETICS=1` turns them on for one run
(development, the bench); `HORIZON_COSMETICS_API=<url>` points the game at another service, and
`HORIZON_COSMETICS_LOCAL=<folder>` reads items from a folder instead: every player and every
mannequin wears its first item of each slot (the art and the bench, without the network).
- **Settings** (Extras, under "Cosmetics", shown only with the flag on): *My cosmetics* and *Other
players' cosmetics*, both on. Off, the game asks for nothing about those players.
- **Drawing** (`game/cosmetics.rs`): an item's texture takes one of the skin atlas's 64×64 slots
and its boxes are posed on the wearer's bone each frame, through the same GPU skin path as the
player model (the CPU path where that is off), lit and culled with the player. A helmet hides a
head item, an elytra or cape a back item. The third person view and the
inventory's player preview show your own.
- **Cost**: a player with nothing to wear adds one cache lookup; with no cosmetic in view the frame
does no cosmetics work beyond that. The bench scene `stress_avatars` (300 mannequins, 20×15)
measures the cost with `HORIZON_COSMETICS=1 HORIZON_COSMETICS_LOCAL=…` against the same run off.
+1
View File
@@ -19,6 +19,7 @@ again restores vanilla's behaviour completely.
| Mouse Tweaks-style controls, sort buttons | `crates/horizon-client/src/game/tweaks.rs` |
| Sort order and its clicks | `crates/horizon-client/src/inv_sort.rs` (pure, unit tested) |
| The music page's rows, and the pools it filters | `options_ui.rs` (`Page::Music`), `crates/horizon-client/src/music.rs` (`track_groups`, `MusicManager::set_off`), `sound_events.rs` (`sound_except`) |
| Cosmetics (show mine, show others'), off with their flag | `extras.rs`, `crates/horizon-client/src/game/cosmetics.rs` ([cosmetics.md](cosmetics.md)) |
| The settings page | `options_ui.rs` (`Page::Extras`): **Options → Horizon Settings... → Extras...** |
The settings are grouped by extra, under a header each, two buttons to a row as
+32
View File
@@ -155,6 +155,38 @@ signs `template_hanging_sign_rot_0` to `_rot_3`, `template_attached_hanging_sign
`minecraft:wall_hanging_signs`, which vanilla's other sign tags and `minecraft:mineable/axe` include. Give the
wall form a loot table of its own, or name it `<x>_wall_sign` so that it drops what `<x>_sign`'s table does.
A block can override its targeting outline with `shapes` and independently override movement
collision with `collision_shapes`. Both take the same table format. Boxes are
`[minX,minY,minZ,maxX,maxY,maxZ]` in block units (one block is 1), at most 16 per shape.
Coordinates must be finite and each minimum must not exceed its maximum. `[]` is an empty shape.
```json
{
"template": "minecraft:stone",
"state_properties": {
"facing": { "type": "enum", "values": ["north", "south"] },
"count": { "type": "enum", "values": ["1", "2"] }
},
"shapes": {
"properties": ["facing", "count"],
"values": { "north,2": [[0,0,0,1,0.5,1]] },
"default": [[0,0,0,1,1,1]]
},
"collision_shapes": { "default": [] }
}
```
`properties` names an ordered tuple of template and/or declared state properties; each `values` key
lists their values in that order, separated by commas without spaces. A nonmatching tuple uses
`default`. Without `default`, every tuple must be covered. A default-only table gives every state the
same shape. The existing `{"property":"axis","values":{"x":[...],"y":[...],"z":[...]}}`
format still works; use either `property` or `properties`, not both. Unknown properties, unreachable
value tuples, malformed boxes and duplicate property names reject the mod with a load error rather
than silently ignoring a typo. Omitted `shapes` preserves the template outline (or the connecting
block's `thickness` outline). Omitted `collision_shapes` preserves the existing behavior: a declared
outline also becomes collision, otherwise the template collision is retained. An explicit collision
table can be used without an outline override, and overrides `thickness` collision too.
An item copies its template's data components, with `max_stack_size` and
`rarity` changed, and any other 26.3 component set or removed in
`components`, written as a data component patch is: `minecraft:<type>` sets
+46
View File
@@ -380,6 +380,14 @@ so the third warning names a `hook`, and `hook = "frame"` and `target = "…::na
`HEAD` is named: the interior kinds may carry `cancellable` too, but what a `Break` skips there is less than the body.
A mod's `priority` defaults to 0, as horizon-mixins defaults it. Neither warning stops the build.
### A mod's own ingredient kinds, matching at craft time
The load-time list only names items. Matching a concrete stack — its components, usually —
is `native_ingredient_test(kind, data, stack, accept)`: the ingredient's `test` calls it with the
kind and JSON the load-time hook saw, the stack being matched and the verdict, already `true`
(the list named the item). Set `accept` to `false` to refuse the stack (`ICustomIngredient.test`).
The hook may be called many times a craft; the answer must be the same for the same stack.
### The mod's crate
A `[[crate]]` is a Cargo package of the bundle's, staged as `native-crates/<mod>/<crate>/` and added to the
@@ -486,6 +494,44 @@ position), `block_entity` (the broken block's entity as it was before the break,
vanilla's `time_check` and `weather_check`). The game sets it for a block a player or an explosion breaks and for a
mob's death loot; on other rolls `place` is `None`.
### Recipe book category declarations (first slice of #5501)
The pending `recipe_book_categories` API fragment adds a declaration hook and a
lookup helper. It has no API number until stamping; do not require a guessed
hooks version. Trait mods import `RecipeBookCategory` from `horizon_client::mod_api`
and append declarations in `Hooks::recipe_book_categories`:
```rust,ignore
event.categories.push(RecipeBookCategory {
id: "mymod:crushing".into(),
recipe_type: "mymod:crusher".into(),
icon: "mymod:crusher".into(),
});
```
Mixin bundles use `native_recipe_book_categories(categories)` at the same hook
point. `host.recipe_book_category("mymod:crusher")` (the mixin helper is
`native_recipe_book_category`) returns the validated declaration, including its
icon, or `None`. It does not grant recipes. Declarations are requested lazily at
first lookup, cached for that recipe table, and invalidated on a new session,
server recipe-content replacement or server `/reload`. No lookup means no hook
call or per-frame work. A client validates against its own custom recipes unless
`native_datapack_sync` has supplied the server's type (API 120).
Every field must be a fully namespaced resource id. Category ids and recipe types
must be outside `minecraft:`; the type must occur in the current custom recipe
table. The icon must name a registered, non-air item. Invalid ids, unknown types,
missing/unknown icons, and duplicate category ids or recipe types are logged and
skipped, never panicked on. The first valid declaration wins each id and type;
an invalid declaration does not reserve either. This slice supports one category
per type, not per-recipe category selectors.
**Not yet book UI support:** custom recipes still have no book displays or
unlock/display-id synchronization. This slice deliberately leaves vanilla tabs,
search, layouts and unlock behavior unchanged. Adding custom tabs, displaying and
searching recipes there, and unlocking them like vanilla remain follow-up work
for #5501; a declaration alone will not make a recipe visible.
### A mod's own ingredient kinds
A recipe's ingredient that names a type in a namespace outside `minecraft:` reaches
+2 -1
View File
@@ -354,6 +354,7 @@ class StressSceneTests(unittest.TestCase):
self.assertEqual(summons("stress_markings", "horse") + summons("stress_markings", "slime"), 1000)
self.assertEqual(summons("stress_frames", "item_frame"), 512)
self.assertEqual(summons("stress_armor_stands", "armor_stand"), 500)
self.assertEqual(summons("stress_avatars", "mannequin"), 300)
self.assertEqual(stress_scenes.setup("stress_empty"), stress_scenes.SETTINGS + stress_scenes.floor(
*stress_scenes.origin("stress_empty")))
# Only the run's first stress scene sets the game rules.
@@ -363,7 +364,7 @@ class StressSceneTests(unittest.TestCase):
def test_every_entity_is_near_enough_to_be_drawn_at_rd16(self):
# Entity.shouldRenderAtSqrDistance with viewScale 2: a dropped item (0.25) within 32 blocks.
from tools import stress_scenes
for name in ("stress_mobs", "stress_items", "stress_frames", "stress_armor_stands"):
for name in ("stress_mobs", "stress_items", "stress_frames", "stress_armor_stands", "stress_avatars"):
_, at, _, _ = stress_scenes.scene(name)
eye = (at[0], at[1] + 1.62, at[2])
for line in stress_scenes.setup(name):
+51
View File
@@ -801,6 +801,9 @@ def items_and_loot():
cmd('/loot replace block 3 206 3 container.8 loot minecraft:blocks/cobblestone'),
cmd('/loot replace entity @s hotbar.5 loot minecraft:blocks/glass'),
cmd('/loot spawn 3 210 3 loot minecraft:blocks/stone'),
# NestedLootTable: the reward tables pick another table and roll it.
cmd('/loot spawn 3 210 3 loot minecraft:chests/trial_chambers/reward'),
cmd('/loot give @s loot minecraft:chests/trial_chambers/reward_ominous'),
cmd('/loot give @s loot {pools:[{rolls:1,entries:[{type:"minecraft:item",name:"minecraft:apple",functions:[{type:"minecraft:set_count",count:4}]}]}]}'),
cmd('/setblock 2 205 2 minecraft:stone'),
cmd('/loot give @s mine 2 205 2'),
@@ -1746,6 +1749,52 @@ def trial_spawner_passengers():
return ops + setup('/fill 1 219 1 13 224 8 air', '/gamerule spawn_mobs false')
def bossbar_list_order():
"""CustomBossEvents lists Java HashMap values, with no sorting (#7469)."""
ops = [
cmd('/bossbar add audit:test "Title"'),
cmd('/bossbar set audit:test name "Other"'),
cmd('/bossbar add audit:second "Second"'),
cmd('/bossbar list'),
]
for i in range(11):
ops.append(cmd(f'/bossbar add audit:grow{i} "Extra {i}"'))
for i in range(11):
ops.append(cmd(f'/bossbar remove audit:grow{i}'))
ops += [
cmd('/bossbar list'),
cmd('/bossbar remove audit:test'),
cmd('/bossbar list'),
cmd('/bossbar add audit:test "Again"'),
cmd('/bossbar list'),
cmd('/bossbar remove audit:test'),
cmd('/bossbar remove audit:second'),
cmd('/bossbar list'),
]
return ops
def experience_level_boundaries():
"""Exact zero retains progress/total; a negative result resets all three (#7515)."""
def observe():
return [cmd('/experience query @s levels'),
cmd('/experience query @s points'),
cmd('/data get entity @s XpLevel'),
cmd('/data get entity @s XpP 1000000'),
cmd('/data get entity @s XpTotal')]
ops = []
for start, delta in [(1, -1), (1, -2), (2, -1), (0, -1)]:
ops += setup('/experience set @s 0 levels', '/experience set @s 0 points',
'/experience add @s -2147483647 points', '/experience add @s 10 points',
'/experience set @s 1 levels', '/experience set @s 3 points',
f'/experience set @s {start} levels')
ops += observe()
ops.append(cmd(f'/experience add @s {delta} levels'))
ops += observe()
return ops + setup('/experience set @s 0 levels', '/experience set @s 0 points',
'/experience add @s -2147483647 points')
def mob_max_health_bounds():
"""Raw bases survive effective clamping without changing Health (#7413)."""
pig = '00000000-0000-0000-0000-000000000001'
@@ -1818,6 +1867,7 @@ def groups():
('client commands', client_commands()),
('game state', game_state()),
('player state', player_state()),
('experience level boundaries', experience_level_boundaries()),
('blocks and border', world_blocks()),
('forceload query order', forceload_query_order()),
('entities', entities()),
@@ -1829,6 +1879,7 @@ def groups():
('trial spawner passengers', trial_spawner_passengers()),
('camel riders', camel_riders()),
('scores, teams and bossbars', scores()),
('bossbar list order', bossbar_list_order()),
('execute', execute()),
('levels', levels()),
('end portal entities', end_portal_entities()),
@@ -0,0 +1,6 @@
{
"hooks": [{"name": "ingredient_test", "target": "horizon_client_lib::crafting::native_ingredient_test", "mod_api": {"contract": "listen"}}],
"rust_summary": "a mod's own ingredient kind matching a concrete stack\n(`native_ingredient_test`, which the ingredient's test calls with the kind,\nits JSON, the stack and the verdict — the load-time list already named\nthe item).",
"doc_summary": "a mod's own ingredient kind matching a concrete stack at craft time. The ingredient's test calls native_ingredient_test(kind, data, stack, accept) with the kind and JSON the load-time hook saw, the stack being matched and the verdict, already true (the load-time list named the item). Set accept to false to refuse the stack - its components are the usual reason (ICustomIngredient.test). The hook may be called many times a craft; the answer must be the same for the same stack. Bundles that use it need \"hooks\": @API:ingredient_test@.",
"comments": ["crates/horizon-client/src/crafting.rs"]
}
@@ -0,0 +1,18 @@
{
"hooks": [
{
"name": "recipe_book_categories",
"target": "horizon_client::game::native_hooks::Game<'_>::native_recipe_book_categories",
"mod_api": { "contract": "listen" }
}
],
"helpers": [
{
"name": "recipe_book_category",
"target": "horizon_client::game::native_hooks::Game<'_>::native_recipe_book_category",
"mod_api": {}
}
],
"rust_summary": "validated category declarations for custom recipe types (`native_recipe_book_categories`, `native_recipe_book_category`); display and unlock integration follows separately.",
"doc_summary": "Declare a category id, custom recipe type and icon item with `native_recipe_book_categories`; read the validated mapping with `native_recipe_book_category`. This first slice does not add book tabs, displays or unlock synchronization. Bundles need hooks version @API:recipe_book_categories@."
}
+1 -1
View File
@@ -71,7 +71,7 @@ ClientDebugSubscriber.ValueMap.forEach skip:java iteration helper
ClientDebugSubscriber.ValueMaps.purgeExpired missing minor: expiry of server debug values; none are received
ClientDebugSubscriber.ValueMaps.dropChunkAndBlocks missing minor: drops server debug values per chunk; none are received
ClientDebugSubscriber.ValueWrapper.hasExpired skip:java timestamp check on a debug value wrapper
ClientExplosionTracker.track inline crates/horizon-client-render/src/particles.rs::ParticleEngine::explosion gap: minor: the debris is thrown at once instead of queued for later ticks
ClientExplosionTracker.track port crates/horizon-client-render/src/particles.rs::ParticleEngine::track_explosion gap: minor: queues explosions for debris generation during ClientExplosionTracker.tick
ClientExplosionTracker.addParticle port crates/horizon-client-render/src/particles.rs::ParticleEngine::explosion_blocks gap: minor: always the default poof and smoke list; the explode packet's own block particle list is not read
ClientHandshakePacketListenerImpl.switchState inline crates/horizon-client/src/net.rs::run login steps run in packet order in run(); no state machine
ClientHandshakePacketListenerImpl.handleHello port crates/horizon-client/src/net.rs::run gap: minor: the session join and server check are in auth.rs (join_server); the encryption setup is in run
1 AccountProfileKeyPairManager.prepareKeyPair port crates/horizon-client-render/src/auth.rs::prepare_chat_keys on a background thread, at start and on each online-mode join
71 ClientDebugSubscriber.ValueMaps.purgeExpired missing minor: expiry of server debug values; none are received
72 ClientDebugSubscriber.ValueMaps.dropChunkAndBlocks missing minor: drops server debug values per chunk; none are received
73 ClientDebugSubscriber.ValueWrapper.hasExpired skip:java timestamp check on a debug value wrapper
74 ClientExplosionTracker.track inline port crates/horizon-client-render/src/particles.rs::ParticleEngine::explosion crates/horizon-client-render/src/particles.rs::ParticleEngine::track_explosion gap: minor: the debris is thrown at once instead of queued for later ticks gap: minor: queues explosions for debris generation during ClientExplosionTracker.tick
75 ClientExplosionTracker.addParticle port crates/horizon-client-render/src/particles.rs::ParticleEngine::explosion_blocks gap: minor: always the default poof and smoke list; the explode packet's own block particle list is not read
76 ClientHandshakePacketListenerImpl.switchState inline crates/horizon-client/src/net.rs::run login steps run in packet order in run(); no state machine
77 ClientHandshakePacketListenerImpl.handleHello port crates/horizon-client/src/net.rs::run gap: minor: the session join and server check are in auth.rs (join_server); the encryption setup is in run
+16
View File
@@ -137,3 +137,19 @@ ENTITIES = [
('minecart', 'Passengers:[{id:"minecraft:cow",UUID:[I;28673,0,0,4],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b}]'),
('horse', 'Tame:1b,Passengers:[{id:"minecraft:skeleton",UUID:[I;28673,0,0,5],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b,Passengers:[{id:"minecraft:bat",UUID:[I;28673,0,0,6],NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b}]}]'),
]
# Leads. A leashed mob saves `leash` as [I;x,y,z] (a fence knot at that block) or
# {UUID:[I;..]} (a holder entity); vanilla restores the holder on the first tick after
# loading and drops the lead if the holder is not found, so the knot and the holder
# are summoned first, with fixed UUIDs, and within a few blocks of what they hold.
# SETBLOCKS run before the entities.
SETBLOCKS = ['setblock 40 -60 40 minecraft:oak_fence']
_NOAI = 'NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b'
_first = len(ENTITIES)
ENTITIES += [
('leash_knot', 'block_pos:[I;40,-60,40],Pos:[40.5d,-59.5d,40.5d]'),
('cow', 'Pos:[42.5d,-60.0d,40.5d],leash:[I;40,-60,40]'),
('pig', 'Pos:[44.5d,-60.0d,44.5d]'),
('oak_boat', f'Pos:[44.5d,-60.0d,42.5d],leash:{{UUID:[I;28672,0,0,{_first + 2}]}}'),
]
LEADS = 2 # the leashed mobs above that vanilla must still have leashed when it saves
+9
View File
@@ -179,6 +179,14 @@ def armor_stands(ox, oz):
for row in range(20) for col in range(25)]
def avatars(ox, oz):
"""300 mannequins, the player model (20 x 15, 1.5 blocks apart): what cosmetics cost on many players
(docs/cosmetics.md: `HORIZON_COSMETICS=1 HORIZON_COSMETICS_LOCAL=<items>` dresses every mannequin)."""
return [f"summon mannequin {num(ox + 0.5 - 14.25 + 1.5 * col)} {FLOOR_Y + 1} {num(oz + 6 + 1.5 * row)} "
"{NoGravity:1b,Silent:1b,Rotation:[180f,0f]}"
for row in range(15) for col in range(20)]
def chests(ox, oz):
"""800 containers with block-entity models: four walls 25 wide and 8 high, one kind each."""
return [f"fill {ox - 12} {FLOOR_Y + 1} {oz + 10 + 4 * i} {ox + 12} {FLOOR_Y + 8} {oz + 10 + 4 * i} {kind}"
@@ -239,6 +247,7 @@ STRESS = [
("stress_held", held),
("stress_eyes", eyes),
("stress_glowing", glowing),
("stress_avatars", avatars),
("stress_markings", markings),
]
NAMES = [name for name, _ in STRESS]
+14 -1
View File
@@ -193,11 +193,16 @@ def entity_uuid(i):
def entity_command(i, kind, extra):
x = 3 + 3 * (i % 10)
z = 36 + 3 * (i // 10)
# `summon` places the entity at the command's coordinates, not at NBT Pos, so a
# scene entry that has to stand somewhere (next to a fence) names it in Pos.
at = re.search(r'Pos:\[(-?[0-9.]+)d,(-?[0-9.]+)d,(-?[0-9.]+)d\]', extra)
if at:
x, z = at.group(1), at.group(3)
nbt = (f'UUID:{entity_uuid(i)},NoAI:1b,Silent:1b,NoGravity:1b,Invulnerable:1b,Tags:["rt"],'
f'CustomName:{{text:"E{i}"}}' + (',' + extra if extra else ''))
if kind in ('item_frame', 'glow_item_frame', 'painting'):
nbt += f',block_pos:[I;{x},-60,{z}],Pos:[{x}.5d,-60.0d,{z}.5d]'
return f'summon minecraft:{kind} {x} -60 {z} {{{nbt}}}'
return f'summon minecraft:{kind} {x} {at.group(2) if at else -60} {z} {{{nbt}}}'
def block_position(i):
@@ -275,6 +280,10 @@ def build_world(directory):
out = server.run(command)
if command_failed(out):
failed.append((command, out))
for command in scene.SETBLOCKS:
out = server.run(command)
if command_failed(out):
failed.append((command, out))
for i, (kind, extra) in enumerate(scene.ENTITIES):
command = entity_command(i, kind, extra)
out = server.run(command)
@@ -536,6 +545,10 @@ def compare_entities(vanilla, horizon, report, chunks=None):
out[uuid] = e
return out
a, b = collect(vanilla), collect(horizon)
# Vanilla drops a lead whose holder it cannot find, which would leave nothing to compare.
leads = sum('leash' in e for e in a.values())
if leads != scene.LEADS:
report.append(('vanilla kept leads in the scene', scene.LEADS, leads))
# Entities Horizon adds in the chunks vanilla never generated (chunk-generation
# spawns) are not part of the scene; anywhere else they are a difference.
vanilla_chunks = {chunk_of(e) for e in a.values()} | set(chunks or ())
+35 -2
View File
@@ -4,8 +4,9 @@ both food and blocks (sweet and glow berries, carrots, potatoes), for sweet
berry bushes, for spawn eggs used on a monster spawner, for shears carving
pumpkins or cropping a growing plant's head (kelp, weeping and twisting vines,
cave vines) to its top age, for copper golem statues turned to their next pose,
for blocks placed where mobs and armor stands stand, for respawn anchors and for
beds in the Nether and the End.
for blocks placed where mobs and armor stands stand, for respawn anchors, for
beds in the Nether and the End, and for dragon eggs, which a click moves somewhere
else nearby (`DragonEggBlock.useWithoutItem`).
Every case has a block at the origin, something above it, and a player three
blocks south, survival or creative, hungry or full, sneaking or not, who
@@ -331,6 +332,32 @@ def jukebox_case(rng, states, case):
'creative': True, 'sneaking': False, 'food': 20}
def dragon_egg_case(rng, states, case):
"""A dragon egg clicked with anything, or an empty hand (`DragonEggBlock.useWithoutItem`, which
takes the click before the item is placed): `teleport` draws 16/8/16-wide offsets up to 1000
times and moves the egg to the first air block inside the build height that has a block under it,
sending level event 2015 with `BlockUtil.packDifferenceInPosition` of the move. A floor gives the
search somewhere to land; in a bare sky only the cell above the egg has a block under it, and a
block in that cell leaves the 1000 draws with nowhere to go and the egg where it was."""
cells = [[0, 0, 0, states.get('dragon_egg')]]
if rng.random() < 0.8:
# A floor for the egg to land on: it only moves to air with a block under it.
cells.append([0, -1, 0, states.get(rng.choice(['stone', 'dirt', 'grass_block', 'netherrack', 'sand']))])
for dx in range(-2, 3):
for dz in range(-2, 3):
if (dx, dz) == (0, 0) or rng.random() < 0.15:
continue
cells.append([dx, -1, dz, states.get(rng.choice(['stone', 'dirt', 'oak_planks', 'glass',
'snow_block', 'gravel']))])
if rng.random() < 0.3:
# Something above it, whose own block below is air, so the search never takes it.
cells.append([0, 1, 0, states.get(rng.choice(['stone', 'oak_slab', 'glass']))])
item = rng.choice(['-', '-', 'stick', 'stone', 'torch', 'dragon_egg'])
case['use_item'] = {'item': '-' if item == '-' else d_plain(item, rng.choice([1, 16])),
'creative': rng.random() < 0.2, 'sneaking': rng.random() < 0.15, 'food': 20}
case['cells'] = cells
# A block placed from an item carrying `minecraft:block_state`: the block and the properties the
# component sets, none of them the ones the click would have chosen. The last two name a property the
# block has no such thing as, and no property at all, which `BlockItemStateProperties.apply` skips.
@@ -423,6 +450,12 @@ def cases(facts, count, seed):
case['mode'] = 'dispense'
yield case
continue
if rng.random() < 0.1:
dragon_egg_case(rng, states, case)
case['items'] = []
case['mode'] = 'dispense'
yield case
continue
if rng.random() < 0.06:
block_state_case(rng, states, case)
case['items'] = []
+1
View File
@@ -1172,6 +1172,7 @@ mod tests {
pub(super) struct LootStack;
pub(super) struct NativeLootContext<'a>(pub &'a ());
pub(super) struct NativeDataMapType;
pub(super) struct RecipeBookCategory;
pub(super) struct NativeMapDecoration;
pub(super) struct NativeMapInfo<'a>(pub &'a ());
pub(super) struct NativePlayerRender<'a>(pub &'a ());