Compare commits

...
Author SHA1 Message Date
Horizon agent ec9c65be87 Merge remote-tracking branch 'origin/main' into layers/packs-entity
# Conflicts:
#	crates/horizon-client/src/client_lib.rs
2026-11-09 23:38:56 +01:00
Ludovic Marie cdfbac97bd Merge pull request #14056 from horizon-rust/layers/packs-polytone-1
🤖 Agent: a pack's own options move to the engine crate horizon-pack-options with no dependencies; Polytone's config reading reaches it only through pack format hooks. layer plan M6 step 8.
2026-11-09 20:59:07 +01:00
Ludovic Marie d237f961f8 Merge pull request #14040 from horizon-rust/layers/packs-cem-mobs
🤖 Agent: custom entity models bake once per pack load behind the engine's model hooks; the mob hook lives in horizon-client's mob_formats glue. layer plan M6 step 7.
2026-11-09 20:55:04 +01:00
Horizon agent c8701964f4 Merge branch 'layers/packs-cem-mobs' into layers/packs-polytone-1
# Conflicts:
#	crates/horizon-client-render/src/pack_formats/mod.rs
#	crates/horizon-client-render/src/pack_hooks.rs
2026-11-09 18:57:34 +01:00
Horizon agent 6a8d226dde Merge branch 'layers/packs-entity' into layers/packs-cem-mobs 2026-11-09 18:57:06 +01:00
Horizon agent 2c8ae8f2ef Merge branch 'layers/packs-cit' into layers/packs-entity 2026-11-09 18:57:05 +01:00
Horizon agent 646a049d2d Merge branch 'layers/packs-ctm' into layers/packs-cit 2026-11-09 18:57:04 +01:00
Horizon agent 717fe15087 Merge branch 'layers/packs-emissive' into layers/packs-ctm
Co-Authored-By: Claude Opus 5.5 <[email protected]>

# Conflicts:
#	crates/horizon-client-render/src/pack_formats/mod.rs
2026-11-09 18:57:02 +01:00
Horizon agent c7ba820eac Merge branch 'layers/packs-cem-mobs' into layers/packs-polytone-1 2026-11-09 18:03:54 +01:00
Horizon agentandClaude Opus 5.5 2ebefd515c CEM: the moved animation tests import in_gui
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:03:19 +01:00
Horizon agent 8ca8cf121b Merge branch 'layers/packs-cem-mobs' into layers/packs-polytone-1 2026-11-09 17:38:13 +01:00
Horizon agent 9eff1ee00a Merge branch 'layers/packs-entity' into layers/packs-cem-mobs 2026-11-09 17:38:13 +01:00
Horizon agent 26d9efa842 Merge branch 'layers/packs-cit' into layers/packs-entity 2026-11-09 17:38:12 +01:00
Horizon agentandClaude Opus 5.5 48aae09c66 Drop an unused trait import
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:38:12 +01:00
Horizon agent f70fb7da5d Merge branch 'layers/packs-cem-mobs' into layers/packs-polytone-1 2026-11-09 17:35:02 +01:00
Horizon agent 99c9f7eaa5 Merge branch 'layers/packs-entity' into layers/packs-cem-mobs 2026-11-09 17:35:01 +01:00
Horizon agent 6ddd66576d Merge branch 'layers/packs-cit' into layers/packs-entity
# Conflicts:
#	crates/horizon-client/src/client_lib.rs
2026-11-09 17:35:00 +01:00
Horizon agent 89564ffce8 Merge branch 'layers/packs-emissive' into layers/packs-ctm 2026-11-09 17:34:47 +01:00
Horizon agent fb9b6d4299 Merge branch 'layers/packs-ctm' into layers/packs-cit 2026-11-09 17:34:47 +01:00
Horizon agentandClaude Opus 5.5 87d4324eb6 Keep the reshaped models type beside the hook
block_entity_render.rs is over 3000 lines and may not grow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:33:54 +01:00
Horizon agentandClaude Opus 5.5 16d2f19957 Expressions read pack options through config_value
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:25:03 +01:00
Horizon agentandClaude Opus 5.5 bb090e827e Pack options through an engine crate and pack format hooks
The options a resource pack declares (Polytone config entries) move to
horizon-pack-options, an engine crate with no dependencies that
horizon-polytone re-exports. Reading them from a pack, the overlay
conditions and the sounds.json filter go through new PackFormats hooks,
so packs.rs, options_ui.rs and pack_options_ui.rs no longer name
horizon_polytone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:19:24 +01:00
Horizon agentandClaude Opus 5.5 8426465800 Format the custom entity model split
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:18:12 +01:00
Horizon agent 427729f784 Merge branch 'layers/packs-entity' into layers/packs-cem-mobs 2026-11-09 17:18:05 +01:00
Horizon agentandClaude Opus 5.5 6a495df673 Format the entity texture split
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:18:04 +01:00
Horizon agent 9eb3dc457b Merge branch 'layers/packs-cit' into layers/packs-entity 2026-11-09 17:17:56 +01:00
Horizon agentandClaude Opus 5.5 975b29a641 Format the custom item texture split
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:17:55 +01:00
Horizon agentandClaude Opus 5.5 23f1b63e9c Mob custom entity models through an engine extension point
mobs/cem.rs (engine) keeps CemModels, CemLayer with a boxed LayerAnimation
(one call per animated layer per draw), the standing-block gate and its own
ModelFormats hook; mobs/cem_anim.rs keeps the per-frame facts (Frame,
set_frame, in_gui). The .jem reader, builder and expression animations move
to mob_formats/ (platform code staged in horizon-client-lib), installed by
entry::main.

Layer plan M6 step 7.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:08:23 +01:00
Horizon agentandClaude Opus 5.5 8aec64cc36 Block entity custom models through an engine extension point
block_entity_cem.rs moves to pack_formats/; the renderer asks
PackFormats::block_entity_models for a pack's reshaped models. Reshaped
moves to block_entity_render.

Layer plan M6 step 7.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:05:25 +01:00
Ludovic Marie 0aa44ff207 Merge branch 'layers/packs-cit' into layers/packs-entity 2026-11-09 16:46:40 +01:00
Ludovic Marie 8a00ab490a Merge branch 'layers/packs-ctm' into layers/packs-cit 2026-11-09 16:46:40 +01:00
Ludovic Marie d486e88b5b Merge branch 'layers/packs-emissive' into layers/packs-ctm 2026-11-09 16:46:39 +01:00
Horizon agentandClaude Opus 5.5 b6c527fb8c Entity texture variants, blinks and emissive overlays through an engine extension point
entity_textures.rs (engine) holds the tables drawing looks up (variants by
texture, blink frames, emissive overlays) and the facts a variant rule asks;
the rule files sit behind entity_textures::VariantRules, asked only when a mob
chooses its variants. pack_formats/entity_textures.rs fills them from
horizon_optifine's scans (PackFormats::entity_textures). The pack-loading
benches install the formats as the game does.

Layer plan M6 step 6.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:43:24 +01:00
Horizon agentandClaude Opus 5.5 88cbb484fd Custom item textures through an engine extension point
The engine's cit.rs now holds what it draws (Rule, Kind, Hand, Glint, Blend,
Factor) and a CitRules table with a boxed Matcher the pack formats fill once
per pack load (pack_hooks::PackFormats::item_textures). The OptiFine/CIT
Resewn reader, matcher and glint composition move to pack_formats/cit.rs and
pack_formats/cit_glint.rs; cit_glint keeps StackGlint and its cycle timing.

Layer plan M6 step 5.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:38:06 +01:00
Horizon agentandClaude Opus 5.5 d3e7582474 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:27:29 +01:00
Horizon agent 08a3a5e8ef Merge branch 'layers/packs-emissive' into layers/packs-ctm 2026-11-09 16:27:29 +01:00
Horizon agentandClaude Opus 5.5 32bd0123e2 Connected textures through the pack-format hook, meshed statically
layer plan M6 (pack formats), step 4: the mesher draws connected textures
through an engine trait (connected::ConnectedTextures) it is generic over:
mesh_reusing calls the pack format's mesh entry point once per section,
which runs Mesher::mesh_with on its own type, so faces call pick
statically (no dynamic dispatch per face). OptiFine's CTM and Fusion's
rules move to pack_formats/ unchanged; the engine keeps the mesher's
vocabulary (Pick, TileLayer, Overlay, Face).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:55:15 +01:00
57 changed files with 6571 additions and 5301 deletions

No files matched your search

+1
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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()
+17 -33
View File
@@ -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),
+147 -798
View File
@@ -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]
+15 -8
View File
@@ -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);
+19 -303
View File
@@ -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"))),
);
+17 -21
View File
@@ -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),
+1 -2
View File
@@ -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;
+46 -45
View File
@@ -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);
}
@@ -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)
}
}
@@ -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);
+21 -64
View File
@@ -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]")?;
+15 -13
View File
@@ -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;
+1
View File
@@ -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
+1 -4
View File
@@ -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) {
+4 -6
View File
@@ -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]
+6 -7
View File
@@ -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
+8 -7
View File
@@ -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
}
+11 -10
View File
@@ -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);
}
+5
View File
@@ -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)
}
+5
View File
@@ -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> {
+12
View File
@@ -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
+160
View File
@@ -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
}
}
+1
View File
@@ -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"
+84 -204
View File
@@ -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!(
+1
View File
@@ -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",