Compare commits

...
Author SHA1 Message Date
rdbt 1e7525e804 Merge pull request #14068 from horizon-rust/layers/packs-polytone-2
Environment modifiers and block colormaps through engine extension points (layer plan M6, step 9)
2026-11-11 22:20:11 +00:00
Horizon agent fe9b82eb39 Merge branch 'layers/packs-entity' into layers/packs-polytone-2
# Conflicts:
#	crates/horizon-client-render/src/environment.rs
2026-11-09 23:41:29 +01:00
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 agentandClaude Opus 5.5 8bdb4520ff Merge branch 'layers/packs-polytone-1' into layers/packs-polytone-2
Main's Polytone biome_colors and column-shared colormaps (#13806, #13809)
follow the colormaps into the glue: PackFormats::biome_colormaps gives the
mesher each replaced BiomeFunction's colormap, BlockColormap::column says a
colormap ignores the height, Colormap::flat moves with Colormap.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 19:05:23 +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 6debbf2f0e Merge branch 'layers/packs-polytone-1' into layers/packs-polytone-2 2026-11-09 18:03:55 +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 992b1fff77 Merge branch 'layers/packs-polytone-1' into layers/packs-polytone-2 2026-11-09 17:38:13 +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 4993e49715 Merge branch 'layers/packs-polytone-1' into layers/packs-polytone-2 2026-11-09 17:35:02 +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 25aef7173b Environment modifiers and block colormaps through engine extension points
The engine keeps Environment, Frame and the attribute stack; Polytone's
biome and dimension modifiers reach it through EnvironmentModifiers, and
its block and fluid colormaps through BlockColormap, both from new
PackFormats hooks called once per pack load. The expression hosts, the
resolver and the colormaps move to pack_formats/environment.rs.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:26:19 +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
61 changed files with 7616 additions and 6101 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()))
}
}
+95 -714
View File
@@ -2,9 +2,7 @@
//! dimension type's attributes, the biome layer, the dimension's timelines and, where it can
//! have weather, the rain and thunder layers, sampled into the sky state the renderer draws.
use crate::Result;
use horizon_world::environment_attributes::{
Attribute, AttributeMap, Entry, Kind, Value, attribute,
};
use horizon_world::environment_attributes::{AttributeMap, Value, attribute};
use horizon_world::environment_system::{BiomeBlend, Builder, Conditions, System};
use horizon_world::environment_timeline::Timeline;
use serde_json::Value as Json;
@@ -65,82 +63,101 @@ pub struct Sample<'a> {
pub post_biome: Option<&'a AttributeMap>,
}
/// Resource packs' Polytone biome and dimension modifiers (#11486): layers they add to, change
/// in or remove from the attribute stack.
/// What resource packs change in the attribute stacks (Polytone's biome and dimension
/// modifiers, #11486), read by the platform's pack formats once per pack load. Without
/// installed formats, or packs with no modifiers, it changes nothing.
#[derive(Default)]
pub struct PackModifiers {
biomes: Vec<horizon_polytone::BiomeModifier>,
dimensions: Vec<horizon_polytone::DimensionModifier>,
/// The packs' config values, for `config(id)` in expressions.
configs: horizon_polytone::pack::Configs,
/// The pack overlay root they were read from, where their colormaps are.
root: Option<std::path::PathBuf>,
colormaps: ColormapCache,
}
pub struct PackModifiers(Option<Box<dyn EnvironmentModifiers>>);
impl PackModifiers {
/// Every `assets/<namespace>/polytone/{biome,dimension}_modifiers/**.json` under a pack
/// overlay root. A file that fails to parse is skipped with a warning.
/// The modifiers of the packs merged at the overlay root `pack_root`.
pub fn read(pack_root: &Path) -> Self {
let mut out = Self {
root: Some(pack_root.to_path_buf()),
..Self::default()
};
let Ok(namespaces) = std::fs::read_dir(pack_root.join("assets")) else {
return out;
};
for namespace in namespaces.flatten() {
for kind in ["biome_modifiers", "dimension_modifiers"] {
let mut stack = vec![namespace.path().join("polytone").join(kind)];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let Ok(relative) = path.strip_prefix(pack_root) else {
continue;
};
let relative = relative.to_string_lossy();
let implicit = horizon_polytone::implicit_id(&relative, kind);
let json = match horizon_world::json_file::read::<Json>(&path) {
Ok(json) => json,
Err(e) => {
log::warn!("{relative}: {e}");
continue;
}
};
let parsed = if kind == "biome_modifiers" {
horizon_polytone::BiomeModifier::parse(&json, implicit.as_deref())
.map(|m| m.map(|m| out.biomes.push(m)))
} else {
horizon_polytone::DimensionModifier::parse(&json, implicit.as_deref())
.map(|m| m.map(|m| out.dimensions.push(m)))
};
if let Err(e) = parsed {
log::warn!("{relative}: {e}");
}
}
}
}
}
out.biomes.sort_by_key(|m| m.priority);
out.dimensions.sort_by_key(|m| m.priority);
out
Self(crate::pack_hooks::formats().environment_modifiers(pack_root))
}
pub fn is_empty(&self) -> bool {
self.biomes.is_empty() && self.dimensions.is_empty()
self.0.as_ref().is_none_or(|m| m.is_empty())
}
/// The config values expressions read.
pub fn with_configs(mut self, configs: horizon_polytone::pack::Configs) -> Self {
self.configs = configs;
/// The packs' option values, which the modifiers' expressions read.
pub fn with_configs(mut self, configs: horizon_pack_options::Configs) -> Self {
if let Some(m) = &mut self.0 {
m.set_options(configs);
}
self
}
}
/// A pack format's changes to the attribute stacks: entries set, replaced (maybe by a
/// [`horizon_world::environment_attributes::Dynamic`] reading [`Frame`]) or removed.
pub trait EnvironmentModifiers: Send + Sync {
fn is_empty(&self) -> bool;
/// The packs' option values.
fn set_options(&mut self, options: horizon_pack_options::Configs);
/// Changes the maps of dimension type `id` (`in_tag`: whether it is in a
/// `dimension_type` tag). Dynamic entries read `frame`.
fn dimension(
&self,
id: &str,
in_tag: &dyn Fn(&str) -> bool,
frame: &Arc<RwLock<Frame>>,
maps: DimensionMaps<'_>,
);
/// Changes biome `id`'s map (`in_tag`: whether it is in a `worldgen/biome` tag), and fills
/// its post-process map, a biome layer after the weather.
fn biome(
&self,
id: &str,
in_tag: &dyn Fn(&str) -> bool,
frame: &Arc<RwLock<Frame>>,
base: &mut AttributeMap,
post: &mut AttributeMap,
);
}
/// A dimension's maps the modifiers change: its own entries, the weather's, and a layer
/// after all others.
pub struct DimensionMaps<'a> {
pub base: &'a mut AttributeMap,
pub rain: &'a mut AttributeMap,
pub thunder: &'a mut AttributeMap,
pub post: &'a mut AttributeMap,
}
/// A pack's colormap over blocks (Polytone's block and fluid colormaps, wiki "Colormaps").
pub trait BlockColormap: Send + Sync {
/// Whether blocks take the mean of the colours around them, over the Biome Blend square.
fn biome_blend(&self) -> bool;
/// Whether its colour stays the same up a column of one biome (it reads no height): a
/// section of one biome samples it once per column.
fn column(&self) -> bool;
/// The colour at block `at`, in `biome`, ARGB; `None` where the pixel is transparent or
/// an axis fails (the block keeps its own tint).
fn sample(&self, at: [i32; 3], biome: &crate::biome::BiomeInfo) -> Option<u32>;
}
/// Vanilla's biome colour functions (`BiomeColors`), which the packs may replace.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BiomeFunction {
Grass,
Foliage,
DryFoliage,
Water,
}
/// A block or fluid modifier with a colormap: the blocks it targets (by name) and the
/// colormap's id.
pub struct ColormapTarget {
pub fluid: bool,
pub targets: Box<dyn Fn(&str) -> bool + Send + Sync>,
pub colormap: String,
}
/// What a pack's expression can ask about the frame (wiki "Scripting Expressions": the global,
/// player, camera and positional contexts), set before the frame samples the sky.
#[derive(Clone, Debug, Default)]
@@ -177,630 +194,6 @@ impl Frame {
}
}
/// The game side of a pack expression in an attribute value: `g`, `p`, `c`, `o` (the camera's
/// position) and `r`, `v` (the value so far) and the pack's `config`.
struct FrameHost<'a> {
frame: &'a Frame,
configs: &'a horizon_polytone::pack::Configs,
value: f64,
seed: u64,
}
/// `program`'s value for the frame (a pack's expression outside attribute layers: its `v` is 0).
pub(crate) fn eval_frame(
program: &horizon_polytone::expr::Program,
frame: &Frame,
configs: &horizon_polytone::pack::Configs,
) -> Option<horizon_polytone::expr::Value> {
let mut host = FrameHost {
frame,
configs,
value: 0.0,
seed: frame.game_time as u64,
};
program.eval(&mut host).ok()
}
impl horizon_polytone::expr::Host for FrameHost<'_> {
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
use horizon_polytone::expr::{Root, Value as V};
match root {
Root::Value => V::Num(self.value),
other => V::Object(other as u32),
}
}
fn method(
&mut self,
target: &horizon_polytone::expr::Value,
name: &str,
_args: &[horizon_polytone::expr::Value],
) -> std::result::Result<horizon_polytone::expr::Value, String> {
use horizon_polytone::expr::{Root, Value as V};
let f = self.frame;
let on = |root: Root| *target == V::Object(root as u32);
let num = |n: f64| Ok(V::Num(n));
match name {
"rain" if on(Root::Global) => num(f.global_rain()),
"time" if on(Root::Global) => num(f.game_time as f64),
"dayTime" if on(Root::Global) => num(f.day_time as f64),
"skyType" if on(Root::Global) => num(f64::from(f.sky_type)),
"dimensionType" if on(Root::Global) => Ok(V::Str(f.dimension.as_str().into())),
"renderDistance" if on(Root::Global) => num(f64::from(f.render_distance)),
"moonPhase" if on(Root::Global) => {
num(f.day_time.div_euclid(24_000).rem_euclid(8) as f64)
}
"seaLevel" if on(Root::Global) => num(63.0),
"viewDistance" if on(Root::Camera) => num(f64::from(f.render_distance * 16)),
"x" | "y" | "z" if on(Root::Player) || on(Root::Camera) || on(Root::Object) => {
let at = if on(Root::Player) { f.player } else { f.camera };
num(at[usize::from(name.as_bytes()[0] - b'x')])
}
"inWater" if on(Root::Player) => Ok(V::Bool(f.in_water)),
"skyLight" if on(Root::Object) || on(Root::Player) => num(f64::from(f.sky_light)),
"blockLight" if on(Root::Object) || on(Root::Player) => num(f64::from(f.block_light)),
"temperature" if on(Root::Object) => num(f64::from(f.temperature)),
"downfall" if on(Root::Object) => num(f64::from(f.downfall)),
"biome" if on(Root::Object) || on(Root::Player) || on(Root::Camera) => {
Ok(V::Str(f.biome.as_str().into()))
}
"rand" if on(Root::Random) => {
// A splitmix64 step: the frame's own sequence.
self.seed = self.seed.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = self.seed;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
num(((z ^ (z >> 31)) >> 11) as f64 / (1u64 << 53) as f64)
}
_ => Err(format!(
"{name} is not available in an attribute expression"
)),
}
}
fn function(
&mut self,
name: &str,
args: &[horizon_polytone::expr::Value],
) -> std::result::Result<horizon_polytone::expr::Value, String> {
match (name, args) {
("config", [horizon_polytone::expr::Value::Str(id)]) => self
.configs
.get(id)
.map(horizon_polytone::pack::ConfigValue::to_value)
.ok_or_else(|| format!("no config {id}")),
_ => Err(format!(
"{name} is not available in an attribute expression"
)),
}
}
}
/// A value as an expression's `v`: floats as they are, colours packed ARGB (`ARGB.as8BitChannel`).
fn as_number(value: &Value) -> f64 {
let byte = |c: f32| ((c.clamp(0.0, 1.0) * 255.0).floor() as u32) & 255;
match value {
Value::Float(f) => f64::from(*f),
Value::Bool(b) => f64::from(u8::from(*b)),
Value::Rgb([r, g, b]) => {
f64::from((0xff00_0000 | byte(*r) << 16 | byte(*g) << 8 | byte(*b)) as i32)
}
Value::Argb([r, g, b, a]) => {
f64::from((byte(*a) << 24 | byte(*r) << 16 | byte(*g) << 8 | byte(*b)) as i32)
}
Value::Other(_) => 0.0,
}
}
/// A Polytone colormap used as an attribute value (wiki "Colormaps"): an image read at the
/// column and row its two expressions pick, each 0 to 1 with 1 the top-left corner.
pub(crate) struct Colormap {
x: Axis,
y: Axis,
/// Only the lower-left triangle: the row value is scaled by the column value, as vanilla's
/// grass colormap scales downfall by temperature.
triangular: bool,
/// Rounds to the nearest pixel (the default) instead of the one below.
rounds: bool,
/// `biome_blend`: blocks take the mean of the colours around them, over the Biome Blend
/// square, as vanilla's biome tints do. Without the field, maps that sample the biome
/// (`temperature`, `downfall`, `biome_id`) blend: Polytone 6.8.6's water by `biome_id`
/// fades over a biome edge.
pub(crate) biome_blend: bool,
/// Its axes read nothing that changes up a column of one biome (no `o.y`): a section of
/// one biome can sample it once per column.
pub(crate) column: bool,
width: u32,
height: u32,
rgba: Vec<u8>,
}
/// A colormap axis: an expression (or a named sampler, wiki "Default Samplers"), or the
/// `biome_id` sampler with its mapper.
enum Axis {
Expr(horizon_polytone::expr::Program),
/// `biome_id`: the value the mapper gives the biome where it samples (0 for the others),
/// and the expression naming that biome. Unlike the other axes it counts from the left (or
/// top) edge, in pixel coordinates over the mapper's `texture_size`: the column (or row) is
/// `floor(value * size)`, so `1` over a `texture_size` of 16 is the second pixel, and the
/// biomes left out are the first, as the wiki's "start your mapper at 1" advice expects.
BiomeId(HashMap<String, f64>, horizon_polytone::expr::Program),
}
impl Axis {
/// Whether the axis reads only what stays the same up a column of one biome.
fn column(&self) -> bool {
use horizon_polytone::expr::Root;
let flat = |root: Root, name: &str| match root {
Root::Object => matches!(name, "x" | "z" | "biome" | "temperature" | "downfall"),
Root::Random => name == "noise",
_ => false,
};
match self {
Self::Expr(program) | Self::BiomeId(_, program) => program.reads_only(&flat),
}
}
fn value(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<f64> {
match self {
Self::Expr(program) => Some(program.eval(host).ok()?.num()),
Self::BiomeId(mapper, biome) => {
let biome = match biome.eval(host).ok()? {
horizon_polytone::expr::Value::Str(s) => s,
_ => return Some(0.0),
};
Some(mapper.get(biome.as_ref()).copied().unwrap_or(0.0))
}
}
}
}
/// A `biome_id_mapper`: inline, or an id of `polytone/biome_id_mappers/<path>.json`. Its values
/// are divided by its `texture_size`, when it has one (pixel coordinates).
fn biome_id_mapper(pack_root: &Path, json: &Json) -> HashMap<String, f64> {
let object = match json {
Json::String(id) => {
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
let file = pack_root.join(format!(
"assets/{namespace}/polytone/biome_id_mappers/{path}.json"
));
horizon_world::json_file::read::<Json>(&file).unwrap_or(Json::Null)
}
other => other.clone(),
};
let Some(object) = object.as_object() else {
return HashMap::new();
};
let size = object
.get("texture_size")
.and_then(|v| v.as_f64().or_else(|| v.as_str()?.parse().ok()))
.filter(|s| *s > 0.0)
.unwrap_or(1.0);
object
.iter()
.filter(|(k, _)| *k != "texture_size")
.filter_map(|(k, v)| {
let id = if k.contains(':') {
k.clone()
} else {
format!("minecraft:{k}")
};
Some((id, v.as_f64()? / size))
})
.collect()
}
impl Colormap {
/// One colour wherever it samples (a `biome_colors` flat colour, RGB).
pub(crate) fn flat(rgb: u32) -> Self {
let zero = || Axis::Expr(horizon_polytone::expr::Program::compile("0").expect("a number"));
let [_, r, g, b] = rgb.to_be_bytes();
Self {
x: zero(),
y: zero(),
triangular: false,
rounds: true,
biome_blend: false,
column: true,
width: 1,
height: 1,
rgba: vec![r, g, b, 255],
}
}
/// `assets/<namespace>/polytone/colormaps/<path>.png` and its optional `.json` (without
/// one, the axes are the biome's temperature and downfall).
pub(crate) fn load(pack_root: &Path, id: &str) -> Option<Self> {
let (namespace, path) = id.split_once(':')?;
let base = pack_root.join(format!("assets/{namespace}/polytone/colormaps/{path}"));
let png = base.with_extension("png");
let (width, height, rgba) = match crate::sprites::read_rgba(&png) {
Ok(image) => image,
Err(e) => {
log::warn!("colormap {id}: {e}");
return None;
}
};
let json: Json =
horizon_world::json_file::read(&base.with_extension("json")).unwrap_or(Json::Null);
let compile = horizon_polytone::expr::Program::compile;
let mut by_biome = false;
let mut axis = |key: &str, default: &str| -> Option<Axis> {
let src = match &json[key] {
Json::String(s) => s.clone(),
Json::Number(n) => n.to_string(),
_ => default.to_owned(),
};
by_biome |= matches!(
src.trim(),
"biome_id" | "temperature" | "legacy_temperature" | "downfall"
) || matches!(src.trim(), "o.temperature" | "o.downfall");
// The named samplers (wiki "Default Samplers") as the expressions they stand for.
let src = match src.trim() {
"biome_id" => {
let mapper = biome_id_mapper(pack_root, &json["biome_id_mapper"]);
return Some(Axis::BiomeId(mapper, compile("o.biome()").ok()?));
}
"temperature" | "legacy_temperature" => "o.temperature".to_owned(),
"downfall" => "o.downfall".to_owned(),
"y_level" => "o.y / 128".to_owned(),
"day_time" => "1 - (g.dayTime % 24000) / 24000".to_owned(),
_ => src,
};
match compile(&src).map(Axis::Expr) {
Ok(program) => Some(program),
Err(e) => {
log::warn!("colormap {id} {key}: {e}");
None
}
}
};
let (x, y) = (
axis("x_axis", "o.temperature")?,
axis("y_axis", "o.downfall")?,
);
let column = [&x, &y].iter().all(|axis| axis.column());
Some(Self {
x,
y,
column,
biome_blend: json["biome_blend"].as_bool().unwrap_or(by_biome),
triangular: json["triangular"].as_bool() == Some(true),
rounds: json["rounds"].as_bool() != Some(false),
width,
height,
rgba,
})
}
/// The colour the axes pick, ARGB; `None` for a transparent pixel (the value stays what
/// it was, the default `fallback_colormap: original`) or an axis that fails.
pub(crate) fn sample(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<u32> {
let x = self.x.value(host)?.clamp(0.0, 1.0);
let mut y = self.y.value(host)?.clamp(0.0, 1.0);
if self.triangular {
y *= x;
}
let pixel = |axis: &Axis, v: f64, size: u32| {
let last = size.saturating_sub(1);
if let Axis::BiomeId(..) = axis {
return ((v * f64::from(size)).floor() as u32).min(last);
}
let p = (1.0 - v) * f64::from(last);
let p = if self.rounds { p.round() } else { p.floor() };
(p as u32).min(last)
};
let (px, py) = (
pixel(&self.x, x, self.width),
pixel(&self.y, y, self.height),
);
let i = (py as usize * self.width as usize + px as usize) * 4;
let [r, g, b, a] = self.rgba.get(i..i + 4)?.try_into().ok()?;
(a != 0).then(|| u32::from(a) << 24 | u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b))
}
}
/// The colormaps of the Polytone block and fluid modifiers of the packs merged at `pack_root`:
/// (a fluid modifier, its targets, the colormap's id). Inline colormaps aren't read.
pub(crate) fn polytone_colormap_modifiers(
pack_root: &Path,
) -> Vec<(bool, horizon_polytone::Targets, String)> {
let mut out = Vec::new();
let namespaces = std::fs::read_dir(pack_root.join("assets"))
.into_iter()
.flatten()
.flatten();
for namespace in namespaces {
for (kind, fluid) in [("block_modifiers", false), ("fluid_modifiers", true)] {
let mut stack = vec![namespace.path().join("polytone").join(kind)];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let Ok(json) = horizon_world::json_file::read::<Json>(&path) else {
continue;
};
let Some(id) = json.get("colormap").and_then(Json::as_str) else {
continue;
};
let id = if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
};
let relative = path
.strip_prefix(pack_root)
.unwrap_or(&path)
.to_string_lossy();
let implicit = horizon_polytone::implicit_id(&relative, kind);
match horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref())
{
Ok(targets) => out.push((fluid, targets, id)),
Err(e) => log::warn!("{relative}: {e}"),
}
}
}
}
}
out
}
/// What a block colormap's axes read where it samples: the block (`o.x`, `o.biome()`, its
/// biome's climate) and `r.noise`. The world isn't reachable from the mesher, so the rest fails
/// and the block keeps its own tint.
pub(crate) struct BlockHost<'a> {
at: [i32; 3],
biome: &'a crate::biome::BiomeInfo,
}
impl<'a> BlockHost<'a> {
pub(crate) fn new(at: [i32; 3], biome: &'a crate::biome::BiomeInfo) -> Self {
Self { at, biome }
}
}
/// The simplex noise `r.noise(x, y)` reads, the same for blocks and particles.
pub(crate) fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
static NOISE: std::sync::OnceLock<horizon_core::noise::SimplexNoise> =
std::sync::OnceLock::new();
NOISE.get_or_init(|| {
horizon_core::noise::SimplexNoise::new(&mut horizon_core::random::LegacyRandom::new(0))
})
}
impl horizon_polytone::expr::Host for BlockHost<'_> {
fn field(
&mut self,
root: horizon_polytone::expr::Root,
field: horizon_polytone::expr::Field,
) -> Option<horizon_polytone::expr::Value> {
use horizon_polytone::expr::Field as F;
if root != horizon_polytone::expr::Root::Object {
return None;
}
Some(horizon_polytone::expr::Value::Num(match field {
F::X => f64::from(self.at[0]),
F::Y => f64::from(self.at[1]),
F::Z => f64::from(self.at[2]),
F::Temperature => f64::from(self.biome.temperature),
F::Downfall => f64::from(self.biome.downfall),
_ => return None,
}))
}
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
use horizon_polytone::expr::Root;
horizon_polytone::expr::Value::Object(match root {
Root::Object => 1,
Root::Random => 2,
_ => 0,
})
}
fn method(
&mut self,
target: &horizon_polytone::expr::Value,
name: &str,
args: &[horizon_polytone::expr::Value],
) -> std::result::Result<horizon_polytone::expr::Value, String> {
use horizon_polytone::expr::Value as V;
let num = |n: f64| Ok(V::Num(n));
match (target, name) {
(V::Object(1), "x" | "y" | "z") => {
num(f64::from(self.at[usize::from(name.as_bytes()[0] - b'x')]))
}
(V::Object(1), "biome") => Ok(V::Str(self.biome.name.as_str().into())),
(V::Object(1), "temperature") => num(f64::from(self.biome.temperature)),
(V::Object(1), "downfall") => num(f64::from(self.biome.downfall)),
(V::Object(2), "noise") => {
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
num(f64::from(polytone_noise().get2(x, y)))
}
_ => Err(format!("{name} is not available to block colormaps")),
}
}
fn function(
&mut self,
name: &str,
_: &[horizon_polytone::expr::Value],
) -> std::result::Result<horizon_polytone::expr::Value, String> {
Err(format!("{name} is not available to block colormaps"))
}
}
/// Turns Polytone layers into vanilla entries, compiling expressions against the frame.
struct Resolver {
frame: Arc<RwLock<Frame>>,
configs: Arc<horizon_polytone::pack::Configs>,
/// Where the packs' colormaps are, and the ones loaded so far (`None`: missing).
pack_root: Option<std::path::PathBuf>,
colormaps: ColormapCache,
}
/// Colormaps by id, loaded once for all three dimensions' stacks.
type ColormapCache = Arc<std::sync::Mutex<HashMap<String, Option<Arc<Colormap>>>>>;
impl Resolver {
/// A Polytone layer as a vanilla entry: `Some(None)` removes the attribute's entry, `None`
/// is a value not supported yet (colormaps) or a broken one.
fn entry(
&self,
attribute: &'static Attribute,
layer: &horizon_polytone::Layer,
) -> Option<Option<Entry>> {
use horizon_polytone::{Argument, Layer, Modifier};
let (modifier, argument) = match layer {
Layer::Remove => return Some(None),
Layer::Apply {
modifier, argument, ..
} => (modifier, argument),
};
let op = match modifier {
Modifier::Override => "override",
Modifier::Add => "add",
Modifier::Subtract => "subtract",
Modifier::Multiply => "multiply",
Modifier::Minimum => "minimum",
Modifier::Maximum => "maximum",
Modifier::AlphaBlend => "alpha_blend",
Modifier::BlendToGray => "blend_to_gray",
Modifier::And => "and",
Modifier::Nand => "nand",
Modifier::Or => "or",
Modifier::Nor => "nor",
Modifier::Xor => "xor",
Modifier::Xnor => "xnor",
};
let json = match argument {
Argument::Number(n) => Json::from(*n),
Argument::Bool(b) => Json::Bool(*b),
// Polytone colours may leave out the `#`; vanilla's codec wants it, 6 digits for RGB.
Argument::Color(c) if attribute.kind == Kind::RgbColor => {
Json::String(format!("#{:06x}", c & 0x00ff_ffff))
}
Argument::Color(c) => Json::String(format!("#{c:08x}")),
Argument::Object(o) => Json::Object(o.clone()),
Argument::Expression(src) => return self.expression(attribute, op, src).map(Some),
Argument::Colormap(id) => return self.colormap(attribute, op, id).map(Some),
};
match Entry::with_modifier(attribute, op, &json) {
Ok(entry) => Some(Some(entry)),
Err(e) => {
log::warn!("{}: {e}", attribute.id);
None
}
}
}
/// An expression value: compiled once, evaluated against the frame each time the layer
/// applies, its result read as the modifier's argument (a number, a boolean, or a packed
/// colour for colour attributes).
fn expression(
&self,
attribute: &'static Attribute,
op: &'static str,
src: &str,
) -> Option<Entry> {
let program = match horizon_polytone::expr::Program::compile(src) {
Ok(program) => program,
Err(e) => {
log::warn!("{}: {e}", attribute.id);
return None;
}
};
let (frame, configs) = (self.frame.clone(), self.configs.clone());
let f = move |value: &Value| -> Option<Entry> {
let frame = frame.read().ok()?;
let mut host = FrameHost {
frame: &frame,
configs: &configs,
value: as_number(value),
seed: 0,
};
let json = match program.eval(&mut host).ok()? {
horizon_polytone::expr::Value::Bool(b) => Json::Bool(b),
result => Json::from(result.num()),
};
// A number out of the attribute's range is clamped into it, as the sampled value is
// (`AttributeRange.sanitize`), not dropped: a pack's fog end that goes negative deep
// underground is the thickest fog, not the default.
Entry::with_modifier(attribute, op, &json).ok().or_else(|| {
let n = json.as_f64()? as f32;
let Value::Float(clamped) = attribute.sanitize(Value::Float(n)) else {
return None;
};
Entry::with_modifier(attribute, op, &Json::from(clamped)).ok()
})
};
Some(Entry::Dynamic(
horizon_world::environment_attributes::Dynamic(Arc::new(f)),
))
}
/// A colormap value: sampled whenever its layer applies, its colour the modifier's argument
/// (`#rrggbb` on RGB attributes, `#aarrggbb` on ARGB ones).
fn colormap(&self, attribute: &'static Attribute, op: &'static str, id: &str) -> Option<Entry> {
let root = self.pack_root.as_deref()?;
let colormap = self
.colormaps
.lock()
.ok()?
.entry(id.to_owned())
.or_insert_with(|| Colormap::load(root, id).map(Arc::new))
.clone()?;
let (frame, configs) = (self.frame.clone(), self.configs.clone());
let rgb = attribute.kind == Kind::RgbColor;
let f = move |value: &Value| -> Option<Entry> {
let frame = frame.read().ok()?;
let mut host = FrameHost {
frame: &frame,
configs: &configs,
value: as_number(value),
seed: 0,
};
let color = colormap.sample(&mut host)?;
let text = if rgb {
format!("#{:06x}", color & 0x00ff_ffff)
} else {
format!("#{color:08x}")
};
Entry::with_modifier(attribute, op, &Json::String(text)).ok()
};
Some(Entry::Dynamic(
horizon_world::environment_attributes::Dynamic(Arc::new(f)),
))
}
/// Applies a group of Polytone layers to `map`: entries set or replaced, `REMOVE` drops
/// them.
fn apply(&self, map: &mut AttributeMap, group: &horizon_polytone::Attributes) {
for (id, layer) in group {
let Some(attribute) = attribute(id) else {
log::warn!("unknown attribute {id}");
continue;
};
match self.entry(attribute, layer) {
// A computed value that can't decide (a transparent colormap pixel, a failing
// expression) falls back to the entry it replaces: the value before the pack.
Some(Some(Entry::Dynamic(f))) => match map.get(attribute).cloned() {
Some(original) => {
let fallback = move |v: &Value| (f.0)(v).or_else(|| Some(original.clone()));
let entry =
horizon_world::environment_attributes::Dynamic(Arc::new(fallback));
map.set(attribute, Entry::Dynamic(entry));
}
None => map.set(attribute, Entry::Dynamic(f)),
},
Some(Some(entry)) => map.set(attribute, entry),
Some(None) => map.remove(attribute),
None => {}
}
}
}
}
/// The timeline ids a dimension type's `timelines` names: an id, a `#tag` or a list,
/// tags resolved through `tags/timeline/` (their entries may be `{"id", "required"}`).
fn timeline_ids(root: &Path, json: &Json, out: &mut Vec<String>, depth: u32) {
@@ -855,29 +248,19 @@ impl Environment {
)?;
let id = format!("minecraft:{dimension}");
let tags = crate::pack_tags::Tags::new(root);
let mine: Vec<_> = packs
.dimensions
.iter()
.filter(|m| {
m.targets
.matches(&id, |tag| tags.contains("dimension_type", tag, &id))
})
.collect();
let frame = Arc::new(RwLock::new(Frame::default()));
let resolver = Resolver {
frame: frame.clone(),
configs: Arc::new(packs.configs.clone()),
pack_root: packs.root.clone(),
colormaps: packs.colormaps.clone(),
};
let mut dimension_map = AttributeMap::parse(&json["attributes"])?;
let (mut rain, mut thunder) = horizon_world::environment_system::weather_maps();
let mut dimension_post = AttributeMap::default();
for m in &mine {
resolver.apply(&mut dimension_map, &m.base);
resolver.apply(&mut rain, &m.rain);
resolver.apply(&mut thunder, &m.thunder);
resolver.apply(&mut dimension_post, &m.post_process);
if let Some(m) = &packs.0 {
let in_tag = |tag: &str| tags.contains("dimension_type", tag, &id);
let maps = DimensionMaps {
base: &mut dimension_map,
rain: &mut rain,
thunder: &mut thunder,
post: &mut dimension_post,
};
m.dimension(&id, &in_tag, &frame, maps);
}
let mut builder = Builder::default();
builder.constant_layer(&dimension_map);
@@ -898,12 +281,9 @@ impl Environment {
}
};
let mut post_map = AttributeMap::default();
for m in &packs.biomes {
if let Some(m) = &packs.0 {
let in_tag = |tag: &str| tags.contains("worldgen/biome", tag, &name);
if m.targets.matches(&name, in_tag) {
resolver.apply(&mut map, &m.base);
resolver.apply(&mut post_map, &m.post_process);
}
m.biome(&name, &in_tag, &frame, &mut map, &mut post_map);
}
if !post_map.is_empty() {
post.insert(name.clone(), post_map);
@@ -1103,6 +483,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"))),
);
@@ -134,7 +134,9 @@ impl super::Renderer {
if let Some(frame) = frame {
for (source, program) in &modes.programs {
let configs = Default::default();
if let Some(value) = crate::environment::eval_frame(program, frame, &configs) {
if let Some(value) =
crate::pack_formats::environment::eval_frame(program, frame, &configs)
{
modes.values.insert(source.clone(), value.num());
}
}
+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;
+92 -111
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},
@@ -758,51 +757,23 @@ pub struct MeshTables {
lava_flow: u16,
animated_layers: Vec<bool>,
/// Resource packs' Polytone block and fluid colormaps (#11486), and the water's.
colormaps: Vec<Arc<crate::environment::Colormap>>,
colormaps: Vec<Arc<dyn crate::environment::BlockColormap>>,
water_colormap: Option<u16>,
}
/// Vanilla's four biome colour functions (`BiomeColors`) with what the packs replace them by:
/// `biome_colors` (a colormap or a flat colour), else the colormap a block modifier gives
/// `grass_block`, `oak_leaves` or `leaf_litter`, or a fluid modifier gives water, which Polytone
/// spreads to every block of that function. Each with the tints that read it.
fn biome_functions(
pack_root: &std::path::Path,
modifiers: &[(bool, horizon_polytone::Targets, String)],
) -> Vec<(&'static [Tint], crate::environment::Colormap)> {
use crate::environment::Colormap;
use horizon_polytone::colors::BiomeColor;
const FUNCTIONS: [(&str, &str, &[Tint]); 4] = [
(
"grass",
"minecraft:grass_block",
&[
Tint::Grass,
Tint::GrassBlock,
Tint::TallGrassUpper,
Tint::SugarCane,
],
),
("foliage", "minecraft:oak_leaves", &[Tint::Foliage]),
("dry_foliage", "minecraft:leaf_litter", &[Tint::DryFoliage]),
("water", "minecraft:water", &[Tint::Water]),
];
let colors = horizon_polytone::colors::PackColors::read(pack_root);
FUNCTIONS
.into_iter()
.filter_map(|(function, block, tints)| {
let colormap = match colors.biome_color(function) {
Some(BiomeColor::Flat(rgb)) => Some(Colormap::flat(rgb)),
Some(BiomeColor::Colormap(id)) => Colormap::load(pack_root, &id),
None => modifiers
.iter()
.find(|(fluid, targets, _)| {
*fluid == (function == "water") && targets.matches(block, |_| false)
})
.and_then(|(_, _, id)| Colormap::load(pack_root, id)),
}?;
Some((tints, colormap))
})
.collect()
/// The tints that read each of vanilla's biome colour functions (`BiomeColors`).
fn biome_function_tints(function: crate::environment::BiomeFunction) -> &'static [Tint] {
use crate::environment::BiomeFunction;
match function {
BiomeFunction::Grass => &[
Tint::Grass,
Tint::GrassBlock,
Tint::TallGrassUpper,
Tint::SugarCane,
],
BiomeFunction::Foliage => &[Tint::Foliage],
BiomeFunction::DryFoliage => &[Tint::DryFoliage],
BiomeFunction::Water => &[Tint::Water],
}
}
fn stem_color(age: u8) -> u32 {
let a = u32::from(age);
@@ -996,23 +967,25 @@ impl MeshTables {
pack_root: &std::path::Path,
registry: &horizon_world::registry::BlockRegistry,
) -> Self {
let modifiers = crate::environment::polytone_colormap_modifiers(pack_root);
let biome_functions = biome_functions(pack_root, &modifiers);
let formats = crate::pack_hooks::formats();
let modifiers = formats.colormap_targets(pack_root);
let biome_functions = formats.biome_colormaps(pack_root, &modifiers);
if modifiers.is_empty() && biome_functions.is_empty() {
return self;
}
let mut loaded: std::collections::HashMap<String, Option<u16>> = Default::default();
let mut index = |id: &str, colormaps: &mut Vec<Arc<crate::environment::Colormap>>| {
*loaded.entry(id.to_owned()).or_insert_with(|| {
let colormap = crate::environment::Colormap::load(pack_root, id)?;
colormaps.push(Arc::new(colormap));
Some((colormaps.len() - 1) as u16)
})
};
for (tints, colormap) in biome_functions {
self.colormaps.push(Arc::new(colormap));
let mut index =
|id: &str, colormaps: &mut Vec<Arc<dyn crate::environment::BlockColormap>>| {
*loaded.entry(id.to_owned()).or_insert_with(|| {
colormaps.push(formats.block_colormap(pack_root, id)?);
Some((colormaps.len() - 1) as u16)
})
};
for (function, colormap) in biome_functions {
let tints = biome_function_tints(function);
self.colormaps.push(colormap);
let i = Some((self.colormaps.len() - 1) as u16);
if tints == [Tint::Water] {
if function == crate::environment::BiomeFunction::Water {
self.water_colormap = i;
}
for info in &mut self.info {
@@ -1023,12 +996,13 @@ impl MeshTables {
}
}
let water_function = self.water_colormap;
for (fluid, targets, id) in &modifiers {
if *fluid && water_function.is_some() {
for modifier in &modifiers {
let (targets, id) = (&modifier.targets, &modifier.colormap);
if modifier.fluid && water_function.is_some() {
continue;
}
if *fluid {
if targets.matches("minecraft:water", |_| false) {
if modifier.fluid {
if targets("minecraft:water") {
self.water_colormap = index(id, &mut self.colormaps);
}
continue;
@@ -1040,7 +1014,7 @@ impl MeshTables {
let Some(block) = registry.state(BlockStateId(state as u32)) else {
continue;
};
if info.tint_count > 0 && targets.matches(&block.name, |_| false) {
if info.tint_count > 0 && targets(&block.name) {
info.colormap = Some(i);
}
}
@@ -1555,7 +1529,7 @@ struct Tints<'a> {
uniform: Option<Option<u16>>,
/// The packs' colormaps (`MeshTables::colormaps`), the water's, and their colours so far
/// by colormap and section-local block.
colormaps: &'a [Arc<crate::environment::Colormap>],
colormaps: &'a [Arc<dyn crate::environment::BlockColormap>],
water_colormap: Option<u16>,
sampled: crate::fast_hash::FxHashMap<(u16, [i32; 3]), Option<u32>>,
/// Each colormap's own colour by block, before the blend averages them.
@@ -1595,7 +1569,7 @@ impl<'a> Tints<'a> {
fn colormap(&mut self, index: u16, x: i32, y: i32, z: i32) -> Option<u32> {
let colormap = self.colormaps.get(usize::from(index))?.clone();
// A colormap that ignores the height, over one biome: one colour per column.
let y = if colormap.column && self.uniform_biome().is_some() {
let y = if colormap.column() && self.uniform_biome().is_some() {
0
} else {
y
@@ -1605,7 +1579,11 @@ impl<'a> Tints<'a> {
}
// ClientLevel.calculateBlockTint: the mean of the (2r+1)^2 colours around the block, each
// channel summed and divided. A transparent pixel in the square leaves the block's own.
let r = if colormap.biome_blend { self.radius } else { 0 };
let r = if colormap.biome_blend() {
self.radius
} else {
0
};
let mut sum = [0u32; 3];
let mut blended = true;
'square: for dz in -r..=r {
@@ -1634,7 +1612,7 @@ impl<'a> Tints<'a> {
/// neighbours), before any blend.
fn colormap_cell(
&mut self,
colormap: &crate::environment::Colormap,
colormap: &Arc<dyn crate::environment::BlockColormap>,
index: u16,
at: [i32; 3],
) -> Option<u32> {
@@ -1648,8 +1626,9 @@ impl<'a> Tints<'a> {
};
let [cx, cz, base_y] = self.origin;
let block = [cx * 16 + at[0], base_y + at[1], cz * 16 + at[2]];
let mut host = crate::environment::BlockHost::new(block, self.biomes.get(id));
let colour = colormap.sample(&mut host).map(|c| c & 0xFF_FFFF);
let colour = colormap
.sample(block, self.biomes.get(id))
.map(|c| c & 0xFF_FFFF);
self.cells.insert((index, at), colour);
colour
}
@@ -1872,11 +1851,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 +2016,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 +2046,7 @@ impl Mesher<'_> {
}
}
self.face(
connected,
pad,
&mut tints,
&mut out,
@@ -2102,8 +2097,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 +2159,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 {
@@ -165,9 +165,9 @@ fn a_polytone_colormap_tints_by_biome_and_keeps_the_biome_colour_where_transpare
.to_vec(),
),
]);
let colormaps = vec![Arc::new(
crate::environment::Colormap::load(&root, "mypack:water").unwrap(),
)];
let colormap = crate::pack_formats::environment::Colormap::load(&root, "mypack:water");
let colormaps: Vec<Arc<dyn crate::environment::BlockColormap>> =
vec![Arc::new(colormap.unwrap())];
for (biome, expected) in [(1, 0xFF_0000), (0, 0x3F_76E4)] {
let chunks: Vec<Vec<Chunk>> = (0..3)
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, biome)).collect())
@@ -213,9 +213,9 @@ fn a_biome_colormap_blends_over_a_biome_edge_as_the_vanilla_water_tint_does() {
json.into_bytes(),
),
]);
let map = crate::environment::Colormap::load(&root, "mypack:water").unwrap();
let map = crate::pack_formats::environment::Colormap::load(&root, "mypack:water").unwrap();
std::fs::remove_dir_all(root).unwrap();
vec![Arc::new(map)]
vec![Arc::new(map) as Arc<dyn crate::environment::BlockColormap>]
};
let (desert, plains) = (biomes.id("minecraft:desert"), biomes.id("minecraft:plains"));
// The west neighbours are desert, the section and the rest plains: an edge at x = 0.
@@ -274,12 +274,12 @@ fn biome_colors_and_a_grass_block_colormap_replace_their_whole_biome_function()
br#"{"targets": ["minecraft:oak_leaves"], "colormap": "mypack:leaves"}"#.to_vec(),
),
]);
let modifiers = crate::environment::polytone_colormap_modifiers(&root);
let functions = biome_functions(&root, &modifiers);
let tints: Vec<_> = functions.iter().map(|(t, _)| *t).collect();
assert_eq!(tints.len(), 2);
assert!(tints[0] == [Tint::Foliage] && tints[1] == [Tint::Water]);
let colormaps: Vec<_> = functions.into_iter().map(|(_, c)| Arc::new(c)).collect();
let modifiers = crate::pack_formats::environment::colormap_targets(&root);
let functions = crate::pack_formats::environment::biome_colormaps(&root, &modifiers);
let names: Vec<_> = functions.iter().map(|(f, _)| *f).collect();
use crate::environment::BiomeFunction;
assert_eq!(names, [BiomeFunction::Foliage, BiomeFunction::Water]);
let colormaps: Vec<_> = functions.into_iter().map(|(_, c)| c).collect();
let chunks: Vec<Vec<Chunk>> = (0..3)
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, 0)).collect())
.collect();
@@ -312,7 +312,7 @@ fn only_a_colormap_that_ignores_the_height_is_shared_up_a_column() {
br#"{"y_axis": "y_level"}"#.to_vec(),
),
]);
let load = |id| crate::environment::Colormap::load(&root, id).unwrap();
let load = |id| crate::pack_formats::environment::Colormap::load(&root, id).unwrap();
assert!(load("mypack:flat").column);
assert!(!load("mypack:tall").column);
std::fs::remove_dir_all(root).unwrap();
@@ -339,8 +339,8 @@ fn colormap_tint_cost() {
.to_vec(),
),
]);
let colormaps = vec![Arc::new(
crate::environment::Colormap::load(&root, "mypack:grass").unwrap(),
let colormaps: Vec<Arc<dyn crate::environment::BlockColormap>> = vec![Arc::new(
crate::pack_formats::environment::Colormap::load(&root, "mypack:grass").unwrap(),
)];
let chunks: Vec<Vec<Chunk>> = (0..3)
.map(|rz| (0..3).map(|rx| filled(rx - 1, rz - 1, 0)).collect())
@@ -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)
}
}
@@ -0,0 +1,811 @@
//! Polytone's biome and dimension modifiers (wiki "Environment Attributes"), its colormaps
//! (wiki "Colormaps") and the expressions both run against the frame or a block, on the
//! engine's environment extension points ([`crate::environment::EnvironmentModifiers`],
//! [`crate::environment::BlockColormap`]).
use crate::environment::{
BiomeFunction, BlockColormap, ColormapTarget, DimensionMaps, EnvironmentModifiers, Frame,
};
use horizon_pack_options::Configs;
use horizon_world::environment_attributes::{
Attribute, AttributeMap, Entry, Kind, Value, attribute,
};
use serde_json::Value as Json;
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, RwLock};
/// Resource packs' Polytone biome and dimension modifiers (#11486): layers they add to, change
/// in or remove from the attribute stack.
#[derive(Default)]
pub struct Modifiers {
biomes: Vec<horizon_polytone::BiomeModifier>,
dimensions: Vec<horizon_polytone::DimensionModifier>,
/// The packs' config values, for `config(id)` in expressions.
configs: Arc<Configs>,
/// The pack overlay root they were read from, where their colormaps are.
root: Option<std::path::PathBuf>,
colormaps: ColormapCache,
}
impl Modifiers {
/// Every `assets/<namespace>/polytone/{biome,dimension}_modifiers/**.json` under a pack
/// overlay root. A file that fails to parse is skipped with a warning.
pub fn read(pack_root: &Path) -> Self {
let mut out = Self {
root: Some(pack_root.to_path_buf()),
..Self::default()
};
let Ok(namespaces) = std::fs::read_dir(pack_root.join("assets")) else {
return out;
};
for namespace in namespaces.flatten() {
for kind in ["biome_modifiers", "dimension_modifiers"] {
let mut stack = vec![namespace.path().join("polytone").join(kind)];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let Ok(relative) = path.strip_prefix(pack_root) else {
continue;
};
let relative = relative.to_string_lossy();
let implicit = horizon_polytone::implicit_id(&relative, kind);
let json = match horizon_world::json_file::read::<Json>(&path) {
Ok(json) => json,
Err(e) => {
log::warn!("{relative}: {e}");
continue;
}
};
let parsed = if kind == "biome_modifiers" {
horizon_polytone::BiomeModifier::parse(&json, implicit.as_deref())
.map(|m| m.map(|m| out.biomes.push(m)))
} else {
horizon_polytone::DimensionModifier::parse(&json, implicit.as_deref())
.map(|m| m.map(|m| out.dimensions.push(m)))
};
if let Err(e) = parsed {
log::warn!("{relative}: {e}");
}
}
}
}
}
out.biomes.sort_by_key(|m| m.priority);
out.dimensions.sort_by_key(|m| m.priority);
out
}
fn resolver(&self, frame: &Arc<RwLock<Frame>>) -> Resolver {
Resolver {
frame: frame.clone(),
configs: self.configs.clone(),
pack_root: self.root.clone(),
colormaps: self.colormaps.clone(),
}
}
}
impl EnvironmentModifiers for Modifiers {
fn is_empty(&self) -> bool {
self.biomes.is_empty() && self.dimensions.is_empty()
}
fn set_options(&mut self, options: Configs) {
self.configs = Arc::new(options);
}
fn dimension(
&self,
id: &str,
in_tag: &dyn Fn(&str) -> bool,
frame: &Arc<RwLock<Frame>>,
maps: DimensionMaps<'_>,
) {
let resolver = self.resolver(frame);
for m in self
.dimensions
.iter()
.filter(|m| m.targets.matches(id, in_tag))
{
resolver.apply(maps.base, &m.base);
resolver.apply(maps.rain, &m.rain);
resolver.apply(maps.thunder, &m.thunder);
resolver.apply(maps.post, &m.post_process);
}
}
fn biome(
&self,
id: &str,
in_tag: &dyn Fn(&str) -> bool,
frame: &Arc<RwLock<Frame>>,
base: &mut AttributeMap,
post: &mut AttributeMap,
) {
let resolver = self.resolver(frame);
for m in self.biomes.iter().filter(|m| m.targets.matches(id, in_tag)) {
resolver.apply(base, &m.base);
resolver.apply(post, &m.post_process);
}
}
}
/// The game side of a pack expression in an attribute value: `g`, `p`, `c`, `o` (the camera's
/// position) and `r`, `v` (the value so far) and the pack's `config`.
struct FrameHost<'a> {
frame: &'a Frame,
configs: &'a Configs,
value: f64,
seed: u64,
}
/// `program`'s value for the frame (a pack's expression outside attribute layers: its `v` is 0).
pub fn eval_frame(
program: &horizon_polytone::expr::Program,
frame: &Frame,
configs: &Configs,
) -> Option<horizon_polytone::expr::Value> {
let mut host = FrameHost {
frame,
configs,
value: 0.0,
seed: frame.game_time as u64,
};
program.eval(&mut host).ok()
}
impl horizon_polytone::expr::Host for FrameHost<'_> {
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
use horizon_polytone::expr::{Root, Value as V};
match root {
Root::Value => V::Num(self.value),
other => V::Object(other as u32),
}
}
fn method(
&mut self,
target: &horizon_polytone::expr::Value,
name: &str,
_args: &[horizon_polytone::expr::Value],
) -> std::result::Result<horizon_polytone::expr::Value, String> {
use horizon_polytone::expr::{Root, Value as V};
let f = self.frame;
let on = |root: Root| *target == V::Object(root as u32);
let num = |n: f64| Ok(V::Num(n));
match name {
"rain" if on(Root::Global) => num(f.global_rain()),
"time" if on(Root::Global) => num(f.game_time as f64),
"dayTime" if on(Root::Global) => num(f.day_time as f64),
"skyType" if on(Root::Global) => num(f64::from(f.sky_type)),
"dimensionType" if on(Root::Global) => Ok(V::Str(f.dimension.as_str().into())),
"renderDistance" if on(Root::Global) => num(f64::from(f.render_distance)),
"moonPhase" if on(Root::Global) => {
num(f.day_time.div_euclid(24_000).rem_euclid(8) as f64)
}
"seaLevel" if on(Root::Global) => num(63.0),
"viewDistance" if on(Root::Camera) => num(f64::from(f.render_distance * 16)),
"x" | "y" | "z" if on(Root::Player) || on(Root::Camera) || on(Root::Object) => {
let at = if on(Root::Player) { f.player } else { f.camera };
num(at[usize::from(name.as_bytes()[0] - b'x')])
}
"inWater" if on(Root::Player) => Ok(V::Bool(f.in_water)),
"skyLight" if on(Root::Object) || on(Root::Player) => num(f64::from(f.sky_light)),
"blockLight" if on(Root::Object) || on(Root::Player) => num(f64::from(f.block_light)),
"temperature" if on(Root::Object) => num(f64::from(f.temperature)),
"downfall" if on(Root::Object) => num(f64::from(f.downfall)),
"biome" if on(Root::Object) || on(Root::Player) || on(Root::Camera) => {
Ok(V::Str(f.biome.as_str().into()))
}
"rand" if on(Root::Random) => {
// A splitmix64 step: the frame's own sequence.
self.seed = self.seed.wrapping_add(0x9e37_79b9_7f4a_7c15);
let mut z = self.seed;
z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
num(((z ^ (z >> 31)) >> 11) as f64 / (1u64 << 53) as f64)
}
_ => Err(format!(
"{name} is not available in an attribute expression"
)),
}
}
fn function(
&mut self,
name: &str,
args: &[horizon_polytone::expr::Value],
) -> std::result::Result<horizon_polytone::expr::Value, String> {
match (name, args) {
("config", [horizon_polytone::expr::Value::Str(id)]) => self
.configs
.get(id)
.map(horizon_polytone::pack::config_value)
.ok_or_else(|| format!("no config {id}")),
_ => Err(format!(
"{name} is not available in an attribute expression"
)),
}
}
}
/// A value as an expression's `v`: floats as they are, colours packed ARGB (`ARGB.as8BitChannel`).
fn as_number(value: &Value) -> f64 {
let byte = |c: f32| ((c.clamp(0.0, 1.0) * 255.0).floor() as u32) & 255;
match value {
Value::Float(f) => f64::from(*f),
Value::Bool(b) => f64::from(u8::from(*b)),
Value::Rgb([r, g, b]) => {
f64::from((0xff00_0000 | byte(*r) << 16 | byte(*g) << 8 | byte(*b)) as i32)
}
Value::Argb([r, g, b, a]) => {
f64::from((byte(*a) << 24 | byte(*r) << 16 | byte(*g) << 8 | byte(*b)) as i32)
}
Value::Other(_) => 0.0,
}
}
/// A Polytone colormap used as an attribute value (wiki "Colormaps"): an image read at the
/// column and row its two expressions pick, each 0 to 1 with 1 the top-left corner.
pub struct Colormap {
x: Axis,
y: Axis,
/// Only the lower-left triangle: the row value is scaled by the column value, as vanilla's
/// grass colormap scales downfall by temperature.
triangular: bool,
/// Rounds to the nearest pixel (the default) instead of the one below.
rounds: bool,
/// `biome_blend`: blocks take the mean of the colours around them, over the Biome Blend
/// square, as vanilla's biome tints do. Without the field, maps that sample the biome
/// (`temperature`, `downfall`, `biome_id`) blend: Polytone 6.8.6's water by `biome_id`
/// fades over a biome edge.
pub(crate) biome_blend: bool,
/// Its axes read nothing that changes up a column of one biome (no `o.y`): a section of
/// one biome can sample it once per column.
pub(crate) column: bool,
width: u32,
height: u32,
rgba: Vec<u8>,
}
/// A colormap axis: an expression (or a named sampler, wiki "Default Samplers"), or the
/// `biome_id` sampler with its mapper.
enum Axis {
Expr(horizon_polytone::expr::Program),
/// `biome_id`: the value the mapper gives the biome where it samples (0 for the others),
/// and the expression naming that biome. Unlike the other axes it counts from the left (or
/// top) edge, in pixel coordinates over the mapper's `texture_size`: the column (or row) is
/// `floor(value * size)`, so `1` over a `texture_size` of 16 is the second pixel, and the
/// biomes left out are the first, as the wiki's "start your mapper at 1" advice expects.
BiomeId(HashMap<String, f64>, horizon_polytone::expr::Program),
}
impl Axis {
/// Whether the axis reads only what stays the same up a column of one biome.
fn column(&self) -> bool {
use horizon_polytone::expr::Root;
let flat = |root: Root, name: &str| match root {
Root::Object => matches!(name, "x" | "z" | "biome" | "temperature" | "downfall"),
Root::Random => name == "noise",
_ => false,
};
match self {
Self::Expr(program) | Self::BiomeId(_, program) => program.reads_only(&flat),
}
}
fn value(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<f64> {
match self {
Self::Expr(program) => Some(program.eval(host).ok()?.num()),
Self::BiomeId(mapper, biome) => {
let biome = match biome.eval(host).ok()? {
horizon_polytone::expr::Value::Str(s) => s,
_ => return Some(0.0),
};
Some(mapper.get(biome.as_ref()).copied().unwrap_or(0.0))
}
}
}
}
/// A `biome_id_mapper`: inline, or an id of `polytone/biome_id_mappers/<path>.json`. Its values
/// are divided by its `texture_size`, when it has one (pixel coordinates).
fn biome_id_mapper(pack_root: &Path, json: &Json) -> HashMap<String, f64> {
let object = match json {
Json::String(id) => {
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
let file = pack_root.join(format!(
"assets/{namespace}/polytone/biome_id_mappers/{path}.json"
));
horizon_world::json_file::read::<Json>(&file).unwrap_or(Json::Null)
}
other => other.clone(),
};
let Some(object) = object.as_object() else {
return HashMap::new();
};
let size = object
.get("texture_size")
.and_then(|v| v.as_f64().or_else(|| v.as_str()?.parse().ok()))
.filter(|s| *s > 0.0)
.unwrap_or(1.0);
object
.iter()
.filter(|(k, _)| *k != "texture_size")
.filter_map(|(k, v)| {
let id = if k.contains(':') {
k.clone()
} else {
format!("minecraft:{k}")
};
Some((id, v.as_f64()? / size))
})
.collect()
}
impl Colormap {
/// One colour wherever it samples (a `biome_colors` flat colour, RGB).
pub fn flat(rgb: u32) -> Self {
let zero = || Axis::Expr(horizon_polytone::expr::Program::compile("0").expect("a number"));
let [_, r, g, b] = rgb.to_be_bytes();
Self {
x: zero(),
y: zero(),
triangular: false,
rounds: true,
biome_blend: false,
column: true,
width: 1,
height: 1,
rgba: vec![r, g, b, 255],
}
}
/// `assets/<namespace>/polytone/colormaps/<path>.png` and its optional `.json` (without
/// one, the axes are the biome's temperature and downfall).
pub fn load(pack_root: &Path, id: &str) -> Option<Self> {
let (namespace, path) = id.split_once(':')?;
let base = pack_root.join(format!("assets/{namespace}/polytone/colormaps/{path}"));
let png = base.with_extension("png");
let (width, height, rgba) = match crate::sprites::read_rgba(&png) {
Ok(image) => image,
Err(e) => {
log::warn!("colormap {id}: {e}");
return None;
}
};
let json: Json =
horizon_world::json_file::read(&base.with_extension("json")).unwrap_or(Json::Null);
let compile = horizon_polytone::expr::Program::compile;
let mut by_biome = false;
let mut axis = |key: &str, default: &str| -> Option<Axis> {
let src = match &json[key] {
Json::String(s) => s.clone(),
Json::Number(n) => n.to_string(),
_ => default.to_owned(),
};
by_biome |= matches!(
src.trim(),
"biome_id" | "temperature" | "legacy_temperature" | "downfall"
) || matches!(src.trim(), "o.temperature" | "o.downfall");
// The named samplers (wiki "Default Samplers") as the expressions they stand for.
let src = match src.trim() {
"biome_id" => {
let mapper = biome_id_mapper(pack_root, &json["biome_id_mapper"]);
return Some(Axis::BiomeId(mapper, compile("o.biome()").ok()?));
}
"temperature" | "legacy_temperature" => "o.temperature".to_owned(),
"downfall" => "o.downfall".to_owned(),
"y_level" => "o.y / 128".to_owned(),
"day_time" => "1 - (g.dayTime % 24000) / 24000".to_owned(),
_ => src,
};
match compile(&src).map(Axis::Expr) {
Ok(program) => Some(program),
Err(e) => {
log::warn!("colormap {id} {key}: {e}");
None
}
}
};
let (x, y) = (
axis("x_axis", "o.temperature")?,
axis("y_axis", "o.downfall")?,
);
let column = [&x, &y].iter().all(|axis| axis.column());
Some(Self {
x,
y,
column,
biome_blend: json["biome_blend"].as_bool().unwrap_or(by_biome),
triangular: json["triangular"].as_bool() == Some(true),
rounds: json["rounds"].as_bool() != Some(false),
width,
height,
rgba,
})
}
/// The colour the axes pick, ARGB; `None` for a transparent pixel (the value stays what
/// it was, the default `fallback_colormap: original`) or an axis that fails.
pub fn sample(&self, host: &mut dyn horizon_polytone::expr::Host) -> Option<u32> {
let x = self.x.value(host)?.clamp(0.0, 1.0);
let mut y = self.y.value(host)?.clamp(0.0, 1.0);
if self.triangular {
y *= x;
}
let pixel = |axis: &Axis, v: f64, size: u32| {
let last = size.saturating_sub(1);
if let Axis::BiomeId(..) = axis {
return ((v * f64::from(size)).floor() as u32).min(last);
}
let p = (1.0 - v) * f64::from(last);
let p = if self.rounds { p.round() } else { p.floor() };
(p as u32).min(last)
};
let (px, py) = (
pixel(&self.x, x, self.width),
pixel(&self.y, y, self.height),
);
let i = (py as usize * self.width as usize + px as usize) * 4;
let [r, g, b, a] = self.rgba.get(i..i + 4)?.try_into().ok()?;
(a != 0).then(|| u32::from(a) << 24 | u32::from(r) << 16 | u32::from(g) << 8 | u32::from(b))
}
}
/// The colormaps of the Polytone block and fluid modifiers of the packs merged at `pack_root`.
/// Inline colormaps aren't read.
pub fn colormap_targets(pack_root: &Path) -> Vec<ColormapTarget> {
let mut out = Vec::new();
let namespaces = std::fs::read_dir(pack_root.join("assets"))
.into_iter()
.flatten()
.flatten();
for namespace in namespaces {
for (kind, fluid) in [("block_modifiers", false), ("fluid_modifiers", true)] {
let mut stack = vec![namespace.path().join("polytone").join(kind)];
while let Some(dir) = stack.pop() {
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let path = entry.path();
if path.is_dir() {
stack.push(path);
continue;
}
let Ok(json) = horizon_world::json_file::read::<Json>(&path) else {
continue;
};
let Some(id) = json.get("colormap").and_then(Json::as_str) else {
continue;
};
let id = if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
};
let relative = path
.strip_prefix(pack_root)
.unwrap_or(&path)
.to_string_lossy();
let implicit = horizon_polytone::implicit_id(&relative, kind);
match horizon_polytone::Targets::parse(json.get("targets"), implicit.as_deref())
{
Ok(targets) => out.push(ColormapTarget {
fluid,
targets: Box::new(move |name: &str| targets.matches(name, |_| false)),
colormap: id,
}),
Err(e) => log::warn!("{relative}: {e}"),
}
}
}
}
}
out
}
/// What the packs put in place of vanilla's biome colour functions: `biome_colors` (a colormap
/// or a flat colour), else the colormap a block modifier gives `grass_block`, `oak_leaves` or
/// `leaf_litter`, or a fluid modifier gives water, which Polytone spreads to every block of that
/// function.
pub fn biome_colormaps(
pack_root: &Path,
modifiers: &[ColormapTarget],
) -> Vec<(BiomeFunction, Arc<dyn BlockColormap>)> {
use horizon_polytone::colors::BiomeColor;
const FUNCTIONS: [(&str, &str, BiomeFunction); 4] = [
("grass", "minecraft:grass_block", BiomeFunction::Grass),
("foliage", "minecraft:oak_leaves", BiomeFunction::Foliage),
(
"dry_foliage",
"minecraft:leaf_litter",
BiomeFunction::DryFoliage,
),
("water", "minecraft:water", BiomeFunction::Water),
];
let colors = horizon_polytone::colors::PackColors::read(pack_root);
FUNCTIONS
.into_iter()
.filter_map(|(name, block, function)| {
let colormap = match colors.biome_color(name) {
Some(BiomeColor::Flat(rgb)) => Some(Colormap::flat(rgb)),
Some(BiomeColor::Colormap(id)) => Colormap::load(pack_root, &id),
None => modifiers
.iter()
.find(|m| m.fluid == (name == "water") && (m.targets)(block))
.and_then(|m| Colormap::load(pack_root, &m.colormap)),
}?;
Some((function, Arc::new(colormap) as Arc<dyn BlockColormap>))
})
.collect()
}
impl BlockColormap for Colormap {
fn biome_blend(&self) -> bool {
self.biome_blend
}
fn column(&self) -> bool {
self.column
}
fn sample(&self, at: [i32; 3], biome: &crate::biome::BiomeInfo) -> Option<u32> {
Colormap::sample(self, &mut BlockHost::new(at, biome))
}
}
/// What a block colormap's axes read where it samples: the block (`o.x`, `o.biome()`, its
/// biome's climate) and `r.noise`. The world isn't reachable from the mesher, so the rest fails
/// and the block keeps its own tint.
pub(crate) struct BlockHost<'a> {
at: [i32; 3],
biome: &'a crate::biome::BiomeInfo,
}
impl<'a> BlockHost<'a> {
pub(crate) fn new(at: [i32; 3], biome: &'a crate::biome::BiomeInfo) -> Self {
Self { at, biome }
}
}
/// The simplex noise `r.noise(x, y)` reads, the same for blocks and particles.
pub fn polytone_noise() -> &'static horizon_core::noise::SimplexNoise {
static NOISE: std::sync::OnceLock<horizon_core::noise::SimplexNoise> =
std::sync::OnceLock::new();
NOISE.get_or_init(|| {
horizon_core::noise::SimplexNoise::new(&mut horizon_core::random::LegacyRandom::new(0))
})
}
impl horizon_polytone::expr::Host for BlockHost<'_> {
fn field(
&mut self,
root: horizon_polytone::expr::Root,
field: horizon_polytone::expr::Field,
) -> Option<horizon_polytone::expr::Value> {
use horizon_polytone::expr::Field as F;
if root != horizon_polytone::expr::Root::Object {
return None;
}
Some(horizon_polytone::expr::Value::Num(match field {
F::X => f64::from(self.at[0]),
F::Y => f64::from(self.at[1]),
F::Z => f64::from(self.at[2]),
F::Temperature => f64::from(self.biome.temperature),
F::Downfall => f64::from(self.biome.downfall),
_ => return None,
}))
}
fn root(&mut self, root: horizon_polytone::expr::Root) -> horizon_polytone::expr::Value {
use horizon_polytone::expr::Root;
horizon_polytone::expr::Value::Object(match root {
Root::Object => 1,
Root::Random => 2,
_ => 0,
})
}
fn method(
&mut self,
target: &horizon_polytone::expr::Value,
name: &str,
args: &[horizon_polytone::expr::Value],
) -> std::result::Result<horizon_polytone::expr::Value, String> {
use horizon_polytone::expr::Value as V;
let num = |n: f64| Ok(V::Num(n));
match (target, name) {
(V::Object(1), "x" | "y" | "z") => {
num(f64::from(self.at[usize::from(name.as_bytes()[0] - b'x')]))
}
(V::Object(1), "biome") => Ok(V::Str(self.biome.name.as_str().into())),
(V::Object(1), "temperature") => num(f64::from(self.biome.temperature)),
(V::Object(1), "downfall") => num(f64::from(self.biome.downfall)),
(V::Object(2), "noise") => {
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
num(f64::from(polytone_noise().get2(x, y)))
}
_ => Err(format!("{name} is not available to block colormaps")),
}
}
fn function(
&mut self,
name: &str,
_: &[horizon_polytone::expr::Value],
) -> std::result::Result<horizon_polytone::expr::Value, String> {
Err(format!("{name} is not available to block colormaps"))
}
}
/// Turns Polytone layers into vanilla entries, compiling expressions against the frame.
struct Resolver {
frame: Arc<RwLock<Frame>>,
configs: Arc<Configs>,
/// Where the packs' colormaps are, and the ones loaded so far (`None`: missing).
pack_root: Option<std::path::PathBuf>,
colormaps: ColormapCache,
}
/// Colormaps by id, loaded once for all three dimensions' stacks.
type ColormapCache = Arc<std::sync::Mutex<HashMap<String, Option<Arc<Colormap>>>>>;
impl Resolver {
/// A Polytone layer as a vanilla entry: `Some(None)` removes the attribute's entry, `None`
/// is a value not supported yet (colormaps) or a broken one.
fn entry(
&self,
attribute: &'static Attribute,
layer: &horizon_polytone::Layer,
) -> Option<Option<Entry>> {
use horizon_polytone::{Argument, Layer, Modifier};
let (modifier, argument) = match layer {
Layer::Remove => return Some(None),
Layer::Apply {
modifier, argument, ..
} => (modifier, argument),
};
let op = match modifier {
Modifier::Override => "override",
Modifier::Add => "add",
Modifier::Subtract => "subtract",
Modifier::Multiply => "multiply",
Modifier::Minimum => "minimum",
Modifier::Maximum => "maximum",
Modifier::AlphaBlend => "alpha_blend",
Modifier::BlendToGray => "blend_to_gray",
Modifier::And => "and",
Modifier::Nand => "nand",
Modifier::Or => "or",
Modifier::Nor => "nor",
Modifier::Xor => "xor",
Modifier::Xnor => "xnor",
};
let json = match argument {
Argument::Number(n) => Json::from(*n),
Argument::Bool(b) => Json::Bool(*b),
// Polytone colours may leave out the `#`; vanilla's codec wants it, 6 digits for RGB.
Argument::Color(c) if attribute.kind == Kind::RgbColor => {
Json::String(format!("#{:06x}", c & 0x00ff_ffff))
}
Argument::Color(c) => Json::String(format!("#{c:08x}")),
Argument::Object(o) => Json::Object(o.clone()),
Argument::Expression(src) => return self.expression(attribute, op, src).map(Some),
Argument::Colormap(id) => return self.colormap(attribute, op, id).map(Some),
};
match Entry::with_modifier(attribute, op, &json) {
Ok(entry) => Some(Some(entry)),
Err(e) => {
log::warn!("{}: {e}", attribute.id);
None
}
}
}
/// An expression value: compiled once, evaluated against the frame each time the layer
/// applies, its result read as the modifier's argument (a number, a boolean, or a packed
/// colour for colour attributes).
fn expression(
&self,
attribute: &'static Attribute,
op: &'static str,
src: &str,
) -> Option<Entry> {
let program = match horizon_polytone::expr::Program::compile(src) {
Ok(program) => program,
Err(e) => {
log::warn!("{}: {e}", attribute.id);
return None;
}
};
let (frame, configs) = (self.frame.clone(), self.configs.clone());
let f = move |value: &Value| -> Option<Entry> {
let frame = frame.read().ok()?;
let mut host = FrameHost {
frame: &frame,
configs: &configs,
value: as_number(value),
seed: 0,
};
let json = match program.eval(&mut host).ok()? {
horizon_polytone::expr::Value::Bool(b) => Json::Bool(b),
result => Json::from(result.num()),
};
// A number out of the attribute's range is clamped into it, as the sampled value is
// (`AttributeRange.sanitize`), not dropped: a pack's fog end that goes negative deep
// underground is the thickest fog, not the default.
Entry::with_modifier(attribute, op, &json).ok().or_else(|| {
let n = json.as_f64()? as f32;
let Value::Float(clamped) = attribute.sanitize(Value::Float(n)) else {
return None;
};
Entry::with_modifier(attribute, op, &Json::from(clamped)).ok()
})
};
Some(Entry::Dynamic(
horizon_world::environment_attributes::Dynamic(Arc::new(f)),
))
}
/// A colormap value: sampled whenever its layer applies, its colour the modifier's argument
/// (`#rrggbb` on RGB attributes, `#aarrggbb` on ARGB ones).
fn colormap(&self, attribute: &'static Attribute, op: &'static str, id: &str) -> Option<Entry> {
let root = self.pack_root.as_deref()?;
let colormap = self
.colormaps
.lock()
.ok()?
.entry(id.to_owned())
.or_insert_with(|| Colormap::load(root, id).map(Arc::new))
.clone()?;
let (frame, configs) = (self.frame.clone(), self.configs.clone());
let rgb = attribute.kind == Kind::RgbColor;
let f = move |value: &Value| -> Option<Entry> {
let frame = frame.read().ok()?;
let mut host = FrameHost {
frame: &frame,
configs: &configs,
value: as_number(value),
seed: 0,
};
let color = colormap.sample(&mut host)?;
let text = if rgb {
format!("#{:06x}", color & 0x00ff_ffff)
} else {
format!("#{color:08x}")
};
Entry::with_modifier(attribute, op, &Json::String(text)).ok()
};
Some(Entry::Dynamic(
horizon_world::environment_attributes::Dynamic(Arc::new(f)),
))
}
/// Applies a group of Polytone layers to `map`: entries set or replaced, `REMOVE` drops
/// them.
fn apply(&self, map: &mut AttributeMap, group: &horizon_polytone::Attributes) {
for (id, layer) in group {
let Some(attribute) = attribute(id) else {
log::warn!("unknown attribute {id}");
continue;
};
match self.entry(attribute, layer) {
// A computed value that can't decide (a transparent colormap pixel, a failing
// expression) falls back to the entry it replaces: the value before the pack.
Some(Some(Entry::Dynamic(f))) => match map.get(attribute).cloned() {
Some(original) => {
let fallback = move |v: &Value| (f.0)(v).or_else(|| Some(original.clone()));
let entry =
horizon_world::environment_attributes::Dynamic(Arc::new(fallback));
map.set(attribute, Entry::Dynamic(entry));
}
None => map.set(attribute, Entry::Dynamic(f)),
},
Some(Some(entry)) => map.set(attribute, entry),
Some(None) => map.remove(attribute),
None => {}
}
}
}
}
@@ -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,18 @@
//! 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;
pub mod environment;
mod fusion;
mod item_bars;
mod pack_options;
use std::path::Path;
use std::sync::Arc;
@@ -20,15 +29,93 @@ 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 environment_modifiers(
&self,
root: &Path,
) -> Option<Box<dyn crate::environment::EnvironmentModifiers>> {
Some(Box::new(environment::Modifiers::read(root)))
}
fn colormap_targets(&self, root: &Path) -> Vec<crate::environment::ColormapTarget> {
environment::colormap_targets(root)
}
fn block_colormap(
&self,
root: &Path,
id: &str,
) -> Option<Arc<dyn crate::environment::BlockColormap>> {
Some(Arc::new(environment::Colormap::load(root, id)?))
}
fn biome_colormaps(
&self,
root: &Path,
modifiers: &[crate::environment::ColormapTarget],
) -> Vec<(
crate::environment::BiomeFunction,
Arc<dyn crate::environment::BlockColormap>,
)> {
environment::biome_colormaps(root, modifiers)
}
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,122 @@ 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 overlay's changes to the dimensions' and biomes' environment attributes.
fn environment_modifiers(
&self,
_root: &Path,
) -> Option<Box<dyn crate::environment::EnvironmentModifiers>> {
None
}
/// The overlay's block and fluid modifiers that tint with a colormap.
fn colormap_targets(&self, _root: &Path) -> Vec<crate::environment::ColormapTarget> {
Vec::new()
}
/// The overlay's colormap `id` (`namespace:path`), as blocks sample it.
fn block_colormap(
&self,
_root: &Path,
_id: &str,
) -> Option<Arc<dyn crate::environment::BlockColormap>> {
None
}
/// The colormaps the overlay puts in place of vanilla's biome colour functions, given its
/// `modifiers` ([`Self::colormap_targets`]).
fn biome_colormaps(
&self,
_root: &Path,
_modifiers: &[crate::environment::ColormapTarget],
) -> Vec<(
crate::environment::BiomeFunction,
Arc<dyn crate::environment::BlockColormap>,
)> {
Vec::new()
}
/// 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);
@@ -59,7 +59,7 @@ enum TintSource {
/// `water_color`): the biome's own, as blocks get it.
Biome(fn(&crate::biome::BiomeInfo) -> u32),
/// A pack's colormap (`polytone/colormaps`), its axes run against the particle.
Pack(crate::environment::Colormap),
Pack(crate::pack_formats::environment::Colormap),
}
/// When a particle's colormap is sampled again (`cache`).
@@ -101,7 +101,9 @@ impl Tint {
"minecraft:foliage_color" => TintSource::Biome(|b| b.foliage),
"minecraft:dry_foliage_color" => TintSource::Biome(|b| b.dry_foliage),
"minecraft:water_color" => TintSource::Biome(|b| b.water),
_ => TintSource::Pack(crate::environment::Colormap::load(pack_root, &id)?),
_ => TintSource::Pack(crate::pack_formats::environment::Colormap::load(
pack_root, &id,
)?),
};
Some(Self { source, cache })
}
@@ -774,7 +776,9 @@ impl Host for Ctx<'_, '_> {
// `r.noise(x, y)`: one simplex noise (named noises aren't read yet).
let mut coords = args.iter().filter(|v| !matches!(v, V::Str(_))).map(V::num);
let (x, y) = (coords.next().unwrap_or(0.0), coords.next().unwrap_or(0.0));
num(f64::from(crate::environment::polytone_noise().get2(x, y)))
num(f64::from(
crate::pack_formats::environment::polytone_noise().get2(x, y),
))
}
(id, _) if id >= BLOCKS => {
let at = *self
@@ -796,7 +800,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")),
}
@@ -178,14 +178,16 @@ impl PolytonePost {
.insert("PolyGlobals".to_owned(), globals.bytes());
for chain in &self.chains {
let on = chain.activation.as_ref().is_none_or(|c| {
crate::environment::eval_frame(c, frame, &self.configs).is_some_and(|v| v.truthy())
crate::pack_formats::environment::eval_frame(c, frame, &self.configs)
.is_some_and(|v| v.truthy())
});
if !on {
continue;
}
for (name, program) in &chain.uniforms {
let value = crate::environment::eval_frame(program, frame, &self.configs)
.map_or(0.0, |v| v.num()) as f32;
let value =
crate::pack_formats::environment::eval_frame(program, frame, &self.configs)
.map_or(0.0, |v| v.num()) as f32;
let mut block = value.to_le_bytes().to_vec();
block.resize(16, 0);
extras.blocks.insert(name.clone(), block);
@@ -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",