Merge main into #5685: uiSoundReserve beside main's splitscreen extra

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Ludovic MarieandClaude Opus 5.5 committed 2026-11-01 14:46:28 +01:00
commit 40704247c7
206 files changed
+10283 -1160

No files matched your search

Generated
+1
View File
@@ -1619,6 +1619,7 @@ version = "0.1.0"
dependencies = [
"bytemuck",
"naga",
"png",
"serde",
"serde_json",
"sha2",
@@ -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.)
@@ -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,
})
}
}
@@ -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 {
@@ -611,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.
+23 -5
View File
@@ -116,6 +116,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 {
@@ -150,6 +152,7 @@ impl Default for Extras {
no_ai: false,
agent_control: false,
discord_presence: true,
splitscreen: false,
}
}
}
@@ -226,6 +229,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),
_ => {}
}
}
@@ -233,7 +237,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:{}\nuiSoundReserve:{}\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:{}\nuiSoundReserve:{}\nownNameTag:{}\nmouseTweaks:{}\nmouseTweaksWheel:{}\nsort:{}\nsortKey:{}\n{}:{}\nagentControl:{}\ndiscordPresence:{}\nsplitscreen:{}\n",
bool_text(self.fullbright),
bool_text(self.dynamic_lights),
bool_text(self.zoom),
@@ -269,6 +273,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
@@ -400,6 +405,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>> {
@@ -436,6 +443,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.
@@ -446,7 +454,7 @@ impl Item {
MouseTweaksWheel => MouseTweaks,
SortKey => Sort,
Fullbright | DynamicLights | OwnNameTag | MouseTweaks | Sort | AutoReconnect | NoAi
| AgentControl | DiscordPresence => {
| AgentControl | DiscordPresence | Splitscreen => {
return None;
}
})
@@ -485,6 +493,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."
}
@@ -497,9 +508,11 @@ impl Item {
Item::Fullbright => Some(Cost::None),
Item::UiSoundReserve => 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,
}
}
@@ -517,6 +530,7 @@ impl Item {
Item::NoAi => e.no_ai,
Item::AgentControl => e.agent_control,
Item::DiscordPresence => e.discord_presence,
Item::Splitscreen => e.splitscreen,
_ => return None,
})
}
@@ -545,6 +559,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 => {}
}
}
@@ -597,6 +612,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::*;
+185 -16
View File
@@ -88,6 +88,7 @@ mod frame_gen;
mod hdr;
mod hdr_stats;
pub use hdr::Calibration as HdrCalibration;
pub mod block_light;
pub mod lightning;
mod lod;
mod native_material;
@@ -412,6 +413,9 @@ pub struct Frame<'a> {
pub additive: &'a [Vertex],
/// Name tags over everything, without depth (`TEXT_SEE_THROUGH`).
pub see_through: &'a [Vertex],
/// Opaque entity quads only a native pack's shadow pass draws (the local player in first
/// person).
pub shadow_only: &'a [Vertex],
/// Camera-relative sun and moon, or the animated End flash (additive,
/// behind everything and after the resident End sky box).
pub celestial: &'a [Vertex],
@@ -599,6 +603,8 @@ pub struct Renderer {
screen_buffer: wgpu::Buffer,
screen_capacity: u64,
entity_group: wgpu::BindGroup,
/// The layout of `entity_group`, for the native shadow pass.
entity_bindings: wgpu::BindGroupLayout,
/// The entity resources with the hand's projection (`hand_globals`).
hand_group: wgpu::BindGroup,
hand_globals: wgpu::Buffer,
@@ -607,6 +613,8 @@ pub struct Renderer {
entity_batch: EntityBatch,
group: wgpu::BindGroup,
globals: wgpu::Buffer,
/// The resource packs' LabPBR maps (normals, specular), for native shader packs.
pbr_maps: Option<[wgpu::Texture; 2]>,
lightmap: wgpu::Texture,
/// The block sprites, `sprite_split` layers per array (the first is
/// `sprites` in terrain.wgsl, the others `sprites_1`...).
@@ -699,6 +707,9 @@ pub struct Renderer {
section_uploaded: crate::fast_hash::FxHashMap<SectionKey, std::time::Instant>,
/// Changes whenever `block_entities` does (unique across renderers).
block_entity_revision: u64,
/// The light sources of each section and their revision, for `block_light`.
light_blocks: crate::fast_hash::FxHashMap<SectionKey, Vec<([i32; 3], u32)>>,
light_revision: u64,
/// VisGraph face connectivity per meshed section (cave culling).
occlusion: occlusion::Occlusion,
/// The column `occlusion`, `sections` and `detail_columns` count in: the
@@ -924,6 +935,37 @@ pub struct PreparedTextures {
/// The sprite layers per array `sprites` is cut by.
split: u32,
forcefield: (Vec<u8>, u32),
/// The LabPBR maps' mip chains (normals, specular), their levels, layers and size.
pbr: Option<(Vec<u8>, Vec<u8>, u32, u32, u32)>,
}
/// Every level of `layers` square RGBA layers of `size`, each the mean of four texels of the
/// one above, layer major: the LabPBR maps' mips (their channels are data, not colour).
fn box_mip_chain(pixels: &[u8], size: u32, layers: u32) -> Vec<u8> {
let layer_bytes = (size * size * 4) as usize;
let mut out = Vec::new();
for layer in pixels.chunks_exact(layer_bytes).take(layers as usize) {
let mut level = layer.to_vec();
let mut side = size as usize;
out.extend_from_slice(&level);
while side > 1 {
let half = side / 2;
let mut next = Vec::with_capacity(half * half * 4);
for y in 0..half {
for x in 0..half {
for c in 0..4 {
let at = |dx: usize, dy: usize| {
u32::from(level[((2 * y + dy) * side + 2 * x + dx) * 4 + c])
};
next.push(((at(0, 0) + at(1, 0) + at(0, 1) + at(1, 1) + 2) / 4) as u8);
}
}
}
out.extend_from_slice(&next);
level = next;
side = half;
}
}
out
}
impl PreparedTextures {
pub fn new(
@@ -956,9 +998,25 @@ impl PreparedTextures {
.collect(),
split,
forcefield: mip_chain(forcefield.0, forcefield.1, &[], 0, 1),
pbr: None,
}
}
/// With the resource packs' LabPBR `normals` and `specular` maps (`layers` layers of
/// `size`, as `RenderAssets` holds them; nothing when empty).
pub fn with_pbr(mut self, normals: &[u8], specular: &[u8], size: u32, layers: u32) -> Self {
if !normals.is_empty() {
self.pbr = Some((
box_mip_chain(normals, size, layers),
box_mip_chain(specular, size, layers),
size.trailing_zeros() + 1,
layers,
size,
));
}
self
}
/// The same mip chains cut by `split` layers per array: chains made before
/// the adapter was known, for a GPU with fewer layers per array.
fn resplit(mut self, layers: u32, split: u32) -> Self {
@@ -1923,6 +1981,40 @@ impl Renderer {
};
let mut mip_levels = 1;
let gl = adapter.get_info().backend == wgpu::Backend::Gl;
// The resource packs' LabPBR maps, for native shader packs: one array each, cut to
// the layers the GPU has (`block_normal_map` gives the default past them).
let pbr_maps = prepared
.pbr
.map(|(normals, specular, levels, layers, size)| {
let kept = layers.min(device.limits().max_texture_array_layers);
let count = array_layers(kept, gl);
[("LabPBR normals", normals), ("LabPBR specular", specular)].map(
|(label, mut data)| {
// The extra layers are empty.
let layer_bytes = data.len() / layers as usize;
data.resize(layer_bytes * count as usize, 0);
device.create_texture_with_data(
&queue,
&wgpu::TextureDescriptor {
label: Some(label),
size: wgpu::Extent3d {
width: size,
height: size,
depth_or_array_layers: count,
},
mip_level_count: levels,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
},
wgpu::util::TextureDataOrder::LayerMajor,
&data,
)
},
)
});
let sprite_textures: Vec<wgpu::Texture> = prepared
.sprites
.into_iter()
@@ -3160,6 +3252,7 @@ impl Renderer {
screen_buffer,
screen_capacity,
entity_group,
entity_bindings,
hand_group,
hand_globals,
entity_buffer,
@@ -3167,6 +3260,7 @@ impl Renderer {
entity_batch: EntityBatch::default(),
group,
globals,
pbr_maps,
lightmap,
sprite_textures,
sprite_unused,
@@ -3224,6 +3318,8 @@ impl Renderer {
block_entities: Default::default(),
section_uploaded: Default::default(),
block_entity_revision: next_block_entity_revision(),
light_blocks: Default::default(),
light_revision: 0,
occlusion: occlusion::Occlusion::new(crate::chunk::MIN_Y, crate::chunk::HEIGHT / 16),
layout: crate::chunk::ColumnLayout::VANILLA,
world_post,
@@ -3472,6 +3568,11 @@ impl Renderer {
pub fn section_count(&self) -> usize {
self.sections.len()
}
/// Whether one of Horizon's own packs with `[shadow]` draws, whose shadow pass takes the
/// frame's `shadow_only` quads.
pub fn native_shadow(&self) -> bool {
self.native.as_ref().is_some_and(|n| n.shadow.is_some())
}
/// What a shader pack's shadow pass draws of the entities this frame: (entities or block
/// entities other than the player, so those Horizon's culling hides from the camera are
/// kept for it; the local player, so its model is built in first person too). (false,
@@ -3504,6 +3605,15 @@ impl Renderer {
if changed {
self.block_entity_revision = next_block_entity_revision();
}
let lights_changed = if mesh.lights.is_empty() {
self.light_blocks.remove(&key).is_some()
} else {
let old = self.light_blocks.insert(key, mesh.lights.clone());
old.as_deref() != Some(&mesh.lights[..])
};
if lights_changed {
self.light_revision += 1;
}
if !self.lod_retiring.is_empty() {
self.lod_retiring.remove(&key);
}
@@ -3883,6 +3993,9 @@ impl Renderer {
for sy in 0..self.layout.sections() as i32 {
let key = (x, sy, z);
block_entities |= self.block_entities.remove(&key).is_some();
if self.light_blocks.remove(&key).is_some() {
self.light_revision += 1;
}
self.section_uploaded.remove(&key);
if retiring {
// Distant tiles still leave its column out: drawn until they don't.
@@ -4182,9 +4295,11 @@ impl Renderer {
horizon_shader_api::Report,
Vec<(String, f64)>,
Vec<(u32, u32)>,
Vec<(u32, u32)>,
block_light::LightTable,
)>,
) -> std::result::Result<Vec<String>, String> {
let Some((report, constants, sprite_tags)) = pack else {
let Some((report, constants, sprite_tags, sprite_materials, lights)) = pack else {
self.native = None;
return Ok(Vec::new());
};
@@ -4194,9 +4309,19 @@ impl Renderer {
sprites: self.terrain_sprites,
format: self.config.format.remove_srgb_suffix(),
sprite_tags: &sprite_tags,
sprite_materials: &sprite_materials,
entities: &self.entity_bindings,
weather_groups: [&self.weather.entity_layout, &self.weather.time_layout],
pbr: self.pbr_maps.as_ref(),
};
let (pack, labels) =
native_pack::NativePack::new(&self.device, &terrain, report, &constants)?;
let (pack, labels) = native_pack::NativePack::new(
&self.device,
&self.queue,
&terrain,
report,
&constants,
lights,
)?;
self.native = pack.draws().then_some(pack);
if self.native.is_some() {
self.pack_post = None;
@@ -5465,6 +5590,7 @@ impl Renderer {
frame.see_through,
oit_on,
);
self.entity_batch.append_shadow_only(frame.shadow_only);
let weather_quads = frame.weather.len() as u32 / 4;
let world_quads = entity_quads
+ text_quads
@@ -5479,7 +5605,8 @@ impl Renderer {
// Where the tail of materials the classic and the improved passes
// both draw ends: the shadows, particles and items count back from it.
let base_quads = world_quads - oit_entity_quads;
let cloud_quads = if let Some(input) = &frame.clouds {
let native_clouds = self.native.as_ref().is_none_or(|n| n.vanilla_clouds);
let cloud_quads = if let Some(input) = frame.clouds.as_ref().filter(|_| native_clouds) {
let (rebuilt, uniform) =
crate::spans::time("clouds", || self.cloud_mesh.prepare(input));
let vertices = self.cloud_mesh.vertices.len() as u64;
@@ -5521,6 +5648,7 @@ impl Renderer {
let hand_quads = (frame.hand.len() / 4) as u32;
let additive_quads = (frame.additive.len() / 4) as u32 - self.entity_batch.moved_additive;
let see_through_quads = (frame.see_through.len() / 4) as u32;
let shadow_only_quads = (frame.shadow_only.len() / 4) as u32;
let entity_vertices = all_entities.len() as u64;
if entity_vertices > self.entity_capacity {
self.entity_capacity = entity_vertices.next_power_of_two();
@@ -5566,15 +5694,16 @@ impl Renderer {
bytemuck::cast_slice(&frame.celestial[..celestial_vertices]),
);
}
let max_quads = (world_quads + additive_quads + hand_quads + see_through_quads)
.max(cloud_quads)
.max(if frame.lightning.bolts.is_empty() {
0
} else {
lightning::QUADS
})
.max(weather_quads)
.max(frame.glowing.len() as u32 / 4);
let max_quads =
(world_quads + additive_quads + hand_quads + see_through_quads + shadow_only_quads)
.max(cloud_quads)
.max(if frame.lightning.bolts.is_empty() {
0
} else {
lightning::QUADS
})
.max(weather_quads)
.max(frame.glowing.len() as u32 / 4);
if max_quads > self.index_quads {
self.index_quads = max_quads.next_power_of_two();
self.indices = Self::quad_indices(&self.device, self.index_quads);
@@ -5859,6 +5988,12 @@ impl Renderer {
.filter(|_| scaled.is_some())
.unwrap_or([self.config.width, self.config.height]);
native.frame.write(&self.queue, frame, size);
native.frame.block_light.update(
&self.queue,
frame.camera,
&self.light_blocks,
self.light_revision,
);
if let Some(t) = &mut native.timing {
t.begin(&self.device, &self.queue);
native_sky_pass =
@@ -5927,6 +6062,22 @@ impl Renderer {
shadow_translucent,
);
}
drop(pass);
if entity_quads > 0 || shadow_only_quads > 0 {
let mut pass = native
.frame
.shadow_map
.begin_entities(&mut encoder, "Native shader pack entity shadow");
pass.set_pipeline(&shadow.entity);
pass.set_bind_group(0, &self.entity_group, &[]);
pass.set_bind_group(horizon_shader_api::stages::GROUP, &shadow.group, &[]);
pass.set_vertex_buffer(0, self.entity_buffer.slice(..));
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.draw_indexed(0..entity_quads * 6, 0, 0..1);
// At the end of the buffer (`EntityBatch::append_shadow_only`).
let first = entity_vertices as u32 - shadow_only_quads * 4;
pass.draw_indexed(0..shadow_only_quads * 6, first as i32, 0..1);
}
}
if native_sky_pass
&& let Some(native) = &mut self.native
@@ -6160,12 +6311,24 @@ impl Renderer {
self.end_sky.draw(&mut pass);
}
if celestial_vertices > 0 && !pack_draws_celestial {
// The Overworld's bodies start with the sun's quad
// (`Game::celestial_geometry`): a pack sky drawing its own skips it.
let own_sun = !frame.end_sky
&& self
.native
.as_ref()
.is_some_and(|n| n.sky.is_some() && !n.vanilla_sun);
let first = if own_sun { 6 } else { 0 };
pass.set_vertex_buffer(0, self.celestial_buffer.slice(..));
pass.draw_indexed(0..(celestial_vertices as u32 / 4) * 6, 0, 0..1);
pass.draw_indexed(first..(celestial_vertices as u32 / 4) * 6, 0, 0..1);
}
pass.set_bind_group(0, &self.group, &[]);
}
if !frame.title && frame.stars.is_some() && !pack_draws_sky {
let own_stars = self
.native
.as_ref()
.is_some_and(|n| n.sky.is_some() && !n.vanilla_stars);
if !frame.title && frame.stars.is_some() && !pack_draws_sky && !own_stars {
self.stars.draw(&mut pass);
pass.set_bind_group(0, &self.group, &[]);
}
@@ -6649,7 +6812,13 @@ impl Renderer {
// terrain and clouds, so the water behind the rain is drawn first.
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
if !pack_draws_weather && !oit_on {
self.weather.draw(&mut pass, &self.entity_group);
let native_weather = self
.native
.as_ref()
.filter(|_| !frame.title)
.and_then(|n| Some((n.weather.as_ref()?, &n.frame.group)));
self.weather
.draw(&mut pass, &self.entity_group, native_weather);
pass.set_bind_group(0, &self.group, &[]);
}
// LevelRenderer: the world border after translucent terrain and weather.
@@ -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)
);
}
}
@@ -79,6 +79,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],
@@ -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()
@@ -14,6 +14,11 @@ 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`).
@@ -27,20 +32,23 @@ 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 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());
let mut frame = NativeFrame::new(device, settings.as_ref(), textures, lights, terrain.pbr);
let shadow = report
.shadow
.as_ref()
.map(|_| NativeShadow::new(device, terrain, &frame))
.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());
@@ -55,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()
@@ -67,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(
@@ -79,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));
}
@@ -87,6 +124,10 @@ impl NativePack {
sky,
material,
post,
vanilla_clouds,
vanilla_sun,
vanilla_stars,
weather,
shadow,
timing: None,
};
@@ -95,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,9 +16,59 @@ 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,
@@ -40,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,
}
@@ -50,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.
@@ -57,6 +117,9 @@ 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,
@@ -151,10 +214,14 @@ fn eased(value: f32, target: f32, half_life: f32, dt: f32) -> f32 {
}
impl NativeFrame {
/// The frame of a pack with the `[shadow]` settings `shadow`, if any.
/// 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"),
@@ -162,19 +229,46 @@ impl NativeFrame {
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(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 }),
],
entries: &entries,
});
let opaque_depth = depth_copy(device, [1, 1]);
let opaque_color = color_copy(device, [1, 1], wgpu::TextureFormat::Rgba8Unorm);
@@ -192,16 +286,49 @@ impl NativeFrame {
..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),
&shadow_map,
&opaque_depth,
&opaque_color,
(&opaque_depth, &opaque_color),
&textures,
&block_light,
&pbr,
);
Self {
textures,
block_light,
pbr,
buffer,
layout,
group,
@@ -241,54 +368,74 @@ impl NativeFrame {
buffer: &wgpu::Buffer,
(reflection, sampler): (&wgpu::TextureView, &wgpu::Sampler),
shadow: &super::native_shadow::ShadowMap,
opaque_depth: &wgpu::Texture,
opaque_color: &wgpu::Texture,
(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: 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.create_view(&Default::default()),
),
},
wgpu::BindGroupEntry {
binding: 11,
resource: wgpu::BindingResource::TextureView(
&opaque_color.create_view(&Default::default()),
),
},
],
entries: &entries,
})
}
@@ -338,8 +485,10 @@ impl NativeFrame {
&self.buffer,
(&self.reflection, &self.reflection_sampler),
&self.shadow_map,
&self.opaque_depth,
&self.opaque_color,
(&self.opaque_depth, &self.opaque_color),
&self.textures,
&self.block_light,
&self.pbr,
);
}
@@ -437,6 +586,20 @@ 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,
@@ -461,26 +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(6, wgpu::TextureSampleType::Depth),
comparison_entry(7),
texture_entry(8, float),
texture_entry(9, wgpu::TextureSampleType::Depth),
texture_entry(10, float),
],
entries: &entries,
});
let mut groups: [Option<&wgpu::BindGroupLayout>; GROUP as usize + 1] = Default::default();
groups[GROUP as usize] = Some(&layout);
@@ -519,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,
})],
@@ -543,6 +727,7 @@ impl NativePost {
Ok(Self {
passes,
layout,
textures: frame.textures.clone(),
frame: frame.buffer.clone(),
shadow: (
frame.shadow_map.view.clone(),
@@ -557,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,
})
}
@@ -573,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 {
@@ -624,62 +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: 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: &entries,
})
})
.collect();
@@ -706,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 {
@@ -729,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(),
);
@@ -871,8 +1112,125 @@ pub fn frame_inputs(
}
}
/// 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;
@@ -934,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, None);
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
queue.write_buffer(
&frame.buffer,
0,
@@ -943,6 +1301,7 @@ mod tests {
let mut post = NativePost::new(
&device,
report.stages,
&report.manifest,
&[("gain".into(), 1.0)],
format,
&frame,
@@ -1053,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, None);
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)
@@ -1,5 +1,5 @@
//! The shadow map of Horizon's own shader packs (`[shadow]`, docs/shaders.md): each frame the
//! solid terrain the light (the sun by day, the moon by night) sees is drawn into a square depth
//! 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
@@ -56,7 +56,7 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
@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 { discard; }
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.
@@ -67,6 +67,40 @@ fn horizon_shadow_vertex(pos: vec3<f32>, uv: vec2<f32>, layer: u32, color: vec4<
}
";
/// 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;
@@ -191,6 +225,25 @@ impl ShadowMap {
&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),
@@ -198,7 +251,7 @@ impl ShadowMap {
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
@@ -261,14 +314,106 @@ pub struct NativeShadow {
/// 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 shadow pass's shader: the frame, Horizon's terrain, and the entries.
fn shader_source() -> String {
/// 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{}{ENTRIES}",
// Horizon's terrain (terrain.wgsl)\n{}{solid}{ENTRIES}",
FrameInputs::WGSL.wgsl(),
horizon_shader_api::stages::SHADOW_HELPERS,
prefixed(
@@ -283,8 +428,9 @@ impl NativeShadow {
device: &wgpu::Device,
shape: &TerrainShape<'_>,
frame: &super::native_post::NativeFrame,
solid_materials: &[u32],
) -> Result<Self, String> {
let text = shader_source();
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 = [
@@ -420,6 +566,7 @@ impl NativeShadow {
};
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}"));
}
@@ -428,6 +575,7 @@ impl NativeShadow {
flat,
translucent,
flat_translucent,
entity,
group,
})
}
@@ -439,7 +587,29 @@ mod tests {
#[test]
fn the_shadow_shader_validates() {
let text = shader_source();
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(),
@@ -454,6 +624,7 @@ mod tests {
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(
@@ -474,6 +645,7 @@ mod tests {
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] {
@@ -252,7 +252,7 @@ mod tests {
resource: globals_buffer.as_entire_binding(),
}],
});
let frame = NativeFrame::new(&device, None);
let frame = NativeFrame::new(&device, None, Vec::new(), Default::default(), None);
queue.write_buffer(
&frame.buffer,
0,
+169 -2
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,
@@ -84,6 +88,8 @@ impl Weather {
});
Self {
pipeline,
entity_layout: entities.clone(),
time_layout: bindings,
group,
uniform,
vertices: Self::buffer(device, 4),
@@ -138,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(..));
@@ -163,8 +182,156 @@ fn depth_stencil() -> wgpu::DepthStencilState {
}
}
/// 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;
+17 -1
View File
@@ -364,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.
@@ -424,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,
@@ -490,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" {
@@ -548,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:{}\nscreenshotQuality:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\nshadowCache:{}\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(),
@@ -556,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",
@@ -709,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,
@@ -33,6 +33,7 @@ pub enum H {
Fog,
Weather,
AnimatedTextures,
DitherCutoutMips,
PresentMode,
Pacing,
Workers,
@@ -261,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,
@@ -525,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",
@@ -646,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 => {
@@ -733,6 +741,7 @@ 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,
+17
View File
@@ -544,6 +544,17 @@ impl HudAtlas {
}
}
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() {
@@ -567,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()?;
@@ -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();
+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}");
}
}
}
+143 -43
View File
@@ -1236,8 +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.
NativePackOptions(usize),
/// 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`).
@@ -1263,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 {
@@ -1525,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 {
@@ -1570,6 +1575,7 @@ impl Default for OptionsUi {
framebuffer: [1280, 720],
split_guest: false,
row_bands: Vec::new(),
split_joined: false,
}
}
}
@@ -1633,30 +1639,64 @@ 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).
/// The options of the Horizon pack `s.shader_packs[i]`, two a row.
fn native_pack_option_rows(s: &Settings, i: usize) -> Vec<Row> {
/// 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,
h: 20.0,
kind: WKind::Text {
text: Text::literal(""),
color: 0,
centered: false,
},
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 mut widgets = pack
.options
.iter()
.zip(&pack.values)
.enumerate()
.map(|(o, (option, v))| {
let value = crate::shader_pack_list::show_value(&option.kind, *v);
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, 150.0, label, Act::NativePackOption(i, o))
});
let mut rows = vec![Row::Note(pack.title.clone())];
while let Some(a) = widgets.next() {
rows.push(Row::Buttons(a, widgets.next()));
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> {
@@ -1667,19 +1707,7 @@ fn shader_option_rows(s: &Settings) -> Vec<Row> {
.screens
.screen(options.screen())
.map_or(2, |(_, c)| c);
let blank = || Widget {
x: 0.0,
y: 0.0,
w: 150.0,
h: 20.0,
kind: WKind::Text {
text: Text::literal(""),
color: 0,
centered: false,
},
list: false,
row: 0,
};
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;
@@ -1744,13 +1772,25 @@ 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> {
if let WKind::Button {
act: Act::NativePackOption(pack, option),
..
} = &wd.kind
{
let option = s.shader_packs.get(*pack)?.options.get(*option)?;
return (!option.description.is_empty()).then(|| Text::literal(&option.description));
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 {
@@ -2095,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",
@@ -2113,7 +2161,7 @@ impl OptionsUi {
Page::Mods => "horizon.pages.mods",
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::ShaderOptions | Page::NativePackOptions(_) => "horizon.pages.shader_options",
Page::ShaderOptions | Page::NativePackOptions(..) => "horizon.pages.shader_options",
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
"options.videoTitle"
}
@@ -2124,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 {
@@ -2213,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.
@@ -2279,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),
@@ -2572,7 +2640,7 @@ impl OptionsUi {
.collect()
}
Page::ShaderOptions => shader_option_rows(s),
Page::NativePackOptions(i) => native_pack_option_rows(s, i),
Page::NativePackOptions(i, at) => native_pack_option_rows(s, i, at),
_ => {
let _ = s;
Vec::new()
@@ -3024,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,
},
@@ -3109,7 +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))),
Page::NativePackOptions(i, _) => {
Some(("controls.reset", Act::NativePackReset(i)))
}
_ => None,
};
if let Some((label, act)) = folder {
@@ -3906,7 +3977,7 @@ impl OptionsUi {
}
}
Act::NativePackOptions(i) => {
self.push(Page::NativePackOptions(i));
self.push(Page::NativePackOptions(i, 0));
}
Act::NativePackOption(pack, option) => {
if let Some(entry) = s.shader_packs.get_mut(pack)
@@ -4829,7 +4900,7 @@ impl OptionsUi {
}
}
Act::NativePackOptions(i) => {
self.push(Page::NativePackOptions(i));
self.push(Page::NativePackOptions(i, 0));
}
Act::NativePackOption(pack, option) => {
if let Some(entry) = s.shader_packs.get_mut(pack)
@@ -5153,6 +5224,22 @@ mod tests {
},
}],
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);
@@ -5166,7 +5253,11 @@ mod tests {
};
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));
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)));
@@ -5174,9 +5265,17 @@ mod tests {
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]
@@ -6715,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)
@@ -4,7 +4,7 @@
use std::path::{Path, PathBuf};
use horizon_shader_api::{OptionKind, PackOption};
use horizon_shader_api::{OptionKind, OptionPage, PackOption};
pub const DIR: &str = "shaderpacks";
@@ -26,6 +26,8 @@ pub struct Entry {
/// 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 {
@@ -88,8 +90,10 @@ fn entry(path: &Path) -> Option<Entry> {
.ok()
.map(|p| p.manifest)
};
let (title, options) =
manifest.map_or_else(|| (file.clone(), Vec::new()), |m| (m.name, m.options));
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,
@@ -97,6 +101,7 @@ fn entry(path: &Path) -> Option<Entry> {
kind: Kind::Horizon,
options,
values,
pages,
});
}
let zip = path.is_file()
@@ -109,6 +114,7 @@ fn entry(path: &Path) -> Option<Entry> {
kind: Kind::Classic,
options: Vec::new(),
values: Vec::new(),
pages: Vec::new(),
})
}
+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);
@@ -2371,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();
@@ -2387,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);
@@ -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...",
@@ -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);
@@ -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}"))
}
@@ -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)?;
@@ -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(
+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 {
+31 -3
View File
@@ -1,6 +1,7 @@
//! Identity of the ordered block-state registry used by chunk packets, and of
//! the item, mob effect and entity type registries once content mods add to
//! them (item stacks, effect and entity 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};
@@ -108,10 +109,11 @@ fn hash_facts(bytes: &[u8]) -> Result<[u8; 32], IdentityError> {
}
/// The built-in registries whose network ids content mods extend besides blocks.
const REGISTRIES: [&str; 3] = [
const REGISTRIES: [&str; 4] = [
"minecraft:item",
"minecraft:mob_effect",
"minecraft:entity_type",
"minecraft:menu",
];
fn parse_registries(bytes: &[u8]) -> Result<Builtin, IdentityError> {
@@ -180,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!(
+42 -2
View File
@@ -2287,12 +2287,23 @@ const THROWABLES: [(ProjectileKind, &str); 3] = [
(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 {
@@ -2341,8 +2352,7 @@ pub fn throwable_to_nbt(
/// `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 id = kind(tag)?;
let (kind, _) = THROWABLES.iter().find(|(_, i)| *i == id)?.clone();
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));
@@ -4472,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());
}
}
+329 -112
View File
@@ -142,6 +142,7 @@ mod integrated;
mod interactions;
mod item_draw;
mod item_particles;
mod item_use;
mod jigsaw_blocks;
mod jigsaw_edit;
mod jukebox;
@@ -196,6 +197,7 @@ mod native_hooks;
mod native_lifecycle;
mod native_menus;
mod native_mobs;
mod native_motion;
mod native_net;
mod native_panel;
mod native_panel_recipes;
@@ -1647,6 +1649,8 @@ struct FallingBlock {
age: i32,
/// `Entity.isNoGravity`.
no_gravity: bool,
/// `FallingBlockEntity.tick` handles portals after moving, before landing.
portal: horizon_client::nether_portal::PortalState,
/// `FallingBlockEntity.dropItem`.
drop_item: bool,
/// `Entity.fallDistance` (powder snow holds a block that fell far at 0.9).
@@ -1737,8 +1741,8 @@ struct WorldRest {
/// recipes, loot tables and tags from the world's pack stack, over the
/// resource root's own. Worlds without packs use `Resources.rest` directly.
struct WorldTables {
/// The pack files the tables were built from, for the advancement tree
/// reload and the loot data.
/// The pack files the tables were built from (a native mod's among
/// them), for the advancement tree reload and the loot data.
files: Vec<(String, Vec<Vec<u8>>)>,
items: Items,
recipes: Recipes,
@@ -2226,6 +2230,8 @@ struct Game<'a> {
luminance: Option<horizon_client::dynamic_light::Luminance>,
/// Name tags drawn over everything (`RenderPipelines.TEXT_SEE_THROUGH`).
see_through_vertices: Vec<Vertex>,
/// The first-person player's model, which only a native pack's shadow pass draws.
shadow_only_vertices: std::cell::RefCell<Vec<Vertex>>,
/// Runs of `entity_vertices` by entity, for a shader pack's per-entity draws; recorded only
/// while `pack_ids` is set (game/shader_packs.rs).
entity_spans: std::cell::RefCell<Vec<horizon_client::gfx::pack_draw::EntitySpan>>,
@@ -2245,6 +2251,9 @@ struct Game<'a> {
/// The glint clock and options, read once at the start of each frame
/// (game/item_draw.rs).
glint_frame: horizon_client::item_render::GlintFrame,
/// Mod block entities' geometry their mods kept
/// (game/native_block_entity_draw.rs).
kept_block_entities: std::cell::RefCell<native_block_entity_draw::KeptGeometry>,
/// `item_level` for one frame, game time, day time and dimension.
item_level_cache: std::cell::RefCell<
Option<(
@@ -2332,6 +2341,10 @@ struct Game<'a> {
/// Mobs a player hurt (`LivingEntity.lastHurtByPlayer`), with the guest
/// it was by connection (None: the host).
last_hurt_by_player: HashMap<u32, Option<u32>>,
/// Leashed mobs a player killed, with the killer's UUID: the lead drops
/// on the corpse's next tick (`Leashable.tickLeash`), for the mods. With
/// the corpse's dimension: each level's tick prunes only its own.
lead_killers: HashMap<u32, (u128, Dim)>,
/// Animals a player fed into love (`Animal.loveCause`), with the guest it was by
/// connection (None: the host). Breeding credits them, wherever they are.
love_cause: HashMap<u32, Option<u32>>,
@@ -2342,6 +2355,9 @@ struct Game<'a> {
be_anims: horizon_client::block_entity_anim::BlockEntityAnims,
/// Item being used (eaten): hotbar slot and ticks used.
using: Option<(usize, u32)>,
/// `getUseDuration` when that use started, as a mod left it
/// (`game/item_use.rs`): consumables and mod items count to it.
use_duration: i32,
/// Camera.oldFovModifier / fovModifier.
/// `Camera` (eye height and field-of-view easing, view placement).
view: horizon_client::camera::Camera,
@@ -2845,6 +2861,56 @@ impl Dim {
}
}
}
/// `Inventory.add` on a player's 36 slots (hotbar first) and off hand,
/// `max` the stack's own maximum: onto equal stacks first
/// (`getSlotWithRemainingSpace`: the selected slot, the off hand, then the
/// hotbar and the main inventory), then into empty slots of the inventory
/// (`getFreeSlot`, never the off hand). What does not fit stays in `stack`;
/// true when some of it is left.
fn inventory_add(
inventory: &mut [Option<ItemStack>],
offhand: &mut Option<ItemStack>,
selected: usize,
max: u32,
stack: &mut ItemStack,
) -> bool {
let order = std::iter::once(selected)
.chain(std::iter::once(shield::OFFHAND_SLOT))
.chain((0..36).filter(|&i| i != selected));
for i in order {
let slot = if i == shield::OFFHAND_SLOT {
&mut *offhand
} else {
&mut inventory[i]
};
if let Some(slot) = slot
&& slot.same_item_same_components(stack)
&& slot.count < max
{
let moved = (max - slot.count).min(stack.count);
slot.count += moved;
stack.count -= moved;
if stack.count == 0 {
return false;
}
}
}
for slot in inventory.iter_mut().take(36) {
if slot.is_none() {
let moved = stack.count.min(max);
*slot = Some(ItemStack {
count: moved,
..stack.clone()
});
stack.count -= moved;
if stack.count == 0 {
return false;
}
}
}
true
}
fn facing_offset(facing: &str) -> [i32; 3] {
match facing {
"north" => [0, 0, -1],
@@ -3236,57 +3302,23 @@ impl Game<'_> {
tutorial::Step::FindTree | tutorial::Step::PunchTree | tutorial::Step::CraftPlanks
)
.then(|| stack.clone());
let mut added = false;
let max = self
.res
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack);
// getSlotWithRemainingSpace: the selected slot, the off hand, then
// the hotbar and the main inventory.
let order: Vec<usize> = std::iter::once(self.selected)
.chain(std::iter::once(shield::OFFHAND_SLOT))
.chain((0..36).filter(|&i| i != self.selected))
.collect();
for &i in &order {
let slot = if i == shield::OFFHAND_SLOT {
&mut self.offhand
} else {
&mut self.inventory[i]
};
if let Some(slot) = slot
&& slot.same_item_same_components(&stack)
&& slot.count < max
{
let moved = (max - slot.count).min(stack.count);
slot.count += moved;
stack.count -= moved;
added |= moved > 0;
if stack.count == 0 {
self.tutorial_get_item(tutorial_item.as_ref());
return None;
}
}
}
for i in 0..36 {
if self.inventory[i].is_none() {
let moved = stack.count.min(max);
self.inventory[i] = Some(ItemStack {
count: moved,
..stack.clone()
});
stack.count -= moved;
added |= moved > 0;
if stack.count == 0 {
self.tutorial_get_item(tutorial_item.as_ref());
return None;
}
}
}
if added {
let before = stack.count;
let left = inventory_add(
&mut self.inventory,
&mut self.offhand,
self.selected,
max,
&mut stack,
);
if stack.count < before {
self.tutorial_get_item(tutorial_item.as_ref());
}
Some(stack)
left.then_some(stack)
}
/// The block's first up face (or any face) and its fixed tint, for block
/// models drawn as entities (falling blocks, carried blocks).
@@ -3539,6 +3571,10 @@ impl Game<'_> {
// Block.popResource: Mth.nextDouble offsets from the level random; the
// motion is `new ItemEntity(..)`'s own, from the item's random.
let p = spill::pop_position(&mut self.world.random, pos);
// Block.popResource draws its offsets before the private overload checks the gamerule.
if !self.ui.rules.bool("block_drops") {
return;
}
let v = dispenser::new_item_motion();
self.spawn_item(stack, p, v, 10);
}
@@ -4696,6 +4732,7 @@ impl Game<'_> {
velocity: Vec3::ZERO,
age: 0,
no_gravity: false,
portal: Default::default(),
drop_item: true,
fall_distance: 0.0,
stuck: Vec3::ZERO,
@@ -5041,7 +5078,7 @@ impl Game<'_> {
Ok(())
}
/// One `FallingBlockEntity.tick` move of `f`.
fn fall_step(&self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
fn fall_step(&mut self, f: &mut FallingBlock, entity_drops: bool) -> Result<FallStep> {
f.previous = f.position;
f.age = f.age.wrapping_add(1);
if !f.no_gravity {
@@ -5087,30 +5124,11 @@ impl Game<'_> {
f.position.z + movement.z,
);
let on_ground = wanted.y < 0.0 && movement.y != wanted.y;
// FallingBlockEntity.tick: off the ground, it gives up after 600
// ticks (100 outside the world) and drops itself as an item.
let y = next.y.floor() as i32;
let outside = y <= self.world.bottom_y() || y >= self.world.top_y();
if !on_ground && (f.age > 100 && outside || f.age > 600) {
return Ok(if f.drop_item && entity_drops {
FallStep::Dropped(next)
} else {
FallStep::Gone
});
}
if next.y < f.position.y {
f.fall_distance += f.position.y - next.y;
}
let from = f.position;
f.position = next;
if on_ground {
let pos = [
f.position.x.floor() as i32,
f.position.y.floor() as i32,
f.position.z.floor() as i32,
];
return Ok(FallStep::Landed(pos));
}
// Entity.move stops it on the axes it collided on.
if movement.x != wanted.x {
f.velocity.x = 0.0;
@@ -5121,6 +5139,10 @@ impl Game<'_> {
if movement.z != wanted.z {
f.velocity.z = 0.0;
}
if on_ground {
self.falling_block_impact(f);
f.fall_distance = 0.0;
}
// applyEffectsFromBlocks: WebBlock.entityInside makes it stuck
// (Entity.makeStuckInBlock), BubbleColumnBlock.entityInside lifts or
// drags it. Entity.move records the move unless it was blocked short
@@ -5152,12 +5174,44 @@ impl Game<'_> {
if stuck {
f.stuck = horizon_client::falling_block::web_multiplier();
}
// FallingBlockEntity.tick handles its portal after the move's block
// effects, before placing/dropping on the floor under an End portal.
let teleported = self.falling_through_portal(f);
// FallingBlockEntity.forceTickAfterTeleportToDuplicate: crossing
// either way through the End deliberately keeps the source's final
// placement/drop branch, even though the source entity was removed.
if let Some(target) = teleported
&& self.dim != Dim::End
&& target != Dim::End
{
return Ok(FallStep::Gone);
}
let y = next.y.floor() as i32;
let outside = y <= self.world.bottom_y() || y >= self.world.top_y();
if !on_ground && (f.age > 100 && outside || f.age > 600) {
return Ok(if f.drop_item && entity_drops {
FallStep::Dropped(next)
} else {
FallStep::Gone
});
}
if on_ground {
return Ok(FallStep::Landed([
f.position.x.floor() as i32,
f.position.y.floor() as i32,
f.position.z.floor() as i32,
]));
}
f.velocity = Vec3::new(
f.velocity.x * 0.98,
f.velocity.y * 0.98,
f.velocity.z * 0.98,
);
Ok(FallStep::Falling)
Ok(if teleported.is_some() {
FallStep::Gone
} else {
FallStep::Falling
})
}
/// `Entity.spawnAtLocation`: an item entity where the block was.
fn drop_falling_block(&mut self, state: BlockStateId, at: Vec3) {
@@ -5309,6 +5363,9 @@ impl Game<'_> {
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, disarmed, 260));
self.post_block_game_event("minecraft:shear", pos, self.sculk_actor(), None);
}
if creative && storage::is_shulker_box(&facts.name) {
self.drop_creative_shulker(pos);
}
// BeehiveBlock.playerWillDestroy: a creative break still drops the hive
// with its bees and honey level.
if creative
@@ -5835,6 +5892,12 @@ impl Game<'_> {
{
return Ok(true);
}
// a mod's item used over time: its Item.use, once a block item's
// useOn had its turn.
if !on_block && (info.block.is_none() || block_hit.is_none()) && self.start_mod_use(&stack)
{
return Ok(true);
}
if (!on_block && self.use_potion_item(&stack)?)
|| ((block_hit.is_some() || !spawn_egg)
&& (!on_block || spawn_egg)
@@ -5844,9 +5907,7 @@ impl Game<'_> {
}
// BrushItem.useOn: brushing starts on the block aimed at (CONSUME).
if block_hit.is_some() && &*stack.item == "minecraft:brush" {
if self.using.is_none() {
self.using = Some((self.held_slot(), 0));
}
self.start_using_item();
return Ok(true);
}
if !on_block
@@ -5873,9 +5934,7 @@ impl Game<'_> {
}
// BundleItem.use: startUsingItem, then onUseTick empties it.
if !on_block && info.bundle {
if self.using.is_none() {
self.using = Some((self.held_slot(), 0));
}
self.start_using_item();
self.swing_arm();
return Ok(true);
}
@@ -5884,10 +5943,8 @@ impl Game<'_> {
return Ok(true);
}
if !on_block && &*stack.item == "minecraft:bow" {
if self.using.is_none()
&& (self.creative || self.projectile_slot("minecraft:bow").is_some())
{
self.using = Some((self.held_slot(), 0));
if self.creative || self.projectile_slot("minecraft:bow").is_some() {
self.start_using_item();
}
return Ok(true);
}
@@ -6043,17 +6100,28 @@ impl Game<'_> {
/// `Player.canEat` (`Consumable.canConsume`), anything else is always
/// consumed. The right click is held for `consume_seconds`.
fn start_eating(&mut self, info: &horizon_client::items::ItemInfo) -> bool {
let Some(stack) = self.held().cloned() else {
return false;
};
if info.consumable.is_none()
|| (info.food.is_some()
&& !self.player.as_ref().is_some_and(|p| {
info.can_always_eat || p.health.food.food < 20 || self.creative
}))
&& (self.player.is_none() || !self.can_eat(&stack, info.can_always_eat)))
{
return false;
}
if self.using.is_none() {
self.using = Some((self.held_slot(), 0));
// as Consumable.startConsuming does, no time is eaten at once, without a use
// (the server's eating reaches a client through the inventory).
if let Some(consumable) = info.consumable.as_ref()
&& item_use::consumes_at_once(consumable.ticks())
{
if self.remote.is_none() {
// the cooldown of ItemStack.use's applyAfterUseComponentSideEffects.
self.apply_use_cooldown(&stack);
self.finish_consuming(info, consumable, &stack);
}
return true;
}
self.start_using_item();
true
}
/// `InstrumentItem.use` for a goat horn: the player holds it up for the
@@ -6065,9 +6133,7 @@ impl Game<'_> {
let Some(instrument) = self.res.items().instrument(stack) else {
return;
};
if self.using.is_none() {
self.using = Some((self.held_slot(), 0));
}
self.start_using_item();
let at = self
.player
.as_ref()
@@ -6188,6 +6254,9 @@ impl Game<'_> {
return;
}
if self.tick_drinking(slot, ticks, &stack) {
if self.using.is_none() {
self.use_item_finished(&stack);
}
return;
}
if &*stack.item == "minecraft:bow" {
@@ -6239,24 +6308,30 @@ impl Game<'_> {
self.tick_bundle(slot, ticks);
return;
}
if self.mod_use(&stack) {
self.tick_mod_use(slot, ticks, &stack);
return;
}
let Some(consumable) = info.consumable.as_ref() else {
self.using = None;
return;
};
// ItemStack.onUseTick: Consumable.shouldEmitParticlesAndSounds with
// the remaining ticks before this tick counts them down.
let duration = consumable.ticks().max(0) as u32;
let remaining = duration - ticks.min(duration);
// the duration is getUseDuration's when the use started.
let remaining = self.use_duration - ticks as i32;
let done = item_use::count_down(ticks, self.use_duration).is_none();
let ticks = ticks + 1;
if consumable.should_emit(remaining as i32) {
if consumable.should_emit(remaining) {
self.emit_consume(consumable, &stack, 5);
}
if ticks >= duration {
if done {
self.using = None;
self.send_use_item_complete();
// ItemStack.finishUsingItem's applyAfterUseComponentSideEffects.
self.apply_use_cooldown(&stack);
self.finish_consuming(&info, consumable, &stack);
self.use_item_finished(&stack);
} else {
self.using = Some((slot, ticks));
}
@@ -7659,7 +7734,12 @@ impl Game<'_> {
// GuiGraphicsExtractor.itemDecorations, laid out at the origin and
// moved to (x, y): the damage bar, the cooldown shade and the count.
let info = self.items().get(&stack.item);
let max_damage = info.map_or(0, |info| info.max_damage as i32);
// ItemStack.isBarVisible: isDamaged, which an unbreakable stack never is.
let max_damage = if horizon_client::stations::is_damageable(self.items(), stack) {
info.map_or(0, |info| info.max_damage as i32)
} else {
0
};
let cooldown =
horizon_client::cooldowns::group(stack, info.and_then(|i| i.use_cooldown.as_ref()));
let partial = self.clock.fraction() as f32;
@@ -7686,6 +7766,7 @@ impl Game<'_> {
));
draws.push(hud_layout::GuiDraw::Pop);
hud_layout::paint(batch, &self.gui.atlas, &draws);
self.native_item_decorations(stack, x, y, batch);
}
fn hud(&mut self, size: [u32; 2]) -> HudBatch {
// The pack prompt replaces the connecting screen (setScreen), but
@@ -10156,7 +10237,15 @@ impl Game<'_> {
self.mob_geometry(camera, alpha, &mut out);
// In first person only a shader pack's shadow pass draws the player.
self.mark_player(out.len(), !self.own_body_in_view());
let player_start = out.len();
self.player_model_geometry(camera, self.clock.camera_fraction(), &mut out);
// A native pack has no per-entity draws to hide it with: its shadow pass alone gets
// the first-person model.
if self.pack_ids.is_none() && !self.own_body_in_view() {
self.shadow_only_vertices
.borrow_mut()
.extend(out.drain(player_start..));
}
self.mark_entity(out.len(), "minecraft:player");
self.guest_model_geometry(camera, alpha, &mut out);
self.mark_entity(out.len(), "minecraft:evoker_fangs");
@@ -10171,9 +10260,11 @@ impl Game<'_> {
self.see_through_vertices = see_through;
// Entity shadows (EntityRenderer.extractShadow).
self.mark_translucent_entity(out.len(), "minecraft:entity_shadow");
let shadow_sprite = self
.settings
.entity_shadows
// A native shader pack's sun shadows already fall under the entities in the Overworld:
// the round blob would be a second one. The Nether and the End have no sun to cast them.
let pack_shadows =
self.dim == Dim::Overworld && self.renderer.as_ref().is_some_and(|r| r.native_shadow());
let shadow_sprite = (self.settings.entity_shadows && !pack_shadows)
.then(|| self.gui.atlas.sprites.get("misc/shadow").copied())
.flatten();
if let Some(sprite) = shadow_sprite {
@@ -11745,6 +11836,7 @@ impl Game<'_> {
self.boss_overlay.reset();
// GameRenderer.resetData (Minecraft.clearClientLevel).
self.item_activation.reset();
self.kept_block_entities.get_mut().clear();
self.bosses = Default::default();
self.streaming.paused = true;
self.world = World::new(self.res.facts().air, 0);
@@ -12986,6 +13078,18 @@ impl Game<'_> {
let attack = attacks[i];
let inverse_height =
horizon_client::first_person::equip_displacement(heights[i], scale);
let mut canceled = false;
self.native_render_hand(
hand,
item.as_ref(),
a,
attack,
inverse_height,
&mut canceled,
);
if canceled {
continue;
}
// The main hand on its arm, the off hand on the other one.
let left = self.settings.main_hand_left != (hand == Hand::Off);
let Some(stack) = item else {
@@ -13078,24 +13182,18 @@ impl Game<'_> {
.item_renderer
.models
.components(&horizon_client::item_render::stack_view(&stack));
let animation = components
let mut animation = components
.get("minecraft:consumable")
.map(horizon_client::first_person::consumable_animation);
if matches!(
.map_or(horizon_world::consumable::UseAnimation::None, |c| {
horizon_client::first_person::consumable_animation(c)
});
self.native_use_animation(&stack, &mut animation);
item_use::use_pose(
animation,
Some(
horizon_world::consumable::UseAnimation::Eat
| horizon_world::consumable::UseAnimation::Drink
)
) {
ItemPose::Eat {
remaining: owner.use_item_remaining_ticks as f32,
duration: owner.use_duration as f32,
partial: a,
}
} else {
ItemPose::Using
}
owner.use_item_remaining_ticks as f32,
owner.use_duration as f32,
a,
)
}
_ if spinning => ItemPose::SpinAttack,
// ItemInHandRenderer: a STAB swing of this hand jabs.
@@ -14529,6 +14627,12 @@ impl Game<'_> {
|| !self.block_action_restricted(h.pos)
|| self.can_place_on_in_adventure(h.pos)
})
.filter(|h| {
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
let mut canceled = false;
self.native_block_highlight(h.pos, state, alpha as f32, &mut canceled);
!canceled
})
.map(|h| {
let state = self.world.block(h.pos[0], h.pos[1], h.pos[2]);
let f = self.res.facts().get(state);
@@ -14618,6 +14722,7 @@ impl Game<'_> {
entities.clear();
self.see_through_vertices.clear();
self.glowing_vertices.get_mut().clear();
self.shadow_only_vertices.get_mut().clear();
if !title {
entities = horizon_client::spans::time("entity_geometry", || {
self.entity_geometry(camera, alpha, entities)
@@ -14698,6 +14803,7 @@ impl Game<'_> {
hud.patches.append(&mut self.map_patches);
let entity_spans = std::mem::take(self.entity_spans.get_mut());
let glowing = std::mem::take(self.glowing_vertices.get_mut());
let shadow_only = std::mem::take(self.shadow_only_vertices.get_mut());
let frame = Frame {
weather: &weather,
weather_time: horizon_client::weather::Time::new(self.world.game_time, alpha as f32),
@@ -14790,6 +14896,7 @@ impl Game<'_> {
},
additive: &additive,
see_through: &see_through,
shadow_only: &shadow_only,
celestial: if self.settings.horizon.sky || title {
&celestial
} else {
@@ -14857,6 +14964,7 @@ impl Game<'_> {
self.entity_vertices = entities;
*self.entity_spans.get_mut() = entity_spans;
*self.glowing_vertices.get_mut() = glowing;
*self.shadow_only_vertices.get_mut() = shadow_only;
self.see_through_vertices = see_through;
result
}
@@ -15859,6 +15967,63 @@ impl Game<'_> {
}
return;
}
// ScreenEvent.KeyPressed.Pre: the open screen hears of the key before
// it acts on it, and a cancelled press goes no further — no focus
// move, no Escape-close, no inventory or hotbar key, no typing by the
// key itself, no `native_key`. It fires after the window's own
// shortcuts (fullscreen, screenshot, the debug keys, the narrator) so
// those keep working with a mod that cancels; the key's release still
// comes through.
//
// A key's characters are a separate callback of GLFW's, and it comes
// after the key callback (`KeyboardHandler.charTyped`): they are taken
// off the key here and dispatched once the screen has acted on it, on
// the screen that is open then (`ScreenEvent.CharacterTyped.Pre`).
// Options keeps its text on the key: its own key path routes a key's
// text between its boxes and its key-bind capture, and no key that
// carries text closes that screen or moves its focus.
let mut typed: Option<String> = None;
if let WindowEvent::KeyboardInput {
event: key_event, ..
} = &mut event
&& key_event.state == ElementState::Pressed
{
if self.screen_hooks_open() && self.screen != Screen::Options {
typed = key_event.text.take().map(|text| text.to_string());
}
let canceled = match key_event.physical_key {
PhysicalKey::Code(key) => self.screen_key_pressed(key, key_event.repeat),
PhysicalKey::Unidentified(_) => false,
};
if self.screen == Screen::Options && self.screen_hooks_open() {
// The hook after the key's own, then the text the key path
// types in place (its own routing keeps it).
if let Some(text) = key_event.text.as_deref() {
let left = self.screen_text_typed(text);
if left.as_deref() != Some(text) {
key_event.text = left.map(Into::into);
}
}
}
if canceled {
// charTyped is a separate callback: the characters still
// reach the text box, without the key's own effects. Options'
// characters already passed the hook in place.
if let Some(text) = typed.as_deref() {
self.screen_chars_typed(Some(text));
} else if self.screen == Screen::Options
&& let Some(text) = key_event.text.as_deref()
&& self.types_text()
{
let text: String = text.chars().filter(|c| !c.is_control()).collect();
if !text.is_empty() {
// Err only says no box of the screen takes it.
let _ = self.type_chars_into_screen(&text);
}
}
return;
}
}
// Screen.keyPressed: Tab, the arrows and Enter move and press the
// menu's focus, unless the focused widget or the screen keeps them.
if let WindowEvent::KeyboardInput {
@@ -15868,6 +16033,9 @@ impl Game<'_> {
&& let PhysicalKey::Code(key) = key_event.physical_key
&& self.key_focus_key(event_loop, key)
{
// The keyboard's focus took the key; its characters are a separate
// callback and still come (`KeyboardHandler.charTyped`).
self.screen_chars_typed(typed.as_deref());
return;
}
let result: Result<()> = (|| {
@@ -15903,6 +16071,7 @@ impl Game<'_> {
return Ok(());
}
}
self.key_focus_mouse_press(&event);
match event {
WindowEvent::CloseRequested => {
if !self.settings.quit_shortcuts
@@ -16018,6 +16187,27 @@ impl Game<'_> {
}) * wheel_sensitivity
};
let d = scaled(raw_y) as f32;
// ScreenEvent.MouseScrolled.Pre: the open screen hears of
// the wheel before anything scrolls, and a cancelled turn
// scrolls nothing — no bundle, no list, slider or
// scrollbar. The hotbar below reads the wheel only while
// playing, which the gate excludes. A wheel the pad or the
// keyboard's focus made up is not the mouse: it fires
// nothing.
if !self.pad_synthetic_input()
&& let Some(screen) = self.screen_view_now()
{
let mut canceled = false;
self.native_screen_mouse_scrolled_pre(
&screen,
[scaled(raw_x), scaled(raw_y)],
glfw_modifiers(self.modifiers),
&mut canceled,
);
if canceled {
return Ok(());
}
}
if self.dialog_open() {
self.dialog_scroll(d);
} else if self.code_of_conduct.is_some() {
@@ -16411,7 +16601,9 @@ impl Game<'_> {
if let PhysicalKey::Code(key) = event.physical_key
&& self.search_tab_hotbar_key(key)
{
// The hovered slot took the key (`ignoreTextInput`).
// The hovered slot took the key, and its characters
// go nowhere (`ignoreTextInput`).
typed = None;
} else if let Some(shortcut) = self.edit_shortcut(&event) {
self.creative_search_shortcut(shortcut);
} else if let PhysicalKey::Code(key) = event.physical_key {
@@ -16853,6 +17045,10 @@ impl Game<'_> {
Ok(())
})();
// ScreenEvent.CharacterTyped.Pre: GLFW's char callback comes after its
// key callback, so a key's characters reach the screen the key left
// open, after the key's own effects (`KeyboardHandler.charTyped`).
self.screen_chars_typed(typed.as_deref());
if let Err(e) = result {
self.error = Some(e);
self.exit_on_error(event_loop);
@@ -17106,6 +17302,7 @@ impl<'a> Game<'a> {
dynamic_lights: Vec::new(),
luminance: None,
see_through_vertices: Vec::new(),
shadow_only_vertices: Default::default(),
entity_spans: Default::default(),
glowing_vertices: Default::default(),
pack_ids: None,
@@ -17117,6 +17314,7 @@ impl<'a> Game<'a> {
pad: Default::default(),
frames: 0,
glint_frame: Default::default(),
kept_block_entities: Default::default(),
item_level_cache: Default::default(),
world_frames: 0,
fps: 0.0,
@@ -17172,6 +17370,7 @@ impl<'a> Game<'a> {
attack_ticks: 100,
attack_item: None,
last_hurt_by_player: HashMap::new(),
lead_killers: HashMap::new(),
love_cause: HashMap::new(),
died: Default::default(),
be_anims: {
@@ -17180,6 +17379,7 @@ impl<'a> Game<'a> {
anims
},
using: None,
use_duration: 0,
view: Default::default(),
limiter: horizon_client::window::FramerateLimiter::new(0),
frame_clock: Default::default(),
@@ -17757,11 +17957,16 @@ impl Game<'_> {
/// packs, and `Resources.rest` stays in use). This re-reads the tables,
/// so it runs once per world open and on `/reload`.
fn build_world_tables(&self) -> Option<WorldTables> {
let files = functions::content_overlay(
// The ask of this load, both here and on `/reload`: a native mod
// recomputes the pack files it contributes every time.
let mut contributed = Vec::new();
self.native_data_pack_files(&mut contributed);
let files = functions::overlay_files(
&self.world_root,
&self.options.resources,
self.cmds.vanilla_disabled,
&self.level.enabled_features,
contributed,
);
if files.is_empty() {
return None;
@@ -18080,7 +18285,7 @@ fn gameplay_tables(
))
.map_err(|e| e.to_string())?;
let (facts, gameplay_projection) =
BlockFacts::load_with_projection(root, assets.registry, &behaviour_json)
BlockFacts::load_with_projection(root, assets.registry, &behaviour_json, parallel)
.map_err(|e| e.to_string())?;
horizon_client::block_contact::set_mod_blocks(
facts
@@ -18122,6 +18327,8 @@ fn load_resources(
publish: &mut dyn FnMut(UiResources, &std::path::Path),
) -> Result<Loaded> {
let threads = settings.horizon.worker_threads();
horizon_client::sprites::set_texture_budget(settings.horizon.texture_budget_mib);
horizon_client::sprites::set_labpbr_maps(shader_packs::picks_native_pack(settings));
// Stage A: the menu bundle (atlas and panorama). With a loading screen it is
// published at once so the title screen can appear while the world bundle
// loads; the synchronous launch instead carries it in `Loaded`.
@@ -18470,6 +18677,12 @@ fn prepare_renderer_textures(pack: &PackResources) -> horizon_client::gfx::Prepa
&assets.sprite_mips,
(&pack.forcefield.2, pack.forcefield.0),
)
.with_pbr(
&assets.pbr_normals,
&assets.pbr_specular,
assets.pbr_size,
assets.texture_layers,
)
}
/// Upload pack textures and create the renderer on the window's device.
@@ -18492,6 +18705,8 @@ fn build_renderer(
// Chinese and Japanese menu labels go blank when the loading screen hands
// over to the game.
atlas.font.reset_cache();
// Made here when the worker did not: the renderer's own fallback has no LabPBR maps.
let prepared = Some(prepared.unwrap_or_else(|| prepare_renderer_textures(pack)));
let mut renderer = pollster::block_on(Renderer::new(
window,
offscreen,
@@ -18651,6 +18866,7 @@ fn loading_frame(hud: &HudBatch) -> Frame<'_> {
lightning: Default::default(),
additive: &[],
see_through: &[],
shadow_only: &[],
celestial: &[],
stars: None,
hand: &[],
@@ -19929,6 +20145,7 @@ mod tests {
velocity: Vec3::ZERO,
age: 5,
no_gravity: false,
portal: Default::default(),
drop_item: true,
fall_distance: 0.0,
stuck: Vec3::ZERO,
+13 -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 }))
+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
@@ -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<'_> {
+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);
+3
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;
@@ -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()),
}
}
+25 -11
View File
@@ -77,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()
}
@@ -2014,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", &[])),
};
@@ -2033,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" => {
@@ -2125,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", &[])),
};
@@ -3569,6 +3569,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};
+88 -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);
@@ -675,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" => {
+49 -8
View File
@@ -1220,6 +1220,9 @@ impl Game<'_> {
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 {
@@ -1467,6 +1470,7 @@ impl Game<'_> {
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");
@@ -4891,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;
@@ -4980,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,
@@ -5482,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;
@@ -5501,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,
@@ -5536,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);
@@ -5562,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
+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
}
+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);
}
+1 -3
View File
@@ -392,9 +392,7 @@ impl Game<'_> {
// EnderEyeItem.use: startUsingItem (getUseDuration 0, so the use
// lasts until the button lets go), then the eye is thrown towards
// the nearest stronghold if there is one (CONSUME without).
if self.using.is_none() {
self.using = Some((self.held_slot(), 0));
}
self.start_using_item();
self.throw_ender_eye();
return Ok(true);
};
@@ -17,6 +17,9 @@ impl Game<'_> {
// Spans tell the pack's passes which entities are hidden; without them, nothing changes.
self.pack_shadow_casters = match (&self.renderer, &self.pack_ids) {
(Some(r), Some(_)) => r.pack_shadow_casters(),
// A native pack's shadow pass has no spans to hide entities with: it only needs
// the first-person model.
(Some(r), None) => (false, r.native_shadow()),
_ => (false, false),
};
let at = camera.to_array().map(|v| v.floor() as i32);
@@ -5,18 +5,15 @@ use horizon_core::{geometry::Vec3, position::BlockPos, random::RandomSource};
use horizon_world::level::LevelEvent;
impl Game<'_> {
pub(super) fn finish_falling_block(&mut self, mut block: FallingBlock, pos: [i32; 3]) {
/// `FallingBlockEntity.causeFallDamage`: impact damage and anvil wear,
/// before block effects and portal handling can transfer the entity.
pub(super) fn falling_block_impact(&mut self, block: &mut FallingBlock) {
let name = self.res.facts().get(block.state).name.clone();
let anvil = name.ends_with("anvil");
let stalactite = matches!(
&*name,
"minecraft:pointed_dripstone" | "minecraft:sulfur_spike"
);
let sound_event = match &*name {
"minecraft:pointed_dripstone" => Some(1045),
"minecraft:sulfur_spike" => Some(1052),
_ => None,
};
if let Some(impact) = block.damage.impact(block.fall_distance) {
if impact.amount > 0.0 {
let mob_kind = if stalactite {
@@ -79,6 +76,16 @@ impl Game<'_> {
}
}
}
}
pub(super) fn finish_falling_block(&mut self, block: FallingBlock, pos: [i32; 3]) {
let name = self.res.facts().get(block.state).name.clone();
let anvil = name.ends_with("anvil");
let sound_event = match &*name {
"minecraft:pointed_dripstone" => Some(1045),
"minecraft:sulfur_spike" => Some(1052),
_ => None,
};
if block.damage.cancel_drop {
if let Some(id) = sound_event {
self.level_events.push(LevelEvent::LevelEvent {
+1 -2
View File
@@ -184,8 +184,7 @@ impl Game<'_> {
}
/// `SpyglassItem.use`: start scoping.
pub(super) fn use_spyglass(&mut self) {
if self.using.is_none() {
self.using = Some((self.held_slot(), 0));
if self.start_using_item() {
let at = self.eye_position();
self.audio.play("minecraft:item.spyglass.use", at, 1.0, 1.0);
}
+18 -4
View File
@@ -300,19 +300,33 @@ impl Game<'_> {
let s = f.step().map(f64::from);
let c = center(f);
let at = [c[0] + s[0] * 0.15, c[1], c[2] + s[2] * 0.15];
let mut drops = Vec::new();
if with_frame {
let frame = ItemStack {
drops.push(ItemStack {
item: Arc::from(f.kind()),
count: 1,
damage: 0,
extra: None,
};
self.spawn_item(frame, at, [0.0; 3], 10);
});
}
if let Some(item) = f.item.clone()
&& self.world.random.next_float() < f.drop_chance
{
self.spawn_item(item, at, [0.0; 3], 10);
drops.push(item);
}
if by_player && !drops.is_empty() {
let tool = self.held().cloned();
self.native_use_drops(
"",
c,
None,
Some((f.id, f.kind())),
tool.as_ref(),
&mut drops,
);
}
for stack in drops {
self.spawn_item(stack, at, [0.0; 3], 10);
}
}
/// `ServerExplosion.hurtEntities` for item frames: a frame within the blast
+125
View File
@@ -96,6 +96,48 @@ pub(super) fn content_overlay(
DataStore::load(&packs).files.into_iter().collect()
}
/// The files the content tables load from: the world's enabled packs'
/// (`content_overlay`) with what native mods contribute for this load
/// (`native_data_pack_files`) below them. `PackOverlay::top` is a path's last
/// file, so a pack's file still overrides a mod's for the same path, and a
/// later contribution overrides an earlier one, as packs do. `Game` builds
/// its tables through here on a world's first open and on `/reload`, so the
/// mods are asked once per load.
pub(super) fn overlay_files(
world_root: &std::path::Path,
resources: &std::path::Path,
vanilla_disabled: bool,
enabled_features: &[String],
contributed: Vec<(String, Vec<u8>)>,
) -> Vec<(String, Vec<Vec<u8>>)> {
let mut files = content_overlay(world_root, resources, vanilla_disabled, enabled_features);
if contributed.is_empty() {
return files;
}
// A path's mod files in contribution order, under the pack files.
let mut mods: std::collections::BTreeMap<String, Vec<Vec<u8>>> = Default::default();
for (path, bytes) in contributed {
// One bad file would fail its whole table and drop the world's pack
// content with it, so it is skipped alone: every kind is JSON.
if let Err(e) = serde_json::from_slice::<serde_json::Value>(&bytes) {
log::warn!("Horizon: a native mod's data pack file {path} is not JSON, skipped: {e}");
continue;
}
mods.entry(path).or_default().push(bytes);
}
for (path, versions) in files.iter_mut() {
let Some(under) = mods.remove(path.as_str()) else {
continue;
};
let mut merged = under;
merged.append(versions);
*versions = merged;
}
// The paths only a mod has.
files.extend(mods);
files
}
impl FnState {
/// The level function files compile at (`ServerFunctionLibrary`'s
/// `functionCompilationPermissions`): `function-permission-level` on a
@@ -948,4 +990,87 @@ mod tests {
let _ = std::fs::remove_dir_all(&dir);
}
}
/// What a native mod contributes (`native_data_pack_files`) joins the load
/// the tables are built from, a world's first open and `/reload` alike
/// (`Game::build_world_tables` is both), and sits below the world's own
/// packs. Each load asks again, so it is the current contribution that
/// counts.
#[test]
fn contributed_files_join_the_load_below_the_worlds_packs() {
let dir = std::env::temp_dir().join(format!("horizon-contributed-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let pack = dir.join("datapacks").join("pack");
std::fs::create_dir_all(pack.join("data/pkg/tags/item")).unwrap();
std::fs::write(pack.join("pack.mcmeta"), "{}").unwrap();
std::fs::write(
pack.join("data/pkg/tags/item/both.json"),
r#"{"replace": true, "values": ["pkg:from_pack"]}"#,
)
.unwrap();
let settings = serde_json::json!({"enabled": ["file/pack"], "disabled": []});
std::fs::write(dir.join("datapacks.json"), settings.to_string()).unwrap();
let tag = |entry: &str| format!(r#"{{"values": ["{entry}"]}}"#).into_bytes();
let files = overlay_files(
&dir,
std::path::Path::new(""),
true,
&[],
vec![
("tags/pkg:item/both".to_owned(), tag("pkg:from_mod")),
("tags/pkg:item/mod_only".to_owned(), tag("pkg:only_mod")),
],
);
// The pack's file is the path's last (`PackOverlay::top`), and its
// `replace` drops what the mod put below it.
let (_, versions) = files
.iter()
.find(|(id, _)| id == "tags/pkg:item/both")
.unwrap();
assert!(horizon_world::loot::PackOverlay::top(versions).starts_with(b"{\"replace\""));
let overlay = horizon_world::loot::PackOverlay { files: &files };
let tags = horizon_items::items::load_tags(&dir, "item", &overlay).unwrap();
assert!(tags["pkg:both"].contains("pkg:from_pack"));
assert!(!tags["pkg:both"].contains("pkg:from_mod"));
// A path only the mod has reaches the tables too.
assert_eq!(
tags["pkg:mod_only"].iter().collect::<Vec<_>>(),
["pkg:only_mod"]
);
// The next load asks again: the new contribution, not the first's.
let files = overlay_files(
&dir,
std::path::Path::new(""),
true,
&[],
vec![("tags/pkg:item/mod_only".to_owned(), tag("pkg:second_load"))],
);
let overlay = horizon_world::loot::PackOverlay { files: &files };
let tags = horizon_items::items::load_tags(&dir, "item", &overlay).unwrap();
assert_eq!(
tags["pkg:mod_only"].iter().collect::<Vec<_>>(),
["pkg:second_load"]
);
// A file that isn't JSON is skipped alone, not the whole load.
let files = overlay_files(
&dir,
std::path::Path::new(""),
true,
&[],
vec![
("tags/pkg:item/mod_only".to_owned(), b"{not json".to_vec()),
("tags/pkg:item/good".to_owned(), tag("pkg:good")),
],
);
let overlay = horizon_world::loot::PackOverlay { files: &files };
let tags = horizon_items::items::load_tags(&dir, "item", &overlay).unwrap();
assert!(!tags.contains_key("pkg:mod_only"));
assert!(tags["pkg:good"].contains("pkg:good"));
// With nothing contributed the overlay is the packs' own.
assert_eq!(
overlay_files(&dir, std::path::Path::new(""), true, &[], Vec::new()),
content_overlay(&dir, std::path::Path::new(""), true, &[])
);
let _ = std::fs::remove_dir_all(&dir);
}
}
+7
View File
@@ -94,6 +94,13 @@ impl Game<'_> {
pub(super) fn ime_commit(&mut self, text: &str) {
preedit::set_preedit(None);
let text: String = text.chars().filter(|c| !c.is_control()).collect();
// ScreenEvent.CharacterTyped.Pre: the input method's committed text
// reaches a text box by characters, as a key's own text does, so the
// open screen hears of each of them first and a cancelled one never
// reaches the box.
let Some(text) = self.screen_text_typed(&text) else {
return;
};
if text.is_empty() || !self.text_field_focused() {
return;
}
+5 -2
View File
@@ -268,10 +268,13 @@ impl Game<'_> {
&& self.stack_in(slot) == Some(stack)
{
let view = item_render::stack_view(stack);
let duration = use_duration(&self.pack.item_renderer.models.components(&view));
let mut duration = use_duration(&self.pack.item_renderer.models.components(&view));
self.native_use_duration(stack, &mut duration);
owner.using_this_item = true;
owner.use_duration = duration;
owner.use_item_remaining_ticks = duration - ticks as i32;
// getUseItemRemainingTicks: the duration the use started with,
// as LivingEntityUseItemEvent.Start left it (`start_using_item`).
owner.use_item_remaining_ticks = self.use_duration - ticks as i32;
owner.crossbow_charge_duration = Self::charge_ticks(stack).max(1) as i32;
}
owner.fishing_cast_with_this_item = stack
+218
View File
@@ -0,0 +1,218 @@
//! `LivingEntity.startUsingItem` and the use over time of items vanilla's
//! `Item` subclasses don't know: what `Item.getUseDuration` answers, a
//! native mod's own item counted down to its `finishUsingItem`, and the hand
//! pose of a `getUseAnimation` (`native_use_duration`, `native_use_animation`,
//! `native_living_entity_use_item_start`, `native_living_entity_use_item_finish`).
use super::Game;
use horizon_client::{items::ItemStack, spear::Kinetic};
use horizon_world::consumable::UseAnimation;
impl Game<'_> {
/// `LivingEntity.startUsingItem` for the acting player's hand: nothing
/// when a use runs already or the hand is empty. The use lasts
/// `getUseDuration` ticks, which mods may change or refuse
/// (`LivingEntityUseItemEvent.Start`), on both sides as in vanilla. True
/// when the use started.
pub(super) fn start_using_item(&mut self) -> bool {
let Some(stack) = self.held().cloned().filter(|_| self.using.is_none()) else {
return false;
};
let mut duration = self.use_duration(&stack);
let mut canceled = false;
let hand = self.hand;
self.native_living_entity_use_item_start(&stack, None, hand, &mut duration, &mut canceled);
if canceled {
return false;
}
self.using = Some((self.held_slot(), 0));
self.use_duration = duration;
true
}
/// `ItemStack.getUseDuration`: a consumable's time, 72000 for what is
/// held until let go, the fixed times of brushes, bundles, spyglasses and
/// goat horns, 0 for the rest, then a mod's answer.
pub(super) fn use_duration(&self, stack: &ItemStack) -> i32 {
let info = self.items().get(&stack.item);
let mut duration = match &*stack.item {
"minecraft:bow" | "minecraft:crossbow" | "minecraft:trident" => 72_000,
"minecraft:spyglass" => 1200,
"minecraft:brush" => 200,
_ if info.is_some_and(|i| i.bundle) => 200,
_ if let Some(i) = self.res.items().instrument(stack) => i.use_ticks(),
_ => match info {
Some(i) if let Some(c) = &i.consumable => c.ticks(),
Some(i) if i.blocks_attacks.is_some() => 72_000,
_ if Kinetic::of(&stack.item).is_some() => 72_000,
_ => 0,
},
};
self.native_use_duration(stack, &mut duration);
duration
}
/// Whether `stack` is a mod's item that only a mod uses over time: no
/// `consumable`, `blocks_attacks` or kinetic weapon of vanilla's own, and
/// a `native_use_duration` above 0. Its use is its own `Item.use`.
pub(super) fn mod_use(&self, stack: &ItemStack) -> bool {
!stack.item.starts_with("minecraft:")
&& self
.items()
.get(&stack.item)
.is_some_and(|i| i.consumable.is_none() && i.blocks_attacks.is_none())
&& Kinetic::of(&stack.item).is_none()
&& self.use_duration(stack) > 0
}
/// A right click with a `mod_use` item: `startUsingItem` (CONSUME).
pub(super) fn start_mod_use(&mut self, stack: &ItemStack) -> bool {
if !self.mod_use(stack) {
return false;
}
self.start_using_item();
true
}
/// `LivingEntity.updateUsingItem` for a `mod_use` item used `ticks` so far:
/// `completeUsingItem` once its duration ran out.
pub(super) fn tick_mod_use(&mut self, slot: usize, ticks: u32, stack: &ItemStack) {
if let Some(ticks) = count_down(ticks, self.use_duration) {
self.using = Some((slot, ticks));
return;
}
self.using = None;
self.send_use_item_complete();
// in ItemStack.finishUsingItem, Item's own gives the stack back.
self.apply_use_cooldown(stack);
self.use_item_finished(stack);
}
/// `LivingEntityUseItemEvent.Finish` after `finishUsingItem` of `stack`
/// (no ticks left): the hand holds what a mod made the result.
pub(super) fn use_item_finished(&mut self, stack: &ItemStack) {
let mut result = self.held().cloned();
let before = result.clone();
let hand = self.hand;
self.native_living_entity_use_item_finish(stack, None, hand, 0, &mut result);
if result != before {
*self.held_mut() = result.filter(|s| s.count > 0);
}
}
/// `Player.canEat(canAlwaysEat)` for `stack`, food that is eaten only
/// when hungry, then a mod's answer.
pub(super) fn can_eat(&self, stack: &ItemStack, can_always_eat: bool) -> bool {
let mut can_eat = can_always_eat
|| self.creative
|| self
.player
.as_ref()
.is_some_and(|p| p.health.food.food < 20);
self.native_can_eat(stack, &mut can_eat);
can_eat
}
}
/// `Consumable.startConsuming`: a time of 0 or less is no use, the item is
/// consumed at once (`onConsume`).
pub(super) fn consumes_at_once(consume_ticks: i32) -> bool {
consume_ticks <= 0
}
/// `updateUsingItem`'s `--useItemRemaining == 0` for a use `ticks` old of
/// `duration` ticks: the ticks used after this one, none on the tick it runs
/// out. A time of 0 or less, or one a mod cut below what was used, never
/// does: the use lasts until let go.
pub(super) fn count_down(ticks: u32, duration: i32) -> Option<u32> {
let ticks = ticks + 1;
(i64::from(duration) - i64::from(ticks) != 0).then_some(ticks)
}
/// The first person pose of a hand using an item of `animation`
/// (`ItemInHandRenderer.renderArmWithItem`'s switch), for an item of no
/// pose of its own.
pub(super) fn use_pose(
animation: UseAnimation,
remaining: f32,
duration: f32,
partial: f32,
) -> horizon_client::first_person::ItemPose {
use horizon_client::first_person::ItemPose;
let ticks_used = duration - (remaining - partial + 1.0);
match animation {
UseAnimation::Eat | UseAnimation::Drink => ItemPose::Eat {
remaining,
duration,
partial,
},
UseAnimation::Bow => ItemPose::Bow { ticks_used },
UseAnimation::Trident => ItemPose::Trident { ticks_used },
UseAnimation::Brush => ItemPose::Brush { remaining, partial },
_ => ItemPose::Using,
}
}
#[cfg(test)]
mod tests {
use super::{consumes_at_once, count_down, use_pose};
use horizon_client::first_person::ItemPose;
use horizon_world::consumable::UseAnimation;
#[test]
fn a_use_runs_out_on_its_last_tick() {
// a 32 tick food: ticks 0 to 30 go on, the 32nd tick finishes it.
assert_eq!(count_down(0, 32), Some(1));
assert_eq!(count_down(30, 32), Some(31));
assert_eq!(count_down(31, 32), None);
// a one tick use finishes on its first tick.
assert_eq!(count_down(0, 1), None);
// no time, or one below what was used, waits for the release.
assert_eq!(count_down(0, 0), Some(1));
assert_eq!(count_down(10, 4), Some(11));
assert_eq!(count_down(5, 72_000), Some(6));
}
#[test]
fn a_started_use_finishes_after_its_duration() {
// start at 0 ticks, count down each tick, finish when it runs out.
for duration in [1, 16, 32, 40] {
let (mut ticks, mut ticked) = (0, 0);
while let Some(next) = count_down(ticks, duration) {
ticks = next;
ticked += 1;
}
assert_eq!(ticked + 1, duration);
}
}
#[test]
fn a_food_of_no_time_is_eaten_at_once() {
assert!(consumes_at_once(0));
assert!(consumes_at_once(-3));
assert!(!consumes_at_once(1));
assert!(!consumes_at_once(32));
}
#[test]
fn a_use_animation_picks_the_hand_pose() {
let pose = |animation| use_pose(animation, 12.0, 32.0, 0.5);
assert!(matches!(
pose(UseAnimation::Drink),
ItemPose::Eat { remaining, duration, partial }
if (remaining, duration, partial) == (12.0, 32.0, 0.5)
));
// ticks used: 32 - (12 - 0.5 + 1).
assert!(
matches!(pose(UseAnimation::Bow), ItemPose::Bow { ticks_used } if ticks_used == 19.5)
);
assert!(matches!(
pose(UseAnimation::Trident),
ItemPose::Trident { ticks_used } if ticks_used == 19.5
));
assert!(matches!(
pose(UseAnimation::Brush),
ItemPose::Brush { remaining, partial } if (remaining, partial) == (12.0, 0.5)
));
for animation in [
UseAnimation::None,
UseAnimation::Block,
UseAnimation::Spyglass,
] {
assert!(matches!(pose(animation), ItemPose::Using));
}
}
}
+201 -6
View File
@@ -42,6 +42,7 @@ pub(super) struct GuestPlay {
mining: Option<Mining>,
destroy_delay: u32,
using: Option<(usize, u32)>,
use_duration: i32,
use_delay: u32,
/// The shield's ItemCooldowns entry: an axe disabled it for this many ticks.
pub(super) shield_cooldown: u32,
@@ -172,6 +173,7 @@ pub(super) struct GuestSwap {
mining: Option<Mining>,
destroy_delay: u32,
using: Option<(usize, u32)>,
use_duration: i32,
use_delay: u32,
pub(super) shield_cooldown: u32,
/// The guest's item cooldowns (`Game::cooldowns` for its turn).
@@ -412,6 +414,7 @@ impl Game<'_> {
motion.sprinting = g.sprinting;
motion.may_fly = g.creative() || g.game_mode == SPECTATOR;
motion.flying = g.flying;
motion.flying_speed = g.flying_speed;
// FireworkRocketItem.use boosts only a gliding player.
motion.fall_flying = g.fall_flying;
motion.invulnerable = motion.may_fly;
@@ -466,6 +469,7 @@ impl Game<'_> {
let mining = std::mem::replace(&mut self.mining, play.mining.take());
let destroy_delay = std::mem::replace(&mut self.destroy_delay, play.destroy_delay);
let using = std::mem::replace(&mut self.using, play.using.take());
let use_duration = std::mem::replace(&mut self.use_duration, play.use_duration);
let use_delay = std::mem::replace(&mut self.use_delay, play.use_delay);
let shield_cooldown = std::mem::replace(&mut self.shield_cooldown, play.shield_cooldown);
let cooldowns = std::mem::replace(&mut self.cooldowns, std::mem::take(&mut play.cooldowns));
@@ -579,6 +583,7 @@ impl Game<'_> {
mining,
destroy_delay,
using,
use_duration,
use_delay,
shield_cooldown,
cooldowns,
@@ -654,6 +659,7 @@ impl Game<'_> {
mining,
destroy_delay,
using,
use_duration,
use_delay,
shield_cooldown,
cooldowns,
@@ -771,6 +777,7 @@ impl Game<'_> {
play.mining = std::mem::replace(&mut self.mining, mining);
play.destroy_delay = std::mem::replace(&mut self.destroy_delay, destroy_delay);
play.using = std::mem::replace(&mut self.using, using);
play.use_duration = std::mem::replace(&mut self.use_duration, use_duration);
play.use_delay = std::mem::replace(&mut self.use_delay, use_delay);
play.shield_cooldown = std::mem::replace(&mut self.shield_cooldown, shield_cooldown);
play.cooldowns = std::mem::replace(&mut self.cooldowns, cooldowns);
@@ -1183,6 +1190,12 @@ impl Game<'_> {
);
return false;
}
// A mod menu type only for a client that has it: as
// `ServerPlayer.openMenu` with no menu, nothing opens.
let brand = host.hub.guest(conn).map_or("", |g| g.brand.as_str());
if !horizon_client::containers::menu_reaches(brand, menu) {
return false;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let hidden = self.native_hidden_slots(pos, items.len());
@@ -2462,6 +2475,25 @@ impl Game<'_> {
}
}
/// Whether a block outline occludes the straight line from `eye` to `point`.
fn block_between(&self, eye: [f64; 3], point: [f64; 3]) -> bool {
let d = [point[0] - eye[0], point[1] - eye[1], point[2] - eye[2]];
let dist = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if dist < 1e-9 {
return false;
}
let dir = [d[0] / dist, d[1] / dist, d[2] / dist];
self.clip_ray(eye, dir, dist).is_some_and(|h| {
let hd = [
h.point[0] - eye[0],
h.point[1] - eye[1],
h.point[2] - eye[2],
];
let hit_dist = (hd[0] * hd[0] + hd[1] * hd[1] + hd[2] * hd[2]).sqrt();
hit_dist < dist - 1e-6
})
}
/// Aim the swapped-in player at an entity; returns where it aims and the
/// eye. A boat, minecart, armor stand, item frame or painting is aimed at
/// the point the client hit (the interact packet's location) when that
@@ -2502,12 +2534,13 @@ impl Game<'_> {
let on = (0..3).all(|k| (point[k] - inside[k]).abs() < 0.1);
Some(if on { inside } else { middle })
};
let centre = match self.mobs.iter().find(|m| m.id as i32 == id) {
Some(m) => [
m.position.x,
m.position.y + m.kind.height * 0.5,
m.position.z,
],
let mob_box = self.mobs.iter().find(|m| m.id as i32 == id).map(|m| {
let b = m.bounds();
let (mn, mx) = (b.min_position(), b.max_position());
([mn.x, mn.y, mn.z], [mx.x, mx.y, mx.z])
});
let centre = match mob_box {
Some((lo, hi)) => visible_aim_point(eye, lo, hi, |p| self.block_between(eye, p)),
None if self.object_target(id).is_some() => object()?,
// A dragon part or an end crystal (game/dragon.rs).
None => {
@@ -2750,6 +2783,30 @@ impl Game<'_> {
command: &str,
signed: Option<Vec<(String, horizon_client::lan::hub::chat::ChatMessage)>>,
) {
// The mods hear every command first, and may stop it.
let mut canceled = false;
let mut replies = Vec::new();
if let Some((uuid, name)) = host.hub.guest(conn).map(|g| (g.uuid, g.name.clone())) {
self.native_player_command(
uuid,
&name,
command.trim_start_matches('/'),
&mut canceled,
&mut replies,
);
}
for line in replies {
host.hub.send_to(
conn,
&C::SystemChat(play::SystemChat {
content: horizon_client::lan::data::component_nbt(&line.component()),
overlay: false,
}),
);
}
if canceled {
return;
}
if self.guest_message_command(host, conn, command, signed.as_ref()) {
return;
}
@@ -3086,6 +3143,7 @@ impl Game<'_> {
// restartClientLoadTimerAfterRespawn.
g.dead = false;
g.load_timeout = horizon_client::lan::guest::CLIENT_LOADED_TIMEOUT;
g.loading = true;
g.death_time = 0;
g.health = 20.0;
g.food = 20;
@@ -4735,3 +4793,140 @@ mod bundle_slot_tests {
assert_eq!(bundle_inventory_slot(45, None), Some(45));
}
}
/// Where the server aims a guest's attack or interact on a mob. The packet names only the
/// entity, so the server picked the mob's centre; a slab, trapdoor, stair or fence between the
/// eyes and that centre then hid a mob the guest's own crosshair hit through the gap (#5772).
/// This returns the centre when it is visible, else the visible point of the box nearest to it (the one nearer the eyes when two are equally near)
/// (`occluded` tells whether a block outline hides a point from the eyes); the centre again
/// when the whole box is hidden.
fn visible_aim_point(
eye: [f64; 3],
lo: [f64; 3],
hi: [f64; 3],
occluded: impl Fn([f64; 3]) -> bool,
) -> [f64; 3] {
let centre = [0, 1, 2].map(|k| (lo[k] + hi[k]) / 2.0);
if !occluded(centre) {
return centre;
}
let margin = [0, 1, 2].map(|k| ((hi[k] - lo[k]) / 2.0).min(0.02));
let across = |k: usize, steps: usize| {
(0..=steps)
.map(|i| {
lo[k] + margin[k] + (hi[k] - lo[k] - 2.0 * margin[k]) * i as f64 / steps as f64
})
.collect::<Vec<f64>>()
};
let (xs, ys, zs) = (across(0, 2), across(1, 4), across(2, 2));
let mut candidates = Vec::with_capacity(xs.len() * ys.len() * zs.len());
for &x in &xs {
for &y in &ys {
for &z in &zs {
candidates.push([x, y, z]);
}
}
}
let nearness = |p: &[f64; 3]| (0..3).map(|k| (p[k] - centre[k]).powi(2)).sum::<f64>();
let from_eye = |p: &[f64; 3]| (0..3).map(|k| (p[k] - eye[k]).powi(2)).sum::<f64>();
candidates.sort_by(|a, b| {
nearness(a)
.total_cmp(&nearness(b))
.then(from_eye(a).total_cmp(&from_eye(b)))
.then(a[1].total_cmp(&b[1]))
.then(a[0].total_cmp(&b[0]))
.then(a[2].total_cmp(&b[2]))
});
candidates
.into_iter()
.find(|p| !occluded(*p))
.unwrap_or(centre)
}
#[cfg(test)]
mod aim_point_tests {
use super::visible_aim_point;
const EYE: [f64; 3] = [-760.7, 70.27, -450.4];
// A zombie one block behind a top slab: its centre is above the 0.5 gap under the slab.
const LO: [f64; 3] = [-763.0, 70.0, -450.9];
const HI: [f64; 3] = [-762.4, 71.95, -450.3];
/// A top slab filling x in [-762, -761] with its underside at y 70.5: a point is hidden when
/// the line from the eyes is above 70.5 anywhere inside that column.
fn behind_top_slab(p: [f64; 3]) -> bool {
let y_at = |x: f64| EYE[1] + (p[1] - EYE[1]) * (x - EYE[0]) / (p[0] - EYE[0]);
p[0] < -762.0 && (y_at(-761.0) > 70.5 || y_at(-762.0) > 70.5)
}
#[test]
fn a_visible_centre_is_kept() {
let centre = [
(LO[0] + HI[0]) / 2.0,
(LO[1] + HI[1]) / 2.0,
(LO[2] + HI[2]) / 2.0,
];
assert_eq!(visible_aim_point(EYE, LO, HI, |_| false), centre);
}
/// A bottom slab hides everything under y 70.9: the aim goes above it, through the gap over the slab.
#[test]
fn a_mob_behind_a_bottom_slab_is_aimed_at_above_the_slab() {
let centre = [
(LO[0] + HI[0]) / 2.0,
(LO[1] + HI[1]) / 2.0,
(LO[2] + HI[2]) / 2.0,
];
let p = visible_aim_point(EYE, LO, HI, |pt| pt[1] < 70.9);
assert!(p[1] >= 70.9, "{p:?} is still under the slab");
assert!(p[1] > centre[1] - 0.5, "{p:?} is far from the centre");
}
/// With only the centre hidden, two equally near candidates go to the one nearer the eyes.
#[test]
fn an_equally_near_point_on_the_side_of_the_eyes_wins() {
let eye = [5.0, 70.5, 0.0];
let (lo, hi) = ([-0.3, 70.0, -0.3], [0.3, 71.9, 0.3]);
let centre = [
(lo[0] + hi[0]) / 2.0,
(lo[1] + hi[1]) / 2.0,
(lo[2] + hi[2]) / 2.0,
];
let p = visible_aim_point(eye, lo, hi, |pt| pt == centre);
assert!(p[0] > centre[0], "{p:?} is on the far side from the eyes");
}
#[test]
fn a_mob_seen_only_through_a_gap_is_aimed_at_through_the_gap() {
let centre = [
(LO[0] + HI[0]) / 2.0,
(LO[1] + HI[1]) / 2.0,
(LO[2] + HI[2]) / 2.0,
];
assert!(behind_top_slab(centre), "the slab hides the centre");
let p = visible_aim_point(EYE, LO, HI, behind_top_slab);
assert!(!behind_top_slab(p), "{p:?} is still hidden");
for k in 0..3 {
assert!(LO[k] <= p[k] && p[k] <= HI[k], "{p:?} is outside the mob");
}
assert!(p[1] < centre[1], "{p:?} is not lower than the centre");
}
#[test]
fn a_fully_hidden_mob_keeps_the_centre() {
let centre = [
(LO[0] + HI[0]) / 2.0,
(LO[1] + HI[1]) / 2.0,
(LO[2] + HI[2]) / 2.0,
];
assert_eq!(visible_aim_point(EYE, LO, HI, |_| true), centre);
}
#[test]
fn the_choice_is_deterministic() {
assert_eq!(
visible_aim_point(EYE, LO, HI, behind_top_slab),
visible_aim_point(EYE, LO, HI, behind_top_slab)
);
}
}
@@ -6,7 +6,7 @@ use horizon_client::lan::hub::Hub;
use horizon_client::particles::{ParticleOptions, ParticleType};
use horizon_core::{geometry::Vec3, random::RandomSource};
use horizon_protocol::{
entity_data::{Particle, ParticleData},
entity_data::{MOD_PARTICLE_ID, Particle, ParticleData},
packets::play::{self, Clientbound as C},
};
use horizon_world::registry::BlockStateId;
@@ -28,6 +28,7 @@ fn send_native_nearby(
host: &LanHost,
dimension: &str,
packet: Vec<u8>,
horizon_only: bool,
within_range: impl Fn([f64; 3]) -> bool,
) {
for guest in host
@@ -36,10 +37,22 @@ fn send_native_nearby(
.iter()
.filter(|g| g.placed && g.dimension == dimension && within_range(g.position))
{
// A vanilla client cannot decode a native mod's particle id; see
// `mod_particle_only_for_horizon`.
if horizon_only && guest.brand != "horizon" {
continue;
}
guest.sender.send(packet.clone());
}
}
/// A native mod's particle travels as `MOD_PARTICLE_ID` (-1) plus its name.
/// Vanilla reads the id with `IdMap.byIdOrThrow`, which throws on -1 and
/// drops the connection, so such a particle may only go to Horizon clients.
fn mod_particle_only_for_horizon(particle: &Particle) -> bool {
matches!(particle.data, Some(ParticleData::Mod(_)))
}
/// One of a packet's `count` particles (`handleParticleEvent`): its position
/// and speed. DEFAULT (0) draws both from a Gaussian; ALTERNATIVE (1) and
/// ALTERNATIVE_WITH_SPEED (2) spread over `[0, dist)`, the first keeping
@@ -152,6 +165,7 @@ impl Game<'_> {
else {
return;
};
let horizon_only = mod_particle_only_for_horizon(&particle);
let packet = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
@@ -163,6 +177,9 @@ impl Game<'_> {
randomization: 0,
});
for g in host.hub.guests.iter().filter(|g| host.hub.here(g)) {
if horizon_only && g.brand != "horizon" {
continue;
}
// ServerLevel compares the player's block centre, strictly within 32.
let distance: f64 = (0..3)
.map(|k| (g.position[k].floor() + 0.5 - at[k]).powi(2))
@@ -252,6 +269,13 @@ impl Game<'_> {
options: &ParticleOptions,
state: Option<BlockStateId>,
) -> Option<Particle> {
// A native mod's own type: no registry id, so the sentinel and the name.
if let Some(name) = &options.mod_particle {
return Some(Particle {
id: MOD_PARTICLE_ID,
data: Some(ParticleData::Mod(name.clone())),
});
}
let name = format!("minecraft:{}", options.ty.name());
let id = hub.registry_id("minecraft:particle_type", &name)?;
use ParticleType as T;
@@ -292,6 +316,11 @@ impl Game<'_> {
p: &Particle,
at: [f64; 3],
) -> Option<ParticleOptions> {
if p.id == MOD_PARTICLE_ID
&& let Some(ParticleData::Mod(name)) = &p.data
{
return Some(ParticleOptions::named(name.clone()));
}
let name = self
.remote
.as_ref()?
@@ -434,6 +463,7 @@ impl Game<'_> {
let Some(host) = self.lan.as_ref() else {
return;
};
let horizon_only = mod_particle_only_for_horizon(&particle);
let body = C::LevelParticles(play::LevelParticles {
particle,
override_limiter: false,
@@ -447,7 +477,7 @@ impl Game<'_> {
.encode();
// BlockPos.closerToCenterThan(at, 32): from the centre of the
// player's block.
send_native_nearby(host, dimension, body, |position| {
send_native_nearby(host, dimension, body, horizon_only, |position| {
let d: f64 = (0..3)
.map(|k| (position[k].floor() + 0.5 - at[k]).powi(2))
.sum();
@@ -480,7 +510,7 @@ impl Game<'_> {
)
.encode();
let range = f64::from(sound.volume.max(1.0)) * 16.0;
send_native_nearby(host, sound.dimension, packet, |position| {
send_native_nearby(host, sound.dimension, packet, false, |position| {
let distance: f64 = (0..3).map(|k| (position[k] - sound.at[k]).powi(2)).sum();
distance <= range * range
});
+12 -1
View File
@@ -112,13 +112,24 @@ impl Game<'_> {
if !integrated && (!host.settings.guest_commands || !allowed) {
return false;
}
let Some(permission) = host.hub.guest(conn).map(|g| g.permission) else {
let Some((permission, uuid, name)) = host
.hub
.guest(conn)
.map(|g| (g.permission, g.uuid, g.name.clone()))
else {
return false;
};
let command = line.trim_start_matches('/');
if !self.signable_arguments(permission, command).is_empty() {
return false;
}
// The mods hear it as they hear a typed command; the batch is quiet,
// so their replies go nowhere.
let mut canceled = false;
self.native_player_command(uuid, &name, command, &mut canceled, &mut Vec::new());
if canceled {
return false;
}
self.run_guest_command(GuestCommand {
conn,
command: command.to_owned(),
+15 -4
View File
@@ -1205,8 +1205,14 @@ impl Game<'_> {
None => {
let mut handled = false;
rules.edit(|b| handled = b.key(key, ctrl, false));
if !handled && types_text {
self.world_options_text(event.text.as_deref());
// Only a key with text types: a `None` text is a
// backspace to `world_options_text`, which is the
// control socket's `\b`, not a key's.
if !handled
&& types_text
&& let Some(text) = event.text.as_deref()
{
self.world_options_text(Some(text));
}
}
}
@@ -1312,8 +1318,13 @@ impl Game<'_> {
o.port.shortcut(shortcut, &mut self.clipboard);
return;
}
if !o.port.key(key, ctrl, false) && types_text {
self.world_options_text(event.text.as_deref());
// Only a key with text types (a `None` is a backspace to
// `world_options_text`, the control socket's `\b`).
if !o.port.key(key, ctrl, false)
&& types_text
&& let Some(text) = event.text.as_deref()
{
self.world_options_text(Some(text));
}
return;
}
+52 -6
View File
@@ -127,6 +127,14 @@ impl Game<'_> {
}
/// `Leashable.dropLeash` / `removeLeash`: the lead falls as an item.
pub(super) fn break_leash(&mut self, id: u32, drop: bool, sound: bool) {
self.drop_leash(id, drop, sound, false);
}
/// `break_leash` by the acting player's use (untying it, shears).
fn break_leash_used(&mut self, id: u32, drop: bool) {
self.drop_leash(id, drop, false, true);
}
fn drop_leash(&mut self, id: u32, drop: bool, sound: bool, used: bool) {
let killer = self.lead_killers.remove(&id).map(|(killer, _)| killer);
let Some(m) = self.mobs.iter_mut().find(|m| m.id == id) else {
return;
};
@@ -136,16 +144,54 @@ impl Game<'_> {
// Leashable.tickLeash gates a dying entity's lead drop on entity_drops.
let drop = drop && (m.alive() || self.ui.rules.bool("entity_drops"));
let at = [m.position.x, m.position.y, m.position.z];
let kind = super::native_hooks::mob_kind(m);
if sound {
self.lead_sound("break", at);
}
if drop {
if !drop {
return;
}
// The mods' say when a player is behind it: the user, else the
// killer of a mob that died leashed.
let player = if used {
Some(self.native_acting_id())
} else {
killer
};
let Some(player) = player else {
self.drop_lead_item(at);
return;
};
let mut drops = vec![ItemStack::new("minecraft:lead", 1)];
let context = super::native_hooks::NativeLootDrops {
table: "",
dimension: self.dim.id(),
origin: at,
block: None,
entity: Some((id, &kind)),
player: Some(player),
tool: None,
explosion: None,
used,
};
self.native_loot_drops(&context, &mut drops);
for stack in drops {
if stack.count > 0 && &*stack.item != "minecraft:air" {
let v = super::dispenser::new_item_motion();
self.spawn_item(stack, at, v, 10);
}
}
}
/// Holders read from a save become real once the entity is found; a
/// holder that never shows up drops the lead after 100 ticks.
pub(super) fn tick_leads(&mut self) {
// A corpse gone without dropping its lead takes its killer along.
// Only this level's: the others' mobs aren't in place.
if !self.lead_killers.is_empty() {
let (mobs, dim) = (&self.mobs, self.dim);
self.lead_killers
.retain(|id, (_, at)| *at != dim || mobs.iter().any(|m| m.id == *id && !m.removed));
}
let player_uuid = self.level.player_uuid.unwrap_or([0, 0, 0, 1]);
for i in 0..self.mobs.len() {
let Some(pending) = self.mobs[i].extra.leash_pending else {
@@ -222,7 +268,7 @@ impl Game<'_> {
if item == "minecraft:shears" {
let mut dropped = false;
if self.mobs[index].extra.leash.is_some() {
self.break_leash(id, true, false);
self.break_leash_used(id, true);
dropped = true;
}
let others: Vec<u32> = self
@@ -232,7 +278,7 @@ impl Game<'_> {
.map(|m| m.id)
.collect();
for o in others {
self.break_leash(o, true, false);
self.break_leash_used(o, true);
dropped = true;
}
let boats = self.boats_on(Holder::Mob(id));
@@ -255,7 +301,7 @@ impl Game<'_> {
.is_none_or(|p| p == self.acting_player_uuid())
{
let creative = self.creative;
self.break_leash(id, !creative, false);
self.break_leash_used(id, !creative);
self.lead_game_event("minecraft:entity_interact", at);
self.lead_sound("untied", at);
return true;
@@ -264,7 +310,7 @@ impl Game<'_> {
let center = self.player_center();
if self.can_attach_lead(index, center) {
if self.mobs[index].extra.leash.is_some() {
self.break_leash(id, true, false);
self.break_leash_used(id, true);
}
let holder = self.acting_player_uuid();
self.mobs[index].extra.leash = Some(Holder::Player);
@@ -335,7 +381,7 @@ impl Game<'_> {
if knot_hit && held_item.as_deref() == Some("minecraft:shears") {
for i in existing {
let id = self.mobs[i].id;
self.break_leash(id, true, false);
self.break_leash_used(id, true);
}
self.drop_boat_leashes(&existing_boats);
self.audio
+4
View File
@@ -46,6 +46,8 @@ pub(super) struct Parked {
tickets: ChunkTickets,
random_tick_sections: RandomTickSections,
level_events: Vec<LevelEvent>,
/// Entity portal processes and cooldowns belong to this level's entities.
portal_states: HashMap<u32, horizon_client::nether_portal::PortalState>,
falling: Vec<FallingBlock>,
paintings: Vec<horizon_client::entities::Painting>,
item_frames: Vec<horizon_client::entities::ItemFrame>,
@@ -179,6 +181,7 @@ impl Game<'_> {
tickets: Default::default(),
random_tick_sections: Default::default(),
level_events: Vec::new(),
portal_states: HashMap::new(),
falling: Vec::new(),
paintings: Vec::new(),
item_frames: Vec::new(),
@@ -233,6 +236,7 @@ impl Game<'_> {
swap(&mut self.tickets, &mut p.tickets);
swap(&mut self.random_tick_sections, &mut p.random_tick_sections);
swap(&mut self.level_events, &mut p.level_events);
swap(&mut self.portals.entities, &mut p.portal_states);
swap(&mut self.falling, &mut p.falling);
swap(&mut self.paintings, &mut p.paintings);
swap(&mut self.item_frames, &mut p.item_frames);
@@ -183,7 +183,7 @@ impl Game<'_> {
}
let state = horizon_world::chunk::BlockState {
name: name.to_owned(),
properties,
properties: properties.into(),
};
match self.pack.assets.registry.resolve(&state) {
Ok(id) => self.level_set(pos, id).is_ok(),
+21
View File
@@ -103,12 +103,14 @@ pub(super) fn content_root(
}
register_mob_effects(&mods);
register_entity_types(&mods);
register_menu_types(&mods);
out
}
Err(e) => {
log::warn!("Horizon: content mods not loaded: {e}");
register_mob_effects(&[]);
register_entity_types(&[]);
register_menu_types(&[]);
root.to_owned()
}
}
@@ -161,6 +163,25 @@ fn register_entity_types(mods: &[horizon_client::mod_content::ContentMod]) {
}
}
/// the mods' menu types, as their entity types: set once, from the startup
/// root.
fn register_menu_types(mods: &[horizon_client::mod_content::ContentMod]) {
use horizon_client::containers;
let menus = horizon_client::mod_content::menu_types(mods);
let set = containers::register_mod_menu_types(menus.iter().map(|m| {
let results = m.results.iter().map(|&r| usize::from(r)).collect();
(m.id.clone(), m.slots.into(), m.data_slots.into(), results)
}));
if !set
&& containers::mod_menu_types()
.iter()
.map(|m| m.id)
.ne(menus.iter().map(|m| m.id.as_str()))
{
log::warn!("Horizon: the mods' menu types changed; restart the game to use them");
}
}
/// What the launcher writes beside a game it built with native mods: their ids under `mods`.
const BUILD_INFO: &str = "native-mods.json";
@@ -7,13 +7,16 @@ use super::{Dim, Game};
use glam::{DVec3, Mat4, Quat, Vec3};
use horizon_assets::{
bake::{bake_flat_face, default_uv},
item_model::DisplayContext,
model::{Direction, Material},
};
use horizon_client::{
block_entity_render::{diffuse, diffuse_lights, vertex_light},
containers::BlockEntity,
containers::{BlockEntity, ModBlockEntity},
fast_hash::FxHashMap,
gfx::Cull,
hud::Rect,
items::ItemStack,
mesher::Vertex,
};
use horizon_world::registry::BlockStateId;
@@ -50,9 +53,12 @@ pub struct BlockEntityDraw {
/// (`LevelRenderer.getLightCoords`), 16 times sky light in bits 0..8 and
/// 16 times block light in bits 8..16; set it to light a part otherwise.
pub light: u32,
/// The block drawn, for `native_draw_item`'s owner.
block: [i32; 3],
poses: Vec<Mat4>,
lights: (Vec3, Vec3),
vertices: Vec<Vertex>,
kept: bool,
}
#[allow(dead_code)]
@@ -88,6 +94,19 @@ impl BlockEntityDraw {
pub fn scale(&mut self, by: [f32; 3]) {
self.apply(Mat4::from_scale(Vec3::from_array(by)));
}
/// Keeps what this call drew: the block is drawn the same again, with
/// neither `native_block_entity_submit` nor
/// `native_block_entity_render_bounding_box` called, until its state,
/// its block entity (what the server sent), its light or the resource
/// packs change. Call it only when what you drew reads none of
/// `partial`, `game_time`, the blocks around it or anything else of
/// yours, and moves nothing by itself: an idle machine, not a turning
/// shaft. Kept geometry stands still: an enchanted item's glint, and an
/// item drawn with `native_draw_item` whose model reads the time (a clock,
/// a compass), stop where they were. API @API:block_entity_keep@.
pub fn keep(&mut self) {
self.kept = true;
}
fn apply(&mut self, m: Mat4) {
if let Some(pose) = self.poses.last_mut() {
*pose *= m;
@@ -141,6 +160,98 @@ fn shaded(tint: u32, brightness: f32) -> u32 {
0xFF00_0000 | c(0) << 16 | c(8) << 8 | c(16)
}
/// Game ticks kept geometry stays after it was last drawn, as signs' text.
const KEPT_FOR: i64 = 100;
/// What kept geometry was drawn with besides its block entity: drawn again
/// when any of it changes.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
struct KeptKey {
dim: Dim,
state: u32,
light: u32,
}
struct KeptEntry {
key: KeptKey,
entity: ModBlockEntity,
bounds: [f32; 6],
/// Game time it was last drawn.
used: i64,
/// Relative to the block's corner.
vertices: Vec<Vertex>,
}
/// Geometry mods kept (`BlockEntityDraw::keep`), by position. As
/// `SignTexts` keeps a sign's text, it is kept relative to the block and
/// moved to the camera each frame.
#[derive(Default)]
pub(super) struct KeptGeometry {
entries: FxHashMap<[i32; 3], KeptEntry>,
/// Game time of the last sweep of what is out of view.
swept: i64,
}
impl KeptGeometry {
/// Drops what was not drawn for `KEPT_FOR` ticks, once every `KEPT_FOR`.
fn sweep(&mut self, now: i64) {
if (now - self.swept).abs() >= KEPT_FOR {
self.swept = now;
self.entries
.retain(|_, e| (now - KEPT_FOR..=now).contains(&e.used));
}
}
/// What is kept at `pos` when it was drawn with `key` and `entity`,
/// marked drawn at `now`.
fn get(
&mut self,
pos: [i32; 3],
key: KeptKey,
entity: &ModBlockEntity,
now: i64,
) -> Option<&KeptEntry> {
let entry = self
.entries
.get_mut(&pos)
.filter(|e| e.key == key && e.entity == *entity)?;
entry.used = now;
Some(entry)
}
fn keep(
&mut self,
pos: [i32; 3],
key: KeptKey,
entity: &ModBlockEntity,
bounds: [f32; 6],
vertices: &[Vertex],
now: i64,
) {
let mut kept = self
.entries
.remove(&pos)
.map_or_else(Vec::new, |e| e.vertices);
kept.clear();
kept.extend_from_slice(vertices);
self.entries.insert(
pos,
KeptEntry {
key,
entity: entity.clone(),
bounds,
used: now,
vertices: kept,
},
);
}
fn forget(&mut self, pos: [i32; 3]) {
self.entries.remove(&pos);
}
/// Forgets everything: the atlas's sprites moved.
pub(super) fn clear(&mut self) {
self.entries.clear();
}
}
impl Game<'_> {
/// `native_block_entity_submit` for each of `blocks` (in drawn sections,
/// drawn by no vanilla renderer) whose block entity is a mod's, closer than
@@ -158,10 +269,15 @@ impl Game<'_> {
partial,
game_time: self.world.game_time,
light: 0,
block: [0; 3],
poses: Vec::new(),
lights: diffuse_lights(self.dim == Dim::Nether),
vertices: std::mem::take(out),
kept: false,
};
let now = self.world.game_time;
let mut kept = self.kept_block_entities.borrow_mut();
kept.sweep(now);
for &(pos, state) in blocks {
let corner = DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2]));
// Vec3.closerThan: strictly closer.
@@ -173,9 +289,27 @@ impl Game<'_> {
else {
continue;
};
let origin = (corner - camera).as_vec3();
let light = vertex_light(self.world.light(pos[0], pos[1], pos[2]));
let key = KeptKey {
dim: self.dim,
state,
light,
};
if let Some(entry) = kept.get(pos, key, entity, now) {
if in_frustum(self.view_frustum.as_ref(), origin, entry.bounds) {
let start = draw.vertices.len();
self.mark_block_entity(start, state);
draw.vertices.extend(entry.vertices.iter().map(|v| Vertex {
pos: (Vec3::from_array(v.pos) + origin).to_array(),
..*v
}));
self.cull_entity_vertices(&mut draw.vertices, start);
}
continue;
}
let id = BlockStateId(state);
let block = &*self.res.facts().get(id).name;
let origin = (corner - camera).as_vec3();
let mut bounds = DEFAULT_BOUNDS;
self.native_block_entity_render_bounding_box(pos, block, id, entity, &mut bounds);
if !in_frustum(self.view_frustum.as_ref(), origin, bounds) {
@@ -183,10 +317,22 @@ impl Game<'_> {
}
let start = draw.vertices.len();
self.mark_block_entity(start, state);
// Drawn relative to the block, then moved to the camera, so what
// is kept can be moved again.
draw.block = pos;
draw.poses.clear();
draw.poses.push(Mat4::from_translation(origin));
draw.light = vertex_light(self.world.light(pos[0], pos[1], pos[2]));
draw.poses.push(Mat4::IDENTITY);
draw.light = light;
draw.kept = false;
self.native_block_entity_submit(pos, block, id, entity, &mut draw);
if draw.kept {
kept.keep(pos, key, entity, bounds, &draw.vertices[start..], now);
} else {
kept.forget(pos);
}
for v in &mut draw.vertices[start..] {
v.pos = (Vec3::from_array(v.pos) + origin).to_array();
}
self.cull_entity_vertices(&mut draw.vertices, start);
}
*out = draw.vertices;
@@ -290,6 +436,57 @@ impl Game<'_> {
self.block_model_vertices(id, 42, pose, draw.light, true, &mut draw.vertices);
true
}
/// Draws `stack` under `draw`'s pose as an item is drawn in the world
/// (`ItemModelResolver.updateForTopItem` and `ItemStackRenderState.submit`), centred on the
/// pose's origin, in its model's `display` transform: `Fixed` as a
/// campfire's or an item frame's, `Ground` as a dropped item's. `seed`
/// picks among random models (a campfire passes its slot plus its
/// position's hash). `owner_yaw` makes the block entity the item's
/// owner (`ItemOwner`), at the block's centre and turned that many
/// degrees (`getVisualRotationYInDegrees`), as a shelf is: a compass on
/// it points to its target from there. None draws it with no owner, as
/// a campfire does. False for an empty stack or one with no model. The
/// client's copy of a block entity has no item slots (`entity.items` is
/// empty there): send the stacks you draw in
/// `native_block_entity_update_tag` and read them from `entity.nbt`.
/// API @API:block_entity_draw_item@.
pub(super) fn native_draw_item(
&self,
draw: &mut BlockEntityDraw,
stack: &ItemStack,
display: DisplayContext,
owner_yaw: Option<f32>,
seed: i32,
) -> bool {
// ShelfBlockEntity.position(): Vec3.atCenterOf(getBlockPos()).
let owner = owner_yaw
.map(|yaw| super::item_draw::owner_at(draw.block.map(|c| f64::from(c) + 0.5), yaw, ""));
let item = self.resolve_item(stack, display, owner.as_ref(), seed);
if item.is_empty() {
return false;
}
let pose = draw.pose();
self.submit_item_world(&item, pose, draw.light, &mut draw.vertices);
true
}
/// Property `key` of block state `state` (a machine's `facing`, from the
/// `state` `native_block_entity_submit` gets), if it has one: no lookup in
/// the world, so cheap each frame. API @API:block_entity_draw_item@.
pub(super) fn native_block_state_property(
&self,
state: BlockStateId,
key: &str,
) -> Option<&str> {
self.pack
.assets
.registry
.state(state)?
.properties
.get(key)
.map(String::as_str)
}
}
#[cfg(test)]
@@ -301,9 +498,11 @@ mod tests {
partial: 0.0,
game_time: 0,
light: 0xF0F0,
block: [0; 3],
poses: vec![Mat4::from_translation(Vec3::new(10.0, 0.0, 0.0))],
lights: diffuse_lights(false),
vertices: Vec::new(),
kept: false,
}
}
@@ -388,4 +587,81 @@ mod tests {
// The vertex colour is RGBA8 read little-endian: red in the low byte.
assert_eq!(shaded(0x10_20_30, 1.0), 0xFF_30_20_10);
}
fn machine(progress: i32) -> ModBlockEntity {
ModBlockEntity {
id: "mymod:machine".into(),
data: vec![progress],
..Default::default()
}
}
fn vertex(x: f32) -> Vertex {
Vertex {
pos: [x, 0.0, 0.0],
..bytemuck::Zeroable::zeroed()
}
}
const KEY: KeptKey = KeptKey {
dim: Dim::Overworld,
state: 7,
light: 0xF0F0,
};
#[test]
fn kept_geometry_is_drawn_again_until_what_it_was_drawn_with_changes() {
let mut kept = KeptGeometry::default();
let pos = [1, 2, 3];
kept.keep(pos, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.5)], 0);
let entry = kept.get(pos, KEY, &machine(0), 1).expect("kept");
assert_eq!(entry.vertices.len(), 1);
assert_eq!(entry.vertices[0].pos, [0.5, 0.0, 0.0]);
assert_eq!(entry.bounds, DEFAULT_BOUNDS);
// New data from the server, another state, light or dimension, or
// another position: none of them is the kept geometry.
assert!(kept.get(pos, KEY, &machine(1), 1).is_none());
for changed in [
KeptKey { state: 8, ..KEY },
KeptKey {
light: 0x00F0,
..KEY
},
KeptKey {
dim: Dim::Nether,
..KEY
},
] {
assert!(kept.get(pos, changed, &machine(0), 1).is_none());
}
assert!(kept.get([1, 2, 4], KEY, &machine(0), 1).is_none());
// Drawn again and kept with the new data, it replaces the old.
kept.keep(pos, KEY, &machine(1), DEFAULT_BOUNDS, &[vertex(0.25)], 2);
assert!(kept.get(pos, KEY, &machine(0), 2).is_none());
let entry = kept.get(pos, KEY, &machine(1), 2).expect("kept again");
assert_eq!(entry.vertices[0].pos, [0.25, 0.0, 0.0]);
// Drawn without keeping: forgotten.
kept.forget(pos);
assert!(kept.get(pos, KEY, &machine(1), 3).is_none());
}
#[test]
fn kept_geometry_out_of_view_for_its_ticks_is_swept() {
let mut kept = KeptGeometry::default();
let (seen, gone) = ([0, 0, 0], [5, 0, 0]);
kept.keep(seen, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.0)], 0);
kept.keep(gone, KEY, &machine(0), DEFAULT_BOUNDS, &[vertex(0.0)], 0);
kept.get(gone, KEY, &machine(0), 50);
// A sweep at 100 keeps both: each was drawn in the last 100 ticks.
kept.sweep(100);
assert!(kept.entries.contains_key(&gone));
assert!(kept.entries.contains_key(&seen));
kept.get(seen, KEY, &machine(0), 150);
// At 199 no sweep is due yet; at 200 the one last drawn at 50 goes.
kept.sweep(199);
assert!(kept.entries.contains_key(&gone));
kept.sweep(200);
assert!(!kept.entries.contains_key(&gone));
assert!(kept.get(seen, KEY, &machine(0), 200).is_some());
}
}
@@ -105,6 +105,7 @@ impl Game<'_> {
stuck: Vec3::ZERO,
damage,
saved: None,
portal: Default::default(),
});
true
}
+569 -16
View File
@@ -289,6 +289,7 @@ use horizon_client::{
extras::Corner,
extras_hud::Corners,
fast_hash::FxHashMap,
first_person::Hand,
health::{HurtSource, Hurting},
hud::{HudBatch, rgba},
items::ItemStack,
@@ -304,6 +305,7 @@ use horizon_core::{
ticks::{ScheduleResult, TickPriority},
};
use horizon_world::{
consumable::UseAnimation,
fluid::{FluidKind, FluidState},
level::{BlockId, LevelAccess, PlacementContext, Ticks},
nbt::Compound,
@@ -676,7 +678,9 @@ pub(crate) struct NativeLootDrops<'a> {
pub(crate) origin: [f64; 3],
/// The block broken: its position and state.
pub(crate) block: Option<([i32; 3], BlockStateId)>,
/// The mob that died: its entity ID and type (`minecraft:zombie`).
/// The mob that died, or the entity a player's use dropped them from (the
/// sheep sheared, the item frame broken): its entity ID and type
/// (`minecraft:zombie`).
pub(crate) entity: Option<(u32, &'a str)>,
/// The player who broke the block or killed the mob, when known. A mob
/// names one only while it remembers the player's hit (100 ticks), so one
@@ -686,6 +690,16 @@ pub(crate) struct NativeLootDrops<'a> {
pub(crate) tool: Option<&'a ItemStack>,
/// The radius of the explosion that broke the block.
pub(crate) explosion: Option<f32>,
/// A player's use dropped them rather than a break or a death: shears on a
/// sheep, a mooshroom, a snow golem or a bogged, a brush on an armadillo or
/// a suspicious block, shears carving a pumpkin, picking sweet berries or
/// cave vines, a hit taking an item frame or its item, or untying a lead
/// or cutting it with shears. `player` names them; `block` or `entity` says
/// what dropped them. API @API:use_drops@. A mob that a player killed while
/// leashed drops its lead on its next tick as a call of its own (`entity`
/// the mob, `player` its killer, `table` empty, `used` false). API
/// @API:lead_drops@.
pub(crate) used: bool,
}
/// A depth-tested outline of the client's block selection shape.
@@ -802,10 +816,12 @@ pub struct NativeScreen {
pub own_labels: bool,
}
/// The open screen, for the screen hooks (`native_screen_render_post`,
/// `native_screen_mouse_button_pressed_pre`, `..._released_pre`): what it is,
/// its menu and title, where its container panel is, how many slots its menu
/// has, and where the mouse and the frame's partial tick are.
/// The open screen, for the screen hooks (`native_screen_render_post`, the
/// mouse ones `native_screen_mouse_button_pressed_pre`, `..._released_pre` and
/// `native_screen_mouse_scrolled_pre`, and the key ones
/// `native_screen_key_pressed_pre` and `native_screen_char_typed_pre`): what
/// it is, its menu and title, where its container panel is, how many slots its
/// menu has, and where the mouse and the frame's partial tick are.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct NativeScreenView<'a> {
@@ -888,6 +904,17 @@ pub(crate) struct NativePlayer {
pub(crate) dimension: &'static str,
}
/// The world's clock as a client mod reads it (`native_clock`).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct NativeClock {
/// Ticks since the world was created (`Level.getGameTime`).
pub(crate) game_time: i64,
/// The time of day in ticks (`Level.getOverworldClockTime`): 0 at sunrise, 24000 a
/// day. It keeps counting past 24000 (`/time add`, days going by), so take
/// it modulo 24000 for the hour; `/time set` can make it negative.
pub(crate) day_time: i64,
}
/// A native mod as Options > Mods lists it (`native_mods`).
#[derive(Clone, Debug, Default, PartialEq)]
pub(crate) struct NativeMod {
@@ -1074,16 +1101,61 @@ const MAPPINGS: [(&str, Option<KeyCode>); 9] = [
("key.use", None),
];
/// What a `ScreenEvent.CharacterTyped.Pre` leaves of `text`, given `canceled`
/// (whether a hook cancelled character `c`): the cancelled printable
/// characters are gone and every other character stays, control characters
/// included — a text box reads Backspace, Delete or Escape as keys and vanilla
/// fires no `charTyped` for them, so they are never offered to the hook.
/// `None` when nothing is left to type.
fn screen_text_hooked(text: &str, mut canceled: impl FnMut(char) -> bool) -> Option<String> {
let mut left = String::with_capacity(text.len());
for c in text.chars() {
if !c.is_control() && canceled(c) {
continue;
}
left.push(c);
}
if left.is_empty() && !text.is_empty() {
return None;
}
Some(left)
}
/// What a key press's characters leave for a text box after the
/// `ScreenEvent.CharacterTyped.Pre` hook: the characters no mod cancelled,
/// control ones gone (the key's own handling takes those, and vanilla fires
/// no `charTyped` for them). `None` when nothing is left to type.
fn chars_to_type(text: &str, canceled: impl FnMut(char) -> bool) -> Option<String> {
let left = screen_text_hooked(text, canceled)?;
let text: String = left.chars().filter(|c| !c.is_control()).collect();
(!text.is_empty()).then_some(text)
}
/// The hook points. Each does nothing in Horizon.
impl Game<'_> {
/// Once a frame, in a world or not, after Horizon's extras.
pub(super) fn native_frame(&mut self, elapsed: Duration) {
let _ = elapsed;
}
/// Render-thread notification after a resource-pack reload has applied successfully.
/// Atlas, models, renderer and client caches already refer to the new resources.
/// Selection/F3+T and server-pack reloads share this point; initial startup does not.
pub(super) fn native_client_resources_reloaded(&mut self) {}
/// Once a tick, in every `Game`: the window's (in single player, on LAN
/// and on servers), and the server's own (`native_window` is false
/// there: the single player server thread, a dedicated server).
pub(super) fn native_tick(&mut self) {}
/// Before an eligible targeted block's selection outline is built.
/// Cancel skips only the default block highlight, after normal HUD and game-mode gates.
pub(super) fn native_block_highlight(
&self,
pos: [i32; 3],
state: BlockStateId,
partial: f32,
canceled: &mut bool,
) {
let _ = (pos, state, partial, canceled);
}
/// The world frame's local visual sources. Push requests; Horizon validates
/// and limits them before rendering. Called only while a world is visible.
pub(super) fn native_visuals(
@@ -1111,6 +1183,22 @@ impl Game<'_> {
) {
let _ = (context, phase, error_text);
}
/// The server is assembling its data tables from the world's data packs
/// (`ReloadableServerResources`): a world is opening, or `/reload` was
/// run, and the recipes, tags, loot and advancements are built after
/// this. Push `(path, bytes)` pack files and they join this load. `path`
/// is the overlay's key, `<kind>/<namespace>:<path>` without `.json`, over
/// the kinds the tables read: `recipe/mymod:thing`,
/// `tags/mymod:block/thing`, `loot_table/…`, `advancement/…`,
/// `trade_set/…`, `villager_trade/…`, `dialog/…`. A file that isn't JSON
/// is skipped with a warning. Asked once per load on both paths, so this
/// is where a mod recomputes them.
/// Contributed files sit below the world's own data packs: a pack's file
/// for the same path still overrides a mod's. Server-side (the single
/// player's integrated server included). API @API:data_pack_files@.
pub(super) fn native_data_pack_files(&self, files: &mut Vec<(String, Vec<u8>)>) {
let _ = files;
}
/// The server is about to open its worlds: nothing of the world is
/// loaded yet (NeoForge's `ServerAboutToStartEvent`, "called before the
/// server begins loading anything"). Vanilla's `MinecraftServer.loadLevel`
@@ -1251,6 +1339,21 @@ impl Game<'_> {
pub(super) fn native_camera_shake(&self, partial: f32, shake: &mut NativeShake) {
let _ = (partial, shake);
}
/// Before one first-person hand and its displayed stack are drawn (RenderHandEvent).
/// Equip progress is the inverse arm height, including the item's swap animation scale.
/// Cancel skips that hand's default geometry, including its map and arm geometry.
/// It only hides: the hook has no way to draw a replacement.
pub(super) fn native_render_hand(
&self,
hand: horizon_client::first_person::Hand,
stack: Option<&ItemStack>,
partial: f32,
swing: f32,
equip: f32,
canceled: &mut bool,
) {
let _ = (hand, stack, partial, swing, equip, canceled);
}
/// The values native mods hand their shader packs in this frame,
/// `partial` ticks past the last tick (0 to 1): a pack's WGSL reads
@@ -1330,9 +1433,11 @@ impl Game<'_> {
/// set `container` when the block is yours. Asked once for each mod
/// block when the ticking table is built; at most 256 slots, and slot
/// numbers past the count are left out. Its `menu`, one of
/// `containers::MOD_MENUS` with as many slots, opens when the block is
/// used (its template must open a menu, as a chest or a furnace does);
/// another is left out. API 17.
/// `containers::MOD_MENUS` or a content mod's menu type
/// (`data/<id>/horizon/menu/`, API @API:mod_menu_types@) with as many
/// slots, opens when the block is used (its template must open a menu, as
/// a chest or a furnace does); another is left out. A mod menu type shows
/// on Horizon clients through the mod's `native_screen` only. API 17.
pub(super) fn native_block_entity_container(
&self,
block: &str,
@@ -1396,12 +1501,13 @@ impl Game<'_> {
/// (vanilla's `BlockEntityRenderer.submit`: within 64 blocks, its chunk
/// faded in, and its bounds, `native_block_entity_render_bounding_box`,
/// in the frustum), when its `id` is yours. `state` is the block's state
/// (`blockEntity.getBlockState()`), `entity` holds what the server sent
/// (`blockEntity.getBlockState()`: read its `facing` with
/// `native_block_state_property`), `entity` holds what the server sent
/// (`native_block_entity_update_tag`). Add geometry with
/// `native_draw_quad`, `native_draw_cube` and `native_draw_block` under
/// `draw`'s pose stack, which starts at the block's lower north-west
/// corner, in blocks; `draw` also has the partial tick, the game time and
/// the block's light. API 71.
/// `native_draw_quad`, `native_draw_cube`, `native_draw_block` and
/// `native_draw_item` under `draw`'s pose stack, which starts at the
/// block's lower north-west corner, in blocks; `draw` also has the partial
/// tick, the game time and the block's light. API 71.
pub(super) fn native_block_entity_submit(
&self,
pos: [i32; 3],
@@ -1524,6 +1630,105 @@ impl Game<'_> {
) {
let _ = (screen, button, modifiers, canceled);
}
/// The wheel turned over the open screen (`ScreenEvent.MouseScrolled.Pre`,
/// vanilla's `Screen.mouseScrolled`): `screen` is the screen it is,
/// `screen.mouse` where the pointer is (so there is no position of its
/// own), `scroll` the wheel's movement in x and y as the game's own scroll
/// handler scales it (a line times the wheel sensitivity: with Discrete
/// Scrolling on, a notch is ±1, its sign only; a touchpad's pixels are
/// divided by 30), and
/// `modifiers` GLFW's bits (shift 1, control 2, alt 4, super 8). Set
/// `canceled` and nothing the screen would scroll moves: not the bundle
/// under the pointer, nor a list, slider, scrollbar or map. Nothing else
/// reads the wheel while a screen of the game's is open, so no hotbar slot
/// changes either.
///
/// It fires before the screen scrolls anything, for the window's wheel
/// (horizon-ctl's `scroll-at` sends it through the window too), and
/// only while a screen of the game's is open (Options and the pack list
/// opened from the title screen count, with no player and no `menu`). It
/// does not fire on the title screen itself, the connect and loading
/// screens, nor for a server dialog, the server pack, link and
/// code-of-conduct prompts, a book or a sign editor drawn over gameplay,
/// nor while a world loads or saves: the scroll there stays the game's
/// own. A wheel a gamepad or the keyboard's focus sends is not the mouse
/// and fires nothing.
/// API @API:screen_mouse_scrolled@.
pub(super) fn native_screen_mouse_scrolled_pre(
&self,
screen: &NativeScreenView<'_>,
scroll: [f64; 2],
modifiers: u32,
canceled: &mut bool,
) {
let _ = (screen, scroll, modifiers, canceled);
}
/// A key went down over the open screen (`ScreenEvent.KeyPressed.Pre`,
/// vanilla's `Screen.keyPressed`): `screen` is the screen it is, `key`
/// vanilla's name for the key (`key.keyboard.e`, the name `native_key`
/// hands over) — a key with no name fires nothing — `repeat` whether it is
/// the keyboard's auto-repeat of a held press (Horizon's extra; NeoForge
/// folds repeats into the event), and `modifiers` GLFW's bits (shift 1,
/// control 2, alt 4, super 8). Set `canceled` and the press goes no
/// further: no focus move, no Escape-close, no inventory or hotbar key, no
/// typing by the key itself, no `native_key`.
///
/// It fires for the window's presses and their repeats, and for the
/// control socket's `key` (a release fires nothing), after the window's
/// own shortcuts have run — the fullscreen and screenshot keys, the debug
/// keys and the narrator — and only while a screen of the game's is open
/// (Options and the pack list opened from the title screen count, with no
/// player and no `menu`). It does not fire on the title screen itself, the
/// connect and loading screens, nor for a server dialog, the server pack,
/// link and code-of-conduct prompts, a book or a sign editor drawn over
/// gameplay, nor while a world loads or saves: the key there stays the
/// game's own. A cancelled press's release still runs, so a `native_key`
/// listener sees that release without its press, and its characters are
/// GLFW's separate char callback and still come; see
/// `native_screen_char_typed_pre`.
/// API @API:screen_key_input@.
pub(super) fn native_screen_key_pressed_pre(
&self,
screen: &NativeScreenView<'_>,
key: &str,
repeat: bool,
modifiers: u32,
canceled: &mut bool,
) {
let _ = (screen, key, repeat, modifiers, canceled);
}
/// A character was typed at the open screen
/// (`ScreenEvent.CharacterTyped.Pre`, vanilla's `Screen.charTyped`):
/// `screen` is the screen it is, `c` the character, and `modifiers` GLFW's
/// bits (shift 1, control 2, alt 4, super 8). Set `canceled` and that one
/// character never reaches a text box.
///
/// It fires once per printable character of a key press's text and once
/// per character of the input method's committed text, never for a control
/// character (Enter, Backspace, Tab, Delete, Escape, a Control letter):
/// those are keys, and the game reads them as keys, not as text. A key's
/// characters fire after the key has acted, as GLFW's char callback comes
/// after its key callback (`KeyboardHandler.charTyped`): `screen` is the
/// screen open then, so a key that closes the screen (the inventory key)
/// leaves none to hear its character, where vanilla's closed screen hears
/// nothing, and one that moves the focus types into the box it moved to.
/// They fire even when `native_screen_key_pressed_pre` cancelled the press
/// that carried the text, as NeoForge delivers `CharacterTyped.Pre` for a
/// cancelled `KeyPressed.Pre`: the characters left reach the text box
/// without the key's own effects. Cancelling one character drops only that
/// one. The control socket's `type` fires it too, but not for its `\b`
/// backspace. Same screens as the key press, and it fires with no text box
/// focused (there is nothing to take the character then).
/// API @API:screen_key_input@.
pub(super) fn native_screen_char_typed_pre(
&self,
screen: &NativeScreenView<'_>,
c: char,
modifiers: u32,
canceled: &mut bool,
) {
let _ = (screen, c, modifiers, canceled);
}
/// The open screen as a native mod sees it (`NativeScreenView`): its id,
/// the open menu's type and title, its slot count, and its panel's
/// rectangle, with the mouse and the partial tick. `screen` is the screen
@@ -1582,6 +1787,74 @@ impl Game<'_> {
let batch = HudBatch::new([renderer.config.width, renderer.config.height]);
Some(self.screen_view(&batch, self.screen))
}
/// Whether the key press `key` over the open screen went no further
/// (`native_screen_key_pressed_pre`): what `handle_window_event` and the
/// control socket's `key` command both ask before a screen acts on a key.
/// False when no screen of the hooks' is open, and for a key with no
/// vanilla name.
pub(super) fn screen_key_pressed(&self, key: KeyCode, repeat: bool) -> bool {
let Some(name) = keybinds::key_name(key) else {
return false;
};
let Some(screen) = self.screen_view_now() else {
return false;
};
let mut canceled = false;
self.native_screen_key_pressed_pre(
&screen,
name,
repeat,
super::glfw_modifiers(self.modifiers),
&mut canceled,
);
canceled
}
/// Text on its way to a text box after the open screen's character hooks
/// (`native_screen_char_typed_pre` per printable character): the cancelled
/// characters are gone and every other one stays, control characters
/// included (a text box reads Backspace, Delete and the like as keys).
/// `None` when none of them is left to type.
pub(super) fn screen_text_typed(&self, text: &str) -> Option<String> {
let Some(screen) = self.screen_view_now() else {
return Some(text.to_owned());
};
let modifiers = super::glfw_modifiers(self.modifiers);
screen_text_hooked(text, |c| {
let mut canceled = false;
self.native_screen_char_typed_pre(&screen, c, modifiers, &mut canceled);
canceled
})
}
/// A key press's characters (`ScreenEvent.CharacterTyped.Pre`, vanilla's
/// `KeyboardHandler.charTyped`), dispatched once the screen has acted on
/// the key: GLFW's char callback comes after its key callback, so the
/// characters go to the screen that is open now — a key that closed the
/// screen leaves none to hear them, as vanilla's closed screen hears
/// nothing, and one that moved the focus types into the box it moved to.
/// Each printable one is offered to `native_screen_char_typed_pre`; the
/// survivors go to the focused text box, control characters (keys) gone.
pub(super) fn screen_chars_typed(&mut self, text: Option<&str>) {
let Some(text) = text else {
return;
};
let Some(screen) = self.screen_view_now() else {
return;
};
let modifiers = super::glfw_modifiers(self.modifiers);
let Some(text) = chars_to_type(text, |c| {
let mut canceled = false;
self.native_screen_char_typed_pre(&screen, c, modifiers, &mut canceled);
canceled
}) else {
return;
};
// A held edit modifier makes a shortcut of the key, not text.
if !self.types_text() {
return;
}
// `Err` only says no text box of the screen takes the characters.
let _ = self.type_chars_into_screen(&text);
}
/// A left or right (`right`) press at `at` (GUI pixels from the screen's
/// top left, so outside it too) on block `block`'s screen, a mod's, over
/// no slot: set `button` to a menu button id when it is on one of yours.
@@ -1656,6 +1929,39 @@ impl Game<'_> {
) {
let _ = (player, command, ran);
}
/// A command a player typed (NeoForge's `CommandEvent`, before
/// `Commands.performCommand` runs it), without its slash: any command,
/// vanilla's and the mods' own, also one the player may not run. Set
/// `canceled` to stop it: nothing runs and nothing is answered but the
/// lines pushed to `replies`, which go to the player as system chat.
/// No player's state is swapped in and the server's player list is busy
/// with this one, so `native_tell` and the other helpers that take a UUID
/// reach nobody here: answer through `replies`. Once for each command a
/// player sends, on a server, and for each line of a quiet command batch
/// (`horizon:quiet_commands`, whose replies are dropped). A command the
/// server refuses before reading it (unsigned chat arguments, the chat
/// rate limit) never reaches it. @API:native_player_command@.
pub(super) fn native_player_command(
&mut self,
player: u128,
name: &str,
command: &str,
canceled: &mut bool,
replies: &mut Vec<Text>,
) {
let _ = (player, name, command, canceled, replies);
}
/// A command a mod registered, typed on the dedicated server's console
/// or sent through `horizon-server console`
/// (`DedicatedServer.handleConsoleInputs`), without its slash: set `ran`
/// when it is yours. There is no player: the source is the console, at
/// permission level 4, so `native_acting_player` is None and the
/// player-only helpers give nothing; `native_feedback` answers on the
/// console. A name no mod registered goes to vanilla's dispatcher as
/// before. @API:native_perform_console_command@.
pub(super) fn native_perform_console_command(&mut self, command: &str, ran: &mut bool) {
let _ = (command, ran);
}
/// A click in a menu a mod opened (`native_open_menu`), which moved
/// nothing: the menu's `slot` (its own first, then the player's
/// inventory, `native_menu_inventory_slot`; -999 outside), the `button`
@@ -1714,6 +2020,20 @@ impl Game<'_> {
pub(super) fn native_item_bar(&self, stack: &ItemStack, bar: &mut Option<(i32, u32)>) {
let _ = (stack, bar);
}
/// Append an item's custom decorations after its default bar, cooldown and count.
/// Coordinates are the icon's top-left in GUI units, as the batch takes them;
/// no item translation is active.
/// Runs wherever the shared item-decoration path is used (hotbar, menus, tooltips,
/// and the other screens that draw item icons: advancements, beacon, stats, trading).
pub(super) fn native_item_decorations(
&self,
stack: &ItemStack,
x: f32,
y: f32,
batch: &mut HudBatch,
) {
let _ = (stack, x, y, batch);
}
/// A player uses `stack` (right click) with `self.hand` on the block at
/// `pos`, `face` the face clicked (`[0, 1, 0]` is the top), once the
/// block itself let the click through (a chest still opens): where
@@ -1757,6 +2077,79 @@ impl Game<'_> {
pub(super) fn native_use_item(&mut self, stack: &ItemStack, claimed: &mut bool) {
let _ = (stack, claimed);
}
/// `Item.getUseDuration` of `stack`, in ticks: `duration` arrives with
/// vanilla's (a consumable's time, 72000 for a shield, a spear, a bow,
/// 0 for an item with no use over time). A mod's own item (outside
/// `minecraft:`) with no `consumable`, `blocks_attacks` or kinetic
/// weapon that answers above 0 is used over time on a right click, as an
/// `Item.use` that calls `startUsingItem`, until it is let go or the time
/// runs out (`native_living_entity_use_item_finish`). A consumable's time
/// changes too, potions and the other vanilla items keep their own.
/// Asked when a use starts, each tick a mod's item is in use, and each
/// frame the hand draws one in use, on both sides. It has no user, as
/// it is asked so often: answer from the stack alone. API @API:item_use_over_time@.
pub(super) fn native_use_duration(&self, stack: &ItemStack, duration: &mut i32) {
let _ = (stack, duration);
}
/// `Item.getUseAnimation` of `stack` in use, for the first person hand:
/// `animation` arrives with its consumable's (`eat`, `drink`), else
/// `None`. `Eat` and `Drink` bring the food to the mouth, `Bow` draws,
/// `Trident` raises, `Brush` sweeps; the rest hold the item still.
/// Asked each frame the hand draws an item in use, for an item with no
/// pose of its own (bows, crossbows, tridents, brushes and spears keep
/// theirs). Client-side. API @API:item_use_over_time@.
pub(super) fn native_use_animation(&self, stack: &ItemStack, animation: &mut UseAnimation) {
let _ = (stack, animation);
}
/// `Player.canEat` for the food `stack`, from `Consumable.canConsume`
/// when eating starts: `can_eat` arrives with vanilla's (creative,
/// `can_always_eat`, or hungry). Clear it to refuse the food, set it to
/// let a full player eat. Both sides (the client starts eating as the
/// server does), so answer from the stack and the player alone. API @API:item_use_over_time@.
pub(super) fn native_can_eat(&self, stack: &ItemStack, can_eat: &mut bool) {
let _ = (stack, can_eat);
}
/// NeoForge's `LivingEntityUseItemEvent.Start`: `entity` starts using
/// `stack` in `hand` (`LivingEntity.startUsingItem`), for `duration`
/// ticks (`native_use_duration`'s answer). `entity` is a mob's
/// `Entity.getId`, None for a player (`native_acting_player` says who);
/// only players fire it so far. Change `duration` (consumables other
/// than potions, and mods' items, count to it; 0 or less lasts until let
/// go), or set `canceled` and the use doesn't start. Both sides, as
/// vanilla's: wherever the world runs (the server, single player
/// in-frame, a LAN host) for every player of the world, and on a
/// server's client for the uses it predicts. Answer the same on both,
/// and change the world only where this `Game` runs the world, not on a
/// server's client: no helper tells the two apart yet (`native_window`
/// is also true in-frame and on an in-frame LAN host). API @API:item_use_over_time@.
pub(super) fn native_living_entity_use_item_start(
&mut self,
stack: &ItemStack,
entity: Option<i32>,
hand: Hand,
duration: &mut i32,
canceled: &mut bool,
) {
let _ = (stack, entity, hand, duration, canceled);
}
/// NeoForge's `LivingEntityUseItemEvent.Finish`: `entity`'s use of
/// `stack` in `hand` ran its time (`LivingEntity.completeUsingItem`),
/// `duration` the ticks left, always 0. `entity` is as in
/// `native_living_entity_use_item_start`. `result` arrives with what
/// `finishUsingItem` left in the hand (the bowl of a stew, none for the
/// last apple; a mod's own item is kept) and may be replaced. Fired for
/// consumables, potions and mods' items. Server-side, for every player
/// of the world (the helpers read the acting one). API @API:item_use_over_time@.
pub(super) fn native_living_entity_use_item_finish(
&mut self,
stack: &ItemStack,
entity: Option<i32>,
hand: Hand,
duration: i32,
result: &mut Option<ItemStack>,
) {
let _ = (stack, entity, hand, duration, result);
}
/// A player breaks the block `state` at `pos`, once the game allowed it
/// (the held item can break blocks, the debug stick didn't take the
/// click): where `ServerPlayerGameMode.destroyBlock` starts. Set
@@ -1963,7 +2356,9 @@ impl Game<'_> {
/// entity's data (a shulker box's items) after this. Add, change or take out
/// stacks; empty ones don't drop (NeoForge's global loot modifiers, Fabric's
/// `LootTableEvents.MODIFY_DROPS`); `context` says what dropped them and
/// how, and it is called even when `drops` is empty. It hears blocks that a
/// how, and it is called even when `drops` is empty. A player's use drops
/// through it too (`NativeLootDrops::used`: shears, a brush, an item
/// frame), with that player named. It hears blocks that a
/// player, an explosion or a mob breaks, not other breaks (leaves decaying,
/// a torch losing its wall, a piston, `/setblock ... destroy`), nor a
/// creative break or a tool that can't harvest the block, nor experience.
@@ -2289,6 +2684,10 @@ impl GameLevel<'_> {
/// directional particle. The return value means accepted, not delivered.
/// Block particle types require their block state; item, trail and
/// vibration particles are not supported by the current network dispatch.
/// `ParticleOptions::named("mymod:spark")` sends a mod's own type
/// (API @API:mod_particle_types@): Horizon clients draw it from the sprite
/// set in the mod's `assets/<namespace>/particles/<path>.json`, a client
/// without that file draws nothing, and vanilla clients aren't sent it.
pub(super) fn native_send_particles(
&mut self,
options: &ParticleOptions,
@@ -2299,6 +2698,16 @@ impl GameLevel<'_> {
count: i32,
) -> bool {
use ParticleType as T;
// A native mod's own type: the name has to be namespaced, and a client
// draws it only when its pack has the sprite set.
if let Some(name) = &options.mod_particle {
let namespaced = name
.split_once(':')
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty());
if !self.native_authoritative || !namespaced {
return false;
}
}
let supported = match options.ty {
T::Block | T::BlockMarker | T::FallingDust | T::DustPillar | T::BlockCrumble => {
state.is_some_and(|id| self.registry.state(id).is_some())
@@ -2525,6 +2934,62 @@ impl Game<'_> {
let _ = self.process_level_events();
changed
}
/// The strongest redstone signal reaching the block at `pos` from its six
/// neighbours, 0 to 15 (`SignalGetter.getBestNeighborSignal`, what a
/// dispenser or a piston reads), in the level in place, `dimension`. 0 on
/// a client's copy of the world and for another dimension. A mod's
/// explosive, launcher or machine reads it in its block entity's tick to
/// go off or switch on. API @API:native_neighbor_signal@.
pub(super) fn native_neighbor_signal(&mut self, dimension: &str, pos: [i32; 3]) -> i32 {
if (self.serving.is_none() && self.options.dedicated.is_none())
|| dimension != self.dim.id()
{
return 0;
}
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.best_neighbor_signal(level, ticks, at))
}
/// The light at `pos` in the world in place, as `(block, sky)`, each 0 to
/// 15 (`Level.getBrightness` for `LightLayer.BLOCK` and `LightLayer.SKY`).
/// A solar panel checks for full sky light above it, a lamp or a mob farm
/// for darkness. `(0, 0)` in a chunk that isn't loaded. Like the other
/// reads here it reads the world this game holds: the level in place on
/// a server, the server's copy on a client. API @API:native_world_state@.
pub(super) fn native_light(&self, pos: [i32; 3]) -> (u8, u8) {
if !self.world.loaded(pos[0], pos[2]) {
return (0, 0);
}
let light = self.world.light(pos[0], pos[1], pos[2]);
(light & 15, light >> 4)
}
/// The rain and thunder levels of the world in place, each 0.0 to 1.0
/// (`Level.getRainLevel` and `Level.getThunderLevel`): what a solar panel
/// or a wind turbine reads. Both 0 in the Nether and the End, which have
/// no weather (`Level.canHaveWeather`). API @API:native_world_state@.
pub(super) fn native_weather(&self) -> (f32, f32) {
if self.dim != super::Dim::Overworld {
return (0.0, 0.0);
}
self.world.weather.levels(1.0)
}
/// The world's time of day in ticks (`Level.getOverworldClockTime`), the
/// same as `native_clock`'s `day_time`; it keeps counting past 24000, so
/// take it modulo 24000 for the hour. API @API:native_world_state@.
pub(super) fn native_day_time(&self) -> i64 {
self.world.day_time
}
/// The lowest block y and the height of the world in place
/// (`LevelHeightAccessor.getMinY` and `getHeight`): how deep a quarry or a
/// miner may dig. API @API:native_world_state@.
pub(super) fn native_world_height(&self) -> (i32, i32) {
(self.world.shape.min_y, self.world.shape.height)
}
/// The most of the item `item` one slot holds (`Item.getDefaultMaxStackSize`),
/// or `None` for an unknown item: for a mod's container that fills or
/// merges stacks itself. API @API:native_world_state@.
pub(super) fn native_max_stack_size(&self, item: &str) -> Option<u32> {
self.res.items().get(item).map(|i| i.max_stack)
}
/// The random of the server level `dimension`: vanilla's `Level.random`
/// (`level.getRandom()`), the one its explosions, block drops and other
/// level draws use, so a mod's draws move vanilla's later ones as a Java
@@ -2912,6 +3377,16 @@ impl Game<'_> {
dimension: self.dim.id(),
})
}
/// The clock of the world this window shows, for client mods: its game
/// time and time of day as the server last sent them
/// (`ClientboundSetTimePacket`), advanced each tick between packets; none
/// on the title screen. API @API:native_clock@.
pub(super) fn native_clock(&self) -> Option<NativeClock> {
self.player.is_some().then_some(NativeClock {
game_time: self.world.game_time,
day_time: self.world.day_time,
})
}
/// The frame counter: frames in the last second, the slowest frame and
/// the 1% low of the last 5 seconds, in frames per second.
pub(super) fn native_frame_rate(&self) -> (u32, u32, u32) {
@@ -3145,6 +3620,33 @@ impl Game<'_> {
player,
tool,
explosion,
used: false,
};
self.native_loot_drops(&context, drops);
drops.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
}
/// The mods' say on what the acting player's use drops
/// (`NativeLootDrops::used`): `table` is the loot table rolled, if any.
pub(super) fn native_use_drops(
&mut self,
table: &str,
origin: [f64; 3],
block: Option<([i32; 3], BlockStateId)>,
entity: Option<(u32, &str)>,
tool: Option<&ItemStack>,
drops: &mut Vec<ItemStack>,
) {
let player = Some(self.native_acting_id());
let context = NativeLootDrops {
table,
dimension: self.dim.id(),
origin,
block,
entity,
player,
tool,
explosion: None,
used: true,
};
self.native_loot_drops(&context, drops);
drops.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
@@ -3912,9 +4414,12 @@ fn debug_takes(corner: Corner, debug: bool, charts: [bool; 2]) -> bool {
}
}
/// A menu of `MOD_MENUS`, by its name.
/// A menu of `MOD_MENUS` or a content mod's menu type, by its name.
fn mod_menu(name: &str) -> Option<(&'static str, usize)> {
MOD_MENUS.into_iter().find(|(menu, _)| *menu == name)
MOD_MENUS
.into_iter()
.find(|(menu, _)| *menu == name)
.or_else(|| horizon_client::containers::mod_menu_type(name).map(|m| (m.id, m.slots)))
}
/// `state` with its property `key` set to `value`; none when the block has
@@ -4006,6 +4511,54 @@ fn box_meets(b: &horizon_core::geometry::Aabb, min: [f64; 3], max: [f64; 3]) ->
#[cfg(test)]
mod tests {
/// The characters a `ScreenEvent.CharacterTyped.Pre` sees are the
/// printable ones of a text: a cancelled one leaves that text and a
/// control character (a key, not text) is never offered a hook.
#[test]
fn typed_text_keeps_control_characters_and_drops_cancelled_ones() {
let mut offered = Vec::new();
let left = super::screen_text_hooked("a\u{7f}b\u{1b}c", |c| {
offered.push(c);
c == 'b'
});
assert_eq!(offered, ['a', 'b', 'c']);
assert_eq!(left.as_deref(), Some("a\u{7f}\u{1b}c"));
// Every printable character cancelled: no text is left to type.
assert_eq!(super::screen_text_hooked("ab", |_| true), None);
// Nothing to offer: an empty text stays empty and keys alone stay whole.
assert_eq!(super::screen_text_hooked("", |_| true).as_deref(), Some(""));
assert_eq!(
super::screen_text_hooked("\u{7f}\t", |_| true).as_deref(),
Some("\u{7f}\t")
);
// What no hook cancels is handed back as it came in.
assert_eq!(
super::screen_text_hooked("hello", |_| false).as_deref(),
Some("hello")
);
}
/// The window's key path: a key whose press a mod cancelled still types
/// the characters no mod cancelled (vanilla's separate `charTyped`), while
/// the key characters never reach the text box.
#[test]
fn a_cancelled_key_still_types_the_characters_left() {
// `axb` with 'x' cancelled: the key went no further, `ab` still types.
assert_eq!(
super::chars_to_type("axb", |c| c == 'x').as_deref(),
Some("ab")
);
// Backspace, Escape and the like are keys, not text.
assert_eq!(
super::chars_to_type("a\u{7f}\u{1b}b", |_| false).as_deref(),
Some("ab")
);
// Every character cancelled: the box takes none of them.
assert_eq!(super::chars_to_type("ab", |_| true), None);
// Nothing typed: nothing to hand over.
assert_eq!(super::chars_to_type("", |_| false), None);
}
/// A shake is scaled by Distortion Effects, bounded on each axis, and a
/// value that isn't finite is none.
#[test]
@@ -0,0 +1,82 @@
//! The player's motion for mods (docs/native-mods.md, "A player's motion"): a push for the local player, on a
//! client, and a player's creative flying speed, on a server.
use super::Game;
/// Each component of a push is clamped to this, in blocks a tick.
const PUSH_MAX: f32 = 4.0;
/// The fastest flying speed a mod may set: 1000 times vanilla's 0.05, for crossing a world's distances in seconds.
/// It only stops a stray value from throwing the player millions of blocks in a tick; past about 2 the player
/// outruns the chunks loading. Past about 0.45 (sprinting; 0.9 without) a player flies more than 10 blocks a
/// tick: the single-player owner is never checked, as in vanilla, but anyone else meets the server's moved-too-quickly
/// check, which can send them back (`Hub::position_change`, `ServerGamePacketListenerImpl.handleMovePlayer`).
const FLYING_MAX: f32 = 50.0;
/// Vanilla's creative flying speed (`Abilities.flyingSpeed`).
const FLYING_VANILLA: f32 = 0.05;
/// The velocity after a push: `kick` added, each component clamped; none when one of `kick`'s isn't a number.
fn pushed(velocity: [f64; 3], kick: [f32; 3]) -> Option<[f64; 3]> {
if !kick.iter().all(|v| v.is_finite()) {
return None;
}
Some(std::array::from_fn(|i| {
velocity[i] + f64::from(kick[i].clamp(-PUSH_MAX, PUSH_MAX))
}))
}
#[allow(dead_code)]
impl Game<'_> {
/// For a client mod: a push added to the local player's velocity, in blocks a tick, as an
/// explosion's knockback is (the window's player moves itself, as vanilla's client does). Each component is
/// clamped to -4..4; a spectator, or a value that is not a number, is left alone. API @API:native_motion@.
pub(super) fn native_push_player(&mut self, kick: [f32; 3]) {
if let Some(p) = self.player.as_mut()
&& !p.spectator
&& let Some([x, y, z]) = pushed([p.velocity.x, p.velocity.y, p.velocity.z], kick)
{
p.velocity = horizon_core::geometry::Vec3::new(x, y, z);
if kick[1] > 0.0 {
p.ground.on_ground = false;
}
}
}
/// For a server mod: `player`'s creative flying speed (vanilla's 0.05 when none, at most `FLYING_MAX`), kept on
/// the server's player and sent to their client. A respawn or a game mode change keeps it, and it is saved
/// with the player (`abilities.flySpeed`, as vanilla saves it). False for a player not here or a client of a
/// server, and inside a player's hook, where the server's player list is busy: call it from a tick hook.
/// API @API:native_motion@.
pub(super) fn native_set_flying_speed(&mut self, player: u128, speed: Option<f32>) -> bool {
let speed = speed
.filter(|s| s.is_finite())
.map_or(FLYING_VANILLA, |s| s.clamp(0.0, FLYING_MAX));
let Some(host) = self.lan.as_mut() else {
return false;
};
let Some(conn) = host
.hub
.guests
.iter()
.find(|g| g.uuid == player && g.placed)
.map(|g| g.conn)
else {
return false;
};
host.hub.set_flying_speed(conn, speed);
true
}
}
#[cfg(test)]
mod motion_tests {
use super::*;
#[test]
fn a_push_adds_to_the_velocity_clamped() {
assert_eq!(
pushed([0.1, -0.08, 0.0], [0.8, 0.55, -0.5]),
Some([0.1 + 0.8f32 as f64, -0.08 + 0.55f32 as f64, -0.5])
);
assert_eq!(pushed([0.0; 3], [9.0, -9.0, 0.0]), Some([4.0, -4.0, 0.0]));
assert_eq!(pushed([1.0; 3], [f32::NAN, 0.0, 0.0]), None);
assert_eq!(pushed([1.0; 3], [0.0, f32::INFINITY, 0.0]), None);
}
}
+264 -12
View File
@@ -275,20 +275,13 @@ impl Game<'_> {
})
.collect()
}
/// A system line in that player's chat; false when they aren't on.
pub(super) fn native_tell(&self, uuid: u128, line: Text) -> bool {
let Some(host) = &self.lan else {
return false;
};
let Some(g) = host.hub.guests.iter().find(|g| g.placed && g.uuid == uuid) else {
return false;
};
host.hub.send_to(g.conn, &system_chat(&line));
true
/// A system line in that player's chat, also inside a player's hook for
/// the acting player; false when they aren't on.
pub(super) fn native_tell(&mut self, uuid: u128, line: Text) -> bool {
self.native_send(uuid, system_chat(&line))
}
/// A line over that player's hotbar (the action bar), also inside a
/// player's hook for the acting player (`native_tell` reaches nobody
/// there); false when they aren't on. API 21.
/// player's hook for the acting player; false when they aren't on. API 21.
pub(super) fn native_action_bar(&mut self, uuid: u128, line: Text) -> bool {
let packet = C::SystemChat(play::SystemChat {
content: component_nbt(&line.component()),
@@ -702,6 +695,188 @@ impl Game<'_> {
count,
)
}
/// Gives `stack` whole (its damage, name, enchantments... kept) to the
/// player `uuid`, as `Inventory.add` places it: onto equal stacks first,
/// then into empty inventory slots, never the armour or an empty off
/// hand. What did not fit comes back (nothing is dropped): all of it for
/// a player who isn't here, is dead, or whose state is swapped out by
/// another player's (inside that player's hook, only they are reached).
/// The player's screen follows as for any change to their inventory.
/// What an auto-pickup or a machine's output to a player needs, from any
/// server hook. API @API:give_stack@.
pub(super) fn native_give_stack(
&mut self,
uuid: u128,
mut stack: ItemStack,
) -> Option<ItemStack> {
if stack.count == 0 {
return None;
}
if uuid == self.native_acting_id() {
// Their state is in place: the host's, or a guest's swapped in.
if self
.player
.as_ref()
.is_none_or(|p| p.health.health() <= 0.0)
{
return Some(stack);
}
return self.give(stack);
}
// Inside a player's hook their state is in place and the records of
// the others may hold swapped state (turns nest): only they are
// reached, as the doc says.
if self.lan_acting.is_some() {
return Some(stack);
}
let max = self
.res
.items()
.get(&stack.item)
.map_or(64, |i| i.max_stack);
let Some(host) = self.lan.as_mut() else {
return Some(stack);
};
let Some(g) = host
.hub
.guests
.iter_mut()
.find(|g| g.placed && !g.dead && g.uuid == uuid)
else {
return Some(stack);
};
// inventory_add indexes the 36 main slots.
if g.inventory.len() < 36 {
g.inventory.resize(36, None);
}
let left = super::inventory_add(
&mut g.inventory,
&mut g.offhand,
g.selected,
max,
&mut stack,
);
left.then_some(stack)
}
}
/// An experience orb as a server mod reads it (`native_experience_orbs`).
/// Read by mods only.
#[allow(dead_code)]
#[derive(Clone, Debug)]
pub(super) struct NativeExperienceOrb {
/// The entity's ID, as `native_entity_join_level` names it.
pub id: u32,
pub position: [f64; 3],
/// `ExperienceOrb.value`: the points one pickup gives.
pub value: u32,
/// `ExperienceOrb.count`: orbs merged into this one, each worth `value`.
pub count: u32,
}
/// Whether an orb at `position` (its box: 0.5 wide and high, standing on
/// it) meets the box `min`..`max`, touching faces excluded (`AABB.intersects`).
fn orb_meets(position: [f64; 3], min: [f64; 3], max: [f64; 3]) -> bool {
position[0] - 0.25 < max[0]
&& position[0] + 0.25 > min[0]
&& position[1] < max[1]
&& position[1] + 0.5 > min[1]
&& position[2] - 0.25 < max[2]
&& position[2] + 0.25 > min[2]
}
/// Experience for server mods. API @API:give_experience@.
#[allow(dead_code)]
impl Game<'_> {
/// The experience orbs of the server level `dimension` whose box meets
/// the box `min`..`max`, in the level's order: what a mod reads before it
/// cancels an orb in `native_entity_join_level`, to give its points with
/// `native_give_experience`. Empty for a level not in place and on a
/// client of a server.
pub(super) fn native_experience_orbs(
&self,
dimension: &str,
min: [f64; 3],
max: [f64; 3],
) -> Vec<NativeExperienceOrb> {
if (self.serving.is_none() && self.options.dedicated.is_none())
|| dimension != self.dim.id()
{
return Vec::new();
}
self.orbs
.iter()
.filter(|o| o.count > 0)
.map(|o| NativeExperienceOrb {
id: o.id,
position: [o.position.x, o.position.y, o.position.z],
value: o.value,
count: o.count,
})
.filter(|o| orb_meets(o.position, min, max))
.collect()
}
/// Gives `points` of experience to the player `uuid` as an orb they touch
/// does (`ExperienceOrb.playerTouch`): Mending repairs their damaged items
/// with it first (`repairPlayerItems`), then the rest goes to their
/// experience (`Player.giveExperiencePoints`), with the level-up sound
/// every fifth level. No orb pickup sound. False, and nothing given, for
/// a player who isn't here or is dead, and for another player than the one
/// whose state is swapped in (inside that player's hook, only they are
/// reached), and while the server's player list is busy elsewhere in the
/// tick: give first, and cancel an orb only when this returned true.
/// Their bar follows as for any change to their experience.
pub(super) fn native_give_experience(&mut self, uuid: u128, points: u32) -> bool {
self.native_as_player(uuid, |game| {
let rest = if points > 0 { game.mending(points) } else { 0 };
if rest > 0 {
game.give_orb_points(rest);
}
})
}
/// Adds `levels` to the player `uuid`'s level, or takes them off when
/// negative (`Player.giveExperienceLevels`): what the anvil and the
/// enchanting table charge with, and a mod can charge or reward with.
/// Below level 0 the level and the points are 0. The progress toward the
/// next level is kept. False, and nothing changed, as for
/// `native_give_experience`.
pub(super) fn native_give_levels(&mut self, uuid: u128, levels: i32) -> bool {
self.native_as_player(uuid, |game| game.give_levels(levels))
}
/// Runs `f` with the player `uuid`'s state in place: theirs already when
/// they are the acting player (the host, or a guest swapped in), else a
/// guest's through `as_guest`, only where no other player is swapped in.
/// False when it didn't run: no such player here, or dead.
fn native_as_player(&mut self, uuid: u128, f: impl FnOnce(&mut Self)) -> bool {
if uuid == self.native_acting_id() {
if self
.player
.as_ref()
.is_none_or(|p| p.health.health() <= 0.0)
{
return false;
}
f(self);
return true;
}
if self.lan_acting.is_some() {
return false;
}
let Some(mut host) = self.lan.take() else {
return false;
};
let conn = host
.hub
.guests
.iter()
.find(|g| g.placed && !g.dead && g.uuid == uuid)
.map(|g| g.conn);
let ran = conn
.and_then(|conn| self.as_guest(&mut host, conn, f))
.is_some();
self.lan = Some(host);
ran
}
}
fn system_chat(line: &Text) -> C {
@@ -773,6 +948,30 @@ impl Game<'_> {
}
}
impl Game<'_> {
/// A console line for the mods: true when a mod registered its command
/// (`native_register_commands`). The console is level 4, so a registered
/// usage is always allowed; a command no mod takes answers as vanilla's
/// unknown command.
pub(super) fn perform_native_console_command(&mut self, line: &str) -> bool {
let Some(host) = &self.lan else {
return false;
};
if Self::native_command_level(host, line).is_none() {
return false;
}
let mut ran = false;
self.native_perform_console_command(line, &mut ran);
if !ran {
self.native_feedback(
Text::translate("command.unknown.command", &[])
.with_color(horizon_client::text::RED),
);
}
true
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -843,6 +1042,59 @@ mod tests {
);
}
#[test]
fn orbs_are_found_by_their_half_block_box() {
// A block's cell, as a mod asks for a broken block's drops.
let (min, max) = ([10.0, 64.0, -3.0], [11.0, 65.0, -2.0]);
assert!(orb_meets([10.5, 64.2, -2.5], min, max), "inside");
assert!(
orb_meets([11.2, 64.0, -2.5], min, max),
"its box reaches in"
);
assert!(
orb_meets([10.5, 63.6, -2.5], min, max),
"standing just below"
);
assert!(!orb_meets([11.25, 64.0, -2.5], min, max), "a face touching");
assert!(!orb_meets([10.5, 63.5, -2.5], min, max), "its top touching");
assert!(!orb_meets([10.5, 65.0, -2.5], min, max), "standing on top");
}
#[test]
fn whole_stacks_are_given_onto_equal_stacks_only() {
use super::super::inventory_add;
let mut inv = vec![stack("minecraft:dirt", 64); 36];
inv[2] = stack("minecraft:iron_sword", 1);
inv[2].as_mut().unwrap().damage = 5;
inv[5] = stack("minecraft:arrow", 60);
inv[7] = None;
inv[20] = None;
let mut off = stack("minecraft:arrow", 62);
// A damaged sword never joins another stack: the first free slot.
let mut sword = stack("minecraft:iron_sword", 1).unwrap();
sword.damage = 40;
assert!(!inventory_add(&mut inv, &mut off, 5, 1, &mut sword));
assert_eq!(inv[7].as_ref().unwrap().damage, 40, "its damage is kept");
assert_eq!(inv[2].as_ref().unwrap().damage, 5);
// Arrows: the selected slot, then the off hand, then a free slot,
// and what is left over comes back.
let mut arrows = stack("minecraft:arrow", 80).unwrap();
assert!(inventory_add(&mut inv, &mut off, 5, 64, &mut arrows));
assert_eq!(inv[5].as_ref().unwrap().count, 64);
assert_eq!(off.as_ref().unwrap().count, 64);
assert_eq!(inv[20].as_ref().unwrap().count, 64);
assert_eq!(arrows.count, 80 - 4 - 2 - 64);
// A full inventory with an empty off hand takes nothing.
let mut off = None;
let mut apple = stack("minecraft:apple", 3).unwrap();
assert!(inventory_add(&mut inv, &mut off, 0, 64, &mut apple));
assert!(off.is_none(), "never into an empty off hand (getFreeSlot)");
assert_eq!(apple.count, 3);
}
#[test]
fn usages_merge_into_one_tree_with_runnable_nodes() {
let (t, levels, problems) = tree(&[
@@ -62,15 +62,38 @@ impl Game<'_> {
let options = &pack.manifest.options;
let values = horizon_client::shader_pack_list::load_values(path, options);
let constants = options.iter().map(|o| o.id.clone()).zip(values).collect();
let sprites = self.pack.render.sprite_layers.iter();
let tags = pack
.manifest
.sprite_tags(sprites.map(|(name, layer)| (name.as_str(), *layer)));
let sprites = || {
let layers = self.pack.render.sprite_layers.iter();
layers.map(|(name, layer)| (name.as_str(), *layer))
};
let tags = pack.manifest.sprite_tags(sprites());
let materials = pack.manifest.sprite_materials(sprites());
// Each light-giving block state's `[[lights]]` colour, with its level.
let mut lights = horizon_client::gfx::block_light::LightTable::default();
if !pack.manifest.lights.is_empty() {
for (id, state) in self.res.facts().states.iter().enumerate() {
if state.emission == 0 {
continue;
}
if let Some(color) = pack.manifest.light_of(&state.name) {
lights.insert(id as u32, (color, state.emission));
}
}
}
let labels = renderer
.set_native_shader_pack(Some((report, constants, tags)))
.set_native_shader_pack(Some((report, constants, tags, materials, lights)))
.map_err(|e| vec![e])?;
Ok((pack.manifest, labels))
});
// The resources were loaded without a native pack, so without the resource packs'
// LabPBR maps its material stage reads: load them once, now that one runs.
if result.as_ref().is_ok_and(|(m, _)| m.material.is_some())
&& !horizon_client::sprites::labpbr_maps()
{
horizon_client::sprites::set_labpbr_maps(true);
log::info!("Horizon: reloading resources for the LabPBR maps a native pack reads");
self.reload_packs();
}
let name = path.file_name().unwrap_or_default().to_string_lossy();
match result {
Ok((m, labels)) => {
@@ -6,10 +6,9 @@
//! End portals here (`EndPortalBlock.entityInside` / `getPortalDestination`);
//! the player's End portals are game/end.rs's.
//!
//! Only one dimension is loaded at a time. The player's destination is found
//! once the other dimension has loaded around it. An item or mob that goes
//! through waits as an `Arrival` (its saved compound and where it stood in its
//! portal) until the player is in that dimension and its exit is loaded.
//! A server keeps one level per dimension. Entity portal processes and
//! cooldowns follow their level; shared `Arrival`s hold each traveller's
//! saved compound until its destination chunks are loaded.
use super::{Dim, Game, Screen};
use horizon_client::Result;
use horizon_client::fast_hash::FxHashMap;
@@ -86,7 +85,7 @@ pub(super) struct Portals {
/// `LocalPlayer.portalProcess.isInsidePortalThisTick`.
client_inside: bool,
/// Items and mobs, by entity id.
entities: HashMap<u32, PortalState>,
pub(super) entities: HashMap<u32, PortalState>,
/// Positions before this tick's moves, by entity id.
pub(super) before: FxHashMap<u32, Vec3>,
arrivals: Vec<Arrival>,
@@ -1103,6 +1102,60 @@ impl Game<'_> {
}
}
/// `FallingBlockEntity.tick`: block effects mark its portal, then
/// `Entity.handlePortal` transfers it before the landing branch runs.
pub(super) fn falling_through_portal(&mut self, f: &mut super::FallingBlock) -> Option<Dim> {
if self.remote.is_some() {
return None;
}
let mut sections = std::mem::take(&mut self.portals.sections);
let entry = self.swept_portal(f.previous, f.position, 0.98, 0.98, &mut sections);
self.portals.sections = sections;
if let Some(entry) = entry {
f.portal.set_inside(entry, ENTITY_DELAY);
}
let entry = f.portal.handle(true, 0, ENTITY_DELAY)?;
let plan = self.entity_plan(entry, f.position, 0.98, 0.98, false);
if !self.may_enter(plan.dim) {
return None;
}
let uuid = f.saved.as_ref().map_or_else(
|| {
if f.id == 0 {
horizon_client::save::random_uuid()
} else {
self.uuid_for(f.id)
}
},
|saved| saved.0,
);
let modelled = horizon_client::entity_nbt::falling_block_to_nbt(
self.state_to_nbt(f.state),
f.position,
f.velocity,
f.age,
f.drop_item,
f.damage,
f.fall_distance,
uuid,
);
let mut tag = super::persist::with_raw(modelled, f.saved.as_ref().map(|s| &s.1));
if let Tag::Compound(fields) = &mut tag {
fields.insert("PortalCooldown".into(), Tag::Int(ENTITY_DELAY));
if f.no_gravity {
fields.insert("NoGravity".into(), Tag::Byte(1));
} else {
fields.remove(&"NoGravity".into());
}
}
if f.id != 0 {
self.forget_entity(f.id);
}
self.portals.arrivals.push(Arrival { plan, tag });
self.portals.dirty = true;
Some(plan.dim)
}
// ---- persistence ----
fn arrivals_path(&self) -> std::path::PathBuf {
@@ -441,6 +441,11 @@ impl Game<'_> {
}
/// Settings that Horizon applies beyond render distance, volume and GUI scale.
pub(super) fn apply_ui_settings(&mut self) {
if horizon_client_render::mipmap::set_dither_cutouts(
self.settings.horizon.dither_cutout_mips,
) {
self.reload_packs();
}
self.apply_fullscreen();
let s = &self.settings;
if let Some(r) = self.renderer.as_mut() {
+3
View File
@@ -331,6 +331,8 @@ impl Game<'_> {
.set_resource_root(self.options.resources.clone());
self.maps.slots.forget();
self.map_patches.clear();
// Kept mod block entities name the old atlas's sprites.
self.kept_block_entities.get_mut().clear();
// The atlas's sprites moved: the particle engine re-reads every rect
// and the packs' sprite sets, or every particle draws a stale region.
self.particles
@@ -370,6 +372,7 @@ impl Game<'_> {
self.ui.chat.rescale(&self.gui.atlas);
// The creative screen survives the reload; item names may not.
self.creative_screen.forget_items();
self.native_client_resources_reloaded();
Ok(())
}
}
+27 -17
View File
@@ -1183,8 +1183,7 @@ impl Game<'_> {
/// The real mouse, before the screen takes it: where it is, and a press
/// or a scroll hands it the pointer back from the keyboard, at the
/// place the mouse left it (`setLastInputType(MOUSE)`). A left press
/// focuses what it lands on, for the next Tab or arrow.
/// place the mouse left it (`setLastInputType(MOUSE)`).
pub(super) fn key_focus_mouse(&mut self, event_loop: &dyn EventLoopCtl, event: &WindowEvent) {
if self.pad.menu.synthetic {
return;
@@ -1195,27 +1194,38 @@ impl Game<'_> {
}
WindowEvent::MouseInput {
state: ElementState::Pressed,
button,
..
} => {
self.key_focus_to_mouse(event_loop);
if *button == MouseButton::Left && self.pad_in_menu() {
let (cx, cy) = self.cursor;
self.pad.menu.origin = self
.key_targets()
.into_iter()
.find(|t| {
let [x, y, w, h] = t.rect;
cx >= x && cy >= y && cx < x + w && cy < y + h
})
.map(|t| t.rect);
}
}
} => self.key_focus_to_mouse(event_loop),
WindowEvent::MouseWheel { .. } => self.key_focus_to_mouse(event_loop),
_ => {}
}
}
/// Only a press accepted by ScreenEvent.Pre may focus the widget under it.
pub(super) fn key_focus_mouse_press(&mut self, event: &WindowEvent) {
if !self.pad.menu.synthetic
&& self.pad_in_menu()
&& matches!(
event,
WindowEvent::MouseInput {
state: ElementState::Pressed,
button: MouseButton::Left,
..
}
)
{
let (cx, cy) = self.cursor;
self.pad.menu.origin = self
.key_targets()
.into_iter()
.find(|t| {
let [x, y, w, h] = t.rect;
cx >= x && cy >= y && cx < x + w && cy < y + h
})
.map(|t| t.rect);
}
}
/// The keyboard lets go of the pointer: it goes back to the real mouse.
fn key_focus_to_mouse(&mut self, event_loop: &dyn EventLoopCtl) {
if !std::mem::take(&mut self.pad.menu.keyboard) {
+18 -7
View File
@@ -94,7 +94,7 @@ fn anon_key(kind: &str, p: Vec3) -> (String, [u64; 3]) {
}
/// The loaded compound underneath the fields Horizon models: what the entity was
/// loaded with, with every key of `modelled` replacing its loaded value.
fn with_raw(modelled: Tag, raw: Option<&Tag>) -> Tag {
pub(super) fn with_raw(modelled: Tag, raw: Option<&Tag>) -> Tag {
let (Some(Tag::Compound(raw)), Tag::Compound(modelled)) = (raw, &modelled) else {
return modelled;
};
@@ -305,7 +305,7 @@ impl Game<'_> {
if uuid.is_some_and(|u| self.restored_vehicle(u).is_some()) {
continue;
}
if matches!(
if (matches!(
kind.as_str(),
"minecraft:experience_orb"
| "minecraft:arrow"
@@ -319,7 +319,8 @@ impl Game<'_> {
| "minecraft:falling_block"
| "minecraft:end_crystal"
| "minecraft:ender_dragon"
) && let Some(at) = entity_nbt::position(&tag)
) || horizon_client::projectiles::thrown_type(&kind).is_some())
&& let Some(at) = entity_nbt::position(&tag)
{
self.persist
.anon
@@ -384,7 +385,7 @@ impl Game<'_> {
self.add_projectile(p);
}
}
"minecraft:snowball" | "minecraft:egg" | "minecraft:ender_pearl" => {
id if entity_nbt::throwable_kind(id).is_some() => {
if let Some(mut p) = entity_nbt::throwable_from_nbt(&tag, player_uuid) {
self.claim_owner(&mut p);
self.add_projectile(p);
@@ -406,6 +407,14 @@ impl Game<'_> {
velocity,
age,
no_gravity,
portal: horizon_client::nether_portal::PortalState {
cooldown: horizon_client::save::number(
tag.get("PortalCooldown"),
)
.unwrap_or(0)
as i32,
process: None,
},
drop_item,
fall_distance,
stuck: Vec3::ZERO,
@@ -1093,9 +1102,11 @@ impl Game<'_> {
f.fall_distance,
uuid,
);
out.entry(chunk_of(f.position))
.or_default()
.push(with_raw(tag, raw.as_ref()));
let mut tag = with_raw(tag, raw.as_ref());
if let Tag::Compound(fields) = &mut tag {
fields.insert("PortalCooldown".into(), Tag::Int(f.portal.cooldown));
}
out.entry(chunk_of(f.position)).or_default().push(tag);
}
}
let boats: Vec<usize> = (0..self.boats.len())
+1 -3
View File
@@ -425,9 +425,7 @@ impl Game<'_> {
pub(super) fn use_potion_item(&mut self, stack: &ItemStack) -> horizon_client::Result<bool> {
match &*stack.item {
"minecraft:potion" | "minecraft:ominous_bottle" => {
if self.using.is_none() {
self.using = Some((self.held_slot(), 0));
}
self.start_using_item();
Ok(true)
}
"minecraft:splash_potion"
+5 -1
View File
@@ -115,7 +115,11 @@ impl Game<'_> {
return;
}
let g = host.hub.guest(conn).expect("found above");
if excused(g, &self.world, host.states.as_slice(), &host.hub.guests) {
// A client that hasn't said it loaded the level is still on its loading screen, where it
// doesn't move (the server stopped waiting for it after 60 ticks): the replay falls or drifts
// where it stands. Its first move counts as any other.
let loading = g.loading && !over.moved;
if loading || excused(g, &self.world, host.states.as_slice(), &host.hub.guests) {
if let Some(replay) = host
.hub
.guest_mut(conn)
+11 -4
View File
@@ -42,11 +42,18 @@ impl Game<'_> {
// ItemEntity in front of the carved side, moved by the level random
// (no pickup delay).
let (dx, dz) = (f64::from(dx), f64::from(dz));
for seeds in self.sequence_loot(
"minecraft:carve/pumpkin",
"minecraft:carve/pumpkin",
let table = "minecraft:carve/pumpkin";
let mut drops = self.sequence_loot(table, table, Some(shears));
let origin = pos.map(|c| f64::from(c) + 0.5);
self.native_use_drops(
table,
origin,
Some((pos, state)),
None,
Some(shears),
) {
&mut drops,
);
for seeds in drops {
let at = [
f64::from(pos[0]) + 0.5 + dx * 0.65,
f64::from(pos[1]) + 0.1,
+51 -10
View File
@@ -1064,6 +1064,8 @@ impl Game<'_> {
self.titles.overlay_time = 0;
// GameRenderer.resetData and narrator.clear (Minecraft.disconnect).
self.item_activation.reset();
// Kept mod block entity geometry belongs to the world leaving.
self.kept_block_entities.get_mut().clear();
self.clear_narrator();
// A world is leaving either way: its icon attempt goes with it.
self.world_icon = None;
@@ -2716,8 +2718,12 @@ impl Game<'_> {
_ => None,
}
}
// A content mod's menu type: its container, then the inventory.
// A workstation: its inputs, its result, then the inventory.
m => {
if let Some(t) = horizon_client::containers::mod_menu_type(m) {
return slots(t.slots);
}
let kind = Self::remote_station_kind(m)?;
let inputs = kind.inputs();
let own = inputs + usize::from(kind.has_result());
@@ -3447,6 +3453,14 @@ impl Game<'_> {
self.using = synced_local_use(using, *flags, selected, |slot| {
self.stack_in(slot).is_some()
});
// onSyncedDataUpdated's useItemRemaining: getUseDuration,
// without startUsingItem's event.
if using.is_none()
&& let Some((slot, _)) = self.using
&& let Some(stack) = self.stack_in(slot).cloned()
{
self.use_duration = self.use_duration(&stack);
}
}
_ => {}
}
@@ -7036,11 +7050,7 @@ impl Game<'_> {
} else if held.as_ref().is_some_and(|s| &*s.item == "minecraft:brush") {
// BrushItem.useOn: brushing starts here (CONSUME, no swing); the
// server brushes and the dust is this client's own.
self.with_hand(hand, |g| {
if g.using.is_none() {
g.using = Some((g.held_slot(), 0));
}
});
self.with_hand(hand, Game::start_using_item);
return true;
} else if held
.as_ref()
@@ -7431,6 +7441,10 @@ impl Game<'_> {
if self.cooldowns.is_on_cooldown(&group) {
return false;
}
// a mod's item used over time starts here too, as the server starts it.
if self.with_hand(hand, |g| g.start_mod_use(&held)) {
return true;
}
// Item.use: Equippable.swapWithEquipmentSlot runs on this client too, so
// the armor slot and the hand change at once (the server confirms).
if info.equip_slot.is_some() && self.with_hand(hand, |g| g.equip_from_hand(&held)) {
@@ -7604,7 +7618,7 @@ impl Game<'_> {
}
};
if start {
self.using = Some((self.held_slot(), 0));
self.start_using_item();
}
start || !matches!(&*held.item, "minecraft:bow" | "minecraft:crossbow")
}
@@ -7618,7 +7632,7 @@ impl Game<'_> {
if self.cooldowns.is_on_cooldown(&group) {
return;
}
self.using = Some((self.held_slot(), 0));
self.start_using_item();
let at = self
.player
.as_ref()
@@ -7631,9 +7645,12 @@ impl Game<'_> {
}
/// Whether `stack` stays in use until the button lets go (bows,
/// crossbows, tridents, spyglasses, shields, eyes of ender, whose
/// `getUseDuration` 0 never counts down to the end): its use only counts
/// ticks here.
/// `getUseDuration` 0 never counts down to the end) or the server ends
/// it (a mod's item): its use only counts ticks here.
fn held_until_released(&self, stack: &ItemStack) -> bool {
if self.mod_use(stack) {
return true;
}
matches!(
&*stack.item,
"minecraft:bow"
@@ -7761,7 +7778,8 @@ impl Game<'_> {
// Consumable.shouldEmitParticlesAndSounds, as in tick_used_item. The
// countdown goes on past zero (LivingEntity.updateUsingItem completes
// only on the server) until the server's USE_ITEM_COMPLETE.
let remaining = consumable.ticks() - ticks as i32;
// getUseItemRemainingTicks: the duration this use started with.
let remaining = self.use_duration - ticks as i32;
if consumable.should_emit(remaining) {
// `emit_consume` plays the eat sound and spawns the bites (when the
// item has consume particles), as `tick_used_item` does on the
@@ -8602,6 +8620,7 @@ impl Game<'_> {
self.remote_open_merchant(p.container, title, menu_title(&p.title));
return;
}
let mod_type = horizon_client::containers::mod_menu_type(&menu);
let entity = match menu.as_str() {
"minecraft:crafting" => None,
"minecraft:furnace" | "minecraft:blast_furnace" | "minecraft:smoker" => {
@@ -8638,6 +8657,14 @@ impl Game<'_> {
.unwrap_or(3);
Some(BlockEntity::Chest(vec![None; rows * 9]))
}
// A content mod's menu type: its container's slots, which its
// mod's screen places.
_ if mod_type.is_some() => Some(BlockEntity::Mod(Box::new(
horizon_client::containers::ModBlockEntity {
items: vec![None; mod_type.map_or(0, |t| t.slots)],
..Default::default()
},
))),
_ => {
// Unsupported menu: tell the server we closed it.
self.send(S::ContainerClose(play::ContainerClose {
@@ -8673,6 +8700,20 @@ impl Game<'_> {
self.world.block_entities.insert(key, entity);
self.container = Some(key);
self.open_native_screen(key, target, p.container, &menu);
// A mod menu type shows through its mod's screen only: without one
// (`MenuScreens.create` finds no screen) the window closes again.
if self.mod_screen.is_none() && mod_type.is_some() {
self.world.block_entities.remove(&key);
self.container = None;
if let Some(r) = self.remote.as_mut() {
r.menu = None;
r.menu_title = None;
}
self.send(S::ContainerClose(play::ContainerClose {
container: p.container,
}));
return;
}
}
self.screen = Screen::Inventory;
self.capture(false);
@@ -297,6 +297,7 @@ impl Game<'_> {
velocity,
age: 0,
no_gravity: false,
portal: Default::default(),
drop_item: false,
fall_distance: 0.0,
stuck: Vec3::ZERO,
+97 -43
View File
@@ -44,11 +44,10 @@ impl Screen {
}
/// The pause menu grid's origin: GridLayout of two 98px columns (padding 4),
/// aligned at (0.5, 0.25). `row3`: the full-width row at y + 122 that holds
/// the `pause_screen_additions` button, or the `minecraft:server_links` one
/// when the tag is empty (`PauseScreen.getCustomAdditions`).
fn pause_grid(w: f32, h: f32, row3: bool) -> (f32, f32) {
let (grid_w, grid_h) = (212.0, 166.0 + if row3 { 24.0 } else { 0.0 });
/// aligned at (0.5, 0.25). Extra rows hold a server dialog or links
/// (`PauseScreen.getCustomAdditions`), followed by the guest profile settings.
fn pause_grid(w: f32, h: f32, extra_rows: u8) -> (f32, f32) {
let (grid_w, grid_h) = (212.0, 166.0 + 24.0 * f32::from(extra_rows));
(((w - grid_w) * 0.5).floor(), ((h - grid_h) * 0.25).floor())
}
@@ -67,10 +66,11 @@ fn pause_buttons(
remote: bool,
links: bool,
additions: bool,
split: bool,
) -> Vec<(f32, f32, f32, &'static str, u8, bool)> {
let row3 = links || additions;
let (x0, y0) = pause_grid(w, h, row3);
let down = if row3 { 24.0 } else { 0.0 };
let (x0, y0) = pause_grid(w, h, u8::from(row3) + u8::from(split));
let down = 24.0 * f32::from(u8::from(row3) + u8::from(split));
let mut v = vec![
(x0 + 4.0, y0 + 50.0, 204.0, "menu.returnToGame", 0, true),
(x0 + 4.0, y0 + 74.0, 98.0, "gui.advancements", 4, true),
@@ -84,6 +84,10 @@ fn pause_buttons(
// CommonDialogData.computeExternalTitle of Dialogs.SERVER_LINKS.
v.push((x0 + 4.0, y0 + 122.0, 204.0, "menu.server_links", 11, true));
}
if split {
let y = y0 + 122.0 + if row3 { 24.0 } else { 0.0 };
v.push((x0 + 4.0, y, 204.0, SPLIT_SETTINGS, 30, true));
}
v.extend([
(x0 + 4.0, y0 + 122.0 + down, 98.0, "menu.options", 1, true),
(
@@ -149,8 +153,15 @@ fn pause_icon_focus_ids(friends: bool) -> Vec<u8> {
.map(|i| if i == FRIENDS_ICON { 10 } else { 12 + i as u8 })
.collect()
}
fn pause_icons(w: f32, h: f32, links: bool, additions: bool) -> [(f32, f32, &'static str); 4] {
let (x0, y0) = pause_grid(w, h, links || additions);
const SPLIT_SETTINGS: &str = "horizon.pages.split_settings";
fn pause_icons(
w: f32,
h: f32,
links: bool,
additions: bool,
split: bool,
) -> [(f32, f32, &'static str); 4] {
let (x0, y0) = pause_grid(w, h, u8::from(links || additions) + u8::from(split));
let (x0, y) = (x0 + 60.0, y0 + 98.0);
[
(x0, y, "gui/sprites/pause_menu/bug"),
@@ -240,9 +251,14 @@ impl Game<'_> {
let links = !self.server_links().is_empty();
let additions = self.pause_screen_additions();
let mut tooltip = None;
for (x, y, bw, key, id, active) in
pause_buttons(w, h, self.external_server(), links, additions.is_some())
{
for (x, y, bw, key, id, active) in pause_buttons(
w,
h,
self.external_server(),
links,
additions.is_some(),
self.options.split_guest,
) {
let is_focused = self.pause_focus == Some(id);
let hover = active && (ui::hovered(self.cursor, x, y, bw, 20.0) || is_focused);
let label = match (id, &additions) {
@@ -255,9 +271,10 @@ impl Game<'_> {
tooltip = Some(Text::translate("menu.custom_options.tooltip", &[]));
}
}
for (i, (x, y, sprite)) in pause_icons(w, h, links, additions.is_some())
.into_iter()
.enumerate()
for (i, (x, y, sprite)) in
pause_icons(w, h, links, additions.is_some(), self.options.split_guest)
.into_iter()
.enumerate()
{
let active = icon_active(i, friends);
let is_focused = (self.pause_focus == Some(10) && i == FRIENDS_ICON)
@@ -344,6 +361,7 @@ impl Game<'_> {
// ConfirmLinkScreen.confirmLink(this, CommonLinks...) on the pause screen.
12 => self.confirm_trusted_link(BUGS_LINK, Screen::Paused),
13 => self.confirm_trusted_link(feedback_link(), Screen::Paused),
30 => self.open_split_settings(),
// addCustomDialogButtons: DialogConnectionAccess.openDialog of the
// additions dialog, from the pause menu.
14 => {
@@ -370,23 +388,30 @@ impl Game<'_> {
};
let links = !self.server_links().is_empty();
let additions = self.pause_screen_additions();
let icons = pause_icons(w, h, links, additions.is_some());
let icons = pause_icons(w, h, links, additions.is_some(), self.options.split_guest);
let friends = icons[FRIENDS_ICON];
let hit = pause_buttons(w, h, self.external_server(), links, additions.is_some())
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
.or_else(|| {
// The report bugs and feedback icons (ids 12 and 13).
(0..2)
.find(|&i| ui::hovered(self.cursor, icons[i].0, icons[i].1, 20.0, 20.0))
.map(|i| 12 + i as u8)
})
.or_else(|| {
(self.friends_view().shown
&& ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
.then_some(10)
});
let hit = pause_buttons(
w,
h,
self.external_server(),
links,
additions.is_some(),
self.options.split_guest,
)
.into_iter()
.find(|b| b.5 && ui::hovered(self.cursor, b.0, b.1, b.2, 20.0))
.map(|b| b.4)
.or_else(|| {
// The report bugs and feedback icons (ids 12 and 13).
(0..2)
.find(|&i| ui::hovered(self.cursor, icons[i].0, icons[i].1, 20.0, 20.0))
.map(|i| 12 + i as u8)
})
.or_else(|| {
(self.friends_view().shown
&& ui::hovered(self.cursor, friends.0, friends.1, 20.0, 20.0))
.then_some(10)
});
if let Some(id) = hit {
self.pause_focus = Some(id);
self.audio.play_button_click();
@@ -410,6 +435,7 @@ impl Game<'_> {
self.external_server(),
links,
self.pause_screen_additions().is_some(),
self.options.split_guest,
);
let mut active_ids: Vec<u8> = buttons.into_iter().filter(|b| b.5).map(|b| b.4).collect();
let friends = self.friends_view().shown;
@@ -457,6 +483,7 @@ impl Game<'_> {
self.external_server(),
links,
self.pause_screen_additions().is_some(),
self.options.split_guest,
);
let mut rects: Vec<(u8, f32, f32, f32)> = buttons
.into_iter()
@@ -464,10 +491,15 @@ impl Game<'_> {
.map(|b| (b.4, b.0, b.1, b.2))
.collect();
let friends = self.friends_view().shown;
for (i, (x, y, _)) in
pause_icons(w, h, links, self.pause_screen_additions().is_some())
.into_iter()
.enumerate()
for (i, (x, y, _)) in pause_icons(
w,
h,
links,
self.pause_screen_additions().is_some(),
self.options.split_guest,
)
.into_iter()
.enumerate()
{
if icon_active(i, friends) {
let id = if i == FRIENDS_ICON { 10 } else { 12 + i as u8 };
@@ -871,7 +903,7 @@ mod tests {
#[test]
fn pause_menu_uses_disconnect_for_servers_and_keeps_world_options() {
for (remote, label) in [(false, "menu.returnToMenu"), (true, "menu.disconnect")] {
let buttons = pause_buttons(427.0, 240.0, remote, false, false);
let buttons = pause_buttons(427.0, 240.0, remote, false, false, false);
let quit = buttons.iter().find(|b| b.4 == 2).unwrap();
assert_eq!(quit.3, label);
assert!(quit.5);
@@ -897,14 +929,14 @@ mod tests {
#[test]
fn server_links_adds_a_full_width_row_above_options() {
let with = pause_buttons(427.0, 240.0, true, true, false);
let without = pause_buttons(427.0, 240.0, true, false, false);
let with = pause_buttons(427.0, 240.0, true, true, false, false);
let without = pause_buttons(427.0, 240.0, true, false, false, false);
let links = with.iter().find(|b| b.4 == 11).unwrap();
assert_eq!(links.2, 204.0);
assert_eq!(links.3, "menu.server_links");
assert!(links.5);
// The icon row sits above it; Options and Disconnect move down 24.
let icons = pause_icons(427.0, 240.0, true, false);
let icons = pause_icons(427.0, 240.0, true, false, false);
let y0_icons = ((240.0f32 - 190.0) * 0.25).floor() + 98.0;
assert_eq!(icons[0].1, y0_icons);
assert!(links.1 > icons[0].1);
@@ -920,8 +952,8 @@ mod tests {
fn pause_screen_additions_takes_the_server_links_row() {
// getCustomAdditions: one additions button in row 3, never next to the
// links button, so the grid keeps one extra row either way.
let with = pause_buttons(427.0, 240.0, true, true, true);
let links = pause_buttons(427.0, 240.0, true, true, false);
let with = pause_buttons(427.0, 240.0, true, true, true, false);
let links = pause_buttons(427.0, 240.0, true, true, false, false);
assert!(with.iter().all(|b| b.4 != 11));
let button = with.iter().find(|b| b.4 == 14).unwrap();
assert_eq!(
@@ -934,8 +966,30 @@ mod tests {
assert_eq!(add.1, link.1);
}
assert_eq!(
pause_icons(427.0, 240.0, true, true)[0].1,
pause_icons(427.0, 240.0, true, false)[0].1
pause_icons(427.0, 240.0, true, true, false)[0].1,
pause_icons(427.0, 240.0, true, false, false)[0].1
);
}
#[test]
fn split_settings_preserves_dialog_rows_and_icon_actions() {
for (links, additions) in [(false, false), (true, false), (false, true), (true, true)] {
let buttons = pause_buttons(427.0, 300.0, true, links, additions, true);
let split = buttons.iter().find(|b| b.4 == 30).unwrap();
let options = buttons.iter().find(|b| b.4 == 1).unwrap();
assert_eq!(options.1, split.1 + 24.0);
if links || additions {
let custom = buttons
.iter()
.find(|b| b.4 == if additions { 14 } else { 11 })
.unwrap();
assert_eq!(split.1, custom.1 + 24.0);
}
assert!(buttons.iter().all(|b| b.4 != 12 && b.4 != 13));
assert!(
!pause_buttons(427.0, 300.0, true, links, additions, false)
.iter()
.any(|b| b.4 == 30)
);
}
}
}
@@ -195,7 +195,7 @@ impl Game<'_> {
}
Ok(registry.resolve(&BlockState {
name: name.to_owned(),
properties,
properties: properties.into(),
})?)
}
@@ -81,6 +81,18 @@ fn active_pack<'a>(picked: &'a str, mods: &horizon_client::mod_list::ModList) ->
}
}
/// Whether the pack `settings` pick (or `HORIZON_SHADER_PACK`) is one of Horizon's own: only
/// then do resource loads read the resource packs' LabPBR maps (`sprites::set_labpbr_maps`).
pub(super) fn picks_native_pack(settings: &horizon_client::settings::Settings) -> bool {
let dev = std::env::var("HORIZON_SHADER_PACK").ok();
let picked = active_pack(
dev.as_deref().unwrap_or(&settings.horizon.shader_pack),
&settings.mods,
);
!picked.is_empty()
&& horizon_shader_api::package::is_pack(&Game::shader_packs_dir().join(picked))
}
/// The ids a pack gives entities and block entities (4.1, 5.3), for the spans of the entity
/// geometry.
pub(super) struct PackIds {
+2 -2
View File
@@ -46,8 +46,8 @@ impl Game<'_> {
if self.blocks_attacks(stack).is_none() {
return false;
}
if self.shield_cooldown == 0 && self.using.is_none() {
self.using = Some((slot, 0));
if self.shield_cooldown == 0 {
self.start_using_item();
}
true
}
+1 -2
View File
@@ -32,8 +32,7 @@ impl Game<'_> {
let Some(kinetic) = Kinetic::of(&stack.item) else {
return false;
};
if self.using.is_none() {
self.using = Some((self.held_slot(), 0));
if self.start_using_item() {
let actor = self.lan_acting;
self.stabbed.retain(|(who, _), _| *who != actor);
if let Some(p) = &self.player {
+105 -3
View File
@@ -222,11 +222,54 @@ pub(super) struct Spill {
/// Block entities the behaviour removed, for the game's own follow-ups
/// (open screens, playing songs).
pub(super) removed: Vec<([i32; 3], BlockEntity)>,
/// Runs of `spawns` a player's use popped (a picked berry bush), for the
/// mods' `native_loot_drops`.
pub(super) used: Vec<UsedDrops>,
}
/// `spawns[start..end]` came from `table` at the block `pos` in `state`.
pub(super) struct UsedDrops {
start: usize,
end: usize,
pos: [i32; 3],
state: BlockStateId,
table: &'static str,
}
impl Spill {
pub(super) fn is_empty(&self) -> bool {
self.spawns.is_empty() && self.orbs.is_empty() && self.removed.is_empty()
self.spawns.is_empty()
&& self.orbs.is_empty()
&& self.removed.is_empty()
&& self.used.is_empty()
}
}
/// Lets `say` change each use's run of `spawns`, newest first so the earlier
/// ranges hold. A run's stacks keep their spots, and a stack `say` adds takes
/// the run's last spot, else the block's top.
fn retell_runs(
spawns: &mut Vec<Spawn>,
used: &[UsedDrops],
mut say: impl FnMut(&UsedDrops, [f64; 3], &mut Vec<ItemStack>),
) {
for used in used.iter().rev() {
let run: Vec<Spawn> = spawns.drain(used.start..used.end).collect();
let mut drops: Vec<ItemStack> = run.iter().map(|s| s.stack.clone()).collect();
let origin = used.pos.map(|c| f64::from(c) + 0.5);
say(used, origin, &mut drops);
let top = [origin[0], origin[1] + 0.5, origin[2]];
let kept: Vec<Spawn> = drops
.into_iter()
.enumerate()
.map(|(i, stack)| Spawn {
stack,
pos: run.get(i).or(run.last()).map_or(top, |s| s.pos),
motion: run.get(i).and_then(|s| s.motion),
delay: run.get(i).map_or(10, |s| s.delay),
})
.collect();
spawns.splice(used.start..used.start, kept);
}
}
@@ -324,7 +367,12 @@ impl GameLevel<'_> {
}
/// `Block.dropFromBlockInteractLootTable` with `Block.popResource` of
/// every stack.
pub(super) fn drop_interact_loot(&mut self, pos: BlockPos, state: BlockStateId, table: &str) {
pub(super) fn drop_interact_loot(
&mut self,
pos: BlockPos,
state: BlockStateId,
table: &'static str,
) {
let drops = self.items.interact_loot(
table,
self.registry,
@@ -333,11 +381,19 @@ impl GameLevel<'_> {
self.rand_sequences,
self.seed,
);
let start = self.spill.spawns.len();
for stack in drops {
if &*stack.item != "minecraft:air" && stack.count > 0 {
self.pop_resource([pos.x, pos.y, pos.z], stack);
}
}
self.spill.used.push(UsedDrops {
start,
end: self.spill.spawns.len(),
pos: [pos.x, pos.y, pos.z],
state,
table,
});
}
/// `Block.dropResources(state, level, pos, blockEntity)`: the block's loot
/// (from its random sequence, else the level random), with what its block
@@ -648,7 +704,13 @@ impl Game<'_> {
if self.spill.is_empty() {
return;
}
let spill = std::mem::take(&mut self.spill);
let mut spill = std::mem::take(&mut self.spill);
let used = std::mem::take(&mut spill.used);
let tool = self.held().cloned();
retell_runs(&mut spill.spawns, &used, |used, origin, drops| {
let block = Some((used.pos, used.state));
self.native_use_drops(used.table, origin, block, None, tool.as_ref(), drops);
});
for s in spill.spawns {
let motion = s.motion.unwrap_or_else(new_item_motion);
self.spawn_item(s.stack, s.pos, motion, s.delay);
@@ -676,6 +738,46 @@ mod tests {
use horizon_client::{block_entity_item, item_data, mobs::raider, save};
use horizon_world::nbt::Tag;
#[test]
fn a_used_run_is_retold_in_place() {
let spawn = |item: &str, x: f64| Spawn {
stack: ItemStack::new(item, 1),
pos: [x, 0.0, 0.0],
motion: None,
delay: 10,
};
let mut spawns = vec![
spawn("minecraft:stone", 0.0),
spawn("minecraft:sweet_berries", 1.0),
spawn("minecraft:sweet_berries", 2.0),
spawn("minecraft:dirt", 3.0),
];
let used = [UsedDrops {
start: 1,
end: 3,
pos: [4, 64, 4],
state: BlockStateId(0),
table: "minecraft:harvest/sweet_berry_bush",
}];
retell_runs(&mut spawns, &used, |used, origin, drops| {
assert_eq!(used.table, "minecraft:harvest/sweet_berry_bush");
assert_eq!(origin, [4.5, 64.5, 4.5]);
assert_eq!(drops.len(), 2);
drops.remove(0);
drops.push(ItemStack::new("minecraft:diamond", 1));
});
let items: Vec<(&str, f64)> = spawns.iter().map(|s| (&*s.stack.item, s.pos[0])).collect();
assert_eq!(
items,
[
("minecraft:stone", 0.0),
("minecraft:sweet_berries", 1.0),
("minecraft:diamond", 2.0),
("minecraft:dirt", 3.0),
]
);
}
#[test]
fn ominous_banner_survives_placement_save_and_drop() {
let original = raider::ominous_banner();
+72 -16
View File
@@ -21,7 +21,8 @@
//! disconnects leaves its player waiting, its view dimmed, until it comes back or another takes
//! over (`pads`).
//!
//! `HORIZON_SPLIT=N` (testing, the only way in so far): splitscreen watches the controllers, and
//! Horizon Settings > Splitscreen... enables joining (off by default).
//! `HORIZON_SPLIT=N` (testing) enables it too, and
//! N-1 players without one join once P1 is in its world, as P2, P3... (`HORIZON_SPLIT=1`: only
//! the controllers). The player in seat N takes `horizon-ctl` on P1's socket path + "N".
@@ -30,6 +31,7 @@ use super::{
pad::{PadId, PadRouter},
start_game,
};
use horizon_client::gamepad::Button;
use horizon_client::split_layout::{self as layout, MAX_PLAYERS, Placement, View};
use horizon_client::{Result, gfx::compositor::Compositor, widgets};
use std::{
@@ -77,10 +79,21 @@ impl Game<'_> {
}
}
const WINDOW_1: &str = "Window 1";
const NEW_WINDOW: &str = "New window";
/// A place offered while picking a profile.
#[derive(Clone, PartialEq)]
enum Spot {
Window(u8),
New,
Fill(String),
}
struct Guest<'a> {
game: Box<Game<'a>>,
/// The name it plays as.
name: String,
/// The profile it plays as; `None` while it picks one.
name: Option<String>,
/// Its seat (2 to `MAX_PLAYERS`): its number (P2...), its control socket, its sounds.
seat: usize,
options: PathBuf,
@@ -90,15 +103,19 @@ struct Guest<'a> {
window: u8,
/// Its buttons and focus targets while another player's game runs.
widgets: widgets::Saved,
/// Its profile got into the world: turned away after that (a kick), it leaves; before, it
/// picks again.
entered: bool,
bumpers: [bool; 2],
}
pub(super) struct Split<'a> {
pub(super) host: Game<'a>,
guests: Vec<Guest<'a>>,
/// `HORIZON_SPLIT`: splitscreen watches the controllers for players joining.
watching: bool,
/// `HORIZON_SPLIT=N`: players still to add once P1 is in a world.
test_players: usize,
/// `HORIZON_SPLIT` is set: splitscreen is on for this run, without touching the saved switch.
forced: bool,
/// While another player is in, the windows players play in, each with its compositor: the
/// game's first, then the others (else P1 draws on its window).
surfaces: Vec<Surface>,
@@ -106,6 +123,8 @@ pub(super) struct Split<'a> {
/// monitor, and P1's.
windows: Vec<layout::Window>,
p1_window: u8,
/// Seats in the order their views fill a shared window.
order: Vec<usize>,
/// Whether Window 1 is shown (hidden while nobody plays in it): set once per change, since
/// Wayland can't say whether a window is visible.
window1_shown: bool,
@@ -150,14 +169,15 @@ impl<'a> Split<'a> {
Self {
host,
guests: Vec::new(),
watching: split.is_some(),
test_players,
forced: split.is_some(),
surfaces: Vec::new(),
windows: vec![layout::Window {
number: 1,
placement: Placement::Free(None),
}],
p1_window: 1,
order: Vec::new(),
window1_shown: true,
window_rects: Default::default(),
screens: Vec::new(),
@@ -304,7 +324,10 @@ impl<'a> Split<'a> {
fn name(&self, i: usize) -> String {
match i {
0 => "P1".into(),
_ => self.guests[i - 1].name.clone(),
_ => self.guests[i - 1]
.name
.clone()
.unwrap_or_else(|| "New player".into()),
}
}
@@ -399,13 +422,17 @@ impl<'a> Split<'a> {
Ok(())
}
/// P1 alone again: it draws on its window again.
/// Splitscreen off or P1 alone again: it draws on its window again.
#[inline(never)]
fn disable(&mut self, event_loop: &ActiveEventLoop) -> Result<()> {
fn disable(&mut self, event_loop: &ActiveEventLoop, watching: bool) -> Result<()> {
for k in (0..self.guests.len()).rev() {
self.remove_guest(k);
}
self.applied.clear();
self.order.clear();
if !watching {
self.stop_routing();
}
self.p1_window = 1;
self.windows.truncate(1);
self.surfaces.truncate(1);
@@ -466,13 +493,17 @@ impl<'a> Split<'a> {
fn admit(&mut self, _event_loop: &ActiveEventLoop, pad_joins: Vec<PadId>) -> bool {
let mut changed = false;
for pad in pad_joins {
match self.join(Some(pad)) {
match self.join(Some(pad), None) {
Ok(()) => changed = true,
Err(e) => log::warn!("Horizon: splitscreen: a player could not join: {e}"),
}
}
if self.in_world() && self.test_players > 0 {
match self.join(None) {
// HORIZON_SPLIT_PICK: they pick a profile like a controller's player, else P2...
let name = std::env::var_os("HORIZON_SPLIT_PICK")
.is_none()
.then(|| format!("P{}", self.free_seat().unwrap_or(0)));
match self.join(None, name) {
Ok(()) => {
self.test_players -= 1;
changed = true;
@@ -652,16 +683,19 @@ impl ApplicationHandler for Split<'_> {
}
fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
// The last other player gone: P1 draws on its window again.
self.refresh_monitors();
let on = self.host.settings.extras.splitscreen || self.forced;
self.host.ui.options.split_joined = self.guests.iter().any(|g| g.name.is_some());
// Off, or the last other player gone: P1 draws on its window again.
if !self.surfaces.is_empty()
&& self.guests.is_empty()
&& let Err(e) = self.disable(event_loop)
&& (!on || self.guests.is_empty())
&& let Err(e) = self.disable(event_loop, on)
{
log::warn!("Horizon: splitscreen could not switch back: {e}");
}
if self.surfaces.is_empty() {
if !self.watching {
if !on {
self.stop_routing();
return self.host.about_to_wait(event_loop);
}
// Alone, the controllers watched for someone joining; P1's own drives P1, its
@@ -684,6 +718,8 @@ impl ApplicationHandler for Split<'_> {
self.turn(0);
self.host.about_to_wait(event_loop);
let mut changed = self.drop_out() | self.admit(event_loop, pad_joins);
self.sync_profiles();
changed |= self.pick_profiles();
changed |= self.sync_surfaces(event_loop);
if changed || self.views() != self.applied {
self.relayout(event_loop);
@@ -691,7 +727,7 @@ impl ApplicationHandler for Split<'_> {
// Each player's badge: who it is, shown a moment when something changes for it.
self.host.split_hud.badge = Some("P1".into());
for g in &mut self.guests {
g.game.split_hud.badge = Some(g.name.clone());
g.game.split_hud.badge = Some(g.name.clone().unwrap_or_else(|| "New player".into()));
}
let master = self.host.audio.master;
for i in 0..self.guests.len() {
@@ -703,6 +739,26 @@ impl ApplicationHandler for Split<'_> {
if let Some(e) = &self.guests[i].game.error {
log::error!("Horizon: splitscreen: P{}: {e}", self.guests[i].seat);
}
// Choosing its profile, the bumpers move its view (its pad as its game read it: the
// controller, or `horizon-ctl pad`).
let place = self.stop_label(i + 1);
let game = &mut self.guests[i].game;
if game.ui.options.page() == horizon_client::options_ui::Page::SplitProfiles
&& game.screen == super::Screen::Options
{
game.ui.options.split_profiles.place = place;
let state = game.pad.state;
let held = [
state.held(Button::LeftBumper),
state.held(Button::RightBumper),
];
let before = std::mem::replace(&mut self.guests[i].bumpers, held);
if held[0] && !before[0] {
self.move_with_bumpers(i + 1, true);
} else if held[1] && !before[1] {
self.move_with_bumpers(i + 1, false);
}
}
}
self.turn(0);
self.present();
+12 -25
View File
@@ -77,6 +77,11 @@ pub(super) fn lost_prompt(name: &str) -> Prompt {
}
impl Game<'_> {
/// Another player's pause menu opens its profiles to switch identity.
pub(in crate::game) fn open_split_settings(&mut self) {
self.open_options_at(false, horizon_client::options_ui::Page::SplitProfiles);
}
/// Splitscreen over this player's view, above the other toasts (`ToastManager`'s slots).
pub(in crate::game) fn draw_split_hud(&mut self, batch: &mut HudBatch, hidden: bool) {
let atlas = &self.gui.atlas;
@@ -151,7 +156,7 @@ impl Game<'_> {
return;
}
let max = (w - 24.0).min(260.0);
let lines = wrap(&notice.body, max, width);
let lines = horizon_client::split_profiles::wrap(&notice.body, max, width);
let inner = lines
.iter()
.map(|l| width(l))
@@ -176,8 +181,12 @@ impl Game<'_> {
impl<'a> Split<'a> {
/// The join question on every player's view in the world (a strip at the top), or gone.
pub(super) fn prompt_join(&mut self, prompt: Option<Prompt>) {
let views = std::iter::once(&mut self.host.split_hud)
.chain(self.guests.iter_mut().map(|g| &mut g.game.split_hud));
let views = std::iter::once(&mut self.host.split_hud).chain(
self.guests
.iter_mut()
.filter(|g| g.name.is_some())
.map(|g| &mut g.game.split_hud),
);
for hud in views {
match &prompt {
Some(p) => {
@@ -201,25 +210,3 @@ impl<'a> Split<'a> {
}
}
}
/// `text` in lines at most `max` wide (by `width`), broken between words.
fn wrap(text: &str, max: f32, width: impl Fn(&str) -> f32) -> Vec<String> {
let mut lines = Vec::new();
let mut line = String::new();
for word in text.split_whitespace() {
let longer = if line.is_empty() {
word.to_owned()
} else {
format!("{line} {word}")
};
if width(&longer) > max && !line.is_empty() {
lines.push(std::mem::replace(&mut line, word.to_owned()));
} else {
line = longer;
}
}
if !line.is_empty() {
lines.push(line);
}
lines
}
+19 -1
View File
@@ -64,6 +64,19 @@ pub(super) fn claim<Id>(
}
impl Split<'_> {
/// Give P1 its normal controller reader again and discard pending joins and handoffs.
pub(super) fn stop_routing(&mut self) {
if self.host.pad.feed.take().is_some() {
self.host.pad.release_hardware();
self.host.split_hud = Default::default();
self.offers.clear();
self.fresh.clear();
self.lost.clear();
self.p1_pad = None;
self.pads = PadRouter::default();
}
}
/// The pad's state for its player: nothing while the press that gave it is still held.
fn pad_state(&mut self, pad: Option<PadId>) -> PadState {
let Some(id) = pad else {
@@ -212,12 +225,17 @@ impl Split<'_> {
return;
};
g.pad = None;
let Some(name) = g.name.clone() else {
// Still picking a profile: as if it pressed Cancel (`pick_profiles`).
g.game.screen = crate::game::Screen::Playing;
return;
};
self.lost.push(Lost {
seat: g.seat,
id,
uuid,
});
g.game.split_hud.prompt = Some(lost_prompt(&g.name));
g.game.split_hud.prompt = Some(lost_prompt(&name));
}
/// Player in `seat` plays with controller `id` (back, or another): its question goes.
+270 -51
View File
@@ -1,6 +1,22 @@
//! Players coming and going: joining P1's world through memory, dropping out.
//! Players coming and going: joining P1's world through memory as a profile it picks
//! (`split_profiles`), dropping out.
use super::*;
use horizon_client::gamepad::PadState;
use horizon_client::{gamepad::PadState, options_ui::Page};
/// Whether `game` shows its profile page now.
fn on_profiles(game: &Game<'_>) -> bool {
game.screen == crate::game::Screen::Options && game.ui.options.page() == Page::SplitProfiles
}
/// Why a player's game left its server, when the server said (its Disconnected or error screen).
fn turned_away(game: &Game<'_>) -> Option<String> {
use horizon_client::{multiplayer::Screen, title::Mode};
match &game.title.as_ref()?.mode {
Mode::Multiplayer(Screen::Disconnected { reason, .. }) => Some(reason.clone()),
Mode::Error { message, .. } => Some(message.clone()),
_ => None,
}
}
/// The `Game` `start` makes (~67 KB, by value) into a box, in a frame of its own that is gone
/// once it is: the copies stay out of the caller's frame, deep in the event loop.
@@ -15,29 +31,134 @@ fn boxed(start: impl FnOnce() -> Result<Option<Game<'static>>>) -> Result<Box<Ga
impl Split<'_> {
/// The first seat free (2 to `MAX_PLAYERS`).
fn free_seat(&self) -> Option<usize> {
pub(super) fn free_seat(&self) -> Option<usize> {
(2..=MAX_PLAYERS).find(|n| self.guests.iter().all(|g| g.seat != *n))
}
/// A player joins P1's world as `P<seat>`, with its own options in the game folder's
/// `splitscreen/profiles/P<seat>/` (P1's, the first time).
pub(super) fn join(&mut self, pad: Option<PadId>) -> Result<()> {
/// The splitscreen folder in the game folder: `profiles/<name>/` holds each profile.
fn folder() -> Result<PathBuf> {
Ok(std::env::current_dir()?.join("splitscreen"))
}
/// Add a player driven by `pad`: it picks a profile first, unless `name` is given (tests).
pub(super) fn join(&mut self, pad: Option<PadId>, name: Option<String>) -> Result<()> {
let seat = self.free_seat().ok_or("every seat is taken")?;
let name = format!("P{seat}");
let profile = std::env::current_dir()?.join(format!("splitscreen/profiles/{name}"));
// The first other player: P1 moves to the compositor now (back with nobody joined, if
// this one can't: `about_to_wait`).
if self.surfaces.is_empty() {
self.enable()?;
}
// Until it has a profile, a scratch options file: never P1's.
let options = Self::folder()?.join(".joining/options.txt");
OPTIONS.set(Some(options.clone()));
let game = self.start_guest(seat, &options, self.host.settings.clone());
OPTIONS.set(None);
let mut game = game?;
if let Some(pad) = pad {
// The press that joined it doesn't act in its game.
self.fresh.push(pad);
}
// Over its title screen: Cancel leaves (`pick_profiles`).
game.open_options_at(true, Page::SplitProfiles);
let i = self.guests.len() + 1;
self.guests.push(Guest {
game,
name: None,
seat,
options,
pad,
window: 1,
widgets: Default::default(),
entered: false,
bumpers: [false; 2],
});
self.planned_window(i, seat);
if let Some(name) = name {
self.enter(i - 1, name)?;
}
Ok(())
}
/// The names playing now: P1's and the joined players'.
fn taken(&self) -> Vec<String> {
std::iter::once(self.host.options.player_name())
.chain(self.guests.iter().filter_map(|g| g.name.clone()))
.collect()
}
/// The profile pages just opened, filled: the list and who plays now.
pub(super) fn sync_profiles(&mut self) {
let taken = self.taken();
for g in self.guests.iter_mut().filter(|g| on_profiles(&g.game)) {
g.game.ui.options.split_profiles.taken = taken.clone();
}
let open = |g: &Guest<'_>| on_profiles(&g.game) && !g.game.ui.options.split_profiles.filled;
if !self.guests.iter().any(open) {
return;
}
let names = Self::folder()
.and_then(|f| {
Ok(horizon_client::split_profiles::store::names(
&f.join("profiles"),
)?)
})
.map(|mut names| {
names.sort_by_key(|n| n.to_lowercase());
names
})
.unwrap_or_else(|e| {
log::warn!("Horizon: splitscreen: the profiles: {e}");
Vec::new()
});
for g in self.guests.iter_mut().filter(|g| open(g)) {
let page = &mut g.game.ui.options.split_profiles;
page.filled = true;
page.names = names.clone();
if let Some(me) = g.name.as_ref().filter(|me| !names.contains(me)) {
page.names.insert(0, me.clone());
}
page.taken = taken.clone();
page.current = g.name.clone();
}
}
/// Guest `i`, playing, switches to profile `name` where it plays: out of the world as its
/// old profile, in as the new one.
fn switch(&mut self, i: usize, name: String) -> Result<()> {
self.turn(i + 1);
let left = self.guests[i].game.leave_server(None);
self.turn(0);
let old = self.guests[i].name.take();
if let (Some(server), Some(old)) = (&self.host.integrated, &old) {
server.leave_split(old);
}
left?;
self.enter(i, name)
}
/// Guest `i` plays as profile `name`: its options, then into P1's world.
pub(super) fn enter(&mut self, i: usize, name: String) -> Result<()> {
if self.taken().iter().any(|n| n.eq_ignore_ascii_case(&name)) {
return Err(format!("{name} is already playing.").into());
}
// The page's names are player names; a test seat's (P2) isn't one, but no name leaves
// the profiles folder.
if !folder_name(&name) {
return Err(format!("{name:?} can't be a profile.").into());
}
let profiles = Self::folder()?.join("profiles");
if horizon_client::split_profiles::valid_name(&name) {
horizon_client::split_profiles::store::restore(&profiles, &name)?;
}
let profile = profiles.join(&name);
let options = profile.join("options.txt");
let settings = if options.exists() {
let mut settings = if options.exists() {
horizon_client::settings::Settings::load(&options)
} else {
std::fs::create_dir_all(&profile)?;
self.host.settings.save(&options);
self.host.settings.clone()
};
// The first other player: P1 moves to the compositor now (back with nobody joined, if
// this one can't: `about_to_wait`).
if self.surfaces.is_empty() {
self.enable()?;
}
let pipe = self
.host
.integrated
@@ -45,41 +166,99 @@ impl Split<'_> {
.ok_or("P1 is not in its world")?
.join_split(&name)
.ok_or("P1's world does not let players in")?;
OPTIONS.set(Some(options.clone()));
let game = self.start_guest(seat, &options, settings);
OPTIONS.set(None);
let joined = game.and_then(|mut game| {
game.connect_local(pipe, &name)?;
Ok(game)
});
let game = match joined {
Ok(game) => game,
Err(e) => {
// Let in for nothing: the world pauses again, the name is free.
if let Some(server) = &self.host.integrated {
server.leave_split(&name);
}
return Err(e);
self.guests[i].options = options;
self.turn(i + 1);
let guest = &mut self.guests[i];
// Its profile page closes as it goes in.
guest.game.screen = crate::game::Screen::Playing;
settings.extras.agent_control &= guest.game.options.control.is_some();
guest.game.settings = settings;
guest.game.options.player_name = Some(name.clone());
guest.game.apply_settings();
let joined = guest.game.connect_local(pipe, &name);
self.turn(0);
if let Err(e) = joined {
// Let in for nothing: the world pauses again, the name is free.
if let Some(server) = &self.host.integrated {
server.leave_split(&name);
}
};
if let Some(pad) = pad {
// The press that joined it doesn't act in its game.
self.fresh.push(pad);
return Err(e);
}
let i = self.guests.len() + 1;
self.guests.push(Guest {
game,
name,
seat,
options,
pad,
window: 1,
widgets: Default::default(),
});
self.planned_window(i, seat);
self.guests[i].name = Some(name);
self.guests[i].entered = false;
Ok(())
}
/// Players' profile pages: a pick goes in, one that can't says why there; a forgotten
/// profile is hidden; a joining player who cancels (or closes the page) leaves.
pub(super) fn pick_profiles(&mut self) -> bool {
let mut changed = false;
let mut i = 0;
while i < self.guests.len() {
if let Some(name) = self.guests[i]
.game
.ui
.options
.split_profiles
.take_forgotten()
{
let hidden = if self.taken().iter().any(|n| n.eq_ignore_ascii_case(&name)) {
Err("This profile cannot be forgotten while it is playing.".into())
} else {
Self::folder().and_then(|f| {
Ok(horizon_client::split_profiles::store::hide(
&f.join("profiles"),
&name,
)?)
})
};
match hidden {
Ok(()) => {
for guest in &mut self.guests {
guest.game.ui.options.split_profiles.filled = false;
}
}
Err(e) => self.refuse(i, e.to_string()),
}
}
let joining = self.guests[i].name.is_none();
match self.guests[i].game.ui.options.split_profiles.take_choice() {
Some(Some(name)) => match (|| {
if self.taken().iter().any(|n| n.eq_ignore_ascii_case(&name)) {
return Err(format!("{name} is already playing.").into());
}
if joining {
self.enter(i, name)
} else {
self.switch(i, name)
}
})() {
Ok(()) => changed = true,
Err(why) => self.refuse(i, why.to_string()),
},
_ if joining && !on_profiles(&self.guests[i].game) => {
self.remove_guest(i);
changed = true;
continue;
}
_ => {}
}
i += 1;
}
changed
}
/// Guest `i`'s pick can't play: its profile page says why (opened again if it had left the
/// world for it).
fn refuse(&mut self, i: usize, why: String) {
log::warn!("Horizon: splitscreen: P{}: {why}", self.guests[i].seat);
let game = &mut self.guests[i].game;
if !on_profiles(game) {
game.open_options_at(true, Page::SplitProfiles);
}
game.ui.options.split_profiles.refused = Some(why);
}
/// A new player's game in its seat. Boxed as it comes out of `start_game`, and never
/// inlined: a `Game` is ~67 KB, and copies of it in the event loop's frames overflowed
/// Windows' 1 MB main stack.
@@ -106,7 +285,6 @@ impl Split<'_> {
options.split_guest = true;
options.offscreen = true;
options.paced_by_split = true;
options.player_name = Some(format!("P{seat}"));
// P1's socket (`--control` or Agent Control) + the seat: every game in this process
// would otherwise take the one default endpoint, P1's.
options.control = host.control.as_ref().map(|c| {
@@ -152,17 +330,40 @@ impl Split<'_> {
guest.apply_settings();
// Its pad comes from the split (`route_pads`): it never opens the controllers itself.
guest.pad.feed = Some(PadState::default());
guest.ui.options.split_guest = true;
Ok(guest)
}
/// Players who left their world (Disconnect) or failed drop out; all of them once P1 has
/// left its world. Whether any did.
/// Players who disconnected drop out; all of them once P1 has left its world. One the
/// server turned away as it came in picks again, told why; one it sent away later (a kick)
/// leaves, and P1 hears why. Whether any left.
pub(super) fn drop_out(&mut self) -> bool {
let p1_left = self.host.title.is_some() || self.host.integrated.is_none();
let before = self.guests.len();
if p1_left {
self.host.split_hud.notice = None;
}
for k in (0..self.guests.len()).rev() {
let game = &self.guests[k].game;
if p1_left || game.remote.is_none() || game.error.is_some() {
let g = &mut self.guests[k];
g.entered |= g.game.remote.is_some() && g.game.player.is_some();
let g = &self.guests[k];
// A player picking a profile has no connection yet.
let playing = g.name.is_none() || g.game.remote.is_some();
let why = (!p1_left && g.game.error.is_none() && !playing)
.then(|| turned_away(&g.game))
.flatten();
if let (true, Some(name), Some(why)) = (g.entered, &g.name, &why) {
self.host.split_hud.notice =
Some(Notice::new(format!("{name} could not stay"), why.clone()));
} else if let Some(why) = why {
let old = self.guests[k].name.take();
if let (Some(server), Some(old)) = (&self.host.integrated, &old) {
server.leave_split(old);
}
self.refuse(k, why);
continue;
}
if p1_left || !playing || g.game.error.is_some() {
self.remove_guest(k);
}
}
@@ -182,8 +383,26 @@ impl Split<'_> {
if self.focus > k + 1 {
self.focus -= 1;
}
if let Some(server) = &self.host.integrated {
server.leave_split(&guest.name);
if let (Some(server), Some(name)) = (&self.host.integrated, &guest.name) {
server.leave_split(name);
}
}
}
/// A name that stays one folder inside `profiles`.
fn folder_name(name: &str) -> bool {
!name.is_empty() && name != "." && !name.contains(['/', '\\']) && !name.contains("..")
}
#[cfg(test)]
mod tests {
use super::folder_name;
#[test]
fn a_profile_name_never_leaves_the_profiles_folder() {
assert!(folder_name("P2") && folder_name("Steve"));
for bad in ["", ".", "..", "../x", "a/b", "a\\b"] {
assert!(!folder_name(bad), "{bad:?}");
}
}
}
+227 -1
View File
@@ -147,6 +147,23 @@ impl<'a> Split<'a> {
self.main_screen.as_ref()
}
/// How a monitor is called: numbered left to right, with its size (the system's names are
/// ids on some).
pub(super) fn monitor_label(&self, name: &str) -> Option<String> {
let (n, (_, m)) = self
.monitors()
.iter()
.enumerate()
.find(|(_, (n, _))| n == name)?;
let size = m.size();
Some(format!(
"Monitor {} ({}x{})",
n + 1,
size.width,
size.height
))
}
/// The window player `i` plays in now: Window 1 for a window that fills a monitor that's
/// gone, or the one Window 1 is on.
pub(super) fn window_of(&self, i: usize) -> u8 {
@@ -161,6 +178,94 @@ impl<'a> Split<'a> {
if shown { number } else { 1 }
}
/// Where player `i` can play, as the screens say it: Window 1 (the game's), the other windows
/// open, a new window, and each monitor no window fills but Window 1's. The page's button
/// steps forward through them (buttons take the left click only), a joining player's bumpers
/// both ways. A player alone in its window is at "New window" or its monitor's choice (its
/// window is that), so the list stays the same as it steps.
pub(super) fn choices(&self, i: usize) -> Vec<(String, Spot)> {
let alone = self.alone(i);
let mine = self.window_of(i);
let mut open: Vec<u8> = (0..self.players())
.map(|j| self.window_of(j))
.filter(|&n| !(alone && n == mine))
.collect();
open.sort_unstable();
open.dedup();
let filled: Vec<&String> = open
.iter()
.filter_map(|&n| match self.placement(n) {
Some(Placement::Fill(name)) => Some(name),
_ => None,
})
.collect();
let main = self.main_monitor();
let monitors = self.monitors().iter().filter_map(|(name, _)| {
if Some(name) == main || filled.contains(&name) {
return None;
}
Some((self.monitor_label(name)?, Spot::Fill(name.clone())))
});
std::iter::once((WINDOW_1.to_owned(), Spot::Window(1)))
.chain(
open.into_iter()
.filter(|&n| n != 1)
.map(|n| (self.window_label(n), Spot::Window(n))),
)
.chain(std::iter::once((NEW_WINDOW.to_owned(), Spot::New)))
.chain(monitors)
.collect()
}
/// Player `i` plays alone in a window of its own (not Window 1).
pub(super) fn alone(&self, i: usize) -> bool {
let mine = self.window_of(i);
mine != 1 && (0..self.players()).all(|j| j == i || self.window_of(j) != mine)
}
/// Player `i`'s place among its choices: its window, or alone, what its window is.
pub(super) fn current(&self, i: usize) -> Spot {
let mine = self.window_of(i);
match self.placement(mine) {
Some(Placement::Free(_)) if self.alone(i) => Spot::New,
Some(Placement::Fill(name)) if self.alone(i) => Spot::Fill(name.clone()),
_ => Spot::Window(mine),
}
}
/// How window `number` is shown: "Window 2", and the monitor it fills.
pub(super) fn window_label(&self, number: u8) -> String {
match self.placement(number) {
Some(Placement::Fill(name)) if number != 1 => match self.monitor_label(name) {
Some(monitor) => format!("Window {number}, {monitor}"),
None => format!("Window {number}"),
},
_ => format!("Window {number}"),
}
}
/// Where player `i` plays, as the screens say it.
pub(super) fn label(&self, i: usize) -> String {
self.window_label(self.window_of(i))
}
/// Player `i` into a window: one there is, or a new one with the first number free.
pub(super) fn choose(&mut self, i: usize, choice: Spot) {
let placement = match choice {
Spot::Window(n) => return self.set_window(i, n),
Spot::New => Placement::Free(None),
Spot::Fill(name) => Placement::Fill(name),
};
// Out of its window first: alone there, its number is free again.
self.set_window(i, 1);
self.forget_empty_windows();
let number = (2..=u8::MAX)
.find(|n| self.windows.iter().all(|w| w.number != *n))
.unwrap_or(u8::MAX);
self.windows.push(layout::Window { number, placement });
self.set_window(i, number);
}
/// Windows nobody plays in close (Window 1 stays).
pub(super) fn forget_empty_windows(&mut self) {
let used: Vec<u8> = (0..self.players()).map(|i| self.window_number(i)).collect();
@@ -180,7 +285,21 @@ impl<'a> Split<'a> {
})
.collect();
let sizes: Vec<_> = self.surfaces.iter().map(|s| s.size).collect();
layout::place(&windows, &sizes, 0, 0)
layout::place_ordered(&windows, &self.player_order(), &sizes, 0, 0)
}
/// The players (indices) in the order they fill a window they share (`order`, by seat;
/// players it doesn't name last).
pub(super) fn player_order(&self) -> Vec<usize> {
let seats: Vec<usize> = std::iter::once(1)
.chain(self.guests.iter().map(|g| g.seat))
.collect();
let named: Vec<usize> = self
.order
.iter()
.filter_map(|seat| seats.iter().position(|s| s == seat))
.collect();
layout::ordered(&named, seats.len())
}
/// Every player into its view: its window, then its size. Keyboard and mouse follow their
@@ -212,6 +331,113 @@ impl<'a> Split<'a> {
self.turn(0);
}
/// The places a joining player steps through with its bumpers: each window it could play in,
/// at each place among the players there ("Window 1 (1 of 2)": first is left or on top),
/// then a new window and each monitor (`choices`).
pub(super) fn joining_stops(&self, i: usize) -> Vec<(String, Spot, usize)> {
let mut stops = Vec::new();
for (label, spot) in self.choices(i) {
let others = match spot {
Spot::Window(n) => self.sharing(i, n).len(),
_ => 0,
};
if others == 0 {
stops.push((label, spot, 0));
continue;
}
for k in 0..=others {
let label = format!("{label} ({} of {})", k + 1, others + 1);
stops.push((label, spot.clone(), k));
}
}
stops
}
/// The players but `i` in window `number`, in their order.
fn sharing(&self, i: usize, number: u8) -> Vec<usize> {
self.player_order()
.into_iter()
.filter(|&j| j != i && self.window_of(j) == number)
.collect()
}
pub(super) fn seat_of(&self, i: usize) -> usize {
if i == 0 { 1 } else { self.guests[i - 1].seat }
}
/// Player `i` to place `k` among the others in its window (0: before them all).
pub(super) fn put_at(&mut self, i: usize, k: usize) {
let others: Vec<usize> = self
.sharing(i, self.window_of(i))
.into_iter()
.map(|j| self.seat_of(j))
.collect();
let me = self.seat_of(i);
let mut order: Vec<usize> = self
.player_order()
.into_iter()
.map(|j| self.seat_of(j))
.filter(|&s| s != me)
.collect();
let at = match others.get(k) {
Some(seat) => order.iter().position(|s| s == seat).unwrap_or(order.len()),
// After the last of them.
None => others
.last()
.and_then(|seat| order.iter().position(|s| s == seat))
.map_or(order.len(), |p| p + 1),
};
order.insert(at, me);
self.order = order;
}
/// A bumper on the profile page (joining or switching): player `i`'s view moves to the next
/// place it could play (`back`: the one before), at once, so it sees where it will be.
pub(super) fn move_with_bumpers(&mut self, i: usize, back: bool) {
let stops = self.joining_stops(i);
let n = stops.len();
if n == 0 {
return;
}
let (spot, before) = (self.current(i), self.sharing_before(i));
let at = stops
.iter()
.position(|(_, s, k)| *s == spot && *k == before)
.unwrap_or(0);
let (shown, next, k) = stops[if back { (at + n - 1) % n } else { (at + 1) % n }].clone();
self.choose(i, next);
self.put_at(i, k);
if let Some(game) = self.game_mut(i) {
game.split_hud.place = Some((
format!("LB < {shown} > RB"),
Instant::now() + Duration::from_secs(3),
));
}
}
/// Where player `i` plays now, as its bumpers' stops name it ("Window 1 (2 of 2)"); its own
/// window by its name ("Window 2").
pub(super) fn stop_label(&self, i: usize) -> Option<String> {
let (spot, before) = (self.current(i), self.sharing_before(i));
if spot != Spot::Window(self.window_of(i)) {
return Some(self.label(i));
}
self.joining_stops(i)
.into_iter()
.find(|(_, s, k)| *s == spot && *k == before)
.map(|(label, ..)| label)
}
/// How many players come before `i` in its window.
fn sharing_before(&self, i: usize) -> usize {
let mine = self.window_of(i);
self.player_order()
.into_iter()
.take_while(|&j| j != i)
.filter(|&j| self.window_of(j) == mine)
.count()
}
/// What a window's title says: its name and who plays in it.
pub(super) fn title(&self, k: usize) -> String {
let views = &self.applied;
+1 -3
View File
@@ -186,9 +186,7 @@ impl Game<'_> {
if stack.damage + 1 >= max || (riptide && !self.in_water_or_rain()) {
return true;
}
if self.using.is_none() {
self.using = Some((self.held_slot(), 0));
}
self.start_using_item();
true
}
fn in_water_or_rain(&mut self) -> bool {
+52
View File
@@ -574,6 +574,58 @@ mod tests {
);
}
/// An unbreakable damaged stack must keep both components through inventory
/// sync, including the integrated server's window (#5721).
#[test]
fn unbreakable_survives_inventory_sync() {
use horizon_protocol::buf::{Codec, Reader, Writer};
use horizon_protocol::item::Slot;
let mut components = vec![String::new(); component::UNBREAKABLE as usize + 1];
components[component::DAMAGE as usize] = "minecraft:damage".into();
components[component::UNBREAKABLE as usize] = "minecraft:unbreakable".into();
let builtin = Builtin {
registries: [
(
"minecraft:item".into(),
Ids::from_names(vec!["minecraft:air".into(), "minecraft:diamond_hoe".into()]),
),
(
"minecraft:data_component_type".into(),
Ids::from_names(components),
),
]
.into(),
};
for unbreakable in [false, true] {
let mut hoe = ItemStack::new("minecraft:diamond_hoe", 1);
hoe.damage = 58;
let marker = Tag::Compound(Default::default());
if unbreakable {
hoe.data_mut()
.set_other("minecraft:unbreakable", Some(marker.clone()));
}
let sent = crate::lan::guest::stack_to_network(|_| 1, |_, _| None, &Some(hoe));
let mut w = Writer::new();
Slot::write(&sent, &mut w);
let mut r = Reader::new(&w.data);
let received = Slot::read(&mut r).unwrap().unwrap();
assert_eq!(r.remaining(), 0);
assert_eq!(
received.component(component::UNBREAKABLE),
unbreakable.then_some(&Component::Unit)
);
let back = from_network(&received, &builtin, &Dynamic::default());
assert_eq!(
(&*back.item, back.count, back.damage),
("minecraft:diamond_hoe", 1, 58)
);
assert_eq!(
back.data().and_then(|d| d.other("minecraft:unbreakable")),
unbreakable.then_some(&marker)
);
}
}
/// An ominous bottle's `ominous_bottle_amplifier` reaches the client
/// through the packet's bytes, so its tooltip names the Bad Omen level.
#[test]
+19 -3
View File
@@ -39,6 +39,13 @@ const KEEP_ALIVE_EVERY: Duration = Duration::from_secs(15);
/// ReadTimeoutHandler(30) in ServerConnectionListener: a connection that sends
/// nothing for 30 s.
const READ_TIMEOUT: Duration = Duration::from_secs(30);
/// How long the handshake, a status request and the login may wait for the client's next packet,
/// where vanilla waits 30 s: a client answers these at once, and each connection holds a thread
/// and one of the places the server keeps for connections, which a crowd of silent or slow
/// sockets would otherwise take for 30 s each. Configuration and play keep [`READ_TIMEOUT`] (a
/// resource pack downloads in configuration).
const LOGIN_TIMEOUT: Duration = Duration::from_secs(10);
/// Packets queued for one client beyond this many bytes disconnect it.
const MAX_PENDING: usize = 96 << 20;
/// Vanilla's server-side disconnect during login is a JSON component.
@@ -457,9 +464,16 @@ pub fn serve(
{
return serve_legacy(stream, &shared);
}
// A peer that stops reading (a frozen game, a dropped network) would otherwise hold
// its writer in a full send buffer for good, or until TCP gives up minutes later, and
// its thread and its place among the server's connections with it.
let _ = stream.set_write_timeout(Some(WRITE_TIMEOUT));
serve_connection(Connection::from_stream(stream).map_err(e)?, address, shared)
}
/// How long one write may wait on a peer that reads nothing: as long as a read may wait.
const WRITE_TIMEOUT: Duration = READ_TIMEOUT;
/// The address a memory connection logs in from: no network peer has port 0.
pub const MEMORY_ADDRESS: std::net::SocketAddr = std::net::SocketAddr::V4(
std::net::SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 0),
@@ -480,9 +494,7 @@ fn serve_connection(
shared: Arc<Shared>,
) -> Result<(), String> {
let e = |e: horizon_protocol::Error| e.to_string();
c.reader
.set_read_timeout(Some(Duration::from_secs(30)))
.map_err(e)?;
c.reader.set_read_timeout(Some(LOGIN_TIMEOUT)).map_err(e)?;
let (id, payload) = c.recv().map_err(e)?;
let intention = match handshake::Serverbound::decode(id, &payload).map_err(e)? {
handshake::Serverbound::Intention(i) => i,
@@ -881,6 +893,7 @@ fn login_and_configure(
_ => return Err("unexpected packet during login".into()),
}
}
c.reader.set_read_timeout(Some(READ_TIMEOUT)).map_err(e)?;
// Configuration.
let sync_data = shared.sync();
let sync: &SyncData = &sync_data;
@@ -1187,6 +1200,7 @@ fn wait_alive<T>(
}
fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::SocketAddr) {
shared.entered_play();
let conn_id = shared.next_id();
let events = shared.events.clone();
let _ = c.reader.set_read_timeout(Some(READ_TIMEOUT));
@@ -1258,9 +1272,11 @@ fn play(c: Connection, shared: &Arc<Shared>, parts: Parts, address: std::net::So
};
let _ = tx.send(Out::Close);
let _ = events.send(NetEvent::Left(conn_id, reason));
shared.closing.fetch_add(1, Ordering::AcqRel);
if let Some(t) = writer_thread {
let _ = t.join();
}
shared.closing.fetch_sub(1, Ordering::AcqRel);
shared.release(parts.uuid);
}
+24 -5
View File
@@ -252,6 +252,7 @@ impl Hub {
menu: kind,
title,
}));
let mod_type = crate::containers::mod_menu_type(menu_kind);
g.menu = Some(Menu {
window,
kind,
@@ -263,6 +264,8 @@ impl Hub {
drag: None,
sent_data: Vec::new(),
hidden: Vec::new(),
results: mod_type.map_or_else(Vec::new, |t| t.results.clone()),
data_slots: mod_type.map(|t| t.data_slots),
});
self.send_menu_content(i, items);
}
@@ -321,6 +324,8 @@ impl Hub {
drag: None,
sent_data: Vec::new(),
hidden: Vec::new(),
results: Vec::new(),
data_slots: None,
});
self.send_menu_content(i, items);
Some(window)
@@ -557,6 +562,11 @@ impl Hub {
.as_ref()
.filter(|_| in_menu)
.map_or_else(Vec::new, |m| m.hidden.clone());
let results = self.guests[i]
.menu
.as_ref()
.filter(|_| in_menu)
.map_or_else(Vec::new, |m| m.results.clone());
let guest = &mut self.guests[i];
let mut items = source.and_then(|s| s.read(world, guest));
if in_menu && items.is_none() {
@@ -567,6 +577,7 @@ impl Hub {
container: items.as_deref_mut(),
shulker,
hidden: &hidden,
results: &results,
};
let (drops, changed) = menu::click(
&mut view,
@@ -791,23 +802,31 @@ impl Hub {
.chain([&c.triggered])
.map(|&d| i32::from(d))
.collect(),
// A mod's menu: the data slots its tick set.
Some(BlockEntity::Mod(m)) if !m.data.is_empty() => m.data.clone(),
// A mod's menu: the data slots its tick set. A mod menu type's
// `ContainerData` has as many as it declares, all sent on open
// (`sendInitialData`), 0 where the tick set none.
Some(BlockEntity::Mod(e)) if m.data_slots.is_some() || !e.data.is_empty() => {
let mut data = e.data.clone();
if let Some(n) = m.data_slots {
data.resize(n, 0);
}
data
}
_ => return,
}
.into_iter()
.map(|v| v as i16)
.collect::<Vec<i16>>();
m.sent_data.resize(data.len(), i16::MIN);
m.sent_data.resize(data.len(), None);
let window = m.window;
let changed: Vec<(usize, i16)> = data
.iter()
.enumerate()
.filter(|&(k, v)| m.sent_data[k] != *v)
.filter(|&(k, v)| m.sent_data[k] != Some(*v))
.map(|(k, &v)| (k, v))
.collect();
for &(k, value) in &changed {
m.sent_data[k] = value;
m.sent_data[k] = Some(value);
}
for (k, value) in changed {
g.send(&C::ContainerSetData(play::ContainerSetData {
+32
View File
@@ -173,6 +173,10 @@ pub struct Guest {
pub moved: bool,
/// The client finished loading terrain (ServerboundPlayerLoadedPacket).
pub loaded: bool,
/// Since it joined or respawned, the client hasn't said it loaded the level
/// (ServerboundPlayerLoadedPacket): it may still be on its loading screen, where it doesn't
/// move, even once the server stops waiting for it (`load_timeout`).
pub loading: bool,
/// Ticks left before the server stops waiting for that packet
/// (ServerGamePacketListenerImpl.clientLoadedTimeoutTimer): 60 at join and
/// after a respawn, 0 once it comes.
@@ -274,6 +278,9 @@ pub struct Guest {
pub death_time: u32,
/// The join packets were sent (PlayerList.placeNewPlayer ran).
pub placed: bool,
/// `Abilities.flyingSpeed`: vanilla's 0.05, unless a server mod set
/// another (`native_set_flying_speed`). Saved with the player.
pub flying_speed: f32,
/// The level the player is in (`ServerPlayer.level().dimension()`): the
/// hub's level passes only reach the players in theirs.
pub dimension: String,
@@ -370,6 +377,7 @@ impl Guest {
changes: Default::default(),
moved: false,
loaded: false,
loading: true,
load_timeout: CLIENT_LOADED_TIMEOUT,
center: None,
sent_chunks: Default::default(),
@@ -422,6 +430,7 @@ impl Guest {
dead: false,
death_time: 0,
placed: false,
flying_speed: 0.05,
dimension: "minecraft:overworld".into(),
menu: None,
next_window: 0,
@@ -439,6 +448,14 @@ impl Guest {
recipes_unlocked: Vec::new(),
}
}
/// `ServerPlayer.onUpdateAbilities`: the guest's abilities, flying speed included.
pub fn abilities(&self) -> play::Clientbound {
play::Clientbound::PlayerAbilities(play::PlayerAbilities {
flags: self.ability_flags(),
flying_speed: self.flying_speed,
walking_speed: 0.1,
})
}
pub fn send(&self, packet: &play::Clientbound) {
if self.anticheat.record.is_some() {
self.note_change(packet);
@@ -472,6 +489,13 @@ impl Guest {
.map(|a| a.modifiers.iter().any(|m| m.id == "minecraft:sprinting"));
Change::Attributes { sprint }
}
// Entity.DATA_SHARED_FLAGS_ID: its sprinting bit (0x08).
C::SetEntityData(p) if own(p.id) => match p.values.iter().find(|v| v.0 == 0) {
Some(&(_, _, horizon_protocol::entity_data::Value::Byte(flags))) => {
Change::Sprinting(flags & 0x08 != 0)
}
_ => return,
},
C::UpdateMobEffect(p) if own(p.id) => Change::Effects,
C::RemoveMobEffect(p) if own(p.id) => Change::Effects,
C::PlayerAbilities(_) => Change::Abilities,
@@ -736,6 +760,14 @@ fn network_stack(
));
}
if let Some(d) = s.data() {
// Unbreakable is a unit component; clients need its presence for the
// durability bar, tooltip and item model, including in single player.
if d.other("minecraft:unbreakable").is_some() {
stack.added.push((
horizon_protocol::item::component::UNBREAKABLE,
horizon_protocol::item::Component::Unit,
));
}
for (name, value) in &d.other {
let (removed, kind) = match name.strip_prefix('!') {
Some(kind) => (true, kind),
+30 -22
View File
@@ -933,11 +933,7 @@ impl Hub {
difficulty: ctx.difficulty,
locked: ctx.difficulty_locked,
}));
g.send(&C::PlayerAbilities(play::PlayerAbilities {
flags: g.ability_flags(),
flying_speed: 0.05,
walking_speed: 0.1,
}));
g.send(&g.abilities());
g.send(&C::SetHeldSlot(play::SetHeldSlot {
slot: g.selected as i32,
}));
@@ -2410,9 +2406,16 @@ impl Hub {
// track_entities). On the first tick vanilla's ran before
// ServerPlayer.tick, whose updatePlayerAttributes (and doTick's
// removeFrost) created the instances the guest's tick just made: they
// go out on the next tick, after the first chunk batch (#5560).
// go out on the next tick, after the first chunk batch (#5560). This
// server's first batch waits for the player's chunks to be ready, so
// they wait for it too: a slow load must not put them before it.
let g = &mut self.guests[i];
if let Some(m) = g.motion.as_mut().filter(|_| !first_tick) {
let after_first_batch = g.unacknowledged > 0 || g.max_unacknowledged > 1;
if let Some(m) = g
.motion
.as_mut()
.filter(|_| !first_tick && after_first_batch)
{
let values = super::attributes::take_dirty(&mut m.attributes);
if !values.is_empty() {
g.send(&C::UpdateAttributes(play::UpdateAttributes {
@@ -2756,6 +2759,7 @@ impl Hub {
S::PlayerLoaded(_) => {
let g = &mut self.guests[i];
g.loaded = true;
g.loading = false;
g.load_timeout = 0;
}
// PlayerChunkSender.onChunkBatchReceivedByClient.
@@ -3256,11 +3260,7 @@ impl Hub {
event: game_event::CHANGE_GAME_MODE,
param: mode as f32,
}));
g.send(&C::PlayerAbilities(play::PlayerAbilities {
flags: g.ability_flags(),
flying_speed: 0.05,
walking_speed: 0.1,
}));
g.send(&g.abilities());
self.broadcast(&C::PlayerInfoUpdate(play::PlayerInfoUpdate {
actions: 4,
entries: vec![play::PlayerInfo {
@@ -3289,6 +3289,21 @@ impl Hub {
}));
}
/// Set a guest's flying speed and tell it (`Abilities.setFlyingSpeed`, then
/// `onUpdateAbilities`). The abilities packets after carry it too, so a
/// respawn or a game mode change keeps it.
pub fn set_flying_speed(&mut self, conn: u32, speed: f32) {
let Some(g) = self.guest_mut(conn) else {
return;
};
g.flying_speed = speed;
// The server's copy of the body, which the movement replay reads.
if let Some(motion) = g.motion.as_mut() {
motion.flying_speed = speed;
}
g.send(&g.abilities());
}
/// The host's game mode changed (PlayerInfo UPDATE_GAME_MODE for everyone).
pub fn set_host_game_mode(&self, uuid: u128, mode: i32) {
self.broadcast(&C::PlayerInfoUpdate(play::PlayerInfoUpdate {
@@ -3366,6 +3381,7 @@ impl Hub {
// restartClientLoadTimerAfterRespawn: the client loads a whole new level before it
// reports it loaded.
g.load_timeout = super::guest::CLIENT_LOADED_TIMEOUT;
g.loading = true;
// ServerLevel.removePlayerImmediately: the dead player's
// Entity.setRemoved (KILLED) stops its riding; the new player rides
// nothing.
@@ -3385,11 +3401,7 @@ impl Hub {
self.respawned(id);
let g = &mut self.guests[i];
// ServerPlayer.restoreFrom: the game mode again (onUpdateAbilities).
g.send(&C::PlayerAbilities(play::PlayerAbilities {
flags: g.ability_flags(),
flying_speed: 0.05,
walking_speed: 0.1,
}));
g.send(&g.abilities());
// The new player faces the level's respawn direction (snapTo).
g.yaw = 0.0;
g.pitch = 0.0;
@@ -3525,11 +3537,7 @@ impl Hub {
self.resend_teleport(i, None);
}
let g = &mut self.guests[i];
g.send(&C::PlayerAbilities(play::PlayerAbilities {
flags: g.ability_flags(),
flying_speed: 0.05,
walking_speed: 0.1,
}));
g.send(&g.abilities());
self.send_level_info(i, ctx, !portal);
// PlayerList.sendAllPlayerInfo: the inventory and the held slot.
self.send_inventory(i);
Loaded 100 of 206 files, more files were not shown because too many files have changed in this diff. Show more