mirror of
https://github.com/horizon-rust/horizon.git
synced 2026-11-07 17:21:49 -10:00
Merge main into optifine/etf-fixes
#6246 picks a .jem's texture through cem_texture and paints_with_jem: render_visible takes main's choice, and drawn_textures follows it (the .jem texture and the vanilla one it falls back to are both listed). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
8512214d0e
16 files changed
+1059
-144
No files matched your search
@@ -159,7 +159,7 @@ state stays separate from the library tests. This compiles gameplay twice for ex
|
||||
| Save and load | L: `save.rs` (level.dat, regions, playerdata), `entity_nbt.rs`, `options_file.rs`; `horizon-client-core/src/block_entity_nbt.rs`; G: `saving.rs`, `persist.rs` (entities); `crates/horizon-world/src/{region.rs,nbt.rs,datafix/}` |
|
||||
| Input | game.rs `fn window_event`, `fn key_pressed`, `fn play_mouse`; crate `horizon-client-input` (`keybinds.rs`, `keymapping.rs`, `key_names.rs`, `mouse.rs`, `input.rs`, `interaction_input.rs`, `gamepad.rs`, `clipboard.rs`); L: `osk.rs`; G: `pad.rs`, `pad_menu.rs` |
|
||||
| Audio | L: `audio.rs` (mixer), `sound_engine.rs`, `sound_events.rs`, `music.rs`, `ambience.rs`; G: `music.rs`, `subtitle_hud.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`; `horizon-client-core/src/lang.rs`; crate `horizon-assets`; G: `packs.rs` |
|
||||
| Resource packs | L: `packs.rs` + `packs/`, `pack_screen.rs`, `atlas_sources.rs`, `sprites.rs`, `mipmap.rs`, `model_library.rs`, `font.rs`, `mobs/cem.rs` + `mobs/cem_anim.rs` (OptiFine/EMF entity models and their animations); `horizon-client-core/src/lang.rs`; crates `horizon-assets`, `horizon-optifine`; G: `packs.rs` |
|
||||
| Agent control | L: `control.rs` (socket), `bin/horizon-ctl/`; G: `control.rs`, `agent.rs`, `events.rs`, `ui_control.rs` ([docs/agent-control.md](docs/agent-control.md)) |
|
||||
| Mods | crate `horizon-mods`; G: `mods.rs`; L: `mod_content.rs`, `mod_list.rs` |
|
||||
| Horizon extras, settings | L: `extras.rs`, `graphics.rs`, `settings.rs`, `minimap.rs`; G: `extras.rs`, `tweaks.rs`, `options.rs` |
|
||||
|
||||
@@ -111,6 +111,7 @@ mod end_sky;
|
||||
mod flat;
|
||||
mod frame_gen;
|
||||
mod hdr;
|
||||
mod hdr_icc;
|
||||
mod hdr_stats;
|
||||
pub use hdr::Calibration as HdrCalibration;
|
||||
pub mod block_light;
|
||||
@@ -8250,19 +8251,19 @@ impl Renderer {
|
||||
Ok([width, height])
|
||||
}
|
||||
/// The HDR10 copy (`Hdr::capture`) as a 16-bit PNG tagged BT.2020 with PQ (cICP, PNG's
|
||||
/// third edition), with the peak and paper white as its content light levels (cLLI).
|
||||
/// third edition), with the peak and paper white as its content light levels (cLLI), and
|
||||
/// an ICC profile for the programs that don't read cICP (`hdr_icc`).
|
||||
fn write_hdr_screenshot(&self, path: &std::path::Path, buffer: &wgpu::Buffer) -> Result<()> {
|
||||
let Some(hdr) = &self.hdr else {
|
||||
return Ok(());
|
||||
};
|
||||
let pixels = hdr10_to_rgb16(&self.read_frame(buffer)?);
|
||||
let mut encoder = png::Encoder::new(
|
||||
std::io::BufWriter::new(std::fs::File::create(path)?),
|
||||
self.config.width,
|
||||
self.config.height,
|
||||
);
|
||||
encoder.set_color(png::ColorType::Rgb);
|
||||
encoder.set_depth(png::BitDepth::Sixteen);
|
||||
let mut info = png::Info::with_size(self.config.width, self.config.height);
|
||||
info.color_type = png::ColorType::Rgb;
|
||||
info.bit_depth = png::BitDepth::Sixteen;
|
||||
info.icc_profile = Some(hdr_icc::profile().into());
|
||||
let encoder =
|
||||
png::Encoder::with_info(std::io::BufWriter::new(std::fs::File::create(path)?), info)?;
|
||||
let mut writer = encoder.write_header()?;
|
||||
// BT.2020 primaries, PQ, RGB, full range.
|
||||
writer.write_chunk(png::chunk::cICP, &[9, 16, 0, 1])?;
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
//! The ICC profile of HDR screenshots, for programs that don't read PNG's `cICP`: BT.2020
|
||||
//! primaries and the PQ curve, with HDR reference white (203 nits) as the profile's white.
|
||||
//! An ICC profile has no values above white, so those programs show a correct SDR picture
|
||||
//! with its highlights clipped, where without it they took PQ's values for sRGB's and showed
|
||||
//! them washed out. Programs that read `cICP` ignore this (PNG's third edition).
|
||||
|
||||
/// HDR reference white in nits (ITU-R BT.2408): where the curve reaches the profile's white.
|
||||
const WHITE: f64 = 203.0;
|
||||
/// PQ's values from 0 to 1, in the curve's steps.
|
||||
const STEPS: usize = 4096;
|
||||
/// BT.2020's primaries, adapted to the profile connection space's D50 (Bradford).
|
||||
const RED: [f64; 3] = [0.67348, 0.27904, -0.00193];
|
||||
const GREEN: [f64; 3] = [0.16567, 0.67534, 0.02998];
|
||||
const BLUE: [f64; 3] = [0.12505, 0.04561, 0.79685];
|
||||
const D50: [f64; 3] = [0.9642, 1.0, 0.8249];
|
||||
|
||||
/// PQ's EOTF (SMPTE ST 2084): a signal from 0 to 1 in nits.
|
||||
fn pq_nits(signal: f64) -> f64 {
|
||||
let (m1, m2) = (2610.0 / 16384.0, 2523.0 / 32.0);
|
||||
let (c1, c2, c3) = (3424.0 / 4096.0, 2413.0 / 128.0, 2392.0 / 128.0);
|
||||
let p = signal.powf(1.0 / m2);
|
||||
10_000.0 * ((p - c1).max(0.0) / (c2 - c3 * p)).powf(1.0 / m1)
|
||||
}
|
||||
|
||||
fn s15_fixed16(v: f64) -> [u8; 4] {
|
||||
((v * 65536.0).round() as i32).to_be_bytes()
|
||||
}
|
||||
|
||||
fn xyz(v: [f64; 3]) -> Vec<u8> {
|
||||
let mut tag = b"XYZ \0\0\0\0".to_vec();
|
||||
tag.extend(v.iter().flat_map(|&c| s15_fixed16(c)));
|
||||
tag
|
||||
}
|
||||
|
||||
fn curve() -> Vec<u8> {
|
||||
let mut tag = b"curv\0\0\0\0".to_vec();
|
||||
tag.extend((STEPS as u32).to_be_bytes());
|
||||
for i in 0..STEPS {
|
||||
let y = (pq_nits(i as f64 / (STEPS - 1) as f64) / WHITE).min(1.0);
|
||||
tag.extend(((y * 65535.0).round() as u16).to_be_bytes());
|
||||
}
|
||||
tag
|
||||
}
|
||||
|
||||
/// `textDescriptionType` (ICC v2), with its empty Unicode and ScriptCode parts.
|
||||
fn description(text: &str) -> Vec<u8> {
|
||||
let mut tag = b"desc\0\0\0\0".to_vec();
|
||||
tag.extend((text.len() as u32 + 1).to_be_bytes());
|
||||
tag.extend(text.as_bytes());
|
||||
tag.extend([0; 1 + 4 + 4 + 2 + 1 + 67]);
|
||||
tag
|
||||
}
|
||||
|
||||
/// An ICC v2 matrix-shaper display profile.
|
||||
pub(super) fn profile() -> Vec<u8> {
|
||||
let trc = curve();
|
||||
let copyright = b"text\0\0\0\0Public domain\0".to_vec();
|
||||
let tags: [(&[u8; 4], &[u8]); 9] = [
|
||||
(b"desc", &description("BT.2020 PQ, 203 nits = white")),
|
||||
(b"cprt", ©right),
|
||||
(b"wtpt", &xyz(D50)),
|
||||
(b"rXYZ", &xyz(RED)),
|
||||
(b"gXYZ", &xyz(GREEN)),
|
||||
(b"bXYZ", &xyz(BLUE)),
|
||||
(b"rTRC", &trc),
|
||||
(b"gTRC", &trc),
|
||||
(b"bTRC", &trc),
|
||||
];
|
||||
let data_start = 128 + 4 + 12 * tags.len();
|
||||
let mut table = (tags.len() as u32).to_be_bytes().to_vec();
|
||||
let mut data = Vec::new();
|
||||
let mut trc_at = None;
|
||||
for (name, tag) in tags {
|
||||
// The three channels share one curve.
|
||||
let is_trc = tag == &trc[..];
|
||||
let at = match trc_at.filter(|_| is_trc) {
|
||||
Some(at) => at,
|
||||
None => {
|
||||
let at = data_start + data.len();
|
||||
data.extend(tag);
|
||||
data.resize(data.len().next_multiple_of(4), 0);
|
||||
at
|
||||
}
|
||||
};
|
||||
if is_trc {
|
||||
trc_at = Some(at);
|
||||
}
|
||||
table.extend(name);
|
||||
table.extend((at as u32).to_be_bytes());
|
||||
table.extend((tag.len() as u32).to_be_bytes());
|
||||
}
|
||||
let mut out = vec![0; 128];
|
||||
let size = data_start + data.len();
|
||||
out[0..4].copy_from_slice(&(size as u32).to_be_bytes());
|
||||
out[8..12].copy_from_slice(&[2, 0x40, 0, 0]);
|
||||
out[12..16].copy_from_slice(b"mntr");
|
||||
out[16..20].copy_from_slice(b"RGB ");
|
||||
out[20..24].copy_from_slice(b"XYZ ");
|
||||
out[36..40].copy_from_slice(b"acsp");
|
||||
// Relative colorimetric, and the connection space's illuminant.
|
||||
out[64..68].copy_from_slice(&1u32.to_be_bytes());
|
||||
for (i, &c) in D50.iter().enumerate() {
|
||||
out[68 + 4 * i..72 + 4 * i].copy_from_slice(&s15_fixed16(c));
|
||||
}
|
||||
out.extend(table);
|
||||
out.extend(data);
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tag<'a>(profile: &'a [u8], name: &[u8; 4]) -> &'a [u8] {
|
||||
let u32_at = |i: usize| u32::from_be_bytes(profile[i..i + 4].try_into().unwrap()) as usize;
|
||||
let at = (0..u32_at(128))
|
||||
.map(|i| 132 + 12 * i)
|
||||
.find(|&e| &profile[e..e + 4] == name)
|
||||
.unwrap();
|
||||
&profile[u32_at(at + 4)..u32_at(at + 4) + u32_at(at + 8)]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_profile_is_whole() {
|
||||
let p = profile();
|
||||
assert_eq!(
|
||||
u32::from_be_bytes(p[0..4].try_into().unwrap()) as usize,
|
||||
p.len()
|
||||
);
|
||||
assert_eq!(&p[36..40], b"acsp");
|
||||
assert_eq!(p.len() % 4, 0);
|
||||
assert_eq!(&tag(&p, b"desc")[0..4], b"desc");
|
||||
// One curve, shared: the profile stays small.
|
||||
assert_eq!(tag(&p, b"rTRC").as_ptr(), tag(&p, b"bTRC").as_ptr());
|
||||
assert!(p.len() < 10_000, "{}", p.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_primaries_add_up_to_white() {
|
||||
for i in 0..3 {
|
||||
assert!((RED[i] + GREEN[i] + BLUE[i] - D50[i]).abs() < 0.0001);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_curve_reaches_white_at_reference_white_and_stays() {
|
||||
let p = profile();
|
||||
let trc = tag(&p, b"rTRC");
|
||||
let value = |signal: f64| {
|
||||
let i = 12 + 2 * (signal * (STEPS - 1) as f64).round() as usize;
|
||||
u16::from_be_bytes([trc[i], trc[i + 1]])
|
||||
};
|
||||
assert_eq!(value(0.0), 0);
|
||||
// 203 nits in PQ is a signal of 0.5807.
|
||||
assert!(value(0.57) < 65535);
|
||||
assert_eq!(value(0.59), 65535);
|
||||
assert_eq!(value(1.0), 65535);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,8 @@ horizon-client-render = { path = "../horizon-client-render" }
|
||||
horizon-command = { path = "../horizon-command" }
|
||||
horizon-items = { path = "../horizon-items" }
|
||||
horizon-core = { path = "../horizon-core" }
|
||||
# OptiFine-format pack files: custom entity models, random entity textures.
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
# The server thread ahead of the workers (MinecraftServer.spin's Thread.setPriority).
|
||||
horizon-threads = { path = "../horizon-threads" }
|
||||
horizon-hdr = { path = "../horizon-hdr" }
|
||||
@@ -40,7 +42,6 @@ horizon-narrator = { path = "../horizon-narrator" }
|
||||
# Horizon mods: sandboxed WebAssembly components, off unless installed and turned on (docs/mods.md).
|
||||
# Their runtime is the `mods` feature.
|
||||
horizon-mods = { path = "../horizon-mods", default-features = false, features = ["index"] }
|
||||
horizon-optifine = { path = "../horizon-optifine" }
|
||||
wgpu = { version = "30.0.1", default-features = false, features = ["std", "parking_lot", "vulkan", "metal", "wgsl", "naga-ir"] }
|
||||
winit = "0.30.13"
|
||||
pollster = "0.4"
|
||||
|
||||
@@ -2322,6 +2322,12 @@ mod armor_stand_motion_tests {
|
||||
let start = Vec3::new(20.5, 20.0, 20.5);
|
||||
let mut stand = ArmorStand::new(1, start, 0.0);
|
||||
stand.on_ground = false;
|
||||
// A summoned stand has no motion yet: the first tick only builds it
|
||||
// (vanilla moves by the velocity, then applies gravity), so it falls
|
||||
// from the second tick on (measured on the real server: drop 0, 0, 0.0784 blocks).
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
assert_eq!(stand.position, start);
|
||||
assert!((stand.velocity.y + 0.0784).abs() < 1e-6);
|
||||
tick_stand(&mut stand, &world, &facts, &collision);
|
||||
assert_eq!(stand.previous, start);
|
||||
assert!(
|
||||
|
||||
@@ -1047,6 +1047,13 @@ impl Game<'_> {
|
||||
/// floor there, else on top of the boat.
|
||||
pub(super) fn dismount(&mut self, i: usize) {
|
||||
self.riding = None;
|
||||
// `AbstractBoat.tick`'s non-authoritative branch: the copy of a boat
|
||||
// that no player of this side drives drops its cached motion
|
||||
// (`setDeltaMovement(Vec3.ZERO)`, `deltaRotation = 0`) and is not
|
||||
// moved, so a boat is at a standstill when its rider gets out instead
|
||||
// of coasting on the velocity of the ride that just ended.
|
||||
self.boats[i].velocity = Vec3::ZERO;
|
||||
self.boats[i].delta_rotation = 0.0;
|
||||
let target = self.boat_dismount_target(i, 0.6, self.yaw);
|
||||
if let Some(p) = self.player.as_mut() {
|
||||
p.position = target;
|
||||
|
||||
@@ -100,6 +100,15 @@ pub(super) fn blast_reaches(center: [f64; 3], diameter: f64, at: [f64; 3]) -> bo
|
||||
d / diameter <= 1.0
|
||||
}
|
||||
|
||||
/// `ItemEntity.ignoreExplosion` through
|
||||
/// `ServerExplosion.shouldAffectBlocklikeEntities`: an explosion that does not
|
||||
/// affect blocklike entities is ignored entirely by a dropped item. With
|
||||
/// `mobGriefing` off that is every blast that keeps blocks; with it on, none
|
||||
/// (no wind charge goes through `server_explode`).
|
||||
fn items_ignore_explosion(mob_griefing: bool, interaction: BlockInteraction) -> bool {
|
||||
!mob_griefing && interaction == BlockInteraction::Keep
|
||||
}
|
||||
|
||||
/// Whether a blast leaves item frames, paintings, cushions and armour stands
|
||||
/// alone. Without `mobGriefing`, a blast that keeps blocks is ignored
|
||||
/// (`ServerExplosion.shouldAffectBlocklikeEntities` through their
|
||||
@@ -593,9 +602,15 @@ impl Game<'_> {
|
||||
}
|
||||
self.mobs = mobs;
|
||||
// Dropped items: 5 health, destroyed by the explosion damage (nether stars resist).
|
||||
// `ItemEntity.ignoreExplosion` asks `shouldAffectBlocklikeEntities`
|
||||
// first, so a blast that keeps blocks leaves every item untouched.
|
||||
let items_ignore = items_ignore_explosion(self.ui.rules.bool("mob_griefing"), interaction);
|
||||
let before = self.dropped_ids();
|
||||
let mut items = std::mem::take(&mut self.dropped);
|
||||
items.retain_mut(|item| {
|
||||
if items_ignore {
|
||||
return true;
|
||||
}
|
||||
let feet = [item.position.x, item.position.y, item.position.z];
|
||||
let dist = distance(feet) / diameter;
|
||||
if dist > 1.0 {
|
||||
@@ -1168,6 +1183,20 @@ fn add_or_append_stack(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A dropped item survives a mob's blast untouched while `mobGriefing` is
|
||||
/// off, and is thrown and hurt as usual by one that destroys blocks or by
|
||||
/// any blast with the rule back on.
|
||||
#[test]
|
||||
fn items_ignore_a_blast_that_keeps_blocks_with_griefing_off() {
|
||||
assert!(items_ignore_explosion(false, BlockInteraction::Keep));
|
||||
assert!(!items_ignore_explosion(false, BlockInteraction::Destroy));
|
||||
assert!(!items_ignore_explosion(
|
||||
false,
|
||||
BlockInteraction::DestroyWithDecay
|
||||
));
|
||||
assert!(!items_ignore_explosion(true, BlockInteraction::Keep));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blasts_reach_entities_within_their_diameter() {
|
||||
// A TNT blast: radius 4, so the entity pass reaches 8 blocks out.
|
||||
|
||||
@@ -709,7 +709,9 @@ impl Game<'_> {
|
||||
if let Some(search) = self.recipe_ui.search.as_mut() {
|
||||
search.x = xo + 25.0;
|
||||
search.y = yo + 13.0;
|
||||
let magnifier = inside(cursor, xo + 8.0, yo + 13.0, 17.0, 14.0);
|
||||
// RecipeBookComponent's magnifierIconPlacement: from the book's
|
||||
// left edge plus eight to the search box's own left edge.
|
||||
let magnifier = inside(cursor, xo + 8.0, yo + 13.0, search.x - xo, 14.0);
|
||||
if magnifier || inside(cursor, search.x, search.y, search.width, search.height) {
|
||||
self.recipe_ui.search_drag = true;
|
||||
search.focused = true;
|
||||
|
||||
@@ -624,7 +624,7 @@ mod tests {
|
||||
}
|
||||
world.set(20, 4, 20, water).unwrap();
|
||||
world.set(21, 4, 20, flowing).unwrap();
|
||||
let at = Vec3::new(20.5, 6.0, 20.5);
|
||||
let at = Vec3::new(20.5, 4.0, 20.5);
|
||||
let mut tnt = PrimedTnt {
|
||||
id: 0,
|
||||
position: at,
|
||||
@@ -702,10 +702,15 @@ mod tests {
|
||||
.registry
|
||||
.default_state("minecraft:soul_sand")
|
||||
.unwrap();
|
||||
let column = assets
|
||||
// Soul sand makes an up-pushing column (`drag=false`); the block's
|
||||
// default state is the down-dragging one.
|
||||
let drag_down = assets
|
||||
.registry
|
||||
.default_state("minecraft:bubble_column")
|
||||
.unwrap();
|
||||
let mut up = assets.registry.state(drag_down).unwrap().clone();
|
||||
up.properties.insert("drag".into(), "false".into());
|
||||
let column = assets.registry.resolve(&up).unwrap();
|
||||
for y in 3..=8 {
|
||||
world.set(20, y, 20, column).unwrap();
|
||||
}
|
||||
|
||||
@@ -632,7 +632,7 @@ impl Game<'_> {
|
||||
};
|
||||
if m.session.offers.len() > 7 {
|
||||
let max = m.session.offers.len() - 7;
|
||||
m.scroll = (m.scroll as i32 - d.signum() as i32).clamp(0, max as i32) as usize;
|
||||
m.scroll = scrolled(m.scroll, max, d);
|
||||
}
|
||||
}
|
||||
/// `MerchantScreen` rendering.
|
||||
@@ -930,10 +930,30 @@ fn drag_scroll(y: f32, offers: usize) -> usize {
|
||||
((offs * max as f32 + 0.5) as i32).clamp(0, max) as usize
|
||||
}
|
||||
|
||||
/// `MerchantScreen.mouseScrolled`: `Mth.clamp((int)(scrollOff - scrollY), 0, max)`,
|
||||
/// so a fractional wheel step that truncates to zero does not move the list.
|
||||
fn scrolled(scroll: usize, max: usize, d: f32) -> usize {
|
||||
((scroll as f64 - f64::from(d)) as i32).clamp(0, max as i32) as usize
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// `MerchantScreen.mouseScrolled`: the offset is `(int)(scrollOff - scrollY)`
|
||||
/// clamped, so the list moves by as many rows as the event's delta and a
|
||||
/// fractional delta still moves one row (the truncation is after the
|
||||
/// subtraction). Horizon moved exactly one row per event.
|
||||
#[test]
|
||||
fn the_offers_scroll_by_the_wheel_delta() {
|
||||
assert_eq!(scrolled(3, 5, 1.0), 2, "one notch down");
|
||||
assert_eq!(scrolled(3, 5, -1.0), 4, "one notch up");
|
||||
assert_eq!(scrolled(3, 5, 0.25), 2, "a fraction still moves a row");
|
||||
assert_eq!(scrolled(3, 5, 2.5), 0, "a two-and-a-half notch event");
|
||||
assert_eq!(scrolled(0, 5, 1.0), 0, "clamped at the top");
|
||||
assert_eq!(scrolled(5, 5, -1.0), 5, "clamped at the bottom");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scroller_takes_presses_only_with_more_than_seven_offers() {
|
||||
let x = SHIFT + 97.0;
|
||||
|
||||
@@ -1,18 +1,30 @@
|
||||
//! OptiFine custom entity models over Horizon's mob model layers: `optifine/cem/<name>.jem` (or
|
||||
//! `cem/<name>/<name>.jem`, and Entity Model Features' `emf/cem/`, which wins) maps to one layer
|
||||
//! (`zombie.jem` to `zombie#main`, `sheep_wool.jem` to `sheep#wool`), its parts to the layer's
|
||||
//! vanilla parts, and its boxes join or replace theirs. A baby layer without its own model
|
||||
//! takes the adult's (OptiFine's fallbacks, and Entity Model Features' for horses too).
|
||||
//! vanilla parts, and its boxes join or replace theirs. A layer without its own model takes the
|
||||
//! one it falls back to: a baby's adult (OptiFine's fallbacks, and Entity Model Features' for
|
||||
//! horses too), and the few of [`FALLBACKS`], following their chains (`cat_baby_collar` to
|
||||
//! `cat_collar` to `cat`).
|
||||
//!
|
||||
//! Built when the packs load; a mob's draw only swaps the layer's part tree. Without CEM files
|
||||
//! nothing is built and every model stays vanilla's.
|
||||
//! nothing is built and every model stays vanilla's, and a file no layer uses isn't parsed.
|
||||
//!
|
||||
//! The seam with random entity textures (ETF): a `.jem`'s own `texture` replaces the layer's
|
||||
//! vanilla texture name, and the texture selection then works on that name as on vanilla's.
|
||||
//! vanilla texture name, and the random texture lookup then gets that name. Variants without
|
||||
//! rules (`green2.png` beside `green.png`) follow it; a `.properties` with rules for it is not
|
||||
//! matched yet, because the random-texture picks are made for the vanilla names. That half
|
||||
//! belongs with random textures. The `.jem` texture paints the layer the file is for, not the overlays drawn on that layer's model (a wolf's
|
||||
//! collar, a spider's eyes, the cracks on a wolf's armour): those keep their textures over the
|
||||
//! new geometry.
|
||||
|
||||
use super::{MobType, Part, Polygon};
|
||||
use horizon_optifine::cem::model::{Bone, EntityModel, MAX_BYTES, parse_jem};
|
||||
use std::{collections::HashMap, fs, path::Path, sync::Arc};
|
||||
use std::{
|
||||
collections::{BTreeMap, BTreeSet, HashMap},
|
||||
fs,
|
||||
path::Path,
|
||||
sync::Arc,
|
||||
};
|
||||
|
||||
/// The CEM models of the enabled packs, by model layer (`zombie#main`).
|
||||
#[derive(Default)]
|
||||
@@ -30,62 +42,85 @@ pub struct CemLayer {
|
||||
pub texture: Option<String>,
|
||||
}
|
||||
|
||||
/// How many fallbacks a chain follows (`cat_baby_collar`, `cat_collar`, `cat` takes two).
|
||||
const FALLBACK_STEPS: usize = 3;
|
||||
|
||||
impl CemModels {
|
||||
/// The `.jem` files under `root` applied to the layers of `types`.
|
||||
pub fn load(root: &Path, types: &HashMap<String, Arc<MobType>>) -> Self {
|
||||
let vanilla: HashMap<&str, &Arc<Part>> = types
|
||||
.values()
|
||||
.flat_map(|t| t.layers.iter().map(|(k, v)| (k.as_str(), v)))
|
||||
.collect();
|
||||
Self::over(root, &vanilla)
|
||||
}
|
||||
|
||||
/// The `.jem` files under `root` applied to the `vanilla` layers.
|
||||
fn over(root: &Path, vanilla: &HashMap<&str, &Arc<Part>>) -> Self {
|
||||
let files = jem_files(root);
|
||||
if files.is_empty() {
|
||||
return Self::default();
|
||||
}
|
||||
let mut read = |path: &str| read_small(&root.join(path));
|
||||
let mut layers = HashMap::new();
|
||||
// A baby layer without its own file takes the adult's: those go after every own file.
|
||||
let mut fallbacks = Vec::new();
|
||||
for (name, path) in &files {
|
||||
let Some(text) = read_small(&root.join(path)) else {
|
||||
log::warn!(
|
||||
"Horizon: custom entity model {path}: unreadable or over {MAX_BYTES} bytes"
|
||||
);
|
||||
continue;
|
||||
};
|
||||
let jem = match parse_jem(path, &text, &mut read) {
|
||||
Ok(jem) => jem,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: custom entity model {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
for warning in &jem.warnings {
|
||||
log::warn!("Horizon: custom entity model {warning}");
|
||||
}
|
||||
let jem = Arc::new(jem);
|
||||
Self::from_files(root, &files, &vanilla_layers(types))
|
||||
}
|
||||
|
||||
/// `files` ([`jem_files`]) applied to the `vanilla` layers.
|
||||
fn from_files(
|
||||
root: &Path,
|
||||
files: &BTreeMap<String, Vec<String>>,
|
||||
vanilla: &HashMap<&str, &Arc<Part>>,
|
||||
) -> Self {
|
||||
// Every model name a file can shape: the files' own, and the names that fall back to
|
||||
// them, a chain of fallbacks deep.
|
||||
let mut names: BTreeSet<String> = files.keys().cloned().collect();
|
||||
let mut frontier: Vec<String> = names.iter().cloned().collect();
|
||||
for _ in 0..FALLBACK_STEPS {
|
||||
frontier = frontier
|
||||
.iter()
|
||||
.flat_map(|name| fallback_names(name))
|
||||
.filter(|name| names.insert(name.clone()))
|
||||
.collect();
|
||||
}
|
||||
// The files each layer may take, best first: the first that parses wins. Every name of
|
||||
// the layer's own file in name order, then the files its names fall back to, nearest
|
||||
// first. Two names of one layer (`llama_decor` and `trader_llama_decor` are both
|
||||
// `llama#decor`, `skeleton_horse_armor` and `zombie_horse_armor` both
|
||||
// `undead_horse_armor#main`) give it the first's, and the second's if the first is
|
||||
// broken; within one name, EMF's file before OptiFine's.
|
||||
let mut plan: HashMap<String, Vec<(usize, &str)>> = HashMap::new();
|
||||
for name in &names {
|
||||
let key = layer_key(name);
|
||||
if let Some(part) = vanilla.get(key.as_str()) {
|
||||
layers.insert(key, CemLayer::build(part, &jem, name));
|
||||
if !vanilla.contains_key(key.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if !name.contains("_baby") {
|
||||
fallbacks.extend(baby_names(name).map(|baby| (baby, name, jem.clone())));
|
||||
let chain = std::iter::successors(Some(name.clone()), |n| falls_back_to(n));
|
||||
let candidates = plan.entry(key).or_default();
|
||||
for (depth, target) in chain.take(FALLBACK_STEPS + 1).enumerate() {
|
||||
if let Some((file, _)) = files.get_key_value(&target) {
|
||||
candidates.push((depth, file.as_str()));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (baby, adult, jem) in fallbacks {
|
||||
if files.contains_key(&baby) {
|
||||
// Each file parsed once, when a layer first tries it.
|
||||
let mut parsed: HashMap<&str, Option<EntityModel>> = HashMap::new();
|
||||
let mut layers = HashMap::new();
|
||||
for (key, mut candidates) in plan {
|
||||
candidates.sort_by_key(|&(depth, _)| depth);
|
||||
let found = candidates
|
||||
.iter()
|
||||
.flat_map(move |&(_, name)| {
|
||||
files[name].iter().map(move |path| (name, path.as_str()))
|
||||
})
|
||||
.find(|&(_, path)| {
|
||||
parsed
|
||||
.entry(path)
|
||||
.or_insert_with(|| load_jem(root, path))
|
||||
.is_some()
|
||||
});
|
||||
let Some((name, path)) = found else {
|
||||
continue;
|
||||
};
|
||||
let Some(Some(jem)) = parsed.get(path) else {
|
||||
continue;
|
||||
};
|
||||
let mut layer = CemLayer::build(vanilla[key.as_str()], jem, name);
|
||||
// A fallback onto another layer (the cat's collar from `cat`) takes the geometry
|
||||
// only: the layer keeps its own texture.
|
||||
if layer_suffix(&key) != layer_suffix(&layer_key(name)) {
|
||||
layer.texture = None;
|
||||
}
|
||||
let key = layer_key(&baby);
|
||||
if let Some(part) = vanilla.get(key.as_str()) {
|
||||
layers
|
||||
.entry(key)
|
||||
.or_insert_with(|| CemLayer::build(part, &jem, adult));
|
||||
}
|
||||
layers.insert(key, layer);
|
||||
}
|
||||
Self { layers }
|
||||
}
|
||||
@@ -95,6 +130,47 @@ impl CemModels {
|
||||
}
|
||||
}
|
||||
|
||||
/// The `.jem` at `path`, its warnings logged; None, with a warning, when it can't be read or
|
||||
/// parsed.
|
||||
fn load_jem(root: &Path, path: &str) -> Option<EntityModel> {
|
||||
let Some(text) = read_small(&root.join(path)) else {
|
||||
log::warn!("Horizon: custom entity model {path}: unreadable or over {MAX_BYTES} bytes");
|
||||
return None;
|
||||
};
|
||||
let jem = match parse_jem(path, &text, &mut |p: &str| read_small(&root.join(p))) {
|
||||
Ok(jem) => jem,
|
||||
Err(e) => {
|
||||
log::warn!("Horizon: custom entity model {e}");
|
||||
return None;
|
||||
}
|
||||
};
|
||||
for warning in &jem.warnings {
|
||||
log::warn!("Horizon: custom entity model {warning}");
|
||||
}
|
||||
if let Some(texture) = &jem.texture {
|
||||
if atlas_name(texture).is_none() {
|
||||
log::warn!(
|
||||
"Horizon: custom entity model {path}: its texture {texture} is outside \
|
||||
assets/minecraft/textures/, so not in the atlas: the vanilla one draws"
|
||||
);
|
||||
} else if !root.join(texture).is_file() {
|
||||
log::warn!(
|
||||
"Horizon: custom entity model {path}: its texture {texture} doesn't exist, \
|
||||
so not in the atlas: the vanilla one draws"
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(jem)
|
||||
}
|
||||
|
||||
/// Every model layer of `types` by its key.
|
||||
fn vanilla_layers(types: &HashMap<String, Arc<MobType>>) -> HashMap<&str, &Arc<Part>> {
|
||||
types
|
||||
.values()
|
||||
.flat_map(|t| t.layers.iter().map(|(k, v)| (k.as_str(), v)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A pack file's text, None when it's missing or larger than a model may read: its size is
|
||||
/// checked before anything is read.
|
||||
fn read_small(path: &Path) -> Option<String> {
|
||||
@@ -105,26 +181,65 @@ fn read_small(path: &Path) -> Option<String> {
|
||||
fs::read_to_string(path).ok()
|
||||
}
|
||||
|
||||
/// Every `.jem` of the CEM folders by model name, `emf/cem` over `optifine/cem`.
|
||||
fn jem_files(root: &Path) -> HashMap<String, String> {
|
||||
let mut out = HashMap::new();
|
||||
for base in ["assets/minecraft/optifine/cem", "assets/minecraft/emf/cem"] {
|
||||
/// Every `.jem` of the CEM folders by model name, sorted, each name's files best first:
|
||||
/// `emf/cem` before `optifine/cem` whichever form either uses, and within one folder the flat
|
||||
/// `<name>.jem` before `<name>/<name>.jem`.
|
||||
fn jem_files(root: &Path) -> BTreeMap<String, Vec<String>> {
|
||||
let mut out: BTreeMap<String, Vec<String>> = BTreeMap::new();
|
||||
for base in ["assets/minecraft/emf/cem", "assets/minecraft/optifine/cem"] {
|
||||
let Ok(entries) = fs::read_dir(root.join(base)) else {
|
||||
continue;
|
||||
};
|
||||
let (mut flat, mut nested) = (Vec::new(), Vec::new());
|
||||
for e in entries.flatten() {
|
||||
let name = e.file_name().to_string_lossy().into_owned();
|
||||
if let Some(model) = name.strip_suffix(".jem") {
|
||||
out.insert(model.to_owned(), format!("{base}/{name}"));
|
||||
} else if root.join(format!("{base}/{name}/{name}.jem")).is_file() {
|
||||
out.entry(name.clone())
|
||||
.or_insert_with(|| format!("{base}/{name}/{name}.jem"));
|
||||
// A directory named `x.jem` isn't a model.
|
||||
if e.path().is_file() {
|
||||
flat.push((model.to_owned(), format!("{base}/{name}")));
|
||||
}
|
||||
} else {
|
||||
let path = format!("{base}/{name}/{name}.jem");
|
||||
if root.join(&path).is_file() {
|
||||
nested.push((name, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
for (model, path) in flat.into_iter().chain(nested) {
|
||||
out.entry(model).or_default().push(path);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The CEM model names with a layer key of their own; any other name `n` is `n#main`.
|
||||
const LAYERS: &[(&str, &str)] = &[
|
||||
("sheep_wool", "sheep#wool"),
|
||||
("creeper_charge", "creeper#armor"),
|
||||
("slime_outer", "slime#outer"),
|
||||
("drowned_outer", "drowned#outer"),
|
||||
("stray_outer", "stray#outer"),
|
||||
("bogged_outer", "bogged#outer"),
|
||||
("wither_armor", "wither#armor"),
|
||||
("breeze_wind", "breeze#wind"),
|
||||
// Horizon draws both llamas' decor on one layer, and both undead horses' armour.
|
||||
("llama_decor", "llama#decor"),
|
||||
("trader_llama_decor", "llama#decor"),
|
||||
("cat_collar", "cat#collar"),
|
||||
("pig_saddle", "pig#saddle"),
|
||||
("strider_saddle", "strider#saddle"),
|
||||
("nautilus_saddle", "nautilus#saddle"),
|
||||
("tropical_fish_a", "tropical_fish_small#main"),
|
||||
("tropical_fish_b", "tropical_fish_large#main"),
|
||||
("tropical_fish_pattern_a", "tropical_fish_small#pattern"),
|
||||
("tropical_fish_pattern_b", "tropical_fish_large#pattern"),
|
||||
("puffer_fish_big", "pufferfish_big#main"),
|
||||
("puffer_fish_medium", "pufferfish_medium#main"),
|
||||
("puffer_fish_small", "pufferfish_small#main"),
|
||||
("skeleton_horse_armor", "undead_horse_armor#main"),
|
||||
("zombie_horse_armor", "undead_horse_armor#main"),
|
||||
];
|
||||
|
||||
/// The model layer a CEM model name reshapes.
|
||||
fn layer_key(name: &str) -> String {
|
||||
// `<model>_baby[_<layer>]`: the adult's layer on the baby model.
|
||||
@@ -133,31 +248,49 @@ fn layer_key(name: &str) -> String {
|
||||
let (model, layer) = adult.split_once('#').unwrap_or((&adult, "main"));
|
||||
return format!("{model}_baby#{layer}");
|
||||
}
|
||||
match name {
|
||||
"sheep_wool" => "sheep#wool",
|
||||
"creeper_charge" => "creeper#armor",
|
||||
"slime_outer" => "slime#outer",
|
||||
"drowned_outer" => "drowned#outer",
|
||||
"stray_outer" => "stray#outer",
|
||||
"bogged_outer" => "bogged#outer",
|
||||
"wither_armor" => "wither#armor",
|
||||
"breeze_wind" => "breeze#wind",
|
||||
"llama_decor" | "trader_llama_decor" => "llama#decor",
|
||||
"cat_collar" => "cat#collar",
|
||||
"pig_saddle" => "pig#saddle",
|
||||
"strider_saddle" => "strider#saddle",
|
||||
"nautilus_saddle" => "nautilus#saddle",
|
||||
"tropical_fish_a" => "tropical_fish_small#main",
|
||||
"tropical_fish_b" => "tropical_fish_large#main",
|
||||
"tropical_fish_pattern_a" => "tropical_fish_small#pattern",
|
||||
"tropical_fish_pattern_b" => "tropical_fish_large#pattern",
|
||||
"puffer_fish_big" => "pufferfish_big#main",
|
||||
"puffer_fish_medium" => "pufferfish_medium#main",
|
||||
"puffer_fish_small" => "pufferfish_small#main",
|
||||
"zombie_horse_armor" | "skeleton_horse_armor" => "undead_horse_armor#main",
|
||||
_ => return format!("{name}#main"),
|
||||
match LAYERS.iter().find(|(n, _)| *n == name) {
|
||||
Some((_, key)) => (*key).to_owned(),
|
||||
None => format!("{name}#main"),
|
||||
}
|
||||
.to_owned()
|
||||
}
|
||||
|
||||
/// The layer of a model layer key: `wool` of `sheep#wool`.
|
||||
fn layer_suffix(key: &str) -> &str {
|
||||
key.split_once('#').map_or(key, |(_, layer)| layer)
|
||||
}
|
||||
|
||||
/// Models without a file of their own that take another's (OptiFine's and Entity Model
|
||||
/// Features' tables), besides the babies: (model, the model it takes).
|
||||
const FALLBACKS: &[(&str, &str)] = &[
|
||||
("cat_collar", "cat"),
|
||||
("player_slim", "player"),
|
||||
("salmon_large", "salmon"),
|
||||
("salmon_small", "salmon"),
|
||||
];
|
||||
|
||||
/// The model `name` takes when it has no file: [`FALLBACKS`], or a baby's adult (`sheep_wool`
|
||||
/// for `sheep_baby_wool`). The baby nautilus has none (OptiFine's and Entity Model Features'
|
||||
/// tables).
|
||||
fn falls_back_to(name: &str) -> Option<String> {
|
||||
if let Some((_, to)) = FALLBACKS.iter().find(|(from, _)| *from == name) {
|
||||
return Some((*to).to_owned());
|
||||
}
|
||||
(name.contains("_baby") && !name.starts_with("nautilus_baby"))
|
||||
.then(|| name.replacen("_baby", "", 1))
|
||||
}
|
||||
|
||||
/// The model names that take `name`'s file when they have none ([`falls_back_to`] the other
|
||||
/// way): the babies of an adult and [`FALLBACKS`].
|
||||
fn fallback_names(name: &str) -> Vec<String> {
|
||||
let mut out: Vec<String> = FALLBACKS
|
||||
.iter()
|
||||
.filter(|(_, to)| *to == name)
|
||||
.map(|(from, _)| (*from).to_owned())
|
||||
.collect();
|
||||
if !name.contains("_baby") {
|
||||
out.extend(baby_names(name).filter(|baby| !baby.starts_with("nautilus_baby")));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The baby model names that fall back to the adult `name`: `_baby` before any of its
|
||||
@@ -286,8 +419,13 @@ fn vanilla_part(model: &str, part: &str) -> String {
|
||||
("cod" | "tropical_fish_a" | "tropical_fish_b", "fin_back" | "fin_top") => {
|
||||
Some("top_fin".into())
|
||||
}
|
||||
(_, "fin_right") if model.starts_with("puffer") => Some("right_blue_fin".into()),
|
||||
(_, "fin_left") if model.starts_with("puffer") => Some("left_blue_fin".into()),
|
||||
// The big and medium pufferfish have blue fins; the small one has plain `right_fin`s
|
||||
// (the fin animation in aquatic.rs).
|
||||
("puffer_fish_big" | "puffer_fish_medium", "fin_right") => Some("right_blue_fin".into()),
|
||||
("puffer_fish_big" | "puffer_fish_medium", "fin_left") => Some("left_blue_fin".into()),
|
||||
// RabbitModel's hind feet (rabbit.rs).
|
||||
("rabbit", "left_foot") => Some("left_hind_foot".into()),
|
||||
("rabbit", "right_foot") => Some("right_hind_foot".into()),
|
||||
(_, "fin_right") => Some("right_fin".into()),
|
||||
(_, "fin_left") => Some("left_fin".into()),
|
||||
(_, "fin_bottom") => Some("bottom_fin".into()),
|
||||
@@ -325,7 +463,7 @@ impl CemLayer {
|
||||
let mut entries = Vec::new();
|
||||
for p in &jem.parts {
|
||||
let target = vanilla_part(&model, &p.part);
|
||||
let target = if find(&out, &target) {
|
||||
let target = if super::render::find_part(&out, &target).is_some() {
|
||||
target
|
||||
} else {
|
||||
p.part.clone()
|
||||
@@ -369,10 +507,6 @@ fn atlas_name(path: &str) -> Option<String> {
|
||||
Some(rest.strip_suffix(".png").unwrap_or(rest).to_owned())
|
||||
}
|
||||
|
||||
fn find(part: &Part, name: &str) -> bool {
|
||||
part.name == name || part.children.iter().any(|c| find(c, name))
|
||||
}
|
||||
|
||||
/// The first part named `name`, depth first (`ModelPart.getChild` through the tree).
|
||||
fn find_mut<'a>(part: &'a mut Part, name: &str) -> Option<&'a mut Part> {
|
||||
if part.name == name {
|
||||
@@ -468,6 +602,9 @@ mod tests {
|
||||
("strider", "hair_left_top", "left_top_bristle"),
|
||||
("puffer_fish_big", "spikes_front_top", "top_front_fin"),
|
||||
("squid", "tentacle0", "tentacle0"),
|
||||
("puffer_fish_medium", "fin_left", "left_blue_fin"),
|
||||
("puffer_fish_small", "fin_right", "right_fin"),
|
||||
("rabbit", "left_foot", "left_hind_foot"),
|
||||
] {
|
||||
assert_eq!(vanilla_part(model, part), vanilla, "{model} {part}");
|
||||
}
|
||||
@@ -510,7 +647,7 @@ mod tests {
|
||||
]
|
||||
.into_iter()
|
||||
.collect();
|
||||
let models = CemModels::over(&root, &vanilla);
|
||||
let models = CemModels::from_files(&root, &jem_files(&root), &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
|
||||
let adult = models.layer("zombie#main").unwrap();
|
||||
@@ -530,4 +667,511 @@ mod tests {
|
||||
assert_eq!((leg.polygons.len(), leg.children.len()), (0, 1));
|
||||
assert!(models.layer("sheep#main").is_none());
|
||||
}
|
||||
|
||||
const BOX: &str = r#""boxes": [{"coordinates": [0, 0, 0, 1, 1, 1], "textureOffset": [0, 0]}]"#;
|
||||
|
||||
/// A `.jem` with one entry on `part`, attached or replacing it.
|
||||
fn jem(part: &str, attach: bool) -> String {
|
||||
format!(r#"{{"models": [{{"part": "{part}", "attach": {attach}, {BOX}}}]}}"#)
|
||||
}
|
||||
|
||||
/// A fresh pack folder for the test `test` holding `files` (paths under `assets/minecraft/`).
|
||||
fn pack(test: &str, files: &[(&str, &str)]) -> std::path::PathBuf {
|
||||
let root = std::env::temp_dir().join(format!("horizon-cem-{test}-{}", std::process::id()));
|
||||
let _ = fs::remove_dir_all(&root);
|
||||
for (path, text) in files {
|
||||
let path = root.join("assets/minecraft").join(path);
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, text).unwrap();
|
||||
}
|
||||
root
|
||||
}
|
||||
|
||||
/// The models of the pack `files` over the `layers`, the same tree for each.
|
||||
fn models(test: &str, files: &[(&str, &str)], layers: &[&str], tree: &Part) -> CemModels {
|
||||
let root = pack(test, files);
|
||||
let tree = Arc::new(tree.clone());
|
||||
let vanilla: HashMap<&str, &Arc<Part>> = layers.iter().map(|k| (*k, &tree)).collect();
|
||||
let models = CemModels::from_files(&root, &jem_files(&root), &vanilla);
|
||||
fs::remove_dir_all(&root).unwrap();
|
||||
models
|
||||
}
|
||||
|
||||
/// Whether the layer `key`'s part `name` was replaced (lost its own face) or had a box
|
||||
/// attached (kept it); None without the layer. Either way the box joins as a child.
|
||||
fn attached(models: &CemModels, key: &str, name: &str) -> Option<bool> {
|
||||
let part = get(&models.layer(key)?.model, name);
|
||||
assert_eq!(part.children.len(), 1, "{key} {name}: one custom box");
|
||||
Some(part.polygons.len() == 1)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn emf_files_win_and_a_flat_file_beats_its_folder() {
|
||||
let (replace, attach) = (jem("leg1", false), jem("leg1", true));
|
||||
let files = [
|
||||
// EMF's folder form over OptiFine's flat file.
|
||||
("optifine/cem/cow.jem", attach.as_str()),
|
||||
("emf/cem/cow/cow.jem", replace.as_str()),
|
||||
// One folder: the flat file over the folder form.
|
||||
("optifine/cem/pig.jem", replace.as_str()),
|
||||
("optifine/cem/pig/pig.jem", attach.as_str()),
|
||||
// A directory named `sheep.jem` is no file: the folder form stands.
|
||||
("optifine/cem/sheep.jem/readme.txt", ""),
|
||||
("optifine/cem/sheep/sheep.jem", attach.as_str()),
|
||||
];
|
||||
let tree = plane("root", vec![plane("right_hind_leg", vec![])]);
|
||||
let models = models(
|
||||
"precedence",
|
||||
&files,
|
||||
&["cow#main", "pig#main", "sheep#main"],
|
||||
&tree,
|
||||
);
|
||||
assert_eq!(attached(&models, "cow#main", "right_hind_leg"), Some(false));
|
||||
assert_eq!(attached(&models, "pig#main", "right_hind_leg"), Some(false));
|
||||
assert_eq!(
|
||||
attached(&models, "sheep#main", "right_hind_leg"),
|
||||
Some(true)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_baby_with_its_own_file_takes_it_not_the_adults() {
|
||||
let (adult, baby) = (jem("head", false), jem("head", true));
|
||||
let files = [
|
||||
("optifine/cem/zombie.jem", adult.as_str()),
|
||||
("optifine/cem/zombie_baby.jem", baby.as_str()),
|
||||
];
|
||||
let tree = plane("root", vec![plane("head", vec![])]);
|
||||
let models = models("baby", &files, &["zombie#main", "zombie_baby#main"], &tree);
|
||||
assert_eq!(attached(&models, "zombie#main", "head"), Some(false));
|
||||
assert_eq!(attached(&models, "zombie_baby#main", "head"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_names_of_one_layer_give_it_the_first_by_name() {
|
||||
let (llama, trader) = (jem("body", false), jem("body", true));
|
||||
let files = [
|
||||
("optifine/cem/trader_llama_decor.jem", trader.as_str()),
|
||||
("optifine/cem/llama_decor.jem", llama.as_str()),
|
||||
];
|
||||
let tree = plane("root", vec![plane("body", vec![])]);
|
||||
// Written trader first, `llama_decor` still shapes the layer, and the baby's (both
|
||||
// fall back to the adult files).
|
||||
let models = models("decor", &files, &["llama#decor", "llama_baby#decor"], &tree);
|
||||
assert_eq!(attached(&models, "llama#decor", "body"), Some(false));
|
||||
assert_eq!(attached(&models, "llama_baby#decor", "body"), Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_models_fall_back_but_the_baby_nautilus_does_not() {
|
||||
let (replace, attach) = (jem("body", false), jem("body", true));
|
||||
let cat = format!(
|
||||
r#"{{"texture": "textures/entity/cat/custom.png", "models": [
|
||||
{{"part": "body", {BOX}}}]}}"#
|
||||
);
|
||||
let files = [
|
||||
("optifine/cem/salmon.jem", replace.as_str()),
|
||||
("optifine/cem/salmon_large.jem", attach.as_str()),
|
||||
("optifine/cem/nautilus.jem", replace.as_str()),
|
||||
("optifine/cem/cat.jem", cat.as_str()),
|
||||
];
|
||||
let tree = plane("root", vec![plane("body", vec![])]);
|
||||
let models = models(
|
||||
"fallbacks",
|
||||
&files,
|
||||
&[
|
||||
"salmon#main",
|
||||
"salmon_small#main",
|
||||
"salmon_large#main",
|
||||
"nautilus#main",
|
||||
"nautilus_baby#main",
|
||||
"cat#main",
|
||||
"cat#collar",
|
||||
"cat_baby#collar",
|
||||
],
|
||||
&tree,
|
||||
);
|
||||
// The small salmon has no file: salmon.jem's. The large one has its own.
|
||||
assert_eq!(attached(&models, "salmon_small#main", "body"), Some(false));
|
||||
assert_eq!(attached(&models, "salmon_large#main", "body"), Some(true));
|
||||
assert_eq!(attached(&models, "nautilus#main", "body"), Some(false));
|
||||
assert!(models.layer("nautilus_baby#main").is_none());
|
||||
// The collar takes the cat's geometry, but its own collar texture stays.
|
||||
let texture = |key: &str| models.layer(key).unwrap().texture.clone();
|
||||
assert_eq!(texture("cat#main").as_deref(), Some("entity/cat/custom"));
|
||||
assert_eq!(attached(&models, "cat#collar", "body"), Some(false));
|
||||
assert_eq!(texture("cat#collar"), None);
|
||||
// So does the baby's, two fallbacks down: `cat_baby_collar`, `cat_collar`, `cat`.
|
||||
assert_eq!(attached(&models, "cat_baby#collar", "body"), Some(false));
|
||||
assert_eq!(texture("cat_baby#collar"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_file_gives_way_to_the_next() {
|
||||
let (replace, attach) = (jem("body", false), jem("body", true));
|
||||
let files = [
|
||||
// The first name of the layer is broken: the second shapes it.
|
||||
("optifine/cem/llama_decor.jem", "{"),
|
||||
("optifine/cem/trader_llama_decor.jem", attach.as_str()),
|
||||
// EMF's file is broken: OptiFine's stands.
|
||||
("emf/cem/cow.jem", "{"),
|
||||
("optifine/cem/cow.jem", replace.as_str()),
|
||||
// The baby's own file is broken: the adult's falls back onto it.
|
||||
("optifine/cem/zombie_baby.jem", "{"),
|
||||
("optifine/cem/zombie.jem", attach.as_str()),
|
||||
];
|
||||
let tree = plane("root", vec![plane("body", vec![])]);
|
||||
let models = models(
|
||||
"broken",
|
||||
&files,
|
||||
&["llama#decor", "cow#main", "zombie_baby#main"],
|
||||
&tree,
|
||||
);
|
||||
assert_eq!(attached(&models, "llama#decor", "body"), Some(true));
|
||||
assert_eq!(attached(&models, "cow#main", "body"), Some(false));
|
||||
assert_eq!(attached(&models, "zombie_baby#main", "body"), Some(true));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_part_is_skipped_and_the_rest_applies() {
|
||||
let file =
|
||||
format!(r#"{{"models": [{{"part": "wings", {BOX}}}, {{"part": "head", {BOX}}}]}}"#);
|
||||
let tree = plane("root", vec![plane("head", vec![])]);
|
||||
let models = models(
|
||||
"unknown",
|
||||
&[("optifine/cem/zombie.jem", file.as_str())],
|
||||
&["zombie#main"],
|
||||
&tree,
|
||||
);
|
||||
assert_eq!(attached(&models, "zombie#main", "head"), Some(false));
|
||||
fn custom(p: &Part) -> usize {
|
||||
usize::from(p.name.starts_with("cem#")) + p.children.iter().map(custom).sum::<usize>()
|
||||
}
|
||||
assert_eq!(custom(&models.layer("zombie#main").unwrap().model), 1);
|
||||
}
|
||||
|
||||
/// A mob type `id` of `species` whose body and `layers` are all `tree`, with the vanilla
|
||||
/// `texture`.
|
||||
fn mob_kind(
|
||||
id: &str,
|
||||
species: super::super::Species,
|
||||
anim: super::super::Anim,
|
||||
texture: &str,
|
||||
layers: &[&str],
|
||||
tree: &Part,
|
||||
) -> Arc<MobType> {
|
||||
let mut kind = (*super::super::sensor_scan_tests::kind()).clone();
|
||||
kind.id = id.into();
|
||||
kind.texture = texture.into();
|
||||
kind.species = species;
|
||||
kind.anim = anim;
|
||||
kind.model = Arc::new(tree.clone());
|
||||
kind.layers = layers
|
||||
.iter()
|
||||
.map(|k| ((*k).to_owned(), Arc::new(tree.clone())))
|
||||
.collect();
|
||||
Arc::new(kind)
|
||||
}
|
||||
|
||||
/// How many vertices `render` draws of `mob` in each of `sprites`, laid far apart in the
|
||||
/// atlas: the sprite a vertex's UV falls in says what it drew with. `sprites[0]`, the
|
||||
/// `.jem`'s texture, is in the atlas only with `custom`.
|
||||
fn sprite_counts(
|
||||
mob: &super::super::Mob,
|
||||
models: &Arc<CemModels>,
|
||||
sprites: &[&str],
|
||||
custom: bool,
|
||||
) -> Vec<usize> {
|
||||
let data = super::super::MobData {
|
||||
states: Vec::new(),
|
||||
placements: Default::default(),
|
||||
heightmaps: Default::default(),
|
||||
animations: Default::default(),
|
||||
biome_tags: Default::default(),
|
||||
entity_tags: Default::default(),
|
||||
villager_hats: Default::default(),
|
||||
cem: std::sync::RwLock::new(models.clone()),
|
||||
random_textures: Default::default(),
|
||||
pois: Default::default(),
|
||||
species_placements: Default::default(),
|
||||
};
|
||||
// Sprite i spans u and v from 0.2 i to 0.2 i + 0.1.
|
||||
let atlas = |name: &str| -> Option<[f32; 4]> {
|
||||
let i = sprites.iter().position(|s| *s == name)?;
|
||||
let lo = i as f32 * 0.2;
|
||||
(custom || i != 0).then_some([lo, lo, lo + 0.1, lo + 0.1])
|
||||
};
|
||||
let mut vertices = Vec::new();
|
||||
super::super::render::render(
|
||||
mob,
|
||||
0.0,
|
||||
glam::DVec3::ZERO,
|
||||
&atlas,
|
||||
0xf0f0,
|
||||
&data,
|
||||
&mut vertices,
|
||||
);
|
||||
let mut counts = vec![0; sprites.len()];
|
||||
for v in &vertices {
|
||||
let u = v.uv[0];
|
||||
let i = ((u + 0.05) / 0.2).floor() as usize;
|
||||
assert!(
|
||||
i < sprites.len() && u - i as f32 * 0.2 <= 0.1 + 1e-4,
|
||||
"a UV between the sprites: {u}"
|
||||
);
|
||||
counts[i] += 1;
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
/// A spider whose `.jem` replaces its body: the body draws with the `.jem`'s texture, the
|
||||
/// eyes (an overlay on the body's model) with theirs on the new geometry, and a texture the
|
||||
/// atlas lacks leaves the vanilla one. A sheep's wool, a layer with a model of its own,
|
||||
/// takes its `.jem`'s texture.
|
||||
#[test]
|
||||
fn a_jem_texture_paints_its_layer_and_leaves_the_overlays() {
|
||||
use super::super::{Anim, Mob, Species};
|
||||
let tree = |part: &str| Part {
|
||||
name: "root".into(),
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: Vec::new(),
|
||||
children: vec![plane(part, vec![])],
|
||||
};
|
||||
let textured = |texture: &str| {
|
||||
format!(
|
||||
r#"{{"texture": "textures/{texture}.png", "models": [
|
||||
{{"part": "body", {BOX}}}]}}"#
|
||||
)
|
||||
};
|
||||
let spider = textured("entity/spider/custom");
|
||||
let cem = Arc::new(models(
|
||||
"render",
|
||||
&[("optifine/cem/spider.jem", spider.as_str())],
|
||||
&["spider#main"],
|
||||
&tree("body1"),
|
||||
));
|
||||
let mob = Mob::new(
|
||||
1,
|
||||
mob_kind(
|
||||
"minecraft:spider",
|
||||
Species::Spider,
|
||||
Anim::Spider,
|
||||
"entity/spider/spider",
|
||||
&["spider#main"],
|
||||
&tree("body1"),
|
||||
),
|
||||
horizon_core::geometry::Vec3::ZERO,
|
||||
0.0,
|
||||
);
|
||||
let sprites = [
|
||||
"entity/spider/custom",
|
||||
"entity/spider/spider_eyes",
|
||||
"entity/spider/spider",
|
||||
];
|
||||
// The custom box's six faces, four corners each, for the body and again for the eyes;
|
||||
// the replaced body part's own face is gone.
|
||||
assert_eq!(sprite_counts(&mob, &cem, &sprites, true), [24, 24, 0]);
|
||||
assert_eq!(sprite_counts(&mob, &cem, &sprites, false), [0, 24, 24]);
|
||||
|
||||
let wool = textured("entity/sheep/custom_wool");
|
||||
let cem = Arc::new(models(
|
||||
"render-sheep",
|
||||
&[("optifine/cem/sheep_wool.jem", wool.as_str())],
|
||||
&["sheep#main", "sheep#wool"],
|
||||
&tree("body"),
|
||||
));
|
||||
let mob = Mob::new(
|
||||
1,
|
||||
mob_kind(
|
||||
"minecraft:sheep",
|
||||
Species::Sheep,
|
||||
Anim::Sheep,
|
||||
"entity/sheep/sheep",
|
||||
&["sheep#main", "sheep#wool"],
|
||||
&tree("body"),
|
||||
),
|
||||
horizon_core::geometry::Vec3::ZERO,
|
||||
0.0,
|
||||
);
|
||||
let sprites = [
|
||||
"entity/sheep/custom_wool",
|
||||
"entity/sheep/sheep_wool",
|
||||
"entity/sheep/sheep",
|
||||
];
|
||||
// The wool's custom box in the `.jem`'s texture; the vanilla body's one face.
|
||||
assert_eq!(sprite_counts(&mob, &cem, &sprites, true), [24, 0, 4]);
|
||||
assert_eq!(sprite_counts(&mob, &cem, &sprites, false), [0, 24, 4]);
|
||||
}
|
||||
|
||||
/// A wolf armour `.jem`'s texture paints the armour, the first layer on its model, and
|
||||
/// not the cracks drawn over it on the same model.
|
||||
#[test]
|
||||
fn armour_cracks_keep_their_texture() {
|
||||
use super::super::{Anim, Mob, Species, WOLF_ARMOR_DURABILITY};
|
||||
let tree = Part {
|
||||
name: "root".into(),
|
||||
pose: [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.0],
|
||||
polygons: Vec::new(),
|
||||
children: vec![plane("body", vec![])],
|
||||
};
|
||||
let file = format!(
|
||||
r#"{{"texture": "textures/entity/wolf/custom_armor.png", "models": [
|
||||
{{"part": "body", {BOX}}}]}}"#
|
||||
);
|
||||
let layers = ["wolf#main", "wolf_armor#main"];
|
||||
let cem = Arc::new(models(
|
||||
"cracks",
|
||||
&[("optifine/cem/wolf_armor.jem", file.as_str())],
|
||||
&layers,
|
||||
&tree,
|
||||
));
|
||||
let mut mob = Mob::new(
|
||||
1,
|
||||
mob_kind(
|
||||
"minecraft:wolf",
|
||||
Species::Wolf,
|
||||
Anim::Wolf,
|
||||
"entity/wolf/wolf",
|
||||
&layers,
|
||||
&tree,
|
||||
),
|
||||
horizon_core::geometry::Vec3::ZERO,
|
||||
0.0,
|
||||
);
|
||||
mob.extra.tame = true;
|
||||
mob.extra.head = Some("minecraft:wolf_armor".into());
|
||||
// Worn through: the high cracks.
|
||||
mob.extra.body_damage = WOLF_ARMOR_DURABILITY;
|
||||
let sprites = [
|
||||
"entity/wolf/custom_armor",
|
||||
"entity/equipment/wolf_body/armadillo_scute",
|
||||
"entity/wolf/wolf_armor_crackiness_high",
|
||||
"entity/wolf/wolf_collar",
|
||||
];
|
||||
// The armour's custom box in the `.jem`'s texture, the cracks' over the same box in
|
||||
// theirs, and the collar on the vanilla body (whose own texture isn't in the atlas).
|
||||
assert_eq!(sprite_counts(&mob, &cem, &sprites, true), [24, 0, 24, 4]);
|
||||
assert_eq!(sprite_counts(&mob, &cem, &sprites, false), [0, 24, 24, 4]);
|
||||
}
|
||||
|
||||
/// Every layer of [`LAYERS`] and [`FALLBACKS`] is in Horizon's own models, every baby layer
|
||||
/// there is one a baby model name maps to, and so are the parts a sample of part names is
|
||||
/// renamed to.
|
||||
#[test]
|
||||
#[ignore = "requires prepared entity models in HORIZON_TEST_RESOURCES"]
|
||||
fn names_reach_horizons_models() {
|
||||
let root = std::env::var_os("HORIZON_TEST_RESOURCES").expect("HORIZON_TEST_RESOURCES");
|
||||
let types: HashMap<String, Arc<MobType>> = super::super::MobTypes::load(Path::new(&root))
|
||||
.unwrap()
|
||||
.types
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, Arc::new(v)))
|
||||
.collect();
|
||||
let vanilla = vanilla_layers(&types);
|
||||
let mut missing = Vec::new();
|
||||
for (name, key) in LAYERS {
|
||||
if !vanilla.contains_key(key) {
|
||||
missing.push(format!("{name}: no layer {key}"));
|
||||
}
|
||||
}
|
||||
for name in FALLBACKS.iter().flat_map(|(name, takes)| [*name, *takes]) {
|
||||
let key = layer_key(name);
|
||||
if !vanilla.contains_key(key.as_str()) {
|
||||
missing.push(format!("{name}: no layer {key}"));
|
||||
}
|
||||
}
|
||||
// `sheep_baby#wool` is `sheep_baby_wool`'s: the baby name of a name of `sheep#wool`.
|
||||
for key in vanilla.keys() {
|
||||
let Some((model, layer)) = key.split_once('#') else {
|
||||
continue;
|
||||
};
|
||||
let Some(adult) = model.strip_suffix("_baby") else {
|
||||
continue;
|
||||
};
|
||||
let adult_key = format!("{adult}#{layer}");
|
||||
let reached = LAYERS
|
||||
.iter()
|
||||
.map(|(name, _)| *name)
|
||||
.chain([adult])
|
||||
.filter(|name| layer_key(name) == adult_key)
|
||||
.flat_map(baby_names)
|
||||
.any(|baby| layer_key(&baby) == *key);
|
||||
if !reached {
|
||||
missing.push(format!("{key}: no baby model name maps to it"));
|
||||
}
|
||||
}
|
||||
// A sample of the renamed parts.
|
||||
for (model, part) in [
|
||||
("zombie", "headwear"),
|
||||
("cow", "leg1"),
|
||||
("cow", "leg4"),
|
||||
("camel", "back_left_leg"),
|
||||
("camel", "front_right_leg"),
|
||||
("spider", "leg1"),
|
||||
("spider", "leg8"),
|
||||
("spider", "neck"),
|
||||
("spider", "body"),
|
||||
("villager", "headwear2"),
|
||||
("villager", "bodywear"),
|
||||
("witch", "headwear2"),
|
||||
("witch", "bodywear"),
|
||||
("chicken", "bill"),
|
||||
("chicken", "chin"),
|
||||
("llama", "chest_right"),
|
||||
("llama", "chest_left"),
|
||||
("turtle", "body2"),
|
||||
("ravager", "jaw"),
|
||||
("bee", "body"),
|
||||
("bee", "torso"),
|
||||
("warden", "body"),
|
||||
("warden", "torso"),
|
||||
("snow_golem", "body"),
|
||||
("snow_golem", "body_bottom"),
|
||||
("snow_golem", "right_hand"),
|
||||
("snow_golem", "left_hand"),
|
||||
("slime", "body"),
|
||||
("magma_cube", "core"),
|
||||
("magma_cube", "segment1"),
|
||||
("blaze", "stick1"),
|
||||
("ghast", "tentacle1"),
|
||||
("squid", "tentacle1"),
|
||||
("silverfish", "body1"),
|
||||
("silverfish", "wing1"),
|
||||
("endermite", "body1"),
|
||||
("guardian", "spine1"),
|
||||
("guardian", "tail1"),
|
||||
("wither", "body1"),
|
||||
("wither", "head1"),
|
||||
("phantom", "left_wing"),
|
||||
("phantom", "tail2"),
|
||||
("cat", "tail"),
|
||||
("ocelot", "tail"),
|
||||
("bat", "outer_right_wing"),
|
||||
("cod", "fin_back"),
|
||||
("cod", "fin_right"),
|
||||
("cod", "tail"),
|
||||
("tropical_fish_b", "fin_top"),
|
||||
("tropical_fish_b", "fin_bottom"),
|
||||
("puffer_fish_big", "fin_right"),
|
||||
("puffer_fish_medium", "fin_left"),
|
||||
("puffer_fish_small", "fin_right"),
|
||||
("puffer_fish_big", "spikes_front_top"),
|
||||
("rabbit", "left_foot"),
|
||||
("rabbit", "right_foot"),
|
||||
("sniffer", "middle_left_leg"),
|
||||
("strider", "hair_left_top"),
|
||||
("horse", "neck"),
|
||||
("horse", "mouth"),
|
||||
] {
|
||||
let key = layer_key(model);
|
||||
let target = vanilla_part(model, part);
|
||||
let found = vanilla
|
||||
.get(key.as_str())
|
||||
.is_some_and(|tree| super::super::render::find_part(tree, &target).is_some());
|
||||
if !found {
|
||||
missing.push(format!("{model} {part}: no part {target} in {key}"));
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "{missing:#?}");
|
||||
}
|
||||
}
|
||||
@@ -1656,30 +1656,67 @@ fn cem_key(model_key: &str) -> &str {
|
||||
}
|
||||
/// Every texture [`render_visible`] may look up for `m` now (keep the two in step: a texture
|
||||
/// drawn but not listed here never gets a random pick): the body's and each layer's, a
|
||||
/// custom model's own texture in their place, and the warden's glowing layers (whether they
|
||||
/// glow this frame or not). Random textures choose a variant for each.
|
||||
/// custom model's own texture beside them (`cem_texture` falls back to the vanilla one when
|
||||
/// the atlas lacks it, and only the layer a `.jem` was made for takes it, `paints_with_jem`),
|
||||
/// and the warden's glowing layers (whether they glow this frame or not). Random textures
|
||||
/// choose a variant for each.
|
||||
pub fn drawn_textures(m: &Mob, data: &MobData) -> Vec<String> {
|
||||
fn push(names: &mut Vec<String>, custom: Option<&super::cem::CemLayer>, vanilla: String) {
|
||||
if let Some(texture) = custom.and_then(|c| c.texture.clone()) {
|
||||
names.push(texture);
|
||||
}
|
||||
names.push(vanilla);
|
||||
}
|
||||
let cem = data
|
||||
.cem
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let custom = |key: &str, texture: String| {
|
||||
cem.layer(key)
|
||||
.and_then(|c| c.texture.clone())
|
||||
.unwrap_or(texture)
|
||||
};
|
||||
let (model_key, texture) = body(m);
|
||||
let mut names = vec![custom(cem_key(&model_key), texture)];
|
||||
for layer in layers(m, m.age as f32) {
|
||||
names.push(custom(&layer.model, layer.texture));
|
||||
}
|
||||
let cem_key = cem_key(&model_key);
|
||||
let mut names = Vec::new();
|
||||
push(&mut names, cem.layer(cem_key), texture);
|
||||
let mut all_layers = layers(m, m.age as f32);
|
||||
if m.species() == Species::Warden {
|
||||
for (model, texture, _) in super::warden::emissive_layers(m, 0.0) {
|
||||
names.push(custom(model, texture.to_owned()));
|
||||
all_layers.push(Layer {
|
||||
model: model.into(),
|
||||
texture: texture.into(),
|
||||
color: 0,
|
||||
full_bright: true,
|
||||
scroll: 0.0,
|
||||
});
|
||||
}
|
||||
}
|
||||
let body_keys = [model_key.as_str(), cem_key];
|
||||
for (i, layer) in all_layers.iter().enumerate() {
|
||||
let custom = cem
|
||||
.layer(&layer.model)
|
||||
.filter(|_| paints_with_jem(&all_layers, i, body_keys));
|
||||
push(&mut names, custom, layer.texture.clone());
|
||||
}
|
||||
names
|
||||
}
|
||||
/// The texture a model drawn as `custom` takes: the `.jem`'s when the atlas has it, else
|
||||
/// `vanilla` (a texture the atlas lacks would hide the model).
|
||||
fn cem_texture<'a>(
|
||||
custom: Option<&'a super::cem::CemLayer>,
|
||||
vanilla: &'a str,
|
||||
atlas: Atlas<'_>,
|
||||
) -> &'a str {
|
||||
custom
|
||||
.and_then(|c| c.texture.as_deref())
|
||||
.filter(|t| atlas(t).is_some())
|
||||
.unwrap_or(vanilla)
|
||||
}
|
||||
/// Whether `layers[i]` is the layer its model's `.jem` was made for, so the `.jem`'s texture
|
||||
/// paints it: the first drawn with that model (`sheep#wool`, a wolf's armour). Later layers
|
||||
/// on the same model are overlays (the armour's cracks), like those on the body's model
|
||||
/// (`body`: eyes, collars, a villager's type and profession), and keep their own textures
|
||||
/// over the new geometry.
|
||||
fn paints_with_jem(layers: &[Layer], i: usize, body: [&str; 2]) -> bool {
|
||||
let key = layers[i].model.as_str();
|
||||
!body.contains(&key) && layers[..i].iter().all(|l| l.model != key)
|
||||
}
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn render_visible(
|
||||
m: &Mob,
|
||||
@@ -1702,20 +1739,17 @@ fn render_visible(
|
||||
.read()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let cem_key = cem_key(&model_key);
|
||||
let body_animation = cem.layer(cem_key).and_then(|c| c.animation.as_ref());
|
||||
let (model, texture) = match cem.layer(cem_key) {
|
||||
Some(custom) => (
|
||||
custom.model.clone(),
|
||||
custom.texture.clone().unwrap_or(texture),
|
||||
),
|
||||
None => (
|
||||
m.kind
|
||||
.layer(&model_key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| m.kind.model.clone()),
|
||||
texture,
|
||||
),
|
||||
let custom_body = cem.layer(cem_key);
|
||||
let body_animation = custom_body.and_then(|c| c.animation.as_ref());
|
||||
let model = match custom_body {
|
||||
Some(custom) => custom.model.clone(),
|
||||
None => m
|
||||
.kind
|
||||
.layer(&model_key)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| m.kind.model.clone()),
|
||||
};
|
||||
let texture = cem_texture(custom_body, &texture, atlas);
|
||||
// OptiFine random textures: the sprite of the variant the mob wears for a texture, when
|
||||
// the atlas has it. The texture keeps its name for everything else.
|
||||
let random = data
|
||||
@@ -1736,7 +1770,7 @@ fn render_visible(
|
||||
let draw_body = |out: &mut Vec<Vertex>| {
|
||||
// A player or mannequin whose skin has no atlas sprite (an evicted or
|
||||
// never stored download) draws with its default skin, not invisibly.
|
||||
let uv = random_uv(&texture).or_else(|| atlas(&texture)).or_else(|| {
|
||||
let uv = random_uv(texture).or_else(|| atlas(texture)).or_else(|| {
|
||||
if avatar(m) {
|
||||
atlas(&m.kind.texture)
|
||||
} else {
|
||||
@@ -1786,21 +1820,24 @@ fn render_visible(
|
||||
// One Poses refilled for every layer (armour draws up to seven): it keeps
|
||||
// its maps' capacity.
|
||||
let mut layer_poses = Poses::default();
|
||||
for mut layer in all_layers {
|
||||
let body_keys = [model_key.as_str(), cem_key];
|
||||
for (i, layer) in all_layers.iter().enumerate() {
|
||||
let custom = cem.layer(&layer.model);
|
||||
// `layer.texture` stays the vanilla name (the villager hats below look for its
|
||||
// `/type/` folder).
|
||||
let texture = cem_texture(
|
||||
custom.filter(|_| paints_with_jem(&all_layers, i, body_keys)),
|
||||
&layer.texture,
|
||||
atlas,
|
||||
);
|
||||
let part = match custom {
|
||||
Some(custom) => {
|
||||
if let Some(texture) = &custom.texture {
|
||||
layer.texture.clone_from(texture);
|
||||
}
|
||||
custom.model.clone()
|
||||
}
|
||||
Some(custom) => custom.model.clone(),
|
||||
None => match m.kind.layer(&layer.model) {
|
||||
Some(part) => part.clone(),
|
||||
None => continue,
|
||||
},
|
||||
};
|
||||
let Some(uv) = random_uv(&layer.texture).or_else(|| atlas(&layer.texture)) else {
|
||||
let Some(uv) = random_uv(texture).or_else(|| atlas(texture)) else {
|
||||
continue;
|
||||
};
|
||||
layer_poses.reset(&part);
|
||||
@@ -1844,7 +1881,7 @@ fn render_visible(
|
||||
overlay,
|
||||
out,
|
||||
);
|
||||
if translucent_layer(m, &layer) {
|
||||
if translucent_layer(m, layer) {
|
||||
mark_oit(&mut out[start..]);
|
||||
}
|
||||
// The eyes render type's glowing eyes also shine in HDR output, at
|
||||
@@ -1956,7 +1993,8 @@ pub fn part_matrix(
|
||||
.iter()
|
||||
.find_map(|c| part_matrix(c, m, poses, name, x_offset))
|
||||
}
|
||||
fn find_part<'a>(part: &'a Part, name: &str) -> Option<&'a Part> {
|
||||
/// The first part named `name`, depth first.
|
||||
pub(super) fn find_part<'a>(part: &'a Part, name: &str) -> Option<&'a Part> {
|
||||
if part.name == name {
|
||||
return Some(part);
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ command above to inspect the selection rather than relying on historical counts.
|
||||
| 20 | Mob models and animation (78) | `L/mobs/render.rs`, `L/mobs/anim.rs`, `L/mobs/body_anim.rs`, `L/mobs/keyframes.rs`, `R/entity.wgsl` | entity_model |
|
||||
| 21 | Block behaviour, server side (78) | `W/level/` (`mod.rs`, `tick.rs`, `hooks.rs`, `data.rs`, `redstone.rs`, `place.rs`), `G/block_entity_ticks.rs`, `G/moving_pistons.rs` | block_update, redstone, random_tick, simple_block, piston_movement, block_entity_tickers |
|
||||
| 22 | Player movement and collision (75) | `L/motion.rs` + `L/motion/`, `R/collisions.rs`, `W/entity_move.rs`, `W/player_travel.rs`, `W/collision.rs` | collision, entity_move, player_travel, living_motion, climbing, swimming; `python3 tools/test_native_scene_motion.py` |
|
||||
| 23 | Resource packs and models (65) | `R/packs.rs` + `R/packs/`, `R/pack_screen.rs`, `R/atlas_sources.rs`, `R/sprites.rs`, `R/model_library.rs`, `G/packs.rs`, `crates/horizon-assets/` | resource_pack, pack_selection, pack_screen, model_resolution, item_model, mipmap |
|
||||
| 23 | Resource packs and models (65) | `R/packs.rs` + `R/packs/`, `R/pack_screen.rs`, `R/atlas_sources.rs`, `R/sprites.rs`, `R/model_library.rs`, `G/packs.rs`, `L/mobs/cem.rs`, `L/mobs/cem_anim.rs`, `crates/horizon-assets/`, `crates/horizon-optifine/` | resource_pack, pack_selection, pack_screen, model_resolution, item_model, mipmap |
|
||||
| 24 | Container menus and inventory (61) | `L/menu.rs`, `L/container_screen.rs`, `R/containers.rs`, `L/creative.rs`; game.rs `fn menu_ops`, `fn click_inventory` | menu_click, container_screen, creative, lan_e2e containers clicks |
|
||||
| 25 | Workstations (60) | `L/stations.rs`, `G/stations.rs`, `I/enchant.rs`, `R/brewing.rs` | enchanting, screen_layout, lan_e2e brewing loom stonecutter grindstone anvil smithing |
|
||||
| 26 | Crafting and the recipe book (59) | `L/crafting.rs`, `L/recipe_book.rs`, `L/recipe_book_screen.rs`, `G/recipe_book.rs`, `G/machines.rs`, `G/machines/crafter.rs` | recipe, recipe_book, recipe_book_view, place_recipe, ghost_recipe, crafter |
|
||||
|
||||
+1
-1
@@ -35,7 +35,7 @@ Every row has a one-line description and its cost in frame time (none, low, medi
|
||||
|
||||
**Translucent batching** (only in the file for now: `translucentBatching` in `horizon-graphics.txt`, off by default; edit it with the game closed, it applies at the next start) gives water, glass, ice and other translucent quads a vertex buffer of their own, with pages up to 2 GiB, and their sorted indices the same: the back-to-front list then stays in one page and goes out in a few draw calls instead of switching pages every few sections. The picture is the same; it costs a little video memory, as solid and translucent quads no longer share free space. It needs indirect multi-draw that isn't OpenGL's (which loops over the entries on the CPU), and devices whose vanilla frames draw flat (Metal, Intel Gen11 and Gen12 on Vulkan) don't offer it. A `shaders/` pack's terrain and shadow passes, and the `[shadow]` pass of one of Horizon's own packs, draw flat where the device can, reading translucent quads beside their section's solid ones: while such a pack runs they go back there. Such a pack picked at the start has the world laid out for it from the first; switching to or from one later meshes the world again.
|
||||
|
||||
**HDR** (off by default) lives on its own screen, Graphics: HDR, behind **HDR Settings...** under Display, which is greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); there the switch comes first and the sliders below it only while it is on. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), asked and then watched while HDR output is on (with HDR off the game doesn't ask), so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; these often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Brightness and Paper White full screen, one test picture at a time, drawn by the last pass straight in nits above a band of controls: Horizon's logo at the slider on black, lowered until it disappears; the logo at the slider inside a tenth of the screen at 10 000 nits (small, so the display doesn't dim), raised until it disappears; a white card, set to a white that's comfortable in the light the player plays in. Save writes the three, Cancel or Escape keeps what was set, and a step left untouched keeps Auto. The Peak step needs a path that doesn't tone-map: on KWin the logo never disappears, so where the system reports its values the button reads **Calibrate Anyway...** and says the display is already set up (**Use Automatic Setup** once they were changed by hand). Where nothing is reported, Auto is 203 and 1000 nits, and the Graphics: HDR screen asks the player to calibrate. **Black Level** (0 by default, for an OLED's true black) is the display's darkest black, in nits: the last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ, which leaves the peak where it is and fades out by the fourth power above black, and the metadata gives it as the minimum luminance; at 0 the picture is unchanged. **UI Brightness** (100% by default) sets the HUD and menus against paper white: once the world is kept for the last pass, the frame's alpha is zeroed, everything drawn after raises it where it covers the world, the first-person hand's is then scaled down to 1/255 (it stays at the world's brightness), and the last pass scales the rest by UI Brightness and lifts none of it (so text over the sun stays text). With HDR on, F2 writes vanilla's screenshot as always and an HDR10 copy beside it (`…-hdr.png`): the HDR pass drawn once more into a 10-bit PQ target, whatever the swapchain is, saved as a 16-bit PNG tagged BT.2020 with PQ (`cICP`, PNG's third edition) and the peak and paper white as content light levels (`cLLI`); viewers that read `cICP` show it in HDR, others show it washed out. Not lifted yet: translucent blocks (the nether portal), distant LOD terrain.
|
||||
**HDR** (off by default) lives on its own screen, Graphics: HDR, behind **HDR Settings...** under Display, which is greyed where the display path offers no HDR output (so players without an HDR display don't take its settings for their display's); there the switch comes first and the sliders below it only while it is on. It presents to an HDR10 swapchain (`Rgb10a2Unorm`, BT.2020 with the PQ curve) or, where only that is offered, an scRGB one (`Rgba16Float`, extended linear sRGB), on displays and compositors that offer either; `HORIZON_HDR_OUTPUT=pq` or `scrgb` allows only one. The frame is drawn exactly as in SDR, in vanilla's gamma space, and a last pass (`hdr.wgsl`) decodes it to linear light at the **Paper White** and lifts light sources (blocks that emit light, lava, the sun and the moon) towards the **Peak Brightness**; everything else stays as in SDR. The mesher puts the light a block's face emits in its vertex colour's alpha (`EMISSION_BITS`), the frame's alpha carries the mark, and only pixels that still show the world before the hand and HUD are lifted. On Vulkan, `horizon-hdr` sends HDR metadata (MaxCLL, mastering luminance), which wgpu doesn't: without it a compositor may clip everything above SDR white. Paper White and Peak Brightness are **Auto** by default: they follow what the system reports for the display. On Wayland that's the compositor's preferred image description of the window (`wp_color_management_v1`, in `horizon-hdr`'s `wayland.rs`), asked and then watched while HDR output is on (with HDR off the game doesn't ask), so a move to another output or a recalibrated desktop applies at once. KWin maps the content's reference white (203 nits) onto the desktop's SDR white, so Auto puts Paper White at 203 and Peak Brightness at the display's peak over that ratio; Hyprland (0.57) doesn't: it reports the standard 203 as reference white, so Auto puts Paper White at 203, and it shows HDR windows below its own SDR white (`sdr_max_luminance`, which it doesn't report): in a test, a white browser page was brighter than both the game's white and a 1000-nit patch in mpv. Other compositors are unchecked. On Windows Auto takes what wgpu reads from the OS (`Surface::display_hdr_info`: DXGI's `MaxLuminance` and the SDR white level of Windows' SDR content brightness slider, both absolute nits), read again every 2 seconds since no event says they changed; these often come from the display's EDID and may run optimistic, and this is untested so far. **Calibrate...** (`hdr_calibration.rs`) sets Black Level, Peak Brightness and Paper White full screen, one test picture at a time, drawn by the last pass straight in nits above a band of controls: Horizon's logo at the slider on black, lowered until it disappears; the logo at the slider inside a tenth of the screen at 10 000 nits (small, so the display doesn't dim), raised until it disappears; a white card, set to a white that's comfortable in the light the player plays in. Save writes the three, Cancel or Escape keeps what was set, and a step left untouched keeps Auto. The Peak step needs a path that doesn't tone-map: on KWin the logo never disappears, so where the system reports its values the button reads **Calibrate Anyway...** and says the display is already set up (**Use Automatic Setup** once they were changed by hand). Where nothing is reported, Auto is 203 and 1000 nits, and the Graphics: HDR screen asks the player to calibrate. **Black Level** (0 by default, for an OLED's true black) is the display's darkest black, in nits: the last pass lifts the shadows onto it with ITU-R BT.2390's black level lift in PQ, which leaves the peak where it is and fades out by the fourth power above black, and the metadata gives it as the minimum luminance; at 0 the picture is unchanged. **UI Brightness** (100% by default) sets the HUD and menus against paper white: once the world is kept for the last pass, the frame's alpha is zeroed, everything drawn after raises it where it covers the world, the first-person hand's is then scaled down to 1/255 (it stays at the world's brightness), and the last pass scales the rest by UI Brightness and lifts none of it (so text over the sun stays text). With HDR on, F2 writes vanilla's screenshot as always and an HDR10 copy beside it (`…-hdr.png`): the HDR pass drawn once more into a 10-bit PQ target, whatever the swapchain is, saved as a 16-bit PNG tagged BT.2020 with PQ (`cICP`, PNG's third edition) and the peak and paper white as content light levels (`cLLI`); viewers that read `cICP` show it in HDR, and others that honour its ICC profile (reference white at the profile's white) a correct SDR picture with clipped highlights. Not lifted yet: translucent blocks (the nether portal), distant LOD terrain.
|
||||
|
||||
HDR's light sources shine by the kind of light rather than its level (`mesher::hdr_tier`): the sun most, then glowstone, campfires and the soul fires, then flames and lava, torch and candle flames among them (particles too), then faint glowers such as a redstone torch or magma; glow lichen stays as in SDR. Each tier has its share of the headroom and the brightness its lift starts and ends at (`gfx/hdr.rs` `TIERS`); `HORIZON_HDR_DEBUG=1` greys what isn't lifted and tints what is magenta.
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ harness named checks that loader against vanilla).
|
||||
| Block states: variants, multipart, weights, rotations, uvlock | `blockstates/` | works | `test_blockstate_model_parity`, `test_model_choice_parity`, `test_blockstate_transform_parity` |
|
||||
| Block models: parents, elements, rotations, faces, cullface, tint, shade, light emission, ambient occlusion | `models/block/` | works | `test_model_resolution_parity`, `test_model_baking_parity`, `test_section_mesh_parity` |
|
||||
| Item definitions and item models (all model types, display transforms, tints) | `items/`, `models/item/` | works | `test_item_model_parity` |
|
||||
| Entity models | (code in vanilla) | n/a | entity models aren't resource files in 26.3 (`test_entity_model_parity` checks the code ones) |
|
||||
| Custom entity models (OptiFine/EMF): a `.jem` reshapes a mob's model layer, with its animations and texture; Entity Model Features' files win over OptiFine's | `optifine/cem/`, `emf/cem/` | partly | `mobs::cem` and `mobs::cem_anim` unit tests; no per-bone textures, no random models, no textures under `optifine/` (vanilla's own entity models are code in 26.3, which `test_entity_model_parity` checks) |
|
||||
| Textures, any resolution (16x to 512x) | `textures/` | works | atlases stitched from the overlay; Faithful 32x, Default HD 128x and FPBR measured in [reuse-pack-overlays.md](reuse-pack-overlays.md) |
|
||||
| Texture animation: frames, frame time, frame size | `*.png.mcmeta` `animation` | works | `test_sprite_animation_parity` |
|
||||
| Texture animation: `interpolate` | `*.png.mcmeta` `animation` | **no** | frames step instead of blending (vanilla's own prismarine, magma, stems, lit furnaces and campfires, command blocks, sculk too): fix in progress |
|
||||
|
||||
@@ -247,18 +247,21 @@ fn main() {
|
||||
// Grabbing and releasing put the pointer at the window's centre
|
||||
// (MouseHandler.grabMouse / releaseMouse), in the surface's pixels when it
|
||||
// has one; releasing an already released pointer leaves it where it is.
|
||||
// Windows also warps before the Locked grab, which clips the pointer
|
||||
// where it is (#6076).
|
||||
let grab_warps = if cfg!(windows) { 2 } else { 1 };
|
||||
game.renderer = Some(Renderer { config: Config { width: 1000, height: 600 } });
|
||||
game.capture(true);
|
||||
assert!(game.captured);
|
||||
let window = game.window.as_ref().unwrap();
|
||||
assert_eq!((window.warps.get(), window.at.get()), (1, (500.0, 300.0)));
|
||||
assert_eq!((window.warps.get(), window.at.get()), (grab_warps, (500.0, 300.0)));
|
||||
game.capture(false);
|
||||
let window = game.window.as_ref().unwrap();
|
||||
assert_eq!(window.warps.get(), 3);
|
||||
assert_eq!(window.warps.get(), grab_warps + 2);
|
||||
assert_eq!(game.cursor, (250.0, 150.0));
|
||||
game.cursor = (1.0, 1.0);
|
||||
game.capture(false);
|
||||
assert_eq!(game.window.as_ref().unwrap().warps.get(), 3);
|
||||
assert_eq!(game.window.as_ref().unwrap().warps.get(), grab_warps + 2);
|
||||
assert_eq!(game.cursor, (1.0, 1.0));
|
||||
game.renderer = None;
|
||||
game.window.as_mut().unwrap().fail = true;
|
||||
|
||||
Reference in new issue
Block a user