mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-11 10:31:38 -10:00
Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ec9c65be87 | ||
|
|
cdfbac97bd | ||
|
|
d237f961f8 | ||
|
|
c8701964f4 | ||
|
|
6a8d226dde | ||
|
|
2c8ae8f2ef | ||
|
|
646a049d2d | ||
|
|
717fe15087 | ||
|
|
c7ba820eac | ||
|
|
2ebefd515c | ||
|
|
8ca8cf121b | ||
|
|
9eff1ee00a | ||
|
|
26d9efa842 | ||
|
|
48aae09c66 | ||
|
|
f70fb7da5d | ||
|
|
99c9f7eaa5 | ||
|
|
6ddd66576d | ||
|
|
89564ffce8 | ||
|
|
fb9b6d4299 | ||
|
|
87d4324eb6 | ||
|
|
16d2f19957 | ||
|
|
bb090e827e | ||
|
|
8426465800 | ||
|
|
427729f784 | ||
|
|
6a495df673 | ||
|
|
9eb3dc457b | ||
|
|
975b29a641 | ||
|
|
23f1b63e9c | ||
|
|
8aec64cc36 | ||
|
|
0aa44ff207 | ||
|
|
8a00ab490a | ||
|
|
d486e88b5b | ||
|
|
b6c527fb8c | ||
|
|
88cbb484fd | ||
|
|
d3e7582474 | ||
|
|
08a3a5e8ef | ||
|
|
32bd0123e2 |
No files matched your search
@@ -75,6 +75,7 @@ Game workspace (root `Cargo.toml`); `launcher/` and `crates/horizon-telemetry-se
|
||||
| `horizon-port-pack` | Porting Java modpacks ([docs/modpacks.md](docs/modpacks.md)) |
|
||||
| `horizon-optifine` | OptiFine-format pack files below every feature that reads them: `.properties`, the name/range/block matchers, paths and `optifine/` folders, and custom entity models (`cem/`: `.jem`/`.jpm` parsing and baking) |
|
||||
| `horizon-polytone` | Polytone-format pack files (`assets/*/polytone/`), read clean-room from the public wiki: targets, biome and dimension modifiers and their environment attribute layers (#11486) |
|
||||
| `horizon-pack-options` | A resource pack's own options as the game lists and keeps them (entries, controls, values, presets), whatever pack format declares them; engine, no dependencies |
|
||||
| `horizon-shader-api` | Horizon's own WGSL shader packs ([docs/api/shaders.md](docs/api/shaders.md)): `horizon-shaders.toml`, the `.hshader` package; shared by the game, the launcher and the registry |
|
||||
| `horizon-shaders` | Shader packs (the `shaders/` folder format), rebuilt clean-room from a written spec of the format; no wgpu |
|
||||
| `horizon-theme` | Design tokens and fonts for the launcher, the game's own menus and the website |
|
||||
|
||||
Generated
+6
@@ -1590,6 +1590,7 @@ dependencies = [
|
||||
"horizon-items",
|
||||
"horizon-narrator",
|
||||
"horizon-optifine",
|
||||
"horizon-pack-options",
|
||||
"horizon-polytone",
|
||||
"horizon-protocol",
|
||||
"horizon-shader-api",
|
||||
@@ -1831,6 +1832,10 @@ dependencies = [
|
||||
"regex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "horizon-pack-options"
|
||||
version = "0.1.0"
|
||||
|
||||
[[package]]
|
||||
name = "horizon-platform"
|
||||
version = "0.1.0"
|
||||
@@ -1848,6 +1853,7 @@ dependencies = [
|
||||
name = "horizon-polytone"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"horizon-pack-options",
|
||||
"log",
|
||||
"regex",
|
||||
"serde_json",
|
||||
|
||||
@@ -47,6 +47,7 @@ members = [
|
||||
"crates/horizon-shaders",
|
||||
"crates/horizon-optifine",
|
||||
"crates/horizon-polytone",
|
||||
"crates/horizon-pack-options",
|
||||
"crates/horizon-shader-api",
|
||||
"crates/horizon-release-sign",
|
||||
"crates/horizon-mod-deps",
|
||||
|
||||
@@ -24,6 +24,7 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
# OptiFine-format pack files: random entity textures (hud.rs), connected textures
|
||||
# (connected.rs) and custom item texture rules (cit.rs).
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
horizon-pack-options = { path = "../horizon-pack-options" }
|
||||
horizon-polytone = { path = "../horizon-polytone" }
|
||||
horizon-protocol = { path = "../horizon-protocol" }
|
||||
horizon-shader-api = { path = "../horizon-shader-api" }
|
||||
|
||||
@@ -288,6 +288,8 @@ fn zip_pack(id: &str, path: PathBuf) -> Pack {
|
||||
}
|
||||
|
||||
fn main() {
|
||||
// The pack formats the game installs at startup, so their loading is measured too.
|
||||
horizon_client_render::pack_formats::install();
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let scale = args
|
||||
.iter()
|
||||
|
||||
@@ -99,10 +99,8 @@ pub struct RenderAssets {
|
||||
pub pbr_normals: Vec<u8>,
|
||||
pub pbr_specular: Vec<u8>,
|
||||
pub pbr_size: u32,
|
||||
/// OptiFine connected textures (empty without `optifine/ctm` files).
|
||||
pub connected: crate::connected::ConnectedTextures,
|
||||
/// Fusion connected textures (empty without Fusion's connecting textures and models).
|
||||
pub fusion: crate::fusion::FusionTextures,
|
||||
/// The packs' connected textures (OptiFine's, Fusion's), when they have some.
|
||||
pub connected: Option<crate::connected::Connected>,
|
||||
/// Counts up each time resources load, from 1: a native mod asks again where blocks'
|
||||
/// sprites are when it changes (`native_block_sprites_version`).
|
||||
pub generation: u64,
|
||||
@@ -345,40 +343,27 @@ impl RenderAssets {
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_stage_{stage}"))?;
|
||||
sprites.insert(root, &format!("minecraft:block/destroy_oit_stage_{stage}"))?;
|
||||
}
|
||||
let fusion = if draws {
|
||||
let _step = crate::spans::startup::step("core.fusion");
|
||||
crate::fusion::FusionTextures::load(root, registry, &mut sprites, &mut models.catalog)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let mut emissive = Vec::new();
|
||||
let connected = if draws {
|
||||
crate::native_emissive::load(root, &mut sprites);
|
||||
// A side's sprite, for rules that connect by tile: the first quad facing it.
|
||||
let catalog = &models.catalog;
|
||||
let mut found = Vec::new();
|
||||
crate::connected::ConnectedTextures::load(
|
||||
// Native mods' emissive textures go where the pack format puts them (after its
|
||||
// models' tiles, before its rules' tiles), else after both.
|
||||
let mut native_loaded = false;
|
||||
let mut native = |sprites: &mut crate::sprites::Sprites| {
|
||||
if !std::mem::replace(&mut native_loaded, true) {
|
||||
crate::native_emissive::load(root, sprites);
|
||||
}
|
||||
};
|
||||
let connected = crate::pack_hooks::formats().connected_textures(
|
||||
root,
|
||||
registry,
|
||||
&mut sprites,
|
||||
|state, face| {
|
||||
let side = horizon_assets::model::Direction::ALL[face as usize];
|
||||
catalog
|
||||
.models_at(
|
||||
state,
|
||||
horizon_core::position::BlockPos::new(0, 0, 0),
|
||||
&mut found,
|
||||
)
|
||||
.ok()?;
|
||||
found
|
||||
.iter()
|
||||
.flat_map(|m| &m.faces)
|
||||
.find(|f| f.quad.facing == side)
|
||||
.map(|f| f.layer)
|
||||
},
|
||||
)
|
||||
&mut models.catalog,
|
||||
&mut native,
|
||||
);
|
||||
native(&mut sprites);
|
||||
connected
|
||||
} else {
|
||||
Default::default()
|
||||
None
|
||||
};
|
||||
// After the connected tiles: a tile identical to a block shares its layer, and the
|
||||
// overlays skip shared layers.
|
||||
@@ -411,7 +396,6 @@ impl RenderAssets {
|
||||
pbr_specular: sprites.specular,
|
||||
pbr_size: sprites.pbr_size,
|
||||
connected,
|
||||
fusion,
|
||||
generation: GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -960,7 +960,7 @@ pub struct Scene<'a> {
|
||||
pub world: &'a World,
|
||||
pub sprites: &'a HashMap<String, Rect>,
|
||||
/// The packs' OptiFine emissive overlays of the sprites.
|
||||
pub emissive: &'a horizon_optifine::emissive::Emissive,
|
||||
pub emissive: &'a crate::entity_textures::Emissive,
|
||||
/// Atlas sprites that contain a texel with fractional alpha. Static
|
||||
/// geometry stays on the cutout pipeline; these models keep the blended
|
||||
/// entity path so resource packs retain their alpha behavior.
|
||||
@@ -1039,7 +1039,7 @@ impl CacheOptions {
|
||||
}
|
||||
}
|
||||
/// The block-entity models and what is baked from them: vanilla's, or the packs' custom entity
|
||||
/// models over them ([`crate::block_entity_cem`]).
|
||||
/// models over them ([`crate::pack_hooks::PackFormats::block_entity_models`]).
|
||||
#[derive(Default)]
|
||||
pub struct BlockEntityShapes {
|
||||
models: HashMap<&'static str, ModelPart>,
|
||||
@@ -1555,7 +1555,7 @@ impl BlockEntityRenderer {
|
||||
/// The models the packs under `root` give, their `.jem` files over vanilla's, for
|
||||
/// [`Self::set_shapes`] (a pack reload): vanilla's own, shared, without any.
|
||||
pub fn shapes_for(&self, root: &Path) -> Arc<BlockEntityShapes> {
|
||||
match crate::block_entity_cem::reshape(root, &self.vanilla.models) {
|
||||
match crate::pack_hooks::formats().block_entity_models(root, &self.vanilla.models) {
|
||||
Some((models, textures)) => Arc::new(BlockEntityShapes::new(models, textures)),
|
||||
None => self.vanilla.clone(),
|
||||
}
|
||||
@@ -4768,7 +4768,8 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn a_chest_jem_reshapes_the_closed_chest_and_paints_its_texture() {
|
||||
use crate::block_entity_cem::tests::{BOX, chest, pack};
|
||||
use crate::pack_formats::block_entity_cem::tests::{BOX, chest, pack};
|
||||
crate::pack_formats::install();
|
||||
let vanilla = Arc::new(BlockEntityShapes::new(
|
||||
HashMap::from([("chest", chest())]),
|
||||
HashMap::new(),
|
||||
@@ -4864,8 +4865,8 @@ mod tests {
|
||||
};
|
||||
// The chest's three faces become the bottom's, the lock's and the custom lid's six, in
|
||||
// the `.jem`'s texture. Ender and copper chests have no file of their own: vanilla's
|
||||
// three faces in their own textures. (Trapped chests: `block_entity_cem`'s tests; their
|
||||
// texture turns festive in late December.)
|
||||
// three faces in their own textures. (Trapped chests: `block_entity_cem`'s tests in
|
||||
// `pack_formats`; their texture turns festive in late December.)
|
||||
for (block, texture, u0, faces) in [
|
||||
("chest", "entity/chest/normal", 0.4, 8),
|
||||
("ender_chest", "entity/chest/ender", 0.2, 3),
|
||||
|
||||
@@ -1,116 +1,157 @@
|
||||
//! OptiFine's Custom Item Textures (and CIT Resewn's): the rules of the enabled packs'
|
||||
//! `optifine/cit/**/*.properties`, read from the overlay resource root. The rules themselves are
|
||||
//! `horizon_optifine::cit`'s.
|
||||
//! Custom item textures: rules a pack format vanilla doesn't read (OptiFine's and CIT Resewn's
|
||||
//! `cit/` folders) gives, which change a stack's texture, model, worn armour layers or
|
||||
//! enchantment glint. The platform's pack formats read and match them ([`crate::pack_hooks`]);
|
||||
//! the engine draws what the matching rule says ([`Rule`]).
|
||||
|
||||
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};
|
||||
use horizon_items::items::ItemStack;
|
||||
use horizon_optifine::cit::{ComponentTest, Source, ValueTest};
|
||||
use horizon_optifine::{Properties, properties_files_in};
|
||||
use horizon_world::nbt::{JavaString, Tag, parse_snbt};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::Path;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// 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`).
|
||||
/// The pack's global `cit.properties` is not a rule.
|
||||
pub fn load_rules(root: &Path) -> Vec<Rule> {
|
||||
let tops = ["optifine", "mcpatcher", "citresewn"];
|
||||
let mut rules: Vec<Rule> = properties_files_in(root, &tops, "cit")
|
||||
.into_iter()
|
||||
.filter(|path| !path.ends_with("/cit.properties"))
|
||||
.filter_map(|path| {
|
||||
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");
|
||||
}
|
||||
rule
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
rules
|
||||
use horizon_items::items::ItemStack;
|
||||
|
||||
use crate::cit_glint::StackGlint;
|
||||
|
||||
/// What a rule changes.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Kind {
|
||||
Item,
|
||||
Armor,
|
||||
Elytra,
|
||||
Enchantment,
|
||||
}
|
||||
|
||||
/// 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 hand a stack is drawn in; GUI items count as the main hand. `Any` is a rule's "either".
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Hand {
|
||||
Any,
|
||||
Main,
|
||||
Off,
|
||||
}
|
||||
|
||||
/// The rules with an index by item and a cache of what each stack matched.
|
||||
#[derive(Debug, Default)]
|
||||
/// An OpenGL blend factor (`GL_ONE`, `GL_SRC_ALPHA`...), as `glBlendFuncSeparate` takes them.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Factor {
|
||||
Zero,
|
||||
One,
|
||||
SrcColor,
|
||||
OneMinusSrcColor,
|
||||
SrcAlpha,
|
||||
OneMinusSrcAlpha,
|
||||
DstAlpha,
|
||||
OneMinusDstAlpha,
|
||||
DstColor,
|
||||
OneMinusDstColor,
|
||||
SrcAlphaSaturate,
|
||||
}
|
||||
|
||||
/// A glint layer's blend factors for colour and alpha.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Blend {
|
||||
pub src: Factor,
|
||||
pub dst: Factor,
|
||||
pub src_alpha: Factor,
|
||||
pub dst_alpha: Factor,
|
||||
}
|
||||
|
||||
/// An enchantment rule's glint layer.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Glint {
|
||||
pub layer: i32,
|
||||
/// `None` draws without blending (replace).
|
||||
pub blend: Option<Blend>,
|
||||
/// Scroll speed: the texture runs through vanilla's glint periods at `speed` times the
|
||||
/// clock in milliseconds.
|
||||
pub speed: f32,
|
||||
/// Degrees the texture turns by.
|
||||
pub rotation: f32,
|
||||
/// Seconds on this layer when the layers take turns.
|
||||
pub duration: f32,
|
||||
/// Multipliers of the texture's colour, never negative.
|
||||
pub rgba: [f32; 4],
|
||||
/// Whether the default glint still draws under this layer.
|
||||
pub use_glint: bool,
|
||||
/// Linear filtering, else nearest.
|
||||
pub blur: bool,
|
||||
}
|
||||
|
||||
/// A rule as the engine draws it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Rule {
|
||||
/// The rule's file, as a resource path.
|
||||
pub path: String,
|
||||
pub kind: Kind,
|
||||
/// Its texture, as a resource path to a `.png`.
|
||||
pub texture: Option<String>,
|
||||
/// Its named textures: (name, resource path).
|
||||
pub textures: Vec<(String, String)>,
|
||||
/// Its model, as a resource path to a `.json`.
|
||||
pub model: Option<String>,
|
||||
/// Its named models.
|
||||
pub models: Vec<(String, String)>,
|
||||
/// What an Enchantment rule draws.
|
||||
pub glint: Glint,
|
||||
}
|
||||
|
||||
/// Which rules a stack matches: the pack format's own conditions.
|
||||
pub trait Matcher: Send + Sync {
|
||||
/// The winning rule of `kind` (Item, Armor or Elytra) for this stack, as an index into
|
||||
/// [`CitRules::rules`].
|
||||
fn select(&self, kind: Kind, stack: &ItemStack, hand: Hand, max_damage: u32) -> Option<usize>;
|
||||
/// Every matching Enchantment rule for the stack drawn in `hand`, in priority order.
|
||||
fn enchantments(&self, stack: &ItemStack, hand: Hand, max_damage: u32) -> Vec<usize>;
|
||||
/// The glint layers this stack draws in `hand`; `None` when the pack changes no glint.
|
||||
/// `foil` is whether the stack draws a glint at all.
|
||||
fn glint(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
foil: bool,
|
||||
) -> Option<Arc<StackGlint>>;
|
||||
/// Leave out the Enchantment rules `keep` refuses: they match no stack.
|
||||
fn keep_glints(&mut self, keep: &dyn Fn(usize) -> bool);
|
||||
}
|
||||
|
||||
/// The packs' custom item texture rules, the winner first, and their matcher.
|
||||
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.
|
||||
any_item: Vec<u32>,
|
||||
/// Per rule, per component test: its literal read as SNBT and as a JSON text component.
|
||||
literals: Vec<Vec<Prepared>>,
|
||||
selected: Mutex<HashMap<u64, Option<u32>>>,
|
||||
enchanted: Mutex<HashMap<u64, Vec<u32>>>,
|
||||
glints: Mutex<HashMap<u64, Arc<StackGlint>>>,
|
||||
/// Whether the default glint draws on items no enchantment rule matches.
|
||||
pub use_glint: bool,
|
||||
matcher: Option<Box<dyn Matcher>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
struct Prepared {
|
||||
snbt: Option<Tag>,
|
||||
json: Option<String>,
|
||||
}
|
||||
|
||||
/// Cache entries kept before the cache starts over.
|
||||
const CACHE_LIMIT: usize = 4096;
|
||||
|
||||
impl CitRules {
|
||||
/// The enabled packs' rules under the overlay resource root.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
impl Default for CitRules {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
global: crate::cit_glint::load_global(root),
|
||||
..Self::new(load_rules(root))
|
||||
rules: Vec::new(),
|
||||
use_glint: true,
|
||||
matcher: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// `rules` in priority order, the winner first.
|
||||
pub fn new(rules: Vec<Rule>) -> Self {
|
||||
let mut by_item: [HashMap<String, Vec<u32>>; 4] = Default::default();
|
||||
let mut any_item = Vec::new();
|
||||
for (i, rule) in rules.iter().enumerate() {
|
||||
let i = i as u32;
|
||||
if rule.kind == Kind::Enchantment && rule.items.is_empty() {
|
||||
any_item.push(i);
|
||||
}
|
||||
for item in &rule.items {
|
||||
let list = by_item[rule.kind as usize].entry(item.clone()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let literals = rules
|
||||
.iter()
|
||||
.map(|r| r.components.iter().map(prepare).collect())
|
||||
.collect();
|
||||
impl std::fmt::Debug for CitRules {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("CitRules")
|
||||
.field("rules", &self.rules)
|
||||
.field("use_glint", &self.use_glint)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl CitRules {
|
||||
/// The rules of the overlay at `root`, from the installed pack formats.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
crate::pack_hooks::formats()
|
||||
.item_textures(root)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// `rules`, in priority order, matched by `matcher`.
|
||||
pub fn new(rules: Vec<Rule>, use_glint: bool, matcher: Box<dyn Matcher>) -> Self {
|
||||
Self {
|
||||
rules,
|
||||
by_item,
|
||||
any_item,
|
||||
literals,
|
||||
..Default::default()
|
||||
use_glint,
|
||||
matcher: Some(matcher),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,17 +162,12 @@ impl CitRules {
|
||||
/// 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));
|
||||
if let Some(matcher) = &mut self.matcher {
|
||||
matcher.keep_glints(&keep);
|
||||
}
|
||||
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`.
|
||||
/// [`Matcher::select`].
|
||||
pub fn select(
|
||||
&self,
|
||||
kind: Kind,
|
||||
@@ -139,51 +175,17 @@ impl CitRules {
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
) -> Option<usize> {
|
||||
let candidates = self.by_item[kind as usize].get(&*stack.item)?;
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key((0, kind, hand), stack, max_damage, comps.as_ref());
|
||||
if let Some(hit) = lock(&self.selected).get(&key) {
|
||||
return hit.map(|i| i as usize);
|
||||
}
|
||||
let found = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
|
||||
remember(&self.selected, key, found);
|
||||
found.map(|i| i as usize)
|
||||
self.matcher.as_ref()?.select(kind, stack, hand, max_damage)
|
||||
}
|
||||
|
||||
/// Every matching Enchantment rule for the stack drawn in `hand` (`Hand::Main` for one in a
|
||||
/// GUI), in priority order.
|
||||
/// [`Matcher::enchantments`].
|
||||
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);
|
||||
if named.is_empty() && self.any_item.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key(
|
||||
(1, Kind::Enchantment, hand),
|
||||
stack,
|
||||
max_damage,
|
||||
comps.as_ref(),
|
||||
);
|
||||
let hit = lock(&self.enchanted).get(&key).cloned();
|
||||
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, hand, max_damage, comps.as_ref()));
|
||||
remember(&self.enchanted, key, all.clone());
|
||||
all
|
||||
});
|
||||
found.into_iter().map(|i| i as usize).collect()
|
||||
self.matcher
|
||||
.as_ref()
|
||||
.map_or_else(Vec::new, |m| m.enchantments(stack, hand, max_damage))
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// [`Matcher::glint`].
|
||||
pub fn glint(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
@@ -191,659 +193,6 @@ impl CitRules {
|
||||
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: Hand,
|
||||
max_damage: u32,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let rule = &self.rules[i as usize];
|
||||
if rule.hand != Hand::Any && rule.hand != hand {
|
||||
return false;
|
||||
}
|
||||
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
|
||||
if rule.stack_size.as_ref().is_some_and(|r| !r.contains(count)) {
|
||||
return false;
|
||||
}
|
||||
// CIT Resewn: only damageable items have damage.
|
||||
let damage = if max_damage > 0 { stack.damage } else { 0 };
|
||||
if rule
|
||||
.damage
|
||||
.as_ref()
|
||||
.is_some_and(|d| !d.matches(damage, max_damage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let enchantments = stack.crafting_enchantments();
|
||||
let listed = |id: &str| rule.enchantments.iter().any(|e| e == id);
|
||||
if !rule.enchantments.is_empty() && !enchantments.iter().any(|(id, _)| listed(id)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(levels) = &rule.enchantment_levels {
|
||||
let level_in_range = enchantments.iter().any(|(id, level)| {
|
||||
(rule.enchantments.is_empty() || listed(id))
|
||||
&& levels.contains(i32::try_from(*level).unwrap_or(i32::MAX))
|
||||
});
|
||||
if !level_in_range {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let literals = &self.literals[i as usize];
|
||||
rule.components
|
||||
.iter()
|
||||
.zip(literals)
|
||||
.all(|(test, lit)| component_test(test, lit, stack, comps) != test.negate)
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
|
||||
let mut cache = lock(cache);
|
||||
if cache.len() >= CACHE_LIMIT {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, value);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
(stack.count, stack.damage, max, comps).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn prepare(test: &ComponentTest) -> Prepared {
|
||||
let ValueTest::Literal(lit) = &test.value else {
|
||||
return Prepared::default();
|
||||
};
|
||||
let text = lit.text.trim();
|
||||
let snbt = match parse_snbt(text) {
|
||||
Ok((tag, read)) if read == text.len() && text.starts_with(['{', '[']) => Some(tag),
|
||||
_ => None,
|
||||
};
|
||||
let json = json_plain(text);
|
||||
Prepared { snbt, json }
|
||||
}
|
||||
|
||||
/// The plain text of `s` read as a JSON text component.
|
||||
fn json_plain(s: &str) -> Option<String> {
|
||||
if !s.starts_with(['{', '[', '"']) {
|
||||
return None;
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(s).ok()?;
|
||||
Some(comp_text(&value).plain())
|
||||
}
|
||||
|
||||
/// Whether one test passes, before its `negate`.
|
||||
fn component_test(
|
||||
test: &ComponentTest,
|
||||
lit: &Prepared,
|
||||
stack: &ItemStack,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let legacy;
|
||||
let (root, text_depth) = match &test.source {
|
||||
Source::Component(id) => {
|
||||
let depth = match id.as_str() {
|
||||
"minecraft:custom_name" | "minecraft:item_name" => Some(0),
|
||||
"minecraft:lore" => Some(1),
|
||||
_ => None,
|
||||
};
|
||||
(comps.and_then(|c| c.get(id)), depth)
|
||||
}
|
||||
Source::LegacyEnchantments { stored } => {
|
||||
let levels = if *stored {
|
||||
stack.stored_enchantments()
|
||||
} else {
|
||||
stack.enchantments()
|
||||
};
|
||||
legacy = Tag::List(
|
||||
levels
|
||||
.iter()
|
||||
.map(|(id, lvl)| {
|
||||
let lvl = i16::try_from(*lvl).unwrap_or(i16::MAX);
|
||||
Tag::compound([
|
||||
("id", Tag::String((&**id).into())),
|
||||
("lvl", Tag::Short(lvl)),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
(Some(&legacy), None)
|
||||
}
|
||||
};
|
||||
// Fields the component leaves out when 0, which pre-1.20.5 items always wrote.
|
||||
let defaulted;
|
||||
let root = match (&test.source, root, test.path.first()) {
|
||||
(Source::Component(id), Some(Tag::Compound(map)), Some(field))
|
||||
if matches!(
|
||||
(id.as_str(), field.as_str()),
|
||||
("minecraft:written_book_content", "generation")
|
||||
| ("minecraft:fireworks", "flight_duration")
|
||||
) && !map.contains_key(&JavaString::from(field.as_str())) =>
|
||||
{
|
||||
let mut map = map.clone();
|
||||
map.insert(JavaString::from(field.as_str()), Tag::Int(0));
|
||||
defaulted = Tag::Compound(map);
|
||||
Some(&defaulted)
|
||||
}
|
||||
(_, root, _) => root,
|
||||
};
|
||||
let Some(root) = root else {
|
||||
return matches!(test.value, ValueTest::Exists(false));
|
||||
};
|
||||
if let ValueTest::Exists(want) = test.value {
|
||||
return walk(root, &test.path, 0, &mut |_, _| true) == want;
|
||||
}
|
||||
walk(root, &test.path, 0, &mut |tag, depth| {
|
||||
value_test(&test.value, lit, tag, text_depth == Some(depth))
|
||||
})
|
||||
}
|
||||
|
||||
/// Visits what `path` reaches from `tag`, true once `visit` is.
|
||||
fn walk(
|
||||
tag: &Tag,
|
||||
path: &[String],
|
||||
depth: usize,
|
||||
visit: &mut dyn FnMut(&Tag, usize) -> bool,
|
||||
) -> bool {
|
||||
let Some((seg, rest)) = path.split_first() else {
|
||||
return visit(tag, depth);
|
||||
};
|
||||
let mut next = |t: &Tag| walk(t, rest, depth + 1, visit);
|
||||
let len = match tag {
|
||||
Tag::Compound(map) if seg == "*" => return map.values().any(&mut next),
|
||||
Tag::Compound(map) => return map.get(&JavaString::from(seg.as_str())).is_some_and(next),
|
||||
Tag::List(v) => v.len(),
|
||||
Tag::ByteArray(v) => v.len(),
|
||||
Tag::IntArray(v) => v.len(),
|
||||
Tag::LongArray(v) => v.len(),
|
||||
_ => return false,
|
||||
};
|
||||
let element = |i: usize| match tag {
|
||||
Tag::List(v) => Cow::Borrowed(&v[i]),
|
||||
Tag::ByteArray(v) => Cow::Owned(Tag::Byte(v[i] as i8)),
|
||||
Tag::IntArray(v) => Cow::Owned(Tag::Int(v[i])),
|
||||
Tag::LongArray(v) => Cow::Owned(Tag::Long(v[i])),
|
||||
_ => Cow::Owned(Tag::End),
|
||||
};
|
||||
if seg == "count" {
|
||||
return next(&Tag::Int(i32::try_from(len).unwrap_or(i32::MAX)));
|
||||
}
|
||||
if seg == "*" {
|
||||
return (0..len).any(|i| next(&element(i)));
|
||||
}
|
||||
seg.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|&i| i < len)
|
||||
.is_some_and(|i| next(&element(i)))
|
||||
}
|
||||
|
||||
fn int_value(tag: &Tag) -> Option<i64> {
|
||||
match *tag {
|
||||
Tag::Byte(v) => Some(v.into()),
|
||||
Tag::Short(v) => Some(v.into()),
|
||||
Tag::Int(v) => Some(v.into()),
|
||||
Tag::Long(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A number as Java's `String.valueOf` writes it (`3`, `3.0`).
|
||||
fn decimal(tag: &Tag) -> Option<String> {
|
||||
let s = match *tag {
|
||||
Tag::Float(b) => f32::from_bits(b).to_string(),
|
||||
Tag::Double(b) => f64::from_bits(b).to_string(),
|
||||
_ => return int_value(tag).map(|v| v.to_string()),
|
||||
};
|
||||
Some(if s.contains(['.', 'e', 'N', 'i']) {
|
||||
s
|
||||
} else {
|
||||
s + ".0"
|
||||
})
|
||||
}
|
||||
|
||||
/// `text` against a string, its JSON text, or the plain text of a text component.
|
||||
fn text_matches(tag: &Tag, is_text: bool, text: impl Fn(&str) -> bool) -> bool {
|
||||
match tag {
|
||||
Tag::String(s) => {
|
||||
let s = s.to_string_lossy();
|
||||
text(&s)
|
||||
|| (is_text && text(&name_text(tag).plain()))
|
||||
|| json_plain(&s).is_some_and(|p| text(&p))
|
||||
}
|
||||
Tag::Compound(_) | Tag::List(_) => is_text && text(&name_text(tag).plain()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_test(test: &ValueTest, lit: &Prepared, tag: &Tag, is_text: bool) -> bool {
|
||||
match test {
|
||||
ValueTest::Exists(_) => true,
|
||||
ValueTest::Range(r) => {
|
||||
int_value(tag).is_some_and(|v| i32::try_from(v).is_ok_and(|v| r.contains(v)))
|
||||
}
|
||||
ValueTest::Raw(name) => name.matches(&canon_tag(tag)),
|
||||
ValueTest::Text(name) => match decimal(tag) {
|
||||
Some(n) => name.matches(&n),
|
||||
None => text_matches(tag, is_text, |s| name.matches(s)),
|
||||
},
|
||||
ValueTest::Literal(l) => match tag {
|
||||
Tag::Float(b) => l.text.trim().parse::<f32>().ok() == Some(f32::from_bits(*b)),
|
||||
Tag::Double(b) => l.float == Some(f64::from_bits(*b)),
|
||||
Tag::Compound(_) | Tag::List(_)
|
||||
if lit.snbt.as_ref().is_some_and(|e| contains(e, tag)) =>
|
||||
{
|
||||
true
|
||||
}
|
||||
_ => match int_value(tag) {
|
||||
Some(v) => l.int == Some(v),
|
||||
None => text_matches(tag, is_text, |s| {
|
||||
s == l.text || lit.json.as_deref() == Some(s)
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `NbtUtils.compareNbt(expected, actual, true)`: every key and element of `expected` found in
|
||||
/// `actual`.
|
||||
fn contains(expected: &Tag, actual: &Tag) -> bool {
|
||||
match (expected, actual) {
|
||||
(Tag::Compound(e), Tag::Compound(a)) => e
|
||||
.iter()
|
||||
.all(|(k, v)| a.get(k).is_some_and(|a| contains(v, a))),
|
||||
(Tag::List(e), Tag::List(a)) if e.is_empty() => a.is_empty(),
|
||||
(Tag::List(e), Tag::List(a)) => e.iter().all(|e| a.iter().any(|a| contains(e, a))),
|
||||
_ => expected == actual,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Rules from (file stem, properties), sorted as `load_rules` sorts them.
|
||||
fn rules(files: &[(&str, &str)]) -> CitRules {
|
||||
let mut rules: Vec<Rule> = files
|
||||
.iter()
|
||||
.map(|(stem, text)| {
|
||||
let path = format!("assets/minecraft/optifine/cit/{stem}.properties");
|
||||
let text = format!("texture=x\n{text}");
|
||||
Rule::parse(&path, &Properties::parse_equals_first(&text), |_| true).unwrap()
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
CitRules::new(rules)
|
||||
}
|
||||
|
||||
fn stem(cit: &CitRules, i: Option<usize>) -> Option<&str> {
|
||||
let path = &cit.rules[i?].path;
|
||||
path.rsplit('/').next()?.strip_suffix(".properties")
|
||||
}
|
||||
|
||||
fn item(cit: &CitRules, stack: ItemStack) -> Option<&str> {
|
||||
stem(cit, cit.select(Kind::Item, &stack, Hand::Main, 0))
|
||||
}
|
||||
|
||||
fn new(id: &str) -> ItemStack {
|
||||
ItemStack::new(&format!("minecraft:{id}"), 1)
|
||||
}
|
||||
|
||||
fn string(s: &str) -> Tag {
|
||||
Tag::String(s.into())
|
||||
}
|
||||
|
||||
fn with(id: &str, component: &str, tag: Tag) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let component = format!("minecraft:{component}");
|
||||
stack.data_mut().other.push((component, tag));
|
||||
stack
|
||||
}
|
||||
|
||||
fn enchanted(id: &str, levels: &[(&str, u32)]) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let levels = levels
|
||||
.iter()
|
||||
.map(|(e, l)| (Arc::from(format!("minecraft:{e}")), *l));
|
||||
stack.data_mut().enchantments = levels.collect();
|
||||
stack
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavier_rules_win_and_kinds_stay_apart() {
|
||||
let cit = rules(&[
|
||||
("plain", "items=stick"),
|
||||
("heavy", "items=stick\nweight=2\nstackSize=2-"),
|
||||
("offhand", "items=stick\nweight=5\nhand=off"),
|
||||
("armor", "type=armor\nitems=stick"),
|
||||
("wings", "type=elytra"),
|
||||
]);
|
||||
assert_eq!(item(&cit, new("stick")), Some("plain"));
|
||||
assert_eq!(
|
||||
item(&cit, ItemStack::new("minecraft:stick", 3)),
|
||||
Some("heavy")
|
||||
);
|
||||
let pick = |kind, id, hand, max| stem(&cit, cit.select(kind, &new(id), hand, max));
|
||||
assert_eq!(pick(Kind::Item, "stick", Hand::Off, 0), Some("offhand"));
|
||||
assert_eq!(item(&cit, new("stone")), None);
|
||||
assert_eq!(item(&cit, new("elytra")), None);
|
||||
assert_eq!(pick(Kind::Elytra, "elytra", Hand::Main, 432), Some("wings"));
|
||||
assert_eq!(pick(Kind::Armor, "stick", Hand::Main, 0), Some("armor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_match_by_their_plain_text() {
|
||||
let cit = rules(&[
|
||||
("glob", "items=stick\nnbt.display.Name=ipattern:*sword"),
|
||||
("exact", "items=stone\ncomponents.custom_name=My Sword"),
|
||||
("json", "items=dirt\nnbt.display.Name=[\"My \",\"Sword\"]"),
|
||||
]);
|
||||
let named = |id: &str, name: Tag| {
|
||||
let mut stack = new(id);
|
||||
stack.data_mut().custom_name = Some(name);
|
||||
stack
|
||||
};
|
||||
let styled = Tag::compound([("text", string("My Sword")), ("color", string("red"))]);
|
||||
assert_eq!(item(&cit, named("stick", styled.clone())), Some("glob"));
|
||||
assert_eq!(item(&cit, named("stick", string("Blade"))), None);
|
||||
assert_eq!(item(&cit, new("stick")), None);
|
||||
assert_eq!(item(&cit, named("stone", styled.clone())), Some("exact"));
|
||||
assert_eq!(item(&cit, named("dirt", styled)), Some("json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lore_lines_and_list_counts() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"any_line",
|
||||
"items=stick\nnbt.display.Lore.*=ipattern:*legendary*",
|
||||
),
|
||||
("two_lines", "items=stone\ncomponents.lore.count=2"),
|
||||
]);
|
||||
let bold = Tag::compound([("text", string("A LEGENDARY item")), ("bold", Tag::Byte(1))]);
|
||||
let lore = Tag::List(vec![string("Common"), bold]);
|
||||
assert_eq!(
|
||||
item(&cit, with("stick", "lore", lore.clone())),
|
||||
Some("any_line")
|
||||
);
|
||||
assert_eq!(item(&cit, with("stone", "lore", lore)), Some("two_lines"));
|
||||
let one = Tag::List(vec![string("A legendary item")]);
|
||||
assert_eq!(item(&cit, with("stone", "lore", one)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_data_existence_and_negation() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"aote",
|
||||
"items=stick\nweight=2\nnbt.ExtraAttributes.id=ASPECT_OF_THE_END",
|
||||
),
|
||||
(
|
||||
"not_hyp",
|
||||
"items=stick\nweight=1\ncomponent.custom_data.ExtraAttributes.id=!HYP",
|
||||
),
|
||||
("unnamed", "items=stick\nnbt.display.Name=exists:false"),
|
||||
]);
|
||||
let data = |id: &str| {
|
||||
let attributes =
|
||||
Tag::compound([("ExtraAttributes", Tag::compound([("id", string(id))]))]);
|
||||
with("stick", "custom_data", attributes)
|
||||
};
|
||||
assert_eq!(item(&cit, data("ASPECT_OF_THE_END")), Some("aote"));
|
||||
assert_eq!(item(&cit, data("MIDAS")), Some("not_hyp"));
|
||||
assert_eq!(item(&cit, data("HYP")), Some("unnamed"));
|
||||
let mut named = data("HYP");
|
||||
named.data_mut().custom_name = Some(string("Hyperion"));
|
||||
assert_eq!(item(&cit, named), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_enchantment_lists_and_model_data() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp",
|
||||
"items=stick\nnbt.Enchantments.*.id=minecraft:sharpness\nnbt.Enchantments.0.lvl=5",
|
||||
),
|
||||
("cmd", "items=stone\nnbt.CustomModelData=3"),
|
||||
(
|
||||
"cmd_snbt",
|
||||
"items=dirt\ncomponents.custom_model_data={floats:[3.0f]}",
|
||||
),
|
||||
]);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 5)])),
|
||||
Some("sharp")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stick", &[("sharpness", 4)])), None);
|
||||
let model = |id: &str, f: f32| {
|
||||
let floats = Tag::List(vec![Tag::Float(f.to_bits()), Tag::Float(7f32.to_bits())]);
|
||||
with(id, "custom_model_data", Tag::compound([("floats", floats)]))
|
||||
};
|
||||
assert_eq!(item(&cit, model("stone", 3.0)), Some("cmd"));
|
||||
assert_eq!(item(&cit, model("stone", 3.5)), None);
|
||||
assert_eq!(item(&cit, model("dirt", 3.0)), Some("cmd_snbt"));
|
||||
assert_eq!(item(&cit, model("dirt", 2.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_left_out_at_zero_read_as_zero() {
|
||||
let cit = rules(&[("zero", "items=firework_rocket\nnbt.Fireworks.Flight=0")]);
|
||||
let mut rocket = new("firework_rocket");
|
||||
rocket.data_mut().fireworks = Some((0, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket.clone()), Some("zero"));
|
||||
rocket.data_mut().fireworks = Some((2, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damage_counts_only_for_damageable_items() {
|
||||
let cit = rules(&[
|
||||
("worn", "items=diamond_sword\ndamage=50-100%"),
|
||||
("new", "items=stick\ndamage=0"),
|
||||
]);
|
||||
let damaged = |id: &str, damage: u32, max: u32| {
|
||||
let mut stack = new(id);
|
||||
stack.damage = damage;
|
||||
stem(&cit, cit.select(Kind::Item, &stack, Hand::Main, max))
|
||||
};
|
||||
// Half of 1561 is 780.5.
|
||||
assert_eq!(damaged("diamond_sword", 780, 1561), None);
|
||||
assert_eq!(damaged("diamond_sword", 781, 1561), Some("worn"));
|
||||
assert_eq!(damaged("stick", 5, 0), Some("new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enchantment_levels_and_rules_for_any_item() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp3",
|
||||
"items=stick\nenchantments=sharpness\nenchantmentLevels=3-",
|
||||
),
|
||||
("any3", "items=stone\nenchantmentLevels=3-"),
|
||||
("glint", "type=enchantment\nenchantments=sharpness"),
|
||||
(
|
||||
"sword_glint",
|
||||
"type=enchantment\nitems=diamond_sword\nweight=2",
|
||||
),
|
||||
]);
|
||||
let mixed = [("sharpness", 2), ("unbreaking", 3)];
|
||||
assert_eq!(item(&cit, enchanted("stick", &mixed)), None);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 4)])),
|
||||
Some("sharp3")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
|
||||
let glints = |stack: ItemStack| -> Vec<&str> {
|
||||
let found = cit.enchantments(&stack, Hand::Main, 0).into_iter();
|
||||
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
|
||||
};
|
||||
let sharp = [("sharpness", 1)];
|
||||
assert_eq!(
|
||||
glints(enchanted("diamond_sword", &sharp)),
|
||||
["sword_glint", "glint"]
|
||||
);
|
||||
assert_eq!(glints(enchanted("stick", &sharp)), ["glint"]);
|
||||
assert!(glints(new("stick")).is_empty());
|
||||
// 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"]);
|
||||
self.matcher.as_ref()?.glint(stack, hand, max_damage, foil)
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,8 @@
|
||||
//! 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::{Kind, Rule};
|
||||
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`
|
||||
@@ -114,10 +114,9 @@ pub fn armor_sprites(rules: &[Rule]) -> BTreeSet<String> {
|
||||
#[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()
|
||||
crate::pack_formats::cit::parse_rule(path, text).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
//! `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 crate::cit::{Kind, Rule};
|
||||
use horizon_assets::model::{CuboidModel, TextureSlot};
|
||||
use horizon_optifine::cit::{Kind, Rule};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
@@ -55,7 +55,7 @@ pub fn model_id(resource: &str) -> Option<String> {
|
||||
/// and for one that would leave the resource root (a pack model may write any id).
|
||||
pub fn resource(id: &str, ext: &str) -> Option<String> {
|
||||
let rest = id.strip_prefix(NAMESPACE)?.strip_prefix(':')?;
|
||||
horizon_optifine::paths::valid(rest).then(|| format!("assets/{rest}.{ext}"))
|
||||
stays_in_root(rest).then(|| format!("assets/{rest}.{ext}"))
|
||||
}
|
||||
|
||||
/// Sprite ids the rules whose kind passes `keep` draw: their `texture` and `texture.<name>`
|
||||
@@ -102,7 +102,14 @@ fn relative(file: &str, value: &str, ext: &str) -> Option<String> {
|
||||
.and_then(|r| r.strip_suffix('.'))
|
||||
.unwrap_or(rest);
|
||||
let path = format!("{dir}/{rest}.{ext}");
|
||||
horizon_optifine::paths::valid(&path).then_some(path)
|
||||
stays_in_root(&path).then_some(path)
|
||||
}
|
||||
|
||||
/// Whether a resource path stays in the root: no `.`, `..` or empty segment, and no `\` (a
|
||||
/// separator on Windows, where `..\..` would climb).
|
||||
fn stays_in_root(path: &str) -> bool {
|
||||
path.split('/')
|
||||
.all(|segment| !matches!(segment, "" | "." | "..") && !segment.contains('\\'))
|
||||
}
|
||||
|
||||
/// The id a relative value of the model at `file` names, as `CuboidModel::from_json` read it
|
||||
@@ -294,11 +301,11 @@ mod tests {
|
||||
"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()
|
||||
})
|
||||
let rule = crate::pack_formats::cit::parse_rule_in(
|
||||
&root,
|
||||
"assets/minecraft/optifine/cit/a.properties",
|
||||
"items=stick\nmodel=./swords/flame",
|
||||
)
|
||||
.unwrap();
|
||||
let models = load_models(&root, &[rule]);
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
|
||||
@@ -1,26 +1,6 @@
|
||||
//! 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()
|
||||
}
|
||||
//! Custom item textures' enchantment glints: the layers a stack draws over (or instead of)
|
||||
//! vanilla's glint, and how strongly each draws over time. Which layers a stack gets is the pack
|
||||
//! format's ([`crate::cit::Matcher::glint`]); a layer's look is its rule's [`crate::cit::Glint`].
|
||||
|
||||
/// One glint layer a stack draws.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
@@ -33,6 +13,17 @@ pub struct GlintLayer {
|
||||
duration: f32,
|
||||
}
|
||||
|
||||
impl GlintLayer {
|
||||
/// Rule `rule`'s layer at `weight`, on for `duration` seconds when the layers take turns.
|
||||
pub fn new(rule: usize, weight: f32, duration: f32) -> Self {
|
||||
Self {
|
||||
rule,
|
||||
weight,
|
||||
duration,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What a stack's glint is made of, worked out when the stack changes.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct StackGlint {
|
||||
@@ -45,84 +36,14 @@ pub struct StackGlint {
|
||||
}
|
||||
|
||||
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();
|
||||
/// `layers`, bottom first; `vanilla` whether vanilla's glint draws under them; `cycle_fade`
|
||||
/// the seconds each layer fades into the next when they take turns, else `None` (each
|
||||
/// draws at its weight).
|
||||
pub fn from_parts(layers: Vec<GlintLayer>, vanilla: bool, cycle_fade: Option<f32>) -> Self {
|
||||
Self {
|
||||
layers,
|
||||
vanilla,
|
||||
cycle_fade: cycle.then_some(global.fade),
|
||||
cycle_fade,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,208 +100,3 @@ fn cycle_intensity(layers: &[GlintLayer], fade: f32, index: usize, seconds: f64)
|
||||
// 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)));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,271 @@
|
||||
//! Entity textures a pack format vanilla doesn't read changes (OptiFine's random entities, ETF's
|
||||
//! blinking and emissive textures): the variants of each texture and how an entity picks one,
|
||||
//! the blink frames a texture shows now and then, and the overlays that glow over a texture.
|
||||
//! The platform's pack formats fill them once per pack load ([`crate::pack_hooks`]); drawing
|
||||
//! only looks them up in these tables.
|
||||
//!
|
||||
//! Textures have the names the entity atlas gives them (`entity/zombie/zombie`,
|
||||
//! `<ns>:<path>`); files outside `textures/` are listed with the name they take
|
||||
//! ([`RandomTextures::files`], [`Blinks::files`], [`Emissive::files`]) for the atlas to pack.
|
||||
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::path::Path;
|
||||
|
||||
/// A pack's entity texture changes: variants, blinks and emissive overlays.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct EntityTextures {
|
||||
pub random: RandomTextures,
|
||||
pub blinks: Blinks,
|
||||
pub emissive: Emissive,
|
||||
}
|
||||
|
||||
impl EntityTextures {
|
||||
/// The changes of the overlay at `root`, from the installed pack formats.
|
||||
pub fn load(root: &Path) -> Self {
|
||||
crate::pack_hooks::formats()
|
||||
.entity_textures(root)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// What a variant rule can ask about an entity. `None` is a fact the entity doesn't have (a
|
||||
/// creeper's profession): a rule asking for it doesn't match.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Facts<'a> {
|
||||
/// Where the entity was first seen: its biome (`minecraft:plains`) and block height.
|
||||
pub biome: Option<&'a str>,
|
||||
pub y: Option<i32>,
|
||||
/// The block it stood on and the block it stood in when first seen; empty when unknown.
|
||||
pub blocks: &'a [Block<'a>],
|
||||
/// Its custom name, with `§` formatting codes where the name has them.
|
||||
pub name: Option<&'a str>,
|
||||
/// Villager profession (`minecraft:farmer`) and level.
|
||||
pub profession: Option<(&'a str, i32)>,
|
||||
/// Collar, wool, carpet or shell colour, as a dye name (`light_blue`).
|
||||
pub color: Option<&'a str>,
|
||||
pub baby: Option<bool>,
|
||||
/// Health and max health.
|
||||
pub health: Option<(f32, f32)>,
|
||||
/// When first seen: moon phase (0-7), time of day (0-23999) and weather.
|
||||
pub moon_phase: Option<i32>,
|
||||
pub day_time: Option<i32>,
|
||||
pub weather: Option<Weather>,
|
||||
/// Slime, magma cube or phantom size: 0 small, 1 medium, 3 big.
|
||||
pub size: Option<i32>,
|
||||
}
|
||||
|
||||
/// A block and the values of its state properties.
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub struct Block<'a> {
|
||||
pub id: &'a str,
|
||||
pub states: &'a [(&'a str, &'a str)],
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Weather {
|
||||
Clear,
|
||||
Rain,
|
||||
Thunder,
|
||||
}
|
||||
|
||||
/// How a pack format's rule files choose a variant.
|
||||
pub trait VariantRules: Send + Sync {
|
||||
/// The variant rule file `rules` ([`Entry::rules`]) gives an entity with these facts and
|
||||
/// seed, `None` when no rule matches.
|
||||
fn matching(&self, rules: usize, facts: &Facts, seed: i32) -> Option<u32>;
|
||||
}
|
||||
|
||||
/// Every texture a pack varies, by its atlas name.
|
||||
#[derive(Default)]
|
||||
pub struct RandomTextures {
|
||||
entries: HashMap<String, Entry>,
|
||||
/// Every variant's file (from the resource root) and atlas name.
|
||||
files: Vec<(String, String)>,
|
||||
rules: Option<Box<dyn VariantRules>>,
|
||||
generation: u64,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for RandomTextures {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("RandomTextures")
|
||||
.field("entries", &self.entries)
|
||||
.field("generation", &self.generation)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// One texture's variants.
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Entry {
|
||||
/// The rule file that chooses this texture's variant ([`RandomTextures::matching`]).
|
||||
pub rules: Option<usize>,
|
||||
/// The atlas name of each variant, by its number.
|
||||
pub variants: BTreeMap<u32, String>,
|
||||
}
|
||||
|
||||
impl RandomTextures {
|
||||
/// `entries` by atlas name, their variants' `files` (file, atlas name), the rule files
|
||||
/// `rules` that choose, and `generation`: different for every load (never 0), so choices
|
||||
/// made from another load's rules are known to be stale.
|
||||
pub fn new(
|
||||
entries: HashMap<String, Entry>,
|
||||
files: Vec<(String, String)>,
|
||||
rules: Box<dyn VariantRules>,
|
||||
generation: u64,
|
||||
) -> Self {
|
||||
Self {
|
||||
entries,
|
||||
files,
|
||||
rules: Some(rules),
|
||||
generation,
|
||||
}
|
||||
}
|
||||
|
||||
/// This load's number; 0 for the empty set.
|
||||
pub fn generation(&self) -> u64 {
|
||||
self.generation
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.entries.is_empty()
|
||||
}
|
||||
|
||||
/// The variants of the texture with this atlas name.
|
||||
pub fn get(&self, texture: &str) -> Option<&Entry> {
|
||||
self.entries.get(texture)
|
||||
}
|
||||
|
||||
/// [`VariantRules::matching`].
|
||||
pub fn matching(&self, rules: usize, facts: &Facts, seed: i32) -> Option<u32> {
|
||||
self.rules.as_ref()?.matching(rules, facts, seed)
|
||||
}
|
||||
|
||||
/// Every variant's file (its path from the resource root) and atlas name, for the entity
|
||||
/// atlas to pack.
|
||||
pub fn files(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.files.iter().map(|(f, n)| (f.as_str(), n.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// The atlas name of variant `index`, or `None` for the texture itself.
|
||||
pub fn texture(&self, index: u32) -> Option<&str> {
|
||||
self.variants.get(&index).map(String::as_str)
|
||||
}
|
||||
|
||||
/// The even choice of an entity with this seed when no rules apply: the texture and its
|
||||
/// variants 2, 3... up to the first missing one.
|
||||
pub fn even(&self, seed: i32) -> u32 {
|
||||
let mut count = 1;
|
||||
while self.variants.contains_key(&(count + 1)) {
|
||||
count += 1;
|
||||
}
|
||||
seed.unsigned_abs() % count + 1
|
||||
}
|
||||
}
|
||||
|
||||
/// An entity's seed from its UUID (most then least significant 64 bits): Java's
|
||||
/// `UUID.hashCode`, so it is fixed for the entity's whole life, on every client.
|
||||
pub fn seed(most: i64, least: i64) -> i32 {
|
||||
let x = most ^ least;
|
||||
((x >> 32) ^ x) as i32
|
||||
}
|
||||
|
||||
/// The pack's blinking textures, by the atlas name of the texture they replace.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Blinks {
|
||||
blinks: HashMap<String, Blink>,
|
||||
/// The blink frames outside `textures/`: file and atlas name.
|
||||
files: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
/// A texture's blink.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Blink {
|
||||
/// The atlas names of the shut and half-shut frames.
|
||||
pub shut: String,
|
||||
pub half: Option<String>,
|
||||
/// Ticks a blink lasts (at least 1) and ticks from one blink to the next (more).
|
||||
pub length: u32,
|
||||
pub frequency: u32,
|
||||
}
|
||||
|
||||
impl Blinks {
|
||||
/// `blinks` by the atlas name of the texture they replace, and the frames outside
|
||||
/// `textures/` (file, atlas name).
|
||||
pub fn new(blinks: HashMap<String, Blink>, files: Vec<(String, String)>) -> Self {
|
||||
Self { blinks, files }
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.blinks.is_empty()
|
||||
}
|
||||
|
||||
pub fn get(&self, texture: &str) -> Option<&Blink> {
|
||||
self.blinks.get(texture)
|
||||
}
|
||||
|
||||
/// The frames outside `textures/`: file and atlas name, for the entity atlas to pack (it
|
||||
/// walks `textures/` itself).
|
||||
pub fn files(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.files.iter().map(|(f, n)| (f.as_str(), n.as_str()))
|
||||
}
|
||||
}
|
||||
|
||||
impl Blink {
|
||||
/// The frame an entity shows `tick` ticks into its life: `None` with its eyes open. `phase`
|
||||
/// spreads entities' blinks apart. With a half-shut frame, the first and last ticks of a
|
||||
/// blink of 3 or more show it.
|
||||
pub fn frame(&self, tick: u32, phase: u32) -> Option<&str> {
|
||||
let t = (u64::from(tick) + u64::from(phase)) % u64::from(self.frequency);
|
||||
let t = t as u32;
|
||||
if t >= self.length {
|
||||
return None;
|
||||
}
|
||||
match &self.half {
|
||||
Some(half) if self.length >= 3 && (t == 0 || t == self.length - 1) => Some(half),
|
||||
_ => Some(&self.shut),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The pack's emissive overlays, by the atlas name of the texture they glow over.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct Emissive {
|
||||
/// The name ending of the pack's emissive textures (`_e`).
|
||||
pub suffix: String,
|
||||
overlays: HashMap<String, String>,
|
||||
/// Overlays outside `textures/`: file and atlas name.
|
||||
files: Vec<(String, String)>,
|
||||
}
|
||||
|
||||
impl Emissive {
|
||||
/// `overlays` (texture → overlay, atlas names) with name ending `suffix`, and those outside
|
||||
/// `textures/` (file, atlas name).
|
||||
pub fn new(
|
||||
suffix: String,
|
||||
overlays: HashMap<String, String>,
|
||||
files: Vec<(String, String)>,
|
||||
) -> Self {
|
||||
Self {
|
||||
suffix,
|
||||
overlays,
|
||||
files,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.overlays.is_empty()
|
||||
}
|
||||
|
||||
/// The atlas name of the overlay that glows over `texture`.
|
||||
pub fn overlay(&self, texture: &str) -> Option<&str> {
|
||||
self.overlays.get(texture).map(String::as_str)
|
||||
}
|
||||
|
||||
/// The overlays outside `textures/`: file (from the resource root) and atlas name, for the
|
||||
/// entity atlas to pack (it walks `textures/` itself).
|
||||
pub fn files(&self) -> impl Iterator<Item = (&str, &str)> {
|
||||
self.files.iter().map(|(f, n)| (f.as_str(), n.as_str()))
|
||||
}
|
||||
}
|
||||
@@ -265,7 +265,7 @@ impl horizon_polytone::expr::Host for FrameHost<'_> {
|
||||
("config", [horizon_polytone::expr::Value::Str(id)]) => self
|
||||
.configs
|
||||
.get(id)
|
||||
.map(horizon_polytone::pack::ConfigValue::to_value)
|
||||
.map(horizon_polytone::pack::config_value)
|
||||
.ok_or_else(|| format!("no config {id}")),
|
||||
_ => Err(format!(
|
||||
"{name} is not available in an attribute expression"
|
||||
@@ -1103,6 +1103,7 @@ mod tests {
|
||||
for (path, text) in pack {
|
||||
write(&format!("pack/{path}"), text);
|
||||
}
|
||||
crate::pack_formats::install();
|
||||
let modifiers = PackModifiers::read(&dir.join("pack")).with_configs(
|
||||
crate::packs::polytone_configs(&crate::packs::Source::Dir(dir.join("pack"))),
|
||||
);
|
||||
|
||||
@@ -111,11 +111,11 @@ pub struct HudAtlas {
|
||||
/// The packs' Polytone screen sprite modifiers.
|
||||
pub sprite_modifiers: std::sync::Arc<sprite_modifiers::SpriteModifiers>,
|
||||
/// The packs' OptiFine random entity textures, whose variants this atlas packs.
|
||||
pub random_textures: horizon_optifine::random_textures::RandomTextures,
|
||||
pub random_textures: crate::entity_textures::RandomTextures,
|
||||
/// Their emissive overlays (`_e`), packed here too.
|
||||
pub emissive: std::sync::Arc<horizon_optifine::emissive::Emissive>,
|
||||
pub emissive: std::sync::Arc<crate::entity_textures::Emissive>,
|
||||
/// ETF's blinking frames (`_blink`), packed here too.
|
||||
pub blinks: horizon_optifine::blink::Blinks,
|
||||
pub blinks: crate::entity_textures::Blinks,
|
||||
/// Of the shared atlas's textures, their LabPBR normal and specular maps as paged maps
|
||||
/// (`paged_maps.rs`), read while loading for shader packs (`sprites::labpbr_maps`).
|
||||
pub material_maps: std::sync::Arc<[Option<crate::paged_maps::PagedMap>; 2]>,
|
||||
@@ -433,7 +433,7 @@ fn cit_sprites(
|
||||
packed: &[PackedImage],
|
||||
) -> Vec<(PackedImage, Option<TextureFlags>, bool)> {
|
||||
let have: HashSet<&str> = packed.iter().map(|i| i.0.as_str()).collect();
|
||||
let rules = crate::cit::load_rules(root);
|
||||
let rules = crate::cit::CitRules::load(root).rules;
|
||||
let mut ids = crate::cit_assets::item_sprites(&rules);
|
||||
if !rules.is_empty() {
|
||||
let models = crate::cit_assets::load_models(root, &rules);
|
||||
@@ -1204,25 +1204,18 @@ impl HudAtlas {
|
||||
let s = p.to_string_lossy().replace('\\', "/");
|
||||
!s.contains("title/background") && !s.contains("presets")
|
||||
});
|
||||
// OptiFine random entity variants, under the names their scan gives them (those
|
||||
// outside `textures/` are `<ns>:optifine/random/...`).
|
||||
let random_textures = if folders.contains(&"entity") {
|
||||
horizon_optifine::random_textures::RandomTextures::scan(root)
|
||||
// Random entity variants, under the names their scan gives them (those outside
|
||||
// `textures/` are `<ns>:optifine/random/...`), blink frames and emissive overlays.
|
||||
let crate::entity_textures::EntityTextures {
|
||||
random: random_textures,
|
||||
blinks,
|
||||
emissive,
|
||||
} = if folders.contains(&"entity") {
|
||||
crate::entity_textures::EntityTextures::load(root)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let blinks = if folders.contains(&"entity") {
|
||||
horizon_optifine::blink::Blinks::scan(root, &random_textures)
|
||||
} else {
|
||||
Default::default()
|
||||
};
|
||||
let emissive = std::sync::Arc::new(if folders.contains(&"entity") {
|
||||
let mut e = horizon_optifine::emissive::Emissive::scan(root, &random_textures);
|
||||
e.pair_beside(root, blinks.files());
|
||||
e
|
||||
} else {
|
||||
Default::default()
|
||||
});
|
||||
let emissive = std::sync::Arc::new(emissive);
|
||||
let mut named: HashMap<PathBuf, String> = HashMap::new();
|
||||
let extra = random_textures
|
||||
.files()
|
||||
@@ -3474,6 +3467,7 @@ mod tests {
|
||||
/// `RandomTextures` gives them; one beside its texture packs once, under its own.
|
||||
#[test]
|
||||
fn random_entity_variants_pack_under_their_scan_names() {
|
||||
crate::pack_formats::install();
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-random-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
@@ -3496,6 +3490,7 @@ mod tests {
|
||||
/// A random variant's emissive overlay packs beside the variant, under its name.
|
||||
#[test]
|
||||
fn variant_overlays_pack_under_their_names() {
|
||||
crate::pack_formats::install();
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-overlay-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
@@ -3810,6 +3805,7 @@ mod tests {
|
||||
}
|
||||
/// A resource root with one plain item texture and these CIT files (relative path, contents).
|
||||
fn cit_root(name: &str, files: &[(&str, &str)]) -> PathBuf {
|
||||
crate::pack_formats::install();
|
||||
let root =
|
||||
std::env::temp_dir().join(format!("horizon-atlas-cit-{name}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
@@ -3899,7 +3895,7 @@ mod tests {
|
||||
);
|
||||
let cit = root.join("assets/minecraft/optifine/cit");
|
||||
write_strip(&cit.join("a.png"), 64, 32, &[[1, 2, 3, 255]], None);
|
||||
let rules = crate::cit::load_rules(&root);
|
||||
let rules = crate::cit::CitRules::load(&root).rules;
|
||||
assert_eq!(rules.len(), 1);
|
||||
let atlas = HudAtlas::build(&root).unwrap();
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
//! OptiFine CIT enchantment glints on GUI items (`type=enchantment`, see `crate::cit_glint` for
|
||||
//! Custom item texture enchantment glints on GUI items (`Kind::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 crate::cit::{Blend, Factor, Kind, Rule};
|
||||
use crate::gfx::hud_coverage::Pipeline;
|
||||
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).
|
||||
@@ -438,13 +438,11 @@ impl CitGlintPass {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use horizon_optifine::Properties;
|
||||
use horizon_optifine::cit::parse_blend;
|
||||
use crate::pack_formats::cit::{parse_blend, parse_rule};
|
||||
|
||||
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()
|
||||
parse_rule(&path, &format!("texture=glint\n{text}")).unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -338,7 +338,7 @@ fn coverage_preserves_each_cit_blend_and_its_destination_alpha() {
|
||||
let Some(mut gpu) = Gpu::new() else {
|
||||
return;
|
||||
};
|
||||
use horizon_optifine::cit::parse_blend;
|
||||
use crate::pack_formats::cit::parse_blend;
|
||||
for (name, expected) in [
|
||||
("alpha", 128),
|
||||
("add", 0),
|
||||
|
||||
@@ -14,7 +14,6 @@ pub mod biome;
|
||||
pub mod block_edit;
|
||||
pub mod block_entities;
|
||||
pub mod block_entity_anim;
|
||||
pub mod block_entity_cem;
|
||||
pub mod block_entity_render;
|
||||
pub mod block_sprites;
|
||||
pub mod block_states;
|
||||
@@ -44,6 +43,7 @@ pub mod custom_sky;
|
||||
pub mod datapack;
|
||||
pub mod dynamic_light;
|
||||
pub mod effects;
|
||||
pub mod entity_textures;
|
||||
pub mod environment;
|
||||
pub mod extras;
|
||||
pub(crate) mod extras_ui;
|
||||
@@ -51,7 +51,6 @@ pub mod fast_hash;
|
||||
pub mod fluid_render;
|
||||
pub mod fog;
|
||||
pub mod font;
|
||||
pub mod fusion;
|
||||
pub mod gfx;
|
||||
pub mod graphics;
|
||||
pub mod graphics_style;
|
||||
|
||||
@@ -11,7 +11,7 @@ mod sides;
|
||||
#[cfg(test)]
|
||||
#[path = "mesher/source_tests.rs"]
|
||||
mod source_tests;
|
||||
use crate::connected::{Pick, TileLayer};
|
||||
use crate::connected::{ConnectedTextures, NoConnected, Pick, TileLayer};
|
||||
use crate::{
|
||||
assets::{Assets, RenderAssets},
|
||||
biome::{Biomes, GrassModifier},
|
||||
@@ -24,7 +24,6 @@ use horizon_assets::model::Direction as ModelDirection;
|
||||
use horizon_core::{
|
||||
biome::BiomeZoom, geometry::Direction, position::BlockPos, random::position_seed,
|
||||
};
|
||||
use horizon_optifine::ctm::connect::{Axes as CtmAxes, Face as CtmFace};
|
||||
use horizon_world::{
|
||||
block_render::FaceVisibility,
|
||||
fluid::{FluidKind, FluidState},
|
||||
@@ -1872,11 +1871,27 @@ impl Mesher<'_> {
|
||||
)
|
||||
}
|
||||
/// The meshing workers' path: `out`'s buffers and `scratch` are reused
|
||||
/// rather than allocated per section.
|
||||
/// rather than allocated per section. A pack's connected textures mesh through their
|
||||
/// format's entry point ([`crate::connected::Connected::mesh`]).
|
||||
pub fn mesh_reusing(
|
||||
&mut self,
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
sy: i32,
|
||||
out: SectionMesh,
|
||||
scratch: &mut MeshScratch,
|
||||
) -> SectionMesh {
|
||||
let render = self.render;
|
||||
match &render.connected {
|
||||
Some(connected) => (connected.mesh)(self, &*connected.rules, region, sy, out, scratch),
|
||||
None => self.mesh_with(&NoConnected, region, sy, out, scratch),
|
||||
}
|
||||
}
|
||||
/// [`Self::mesh_reusing`] with the connected textures `connected`.
|
||||
pub fn mesh_with<C: ConnectedTextures>(
|
||||
&mut self,
|
||||
connected: &C,
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
sy: i32,
|
||||
mut out: SectionMesh,
|
||||
scratch: &mut MeshScratch,
|
||||
) -> SectionMesh {
|
||||
@@ -2021,7 +2036,6 @@ impl Mesher<'_> {
|
||||
)
|
||||
.unwrap_or(true);
|
||||
}
|
||||
let connected = &self.render.connected;
|
||||
let start = [out.solid.len(), out.translucent.len()];
|
||||
for model in &models {
|
||||
for face in &model.faces {
|
||||
@@ -2052,6 +2066,7 @@ impl Mesher<'_> {
|
||||
}
|
||||
}
|
||||
self.face(
|
||||
connected,
|
||||
pad,
|
||||
&mut tints,
|
||||
&mut out,
|
||||
@@ -2102,8 +2117,9 @@ impl Mesher<'_> {
|
||||
})
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn face(
|
||||
fn face<C: ConnectedTextures>(
|
||||
&self,
|
||||
connected: &C,
|
||||
pad: &Padded,
|
||||
tints: &mut Tints,
|
||||
out: &mut SectionMesh,
|
||||
@@ -2163,55 +2179,40 @@ impl Mesher<'_> {
|
||||
}
|
||||
None => tint,
|
||||
};
|
||||
// Fusion's and then OptiFine's connected textures: another sprite, or one per quarter,
|
||||
// and the overlays drawn over it (a few per face at most; more are dropped).
|
||||
let (fusion, connected) = (&self.render.fusion, &self.render.connected);
|
||||
// Connected textures: another sprite, or one per quarter, and the overlays drawn over it
|
||||
// (a few per face at most; more are dropped).
|
||||
let mut overlays = [None; 8];
|
||||
let mut count = 0;
|
||||
let mut push = |overlay| {
|
||||
if let Some(slot) = overlays.get_mut(count) {
|
||||
*slot = Some(overlay);
|
||||
count += 1;
|
||||
}
|
||||
};
|
||||
let neighbour = |n: [i32; 3]| pad.blocks[(cell as isize + step_index(n)) as usize];
|
||||
let fused = face.fusion.and_then(|rule| {
|
||||
fusion.pick(
|
||||
rule,
|
||||
id,
|
||||
CtmFace::ALL[q.facing as usize],
|
||||
q.tint_index,
|
||||
neighbour,
|
||||
let pick = if connected.is_empty() {
|
||||
Pick::Keep
|
||||
} else {
|
||||
let mut push = |overlay| {
|
||||
if let Some(slot) = overlays.get_mut(count) {
|
||||
*slot = Some(overlay);
|
||||
count += 1;
|
||||
}
|
||||
};
|
||||
let tints = &*tints;
|
||||
connected.pick(
|
||||
&crate::connected::Face {
|
||||
state: id,
|
||||
layer: face.layer as u16,
|
||||
facing: q.facing as u8,
|
||||
pos: [world.x, world.y, world.z],
|
||||
positions: &q.positions,
|
||||
uv: &q.uv,
|
||||
pane: state.pane,
|
||||
tint_index: q.tint_index,
|
||||
rule: face.fusion,
|
||||
},
|
||||
|n: [i32; 3]| pad.blocks[(cell as isize + step_index(n)) as usize],
|
||||
|s| {
|
||||
self.tables
|
||||
.info
|
||||
.get(s as usize)
|
||||
.is_some_and(|i| i.opaque_cube)
|
||||
},
|
||||
&mut push,
|
||||
)
|
||||
});
|
||||
let pick = if let Some(pick) = fused {
|
||||
pick
|
||||
} else if connected.is_empty() {
|
||||
Pick::Keep
|
||||
} else {
|
||||
let tints = &*tints;
|
||||
connected.pick(
|
||||
id,
|
||||
face.layer as u16,
|
||||
CtmFace::ALL[q.facing as usize],
|
||||
[world.x, world.y, world.z],
|
||||
neighbour,
|
||||
|| Some(tints.biome(pos[0], pos[1], pos[2])),
|
||||
// A pane's tiles are drawn with the cube's UVs (below), so that is its texture.
|
||||
|| {
|
||||
if state.pane {
|
||||
Some(CtmAxes::cube(CtmFace::ALL[q.facing as usize]))
|
||||
} else {
|
||||
CtmAxes::texture(&q.positions, &q.uv)
|
||||
}
|
||||
},
|
||||
&mut push,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -100,8 +100,7 @@ fn fixture() -> (Scratch, Assets, RenderAssets, MeshTables, Biomes) {
|
||||
pbr_normals: vec![],
|
||||
pbr_specular: vec![],
|
||||
pbr_size: 1,
|
||||
connected: Default::default(),
|
||||
fusion: Default::default(),
|
||||
connected: None,
|
||||
generation: 1,
|
||||
};
|
||||
let assets = Assets {
|
||||
|
||||
@@ -1501,7 +1501,7 @@ pub enum Event {
|
||||
/// ones that differ from their defaults (the others went back to theirs).
|
||||
PackOptions {
|
||||
entries: Vec<String>,
|
||||
chosen: std::collections::BTreeMap<String, horizon_polytone::pack::ConfigValue>,
|
||||
chosen: std::collections::BTreeMap<String, horizon_pack_options::ConfigValue>,
|
||||
},
|
||||
/// An accepted button press or the release of a slider.
|
||||
ButtonClick,
|
||||
@@ -1688,7 +1688,7 @@ pub struct OptionsUi {
|
||||
/// The Pack Options page, and the packs' Polytone config entries by pack id (the game
|
||||
/// fills them beside `pack_infos`).
|
||||
pub pack_options: crate::pack_options_ui::PackOptionsUi,
|
||||
pub pack_configs: std::collections::BTreeMap<String, horizon_polytone::pack::Configs>,
|
||||
pub pack_configs: std::collections::BTreeMap<String, horizon_pack_options::Configs>,
|
||||
pub split_profiles: crate::split_profiles::SplitProfiles,
|
||||
/// HDR calibration while its page is open.
|
||||
pub calibration: Option<crate::hdr_calibration::Calibration>,
|
||||
@@ -4018,7 +4018,7 @@ impl OptionsUi {
|
||||
events.push(Event::DownloadPack(i));
|
||||
}
|
||||
crate::pack_screen::Action::Options(id) => {
|
||||
let mut configs = horizon_polytone::pack::Configs::default();
|
||||
let mut configs = horizon_pack_options::Configs::default();
|
||||
if let Some(own) = self.pack_configs.get(&id) {
|
||||
configs.merge(own);
|
||||
}
|
||||
|
||||
+1
-6
@@ -12,6 +12,7 @@
|
||||
//! block models here, so their files are not read; copper golem statues have no CEM name.
|
||||
|
||||
use crate::block_entity_render::ModelPart;
|
||||
use crate::pack_hooks::Reshaped;
|
||||
use horizon_optifine::cem::{
|
||||
files::{atlas_name, jem_files, load_jem},
|
||||
model::{Bone, EntityModel},
|
||||
@@ -140,12 +141,6 @@ const TARGETS: &[Target] = &[
|
||||
model("decorated_pot_sides", &["decorated_pot"], POT_SIDES),
|
||||
];
|
||||
|
||||
/// Every model, reshaped or vanilla's, and the atlas names of the `.jem` textures by model.
|
||||
pub type Reshaped = (
|
||||
HashMap<&'static str, ModelPart>,
|
||||
HashMap<&'static str, String>,
|
||||
);
|
||||
|
||||
/// The models the packs under `root` reshape, over `vanilla`'s; None when no block entity has
|
||||
/// a `.jem` there.
|
||||
pub fn reshape(root: &Path, vanilla: &HashMap<&'static str, ModelPart>) -> Option<Reshaped> {
|
||||
@@ -0,0 +1,969 @@
|
||||
//! OptiFine's Custom Item Textures (and CIT Resewn's): the rules of the enabled packs'
|
||||
//! `optifine/cit/**/*.properties`, read from the overlay resource root, and which of them a stack
|
||||
//! matches ([`crate::cit::Matcher`]). The rules themselves are `horizon_optifine::cit`'s; the
|
||||
//! engine gets them as [`crate::cit::Rule`]s.
|
||||
|
||||
use super::cit_glint::{load_global, stack_glint};
|
||||
use crate::cit::{self as engine, CitRules};
|
||||
use crate::cit_glint::StackGlint;
|
||||
use horizon_client_core::component::name_text;
|
||||
use horizon_client_core::scoreboard::comp_text;
|
||||
use horizon_items::item_data::{canon_tag, encode_components};
|
||||
use horizon_items::items::ItemStack;
|
||||
use horizon_optifine::cit::{
|
||||
Blend, ComponentTest, Factor, Glint, Global, Hand, Kind, Rule, Source, ValueTest,
|
||||
};
|
||||
use horizon_optifine::{Properties, properties_files_in};
|
||||
use horizon_world::nbt::{JavaString, Tag, parse_snbt};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::hash::{DefaultHasher, Hash, Hasher};
|
||||
use std::path::Path;
|
||||
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`).
|
||||
/// The pack's global `cit.properties` is not a rule.
|
||||
pub fn load_rules(root: &Path) -> Vec<Rule> {
|
||||
let tops = ["optifine", "mcpatcher", "citresewn"];
|
||||
let mut rules: Vec<Rule> = properties_files_in(root, &tops, "cit")
|
||||
.into_iter()
|
||||
.filter(|path| !path.ends_with("/cit.properties"))
|
||||
.filter_map(|path| {
|
||||
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");
|
||||
}
|
||||
rule
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
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 at `root` with the pack's `cit.properties`, as the engine draws and matches them.
|
||||
pub fn load(root: &Path) -> CitRules {
|
||||
cit_rules(load_rules(root), load_global(root))
|
||||
}
|
||||
|
||||
/// `rules` (in priority order, the winner first) under `global`, for the engine.
|
||||
pub fn cit_rules(rules: Vec<Rule>, global: Global) -> CitRules {
|
||||
let drawn = rules.iter().map(rule).collect();
|
||||
let use_glint = global.use_glint;
|
||||
CitRules::new(drawn, use_glint, Box::new(Matcher::new(rules, global)))
|
||||
}
|
||||
|
||||
/// A rule's file as the engine draws it: `text` read as the `.properties` file at `path`, every
|
||||
/// file it names taken to exist. For tests and tools.
|
||||
pub fn parse_rule(path: &str, text: &str) -> Option<engine::Rule> {
|
||||
Rule::parse(path, &Properties::parse_equals_first(text), |_| true).map(|r| rule(&r))
|
||||
}
|
||||
|
||||
/// [`parse_rule`] for a rule in the pack at `root`, whose files it looks for there.
|
||||
pub fn parse_rule_in(root: &Path, path: &str, text: &str) -> Option<engine::Rule> {
|
||||
let props = Properties::parse_equals_first(text);
|
||||
Rule::parse(path, &props, |p| root.join(p).is_file()).map(|r| rule(&r))
|
||||
}
|
||||
|
||||
/// Rules from (path, `.properties` text) under the global `cit.properties` text `global`, in
|
||||
/// priority order, every file they name taken to exist. For tests and tools.
|
||||
pub fn parse_rules(files: &[(&str, &str)], global: &str) -> CitRules {
|
||||
let mut rules: Vec<Rule> = files
|
||||
.iter()
|
||||
.filter_map(|(path, text)| {
|
||||
Rule::parse(path, &Properties::parse_equals_first(text), |_| true)
|
||||
})
|
||||
.collect();
|
||||
rules.sort_by(Rule::priority);
|
||||
cit_rules(
|
||||
rules,
|
||||
Global::parse(&Properties::parse_equals_first(global)),
|
||||
)
|
||||
}
|
||||
|
||||
/// A `blend=` value as the engine draws it ([`horizon_optifine::cit::parse_blend`]). For tests
|
||||
/// and tools.
|
||||
pub fn parse_blend(value: &str) -> Option<Option<engine::Blend>> {
|
||||
horizon_optifine::cit::parse_blend(value).map(|b| b.map(blend))
|
||||
}
|
||||
|
||||
fn rule(r: &Rule) -> engine::Rule {
|
||||
engine::Rule {
|
||||
path: r.path.clone(),
|
||||
kind: kind(r.kind),
|
||||
texture: r.texture.clone(),
|
||||
textures: r.textures.clone(),
|
||||
model: r.model.clone(),
|
||||
models: r.models.clone(),
|
||||
glint: glint(&r.glint),
|
||||
}
|
||||
}
|
||||
|
||||
fn kind(k: Kind) -> engine::Kind {
|
||||
match k {
|
||||
Kind::Item => engine::Kind::Item,
|
||||
Kind::Armor => engine::Kind::Armor,
|
||||
Kind::Elytra => engine::Kind::Elytra,
|
||||
Kind::Enchantment => engine::Kind::Enchantment,
|
||||
}
|
||||
}
|
||||
|
||||
fn of_kind(k: engine::Kind) -> Kind {
|
||||
match k {
|
||||
engine::Kind::Item => Kind::Item,
|
||||
engine::Kind::Armor => Kind::Armor,
|
||||
engine::Kind::Elytra => Kind::Elytra,
|
||||
engine::Kind::Enchantment => Kind::Enchantment,
|
||||
}
|
||||
}
|
||||
|
||||
fn of_hand(h: engine::Hand) -> Hand {
|
||||
match h {
|
||||
engine::Hand::Any => Hand::Any,
|
||||
engine::Hand::Main => Hand::Main,
|
||||
engine::Hand::Off => Hand::Off,
|
||||
}
|
||||
}
|
||||
|
||||
fn glint(g: &Glint) -> engine::Glint {
|
||||
engine::Glint {
|
||||
layer: g.layer,
|
||||
blend: g.blend.map(blend),
|
||||
speed: g.speed,
|
||||
rotation: g.rotation,
|
||||
duration: g.duration,
|
||||
rgba: g.rgba,
|
||||
use_glint: g.use_glint,
|
||||
blur: g.blur,
|
||||
}
|
||||
}
|
||||
|
||||
fn blend(b: Blend) -> engine::Blend {
|
||||
engine::Blend {
|
||||
src: factor(b.src),
|
||||
dst: factor(b.dst),
|
||||
src_alpha: factor(b.src_alpha),
|
||||
dst_alpha: factor(b.dst_alpha),
|
||||
}
|
||||
}
|
||||
|
||||
fn factor(f: Factor) -> engine::Factor {
|
||||
use engine::Factor as F;
|
||||
match f {
|
||||
Factor::Zero => F::Zero,
|
||||
Factor::One => F::One,
|
||||
Factor::SrcColor => F::SrcColor,
|
||||
Factor::OneMinusSrcColor => F::OneMinusSrcColor,
|
||||
Factor::SrcAlpha => F::SrcAlpha,
|
||||
Factor::OneMinusSrcAlpha => F::OneMinusSrcAlpha,
|
||||
Factor::DstAlpha => F::DstAlpha,
|
||||
Factor::OneMinusDstAlpha => F::OneMinusDstAlpha,
|
||||
Factor::DstColor => F::DstColor,
|
||||
Factor::OneMinusDstColor => F::OneMinusDstColor,
|
||||
Factor::SrcAlphaSaturate => F::SrcAlphaSaturate,
|
||||
}
|
||||
}
|
||||
|
||||
/// The rules with an index by item and a cache of what each stack matched.
|
||||
#[derive(Debug, Default)]
|
||||
struct Matcher {
|
||||
rules: Vec<Rule>,
|
||||
/// The pack's `cit.properties`.
|
||||
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.
|
||||
any_item: Vec<u32>,
|
||||
/// Per rule, per component test: its literal read as SNBT and as a JSON text component.
|
||||
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)]
|
||||
struct Prepared {
|
||||
snbt: Option<Tag>,
|
||||
json: Option<String>,
|
||||
}
|
||||
|
||||
/// Cache entries kept before the cache starts over.
|
||||
const CACHE_LIMIT: usize = 4096;
|
||||
|
||||
impl Matcher {
|
||||
/// `rules` in priority order, the winner first.
|
||||
fn new(rules: Vec<Rule>, global: Global) -> Self {
|
||||
let mut by_item: [HashMap<String, Vec<u32>>; 4] = Default::default();
|
||||
let mut any_item = Vec::new();
|
||||
for (i, rule) in rules.iter().enumerate() {
|
||||
let i = i as u32;
|
||||
if rule.kind == Kind::Enchantment && rule.items.is_empty() {
|
||||
any_item.push(i);
|
||||
}
|
||||
for item in &rule.items {
|
||||
let list = by_item[rule.kind as usize].entry(item.clone()).or_default();
|
||||
if list.last() != Some(&i) {
|
||||
list.push(i);
|
||||
}
|
||||
}
|
||||
}
|
||||
let literals = rules
|
||||
.iter()
|
||||
.map(|r| r.components.iter().map(prepare).collect())
|
||||
.collect();
|
||||
Self {
|
||||
rules,
|
||||
global,
|
||||
by_item,
|
||||
any_item,
|
||||
literals,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether rule `i` matches the stack drawn in `hand`.
|
||||
fn matches(
|
||||
&self,
|
||||
i: u32,
|
||||
stack: &ItemStack,
|
||||
hand: Hand,
|
||||
max_damage: u32,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let rule = &self.rules[i as usize];
|
||||
if rule.hand != Hand::Any && rule.hand != hand {
|
||||
return false;
|
||||
}
|
||||
let count = i32::try_from(stack.count).unwrap_or(i32::MAX);
|
||||
if rule.stack_size.as_ref().is_some_and(|r| !r.contains(count)) {
|
||||
return false;
|
||||
}
|
||||
// CIT Resewn: only damageable items have damage.
|
||||
let damage = if max_damage > 0 { stack.damage } else { 0 };
|
||||
if rule
|
||||
.damage
|
||||
.as_ref()
|
||||
.is_some_and(|d| !d.matches(damage, max_damage))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let enchantments = stack.crafting_enchantments();
|
||||
let listed = |id: &str| rule.enchantments.iter().any(|e| e == id);
|
||||
if !rule.enchantments.is_empty() && !enchantments.iter().any(|(id, _)| listed(id)) {
|
||||
return false;
|
||||
}
|
||||
if let Some(levels) = &rule.enchantment_levels {
|
||||
let level_in_range = enchantments.iter().any(|(id, level)| {
|
||||
(rule.enchantments.is_empty() || listed(id))
|
||||
&& levels.contains(i32::try_from(*level).unwrap_or(i32::MAX))
|
||||
});
|
||||
if !level_in_range {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
let literals = &self.literals[i as usize];
|
||||
rule.components
|
||||
.iter()
|
||||
.zip(literals)
|
||||
.all(|(test, lit)| component_test(test, lit, stack, comps) != test.negate)
|
||||
}
|
||||
}
|
||||
|
||||
impl engine::Matcher for Matcher {
|
||||
fn keep_glints(&mut self, keep: &dyn 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();
|
||||
}
|
||||
|
||||
fn select(
|
||||
&self,
|
||||
kind: engine::Kind,
|
||||
stack: &ItemStack,
|
||||
hand: engine::Hand,
|
||||
max_damage: u32,
|
||||
) -> Option<usize> {
|
||||
let (kind, hand) = (of_kind(kind), of_hand(hand));
|
||||
let candidates = self.by_item[kind as usize].get(&*stack.item)?;
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key((0, kind, hand), stack, max_damage, comps.as_ref());
|
||||
if let Some(hit) = lock(&self.selected).get(&key) {
|
||||
return hit.map(|i| i as usize);
|
||||
}
|
||||
let found = candidates
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|&i| self.matches(i, stack, hand, max_damage, comps.as_ref()));
|
||||
remember(&self.selected, key, found);
|
||||
found.map(|i| i as usize)
|
||||
}
|
||||
|
||||
fn enchantments(&self, stack: &ItemStack, hand: engine::Hand, max_damage: u32) -> Vec<usize> {
|
||||
let hand = of_hand(hand);
|
||||
let named = self.by_item[Kind::Enchantment as usize]
|
||||
.get(&*stack.item)
|
||||
.map_or(&[][..], Vec::as_slice);
|
||||
if named.is_empty() && self.any_item.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let comps = encode_components(stack);
|
||||
let key = cache_key(
|
||||
(1, Kind::Enchantment, hand),
|
||||
stack,
|
||||
max_damage,
|
||||
comps.as_ref(),
|
||||
);
|
||||
let hit = lock(&self.enchanted).get(&key).cloned();
|
||||
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, hand, max_damage, comps.as_ref()));
|
||||
remember(&self.enchanted, key, all.clone());
|
||||
all
|
||||
});
|
||||
found.into_iter().map(|i| i as usize).collect()
|
||||
}
|
||||
|
||||
/// Worked out once per stack and hand.
|
||||
fn glint(
|
||||
&self,
|
||||
stack: &ItemStack,
|
||||
hand: engine::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, of_hand(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(stack_glint(
|
||||
&self.rules,
|
||||
&matched,
|
||||
stack.crafting_enchantments(),
|
||||
&self.global,
|
||||
));
|
||||
remember(&self.glints, key, glint.clone());
|
||||
Some(glint)
|
||||
}
|
||||
}
|
||||
|
||||
fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||
m.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn remember<V>(cache: &Mutex<HashMap<u64, V>>, key: u64, value: V) {
|
||||
let mut cache = lock(cache);
|
||||
if cache.len() >= CACHE_LIMIT {
|
||||
cache.clear();
|
||||
}
|
||||
cache.insert(key, value);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
(stack.count, stack.damage, max, comps).hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
|
||||
fn prepare(test: &ComponentTest) -> Prepared {
|
||||
let ValueTest::Literal(lit) = &test.value else {
|
||||
return Prepared::default();
|
||||
};
|
||||
let text = lit.text.trim();
|
||||
let snbt = match parse_snbt(text) {
|
||||
Ok((tag, read)) if read == text.len() && text.starts_with(['{', '[']) => Some(tag),
|
||||
_ => None,
|
||||
};
|
||||
let json = json_plain(text);
|
||||
Prepared { snbt, json }
|
||||
}
|
||||
|
||||
/// The plain text of `s` read as a JSON text component.
|
||||
fn json_plain(s: &str) -> Option<String> {
|
||||
if !s.starts_with(['{', '[', '"']) {
|
||||
return None;
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_str(s).ok()?;
|
||||
Some(comp_text(&value).plain())
|
||||
}
|
||||
|
||||
/// Whether one test passes, before its `negate`.
|
||||
fn component_test(
|
||||
test: &ComponentTest,
|
||||
lit: &Prepared,
|
||||
stack: &ItemStack,
|
||||
comps: Option<&Tag>,
|
||||
) -> bool {
|
||||
let legacy;
|
||||
let (root, text_depth) = match &test.source {
|
||||
Source::Component(id) => {
|
||||
let depth = match id.as_str() {
|
||||
"minecraft:custom_name" | "minecraft:item_name" => Some(0),
|
||||
"minecraft:lore" => Some(1),
|
||||
_ => None,
|
||||
};
|
||||
(comps.and_then(|c| c.get(id)), depth)
|
||||
}
|
||||
Source::LegacyEnchantments { stored } => {
|
||||
let levels = if *stored {
|
||||
stack.stored_enchantments()
|
||||
} else {
|
||||
stack.enchantments()
|
||||
};
|
||||
legacy = Tag::List(
|
||||
levels
|
||||
.iter()
|
||||
.map(|(id, lvl)| {
|
||||
let lvl = i16::try_from(*lvl).unwrap_or(i16::MAX);
|
||||
Tag::compound([
|
||||
("id", Tag::String((&**id).into())),
|
||||
("lvl", Tag::Short(lvl)),
|
||||
])
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
(Some(&legacy), None)
|
||||
}
|
||||
};
|
||||
// Fields the component leaves out when 0, which pre-1.20.5 items always wrote.
|
||||
let defaulted;
|
||||
let root = match (&test.source, root, test.path.first()) {
|
||||
(Source::Component(id), Some(Tag::Compound(map)), Some(field))
|
||||
if matches!(
|
||||
(id.as_str(), field.as_str()),
|
||||
("minecraft:written_book_content", "generation")
|
||||
| ("minecraft:fireworks", "flight_duration")
|
||||
) && !map.contains_key(&JavaString::from(field.as_str())) =>
|
||||
{
|
||||
let mut map = map.clone();
|
||||
map.insert(JavaString::from(field.as_str()), Tag::Int(0));
|
||||
defaulted = Tag::Compound(map);
|
||||
Some(&defaulted)
|
||||
}
|
||||
(_, root, _) => root,
|
||||
};
|
||||
let Some(root) = root else {
|
||||
return matches!(test.value, ValueTest::Exists(false));
|
||||
};
|
||||
if let ValueTest::Exists(want) = test.value {
|
||||
return walk(root, &test.path, 0, &mut |_, _| true) == want;
|
||||
}
|
||||
walk(root, &test.path, 0, &mut |tag, depth| {
|
||||
value_test(&test.value, lit, tag, text_depth == Some(depth))
|
||||
})
|
||||
}
|
||||
|
||||
/// Visits what `path` reaches from `tag`, true once `visit` is.
|
||||
fn walk(
|
||||
tag: &Tag,
|
||||
path: &[String],
|
||||
depth: usize,
|
||||
visit: &mut dyn FnMut(&Tag, usize) -> bool,
|
||||
) -> bool {
|
||||
let Some((seg, rest)) = path.split_first() else {
|
||||
return visit(tag, depth);
|
||||
};
|
||||
let mut next = |t: &Tag| walk(t, rest, depth + 1, visit);
|
||||
let len = match tag {
|
||||
Tag::Compound(map) if seg == "*" => return map.values().any(&mut next),
|
||||
Tag::Compound(map) => return map.get(&JavaString::from(seg.as_str())).is_some_and(next),
|
||||
Tag::List(v) => v.len(),
|
||||
Tag::ByteArray(v) => v.len(),
|
||||
Tag::IntArray(v) => v.len(),
|
||||
Tag::LongArray(v) => v.len(),
|
||||
_ => return false,
|
||||
};
|
||||
let element = |i: usize| match tag {
|
||||
Tag::List(v) => Cow::Borrowed(&v[i]),
|
||||
Tag::ByteArray(v) => Cow::Owned(Tag::Byte(v[i] as i8)),
|
||||
Tag::IntArray(v) => Cow::Owned(Tag::Int(v[i])),
|
||||
Tag::LongArray(v) => Cow::Owned(Tag::Long(v[i])),
|
||||
_ => Cow::Owned(Tag::End),
|
||||
};
|
||||
if seg == "count" {
|
||||
return next(&Tag::Int(i32::try_from(len).unwrap_or(i32::MAX)));
|
||||
}
|
||||
if seg == "*" {
|
||||
return (0..len).any(|i| next(&element(i)));
|
||||
}
|
||||
seg.parse::<usize>()
|
||||
.ok()
|
||||
.filter(|&i| i < len)
|
||||
.is_some_and(|i| next(&element(i)))
|
||||
}
|
||||
|
||||
fn int_value(tag: &Tag) -> Option<i64> {
|
||||
match *tag {
|
||||
Tag::Byte(v) => Some(v.into()),
|
||||
Tag::Short(v) => Some(v.into()),
|
||||
Tag::Int(v) => Some(v.into()),
|
||||
Tag::Long(v) => Some(v),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A number as Java's `String.valueOf` writes it (`3`, `3.0`).
|
||||
fn decimal(tag: &Tag) -> Option<String> {
|
||||
let s = match *tag {
|
||||
Tag::Float(b) => f32::from_bits(b).to_string(),
|
||||
Tag::Double(b) => f64::from_bits(b).to_string(),
|
||||
_ => return int_value(tag).map(|v| v.to_string()),
|
||||
};
|
||||
Some(if s.contains(['.', 'e', 'N', 'i']) {
|
||||
s
|
||||
} else {
|
||||
s + ".0"
|
||||
})
|
||||
}
|
||||
|
||||
/// `text` against a string, its JSON text, or the plain text of a text component.
|
||||
fn text_matches(tag: &Tag, is_text: bool, text: impl Fn(&str) -> bool) -> bool {
|
||||
match tag {
|
||||
Tag::String(s) => {
|
||||
let s = s.to_string_lossy();
|
||||
text(&s)
|
||||
|| (is_text && text(&name_text(tag).plain()))
|
||||
|| json_plain(&s).is_some_and(|p| text(&p))
|
||||
}
|
||||
Tag::Compound(_) | Tag::List(_) => is_text && text(&name_text(tag).plain()),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn value_test(test: &ValueTest, lit: &Prepared, tag: &Tag, is_text: bool) -> bool {
|
||||
match test {
|
||||
ValueTest::Exists(_) => true,
|
||||
ValueTest::Range(r) => {
|
||||
int_value(tag).is_some_and(|v| i32::try_from(v).is_ok_and(|v| r.contains(v)))
|
||||
}
|
||||
ValueTest::Raw(name) => name.matches(&canon_tag(tag)),
|
||||
ValueTest::Text(name) => match decimal(tag) {
|
||||
Some(n) => name.matches(&n),
|
||||
None => text_matches(tag, is_text, |s| name.matches(s)),
|
||||
},
|
||||
ValueTest::Literal(l) => match tag {
|
||||
Tag::Float(b) => l.text.trim().parse::<f32>().ok() == Some(f32::from_bits(*b)),
|
||||
Tag::Double(b) => l.float == Some(f64::from_bits(*b)),
|
||||
Tag::Compound(_) | Tag::List(_)
|
||||
if lit.snbt.as_ref().is_some_and(|e| contains(e, tag)) =>
|
||||
{
|
||||
true
|
||||
}
|
||||
_ => match int_value(tag) {
|
||||
Some(v) => l.int == Some(v),
|
||||
None => text_matches(tag, is_text, |s| {
|
||||
s == l.text || lit.json.as_deref() == Some(s)
|
||||
}),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `NbtUtils.compareNbt(expected, actual, true)`: every key and element of `expected` found in
|
||||
/// `actual`.
|
||||
fn contains(expected: &Tag, actual: &Tag) -> bool {
|
||||
match (expected, actual) {
|
||||
(Tag::Compound(e), Tag::Compound(a)) => e
|
||||
.iter()
|
||||
.all(|(k, v)| a.get(k).is_some_and(|a| contains(v, a))),
|
||||
(Tag::List(e), Tag::List(a)) if e.is_empty() => a.is_empty(),
|
||||
(Tag::List(e), Tag::List(a)) => e.iter().all(|e| a.iter().any(|a| contains(e, a))),
|
||||
_ => expected == actual,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::cit::{Hand, Kind};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Rules from (file stem, properties), sorted as `load_rules` sorts them, under the global
|
||||
/// `cit.properties` text `global`.
|
||||
fn rules_under(files: &[(&str, &str)], global: &str) -> CitRules {
|
||||
let files: Vec<(String, String)> = files
|
||||
.iter()
|
||||
.map(|(stem, text)| {
|
||||
let path = format!("assets/minecraft/optifine/cit/{stem}.properties");
|
||||
(path, format!("texture=x\n{text}"))
|
||||
})
|
||||
.collect();
|
||||
let files: Vec<(&str, &str)> = files.iter().map(|(p, t)| (&**p, &**t)).collect();
|
||||
let cit = parse_rules(&files, global);
|
||||
assert_eq!(cit.rules.len(), files.len(), "every rule parses");
|
||||
cit
|
||||
}
|
||||
|
||||
fn rules(files: &[(&str, &str)]) -> CitRules {
|
||||
rules_under(files, "")
|
||||
}
|
||||
|
||||
fn stem(cit: &CitRules, i: Option<usize>) -> Option<&str> {
|
||||
let path = &cit.rules[i?].path;
|
||||
path.rsplit('/').next()?.strip_suffix(".properties")
|
||||
}
|
||||
|
||||
fn item(cit: &CitRules, stack: ItemStack) -> Option<&str> {
|
||||
stem(cit, cit.select(Kind::Item, &stack, Hand::Main, 0))
|
||||
}
|
||||
|
||||
fn new(id: &str) -> ItemStack {
|
||||
ItemStack::new(&format!("minecraft:{id}"), 1)
|
||||
}
|
||||
|
||||
fn string(s: &str) -> Tag {
|
||||
Tag::String(s.into())
|
||||
}
|
||||
|
||||
fn with(id: &str, component: &str, tag: Tag) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let component = format!("minecraft:{component}");
|
||||
stack.data_mut().other.push((component, tag));
|
||||
stack
|
||||
}
|
||||
|
||||
fn enchanted(id: &str, levels: &[(&str, u32)]) -> ItemStack {
|
||||
let mut stack = new(id);
|
||||
let levels = levels
|
||||
.iter()
|
||||
.map(|(e, l)| (Arc::from(format!("minecraft:{e}")), *l));
|
||||
stack.data_mut().enchantments = levels.collect();
|
||||
stack
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavier_rules_win_and_kinds_stay_apart() {
|
||||
let cit = rules(&[
|
||||
("plain", "items=stick"),
|
||||
("heavy", "items=stick\nweight=2\nstackSize=2-"),
|
||||
("offhand", "items=stick\nweight=5\nhand=off"),
|
||||
("armor", "type=armor\nitems=stick"),
|
||||
("wings", "type=elytra"),
|
||||
]);
|
||||
assert_eq!(item(&cit, new("stick")), Some("plain"));
|
||||
assert_eq!(
|
||||
item(&cit, ItemStack::new("minecraft:stick", 3)),
|
||||
Some("heavy")
|
||||
);
|
||||
let pick = |kind, id, hand, max| stem(&cit, cit.select(kind, &new(id), hand, max));
|
||||
assert_eq!(pick(Kind::Item, "stick", Hand::Off, 0), Some("offhand"));
|
||||
assert_eq!(item(&cit, new("stone")), None);
|
||||
assert_eq!(item(&cit, new("elytra")), None);
|
||||
assert_eq!(pick(Kind::Elytra, "elytra", Hand::Main, 432), Some("wings"));
|
||||
assert_eq!(pick(Kind::Armor, "stick", Hand::Main, 0), Some("armor"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn names_match_by_their_plain_text() {
|
||||
let cit = rules(&[
|
||||
("glob", "items=stick\nnbt.display.Name=ipattern:*sword"),
|
||||
("exact", "items=stone\ncomponents.custom_name=My Sword"),
|
||||
("json", "items=dirt\nnbt.display.Name=[\"My \",\"Sword\"]"),
|
||||
]);
|
||||
let named = |id: &str, name: Tag| {
|
||||
let mut stack = new(id);
|
||||
stack.data_mut().custom_name = Some(name);
|
||||
stack
|
||||
};
|
||||
let styled = Tag::compound([("text", string("My Sword")), ("color", string("red"))]);
|
||||
assert_eq!(item(&cit, named("stick", styled.clone())), Some("glob"));
|
||||
assert_eq!(item(&cit, named("stick", string("Blade"))), None);
|
||||
assert_eq!(item(&cit, new("stick")), None);
|
||||
assert_eq!(item(&cit, named("stone", styled.clone())), Some("exact"));
|
||||
assert_eq!(item(&cit, named("dirt", styled)), Some("json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lore_lines_and_list_counts() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"any_line",
|
||||
"items=stick\nnbt.display.Lore.*=ipattern:*legendary*",
|
||||
),
|
||||
("two_lines", "items=stone\ncomponents.lore.count=2"),
|
||||
]);
|
||||
let bold = Tag::compound([("text", string("A LEGENDARY item")), ("bold", Tag::Byte(1))]);
|
||||
let lore = Tag::List(vec![string("Common"), bold]);
|
||||
assert_eq!(
|
||||
item(&cit, with("stick", "lore", lore.clone())),
|
||||
Some("any_line")
|
||||
);
|
||||
assert_eq!(item(&cit, with("stone", "lore", lore)), Some("two_lines"));
|
||||
let one = Tag::List(vec![string("A legendary item")]);
|
||||
assert_eq!(item(&cit, with("stone", "lore", one)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_data_existence_and_negation() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"aote",
|
||||
"items=stick\nweight=2\nnbt.ExtraAttributes.id=ASPECT_OF_THE_END",
|
||||
),
|
||||
(
|
||||
"not_hyp",
|
||||
"items=stick\nweight=1\ncomponent.custom_data.ExtraAttributes.id=!HYP",
|
||||
),
|
||||
("unnamed", "items=stick\nnbt.display.Name=exists:false"),
|
||||
]);
|
||||
let data = |id: &str| {
|
||||
let attributes =
|
||||
Tag::compound([("ExtraAttributes", Tag::compound([("id", string(id))]))]);
|
||||
with("stick", "custom_data", attributes)
|
||||
};
|
||||
assert_eq!(item(&cit, data("ASPECT_OF_THE_END")), Some("aote"));
|
||||
assert_eq!(item(&cit, data("MIDAS")), Some("not_hyp"));
|
||||
assert_eq!(item(&cit, data("HYP")), Some("unnamed"));
|
||||
let mut named = data("HYP");
|
||||
named.data_mut().custom_name = Some(string("Hyperion"));
|
||||
assert_eq!(item(&cit, named), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_enchantment_lists_and_model_data() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp",
|
||||
"items=stick\nnbt.Enchantments.*.id=minecraft:sharpness\nnbt.Enchantments.0.lvl=5",
|
||||
),
|
||||
("cmd", "items=stone\nnbt.CustomModelData=3"),
|
||||
(
|
||||
"cmd_snbt",
|
||||
"items=dirt\ncomponents.custom_model_data={floats:[3.0f]}",
|
||||
),
|
||||
]);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 5)])),
|
||||
Some("sharp")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stick", &[("sharpness", 4)])), None);
|
||||
let model = |id: &str, f: f32| {
|
||||
let floats = Tag::List(vec![Tag::Float(f.to_bits()), Tag::Float(7f32.to_bits())]);
|
||||
with(id, "custom_model_data", Tag::compound([("floats", floats)]))
|
||||
};
|
||||
assert_eq!(item(&cit, model("stone", 3.0)), Some("cmd"));
|
||||
assert_eq!(item(&cit, model("stone", 3.5)), None);
|
||||
assert_eq!(item(&cit, model("dirt", 3.0)), Some("cmd_snbt"));
|
||||
assert_eq!(item(&cit, model("dirt", 2.0)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fields_left_out_at_zero_read_as_zero() {
|
||||
let cit = rules(&[("zero", "items=firework_rocket\nnbt.Fireworks.Flight=0")]);
|
||||
let mut rocket = new("firework_rocket");
|
||||
rocket.data_mut().fireworks = Some((0, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket.clone()), Some("zero"));
|
||||
rocket.data_mut().fireworks = Some((2, Vec::new()));
|
||||
assert_eq!(item(&cit, rocket), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn damage_counts_only_for_damageable_items() {
|
||||
let cit = rules(&[
|
||||
("worn", "items=diamond_sword\ndamage=50-100%"),
|
||||
("new", "items=stick\ndamage=0"),
|
||||
]);
|
||||
let damaged = |id: &str, damage: u32, max: u32| {
|
||||
let mut stack = new(id);
|
||||
stack.damage = damage;
|
||||
stem(&cit, cit.select(Kind::Item, &stack, Hand::Main, max))
|
||||
};
|
||||
// Half of 1561 is 780.5.
|
||||
assert_eq!(damaged("diamond_sword", 780, 1561), None);
|
||||
assert_eq!(damaged("diamond_sword", 781, 1561), Some("worn"));
|
||||
assert_eq!(damaged("stick", 5, 0), Some("new"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enchantment_levels_and_rules_for_any_item() {
|
||||
let cit = rules(&[
|
||||
(
|
||||
"sharp3",
|
||||
"items=stick\nenchantments=sharpness\nenchantmentLevels=3-",
|
||||
),
|
||||
("any3", "items=stone\nenchantmentLevels=3-"),
|
||||
("glint", "type=enchantment\nenchantments=sharpness"),
|
||||
(
|
||||
"sword_glint",
|
||||
"type=enchantment\nitems=diamond_sword\nweight=2",
|
||||
),
|
||||
]);
|
||||
let mixed = [("sharpness", 2), ("unbreaking", 3)];
|
||||
assert_eq!(item(&cit, enchanted("stick", &mixed)), None);
|
||||
assert_eq!(
|
||||
item(&cit, enchanted("stick", &[("sharpness", 4)])),
|
||||
Some("sharp3")
|
||||
);
|
||||
assert_eq!(item(&cit, enchanted("stone", &mixed)), Some("any3"));
|
||||
let glints = |stack: ItemStack| -> Vec<&str> {
|
||||
let found = cit.enchantments(&stack, Hand::Main, 0).into_iter();
|
||||
found.map(|i| stem(&cit, Some(i)).unwrap()).collect()
|
||||
};
|
||||
let sharp = [("sharpness", 1)];
|
||||
assert_eq!(
|
||||
glints(enchanted("diamond_sword", &sharp)),
|
||||
["sword_glint", "glint"]
|
||||
);
|
||||
assert_eq!(glints(enchanted("stick", &sharp)), ["glint"]);
|
||||
assert!(glints(new("stick")).is_empty());
|
||||
// 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 files = [
|
||||
("sharp", "type=enchantment\nenchantments=sharpness"),
|
||||
(
|
||||
"keep",
|
||||
"type=enchantment\nlayer=1\nuseGlint=true\nenchantments=mending",
|
||||
),
|
||||
];
|
||||
let cit = rules(&files);
|
||||
// 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 bare = rules(&[("stick", "items=stick")]);
|
||||
assert!(glint(&bare, &overridden(new("stick"), true), false).is_none());
|
||||
let bare = rules_under(&[("stick", "items=stick")], "useGlint=false");
|
||||
assert!(!bare.use_glint);
|
||||
assert!(
|
||||
!glint(&bare, &overridden(new("stick"), true), false)
|
||||
.unwrap()
|
||||
.vanilla
|
||||
);
|
||||
let cit = rules_under(&files, "useGlint=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,301 @@
|
||||
//! OptiFine's 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 ([`crate::cit_glint::StackGlint`]). 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.
|
||||
|
||||
use super::cit::read_properties;
|
||||
use crate::cit_glint::{GlintLayer, StackGlint};
|
||||
use horizon_optifine::cit::{Global, Method, 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()
|
||||
}
|
||||
|
||||
/// A stack's glint: the layers among `matched` (its matching enchantment rules, in priority
|
||||
/// order, as `CitRules::enchantments` gives them) that draw, for a stack with `enchantments`
|
||||
/// (`ItemStack::crafting_enchantments`).
|
||||
pub fn stack_glint(
|
||||
rules: &[Rule],
|
||||
matched: &[usize],
|
||||
enchantments: &[(impl AsRef<str>, u32)],
|
||||
global: &Global,
|
||||
) -> StackGlint {
|
||||
// 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::new(i, weight, rule.glint.duration)
|
||||
})
|
||||
.collect();
|
||||
StackGlint::from_parts(layers, vanilla, cycle.then_some(global.fade))
|
||||
}
|
||||
|
||||
#[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 = stack_glint(&rules, &[0, 1, 2, 3], &SWORD, &Global::default());
|
||||
assert_eq!(rules_of(&g), [3, 1, 0]);
|
||||
let g = stack_glint(&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 = stack_glint(&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 = stack_glint(&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 = stack_glint(&rules, &[0, 1, 2], &SWORD, &global("cap=2"));
|
||||
assert_eq!(rules_of(&g), [1, 2]);
|
||||
let g = stack_glint(&rules, &[0, 1, 2], &SWORD, &global("cap=0"));
|
||||
assert!(g.is_empty());
|
||||
let g = stack_glint(&rules, &[0, 1, 2], &SWORD, &global("cap=1\nmethod=none"));
|
||||
assert_eq!(rules_of(&g), [2]);
|
||||
// Cycles show every layer in turn.
|
||||
let g = stack_glint(&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| {
|
||||
stack_glint(&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 = stack_glint(&rules, &[0, 1], &SWORD, &global("cap=1"));
|
||||
assert!(!g.vanilla);
|
||||
let g = stack_glint(&rules, &[0, 1], &SWORD, &global("cap=0"));
|
||||
assert!(g.is_empty() && g.vanilla);
|
||||
let g = stack_glint(&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 = stack_glint(
|
||||
&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 = stack_glint(&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 = stack_glint(
|
||||
&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 = stack_glint(&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)));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
//! OptiFine's and Fusion's connected textures as the mesher draws them
|
||||
//! ([`crate::connected::ConnectedTextures`]): a Fusion face first, then OptiFine's rules.
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
use horizon_optifine::ctm::connect::{Axes, Face};
|
||||
use horizon_world::registry::{BlockRegistry, BlockStateId};
|
||||
|
||||
use super::ctm::ConnectedTextures as Ctm;
|
||||
use super::fusion::FusionTextures;
|
||||
use crate::chunk::Chunk;
|
||||
use crate::connected::{self as engine, Connected, ConnectedTextures, Overlay, Pick};
|
||||
use crate::mesher::{MeshScratch, Mesher, SectionMesh};
|
||||
use crate::models::StateModels;
|
||||
use crate::sprites::Sprites;
|
||||
|
||||
/// A pack's Fusion and OptiFine connected textures.
|
||||
pub struct Rules {
|
||||
ctm: Ctm,
|
||||
fusion: FusionTextures,
|
||||
}
|
||||
|
||||
/// The connected textures under `root`: Fusion's models' tiles, native mods' sprites, then
|
||||
/// OptiFine's rules' tiles go into `sprites`, in that order. `None` without either format's files.
|
||||
pub fn load(
|
||||
root: &Path,
|
||||
registry: &BlockRegistry,
|
||||
sprites: &mut Sprites,
|
||||
models: &mut StateModels,
|
||||
native_mods: &mut dyn FnMut(&mut Sprites),
|
||||
) -> Option<Connected> {
|
||||
let fusion = {
|
||||
let _step = crate::spans::startup::step("core.fusion");
|
||||
FusionTextures::load(root, registry, sprites, models)
|
||||
};
|
||||
native_mods(sprites);
|
||||
// A side's sprite, for rules that connect by tile: the first quad facing it.
|
||||
let catalog = &*models;
|
||||
let mut found = Vec::new();
|
||||
let ctm = Ctm::load(
|
||||
root,
|
||||
registry,
|
||||
sprites,
|
||||
|state: BlockStateId, face: Face| {
|
||||
let side = horizon_assets::model::Direction::ALL[face as usize];
|
||||
catalog
|
||||
.models_at(
|
||||
state,
|
||||
horizon_core::position::BlockPos::new(0, 0, 0),
|
||||
&mut found,
|
||||
)
|
||||
.ok()?;
|
||||
found
|
||||
.iter()
|
||||
.flat_map(|m| &m.faces)
|
||||
.find(|f| f.quad.facing == side)
|
||||
.map(|f| f.layer)
|
||||
},
|
||||
);
|
||||
let rules = Rules { ctm, fusion };
|
||||
(!rules.is_empty()).then(|| Connected {
|
||||
rules: Arc::new(rules),
|
||||
mesh,
|
||||
})
|
||||
}
|
||||
|
||||
/// The mesher on [`Rules`].
|
||||
fn mesh(
|
||||
mesher: &mut Mesher<'_>,
|
||||
rules: &(dyn std::any::Any + Send + Sync),
|
||||
region: &[[Option<&Chunk>; 3]; 3],
|
||||
sy: i32,
|
||||
out: SectionMesh,
|
||||
scratch: &mut MeshScratch,
|
||||
) -> SectionMesh {
|
||||
let rules = rules
|
||||
.downcast_ref::<Rules>()
|
||||
.expect("the connected textures this format loaded");
|
||||
mesher.mesh_with(rules, region, sy, out, scratch)
|
||||
}
|
||||
|
||||
impl ConnectedTextures for Rules {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.ctm.is_empty() && self.fusion.is_empty()
|
||||
}
|
||||
|
||||
fn stacked_pane_hides(&self, state: u32, other: u32, centre: [f32; 2]) -> bool {
|
||||
!self.ctm.is_empty() && self.ctm.stacked_pane_hides(state, other, centre)
|
||||
}
|
||||
|
||||
fn pick<'b>(
|
||||
&self,
|
||||
face: &engine::Face,
|
||||
neighbour: impl Fn([i32; 3]) -> u32,
|
||||
covers: impl Fn(u32) -> bool,
|
||||
biome: impl Fn() -> Option<&'b str>,
|
||||
mut overlay: impl FnMut(Overlay),
|
||||
) -> Pick {
|
||||
let side = Face::ALL[usize::from(face.facing)];
|
||||
let fused = face.rule.and_then(|rule| {
|
||||
self.fusion.pick(
|
||||
rule,
|
||||
face.state,
|
||||
side,
|
||||
face.tint_index,
|
||||
&neighbour,
|
||||
covers,
|
||||
&mut overlay,
|
||||
)
|
||||
});
|
||||
if let Some(pick) = fused {
|
||||
pick
|
||||
} else if self.ctm.is_empty() {
|
||||
Pick::Keep
|
||||
} else {
|
||||
self.ctm.pick(
|
||||
face.state,
|
||||
face.layer,
|
||||
side,
|
||||
face.pos,
|
||||
&neighbour,
|
||||
biome,
|
||||
// A pane's tiles are drawn with the cube's UVs, so that is its texture.
|
||||
|| {
|
||||
if face.pane {
|
||||
Some(Axes::cube(side))
|
||||
} else {
|
||||
Axes::texture(face.positions, face.uv)
|
||||
}
|
||||
},
|
||||
&mut overlay,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,88 @@
|
||||
//! OptiFine's random entity textures and ETF's blinking and emissive textures
|
||||
//! (`horizon_optifine::{random_textures, blink, emissive}`) as the engine's
|
||||
//! [`crate::entity_textures`] tables.
|
||||
|
||||
use crate::entity_textures::{
|
||||
self as engine, Blink, Blinks, Emissive, EntityTextures, Entry, RandomTextures,
|
||||
};
|
||||
use horizon_optifine::random::{self, Rules};
|
||||
use std::path::Path;
|
||||
|
||||
/// The pack's variants, blinks and emissive overlays under the overlay resource root.
|
||||
pub fn load(root: &Path) -> EntityTextures {
|
||||
let random = horizon_optifine::random_textures::RandomTextures::scan(root);
|
||||
let blinks = horizon_optifine::blink::Blinks::scan(root, &random);
|
||||
let mut emissive = horizon_optifine::emissive::Emissive::scan(root, &random);
|
||||
emissive.pair_beside(root, blinks.files());
|
||||
let files = random
|
||||
.files()
|
||||
.map(|(f, n)| (f.to_owned(), n.to_owned()))
|
||||
.collect();
|
||||
let generation = random.generation();
|
||||
let (entries, rules) = random.into_parts();
|
||||
let entries = entries
|
||||
.into_iter()
|
||||
.map(|(texture, entry)| {
|
||||
let variants = entry
|
||||
.variant_names()
|
||||
.map(|(n, name)| (n, name.to_owned()))
|
||||
.collect();
|
||||
let rules = entry.rules;
|
||||
(texture, Entry { rules, variants })
|
||||
})
|
||||
.collect();
|
||||
let (blinks, blink_files) = blinks.into_parts();
|
||||
let blinks = blinks
|
||||
.into_iter()
|
||||
.map(|(texture, b)| {
|
||||
let blink = Blink {
|
||||
shut: b.shut,
|
||||
half: b.half,
|
||||
length: b.length,
|
||||
frequency: b.frequency,
|
||||
};
|
||||
(texture, blink)
|
||||
})
|
||||
.collect();
|
||||
let (suffix, overlays, overlay_files) = emissive.into_parts();
|
||||
EntityTextures {
|
||||
random: RandomTextures::new(entries, files, Box::new(Variants(rules)), generation),
|
||||
blinks: Blinks::new(blinks, blink_files),
|
||||
emissive: Emissive::new(suffix, overlays, overlay_files),
|
||||
}
|
||||
}
|
||||
|
||||
/// The rule files of a scan.
|
||||
struct Variants(Vec<Rules>);
|
||||
|
||||
impl engine::VariantRules for Variants {
|
||||
fn matching(&self, rules: usize, facts: &engine::Facts, seed: i32) -> Option<u32> {
|
||||
let blocks: Vec<random::Block> = facts
|
||||
.blocks
|
||||
.iter()
|
||||
.map(|b| random::Block {
|
||||
id: b.id,
|
||||
states: b.states,
|
||||
})
|
||||
.collect();
|
||||
let facts = random::Facts {
|
||||
biome: facts.biome,
|
||||
y: facts.y,
|
||||
blocks: &blocks,
|
||||
name: facts.name,
|
||||
profession: facts.profession,
|
||||
color: facts.color,
|
||||
baby: facts.baby,
|
||||
health: facts.health,
|
||||
moon_phase: facts.moon_phase,
|
||||
day_time: facts.day_time,
|
||||
weather: facts.weather.map(|w| match w {
|
||||
engine::Weather::Clear => random::Weather::Clear,
|
||||
engine::Weather::Rain => random::Weather::Rain,
|
||||
engine::Weather::Thunder => random::Weather::Thunder,
|
||||
}),
|
||||
size: facts.size,
|
||||
};
|
||||
self.0.get(rules)?.matching(&facts, seed)
|
||||
}
|
||||
}
|
||||
+4
@@ -273,6 +273,10 @@ fn sheet(
|
||||
}
|
||||
|
||||
impl FusionTextures {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.rules.is_empty()
|
||||
}
|
||||
|
||||
/// The connecting textures under `root`: the tiles of those `fusion:connecting` models
|
||||
/// draw are inserted into `sprites` (before it finishes), and each face in `catalog` that
|
||||
/// shows one gets its rule.
|
||||
@@ -3,9 +3,17 @@
|
||||
//! crate until the platform's executable installs it: it uses only the crate's public API, so it
|
||||
//! moves to a platform crate unchanged, and nothing else in the crate names it.
|
||||
|
||||
pub(crate) mod block_entity_cem;
|
||||
pub mod cit;
|
||||
mod cit_glint;
|
||||
mod colors;
|
||||
mod connected;
|
||||
mod ctm;
|
||||
mod custom_sky;
|
||||
mod entity_textures;
|
||||
mod fusion;
|
||||
mod item_bars;
|
||||
mod pack_options;
|
||||
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
@@ -20,15 +28,63 @@ impl PackFormats for Formats {
|
||||
Some(colors::read(root))
|
||||
}
|
||||
|
||||
fn connected_textures(
|
||||
&self,
|
||||
root: &Path,
|
||||
registry: &horizon_world::registry::BlockRegistry,
|
||||
sprites: &mut crate::sprites::Sprites,
|
||||
models: &mut crate::models::StateModels,
|
||||
native_mods: &mut dyn FnMut(&mut crate::sprites::Sprites),
|
||||
) -> Option<crate::connected::Connected> {
|
||||
connected::load(root, registry, sprites, models, native_mods)
|
||||
}
|
||||
|
||||
fn emissive_suffix(&self, root: &Path) -> Option<String> {
|
||||
Some(horizon_optifine::emissive::suffix(root))
|
||||
}
|
||||
|
||||
fn item_textures(&self, root: &Path) -> Option<crate::cit::CitRules> {
|
||||
Some(cit::load(root))
|
||||
}
|
||||
|
||||
fn entity_textures(&self, root: &Path) -> Option<crate::entity_textures::EntityTextures> {
|
||||
Some(entity_textures::load(root))
|
||||
}
|
||||
|
||||
fn block_entity_models(
|
||||
&self,
|
||||
root: &Path,
|
||||
vanilla: &std::collections::HashMap<&'static str, crate::block_entity_render::ModelPart>,
|
||||
) -> Option<crate::pack_hooks::Reshaped> {
|
||||
block_entity_cem::reshape(root, vanilla)
|
||||
}
|
||||
|
||||
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
let sky = custom_sky::CustomSky::load(root);
|
||||
(!sky.is_empty()).then(|| Arc::new(sky) as _)
|
||||
}
|
||||
|
||||
fn pack_options(&self, source: &crate::packs::Source) -> Option<horizon_pack_options::Configs> {
|
||||
Some(pack_options::read(source))
|
||||
}
|
||||
|
||||
fn overlay_active(
|
||||
&self,
|
||||
entry: &serde_json::Value,
|
||||
options: &horizon_pack_options::Configs,
|
||||
formats_apply: bool,
|
||||
) -> bool {
|
||||
horizon_polytone::pack::overlay_active(entry, options, formats_apply)
|
||||
}
|
||||
|
||||
fn sounds_without_unmet(
|
||||
&self,
|
||||
text: &[u8],
|
||||
options: &horizon_pack_options::Configs,
|
||||
) -> Option<Vec<u8>> {
|
||||
horizon_polytone::pack::sounds_without_unmet(text, options)
|
||||
}
|
||||
|
||||
fn item_bars(&self, root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
item_bars::read(root)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Polytone's pack options (wiki "Polytone Configs") and the `polytone_condition`s that read
|
||||
//! them, on pack overlays and on `sounds.json` events.
|
||||
|
||||
use crate::packs::Source;
|
||||
use crate::zip::Zip;
|
||||
use horizon_pack_options::Configs;
|
||||
use horizon_polytone::pack::add_entry;
|
||||
use std::fs;
|
||||
|
||||
/// A pack's Polytone config entries (`assets/<namespace>/polytone/config_entries/<name>.json`,
|
||||
/// in the pack's base, not its overlays) at their default values.
|
||||
pub fn read(source: &Source) -> Configs {
|
||||
let mut configs = Configs::default();
|
||||
let id_of = |name: &str| -> Option<String> {
|
||||
let rest = name.strip_prefix("assets/")?;
|
||||
let (namespace, rest) = rest.split_once('/')?;
|
||||
let entry = rest
|
||||
.strip_prefix("polytone/config_entries/")?
|
||||
.strip_suffix(".json")?;
|
||||
Some(format!("{namespace}:{entry}"))
|
||||
};
|
||||
match source {
|
||||
Source::Dir(d) => {
|
||||
let Ok(namespaces) = fs::read_dir(d.join("assets")) else {
|
||||
return configs;
|
||||
};
|
||||
for namespace in namespaces.flatten() {
|
||||
let folder = namespace.path().join("polytone/config_entries");
|
||||
for file in fs::read_dir(&folder).into_iter().flatten().flatten() {
|
||||
let name = format!(
|
||||
"assets/{}/polytone/config_entries/{}",
|
||||
namespace.file_name().to_string_lossy(),
|
||||
file.file_name().to_string_lossy()
|
||||
);
|
||||
if let (Some(id), Ok(bytes)) = (id_of(&name), fs::read(file.path()))
|
||||
&& let Ok(json) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
add_entry(&mut configs, id, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Source::Zip(z) => {
|
||||
let Ok(zip) = Zip::open(z) else {
|
||||
return configs;
|
||||
};
|
||||
let names: Vec<String> = zip
|
||||
.names()
|
||||
.filter(|n| id_of(n).is_some())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
for name in names {
|
||||
if let (Some(id), Ok(bytes)) = (id_of(&name), zip.read(&name))
|
||||
&& let Ok(json) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
add_entry(&mut configs, id, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
configs
|
||||
}
|
||||
@@ -21,17 +21,87 @@ pub trait PackFormats: Send + Sync {
|
||||
None
|
||||
}
|
||||
|
||||
/// The overlay's connected textures: their tiles go into `sprites` (before it finishes), and
|
||||
/// baked faces they draw on may get a rule (`crate::models::BakedFace::fusion`).
|
||||
/// `native_mods` loads native mods' own sprites: call it after the tiles that go before them.
|
||||
fn connected_textures(
|
||||
&self,
|
||||
_root: &Path,
|
||||
_registry: &horizon_world::registry::BlockRegistry,
|
||||
_sprites: &mut crate::sprites::Sprites,
|
||||
_models: &mut crate::models::StateModels,
|
||||
_native_mods: &mut dyn FnMut(&mut crate::sprites::Sprites),
|
||||
) -> Option<crate::connected::Connected> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The overlay's custom item texture rules and their matcher.
|
||||
fn item_textures(&self, _root: &Path) -> Option<crate::cit::CitRules> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The overlay's entity texture variants, blinks and emissive overlays.
|
||||
fn entity_textures(&self, _root: &Path) -> Option<crate::entity_textures::EntityTextures> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The block entity models the overlay reshapes over `vanilla`'s, with their textures;
|
||||
/// `None` when it reshapes none.
|
||||
fn block_entity_models(
|
||||
&self,
|
||||
_root: &Path,
|
||||
_vanilla: &std::collections::HashMap<&'static str, crate::block_entity_render::ModelPart>,
|
||||
) -> Option<Reshaped> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The sky layers the overlay draws over the vanilla sky, if it has any.
|
||||
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The options the pack (its base, not its overlays) declares, at their defaults.
|
||||
fn pack_options(
|
||||
&self,
|
||||
_source: &crate::packs::Source,
|
||||
) -> Option<horizon_pack_options::Configs> {
|
||||
None
|
||||
}
|
||||
|
||||
/// Whether an `overlays.entries[]` item of `pack.mcmeta` is active, given the pack's options;
|
||||
/// `formats_apply` is vanilla's answer from its `formats`.
|
||||
fn overlay_active(
|
||||
&self,
|
||||
_entry: &serde_json::Value,
|
||||
_options: &horizon_pack_options::Configs,
|
||||
formats_apply: bool,
|
||||
) -> bool {
|
||||
formats_apply
|
||||
}
|
||||
|
||||
/// A pack's `sounds.json` without the events its options turn off; `None` when it keeps
|
||||
/// them all.
|
||||
fn sounds_without_unmet(
|
||||
&self,
|
||||
_text: &[u8],
|
||||
_options: &horizon_pack_options::Configs,
|
||||
) -> Option<Vec<u8>> {
|
||||
None
|
||||
}
|
||||
|
||||
/// The durability bar colours the overlay sets by item, if any.
|
||||
fn item_bars(&self, _root: &Path) -> Option<Arc<dyn crate::pack_palette::ItemBars>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Every block entity model, reshaped by a pack or vanilla's, and the atlas names of the
|
||||
/// pack's textures by model ([`PackFormats::block_entity_models`]).
|
||||
pub type Reshaped = (
|
||||
std::collections::HashMap<&'static str, crate::block_entity_render::ModelPart>,
|
||||
std::collections::HashMap<&'static str, String>,
|
||||
);
|
||||
|
||||
/// No format beyond vanilla's.
|
||||
struct Vanilla;
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::{
|
||||
text::Text,
|
||||
ui,
|
||||
};
|
||||
use horizon_polytone::pack::{ConfigKind, ConfigValue, Configs};
|
||||
use horizon_pack_options::{ConfigKind, ConfigValue, Configs};
|
||||
|
||||
/// What a click asks of the options screen.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
@@ -481,17 +481,36 @@ impl PackOptionsUi {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use horizon_pack_options::ConfigEntry;
|
||||
|
||||
fn slider(default: f64) -> ConfigEntry {
|
||||
let kind = ConfigKind::Number {
|
||||
min: 0.0,
|
||||
max: 1.0,
|
||||
step: 0.1,
|
||||
};
|
||||
ConfigEntry::new(kind, ConfigValue::Number(default))
|
||||
}
|
||||
|
||||
fn in_section(entry: ConfigEntry, section: &str, order: i64) -> ConfigEntry {
|
||||
ConfigEntry {
|
||||
section: Some(section.into()),
|
||||
section_order: Some(order),
|
||||
..entry
|
||||
}
|
||||
}
|
||||
|
||||
fn configs() -> Configs {
|
||||
let mut c = Configs::default();
|
||||
c.add_entry(
|
||||
"p:fog".into(),
|
||||
&serde_json::json!({"default_value": 0.5, "section": "sky", "section_order": 2}),
|
||||
);
|
||||
c.add_entry("p:rain".into(), &serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"], "section": "weather", "section_order": 1}));
|
||||
c.add_entry(
|
||||
"p:birds".into(),
|
||||
&serde_json::json!({"default_value": true}),
|
||||
c.insert("p:fog".into(), in_section(slider(0.5), "sky", 2));
|
||||
let allowed = vec!["drops".into(), "streaks".into()];
|
||||
let rain = ConfigEntry::new(
|
||||
ConfigKind::Text { allowed },
|
||||
ConfigValue::Text("drops".into()),
|
||||
);
|
||||
c.insert("p:rain".into(), in_section(rain, "weather", 1));
|
||||
let birds = ConfigEntry::new(ConfigKind::Bool, ConfigValue::Bool(true));
|
||||
c.insert("p:birds".into(), birds);
|
||||
c
|
||||
}
|
||||
|
||||
@@ -537,9 +556,16 @@ mod tests {
|
||||
#[test]
|
||||
fn the_presets_row_cycles_the_pack_wide_presets_and_shows_custom_otherwise() {
|
||||
let mut c = Configs::default();
|
||||
let entry = |default: f64, fast: f64, pretty: f64| serde_json::json!({"default_value": default, "presets": {"fast": fast, "pretty": pretty}});
|
||||
c.add_entry("p:fog".into(), &entry(0.5, 0.0, 1.0));
|
||||
c.add_entry("p:dust".into(), &entry(0.5, 0.0, 0.8));
|
||||
let entry = |fast: f64, pretty: f64| ConfigEntry {
|
||||
presets: [
|
||||
("fast".into(), ConfigValue::Number(fast)),
|
||||
("pretty".into(), ConfigValue::Number(pretty)),
|
||||
]
|
||||
.into(),
|
||||
..slider(0.5)
|
||||
};
|
||||
c.insert("p:fog".into(), entry(0.0, 1.0));
|
||||
c.insert("p:dust".into(), entry(0.0, 0.8));
|
||||
let mut ui = PackOptionsUi::open(c);
|
||||
// The defaults are no preset.
|
||||
assert_eq!(ui.current_preset(), None);
|
||||
|
||||
@@ -38,7 +38,7 @@ pub struct Pack {
|
||||
/// Polytone `polytone_condition` turns on.
|
||||
pub overlays: Vec<String>,
|
||||
/// The values of the pack's Polytone config entries.
|
||||
pub polytone_configs: horizon_polytone::pack::Configs,
|
||||
pub polytone_configs: Configs,
|
||||
/// `filter.block` entries (namespace and path patterns) hiding resources
|
||||
/// of the packs below this one.
|
||||
pub filter: Vec<Filter>,
|
||||
@@ -419,12 +419,13 @@ fn overlay_applies(entry: &serde_json::Value) -> bool {
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
fn overlays_of(meta: &serde_json::Value, configs: &horizon_polytone::pack::Configs) -> Vec<String> {
|
||||
fn overlays_of(meta: &serde_json::Value, configs: &Configs) -> Vec<String> {
|
||||
let formats = crate::pack_hooks::formats();
|
||||
meta["overlays"]["entries"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter(|e| horizon_polytone::pack::overlay_active(e, configs, overlay_applies(e)))
|
||||
.filter(|e| formats.overlay_active(e, configs, overlay_applies(e)))
|
||||
.filter_map(|e| e["directory"].as_str().map(str::to_owned))
|
||||
// OverlayMetadataSection's DIR_VALIDATOR: one folder of the pack, `[-_a-zA-Z0-9.]+`.
|
||||
.filter(|d| {
|
||||
@@ -471,57 +472,12 @@ pub(crate) fn parse_meta(bytes: &[u8]) -> Option<serde_json::Value> {
|
||||
.next()?
|
||||
.ok()
|
||||
}
|
||||
/// A pack's Polytone config entries (`assets/<namespace>/polytone/config_entries/<name>.json`,
|
||||
/// in the pack's base, not its overlays) at their default values.
|
||||
pub fn polytone_configs(source: &Source) -> horizon_polytone::pack::Configs {
|
||||
let mut configs = horizon_polytone::pack::Configs::default();
|
||||
let id_of = |name: &str| -> Option<String> {
|
||||
let rest = name.strip_prefix("assets/")?;
|
||||
let (namespace, rest) = rest.split_once('/')?;
|
||||
let entry = rest
|
||||
.strip_prefix("polytone/config_entries/")?
|
||||
.strip_suffix(".json")?;
|
||||
Some(format!("{namespace}:{entry}"))
|
||||
};
|
||||
match source {
|
||||
Source::Dir(d) => {
|
||||
let Ok(namespaces) = fs::read_dir(d.join("assets")) else {
|
||||
return configs;
|
||||
};
|
||||
for namespace in namespaces.flatten() {
|
||||
let folder = namespace.path().join("polytone/config_entries");
|
||||
for file in fs::read_dir(&folder).into_iter().flatten().flatten() {
|
||||
let name = format!(
|
||||
"assets/{}/polytone/config_entries/{}",
|
||||
namespace.file_name().to_string_lossy(),
|
||||
file.file_name().to_string_lossy()
|
||||
);
|
||||
if let (Some(id), Ok(bytes)) = (id_of(&name), fs::read(file.path()))
|
||||
&& let Ok(json) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
configs.add_entry(id, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Source::Zip(z) => {
|
||||
let Ok(zip) = Zip::open(z) else {
|
||||
return configs;
|
||||
};
|
||||
let names: Vec<String> = zip
|
||||
.names()
|
||||
.filter(|n| id_of(n).is_some())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
for name in names {
|
||||
if let (Some(id), Ok(bytes)) = (id_of(&name), zip.read(&name))
|
||||
&& let Ok(json) = serde_json::from_slice(&bytes)
|
||||
{
|
||||
configs.add_entry(id, &json);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
/// A pack's own options (Polytone's config entries, in the pack's base, not its overlays) at
|
||||
/// the values the player chose, else their defaults.
|
||||
pub fn polytone_configs(source: &Source) -> Configs {
|
||||
let mut configs = crate::pack_hooks::formats()
|
||||
.pack_options(source)
|
||||
.unwrap_or_default();
|
||||
for (id, value) in polytone_choices() {
|
||||
if configs.entries().contains_key(&id) {
|
||||
configs.set(&id, value);
|
||||
@@ -530,15 +486,15 @@ pub fn polytone_configs(source: &Source) -> horizon_polytone::pack::Configs {
|
||||
configs
|
||||
}
|
||||
|
||||
/// A pack's Polytone config entries and one's value, for the game's Pack Options.
|
||||
pub use horizon_polytone::pack::{ConfigValue, Configs};
|
||||
/// A pack's options and one's value, for the game's Pack Options.
|
||||
pub use horizon_pack_options::{ConfigValue, Configs};
|
||||
|
||||
/// The player's Polytone config choices (#11486): what they changed from the packs' defaults,
|
||||
/// by entry id, for every pack. `polytone_configs` lays them over each pack's defaults.
|
||||
static POLYTONE_CHOICES: std::sync::RwLock<BTreeMap<String, horizon_polytone::pack::ConfigValue>> =
|
||||
static POLYTONE_CHOICES: std::sync::RwLock<BTreeMap<String, ConfigValue>> =
|
||||
std::sync::RwLock::new(BTreeMap::new());
|
||||
|
||||
pub fn polytone_choices() -> BTreeMap<String, horizon_polytone::pack::ConfigValue> {
|
||||
pub fn polytone_choices() -> BTreeMap<String, ConfigValue> {
|
||||
POLYTONE_CHOICES
|
||||
.read()
|
||||
.map(|c| c.clone())
|
||||
@@ -546,7 +502,7 @@ pub fn polytone_choices() -> BTreeMap<String, horizon_polytone::pack::ConfigValu
|
||||
}
|
||||
|
||||
/// Replaces the player's Polytone config choices (packs read them when they load again).
|
||||
pub fn set_polytone_choices(choices: BTreeMap<String, horizon_polytone::pack::ConfigValue>) {
|
||||
pub fn set_polytone_choices(choices: BTreeMap<String, ConfigValue>) {
|
||||
if let Ok(mut c) = POLYTONE_CHOICES.write() {
|
||||
*c = choices;
|
||||
}
|
||||
@@ -558,8 +514,7 @@ pub fn polytone_choices_file() -> std::path::PathBuf {
|
||||
}
|
||||
|
||||
/// Reads the choices from `path` (none when it is missing or broken).
|
||||
pub fn load_polytone_choices(path: &Path) -> BTreeMap<String, horizon_polytone::pack::ConfigValue> {
|
||||
use horizon_polytone::pack::ConfigValue;
|
||||
pub fn load_polytone_choices(path: &Path) -> BTreeMap<String, ConfigValue> {
|
||||
let Ok(bytes) = fs::read(path) else {
|
||||
return BTreeMap::new();
|
||||
};
|
||||
@@ -583,9 +538,8 @@ pub fn load_polytone_choices(path: &Path) -> BTreeMap<String, horizon_polytone::
|
||||
/// Writes the choices to `path`.
|
||||
pub fn save_polytone_choices(
|
||||
path: &Path,
|
||||
choices: &BTreeMap<String, horizon_polytone::pack::ConfigValue>,
|
||||
choices: &BTreeMap<String, ConfigValue>,
|
||||
) -> std::io::Result<()> {
|
||||
use horizon_polytone::pack::ConfigValue;
|
||||
let map: serde_json::Map<String, serde_json::Value> = choices
|
||||
.iter()
|
||||
.map(|(id, v)| {
|
||||
@@ -856,7 +810,8 @@ fn build_overlay_uncached(
|
||||
packs
|
||||
.get(*owner)
|
||||
.and_then(|p| {
|
||||
horizon_polytone::pack::sounds_without_unmet(&b, &p.polytone_configs)
|
||||
crate::pack_hooks::formats()
|
||||
.sounds_without_unmet(&b, &p.polytone_configs)
|
||||
})
|
||||
.unwrap_or(b),
|
||||
),
|
||||
@@ -1606,6 +1561,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn a_sound_entry_whose_polytone_condition_fails_is_left_out() {
|
||||
crate::pack_formats::install();
|
||||
let base =
|
||||
std::env::temp_dir().join(format!("horizon-packs-sound-cond-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&base);
|
||||
@@ -2121,6 +2077,7 @@ mod tests {
|
||||
}
|
||||
#[test]
|
||||
fn polytone_conditions_turn_overlays_on_from_the_packs_configs() {
|
||||
crate::pack_formats::install();
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("horizon-packs-polytone-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&dir);
|
||||
|
||||
@@ -796,7 +796,7 @@ impl Host for Ctx<'_, '_> {
|
||||
.registry
|
||||
.configs
|
||||
.get(id)
|
||||
.map(horizon_polytone::pack::ConfigValue::to_value)
|
||||
.map(horizon_polytone::pack::config_value)
|
||||
.ok_or_else(|| format!("no config {id}")),
|
||||
_ => Err(format!("{name} is not available here")),
|
||||
}
|
||||
|
||||
@@ -50,6 +50,8 @@ unsafe impl GlobalAlloc for CountingAllocator {
|
||||
static ALLOCATOR: CountingAllocator = CountingAllocator;
|
||||
|
||||
fn main() -> horizon_client::Result<()> {
|
||||
// The pack formats the game installs at startup, so their loading is measured too.
|
||||
horizon_client::pack_formats::install();
|
||||
let root = std::env::args()
|
||||
.nth(1)
|
||||
.ok_or("usage: model_library_bench ROOT [--parity]")?;
|
||||
|
||||
@@ -14,19 +14,20 @@ pub use horizon_client_render::{
|
||||
block_entities, block_entity_anim, block_entity_render, block_sprites, block_states, bluemap,
|
||||
bluemap_detect, border_render, breaking, brewing, camera, chat, chunk, chunk_map, cit,
|
||||
cit_armor, cit_assets, cit_glint, client_resources, clouds, collisions, command_block,
|
||||
containers, controller_ui, crash, custom_sky, datapack, dynamic_light, effects, environment,
|
||||
extras, fast_hash, fluid_render, fog, font, gfx, graphics, graphics_style, graphics_ui,
|
||||
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
|
||||
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, menu_style, mesher,
|
||||
minimap, mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
|
||||
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
|
||||
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
|
||||
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
|
||||
pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask, server_terrain,
|
||||
settings, shader_options, shader_pack_list, signs, skins, social, sound_engine, sound_events,
|
||||
sound_preview, spans, split_layout, split_map, split_profiles, sprites, streaming,
|
||||
structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
|
||||
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
|
||||
containers, controller_ui, crash, custom_sky, datapack, dynamic_light, effects,
|
||||
entity_textures, environment, extras, fast_hash, fluid_render, fog, font, gfx, graphics,
|
||||
graphics_style, graphics_ui, hdr_calibration, hud, hud_kit, hud_layout, hud_overlays,
|
||||
inventory_effects, java_text, jigsaw_block, json_folder, level_loading, light, lod, logging,
|
||||
menu_blur, menu_style, mesher, minimap, mipmap, mod_browser, mod_list, model_library, models,
|
||||
multiline_edit, native_emissive, options_file, options_fixes, options_ui, overlay,
|
||||
pack_browser, pack_formats, pack_hooks, pack_palette, pack_screen, packs, pad_cursor,
|
||||
pad_glyph, par, particles, poi_sections, polytone_creative_tabs, polytone_post, post_chain,
|
||||
potent_sulfur, preedit, providers, pvp, pvp_hud, random_source, screen_draw, screen_effects,
|
||||
sculk, section_mask, server_terrain, settings, shader_options, shader_pack_list, signs, skins,
|
||||
social, sound_engine, sound_events, sound_preview, spans, split_layout, split_map,
|
||||
split_profiles, sprites, streaming, structure_block, tab_list, terrain_model, test_block,
|
||||
test_instance_block, testgen, title_edition, trims, ui, vault, weather, widgets, world,
|
||||
worldborder, zip,
|
||||
};
|
||||
pub mod attack_strength;
|
||||
pub mod attribute_updates;
|
||||
@@ -89,6 +90,7 @@ pub mod locator_bar;
|
||||
pub mod lod_net;
|
||||
pub mod menu;
|
||||
pub mod merchant;
|
||||
pub mod mob_formats;
|
||||
pub mod mobs;
|
||||
pub mod mod_content;
|
||||
pub mod mod_registry;
|
||||
|
||||
@@ -16,6 +16,7 @@ pub fn main_with(mods: crate::mod_api::Mods) {
|
||||
crate::horizon_look::give();
|
||||
// OptiFine's and Polytone's pack formats (platform, docs/layers.md), before any pack loads.
|
||||
horizon_client::pack_formats::install();
|
||||
horizon_client::mob_formats::install();
|
||||
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
|
||||
if !cfg!(feature = "horizon-extras") {
|
||||
horizon_client::options_ui::HORIZON_EXTRAS
|
||||
|
||||
@@ -367,10 +367,7 @@ impl Game<'_> {
|
||||
// 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);
|
||||
let pack_keeps = renderer.cit.as_ref().is_none_or(|c| c.rules.use_glint);
|
||||
if !glint.vanilla && (gui || !pack_keeps) {
|
||||
for layer in &mut state.layers {
|
||||
layer.foil = horizon_assets::item_model::FoilType::None;
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
//! checks (`horizon_client::mobs::random_textures`).
|
||||
|
||||
use super::Game;
|
||||
use horizon_client::entity_textures::Weather;
|
||||
use horizon_client::mobs::random_textures::{Seen, choose, seed};
|
||||
use horizon_core::geometry::Vec3;
|
||||
use horizon_optifine::random::Weather;
|
||||
|
||||
impl Game<'_> {
|
||||
pub(super) fn tick_random_textures(&mut self) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! OptiFine Custom Item Textures on item models: the layer of a stack a `type=item` rule matches
|
||||
//! Custom item textures on item models: the layer of a stack an item rule (`Kind::Item`) 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`).
|
||||
|
||||
@@ -105,7 +105,7 @@ pub(super) fn load(
|
||||
) -> (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 {
|
||||
if rules.is_empty() && rules.use_glint {
|
||||
return (library, None);
|
||||
}
|
||||
let extra = cit_assets::load_models(root, &rules.rules);
|
||||
@@ -387,6 +387,7 @@ mod tests {
|
||||
|
||||
/// A stick, a bow's frames and a potion; with `cit`, rules over them.
|
||||
fn root(name: &str, cit: bool) -> PathBuf {
|
||||
crate::pack_formats::install();
|
||||
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| {
|
||||
@@ -719,11 +720,8 @@ mod tests {
|
||||
}
|
||||
|
||||
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(),
|
||||
])
|
||||
crate::pack_formats::cit::parse_rules(&[(path, text)], "")
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -692,23 +692,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cit_layers_draw_once_over_the_whole_item() {
|
||||
use crate::cit_glint::Global;
|
||||
use crate::cit_glint::GlintLayer;
|
||||
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()
|
||||
crate::pack_formats::cit::parse_rule(&path, &text).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);
|
||||
// The second layer at intensity 0 (shared by level, and the stack has none of its
|
||||
// enchantment).
|
||||
let layers = vec![GlintLayer::new(0, 1.0, 1.0), GlintLayer::new(1, 0.0, 1.0)];
|
||||
let glint = StackGlint::from_parts(layers, false, None);
|
||||
let slot = |blend| Slot {
|
||||
rgba: [1.0; 4],
|
||||
blur: false,
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,31 @@
|
||||
//! The pack formats vanilla doesn't read that shape mobs (OptiFine's and Entity Model Features'
|
||||
//! custom entity models) on the engine's extension point ([`crate::mobs::cem::install`]). Platform
|
||||
//! code (docs/layers.md) kept in this crate until a platform crate installs it: it uses only the
|
||||
//! crate's public API, and nothing else in the crate names it.
|
||||
|
||||
pub mod cem;
|
||||
mod cem_anim;
|
||||
|
||||
use crate::mobs::MobType;
|
||||
use crate::mobs::cem::{CemModels, ModelFormats};
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// OptiFine's custom entity models.
|
||||
pub struct Formats;
|
||||
|
||||
impl ModelFormats for Formats {
|
||||
fn entity_models(
|
||||
&self,
|
||||
root: &Path,
|
||||
types: &HashMap<String, Arc<MobType>>,
|
||||
) -> (CemModels, Vec<String>) {
|
||||
cem::load(root, types)
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs these formats ([`crate::mobs::cem::install`]); the game does at startup.
|
||||
pub fn install() {
|
||||
crate::mobs::cem::install(&Formats);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -51,11 +51,11 @@ pub struct MobData {
|
||||
/// The packs' custom entity models (OptiFine CEM); replaced when the resource packs change.
|
||||
pub cem: std::sync::RwLock<std::sync::Arc<super::cem::CemModels>>,
|
||||
/// The packs' random entity textures, replaced with them too.
|
||||
pub random_textures: std::sync::RwLock<horizon_optifine::random_textures::RandomTextures>,
|
||||
pub random_textures: std::sync::RwLock<crate::entity_textures::RandomTextures>,
|
||||
/// The packs' emissive overlays of entity textures, replaced with them too.
|
||||
pub emissive: std::sync::RwLock<std::sync::Arc<horizon_optifine::emissive::Emissive>>,
|
||||
pub emissive: std::sync::RwLock<std::sync::Arc<crate::entity_textures::Emissive>>,
|
||||
/// ETF's blinking textures, replaced with the packs too.
|
||||
pub blinks: std::sync::RwLock<horizon_optifine::blink::Blinks>,
|
||||
pub blinks: std::sync::RwLock<crate::entity_textures::Blinks>,
|
||||
/// Biome tags (spawns_warm_variant_farm_animals, is_ocean...) by tag id.
|
||||
pub biome_tags: HashMap<String, std::collections::HashSet<String>>,
|
||||
/// Entity type tags (`data/*/tags/entity_type`).
|
||||
@@ -92,10 +92,11 @@ impl MobData {
|
||||
crate::items::load_tags(root, "entity_type", &horizon_world::loot::PackOverlay::NONE)
|
||||
.unwrap_or_default();
|
||||
data.villager_hats = std::sync::RwLock::new(super::villager_hats::VillagerHats::load(root));
|
||||
let random = horizon_optifine::random_textures::RandomTextures::scan(root);
|
||||
let blinks = horizon_optifine::blink::Blinks::scan(root, &random);
|
||||
let mut emissive = horizon_optifine::emissive::Emissive::scan(root, &random);
|
||||
emissive.pair_beside(root, blinks.files());
|
||||
let crate::entity_textures::EntityTextures {
|
||||
random,
|
||||
blinks,
|
||||
emissive,
|
||||
} = crate::entity_textures::EntityTextures::load(root);
|
||||
data.emissive = std::sync::RwLock::new(std::sync::Arc::new(emissive));
|
||||
data.blinks = std::sync::RwLock::new(blinks);
|
||||
data.random_textures = std::sync::RwLock::new(random);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! OptiFine random entity textures on mobs: which variant of each of its textures a mob wears
|
||||
//! (`horizon_optifine::random_textures` finds them in the packs).
|
||||
//! (the pack formats find them in the packs: [`crate::entity_textures`]).
|
||||
//!
|
||||
//! [`choose`] runs when the client first sees a mob, about once a second after that, and on the
|
||||
//! next tick when the mob draws a texture with rules it hasn't chosen for (a wolf just tamed;
|
||||
@@ -16,8 +16,7 @@
|
||||
|
||||
use super::render::{DYE_NAMES, PROFESSIONS};
|
||||
use super::{Mob, MobData, Species};
|
||||
use horizon_optifine::random::{self, Block, Facts, Weather};
|
||||
use horizon_optifine::random_textures::RandomTextures;
|
||||
use crate::entity_textures::{self as random, Block, Facts, RandomTextures, Weather};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
|
||||
/// Where the mob was when the client first saw it.
|
||||
@@ -41,7 +40,7 @@ pub struct Choice {
|
||||
/// The [`RandomTextures::generation`] the picks are from: another (a pack reload) makes
|
||||
/// them stale.
|
||||
generation: u64,
|
||||
/// The variant each rule file chose, by its index in [`RandomTextures::rules`].
|
||||
/// The variant each rule file chose, by its index ([`RandomTextures::matching`]).
|
||||
picks: Vec<(usize, u32)>,
|
||||
/// The body texture's variant.
|
||||
primary: Option<u32>,
|
||||
@@ -176,7 +175,7 @@ pub fn choose(
|
||||
Some(&(_, index)) => index,
|
||||
None => {
|
||||
// No rule matching is the texture itself, as is ETF's 0.
|
||||
let index = textures.rules(id).matching(&facts, seed).unwrap_or(0);
|
||||
let index = textures.matching(id, &facts, seed).unwrap_or(0);
|
||||
choice.picks.push((id, index));
|
||||
index
|
||||
}
|
||||
@@ -301,7 +300,8 @@ mod tests {
|
||||
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
std::fs::write(path, text).unwrap();
|
||||
}
|
||||
let textures = RandomTextures::scan(&root);
|
||||
crate::pack_formats::install();
|
||||
let textures = crate::entity_textures::EntityTextures::load(&root).random;
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
textures
|
||||
}
|
||||
|
||||
@@ -2351,7 +2351,7 @@ pub fn part_matrix(
|
||||
.find_map(|c| part_matrix(c, m, poses, name, x_offset))
|
||||
}
|
||||
/// The first part named `name`, depth first.
|
||||
pub(super) fn find_part<'a>(part: &'a Part, name: &str) -> Option<&'a Part> {
|
||||
pub fn find_part<'a>(part: &'a Part, name: &str) -> Option<&'a Part> {
|
||||
if part.name == name {
|
||||
return Some(part);
|
||||
}
|
||||
@@ -3640,7 +3640,6 @@ mod scroll_tests {
|
||||
mod emissive_tests {
|
||||
use super::*;
|
||||
use crate::mobs::{Polygon, Species};
|
||||
use horizon_optifine::{blink::Blinks, emissive::Emissive, random_textures::RandomTextures};
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// A silverfish (drawn with its type's texture) whose model is one quad.
|
||||
@@ -3686,10 +3685,12 @@ mod emissive_tests {
|
||||
};
|
||||
std::fs::write(path, text).unwrap();
|
||||
}
|
||||
let random = RandomTextures::scan(&root);
|
||||
let blinks = Blinks::scan(&root, &random);
|
||||
let mut emissive = Emissive::scan(&root, &random);
|
||||
emissive.pair_beside(&root, blinks.files());
|
||||
crate::pack_formats::install();
|
||||
let crate::entity_textures::EntityTextures {
|
||||
random,
|
||||
blinks,
|
||||
emissive,
|
||||
} = crate::entity_textures::EntityTextures::load(&root);
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
MobData {
|
||||
states: Vec::new(),
|
||||
@@ -3869,7 +3870,7 @@ mod emissive_tests {
|
||||
blocks: Vec::new(),
|
||||
moon_phase: 0,
|
||||
day_time: 0,
|
||||
weather: horizon_optifine::random::Weather::Clear,
|
||||
weather: crate::entity_textures::Weather::Clear,
|
||||
};
|
||||
let props = format!("{RANDOM}/silverfish.properties");
|
||||
let variant = format!("{RANDOM}/silverfish2.png");
|
||||
@@ -3931,7 +3932,6 @@ mod emissive_tests {
|
||||
mod random_texture_tests {
|
||||
use super::*;
|
||||
use crate::mobs::{Polygon, Species};
|
||||
use horizon_optifine::random_textures::RandomTextures;
|
||||
use std::sync::{Arc, RwLock};
|
||||
|
||||
/// A silverfish (drawn with its type's texture) whose model is one quad.
|
||||
@@ -3967,7 +3967,8 @@ mod random_texture_tests {
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(dir.join("silverfish.properties"), "textures.1=2").unwrap();
|
||||
std::fs::write(dir.join("silverfish2.png"), "").unwrap();
|
||||
let random = RandomTextures::scan(&root);
|
||||
crate::pack_formats::install();
|
||||
let random = crate::entity_textures::EntityTextures::load(&root).random;
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
MobData {
|
||||
states: Vec::new(),
|
||||
@@ -4004,7 +4005,7 @@ mod random_texture_tests {
|
||||
blocks: Vec::new(),
|
||||
moon_phase: 0,
|
||||
day_time: 0,
|
||||
weather: horizon_optifine::random::Weather::Clear,
|
||||
weather: crate::entity_textures::Weather::Clear,
|
||||
};
|
||||
crate::mobs::random_textures::choose(&mut m, &textures, &data, Some(seen), 0);
|
||||
}
|
||||
|
||||
@@ -81,6 +81,11 @@ impl Blinks {
|
||||
self.blinks.is_empty()
|
||||
}
|
||||
|
||||
/// The blinks by texture, and [`Self::files`].
|
||||
pub fn into_parts(self) -> (HashMap<String, Blink>, Vec<(String, String)>) {
|
||||
(self.blinks, self.files)
|
||||
}
|
||||
|
||||
pub fn get(&self, texture: &str) -> Option<&Blink> {
|
||||
self.blinks.get(texture)
|
||||
}
|
||||
|
||||
@@ -95,6 +95,11 @@ impl Emissive {
|
||||
self.overlays.is_empty()
|
||||
}
|
||||
|
||||
/// The suffix, the overlays by texture, and [`Self::files`].
|
||||
pub fn into_parts(self) -> (String, HashMap<String, String>, Vec<(String, String)>) {
|
||||
(self.suffix, self.overlays, self.files)
|
||||
}
|
||||
|
||||
/// The atlas name of the overlay that glows over `texture`.
|
||||
pub fn overlay(&self, texture: &str) -> Option<&str> {
|
||||
self.overlays.get(texture).map(String::as_str)
|
||||
|
||||
@@ -183,6 +183,11 @@ impl RandomTextures {
|
||||
&self.rules[id]
|
||||
}
|
||||
|
||||
/// Every texture's entry by atlas name, and the rule files they index.
|
||||
pub fn into_parts(self) -> (HashMap<String, Entry>, Vec<Rules>) {
|
||||
(self.entries, self.rules)
|
||||
}
|
||||
|
||||
/// Every variant's file (its path from the resource root) and atlas name, for the entity
|
||||
/// atlas to pack. That includes variants beside their texture under `textures/entity/`,
|
||||
/// which the atlas may already walk.
|
||||
@@ -196,6 +201,11 @@ impl RandomTextures {
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
/// Each variant's number and atlas name.
|
||||
pub fn variant_names(&self) -> impl Iterator<Item = (u32, &str)> {
|
||||
self.variants.iter().map(|(&n, v)| (n, v.name.as_str()))
|
||||
}
|
||||
|
||||
/// The atlas name of variant `index`, or `None` for the texture itself: variant 0, 1 without
|
||||
/// a `<name>1.png`, and a variant whose file is missing.
|
||||
pub fn texture(&self, index: u32) -> Option<&str> {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "horizon-pack-options"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "A resource pack's own options as the game lists and keeps them: entries, their controls, values and presets, whatever format declares them"
|
||||
|
||||
[dependencies]
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,160 @@
|
||||
//! A resource pack's own options (its "configs"), as the game lists, keeps and applies them:
|
||||
//! each entry's control, default and place on the Pack Options screen, the values a player
|
||||
//! picked, and the pack-wide presets. The pack formats that declare options (Polytone's
|
||||
//! `config_entries`, wiki "Polytone Configs") fill these; the engine only stores and edits them.
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
/// A config entry's value: a number (slider), text (cycle button) or boolean (toggle).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ConfigValue {
|
||||
Number(f64),
|
||||
Text(String),
|
||||
Bool(bool),
|
||||
}
|
||||
|
||||
/// What kind of control a config entry is, with its kind's own fields.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ConfigKind {
|
||||
/// A slider from `min` to `max` in steps of `step`.
|
||||
Number { min: f64, max: f64, step: f64 },
|
||||
/// A cycle button over `allowed_values`.
|
||||
Text { allowed: Vec<String> },
|
||||
/// An ON/OFF toggle.
|
||||
Bool,
|
||||
}
|
||||
|
||||
/// A config entry: its control, default and how the options screen lists it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ConfigEntry {
|
||||
pub kind: ConfigKind,
|
||||
pub default: ConfigValue,
|
||||
/// The `config.<namespace>.section.<id>` group it is listed under, and that group's place.
|
||||
pub section: Option<String>,
|
||||
pub section_order: Option<i64>,
|
||||
pub display_order: i64,
|
||||
/// `low`, `medium`, `high` or `varies`.
|
||||
pub performance_impact: Option<String>,
|
||||
/// A full-width row instead of a half-width one.
|
||||
pub wide: bool,
|
||||
/// The translation key of the value's label (`%s blocks`).
|
||||
pub value_translation: Option<String>,
|
||||
/// Its value in each pack-wide preset, and in each preset of its section.
|
||||
pub presets: BTreeMap<String, ConfigValue>,
|
||||
pub section_presets: BTreeMap<String, ConfigValue>,
|
||||
}
|
||||
|
||||
impl ConfigEntry {
|
||||
/// An entry of `kind` starting at `default`, in no section and no preset.
|
||||
pub fn new(kind: ConfigKind, default: ConfigValue) -> Self {
|
||||
Self {
|
||||
kind,
|
||||
default,
|
||||
section: None,
|
||||
section_order: None,
|
||||
display_order: 0,
|
||||
performance_impact: None,
|
||||
wide: false,
|
||||
value_translation: None,
|
||||
presets: BTreeMap::new(),
|
||||
section_presets: BTreeMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// `value` kept within what the entry allows: a number clamped to its range and snapped
|
||||
/// to its step, a text one of its values; `None` for a value of another kind.
|
||||
pub fn accept(&self, value: &ConfigValue) -> Option<ConfigValue> {
|
||||
match (&self.kind, value) {
|
||||
(ConfigKind::Number { min, max, step }, ConfigValue::Number(n)) => {
|
||||
let (lo, hi) = (min.min(*max), max.max(*min));
|
||||
let mut v = n.clamp(lo, hi);
|
||||
if *step > 0.0 {
|
||||
v = (lo + ((v - lo) / step).round() * step).clamp(lo, hi);
|
||||
}
|
||||
Some(ConfigValue::Number(v))
|
||||
}
|
||||
(ConfigKind::Text { allowed }, ConfigValue::Text(t)) => {
|
||||
(allowed.is_empty() || allowed.contains(t)).then(|| value.clone())
|
||||
}
|
||||
(ConfigKind::Bool, ConfigValue::Bool(_)) => Some(value.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pack's config entries and their values, by id (`namespace:name`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Configs {
|
||||
values: BTreeMap<String, ConfigValue>,
|
||||
entries: BTreeMap<String, ConfigEntry>,
|
||||
}
|
||||
|
||||
impl Configs {
|
||||
/// Adds an entry; its value starts at its default.
|
||||
pub fn insert(&mut self, id: String, entry: ConfigEntry) {
|
||||
self.values.insert(id.clone(), entry.default.clone());
|
||||
self.entries.insert(id, entry);
|
||||
}
|
||||
|
||||
/// Changes an entry's value (a player's choice), kept within what the entry allows. An id
|
||||
/// without an entry takes any value.
|
||||
pub fn set(&mut self, id: &str, value: ConfigValue) {
|
||||
let value = match self.entries.get(id) {
|
||||
Some(entry) => match entry.accept(&value) {
|
||||
Some(v) => v,
|
||||
None => return,
|
||||
},
|
||||
None => value,
|
||||
};
|
||||
self.values.insert(id.to_owned(), value);
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&ConfigValue> {
|
||||
self.values.get(id)
|
||||
}
|
||||
|
||||
/// The entries, by id.
|
||||
pub fn entries(&self) -> &BTreeMap<String, ConfigEntry> {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// The values that differ from their entries' defaults: what a player chose.
|
||||
pub fn chosen(&self) -> BTreeMap<String, ConfigValue> {
|
||||
self.values
|
||||
.iter()
|
||||
.filter(|(id, v)| self.entries.get(*id).is_none_or(|e| &e.default != *v))
|
||||
.map(|(id, v)| (id.clone(), v.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every entry with a value in `preset` (a pack-wide preset) takes it.
|
||||
pub fn apply_preset(&mut self, preset: &str) {
|
||||
let picks: Vec<_> = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|(id, e)| Some((id.clone(), e.presets.get(preset)?.clone())))
|
||||
.collect();
|
||||
for (id, value) in picks {
|
||||
self.set(&id, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes `other`'s entries and values (another pack's), over its own of the same id.
|
||||
pub fn merge(&mut self, other: &Configs) {
|
||||
self.entries
|
||||
.extend(other.entries.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
self.values
|
||||
.extend(other.values.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
|
||||
/// The pack-wide presets' names, in order.
|
||||
pub fn presets(&self) -> Vec<String> {
|
||||
let mut names: Vec<_> = self
|
||||
.entries
|
||||
.values()
|
||||
.flat_map(|e| e.presets.keys().cloned())
|
||||
.collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ rust-version.workspace = true
|
||||
description = "Polytone-format resource pack files: targets, and the biome and dimension modifiers' environment attribute layers"
|
||||
|
||||
[dependencies]
|
||||
horizon-pack-options = { path = "../horizon-pack-options" }
|
||||
log = "0.4"
|
||||
regex = { version = "1", default-features = false, features = ["std", "unicode-perl"] }
|
||||
serde_json = "1"
|
||||
|
||||
@@ -8,211 +8,75 @@ use std::collections::BTreeMap;
|
||||
/// The Minecraft version Horizon plays, for `modOn('minecraft', range)`.
|
||||
pub const MINECRAFT_VERSION: &str = "26.3";
|
||||
|
||||
/// A config entry's value: a number (slider), text (cycle button) or boolean (toggle).
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ConfigValue {
|
||||
Number(f64),
|
||||
Text(String),
|
||||
Bool(bool),
|
||||
}
|
||||
pub use horizon_pack_options::{ConfigEntry, ConfigKind, ConfigValue, Configs};
|
||||
|
||||
impl ConfigValue {
|
||||
/// The value an expression's `config(id)` returns.
|
||||
pub fn to_value(&self) -> Value {
|
||||
match self {
|
||||
Self::Number(n) => Value::Num(*n),
|
||||
Self::Text(s) => Value::Str(s.as_str().into()),
|
||||
Self::Bool(b) => Value::Bool(*b),
|
||||
}
|
||||
/// The value an expression's `config(id)` returns.
|
||||
pub fn config_value(value: &ConfigValue) -> Value {
|
||||
match value {
|
||||
ConfigValue::Number(n) => Value::Num(*n),
|
||||
ConfigValue::Text(s) => Value::Str(s.as_str().into()),
|
||||
ConfigValue::Bool(b) => Value::Bool(*b),
|
||||
}
|
||||
}
|
||||
|
||||
/// What kind of control a config entry is, with its kind's own fields.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub enum ConfigKind {
|
||||
/// A slider from `min` to `max` in steps of `step`.
|
||||
Number { min: f64, max: f64, step: f64 },
|
||||
/// A cycle button over `allowed_values`.
|
||||
Text { allowed: Vec<String> },
|
||||
/// An ON/OFF toggle.
|
||||
Bool,
|
||||
fn parse_value(v: &Json) -> Option<ConfigValue> {
|
||||
Some(match v {
|
||||
Json::Number(n) => ConfigValue::Number(n.as_f64()?),
|
||||
Json::String(s) => ConfigValue::Text(s.clone()),
|
||||
Json::Bool(b) => ConfigValue::Bool(*b),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// A config entry's file (wiki "Polytone Configs"): its control, default and how the options
|
||||
/// screen lists it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ConfigEntry {
|
||||
pub kind: ConfigKind,
|
||||
pub default: ConfigValue,
|
||||
/// The `config.<namespace>.section.<id>` group it is listed under, and that group's place.
|
||||
pub section: Option<String>,
|
||||
pub section_order: Option<i64>,
|
||||
pub display_order: i64,
|
||||
/// `low`, `medium`, `high` or `varies`.
|
||||
pub performance_impact: Option<String>,
|
||||
/// A full-width row instead of a half-width one.
|
||||
pub wide: bool,
|
||||
/// The translation key of the value's label (`%s blocks`).
|
||||
pub value_translation: Option<String>,
|
||||
/// Its value in each pack-wide preset, and in each preset of its section.
|
||||
pub presets: BTreeMap<String, ConfigValue>,
|
||||
pub section_presets: BTreeMap<String, ConfigValue>,
|
||||
}
|
||||
|
||||
impl ConfigValue {
|
||||
fn parse(v: &Json) -> Option<Self> {
|
||||
Some(match v {
|
||||
Json::Number(n) => Self::Number(n.as_f64()?),
|
||||
Json::String(s) => Self::Text(s.clone()),
|
||||
Json::Bool(b) => Self::Bool(*b),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl ConfigEntry {
|
||||
/// `None` without a `default_value` of a number, string or boolean.
|
||||
pub fn parse(json: &Json) -> Option<Self> {
|
||||
let default = ConfigValue::parse(&json["default_value"])?;
|
||||
let num = |key: &str, d: f64| json[key].as_f64().unwrap_or(d);
|
||||
let kind = match &default {
|
||||
ConfigValue::Number(_) => ConfigKind::Number {
|
||||
min: num("min", 0.0),
|
||||
max: num("max", 1.0),
|
||||
step: num("step", 0.1),
|
||||
},
|
||||
ConfigValue::Text(_) => ConfigKind::Text {
|
||||
allowed: json["allowed_values"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|v| v.as_str().map(str::to_owned))
|
||||
.collect(),
|
||||
},
|
||||
ConfigValue::Bool(_) => ConfigKind::Bool,
|
||||
};
|
||||
let presets = |key: &str| -> BTreeMap<String, ConfigValue> {
|
||||
json[key]
|
||||
.as_object()
|
||||
/// A `config_entries/<name>.json` file's entry; `None` without a `default_value` of a number,
|
||||
/// string or boolean.
|
||||
pub fn parse_entry(json: &Json) -> Option<ConfigEntry> {
|
||||
let default = parse_value(&json["default_value"])?;
|
||||
let num = |key: &str, d: f64| json[key].as_f64().unwrap_or(d);
|
||||
let kind = match &default {
|
||||
ConfigValue::Number(_) => ConfigKind::Number {
|
||||
min: num("min", 0.0),
|
||||
max: num("max", 1.0),
|
||||
step: num("step", 0.1),
|
||||
},
|
||||
ConfigValue::Text(_) => ConfigKind::Text {
|
||||
allowed: json["allowed_values"]
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|(k, v)| Some((k.clone(), ConfigValue::parse(v)?)))
|
||||
.collect()
|
||||
};
|
||||
let text = |key: &str| json[key].as_str().map(str::to_owned);
|
||||
Some(Self {
|
||||
kind,
|
||||
default,
|
||||
section: text("section"),
|
||||
section_order: json["section_order"].as_i64(),
|
||||
display_order: json["display_order"].as_i64().unwrap_or(0),
|
||||
performance_impact: text("performance_impact"),
|
||||
wide: json["wide"].as_bool() == Some(true),
|
||||
value_translation: text("value_translation"),
|
||||
presets: presets("presets"),
|
||||
section_presets: presets("section_presets"),
|
||||
})
|
||||
}
|
||||
|
||||
/// `value` kept within what the entry allows: a number clamped to its range and snapped
|
||||
/// to its step, a text one of its values; `None` for a value of another kind.
|
||||
pub fn accept(&self, value: &ConfigValue) -> Option<ConfigValue> {
|
||||
match (&self.kind, value) {
|
||||
(ConfigKind::Number { min, max, step }, ConfigValue::Number(n)) => {
|
||||
let (lo, hi) = (min.min(*max), max.max(*min));
|
||||
let mut v = n.clamp(lo, hi);
|
||||
if *step > 0.0 {
|
||||
v = (lo + ((v - lo) / step).round() * step).clamp(lo, hi);
|
||||
}
|
||||
Some(ConfigValue::Number(v))
|
||||
}
|
||||
(ConfigKind::Text { allowed }, ConfigValue::Text(t)) => {
|
||||
(allowed.is_empty() || allowed.contains(t)).then(|| value.clone())
|
||||
}
|
||||
(ConfigKind::Bool, ConfigValue::Bool(_)) => Some(value.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A pack's config entries and their values, by id (`namespace:name`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct Configs {
|
||||
values: BTreeMap<String, ConfigValue>,
|
||||
entries: BTreeMap<String, ConfigEntry>,
|
||||
}
|
||||
|
||||
impl Configs {
|
||||
/// A `config_entries/<name>.json` file: its value starts at its `default_value`.
|
||||
pub fn add_entry(&mut self, id: String, json: &Json) {
|
||||
let Some(entry) = ConfigEntry::parse(json) else {
|
||||
return;
|
||||
};
|
||||
self.values.insert(id.clone(), entry.default.clone());
|
||||
self.entries.insert(id, entry);
|
||||
}
|
||||
|
||||
/// Changes an entry's value (a player's choice), kept within what the entry allows. An id
|
||||
/// without an entry takes any value.
|
||||
pub fn set(&mut self, id: &str, value: ConfigValue) {
|
||||
let value = match self.entries.get(id) {
|
||||
Some(entry) => match entry.accept(&value) {
|
||||
Some(v) => v,
|
||||
None => return,
|
||||
},
|
||||
None => value,
|
||||
};
|
||||
self.values.insert(id.to_owned(), value);
|
||||
}
|
||||
|
||||
pub fn get(&self, id: &str) -> Option<&ConfigValue> {
|
||||
self.values.get(id)
|
||||
}
|
||||
|
||||
/// The entries, by id.
|
||||
pub fn entries(&self) -> &BTreeMap<String, ConfigEntry> {
|
||||
&self.entries
|
||||
}
|
||||
|
||||
/// The values that differ from their entries' defaults: what a player chose.
|
||||
pub fn chosen(&self) -> BTreeMap<String, ConfigValue> {
|
||||
self.values
|
||||
.iter()
|
||||
.filter(|(id, v)| self.entries.get(*id).is_none_or(|e| &e.default != *v))
|
||||
.map(|(id, v)| (id.clone(), v.clone()))
|
||||
.filter_map(|v| v.as_str().map(str::to_owned))
|
||||
.collect(),
|
||||
},
|
||||
ConfigValue::Bool(_) => ConfigKind::Bool,
|
||||
};
|
||||
let presets = |key: &str| -> BTreeMap<String, ConfigValue> {
|
||||
json[key]
|
||||
.as_object()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|(k, v)| Some((k.clone(), parse_value(v)?)))
|
||||
.collect()
|
||||
}
|
||||
};
|
||||
let text = |key: &str| json[key].as_str().map(str::to_owned);
|
||||
Some(ConfigEntry {
|
||||
kind,
|
||||
default,
|
||||
section: text("section"),
|
||||
section_order: json["section_order"].as_i64(),
|
||||
display_order: json["display_order"].as_i64().unwrap_or(0),
|
||||
performance_impact: text("performance_impact"),
|
||||
wide: json["wide"].as_bool() == Some(true),
|
||||
value_translation: text("value_translation"),
|
||||
presets: presets("presets"),
|
||||
section_presets: presets("section_presets"),
|
||||
})
|
||||
}
|
||||
|
||||
/// Every entry with a value in `preset` (a pack-wide preset) takes it.
|
||||
pub fn apply_preset(&mut self, preset: &str) {
|
||||
let picks: Vec<_> = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|(id, e)| Some((id.clone(), e.presets.get(preset)?.clone())))
|
||||
.collect();
|
||||
for (id, value) in picks {
|
||||
self.set(&id, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// Takes `other`'s entries and values (another pack's), over its own of the same id.
|
||||
pub fn merge(&mut self, other: &Configs) {
|
||||
self.entries
|
||||
.extend(other.entries.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
self.values
|
||||
.extend(other.values.iter().map(|(k, v)| (k.clone(), v.clone())));
|
||||
}
|
||||
|
||||
/// The pack-wide presets' names, in order.
|
||||
pub fn presets(&self) -> Vec<String> {
|
||||
let mut names: Vec<_> = self
|
||||
.entries
|
||||
.values()
|
||||
.flat_map(|e| e.presets.keys().cloned())
|
||||
.collect();
|
||||
names.sort();
|
||||
names.dedup();
|
||||
names
|
||||
/// Adds a `config_entries/<name>.json` file to `configs`: its value starts at its
|
||||
/// `default_value`. A file without one adds nothing.
|
||||
pub fn add_entry(configs: &mut Configs, id: String, json: &Json) {
|
||||
if let Some(entry) = parse_entry(json) {
|
||||
configs.insert(id, entry);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -285,7 +149,7 @@ impl Host for ConditionHost<'_> {
|
||||
let id = text(0).ok_or("config needs an id")?;
|
||||
self.configs
|
||||
.get(&id)
|
||||
.map(ConfigValue::to_value)
|
||||
.map(config_value)
|
||||
.ok_or_else(|| format!("no config {id}"))
|
||||
}
|
||||
"modOn" => {
|
||||
@@ -367,7 +231,7 @@ impl Condition {
|
||||
!ignore
|
||||
&& required
|
||||
.iter()
|
||||
.all(|id| configs.get(id).is_some_and(|c| c.to_value().truthy()))
|
||||
.all(|id| configs.get(id).is_some_and(|c| config_value(c).truthy()))
|
||||
&& mods
|
||||
.iter()
|
||||
.all(|(id, range)| ConditionHost::mod_on(id, range.as_deref()))
|
||||
@@ -449,12 +313,21 @@ mod tests {
|
||||
|
||||
fn configs() -> Configs {
|
||||
let mut c = Configs::default();
|
||||
c.add_entry("pack:leaves".into(), &json!({"default_value": "§aON"}));
|
||||
c.add_entry(
|
||||
add_entry(
|
||||
&mut c,
|
||||
"pack:leaves".into(),
|
||||
&json!({"default_value": "§aON"}),
|
||||
);
|
||||
add_entry(
|
||||
&mut c,
|
||||
"pack:fog".into(),
|
||||
&json!({"default_value": 60, "min": 0, "max": 100}),
|
||||
);
|
||||
c.add_entry("pack:glare".into(), &json!({"default_value": false}));
|
||||
add_entry(
|
||||
&mut c,
|
||||
"pack:glare".into(),
|
||||
&json!({"default_value": false}),
|
||||
);
|
||||
c
|
||||
}
|
||||
|
||||
@@ -518,7 +391,8 @@ mod tests {
|
||||
#[test]
|
||||
fn config_entries_keep_their_control_and_values_within_it() {
|
||||
let mut configs = Configs::default();
|
||||
configs.add_entry(
|
||||
add_entry(
|
||||
&mut configs,
|
||||
"p:fog".into(),
|
||||
&serde_json::json!({
|
||||
"default_value": 0.5, "min": 0, "max": 2, "step": 0.25,
|
||||
@@ -526,16 +400,22 @@ mod tests {
|
||||
"presets": {"performance": 0, "pretty": 2}
|
||||
}),
|
||||
);
|
||||
configs.add_entry(
|
||||
add_entry(
|
||||
&mut configs,
|
||||
"p:rain".into(),
|
||||
&serde_json::json!({"default_value": "drops", "allowed_values": ["drops", "streaks"]}),
|
||||
);
|
||||
configs.add_entry(
|
||||
add_entry(
|
||||
&mut configs,
|
||||
"p:birds".into(),
|
||||
&serde_json::json!({"default_value": true}),
|
||||
);
|
||||
// No default: no entry.
|
||||
configs.add_entry("p:broken".into(), &serde_json::json!({"min": 1}));
|
||||
add_entry(
|
||||
&mut configs,
|
||||
"p:broken".into(),
|
||||
&serde_json::json!({"min": 1}),
|
||||
);
|
||||
assert_eq!(configs.entries().len(), 3);
|
||||
let fog = &configs.entries()["p:fog"];
|
||||
assert_eq!(
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
"horizon-hooks": "engine",
|
||||
"horizon-items": "engine",
|
||||
"horizon-narrator": "engine",
|
||||
"horizon-pack-options": "engine",
|
||||
"horizon-prepare": "engine",
|
||||
"horizon-protocol": "engine",
|
||||
"horizon-server-files": "engine",
|
||||
|
||||
Reference in new issue
Block a user