Merge main into #5646

# Conflicts:
#	tools/command_exec_corpus.py
This commit is contained in:
Ludovic Marie committed 2026-11-01 15:03:10 +01:00
commit 938f0323ac
444 files changed
+36279 -5414

No files matched your search

Generated
+11
View File
@@ -1334,6 +1334,9 @@ dependencies = [
"log",
"miniz_oxide 0.8.9",
"naga",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"png",
"pollster",
@@ -1506,6 +1509,13 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"regex",
]
[[package]]
name = "horizon-port-pack"
version = "0.1.0"
@@ -1609,6 +1619,7 @@ version = "0.1.0"
dependencies = [
"bytemuck",
"naga",
"png",
"serde",
"serde_json",
"sha2",
+1
View File
@@ -30,6 +30,7 @@ members = [
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-optifine",
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"xtask",
@@ -6,5 +6,5 @@ authors = []
# A content mod: blocks, items and mob effects from data/ and assets/, no code (API 0.2).
api = "0.2"
side = "both"
# registries: "May add blocks, items and mob effects to your worlds".
# registries: "May add blocks, items, mob effects and entity types to your worlds".
capabilities = ["registries"]
@@ -39,6 +39,11 @@ pub enum Change {
/// movement speed (the client takes the modifiers as sent, so a sprint modifier the packet
/// lacks is gone while the client sprints), None when it didn't (the client keeps its own).
Attributes { sprint: Option<bool> },
/// ClientboundSetEntityDataPacket for the player itself with its shared flags: their sprinting
/// bit, which the client takes as sent (SynchedEntityData.assignValues sets the flag alone, its
/// speed modifier and wasSprinting stay). A client whose sprint the server cleared this way
/// starts it again in its next aiStep, which adds the modifier back, and says nothing.
Sprinting(bool),
/// ClientboundUpdateMobEffectPacket or ClientboundRemoveMobEffectPacket for the player itself:
/// the server's effects, as they are when the replay reaches it. (Their attribute modifiers
/// are the server's: they reach the client as attributes.)
@@ -81,6 +81,19 @@ impl InteractionInput {
}
}
/// The sentence of `Minecraft.handleKeybinds` that follows a key which opened
/// a screen: `Gui.setScreen` has already run `KeyMapping.releaseAll()`, so
/// `key.use` reads up and the same call releases an active use
/// (`player.isUsingItem() && !keyUse.isDown()` → `MultiPlayerGameMode.releaseUsingItem`).
/// Only the screens opened from the keybinds do this (the inventory, chat and
/// advancements); the pause menu, server containers, mount inventories and
/// dialogs release on the first tick after closing instead, as vanilla does.
pub fn release_on_screen_open(world: &mut impl InteractionWorld) {
if world.using_item() {
world.release_using();
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -96,6 +96,11 @@ pub struct RenderAssets {
pub translucent_layers: Vec<bool>,
/// Each layer's `MipmapGenerator` settings.
pub sprite_mips: Vec<crate::mipmap::Settings>,
/// The resource packs' LabPBR maps (normals, specular), layer by layer at `pbr_size`:
/// empty when they ship none.
pub pbr_normals: Vec<u8>,
pub pbr_specular: Vec<u8>,
pub pbr_size: u32,
}
fn invalid(message: &str) -> io::Error {
io::Error::new(io::ErrorKind::InvalidData, message)
@@ -259,6 +264,9 @@ impl RenderAssets {
sprite_layers: sprites.layers,
translucent_layers: sprites.translucent,
sprite_mips: sprites.mips,
pbr_normals: sprites.normals,
pbr_specular: sprites.specular,
pbr_size: sprites.pbr_size,
})
}
}
+12 -2
View File
@@ -1226,6 +1226,17 @@ impl Audio {
self.play(event, Some(at), volume, pitch);
return;
}
self.play_after(
event,
at,
volume,
pitch,
(distance_sqr.sqrt() / 40.0 * 20.0) as i32,
);
}
/// `play` at a world position, starting `ticks` sound-engine ticks (20 a
/// second) from now: `SoundEngine.playDelayed`.
pub fn play_after(&mut self, event: &str, at: [f64; 3], volume: f32, pitch: f32, ticks: i32) {
let seed = self.random.next_long();
if let Some(capture) = self.capture.as_mut() {
capture.push((event.to_owned(), at, volume, pitch));
@@ -1239,8 +1250,7 @@ impl Audio {
let instance =
SoundInstance::at(&event, source, volume, pitch, LegacyRandom::new(seed), at);
let id = self.engine.add(instance, false);
self.engine
.play_delayed(id, (distance_sqr.sqrt() / 40.0 * 20.0) as i32);
self.engine.play_delayed(id, ticks);
}
/// `play` for this client only: a sound a packet's handler plays
/// (`playLocalSound`), which the guests play from that packet themselves.
@@ -885,25 +885,26 @@ impl BlockEntityRenderer {
let mut renderer = Self::new(models, registry);
renderer.visibility = Some(Mutex::new(visibility));
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
let items: Value = serde_json::from_slice(&bytes)?;
for (item, components) in items.as_object().into_iter().flatten() {
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str()
else {
continue;
};
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
let Ok(def) = std::fs::read(file) else {
continue;
};
let def: Value = serde_json::from_slice(&def)?;
if let Some(asset) = def["asset_id"].as_str() {
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
renderer
.pot_patterns
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
}
let items = match horizon_world::item_defaults::read(root) {
Ok(items) => items,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
Err(e) => return Err(e.into()),
};
for (item, components) in &items {
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str() else {
continue;
};
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
let Ok(def) = std::fs::read(file) else {
continue;
};
let def: Value = serde_json::from_slice(&def)?;
if let Some(asset) = def["asset_id"].as_str() {
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
renderer
.pot_patterns
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
}
}
Ok(renderer)
@@ -160,6 +160,18 @@ pub struct Brewing {
}
impl Brewing {
pub fn load(root: &Path, items: &Items, overlay: &PackOverlay) -> Self {
// NeoForge's load conditions: a file whose don't hold is silently
// absent. Vanilla's brewing recipes carry none; a pack's can.
let mods: Vec<String> = {
let mut mods = horizon_items::content_namespaces::mod_ids(root);
if !mods.iter().any(|m| m == "minecraft") {
mods.push("minecraft".to_owned());
}
mods
};
let conditions_hold = |v: &Value| {
horizon_world::conditions::conditions_hold(v, &|id: &str| mods.iter().any(|m| m == id))
};
let mut sources: Vec<(String, Value)> =
fs::read_dir(root.join("data/minecraft/recipe/brewing"))
.into_iter()
@@ -191,6 +203,9 @@ impl Brewing {
None => sources.push((id.to_owned(), v)),
}
}
// The gate answers the merged file, as the crafting loader's does:
// a false-conditioned pack file leaves the lower one gone too.
sources.retain(|(_, v)| conditions_hold(v));
sources.sort_by(|a, b| a.0.cmp(&b.0));
let mut recipes = Vec::new();
for (id, v) in sources {
@@ -233,6 +233,9 @@ pub struct SkullSkin {
/// `ResolvableProfile.nameOrId` when the profile is `Dynamic` (a name or
/// an id only): the key `skins::Resolver` resolves it by.
pub dynamic: Option<NameOrId>,
/// `PlayerSkin.Patch.body`: the profile's `texture`, a resource pack
/// texture drawn instead of any skin.
pub texture: Option<String>,
pub fallback: &'static str,
}
/// `Either<String, UUID>`: the key a `Dynamic` `ResolvableProfile` resolves
@@ -9,6 +9,59 @@ pub use crate::chunk::block_entity::{
use crate::items::ItemStack;
use std::collections::HashMap;
/// a content mod's menu type (`data/<id>/horizon/menu/`, vanilla's
/// `MenuType`): its container slots, its data slots (`ContainerData`) and the
/// slots players only take from.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ModMenuType {
pub id: &'static str,
pub slots: usize,
pub data_slots: usize,
pub results: Vec<usize>,
}
static MOD_MENU_TYPES: std::sync::OnceLock<Vec<ModMenuType>> = std::sync::OnceLock::new();
/// registers the content mods' menu types, `(id, slots, data slots,
/// results)`, a list set once per process (false when it already was).
pub fn register_mod_menu_types(
menus: impl IntoIterator<Item = (String, usize, usize, Vec<usize>)>,
) -> bool {
if MOD_MENU_TYPES.get().is_some() {
return false;
}
let menus = menus
.into_iter()
.map(|(id, slots, data_slots, results)| ModMenuType {
id: Box::leak(id.into_boxed_str()),
slots,
data_slots,
results,
});
MOD_MENU_TYPES.set(menus.collect()).is_ok()
}
/// the content mods' menu types, in registry order.
pub fn mod_menu_types() -> &'static [ModMenuType] {
MOD_MENU_TYPES.get().map_or(&[], Vec::as_slice)
}
/// the mod menu type named `id`, kept out of line: a vanilla menu never
/// looks one up.
#[cold]
#[inline(never)]
pub fn mod_menu_type(id: &str) -> Option<&'static ModMenuType> {
mod_menu_types().iter().find(|m| m.id == id)
}
/// whether a client of `brand` can be sent menu type `menu`: a vanilla
/// client reads the type through `ByteBufCodecs.registry(Registries.MENU)`,
/// and an id it lacks (`IdMap.byIdOrThrow`) disconnects it, so it is sent
/// vanilla's only. A Horizon client has the server's (the content identity).
pub fn menu_reaches(brand: &str, menu: &str) -> bool {
brand == "horizon" || menu.starts_with("minecraft:")
}
impl FurnaceKind {
pub fn recipe_type(self) -> &'static str {
match self {
@@ -196,6 +249,7 @@ impl SkullSkin {
Self {
download,
dynamic,
texture: crate::skins::profile_texture_nbt(profile),
fallback,
}
}
@@ -610,6 +664,15 @@ mod tests {
use super::*;
use horizon_core::random::{LegacyRandom, RandomSource};
/// a client that isn't Horizon's is sent vanilla's menu types only.
#[test]
fn a_mod_menu_type_reaches_horizon_clients_only() {
assert!(menu_reaches("horizon", "ruby:press"));
assert!(!menu_reaches("vanilla", "ruby:press"));
assert!(!menu_reaches("fabric", "ruby:press"));
assert!(menu_reaches("vanilla", "minecraft:generic_9x3"));
}
#[test]
fn campfire_slots_take_food_cook_it_and_cool_down() {
let mut c = Campfire::default();
+36
View File
@@ -195,6 +195,14 @@ fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
fn windows_processor_name(query: &str) -> Option<String> {
query.lines().find_map(|line| {
let (_, value) = line.split_once("REG_SZ")?;
let value = value.trim();
(!value.is_empty()).then(|| value.to_owned())
})
}
/// `PRETTY_NAME` of /etc/os-release and the kernel, or the system's own version string.
fn operating_system() -> String {
let arch = std::env::consts::ARCH;
@@ -235,6 +243,18 @@ pub fn cpu() -> String {
})
} else if cfg!(target_os = "macos") {
command_output("sysctl", &["-n", "machdep.cpu.brand_string"])
} else if cfg!(windows) {
command_output(
"reg",
&[
"query",
r"HKLM\HARDWARE\DESCRIPTION\System\CentralProcessor\0",
"/v",
"ProcessorNameString",
],
)
.and_then(|query| windows_processor_name(&query))
.or_else(|| std::env::var("PROCESSOR_IDENTIFIER").ok())
} else {
std::env::var("PROCESSOR_IDENTIFIER").ok()
};
@@ -636,6 +656,22 @@ pub fn debug_crash_after() -> Option<std::time::Duration> {
mod tests {
use super::*;
#[test]
fn windows_registry_cpu_name_is_read_without_the_registry_columns() {
let query = r#"
HKEY_LOCAL_MACHINE\HARDWARE\DESCRIPTION\System\CentralProcessor\0
ProcessorNameString REG_SZ 13th Gen Intel(R) Core(TM) i7-13700F
"#;
assert_eq!(
windows_processor_name(query).as_deref(),
Some("13th Gen Intel(R) Core(TM) i7-13700F")
);
assert_eq!(
windows_processor_name("ProcessorNameString REG_SZ "),
None
);
}
#[test]
fn a_release_reports_its_built_in_version() {
// release.yml builds with HORIZON_VERSION (docs/versioning.md): `v0.2.3`, wherever it is installed.
@@ -320,6 +320,13 @@ fn shadow_color(in: Out) -> vec4<f32> {
}
fn celestial_color(in: Out) -> vec4<f32> {
// RenderPipelines.DRAGON_RAYS / rendertype_lightning.fsh: untextured
// vertex colour, with fog fading both RGB and alpha before additive blending.
if in.overlay == 37u {
let fog = max(linear_fog(in.spherical, globals.fog.x, globals.fog.y),
linear_fog(in.cylindrical, globals.fog.z, globals.fog.w));
return in.color * (1.0 - fog);
}
return textureSample(atlas, atlas_sampler, in.uv) * in.color * in.light_color;
}
@fragment fn fs_celestial(in: Out) -> @location(0) vec4<f32> {
+23 -5
View File
@@ -113,6 +113,8 @@ pub struct Extras {
pub agent_control: bool,
/// "Playing Horizon" on the player's Discord profile (`discord_presence`; on by default).
pub discord_presence: bool,
/// Another controller can join P1's world (`splitscreen`, off by default).
pub splitscreen: bool,
}
impl Default for Extras {
fn default() -> Self {
@@ -146,6 +148,7 @@ impl Default for Extras {
no_ai: false,
agent_control: false,
discord_presence: true,
splitscreen: false,
}
}
}
@@ -221,6 +224,7 @@ impl Extras {
horizon_core::no_ai::KEY => e.no_ai = flag(e.no_ai),
"agentControl" => e.agent_control = flag(e.agent_control),
"discordPresence" => e.discord_presence = flag(e.discord_presence),
"splitscreen" => e.splitscreen = flag(e.splitscreen),
_ => {}
}
}
@@ -228,7 +232,7 @@ impl Extras {
}
pub fn serialize(&self) -> String {
format!(
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\n",
"fullbright:{}\ndynamicLights:{}\nzoom:{}\nzoomKey:{}\nzoomLevel:{}\nzoomSmooth:{}\nzoomSensitivity:{}\nfps:{}\nfpsCorner:{}\nfpsLows:{}\nkeystrokes:{}\nkeystrokesCorner:{}\nkeystrokesCps:{}\nmovedToMods:{}\nminimap:{}\nminimapCorner:{}\nminimapSize:{}\nminimapShape:{}\nminimapZoom:{}\nminimapRotate:{}\nautoReconnect:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -263,6 +267,7 @@ impl Extras {
bool_text(self.no_ai),
bool_text(self.agent_control),
bool_text(self.discord_presence),
bool_text(self.splitscreen),
)
}
/// Binds the extra key `name` (`SORT_KEY`); false for
@@ -393,6 +398,8 @@ pub enum Item {
AgentControl,
/// On the Horizon Settings page.
DiscordPresence,
/// On the Splitscreen settings page.
Splitscreen,
}
/// The Extras page: headers and items.
pub fn rows() -> Vec<Result<Item, &'static str>> {
@@ -426,6 +433,7 @@ impl Item {
Item::NoAi => "No AI",
Item::AgentControl => "Agent Control",
Item::DiscordPresence => "Discord Rich Presence",
Item::Splitscreen => "Splitscreen",
}
}
/// The switch this setting belongs to: it does nothing while that is off.
@@ -435,7 +443,7 @@ impl Item {
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
| AgentControl | DiscordPresence => {
| AgentControl | DiscordPresence | Splitscreen => {
return None;
}
})
@@ -471,6 +479,9 @@ impl Item {
Item::DiscordPresence => {
"Shows Playing Horizon on your Discord profile while Discord runs: menus, single player or multiplayer, the dimension and the time played. Never a world's name or a server's address."
}
Item::Splitscreen => {
"In a world, another controller can join as a new player: press A when it asks, then pick a profile and where to play."
}
Item::AgentControl => {
"Your own AI agent can play this game through horizon-ctl or an MCP client, on this computer only."
}
@@ -482,9 +493,11 @@ impl Item {
match self {
Item::Fullbright => Some(Cost::None),
Item::DynamicLights => Some(Cost::Low),
Item::OwnNameTag | Item::AutoReconnect | Item::MouseTweaks | Item::Sort => {
Some(Cost::None)
}
Item::OwnNameTag
| Item::AutoReconnect
| Item::MouseTweaks
| Item::Sort
| Item::Splitscreen => Some(Cost::None),
_ => None,
}
}
@@ -501,6 +514,7 @@ impl Item {
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
_ => return None,
})
}
@@ -528,6 +542,7 @@ impl Item {
Item::NoAi => e.no_ai = !e.no_ai,
Item::AgentControl => e.agent_control = !e.agent_control,
Item::DiscordPresence => e.discord_presence = !e.discord_presence,
Item::Splitscreen => e.splitscreen = !e.splitscreen,
Item::SortKey => {}
}
}
@@ -580,6 +595,9 @@ mod tests {
e.sort = true;
e.sort_key = "key.keyboard.r".into();
e.discord_presence = false;
assert!(!e.splitscreen);
Item::Splitscreen.cycle(&mut e, false);
assert_eq!(Item::Splitscreen.switch(&e), Some(true));
assert_eq!(Extras::parse(&e.serialize()), e);
}
+3 -85
View File
@@ -1,85 +1,3 @@
//! A fast, non-cryptographic hasher (the Fx hash used by rustc) for hot maps
//! keyed by small integers, such as chunk coordinates. Iteration order of
//! such maps carries no meaning: the default hasher's is random anyway.
use std::hash::{BuildHasherDefault, Hasher};
const SEED: u64 = 0xf135_7aea_2e62_a9c5;
#[derive(Clone, Copy, Default)]
pub struct FxHasher {
hash: u64,
}
impl FxHasher {
#[inline]
fn add(&mut self, word: u64) {
self.hash = self.hash.wrapping_add(word).wrapping_mul(SEED);
}
}
impl Hasher for FxHasher {
#[inline]
fn write(&mut self, bytes: &[u8]) {
let (words, rest) = bytes.as_chunks::<8>();
for word in words {
self.add(u64::from_le_bytes(*word));
}
if !rest.is_empty() {
let mut word = [0u8; 8];
word[..rest.len()].copy_from_slice(rest);
self.add(u64::from_le_bytes(word));
}
}
#[inline]
fn write_u8(&mut self, i: u8) {
self.add(u64::from(i));
}
#[inline]
fn write_u16(&mut self, i: u16) {
self.add(u64::from(i));
}
#[inline]
fn write_u32(&mut self, i: u32) {
self.add(u64::from(i));
}
#[inline]
fn write_u64(&mut self, i: u64) {
self.add(i);
}
#[inline]
fn write_usize(&mut self, i: usize) {
self.add(i as u64);
}
#[inline]
fn write_i32(&mut self, i: i32) {
self.add(u64::from(i as u32));
}
#[inline]
fn finish(&self) -> u64 {
// The multiply leaves the low bits weakest; hash tables use them.
self.hash.rotate_left(26)
}
}
pub type FxBuild = BuildHasherDefault<FxHasher>;
pub type FxHashMap<K, V> = std::collections::HashMap<K, V, FxBuild>;
pub type FxHashSet<K> = std::collections::HashSet<K, FxBuild>;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn nearby_chunks_spread_over_buckets() {
let mut map: FxHashMap<(i32, i32), i32> = FxHashMap::default();
for x in -40..40 {
for z in -40..40 {
map.insert((x, z), x * z);
}
}
assert_eq!(map.len(), 6400);
assert_eq!(map[&(-7, 13)], -91);
let mut low = std::collections::HashSet::new();
for x in 0..64 {
low.insert(std::hash::BuildHasher::hash_one(&FxBuild::default(), (x, 0)) & 63);
}
assert!(low.len() > 32, "low bits vary: {}", low.len());
}
}
//! The Fx hasher for hot maps keyed by small integers: kept in horizon-core
//! (the scheduled ticks use it too), and reachable here under its old path.
pub use horizon_core::fast_hash::*;
File diff suppressed because it is too large. Load diff
+194 -10
View File
@@ -76,8 +76,22 @@ struct Page {
/// Capacity in vertices.
capacity: u64,
free: FreeList,
/// The buffer went to system memory (`Arena::create`'s fallback): meshes
/// are never moved into it, and it is the first to empty.
host: bool,
}
impl Page {
/// Vertices in the page's free ranges.
fn free_vertices(&self) -> u64 {
self.free.0.values().sum()
}
}
/// An arena with this many bytes in free ranges, at least half of its pages,
/// has a page worth emptying (`Arena::sparsest`).
const COMPACT_MIN_FREE: u64 = 128 << 20;
/// Terrain vertices in pages of at most `page_limit` vertices (devices cap
/// buffer sizes): a section's vertices sit in one page.
pub struct Arena {
@@ -86,6 +100,8 @@ pub struct Arena {
page_limit: u64,
initial_capacity: u64,
usage: wgpu::BufferUsages,
/// A page being emptied (`Arena::close`): nothing is placed in it.
closing: Option<u32>,
/// A page had to go to system memory (logged once).
spilled: bool,
/// Tests: GPU-local pages fail as if video memory were full.
@@ -129,6 +145,7 @@ impl Arena {
page_limit,
initial_capacity: capacity,
usage,
closing: None,
spilled: false,
#[cfg(test)]
video_memory_full: false,
@@ -138,7 +155,7 @@ impl Arena {
/// and the device allows it, in system memory: Vulkan drivers fail an
/// allocation rather than page like OpenGL ones, and wgpu's default error
/// handler panics. `None` when neither has room.
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<wgpu::Buffer> {
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<(wgpu::Buffer, bool)> {
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
#[cfg(test)]
let local = (!self.video_memory_full)
@@ -146,6 +163,7 @@ impl Arena {
.flatten();
#[cfg(not(test))]
let local = try_create(device, capacity * self.stride, usage);
let local = local.map(|buffer| (buffer, false));
local.or_else(|| {
// Mappable buffers are placed in host-visible memory, which the
// allocator takes from system memory once video memory is full.
@@ -166,7 +184,7 @@ impl Arena {
(lower the render distance if frames slow down)"
);
}
Some(buffer)
Some((buffer, true))
})
}
/// The rounded length a request of `vertices` occupies.
@@ -207,16 +225,13 @@ impl Arena {
vertices: u64,
) -> Option<(u32, u64)> {
let len = Self::span(vertices).min(self.page_limit);
for (i, page) in self.pages.iter_mut().enumerate() {
if let Some(page) = page
&& let Some(start) = page.free.take(len)
{
return Some((i as u32, start));
}
if let Some(found) = self.alloc_existing(len) {
return Some(found);
}
let last = self.pages.len().saturating_sub(1);
if let Some(page) = self.pages.last().and_then(Option::as_ref)
&& page.capacity < self.page_limit
&& self.closing != Some(last as u32)
{
let current = page.capacity;
let grown = (current * 2).max(current + len).min(self.page_limit);
@@ -235,7 +250,7 @@ impl Arena {
.max(len)
.min(self.page_limit);
// A page just large enough when memory is short.
let (buffer, capacity) = match self.create(device, capacity) {
let ((buffer, host), capacity) = match self.create(device, capacity) {
Some(buffer) => (buffer, capacity),
None if capacity > len => (self.create(device, len)?, len),
None => return None,
@@ -246,6 +261,7 @@ impl Arena {
buffer,
capacity,
free,
host,
};
let index = self
.pages
@@ -258,6 +274,96 @@ impl Arena {
self.pages[index] = Some(page);
Some((index as u32, start))
}
/// A range of `vertices` in a page that exists already and is not being
/// emptied: never grows the arena.
pub fn alloc_existing(&mut self, vertices: u64) -> Option<(u32, u64)> {
let len = Self::span(vertices).min(self.page_limit);
let closing = self.closing;
self.pages
.iter_mut()
.enumerate()
.filter(|(i, _)| closing != Some(*i as u32))
.find_map(|(i, page)| Some((i as u32, page.as_mut()?.free.take(len)?)))
}
/// `alloc_existing` for a mesh moved off the closing page: only pages in
/// video memory, so compaction never pushes terrain into system memory.
pub fn alloc_moved(&mut self, vertices: u64) -> Option<(u32, u64)> {
let len = Self::span(vertices).min(self.page_limit);
let closing = self.closing;
self.pages
.iter_mut()
.enumerate()
.filter(|(i, p)| closing != Some(*i as u32) && p.as_ref().is_some_and(|p| !p.host))
.find_map(|(i, page)| Some((i as u32, page.as_mut()?.free.take(len)?)))
}
/// The page to empty when much of the arena is free ranges (video memory
/// that fragmentation keeps: a page goes back only once all of it is free):
/// the one with the fewest vertices in use that the other pages' free
/// ranges can take. `None` when the arena is dense enough or already
/// emptying a page.
pub fn sparsest(&self) -> Option<u32> {
self.sparsest_from(COMPACT_MIN_FREE)
}
/// `sparsest` for an arena with at least `min_free` bytes in free ranges.
fn sparsest_from(&self, min_free: u64) -> Option<u32> {
if self.closing.is_some() {
return None;
}
let pages = || {
self.pages
.iter()
.enumerate()
.filter_map(|(i, p)| Some((i, p.as_ref()?)))
};
let free: u64 = pages().map(|(_, p)| p.free_vertices()).sum();
let capacity: u64 = pages().map(|(_, p)| p.capacity).sum();
if free * self.stride < min_free || free * 2 < capacity {
return None;
}
// Moves only go to video-memory pages (`alloc_moved`).
let local_free: u64 = pages()
.filter(|(_, p)| !p.host)
.map(|(_, p)| p.free_vertices())
.sum();
pages()
.filter_map(|(i, p)| {
let used = p.capacity - p.free_vertices();
let own = if p.host { 0 } else { p.free_vertices() };
// The room elsewhere, less a quarter for first fit's gaps.
let elsewhere = (local_free - own) / 4 * 3;
// A page in system memory is emptied first.
(used > 0 && used <= elsewhere).then_some((!p.host, used, i as u32))
})
.min()
.map(|(_, _, i)| i)
}
/// Stops placing vertices in `page` until `reopen`.
pub fn close(&mut self, page: u32) {
self.closing = Some(page);
}
pub fn reopen(&mut self) {
self.closing = None;
}
pub fn closing(&self) -> Option<u32> {
self.closing
}
/// Records the copy of `vertices` from one range to another of other pages.
pub fn copy(
&self,
encoder: &mut wgpu::CommandEncoder,
from: (u32, u64),
to: (u32, u64),
vertices: u64,
) {
debug_assert_ne!(from.0, to.0);
encoder.copy_buffer_to_buffer(
self.buffer(from.0),
from.1 * self.stride,
self.buffer(to.0),
to.1 * self.stride,
vertices * self.stride,
);
}
pub fn release(&mut self, page: u32, start: u64, vertices: u64) {
let len = Self::span(vertices).min(self.page_limit);
self.pages[page as usize]
@@ -296,7 +402,7 @@ impl Arena {
capacity: u64,
) -> bool {
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
let Some(buffer) = self.create(device, capacity) else {
let Some((buffer, host)) = self.create(device, capacity) else {
return false;
};
let old = self.pages[page].as_mut().expect("live arena page");
@@ -304,6 +410,7 @@ impl Arena {
encoder.copy_buffer_to_buffer(&old.buffer, 0, &buffer, 0, old.capacity * self.stride);
let previous = old.capacity;
old.buffer = buffer;
old.host = host;
old.capacity = capacity;
old.free.give(previous, capacity - previous);
true
@@ -318,6 +425,9 @@ impl Arena {
*page = None;
}
}
if self.closing.is_some_and(|page| !self.has_page(page)) {
self.closing = None;
}
// Keep live page indices stable; later allocations reuse interior holes.
while self.pages.last().is_some_and(Option::is_none) {
self.pages.pop();
@@ -325,6 +435,7 @@ impl Arena {
}
pub fn clear(&mut self) {
self.pages.clear();
self.closing = None;
}
}
@@ -407,6 +518,76 @@ mod tests {
assert_eq!(read(&device, &queue, arena.buffer(page), next * 4), 9);
}
#[test]
fn a_sparse_page_empties_into_the_others_and_is_reclaimed() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::new(&device, 4, GRAIN, false);
arena.page_limit = 4 * GRAIN;
// Eight ranges fill two pages, each holding its own number.
let ranges: Vec<(u32, u64)> = (0..8)
.map(|i| {
let range = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
queue.write_buffer(
arena.buffer(range.0),
range.1 * 4,
&(100 + i as u32).to_le_bytes(),
);
range
})
.collect();
assert_eq!(arena.pages.iter().flatten().count(), 2);
let (first, second) = (ranges[0].0, ranges[7].0);
assert_ne!(first, second);
// Fragmented: one range live in the first page, two in the second.
for (i, &(page, start)) in ranges.iter().enumerate() {
if ![0, 6, 7].contains(&i) {
arena.release(page, start, GRAIN);
}
}
// Dense enough arenas, and ones without room elsewhere, are left alone.
assert_eq!(arena.sparsest_from(u64::MAX), None);
assert_eq!(arena.sparsest_from(0), Some(first));
arena.close(first);
assert_eq!(
arena.sparsest_from(0),
None,
"one page is emptied at a time"
);
// New ranges avoid the closing page though it has room.
let (page, extra) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
assert_eq!(page, second);
arena.release(page, extra, GRAIN);
// Its live range moves, data included, and the page goes back.
let (page, start) = ranges[0];
let to = arena.alloc_existing(GRAIN).unwrap();
assert_eq!(to.0, second);
let mut encoder = device.create_command_encoder(&Default::default());
arena.copy(&mut encoder, (page, start), to, GRAIN);
queue.submit([encoder.finish()]);
arena.release(page, start, GRAIN);
assert_eq!(read(&device, &queue, arena.buffer(to.0), to.1 * 4), 100);
// Emptied but not trimmed yet: it stays closed, so uploads can't refill it.
assert_eq!(arena.closing(), Some(page));
let refill = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
assert_ne!(refill.0, page);
arena.release(refill.0, refill.1, GRAIN);
let before = arena.bytes();
arena.trim();
assert_eq!(arena.bytes(), before - 4 * GRAIN * 4);
assert_eq!(arena.closing(), None);
// The others kept their ranges.
for i in [6, 7] {
let (page, start) = ranges[i];
assert_eq!(
read(&device, &queue, arena.buffer(page), start * 4),
100 + i as u32
);
}
}
#[test]
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
let Some((device, queue)) = device() else {
@@ -572,6 +753,9 @@ mod tests {
queue.write_buffer(arena.buffer(grown), next * 4, &9u32.to_le_bytes());
assert_eq!(read(&device, &queue, arena.buffer(page), start * 4), 7);
assert_eq!(read(&device, &queue, arena.buffer(grown), next * 4), 9);
// Compaction never moves terrain into a page in system memory.
assert!(arena.pages[page as usize].as_ref().unwrap().host);
assert_eq!(arena.alloc_moved(GRAIN), None);
}
#[test]
@@ -0,0 +1,224 @@
//! The colour of block light for Horizon's shader packs (`[[lights]]`, docs/api/shaders.md):
//! a grid of cells around the camera, each holding the colour of the light sources that
//! reach it, averaged by how near each is, which the material stage reads through the
//! prelude's `block_light_color`. Block light keeps its own brightness (the light engine's,
//! which walls stop): the grid only tints it, so it needs no occlusion of its own.
//!
//! The grid is rebuilt on the CPU when the camera moves on to another 16-block step or the
//! light sources change, from the sources the section meshes list (`SectionMesh::lights`).
use crate::fast_hash::FxHashMap;
use glam::{DVec3, Vec3};
use super::SectionKey;
/// Cells along each side of the grid.
pub const SIZE: u32 = 64;
/// The side of a cell, in blocks: the grid spans 128 blocks.
pub const CELL: f32 = 2.0;
/// The grid moves in steps of this many blocks.
const STEP: i32 = 16;
/// The most sources one rebuild splats, the nearest first (a lava lake lists thousands).
const MAX_SOURCES: usize = 2048;
/// `[[lights]]` colours go up to 4; the cells keep them over 4 in 8 bits.
const COLOR_RANGE: f32 = 4.0;
/// The light a block state gives off: its `[[lights]]` colour and its level, 1 to 15.
pub type LightTable = FxHashMap<u32, ([f32; 3], u8)>;
pub struct BlockLight {
texture: wgpu::Texture,
pub view: wgpu::TextureView,
/// `block_light_grid`: the grid's corner, camera-relative, and the side of a cell.
pub grid: wgpu::Buffer,
table: LightTable,
/// The world corner and the sources' revision the texture was last built for.
built: Option<([i32; 3], u64)>,
}
impl BlockLight {
pub fn new(device: &wgpu::Device, table: LightTable) -> Self {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack block light colours"),
size: wgpu::Extent3d {
width: SIZE,
height: SIZE,
depth_or_array_layers: SIZE,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D3,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
let grid = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack block light grid"),
size: 16,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
Self {
view: texture.create_view(&Default::default()),
texture,
grid,
table,
built: None,
}
}
/// Places the grid around `camera`, rebuilding it when it moved or `revision` (of
/// `sections`' sources) changed.
pub fn update(
&mut self,
queue: &wgpu::Queue,
camera: DVec3,
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
revision: u64,
) {
let half = (SIZE as f32 * CELL / 2.0) as f64;
let origin = ((camera - half) / f64::from(STEP))
.floor()
.as_ivec3()
.to_array()
.map(|v| v * STEP);
let corner = (glam::IVec3::from(origin).as_dvec3() - camera).as_vec3();
queue.write_buffer(
&self.grid,
0,
bytemuck::cast_slice(&[corner.x, corner.y, corner.z, CELL]),
);
if self.built == Some((origin, revision)) {
return;
}
self.built = Some((origin, revision));
let cells = build(&self.table, origin, camera, sections);
queue.write_texture(
self.texture.as_image_copy(),
&cells,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(SIZE * 4),
rows_per_image: Some(SIZE),
},
self.texture.size(),
);
}
}
/// The cells of the grid with its corner at `origin`: RGBA8, x fastest, then y, then z.
fn build(
table: &LightTable,
origin: [i32; 3],
camera: DVec3,
sections: &FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
) -> Vec<u8> {
let n = SIZE as usize;
let mut cells = vec![0u8; n * n * n * 4];
if table.is_empty() {
return cells;
}
let span = SIZE as i32 * CELL as i32;
// Sections the grid or the light reaching into it (15 blocks) touches.
let lo = origin.map(|v| (v - 15).div_euclid(16));
let hi = origin.map(|v| (v + span + 15).div_euclid(16));
let mut sources: Vec<(Vec3, [f32; 3], f32, f64)> = Vec::new();
for (key, lights) in sections {
let key = [key.0, key.1 + crate::chunk::MIN_Y.div_euclid(16), key.2];
if (0..3).any(|i| key[i] < lo[i] || key[i] > hi[i]) {
continue;
}
for &(pos, state) in lights {
let Some(&(color, level)) = table.get(&state) else {
continue;
};
let center = glam::IVec3::from(pos).as_dvec3() + 0.5;
let local = (center - glam::IVec3::from(origin).as_dvec3()).as_vec3();
sources.push((
local,
color,
f32::from(level),
center.distance_squared(camera),
));
}
}
if sources.len() > MAX_SOURCES {
sources.select_nth_unstable_by(MAX_SOURCES, |a, b| a.3.total_cmp(&b.3));
sources.truncate(MAX_SOURCES);
}
let mut sum = vec![[0.0f32; 4]; n * n * n];
for (at, color, reach, _) in sources {
let first = ((at - reach) / CELL).floor().max(Vec3::ZERO).as_ivec3();
let last = ((at + reach) / CELL)
.ceil()
.min(Vec3::splat(SIZE as f32 - 1.0))
.as_ivec3();
for z in first.z..=last.z {
for y in first.y..=last.y {
for x in first.x..=last.x {
let cell = (Vec3::new(x as f32, y as f32, z as f32) + 0.5) * CELL;
let d = cell.distance(at);
if d >= reach {
continue;
}
// As block light falls off: strongest at the source, gone at its level.
let w = (1.0 - d / reach).powi(2);
let s = &mut sum[x as usize + n * (y as usize + n * z as usize)];
s[0] += color[0] * w;
s[1] += color[1] * w;
s[2] += color[2] * w;
s[3] += w;
}
}
}
}
for (cell, s) in cells.as_chunks_mut::<4>().0.iter_mut().zip(&sum) {
if s[3] <= 0.0 {
continue;
}
let byte = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
for i in 0..3 {
cell[i] = byte(s[i] / s[3] / COLOR_RANGE);
}
cell[3] = byte(s[3]);
}
cells
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_source_tints_the_cells_it_reaches_with_its_colour() {
let mut table = LightTable::default();
table.insert(7, ([2.0, 1.0, 0.0], 14));
let mut sections = FxHashMap::default();
// A torch at world (8, 64, 8): section y = (64 - MIN_Y) / 16.
let sy = (64 - crate::chunk::MIN_Y) / 16;
sections.insert((0, sy, 0), vec![([8, 64, 8], 7)]);
let camera = DVec3::new(8.0, 64.0, 8.0);
let origin = [-64, 0, -64];
let cells = build(&table, origin, camera, &sections);
let n = SIZE as usize;
let at = |p: [i32; 3]| {
let c = p
.iter()
.zip(origin)
.map(|(v, o)| ((v - o) as f32 / CELL) as usize)
.collect::<Vec<_>>();
let i = (c[0] + n * (c[1] + n * c[2])) * 4;
[cells[i], cells[i + 1], cells[i + 2], cells[i + 3]]
};
let near = at([8, 64, 8]);
// The colour over 4, whatever the weight: (2, 1, 0) / 4.
assert_eq!(&near[..3], &[128, 64, 0]);
assert!(near[3] > 150);
assert_eq!(at([8, 64, 40]), [0; 4], "past its level, nothing");
assert!(
build(&LightTable::default(), origin, camera, &sections)
.iter()
.all(|&b| b == 0)
);
}
}
@@ -20,6 +20,8 @@ const MATERIALS: usize = 10;
/// (`RenderTypes.entityTranslucent`, `entityTranslucentEmissive`, `eyes`,
/// `breezeWind`). Only the improved transparency path reads it.
pub const OIT_ENTITY: u32 = 1 << 24;
/// `RenderPipelines.DRAGON_RAYS`: untextured colour, additive with depth writes.
pub const DRAGON_RAYS_OVERLAY: u32 = 37;
/// Tags quads as drawn by a render type vanilla gives an `OitPipelineSet`
/// (`OIT_ENTITY`): improved transparency draws them in its stages.
pub fn mark_oit(quads: &mut [Vertex]) {
@@ -79,6 +81,11 @@ pub(super) struct EntityBatch {
}
impl EntityBatch {
/// After `update`: quads only a native pack's shadow pass draws, at the buffer's end.
pub(super) fn append_shadow_only(&mut self, shadow_only: &[Vertex]) {
self.combined.extend_from_slice(shadow_only);
}
pub(super) fn update(
&mut self,
entities: &[Vertex],
@@ -1,9 +1,11 @@
//! Frame generation: FSR 3.1's frame interpolation will show a frame halfway between each
//! two real ones. So far this holds its optical flow and interpolation; the setting that
//! runs them comes next.
//! two real ones. So far this holds its optical flow, its interpolation and the motion
//! vectors of what moves on its own; the setting that runs them comes next.
// Unused until the renderer runs them.
#[allow(dead_code)]
mod interpolation;
#[allow(dead_code)]
mod motion;
#[allow(dead_code)]
mod optical_flow;
@@ -10,8 +10,8 @@
//! priorities and vectors) and fill the game field's holes from a pyramid; mask what each
//! frame can't see; blend the two frames along both fields; fill what is left from a
//! pyramid of the result. What differs from AMD's, all forced by WGSL or by Horizon:
//! the game's motion vectors are computed from the depth and `Inputs::reproject` (the
//! camera's, so what moves on its own relies on the optical flow); textures AMD updates
//! the game's motion vectors are the camera's, from the depth and `Inputs::reproject`,
//! except where `Inputs::object_motion` has an entity's own; textures AMD updates
//! with image atomics are storage buffers; SPD's single-pass pyramids are a pass per
//! level; and there is no HUD-less pass, as Horizon draws its HUD over the made frame.
@@ -33,6 +33,9 @@ pub struct Inputs<'a> {
/// `world_post::reprojection`): the motion vectors are the camera's, from this and
/// the depth.
pub reproject: Mat4,
/// What moved on its own: `motion::FORMAT` at `render_size`, xy its motion vector
/// (FSR's convention, in UV) where z is 1, used over the camera's.
pub object_motion: &'a wgpu::TextureView,
pub near: f32,
pub far: f32,
/// Vertical field of view, radians.
@@ -442,6 +445,7 @@ impl Interpolation {
&[
(0, params()),
(2, view(inputs.depth)),
(34, view(inputs.object_motion)),
(5, view(&r.dilated_depth)),
(6, view(&r.dilated_mv)),
(9, whole(&r.rec_prev_depth)),
@@ -644,10 +648,12 @@ mod tests {
/// Runs `frames` (pattern shifts) through the optical flow and the interpolation with
/// a camera that moves by `reproject` each frame, and returns the mean error of the
/// last made frame's interior against `expected`, and the last two real frames'.
/// `object`: every pixel's own motion vector (UV), as if all of it moved on its own.
fn interpolate(
seed: u32,
shifts: &[[i32; 2]],
reproject: Mat4,
object: Option<[f32; 2]>,
expected: [i32; 2],
) -> Option<[f32; 3]> {
let (device, queue) = of::device()?;
@@ -682,6 +688,35 @@ mod tests {
..Default::default()
});
queue.submit([encoder.finish()]);
let object_motion = device.create_texture(&wgpu::TextureDescriptor {
label: None,
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: super::super::motion::FORMAT,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
if let Some(mv) = object {
let texel = [mv[0], mv[1], 1.0, 0.0].map(|v| half::f16::from_f32(v).to_bits());
let texels: Vec<u16> = (0..size[0] * size[1]).flat_map(|_| texel).collect();
queue.write_texture(
object_motion.as_image_copy(),
bytemuck::cast_slice(&texels),
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(size[0] * 8),
rows_per_image: None,
},
object_motion.size(),
);
}
let object_motion = object_motion.create_view(&Default::default());
let mut flow = OpticalFlow::new(&device, size);
let mut interpolation = Interpolation::new(&device, size);
let frames: Vec<_> = shifts
@@ -731,6 +766,7 @@ mod tests {
render_size: size,
jitter: [0.0; 2],
reproject: if i == 0 { Mat4::IDENTITY } else { reproject },
object_motion: &object_motion,
near: 0.05,
far: 512.0,
fov_y: 1.2,
@@ -776,7 +812,8 @@ mod tests {
fn moves_what_the_optical_flow_follows_halfway() {
let mut shifts = vec![[0, 0]; 20];
shifts.push([16, 8]);
let Some([made, previous, current]) = interpolate(100, &shifts, Mat4::IDENTITY, [8, 4])
let Some([made, previous, current]) =
interpolate(100, &shifts, Mat4::IDENTITY, None, [8, 4])
else {
return;
};
@@ -795,7 +832,26 @@ mod tests {
// What is at a pixel now was 16 pixels to its right last frame.
let reproject = Mat4::from_translation(glam::Vec3::new(32.0 / size[0], 0.0, 0.0));
let shifts = [[0, 0], [-16, 0], [-32, 0], [-48, 0]];
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, [-40, 0]) else {
let Some([made, previous, current]) = interpolate(1, &shifts, reproject, None, [-40, 0])
else {
return;
};
assert!(
made * 4.0 < previous.min(current),
"{made} against {previous} and {current}"
);
}
/// Something moving on its own with its own motion vectors (the camera still): the
/// made frame is halfway, as with the camera's.
#[test]
fn moves_what_has_its_own_motion_vectors_halfway() {
let size = [512.0, 288.0];
let shifts = [[0, 0], [16, 8], [32, 16], [48, 24]];
let mv = [-16.0 / size[0], -8.0 / size[1]];
let Some([made, previous, current]) =
interpolate(1, &shifts, Mat4::IDENTITY, Some(mv), [40, 20])
else {
return;
};
assert!(
@@ -24,7 +24,8 @@
// for Horizon, for an SDR back buffer, inverted finite depth, the whole screen as the
// interpolation rect and no distortion field. What differs from AMD's (interpolation.rs
// lists why):
// - the motion vectors come from the depth and the camera (`prepare`), already unjittered;
// - the motion vectors are the camera's, from the depth (`prepare`), already unjittered,
// or an entity's own where `object_motion` has one;
// - the vector fields and reconstructed depths are storage buffers of atomics;
// - both inpainting pyramids are a pass per level, and textures of their own;
// - the interpolation writes one texture and the inpainting another (rgba16float can't
@@ -85,6 +86,8 @@ struct Params {
@group(0) @binding(31) var interpolated_out: texture_storage_2d<rgba16float, write>;
@group(0) @binding(32) var interpolated: texture_2d<f32>;
@group(0) @binding(33) var output: texture_storage_2d<rgba16float, write>;
// What moved on its own (motion.wgsl): xy its motion vector where z is 1.
@group(0) @binding(34) var object_motion: texture_2d<f32>;
const EPSILON: f32 = 1e-03;
const RECONSTRUCTED_DEPTH_WEIGHT_THRESHOLD: f32 = EPSILON;
@@ -355,7 +358,11 @@ fn prepare_pass(@builtin(global_invocation_id) gid: vec3<u32>) {
}
}
}
let mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
let object = textureLoad(object_motion, nearest_coord, 0);
var mv = camera_motion_vector(nearest_coord, load_input_depth(nearest_coord));
if object.z > 0.5 {
mv = object.xy;
}
textureStore(dilated_depth_out, p, vec4(nearest, 0.0, 0.0, 0.0));
textureStore(dilated_mv_out, p, vec4(mv, 0.0, 0.0));
// ReconstructPrevDepth
@@ -0,0 +1,306 @@
//! Motion vectors for what moves on its own, for frame generation: the camera's motion
//! vectors (from the depth) see mobs and dropped items as still. The game tags each
//! entity's vertices with its id (`Frame::entity_motion`); an entity whose vertices have
//! the same shape as last frame's (the same count, UVs and layers) gets each vertex's
//! own motion, limbs and spin included, drawn into a texture at the render size
//! (`motion.wgsl`) that the interpolation reads before the camera's.
use crate::mesher::Vertex;
use glam::{DVec3, Mat4, Vec3};
use std::collections::HashMap;
/// `motion.wgsl`'s `Params`.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Params {
jittered: [[f32; 4]; 4],
current: [[f32; 4]; 4],
previous: [[f32; 4]; 4],
}
/// A vertex this frame and where it was last frame, both relative to this frame's camera.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct MotionVertex {
position: [f32; 3],
previous: [f32; 3],
uv: [f32; 2],
/// 1 for a translucent item, whose texture says where it shows.
translucent: u32,
}
pub struct ObjectMotion {
pipeline: wgpu::RenderPipeline,
atlas: wgpu::TextureView,
sampler: wgpu::Sampler,
params: wgpu::Buffer,
vertices: wgpu::Buffer,
/// Triangles of the entities matched this frame.
staged: Vec<MotionVertex>,
/// Last frame's vertices by entity, and its camera.
tracked: HashMap<u64, Vec<Vertex>>,
camera: DVec3,
target: Option<([u32; 2], wgpu::TextureView)>,
}
pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
impl ObjectMotion {
/// `atlas`: the entities' texture atlas.
pub fn new(device: &wgpu::Device, atlas: wgpu::TextureView) -> Self {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Frame generation motion"),
source: wgpu::ShaderSource::Wgsl(include_str!("motion.wgsl").into()),
});
let attributes =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2, 3 => Uint32];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Frame generation motion"),
layout: None,
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("vs_motion"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<MotionVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: Default::default(),
depth_stencil: None,
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("fs_motion"),
compilation_options: Default::default(),
targets: &[Some(FORMAT.into())],
}),
multiview_mask: None,
cache: None,
});
Self {
pipeline,
atlas,
sampler: device.create_sampler(&Default::default()),
params: device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame generation motion"),
size: std::mem::size_of::<Params>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
}),
vertices: vertex_buffer(device, 1 << 12),
staged: Vec::new(),
tracked: HashMap::new(),
camera: DVec3::ZERO,
target: None,
}
}
/// Forget last frame's entities: the next frame has no motion of its own.
pub fn reset(&mut self) {
self.tracked.clear();
}
/// Match this frame's `entities` (relative to `camera`) to last frame's by the ids
/// `spans` gives their vertices (start, id; 0 for none, each up to the next start), and
/// keep them for the next frame.
pub fn track(&mut self, entities: &[Vertex], spans: &[(u32, u64)], camera: DVec3) {
self.staged.clear();
let shift = (self.camera - camera).as_vec3();
let mut tracked = HashMap::with_capacity(self.tracked.len());
for (i, &(start, id)) in spans.iter().enumerate() {
let end = spans.get(i + 1).map_or(entities.len(), |s| s.0 as usize);
let Some(now) = entities
.get(start as usize..end)
.filter(|v| id != 0 && !v.is_empty())
else {
continue;
};
if let Some(then) = self.tracked.remove(&id)
&& then.len() == now.len()
&& then
.iter()
.zip(now)
.all(|(a, b)| a.uv == b.uv && a.layer == b.layer)
{
let pair = |i: usize| MotionVertex {
position: now[i].pos,
previous: (Vec3::from(then[i].pos) + shift).into(),
uv: now[i].uv,
translucent: u32::from(matches!(
now[i].layer,
super::super::entity_batch::TRANSLUCENT_ITEM_OVERLAY
| super::super::entity_batch::TRANSLUCENT_GLINT_ITEM_OVERLAY
)),
};
for q in (0..now.len() / 4).map(|q| q * 4) {
self.staged
.extend([q, q + 1, q + 2, q, q + 2, q + 3].map(pair));
}
}
tracked.insert(id, now.to_vec());
}
self.tracked = tracked;
self.camera = camera;
}
/// Draw the matched entities' motion vectors at `size` (the world's render size),
/// where `depth` (the world's) shows them; the rest of the texture is 0.
#[allow(clippy::too_many_arguments)]
pub fn draw(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
depth: &wgpu::TextureView,
size: [u32; 2],
jittered: Mat4,
current: Mat4,
previous: Mat4,
) -> wgpu::TextureView {
if self.target.as_ref().is_none_or(|t| t.0 != size) {
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Frame generation motion"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
self.target = Some((size, texture.create_view(&Default::default())));
}
let target = self.target.as_ref().expect("made above").1.clone();
let bytes: &[u8] = bytemuck::cast_slice(&self.staged);
if bytes.len() as u64 > self.vertices.size() {
self.vertices = vertex_buffer(device, (bytes.len() as u64).next_power_of_two());
}
if !bytes.is_empty() {
queue.write_buffer(&self.vertices, 0, bytes);
}
let params = Params {
jittered: jittered.to_cols_array_2d(),
current: current.to_cols_array_2d(),
previous: previous.to_cols_array_2d(),
};
queue.write_buffer(&self.params, 0, bytemuck::bytes_of(&params));
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Frame generation motion"),
layout: &self.pipeline.get_bind_group_layout(0),
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.params.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(depth),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(&self.atlas),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::Sampler(&self.sampler),
},
],
});
let attachments = [Some(wgpu::RenderPassColorAttachment {
view: &target,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT),
store: wgpu::StoreOp::Store,
},
})];
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Frame generation motion"),
color_attachments: &attachments,
..Default::default()
});
if !self.staged.is_empty() {
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..bytes.len() as u64));
pass.draw(0..self.staged.len() as u32, 0..1);
}
drop(pass);
target
}
}
fn vertex_buffer(device: &wgpu::Device, size: u64) -> wgpu::Buffer {
device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Frame generation motion vertices"),
size,
usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn shader_validates() {
let module = naga::front::wgsl::parse_str(include_str!("motion.wgsl")).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::empty(),
)
.validate(&module)
.unwrap();
}
fn quad(x: f32, uv: f32) -> Vec<Vertex> {
(0..4)
.map(|i| Vertex {
pos: [x + i as f32, 0.0, -5.0],
uv: [uv, 0.0],
..bytemuck::Zeroable::zeroed()
})
.collect()
}
/// An entity seen last frame gets its vertices' own motion, in this frame's camera
/// space; a new one, one whose shape changed and untagged geometry get none.
#[test]
fn tracks_each_entity_by_id() {
let Some((device, queue)) = super::super::optical_flow::tests::device() else {
return;
};
let atlas = super::super::optical_flow::tests::upload(&device, &queue, [1, 1], &[0; 4]);
let mut motion = ObjectMotion::new(&device, atlas);
let mut entities = quad(0.0, 0.0);
entities.extend(quad(10.0, 0.5));
motion.track(&entities, &[(0, 7), (4, 8)], DVec3::ZERO);
assert!(motion.staged.is_empty());
// Entity 7 moved 1 block right, the camera 2 blocks right; entity 8 became
// another item; 9 is new; then untagged geometry.
let mut entities = quad(-1.0, 0.0);
entities.extend(quad(10.0, 0.25));
entities.extend(quad(20.0, 0.0));
entities.extend(quad(30.0, 0.0));
motion.track(
&entities,
&[(0, 7), (4, 8), (8, 9), (12, 0)],
DVec3::new(2.0, 0.0, 0.0),
);
assert_eq!(motion.staged.len(), 6);
let v = motion.staged[0];
assert_eq!(v.position, [-1.0, 0.0, -5.0]);
assert_eq!(v.previous, [-2.0, 0.0, -5.0]);
assert_eq!(motion.tracked.len(), 3);
}
}
@@ -0,0 +1,58 @@
// Frame generation's motion vectors for what moves on its own (mobs, dropped items): each
// vertex drawn where the world pass drew it, with where it was last frame; only the pixels
// the world shows it at keep its vector: where the depth is its own, or, for translucent
// items (which don't write depth), where its texture shows and nothing is in front.
struct Params {
// The world's projection this frame (TAA's jitter in it), the same without the jitter,
// and last frame's projection from this frame's camera.
jittered: mat4x4<f32>,
current: mat4x4<f32>,
previous: mat4x4<f32>,
};
@group(0) @binding(0) var<uniform> params: Params;
@group(0) @binding(1) var depth: texture_depth_2d;
@group(0) @binding(2) var atlas: texture_2d<f32>;
@group(0) @binding(3) var atlas_sampler: sampler;
struct Out {
@builtin(position) clip: vec4<f32>,
@location(0) current: vec4<f32>,
@location(1) previous: vec4<f32>,
@location(2) uv: vec2<f32>,
// 1: a translucent item.
@location(3) @interpolate(flat) translucent: u32,
};
@vertex fn vs_motion(@location(0) position: vec3<f32>, @location(1) previous: vec3<f32>,
@location(2) uv: vec2<f32>, @location(3) translucent: u32) -> Out {
var out: Out;
out.uv = uv;
out.translucent = translucent;
out.clip = params.jittered * vec4(position, 1.0);
out.current = params.current * vec4(position, 1.0);
out.previous = params.previous * vec4(previous, 1.0);
return out;
}
// xy: the motion vector in UV (to where the pixel was last frame, FSR's convention), z: 1.
@fragment fn fs_motion(in: Out) -> @location(0) vec4<f32> {
let stored = textureLoad(depth, vec2<i32>(in.clip.xy), 0);
let tolerance = stored * 1e-3 + 1e-7;
// Not what the world shows here: a transparent texel, or behind something.
if in.translucent != 0u {
let alpha = textureSampleLevel(atlas, atlas_sampler, in.uv, 0.0).a;
if alpha < 0.1 || in.clip.z < stored - tolerance {
discard;
}
} else if abs(in.clip.z - stored) > tolerance {
discard;
}
if in.previous.w <= 0.0 {
discard;
}
let now = in.current.xy / in.current.w;
let then = in.previous.xy / in.previous.w;
return vec4((then - now) * vec2(0.5, -0.5), 1.0, 0.0);
}
@@ -152,6 +152,10 @@ impl Tile {
None => &mut self.mesh,
}
}
/// Both meshes it holds in the arena: its own and the stand-in, if any.
pub fn meshes_mut(&mut self) -> impl Iterator<Item = &mut SectionGpu> {
std::iter::once(&mut self.mesh).chain(self.previous.as_mut().map(|(mesh, _)| mesh))
}
pub fn visible(
&self,
key: TileKey,
@@ -18,6 +18,14 @@ const PREFIX: &str = "horizon_terrain_";
/// The entries around the pack's `material`: vanilla's vertex work without the lightmap
/// product, then the material, the alpha test and fog as `fs_solid` and `fs_translucent` do.
const ENTRIES: &str = "
// The manifest's `[material] fog`: false when the pack fogs its surfaces itself.
override horizon_material_fog: bool = true;
fn horizon_material_fogged(color: vec4<f32>, spherical: f32, cylindrical: f32) -> vec4<f32> {
if horizon_material_fog {
return horizon_terrain_apply_fog(color, spherical, cylindrical);
}
return color;
}
struct HorizonMaterialOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@@ -79,26 +87,52 @@ fn horizon_material_vertex(position: vec3<f32>, uv: vec2<f32>, layer: u32, color
return horizon_material_vertex(position, horizon_terrain_terrain_uv(uv, origin.w), layer,
color, light, origin.xyz);
}
// The prelude's `block_normal_map` and `block_specular_map`: the resource packs' LabPBR maps
// in the pack's frame group (3), one array each, a blank 1x1 without them.
@group(3) @binding(12) var horizon_pbr_normals: texture_2d_array<f32>;
@group(3) @binding(13) var horizon_pbr_specular: texture_2d_array<f32>;
fn horizon_block_pbr_present() -> bool {
return textureDimensions(horizon_pbr_normals).x > 1u;
}
fn horizon_block_pbr_size() -> vec2<f32> {
return vec2<f32>(textureDimensions(horizon_pbr_normals).xy);
}
fn horizon_block_pbr(uv: vec2<f32>, layer: u32, lod: f32, map: u32) -> vec4<f32> {
if !horizon_block_pbr_present() || layer >= textureNumLayers(horizon_pbr_normals) {
return select(vec4<f32>(0.5, 0.5, 1.0, 1.0), vec4<f32>(0.0), map == 1u);
}
let s = horizon_terrain_sprite_sampler;
if map == 0u {
return textureSampleLevel(horizon_pbr_normals, s, uv, i32(layer), lod);
}
return textureSampleLevel(horizon_pbr_specular, s, uv, i32(layer), lod);
}
fn horizon_material_color(in: HorizonMaterialOut, translucent: bool) -> vec4<f32> {
let albedo = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
horizon_block_layer = in.layer & 0x7fffu;
// The mip level of this point in the maps: their own texels, as the sampler would pick.
let texels = in.uv * horizon_block_pbr_size();
let footprint = max(dot(dpdx(texels), dpdx(texels)), dot(dpdy(texels), dpdy(texels)));
horizon_block_lod = max(0.5 * log2(max(footprint, 1e-8)), 0.0);
// Water draws translucent and lava solid, so the pass resolves the fluid kind.
let fluid = select(0u, select(2u, 1u, translucent), in.fluid_quad != 0u);
let normal = horizon_material_normal(in.position);
let tangent = horizon_material_tangent(in.position, in.uv, normal);
return material(MaterialInput(albedo, in.tint, in.light, in.lightmap, in.position, in.uv,
translucent, horizon_sprite_tags(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
translucent, horizon_sprite_tags(in.layer & 0x7fffu),
horizon_sprite_material(in.layer & 0x7fffu), in.clip.xy / frame.resolution,
in.face, fluid, in.emission, normal, tangent)).color;
}
@fragment fn horizon_material_fs_solid(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, false);
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
if color.a < 0.5 && (in.layer & 0x8000u) == 0u { discard; }
return horizon_terrain_apply_fog(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
return horizon_material_fogged(vec4(color.rgb, 1.0), in.spherical, in.cylindrical);
}
@fragment fn horizon_material_fs_translucent(in: HorizonMaterialOut) -> @location(0) vec4<f32> {
let color = horizon_material_color(in, true);
if color.a < 0.01 { discard; }
return horizon_terrain_apply_fog(color, in.spherical, in.cylindrical);
return horizon_material_fogged(color, in.spherical, in.cylindrical);
}
";
@@ -115,6 +149,16 @@ pub struct TerrainShape<'a> {
/// The pack's tags of each tagged sprite layer, as `(layer, bits)`
/// (`Manifest::sprite_tags`).
pub sprite_tags: &'a [(u32, u32)],
/// The pack's material id of each sprite layer with one, as `(layer, id)`
/// (`Manifest::sprite_materials`).
pub sprite_materials: &'a [(u32, u32)],
/// The entities' group 0 (their atlas), for the shadow pass's entity draw.
pub entities: &'a wgpu::BindGroupLayout,
/// The weather's groups 0 and 1 (the entities' atlas, the fall clock), for the weather
/// stage.
pub weather_groups: [&'a wgpu::BindGroupLayout; 2],
/// The resource packs' LabPBR maps (normals, specular), when they ship any.
pub pbr: Option<&'a [wgpu::Texture; 2]>,
}
/// The pack's terrain pipelines: solid (with the cutout test) and translucent, for the
@@ -134,12 +178,14 @@ fn vanilla_terrain() -> String {
)
}
/// `horizon_sprite_tags(layer)`: the bits of `MaterialInput.tags` of each sprite layer.
fn sprite_tags(tags: &[(u32, u32)]) -> String {
let mut out = String::from("fn horizon_sprite_tags(layer: u32) -> u32 {\n switch layer {\n");
let mut tags = tags.to_vec();
tags.sort_unstable_by_key(|&(layer, bits)| (bits, layer));
for group in tags.chunk_by(|a, b| a.1 == b.1) {
/// The function `name(layer)`, the value of each sprite layer in `table` and 0 for the rest:
/// `horizon_sprite_tags` (`MaterialInput.tags`) and `horizon_sprite_material`
/// (`MaterialInput.material`).
fn layer_table(name: &str, table: &[(u32, u32)]) -> String {
let mut out = format!("fn {name}(layer: u32) -> u32 {{\n switch layer {{\n");
let mut table = table.to_vec();
table.sort_unstable_by_key(|&(layer, value)| (value, layer));
for group in table.chunk_by(|a, b| a.1 == b.1) {
let layers: Vec<String> = group.iter().map(|(layer, _)| format!("{layer}u")).collect();
out += &format!(
" case {}: {{ return {}u; }}\n",
@@ -150,18 +196,24 @@ fn sprite_tags(tags: &[(u32, u32)]) -> String {
out + " default: { return 0u; }\n }\n}\n"
}
/// The terrain shader for the pack's material stage `source`, with its sprite `tags`.
fn shader_source(source: &str, tags: &[(u32, u32)]) -> String {
/// The terrain shader for the pack's material stage `source`, with its sprite `tags` and
/// `materials`.
fn shader_source(source: &str, tags: &[(u32, u32)], materials: &[(u32, u32)]) -> String {
format!(
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{ENTRIES}",
"{source}\n// Horizon's terrain (terrain.wgsl)\n{}{}{}{ENTRIES}",
vanilla_terrain(),
sprite_tags(tags)
layer_table("horizon_sprite_tags", tags),
layer_table("horizon_sprite_material", materials)
)
}
/// The material's naga module, its errors in the stage's file.
fn module(stage: &CheckedStage, tags: &[(u32, u32)]) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags);
fn module(
stage: &CheckedStage,
tags: &[(u32, u32)],
materials: &[(u32, u32)],
) -> Result<naga::Module, String> {
let text = shader_source(&stage.source, tags, materials);
naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))
}
@@ -174,14 +226,16 @@ impl NativeMaterial {
frame: &NativeFrame,
stage: &CheckedStage,
constants: &[(String, f64)],
fog: bool,
) -> Result<Self, String> {
let module = module(stage, shape.sprite_tags)?;
let module = module(stage, shape.sprite_tags, shape.sprite_materials)?;
let mut constants = present_constants(&module, constants);
let sprite_names = [
"horizon_terrain_SPRITE_ARRAYS",
"horizon_terrain_SPRITE_SPLIT",
];
constants.extend(sprite_names.into_iter().zip(shape.sprites));
constants.push(("horizon_material_fog", f64::from(u8::from(fog))));
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
@@ -346,14 +400,20 @@ mod tests {
return Surface(apply_fog(sprite(lit)) + (behind + frame_of) * 0.0);\n}\n";
let stage = stage(own);
let tags = [(7, 1), (3, 1), (9, 2)];
assert!(sprite_tags(&tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags).unwrap();
let materials = [(5, 1000)];
assert!(layer_table("t", &tags).contains("case 3u, 7u: { return 1u; }"));
let module = module(&stage, &tags, &materials).unwrap();
let info = naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&shader_source(&stage.source, &tags))));
.unwrap_or_else(|e| {
panic!(
"{}",
e.emit_to_string(&shader_source(&stage.source, &tags, &materials))
)
});
let entries: Vec<&str> = module
.entry_points
.iter()
@@ -5,6 +5,7 @@ use horizon_shader_api::{Report, StageKind};
use super::native_material::{NativeMaterial, TerrainShape};
use super::native_post::{NativeFrame, NativePost};
use super::native_shadow::NativeShadow;
use super::native_sky::NativeSky;
use super::pack_timing::PassTiming;
@@ -13,6 +14,13 @@ pub struct NativePack {
pub sky: Option<NativeSky>,
pub material: Option<NativeMaterial>,
pub post: Option<NativePost>,
/// With a sky stage, what of Minecraft's sky still draws over it (`[sky]`).
pub vanilla_clouds: bool,
pub vanilla_sun: bool,
pub vanilla_stars: bool,
pub weather: Option<wgpu::RenderPipeline>,
/// The draw of the shadow map the stages read, if the pack asks for one.
pub shadow: Option<NativeShadow>,
/// GPU time of each pass, while the renderer asks for it (`pack_timing.rs`).
pub timing: Option<PassTiming>,
}
@@ -24,12 +32,24 @@ impl NativePack {
/// refused.
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
terrain: &TerrainShape<'_>,
report: Report,
constants: &[(String, f64)],
lights: super::block_light::LightTable,
) -> Result<(Self, Vec<String>), String> {
let (globals, format) = (terrain.globals, terrain.format);
let mut frame = NativeFrame::new(device);
let textures = super::native_post::pack_textures(device, queue, &report);
let settings = report
.shadow
.as_ref()
.map(|settings| super::native_shadow::fitted(device, settings));
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
let shadow = report
.shadow
.as_ref()
.map(|s| NativeShadow::new(device, terrain, &frame, &s.solid_materials))
.transpose()?;
// The mirror is drawn for a material that reads it, while `[reflection] option` is on.
let option = report.reflection.as_ref().and_then(|r| r.option.as_ref());
frame.reflects = report
@@ -43,6 +63,34 @@ impl NativePack {
.find(|(k, _)| k == id)
.is_none_or(|(_, v)| *v != 0.0)
});
let fog = report.material_fog;
let keeps = |key: fn(&horizon_shader_api::Stage) -> Option<bool>| {
report.sky.as_ref().and_then(key).unwrap_or(true)
};
let vanilla_clouds = keeps(|s| s.clouds);
let vanilla_sun = keeps(|s| s.sun);
let vanilla_stars = keeps(|s| s.stars);
let weather = report
.stages
.iter()
.find(|s| s.kind == StageKind::Weather)
.map(|stage| {
super::weather::native_pipeline(
device,
format,
terrain.weather_groups[0],
terrain.weather_groups[1],
&frame,
stage,
constants,
)
})
.transpose()?;
let weather_label = report
.stages
.iter()
.find(|s| s.kind == StageKind::Weather)
.map(|s| s.label.clone());
let (post, others): (Vec<_>, Vec<_>) = report
.stages
.into_iter()
@@ -55,10 +103,10 @@ impl NativePack {
let material = others
.iter()
.find(|s| s.kind == StageKind::Material)
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants))
.map(|stage| NativeMaterial::new(device, terrain, &frame, stage, constants, fog))
.transpose()?;
let post = (!post.is_empty())
.then(|| NativePost::new(device, post, constants, format, &frame))
.then(|| NativePost::new(device, post, &report.manifest, constants, format, &frame))
.transpose()?;
let mut labels: Vec<String> = sky.iter().map(|s| s.label.clone()).collect();
labels.extend(
@@ -67,6 +115,7 @@ impl NativePack {
.filter(|s| material.is_some() && s.kind == StageKind::Material)
.map(|s| s.label.clone()),
);
labels.extend(weather_label);
if let Some(post) = &post {
labels.extend(post.labels().into_iter().map(str::to_owned));
}
@@ -75,6 +124,11 @@ impl NativePack {
sky,
material,
post,
vanilla_clouds,
vanilla_sun,
vanilla_stars,
weather,
shadow,
timing: None,
};
Ok((pack, labels))
@@ -82,6 +136,9 @@ impl NativePack {
/// Whether the pack draws anything the game runs today.
pub fn draws(&self) -> bool {
self.sky.is_some() || self.material.is_some() || self.post.is_some()
self.sky.is_some()
|| self.material.is_some()
|| self.weather.is_some()
|| self.post.is_some()
}
}
@@ -16,14 +16,72 @@ const INTERMEDIATE: wgpu::TextureFormat = wgpu::TextureFormat::Rgba16Float;
/// The world's colour and depth views a set of bind groups was made for.
type WorldViews = (wgpu::TextureView, wgpu::TextureView);
/// Where a post pass draws.
#[derive(Clone, Copy, PartialEq)]
enum Writes {
/// One of the chain's two targets.
Chain(usize),
/// A pack target (`[[post]] output`).
Target(usize),
/// The screen: the last pass.
Screen,
}
/// A post pass's target, and the chain's picture it reads as `previous` (the world's for
/// none).
#[derive(Clone, Copy)]
struct PassPlan {
writes: Writes,
reads: Option<usize>,
}
/// The passes' plan for `outputs` (each pass's `[[post]] output`, by target index): the chain's
/// passes write its targets in turn, each reading what the one before wrote; a pass with an
/// output writes that and reads the chain's picture, which goes on past it.
fn plan(outputs: &[Option<usize>]) -> Vec<PassPlan> {
let mut current = None;
outputs
.iter()
.enumerate()
.map(|(i, output)| {
let reads = current;
let writes = match output {
_ if i + 1 == outputs.len() => Writes::Screen,
Some(t) => Writes::Target(*t),
None => {
let slot = usize::from(current == Some(0));
current = Some(slot);
Writes::Chain(slot)
}
};
PassPlan { writes, reads }
})
.collect()
}
/// A pack target's size: `scale` of the world's `size`, at least a texel.
fn scaled(size: [u32; 2], scale: f32) -> [u32; 2] {
size.map(|s| ((s as f32 * scale).ceil() as u32).max(1))
}
pub struct NativePost {
passes: Vec<(String, wgpu::RenderPipeline)>,
layout: wgpu::BindGroupLayout,
/// The pack's textures.
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
frame: wgpu::Buffer,
linear: wgpu::Sampler,
nearest: wgpu::Sampler,
/// Two targets of the world's size, written in turn (none with a single pass).
targets: Option<([u32; 2], [wgpu::TextureView; 2])>,
/// The frame's shadow map, its comparison sampler (bindings 6 and 7), the translucent map
/// and its colour (bindings 9 and 10).
shadow: (
wgpu::TextureView,
wgpu::Sampler,
wgpu::TextureView,
wgpu::TextureView,
),
/// Last frame's input to the last pass (`history`, binding 8), made with the targets,
/// and a black 1×1 in its place before then.
history: Option<wgpu::Texture>,
@@ -32,6 +90,12 @@ pub struct NativePost {
groups: Option<(WorldViews, bool, Vec<wgpu::BindGroup>)>,
/// Whether `history` contains the last frame's input to the last pass.
history_valid: bool,
/// What each pass writes and reads (`[[post]] output`).
plan: Vec<PassPlan>,
/// The pack's targets' scales (`Manifest::post_targets`), and their views for the world's
/// size.
scales: Vec<f32>,
named: Option<([u32; 2], Vec<wgpu::TextureView>)>,
/// The screen's colour format, the world target's too.
format: wgpu::TextureFormat,
}
@@ -42,6 +106,10 @@ pub struct NativeFrame {
pub(super) buffer: wgpu::Buffer,
/// Group 3 of the sky and material stages.
pub layout: wgpu::BindGroupLayout,
/// The pack's `[[textures]]` and their samplers, bound for every stage.
pub textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
/// The colour of block light around the camera (`[[lights]]`), bindings 9 and 10.
pub block_light: super::block_light::BlockLight,
pub group: wgpu::BindGroup,
/// The world's depth before translucent terrain (the material stage's `opaque_depth`,
/// binding 6), copied each frame.
@@ -49,11 +117,17 @@ pub struct NativeFrame {
/// The world's colour before translucent terrain (`opaque_color`, binding 11), copied
/// with it; 1×1 while the world draws straight to the window.
pub opaque_color: wgpu::Texture,
/// The resource packs' LabPBR maps (bindings 12 and 13: normals, specular), a blank 1x1
/// without them.
pbr: [wgpu::TextureView; 2],
/// The material stage's `reflection` (binding 1): this frame's water mirror, else
/// `blank`; and its sampler (binding 2).
reflection: wgpu::TextureView,
blank: wgpu::TextureView,
reflection_sampler: wgpu::Sampler,
/// The shadow map the stages read, and the camera that draws it when the pack asks for one.
pub shadow_map: super::native_shadow::ShadowMap,
shadow_camera: Option<horizon_shaders::Shadow>,
/// Whether the pack's material reads `reflection` (`NativePack::new`): else no mirror
/// is drawn and `has_reflection` stays 0.
pub reflects: bool,
@@ -140,22 +214,61 @@ fn eased(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
}
impl NativeFrame {
pub fn new(device: &wgpu::Device) -> Self {
/// The frame of a pack with the `[shadow]` settings `shadow`, if any, and the
/// block light colours `lights`.
pub fn new(
device: &wgpu::Device,
shadow: Option<&horizon_shader_api::manifest::Shadow>,
textures: Vec<(wgpu::TextureView, wgpu::Sampler)>,
lights: super::block_light::LightTable,
pbr: Option<&[wgpu::Texture; 2]>,
) -> Self {
let buffer = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("Native shader pack frame"),
size: size_of::<FrameInputs>() as u64,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let mut entries = vec![
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
sampler_entry(2),
texture_entry(3, wgpu::TextureSampleType::Depth),
comparison_entry(4),
texture_entry(6, wgpu::TextureSampleType::Depth),
texture_entry(7, wgpu::TextureSampleType::Depth),
texture_entry(8, wgpu::TextureSampleType::Float { filterable: true }),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
pbr_entry(12),
pbr_entry(13),
];
let block_light_entries = [
wgpu::BindGroupLayoutEntry {
binding: 9,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D3,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: 10,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
];
entries.extend(block_light_entries);
entries.extend(pack_texture_entries(textures.len()));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack frame"),
entries: &[
frame_entry(wgpu::ShaderStages::VERTEX_FRAGMENT),
texture_entry(1, wgpu::TextureSampleType::Float { filterable: true }),
sampler_entry(2),
texture_entry(6, wgpu::TextureSampleType::Depth),
texture_entry(11, wgpu::TextureSampleType::Float { filterable: false }),
],
entries: &entries,
});
let opaque_depth = depth_copy(device, [1, 1]);
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
@@ -172,15 +285,50 @@ impl NativeFrame {
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
let shadow_map = super::native_shadow::ShadowMap::new(device, shadow);
let block_light = super::block_light::BlockLight::new(device, lights);
let array_view = |texture: &wgpu::Texture| {
texture.create_view(&wgpu::TextureViewDescriptor {
dimension: Some(wgpu::TextureViewDimension::D2Array),
..Default::default()
})
};
let pbr = match pbr {
Some(maps) => maps.each_ref().map(array_view),
None => {
// Two layers: one would make a 2D texture on the OpenGL backend.
let blank = device.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack LabPBR (none)"),
size: wgpu::Extent3d {
width: 1,
height: 1,
depth_or_array_layers: 2,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
[array_view(&blank), array_view(&blank)]
}
};
let group = Self::group(
device,
&layout,
&buffer,
(&blank, &reflection_sampler),
&opaque_depth,
&opaque_color,
&shadow_map,
(&opaque_depth, &opaque_color),
&textures,
&block_light,
&pbr,
);
Self {
textures,
block_light,
pbr,
buffer,
layout,
group,
@@ -189,6 +337,8 @@ impl NativeFrame {
reflection: blank.clone(),
blank,
reflection_sampler,
shadow_map,
shadow_camera: shadow.map(super::native_shadow::camera),
reflects: false,
last: None,
eye: [f32::NAN; 4],
@@ -199,43 +349,93 @@ impl NativeFrame {
}
}
/// What the shadow camera sees this frame, for the renderer's shadow lists.
pub fn shadow_view(&self, frame: &Frame) -> Option<super::pack_post::ShadowView> {
let camera = self.shadow_camera.as_ref()?;
Some(super::pack_post::ShadowView {
cull: super::pack_uniforms::shadow_culling(frame, camera),
light: super::pack_uniforms::shadow_light(frame, camera),
// The light's box culls: on the ground it reaches further than `distance` along
// the sun's azimuth while the sun is low.
distance: None,
unculled: None,
})
}
fn group(
device: &wgpu::Device,
layout: &wgpu::BindGroupLayout,
buffer: &wgpu::Buffer,
(reflection, sampler): (&wgpu::TextureView, &wgpu::Sampler),
opaque_depth: &wgpu::Texture,
opaque_color: &wgpu::Texture,
shadow: &super::native_shadow::ShadowMap,
(opaque_depth, opaque_color): (&wgpu::Texture, &wgpu::Texture),
textures: &[(wgpu::TextureView, wgpu::Sampler)],
block_light: &super::block_light::BlockLight,
pbr: &[wgpu::TextureView; 2],
) -> wgpu::BindGroup {
let opaque_depth = opaque_depth.create_view(&Default::default());
let opaque_color = opaque_color.create_view(&Default::default());
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(reflection),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(sampler),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(&shadow.view),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&shadow.sampler),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::TextureView(&shadow.translucent_view),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&shadow.color_view),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&opaque_depth),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(&opaque_color),
},
wgpu::BindGroupEntry {
binding: 12,
resource: wgpu::BindingResource::TextureView(&pbr[0]),
},
wgpu::BindGroupEntry {
binding: 13,
resource: wgpu::BindingResource::TextureView(&pbr[1]),
},
];
entries.extend([
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&block_light.view),
},
wgpu::BindGroupEntry {
binding: 10,
resource: block_light.grid.as_entire_binding(),
},
]);
entries.extend(pack_texture_group_entries(textures));
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack frame"),
layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(reflection),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::Sampler(sampler),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(
&opaque_depth.create_view(&Default::default()),
),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(
&opaque_color.create_view(&Default::default()),
),
},
],
entries: &entries,
})
}
@@ -284,8 +484,11 @@ impl NativeFrame {
&self.layout,
&self.buffer,
(&self.reflection, &self.reflection_sampler),
&self.opaque_depth,
&self.opaque_color,
&self.shadow_map,
(&self.opaque_depth, &self.opaque_color),
&self.textures,
&self.block_light,
&self.pbr,
);
}
@@ -295,7 +498,13 @@ impl NativeFrame {
let now = std::time::Instant::now();
let since = self.wetness.1.map_or(0.0, |t| (now - t).as_secs_f32());
self.wetness = (wetness(self.wetness.0, frame.pack.rain, since), Some(now));
let mut inputs = frame_inputs(frame, size, seconds, self.frames);
let mut inputs = frame_inputs(
frame,
size,
seconds,
self.frames,
self.shadow_camera.as_ref(),
);
inputs.wetness = self.wetness.0;
if !self.reflects {
inputs.has_reflection = 0;
@@ -377,6 +586,29 @@ fn texture_entry(binding: u32, sample_type: wgpu::TextureSampleType) -> wgpu::Bi
}
}
/// A LabPBR map's array, read by the material entries (`native_material.rs`).
fn pbr_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2Array,
multisampled: false,
},
count: None,
}
}
fn comparison_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Comparison),
count: None,
}
}
fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
@@ -392,22 +624,43 @@ impl NativePost {
pub fn new(
device: &wgpu::Device,
stages: Vec<CheckedStage>,
manifest: &horizon_shader_api::Manifest,
constants: &[(String, f64)],
format: wgpu::TextureFormat,
frame: &NativeFrame,
) -> Result<Self, String> {
let targets = manifest.post_targets();
let outputs: Vec<Option<usize>> = manifest
.post
.iter()
.map(|p| {
let name = p.output.as_deref()?;
targets.iter().position(|t| t.0 == name)
})
.collect();
let plan = plan(&outputs);
let float = wgpu::TextureSampleType::Float { filterable: true };
let mut entries = vec![
frame_entry(wgpu::ShaderStages::FRAGMENT),
texture_entry(1, float),
texture_entry(2, wgpu::TextureSampleType::Depth),
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
texture_entry(6, wgpu::TextureSampleType::Depth),
comparison_entry(7),
texture_entry(8, float),
texture_entry(9, wgpu::TextureSampleType::Depth),
texture_entry(10, float),
];
entries.extend(pack_texture_entries(frame.textures.len()));
entries
.extend((0..targets.len() as u32).map(|j| {
texture_entry(horizon_shader_api::stages::POST_TARGET_BINDING + j, float)
}));
let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack post"),
entries: &[
frame_entry(wgpu::ShaderStages::FRAGMENT),
texture_entry(1, float),
texture_entry(2, wgpu::TextureSampleType::Depth),
texture_entry(3, float),
sampler_entry(4),
sampler_entry(5),
texture_entry(8, float),
],
entries: &entries,
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[GROUP as usize] = Some(&layout);
@@ -446,7 +699,11 @@ impl NativePost {
entry_point: Some("horizon_fs_post"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format: if i + 1 == count { format } else { INTERMEDIATE },
format: if plan[i].writes == Writes::Screen {
format
} else {
INTERMEDIATE
},
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
@@ -470,7 +727,14 @@ impl NativePost {
Ok(Self {
passes,
layout,
textures: frame.textures.clone(),
frame: frame.buffer.clone(),
shadow: (
frame.shadow_map.view.clone(),
frame.shadow_map.sampler.clone(),
frame.shadow_map.translucent_view.clone(),
frame.shadow_map.color_view.clone(),
),
linear: sampler("Native shader pack linear", wgpu::FilterMode::Linear),
nearest: sampler("Native shader pack nearest", wgpu::FilterMode::Nearest),
targets: None,
@@ -478,6 +742,9 @@ impl NativePost {
history_blank: history_target(device, "Native shader pack history blank", [1, 1]),
groups: None,
history_valid: false,
scales: targets.iter().map(|t| t.1).collect(),
named: None,
plan,
format,
})
}
@@ -494,7 +761,39 @@ impl NativePost {
size: [u32; 2],
history_reset: bool,
) {
if self.passes.len() > 1 && self.targets.as_ref().is_none_or(|t| t.0 != size) {
if !self.scales.is_empty() && self.named.as_ref().is_none_or(|n| n.0 != size) {
let views = self
.scales
.iter()
.map(|&scale| {
let size = scaled(size, scale);
device
.create_texture(&wgpu::TextureDescriptor {
label: Some("Native shader pack target"),
size: wgpu::Extent3d {
width: size[0],
height: size[1],
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: INTERMEDIATE,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT
| wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
})
.create_view(&Default::default())
})
.collect();
self.named = Some((size, views));
self.groups = None;
}
let chain = self
.plan
.iter()
.any(|p| matches!(p.writes, Writes::Chain(_)));
if chain && self.targets.as_ref().is_none_or(|t| t.0 != size) {
let target = |label| {
device
.create_texture(&wgpu::TextureDescriptor {
@@ -545,46 +844,79 @@ impl NativePost {
.expect("history exists after a post stage has more than one pass")
}
.create_view(&Default::default());
let blank = self.history_blank.create_view(&Default::default());
let groups = (0..self.passes.len())
.map(|i| {
// Pass i reads what pass i - 1 wrote: target (i - 1) % 2, the world for the first.
let previous = match (&self.targets, i) {
(Some((_, t)), 1..) => &t[(i - 1) % 2],
// The chain's picture so far: the world's before its first pass.
let previous = match (&self.targets, self.plan[i].reads) {
(Some((_, t)), Some(slot)) => &t[slot],
_ => world.0,
};
let mut entries = vec![
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(world.0),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 6,
resource: wgpu::BindingResource::TextureView(&self.shadow.0),
},
wgpu::BindGroupEntry {
binding: 7,
resource: wgpu::BindingResource::Sampler(&self.shadow.1),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
wgpu::BindGroupEntry {
binding: 9,
resource: wgpu::BindingResource::TextureView(&self.shadow.2),
},
wgpu::BindGroupEntry {
binding: 10,
resource: wgpu::BindingResource::TextureView(&self.shadow.3),
},
];
entries.extend(pack_texture_group_entries(&self.textures));
// The pack's targets, a blank one in place of the one this pass writes.
if let Some((_, views)) = &self.named {
entries.extend(views.iter().enumerate().map(|(j, view)| {
let view = if self.plan[i].writes == Writes::Target(j) {
&blank
} else {
view
};
wgpu::BindGroupEntry {
binding: horizon_shader_api::stages::POST_TARGET_BINDING + j as u32,
resource: wgpu::BindingResource::TextureView(view),
}
}));
}
device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack post"),
layout: &self.layout,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: self.frame.as_entire_binding(),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::TextureView(world.0),
},
wgpu::BindGroupEntry {
binding: 2,
resource: wgpu::BindingResource::TextureView(world.1),
},
wgpu::BindGroupEntry {
binding: 3,
resource: wgpu::BindingResource::TextureView(previous),
},
wgpu::BindGroupEntry {
binding: 4,
resource: wgpu::BindingResource::Sampler(&self.linear),
},
wgpu::BindGroupEntry {
binding: 5,
resource: wgpu::BindingResource::Sampler(&self.nearest),
},
wgpu::BindGroupEntry {
binding: 8,
resource: wgpu::BindingResource::TextureView(&history),
},
],
entries: &entries,
})
})
.collect();
@@ -611,8 +943,9 @@ impl NativePost {
return;
};
for (i, ((label, pipeline), group)) in self.passes.iter().zip(groups).enumerate() {
let view = match &self.targets {
Some((_, t)) if i + 1 < self.passes.len() => &t[i % 2],
let view = match (self.plan[i].writes, &self.targets, &self.named) {
(Writes::Chain(slot), Some((_, t)), _) => &t[slot],
(Writes::Target(j), _, Some((_, n))) => &n[j],
_ => screen,
};
let attachments = [Some(wgpu::RenderPassColorAttachment {
@@ -634,14 +967,17 @@ impl NativePost {
pass.set_bind_group(GROUP, group, &[]);
pass.draw(0..3, 0..1);
}
// The last pass's input becomes next frame's `history`, once that pass has read this
// frame's.
let count = self.passes.len();
if count > 1
// The chain's last picture becomes next frame's `history`, once every pass has read
// this frame's.
let before_last = &self.plan[..self.plan.len() - 1];
if let Some(Writes::Chain(slot)) = before_last
.iter()
.rfind(|p| matches!(p.writes, Writes::Chain(_)))
.map(|p| p.writes)
&& let (Some(history), Some((_, targets))) = (&self.history, &self.targets)
{
encoder.copy_texture_to_texture(
targets[(count - 2) % 2].texture().as_image_copy(),
targets[slot].texture().as_image_copy(),
history.as_image_copy(),
history.size(),
);
@@ -676,7 +1012,13 @@ fn wetness(wetness: f32, rain: f32, dt: f32) -> f32 {
}
/// The frame as packs read it (docs/api/shaders-reference.md), from the renderer's `Frame`.
pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) -> FrameInputs {
pub fn frame_inputs(
frame: &Frame,
size: [u32; 2],
seconds: f32,
index: u32,
shadow: Option<&horizon_shaders::Shadow>,
) -> FrameInputs {
let aspect = size[0] as f32 / size[1].max(1) as f32;
let view = crate::camera::view_matrix(frame.yaw, frame.pitch);
let projection = super::world_projection(frame.fov_degrees, aspect, frame.depth_far)
@@ -748,11 +1090,147 @@ pub fn frame_inputs(frame: &Frame, size: [u32; 2], seconds: f32, index: u32) ->
water_plane: frame.water_plane.unwrap_or(0.0),
has_reflection: u32::from(frame.water_plane.is_some()),
reflection_resolution: reflection_size(size).map(|s| s as f32),
mods: pack.mod_params,
light_direction: match shadow {
Some(s) => super::pack_uniforms::shadow_light(frame, s),
None => Vec3::Y,
}
.to_array(),
has_shadow: u32::from(shadow.is_some()),
shadow_view_proj: m(shadow.map_or(Mat4::IDENTITY, |s| {
super::pack_uniforms::shadow_culling(frame, s)
})),
shadow_distance: shadow.map_or(0.0, |s| s.distance),
shadow_distortion: shadow.map_or(0.0, |s| {
super::native_shadow::distortion(s.distance, s.resolution)
}),
shadow_resolution: shadow.map_or(0.0, |s| s.resolution as f32),
shadow_center_texel: shadow.map_or(0.0, |s| {
let k = super::native_shadow::distortion(s.distance, s.resolution);
2.0 * s.distance / s.resolution.max(1) as f32 * (1.0 - k)
}),
}
}
/// The pack's `[[textures]]` (`check::Image`s), uploaded with samplers as the manifest says.
pub fn pack_textures(
device: &wgpu::Device,
queue: &wgpu::Queue,
report: &horizon_shader_api::Report,
) -> Vec<(wgpu::TextureView, wgpu::Sampler)> {
report
.textures
.iter()
.map(|(t, image)| {
let texture = wgpu::util::DeviceExt::create_texture_with_data(
device,
queue,
&wgpu::TextureDescriptor {
label: Some(&t.id),
size: wgpu::Extent3d {
width: image.width,
height: image.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&image.rgba,
);
let filter = if t.linear {
wgpu::FilterMode::Linear
} else {
wgpu::FilterMode::Nearest
};
let address = if t.repeat {
wgpu::AddressMode::Repeat
} else {
wgpu::AddressMode::ClampToEdge
};
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some(&t.id),
address_mode_u: address,
address_mode_v: address,
mag_filter: filter,
min_filter: filter,
..Default::default()
});
(texture.create_view(&Default::default()), sampler)
})
.collect()
}
/// Layout entries of `count` pack textures and their samplers.
pub(super) fn pack_texture_entries(count: usize) -> Vec<wgpu::BindGroupLayoutEntry> {
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
(0..count as u32)
.flat_map(|i| {
[
wgpu::BindGroupLayoutEntry {
binding: TEXTURE_BINDING + i,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
wgpu::BindGroupLayoutEntry {
binding: TEXTURE_SAMPLER_BINDING + i,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
]
})
.collect()
}
/// Group entries of the pack's textures, as `pack_texture_entries` lays them out.
pub(super) fn pack_texture_group_entries(
textures: &[(wgpu::TextureView, wgpu::Sampler)],
) -> Vec<wgpu::BindGroupEntry<'_>> {
use horizon_shader_api::stages::{TEXTURE_BINDING, TEXTURE_SAMPLER_BINDING};
textures
.iter()
.enumerate()
.flat_map(|(i, (view, sampler))| {
[
wgpu::BindGroupEntry {
binding: TEXTURE_BINDING + i as u32,
resource: wgpu::BindingResource::TextureView(view),
},
wgpu::BindGroupEntry {
binding: TEXTURE_SAMPLER_BINDING + i as u32,
resource: wgpu::BindingResource::Sampler(sampler),
},
]
})
.collect()
}
#[cfg(test)]
mod tests {
#[test]
fn a_pass_with_an_output_leaves_the_chain_as_it_was() {
use super::{Writes, plan};
// Bloom into a target, a pass of the chain, a second target, the picture.
let p = plan(&[Some(0), None, Some(1), None, None]);
let writes: Vec<_> = p.iter().map(|p| p.writes).collect();
assert!(writes[0] == Writes::Target(0));
assert!(writes[1] == Writes::Chain(0));
assert!(writes[2] == Writes::Target(1));
assert!(writes[3] == Writes::Chain(1));
assert!(writes[4] == Writes::Screen);
let reads: Vec<_> = p.iter().map(|p| p.reads).collect();
assert_eq!(reads, [None, None, Some(0), Some(0), Some(1)]);
}
#[test]
fn eased_values_start_at_the_target_and_halve_the_distance() {
use super::eased;
@@ -814,7 +1292,7 @@ mod tests {
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device);
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
queue.write_buffer(
&frame.buffer,
0,
@@ -823,6 +1301,7 @@ mod tests {
let mut post = NativePost::new(
&device,
report.stages,
&report.manifest,
&[("gain".into(), 1.0)],
format,
&frame,
@@ -933,8 +1412,16 @@ file = \"b.wgsl\"
.into();
let report = check(&Pack::from_files(files).unwrap()).unwrap();
let format = wgpu::TextureFormat::Rgba8Unorm;
let frame = NativeFrame::new(&device);
let mut post = NativePost::new(&device, report.stages, &[], format, &frame).unwrap();
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
let mut post = NativePost::new(
&device,
report.stages,
&report.manifest,
&[],
format,
&frame,
)
.unwrap();
let usage = wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING;
let color = texture(&device, format, usage).create_view(&Default::default());
let depth = texture(&device, wgpu::TextureFormat::Depth32Float, usage)
@@ -0,0 +1,668 @@
//! The shadow map of Horizon's own shader packs (`[shadow]`, docs/shaders.md): each frame the
//! solid terrain and entities the light (the sun by day, the moon by night) sees are drawn into a square depth
//! map, which the material and post stages read as `shadow_map`; translucent terrain goes over a
//! copy of it, `shadow_map_translucent`, with its colour in `shadow_color`. The shadow camera is the one
//! `shaders/` packs use (`pack_uniforms::shadow_camera`), so the renderer's shadow visibility and
//! draw lists (`ShadowView`) serve both; the map's texels are distorted toward the camera as the
//! prelude's `shadow_position` undoes.
use horizon_shader_api::{Frame as FrameInputs, manifest::Shadow as Settings, stages::GROUP};
use horizon_shaders::Shadow;
use super::native_material::TerrainShape;
use super::wgsl_prefix::{TERRAIN, prefixed, section};
const PREFIX: &str = "horizon_terrain_";
/// The shadow pass's entries: terrain's vertices through the shadow camera, distorted, and the
/// cutout test (leaves' and plants' holes let the light through).
const ENTRIES: &str = "
struct HorizonShadowOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) @interpolate(flat) layer: u32,
@location(2) tint: vec3<f32>,
@location(3) @interpolate(flat) fluid: u32,
}
// `color` is the vertex colour: the tint, and the fluid flag in its alpha's high nibble
// (mesher.rs's `fluid_face`).
fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<f32>)
-> HorizonShadowOut {
let clip = frame.shadow_view_proj * vec4(pos, 1.0);
let xy = shadow_distort(clip.xy);
var out: HorizonShadowOut;
out.clip = vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0);
out.uv = uv;
out.layer = layer;
out.tint = color.rgb;
let high = u32(round(color.a * 255.0)) >> 4u;
out.fluid = u32(high >= 8u && (high & 7u) < 6u);
return out;
}
@vertex fn horizon_shadow_vs(
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>,
@location(2) layer: u32, @location(3) color: vec4<f32>,
@location(5) origin: vec4<f32>) -> HorizonShadowOut {
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
}
@vertex fn horizon_shadow_vs_flat(
@builtin(vertex_index) index: u32,
@location(0) position: vec3<f32>, @location(1) uv: vec2<u32>, @location(2) layer: u32,
@location(3) color: vec4<f32>
) -> HorizonShadowOut {
let origin = horizon_terrain_slot_origins[horizon_terrain_block_slots[index >> 8u]];
return horizon_shadow_vertex(position + origin.xyz, horizon_terrain_terrain_uv(uv, origin.w), layer, color);
}
@fragment fn horizon_shadow_fs(in: HorizonShadowOut) {
// Bit 15 of the layer marks opaque leaves (forceOpaque): no alpha cutout.
let alpha = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu).a;
if alpha < 0.5 && (in.layer & 0x8000u) == 0u && !horizon_shadow_solid(in.layer & 0x7fffu) { discard; }
}
// Translucent terrain: its colour where it is the nearest thing the light passes through;
// alpha 1 marks a fluid, other surfaces keep their alpha below it.
@fragment fn horizon_shadow_fs_translucent(in: HorizonShadowOut) -> @location(0) vec4<f32> {
let color = horizon_terrain_sprite(in.uv, in.layer & 0x7fffu);
if color.a < 0.01 { discard; }
return vec4(color.rgb * in.tint, select(min(color.a, 0.99), 1.0, in.fluid != 0u));
}
";
/// The entity draw of the shadow pass: the renderer's opaque entity quads (`EntityBatch`'s first
/// material) through the shadow camera, with their texture's cutout. The sky's bodies, clouds,
/// beacon beams and breaking cracks share that buffer but are unlit (entity.wgsl's overlays) and
/// cast nothing. A quad's layer holds the overlay in its low 16 bits.
const ENTITY_ENTRIES: &str = "
@group(0) @binding(1) var horizon_atlas: texture_2d<f32>;
@group(0) @binding(2) var horizon_atlas_sampler: sampler;
struct HorizonEntityShadowOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) alpha: f32,
}
@vertex fn horizon_entity_shadow_vs(@location(0) position: vec3<f32>,
@location(1) uv: vec2<f32>, @location(2) layer: u32, @location(3) color: vec4<f32>)
-> HorizonEntityShadowOut {
let clip = frame.shadow_view_proj * vec4(position, 1.0);
let xy = shadow_distort(clip.xy);
let overlay = layer & 0xffffu;
// As entity.wgsl's `unlit`.
let unlit = overlay == 2u || overlay == 4u || overlay == 6u
|| (overlay >= 9u && overlay <= 12u);
var out: HorizonEntityShadowOut;
// Unlit geometry lands outside the clip volume.
out.clip = select(vec4(xy, clamp(clip.z, 0.0, 1.0), 1.0), vec4(2.0, 2.0, 2.0, 1.0), unlit);
out.uv = uv;
out.alpha = color.a;
return out;
}
@fragment fn horizon_entity_shadow_fs(in: HorizonEntityShadowOut) {
let alpha = textureSampleLevel(horizon_atlas, horizon_atlas_sampler, in.uv, 0.0).a;
if alpha * in.alpha < 0.5 { discard; }
}
";
/// `shadow_color`'s format.
const SHADOW_COLOR: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm;
/// The shader pack's shadow camera for `settings`: orthographic over `distance` each side,
/// staying put while the camera walks a `SNAP`-block cell. The map is distorted about its
/// centre, so a centre that moves with the camera, even by a texel, resamples every shadow but
/// the nearest each frame and they shimmer; a still one maps the world to the same texels until
/// the next cell. Its depth starts 256 blocks further toward the light than `shaders/` packs'
/// (the projection's near plane is at `-near`), so the ground over a deep cave still casts its
/// shadow, and ends past the build height and the slant across the map below the camera.
pub fn camera(settings: &Settings) -> Shadow {
Shadow {
resolution: settings.resolution,
distance: settings.distance,
render_mul: 1.0,
interval: SNAP,
euclidean_snap: true,
near: 256.0,
far: super::pack_uniforms::SHADOW_EYE + 2.0 * settings.distance + 384.0,
..Shadow::default()
}
}
/// `settings` with the resolution `device` can make.
pub fn fitted(device: &wgpu::Device, settings: &Settings) -> Settings {
Settings {
resolution: settings
.resolution
.min(device.limits().max_texture_dimension_2d),
..settings.clone()
}
}
/// The cell the shadow camera keeps to, in blocks along the world's axes: the player stays
/// within 7 blocks of the centre (half the cell on each axis), where the map is still over
/// three times as fine as at its edge.
const SNAP: f32 = 8.0;
/// The ground one shadow texel covers right under the camera, in blocks: a 32nd, half a texture
/// pixel, so shadow edges near the player stay as sharp as the blocks casting them.
const CENTRE_TEXEL: f32 = 1.0 / 32.0;
/// How strongly the map is distorted toward its centre (`Frame::shadow_distortion`).
/// `xy / (|xy| k + 1 - k)` magnifies the centre by 1 / (1 - k); an undistorted texel covers
/// 2 distance / resolution blocks, so k is what brings that down to `CENTRE_TEXEL`. None when
/// the map is already that fine; at most 0.95, past which the edge of the map gets too few texels.
pub fn distortion(distance: f32, resolution: u32) -> f32 {
let texel = 2.0 * distance / resolution.max(1) as f32;
(1.0 - CENTRE_TEXEL / texel).clamp(0.0, 0.95)
}
/// The shadow map: a square depth map of the pack's resolution, or a 1×1 one (cleared to "far",
/// so everything is lit) for a pack without `[shadow]`; the same with translucent terrain drawn
/// in, and that terrain's colour.
pub struct ShadowMap {
depth: wgpu::Texture,
pub view: wgpu::TextureView,
translucent: wgpu::Texture,
pub translucent_view: wgpu::TextureView,
pub color_view: wgpu::TextureView,
pub sampler: wgpu::Sampler,
/// The 1×1 map still holds the zeroes it was made with.
blank_uncleared: bool,
}
impl ShadowMap {
pub fn new(device: &wgpu::Device, settings: Option<&Settings>) -> Self {
let size = settings.map_or(1, |s| s.resolution);
let texture = |label, format, usage| {
device.create_texture(&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format,
usage: wgpu::TextureUsages::TEXTURE_BINDING
| wgpu::TextureUsages::RENDER_ATTACHMENT
| usage,
view_formats: &[],
})
};
let depth = texture(
"Native shader pack shadow map",
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::COPY_SRC,
);
let translucent = texture(
"Native shader pack translucent shadow map",
wgpu::TextureFormat::Depth32Float,
wgpu::TextureUsages::COPY_DST,
);
let color = texture(
"Native shader pack shadow colour",
SHADOW_COLOR,
wgpu::TextureUsages::empty(),
);
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("Native shader pack shadow comparison"),
compare: Some(wgpu::CompareFunction::LessEqual),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
..Default::default()
});
Self {
view: depth.create_view(&Default::default()),
depth,
translucent_view: translucent.create_view(&Default::default()),
translucent,
color_view: color.create_view(&Default::default()),
sampler,
blank_uncleared: settings.is_none(),
}
}
/// A depth pass into the map that clears it to "far".
pub fn begin<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
) -> wgpu::RenderPass<'e> {
self.depth_pass(encoder, label, wgpu::LoadOp::Clear(1.0))
}
/// A depth pass that adds to the map. Entities draw here, after the translucent map was
/// copied: where one casts, the solid map is the darker and decides.
pub fn begin_entities<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
) -> wgpu::RenderPass<'e> {
self.depth_pass(encoder, label, wgpu::LoadOp::Load)
}
fn depth_pass<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
load: wgpu::LoadOp<f32>,
) -> wgpu::RenderPass<'e> {
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &[],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.view,
depth_ops: Some(wgpu::Operations {
load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
})
}
/// The translucent pass: the solid terrain's map copied into the translucent one, which it
/// goes on drawing into, and the colour cleared to white (nothing to pass through).
pub fn begin_translucent<'e>(
&self,
encoder: &'e mut wgpu::CommandEncoder,
label: &str,
) -> wgpu::RenderPass<'e> {
encoder.copy_texture_to_texture(
self.depth.as_image_copy(),
self.translucent.as_image_copy(),
self.depth.size(),
);
encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some(label),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.color_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color::WHITE),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.translucent_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
})
}
/// Clears the 1×1 map of a pack without `[shadow]` once.
pub fn clear_blank(&mut self, encoder: &mut wgpu::CommandEncoder) {
if std::mem::take(&mut self.blank_uncleared) {
drop(self.begin(encoder, "Native shader pack shadow map clear"));
drop(self.begin_translucent(encoder, "Native shader pack shadow colour clear"));
}
}
}
/// The pipelines that draw terrain into the shadow map, for the renderer's instanced draws and,
/// where they run, its flat ones, and the frame they read (group 3, the uniform alone: the
/// stages' own group 3 also holds the map being drawn).
pub struct NativeShadow {
pub solid: wgpu::RenderPipeline,
pub flat: Option<wgpu::RenderPipeline>,
/// The translucent terrain's draws into the translucent map and the colour, instanced and
/// flat.
pub translucent: wgpu::RenderPipeline,
pub flat_translucent: Option<wgpu::RenderPipeline>,
/// The entities' draw, with the entities' group 0 (`ENTITY_ENTRIES`).
pub entity: wgpu::RenderPipeline,
pub group: wgpu::BindGroup,
}
/// The entity draw's shader.
fn entity_source() -> String {
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}{ENTITY_ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
)
}
/// The shadow pass's entity pipeline: the entities' vertices and group 0, the frame at group 3.
fn entity_pipeline(
device: &wgpu::Device,
entities: &wgpu::BindGroupLayout,
frame_layout: &wgpu::BindGroupLayout,
) -> wgpu::RenderPipeline {
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Native shader pack entity shadow"),
source: wgpu::ShaderSource::Wgsl(entity_source().into()),
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[0] = Some(entities);
groups[GROUP as usize] = Some(frame_layout);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack entity shadow"),
bind_group_layouts: &groups,
..Default::default()
});
let attributes =
wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x2, 2 => Uint32, 3 => Unorm8x4];
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack entity shadow"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("horizon_entity_shadow_vs"),
compilation_options: Default::default(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
// Both sides, as the terrain: thin parts (ears, item quads) cast from either.
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: wgpu::DepthBiasState {
constant: 0,
slope_scale: 1.5,
clamp: 0.0,
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("horizon_entity_shadow_fs"),
compilation_options: Default::default(),
targets: &[],
}),
multiview_mask: None,
cache: None,
})
}
/// `horizon_shadow_solid(layer)`: whether the sprite layer's id, out of `materials`
/// (`(layer, id)`), is one of the pack's `solid` ones.
fn shadow_solid(materials: &[(u32, u32)], solid: &[u32]) -> String {
let layers: Vec<String> = materials
.iter()
.filter(|(_, id)| solid.contains(id))
.map(|(layer, _)| format!("{layer}u"))
.collect();
let cases = if layers.is_empty() {
String::new()
} else {
format!(" case {}: {{ return true; }}\n", layers.join(", "))
};
format!(
"fn horizon_shadow_solid(layer: u32) -> bool {{\n switch layer {{\n{cases} \
default: {{ return false; }}\n }}\n}}\n"
)
}
/// The shadow pass's shader: the frame, Horizon's terrain, and the entries, with the layers
/// `solid` of `shadow_solid`.
fn shader_source(solid: &str) -> String {
format!(
"{}@group({GROUP}) @binding(0) var<uniform> frame: Frame;\n{}\
// Horizon's terrain (terrain.wgsl)\n{}{solid}{ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
prefixed(
section(TERRAIN, "struct Globals {", "// Sky (SkyRenderer)"),
PREFIX
)
)
}
impl NativeShadow {
pub fn new(
device: &wgpu::Device,
shape: &TerrainShape<'_>,
frame: &super::native_post::NativeFrame,
solid_materials: &[u32],
) -> Result<Self, String> {
let text = shader_source(&shadow_solid(shape.sprite_materials, solid_materials));
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("shadow pass: {}", e.emit_to_string(&text)))?;
let constants = [
("horizon_terrain_SPRITE_ARRAYS", shape.sprites[0]),
("horizon_terrain_SPRITE_SPLIT", shape.sprites[1]),
];
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("Native shader pack shadow"),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let frame_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Native shader pack shadow frame"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: wgpu::BufferSize::new(size_of::<FrameInputs>() as u64),
},
count: None,
}],
});
let group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Native shader pack shadow frame"),
layout: &frame_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: frame.buffer.as_entire_binding(),
}],
});
let layout = |flat: Option<&wgpu::BindGroupLayout>| {
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] =
Default::default();
groups[0] = Some(shape.globals);
groups[1] = flat;
groups[GROUP as usize] = Some(&frame_layout);
device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack shadow"),
bind_group_layouts: &groups,
..Default::default()
})
};
let instance_attributes = wgpu::vertex_attr_array![5=>Float32x4];
let vertices = wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<super::TerrainVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &super::TERRAIN_ATTRIBUTES,
};
let instances = wgpu::VertexBufferLayout {
array_stride: 16,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &instance_attributes,
};
let color_target = [Some(wgpu::ColorTargetState {
format: SHADOW_COLOR,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})];
let pipeline = |layout: &wgpu::PipelineLayout,
vertex: &str,
buffers: &[Option<wgpu::VertexBufferLayout<'_>>],
translucent: bool| {
let options = wgpu::PipelineCompilationOptions {
constants: &constants,
..Default::default()
};
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack shadow"),
layout: Some(layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some(vertex),
compilation_options: options.clone(),
buffers,
},
// Both sides: the quads that face the light are drawn (`facing_runs`), and
// plants' crossed quads cast from either side.
primitive: wgpu::PrimitiveState {
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::LessEqual),
stencil: Default::default(),
bias: wgpu::DepthBiasState {
constant: 0,
slope_scale: 1.5,
clamp: 0.0,
},
}),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some(if translucent {
"horizon_shadow_fs_translucent"
} else {
"horizon_shadow_fs"
}),
compilation_options: options,
targets: if translucent { &color_target } else { &[] },
}),
multiview_mask: None,
cache: None,
})
};
let instanced = layout(None);
let solid = pipeline(
&instanced,
"horizon_shadow_vs",
&[Some(vertices.clone()), Some(instances.clone())],
false,
);
let translucent = pipeline(
&instanced,
"horizon_shadow_vs",
&[Some(vertices.clone()), Some(instances)],
true,
);
let flat_layout = shape.flat.map(|group| layout(Some(group)));
let flat_pipeline = |translucent| {
flat_layout.as_ref().map(|layout| {
pipeline(
layout,
"horizon_shadow_vs_flat",
&[Some(vertices.clone())],
translucent,
)
})
};
let flat = flat_pipeline(false);
let flat_translucent = flat_pipeline(true);
let entity = entity_pipeline(device, shape.entities, &frame_layout);
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("shadow pass: {e}"));
}
Ok(Self {
solid,
flat,
translucent,
flat_translucent,
entity,
group,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_shadow_shader_validates() {
for materials in [&[][..], &[(3, 7), (4, 8), (9, 7)][..]] {
let text = shader_source(&shadow_solid(materials, &[7]));
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
}
#[test]
fn only_the_layers_of_solid_ids_lose_their_cutouts() {
let materials = [(3, 7), (4, 8), (9, 7)];
assert!(shadow_solid(&materials, &[7]).contains("case 3u, 9u: { return true; }"));
assert!(!shadow_solid(&materials, &[]).contains("case"));
assert!(!shadow_solid(&materials, &[5]).contains("case"));
}
#[test]
fn the_entity_shader_validates() {
let text = entity_source();
let module = naga::front::wgsl::parse_str(&text).unwrap();
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
#[test]
fn what_the_camera_stands_on_is_inside_the_shadow_cameras_depth() {
let settings = Settings {
resolution: 2048,
distance: 128.0,
solid_materials: Vec::new(),
};
let shadow = camera(&settings);
let (view, projection) = super::super::pack_uniforms::shadow_camera(
glam::Vec3::new(0.3, 0.9, 0.0).normalize(),
glam::DVec3::new(-2064.5, 246.0, -4304.5),
&shadow,
);
// Up to the build height's span below a player flying high, as well as above them.
for y in [64.0, 0.0, -64.0, -200.0, -384.0] {
let clip = projection * view * glam::Vec4::new(0.0, y, 0.0, 1.0);
assert!(clip.is_finite(), "{clip}");
assert!((-1.0..=1.0).contains(&clip.z), "{clip}");
}
}
#[test]
fn the_centre_stays_near_the_player_on_both_sides_of_zero() {
let shadow = camera(&Settings {
resolution: 2048,
distance: 128.0,
solid_materials: Vec::new(),
});
let light = glam::Vec3::Y;
for x in [5.5, -5.5, -2064.5, 2064.5] {
let camera = glam::DVec3::new(x, 64.0, x);
let (view, _) = super::super::pack_uniforms::shadow_camera(light, camera, &shadow);
let centre = view.inverse().w_axis.truncate();
assert!(centre.x.abs() <= SNAP / 2.0, "{x}: {centre}");
assert!(centre.z.abs() <= SNAP / 2.0, "{x}: {centre}");
}
}
#[test]
fn the_distortion_brings_the_centre_texel_to_a_32nd_of_a_block() {
// 2048 texels over 256 blocks: an 8th of a block each, magnified 4 times.
assert!((distortion(128.0, 2048) - 0.75).abs() < 1e-6);
// Already that fine: no distortion.
assert_eq!(distortion(32.0, 2048), 0.0);
assert_eq!(distortion(10_000.0, 1024), 0.95);
}
}
@@ -252,7 +252,7 @@ mod tests {
resource: globals_buffer.as_entire_binding(),
}],
});
let frame = NativeFrame::new(&device);
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
queue.write_buffer(
&frame.buffer,
0,
@@ -176,7 +176,7 @@ impl Features {
&entity_module,
entities,
&entity_buffers,
kind("text", "entity", None, offset),
kind("text", "entity", Some(wgpu::Face::Back), offset),
),
crumbling: set(
&entity_module,
@@ -217,7 +217,12 @@ impl Features {
&entity_module,
entities,
&entity_buffers,
kind("additive entities", "lightning", None, PLAIN),
kind(
"additive entities",
"lightning",
Some(wgpu::Face::Back),
PLAIN,
),
),
weather: set(
&entity_module,
@@ -13,6 +13,7 @@
use std::collections::HashMap;
use glam::{DMat4, DVec3};
use horizon_shaders::{Format, Group, Kind, Post, Val};
use super::pack_draw::DrawUniforms;
@@ -434,9 +435,33 @@ pub(super) struct Weather {
pub generation: u64,
}
/// Shadow cache: the key of the shadow map the shadow targets hold. Only the
/// shadow passes write those targets, so while the key matches they still hold
/// it and the pass is skipped.
#[derive(Default)]
struct ShadowCache {
key: Option<ShadowCacheKey>,
}
/// Key for validating the shadow cache.
/// Must match the cache state exactly to be considered valid.
#[derive(Clone, Copy, Debug, PartialEq)]
struct ShadowCacheKey {
sun_angle: f32,
moon_angle: f32,
snapped_camera: Option<DVec3>,
shadow_settings_hash: u64,
shadow_scene_revision: (u64, u64),
ticks: u64,
day_time: i64,
shadow_uniforms_hash: u64,
}
pub struct PackPost {
pub notes: Vec<String>,
passes: Vec<Pass>,
/// Shadow cache for temporal reprojection.
shadow_cache: ShadowCache,
/// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`.
terrain: Option<Pass>,
/// What draws translucent terrain (`gbuffers_water` or its fallback), after `deferred`.
@@ -2330,6 +2355,7 @@ impl PackPost {
u.shadow = shadow_config;
u
},
shadow_cache: Default::default(),
}
}
@@ -3322,6 +3348,45 @@ impl PackPost {
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
terrain: &mut TerrainDraw<'_>,
frame: &super::Frame,
) {
// Opt-in only (`horizon-graphics.txt` shadowCache). Off: always redraw, drop any cache.
if !frame.shadow_cache {
self.shadow_cache = ShadowCache::default();
self.draw_shadow_pass(device, queue, encoder, terrain);
return;
}
// Compute the current frame's cache key before borrowing shadow_targets mutably.
let cache_key = self.compute_shadow_cache_key(frame);
// Entity vertices are interpolated each frame, so their shadows cannot be reused.
let has_shadow_entities = self
.entities
.as_ref()
.is_some_and(|entities| entities.geometry.draws.iter().any(|draw| draw.shadow));
// Custom images are cleared each frame (`pack_images::clear`) before the
// shadow pass writes them, so a pack with any redraws it too.
let cache_hit = !has_shadow_entities
&& self.images.is_empty()
&& self.shadow_cache.key == Some(cache_key)
&& self.shadow_targets.is_some();
if cache_hit {
// The targets still hold this map (and `main`, the A/B side
// `shadowcomp` left current).
return;
}
self.draw_shadow_pass(device, queue, encoder, terrain);
self.shadow_cache.key = self.shadow_targets.is_some().then_some(cache_key);
}
/// The shadow pass body: terrain, entities, clears, and `shadowcomp`.
fn draw_shadow_pass(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
terrain: &mut TerrainDraw<'_>,
) {
let Some(t) = &mut self.shadow_targets else {
return;
@@ -3397,6 +3462,106 @@ impl PackPost {
self.shadow_comp = comp;
}
/// Computes the cache key for the current frame.
fn compute_shadow_cache_key(&self, frame: &super::Frame) -> ShadowCacheKey {
let shadow = &self.uniforms.shadow;
// Use the same logic as shadow_camera to compute the light direction and snapped camera.
// Use the public shadow_light function which returns the normalized light direction.
let light = super::pack_uniforms::shadow_light(frame, shadow);
let light = light.as_dvec3();
let up = if light.z.abs() > 0.99 {
DVec3::Y
} else {
DVec3::Z
};
let rotation = DMat4::look_to_rh(DVec3::ZERO, -light, up);
let at = rotation.transform_vector3(frame.camera);
let step = f64::from(shadow.interval);
let snapped_camera = if step > 0.0 {
Some((at / step).floor() * step)
} else {
Some(at)
};
// Hash the shadow settings that affect the shadow map.
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
shadow.resolution.hash(&mut hasher);
shadow.distance.to_bits().hash(&mut hasher);
shadow.render_mul.to_bits().hash(&mut hasher);
shadow.interval.to_bits().hash(&mut hasher);
shadow.sun_path_rotation.to_bits().hash(&mut hasher);
shadow.fov.map(|f| f.to_bits()).hash(&mut hasher);
shadow.near.to_bits().hash(&mut hasher);
shadow.far.to_bits().hash(&mut hasher);
for hf in &shadow.hardware_filtering {
hf.hash(&mut hasher);
}
for color in &shadow.colors {
format!("{:?}", color.format).hash(&mut hasher);
color.clear.hash(&mut hasher);
if let Some(cc) = color.clear_color {
for c in cc {
c.to_bits().hash(&mut hasher);
}
}
color.mipmap.hash(&mut hasher);
}
let shadow_settings_hash = hasher.finish();
let shadow_uniforms_hash = self.shadow_uniforms_hash();
ShadowCacheKey {
sun_angle: frame.sky.sun_angle,
moon_angle: frame.sky.moon_angle,
snapped_camera,
shadow_settings_hash,
shadow_scene_revision: frame.shadow_scene_revision,
ticks: frame.ticks,
day_time: frame.pack.day_time,
shadow_uniforms_hash,
}
}
fn shadow_uniforms_hash(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
let passes = self
.shadow_terrain
.iter()
.chain(self.shadow_water.iter())
.chain(self.shadow_comp.iter())
.chain(
self.entities
.iter()
.flat_map(|entities| entities.shadow.iter().flatten()),
);
for pass in passes {
pass.name.hash(&mut hasher);
for (index, ty, offset) in &pass.uniforms {
index.hash(&mut hasher);
ty.hash(&mut hasher);
offset.hash(&mut hasher);
let Some(value) = index.and_then(|index| self.uniforms.values.get(index)) else {
continue;
};
match value {
pack_uniforms::Value::F(values) => {
0u8.hash(&mut hasher);
for value in values {
value.to_bits().hash(&mut hasher);
}
}
pack_uniforms::Value::I(values) => {
1u8.hash(&mut hasher);
values.hash(&mut hasher);
}
}
}
}
hasher.finish()
}
/// Runs the pack over the world drawn into `world` (colour) and `world_depth`, then draws
/// the result onto `screen`.
#[allow(clippy::too_many_arguments)]
@@ -3464,7 +3629,7 @@ impl PackPost {
self.gpu.clear,
&mut self.zeros,
);
self.run_shadow(device, queue, encoder, terrain);
self.run_shadow(device, queue, encoder, terrain, frame);
let Some(targets) = &self.targets else { return };
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
let view = |m: u16, i: usize, alt: bool| {
@@ -3720,6 +3885,36 @@ mod tests {
use super::*;
use wgpu::TextureFormat as T;
#[test]
fn shadow_cache_key_tracks_world_and_time_changes() {
let key = ShadowCacheKey {
sun_angle: 0.0,
moon_angle: 180.0,
snapped_camera: Some(DVec3::ZERO),
shadow_settings_hash: 1,
shadow_scene_revision: (2, 3),
ticks: 4,
day_time: 5,
shadow_uniforms_hash: 6,
};
assert_ne!(
key,
ShadowCacheKey {
shadow_scene_revision: (8, 3),
..key
}
);
assert_ne!(key, ShadowCacheKey { day_time: 9, ..key });
assert_ne!(
key,
ShadowCacheKey {
shadow_uniforms_hash: 10,
..key
}
);
}
/// Two textures with a sampler each become two textures sharing one sampler variable.
#[test]
fn samplers_of_a_kind_share_one_variable() {
@@ -35,6 +35,8 @@ pub struct PackInputs {
/// `heldItemId`, `heldItemId2` (item.properties, 5.3) and `heldBlockLightValue(2)`.
pub held_items: [i32; 2],
pub held_light: [i32; 2],
/// The values native mods hand their packs (`frame.mods`, `native_shader_params`).
pub mod_params: [[f32; 4]; 8],
}
/// One uniform's value: up to 16 floats or 3 integers.
@@ -245,7 +247,7 @@ pub fn shadow_culling(frame: &Frame, shadow: &Shadow) -> Mat4 {
/// How far toward the light the shadow projection starts, in blocks: part of the projection,
/// not of `shadowModelView` (Iris's matrices, measured).
const SHADOW_EYE: f32 = 100.0;
pub const SHADOW_EYE: f32 = 100.0;
/// The shadow camera as Iris places it (7.1; measured from the outside): its view (player
/// space to shadow view space) and GL projection. The view undoes `sunPathRotation`, turns the
@@ -264,7 +266,12 @@ pub fn shadow_camera(light: Vec3, camera: DVec3, shadow: &Shadow) -> (Mat4, Mat4
* DMat4::from_rotation_x(tilt);
let step = f64::from(shadow.interval);
let origin = if step > 0.0 {
DVec3::splat(step / 2.0) - camera % step
let remainder = if shadow.euclidean_snap {
camera.rem_euclid(DVec3::splat(step))
} else {
camera % step
};
DVec3::splat(step / 2.0) - remainder
} else {
DVec3::ZERO
};
@@ -61,6 +61,9 @@ impl<T> Resident<T> {
pub fn values(&self) -> impl Iterator<Item = &T> {
self.items.iter().map(|(_, v)| v)
}
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut T> {
self.items.iter_mut().map(|(_, v)| v)
}
/// `key`'s value, when it is resident.
pub fn find(&self, key: SectionKey) -> Option<&T> {
self.slots.get(&key).map(|&slot| self.get(slot))
+192 -9
View File
@@ -3,6 +3,10 @@ use crate::weather::{Time, Vertex};
pub(super) struct Weather {
pipeline: wgpu::RenderPipeline,
/// The layouts of groups 0 (the entities') and 1 (the fall clock), for a native pack's
/// weather pipeline.
pub entity_layout: wgpu::BindGroupLayout,
pub time_layout: wgpu::BindGroupLayout,
group: wgpu::BindGroup,
uniform: wgpu::Buffer,
vertices: wgpu::Buffer,
@@ -53,13 +57,7 @@ impl Weather {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
depth_stencil: Some(depth_stencil()),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
@@ -90,6 +88,8 @@ impl Weather {
});
Self {
pipeline,
entity_layout: entities.clone(),
time_layout: bindings,
group,
uniform,
vertices: Self::buffer(device, 4),
@@ -144,11 +144,24 @@ impl Weather {
pass.set_vertex_buffer(0, self.vertices.slice(..));
pass.draw_indexed(0..self.count / 4 * 6, 0, 0..1);
}
pub fn draw(&self, pass: &mut wgpu::RenderPass<'_>, entities: &wgpu::BindGroup) {
/// Draws the streaks, through a native pack's weather stage when `native` is given (its
/// pipeline and the pack's frame group).
pub fn draw(
&self,
pass: &mut wgpu::RenderPass<'_>,
entities: &wgpu::BindGroup,
native: Option<(&wgpu::RenderPipeline, &wgpu::BindGroup)>,
) {
if self.count == 0 {
return;
}
pass.set_pipeline(&self.pipeline);
match native {
Some((pipeline, frame)) => {
pass.set_pipeline(pipeline);
pass.set_bind_group(horizon_shader_api::stages::GROUP, frame, &[]);
}
None => pass.set_pipeline(&self.pipeline),
}
pass.set_bind_group(0, entities, &[]);
pass.set_bind_group(1, &self.group, &[]);
pass.set_vertex_buffer(0, self.vertices.slice(..));
@@ -157,11 +170,181 @@ impl Weather {
}
}
/// RenderPipelines.WEATHER: depth-tested, but no depth write, so the water
/// and glass drawn behind the rain still show through it.
fn depth_stencil() -> wgpu::DepthStencilState {
wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}
}
/// The entries around a native pack's `weather`: Horizon's weather vertex, then the texture
/// as `fs_weather` reads it, handed to the pack with the light and the position.
const NATIVE_ENTRIES: &str = "
struct HorizonWeatherOut {
@builtin(position) clip: vec4<f32>,
@location(0) uv: vec2<f32>,
@location(1) color: vec4<f32>,
@location(2) @interpolate(flat) rect: vec4<f32>,
@location(3) light: vec2<f32>,
@location(4) position: vec3<f32>,
@location(5) @interpolate(flat) snow: u32,
}
@vertex fn horizon_weather_vs(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) rect: vec4<f32>, @location(3) animation: vec4<f32>,
@location(4) bases: vec4<u32>, @location(5) color: vec4<f32>,
@location(6) light: vec4<u32>, @location(7) snow: u32) -> HorizonWeatherOut {
let phase = horizon_entity_weather_phase(animation, bases, snow,
horizon_entity_weather_time.values);
let v = horizon_entity_entity_vertex(position, uv + phase, 0u, color, light, vec4(0.0));
var out: HorizonWeatherOut;
out.clip = v.clip;
out.uv = v.uv;
out.color = v.color;
out.rect = rect;
// entity_vertex's light: block in y, sky in x, 0..240.
out.light = min(vec2(f32(light.y), f32(light.x)) / 240.0, vec2(1.0));
out.position = position;
out.snow = snow;
return out;
}
@fragment fn horizon_weather_fs(in: HorizonWeatherOut) -> @location(0) vec4<f32> {
let texel = horizon_entity_weather_texel(in.uv, in.rect,
textureDimensions(horizon_entity_atlas));
let color = textureLoad(horizon_entity_atlas, texel, 0) * in.color;
if color.a < 0.1 { discard; }
return weather(WeatherInput(color, in.light, in.position, in.snow != 0u));
}
";
/// The shader of a native pack's weather stage `source`.
fn native_source(source: &str) -> String {
format!(
"{source}\n// Horizon's weather (entity.wgsl)\n{}{NATIVE_ENTRIES}",
super::wgsl_prefix::prefixed(&super::entity_shader(), "horizon_entity_")
)
}
/// A native pack's weather pipeline: its `stage` (with the option values `constants`), then
/// entity.wgsl with every name prefixed, then the entries above. Group 0 is the entities',
/// 1 the fall clock (`time_layout`), 3 the pack's frame.
pub(super) fn native_pipeline(
device: &wgpu::Device,
format: wgpu::TextureFormat,
entities: &wgpu::BindGroupLayout,
time_layout: &wgpu::BindGroupLayout,
frame: &super::native_post::NativeFrame,
stage: &horizon_shader_api::check::CheckedStage,
constants: &[(String, f64)],
) -> Result<wgpu::RenderPipeline, String> {
use horizon_shader_api::stages::GROUP;
let text = native_source(&stage.source);
let module = naga::front::wgsl::parse_str(&text)
.map_err(|e| format!("{}: {}", stage.file, e.emit_to_string(&text)))?;
let present = super::native_post::present_constants(&module, constants);
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[0] = Some(entities);
groups[1] = Some(time_layout);
groups[GROUP as usize] = Some(&frame.layout);
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("Native shader pack weather"),
bind_group_layouts: &groups,
..Default::default()
});
let scope = device.push_error_scope(wgpu::ErrorFilter::Validation);
let module = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(&stage.label),
source: wgpu::ShaderSource::Naga(std::borrow::Cow::Owned(module)),
});
let options = wgpu::PipelineCompilationOptions {
constants: &present,
..Default::default()
};
let attributes = wgpu::vertex_attr_array![0=>Float32x3,1=>Float32x2,2=>Float32x4,
3=>Float32x4,4=>Uint32x4,5=>Unorm8x4,6=>Uint8x4,7=>Uint32];
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("Native shader pack weather"),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &module,
entry_point: Some("horizon_weather_vs"),
compilation_options: options.clone(),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<Vertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &attributes,
})],
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(depth_stencil()),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: &module,
entry_point: Some("horizon_weather_fs"),
compilation_options: options,
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState {
color: wgpu::BlendState::ALPHA_BLENDING.color,
// The frame's alpha stays as the surfaces left it (entities mark it 0).
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::Zero,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
}),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview_mask: None,
cache: None,
});
if let Some(e) = pollster::block_on(scope.pop()) {
return Err(format!("{}: {e}", stage.file));
}
Ok(pipeline)
}
#[cfg(test)]
mod tests {
use super::native_source;
#[test]
fn a_native_weather_stage_validates() {
use horizon_shader_api::stages::StageKind;
let source = StageKind::Weather.prelude()
+ "fn weather(input: WeatherInput) -> vec4<f32> {\n \
return input.color * vec4(input.light, input.position.x, f32(input.snow));\n}\n";
let text = native_source(&source);
let module = naga::front::wgsl::parse_str(&text)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
naga::valid::Validator::new(
naga::valid::ValidationFlags::all(),
naga::valid::Capabilities::all(),
)
.validate(&module)
.unwrap_or_else(|e| panic!("{}", e.emit_to_string(&text)));
}
use crate::weather::{Parameters, Time};
use wgpu::util::DeviceExt;
#[test]
fn weather_writes_no_depth() {
let depth = super::depth_stencil();
assert_eq!(depth.depth_write_enabled, Some(false));
assert_eq!(
depth.depth_compare,
Some(wgpu::CompareFunction::GreaterEqual)
);
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Case {
+50 -1
View File
@@ -313,6 +313,8 @@ pub fn reduced_far_detail_suggested() -> bool {
/// The name the options screen waits on to bind `Graphics::hdr_key`.
pub const HDR_KEY: &str = "horizon.hdrSettings";
/// The Screenshot Quality choices, times the game's resolution.
pub const SCREENSHOT_QUALITIES: [u32; 4] = [1, 2, 4, 8];
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
/// opens its surface; the settings open HDR's screen only then.
@@ -362,10 +364,16 @@ pub struct Graphics {
pub chunk_updates: u32,
/// Section mesh bytes uploaded per frame, in KiB; 0 is unlimited.
pub upload_budget_kib: u32,
/// The block textures' video memory (their top mip level), in MiB; 0 leaves it to
/// Horizon (`sprites::DEFAULT_TEXTURE_BUDGET`). Applies when resources next load.
pub texture_budget_mib: u32,
pub sky: bool,
pub fog: bool,
pub weather: bool,
pub animated_textures: bool,
/// Dithers the small mip levels of cutouts from 64 texels up, so a high-resolution pack's
/// fine plants keep their coverage in the distance; off is vanilla's look.
pub dither_cutout_mips: bool,
pub present_mode: PresentMode,
pub pacing: Pacing,
/// Chunk worker threads; 0 uses every core but one. Applies at the next start.
@@ -379,6 +387,8 @@ pub struct Graphics {
pub taa: bool,
/// TAA upscaling; anything but `Native` comes with `taa` on.
pub upscaling: Upscaling,
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
pub screenshot_quality: u32,
/// HDR output (HDR10, or scRGB), where the display path offers it.
pub hdr: bool,
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
@@ -399,6 +409,10 @@ pub struct Graphics {
/// or behind other windows, and half the worker threads. Off by default;
/// it changes when frames are drawn, never what they show or the ticks.
pub battery: bool,
/// Reuse the shader pack's last shadow map when sun, view, scene revision and
/// pack uniforms still match. Off by default: vanilla loaders redraw every frame
/// (or every `shadowInterval`), and a hit with a stale key shows old shadows.
pub shadow_cache: bool,
/// The graphics API (Windows only; see `GraphicsApi`).
pub api: GraphicsApi,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
@@ -416,10 +430,12 @@ impl Default for Graphics {
entity_culling: false,
chunk_updates: 0,
upload_budget_kib: 0,
texture_budget_mib: 0,
sky: true,
fog: true,
weather: true,
animated_textures: true,
dither_cutout_mips: false,
present_mode: PresentMode::Auto,
pacing: Pacing::Efficient,
workers: 0,
@@ -428,6 +444,7 @@ impl Default for Graphics {
fxaa: false,
taa: false,
upscaling: Upscaling::Native,
screenshot_quality: 1,
hdr: false,
hdr_paper_white: 0,
hdr_peak: 0,
@@ -435,6 +452,7 @@ impl Default for Graphics {
hdr_ui_brightness: 100,
hdr_key: "key.keyboard.unknown".into(),
battery: false,
shadow_cache: false,
api: GraphicsApi::Auto,
shader_pack: String::new(),
}
@@ -480,10 +498,14 @@ impl Graphics {
"entityCulling" => g.entity_culling = flag(g.entity_culling),
"chunkUpdates" => g.chunk_updates = number(g.chunk_updates).min(512),
"uploadBudgetKiB" => g.upload_budget_kib = number(g.upload_budget_kib).min(65536),
"textureBudgetMiB" => {
g.texture_budget_mib = number(g.texture_budget_mib).min(16384);
}
"sky" => g.sky = flag(g.sky),
"fog" => g.fog = flag(g.fog),
"weather" => g.weather = flag(g.weather),
"animatedTextures" => g.animated_textures = flag(g.animated_textures),
"ditherCutoutMips" => g.dither_cutout_mips = flag(g.dither_cutout_mips),
"presentMode" => g.present_mode = PresentMode::parse(value),
"framePacing" => {
g.pacing = if value == "precise" {
@@ -497,6 +519,11 @@ impl Graphics {
"fxaa" => g.fxaa = flag(g.fxaa),
"taa" => g.taa = flag(g.taa),
"upscaling" => g.upscaling = Upscaling::parse(value),
"screenshotQuality" => {
g.screenshot_quality = Some(number(0))
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
.unwrap_or(1)
}
"hdr" => g.hdr = flag(g.hdr),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
@@ -516,6 +543,7 @@ impl Graphics {
}
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
"batteryMode" => g.battery = flag(g.battery),
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
"overlay" => {
@@ -532,7 +560,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\ntextureBudgetMiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\nditherCutoutMips:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
self.preset.name(),
self.lod_distance,
self.far_detail.name(),
@@ -540,10 +568,12 @@ impl Graphics {
bool_text(self.entity_culling),
self.chunk_updates,
self.upload_budget_kib,
self.texture_budget_mib,
bool_text(self.sky),
bool_text(self.fog),
bool_text(self.weather),
bool_text(self.animated_textures),
bool_text(self.dither_cutout_mips),
self.present_mode.name(),
match self.pacing {
Pacing::Efficient => "efficient",
@@ -559,6 +589,7 @@ impl Graphics {
bool_text(self.fxaa),
bool_text(self.taa),
self.upscaling.name(),
self.screenshot_quality,
bool_text(self.hdr),
self.hdr_paper_white,
self.hdr_peak,
@@ -566,6 +597,7 @@ impl Graphics {
self.hdr_ui_brightness,
self.hdr_key,
bool_text(self.battery),
bool_text(self.shadow_cache),
self.api.name(),
self.shader_pack,
)
@@ -691,10 +723,12 @@ mod tests {
entity_culling: true,
chunk_updates: 12,
upload_budget_kib: 4096,
texture_budget_mib: 1536,
sky: false,
fog: false,
weather: false,
animated_textures: false,
dither_cutout_mips: true,
present_mode: PresentMode::Mailbox,
pacing: Pacing::Precise,
workers: 6,
@@ -703,6 +737,7 @@ mod tests {
fxaa: true,
taa: true,
upscaling: Upscaling::Balanced,
screenshot_quality: 4,
hdr: true,
hdr_paper_white: 240,
hdr_peak: 1400,
@@ -710,6 +745,7 @@ mod tests {
hdr_ui_brightness: 60,
hdr_key: "key.keyboard.h".into(),
battery: true,
shadow_cache: true,
api: GraphicsApi::Dx12,
shader_pack: "Golden Hour".into(),
};
@@ -720,6 +756,19 @@ mod tests {
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 10);
// Only the button's choices.
assert_eq!(
Graphics::parse("screenshotQuality:8\n").screenshot_quality,
8
);
assert_eq!(
Graphics::parse("screenshotQuality:3\n").screenshot_quality,
1
);
assert_eq!(
Graphics::parse("screenshotQuality:16\n").screenshot_quality,
1
);
// The old menus' Simple / Advanced choice is ignored, and so is the
// shader pack older versions saved.
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
@@ -33,6 +33,7 @@ pub enum H {
Fog,
Weather,
AnimatedTextures,
DitherCutoutMips,
PresentMode,
Pacing,
Workers,
@@ -40,6 +41,7 @@ pub enum H {
ResolutionScale,
Antialiasing,
Upscaling,
ScreenshotQuality,
Hdr,
/// Opens `Level::Hdr`.
HdrSettings,
@@ -52,6 +54,7 @@ pub enum H {
HdrPaperWhite,
HdrUiBrightness,
BatteryMode,
ShadowCache,
GraphicsApi,
}
@@ -259,6 +262,11 @@ pub fn rows(level: Level) -> Vec<Row> {
Low,
"Animates water, lava, fire and other moving textures.",
),
h(
H::DitherCutoutMips,
Low,
"Keeps fine plants in high-res packs from fading far away.",
),
Row::Header("Display"),
fov,
fps,
@@ -278,6 +286,11 @@ pub fn rows(level: Level) -> Vec<Row> {
High,
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
),
h(
H::ScreenshotQuality,
None,
"F2 screenshots at this many times the game's resolution. Higher takes longer.",
),
h(
H::HdrSettings,
None,
@@ -313,6 +326,11 @@ pub fn rows(level: Level) -> Vec<Row> {
None,
"60 FPS cap (30 in menus or unfocused), half the workers: less power.",
),
h(
H::ShadowCache,
Saves,
"Reuses the pack shadow map when sun, view and world match. Off by default.",
),
h(
H::Workers,
None,
@@ -513,6 +531,7 @@ impl H {
H::Fog => labelled("Fog", on_off(g.fog)),
H::Weather => labelled("Weather", on_off(g.weather)),
H::AnimatedTextures => labelled("Animated Textures", on_off(g.animated_textures)),
H::DitherCutoutMips => labelled("Distant Plant Dither", on_off(g.dither_cutout_mips)),
H::PresentMode => labelled("Present Mode", g.present_mode.label()),
H::Pacing => labelled(
"Frame Pacing",
@@ -538,6 +557,9 @@ impl H {
},
),
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
H::ScreenshotQuality => {
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
}
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
@@ -571,6 +593,7 @@ impl H {
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
}
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
H::GraphicsApi => labelled("Graphics API", g.api.label()),
H::Overlay => labelled(
"Performance Overlay",
@@ -630,6 +653,7 @@ impl H {
H::Fog => g.fog = !g.fog,
H::Weather => g.weather = !g.weather,
H::AnimatedTextures => g.animated_textures = !g.animated_textures,
H::DitherCutoutMips => g.dither_cutout_mips = !g.dither_cutout_mips,
H::PresentMode => g.present_mode = step(&PresentMode::ALL, g.present_mode, back),
// Off, FXAA, TAA.
H::Antialiasing => {
@@ -647,8 +671,16 @@ impl H {
(g.fxaa, g.taa) = (false, true);
}
}
H::ScreenshotQuality => {
g.screenshot_quality = step(
&crate::graphics::SCREENSHOT_QUALITIES,
g.screenshot_quality,
back,
)
}
H::Hdr => g.hdr = !g.hdr,
H::BatteryMode => g.battery = !g.battery,
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
H::Pacing => {
g.pacing = match g.pacing {
@@ -709,8 +741,10 @@ impl Item {
Item::Horizon(H::Fog) => g.fog,
Item::Horizon(H::Weather) => g.weather,
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
Item::Horizon(H::DitherCutoutMips) => g.dither_cutout_mips,
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
Item::Vanilla(Opt::Vsync) => s.vsync,
Item::Vanilla(Opt::Fullscreen) => s.fullscreen,
@@ -766,10 +800,12 @@ impl Item {
Item::Horizon(
H::Preset
| H::Overlay
| H::ScreenshotQuality
| H::Pacing
| H::PresentMode
| H::Workers
| H::BatteryMode
| H::ShadowCache
| H::GraphicsApi
) | Item::Vanilla(
Opt::GuiScale
@@ -1733,6 +1769,21 @@ mod tests {
assert_eq!(s.horizon.preset, Preset::Ultra);
}
#[test]
fn screenshot_quality_cycles_1_2_4_8_and_keeps_the_preset() {
let mut s = Settings::default();
Item::Horizon(H::Preset).cycle(&mut s, false, [854, 480]);
let mut seen = Vec::new();
for _ in 0..4 {
Item::Horizon(H::ScreenshotQuality).cycle(&mut s, false, [854, 480]);
seen.push(s.horizon.screenshot_quality);
}
assert_eq!(seen, [2, 4, 8, 1]);
Item::Horizon(H::ScreenshotQuality).cycle(&mut s, true, [854, 480]);
assert_eq!(s.horizon.screenshot_quality, 8);
assert_eq!(s.horizon.preset, Preset::Performance);
}
#[test]
fn upload_budget_slider_steps() {
let mut s = Settings::default();
+40 -9
View File
@@ -528,20 +528,33 @@ impl HudAtlas {
for folder in folders {
walk(&textures.join(folder), &mut files);
}
// Content mods' equipment textures, which their equipment assets name as
// `<ns>:<path>`, and their mob effects' icons, `<ns>:mob_effect/<path>`.
// Content mods' entity textures: their equipment assets name them as
// `<ns>:<path>`, and native mods draw their block entities with them
// (`native_draw_quad`: `<ns>:entity/...`); and their mob effects'
// icons, `<ns>:mob_effect/<path>`.
let namespaces = horizon_items::content_namespaces::namespaces(root);
for (folder, textures) in [("entity", "entity/equipment"), ("mob_effect", "mob_effect")] {
for folder in ["entity", "mob_effect"] {
if folders.contains(&folder) {
for namespace in &namespaces {
walk(
&root.join(format!("assets/{namespace}/textures/{textures}")),
&root.join(format!("assets/{namespace}/textures/{folder}")),
&mut files,
);
}
}
}
let assets = root.join("assets");
// A native mod's particle type names its textures `<ns>:particle/<path>`
// and needn't ship content, so every namespace's folder is packed.
if folders.contains(&"particle")
&& let Ok(namespaces) = fs::read_dir(&assets)
{
for ns in namespaces.flatten() {
if ns.file_name() != "minecraft" {
walk(&ns.path().join("textures/particle"), &mut files);
}
}
}
// Content mods' GUI textures, for their screens: `<namespace>:gui/...`.
if full && let Ok(namespaces) = fs::read_dir(&assets) {
for ns in namespaces.flatten() {
@@ -565,6 +578,12 @@ impl HudAtlas {
format!("{namespace}:{rest}")
}
};
// LabPBR's maps beside a texture are the world's (sprites.rs), not images to draw.
if let Some(base) = name.strip_suffix("_n").or_else(|| name.strip_suffix("_s"))
&& textures.join(format!("{base}.png")).is_file()
{
return None;
}
let image = decode(path)
.map_err(|e| log::warn!("skipped GUI texture {}: {e}", path.display()))
.ok()?;
@@ -1604,6 +1623,9 @@ pub struct HudRenderer {
atlas_view: wgpu::TextureView,
/// The resource pack's text shader, when it has one that works.
pack_text: Option<pack_text::PackText>,
/// Target pixels per window pixel: above 1 for a screenshot drawn larger than the
/// window (`Renderer::screenshot_quality`).
pub zoom: f32,
}
impl HudRenderer {
pub fn new(
@@ -1833,6 +1855,7 @@ impl HudRenderer {
format,
atlas_view: view.clone(),
pack_text: None,
zoom: 1.0,
})
}
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
@@ -1968,14 +1991,14 @@ impl HudRenderer {
[
size[0] as f32,
size[1] as f32,
batch.scale,
batch.scale * self.zoom,
batch.glint_alpha,
],
batch.end_portal,
)),
);
if let Some(pack_text) = &self.pack_text {
pack_text.prepare(uploads, encoder, batch, size);
pack_text.prepare(uploads, encoder, batch, size, batch.scale * self.zoom);
}
}
/// Draw the batch `prepare` staged.
@@ -2048,7 +2071,8 @@ impl HudRenderer {
});
pass.set_bind_group(0, &self.group, &[]);
pass.set_vertex_buffer(0, self.buffer.slice(..));
if let Some([x, y, w, h]) = clip {
if let Some(clip) = clip {
let [x, y, w, h] = clip.map(|v| (v as f32 * self.zoom) as u32);
let (x, y) = (x.min(size[0]), y.min(size[1]));
let (w, h) = (w.min(size[0] - x), h.min(size[1] - y));
if w == 0 || h == 0 {
@@ -2186,7 +2210,7 @@ mod tests {
atlas.pixels[at..at + 4].try_into().unwrap()
}
#[test]
fn content_mod_equipment_textures_pack_under_their_namespace() {
fn content_mod_entity_textures_pack_under_their_namespace() {
let root = std::env::temp_dir().join(format!("horizon-atlas-equip-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let equipment = root.join("assets/gems/textures/entity/equipment/humanoid");
@@ -2206,10 +2230,17 @@ mod tests {
br#"["gems"]"#,
)
.unwrap();
// A block entity's texture, for a native mod's renderer.
let machine = root.join("assets/gems/textures/entity/machine");
fs::create_dir_all(&machine).unwrap();
write_strip(&machine.join("cog.png"), 32, 32, &[[0, 0, 200, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["gems:entity/equipment/humanoid/ruby"];
assert_eq!(texel_at(&atlas, rect, 0, 0), [200, 0, 0, 255]);
// Only equipment: the mods' other textures reach the atlas through atlas definitions.
let rect = atlas.sprites["gems:entity/machine/cog"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (32, 32));
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 0, 200, 255]);
// Only entity textures: the mods' others reach the atlas through atlas definitions.
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
fs::remove_dir_all(&root).unwrap();
}
@@ -216,8 +216,9 @@ impl PackText {
encoder: &mut wgpu::CommandEncoder,
batch: &HudBatch,
size: [u32; 2],
scale: f32,
) {
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size));
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size, scale));
}
/// Draw `range` (whole glyph quads) with the pack's shader; the caller sets its own
@@ -233,7 +234,8 @@ impl PackText {
/// `GlintAlpha`, `GameTime`, `ScreenSize`), `Projection` (`GuiRenderer`'s orthographic GUI
/// projection, 1000 to 11000 deep, in GL's depth range), `DynamicTransforms` (the GUI's model view
/// moving 11000 back, colour modulator white) and an empty `Fog`.
fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
/// `scale`: target pixels per GUI pixel (`batch.scale`, times `HudRenderer::zoom`).
fn uniforms(batch: &HudBatch, size: [u32; 2], scale: f32) -> Vec<u8> {
let mut data = vec![0f32; 256];
let globals = 0;
data[globals + 3] = batch.glint_alpha;
@@ -241,7 +243,7 @@ fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
data[globals + 8] = size[0] as f32;
data[globals + 9] = size[1] as f32;
// Matrix4f.setOrtho(0, width, height, 0, near, far) with the GUI's scale, columns.
let (w, h) = (size[0] as f32 / batch.scale, size[1] as f32 / batch.scale);
let (w, h) = (size[0] as f32 / scale, size[1] as f32 / scale);
let (near, far) = (1000.0f32, 11000.0f32);
let projection = 64;
data[projection] = 2.0 / w;
@@ -287,7 +289,8 @@ mod tests {
fn the_gui_projection_maps_gui_pixels_to_the_screen() {
let mut batch = HudBatch::new([1920, 1080]);
batch.scale = 4.0;
let data: Vec<f32> = bytemuck::cast_slice(&uniforms(&batch, [1920, 1080])).to_vec();
let data: Vec<f32> =
bytemuck::cast_slice(&uniforms(&batch, [1920, 1080], batch.scale)).to_vec();
let column = |matrix: usize, c: usize| &data[matrix + 4 * c..matrix + 4 * c + 4];
let apply = |matrix: usize, v: [f32; 4]| -> [f32; 4] {
std::array::from_fn(|r| (0..4).map(|c| column(matrix, c)[r] * v[c]).sum())
@@ -385,9 +385,40 @@ pub fn draw(
}
}
/// `LoadingOverlay.MOJANG_STUDIOS_LOGO_LOCATION`, from the player's own resources.
pub const MOJANG_STUDIOS_LOGO: &str = "gui/title/mojangstudios";
/// `LoadingOverlay.extractRenderState`'s logo on a GUI `w` x `h`: the left
/// half's corner, then each half's width and height.
pub fn logo_layout(w: f32, h: f32) -> (i32, i32, i32, i32) {
let (w, h) = (f64::from(w), f64::from(h));
let (content_x, logo_y) = ((w * 0.5) as i32, (h * 0.5) as i32);
let logo_height = (w * 0.75).min(h) * 0.25;
let half_height = (logo_height * 0.5) as i32;
let half_width = (logo_height * 4.0 * 0.5) as i32;
(
content_x - half_width,
logo_y - half_height,
half_width,
logo_height as i32,
)
}
/// `GuiGraphicsExtractor.fill(x0, y0, x1, y1, color)`: the corners in either order.
fn fill_corners(batch: &mut HudBatch, atlas: &HudAtlas, [x0, y0, x1, y1]: [i32; 4], color: u32) {
let (left, right) = (x0.min(x1), x0.max(x1));
let (top, bottom) = (y0.min(y1), y0.max(y1));
let (w, h) = ((right - left) as f32, (bottom - top) as f32);
batch.fill(atlas, left as f32, top as f32, w, h, color);
}
/// `LoadingOverlay.extractRenderState`: fills the frame with `BRAND_BACKGROUND`
/// (Mojang red, or black when `darkMojangStudiosBackground` is set) and draws the
/// progress line and bar.
/// (Mojang red, or black when `darkMojangStudiosBackground` is set), draws the
/// Mojang Studios logo as its two halves side by side, and the progress bar
/// at 83.25% of the height (`extractProgressBar`, unfaded), with the stage's
/// line above it. The logo's `MOJANG_LOGO` pipeline adds it (`SRC_ALPHA, ONE`):
/// its texels are white, which alpha blending matches but for the red channel
/// of the logo's soft edges, a 16/255 difference at most.
pub fn draw_loading_overlay(
batch: &mut HudBatch,
atlas: &HudAtlas,
@@ -395,15 +426,58 @@ pub fn draw_loading_overlay(
progress: f32,
dark: bool,
) {
batch.fill(
let (w, h) = (batch.width, batch.height);
batch.fill(atlas, 0.0, 0.0, w, h, brand_background(dark));
let (x, y, half_width, logo_height) = logo_layout(w, h);
if let Some(r) = atlas.sprites.get(MOJANG_STUDIOS_LOGO).copied() {
// blit(.., u, v, width, height, 120, 60, 120, 120): the left half
// samples the texture's top half from u = -0.0625, the right half its
// bottom half from u = 0.0625 (clamped, as the logo's sampler is).
let at = |u: f32, v: f32| {
let (u, v) = ((u / 120.0).clamp(0.0, 1.0), v / 120.0);
(r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v)
};
for (i, (u, v)) in [(-0.0625, 0.0), (0.0625, 60.0)].into_iter().enumerate() {
let (u0, v0) = at(u, v);
let (u1, v1) = at(u + 120.0, v + 60.0);
let half = crate::hud::Rect {
u0,
v0,
u1,
v1,
..r
};
let left = x + i as i32 * half_width;
let (lw, lh) = (half_width as f32, logo_height as f32);
batch.rect_uv(left as f32, y as f32, lw, lh, half, u32::MAX);
}
}
let bar_y = (f64::from(h) * 0.8325) as i32;
let x_center = (w as i32) / 2;
let [x0, y0, x1, y1] = [
x_center - half_width,
bar_y - 5,
x_center + half_width,
bar_y + 5,
];
let filled = (((x1 - x0) - 2) as f32 * progress.clamp(0.0, 1.0)).ceil() as i32;
let white = u32::MAX;
fill_corners(batch, atlas, [x0 + 2, y0 + 2, x0 + filled, y1 - 2], white);
fill_corners(batch, atlas, [x0 + 1, y0, x1 - 1, y0 + 1], white);
fill_corners(batch, atlas, [x0 + 1, y1, x1 - 1, y1 - 1], white);
fill_corners(batch, atlas, [x0, y0, x0 + 1, y1], white);
fill_corners(batch, atlas, [x1, y0, x1 - 1, y1], white);
let text = Text::literal(line);
let tw = atlas.font.width_text(&text) as i32;
let text_top = (y0 - 9 - 3) as f32;
batch.text_styled(
atlas,
0.0,
0.0,
batch.width,
batch.height,
brand_background(dark),
&text,
(x_center - tw / 2) as f32,
text_top,
white,
true,
);
draw_progress(batch, atlas, line, progress);
}
/// The startup loading screen: the stage's line and a progress bar, centred
@@ -572,6 +646,14 @@ pub fn draw_file_fixer(
mod tests {
use super::*;
#[test]
fn the_mojang_logo_is_a_quarter_of_the_shorter_side_and_four_times_as_wide() {
// 320x240: min(240, 240) / 4 = 60 high, two halves of 120.
assert_eq!(logo_layout(320.0, 240.0), (40, 90, 120, 60));
// A tall frame sizes the logo by three quarters of its width.
assert_eq!(logo_layout(400.0, 500.0), (50, 213, 150, 75));
}
#[test]
fn progress_weighs_the_prepared_server_and_the_player_chunks() {
assert_eq!(progress(0), 10.0 / 59.0);
@@ -231,6 +231,9 @@ pub struct SectionMesh {
/// each frame by `block_entity_render` like vanilla's per-section
/// `SectionCompiler.Results.blockEntities`.
pub block_entities: Vec<([i32; 3], u32)>,
/// Blocks that give off light (world position, state id), for the colour of block light
/// Horizon's shader packs read (`gfx::block_light`).
pub lights: Vec<([i32; 3], u32)>,
/// Sprite layers emitted while building this mesh. Kept distinct before
/// upload so the renderer does not rescan every terrain vertex.
pub sprite_layers: Vec<u16>,
@@ -248,6 +251,7 @@ impl Default for SectionMesh {
translucent: Vec::new(),
visibility: ALL_VISIBLE,
block_entities: Vec::new(),
lights: Vec::new(),
sprite_layers: Vec::new(),
bounds: None,
keep: false,
@@ -278,6 +282,7 @@ impl SectionMesh {
self.solid.clear();
self.translucent.clear();
self.block_entities.clear();
self.lights.clear();
self.sprite_layers.clear();
self.sides = Sides::default();
self.visibility = ALL_VISIBLE;
@@ -288,6 +293,7 @@ impl SectionMesh {
self.solid.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.translucent.capacity() * std::mem::size_of::<TerrainVertex>()
+ self.block_entities.capacity() * std::mem::size_of::<([i32; 3], u32)>()
+ self.lights.capacity() * std::mem::size_of::<([i32; 3], u32)>()
+ self.sprite_layers.capacity() * std::mem::size_of::<u16>()
}
/// Sorts the solid quads by the side they face, for the renderer to
@@ -1124,6 +1130,10 @@ impl Mesher<'_> {
out.block_entities
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
}
if state.emission > 0 {
out.lights
.push(([center.x * 16 + x, base_y + y, center.z * 16 + z], id));
}
if state.solid
&& Direction::ALL.iter().all(|d| {
let [dx, dy, dz] = d.normal();
@@ -13,9 +13,10 @@
//!
//! The buckets lie one after another in the mesh, in an order chosen so that
//! the buckets an eye can see are usually one or two runs of quads: one draw
//! each. Inside a bucket quads keep their model order, so coplanar quads
//! facing one way (an overlay on its base) still draw in that order.
use super::TerrainVertex;
//! each. Coplanar quads facing one way keep their model order, so an overlay
//! on its base still draws in that order. Solid cutout quads are otherwise
//! ordered from the outside toward the section interior to reduce overdraw.
use super::{OPAQUE_LAYER, TerrainVertex};
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
/// above (+Y, the commonest case), each pair of horizontal sides with a
@@ -138,11 +139,14 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
OTHER
}
/// Reorders `solid`'s quads by bucket, keeping their order inside each.
/// Reorders `solid`'s quads by bucket. Solid cutout quads are also ordered
/// front to back within each axis-facing bucket; stable sorting preserves the
/// mesher's order for coplanar overlays.
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
let (quads, rest) = solid.as_chunks::<4>();
debug_assert!(rest.is_empty(), "solid vertices come in quads");
let buckets: Vec<u8> = quads.iter().map(|q| bucket(q) as u8).collect();
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
let mut counts = [0u32; 7];
let mut planes = [0f32; 7];
for (quad, &b) in quads.iter().zip(&buckets) {
@@ -163,7 +167,7 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
total += count;
*end = total;
}
if counts.iter().filter(|&&c| c > 0).count() > 1 {
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
let mut next: [u32; 7] = std::array::from_fn(|b| ends[b] - counts[b]);
let mut sorted = vec![quads[0]; quads.len()];
for (quad, &b) in quads.iter().zip(&buckets) {
@@ -171,6 +175,25 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sorted[*slot as usize] = *quad;
*slot += 1;
}
if has_cutout {
let mut start = 0;
for (i, bucket) in BUCKETS.iter().enumerate() {
let end = ends[i] as usize;
let slice = &mut sorted[start..end];
if let Bucket::Side(axis, positive) = *bucket {
slice.sort_by(|a, b| {
let a = a[0].pos[axis];
let b = b[0].pos[axis];
if positive {
b.total_cmp(&a)
} else {
a.total_cmp(&b)
}
});
}
start = end;
}
}
*solid = sorted.into_flattened();
}
Sides { ends, planes }
@@ -368,4 +391,46 @@ mod tests {
before.iter().map(|v| v.layer).collect::<Vec<_>>()
);
}
#[test]
fn front_to_back_sort_orders_positive_faces_and_keeps_ties_stable() {
let mut solid: Vec<_> = [(1.0, 10), (3.0, 30), (3.0, 31)]
.into_iter()
.flat_map(|(x, layer)| {
quad(
[[x, 0.0, 0.0], [x, 1.0, 0.0], [x, 1.0, 1.0], [x, 0.0, 1.0]],
layer,
)
})
.collect();
sort(&mut solid);
let layers: Vec<u16> = solid
.as_chunks::<4>()
.0
.iter()
.map(|q| q[0].layer)
.collect();
assert_eq!(layers, [30, 31, 10]);
}
#[test]
fn front_to_back_sort_orders_negative_faces_toward_the_eye() {
let mut solid: Vec<_> = [(3.0, 30), (1.0, 10), (2.0, 20)]
.into_iter()
.flat_map(|(x, layer)| {
quad(
[[x, 0.0, 0.0], [x, 0.0, 1.0], [x, 1.0, 1.0], [x, 1.0, 0.0]],
layer,
)
})
.collect();
sort(&mut solid);
let layers: Vec<u16> = solid
.as_chunks::<4>()
.0
.iter()
.map(|q| q[0].layer)
.collect();
assert_eq!(layers, [10, 20, 30]);
}
}
+123
View File
@@ -38,6 +38,21 @@ pub struct Settings {
pub alpha_cutoff_bias: f32,
pub has_transparent: bool,
pub item: bool,
/// Whether a high-resolution cutout's small levels are dithered ([`dither_cutout_levels`]).
pub dither_cutouts: bool,
}
/// The `ditherCutoutMips` setting, for textures loaded from now on.
static DITHER_CUTOUTS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Sets `ditherCutoutMips`; true if that changes it, when the textures must load again.
pub fn set_dither_cutouts(on: bool) -> bool {
DITHER_CUTOUTS.swap(on, std::sync::atomic::Ordering::Relaxed) != on
}
/// The `ditherCutoutMips` setting.
pub fn dither_cutouts() -> bool {
DITHER_CUTOUTS.load(std::sync::atomic::Ordering::Relaxed)
}
/// A square or rectangular RGBA8 image, rows top to bottom.
@@ -362,5 +377,113 @@ fn generate_native(mut base: Image, levels: u32, settings: Settings) -> Vec<Imag
}
out.push(data);
}
if settings.dither_cutouts && cutout && out[0].width >= DITHERED_CUTOUT_SIZE {
dither_cutout_levels(&mut out);
}
out
}
/// From this size a cutout texture's mips can be dithered ([`dither_cutout_levels`]).
const DITHERED_CUTOUT_SIZE: u32 = 64;
/// A high-resolution cutout's detail is finer than its small mips: grass blades a few
/// texels wide at 2048 average to about the same alpha everywhere at 64, and no scale of
/// that alpha can keep the coverage, so the alpha test passes the whole quad or none of it.
/// Each level below the base takes the base's mean alpha through an ordered dither instead:
/// its texels are in or out, about as many in as the base has.
fn dither_cutout_levels(levels: &mut [Image]) {
const BAYER: [[f32; 4]; 4] = [
[0.0, 8.0, 2.0, 10.0],
[12.0, 4.0, 14.0, 6.0],
[3.0, 11.0, 1.0, 9.0],
[15.0, 7.0, 13.0, 5.0],
];
let (mut w, mut h) = (levels[0].width as usize, levels[0].height as usize);
let mut alpha: Vec<f32> = levels[0]
.pixels
.iter()
.map(|p| f32::from(p[3]) / 255.0)
.collect();
for level in &mut levels[1..] {
let (nw, nh) = (level.width as usize, level.height as usize);
let mut next = vec![0.0; nw * nh];
for y in 0..nh {
for x in 0..nw {
let at = |dx: usize, dy: usize| {
alpha[(y * 2 + dy).min(h - 1) * w + (x * 2 + dx).min(w - 1)]
};
let mean = (at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1)) / 4.0;
next[y * nw + x] = mean;
let threshold = (BAYER[y % 4][x % 4] + 0.5) / 16.0;
level.pixels[y * nw + x][3] = if mean > threshold { 255 } else { 0 };
}
}
alpha = next;
(w, h) = (nw, nh);
}
}
#[cfg(test)]
mod dither_tests {
use super::*;
#[test]
fn the_dither_only_runs_when_asked_for_and_from_its_size() {
let cutout = |side: u32| Image {
width: side,
height: side,
pixels: (0..side * side)
.map(|i| {
[
90,
140,
40,
if (i % side).is_multiple_of(4) { 255 } else { 0 },
]
})
.collect(),
};
let settings = |dither_cutouts| Settings {
has_transparent: true,
dither_cutouts,
..Settings::default()
};
assert_ne!(
generate(cutout(128), 4, settings(false)),
generate(cutout(128), 4, settings(true))
);
assert_eq!(
generate(cutout(32), 4, settings(false)),
generate(cutout(32), 4, settings(true))
);
}
#[test]
fn a_high_resolution_cutouts_fine_detail_keeps_its_coverage_in_small_mips() {
// Blades one texel wide, one column in four: too fine for the mips to hold.
let side = 128u32;
let pixels = (0..side * side)
.map(|i| {
[
90,
140,
40,
if (i % side).is_multiple_of(4) { 255 } else { 0 },
]
})
.collect();
let base = Image {
width: side,
height: side,
pixels,
};
let settings = Settings {
has_transparent: true,
dither_cutouts: true,
..Settings::default()
};
let mips = generate(base, 4, settings);
for level in &mips[3..] {
let passing = level.pixels.iter().filter(|p| p[3] >= 128).count() as f32;
let coverage = passing / level.pixels.len() as f32;
assert!((0.15..0.35).contains(&coverage), "{coverage}");
}
}
}
+265 -18
View File
@@ -1236,6 +1236,9 @@ pub enum Page {
NativeMod(usize),
/// Horizon's shader packs, either kind (`shader_pack_list`).
ShaderPacks,
/// The options of `s.shader_packs[.0]`, a Horizon pack: its `[[pages]]` entry `.1`, the
/// first the one the button opens.
NativePackOptions(usize, u8),
/// The picked `shaders/` pack's options (`shader_options`), one of its screens at a time.
ShaderOptions,
/// Horizon's graphics settings (`graphics_ui`).
@@ -1261,6 +1264,8 @@ pub enum Page {
Online,
/// Splitscreen: the profile another player joins as (`split_profiles`).
SplitProfiles,
/// Splitscreen: enable joining on this computer.
SplitSetup,
}
#[derive(Clone, Debug, PartialEq)]
enum Act {
@@ -1282,6 +1287,12 @@ enum Act {
OpenShaderPackFolder,
/// Open the picked pack's Shader Options page.
ShaderOptions,
/// Open the options page of the Horizon pack `s.shader_packs[.0]`.
NativePackOptions(usize),
/// Option `.1` of that pack: its next value.
NativePackOption(usize, usize),
/// Every option of that pack back to its default.
NativePackReset(usize),
/// Shader Options: the next value of an option, a screen of the pack, the next profile,
/// every option back to its default.
ShaderOption(String),
@@ -1411,6 +1422,9 @@ pub enum Event {
OpenShaderOptions,
/// The Shader Options page closed with changes: save them and load the pack again.
ShaderOptions,
/// The options of `shader_packs[.0]`, a Horizon pack, changed: save them (the pack reloads
/// with them).
NativePackOptions(usize),
/// Native mod `.0`'s page opened: the game asks the mod for its labels
/// again (they were made with the list, maybe in another language).
NativeSettings(usize),
@@ -1514,6 +1528,8 @@ pub struct OptionsUi {
/// The list's rows as last laid out: each row's top and bottom (GUI
/// pixels), for the narration's row under the mouse.
row_bands: Vec<[f32; 2]>,
/// Other local players are joined: leave before turning splitscreen off.
pub split_joined: bool,
}
impl Default for OptionsUi {
fn default() -> Self {
@@ -1559,6 +1575,7 @@ impl Default for OptionsUi {
framebuffer: [1280, 720],
split_guest: false,
row_bands: Vec::new(),
split_joined: false,
}
}
}
@@ -1622,23 +1639,18 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
Page::SplitProfiles => "horizon.pages.split_profiles.tooltip",
Page::ShaderPacks => "horizon.pages.shader_packs.tooltip",
Page::Mods => "horizon.pages.mods.tooltip",
Page::SplitSetup => "horizon.pages.split_setup.tooltip",
_ => return None,
})
}
/// The open screen of the picked pack: its title, then its items two to a row (one when the
/// pack asks for a single column).
fn shader_option_rows(s: &Settings) -> Vec<Row> {
let Some(options) = &s.shader_options else {
return vec![Row::Header("horizon.shader_options.loading")];
};
let columns = options
.screens
.screen(options.screen())
.map_or(2, |(_, c)| c);
let blank = || Widget {
/// A list's slot with nothing in it: text takes no clicks.
fn blank_slot(w: f32) -> Widget {
Widget {
x: 0.0,
y: 0.0,
w: 150.0,
w,
h: 20.0,
kind: WKind::Text {
text: Text::literal(""),
@@ -1647,7 +1659,55 @@ fn shader_option_rows(s: &Settings) -> Vec<Row> {
},
list: false,
row: 0,
}
}
/// Page `at` of the options of the Horizon pack `s.shader_packs[i]`: the pack's own layout,
/// else every option two a row.
fn native_pack_option_rows(s: &Settings, i: usize, at: u8) -> Vec<Row> {
use horizon_shader_api::PageItem;
let Some(pack) = s.shader_packs.get(i) else {
return Vec::new();
};
let count = pack.options.len().min(pack.values.len());
let (columns, items) = match pack.pages.get(usize::from(at)) {
Some(page) => (usize::from(page.columns), page.items.clone()),
None => (2, (0..count).map(PageItem::Option).collect()),
};
let width = if columns == 1 { 310.0 } else { 150.0 };
let widget = |item: &PageItem| match *item {
PageItem::Option(o) if o < count => {
let option = &pack.options[o];
let value = crate::shader_pack_list::show_value(&option.kind, pack.values[o]);
let label = Text::literal(format!("{}: {value}", option.name));
button(0.0, 0.0, width, label, Act::NativePackOption(i, o))
}
PageItem::Page(p) if p < pack.pages.len() => {
let label = Text::literal(format!("{}...", pack.pages[p].name));
let page = Page::NativePackOptions(i, p as u8);
button(0.0, 0.0, width, label, Act::Push(page))
}
_ => blank_slot(width),
};
let mut rows = Vec::new();
if at == 0 {
rows.push(Row::Note(pack.title.clone()));
}
rows.extend(
items
.chunks(columns)
.map(|row| Row::Buttons(widget(&row[0]), row.get(1).map(widget))),
);
rows
}
fn shader_option_rows(s: &Settings) -> Vec<Row> {
let Some(options) = &s.shader_options else {
return vec![Row::Header("horizon.shader_options.loading")];
};
let columns = options
.screens
.screen(options.screen())
.map_or(2, |(_, c)| c);
let blank = || blank_slot(150.0);
let width = if columns == 1 { 310.0 } else { 150.0 };
let widget = |item: &horizon_shaders::ScreenItem| -> Option<Widget> {
use horizon_shaders::ScreenItem;
@@ -1712,6 +1772,26 @@ enum Row {
/// A widget's tooltip (`setTooltip`), unwrapped: drawn while hovered, and
/// its narration's hint.
fn tooltip_text(s: &Settings, wd: &Widget) -> Option<Text> {
let pack_text = match &wd.kind {
WKind::Button {
act: Act::NativePackOption(pack, option),
..
} => Some(&s.shader_packs.get(*pack)?.options.get(*option)?.description),
WKind::Button {
act: Act::Push(Page::NativePackOptions(pack, at)),
..
} => Some(
&s.shader_packs
.get(*pack)?
.pages
.get(usize::from(*at))?
.description,
),
_ => None,
};
if let Some(text) = pack_text {
return (!text.is_empty()).then(|| Text::literal(text));
}
let WKind::Button { act, .. } = &wd.kind else {
let WKind::Slider { opt, .. } = &wd.kind else {
return None;
@@ -2055,6 +2135,14 @@ impl OptionsUi {
_ => None,
}
}
/// The name of a Horizon pack's options page past its first, as the page's title.
fn pack_page_title(s: &Settings, page: Page) -> Option<Text> {
let Page::NativePackOptions(pack, at @ 1..) = page else {
return None;
};
let page = s.shader_packs.get(pack)?.pages.get(usize::from(at))?;
Some(Text::literal(&page.name))
}
fn title(page: Page) -> &'static str {
match page {
Page::Main => "options.title",
@@ -2073,7 +2161,7 @@ impl OptionsUi {
Page::Mods => "horizon.pages.mods",
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::ShaderOptions => "horizon.pages.shader_options",
Page::ShaderOptions | Page::NativePackOptions(..) => "horizon.pages.shader_options",
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
"options.videoTitle"
}
@@ -2084,6 +2172,7 @@ impl OptionsUi {
Page::Horizon => "horizon.pages.settings",
Page::Agent => "horizon.pages.agent",
Page::SplitProfiles => "horizon.pages.split_profiles",
Page::SplitSetup => "horizon.pages.split_setup",
}
}
fn scrollbar_x(&self, width: f32) -> f32 {
@@ -2173,6 +2262,14 @@ impl OptionsUi {
fn list_rows(&self, s: &Settings) -> Vec<Row> {
match self.page() {
Page::SplitSetup => vec![Row::Buttons(
if self.split_guest || self.split_joined {
inactive(self.extra_button(s, crate::extras::Item::Splitscreen))
} else {
self.extra_button(s, crate::extras::Item::Splitscreen)
},
None,
)],
Page::Extras => {
// Each section's switches two to a row, as vanilla's
// OptionsList.addSmall lays out an options screen.
@@ -2239,6 +2336,17 @@ impl OptionsUi {
open("horizon.pages.open.extras", Page::Extras),
Some(open("horizon.pages.open.mods", Page::Mods)),
),
Row::Buttons(
open(
"horizon.pages.open.split_setup",
if self.split_guest {
Page::SplitProfiles
} else {
Page::SplitSetup
},
),
None,
),
// No AI hides the AI Agent page and everything AI.
Row::Buttons(
self.extra_button(s, crate::extras::Item::DiscordPresence),
@@ -2502,13 +2610,19 @@ impl OptionsUi {
if s.shader_packs.is_empty() {
return vec![Row::Header("horizon.shader_packs.empty")];
}
let options = s.shader_packs.iter().any(|p| {
p.file == s.horizon.shader_pack
&& p.kind == crate::shader_pack_list::Kind::Classic
});
let options = options.then(|| {
let picked = s
.shader_packs
.iter()
.position(|p| p.file == s.horizon.shader_pack);
let options = picked.and_then(|i| {
let pack = &s.shader_packs[i];
let act = match pack.kind {
crate::shader_pack_list::Kind::Classic => Act::ShaderOptions,
_ if pack.options.is_empty() => return None,
crate::shader_pack_list::Kind::Horizon => Act::NativePackOptions(i),
};
let label = tr("horizon.shader_options.open");
Row::Buttons(button(0.0, 0.0, 310.0, label, Act::ShaderOptions), None)
Some(Row::Buttons(button(0.0, 0.0, 310.0, label, act), None))
});
options
.into_iter()
@@ -2526,6 +2640,7 @@ impl OptionsUi {
.collect()
}
Page::ShaderOptions => shader_option_rows(s),
Page::NativePackOptions(i, at) => native_pack_option_rows(s, i, at),
_ => {
let _ = s;
Vec::new()
@@ -2977,7 +3092,8 @@ impl OptionsUi {
w,
h: 9.0,
kind: WKind::Text {
text: tr(Self::title(page)),
text: Self::pack_page_title(s, page)
.unwrap_or_else(|| tr(Self::title(page))),
color: u32::MAX,
centered: true,
},
@@ -3062,6 +3178,9 @@ impl OptionsUi {
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
Page::ShaderOptions => Some(("controls.reset", Act::ShaderReset)),
Page::NativePackOptions(i, _) => {
Some(("controls.reset", Act::NativePackReset(i)))
}
_ => None,
};
if let Some((label, act)) = folder {
@@ -3722,6 +3841,25 @@ impl OptionsUi {
options.cycle(&name, true);
events.push(Event::ButtonClick);
}
let native = widgets.iter().find_map(|wd| match wd.kind {
WKind::Button {
active: true,
act: Act::NativePackOption(pack, option),
..
} if ui::hovered((x, y), wd.x, wd.y, wd.w, wd.h)
&& !(wd.list && (y < top || y >= bottom)) =>
{
Some((pack, option))
}
_ => None,
});
if let Some((pack, option)) = native
&& let Some(entry) = s.shader_packs.get_mut(pack)
&& entry.step_option(option, true, false)
{
events.push(Event::ButtonClick);
events.push(Event::NativePackOptions(pack));
}
return events;
}
if ui::scrollbar_thumb(top, bottom, content, self.scroll).is_some()
@@ -3838,6 +3976,22 @@ impl OptionsUi {
options.reset();
}
}
Act::NativePackOptions(i) => {
self.push(Page::NativePackOptions(i, 0));
}
Act::NativePackOption(pack, option) => {
if let Some(entry) = s.shader_packs.get_mut(pack)
&& entry.step_option(option, false, self.shift)
{
events.push(Event::NativePackOptions(pack));
}
}
Act::NativePackReset(pack) => {
if let Some(entry) = s.shader_packs.get_mut(pack) {
entry.reset_options();
events.push(Event::NativePackOptions(pack));
}
}
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::None => {}
@@ -4745,6 +4899,22 @@ impl OptionsUi {
options.reset();
}
}
Act::NativePackOptions(i) => {
self.push(Page::NativePackOptions(i, 0));
}
Act::NativePackOption(pack, option) => {
if let Some(entry) = s.shader_packs.get_mut(pack)
&& entry.step_option(option, false, shift)
{
events.push(Event::NativePackOptions(pack));
}
}
Act::NativePackReset(pack) => {
if let Some(entry) = s.shader_packs.get_mut(pack) {
entry.reset_options();
events.push(Event::NativePackOptions(pack));
}
}
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::None => {}
@@ -5032,6 +5202,82 @@ mod tests {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
#[test]
fn a_horizon_packs_options_step_both_ways_from_the_picked_packs_button() {
let atlas = atlas();
let size = (960.0, 540.0);
let mut s = Settings::default();
s.horizon.shader_pack = "p".into();
s.shader_packs = vec![crate::shader_pack_list::Entry {
file: "p".into(),
title: "P".into(),
kind: crate::shader_pack_list::Kind::Horizon,
options: vec![horizon_shader_api::PackOption {
id: "a".into(),
name: "A".into(),
description: String::new(),
kind: horizon_shader_api::OptionKind::Float {
default: 1.0,
min: 0.0,
max: 2.0,
step: 0.5,
},
}],
values: vec![1.0],
pages: vec![
horizon_shader_api::OptionPage {
id: "main".into(),
name: "P".into(),
description: String::new(),
columns: 2,
items: vec![horizon_shader_api::PageItem::Page(1)],
},
horizon_shader_api::OptionPage {
id: "more".into(),
name: "More".into(),
description: String::new(),
columns: 1,
items: vec![horizon_shader_api::PageItem::Option(0)],
},
],
}];
let mut ui = OptionsUi::default();
ui.show(Page::ShaderPacks);
let find = |ui: &mut OptionsUi, s: &Settings, want: &Act| {
let (widgets, ..) = ui.layout(s, size.0, size.1);
let w = widgets
.into_iter()
.find(|w| matches!(&w.kind, WKind::Button { act, .. } if act == want))
.expect("button");
(w.x + w.w / 2.0, w.y + w.h / 2.0)
};
let (x, y) = find(&mut ui, &s, &Act::NativePackOptions(0));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
// The option is on the pack's second page, behind a button on its first.
let (x, y) = find(&mut ui, &s, &Act::Push(Page::NativePackOptions(0, 1)));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(ui.page(), Page::NativePackOptions(0, 1));
let (x, y) = find(&mut ui, &s, &Act::NativePackOption(0, 0));
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert!(events.contains(&Event::NativePackOptions(0)));
assert_eq!(s.shader_packs[0].values, [1.5]);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
assert_eq!(s.shader_packs[0].values, [0.5]);
// Shift-click: the option's default.
ui.shift = true;
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
ui.shift = false;
assert_eq!(s.shader_packs[0].values, [1.0]);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
let (x, y) = find(&mut ui, &s, &Act::NativePackReset(0));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(s.shader_packs[0].values, [1.0]);
ui.back(&mut s);
assert_eq!(ui.page(), Page::NativePackOptions(0, 0));
}
#[test]
fn resource_reload_error_blocks_options_until_dismissed() {
use winit::keyboard::KeyCode;
@@ -6568,6 +6814,7 @@ mod tests {
Page::Controller,
Page::Extras,
Page::Mods,
Page::SplitSetup,
Page::Agent,
]
);
+137 -1
View File
@@ -47,6 +47,9 @@ pub struct ParticleOptions {
/// `GeyserParticleOptions.waterBlocks` and `GeyserBaseParticleOptions`'
/// `waterBlocks` and `burstImpulseBase` (0 where the type has none).
pub geyser: Option<(i32, f32)>,
/// A native mod's own particle type, by its namespaced name (`mymod:spark`):
/// `SimpleParticleType` plus the provider `particles/<name>.json` names.
pub mod_particle: Option<String>,
}
impl ParticleOptions {
pub fn simple(ty: ParticleType) -> Self {
@@ -59,6 +62,15 @@ impl ParticleOptions {
trail: None,
item: None,
geyser: None,
mod_particle: None,
}
}
/// A native mod's `SimpleParticleType`: the sprite set of
/// `assets/<namespace>/particles/<path>.json`, drawn as a plain billboard.
pub fn named(name: impl Into<String>) -> Self {
Self {
mod_particle: Some(name.into()),
..Self::simple(ParticleType::Smoke)
}
}
/// `GeyserParticleOptions(type, waterBlocks)`.
@@ -1055,6 +1067,8 @@ pub struct ParticleEngine {
/// it on the translucent layer (a texel with 0 < alpha < 255).
atlas: HashMap<String, (Rect, bool)>,
sets: HashMap<ParticleType, u32>,
/// A native mod's sprite sets, by namespaced particle name.
mod_sets: HashMap<String, u32>,
set_sprites: Vec<Vec<u32>>,
/// `TrackingEmitter`s: particle type, entity feet position, width, height, ticks left.
emitters: Vec<(ParticleOptions, [f64; 3], f64, f64, u32)>,
@@ -1092,6 +1106,7 @@ impl ParticleEngine {
rects: Vec::new(),
atlas: HashMap::new(),
sets: HashMap::new(),
mod_sets: HashMap::new(),
set_sprites: Vec::new(),
emitters: Vec::new(),
starters: Vec::new(),
@@ -1123,12 +1138,24 @@ impl ParticleEngine {
.map(|name| self.atlas.get(name).map(|&(rect, _)| rect))
.collect();
self.sets.clear();
self.mod_sets.clear();
self.set_sprites.clear();
self.item_sprites.clear();
self.load_sprites(root);
}
/// The sprite sets of every `particles/*.json` under `root`.
/// The sprite sets of every `particles/*.json` under `root`: vanilla's own
/// and a native mod's (`assets/<namespace>/particles/<name>.json`).
fn load_sprites(&mut self, root: &Path) {
let assets = root.join("assets");
if let Ok(namespaces) = std::fs::read_dir(&assets) {
for namespace in namespaces.flatten() {
let ns = namespace.file_name().to_string_lossy().into_owned();
if ns == "minecraft" || !namespace.path().is_dir() {
continue;
}
self.load_mod_sprites(&ns, &namespace.path().join("particles"));
}
}
let dir = root.join("assets/minecraft/particles");
for &ty in ParticleType::ALL {
let Ok(bytes) = std::fs::read(dir.join(format!("{}.json", ty.name()))) else {
@@ -1154,6 +1181,56 @@ impl ParticleEngine {
}
}
}
/// A namespace's particle JSONs that vanilla has no type for: each becomes
/// one sprite set under `<ns>:<file name>`.
fn load_mod_sprites(&mut self, ns: &str, dir: &Path) {
let Ok(files) = std::fs::read_dir(dir) else {
return;
};
for file in files.flatten() {
let path = file.path();
if path.extension().is_none_or(|e| e != "json") {
continue;
}
let Some(stem) = path.file_stem().map(|s| s.to_string_lossy().into_owned()) else {
continue;
};
let key = format!("{ns}:{stem}");
if ParticleType::from_name(&key).is_some() {
continue;
}
let Ok(bytes) = std::fs::read(&path) else {
continue;
};
let Ok(json) = serde_json::from_slice::<serde_json::Value>(&bytes) else {
continue;
};
let sprites: Vec<u32> = json["textures"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|t| t.as_str())
.map(|t| {
// A texture is `<ns>:<path>` (bare means
// `minecraft:`), and the atlas names a sprite
// `<ns>:particle/<path>` — `minecraft`'s own
// without its namespace.
let name = match t.split_once(':') {
Some(("minecraft", path)) => format!("particle/{path}"),
Some((ns, path)) => format!("{ns}:particle/{path}"),
None => format!("particle/{t}"),
};
self.intern(&name)
})
.collect()
})
.unwrap_or_default();
if !sprites.is_empty() {
self.mod_sets.insert(key, self.set_sprites.len() as u32);
self.set_sprites.push(sprites);
}
}
}
fn intern(&mut self, name: &str) -> u32 {
if let Some(i) = self.names.iter().position(|n| n == name) {
return i as u32;
@@ -1626,6 +1703,23 @@ impl ParticleEngine {
) -> Option<Particle> {
use ParticleType as T;
let ty = o.ty;
// A native mod's SimpleParticleType: vanilla's TextureSheetParticle with
// the sprite set its particles/<name>.json names, no tint, 20..40 ticks.
if let Some(name) = &o.mod_particle {
let set = *self.mod_sets.get(name)?;
let mut r = LegacyRandom::new(seed);
let mut q = Particle::moving(&mut r, ParticleType::Smoke, p, v);
q.ty = ParticleType::Smoke;
q.gravity = 0.0;
q.rgba = [1.0; 4];
q.lifetime = 20 + r.next_int_bound(20).unwrap_or(0);
q.set = Some(set);
let sprites = &self.set_sprites[set as usize];
q.sprite = Sprite::Index(
sprites[r.next_int_bound(sprites.len() as i32).unwrap_or(0) as usize],
);
return Some(q);
}
let set = self.set_of(ty);
let mut local = LegacyRandom::new(seed);
let r = &mut local;
@@ -4222,6 +4316,48 @@ const CREEPER: [[f64; 2]; 12] = [
mod tests {
use super::*;
#[test]
fn a_mod_particles_json_becomes_its_sprite_set() {
let dir =
std::env::temp_dir().join(format!("horizon-mod-particles-{}", std::process::id()));
let particles = dir.join("assets/mymod/particles");
std::fs::create_dir_all(&particles).unwrap();
std::fs::write(
particles.join("spark.json"),
br#"{"textures":["mymod:spark/1","mymod:spark/2"]}"#,
)
.unwrap();
assert!(particles.join("spark.json").exists());
let mut engine = ParticleEngine::new(&dir, &HashMap::new(), &HashSet::new());
let set = *engine
.mod_sets
.get("mymod:spark")
.expect("the set is loaded");
assert_eq!(engine.set_sprites[set as usize].len(), 2);
// The named option spawns a billboard from that set, not a vanilla type.
let made = engine
.make(
&ParticleOptions::named("mymod:spark"),
[0.0; 3],
[0.0; 3],
7,
)
.expect("a mod particle is made");
assert_eq!(made.set, Some(set));
// A name no pack knows draws nothing, like a missing vanilla sprite set.
assert!(
engine
.make(
&ParticleOptions::named("mymod:missing"),
[0.0; 3],
[0.0; 3],
7
)
.is_none()
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn egg_teleport_unpacks_offsets_and_preserves_seeded_draw_order() {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
@@ -332,6 +332,7 @@ impl Settings {
.parent()
.map(crate::graphics::Graphics::load)
.unwrap_or_default();
crate::mipmap::set_dither_cutouts(horizon.dither_cutout_mips);
let controller = path
.parent()
.map(crate::gamepad::PadSettings::load)
@@ -2,7 +2,9 @@
//! Settings > Shader Packs lists them: Horizon's own (docs/shaders.md) and those in the
//! `shaders/` format, side by side.
use std::path::Path;
use std::path::{Path, PathBuf};
use horizon_shader_api::{OptionKind, OptionPage, PackOption};
pub const DIR: &str = "shaderpacks";
@@ -14,13 +16,48 @@ pub enum Kind {
Classic,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
/// The folder's or file's name in `shaderpacks`: what the pick saves.
pub file: String,
/// The pack's own name for Horizon's packs, else `file`.
pub title: String,
pub kind: Kind,
/// A Horizon pack's `[[options]]`, and the value of each the player set (`values_path`).
pub options: Vec<PackOption>,
pub values: Vec<f64>,
/// Its options page's `[[pages]]`; empty for one page of every option.
pub pages: Vec<OptionPage>,
}
impl Entry {
/// Saves the player's option values, for the pack in the folder `dir`.
pub fn save_values(&self, dir: &Path) {
let path = dir.join(&self.file);
if let Err(e) = save_values(&path, &self.options, &self.values) {
log::warn!("shader pack options of {}: {e}", self.file);
}
}
/// The option `i` one value on (`back`: one before), or at its default (`reset`). Whether
/// the pack has that option.
pub fn step_option(&mut self, i: usize, back: bool, reset: bool) -> bool {
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
return false;
};
*v = if reset {
o.kind.default_constant()
} else {
step_value(&o.kind, *v, back)
};
true
}
/// Every option back to its default.
pub fn reset_options(&mut self) {
let defaults = self.options.iter().map(|o| o.kind.default_constant());
self.values = defaults.collect();
}
}
/// The packs in `dir`, Horizon's first, each kind by title. A missing folder has none.
@@ -53,11 +90,18 @@ fn entry(path: &Path) -> Option<Entry> {
.ok()
.map(|p| p.manifest)
};
let title = manifest.map_or_else(|| file.clone(), |m| m.name);
let (title, options, pages) = manifest.map_or_else(
|| (file.clone(), Vec::new(), Vec::new()),
|m| (m.name, m.options, m.pages),
);
let values = load_values(path, &options);
return Some(Entry {
file,
title,
kind: Kind::Horizon,
options,
values,
pages,
});
}
let zip = path.is_file()
@@ -68,9 +112,128 @@ fn entry(path: &Path) -> Option<Entry> {
title: file.clone(),
file,
kind: Kind::Classic,
options: Vec::new(),
values: Vec::new(),
pages: Vec::new(),
})
}
/// Where the option values of the Horizon pack at `pack` are kept: `<pack>.txt` beside it,
/// one `id=value` a line.
pub fn values_path(pack: &Path) -> PathBuf {
let mut path = pack.as_os_str().to_owned();
path.push(".txt");
PathBuf::from(path)
}
/// The value of each of `options` saved for the pack at `pack`, else its default; a value
/// out of the option's range is brought into it.
pub fn load_values(pack: &Path, options: &[PackOption]) -> Vec<f64> {
let text = std::fs::read_to_string(values_path(pack)).unwrap_or_default();
let saved: std::collections::HashMap<&str, f64> = text
.lines()
.filter_map(|l| l.split_once('='))
.filter_map(|(k, v)| Some((k.trim(), v.trim().parse().ok()?)))
.collect();
options
.iter()
.map(|o| {
saved
.get(o.id.as_str())
.map_or_else(|| o.kind.default_constant(), |&v| clamp(&o.kind, v))
})
.collect()
}
/// Saves `values` of `options`; values at their defaults are left out, and with none left
/// the file goes.
fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::Result<()> {
let text: String = options
.iter()
.zip(values)
.filter(|(o, v)| o.kind.default_constant() != **v)
.map(|(o, v)| format!("{}={v}\n", o.id))
.collect();
let path = values_path(pack);
if text.is_empty() {
match std::fs::remove_file(&path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
}
} else {
std::fs::write(path, text)
}
}
fn clamp(kind: &OptionKind, v: f64) -> f64 {
match kind {
OptionKind::Bool { .. } => f64::from(u8::from(v != 0.0)),
OptionKind::Float { min, max, .. } => v.clamp(f64::from(*min), f64::from(*max)),
OptionKind::Int { min, max, .. } => v.round().clamp(f64::from(*min), f64::from(*max)),
OptionKind::Choice { values, .. } => v.round().clamp(0.0, values.len() as f64 - 1.0),
}
}
/// The option's next value (`back`: the one before), wrapping around at the ends.
fn step_value(kind: &OptionKind, v: f64, back: bool) -> f64 {
let dir = if back { -1.0 } else { 1.0 };
match kind {
OptionKind::Bool { .. } => f64::from(u8::from(v == 0.0)),
OptionKind::Float { min, max, step, .. } => {
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
if !back && v >= max - step * 0.01 {
min
} else if back && v <= min + step * 0.01 {
max
} else {
// On the grid of steps from the minimum, so float error never piles up.
let n = ((v - min) / step).round() + dir;
(min + n * step).clamp(min, max)
}
}
OptionKind::Int { min, max, .. } => {
let (min, max) = (f64::from(*min), f64::from(*max));
let n = v.round() + dir;
if n > max {
min
} else if n < min {
max
} else {
n
}
}
OptionKind::Choice { values, .. } => {
let n = values.len() as f64;
(v.round() + dir).rem_euclid(n)
}
}
}
/// The value as the settings page shows it.
pub fn show_value(kind: &OptionKind, v: f64) -> String {
match kind {
OptionKind::Bool { .. } => crate::lang::get(if v != 0.0 {
"options.on"
} else {
"options.off"
}),
OptionKind::Float { step, .. } => {
let digits = if *step >= 1.0 {
0
} else if *step >= 0.1 {
1
} else {
2
};
format!("{v:.digits$}")
}
OptionKind::Int { .. } => format!("{v:.0}"),
OptionKind::Choice { values, .. } => {
values.get(v.round() as usize).cloned().unwrap_or_default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -106,4 +269,59 @@ mod tests {
assert!(scan(&d.join("missing")).is_empty());
std::fs::remove_dir_all(d).unwrap();
}
fn option(id: &str, kind: OptionKind) -> PackOption {
PackOption {
id: id.into(),
name: id.into(),
description: String::new(),
kind,
}
}
#[test]
fn values_step_wrap_and_round_trip() {
let float = OptionKind::Float {
default: 1.0,
min: 0.0,
max: 2.0,
step: 0.5,
};
assert_eq!(step_value(&float, 1.0, false), 1.5);
assert_eq!(step_value(&float, 2.0, false), 0.0);
assert_eq!(step_value(&float, 0.0, true), 2.0);
assert_eq!(show_value(&float, 1.5), "1.5");
let choice = OptionKind::Choice {
values: vec!["a".into(), "b".into(), "c".into()],
default: 0,
};
assert_eq!(step_value(&choice, 2.0, false), 0.0);
assert_eq!(step_value(&choice, 0.0, true), 2.0);
assert_eq!(show_value(&choice, 1.0), "b");
let int = OptionKind::Int {
default: 0,
min: -1,
max: 1,
};
assert_eq!(step_value(&int, 1.0, false), -1.0);
let on = OptionKind::Bool { default: true };
assert_eq!(step_value(&on, 1.0, false), 0.0);
let dir =
std::env::temp_dir().join(format!("horizon-shader-values-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let pack = dir.join("p.hshader");
let options = [option("a", float), option("b", choice)];
// Only what differs from the defaults is written; out of range comes back clamped.
save_values(&pack, &options, &[1.0, 2.0]).unwrap();
let text = std::fs::read_to_string(values_path(&pack)).unwrap();
assert_eq!(text, "b=2\n");
std::fs::write(values_path(&pack), "a=9\nb=1\nzzz=4\n").unwrap();
assert_eq!(load_values(&pack, &options), [2.0, 1.0]);
save_values(&pack, &options, &[1.0, 0.0]).unwrap();
assert!(!values_path(&pack).exists());
assert_eq!(load_values(&pack, &options), [1.0, 0.0]);
std::fs::remove_dir_all(&dir).unwrap();
}
}
+141
View File
@@ -158,6 +158,12 @@ pub fn profile_skin_url_nbt(profile: &horizon_world::nbt::Tag) -> Option<String>
unpack(&value, Signature::Unsigned, &TEXTURE_URIS).skin
}
/// `ResolvableProfile.skinPatch().body()` of a skull's `profile` NBT: the
/// `texture` id that replaces its skin.
pub fn profile_texture_nbt(profile: &horizon_world::nbt::Tag) -> Option<String> {
nbt_text(profile.get("texture"))
}
/// The profile's uuid as u128: `id` as an int array (block entity NBT) or a
/// uuid string (older and component forms). Used for `DefaultPlayerSkin.get`
/// when a head's skin is not downloaded yet.
@@ -467,6 +473,11 @@ pub fn head_skin(
downloads: &mut Downloader,
atlas: impl Fn(&str) -> bool,
) -> String {
if let Some(texture) = profile.get("texture").and_then(|t| t.as_str())
&& let Some(sprite) = patch_skin(texture, downloads, &atlas)
{
return sprite;
}
let p = parse_profile(profile);
let property = p.textures.or_else(|| {
let key = p.dynamic.as_ref()?;
@@ -495,6 +506,68 @@ pub fn head_skin(
crate::tab_list::default_skin(p.id).to_owned()
}
/// `PlayerSkin.Patch.apply` for a head: the sprite of a profile's `texture`
/// override (`ClientAsset.ResourceTexture`, a resource pack's
/// `<namespace>:textures/<path>.png`), which replaces the downloaded or
/// default skin. Vanilla textures are in the atlas already; another
/// namespace's loads into a skin slot, and `None` answers until it is there
/// (or when the packs lack it).
pub fn patch_skin(
texture: &str,
downloads: &mut Downloader,
atlas: impl Fn(&str) -> bool,
) -> Option<String> {
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
if !valid_texture_id(namespace, path) {
return None;
}
if namespace == "minecraft" && atlas(path) {
return Some(path.to_owned());
}
let location = format!("patch/{namespace}/{path}");
if atlas(&location) {
downloads.drawn(&location);
return Some(location);
}
downloads.resource(&location, namespace, path);
None
}
/// `Identifier`'s characters (`isValidNamespace`, `isValidPath`), and no
/// `.`, `..` or empty segment (`FileUtil.decomposePath`, as the packs read
/// it): a server's `texture` never names a file outside the packs.
fn valid_texture_id(namespace: &str, path: &str) -> bool {
let segment = |s: &str| !s.is_empty() && s != "." && s != "..";
!namespace.is_empty()
&& namespace
.chars()
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_' | '-' | '.'))
&& path
.chars()
.all(|c| matches!(c, 'a'..='z' | '0'..='9' | '_' | '-' | '.' | '/'))
&& segment(namespace)
&& path.split('/').all(segment)
}
/// A `width`x`height` RGBA image as the 64x64 skin it maps onto: each texel
/// the nearest of the image's at the same normalised UV.
fn resample_64(width: u32, height: u32, rgba: &[u8]) -> Option<Vec<u8>> {
let (w, h) = (width as usize, height as usize);
if w == 0 || h == 0 || rgba.len() < w * h * 4 {
return None;
}
let mut out = vec![0u8; 64 * 64 * 4];
for y in 0..64 {
for x in 0..64 {
let (sx, sy) = (x * w / 64, y * h / 64);
let s = (sy * w + sx) * 4;
let d = (y * 64 + x) * 4;
out[d..d + 4].copy_from_slice(&rgba[s..s + 4]);
}
}
Some(out)
}
/// `MinecraftProfileTexture.getHash`: the base name of the URL's path.
pub fn texture_hash(url: &str) -> Option<String> {
let rest = url.split_once("://")?.1;
@@ -732,6 +805,10 @@ pub struct Downloader {
image_order: std::collections::VecDeque<String>,
/// Skins and capes drawn from the atlas since `take_drawn`.
drawn: std::collections::HashSet<String>,
/// The resource packs' root, where a profile's `texture` patch is read.
resource_root: std::path::PathBuf,
/// How each `patch/` texture of the resource packs loaded, by location.
resources: std::collections::HashMap<String, Download>,
}
/// `images` is bounded only so a long session on a busy server does not grow
/// without end; the disk cache holds everything it drops.
@@ -749,8 +826,48 @@ impl Downloader {
images: Default::default(),
image_order: Default::default(),
drawn: Default::default(),
resource_root: Default::default(),
resources: Default::default(),
}
}
/// The resource packs changed (or were first loaded) at `root`: patch
/// textures load again from there.
pub fn set_resource_root(&mut self, root: std::path::PathBuf) {
for location in std::mem::take(&mut self.resources).into_keys() {
self.images.remove(&location);
self.image_order.retain(|l| *l != location);
}
self.resource_root = root;
}
/// `ClientAsset.ResourceTexture`: loads `<namespace>:textures/<path>.png`
/// from the resource packs into a skin slot as `location`, once. A
/// texture of another size is sampled as the model's normalised UVs would
/// sample it (nearest texel) into the 64x64 slot.
fn resource(&mut self, location: &str, namespace: &str, path: &str) {
match self.resources.get(location) {
Some(Download::Failed) => return,
Some(_) => {
self.again(location);
return;
}
None => {}
}
let file = self
.resource_root
.join(format!("assets/{namespace}/textures/{path}.png"));
let image = std::fs::read(file)
.ok()
.and_then(|b| crate::hud::decode_bytes(&b).ok())
.and_then(|(w, h, rgba)| resample_64(w, h, &rgba));
let state = match image {
Some(rgba) => {
self.queue(location.to_owned(), rgba);
Download::Ready
}
None => Download::Failed,
};
self.resources.insert(location.to_owned(), state);
}
/// How the download of `url` stands, starting it the first time.
pub fn state(&mut self, kind: Kind, url: &str) -> Download {
while let Ok((key, image)) = self.done.try_recv() {
@@ -824,6 +941,15 @@ impl Downloader {
if self.queued.contains(location) {
return true;
}
if location.starts_with("patch/") {
let Some(image) = self.images.get(location).cloned() else {
// Dropped from memory: read from the packs again.
self.resources.remove(location);
return false;
};
self.queue(location.to_owned(), image);
return true;
}
let (digest, process): (_, fn(u32, u32, &[u8]) -> Option<Vec<u8>>) =
if let Some(digest) = location.strip_prefix("skins/") {
(digest, process_legacy_skin)
@@ -960,6 +1086,21 @@ pub fn sha1(data: &[u8]) -> [u8; 20] {
#[cfg(test)]
mod tests {
/// A profile's `texture` names a pack texture, never a file outside the
/// packs: bad characters and `..` segments draw nothing.
#[test]
fn a_texture_patch_stays_inside_the_packs() {
use super::valid_texture_id;
assert!(valid_texture_id("ethereal", "entity/plush_blank"));
assert!(valid_texture_id("minecraft", "entity/player/wide/steve"));
assert!(!valid_texture_id("ethereal", "../../../secret"));
assert!(!valid_texture_id("..", "entity/x"));
assert!(!valid_texture_id("ethereal", "entity//x"));
assert!(!valid_texture_id("Ethereal", "entity/x"));
assert!(!valid_texture_id("ethereal", "entity\\x"));
assert!(!valid_texture_id("", "entity/x"));
}
/// A pack reload swaps the atlas: its skin slots are blank, so a forgotten
/// table holds nothing and hands out slots from the start again, even for
/// skins drawn in the last frame.
@@ -25,6 +25,30 @@ const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
&[4, 4, 4, 4],
];
/// Where a window is.
#[derive(Clone, Debug, PartialEq)]
pub enum Placement {
/// A normal window: its corner on the desktop and its size in pixels (`None`: a new one,
/// where the system opens it).
Free(Option<([i32; 2], [u32; 2])>),
/// Borderless over the monitor of this name.
Fill(String),
}
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
/// centred, each next one a step further, as systems cascade new windows (back to the centre
/// after six), so new windows never open exactly on one another.
pub fn cascade(number: u8) -> f64 {
f64::from(number.saturating_sub(2) % 6) * 32.0
}
/// A window: its number (1 is the game's own) and where it is.
#[derive(Clone, Debug, PartialEq)]
pub struct Window {
pub number: u8,
pub placement: Placement,
}
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
/// others, P1 first.
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
+244 -41
View File
@@ -85,18 +85,93 @@ pub struct Sprites {
/// Each layer's frames at the texture's own resolution (square), and the
/// frame shown first.
native: Vec<(u32, Arc<Vec<Vec<u8>>>, usize)>,
/// Still textures by their pixels and mip settings: mods copy vanilla's
/// textures under their own names, and one layer serves them all (the
/// GPU's array may hold as few as 2048).
still: std::collections::HashMap<(Vec<u8>, u32, [u32; 2], [bool; 4]), u32>,
/// Each layer's LabPBR maps (`_n` normals, `_s` specular) beside its texture: their first
/// frame at their own resolution (square).
pbr: Vec<[Option<(u32, Vec<u8>)>; 2]>,
/// The layers' normal and specular maps at `pbr_size`, layer after layer, set by
/// [`Sprites::finish`]: empty when no texture has either.
pub normals: Vec<u8>,
pub specular: Vec<u8>,
pub pbr_size: u32,
/// Still textures by their pixels, their LabPBR maps and mip settings: mods copy vanilla's
/// textures under their own names, and one layer serves them all (the GPU's array may
/// hold as few as 2048).
still: std::collections::HashMap<(Vec<u8>, u64, u32, [u32; 2], [bool; 4]), u32>,
/// Decoded textures kept on disk between launches ([`Sprites::with_disk_cache`]).
disk: Option<crate::sprite_cache::SpriteCache>,
}
/// Edge length of vanilla's block textures: the smallest array layer.
pub const SPRITE_SIZE: u32 = 16;
/// Largest sprite array (all layers of the top mip level): resource packs
/// with bigger textures are sampled down to fit.
const MAX_ARRAY_BYTES: u64 = 256 << 20;
/// The largest sprite array (all layers of the top mip level) when the settings leave it to
/// Horizon: resource packs with bigger textures are sampled down to fit.
pub const DEFAULT_TEXTURE_BUDGET: u64 = 256 << 20;
/// The budget in bytes the next resource load keeps to (`set_texture_budget`).
static TEXTURE_BUDGET: std::sync::atomic::AtomicU64 =
std::sync::atomic::AtomicU64::new(DEFAULT_TEXTURE_BUDGET);
/// The block textures' budget from the `textureBudgetMiB` setting (0 for the default), for
/// resources loaded from now on.
pub fn set_texture_budget(mib: u32) {
let bytes = match mib {
0 => DEFAULT_TEXTURE_BUDGET,
mib => u64::from(mib) << 20,
};
TEXTURE_BUDGET.store(bytes, std::sync::atomic::Ordering::Relaxed);
}
/// Whether resource loads read the resource packs' LabPBR maps (`set_labpbr_maps`).
static LABPBR_MAPS: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether resource loads from now on read the resource packs' LabPBR maps beside their block
/// textures. Only a native shader pack's material stage reads them, so without one they cost
/// neither loading time nor video memory.
pub fn set_labpbr_maps(on: bool) {
LABPBR_MAPS.store(on, std::sync::atomic::Ordering::Relaxed);
}
/// Whether resource loads read the LabPBR maps (`set_labpbr_maps`).
pub fn labpbr_maps() -> bool {
LABPBR_MAPS.load(std::sync::atomic::Ordering::Relaxed)
}
/// Largest array of each LabPBR map, which only a native shader pack reads.
const MAX_MAP_BYTES: u64 = DEFAULT_TEXTURE_BUDGET / 4;
/// Fewer layers than any game has (vanilla's are about 1300): what a map is kept at while
/// loading assumes at least these many share the array.
const LAYERS_AT_LEAST: u64 = 1024;
/// A layer's normal map where its texture has none: flat, unoccluded, at full height.
pub const FLAT_NORMAL: [u8; 4] = [128, 128, 255, 255];
/// A layer's specular map where its texture has none: rough, not metal, not glowing.
pub const NO_SPECULAR: [u8; 4] = [0, 0, 0, 0];
/// The largest power-of-two side from `start` down (at least `SPRITE_SIZE`) at which `layers`
/// square RGBA layers take at most `bytes`.
fn side_within(start: u32, layers: u64, bytes: u64) -> u32 {
let mut size = start.max(SPRITE_SIZE).next_power_of_two();
while size > SPRITE_SIZE && layers * u64::from(size) * u64::from(size) * 4 > bytes {
size /= 2;
}
size
}
/// A LabPBR map's square `frame` of `side` averaged down by whole powers of two to at most
/// `max`, channel by channel: they are data, not colour.
fn shrink_map(frame: Vec<u8>, side: u32, max: u32) -> (u32, Vec<u8>) {
if side <= max || !side.is_power_of_two() || !max.is_power_of_two() {
return (side, frame);
}
let (out_side, f, side) = (max as usize, (side / max) as usize, side as usize);
let mut out = Vec::with_capacity(out_side * out_side * 4);
for y in 0..out_side {
for x in 0..out_side {
let mut sum = [0u32; 4];
for dy in 0..f {
let row = ((y * f + dy) * side + x * f) * 4;
for texel in frame[row..row + f * 4].as_chunks::<4>().0 {
for (s, &v) in sum.iter_mut().zip(texel) {
*s += u32::from(v);
}
}
}
let n = (f * f) as u32;
out.extend(sum.map(|s| ((s + n / 2) / n) as u8));
}
}
(max, out)
}
/// Nearest-resample a square RGBA frame to another square size.
pub(crate) fn resample(frame: &[u8], from: u32, to: u32) -> Vec<u8> {
if from == to {
@@ -156,7 +231,7 @@ impl Sprites {
}
/// Lay every layer out at one size: the largest texture's (resource packs
/// may ship 32x, 64x... textures, like vanilla's stitched atlas), rounded
/// up to a power of two and limited to [`MAX_ARRAY_BYTES`]. Smaller
/// up to a power of two and limited to the texture budget (`set_texture_budget`). Smaller
/// textures are scaled up by whole pixels, so UVs stay per layer.
pub fn finish(&mut self) {
self.lay_out(true);
@@ -170,13 +245,18 @@ impl Sprites {
let layers = self.native.len().max(1) as u64;
let largest = self.native.iter().map(|n| n.0).max().unwrap_or(SPRITE_SIZE);
self.minimum_size = self.native.iter().map(|n| n.0).min().unwrap_or(SPRITE_SIZE);
let mut size = largest.max(SPRITE_SIZE).next_power_of_two();
while size > SPRITE_SIZE && layers * u64::from(size * size) * 4 > MAX_ARRAY_BYTES {
size /= 2;
let budget = TEXTURE_BUDGET.load(std::sync::atomic::Ordering::Relaxed);
let size = side_within(largest, layers, budget);
if pixels {
log::info!(
"Horizon: block sprites: {layers} layers at {size} px (largest {largest} px, budget {} MiB)",
budget >> 20
);
}
if !pixels {
self.pixels = Vec::new();
self.animations = Vec::new();
self.pbr = Vec::new();
self.size = size;
return;
}
@@ -192,6 +272,30 @@ impl Sprites {
animation.frames = resized[animation.layer as usize].clone();
}
self.size = size;
self.normals.clear();
self.specular.clear();
let largest_map = self.pbr.iter().flatten().flatten().map(|m| m.0).max();
self.pbr_size = largest_map.map_or(SPRITE_SIZE, |l| {
side_within(l.min(size), layers, MAX_MAP_BYTES)
});
if largest_map.is_some() {
let pbr_size = self.pbr_size;
log::info!("Horizon: LabPBR maps at {pbr_size} px, {layers} layers");
for maps in std::mem::take(&mut self.pbr) {
for (map, (out, none)) in maps.into_iter().zip([
(&mut self.normals, FLAT_NORMAL),
(&mut self.specular, NO_SPECULAR),
]) {
match map {
Some((from, pixels)) => {
let (from, pixels) = shrink_map(pixels, from, pbr_size);
out.extend(resample(&pixels, from, pbr_size));
}
None => out.extend(none.repeat((pbr_size * pbr_size) as usize)),
}
}
}
}
}
/// The layer of a texture, added on first use. A texture that is missing
/// or does not load (a resource pack's filter or a broken file) becomes
@@ -222,6 +326,7 @@ impl Sprites {
.collect();
let layer = u32::try_from(self.native.len())?;
self.opaque.push(true);
self.pbr.push([None, None]);
self.translucent.push(false);
self.mips.push(crate::mipmap::Settings::default());
self.layers.insert(name.to_owned(), layer);
@@ -242,7 +347,7 @@ impl Sprites {
decoded
}
};
self.register(name, decoded)
self.register(root, name, decoded)
}
/// Read and decode one texture and its `.mcmeta`.
fn decode(root: &Path, name: &str) -> Result<crate::sprite_cache::Decoded> {
@@ -263,40 +368,19 @@ impl Sprites {
.as_deref()
.map(AnimationMetadata::from_json)
.transpose()?;
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![
0;
reader
.output_buffer_size()
.ok_or("sprite exceeds decoder limits")?
];
let info = reader.next_frame(&mut bytes)?;
let (png_width, png_height, pixels) = read_rgba(&path)?;
let (width, height) = if let Some(animation) = &animation {
let [width, height] =
animation.frame_size(i32::try_from(info.width)?, i32::try_from(info.height)?);
animation.frame_size(i32::try_from(png_width)?, i32::try_from(png_height)?);
(u32::try_from(width)?, u32::try_from(height)?)
} else {
(info.width, info.height)
(png_width, png_height)
};
if width == 0 || height == 0 || info.width % width != 0 || info.height % height != 0 {
if width == 0 || height == 0 || png_width % width != 0 || png_height % height != 0 {
return Err(format!("sprite {name} has invalid frame dimensions").into());
}
let channels = info.color_type.samples();
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
for pixel in bytes[..info.buffer_size()].chunks_exact(channels) {
let rgba = match info.color_type {
png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
png::ColorType::Grayscale => [pixel[0], pixel[0], pixel[0], 255],
png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
_ => return Err("unsupported expanded sprite format".into()),
};
pixels.extend(rgba);
}
let columns = info.width / width;
let count = columns * (info.height / height);
let columns = png_width / width;
let count = columns * (png_height / height);
// Layers are square; a frame of another shape is nearest-resampled to
// its longer side so UVs (normalized per layer) are unchanged.
let side = width.max(height);
@@ -309,7 +393,7 @@ impl Sprites {
let source_row = y + row * height / side;
for column in 0..side {
let offset =
((source_row * info.width + x + column * width / side) * 4) as usize;
((source_row * png_width + x + column * width / side) * 4) as usize;
frame.extend_from_slice(&pixels[offset..offset + 4]);
}
}
@@ -339,7 +423,12 @@ impl Sprites {
})
}
/// Give a decoded texture its layer (or the one an identical still has).
fn register(&mut self, name: &str, decoded: crate::sprite_cache::Decoded) -> Result<u32> {
fn register(
&mut self,
root: &Path,
name: &str,
decoded: crate::sprite_cache::Decoded,
) -> Result<u32> {
let crate::sprite_cache::Decoded {
side,
frames,
@@ -356,6 +445,13 @@ impl Sprites {
.transpose()?;
let count = frames.len() as u32;
let layer = u32::try_from(self.native.len())?;
let pbr = match name.split_once(':') {
Some((namespace, path)) if labpbr_maps() => {
let path = root.join(format!("assets/{namespace}/textures/{path}.png"));
["_n", "_s"].map(|suffix| pbr_map(&path, suffix, count))
}
_ => [None, None],
};
let settings = crate::mipmap::Settings {
native_size: side,
strategy,
@@ -364,10 +460,15 @@ impl Sprites {
item: name
.split_once(':')
.is_some_and(|(_, p)| p.starts_with("item/")),
dither_cutouts: crate::mipmap::dither_cutouts(),
};
let still = (animation.is_none() && frames.len() == 1).then(|| {
use std::hash::{Hash, Hasher};
let mut maps = std::collections::hash_map::DefaultHasher::new();
pbr.hash(&mut maps);
(
frames[0].clone(),
maps.finish(),
side,
[
settings.strategy as u32,
@@ -401,12 +502,66 @@ impl Sprites {
self.mips.push(settings);
self.opaque.push(opaque);
self.translucent.push(translucent);
self.pbr.push(pbr);
self.layers.insert(name.to_owned(), layer);
self.native.push((side, frames, initial));
Ok(layer)
}
}
/// A PNG's width, height and pixels as RGBA.
fn read_rgba(path: &Path) -> Result<(u32, u32, Vec<u8>)> {
let mut decoder = png::Decoder::new(BufReader::new(File::open(path)?));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info()?;
let mut bytes = vec![
0;
reader
.output_buffer_size()
.ok_or("sprite exceeds decoder limits")?
];
let info = reader.next_frame(&mut bytes)?;
let channels = info.color_type.samples();
let mut pixels = Vec::with_capacity(info.width as usize * info.height as usize * 4);
for pixel in bytes[..info.buffer_size()].chunks_exact(channels) {
let rgba = match info.color_type {
png::ColorType::Rgba => [pixel[0], pixel[1], pixel[2], pixel[3]],
png::ColorType::Rgb => [pixel[0], pixel[1], pixel[2], 255],
png::ColorType::Grayscale => [pixel[0], pixel[0], pixel[0], 255],
png::ColorType::GrayscaleAlpha => [pixel[0], pixel[0], pixel[0], pixel[1]],
_ => return Err("unsupported expanded sprite format".into()),
};
pixels.extend(rgba);
}
Ok((info.width, info.height, pixels))
}
/// The LabPBR map with `suffix` beside the texture at `path`, whose frames lie in a column of
/// `count`: its first frame, square, no larger than the maps' array allows any game's
/// textures (a 512x pack's maps would otherwise fill the memory while loading).
fn pbr_map(path: &Path, suffix: &str, count: u32) -> Option<(u32, Vec<u8>)> {
let stem = path.file_stem()?.to_str()?;
let map = path.with_file_name(format!("{stem}{suffix}.png"));
let (map_width, map_height, pixels) = read_rgba(&map).ok()?;
let (w, h) = (map_width, map_height / count.max(1));
if w == 0 || h == 0 {
return None;
}
let side = w.max(h);
let mut frame = Vec::with_capacity((side * side * 4) as usize);
for row in 0..side {
for column in 0..side {
let offset = (((row * h / side) * map_width + column * w / side) * 4) as usize;
frame.extend_from_slice(&pixels[offset..offset + 4]);
}
}
Some(shrink_map(
frame,
side,
side_within(side, LAYERS_AT_LEAST, MAX_MAP_BYTES),
))
}
/// SpriteContents: the `.mcmeta` `texture` section (TextureMetadataSection's
/// mipmap strategy and alpha cutoff bias).
fn texture_section(
@@ -452,6 +607,54 @@ pub(crate) fn sprite_mipmap_limit(requested: u32, size: u32, minimum: u32, avail
mod tests {
use super::*;
#[test]
fn the_texture_budget_picks_the_largest_side_that_fits() {
// 1300 layers: vanilla's count, about.
assert_eq!(side_within(512, 1300, DEFAULT_TEXTURE_BUDGET), 128);
assert_eq!(side_within(512, 1300, 1536 << 20), 512);
assert_eq!(side_within(64, 1300, 1536 << 20), 64);
// Never under vanilla's size, whatever the budget.
assert_eq!(side_within(512, 1300, 1), SPRITE_SIZE);
}
#[test]
fn a_labpbr_map_shrinks_by_plain_means_and_a_layer_without_one_gets_the_default() {
// Alpha is data here: it does not weigh the other channels.
let frame = [
[200, 100, 0, 255],
[0, 0, 0, 0],
[200, 100, 0, 255],
[0, 0, 0, 0],
]
.concat();
assert_eq!(shrink_map(frame, 2, 1), (1, vec![100, 50, 0, 128]));
let mut sprites = Sprites {
native: vec![
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0),
],
pbr: vec![
[Some((16, [9, 8, 7, 6].repeat(16 * 16))), None],
[None, None],
],
..Default::default()
};
sprites.finish();
assert_eq!(sprites.pbr_size, 16);
assert_eq!(sprites.normals[..4], [9, 8, 7, 6]);
assert_eq!(sprites.normals[16 * 16 * 4..][..4], FLAT_NORMAL);
assert_eq!(sprites.specular[..4], NO_SPECULAR);
assert_eq!(sprites.specular.len(), 2 * 16 * 16 * 4);
}
#[test]
fn textures_without_labpbr_maps_keep_none() {
let mut sprites = Sprites {
native: vec![(SPRITE_SIZE, Arc::new(vec![vec![0; 16 * 16 * 4]]), 0)],
pbr: vec![[None, None]],
..Default::default()
};
sprites.finish();
assert!(sprites.normals.is_empty() && sprites.specular.is_empty());
}
#[test]
fn mipmap_option_keeps_real_16px_reductions() {
for levels in 0..=4 {
assert_eq!(sprite_mipmap_limit(levels, 32, 16, 6), levels + 1);
+70 -22
View File
@@ -215,6 +215,8 @@ enum Job {
/// Proto chunks checked out to be saved (`ChunkGenerator::save_protos`),
/// off the main thread like vanilla's IOWorker.
Save(Vec<((i32, i32), Proto)>),
/// A proto chunk checked out for its light view (`light_region`).
LightView(i32, i32, Proto),
/// Meshes the renderer is done with (`Streaming::free`): their memory
/// is given back here, not in the frame.
Free(Vec<((i32, i32, i32), SectionMesh)>),
@@ -235,6 +237,8 @@ pub enum Done {
Lit(i32, i32, Vec<crate::chunk::Light>, u64),
Meshed(i32, i32, i32, SectionMesh, u64),
Saved(Vec<((i32, i32), Proto)>),
/// A proto chunk back from `Job::LightView`, with its view.
LightView(i32, i32, Proto, Option<Chunk>),
}
struct Queued {
priority: i64,
@@ -279,6 +283,8 @@ struct Shared {
/// Workers that have freed their buffers ([`IDLE_RELEASE`]) and taken no
/// job since: the last of them gives the freed memory back to the OS.
released: AtomicUsize,
/// The workers that started.
workers: AtomicUsize,
}
#[derive(Default)]
struct MeshPool {
@@ -352,7 +358,6 @@ pub struct Streaming {
/// Columns generated and saved as they were left behind: new when they
/// next arrive, though read from the save (`World::loaded_chunks`).
dropped_new: FxHashSet<(i32, i32)>,
light_view: Arc<dyn Fn(&Proto) -> Option<Chunk> + Send + Sync>,
non_air_sections: Arc<dyn Fn(&Proto) -> Option<SectionMask> + Send + Sync>,
threads: Vec<thread::JoinHandle<()>>,
order: u64,
@@ -377,6 +382,8 @@ pub struct Streaming {
decorating_finals: HashSet<(i32, i32)>,
/// Save jobs in flight.
saving: usize,
/// Proto chunks checked out to a worker for their light view.
viewing: FxHashSet<(i32, i32)>,
/// Vanilla's decoration order around the player, and the centre and
/// radius it was last ranked for.
decoration_order: DecorationOrder,
@@ -618,8 +625,8 @@ impl Streaming {
done: Mailbox::default(),
meshes: Mutex::new(MeshPool::default()),
released: AtomicUsize::new(0),
workers: AtomicUsize::new(0),
});
let workers = threads.max(1);
let light_tables = context.light.clone();
let saves: Arc<dyn Saves> = generator.clone();
let threads = (0..threads.max(1))
@@ -673,7 +680,7 @@ impl Streaming {
// runs short: once every worker has freed its own,
// hand them back.
if shared.released.fetch_add(1, Ordering::Relaxed) + 1
== workers
== shared.workers.load(Ordering::Relaxed)
{
// No worker needs retained mesh capacity now.
// Drop it outside the pool lock before asking
@@ -732,6 +739,10 @@ impl Streaming {
generator.save_protos(&refs);
Done::Saved(protos)
}
Job::LightView(x, z, proto) => {
let view = generator.light_view(&proto);
Done::LightView(x, z, proto, view)
}
Job::Free(meshes) => {
for (_, mesh) in meshes {
let rejected = shared.meshes.lock().unwrap().put(mesh);
@@ -780,20 +791,22 @@ impl Streaming {
shared.done.send(done);
}
})
.expect("spawn worker")
})
.collect();
let light_view = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.light_view(p))
};
// A thread the system refuses (a container's process limit reached) leaves the pool a
// worker short: a server that panicked here went down with its players' moves unsaved.
.filter_map(|spawned| {
spawned
.inspect_err(|e| log::error!("Horizon: chunk worker not started: {e}"))
.ok()
})
.collect::<Vec<_>>();
shared.workers.store(threads.len(), Ordering::Relaxed);
let non_air_sections = {
let generator = generator.clone();
Arc::new(move |p: &Proto| generator.non_air_sections(p))
};
Self {
shared,
light_view,
non_air_sections,
threads,
order: 0,
@@ -808,6 +821,7 @@ impl Streaming {
decorating: 0,
decorating_finals: HashSet::new(),
saving: 0,
viewing: Default::default(),
decoration_order: DecorationOrder::default(),
order_area: None,
queue_basis: Default::default(),
@@ -1092,28 +1106,42 @@ impl Streaming {
/// The 3x3 a light job reads: the finished columns, and for a neighbour
/// that will not finish (past the area), its decorated proto chunk, as
/// vanilla lights its last FULL ring against neighbours at FEATURES.
/// A proto neighbour without its view yet is checked out to a worker,
/// which builds the view off the server thread (`Job::LightView`); the
/// region is asked for again once it is back.
fn light_region(&mut self, world: &World, x: i32, z: i32) -> Option<Box<Region>> {
let mut region: Region = Default::default();
let mut complete = true;
for dz in 0..3 {
for dx in 0..3 {
let n = (x + dx as i32 - 1, z + dz as i32 - 1);
region[dz][dx] = Some(match world.chunks.get(&n) {
Some(chunk) => chunk.clone(),
region[dz][dx] = match world.chunks.get(&n) {
Some(chunk) => Some(chunk.clone()),
None if !self.in_range(n.0, n.1, 1) && self.decorated.contains(&n) => {
if let Some(view) = self.light_views.get(&n) {
view.clone()
Some(view.clone())
} else {
let proto = self.protos.get(&n)?.as_ref()?;
let view = Arc::new((self.light_view)(proto)?);
self.light_views.insert(n, view.clone());
view
let proto = self.protos.get_mut(&n)?.take()?;
self.viewing.insert(n);
self.submit(Some((x, z)), 0, Job::LightView(n.0, n.1, proto));
complete = false;
None
}
}
None => return None,
});
};
}
}
Some(Box::new(region))
complete.then(|| Box::new(region))
}
/// A proto chunk back from its light view: the view waits in
/// `light_views` for the light jobs that asked for it.
fn receive_light_view(&mut self, x: i32, z: i32, proto: Proto, view: Option<Chunk>) {
self.viewing.remove(&(x, z));
self.protos.insert((x, z), Some(proto));
if let Some(view) = view {
self.light_views.insert((x, z), Arc::new(view));
}
}
/// Whether `light_region` would find all nine columns: the same walk
/// without building the snapshot, so the scheduler can rank the ready
@@ -1201,6 +1229,7 @@ impl Streaming {
pub fn busy(&self) -> bool {
!(self.generating.is_empty()
&& self.decorating == 0
&& self.viewing.is_empty()
&& self.finishing.is_empty()
&& self.lighting.is_empty()
&& self.meshing.is_empty()
@@ -1264,6 +1293,7 @@ impl Streaming {
}
Done::Generated(chunk) => self.receive_generated(chunk, world),
Done::Saved(protos) => self.receive_saved(protos),
Done::LightView(x, z, proto, view) => self.receive_light_view(x, z, proto, view),
Done::Lit(x, z, sections, seq) => {
if self.lighting.get(&(x, z)) != Some(&seq) {
continue;
@@ -1822,7 +1852,11 @@ impl Streaming {
return;
}
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
while self.decorating > 0
|| !self.finishing.is_empty()
|| self.saving > 0
|| !self.viewing.is_empty()
{
let left = deadline.saturating_duration_since(std::time::Instant::now());
match self.shared.done.recv_timeout(left) {
Some(Done::Decorated(x, z, region, finals, writes)) => {
@@ -1830,6 +1864,9 @@ impl Streaming {
}
Some(Done::Generated(chunk)) => self.receive_generated(chunk, world),
Some(Done::Saved(protos)) => self.receive_saved(protos),
Some(Done::LightView(x, z, proto, view)) => {
self.receive_light_view(x, z, proto, view)
}
Some(other) => self.deferred.push(other),
None => {
log::warn!("Horizon: chunk generation did not finish before saving");
@@ -1860,13 +1897,18 @@ impl Streaming {
}
Done::Generated(chunk) => self.receive_generated(chunk, world),
Done::Saved(protos) => self.receive_saved(protos),
Done::LightView(x, z, proto, view) => self.receive_light_view(x, z, proto, view),
other => self.deferred.push(other),
}
if started.elapsed() >= std::time::Duration::from_millis(2) {
return false;
}
}
if self.decorating > 0 || !self.finishing.is_empty() || self.saving > 0 {
if self.decorating > 0
|| !self.finishing.is_empty()
|| self.saving > 0
|| !self.viewing.is_empty()
{
return false;
}
let written: Vec<_> = self.written.drain().collect();
@@ -1940,7 +1982,11 @@ impl Streaming {
counts[1] += 1;
} else {
counts[2] += 1;
if self.light_region(world, key.0, key.1).is_some() {
if self.light_region(world, key.0, key.1).is_some()
|| (-1..=1).any(|dz| {
(-1..=1).any(|dx| self.viewing.contains(&(key.0 + dx, key.1 + dz)))
})
{
world.dirty_light.insert(key);
}
}
@@ -2325,6 +2371,7 @@ mod tests {
mesh.solid.resize(64, bytemuck::Zeroable::zeroed());
mesh.translucent.resize(16, bytemuck::Zeroable::zeroed());
mesh.block_entities.push(([1, 2, 3], 7));
mesh.lights.push(([1, 2, 3], 7));
mesh.visibility = 0;
mesh.sides = crate::mesher::Sides::unsorted(16);
let capacities = mesh.capacity_bytes();
@@ -2341,6 +2388,7 @@ mod tests {
assert!(reused.solid.is_empty());
assert!(reused.translucent.is_empty());
assert!(reused.block_entities.is_empty());
assert!(reused.lights.is_empty());
assert_eq!(reused.visibility, crate::mesher::ALL_VISIBLE);
assert_eq!(reused.sides, crate::mesher::Sides::default());
assert_eq!(pool.bytes, 0);
@@ -98,6 +98,25 @@ thread_local! {
static TARGETS: RefCell<(Vec<Target>, Vec<Target>, u64)> = const { RefCell::new((Vec::new(), Vec::new(), 0)) };
}
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
/// runs on this thread ([`take`] and [`put`] around each turn).
#[derive(Default)]
pub struct Saved(
(Vec<Widget>, Vec<Widget>, u64),
(Vec<Target>, Vec<Target>, u64),
);
/// This thread's buttons and targets, leaving none.
pub fn take() -> Saved {
Saved(WIDGETS.take(), TARGETS.take())
}
/// Bring back what [`take`] kept.
pub fn put(saved: Saved) {
WIDGETS.set(saved.0);
TARGETS.set(saved.1);
}
/// An interactive rectangle (GUI pixels) a controller can focus and click.
pub fn target(rect: [f32; 4]) {
target_as(rect, Kind::Button);
+7
View File
@@ -83,6 +83,13 @@ open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
# `above_menu_bar`); all already in the tree through winit and wgpu.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
# DirectX 12 driver (`graphicsApi`, graphics.rs).
@@ -34,6 +34,8 @@
"horizon.pages.extras": "Horizon Extras",
"horizon.pages.settings": "Horizon Settings",
"horizon.pages.agent": "AI Agent",
"horizon.pages.split_settings": "Splitscreen Settings",
"horizon.pages.split_setup": "Splitscreen",
"horizon.pages.split_profiles": "Who's playing?",
"horizon.pages.settings.tooltip": "Horizon's own settings: graphics, controllers, extras and mods.",
"horizon.pages.graphics.tooltip": "Graphics, simply: each setting says what it costs.",
@@ -41,6 +43,7 @@
"horizon.pages.controller.tooltip": "Controllers: their buttons, sticks, vibration and menu navigation.",
"horizon.pages.extras.tooltip": "Optional additions, all off by default: the game stays vanilla until you turn one on.",
"horizon.pages.agent.tooltip": "Let your own AI agent play: horizon-ctl and MCP clients on this computer.",
"horizon.pages.split_setup.tooltip": "Players on this computer: controllers can join your world.",
"horizon.pages.split_profiles.tooltip": "Who plays: a profile kept in this game folder.",
"horizon.pages.shader_packs.tooltip": "Shader packs from the shaderpacks folder: Horizon's own and those in the shaders/ format. One runs at a time.",
"horizon.pages.mods.tooltip": "Mods made for Horizon, from the horizon-mods folder. Each runs in a sandbox and stays off until you turn it on.",
@@ -48,6 +51,7 @@
"horizon.pages.open.graphics_advanced": "Advanced Graphics...",
"horizon.pages.open.shader_packs": "Shader Packs...",
"horizon.pages.open.controller": "Controller Settings...",
"horizon.pages.open.split_setup": "Splitscreen...",
"horizon.pages.open.extras": "Extras...",
"horizon.pages.open.mods": "Mods...",
"horizon.pages.open.agent": "AI Agent...",
@@ -205,5 +209,9 @@
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
"horizon.loading.opening_world": "Opening world",
"horizon.loading.cancelling_world": "Cancelling world"
"horizon.loading.cancelling_world": "Cancelling world",
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay..."
}
@@ -199,5 +199,9 @@
"Frame rate and frame times over the game, with a graph.": "Fotogramas por segundo y tiempos de fotograma sobre el juego, con un gráfico.",
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
"horizon.loading.opening_world": "Abriendo el mundo",
"horizon.loading.cancelling_world": "Cancelando"
"horizon.loading.cancelling_world": "Cancelando",
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…"
}
@@ -14,5 +14,9 @@
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa",
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
"horizon.loading.opening_world": "Otwieranie świata",
"horizon.loading.cancelling_world": "Anulowanie"
"horizon.loading.cancelling_world": "Anulowanie",
"horizon.multiplayer.not_signed_in": "Ten serwer działa w trybie online. Zaloguj się kontem Microsoft w launcherze Horizon, aby dołączyć.",
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…"
}
@@ -198,5 +198,9 @@
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
"horizon.loading.opening_world": "Открытие мира",
"horizon.loading.cancelling_world": "Отмена"
"horizon.loading.cancelling_world": "Отмена",
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…"
}
@@ -14,5 +14,9 @@
"horizon.options.on_screen_keyboard": "Екранна клавіатура",
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
"horizon.loading.opening_world": "Відкриття світу",
"horizon.loading.cancelling_world": "Скасування"
"horizon.loading.cancelling_world": "Скасування",
"horizon.multiplayer.not_signed_in": "Цей сервер працює в онлайн-режимі. Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб приєднатися.",
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…"
}
@@ -199,5 +199,9 @@
"Frame rate and frame times over the game, with a graph.": "游戏运行时的帧率与帧时间,附带图表。",
"horizon.multiplayer.finding_friend": "正在查找好友的世界…",
"horizon.loading.opening_world": "正在打开世界",
"horizon.loading.cancelling_world": "正在取消"
"horizon.loading.cancelling_world": "正在取消",
"horizon.multiplayer.not_signed_in": "该服务器为在线模式服务器。请先在 Horizon 启动器中登录 Microsoft 账户再加入。",
"horizon.multiplayer.invalid_address": "无效的服务器地址:%s",
"horizon.multiplayer.connected_friend_direct": "已直连好友的世界…",
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…"
}
+9
View File
@@ -5,6 +5,15 @@ fn main() {
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
println!("cargo:rerun-if-changed={ico}");
// The game's main thread gets 8 MB of stack on Windows as on macOS and Linux (Windows gives 1
// MB): splitscreen builds a joining player's `Game`, ~67 KB, deep in the event loop.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let arg = match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() {
Ok("msvc") => "/STACK:8388608",
_ => "-Wl,--stack,8388608",
};
println!("cargo:rustc-link-arg-bin=horizon-client={arg}");
}
#[cfg(windows)]
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let mut resource = winresource::WindowsResource::new();
@@ -133,7 +133,7 @@ fn main() -> Result<()> {
.collect();
let descriptor = BlockState {
name: name.clone(),
properties,
properties: properties.into(),
};
let Some(id) = registry.id(&descriptor) else {
states.insert(key.clone(), Value::Null);
@@ -291,6 +291,8 @@ pub fn main() -> horizon_client::Result<()> {
for d in items {
let _ = match d {
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
// The fixture's mobs keep no whole stacks.
drops::Drop::Stack(s) => writeln!(out, "drop {} {}", s.item, s.count),
drops::Drop::CarriedBlock(state) => {
let stacks = enderman::carried_block_drops(
&block_items,
@@ -1,5 +1,6 @@
use horizon_client::interaction_input::{
Button, InteractionInput, InteractionWorld, TargetUseWorld, use_on_target,
Button, InteractionInput, InteractionWorld, TargetUseWorld, release_on_screen_open,
use_on_target,
};
use serde_json::Value;
use std::io::{BufRead, Write};
@@ -103,14 +104,23 @@ pub fn main() -> Result<(), Box<dyn std::error::Error>> {
passing: row["passing"].as_bool().unwrap_or(false),
};
let mut input = InteractionInput::default();
for button in row["clicks"].as_array().unwrap() {
input.press(match button.as_u64().unwrap() {
0 => Button::Attack,
1 => Button::Use,
_ => Button::Pick,
});
let screen = row["screen"].as_bool().unwrap_or(false);
if screen {
// A keybind opened a screen: Gui.setScreen already ran
// KeyMapping.releaseAll, so the same handleKeybinds call releases an
// active use; with the screen open nothing else acts this tick.
release_on_screen_open(&mut world);
input.tick(&mut world, false, false);
} else {
for button in row["clicks"].as_array().unwrap() {
input.press(match button.as_u64().unwrap() {
0 => Button::Attack,
1 => Button::Use,
_ => Button::Pick,
});
}
input.tick(&mut world, flag("attack_down"), flag("use_down"));
}
input.tick(&mut world, flag("attack_down"), flag("use_down"));
writeln!(out, "== {i}")?;
for event in world.events {
writeln!(out, "{event}")?;
@@ -179,7 +179,7 @@ fn state(registry: &BlockRegistry, text: &str) -> BlockStateId {
registry
.resolve(&BlockState {
name: name.to_owned(),
properties,
properties: properties.into(),
})
.unwrap_or_else(|e| panic!("{text}: {e}"))
}
@@ -1315,7 +1315,7 @@ pub fn main() -> horizon_client::Result<()> {
continue;
}
if let Some(Some((name, volume, pitch))) =
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random)
horizon_client::mobs::ai::feed_animal(&mut mobs[i], &mut level_random, None)
{
events.push(MobEvent::Sound(name, [0.0; 3], volume, pitch));
}
@@ -119,7 +119,7 @@ fn parse_state(spec: &str) -> BlockState {
.collect();
BlockState {
name: name.to_owned(),
properties,
properties: properties.into(),
}
}
@@ -47,7 +47,8 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
))?;
let parse_ms = parse_started.elapsed().as_secs_f64() * 1000.0;
let facts_started = Instant::now();
let (facts, gameplay) = BlockFacts::load_with_projection(root, assets.registry, &json)?;
let (facts, gameplay) =
BlockFacts::load_with_projection(root, assets.registry, &json, true)?;
let facts_ms = facts_started.elapsed().as_secs_f64() * 1000.0;
let behaviours_started = Instant::now();
let behaviours = Behaviours::load_with_gameplay(assets.registry, root, &json, &gameplay)?;
+9
View File
@@ -1071,6 +1071,15 @@ impl Ticker<'_, '_, '_, '_> {
}
effects.advance(step);
let name = &*self.scene.facts.get(state).name;
crate::block_contact::emit(crate::block_contact::Event::Inside(
crate::block_contact::Contact {
entity: self.p.id as i32,
kind: self.p.kind.id(),
block: name,
state,
position: p,
},
));
if matches!(name, "minecraft:fire" | "minecraft:soul_fire") {
// BaseFireBlock.entityInside (its inFire hurt does nothing to arrows).
effects.apply(Effect::ClearFreeze);
+1 -3
View File
@@ -92,9 +92,7 @@ impl Banners {
});
}
banners.no_item_required = read_tag(root, "no_item_required", 0);
if let Some(raw) = fs::read(root.join("items.json")).ok().and_then(|b| {
serde_json::from_slice::<std::collections::BTreeMap<String, Value>>(&b).ok()
}) {
if let Ok(raw) = horizon_world::item_defaults::read(root) {
for (item, c) in raw {
let id: Arc<str> = Arc::from(item.as_str());
if let Some(dye) = c["minecraft:dye"].as_str() {
@@ -194,7 +194,8 @@ pub const COMMANDS: &[Cmd] = &[
&[flag("world", Ty::Str, "world folder to open or create"), flag("seed", Ty::Str, "seed for a new world"), flag("creative", Ty::Bool, "creative mode for a new world"),
flag("version", Ty::Str, "an installed launcher version tag such as v2026.10.13"), flag("game", Ty::Str, "path to a horizon-client binary"),
flag("wait", Ty::Bool, "also wait until the world is ready (default in MCP)"),
flag("offscreen", Ty::Bool, "render without showing a window (screenshots still work): locked or headless sessions")],
flag("offscreen", Ty::Bool, "render without showing a window (screenshots still work): locked or headless sessions"),
flag("headless", Ty::Bool, "no display, window or GPU: a player that plays through these commands only (no screenshots)")],
mcp: true, local: true),
cmd!("wait-for", Session, "wait-for screen NAME|widget ID|loaded|chat TEXT|event KIND [--timeout S] [--since SEQ]", "wait until the game reaches a state, then reply",
"screen takes the names state shows (playing, inventory, paused, dead, title...); widget an id from ui; loaded means a world is open, the player is in it and no chunk is pending; chat a new line containing TEXT; event a kind from events (death, respawn, item, screen...). chat and event count from --since SEQ (take last from events before acting), else from now. A timeout (default 30 s) is an error with kind timeout.",
@@ -6,7 +6,19 @@ use crate::{Session, commands};
use serde_json::{Value, json};
use std::io::{BufRead, Write};
const PROTOCOL: &str = "2025-06-18";
/// Revisions this server implements. Older ones are not claimed: only the revision actually
/// implemented is advertised (no JSON-RPC batches, no other capability than `tools`).
const SUPPORTED: &[&str] = &["2025-06-18"];
/// MCP lifecycle: answer with the client's version if supported, otherwise one we support.
fn negotiate_version(params: &Value) -> &'static str {
let wanted = params.get("protocolVersion").and_then(Value::as_str);
SUPPORTED
.iter()
.copied()
.find(|v| Some(*v) == wanted)
.unwrap_or(SUPPORTED[0])
}
fn tools() -> Vec<Value> {
commands::COMMANDS
@@ -134,7 +146,7 @@ impl Session {
fn handle(&mut self, method: &str, params: &Value) -> Result<Value, (i64, String)> {
match method {
"initialize" => Ok(json!({
"protocolVersion": params.get("protocolVersion").and_then(Value::as_str).unwrap_or(PROTOCOL),
"protocolVersion": negotiate_version(params),
"capabilities": { "tools": {} },
"serverInfo": { "name": "horizon-ctl", "version": horizon_core::release::VERSION },
"instructions": "Play Horizon, a Rust reimplementation of Minecraft Java Edition. Start with launch (it finds the game installed by the Horizon launcher and opens or creates a world), then read the world with state, inventory, screen, entities and find_block, and act with goto, look_at_block, break, place, interact, click_slot and click_button. Actions block until they finish and report what happened. Coordinates are block coordinates; yaw 0 faces south (+Z).",
@@ -194,7 +206,17 @@ pub fn serve(session: &mut Session) -> Result<(), String> {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn version_negotiation() {
let v = |p: Value| negotiate_version(&p);
assert_eq!(v(json!({ "protocolVersion": "2025-06-18" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "2026-07-28" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": "1999-01-01" })), "2025-06-18");
assert_eq!(v(json!({ "protocolVersion": 7 })), "2025-06-18");
assert_eq!(v(json!({})), "2025-06-18");
}
#[test]
fn base64_matches_rfc_4648() {
@@ -234,9 +256,9 @@ mod tests {
fn protocol_basics() {
let mut s = Session::new(std::env::temp_dir().join("horizon-ctl-mcp-test.sock"), None);
let init = s
.handle("initialize", &json!({ "protocolVersion": "2025-03-26" }))
.handle("initialize", &json!({ "protocolVersion": "2025-06-18" }))
.unwrap();
assert_eq!(init["protocolVersion"], "2025-03-26");
assert_eq!(init["protocolVersion"], "2025-06-18");
assert!(s.handle("nope", &json!({})).is_err());
let r = s
.handle(
+201
View File
@@ -0,0 +1,201 @@
//! Synchronous mod-block contacts from the server's simulation code.
use horizon_core::position::BlockPos;
use horizon_world::registry::BlockStateId;
use std::{
cell::Cell,
sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
},
};
#[derive(Clone, Copy, Debug)]
pub struct Contact<'a> {
pub entity: i32,
pub kind: &'a str,
pub block: &'a str,
pub state: BlockStateId,
pub position: BlockPos,
}
#[derive(Clone, Copy, Debug)]
pub enum Event<'a> {
StepOn(Contact<'a>),
Inside(Contact<'a>),
FallOn(Contact<'a>, f64),
}
pub static HOOK: OnceLock<fn(&str, Event<'_>, &mut f32)> = OnceLock::new();
/// Whether the loaded blocks hold one outside `minecraft:` (a content mod's).
/// Without one no contact can dispatch, so the item and projectile sweeps
/// skip their per-tick work: every item and thrown thing on a server would
/// otherwise sweep its blocks each tick for nothing.
static MOD_BLOCKS: AtomicBool = AtomicBool::new(false);
/// Called when the block tables load.
pub fn set_mod_blocks(any: bool) {
MOD_BLOCKS.store(any, Ordering::Relaxed);
}
thread_local! {
static LEVEL: Cell<Option<&'static str>> = const { Cell::new(None) };
}
/// Scope authority to this thread and restore it even on an error or panic.
/// Nested guest actions may temporarily enter a different dimension.
pub fn in_level<R>(dimension: Option<&'static str>, f: impl FnOnce() -> R) -> R {
struct Restore(Option<&'static str>);
impl Drop for Restore {
fn drop(&mut self) {
LEVEL.set(self.0);
}
}
let _restore = Restore(LEVEL.replace(dimension));
f()
}
pub fn emit(event: Event<'_>) {
dispatch(event, &mut 0.0);
}
/// Damage is the calculated fall damage, before hurt handling and fall sounds.
pub fn fall(contact: Contact<'_>, distance: f64, mut damage: f32) -> f32 {
dispatch(Event::FallOn(contact, distance), &mut damage);
damage
}
fn dispatch(event: Event<'_>, damage: &mut f32) {
let contact = match event {
Event::StepOn(c) | Event::Inside(c) | Event::FallOn(c, _) => c,
};
if contact.block.starts_with("minecraft:") {
return;
}
if let Some(dimension) = LEVEL.get()
&& let Some(hook) = HOOK.get()
{
let original = *damage;
hook(dimension, event, damage);
if !damage.is_finite() {
*damage = original;
}
*damage = damage.max(0.0);
}
}
/// Reuse the entity block sweep for item and projectile ticks without an effect collector.
pub fn swept_inside<'a>(
entity: i32,
kind: &str,
from: horizon_core::geometry::Vec3,
to: horizon_core::geometry::Vec3,
bounds: horizon_core::geometry::Aabb,
mut block: impl FnMut(BlockPos) -> (BlockStateId, &'a str),
) {
if !MOD_BLOCKS.load(Ordering::Relaxed) || LEVEL.get().is_none() || HOOK.get().is_none() {
return;
}
let bounds = bounds.deflate(horizon_core::geometry::Vec3::new(1e-5, 1e-5, 1e-5));
let mut visited = std::collections::HashSet::new();
let _ =
horizon_core::geometry::sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, step| {
if step >= 16 {
return false;
}
if visited.insert(p) {
let (state, name) = block(p);
emit(Event::Inside(Contact {
entity,
kind,
block: name,
state,
position: p,
}));
}
true
});
}
#[cfg(test)]
mod tests {
use super::*;
thread_local! {
static SEEN: Cell<usize> = const { Cell::new(0) };
}
fn hook(dimension: &str, event: Event<'_>, damage: &mut f32) {
assert_eq!(dimension, "minecraft:the_nether");
let c = match event {
Event::FallOn(c, distance) => {
assert_eq!(distance, 12.0);
*damage = match *damage {
99.0 => f32::NAN,
98.0 => -1.0,
_ => *damage * 0.2,
};
c
}
Event::Inside(c) | Event::StepOn(c) => c,
};
assert_eq!(
(c.entity, c.kind, c.state, c.position),
(
17,
"minecraft:zombie",
BlockStateId(9),
BlockPos::new(1, 2, 3)
)
);
SEEN.set(SEEN.get() + 1);
}
#[test]
fn contacts_require_server_scope_and_mod_blocks_and_restore_nested_levels() {
HOOK.set(hook).unwrap();
let c = Contact {
entity: 17,
kind: "minecraft:zombie",
block: "test:hay",
state: BlockStateId(9),
position: BlockPos::new(1, 2, 3),
};
assert_eq!(fall(c, 12.0, 10.0), 10.0);
in_level(Some("minecraft:the_nether"), || {
emit(Event::StepOn(c));
emit(Event::Inside(c));
assert_eq!(fall(c, 12.0, 10.0), 2.0);
in_level(None, || assert_eq!(fall(c, 12.0, 10.0), 10.0));
assert_eq!(fall(c, 12.0, 0.0), 0.0);
emit(Event::Inside(Contact {
block: "minecraft:hay_block",
..c
}));
});
assert_eq!(fall(c, 12.0, 10.0), 10.0);
assert_eq!(SEEN.get(), 4);
in_level(Some("minecraft:the_nether"), || {
assert_eq!(fall(c, 12.0, 99.0), 99.0);
assert_eq!(fall(c, 12.0, 98.0), 0.0);
use horizon_core::geometry::{Aabb, Vec3};
let to = Vec3::new(2.5, 2.0, 3.5);
let bounds = Aabb::new(Vec3::new(2.25, 2.0, 3.25), Vec3::new(2.75, 2.5, 3.75));
let block = |p| {
(
BlockStateId(9),
if p == c.position {
"test:hay"
} else {
"minecraft:air"
},
)
};
set_mod_blocks(true);
swept_inside(17, c.kind, Vec3::new(0.5, 2.0, 3.5), to, bounds, block);
assert_eq!(SEEN.get(), 7, "a block crossed mid-move is notified once");
let at = Vec3::new(1.5, 2.0, 3.5);
let bounds = bounds.move_by(Vec3::new(-1.0, 0.0, 0.0));
swept_inside(17, c.kind, at, at, bounds, block);
assert_eq!(SEEN.get(), 8, "stationary overlap still notifies");
});
let _ =
std::panic::catch_unwind(|| in_level(Some("minecraft:the_nether"), || panic!("test")));
assert_eq!(fall(c, 12.0, 10.0), 10.0);
}
}
+20 -13
View File
@@ -283,6 +283,25 @@ fn custom_only(saved: &Tag) -> Compound {
}
}
/// `BlockEntity.collectComponents`: explicit components plus the type's implicit components.
/// Unlike Ctrl + Pick Block, this does not carry the remaining block entity NBT.
pub fn collected_components(saved: &Tag) -> Compound {
let id = block_entity_nbt::id(saved).unwrap_or_default();
let mut collected = match saved.get("components") {
Some(Tag::Compound(c)) => c.clone(),
_ => Compound::new(),
};
for part in parts(&id) {
if let Some((component, value)) = collect(*part, saved) {
match value {
Some(v) => collected.insert(component.into(), v),
None => collected.remove(&JavaString::from(component)),
};
}
}
collected
}
/// `addBlockDataToItem`: the component patch Ctrl + Pick Block gives the clone
/// item of the block entity saved as `saved` (a full, canonical compound).
pub fn pick(saved: &Tag) -> Compound {
@@ -310,19 +329,7 @@ pub fn pick(saved: &Tag) -> Compound {
// ItemStack.applyComponents(collectComponents()): the block entity's own
// components, then the implicit ones; a value equal to the item's own is
// no change.
let mut collected = match saved.get("components") {
Some(Tag::Compound(c)) => c.clone(),
_ => Compound::new(),
};
for part in parts(&id) {
if let Some((component, value)) = collect(*part, saved) {
match value {
Some(v) => collected.insert(component.into(), v),
None => collected.remove(&JavaString::from(component)),
};
}
}
for (component, value) in collected {
for (component, value) in collected_components(saved) {
if item_default(&component.to_string_lossy()).as_ref() == Some(&value) {
patch.remove(&component);
} else {
+40 -9
View File
@@ -1,6 +1,7 @@
//! Identity of the ordered block-state registry used by chunk packets, and of
//! the item and mob effect registries once content mods add to them (item
//! stacks and effect packets carry their network ids).
//! the item, mob effect, entity type and menu registries once content mods
//! add to them (item stacks, effect, entity and open screen packets carry
//! their network ids).
use horizon_protocol::registry::Builtin;
use serde::{Deserialize, Serialize, de::IgnoredAny};
use sha2::{Digest, Sha256};
@@ -77,14 +78,13 @@ pub fn identity(root: &Path) -> Result<[u8; 32], IdentityError> {
if !components.is_empty() {
hash = hash_entries(hash, "horizon:nbt_components/v1", &components);
// Defaults are not sent in a stack patch. Both peers must therefore
// agree on their values as well as the registered wire IDs.
let bytes = std::fs::read(root.join("items.json")).map_err(IdentityError::Read)?;
let items: BTreeMap<String, BTreeMap<String, serde_json::Value>> =
serde_json::from_slice(&bytes).map_err(IdentityError::Json)?;
// agree on their values as well as the registered wire IDs, native
// mods' changes included.
let items = horizon_world::item_defaults::read(root).map_err(IdentityError::Read)?;
let mut defaults = Vec::new();
for (item, components) in items {
for (name, value) in components {
if horizon_protocol::item::custom::id(&name).is_some() {
for (name, value) in components.as_object().into_iter().flatten() {
if horizon_protocol::item::custom::id(name).is_some() {
defaults.push((defaults.len() as u64, format!("{item}\0{name}\0{value}")));
}
}
@@ -109,7 +109,12 @@ fn hash_facts(bytes: &[u8]) -> Result<[u8; 32], IdentityError> {
}
/// The built-in registries whose network ids content mods extend besides blocks.
const REGISTRIES: [&str; 2] = ["minecraft:item", "minecraft:mob_effect"];
const REGISTRIES: [&str; 4] = [
"minecraft:item",
"minecraft:mob_effect",
"minecraft:entity_type",
"minecraft:menu",
];
fn parse_registries(bytes: &[u8]) -> Result<Builtin, IdentityError> {
let text = std::str::from_utf8(bytes).map_err(|e| IdentityError::Registries(e.to_string()))?;
@@ -177,6 +182,32 @@ mod tests {
);
}
/// a mod menu type in the report changes the identity, so a client
/// without it is refused before it could be sent an id it lacks.
#[test]
fn a_mod_menu_type_joins_the_identity() {
let root = |name: &str, extra: &str| {
let root = std::env::temp_dir().join(format!(
"horizon-content-identity-menu-{name}-{}",
std::process::id()
));
std::fs::create_dir_all(&root).unwrap();
let facts = br#"{"states":[["minecraft:stone",false,false,"Block{minecraft:stone}",{"id":"minecraft:stone"}]]}"#;
std::fs::write(root.join("facts.json"), facts).unwrap();
let report = format!(
r#"{{"minecraft:menu":{{"entries":{{"minecraft:generic_9x1":{{"protocol_id":0}}{extra}}}}}}}"#
);
std::fs::write(root.join("registries.json"), report).unwrap();
let hash = identity(&root).unwrap();
std::fs::remove_dir_all(&root).unwrap();
hash
};
let vanilla = root("vanilla", "");
let press = root("press", r#","ruby:press":{"protocol_id":25}"#);
assert_ne!(press, vanilla);
assert_ne!(press, root("mill", r#","ruby:mill":{"protocol_id":25}"#));
}
#[test]
fn a_root_without_mod_entries_keeps_its_block_identity() {
let root = std::env::temp_dir().join(format!(
+235
View File
@@ -44,10 +44,73 @@ impl Ingredient {
match v {
Value::String(s) => add(s),
Value::Array(a) => a.iter().filter_map(Value::as_str).for_each(&mut add),
Value::Object(o) => return Self::parse_object(o, items),
_ => return None,
}
(!list.is_empty()).then(|| Self::of(list))
}
/// NeoForge's ingredient types (`NeoForgeRegistries.Keys.INGREDIENT_TYPES`),
/// the standard shapes mods' recipes carry, each resolved to the flat item
/// list it matches. A kind outside these still fails the file: a mod's
/// own ingredient types come with their own hook, not the loader.
fn parse_object(o: &serde_json::Map<String, Value>, items: &Items) -> Option<Self> {
// Current NeoForge marks a custom ingredient with
// `neoforge:ingredient_type`; `type` is the older packs' key.
let kind = o
.get("neoforge:ingredient_type")
.or_else(|| o.get("type"))?
.as_str()?;
let children = || {
o.get("children")
.or_else(|| o.get("ingredients"))
.and_then(Value::as_array)
};
let kept = match kind {
// `CompoundIngredient`: any of its children matching the stack
// is enough — the union of their items.
"neoforge:compound" => {
let subs: Vec<Self> = children()?
.iter()
.map(|s| Self::parse(s, items))
.collect::<Option<_>>()?;
let mut seen = Vec::new();
for sub in &subs {
for item in &sub.1 {
if !seen.contains(item) {
seen.push(item.clone());
}
}
}
seen
}
// `IntersectionIngredient`: every child must match the same
// stack — the items all of them name.
"neoforge:intersection" => {
let subs: Vec<Self> = children()?
.iter()
.map(|s| Self::parse(s, items))
.collect::<Option<_>>()?;
subs.first()?
.1
.iter()
.filter(|i| subs[1..].iter().all(|s| s.0.contains(*i)))
.cloned()
.collect()
}
// The base's items without the subtracted's.
"neoforge:difference" => {
let base = Self::parse(o.get("base")?, items)?;
let gone = Self::parse(o.get("subtracted")?, items)?;
base.1
.iter()
.filter(|i| !gone.0.contains(*i))
.cloned()
.collect()
}
_ => return None,
};
(!kept.is_empty()).then(|| Self::of(kept))
}
pub fn test(&self, stack: &ItemStack) -> bool {
self.0.contains(&*stack.item)
}
@@ -1252,6 +1315,18 @@ impl Recipes {
let mut meta = HashMap::new();
// Vanilla's recipes by bare name, then content mods' as `<ns>:<name>`.
let mut sources: Vec<(String, Vec<u8>)> = Vec::new();
// NeoForge's load conditions: `mod_loaded` asks which mods' data is
// in this root (`minecraft`, always, and the content mods').
// `mod_loaded` asks which mods are loaded, not which namespaces
// they own: a mod that also owns a namespace doesn't make that
// namespace another mod.
let mods: Vec<String> = {
let mut mods = horizon_items::content_namespaces::mod_ids(root);
if !mods.iter().any(|m| m == "minecraft") {
mods.push("minecraft".to_owned());
}
mods
};
for (name, p) in recipe_files(&root.join("data/minecraft/recipe"))? {
sources.push((name, fs::read(&p)?));
}
@@ -1291,6 +1366,13 @@ impl Recipes {
continue;
}
};
// A file whose load conditions don't hold is silently absent,
// as NeoForge's ICondition leaves it out of the manager.
if !horizon_world::conditions::conditions_hold(&v, &|id: &str| {
mods.iter().any(|m| m == id)
}) {
continue;
}
if let Some(kind) = custom_type(&v) {
let id = if name.contains(':') {
name
@@ -1759,6 +1841,99 @@ mod tests {
}
}
/// A mod's recipe whose ingredient is one of NeoForge's standard shapes
/// loads and carries the resolved items, where the plain string parser
/// used to drop the file whole.
#[test]
fn a_neoforge_ingredient_recipe_loads() {
let root =
std::env::temp_dir().join(format!("horizon-neo-ingredient-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
fs::create_dir_all(root.join("data/mymod/recipe")).unwrap();
fs::write(
root.join(horizon_items::content_namespaces::MARKER),
br#"["mymod"]"#,
)
.unwrap();
let recipe = r##"{
"type": "minecraft:crafting_shapeless",
"ingredients": [
{"neoforge:ingredient_type": "neoforge:compound",
"children": ["minecraft:oak_planks", "minecraft:birch_planks"]},
{"type": "neoforge:difference",
"base": ["minecraft:oak_planks", "minecraft:birch_planks"],
"subtracted": "minecraft:birch_planks"}
],
"result": {"id": "minecraft:stick", "count": 4}
}"##;
fs::write(root.join("data/mymod/recipe/treated.json"), recipe).unwrap();
let items = Items::for_tests(HashMap::new());
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
assert_eq!(recipes.crafting.len(), 1);
assert_eq!(recipes.crafting[0].0, "mymod:treated");
let _ = fs::remove_dir_all(&root);
}
/// NeoForge's standard ingredient shapes resolve to their flat item
/// lists; a kind the loader doesn't know still fails the file.
#[test]
fn neoforge_ingredient_shapes_parse() {
let mut tags = HashMap::new();
tags.insert(
"minecraft:planks".to_owned(),
["minecraft:oak_planks", "minecraft:birch_planks"]
.iter()
.map(|s| s.to_string())
.collect::<HashSet<_>>(),
);
let items = Items::for_tests(tags);
let json = |s: &str| serde_json::from_str::<Value>(s).unwrap();
// Compound (`CompoundIngredient`): any child matching is enough —
// the union of their items, a tag beside an item gives both.
let union = Ingredient::parse(
&json(r##"{"neoforge:ingredient_type":"neoforge:compound","children":["#minecraft:planks","minecraft:stone"]}"##),
&items,
)
.unwrap();
// The tag's items come from a HashSet: their order is not fixed.
let mut all = union.1.clone();
all.sort();
assert_eq!(
all,
[
"minecraft:birch_planks",
"minecraft:oak_planks",
"minecraft:stone"
]
);
// Intersection: every child must match — only the items all name.
let both = Ingredient::parse(
&json(r##"{"type":"neoforge:intersection","children":["#minecraft:planks","minecraft:oak_planks"]}"##),
&items,
)
.unwrap();
assert_eq!(both.1, ["minecraft:oak_planks"]);
// Difference: the base without the subtracted.
let no_oak = Ingredient::parse(
&json(r##"{"type":"neoforge:difference","base":"#minecraft:planks","subtracted":"minecraft:oak_planks"}"##),
&items,
)
.unwrap();
assert_eq!(no_oak.1, ["minecraft:birch_planks"]);
// An empty intersection and an unknown kind fail the file, as today.
let empty = Ingredient::parse(
&json(
r#"{"type":"neoforge:intersection","children":["minecraft:oak_planks","minecraft:birch_planks"]}"#,
),
&items,
);
assert!(empty.is_none());
let unknown =
Ingredient::parse(&json(r#"{"type":"mymod:aspect","aspect":"fire"}"#), &items);
assert!(unknown.is_none());
}
fn ingredient(items: &[&str]) -> Ingredient {
Ingredient::of(items.iter().map(|s| (*s).to_owned()).collect())
}
@@ -1928,6 +2103,66 @@ mod tests {
assert_eq!((&*made.item, made.count), ("minecraft:stick", 1));
let _ = fs::remove_dir_all(&root);
}
/// A recipe whose `neoforge:conditions` hold loads, one whose don't is
/// silently absent, from the root's own files and a pack's alike.
#[test]
fn load_conditions_gate_recipes() {
let root = std::env::temp_dir().join(format!("horizon-conditions-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
fs::create_dir_all(root.join("data/minecraft/recipe")).unwrap();
fs::write(
root.join(horizon_items::content_namespaces::MARKER),
br#"["mymod"]"#,
)
.unwrap();
fs::write(
root.join(horizon_items::content_namespaces::MOD_IDS_MARKER),
br#"["mymod"]"#,
)
.unwrap();
let file = |conditions: &str, tag: &str| {
format!(
r#"{{"type":"mymod:crusher","tag":"{tag}","neoforge:conditions":{conditions}}}"#
)
.into_bytes()
};
// `mymod` is in the root, `ic2` is not.
fs::write(
root.join("data/minecraft/recipe/yes.json"),
file(r#"[{"type":"neoforge:mod_loaded","modid":"mymod"}]"#, "yes"),
)
.unwrap();
fs::write(
root.join("data/minecraft/recipe/no.json"),
file(r#"[{"type":"neoforge:mod_loaded","modid":"ic2"}]"#, "no"),
)
.unwrap();
fs::write(
root.join("data/minecraft/recipe/inverted.json"),
file(
r#"[{"type":"neoforge:not","value":{"type":"neoforge:mod_loaded","modid":"ic2"}}]"#,
"inverted",
),
)
.unwrap();
let items = Items::for_tests(HashMap::new());
let recipes = Recipes::load(&root, &items, &PackOverlay::NONE).unwrap();
let ids = &recipes.custom["mymod:crusher"];
assert_eq!(
ids.iter().map(|r| r.id.as_str()).collect::<Vec<_>>(),
["minecraft:inverted", "minecraft:yes"]
);
// A pack's gated file answers the same gate.
let packs = [(
"recipe/minecraft:pack_yes".to_owned(),
vec![file(r#"[{"type":"neoforge:true"}]"#, "pack_yes")],
)];
let recipes = Recipes::load(&root, &items, &PackOverlay { files: &packs }).unwrap();
let ids = &recipes.custom["mymod:crusher"];
assert!(ids.iter().any(|r| r.id == "minecraft:pack_yes"));
let _ = fs::remove_dir_all(&root);
}
#[test]
fn cook_finds_the_first_recipe_the_scan_finds() {
let recipes = cooking_book();
+131
View File
@@ -0,0 +1,131 @@
//! `DebugLevelSource`: the `minecraft:debug` generator of the Debug world preset,
//! a grid of every block state floating over a layer of barriers.
use crate::{
chunk::{Chunk, LevelShape},
streaming::ChunkGenerator,
};
use horizon_world::{nbt::Tag, registry::BlockRegistry};
/// `DebugLevelSource.HEIGHT`: the layer the block states stand in.
pub const HEIGHT: i32 = 70;
/// `DebugLevelSource.BARRIER_HEIGHT`.
pub const BARRIER_HEIGHT: i32 = 60;
/// The debug world's generator: every block state in registry order
/// (`ALL_BLOCKS`) on a square grid, one every other block, at `HEIGHT`.
pub struct DebugTerrain {
shape: LevelShape,
biome: u16,
barrier: u32,
/// `ALL_BLOCKS.size()`: block states are numbered in that order, so the
/// state at an index is the state with that id.
states: u32,
}
impl DebugTerrain {
/// The generator of a world whose overworld is `minecraft:debug`.
pub fn from_settings(
settings: &Tag,
registry: &BlockRegistry,
biomes: &crate::biome::Biomes,
shape: LevelShape,
) -> Option<Self> {
let generator = settings
.get("dimensions")?
.get("minecraft:overworld")?
.get("generator")?;
if crate::save::text(generator.get("type"))?.trim_start_matches("minecraft:") != "debug" {
return None;
}
Some(Self {
shape,
// `FixedBiomeSource(plains)`.
biome: biomes.id("minecraft:plains"),
barrier: registry.default_state("minecraft:barrier")?.0,
states: u32::try_from(registry.len()).ok()?,
})
}
/// `DebugLevelSource.getBlockStateFor`: the state id at a block column
/// (air, id 0, where the grid has none).
pub fn state_for(&self, x: i32, z: i32) -> u32 {
grid_state(self.states, x, z)
}
}
/// `DebugLevelSource.GRID_WIDTH` and `GRID_HEIGHT` for `count` block states:
/// `Mth.ceil(Mth.sqrt(size))` and `ceil(size / width)`.
fn grid(count: u32) -> (i32, i32) {
let width = (count as f32).sqrt().ceil() as i32;
let height = (count as f32 / width as f32).ceil() as i32;
(width, height)
}
/// `getBlockStateFor(worldX, worldZ)` as a state id.
fn grid_state(count: u32, mut x: i32, mut z: i32) -> u32 {
let (width, height) = grid(count);
if x > 0 && z > 0 && x % 2 != 0 && z % 2 != 0 {
x /= 2;
z /= 2;
if x <= width && z <= height {
let index = (x * width + z).unsigned_abs();
if index < count {
return index;
}
}
}
0
}
impl ChunkGenerator for DebugTerrain {
type Worker = ();
fn worker(&self) {}
/// `DebugLevelSource.buildTerrain` builds nothing; `applyBiomeDecoration`
/// writes the barriers and the grid.
fn proto(&self, _: &mut (), cx: i32, cz: i32) -> crate::streaming::Proto {
let mut chunk = Chunk::with_layout(cx, cz, self.shape.column());
for s in &mut chunk.sections {
std::sync::Arc::make_mut(s).biomes = horizon_core::paletted::PalettedContainer::new(
crate::chunk::BIOME_STRATEGY,
u32::from(self.biome),
);
}
for z in 0..16 {
for x in 0..16 {
chunk.set(x, BARRIER_HEIGHT, z, self.barrier);
let state = self.state_for(cx * 16 + x, cz * 16 + z);
if state != 0 {
chunk.set(x, HEIGHT, z, state);
}
}
}
for s in &mut chunk.sections {
std::sync::Arc::make_mut(s).compact();
}
Box::new(chunk)
}
/// `spawnOriginalMobs` is empty.
fn finish(&self, proto: crate::streaming::Proto) -> Chunk {
*proto.downcast::<Chunk>().expect("debug terrain proto")
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `getBlockStateFor`: only odd, positive columns hold a state, in the order
/// `x / 2 * width + z / 2`, and nothing past the last state.
#[test]
fn the_grid_holds_a_state_every_other_block() {
// 1000 states: a 32 wide grid, 32 high (ceil(1000 / 32) = 32).
assert_eq!(grid(1000), (32, 32));
assert_eq!(grid(1), (1, 1));
assert_eq!(grid(26_000), (162, 161));
// Even, zero and negative columns are air.
for (x, z) in [(2, 1), (1, 2), (0, 1), (1, 0), (-1, 1), (1, -1)] {
assert_eq!(grid_state(1000, x, z), 0, "{x} {z}");
}
// (1, 1) is index 0 (air), (3, 5) is 1 * 32 + 2 = 34.
assert_eq!(grid_state(1000, 1, 1), 0);
assert_eq!(grid_state(1000, 3, 5), 34);
// x = 2 * 32 + 1 maps to x / 2 = 32: index 32 * 32 + z / 2, past the 1000 states.
assert_eq!(grid_state(1000, 65, 1), 0);
assert_eq!(grid_state(1000, 61, 3), 30 * 32 + 1);
}
}
+267 -53
View File
@@ -182,47 +182,14 @@ impl ItemEntity {
/// up) without a full collision cube, at `speed` (`nextFloat() * 0.2 +
/// 0.1`).
pub fn move_towards_closest_space(&mut self, world: &World, facts: &BlockFacts, speed: f32) {
let bounds = self.bounds();
let at = Vec3::new(
self.position.x,
(bounds.min_position().y + bounds.max_position().y) / 2.0,
self.position.z,
self.velocity = towards_closest_space(
self.position,
self.bounds(),
self.velocity,
world,
facts,
speed,
);
let pos = [
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
];
let delta = [
at.x - f64::from(pos[0]),
at.y - f64::from(pos[1]),
at.z - f64::from(pos[2]),
];
// (axis, step): NORTH, SOUTH, WEST, EAST, UP.
let mut closest = (1, 1);
let mut nearest = f64::MAX;
for (axis, step) in [(2, -1), (2, 1), (0, -1), (0, 1), (1, 1)] {
let mut n = pos;
n[axis] += step;
if !facts.get(world.block(n[0], n[1], n[2])).full_collision {
let d = if step > 0 {
1.0 - delta[axis]
} else {
delta[axis]
};
if d < nearest {
nearest = d;
closest = (axis, step);
}
}
}
let push = f64::from(closest.1 as f32 * speed);
let v = self.velocity.scale(0.75);
self.velocity = match closest.0 {
0 => Vec3::new(push, v.y, v.z),
1 => Vec3::new(v.x, push, v.z),
_ => Vec3::new(v.x, v.y, push),
};
}
/// Returns false when the item should be removed. `no_physics`: the item
/// moves through blocks this tick ([`ItemEntity::inside_block`]).
@@ -339,6 +306,37 @@ impl ItemEntity {
&mut self.velocity,
);
}
if !no_physics {
// Entity.applyEffectsFromBlocks: stepOn, then checkInsideBlocks.
if self.on_ground {
let p = BlockPos::containing(self.position.add_vector(Vec3::new(
0.0,
-f64::from(0.2_f32),
0.0,
)));
let state = world.block(p.x, p.y, p.z);
crate::block_contact::emit(crate::block_contact::Event::StepOn(
crate::block_contact::Contact {
entity: self.id as i32,
kind: "minecraft:item",
block: &facts.get(state).name,
state,
position: p,
},
));
}
crate::block_contact::swept_inside(
self.id as i32,
"minecraft:item",
self.previous,
self.position,
self.bounds(),
|p| {
let state = world.block(p.x, p.y, p.z);
(state, &facts.get(state).name)
},
);
}
if self.velocity.subtract(old_velocity).length_squared() > 0.01 {
self.needs_sync = true;
}
@@ -638,6 +636,7 @@ impl ExperienceOrb {
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
player: Option<[f64; 3]>,
server: bool,
next_float: &mut dyn FnMut() -> f32,
) -> bool {
self.ticks += 1;
@@ -647,13 +646,22 @@ impl ExperienceOrb {
self.position.y.floor() as i32,
self.position.z.floor() as i32,
);
let bounds = self.bounds();
let mut scene = SceneCollisions {
world,
binding: collision,
subject: None,
};
// `boolean colliding = !level.noCollision(getBoundingBox())`: an orb
// inside blocks takes no gravity until it is pushed out below.
let colliding = !crate::collisions::no_collision_empty(&mut scene, bounds);
if facts.get(block).fluid == Fluid::Water {
self.velocity = Vec3::new(
self.velocity.x * 0.99,
(self.velocity.y + 5.0e-4).min(0.06),
self.velocity.z * 0.99,
);
} else {
} else if !colliding {
self.velocity.y -= 0.03;
}
if facts.get(block).fluid == Fluid::Lava {
@@ -681,12 +689,18 @@ impl ExperienceOrb {
);
}
}
let bounds = self.bounds();
let mut scene = SceneCollisions {
world,
binding: collision,
subject: None,
};
if player.is_none() && server && colliding {
// `followingPlayer == null && !level().isClientSide() && colliding`: an orb that would still
// be stuck at its next position gets
// `Entity.moveTowardsClosestSpace`, a nudge towards the nearest
// free side of its block.
if !crate::collisions::no_collision_empty(&mut scene, bounds.move_by(self.velocity)) {
let speed = next_float() * 0.2 + 0.1;
self.move_towards_closest_space(world, facts, speed);
}
}
// `double fallSpeed = getDeltaMovement().y`, taken after the stuck
// push and before the move.
let wanted = self.velocity;
let moved = scene
.resolve(bounds, wanted, 0.0, self.on_ground)
@@ -697,26 +711,114 @@ impl ExperienceOrb {
self.position.z + moved.z,
);
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
let mut friction = 0.98;
// Entity.move's restituteMovementAfterCollisions, an orb's bounciness
// 0: each collided axis's velocity is zeroed — x and z by Mth.equal's
// 1.0E-5, y exactly.
if (wanted.x - moved.x).abs() >= 1.0e-5 {
self.velocity.x = 0.0;
}
if (wanted.z - moved.z).abs() >= 1.0e-5 {
self.velocity.z = 0.0;
}
if moved.y != wanted.y {
self.velocity.y = 0.0;
}
// `float friction = getAirDrag()` (0.98F), the ground's block
// friction multiplied in as a float, widened once for the scale.
let mut friction = 0.98_f32;
if self.on_ground {
let below = world.block(
self.position.x.floor() as i32,
(self.position.y - 1.0).floor() as i32,
(self.position.y - 0.999_999).floor() as i32,
self.position.z.floor() as i32,
);
friction = f64::from(facts.get(below).friction) * 0.98;
friction *= facts.get(below).friction;
}
let friction = f64::from(friction);
// The drag scales every axis by the same friction, the ground's
// block friction included.
self.velocity = Vec3::new(
self.velocity.x * friction,
self.velocity.y * 0.98,
self.velocity.y * friction,
self.velocity.z * friction,
);
if self.on_ground {
self.velocity.y *= -0.9;
// `if (verticalCollisionBelow && fallSpeed < -getGravity())`: the
// landing bounce is 0.4 of the pre-move speed, not a fixed
// restitution.
if self.on_ground && wanted.y < -0.03 {
self.velocity.y = -wanted.y * 0.4;
}
self.age += 1;
self.age < 6000
}
/// `Entity.moveTowardsClosestSpace` from the middle of the orb's box:
/// towards the nearest side of its block (north, south, west, east or
/// up) without a full collision cube, at `speed` (the orb's own
/// random, `nextFloat() * 0.2 + 0.1`).
fn move_towards_closest_space(&mut self, world: &World, facts: &BlockFacts, speed: f32) {
self.velocity = towards_closest_space(
self.position,
self.bounds(),
self.velocity,
world,
facts,
speed,
);
}
}
/// `Entity.moveTowardsClosestSpace` from the middle of an entity's box
/// (`bounds`, its feet at `position`): the velocity pushed towards the
/// nearest side of its block (north, south, west, east or up) without a full
/// collision cube, at `speed` (`nextFloat() * 0.2 + 0.1`). Items and
/// experience orbs share it.
fn towards_closest_space(
position: Vec3,
bounds: Aabb,
velocity: Vec3,
world: &World,
facts: &BlockFacts,
speed: f32,
) -> Vec3 {
let at = Vec3::new(
position.x,
(bounds.min_position().y + bounds.max_position().y) / 2.0,
position.z,
);
let pos = [
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
];
let delta = [
at.x - f64::from(pos[0]),
at.y - f64::from(pos[1]),
at.z - f64::from(pos[2]),
];
// (axis, step): NORTH, SOUTH, WEST, EAST, UP.
let mut closest = (1, 1);
let mut nearest = f64::MAX;
for (axis, step) in [(2, -1), (2, 1), (0, -1), (0, 1), (1, 1)] {
let mut n = pos;
n[axis] += step;
if !facts.get(world.block(n[0], n[1], n[2])).full_collision {
let d = if step > 0 {
1.0 - delta[axis]
} else {
delta[axis]
};
if d < nearest {
nearest = d;
closest = (axis, step);
}
}
}
let push = f64::from(closest.1 as f32 * speed);
let v = velocity.scale(0.75);
match closest.0 {
0 => Vec3::new(push, v.y, v.z),
1 => Vec3::new(v.x, push, v.z),
_ => Vec3::new(v.x, v.y, push),
}
}
/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`:
/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box,
@@ -947,9 +1049,15 @@ pub struct ArmorStand {
pub health: f32,
pub id: u32,
pub position: Vec3,
/// `Entity.xo`, `yo`, `zo`: the position at the start of the tick, which
/// the renderer blends toward `position` by the partial tick.
pub previous: Vec3,
pub velocity: Vec3,
pub on_ground: bool,
/// `yRot`, which the body follows (`ArmorStand.tickHeadTurn`: `yBodyRot`).
pub yaw: f32,
/// `yBodyRotO`: the body's yaw at the start of the tick.
pub previous_yaw: f32,
/// mainhand, offhand, feet, legs, chest, head (EquipmentSlot order).
pub equipment: [Option<crate::items::ItemStack>; 6],
pub small: bool,
@@ -970,6 +1078,9 @@ pub struct ArmorStand {
/// rides, which seats it every tick (`positionRider`), as NPC plugins hang
/// a second name over a player's head.
pub vehicle: Option<u32>,
/// `ArmorStand.disabledSlots`: bit `filterFlag` locks a slot, bit
/// `filterFlag + 8` takes from it and bit `filterFlag + 16` puts into it.
pub disabled_slots: i32,
}
impl ArmorStand {
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
@@ -985,15 +1096,50 @@ impl ArmorStand {
[-1.0, 0.0, -1.0],
[1.0, 0.0, 1.0],
];
/// Where the renderer draws the stand `alpha` of the way through the tick
/// (`EntityRenderer`'s `Mth.lerp(partialTick, xo, x)` and so on).
pub fn render_position(&self, alpha: f64) -> Vec3 {
self.previous.lerp(self.position, alpha)
}
/// The body's yaw `alpha` of the way through the tick
/// (`LivingEntityRenderer.solveBodyRot`: `Mth.rotLerp(partialTick,
/// yBodyRotO, yBodyRot)`).
pub fn render_yaw(&self, alpha: f32) -> f32 {
self.previous_yaw + alpha * crate::camera::wrap_degrees(self.yaw - self.previous_yaw)
}
/// A server's stand on its client, each tick:
/// `setOldPosAndRot` (`ClientLevel.tickNonPassenger`), then the step of
/// its `SteppedInterpolationHandler` (`step`: the position and yaw it is
/// put at, when one is due). The yaw
/// turns the short way and stays continuous, so a frame between ticks
/// never spins it.
pub fn interpolate(&mut self, step: Option<(Vec3, f32)>) {
self.previous = self.position;
self.previous_yaw = self.yaw;
if let Some((at, yaw)) = step {
self.position = at;
self.yaw += crate::camera::wrap_degrees(yaw - self.yaw);
}
}
/// Put there at once, with nothing to blend from
/// (`Entity.snapTo`).
pub fn snap(&mut self, at: Vec3, yaw: f32) {
self.position = at;
self.previous = at;
self.yaw = yaw;
self.previous_yaw = yaw;
}
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
Self {
fire_ticks: 0,
health: 20.0,
id,
position,
previous: position,
velocity: Vec3::ZERO,
on_ground: true,
yaw,
previous_yaw: yaw,
equipment: Default::default(),
small: false,
show_arms: false,
@@ -1005,8 +1151,29 @@ impl ArmorStand {
last_hit: i64::MIN / 2,
pose: Self::DEFAULT_POSE,
vehicle: None,
disabled_slots: 0,
}
}
/// `EquipmentSlot.getFilterFlag` of an `equipment` index (main hand, off
/// hand, feet, legs, chest, head).
fn filter_flag(slot: usize) -> u32 {
[0, 5, 1, 2, 3, 4][slot]
}
/// `ArmorStand.isDisabled`: the slot is locked, or it is a hand and the
/// stand has no arms.
pub fn is_disabled(&self, slot: usize) -> bool {
self.disabled_slots & (1 << Self::filter_flag(slot)) != 0 || (slot < 2 && !self.show_arms)
}
/// The `swapItem` locks: taking from a filled slot or putting into an
/// empty one can each be disabled.
pub fn swap_locked(&self, slot: usize) -> bool {
let offset = if self.equipment[slot].is_some() {
8
} else {
16
};
self.disabled_slots & (1 << (Self::filter_flag(slot) + offset)) != 0
}
pub fn environment_tick(
&mut self,
world: &World,
@@ -1296,6 +1463,53 @@ mod fire_environment_tests {
}
}
#[cfg(test)]
mod armor_stand_render_tests {
use super::*;
/// A falling stand is drawn between last tick's position and this one's,
/// so it slides down every frame instead of jumping once a tick (#4416).
#[test]
fn a_falling_stand_is_drawn_between_its_ticks() {
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 70.0, 0.5), 0.0);
// A new stand has no move behind it.
assert_eq!(stand.render_position(0.5), stand.position);
let fall = |s: &ArmorStand| Some((Vec3::new(0.5, s.position.y - 1.0, 0.5), 0.0));
stand.interpolate(fall(&stand));
assert_eq!(stand.render_position(0.0).y, 70.0);
assert_eq!(stand.render_position(0.25).y, 69.75);
assert_eq!(stand.render_position(1.0).y, 69.0);
stand.interpolate(fall(&stand));
assert_eq!(stand.render_position(0.5).y, 68.5);
// Landed: no step is due, so it stays put, and so does every frame.
stand.interpolate(None);
assert_eq!(stand.render_position(0.5).y, 68.0);
}
/// A turning stand's body turns between ticks too, the short way round.
#[test]
fn a_turning_stand_is_drawn_between_its_ticks() {
let mut stand = ArmorStand::new(1, Vec3::ZERO, 170.0);
assert_eq!(stand.render_yaw(0.5), 170.0);
// To -170 is 20 degrees on through 180, not 340 back through 0.
stand.interpolate(Some((Vec3::ZERO, -170.0)));
assert_eq!(stand.render_yaw(0.0), 170.0);
assert_eq!(stand.render_yaw(0.5), 180.0);
assert_eq!(stand.render_yaw(1.0), 190.0);
stand.interpolate(None);
assert_eq!(stand.render_yaw(0.5), 190.0);
}
/// A snap (a move too far to interpolate) leaves nothing to blend from.
#[test]
fn a_snapped_stand_is_drawn_where_it_landed() {
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 70.0, 0.5), 0.0);
stand.interpolate(Some((Vec3::new(0.5, 69.0, 0.5), 0.0)));
stand.snap(Vec3::new(200.5, 64.0, 0.5), 90.0);
assert_eq!(stand.render_position(0.5), Vec3::new(200.5, 64.0, 0.5));
assert_eq!(stand.render_yaw(0.5), 90.0);
}
}
#[cfg(test)]
mod merge_tests {
use super::*;
+342 -14
View File
@@ -511,6 +511,10 @@ pub fn mob_to_nbt(
// Animal.addAdditionalSaveData: the love ticks left, whatever the save had.
if is_animal(id) {
map.insert("InLove".into(), Tag::Int(m.extra.in_love.max(0)));
// Animal.addAdditionalSaveData: the player who fed it.
if let Some(cause) = m.extra.love_cause {
map.insert("LoveCause".into(), Tag::IntArray(cause.to_vec()));
}
}
let species = m.species();
// MushroomCow saves its variant under `Type` (below), not `variant`.
@@ -538,7 +542,15 @@ pub fn mob_to_nbt(
"minecraft:fox" => {
let kind = if m.extra.variant == 1 { "snow" } else { "red" };
map.insert("Type".into(), string(kind));
map.entry("Trusted".into()).or_insert(Tag::List(Vec::new()));
// The trusted UUIDs (`Fox.TRUSTED_LIST_CODEC`: a list of int arrays).
map.insert(
"Trusted".into(),
Tag::List(
crate::mobs::fox::trusted_entities(m)
.map(|uuid| Tag::IntArray(uuid.to_vec()))
.collect(),
),
);
for (key, bit) in [
("Sleeping", crate::mobs::fox::SLEEPING),
("Sitting", crate::mobs::fox::SITTING),
@@ -575,7 +587,10 @@ pub fn mob_to_nbt(
// Rabbit.addAdditionalSaveData (the legacy id; 99 is the killer bunny).
"minecraft:rabbit" => {
map.insert("RabbitType".into(), Tag::Int(i32::from(m.extra.variant)));
map.entry("MoreCarrotTicks".into()).or_insert(Tag::Int(0));
map.insert(
"MoreCarrotTicks".into(),
Tag::Int(m.extra.more_carrot_ticks),
);
}
"minecraft:goat" => {
map.insert(
@@ -1141,6 +1156,25 @@ pub fn mob_to_nbt(
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
// A worn piece or held item that kept its damage, name or
// components is saved with them.
if slot != "mainhand"
&& item.is_some()
&& let Some(stack) = crate::mobs::finalize::stored_stack(
m,
[
crate::mobs::finalize::MAINHAND,
crate::mobs::finalize::OFFHAND,
crate::mobs::finalize::HEAD,
crate::mobs::finalize::CHEST,
crate::mobs::finalize::LEGS,
crate::mobs::finalize::FEET,
][index],
)
{
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
// Enchanted or trimmed gear is saved with its components.
if let (Some(id), Some(g)) = (item, crate::mobs::gear::gear(m))
&& (!g.slots[index].is_empty() || g.trims[index].is_some())
@@ -1405,6 +1439,10 @@ pub fn mob_from_nbt(
crate::mobs::gear::set(&mut m, index, stack.enchantments().to_vec());
let trim = stack.data().and_then(|d| d.trim.clone());
crate::mobs::gear::set_trim(&mut m, index, trim);
// The damage, name and components stay with the slot.
if index != crate::mobs::gear::MAIN {
crate::mobs::gear::set_stack(&mut m, index, stack);
}
}
}
} else {
@@ -1463,6 +1501,8 @@ pub fn mob_from_nbt(
}
if is_animal(&m.kind.id) {
m.extra.in_love = number(tag.get("InLove")).unwrap_or(0) as i32;
// Animal.readAdditionalSaveData: the player who fed it into love.
m.extra.love_cause = crate::save::uuid_from_tag(tag.get("LoveCause"));
}
m.name_visible = flag(tag, "CustomNameVisible");
m.no_ai = flag(tag, "NoAI");
@@ -1592,6 +1632,17 @@ pub fn mob_from_nbt(
match m.species() {
crate::mobs::Species::Fox => {
m.extra.variant = u8::from(text(tag.get("Type")).as_deref() == Some("snow"));
// Fox.readAdditionalSaveData: clearTrusted, then addTrustedEntity for
// each of "Trusted".
crate::mobs::fox::clear_trusted(&mut m);
if let Some(Tag::List(trusted)) = tag.get("Trusted") {
for uuid in trusted
.iter()
.filter_map(|t| crate::save::uuid_from_tag(Some(t)))
{
crate::mobs::fox::add_trusted_entity(&mut m, uuid);
}
}
// Fox.readAdditionalSaveData: the sleeping, sitting and crouching flags.
for (key, bit) in [
("Sleeping", crate::mobs::fox::SLEEPING),
@@ -1633,6 +1684,10 @@ pub fn mob_from_nbt(
}
_ => {}
}
// `Rabbit.readAdditionalSaveData`.
if m.species() == crate::mobs::Species::Rabbit {
m.extra.more_carrot_ticks = number(tag.get("MoreCarrotTicks")).unwrap_or(0) as i32;
}
// `Ocelot.readAdditionalSaveData`.
if m.species() == crate::mobs::Species::Ocelot {
m.extra.trusting = flag(tag, "Trusting");
@@ -2155,6 +2210,7 @@ pub fn arrow_from_nbt(
p.velocity = motion(tag);
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.owner_uuid = owner;
let a = p.arrow.as_mut()?;
a.life = number(tag.get("life")).unwrap_or(0) as i16 as i32;
a.shake = (number(tag.get("shake")).unwrap_or(0) as i8 as u8).into();
@@ -2198,26 +2254,121 @@ pub fn fireball_to_nbt(
"acceleration_power",
Tag::Double(p.acceleration.to_bits()),
);
// Projectile: LeftOwner only when set; HasBeenShot once it has ticked
// (a fireball saved before its first tick keeps what it was loaded with).
if let Tag::Compound(map) = &mut tag {
map.remove(&"LeftOwner".into());
}
if p.left_owner {
put(&mut tag, "LeftOwner", Tag::Byte(1));
}
if p.age > 0 || tag.get("HasBeenShot").is_none() {
put(&mut tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
}
put_projectile(&mut tag, p, owner);
if let Some(power) = power {
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
}
Some(tag)
}
/// `Projectile.addAdditionalSaveData`: Owner, LeftOwner only when set, and
/// HasBeenShot once it has ticked (one saved before its first tick keeps
/// what it was loaded with).
fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
if let Tag::Compound(map) = tag {
map.remove(&"LeftOwner".into());
}
if p.left_owner {
put(tag, "LeftOwner", Tag::Byte(1));
}
if p.age > 0 || tag.get("HasBeenShot").is_none() {
put(tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
}
if let Some(owner) = owner {
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
put(tag, "Owner", Tag::IntArray(owner.to_vec()));
}
}
/// The thrown item projectiles Horizon saves: each entity type's id is also
/// the item it saves when none was loaded (`getDefaultItem`).
const THROWABLES: [(ProjectileKind, &str); 3] = [
(ProjectileKind::Snowball, "minecraft:snowball"),
(ProjectileKind::Egg, "minecraft:egg"),
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
];
fn throwable_item(kind: &ProjectileKind) -> Option<&'static str> {
if let ProjectileKind::Thrown(t) = kind {
return Some(t.item);
}
THROWABLES
.iter()
.find(|(k, _)| k == kind)
.map(|&(_, id)| id)
}
/// The thrown kind an entity id names: vanilla's, or a content mod's type.
pub fn throwable_kind(id: &str) -> Option<ProjectileKind> {
match THROWABLES.iter().find(|(_, i)| *i == id) {
Some((kind, _)) => Some(kind.clone()),
None => crate::projectiles::thrown_type(id).map(ProjectileKind::Thrown),
}
}
/// Whether its chunk saves it: the kinds `arrow_to_nbt`, `fireball_to_nbt`
/// and [`throwable_to_nbt`] write.
pub fn saved_with_chunk(p: &Projectile) -> bool {
matches!(
p.kind,
ProjectileKind::LargeFireball { .. } | ProjectileKind::SmallFireball
) || (p.arrow.is_some() && matches!(p.kind, ProjectileKind::Arrow { .. }))
|| throwable_item(&p.kind).is_some()
}
/// `ThrowableItemProjectile.addAdditionalSaveData`: `Projectile`'s keys and
/// the `Item` it flies as.
pub fn throwable_to_nbt(
p: &Projectile,
uuid: [i32; 4],
owner: Option<[i32; 4]>,
raw: Option<&Tag>,
) -> Option<Tag> {
let item = throwable_item(&p.kind)?;
let base = Base {
id: p.kind.id(),
uuid,
position: p.position,
motion: p.velocity,
rotation: [p.yaw, p.pitch],
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
put_projectile(&mut tag, p, owner);
// Entity.saveWithoutId writes NoGravity only when set.
if p.no_gravity {
put(&mut tag, "NoGravity", Tag::Byte(1));
}
// the loaded item stays (a blue egg's, a renamed one's).
if tag.get("Item").is_none() {
put(
&mut tag,
"Item",
item_to_nbt(&crate::items::ItemStack::new(item, 1)),
);
}
Some(tag)
}
/// `ThrowableItemProjectile.readAdditionalSaveData` for the kinds
/// [`throwable_to_nbt`] writes; the owner is the player when it is theirs.
pub fn throwable_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
let kind = throwable_kind(&kind(tag)?)?;
let owner = owner_uuid(tag);
let from_player = player.is_some() && owner == player;
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
p.velocity = motion(tag);
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.owner_uuid = owner;
p.no_gravity = flag(tag, "NoGravity");
Some(p)
}
/// `Projectile`'s `Owner`.
pub fn owner_uuid(tag: &Tag) -> Option<[i32; 4]> {
uuid_from_tag(tag.get("Owner"))
}
pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
let kind = match kind(tag)?.as_str() {
"minecraft:fireball" => ProjectileKind::LargeFireball {
@@ -2234,6 +2385,7 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
p.velocity = motion(tag);
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.owner_uuid = owner;
p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1);
Some(p)
}
@@ -2498,6 +2650,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
} else {
put(&mut tag, "equipment", Tag::compound(equipment));
}
put(&mut tag, "DisabledSlots", Tag::Int(a.disabled_slots));
for (key, value) in [
("Small", a.small),
("ShowArms", a.show_arms),
@@ -2572,6 +2725,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
}
a.small = flag(tag, "Small");
a.show_arms = flag(tag, "ShowArms");
a.disabled_slots = number(tag.get("DisabledSlots")).unwrap_or(0) as i32;
a.no_base_plate = flag(tag, "NoBasePlate");
a.invisible = flag(tag, "Invisible");
a.marker = flag(tag, "Marker");
@@ -3519,6 +3673,34 @@ mod sheep_nbt_tests {
assert_eq!((back.extra.color, back.extra.sheared), (14, true));
}
/// `Rabbit.addAdditionalSaveData` and `readAdditionalSaveData`: `MoreCarrotTicks`.
#[test]
fn a_rabbit_keeps_how_full_of_carrots_it_is() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<String, Arc<MobType>> = data
.types
.iter()
.map(|(k, v)| (k.clone(), Arc::new(v.clone())))
.collect();
let mut m = Mob::new(
1,
Arc::new(data.types["minecraft:rabbit"].clone()),
Vec3::new(0.0, 4.0, 0.0),
0.0,
);
m.extra.more_carrot_ticks = 33;
let tag = mob_to_nbt(&m, [1, 2, 3, 4], None, None);
assert_eq!(number(tag.get("MoreCarrotTicks")), Some(33));
let back = mob_from_nbt(&tag, &types, 2, None).unwrap();
assert_eq!(back.extra.more_carrot_ticks, 33);
}
/// `Ocelot.addAdditionalSaveData` and `readAdditionalSaveData`: `Trusting`.
#[test]
fn an_ocelot_keeps_its_trust() {
@@ -4084,6 +4266,85 @@ mod fireball_nbt_tests {
assert_eq!(fireball_from_nbt(&tag, None).unwrap().kind, kind);
}
}
#[test]
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
let player = Some([5, 6, 7, 8]);
for (kind, id) in [
(ProjectileKind::Snowball, "minecraft:snowball"),
(ProjectileKind::Egg, "minecraft:egg"),
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
] {
let mut p =
Projectile::new(kind.clone(), Vec3::new(1.5, 70.0, -2.5), Some(Who::Player));
(p.velocity, p.left_owner, p.age) = (Vec3::new(0.3, -0.1, 1.2), true, 4);
[p.yaw, p.pitch] = [30.0, -10.0];
let tag = throwable_to_nbt(&p, [1, 2, 3, 4], player, None).unwrap();
assert_eq!(super::kind(&tag).as_deref(), Some(id));
// ThrowableItemProjectile: the item it flies as, by its default.
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!((&*item.item, item.count), (id, 1));
assert_eq!(number(tag.get("HasBeenShot")), Some(1));
assert_eq!(uuid_from_tag(tag.get("Owner")), player);
let back = throwable_from_nbt(&tag, player).unwrap();
assert_eq!((back.kind, back.owner), (kind, Some(Who::Player)));
assert_eq!((back.position, back.velocity), (p.position, p.velocity));
assert_eq!(
([back.yaw, back.pitch], back.left_owner),
([30.0, -10.0], true)
);
// an owner who is not here keeps their UUID through a moved resave.
let mut away = throwable_from_nbt(&tag, Some([9; 4])).unwrap();
assert_eq!((away.owner, away.owner_uuid), (None, player));
away.position.y += 3.0;
let resaved = throwable_to_nbt(&away, [1; 4], away.owner_uuid, None).unwrap();
assert_eq!(uuid_from_tag(resaved.get("Owner")), player);
}
// the chunk saves arrows, fireballs and these, and so takes them with it.
let kinds = [
(ProjectileKind::SmallFireball, true),
(ProjectileKind::Snowball, true),
(ProjectileKind::WindCharge, false),
(ProjectileKind::Trident, false),
];
for (kind, saved) in kinds {
let p = Projectile::new(kind, Vec3::ZERO, None);
assert_eq!(saved_with_chunk(&p), saved, "{:?}", p.kind);
}
// a loaded item stays, and a new projectile leaves LeftOwner out.
let raw = Tag::compound([
("id", Tag::String("minecraft:egg".into())),
("Pos", doubles(&[0.0, 64.0, 0.0])),
(
"Item",
item_to_nbt(&crate::items::ItemStack::new("minecraft:blue_egg", 1)),
),
]);
let egg = throwable_from_nbt(&raw, None).unwrap();
let tag = throwable_to_nbt(&egg, [1; 4], None, Some(&raw)).unwrap();
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!(&*item.item, "minecraft:blue_egg");
assert!(tag.get("LeftOwner").is_none() && tag.get("Owner").is_none());
assert_eq!(number(tag.get("HasBeenShot")), Some(0));
let wind = Projectile::new(ProjectileKind::WindCharge, Vec3::ZERO, None);
assert!(throwable_to_nbt(&wind, [1; 4], None, None).is_none());
// NoGravity is loaded and saved again (a summoned pearl that floats).
let floating = Tag::compound([
("id", Tag::String("minecraft:ender_pearl".into())),
("Pos", doubles(&[0.0, 64.0, 0.0])),
("NoGravity", Tag::Byte(1)),
]);
let pearl = throwable_from_nbt(&floating, None).unwrap();
assert!(pearl.no_gravity);
let tag = throwable_to_nbt(&pearl, [1; 4], None, None).unwrap();
assert_eq!(number(tag.get("NoGravity")), Some(1));
assert!(
throwable_to_nbt(&egg, [1; 4], None, None)
.unwrap()
.get("NoGravity")
.is_none()
);
}
}
#[cfg(test)]
@@ -4136,6 +4397,43 @@ mod neutral_anger_tests {
mod held_item_name_tests {
use super::*;
#[test]
fn a_worn_piece_keeps_its_damage_and_name_through_the_save() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<_, _> = data
.types
.into_iter()
.map(|(id, kind)| (id, Arc::new(kind)))
.collect();
let mut mob = Mob::new(1, Arc::clone(&types["minecraft:zombie"]), Vec3::ZERO, 0.0);
let mut helmet = crate::items::ItemStack::new("minecraft:iron_helmet", 1);
helmet.damage = 40;
helmet.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Hat".into()));
crate::mobs::finalize::set_item_stack(&mut mob, crate::mobs::finalize::HEAD, helmet);
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None);
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
let worn = crate::mobs::finalize::item_stack(&loaded, crate::mobs::finalize::HEAD).unwrap();
assert_eq!(worn.damage, 40);
assert_eq!(worn.custom_name().as_deref(), Some("Hat"));
// Replacing the item forgets the old stack.
let mut loaded = loaded;
crate::mobs::finalize::set_item(
&mut loaded,
crate::mobs::finalize::HEAD,
"minecraft:iron_helmet",
);
let fresh =
crate::mobs::finalize::item_stack(&loaded, crate::mobs::finalize::HEAD).unwrap();
assert_eq!(fresh.damage, 0);
assert!(fresh.custom_name().is_none());
}
#[test]
fn replacing_a_named_item_does_not_restore_its_old_name_on_save() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
@@ -4184,3 +4482,33 @@ mod held_item_name_tests {
);
}
}
#[cfg(test)]
mod mod_thrown_nbt_tests {
use super::*;
#[test]
fn a_mod_thrown_type_saves_its_id_and_item_and_loads_back() {
crate::projectiles::register_test_types();
let bolt = crate::projectiles::thrown_type("ruby:bolt").unwrap();
let mut p = Projectile::new(
ProjectileKind::Thrown(bolt),
Vec3::new(1.0, 80.0, 2.0),
None,
);
p.velocity = Vec3::new(0.0, 0.5, 1.0);
assert!(saved_with_chunk(&p));
let tag = throwable_to_nbt(&p, [1; 4], None, None).unwrap();
assert_eq!(super::kind(&tag).as_deref(), Some("ruby:bolt"));
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!(&*item.item, "ruby:gem");
let back = throwable_from_nbt(&tag, None).unwrap();
assert_eq!((&back.kind, back.velocity), (&p.kind, p.velocity));
// NoGravity comes back too, as for vanilla's (a summoned bolt that floats).
p.no_gravity = true;
let tag = throwable_to_nbt(&p, [1; 4], None, None).unwrap();
assert!(throwable_from_nbt(&tag, None).unwrap().no_gravity);
// a type no mod declares now is not a thrown kind.
assert!(throwable_kind("gone:bolt").is_none());
}
}
+109
View File
@@ -150,6 +150,52 @@ impl FlatTerrain {
void_platform,
})
}
/// The settings of a superflat overworld that lets structure sets generate
/// (`FlatLevelSource.createState`): its layers, biome and `structure_overrides`
/// (no key means every set; `[]` none). `None` for anything else, a world with no
/// structure set to generate, the Void preset's biome (its platform is a feature
/// this generator places) or one that is not superflat.
pub fn structure_settings(
settings: &Tag,
) -> Option<horizon_world::worldgen::generator::FlatSettings> {
let generator = settings
.get("dimensions")?
.get("minecraft:overworld")?
.get("generator")?;
if crate::save::text(generator.get("type"))?.trim_start_matches("minecraft:") != "flat" {
return None;
}
let s = generator.get("settings")?;
let biome = crate::save::text(s.get("biome")).unwrap_or_else(|| "minecraft:plains".into());
if biome == "minecraft:the_void" {
return None;
}
let mut layers = Vec::new();
if let Some(Tag::List(list)) = s.get("layers") {
for layer in list {
let block = crate::save::text(layer.get("block"))?;
let height = crate::save::number(layer.get("height")).unwrap_or(0).max(0) as i32;
layers.push((block, height));
}
}
let structure_overrides = match s.get("structure_overrides") {
Some(Tag::List(sets)) => Some(
sets.iter()
.filter_map(|set| crate::save::text(Some(set)))
.collect::<Vec<_>>(),
),
Some(Tag::String(set)) => Some(vec![set.to_string_lossy()]),
_ => None,
};
if structure_overrides.as_ref().is_some_and(Vec::is_empty) {
return None;
}
Some(horizon_world::worldgen::generator::FlatSettings {
layers,
biome,
structure_overrides,
})
}
/// FlatLevelSource.getSpawnHeight: the top of the layers.
pub fn spawn_height(&self) -> i32 {
self.shape.min_y + self.layers.len().min(self.shape.height as usize) as i32
@@ -479,4 +525,67 @@ mod tests {
(None, vec!["Sum of layer heights is > 4064".into()])
);
}
/// `FlatLevelSource.createState`: the settings a flat world's structure sets run with.
#[test]
fn a_flat_world_names_the_structure_sets_that_may_generate() {
let world = |generator: Tag| {
Tag::compound([(
"dimensions",
Tag::compound([(
"minecraft:overworld",
Tag::compound([("generator", generator)]),
)]),
)])
};
let flat = |extra: Vec<(&str, Tag)>, biome: &str| {
let mut settings = vec![
("biome", Tag::String(biome.into())),
(
"layers",
Tag::List(vec![Tag::compound([
("block", Tag::String("minecraft:stone".into())),
("height", Tag::Int(3)),
])]),
),
];
settings.extend(extra);
world(Tag::compound([
("type", Tag::String("minecraft:flat".into())),
("settings", Tag::compound(settings)),
]))
};
// No key: every set. A list: those. An empty list: none. The void and a noise world: none.
let all = FlatTerrain::structure_settings(&flat(vec![], "minecraft:plains")).unwrap();
assert_eq!(all.structure_overrides, None);
assert_eq!(all.layers, [("minecraft:stone".to_owned(), 3)]);
let some = FlatTerrain::structure_settings(&flat(
vec![(
"structure_overrides",
Tag::List(vec![Tag::String("minecraft:villages".into())]),
)],
"minecraft:desert",
))
.unwrap();
assert_eq!(
some.structure_overrides,
Some(vec!["minecraft:villages".to_owned()])
);
assert_eq!(some.biome, "minecraft:desert");
assert!(
FlatTerrain::structure_settings(&flat(
vec![("structure_overrides", Tag::List(vec![]))],
"minecraft:plains"
))
.is_none()
);
assert!(FlatTerrain::structure_settings(&flat(vec![], "minecraft:the_void")).is_none());
assert!(
FlatTerrain::structure_settings(&world(Tag::compound([(
"type",
Tag::String("minecraft:noise".into())
)])))
.is_none()
);
}
}
File diff suppressed because it is too large. Load diff
+15 -7
View File
@@ -326,13 +326,19 @@ impl Game<'_> {
return;
}
let data = self.options.resources.join("data");
// The world's packs' advancements go over the game's own.
let files = super::functions::content_overlay(
&self.world_root,
&self.options.resources,
self.cmds.vanilla_disabled,
&self.level.enabled_features,
);
// The world's packs' advancements go over the game's own, and a
// native mod's contributed files are already among them: the tables
// were built for this open (`build_world_tables`), so the hook is
// asked once, not again here.
let files = match &self.world_tables {
Some(tables) => tables.files.clone(),
None => super::functions::content_overlay(
&self.world_root,
&self.options.resources,
self.cmds.vanilla_disabled,
&self.level.enabled_features,
),
};
self.adv.pending_tree = std::thread::Builder::new()
.name("horizon-advancements".into())
.spawn(move || Tree::shared(&data, &horizon_world::loot::PackOverlay { files: &files }))
@@ -457,6 +463,8 @@ impl Game<'_> {
EntityView {
kind: "minecraft:player".into(),
position: [p.position.x, p.position.y, p.position.z],
// Entity.isOnFire on a server: remainingFireTicks (players aren't fire immune).
on_fire: p.fire_ticks > 0,
sneaking: p.pose == horizon_client::pose::Pose::Crouching,
// Input.shift: the held key, as `BlockItem.place`'s sneaking reads it.
input_sneak: self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft),
+26 -2
View File
@@ -498,7 +498,7 @@ impl Game<'_> {
json!({
"dialog": self.dialog_status(),
"screen": self.screen_label(),
"menu": self.title.as_ref().map(|_| self.title_value()),
"menu": (self.title.is_some() || self.split_profiles_open()).then(|| self.title_value()),
"world": (self.title.is_none()).then(|| match &self.integrated {
Some(server) => server.world_name.clone(),
None => self.level.name.clone(),
@@ -528,8 +528,16 @@ impl Game<'_> {
})
}
/// Splitscreen's profile page, a joining player's (`split_profiles`).
fn split_profiles_open(&self) -> bool {
self.screen == Screen::Options
&& self.ui.options.page() == horizon_client::options_ui::Page::SplitProfiles
}
fn title_value(&self) -> Value {
use horizon_client::title::Mode;
if self.split_profiles_open() {
return self.ui.options.split_profiles.state();
}
let Some(title) = &self.title else {
return Value::Null;
};
@@ -727,7 +735,7 @@ impl Game<'_> {
v["chat_input"] = json!(chat.input.value);
v["chat_suggestions"] = json!(chat.suggestions_visible());
}
if self.title.is_some() {
if self.title.is_some() || self.split_profiles_open() {
v["menu"] = self.title_value();
}
if self.screen == Screen::Inventory && self.title.is_none() {
@@ -1591,6 +1599,22 @@ impl Game<'_> {
self.synth_click(event_loop, x, y, button)?;
ok(json!({ "ok": true, "screen": self.screen_label() }))
}
// Splitscreen's profile page, over a joining player's title screen.
"type" if self.split_profiles_open() => {
let text = line
.trim_start()
.strip_prefix("type")
.unwrap_or("")
.strip_prefix(' ')
.unwrap_or("");
let page = &mut self.ui.options.split_profiles;
if text == "\\b" {
page.key(winit::keyboard::KeyCode::Backspace, None);
} else {
page.type_text(text);
}
ok(json!({ "ok": true, "menu": self.title_value() }))
}
"type" if self.title.is_some() => {
// type [--field name|seed] [--replace] TEXT on the title screens.
let mut text = line
+48 -9
View File
@@ -522,7 +522,9 @@ impl Game<'_> {
let slot = match &held {
Some(h) => {
let slot = self.equipment_slot(h);
if slot < 2 && !a.show_arms {
// ArmorStand.interactAt: a disabled slot, or a hand without
// arms, is FAIL.
if a.is_disabled(slot) {
return true;
}
slot
@@ -550,6 +552,18 @@ impl Game<'_> {
}
}
};
// An empty hand takes from the main hand when the clicked slot is
// disabled (`isDisabled(clickedSlot) ? slot : clickedSlot`).
let slot = if held.is_none() && a.is_disabled(slot) {
0
} else {
slot
};
// ArmorStand.swapItem: taking from a filled slot or putting into an
// empty one can be disabled; the click then passes.
if a.swap_locked(slot) {
return false;
}
let current = self.armor_stands[i].equipment[slot].clone();
if held.is_none() && current.is_none() {
return false;
@@ -722,6 +736,8 @@ impl Game<'_> {
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
for (i, a) in self.armor_stands.iter_mut().enumerate() {
a.previous = a.position;
a.previous_yaw = a.yaw;
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
continue;
}
@@ -785,11 +801,10 @@ impl Game<'_> {
let dragon = a.equipment[5]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:dragon_head");
let position = super::entity_culling::at(a.position);
if !self.standing_entity_in_view(
camera,
position,
position,
super::entity_culling::at(a.previous),
super::entity_culling::at(a.position),
alpha,
width,
height,
@@ -798,7 +813,9 @@ impl Game<'_> {
) {
continue;
}
let p = glam::DVec3::new(a.position.x, a.position.y, a.position.z) - camera;
// EntityRenderer: drawn between last tick's position and this one's.
let at = a.render_position(alpha);
let p = super::entity_culling::at(at) - camera;
// setupRotations: 180 - body yaw, plus the wobble for 5 ticks after a hit.
let wiggle = (self.world.game_time - a.last_hit) as f32 + alpha as f32;
let wobble = if wiggle < 5.0 {
@@ -807,13 +824,15 @@ impl Game<'_> {
0.0
};
let base = glam::Mat4::from_translation(p.as_vec3())
* glam::Mat4::from_rotation_y((180.0 - a.yaw + wobble).to_radians())
* glam::Mat4::from_rotation_y(
(180.0 - a.render_yaw(alpha as f32) + wobble).to_radians(),
)
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
let light = self.world.light(
a.position.x.floor() as i32,
(a.position.y + 0.5).floor() as i32,
a.position.z.floor() as i32,
at.x.floor() as i32,
(at.y + 0.5).floor() as i32,
at.z.floor() as i32,
);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
self.armor_stand_vertices(a, base, packed, out);
@@ -1322,6 +1341,26 @@ mod swap_tests {
ItemStack::new(item, count)
}
#[test]
fn disabled_slots_lock_by_filter_flag() {
let mut a = ArmorStand::new(1, horizon_core::geometry::Vec3::ZERO, 0.0);
// Hands need arms.
assert!(a.is_disabled(0) && a.is_disabled(1));
a.show_arms = true;
assert!(!a.is_disabled(0) && !a.is_disabled(1));
// Bit 4 locks the head (equipment index 5), bit 5 the off hand (1).
a.disabled_slots = (1 << 4) | (1 << 5);
assert!(a.is_disabled(5) && a.is_disabled(1));
assert!(!a.is_disabled(4) && !a.is_disabled(0));
// Taking (+8) from a filled slot and putting (+16) into an empty one.
a.disabled_slots = (1 << (3 + 8)) | (1 << (1 + 16));
assert!(!a.swap_locked(4), "an empty chest slot is not take-locked");
a.equipment[4] = Some(stack("minecraft:iron_chestplate", 1));
assert!(a.swap_locked(4));
assert!(a.swap_locked(2), "an empty feet slot is put-locked");
a.equipment[2] = Some(stack("minecraft:iron_boots", 1));
assert!(!a.swap_locked(2));
}
#[test]
fn creative_swap_gives_the_worn_piece_to_the_hand() {
let swap = swap_with_equipment_slot(
&stack("minecraft:diamond_chestplate", 1),
@@ -778,6 +778,15 @@ impl Game<'_> {
return None;
};
let skin = skull.skin.as_ref()?;
// PlayerSkin.Patch: a `texture` override replaces the profile's skin.
if let Some(texture) = &skin.texture
&& let Some(sprite) =
skins::patch_skin(texture, &mut self.skin_downloads.borrow_mut(), |location| {
self.gui.atlas.sprites.contains_key(location)
})
{
return Some(sprite);
}
// The texture a packed or resolved `textures` property fetches: the
// download of a static profile, or the one the services answer for a
// `Dynamic` (name- or id-only) profile.
@@ -152,6 +152,31 @@ impl Game<'_> {
Some(stack)
}
/// `ShulkerBoxBlock.playerWillDestroy`: a Creative player's filled box is
/// one item at the block center, with collectComponents and the default pickup delay.
/// This direct spawn intentionally does not use the block_drops gamerule.
pub(super) fn drop_creative_shulker(&mut self, pos: [i32; 3]) {
let key = (pos[0], pos[1], pos[2]);
let filled = matches!(self.world.block_entities.get(&key), Some(BlockEntity::Chest(items))
if items.iter().flatten().any(|s| s.count > 0));
if !filled {
return;
}
let Some(saved) = self.saved_block_entity(key) else {
return;
};
let state = self.world.block(pos[0], pos[1], pos[2]);
let mut stack = ItemStack::new(&self.res.facts().get(state).item, 1);
let patch = block_entity_item::collected_components(&saved);
(stack.damage, stack.extra) = item_data::decode_components(Some(&Tag::Compound(patch)));
self.spawn_item(
stack,
pos.map(|v| f64::from(v) + 0.5),
super::dispenser::new_item_motion(),
10,
);
}
/// `BlockItem.updateBlockStateFromTag`: the properties of the item's
/// `block_state` component (a hive's honey level, a statue's pose) set on
/// the block just placed at `pos`. Returns the state it has now.
+4 -1
View File
@@ -28,7 +28,10 @@ fn parse_state(text: &str) -> Result<BlockState> {
let (k, v) = pair.split_once('=').ok_or("properties are key=value")?;
properties.insert(k.to_owned(), v.to_owned());
}
Ok(BlockState { name, properties })
Ok(BlockState {
name,
properties: properties.into(),
})
}
impl Game<'_> {
+360 -1
View File
@@ -14,7 +14,7 @@ use horizon_client::{
leash::{Holder, Pending},
riders,
},
motion::RiddenBoat,
motion::{BubbleColumn, RiddenBoat, bubble_column_at},
};
use horizon_core::{
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
@@ -48,6 +48,25 @@ pub(super) struct Boat {
water_level: f64,
land_friction: f32,
out_of_control: f32,
/// `AbstractBoat.DATA_ID_BUBBLE_TIME` (synched): the ticks of `BUBBLE_TIME`
/// left before a bubble column lifts or drags the boat; 0 when it is not
/// over one.
pub bubble_time: i32,
/// `AbstractBoat.isAboveBubbleColumn`, set by `onAboveBubbleColumn` and
/// cleared by `tickBubbleColumn` (the server's).
above_bubble_column: bool,
/// `AbstractBoat.bubbleColumnDirectionIsDown`.
bubble_down: bool,
/// A bubble column's drag asked a rider the server can't move itself (a
/// player) to get out: `guest_vehicle_tick` takes it (`ejectPassengers`).
pub eject_riders: bool,
/// `Entity.tickCount`, counted where the wobble is (a client's tick).
tick_count: i32,
/// `AbstractBoat.bubbleMultiplier`, `bubbleAngle` and `bubbleAngleO`
/// (a client's).
bubble_multiplier: f32,
bubble_angle: f32,
bubble_angle_old: f32,
pub hurt_time: i32,
pub hurt_dir: i32,
pub damage: f32,
@@ -72,6 +91,13 @@ pub(super) struct Boat {
}
/// `ChestBoat.CONTAINER_SIZE`.
pub(super) const CHEST_SLOTS: usize = 27;
/// `AbstractBoat.BUBBLE_TIME`: the ticks over a bubble column before it
/// lifts or drags the boat.
const BUBBLE_TIME: i32 = 60;
/// `EntityEvent` 71 and 72, which `AbstractBoat.tickBubbleColumn` broadcasts
/// for a down and an up column.
pub(super) const BUBBLE_DOWN_EVENT: i8 = 71;
pub(super) const BUBBLE_UP_EVENT: i8 = 72;
pub(super) const WIDTH: f64 = 1.375;
pub(super) const HEIGHT: f64 = 0.5625;
impl Boat {
@@ -90,6 +116,14 @@ impl Boat {
water_level: 0.0,
land_friction: 0.0,
out_of_control: 0.0,
bubble_time: 0,
above_bubble_column: false,
bubble_down: false,
eject_riders: false,
tick_count: 0,
bubble_multiplier: 0.0,
bubble_angle: 0.0,
bubble_angle_old: 0.0,
hurt_time: 0,
hurt_dir: 1,
damage: 0.0,
@@ -174,6 +208,42 @@ impl Boat {
yaw,
)
}
/// `AbstractBoat.getBubbleAngle`: the wobble, in degrees, `a` of the way
/// from the last tick's to this one's.
pub fn bubble_angle(&self, a: f32) -> f32 {
self.bubble_angle_old + a * (self.bubble_angle - self.bubble_angle_old)
}
/// `AbstractBoat.tickBubbleColumn`: on a client, the wobble grows by 0.05 a
/// tick while the synched bubble time runs and fades by 0.1 otherwise.
pub(super) fn tick_bubble_wobble(&mut self) {
self.tick_count += 1;
if self.bubble_time > 0 {
self.bubble_multiplier += 0.05;
} else {
self.bubble_multiplier -= 0.1;
}
self.bubble_multiplier = self.bubble_multiplier.clamp(0.0, 1.0);
self.bubble_angle_old = self.bubble_angle;
self.bubble_angle =
10.0 * (0.5 * f64::from(self.tick_count)).sin() as f32 * self.bubble_multiplier;
}
/// `AbstractBoat.tickBubbleColumn`'s countdown on the server: the bubble
/// time drops while the boat stays over a column and resets when it leaves.
/// `Some(down)` on the tick it runs out: the boat is thrown up (`false`) or
/// pulled down (`true`).
pub(super) fn tick_bubble_countdown(&mut self) -> Option<bool> {
if !self.above_bubble_column {
self.bubble_time = 0;
}
if self.bubble_time <= 0 {
return None;
}
self.bubble_time -= 1;
let diff = BUBBLE_TIME - self.bubble_time - 1;
let fired = (diff > 0 && self.bubble_time == 0).then_some(self.bubble_down);
self.above_bubble_column = false;
fired
}
/// `getRowingTime`.
pub fn rowing_time(&self, side: usize, a: f32) -> f32 {
if self.paddling[side] {
@@ -684,9 +754,194 @@ impl Game<'_> {
if self.boats[i].out_of_control >= 60.0 {
self.eject_boat_mobs(i, false);
}
self.boat_bubble_tick(i);
self.boat_contacts(i);
}
}
/// `Entity.canSimulateMovement`: for a boat (`AUTHORITATIVE_SIDE`) a
/// server leaves the boat its player drives to that player's client, and a
/// client moves only the boat its player drives.
fn boat_can_simulate_movement(&self, id: u32) -> bool {
if self.remote.is_some() {
self.riding == Some(id)
} else {
!self.guest_vehicles().contains(&id)
}
}
/// `AbstractBoat.tick`: `status = getStatus()` for a boat this window
/// does not tick (`tick_boat` sets it for the others): a client's boats
/// other than the one it drives, and a server's boats its players drive.
pub(super) fn refresh_boat_status(&mut self, i: usize) {
let b = &self.boats[i];
let mut probe = Boat::new(b.id, b.wood.clone(), b.position, b.yaw);
let status = self.boat_status(&mut probe);
self.boats[i].status = status;
}
/// `BubbleColumnBlock.entityInside`: the bubble columns boat `i` touches
/// (its box deflated by 1e-5 as `Entity.checkInsideBlocks` does), in x, y,
/// z order.
pub(super) fn boat_bubble_columns(&self, i: usize) -> Vec<BubbleColumn> {
let b = self.boats[i].bounds().deflate(Vec3::new(
f64::from(1.0e-5f32),
f64::from(1.0e-5f32),
f64::from(1.0e-5f32),
));
let (lo, hi) = (b.min_position(), b.max_position());
let mut columns = Vec::new();
for x in lo.x.floor() as i32..=(hi.x.ceil() as i32 - 1) {
for y in lo.y.floor() as i32..=(hi.y.ceil() as i32 - 1) {
for z in lo.z.floor() as i32..=(hi.z.ceil() as i32 - 1) {
if let Some(column) = bubble_column_at(
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
BlockPos::new(x, y, z),
) {
columns.push(column);
}
}
}
}
columns
}
/// `Entity.handleOnInsideBubbleColumn`: a column under more water lifts
/// boat `i` up to 0.7 or drags it down to -0.3 (`AbstractBoat` does not
/// override `onInsideBubbleColumn`), applied by the column's own push below
/// the surface.
pub(super) fn boat_inside_bubble_column(&mut self, i: usize, column: BubbleColumn) {
if !self.boat_can_simulate_movement(self.boats[i].id) {
return;
}
column.push(&mut self.boats[i].velocity);
}
/// `AbstractBoat.onAboveBubbleColumn`: whether the splash comes up, one
/// time in a hundred for a boat that is not under water.
pub(super) fn boat_splash_roll(&mut self, i: usize) -> bool {
!self.boats[i].under_water() && self.world.random.next_int_bound(100).unwrap_or(1) == 0
}
/// `AbstractBoat.onAboveBubbleColumn`: the client's half after the roll,
/// the splash sound and particle at the boat.
pub(super) fn boat_bubble_splash(&mut self, i: usize) {
use horizon_client::particles::{ParticleOptions, ParticleType};
let p = self.boats[i].position;
let pitch = 0.8 + 0.4 * self.world.random.next_float();
self.audio.play(
"minecraft:entity.generic.splash",
Some([p.x, p.y, p.z]),
1.0,
pitch,
);
let x = p.x + f64::from(self.world.random.next_float());
let z = p.z + f64::from(self.world.random.next_float());
self.particles.add(
&ParticleOptions::simple(ParticleType::Splash),
[x, p.y + 0.7, z],
[0.0; 3],
);
}
/// `AbstractBoat.onAboveBubbleColumn`: on the server (this window
/// simulates the boat) the column is remembered, the countdown starts and
/// the splash's game event goes to the listeners (its sound and particle
/// are the client's).
fn boat_above_bubble_column(&mut self, i: usize, drag_down: bool) {
let b = &mut self.boats[i];
b.above_bubble_column = true;
b.bubble_down = drag_down;
if b.bubble_time == 0 {
b.bubble_time = BUBBLE_TIME;
}
if !self.boat_splash_roll(i) {
return;
}
// A window that is its own client hears and sees the splash too.
if self.options.execution_role.is_client() {
self.boat_bubble_splash(i);
}
let (id, p) = (self.boats[i].id, self.boats[i].position);
// Entity.gameEvent(SPLASH, getControllingPassenger()): the window's
// own player when it drives the boat.
let ctx = if self.riding == Some(id) && self.player.is_some() {
self.player_context(None)
} else {
self.who_context(None, None)
};
self.post_game_event("minecraft:splash", [p.x, p.y, p.z], ctx);
}
/// `AbstractBoat.handleBubbleColumnEffect`: an up column throws the boat
/// up at 0.6, a down column pulls it down by 0.7.
pub(super) fn handle_bubble_column_effect(&mut self, i: usize, drag_down: bool) {
if !self.boat_can_simulate_movement(self.boats[i].id) {
return;
}
let v = &mut self.boats[i].velocity;
if drag_down {
// movement.add(0.0, -0.7, 0.0)
v.y += -0.7;
} else {
v.y = 0.6;
}
}
/// `Entity.ejectPassengers`: for a down column the mobs get out here, this
/// window's player gets out, and a server's player is asked to
/// (`guest_vehicle_tick` lets it out through its client).
fn eject_boat_riders(&mut self, i: usize) {
self.eject_boat_mobs(i, false);
let id = self.boats[i].id;
if self.riding == Some(id) && self.player.is_some() {
self.dismount(i);
} else if self.guest_rides(id) {
self.boats[i].eject_riders = true;
}
}
/// `AbstractBoat.tickBubbleColumn`: on the server, a boat that stays over
/// a column for `BUBBLE_TIME` ticks is thrown up or pulled down (a down
/// column lets the riders out first), and every client is told.
fn tick_boat_bubble_column(&mut self, i: usize) {
if let Some(down) = self.boats[i].tick_bubble_countdown() {
let (id, p) = (self.boats[i].id, self.boats[i].position);
let event = if down {
BUBBLE_DOWN_EVENT
} else {
BUBBLE_UP_EVENT
};
if down {
self.eject_boat_riders(i);
}
self.lan_entity_event(id as i32, event, [p.x, p.y, p.z]);
self.handle_bubble_column_effect(i, down);
}
}
/// `AbstractBoat.tick`: the end of it on the server for boat `i`, the two
/// `applyEffectsFromBlocks` that reach bubble columns (the lava is
/// `boat_lava_tick`'s), then `tickBubbleColumn`; and the wobble when this
/// window is its own client.
fn boat_bubble_tick(&mut self, i: usize) {
let id = self.boats[i].id;
// The boat a server's player drives is not ticked here: its status is.
if self.guest_vehicles().contains(&id) {
self.refresh_boat_status(i);
}
// An ejection nobody took (its player left the boat first) is void.
if !self.guest_rides(id) {
self.boats[i].eject_riders = false;
}
let touches = self.boat_bubble_columns(i);
// AbstractBoat.tick calls applyEffectsFromBlocks twice.
for _ in 0..2 {
for &column in &touches {
if column.surface {
self.boat_above_bubble_column(i, column.drag);
} else {
self.boat_inside_bubble_column(i, column);
}
}
}
self.tick_boat_bubble_column(i);
if self.options.execution_role.is_client() {
self.boats[i].tick_bubble_wobble();
}
}
/// `LavaFluid.entityInside` (`lavaHurt`) from the two
/// `applyEffectsFromBlocks` calls in `AbstractBoat.tick`: twice 40
/// damage, so lava breaks a boat on its first tick inside, and the fire
@@ -1053,6 +1308,8 @@ impl Game<'_> {
let destroyed = creative || b.damage > 40.0;
if destroyed {
self.eject_boat_mobs(i, true);
let id = self.boats[i].id;
self.release_vehicle_container(id);
let b = self.boats.remove(i);
let at = [b.position.x, b.position.y, b.position.z];
// AbstractBoat.remove: a destroyed boat drops its lead.
@@ -1132,6 +1389,16 @@ impl Game<'_> {
(hurt.sin() * hurt * damage / 10.0 * b.hurt_dir as f32).to_radians(),
);
}
// AbstractBoatRenderer.extractRenderState: bubbleAngle.
let wobble = b.bubble_angle(a);
// AbstractBoatRenderer.submit: a boat above water wobbles in a
// bubble column, about the axis (1, 0, 1).
if !b.under_water() && wobble.abs() >= 1.0e-5 {
base *= glam::Mat4::from_axis_angle(
glam::Vec3::new(1.0, 0.0, 1.0).normalize(),
wobble * (std::f64::consts::PI / 180.0) as f32,
);
}
base *= glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
* glam::Mat4::from_rotation_y(90f32.to_radians());
let mut poses = Poses::new(model);
@@ -1186,3 +1453,95 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn boat() -> Boat {
Boat::new(1, Arc::from("oak"), Vec3::ZERO, 0.0)
}
/// `AbstractBoat.tickBubbleColumn` on a client: the wobble grows while the
/// bubble time runs and fades after.
#[test]
fn the_wobble_grows_while_the_bubble_time_runs_and_fades_after() {
let mut b = boat();
b.bubble_time = BUBBLE_TIME;
for _ in 0..30 {
b.tick_bubble_wobble();
}
// 0.05 a tick, capped at 1.
assert_eq!(b.bubble_multiplier, 1.0);
// 10 * sin(0.5 * tickCount) * multiplier.
let expected = 10.0 * (0.5 * 30.0f64).sin() as f32;
assert!((b.bubble_angle - expected).abs() < 1.0e-4);
// The render angle lerps between the last two ticks.
let mid = b.bubble_angle(0.5);
assert!((mid - (b.bubble_angle_old + b.bubble_angle) / 2.0).abs() < 1.0e-5);
// Once the bubble time is 0 the multiplier fades by 0.1 a tick.
b.bubble_time = 0;
for _ in 0..4 {
b.tick_bubble_wobble();
}
assert!((b.bubble_multiplier - 0.6).abs() < 1.0e-5);
for _ in 0..10 {
b.tick_bubble_wobble();
}
assert_eq!(b.bubble_multiplier, 0.0);
assert_eq!(b.bubble_angle, 0.0);
}
/// `AbstractBoat.tickBubbleColumn` on the server: a boat held over a
/// column fires on the 60th tick, with the column's direction, then the
/// time restarts.
#[test]
fn the_bubble_countdown_fires_every_sixty_ticks_over_a_column() {
for down in [false, true] {
let mut b = boat();
let mut fired = Vec::new();
for tick in 1..=130 {
// What boat_above_bubble_column does each tick.
b.above_bubble_column = true;
b.bubble_down = down;
if b.bubble_time == 0 {
b.bubble_time = BUBBLE_TIME;
}
if let Some(d) = b.tick_bubble_countdown() {
fired.push((tick, d));
}
assert!(!b.above_bubble_column);
}
assert_eq!(fired, vec![(60, down), (120, down)]);
}
}
/// Leaving the column resets the time: nothing fires, and a boat with no
/// time left does nothing over a column until the column sets it.
#[test]
fn leaving_the_column_resets_the_bubble_countdown() {
let mut b = boat();
b.bubble_time = BUBBLE_TIME;
for _ in 0..30 {
b.above_bubble_column = true;
assert_eq!(b.tick_bubble_countdown(), None);
}
assert_eq!(b.bubble_time, 30);
// A tick away from the column.
assert_eq!(b.tick_bubble_countdown(), None);
assert_eq!(b.bubble_time, 0);
b.above_bubble_column = true;
assert_eq!(b.tick_bubble_countdown(), None);
assert!(b.above_bubble_column);
}
#[test]
fn a_boat_that_never_saw_a_column_does_not_wobble() {
let mut b = boat();
for _ in 0..10 {
b.tick_bubble_wobble();
}
assert_eq!(b.bubble_angle, 0.0);
assert_eq!(b.bubble_angle(0.5), 0.0);
}
}
+18 -5
View File
@@ -71,7 +71,7 @@ impl Game<'_> {
}
let at = hit.pos.map(|v| f64::from(v) + 0.5);
self.audio.play(sound, Some(at), 1.0, 1.0);
if brushable && self.brush_block(hit.pos, hit.face) {
if brushable && self.brush_block(hit.pos, hit.face, slot) {
self.damage_held(1);
}
}
@@ -113,7 +113,7 @@ impl Game<'_> {
}
/// `BrushableBlockEntity.brush`: whether the block changed (the brush wears).
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3]) -> bool {
fn brush_block(&mut self, pos: [i32; 3], face: [i32; 3], slot: usize) -> bool {
let time = self.world.game_time;
let mut brush = self.brushing.remove(&pos).unwrap_or_default();
brush.hit_face.get_or_insert(face);
@@ -141,7 +141,7 @@ impl Game<'_> {
let before = completion_state(brush.count);
brush.count += 1;
if brush.count >= 10 {
self.brushing_completed(pos, brush.hit_face);
self.brushing_completed(pos, brush.hit_face, slot);
return true;
}
self.schedule_brushable_tick(pos);
@@ -191,7 +191,7 @@ impl Game<'_> {
/// `BrushableBlockEntity.brushingCompleted`: the item drops out of the
/// brushed face and the block turns into its sand or gravel.
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>) {
fn brushing_completed(&mut self, pos: [i32; 3], face: Option<[i32; 3]>, slot: usize) {
let state = self.world.block(pos[0], pos[1], pos[2]);
let turns_into = match &*self.res.facts().get(state).name {
"minecraft:suspicious_sand" => "minecraft:sand",
@@ -212,7 +212,20 @@ impl Game<'_> {
f64::from(pos[1] + face[1]) + 0.625,
f64::from(pos[2] + face[2]) + 0.5,
];
self.spawn_item(item, at, [0.0; 3], 0);
let mut drops = vec![item];
let tool = self.stack_in(slot).cloned();
let origin = pos.map(|c| f64::from(c) + 0.5);
self.native_use_drops(
"",
origin,
Some((pos, state)),
None,
tool.as_ref(),
&mut drops,
);
for item in drops {
self.spawn_item(item, at, [0.0; 3], 0);
}
}
// Level event 3008 for every player, then the block change.
self.level_event_at(3008, pos, state.0 as i32);
+4
View File
@@ -435,6 +435,9 @@ impl Game<'_> {
]
.map(String::from),
);
// content mods' thrown types.
let thrown = horizon_client::projectiles::thrown_types().iter();
summonable.extend(thrown.map(|t| t.id.to_owned()));
summonable.sort();
summonable.dedup();
reg.summonable = summonable;
@@ -1432,6 +1435,7 @@ impl Game<'_> {
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.clone(),
size: None,
scaled: true,
});
self.ui.screenshot_message = Some(path);
}
+65 -12
View File
@@ -80,6 +80,9 @@ pub(super) struct CombatTracker {
pending: Vec<(String, (u32, Text), Option<Text>)>,
/// `pending` for a direct entity with no causing entity: only its name.
pending_direct: Vec<(String, Text)>,
/// The lengths of `pending` and `pending_direct` before the first
/// attacker noted for the hurt being dealt now (`forget_noted`).
noted_since: Option<(usize, usize)>,
/// `LivingEntity.lastHurtByMob` and its timestamp (the kill credit).
last_hurt_by: Option<((u32, Text), i64)>,
/// The mob the player last hurt (`getLastHurtMob`) and when.
@@ -91,6 +94,12 @@ pub(super) struct CombatTracker {
/// known (`AbstractArrow.onHitEntity` makes an ownerless arrow its own
/// cause).
pub(super) projectile_direct: Option<Text>,
/// The player whose primed TNT is going off now (`PrimedTnt.getOwner`):
/// its entity id and display name, the blast's causing entity.
pub(super) blast_owner: Option<(u32, Text)>,
/// The guest behind `blast_owner`, when that player is a LAN guest; the
/// host's own blast leaves it None.
pub(super) blast_guest: Option<u32>,
pub(super) projectile_attacker: Option<(u32, Text)>,
/// The projectile hitting the player now is another player's that
/// `ServerPlayer.canHarmPlayer` refuses (the `pvp` rule, an ally).
@@ -145,6 +154,23 @@ pub(super) struct CombatTracker {
/// end of the last one).
fall_before: f64,
}
impl CombatTracker {
fn mark(&mut self) {
let marks = (self.pending.len(), self.pending_direct.len());
self.noted_since.get_or_insert(marks);
}
/// The player's hurt is over: when a native mod canceled it, the
/// attackers noted for it go, as no combat entry takes them and the next
/// hit of the same damage type must not be credited to them.
pub(super) fn forget_noted(&mut self, canceled: bool) {
if let Some((attackers, directs)) = self.noted_since.take()
&& canceled
{
self.pending.truncate(attackers);
self.pending_direct.truncate(directs);
}
}
}
impl Game<'_> {
/// `LivingEntity.makeDrownParticles`: eight bubbles around the entity,
/// moving with it.
@@ -197,18 +223,6 @@ impl Game<'_> {
self.note_named_attacker(damage_type, mob, name);
}
}
/// How many attackers wait for the damage they caused, for
/// [`Game::drop_pending_since`].
pub(super) fn pending_marks(&self) -> (usize, usize) {
(self.combat.pending.len(), self.combat.pending_direct.len())
}
/// Forgets the attackers noted since `marks`: their hit didn't land (a
/// native mod canceled it), so no combat entry takes them, and the next
/// hit of the same damage type must not be credited to them.
pub(super) fn drop_pending_since(&mut self, marks: (usize, usize)) {
self.combat.pending.truncate(marks.0);
self.combat.pending_direct.truncate(marks.1);
}
/// `note_attacker` for an entity that is not one of `self.mobs` (the
/// ender dragon).
pub(super) fn note_named_attacker(&mut self, damage_type: &str, id: u32, name: Text) {
@@ -223,6 +237,7 @@ impl Game<'_> {
name: Text,
item: Option<Text>,
) {
self.combat.mark();
self.combat
.pending
.push((damage_type.to_owned(), (id, name), item));
@@ -231,6 +246,7 @@ impl Game<'_> {
/// nothing caused it (a splash potion nobody threw): the death message
/// names it instead (`getLocalizedDeathMessage`). It is no kill credit.
pub(super) fn note_direct_entity(&mut self, damage_type: &str, name: Text) {
self.combat.mark();
self.combat
.pending_direct
.push((damage_type.to_owned(), name));
@@ -314,6 +330,7 @@ impl Game<'_> {
}
self.combat.pending.clear();
self.combat.pending_direct.clear();
self.combat.noted_since = None;
self.combat.projectile_direct = None;
self.combat.projectile_attacker = None;
self.combat.fall_before = self.player.as_ref().map_or(0.0, |p| p.fall_distance);
@@ -741,7 +758,43 @@ mod held_item_tests {
assert!(!has_custom_name(&gone));
}
}
#[cfg(test)]
mod noted_tests {
use super::CombatTracker;
use horizon_client::text::Text;
/// A canceled hit forgets what was noted for it, also before its hurt
/// started (a PvP blow, `/damage`), and nothing a landed hit noted.
#[test]
fn a_canceled_hit_forgets_only_its_own_attackers() {
let mut c = CombatTracker::default();
let arrow = |c: &mut CombatTracker, id| {
c.mark();
c.pending.push((
"minecraft:arrow".into(),
(id, Text::literal("skeleton")),
None,
));
};
arrow(&mut c, 1);
c.forget_noted(false);
arrow(&mut c, 2);
c.mark();
c.pending_direct
.push(("minecraft:arrow".into(), Text::literal("arrow")));
c.forget_noted(true);
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1]);
assert!(c.pending_direct.is_empty());
// A hit noted and landed, then one noted and canceled: the landed
// one keeps its attacker.
c.forget_noted(false);
arrow(&mut c, 3);
c.forget_noted(false);
arrow(&mut c, 4);
c.forget_noted(true);
assert_eq!(c.pending.iter().map(|p| p.1.0).collect::<Vec<_>>(), [1, 3]);
}
}
#[cfg(test)]
mod stuck_tests {
use super::Stuck;
@@ -6,7 +6,9 @@
//! system message it produced, as styled spans, with the results reported to
//! the player's `CommandResultCallback`; `tick` advances the game; `settle`
//! waits until the terrain and entities around the player are loaded;
//! `portal` sends the player through the nether portal it stands in.
//! `break` destroys a block as the server player; `portal` sends the player
//! through the nether portal it stands in; `falling` counts physical falling
//! blocks by dimension, block and gravity.
use super::Game;
use horizon_client::{Result, text::Text};
use serde_json::{Value, json};
@@ -244,7 +246,36 @@ impl Game<'_> {
}
self.harness_owner(|g| g.harness_settle(deadline))?;
}
"break" => {
let pos = std::array::from_fn(|i| op["pos"][i].as_i64().unwrap_or(0) as i32);
self.harness_owner(|g| g.break_block(pos))?;
}
"portal" => self.harness_portal()?,
"falling" => {
// Physical falling blocks are not command-selector targets
// yet. Inspect the actual level rather than that unrelated gap.
let dimension = op["dimension"].as_str().unwrap_or("minecraft:overworld");
let block = op["block"].as_str().unwrap_or("minecraft:sand");
let no_gravity = op["no_gravity"].as_bool().unwrap_or(false);
let back = self.dim;
self.enter_level(None, super::Dim::from_id(dimension))?;
let facts = self.res.facts();
let matches = |f: &super::FallingBlock| {
&*facts.get(f.state).name == block && f.no_gravity == no_gravity
};
let count = self.falling.iter().filter(|f| matches(f)).count();
if op["discard"].as_bool() == Some(true) {
self.falling.retain(|f| !matches(f));
}
self.enter_level(None, back)?;
let record = json!({
"cmd": format!("#falling {dimension} {block} {no_gravity}"),
"messages": [],
"results": [[true, count]],
});
out.push_str(&record.to_string());
out.push('\n');
}
other => return Err(format!("command harness: unknown op {other}").into()),
}
}
+76 -30
View File
@@ -3,6 +3,7 @@
mod admin;
pub(super) mod compute;
mod data;
pub(super) use data::place_summoned;
mod effects;
pub(super) mod execute;
pub(super) mod extra;
@@ -76,6 +77,11 @@ pub(in crate::game) struct OtherEntity {
pub(super) fn err(key: &str, args: &[&dyn std::fmt::Display]) -> CommandError {
CommandError::tr(key, args)
}
/// `EntityType.getDescriptionId`: `entity.<namespace>.<path>`.
fn entity_name_key(id: &str) -> String {
let (namespace, path) = id.split_once(':').unwrap_or(("minecraft", id));
format!("entity.{namespace}.{}", path.replace('/', "."))
}
fn texts(args: &[Text]) -> Vec<Text> {
args.to_vec()
}
@@ -1227,21 +1233,11 @@ impl Game<'_> {
static RARITY: std::sync::OnceLock<std::collections::HashMap<String, String>> =
std::sync::OnceLock::new();
let map = RARITY.get_or_init(|| {
let v: serde_json::Value = serde_json::from_slice(
&std::fs::read(self.options.resources.join("items.json")).unwrap_or_default(),
)
.unwrap_or_default();
v.as_object()
.map(|o| {
o.iter()
.filter_map(|(k, c)| {
c["minecraft:rarity"]
.as_str()
.map(|r| (k.clone(), r.to_owned()))
})
.collect()
})
horizon_world::item_defaults::read(&self.options.resources)
.unwrap_or_default()
.into_iter()
.filter_map(|(k, c)| c["minecraft:rarity"].as_str().map(|r| (k, r.to_owned())))
.collect()
});
map.get(item).cloned().unwrap_or_else(|| "common".into())
}
@@ -1951,11 +1947,15 @@ impl Game<'_> {
Target::Boat(id) => {
// AbstractBoat.remove: a killed boat drops its lead.
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
self.release_vehicle_container(id);
let b = self.boats.remove(i);
self.boat_removed(&b);
}
}
Target::Cart(id) => self.carts.retain(|c| c.id != id),
Target::Cart(id) => {
self.release_vehicle_container(id);
self.carts.retain(|c| c.id != id);
}
Target::Other(id) => self.others.retain(|o| o.id != id),
Target::Crystal(id) => self.kill_crystal(id),
Target::Dragon(id) => self.kill_dragon(id),
@@ -2019,18 +2019,18 @@ impl Game<'_> {
&& self
.res
.mob_types()
.get(&format!("minecraft:{short}"))
.get(&kind)
.is_some_and(|t| t.category == "monster")
{
return Err(err("commands.summon.failed.peaceful", &[]));
}
let before = self.spawned_mark();
let name = match self.summon_with_nbt(&format!("minecraft:{short}"), pos, &nbt)? {
let name = match self.summon_with_nbt(&kind, pos, &nbt)? {
Some(target) => self.target_name(target),
// Spawned as a kind no command target stands for (primed
// TNT, falling blocks, projectiles): its type's name.
None if self.spawned_mark() != before => {
Text::translate(&format!("entity.minecraft.{short}"), &[])
Text::translate(&entity_name_key(&kind), &[])
}
None => return Err(err("commands.summon.failed", &[])),
};
@@ -2038,17 +2038,12 @@ impl Game<'_> {
return Ok(1);
}
let name;
if let Some(t) = self
.res
.mob_types()
.get(&format!("minecraft:{short}"))
.cloned()
{
if let Some(t) = self.res.mob_types().get(&kind).cloned() {
if self.ui.difficulty == 0 && t.category == "monster" {
return Err(err("commands.summon.failed.peaceful", &[]));
}
self.spawn_mob_spawned(&short, pos);
name = Text::translate(&format!("entity.minecraft.{short}"), &[]);
name = Text::translate(&entity_name_key(&kind), &[]);
} else {
match short.as_str() {
"item" => {
@@ -2106,11 +2101,19 @@ impl Game<'_> {
let cart_kind = super::minecarts::CartKind::from_id(&kind)
.unwrap_or(super::minecarts::CartKind::Rideable);
self.next_entity += 1;
let cart = super::minecarts::Minecart::new(
let mut cart = super::minecarts::Minecart::new(
self.next_entity,
cart_kind,
Vec3::new(pos[0], pos[1], pos[2]),
);
// SummonCommand.createEntity loads even an empty compound, so a
// spawner minecart's BaseSpawner.load runs: SpawnPotentials becomes
// WeightedList.of(new SpawnData()).
if cart.spawner.is_some() {
use horizon_client::mobs::spawner::{Spawner, SpawnerExt};
let empty = horizon_world::nbt::Tag::Compound(Default::default());
cart.spawner = Some(Box::new(Spawner::load(&empty)));
}
self.carts.push(cart);
name = Text::translate(&format!("entity.minecraft.{k}"), &[]);
}
@@ -2122,7 +2125,7 @@ impl Game<'_> {
// Spawned as a kind no command target stands for
// (primed TNT, falling blocks, projectiles).
None if self.spawned_mark() != before => {
Text::translate(&format!("entity.minecraft.{short}"), &[])
Text::translate(&entity_name_key(&kind), &[])
}
None => return Err(err("commands.summon.failed", &[])),
};
@@ -2236,6 +2239,10 @@ impl Game<'_> {
return Ok(0);
};
let state = self.block_state(&b)?;
// SetBlockCommand.setBlock: `ServerLevel.isDebug` fails it.
if self.world_debug {
return Err(err("commands.setblock.failed", &[]));
}
let current = self.world.block(pos[0], pos[1], pos[2]);
if ctx.has("keep") && current != self.world.air {
return Err(err("commands.setblock.failed", &[]));
@@ -2438,6 +2445,10 @@ impl Game<'_> {
if area > i64::from(limit) {
return Err(err("commands.fill.toobig", &[&limit, &area]));
}
// FillCommand.fillBlocks: `ServerLevel.isDebug` fails it.
if self.world_debug {
return Err(err("commands.fill.failed", &[]));
}
let state = self.block_state(&block)?;
let air = self.world.air;
let (hollow, outline, destroy, strict, keep) = (
@@ -2448,6 +2459,11 @@ impl Game<'_> {
ctx.has("keep"),
);
let mut count = 0;
// Whether the filter takes a state, worked out once per state: a fill visits up to 32768 blocks of a handful
// of states, and matching a tag is a name lookup.
let mut matched: std::collections::HashMap<BlockStateId, bool> =
std::collections::HashMap::new();
let plain = !(destroy || hollow || outline);
for y in min[1]..=max[1] {
for z in min[2]..=max[2] {
for x in min[0]..=max[0] {
@@ -2455,11 +2471,23 @@ impl Game<'_> {
if keep && current != air {
continue;
}
if let Some(f) = &filter
&& !self.block_matches(f, current)
{
// Already the block it would become: nothing to place (place_state would do nothing either).
if plain && current == state {
continue;
}
if let Some(f) = &filter {
let ok = match matched.get(&current) {
Some(&ok) => ok,
None => {
let ok = self.block_matches(f, current);
matched.insert(current, ok);
ok
}
};
if !ok {
continue;
}
}
let edge = x == min[0]
|| x == max[0]
|| y == min[1]
@@ -2518,6 +2546,10 @@ impl Game<'_> {
return Err(err("argument.pos.unloaded", &[]));
}
}
// CloneCommands.clone: the destination level being a Debug world fails it.
if self.world_debug {
return Err(err("commands.clone.failed", &[]));
}
let filter = match ctx.get("filter") {
Some(Arg::Block(f)) => Some(f.clone()),
_ => None,
@@ -3554,6 +3586,20 @@ fn split_top_level(s: &str, sep: char) -> Vec<&str> {
out
}
#[cfg(test)]
mod summon_tests {
#[test]
fn a_summoned_entity_is_named_from_its_own_namespace() {
use super::entity_name_key;
assert_eq!(
entity_name_key("minecraft:snowball"),
"entity.minecraft.snowball"
);
assert_eq!(entity_name_key("ruby:bolt"), "entity.ruby.bolt");
assert_eq!(entity_name_key("ruby:spell/bolt"), "entity.ruby.spell.bolt");
}
}
#[cfg(test)]
mod look_at_tests {
use super::{Target, non_player_entity_id};
+31 -30
View File
@@ -28,6 +28,36 @@ fn set_key(tag: &mut Tag, key: &str, value: Tag) {
c.insert(JavaString::from(key), value);
}
}
/// `EntityType.loadEntityRecursive` snaps every entity of the stack,
/// passengers included, to the position (`/summon`, `native_summon`).
pub(in crate::game) fn place_summoned(tag: &mut Tag, pos: [f64; 3]) {
let p = pos.map(|v| Tag::Double(v.to_bits()));
set_key(tag, "Pos", Tag::List(p.to_vec()));
// Entity.load: an absent Fire reads as 0 (until the entity moves).
if tag.get("Fire").is_none() {
set_key(tag, "Fire", Tag::Short(0));
}
if tag.get("Rotation").is_none() {
set_key(
tag,
"Rotation",
Tag::List(vec![
Tag::Float(0.0f32.to_bits()),
Tag::Float(0.0f32.to_bits()),
]),
);
}
if let Tag::Compound(c) = tag
&& let Some(Tag::List(riders)) = c.get_mut(&"Passengers".into())
{
for r in riders {
if matches!(r, Tag::Compound(_)) {
place_summoned(r, pos);
}
}
}
}
impl Game<'_> {
// ---- entity data ---------------------------------------------------
@@ -762,36 +792,7 @@ impl Game<'_> {
return Err(err("argument.nbt.expected.compound", &[]));
}
set_key(&mut tag, "id", Tag::String(kind.into()));
// EntityType.loadEntityRecursive snaps every entity of the stack,
// passengers included, to the position.
fn place(tag: &mut Tag, pos: [f64; 3]) {
let p = pos.map(|v| Tag::Double(v.to_bits()));
set_key(tag, "Pos", Tag::List(p.to_vec()));
// Entity.load: an absent Fire reads as 0 (until the entity moves).
if tag.get("Fire").is_none() {
set_key(tag, "Fire", Tag::Short(0));
}
if tag.get("Rotation").is_none() {
set_key(
tag,
"Rotation",
Tag::List(vec![
Tag::Float(0.0f32.to_bits()),
Tag::Float(0.0f32.to_bits()),
]),
);
}
if let Tag::Compound(c) = tag
&& let Some(Tag::List(riders)) = c.get_mut(&"Passengers".into())
{
for r in riders {
if matches!(r, Tag::Compound(_)) {
place(r, pos);
}
}
}
}
place(&mut tag, pos);
place_summoned(&mut tag, pos);
let from = self.mobs.len();
let canceled = self.persist.joins_canceled;
let target = self.spawn_entity_from_nbt(tag);
@@ -186,10 +186,13 @@ impl Game<'_> {
_ => Some(None),
};
self.for_player(target, |g| {
if g.spectator() {
return false;
}
if let Some(name) = named {
g.note_direct_entity(&ty, name);
}
!g.spectator() && g.hurt_player_from(amount, from, &ty, attacker)
g.hurt_player_from(amount, from, &ty, attacker)
})
.unwrap_or(false)
}
+98 -42
View File
@@ -150,6 +150,69 @@ impl Game<'_> {
let _ = request.reply.send(reply.to_string());
}
/// Text typed at the open screen's text box by the control socket's
/// `type`: its `\b` is a backspace sentinel for the boxes that have always
/// taken one, not the characters `\` and `b`. `Err` when no text box of
/// the screen takes it.
pub(super) fn type_into_screen(&mut self, text: &str) -> Result<()> {
if text == "\\b" {
match self.screen {
Screen::WorldOptions => {
self.world_options_text(None);
return Ok(());
}
Screen::Inventory if self.mod_browser_typing() => {
self.mod_browser_text(None, true);
return Ok(());
}
_ => {}
}
}
self.type_chars_into_screen(text)
}
/// Characters the open screen's focused text box takes: the control
/// socket's `type` and a key press's surviving characters (`charTyped`).
/// Nothing here is a key — the caller drops control characters. `Err` when
/// no text box of the screen takes them.
pub(super) fn type_chars_into_screen(&mut self, text: &str) -> Result<()> {
if self.book.is_some() {
self.book_type(text);
return Ok(());
}
if self.sign_edit.is_some() {
self.sign_input(None, Some(text));
return Ok(());
}
match self.screen {
Screen::Chat => self.chat_type(text),
Screen::CommandBlock => self.command_block_type(text),
Screen::BlockEditor => self.block_editor_type(text),
Screen::DebugOptions => self.debug_options_type(text),
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
// The port, or the game rules' search and number boxes.
Screen::WorldOptions => self.world_options_text(Some(text)),
Screen::Inventory if self.mod_browser_typing() => {
self.mod_browser_text(Some(text), false)
}
Screen::Inventory if self.recipe_search_focused() => {
self.recipe_text(Some(text), false)
}
// The anvil's rename box, while it has an input to name.
Screen::Inventory if self.station_typing() => self.station_text(Some(text), false),
Screen::Creative if self.creative_screen.tab().search => {
// As typed: replaces a selected query, keeps the length limit.
self.creative_search_edit(Some(text))
}
_ => {
return Err(
"type needs the chat screen (key t tap), a sign or book, the language search, the creative search, the anvil or World Options".into(),
);
}
}
Ok(())
}
fn control_command(
&mut self,
line: &str,
@@ -371,6 +434,17 @@ impl Game<'_> {
}
// Native mods see the key as the keyboard hook does.
let action = arg(2)?;
// ScreenEvent.KeyPressed.Pre: the open screen hears of the
// press before it acts on it, as the window's key event hands
// it over. A cancelled press goes no further — not to the
// screens, not to `native_key` — and its release still comes.
if action != "up" && self.screen_key_pressed(physical, action == "repeat") {
if action == "tap" {
self.physical_keys.remove(&physical);
self.native_key_event(physical, false, false);
}
return ok();
}
self.native_key_event(physical, action != "up", action == "repeat");
if action == "tap" {
self.native_key_event(physical, false, false);
@@ -644,6 +718,7 @@ impl Game<'_> {
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.trim().into(),
size: None,
scaled: false,
});
self.pending_screenshot = Some(reply.clone());
Ok(None)
@@ -674,48 +749,20 @@ impl Game<'_> {
self.require_world()?;
// Typed keys: the open screen's narration hears them.
self.narration.keyboard_action();
if self.book.is_some() {
self.book_type(text);
return ok();
}
if self.sign_edit.is_some() {
self.sign_input(None, Some(text));
return ok();
}
match self.screen {
Screen::Chat => self.chat_type(text),
Screen::CommandBlock => self.command_block_type(text),
Screen::BlockEditor => self.block_editor_type(text),
Screen::DebugOptions => self.debug_options_type(text),
Screen::Options => self.ui.options.type_text(&self.gui.atlas, text),
// The port, or the game rules' search and number boxes.
Screen::WorldOptions if text == "\\b" => self.world_options_text(None),
Screen::WorldOptions => self.world_options_text(Some(text)),
Screen::Inventory if self.mod_browser_typing() => {
self.mod_browser_text((text != "\\b").then_some(text), text == "\\b")
}
Screen::Inventory if self.recipe_search_focused() => {
self.recipe_text(Some(text), false)
}
// The anvil's rename box.
Screen::Inventory
if self
.station
.as_ref()
.is_some_and(|s| s.kind == super::stations::Kind::Anvil) =>
{
self.station_text(Some(text), false)
}
Screen::Creative if self.creative_screen.tab().search => {
// As typed: replaces a selected query, keeps the length limit.
self.creative_search_edit(Some(text))
}
_ => {
return Err(
"type needs the chat screen (key t tap), a sign or book, the language search, the creative search, the anvil or World Options".into(),
);
}
}
// ScreenEvent.CharacterTyped.Pre: the open screen hears of
// each character before a text box takes it, as the window's
// key event fires it. A cancelled character is dropped, so
// `type` types the survivors only.
// `\b` is the socket's backspace sentinel, not two characters.
let typed = if text == "\\b" {
text.to_owned()
} else {
let Some(typed) = self.screen_text_typed(text) else {
return ok();
};
typed
};
self.type_into_screen(&typed)?;
ok()
}
"options" => {
@@ -895,6 +942,15 @@ impl Game<'_> {
);
ok()
}
// A splitscreen player leaves splitscreen; only P1's game ends the process.
"quit" if self.options.split_guest => {
if self.remote.is_some() {
self.leave_server(None)?;
} else if let Some(title) = self.title.as_mut() {
title.escape();
}
ok()
}
"quit" => {
self.begin_closing_save(true)?;
ok()
+254 -43
View File
@@ -89,6 +89,12 @@ pub(super) struct ProjWorld<'a, 'w> {
pub targets: Vec<(Who, Aabb)>,
}
impl ProjectileWorld for ProjWorld<'_, '_> {
fn block_contacts(&self, entity: i32, kind: &str, from: Vec3, to: Vec3, bounds: Aabb) {
horizon_client::block_contact::swept_inside(entity, kind, from, to, bounds, |p| {
let state = self.w.world.block(p.x, p.y, p.z);
(state, &self.w.facts.get(state).name)
});
}
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
let mut scene = SceneCollisions {
world: self.w.world,
@@ -1060,7 +1066,14 @@ impl Game<'_> {
bindings.retain(|id, _| live.contains(id));
host.mob_players = bindings;
}
self.mobs.append(&mut mobs);
// The mobs added during the tick (into the emptied `self.mobs`) come
// first, as `append` put them; with none, the ticked list goes back
// as it is instead of being copied into a new one, every mob a tick.
if self.mobs.is_empty() {
self.mobs = mobs;
} else {
self.mobs.append(&mut mobs);
}
self.died = died;
// ... and entity event 60 goes out as it is: the poof.
for (id, at, width, height) in gone {
@@ -1201,7 +1214,16 @@ impl Game<'_> {
self.villager_murdered(m, slain_by_player, killer.flatten());
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
let killer_entity = self.killer_loot_entity(killer.flatten());
self.drop_death_loot(&mut dying, by_player, killer_entity, consumed, pos);
// LAST_DAMAGE_PLAYER only while the player-hurt memory lasts
// (LivingEntity.dropFromLootTable's playerKilled): a mob that dies to
// the environment long after a player's hit names no player.
let player = killer
.filter(|_| m.ai.last_hurt_by_player > 0)
.and_then(|conn| self.killer_uuid(conn));
if let Some(player) = player.filter(|_| m.extra.leash.is_some()) {
self.lead_killers.insert(m.id, (player, self.dim));
}
self.drop_death_loot(&mut dying, player, by_player, killer_entity, consumed, pos);
self.create_wither_rose(m);
if m.species() == Species::CopperGolem {
// CopperGolem.dropEquipment: the preserved equipment too.
@@ -1318,9 +1340,26 @@ impl Game<'_> {
}
own(self)
}
/// The UUID of the player `last_hurt_by_player` keeps: the guest on
/// connection `conn` (None once they left), else the player in place
/// (None on a server, which has none of its own).
fn killer_uuid(&mut self, conn: Option<u32>) -> Option<u128> {
match conn {
// During that guest's own turn, the hub is out of `lan`.
Some(conn) if self.lan_acting != Some(conn) => {
Some(self.lan.as_ref()?.hub.guest(conn)?.uuid)
}
Some(_) => Some(self.native_acting_id()),
None if self.lan_acting.is_none() && self.player.is_some() => {
Some(self.native_acting_id())
}
None => None,
}
}
fn drop_death_loot(
&mut self,
m: &mut Mob,
player_uuid: Option<u128>,
by_player: bool,
killer: horizon_world::loot::LootEntity,
consumed: bool,
@@ -1403,22 +1442,49 @@ impl Game<'_> {
}
}
let raider = horizon_client::mobs::raider::is_raider(m.species());
// The item drops come before the carried block and the orbs, so the
// mods get them all at once and they still spawn in that order.
let mut stacks = Vec::new();
let mut rest = Vec::new();
for d in items {
match d {
drops::Drop::Item(stack) => {
let item = if raider && stack.item == horizon_client::mobs::raider::BANNER {
drops::Drop::Item(stack) => stacks.push(
if raider && stack.item == horizon_client::mobs::raider::BANNER {
horizon_client::mobs::raider::ominous_banner()
} else {
from_loot(&stack)
};
// Every death drop is its own ItemEntity, and the drop
// entity's own random (not the level's) gives it the
// constructor's horizontal push (its motion otherwise
// stays [0, 0.2, 0] and the drops land stacked at the
// mob's centre, without disturbing the level random that
// the orbs' merge ids and block ticks read next).
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
}
},
),
drops::Drop::Stack(stack) => stacks.push(stack),
other => rest.push(other),
}
}
let kind = super::native_hooks::mob_kind(m);
let table = drops::death_loot_table(m, death.mob_drops).filter(|t| loot.has_table(t));
let context = super::native_hooks::NativeLootDrops {
table: table.as_deref().unwrap_or_default(),
dimension: self.dim.id(),
origin: [m.position.x, m.position.y, m.position.z],
block: None,
entity: Some((m.id, &kind)),
player: player_uuid,
tool: None,
explosion: None,
used: false,
};
self.native_loot_drops(&context, &mut stacks);
stacks.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
for item in stacks {
// Every death drop is its own ItemEntity, and the drop entity's
// own random (not the level's) gives it the constructor's
// horizontal push (its motion otherwise stays [0, 0.2, 0] and
// the drops land stacked at the mob's centre, without disturbing
// the level random that the orbs' merge ids and block ticks read
// next).
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
}
for d in rest {
match d {
drops::Drop::CarriedBlock(state) => {
// Enderman.dropCustomDeathLoot: spawnAtLocation for each
// of the carried block's silk-touch drops.
@@ -1434,7 +1500,7 @@ impl Game<'_> {
}
}
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
drops::Drop::Orb(_) => {}
drops::Drop::Item(_) | drops::Drop::Stack(_) | drops::Drop::Orb(_) => {}
}
}
}
@@ -1599,6 +1665,15 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
}
// Projectile.setOwner: the acting player's or the mob's UUID, which
// its save keeps even once the owner is gone.
if p.owner_uuid.is_none() {
p.owner_uuid = match p.owner {
Some(Who::Player) => Some(self.acting_player_uuid()),
Some(Who::Mob(id)) => Some(self.uuid_for(id)),
None => None,
};
}
self.creatures.projectiles.push(p);
}
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
@@ -2081,6 +2156,27 @@ impl Game<'_> {
// `Level.setBlockAndUpdate`: neighbour and shape updates, so a crop
// above trampled farmland breaks and drops.
MobEvent::SetBlock { pos, state } => self.level_set(pos, state)?,
MobEvent::CropRaided {
pos,
state,
old,
mob,
} => {
self.level_set_with_flags(
pos,
state,
horizon_world::level::flags::UPDATE_CLIENTS,
)?;
if let Some(old) = old {
self.post_block_game_event(
"minecraft:block_change",
pos,
Some(Who::Mob(mob)),
None,
);
self.level_event_at(2001, pos, old.0 as i32);
}
}
MobEvent::CrackTurtleEgg { pos } => {
let state = self.world.block(pos[0], pos[1], pos[2]);
let eggs = self
@@ -2218,13 +2314,14 @@ impl Game<'_> {
self.destroy_particles(pos, old);
}
if drop {
let drops = world_items!(self).block_drops_in(
let mut drops = world_items!(self).block_drops_in(
self.pack.assets.registry,
old,
None,
&mut self.world.random,
Some((&mut self.rand_sequences, self.level.seed)),
);
self.native_block_drops(pos, old, None, None, None, &mut drops);
for d in drops {
self.pop_resource(pos, d);
}
@@ -2587,7 +2684,7 @@ impl Game<'_> {
&mut self,
feet: [f64; 3],
conn: Option<u32>,
bindings: &std::collections::HashMap<u32, u32>,
bindings: &horizon_client::fast_hash::FxHashMap<u32, u32>,
) {
// A client of someone else's world has the server's mobs only to show.
if self.remote.is_some() || !self.ui.rules.bool("forgive_dead_players") {
@@ -2735,6 +2832,15 @@ impl Game<'_> {
if p.species() == Species::Fox {
child.extra.main_hand = None;
}
// Fox.FoxBreedGoal.breed: the baby trusts the players who fed its
// parents into love.
if p.species() == Species::Fox {
horizon_client::mobs::fox::trust_breeders(
child,
p.extra.love_cause,
q.as_ref().and_then(|q| q.extra.love_cause),
);
}
// Goat.getBreedOffspring: GoatAi.initMemories, then the baby
// screams if the parent a coin toss picks does, or on a 2% roll.
if p.species() == Species::Goat {
@@ -2966,6 +3072,7 @@ impl Game<'_> {
let rocket = std::mem::take(&mut self.combat.rocket_hit);
// ServerPlayer.canHarmPlayer refused another player's projectile.
if self.creative || self.combat.refuse_player_hit {
self.combat.forget_noted(false);
return false;
}
// A projectile entity dealt it: `#is_projectile`, or a firework
@@ -2999,7 +3106,6 @@ impl Game<'_> {
}),
};
// The causing entity for the death message.
let marks = self.pending_marks();
let owner = self.combat.projectile_owner.take();
if let Some(a) = attacker.or(owner) {
self.note_attacker(damage_type, a);
@@ -3067,9 +3173,7 @@ impl Game<'_> {
// LivingEntity.hurtServer: ENTITY_HURT_PLAYER, unless it returned
// early (invulnerable, or inside the hurt cooldown).
// A hit a native mod canceled records no damage (hurtServer never ran).
if outcome.canceled {
self.drop_pending_since(marks);
}
self.combat.forget_noted(outcome.canceled);
if outcome.hurt || outcome.sound {
let source = attacker
.or(owner)
@@ -3096,7 +3200,7 @@ impl Game<'_> {
});
}
if raised {
self.after_block(outcome, amount, attacker, projectile);
self.after_block(outcome, attacker, projectile);
}
outcome.hurt
}
@@ -3152,14 +3256,21 @@ impl Game<'_> {
self.give_player_effect("minecraft:wither", 40, 0);
}
pub(super) fn hurt_player_by(&mut self, damage_type: &str, amount: f32) {
if self.creative {
return;
}
if let Some(player) = self.player.as_mut()
let mut canceled = false;
if !self.creative
&& let Some(player) = self.player.as_mut()
&& let Some(id) = self.motion.health.damage.id(damage_type)
&& let Ok(o) = player.hurt_blocking(
&self.motion.health,
self.world.health_rules,
id,
amount,
None,
)
{
let _ = player.hurt(&self.motion.health, self.world.health_rules, id, amount);
canceled = o.canceled;
}
self.combat.forget_noted(canceled);
}
/// `Player.aiStep` touching arrows in its box grown by (1, 0.5, 1):
/// `AbstractArrow.playerTouch`, then `Player.take`'s pickup sound at the
@@ -3411,9 +3522,10 @@ impl Game<'_> {
} = hit
&& let Some(j) = wind_charge_hit(id)
{
let (owner, guest) = (p.owner, p.guest);
let (owner, owner_uuid, guest) = (p.owner, p.owner_uuid, p.guest);
let look = self.owner_look(owner, guest);
super::deflect::aim_deflect(&mut projectiles[j], look, owner, guest);
let wind = &mut projectiles[j];
super::deflect::aim_deflect(wind, look, owner, owner_uuid, guest);
}
}
let mut idx = 0;
@@ -3564,6 +3676,7 @@ impl Game<'_> {
ProjectileKind::Arrow { .. }
| ProjectileKind::Trident
| ProjectileKind::Snowball
| ProjectileKind::Thrown(_)
| ProjectileKind::Egg
| ProjectileKind::SmallFireball
| ProjectileKind::LargeFireball { .. }
@@ -3675,7 +3788,7 @@ impl Game<'_> {
}
ArrowEvent::Block { pos, reaction } => match reaction {
BlockReaction::PrimeTnt => {
self.spawn_primed_tnt(pos, false);
self.spawn_primed_tnt(pos, false, None, None);
self.with_level(|b, level, ticks| b.remove_block(level, ticks, pos, false));
let _ = self.process_level_events();
}
@@ -3860,6 +3973,7 @@ impl Game<'_> {
let amount = match p.kind {
ProjectileKind::SmallFireball => 5.0,
ProjectileKind::LargeFireball { .. } => 6.0,
ProjectileKind::Thrown(t) => t.damage,
_ => 0.0,
};
let kind = match p.kind {
@@ -3880,6 +3994,7 @@ impl Game<'_> {
ProjectileKind::EnderPearl
| ProjectileKind::Egg
| ProjectileKind::Snowball
| ProjectileKind::Thrown(_)
| ProjectileKind::LargeFireball { .. }
) {
return Ok(());
@@ -4133,6 +4248,47 @@ impl Game<'_> {
self.thrown_break_event(p.id, atp);
self.item_burst(atp, "minecraft:snowball");
}
// a mod's copy of Snowball: its damage to anything it hits.
(ProjectileKind::Thrown(t), hit) => {
match hit {
Hit::Entity {
who: Who::Mob(id), ..
} => {
let push = [p.velocity.x, p.velocity.z];
let hurt = self
.mobs
.iter_mut()
.find(|m| m.id == id)
.is_some_and(|m| m.damage_projectile(t.damage, source, aim, push));
if hurt {
self.player_hurt_mob(p, id);
}
// LivingEntity.hurtServer: PLAYER_HURT_ENTITY.
if hurt
&& p.owner == Some(Who::Player)
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
{
let victim = self.mob_entity(m);
let direct = horizon_client::advancements::EntityView {
kind: t.id.into(),
position: [p.position.x, p.position.y, p.position.z],
..Default::default()
};
self.player_hit(&victim, "minecraft:thrown", Some(direct), t.damage);
}
}
// Player.hurtServer takes no 0 damage.
Hit::Entity {
who: Who::Player, ..
} if t.damage > 0.0 => {
let from = [p.previous.x, p.previous.y, p.previous.z];
self.mob_hits_player(t.damage, from, "minecraft:thrown");
}
_ => {}
}
self.thrown_break_event(p.id, atp);
self.item_burst(atp, t.item);
}
(ProjectileKind::Egg, hit) => {
if let Hit::Entity {
who: Who::Mob(id), ..
@@ -4450,7 +4606,7 @@ impl Game<'_> {
/// client entity's random (the particle engine's here) for an egg.
pub(super) fn item_burst(&mut self, at: [f64; 3], item: &str) {
let options = self.item_particle(&horizon_client::items::ItemStack::new(item, 1));
let egg = item != "minecraft:snowball";
let egg = item == "minecraft:egg";
for _ in 0..8 {
let v = if egg {
let r = self.particles.random();
@@ -4739,6 +4895,9 @@ impl Game<'_> {
let feet = [m.position.x, m.position.y, m.position.z];
let mut handled = true;
let mut drops: Vec<(String, u32)> = Vec::new();
// The shearing table rolled, for the mods (none for a mooshroom).
let mut drops_table = String::new();
let mob = (m.id, super::native_hooks::mob_kind(m));
// `Sheep.shear`'s drops add a spread on top of the ItemEntity's own
// motion; `MushroomCow.shear`'s do not.
let mut wool = false;
@@ -4828,8 +4987,9 @@ impl Game<'_> {
Species::Sheep if item == "minecraft:shears" && !m.extra.sheared && !m.is_baby() => {
m.extra.sheared = true;
let color = horizon_client::mobs::render::DYE_NAMES[m.extra.color as usize & 15];
drops_table = format!("minecraft:shearing/sheep/{color}");
let stacks = world_items!(self).loot_table(
&format!("minecraft:shearing/sheep/{color}"),
&drops_table,
false,
None,
&mut self.world.random,
@@ -5214,7 +5374,7 @@ impl Game<'_> {
{
// Animal.mobInteract: breed adults, speed up babies, then
// playEatingSound (silent unless the species overrides it).
match feed_animal(m, &mut self.world.random) {
match feed_animal(m, &mut self.world.random, Some(acting)) {
Some(sound) => {
consume = true;
eat = sound;
@@ -5268,6 +5428,7 @@ impl Game<'_> {
spread,
speed: 0.2,
count: count as i32,
randomization: 0,
});
}
// PlayerInteractTrigger: the item used on the mob, once the interaction took.
@@ -5329,8 +5490,22 @@ impl Game<'_> {
self.trigger_item_entity("minecraft:player_sheared_equipment", &stack, &target);
self.spawn_item(stack, [center[0], center[1] + 0.5, center[2]], [0.0; 3], 10);
}
for (item, count) in drops {
for _ in 0..count {
let mut drops: Vec<ItemStack> = drops
.into_iter()
.map(|(item, count)| ItemStack::new(&item, count))
.collect();
if !drops.is_empty() {
self.native_use_drops(
&drops_table,
feet,
None,
Some((mob.0, &mob.1)),
held.as_ref(),
&mut drops,
);
}
for stack in drops {
for _ in 0..stack.count {
// `spawnAtLocation(stack, 1.0)`: an ItemEntity a block above
// the feet that starts with the constructor's own motion, and
// `setDefaultPickUpDelay`. Sheep.shear adds its spread on top;
@@ -5348,10 +5523,8 @@ impl Game<'_> {
}
self.spawn_item(
ItemStack {
item: item.as_str().into(),
count: 1,
damage: 0,
extra: None,
..stack.clone()
},
[feet[0], feet[1] + 1.0, feet[2]],
v,
@@ -5383,10 +5556,21 @@ impl Game<'_> {
_ => return false,
};
let at = m.position;
let mob = (m.id, super::native_hooks::mob_kind(m));
let tool = self.held().cloned();
self.audio.play(sound, Some([at.x, at.y, at.z]), 1.0, 1.0);
self.mobs[index].extra.sheared = true;
for stack in self.sequence_loot(table, table, tool.as_ref()) {
let mut stacks = self.sequence_loot(table, table, tool.as_ref());
let origin = [at.x, at.y, at.z];
self.native_use_drops(
table,
origin,
None,
Some((mob.0, &mob.1)),
tool.as_ref(),
&mut stacks,
);
for stack in stacks {
self.spawn_at_location(at, height, stack);
}
let ctx = self.who_context(Some(horizon_client::mobs::Who::Player), None);
@@ -5409,7 +5593,17 @@ impl Game<'_> {
let feet = [at.x, at.y, at.z];
let tool = self.held().cloned();
let table = "minecraft:brush/armadillo";
for d in self.sequence_loot(table, table, tool.as_ref()) {
let mut stacks = self.sequence_loot(table, table, tool.as_ref());
let kind = super::native_hooks::mob_kind(&self.mobs[index]);
self.native_use_drops(
table,
feet,
None,
Some((id, &kind)),
tool.as_ref(),
&mut stacks,
);
for d in stacks {
self.spawn_at_location(at, 0.0, d);
}
self.audio
@@ -5450,7 +5644,10 @@ impl Game<'_> {
return Ok(thrown);
}
_ if item.ends_with("_spawn_egg") => return self.use_spawn_egg(stack),
_ => return Ok(false),
_ => match horizon_client::projectiles::thrown_by_item(item) {
Some(t) => ProjectileKind::Thrown(t),
None => return Ok(false),
},
};
let Some(eye) = self.eye_position() else {
return Ok(false);
@@ -5480,7 +5677,9 @@ impl Game<'_> {
// second is the `use_cooldown` component, as for any thrown item.
self.apply_use_cooldown(stack);
let sound = match kind {
ProjectileKind::Snowball => "minecraft:entity.snowball.throw",
ProjectileKind::Snowball | ProjectileKind::Thrown(_) => {
"minecraft:entity.snowball.throw"
}
ProjectileKind::Egg => "minecraft:entity.egg.throw",
_ => "minecraft:entity.ender_pearl.throw",
};
@@ -5528,6 +5727,15 @@ impl Game<'_> {
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::finalize::spawn_egg_offspring(child, species, variant, color);
}
// `Mob.checkAndHandleImportantInteractions` then calls
// `onOffspringSpawnedFromEgg`: `Fox`'s makes the baby trust the player
// who used the egg (a fox egg used on a block trusts nobody).
if species == Species::Fox {
let spawner = self.acting_player_uuid();
if let Some(child) = self.mobs.iter_mut().find(|m| m.id == id) {
horizon_client::mobs::fox::add_trusted_entity(child, spawner);
}
}
self.trigger_item_entity("minecraft:player_interacted_with_entity", &stack, &target);
self.consume_held();
true
@@ -5693,6 +5901,7 @@ impl Game<'_> {
self.rendered_mobs.set(0);
for mob in &self.mobs {
let entity_start = out.len();
self.mark_motion(entity_start, (1 << 32) | u64::from(mob.id));
let outline = self.mob_outline(mob);
// Entity.DATA_SHARED_FLAGS_ID invisible: only the name tag draws (and
// the outline of a glowing mob, LivingEntityRenderer.getRenderType).
@@ -5867,6 +6076,8 @@ impl Game<'_> {
);
}
}
// What follows isn't a tracked mob: close the last one's span.
self.mark_motion(out.len(), 0);
self.elder_apparition_geometry(alpha, out);
self.spawner_geometry(camera, alpha, out);
// Projectiles: the baked vanilla arrow model, the rest as item billboards.
@@ -6080,8 +6291,8 @@ impl Game<'_> {
}
continue;
}
if let Some(stack) = a.potion_item() {
let owner = super::item_draw::owner_at(at, 0.0, "minecraft:splash_potion");
if let Some(stack) = a.drawn_item() {
let owner = super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(&stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation());
+2 -4
View File
@@ -89,10 +89,8 @@ impl Game<'_> {
}
if stack.data().is_some_and(|d| !d.charged.is_empty()) {
self.shoot_crossbow();
} else if (self.projectile_slot(CROSSBOW).is_some() || self.creative)
&& self.using.is_none()
{
self.using = Some((self.held_slot(), 0));
} else if self.projectile_slot(CROSSBOW).is_some() || self.creative {
self.start_using_item();
}
true
}
+44 -1
View File
@@ -374,7 +374,7 @@ impl Game<'_> {
let p = c.position;
self.world.modified.insert((c.pos[0] >> 4, c.pos[2] >> 4));
if self.riding == Some(c.id) {
self.riding = None;
self.dismount_removed_vehicle(p.y);
}
self.audio.play(
"minecraft:entity.cushion.break",
@@ -468,6 +468,49 @@ impl Game<'_> {
);
}
/// `LivingEntity.dismountVehicle` from a removed vehicle (a broken
/// cushion) whose position was at `vehicle_y`: up to the higher of the
/// rider's and the vehicle's heights, then the free spot nearest there
/// (`findFreePosition` within the rider's own box). No get-up sound:
/// `Cushion.removePassenger` plays none once the cushion is removed.
pub(super) fn dismount_removed_vehicle(&mut self, vehicle_y: f64) {
self.riding = None;
let border = self.border_bounds();
let b = self.res.behaviours();
let Some(p) = self.player.as_ref() else {
return;
};
let (w, h) = (
f64::from(p.dimensions.width),
f64::from(p.dimensions.height),
);
let mut at = Vec3::new(p.position.x, p.position.y.max(vehicle_y), p.position.z);
// `getBbWidth() <= 4 && getBbHeight() <= 4`.
if w <= 4.0 && h <= 4.0 {
let center = at.add_vector(Vec3::new(0.0, h / 2.0, 0.0));
let size = Vec3::new(w, h, w);
let free = horizon_world::level::portal_travel::find_free_position(
Aabb::of_size(center, size),
center,
size,
&border,
|q| {
b.collision_shape(self.world.block(q.x, q.y, q.z))
.map(|s| s.moved(Vec3::new(f64::from(q.x), f64::from(q.y), f64::from(q.z))))
},
);
if let Some(free) = free {
at = free.add_vector(Vec3::new(0.0, -h / 2.0, 0.0));
}
}
if let Some(p) = self.player.as_mut() {
self.previous_position = p.position;
p.position = at;
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
}
/// `Player.rideTick` on a cushion: hold the seat, and get up on sneak.
pub(super) fn tick_cushion_riding(&mut self, keys: super::Keys) -> bool {
let Some(c) = self
+27 -5
View File
@@ -954,6 +954,20 @@ impl Game<'_> {
let p = c.position;
g.entity_box(min, max, [p.x, p.y, p.z], 0.2125, false, (0.0, c.yaw));
}
// Interaction: EntityDimensions.scalable(width, height), so its eyes
// are at 0.85 of the height. Its rotation is not kept here.
for i in self.interactions.values() {
let b = i.bounds();
let p = i.position;
g.entity_box(
[b.min(Axis::X), b.min(Axis::Y), b.min(Axis::Z)],
[b.max(Axis::X), b.max(Axis::Y), b.max(Axis::Z)],
[p.x, p.y, p.z],
i.height.max(0.0) * 0.85,
false,
(0.0, 0.0),
);
}
for a in self.armor_stands.iter().filter(|a| !a.invisible) {
// ArmorStand.getDimensions: a marker has none, a small one is
// half scale with its eyes at 0.9875.
@@ -962,8 +976,13 @@ impl Game<'_> {
(_, true) => ((0.25, 0.9875), 0.9875),
_ => ((0.5, 1.975), 1.7775),
};
let p = a.position;
g.hitbox([p.x, p.y, p.z], size, eye, true, (0.0, a.yaw));
g.hitbox(
at(a.previous, a.position),
size,
eye,
true,
(0.0, a.render_yaw(alpha)),
);
}
for b in &self.boats {
let h = super::boats::HEIGHT as f32;
@@ -1023,9 +1042,12 @@ impl Game<'_> {
use horizon_client::projectiles::ProjectileKind as K;
let (size, eye) = match p.kind {
K::Arrow { .. } | K::Trident => (0.5, 0.13),
K::Snowball | K::Egg | K::EnderPearl | K::LlamaSpit | K::Potion { .. } => {
(0.25, 0.2125)
}
K::Snowball
| K::Egg
| K::EnderPearl
| K::LlamaSpit
| K::Potion { .. }
| K::Thrown(_) => (0.25, 0.2125),
K::SmallFireball | K::ShulkerBullet { .. } | K::WitherSkull { .. } => {
(0.3125, 0.265_625)
}
+5 -1
View File
@@ -1583,7 +1583,11 @@ impl Game<'_> {
_ => {
// The game's dispatcher, captured for the console.
self.chat_capture = Some(Vec::new());
self.as_console(|g| g.run_command(line));
// A command a native mod registered goes to the mods first,
// looked up by the same first word the match above read.
if !self.perform_native_console_command(line.trim()) {
self.as_console(|g| g.run_command(line));
}
for text in self.chat_capture.take().unwrap_or_default() {
self.console_reply(text);
}
+9 -8
View File
@@ -34,14 +34,14 @@ pub(super) fn aim_deflect(
p: &mut Projectile,
look: Option<Vec3>,
owner: Option<Who>,
owner_uuid: Option<[i32; 4]>,
guest: Option<u32>,
) {
if let Some(look) = look {
p.velocity = look;
p.needs_sync = true;
}
p.owner = owner;
p.guest = guest;
(p.owner, p.owner_uuid, p.guest) = (owner, owner_uuid, guest);
p.acceleration = 0.1;
}
@@ -196,14 +196,14 @@ impl Game<'_> {
/// `Projectile.deflect(AIM_DEFLECT, player, player, true)`.
pub(super) fn deflect(&mut self, i: usize) {
let dir = view_vector(self.yaw, self.pitch);
let guest = self.lan_acting;
let (guest, owner_uuid) = (self.lan_acting, Some(self.acting_player_uuid()));
let p = &mut self.creatures.projectiles[i];
p.velocity = Vec3::new(dir[0], dir[1], dir[2]);
p.yaw = dir[0].atan2(dir[2]).to_degrees() as f32;
p.pitch = dir[1].atan2(dir[0].hypot(dir[2])).to_degrees() as f32;
p.owner = Some(Who::Player);
// On a server, the guest who struck it (their projectile now).
p.guest = guest;
(p.guest, p.owner_uuid) = (guest, owner_uuid);
// Not into the one who turned it on its way out.
p.left_owner = false;
// `AbstractHurtingProjectile.onDeflection` by an attack, and the
@@ -274,9 +274,9 @@ mod tests {
fn a_redirected_wind_charge_flies_along_the_new_owners_look() {
let mut p = wind_charge();
let look = Vec3::new(0.0, -1.0, 0.0);
aim_deflect(&mut p, Some(look), Some(Who::Player), Some(3));
aim_deflect(&mut p, Some(look), Some(Who::Player), Some([4; 4]), Some(3));
assert_eq!(p.velocity, look);
assert_eq!(p.owner, Some(Who::Player));
assert_eq!((p.owner, p.owner_uuid), (Some(Who::Player), Some([4; 4])));
assert_eq!(p.guest, Some(3));
assert_eq!(p.acceleration, 0.1);
assert!(p.needs_sync);
@@ -285,9 +285,10 @@ mod tests {
#[test]
fn with_no_owner_here_it_keeps_its_movement_but_changes_hands() {
let mut p = wind_charge();
aim_deflect(&mut p, None, None, None);
p.owner_uuid = Some([7; 4]);
aim_deflect(&mut p, None, None, None, None);
assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0));
assert_eq!(p.owner, None);
assert_eq!((p.owner, p.owner_uuid), (None, None));
assert_eq!(p.acceleration, 0.1);
assert!(!p.needs_sync);
}
Loaded 100 of 444 files, more files were not shown because too many files have changed in this diff. Show more