Merge split/fresh-controller-setup (main merged) into split/fresh-servers

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
martipopsandClaude Opus 5.5 committed 2026-11-01 21:12:07 -05:00
commit 1a5c943591
125 files changed
+15133 -962

No files matched your search

+5
View File
@@ -4,3 +4,8 @@ docs/launcher-commands.md text eol=lf
docs/horizon-ctl-commands.md text eol=lf
crates/horizon-theme/tokens.css text eol=lf
docs/api/*-reference.md text eol=lf
# Generated by `cargo run -p xtask -- mod-api`; `--check` compares them byte for byte.
crates/horizon-client/src/mod_api/generated.rs linguist-generated=true text eol=lf
crates/horizon-client/src/mod_api/generated_host.rs linguist-generated=true text eol=lf
crates/horizon-client/src/game/mod_api_host_generated.rs linguist-generated=true text eol=lf
crates/horizon-client-render/src/gfx/native_render/generated.rs linguist-generated=true text eol=lf
Generated
+3
View File
@@ -5120,6 +5120,9 @@ name = "xtask"
version = "0.1.0"
dependencies = [
"horizon-theme",
"quote",
"serde_json",
"syn 2.0.119",
]
[[package]]
+39
View File
@@ -130,6 +130,26 @@ impl ModelLibrary {
pub fn resolve(&self, name: &str) -> Result<ResolvedCuboidModel> {
self.resolve_chain(name, None)
}
/// Whether `name` and every parent it reaches are models of this library,
/// with no cycle: `resolve` puts the missing model in for any other.
pub fn resolves(&self, name: &str) -> bool {
let Ok(mut name) = identifier(name) else {
return false;
};
let mut visited = BTreeSet::new();
loop {
let Some(model) = self.models.get(&name) else {
return false;
};
if !visited.insert(name.clone()) {
return false;
}
match &model.parent {
Some(parent) => name = parent.clone(),
None => return true,
}
}
}
/// `name`'s resolved model with a virtual child in front of it that has
/// only `textures`: the child's slots win, and `#references` anywhere in
/// the chain re-bind through them (so `builtin/generated`'s
@@ -529,4 +549,23 @@ mod tests {
"minecraft:missingno"
);
}
#[test]
fn a_model_resolves_only_with_its_whole_parent_chain() {
let library = library(&[
("item/generated", GENERATED),
(
"item/stick",
r#"{"parent":"item/generated","textures":{"layer0":"item/stick"}}"#,
),
("item/orphan", r#"{"parent":"item/gone"}"#),
("item/a", r#"{"parent":"item/b"}"#),
("item/b", r#"{"parent":"item/a"}"#),
]);
assert!(library.resolves("item/stick"));
assert!(library.resolves("minecraft:item/stick"));
assert!(!library.resolves("item/absent"));
assert!(!library.resolves("item/orphan"));
assert!(!library.resolves("item/a"));
assert!(!library.resolves("Item/Stick"));
}
}
@@ -46,6 +46,7 @@ pub enum Ticking {
#[derive(Default)]
pub struct BlockEntities {
map: HashMap<Pos, BlockEntity>,
attachments: HashMap<Pos, horizon_world::attachments::Attachments>,
/// `Level.blockEntityTickers`: positions and the slot each was given.
tickers: Arc<Vec<(Pos, u32)>>,
/// `LevelChunk.tickersInLevel`: the live slot of each ticking position.
@@ -232,6 +233,34 @@ impl BlockEntities {
self.pending.remove(pos);
}
/// Attachment map for a live block entity.
pub fn attachments_mut(
&mut self,
pos: Pos,
) -> Option<&mut horizon_world::attachments::Attachments> {
if !self.map.contains_key(&pos) && !self.out.contains(&pos) {
return None;
}
Some(self.attachments.entry(pos).or_default())
}
/// Attachment maps in a loaded chunk, for a save snapshot.
pub fn chunk_attachments(
&self,
x: i32,
z: i32,
) -> Vec<(Pos, horizon_world::attachments::Attachments)> {
self.attachments
.iter()
.filter(|(p, _)| p.0 >> 4 == x && p.2 >> 4 == z)
.map(|(p, data)| (*p, data.clone()))
.collect()
}
/// Restore a loaded block entity's attachment map.
pub fn restore_attachments(&mut self, pos: Pos, data: horizon_world::attachments::Attachments) {
if self.map.contains_key(&pos) {
self.attachments.insert(pos, data);
}
}
/// Whether any mod block entity is in the map: until one is, a block change
/// never looks one up (`LevelAccess::has_mod_block_entity`).
pub fn has_mods(&self) -> bool {
@@ -274,8 +303,18 @@ impl BlockEntities {
}
/// `LevelChunk.setBlockEntity` (+ `addAndRegisterBlockEntity`): a new
/// block entity may get a slot; one replacing another keeps its slot.
/// One of the same type rewrites it in place (a jukebox taking a disc,
/// `/data merge block`), where vanilla keeps the object, so its
/// attachments stay; any other replacement starts them over.
pub fn insert(&mut self, pos: Pos, entity: BlockEntity) -> Option<BlockEntity> {
let new_is_mod = matches!(entity, BlockEntity::Mod(_));
let same_type = |old: &BlockEntity| match (old, &entity) {
(BlockEntity::Mod(old), BlockEntity::Mod(new)) => old.id == new.id,
(old, new) => std::mem::discriminant(old) == std::mem::discriminant(new),
};
if !self.map.get(&pos).is_some_and(same_type) {
self.attachments.remove(&pos);
}
let new_box = shows_box(&entity);
let old = self.map.insert(pos, entity);
if old.is_none() {
@@ -314,6 +353,7 @@ impl BlockEntities {
}
/// `LevelChunk.removeBlockEntity`: the block entity and its slot.
pub fn remove(&mut self, pos: &Pos) -> Option<BlockEntity> {
self.attachments.remove(pos);
self.cancel(pos);
let removed = self.map.remove(pos);
if matches!(removed, Some(BlockEntity::Mod(_))) {
@@ -348,11 +388,17 @@ impl BlockEntities {
/// Removes the block entities `keep` refuses, and their slots
/// (`clearAllBlockEntities` of unloaded chunks).
pub fn retain(&mut self, mut keep: impl FnMut(&Pos, &mut BlockEntity) -> bool) {
let (live, pending, freed) = (&mut self.live, &mut self.pending, &mut self.freed);
let (live, pending, freed, attachments) = (
&mut self.live,
&mut self.pending,
&mut self.freed,
&mut self.attachments,
);
let (mods, boxes) = (&mut self.mods, &mut self.boxes);
self.map.retain(|p, e| {
let kept = keep(p, e);
if !kept {
attachments.remove(p);
*freed |= live.remove(p).is_some();
pending.remove(p);
forget_mod(mods, *p);
@@ -362,6 +408,7 @@ impl BlockEntities {
});
}
pub fn clear(&mut self) {
self.attachments.clear();
self.map.clear();
self.mods.clear();
self.boxes.clear();
@@ -381,6 +428,7 @@ impl BlockEntities {
pub fn load_chunk(&mut self, entities: Vec<(Pos, BlockEntity)>) {
let order = chunk_map_order(entities.iter().map(|(p, _)| *p));
for (p, e) in entities {
self.attachments.remove(&p);
let new_is_mod = matches!(e, BlockEntity::Mod(_));
self.note_box(p, Some(&e));
let old = self.map.insert(p, e);
@@ -516,6 +564,52 @@ mod tests {
b.clear();
assert!(near(&b, 200.0, 0.0, 8.0).is_empty());
}
#[test]
fn attachments_follow_block_entity_lifetime() {
use horizon_world::attachments::AttachmentType;
let ty = AttachmentType {
namespace: "test",
name: "value",
default: || 0,
codec: None,
copy_on_death: false,
};
let p = (1, 64, 2);
let mut entities = level();
assert!(entities.attachments_mut(p).is_none());
entities.insert(p, hopper());
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
let held = entities.take(&p).unwrap();
entities.put(p, held);
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
let machine = |id: &str| {
BlockEntity::Mod(Box::new(
crate::chunk::block_entity::ModBlockEntityFields {
id: id.into(),
..Default::default()
}
.into(),
))
};
entities.insert(p, machine("test:a"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
entities.insert(p, machine("test:a"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(4));
entities.insert(p, machine("test:b"));
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 4).unwrap();
entities.remove(&p);
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
entities.attachments_mut(p).unwrap().set(&ty, 5).unwrap();
entities.retain(|_, _| false);
entities.insert(p, hopper());
assert_eq!(entities.attachments_mut(p).unwrap().get(&ty), Ok(0));
}
#[test]
fn slots_follow_vanilla_lifecycle() {
let mut b = level();
@@ -644,6 +644,10 @@ impl RolledLoot {
/// Save data loaded with a chunk that Horizon keeps but does not simulate.
#[derive(Clone, Debug, Default)]
pub struct ChunkExtras {
/// Server-owned mod data attached to this chunk.
pub attachments: horizon_world::attachments::Attachments,
/// Attachment maps of the chunk's block entities, on a save snapshot.
pub block_entity_attachments: Vec<((i32, i32, i32), horizon_world::attachments::Attachments)>,
/// Chunk root keys written back as loaded (structures, PostProcessing,
/// InhabitedTime, blending data, ...).
pub root: Compound,
+206 -19
View File
@@ -4,6 +4,7 @@
pub use horizon_optifine::cit::{Hand, Kind, Rule};
use crate::cit_glint::{Global, StackGlint};
use horizon_client_core::component::name_text;
use horizon_client_core::scoreboard::comp_text;
use horizon_items::item_data::{canon_tag, encode_components};
@@ -16,7 +17,7 @@ use std::collections::HashMap;
use std::fs;
use std::hash::{DefaultHasher, Hash, Hasher};
use std::path::Path;
use std::sync::Mutex;
use std::sync::{Arc, Mutex};
/// Every rule under the root's `optifine/cit/`, `mcpatcher/cit/` and CIT Resewn's
/// `citresewn/cit/` folders, the one chosen first when several match first (`Rule::priority`).
@@ -27,13 +28,8 @@ pub fn load_rules(root: &Path) -> Vec<Rule> {
.into_iter()
.filter(|path| !path.ends_with("/cit.properties"))
.filter_map(|path| {
// Java reads `.properties` as Latin-1: a pack saved that way still loads.
let bytes = fs::read(root.join(&path)).ok()?;
let text = String::from_utf8(bytes)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
let rule = Rule::parse(&path, &Properties::parse_equals_first(&text), |p| {
root.join(p).is_file()
});
let props = read_properties(&root.join(&path))?;
let rule = Rule::parse(&path, &props, |p| root.join(p).is_file());
if rule.is_none() {
log::warn!("custom item textures: {path} is not a rule Horizon can read");
}
@@ -44,10 +40,22 @@ pub fn load_rules(root: &Path) -> Vec<Rule> {
rules
}
/// A `.properties` file as CIT Resewn reads them (`Properties::parse_equals_first`): UTF-8,
/// else Latin-1 as Java reads them, so a pack saved that way still loads. `None` when it can't
/// be read.
pub fn read_properties(path: &Path) -> Option<Properties> {
let bytes = fs::read(path).ok()?;
let text = String::from_utf8(bytes)
.unwrap_or_else(|e| e.into_bytes().iter().map(|&b| char::from(b)).collect());
Some(Properties::parse_equals_first(&text))
}
/// The rules with an index by item and a cache of what each stack matched.
#[derive(Debug, Default)]
pub struct CitRules {
pub rules: Vec<Rule>,
/// The pack's `cit.properties`.
pub global: Global,
/// Per kind (`Kind as usize`), the rules naming each item, in priority order.
by_item: [HashMap<String, Vec<u32>>; 4],
/// Enchantment rules without `items`, in priority order.
@@ -56,6 +64,7 @@ pub struct CitRules {
literals: Vec<Vec<Prepared>>,
selected: Mutex<HashMap<u64, Option<u32>>>,
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
glints: Mutex<HashMap<u64, Arc<StackGlint>>>,
}
#[derive(Debug, Default)]
@@ -70,7 +79,10 @@ const CACHE_LIMIT: usize = 4096;
impl CitRules {
/// The enabled packs' rules under the overlay resource root.
pub fn load(root: &Path) -> Self {
Self::new(load_rules(root))
Self {
global: crate::cit_glint::load_global(root),
..Self::new(load_rules(root))
}
}
/// `rules` in priority order, the winner first.
@@ -106,6 +118,19 @@ impl CitRules {
self.rules.is_empty()
}
/// Leave out the Enchantment rules `keep` refuses (those without a drawable texture): they
/// match no stack, so vanilla's glint and the other rules of their layer stay.
pub fn keep_glints(&mut self, keep: impl Fn(usize) -> bool) {
self.any_item.retain(|&i| keep(i as usize));
let named = &mut self.by_item[Kind::Enchantment as usize];
for list in named.values_mut() {
list.retain(|&i| keep(i as usize));
}
named.retain(|_, list| !list.is_empty());
lock(&self.enchanted).clear();
lock(&self.glints).clear();
}
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into `rules`.
pub fn select(
&self,
@@ -123,13 +148,14 @@ impl CitRules {
let found = candidates
.iter()
.copied()
.find(|&i| self.matches(i, stack, Some(hand), max_damage, comps.as_ref()));
.find(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
remember(&self.selected, key, found);
found.map(|i| i as usize)
}
/// Every matching Enchantment rule, in priority order (layers are the glint PR's business).
pub fn enchantments(&self, stack: &ItemStack, max_damage: u32) -> Vec<usize> {
/// Every matching Enchantment rule for the stack drawn in `hand` (`Hand::Main` for one in a
/// GUI), in priority order.
pub fn enchantments(&self, stack: &ItemStack, hand: Hand, max_damage: u32) -> Vec<usize> {
let named = self.by_item[Kind::Enchantment as usize]
.get(&*stack.item)
.map_or(&[][..], Vec::as_slice);
@@ -138,7 +164,7 @@ impl CitRules {
}
let comps = encode_components(stack);
let key = cache_key(
(1, Kind::Enchantment, Hand::Any),
(1, Kind::Enchantment, hand),
stack,
max_damage,
comps.as_ref(),
@@ -147,24 +173,62 @@ impl CitRules {
let found = hit.unwrap_or_else(|| {
let mut all: Vec<u32> = named.iter().chain(&self.any_item).copied().collect();
all.sort_unstable();
all.retain(|&i| self.matches(i, stack, None, max_damage, comps.as_ref()));
all.retain(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
remember(&self.enchanted, key, all.clone());
all
});
found.into_iter().map(|i| i as usize).collect()
}
/// Whether rule `i` matches; `hand` None skips the hand test (enchantment glints).
/// The glint layers this stack draws in `hand` (`Hand::Main` in a GUI) and whether
/// vanilla's still does (`StackGlint`), worked out once per stack and hand; `None` when the
/// pack changes no glint. `foil` is whether the stack draws a glint at all
/// (`ItemStack::has_foil`): one that doesn't gets no layers.
pub fn glint(
&self,
stack: &ItemStack,
hand: Hand,
max_damage: u32,
foil: bool,
) -> Option<Arc<StackGlint>> {
let no_rules =
self.any_item.is_empty() && self.by_item[Kind::Enchantment as usize].is_empty();
// Before the cache, which `foil` is not part of.
if !foil || (no_rules && self.global.use_glint) {
return None;
}
let comps = encode_components(stack);
let key = cache_key(
(2, Kind::Enchantment, hand),
stack,
max_damage,
comps.as_ref(),
);
if let Some(hit) = lock(&self.glints).get(&key) {
return Some(hit.clone());
}
let matched = self.enchantments(stack, hand, max_damage);
let glint = Arc::new(StackGlint::new(
&self.rules,
&matched,
stack.crafting_enchantments(),
&self.global,
));
remember(&self.glints, key, glint.clone());
Some(glint)
}
/// Whether rule `i` matches the stack drawn in `hand`.
fn matches(
&self,
i: u32,
stack: &ItemStack,
hand: Option<Hand>,
hand: Hand,
max_damage: u32,
comps: Option<&Tag>,
) -> bool {
let rule = &self.rules[i as usize];
if hand.is_some_and(|h| rule.hand != Hand::Any && rule.hand != h) {
if rule.hand != Hand::Any && rule.hand != hand {
return false;
}
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
@@ -214,7 +278,7 @@ fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
cache.insert(key, value);
}
/// What a cached answer depends on; `query` tells `select` from `enchantments`.
/// What a cached answer depends on; `query` tells `select`, `enchantments` and `glint` apart.
fn cache_key(query: (u8, Kind, Hand), stack: &ItemStack, max: u32, comps: Option<&Tag>) -> u64 {
let mut h = DefaultHasher::new();
(query.0, query.1 as u8, query.2 as u8, &*stack.item).hash(&mut h);
@@ -646,7 +710,7 @@ mod tests {
);
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
let glints = |stack: ItemStack| -> Vec<&str> {
let found = cit.enchantments(&stack, 0).into_iter();
let found = cit.enchantments(&stack, Hand::Main, 0).into_iter();
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
};
let sharp = [("sharpness", 1)];
@@ -659,4 +723,127 @@ mod tests {
// Glints are never an item's texture.
assert_eq!(item(&cit, enchanted("diamond_sword", &sharp)), None);
}
/// `CitRules::glint` in the main hand, with the stack's own `has_foil` as callers pass it.
fn glint(cit: &CitRules, stack: &ItemStack, item_glint: bool) -> Option<Arc<StackGlint>> {
cit.glint(stack, Hand::Main, 0, stack.has_foil(item_glint))
}
/// `stack` with its `enchantment_glint_override` set to `on`.
fn overridden(mut stack: ItemStack, on: bool) -> ItemStack {
let flag = (
"minecraft:enchantment_glint_override".to_owned(),
Tag::Byte(on.into()),
);
stack.data_mut().other.push(flag);
stack
}
#[test]
fn a_stack_glint_reads_the_book_and_the_pack_file() {
let mut cit = rules(&[
("sharp", "type=enchantment\nenchantments=sharpness"),
(
"keep",
"type=enchantment\nlayer=1\nuseGlint=true\nenchantments=mending",
),
]);
// An enchanted book's glint follows its stored enchantments.
let mut book = new("enchanted_book");
book.data_mut().stored_enchantments = vec![
(Arc::from("minecraft:sharpness"), 3),
(Arc::from("minecraft:mending"), 1),
];
let book = glint(&cit, &book, true).unwrap();
let weights: Vec<_> = book.layers.iter().map(|l| l.weight).collect();
assert_eq!(weights, [0.75, 0.25]);
assert!(book.vanilla);
let plain = glint(&cit, &enchanted("stick", &[("sharpness", 1)]), false).unwrap();
assert_eq!(plain.layers.len(), 1);
assert!(!plain.vanilla);
// No enchantment rules: nothing changes, unless the pack turns vanilla's glint off.
let mut bare = rules(&[("stick", "items=stick")]);
assert!(glint(&bare, &overridden(new("stick"), true), false).is_none());
bare.global.use_glint = false;
assert!(
!glint(&bare, &overridden(new("stick"), true), false)
.unwrap()
.vanilla
);
cit.global.use_glint = false;
assert!(
glint(&cit, &overridden(new("stick"), true), false)
.unwrap()
.is_empty()
);
}
#[test]
fn only_a_stack_with_a_glint_gets_layers() {
let cit = rules(&[("any", "type=enchantment")]);
assert!(glint(&cit, &new("stick"), false).is_none());
// The override turns an enchanted stack's glint off, and an unenchanted one's on.
let off = overridden(enchanted("stick", &[("sharpness", 1)]), false);
assert!(glint(&cit, &off, false).is_none());
let on = glint(&cit, &overridden(new("stick"), true), false).unwrap();
let weights: Vec<_> = on.layers.iter().map(|l| l.weight).collect();
assert_eq!(
weights,
[1.0],
"nothing to share: the rule at full strength"
);
// The same stack, asked again without its glint, is not answered from the cache.
assert!(
cit.glint(&overridden(new("stick"), true), Hand::Main, 0, false)
.is_none()
);
}
#[test]
fn a_rule_left_out_of_the_glints_matches_nothing() {
let mut cit = rules(&[
("broken", "type=enchantment\nitems=diamond_sword\nweight=1"),
("shown", "type=enchantment"),
]);
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
let layers = |cit: &CitRules| -> Vec<String> {
let glint = glint(cit, &sword, false).unwrap();
let stems = glint
.layers
.iter()
.map(|l| stem(cit, Some(l.rule)).unwrap());
stems.map(str::to_owned).collect()
};
// The heavier rule wins the layer, until its texture is missing.
assert_eq!(layers(&cit), ["broken"]);
let broken = (0..cit.rules.len())
.find(|&i| stem(&cit, Some(i)) == Some("broken"))
.unwrap();
cit.keep_glints(|i| i != broken);
assert_eq!(layers(&cit), ["shown"]);
assert_eq!(cit.enchantments(&sword, Hand::Main, 0).len(), 1);
// None left: vanilla's glint stays.
cit.keep_glints(|_| false);
assert!(glint(&cit, &sword, false).is_none());
}
#[test]
fn an_off_hand_layer_draws_only_in_the_off_hand() {
let cit = rules(&[
("left", "type=enchantment\nlayer=1\nhand=off"),
("both", "type=enchantment"),
]);
let sword = enchanted("diamond_sword", &[("sharpness", 1)]);
let layers = |hand| -> Vec<&str> {
let glint = cit.glint(&sword, hand, 0, true).unwrap();
glint
.layers
.iter()
.map(|l| stem(&cit, Some(l.rule)).unwrap())
.collect()
};
assert_eq!(layers(Hand::Off), ["both", "left"]);
// Asked in this order, a cache keyed without the hand would answer as for the off hand.
assert_eq!(layers(Hand::Main), ["both"]);
}
}
@@ -0,0 +1,319 @@
//! Custom Item Textures for worn armour and elytra (`type=armor`, `type=elytra`). An armour rule
//! names its textures after the armour texture files of before 1.21.2
//! (`textures/models/armor/diamond_layer_1.png`): `texture.diamond_layer_1`,
//! `texture.leather_layer_2_overlay`..., with `texture` for any layer it does not name. 26.3's
//! equipment assets name the same layers `entity/equipment/humanoid/diamond` (layer 1: helmet,
//! chestplate, boots) and `entity/equipment/humanoid_leggings/diamond` (layer 2), so the layers
//! are mapped back to the old names here. An elytra rule's `texture` replaces the wings'.
use crate::cit_assets::{rule_sprites, sprite_id};
use horizon_optifine::cit::{Kind, Rule};
use std::collections::BTreeSet;
/// The pre-1.21.2 armour texture name of an equipment asset's layer: `asset` (`minecraft:gold`
/// or `gold`) on the leggings layer (`humanoid_leggings`) or the others (`humanoid`), the dye
/// overlay (`leather_overlay`) or the main texture.
pub fn armor_layer_name(asset: &str, leggings: bool, overlay: bool) -> String {
let path = asset.rsplit_once(':').map_or(asset, |(_, p)| p);
// The equipment assets renamed from their old textures; the rest kept their names.
let old = match path {
"turtle_scute" => "turtle",
other => other,
};
let layer = if leggings { 2 } else { 1 };
let suffix = if overlay { "_overlay" } else { "" };
format!("{old}_layer_{layer}{suffix}")
}
/// The texture (a resource path) `rule` draws on the layer named `layer`: its `texture.<layer>`,
/// else its `texture`.
pub fn armor_texture<'a>(rule: &'a Rule, layer: &str) -> Option<&'a str> {
rule.textures
.iter()
.find(|(name, _)| name == layer)
.map(|(_, path)| path.as_str())
.or(rule.texture.as_deref())
}
/// The atlas name of a CIT texture: its `cit_assets` id, without `minecraft:` as the atlas packs
/// vanilla's sprites.
fn atlas_name(resource: &str) -> Option<String> {
let id = sprite_id(resource)?;
Some(match id.strip_prefix("minecraft:") {
Some(name) => name.to_owned(),
None => id,
})
}
/// The atlas sprite `rule` puts on the layer of equipment asset `asset` (see
/// [`armor_layer_name`]); `None` keeps the vanilla texture.
pub fn layer_sprite(rule: &Rule, asset: &str, leggings: bool, overlay: bool) -> Option<String> {
atlas_name(armor_texture(
rule,
&armor_layer_name(asset, leggings, overlay),
)?)
}
/// The atlas sprite an elytra rule puts on the wings.
pub fn elytra_sprite(rule: &Rule) -> Option<String> {
atlas_name(armor_texture(rule, "elytra")?)
}
/// The atlas sprite the one CIT armour rule `rule` puts on a worn armour layer: the layer
/// `vanilla` (an atlas name, `…_overlay` for the dye overlay) of equipment asset `asset` in
/// equipment layer `kind` (`humanoid_leggings` for the leggings). `None` draws `vanilla`, as does
/// a name the atlas lacks; nothing is allocated without a rule.
pub fn worn_layer_sprite(
rule: Option<&Rule>,
asset: &str,
kind: &str,
vanilla: &str,
) -> Option<String> {
let leggings = kind == "humanoid_leggings";
let overlay = vanilla.ends_with("_overlay");
layer_sprite(rule?, asset, leggings, overlay)
}
/// The CIT elytra rule's wings sprite. On `None`, or a name the atlas lacks, the caller draws the
/// cape, else the equipment asset's own wings.
pub fn wings_sprite(rule: Option<&Rule>) -> Option<String> {
elytra_sprite(rule?)
}
/// What a worn layer draws, looked up with `get` (the atlas): the rule's sprite
/// ([`worn_layer_sprite`]), else `vanilla` when there is no rule or the atlas lacks its texture.
pub fn worn_layer<R>(
rule: Option<&Rule>,
asset: &str,
kind: &str,
vanilla: &str,
get: impl Fn(&str) -> Option<R>,
) -> Option<R> {
worn_layer_sprite(rule, asset, kind, vanilla)
.and_then(|name| get(&name))
.or_else(|| get(vanilla))
}
/// What worn wings draw, looked up with `get`: the rule's sprite, else the cape; `None` leaves
/// them to the equipment asset's own wings.
pub fn wings<R>(
rule: Option<&Rule>,
cape: Option<&str>,
get: impl Fn(&str) -> Option<R>,
) -> Option<R> {
wings_sprite(rule)
.and_then(|name| get(&name))
.or_else(|| cape.and_then(&get))
}
/// Sprite ids the armour and elytra rules draw (see [`rule_sprites`]).
pub fn armor_sprites(rules: &[Rule]) -> BTreeSet<String> {
rule_sprites(rules, |kind| matches!(kind, Kind::Armor | Kind::Elytra))
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
fn rule(path: &str, text: &str) -> Rule {
Rule::parse(path, &Properties::parse_equals_first(text), |_| true).unwrap()
}
#[test]
fn layers_take_their_old_texture_names() {
assert_eq!(
armor_layer_name("minecraft:diamond", false, false),
"diamond_layer_1"
);
assert_eq!(armor_layer_name("diamond", true, false), "diamond_layer_2");
assert_eq!(
armor_layer_name("minecraft:leather", false, true),
"leather_layer_1_overlay"
);
assert_eq!(
armor_layer_name("minecraft:leather", true, true),
"leather_layer_2_overlay"
);
assert_eq!(
armor_layer_name("minecraft:gold", false, false),
"gold_layer_1"
);
assert_eq!(
armor_layer_name("minecraft:chainmail", true, false),
"chainmail_layer_2"
);
assert_eq!(
armor_layer_name("minecraft:turtle_scute", false, false),
"turtle_layer_1"
);
assert_eq!(
armor_layer_name("minecraft:netherite", true, false),
"netherite_layer_2"
);
}
#[test]
fn a_named_layer_wins_over_the_generic_texture() {
let r = rule(
"assets/minecraft/optifine/cit/armor/blue.properties",
"type=armor\nitems=diamond_chestplate diamond_leggings\ntexture=generic\n\
texture.diamond_layer_2=blue_legs\n",
);
let dir = "assets/minecraft/optifine/cit/armor";
assert_eq!(
armor_texture(&r, "diamond_layer_2"),
Some(format!("{dir}/blue_legs.png").as_str())
);
assert_eq!(
armor_texture(&r, "diamond_layer_1"),
Some(format!("{dir}/generic.png").as_str())
);
assert_eq!(
layer_sprite(&r, "minecraft:diamond", true, false).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/armor/blue_legs")
);
assert_eq!(
layer_sprite(&r, "minecraft:diamond", false, false).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/armor/generic")
);
}
#[test]
fn without_a_generic_texture_other_layers_stay_vanilla() {
let r = rule(
"assets/minecraft/optifine/cit/a.properties",
"type=armor\nitems=leather_helmet\ntexture.leather_layer_1=dyed\n",
);
assert_eq!(
layer_sprite(&r, "minecraft:leather", false, false).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/dyed")
);
assert_eq!(layer_sprite(&r, "minecraft:leather", false, true), None);
assert_eq!(layer_sprite(&r, "minecraft:leather", true, false), None);
}
#[test]
fn a_vanilla_texture_path_keeps_its_atlas_name() {
let r = rule(
"assets/minecraft/optifine/cit/wings.properties",
"type=elytra\ntexture=minecraft:textures/entity/wings/dragon.png\n",
);
assert_eq!(elytra_sprite(&r).as_deref(), Some("entity/wings/dragon"));
}
#[test]
fn worn_layers_pick_the_rules_texture_for_their_layer() {
let r = rule(
"assets/minecraft/optifine/cit/dye.properties",
"type=armor\nitems=leather_chestplate leather_leggings\n\
texture.leather_layer_1=body\ntexture.leather_layer_1_overlay=body_dye\n\
texture.leather_layer_2=legs\n",
);
let cit = |p: &str| Some(format!("horizon_cit:minecraft/optifine/cit/{p}"));
let pick = |kind: &str, vanilla: &str| {
worn_layer_sprite(Some(&r), "minecraft:leather", kind, vanilla)
};
let body = "entity/equipment/humanoid/leather";
assert_eq!(pick("humanoid", body), cit("body"));
assert_eq!(
pick("humanoid", "entity/equipment/humanoid/leather_overlay"),
cit("body_dye")
);
assert_eq!(
pick(
"humanoid_leggings",
"entity/equipment/humanoid_leggings/leather"
),
cit("legs")
);
// No `texture.leather_layer_2_overlay` and no `texture`: the overlay stays vanilla.
let legs_dye = "entity/equipment/humanoid_leggings/leather_overlay";
assert_eq!(pick("humanoid_leggings", legs_dye), None);
// No rule draws vanilla's.
assert_eq!(
worn_layer_sprite(None, "minecraft:leather", "humanoid", body),
None
);
}
#[test]
fn wings_take_the_rules_texture() {
let r = rule(
"assets/minecraft/optifine/cit/wings.properties",
"type=elytra\ntexture=wings\n",
);
assert_eq!(
wings_sprite(Some(&r)).as_deref(),
Some("horizon_cit:minecraft/optifine/cit/wings")
);
assert_eq!(wings_sprite(None), None);
}
#[test]
fn only_armor_and_elytra_rules_reach_the_atlas() {
let rules = [
rule(
"assets/minecraft/optifine/cit/sword.properties",
"items=diamond_sword\ntexture=sword\n",
),
rule(
"assets/minecraft/optifine/cit/plate.properties",
"type=armor\nitems=iron_chestplate iron_leggings\ntexture=plate\n\
texture.iron_layer_2=legs\n",
),
rule(
"assets/minecraft/optifine/cit/wings.properties",
"type=elytra\ntexture=wings\n",
),
rule(
"assets/minecraft/optifine/cit/glint.properties",
"type=enchantment\nenchantments=sharpness\ntexture=glint\n",
),
];
let got: Vec<String> = armor_sprites(&rules).into_iter().collect();
assert_eq!(
got,
[
"horizon_cit:minecraft/optifine/cit/legs",
"horizon_cit:minecraft/optifine/cit/plate",
"horizon_cit:minecraft/optifine/cit/wings",
]
);
}
/// An atlas lookup that knows `names`.
fn atlas(names: &[&str]) -> impl Fn(&str) -> Option<String> {
let names: Vec<String> = names.iter().map(|n| (*n).to_owned()).collect();
move |s| names.iter().any(|n| n == s).then(|| s.to_owned())
}
#[test]
fn the_atlas_lookup_falls_back_to_vanilla_and_the_cape() {
let iron = rule(
"assets/minecraft/optifine/cit/iron.properties",
"type=armor\nitems=iron_chestplate\ntexture.iron_layer_1=plate",
);
let plate = "horizon_cit:minecraft/optifine/cit/plate";
let vanilla = "entity/equipment/humanoid/iron";
let layer = |rule: Option<&Rule>, names: &[&str]| {
worn_layer(rule, "minecraft:iron", "humanoid", vanilla, atlas(names))
};
assert_eq!(
layer(Some(&iron), &[plate, vanilla]).as_deref(),
Some(plate)
);
// The rule's texture didn't reach the atlas, or there's no rule: vanilla's layer.
assert_eq!(layer(Some(&iron), &[vanilla]).as_deref(), Some(vanilla));
assert_eq!(layer(None, &[plate, vanilla]).as_deref(), Some(vanilla));
let elytra = rule(
"assets/minecraft/optifine/cit/w.properties",
"type=elytra\ntexture=wings",
);
let w = "horizon_cit:minecraft/optifine/cit/wings";
let wing = |rule: Option<&Rule>, names: &[&str]| wings(rule, Some("cape/x"), atlas(names));
assert_eq!(wing(Some(&elytra), &[w, "cape/x"]).as_deref(), Some(w));
assert_eq!(wing(Some(&elytra), &["cape/x"]).as_deref(), Some("cape/x"));
assert_eq!(wings(None, None, atlas(&[w])), None);
}
}
+184 -6
View File
@@ -5,8 +5,11 @@
//! `horizon_cit:minecraft/optifine/cit/swords/flame`). A file already under `textures/` (or
//! `models/`) keeps its vanilla id, so it is not packed twice.
use horizon_assets::model::{CuboidModel, TextureSlot};
use horizon_optifine::cit::{Kind, Rule};
use std::collections::BTreeSet;
use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::Path;
/// The namespace of CIT files outside `textures/` and `models/`.
pub const NAMESPACE: &str = "horizon_cit";
@@ -55,12 +58,11 @@ pub fn resource(id: &str, ext: &str) -> Option<String> {
horizon_optifine::paths::valid(rest).then(|| format!("assets/{rest}.{ext}"))
}
/// Sprite ids the item rules draw: `Kind::Item` rules' `texture` and `texture.<name>` values.
/// (Textures named by CIT JSON models come in a later PR.) A path no id can name is skipped with
/// a warning.
pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
/// Sprite ids the rules whose kind passes `keep` draw: their `texture` and `texture.<name>`
/// values. A path no id can name is skipped with a warning.
pub fn rule_sprites(rules: &[Rule], keep: impl Fn(Kind) -> bool) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for rule in rules.iter().filter(|r| r.kind == Kind::Item) {
for rule in rules.iter().filter(|r| keep(r.kind)) {
let named = rule.textures.iter().map(|(_, path)| path);
for path in rule.texture.iter().chain(named) {
match sprite_id(path) {
@@ -77,6 +79,122 @@ pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
out
}
/// Sprite ids the item rules draw (`Kind::Item`, see [`rule_sprites`]). Textures named by CIT
/// JSON models are [`model_sprites`].
pub fn item_sprites(rules: &[Rule]) -> BTreeSet<String> {
rule_sprites(rules, |kind| kind == Kind::Item)
}
/// The resource path a model value `./x` (or `./../x`, `../x`) names beside the file at `file`,
/// ending in `.<ext>` (added unless the value has it). `None` for other values and for paths
/// that would leave the root.
fn relative(file: &str, value: &str, ext: &str) -> Option<String> {
let mut rest = value
.strip_prefix("./")
.or_else(|| value.starts_with("../").then_some(value))?;
let mut dir = file.rsplit_once('/')?.0;
while let Some(up) = rest.strip_prefix("../") {
dir = dir.rsplit_once('/')?.0;
rest = up;
}
let rest = rest
.strip_suffix(ext)
.and_then(|r| r.strip_suffix('.'))
.unwrap_or(rest);
let path = format!("{dir}/{rest}.{ext}");
horizon_optifine::paths::valid(&path).then_some(path)
}
/// The id a relative value of the model at `file` names, as `CuboidModel::from_json` read it
/// (`./x` is `minecraft:./x` there), with the resource path of the file it names.
fn rebase(file: &str, id: &str, ext: &str) -> Option<(String, String)> {
let path = relative(file, id.strip_prefix("minecraft:")?, ext)?;
let id = if ext == "png" {
sprite_id(&path)
} else {
model_id(&path)
}?;
Some((id, path))
}
/// Parents followed from a rule's model before the rest are left to the library.
const MAX_PARENTS: usize = 16;
/// The CIT JSON models the item rules name (`model`, `model.<name>`) under their [`model_id`]s,
/// with the parents they reach by relative path. Their `./x` texture and parent values become
/// the ids of the files beside them. Only `horizon_cit` models: the others are in the model
/// library already. A file that does not read or parse is left out, and a rule's layer that
/// names it (or a model whose parents reach it) keeps its vanilla model.
pub fn load_models(root: &Path, rules: &[Rule]) -> BTreeMap<String, CuboidModel> {
let mut out = BTreeMap::new();
let mut seen = BTreeSet::new();
for rule in rules.iter().filter(|r| r.kind == Kind::Item) {
let named = rule.models.iter().map(|(_, path)| path);
for path in rule.model.iter().chain(named) {
load_model(root, path, 0, &mut seen, &mut out);
}
}
out
}
fn load_model(
root: &Path,
resource: &str,
depth: usize,
seen: &mut BTreeSet<String>,
out: &mut BTreeMap<String, CuboidModel>,
) {
let Some(id) = model_id(resource) else {
log::warn!("custom item textures: {resource} is not a valid model id");
return;
};
let ours = id
.strip_prefix(NAMESPACE)
.is_some_and(|r| r.starts_with(':'));
if !ours || depth > MAX_PARENTS || !seen.insert(id.clone()) {
return;
}
let model = fs::read_to_string(root.join(resource))
.map_err(|e| e.to_string())
.and_then(|text| CuboidModel::from_json(&text).map_err(|e| e.to_string()));
let mut model = match model {
Ok(model) => model,
Err(e) => {
log::warn!("custom item textures: skipped model {resource}: {e}");
return;
}
};
for slot in model.textures.values_mut() {
if let TextureSlot::Material(m) = slot
&& let Some((id, _)) = rebase(resource, &m.sprite, "png")
{
m.sprite = id;
}
}
let parent = model
.parent
.as_deref()
.and_then(|p| rebase(resource, p, "json"));
if let Some((id, path)) = parent {
model.parent = Some(id);
load_model(root, &path, depth + 1, seen, out);
}
out.insert(id, model);
}
/// The `horizon_cit` sprites `models` name, which the atlas packs beside [`item_sprites`].
pub fn model_sprites(models: &BTreeMap<String, CuboidModel>) -> BTreeSet<String> {
let prefix = format!("{NAMESPACE}:");
models
.values()
.flat_map(|m| m.textures.values())
.filter_map(|slot| match slot {
TextureSlot::Material(m) if m.sprite.starts_with(&prefix) => Some(m.sprite.clone()),
_ => None,
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -161,4 +279,64 @@ mod tests {
root.join("assets/minecraft/textures/item/stick.png")
);
}
#[test]
fn rule_models_load_with_their_relative_textures_and_parents() {
let root = std::env::temp_dir().join(format!("horizon-cit-models-{}", std::process::id()));
let cit = root.join("assets/minecraft/optifine/cit");
fs::create_dir_all(cit.join("swords")).unwrap();
let write = |rel: &str, text: &str| fs::write(cit.join(rel), text).unwrap();
write(
"swords/flame.json",
r#"{"parent":"./../base","textures":{"layer0":"./flame","layer1":"item/stick"}}"#,
);
write(
"base.json",
r#"{"parent":"item/handheld","textures":{"layer2":"./glow.png"}}"#,
);
let props =
horizon_optifine::Properties::parse_equals_first("items=stick\nmodel=./swords/flame");
let rule = Rule::parse("assets/minecraft/optifine/cit/a.properties", &props, |p| {
root.join(p).is_file()
})
.unwrap();
let models = load_models(&root, &[rule]);
let _ = fs::remove_dir_all(&root);
let cit = |p: &str| format!("horizon_cit:minecraft/optifine/cit/{p}");
assert_eq!(
models.keys().cloned().collect::<Vec<_>>(),
[cit("base"), cit("swords/flame")]
);
let flame = &models[&cit("swords/flame")];
assert_eq!(flame.parent.as_deref(), Some(cit("base").as_str()));
assert_eq!(
models[&cit("base")].parent.as_deref(),
Some("minecraft:item/handheld")
);
assert_eq!(
model_sprites(&models).into_iter().collect::<Vec<_>>(),
[cit("glow"), cit("swords/flame")]
);
}
#[test]
fn relative_values_stay_in_the_root() {
let file = "assets/minecraft/optifine/cit/a/m.json";
let at = |v: &str| relative(file, v, "png");
assert_eq!(
at("./x").as_deref(),
Some("assets/minecraft/optifine/cit/a/x.png")
);
assert_eq!(
at("./../x.png").as_deref(),
Some("assets/minecraft/optifine/cit/x.png")
);
assert_eq!(
at("../../x").as_deref(),
Some("assets/minecraft/optifine/x.png")
);
assert_eq!(at("item/x"), None);
assert_eq!(at("./../../../../../x"), None);
assert_eq!(at("./a/../x"), None);
}
}
@@ -0,0 +1,386 @@
//! Custom Item Textures' enchantment glints (`type=enchantment`): which of a stack's matching
//! rules draw, in which order and how strongly, and whether vanilla's glint still draws under
//! them. The pack's `cit.properties` (`Global`) says how layers share an item.
//!
//! Written from OptiFine's `cit.properties` doc and CIT Resewn's (MIT) `TypeEnchantment` of
//! before its 1.21.11 port, which implemented `method`, `cap` and `fade`: the sums and the
//! cycle's fading follow it, except where noted.
pub use horizon_optifine::cit::{Global, Method};
use crate::cit::read_properties;
use horizon_optifine::cit::Rule;
use std::path::Path;
/// The pack's global `cit.properties` under the overlay resource root: the first of
/// `Global::PATHS` that exists, else the defaults.
pub fn load_global(root: &Path) -> Global {
Global::PATHS
.iter()
.find_map(|path| read_properties(&root.join(path)))
.map(|props| Global::parse(&props))
.unwrap_or_default()
}
/// One glint layer a stack draws.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct GlintLayer {
/// The rule, an index into `CitRules::rules`.
pub rule: usize,
/// Its `average` or `layered` share, 1 under `none` and `cycle`.
pub weight: f32,
/// Its `duration`, for `cycle`.
duration: f32,
}
/// What a stack's glint is made of, worked out when the stack changes.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct StackGlint {
/// Bottom first (`layer` ascending).
pub layers: Vec<GlintLayer>,
/// Whether vanilla's glint draws (on a stack that has one) under the layers.
pub vanilla: bool,
/// `fade` when the layers take turns (`method=cycle`).
cycle_fade: Option<f32>,
}
impl StackGlint {
/// The layers among `matched` (the stack's matching enchantment rules, in priority order,
/// as `CitRules::enchantments` gives them) that draw, for a stack with `enchantments`
/// (`ItemStack::crafting_enchantments`).
pub fn new(
rules: &[Rule],
matched: &[usize],
enchantments: &[(impl AsRef<str>, u32)],
global: &Global,
) -> Self {
// The highest priority rule of each layer.
let mut layers: Vec<usize> = Vec::new();
for &i in matched {
let layer = rules[i].glint.layer;
if !layers.iter().any(|&j| rules[j].glint.layer == layer) {
layers.push(i);
}
}
layers.sort_by_key(|&i| rules[i].glint.layer);
let cycle = global.method == Method::Cycle;
// OptiFine's doc: `cap` is for `average` and `layered`, and keeps the topmost layers.
// `none`, which OptiFine lacks, is capped too, as CIT Resewn caps every method (it keeps
// the bottom layers, though, and caps cycles as well).
if let Some(cap) = global.cap.filter(|_| !cycle) {
let cap = usize::try_from(cap).unwrap_or(usize::MAX);
layers = layers.split_off(layers.len().saturating_sub(cap));
}
// CIT Resewn: any layer left asking for vanilla's glint keeps it, and the global
// `useGlint=false` removes it everywhere, as OptiFine's doc says too. `cap=0` leaves no
// custom layer, and vanilla's glint only if the pack's `useGlint` allows it.
let vanilla = global.use_glint
&& (layers.is_empty() || layers.iter().any(|&i| rules[i].glint.use_glint));
// CIT Resewn: shares are against every enchantment of the stack, not only those the
// layers name.
let levels = enchantments.iter().map(|(_, level)| *level as f32);
let total = match global.method {
Method::Average => levels.sum::<f32>(),
Method::Layered => levels.fold(0.0, f32::max),
Method::None | Method::Cycle => 0.0,
};
let shared = matches!(global.method, Method::Average | Method::Layered);
let layers = layers
.into_iter()
.map(|i| {
let rule = &rules[i];
let named = &rule.enchantments;
let weight = if !shared || named.is_empty() {
1.0
} else {
// The highest level among the enchantments the rule names whose level also
// passes its `enchantmentLevels` (CIT Resewn takes the first named one the
// stack has, whatever its level).
let in_range = |level: u32| {
let level = i32::try_from(level).unwrap_or(i32::MAX);
rule.enchantment_levels
.as_ref()
.is_none_or(|r| r.contains(level))
};
let level = enchantments
.iter()
.filter(|(id, level)| {
let id: &str = id.as_ref();
named.iter().any(|n| n == id) && in_range(*level)
})
.map(|(_, level)| *level as f32)
.fold(0.0, f32::max);
if total > 0.0 { level / total } else { 0.0 }
};
GlintLayer {
rule: i,
weight,
duration: rule.glint.duration,
}
})
.collect();
Self {
layers,
vanilla,
cycle_fade: cycle.then_some(global.fade),
}
}
pub fn is_empty(&self) -> bool {
self.layers.is_empty()
}
/// Layer `index`'s intensity `seconds` into the game: its weight, and under `method=cycle`
/// its turn.
pub fn intensity(&self, index: usize, seconds: f64) -> f32 {
let layer = self.layers[index];
match self.cycle_fade {
None => layer.weight,
Some(fade) => cycle_intensity(&self.layers, fade, index, seconds),
}
}
}
/// CIT Resewn's `Loops.statelessFadingLoop`: each layer shows alone for its `duration`, then
/// fades into the next over `fade` seconds, the last into the first. With no time at all
/// (every `duration` and `fade` 0) the bottom layer shows alone.
fn cycle_intensity(layers: &[GlintLayer], fade: f32, index: usize, seconds: f64) -> f32 {
let n = layers.len();
let fade = f64::from(fade);
let period: f64 = layers.iter().map(|l| f64::from(l.duration) + fade).sum();
if n == 1 || period <= 0.0 {
return if index == 0 { 1.0 } else { 0.0 };
}
let mut t = seconds.rem_euclid(period);
for (i, layer) in layers.iter().enumerate() {
let duration = f64::from(layer.duration);
if t < duration {
return if i == index { 1.0 } else { 0.0 };
}
t -= duration;
// Defensive: the last fade also takes anything rounding might leave of the period
// (none seen), ending fully on the first layer.
if t < fade || i + 1 == n {
let into = if fade > 0.0 {
(t / fade).min(1.0) as f32
} else {
1.0
};
return if i == index {
1.0 - into
} else if (i + 1) % n == index {
into
} else {
0.0
};
}
t -= fade;
}
// Not reached, as the last layer returns above; no layer rather than a panic.
0.0
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
const DIR: &str = "assets/minecraft/optifine/cit";
/// An enchantment rule named `name` with `text` after `type` and `texture`.
fn rule(name: &str, text: &str) -> Rule {
let text = format!("type=enchantment\ntexture=glint\n{text}");
let path = format!("{DIR}/{name}.properties");
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
}
fn global(text: &str) -> Global {
Global::parse(&Properties::parse_equals_first(text))
}
fn rules_of(layers: &StackGlint) -> Vec<usize> {
layers.layers.iter().map(|l| l.rule).collect()
}
const SWORD: [(&str, u32); 2] = [("minecraft:sharpness", 3), ("minecraft:unbreaking", 1)];
#[test]
fn one_rule_per_layer_bottom_first() {
let rules = [
rule("a", "layer=2"),
rule("b", "layer=0"),
rule("c", "layer=2"),
rule("d", "layer=-1"),
];
// Priority order a, b, c, d: c loses layer 2 to a.
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &Global::default());
assert_eq!(rules_of(&g), [3, 1, 0]);
let g = StackGlint::new(&rules, &[2, 0], &SWORD, &Global::default());
assert_eq!(rules_of(&g), [2]);
}
#[test]
fn average_and_layered_shares() {
let rules = [
rule("sharp", "enchantments=sharpness smite"),
rule("unb", "layer=1\nenchantments=unbreaking"),
rule("any", "layer=2"),
rule("fire", "layer=3\nenchantments=fire_aspect"),
];
let weights = |method: &str| {
let g = StackGlint::new(&rules, &[0, 1, 2, 3], &SWORD, &global(method));
g.layers.iter().map(|l| l.weight).collect::<Vec<_>>()
};
// Sharpness III and Unbreaking I: 3 / 4 and 1 / 4; against the highest, 3 / 3 and 1 / 3.
// A rule naming no enchantment is at full strength, one the stack lacks at none.
assert_eq!(weights("method=average"), [0.75, 0.25, 1.0, 0.0]);
assert_eq!(weights("method=layered"), [1.0, 1.0 / 3.0, 1.0, 0.0]);
assert_eq!(weights("method=cycle"), [1.0; 4]);
assert_eq!(weights("method=none"), [1.0; 4]);
}
#[test]
fn the_level_is_one_the_rules_levels_allow() {
let rules = [rule(
"holy",
"enchantments=sharpness smite\nenchantmentLevels=1-2",
)];
// Sharpness V is out of range, so Smite I sets the share: 1 / 5 of the highest level.
let stack = [("minecraft:sharpness", 5), ("minecraft:smite", 1)];
let g = StackGlint::new(&rules, &[0], &stack, &global("method=layered"));
assert_eq!(g.layers[0].weight, 1.0 / 5.0);
}
#[test]
fn cap_keeps_the_topmost_layers() {
let rules = [
rule("a", "layer=0"),
rule("b", "layer=1"),
rule("c", "layer=2"),
];
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=2"));
assert_eq!(rules_of(&g), [1, 2]);
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=0"));
assert!(g.is_empty());
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=none"));
assert_eq!(rules_of(&g), [2]);
// Cycles show every layer in turn.
let g = StackGlint::new(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=cycle"));
assert_eq!(rules_of(&g), [0, 1, 2]);
}
#[test]
fn vanilla_glint() {
let plain = rule("plain", "");
let keep = rule("keep", "layer=1\nuseGlint=true");
let rules = [plain, keep];
let vanilla = |matched: &[usize], text: &str| {
StackGlint::new(&rules, matched, &SWORD, &global(text)).vanilla
};
assert!(vanilla(&[], ""), "no rule: vanilla's glint");
assert!(!vanilla(&[0], ""), "a rule replaces it");
assert!(vanilla(&[0, 1], ""), "unless one keeps it");
assert!(!vanilla(&[], "useGlint=false"));
assert!(!vanilla(&[1], "useGlint=false"), "the pack's word is final");
// Only the layers `cap` keeps count.
let rules = [rule("keep", "useGlint=true"), rule("top", "layer=1")];
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=1"));
assert!(!g.vanilla);
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0"));
assert!(g.is_empty() && g.vanilla);
let g = StackGlint::new(&rules, &[0, 1], &SWORD, &global("cap=0\nuseGlint=false"));
assert!(!g.vanilla);
}
#[test]
fn cycles_take_turns_and_fade() {
let rules = [
rule("a", "duration=2"),
rule("b", "layer=1\nduration=1"),
rule("c", "layer=2\nduration=1"),
];
let g = StackGlint::new(
&rules,
&[0, 1, 2],
&SWORD,
&global("method=cycle\nfade=0.5"),
);
// a 0-2, a→b 2-2.5, b 2.5-3.5, b→c 3.5-4, c 4-5, c→a 5-5.5, then again.
let at = |t: f64| [0, 1, 2].map(|i| g.intensity(i, t));
assert_eq!(at(1.0), [1.0, 0.0, 0.0]);
assert_eq!(at(2.125), [0.75, 0.25, 0.0]);
assert_eq!(at(3.0), [0.0, 1.0, 0.0]);
assert_eq!(at(3.75), [0.0, 0.5, 0.5]);
assert_eq!(at(4.5), [0.0, 0.0, 1.0]);
assert_eq!(at(5.25), [0.5, 0.0, 0.5]);
assert_eq!(at(5.5 + 3.0), [0.0, 1.0, 0.0]);
// One layer always shows.
let one = StackGlint::new(&rules, &[1], &SWORD, &global("method=cycle"));
assert_eq!(one.intensity(0, 0.7), 1.0);
}
#[test]
fn a_cycle_always_shows_a_whole_layer() {
let rules = [
rule("a", "duration=0.1"),
rule("b", "layer=1\nduration=0.7"),
rule("c", "layer=2\nduration=0.2"),
];
let g = StackGlint::new(
&rules,
&[0, 1, 2],
&SWORD,
&global("method=cycle\nfade=0.3"),
);
// Every millisecond of the first two seconds, and just under a period, the last fade's
// end. (Rounding has not been seen to leave time past it; the last fade's clamp is
// defensive.)
let period: f64 = [0.1f32, 0.7, 0.2]
.map(|d| f64::from(d) + f64::from(0.3f32))
.iter()
.sum();
let last = f64::from_bits(period.to_bits() - 1);
for t in (0..2000_i32)
.map(|i| f64::from(i) * 0.001)
.chain([last, period + last])
{
let sum: f32 = (0..3).map(|i| g.intensity(i, t)).sum();
assert!((sum - 1.0).abs() < 1e-5, "{sum} at {t}");
}
// No time at all: the bottom layer alone, never NaN.
let still = [rule("a", "duration=0"), rule("b", "layer=1\nduration=0")];
let g = StackGlint::new(&still, &[0, 1], &SWORD, &global("method=cycle\nfade=0"));
for t in [0.0, 0.5, 7.25] {
assert_eq!([0, 1].map(|i| g.intensity(i, t)), [1.0, 0.0]);
}
}
#[test]
fn the_first_global_file_wins() {
let root = std::env::temp_dir().join(format!("horizon-cit-glint-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
assert_eq!(load_global(&root), Global::default());
let write = |path: &str, text: &[u8]| {
let file = root.join(path);
std::fs::create_dir_all(file.parent().unwrap()).unwrap();
std::fs::write(file, text).unwrap();
};
// Saved as Latin-1 (`é` is not UTF-8 alone), in the OptiFine doc's `cit/` folder.
write(
"assets/minecraft/optifine/cit/cit.properties",
b"# caf\xE9\nmethod=cycle",
);
assert_eq!(load_global(&root).method, Method::Cycle);
write("assets/minecraft/mcpatcher/cit.properties", b"method=none");
assert_eq!(load_global(&root).method, Method::Cycle);
write(
"assets/minecraft/optifine/cit.properties",
b"method=layered",
);
assert_eq!(load_global(&root).method, Method::Layered);
write("assets/minecraft/citresewn/cit.properties", b"cap=1");
let g = load_global(&root);
let _ = std::fs::remove_dir_all(&root);
assert_eq!((g.method, g.cap), (Method::Average, Some(1)));
}
}
+43 -6
View File
@@ -743,7 +743,8 @@ pub struct Renderer {
/// (F3+4 shows it).
pub lightmap_pixels: [u8; 16 * 16 * 4],
/// Time the last `render` spent blocked on the GPU or the display
/// (acquiring, presenting, offscreen pacing), excluded from CPU time.
/// (acquiring, presenting, offscreen pacing, the submit thread holding the queue),
/// excluded from CPU time.
pub last_wait: std::time::Duration,
/// CPU time of the last `render`, waits excluded.
pub last_encode: std::time::Duration,
@@ -1109,7 +1110,7 @@ const SPRITE_ARRAYS: u32 = 8;
/// array. wgpu's OpenGL backend picks a texture's target from its layer
/// count when it is created: one layer makes a 2D texture and a multiple of
/// six a cube map, neither of which can be viewed as an array.
fn array_layers(count: u32, gl: bool) -> u32 {
pub(crate) fn array_layers(count: u32, gl: bool) -> u32 {
match count {
_ if !gl => count,
0 | 1 => 2,
@@ -5354,7 +5355,11 @@ impl Renderer {
}
/// `checks`: the error scopes of a scaled screenshot's frame, checked before its PNG is written.
fn draw_frame(&mut self, frame: &Frame, checks: Option<GpuChecks>) -> Result<bool> {
let landing = Instant::now();
self.land_frame();
// Waiting for the previous frame (offscreen: at most two on the GPU), not work.
let landed = landing.elapsed();
self.queue.take_waited();
// World frames alone follow the shader pack: the game loads the picked one
// before it draws one. Title frames draw no terrain and keep the layout
// through a pack dropped (Shader Options) or not loaded yet (`Renderer::new`),
@@ -5420,6 +5425,8 @@ impl Renderer {
if !fg {
self.frame_gen.stop();
}
// Waits for the submit thread since the last frame's present (this frame's uploads).
let queued = self.queue.take_waited();
let acquire = Instant::now();
let fg_frames = fg
.then(|| {
@@ -5441,7 +5448,9 @@ impl Renderer {
} else {
self.surface.as_ref().map(|s| s.get_current_texture())
};
self.last_wait = acquire.elapsed();
self.last_wait = landed + queued + acquire.elapsed();
// Inside the acquire's time already.
self.queue.take_waited();
let (surface_frame, reconfigure) = match acquired {
None => (None, false),
Some(wgpu::CurrentSurfaceTexture::Success(f)) => (Some(f), false),
@@ -8112,6 +8121,8 @@ impl Renderer {
match self.queue.submit_later(job) {
Ok(()) => {
self.deferred = Some(offscreen);
// The frame's waits for the previous one on the submit thread.
self.last_wait += self.queue.take_waited();
self.last_encode = started.elapsed().saturating_sub(self.last_wait);
self.visible = visible;
self.visible_lods = visible_lods;
@@ -8176,6 +8187,8 @@ impl Renderer {
);
}
}
// The frame's own waits for the submit thread (its queue writes and passes).
self.last_wait += self.queue.take_waited();
let present = Instant::now();
if let Some(surface_frame) = surface_frame {
// On Wayland this ties RedrawRequested to the compositor's frame
@@ -8208,6 +8221,8 @@ impl Renderer {
}
}
self.last_wait += present.elapsed();
// Inside the present's time already.
self.queue.take_waited();
self.last_encode = started.elapsed().saturating_sub(self.last_wait);
let _ = self.started;
self.visible = visible;
@@ -9087,6 +9102,14 @@ mod tests {
assert!(!drawable_size(984, u32::MAX, 32768));
}
#[test]
fn a_reported_black_level_rounds_down() {
use super::hundredths_of_a_nit as h;
assert_eq!(h(0.005), 0);
assert_eq!(h(0.05), 5);
assert_eq!(h(5.0), 100);
assert_eq!(h(f32::NAN), 0);
}
#[test]
fn os_luminances_become_auto_values() {
use super::os_reported;
@@ -9104,14 +9127,16 @@ mod tests {
os_reported(&info(Some(998.4), Some(240.0))),
Some(Reported {
paper_white: 240,
peak: 998
peak: 998,
black: 0
})
);
assert_eq!(
os_reported(&info(Some(120.0), Some(1000.0))),
Some(Reported {
paper_white: 480,
peak: 480
peak: 480,
black: 0
})
);
// White above the calibrated peak (a Samsung QD-OLED's 380, the slider at 456).
@@ -9119,7 +9144,8 @@ mod tests {
os_reported(&info(Some(380.0), Some(456.0))),
Some(Reported {
paper_white: 456,
peak: 456
peak: 456,
black: 0
})
);
assert_eq!(os_reported(&info(Some(1000.0), None)), None);
@@ -9902,6 +9928,15 @@ fn flush_display_log() {
(log.last, log.at) = (line, Some(now));
}
}
/// A reported black level, within the setting's range. Rounded down: OLEDs report a figure
/// just above 0, like KWin's 0.005, which isn't worth lifting the shadows for.
fn hundredths_of_a_nit(nits: f32) -> u32 {
if nits.is_finite() && nits > 0.0 {
((nits * 100.0) as u32).min(100)
} else {
0
}
}
fn log_os_reported(reported: Option<crate::graphics::Reported>) {
log_display(match reported {
Some(r) => format!(
@@ -9924,6 +9959,7 @@ fn os_reported(info: &wgpu::DisplayHdrInfo) -> Option<crate::graphics::Reported>
let paper_white = (white.round() as u32).clamp(80, 480);
Some(crate::graphics::Reported {
paper_white,
black: hundredths_of_a_nit(luminance.min_nits.unwrap_or(0.0)),
// Windows' SDR brightness slider can set white above the reported peak (480 against
// a calibrated 380): the highlights then stay at white instead of dropping below it.
peak: (peak.round() as u32).clamp(200, 10_000).max(paper_white),
@@ -9949,5 +9985,6 @@ fn reported(l: horizon_hdr::wayland::Luminances) -> crate::graphics::Reported {
crate::graphics::Reported {
paper_white: 203,
peak: peak.clamp(200, 10_000),
black: hundredths_of_a_nit(l.target_min * 203.0 / l.reference.max(1.0)),
}
}
@@ -6,7 +6,23 @@
use super::Vertex;
use super::pack_draw::EntitySpan;
use glam::{DVec3, Mat4};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
};
mod generated;
pub use generated::{RenderHooks, RenderNeedsEvent, WorldDrawnEvent};
pub use wgpu;
static RENDERERS: OnceLock<Box<[&'static dyn RenderHooks]>> = OnceLock::new();
/// Install trait mod renderers in dispatch order, once per process.
pub fn install(
renderers: Box<[&'static dyn RenderHooks]>,
) -> Result<(), Box<[&'static dyn RenderHooks]>> {
RENDERERS.set(renderers)
}
static MODS_BUILT_IN: AtomicBool = AtomicBool::new(false);
@@ -18,12 +34,16 @@ pub fn set_mods_built_in(on: bool) {
/// Whether [`native_world_drawn`] may draw ([`set_mods_built_in`]).
pub fn world_drawn_hooked() -> bool {
MODS_BUILT_IN.load(Ordering::Relaxed)
|| RENDERERS
.get()
.is_some_and(|renderers| !renderers.is_empty())
}
/// What a mod can draw with once the world is drawn: after the sky, terrain, entities,
/// particles, weather and the world post pass, before the first-person hand, the HUD and the
/// screens. `color` holds the world's picture (copy it out before reading it: it is the target
/// drawn into), `depth` the world's depth, reversed (0 is the sky).
#[non_exhaustive]
pub struct WorldDrawn<'a> {
pub device: &'a wgpu::Device,
pub queue: &'a wgpu::Queue,
@@ -59,6 +79,7 @@ pub struct WorldDrawn<'a> {
/// What the mods need from a frame that the game doesn't make for itself. Each mod sets what it
/// needs and leaves the rest, so several can ask.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[non_exhaustive]
pub struct RenderNeeds {
/// Record `Frame::entity_spans` without a shader pack: entity runs get a stable id from
/// their type's name, block entity runs their block state.
@@ -70,13 +91,13 @@ pub struct RenderNeeds {
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
#[inline(never)]
pub fn native_world_drawn(world: &mut WorldDrawn<'_>) {
let _ = world;
generated::dispatch_world_drawn(RENDERERS.get().map_or(&[], |renderers| renderers), world);
}
/// Asked as a frame is built and drawn: set what your mod needs.
#[inline(never)]
pub fn native_render_needs(needs: &mut RenderNeeds) {
let _ = needs;
generated::dispatch_render_needs(RENDERERS.get().map_or(&[], |renderers| renderers), needs);
}
/// What the mods need this frame ([`native_render_needs`]).
@@ -85,3 +106,34 @@ pub fn render_needs() -> RenderNeeds {
native_render_needs(&mut needs);
needs
}
#[cfg(test)]
mod tests {
use super::*;
struct Depth;
impl RenderHooks for Depth {
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
event.needs.world_depth = true;
}
}
struct Spans;
impl RenderHooks for Spans {
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
event.needs.entity_spans = event.needs.world_depth;
}
}
#[test]
fn needs_accumulate_in_mod_order() {
let mut needs = RenderNeeds::default();
generated::dispatch_render_needs(&[&Depth, &Spans], &mut needs);
assert_eq!(
needs,
RenderNeeds {
world_depth: true,
entity_spans: true
}
);
}
}
@@ -0,0 +1,36 @@
// @generated by cargo run -p xtask -- mod-api; do not edit.
use super::*;
pub trait RenderHooks: Send + Sync {
/// The world is drawn (see [`WorldDrawn`]). Not called on the title screen's panorama.
fn world_drawn(&self, event: &mut WorldDrawnEvent<'_, '_>) {
let _ = event;
}
/// Asked as a frame is built and drawn: set what your mod needs.
///
/// Every mod gets `event.needs` in order and may add, change or remove needs.
fn render_needs(&self, event: &mut RenderNeedsEvent<'_>) {
let _ = event;
}
}
/// What [`RenderHooks::world_drawn`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct WorldDrawnEvent<'a, 'b> {
pub world: &'a mut WorldDrawn<'b>,
}
pub fn dispatch_world_drawn(mods: &[&dyn RenderHooks], world: &mut WorldDrawn<'_>) {
let mut event = WorldDrawnEvent { world };
for mod_hook in mods {
mod_hook.world_drawn(&mut event);
}
}
/// What [`RenderHooks::render_needs`] is told. Fields may be added: the struct is `#[non_exhaustive]`.
#[non_exhaustive]
pub struct RenderNeedsEvent<'a> {
pub needs: &'a mut RenderNeeds,
}
pub fn dispatch_render_needs(mods: &[&dyn RenderHooks], needs: &mut RenderNeeds) {
let mut event = RenderNeedsEvent { needs };
for mod_hook in mods {
mod_hook.render_needs(&mut event);
}
}
+15 -1
View File
@@ -7,8 +7,9 @@
//! ([`Deref`]) first waits for that frame to be submitted, so writes, copies and
//! submissions keep the order they had on one thread.
use std::ops::Deref;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Mutex, mpsc};
use std::time::{Duration, Instant};
/// Whether frames go to the submit thread, unless `HORIZON_SUBMIT_THREAD` says (`0` off, `1` on):
/// on Linux, where it was measured faster (+32% FPS in a CPU-bound view on Vulkan, #5779). On
@@ -50,6 +51,9 @@ pub struct Queue {
/// A frame went to the submit thread and has not been waited for.
pending: AtomicBool,
landing: Mutex<Option<Landing>>,
/// Nanoseconds the window's thread waited for the submit thread since the last
/// [`take_waited`](Self::take_waited): the GPU or the display holding a frame back, not work.
waited: AtomicU64,
}
impl Queue {
@@ -60,6 +64,7 @@ impl Queue {
thread: None,
pending: AtomicBool::new(false),
landing: Mutex::new(None),
waited: AtomicU64::new(0),
};
if !threaded {
return this;
@@ -130,15 +135,24 @@ impl Queue {
return;
}
if let Some(l) = landing.as_mut() {
let started = Instant::now();
// A thread that died (a wgpu panic on it) panics here, as it would have in place.
let submission = l
.done
.recv()
.expect("Horizon renderer: the submit thread stopped");
l.landed = Some(submission);
let nanos = u64::try_from(started.elapsed().as_nanos()).unwrap_or(u64::MAX);
self.waited.fetch_add(nanos, Ordering::Relaxed);
}
self.pending.store(false, Ordering::Release);
}
/// How long the window's thread waited for the submit thread since the last call: in a
/// GPU-bound view, the previous frame's submission and present hold up every use of the
/// queue (uploads in the frame's first half too), and that is waiting, not the frame's work.
pub fn take_waited(&self) -> Duration {
Duration::from_nanos(self.waited.swap(0, Ordering::Relaxed))
}
/// The submission of the frame [`submit_later`](Self::submit_later) took, once it is
/// made; `None` if it was taken already.
pub fn landed(&self) -> Option<wgpu::SubmissionIndex> {
+23 -6
View File
@@ -19,20 +19,28 @@ pub const HDR_MARKER: &str = "horizon-hdr-pending";
pub struct Reported {
pub paper_white: u32,
pub peak: u32,
/// The display's darkest black, in hundredths of a nit.
pub black: u32,
}
static REPORTED: [AtomicU32; 2] = [AtomicU32::new(0), AtomicU32::new(0)];
static REPORTED: [AtomicU32; 3] = [AtomicU32::new(0), AtomicU32::new(0), AtomicU32::new(0)];
/// Sets what the system reports (the renderer, when it opens its surface).
pub fn report(reported: Option<Reported>) {
let (paper_white, peak) = reported.map_or((0, 0), |r| (r.paper_white, r.peak));
let (paper_white, peak, black) =
reported.map_or((0, 0, 0), |r| (r.paper_white, r.peak, r.black));
REPORTED[0].store(paper_white, Ordering::Relaxed);
REPORTED[1].store(peak, Ordering::Relaxed);
REPORTED[2].store(black, Ordering::Relaxed);
}
/// What the system reports, if it does.
pub fn reported() -> Option<Reported> {
let [paper_white, peak] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
(peak > 0).then_some(Reported { paper_white, peak })
let [paper_white, peak, black] = REPORTED.each_ref().map(|n| n.load(Ordering::Relaxed));
(peak > 0).then_some(Reported {
paper_white,
peak,
black,
})
}
/// Simple-level presets. `Custom` leaves every option as the player set it.
@@ -582,8 +590,9 @@ pub struct Graphics {
/// HDR peak brightness the world's highlights reach, in nits; 0 is Auto:
/// what the system reports for the display ([`Graphics::peak_nits`]).
pub hdr_peak: u32,
/// HDR black level: the display's darkest black, in hundredths of a nit (0 for
/// an OLED's true black). Shadows are lifted onto it, and the HDR metadata says it.
/// HDR black level: the display's darkest black, in hundredths of a nit; 0 is Auto:
/// what the system reports, else true black. Shadows are lifted onto it, and the HDR
/// metadata says it.
pub hdr_black_level: u32,
/// HDR brightness of the HUD and menus, in percent of paper white (25..=100); the
/// first-person hand stays at the world's.
@@ -830,6 +839,14 @@ impl Graphics {
n => n,
}
}
/// The black level, in nits: the player's, or on Auto what the system reports, else 0.
pub fn black_level_nits(&self) -> f32 {
let hundredths = match self.hdr_black_level {
0 => reported().map_or(0, |r| r.black),
n => n,
};
hundredths as f32 / 100.0
}
/// Worker threads for chunk generation and meshing.
pub fn worker_threads(&self) -> usize {
if self.workers > 0 {
+10 -13
View File
@@ -606,24 +606,21 @@ impl H {
H::HdrPaperWhite => match g.hdr_paper_white {
0 => labelled(
"Paper White",
lang::tr("Auto (%s nits)", &[&g.paper_white_nits()]),
lang::tr("Auto (%s)", &[&g.paper_white_nits()]),
),
n => labelled("Paper White", lang::tr("%s nits", &[&n])),
n => labelled("Paper White", n.to_string()),
},
H::HdrPeak => match g.hdr_peak {
0 => labelled(
"Peak Brightness",
lang::tr("Auto (%s nits)", &[&g.peak_nits()]),
),
n => labelled("Peak Brightness", lang::tr("%s nits", &[&n])),
0 => labelled("Peak Brightness", lang::tr("Auto (%s)", &[&g.peak_nits()])),
n => labelled("Peak Brightness", n.to_string()),
},
H::HdrBlackLevel => labelled(
"Black Level",
lang::tr(
"%s nits",
&[&format!("{:.2}", g.hdr_black_level as f32 / 100.0)],
H::HdrBlackLevel => match g.hdr_black_level {
0 => labelled(
"Black Level",
lang::tr("Auto (%s)", &[&format!("{:.2}", g.black_level_nits())]),
),
),
n => labelled("Black Level", format!("{:.2}", n as f32 / 100.0)),
},
H::HdrUiBrightness => labelled("UI Brightness", format!("{}%", g.hdr_ui_brightness)),
H::HdrKey => {
Text::translate("Shortcut: %s", &[crate::keybinds::display_name(&g.hdr_key)])
@@ -49,7 +49,7 @@ pub struct Calibration {
values: [f32; 3],
/// Which values were Auto (peak, paper white): saved as Auto again unless the
/// player moved them.
auto: [Option<f32>; 2],
auto: [Option<f32>; 3],
dragging: bool,
}
@@ -125,11 +125,12 @@ impl Calibration {
Self {
step: 0,
values: [
g.hdr_black_level as f32 / 100.0,
g.black_level_nits(),
g.peak_nits() as f32,
g.paper_white_nits() as f32,
],
auto: [
(g.hdr_black_level == 0).then_some(g.black_level_nits()),
(g.hdr_peak == 0).then_some(g.peak_nits() as f32),
(g.hdr_paper_white == 0).then_some(g.paper_white_nits() as f32),
],
@@ -146,15 +147,11 @@ impl Calibration {
fn label(&self) -> Text {
let v = self.values[self.step];
let step = self.current();
let nits = match step {
let value = match step {
Step::Black => format!("{v:.2}"),
_ => format!("{v:.0}"),
};
Text::literal(format!(
"{}: {}",
step.name(),
lang::tr("%s nits", &[&nits])
))
Text::literal(format!("{}: {}", step.name(), value))
}
/// The step, its nits and where the controls start (a share of the height), for
/// HDR output's test picture.
@@ -284,11 +281,12 @@ impl Calibration {
/// Writes what the player landed on into Horizon's settings.
pub fn save(&self, s: &mut Settings) {
let g = &mut s.horizon;
g.hdr_black_level = (self.values[0] * 100.0).round() as u32;
let [peak, paper_white] = [1, 2].map(|i| match self.auto[i - 1] {
let scale = [100.0, 1.0, 1.0];
let [black, peak, paper_white] = [0, 1, 2].map(|i| match self.auto[i] {
Some(auto) if auto == self.values[i] => 0,
_ => self.values[i].round() as u32,
_ => (self.values[i] * scale[i]).round() as u32,
});
g.hdr_black_level = black;
g.hdr_peak = peak;
g.hdr_paper_white = paper_white;
}
+118 -24
View File
@@ -10,6 +10,7 @@ use std::{
};
use wgpu::util::DeviceExt;
pub mod cit_glint;
mod pack_text;
/// A sprite's atlas region, and its size in GUI pixels: the size of the
@@ -247,16 +248,23 @@ fn wrap_border((name, w, h, data): &PackedImage) -> PackedImage {
}
(name.clone(), bw, bh, out)
}
/// The textures the enabled packs' Custom Item Textures item rules draw, under their
/// `cit_assets` ids, beyond those already packed; with their flags and whether they animate.
/// They are item textures, so an animated strip keeps its first frame. None without
/// `optifine/cit` rules.
/// The textures the enabled packs' Custom Item Textures rules draw, beyond those already packed:
/// item rules' textures and their JSON models', armour and elytra rules' textures, under their
/// `cit_assets` ids, with their flags and whether they animate. An animated strip keeps its
/// first frame; a still texture is packed whole. None without `optifine/cit` rules.
fn cit_sprites(
root: &Path,
packed: &[PackedImage],
) -> Vec<(PackedImage, Option<TextureFlags>, bool)> {
let have: HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
let ids: Vec<String> = crate::cit_assets::item_sprites(&crate::cit::load_rules(root))
let rules = crate::cit::load_rules(root);
let mut ids = crate::cit_assets::item_sprites(&rules);
if !rules.is_empty() {
let models = crate::cit_assets::load_models(root, &rules);
ids.extend(crate::cit_assets::model_sprites(&models));
ids.extend(crate::cit_armor::armor_sprites(&rules));
}
let ids: Vec<String> = ids
.into_iter()
// Vanilla's sprites are packed without their namespace, as `sprite_name` looks them up;
// Horizon's own (`horizon/white`, the run-time slots) are added after, and stay its own.
@@ -664,25 +672,21 @@ impl HudAtlas {
/// packs fill a 4096-wide atlas past what GPUs allow (Faithful 64x needs
/// 32768 rows); vanilla's stitcher fails the reload when its atlases don't
/// fit, and the packs are then turned off (`Minecraft.clearResourcePacksOnError`).
/// A width where the plain shelves (each image after the last) leave too
/// much unused is tried again with each image in the first shelf with
/// room: a pack of thousands of mixed sizes (server packs with custom
/// items) fits where the shelves' ragged ends would not.
fn layout(images: &[PackedImage], max: u32) -> Result<(u32, Vec<(u32, u32)>, u32)> {
let mut width = 4096.min(max);
loop {
let mut placements = Vec::with_capacity(images.len());
let (mut x, mut y, mut row) = (0u32, 0u32, 0u32);
for (_, w, h, _) in images {
if x + w + 1 > width {
x = 0;
y += row + 1;
row = 0;
}
placements.push((x, y));
x += w + 1;
row = row.max(*h);
}
let glyph_region = y + row + 1;
let wide_enough = images.iter().all(|i| i.1 < width);
if wide_enough && glyph_region + GLYPH_ROWS <= max {
return Ok((width, placements, glyph_region));
if wide_enough {
for first_fit in [false, true] {
let (placements, glyph_region) = Self::shelves(images, width, first_fit);
if glyph_region + GLYPH_ROWS <= max {
return Ok((width, placements, glyph_region));
}
}
}
if width >= max {
let texels: u64 = images.iter().map(|i| u64::from(i.1) * u64::from(i.2)).sum();
@@ -695,6 +699,33 @@ impl HudAtlas {
width = (width * 2).min(max);
}
}
/// `layout`'s shelves at one width: the placements and the first row below
/// them. `first_fit` puts an image in the first shelf that is tall enough
/// and has room, instead of only the last one.
fn shelves(images: &[PackedImage], width: u32, first_fit: bool) -> (Vec<(u32, u32)>, u32) {
// Each shelf: its top row, its height (its first image's) and the next free column.
let mut shelves: Vec<(u32, u32, u32)> = Vec::new();
let mut placements = Vec::with_capacity(images.len());
for (_, w, h, _) in images {
let open = if first_fit {
shelves.iter().position(|s| s.1 >= *h && s.2 + w < width)
} else {
shelves
.len()
.checked_sub(1)
.filter(|&i| shelves[i].1 >= *h && shelves[i].2 + w < width)
};
let i = open.unwrap_or_else(|| {
let top = shelves.last().map_or(0, |s| s.0 + s.1 + 1);
shelves.push((top, *h, 0));
shelves.len() - 1
});
placements.push((shelves[i].2, shelves[i].0));
shelves[i].2 += w + 1;
}
let glyph_region = shelves.last().map_or(1, |s| s.0 + s.1 + 1);
(placements, glyph_region)
}
/// Pack the textures under these folders of `textures/`, and for the
/// `full` atlas the generated sprites and the run-time slots too.
fn pack(root: &Path, defaults: &Path, folders: &[&str], full: bool) -> Result<Self> {
@@ -1155,6 +1186,9 @@ pub enum Blend {
/// `RenderPipelines.END_PORTAL` as a GUI fill: the End portal's layers
/// projected from the screen (`HudBatch::end_portal` sets the textures).
EndPortal,
/// An OptiFine CIT glint layer (`cit_glint`), blended as entry `n` of
/// `GlintTextures::blends` says.
CitGlint(u8),
}
/// What a `Blend::EndPortal` fill samples: the atlas rects (u0, v0, width,
/// height) of `end_sky.png` and `end_portal.png`, and the `GameTime` uniform.
@@ -1889,7 +1923,11 @@ pub struct HudRenderer {
depth_pipeline: wgpu::RenderPipeline,
vignette_pipeline: wgpu::RenderPipeline,
end_portal_pipeline: wgpu::RenderPipeline,
/// The layout of `group`, which the CIT glint pipelines share.
layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
/// The pack's CIT enchantment glints, when it has any.
cit_glint: Option<cit_glint::CitGlintPass>,
uniform: wgpu::Buffer,
buffer: wgpu::Buffer,
capacity: u64,
@@ -2122,7 +2160,9 @@ impl HudRenderer {
depth_pipeline,
vignette_pipeline,
end_portal_pipeline,
layout,
group,
cit_glint: None,
uniform,
buffer,
capacity,
@@ -2132,6 +2172,18 @@ impl HudRenderer {
zoom: 1.0,
})
}
/// Draw the CIT glint layers of `textures` (the pack's enchantment rules); `None` or no
/// textures draws none.
pub fn set_cit_glints(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
textures: Option<&cit_glint::GlintTextures>,
) {
self.cit_glint = textures.and_then(|t| {
cit_glint::CitGlintPass::new(device, queue, self.format, &self.layout, t)
});
}
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
pub fn set_text_shader(
&mut self,
@@ -2367,6 +2419,12 @@ impl HudRenderer {
Blend::Depth(_) => &self.depth_pipeline,
Blend::Vignette => &self.vignette_pipeline,
Blend::EndPortal => &self.end_portal_pipeline,
Blend::CitGlint(blend) => {
if let Some(cit) = &self.cit_glint {
cit.draw(&mut pass, *blend, range.clone());
}
continue;
}
};
match &self.pack_text {
Some(pack_text) if *blend == Blend::Alpha => {
@@ -2827,6 +2885,25 @@ mod tests {
assert_eq!(texel_at(&atlas, rect, 1, 1), [10, 200, 30, 255]);
}
#[test]
fn a_cit_models_relative_texture_is_packed() {
let root = cit_root(
"model",
&[
("a.properties", "items=stick\nmodel=sword"),
(
"sword.json",
r#"{"parent":"item/generated","textures":{"layer0":"./blade"}}"#,
),
],
);
let cit = root.join("assets/minecraft/optifine/cit");
write_strip(&cit.join("blade.png"), 2, 2, &[[40, 50, 60, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/blade"];
assert_eq!(texel_at(&atlas, rect, 1, 1), [40, 50, 60, 255]);
}
#[test]
fn a_still_cit_texture_is_packed_whole() {
// Not cut to a square as an unmarked strip would be: armour layers are 2:1.
let root = cit_root("still", &[("a.properties", "items=stick\ntexture=a")]);
@@ -2862,7 +2939,7 @@ mod tests {
assert_eq!(animations[0].1.frame_count(), 3);
}
#[test]
fn a_texture_only_an_armor_rule_names_is_not_packed() {
fn an_armor_rules_texture_is_packed_too() {
let root = cit_root(
"armor",
&[(
@@ -2872,11 +2949,15 @@ mod tests {
);
let cit = root.join("assets/minecraft/optifine/cit");
write_strip(&cit.join("a.png"), 64, 32, &[[1, 2, 3, 255]], None);
// The rule loads; only its kind keeps the texture out.
assert_eq!(crate::cit::load_rules(&root).len(), 1);
let rules = crate::cit::load_rules(&root);
assert_eq!(rules.len(), 1);
let atlas = HudAtlas::build(&root).unwrap();
let _ = fs::remove_dir_all(&root);
assert!(!atlas.sprites.keys().any(|k| k.starts_with("horizon_cit:")));
// Worn armour draws from the same atlas, under the name the layer looks up.
let rect = atlas.sprites["horizon_cit:minecraft/optifine/cit/a"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (64, 32));
let layer = crate::cit_armor::layer_sprite(&rules[0], "minecraft:iron", false, false);
assert!(atlas.sprites.contains_key(&layer.unwrap()));
}
#[test]
fn named_cit_textures_pack_once_and_bad_ones_are_skipped() {
@@ -3104,6 +3185,19 @@ texture=wide",
assert_inside(&images, width, &placements, free);
}
#[test]
fn mixed_sizes_fill_the_ends_of_the_shelves() {
// 15 images 9000 wide leave 7383 of a 16384 shelf unused each; the
// 4000-wide ones (same height) come after them, and on shelves of their
// own (4 to a shelf) the atlas is 19 shelves tall: past 16384 rows.
// Each in a gap, it is 15.
let mut images = sized(15, 9000, 1000);
images.extend(sized(15, 4000, 1000));
let (width, placements, free) = HudAtlas::layout(&images, 16384).unwrap();
assert_eq!(width, 16384);
assert!(free + GLYPH_ROWS <= 16384);
assert_inside(&images, width, &placements, free);
}
#[test]
fn textures_that_cannot_fit_are_an_error_not_a_gpu_panic() {
let error = HudAtlas::layout(&sized(300, 1024, 1024), MAX_ATLAS_SIZE).unwrap_err();
assert!(error.to_string().contains("do not fit a 16384x16384 atlas"));
+29
View File
@@ -41,6 +41,35 @@ struct Out {
if c.a < 0.004 { discard; }
return c;
}
// OptiFine CIT glint layers (hud/cit_glint.rs): every rule's texture in one array, the slot in
// the vertex colour's red and the layer's intensity in its alpha. Masked by the item's texture
// as the vanilla glint is, then blended by the pipeline the rule's `blend` asks for.
struct CitSlot {
rgba: vec4<f32>,
// x: linear filtering (`blur`).
blur: vec4<f32>,
}
struct CitSlots {
slots: array<CitSlot, 256>,
}
@group(1) @binding(0) var cit_tex: texture_2d_array<f32>;
@group(1) @binding(1) var cit_blur: sampler;
@group(1) @binding(2) var cit_nearest: sampler;
@group(1) @binding(3) var<uniform> cit: CitSlots;
@fragment fn fs_cit_glint(in: Out) -> @location(0) vec4<f32> {
let mask = textureSample(atlas, atlas_sampler, in.uv).a;
let index = u32(round(in.color.r * 255.0));
let slot = cit.slots[index];
let blurred = textureSampleLevel(cit_tex, cit_blur, in.glint_uv, index, 0.0);
let sharp = textureSampleLevel(cit_tex, cit_nearest, in.glint_uv, index, 0.0);
if mask < 0.1 { discard; }
// The layer's colour times its intensity (the glint strength option included) in alpha,
// cut where vanilla's glint shader cuts, which CIT Resewn draws through.
var t = select(sharp, blurred, slot.blur.x > 0.5) * slot.rgba;
t.a *= in.color.a;
if t.a < 0.1 { discard; }
return t;
}
@fragment fn fs_crosshair(in: Out) -> @location(0) vec4<f32> {
let c = textureSample(atlas, atlas_sampler, in.uv) * in.color;
if c.a == 0.0 { discard; }
@@ -0,0 +1,563 @@
//! OptiFine CIT enchantment glints on GUI items (`type=enchantment`, see `crate::cit_glint` for
//! which layers a stack draws): every rule's texture in one texture array, each layer an extra
//! pass over the item's quads with its rule's blend. A layer's vertices carry its texture slot
//! (red) and its intensity (alpha) in their colour; its scrolled UV is worked out on the CPU, as
//! vanilla's glint's is. Without enchantment rules nothing here is made.
use super::HudVertex;
use horizon_optifine::cit::{Blend, Factor, Kind, Rule};
use std::path::Path;
/// Slots the shader's table holds; rules past it draw no layer (and match no stack).
pub const MAX_SLOTS: usize = 256;
/// The largest side the shared texture size takes: bigger glints are scaled down to it.
const MAX_SIDE: u32 = 1024;
/// The most the texture array takes, in bytes: past it the shared size is halved.
const MAX_BYTES: u64 = 64 << 20;
/// One slot's constants.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Slot {
/// The rule's `r`, `g`, `b`, `a`.
pub rgba: [f32; 4],
/// Linear filtering (`blur`), else nearest.
pub blur: bool,
/// An index into `GlintTextures::blends`.
pub blend: u8,
}
/// The enchantment rules' textures, resized to one size.
#[derive(Debug, Default)]
pub struct GlintTextures {
pub width: u32,
pub height: u32,
/// One `width` x `height` RGBA image per slot, slot after slot. Kept after the upload: every
/// split-screen player's renderer builds its own array from it.
pub pixels: Vec<u8>,
/// Per rule (an index into `CitRules::rules`): its slot; `None` for other kinds of rules,
/// textures that did not load and rules past `MAX_SLOTS`.
pub slot_of: Vec<Option<u8>>,
pub slots: Vec<Slot>,
/// The blends the slots use, once each: the HUD makes a pipeline per entry.
pub blends: Vec<Option<Blend>>,
}
impl GlintTextures {
/// The textures of `rules` under the overlay resource root.
pub fn load(root: &Path, rules: &[Rule]) -> Self {
let mut images = Vec::new();
let mut past = 0;
for (i, rule) in rules.iter().enumerate() {
let Some(path) = rule.texture.as_deref() else {
continue;
};
if rule.kind != Kind::Enchantment {
continue;
}
if images.len() == MAX_SLOTS {
past += 1;
continue;
}
match super::decode(&root.join(path)) {
Ok(image) => images.push((i, image)),
Err(e) => log::warn!("custom item textures: glint {path} left out: {e}"),
}
}
if past > 0 {
log::warn!(
"custom item textures: {past} glint rules past the first {MAX_SLOTS} left out"
);
}
Self::new(rules, images, MAX_BYTES)
}
/// `images`: (rule, (width, height, RGBA)), in at most `budget` bytes.
fn new(rules: &[Rule], images: Vec<(usize, (u32, u32, Vec<u8>))>, budget: u64) -> Self {
let sizes = images.iter().map(|(_, (w, h, _))| (*w, *h));
let Some((width, height)) = shared_size(sizes, images.len(), budget) else {
return Self::default();
};
let mut out = Self {
width,
height,
slot_of: vec![None; rules.len()],
..Self::default()
};
for (rule, (w, h, rgba)) in images {
let glint = &rules[rule].glint;
out.pixels
.extend(resize(&rgba, (w, h), (width, height), glint.blur));
let blend = match out.blends.iter().position(|b| *b == glint.blend) {
Some(i) => i,
None => {
out.blends.push(glint.blend);
out.blends.len() - 1
}
};
out.slot_of[rule] = Some(out.slots.len() as u8);
out.slots.push(Slot {
rgba: glint.rgba,
blur: glint.blur,
blend: blend as u8,
});
}
out
}
pub fn is_empty(&self) -> bool {
self.slots.is_empty()
}
}
/// The size every slot takes: the largest width and height of `sizes`, each at most
/// `MAX_SIDE`, then halved until `layers` RGBA images of it fit in `budget` bytes.
fn shared_size(
sizes: impl Iterator<Item = (u32, u32)>,
layers: usize,
budget: u64,
) -> Option<(u32, u32)> {
let (mut w, mut h) = sizes.fold(None, |max: Option<(u32, u32)>, (w, h)| {
let (w, h) = (w.clamp(1, MAX_SIDE), h.clamp(1, MAX_SIDE));
Some(max.map_or((w, h), |(mw, mh)| (mw.max(w), mh.max(h))))
})?;
let bytes = |w: u32, h: u32| u64::from(w) * u64::from(h) * 4 * layers as u64;
while bytes(w, h) > budget && (w > 1 || h > 1) {
(w, h) = ((w / 2).max(1), (h / 2).max(1));
}
Some((w, h))
}
/// `rgba` (`from` texels) scaled to `to`: bilinearly for a `blur` glint, as the GPU would have
/// filtered the texture at its own size, else to the nearest texel. Both wrap at the edges, as
/// the glint repeats over the item.
fn resize(rgba: &[u8], from: (u32, u32), to: (u32, u32), smooth: bool) -> Vec<u8> {
if from == to {
return rgba.to_vec();
}
let ((w, h), (to_w, to_h)) = (from, to);
let texel = |x: i64, y: i64| {
let (x, y) = (x.rem_euclid(i64::from(w)), y.rem_euclid(i64::from(h)));
let at = (y as usize * w as usize + x as usize) * 4;
&rgba[at..at + 4]
};
// Where texel `i` of `n` samples the source's `size` texels, in source texels.
let centre = |i: u32, n: u32, size: u32| (f64::from(i) + 0.5) * f64::from(size) / f64::from(n);
let mut out = Vec::with_capacity(to_w as usize * to_h as usize * 4);
for y in 0..to_h {
let sy = centre(y, to_h, h);
for x in 0..to_w {
let sx = centre(x, to_w, w);
if !smooth {
let pick = |i: u32, n: u32, size: u32| {
(u64::from(i) * u64::from(size) / u64::from(n)) as i64
};
out.extend_from_slice(texel(pick(x, to_w, w), pick(y, to_h, h)));
continue;
}
let (fx, fy) = (sx - 0.5, sy - 0.5);
let (x0, y0) = (fx.floor() as i64, fy.floor() as i64);
let (tx, ty) = (fx - fx.floor(), fy - fy.floor());
let [a, b, c, d] = [
texel(x0, y0),
texel(x0 + 1, y0),
texel(x0, y0 + 1),
texel(x0 + 1, y0 + 1),
];
let lerp =
|p: &[u8], q: &[u8], k: usize| f64::from(p[k]) * (1.0 - tx) + f64::from(q[k]) * tx;
out.extend((0..4).map(|k| {
let (top, bottom) = (lerp(a, b, k), lerp(c, d, k));
(top * (1.0 - ty) + bottom * ty).round() as u8
}));
}
}
out
}
/// CIT Resewn's glint texturing for one layer: `translation(-x, y)`, `rotateZ(rotation)`, then
/// `scale`, where `x` and `y` run through vanilla's 110000 and 30000 periods of the clock
/// `millis * speed` (vanilla's own glint runs at `millis * glintSpeed * 8`). `rotation` is in
/// degrees, as OptiFine's doc has it.
pub fn layer_uv(model: [f32; 2], millis: f32, speed: f32, rotation: f32, scale: f32) -> [f32; 2] {
let clock = f64::from(millis) * f64::from(speed);
let x = (clock % 110_000.0 / 110_000.0) as f32;
let y = (clock % 30_000.0 / 30_000.0) as f32;
let (sin, cos) = rotation.to_radians().sin_cos();
let (u, v) = (model[0] * scale, model[1] * scale);
[u * cos - v * sin - x, u * sin + v * cos + y]
}
/// The colour of a layer's vertices: its slot in red, its intensity in alpha.
pub fn vertex_color(slot: u8, intensity: f32) -> u32 {
let alpha = (intensity.clamp(0.0, 1.0) * 255.0).round() as u8;
super::rgba(slot, 0, 0, alpha)
}
fn factor(f: Factor) -> wgpu::BlendFactor {
use wgpu::BlendFactor as W;
match f {
Factor::Zero => W::Zero,
Factor::One => W::One,
Factor::SrcColor => W::Src,
Factor::OneMinusSrcColor => W::OneMinusSrc,
Factor::SrcAlpha => W::SrcAlpha,
Factor::OneMinusSrcAlpha => W::OneMinusSrcAlpha,
Factor::DstAlpha => W::DstAlpha,
Factor::OneMinusDstAlpha => W::OneMinusDstAlpha,
Factor::DstColor => W::Dst,
Factor::OneMinusDstColor => W::OneMinusDst,
Factor::SrcAlphaSaturate => W::SrcAlphaSaturated,
}
}
/// `glBlendFuncSeparate` with the rule's factors; `replace` draws unblended.
pub fn blend_state(blend: Option<Blend>) -> wgpu::BlendState {
let Some(b) = blend else {
return wgpu::BlendState::REPLACE;
};
let component = |src, dst| wgpu::BlendComponent {
src_factor: factor(src),
dst_factor: factor(dst),
operation: wgpu::BlendOperation::Add,
};
wgpu::BlendState {
color: component(b.src, b.dst),
alpha: component(b.src_alpha, b.dst_alpha),
}
}
/// The GPU side: the texture array and slot table (bind group 1 of `fs_cit_glint`), and one
/// pipeline per blend.
pub(super) struct CitGlintPass {
group: wgpu::BindGroup,
pipelines: Vec<wgpu::RenderPipeline>,
}
impl CitGlintPass {
/// `None` without textures. `hud` is the HUD's bind group layout (group 0).
pub(super) fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
format: wgpu::TextureFormat,
hud: &wgpu::BindGroupLayout,
textures: &GlintTextures,
) -> Option<Self> {
use wgpu::util::DeviceExt;
if textures.is_empty() {
return None;
}
// wgpu's OpenGL backend makes 1 layer a 2D texture and 6n a cube map
// (`gfx::array_layers`): pad with empty layers, within the GPU's limit. Slots past the
// layers kept sample the last one.
let gl = device.adapter_info().backend == wgpu::Backend::Gl;
let max = device.limits().max_texture_array_layers.max(1);
let slots = textures.slots.len() as u32;
let mut kept = slots.min(max);
while kept > 1 && crate::gfx::array_layers(kept, gl) > max {
kept -= 1;
}
if kept < slots {
log::warn!("custom item textures: the GPU holds {kept} of {slots} glint textures");
}
let layers = crate::gfx::array_layers(kept, gl).min(max);
let layer_bytes = textures.width as usize * textures.height as usize * 4;
let mut data = std::borrow::Cow::Borrowed(&textures.pixels[..]);
if layers != slots {
let mut padded = textures.pixels[..layer_bytes * kept as usize].to_vec();
padded.resize(layer_bytes * layers as usize, 0);
data = std::borrow::Cow::Owned(padded);
}
let texture = device.create_texture_with_data(
queue,
&wgpu::TextureDescriptor {
label: Some("CIT glints"),
size: wgpu::Extent3d {
width: textures.width,
height: textures.height,
depth_or_array_layers: layers,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&data,
);
let view = texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
});
let sampler = |label, filter| {
device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(label),
address_mode_u: wgpu::AddressMode::Repeat,
address_mode_v: wgpu::AddressMode::Repeat,
mag_filter: filter,
min_filter: filter,
..Default::default()
})
};
let linear = sampler("CIT glint blur", wgpu::FilterMode::Linear);
let nearest = sampler("CIT glint", wgpu::FilterMode::Nearest);
// hud.wgsl's `CitSlot`: rgba, then blur in x.
let mut table = vec![[0.0f32; 8]; MAX_SLOTS];
for (row, slot) in table.iter_mut().zip(&textures.slots) {
row[..4].copy_from_slice(&slot.rgba);
row[4] = if slot.blur { 1.0 } else { 0.0 };
}
let table = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("CIT glint slots"),
contents: bytemuck::cast_slice(&table),
usage: wgpu::BufferUsages::UNIFORM,
});
let fragment = wgpu::ShaderStages::FRAGMENT;
let filtering = wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering);
let entry = |binding, ty| wgpu::BindGroupLayoutEntry {
binding,
visibility: fragment,
ty,
count: None,
};
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("CIT glint bindings"),
entries: &[
entry(
0,
wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
),
entry(1, filtering),
entry(2, filtering),
entry(
3,
wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
),
],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("CIT glint resources"),
layout: &layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&linear),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(&nearest),
},
wgpu::BindGroupEntry {
binding: 3,
resource: table.as_entire_binding(),
},
],
});
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Horizon HUD (CIT glints)"),
source: wgpu::ShaderSource::Wgsl(include_str!("../hud.wgsl").into()),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("CIT glint layout"),
bind_group_layouts: &[Some(hud), Some(&layout)],
..Default::default()
});
let attributes =
wgpu::vertex_attr_array![0=>Float32x2,1=>Float32x2,2=>Unorm8x4,3=>Float32,4=>Float32x2];
let pipelines = textures
.blends
.iter()
.map(|&blend| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("CIT glint"),
layout: Some(&pipeline_layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("vs_main"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<HudVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("fs_cit_glint"),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(blend_state(blend)),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
})
})
.collect();
Some(Self { group, pipelines })
}
/// Draw `vertices` with blend `blend` (an index into `GlintTextures::blends`).
pub(super) fn draw(
&self,
pass: &mut wgpu::RenderPass<'_>,
blend: u8,
vertices: std::ops::Range<u32>,
) {
if let Some(pipeline) = self.pipelines.get(usize::from(blend)) {
pass.set_pipeline(pipeline);
pass.set_bind_group(1, &self.group, &[]);
pass.draw(vertices, 0..1);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::Properties;
use horizon_optifine::cit::parse_blend;
fn rule(name: &str, text: &str) -> Rule {
let path = format!("assets/minecraft/optifine/cit/{name}.properties");
let text = format!("texture=glint\n{text}");
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
}
#[test]
fn slots_share_one_size_and_their_blends() {
let rules = [
rule("a", "type=enchantment\nr=0.5"),
rule("item", "items=stick"),
rule("b", "type=enchantment\nblend=multiply\nblur=false"),
rule("c", "type=enchantment"),
];
// A 1x1 red glint and a 2x2 one: the red texel fills 2x2.
let red = vec![255, 0, 0, 255];
let quad: Vec<u8> = (0..16).collect();
let t = GlintTextures::new(
&rules,
vec![
(0, (1, 1, red)),
(2, (2, 2, quad.clone())),
(3, (2, 2, quad.clone())),
],
MAX_BYTES,
);
assert_eq!((t.width, t.height), (2, 2));
assert_eq!(t.slot_of, [Some(0), None, Some(1), Some(2)]);
assert_eq!(&t.pixels[..16], [255, 0, 0, 255].repeat(4));
assert_eq!(&t.pixels[16..32], &quad[..]);
assert_eq!(
t.blends,
[
parse_blend("add").unwrap(),
parse_blend("multiply").unwrap()
]
);
let blends: Vec<_> = t.slots.iter().map(|s| s.blend).collect();
assert_eq!(blends, [0, 1, 0]);
assert_eq!(t.slots[0].rgba, [0.5, 1.0, 1.0, 1.0]);
assert!(!t.slots[1].blur && t.slots[2].blur);
assert!(GlintTextures::new(&rules, Vec::new(), MAX_BYTES).is_empty());
}
#[test]
fn big_glints_are_scaled_down_to_the_largest_side() {
let rules = [rule("wide", "type=enchantment\nblur=false")];
// 2048 x 1, texel x holding x in red and green: every other texel is kept.
let wide: Vec<u8> = (0..2048u32)
.flat_map(|x| [(x % 256) as u8, (x / 256) as u8, 0, 255])
.collect();
let t = GlintTextures::new(&rules, vec![(0, (2048, 1, wide))], MAX_BYTES);
assert_eq!((t.width, t.height), (1024, 1));
for x in [0u32, 1, 300, 1023] {
let at = x as usize * 4;
let source = 2 * x;
let want = [(source % 256) as u8, (source / 256) as u8, 0, 255];
assert_eq!(t.pixels[at..at + 4], want, "texel {x}");
}
}
#[test]
fn the_array_is_halved_until_it_fits() {
let sizes = |n: usize| std::iter::repeat_n((1024, 1024), n);
// 16 layers of 1024 x 1024 RGBA are 64 MiB; a 17th halves both sides.
assert_eq!(shared_size(sizes(16), 16, 64 << 20), Some((1024, 1024)));
assert_eq!(shared_size(sizes(17), 17, 64 << 20), Some((512, 512)));
// The largest of each side, never past MAX_SIDE.
let mixed = [(16, 4096), (64, 32)].into_iter();
assert_eq!(shared_size(mixed, 2, 64 << 20), Some((64, 1024)));
assert_eq!(shared_size(std::iter::empty(), 0, 64 << 20), None);
}
#[test]
fn blurred_glints_grow_smoothly_and_sharp_ones_by_texel() {
// Black then white, doubled: the wrapping blend of the two, or each texel twice.
let row = [0, 0, 0, 255, 255, 255, 255, 255];
let red = |rgba: Vec<u8>| rgba.chunks(4).map(|t| t[0]).collect::<Vec<_>>();
assert_eq!(red(resize(&row, (2, 1), (4, 1), true)), [64, 64, 191, 191]);
assert_eq!(red(resize(&row, (2, 1), (4, 1), false)), [0, 0, 255, 255]);
}
#[test]
fn a_layer_vertex_carries_its_slot_and_intensity() {
// Unorm8x4 reads the colour's bytes in memory order: r, g, b, a.
assert_eq!(vertex_color(3, 0.5).to_le_bytes(), [3, 0, 0, 128]);
assert_eq!(vertex_color(200, 2.0).to_le_bytes(), [200, 0, 0, 255]);
assert_eq!(vertex_color(0, -1.0).to_le_bytes(), [0, 0, 0, 0]);
}
#[test]
fn a_still_layer_is_scaled_and_turned() {
let close =
|a: [f32; 2], b: [f32; 2]| (a[0] - b[0]).abs() < 1e-5 && (a[1] - b[1]).abs() < 1e-5;
// No speed: no scroll whatever the clock.
assert!(close(
layer_uv([0.25, 0.5], 12_345.0, 0.0, 0.0, 8.0),
[2.0, 4.0]
));
// A quarter turn takes (1, 0) to (0, 1).
assert!(close(layer_uv([1.0, 0.0], 0.0, 0.0, 90.0, 1.0), [0.0, 1.0]));
// 1500 ms at speed 2: 3000 / 110000 back, 3000 / 30000 up.
assert!(close(
layer_uv([0.0, 0.0], 1500.0, 2.0, 0.0, 8.0),
[-3.0 / 110.0, 0.1]
));
}
#[test]
fn named_blends_take_their_gl_factors() {
let multiply = blend_state(parse_blend("multiply").unwrap());
assert_eq!(multiply.color.src_factor, wgpu::BlendFactor::Dst);
assert_eq!(
multiply.color.dst_factor,
wgpu::BlendFactor::OneMinusSrcAlpha
);
// The alpha factors default to GL_ZERO, GL_ONE: the target's alpha stays.
assert_eq!(multiply.alpha.src_factor, wgpu::BlendFactor::Zero);
assert_eq!(multiply.alpha.dst_factor, wgpu::BlendFactor::One);
assert_eq!(
blend_state(parse_blend("replace").unwrap()),
wgpu::BlendState::REPLACE
);
}
}
+2
View File
@@ -20,7 +20,9 @@ pub mod chat;
pub mod chunk;
pub mod chunk_map;
pub mod cit;
pub mod cit_armor;
pub mod cit_assets;
pub mod cit_glint;
pub mod clouds;
pub mod collisions;
pub mod command_block;
@@ -1131,6 +1131,13 @@ impl Streaming {
world
.block_entities
.load_chunk(std::mem::take(&mut chunk.block_entities));
if let Some(extras) = &chunk.saved {
for (pos, attachments) in &extras.block_entity_attachments {
world
.block_entities
.restore_attachments(*pos, attachments.clone());
}
}
world
.block_names
.extend(std::mem::take(&mut chunk.block_names));
@@ -52,6 +52,8 @@ impl DirtySections {
}
}
pub struct World {
/// Server-owned mod data attached to this dimension.
pub attachments: horizon_world::attachments::Attachments,
/// The level's build range and skylight (its dimension type); its
/// chunks hold `shape.column()`.
pub shape: LevelShape,
@@ -200,6 +202,7 @@ impl World {
loaded_entities: Vec::new(),
loaded_chunks: Vec::new(),
modified: FxHashSet::default(),
attachments: Default::default(),
block_entities: Default::default(),
chest_openers: FxHashMap::default(),
block_names: FxHashMap::default(),
@@ -146,10 +146,7 @@
"%s per frame": "%s pro Bild",
"%s MiB/frame": "%s MiB/Bild",
"%s KiB/frame": "%s KiB/Bild",
"%s nits": "%s Nits",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s Nits)",
"%.2f nits": "%.2f Nits",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Gelände mit vollen Details. Die LOD-Entfernung reicht weiter.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Grobes fernes Gelände in lokalen Welten. Integrierte GPUs: max. 128.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduziert: fernes Gelände gröber, schneller auf dieser GPU.",
@@ -153,10 +153,7 @@
"%s per frame": "%s per frame",
"%s MiB/frame": "%s MiB/frame",
"%s KiB/frame": "%s KiB/frame",
"%s nits": "%s nits",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s nits)",
"%.2f nits": "%.2f nits",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Full-detail terrain range. LOD Distance can extend local worlds farther.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reduced draws far terrain coarser: faster on this integrated GPU.",
@@ -140,10 +140,7 @@
"%s per frame": "%s por fotograma",
"%s MiB/frame": "%s MiB/fotograma",
"%s KiB/frame": "%s KiB/fotograma",
"%s nits": "%s nits",
"Auto (%s)": "Automático (%s)",
"Auto (%s nits)": "Automático (%s nits)",
"%.2f nits": "%.2f nits",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Alcance del terreno con todo el detalle. La distancia LOD puede extender más los mundos locales.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terreno lejano de baja resolución en mundos locales. Las GPU integradas se limitan a 128 chunks.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Reducido dibuja el terreno lejano con menos detalle: más rápido en esta GPU integrada.",
@@ -146,10 +146,7 @@
"%s per frame": "%s par image",
"%s MiB/frame": "%s MiB/image",
"%s KiB/frame": "%s KiB/image",
"%s nits": "%s nits",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s nits)",
"%.2f nits": "%.2f nits",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Terrain en détail complet. La distance LOD va plus loin.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Terrain lointain simplifié, mondes locaux. GPU intégrés : 128 max.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Réduit : terrain lointain simplifié, plus rapide sur ce GPU.",
@@ -146,10 +146,7 @@
"%s per frame": "%s na klatkę",
"%s MiB/frame": "%s MiB/klatka",
"%s KiB/frame": "%s KiB/klatka",
"%s nits": "%s nitów",
"Auto (%s)": "Auto (%s)",
"Auto (%s nits)": "Auto (%s nitów)",
"%.2f nits": "%.2f nitów",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Zasięg terenu w pełnych szczegółach. Zasięg LOD sięga dalej.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Uproszczony odległy teren w światach lokalnych. Zintegr. GPU: do 128.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Ograniczone: odległy teren rysowany prościej, szybciej na tym GPU.",
@@ -139,10 +139,7 @@
"%s per frame": "%s за кадр",
"%s MiB/frame": "%s МиБ/кадр",
"%s KiB/frame": "%s КиБ/кадр",
"%s nits": "%s нит",
"Auto (%s)": "Авто (%s)",
"Auto (%s nits)": "Авто (%s нит)",
"%.2f nits": "%.2f нит",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Дальность прорисованного рельефа в деталях. Дистанция LOD может продлить её для локальных миров.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Грубый дальний рельеф в локальных мирах. На встроенной графике — не дальше 128 чанков.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "«Снижено» рисует дальний рельеф грубее: на этой встроенной видеокарте быстрее.",
@@ -140,10 +140,7 @@
"%s per frame": "Kare başına %s",
"%s MiB/frame": "Kare başına %s MiB",
"%s KiB/frame": "Kare başına %s KiB",
"%s nits": "%s nit",
"Auto (%s)": "Otomatik (%s)",
"Auto (%s nits)": "Otomatik (%s nit)",
"%.2f nits": "%.2f nit",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Arazinin tam ayrıntıyla görüntülendiği mesafe. LOD Mesafesi, yerel dünyalarda bu mesafeyi daha da artırabilir.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Yerel dünyalarda uzaktaki arazileri daha düşük ayrıntıyla görüntüler. Tümleşik GPU'larda sınır 128 parçadır.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Düşük ayarı, uzak arazileri daha düşük ayrıntıyla görüntüler. Bu tümleşik GPU'da daha yüksek performans sağlar.",
@@ -146,10 +146,7 @@
"%s per frame": "%s на кадр",
"%s MiB/frame": "%s MiB/кадр",
"%s KiB/frame": "%s KiB/кадр",
"%s nits": "%s ніт",
"Auto (%s)": "Авто (%s)",
"Auto (%s nits)": "Авто (%s ніт)",
"%.2f nits": "%.2f ніт",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "Дальність повних деталей. Дальність LOD сягає далі.",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "Спрощена далека місцевість локальних світів. Інтегр. GPU: до 128.",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "Знижена: далека місцевість простіша, швидше на цьому GPU.",
@@ -140,10 +140,7 @@
"%s per frame": "每帧 %s",
"%s MiB/frame": "每帧 %s MiB",
"%s KiB/frame": "每帧 %s KiB",
"%s nits": "%s 尼特",
"Auto (%s)": "自动(%s)",
"Auto (%s nits)": "自动(%s 尼特)",
"%.2f nits": "%.2f 尼特",
"Full-detail terrain range. LOD Distance can extend local worlds farther.": "完整细节的地形范围。LOD 距离可以让本地世界延伸得更远。",
"Coarse distant terrain in local worlds. Integrated GPUs stop at 128 chunks.": "本地世界的远景粗略地形。集成显卡最多 128 个区块。",
"Reduced draws far terrain coarser: faster on this integrated GPU.": "“精简”把远景画得更粗略:在这块集成显卡上更快。",
+13 -5
View File
@@ -403,6 +403,18 @@ pub fn clip_block_stop(
collision: &BlockCollisionRegistry<'_>,
from: Vec3,
movement: Vec3,
) -> Option<(Vec3, BlockPos, [i32; 3])> {
let (location, block, normal) = clip_block_hit(world, facts, collision, from, movement)?;
Some((back_off(location, movement), block, normal))
}
/// The clip of `clip_block_stop` before the back-off: the hit location (what a
/// `BlockHitResult` carries), the block and the normal of the face.
pub fn clip_block_hit(
world: &World,
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
from: Vec3,
movement: Vec3,
) -> Option<(Vec3, BlockPos, [i32; 3])> {
let to = Vec3::new(
from.x + movement.x,
@@ -426,11 +438,7 @@ pub fn clip_block_stop(
})
.ok()
.flatten()?;
Some((
back_off(hit.location, movement),
hit.block,
hit.direction.normal(),
))
Some((hit.location, hit.block, hit.direction.normal()))
}
/// `AbstractArrow.onHitBlock`'s back-off: vanilla scales `(double) 0.05f` by
/// `Math.signum` per axis, whose value at zero is zero (not Rust's -1.0).
+8 -8
View File
@@ -10,14 +10,14 @@ pub mod block_contact;
pub use horizon_client_render::{
assets, atlas_sources, audio, auth, ban_notice, bidi, biome, block_edit, block_entities,
block_entity_anim, block_entity_render, border_render, breaking, brewing, camera, chat, chunk,
chunk_map, cit, cit_assets, clouds, collisions, command_block, containers, crash, datapack,
dynamic_light, effects, environment, extras, fast_hash, fog, font, gfx, graphics,
graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays, inventory_effects,
java_text, jigsaw_block, level_loading, light, lod, logging, menu_blur, mesher, minimap,
mipmap, mod_list, model_library, models, multiline_edit, native_emissive, options_file,
options_fixes, options_ui, overlay, pack_screen, packs, par, particles, poi_sections,
post_chain, potent_sulfur, preedit, providers, screen_draw, screen_effects, sculk,
section_mask, settings, shader_options, shader_pack_list, signs, skins, sound_engine,
chunk_map, cit, cit_armor, cit_assets, cit_glint, clouds, collisions, command_block,
containers, crash, datapack, dynamic_light, effects, environment, extras, fast_hash, fog, font,
gfx, graphics, graphics_style, graphics_ui, hdr_calibration, hud, hud_layout, hud_overlays,
inventory_effects, java_text, jigsaw_block, level_loading, light, lod, logging, menu_blur,
mesher, minimap, mipmap, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_screen, packs, par, particles,
poi_sections, post_chain, potent_sulfur, preedit, providers, screen_draw, screen_effects,
sculk, section_mask, settings, shader_options, shader_pack_list, signs, skins, sound_engine,
sound_events, sound_preview, spans, split_layout, split_map, split_profiles, sprites,
streaming, structure_block, tab_list, telemetry, test_block, test_instance_block, testgen,
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
+35 -1
View File
@@ -3,6 +3,15 @@ use crate::{crash, game, logging, ram_limit};
/// Start the game. The executable supplies the allocator and retains the mod API marker.
pub fn main() {
main_with(Default::default());
}
/// Start the game with trait mods in dispatch order.
pub fn main_with(mods: crate::mod_api::Mods) {
// First: a trait injection into anything below (logging, the crash handler) calls its mod.
if crate::mod_api::MODS.set(mods).is_err() {
log::warn!("Trait mods already installed; ignoring the second list");
}
horizon_threads::configure_allocator();
horizon_client::telemetry::mark_start();
horizon_client::spans::startup::init(std::time::Instant::now());
@@ -46,8 +55,33 @@ pub fn main() {
crash::install(home.clone(), side);
ram_limit::start(&home, side);
}
let renderers = crate::mod_api::mods()
.iter()
.filter_map(|m| m.render())
.collect();
if crate::gfx::native_render::install(renderers).is_err() {
log::warn!("Trait renderers already installed; keeping the first list");
}
if horizon_world::native_load_hooks::install(
horizon_world::native_load_hooks::NativeLoadHooks {
loot_table_load: |id, table| {
crate::mod_api::dispatch_loot_table_load(crate::mod_api::mods(), id, table);
},
modify_default_components: |item, components| {
crate::mod_api::dispatch_modify_default_components(
crate::mod_api::mods(),
item,
components,
);
},
},
)
.is_err()
{
log::warn!("Native data-load hooks already installed; keeping the first callbacks");
}
raise_open_file_limit();
if let Err(error) = game::run() {
if let Err(error) = game::run_with() {
// A bad command line is the caller's mistake, not a crash: no report.
let usage = error.is::<game::UsageError>();
log::error!("Horizon: {error}");
+64 -9
View File
@@ -184,6 +184,7 @@ mod mob_blocks;
mod mob_gear;
mod mob_looting;
mod mob_sounds;
mod mod_api_host;
mod mods;
mod mount_menu;
mod mounts;
@@ -192,7 +193,7 @@ mod music;
mod names;
mod narrator;
mod native_biome_colors;
mod native_block_entity_draw;
pub mod native_block_entity_draw;
mod native_claimed_items;
mod native_components;
mod native_criteria;
@@ -911,6 +912,9 @@ struct GameLevel<'w> {
/// not a rocket boosting a player or a hook on an entity, whose ticks
/// skip that step, nor a trident, which `tick_blocks` sweeps itself.
trigger_projectiles: Vec<(Aabb, bool)>,
/// The lightning bolts here: none ignores block triggers
/// (`Entity.isIgnoringBlockTriggers`).
trigger_extras: Vec<Aabb>,
biomes: &'w Biomes,
seed: i64,
behaviours: &'w Behaviours,
@@ -990,6 +994,18 @@ impl EntityObstructionWorld for BuildingObstruction {
.collect())
}
}
/// An entity's box (`EntityDimensions.makeBoundingBox`): `width` wide and
/// `height` tall, standing on `feet`.
fn point_box(feet: [f64; 3], width: f64, height: f64) -> Aabb {
Aabb::new(
Vec3::new(feet[0] - width / 2.0, feet[1], feet[2] - width / 2.0),
Vec3::new(
feet[0] + width / 2.0,
feet[1] + height,
feet[2] + width / 2.0,
),
)
}
impl GameLevel<'_> {
/// The living entities that trigger plates and tripwires and don't ignore
/// block triggers (`Entity.isIgnoringBlockTriggers`: bats and marker armor
@@ -1049,6 +1065,7 @@ impl GameLevel<'_> {
.iter()
.map(move |p| square(p.position, p.kind.size())),
)
.chain(self.trigger_extras.iter().copied())
}
/// `DetectorRailBlock.getAnalogOutputSignal`: a powered rail reads the
/// minecart on it, a command block cart first (its success count), else the first container cart's fullness.
@@ -2756,6 +2773,7 @@ struct Game<'a> {
/// The camera's block and the second `water_plane` last scanned at.
water_plane_scan: std::cell::Cell<Option<([i32; 3], i64)>>,
persist: persist::EntityMemory,
entity_attachments: HashMap<[i32; 4], horizon_world::attachments::Attachments>,
/// Connected to a server: the world comes from the network (game/remote.rs).
remote: Option<remote::Remote>,
/// This world is open to the network (game/lan.rs).
@@ -2871,6 +2889,12 @@ struct Game<'a> {
/// have each chest open.
lid_openers: HashMap<(i32, i32, i32), i32>,
lan_acting: Option<u32>,
player_attachments: horizon_world::attachments::Attachments,
/// The joined guests' attachments, by UUID: here rather than in their
/// records, so a hook in one guest's turn (when the hub is out of
/// `self.lan`) still reaches the others'. The acting guest's are in
/// `player_attachments` for the turn.
guest_attachments: HashMap<u128, horizon_world::attachments::Attachments>,
/// The block the acting player is breaking (`break_block`): its destroy
/// event, and the ones before it, are the player's own (`Level.levelEvent`
/// with the player excluded); the events after it are not.
@@ -3719,13 +3743,16 @@ impl Game<'_> {
/// Where a loot roll at `origin` happens, for a mod's own loot kinds:
/// the block entity it may copy from, and this level as
/// `LootContext.getLevel` gives it (`Level.isRaining`: rain above 0.2;
/// `isThundering`: thunder above 0.9).
/// `isThundering`: thunder above 0.9; neither where `canHaveWeather` is
/// false: a level with a ceiling or no sky light, or the End).
fn loot_place_at(
&self,
origin: [f64; 3],
block_entity: Option<horizon_world::nbt::Tag>,
) -> horizon_world::loot::NativeLootPlace {
let (rain, thunder) = self.world.weather.levels(1.0);
// Level.canHaveWeather: the Nether has a ceiling, the End is the End.
let weather = self.dim == Dim::Overworld;
horizon_world::loot::NativeLootPlace {
origin: Some(origin),
block_entity,
@@ -3733,8 +3760,8 @@ impl Game<'_> {
dimension: self.dim.id().to_owned(),
game_time: self.world.game_time,
day_time: self.world.day_time,
raining: rain > 0.2,
thundering: thunder > 0.9,
raining: weather && rain > 0.2,
thundering: weather && thunder > 0.9,
}),
}
}
@@ -4175,6 +4202,12 @@ impl Game<'_> {
)
.collect();
self.lan_hook_triggers(&mut trigger_projectiles);
// EntityType.LIGHTNING_BOLT: 0 x 0.
let trigger_extras: Vec<Aabb> = self
.bolts
.iter()
.map(|b| point_box(b.at, 0.0, 0.0))
.collect();
let blockers = Blockers {
mobs: &self.mobs,
armor_stands: &self.armor_stands,
@@ -4183,6 +4216,7 @@ impl Game<'_> {
tnts: &self.tnts,
falling: &self.falling,
};
let native_authoritative = self.native_authoritative();
let mut level = GameLevel {
cart_boxes,
item_frames: &self.item_frames,
@@ -4193,6 +4227,7 @@ impl Game<'_> {
orbs: &self.orbs,
projectiles: &self.creatures.projectiles,
trigger_projectiles,
trigger_extras,
world: &mut self.world,
facts: self.res.facts(),
collision: self.collision,
@@ -4235,7 +4270,7 @@ impl Game<'_> {
random_tick_sections: &mut self.random_tick_sections,
lid_openers: &self.lid_openers,
client_side: self.remote.is_some(),
native_authoritative: self.serving.is_some() || self.options.dedicated.is_some(),
native_authoritative,
debug: self.world_debug,
native_sounds: &mut self.creatures.lan_sounds,
native_particles: &mut self.creatures.lan_particles,
@@ -11555,6 +11590,9 @@ impl Game<'_> {
}
fn tick_mobs(&mut self) -> Result<()> {
self.tick_creatures()?;
if !self.entity_attachments.is_empty() {
self.prune_entity_attachments();
}
self.native_mobs_equipment();
self.position_rider();
self.tick_stats();
@@ -12358,6 +12396,7 @@ impl Game<'_> {
self.pickups.clear();
self.riding = None;
self.persist = Default::default();
self.entity_attachments.clear();
}
/// Equipment attribute modifiers (LivingEntity.collectEquipmentChanges).
fn apply_equipment(&mut self) {
@@ -13791,7 +13830,12 @@ impl Game<'_> {
4,
)
};
let state = self.resolve_item(&stack, context, Some(&owner), seed);
let cit_hand = if hand == Hand::Off {
horizon_client::cit::Hand::Off
} else {
horizon_client::cit::Hand::Main
};
let state = self.resolve_item_in_hand(&stack, context, Some(&owner), seed, cit_hand);
let light = self.world.light(
eye[0].floor() as i32,
eye[1].floor() as i32,
@@ -14917,7 +14961,9 @@ impl Game<'_> {
{
// The vehicle tree goes into a player's file when they leave
// (`take_parent_vehicle`); here it stays in the chunk saves.
for g in host.hub.guests.iter().filter(|g| g.placed) {
let mut host = host;
for g in host.hub.guests.iter_mut().filter(|g| g.placed) {
self.guest_attachments_into(g);
horizon_client::lan::playerdata::save(g, &self.world_root);
}
self.lan = Some(host);
@@ -14928,6 +14974,7 @@ impl Game<'_> {
}
/// Save the level in place: its changed chunks, its entities and its raids.
fn save_level(&mut self, wait: bool) -> Result<()> {
self.save.save_attachments(&self.world.attachments)?;
self.save.save_forced_chunks(&self.streaming.forced)?;
self.streaming.save(&mut self.world, wait);
let keys: Vec<_> = self.world.modified.drain().collect();
@@ -17623,8 +17670,9 @@ impl Game<'_> {
| KeyCode::ArrowUp
| KeyCode::ArrowDown),
) => {
let shift = self.has_shift_down();
if let Some(t) = self.title.as_mut() {
t.arrow_key(key, (w, h));
t.arrow_key(key, (w, h), shift);
}
}
PhysicalKey::Code(KeyCode::Backspace) => self.title_edit(None, true),
@@ -18425,6 +18473,7 @@ impl<'a> Game<'a> {
water_plane_memo: Default::default(),
water_plane_scan: Default::default(),
persist: Default::default(),
entity_attachments: Default::default(),
remote: None,
lan: None,
integrated: None,
@@ -18481,6 +18530,8 @@ impl<'a> Game<'a> {
lan_global_events: Vec::new(),
lid_openers: HashMap::new(),
lan_acting: None,
player_attachments: Default::default(),
guest_attachments: HashMap::new(),
lan_break_pos: None,
guest_packets: Vec::new(),
guest_cooldowns: Vec::new(),
@@ -19721,6 +19772,10 @@ fn build_renderer(
renderer
.hud
.set_text_shader(&renderer.device, atlas.text_shader.as_deref());
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
renderer
.hud
.set_cit_glints(&renderer.device, &renderer.queue, cit_glints);
Ok(renderer)
}
@@ -20563,7 +20618,7 @@ fn run_loading(
pub use launch_api::{UsageError, exit_code};
pub fn run() -> Result<()> {
pub fn run_with() -> Result<()> {
let Some(mut options) = Options::read()? else {
return Ok(());
};
+36 -11
View File
@@ -4,6 +4,8 @@
//! the default pose, the hit wobble and humanoid armor layers.
use super::{Game, InteractTarget};
use horizon_client::{
cit::{Hand, Kind, Rule},
cit_armor,
entities::ArmorStand,
items::ItemStack,
mesher::Vertex,
@@ -154,6 +156,27 @@ impl ArmorStandAssets {
}
}
impl Game<'_> {
/// OptiFine CIT: the winning armour or elytra rule (`kind`) of the worn `stack`. No lookup
/// without CIT rules.
pub(super) fn cit_worn_rule(&self, kind: Kind, stack: &ItemStack) -> Option<&Rule> {
let rules = &self.pack.item_renderer.cit.as_ref()?.rules;
let item_max = self.items().get(&stack.item).map_or(0, |i| i.max_damage);
horizon_client::item_render::cit::worn_rule(rules, kind, stack, item_max)
.and_then(|i| rules.rules.get(i))
}
/// The atlas rect of an armour layer `texture` of equipment asset `asset`, or of the one CIT
/// armour rule `cit` puts there when the atlas has it.
fn armor_layer_rect(
&self,
cit: Option<&Rule>,
asset: &str,
kind: &str,
texture: &str,
) -> Option<[f32; 4]> {
let sprites = &self.gui.atlas.sprites;
let r = cit_armor::worn_layer(cit, asset, kind, texture, |s| sprites.get(s))?;
Some([r.u0, r.v0, r.u1, r.v1])
}
/// `HumanoidArmorLayer` for the player: (model, texture rect, colour,
/// overlay) of each worn piece, head to feet.
pub(super) fn player_armor_layers(&self) -> Vec<(&Part, [f32; 4], u32, u32, bool)> {
@@ -185,7 +208,7 @@ impl Game<'_> {
)
}
/// The layers of the armour items `worn` (with their stacks when known,
/// for trims), head to feet.
/// for trims and CIT armour rules), head to feet.
fn armor_layers(
&self,
worn: [Option<(&str, Option<&ItemStack>)>; 4],
@@ -240,18 +263,13 @@ impl Game<'_> {
};
// Every layer of the asset, the leather overlay too, then the
// trim layer over the armour.
let cit = stack.and_then(|s| self.cit_worn_rule(Kind::Armor, s));
for (i, (texture, undyed)) in layers.iter().enumerate() {
let Some(r) = self.gui.atlas.sprites.get(texture.as_str()) else {
let Some(uv) = self.armor_layer_rect(cit, &asset, kind, texture) else {
continue;
};
let color = layer_color(*undyed, stack);
out.push((
part,
[r.u0, r.v0, r.u1, r.v1],
color,
if i == 0 { overlay } else { 0 },
false,
));
out.push((part, uv, color, if i == 0 { overlay } else { 0 }, false));
// The layer's OptiFine emissive overlay, full bright without the glint.
if let Some(g) = emissive
.overlay(texture)
@@ -999,8 +1017,9 @@ impl Game<'_> {
apply_pose(&mut layer_poses, &a.pose);
// EquipmentLayerRenderer: every layer of the asset, the leather overlay
// too; the shader glint goes on the first layer only (renderShaderGlint).
let cit = self.cit_worn_rule(Kind::Armor, stack);
for (i, (texture, undyed)) in layers.iter().enumerate() {
let Some(uv) = uv_of(texture) else {
let Some(uv) = self.armor_layer_rect(cit, &asset, kind, texture) else {
continue;
};
let color = layer_color(*undyed, Some(stack));
@@ -1049,6 +1068,7 @@ impl Game<'_> {
// never crouches or glides, so its wings rest folded.
self.wings_geometry(
a.equipment[4].as_ref().map(|s| &*s.item),
a.equipment[4].as_ref(),
None,
a.small,
base,
@@ -1296,7 +1316,12 @@ impl Game<'_> {
oz / 16.0,
));
let seed = a.id as i32 + if left { 1 } else { 2 };
let state = self.resolve_item(stack, context, Some(&owner), seed);
// OptiFine CIT `hand=off` rules: slot 1 is the off hand.
let state = if left {
self.resolve_item_in_hand(stack, context, Some(&owner), seed, Hand::Off)
} else {
self.resolve_item(stack, context, Some(&owner), seed)
};
self.submit_item_world(&state, pose, light, out);
}
// `CustomHeadLayer`: a worn skull draws the skull model.
+78 -72
View File
@@ -738,84 +738,90 @@ impl Game<'_> {
// A server player's boat is driven by its client (lan_vehicles.rs).
let driven = self.guest_vehicles();
// EntityTickEvent.Post comes once AbstractBoat.tick is over, after the
// passes below (lava, ejection, bubbles, contacts): the ticked boats,
// in order, kept only while a mod listens (no list without mods).
let mut ticked = Vec::new();
for i in 0..self.boats.len() {
// A mod's hook may have removed boats.
if i >= self.boats.len() {
break;
}
let p = self.boats[i].position;
if Some(self.boats[i].id) == self.riding
|| driven.contains(&self.boats[i].id)
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
{
// Its rider's client moves it, but the lead still ticks.
if ticking(p.x.floor() as i32, p.z.floor() as i32) && self.remote.is_none() {
let mut b = self.boats[i].clone();
self.tick_boat_leash(&mut b);
self.boats[i] = b;
}
continue;
}
let id = self.boats[i].id;
let kind = if round.listening() {
self.boats[i].entity_type()
} else {
String::new()
};
if !self.native_tick_pre_one(&mut round, id, &kind) {
continue;
}
// The hook may have removed boats.
let Some(i) = Some(i)
.filter(|&i| self.boats.get(i).is_some_and(|b| b.id == id))
.or_else(|| self.boats.iter().position(|b| b.id == id))
else {
continue;
};
let b = self.boats[i].clone();
self.boats[i] = self.tick_boat(b, false);
if round.listening() {
ticked.push((id, kind));
}
}
// AbstractBoat.tick's applyEffectsFromBlocks, on the server for every
// boat, the client-driven ones too.
// passes of `boat_server_passes` (lava, ejection, bubbles, contacts),
// and before the next boat ticks, as `ServerLevel.tickNonPassenger`.
// A pass (lava breaks a boat) or a mod's hook may remove boats: the next one is found
// after this one by its id, so none is skipped or ticked twice.
let mut i = 0;
while i < self.boats.len() {
let p = self.boats[i].position;
let skip = Some(self.boats[i].id) == self.riding
|| round.canceled(self.boats[i].id)
|| !ticking(p.x.floor() as i32, p.z.floor() as i32);
if skip || !self.boat_lava_tick(i) {
i += 1;
let id = self.boats[i].id;
'boat: {
let p = self.boats[i].position;
if Some(id) == self.riding
|| driven.contains(&id)
|| !ticking(p.x.floor() as i32, p.z.floor() as i32)
{
// Its rider's client moves it, but the lead still ticks.
if ticking(p.x.floor() as i32, p.z.floor() as i32) && self.remote.is_none() {
let mut b = self.boats[i].clone();
self.tick_boat_leash(&mut b);
self.boats[i] = b;
}
self.boat_server_passes(id, &driven, &ticking, &round);
break 'boat;
}
let kind = if round.listening() {
self.boats[i].entity_type()
} else {
String::new()
};
if !self.native_tick_pre_one(&mut round, id, &kind) {
self.boat_server_passes(id, &driven, &ticking, &round);
break 'boat;
}
// The hook may have removed boats.
let Some(i) = Some(i)
.filter(|&i| self.boats.get(i).is_some_and(|b| b.id == id))
.or_else(|| self.boats.iter().position(|b| b.id == id))
else {
break 'boat;
};
let b = self.boats[i].clone();
self.boats[i] = self.tick_boat(b, false);
self.boat_server_passes(id, &driven, &ticking, &round);
self.native_tick_post_one(&round, id, &kind);
}
i = match self.boats.get(i) {
Some(b) if b.id == id => i + 1,
_ => self
.boats
.iter()
.position(|b| b.id == id)
.map_or(i, |k| k + 1),
};
}
// The rest of AbstractBoat.tick on the server, for every boat: the
// passengers of one under water too long get out, and it picks up or
// pushes what it touches.
for i in 0..self.boats.len() {
let p = self.boats[i].position;
if !ticking(p.x.floor() as i32, p.z.floor() as i32) || round.canceled(self.boats[i].id)
{
continue;
}
// `AbstractBoat.tick` counts submersion on the server even when
// its controlling client owns the boat's movement.
if driven.contains(&self.boats[i].id) {
self.refresh_boat_status(i);
}
if self.boats[i].out_of_control >= 60.0 {
self.eject_boat_riders(i);
}
self.boat_bubble_tick(i);
self.boat_contacts(i);
}
/// The rest of `AbstractBoat.tick` on the server, for every boat, the
/// client-driven ones too: `applyEffectsFromBlocks` (lava breaks a boat
/// at once), the passengers of one under water too long get out, and it
/// picks up or pushes what it touches.
fn boat_server_passes(
&mut self,
id: u32,
driven: &[u32],
ticking: &impl Fn(i32, i32) -> bool,
round: &super::native_ticks::TickRound,
) {
let Some(i) = self.boats.iter().position(|b| b.id == id) else {
return;
};
let p = self.boats[i].position;
if !ticking(p.x.floor() as i32, p.z.floor() as i32) || round.canceled(id) {
return;
}
for (id, kind) in ticked {
self.native_tick_post_one(&round, id, &kind);
if Some(id) != self.riding && self.boat_lava_tick(i) {
return;
}
// `AbstractBoat.tick` counts submersion on the server even when
// its controlling client owns the boat's movement.
if driven.contains(&id) {
self.refresh_boat_status(i);
}
if self.boats[i].out_of_control >= 60.0 {
self.eject_boat_riders(i);
}
self.boat_bubble_tick(i);
self.boat_contacts(i);
}
/// `Entity.canSimulateMovement`: for a boat (`AUTHORITATIVE_SIDE`) a
/// server leaves the boat its player drives to that player's client, and a
+2 -1
View File
@@ -21,6 +21,7 @@ use horizon_client::{
farm::Climate,
spawning::{self, BiomeFacts},
},
mod_api::NativeLootDrops,
particles::{ParticleOptions, ParticleType},
projectiles::{Hit, Projectile, ProjectileKind, ProjectileWorld},
text::Text,
@@ -1498,7 +1499,7 @@ impl Game<'_> {
}
let kind = super::native_hooks::mob_kind(m);
let table = drops::death_loot_table(m, death.mob_drops).filter(|t| loot.has_table(t));
let context = super::native_hooks::NativeLootDrops {
let context = NativeLootDrops {
table: table.as_deref().unwrap_or_default(),
dimension: self.dim.id(),
origin: [m.position.x, m.position.y, m.position.z],
+2 -4
View File
@@ -13,6 +13,8 @@ use horizon_client::{
particles::{ParticleOptions, ParticleType},
projectiles::{Projectile, ProjectileKind},
};
// Its old home, for mixin code that names it here.
pub(super) use horizon_client::mod_api::NativeDispensed;
use horizon_core::{
geometry::{Aabb, Direction, Vec3},
position::BlockPos,
@@ -99,10 +101,6 @@ fn shrink(mut stack: ItemStack) -> Option<ItemStack> {
/// A dispenser's answer: the slot's new content and the level event played.
type Dispensed = (Option<ItemStack>, i32);
/// A dispenser hook's answer: the slot's remainder (None empties it) and the
/// level event to play, `CLICK` (1000), `FAIL` (1001) or 0 for none.
pub(super) type NativeDispensed = (Option<ItemStack>, i32);
/// None keeps vanilla's behaviour; an explicit empty remainder consumes the stack.
fn mod_dispensed(answer: Option<NativeDispensed>) -> Option<Dispensed> {
answer.map(|(left, event)| (left.filter(|stack| stack.count > 0), event))
+6 -3
View File
@@ -1814,8 +1814,9 @@ impl Game<'_> {
{
y -= 1.0;
}
let id = self.take_entity_id();
self.breath_clouds.push(Breath {
id: 0,
id,
at: [x, y.floor() + 1.0, z],
radius: 5.0,
age: 0,
@@ -2291,8 +2292,9 @@ impl Game<'_> {
);
// assignDirectionalMovement: the direction times the acceleration power 0.1.
let velocity = Vec3::new(v.x * 0.1, v.y * 0.1, v.z * 0.1);
let id = self.take_entity_id();
self.dragon_fireballs.push(DragonFireball {
id: 0,
id,
position: at,
previous: at,
velocity,
@@ -2338,8 +2340,9 @@ impl Game<'_> {
))
});
if hit_block || hit_player || b.age > 600 {
let id = self.take_entity_id();
self.breath_clouds.push(Breath {
id: 0,
id,
at: [next.x, next.y, next.z],
radius: 3.0,
age: 0,
+5 -3
View File
@@ -1302,8 +1302,10 @@ impl Game<'_> {
target
};
let survive = self.world.random.next_int_bound(5).unwrap_or(0) > 0;
self.next_entity += 1;
let id = self.next_entity;
self.end.eyes.push(EyeOfEnder {
id: 0,
id,
position: start,
previous: start,
velocity: glam::DVec3::ZERO,
@@ -1571,7 +1573,7 @@ impl Game<'_> {
// A gateway keeps the item in its level (teleportSameDimension
// moves it): it doesn't leave, so no native_entity_leave_level.
self.dropped.remove(i);
self.forget_entity(id);
self.detach_entity(id);
tag
} else if let Some(i) = self.mobs.iter().position(|m| m.id == id) {
let mut tag = self.mob_tree_nbt(i);
@@ -1594,7 +1596,7 @@ impl Game<'_> {
}
self.mobs.retain(|m| !gone.contains(&m.id));
for g in gone {
self.forget_entity(g);
self.detach_entity(g);
}
tag
} else if let Some(i) = self.creatures.projectiles.iter().position(|p| p.id == id) {
+58 -1
View File
@@ -9,6 +9,7 @@ use horizon_assets::item_model::{
};
use horizon_client::{
block_entity_render::{diffuse_lights, vertex_light},
cit::{Hand as CitHand, Kind as CitKind},
hud::{HudBatch, Rect},
item_render::{self, ItemRenderState, SpecialHost, WorldSink},
items::ItemStack,
@@ -292,16 +293,72 @@ impl Game<'_> {
display: DisplayContext,
owner: Option<&OwnerState>,
seed: i32,
) -> ItemRenderState {
self.resolve_item_in_hand(stack, display, owner, seed, CitHand::Main)
}
/// `ItemStack.hasFoil`, with the item's default glint.
fn has_foil(&self, stack: &ItemStack) -> bool {
let item_glint = self
.res
.items()
.enchantments
.props
.get(&*stack.item)
.is_some_and(|p| p.glint);
stack.has_foil(item_glint)
}
/// [`Self::resolve_item`] of a stack held in `hand`, which OptiFine CIT rules can test
/// (GUI items count as the main hand).
pub(super) fn resolve_item_in_hand(
&self,
stack: &ItemStack,
display: DisplayContext,
owner: Option<&OwnerState>,
seed: i32,
hand: CitHand,
) -> ItemRenderState {
let level = self.item_level();
let mut state = self.pack.item_renderer.resolve(
let renderer = &self.pack.item_renderer;
let (rule, glint) = renderer.cit.as_ref().map_or((None, None), |cit| {
let item_max = self
.res
.items()
.get(&stack.item)
.map_or(0, |i| i.max_damage);
let max_damage = item_render::cit::stack_max_damage(stack, item_max);
(
cit.rules.select(CitKind::Item, stack, hand, max_damage),
cit.rules
.glint(stack, hand, max_damage, self.has_foil(stack)),
)
});
let mut state = renderer.resolve_with_rule(
&item_render::stack_view(stack),
display,
owner,
Some(&level),
seed,
&self.item_env(),
rule,
);
if let Some(glint) = glint {
// The pack's enchantment rules replace vanilla's glint unless one keeps it, so far
// only in the GUI, where their own layers draw: held, dropped and framed items keep
// vanilla's until theirs do. The pack's `useGlint=false` removes it everywhere.
let gui = display == DisplayContext::Gui;
let pack_keeps = renderer
.cit
.as_ref()
.is_none_or(|c| c.rules.global.use_glint);
if !glint.vanilla && (gui || !pack_keeps) {
for layer in &mut state.layers {
layer.foil = horizon_assets::item_model::FoilType::None;
}
}
if gui && !glint.is_empty() {
state.cit_glint = Some(glint);
}
}
state.hdr_tier = self.luminance().hdr_tier(&stack.item);
if state.hdr_tier > 0 && self.renderer.as_ref().is_some_and(|r| r.hdr_on()) {
state.hdr_light = self.luminance().of(&stack.item);
+11 -3
View File
@@ -1437,7 +1437,7 @@ impl Game<'_> {
for i in 0..host.hub.guests.len() {
let mut g = host.hub.guests.remove(i);
self.take_parent_vehicle(host, &mut g);
self.save_guest(host, &g);
self.save_guest(host, &mut g);
host.hub.guests.insert(i, g);
}
}
@@ -1445,7 +1445,12 @@ impl Game<'_> {
/// `take_parent_vehicle`, `PlayerList.save`): an integrated server's owner
/// into the level (`/data`) and both its files (`playerdata::save_owner`),
/// anyone else into their own file.
pub(super) fn save_guest(&mut self, host: &LanHost, guest: &horizon_client::lan::guest::Guest) {
pub(super) fn save_guest(
&mut self,
host: &LanHost,
guest: &mut horizon_client::lan::guest::Guest,
) {
self.guest_attachments_into(guest);
if guest.placed && host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
playerdata::save_owner(guest, &mut self.level, &self.world_root);
} else if guest.placed {
@@ -1954,7 +1959,8 @@ impl Game<'_> {
}
// PlayerList.remove: the vehicle it alone rides goes too.
self.take_parent_vehicle(&mut host, &mut guest);
self.save_guest(&host, &guest);
self.save_guest(&host, &mut guest);
self.guest_attachments.remove(&guest.uuid);
host.models.remove(&guest.conn);
host.play.remove(&guest.conn);
self.guest_combat.remove(&guest.conn);
@@ -2345,6 +2351,8 @@ impl Game<'_> {
Some(o) => playerdata::load_owner(g, &root, o.saved.take().as_deref()),
None => playerdata::load(g, &root),
};
self.guest_attachments
.insert(g.uuid, std::mem::take(&mut g.attachments));
if !known || force {
g.game_mode = default_mode;
}
@@ -412,6 +412,10 @@ impl Game<'_> {
host.play.insert(conn, play);
return None;
};
std::mem::swap(
&mut self.player_attachments,
self.guest_attachments.entry(g.uuid).or_default(),
);
self.sculk.swap_player(&mut play.sculk);
// The guest's own CombatTracker: damage to them is not the host's.
// A new body starts with no record of the last one.
@@ -834,6 +838,10 @@ impl Game<'_> {
host.hub.seat_order
});
let g = &mut host.hub.guests[i];
std::mem::swap(
&mut self.player_attachments,
self.guest_attachments.entry(g.uuid).or_default(),
);
let left_vehicle = g.vehicle.filter(|_| guest_vehicle.is_none());
if guest_vehicle.is_none() {
g.vehicle_good = None;
@@ -3176,6 +3184,13 @@ impl Game<'_> {
if !host.hub.guests[i].dead {
return;
}
// PlayerList.respawn replaces the player, retaining only opted-in data.
let uuid = host.hub.guests[i].uuid;
if let Some(attachments) = self.guest_attachments.get_mut(&uuid)
&& let Ok(copy) = attachments.copy_on_death()
{
*attachments = copy;
}
// PlayerList.respawn removes the dead player (Player.remove).
self.guest_drop_carried(host, i);
// AbstractContainerScreen.tick / ServerPlayer.closeContainer: the dead
@@ -21,6 +21,14 @@ pub(super) fn assign_entity_id(next: &mut u32, id: &mut u32) -> u32 {
*id
}
impl Game<'_> {
/// `Entity.nextEntityId`: the id a new entity takes from the one counter.
pub(super) fn take_entity_id(&mut self) -> u32 {
self.next_entity += 1;
self.next_entity
}
}
fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) -> EntityState {
EntityState {
id: id as i32,
+28 -10
View File
@@ -128,9 +128,22 @@ pub(super) fn acts_on_player(e: &MobEvent) -> bool {
)
}
/// A guest's `player_view`, taken with their state swapped in, as the mobs see
/// them. Their last attacker and last victim stay: a tamed wolf defends and
/// helps whoever owns it (`OwnerHurtByTargetGoal`, `OwnerHurtTargetGoal`), and
/// the window's own player is a guest of its server thread.
fn guest_view(mut v: PlayerView, uuid: u128, moved: [f64; 3]) -> PlayerView {
v.hero = false;
v.velocity = Vec3::new(moved[0], moved[1], moved[2]);
v.uuid = horizon_client::lan::playerdata::uuid_ints(uuid);
v.allies = Default::default();
v.remote = true;
v
}
impl Game<'_> {
/// How mobs see each guest in play (`player_view` with the guest swapped
/// in; what the host alone tracks, such as its last attacker, stays out).
/// in).
pub(super) fn lan_guest_views(&mut self) -> GuestViews {
let Some(mut host) = self.lan.take() else {
return Vec::new();
@@ -147,15 +160,8 @@ impl Game<'_> {
.collect();
let mut views = Vec::new();
for (conn, uuid, moved) in conns {
if let Some(Some(mut v)) = self.as_guest(&mut host, conn, |g| g.player_view()) {
v.hurt_by = None;
v.last_hurt = None;
v.hero = false;
v.velocity = Vec3::new(moved[0], moved[1], moved[2]);
v.uuid = horizon_client::lan::playerdata::uuid_ints(uuid);
v.allies = Default::default();
v.remote = true;
views.push((conn, v));
if let Some(Some(v)) = self.as_guest(&mut host, conn, |g| g.player_view()) {
views.push((conn, guest_view(v, uuid, moved)));
}
}
self.lan = Some(host);
@@ -425,6 +431,18 @@ pub(super) fn guest_bounds(g: &horizon_client::lan::guest::Guest) -> Aabb {
mod tests {
use super::*;
#[test]
fn a_guests_view_keeps_who_hurt_them_and_who_they_hurt() {
let mut v = view(0.0, false);
v.hurt_by = Some((Who::Mob(7), 90));
v.last_hurt = Some((Who::Mob(8), 95));
let seen = guest_view(v, 5, [1.0, 0.0, 0.0]);
assert_eq!(seen.hurt_by, Some((Who::Mob(7), 90)));
assert_eq!(seen.last_hurt, Some((Who::Mob(8), 95)));
assert!(seen.remote);
assert_eq!(seen.velocity, Vec3::new(1.0, 0.0, 0.0));
}
fn view(x: f64, creative: bool) -> PlayerView {
PlayerView {
position: Vec3::new(x, 64.0, 0.0),
@@ -417,7 +417,11 @@ impl Game<'_> {
// its own host too (`lan.rs:165`), so this one path covers
// both single player and a LAN guest.
let mut carried = g.carried.clone();
self.mod_menu_button(pos, id, &mut carried);
// As that player: `native_acting_player`, `native_acting_id`
// and `native_give_stack` answer for the one who pressed.
self.as_guest(host, conn, |game| {
game.mod_menu_button(pos, id, &mut carried)
});
if host.hub.guest(conn).is_some_and(|g| g.carried != carried) {
let net = host.hub.stack(&carried);
if let Some(g) = host.hub.guest_mut(conn) {
+2 -1
View File
@@ -7,6 +7,7 @@ use horizon_client::{
items::ItemStack,
mesher::Vertex,
mobs::leash::{self, Holder, Pending},
mod_api::NativeLootDrops,
};
use horizon_world::nbt::Tag;
@@ -163,7 +164,7 @@ impl Game<'_> {
return;
};
let mut drops = vec![ItemStack::new("minecraft:lead", 1)];
let context = super::native_hooks::NativeLootDrops {
let context = NativeLootDrops {
table: "",
dimension: self.dim.id(),
origin: at,
+5
View File
@@ -175,6 +175,7 @@ impl Game<'_> {
&self.level.enabled_features,
)?;
let mut world = World::new(self.res.facts().air, self.level.seed);
world.attachments = save.load_attachments();
world.shape = self.level_shapes.get(dim.id());
world.fast_lava = dim == Dim::Nether;
Ok(Parked {
@@ -637,6 +638,10 @@ impl Game<'_> {
let chunks: Vec<_> = self.world.chunks.values().cloned().collect();
self.native_chunks_changed(&chunks);
self.save_level(true)?;
// Its entities' attachments are saved with them; they leave memory too.
for uuid in self.persist.uuids.values() {
self.entity_attachments.remove(uuid);
}
self.enter_level(None, back)?;
self.levels.parked.remove(&dim);
self.native_level_closed(dim);
+2 -1
View File
@@ -227,8 +227,9 @@ impl Game<'_> {
let seed = r.next_long();
let flashes = r.next_int_bound(3).unwrap_or(0) + 1;
self.lightning_seen[0] += 1;
let id = self.take_entity_id();
self.bolts.push(Bolt {
id: 0,
id,
at,
life: 2,
flashes,
+6 -1
View File
@@ -513,7 +513,12 @@ impl Game<'_> {
};
// `updateForLiving`: seeded by the entity id plus the context ordinal.
let seed = mob.id as i32 + ordinal;
let state = self.resolve_item(&stack, context, Some(&owner), seed);
let hand = if off_hand {
horizon_client::cit::Hand::Off
} else {
horizon_client::cit::Hand::Main
};
let state = self.resolve_item_in_hand(&stack, context, Some(&owner), seed, hand);
if lit {
self.submit_item_world(&state, t, light, out);
} else {
@@ -0,0 +1,227 @@
use super::{BlockStateId, Game};
use horizon_client::mod_api::BlockPos;
use horizon_world::{
attachments::{ATTACHMENTS_KEY, attachment_compound},
level::Ticks,
};
fn world_attachments(
world: &mut horizon_client::world::World,
holder: horizon_client::mod_api::Holder,
) -> Result<&mut horizon_client::mod_api::Attachments, horizon_client::mod_api::AttachmentError> {
use horizon_client::mod_api::{AttachmentError, Holder};
if holder == Holder::Level {
return Ok(&mut world.attachments);
}
if let Holder::BlockEntity([x, y, z]) = holder {
let pos = (x, y, z);
let chunk = world
.chunks
.get(&(x >> 4, z >> 4))
.ok_or(AttachmentError::MissingHolder)?;
attachment_compound(
chunk
.saved
.as_ref()
.and_then(|e| e.block_entity_sources.iter().find(|(p, _)| *p == pos))
.and_then(|(_, tag)| tag.get(ATTACHMENTS_KEY)),
)?;
let map = world
.block_entities
.attachments_mut(pos)
.ok_or(AttachmentError::MissingHolder)?;
world.modified.insert((x >> 4, z >> 4));
return Ok(map);
}
let Holder::Chunk([x, z]) = holder else {
return Err(AttachmentError::MissingHolder);
};
let chunk = world
.chunks
.get_mut(&(x, z))
.ok_or(AttachmentError::MissingHolder)?;
let chunk = std::sync::Arc::make_mut(chunk);
let extras = std::sync::Arc::make_mut(chunk.saved.get_or_insert_with(Default::default));
attachment_compound(extras.root.get(&ATTACHMENTS_KEY.into()))?;
world.modified.insert((x, z));
Ok(&mut extras.attachments)
}
impl Game<'_> {
/// A guest's attachments back in their record, for `playerdata` to save.
pub(super) fn guest_attachments_into(&self, guest: &mut horizon_client::lan::guest::Guest) {
guest.attachments = self
.guest_attachments
.get(&guest.uuid)
.cloned()
.unwrap_or_default();
}
/// Whether entity `id` is in the level, as a holder of attachments.
fn attachment_entity_exists(&self, id: u32) -> bool {
use super::commands::Target;
match i32::try_from(id).ok().and_then(|id| self.level_entity(id)) {
Some(Target::Mob(_)) => self.mobs.iter().any(|e| e.id == id && !e.removed),
Some(
Target::Item(_)
| Target::Boat(_)
| Target::Cart(_)
| Target::Stand(_)
| Target::Frame(_)
| Target::Painting(_)
| Target::Other(_),
) => true,
_ => self.cushions.iter().any(|e| e.id == id),
}
}
/// The attachments of entities that left the level, dropped: each tick
/// while any entity has some. Only those entities are looked at, and
/// nothing else of the entities' memory changes.
pub(super) fn prune_entity_attachments(&mut self) {
let gone: Vec<[i32; 4]> = self
.persist
.uuids
.iter()
.filter(|(id, uuid)| {
self.entity_attachments.contains_key(*uuid) && !self.attachment_entity_exists(**id)
})
.map(|(_, uuid)| *uuid)
.collect();
for uuid in gone {
self.entity_attachments.remove(&uuid);
}
}
pub(super) fn native_authoritative(&self) -> bool {
self.serving.is_some() || self.options.dedicated.is_some()
}
/// Access a server-owned holder's attachment map.
pub(super) fn attachment_map(
&mut self,
holder: horizon_client::mod_api::Holder,
) -> Result<&mut horizon_client::mod_api::Attachments, horizon_client::mod_api::AttachmentError>
{
use horizon_client::mod_api::AttachmentError;
if !self.native_authoritative() {
return Err(AttachmentError::MissingHolder);
}
if let horizon_client::mod_api::Holder::Entity(id) = holder {
if !self.attachment_entity_exists(id) {
return Err(AttachmentError::MissingHolder);
}
attachment_compound(
self.persist
.raw
.get(&id)
.and_then(|tag| tag.get(ATTACHMENTS_KEY)),
)?;
let uuid = self.uuid_for(id);
return Ok(self.entity_attachments.entry(uuid).or_default());
}
if let horizon_client::mod_api::Holder::Player(uuid) = holder {
if self.lan_acting_uuid == Some(uuid) {
return Ok(&mut self.player_attachments);
}
return self
.guest_attachments
.get_mut(&uuid)
.ok_or(AttachmentError::MissingHolder);
}
world_attachments(&mut self.world, holder)
}
}
#[path = "mod_api_host_generated.rs"]
mod generated;
impl horizon_client::mod_api::LevelHost for super::GameLevel<'_> {
fn block_state(&self, pos: [i32; 3]) -> BlockStateId {
horizon_world::level::LevelAccess::block_state(self, BlockPos::new(pos[0], pos[1], pos[2]))
}
fn is_client_side(&self) -> bool {
horizon_world::level::LevelAccess::is_client_side(self)
}
fn state_with_property(
&self,
state: BlockStateId,
key: &str,
value: &str,
) -> Option<BlockStateId> {
self.native_state_with_property(state, key, value)
}
}
impl horizon_client::mod_api::LevelRandomHost for super::GameLevel<'_> {
fn attachments(
&mut self,
holder: horizon_client::mod_api::Holder,
) -> Result<&mut horizon_client::mod_api::Attachments, horizon_client::mod_api::AttachmentError>
{
if !self.native_authoritative {
return Err(horizon_client::mod_api::AttachmentError::MissingHolder);
}
world_attachments(self.world, holder)
}
fn random(&mut self) -> Option<&mut horizon_client::mod_api::LegacyRandom> {
self.native_level_random()
}
}
pub(super) struct LevelTicks<'a, L> {
pub level: L,
pub ticks: &'a mut Ticks,
}
impl<'a, L: std::ops::Deref<Target = super::GameLevel<'a>>> horizon_client::mod_api::LevelHost
for LevelTicks<'_, L>
{
fn block_state(&self, pos: [i32; 3]) -> BlockStateId {
horizon_client::mod_api::LevelHost::block_state(&*self.level, pos)
}
fn is_client_side(&self) -> bool {
horizon_client::mod_api::LevelHost::is_client_side(&*self.level)
}
fn state_with_property(
&self,
state: BlockStateId,
key: &str,
value: &str,
) -> Option<BlockStateId> {
horizon_client::mod_api::LevelHost::state_with_property(&*self.level, state, key, value)
}
}
impl horizon_client::mod_api::LevelRandomHost for LevelTicks<'_, &mut super::GameLevel<'_>> {
fn attachments(
&mut self,
holder: horizon_client::mod_api::Holder,
) -> Result<&mut horizon_client::mod_api::Attachments, horizon_client::mod_api::AttachmentError>
{
horizon_client::mod_api::LevelRandomHost::attachments(&mut *self.level, holder)
}
fn random(&mut self) -> Option<&mut horizon_client::mod_api::LegacyRandom> {
horizon_client::mod_api::LevelRandomHost::random(&mut *self.level)
}
}
impl<'a, L: std::ops::Deref<Target = super::GameLevel<'a>>> horizon_client::mod_api::LevelTicksHost
for LevelTicks<'_, L>
{
fn schedule_block_tick(&mut self, pos: [i32; 3], block: &str, delay: i32) -> bool {
self.level
.native_schedule_block_tick(self.ticks, pos, block, delay)
}
fn schedule_fluid_tick(&mut self, pos: [i32; 3], fluid: &str, delay: i32) -> bool {
self.level
.native_schedule_fluid_tick(self.ticks, pos, fluid, delay)
}
}
impl horizon_client::mod_api::LevelWriteHost for LevelTicks<'_, &mut super::GameLevel<'_>> {
fn set_block_property(&mut self, pos: [i32; 3], key: &str, value: &str, flags: i32) -> bool {
self.level
.native_set_block_property(self.ticks, pos, key, value, flags)
}
}
+344
View File
@@ -0,0 +1,344 @@
// @generated by cargo run -p xtask -- mod-api; do not edit.
use horizon_client::mod_api::*;
use std::path::PathBuf;
use std::sync::Arc;
impl Host for super::Game<'_> {
fn draw_quad(
&self,
draw: &mut BlockEntityDraw,
sprite: &str,
corners: [[f32; 3]; 4],
uv: [f32; 4],
tint: u32,
) -> bool {
self.native_draw_quad(draw, sprite, corners, uv, tint)
}
fn draw_cube(
&self,
draw: &mut BlockEntityDraw,
from: [f32; 3],
to: [f32; 3],
faces: [Option<CubeFace<'_>>; 6],
tint: u32,
) -> bool {
self.native_draw_cube(draw, from, to, faces, tint)
}
fn draw_block(
&self,
draw: &mut BlockEntityDraw,
block: &str,
properties: &[(&str, &str)],
) -> bool {
self.native_draw_block(draw, block, properties)
}
fn draw_item(
&self,
draw: &mut BlockEntityDraw,
stack: &ItemStack,
display: DisplayContext,
owner_yaw: Option<f32>,
seed: i32,
) -> bool {
self.native_draw_item(draw, stack, display, owner_yaw, seed)
}
fn attachments(&mut self, holder: Holder) -> AttachmentResult<&mut Attachments> {
self.native_attachments(holder)
}
fn teleport_mob(&mut self, dimension: &str, id: u32, pos: [f64; 3]) -> bool {
self.native_teleport_mob(dimension, id, pos)
}
fn push_mob(&mut self, dimension: &str, id: u32, velocity: [f64; 3]) -> bool {
self.native_push_mob(dimension, id, velocity)
}
fn set_mob_health(&mut self, dimension: &str, id: u32, health: f32) -> bool {
self.native_set_mob_health(dimension, id, health)
}
fn kill_mob(&mut self, dimension: &str, id: u32) -> bool {
self.native_kill_mob(dimension, id)
}
fn remove_mob(&mut self, dimension: &str, id: u32) -> bool {
self.native_remove_mob(dimension, id)
}
fn summon(&mut self, dimension: &str, kind: &str, pos: [f64; 3], nbt: Compound) -> Option<u32> {
self.native_summon(dimension, kind, pos, nbt)
}
fn move_entity(
&mut self,
dimension: &str,
id: u32,
pos: [f64; 3],
yaw: f32,
pitch: f32,
) -> bool {
self.native_move_entity(dimension, id, pos, yaw, pitch)
}
fn remove_entity(&mut self, dimension: &str, id: u32) -> bool {
self.native_remove_entity(dimension, id)
}
fn server_players(&self) -> Vec<NativeServerPlayer> {
self.native_server_players()
}
fn tell(&mut self, uuid: u128, line: Text) -> bool {
self.native_tell(uuid, line)
}
fn action_bar(&mut self, uuid: u128, line: Text) -> bool {
self.native_action_bar(uuid, line)
}
fn tab_list(&mut self, uuid: u128, header: &Text, footer: &Text) -> bool {
self.native_tab_list(uuid, header, footer)
}
fn tick_stats(&self) -> (f32, f64) {
self.native_tick_stats()
}
fn sleep_ignored(&mut self, uuid: u128, ignored: bool) -> bool {
self.native_sleep_ignored(uuid, ignored)
}
fn set_nickname(&mut self, uuid: u128, nickname: Option<&str>) -> bool {
self.native_set_nickname(uuid, nickname)
}
fn text_allowed(&self, text: &str) -> Option<bool> {
self.native_text_allowed(text)
}
fn broadcast(&self, line: Text) {
self.native_broadcast(line)
}
fn feedback(&mut self, line: Text) {
self.native_feedback(line)
}
fn acting_player(&self) -> Option<NativeServerPlayer> {
self.native_acting_player()
}
fn run_command(&mut self, line: &str) -> bool {
self.native_run_command(line)
}
fn world_dir(&self) -> Option<PathBuf> {
self.native_world_dir()
}
fn held(&self) -> Option<(String, u32, bool)> {
self.native_held()
}
fn count_items(&mut self, item: &str) -> u32 {
self.native_count_items(item)
}
fn carried_items(&self) -> Vec<(String, u32)> {
self.native_carried_items()
}
fn take_items(&mut self, item: &str, count: u32) -> u32 {
self.native_take_items(item, count)
}
fn room_for(&mut self, item: &str) -> u32 {
self.native_room_for(item)
}
fn give_items(&mut self, item: &str, count: u32) -> u32 {
self.native_give_items(item, count)
}
fn give_stack(&mut self, uuid: u128, stack: ItemStack) -> Option<ItemStack> {
self.native_give_stack(uuid, stack)
}
fn give_experience(&mut self, uuid: u128, points: u32) -> bool {
self.native_give_experience(uuid, points)
}
fn give_levels(&mut self, uuid: u128, levels: i32) -> bool {
self.native_give_levels(uuid, levels)
}
fn lit_block_name(&self, pos: [i32; 3]) -> Option<&str> {
self.native_lit_block_name(pos)
}
fn flame_particle(&mut self, at: [f64; 3], soul: bool) {
self.native_flame_particle(at, soul)
}
fn carried_stack(&mut self, slot: i32) -> Option<&mut ItemStack> {
self.native_carried_stack(slot)
}
fn set_block(
&mut self,
dimension: &str,
pos: [i32; 3],
state: BlockStateId,
flags: i32,
) -> bool {
self.native_set_block(dimension, pos, state, flags)
}
fn set_item_entity(&mut self, dimension: &str, id: u32, stack: ItemStack) -> bool {
self.native_set_item_entity(dimension, id, stack)
}
fn update_output_neighbors(&mut self, dimension: &str, pos: [i32; 3]) -> bool {
self.native_update_output_neighbors(dimension, pos)
}
fn update_signal_neighbors(&mut self, dimension: &str, pos: [i32; 3]) -> bool {
self.native_update_signal_neighbors(dimension, pos)
}
fn block_entity_sync(&mut self, pos: [i32; 3]) {
self.native_block_entity_sync(pos)
}
fn play_local_sound(
&mut self,
sound: &str,
at: [f64; 3],
volume: f32,
pitch: f32,
delay: i32,
) -> bool {
self.native_play_local_sound(sound, at, volume, pitch, delay)
}
fn fill(&self, batch: &mut HudBatch, rect: [f32; 4], argb: u32) {
self.native_fill(batch, rect, argb)
}
fn sprite(&self, batch: &mut HudBatch, name: &str, src: [f32; 4], dst: [f32; 4]) -> bool {
self.native_sprite(batch, name, src, dst)
}
fn item(&self, batch: &mut HudBatch, item: &str, at: [f32; 2], size: f32) -> bool {
self.native_item(batch, item, at, size)
}
fn screen_title(&self) -> Option<String> {
self.native_screen_title()
}
fn data(&self, path: &str) -> Option<Vec<u8>> {
self.native_data(path)
}
fn block(&self, pos: [i32; 3]) -> String {
self.native_block(pos)
}
fn block_property(&self, pos: [i32; 3], key: &str) -> Option<String> {
self.native_block_property(pos, key)
}
fn insert_item(
&mut self,
pos: [i32; 3],
face: Direction,
stack: ItemStack,
) -> Option<ItemStack> {
self.native_insert_item(pos, face, stack)
}
fn cursor(&self) -> [f32; 2] {
self.native_cursor()
}
fn text(&self, batch: &mut HudBatch, text: &str, at: [f32; 2], rgb: u32, shadow: bool) {
self.native_text(batch, text, at, rgb, shadow)
}
fn built_data(&self, mod_id: &str) -> Option<PathBuf> {
self.native_built_data(mod_id)
}
fn panel_items(&mut self, mod_id: &str, items: Vec<NativeStack>) {
self.native_panel_items(mod_id, items)
}
fn recipes_of_type(&self, kind: &str) -> &[CustomRecipe] {
self.native_recipes_of_type(kind)
}
fn recipe_ingredient(&self, json: &serde_json::Value) -> Option<Ingredient> {
self.native_recipe_ingredient(json)
}
fn recipe_result(&self, json: &serde_json::Value) -> Option<ItemStack> {
self.native_recipe_result(json)
}
fn known_recipes(&mut self) -> Arc<[i32]> {
self.native_known_recipes()
}
fn place_recipe(&mut self, book_id: i32, all: bool) -> Result<(), String> {
self.native_place_recipe(book_id, all)
}
fn give(&mut self, item: &str, count: u32) -> Result<(), String> {
self.native_give(item, count)
}
fn push_player(&mut self, kick: [f32; 3]) {
self.native_push_player(kick)
}
fn set_flying_speed(&mut self, player: u128, speed: Option<f32>) -> bool {
self.native_set_flying_speed(player, speed)
}
fn net_send_to_player(
&mut self,
player: u128,
channel: &str,
data: &[u8],
) -> Result<(), String> {
self.native_net_send_to_player(player, channel, data)
}
fn net_send_to_server(&mut self, channel: &str, data: &[u8]) -> Result<(), String> {
self.native_net_send_to_server(channel, data)
}
fn dispense_default(
&mut self,
dimension: &str,
pos: [i32; 3],
facing: Direction,
stack: ItemStack,
) -> Option<ItemStack> {
self.native_dispense_default(dimension, pos, facing, stack)
}
fn open_menu(
&mut self,
uuid: u128,
rows: u8,
title: Text,
items: Vec<Option<ItemStack>>,
) -> Option<i32> {
self.native_open_menu(uuid, rows, title, items)
}
fn set_menu_items(&mut self, uuid: u128, items: Vec<Option<ItemStack>>) {
self.native_set_menu_items(uuid, items)
}
fn close_menu(&mut self, uuid: u128) {
self.native_close_menu(uuid)
}
fn player_stacks(&self, uuid: u128) -> Option<Vec<Option<ItemStack>>> {
self.native_player_stacks(uuid)
}
fn exchange_items(
&mut self,
a: u128,
a_offer: &[(usize, ItemStack)],
b: u128,
b_offer: &[(usize, ItemStack)],
) -> Result<(), String> {
self.native_exchange_items(a, a_offer, b, b_offer)
}
fn fluid_insert(&mut self, pos: [i32; 3], face: Direction, fluid: &str, amount: i32) -> i32 {
self.native_fluid_insert(pos, face, fluid, amount)
}
fn fluid_extract(
&mut self,
dimension: &str,
pos: [i32; 3],
face: Direction,
amount: i32,
) -> Option<(String, i32)> {
self.native_fluid_extract(dimension, pos, face, amount)
}
fn move_fluids(
&mut self,
dimension: &str,
from: [i32; 3],
from_face: Direction,
to: [i32; 3],
to_face: Direction,
max: i32,
) -> Option<(String, i32)> {
self.native_move_fluids(dimension, from, from_face, to, to_face, max)
}
fn dimension(&self) -> &'static str {
self.native_dimension()
}
fn extract_item(
&mut self,
dimension: &str,
pos: [i32; 3],
face: Direction,
slot: usize,
count: u32,
) -> Option<ItemStack> {
self.native_extract_item(dimension, pos, face, slot, count)
}
fn move_items(
&mut self,
dimension: &str,
from: [i32; 3],
from_face: Direction,
to: [i32; 3],
to_face: Direction,
item: Option<&str>,
max: u32,
) -> u32 {
self.native_move_items(dimension, from, from_face, to, to_face, item, max)
}
}
File diff suppressed because it is too large. Load diff
@@ -5,28 +5,16 @@
use std::sync::atomic::{AtomicU64, Ordering};
use super::{Game, execution_role::ExecutionRole};
// Their old home, for mixin code that names them here.
pub(super) use horizon_client::mod_api::{
NativeAuthority, NativeLifecycleContext, NativeTickId, NativeWorldEpoch, ServerTickPhase,
WorldPhase,
};
/// Epoch allocation spans `Game` instances (a new server `Game` per
/// launch), so the counter is process-wide.
static NEXT_EPOCH: AtomicU64 = AtomicU64::new(1);
/// Which `Game` observes the phase; a mirror `Game` never fires.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum NativeAuthority {
LocalAuthoritative,
IntegratedServer,
DedicatedServer,
RemoteClient,
}
/// One open attempt of one world; it rejects a previous open's commands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct NativeWorldEpoch(u64);
/// Non-paused authoritative server ticks since open (not game or frame time).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct NativeTickId(u64);
/// Internal state for deciding which events can fire.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum OpenState {
@@ -35,39 +23,6 @@ enum OpenState {
Closing,
}
/// The world phases (`native_world_lifecycle`'s `phase`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
/// The phase names are the hook log's stable vocabulary (the Debug text
/// the mixins write); keep them despite the shared prefix.
#[allow(clippy::enum_variant_names)]
pub(super) enum WorldPhase {
WorldOpenReady,
/// The open failed; `error_text` is the engine's own error text.
WorldOpenFailed,
WorldCloseFinal,
/// The server loop or final save failed; `error_text` carries the error.
WorldCloseFailed,
}
/// The tick phases carry whether the world runs, including frozen steps.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) enum ServerTickPhase {
ServerTickStart { world_running: bool },
ServerTickEnd { world_running: bool },
}
/// Context for every world lifecycle phase.
/// `authority` is the server role. LocalAuthoritative and RemoteClient never
/// fire today. `epoch` identifies the open attempt, including failed opens;
/// the same epoch identifies its ready, final-close, or failed-close event.
/// `tick` counts non-paused server ticks since the world opened.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(super) struct NativeLifecycleContext {
pub(super) authority: NativeAuthority,
pub(super) epoch: NativeWorldEpoch,
pub(super) tick: NativeTickId,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct OpenAttempt {
authority: NativeAuthority,
@@ -83,8 +38,8 @@ impl OpenAttempt {
fn context(&self) -> NativeLifecycleContext {
NativeLifecycleContext {
authority: self.authority,
epoch: NativeWorldEpoch(self.epoch),
tick: NativeTickId(self.ticks),
epoch: NativeWorldEpoch::new(self.epoch),
tick: NativeTickId::new(self.ticks),
}
}
}
@@ -350,7 +305,7 @@ mod tests {
let mut lifecycle = opening();
let context = lifecycle.world_open_ready().expect("the first ready fires");
assert_eq!(context.authority, NativeAuthority::IntegratedServer);
assert!(context.epoch.0 >= 1, "the epoch is globally allocated");
assert!(context.epoch.get() >= 1, "the epoch is globally allocated");
assert!(lifecycle.world_open_ready().is_none(), "at most once");
}
@@ -363,7 +318,7 @@ mod tests {
assert!(lifecycle.server_tick_start(true).is_none());
lifecycle.begin_open(NativeAuthority::IntegratedServer);
let context = lifecycle.world_open_ready().expect("the retry opens fresh");
assert!(context.epoch.0 > failed_epoch.0, "a fresh epoch");
assert!(context.epoch.get() > failed_epoch.get(), "a fresh epoch");
}
#[test]
@@ -372,7 +327,7 @@ mod tests {
lifecycle.world_open_ready();
lifecycle.begin_close();
let context = lifecycle.world_close_final().expect("the close fires");
assert!(context.epoch.0 >= 1);
assert!(context.epoch.get() >= 1);
assert!(
lifecycle.world_close_final().is_none(),
"a repeated close request is a no-op"
@@ -8,33 +8,13 @@
//! players (the hub's: a dedicated server's, a LAN host's, single player's
//! server thread's), never on a client's.
use super::{Dim, Game, commands::execute, lan::LanHost};
// Their old home, for mixin code that names them here.
pub(crate) use horizon_client::mod_api::{NativeCommand, NativeServerPlayer};
use horizon_client::{items::ItemStack, lan::data::component_nbt, text::Text};
use horizon_protocol::packets::play::{self, Clientbound as C};
use serde_json::{Map, Value, json};
use std::{collections::HashMap, path::PathBuf};
/// A player on the server, as a server hook sees them.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct NativeServerPlayer {
pub(crate) uuid: u128,
pub(crate) name: String,
/// The operator level, 0 to 4 (`ops.json`; single player's owner with
/// cheats on is 4).
pub(crate) permission: u8,
/// `minecraft:overworld`, `minecraft:the_nether`, `minecraft:the_end`.
pub(crate) dimension: String,
pub(crate) position: [f64; 3],
/// 0 survival, 1 creative, 2 adventure, 3 spectator.
pub(crate) game_mode: i32,
/// Health points, 20 at full; 0 once dead. API 21.
pub(crate) health: f32,
/// Where they look, in degrees (`yRot`, `xRot`), as their last move told
/// the server. API 49.
pub(crate) yaw: f32,
pub(crate) pitch: f32,
}
/// A sidebar a server mod shows one player (`native_sidebar`): its title and
/// up to 15 lines, top first, each a text on the left and, where the score's
/// number would be, an optional text on the right. API 49.
@@ -49,39 +29,6 @@ pub(crate) struct NativeSidebar {
/// server's scoreboard never holds it.
const SIDEBAR_OBJECTIVE: &str = "horizon:native_sidebar";
/// A command a native mod adds, as one usage: words, then arguments
/// `<name:kind>`, optional ones last in brackets `[name:kind]`:
/// `pay <player:player> <amount:integer>`, `balance [player:player]`,
/// `eco give <player:player> <amount:integer>`. Kinds: `word`, `text` (the
/// rest of the line, last), `integer`, `number`, `boolean`, `player`,
/// `item`, `block_pos`. Usages of one command share their words; the
/// server merges them into one tree.
#[allow(dead_code)]
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct NativeCommand {
pub(crate) usage: String,
/// The operator level a player needs to see and run it (0: everyone).
pub(crate) level: u8,
}
#[allow(dead_code)]
impl NativeCommand {
/// A usage every player may run.
pub(crate) fn new(usage: &str) -> Self {
Self {
usage: usage.to_owned(),
level: 0,
}
}
/// The same, for operators of `level` and above.
pub(crate) fn op(usage: &str, level: u8) -> Self {
Self {
usage: usage.to_owned(),
level: level.min(4),
}
}
}
/// The brigadier parser of an argument kind, and its properties.
fn parser(kind: &str) -> Option<(&'static str, Value)> {
Some(match kind {
@@ -1,5 +1,6 @@
//! Read-only block and tag queries for native server hooks.
use super::{Dim, Game, native_lifecycle::NativeLifecycleContext};
use super::{Dim, Game};
use horizon_client::mod_api::NativeLifecycleContext;
use horizon_client::{
items::{ItemStack, Items},
world::World,
@@ -1035,7 +1035,7 @@ impl Game<'_> {
self.persist_raw(id),
);
self.remove_dropped(i);
self.forget_entity(id);
self.detach_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.tnts.iter().position(|t| t.id == id) {
@@ -1094,7 +1094,7 @@ impl Game<'_> {
}
self.mobs.retain(|m| !gone.contains(&m.id));
for g in gone {
self.forget_entity(g);
self.detach_entity(g);
}
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
@@ -1118,7 +1118,7 @@ impl Game<'_> {
b.chest.then_some(&b.items[..]),
);
self.boats.remove(i);
self.forget_entity(id);
self.detach_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self.carts.iter().position(|c| c.id == id) {
@@ -1135,7 +1135,7 @@ impl Game<'_> {
let uuid = self.uuid_for(id);
let tag = self.carts[i].to_nbt(uuid, self.persist_raw(id));
self.carts.remove(i);
self.forget_entity(id);
self.detach_entity(id);
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
} else if let Some(i) = self
@@ -1331,7 +1331,20 @@ impl Game<'_> {
if !self.portals.dirty || self.remote.is_some() {
return;
}
let list = self.portals.arrivals.iter().map(arrival_tag).collect();
let list = self
.portals
.arrivals
.iter()
.map(|arrival| {
let mut tag = arrival_tag(arrival);
if let Tag::Compound(root) = &mut tag
&& let Some(entity) = root.get_mut(&"entity".into())
{
super::persist::write_attachments(entity, &self.entity_attachments);
}
tag
})
.collect();
let root = Tag::compound([("arrivals", Tag::List(list))]);
if horizon_client::save::write_dat(&self.arrivals_path(), root).is_ok() {
self.portals.dirty = false;
+1 -1
View File
@@ -478,7 +478,7 @@ impl Game<'_> {
s.horizon.hdr,
s.horizon.paper_white_nits() as f32,
s.horizon.peak_nits() as f32,
s.horizon.hdr_black_level as f32 / 100.0,
s.horizon.black_level_nits(),
s.horizon.hdr_ui_brightness as f32 / 100.0,
&Self::options_path().with_file_name(horizon_client::graphics::HDR_MARKER),
);
+104 -3
View File
@@ -15,6 +15,7 @@ use horizon_core::{
random::RandomSource,
ticks::ChunkTicks,
};
use horizon_world::attachments::{ATTACHMENTS_KEY, attachment_compound};
use horizon_world::{level::MovingPiston, nbt::Tag, registry::BlockStateId};
use std::{
collections::{HashMap, HashSet},
@@ -112,6 +113,42 @@ pub(super) fn with_raw(modelled: Tag, raw: Option<&Tag>) -> Tag {
Tag::Compound(out)
}
fn read_attachments(
tag: &Tag,
uuid: [i32; 4],
maps: &mut HashMap<[i32; 4], horizon_world::attachments::Attachments>,
) {
if let Ok(Some(data)) = attachment_compound(tag.get(ATTACHMENTS_KEY)) {
maps.entry(uuid)
.or_insert_with(|| horizon_world::attachments::Attachments::from_nbt(data.clone()));
}
}
/// Overlay live attachment maps on entity tags, including passenger trees.
pub(super) fn write_attachments(
tag: &mut Tag,
maps: &HashMap<[i32; 4], horizon_world::attachments::Attachments>,
) {
let uuid = entity_nbt::uuid(tag);
if let Tag::Compound(root) = tag {
if let Some(map) = uuid.and_then(|id| maps.get(&id))
&& attachment_compound(root.get(&ATTACHMENTS_KEY.into())).is_ok()
{
let data = map.to_nbt();
if data.is_empty() {
root.remove(&ATTACHMENTS_KEY.into());
} else {
root.insert(ATTACHMENTS_KEY.into(), Tag::Compound(data));
}
}
if let Some(Tag::List(passengers)) = root.get_mut(&"Passengers".into()) {
for passenger in passengers {
write_attachments(passenger, maps);
}
}
}
}
fn painting_center(p: &horizon_client::entities::Painting) -> Vec3 {
let (lo, hi) = p.bounds();
Vec3::new(
@@ -134,6 +171,10 @@ impl Game<'_> {
let pos = ChunkPos::new(chunk.x, chunk.z);
let time = self.world.game_time;
let mut extras: ChunkExtras = chunk.saved.as_deref().cloned().unwrap_or_default();
extras.block_entity_attachments = self
.world
.block_entities
.chunk_attachments(chunk.x, chunk.z);
// A chunk the scheduler hasn't registered yet (one loaded and
// unloaded again before the next tick) still holds its saved ticks:
// `register_chunk_ticks` empties that list once it takes them.
@@ -848,7 +889,8 @@ impl Game<'_> {
) -> Option<(Tag, u32, [i32; 4])> {
let vehicle = vehicle?;
let root = parent_vehicle_root(vehicle, others, |id| self.vehicle_of(id))?;
let entity = self.vehicle_tree_nbt(root)?;
let mut entity = self.vehicle_tree_nbt(root)?;
write_attachments(&mut entity, &self.entity_attachments);
let attach = self.uuid_for(vehicle);
Some((root_vehicle_tag(attach, entity), root, attach))
}
@@ -992,6 +1034,13 @@ impl Game<'_> {
}
/// Forget an entity that left the world (its UUID and loaded compound).
pub(super) fn forget_entity(&mut self, id: u32) {
if let Some(uuid) = self.persist.uuids.remove(&id) {
self.entity_attachments.remove(&uuid);
}
self.persist.raw.remove(&id);
}
/// Detach a travelling entity without discarding its in-memory attachments.
pub(super) fn detach_entity(&mut self, id: u32) {
self.persist.uuids.remove(&id);
self.persist.raw.remove(&id);
}
@@ -1054,6 +1103,7 @@ impl Game<'_> {
self.restore_portal_cooldown(id, &tag);
if let Some(u) = uuid {
self.persist.uuids.insert(id, u);
read_attachments(&tag, u, &mut self.entity_attachments);
}
self.persist.raw.insert(id, tag);
}
@@ -1360,6 +1410,11 @@ impl Game<'_> {
out.entry(*key).or_default().extend(list);
}
}
for tags in out.values_mut() {
for tag in tags {
write_attachments(tag, &self.entity_attachments);
}
}
if remove {
let gone = |p: Vec3| keys.contains(&chunk_of(p));
let mut removed: Vec<u32> = Vec::new();
@@ -1468,8 +1523,7 @@ impl Game<'_> {
.anon
.retain(|_, (at, _, _)| !keys.contains(&chunk_of(*at)));
for id in removed {
self.persist.uuids.remove(&id);
self.persist.raw.remove(&id);
self.forget_entity(id);
}
}
out.into_iter().collect()
@@ -1509,6 +1563,11 @@ impl Game<'_> {
.chain(self.dragon.iter().map(|e| e.id))
.chain(self.tnts.iter().map(|e| e.id))
.collect();
for (id, uuid) in &self.persist.uuids {
if !alive.contains(id) {
self.entity_attachments.remove(uuid);
}
}
self.persist.uuids.retain(|id, _| alive.contains(id));
self.persist.raw.retain(|id, _| alive.contains(id));
}
@@ -1638,6 +1697,48 @@ mod tests {
}
}
#[test]
fn entity_attachment_boundary_includes_passengers() {
use horizon_world::attachments::{
AttachmentCodec, AttachmentError, AttachmentType, Attachments,
};
let key = AttachmentType {
namespace: "test",
name: "counter",
default: || 0,
codec: Some(AttachmentCodec {
encode: |n: &i32| Ok([("count".into(), Tag::Int(*n))].into()),
decode: |c| match c.get(&"count".into()) {
Some(Tag::Int(n)) => Ok(*n),
_ => Err(AttachmentError::Decode),
},
}),
copy_on_death: false,
};
let root_id = [1, 2, 3, 4];
let child_id = [5, 6, 7, 8];
let mut data = Attachments::default();
data.set(&key, 29).unwrap();
let maps = [(child_id, data)].into();
let mut root = Tag::compound([
("UUID", Tag::IntArray(root_id.to_vec())),
(
"Passengers",
Tag::List(vec![Tag::compound([(
"UUID",
Tag::IntArray(child_id.to_vec()),
)])]),
),
]);
write_attachments(&mut root, &maps);
let Some(Tag::List(children)) = root.get("Passengers") else {
panic!("passenger missing");
};
let mut loaded = HashMap::new();
read_attachments(&children[0], child_id, &mut loaded);
assert_eq!(loaded.get_mut(&child_id).unwrap().get(&key), Ok(29));
}
#[test]
fn a_lone_rider_saves_the_horse() {
let horse = 7;
+11 -7
View File
@@ -826,19 +826,19 @@ impl Game<'_> {
light: u32,
out: &mut Vec<horizon_client::mesher::Vertex>,
) {
let stack = m.extra.armor[1]
.as_ref()
.filter(|s| chest == Some(&*s.item));
self.wings_geometry(
chest,
stack,
m.cape.as_deref(),
false,
horizon_client::mobs::render::base_transform(m, partial, camera),
m.extra.elytra.angles(partial),
m.extra.crouching,
light,
self.wings_overlay(
m.extra.armor[1]
.as_ref()
.filter(|s| chest == Some(&*s.item)),
),
self.wings_overlay(stack),
out,
);
}
@@ -857,11 +857,14 @@ impl Game<'_> {
/// `WingsLayer.submit` for any wearer: the wings of `chest`'s equipment
/// asset on `base` (the wearer's model transform), `baby` for the baby
/// elytra model. `cape` is a player's own cape texture, which has its
/// elytra (`WingsLayer.getPlayerElytraTexture`).
/// elytra (`WingsLayer.getPlayerElytraTexture`). `stack` is the worn
/// stack when known: an OptiFine CIT elytra rule for it replaces the
/// texture, the cape's too, as OptiFine's `LayerElytra` does.
#[allow(clippy::too_many_arguments)]
pub(super) fn wings_geometry(
&self,
chest: Option<&str>,
stack: Option<&horizon_client::items::ItemStack>,
cape: Option<&str>,
baby: bool,
base: glam::Mat4,
@@ -887,7 +890,8 @@ impl Game<'_> {
return;
};
let sprites = &self.gui.atlas.sprites;
let rect = cape.and_then(|c| sprites.get(c));
let rule = stack.and_then(|s| self.cit_worn_rule(horizon_client::cit::Kind::Elytra, s));
let rect = horizon_client::cit_armor::wings(rule, cape, |s| sprites.get(s));
let (Some(model), Some(rect)) = (
assets
.player
+6 -3
View File
@@ -327,8 +327,9 @@ impl Game<'_> {
velocity: Vec3,
owner: Option<Text>,
) {
let id = self.take_entity_id();
self.potions.thrown.push(ThrownPotion {
id: 0,
id,
stack,
previous: position,
position,
@@ -1270,8 +1271,9 @@ impl Game<'_> {
ambient: Vec<&'static str>,
color: u32,
) {
let id = self.take_entity_id();
self.breath_clouds.push(Breath {
id: 0,
id,
at,
radius: 2.5,
age: 0,
@@ -1601,8 +1603,9 @@ impl Game<'_> {
// potion with no effects leaves none.
if !def.effects.is_empty() {
let at = mob.unwrap_or(at);
let id = self.take_entity_id();
self.breath_clouds.push(Breath {
id: 0,
id,
at: [at.x, at.y, at.z],
radius: 3.0,
age: 0,
+1
View File
@@ -186,6 +186,7 @@ impl Game<'_> {
if keys.is_empty() {
self.prune_entity_memory();
self.save_raids();
self.save.save_attachments(&self.world.attachments)?;
self.save.save_forced_chunks(&self.streaming.forced)?;
self.save_game_metadata()?;
save.stage = Stage::Writes;
@@ -293,7 +293,7 @@ impl StartupMenu {
| KeyCode::ArrowUp
| KeyCode::ArrowDown),
) => {
title.arrow_key(key, viewport);
title.arrow_key(key, viewport, self.modifiers.shift_key());
}
_ => {
let cleared = !title.has_caret_field()
+17 -13
View File
@@ -5,7 +5,7 @@
//! lightning on a hit (`channeling.json`).
use super::{Game, view_vector};
use horizon_client::{
arrows::{back_off, clip_block_stop},
arrows::{back_off, clip_block_hit},
collisions::SceneCollisions,
enchant::EffectContext,
items::ItemStack,
@@ -433,8 +433,9 @@ impl Game<'_> {
v,
on_ground,
);
let id = self.take_entity_id();
self.tridents.push(ThrownTrident {
id: 0,
id,
loyalty: thrown.enchantment_level("minecraft:loyalty"),
stack: thrown,
position: start,
@@ -606,8 +607,14 @@ impl Game<'_> {
}
// ProjectileImpactEvent for the mob hit: a canceled one does
// nothing, and the trident stays where it would have hit.
let canceled_mob = hit_mob.is_some_and(|(id, _)| {
self.native_trident_impact(t.id, t.guest, Some(id), [0; 3], [0; 3], t.position)
let canceled_mob = hit_mob.is_some_and(|(id, d)| {
// `at` is where the movement enters the mob's box.
let at = Vec3::new(
t.position.x + t.velocity.x * d,
t.position.y + t.velocity.y * d,
t.position.z + t.velocity.z * d,
);
self.native_trident_impact(t.id, t.guest, Some(id), [0; 3], [0; 3], at)
});
if canceled_mob {
// No damage, sound or deflection: it flies on.
@@ -696,26 +703,23 @@ impl Game<'_> {
// `AbstractArrow.onHitBlock` on a hit.
let from = t.position;
let flight = t.velocity;
let clip = clip_block_stop(
&self.world,
self.res.facts(),
self.collision,
from,
flight,
);
let clip =
clip_block_hit(&self.world, self.res.facts(), self.collision, from, flight);
// ProjectileImpactEvent for the block hit: a canceled one
// doesn't stick the trident, which stays where it would
// have hit, still flying.
let canceled_block = clip.is_some_and(|(stop, block, face)| {
// The hook's `at` is the clip's hit location, before the back-off.
let canceled_block = clip.is_some_and(|(at, block, face)| {
self.native_trident_impact(
t.id,
t.guest,
None,
[block.x, block.y, block.z],
face,
stop,
at,
)
});
let clip = clip.map(|(at, block, face)| (back_off(at, flight), block, face));
if canceled_block && let Some((stop, _, _)) = clip {
t.position = stop;
} else if let Some((stop, block, _)) = clip {
+4 -8
View File
@@ -459,19 +459,15 @@ impl Game<'_> {
}
fn hdr_input(&mut self, name: &str) -> bool {
if self.screen != Screen::Playing
|| self.title.is_some()
|| self.settings.horizon.hdr_key != name
|| !horizon_client::graphics::hdr_offered()
{
return false;
}
if self.title.is_none() {
self.capture(false);
}
self.open_options_at(
self.title.is_some(),
horizon_client::options_ui::Page::GraphicsHdr,
);
self.options_to_game = self.title.is_none();
self.capture(false);
self.open_options_at(false, horizon_client::options_ui::Page::GraphicsHdr);
self.options_to_game = true;
true
}
fn sort_input(&mut self, name: &str) -> bool {
@@ -242,6 +242,7 @@ impl WorldRequest {
)?;
streaming.radius = settings.render_distance;
let mut world = World::new(res.facts().air, level.seed);
world.attachments = save.load_attachments();
world.shape = shape;
world.fast_lava = dim == Dim::Nether;
world.game_time = level.time;
+46 -13
View File
@@ -6,6 +6,7 @@
//! and the special model renderers submit, in pose space. A sink then draws
//! them: [`gui`] into the HUD batch with the GUI item lighting, [`WorldSink`]
//! into entity vertices with the level's lighting.
pub mod cit;
pub mod gui;
mod special;
@@ -201,6 +202,8 @@ pub struct ItemRenderState {
pub layers: Vec<LayerRender>,
pub oversized_in_gui: bool,
pub animated: bool,
/// The OptiFine CIT glint layers the stack draws in the GUI (`cit_glint`).
pub cit_glint: Option<Arc<crate::cit_glint::StackGlint>>,
/// HDR output's tier of an item that glows (`dynamic_light::Luminance::hdr_tier`).
pub hdr_tier: u8,
/// In HDR output, the block light of an item that glows: its block's, as a placed one has
@@ -498,6 +501,9 @@ pub struct ItemRenderer {
grass: Vec<i32>,
/// `minecraft:item` registry ids (`Item.getId`).
item_ids: HashMap<String, i32>,
/// The enabled packs' OptiFine CIT rules and their swapped bakes; `None`
/// without rules.
pub cit: Option<cit::CitModels>,
}
/// Every `items/**/*.json` client item definition, by identifier.
@@ -582,7 +588,7 @@ impl ItemRenderer {
},
prototypes,
};
let library = library?;
let (library, cit) = cit::load(root, library?);
let permutations = PalettedPermutations::from_atlas(
&fs::read_to_string(root.join("assets/minecraft/atlases/items.json"))
.unwrap_or_default(),
@@ -626,6 +632,7 @@ impl ItemRenderer {
gui_drawn: Mutex::default(),
parts,
grass,
cit,
})
}
/// `ModelBakery` item baking, cached per model.
@@ -661,16 +668,7 @@ impl ItemRenderer {
{
return hit.clone();
}
let mut out: Vec<Vec3> = Vec::new();
for q in &baked.quads {
for p in q.positions {
let p = Vec3::from(p);
if !out.contains(&p) {
out.push(p);
}
}
}
let out = Arc::new(out);
let out = Arc::new(corners(baked));
if let Ok(mut cache) = self.extents.lock() {
cache.insert(model.to_owned(), out.clone());
}
@@ -698,6 +696,22 @@ impl ItemRenderer {
level: Option<&LevelState>,
seed: i32,
env: &dyn ItemEnv,
) -> ItemRenderState {
self.resolve_with_rule(stack, display, owner, level, seed, env, None)
}
/// [`Self::resolve`] for a stack OptiFine CIT item rule `rule` (an index
/// into `cit.rules.rules`) matched: its model layers draw what the rule
/// swaps them for.
#[allow(clippy::too_many_arguments)]
pub fn resolve_with_rule(
&self,
stack: &StackView,
display: DisplayContext,
owner: Option<&OwnerState>,
level: Option<&LevelState>,
seed: i32,
env: &dyn ItemEnv,
rule: Option<usize>,
) -> ItemRenderState {
let ctx = Context {
display,
@@ -713,8 +727,13 @@ impl ItemRenderer {
.map(|layer| {
let (geometry, baked, extents) = match layer.content {
LayerContent::Model { model } => {
let baked = self.bake(&model);
let extents = self.quad_extents(&model, &baked);
let (baked, extents) = rule
.and_then(|rule| self.cit_layer(rule, &model))
.unwrap_or_else(|| {
let baked = self.bake(&model);
let extents = self.quad_extents(&model, &baked);
(baked, extents)
});
(LayerGeometry::Quads(baked.clone()), baked, extents)
}
LayerContent::Missing => {
@@ -751,6 +770,7 @@ impl ItemRenderer {
layers,
oversized_in_gui: resolved.oversized_in_gui,
animated,
cit_glint: None,
hdr_tier: 0,
hdr_light: 0,
}
@@ -786,6 +806,19 @@ impl ItemRenderer {
self.parts.get(key)
}
}
/// `CuboidItemModelWrapper.computeExtents`: the distinct quad corners.
fn corners(baked: &BakedItemModel) -> Vec<Vec3> {
let mut out: Vec<Vec3> = Vec::new();
for q in &baked.quads {
for p in q.positions {
let p = Vec3::from(p);
if !out.contains(&p) {
out.push(p);
}
}
}
out
}
/// A `horizon-assets` (row-major, JOML-exact) matrix as glam's.
pub fn glam_matrix(m: &horizon_assets::math::Mat4) -> Mat4 {
Mat4::from_cols_array_2d(&m.rows()).transpose()
@@ -0,0 +1,810 @@
//! OptiFine Custom Item Textures on item models: the layer of a stack a `type=item` rule matches
//! draws the rule's model, or its own model with the rule's textures in some slots. The rules'
//! JSON models join the model library, their textures the atlas (`cit_assets`).
use super::{ItemRenderer, corners};
use crate::cit::{CitRules, Hand, Kind};
use crate::cit_assets::{self, model_id, sprite_id};
use crate::hud::cit_glint::GlintTextures;
use crate::items::ItemStack;
use glam::Vec3;
use horizon_assets::{
item_bake::{BakedItemModel, bake_resolved},
model::{Material, TextureSlot},
resolve::{MISSING_MODEL, ModelLibrary},
};
use horizon_world::nbt::Tag;
use std::{
collections::{BTreeMap, HashMap},
path::Path,
sync::{Arc, Mutex, MutexGuard, PoisonError},
};
/// A baked layer and its quad corners, as `ItemRenderer::bake` and `quad_extents` give them.
pub(super) type Baked = (Arc<BakedItemModel>, Arc<Vec<Vec3>>);
/// What one item rule swaps, as model and sprite ids.
#[derive(Debug, Default)]
struct Plan {
model: Option<String>,
/// `model.<name>`, by the leaf model's [`short`] name.
models: HashMap<String, String>,
texture: Option<String>,
/// `texture.<name>`: a leaf, a slot or a sprite's file name, and its sprite.
textures: Vec<(String, String)>,
}
/// One swapped bake: the model and the sprite each changed slot takes, `None`
/// for a layer cleared; and the generic texture, whose quads are left untinted.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
struct CitBake {
base: String,
slots: BTreeMap<String, Option<String>>,
untinted: Option<String>,
}
/// The enabled packs' rules and what their item rules bake to.
#[derive(Debug)]
pub struct CitModels {
pub rules: CitRules,
/// The enchantment rules' glint textures.
pub glint_textures: GlintTextures,
/// Per rule (an index into `rules.rules`); `None` for rules of other kinds.
plans: Vec<Option<Plan>>,
/// Per (rule, leaf model): its bake, `None` where the rule leaves the leaf alone.
memo: Mutex<HashMap<(usize, String), Option<CitBake>>>,
baked: Mutex<HashMap<CitBake, Baked>>,
}
fn lock<T>(m: &Mutex<T>) -> MutexGuard<'_, T> {
m.lock().unwrap_or_else(PoisonError::into_inner)
}
/// The last segment of an id: `minecraft:item/bow_pulling_1` is `bow_pulling_1`.
fn short(id: &str) -> &str {
let path = id.rsplit_once(':').map_or(id, |(_, p)| p);
path.rsplit('/').next().unwrap_or(path)
}
/// The 26.x name of an OptiFine sub-texture or sub-model name: the bow's
/// `bow_standby` frame is model `item/bow`, and the potions' bottle layers are
/// the files `potion`, `splash_potion` and `lingering_potion`.
fn legacy(name: &str) -> &str {
match name {
"bow_standby" => "bow",
"potion_bottle_drinkable" => "potion",
"potion_bottle_splash" => "splash_potion",
"potion_bottle_lingering" => "lingering_potion",
other => other,
}
}
/// The `max_damage` a CIT rule's `damage` test reads: the stack's own
/// component when it holds a non-negative int, else `item_max`, its item's.
pub fn stack_max_damage(stack: &ItemStack, item_max: u32) -> u32 {
stack
.data()
.and_then(|d| match d.other("minecraft:max_damage") {
Some(Tag::Int(v)) => u32::try_from(*v).ok(),
_ => None,
})
.unwrap_or(item_max)
}
/// The winning `kind` (Armor or Elytra) rule of a worn `stack` whose item has `item_max` max
/// damage, as an index into `rules.rules`. Worn items count as the main hand: OptiFine's off hand
/// is the off-hand slot alone.
pub fn worn_rule(rules: &CitRules, kind: Kind, stack: &ItemStack, item_max: u32) -> Option<usize> {
rules.select(kind, stack, Hand::Main, stack_max_damage(stack, item_max))
}
/// The rules at `root` and `library` with their models, or `library` alone without rules.
pub(super) fn load(
root: &Path,
library: Arc<ModelLibrary>,
) -> (Arc<ModelLibrary>, Option<CitModels>) {
let rules = CitRules::load(root);
// A `cit.properties` alone can still turn vanilla's glint off.
if rules.is_empty() && rules.global.use_glint {
return (library, None);
}
let extra = cit_assets::load_models(root, &rules.rules);
let library = if extra.is_empty() {
library
} else {
match library.overlaid(extra) {
Ok(over) => Arc::new(over),
Err(e) => {
log::warn!("custom item textures: models left out: {e}");
library
}
}
};
let mut models = CitModels::new(rules);
models.glint_textures = GlintTextures::load(root, &models.rules.rules);
// An enchantment rule whose texture has no slot draws nothing, so it must not take its
// layer (or vanilla's glint) from the stack either.
let slots = &models.glint_textures.slot_of;
models
.rules
.keep_glints(|i| slots.get(i).copied().flatten().is_some());
(library, Some(models))
}
impl CitModels {
fn new(rules: CitRules) -> Self {
// By 26.x name, in file order. A 26.x name beats OptiFine's old name
// for the same thing (`texture.bow` over `texture.bow_standby`)
// wherever either stands; of two alike, the later wins.
let pairs = |list: &[(String, String)], id: fn(&str) -> Option<String>| {
let mut out: Vec<(String, String, bool)> = Vec::new();
for (name, path) in list {
let Some(asset) = id(path) else { continue };
let (old, new) = (short(name), legacy(short(name)));
let modern = old == new;
match out.iter_mut().find(|(n, ..)| n.as_str() == new) {
Some(entry) if modern || !entry.2 => *entry = (new.to_owned(), asset, modern),
Some(_) => {}
None => out.push((new.to_owned(), asset, modern)),
}
}
out.into_iter()
.map(|(name, asset, _)| (name, asset))
.collect::<Vec<_>>()
};
let plans = rules
.rules
.iter()
.map(|r| {
(r.kind == Kind::Item).then(|| Plan {
model: r.model.as_deref().and_then(model_id),
models: pairs(&r.models, model_id).into_iter().collect(),
texture: r.texture.as_deref().and_then(sprite_id),
textures: pairs(&r.textures, sprite_id),
})
})
.collect();
Self {
rules,
glint_textures: GlintTextures::default(),
plans,
memo: Mutex::default(),
baked: Mutex::default(),
}
}
}
impl ItemRenderer {
/// The layer whose model is `leaf` as item rule `rule` draws it; `None` draws it as usual.
pub(super) fn cit_layer(&self, rule: usize, leaf: &str) -> Option<Baked> {
let cit = self.cit.as_ref()?;
let key = (rule, leaf.to_owned());
let known = lock(&cit.memo).get(&key).cloned();
let bake = match known {
Some(bake) => bake,
None => {
let bake = self.cit_plan(cit, rule, leaf);
lock(&cit.memo).insert(key, bake.clone());
bake
}
}?;
if let Some(hit) = lock(&cit.baked).get(&bake) {
return Some(hit.clone());
}
let baked = self.cit_bake(&bake);
lock(&cit.baked).insert(bake, baked.clone());
Some(baked)
}
/// The model choice for one leaf, as CIT Resewn's wiki (CIT Item) gives it:
/// `model.<leaf>` draws that model. `texture.<leaf>` replaces the leaf
/// with the item generated from it (on a model with elements, its every
/// face); on a leaf of several layers, one of
/// which is a slot or a sprite file named `<leaf>` (the 26.x potion's
/// bottle is `layer1`, file `potion`), it is that layer's texture instead.
/// Else `model` (or the leaf) with `texture` in every slot of a model, or
/// the item generated from `texture`. Each `texture.<name>` goes to the
/// slot `name` and to every slot whose sprite's file is `name`
/// (`potion_overlay`); the generic `texture` takes the layers none of
/// them claims. A model that did not load, or whose parents do not,
/// leaves the leaf as it is.
fn cit_plan(&self, cit: &CitModels, rule: usize, leaf: &str) -> Option<CitBake> {
let plan = cit.plans.get(rule)?.as_ref()?;
let sub = short(leaf);
if let Some(model) = plan.models.get(sub) {
return self.library.resolves(model).then(|| CitBake {
base: model.clone(),
slots: BTreeMap::new(),
untinted: None,
});
}
let resolved = |name: &str| self.library.resolve(name).ok();
let matches = |name: &str, slot: &str, material: &Material| {
slot == name || short(&material.sprite) == name
};
let layer = |slot: &str| {
slot.strip_prefix("layer")
.and_then(|n| n.parse::<u32>().ok())
};
let leaf_model = resolved(leaf);
let leaf_textures = leaf_model
.as_ref()
.map(|m| m.textures.clone())
.unwrap_or_default();
// `texture.<leaf>` swaps the leaf (`texture.bow_pulling_1`). On a leaf
// of several layers, one of which answers to the name (`potion`'s
// bottle), it is that layer's swap instead, done below, and leaves
// `model` and `texture` to the rule.
let layers = leaf_textures.keys().filter(|s| layer(s).is_some()).count();
let named = plan
.textures
.iter()
.find(|(n, _)| n == sub)
.map(|(_, s)| s)
.filter(|_| {
layers < 2
|| !leaf_textures
.iter()
.any(|(slot, m)| matches(sub, slot.as_str(), m))
});
let base = match (named, &plan.model) {
(None, Some(model)) => model.clone(),
_ => leaf.to_owned(),
};
if base != leaf && !self.library.resolves(&base) {
return None;
}
let base_model = if base == leaf {
leaf_model
} else {
resolved(&base)
};
let generated = base_model.as_ref().is_some_and(|m| m.uses_item_generator());
let textures = base_model.map(|m| m.textures).unwrap_or_default();
// The slots each `texture.<name>` claims, by name or by sprite file.
let mut claimed = BTreeMap::new();
for (name, sprite) in &plan.textures {
for (slot, material) in &textures {
if matches(name.as_str(), slot.as_str(), material) {
claimed.insert(slot.clone(), sprite.clone());
}
}
}
let mut slots = BTreeMap::new();
let mut untinted = None;
match named.or(plan.texture.as_ref()) {
// A model's elements (a block item, a 3D model) draw the sprite
// on every face, as does the rule's `model` with `texture`.
Some(sprite) if (named.is_none() && plan.model.is_some()) || !generated => {
for slot in textures.keys() {
slots.insert(slot.clone(), Some(sprite.clone()));
}
}
// The item generated from the sprite, drawn once and untinted: a
// pack's art holds its own colours (OptiFine's doc: a potion's
// replacement includes "the colored liquid"), and the tinted layer
// is the bottom one on potions but the top one on maps, firework
// stars and dyed wolf armour. It is `layer0`, or the lowest layer no
// `texture.<name>` claims (`texture.potion_overlay` with `texture`:
// the bottle), and fills the unclaimed layers up to the highest
// claimed one, so they stay contiguous; the leaf's layers above are
// cleared (`bake_resolved` stops at the first layer without one).
Some(sprite) => {
untinted = Some(sprite.clone());
let first_free = (0..)
.find(|n| !claimed.contains_key(&format!("layer{n}")))
.unwrap_or(0);
let top = claimed
.keys()
.filter_map(|s| layer(s))
.max()
.unwrap_or(0)
.max(first_free);
slots.insert("layer0".to_owned(), Some(sprite.clone()));
for slot in textures.keys() {
match layer(slot) {
Some(n) if n <= top => {
slots.insert(slot.clone(), Some(sprite.clone()));
}
Some(_) => {
slots.insert(slot.clone(), None);
}
None => {}
}
}
}
None => {}
}
slots.extend(
claimed
.into_iter()
.map(|(slot, sprite)| (slot, Some(sprite))),
);
(base != leaf || !slots.is_empty()).then_some(CitBake {
base,
slots,
untinted,
})
}
fn cit_bake(&self, bake: &CitBake) -> Baked {
let child = bake
.slots
.iter()
.map(|(slot, sprite)| {
let texture = match sprite {
Some(sprite) => TextureSlot::Material(Material {
sprite: sprite.clone(),
force_translucent: false,
}),
// A slot referring to itself resolves to nothing
// (`ModelLibrary::resolve_with_textures` stops at the loop).
None => TextureSlot::Reference(slot.clone()),
};
(slot.clone(), texture)
})
.collect();
let baked = self
.library
.resolve_with_textures(&bake.base, &child)
.and_then(|model| bake_resolved(&model, &self.sprites))
.map(|mut baked| {
if let Some(sprite) = &bake.untinted {
for quad in baked
.quads
.iter_mut()
.filter(|q| &q.material.sprite == sprite)
{
quad.tint_index = -1;
}
}
Arc::new(baked)
})
.unwrap_or_else(|_| self.bake(MISSING_MODEL));
let extents = Arc::new(corners(&baked));
(baked, extents)
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_assets::item_model::{DisplayContext, NoEnv, StackView};
use std::{collections::BTreeSet, fs, path::PathBuf};
const CIT: &str = "assets/minecraft/optifine/cit";
fn png(path: &Path) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
let file = std::io::BufWriter::new(fs::File::create(path).unwrap());
let mut encoder = png::Encoder::new(file, 2, 2);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer.write_image_data(&[255; 16]).unwrap();
writer.finish().unwrap();
}
/// A stick, a bow's frames and a potion; with `cit`, rules over them.
fn root(name: &str, cit: bool) -> PathBuf {
let root = std::env::temp_dir().join(format!("horizon-cit-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let write = |rel: &str, text: &str| {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, text).unwrap();
};
write(
"items.json",
r#"{"minecraft:stick":{"minecraft:item_model":"minecraft:stick"}}"#,
);
write(
"assets/minecraft/items/stick.json",
r#"{"model":{"type":"minecraft:model","model":"minecraft:item/stick"}}"#,
);
let models = "assets/minecraft/models/item";
write(
&format!("{models}/generated.json"),
r#"{"parent":"builtin/generated"}"#,
);
let flat = |name: &str, layers: &[&str]| {
let textures: Vec<String> = layers
.iter()
.enumerate()
.map(|(i, l)| format!(r#""layer{i}":"item/{l}""#))
.collect();
write(
&format!("{models}/{name}.json"),
&format!(
r#"{{"parent":"item/generated","textures":{{{}}}}}"#,
textures.join(",")
),
);
for l in layers {
png(&root.join(format!("assets/minecraft/textures/item/{l}.png")));
}
};
flat("stick", &["stick"]);
flat("bow", &["bow"]);
flat("bow_pulling_0", &["bow_pulling_0"]);
flat("bow_pulling_1", &["bow_pulling_1"]);
flat("potion", &["potion_overlay", "potion"]);
// A block item: one cube whose faces all draw `#all`.
let faces: Vec<String> = ["down", "up", "north", "south", "west", "east"]
.iter()
.map(|f| format!(r##""{f}":{{"texture":"#all"}}"##))
.collect();
write(
"assets/minecraft/models/block/cube_fixture.json",
&format!(
r##"{{"textures":{{"particle":"#all","all":"block/cube_fixture"}},"elements":[{{"from":[0,0,0],"to":[16,16,16],"faces":{{{}}}}}]}}"##,
faces.join(",")
),
);
png(&root.join("assets/minecraft/textures/block/cube_fixture.png"));
if cit {
for t in ["a", "b", "blade", "hilt"] {
png(&root.join(format!("{CIT}/{t}.png")));
}
write(
&format!("{CIT}/sword.json"),
r#"{"parent":"item/generated","textures":{"layer0":"./blade","layer1":"./hilt"}}"#,
);
write(
&format!("{CIT}/other.json"),
r#"{"parent":"item/generated","textures":{"layer0":"./hilt"}}"#,
);
write(&format!("{CIT}/bad.json"), "{not json");
let rules = [
("plain", "items=bow\ntexture.bow_pulling_1=a"),
(
"mix",
"items=bow\nmodel=sword\ntexture=a\ntexture.bow_pulling_1=b\nmodel.bow_pulling_0=other",
),
(
"potion",
"items=potion\ntexture.potion_overlay=a\ntexture.layer1=b",
),
("stick", "items=stick\nmodel=sword"),
("bottle", "items=potion\ntexture.potion=a"),
(
"drinkable",
"items=potion\ntexture.potion_bottle_drinkable=b",
),
("whole", "items=potion\ntexture=a"),
("standby", "items=bow\ntexture.bow_standby=b"),
("broken", "items=bow\nmodel=bad\nmodel.bow_pulling_0=sword"),
("bad_sub", "items=bow\ntexture=a\nmodel.bow_pulling_1=bad"),
(
"overlay_and_generic",
"items=potion\ntexture=a\ntexture.potion_overlay=b",
),
("block", "items=stone\ntexture=a"),
(
"overlay_and_bottle",
"items=potion\ntexture=a\ntexture.potion=b",
),
(
"bottle_on_model",
"items=potion\nmodel=sword\ntexture.potion=b",
),
(
"old_first",
"items=bow\ntexture.bow_standby=b\ntexture.bow=a",
),
(
"new_first",
"items=bow\ntexture.bow=a\ntexture.bow_standby=b",
),
(
"old_model_first",
"items=bow\nmodel.bow_standby=other\nmodel.bow=sword",
),
(
"new_model_first",
"items=bow\nmodel.bow=sword\nmodel.bow_standby=other",
),
];
for (name, text) in rules {
write(&format!("{CIT}/{name}.properties"), text);
}
}
root
}
fn renderer(root: &Path) -> ItemRenderer {
let library = crate::model_library::ParsedModels::load(root).into_item_library();
ItemRenderer::load_with_library(root, library).unwrap()
}
fn rule(r: &ItemRenderer, name: &str) -> usize {
let path = format!("{CIT}/{name}.properties");
let rules = &r.cit.as_ref().unwrap().rules.rules;
rules.iter().position(|r| r.path == path).unwrap()
}
fn sprites(baked: &BakedItemModel) -> BTreeSet<String> {
baked
.quads
.iter()
.map(|q| q.material.sprite.clone())
.collect()
}
/// Each quad's layer (its tint index) with the sprite it draws.
fn layers(baked: &BakedItemModel) -> BTreeSet<(i32, String)> {
baked
.quads
.iter()
.map(|q| (q.tint_index, q.material.sprite.clone()))
.collect()
}
fn cit(name: &str) -> String {
format!("horizon_cit:minecraft/optifine/cit/{name}")
}
#[test]
fn rules_pick_a_model_or_textures_per_leaf() {
let root = root("plans", true);
let r = renderer(&root);
let layer = |rule: &str, leaf: &str| {
r.cit_layer(self::rule(&r, rule), &format!("minecraft:item/{leaf}"))
.map(|(baked, _)| sprites(&baked).into_iter().collect::<Vec<_>>())
};
// `texture.<leaf>` swaps that leaf only.
assert_eq!(layer("plain", "bow_pulling_1"), Some(vec![cit("a")]));
assert_eq!(layer("plain", "bow_pulling_0"), None);
assert_eq!(layer("plain", "bow"), None);
// `model.<leaf>` first, then `texture.<leaf>` (a one-layer frame: the whole leaf), then
// `model` with `texture` in every slot.
assert_eq!(layer("mix", "bow_pulling_0"), Some(vec![cit("hilt")]));
assert_eq!(layer("mix", "bow_pulling_1"), Some(vec![cit("b")]));
assert_eq!(layer("mix", "bow"), Some(vec![cit("a")]));
// A model alone keeps its own (relative) textures.
assert_eq!(
layer("stick", "stick"),
Some(vec![cit("blade"), cit("hilt")])
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn potion_textures_go_to_the_layer_they_name() {
let root = root("potion", true);
let r = renderer(&root);
let layer = |rule: &str| {
let (baked, _) = r.cit_layer(self::rule(&r, rule), "minecraft:item/potion")?;
Some(layers(&baked))
};
let vanilla = |i: i32, name: &str| (i, format!("minecraft:item/{name}"));
// By sprite file name (the overlay) and by slot name (the bottle).
assert_eq!(
layer("potion"),
Some(BTreeSet::from([(0, cit("a")), (1, cit("b"))]))
);
// `texture.potion` names the bottle's file, not the leaf's `layer0`.
assert_eq!(
layer("bottle"),
Some(BTreeSet::from([
vanilla(0, "potion_overlay"),
(1, cit("a"))
]))
);
// OptiFine's name for the same bottle.
assert_eq!(
layer("drinkable"),
Some(BTreeSet::from([
vanilla(0, "potion_overlay"),
(1, cit("b"))
]))
);
// A plain `texture` is drawn once, untinted: the pack's whole picture, no vanilla
// bottle over it and no potion colour on it.
assert_eq!(layer("whole"), Some(BTreeSet::from([(-1, cit("a"))])));
// ... unless a `texture.<name>` fills the bottle, even one named like the leaf.
assert_eq!(
layer("overlay_and_bottle"),
Some(BTreeSet::from([(-1, cit("a")), (1, cit("b"))]))
);
// A slot named like the leaf leaves the rule's `model` the base.
let (baked, _) = r
.cit_layer(rule(&r, "bottle_on_model"), "minecraft:item/potion")
.unwrap();
assert_eq!(sprites(&baked), BTreeSet::from([cit("blade"), cit("hilt")]));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn the_generic_texture_takes_the_layers_no_named_texture_claims() {
let root = root("generic", true);
let r = renderer(&root);
let (baked, _) = r
.cit_layer(rule(&r, "overlay_and_generic"), "minecraft:item/potion")
.unwrap();
// `texture.potion_overlay` claims `layer0`; `texture` is the bottle.
assert_eq!(
layers(&baked),
BTreeSet::from([(0, cit("b")), (-1, cit("a"))])
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_plain_texture_covers_every_face_of_a_block_item() {
let root = root("block", true);
let r = renderer(&root);
let (baked, _) = r
.cit_layer(rule(&r, "block"), "minecraft:block/cube_fixture")
.unwrap();
assert_eq!(baked.quads.len(), 6);
assert_eq!(sprites(&baked), BTreeSet::from([cit("a")]));
assert_eq!(baked.particle.sprite, cit("a"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn the_26x_name_beats_optifines_old_one_in_any_order() {
let root = root("aliases", true);
let r = renderer(&root);
let bow = |rule: &str| {
let (baked, _) = r
.cit_layer(self::rule(&r, rule), "minecraft:item/bow")
.unwrap();
sprites(&baked)
};
for name in ["old_first", "new_first"] {
assert_eq!(bow(name), BTreeSet::from([cit("a")]), "{name}");
}
for name in ["old_model_first", "new_model_first"] {
assert_eq!(
bow(name),
BTreeSet::from([cit("blade"), cit("hilt")]),
"{name}"
);
}
let _ = fs::remove_dir_all(&root);
}
#[test]
fn bow_standby_is_the_resting_bow() {
let root = root("standby", true);
let r = renderer(&root);
let standby = rule(&r, "standby");
let (baked, _) = r.cit_layer(standby, "minecraft:item/bow").unwrap();
assert_eq!(layers(&baked), BTreeSet::from([(-1, cit("b"))]));
assert!(
r.cit_layer(standby, "minecraft:item/bow_pulling_0")
.is_none()
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_model_that_did_not_load_leaves_the_leaf_vanilla() {
let root = root("broken", true);
let r = renderer(&root);
let broken = rule(&r, "broken");
assert!(r.cit_layer(broken, "minecraft:item/bow").is_none());
// The rule's other model still loads and draws.
let (baked, _) = r.cit_layer(broken, "minecraft:item/bow_pulling_0").unwrap();
assert_eq!(sprites(&baked), BTreeSet::from([cit("blade"), cit("hilt")]));
// A broken `model.<leaf>` leaves that leaf vanilla, not drawn from the rule's `texture`.
let bad_sub = rule(&r, "bad_sub");
assert!(
r.cit_layer(bad_sub, "minecraft:item/bow_pulling_1")
.is_none()
);
let (baked, _) = r.cit_layer(bad_sub, "minecraft:item/bow").unwrap();
assert_eq!(sprites(&baked), BTreeSet::from([cit("a")]));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn max_damage_is_the_stacks_component_else_the_items() {
let with = |tag: Option<Tag>| {
let mut stack = ItemStack::new("minecraft:bow", 1);
if let Some(tag) = tag {
stack
.data_mut()
.set_other("minecraft:max_damage", Some(tag));
}
stack_max_damage(&stack, 384)
};
assert_eq!(with(Some(Tag::Int(20))), 20);
assert_eq!(with(Some(Tag::Int(0))), 0);
assert_eq!(with(None), 384);
assert_eq!(with(Some(Tag::Int(-1))), 384);
assert_eq!(with(Some(Tag::String("20".into()))), 384);
}
fn armor_rules(text: &str) -> CitRules {
let props = horizon_optifine::Properties::parse_equals_first(text);
let path = "assets/minecraft/optifine/cit/worn.properties";
CitRules::new(vec![
crate::cit::Rule::parse(path, &props, |_| true).unwrap(),
])
}
#[test]
fn worn_armor_counts_as_the_main_hand() {
let rules = armor_rules("type=armor\nitems=diamond_chestplate\ntexture=x\nhand=main\n");
let stack = ItemStack::new("minecraft:diamond_chestplate", 1);
assert_eq!(worn_rule(&rules, Kind::Armor, &stack, 528), Some(0));
let off = armor_rules("type=armor\nitems=diamond_chestplate\ntexture=x\nhand=off\n");
assert_eq!(worn_rule(&off, Kind::Armor, &stack, 528), None);
}
#[test]
fn worn_damage_reads_the_stacks_max_damage() {
let rules = armor_rules("type=armor\nitems=diamond_chestplate\ntexture=x\ndamage=50%\n");
let mut stack = ItemStack::new("minecraft:diamond_chestplate", 1);
stack.damage = 5;
// 5 of the item's 528 is under 1%.
assert_eq!(worn_rule(&rules, Kind::Armor, &stack, 528), None);
stack
.data_mut()
.set_other("minecraft:max_damage", Some(Tag::Int(10)));
assert_eq!(worn_rule(&rules, Kind::Armor, &stack, 528), Some(0));
}
#[test]
fn a_bake_is_shared_by_its_rule_and_leaf_only() {
let root = root("cache", true);
let r = renderer(&root);
let leaf = "minecraft:item/bow_pulling_1";
let (plain, mix) = (rule(&r, "plain"), rule(&r, "mix"));
let first = r.cit_layer(plain, leaf).unwrap();
let again = r.cit_layer(plain, leaf).unwrap();
assert!(Arc::ptr_eq(&first.0, &again.0));
assert!(Arc::ptr_eq(&first.1, &again.1));
assert!(!Arc::ptr_eq(&first.0, &r.cit_layer(mix, leaf).unwrap().0));
assert!(!Arc::ptr_eq(&first.0, &r.bake(leaf)));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn a_matched_stack_resolves_to_the_rule_model() {
let root = root("resolve", true);
let r = renderer(&root);
let view = StackView::new("minecraft:stick", 1);
let resolve = |rule| {
let state =
r.resolve_with_rule(&view, DisplayContext::Gui, None, None, 0, &NoEnv, rule);
match &state.layers[0].geometry {
super::super::LayerGeometry::Quads(baked) => sprites(baked),
other => panic!("{other:?}"),
}
};
let stick = rule(&r, "stick");
assert_eq!(
resolve(Some(stick)),
BTreeSet::from([cit("blade"), cit("hilt")])
);
assert_eq!(
resolve(None),
BTreeSet::from(["minecraft:item/stick".to_owned()])
);
let _ = fs::remove_dir_all(&root);
}
#[test]
fn without_rules_nothing_changes() {
let root = root("none", false);
let r = renderer(&root);
assert!(r.cit.is_none());
let view = StackView::new("minecraft:stick", 1);
let state = |rule| {
let s = r.resolve_with_rule(&view, DisplayContext::Gui, None, None, 0, &NoEnv, rule);
format!("{s:?}")
};
let plain = format!(
"{:?}",
r.resolve(&view, DisplayContext::Gui, None, None, 0, &NoEnv)
);
assert_eq!(state(Some(0)), plain);
assert_eq!(state(None), plain);
assert!(plain.contains("minecraft:item/stick"));
let _ = fs::remove_dir_all(&root);
}
}
+135 -2
View File
@@ -6,6 +6,9 @@
//! is oversized in the GUI (`GuiItemAtlas` draws each item into a scissored
//! 16x16 slot; oversized items get a picture-in-picture of their bounds).
use super::{Foil, Glint, ItemQuad, ItemRenderState, ItemRenderer, SpecialHost};
use crate::cit::Rule;
use crate::cit_glint::StackGlint;
use crate::hud::cit_glint::{GlintTextures, layer_uv, vertex_color};
use crate::hud::{HudBatch, Rect};
use glam::{EulerRot, Mat4, Vec2, Vec3};
use std::{collections::HashMap, f32::consts::PI, sync::Arc};
@@ -125,6 +128,50 @@ fn emit(batch: &mut HudBatch, fan: &[Point], color: impl Fn(Vec2) -> u32, glint:
}
}
/// A stack's OptiFine CIT glint layers this frame.
#[derive(Clone, Copy)]
struct CitDraw<'a> {
glint: &'a StackGlint,
textures: &'a GlintTextures,
rules: &'a [Rule],
millis: f32,
}
/// The CIT glint layers over the item's clipped faces (`fans`, in draw order), bottom first:
/// each layer is one run over all of them, blended as its rule says, at its intensity times
/// the glint strength option (`HudBatch::glint_alpha`).
fn emit_cit(batch: &mut HudBatch, fans: &[Vec<Point>], cit: CitDraw) {
let seconds = f64::from(cit.millis) / 1000.0;
for (i, layer) in cit.glint.layers.iter().enumerate() {
let Some(slot) = cit.textures.slot_of.get(layer.rule).copied().flatten() else {
continue;
};
let Some(constants) = cit.textures.slots.get(usize::from(slot)) else {
continue;
};
let intensity = cit.glint.intensity(i, seconds) * batch.glint_alpha;
if intensity <= 0.0 {
continue;
}
let glint = &cit.rules[layer.rule].glint;
let color = vertex_color(slot, intensity);
let uv = |v: Point| layer_uv(v.glint_uv, cit.millis, glint.speed, glint.rotation, 8.0);
let start = batch.vertices.len();
for fan in fans {
for k in 1..fan.len().saturating_sub(1) {
let corners = [fan[0], fan[k], fan[k + 1], fan[k + 1]];
batch.quad_glint(
corners.map(|v| v.p.to_array()),
corners.map(|v| v.uv),
corners.map(uv),
color,
);
}
}
batch.blend_since(start, crate::hud::Blend::CitGlint(constants.blend));
}
}
/// Submit, light, cull, sort and clip one item's quads into `batch` at GUI
/// position `(x, y)`.
#[allow(clippy::too_many_arguments)]
@@ -236,7 +283,7 @@ impl DrawnKey {
x: f32,
y: f32,
) -> Option<Self> {
if state.animated {
if state.animated || state.cit_glint.is_some() {
return None;
}
let layers = state
@@ -316,7 +363,14 @@ fn draw_now(
scale: 8.0,
});
batch.glint_alpha = glint_alpha;
draw_quads(&quads, lights, batch, rect, glint.as_ref());
let cit = state.cit_glint.as_deref().zip(renderer.cit.as_ref());
let cit = cit.map(|(glint, models)| CitDraw {
glint,
textures: &models.glint_textures,
rules: &models.rules.rules,
millis: time * 1000.0,
});
draw_quads_with(&quads, lights, batch, rect, glint.as_ref(), cit);
}
/// Lights, culls, sorts far to near and clips `quads` (GUI positions) to
@@ -328,6 +382,20 @@ fn draw_quads(
rect: [f32; 4],
glint: Option<&Glint>,
) {
draw_quads_with(quads, lights, batch, rect, glint, None);
}
/// [`draw_quads`], then a stack's CIT glint layers over every face.
fn draw_quads_with(
quads: &[ItemQuad],
lights: (Vec3, Vec3),
batch: &mut HudBatch,
rect: [f32; 4],
glint: Option<&Glint>,
cit: Option<CitDraw>,
) {
// The faces the CIT layers go over once the item is drawn.
let mut fans = Vec::new();
for face in depth::sort(quads) {
let q = &quads[face.quad];
let points: Vec<_> = face
@@ -350,8 +418,14 @@ fn draw_quads(
if q.foil != Foil::None {
emit(batch, &fan, |_| color, glint);
}
if cit.is_some() {
fans.push(fan);
}
}
}
if let Some(cit) = cit {
emit_cit(batch, &fans, cit);
}
}
/// `PictureInPictureRenderer.prepare` for the loom's 20x40 result rectangle
@@ -612,6 +686,65 @@ mod tests {
}
}
#[test]
fn cit_layers_draw_once_over_the_whole_item() {
use crate::cit_glint::Global;
use crate::hud::Blend;
use crate::hud::cit_glint::Slot;
use horizon_optifine::Properties;
let rule = |name: &str, text: &str| {
let path = format!("assets/minecraft/optifine/cit/{name}.properties");
let text = format!("type=enchantment\ntexture=glint\n{text}");
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
};
let rules = [
rule("shown", ""),
rule("fire", "layer=1\nenchantments=fire_aspect"),
];
// Shared by level, and the stack has no Fire Aspect: that layer is at intensity 0.
let global = Global::parse(&Properties::parse_equals_first("method=average"));
let glint = StackGlint::new(&rules, &[0, 1], &[("minecraft:sharpness", 3)], &global);
assert_eq!(glint.layers.len(), 2);
let slot = |blend| Slot {
rgba: [1.0; 4],
blur: false,
blend,
};
let textures = GlintTextures {
width: 1,
height: 1,
slot_of: vec![Some(1), Some(0)],
slots: vec![slot(0), slot(1)],
..GlintTextures::default()
};
let cit = CitDraw {
glint: &glint,
textures: &textures,
rules: &rules,
millis: 0.0,
};
let mut batch = HudBatch::new([320, 240]);
batch.glint_alpha = 0.5;
draw_quads_with(
&[face(0.0, 8.0, |_| 0.0, 0xFFFF_FFFF)],
(Vec3::Z, Vec3::Z),
&mut batch,
[0.0, 0.0, 16.0, 16.0],
None,
Some(cit),
);
// The square's two triangles, then the one layer over both: six vertices a triangle.
assert_eq!(batch.vertices.len(), 24);
assert_eq!(
batch.runs(),
[(0..12, Blend::Alpha), (12..24, Blend::CitGlint(1))]
);
for v in &batch.vertices[12..] {
// Slot 1 in red; full intensity at half the glint strength in alpha.
assert_eq!(v.color.to_le_bytes(), [1, 0, 0, 128]);
}
}
#[test]
fn flat_lights_match_joml() {
// new Matrix4f().rotationY(-PI/8).rotateX(3PI/4).transformDirection(L0).
+3
View File
@@ -304,6 +304,8 @@ pub struct Guest {
pub drag: Option<(u8, Vec<usize>)>,
/// The compound this player was loaded from, for fields Horizon does not model.
pub saved: Option<horizon_world::nbt::Tag>,
/// Server-owned attachment data for this player.
pub attachments: horizon_world::attachments::Attachments,
/// `ServerPlayer.seenCredits`: the end poem has been shown.
pub seen_credits: bool,
/// `ServerPlayer.postEffects` and `postEffectsDirty`.
@@ -454,6 +456,7 @@ impl Guest {
next_window: 0,
drag: None,
saved: None,
attachments: Default::default(),
seen_credits: false,
post_effects: Vec::new(),
post_effects_dirty: true,
+54 -1
View File
@@ -6,7 +6,10 @@ use crate::{
save::{Level, put, read_dat, uuid_from_tag, write_dat},
};
use horizon_protocol::crypto::uuid_string;
use horizon_world::nbt::Tag;
use horizon_world::{
attachments::{ATTACHMENTS_KEY, attachment_compound},
nbt::Tag,
};
use std::{
path::{Path, PathBuf},
sync::Arc,
@@ -126,6 +129,15 @@ pub fn apply(guest: &mut Guest, level: &Level) {
.map(|s| [s[0] + 0.5, s[1], s[2] + 0.5]);
guest.respawn_elsewhere = level.respawn_elsewhere.clone();
guest.saved = level.player_extra.clone();
guest.attachments = match attachment_compound(
guest
.saved
.as_ref()
.and_then(|tag| tag.get(ATTACHMENTS_KEY)),
) {
Ok(Some(data)) => horizon_world::attachments::Attachments::from_nbt(data.clone()),
_ => Default::default(),
};
guest.seen_credits = level.seen_credits;
guest.post_effects.clone_from(&level.post_effects);
guest.last_death = level.last_death.clone();
@@ -220,6 +232,19 @@ pub fn store(guest: &Guest, level: &mut Level, vehicle: Option<Tag>) {
level.post_effects.clone_from(&guest.post_effects);
level.last_death = guest.last_death.clone();
level.player_extra = guest.saved.clone();
let extra = level
.player_extra
.get_or_insert_with(|| Tag::Compound(Default::default()));
if let Tag::Compound(map) = extra
&& attachment_compound(map.get(&ATTACHMENTS_KEY.into())).is_ok()
{
let data = guest.attachments.to_nbt();
if data.is_empty() {
map.remove(&ATTACHMENTS_KEY.into());
} else {
map.insert(ATTACHMENTS_KEY.into(), Tag::Compound(data));
}
}
// The vehicle the player rides (`ServerPlayer.saveParentVehicle`), or
// the one its file names that has not come back yet (kept in `saved`).
level.root_vehicle = guest.saved_vehicle;
@@ -371,6 +396,34 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn player_attachments_survive_playerdata_and_respawn() {
use horizon_world::attachments::{AttachmentCodec, AttachmentError, AttachmentType};
let keep = AttachmentType {
namespace: "test",
name: "keep",
default: || 0,
codec: Some(AttachmentCodec {
encode: |n: &i32| Ok([("count".into(), Tag::Int(*n))].into()),
decode: |c| match c.get(&"count".into()) {
Some(Tag::Int(n)) => Ok(*n),
_ => Err(AttachmentError::Decode),
},
}),
copy_on_death: true,
};
let dir = world("attachments");
let mut original = guest();
original.attachments.set(&keep, 31).unwrap();
save(&original, &dir);
let mut loaded = guest();
loaded.uuid = original.uuid;
assert!(load(&mut loaded, &dir));
loaded.attachments = loaded.attachments.copy_on_death().unwrap();
assert_eq!(loaded.attachments.get(&keep), Ok(31));
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn a_save_cut_off_between_its_renames_loads_the_dat_old() {
// PlayerDataStorage.load: a file `write_dat` moved to `.dat_old` and
+1
View File
@@ -4,3 +4,4 @@ extern crate self as horizon_client;
pub use horizon_client_lib::*;
pub mod entry;
pub mod game;
pub mod mod_api;
+454
View File
@@ -0,0 +1,454 @@
//! Trait mods implement `Hooks` and link into the game executable.
//! The executable passes the ordered `Mods` to `entry::main_with`.
//! Trait mods are process-wide, like mixin module statics.
//! Hooks use `&self + Send + Sync` for shared and reentrant calls; see `docs/native-mods.md`.
//! Generate the trait and dispatch with `cargo run -p xtask -- mod-api`.
use std::sync::{Arc, OnceLock};
pub use crate::containers::{ModBlockEntity, ModContainer, ModFluidTanks};
pub use crate::crafting::{CustomRecipe, Ingredient};
pub use crate::extras::Corner;
pub use crate::extras_hud::Corners;
pub use crate::game::native_block_entity_draw::{BlockEntityDraw, CubeFace};
pub use crate::gfx::native_render::{
RenderHooks, RenderNeeds, RenderNeedsEvent, WorldDrawn, WorldDrawnEvent, wgpu,
};
pub use crate::hud::HudBatch;
pub use crate::hud_layout::{GuiDraw, HealthInputs};
pub use crate::items::ItemStack;
pub use crate::text::Text;
pub use horizon_assets::item_model::DisplayContext;
pub use horizon_core::{
geometry::Direction,
position::BlockPos,
random::{LegacyRandom, RandomSource},
};
pub use horizon_world::{
attachments::{
AttachmentCodec, AttachmentError, AttachmentHost, AttachmentType, Attachments, Holder,
},
fluid::FluidState,
level::{BlockId, Dimension as LevelDimension, PlacementContext},
nbt::{Compound, Tag},
registry::BlockStateId,
};
pub use serde_json;
use serde_json::{Map, Value};
use std::time::Duration;
/// A dispenser hook's answer: the slot's remainder (None empties it) and the
/// level event to play, `CLICK` (1000), `FAIL` (1001) or 0 for none.
pub type NativeDispensed = (Option<ItemStack>, i32);
/// What dropped the items heard by the `loot_drops` hook.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct NativeLootDrops<'a> {
/// The block's or mob's own loot table (`minecraft:blocks/stone`, `minecraft:entities/zombie`);
/// empty when none rolled: the block or mob has none, the `mob_drops` rule is off, a baby
/// animal. A mob's drops may hold more: a charged creeper's head, a fox's mouth item.
pub table: &'a str,
/// The level they drop in (`minecraft:overworld`).
pub dimension: &'a str,
/// `LootContextParams.ORIGIN`: a broken block's centre, a mob's position.
pub origin: [f64; 3],
/// The block broken: its position and state.
pub block: Option<([i32; 3], BlockStateId)>,
/// The mob that died, or the entity a player's use dropped them from (the
/// sheep sheared, the item frame broken): its entity ID and type
/// (`minecraft:zombie`).
pub entity: Option<(u32, &'a str)>,
/// The player who broke the block or killed the mob, when known. A mob
/// names one only while it remembers the player's hit (100 ticks), so one
/// that dies to lava long after names none.
pub player: Option<u128>,
/// What broke the block (`LootContextParams.TOOL`).
pub tool: Option<&'a ItemStack>,
/// The radius of the explosion that broke the block.
pub explosion: Option<f32>,
/// A player's use dropped them rather than a break or a death: shears on a
/// sheep, a mooshroom, a snow golem or a bogged, a brush on an armadillo or
/// a suspicious block, shears carving a pumpkin, picking sweet berries or
/// cave vines, a hit taking an item frame or its item, or untying a lead
/// or cutting it with shears. `player` names them; `block` or `entity` says
/// what dropped them. API 100. A mob that a player killed while
/// leashed drops its lead on its next tick as a call of its own (`entity`
/// the mob, `player` its killer, `table` empty, `used` false). API
/// 102.
pub used: bool,
}
/// The open screen, for the screen hooks (`native_screen_render_post`, the
/// mouse ones `native_screen_mouse_button_pressed_pre`, `..._released_pre` and
/// `native_screen_mouse_scrolled_pre`, and the key ones
/// `native_screen_key_pressed_pre` and `native_screen_char_typed_pre`): what
/// it is, its menu and title, where its container panel is, how many slots its
/// menu has, and where the mouse and the frame's partial tick are.
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct NativeScreenView<'a> {
/// One id per `Screen` (`Screen::id`): `inventory`, `creative`, `paused`,
/// `stats`, `options`, `win`… Stable across releases, so a mod can match
/// on it. Every container, station and mod window draws on the inventory
/// screen and so reports `inventory`: tell them apart by `menu` and
/// `title` (both None on a window the client opened itself) and by
/// `slots`.
pub id: &'static str,
/// The type id of the menu the server opened for this screen
/// (`minecraft:generic_9x3`, `minecraft:furnace`, a mod's own id; a mount's
/// inventory is `horizon:mount<n>`). None on a screen without one (the
/// client's own inventory, creative, the pause menu, stats…) and for a
/// menu type the client does not know.
pub menu: Option<&'a str>,
/// The title the server gave the open menu, if it has one: the component
/// itself, not a copy (`.plain()` it to draw, `chars()` for the styles).
/// None when the server gave none, on a screen the client opened, and
/// while playing.
pub title: Option<&'a Text>,
/// The screen's container panel in GUI pixels, `[x, y, width, height]`;
/// the whole window for a screen with none. The panel is where the game
/// draws the window: 176 wide for most, wider for the merchant and the
/// beacon, a mod screen's own size for a mod's.
pub rect: [f32; 4],
/// The slots the open menu has, as `Game::slots` counts them: the player
/// inventory's 36, then the container's, the station's, the mod screen's
/// or the crafting grid's (a screen with no menu of its own still counts
/// the inventory menu's). Counted, not listed.
pub slots: usize,
/// The pointer in GUI pixels (`Screen.mouseX`, `mouseY`, from the
/// window's top left), as `native_cursor` gives it: for hover highlights.
pub mouse: [f32; 2],
/// This frame's partial tick (`getGameTimeDeltaPartialTick(false)`, 0 to
/// 1): what the screen draws animation against.
pub partial_tick: f32,
}
/// A depth-tested outline of the client's block selection shape.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NativeBlockRim {
pub pos: [i32; 3],
pub argb: u32,
pub width: f32,
}
/// A camera-local visual light, with no gameplay light or shadow writes.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NativeColoredLight {
pub at: [f64; 3],
pub rgb: u32,
pub radius: f32,
pub intensity: f32,
}
/// The minimap a native mod shows (`native_minimap`), drawn by Horizon's
/// minimap: its corner, its side in GUI pixels, blocks per map pixel,
/// turning with the player or north up, round or square.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NativeMinimap {
pub corner: Corner,
pub size: f32,
pub blocks_per_pixel: f32,
pub rotate: bool,
pub round: bool,
}
/// A mod block's screen over its menu (`native_screen`): its size, where
/// each of the container's slots is (`Slot.x`, `Slot.y`; none hides the
/// slot, and so do slots past the list; the server's menu does not have a
/// hidden slot either, so clicks and shift-clicks skip it), and the player inventory's first
/// row (vanilla's is 84; the hotbar is 58 below it) and left column, from
/// its top left.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NativeScreen {
/// Vanilla's container screens are 176 wide.
pub width: f32,
pub height: f32,
pub slots: Vec<Option<[f32; 2]>>,
pub inventory_y: f32,
/// The inventory's left column (vanilla's is 8).
pub inventory_x: f32,
/// The mod draws the title and the "Inventory" label itself
/// (`native_screen_draw`); Horizon leaves them out.
pub own_labels: bool,
}
/// A native mod as Options > Mods lists it (`native_mods`).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NativeMod {
/// Its id: its settings folder and its keys' lines.
pub id: String,
/// `Name 1.0`.
pub title: String,
pub description: String,
/// Its keys: name, label and default key (`key.keyboard.z`). The page
/// lists them first; `native_mod_key` reads the player's choice.
pub keys: Vec<[String; 3]>,
}
/// An item the panel shows (`native_panel_items`): its id
/// (`minecraft:diamond`), a count (1 draws none) and its components beyond
/// the item's own, in vanilla's item syntax, or "".
#[derive(Clone, Debug, Default, PartialEq)]
pub struct NativeStack {
pub item: String,
pub count: u32,
pub components: String,
}
/// What the player did on a native mod's panel (`native_panel_event`).
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum NativePanelEvent {
/// A click on an item of the panel, by its index in
/// `native_panel_items`' list.
Clicked { index: u32, right: bool },
/// The search box's text, each time the player changes it.
Search(String),
/// A key (`key.keyboard.r`; compare it with `native_mod_key`) went down
/// while the pointer was on an item: a slot of the screen's, or the
/// panel's.
Key { key: String, stack: NativeStack },
/// A click on a slot of a recipe the panel shows: the recipe's index in
/// `native_panel_recipes`' list and the slot's in it (the results come
/// after the slots), and the stack it showed then.
SlotClicked {
recipe: u32,
slot: u32,
stack: NativeStack,
right: bool,
},
/// The action button of the recipe at this index.
Action(u32),
/// The player turned the recipes' page: the index of the first recipe
/// now shown.
Page(u32),
/// The recipe page's back button.
Back,
/// The player picked the tab at this index.
Tab(u32),
}
/// The zoom a native mod asks for (`native_zoom`): the factor (1 is none), eased through the
/// field of view like the spyglass when `smooth`, and the scale of mouse turning.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct NativeZoom {
pub factor: f32,
pub smooth: bool,
pub turn: f32,
}
/// A player on the server, as a server hook sees them.
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub struct NativeServerPlayer {
pub uuid: u128,
pub name: String,
/// The operator level, 0 to 4 (`ops.json`; single player's owner with
/// cheats on is 4).
pub permission: u8,
/// `minecraft:overworld`, `minecraft:the_nether`, `minecraft:the_end`.
pub dimension: String,
pub position: [f64; 3],
/// 0 survival, 1 creative, 2 adventure, 3 spectator.
pub game_mode: i32,
/// Health points, 20 at full; 0 once dead. API 21.
pub health: f32,
/// Where they look, in degrees (`yRot`, `xRot`), as their last move told
/// the server. API 49.
pub yaw: f32,
pub pitch: f32,
}
/// A command a native mod adds, as one usage: words, then arguments
/// `<name:kind>`, optional ones last in brackets `[name:kind]`:
/// `pay <player:player> <amount:integer>`, `balance [player:player]`,
/// `eco give <player:player> <amount:integer>`. Kinds: `word`, `text` (the
/// rest of the line, last), `integer`, `number`, `boolean`, `player`,
/// `item`, `block_pos`. Usages of one command share their words; the
/// server merges them into one tree.
#[derive(Clone, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct NativeCommand {
pub usage: String,
/// The operator level a player needs to see and run it (0: everyone).
pub level: u8,
}
impl NativeCommand {
/// A usage every player may run.
pub fn new(usage: &str) -> Self {
Self {
usage: usage.to_owned(),
level: 0,
}
}
/// The same, for operators of `level` and above.
pub fn op(usage: &str, level: u8) -> Self {
Self {
usage: usage.to_owned(),
level: level.min(4),
}
}
}
/// Which `Game` observes the phase; a mirror `Game` never fires.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum NativeAuthority {
LocalAuthoritative,
IntegratedServer,
DedicatedServer,
RemoteClient,
}
/// One open attempt of one world; it rejects a previous open's commands.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NativeWorldEpoch(u64);
impl NativeWorldEpoch {
/// Create an epoch for the game.
#[doc(hidden)]
pub fn new(n: u64) -> Self {
Self(n)
}
/// Read the epoch number.
pub fn get(self) -> u64 {
self.0
}
}
/// Non-paused authoritative server ticks since open (not game or frame time).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct NativeTickId(u64);
impl NativeTickId {
/// Create a tick ID for the game.
#[doc(hidden)]
pub fn new(n: u64) -> Self {
Self(n)
}
/// Read the tick number.
pub fn get(self) -> u64 {
self.0
}
}
/// The world phases (`native_world_lifecycle`'s `phase`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
/// The phase names are the hook log's stable vocabulary (the Debug text
/// the mixins write); keep them despite the shared prefix.
#[allow(clippy::enum_variant_names)]
#[non_exhaustive]
pub enum WorldPhase {
WorldOpenReady,
/// The open failed; `error_text` is the engine's own error text.
WorldOpenFailed,
WorldCloseFinal,
/// The server loop or final save failed; `error_text` carries the error.
WorldCloseFailed,
}
/// The tick phases carry whether the world runs, including frozen steps.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum ServerTickPhase {
ServerTickStart { world_running: bool },
ServerTickEnd { world_running: bool },
}
/// Context for every world lifecycle phase.
/// `authority` is the server role. LocalAuthoritative and RemoteClient never
/// fire today. `epoch` identifies the open attempt, including failed opens;
/// the same epoch identifies its ready, final-close, or failed-close event.
/// `tick` counts non-paused server ticks since the world opened.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub struct NativeLifecycleContext {
pub authority: NativeAuthority,
pub epoch: NativeWorldEpoch,
pub tick: NativeTickId,
}
/// The ordered list of mods shared by the client and server.
pub type Mods = Arc<[Box<dyn Hooks>]>;
pub(crate) static MODS: OnceLock<Mods> = OnceLock::new();
/// The process-wide list in dispatch order, or an empty list before installation.
pub fn mods() -> &'static [Box<dyn Hooks>] {
MODS.get().map_or(&[], |mods| mods.as_ref())
}
/// Whether a mod claims a cancellable hook.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Flow {
/// Later mods and the game continue.
Continue,
/// Set the hook flag and skip later mods.
Cancel,
}
mod generated_host;
pub use generated_host::Host;
/// Result of accessing or decoding an attachment.
pub type AttachmentResult<T> = std::result::Result<T, AttachmentError>;
impl AttachmentHost for dyn Host + '_ {
fn attachments(&mut self, holder: Holder) -> Result<&mut Attachments, AttachmentError> {
Host::attachments(self, holder)
}
}
/// Read-only level services, like vanilla's `LevelReader`.
pub trait LevelHost {
/// Read a block through `LevelAccess::block_state`.
fn block_state(&self, pos: [i32; 3]) -> BlockStateId;
/// `Level.isClientSide`, through `LevelAccess::is_client_side`.
fn is_client_side(&self) -> bool;
/// `BlockState.setValue`, through `native_state_with_property`; None if invalid.
fn state_with_property(
&self,
state: BlockStateId,
key: &str,
value: &str,
) -> Option<BlockStateId>;
}
/// Access vanilla's `Level.random`, so draws come in vanilla's order.
pub trait LevelRandomHost: LevelHost {
/// Access a holder's server-owned attachment map.
fn attachments(&mut self, holder: Holder) -> Result<&mut Attachments, AttachmentError>;
/// The level's random stream through `native_level_random`, or None on a non-authoritative level.
fn random(&mut self) -> Option<&mut LegacyRandom>;
}
/// A `LevelReader` with vanilla's `ScheduledTickAccess`. Shape updates cannot write blocks.
pub trait LevelTicksHost: LevelHost {
/// Schedule a block tick through `native_schedule_block_tick`.
fn schedule_block_tick(&mut self, pos: [i32; 3], block: &str, delay: i32) -> bool;
/// Schedule a fluid tick through `native_schedule_fluid_tick`.
fn schedule_fluid_tick(&mut self, pos: [i32; 3], fluid: &str, delay: i32) -> bool;
}
/// Mutable level services, like vanilla's `ServerLevel`. Whole-block writes wait for block entity support.
pub trait LevelWriteHost: LevelTicksHost + LevelRandomHost {
/// Change a block property through `native_set_block_property`.
fn set_block_property(&mut self, pos: [i32; 3], key: &str, value: &str, flags: i32) -> bool;
}
macro_rules! level_attachment_host {
($($host:ident),+) => { $(
impl AttachmentHost for dyn $host + '_ {
fn attachments(&mut self, holder: Holder) -> Result<&mut Attachments, AttachmentError> {
LevelRandomHost::attachments(self, holder)
}
}
)+ };
}
level_attachment_host!(LevelRandomHost, LevelWriteHost);
mod generated;
pub use generated::Hooks;
#[doc(hidden)]
pub use generated::*;
File diff suppressed because it is too large. Load diff
+506
View File
@@ -0,0 +1,506 @@
// @generated by cargo run -p xtask -- mod-api; do not edit.
use super::*;
use std::path::PathBuf;
use std::sync::Arc;
/// Services supplied by the game to trait mods.
pub trait Host {
/// Draws a quad with `corners` in blocks under `draw`'s pose, in a model
/// face's order (top left, bottom left, bottom right, top right as seen
/// from its front), textured with the `uv` rectangle (u0, v0, u1, v1 in
/// texels of 16 per side) of `sprite` (`mymod:entity/screen`,
/// `mymod:block/casing`, `block/stone`) times the 0xRRGGBB `tint`
/// (0xFFFFFF for none). False for an unknown sprite. API 71.
fn draw_quad(
&self,
draw: &mut BlockEntityDraw,
sprite: &str,
corners: [[f32; 3]; 4],
uv: [f32; 4],
tint: u32,
) -> bool;
/// Draws a box from `from` to `to` (texels of 16 per block, as a block
/// model's element) under `draw`'s pose, with the faces given, by
/// `Direction` (down, up, north, south, west, east), baked as a model
/// element's are (`FaceBakery`), times the 0xRRGGBB `tint`. A turned box
/// is drawn under a turned pose (`BlockEntityDraw::rotate_about`). False
/// when a face's sprite is unknown; that face is left out. API 71.
fn draw_cube(
&self,
draw: &mut BlockEntityDraw,
from: [f32; 3],
to: [f32; 3],
faces: [Option<CubeFace<'_>>; 6],
tint: u32,
) -> bool;
/// Draws block `block`'s model (`mymod:cog`, any block with a model) in
/// the state with `properties` (`[("axis", "x")]`, the rest default)
/// under `draw`'s pose, as vanilla's `submitBlockModel` draws a block in
/// an entity's place: a block's baked model, with its default tints. A
/// moving part that is no block is a standalone model
/// (`native_draw_model`). False for an unknown block or property. API 71.
fn draw_block(
&self,
draw: &mut BlockEntityDraw,
block: &str,
properties: &[(&str, &str)],
) -> bool;
/// Draws `stack` under `draw`'s pose as an item is drawn in the world
/// (`ItemModelResolver.updateForTopItem` and `ItemStackRenderState.submit`), centred on the
/// pose's origin, in its model's `display` transform: `Fixed` as a
/// campfire's or an item frame's, `Ground` as a dropped item's. `seed`
/// picks among random models (a campfire passes its slot plus its
/// position's hash). `owner_yaw` makes the block entity the item's
/// owner (`ItemOwner`), at the block's centre and turned that many
/// degrees (`getVisualRotationYInDegrees`), as a shelf is: a compass on
/// it points to its target from there. None draws it with no owner, as
/// a campfire does. False for an empty stack or one with no model. The
/// client's copy of a block entity has no item slots (`entity.items` is
/// empty there): send the stacks you draw in
/// `native_block_entity_update_tag` and read them from `entity.nbt`.
/// API 91.
fn draw_item(
&self,
draw: &mut BlockEntityDraw,
stack: &ItemStack,
display: DisplayContext,
owner_yaw: Option<f32>,
seed: i32,
) -> bool;
/// Access a server-owned holder's attachment map.
fn attachments(&mut self, holder: Holder) -> AttachmentResult<&mut Attachments>;
/// Moves mob `id` to `pos` at once, as `/tp` does: it dismounts, drops its
/// path and resets its fall. False for no such mob, another dimension or a
/// position outside `/summon`'s bounds. API 37.
fn teleport_mob(&mut self, dimension: &str, id: u32, pos: [f64; 3]) -> bool;
/// Adds `velocity` to mob `id`'s: false for no such mob, another dimension
/// or a velocity that isn't finite. API 37.
fn push_mob(&mut self, dimension: &str, id: u32, velocity: [f64; 3]) -> bool;
/// Sets the living mob `id`'s health, cut to its maximum: false for no such
/// mob, another dimension or a health that isn't above 0 (`native_kill_mob`
/// kills). API 37.
fn set_mob_health(&mut self, dimension: &str, id: u32, health: f32) -> bool;
/// Kills mob `id` as `/kill` does: it falls and drops its loot. False for
/// no such living mob or another dimension. API 37.
fn kill_mob(&mut self, dimension: &str, id: u32) -> bool;
/// Takes mob `id` out of the world with no death and no loot: false for no
/// such mob or another dimension. API 37.
fn remove_mob(&mut self, dimension: &str, id: u32) -> bool;
/// Spawns an entity of `kind` (`minecraft:item_display`) at `pos` in the
/// level in place, `dimension`, from `nbt` (its compound, as `/summon
/// <kind> <pos> <nbt>` takes it; passengers included): its id. None on a
/// client's copy of the world, for another dimension, a position outside
/// `/summon`'s bounds, a chunk that isn't loaded, a kind Horizon can't
/// load, or one a mod kept out (`native_entity_join_level`). API
/// 59.
fn summon(&mut self, dimension: &str, kind: &str, pos: [f64; 3], nbt: Compound) -> Option<u32>;
/// Moves entity `id`, one Horizon keeps without simulating (a display, a
/// marker, an interaction), to `pos` facing `yaw` and `pitch` in degrees;
/// a display eases there over its `teleport` ticks. False for no such
/// entity, another dimension or a position outside `/summon`'s bounds.
/// Mobs move with `native_teleport_mob`. API 59.
fn move_entity(
&mut self,
dimension: &str,
id: u32,
pos: [f64; 3],
yaw: f32,
pitch: f32,
) -> bool;
/// Removes entity `id`, one Horizon keeps without simulating, as `/kill`
/// does (it drops nothing and isn't saved). False for no such entity or
/// another dimension. Mobs go with `native_remove_mob`. API
/// 59.
fn remove_entity(&mut self, dimension: &str, id: u32) -> bool;
/// The players on this server, in the order they joined. Empty on a
/// client and in a world nobody else can join.
fn server_players(&self) -> Vec<NativeServerPlayer>;
/// A system line in that player's chat, also inside a player's hook for
/// the acting player; false when they aren't on.
fn tell(&mut self, uuid: u128, line: Text) -> bool;
/// A line over that player's hotbar (the action bar), also inside a
/// player's hook for the acting player; false when they aren't on. API 21.
fn action_bar(&mut self, uuid: u128, line: Text) -> bool;
/// The lines over and under that player's list of players (Tab):
/// `ClientboundTabListPacket`, an empty text for none. False when they
/// aren't on. API 49.
fn tab_list(&mut self, uuid: u128, header: &Text, footer: &Text) -> bool;
/// The server's tick rate (20 unless `/tick rate` changed it) and the
/// milliseconds its ticks took on average lately, as `/tick query`
/// reports them. The ticks a second it keeps up is the rate, or
/// 1000 / milliseconds when that is lower. API 49.
fn tick_stats(&self) -> (f32, f64);
/// Whether that player counts for `players_sleeping_percentage`: an
/// ignored player who isn't in bed is left out of the players the night
/// needs asleep, as a spectator is (an away-from-keyboard mod). Kept
/// until a mod sets it back or the server stops, also if they leave and
/// come back. Only where no player's state is swapped in, as
/// `native_run_command`; false there and when they aren't on. API 49.
fn sleep_ignored(&mut self, uuid: u128, ignored: bool) -> bool;
/// Shows `nickname` in place of that player's name in chat (their
/// messages, `/me`, `/msg`) and in the Tab list; None shows their name
/// again. Hovering the shown name gives the real one, which stays in the
/// log, commands, selectors and the server's events. Kept until a mod
/// sets it again or the server stops (also if they leave and come back).
/// Plain text: the mod checks it (`native_text_allowed`, its length,
/// other players' names). Only where no player's state is swapped in,
/// as `native_run_command`; false there and when they aren't on. API
/// 62.
fn set_nickname(&mut self, uuid: u128, nickname: Option<&str>) -> bool;
/// Whether the server's chat filter (`server.toml` `[chat_filter]`)
/// lets `text` through: true without a filter, None inside a player's
/// hook (where the server is out of reach). API 62.
fn text_allowed(&self, text: &str) -> Option<bool>;
/// A system line in every player's chat and the server's log.
fn broadcast(&self, line: Text);
/// A line of the command's answer, inside `native_perform_command`:
/// sent to the player whose command it is after the call, as vanilla
/// commands' feedback is.
fn feedback(&mut self, line: Text);
/// The player whose action or command a hook hears, as their state is
/// swapped in (`native_perform_command`, `native_right_click_block`,
/// `native_use_item_on`, `native_destroy_block`...): None outside such a
/// hook. API 21.
fn acting_player(&self) -> Option<NativeServerPlayer>;
/// Runs `line` (no slash) as the server's console does: permission
/// level 4, from the overworld's spawn, with no output. A teleport to
/// another level (`execute in minecraft:the_nether run tp Alex 0 70 0`)
/// happens at the next tick's start, as the console's. Whether it ran
/// without failing. Only where no player's state is swapped in
/// (`native_tick`, `native_menu_clicked`): false inside a player's hook,
/// where the other players are out of reach. API 21.
fn run_command(&mut self, line: &str) -> bool;
/// The open world's folder (`world/`, beside its `level.dat`): a server
/// mod keeps its data in a folder of its own there, so the world's
/// backups carry it. None when no world is open.
fn world_dir(&self) -> Option<PathBuf>;
/// The main hand's stack: its item, count, and whether it is plain. On a
/// client, the player's own.
fn held(&self) -> Option<(String, u32, bool)>;
/// How many of plain `item` (`minecraft:diamond`) the player carries.
fn count_items(&mut self, item: &str) -> u32;
/// Every plain item the player carries, with how many, by id.
fn carried_items(&self) -> Vec<(String, u32)>;
/// Takes up to `count` plain `item`, the main hand first; what it took.
fn take_items(&mut self, item: &str, count: u32) -> u32;
/// How many plain `item` still fit; 0 for an unknown item.
fn room_for(&mut self, item: &str) -> u32;
/// Gives up to `count` plain `item` where they fit, as `Inventory.add`
/// places them; what it gave (nothing is dropped). 0 for an unknown item.
fn give_items(&mut self, item: &str, count: u32) -> u32;
/// Gives `stack` whole (its damage, name, enchantments... kept) to the
/// player `uuid`, as `Inventory.add` places it: onto equal stacks first,
/// then into empty inventory slots, never the armour or an empty off
/// hand. What did not fit comes back (nothing is dropped): all of it for
/// a player who isn't here, is dead, or whose state is swapped out by
/// another player's (inside that player's hook, only they are reached).
/// The player's screen follows as for any change to their inventory.
/// What an auto-pickup or a machine's output to a player needs, from any
/// server hook. API 98.
fn give_stack(&mut self, uuid: u128, stack: ItemStack) -> Option<ItemStack>;
/// Gives `points` of experience to the player `uuid` as an orb they touch
/// does (`ExperienceOrb.playerTouch`): Mending repairs their damaged items
/// with it first (`repairPlayerItems`), then the rest goes to their
/// experience (`Player.giveExperiencePoints`), with the level-up sound
/// every fifth level. No orb pickup sound. False, and nothing given, for
/// a player who isn't here or is dead, and for another player than the one
/// whose state is swapped in (inside that player's hook, only they are
/// reached), and while the server's player list is busy elsewhere in the
/// tick: give first, and cancel an orb only when this returned true.
/// Their bar follows as for any change to their experience.
fn give_experience(&mut self, uuid: u128, points: u32) -> bool;
/// Adds `levels` to the player `uuid`'s level, or takes them off when
/// negative (`Player.giveExperienceLevels`): what the anvil and the
/// enchanting table charge with, and a mod can charge or reward with.
/// Below level 0 the level and the points are 0. The progress toward the
/// next level is kept. False, and nothing changed, as for
/// `native_give_experience`.
fn give_levels(&mut self, uuid: u128, levels: i32) -> bool;
/// Read a loaded, light-emitting block. No implicit chunk loading.
fn lit_block_name(&self, pos: [i32; 3]) -> Option<&str>;
/// A small local vanilla particle; caller should rate-limit emission.
fn flame_particle(&mut self, at: [f64; 3], soul: bool);
/// The stack the acting player carries in `slot`, by
/// `native_inventory_tick`'s numbers: none for an empty slot or another
/// number. Change it as you like: a count of 0 empties the slot at the end
/// of `native_inventory_tick`, and the player's client gets the change.
/// API 39.
fn carried_stack(&mut self, slot: i32) -> Option<&mut ItemStack>;
/// Sets a block in the current authoritative level through `Level.setBlock`.
/// Returns false for another dimension, an invalid state, or no change.
/// `flags` selects vanilla's neighbour and shape updates; Horizon sends
/// every write to players whatever the flags. A block entity block gets
/// its block entity. API 24.
fn set_block(
&mut self,
dimension: &str,
pos: [i32; 3],
state: BlockStateId,
flags: i32,
) -> bool;
/// Sets the stack of item entity `id` in the server level `dimension`, as
/// a hopper's `addItem` leaves what it couldn't take (`ItemEntity.setItem`);
/// an empty stack (count 0) removes the entity (`setItem(EMPTY)`, then
/// `discard`). Players see it on their next tracker update. False when the
/// level in place has no such item entity, or on a client. API 42.
fn set_item_entity(&mut self, dimension: &str, id: u32, stack: ItemStack) -> bool;
/// The block at `pos` changed its comparator output
/// (`native_analog_output`) without changing its state: the comparators
/// beside it, or behind a conductor there, read it again
/// (`Level.updateNeighbourForOutputSignal`). Acts on the level in place:
/// false (and nothing happens) when `dimension` is another, or this
/// window isn't a server. API 64.
fn update_output_neighbors(&mut self, dimension: &str, pos: [i32; 3]) -> bool;
/// The block at `pos` changed the signal it gives: its neighbours, and
/// theirs (a block it powers passes the signal on), read it again, as
/// around a lever (`Level.updateNeighborsAt`). Call it when the answer
/// changes and once the block is placed. Acts on the level in place:
/// false (and nothing happens) when `dimension` is another, or this
/// window isn't a server. API 38.
fn update_signal_neighbors(&mut self, dimension: &str, pos: [i32; 3]) -> bool;
/// Sends clients the block at `pos` and its block entity's update tag
/// (`native_block_entity_update_tag`) again, as `Level.sendBlockUpdated`
/// does: with the level's other block changes, to the players who see its
/// chunk, once however often it is called before then. Changes made in a
/// block entity's tick go out at the start of the next tick, as vanilla's
/// do (`ServerLevel.tick` broadcasts chunks before it ticks block
/// entities). Call it when what clients show changed (a machine started),
/// not for every tick's progress: a menu's data slots carry that. Nothing
/// happens for an unloaded block. API 52.
fn block_entity_sync(&mut self, pos: [i32; 3]);
/// Plays sound event `sound` (`minecraft:entity.generic.explode`, or a
/// mod's own from its `assets/<id>/sounds.json`) at `at` for this
/// player only, `delay` ticks from now (0 to 1200, a minute): a client
/// mod's sound that waits, as thunder heard after the flash or a blast
/// heard when its shock wave arrives. `volume` past 1 carries farther,
/// as vanilla's attenuation does (16 blocks times the volume). False, with
/// nothing played, on a server or for a value out of range; an event the
/// resources don't have plays nothing, as vanilla's. API
/// 83.
fn play_local_sound(
&mut self,
sound: &str,
at: [f64; 3],
volume: f32,
pitch: f32,
delay: i32,
) -> bool;
/// A rectangle in GUI pixels, `argb` (0x78000000 is the extras' box).
fn fill(&self, batch: &mut HudBatch, rect: [f32; 4], argb: u32);
/// Part `src` (`[x, y, w, h]` in the sprite's own pixels) of sprite
/// `name` (vanilla's `gui/...`, or a content mod's GUI texture
/// `mymod:gui/...`), drawn stretched into `dst` (`[x, y, w, h]` in GUI
/// pixels). False when there is no such sprite. API 19.
fn sprite(&self, batch: &mut HudBatch, name: &str, src: [f32; 4], dst: [f32; 4]) -> bool;
/// Item `item`'s icon (`mymod:machine`) with its top left at `at`,
/// `size` GUI pixels square (16 in a slot); false for an unknown item.
/// API 20.
fn item(&self, batch: &mut HudBatch, item: &str, at: [f32; 2], size: f32) -> bool;
/// The open menu's title (the block's name, or the one it was given),
/// as plain text. API 20.
fn screen_title(&self) -> Option<String>;
/// A data file of the game's resources, content mods' included
/// (`data/mymod/machines.json`), when there is one: what a mod's content
/// says, read at runtime. Only relative paths of plain names. API 20.
fn data(&self, path: &str) -> Option<Vec<u8>>;
/// The block at `pos` (`mymod:cable`; air where nothing is loaded).
/// API 20.
fn block(&self, pos: [i32; 3]) -> String;
/// Property `key` of the block at `pos` (a machine's `facing`), if it
/// has one. API 20.
fn block_property(&self, pos: [i32; 3], key: &str) -> Option<String>;
/// Puts `stack` into the container at `pos` (a chest, a furnace, a mod
/// container) through its side `face`, as a hopper would, one by one, and
/// returns what did not fit: a machine's auto-output. A composter takes
/// one a call, and a client's copy of the world none. API 20.
fn insert_item(
&mut self,
pos: [i32; 3],
face: Direction,
stack: ItemStack,
) -> Option<ItemStack>;
/// Where the mouse is, in GUI pixels. API 20.
fn cursor(&self) -> [f32; 2];
/// A line of text at `(x, y)` in GUI pixels, opaque `rgb`.
fn text(&self, batch: &mut HudBatch, text: &str, at: [f32; 2], rgb: u32, shadow: bool);
/// The folder mod `mod_id`'s post-build step wrote into when the launcher built the game
/// (`native-data/<id>/` in the build's version folder); None when the game's own path is
/// unknown. API 41.
fn built_data(&self, mod_id: &str) -> Option<PathBuf>;
/// Shows the panel beside container screens with these items (at most
/// 8192), as the game's own slots in pages, with a search box; the
/// player's clicks, searches and keys over items come back as
/// `native_panel_event`. The panel is the last mod's to show one; empty
/// items and no recipe page take yours away.
fn panel_items(&mut self, mod_id: &str, items: Vec<NativeStack>);
/// The recipes of type `kind` (`mymod:crusher`), a type outside `minecraft`,
/// from `data/<namespace>/recipe/*.json` of the content mods (`.hmod`) and
/// the world's data packs, in file order: each one's id and its file as
/// JSON, for the mod to read as it likes. A data pack changes them on
/// `/reload`, so look them up again when you need them. Empty for a type
/// nobody uses and outside a world. These are the server's table on the
/// server thread, a dedicated server or a LAN host; a client, the
/// single-player window included, sees only the content mods' recipes, not
/// the world's data packs', unless a mod had the server send it the type
/// (`native_datapack_sync`). API 36.
fn recipes_of_type(&self, kind: &str) -> &[CustomRecipe];
/// 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
/// when nothing is left (an unknown or empty tag, for one) and outside a
/// world. API 36.
fn recipe_ingredient(&self, json: &serde_json::Value) -> Option<Ingredient>;
/// A stack as a recipe's `result` names it: `id` (a bare one as
/// `minecraft:`), `count` (1 by default, up to 99) and `components`. None
/// without an `id`, for an item the game doesn't know or a count outside
/// that, and outside a world. API 36.
fn recipe_result(&self, json: &serde_json::Value) -> Option<ItemStack>;
/// The `book_id`s of the recipes the player's recipe book holds now
/// (the server's book on a server); empty outside a world.
fn known_recipes(&mut self) -> Arc<[i32]>;
/// Places a recipe in the open screen's grid as a click in the recipe
/// book does (`ServerboundPlaceRecipePacket`): the inventory's grid or a
/// crafting table's. `all` places as many as the items allow, as with
/// Shift. Refused while no such screen is open and for a recipe the
/// player's book doesn't hold; one missing items shows as a ghost.
fn place_recipe(&mut self, book_id: i32, all: bool) -> Result<(), String>;
/// Creative mode only: gives the player `count` of `item`
/// (`minecraft:diamond`) as taking it from the creative inventory does
/// (`ServerboundSetCreativeModeSlot`): the selected slot first, then the
/// hotbar, then the inventory. The count is cut to one stack.
fn give(&mut self, item: &str, count: u32) -> Result<(), String>;
/// For a client mod: a push added to the local player's velocity, in blocks a tick, as an
/// explosion's knockback is (the window's player moves itself, as vanilla's client does). Each component is
/// clamped to -4..4; a spectator, or a value that is not a number, is left alone. API 105.
fn push_player(&mut self, kick: [f32; 3]);
/// For a server mod: `player`'s creative flying speed (vanilla's 0.05 when none, at most `FLYING_MAX`), kept on
/// the server's player and sent to their client. A respawn or a game mode change keeps it, and it is saved
/// with the player (`abilities.flySpeed`, as vanilla saves it). False for a player not here or a client of a
/// server, and inside a player's hook, where the server's player list is busy: call it from a tick hook.
/// API 105.
fn set_flying_speed(&mut self, player: u128, speed: Option<f32>) -> bool;
/// Sends `data` on `channel` (`mod_id:name`, at most 30 000 bytes, at
/// most 32 a player a tick) to `player`'s client, at the tick's end, from
/// any server hook. Ok means accepted: a player who left or isn't on a
/// Horizon client doesn't get it. API 40.
fn net_send_to_player(
&mut self,
player: u128,
channel: &str,
data: &[u8],
) -> Result<(), String>;
/// The client half: sends `data` on `channel` to the server (the same
/// caps, 32 a tick). An error when the server doesn't carry mod messages
/// (a vanilla server), and until the player has joined: a server says it
/// carries them during configuration, before it can take one. API 40.
fn net_send_to_server(&mut self, channel: &str, data: &[u8]) -> Result<(), String>;
/// Drop one item as DefaultDispenseItemBehavior does. For a dispenser hook:
/// return this remainder in its answer with `CLICK`; the caller plays the
/// answer's event and smoke once. A client, another dimension or an unloaded
/// chunk keeps the input stack unchanged. API 67.
fn dispense_default(
&mut self,
dimension: &str,
pos: [i32; 3],
facing: Direction,
stack: ItemStack,
) -> Option<ItemStack>;
/// Opens a chest menu of `rows` rows (1 to 6) titled `title` for the
/// player, showing `items` (by slot, padded with empty ones), in place of
/// the menu they had open: its window id.
fn open_menu(
&mut self,
uuid: u128,
rows: u8,
title: Text,
items: Vec<Option<ItemStack>>,
) -> Option<i32>;
/// Replaces what the player's open native menu shows; sent with the
/// tick's container sync.
fn set_menu_items(&mut self, uuid: u128, items: Vec<Option<ItemStack>>);
/// Closes the player's native menu (a `native_menu_closed` follows).
fn close_menu(&mut self, uuid: u128);
/// The player's 36 inventory slots (0..9 the hotbar), to show or offer
/// them; None for a player not here, dead, or whose state is swapped in.
fn player_stacks(&self, uuid: u128) -> Option<Vec<Option<ItemStack>>>;
/// Swaps two players' offers (inventory slots, each with the stack it
/// must still hold, exactly) in one step: the stacks leave their slots
/// and go to the other player where they fit whole, or nothing moves and
/// the Err says why. Both must be here and alive, neither with their
/// state swapped in. Their screens follow with the tick's sync.
fn exchange_items(
&mut self,
a: u128,
a_offer: &[(usize, ItemStack)],
b: u128,
b_offer: &[(usize, ItemStack)],
) -> Result<(), String>;
/// `native_fluid_insert`: up to `amount` millibuckets of `fluid`
/// (`minecraft:water`, `minecraft:lava`) into the block at `pos`, through
/// its side `face`, in the level in place: a mod block's tanks first
/// those with the same fluid, then empty ones, by the sides and tanks its
/// table gave; a cauldron by thirds of a bucket, a lava one only whole.
/// What did not fit. API 54.
fn fluid_insert(&mut self, pos: [i32; 3], face: Direction, fluid: &str, amount: i32) -> i32;
/// Takes up to `amount` millibuckets out of the block at `pos` in the
/// level in place, `dimension`, through its side `face`: a mod block's
/// drainable tanks, one fluid only (the first that's there), or a
/// cauldron, which drains in thirds of a bucket and a lava one only
/// whole. The fluid and how much; none when the side reaches nothing.
/// API 54.
fn fluid_extract(
&mut self,
dimension: &str,
pos: [i32; 3],
face: Direction,
amount: i32,
) -> Option<(String, i32)>;
/// Moves up to `max` millibuckets from the block at `from`, through its
/// side `from_face`, into the one at `to`, through its side `to_face`, as
/// `native_move_items` moves items: what the target won't take goes back
/// into the source, so none is lost — a cauldron's whole thirds included.
/// The fluid and how much moved; none where `native_fluid_tanks` is none
/// at `from`, where nothing drainable is, where no block holds fluids at
/// `to` or it has no room for that fluid, and within one block. The
/// source is asked for no more than the target's room. API 55.
fn move_fluids(
&mut self,
dimension: &str,
from: [i32; 3],
from_face: Direction,
to: [i32; 3],
to_face: Direction,
max: i32,
) -> Option<(String, i32)>;
/// The level in place (`minecraft:overworld`), for a hook that isn't told
/// which it runs in. API 48.
fn dimension(&self) -> &'static str;
/// Takes up to `count` items out of slot `slot` of the container at `pos`,
/// through its side `face`; they're the mod's to place then, as a hopper's
/// are: to hand them on without losing any, use `native_move_items`. None
/// when nothing can be taken: see `native_container_slots`. API 48.
fn extract_item(
&mut self,
dimension: &str,
pos: [i32; 3],
face: Direction,
slot: usize,
count: u32,
) -> Option<ItemStack>;
/// Moves up to `max` items from the container at `from`, through its side
/// `from_face`, into the one at `to`, through its side `to_face`, one by
/// one as a hopper does: an item the target refuses stays in its slot, so
/// none is lost. `item` (`minecraft:coal`) takes only that item. How many
/// moved: 0 where `native_container_slots` is none at `from`, where no
/// container is at `to`, and within one container. A composter takes one
/// a call. API 48.
fn move_items(
&mut self,
dimension: &str,
from: [i32; 3],
from_face: Direction,
to: [i32; 3],
to_face: Direction,
item: Option<&str>,
max: u32,
) -> u32;
}
+143 -7
View File
@@ -1633,6 +1633,60 @@ impl Screen {
Outcome::None
}
/// The mouse wheel over the server list (`scrollRate`: half a row).
/// Up or Down on the list (`AbstractSelectionList.nextEntry`): the next
/// server or LAN game, the first (Down) or last (Up) with none selected, and
/// none past the ends. With Shift a saved server swaps with its neighbour
/// (`OnlineServerEntry.keyPressed`) and stays selected. False when the screen
/// is not the list or has no entries.
pub fn arrow(&mut self, up: bool, shift: bool, size: (f32, f32), saves: &Path) -> bool {
let Self::List {
servers,
selected,
lan,
scroll,
..
} = self
else {
return false;
};
let total = servers.len() + lan.games().len();
if total == 0 {
return false;
}
let next = match *selected {
Some(k) if shift => {
// A LAN game has no place to move to.
let to = if up { k.checked_sub(1) } else { Some(k + 1) };
if let Some(to) = to.filter(|&to| k < servers.len() && to < servers.len()) {
servers.swap(k, to);
save_servers(&servers_file(saves), servers);
Some(to)
} else {
Some(k)
}
}
Some(k) if up => Some(k.saturating_sub(1)),
Some(k) => Some((k + 1).min(total - 1)),
None if shift => None,
None => Some(if up { total - 1 } else { 0 }),
};
*selected = next;
if let Some(k) = next {
// scrollToEntry: the row, with the LAN header between the servers
// and the games, into view.
let row = if k < servers.len() { k } else { k + 1 };
let (top, bottom, _) = list_area(size.0.ceil(), size.1.ceil());
let content = content_height(servers.len(), lan.games().len());
let (row_top, view) = (2.0 + row as f32 * ROW_HEIGHT, bottom - top);
if row_top < scroll.amount {
scroll.amount = row_top;
} else if row_top + ROW_HEIGHT > scroll.amount + view {
scroll.amount = row_top + ROW_HEIGHT - view;
}
scroll.clamp(top, bottom, content);
}
true
}
pub fn wheel(&mut self, delta: f32, cursor: (f32, f32), size: (f32, f32)) {
if let Self::List {
servers,
@@ -1676,10 +1730,12 @@ impl Screen {
pub fn focused_box(&self, (w, _h): (f32, f32)) -> Option<[f32; 4]> {
let y = match self {
Self::Edit { focus: 0, .. } => 66.0,
Self::Edit { .. } => 106.0,
Self::Edit { focus: 1, .. } => 106.0,
Self::Edit { .. } => return None,
Self::Direct { .. } => 116.0,
Self::Friend { focus: 0, .. } => 64.0,
Self::Friend { .. } => 105.0,
Self::Friend { focus: 1, .. } => 105.0,
Self::Friend { .. } => return None,
_ => return None,
};
Some([(w / 2.0).floor() - 100.0, y, 200.0, 20.0])
@@ -1691,11 +1747,20 @@ impl Screen {
address,
focus,
..
} => Some(if *focus == 0 { name } else { address }),
} => match *focus {
0 => Some(name),
1 => Some(address),
// A button has the focus.
_ => None,
},
Self::Direct { address } => Some(address),
Self::Friend {
code, relay, focus, ..
} => Some(if *focus == 0 { code } else { relay }),
} => match *focus {
0 => Some(code),
1 => Some(relay),
_ => None,
},
_ => None,
}
}
@@ -1776,9 +1841,31 @@ impl Screen {
128
}
}
pub fn tab(&mut self) {
if let Self::Edit { focus, .. } | Self::Friend { focus, .. } = self {
*focus = (*focus + 1) % 2;
/// Tab (`reverse`: Shift+Tab) through the focus order of `ManageServerScreen`
/// and the friend screen: the text fields, then the enabled `buttons`, then
/// round to the first field. `button` is the focused button, if any; a field
/// has the focus (`focus` below 2) only while no button does.
pub fn tab_dir(&mut self, reverse: bool, buttons: &[u8], button: &mut Option<u8>) {
let (Self::Edit { focus, .. } | Self::Friend { focus, .. }) = self else {
return;
};
let stops = 2 + buttons.len();
let at = match *button {
Some(id) => buttons.iter().position(|&b| b == id).map(|i| 2 + i),
None => Some(usize::from(*focus).min(1)),
}
.unwrap_or(0);
let next = if reverse {
(at + stops - 1) % stops
} else {
(at + 1) % stops
};
if next < 2 {
*focus = next as u8;
*button = None;
} else {
*focus = 2;
*button = Some(buttons[next - 2]);
}
}
/// Escape: back to the list, or out of it (true).
@@ -2225,6 +2312,55 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn arrow_keys_select_servers_and_shift_moves_them() {
let dir = std::env::temp_dir().join(format!("horizon-mp-keys-{}", std::process::id()));
let saves = dir.join("saves");
std::fs::create_dir_all(&saves).unwrap();
let file = servers_file(&saves);
let list: Vec<ServerEntry> = ["a", "b", "c"]
.iter()
.map(|n| ServerEntry::new(n, "127.0.0.1:2"))
.collect();
servers::save(&file, &list).unwrap();
let mut screen = Screen::list(&saves);
let size = (427.0, 240.0);
let state = |screen: &Screen| match screen {
Screen::List {
servers, selected, ..
} => (
servers.iter().map(|s| s.name.clone()).collect::<Vec<_>>(),
*selected,
),
_ => unreachable!(),
};
let abc = vec!["a".to_owned(), "b".into(), "c".into()];
// None selected: Down takes the first, as vanilla's nextEntry does.
assert!(screen.arrow(false, false, size, &saves));
assert_eq!(state(&screen), (abc.clone(), Some(0)));
// Up at the top stays; Down walks; the last stays.
screen.arrow(true, false, size, &saves);
assert_eq!(state(&screen).1, Some(0));
screen.arrow(false, false, size, &saves);
screen.arrow(false, false, size, &saves);
screen.arrow(false, false, size, &saves);
assert_eq!(state(&screen).1, Some(2));
// Shift+Up moves "c" above "b", keeps it selected and saves the order.
screen.arrow(true, true, size, &saves);
assert_eq!(
state(&screen),
(vec!["a".to_owned(), "c".into(), "b".into()], Some(1))
);
let saved: Vec<String> = servers::load(&file).into_iter().map(|s| s.name).collect();
assert_eq!(saved, ["a", "c", "b"]);
// Nothing moves past the first server.
screen.arrow(true, true, size, &saves);
screen.arrow(true, true, size, &saves);
assert_eq!(state(&screen).1, Some(0));
assert_eq!(state(&screen).0[0], "c");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn icon_arrows_join_and_move_servers() {
let dir = std::env::temp_dir().join(format!("horizon-mp-arrows-{}", std::process::id()));
+188
View File
@@ -26,6 +26,7 @@ use horizon_core::{
position::{BlockPos, ChunkPos},
ticks::{SavedTick, TickPriority, TickTypeId},
};
use horizon_world::attachments::{ATTACHMENTS_KEY, attachment_compound};
use horizon_world::{
DATA_VERSION,
chunk::BlockState,
@@ -1446,6 +1447,18 @@ pub fn encode_with(
None => current,
};
let mut saved = crate::block_entity_nbt::canonical(&merged).unwrap_or(merged);
if let Some((_, attachments)) =
extras.and_then(|e| e.block_entity_attachments.iter().find(|(pos, _)| pos == p))
&& let Tag::Compound(map) = &mut saved
&& attachment_compound(map.get(&ATTACHMENTS_KEY.into())).is_ok()
{
let data = attachments.to_nbt();
if data.is_empty() {
map.remove(&ATTACHMENTS_KEY.into());
} else {
map.insert(ATTACHMENTS_KEY.into(), Tag::Compound(data));
}
}
if let Some(r) = extras.and_then(|x| x.rolled_loot.iter().find(|r| r.pos == *p))
&& let Tag::Compound(map) = &mut saved
&& !r.restore(map)
@@ -1490,6 +1503,16 @@ pub fn encode_with(
put(&mut root, key, value);
}
if let Some(Tag::Compound(map)) = Some(&mut root) {
if let Some(extras) = extras
&& attachment_compound(map.get(&ATTACHMENTS_KEY.into())).is_ok()
{
let attachments = extras.attachments.to_nbt();
if attachments.is_empty() {
map.remove(&ATTACHMENTS_KEY.into());
} else {
map.insert(ATTACHMENTS_KEY.into(), Tag::Compound(attachments));
}
}
map.entry("InhabitedTime".into()).or_insert(Tag::Long(0));
map.entry("structures".into()).or_insert_with(|| {
Tag::compound([
@@ -1706,6 +1729,12 @@ fn decode_column(
}
match decode_block_entity(e) {
Some(mut be) => {
if let Ok(Some(data)) = attachment_compound(e.get(ATTACHMENTS_KEY)) {
extras.block_entity_attachments.push((
be.0,
horizon_world::attachments::Attachments::from_nbt(data.clone()),
));
}
if let Some(name) = text(e.get("CustomName")) {
chunk.block_names.push((be.0, name));
}
@@ -1720,6 +1749,9 @@ fn decode_column(
}
}
if let Tag::Compound(map) = tag {
if let Ok(Some(data)) = attachment_compound(map.get(&ATTACHMENTS_KEY.into())) {
extras.attachments = horizon_world::attachments::Attachments::from_nbt(data.clone());
}
extras.root = map
.iter()
.filter(|(k, _)| !GENERATED_KEYS.contains(&k.to_string_lossy().as_str()))
@@ -2356,6 +2388,59 @@ struct LodRegion {
/// it was read show in the far view this long after.
const LOD_REGION_TTL: std::time::Duration = std::time::Duration::from_secs(10);
impl SaveDir {
/// This dimension's server-owned mod attachments: `data/horizon/attachments.dat`, Horizon's
/// own namespace. A file that doesn't read is moved aside to `attachments.dat.corrupt`, with
/// a warning, and the level opens without them (as vanilla's `SavedDataStorage` starts a
/// saved data over when its file is broken), so the next save can't overwrite it.
pub fn load_attachments(&self) -> horizon_world::attachments::Attachments {
let path = self.attachments_path();
let data = match try_read_dat(&path) {
Err(DatIssue::Missing) => return Default::default(),
Ok(root) => match root.get("data") {
Some(Tag::Compound(data)) => Ok(data.clone()),
_ => Err("no data compound".to_owned()),
},
Err(DatIssue::Unreadable(why)) => Err(why),
};
data.map(horizon_world::attachments::Attachments::from_nbt)
.unwrap_or_else(|why| {
let aside = path.with_extension("dat.corrupt");
log::warn!(
"{}: {why}; mod attachments start over, the file is kept as {}",
path.display(),
aside.display()
);
let _ = fs::rename(&path, &aside);
Default::default()
})
}
/// Save this dimension's server-owned mod attachments; nothing while it has none and never had.
pub fn save_attachments(
&self,
attachments: &horizon_world::attachments::Attachments,
) -> Result<()> {
let path = self.attachments_path();
let data = attachments.to_nbt();
if data.is_empty() && !path.exists() {
return Ok(());
}
write_dat(
&path,
Tag::compound([
("data", Tag::Compound(data)),
("DataVersion", Tag::Int(DATA_VERSION)),
]),
)
}
fn attachments_path(&self) -> PathBuf {
self.root
.join("data")
.join("horizon")
.join("attachments.dat")
}
/// The saves of a level of `shape` at `root` (its dimension folder).
pub fn new(root: PathBuf, shape: LevelShape) -> Self {
Self {
@@ -5082,6 +5167,82 @@ mod tests {
assert_eq!(saved.tag, expected.tag);
}
#[test]
fn level_attachments_survive_save_and_load() {
use horizon_world::attachments::{
AttachmentCodec, AttachmentError, AttachmentType, Attachments,
};
let dir =
std::env::temp_dir().join(format!("horizon-level-attachments-{}", std::process::id()));
let save = SaveDir::new(dir.clone(), LevelShape::OVERWORLD);
let key = AttachmentType {
namespace: "test",
name: "counter",
default: || 0,
codec: Some(AttachmentCodec {
encode: |n: &i32| Ok([("count".into(), Tag::Int(*n))].into()),
decode: |c: &Compound| match c.get(&"count".into()) {
Some(Tag::Int(n)) => Ok(*n),
_ => Err(AttachmentError::Decode),
},
}),
copy_on_death: false,
};
let mut map = Attachments::default();
map.set(&key, 7).unwrap();
save.save_attachments(&map).unwrap();
assert_eq!(save.load_attachments().get(&key), Ok(7));
std::fs::remove_dir_all(dir).unwrap();
}
#[test]
fn chunk_attachments_survive_save_and_load() {
use horizon_world::attachments::{AttachmentCodec, AttachmentError, AttachmentType};
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
return;
};
let root = PathBuf::from(root);
let assets = crate::assets::Assets::load(&root).expect("assets");
let biomes = Biomes::load(&root).expect("biomes");
let key = AttachmentType {
namespace: "test",
name: "counter",
default: || 0,
codec: Some(AttachmentCodec {
encode: |n: &i32| Ok([("count".into(), Tag::Int(*n))].into()),
decode: |c: &Compound| match c.get(&"count".into()) {
Some(Tag::Int(n)) => Ok(*n),
_ => Err(AttachmentError::Decode),
},
}),
copy_on_death: false,
};
let mut chunk = Chunk::empty(2, 3);
let mut extras = ChunkExtras::default();
extras.attachments.set(&key, 42).unwrap();
let mut block_data = horizon_world::attachments::Attachments::default();
block_data.set(&key, 21).unwrap();
let pos = (33, 64, 50);
let chest = assets.registry.default_state("minecraft:chest").unwrap();
chunk.set(1, 64, 2, chest.0);
chunk
.block_entities
.push((pos, crate::containers::BlockEntity::Chest(vec![None; 27])));
extras.block_entity_attachments.push((pos, block_data));
chunk.saved = Some(Arc::new(extras));
let saved = encode(&chunk, assets.registry, &biomes);
let mut loaded = decode(&saved, assets.registry, &biomes).expect("chunk");
assert_eq!(
Arc::make_mut(loaded.saved.as_mut().unwrap())
.attachments
.get(&key),
Ok(42)
);
let extras = Arc::make_mut(loaded.saved.as_mut().unwrap());
assert_eq!(extras.block_entity_attachments[0].0, pos);
assert_eq!(extras.block_entity_attachments[0].1.get(&key), Ok(21));
}
#[test]
fn unopened_rolled_containers_save_their_loot_table() {
let Some(root) = std::env::var_os("HORIZON_TEST_RESOURCES") else {
@@ -6939,6 +7100,33 @@ mod tests {
);
}
#[test]
fn mod_attachments_save_in_horizons_namespace_only_when_some() {
use horizon_world::attachments::Attachments;
let root = std::env::temp_dir().join(format!("horizon-attach-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let save = SaveDir::new(root.clone(), LevelShape::OVERWORLD);
let file = root.join("data/horizon/attachments.dat");
save.save_attachments(&Attachments::default()).unwrap();
assert!(
!root.join("data").exists(),
"a level without mod data writes nothing"
);
let data = Compound::from_iter([("example:count".into(), Tag::Int(3))]);
save.save_attachments(&Attachments::from_nbt(data.clone()))
.unwrap();
assert!(file.is_file());
assert_eq!(save.load_attachments().to_nbt(), data);
fs::write(&file, b"not nbt").unwrap();
assert!(save.load_attachments().to_nbt().is_empty());
assert_eq!(
fs::read(root.join("data/horizon/attachments.dat.corrupt")).unwrap(),
b"not nbt"
);
assert!(!file.exists());
fs::remove_dir_all(&root).unwrap();
}
#[test]
fn forced_chunks_survive_dimension_restarts_and_removals() {
let root = std::env::temp_dir().join(format!("horizon-forced-{}", std::process::id()));
+27 -3
View File
@@ -3239,6 +3239,14 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
.map(|b| b.id);
if hit.is_some() {
self.focused_button = hit;
} else if matches!(
self.mode,
Mode::Multiplayer(
crate::multiplayer::Screen::Edit { .. } | crate::multiplayer::Screen::Friend { .. }
)
) {
// A press on a text field takes the focus from the button.
self.focused_button = None;
}
if matches!(self.mode, Mode::CreateBuffet { .. }) {
self.buffet_click(x, y, (w, h), hit);
@@ -3857,8 +3865,17 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
if self.rules.get_mut().is_some() {
return;
}
if let Mode::Multiplayer(screen) = &mut self.mode {
screen.tab();
if matches!(self.mode, Mode::Multiplayer(_)) {
let (w, h) = size;
let enabled: Vec<u8> = self
.buttons(w, h)
.iter()
.filter(|b| b.enabled)
.map(|b| b.id)
.collect();
if let Mode::Multiplayer(screen) = &mut self.mode {
screen.tab_dir(reverse, &enabled, &mut self.focused_button);
}
return;
}
let (w, h) = size;
@@ -3991,7 +4008,7 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
}
}
/// 2D spatial arrow key navigation across enabled buttons.
pub fn arrow_key(&mut self, key: winit::keyboard::KeyCode, size: (f32, f32)) {
pub fn arrow_key(&mut self, key: winit::keyboard::KeyCode, size: (f32, f32), shift: bool) {
use winit::keyboard::KeyCode;
if self.rules.get_mut().is_some() {
return;
@@ -3999,6 +4016,13 @@ Would you like to enable the Narrator or visit the Accessibility Settings?",
if self.has_caret_field() {
return;
}
if self.focused_button.is_none()
&& let Mode::Multiplayer(screen) = &mut self.mode
&& matches!(key, KeyCode::ArrowUp | KeyCode::ArrowDown)
&& screen.arrow(key == KeyCode::ArrowUp, shift, size, &self.saves)
{
return;
}
if self.list_focused()
&& self.focused_button.is_none()
&& let Mode::Select { worlds, selected } = &mut self.mode
+49 -2
View File
@@ -1152,6 +1152,53 @@ fn world_fields_edit_at_their_cursor() {
assert_eq!(f.title.search, "world");
}
/// `ManageServerScreen`'s focus order: the name, the address, the Server
/// Resource Packs button, Done, Cancel, then the name again.
#[test]
fn tab_walks_the_edit_server_screen_through_its_buttons() {
use crate::multiplayer::Screen;
let size = (854.0, 480.0);
let mut f = Fixture::new();
f.title.mode = Mode::Multiplayer(Screen::Edit {
index: None,
name: "Minecraft Server".into(),
address: "localhost".into(),
focus: 0,
packs: None,
});
let focus = |f: &Fixture| match &f.title.mode {
Mode::Multiplayer(Screen::Edit { focus, .. }) => (*focus, f.title.focused_button),
_ => panic!("edit"),
};
let mut seen = Vec::new();
for _ in 0..5 {
f.title.tab_dir(false, size);
seen.push(focus(&f));
}
// Address, then packs (60), Done (50), Cancel (51), and round to the name.
assert_eq!(
seen,
[
(1, None),
(2, Some(60)),
(2, Some(50)),
(2, Some(51)),
(0, None)
]
);
// A button has the focus: typing goes nowhere.
f.title.tab_dir(true, size);
assert_eq!(focus(&f), (2, Some(51)));
f.title.type_text("x");
let Mode::Multiplayer(Screen::Edit { name, address, .. }) = &f.title.mode else {
panic!("edit");
};
assert_eq!(
(name.as_str(), address.as_str()),
("Minecraft Server", "localhost")
);
}
#[test]
fn server_fields_edit_at_their_cursor() {
use winit::keyboard::KeyCode;
@@ -1179,8 +1226,8 @@ fn server_fields_edit_at_their_cursor() {
panic!("add server");
};
assert_eq!(address, "Yhost");
// Tab moves to the name, which edits at its own cursor.
f.title.tab();
// Shift+Tab moves back to the name, which edits at its own cursor.
f.title.tab_dir(true, (854.0, 480.0));
f.title.field_key(KeyCode::ArrowLeft, false, false);
f.title.type_text("X");
let Mode::Multiplayer(crate::multiplayer::Screen::Edit { name, .. }) = &f.title.mode else {
+18 -3
View File
@@ -41,9 +41,10 @@ pub struct Index {
#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(from = "String", into = "String")]
pub enum Kind {
/// A `.hmod`.
/// A `.hmod` of content only.
Mod,
/// A source-only native mixin bundle, compiled into an isolated game build.
/// A source-only native mixin bundle, compiled into an isolated game build. Published as an
/// `.hnmod` until launchers and the game read native `.hmod`s (#5865, docs/api/mod-files.md).
NativeMod,
/// A `.hpack`: registry mods played together as a profile.
Modpack,
@@ -557,8 +558,22 @@ impl Facts {
/// this build doesn't know are refused: their checks come with their formats.
pub fn inspect(kind: &Kind, path: &Path) -> Result<Facts, String> {
match kind {
Kind::NativeMod => Ok(native::Bundle::read(path)?.manifest.facts()),
Kind::NativeMod => {
let facts = native::Bundle::read(path)?.manifest.facts();
// A `.hmod` with content too: checked as the game reads it.
if Package::has_manifest(path) {
let m = Package::read(path)?.manifest;
crate::Content::read(path, &m)?;
}
Ok(facts)
}
Kind::Mod => {
if native::holds(path) {
return Err(format!(
"it has native code ({}): it is a native-mod",
native::MANIFEST
));
}
let Package { manifest: m, wasm } = Package::read(path)?;
crate::Content::read(path, &m)?;
let api_level = match wasm {
+93 -4
View File
@@ -5,6 +5,31 @@ use std::{collections::BTreeMap, io::Read, path::Path};
pub const MANIFEST: &str = "horizon-native.json";
/// Why a native mod may not carry `mod.wasm`: WebAssembly mods don't run any more.
pub const WASM_GONE: &str = "mod.wasm: WebAssembly mods don't run any more, port the code to a native mod (docs/native-mods.md)";
/// Whether `path` names a native mod's file: an `.hnmod`, or a `.hmod` holding native code
/// (one file for content and code, docs/native-mods.md).
pub fn is_file(path: &Path) -> bool {
path.extension()
.is_some_and(|e| e == "hnmod" || e == "hmod")
}
/// Whether the mod file at `path` carries native code ([`MANIFEST`]): every `.hnmod`, and a
/// `.hmod` with code beside or instead of its content.
pub fn holds(path: &Path) -> bool {
std::fs::File::open(path)
.ok()
.and_then(|f| zip::ZipArchive::new(f).ok())
.is_some_and(|mut zip| zip.by_name(MANIFEST).is_ok())
}
/// Whether a `.hmod`'s entry `name` is its content's (`horizon-mod.toml`, `assets/`, `data/`),
/// which [`crate::Content`] reads and checks, not the native build.
fn content(name: &str) -> bool {
name == crate::manifest::MANIFEST || name.starts_with("assets/") || name.starts_with("data/")
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Manifest {
@@ -67,7 +92,7 @@ impl Manifest {
pub struct Bundle {
pub manifest: Manifest,
/// Validated relative paths: Rust and TOML, plus files under `mixins/<id>/data/` or a
/// `data/` folder deeper in (a crate's).
/// `data/` folder deeper in (a crate's). A `.hmod`'s content files are not among them.
pub files: BTreeMap<String, Vec<u8>>,
}
@@ -102,10 +127,8 @@ impl Bundle {
pub fn read(path: &Path) -> Result<Self, String> {
let file = std::fs::File::open(path).map_err(|e| e.to_string())?;
let mut zip = zip::ZipArchive::new(file).map_err(|e| e.to_string())?;
if zip.len() > 512 {
return Err("native bundle has too many entries".into());
}
let mut files = BTreeMap::new();
let mut content_id = None;
let mut total = 0u64;
for i in 0..zip.len() {
let mut entry = zip.by_index(i).map_err(|e| e.to_string())?;
@@ -125,6 +148,23 @@ impl Bundle {
{
return Err(format!("invalid native bundle path: {name}"));
}
if name == crate::manifest::WASM {
return Err(WASM_GONE.into());
}
if content(&name) {
if name == crate::manifest::MANIFEST {
let mut text = String::new();
(&mut entry)
.take(1 << 20)
.read_to_string(&mut text)
.map_err(|e| format!("{name}: {e}"))?;
content_id = Some(crate::Manifest::parse(&text)?.id);
}
continue;
}
if files.len() >= 512 {
return Err("native bundle has too many entries".into());
}
let mut bytes = Vec::new();
(&mut entry)
.take(super::MAX_FILE + 1)
@@ -142,6 +182,13 @@ impl Bundle {
serde_json::from_slice(files.get(MANIFEST).ok_or("missing horizon-native.json")?)
.map_err(|e| e.to_string())?;
manifest.check()?;
if let Some(id) = content_id.filter(|id| *id != manifest.id) {
return Err(format!(
"its {} is mod {id}, its {MANIFEST} {}: one file is one mod",
crate::manifest::MANIFEST,
manifest.id
));
}
let prefix = format!("mixins/{}/", manifest.id);
for name in files.keys().filter(|n| n.as_str() != MANIFEST) {
let Some(rest) = name.strip_prefix(&prefix) else {
@@ -282,6 +329,48 @@ mod tests {
}
}
#[test]
fn one_hmod_carries_content_beside_its_native_code() {
let toml = r#"id = "veinminer"
name = "Veinminer"
version = "0.3.0"
api = "0.2"
capabilities = ["registries"]
"#;
let bundle = read(
"unified",
&[
("horizon-mod.toml", toml.as_bytes()),
("assets/veinminer/lang/en_us.json", b"{}"),
("data/veinminer/tags/item/ores.json", b"{}"),
("mixins/veinminer/hook.rs", b"fn hook() {}"),
],
&mixin(),
)
.unwrap();
let names: Vec<&str> = bundle.files.keys().map(String::as_str).collect();
assert_eq!(
names,
[
MANIFEST,
"mixins/veinminer/hook.rs",
"mixins/veinminer/horizon-mixin.toml"
]
);
let other = toml.replace(r#""veinminer""#, r#""other""#);
let e = read(
"two-ids",
&[("horizon-mod.toml", other.as_bytes())],
&mixin(),
)
.err()
.unwrap();
assert!(e.contains("one file is one mod"), "{e}");
let e = read("wasm", &[("mod.wasm", b"\0asm")], &mixin())
.err()
.unwrap();
assert_eq!(e, WASM_GONE);
}
#[test]
fn rejects_missing_or_mismatched_mixin_identity_without_panicking() {
assert!(read("missing-mod", &[], "[unrelated]\nvalue = 1").is_err());
assert!(
+9 -9
View File
@@ -354,8 +354,8 @@ pub fn project(entry: &Entry, src: &Sources) -> Result<Project, Vec<String>> {
for v in &entry.versions {
match version(entry, v, src) {
// A mod that turned native keeps its `.hmod` versions published, but the index offers
// only its native ones: a launcher never installs a `.hmod` as a native mod.
Ok(_) if file_kind(entry, v) != entry.kind => {}
// only its native ones: a launcher never installs a content-only `.hmod` as a native mod.
Ok(_) if file_kind(entry, v, &src.files.join(&v.sha256)) != entry.kind => {}
Ok(v) => versions.push(v),
Err(e) => errors.push(format!("{id} {}: {e}", v.version)),
}
@@ -384,11 +384,11 @@ pub fn project(entry: &Entry, src: &Sources) -> Result<Project, Vec<String>> {
})
}
/// The kind of a version's file: the project's, but a `.hmod` of a mod that turned native
/// (`review`) is a WebAssembly mod's.
fn file_kind(entry: &Entry, v: &EntryVersion) -> Kind {
/// The kind of a version's file at `path`: the project's, but a `.hmod` without native code of a
/// mod that turned native (`review`) is from before it did.
fn file_kind(entry: &Entry, v: &EntryVersion, path: &Path) -> Kind {
match entry.kind {
Kind::NativeMod if v.file.ends_with(".hmod") => Kind::Mod,
Kind::NativeMod if v.file.ends_with(".hmod") && !super::native::holds(path) => Kind::Mod,
_ => entry.kind.clone(),
}
}
@@ -425,7 +425,7 @@ fn version_at(
(None, Some(_)) => return Err("signed, but the project has no key".into()),
(None, None) => {}
}
let facts = inspect(&file_kind(entry, v), path)?;
let facts = inspect(&file_kind(entry, v, path), path)?;
if facts.id.as_ref().is_some_and(|id| *id != entry.id) {
return Err(format!(
"the file's id is {:?}, not {:?}",
@@ -598,7 +598,7 @@ pub struct Review {
/// Compares the entries of a pull request (`head`) with `main`'s (`base`):
/// a published file never changes and is never removed (take it down in
/// `revoked.toml`), a project's kind never changes but for a mod turning
/// native (its next versions are `.hnmod`s), and changes by someone who
/// native (its next versions carry native code), and changes by someone who
/// isn't an owner, or to owners and keys, are pointed out.
pub fn review(base: &[Entry], head: &[Entry], author: &str) -> Review {
let mut r = Review::default();
@@ -622,7 +622,7 @@ pub fn review(base: &[Entry], head: &[Entry], author: &str) -> Review {
}
if (&old.kind, &new.kind) == (&Kind::Mod, &Kind::NativeMod) {
r.notes.push(format!(
"{}: turns native; its .hmod versions stay published, and launchers keep the installed copies",
"{}: turns native; its content-only .hmod versions stay published, and launchers keep the installed copies",
old.id
));
} else if new.kind != old.kind {
@@ -259,9 +259,27 @@ fn a_mod_may_turn_native_once() {
);
assert!(r.errors.is_empty(), "{r:?}");
assert!(r.notes.iter().any(|n| n.contains("turns native")), "{r:?}");
// Its .hmod versions are a WebAssembly mod's files; the new ones native.
assert_eq!(file_kind(&native, &native.versions[1]), Kind::Mod);
assert_eq!(file_kind(&native, &native.versions[0]), Kind::NativeMod);
// Its content-only .hmod versions are from before; the new ones native, and a .hmod
// holding native code is native too.
let none = Path::new("no-such-file");
assert_eq!(file_kind(&native, &native.versions[1], none), Kind::Mod);
assert_eq!(
file_kind(&native, &native.versions[0], none),
Kind::NativeMod
);
let unified = std::env::temp_dir().join(format!("unified-{}.hmod", std::process::id()));
let mut zip = zip::ZipWriter::new(std::fs::File::create(&unified).unwrap());
zip.start_file(
super::super::native::MANIFEST,
zip::write::SimpleFileOptions::default(),
)
.unwrap();
zip.finish().unwrap();
assert_eq!(
file_kind(&native, &native.versions[1], &unified),
Kind::NativeMod
);
std::fs::remove_file(unified).unwrap();
// And never back.
let r = review(&[native], &[base], "ludovic111");
assert!(
+11
View File
@@ -362,6 +362,17 @@ impl Package {
}
Ok(Package { manifest, wasm })
}
/// Whether the mod at `path` (a `.hmod` or a folder) has a [`MANIFEST`]: content, or a legacy
/// WebAssembly mod. A `.hmod` holding only native code has none.
pub fn has_manifest(path: &Path) -> bool {
if path.is_dir() {
return path.join(MANIFEST).is_file();
}
std::fs::File::open(path)
.ok()
.and_then(|f| zip::ZipArchive::new(f).ok())
.is_some_and(|mut zip| zip.by_name(MANIFEST).is_ok())
}
/// The `.hmod` bytes for `manifest_text` and a component.
pub fn write(manifest_text: &str, wasm: &[u8]) -> Result<Vec<u8>, String> {
Self::write_files(manifest_text, Some(wasm), &[])
+405
View File
@@ -57,6 +57,187 @@ impl Damage {
}
}
/// An OpenGL blend factor (`GL_ONE`, `GL_SRC_ALPHA`...), as `glBlendFuncSeparate` takes them.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Factor {
Zero,
One,
SrcColor,
OneMinusSrcColor,
SrcAlpha,
OneMinusSrcAlpha,
DstAlpha,
OneMinusDstAlpha,
DstColor,
OneMinusDstColor,
SrcAlphaSaturate,
}
impl Factor {
/// A GL constant's name (`GL_DST_COLOR`) or its value, decimal or `0x` hex.
fn parse(s: &str) -> Option<Self> {
let code = match s.strip_prefix("0x") {
Some(hex) => u32::from_str_radix(hex, 16).ok(),
None => s.parse().ok(),
};
let code = code.or_else(|| {
let names = [
"GL_ZERO",
"GL_ONE",
"GL_SRC_COLOR",
"GL_ONE_MINUS_SRC_COLOR",
"GL_SRC_ALPHA",
"GL_ONE_MINUS_SRC_ALPHA",
"GL_DST_ALPHA",
"GL_ONE_MINUS_DST_ALPHA",
"GL_DST_COLOR",
"GL_ONE_MINUS_DST_COLOR",
"GL_SRC_ALPHA_SATURATE",
];
let i = names.iter().position(|n| *n == s)? as u32;
Some(if i < 2 { i } else { 0x300 + i - 2 })
})?;
use Factor::*;
Some(match code {
0 => Zero,
1 => One,
0x300 => SrcColor,
0x301 => OneMinusSrcColor,
0x302 => SrcAlpha,
0x303 => OneMinusSrcAlpha,
0x304 => DstAlpha,
0x305 => OneMinusDstAlpha,
0x306 => DstColor,
0x307 => OneMinusDstColor,
0x308 => SrcAlphaSaturate,
_ => return None,
})
}
}
/// `blend`: the factors for colour and alpha; `None` (`replace`) draws without blending.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Blend {
pub src: Factor,
pub dst: Factor,
pub src_alpha: Factor,
pub dst_alpha: Factor,
}
/// `blend=` as CIT Resewn reads it: a named method, or 2 or 4 GL factors (alpha defaulting to
/// `GL_ZERO GL_ONE`). `Some(None)` is `replace`.
pub fn parse_blend(value: &str) -> Option<Option<Blend>> {
use Factor::*;
let pair = |src, dst| {
Some(Some(Blend {
src,
dst,
src_alpha: Zero,
dst_alpha: One,
}))
};
match value.trim().to_ascii_lowercase().as_str() {
"replace" => return Some(None),
"glint" => return pair(SrcColor, One),
"alpha" => return pair(SrcAlpha, OneMinusSrcAlpha),
"add" => return pair(SrcAlpha, One),
"subtract" => return pair(OneMinusDstColor, Zero),
"multiply" => return pair(DstColor, OneMinusSrcAlpha),
"dodge" => return pair(One, One),
"burn" => return pair(Zero, OneMinusSrcColor),
"screen" => return pair(One, OneMinusSrcColor),
"overlay" => return pair(DstColor, SrcColor),
_ => {}
}
let factors: Option<Vec<Factor>> = value.split_whitespace().map(Factor::parse).collect();
match factors?[..] {
[src, dst] => pair(src, dst),
[src, dst, src_alpha, dst_alpha] => Some(Some(Blend {
src,
dst,
src_alpha,
dst_alpha,
})),
_ => None,
}
}
/// A `type=enchantment` rule's glint layer.
#[derive(Clone, Debug, PartialEq)]
pub struct Glint {
pub layer: i32,
pub blend: Option<Blend>,
/// Scroll speed: the texture runs through vanilla's glint periods at `speed` times the
/// clock in milliseconds (vanilla's own glint at 4 by default).
pub speed: f32,
/// Degrees the texture turns by (as in CIT Resewn, its scroll keeps vanilla's direction).
pub rotation: f32,
/// Seconds on this layer when `method=cycle` (1 by default, as OptiFine's doc has it).
pub duration: f32,
/// `r`, `g`, `b`, `a`: multipliers of the texture's colour, never negative.
pub rgba: [f32; 4],
/// Whether the default glint still draws under this layer.
pub use_glint: bool,
/// Linear filtering, else nearest.
pub blur: bool,
}
/// OptiFine's defaults (`blur`, which it does not document, is CIT Resewn's): still, unturned,
/// a second per cycle, added on top.
impl Default for Glint {
fn default() -> Self {
Self {
layer: 0,
blend: parse_blend("add").flatten(),
speed: 0.0,
rotation: 0.0,
duration: 1.0,
rgba: [1.0; 4],
use_glint: false,
blur: true,
}
}
}
impl Glint {
/// A `type=enchantment` rule's glint keys; `None` (dropping the rule) when one is
/// malformed. CIT Resewn clamps `duration` and the colour at 0.
fn parse(props: &Properties) -> Option<Self> {
let d = Self::default();
let float =
|key: &str, default: f32| optional(props, &[key], finite).map(|v| v.unwrap_or(default));
let flag = |key: &str, default: bool| {
props
.get(key)
.map_or(default, |v| v.trim().eq_ignore_ascii_case("true"))
};
let blend = match props.get("blend") {
Some(v) => parse_blend(v)?,
None => d.blend,
};
Some(Self {
layer: optional(props, &["layer"], |v| v.parse::<i32>().ok())?.unwrap_or(d.layer),
blend,
speed: float("speed", d.speed)?,
rotation: float("rotation", d.rotation)?,
duration: float("duration", d.duration)?.max(0.0),
rgba: [
float("r", 1.0)?.max(0.0),
float("g", 1.0)?.max(0.0),
float("b", 1.0)?.max(0.0),
float("a", 1.0)?.max(0.0),
],
use_glint: flag("useGlint", d.use_glint),
blur: flag("blur", d.blur),
})
}
}
/// A number, where NaN and the infinities (`nan`, `inf`, `Infinity` in any case) are malformed.
fn finite(v: &str) -> Option<f32> {
v.trim().parse::<f32>().ok().filter(|f| f.is_finite())
}
/// Where a component test reads its value.
#[derive(Clone, Debug, PartialEq)]
pub enum Source {
@@ -310,6 +491,8 @@ pub struct Rule {
pub model: Option<String>,
/// `model.<name>`.
pub models: Vec<(String, String)>,
/// What an Enchantment rule draws; the defaults for other kinds.
pub glint: Glint,
}
impl Rule {
@@ -398,6 +581,18 @@ impl Rule {
if texture.is_none() && model.is_none() && textures.is_empty() && models.is_empty() {
return None;
}
// A glint is a texture: CIT Resewn drops an enchantment rule without one ("No texture
// specified"), whatever models it names.
if kind == Kind::Enchantment && texture.is_none() {
return None;
}
// Only an enchantment rule reads the glint keys: an item rule's `blend=` is no reason to
// drop it.
let glint = if kind == Kind::Enchantment {
Glint::parse(props)?
} else {
Glint::default()
};
Some(Self {
path: path.to_owned(),
kind,
@@ -427,6 +622,7 @@ impl Rule {
textures,
model,
models,
glint,
})
}
@@ -437,6 +633,71 @@ impl Rule {
}
}
/// `method` of `cit.properties`: how several enchantment layers on one item share it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Method {
/// Every layer at full strength, together (CIT Resewn's `none`).
None,
/// Each layer's intensity is its enchantment's level over the sum of levels.
Average,
/// Over the highest level.
Layered,
/// One layer at a time, each for its `duration`.
Cycle,
}
/// The pack's `cit.properties`.
#[derive(Clone, Debug, PartialEq)]
pub struct Global {
pub method: Method,
/// At most this many enchantment layers per item (the topmost win).
pub cap: Option<u32>,
/// Seconds a cycle takes to fade from one layer to the next.
pub fade: f32,
/// Whether the default glint draws on items no enchantment rule matches.
pub use_glint: bool,
}
impl Default for Global {
fn default() -> Self {
Self {
method: Method::Average,
cap: None,
fade: 0.5,
use_glint: true,
}
}
}
impl Global {
/// Where the global file may be, the first that exists winning, as both docs say (CIT
/// Resewn's code merges the files' keys instead): CIT Resewn's and the OptiFine doc's folders.
pub const PATHS: [&str; 4] = [
"assets/minecraft/citresewn/cit.properties",
"assets/minecraft/optifine/cit.properties",
"assets/minecraft/optifine/cit/cit.properties",
"assets/minecraft/mcpatcher/cit.properties",
];
/// Unknown or malformed values keep their defaults.
pub fn parse(props: &Properties) -> Self {
let mut out = Self::default();
let lower = |key| props.get(key).map(|v| v.trim().to_ascii_lowercase());
match lower("method").as_deref() {
Some("layered") => out.method = Method::Layered,
Some("cycle") => out.method = Method::Cycle,
Some("none") => out.method = Method::None,
_ => {}
}
out.cap = props.get("cap").and_then(|v| v.trim().parse().ok());
if let Some(fade) = props.get("fade").and_then(finite) {
out.fade = fade.max(0.0);
}
out.use_glint = lower("useGlint").as_deref() != Some("false");
out
}
}
/// An optional key, under any of its names (CIT Resewn's aliases): `Some(None)` when absent,
/// `None` (dropping the rule) when malformed.
fn optional<T>(
@@ -761,6 +1022,25 @@ mod tests {
assert_eq!(elytra.items, ["minecraft:elytra"]);
}
#[test]
fn an_enchantment_rule_needs_a_texture() {
let dir = "assets/minecraft/optifine/cit/glint";
let files = [
format!("{dir}/shine.json"),
format!("{dir}/shine.png"),
format!("{dir}/other.png"),
];
let refs: Vec<&str> = files.iter().map(String::as_str).collect();
let path = format!("{dir}/shine.properties");
// The model is there: an item rule takes it.
assert!(rule(&path, "items=stick\nmodel=shine", &refs).is_some());
assert!(rule(&path, "type=enchantment\nmodel=shine", &refs).is_none());
assert!(rule(&path, "type=enchantment\ntexture.a=other", &refs).is_none());
// The texture beside the file still counts.
let beside = rule(&path, "type=enchantment", &refs).unwrap();
assert_eq!(beside.texture.as_deref(), Some(&*files[1]));
}
#[test]
fn legacy_nbt_paths_land_on_components() {
let source = |key: &str| {
@@ -954,4 +1234,129 @@ mod tests {
.collect();
assert_eq!(order, ["c.properties", "a.properties", "b.properties"]);
}
#[test]
fn enchantment_layers() {
let files = ["assets/minecraft/optifine/cit/swords/flame.png"];
let r = rule(
SWORD,
"type=enchantment\ntexture=flame\nlayer=2\nblend=GL_DST_COLOR GL_ONE_MINUS_SRC_ALPHA\n\
speed=0.5\nrotation=45\nduration=2\nr=0.5\ng=-1\n",
&files,
)
.unwrap();
assert_eq!(r.glint.layer, 2);
assert_eq!(r.glint.blend, parse_blend("multiply").unwrap());
assert_eq!(
(r.glint.speed, r.glint.rotation, r.glint.duration),
(0.5, 45.0, 2.0)
);
assert_eq!(
r.glint.rgba,
[0.5, 0.0, 1.0, 1.0],
"colours never go below 0"
);
assert_eq!(parse_blend("replace"), Some(None));
let custom = parse_blend("0x306 771 0 1").unwrap().unwrap();
assert_eq!(Some(Some(custom)), parse_blend("multiply"));
assert_eq!(parse_blend("GL_NOPE GL_ONE"), None);
// OptiFine's defaults: still, unturned, a second per cycle, added on top.
let plain = rule(SWORD, "type=enchantment\ntexture=flame", &files).unwrap();
assert_eq!(
(plain.glint.layer, plain.glint.speed, plain.glint.rotation),
(0, 0.0, 0.0)
);
assert_eq!(plain.glint.duration, 1.0);
assert_eq!(
plain.glint.blend,
parse_blend("GL_SRC_ALPHA GL_ONE").unwrap()
);
assert!(!plain.glint.use_glint && plain.glint.blur);
assert!(rule(SWORD, "type=enchantment\ntexture=flame\nspeed=fast", &files).is_none());
assert!(rule(SWORD, "type=enchantment\ntexture=flame\nblend=mix", &files).is_none());
// NaN and the infinities are malformed too.
for bad in ["speed=NaN", "r=inf", "duration=-Infinity", "a=nan"] {
let text = format!("type=enchantment\ntexture=flame\n{bad}");
assert!(rule(SWORD, &text, &files).is_none(), "{bad}");
}
}
#[test]
fn glint_keys_are_only_an_enchantment_rules() {
let files = ["assets/minecraft/optifine/cit/swords/flame.png"];
let r = rule(
SWORD,
"items=diamond_sword\ntexture=flame\nblend=nonsense\nspeed=fast\nr=nan",
&files,
)
.expect("an item rule ignores the glint keys");
assert_eq!(r.glint, Glint::default());
}
#[test]
fn named_blends() {
use Factor::*;
let pair = |src, dst| Blend {
src,
dst,
src_alpha: Zero,
dst_alpha: One,
};
// CIT Resewn's doc's table.
let table = [
("glint", pair(SrcColor, One)),
("alpha", pair(SrcAlpha, OneMinusSrcAlpha)),
("add", pair(SrcAlpha, One)),
("subtract", pair(OneMinusDstColor, Zero)),
("multiply", pair(DstColor, OneMinusSrcAlpha)),
("dodge", pair(One, One)),
("burn", pair(Zero, OneMinusSrcColor)),
("screen", pair(One, OneMinusSrcColor)),
("overlay", pair(DstColor, SrcColor)),
];
for (name, blend) in table {
assert_eq!(parse_blend(name), Some(Some(blend)), "{name}");
}
assert_eq!(parse_blend("replace"), Some(None));
// Names are case-blind (CIT Resewn upper-cases them); GL constants are not (it looks
// them up as GL11's field names).
assert_eq!(parse_blend("Multiply"), parse_blend("multiply"));
assert_eq!(parse_blend("gl_one GL_ONE"), None);
}
#[test]
fn global_properties() {
let g = Global::parse(&Properties::parse(
"method=cycle\ncap=2\nfade=1.5\nuseGlint=false",
));
assert_eq!(
g,
Global {
method: Method::Cycle,
cap: Some(2),
fade: 1.5,
use_glint: false
}
);
assert_eq!(
Global::parse(&Properties::parse("method=odd\ncap=x\nfade=-1")),
Global {
fade: 0.0,
..Global::default()
}
);
assert_eq!(
Global::parse(&Properties::parse("method=Layered\nuseGlint=FALSE")).method,
Method::Layered
);
assert!(!Global::parse(&Properties::parse("useGlint=FALSE")).use_glint);
assert_eq!(
Global::parse(&Properties::parse("method=NONE")).method,
Method::None
);
for fade in ["nan", "INF", "-infinity"] {
let g = Global::parse(&Properties::parse(&format!("fade={fade}")));
assert_eq!(g.fade, 0.5, "{fade}");
}
}
}
+416
View File
@@ -0,0 +1,416 @@
//! Typed native-mod data attached to server-owned holders.
use std::{any::Any, collections::BTreeMap, fmt};
use crate::nbt::{Compound, JavaString, Tag};
/// An attachment operation could not be completed.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum AttachmentError {
/// The holder does not exist or is unavailable on this side.
MissingHolder,
/// The key was used with a different Rust type.
TypeMismatch,
/// The saved value could not be decoded.
Decode,
/// The value could not be encoded.
Encode,
/// A namespace in the saved compound is not a compound.
InvalidNamespace,
/// Copy-on-death requires a serializer.
InvalidCopyPolicy,
}
/// NBT key reserved for server-owned attachment maps.
pub const ATTACHMENTS_KEY: &str = "horizon:attachments";
/// Read the reserved compound without replacing an invalid saved value.
pub fn attachment_compound(tag: Option<&Tag>) -> Result<Option<&Compound>, AttachmentError> {
match tag {
None => Ok(None),
Some(Tag::Compound(compound)) => Ok(Some(compound)),
Some(_) => Err(AttachmentError::InvalidNamespace),
}
}
/// A server holder in the host's current dimension.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum Holder {
Entity(u32),
Player(u128),
BlockEntity([i32; 3]),
Chunk([i32; 2]),
Level,
}
/// Object-safe access to the current server's holders.
pub trait AttachmentHost {
fn attachments(&mut self, holder: Holder) -> Result<&mut Attachments, AttachmentError>;
}
/// The optional on-disk representation of an attachment.
pub struct AttachmentCodec<T> {
pub encode: fn(&T) -> Result<Compound, AttachmentError>,
pub decode: fn(&Compound) -> Result<T, AttachmentError>,
}
/// A mod-defined attachment key and its value's behavior.
pub struct AttachmentType<T: 'static> {
pub namespace: &'static str,
pub name: &'static str,
pub default: fn() -> T,
pub codec: Option<AttachmentCodec<T>>,
pub copy_on_death: bool,
}
impl<T: Clone + Send + Sync + 'static> AttachmentType<T> {
/// Read a value, inserting its default if absent.
pub fn get<H: AttachmentHost + ?Sized>(
&self,
host: &mut H,
holder: Holder,
) -> Result<T, AttachmentError> {
host.attachments(holder)?.get(self)
}
/// Replace a value on an existing holder.
pub fn set<H: AttachmentHost + ?Sized>(
&self,
host: &mut H,
holder: Holder,
value: T,
) -> Result<(), AttachmentError> {
host.attachments(holder)?.set(self, value)
}
/// Remove a value from an existing holder.
pub fn remove<H: AttachmentHost + ?Sized>(
&self,
host: &mut H,
holder: Holder,
) -> Result<Option<T>, AttachmentError> {
host.attachments(holder)?.remove(self)
}
}
trait Value: Send + Sync {
fn as_any(&self) -> &dyn Any;
fn clone_box(&self) -> Box<dyn Value>;
fn encoded(&self) -> Option<&Compound>;
fn copy_on_death(&self) -> bool;
}
struct TypedValue<T> {
value: T,
encoded: Option<Compound>,
copy_on_death: bool,
}
impl<T: Clone + Send + Sync + 'static> Value for TypedValue<T> {
fn as_any(&self) -> &dyn Any {
&self.value
}
fn clone_box(&self) -> Box<dyn Value> {
Box::new(Self {
value: self.value.clone(),
encoded: self.encoded.clone(),
copy_on_death: self.copy_on_death,
})
}
fn encoded(&self) -> Option<&Compound> {
self.encoded.as_ref()
}
fn copy_on_death(&self) -> bool {
self.copy_on_death
}
}
enum Entry {
Raw(Compound),
Decoded(Box<dyn Value>),
}
impl Clone for Entry {
fn clone(&self) -> Self {
match self {
Self::Raw(value) => Self::Raw(value.clone()),
Self::Decoded(value) => Self::Decoded(value.clone_box()),
}
}
}
/// The data held by one entity, block entity, chunk or level.
#[derive(Clone, Default)]
pub struct Attachments {
entries: BTreeMap<(JavaString, JavaString), Entry>,
// Keep malformed or otherwise unrecognized NBT exactly as it was read.
raw: Compound,
}
impl fmt::Debug for Attachments {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Attachments")
.field("keys", &self.entries.keys())
.finish()
}
}
impl Attachments {
/// Read the namespaced attachment compound, leaving unknown data intact.
pub fn from_nbt(raw: Compound) -> Self {
let mut result = Self {
entries: BTreeMap::new(),
raw,
};
for (namespace, tag) in &result.raw {
if let Tag::Compound(names) = tag {
for (name, value) in names {
if let Tag::Compound(value) = value {
result
.entries
.insert((namespace.clone(), name.clone()), Entry::Raw(value.clone()));
}
}
}
}
result
}
/// Encode changed values, preserving raw entries and unrecognized tags.
pub fn to_nbt(&self) -> Compound {
let mut raw = self.raw.clone();
for ((namespace, name), entry) in &self.entries {
let value = match entry {
Entry::Raw(value) => Some(value.clone()),
Entry::Decoded(value) => value.encoded().map(|encoded| {
let mut envelope = Compound::new();
envelope.insert("value".into(), Tag::Compound(encoded.clone()));
if value.copy_on_death() {
envelope.insert("copy_on_death".into(), Tag::Byte(1));
}
envelope
}),
};
if let Some(value) = value {
let tag = raw
.entry(namespace.clone())
.or_insert_with(|| Tag::Compound(Compound::new()));
let Tag::Compound(names) = tag else {
unreachable!("attachment namespace checked before mutation");
};
names.insert(name.clone(), Tag::Compound(value));
}
}
raw
}
/// Copy opted-in values through their disk encoding for a player respawn.
pub fn copy_on_death(&self) -> Result<Self, AttachmentError> {
let mut copy = Self::default();
for (key, entry) in &self.entries {
let encoded = match entry {
Entry::Raw(raw)
if matches!(raw.get(&"copy_on_death".into()), Some(Tag::Byte(1))) =>
{
Some(raw.clone())
}
Entry::Decoded(value) if value.copy_on_death() => {
let value = value
.encoded()
.ok_or(AttachmentError::InvalidCopyPolicy)?
.clone();
Some(
[
("value".into(), Tag::Compound(value)),
("copy_on_death".into(), Tag::Byte(1)),
]
.into(),
)
}
_ => None,
};
if let Some(encoded) = encoded {
copy.entries.insert(key.clone(), Entry::Raw(encoded));
}
}
Ok(copy)
}
/// Get an existing value or insert its default; saved values decode lazily.
pub fn get<T: Clone + Send + Sync + 'static>(
&mut self,
ty: &AttachmentType<T>,
) -> Result<T, AttachmentError> {
if ty.copy_on_death && ty.codec.is_none() {
return Err(AttachmentError::InvalidCopyPolicy);
}
if self
.raw
.get(&ty.namespace.into())
.is_some_and(|tag| !matches!(tag, Tag::Compound(_)))
{
return Err(AttachmentError::InvalidNamespace);
}
let key = (ty.namespace.into(), ty.name.into());
let value = match self.entries.get(&key) {
Some(Entry::Raw(raw)) => {
let Some(Tag::Compound(value)) = raw.get(&"value".into()) else {
return Err(AttachmentError::Decode);
};
(ty.codec.as_ref().ok_or(AttachmentError::Decode)?.decode)(value)?
}
Some(Entry::Decoded(value)) => {
return value
.as_any()
.downcast_ref::<T>()
.cloned()
.ok_or(AttachmentError::TypeMismatch);
}
None => (ty.default)(),
};
let encoded = ty
.codec
.as_ref()
.map(|codec| (codec.encode)(&value))
.transpose()?;
self.entries.insert(
key,
Entry::Decoded(Box::new(TypedValue {
value: value.clone(),
encoded,
copy_on_death: ty.copy_on_death,
})),
);
Ok(value)
}
/// Replace a value. A key already decoded as a different type is rejected.
pub fn set<T: Clone + Send + Sync + 'static>(
&mut self,
ty: &AttachmentType<T>,
value: T,
) -> Result<(), AttachmentError> {
if ty.copy_on_death && ty.codec.is_none() {
return Err(AttachmentError::InvalidCopyPolicy);
}
if self
.raw
.get(&ty.namespace.into())
.is_some_and(|tag| !matches!(tag, Tag::Compound(_)))
{
return Err(AttachmentError::InvalidNamespace);
}
let key = (ty.namespace.into(), ty.name.into());
if let Some(Entry::Decoded(old)) = self.entries.get(&key)
&& !old.as_any().is::<T>()
{
return Err(AttachmentError::TypeMismatch);
}
let encoded = ty
.codec
.as_ref()
.map(|codec| (codec.encode)(&value))
.transpose()?;
self.entries.insert(
key,
Entry::Decoded(Box::new(TypedValue {
value,
encoded,
copy_on_death: ty.copy_on_death,
})),
);
if let Some(Tag::Compound(names)) = self.raw.get_mut(&ty.namespace.into()) {
names.remove(&ty.name.into());
}
Ok(())
}
/// Remove a value, retaining all other namespaces and names. A saved value
/// that does not decode is removed too, and its error returned.
pub fn remove<T: Clone + Send + Sync + 'static>(
&mut self,
ty: &AttachmentType<T>,
) -> Result<Option<T>, AttachmentError> {
let key = (ty.namespace.into(), ty.name.into());
if !self.entries.contains_key(&key) {
return Ok(None);
}
let value = self.get(ty);
self.entries.remove(&key);
if let Some(Tag::Compound(names)) = self.raw.get_mut(&ty.namespace.into()) {
names.remove(&ty.name.into());
}
value.map(Some)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn encode(n: &i32) -> Result<Compound, AttachmentError> {
Ok([(JavaString::from("value"), Tag::Int(*n))].into())
}
fn decode(c: &Compound) -> Result<i32, AttachmentError> {
match c.get(&"value".into()) {
Some(Tag::Int(n)) => Ok(*n),
_ => Err(AttachmentError::Decode),
}
}
static COUNTER: AttachmentType<i32> = AttachmentType {
namespace: "test",
name: "counter",
default: || 0,
codec: Some(AttachmentCodec { encode, decode }),
copy_on_death: false,
};
static MEMORY: AttachmentType<i32> = AttachmentType {
namespace: "test",
name: "memory",
default: || 3,
codec: None,
copy_on_death: false,
};
#[test]
fn saved_values_round_trip_and_memory_values_do_not() {
let mut map = Attachments::default();
map.set(&COUNTER, 42).unwrap();
map.set(&MEMORY, 8).unwrap();
let mut loaded = Attachments::from_nbt(map.to_nbt());
assert_eq!(loaded.get(&COUNTER), Ok(42));
assert_eq!(loaded.get(&MEMORY), Ok(3));
assert_eq!(loaded.remove(&COUNTER), Ok(Some(42)));
let mut reopened = Attachments::from_nbt(loaded.to_nbt());
assert_eq!(reopened.get(&COUNTER), Ok(0));
}
#[test]
fn untouched_saved_value_copies_on_respawn() {
let keep = AttachmentType {
namespace: "test",
name: "keep",
default: || 0,
codec: Some(AttachmentCodec { encode, decode }),
copy_on_death: true,
};
let mut original = Attachments::default();
original.set(&keep, 42).unwrap();
original.set(&COUNTER, 5).unwrap();
let loaded = Attachments::from_nbt(original.to_nbt());
let mut respawned = loaded.copy_on_death().unwrap();
assert_eq!(respawned.get(&keep), Ok(42));
assert_eq!(respawned.get(&COUNTER), Ok(0));
}
#[test]
fn unknown_and_invalid_values_survive_a_failed_decode() {
let saved: Compound = [(
JavaString::from("test"),
Tag::Compound(
[
(
JavaString::from("counter"),
Tag::Compound(
[(JavaString::from("value"), Tag::String("bad".into()))].into(),
),
),
(JavaString(vec![0xd800]), Tag::Compound(Compound::new())),
]
.into(),
),
)]
.into();
let mut map = Attachments::from_nbt(saved.clone());
assert_eq!(map.get(&COUNTER), Err(AttachmentError::Decode));
assert_eq!(map.to_nbt(), saved);
assert_eq!(map.remove(&COUNTER), Err(AttachmentError::Decode));
assert_eq!(map.remove(&COUNTER), Ok(None), "removed all the same");
}
}
+1 -1
View File
@@ -21,7 +21,7 @@ use std::{collections::BTreeMap, fs, io, path::Path};
/// 79.
#[inline(never)]
pub fn native_modify_default_components(item: &str, components: &mut Map<String, Value>) {
let _ = (item, components);
crate::native_load_hooks::modify_default_components(item, components);
}
/// Every item's default components at `root`, by item id, with the mods'
+2
View File
@@ -3,6 +3,7 @@
pub mod active_effect_tick;
pub mod active_effects;
pub mod attachments;
pub mod attribute_health;
pub mod attribute_map;
pub mod attribute_updates;
@@ -94,6 +95,7 @@ pub mod movement_properties;
pub mod movement_restitution;
pub mod movement_statistics;
pub mod movement_support;
pub mod native_load_hooks;
pub mod nbt;
pub mod object_open_map;
pub mod object_open_set;
+1 -1
View File
@@ -587,7 +587,7 @@ pub fn shared_tables(root: &Path, overlay: &PackOverlay) -> io::Result<Arc<LootT
/// 75.
#[inline(never)]
pub fn native_loot_table_load(id: &str, table: &mut Value) {
let _ = (id, table);
crate::native_load_hooks::loot_table_load(id, table);
}
fn load_kind(data: &Path, kind: &str, out: &mut HashMap<String, Value>) -> io::Result<()> {
@@ -0,0 +1,28 @@
//! Callbacks into the executable for data-loading native trait mods.
use serde_json::{Map, Value};
use std::sync::OnceLock;
/// Data-loading hooks, installed before any data loads. Earlier loads see no mods.
pub struct NativeLoadHooks {
pub loot_table_load: fn(&str, &mut Value),
pub modify_default_components: fn(&str, &mut Map<String, Value>),
}
static HOOKS: OnceLock<NativeLoadHooks> = OnceLock::new();
/// Install the process-wide callbacks once; a second installation keeps the first.
pub fn install(hooks: NativeLoadHooks) -> Result<(), NativeLoadHooks> {
HOOKS.set(hooks)
}
pub(crate) fn loot_table_load(id: &str, table: &mut Value) {
if let Some(hooks) = HOOKS.get() {
(hooks.loot_table_load)(id, table);
}
}
pub(crate) fn modify_default_components(item: &str, components: &mut Map<String, Value>) {
if let Some(hooks) = HOOKS.get() {
(hooks.modify_default_components)(item, components);
}
}
@@ -0,0 +1,34 @@
use horizon_world::{
item_defaults, loot,
native_load_hooks::{self, NativeLoadHooks},
};
use serde_json::{Map, Value, json};
#[test]
fn load_hooks_are_noops_before_install_and_use_first_callbacks_after() {
let mut table = json!([]);
let mut components = Map::new();
loot::native_loot_table_load("table", &mut table);
item_defaults::native_modify_default_components("item", &mut components);
assert_eq!(table, json!([]));
assert!(components.is_empty());
let hooks = || NativeLoadHooks {
loot_table_load: |id, table| table.as_array_mut().unwrap().push(id.into()),
modify_default_components: |item, components| {
components.insert("item".into(), Value::String(item.into()));
},
};
assert!(native_load_hooks::install(hooks()).is_ok());
assert!(
native_load_hooks::install(NativeLoadHooks {
loot_table_load: |_, table| *table = json!("second"),
modify_default_components: |_, components| components.clear(),
})
.is_err()
);
loot::native_loot_table_load("table", &mut table);
item_defaults::native_modify_default_components("item", &mut components);
assert_eq!(table, json!(["table"]));
assert_eq!(components["item"], json!("item"));
}
+2 -2
View File
@@ -10,8 +10,8 @@ and worlds. Each entry names its version field, its documentation and its level
| Benchmark report | the benchmark's output, the local log, uploads | `"format": "horizon-benchmark-report"`, `"version": 1` | stable | [benchmark-report.md](../benchmark-report.md) |
| Telemetry batch | `POST /v1/batch` from the launcher | `"schema": 1` | stable | [telemetry.md](../telemetry.md) |
| Telemetry consent | `telemetry/consent.json` in the game folder | `"version": 2` | stable | [telemetry.md](../telemetry.md) |
| Content mod package | `.hmod`: a zip with `horizon-mod.toml`, `assets/`, `data/` | the manifest's `api` | preview | [mods.md](../mods.md#the-manifest), [mod-index.md](mod-index.md) |
| Native mod package | `.hnmod`: a zip with `horizon-native.json` and the bundle's `.rs` and `.toml` files | `schema` | experimental | [native-mods.md](../native-mods.md) |
| Content mod package | `.hmod`: a zip with `horizon-mod.toml`, `assets/`, `data/` | the manifest's `api` | preview | [mod-files.md](mod-files.md), [mods.md](../mods.md#the-manifest), [mod-index.md](mod-index.md) |
| Native mod package | `.hnmod`, or a `.hmod` with native code: a zip with `horizon-native.json` and the bundle's `.rs` and `.toml` files | `schema` | experimental | [mod-files.md](mod-files.md), [native-mods.md](../native-mods.md) |
| Mods turned on | `horizon-mods.txt` | none | preview | [mods.md](../mods.md#playing-with-mods) |
| Horizon settings | `horizon-graphics.txt`, `horizon-extras.txt` (`key:value`, as `options.txt`) | none: unknown keys fall back to defaults | preview | [graphics.md](../graphics.md), [extras.md](../extras.md#options-storage) |
| Server settings | `server.toml` | its keys | stable | [server-reference.md](server-reference.md) |
+70
View File
@@ -0,0 +1,70 @@
# Mod files
The rules a `.hmod` or `.hnmod` follows, in one place, for tools that make or read them. The readers are
`horizon_mods::Package` and `horizon_mods::Content` (content), and `horizon_mods::index::native::Bundle` (native
code). Level: content is preview, native code experimental ([data-formats.md](data-formats.md)).
## The zip
- A zip archive. Entries are stored or deflate-compressed, the only methods the readers support.
- A path is relative and `/`-separated: no `\`, no `:`, no empty, `.` or `..` part, and it doesn't start with `/`.
Directory entries are skipped. A native entry must be a regular file (no symlinks or devices in the zip's Unix mode).
- Each path appears once.
## Top-level entries
What a file holds decides what it is (#5865):
| Entry | Means | Read by |
| --- | --- | --- |
| `horizon-mod.toml` | a content mod's manifest ([mods.md](../mods.md#the-manifest)) | `Package`, `Content` |
| `assets/…`, `data/…` | the content mod's resources and data | `Content` |
| `horizon-native.json` | native code: the manifest below | `Bundle` |
| `mixins/<id>/…` | the native bundle's sources ([native-mods.md](../native-mods.md#a-bundle)) | `Bundle` |
| `mod.wasm` | a legacy WebAssembly mod: it doesn't run any more | refused in a file with native code |
- **Content only:** `horizon-mod.toml` with `assets/` and `data/`. Nothing compiles.
- **Native code:** `horizon-native.json` and `mixins/<id>/`. The launcher builds it into the game.
- **Both, in one `.hmod`:** the two manifests name the same mod id. Content entries are checked as content and left
out of the build. Any other top-level entry refuses the file.
- **`.hnmod`:** native code only, read exactly as before.
The registry still publishes native mods as `.hnmod`. It switches to `.hmod` once launchers and the game read native
`.hmod`s, so no launcher in use gets a file it can't read.
## `horizon-native.json`
A JSON object. Unknown fields refuse the file.
| Field | Type | Rule |
| --- | --- | --- |
| `schema` | number | `1` |
| `id` | string | the mod id, `[a-z0-9_]{1,64}`, the same as `mixins/<id>/` and its `horizon-mixin.toml` `[mod] id` |
| `version` | string | not empty, the same as `horizon-mixin.toml` `[mod] version` |
| `game` | string | not empty, the Minecraft version (`"26.3"`) |
| `horizon` | string | the exact Horizon commit, 40 lowercase hex characters, the same as `[compatibility] horizon_revision` |
| `mixins` | string | the exact horizon-mixins commit, 40 lowercase hex characters |
| `hooks` | number, optional | the native hook points' version it needs, at least `1` ([native-mods.md](../native-mods.md#mods-on-the-hook-points)) |
## Under `mixins/<id>/`
`mixins/<id>/horizon-mixin.toml` must be there. Besides it, only:
- `.rs` and `.toml` files;
- files under `data/`, or under the `data/` of a top-level folder (`engine/data/`);
- `README*`, `LICENSE*`, `LICENCE*`, `COPYING*`, `NOTICE*` and `.gitignore`, in the bundle's folder or a top-level
folder.
## Content paths
`assets/` or `data/`, then at least two parts of `[a-z0-9_.-]` (vanilla's resource-location characters).
## Limits
| What | Limit |
| --- | --- |
| A registry file, as downloaded | 64 MiB |
| Native files (everything but content entries) | 512 entries, 64 MiB unpacked in total |
| Content files | 65,536 files, 16 MiB each, 512 MiB in total |
| `horizon-mod.toml` inside a native file | 1 MiB |
| `mod.wasm` (legacy) | 32 MiB |
+1 -1
View File
@@ -35,7 +35,7 @@ Every row has a one-line description and its cost in frame time (none, low, medi
**Translucent batching** (Performance, after Graphics API; on by default, `batchTranslucent` in `horizon-graphics.txt`, a new key so that the `translucentBatching:false` earlier builds saved for everyone doesn't keep it off; it applies at the next start or resource reload, and is no part of a preset) gives water, glass, ice and other translucent quads a vertex buffer of their own, with pages up to 2 GiB, and their sorted indices the same: the back-to-front list then stays in one page and goes out in a few draw calls instead of switching pages every few sections. The picture is the same and frames come faster, most over water and at high render distances (RTX 4090 at 1080p: 4 to 16% more FPS at render distance 32; over the ocean at 64, 354 draw calls a frame instead of 3,832 and 43% more FPS); it costs a little video memory, as solid and translucent quads no longer share free space (terrain meshes take 1 to 15% more at render distance 32, 5 to 10% more at 64). The large pages shrink once the view has settled (a second after terrain stops arriving): one much larger than what it holds, as after a teleport or a lower render distance, moves into a page of the right size, about 32 MiB a frame. It needs indirect multi-draw that isn't OpenGL's (which loops over the entries on the CPU), and devices whose vanilla frames draw flat (Metal, Intel Gen11 and Gen12 on Vulkan) don't offer it: there the switch is greyed out and reads Off, and macOS doesn't show it. A `shaders/` pack's terrain and shadow passes, and the `[shadow]` pass of one of Horizon's own packs, draw flat where the device can, reading translucent quads beside their section's solid ones: while such a pack runs they go back there. Such a pack picked at the start has the world laid out for it from the first; switching to or from one later meshes the world again.
**HDR** (off by default) lives on its own screen, Graphics: HDR, behind **HDR Settings...** under Display, which is greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); there the switch comes first and the sliders below it only while it is on. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), asked and then watched while HDR output is on (with HDR off the game doesn't ask), so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; these often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Brightness and Paper White full screen, one test picture at a time, drawn by the last pass straight in nits above a band of controls: Horizon's logo at the slider on black, lowered until it disappears; the logo at the slider inside a tenth of the screen at 10 000 nits (small, so the display doesn't dim), raised until it disappears; a white card, set to a white that's comfortable in the light the player plays in. Save writes the three, Cancel or Escape keeps what was set, and a step left untouched keeps Auto. The Peak step needs a path that doesn't tone-map: on KWin the logo never disappears, so where the system reports its values the button reads **Calibrate Anyway...** and says the display is already set up (**Use Automatic Setup** once they were changed by hand). Where nothing is reported, Auto is 203 and 1000 nits, and the Graphics: HDR screen asks the player to calibrate. **Black Level** (0 by default, for an OLED's true black) is the display's darkest black, in nits: the last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ, which leaves the peak where it is and fades out by the fourth power above black, and the metadata gives it as the minimum luminance; at 0 the picture is unchanged. **UI Brightness** (100% by default) sets the HUD and menus against paper white: once the world is kept for the last pass, the frame's alpha is zeroed, everything drawn after raises it where it covers the world, the first-person hand's is then scaled down to 1/255 (it stays at the world's brightness), and the last pass scales the rest by UI Brightness and lifts none of it (so text over the sun stays text). With HDR on, F2 writes vanilla's screenshot as always and an HDR10 copy beside it (`…-hdr.png`): the HDR pass drawn once more into a 10-bit PQ target, whatever the swapchain is, saved as a 16-bit PNG tagged BT.2020 with PQ (`cICP`, PNG's third edition) and the peak and paper white as content light levels (`cLLI`); viewers that read `cICP` show it in HDR, and others that honour its ICC profile (reference white at the profile's white) a correct SDR picture with clipped highlights. Glowing items shine by their block's tier when dropped, framed, or held by anyone, your own hand included; the hand itself stays at the world's brightness. Not lifted yet: translucent blocks (the nether portal), distant LOD terrain.
**HDR** (off by default) lives on its own screen, Graphics: HDR, behind **HDR Settings...** under Display, which is greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); there the switch comes first and the sliders below it only while it is on. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), asked and then watched while HDR output is on (with HDR off the game doesn't ask), so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; these often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Brightness and Paper White full screen, one test picture at a time, drawn by the last pass straight in nits above a band of controls: Horizon's logo at the slider on black, lowered until it disappears; the logo at the slider inside a tenth of the screen at 10 000 nits (small, so the display doesn't dim), raised until it disappears; a white card, set to a white that's comfortable in the light the player plays in. Save writes the three, Cancel or Escape keeps what was set, and a step left untouched keeps Auto. The Peak step needs a path that doesn't tone-map: on KWin the logo never disappears, so where the system reports its values the button reads **Calibrate Anyway...** and says the display is already set up (**Use Automatic Setup** once they were changed by hand). Where nothing is reported, Auto is 203 and 1000 nits, and the Graphics: HDR screen asks the player to calibrate. **Black Level** (0 is Auto: what the system reports, else an OLED's true black) is the display's darkest black, in nits: the last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ, which leaves the peak where it is and fades out by the fourth power above black, and the metadata gives it as the minimum luminance; at 0 the picture is unchanged. **UI Brightness** (100% by default) sets the HUD and menus against paper white: once the world is kept for the last pass, the frame's alpha is zeroed, everything drawn after raises it where it covers the world, the first-person hand's is then scaled down to 1/255 (it stays at the world's brightness), and the last pass scales the rest by UI Brightness and lifts none of it (so text over the sun stays text). With HDR on, F2 writes vanilla's screenshot as always and an HDR10 copy beside it (`…-hdr.png`): the HDR pass drawn once more into a 10-bit PQ target, whatever the swapchain is, saved as a 16-bit PNG tagged BT.2020 with PQ (`cICP`, PNG's third edition) and the peak and paper white as content light levels (`cLLI`); viewers that read `cICP` show it in HDR, and others that honour its ICC profile (reference white at the profile's white) a correct SDR picture with clipped highlights. Glowing items shine by their block's tier when dropped, framed, or held by anyone, your own hand included; the hand itself stays at the world's brightness. Not lifted yet: translucent blocks (the nether portal), distant LOD terrain. The screen shows these as plain numbers: they are nits onlLine truncated
HDR's light sources shine by the kind of light rather than its level (`mesher::hdr_tier`): the sun most, then glowstone, campfires and the soul fires, then flames and lava, torch and candle flames among them (particles too), then faint glowers such as a redstone torch or magma; glow lichen stays as in SDR. Each tier has its share of the headroom and the brightness its lift starts and ends at, the texel's own before lighting, so a light source shines alike at any light level and on every face (`gfx/hdr.rs` `TIERS`); `HORIZON_HDR_DEBUG=1` greys what isn't lifted and tints what is magenta.
+1
View File
@@ -89,6 +89,7 @@ Native mods (docs/native-mods.md): compile mixin bundles on the Horizon revision
horizon-launcher native-build [FOLDER]
- `FOLDER`: A mod folder (horizon-mixin.toml, or an unpacked .hnmod), or a folder of them (default: the native mods installed and turned on in the instance)
- `--server-output`: Export mode: build a standalone dedicated-server engine into this folder instead of an instance's build. Needs a mod FOLDER; no instance is read or changed, and no launcher config is written
- `--instance`: The instance whose installed native mods to build, and which then plays the build (default: the selected one)
- `--update`: Move to the newest installed release or Nightly instead of the Horizon the instance plays (a full rebuild)
- `--trust-native-code`: You read the mods' source and trust it with full access to this computer
+203 -45
View File
@@ -330,8 +330,9 @@ when it is called, what it does): `{"name": "hud", "version": 1, "target": "hori
An injection names `hook` and `hook_version` instead of `target`, and horizon-mixins resolves it from the catalog
of the source it applies to, so a hook point can move in the source without breaking the mods that use it. A name
the catalog lacks is `E_HOOK_UNKNOWN` (the error lists the names it has), a version it lacks `E_HOOK_VERSION`. A
contract is never changed: an incompatible change adds a version. Helpers (`native_text`, `native_player`…) are not
in the catalog; they are kept stable as the hook points are. A test (`catalog_matches_hook_points`) keeps the
contract is never changed, its `mod_api` entry (the trait method's contract) included: an incompatible change adds
a version. Helpers (`native_text`, `native_player`…) have no hook entry; the catalog's `helpers` list names the ones
the generated `Host` offers, and they are kept stable as the hook points are. A test (`catalog_matches_hook_points`) keeps the
catalog and the hook points' sources (`native_hooks.rs`, `gfx/native_render.rs`, `horizon-world`'s `loot.rs`) in step.
### The mod's crate
@@ -453,6 +454,16 @@ text files a crate folder carries (`README*`, `LICENSE*`, `COPYING*`, `NOTICE*`,
folder or a crate's), so a crate that compiles its README in builds the same from the registry as from a folder. At
most 512 files and 64 MiB unpacked; anything else refuses the bundle. A local folder build has no such limits.
**One `.hmod` for content and code.** A mod's content and its native code ship as one `.hmod`, and what is inside
decides what happens: only `horizon-mod.toml` with `assets/` and `data/` is a content mod, nothing compiles; with
`horizon-native.json` and `mixins/<id>/` (a bundle as above) it is a native mod, built into the game. Both may be in
one file: `horizon-mod.toml`, `assets/` and `data/` are its content, checked as a content mod's
(`horizon_mods::Content`) and left out of the build, and the two manifests name the same mod id. A `.hmod` with
native code is never a WebAssembly mod: one carrying `mod.wasm` is refused with
`horizon_mods::index::native::WASM_GONE`. An `.hnmod` (code only) keeps loading as before. The registry keeps
publishing native mods as `.hnmod` until launchers and the game read native `.hmod`s. Every rule of the file is in
[api/mod-files.md](api/mod-files.md).
### Post-build steps
A `[[post_build]]` names a bin of one of the mod's crates. After the game compiles, the launcher builds it with the
@@ -1031,7 +1042,9 @@ every bundle. The same set on the same commit is never built twice. A change giv
`native/target/` stays, so a rebuild compiles only `horizon-client` and what the mods touched.
**Incremental rebuilds.** With `fast`, Cargo recompiles only the changed parts of `horizon-client`, not the whole
crate with LTO. This holds because the stage (`native/stage/`) is one folder for every build and the launcher writes
crate with LTO. Patched source is cached separately by its mixin inputs and pins (the three most
recent snapshots are kept), so editing only a trait crate skips the mixins pass. This holds because
the stage (`native/stage/`) is one folder for every build and the launcher writes
only the files whose content changed (Cargo goes by file times), switching mods on the same commit reuses the same
`native/target/`, and the launcher removes `CARGO_INCREMENTAL` and `CARGO_BUILD_INCREMENTAL` from the build's
environment. `release` has no incremental compilation (the release profile's own).
@@ -1114,7 +1127,7 @@ each was built or last used): switching a mod off and on again plays the kept bu
The file is pinned by SHA-256 like every registry file, and the version, game and pins are the bundle's own
`horizon-native.json`. `revoked.toml` takes a bad release down.
A content mod's entry may turn `native-mod` once (never back): its `.hmod` versions stay published and
are checked as `.hmod`s, the index lists only its `.hnmod` versions, and the launcher's update keeps the
are checked as `.hmod`s, the index lists only its versions with native code, and the launcher's update keeps the
player's `.hmod` (the game runs it until a native build has the mod, then skips it); removing the mod removes both.
- **Isolation of data.** Native mods run only in the instance they are installed in, with its worlds, options and
logs (the default Horizon instance's, when they are installed there); other instances play their own versions.
@@ -1483,30 +1496,6 @@ it moves only what the player sees, not where they are or aim.
moved-too-quickly check, which can send them back, as vanilla's does. It answers false for a player not on this server,
on a client, and inside a player's hook (call it from a tick hook).
### A client mod's view and items
`crates/horizon-client/src/game/native_claimed_items.rs`: helpers for client mods, for the player's own view and hands.
- `native_view_hit()` gives the first block along the view within 1024 blocks and how far it is, and
`native_raycast(distance)` the same within `distance` (cut to 0 to 1024). Past the loaded chunks the ray goes on over
the distant terrain, a block at a time, and meets its ground: a target far off can be aimed at before its chunks
load. `NativeHit` holds the block, where the ray met it and the block's id. Both answer `None` with no player, or
with nothing in reach.
- `native_claim_items(mod_id, ids)` claims up to 64 item ids (`minecraft:stick`, a content mod's own item) for mod
`mod_id`; a later call replaces the list, an empty one gives them up. Two mods claiming one id: the first in id
order has it. A right-click with a claimed item in the main hand is the mod's alone: the server never hears of it,
and the block or entity under the crosshair isn't used. A claimed item in the off hand does nothing on a
right-click. The use slows the player as an item in use does, which the server doesn't know of: on a LAN or
dedicated server the anticheat's movement check may log it. A claim wins over `native_use_duration` for the same
item.
- `native_poll(mod_id)` gives the mod's events since its last poll, oldest first, at most 256: `UseStart(item, 0)`
when the right-click begins the use, and `UseStop(item, ticks)` when it ends, with the ticks it was held. It ends
when the button comes up, when the hand's item changes or leaves the claim, when the player respawns, and
when the game joins a server, so every `UseStart` has its `UseStop`. `NativeHeldItem` holds the stack's id, count,
slot, and its `minecraft:custom_name` and `minecraft:custom_data` as SNBT: items told apart by custom data (a
stick named "Designator") are told apart here. Poll from a client tick hook.
- The stack in the main hand at any time is `native_held()` (API 9), on a client the player's own.
### Values for a mod's shader pack
`native_shader_params(partial, params)` (client) asks every frame, while a Horizon shader pack is on, for eight
@@ -1542,23 +1531,6 @@ something happened can time the sound to the distance, such as a blast heard whe
`delay = distance / speed`. It answers false on a server and for a value out of range; an event the resources lack
plays nothing.
### Music
The music a client mod chooses, for a soundtrack that follows the seasons or grows with the player's world.
- `native_music(music, volume)` (client) is asked each client tick with what vanilla chose (`Minecraft.getSituationalMusic`
and `getMusicVolume`): the biome's track, the creative, underwater, boss or menu music, None for silence, and its volume.
Set `music` to a track of your own (`Music::new("mymod:music.spring", min_delay, max_delay, replace_current)`, the delays
in ticks between songs), None to stay quiet, or lower `volume` to fade what plays. The music manager plays it as it plays
vanilla's: the delays, the Music Frequency option and the Now Playing toast apply. A different track waits for the one
playing to end unless `replace_current` is set.
- `native_music_layers(layers)` (client) is asked each client tick for tracks to loop over (or instead of) that music: push
a `MusicLayer { sound, volume }` for each. A layer eases to its volume by 0.025 a tick (two seconds from silent to full);
one left out fades out and stops, and starts from its beginning if asked for again. Layers that first appear in the same
tick start together: list every stem of a piece from the start, the quiet ones at 0, and raise them as the world fills
(a value such as a village's wellbeing, sent from the server half with mod messages). Layers play on the Music volume.
- The sound events are a content mod's (`assets/<id>/sounds.json` with `"stream": true`, and its `.ogg` files).
### Falling blocks
`crates/horizon-client/src/game/native_falling.rs`: a helper for server mods, for the level in place, on a server only. It
@@ -1697,3 +1669,189 @@ malformed `minecraft:food` still makes the item edible, with no nutrition. Only
Horizon without the mod) fills in vanilla's defaults for the same stacks: a stone stack the server caps at 16 still
looks like it stacks to 64 there.
- The creative search tab matches vanilla's exported tooltip text, so a changed `item_name` is not searchable yet.
## Trait mods (draft)
A bundle declares its trait mod package by its path inside the bundle:
```toml
[api]
path = "engine"
```
The package may use only crates and versions in Horizon's `Cargo.lock`, like mixin crates.
The folder contains a Rust package with a `horizon-client` dependency. Its library exports
`pub fn hooks() -> Box<dyn horizon_client::mod_api::Hooks>`. The launcher links this package
into a generated game executable and calls `hooks()` in dependency order (a mod after the mods it
depends on, else in bundle order). The package is not a
`[[crate]]`: it depends on the game instead of the game depending on it. A bundle with no
injections, state, bridges, modules, mixin crates or post-build steps skips horizon-mixins.
### Dependencies between trait mods
A mod can publish functions and statics from its `[api]` package for another mod to call:
```toml
[dependencies]
base_mod = "^1"
extra_mod = { version = "^2", optional = true }
```
Requirements use Cargo semver syntax and are checked against each installed bundle's `[mod] version`,
not its Cargo package version. The dependent and every installed dependency need an `[api]` package.
Missing required dependencies, incompatible versions and cycles stop the build with the mods named.
An absent optional dependency is permitted; an installed optional dependency must match its requirement.
The launcher adds a path dependency using the mod ID as the Cargo alias and the target's actual Cargo
package name. In Rust, replace hyphens in the alias with underscores. Optional dependencies also get a
Cargo feature named exactly as their mod ID, enabled only when that mod is installed. Guard calls with
`#[cfg(feature = "extra_mod")]`. Do not define these dependencies or features yourself in Cargo.toml.
Public statics must use appropriate synchronization for shared and reentrant hooks; private state in
another mod's hook object is not exposed.
Trait dispatch follows dependency order, with ties kept in bundle order. Mixin injection order remains
horizon-mixins' priority, mod ID, then injection ID; dependency order does not change injection order.
A bundle with an `[api]` package may declare its own HEAD injection in the trait:
```toml
[[injection]]
id = "before-tick"
target = "horizon_client::game::example"
kind = "HEAD"
trait_method = "before_tick"
bindings = [{ name = "amount", ty = "&mut i32", value = "&mut *amount" }]
```
The generated trait method is `inject_<mod_id>_<trait_method>` (hyphens in the mod id become
underscores). Its event, as a catalog hook's, is that name in CamelCase plus `Event`
(`InjectExampleBeforeTickEvent`), with the bindings as fields under the same rules. Implement it on the object
returned by that bundle's `hooks()`; it is called only for that bundle's object. Types must be
public and available from both the target and `horizon_client::mod_api`. Only HEAD listen is
supported, or HEAD with `cancellable = true` for a unit-returning target: that method returns
`Flow`, and `Flow::Cancel` returns early from the target. Do not specify `helper` on these entries.
Other injection kinds are rejected. Trait injections can target only functions in `horizon-client`;
ordinary mixin helpers in other crates continue to work as before. The generated helper has the same priority and ordering as an
ordinary helper of that mod: priority descending, mod id then injection id. It is not automatically
ordered after other mixins. Catalog hooks retain the mixin-before-trait order below.
A linked trait mod implements `horizon_client::mod_api::Hooks`. Mixin injections run first.
An injection that returns early skips trait mods. A claimed cancellation also skips trait mods.
Otherwise, trait mods run in their supplied order. The first `Cancel` stops dispatch.
For replacement hooks, the first `Some` wins. Collection hooks call every mod. Receiverless hooks have no `Host`.
Each hook method takes its host, where it has one, and one event:
```rust
fn living_incoming_damage(&self, event: &mut LivingIncomingDamageEvent<'_>) -> Flow {
if event.kind == "minecraft:pig" {
event.amount *= 0.5;
}
Flow::Continue
}
```
The event is a generated `#[non_exhaustive]` struct named after the catalog hook, in CamelCase plus `Event`, with
the hook point's arguments as public fields: values as values and borrowed ones as references (`event.kind`). A
number or `bool` a mod may change is a plain field, written back once every mod has run, even after a `Cancel`;
anything else a mod may change, a game struct or a list, is a `&mut` field. A cancellation or replacement is still
the return value; a collection hook's list is a field (`event.lines`). One event goes through every mod, so each
sees the changes before it. Because no mod can build or match the whole
struct, a later version can add a field without breaking any mod, as NeoForge's event objects do; the cost is that
a mod's own tests can't build an event yet.
Hook methods take `&self` and require `Send + Sync`. The process-wide mod list is installed once, like mixin module statics. One mod instance serves both
the window's game and the server-thread game. A hook can also fire again inside
the same mod: for example, `set_block` fires `block_changed`. Mods keep mutable
state with interior mutability so these calls can share the instance safely. Data-load
hooks can run on any loading thread; item default changes must be identical on every side.
The trait has every hook carrying a `mod_api` entry in `native-hooks.json`.
Level hooks use access-specific hosts rather than `Host`. `LevelHost` provides
`block_state` through `LevelAccess::block_state`, client-side detection and
`state_with_property`. `LevelRandomHost` provides vanilla's `Level.random` so
mod draws come in vanilla's order. `LevelTicksHost` adds scheduling through
`native_schedule_block_tick` and `native_schedule_fluid_tick`, while
`LevelWriteHost` adds random access and `native_set_block_property`. Whole-block writes wait for a
later slice that creates block entities, as vanilla's `setBlock` does. The game owns
the tick queue; mods do not pass it back to these helpers.
For frame hooks, implement `RenderHooks` and return it from `Hooks::render`. The ordered
renderer list is installed once alongside the mod list. `render_needs` collects requests;
`world_drawn` observes the world pass and can encode drawing before the hand and HUD. Both
run on the render thread while the frame is built, before any deferred GPU submission.
The renderer module re-exports the game's wgpu types.
### Server attachments
A mod can declare an `AttachmentType<T>` once and call its `get`, `set` and `remove`
methods with the hook's host and a `Holder`. `T` is `Clone + Send + Sync + 'static`;
`get` returns a clone and inserts the default on first access. `set` runs the optional
codec before changing the value, returning an `AttachmentError` if it cannot encode.
Keys are the pair `(namespace, name)`; a key already decoded as another Rust type is
an error. A missing or non-server holder is an error, not a new phantom holder.
`AttachmentError` distinguishes `MissingHolder`, `TypeMismatch`, `Decode`, `Encode`,
`InvalidNamespace` (a malformed saved compound) and `InvalidCopyPolicy` (death-copy
without a codec).
```rust
use horizon_client::mod_api::{AttachmentCodec, AttachmentError, AttachmentType, Compound, Holder, Tag};
static COUNTER: AttachmentType<i32> = AttachmentType {
namespace: "example", name: "counter", default: || 0,
codec: Some(AttachmentCodec {
encode: |n| Ok([("count".into(), Tag::Int(*n))].into()),
decode: |data: &Compound| match data.get(&"count".into()) {
Some(Tag::Int(n)) => Ok(*n), _ => Err(AttachmentError::Decode),
},
}),
copy_on_death: false,
};
// Inside a server hook with `host: &mut dyn Host`:
let old = COUNTER.get(host, Holder::Chunk([0, 0]))?;
COUNTER.set(host, Holder::Chunk([0, 0]), old + 1)?;
Ok::<(), AttachmentError>(())
```
Use `Holder::Level`, `Holder::Chunk([x, z])`, `Holder::BlockEntity([x, y, z])`,
`Holder::Entity(id)`, or `Holder::Player(uuid)`. Entity IDs cover mobs, item
entities, boats, minecarts, armor stands, cushions, item frames, paintings and the
server's `OtherEntity` records; projectiles, orbs and other ID-less save paths are
not covered. Player UUIDs cover online guests and the acting player. Mutable level
hosts can access their level, loaded chunks and block entities; read-only level hosts
cannot access attachments. These maps live on the server only: there is **no client
synchronization**. Unserialized values disappear on unload or restart. A saved value
is stored under `horizon:attachments`, then the mod namespace and attachment name,
as `{value: <compound>, copy_on_death: 1b}` (the flag is omitted when false).
Unknown mods' saved entries survive load and save without being decoded. Level
attachments use the dimension's `data/minecraft/horizon_attachments.dat` SavedData
file, not `level.dat`.
`copy_on_death` requires a codec. Player respawn copies opted-in entries via their
encoded representation, including raw entries of an absent mod; other entries are
removed. Living-entity conversion is not supported. This follows the default supplier,
optional server-side persistence and player-respawn option of NeoForge 26.3's public
`AttachmentType.Builder` contract, but not its holder-aware supplier, custom copy
handler, conditional serialization or sync APIs.
`Host` exposes the documented `Game` helpers listed under `helpers` in
`native-hooks.json`, without their `native_` prefix. Each keeps the helper's
receiver and documentation. Level services stay on the level hosts above.
The following documented `Game` helpers are not in the trait yet:
- `spawn_mob`, `mob`, `mobs`, `block_in_tag`, `item_in_tag`,
`server_block_in_tag`, `server_item_in_tag`, `server_block`,
`set_display`, `sidebar`, `experience_orbs`,
`item_entities`, `player`, `panel_recipes`, `recipes`, `component`,
`set_component`, `remove_component`, `spawn_falling_block`, `explode`,
`fluid_tanks` and `container_slots` use gameplay-private types.
- `with_mod_block_entity` and `trigger_criterion` are generic or take
`impl Trait`, so their signatures cannot be called through `dyn Host`.
The following catalog hooks are not in the trait yet:
- Newly added upstream hooks `player_command`, `perform_console_command`, `item_decorations`,
`client_resources_reloaded`, `camera_shake`, `screen_key_pressed_pre`, `screen_char_typed_pre`,
`screen_mouse_scrolled_pre`, `use_duration`, `use_animation`, `can_eat`,
`living_entity_use_item_start`, `living_entity_use_item_finish`, `render_hand`, `shader_params`
and `block_highlight` will join a later slice.
- Newer upstream hooks `biome_colors`, `camera_distance`, `compute_fog_color`, `compute_fov`,
`data_pack_files`, `mob_submit`, `mouse_button_pre`, `mouse_button_post`, `player_respawned`
and `server_data_loaded` will join a later slice.
+3 -2
View File
@@ -70,15 +70,16 @@ harness named checks that loader against vanilla).
| Sky: sun, moon phases, stars, end sky, clouds, rain and snow | `atlases/celestials.json`, `textures/environment/` | works | `test_environment_render_parity`, `test_cloud_render_parity`, `test_weather_render_parity` |
| Title panorama | `textures/gui/title/background/` | works | `gfx/panorama.rs` loads it from the overlay |
| Screen overlays: vignette, pumpkin, powder snow, nausea, underwater, world border | `textures/misc/` | works | `test_world_border_render_parity`, `test_hud_parity`; loaded from the overlay |
| Enchantment glint | `textures/misc/enchanted_glint_*` | works | `item_render/gui.rs`, `entity.wgsl` |
| Enchantment glint | `textures/misc/enchanted_glint_*` | works | `item_render/gui.rs`, `hud/cit_glint.rs`, `entity.wgsl` |
| Maps: background, decorations | `textures/map/`, `atlases/map_decorations.json` | works | loaded from the overlay |
| Paintings, mob effect icons | `atlases/paintings.json`, `textures/mob_effect/` | works | loaded from the overlay |
| Locator bar styles | `waypoint_style/` | **no** | the locator bar isn't drawn yet |
| Post effects (spectating a creeper, spider or enderman; glowing outlines) | `post_effect/`, `shaders/post/` | **no** | Horizon's effects are WGSL; a pack's GLSL post chain isn't run |
| Core shaders | `shaders/core/`, `shaders/include/` | partly | `core/text` draws the GUI's text (HUDs moved by its vertex shader, text effects), translated by naga (`horizon-shaders` `core_shader.rs`, `hud/pack_text.rs`); the other core shaders aren't run |
| OptiFine / CIT Resewn custom item textures (`type=item`: `texture`, `model`, `texture.<name>`, `model.<name>`; `type=armor`: `texture`, `texture.<layer>` with the pre-1.21.2 layer names such as `diamond_layer_1`, `leather_layer_2_overlay`; `type=elytra`: `texture`; `type=enchantment` layers on GUI items; `useGlint=false` in `cit.properties`, which removes vanilla's glint from items) | `optifine/cit/`, `mcpatcher/cit/`, `citresewn/cit/` | partly | `item_render/cit.rs`, `cit_armor.rs`, `cit_glint.rs` and `hud/cit_glint.rs` tests; enchantment layers on held, dropped, framed and worn items, and `anim/` glints, come later; special renderers (heads, shields, banners) keep their vanilla look; only players and armour stands wear CIT armour and elytra, other mobs keep vanilla's; worn trims are unchanged; armour textures above 1024x512 (16 times vanilla's 64x32) draw vanilla |
| GPU warn list, regional compliance | `gpu_warnlist.json`, `regional_compliancies.json` | n/a | OpenGL driver warnings and play-time notices for some regions; not used |
Summary: 45 rows; 32 work, 4 partly, 7 don't (interpolated animation, TrueType fonts, credits and end poem,
Summary: 46 rows; 32 work, 5 partly, 7 don't (interpolated animation, TrueType fonts, credits and end poem,
villager hats, trims on worn armour, locator bar styles, post effects), 2 don't apply. Except the
shaders, the missing pieces are missing vanilla features too, and each vanilla parity fix covers the pack case as well.
+2
View File
@@ -2699,8 +2699,10 @@ dependencies = [
"oo7",
"proc-macro2",
"pulldown-cmark",
"quote",
"raw-window-handle",
"security-framework 3.7.0",
"semver",
"serde",
"serde_json",
"sha1",
+4
View File
@@ -15,6 +15,9 @@ gpui_platform = { package = "gpui-fast-platform", version = "0.1.12", features =
ureq = { version = "2", features = ["json", "tls"], default-features = false }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Validate and render injected trait signatures before they reach rustc.
syn = { version = "2", features = ["full", "visit-mut"] }
quote = "1"
# Checks the client jar and the version file found in other launchers' folders (sources.rs).
sha1 = "0.10"
# The mod browser checks Modrinth's SHA-512 before a pack reaches the game folder (browser/download.rs).
@@ -25,6 +28,7 @@ pulldown-cmark = { version = "0.12", default-features = false }
flate2 = "1"
tar = { version = "0.4", default-features = false }
toml = "1.1"
semver = "1"
zip = { version = "9.1.3", default-features = false, features = ["deflate"] }
dirs = "5"
# The system's preferred languages, for the window's language (i18n.rs).
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more