mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 11:06:11 -10:00
Merge branch 'main' into perf/static-block-entity-batching
This commit is contained in:
187 files changed
+18523
-1332
No files matched your search
@@ -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
@@ -5120,6 +5120,9 @@ name = "xtask"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-theme",
|
||||
"quote",
|
||||
"serde_json",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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.
|
||||
@@ -84,6 +85,18 @@ pub struct BlockEntities {
|
||||
/// which leaves out a mod block whose template has none. One taken out
|
||||
/// (`take`) stays listed until `put` or `remove`.
|
||||
mods: FxHashMap<(i32, i32), FxHashSet<Pos>>,
|
||||
/// The positions of the block entities that draw a bounding box (structure and test
|
||||
/// instance blocks, `shows_box`), so a frame looks at them without scanning the map.
|
||||
/// Like `mods`, one taken out stays listed until `put` or `remove`.
|
||||
boxes: FxHashSet<Pos>,
|
||||
}
|
||||
|
||||
/// A structure or test instance block: the block entities whose box the frame may draw.
|
||||
fn shows_box(entity: &BlockEntity) -> bool {
|
||||
matches!(
|
||||
entity,
|
||||
BlockEntity::StructureBlock(_) | BlockEntity::TestInstanceBlock(_)
|
||||
)
|
||||
}
|
||||
|
||||
fn chunk_of((x, _, z): Pos) -> (i32, i32) {
|
||||
@@ -220,11 +233,53 @@ 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 {
|
||||
!self.mods.is_empty()
|
||||
}
|
||||
/// The structure and test instance blocks (a frame draws their boxes), in no order.
|
||||
pub fn boxes(&self) -> impl Iterator<Item = (&Pos, &BlockEntity)> {
|
||||
self.boxes
|
||||
.iter()
|
||||
.filter_map(|pos| Some((pos, self.map.get(pos)?)))
|
||||
}
|
||||
/// Keeps `pos` listed in `boxes` exactly while `entity` shows a box.
|
||||
fn note_box(&mut self, pos: Pos, entity: Option<&BlockEntity>) {
|
||||
if entity.is_some_and(shows_box) {
|
||||
self.boxes.insert(pos);
|
||||
} else {
|
||||
self.boxes.remove(&pos);
|
||||
}
|
||||
}
|
||||
/// The mod block entities within `radius` blocks of `x`, `z` (by chunk, so
|
||||
/// far ones are skipped whole; the caller checks each distance).
|
||||
pub fn mods_near(&self, x: f64, z: f64, radius: f64) -> impl Iterator<Item = &Pos> {
|
||||
@@ -248,12 +303,28 @@ 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() {
|
||||
self.add(pos);
|
||||
}
|
||||
if new_box {
|
||||
self.boxes.insert(pos);
|
||||
} else if old.as_ref().is_some_and(shows_box) {
|
||||
self.boxes.remove(&pos);
|
||||
}
|
||||
if new_is_mod {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
} else if matches!(old, Some(BlockEntity::Mod(_))) {
|
||||
@@ -275,15 +346,20 @@ impl BlockEntities {
|
||||
if absent && matches!(entity, BlockEntity::Mod(_)) {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
}
|
||||
if absent && shows_box(entity) {
|
||||
self.boxes.insert(pos);
|
||||
}
|
||||
entity
|
||||
}
|
||||
/// `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(_))) {
|
||||
forget_mod(&mut self.mods, *pos);
|
||||
}
|
||||
self.boxes.remove(pos);
|
||||
removed
|
||||
}
|
||||
/// The block entity at `pos`, out of the map for a while: its slot stays
|
||||
@@ -301,6 +377,7 @@ impl BlockEntities {
|
||||
if matches!(entity, BlockEntity::Mod(_)) {
|
||||
self.mods.entry(chunk_of(pos)).or_default().insert(pos);
|
||||
}
|
||||
self.note_box(pos, Some(&entity));
|
||||
if self.map.insert(pos, entity).is_none()
|
||||
&& !self.live.contains_key(&pos)
|
||||
&& !self.pending.contains_key(&pos)
|
||||
@@ -311,21 +388,30 @@ 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 mods = &mut self.mods;
|
||||
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);
|
||||
boxes.remove(p);
|
||||
}
|
||||
kept
|
||||
});
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.attachments.clear();
|
||||
self.map.clear();
|
||||
self.mods.clear();
|
||||
self.boxes.clear();
|
||||
self.tickers = Arc::default();
|
||||
self.live.clear();
|
||||
self.added.clear();
|
||||
@@ -342,7 +428,9 @@ 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);
|
||||
if old.is_some() {
|
||||
self.replaced.insert(p);
|
||||
@@ -476,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();
|
||||
@@ -560,4 +694,43 @@ mod tests {
|
||||
b.state_changed(p, 2, 1);
|
||||
assert_eq!(order(&mut b, &s), [q, p]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn boxes_list_exactly_the_structure_and_test_blocks() {
|
||||
let test_block = || BlockEntity::TestInstanceBlock(Box::default());
|
||||
let listed = |b: &BlockEntities| {
|
||||
let mut v: Vec<Pos> = b.boxes().map(|(p, _)| *p).collect();
|
||||
v.sort();
|
||||
v
|
||||
};
|
||||
let mut b = level();
|
||||
let (p, q, r) = ((0, 0, 0), (1, 0, 0), (2, 0, 0));
|
||||
b.insert(p, test_block());
|
||||
b.insert(q, hopper());
|
||||
assert_eq!(listed(&b), [p]);
|
||||
// Replaced by another kind, and back.
|
||||
b.insert(p, hopper());
|
||||
assert_eq!(listed(&b), []);
|
||||
b.insert(q, test_block());
|
||||
b.get_or_insert_with(r, test_block);
|
||||
assert_eq!(listed(&b), [q, r]);
|
||||
// Taken out: not shown until put back; put back as another kind: gone.
|
||||
let taken = b.take(&q).unwrap();
|
||||
assert_eq!(listed(&b), [r]);
|
||||
b.put(q, taken);
|
||||
assert_eq!(listed(&b), [q, r]);
|
||||
let taken = b.take(&r).unwrap();
|
||||
drop(taken);
|
||||
b.put(r, hopper());
|
||||
assert_eq!(listed(&b), [q]);
|
||||
b.remove(&q);
|
||||
assert_eq!(listed(&b), []);
|
||||
b.load_chunk(vec![(p, test_block()), (q, hopper())]);
|
||||
assert_eq!(listed(&b), [p]);
|
||||
b.retain(|pos, _| *pos != p);
|
||||
assert_eq!(listed(&b), []);
|
||||
b.insert(r, test_block());
|
||||
b.clear();
|
||||
assert!(b.boxes.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)));
|
||||
}
|
||||
}
|
||||
@@ -153,6 +153,7 @@ mod page_runs;
|
||||
pub mod panorama;
|
||||
mod readback;
|
||||
mod resident;
|
||||
mod reuse;
|
||||
mod stars;
|
||||
pub mod submit;
|
||||
mod sunrise_fan;
|
||||
@@ -770,7 +771,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,
|
||||
@@ -781,6 +783,9 @@ pub struct Renderer {
|
||||
display_fresh: bool,
|
||||
window: Arc<Window>,
|
||||
pub device: wgpu::Device,
|
||||
/// The shader modules, layouts and pipelines kept for the next renderer on
|
||||
/// this device ([`reuse`]).
|
||||
reuse: Arc<reuse::Reuse>,
|
||||
/// Frames are finished and submitted on its submit thread ([`submit`]).
|
||||
pub queue: submit::Queue,
|
||||
/// The last frame went to the submit thread; whether it had no window surface.
|
||||
@@ -961,6 +966,8 @@ pub struct Renderer {
|
||||
/// last asked (`take_dropped_sections`).
|
||||
sections_dropped: bool,
|
||||
sorted_indices: arena::Arena,
|
||||
/// Buffers of released arena pages, freed a few frames later on a thread of their own.
|
||||
graveyard: arena::Graveyard<wgpu::Buffer>,
|
||||
translucent_sorter: translucency::Sorter,
|
||||
translucency_cursor: usize,
|
||||
/// `DrawIndexedIndirectArgs` for this frame's solid then translucent draws.
|
||||
@@ -1147,7 +1154,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,
|
||||
@@ -1901,7 +1908,10 @@ impl Renderer {
|
||||
device_lost: renderer.device_lost.clone(),
|
||||
}
|
||||
}
|
||||
None => open_device(&window, offscreen, api, layers).await?,
|
||||
None => {
|
||||
let _step = crate::spans::startup::step("renderer.open_device");
|
||||
open_device(&window, offscreen, api, layers).await?
|
||||
}
|
||||
};
|
||||
// Computed once: the sprite arrays, the shaders' constants and the
|
||||
// shader pack atlas all cut the sprites by it.
|
||||
@@ -1981,8 +1991,15 @@ impl Renderer {
|
||||
if previous.is_none() {
|
||||
startup_step(format_args!("creating the pipelines"));
|
||||
}
|
||||
// A renderer on the same device takes the previous one's shader
|
||||
// modules, layouts and pipelines where they come out the same.
|
||||
let reuse = previous.map_or_else(Default::default, |r| r.reuse.clone());
|
||||
let generation = reuse.begin();
|
||||
let kept = reuse.on(&device);
|
||||
let pipelines_step = crate::spans::startup::step("renderer.pipelines_textures");
|
||||
let terrain_step = crate::spans::startup::step("renderer.terrain_pipelines");
|
||||
let gpu_timer = timing::GpuTimer::new(&device, &queue);
|
||||
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
let shader = kept.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon terrain"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("terrain.wgsl").into()),
|
||||
});
|
||||
@@ -2006,7 +2023,7 @@ impl Renderer {
|
||||
("SPRITE_ARRAYS", f64::from(sprite_arrays)),
|
||||
("SPRITE_SPLIT", f64::from(split)),
|
||||
];
|
||||
let bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
let bindings = kept.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Horizon terrain bindings"),
|
||||
entries: &[
|
||||
entry(
|
||||
@@ -2064,7 +2081,7 @@ impl Renderer {
|
||||
),
|
||||
],
|
||||
});
|
||||
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
let layout = kept.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Horizon terrain layout"),
|
||||
bind_group_layouts: &[Some(&bindings)],
|
||||
..Default::default()
|
||||
@@ -2100,7 +2117,7 @@ impl Renderer {
|
||||
depth_write: bool,
|
||||
depth_test: bool,
|
||||
blend: Option<wgpu::BlendState>| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(layout),
|
||||
vertex: wgpu::VertexState {
|
||||
@@ -2248,7 +2265,7 @@ impl Renderer {
|
||||
false,
|
||||
Some(alpha_blend),
|
||||
);
|
||||
let depth_reset = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
let depth_reset = kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Hand depth reset"),
|
||||
layout: Some(&layout),
|
||||
vertex: wgpu::VertexState {
|
||||
@@ -2327,6 +2344,7 @@ impl Renderer {
|
||||
false,
|
||||
Some(alpha_blend),
|
||||
);
|
||||
drop(terrain_step);
|
||||
let globals = device.create_buffer(&wgpu::BufferDescriptor {
|
||||
label: Some("Horizon globals"),
|
||||
size: std::mem::size_of::<Globals>() as u64,
|
||||
@@ -2638,7 +2656,8 @@ impl Renderer {
|
||||
&glint_sampler,
|
||||
)?;
|
||||
hud.atlas_texture = Some(atlas.clone());
|
||||
let entity_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
let entity_step = crate::spans::startup::step("renderer.entity_pipelines");
|
||||
let entity_shader = kept.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon entities"),
|
||||
source: wgpu::ShaderSource::Wgsl(entity_shader().into()),
|
||||
});
|
||||
@@ -2646,7 +2665,7 @@ impl Renderer {
|
||||
label: Some("Horizon static block entities"),
|
||||
source: wgpu::ShaderSource::Wgsl(static_entity_shader().into()),
|
||||
});
|
||||
let entity_bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
let entity_bindings = kept.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Entity bindings"),
|
||||
entries: &[
|
||||
entry(
|
||||
@@ -2827,7 +2846,7 @@ impl Renderer {
|
||||
},
|
||||
],
|
||||
});
|
||||
let entity_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
let entity_layout = kept.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Entity layout"),
|
||||
bind_group_layouts: &[Some(&entity_bindings)],
|
||||
..Default::default()
|
||||
@@ -2837,7 +2856,7 @@ impl Renderer {
|
||||
bind_group_layouts: &[Some(&entity_bindings), Some(&static_entity_bindings)],
|
||||
..Default::default()
|
||||
});
|
||||
let cloud_bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
let cloud_bindings = kept.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("Cloud bindings"),
|
||||
entries: &[entry(
|
||||
0,
|
||||
@@ -2863,7 +2882,7 @@ impl Renderer {
|
||||
resource: cloud_uniform.as_entire_binding(),
|
||||
}],
|
||||
});
|
||||
let cloud_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
let cloud_layout = kept.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("Cloud layout"),
|
||||
bind_group_layouts: &[Some(&entity_bindings), Some(&cloud_bindings)],
|
||||
..Default::default()
|
||||
@@ -2903,7 +2922,7 @@ impl Renderer {
|
||||
clouds,
|
||||
fragment: &str,
|
||||
write_mask| {
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some(label),
|
||||
layout: Some(if clouds {
|
||||
&cloud_layout
|
||||
@@ -3200,10 +3219,10 @@ impl Renderer {
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
};
|
||||
let additive_pipeline = device.create_render_pipeline(&additive_descriptor);
|
||||
let additive_pipeline = kept.create_render_pipeline(&additive_descriptor);
|
||||
// `RenderPipelines.DRAGON_RAYS` uses DepthStencilState.DEFAULT:
|
||||
// unlike eyes, a ray hides the faces and rays behind it.
|
||||
let dragon_rays_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
let dragon_rays_pipeline = kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Dragon death rays"),
|
||||
primitive: wgpu::PrimitiveState {
|
||||
cull_mode: Some(wgpu::Face::Back),
|
||||
@@ -3223,7 +3242,7 @@ impl Renderer {
|
||||
write_mask: wgpu::ColorWrites::ALPHA,
|
||||
})];
|
||||
let hdr_mark_lightning_pipeline =
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("HDR lightning mark"),
|
||||
vertex: wgpu::VertexState {
|
||||
entry_point: Some("vs_lightning"),
|
||||
@@ -3238,7 +3257,7 @@ impl Renderer {
|
||||
}),
|
||||
..additive_descriptor.clone()
|
||||
});
|
||||
let lightning_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
let lightning_pipeline = kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Lightning"),
|
||||
vertex: wgpu::VertexState {
|
||||
entry_point: Some("vs_lightning"),
|
||||
@@ -3247,7 +3266,7 @@ impl Renderer {
|
||||
},
|
||||
..additive_descriptor
|
||||
});
|
||||
let celestial_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
let celestial_pipeline = kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Celestial bodies"),
|
||||
layout: Some(&entity_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
@@ -3296,6 +3315,7 @@ impl Renderer {
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
drop(entity_step);
|
||||
// WorldBorderRenderer: misc/forcefield.png as a MipmappedTexture
|
||||
// (repeat, linear minification with mipmaps, nearest magnification).
|
||||
let forcefield_size = forcefield.1;
|
||||
@@ -3341,7 +3361,7 @@ impl Renderer {
|
||||
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
|
||||
mapped_at_creation: false,
|
||||
});
|
||||
let border_bindings = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
let border_bindings = kept.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||
label: Some("World border bindings"),
|
||||
entries: &[
|
||||
entry(
|
||||
@@ -3400,19 +3420,19 @@ impl Renderer {
|
||||
},
|
||||
],
|
||||
});
|
||||
let border_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
let border_layout = kept.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||
label: Some("World border layout"),
|
||||
bind_group_layouts: &[Some(&border_bindings)],
|
||||
..Default::default()
|
||||
});
|
||||
let border_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
let border_shader = kept.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon world border"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("border.wgsl").into()),
|
||||
});
|
||||
let border_attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2];
|
||||
// RenderPipelines.WORLD_BORDER: BlendFunction.OVERLAY, no culling,
|
||||
// depth writes with a polygon offset of 3 towards the camera.
|
||||
let border_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
let border_pipeline = kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("World border"),
|
||||
layout: Some(&border_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
@@ -3465,7 +3485,7 @@ impl Renderer {
|
||||
multiview_mask: None,
|
||||
cache: None,
|
||||
});
|
||||
let screen_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
let screen_shader = kept.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||
label: Some("Horizon screen effects"),
|
||||
source: wgpu::ShaderSource::Wgsl(include_str!("screen.wgsl").into()),
|
||||
});
|
||||
@@ -3504,7 +3524,7 @@ impl Renderer {
|
||||
constants: &constants,
|
||||
..Default::default()
|
||||
};
|
||||
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
kept.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||
label: Some("Screen effects"),
|
||||
layout: Some(&entity_layout),
|
||||
vertex: wgpu::VertexState {
|
||||
@@ -3675,6 +3695,14 @@ impl Renderer {
|
||||
);
|
||||
let panorama =
|
||||
panorama::Panorama::new(&device, config.format.remove_srgb_suffix(), panorama_faces);
|
||||
drop(pipelines_step);
|
||||
reuse.finish(generation);
|
||||
if previous.is_some() {
|
||||
log::info!(
|
||||
"Horizon renderer: rebuilt on the same device ({} shader modules, layouts and pipelines kept)",
|
||||
reuse.len()
|
||||
);
|
||||
}
|
||||
if previous.is_none() {
|
||||
startup_step(format_args!(
|
||||
"pipelines ready (multi-draw {}, occlusion culling {}, GPU timer {})",
|
||||
@@ -3705,6 +3733,7 @@ impl Renderer {
|
||||
last_encode: std::time::Duration::ZERO,
|
||||
window,
|
||||
device,
|
||||
reuse,
|
||||
queue: submit::Queue::new(queue, submit::wanted()),
|
||||
deferred: None,
|
||||
device_lost,
|
||||
@@ -3828,6 +3857,7 @@ impl Renderer {
|
||||
translucent_batching,
|
||||
sections_dropped: false,
|
||||
sorted_indices,
|
||||
graveyard: Default::default(),
|
||||
translucent_sorter: translucency::Sorter::default(),
|
||||
translucency_cursor: 0,
|
||||
indirect,
|
||||
@@ -4656,14 +4686,15 @@ impl Renderer {
|
||||
self.lod_pending = None;
|
||||
self.detail_columns.clear();
|
||||
self.detail_revision += 1;
|
||||
self.arena.clear();
|
||||
// A dimension change drops every page at once: freed later, off this thread.
|
||||
self.graveyard.add(self.arena.clear());
|
||||
if let Some(arena) = &mut self.translucent_arena {
|
||||
arena.clear();
|
||||
self.graveyard.add(arena.clear());
|
||||
}
|
||||
if let Some(flat) = &mut self.flat {
|
||||
flat.clear();
|
||||
}
|
||||
self.sorted_indices.clear();
|
||||
self.graveyard.add(self.sorted_indices.clear());
|
||||
self.translucency_cursor = 0;
|
||||
if layout == self.layout {
|
||||
self.occlusion.clear();
|
||||
@@ -5548,7 +5579,13 @@ 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> {
|
||||
// Its steps as spans (HORIZON_SPIKES): each lap names the step that just ended.
|
||||
let mut laps = crate::spans::laps();
|
||||
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`),
|
||||
@@ -5556,12 +5593,14 @@ impl Renderer {
|
||||
if !frame.title {
|
||||
self.follow_translucent_layout();
|
||||
}
|
||||
self.arena.trim();
|
||||
let released = self.arena.trim();
|
||||
self.graveyard.add(released);
|
||||
let left = self.compact_arena();
|
||||
if let Some(arena) = &mut self.translucent_arena {
|
||||
arena.trim();
|
||||
self.graveyard.add(arena.trim());
|
||||
}
|
||||
self.sorted_indices.trim();
|
||||
self.graveyard.add(self.sorted_indices.trim());
|
||||
arena::free_later(self.graveyard.advance());
|
||||
self.shrink_translucent(left);
|
||||
if let Some(flat) = &mut self.flat {
|
||||
flat.trim(|page| self.arena.has_page(page));
|
||||
@@ -5597,6 +5636,7 @@ impl Renderer {
|
||||
}
|
||||
self.surface_unconfigured = false;
|
||||
}
|
||||
laps.lap("r_prepare");
|
||||
// Frame generation: its present thread acquires and presents, this frame draws
|
||||
// into frames of its own. Not over menus that blur the frame, a shader pack's
|
||||
// own post passes, HDR or the title screen.
|
||||
@@ -5612,6 +5652,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(|| {
|
||||
@@ -5633,7 +5675,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),
|
||||
@@ -5719,6 +5763,7 @@ impl Renderer {
|
||||
let views = self.views(frame);
|
||||
let pose = frame.bob * frame.spin;
|
||||
let view_proj = views.primary().view_proj(pose);
|
||||
laps.lap("r_acquire");
|
||||
// TAA runs where the world post pass does, not under a shader pack's own.
|
||||
let taa = !frame.title
|
||||
&& self.pack_post.as_ref().is_none_or(|p| p.1.is_none())
|
||||
@@ -5861,6 +5906,7 @@ impl Renderer {
|
||||
};
|
||||
self.uploads
|
||||
.buffer(&mut encoder, &self.globals, 0, bytemuck::bytes_of(&globals));
|
||||
laps.lap("r_targets");
|
||||
// GameRenderer.render3dHud: the hand's projection at hudFov, with the
|
||||
// bob in its pose rather than the projection (same product).
|
||||
let hand_projection = Mat4::from_cols_array(&crate::camera::hand_projection(
|
||||
@@ -6066,6 +6112,7 @@ impl Renderer {
|
||||
}
|
||||
self.origins.clear();
|
||||
let (sections, retiring) = (&self.sections, &self.lod_retiring);
|
||||
laps.lap("r_cameras");
|
||||
// `LevelRenderer.extractSectionDrawGroups`: each section's `getVisibility`. Once
|
||||
// nothing is fading (the usual frame), every section is whole without a lookup.
|
||||
let fade_ms = fade_millis(frame.chunk_fade);
|
||||
@@ -6247,6 +6294,7 @@ impl Renderer {
|
||||
self.draw_pages.extend_from_slice(&self.phase2_pages);
|
||||
let culling = cull.is_some();
|
||||
let solid_draws = self.draws.len() as u32;
|
||||
laps.lap("r_section_groups");
|
||||
// LevelRenderer: nearby sections each frame, plus a rotating eighth
|
||||
// (at least 15) of the visible list. Cached views avoid needless sorts.
|
||||
self.translucent_draws.clear();
|
||||
@@ -6444,6 +6492,7 @@ impl Renderer {
|
||||
(shadow_first + shadow_solid) as usize,
|
||||
);
|
||||
let shadow_translucent = self.draws.len() as u32 - shadow_first - shadow_solid;
|
||||
laps.lap("r_nearby");
|
||||
// Metal replays a multi-draw as one draw call per entry.
|
||||
self.section_draw_calls = if self.multi_draw && self.info.backend != wgpu::Backend::Metal {
|
||||
(0..self.section_draws)
|
||||
@@ -6570,6 +6619,7 @@ impl Renderer {
|
||||
+ translucent_particle_quads
|
||||
+ translucent_item_quads
|
||||
+ oit_entity_quads;
|
||||
laps.lap("r_draw_lists");
|
||||
// Where the tail of materials the classic and the improved passes
|
||||
// both draw ends: the shadows, particles and items count back from it.
|
||||
let base_quads = world_quads - oit_entity_quads;
|
||||
@@ -6834,6 +6884,7 @@ impl Renderer {
|
||||
bytemuck::bytes_of(&uniform),
|
||||
);
|
||||
}
|
||||
laps.lap("r_materials");
|
||||
// Every upload before the frame's first pass: a pass ends wgpu-core's
|
||||
// command buffer, so copies between passes would need one more.
|
||||
if !frame.screen.is_empty() {
|
||||
@@ -6960,6 +7011,7 @@ impl Renderer {
|
||||
.flatten();
|
||||
}
|
||||
let pack_reads_world = pack_post || native_post;
|
||||
laps.lap("r_uploads");
|
||||
// Phase 2 of occlusion culling draws over the depth, and a shader pack or TAA reads it.
|
||||
let keep_depth = pack_reads_world
|
||||
|| (taa && self.world_post.taa)
|
||||
@@ -7091,6 +7143,7 @@ impl Renderer {
|
||||
}
|
||||
};
|
||||
let sky_visible = sky_open(frame);
|
||||
laps.lap("r_world_pass");
|
||||
// One of Horizon's own packs: this frame's inputs; in a timed frame its sky draws in a
|
||||
// pass of its own, so that it has a time of its own.
|
||||
let mut native_sky_pass = false;
|
||||
@@ -7229,6 +7282,7 @@ impl Renderer {
|
||||
pass.set_bind_group(0, &self.group, &[]);
|
||||
sky.draw(&mut pass, &native.frame);
|
||||
}
|
||||
laps.lap("r_pack_inputs");
|
||||
// The water mirror, for one of Horizon's own packs that reads it: the world as a
|
||||
// camera mirrored about the water plane sees it, into a half-resolution target the
|
||||
// material stage samples as `reflection`.
|
||||
@@ -7366,6 +7420,7 @@ impl Renderer {
|
||||
};
|
||||
native.frame.fit_surface(&self.device, size);
|
||||
}
|
||||
laps.lap("r_mirror");
|
||||
// One of Horizon's own packs with a material stage: terrain draws with its pipelines,
|
||||
// which read the pack's frame at group 3.
|
||||
let material = self
|
||||
@@ -8378,6 +8433,7 @@ impl Renderer {
|
||||
(&view_target, self.config.format.remove_srgb_suffix()),
|
||||
);
|
||||
}
|
||||
laps.lap("r_passes");
|
||||
// Under `HORIZON_FRAMEGEN_DEBUG=generated` a screenshot is the made frame.
|
||||
let shown_texture = match &fg_frames {
|
||||
Some((_, generated)) if fg_made && frame_gen::generated_only() => generated,
|
||||
@@ -8445,6 +8501,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;
|
||||
@@ -8509,6 +8567,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
|
||||
@@ -8541,6 +8601,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;
|
||||
@@ -9422,6 +9484,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;
|
||||
@@ -9439,14 +9509,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).
|
||||
@@ -9454,7 +9526,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);
|
||||
@@ -10239,6 +10312,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!(
|
||||
@@ -10261,6 +10343,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),
|
||||
@@ -10286,5 +10369,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)),
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
//! with pages up to `LARGE_PAGE_BYTES`, and their sorted indices too
|
||||
//! (`Arena::translucent_indices`): their back-to-front list then stays in one
|
||||
//! page of each and draws in a few multi-draws.
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
|
||||
/// Ranges are rounded up to this many vertices to limit fragmentation, and
|
||||
/// start at multiples of it (`flat` names sections by these blocks).
|
||||
@@ -545,14 +545,17 @@ impl Arena {
|
||||
old.free.give(previous, capacity - previous);
|
||||
true
|
||||
}
|
||||
/// Reclaim after an upload batch, so replacing a section can reuse its range.
|
||||
pub fn trim(&mut self) {
|
||||
/// Reclaim after an upload batch, so replacing a section can reuse its range. Gives back
|
||||
/// the buffers of the pages it emptied, for a [`Graveyard`].
|
||||
pub fn trim(&mut self) -> Vec<wgpu::Buffer> {
|
||||
let mut released = Vec::new();
|
||||
for page in &mut self.pages {
|
||||
if let Some(p) = page
|
||||
&& p.free.0.len() == 1
|
||||
&& p.free.0.get(&0) == Some(&p.capacity)
|
||||
if page
|
||||
.as_ref()
|
||||
.is_some_and(|p| p.free.0.len() == 1 && p.free.0.get(&0) == Some(&p.capacity))
|
||||
&& let Some(p) = page.take()
|
||||
{
|
||||
*page = None;
|
||||
released.push(p.buffer);
|
||||
}
|
||||
}
|
||||
if self.closing.is_some_and(|page| !self.has_page(page)) {
|
||||
@@ -562,13 +565,67 @@ impl Arena {
|
||||
while self.pages.last().is_some_and(Option::is_none) {
|
||||
self.pages.pop();
|
||||
}
|
||||
released
|
||||
}
|
||||
pub fn clear(&mut self) {
|
||||
self.pages.clear();
|
||||
/// Drops every page; gives back their buffers, for a [`Graveyard`].
|
||||
pub fn clear(&mut self) -> Vec<wgpu::Buffer> {
|
||||
self.closing = None;
|
||||
self.pages.drain(..).flatten().map(|p| p.buffer).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames a released page waits before it is freed: by then the GPU is done with the frames that
|
||||
/// used it, and the bind groups naming it (flat draws' page tables) are gone, so the freeing
|
||||
/// thread holds its last reference.
|
||||
const GRAVE_FRAMES: usize = 4;
|
||||
|
||||
/// Buffers of released pages, freed [`GRAVE_FRAMES`] frames later on a thread of their own
|
||||
/// ([`free_later`]): giving a page of up to 256 MB back to the driver on the render thread took
|
||||
/// that frame 8-14 ms of CPU (Mesa's lavapipe), when flight or a teleport emptied a page.
|
||||
pub struct Graveyard<T> {
|
||||
graves: VecDeque<Vec<T>>,
|
||||
}
|
||||
|
||||
impl<T> Default for Graveyard<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
graves: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl<T> Graveyard<T> {
|
||||
/// Released this frame.
|
||||
pub fn add(&mut self, items: impl IntoIterator<Item = T>) {
|
||||
if self.graves.is_empty() {
|
||||
self.graves.push_back(Vec::new());
|
||||
}
|
||||
if let Some(grave) = self.graves.back_mut() {
|
||||
grave.extend(items);
|
||||
}
|
||||
}
|
||||
/// A frame went by: what was released `GRAVE_FRAMES` frames ago, to free.
|
||||
pub fn advance(&mut self) -> Vec<T> {
|
||||
self.graves.push_back(Vec::new());
|
||||
if self.graves.len() > GRAVE_FRAMES {
|
||||
self.graves.pop_front().unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drops `items` on a short-lived thread of its own, as `chunk::free_columns` does for
|
||||
/// unloaded chunks; should the thread not start, they drop here.
|
||||
pub fn free_later<T: Send + 'static>(items: Vec<T>) {
|
||||
if items.is_empty() {
|
||||
return;
|
||||
}
|
||||
let _ = std::thread::Builder::new()
|
||||
.name("Buffer freeing".into())
|
||||
.spawn(move || drop(items));
|
||||
}
|
||||
|
||||
/// `create_buffer` with an out-of-memory failure returned instead of raised
|
||||
/// (raised errors are fatal).
|
||||
fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Option<wgpu::Buffer> {
|
||||
@@ -587,6 +644,24 @@ fn try_create(device: &wgpu::Device, size: u64, usage: wgpu::BufferUsages) -> Op
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A released page is handed back for freeing `GRAVE_FRAMES` frames later, in order.
|
||||
#[test]
|
||||
fn graveyard_frees_after_its_frames() {
|
||||
let mut graves = Graveyard::default();
|
||||
graves.add([1, 2]);
|
||||
let mut freed = Vec::new();
|
||||
for frame in 0..GRAVE_FRAMES + 2 {
|
||||
if frame == 1 {
|
||||
graves.add([3]);
|
||||
}
|
||||
freed.push(graves.advance());
|
||||
}
|
||||
let mut expected = vec![Vec::new(); GRAVE_FRAMES + 2];
|
||||
expected[GRAVE_FRAMES - 1] = vec![1, 2];
|
||||
expected[GRAVE_FRAMES] = vec![3];
|
||||
assert_eq!(freed, expected);
|
||||
}
|
||||
|
||||
fn device() -> Option<(wgpu::Device, wgpu::Queue)> {
|
||||
device_with(wgpu::Features::empty())
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//! Pipelines kept between the renderers built on one device. The start builds
|
||||
//! three (the boot screen's, the title artwork's, the world's) and a resource
|
||||
//! reload or a graphics option builds another; each compiled every shader
|
||||
//! module and pipeline again, though most come out identical (the boot and
|
||||
//! title renderers' entirely, the world's entity pipelines). On a driver that
|
||||
//! compiles slowly (a cold shader cache, llvmpipe, a weak CPU) that was seconds
|
||||
//! of the main thread before the title showed.
|
||||
//!
|
||||
//! An object is kept under its descriptor's `Debug` text: wgpu prints the
|
||||
//! labels, sources, constants, formats and states, and the handles a descriptor
|
||||
//! names (shader modules, layouts) by their ids, so a descriptor that names a
|
||||
//! kept handle matches only the same handle. Kept objects stay alive, so their
|
||||
//! ids are never reused while they are keys.
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Mutex, PoisonError},
|
||||
};
|
||||
|
||||
/// One map of kept objects with the generation each was last asked for in.
|
||||
struct Kept<T> {
|
||||
map: Mutex<HashMap<String, (T, u32)>>,
|
||||
}
|
||||
impl<T> Default for Kept<T> {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
map: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl<T: Clone> Kept<T> {
|
||||
/// The object kept under `key`, else `make`'s, kept. The lock is not
|
||||
/// held while `make` compiles.
|
||||
fn get(&self, key: String, generation: u32, make: impl FnOnce() -> T) -> T {
|
||||
if let Some((object, used)) = self
|
||||
.map
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.get_mut(&key)
|
||||
{
|
||||
*used = generation;
|
||||
return object.clone();
|
||||
}
|
||||
let object = make();
|
||||
self.map
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.insert(key, (object.clone(), generation));
|
||||
object
|
||||
}
|
||||
fn keep_only(&self, generation: u32) {
|
||||
self.map
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.retain(|_, (_, used)| *used == generation);
|
||||
}
|
||||
fn len(&self) -> usize {
|
||||
self.map
|
||||
.lock()
|
||||
.unwrap_or_else(PoisonError::into_inner)
|
||||
.len()
|
||||
}
|
||||
}
|
||||
|
||||
/// The shader modules, layouts and pipelines of the device's renderers.
|
||||
#[derive(Default)]
|
||||
pub struct Reuse {
|
||||
generation: std::sync::atomic::AtomicU32,
|
||||
shaders: Kept<wgpu::ShaderModule>,
|
||||
bind_group_layouts: Kept<wgpu::BindGroupLayout>,
|
||||
pipeline_layouts: Kept<wgpu::PipelineLayout>,
|
||||
render: Kept<wgpu::RenderPipeline>,
|
||||
}
|
||||
|
||||
impl Reuse {
|
||||
/// A renderer starts building: what it asks for is kept, the rest is let
|
||||
/// go by [`Reuse::finish`].
|
||||
pub fn begin(&self) -> u32 {
|
||||
self.generation
|
||||
.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
|
||||
.wrapping_add(1)
|
||||
}
|
||||
/// The renderer of `generation` is built: objects only older renderers
|
||||
/// used are dropped here (the renderers still alive hold their own).
|
||||
pub fn finish(&self, generation: u32) {
|
||||
self.shaders.keep_only(generation);
|
||||
self.bind_group_layouts.keep_only(generation);
|
||||
self.pipeline_layouts.keep_only(generation);
|
||||
self.render.keep_only(generation);
|
||||
}
|
||||
/// How many objects are kept.
|
||||
pub fn len(&self) -> usize {
|
||||
self.shaders.len()
|
||||
+ self.bind_group_layouts.len()
|
||||
+ self.pipeline_layouts.len()
|
||||
+ self.render.len()
|
||||
}
|
||||
fn current(&self) -> u32 {
|
||||
self.generation.load(std::sync::atomic::Ordering::Relaxed)
|
||||
}
|
||||
/// `device`'s `create_*` calls through this cache.
|
||||
pub fn on<'a>(&'a self, device: &'a wgpu::Device) -> On<'a> {
|
||||
On {
|
||||
reuse: self,
|
||||
device,
|
||||
generation: self.current(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A device whose shader modules, layouts and render pipelines come from a
|
||||
/// [`Reuse`] when one with the same descriptor is kept.
|
||||
pub struct On<'a> {
|
||||
reuse: &'a Reuse,
|
||||
device: &'a wgpu::Device,
|
||||
generation: u32,
|
||||
}
|
||||
impl On<'_> {
|
||||
pub fn create_shader_module(
|
||||
&self,
|
||||
descriptor: wgpu::ShaderModuleDescriptor<'_>,
|
||||
) -> wgpu::ShaderModule {
|
||||
let key = format!("{descriptor:?}");
|
||||
self.reuse.shaders.get(key, self.generation, || {
|
||||
self.device.create_shader_module(descriptor)
|
||||
})
|
||||
}
|
||||
pub fn create_bind_group_layout(
|
||||
&self,
|
||||
descriptor: &wgpu::BindGroupLayoutDescriptor<'_>,
|
||||
) -> wgpu::BindGroupLayout {
|
||||
let key = format!("{descriptor:?}");
|
||||
self.reuse.bind_group_layouts.get(key, self.generation, || {
|
||||
self.device.create_bind_group_layout(descriptor)
|
||||
})
|
||||
}
|
||||
pub fn create_pipeline_layout(
|
||||
&self,
|
||||
descriptor: &wgpu::PipelineLayoutDescriptor<'_>,
|
||||
) -> wgpu::PipelineLayout {
|
||||
let key = format!("{descriptor:?}");
|
||||
self.reuse.pipeline_layouts.get(key, self.generation, || {
|
||||
self.device.create_pipeline_layout(descriptor)
|
||||
})
|
||||
}
|
||||
pub fn create_render_pipeline(
|
||||
&self,
|
||||
descriptor: &wgpu::RenderPipelineDescriptor<'_>,
|
||||
) -> wgpu::RenderPipeline {
|
||||
let key = format!("{descriptor:?}");
|
||||
self.reuse.render.get(key, self.generation, || {
|
||||
self.device.create_render_pipeline(descriptor)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Kept;
|
||||
|
||||
/// A key asked for again gets the kept object without making one; what
|
||||
/// the latest generation did not ask for is let go when it finishes.
|
||||
#[test]
|
||||
fn kept_objects_are_shared_until_a_build_stops_asking() {
|
||||
let kept = Kept::<u32>::default();
|
||||
let made = std::cell::Cell::new(0);
|
||||
let make = |value| {
|
||||
made.set(made.get() + 1);
|
||||
value
|
||||
};
|
||||
assert_eq!(kept.get("a".into(), 1, || make(10)), 10);
|
||||
assert_eq!(kept.get("b".into(), 1, || make(20)), 20);
|
||||
kept.keep_only(1);
|
||||
assert_eq!(kept.len(), 2);
|
||||
// The next build asks for "a" only.
|
||||
assert_eq!(kept.get("a".into(), 2, || make(11)), 10);
|
||||
kept.keep_only(2);
|
||||
assert_eq!(kept.len(), 1);
|
||||
assert_eq!(kept.get("b".into(), 3, || make(21)), 21);
|
||||
assert_eq!(made.get(), 3);
|
||||
}
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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.
|
||||
@@ -596,8 +604,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.
|
||||
@@ -882,6 +891,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 {
|
||||
|
||||
@@ -663,24 +663,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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -250,16 +251,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.
|
||||
@@ -672,25 +680,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();
|
||||
@@ -703,6 +707,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> {
|
||||
@@ -1164,6 +1195,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.
|
||||
@@ -1898,7 +1932,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,
|
||||
@@ -2131,7 +2169,9 @@ impl HudRenderer {
|
||||
depth_pipeline,
|
||||
vignette_pipeline,
|
||||
end_portal_pipeline,
|
||||
layout,
|
||||
group,
|
||||
cit_glint: None,
|
||||
uniform,
|
||||
buffer,
|
||||
capacity,
|
||||
@@ -2141,6 +2181,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,
|
||||
@@ -2376,6 +2428,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 => {
|
||||
@@ -2836,6 +2894,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")]);
|
||||
@@ -2871,7 +2948,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",
|
||||
&[(
|
||||
@@ -2881,11 +2958,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() {
|
||||
@@ -3113,6 +3194,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"));
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -283,7 +283,7 @@ impl SectionMesh {
|
||||
pub const BOUNDS_PAD: f32 = 0.5;
|
||||
|
||||
/// Retain geometry capacity after an upload, but none of its old contents.
|
||||
pub(crate) fn clear(&mut self) {
|
||||
pub fn clear(&mut self) {
|
||||
self.solid.clear();
|
||||
self.translucent.clear();
|
||||
self.block_entities.clear();
|
||||
@@ -889,7 +889,7 @@ impl Padded {
|
||||
/// geometry still owned by the renderer, and total about 150 KiB per worker
|
||||
/// (the leaf mask adds 8,000 cells, depths and a queue).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct MeshScratch {
|
||||
pub struct MeshScratch {
|
||||
padded: Padded,
|
||||
own: Option<Box<[u32; 4096]>>,
|
||||
visibility: Vec<usize>,
|
||||
@@ -1144,7 +1144,9 @@ impl Mesher<'_> {
|
||||
&mut MeshScratch::default(),
|
||||
)
|
||||
}
|
||||
pub(crate) fn mesh_reusing(
|
||||
/// The meshing workers' path: `out`'s buffers and `scratch` are reused
|
||||
/// rather than allocated per section.
|
||||
pub fn mesh_reusing(
|
||||
&mut self,
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
sy: i32,
|
||||
|
||||
@@ -46,6 +46,30 @@ pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
|
||||
out
|
||||
}
|
||||
/// Consecutive steps of one function timed as spans: each [`Laps::lap`] records the time since
|
||||
/// the previous one (or since [`laps`]) under its name. Nothing is read while spans are off.
|
||||
pub struct Laps(Option<(Instant, Option<Duration>)>);
|
||||
/// Start timing the steps of a function (see [`Laps`]).
|
||||
#[inline]
|
||||
pub fn laps() -> Laps {
|
||||
Laps(enabled().then(|| (Instant::now(), thread_cpu())))
|
||||
}
|
||||
impl Laps {
|
||||
/// Record the step that just ended under `name`, and start the next.
|
||||
#[inline]
|
||||
pub fn lap(&mut self, name: &'static str) {
|
||||
let Some((started, cpu)) = &mut self.0 else {
|
||||
return;
|
||||
};
|
||||
let (now, now_cpu) = (Instant::now(), thread_cpu());
|
||||
let ms = (now - *started).as_secs_f32() * 1000.0;
|
||||
let cpu_ms = now_cpu
|
||||
.zip(*cpu)
|
||||
.map_or(ms, |(end, start)| (end - start).as_secs_f32() * 1000.0);
|
||||
SPANS.with(|s| s.borrow_mut().push(Span { name, ms, cpu_ms }));
|
||||
(*started, *cpu) = (now, now_cpu);
|
||||
}
|
||||
}
|
||||
/// The CPU time this thread has used, where the platform tells it: a tick
|
||||
/// with more wall time than CPU time waited (for a lock, or for a core on a
|
||||
/// busy machine).
|
||||
@@ -92,6 +116,161 @@ pub fn describe(spans: &[Span]) -> String {
|
||||
list.join(", ")
|
||||
}
|
||||
|
||||
/// The start's timeline: `HORIZON_STARTUP_TRACE=FILE` writes every named step of
|
||||
/// the start (resource tables, the renderer, the title, a world's open, a
|
||||
/// server's join, a dimension change) on whichever thread ran it, one Chrome
|
||||
/// trace event per line (`tools/startup_trace.py` reads it; wrapped in `[...]`,
|
||||
/// Perfetto opens it). Times are microseconds since the process started. Off,
|
||||
/// a step costs one relaxed load.
|
||||
pub mod startup {
|
||||
use std::{
|
||||
io::Write as _,
|
||||
sync::{
|
||||
Mutex, OnceLock,
|
||||
atomic::{AtomicBool, AtomicU32, Ordering},
|
||||
},
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
static ON: AtomicBool = AtomicBool::new(false);
|
||||
static SINK: OnceLock<Option<Mutex<std::fs::File>>> = OnceLock::new();
|
||||
static ORIGIN: OnceLock<Instant> = OnceLock::new();
|
||||
static NEXT_THREAD: AtomicU32 = AtomicU32::new(1);
|
||||
thread_local! {
|
||||
/// This thread's trace id, given (with its name) on its first event.
|
||||
static THREAD: std::cell::Cell<u32> = const { std::cell::Cell::new(0) };
|
||||
}
|
||||
|
||||
/// Opens the trace named by `HORIZON_STARTUP_TRACE`, timed from `origin`
|
||||
/// (the process's start). Call once, first thing.
|
||||
pub fn init(origin: Instant) {
|
||||
ORIGIN.get_or_init(|| origin);
|
||||
let sink = SINK.get_or_init(|| {
|
||||
let path = std::env::var_os("HORIZON_STARTUP_TRACE")?;
|
||||
std::fs::File::create(&path).ok().map(Mutex::new)
|
||||
});
|
||||
ON.store(sink.is_some(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
/// Whether a trace is being written.
|
||||
#[inline]
|
||||
pub fn on() -> bool {
|
||||
ON.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
fn micros(at: Instant) -> u128 {
|
||||
ORIGIN.get().map_or(0, |origin| {
|
||||
at.saturating_duration_since(*origin).as_micros()
|
||||
})
|
||||
}
|
||||
|
||||
fn write(event: std::fmt::Arguments) {
|
||||
let Some(Some(sink)) = SINK.get() else {
|
||||
return;
|
||||
};
|
||||
let tid = THREAD.with(|id| {
|
||||
if id.get() == 0 {
|
||||
id.set(NEXT_THREAD.fetch_add(1, Ordering::Relaxed));
|
||||
let name = std::thread::current()
|
||||
.name()
|
||||
.unwrap_or("unnamed")
|
||||
.replace(['"', '\\'], "_");
|
||||
let mut file = sink.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _ = writeln!(
|
||||
file,
|
||||
r#"{{"ph":"M","name":"thread_name","pid":1,"tid":{},"args":{{"name":"{name}"}}}}"#,
|
||||
id.get()
|
||||
);
|
||||
}
|
||||
id.get()
|
||||
});
|
||||
let mut file = sink
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let _ = writeln!(file, r#"{{"pid":1,"tid":{tid},{event}}}"#);
|
||||
}
|
||||
|
||||
/// A step running until it is dropped.
|
||||
#[must_use = "the step ends when this is dropped"]
|
||||
pub struct Step {
|
||||
name: &'static str,
|
||||
started: Instant,
|
||||
}
|
||||
impl Drop for Step {
|
||||
fn drop(&mut self) {
|
||||
let end = Instant::now();
|
||||
write(format_args!(
|
||||
r#""ph":"X","name":"{}","ts":{},"dur":{}"#,
|
||||
self.name,
|
||||
micros(self.started),
|
||||
end.saturating_duration_since(self.started).as_micros()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Starts a step named `name` (a literal: no quotes or backslashes).
|
||||
#[inline]
|
||||
pub fn step(name: &'static str) -> Option<Step> {
|
||||
on().then(|| Step {
|
||||
name,
|
||||
started: Instant::now(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Runs `f` as a step named `name`.
|
||||
#[inline]
|
||||
pub fn time<T>(name: &'static str, f: impl FnOnce() -> T) -> T {
|
||||
let _step = step(name);
|
||||
f()
|
||||
}
|
||||
|
||||
/// A moment of the start: the title drawn, the world in view.
|
||||
#[inline]
|
||||
pub fn mark(name: &'static str) {
|
||||
if on() {
|
||||
write(format_args!(
|
||||
r#""ph":"i","s":"g","name":"{name}","ts":{}"#,
|
||||
micros(Instant::now())
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// The first time `name` happens, as a mark. `once` is the caller's flag.
|
||||
#[inline]
|
||||
pub fn mark_once(once: &AtomicBool, name: &'static str) {
|
||||
if on() && !once.swap(true, Ordering::Relaxed) {
|
||||
mark(name);
|
||||
}
|
||||
}
|
||||
|
||||
/// Set while a world opens, a server is joined or the dimension changes,
|
||||
/// until the view first fills ([`view_filled`]).
|
||||
static FILLING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
/// A world starts loading: the next full view is marked.
|
||||
pub fn arm(name: &'static str) {
|
||||
if on() {
|
||||
mark(name);
|
||||
FILLING.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Called each frame in a world: marks `world.view_full` the first frame
|
||||
/// after [`arm`] where nothing waits to load, generate, light, mesh or
|
||||
/// upload and the resident columns cover perf_bench's share of the view
|
||||
/// (0.8 pi r^2: corners are never loaded).
|
||||
#[inline]
|
||||
pub fn view_filled(resident: usize, pending: usize, radius: u32) {
|
||||
if FILLING.load(Ordering::Relaxed)
|
||||
&& pending == 0
|
||||
&& resident as f64 >= 0.8 * std::f64::consts::PI * f64::from(radius * radius)
|
||||
&& FILLING.swap(false, Ordering::Relaxed)
|
||||
{
|
||||
mark("world.view_full");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::Span;
|
||||
@@ -138,6 +317,25 @@ mod tests {
|
||||
assert!(super::take().is_empty());
|
||||
}
|
||||
|
||||
/// Laps record the steps between them while spans are on, and nothing while off.
|
||||
#[test]
|
||||
fn laps_time_consecutive_steps() {
|
||||
let _ = super::take();
|
||||
let mut off = super::laps();
|
||||
off.lap("unseen");
|
||||
super::enable(true);
|
||||
let mut laps = super::laps();
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
laps.lap("first");
|
||||
laps.lap("second");
|
||||
super::enable(false);
|
||||
off.lap("still unseen");
|
||||
let spans = super::take();
|
||||
let names: Vec<_> = spans.iter().map(|s| s.name).collect();
|
||||
assert_eq!(names, ["first", "second"]);
|
||||
assert!(spans[0].ms >= 2.0);
|
||||
}
|
||||
|
||||
/// The thread's CPU clock moves with work done on it, not with time
|
||||
/// spent asleep.
|
||||
#[cfg(any(unix, all(windows, target_arch = "x86_64")))]
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -20,9 +20,6 @@ const MIN_SECONDS: f32 = 60.0;
|
||||
const CRASH_DETAILS: [&str; 3] = ["Horizon Version", "Graphics Adapter", "Render Settings"];
|
||||
|
||||
static STARTED: OnceLock<Instant> = OnceLock::new();
|
||||
/// When the menu bundle became ready, so the time to the title screen measures
|
||||
/// the menu and not the world bundle loaded behind it.
|
||||
static UI_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
static CORE_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
static REST_READY_MS: AtomicU32 = AtomicU32::new(u32::MAX);
|
||||
/// The GPU, once the renderer has its adapter: name, maker, kind, driver, backend, its own memory.
|
||||
@@ -46,14 +43,6 @@ pub fn mark_start() {
|
||||
STARTED.get_or_init(Instant::now);
|
||||
}
|
||||
|
||||
/// The menu bundle is ready: the title screen can draw. The first writer wins.
|
||||
pub fn mark_ui_ready() {
|
||||
if let Some(started) = STARTED.get() {
|
||||
let ms = started.elapsed().as_millis() as u32;
|
||||
let _ = UI_READY_MS.compare_exchange(u32::MAX, ms, Ordering::Relaxed, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mark_core_ready() {
|
||||
mark_ready(&CORE_READY_MS);
|
||||
}
|
||||
@@ -241,17 +230,18 @@ impl Session {
|
||||
pub fn title_shown(&mut self) {
|
||||
if !self.title_measured {
|
||||
self.title_measured = true;
|
||||
let ui_ms = UI_READY_MS.load(Ordering::Relaxed);
|
||||
self.to_title_ms = if ui_ms != u32::MAX {
|
||||
Some(ui_ms)
|
||||
} else {
|
||||
STARTED.get().map(|s| s.elapsed().as_millis() as u32)
|
||||
};
|
||||
crate::spans::startup::mark("title.shown");
|
||||
// The frame itself: the menu bundle can be ready seconds before
|
||||
// the renderer that draws it (pipelines compiling on a slow driver).
|
||||
self.to_title_ms = STARTED.get().map(|s| s.elapsed().as_millis() as u32);
|
||||
}
|
||||
}
|
||||
|
||||
/// A world starts loading.
|
||||
pub fn world_opening(&mut self) {
|
||||
if self.opening.is_none() {
|
||||
crate::spans::startup::arm("world.opening");
|
||||
}
|
||||
self.opening.get_or_insert_with(Instant::now);
|
||||
}
|
||||
|
||||
@@ -262,10 +252,11 @@ impl Session {
|
||||
|
||||
/// A frame drawn while playing (not in menus or loading).
|
||||
pub fn frame(&mut self, ms: f32) {
|
||||
if let Some(opening) = self.opening.take()
|
||||
&& self.to_world_ms.is_none()
|
||||
{
|
||||
self.to_world_ms = Some(opening.elapsed().as_millis() as u32);
|
||||
if let Some(opening) = self.opening.take() {
|
||||
crate::spans::startup::mark("world.first_frame");
|
||||
if self.to_world_ms.is_none() {
|
||||
self.to_world_ms = Some(opening.elapsed().as_millis() as u32);
|
||||
}
|
||||
}
|
||||
self.frames.record(ms);
|
||||
self.played_ms += ms;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -152,10 +152,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.": "“精简”把远景画得更粗略:在这块集成显卡上更快。",
|
||||
|
||||
@@ -5,13 +5,16 @@
|
||||
//!
|
||||
//! cargo run --release -p horizon-client --example chunk_load_bench -- \
|
||||
//! .local/horizon-client/26.3 [--seed 1234] [--center CX CZ] [--radius 8]
|
||||
//! [--threads N] [--rounds 3]
|
||||
//! [--threads N] [--rounds 3] [--mesh-threads N] [--json out.json]
|
||||
//!
|
||||
//! The default centre is the village of `tools/perf_bench.py`. The generation
|
||||
//! figure is wall time with N workers (the game's own pool); the mesh figure is
|
||||
//! this thread's CPU time in `Mesher::mesh`, the best of `--rounds`, so other
|
||||
//! work on the machine changes it less. The checksum of the meshes shows that an
|
||||
//! A/B pair of builds made the same ones.
|
||||
//! this thread's CPU time in the workers' path (`Mesher::mesh_reusing` with a
|
||||
//! recycled mesh and scratch, then `sort_solid`), each section's best of
|
||||
//! `--rounds`, so other work on the machine changes it less; then the wall time
|
||||
//! of `--mesh-threads` threads meshing them all at once. The checksum of the
|
||||
//! meshes (vertices and visibility) shows that an A/B pair of builds made the
|
||||
//! same ones.
|
||||
extern crate horizon_client_lib as horizon_client;
|
||||
use horizon_client::{
|
||||
assets::Assets,
|
||||
@@ -20,7 +23,7 @@ use horizon_client::{
|
||||
chunk::HEIGHT,
|
||||
graphics::LeafCullingMode,
|
||||
light::LightTables,
|
||||
mesher::{MeshTables, Mesher, Region, borrow},
|
||||
mesher::{MeshScratch, MeshTables, Mesher, Region, SectionMesh, TerrainVertex, borrow},
|
||||
models,
|
||||
spans::thread_cpu,
|
||||
streaming::{Streaming, WorkerContext},
|
||||
@@ -32,7 +35,10 @@ use horizon_world::registry::BlockStateId;
|
||||
use std::{
|
||||
hash::{DefaultHasher, Hash, Hasher},
|
||||
path::PathBuf,
|
||||
sync::Arc,
|
||||
sync::{
|
||||
Arc,
|
||||
atomic::{AtomicUsize, Ordering},
|
||||
},
|
||||
thread,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
@@ -131,43 +137,176 @@ fn main() -> Result<(), Error> {
|
||||
})
|
||||
.flat_map(|region| (0..HEIGHT / 16).map(move |sy| (region.clone(), sy)))
|
||||
.collect();
|
||||
let mut best = Duration::MAX;
|
||||
let (mut vertices, mut checksum) = (0, 0);
|
||||
for _ in 0..rounds.max(1) {
|
||||
let mut mesher = Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
zoom: BiomeZoom::new(obfuscate_seed(seed)),
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
biomes: &context.biomes,
|
||||
visibility: context.tables.visibility.clone(),
|
||||
};
|
||||
vertices = 0;
|
||||
let mut hash = DefaultHasher::new();
|
||||
let mut spent = Duration::ZERO;
|
||||
for (region, sy) in ®ions {
|
||||
let started = thread_cpu().ok_or("no thread CPU clock here")?;
|
||||
let mesh = mesher.mesh(&borrow(region), *sy);
|
||||
spent += thread_cpu().ok_or("no thread CPU clock here")? - started;
|
||||
vertices += mesh.solid.len() + mesh.translucent.len();
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.solid).hash(&mut hash);
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.translucent).hash(&mut hash);
|
||||
}
|
||||
checksum = hash.finish();
|
||||
best = best.min(spent);
|
||||
}
|
||||
let one = mesh_rounds(&context, seed, ®ions, rounds)?;
|
||||
let mut sorted = one.per_section.clone();
|
||||
sorted.sort_unstable();
|
||||
let pct = |p: f64| {
|
||||
let i = ((sorted.len() as f64 - 1.0) * p).round() as usize;
|
||||
sorted.get(i).map_or(0.0, |d| d.as_secs_f64() * 1e6)
|
||||
};
|
||||
let total: Duration = one.per_section.iter().sum();
|
||||
let filled: Vec<Duration> = one
|
||||
.per_section
|
||||
.iter()
|
||||
.zip(&one.vertices_per_section)
|
||||
.filter(|&(_, &v)| v > 0)
|
||||
.map(|(d, _)| *d)
|
||||
.collect();
|
||||
let filled_total: Duration = filled.iter().sum();
|
||||
let bytes = one.vertices * std::mem::size_of::<TerrainVertex>();
|
||||
println!(
|
||||
"meshed {} sections ({vertices} vertices, checksum {checksum:016x}) in {:.1} ms of CPU ({:.1} us per section)",
|
||||
"meshed {} sections ({} with geometry, {} vertices, {:.1} MB of vertices, checksum {:016x}) in {:.1} ms of CPU ({:.1} us per section)",
|
||||
regions.len(),
|
||||
best.as_secs_f64() * 1e3,
|
||||
best.as_secs_f64() * 1e6 / regions.len().max(1) as f64
|
||||
filled.len(),
|
||||
one.vertices,
|
||||
bytes as f64 / 1e6,
|
||||
one.checksum,
|
||||
total.as_secs_f64() * 1e3,
|
||||
total.as_secs_f64() * 1e6 / regions.len().max(1) as f64
|
||||
);
|
||||
println!(
|
||||
"per section (best of {rounds} rounds, us): p50 {:.1} p90 {:.1} p99 {:.1} max {:.1}; with geometry: mean {:.1}; {:.0} sections/s per core",
|
||||
pct(0.5),
|
||||
pct(0.9),
|
||||
pct(0.99),
|
||||
pct(1.0),
|
||||
filled_total.as_secs_f64() * 1e6 / filled.len().max(1) as f64,
|
||||
regions.len() as f64 / total.as_secs_f64().max(1e-9),
|
||||
);
|
||||
// Every worker meshing at once: the throughput a world being explored gets,
|
||||
// memory bandwidth and the allocator included.
|
||||
let mesh_threads: usize = flag(&args, "--mesh-threads", threads);
|
||||
let parallel = mesh_parallel(&context, seed, ®ions, mesh_threads)?;
|
||||
println!(
|
||||
"{mesh_threads} threads meshed {} sections in {:.1} ms of wall time: {:.0} sections/s",
|
||||
regions.len(),
|
||||
parallel.as_secs_f64() * 1e3,
|
||||
regions.len() as f64 / parallel.as_secs_f64().max(1e-9)
|
||||
);
|
||||
if let Some(path) = args
|
||||
.iter()
|
||||
.position(|a| a == "--json")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
{
|
||||
let json = format!(
|
||||
concat!(
|
||||
"{{\"sections\":{},\"filled\":{},\"vertices\":{},\"vertex_bytes\":{},",
|
||||
"\"checksum\":\"{:016x}\",\"cpu_ms\":{:.3},\"p50_us\":{:.2},\"p90_us\":{:.2},",
|
||||
"\"p99_us\":{:.2},\"max_us\":{:.2},\"filled_mean_us\":{:.2},",
|
||||
"\"mesh_threads\":{},\"parallel_per_s\":{:.1}}}\n"
|
||||
),
|
||||
regions.len(),
|
||||
filled.len(),
|
||||
one.vertices,
|
||||
bytes,
|
||||
one.checksum,
|
||||
total.as_secs_f64() * 1e3,
|
||||
pct(0.5),
|
||||
pct(0.9),
|
||||
pct(0.99),
|
||||
pct(1.0),
|
||||
filled_total.as_secs_f64() * 1e6 / filled.len().max(1) as f64,
|
||||
mesh_threads,
|
||||
regions.len() as f64 / parallel.as_secs_f64().max(1e-9),
|
||||
);
|
||||
std::fs::write(path, json)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mesher<'a>(context: &'a WorkerContext, seed: i64) -> Mesher<'a> {
|
||||
Mesher {
|
||||
nether_shading: false,
|
||||
shader_pack_lighting: false,
|
||||
zoom: BiomeZoom::new(obfuscate_seed(seed)),
|
||||
blend_radius: 2,
|
||||
smooth_lighting: true,
|
||||
cutout_leaves: true,
|
||||
translucent_layers: &[],
|
||||
leaf_culling: LeafCullingMode::Off,
|
||||
assets: &context.assets,
|
||||
render: &context.render,
|
||||
tables: &context.tables,
|
||||
biomes: &context.biomes,
|
||||
visibility: context.tables.visibility.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
struct Rounds {
|
||||
/// Each section's CPU time, the least of the rounds.
|
||||
per_section: Vec<Duration>,
|
||||
vertices_per_section: Vec<usize>,
|
||||
vertices: usize,
|
||||
checksum: u64,
|
||||
}
|
||||
|
||||
/// Meshes every section `rounds` times on this thread the way a worker does:
|
||||
/// one mesher (its face cache kept), one scratch, the mesh buffers recycled,
|
||||
/// and the solid quads sorted by side.
|
||||
fn mesh_rounds(
|
||||
context: &WorkerContext,
|
||||
seed: i64,
|
||||
regions: &[(Region, i32)],
|
||||
rounds: usize,
|
||||
) -> Result<Rounds, Error> {
|
||||
let mut mesher = mesher(context, seed);
|
||||
let mut scratch = MeshScratch::default();
|
||||
let mut recycled = SectionMesh::default();
|
||||
let mut per_section = vec![Duration::MAX; regions.len()];
|
||||
let mut vertices_per_section = vec![0; regions.len()];
|
||||
let mut checksum = 0;
|
||||
for _ in 0..rounds.max(1) {
|
||||
let mut hash = DefaultHasher::new();
|
||||
for (i, (region, sy)) in regions.iter().enumerate() {
|
||||
let started = thread_cpu().ok_or("no thread CPU clock here")?;
|
||||
let mut mesh = mesher.mesh_reusing(&borrow(region), *sy, recycled, &mut scratch);
|
||||
mesh.sort_solid();
|
||||
let spent = thread_cpu().ok_or("no thread CPU clock here")? - started;
|
||||
per_section[i] = per_section[i].min(spent);
|
||||
vertices_per_section[i] = mesh.solid.len() + mesh.translucent.len();
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.solid).hash(&mut hash);
|
||||
bytemuck::cast_slice::<_, u8>(&mesh.translucent).hash(&mut hash);
|
||||
mesh.visibility.hash(&mut hash);
|
||||
recycled = mesh;
|
||||
}
|
||||
checksum = hash.finish();
|
||||
}
|
||||
let vertices = vertices_per_section.iter().sum();
|
||||
Ok(Rounds {
|
||||
per_section,
|
||||
vertices_per_section,
|
||||
vertices,
|
||||
checksum,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wall time for `threads` workers to mesh every section once between them.
|
||||
fn mesh_parallel(
|
||||
context: &WorkerContext,
|
||||
seed: i64,
|
||||
regions: &[(Region, i32)],
|
||||
threads: usize,
|
||||
) -> Result<Duration, Error> {
|
||||
let next = AtomicUsize::new(0);
|
||||
let started = Instant::now();
|
||||
thread::scope(|scope| {
|
||||
for _ in 0..threads.max(1) {
|
||||
scope.spawn(|| {
|
||||
let mut mesher = mesher(context, seed);
|
||||
let mut scratch = MeshScratch::default();
|
||||
let mut recycled = SectionMesh::default();
|
||||
loop {
|
||||
let i = next.fetch_add(1, Ordering::Relaxed);
|
||||
let Some((region, sy)) = regions.get(i) else {
|
||||
break;
|
||||
};
|
||||
let mut mesh =
|
||||
mesher.mesh_reusing(&borrow(region), *sy, recycled, &mut scratch);
|
||||
mesh.sort_solid();
|
||||
recycled = mesh;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
Ok(started.elapsed())
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -214,6 +214,9 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
mcp: true, local: true),
|
||||
cmd!("status", Session, "status", "game, renderer and streaming state (the scripting socket's original summary)",
|
||||
"Also what F3 shows: the overlay flags, the renderer, chunk streaming and memory.", ["horizon-ctl status"], &[], mcp: true, local: false),
|
||||
cmd!("hello", Session, "hello", "the control protocol's version, the game's release and the command groups it serves",
|
||||
"Replies protocol (MAJOR.MINOR.PATCH of the control protocol, docs/api/stability.md), release (the game's version, the string MCP serverInfo carries too) and features (the command groups the game answers, so a script can test for one before calling it).",
|
||||
["horizon-ctl hello"], &[], mcp: true, local: false),
|
||||
cmd!("worlds", Session, "worlds", "the saved worlds, newest first: folder, name, mode, last played, version",
|
||||
"folder is what open, world-rename, world-backup and world-delete take.", ["horizon-ctl worlds"], &[], mcp: true, local: false),
|
||||
cmd!("world-rename", Session, "world-rename FOLDER NAME", "rename a world, as Edit World does (its folder stays)",
|
||||
@@ -444,7 +447,7 @@ pub const COMMANDS: &[Cmd] = &[
|
||||
"Add it to an MCP client as a stdio server: command horizon-ctl, args [\"mcp\"] (with --socket PATH first if needed). The screenshot tool returns the image.",
|
||||
["horizon-ctl mcp"], &[], mcp: false, local: true),
|
||||
cmd!("bench", Scripting, "bench SECONDS", "reset frame stats, wait, reply with the summary", "", ["horizon-ctl bench 10"], &[p("seconds", Ty::Num, "how long to measure")], mcp: true, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again")], mcp: true, local: false),
|
||||
cmd!("stats", Scripting, "stats [reset] [raw]", "frame-time summary since the last reset", "", ["horizon-ctl stats"], &[opt("reset", Ty::Bool, "start counting again"), opt("raw", Ty::Bool, "every frame time too (frame_ms, cpu_ms, gpu_ms)")], mcp: true, local: false),
|
||||
cmd!("gpu-passes", Scripting, "gpu-passes [on|off]", "time Horizon's own render passes on the GPU, shown in F3 and in stats and bench", "", ["horizon-ctl gpu-passes on"], &[opt("mode", Ty::Enum(&["on", "off"]), "on or off; without it, only reports")], mcp: false, local: false),
|
||||
cmd!("fly", Scripting, "fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]]", "fly the camera along a line, then reply with the frame summary",
|
||||
"The reply's tick_ms and tick_ms_max are the world's smoothed tick time over the flight (the server thread's when single player runs on one). With raw, the reply also lists every frame time in milliseconds (frame_ms).",
|
||||
|
||||
@@ -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_profiles, sprites, streaming,
|
||||
structure_block, tab_list, telemetry, test_block, test_instance_block, testgen, title_edition,
|
||||
trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
|
||||
@@ -3,8 +3,18 @@ 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());
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
let game_args = args.iter().take_while(|a| *a != "--dedicated");
|
||||
let help = game_args
|
||||
@@ -45,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}");
|
||||
|
||||
@@ -6,7 +6,6 @@ use crate::{
|
||||
items::{ItemStack, Items},
|
||||
providers::Providers,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// The furnace methods that read the recipes.
|
||||
pub trait FurnaceCooking {
|
||||
@@ -22,15 +21,15 @@ pub trait FurnaceCooking {
|
||||
/// (`Slot.setByPlayer`; partial takes and merges keep the stack).
|
||||
fn input_clicked(&mut self, recipes: &Recipes, before: Option<ItemStack>);
|
||||
/// One server tick. Returns `Some(lit)` when the LIT block state must change.
|
||||
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool>;
|
||||
}
|
||||
fn remainder(item: &str) -> Option<&'static str> {
|
||||
match item {
|
||||
"minecraft:lava_bucket" | "minecraft:water_bucket" | "minecraft:milk_bucket" => {
|
||||
Some("minecraft:bucket")
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
/// `drops` collects what the furnace lets fall at its position
|
||||
/// (`Containers.dropItemStack`): the remainder of a fuel when more of it is left.
|
||||
fn tick(
|
||||
&mut self,
|
||||
recipes: &Recipes,
|
||||
items: &Items,
|
||||
providers: &Providers,
|
||||
drops: &mut Vec<ItemStack>,
|
||||
) -> Option<bool>;
|
||||
}
|
||||
impl FurnaceCooking for Furnace {
|
||||
fn set_input(&mut self, recipes: &Recipes, stack: Option<ItemStack>) {
|
||||
@@ -59,7 +58,13 @@ impl FurnaceCooking for Furnace {
|
||||
self.set_input(recipes, after);
|
||||
}
|
||||
}
|
||||
fn tick(&mut self, recipes: &Recipes, items: &Items, providers: &Providers) -> Option<bool> {
|
||||
fn tick(
|
||||
&mut self,
|
||||
recipes: &Recipes,
|
||||
items: &Items,
|
||||
providers: &Providers,
|
||||
drops: &mut Vec<ItemStack>,
|
||||
) -> Option<bool> {
|
||||
let was_lit = self.lit_remaining > 0;
|
||||
let mut lit = false;
|
||||
if self.lit_remaining > 0 {
|
||||
@@ -101,16 +106,19 @@ impl FurnaceCooking for Furnace {
|
||||
(ratio * self.cooking_total as f32).ceil() as i32;
|
||||
}
|
||||
if time > 0 {
|
||||
// AbstractFurnaceBlockEntity.consumeFuel: the fuel's crafting
|
||||
// remainder takes the slot once the stack is used up, else
|
||||
// it is dropped.
|
||||
if let Some(f) = self.items[1].as_mut() {
|
||||
let item = f.item.clone();
|
||||
let remainder = items
|
||||
.get(&f.item)
|
||||
.and_then(|i| i.crafting_remainder.as_deref())
|
||||
.map(|r| ItemStack::new(r, 1));
|
||||
f.count -= 1;
|
||||
if f.count == 0 {
|
||||
self.items[1] = remainder(&item).map(|r| ItemStack {
|
||||
item: Arc::from(r),
|
||||
count: 1,
|
||||
damage: 0,
|
||||
extra: None,
|
||||
});
|
||||
self.items[1] = remainder;
|
||||
} else if let Some(remainder) = remainder {
|
||||
drops.push(remainder);
|
||||
}
|
||||
}
|
||||
lit = true;
|
||||
@@ -171,3 +179,98 @@ fn total_time(time: u32, speed: f32) -> i32 {
|
||||
time as i32
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::{containers::FurnaceKind, items::Items};
|
||||
use horizon_world::loot::PackOverlay;
|
||||
use std::{collections::HashMap, fs};
|
||||
|
||||
/// A furnace with `fuel` x `count` burning `test:ore` into `test:ingot`, over a
|
||||
/// data root holding that one recipe and an item table with two fuels: `test:coal`
|
||||
/// (no remainder) and `test:cell` (a content mod's item that leaves `test:husk`).
|
||||
fn burn(fuel: &str, count: u32) -> (Furnace, Vec<ItemStack>) {
|
||||
let root = std::env::temp_dir().join(format!(
|
||||
"horizon-furnace-remainder-{}-{}-{count}",
|
||||
std::process::id(),
|
||||
fuel.replace(':', "-")
|
||||
));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
|
||||
fs::write(
|
||||
root.join("data/minecraft/recipe/test_ingot.json"),
|
||||
br#"{"type":"minecraft:smelting","ingredient":"test:ore","result":{"id":"test:ingot"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::create_dir_all(root.join("data/minecraft/loot_table")).unwrap();
|
||||
let fuel_component =
|
||||
r#"{"minecraft:cooking_fuel": {"burn_time": 200, "speed_multiplier": 1.0}}"#;
|
||||
fs::write(
|
||||
root.join("items.json"),
|
||||
format!(
|
||||
r#"{{"test:ore": {{}}, "test:ingot": {{}}, "test:coal": {fuel_component}, "test:cell": {fuel_component}, "test:husk": {{}}}}"#
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join(horizon_items::items::MOD_CRAFT_REMAINDERS),
|
||||
r#"{"test:cell": "test:husk"}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let items = Items::catalog(&root, &PackOverlay::NONE).unwrap();
|
||||
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
|
||||
let providers = Providers::load(&root);
|
||||
let mut furnace = Furnace {
|
||||
kind: FurnaceKind::Furnace,
|
||||
items: [
|
||||
Some(ItemStack::new("test:ore", 1)),
|
||||
Some(ItemStack::new(fuel, count)),
|
||||
None,
|
||||
],
|
||||
lit_remaining: 0,
|
||||
lit_total: 0,
|
||||
cooking_timer: 0,
|
||||
cooking_total: 0,
|
||||
speed: 1.0,
|
||||
recipes_used: HashMap::new(),
|
||||
};
|
||||
let mut drops = Vec::new();
|
||||
let lit = furnace.tick(&recipes, &items, &providers, &mut drops);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
assert_eq!(lit, Some(true), "the fuel lit the furnace");
|
||||
(furnace, drops)
|
||||
}
|
||||
|
||||
fn fuel_slot(furnace: &Furnace) -> Option<(&str, u32)> {
|
||||
furnace.items[1].as_ref().map(|s| (&*s.item, s.count))
|
||||
}
|
||||
|
||||
/// `AbstractFurnaceBlockEntity.consumeFuel`: the last of a fuel with a remainder
|
||||
/// leaves it in the fuel slot.
|
||||
#[test]
|
||||
fn the_last_fuel_leaves_its_remainder_in_the_slot() {
|
||||
let (furnace, drops) = burn("test:cell", 1);
|
||||
assert_eq!(fuel_slot(&furnace), Some(("test:husk", 1)));
|
||||
assert!(drops.is_empty());
|
||||
}
|
||||
|
||||
/// With more of the fuel left, the remainder is dropped at the furnace.
|
||||
#[test]
|
||||
fn a_fuel_still_in_the_stack_drops_its_remainder() {
|
||||
let (furnace, drops) = burn("test:cell", 3);
|
||||
assert_eq!(fuel_slot(&furnace), Some(("test:cell", 2)));
|
||||
assert_eq!(drops, vec![ItemStack::new("test:husk", 1)]);
|
||||
}
|
||||
|
||||
/// A fuel without a remainder leaves nothing.
|
||||
#[test]
|
||||
fn a_fuel_without_a_remainder_leaves_nothing() {
|
||||
let (furnace, drops) = burn("test:coal", 1);
|
||||
assert_eq!(fuel_slot(&furnace), None);
|
||||
assert!(drops.is_empty());
|
||||
let (furnace, drops) = burn("test:coal", 2);
|
||||
assert_eq!(fuel_slot(&furnace), Some(("test:coal", 1)));
|
||||
assert!(drops.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -328,6 +329,7 @@ mod world_loading;
|
||||
mod zombie_reinforcements;
|
||||
mod zombie_villagers;
|
||||
use event_loop::EventLoopCtl;
|
||||
use horizon_client::spans::startup;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
assets::{Assets, RenderAssets},
|
||||
@@ -915,6 +917,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,
|
||||
@@ -994,6 +999,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
|
||||
@@ -1053,6 +1070,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.
|
||||
@@ -1924,9 +1942,22 @@ impl UiResources {
|
||||
/// From a resource root layering packs over `defaults`, the game's own
|
||||
/// resources (`root` itself when no pack is on).
|
||||
pub fn load(root: &std::path::Path, defaults: &std::path::Path) -> Result<Self> {
|
||||
Ok(Self {
|
||||
atlas: HudAtlas::build_over(root, defaults)?,
|
||||
panorama: Arc::new(horizon_client::gfx::panorama::Faces::load(root, defaults)),
|
||||
// The title screen waits for both: the panorama's six faces decode
|
||||
// beside the atlas.
|
||||
std::thread::scope(|scope| {
|
||||
let panorama = scope.spawn(|| {
|
||||
startup::time("ui.panorama", || {
|
||||
horizon_client::gfx::panorama::Faces::load(root, defaults)
|
||||
})
|
||||
});
|
||||
let atlas = startup::time("ui.atlas", || HudAtlas::build_over(root, defaults));
|
||||
let panorama = panorama
|
||||
.join()
|
||||
.unwrap_or_else(|panic| std::panic::resume_unwind(panic));
|
||||
Ok(Self {
|
||||
atlas: atlas?,
|
||||
panorama: Arc::new(panorama),
|
||||
})
|
||||
})
|
||||
}
|
||||
/// What a server's game holds, since it draws nothing: the font, which books
|
||||
@@ -2612,6 +2643,8 @@ struct Game<'a> {
|
||||
crystal_model: Option<horizon_client::mobs::Part>,
|
||||
/// CushionModel's baked layer.
|
||||
cushion_model: Option<horizon_client::mobs::Part>,
|
||||
/// Models drawn only once such an entity shows up (lead knots, wind charges...).
|
||||
entity_models: EntityModels,
|
||||
carts: Vec<minecarts::Minecart>,
|
||||
/// Songs playing from jukeboxes (tracked sound handles).
|
||||
jukebox_sounds: HashMap<(i32, i32, i32), u64>,
|
||||
@@ -2743,6 +2776,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).
|
||||
@@ -2858,6 +2892,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.
|
||||
@@ -3706,13 +3746,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,
|
||||
@@ -3720,8 +3763,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,
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -4162,6 +4205,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,
|
||||
@@ -4170,6 +4219,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,
|
||||
@@ -4180,6 +4230,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,
|
||||
@@ -4222,7 +4273,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,
|
||||
@@ -10408,6 +10459,7 @@ impl Game<'_> {
|
||||
let mut crafted = 0;
|
||||
while let Some((recipe, result)) = self.craft_recipe() {
|
||||
let made = (result.item.clone(), result.count);
|
||||
let taken = result.clone();
|
||||
let cells = self.craft_size * self.craft_size;
|
||||
let ingredients = self.craft_grid[..cells].to_vec();
|
||||
let remainders = world_recipes!(self).remainders(
|
||||
@@ -10448,6 +10500,10 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
crafted += 1;
|
||||
// `ResultSlot.onTake`: the recipe and the stack taken
|
||||
// (native_item_crafted).
|
||||
let player = self.native_acting_id();
|
||||
self.native_item_crafted(player, &format!("minecraft:{recipe}"), &taken);
|
||||
self.stats.add("crafted", &made.0, made.1 as i32);
|
||||
self.award_crafted(&[(format!("minecraft:{recipe}"), ingredients)]);
|
||||
if !shift || crafted >= 64 {
|
||||
@@ -10546,6 +10602,8 @@ impl Game<'_> {
|
||||
Vec<[i32; 3]>,
|
||||
StaticCacheFrameStats,
|
||||
) {
|
||||
// Its steps as spans (HORIZON_SPIKES): each lap names the step that just ended.
|
||||
let mut laps = horizon_client::spans::laps();
|
||||
let mut static_block_entities = std::sync::Arc::from([]);
|
||||
let mut visible_static_block_entity_sections = Vec::new();
|
||||
let mut static_block_entity_stats = StaticCacheFrameStats::default();
|
||||
@@ -10564,6 +10622,7 @@ impl Game<'_> {
|
||||
} else {
|
||||
self.yaw
|
||||
};
|
||||
laps.lap("eg_prep");
|
||||
for m in &self.mobs {
|
||||
// Blaze.isOnFire: a charged blaze burns.
|
||||
let blazing = m.species() == horizon_client::mobs::Species::Blaze && m.extra.flag;
|
||||
@@ -10608,6 +10667,7 @@ impl Game<'_> {
|
||||
&mut out,
|
||||
);
|
||||
}
|
||||
laps.lap("eg_fire_mobs");
|
||||
for item in &self.dropped {
|
||||
if item.fire_ticks > 0
|
||||
&& !self
|
||||
@@ -10632,6 +10692,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
laps.lap("eg_fire_items");
|
||||
for stand in &self.armor_stands {
|
||||
if stand.fire_ticks > 0 && !stand.marker {
|
||||
let scale = if stand.small { 0.5 } else { 1.0 };
|
||||
@@ -10652,6 +10713,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
laps.lap("eg_fire_stands");
|
||||
// `ItemEntityRenderer`.
|
||||
self.mark_entity(out.len(), "minecraft:item");
|
||||
self.dropped_item_geometry(camera, alpha, &mut out);
|
||||
@@ -10666,9 +10728,11 @@ impl Game<'_> {
|
||||
self.shelf_geometry(camera, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:ominous_item_spawner");
|
||||
self.ominous_item_spawner_geometry(camera, alpha, &mut out);
|
||||
laps.lap("eg_items_misc");
|
||||
// Mobs and their projectiles (a span each).
|
||||
self.mob_geometry(camera, alpha, &mut out);
|
||||
self.mark_motion(out.len(), 0);
|
||||
laps.lap("eg_mobs");
|
||||
// In first person only a shader pack's shadow pass draws the player.
|
||||
self.mark_player(out.len(), !self.own_body_in_view());
|
||||
let player_start = out.len();
|
||||
@@ -10685,6 +10749,7 @@ impl Game<'_> {
|
||||
self.mark_entity(out.len(), "minecraft:evoker_fangs");
|
||||
self.fangs_geometry(camera, alpha, &mut out);
|
||||
self.block_item_display_geometry(camera, alpha, &mut out);
|
||||
laps.lap("eg_players");
|
||||
let mut see_through = std::mem::take(&mut self.see_through_vertices);
|
||||
see_through.clear();
|
||||
self.name_tag_geometry(camera, alpha, &mut out, &mut see_through);
|
||||
@@ -10692,6 +10757,7 @@ impl Game<'_> {
|
||||
self.debug_renderer_geometry(camera, &mut out);
|
||||
self.text_display_geometry(camera, alpha, &mut out, &mut see_through);
|
||||
self.see_through_vertices = see_through;
|
||||
laps.lap("eg_name_tags");
|
||||
// Entity shadows (EntityRenderer.extractShadow).
|
||||
self.mark_translucent_entity(out.len(), "minecraft:entity_shadow");
|
||||
// A native shader pack's sun shadows already fall under the entities in the Overworld:
|
||||
@@ -10757,6 +10823,7 @@ impl Game<'_> {
|
||||
);
|
||||
}
|
||||
}
|
||||
laps.lap("eg_shadows");
|
||||
// Block entities (BlockEntityRenderers): chests, shulker boxes, banners, skulls...
|
||||
if let Some(r) = &self.renderer {
|
||||
let lid = |p: [i32; 3]| self.chest_lid(p, alpha as f32);
|
||||
@@ -10902,6 +10969,7 @@ impl Game<'_> {
|
||||
.render_breaking(&scene, pos, state.0, stage, &mut out);
|
||||
}
|
||||
}
|
||||
laps.lap("eg_block_entities");
|
||||
// Experience orbs (ExperienceOrbRenderer): pulsing billboards from a 4x4 icon sheet.
|
||||
self.mark_entity(out.len(), "minecraft:experience_orb");
|
||||
if let Some(r) = self
|
||||
@@ -10971,6 +11039,7 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
laps.lap("eg_orbs");
|
||||
// Falling blocks use their baked model, including non-cubic shapes.
|
||||
self.mark_entity(out.len(), "minecraft:falling_block");
|
||||
for f in &self.falling {
|
||||
@@ -11035,6 +11104,7 @@ impl Game<'_> {
|
||||
self.rocket_geometry(camera, alpha, &mut out);
|
||||
self.mark_entity(out.len(), "minecraft:splash_potion");
|
||||
self.thrown_potion_geometry(camera, alpha, &mut out);
|
||||
laps.lap("eg_objects");
|
||||
// Particles (ParticleEngine.extract): camera-facing quads.
|
||||
self.mark_stage(out.len(), "PARTICLES");
|
||||
let world = &self.world;
|
||||
@@ -11046,6 +11116,7 @@ impl Game<'_> {
|
||||
&|b| world.light(b[0], b[1], b[2]),
|
||||
&mut out,
|
||||
);
|
||||
laps.lap("eg_particles");
|
||||
// Breaking progress overlays (destroy_stage_0..9): ours and the other players'.
|
||||
self.mark_stage(out.len(), "DESTROY");
|
||||
for &(pos, stage) in &cracks {
|
||||
@@ -11076,7 +11147,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
}
|
||||
laps.lap("eg_cracks");
|
||||
self.finish_async_entity_culling();
|
||||
laps.lap("eg_culling_finish");
|
||||
(
|
||||
out,
|
||||
static_block_entities,
|
||||
@@ -11604,6 +11677,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();
|
||||
@@ -12407,6 +12483,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) {
|
||||
@@ -13840,7 +13917,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,
|
||||
@@ -14966,7 +15048,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);
|
||||
@@ -14977,6 +15061,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();
|
||||
@@ -16464,6 +16549,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
attributes, "horizon", "horizon",
|
||||
);
|
||||
// `--offscreen` renders into a texture: its window would only show white.
|
||||
let window_step = startup::step("window.create");
|
||||
let window = match event_loop.create_window(
|
||||
attributes
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
|
||||
@@ -16477,6 +16563,7 @@ impl ApplicationHandler for Game<'_> {
|
||||
return;
|
||||
}
|
||||
};
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
match self.new_renderer(window.clone()) {
|
||||
Ok(r) => {
|
||||
@@ -17679,8 +17766,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),
|
||||
@@ -17925,6 +18013,15 @@ impl Game<'_> {
|
||||
self.perf.title_shown();
|
||||
} else if self.screen == Screen::Playing && self.player.is_some() {
|
||||
self.perf.frame(elapsed.as_secs_f32() * 1000.0);
|
||||
if startup::on() {
|
||||
startup::view_filled(
|
||||
self.world.chunks.len(),
|
||||
self.streaming.pending()
|
||||
+ self.pending_uploads.len()
|
||||
+ self.remote_backlog(),
|
||||
self.streaming.radius.max(0) as u32,
|
||||
);
|
||||
}
|
||||
self.world_frames += 1;
|
||||
}
|
||||
self.frames += 1;
|
||||
@@ -18387,6 +18484,7 @@ impl<'a> Game<'a> {
|
||||
boat_models: prepared.boat_models,
|
||||
crystal_model: prepared.crystal_model,
|
||||
cushion_model: prepared.cushion_model,
|
||||
entity_models: prepared.entity_models,
|
||||
carts: Vec::new(),
|
||||
jukebox_sounds: HashMap::new(),
|
||||
bolts: Vec::new(),
|
||||
@@ -18472,6 +18570,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,
|
||||
@@ -18528,6 +18627,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(),
|
||||
@@ -18593,8 +18694,41 @@ struct GameCpuResources {
|
||||
arrow_model: Option<horizon_client::mobs::Part>,
|
||||
bee_stinger_model: Option<horizon_client::mobs::Part>,
|
||||
trident_model: Option<horizon_client::mobs::Part>,
|
||||
entity_models: EntityModels,
|
||||
creative_screen: Creative,
|
||||
}
|
||||
/// Baked layers of entities that may first show up deep into a session. They come from the
|
||||
/// document parsed once at startup: parsing entity-models.json (2.4 MB) when the first one
|
||||
/// appeared stalled that frame by 40 ms of CPU and the file read.
|
||||
#[derive(Default)]
|
||||
struct EntityModels {
|
||||
/// LeashKnotModel.
|
||||
leash_knot: Option<horizon_client::mobs::Part>,
|
||||
/// WindChargeModel.
|
||||
wind_charge: Option<horizon_client::mobs::Part>,
|
||||
/// ShulkerBulletModel.
|
||||
shulker_bullet: Option<horizon_client::mobs::Part>,
|
||||
/// SkullModel of the wither skull.
|
||||
wither_skull: Option<horizon_client::mobs::Part>,
|
||||
/// EvokerFangsModel.
|
||||
evoker_fangs: Option<Arc<horizon_client::mobs::Part>>,
|
||||
/// BannerModel's pole and flag (a raid captain's banner).
|
||||
banner: Option<(horizon_client::mobs::Part, horizon_client::mobs::Part)>,
|
||||
}
|
||||
impl EntityModels {
|
||||
fn load(document: Option<&horizon_client::mobs::EntityModelDocument>) -> Self {
|
||||
let model = |key| document.and_then(|document| document.model(key));
|
||||
Self {
|
||||
leash_knot: model("minecraft:leash_knot#main"),
|
||||
wind_charge: model("minecraft:wind_charge#main"),
|
||||
shulker_bullet: model("minecraft:shulker_bullet#main"),
|
||||
wither_skull: model("minecraft:wither_skull#main"),
|
||||
evoker_fangs: model("minecraft:evoker_fangs#main").map(Arc::new),
|
||||
banner: model("minecraft:standing_banner#main")
|
||||
.zip(model("minecraft:standing_banner#flag")),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl GameCpuResources {
|
||||
fn load(root: &std::path::Path) -> Result<Self> {
|
||||
let document = horizon_client::mobs::EntityModelDocument::load(root).ok();
|
||||
@@ -18615,6 +18749,7 @@ impl GameCpuResources {
|
||||
arrow_model: model("minecraft:arrow#main"),
|
||||
bee_stinger_model: model("minecraft:bee_stinger#main"),
|
||||
trident_model: model("minecraft:trident#main"),
|
||||
entity_models: EntityModels::load(document.as_ref()),
|
||||
creative_screen: Creative::load(root)?,
|
||||
})
|
||||
}
|
||||
@@ -19139,7 +19274,7 @@ fn load_core(
|
||||
parallel: bool,
|
||||
progress: &Progress,
|
||||
) -> std::result::Result<std::result::Result<CoreTables, String>, String> {
|
||||
let assets = match Assets::load(root) {
|
||||
let assets = match startup::time("core.assets", || Assets::load(root)) {
|
||||
Ok(assets) => assets,
|
||||
Err(e) => return Ok(Err(e.to_string())),
|
||||
};
|
||||
@@ -19151,18 +19286,22 @@ fn load_core(
|
||||
let render = Step::spawn(scope, parallel, move || {
|
||||
// A server reads no block sprites' pixels, which only the
|
||||
// renderer draws (32 MB).
|
||||
let render = if headless {
|
||||
assets.reload_for_server(root)
|
||||
} else {
|
||||
assets.reload(root)
|
||||
}
|
||||
let render = startup::time("core.block_models_sprites", || {
|
||||
if headless {
|
||||
assets.reload_for_server(root)
|
||||
} else {
|
||||
assets.reload(root)
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string())?;
|
||||
let library = render.model_library.clone();
|
||||
let item_renderer = if headless {
|
||||
horizon_client::item_render::ItemRenderer::load_for_server(root, library)
|
||||
} else {
|
||||
horizon_client::item_render::ItemRenderer::load_with_library(root, library)
|
||||
}
|
||||
let item_renderer = startup::time("core.item_models", || {
|
||||
if headless {
|
||||
horizon_client::item_render::ItemRenderer::load_for_server(root, library)
|
||||
} else {
|
||||
horizon_client::item_render::ItemRenderer::load_with_library(root, library)
|
||||
}
|
||||
})
|
||||
.map_err(|e| e.to_string());
|
||||
Ok((render, item_renderer))
|
||||
});
|
||||
@@ -19172,16 +19311,21 @@ fn load_core(
|
||||
if headless {
|
||||
return Ok(horizon_world::block_render::FaceVisibility::empty());
|
||||
}
|
||||
models::load_visibility(root, registry).map_err(|e| e.to_string())
|
||||
startup::time("core.visibility", || {
|
||||
models::load_visibility(root, registry)
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let biomes = Step::spawn(scope, parallel, || {
|
||||
Biomes::load(root).map_err(|e| e.to_string())
|
||||
startup::time("core.biomes", || Biomes::load(root)).map_err(|e| e.to_string())
|
||||
});
|
||||
let environment = Step::spawn(scope, parallel, || {
|
||||
Environment::load(root).map_err(|e| e.to_string())
|
||||
startup::time("core.environment", || Environment::load(root))
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let collision = Step::spawn(scope, parallel, || {
|
||||
collisions::load(root, registry).map_err(|e| e.to_string())
|
||||
startup::time("core.collision", || collisions::load(root, registry))
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let gameplay = gameplay_tables(root, assets, parallel, progress);
|
||||
(
|
||||
@@ -19219,6 +19363,7 @@ fn gameplay_tables(
|
||||
parallel: bool,
|
||||
progress: &Progress,
|
||||
) -> std::result::Result<GameplayTables, String> {
|
||||
let facts_step = startup::step("core.block_facts");
|
||||
let behaviour_json: serde_json::Value = serde_json::from_reader(std::io::BufReader::new(
|
||||
std::fs::File::open(root.join("block-behaviours.json")).map_err(|e| e.to_string())?,
|
||||
))
|
||||
@@ -19232,13 +19377,19 @@ fn gameplay_tables(
|
||||
.iter()
|
||||
.any(|s| !s.name.starts_with("minecraft:")),
|
||||
);
|
||||
drop(facts_step);
|
||||
progress.set(2);
|
||||
let (light, motion) = std::thread::scope(|scope| {
|
||||
let light = Step::spawn(scope, parallel, || {
|
||||
LightTables::load(root, assets.registry, &facts).map_err(|e| e.to_string())
|
||||
startup::time("core.light", || {
|
||||
LightTables::load(root, assets.registry, &facts)
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
});
|
||||
let motion =
|
||||
MotionResources::load_with_facts(root, assets, &facts).map_err(|e| e.to_string());
|
||||
let motion = startup::time("core.motion", || {
|
||||
MotionResources::load_with_facts(root, assets, &facts)
|
||||
})
|
||||
.map_err(|e| e.to_string());
|
||||
(light.join(), motion)
|
||||
});
|
||||
Ok(GameplayTables {
|
||||
@@ -19275,6 +19426,7 @@ fn load_resources(
|
||||
// A dedicated server builds only the font (UiResources::server), as the
|
||||
// integrated server's game does: the full atlas and panorama are 144 MB.
|
||||
let load_ui = |root: &std::path::Path| {
|
||||
let _step = startup::step("ui.load");
|
||||
if headless {
|
||||
UiResources::server(root)
|
||||
} else {
|
||||
@@ -19288,7 +19440,6 @@ fn load_resources(
|
||||
let parallel = threads > 1;
|
||||
let mut offer = |ui: UiResources, root: &std::path::Path| {
|
||||
if menu {
|
||||
horizon_client::telemetry::mark_ui_ready();
|
||||
log::info!(
|
||||
"Horizon: UI bundle ready after {} ms",
|
||||
started.elapsed().as_millis()
|
||||
@@ -19302,7 +19453,7 @@ fn load_resources(
|
||||
let (core, loaded_gui) = loop {
|
||||
let (ui, core) = std::thread::scope(|scope| {
|
||||
let core = Step::spawn(scope, parallel, || {
|
||||
load_core(&root, headless, parallel, progress)
|
||||
startup::time("core", || load_core(&root, headless, parallel, progress))
|
||||
});
|
||||
let ui = load_ui(&root).map(|ui| offer(ui, &root));
|
||||
(ui, core.join())
|
||||
@@ -19351,7 +19502,10 @@ fn load_resources(
|
||||
if let Some(folder) = launch_world {
|
||||
world_loading::preload_launch_world(folder, &root, assets.registry, biomes.clone());
|
||||
}
|
||||
let tables = Arc::new(MeshTables::new(&assets, &render, &facts, visibility)?);
|
||||
let tables = Arc::new(startup::time("pack.mesh_tables", || {
|
||||
MeshTables::new(&assets, &render, &facts, visibility)
|
||||
})?);
|
||||
let pack_step = startup::step("pack.assemble");
|
||||
let pack = PackResources::assemble(
|
||||
&root,
|
||||
assets.clone(),
|
||||
@@ -19361,6 +19515,7 @@ fn load_resources(
|
||||
biomes.clone(),
|
||||
Arc::new(light),
|
||||
);
|
||||
drop(pack_step);
|
||||
log::info!("load pack tables: {:.2}s", started.elapsed().as_secs_f32());
|
||||
progress.set(4);
|
||||
if !menu {
|
||||
@@ -19390,17 +19545,6 @@ fn load_resources(
|
||||
started.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
// Finish fallible CPU preparation before starting the tail, so an error
|
||||
// cannot detach a tail worker that races the next pack-loader generation.
|
||||
let prepared_at = Instant::now();
|
||||
let game_cpu = menu.then(|| GameCpuResources::load(&root)).transpose()?;
|
||||
let renderer_cpu = menu.then(|| prepare_renderer_textures(&pack));
|
||||
if menu {
|
||||
log::info!(
|
||||
"Horizon: handoff CPU resources prepared on worker in {} ms",
|
||||
prepared_at.elapsed().as_millis()
|
||||
);
|
||||
}
|
||||
// The block entity renderer's face visibility: the meshes' own, whose
|
||||
// shapes already hold their faces, not a second load of the same tables.
|
||||
let visibility = pack.context.tables.visibility.clone();
|
||||
@@ -19412,6 +19556,7 @@ fn load_resources(
|
||||
let handle = std::thread::Builder::new()
|
||||
.name("world-rest-loader".into())
|
||||
.spawn(move || {
|
||||
let _step = startup::step("rest");
|
||||
let rest = load_world_rest(
|
||||
&root,
|
||||
&resources,
|
||||
@@ -19458,6 +19603,37 @@ fn load_resources(
|
||||
.map_err(|_| "world resources already loaded")?;
|
||||
None
|
||||
};
|
||||
// The world tables load (above) while the handoff's CPU work is done: the
|
||||
// world renderer's textures beside the game's own tables. A failure here
|
||||
// waits for the tail, so no tail worker outlives its pack-loader generation.
|
||||
let prepared_at = Instant::now();
|
||||
let (game_cpu, renderer_cpu) = if menu {
|
||||
let (game_cpu, renderer_cpu) = std::thread::scope(|scope| {
|
||||
let textures = Step::spawn(scope, parallel, || {
|
||||
startup::time("handoff.renderer_textures", || {
|
||||
prepare_renderer_textures(&pack)
|
||||
})
|
||||
});
|
||||
let game_cpu = startup::time("handoff.game_cpu", || GameCpuResources::load(&root));
|
||||
(game_cpu, textures.join())
|
||||
});
|
||||
let game_cpu = match game_cpu {
|
||||
Ok(game_cpu) => game_cpu,
|
||||
Err(error) => {
|
||||
if let Some(tail) = rest_loader {
|
||||
let _ = tail.handle.join();
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
log::info!(
|
||||
"Horizon: handoff CPU resources prepared on worker in {} ms",
|
||||
prepared_at.elapsed().as_millis()
|
||||
);
|
||||
(Some(game_cpu), Some(renderer_cpu))
|
||||
} else {
|
||||
(None, None)
|
||||
};
|
||||
Ok(Loaded {
|
||||
game_cpu,
|
||||
renderer_cpu,
|
||||
@@ -19492,22 +19668,29 @@ fn load_world_rest(
|
||||
// Independent tables load together, within the worker budget the settings
|
||||
// allow. Only the recipes and the stations read another table: the items.
|
||||
let load_items = || -> Result<_> {
|
||||
let items = Items::load(root, registry, facts, overlay)?;
|
||||
let items = startup::time("rest.items", || Items::load(root, registry, facts, overlay))?;
|
||||
log::info!("load items: {:.2}s", started.elapsed().as_secs_f32());
|
||||
progress.set(7);
|
||||
let recipes = Recipes::load(root, &items, overlay)?;
|
||||
let stations = horizon_client::stations::StationData::load(root, &items, overlay);
|
||||
let recipes = startup::time("rest.recipes", || Recipes::load(root, &items, overlay))?;
|
||||
let stations = startup::time("rest.stations", || {
|
||||
horizon_client::stations::StationData::load(root, &items, overlay)
|
||||
});
|
||||
Ok((items, recipes, stations))
|
||||
};
|
||||
let load_behaviours =
|
||||
|| Behaviours::load_with_gameplay(registry, root, behaviour_json, gameplay_projection);
|
||||
let load_behaviours = || {
|
||||
startup::time("rest.behaviours", || {
|
||||
Behaviours::load_with_gameplay(registry, root, behaviour_json, gameplay_projection)
|
||||
})
|
||||
};
|
||||
let load_block_entity_render = || {
|
||||
let _step = startup::step("rest.block_entity_render");
|
||||
if !draws {
|
||||
return Ok(Default::default());
|
||||
}
|
||||
BlockEntityRenderer::load_with_visibility(root, registry, visibility)
|
||||
};
|
||||
let load_animate = || -> Result<_> {
|
||||
let _step = startup::step("rest.animate_fog");
|
||||
let animate = if draws {
|
||||
horizon_client::animate_tick::AnimateTables::load(root, registry, biomes)?
|
||||
} else {
|
||||
@@ -19516,6 +19699,7 @@ fn load_world_rest(
|
||||
Ok((animate, horizon_client::fog::FogTables::load(root, biomes)))
|
||||
};
|
||||
let load_mobs = || -> Result<_> {
|
||||
let _step = startup::step("rest.mobs");
|
||||
let mob_types: HashMap<_, _> = MobTypes::load(root)?
|
||||
.types
|
||||
.into_iter()
|
||||
@@ -19529,6 +19713,7 @@ fn load_world_rest(
|
||||
Ok((mob_types, mob_data))
|
||||
};
|
||||
let load_transformers = || -> Result<_> {
|
||||
let _step = startup::step("rest.transformers_models");
|
||||
let transformers = horizon_client::block_transform::Transformers::load(root, registry)?;
|
||||
let models = (
|
||||
perspective::player_type(root),
|
||||
@@ -19584,6 +19769,7 @@ fn load_world_rest(
|
||||
threads
|
||||
);
|
||||
progress.set(8);
|
||||
let _tail = startup::step("rest.tail");
|
||||
Ok(WorldRest {
|
||||
stations,
|
||||
items,
|
||||
@@ -19681,6 +19867,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)
|
||||
}
|
||||
|
||||
@@ -20067,10 +20257,26 @@ struct Loading {
|
||||
delivered: bool,
|
||||
handle: Option<JoinHandle<()>>,
|
||||
shown: bool,
|
||||
/// When the menu bundle arrived (`set_ui`), and whether a title frame with
|
||||
/// it was presented since: the world renderer waits for that frame
|
||||
/// (`ready_for_handoff`).
|
||||
ui_at: Option<Instant>,
|
||||
title_drawn: bool,
|
||||
menu: startup_menu::StartupMenu,
|
||||
error: Option<Box<dyn std::error::Error>>,
|
||||
}
|
||||
/// How long the handoff waits for the first title frame before building the
|
||||
/// world renderer anyway (a hidden window may never be asked to redraw).
|
||||
const TITLE_FRAME_WAIT: Duration = Duration::from_secs(1);
|
||||
impl Loading {
|
||||
/// The world renderer is built on the main thread, so building it before
|
||||
/// the title is on the screen holds the title back by that build. Wait for
|
||||
/// one title frame first, unless a world is waiting to open.
|
||||
fn ready_for_handoff(&self) -> bool {
|
||||
self.title_drawn
|
||||
|| self.menu.pending.is_some()
|
||||
|| self.ui_at.is_none_or(|at| at.elapsed() >= TITLE_FRAME_WAIT)
|
||||
}
|
||||
fn resumed(&mut self, event_loop: &ActiveEventLoop) {
|
||||
if self.window.is_some() {
|
||||
return;
|
||||
@@ -20093,6 +20299,7 @@ impl Loading {
|
||||
attributes, "horizon", "horizon",
|
||||
);
|
||||
// `--offscreen` renders into a texture: its window would only show white.
|
||||
let window_step = startup::step("window.create");
|
||||
let window = match event_loop.create_window(
|
||||
attributes
|
||||
.with_inner_size(winit::dpi::LogicalSize::new(size[0], size[1]))
|
||||
@@ -20106,6 +20313,7 @@ impl Loading {
|
||||
return;
|
||||
}
|
||||
};
|
||||
drop(window_step);
|
||||
horizon_client::telemetry::note_display(&window);
|
||||
// The renderer takes seconds to build and winit's window sits white
|
||||
// until the first frame presents, so paint the boot screen on it and
|
||||
@@ -20130,6 +20338,7 @@ impl Loading {
|
||||
stop
|
||||
});
|
||||
let offscreen = self.options.as_ref().is_some_and(|o| o.offscreen);
|
||||
let boot_step = startup::step("renderer.boot");
|
||||
match loading_renderer(
|
||||
window.clone(),
|
||||
offscreen,
|
||||
@@ -20149,6 +20358,7 @@ impl Loading {
|
||||
return;
|
||||
}
|
||||
}
|
||||
drop(boot_step);
|
||||
// The wgpu frames take the window over: stop the boot paint thread
|
||||
// before the first one presents.
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -20177,6 +20387,7 @@ impl Loading {
|
||||
);
|
||||
self.menu.set_root(root, &self.settings);
|
||||
self.gui = Some(gui);
|
||||
self.title_drawn = false;
|
||||
horizon_client::ui_theme::reset_atlas();
|
||||
self.apply_menu_settings();
|
||||
let Some(window) = self.window.clone() else {
|
||||
@@ -20187,6 +20398,7 @@ impl Loading {
|
||||
renderer.release_surface();
|
||||
}
|
||||
let bundle = self.gui.as_ref().expect("the menu bundle was just set");
|
||||
let _step = startup::step("renderer.title_artwork");
|
||||
match loading_renderer(
|
||||
window,
|
||||
offscreen,
|
||||
@@ -20201,6 +20413,8 @@ impl Loading {
|
||||
}
|
||||
Err(e) => self.error = Some(e),
|
||||
}
|
||||
// The title frame's wait starts once its renderer is built.
|
||||
self.ui_at = Some(Instant::now());
|
||||
}
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
// The loop may run once more after exit(): keep the first error, the
|
||||
@@ -20247,6 +20461,7 @@ impl Loading {
|
||||
if let Some(renderer) = &mut self.renderer {
|
||||
match renderer.render(&frame) {
|
||||
Ok(true) if self.gui.is_some() && self.menu.pending.is_none() => {
|
||||
self.title_drawn = true;
|
||||
self.menu.perf.title_shown()
|
||||
}
|
||||
Ok(_) => {}
|
||||
@@ -20255,6 +20470,7 @@ impl Loading {
|
||||
}
|
||||
if !self.shown {
|
||||
self.shown = true;
|
||||
startup::mark("loading_screen.shown");
|
||||
log::info!(
|
||||
"Horizon: loading screen shown after {} ms",
|
||||
self.started.elapsed().as_millis()
|
||||
@@ -20275,17 +20491,19 @@ impl Loading {
|
||||
renderer.release_surface();
|
||||
}
|
||||
let renderer_at = Instant::now();
|
||||
let renderer = build_renderer(
|
||||
window.clone(),
|
||||
options.offscreen,
|
||||
&loaded.pack,
|
||||
&gui,
|
||||
self.renderer.as_ref(),
|
||||
loaded.renderer_cpu,
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
)?;
|
||||
let renderer = startup::time("renderer.world", || {
|
||||
build_renderer(
|
||||
window.clone(),
|
||||
options.offscreen,
|
||||
&loaded.pack,
|
||||
&gui,
|
||||
self.renderer.as_ref(),
|
||||
loaded.renderer_cpu,
|
||||
self.settings.horizon.api,
|
||||
self.settings.horizon.translucent_batching,
|
||||
shader_packs::picked_pack_draws_flat(&self.settings),
|
||||
)
|
||||
})?;
|
||||
log::info!(
|
||||
"Horizon: world renderer ready after {} ms",
|
||||
self.started.elapsed().as_millis()
|
||||
@@ -20302,6 +20520,7 @@ impl Loading {
|
||||
// like the single copy an integrated server would share.
|
||||
let res: &'static Arc<Resources> = Box::leak(Box::new(loaded.resources));
|
||||
let game_at = Instant::now();
|
||||
let game_step = startup::step("handoff.start_game");
|
||||
let Some(mut game) = start_game(
|
||||
res,
|
||||
loaded.pack,
|
||||
@@ -20318,6 +20537,7 @@ impl Loading {
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
drop(game_step);
|
||||
let game_ms = game_at.elapsed().as_millis();
|
||||
game.window = Some(window);
|
||||
game.renderer = Some(renderer);
|
||||
@@ -20379,7 +20599,7 @@ impl ApplicationHandler for App {
|
||||
event_loop.exit();
|
||||
return;
|
||||
}
|
||||
match if loading.menu.reload_packs {
|
||||
match if loading.menu.reload_packs || !loading.ready_for_handoff() {
|
||||
None
|
||||
} else {
|
||||
loading.loaded.take()
|
||||
@@ -20463,6 +20683,8 @@ fn run_loading(
|
||||
delivered: false,
|
||||
handle: Some(loader),
|
||||
shown: false,
|
||||
ui_at: None,
|
||||
title_drawn: false,
|
||||
menu,
|
||||
error: None,
|
||||
};
|
||||
@@ -20494,7 +20716,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(());
|
||||
};
|
||||
@@ -20766,7 +20988,11 @@ mod startup_cpu_tests {
|
||||
r#"{
|
||||
"minecraft:end_crystal#main": {}, "minecraft:ender_dragon#main": {},
|
||||
"minecraft:arrow#main": {}, "minecraft:trident#main": {},
|
||||
"minecraft:armor_stand#main": {}, "minecraft:cushion#main": {}
|
||||
"minecraft:armor_stand#main": {}, "minecraft:cushion#main": {},
|
||||
"minecraft:leash_knot#main": {}, "minecraft:wind_charge#main": {},
|
||||
"minecraft:shulker_bullet#main": {}, "minecraft:wither_skull#main": {},
|
||||
"minecraft:evoker_fangs#main": {}, "minecraft:standing_banner#main": {},
|
||||
"minecraft:standing_banner#flag": {}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
@@ -20787,6 +21013,10 @@ mod startup_cpu_tests {
|
||||
std::fs::remove_dir_all(&root).unwrap();
|
||||
assert!(prepared.crystal_model.is_some());
|
||||
assert!(prepared.cushion_model.is_some());
|
||||
let models = &prepared.entity_models;
|
||||
assert!(models.leash_knot.is_some() && models.wind_charge.is_some());
|
||||
assert!(models.shulker_bullet.is_some() && models.wither_skull.is_some());
|
||||
assert!(models.evoker_fangs.is_some() && models.banner.is_some());
|
||||
assert!(prepared.dragon_model.is_some());
|
||||
assert!(prepared.arrow_model.is_some());
|
||||
assert!(prepared.trident_model.is_some());
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -218,11 +218,17 @@ impl Game<'_> {
|
||||
let Some(BlockEntity::Furnace(f)) = self.world.block_entities.get_mut(&pos) else {
|
||||
return Ok(());
|
||||
};
|
||||
let Some(lit) = f.tick(
|
||||
let mut drops = Vec::new();
|
||||
let lit = f.tick(
|
||||
world_recipes!(self),
|
||||
world_items!(self),
|
||||
self.res.providers(),
|
||||
) else {
|
||||
&mut drops,
|
||||
);
|
||||
for stack in drops {
|
||||
self.campfire_drop(pos, stack);
|
||||
}
|
||||
let Some(lit) = lit else {
|
||||
return Ok(());
|
||||
};
|
||||
let state = self.world.block(pos.0, pos.1, pos.2);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -171,7 +171,7 @@ impl Game<'_> {
|
||||
|
||||
/// `Containers.dropItemStack(level, x, y, z, stack)` at the block's
|
||||
/// integer position.
|
||||
fn campfire_drop(&mut self, pos: Pos, stack: ItemStack) {
|
||||
pub(super) fn campfire_drop(&mut self, pos: Pos, stack: ItemStack) {
|
||||
let at = BlockPos::new(pos.0, pos.1, pos.2);
|
||||
for piece in
|
||||
horizon_world::level::removal::drop_item_stack(&mut self.world.random, at, stack.count)
|
||||
|
||||
@@ -67,6 +67,8 @@ pub(super) enum Target {
|
||||
Tnt(u32),
|
||||
/// A falling block, by its entity id (`minecraft:falling_block`).
|
||||
Falling(u32),
|
||||
/// A firework rocket, by its entity id (`minecraft:firework_rocket`).
|
||||
Rocket(u32),
|
||||
/// An ominous item spawner, by its entity id (`minecraft:ominous_item_spawner`).
|
||||
Spawner(u32),
|
||||
}
|
||||
@@ -144,6 +146,7 @@ fn non_player_entity_id(t: Target) -> Option<i32> {
|
||||
| Target::Dragon(id)
|
||||
| Target::Tnt(id)
|
||||
| Target::Falling(id)
|
||||
| Target::Rocket(id)
|
||||
| Target::Spawner(id)) = t
|
||||
else {
|
||||
return None;
|
||||
@@ -535,6 +538,11 @@ impl Game<'_> {
|
||||
.iter()
|
||||
.find(|f| f.id == id)
|
||||
.map(|f| [f.position.x, f.position.y, f.position.z]),
|
||||
Target::Rocket(id) => self
|
||||
.rockets
|
||||
.iter()
|
||||
.find(|r| r.id == id)
|
||||
.map(|r| [r.position.x, r.position.y, r.position.z]),
|
||||
Target::Spawner(id) => self
|
||||
.ominous_spawners
|
||||
.iter()
|
||||
@@ -588,6 +596,7 @@ impl Game<'_> {
|
||||
Target::Dragon(_) => "minecraft:ender_dragon".into(),
|
||||
Target::Tnt(_) => "minecraft:tnt".into(),
|
||||
Target::Falling(_) => "minecraft:falling_block".into(),
|
||||
Target::Rocket(_) => "minecraft:firework_rocket".into(),
|
||||
Target::Spawner(_) => "minecraft:ominous_item_spawner".into(),
|
||||
}
|
||||
}
|
||||
@@ -880,6 +889,7 @@ impl Game<'_> {
|
||||
}
|
||||
candidates.extend(self.tnts.iter().map(|t| Target::Tnt(t.id)));
|
||||
candidates.extend(self.falling.iter().map(|f| Target::Falling(f.id)));
|
||||
candidates.extend(self.rockets.iter().map(|r| Target::Rocket(r.id)));
|
||||
candidates.extend(self.ominous_spawners.iter().map(|s| Target::Spawner(s.id)));
|
||||
candidates
|
||||
}
|
||||
@@ -914,6 +924,7 @@ impl Game<'_> {
|
||||
Target::Dragon(id),
|
||||
Target::Tnt(id),
|
||||
Target::Falling(id),
|
||||
Target::Rocket(id),
|
||||
Target::Spawner(id),
|
||||
]
|
||||
.into_iter()
|
||||
@@ -938,6 +949,7 @@ impl Game<'_> {
|
||||
Target::Dragon(id) => self.dragon.as_ref().is_some_and(|d| d.id == id),
|
||||
Target::Tnt(id) => self.tnts.iter().any(|t| t.id == id),
|
||||
Target::Falling(id) => self.falling.iter().any(|f| f.id == id),
|
||||
Target::Rocket(id) => self.rockets.iter().any(|r| r.id == id),
|
||||
Target::Spawner(id) => self.ominous_spawners.iter().any(|s| s.id == id),
|
||||
}
|
||||
}
|
||||
@@ -1576,6 +1588,12 @@ impl Game<'_> {
|
||||
f.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Rocket(id) => {
|
||||
if let Some(r) = self.rockets.iter_mut().find(|r| r.id == id) {
|
||||
r.position = v;
|
||||
r.previous = v;
|
||||
}
|
||||
}
|
||||
Target::Spawner(id) => {
|
||||
if let Some(s) = self.ominous_spawners.iter_mut().find(|s| s.id == id) {
|
||||
s.position = [v.x, v.y, v.z];
|
||||
@@ -2080,6 +2098,7 @@ impl Game<'_> {
|
||||
Target::Dragon(id) => self.kill_dragon(id),
|
||||
Target::Tnt(id) => self.tnts.retain(|t| t.id != id),
|
||||
Target::Falling(id) => self.falling.retain(|f| f.id != id),
|
||||
Target::Rocket(id) => self.rockets.retain(|r| r.id != id),
|
||||
Target::Spawner(id) => self.ominous_spawners.retain(|s| s.id != id),
|
||||
}
|
||||
// The orbs marked for removal are gone (Entity.kill removes them).
|
||||
@@ -2981,6 +3000,7 @@ impl Game<'_> {
|
||||
}
|
||||
/// `MinecraftServer.setDifficulty(difficulty, true)`.
|
||||
pub(super) fn set_difficulty_forced(&mut self, i: u8) {
|
||||
let from = self.ui.difficulty;
|
||||
self.ui.difficulty = i;
|
||||
// The level data holds the world's difficulty: it survives the
|
||||
// reopen, so it follows the command.
|
||||
@@ -2999,6 +3019,10 @@ impl Game<'_> {
|
||||
locked: self.level.difficulty_locked,
|
||||
}));
|
||||
}
|
||||
if from != i {
|
||||
let (from, to) = difficulty_names(from, i);
|
||||
self.native_difficulty_changed(from, to);
|
||||
}
|
||||
}
|
||||
fn cmd_spawnpoint(&mut self, ctx: &Context) -> R<i32> {
|
||||
let targets = if ctx.get("targets").is_some() {
|
||||
@@ -3725,6 +3749,30 @@ pub(super) fn block_input_matches(
|
||||
.all(|(k, v)| s.properties.get(k) == Some(v))
|
||||
}
|
||||
|
||||
/// The names `native_difficulty_changed` hears for a change from `from` to
|
||||
/// `to`: the four difficulties of `options.difficulty.*`, the index kept in
|
||||
/// range as the command and the options keep it.
|
||||
fn difficulty_names(from: u8, to: u8) -> (&'static str, &'static str) {
|
||||
(
|
||||
DIFFICULTIES[usize::from(from) & 3],
|
||||
DIFFICULTIES[usize::from(to) & 3],
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod difficulty_tests {
|
||||
use super::difficulty_names;
|
||||
|
||||
#[test]
|
||||
fn a_difficulty_change_names_both_ends() {
|
||||
assert_eq!(difficulty_names(0, 3), ("peaceful", "hard"));
|
||||
assert_eq!(difficulty_names(2, 1), ("normal", "easy"));
|
||||
// The four difficulties are the only indexes the command and the
|
||||
// options keep; an index outside them still names one.
|
||||
assert_eq!(difficulty_names(7, 4), ("hard", "peaceful"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod summon_tests {
|
||||
#[test]
|
||||
@@ -3756,6 +3804,7 @@ mod look_at_tests {
|
||||
Target::Dragon(7),
|
||||
Target::Tnt(7),
|
||||
Target::Falling(7),
|
||||
Target::Rocket(7),
|
||||
Target::Spawner(7),
|
||||
] {
|
||||
assert_eq!(non_player_entity_id(t), Some(7), "{t:?}");
|
||||
|
||||
@@ -68,7 +68,12 @@ impl Game<'_> {
|
||||
.flatten()
|
||||
}
|
||||
fn entity_nbt_in_level(&mut self, t: Target) -> Option<Tag> {
|
||||
let player_uuid = self.level.player_uuid;
|
||||
// The acting player's own UUID: a guest's turn (the window's player on
|
||||
// the server thread) keeps the world owner's in `level`.
|
||||
let player_uuid = self
|
||||
.lan_acting_uuid
|
||||
.map(horizon_client::lan::playerdata::uuid_ints)
|
||||
.or(self.level.player_uuid);
|
||||
Some(match t {
|
||||
Target::Guest(_) => {
|
||||
return self
|
||||
@@ -212,6 +217,11 @@ impl Game<'_> {
|
||||
);
|
||||
crate::game::persist::with_raw(tag, raw)
|
||||
}
|
||||
Target::Rocket(id) => {
|
||||
let uuid = self.uuid_for(id);
|
||||
let r = self.rockets.iter().find(|r| r.id == id)?;
|
||||
r.to_nbt(uuid, player_uuid, self.persist.raw.get(&id))
|
||||
}
|
||||
Target::Spawner(id) => {
|
||||
let s = self.ominous_spawners.iter().find(|s| s.id == id)?;
|
||||
let [x, y, z] = s.position;
|
||||
@@ -842,6 +852,8 @@ impl Game<'_> {
|
||||
Some(Target::Tnt(id))
|
||||
} else if self.falling.iter().any(|f| f.id == id) {
|
||||
Some(Target::Falling(id))
|
||||
} else if self.rockets.iter().any(|r| r.id == id) {
|
||||
Some(Target::Rocket(id))
|
||||
} else if self.ominous_spawners.iter().any(|s| s.id == id) {
|
||||
Some(Target::Spawner(id))
|
||||
} else {
|
||||
|
||||
@@ -10,7 +10,27 @@ use std::{
|
||||
};
|
||||
use winit::{event::MouseButton, keyboard::KeyCode};
|
||||
|
||||
/// The control protocol's version (`docs/api/stability.md`): before 1.0 an
|
||||
/// addition makes the next patch and a breaking change the next line.
|
||||
const PROTOCOL: &str = "0.1.0";
|
||||
|
||||
/// The command groups the game serves, as `help` chains them: what `hello` reports as its features.
|
||||
const FEATURES: &[&str] = &[
|
||||
"game", "station", "blocks", "lan", "friends", "render", "sculk", "agent", "ui", "options",
|
||||
"events", "saves", "pad",
|
||||
];
|
||||
|
||||
/// `hello`: the protocol the socket speaks, the game's release and the command groups it serves.
|
||||
fn hello() -> Value {
|
||||
json!({
|
||||
"protocol": PROTOCOL,
|
||||
"release": horizon_core::release::VERSION,
|
||||
"features": FEATURES,
|
||||
})
|
||||
}
|
||||
|
||||
const HELP: &[&str] = &[
|
||||
"hello the control protocol's version, the game's release and the command groups it serves",
|
||||
"status game, player, streaming and renderer state",
|
||||
"simulation authoritative projectiles in the owner's level",
|
||||
"server-hold on|off hold simulation and player packets at a control-fixture barrier",
|
||||
@@ -36,7 +56,7 @@ const HELP: &[&str] = &[
|
||||
"type TEXT type into the open chat box (key enter tap sends)",
|
||||
"chat-log [N] the newest N chat messages (default 10)",
|
||||
"options [PAGE] open the options (video sound controls keybinds mouse chat accessibility language font packs mods graphics graphics-advanced graphics-hdr)",
|
||||
"stats [reset] frame-time summary since the last reset",
|
||||
"stats [reset] [raw] frame-time summary since the last reset (raw: every frame time too)",
|
||||
"narration the narrator's status and the last messages it was asked to say",
|
||||
"bench SECONDS reset stats, wait, then reply with the summary",
|
||||
"fly X1 Y1 Z1 X2 Y2 Z2 SECONDS [PITCH [raw]] fly the camera along a line at constant speed, then reply with the frame summary (raw: every frame time too)",
|
||||
@@ -355,6 +375,7 @@ impl Game<'_> {
|
||||
Ok(Some(json!({ "ok": true, "sides": r.section_sides() })))
|
||||
}
|
||||
"sculk" => Ok(Some(self.sculk_command(&words)?)),
|
||||
"hello" => Ok(Some(hello())),
|
||||
"status" => Ok(Some(self.control_status())),
|
||||
"stall" => {
|
||||
let millis: u64 = arg(1)?.parse()?;
|
||||
@@ -859,8 +880,13 @@ impl Game<'_> {
|
||||
})))
|
||||
}
|
||||
"stats" => {
|
||||
let summary = self.summary_with_passes();
|
||||
if words.get(1) == Some(&"reset") {
|
||||
let mut summary = self.summary_with_passes();
|
||||
if words[1..].contains(&"raw") {
|
||||
summary["frame_ms"] = self.frame_stats.frame_millis().into();
|
||||
summary["cpu_ms"] = self.frame_stats.cpu_millis().into();
|
||||
summary["gpu_ms"] = self.frame_stats.gpu_millis().into();
|
||||
}
|
||||
if words[1..].contains(&"reset") {
|
||||
self.frame_stats.reset();
|
||||
}
|
||||
Ok(Some(summary))
|
||||
@@ -1403,6 +1429,25 @@ fn key_code(name: &str) -> Option<KeyCode> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn hello_names_the_protocol_the_release_and_the_command_groups() {
|
||||
let reply = hello();
|
||||
assert_eq!(reply["release"], horizon_core::release::VERSION);
|
||||
let protocol = reply["protocol"].as_str().unwrap();
|
||||
let parts: Vec<_> = protocol.split('.').collect();
|
||||
assert!(
|
||||
parts.len() == 3 && parts.iter().all(|p| p.parse::<u32>().is_ok()),
|
||||
"{protocol}"
|
||||
);
|
||||
let features = reply["features"].as_array().unwrap();
|
||||
assert!(!features.is_empty());
|
||||
let mut names: Vec<_> = features.iter().filter_map(Value::as_str).collect();
|
||||
assert_eq!(names.len(), features.len());
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
assert_eq!(names.len(), features.len(), "a group is named twice");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn taps_and_ups_always_release_their_key() {
|
||||
for line in ["key shift tap", "key shift up", "key Shift up"] {
|
||||
|
||||
@@ -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],
|
||||
@@ -6475,14 +6476,7 @@ impl Game<'_> {
|
||||
// ShulkerBulletRenderer: the tumbling bullet model, then a larger
|
||||
// translucent copy of it (colour 0x26FFFFFF); always 15 block light.
|
||||
if matches!(a.kind, ProjectileKind::ShulkerBullet { .. }) {
|
||||
static BULLET: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
|
||||
std::sync::OnceLock::new();
|
||||
let model = BULLET.get_or_init(|| {
|
||||
horizon_client::mobs::load_model(
|
||||
&self.options.resources,
|
||||
"minecraft:shulker_bullet#main",
|
||||
)
|
||||
});
|
||||
let model = &self.entity_models.shulker_bullet;
|
||||
if let (Some(model), Some(r)) =
|
||||
(model, atlas.sprites.get("entity/shulker/spark").copied())
|
||||
{
|
||||
@@ -6534,14 +6528,7 @@ impl Game<'_> {
|
||||
}
|
||||
// WitherSkullRenderer: the skull model facing its flight.
|
||||
if let Some(dangerous) = super::wither::skull_kind(a) {
|
||||
static SKULL: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
|
||||
std::sync::OnceLock::new();
|
||||
let model = SKULL.get_or_init(|| {
|
||||
horizon_client::mobs::load_model(
|
||||
&self.options.resources,
|
||||
"minecraft:wither_skull#main",
|
||||
)
|
||||
});
|
||||
let model = &self.entity_models.wither_skull;
|
||||
let tex = if dangerous {
|
||||
"entity/wither/wither_invulnerable"
|
||||
} else {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -89,7 +89,17 @@ impl Game<'_> {
|
||||
/// `ItemStack.hurtAndBreak` for the player's `stack`: the wear Unbreaking
|
||||
/// leaves, and `ItemDurabilityTrigger` when some is left (`applyDamage`).
|
||||
pub(super) fn wear(&mut self, stack: &ItemStack, amount: u32) -> u32 {
|
||||
let loss = self.durability_loss(stack, amount);
|
||||
let mut loss = self.durability_loss(stack, amount);
|
||||
// The mods hear a server player's wear once Unbreaking has had its say,
|
||||
// and may change it or stop it.
|
||||
if loss > 0
|
||||
&& let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
let mut points = loss as i32;
|
||||
let mut cancel = false;
|
||||
self.native_item_damaged(uuid, &name, &stack.item, &mut points, &mut cancel);
|
||||
loss = if cancel { 0 } else { points.max(0) as u32 };
|
||||
}
|
||||
if loss > 0 {
|
||||
self.trigger_item_durability(stack, stack.damage + loss);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -28,11 +28,7 @@ impl Game<'_> {
|
||||
|
||||
pub(super) fn insert_fangs(&mut self, id: u32, at: [f64; 3], yaw: f32, delay: i32, owner: u32) {
|
||||
if self.fangs.model.is_none() {
|
||||
self.fangs.model = horizon_client::mobs::load_model(
|
||||
&self.options.resources,
|
||||
"minecraft:evoker_fangs#main",
|
||||
)
|
||||
.map(Arc::new);
|
||||
self.fangs.model = self.entity_models.evoker_fangs.clone();
|
||||
}
|
||||
self.fangs.list.push(Fangs {
|
||||
id,
|
||||
|
||||
@@ -29,7 +29,8 @@ enum RocketHit {
|
||||
}
|
||||
|
||||
pub(super) struct Rocket {
|
||||
/// The entity id a server tracks it by (0 until it is tracked).
|
||||
/// Its entity id: from the entity counter on the server, the server's on
|
||||
/// a client.
|
||||
pub id: u32,
|
||||
pub(super) position: Vec3,
|
||||
pub(super) previous: Vec3,
|
||||
@@ -223,8 +224,10 @@ impl Game<'_> {
|
||||
let lifetime = 10 * (1 + i32::from(flight))
|
||||
+ r.next_int_bound(6).unwrap_or(0)
|
||||
+ r.next_int_bound(7).unwrap_or(0);
|
||||
// An id from the entity counter at once, so a command can name it.
|
||||
self.next_entity += 1;
|
||||
Rocket {
|
||||
id: 0,
|
||||
id: self.next_entity,
|
||||
position: at,
|
||||
previous: at,
|
||||
velocity,
|
||||
|
||||
@@ -98,6 +98,22 @@ impl Game<'_> {
|
||||
if &*stack.item != "minecraft:fishing_rod" {
|
||||
return false;
|
||||
}
|
||||
// The mods hear a server player's cast or reel-in first.
|
||||
if self.player.is_some()
|
||||
&& let (Some(uuid), Some(name)) = (self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
let state = match &self.hook {
|
||||
None => 0,
|
||||
Some(h) if matches!(h.state, State::Hooked(_)) => 4,
|
||||
Some(h) if h.nibble > 0 => 2,
|
||||
Some(_) => 3,
|
||||
};
|
||||
let mut cancel = false;
|
||||
self.native_player_fish(uuid, &name, state, &mut cancel);
|
||||
if cancel {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
let Some(p) = self.player.as_ref() else {
|
||||
return false;
|
||||
};
|
||||
@@ -419,6 +435,19 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// The mods hear a server player's fish bite, and may call it
|
||||
// off: the wait starts over.
|
||||
if let (Some(uuid), Some(name)) =
|
||||
(self.lan_acting_uuid, self.lan_acting_name.clone())
|
||||
{
|
||||
let mut cancel = false;
|
||||
self.native_player_fish(uuid, &name, 1, &mut cancel);
|
||||
if cancel {
|
||||
h.time_until_lured = 0;
|
||||
h.time_until_hooked = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
let pitch =
|
||||
1.0 + (self.world.random.next_float() - self.world.random.next_float()) * 0.4;
|
||||
self.audio.play(
|
||||
|
||||
@@ -17,6 +17,7 @@ use super::{
|
||||
world_loading::{WorldLoader, WorldRequest},
|
||||
};
|
||||
use horizon_client::level_loading::{STATUS_RADIUS, Stage};
|
||||
use horizon_client::spans::startup;
|
||||
use horizon_client::{
|
||||
Result,
|
||||
audio::Audio,
|
||||
@@ -685,9 +686,11 @@ impl Game<'_> {
|
||||
self.native_server_about_to_start(false);
|
||||
self.native_lifecycle_open_started();
|
||||
// `load_world`, with the world `serve` started preparing.
|
||||
self.join_world_rest()?;
|
||||
startup::time("server.wait_rest", || self.join_world_rest())?;
|
||||
self.perf.world_opening();
|
||||
let open_step = startup::step("server.open_world");
|
||||
self.finish_open_world(opening.world?.finish()?)?;
|
||||
drop(open_step);
|
||||
if let Some(sync) = opening.sync {
|
||||
sync.adopt(&self.pack_files());
|
||||
}
|
||||
@@ -751,7 +754,9 @@ impl Game<'_> {
|
||||
guest_commands: false,
|
||||
force_game_mode: false,
|
||||
};
|
||||
self.lan_host(settings, net, config, None)?;
|
||||
startup::time("server.lan_host", || {
|
||||
self.lan_host(settings, net, config, None)
|
||||
})?;
|
||||
let commands = self.allow_commands();
|
||||
let pipe = {
|
||||
let host = self
|
||||
@@ -781,7 +786,7 @@ impl Game<'_> {
|
||||
view.start(Stage::LoadInitialChunks, 0);
|
||||
view.finish(Stage::LoadInitialChunks);
|
||||
let _ = ready.send(Ok((pipe, self.level.name.clone())));
|
||||
self.settle_spawn()
|
||||
startup::time("server.settle_spawn", || self.settle_spawn())
|
||||
}
|
||||
|
||||
/// `MinecraftServer.stopServer`: the players are saved and sent away,
|
||||
@@ -1141,7 +1146,9 @@ fn serve(
|
||||
world,
|
||||
sync: preload_sync_data(&options.resources),
|
||||
};
|
||||
let mut server = match server_game(res, pack, options, settings) {
|
||||
let mut server = match startup::time("server.game_new", || {
|
||||
server_game(res, pack, options, settings)
|
||||
}) {
|
||||
Ok(server) => server,
|
||||
Err(e) => {
|
||||
// The world's session lock is let go before the failure is told.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -130,6 +130,7 @@ impl Game<'_> {
|
||||
let equipped = std::mem::take(&mut menu.equipped);
|
||||
let brewed = std::mem::take(&mut menu.brewed);
|
||||
let crafted = std::mem::take(&mut menu.crafted);
|
||||
let item_crafted = std::mem::take(&mut menu.item_crafted);
|
||||
menu.drops.clear();
|
||||
store_player(menu, &mut host.hub.guests[gi]);
|
||||
if !brewed.is_empty() {
|
||||
@@ -148,6 +149,15 @@ impl Game<'_> {
|
||||
}
|
||||
});
|
||||
}
|
||||
if !item_crafted.is_empty() {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
self.as_guest(host, conn, |g| {
|
||||
let player = g.native_acting_id();
|
||||
for (recipe, stack) in &item_crafted {
|
||||
g.native_item_crafted(player, recipe, stack);
|
||||
}
|
||||
});
|
||||
}
|
||||
if !equipped.is_empty() {
|
||||
let conn = host.hub.guests[gi].conn;
|
||||
self.as_guest(host, conn, |g| {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
@@ -598,12 +599,9 @@ impl Game<'_> {
|
||||
}
|
||||
}
|
||||
// The knots: a 6 by 8 by 6 pixel block on the fence post.
|
||||
static KNOT: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
|
||||
std::sync::OnceLock::new();
|
||||
let model = KNOT.get_or_init(|| {
|
||||
horizon_client::mobs::load_model(&self.options.resources, "minecraft:leash_knot#main")
|
||||
});
|
||||
let Some(model) = model else { return };
|
||||
let Some(model) = self.entity_models.leash_knot.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(r) = atlas.sprites.get("entity/lead_knot/lead_knot").copied() else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
@@ -476,12 +477,6 @@ impl Game<'_> {
|
||||
Vec3::new(p[0] + w, p[1] + h, p[2] + w),
|
||||
))
|
||||
};
|
||||
let inside = move |p: [f64; 3]| {
|
||||
(p[0] - at[0]).abs() <= 3.0
|
||||
&& (p[2] - at[2]).abs() <= 3.0
|
||||
&& p[1] >= at[1] - 3.0
|
||||
&& p[1] <= at[1] + 9.0
|
||||
};
|
||||
let struck: Vec<(u32, EntityView)> = self
|
||||
.mobs
|
||||
.iter()
|
||||
@@ -574,12 +569,15 @@ impl Game<'_> {
|
||||
if !events.is_empty() {
|
||||
let _ = self.handle_mob_events(events);
|
||||
}
|
||||
// Cushion.thunderHit: a cushion in the box breaks and drops its item
|
||||
// Cushion.thunderHit: a cushion whose box meets the strike box breaks and drops its item
|
||||
// (the bolt is no player, so no creative spare). Last first, since
|
||||
// break_cushion removes the entry.
|
||||
for i in (0..self.cushions.len()).rev() {
|
||||
let p = self.cushions[i].position;
|
||||
if inside([p.x, p.y, p.z]) {
|
||||
let (lo, hi) = self.cushions[i].bounds();
|
||||
if strike.intersects(Aabb::new(
|
||||
Vec3::new(lo[0], lo[1], lo[2]),
|
||||
Vec3::new(hi[0], hi[1], hi[2]),
|
||||
)) {
|
||||
let before = self.dropped.len();
|
||||
self.break_cushion(i, false);
|
||||
b.spared.extend(self.dropped[before..].iter().map(|e| e.id));
|
||||
|
||||
@@ -173,18 +173,11 @@ impl Game<'_> {
|
||||
light: u32,
|
||||
out: &mut Vec<Vertex>,
|
||||
) {
|
||||
use horizon_client::mobs::{Part, anim::Poses, load_model};
|
||||
static MODELS: std::sync::OnceLock<Option<(Part, Part)>> = std::sync::OnceLock::new();
|
||||
use horizon_client::mobs::anim::Poses;
|
||||
if !horizon_client::mobs::raider::is_captain(mob) {
|
||||
return;
|
||||
}
|
||||
let Some((pole, flag)) = MODELS.get_or_init(|| {
|
||||
let root = &self.options.resources;
|
||||
Some((
|
||||
load_model(root, "minecraft:standing_banner#main")?,
|
||||
load_model(root, "minecraft:standing_banner#flag")?,
|
||||
))
|
||||
}) else {
|
||||
let Some((pole, flag)) = &self.entity_models.banner else {
|
||||
return;
|
||||
};
|
||||
let Some(head) =
|
||||
@@ -520,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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -558,6 +558,11 @@ impl Game<'_> {
|
||||
.get(key)
|
||||
.map(String::as_str)
|
||||
}
|
||||
/// The block `state` is a state of: its name, as `GameLevel`'s helper of
|
||||
/// the same name gives it for the hooks that get a state in the world.
|
||||
pub(super) fn native_state_block(&self, state: BlockStateId) -> Option<&str> {
|
||||
self.pack.assets.registry.state(state).map(|s| &*s.name)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -116,6 +116,7 @@ fn target_id(t: Target) -> Option<u32> {
|
||||
| Target::Dragon(id)
|
||||
| Target::Tnt(id)
|
||||
| Target::Falling(id)
|
||||
| Target::Rocket(id)
|
||||
| Target::Spawner(id) => Some(id),
|
||||
Target::Player | Target::Guest(_) => None,
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -443,6 +390,18 @@ impl Game<'_> {
|
||||
host.hub.send_to(g.conn, &packet);
|
||||
}
|
||||
}
|
||||
/// Posts a line to the dedicated server's event stream (`GET /api/v1/events`)
|
||||
/// as an `announce` event, which horizon-server's Discord bridge relays when
|
||||
/// `[discord] events` lists `announce`: `name` and `uuid` say who it is
|
||||
/// about (empty and 0 for no one), `message` is the line (`§` codes colour it).
|
||||
/// Only on a dedicated server; false elsewhere and for an empty message.
|
||||
pub(super) fn native_server_event(&self, name: &str, uuid: u128, message: &str) -> bool {
|
||||
if self.options.dedicated.is_none() || message.trim().is_empty() {
|
||||
return false;
|
||||
}
|
||||
super::dedicated::events::record("announce", name, uuid, json!({ "message": message }));
|
||||
true
|
||||
}
|
||||
/// 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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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.
|
||||
@@ -458,7 +499,12 @@ impl Game<'_> {
|
||||
}
|
||||
"minecraft:firework_rocket" => {
|
||||
if let Some(r) = entity_nbt::firework_rocket_from_nbt(&tag, player_uuid) {
|
||||
self.rockets.push(super::fireworks::Rocket::from_nbt(r));
|
||||
self.next_entity += 1;
|
||||
let id = self.next_entity;
|
||||
let mut rocket = super::fireworks::Rocket::from_nbt(r);
|
||||
rocket.id = id;
|
||||
self.rockets.push(rocket);
|
||||
self.remember(id, uuid, tag);
|
||||
}
|
||||
}
|
||||
// PrimedTnt.readAdditionalSaveData: its fuse (80 when absent) and motion.
|
||||
@@ -843,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))
|
||||
}
|
||||
@@ -987,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);
|
||||
}
|
||||
@@ -1049,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);
|
||||
}
|
||||
@@ -1173,14 +1228,13 @@ impl Game<'_> {
|
||||
out.entry(chunk_of(f.position)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
for r in &self.rockets {
|
||||
if keys.contains(&chunk_of(r.position)) {
|
||||
let (uuid, raw) = anon(&self.persist, "minecraft:firework_rocket", r.position);
|
||||
out.entry(chunk_of(r.position)).or_default().push(r.to_nbt(
|
||||
uuid,
|
||||
self.level.player_uuid,
|
||||
raw.as_ref(),
|
||||
));
|
||||
for i in 0..self.rockets.len() {
|
||||
if keys.contains(&chunk_of(self.rockets[i].position)) {
|
||||
let (id, at) = (self.rockets[i].id, self.rockets[i].position);
|
||||
let uuid = self.uuid_for(id);
|
||||
let tag =
|
||||
self.rockets[i].to_nbt(uuid, self.level.player_uuid, self.persist.raw.get(&id));
|
||||
out.entry(chunk_of(at)).or_default().push(tag);
|
||||
}
|
||||
}
|
||||
for t in &self.tridents {
|
||||
@@ -1356,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();
|
||||
@@ -1464,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()
|
||||
@@ -1505,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));
|
||||
}
|
||||
@@ -1634,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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2972,6 +2972,9 @@ impl Game<'_> {
|
||||
&p.spawn.dimension,
|
||||
self.dim.id(),
|
||||
);
|
||||
if dimension_changed {
|
||||
horizon_client::spans::startup::arm("dimension.change");
|
||||
}
|
||||
self.enter_remote_dimension(&p.spawn, true, reason);
|
||||
if dimension_changed {
|
||||
// LevelExtractor.setLevel: a new level drops the highlights.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -181,6 +181,7 @@ impl Game<'_> {
|
||||
| Target::Dragon(id)
|
||||
| Target::Tnt(id)
|
||||
| Target::Falling(id)
|
||||
| Target::Rocket(id)
|
||||
| Target::Other(id) => Some(uuid_text(self.uuid_for(id))),
|
||||
// It keeps its UUID with itself.
|
||||
Target::Spawner(id) => self
|
||||
@@ -224,6 +225,7 @@ impl Game<'_> {
|
||||
| Target::Projectile(id)
|
||||
| Target::Crystal(id)
|
||||
| Target::Dragon(id)
|
||||
| Target::Rocket(id)
|
||||
| Target::Other(id) => self.persist.raw.get(&id),
|
||||
Target::Player => self.level.player_extra.as_ref(),
|
||||
// A server's player: the saved compound the guest's file is written from.
|
||||
|
||||
@@ -130,10 +130,11 @@ pub(super) struct Split<'a> {
|
||||
window1_shown: bool,
|
||||
/// Where each numbered window was when it closed, to open there again (this session).
|
||||
window_rects: std::collections::HashMap<u8, (LogicalPosition<f64>, LogicalSize<f64>)>,
|
||||
/// The monitors with their names, left to right, and the one Window 1 is on: read once a
|
||||
/// frame (`refresh_monitors`).
|
||||
/// The monitors with their names, left to right, and the one Window 1 is on: read while
|
||||
/// splitscreen is on, once a second (`refresh_monitors`), and when it last read them.
|
||||
screens: Vec<(String, MonitorHandle)>,
|
||||
main_screen: Option<String>,
|
||||
screens_read: Option<Instant>,
|
||||
/// `HORIZON_SPLIT_WINDOWS`: each seat's window, `1`, `2`... or `m<monitor>`.
|
||||
window_plan: Vec<String>,
|
||||
/// The views the players were last fitted to.
|
||||
@@ -182,6 +183,7 @@ impl<'a> Split<'a> {
|
||||
window_rects: Default::default(),
|
||||
screens: Vec::new(),
|
||||
main_screen: None,
|
||||
screens_read: None,
|
||||
window_plan: std::env::var("HORIZON_SPLIT_WINDOWS")
|
||||
.map(|w| w.split(',').map(|s| s.trim().to_owned()).collect())
|
||||
.unwrap_or_default(),
|
||||
@@ -692,8 +694,17 @@ impl ApplicationHandler for Split<'_> {
|
||||
}
|
||||
|
||||
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
|
||||
self.refresh_monitors();
|
||||
let on = self.host.settings.extras.splitscreen || self.forced;
|
||||
// Only splitscreen places windows on monitors. Listing them is a round trip to the
|
||||
// window server (macOS: 0.1-0.3 ms, more on a busy machine), so not every frame.
|
||||
if (on || !self.surfaces.is_empty())
|
||||
&& self
|
||||
.screens_read
|
||||
.is_none_or(|at| at.elapsed() >= Duration::from_secs(1))
|
||||
{
|
||||
self.screens_read = Some(Instant::now());
|
||||
self.refresh_monitors();
|
||||
}
|
||||
self.host.ui.options.split_joined = self.guests.iter().any(|g| g.name.is_some());
|
||||
// Off, or the last other player gone: P1 draws on its window again.
|
||||
if !self.surfaces.is_empty()
|
||||
|
||||
@@ -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()
|
||||
@@ -1241,11 +1241,30 @@ mod tests {
|
||||
delivered: false,
|
||||
handle: None,
|
||||
shown: false,
|
||||
ui_at: None,
|
||||
title_drawn: false,
|
||||
menu: StartupMenu::default(),
|
||||
error: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_world_renderer_waits_for_the_first_title_frame() {
|
||||
let (_send, receiver) = std::sync::mpsc::channel();
|
||||
let mut loading = loading(receiver);
|
||||
// No menu bundle yet: nothing to wait for.
|
||||
assert!(loading.ready_for_handoff());
|
||||
// The title artwork is up but no frame of it was presented.
|
||||
loading.ui_at = Some(Instant::now());
|
||||
assert!(!loading.ready_for_handoff());
|
||||
loading.title_drawn = true;
|
||||
assert!(loading.ready_for_handoff());
|
||||
// A window never asked to redraw doesn't hold the start for long.
|
||||
loading.title_drawn = false;
|
||||
loading.ui_at = Instant::now().checked_sub(Duration::from_secs(2));
|
||||
assert!(loading.ready_for_handoff());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_is_usable_while_the_core_worker_is_blocked() {
|
||||
let root = std::env::temp_dir().join("horizon-startup-menu-no-world-resources");
|
||||
|
||||
@@ -61,7 +61,7 @@ impl Game<'_> {
|
||||
pub(super) fn bounding_box_gizmos(&self, camera: [f64; 3], g: &mut Gizmos) {
|
||||
let game_master = self.can_use_game_master_blocks() || self.spectator();
|
||||
let facts = self.res.facts();
|
||||
for (pos, entity) in &self.world.block_entities {
|
||||
for (pos, entity) in self.world.block_entities.boxes() {
|
||||
let centre = [pos.0, pos.1, pos.2].map(|v| f64::from(v) + 0.5);
|
||||
let dist: f64 = (0..3).map(|i| (centre[i] - camera[i]).powi(2)).sum();
|
||||
let block = [pos.0, pos.1, pos.2];
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -694,12 +694,9 @@ impl Game<'_> {
|
||||
partial: f32,
|
||||
out: &mut Vec<horizon_client::mesher::Vertex>,
|
||||
) {
|
||||
static MODEL: std::sync::OnceLock<Option<horizon_client::mobs::Part>> =
|
||||
std::sync::OnceLock::new();
|
||||
let model = MODEL.get_or_init(|| {
|
||||
horizon_client::mobs::load_model(&self.options.resources, "minecraft:wind_charge#main")
|
||||
});
|
||||
let Some(model) = model else { return };
|
||||
let Some(model) = self.entity_models.wind_charge.as_ref() else {
|
||||
return;
|
||||
};
|
||||
let Some(r) = self
|
||||
.gui
|
||||
.atlas
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
@@ -297,6 +297,9 @@ pub struct Menu {
|
||||
/// Recipes whose result was taken (`awardUsedRecipes`) and the stacks that
|
||||
/// went into each, for the caller.
|
||||
pub crafted: Vec<(String, Vec<Option<ItemStack>>)>,
|
||||
/// A crafting grid's result taken (`ResultSlot.onTake`): the recipe and
|
||||
/// the stack taken, for the caller (`native_item_crafted`).
|
||||
pub item_crafted: Vec<(String, ItemStack)>,
|
||||
/// Potions taken from a brewing stand's bottle slots
|
||||
/// (`PotionSlot.onTake`), for the caller.
|
||||
pub brewed: Vec<ItemStack>,
|
||||
@@ -729,6 +732,7 @@ impl Menu {
|
||||
sounds: Vec::new(),
|
||||
player_sounds: Vec::new(),
|
||||
crafted: Vec::new(),
|
||||
item_crafted: Vec::new(),
|
||||
brewed: Vec::new(),
|
||||
equipped: Vec::new(),
|
||||
merchant: None,
|
||||
@@ -1732,6 +1736,8 @@ impl Menu {
|
||||
let grid = self.containers[GRID].clone();
|
||||
if let Some((id, _)) = env.recipes.find(&grid, n) {
|
||||
self.crafted.push((format!("minecraft:{id}"), grid.clone()));
|
||||
self.item_crafted
|
||||
.push((format!("minecraft:{id}"), taken.clone()));
|
||||
}
|
||||
let remaining = env
|
||||
.recipes
|
||||
@@ -3292,6 +3298,25 @@ pub(crate) mod tests {
|
||||
});
|
||||
}
|
||||
|
||||
/// A crafting grid's result taken is recorded for `native_item_crafted`:
|
||||
/// the recipe the grid matched and the stack the player took.
|
||||
#[test]
|
||||
fn taking_a_crafting_result_records_the_crafted_stack() {
|
||||
with_menu(Kind::Crafting, |env, menu| {
|
||||
menu.creative = false;
|
||||
menu.set_slot(env, 1, stack("oak_log", 1));
|
||||
menu.settle(env);
|
||||
menu.click(env, 0, 0, Input::Pickup).unwrap();
|
||||
assert_eq!(
|
||||
menu.item_crafted,
|
||||
vec![(
|
||||
"minecraft:oak_planks".to_owned(),
|
||||
stack("oak_planks", 4).unwrap()
|
||||
)]
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quick_move_landing_routes_plain_containers() {
|
||||
for (kind, size) in [
|
||||
|
||||
Loaded 100 of 187 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user