Merge remote-tracking branch 'upstream/main' into fix/native-summon-orbs-passengers

# Conflicts:
#	crates/horizon-client/src/game/native_entities.rs
This commit is contained in:
Claude committed 2026-11-01 02:50:55 +00:00
commit 88784b6910
334 files changed
+22491 -2502

No files matched your search

+7 -7
View File
@@ -27,13 +27,13 @@ jobs:
# One job, not one per check: each job takes a runner from the organisation's shared pool, and with every pull request
# and every merge to main starting this workflow, three jobs a run left the pool's queue tens of minutes deep.
#
# Where: main's pushes and this repository's own pull requests on playhorizon's checks runners (label playhorizon-ci,
# docs/ci-runners.md), out of that pool; a fork's pull request always on GitHub's (its code never runs on a
# self-hosted runner, and the runners refuse it themselves). The repository variable SELF_HOSTED_CI=off sends every
# job back to GitHub's. There the build folder persists (CARGO_TARGET_DIR, outside the checkout), so the apt
# packages, Python and the Rust cache steps are GitHub's only.
# Where: GitHub's runners. With the repository variable SELF_HOSTED_CI=all (for when that pool is full: their 4 CPUs
# are about one GitHub runner, docs/ci-runners.md), main's pushes and this repository's own pull requests run on
# playhorizon's checks runners (label playhorizon-ci); a fork's pull request never does (and the runners refuse it
# themselves). There the build folder persists (CARGO_TARGET_DIR, outside the checkout), so the apt packages,
# Python and the Rust cache steps are GitHub's only.
verify:
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
@@ -103,7 +103,7 @@ jobs:
# changes it or a crate it builds is checked here, and so is every push to main: two pull requests can each pass
# alone and break it together (#2059 and the registry ratings did, 2026-10-26).
launcher:
runs-on: ${{ vars.SELF_HOSTED_CI != 'off' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
runs-on: ${{ vars.SELF_HOSTED_CI == 'all' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository) && fromJSON('["self-hosted", "linux", "playhorizon-ci"]') || 'ubuntu-latest' }}
timeout-minutes: 45
steps:
- uses: actions/checkout@v4
Generated
+10
View File
@@ -1334,6 +1334,9 @@ dependencies = [
"log",
"miniz_oxide 0.8.9",
"naga",
"objc2 0.6.5",
"objc2-app-kit 0.3.2",
"objc2-foundation 0.3.2",
"open",
"png",
"pollster",
@@ -1506,6 +1509,13 @@ dependencies = [
"windows 0.62.2",
]
[[package]]
name = "horizon-optifine"
version = "0.1.0"
dependencies = [
"regex",
]
[[package]]
name = "horizon-port-pack"
version = "0.1.0"
+1
View File
@@ -30,6 +30,7 @@ members = [
"crates/cargo-horizon-mod",
"crates/horizon-port-pack",
"crates/horizon-shaders",
"crates/horizon-optifine",
"crates/horizon-shader-api",
"crates/horizon-release-sign",
"xtask",
+86 -13
View File
@@ -8,12 +8,16 @@
//! check, off. A check that fails *flags* the player: its violation level (VL) rises by the flag's
//! weight and falls back by `decay` a second. From `setback` on, the move or action is cancelled (the
//! player is sent back where the server last had them); from `alert` on, online operators and the
//! console are told; from `kick` on, the player is disconnected. Nothing bans. Any threshold at 0 turns
//! that action off.
//! console are told; from `kick` on, the player is disconnected. A check's kicks are counted per player
//! across sessions ([`Strikes`]), and the kick that reaches its `ban_after` bans the player instead
//! (`banned-players.json`, reason "Horizon anticheat: <check>"). Any threshold at 0 turns that action
//! off.
//!
//! The checks are written from vanilla 26.3's own rules and from what cheats are publicly described to
//! do, never from another anticheat's code.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub mod combat;
@@ -28,12 +32,14 @@ pub struct Actions {
pub kick: f32,
/// Violation level lost each second.
pub decay: f32,
/// The kick, counting this check's earlier ones for the player, that bans instead; 0: never.
pub ban_after: u32,
}
macro_rules! checks {
($(
$(#[$doc:meta])*
$field:ident: $Check:ident => $Config:ident [$setback:expr, $alert:expr, $kick:expr, $decay:expr] {
$field:ident: $Check:ident => $Config:ident [$setback:expr, $alert:expr, $kick:expr, $decay:expr, $ban_after:expr] {
$( $(#[$xdoc:meta])* $extra:ident: $ty:ty = $default:expr, )*
}
)*) => {
@@ -59,6 +65,7 @@ macro_rules! checks {
pub alert: f32,
pub kick: f32,
pub decay: f32,
pub ban_after: u32,
$( $(#[$xdoc])* pub $extra: $ty, )*
}
@@ -70,6 +77,7 @@ macro_rules! checks {
alert: $alert,
kick: $kick,
decay: $decay,
ban_after: $ban_after,
$($extra: $default,)*
}
}
@@ -87,6 +95,9 @@ macro_rules! checks {
pub alert_level: u8,
/// Every flag, one line each, relative to the server's folder; empty: no file.
pub log: String,
/// The players' kicks by check ([`Strikes`]), relative to the server's folder; empty: kept
/// only while the server runs.
pub strikes: String,
$(pub $field: $Config,)*
}
@@ -97,6 +108,7 @@ macro_rules! checks {
alerts: true,
alert_level: 2,
log: "logs/anticheat.log".into(),
strikes: "anticheat-strikes.json".into(),
$($field: $Config::default(),)*
}
}
@@ -112,7 +124,13 @@ macro_rules! checks {
match check {
$(Check::$Check => {
let c = &self.$field;
Actions { setback: c.setback, alert: c.alert, kick: c.kick, decay: c.decay }
Actions {
setback: c.setback,
alert: c.alert,
kick: c.kick,
decay: c.decay,
ban_after: c.ban_after,
}
})*
}
}
@@ -125,7 +143,7 @@ checks! {
/// every tick with ServerboundClientTickEndPacket, at the server's tick rate; each one spends a tick's
/// time from a balance that wall-clock time fills, capped so a pause can't be saved up for a burst.
/// It logs only by default; once trusted: `setback = 2`, `alert = 5`, `kick = 20`.
timer: Timer => TimerConfig [0.0, 0.0, 0.0, 0.02] {
timer: Timer => TimerConfig [0.0, 0.0, 0.0, 0.02, 0] {
/// How far ahead of the clock, in milliseconds, a client may get before it is flagged.
tolerance_ms: f64 = 300.0,
/// The most time, in milliseconds, a quiet connection banks for the ticks that follow (a lag
@@ -135,9 +153,12 @@ checks! {
/// Ground: a move that says it ends on the ground with nothing there to stand on: no-fall (claiming
/// the ground to skip fall damage) and walking on water. The claim is dropped, so vanilla's
/// fall damage follows; from `setback` on the player is also sent back. Logging only (`setback = 0`,
/// the default), the claim stands. Once trusted: `setback = 5`, `alert = 15`, `kick = 60`.
ground: Ground => GroundConfig [0.0, 0.0, 0.0, 2.0] {}
/// fall damage follows (the player is never sent back: `setback = 0` makes the check log only), and
/// a player that keeps claiming is kicked, then banned at the second kick (`ban_after = 2`). A claim standing still isn't
/// judged (no fall to skip). On the official server's first two hours (67 players), every player who
/// wasn't flying or walking on water peaked at level 7 (4 claims standing still in one second); the
/// ones who were, at 113 to 3749.
ground: Ground => GroundConfig [10.0, 15.0, 30.0, 2.0, 2] {}
/// Prediction: each move played again from the client's own inputs with vanilla's movement code,
/// and compared (speed, step, fly, jesus...). Each player's client ticks are recorded
@@ -149,7 +170,7 @@ checks! {
/// a chunk the client doesn't have, a riptide launch) is excused, and so are the ticks after it
/// until the moves agree again. It logs only by default; once trusted: `setback = 5`, `alert = 15`,
/// `kick = 60`.
prediction: Prediction => PredictionConfig [0.0, 0.0, 0.0, 2.0] {
prediction: Prediction => PredictionConfig [0.0, 0.0, 0.0, 2.0, 0] {
/// How far, in blocks, a move may land from where the replay puts it.
tolerance: f64 = 0.03,
}
@@ -157,8 +178,9 @@ checks! {
/// Combat: attacks vanilla's client can't send (`combat`): one with no swing after it (a kill aura
/// that hides its swing), or a second entity attacked in the same client tick (vanilla attacks the
/// one entity under the crosshair, picked once a tick). Each flags once; from `setback` on the
/// player's attacks are dropped. A vanilla client never trips it, so it acts by default.
combat: Combat => CombatConfig [2.0, 5.0, 20.0, 0.5] {}
/// player's attacks are dropped. A vanilla client never trips it, so it acts by default: the first
/// kick warns, the second bans (`ban_after = 2`).
combat: Combat => CombatConfig [2.0, 5.0, 20.0, 0.5, 2] {}
}
/// How many checks there are.
@@ -207,6 +229,33 @@ impl Config {
}
}
/// How many times each check kicked each player, kept across sessions (`strikes`, a JSON object of
/// players' UUIDs to check names to counts): the kick that reaches a check's `ban_after` is a ban.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Strikes(pub BTreeMap<String, BTreeMap<String, u32>>);
impl Strikes {
/// Player `uuid` was kicked by `check`: its kicks by it so far, this one included, and whether this
/// one bans. A ban starts the count again, so a player an operator pardons is kicked before the
/// next ban.
pub fn kick(&mut self, uuid: &str, check: Check, ban_after: u32) -> (u32, bool) {
let count = self
.0
.entry(uuid.to_owned())
.or_default()
.entry(check.name().to_owned())
.or_default();
*count = count.saturating_add(1);
let kicks = *count;
let ban = ban_after > 0 && kicks >= ban_after;
if ban {
*count = 0;
}
(kicks, ban)
}
}
/// What a flag leads to.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Verdict {
@@ -277,14 +326,19 @@ mod tests {
}
#[test]
fn on_by_default_but_only_combat_acts() {
fn on_by_default_but_only_combat_and_ground_act() {
let c = Config::from_server_toml("[server]\nport = 25565\n").unwrap();
assert_eq!(c, Config::default());
for &check in Check::ALL {
assert!(c.runs(check), "{}", check.name());
let a = c.actions(check);
let acts = [a.setback, a.alert, a.kick] != [0.0; 3];
assert_eq!(acts, check == Check::Combat, "{}", check.name());
assert_eq!(
acts,
matches!(check, Check::Combat | Check::Ground),
"{}",
check.name()
);
}
let off = Config::from_server_toml("[anticheat]\nenabled = false\n").unwrap();
assert!(Check::ALL.iter().all(|&check| !off.runs(check)));
@@ -336,6 +390,25 @@ mod tests {
assert!(v.kick && v.alert);
}
#[test]
fn the_kick_that_reaches_ban_after_bans() {
let c = Config::default();
assert_eq!(c.actions(Check::Combat).ban_after, 2);
assert_eq!(c.actions(Check::Prediction).ban_after, 0);
let mut s = Strikes::default();
assert_eq!(s.kick("a", Check::Combat, 2), (1, false));
// Another player, another check: their own counts.
assert_eq!(s.kick("b", Check::Combat, 2), (1, false));
assert_eq!(s.kick("a", Check::Timer, 2), (1, false));
assert_eq!(s.kick("a", Check::Combat, 2), (2, true));
// Pardoned, the player is kicked once more before the next ban.
assert_eq!(s.kick("a", Check::Combat, 2), (1, false));
// 0 never bans; 1 bans at the first kick.
assert_eq!(s.kick("c", Check::Timer, 0), (1, false));
assert_eq!(s.kick("c", Check::Timer, 0), (2, false));
assert_eq!(s.kick("d", Check::Ground, 1), (1, true));
}
#[test]
fn zero_turns_an_action_off() {
let mut c = on();
@@ -81,6 +81,19 @@ impl InteractionInput {
}
}
/// The sentence of `Minecraft.handleKeybinds` that follows a key which opened
/// a screen: `Gui.setScreen` has already run `KeyMapping.releaseAll()`, so
/// `key.use` reads up and the same call releases an active use
/// (`player.isUsingItem() && !keyUse.isDown()` → `MultiPlayerGameMode.releaseUsingItem`).
/// Only the screens opened from the keybinds do this (the inventory, chat and
/// advancements); the pause menu, server containers, mount inventories and
/// dialogs release on the first tick after closing instead, as vanilla does.
pub fn release_on_screen_open(world: &mut impl InteractionWorld) {
if world.using_item() {
world.release_using();
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -885,25 +885,26 @@ impl BlockEntityRenderer {
let mut renderer = Self::new(models, registry);
renderer.visibility = Some(Mutex::new(visibility));
// Sherd items: provides_pottery_pattern -> decorated_pot_pattern asset id.
if let Ok(bytes) = std::fs::read(root.join("items.json")) {
let items: Value = serde_json::from_slice(&bytes)?;
for (item, components) in items.as_object().into_iter().flatten() {
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str()
else {
continue;
};
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
let Ok(def) = std::fs::read(file) else {
continue;
};
let def: Value = serde_json::from_slice(&def)?;
if let Some(asset) = def["asset_id"].as_str() {
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
renderer
.pot_patterns
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
}
let items = match horizon_world::item_defaults::read(root) {
Ok(items) => items,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Default::default(),
Err(e) => return Err(e.into()),
};
for (item, components) in &items {
let Some(pattern) = components["minecraft:provides_pottery_pattern"].as_str() else {
continue;
};
let name = pattern.strip_prefix("minecraft:").unwrap_or(pattern);
let file = root.join(format!("data/minecraft/decorated_pot_pattern/{name}.json"));
let Ok(def) = std::fs::read(file) else {
continue;
};
let def: Value = serde_json::from_slice(&def)?;
if let Some(asset) = def["asset_id"].as_str() {
let asset = asset.strip_prefix("minecraft:").unwrap_or(asset);
renderer
.pot_patterns
.insert(item.clone(), format!("entity/decorated_pot/{asset}"));
}
}
Ok(renderer)
@@ -291,11 +291,19 @@ pub struct Beacon {
}
#[derive(Clone, Debug, Default)]
pub struct Jukebox {
pub record: Option<ItemStack>,
/// `JukeboxBlockEntity`'s one slot (`ContainerSingleItem`), so a hopper or
/// a dropper reaches it as it reaches any container.
pub items: [Option<ItemStack>; 1],
/// `ticks_since_song_started`, while a song plays.
pub ticks: i64,
pub playing: bool,
}
impl Jukebox {
/// `JukeboxBlockEntity.getTheItem`: the disc it holds.
pub fn record(&self) -> Option<&ItemStack> {
self.items[0].as_ref()
}
}
/// `BrewingStandBlockEntity` state. Slots: 0-2 bottles, 3 ingredient, 4 fuel.
#[derive(Clone, Debug, Default)]
pub struct BrewingStand {
+23 -3
View File
@@ -421,7 +421,7 @@ impl BlockEntity {
Self::Furnace(f) => f.items.iter().flatten().cloned().collect(),
Self::Brewing(b) => b.items.iter().flatten().cloned().collect(),
Self::Sign(_) => Vec::new(),
Self::Jukebox(j) => j.record.iter().cloned().collect(),
Self::Jukebox(j) => j.items.iter().flatten().cloned().collect(),
Self::Beacon(_)
| Self::CreakingHeart(_)
| Self::Spawner(_)
@@ -454,7 +454,6 @@ impl BlockEntity {
Self::Furnace(f) => &f.items,
Self::Brewing(b) => &b.items,
Self::Sign(_)
| Self::Jukebox(_)
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
@@ -468,6 +467,7 @@ impl BlockEntity {
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Brushable(_) => &[],
Self::Jukebox(j) => &j.items,
Self::Crafter(c) => &c.items,
Self::ChiseledBookshelf(b) => &b.items,
Self::Shelf(b) => &b.items,
@@ -488,7 +488,6 @@ impl BlockEntity {
Self::Furnace(f) => &mut f.items,
Self::Brewing(b) => &mut b.items,
Self::Sign(_)
| Self::Jukebox(_)
| Self::Beacon(_)
| Self::Lectern(_)
| Self::Sculk(_)
@@ -502,6 +501,7 @@ impl BlockEntity {
| Self::TestInstanceBlock(_)
| Self::DaylightDetector
| Self::Brushable(_) => &mut [],
Self::Jukebox(j) => &mut j.items,
Self::Crafter(c) => &mut c.items,
Self::ChiseledBookshelf(b) => &mut b.items,
Self::Shelf(b) => &mut b.items,
@@ -645,6 +645,26 @@ mod tests {
assert_eq!(fifth.count, 1);
}
/// `JukeboxBlockEntity`'s single slot is a `Container` slot, so a hopper
/// (`HopperBlockEntity.getContainerAt`) can reach the disc in it.
#[test]
fn a_jukebox_offers_its_record_as_its_one_container_slot() {
let mut entity = BlockEntity::Jukebox(Jukebox::default());
assert_eq!(entity.slots().len(), 1);
assert!(entity.slots()[0].is_none(), "empty until a disc lands");
let disc = ItemStack::new("minecraft:cat", 1);
entity.slots_mut()[0] = Some(disc.clone());
let slots = entity.slots();
assert_eq!(slots[0].as_ref().map(|s| &*s.item), Some("minecraft:cat"));
match &entity {
BlockEntity::Jukebox(j) => {
assert_eq!(j.record().map(|s| &*s.item), Some("minecraft:cat"));
}
_ => panic!("a jukebox"),
}
assert_eq!(entity.contents().len(), 1);
}
/// A whole amount pays itself without a draw; a fraction is a chance of one more.
#[test]
fn create_experience_rounds_the_fraction_by_chance() {
+502 -127
View File
@@ -331,6 +331,11 @@ pub struct Frame<'a> {
/// (`crate::camera::spinning_effect_matrix`), not the hand's.
pub spin: Mat4,
pub sky: Sky,
/// Block edits and chunk membership that can change the shader pack's shadow map.
pub shadow_scene_revision: (u64, u64),
/// Opt-in reuse of the shader pack's last shadow map when the key still matches
/// (`horizon-graphics.txt` `shadowCache`; off by default).
pub shadow_cache: bool,
/// Render distance in blocks (fog end).
pub distance: f32,
/// Full-detail terrain distance in blocks; distant LODs bypass the cave graph.
@@ -475,6 +480,9 @@ pub struct ScreenshotRequest {
/// `GameRenderer.takeAutoScreenshot`: centre-crop the frame to a square
/// and average it down to this size. `None` saves the frame as drawn.
pub size: Option<[u32; 2]>,
/// F2's: drawn at the game's resolution (Resolution Scale above 100%) times
/// `Renderer::screenshot_quality`, not the window's.
pub scaled: bool,
}
pub struct Renderer {
instance: wgpu::Instance,
@@ -489,6 +497,9 @@ pub struct Renderer {
offscreen: Option<wgpu::Texture>,
/// Offscreen submissions still on the GPU: at most two frames run ahead.
in_flight: std::collections::VecDeque<wgpu::SubmissionIndex>,
/// Splitscreen: the caller waits for the GPU once every player has submitted
/// (`wait_in_flight`), so one player's work overlaps the GPU drawing another's.
pub paced_elsewhere: bool,
/// Which steps of the first frame on this device still go to the log
/// (see `startup_step`).
first_frame: FirstFrame,
@@ -511,6 +522,9 @@ pub struct Renderer {
window: Arc<Window>,
pub device: wgpu::Device,
pub queue: wgpu::Queue,
/// Set by the device's lost callback. wgpu reports nothing else when an
/// operation fails on a lost device (no error scope sees it).
device_lost: Arc<std::sync::atomic::AtomicBool>,
pub config: wgpu::SurfaceConfiguration,
solid: wgpu::RenderPipeline,
translucent: wgpu::RenderPipeline,
@@ -593,9 +607,11 @@ pub struct Renderer {
group: wgpu::BindGroup,
globals: wgpu::Buffer,
lightmap: wgpu::Texture,
/// The block sprites, [`SPRITE_SPLIT`] layers per array (the first is
/// The block sprites, `sprite_split` layers per array (the first is
/// `sprites` in terrain.wgsl, the others `sprites_1`...).
sprite_textures: Vec<wgpu::Texture>,
/// Block sprite layers per array on this device ([`layer_split`]).
sprite_split: u32,
/// A 1-layer array bound where a pack needs fewer arrays.
sprite_unused: wgpu::TextureView,
mip_levels: u32,
@@ -728,6 +744,10 @@ pub struct Renderer {
/// first gets the swapchain a fresh configuration; later ones reattach
/// the window, as a lost surface does.
acquire_validation_failures: u32,
/// A surface from `recreate_surface` whose configuration failed. wgpu
/// panics when such a surface is acquired from (it never had one that
/// worked), so frames configure it again first and skip until it takes.
surface_unconfigured: bool,
pub drawn_sections: usize,
/// Last frame's section draws (face ranges and translucent sections),
/// and the draw calls the backend issued for them.
@@ -739,6 +759,12 @@ pub struct Renderer {
pub screenshot: Option<ScreenshotRequest>,
/// Outcome of the last screenshot: frame size, or the error.
pub screenshot_result: Option<std::result::Result<[u32; 2], String>>,
/// Screenshot Quality (Horizon setting): a `scaled` screenshot's frame is this many
/// times the game's resolution (1, 2, 4 or 8).
pub screenshot_quality: u32,
/// How many times the pending scaled screenshot ran out of video memory: each
/// halves its size, down to the window's.
screenshot_halvings: u32,
/// Brightness option (lightmap gamma), 0..1.
pub gamma: f32,
/// Optional full-light lightmap (Horizon Extras).
@@ -753,18 +779,45 @@ pub struct Renderer {
}
/// Every layer's mip levels, layer major (`first` is the first layer's
/// index into `settings`), made by `MipmapGenerator`.
/// Block sprite layers per texture array: the smallest
/// `max_texture_array_layers` of the GPUs Horizon runs on. Mods add sprites
/// past it into more arrays (terrain.wgsl's `sprite`).
/// Block sprite layers per texture array at most: the
/// `max_texture_array_layers` of most GPUs. Mods add sprites past it into more
/// arrays (terrain.wgsl's `sprite`); a GPU with fewer layers per array gets a
/// smaller split ([`layer_split`]).
pub(crate) const SPRITE_SPLIT: u32 = 2048;
/// [`SPRITE_SPLIT`], or a smaller split from `HORIZON_SPRITE_SPLIT` (dev
/// only: vanilla's sprites drawn from several arrays must look the same).
fn sprite_split() -> u32 {
/// A smaller split from `HORIZON_SPRITE_SPLIT` (dev only: vanilla's sprites
/// drawn from several arrays must look the same).
fn requested_split() -> Option<u32> {
std::env::var("HORIZON_SPRITE_SPLIT")
.ok()
.and_then(|v| v.parse().ok())
.filter(|&n| (1..=SPRITE_SPLIT).contains(&n))
.unwrap_or(SPRITE_SPLIT)
}
/// The split the CPU mip chains are cut by before the adapter is known.
fn sprite_split() -> u32 {
requested_split().unwrap_or(SPRITE_SPLIT)
}
/// The block sprite layers per texture array on an adapter that holds
/// `max_layers` layers per array: [`SPRITE_SPLIT`], `requested`
/// (`HORIZON_SPRITE_SPLIT`) or `max_layers`, whichever is smallest, with a full
/// array still fitting once [`array_layers`] pads it for OpenGL. Errors when
/// `layers` sprites need more than the [`SPRITE_ARRAYS`] arrays terrain.wgsl binds.
fn layer_split(
max_layers: u32,
gl: bool,
requested: Option<u32>,
layers: u32,
) -> std::result::Result<u32, String> {
let mut split = requested.unwrap_or(SPRITE_SPLIT).min(max_layers).max(1);
if split > 1 && array_layers(split, gl) > max_layers {
split -= 1;
}
if layers > split * SPRITE_ARRAYS {
return Err(format!(
"GPU supports {max_layers} texture layers per array; {layers} block textures need \
more than the {SPRITE_ARRAYS} arrays of {split} Horizon draws from"
));
}
Ok(split)
}
/// Sprite arrays terrain.wgsl binds (`sprites`, `sprites_1`...).
const SPRITE_ARRAYS: u32 = 8;
@@ -867,6 +920,8 @@ fn terrain_group(
/// construction so the temporary mip chains are released after GPU upload.
pub struct PreparedTextures {
sprites: Vec<(Vec<u8>, u32)>,
/// The sprite layers per array `sprites` is cut by.
split: u32,
forcefield: (Vec<u8>, u32),
}
impl PreparedTextures {
@@ -898,9 +953,33 @@ impl PreparedTextures {
mip_chain(&pixels[start..], size, settings, first, count)
})
.collect(),
split,
forcefield: mip_chain(forcefield.0, forcefield.1, &[], 0, 1),
}
}
/// 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 {
if self.split == split || layers <= split.min(self.split) {
self.split = split;
return self;
}
let levels = self.sprites[0].1;
let all: Vec<u8> = self
.sprites
.drain(..)
.flat_map(|(bytes, _)| bytes)
.collect();
// Every layer's mip levels are contiguous (layer major).
let layer_bytes = all.len() / layers as usize;
self.sprites = all
.chunks(layer_bytes * split as usize)
.map(|chunk| (chunk.to_vec(), levels))
.collect();
self.split = split;
self
}
}
fn mip_chain(
@@ -1039,6 +1118,7 @@ struct Opened {
adapter: wgpu::Adapter,
device: wgpu::Device,
queue: wgpu::Queue,
device_lost: Arc<std::sync::atomic::AtomicBool>,
}
/// Logs the adapter and records it for telemetry and crash reports.
fn describe_adapter(adapter: &wgpu::Adapter) {
@@ -1068,16 +1148,13 @@ fn describe_adapter(adapter: &wgpu::Adapter) {
}
/// Whether an adapter's texture arrays hold the block textures Horizon puts
/// in one array.
fn check_layers(limits: &wgpu::Limits, layers: u32) -> Result<()> {
let needed = layers.min(sprite_split());
if limits.max_texture_array_layers < needed {
return Err(format!(
"GPU supports {} texture layers; {needed} required",
limits.max_texture_array_layers
)
.into());
}
Ok(())
fn check_layers(adapter: &wgpu::Adapter, layers: u32) -> Result<u32> {
Ok(layer_split(
adapter.limits().max_texture_array_layers,
adapter.get_info().backend == wgpu::Backend::Gl,
requested_split(),
layers,
)?)
}
/// Whether the swapchain can take a window of `width` x `height`: each side from 1 to the
/// device's `max_texture_dimension_2d` (`max`), else `Surface::configure` panics. 0 is a
@@ -1248,7 +1325,7 @@ async fn open_backend(
describe_adapter(&adapter);
let info = adapter.get_info();
let limits = adapter.limits();
if let Err(e) = check_layers(&limits, layers) {
if let Err(e) = check_layers(&adapter, layers) {
return Err(NoDevice::Failed(format!("{name}: {e}")));
}
// Mappable primary buffers: section pages that no longer fit in
@@ -1300,13 +1377,28 @@ async fn open_backend(
None => adapter.request_device(&desc).await,
};
match opened {
Ok((device, queue)) => Ok(Opened {
instance,
surface,
adapter,
device,
queue,
}),
Ok((device, queue)) => {
let device_lost = Arc::new(std::sync::atomic::AtomicBool::new(false));
let lost = device_lost.clone();
device.set_device_lost_callback(move |reason, message| {
// A device destroyed on purpose was not lost.
if reason == wgpu::DeviceLostReason::Destroyed {
return;
}
log::warn!(
"Horizon renderer: the graphics device was lost ({reason:?}: {message})"
);
lost.store(true, std::sync::atomic::Ordering::Relaxed);
});
Ok(Opened {
instance,
surface,
adapter,
device,
queue,
device_lost,
})
}
Err(e) => Err(NoDevice::Failed(format!("{name} device: {e}"))),
}
}
@@ -1397,27 +1489,20 @@ impl Renderer {
previous: Option<&Self>,
api: GraphicsApi,
) -> Result<Self> {
let split = sprite_split();
if layers > split * SPRITE_ARRAYS {
return Err(format!(
"{layers} block textures; Horizon draws at most {}",
split * SPRITE_ARRAYS
)
.into());
}
let Opened {
instance,
surface,
adapter,
device,
queue,
device_lost,
} = match previous {
// Reload resources on the same device; replacing live Vulkan
// devices can invalidate the driver's swapchain state on the same
// window.
Some(renderer) => {
describe_adapter(&renderer.adapter);
check_layers(&renderer.adapter.limits(), layers)?;
check_layers(&renderer.adapter, layers)?;
Opened {
surface: if offscreen {
None
@@ -1436,10 +1521,17 @@ impl Renderer {
adapter: renderer.adapter.clone(),
device: renderer.device.clone(),
queue: renderer.queue.clone(),
device_lost: renderer.device_lost.clone(),
}
}
None => open_device(&window, offscreen, api, layers).await?,
};
// Computed once: the sprite arrays, the shaders' constants and the
// shader pack atlas all cut the sprites by it.
let split = check_layers(&adapter, layers)?;
if split < sprite_split() {
log::info!("Horizon: {split} block textures per array (the GPU's limit)");
}
let info = adapter.get_info();
crate::graphics::suggest_reduced_far_detail(crate::graphics::lod_capped(&info));
let size = window.inner_size();
@@ -1817,9 +1909,17 @@ impl Renderer {
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let prepared = prepared.unwrap_or_else(|| {
PreparedTextures::new(sprite_pixels, sprite_size, layers, sprite_mips, forcefield)
});
let prepared = match prepared {
Some(prepared) => prepared.resplit(layers, split),
None => PreparedTextures::with_split(
sprite_pixels,
sprite_size,
layers,
sprite_mips,
forcefield,
split,
),
};
let mut mip_levels = 1;
let gl = adapter.get_info().backend == wgpu::Backend::Gl;
let sprite_textures: Vec<wgpu::Texture> = prepared
@@ -2399,10 +2499,13 @@ impl Renderer {
alpha_blend,
true,
);
// RenderPipelines.TEXT_SEE_THROUGH: translucent, no depth test or writes.
// RenderPipelines.TEXT_SEE_THROUGH: translucent, no depth test or
// writes, culled like every world text pipeline (TEXT_SNIPPET keeps
// RenderPipeline.Builder's default): a name tag or text display hides
// its back.
let see_through_pipeline = entity_pipeline(
"See-through text",
None,
Some(wgpu::Face::Back),
false,
wgpu::CompareFunction::Always,
Default::default(),
@@ -2410,10 +2513,11 @@ impl Renderer {
false,
);
// RenderPipelines.TEXT_POLYGON_OFFSET: a text display's text and
// background win against the surface they lie on (bias 1, 10).
// background win against the surface they lie on (bias 1, 10), from
// the front only.
let text_polygon_offset_pipeline = entity_pipeline(
"Polygon offset text",
None,
Some(wgpu::Face::Back),
true,
wgpu::CompareFunction::GreaterEqual,
wgpu::DepthBiasState {
@@ -2984,6 +3088,7 @@ impl Renderer {
spare_surface: std::sync::Mutex::new(None),
offscreen,
in_flight: std::collections::VecDeque::new(),
paced_elsewhere: false,
first_frame: if previous.is_none() {
FirstFrame::Acquire
} else {
@@ -2997,6 +3102,7 @@ impl Renderer {
window,
device,
queue,
device_lost,
config,
solid,
translucent,
@@ -3067,6 +3173,7 @@ impl Renderer {
mipmaps: mip_levels - 1,
terrain_layout: bindings,
terrain_sprites: sprite_constants.map(|(_, v)| v),
sprite_split: split,
sprite_sampler,
texture_filtering: 0,
max_anisotropy: 1,
@@ -3138,6 +3245,7 @@ impl Renderer {
info,
suspended: !drawable_size(size.width, size.height, max_side),
acquire_validation_failures: 0,
surface_unconfigured: false,
drawn_sections: 0,
section_draws: 0,
section_draw_calls: 0,
@@ -3145,6 +3253,8 @@ impl Renderer {
started: Instant::now(),
screenshot: None,
screenshot_result: None,
screenshot_quality: 1,
screenshot_halvings: 0,
gamma: 0.5,
improved_transparency: false,
oit: None,
@@ -3223,12 +3333,19 @@ impl Renderer {
Some(surface) => surface,
None => self.instance.create_surface(self.window.clone())?,
};
Self::configure(
let configured = Self::configure(
&self.device,
&surface,
&self.surface_config(),
self.hdr.as_ref(),
&self.device_lost,
);
if let Err(e) = &configured {
log::warn!(
"Horizon renderer: the new window surface did not configure ({e}); retrying"
);
}
self.surface_unconfigured = configured.is_err();
self.surface = Some(surface);
Ok(())
}
@@ -3283,16 +3400,9 @@ impl Renderer {
Some(PresentMode::Immediate) => wgpu::PresentMode::Immediate,
_ => wgpu::PresentMode::Fifo,
};
if self.config.present_mode != mode
&& let Some(surface) = &self.surface
{
if self.config.present_mode != mode && self.surface.is_some() {
self.config.present_mode = mode;
Self::configure(
&self.device,
surface,
&self.surface_config(),
self.hdr.as_ref(),
);
self.configure_surface();
}
}
/// Follows the window to `width` x `height`. A size the swapchain can't take
@@ -3305,13 +3415,8 @@ impl Renderer {
}
self.config.width = width;
self.config.height = height;
if let Some(surface) = &self.surface {
Self::configure(
&self.device,
surface,
&self.surface_config(),
self.hdr.as_ref(),
);
if self.surface.is_some() {
self.configure_surface();
} else {
self.offscreen = Some(Self::offscreen_target(&self.device, &self.config));
}
@@ -3568,6 +3673,64 @@ impl Renderer {
self.arena
.release(s.page, s.start, u64::from(s.solid + s.translucent) * 4);
}
/// Video memory the arena keeps because fragmentation holds its pages
/// partly used (a render distance that went down, far tiles that were
/// replaced): moves the meshes off the sparsest page, a few MiB a frame,
/// and `Arena::trim` returns the page once it is empty. Frames with the
/// arena in system memory (video memory full) are slow, so this is what
/// keeps the terrain in video memory.
fn compact_arena(&mut self) {
// Copied per frame: a page empties over a few frames, with no hitch.
const BUDGET: u64 = 32 << 20;
if self.arena.closing().is_none()
&& let Some(page) = self.arena.sparsest()
{
self.arena.close(page);
}
let Some(page) = self.arena.closing() else {
return;
};
let stride = std::mem::size_of::<TerrainVertex>() as u64;
let mut budget = BUDGET;
let encoder = self.section_copies.get_or_insert_with(|| {
self.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Section uploads"),
})
});
let meshes = self
.sections
.values_mut()
.chain(self.lods.values_mut().flat_map(|tile| tile.meshes_mut()));
for s in meshes.filter(|s| s.page == page) {
let vertices = u64::from(s.solid + s.translucent) * 4;
if vertices * stride > budget {
break;
}
let Some(to) = self.arena.alloc_moved(vertices) else {
// The other pages have no room after all.
self.arena.reopen();
return;
};
self.arena.copy(encoder, (s.page, s.start), to, vertices);
self.arena.release(s.page, s.start, vertices);
if let (Some(flat), Some(id)) = (&mut self.flat, s.flat_id) {
flat.remove(id);
s.flat_id = Some(flat.add(
&self.device,
encoder,
&mut self.section_uploads,
to.0,
to.1,
vertices,
));
}
(s.page, s.start) = to;
budget -= vertices * stride;
}
// With every mesh moved off, the page stays closed so this frame's
// uploads can't refill it: `trim` returns it and clears `closing`.
}
/// Block-entity blocks of every meshed section (see `SectionMesh::block_entities`).
pub fn block_entities(&self) -> impl Iterator<Item = &([i32; 3], u32)> {
self.block_entities.values().flatten()
@@ -3745,14 +3908,26 @@ impl Renderer {
}
/// Horizon's resolution scale (world pixels per window pixel) and FXAA.
/// Configures `surface` and, while HDR output is on, sends its HDR metadata
/// again: a new swapchain starts without it.
/// again: a new swapchain starts without it. Errs when `surface` was left
/// unconfigured (a validation error, or a lost device, which wgpu reports
/// to no error scope).
fn configure(
device: &wgpu::Device,
surface: &wgpu::Surface<'_>,
config: &wgpu::SurfaceConfiguration,
hdr: Option<&hdr::Hdr>,
) {
device_lost: &std::sync::atomic::AtomicBool,
) -> std::result::Result<(), String> {
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
surface.configure(device, config);
let memory = pollster::block_on(memory.pop());
if let Some(e) = memory.or(pollster::block_on(validation.pop())) {
return Err(e.to_string());
}
if device_lost.load(std::sync::atomic::Ordering::Relaxed) {
return Err("the graphics device was lost".into());
}
if let Some(hdr) = hdr {
horizon_hdr::set_metadata(
device,
@@ -3762,15 +3937,21 @@ impl Renderer {
hdr.black,
);
}
Ok(())
}
/// Configures the window surface again. One that fails is still the one
/// wgpu configured before, which acquisition reports as a validation error.
fn configure_surface(&self) {
if let Some(surface) = &self.surface {
Self::configure(
if let Some(surface) = &self.surface
&& let Err(e) = Self::configure(
&self.device,
surface,
&self.surface_config(),
self.hdr.as_ref(),
);
&self.device_lost,
)
{
log::warn!("Horizon renderer: the window surface did not configure ({e})");
}
}
/// HDR calibration's test picture (`crate::hdr_calibration`) while it runs, else None.
@@ -4057,7 +4238,7 @@ impl Renderer {
&self.device,
&mut encoder,
&self.sprite_textures,
sprite_split(),
self.sprite_split,
layers,
self.sprite_textures[0].width(),
);
@@ -4280,7 +4461,11 @@ impl Renderer {
}
}
pub fn render(&mut self, frame: &Frame) -> Result<bool> {
let rendered = self.draw_frame(frame);
let scaled = self.screenshot_size();
let rendered = match scaled {
Some(size) => self.draw_scaled_frame(frame, size),
None => self.draw_frame(frame, None),
};
// A frame that was not drawn still submits the section copies and
// atlas patches (glyphs, skins, maps), which the caller already took.
if !matches!(rendered, Ok(true)) && !frame.hud.patches.is_empty() {
@@ -4303,10 +4488,76 @@ impl Renderer {
} else if let Some(copies) = self.take_section_copies() {
self.queue.submit([copies]);
}
rendered
// A scaled screenshot's frame never reached the window.
rendered.map(|drawn| drawn && scaled.is_none())
}
fn draw_frame(&mut self, frame: &Frame) -> Result<bool> {
/// The frame size of the pending `scaled` screenshot when it is larger than the
/// window: the window's times the Resolution Scale (when above 100%) and
/// `screenshot_quality`, cut down to what one texture and one readback buffer hold,
/// and halved for each time it ran out of video memory.
fn screenshot_size(&self) -> Option<[u32; 2]> {
let request = self.screenshot.as_ref()?;
if !request.scaled || request.size.is_some() || self.suspended {
return None;
}
let window = [self.config.width, self.config.height];
let limits = self.device.limits();
let factor = scaled_screenshot_factor(
window,
self.world_post.scale,
self.screenshot_quality,
limits.max_texture_dimension_2d,
limits.max_buffer_size,
) / 2f32.powi(self.screenshot_halvings as i32);
(factor > 1.0).then(|| window.map(|side| (side as f32 * factor) as u32))
}
/// Draws one frame at `size` into a texture of its own, for the pending screenshot:
/// every pass sizes itself by `config`, which says `size` for this frame, the HUD
/// keeps its layout at a larger scale, and the world is drawn at `size` (a Resolution
/// Scale below 100% stays as low). The window keeps its last frame.
fn draw_scaled_frame(&mut self, frame: &Frame, size: [u32; 2]) -> Result<bool> {
let window = [self.config.width, self.config.height];
let mut config = self.config.clone();
[config.width, config.height] = size;
config.usage |= wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC;
// Out of video memory (an invalid texture's view is a validation error):
// tried again at half the size next frame; the window keeps its last one.
let checks = GpuChecks::push(&self.device);
let target = Self::offscreen_target(&self.device, &config);
let depth = Self::depth(&self.device, &config);
if let Some(e) = checks.pop() {
log::warn!(
"Horizon renderer: no memory for a {}x{} screenshot ({e}); trying half the size",
size[0],
size[1]
);
self.screenshot_halvings += 1;
return Ok(false);
}
let scale = self.world_post.scale;
self.world_post.scale = scale / scale.max(1.0);
self.hud.zoom = size[0] as f32 / window[0] as f32;
[self.config.width, self.config.height] = size;
let surface = self.surface.take();
// HDR presents to the swapchain; this frame goes to a file.
let hdr = self.hdr.take();
let offscreen = self.offscreen.replace(target);
let window_depth = std::mem::replace(&mut self.depth, depth);
// The frame's own targets at `size` can run out of memory too.
let drawn = self.draw_frame(frame, Some(GpuChecks::push(&self.device)));
self.depth = window_depth;
self.offscreen = offscreen;
self.hdr = hdr;
self.surface = surface;
[self.config.width, self.config.height] = window;
self.hud.zoom = 1.0;
self.world_post.scale = scale;
drawn
}
/// `checks`: the error scopes of a scaled screenshot's frame, checked before its PNG is written.
fn draw_frame(&mut self, frame: &Frame, checks: Option<GpuChecks>) -> Result<bool> {
self.arena.trim();
self.compact_arena();
self.sorted_indices.trim();
if let Some(flat) = &mut self.flat {
flat.trim(|page| self.arena.has_page(page));
@@ -4320,6 +4571,27 @@ impl Renderer {
startup_step(format_args!("acquiring the first frame"));
self.first_frame = FirstFrame::Submit;
}
if self.surface_unconfigured
&& let Some(surface) = &self.surface
{
// Acquiring from it would panic: configure it again, or skip the
// frame. A lost device never configures it.
if self.device_lost.load(std::sync::atomic::Ordering::Relaxed) {
return Err("the graphics device was lost".into());
}
if Self::configure(
&self.device,
surface,
&self.surface_config(),
self.hdr.as_ref(),
&self.device_lost,
)
.is_err()
{
return Ok(false);
}
self.surface_unconfigured = false;
}
let acquire = Instant::now();
let acquired = self.surface.as_ref().map(|s| s.get_current_texture());
self.last_wait = acquire.elapsed();
@@ -4331,14 +4603,7 @@ impl Renderer {
return Ok(false);
}
Some(wgpu::CurrentSurfaceTexture::Outdated) => {
if let Some(surface) = &self.surface {
Self::configure(
&self.device,
surface,
&self.surface_config(),
self.hdr.as_ref(),
);
}
self.configure_surface();
return Ok(false);
}
Some(wgpu::CurrentSurfaceTexture::Lost) => {
@@ -4353,14 +4618,7 @@ impl Renderer {
// window like a Lost one — a fullscreen loading screen then
// skips frames instead of failing every one of them.
if self.acquire_validation_failures == 0 {
if let Some(surface) = &self.surface {
Self::configure(
&self.device,
surface,
&self.surface_config(),
self.hdr.as_ref(),
);
}
self.configure_surface();
} else {
self.recreate_surface()?;
}
@@ -5944,10 +6202,6 @@ impl Renderer {
);
pass.set_bind_group(0, &self.group, &[]);
}
if !pack_draws_weather && !oit_on {
self.weather.draw(&mut pass, &self.entity_group);
}
pass.set_bind_group(0, &self.group, &[]);
if crumbling_quads > 0 && !oit_on {
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
@@ -6297,19 +6551,6 @@ impl Renderer {
if outline_edges > 0 && frame.outline_after_terrain {
self.draw_outline(&mut pass, outline_edges as u32, frame.outline_high_contrast);
}
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
// LevelRenderer: the world border after translucent terrain and weather.
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title && !oit_on) {
pass.set_pipeline(&self.border_pipeline);
pass.set_bind_group(0, &self.border_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.border_vertices.slice(..));
for &side in &b.sides {
let first = side as u32 * 6;
pass.draw_indexed(first..first + 6, 0, 0..1);
}
pass.set_bind_group(0, &self.group, &[]);
}
if translucent_crumbling_quads > 0 && !oit_on {
pass.set_pipeline(&self.crumbling_pipeline);
pass.set_bind_group(0, &self.entity_group, &[]);
@@ -6327,6 +6568,25 @@ impl Renderer {
pass.set_vertex_buffer(0, self.cloud_buffer.slice(..));
pass.draw_indexed(0..cloud_quads * 6, 0, 0..1);
}
// LevelRenderer.executeClassicTransparency: weather after translucent
// 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);
pass.set_bind_group(0, &self.group, &[]);
}
// LevelRenderer: the world border after translucent terrain and weather.
if let Some(b) = frame.border.as_ref().filter(|_| !frame.title && !oit_on) {
pass.set_pipeline(&self.border_pipeline);
pass.set_bind_group(0, &self.border_group, &[]);
pass.set_index_buffer(self.indices.slice(..), wgpu::IndexFormat::Uint32);
pass.set_vertex_buffer(0, self.border_vertices.slice(..));
for &side in &b.sides {
let first = side as u32 * 6;
pass.draw_indexed(first..first + 6, 0, 0..1);
}
pass.set_bind_group(0, &self.group, &[]);
}
// LevelRenderer.executeSeeThrough, after the rest of the world.
if see_through_quads > 0 {
pass.set_pipeline(&self.see_through_pipeline);
@@ -6715,21 +6975,34 @@ impl Renderer {
if let Some(Some(timer)) = Self::pass_timer(&mut self.pack_post, &mut self.native) {
timer.submitted(&self.queue);
}
let failed = checks.and_then(GpuChecks::pop);
if let Some((request, buffer)) = readback {
if let Some(hdr_buffer) = hdr_readback {
let hdr_path = hdr_screenshot_path(&request.path);
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
Ok(()) => log::info!("Saved HDR screenshot to {}", hdr_path.display()),
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
// Out of memory on the way: the PNG would be black; taken again at half the size.
if let Some(e) = failed {
log::warn!(
"Horizon renderer: a {}x{} screenshot failed ({e}); trying half the size",
self.config.width,
self.config.height
);
self.screenshot_halvings += 1;
self.screenshot = Some(request);
} else {
self.screenshot_halvings = 0;
if let Some(hdr_buffer) = hdr_readback {
let hdr_path = hdr_screenshot_path(&request.path);
match self.write_hdr_screenshot(&hdr_path, &hdr_buffer) {
Ok(()) => log::info!("Saved HDR screenshot to {}", hdr_path.display()),
Err(e) => log::warn!(
"Horizon renderer: HDR screenshot {}: {e}",
hdr_path.display()
),
}
}
self.screenshot_result = Some(
self.write_screenshot(&request, &buffer)
.map_err(|e| e.to_string()),
);
}
self.screenshot_result = Some(
self.write_screenshot(&request, &buffer)
.map_err(|e| e.to_string()),
);
}
let present = Instant::now();
if let Some(surface_frame) = surface_frame {
@@ -6749,23 +7022,13 @@ impl Renderer {
let _ = std::fs::remove_file(marker);
log::info!("Horizon renderer: HDR output presented its first frame");
}
if reconfigure && let Some(surface) = &self.surface {
Self::configure(
&self.device,
surface,
&self.surface_config(),
self.hdr.as_ref(),
);
if reconfigure {
self.configure_surface();
}
} else {
// No swapchain paces offscreen frames: keep two on the GPU at most.
self.in_flight.push_back(submission);
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
let _ = self.device.poll(wgpu::PollType::Wait {
submission_index: index,
timeout: Some(std::time::Duration::from_secs(5)),
});
if !self.paced_elsewhere {
self.wait_in_flight();
}
}
self.last_wait += present.elapsed();
@@ -6777,6 +7040,41 @@ impl Renderer {
Ok(true)
}
}
/// Out-of-memory and validation error scopes, popped together: the first error either caught.
struct GpuChecks {
memory: wgpu::ErrorScopeGuard,
validation: wgpu::ErrorScopeGuard,
}
impl GpuChecks {
fn push(device: &wgpu::Device) -> Self {
let memory = device.push_error_scope(wgpu::ErrorFilter::OutOfMemory);
let validation = device.push_error_scope(wgpu::ErrorFilter::Validation);
Self { memory, validation }
}
fn pop(self) -> Option<wgpu::Error> {
let validation = pollster::block_on(self.validation.pop());
let memory = pollster::block_on(self.memory.pop());
memory.or(validation)
}
}
/// How many times the window's size a scaled screenshot is: the Resolution Scale
/// `world_scale` when above 1, times `quality`, as far as a side fits in
/// `max_side` pixels and the readback in `max_buffer` bytes.
fn scaled_screenshot_factor(
[width, height]: [u32; 2],
world_scale: f32,
quality: u32,
max_side: u32,
max_buffer: u64,
) -> f32 {
let wanted = world_scale.max(1.0) * quality.max(1) as f32;
let (w, h) = (f64::from(width.max(1)), f64::from(height.max(1)));
let side = f64::from(max_side) / w.max(h);
// padded_row(w * f) * h * f <= (4 * w * f + 256) * h * f <= max_buffer.
let (a, b) = (4.0 * w * h, 256.0 * h);
let buffer = ((b * b + 4.0 * a * max_buffer as f64).sqrt() - b) / (2.0 * a);
wanted.min(side.min(buffer) as f32)
}
/// Bytes per row of a `width`-pixel frame read back at 4 bytes a pixel.
fn padded_row(width: u32) -> u32 {
(width * 4).next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT)
@@ -6813,6 +7111,16 @@ pub(crate) fn copy_frame(
buffer
}
impl Renderer {
/// No swapchain paces offscreen frames: keep two on the GPU at most.
pub fn wait_in_flight(&mut self) {
while self.in_flight.len() > 2 {
let index = self.in_flight.pop_front();
let _ = self.device.poll(wgpu::PollType::Wait {
submission_index: index,
timeout: Some(std::time::Duration::from_secs(5)),
});
}
}
/// Take a screenshot request: queue the frame's copy, or report why the
/// frame cannot be read back.
fn take_readback(
@@ -7185,6 +7493,30 @@ mod tests {
);
}
#[test]
fn scaled_screenshots_follow_resolution_scale_and_quality_within_limits() {
use super::scaled_screenshot_factor as factor;
let big = u64::MAX / 2;
// The window's size at 100% and 1x; Resolution Scale below 100% stays the window's.
assert_eq!(factor([1920, 1080], 1.0, 1, 16384, big), 1.0);
assert_eq!(factor([1920, 1080], 0.5, 1, 16384, big), 1.0);
// The game's resolution, times the quality.
assert_eq!(factor([1920, 1080], 2.0, 1, 16384, big), 2.0);
assert_eq!(factor([1920, 1080], 1.5, 4, 16384, big), 6.0);
assert_eq!(factor([1920, 1080], 0.5, 8, 16384, big), 8.0);
// Cut to the largest texture side: 3840 * 4.266 = 16384.
let f = factor([3840, 2160], 1.0, 8, 16384, big);
assert!(
(3840.0 * f) as u32 <= 16384 && (3840.0 * f) as u32 > 16300,
"{f}"
);
// Cut to the largest buffer: 256 MiB holds about 3.8x of 1080p's readback.
let max = 256 << 20;
let f = factor([1920, 1080], 1.0, 8, 16384, max);
let [w, h] = [1920, 1080].map(|s| (s as f32 * f) as u32);
assert!(u64::from(super::padded_row(w)) * u64::from(h) <= max, "{f}");
assert!(f > 3.7, "{f}");
}
#[test]
fn drawable_size_skips_minimized_and_oversized_windows() {
use super::drawable_size;
assert!(drawable_size(1280, 720, 8192));
@@ -7793,9 +8125,52 @@ mod sprite_upload_tests {
.collect();
assert_eq!(actual, expected.0);
assert_eq!(prepared.forcefield, mip_chain(&border, size, &[], 0, 1));
// Chains cut before the adapter was known, cut again for it.
for to in [1, 2, 3, 5, 8] {
let direct = PreparedTextures::with_split(
&pixels,
size,
layers,
&settings,
(&border, size),
to,
);
let resplit = PreparedTextures::with_split(
&pixels,
size,
layers,
&settings,
(&border, size),
split,
)
.resplit(layers, to);
assert_eq!(resplit.split, to);
assert_eq!(resplit.sprites, direct.sprites, "{split} -> {to}");
}
}
}
#[test]
fn sprite_split_follows_the_adapters_layer_limit() {
// The Discord report: 512 layers per array, 1291 block textures (3 arrays).
assert_eq!(layer_split(512, false, None, 1291), Ok(512));
assert_eq!(layer_split(512, true, None, 1291), Ok(512));
assert_eq!(layer_split(2048, false, None, 1291), Ok(SPRITE_SPLIT));
assert_eq!(layer_split(8192, false, None, 1291), Ok(SPRITE_SPLIT));
// HORIZON_SPRITE_SPLIT only lowers it.
assert_eq!(layer_split(2048, false, Some(512), 1291), Ok(512));
assert_eq!(layer_split(256, false, Some(512), 1291), Ok(256));
// OpenGL pads a multiple of 6 by a layer: a full array must still fit.
assert_eq!(layer_split(600, true, None, 1291), Ok(599));
assert_eq!(layer_split(600, false, None, 1291), Ok(600));
assert_eq!(array_layers(599, true), 599);
// Past the arrays terrain.wgsl binds.
assert_eq!(layer_split(256, false, None, 256 * SPRITE_ARRAYS), Ok(256));
let err = layer_split(128, false, None, 1291).unwrap_err();
assert!(err.contains("128 texture layers per array"), "{err}");
assert!(layer_split(512, false, None, 512 * SPRITE_ARRAYS + 1).is_err());
}
#[test]
fn mip_chain_allocates_exactly_its_size() {
for size in [16, 64] {
+194 -10
View File
@@ -76,8 +76,22 @@ struct Page {
/// Capacity in vertices.
capacity: u64,
free: FreeList,
/// The buffer went to system memory (`Arena::create`'s fallback): meshes
/// are never moved into it, and it is the first to empty.
host: bool,
}
impl Page {
/// Vertices in the page's free ranges.
fn free_vertices(&self) -> u64 {
self.free.0.values().sum()
}
}
/// An arena with this many bytes in free ranges, at least half of its pages,
/// has a page worth emptying (`Arena::sparsest`).
const COMPACT_MIN_FREE: u64 = 128 << 20;
/// Terrain vertices in pages of at most `page_limit` vertices (devices cap
/// buffer sizes): a section's vertices sit in one page.
pub struct Arena {
@@ -86,6 +100,8 @@ pub struct Arena {
page_limit: u64,
initial_capacity: u64,
usage: wgpu::BufferUsages,
/// A page being emptied (`Arena::close`): nothing is placed in it.
closing: Option<u32>,
/// A page had to go to system memory (logged once).
spilled: bool,
/// Tests: GPU-local pages fail as if video memory were full.
@@ -129,6 +145,7 @@ impl Arena {
page_limit,
initial_capacity: capacity,
usage,
closing: None,
spilled: false,
#[cfg(test)]
video_memory_full: false,
@@ -138,7 +155,7 @@ impl Arena {
/// and the device allows it, in system memory: Vulkan drivers fail an
/// allocation rather than page like OpenGL ones, and wgpu's default error
/// handler panics. `None` when neither has room.
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<wgpu::Buffer> {
fn create(&mut self, device: &wgpu::Device, capacity: u64) -> Option<(wgpu::Buffer, bool)> {
let usage = self.usage | wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::COPY_SRC;
#[cfg(test)]
let local = (!self.video_memory_full)
@@ -146,6 +163,7 @@ impl Arena {
.flatten();
#[cfg(not(test))]
let local = try_create(device, capacity * self.stride, usage);
let local = local.map(|buffer| (buffer, false));
local.or_else(|| {
// Mappable buffers are placed in host-visible memory, which the
// allocator takes from system memory once video memory is full.
@@ -166,7 +184,7 @@ impl Arena {
(lower the render distance if frames slow down)"
);
}
Some(buffer)
Some((buffer, true))
})
}
/// The rounded length a request of `vertices` occupies.
@@ -207,16 +225,13 @@ impl Arena {
vertices: u64,
) -> Option<(u32, u64)> {
let len = Self::span(vertices).min(self.page_limit);
for (i, page) in self.pages.iter_mut().enumerate() {
if let Some(page) = page
&& let Some(start) = page.free.take(len)
{
return Some((i as u32, start));
}
if let Some(found) = self.alloc_existing(len) {
return Some(found);
}
let last = self.pages.len().saturating_sub(1);
if let Some(page) = self.pages.last().and_then(Option::as_ref)
&& page.capacity < self.page_limit
&& self.closing != Some(last as u32)
{
let current = page.capacity;
let grown = (current * 2).max(current + len).min(self.page_limit);
@@ -235,7 +250,7 @@ impl Arena {
.max(len)
.min(self.page_limit);
// A page just large enough when memory is short.
let (buffer, capacity) = match self.create(device, capacity) {
let ((buffer, host), capacity) = match self.create(device, capacity) {
Some(buffer) => (buffer, capacity),
None if capacity > len => (self.create(device, len)?, len),
None => return None,
@@ -246,6 +261,7 @@ impl Arena {
buffer,
capacity,
free,
host,
};
let index = self
.pages
@@ -258,6 +274,96 @@ impl Arena {
self.pages[index] = Some(page);
Some((index as u32, start))
}
/// A range of `vertices` in a page that exists already and is not being
/// emptied: never grows the arena.
pub fn alloc_existing(&mut self, vertices: u64) -> Option<(u32, u64)> {
let len = Self::span(vertices).min(self.page_limit);
let closing = self.closing;
self.pages
.iter_mut()
.enumerate()
.filter(|(i, _)| closing != Some(*i as u32))
.find_map(|(i, page)| Some((i as u32, page.as_mut()?.free.take(len)?)))
}
/// `alloc_existing` for a mesh moved off the closing page: only pages in
/// video memory, so compaction never pushes terrain into system memory.
pub fn alloc_moved(&mut self, vertices: u64) -> Option<(u32, u64)> {
let len = Self::span(vertices).min(self.page_limit);
let closing = self.closing;
self.pages
.iter_mut()
.enumerate()
.filter(|(i, p)| closing != Some(*i as u32) && p.as_ref().is_some_and(|p| !p.host))
.find_map(|(i, page)| Some((i as u32, page.as_mut()?.free.take(len)?)))
}
/// The page to empty when much of the arena is free ranges (video memory
/// that fragmentation keeps: a page goes back only once all of it is free):
/// the one with the fewest vertices in use that the other pages' free
/// ranges can take. `None` when the arena is dense enough or already
/// emptying a page.
pub fn sparsest(&self) -> Option<u32> {
self.sparsest_from(COMPACT_MIN_FREE)
}
/// `sparsest` for an arena with at least `min_free` bytes in free ranges.
fn sparsest_from(&self, min_free: u64) -> Option<u32> {
if self.closing.is_some() {
return None;
}
let pages = || {
self.pages
.iter()
.enumerate()
.filter_map(|(i, p)| Some((i, p.as_ref()?)))
};
let free: u64 = pages().map(|(_, p)| p.free_vertices()).sum();
let capacity: u64 = pages().map(|(_, p)| p.capacity).sum();
if free * self.stride < min_free || free * 2 < capacity {
return None;
}
// Moves only go to video-memory pages (`alloc_moved`).
let local_free: u64 = pages()
.filter(|(_, p)| !p.host)
.map(|(_, p)| p.free_vertices())
.sum();
pages()
.filter_map(|(i, p)| {
let used = p.capacity - p.free_vertices();
let own = if p.host { 0 } else { p.free_vertices() };
// The room elsewhere, less a quarter for first fit's gaps.
let elsewhere = (local_free - own) / 4 * 3;
// A page in system memory is emptied first.
(used > 0 && used <= elsewhere).then_some((!p.host, used, i as u32))
})
.min()
.map(|(_, _, i)| i)
}
/// Stops placing vertices in `page` until `reopen`.
pub fn close(&mut self, page: u32) {
self.closing = Some(page);
}
pub fn reopen(&mut self) {
self.closing = None;
}
pub fn closing(&self) -> Option<u32> {
self.closing
}
/// Records the copy of `vertices` from one range to another of other pages.
pub fn copy(
&self,
encoder: &mut wgpu::CommandEncoder,
from: (u32, u64),
to: (u32, u64),
vertices: u64,
) {
debug_assert_ne!(from.0, to.0);
encoder.copy_buffer_to_buffer(
self.buffer(from.0),
from.1 * self.stride,
self.buffer(to.0),
to.1 * self.stride,
vertices * self.stride,
);
}
pub fn release(&mut self, page: u32, start: u64, vertices: u64) {
let len = Self::span(vertices).min(self.page_limit);
self.pages[page as usize]
@@ -296,7 +402,7 @@ impl Arena {
capacity: u64,
) -> bool {
let capacity = capacity.next_multiple_of(GRAIN).min(self.page_limit);
let Some(buffer) = self.create(device, capacity) else {
let Some((buffer, host)) = self.create(device, capacity) else {
return false;
};
let old = self.pages[page].as_mut().expect("live arena page");
@@ -304,6 +410,7 @@ impl Arena {
encoder.copy_buffer_to_buffer(&old.buffer, 0, &buffer, 0, old.capacity * self.stride);
let previous = old.capacity;
old.buffer = buffer;
old.host = host;
old.capacity = capacity;
old.free.give(previous, capacity - previous);
true
@@ -318,6 +425,9 @@ impl Arena {
*page = None;
}
}
if self.closing.is_some_and(|page| !self.has_page(page)) {
self.closing = None;
}
// Keep live page indices stable; later allocations reuse interior holes.
while self.pages.last().is_some_and(Option::is_none) {
self.pages.pop();
@@ -325,6 +435,7 @@ impl Arena {
}
pub fn clear(&mut self) {
self.pages.clear();
self.closing = None;
}
}
@@ -407,6 +518,76 @@ mod tests {
assert_eq!(read(&device, &queue, arena.buffer(page), next * 4), 9);
}
#[test]
fn a_sparse_page_empties_into_the_others_and_is_reclaimed() {
let Some((device, queue)) = device() else {
eprintln!("no GPU adapter: skipped");
return;
};
let mut arena = Arena::new(&device, 4, GRAIN, false);
arena.page_limit = 4 * GRAIN;
// Eight ranges fill two pages, each holding its own number.
let ranges: Vec<(u32, u64)> = (0..8)
.map(|i| {
let range = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
queue.write_buffer(
arena.buffer(range.0),
range.1 * 4,
&(100 + i as u32).to_le_bytes(),
);
range
})
.collect();
assert_eq!(arena.pages.iter().flatten().count(), 2);
let (first, second) = (ranges[0].0, ranges[7].0);
assert_ne!(first, second);
// Fragmented: one range live in the first page, two in the second.
for (i, &(page, start)) in ranges.iter().enumerate() {
if ![0, 6, 7].contains(&i) {
arena.release(page, start, GRAIN);
}
}
// Dense enough arenas, and ones without room elsewhere, are left alone.
assert_eq!(arena.sparsest_from(u64::MAX), None);
assert_eq!(arena.sparsest_from(0), Some(first));
arena.close(first);
assert_eq!(
arena.sparsest_from(0),
None,
"one page is emptied at a time"
);
// New ranges avoid the closing page though it has room.
let (page, extra) = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
assert_eq!(page, second);
arena.release(page, extra, GRAIN);
// Its live range moves, data included, and the page goes back.
let (page, start) = ranges[0];
let to = arena.alloc_existing(GRAIN).unwrap();
assert_eq!(to.0, second);
let mut encoder = device.create_command_encoder(&Default::default());
arena.copy(&mut encoder, (page, start), to, GRAIN);
queue.submit([encoder.finish()]);
arena.release(page, start, GRAIN);
assert_eq!(read(&device, &queue, arena.buffer(to.0), to.1 * 4), 100);
// Emptied but not trimmed yet: it stays closed, so uploads can't refill it.
assert_eq!(arena.closing(), Some(page));
let refill = alloc(&mut arena, &device, &queue, GRAIN).unwrap();
assert_ne!(refill.0, page);
arena.release(refill.0, refill.1, GRAIN);
let before = arena.bytes();
arena.trim();
assert_eq!(arena.bytes(), before - 4 * GRAIN * 4);
assert_eq!(arena.closing(), None);
// The others kept their ranges.
for i in [6, 7] {
let (page, start) = ranges[i];
assert_eq!(
read(&device, &queue, arena.buffer(page), start * 4),
100 + i as u32
);
}
}
#[test]
fn empty_pages_are_reclaimed_without_moving_live_ranges() {
let Some((device, queue)) = device() else {
@@ -572,6 +753,9 @@ mod tests {
queue.write_buffer(arena.buffer(grown), next * 4, &9u32.to_le_bytes());
assert_eq!(read(&device, &queue, arena.buffer(page), start * 4), 7);
assert_eq!(read(&device, &queue, arena.buffer(grown), next * 4), 9);
// Compaction never moves terrain into a page in system memory.
assert!(arena.pages[page as usize].as_ref().unwrap().host);
assert_eq!(arena.alloc_moved(GRAIN), None);
}
#[test]
@@ -152,6 +152,10 @@ impl Tile {
None => &mut self.mesh,
}
}
/// Both meshes it holds in the arena: its own and the stand-in, if any.
pub fn meshes_mut(&mut self) -> impl Iterator<Item = &mut SectionGpu> {
std::iter::once(&mut self.mesh).chain(self.previous.as_mut().map(|(mesh, _)| mesh))
}
pub fn visible(
&self,
key: TileKey,
@@ -176,7 +176,7 @@ impl Features {
&entity_module,
entities,
&entity_buffers,
kind("text", "entity", None, offset),
kind("text", "entity", Some(wgpu::Face::Back), offset),
),
crumbling: set(
&entity_module,
@@ -13,6 +13,7 @@
use std::collections::HashMap;
use glam::{DMat4, DVec3};
use horizon_shaders::{Format, Group, Kind, Post, Val};
use super::pack_draw::DrawUniforms;
@@ -434,9 +435,33 @@ pub(super) struct Weather {
pub generation: u64,
}
/// Shadow cache: the key of the shadow map the shadow targets hold. Only the
/// shadow passes write those targets, so while the key matches they still hold
/// it and the pass is skipped.
#[derive(Default)]
struct ShadowCache {
key: Option<ShadowCacheKey>,
}
/// Key for validating the shadow cache.
/// Must match the cache state exactly to be considered valid.
#[derive(Clone, Copy, Debug, PartialEq)]
struct ShadowCacheKey {
sun_angle: f32,
moon_angle: f32,
snapped_camera: Option<DVec3>,
shadow_settings_hash: u64,
shadow_scene_revision: (u64, u64),
ticks: u64,
day_time: i64,
shadow_uniforms_hash: u64,
}
pub struct PackPost {
pub notes: Vec<String>,
passes: Vec<Pass>,
/// Shadow cache for temporal reprojection.
shadow_cache: ShadowCache,
/// What draws terrain (`gbuffers_terrain` or its fallback), between `prepare` and `deferred`.
terrain: Option<Pass>,
/// What draws translucent terrain (`gbuffers_water` or its fallback), after `deferred`.
@@ -2330,6 +2355,7 @@ impl PackPost {
u.shadow = shadow_config;
u
},
shadow_cache: Default::default(),
}
}
@@ -3322,6 +3348,45 @@ impl PackPost {
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
terrain: &mut TerrainDraw<'_>,
frame: &super::Frame,
) {
// Opt-in only (`horizon-graphics.txt` shadowCache). Off: always redraw, drop any cache.
if !frame.shadow_cache {
self.shadow_cache = ShadowCache::default();
self.draw_shadow_pass(device, queue, encoder, terrain);
return;
}
// Compute the current frame's cache key before borrowing shadow_targets mutably.
let cache_key = self.compute_shadow_cache_key(frame);
// Entity vertices are interpolated each frame, so their shadows cannot be reused.
let has_shadow_entities = self
.entities
.as_ref()
.is_some_and(|entities| entities.geometry.draws.iter().any(|draw| draw.shadow));
// Custom images are cleared each frame (`pack_images::clear`) before the
// shadow pass writes them, so a pack with any redraws it too.
let cache_hit = !has_shadow_entities
&& self.images.is_empty()
&& self.shadow_cache.key == Some(cache_key)
&& self.shadow_targets.is_some();
if cache_hit {
// The targets still hold this map (and `main`, the A/B side
// `shadowcomp` left current).
return;
}
self.draw_shadow_pass(device, queue, encoder, terrain);
self.shadow_cache.key = self.shadow_targets.is_some().then_some(cache_key);
}
/// The shadow pass body: terrain, entities, clears, and `shadowcomp`.
fn draw_shadow_pass(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
encoder: &mut wgpu::CommandEncoder,
terrain: &mut TerrainDraw<'_>,
) {
let Some(t) = &mut self.shadow_targets else {
return;
@@ -3397,6 +3462,106 @@ impl PackPost {
self.shadow_comp = comp;
}
/// Computes the cache key for the current frame.
fn compute_shadow_cache_key(&self, frame: &super::Frame) -> ShadowCacheKey {
let shadow = &self.uniforms.shadow;
// Use the same logic as shadow_camera to compute the light direction and snapped camera.
// Use the public shadow_light function which returns the normalized light direction.
let light = super::pack_uniforms::shadow_light(frame, shadow);
let light = light.as_dvec3();
let up = if light.z.abs() > 0.99 {
DVec3::Y
} else {
DVec3::Z
};
let rotation = DMat4::look_to_rh(DVec3::ZERO, -light, up);
let at = rotation.transform_vector3(frame.camera);
let step = f64::from(shadow.interval);
let snapped_camera = if step > 0.0 {
Some((at / step).floor() * step)
} else {
Some(at)
};
// Hash the shadow settings that affect the shadow map.
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
shadow.resolution.hash(&mut hasher);
shadow.distance.to_bits().hash(&mut hasher);
shadow.render_mul.to_bits().hash(&mut hasher);
shadow.interval.to_bits().hash(&mut hasher);
shadow.sun_path_rotation.to_bits().hash(&mut hasher);
shadow.fov.map(|f| f.to_bits()).hash(&mut hasher);
shadow.near.to_bits().hash(&mut hasher);
shadow.far.to_bits().hash(&mut hasher);
for hf in &shadow.hardware_filtering {
hf.hash(&mut hasher);
}
for color in &shadow.colors {
format!("{:?}", color.format).hash(&mut hasher);
color.clear.hash(&mut hasher);
if let Some(cc) = color.clear_color {
for c in cc {
c.to_bits().hash(&mut hasher);
}
}
color.mipmap.hash(&mut hasher);
}
let shadow_settings_hash = hasher.finish();
let shadow_uniforms_hash = self.shadow_uniforms_hash();
ShadowCacheKey {
sun_angle: frame.sky.sun_angle,
moon_angle: frame.sky.moon_angle,
snapped_camera,
shadow_settings_hash,
shadow_scene_revision: frame.shadow_scene_revision,
ticks: frame.ticks,
day_time: frame.pack.day_time,
shadow_uniforms_hash,
}
}
fn shadow_uniforms_hash(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut hasher = std::collections::hash_map::DefaultHasher::new();
let passes = self
.shadow_terrain
.iter()
.chain(self.shadow_water.iter())
.chain(self.shadow_comp.iter())
.chain(
self.entities
.iter()
.flat_map(|entities| entities.shadow.iter().flatten()),
);
for pass in passes {
pass.name.hash(&mut hasher);
for (index, ty, offset) in &pass.uniforms {
index.hash(&mut hasher);
ty.hash(&mut hasher);
offset.hash(&mut hasher);
let Some(value) = index.and_then(|index| self.uniforms.values.get(index)) else {
continue;
};
match value {
pack_uniforms::Value::F(values) => {
0u8.hash(&mut hasher);
for value in values {
value.to_bits().hash(&mut hasher);
}
}
pack_uniforms::Value::I(values) => {
1u8.hash(&mut hasher);
values.hash(&mut hasher);
}
}
}
}
hasher.finish()
}
/// Runs the pack over the world drawn into `world` (colour) and `world_depth`, then draws
/// the result onto `screen`.
#[allow(clippy::too_many_arguments)]
@@ -3464,7 +3629,7 @@ impl PackPost {
self.gpu.clear,
&mut self.zeros,
);
self.run_shadow(device, queue, encoder, terrain);
self.run_shadow(device, queue, encoder, terrain, frame);
let Some(targets) = &self.targets else { return };
let main = |m: u16, i: usize| usize::from(m & 1 << i != 0);
let view = |m: u16, i: usize, alt: bool| {
@@ -3720,6 +3885,36 @@ mod tests {
use super::*;
use wgpu::TextureFormat as T;
#[test]
fn shadow_cache_key_tracks_world_and_time_changes() {
let key = ShadowCacheKey {
sun_angle: 0.0,
moon_angle: 180.0,
snapped_camera: Some(DVec3::ZERO),
shadow_settings_hash: 1,
shadow_scene_revision: (2, 3),
ticks: 4,
day_time: 5,
shadow_uniforms_hash: 6,
};
assert_ne!(
key,
ShadowCacheKey {
shadow_scene_revision: (8, 3),
..key
}
);
assert_ne!(key, ShadowCacheKey { day_time: 9, ..key });
assert_ne!(
key,
ShadowCacheKey {
shadow_uniforms_hash: 10,
..key
}
);
}
/// Two textures with a sampler each become two textures sharing one sampler variable.
#[test]
fn samplers_of_a_kind_share_one_variable() {
@@ -61,6 +61,9 @@ impl<T> Resident<T> {
pub fn values(&self) -> impl Iterator<Item = &T> {
self.items.iter().map(|(_, v)| v)
}
pub fn values_mut(&mut self) -> impl Iterator<Item = &mut T> {
self.items.iter_mut().map(|(_, v)| v)
}
/// `key`'s value, when it is resident.
pub fn find(&self, key: SectionKey) -> Option<&T> {
self.slots.get(&key).map(|&slot| self.get(slot))
@@ -53,13 +53,7 @@ impl Weather {
topology: wgpu::PrimitiveTopology::TriangleList,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(true),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}),
depth_stencil: Some(depth_stencil()),
multisample: Default::default(),
fragment: Some(wgpu::FragmentState {
module: shader,
@@ -157,11 +151,33 @@ impl Weather {
}
}
/// RenderPipelines.WEATHER: depth-tested, but no depth write, so the water
/// and glass drawn behind the rain still show through it.
fn depth_stencil() -> wgpu::DepthStencilState {
wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: Some(false),
depth_compare: Some(wgpu::CompareFunction::GreaterEqual),
stencil: Default::default(),
bias: Default::default(),
}
}
#[cfg(test)]
mod tests {
use crate::weather::{Parameters, Time};
use wgpu::util::DeviceExt;
#[test]
fn weather_writes_no_depth() {
let depth = super::depth_stencil();
assert_eq!(depth.depth_write_enabled, Some(false));
assert_eq!(
depth.depth_compare,
Some(wgpu::CompareFunction::GreaterEqual)
);
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct Case {
+34 -1
View File
@@ -313,6 +313,8 @@ pub fn reduced_far_detail_suggested() -> bool {
/// The name the options screen waits on to bind `Graphics::hdr_key`.
pub const HDR_KEY: &str = "horizon.hdrSettings";
/// The Screenshot Quality choices, times the game's resolution.
pub const SCREENSHOT_QUALITIES: [u32; 4] = [1, 2, 4, 8];
static HDR_OFFERED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
/// Whether the display path offers HDR output (HDR10 or scRGB): set when the renderer
/// opens its surface; the settings open HDR's screen only then.
@@ -379,6 +381,8 @@ pub struct Graphics {
pub taa: bool,
/// TAA upscaling; anything but `Native` comes with `taa` on.
pub upscaling: Upscaling,
/// F2 screenshots at this many times the game's resolution: 1, 2, 4 or 8.
pub screenshot_quality: u32,
/// HDR output (HDR10, or scRGB), where the display path offers it.
pub hdr: bool,
/// HDR paper white (where SDR white sits), in nits; 0 is Auto: the desktop's SDR
@@ -399,6 +403,10 @@ pub struct Graphics {
/// or behind other windows, and half the worker threads. Off by default;
/// it changes when frames are drawn, never what they show or the ticks.
pub battery: bool,
/// Reuse the shader pack's last shadow map when sun, view, scene revision and
/// pack uniforms still match. Off by default: vanilla loaders redraw every frame
/// (or every `shadowInterval`), and a hit with a stale key shows old shadows.
pub shadow_cache: bool,
/// The graphics API (Windows only; see `GraphicsApi`).
pub api: GraphicsApi,
/// The shader pack picked in `shaderpacks/` (`shader_pack_list`); empty: none. Its key is
@@ -428,6 +436,7 @@ impl Default for Graphics {
fxaa: false,
taa: false,
upscaling: Upscaling::Native,
screenshot_quality: 1,
hdr: false,
hdr_paper_white: 0,
hdr_peak: 0,
@@ -435,6 +444,7 @@ impl Default for Graphics {
hdr_ui_brightness: 100,
hdr_key: "key.keyboard.unknown".into(),
battery: false,
shadow_cache: false,
api: GraphicsApi::Auto,
shader_pack: String::new(),
}
@@ -497,6 +507,11 @@ impl Graphics {
"fxaa" => g.fxaa = flag(g.fxaa),
"taa" => g.taa = flag(g.taa),
"upscaling" => g.upscaling = Upscaling::parse(value),
"screenshotQuality" => {
g.screenshot_quality = Some(number(0))
.filter(|q| SCREENSHOT_QUALITIES.contains(q))
.unwrap_or(1)
}
"hdr" => g.hdr = flag(g.hdr),
"hdrPaperWhite" => {
g.hdr_paper_white = match number(g.hdr_paper_white) {
@@ -516,6 +531,7 @@ impl Graphics {
}
"hdrKey" if value.starts_with("key.") => g.hdr_key = value.to_owned(),
"batteryMode" => g.battery = flag(g.battery),
"shadowCache" => g.shadow_cache = flag(g.shadow_cache),
"graphicsApi" => g.api = GraphicsApi::from_name(value).unwrap_or_default(),
"pickedShaderPack" => g.shader_pack = value.to_owned(),
"overlay" => {
@@ -532,7 +548,7 @@ impl Graphics {
}
pub fn serialize(&self) -> String {
format!(
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\npresentMode:{}\nframePacing:{}\nworkers:{}\noverlay:{}\nresolutionScale:{}\nfxaa:{}\ntaa:{}\nupscaling:{}\nhdr:{}\nhdrPaperWhite:{}\nhdrPeak:{}\nhdrBlackLevel:{}\nhdrUiBrightness:{}\nhdrKey:{}\nbatteryMode:{}\ngraphicsApi:{}\npickedShaderPack:{}\n",
"preset:{}\nlodDistance:{}\nfarDetail:{}\ncaveCulling:{}\nentityCulling:{}\nchunkUpdates:{}\nuploadBudgetKiB:{}\nsky:{}\nfog:{}\nweather:{}\nanimatedTextures:{}\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(),
@@ -559,6 +575,7 @@ impl Graphics {
bool_text(self.fxaa),
bool_text(self.taa),
self.upscaling.name(),
self.screenshot_quality,
bool_text(self.hdr),
self.hdr_paper_white,
self.hdr_peak,
@@ -566,6 +583,7 @@ impl Graphics {
self.hdr_ui_brightness,
self.hdr_key,
bool_text(self.battery),
bool_text(self.shadow_cache),
self.api.name(),
self.shader_pack,
)
@@ -703,6 +721,7 @@ mod tests {
fxaa: true,
taa: true,
upscaling: Upscaling::Balanced,
screenshot_quality: 4,
hdr: true,
hdr_paper_white: 240,
hdr_peak: 1400,
@@ -710,6 +729,7 @@ mod tests {
hdr_ui_brightness: 60,
hdr_key: "key.keyboard.h".into(),
battery: true,
shadow_cache: true,
api: GraphicsApi::Dx12,
shader_pack: "Golden Hour".into(),
};
@@ -720,6 +740,19 @@ mod tests {
assert_eq!(Graphics::parse("hdrPeak:50\n").hdr_peak, 200);
assert_eq!(Graphics::parse("hdrPaperWhite:0\n").hdr_paper_white, 0);
assert_eq!(Graphics::parse("hdrPaperWhite:5\n").hdr_paper_white, 10);
// Only the button's choices.
assert_eq!(
Graphics::parse("screenshotQuality:8\n").screenshot_quality,
8
);
assert_eq!(
Graphics::parse("screenshotQuality:3\n").screenshot_quality,
1
);
assert_eq!(
Graphics::parse("screenshotQuality:16\n").screenshot_quality,
1
);
// The old menus' Simple / Advanced choice is ignored, and so is the
// shader pack older versions saved.
assert_eq!(Graphics::parse("level:advanced\n"), Graphics::default());
@@ -40,6 +40,7 @@ pub enum H {
ResolutionScale,
Antialiasing,
Upscaling,
ScreenshotQuality,
Hdr,
/// Opens `Level::Hdr`.
HdrSettings,
@@ -52,6 +53,7 @@ pub enum H {
HdrPaperWhite,
HdrUiBrightness,
BatteryMode,
ShadowCache,
GraphicsApi,
}
@@ -278,6 +280,11 @@ pub fn rows(level: Level) -> Vec<Row> {
High,
"Renders the world smaller and rebuilds it with TAA. Faster, softer.",
),
h(
H::ScreenshotQuality,
None,
"F2 screenshots at this many times the game's resolution. Higher takes longer.",
),
h(
H::HdrSettings,
None,
@@ -313,6 +320,11 @@ pub fn rows(level: Level) -> Vec<Row> {
None,
"60 FPS cap (30 in menus or unfocused), half the workers: less power.",
),
h(
H::ShadowCache,
Saves,
"Reuses the pack shadow map when sun, view and world match. Off by default.",
),
h(
H::Workers,
None,
@@ -538,6 +550,9 @@ impl H {
},
),
H::Upscaling => labelled("Upscaling", g.upscaling.label()),
H::ScreenshotQuality => {
labelled("Screenshot Quality", format!("{}x", g.screenshot_quality))
}
H::Hdr => labelled("HDR", on_off(g.hdr)),
H::HdrSettings => Text::literal(lang::get("HDR Settings...")),
H::HdrCalibrate => Text::literal(lang::get(match setup(s) {
@@ -571,6 +586,7 @@ impl H {
Text::literal("Shortcut: ").append(crate::keybinds::display_name(&g.hdr_key))
}
H::BatteryMode => labelled("Battery Mode", on_off(g.battery)),
H::ShadowCache => labelled("Shadow Cache", on_off(g.shadow_cache)),
H::GraphicsApi => labelled("Graphics API", g.api.label()),
H::Overlay => labelled(
"Performance Overlay",
@@ -647,8 +663,16 @@ impl H {
(g.fxaa, g.taa) = (false, true);
}
}
H::ScreenshotQuality => {
g.screenshot_quality = step(
&crate::graphics::SCREENSHOT_QUALITIES,
g.screenshot_quality,
back,
)
}
H::Hdr => g.hdr = !g.hdr,
H::BatteryMode => g.battery = !g.battery,
H::ShadowCache => g.shadow_cache = !g.shadow_cache,
H::GraphicsApi => g.api = step(GraphicsApi::choices(), g.api, back),
H::Pacing => {
g.pacing = match g.pacing {
@@ -711,6 +735,7 @@ impl Item {
Item::Horizon(H::AnimatedTextures) => g.animated_textures,
Item::Horizon(H::Hdr) => g.hdr,
Item::Horizon(H::BatteryMode) => g.battery,
Item::Horizon(H::ShadowCache) => g.shadow_cache,
Item::Vanilla(Opt::ViewBobbing) => s.view_bobbing,
Item::Vanilla(Opt::Vsync) => s.vsync,
Item::Vanilla(Opt::Fullscreen) => s.fullscreen,
@@ -766,10 +791,12 @@ impl Item {
Item::Horizon(
H::Preset
| H::Overlay
| H::ScreenshotQuality
| H::Pacing
| H::PresentMode
| H::Workers
| H::BatteryMode
| H::ShadowCache
| H::GraphicsApi
) | Item::Vanilla(
Opt::GuiScale
@@ -1733,6 +1760,21 @@ mod tests {
assert_eq!(s.horizon.preset, Preset::Ultra);
}
#[test]
fn screenshot_quality_cycles_1_2_4_8_and_keeps_the_preset() {
let mut s = Settings::default();
Item::Horizon(H::Preset).cycle(&mut s, false, [854, 480]);
let mut seen = Vec::new();
for _ in 0..4 {
Item::Horizon(H::ScreenshotQuality).cycle(&mut s, false, [854, 480]);
seen.push(s.horizon.screenshot_quality);
}
assert_eq!(seen, [2, 4, 8, 1]);
Item::Horizon(H::ScreenshotQuality).cycle(&mut s, true, [854, 480]);
assert_eq!(s.horizon.screenshot_quality, 8);
assert_eq!(s.horizon.preset, Preset::Performance);
}
#[test]
fn upload_budget_slider_steps() {
let mut s = Settings::default();
+23 -9
View File
@@ -528,14 +528,16 @@ impl HudAtlas {
for folder in folders {
walk(&textures.join(folder), &mut files);
}
// Content mods' equipment textures, which their equipment assets name as
// `<ns>:<path>`, and their mob effects' icons, `<ns>:mob_effect/<path>`.
// Content mods' entity textures: their equipment assets name them as
// `<ns>:<path>`, and native mods draw their block entities with them
// (`native_draw_quad`: `<ns>:entity/...`); and their mob effects'
// icons, `<ns>:mob_effect/<path>`.
let namespaces = horizon_items::content_namespaces::namespaces(root);
for (folder, textures) in [("entity", "entity/equipment"), ("mob_effect", "mob_effect")] {
for folder in ["entity", "mob_effect"] {
if folders.contains(&folder) {
for namespace in &namespaces {
walk(
&root.join(format!("assets/{namespace}/textures/{textures}")),
&root.join(format!("assets/{namespace}/textures/{folder}")),
&mut files,
);
}
@@ -1604,6 +1606,9 @@ pub struct HudRenderer {
atlas_view: wgpu::TextureView,
/// The resource pack's text shader, when it has one that works.
pack_text: Option<pack_text::PackText>,
/// Target pixels per window pixel: above 1 for a screenshot drawn larger than the
/// window (`Renderer::screenshot_quality`).
pub zoom: f32,
}
impl HudRenderer {
pub fn new(
@@ -1833,6 +1838,7 @@ impl HudRenderer {
format,
atlas_view: view.clone(),
pack_text: None,
zoom: 1.0,
})
}
/// Draw the GUI's text with `shader` (`HudAtlas::text_shader`), or natively.
@@ -1968,14 +1974,14 @@ impl HudRenderer {
[
size[0] as f32,
size[1] as f32,
batch.scale,
batch.scale * self.zoom,
batch.glint_alpha,
],
batch.end_portal,
)),
);
if let Some(pack_text) = &self.pack_text {
pack_text.prepare(uploads, encoder, batch, size);
pack_text.prepare(uploads, encoder, batch, size, batch.scale * self.zoom);
}
}
/// Draw the batch `prepare` staged.
@@ -2048,7 +2054,8 @@ impl HudRenderer {
});
pass.set_bind_group(0, &self.group, &[]);
pass.set_vertex_buffer(0, self.buffer.slice(..));
if let Some([x, y, w, h]) = clip {
if let Some(clip) = clip {
let [x, y, w, h] = clip.map(|v| (v as f32 * self.zoom) as u32);
let (x, y) = (x.min(size[0]), y.min(size[1]));
let (w, h) = (w.min(size[0] - x), h.min(size[1] - y));
if w == 0 || h == 0 {
@@ -2186,7 +2193,7 @@ mod tests {
atlas.pixels[at..at + 4].try_into().unwrap()
}
#[test]
fn content_mod_equipment_textures_pack_under_their_namespace() {
fn content_mod_entity_textures_pack_under_their_namespace() {
let root = std::env::temp_dir().join(format!("horizon-atlas-equip-{}", std::process::id()));
let _ = fs::remove_dir_all(&root);
let equipment = root.join("assets/gems/textures/entity/equipment/humanoid");
@@ -2206,10 +2213,17 @@ mod tests {
br#"["gems"]"#,
)
.unwrap();
// A block entity's texture, for a native mod's renderer.
let machine = root.join("assets/gems/textures/entity/machine");
fs::create_dir_all(&machine).unwrap();
write_strip(&machine.join("cog.png"), 32, 32, &[[0, 0, 200, 255]], None);
let atlas = HudAtlas::build(&root).unwrap();
let rect = atlas.sprites["gems:entity/equipment/humanoid/ruby"];
assert_eq!(texel_at(&atlas, rect, 0, 0), [200, 0, 0, 255]);
// Only equipment: the mods' other textures reach the atlas through atlas definitions.
let rect = atlas.sprites["gems:entity/machine/cog"];
assert_eq!(rect.texels((atlas.width, atlas.height)), (32, 32));
assert_eq!(texel_at(&atlas, rect, 0, 0), [0, 0, 200, 255]);
// Only entity textures: the mods' others reach the atlas through atlas definitions.
assert!(!atlas.sprites.contains_key("gems:item/ruby"));
fs::remove_dir_all(&root).unwrap();
}
@@ -216,8 +216,9 @@ impl PackText {
encoder: &mut wgpu::CommandEncoder,
batch: &HudBatch,
size: [u32; 2],
scale: f32,
) {
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size));
uploads.buffer(encoder, &self.uniform, 0, &uniforms(batch, size, scale));
}
/// Draw `range` (whole glyph quads) with the pack's shader; the caller sets its own
@@ -233,7 +234,8 @@ impl PackText {
/// `GlintAlpha`, `GameTime`, `ScreenSize`), `Projection` (`GuiRenderer`'s orthographic GUI
/// projection, 1000 to 11000 deep, in GL's depth range), `DynamicTransforms` (the GUI's model view
/// moving 11000 back, colour modulator white) and an empty `Fog`.
fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
/// `scale`: target pixels per GUI pixel (`batch.scale`, times `HudRenderer::zoom`).
fn uniforms(batch: &HudBatch, size: [u32; 2], scale: f32) -> Vec<u8> {
let mut data = vec![0f32; 256];
let globals = 0;
data[globals + 3] = batch.glint_alpha;
@@ -241,7 +243,7 @@ fn uniforms(batch: &HudBatch, size: [u32; 2]) -> Vec<u8> {
data[globals + 8] = size[0] as f32;
data[globals + 9] = size[1] as f32;
// Matrix4f.setOrtho(0, width, height, 0, near, far) with the GUI's scale, columns.
let (w, h) = (size[0] as f32 / batch.scale, size[1] as f32 / batch.scale);
let (w, h) = (size[0] as f32 / scale, size[1] as f32 / scale);
let (near, far) = (1000.0f32, 11000.0f32);
let projection = 64;
data[projection] = 2.0 / w;
@@ -287,7 +289,8 @@ mod tests {
fn the_gui_projection_maps_gui_pixels_to_the_screen() {
let mut batch = HudBatch::new([1920, 1080]);
batch.scale = 4.0;
let data: Vec<f32> = bytemuck::cast_slice(&uniforms(&batch, [1920, 1080])).to_vec();
let data: Vec<f32> =
bytemuck::cast_slice(&uniforms(&batch, [1920, 1080], batch.scale)).to_vec();
let column = |matrix: usize, c: usize| &data[matrix + 4 * c..matrix + 4 * c + 4];
let apply = |matrix: usize, v: [f32; 4]| -> [f32; 4] {
std::array::from_fn(|r| (0..4).map(|c| column(matrix, c)[r] * v[c]).sum())
@@ -385,9 +385,40 @@ pub fn draw(
}
}
/// `LoadingOverlay.MOJANG_STUDIOS_LOGO_LOCATION`, from the player's own resources.
pub const MOJANG_STUDIOS_LOGO: &str = "gui/title/mojangstudios";
/// `LoadingOverlay.extractRenderState`'s logo on a GUI `w` x `h`: the left
/// half's corner, then each half's width and height.
pub fn logo_layout(w: f32, h: f32) -> (i32, i32, i32, i32) {
let (w, h) = (f64::from(w), f64::from(h));
let (content_x, logo_y) = ((w * 0.5) as i32, (h * 0.5) as i32);
let logo_height = (w * 0.75).min(h) * 0.25;
let half_height = (logo_height * 0.5) as i32;
let half_width = (logo_height * 4.0 * 0.5) as i32;
(
content_x - half_width,
logo_y - half_height,
half_width,
logo_height as i32,
)
}
/// `GuiGraphicsExtractor.fill(x0, y0, x1, y1, color)`: the corners in either order.
fn fill_corners(batch: &mut HudBatch, atlas: &HudAtlas, [x0, y0, x1, y1]: [i32; 4], color: u32) {
let (left, right) = (x0.min(x1), x0.max(x1));
let (top, bottom) = (y0.min(y1), y0.max(y1));
let (w, h) = ((right - left) as f32, (bottom - top) as f32);
batch.fill(atlas, left as f32, top as f32, w, h, color);
}
/// `LoadingOverlay.extractRenderState`: fills the frame with `BRAND_BACKGROUND`
/// (Mojang red, or black when `darkMojangStudiosBackground` is set) and draws the
/// progress line and bar.
/// (Mojang red, or black when `darkMojangStudiosBackground` is set), draws the
/// Mojang Studios logo as its two halves side by side, and the progress bar
/// at 83.25% of the height (`extractProgressBar`, unfaded), with the stage's
/// line above it. The logo's `MOJANG_LOGO` pipeline adds it (`SRC_ALPHA, ONE`):
/// its texels are white, which alpha blending matches but for the red channel
/// of the logo's soft edges, a 16/255 difference at most.
pub fn draw_loading_overlay(
batch: &mut HudBatch,
atlas: &HudAtlas,
@@ -395,15 +426,58 @@ pub fn draw_loading_overlay(
progress: f32,
dark: bool,
) {
batch.fill(
let (w, h) = (batch.width, batch.height);
batch.fill(atlas, 0.0, 0.0, w, h, brand_background(dark));
let (x, y, half_width, logo_height) = logo_layout(w, h);
if let Some(r) = atlas.sprites.get(MOJANG_STUDIOS_LOGO).copied() {
// blit(.., u, v, width, height, 120, 60, 120, 120): the left half
// samples the texture's top half from u = -0.0625, the right half its
// bottom half from u = 0.0625 (clamped, as the logo's sampler is).
let at = |u: f32, v: f32| {
let (u, v) = ((u / 120.0).clamp(0.0, 1.0), v / 120.0);
(r.u0 + (r.u1 - r.u0) * u, r.v0 + (r.v1 - r.v0) * v)
};
for (i, (u, v)) in [(-0.0625, 0.0), (0.0625, 60.0)].into_iter().enumerate() {
let (u0, v0) = at(u, v);
let (u1, v1) = at(u + 120.0, v + 60.0);
let half = crate::hud::Rect {
u0,
v0,
u1,
v1,
..r
};
let left = x + i as i32 * half_width;
let (lw, lh) = (half_width as f32, logo_height as f32);
batch.rect_uv(left as f32, y as f32, lw, lh, half, u32::MAX);
}
}
let bar_y = (f64::from(h) * 0.8325) as i32;
let x_center = (w as i32) / 2;
let [x0, y0, x1, y1] = [
x_center - half_width,
bar_y - 5,
x_center + half_width,
bar_y + 5,
];
let filled = (((x1 - x0) - 2) as f32 * progress.clamp(0.0, 1.0)).ceil() as i32;
let white = u32::MAX;
fill_corners(batch, atlas, [x0 + 2, y0 + 2, x0 + filled, y1 - 2], white);
fill_corners(batch, atlas, [x0 + 1, y0, x1 - 1, y0 + 1], white);
fill_corners(batch, atlas, [x0 + 1, y1, x1 - 1, y1 - 1], white);
fill_corners(batch, atlas, [x0, y0, x0 + 1, y1], white);
fill_corners(batch, atlas, [x1, y0, x1 - 1, y1], white);
let text = Text::literal(line);
let tw = atlas.font.width_text(&text) as i32;
let text_top = (y0 - 9 - 3) as f32;
batch.text_styled(
atlas,
0.0,
0.0,
batch.width,
batch.height,
brand_background(dark),
&text,
(x_center - tw / 2) as f32,
text_top,
white,
true,
);
draw_progress(batch, atlas, line, progress);
}
/// The startup loading screen: the stage's line and a progress bar, centred
@@ -572,6 +646,14 @@ pub fn draw_file_fixer(
mod tests {
use super::*;
#[test]
fn the_mojang_logo_is_a_quarter_of_the_shorter_side_and_four_times_as_wide() {
// 320x240: min(240, 240) / 4 = 60 high, two halves of 120.
assert_eq!(logo_layout(320.0, 240.0), (40, 90, 120, 60));
// A tall frame sizes the logo by three quarters of its width.
assert_eq!(logo_layout(400.0, 500.0), (50, 213, 150, 75));
}
#[test]
fn progress_weighs_the_prepared_server_and_the_player_chunks() {
assert_eq!(progress(0), 10.0 / 59.0);
@@ -13,9 +13,10 @@
//!
//! The buckets lie one after another in the mesh, in an order chosen so that
//! the buckets an eye can see are usually one or two runs of quads: one draw
//! each. Inside a bucket quads keep their model order, so coplanar quads
//! facing one way (an overlay on its base) still draw in that order.
use super::TerrainVertex;
//! each. Coplanar quads facing one way keep their model order, so an overlay
//! on its base still draws in that order. Solid cutout quads are otherwise
//! ordered from the outside toward the section interior to reduce overdraw.
use super::{OPAQUE_LAYER, TerrainVertex};
/// The buckets in mesh order: +X, +Z, other, +Y, -Z, -X, -Y. Seen from
/// above (+Y, the commonest case), each pair of horizontal sides with a
@@ -138,11 +139,14 @@ fn bucket(quad: &[TerrainVertex; 4]) -> usize {
OTHER
}
/// Reorders `solid`'s quads by bucket, keeping their order inside each.
/// Reorders `solid`'s quads by bucket. Solid cutout quads are also ordered
/// front to back within each axis-facing bucket; stable sorting preserves the
/// mesher's order for coplanar overlays.
pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
let (quads, rest) = solid.as_chunks::<4>();
debug_assert!(rest.is_empty(), "solid vertices come in quads");
let buckets: Vec<u8> = quads.iter().map(|q| bucket(q) as u8).collect();
let has_cutout = quads.iter().any(|q| q[0].layer & OPAQUE_LAYER == 0);
let mut counts = [0u32; 7];
let mut planes = [0f32; 7];
for (quad, &b) in quads.iter().zip(&buckets) {
@@ -163,7 +167,7 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
total += count;
*end = total;
}
if counts.iter().filter(|&&c| c > 0).count() > 1 {
if has_cutout || counts.iter().filter(|&&c| c > 0).count() > 1 {
let mut next: [u32; 7] = std::array::from_fn(|b| ends[b] - counts[b]);
let mut sorted = vec![quads[0]; quads.len()];
for (quad, &b) in quads.iter().zip(&buckets) {
@@ -171,6 +175,25 @@ pub fn sort(solid: &mut Vec<TerrainVertex>) -> Sides {
sorted[*slot as usize] = *quad;
*slot += 1;
}
if has_cutout {
let mut start = 0;
for (i, bucket) in BUCKETS.iter().enumerate() {
let end = ends[i] as usize;
let slice = &mut sorted[start..end];
if let Bucket::Side(axis, positive) = *bucket {
slice.sort_by(|a, b| {
let a = a[0].pos[axis];
let b = b[0].pos[axis];
if positive {
b.total_cmp(&a)
} else {
a.total_cmp(&b)
}
});
}
start = end;
}
}
*solid = sorted.into_flattened();
}
Sides { ends, planes }
@@ -368,4 +391,46 @@ mod tests {
before.iter().map(|v| v.layer).collect::<Vec<_>>()
);
}
#[test]
fn front_to_back_sort_orders_positive_faces_and_keeps_ties_stable() {
let mut solid: Vec<_> = [(1.0, 10), (3.0, 30), (3.0, 31)]
.into_iter()
.flat_map(|(x, layer)| {
quad(
[[x, 0.0, 0.0], [x, 1.0, 0.0], [x, 1.0, 1.0], [x, 0.0, 1.0]],
layer,
)
})
.collect();
sort(&mut solid);
let layers: Vec<u16> = solid
.as_chunks::<4>()
.0
.iter()
.map(|q| q[0].layer)
.collect();
assert_eq!(layers, [30, 31, 10]);
}
#[test]
fn front_to_back_sort_orders_negative_faces_toward_the_eye() {
let mut solid: Vec<_> = [(3.0, 30), (1.0, 10), (2.0, 20)]
.into_iter()
.flat_map(|(x, layer)| {
quad(
[[x, 0.0, 0.0], [x, 0.0, 1.0], [x, 1.0, 1.0], [x, 1.0, 0.0]],
layer,
)
})
.collect();
sort(&mut solid);
let layers: Vec<u16> = solid
.as_chunks::<4>()
.0
.iter()
.map(|q| q[0].layer)
.collect();
assert_eq!(layers, [10, 20, 30]);
}
}
+154 -7
View File
@@ -1236,6 +1236,8 @@ 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 picked `shaders/` pack's options (`shader_options`), one of its screens at a time.
ShaderOptions,
/// Horizon's graphics settings (`graphics_ui`).
@@ -1282,6 +1284,12 @@ enum Act {
OpenShaderPackFolder,
/// Open the picked pack's Shader Options page.
ShaderOptions,
/// Open the options page of the Horizon pack `s.shader_packs[.0]`.
NativePackOptions(usize),
/// Option `.1` of that pack: its next value.
NativePackOption(usize, usize),
/// Every option of that pack back to its default.
NativePackReset(usize),
/// Shader Options: the next value of an option, a screen of the pack, the next profile,
/// every option back to its default.
ShaderOption(String),
@@ -1411,6 +1419,9 @@ pub enum Event {
OpenShaderOptions,
/// The Shader Options page closed with changes: save them and load the pack again.
ShaderOptions,
/// The options of `shader_packs[.0]`, a Horizon pack, changed: save them (the pack reloads
/// with them).
NativePackOptions(usize),
/// Native mod `.0`'s page opened: the game asks the mod for its labels
/// again (they were made with the list, maybe in another language).
NativeSettings(usize),
@@ -1627,6 +1638,27 @@ fn horizon_page_tooltip(page: Page) -> Option<&'static str> {
}
/// 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> {
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 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()));
}
rows
}
fn shader_option_rows(s: &Settings) -> Vec<Row> {
let Some(options) = &s.shader_options else {
return vec![Row::Header("horizon.shader_options.loading")];
@@ -1712,6 +1744,14 @@ 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 WKind::Button { act, .. } = &wd.kind else {
let WKind::Slider { opt, .. } = &wd.kind else {
return None;
@@ -2073,7 +2113,7 @@ impl OptionsUi {
Page::Mods => "horizon.pages.mods",
Page::NativeMod(_) => "Mod Settings",
Page::ShaderPacks => "horizon.pages.shader_packs",
Page::ShaderOptions => "horizon.pages.shader_options",
Page::ShaderOptions | Page::NativePackOptions(_) => "horizon.pages.shader_options",
Page::Graphics | Page::GraphicsAdvanced | Page::GraphicsHdr | Page::HdrCalibration => {
"options.videoTitle"
}
@@ -2502,13 +2542,19 @@ impl OptionsUi {
if s.shader_packs.is_empty() {
return vec![Row::Header("horizon.shader_packs.empty")];
}
let options = s.shader_packs.iter().any(|p| {
p.file == s.horizon.shader_pack
&& p.kind == crate::shader_pack_list::Kind::Classic
});
let options = options.then(|| {
let picked = s
.shader_packs
.iter()
.position(|p| p.file == s.horizon.shader_pack);
let options = picked.and_then(|i| {
let pack = &s.shader_packs[i];
let act = match pack.kind {
crate::shader_pack_list::Kind::Classic => Act::ShaderOptions,
_ if pack.options.is_empty() => return None,
crate::shader_pack_list::Kind::Horizon => Act::NativePackOptions(i),
};
let label = tr("horizon.shader_options.open");
Row::Buttons(button(0.0, 0.0, 310.0, label, Act::ShaderOptions), None)
Some(Row::Buttons(button(0.0, 0.0, 310.0, label, act), None))
});
options
.into_iter()
@@ -2526,6 +2572,7 @@ impl OptionsUi {
.collect()
}
Page::ShaderOptions => shader_option_rows(s),
Page::NativePackOptions(i) => native_pack_option_rows(s, i),
_ => {
let _ = s;
Vec::new()
@@ -3062,6 +3109,7 @@ impl OptionsUi {
Page::Mods => Some(("horizon.mods.open_folder", Act::OpenModFolder)),
Page::ShaderPacks => Some(("pack.openFolder", Act::OpenShaderPackFolder)),
Page::ShaderOptions => Some(("controls.reset", Act::ShaderReset)),
Page::NativePackOptions(i) => Some(("controls.reset", Act::NativePackReset(i))),
_ => None,
};
if let Some((label, act)) = folder {
@@ -3722,6 +3770,25 @@ impl OptionsUi {
options.cycle(&name, true);
events.push(Event::ButtonClick);
}
let native = widgets.iter().find_map(|wd| match wd.kind {
WKind::Button {
active: true,
act: Act::NativePackOption(pack, option),
..
} if ui::hovered((x, y), wd.x, wd.y, wd.w, wd.h)
&& !(wd.list && (y < top || y >= bottom)) =>
{
Some((pack, option))
}
_ => None,
});
if let Some((pack, option)) = native
&& let Some(entry) = s.shader_packs.get_mut(pack)
&& entry.step_option(option, true, false)
{
events.push(Event::ButtonClick);
events.push(Event::NativePackOptions(pack));
}
return events;
}
if ui::scrollbar_thumb(top, bottom, content, self.scroll).is_some()
@@ -3838,6 +3905,22 @@ impl OptionsUi {
options.reset();
}
}
Act::NativePackOptions(i) => {
self.push(Page::NativePackOptions(i));
}
Act::NativePackOption(pack, option) => {
if let Some(entry) = s.shader_packs.get_mut(pack)
&& entry.step_option(option, false, self.shift)
{
events.push(Event::NativePackOptions(pack));
}
}
Act::NativePackReset(pack) => {
if let Some(entry) = s.shader_packs.get_mut(pack) {
entry.reset_options();
events.push(Event::NativePackOptions(pack));
}
}
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::None => {}
@@ -4745,6 +4828,22 @@ impl OptionsUi {
options.reset();
}
}
Act::NativePackOptions(i) => {
self.push(Page::NativePackOptions(i));
}
Act::NativePackOption(pack, option) => {
if let Some(entry) = s.shader_packs.get_mut(pack)
&& entry.step_option(option, false, shift)
{
events.push(Event::NativePackOptions(pack));
}
}
Act::NativePackReset(pack) => {
if let Some(entry) = s.shader_packs.get_mut(pack) {
entry.reset_options();
events.push(Event::NativePackOptions(pack));
}
}
Act::Credits => events.push(Event::Credits),
Act::CopyAgentDetails => events.push(Event::CopyAgentDetails),
Act::None => {}
@@ -5032,6 +5131,54 @@ mod tests {
HudAtlas::build(std::path::Path::new("/nonexistent/horizon-test-assets")).unwrap()
}
#[test]
fn a_horizon_packs_options_step_both_ways_from_the_picked_packs_button() {
let atlas = atlas();
let size = (960.0, 540.0);
let mut s = Settings::default();
s.horizon.shader_pack = "p".into();
s.shader_packs = vec![crate::shader_pack_list::Entry {
file: "p".into(),
title: "P".into(),
kind: crate::shader_pack_list::Kind::Horizon,
options: vec![horizon_shader_api::PackOption {
id: "a".into(),
name: "A".into(),
description: String::new(),
kind: horizon_shader_api::OptionKind::Float {
default: 1.0,
min: 0.0,
max: 2.0,
step: 0.5,
},
}],
values: vec![1.0],
}];
let mut ui = OptionsUi::default();
ui.show(Page::ShaderPacks);
let find = |ui: &mut OptionsUi, s: &Settings, want: &Act| {
let (widgets, ..) = ui.layout(s, size.0, size.1);
let w = widgets
.into_iter()
.find(|w| matches!(&w.kind, WKind::Button { act, .. } if act == want))
.expect("button");
(w.x + w.w / 2.0, w.y + w.h / 2.0)
};
let (x, y) = find(&mut ui, &s, &Act::NativePackOptions(0));
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert_eq!(ui.page(), Page::NativePackOptions(0));
let (x, y) = find(&mut ui, &s, &Act::NativePackOption(0, 0));
let events = ui.click(&mut s, &atlas, x, y, size, [854, 480], false, false);
assert!(events.contains(&Event::NativePackOptions(0)));
assert_eq!(s.shader_packs[0].values, [1.5]);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
ui.click(&mut s, &atlas, x, y, size, [854, 480], false, true);
assert_eq!(s.shader_packs[0].values, [0.5]);
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]);
}
#[test]
fn resource_reload_error_blocks_options_until_dismissed() {
use winit::keyboard::KeyCode;
@@ -2,7 +2,9 @@
//! Settings > Shader Packs lists them: Horizon's own (docs/shaders.md) and those in the
//! `shaders/` format, side by side.
use std::path::Path;
use std::path::{Path, PathBuf};
use horizon_shader_api::{OptionKind, PackOption};
pub const DIR: &str = "shaderpacks";
@@ -14,13 +16,46 @@ pub enum Kind {
Classic,
}
#[derive(Clone, Debug, PartialEq, Eq)]
#[derive(Clone, Debug, PartialEq)]
pub struct Entry {
/// The folder's or file's name in `shaderpacks`: what the pick saves.
pub file: String,
/// The pack's own name for Horizon's packs, else `file`.
pub title: String,
pub kind: Kind,
/// A Horizon pack's `[[options]]`, and the value of each the player set (`values_path`).
pub options: Vec<PackOption>,
pub values: Vec<f64>,
}
impl Entry {
/// Saves the player's option values, for the pack in the folder `dir`.
pub fn save_values(&self, dir: &Path) {
let path = dir.join(&self.file);
if let Err(e) = save_values(&path, &self.options, &self.values) {
log::warn!("shader pack options of {}: {e}", self.file);
}
}
/// The option `i` one value on (`back`: one before), or at its default (`reset`). Whether
/// the pack has that option.
pub fn step_option(&mut self, i: usize, back: bool, reset: bool) -> bool {
let (Some(o), Some(v)) = (self.options.get(i), self.values.get_mut(i)) else {
return false;
};
*v = if reset {
o.kind.default_constant()
} else {
step_value(&o.kind, *v, back)
};
true
}
/// Every option back to its default.
pub fn reset_options(&mut self) {
let defaults = self.options.iter().map(|o| o.kind.default_constant());
self.values = defaults.collect();
}
}
/// The packs in `dir`, Horizon's first, each kind by title. A missing folder has none.
@@ -53,11 +88,15 @@ fn entry(path: &Path) -> Option<Entry> {
.ok()
.map(|p| p.manifest)
};
let title = manifest.map_or_else(|| file.clone(), |m| m.name);
let (title, options) =
manifest.map_or_else(|| (file.clone(), Vec::new()), |m| (m.name, m.options));
let values = load_values(path, &options);
return Some(Entry {
file,
title,
kind: Kind::Horizon,
options,
values,
});
}
let zip = path.is_file()
@@ -68,9 +107,127 @@ fn entry(path: &Path) -> Option<Entry> {
title: file.clone(),
file,
kind: Kind::Classic,
options: Vec::new(),
values: Vec::new(),
})
}
/// Where the option values of the Horizon pack at `pack` are kept: `<pack>.txt` beside it,
/// one `id=value` a line.
pub fn values_path(pack: &Path) -> PathBuf {
let mut path = pack.as_os_str().to_owned();
path.push(".txt");
PathBuf::from(path)
}
/// The value of each of `options` saved for the pack at `pack`, else its default; a value
/// out of the option's range is brought into it.
pub fn load_values(pack: &Path, options: &[PackOption]) -> Vec<f64> {
let text = std::fs::read_to_string(values_path(pack)).unwrap_or_default();
let saved: std::collections::HashMap<&str, f64> = text
.lines()
.filter_map(|l| l.split_once('='))
.filter_map(|(k, v)| Some((k.trim(), v.trim().parse().ok()?)))
.collect();
options
.iter()
.map(|o| {
saved
.get(o.id.as_str())
.map_or_else(|| o.kind.default_constant(), |&v| clamp(&o.kind, v))
})
.collect()
}
/// Saves `values` of `options`; values at their defaults are left out, and with none left
/// the file goes.
fn save_values(pack: &Path, options: &[PackOption], values: &[f64]) -> std::io::Result<()> {
let text: String = options
.iter()
.zip(values)
.filter(|(o, v)| o.kind.default_constant() != **v)
.map(|(o, v)| format!("{}={v}\n", o.id))
.collect();
let path = values_path(pack);
if text.is_empty() {
match std::fs::remove_file(&path) {
Err(e) if e.kind() != std::io::ErrorKind::NotFound => Err(e),
_ => Ok(()),
}
} else {
std::fs::write(path, text)
}
}
fn clamp(kind: &OptionKind, v: f64) -> f64 {
match kind {
OptionKind::Bool { .. } => f64::from(u8::from(v != 0.0)),
OptionKind::Float { min, max, .. } => v.clamp(f64::from(*min), f64::from(*max)),
OptionKind::Int { min, max, .. } => v.round().clamp(f64::from(*min), f64::from(*max)),
OptionKind::Choice { values, .. } => v.round().clamp(0.0, values.len() as f64 - 1.0),
}
}
/// The option's next value (`back`: the one before), wrapping around at the ends.
fn step_value(kind: &OptionKind, v: f64, back: bool) -> f64 {
let dir = if back { -1.0 } else { 1.0 };
match kind {
OptionKind::Bool { .. } => f64::from(u8::from(v == 0.0)),
OptionKind::Float { min, max, step, .. } => {
let (min, max, step) = (f64::from(*min), f64::from(*max), f64::from(*step));
if !back && v >= max - step * 0.01 {
min
} else if back && v <= min + step * 0.01 {
max
} else {
// On the grid of steps from the minimum, so float error never piles up.
let n = ((v - min) / step).round() + dir;
(min + n * step).clamp(min, max)
}
}
OptionKind::Int { min, max, .. } => {
let (min, max) = (f64::from(*min), f64::from(*max));
let n = v.round() + dir;
if n > max {
min
} else if n < min {
max
} else {
n
}
}
OptionKind::Choice { values, .. } => {
let n = values.len() as f64;
(v.round() + dir).rem_euclid(n)
}
}
}
/// The value as the settings page shows it.
pub fn show_value(kind: &OptionKind, v: f64) -> String {
match kind {
OptionKind::Bool { .. } => crate::lang::get(if v != 0.0 {
"options.on"
} else {
"options.off"
}),
OptionKind::Float { step, .. } => {
let digits = if *step >= 1.0 {
0
} else if *step >= 0.1 {
1
} else {
2
};
format!("{v:.digits$}")
}
OptionKind::Int { .. } => format!("{v:.0}"),
OptionKind::Choice { values, .. } => {
values.get(v.round() as usize).cloned().unwrap_or_default()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -106,4 +263,59 @@ mod tests {
assert!(scan(&d.join("missing")).is_empty());
std::fs::remove_dir_all(d).unwrap();
}
fn option(id: &str, kind: OptionKind) -> PackOption {
PackOption {
id: id.into(),
name: id.into(),
description: String::new(),
kind,
}
}
#[test]
fn values_step_wrap_and_round_trip() {
let float = OptionKind::Float {
default: 1.0,
min: 0.0,
max: 2.0,
step: 0.5,
};
assert_eq!(step_value(&float, 1.0, false), 1.5);
assert_eq!(step_value(&float, 2.0, false), 0.0);
assert_eq!(step_value(&float, 0.0, true), 2.0);
assert_eq!(show_value(&float, 1.5), "1.5");
let choice = OptionKind::Choice {
values: vec!["a".into(), "b".into(), "c".into()],
default: 0,
};
assert_eq!(step_value(&choice, 2.0, false), 0.0);
assert_eq!(step_value(&choice, 0.0, true), 2.0);
assert_eq!(show_value(&choice, 1.0), "b");
let int = OptionKind::Int {
default: 0,
min: -1,
max: 1,
};
assert_eq!(step_value(&int, 1.0, false), -1.0);
let on = OptionKind::Bool { default: true };
assert_eq!(step_value(&on, 1.0, false), 0.0);
let dir =
std::env::temp_dir().join(format!("horizon-shader-values-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let pack = dir.join("p.hshader");
let options = [option("a", float), option("b", choice)];
// Only what differs from the defaults is written; out of range comes back clamped.
save_values(&pack, &options, &[1.0, 2.0]).unwrap();
let text = std::fs::read_to_string(values_path(&pack)).unwrap();
assert_eq!(text, "b=2\n");
std::fs::write(values_path(&pack), "a=9\nb=1\nzzz=4\n").unwrap();
assert_eq!(load_values(&pack, &options), [2.0, 1.0]);
save_values(&pack, &options, &[1.0, 0.0]).unwrap();
assert!(!values_path(&pack).exists());
assert_eq!(load_values(&pack, &options), [1.0, 0.0]);
std::fs::remove_dir_all(&dir).unwrap();
}
}
@@ -25,6 +25,30 @@ const ROWS: [&[u32]; MAX_PLAYERS - 4] = [
&[4, 4, 4, 4],
];
/// Where a window is.
#[derive(Clone, Debug, PartialEq)]
pub enum Placement {
/// A normal window: its corner on the desktop and its size in pixels (`None`: a new one,
/// where the system opens it).
Free(Option<([i32; 2], [u32; 2])>),
/// Borderless over the monitor of this name.
Fill(String),
}
/// How far window `number` opens right and down of the centre, in logical pixels: Window 2
/// centred, each next one a step further, as systems cascade new windows (back to the centre
/// after six), so new windows never open exactly on one another.
pub fn cascade(number: u8) -> f64 {
f64::from(number.saturating_sub(2) % 6) * 32.0
}
/// A window: its number (1 is the game's own) and where it is.
#[derive(Clone, Debug, PartialEq)]
pub struct Window {
pub number: u8,
pub placement: Placement,
}
/// `order` made a whole order of `n` players: those it names (once, if there are), then the
/// others, P1 first.
pub fn ordered(order: &[usize], n: usize) -> Vec<usize> {
+20 -2
View File
@@ -34,9 +34,16 @@ fn parse_nine_slice(v: &serde_json::Value) -> Option<NineSlice> {
let g = |k: &str| border[k].as_u64().unwrap_or(0) as u32;
[g("left"), g("top"), g("right"), g("bottom")]
};
let (width, height) = (size("width")?, size("height")?);
// `NineSlice.validate`: borders that meet leave no centre slice, so the
// sprite has no nine-slice metadata (vanilla draws it as a stretch).
let [l, t, r, b] = border;
if l.saturating_add(r) >= width || t.saturating_add(b) >= height {
return None;
}
Some(NineSlice {
width: size("width")?,
height: size("height")?,
width,
height,
border,
stretch_inner: s["stretch_inner"].as_bool().unwrap_or(false),
})
@@ -1458,6 +1465,17 @@ mod tests {
assert_quad(q[1], ([32.0, 5.0, 40.0, 7.0], [0.0, 0.0, 0.25, 1.0]));
}
#[test]
fn a_border_that_leaves_no_centre_slice_is_no_metadata() {
// NineSlice.validate rejects 12 + 12 >= 20, so the sprite has none.
let json = serde_json::json!({"gui": {"scaling": {
"type": "nine_slice", "width": 20, "height": 20, "border": 12}}});
assert!(parse_nine_slice(&json).is_none());
// 9 + 9 < 20 still leaves a centre slice.
let json = serde_json::json!({"gui": {"scaling": {
"type": "nine_slice", "width": 20, "height": 20, "border": 9}}});
assert_eq!(parse_nine_slice(&json).unwrap().border, [9; 4]);
}
#[test]
fn a_high_resolution_button_slices_in_its_gui_units() {
// Faithful 32x's widget/button: 400x40 texels, gui.scaling 200x20.
let n = faithful(
@@ -98,6 +98,25 @@ thread_local! {
static TARGETS: RefCell<(Vec<Target>, Vec<Target>, u64)> = const { RefCell::new((Vec::new(), Vec::new(), 0)) };
}
/// Splitscreen: one player's buttons and focus targets, kept aside while another player's game
/// runs on this thread ([`take`] and [`put`] around each turn).
#[derive(Default)]
pub struct Saved(
(Vec<Widget>, Vec<Widget>, u64),
(Vec<Target>, Vec<Target>, u64),
);
/// This thread's buttons and targets, leaving none.
pub fn take() -> Saved {
Saved(WIDGETS.take(), TARGETS.take())
}
/// Bring back what [`take`] kept.
pub fn put(saved: Saved) {
WIDGETS.set(saved.0);
TARGETS.set(saved.1);
}
/// An interactive rectangle (GUI pixels) a controller can focus and click.
pub fn target(rect: [f32; 4]) {
target_as(rect, Kind::Button);
+7
View File
@@ -83,6 +83,13 @@ open = { version = "5.4.5", features = ["shellexecute-on-windows"] }
# on the PATH.
wgpu = { version = "30.0.1", default-features = false, features = ["dx12"] }
[target.'cfg(target_os = "macos")'.dependencies]
# Splitscreen: a window filling a monitor goes over its menu bar (game/split/windows.rs
# `above_menu_bar`); all already in the tree through winit and wgpu.
objc2 = "0.6"
objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSResponder", "NSWindow", "NSScreen"] }
objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSArray", "NSString"] }
[target.'cfg(not(target_os = "macos"))'.dependencies]
# OpenGL (WGL on Windows, EGL on Linux), the last fallback, for GPUs with no Vulkan or
# DirectX 12 driver (`graphicsApi`, graphics.rs).
@@ -205,5 +205,9 @@
"Frame rate and frame times over the game, with a graph.": "Frame rate and frame times over the game, with a graph.",
"horizon.multiplayer.finding_friend": "Finding your friend's world...",
"horizon.loading.opening_world": "Opening world",
"horizon.loading.cancelling_world": "Cancelling world"
"horizon.loading.cancelling_world": "Cancelling world",
"horizon.multiplayer.not_signed_in": "This server is in online mode. Sign in with your Microsoft account in the Horizon launcher to join it.",
"horizon.multiplayer.invalid_address": "Invalid server address: %s",
"horizon.multiplayer.connected_friend_direct": "Connected straight to your friend...",
"horizon.multiplayer.connected_friend_relay": "Connected to your friend through the relay..."
}
@@ -199,5 +199,9 @@
"Frame rate and frame times over the game, with a graph.": "Fotogramas por segundo y tiempos de fotograma sobre el juego, con un gráfico.",
"horizon.multiplayer.finding_friend": "Buscando el mundo de tu amigo…",
"horizon.loading.opening_world": "Abriendo el mundo",
"horizon.loading.cancelling_world": "Cancelando"
"horizon.loading.cancelling_world": "Cancelando",
"horizon.multiplayer.not_signed_in": "Este servidor está en modo en línea. Inicia sesión con tu cuenta de Microsoft en el launcher de Horizon para unirte.",
"horizon.multiplayer.invalid_address": "Dirección de servidor no válida: %s",
"horizon.multiplayer.connected_friend_direct": "Conectado directamente con tu amigo…",
"horizon.multiplayer.connected_friend_relay": "Conectado con tu amigo a través del relé…"
}
@@ -14,5 +14,9 @@
"horizon.options.on_screen_keyboard": "Klawiatura ekranowa",
"horizon.multiplayer.finding_friend": "Szukanie świata znajomego…",
"horizon.loading.opening_world": "Otwieranie świata",
"horizon.loading.cancelling_world": "Anulowanie"
"horizon.loading.cancelling_world": "Anulowanie",
"horizon.multiplayer.not_signed_in": "Ten serwer działa w trybie online. Zaloguj się kontem Microsoft w launcherze Horizon, aby dołączyć.",
"horizon.multiplayer.invalid_address": "Nieprawidłowy adres serwera: %s",
"horizon.multiplayer.connected_friend_direct": "Połączono bezpośrednio ze znajomym…",
"horizon.multiplayer.connected_friend_relay": "Połączono ze znajomym przez przekaźnik…"
}
@@ -198,5 +198,9 @@
"Frame rate and frame times over the game, with a graph.": "Частота кадров и их время поверх игры, с графиком.",
"horizon.multiplayer.finding_friend": "Ищем мир друга…",
"horizon.loading.opening_world": "Открытие мира",
"horizon.loading.cancelling_world": "Отмена"
"horizon.loading.cancelling_world": "Отмена",
"horizon.multiplayer.not_signed_in": "Этот сервер работает в онлайн-режиме. Войдите с помощью аккаунта Microsoft в лаунчере Horizon, чтобы присоединиться.",
"horizon.multiplayer.invalid_address": "Недействительный адрес сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Установлено прямое соединение с другом…",
"horizon.multiplayer.connected_friend_relay": "Соединение с другом через релей…"
}
@@ -14,5 +14,9 @@
"horizon.options.on_screen_keyboard": "Екранна клавіатура",
"horizon.multiplayer.finding_friend": "Шукаємо світ друга…",
"horizon.loading.opening_world": "Відкриття світу",
"horizon.loading.cancelling_world": "Скасування"
"horizon.loading.cancelling_world": "Скасування",
"horizon.multiplayer.not_signed_in": "Цей сервер працює в онлайн-режимі. Увійдіть за допомогою акаунта Microsoft у лаунчері Horizon, щоб приєднатися.",
"horizon.multiplayer.invalid_address": "Недійсна адреса сервера: %s",
"horizon.multiplayer.connected_friend_direct": "Пряме з'єднання з другом встановлено…",
"horizon.multiplayer.connected_friend_relay": "З'єднання з другом через ретранслятор…"
}
@@ -199,5 +199,9 @@
"Frame rate and frame times over the game, with a graph.": "游戏运行时的帧率与帧时间,附带图表。",
"horizon.multiplayer.finding_friend": "正在查找好友的世界…",
"horizon.loading.opening_world": "正在打开世界",
"horizon.loading.cancelling_world": "正在取消"
"horizon.loading.cancelling_world": "正在取消",
"horizon.multiplayer.not_signed_in": "该服务器为在线模式服务器。请先在 Horizon 启动器中登录 Microsoft 账户再加入。",
"horizon.multiplayer.invalid_address": "无效的服务器地址:%s",
"horizon.multiplayer.connected_friend_direct": "已直连好友的世界…",
"horizon.multiplayer.connected_friend_relay": "已通过中继连接好友的世界…"
}
+9
View File
@@ -5,6 +5,15 @@ fn main() {
let dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default();
let ico = format!("{dir}/../../packaging/icons/horizon.ico");
println!("cargo:rerun-if-changed={ico}");
// The game's main thread gets 8 MB of stack on Windows as on macOS and Linux (Windows gives 1
// MB): splitscreen builds a joining player's `Game`, ~67 KB, deep in the event loop.
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let arg = match std::env::var("CARGO_CFG_TARGET_ENV").as_deref() {
Ok("msvc") => "/STACK:8388608",
_ => "-Wl,--stack,8388608",
};
println!("cargo:rustc-link-arg-bin=horizon-client={arg}");
}
#[cfg(windows)]
if std::env::var("CARGO_CFG_TARGET_OS").as_deref() == Ok("windows") {
let mut resource = winresource::WindowsResource::new();
@@ -291,6 +291,8 @@ pub fn main() -> horizon_client::Result<()> {
for d in items {
let _ = match d {
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
// The fixture's mobs keep no whole stacks.
drops::Drop::Stack(s) => writeln!(out, "drop {} {}", s.item, s.count),
drops::Drop::CarriedBlock(state) => {
let stacks = enderman::carried_block_drops(
&block_items,
@@ -1,5 +1,6 @@
use horizon_client::interaction_input::{
Button, InteractionInput, InteractionWorld, TargetUseWorld, use_on_target,
Button, InteractionInput, InteractionWorld, TargetUseWorld, release_on_screen_open,
use_on_target,
};
use serde_json::Value;
use std::io::{BufRead, Write};
@@ -103,14 +104,23 @@ pub fn main() -> Result<(), Box<dyn std::error::Error>> {
passing: row["passing"].as_bool().unwrap_or(false),
};
let mut input = InteractionInput::default();
for button in row["clicks"].as_array().unwrap() {
input.press(match button.as_u64().unwrap() {
0 => Button::Attack,
1 => Button::Use,
_ => Button::Pick,
});
let screen = row["screen"].as_bool().unwrap_or(false);
if screen {
// A keybind opened a screen: Gui.setScreen already ran
// KeyMapping.releaseAll, so the same handleKeybinds call releases an
// active use; with the screen open nothing else acts this tick.
release_on_screen_open(&mut world);
input.tick(&mut world, false, false);
} else {
for button in row["clicks"].as_array().unwrap() {
input.press(match button.as_u64().unwrap() {
0 => Button::Attack,
1 => Button::Use,
_ => Button::Pick,
});
}
input.tick(&mut world, flag("attack_down"), flag("use_down"));
}
input.tick(&mut world, flag("attack_down"), flag("use_down"));
writeln!(out, "== {i}")?;
for event in world.events {
writeln!(out, "{event}")?;
+9
View File
@@ -1071,6 +1071,15 @@ impl Ticker<'_, '_, '_, '_> {
}
effects.advance(step);
let name = &*self.scene.facts.get(state).name;
crate::block_contact::emit(crate::block_contact::Event::Inside(
crate::block_contact::Contact {
entity: self.p.id as i32,
kind: self.p.kind.id(),
block: name,
state,
position: p,
},
));
if matches!(name, "minecraft:fire" | "minecraft:soul_fire") {
// BaseFireBlock.entityInside (its inFire hurt does nothing to arrows).
effects.apply(Effect::ClearFreeze);
+1 -3
View File
@@ -92,9 +92,7 @@ impl Banners {
});
}
banners.no_item_required = read_tag(root, "no_item_required", 0);
if let Some(raw) = fs::read(root.join("items.json")).ok().and_then(|b| {
serde_json::from_slice::<std::collections::BTreeMap<String, Value>>(&b).ok()
}) {
if let Ok(raw) = horizon_world::item_defaults::read(root) {
for (item, c) in raw {
let id: Arc<str> = Arc::from(item.as_str());
if let Some(dye) = c["minecraft:dye"].as_str() {
@@ -194,7 +194,8 @@ pub const COMMANDS: &[Cmd] = &[
&[flag("world", Ty::Str, "world folder to open or create"), flag("seed", Ty::Str, "seed for a new world"), flag("creative", Ty::Bool, "creative mode for a new world"),
flag("version", Ty::Str, "an installed launcher version tag such as v2026.10.13"), flag("game", Ty::Str, "path to a horizon-client binary"),
flag("wait", Ty::Bool, "also wait until the world is ready (default in MCP)"),
flag("offscreen", Ty::Bool, "render without showing a window (screenshots still work): locked or headless sessions")],
flag("offscreen", Ty::Bool, "render without showing a window (screenshots still work): locked or headless sessions"),
flag("headless", Ty::Bool, "no display, window or GPU: a player that plays through these commands only (no screenshots)")],
mcp: true, local: true),
cmd!("wait-for", Session, "wait-for screen NAME|widget ID|loaded|chat TEXT|event KIND [--timeout S] [--since SEQ]", "wait until the game reaches a state, then reply",
"screen takes the names state shows (playing, inventory, paused, dead, title...); widget an id from ui; loaded means a world is open, the player is in it and no chunk is pending; chat a new line containing TEXT; event a kind from events (death, respawn, item, screen...). chat and event count from --since SEQ (take last from events before acting), else from now. A timeout (default 30 s) is an error with kind timeout.",
+201
View File
@@ -0,0 +1,201 @@
//! Synchronous mod-block contacts from the server's simulation code.
use horizon_core::position::BlockPos;
use horizon_world::registry::BlockStateId;
use std::{
cell::Cell,
sync::{
OnceLock,
atomic::{AtomicBool, Ordering},
},
};
#[derive(Clone, Copy, Debug)]
pub struct Contact<'a> {
pub entity: i32,
pub kind: &'a str,
pub block: &'a str,
pub state: BlockStateId,
pub position: BlockPos,
}
#[derive(Clone, Copy, Debug)]
pub enum Event<'a> {
StepOn(Contact<'a>),
Inside(Contact<'a>),
FallOn(Contact<'a>, f64),
}
pub static HOOK: OnceLock<fn(&str, Event<'_>, &mut f32)> = OnceLock::new();
/// Whether the loaded blocks hold one outside `minecraft:` (a content mod's).
/// Without one no contact can dispatch, so the item and projectile sweeps
/// skip their per-tick work: every item and thrown thing on a server would
/// otherwise sweep its blocks each tick for nothing.
static MOD_BLOCKS: AtomicBool = AtomicBool::new(false);
/// Called when the block tables load.
pub fn set_mod_blocks(any: bool) {
MOD_BLOCKS.store(any, Ordering::Relaxed);
}
thread_local! {
static LEVEL: Cell<Option<&'static str>> = const { Cell::new(None) };
}
/// Scope authority to this thread and restore it even on an error or panic.
/// Nested guest actions may temporarily enter a different dimension.
pub fn in_level<R>(dimension: Option<&'static str>, f: impl FnOnce() -> R) -> R {
struct Restore(Option<&'static str>);
impl Drop for Restore {
fn drop(&mut self) {
LEVEL.set(self.0);
}
}
let _restore = Restore(LEVEL.replace(dimension));
f()
}
pub fn emit(event: Event<'_>) {
dispatch(event, &mut 0.0);
}
/// Damage is the calculated fall damage, before hurt handling and fall sounds.
pub fn fall(contact: Contact<'_>, distance: f64, mut damage: f32) -> f32 {
dispatch(Event::FallOn(contact, distance), &mut damage);
damage
}
fn dispatch(event: Event<'_>, damage: &mut f32) {
let contact = match event {
Event::StepOn(c) | Event::Inside(c) | Event::FallOn(c, _) => c,
};
if contact.block.starts_with("minecraft:") {
return;
}
if let Some(dimension) = LEVEL.get()
&& let Some(hook) = HOOK.get()
{
let original = *damage;
hook(dimension, event, damage);
if !damage.is_finite() {
*damage = original;
}
*damage = damage.max(0.0);
}
}
/// Reuse the entity block sweep for item and projectile ticks without an effect collector.
pub fn swept_inside<'a>(
entity: i32,
kind: &str,
from: horizon_core::geometry::Vec3,
to: horizon_core::geometry::Vec3,
bounds: horizon_core::geometry::Aabb,
mut block: impl FnMut(BlockPos) -> (BlockStateId, &'a str),
) {
if !MOD_BLOCKS.load(Ordering::Relaxed) || LEVEL.get().is_none() || HOOK.get().is_none() {
return;
}
let bounds = bounds.deflate(horizon_core::geometry::Vec3::new(1e-5, 1e-5, 1e-5));
let mut visited = std::collections::HashSet::new();
let _ =
horizon_core::geometry::sweep_intersected_blocks(from, to, bounds, 1_000_000, |p, step| {
if step >= 16 {
return false;
}
if visited.insert(p) {
let (state, name) = block(p);
emit(Event::Inside(Contact {
entity,
kind,
block: name,
state,
position: p,
}));
}
true
});
}
#[cfg(test)]
mod tests {
use super::*;
thread_local! {
static SEEN: Cell<usize> = const { Cell::new(0) };
}
fn hook(dimension: &str, event: Event<'_>, damage: &mut f32) {
assert_eq!(dimension, "minecraft:the_nether");
let c = match event {
Event::FallOn(c, distance) => {
assert_eq!(distance, 12.0);
*damage = match *damage {
99.0 => f32::NAN,
98.0 => -1.0,
_ => *damage * 0.2,
};
c
}
Event::Inside(c) | Event::StepOn(c) => c,
};
assert_eq!(
(c.entity, c.kind, c.state, c.position),
(
17,
"minecraft:zombie",
BlockStateId(9),
BlockPos::new(1, 2, 3)
)
);
SEEN.set(SEEN.get() + 1);
}
#[test]
fn contacts_require_server_scope_and_mod_blocks_and_restore_nested_levels() {
HOOK.set(hook).unwrap();
let c = Contact {
entity: 17,
kind: "minecraft:zombie",
block: "test:hay",
state: BlockStateId(9),
position: BlockPos::new(1, 2, 3),
};
assert_eq!(fall(c, 12.0, 10.0), 10.0);
in_level(Some("minecraft:the_nether"), || {
emit(Event::StepOn(c));
emit(Event::Inside(c));
assert_eq!(fall(c, 12.0, 10.0), 2.0);
in_level(None, || assert_eq!(fall(c, 12.0, 10.0), 10.0));
assert_eq!(fall(c, 12.0, 0.0), 0.0);
emit(Event::Inside(Contact {
block: "minecraft:hay_block",
..c
}));
});
assert_eq!(fall(c, 12.0, 10.0), 10.0);
assert_eq!(SEEN.get(), 4);
in_level(Some("minecraft:the_nether"), || {
assert_eq!(fall(c, 12.0, 99.0), 99.0);
assert_eq!(fall(c, 12.0, 98.0), 0.0);
use horizon_core::geometry::{Aabb, Vec3};
let to = Vec3::new(2.5, 2.0, 3.5);
let bounds = Aabb::new(Vec3::new(2.25, 2.0, 3.25), Vec3::new(2.75, 2.5, 3.75));
let block = |p| {
(
BlockStateId(9),
if p == c.position {
"test:hay"
} else {
"minecraft:air"
},
)
};
set_mod_blocks(true);
swept_inside(17, c.kind, Vec3::new(0.5, 2.0, 3.5), to, bounds, block);
assert_eq!(SEEN.get(), 7, "a block crossed mid-move is notified once");
let at = Vec3::new(1.5, 2.0, 3.5);
let bounds = bounds.move_by(Vec3::new(-1.0, 0.0, 0.0));
swept_inside(17, c.kind, at, at, bounds, block);
assert_eq!(SEEN.get(), 8, "stationary overlap still notifies");
});
let _ =
std::panic::catch_unwind(|| in_level(Some("minecraft:the_nether"), || panic!("test")));
assert_eq!(fall(c, 12.0, 10.0), 10.0);
}
}
+22 -3
View File
@@ -1,6 +1,6 @@
//! Identity of the ordered block-state registry used by chunk packets, and of
//! the item and mob effect registries once content mods add to them (item
//! stacks and effect packets carry their network ids).
//! the item, mob effect and entity type registries once content mods add to
//! them (item stacks, effect and entity packets carry their network ids).
use horizon_protocol::registry::Builtin;
use serde::{Deserialize, Serialize, de::IgnoredAny};
use sha2::{Digest, Sha256};
@@ -76,6 +76,21 @@ pub fn identity(root: &Path) -> Result<[u8; 32], IdentityError> {
.collect();
if !components.is_empty() {
hash = hash_entries(hash, "horizon:nbt_components/v1", &components);
// Defaults are not sent in a stack patch. Both peers must therefore
// agree on their values as well as the registered wire IDs, native
// mods' changes included.
let items = horizon_world::item_defaults::read(root).map_err(IdentityError::Read)?;
let mut defaults = Vec::new();
for (item, components) in items {
for (name, value) in components.as_object().into_iter().flatten() {
if horizon_protocol::item::custom::id(name).is_some() {
defaults.push((defaults.len() as u64, format!("{item}\0{name}\0{value}")));
}
}
}
if !defaults.is_empty() {
hash = hash_entries(hash, "horizon:nbt_component_defaults/v1", &defaults);
}
}
identities.insert(root.to_owned(), hash);
Ok(hash)
@@ -93,7 +108,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; 2] = ["minecraft:item", "minecraft:mob_effect"];
const REGISTRIES: [&str; 3] = [
"minecraft:item",
"minecraft:mob_effect",
"minecraft:entity_type",
];
fn parse_registries(bytes: &[u8]) -> Result<Builtin, IdentityError> {
let text = std::str::from_utf8(bytes).map_err(|e| IdentityError::Registries(e.to_string()))?;
+267 -53
View File
@@ -182,47 +182,14 @@ impl ItemEntity {
/// up) without a full collision cube, at `speed` (`nextFloat() * 0.2 +
/// 0.1`).
pub fn move_towards_closest_space(&mut self, world: &World, facts: &BlockFacts, speed: f32) {
let bounds = self.bounds();
let at = Vec3::new(
self.position.x,
(bounds.min_position().y + bounds.max_position().y) / 2.0,
self.position.z,
self.velocity = towards_closest_space(
self.position,
self.bounds(),
self.velocity,
world,
facts,
speed,
);
let pos = [
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
];
let delta = [
at.x - f64::from(pos[0]),
at.y - f64::from(pos[1]),
at.z - f64::from(pos[2]),
];
// (axis, step): NORTH, SOUTH, WEST, EAST, UP.
let mut closest = (1, 1);
let mut nearest = f64::MAX;
for (axis, step) in [(2, -1), (2, 1), (0, -1), (0, 1), (1, 1)] {
let mut n = pos;
n[axis] += step;
if !facts.get(world.block(n[0], n[1], n[2])).full_collision {
let d = if step > 0 {
1.0 - delta[axis]
} else {
delta[axis]
};
if d < nearest {
nearest = d;
closest = (axis, step);
}
}
}
let push = f64::from(closest.1 as f32 * speed);
let v = self.velocity.scale(0.75);
self.velocity = match closest.0 {
0 => Vec3::new(push, v.y, v.z),
1 => Vec3::new(v.x, push, v.z),
_ => Vec3::new(v.x, v.y, push),
};
}
/// Returns false when the item should be removed. `no_physics`: the item
/// moves through blocks this tick ([`ItemEntity::inside_block`]).
@@ -339,6 +306,37 @@ impl ItemEntity {
&mut self.velocity,
);
}
if !no_physics {
// Entity.applyEffectsFromBlocks: stepOn, then checkInsideBlocks.
if self.on_ground {
let p = BlockPos::containing(self.position.add_vector(Vec3::new(
0.0,
-f64::from(0.2_f32),
0.0,
)));
let state = world.block(p.x, p.y, p.z);
crate::block_contact::emit(crate::block_contact::Event::StepOn(
crate::block_contact::Contact {
entity: self.id as i32,
kind: "minecraft:item",
block: &facts.get(state).name,
state,
position: p,
},
));
}
crate::block_contact::swept_inside(
self.id as i32,
"minecraft:item",
self.previous,
self.position,
self.bounds(),
|p| {
let state = world.block(p.x, p.y, p.z);
(state, &facts.get(state).name)
},
);
}
if self.velocity.subtract(old_velocity).length_squared() > 0.01 {
self.needs_sync = true;
}
@@ -638,6 +636,7 @@ impl ExperienceOrb {
facts: &BlockFacts,
collision: &BlockCollisionRegistry<'_>,
player: Option<[f64; 3]>,
server: bool,
next_float: &mut dyn FnMut() -> f32,
) -> bool {
self.ticks += 1;
@@ -647,13 +646,22 @@ impl ExperienceOrb {
self.position.y.floor() as i32,
self.position.z.floor() as i32,
);
let bounds = self.bounds();
let mut scene = SceneCollisions {
world,
binding: collision,
subject: None,
};
// `boolean colliding = !level.noCollision(getBoundingBox())`: an orb
// inside blocks takes no gravity until it is pushed out below.
let colliding = !crate::collisions::no_collision_empty(&mut scene, bounds);
if facts.get(block).fluid == Fluid::Water {
self.velocity = Vec3::new(
self.velocity.x * 0.99,
(self.velocity.y + 5.0e-4).min(0.06),
self.velocity.z * 0.99,
);
} else {
} else if !colliding {
self.velocity.y -= 0.03;
}
if facts.get(block).fluid == Fluid::Lava {
@@ -681,12 +689,18 @@ impl ExperienceOrb {
);
}
}
let bounds = self.bounds();
let mut scene = SceneCollisions {
world,
binding: collision,
subject: None,
};
if player.is_none() && server && colliding {
// `followingPlayer == null && !level().isClientSide() && colliding`: an orb that would still
// be stuck at its next position gets
// `Entity.moveTowardsClosestSpace`, a nudge towards the nearest
// free side of its block.
if !crate::collisions::no_collision_empty(&mut scene, bounds.move_by(self.velocity)) {
let speed = next_float() * 0.2 + 0.1;
self.move_towards_closest_space(world, facts, speed);
}
}
// `double fallSpeed = getDeltaMovement().y`, taken after the stuck
// push and before the move.
let wanted = self.velocity;
let moved = scene
.resolve(bounds, wanted, 0.0, self.on_ground)
@@ -697,26 +711,114 @@ impl ExperienceOrb {
self.position.z + moved.z,
);
self.on_ground = wanted.y < 0.0 && moved.y != wanted.y;
let mut friction = 0.98;
// Entity.move's restituteMovementAfterCollisions, an orb's bounciness
// 0: each collided axis's velocity is zeroed — x and z by Mth.equal's
// 1.0E-5, y exactly.
if (wanted.x - moved.x).abs() >= 1.0e-5 {
self.velocity.x = 0.0;
}
if (wanted.z - moved.z).abs() >= 1.0e-5 {
self.velocity.z = 0.0;
}
if moved.y != wanted.y {
self.velocity.y = 0.0;
}
// `float friction = getAirDrag()` (0.98F), the ground's block
// friction multiplied in as a float, widened once for the scale.
let mut friction = 0.98_f32;
if self.on_ground {
let below = world.block(
self.position.x.floor() as i32,
(self.position.y - 1.0).floor() as i32,
(self.position.y - 0.999_999).floor() as i32,
self.position.z.floor() as i32,
);
friction = f64::from(facts.get(below).friction) * 0.98;
friction *= facts.get(below).friction;
}
let friction = f64::from(friction);
// The drag scales every axis by the same friction, the ground's
// block friction included.
self.velocity = Vec3::new(
self.velocity.x * friction,
self.velocity.y * 0.98,
self.velocity.y * friction,
self.velocity.z * friction,
);
if self.on_ground {
self.velocity.y *= -0.9;
// `if (verticalCollisionBelow && fallSpeed < -getGravity())`: the
// landing bounce is 0.4 of the pre-move speed, not a fixed
// restitution.
if self.on_ground && wanted.y < -0.03 {
self.velocity.y = -wanted.y * 0.4;
}
self.age += 1;
self.age < 6000
}
/// `Entity.moveTowardsClosestSpace` from the middle of the orb's box:
/// towards the nearest side of its block (north, south, west, east or
/// up) without a full collision cube, at `speed` (the orb's own
/// random, `nextFloat() * 0.2 + 0.1`).
fn move_towards_closest_space(&mut self, world: &World, facts: &BlockFacts, speed: f32) {
self.velocity = towards_closest_space(
self.position,
self.bounds(),
self.velocity,
world,
facts,
speed,
);
}
}
/// `Entity.moveTowardsClosestSpace` from the middle of an entity's box
/// (`bounds`, its feet at `position`): the velocity pushed towards the
/// nearest side of its block (north, south, west, east or up) without a full
/// collision cube, at `speed` (`nextFloat() * 0.2 + 0.1`). Items and
/// experience orbs share it.
fn towards_closest_space(
position: Vec3,
bounds: Aabb,
velocity: Vec3,
world: &World,
facts: &BlockFacts,
speed: f32,
) -> Vec3 {
let at = Vec3::new(
position.x,
(bounds.min_position().y + bounds.max_position().y) / 2.0,
position.z,
);
let pos = [
at.x.floor() as i32,
at.y.floor() as i32,
at.z.floor() as i32,
];
let delta = [
at.x - f64::from(pos[0]),
at.y - f64::from(pos[1]),
at.z - f64::from(pos[2]),
];
// (axis, step): NORTH, SOUTH, WEST, EAST, UP.
let mut closest = (1, 1);
let mut nearest = f64::MAX;
for (axis, step) in [(2, -1), (2, 1), (0, -1), (0, 1), (1, 1)] {
let mut n = pos;
n[axis] += step;
if !facts.get(world.block(n[0], n[1], n[2])).full_collision {
let d = if step > 0 {
1.0 - delta[axis]
} else {
delta[axis]
};
if d < nearest {
nearest = d;
closest = (axis, step);
}
}
}
let push = f64::from(closest.1 as f32 * speed);
let v = velocity.scale(0.75);
match closest.0 {
0 => Vec3::new(push, v.y, v.z),
1 => Vec3::new(v.x, push, v.z),
_ => Vec3::new(v.x, v.y, push),
}
}
/// `ExperienceOrb.scanForMerges`, run by each orb on `tickCount % 20 == 1`:
/// it absorbs the orbs that `canMerge` within 0.5 blocks of its box,
@@ -947,9 +1049,15 @@ pub struct ArmorStand {
pub health: f32,
pub id: u32,
pub position: Vec3,
/// `Entity.xo`, `yo`, `zo`: the position at the start of the tick, which
/// the renderer blends toward `position` by the partial tick.
pub previous: Vec3,
pub velocity: Vec3,
pub on_ground: bool,
/// `yRot`, which the body follows (`ArmorStand.tickHeadTurn`: `yBodyRot`).
pub yaw: f32,
/// `yBodyRotO`: the body's yaw at the start of the tick.
pub previous_yaw: f32,
/// mainhand, offhand, feet, legs, chest, head (EquipmentSlot order).
pub equipment: [Option<crate::items::ItemStack>; 6],
pub small: bool,
@@ -970,6 +1078,9 @@ pub struct ArmorStand {
/// rides, which seats it every tick (`positionRider`), as NPC plugins hang
/// a second name over a player's head.
pub vehicle: Option<u32>,
/// `ArmorStand.disabledSlots`: bit `filterFlag` locks a slot, bit
/// `filterFlag + 8` takes from it and bit `filterFlag + 16` puts into it.
pub disabled_slots: i32,
}
impl ArmorStand {
pub const SLOTS: [&'static str; 6] = ["mainhand", "offhand", "feet", "legs", "chest", "head"];
@@ -985,15 +1096,50 @@ impl ArmorStand {
[-1.0, 0.0, -1.0],
[1.0, 0.0, 1.0],
];
/// Where the renderer draws the stand `alpha` of the way through the tick
/// (`EntityRenderer`'s `Mth.lerp(partialTick, xo, x)` and so on).
pub fn render_position(&self, alpha: f64) -> Vec3 {
self.previous.lerp(self.position, alpha)
}
/// The body's yaw `alpha` of the way through the tick
/// (`LivingEntityRenderer.solveBodyRot`: `Mth.rotLerp(partialTick,
/// yBodyRotO, yBodyRot)`).
pub fn render_yaw(&self, alpha: f32) -> f32 {
self.previous_yaw + alpha * crate::camera::wrap_degrees(self.yaw - self.previous_yaw)
}
/// A server's stand on its client, each tick:
/// `setOldPosAndRot` (`ClientLevel.tickNonPassenger`), then the step of
/// its `SteppedInterpolationHandler` (`step`: the position and yaw it is
/// put at, when one is due). The yaw
/// turns the short way and stays continuous, so a frame between ticks
/// never spins it.
pub fn interpolate(&mut self, step: Option<(Vec3, f32)>) {
self.previous = self.position;
self.previous_yaw = self.yaw;
if let Some((at, yaw)) = step {
self.position = at;
self.yaw += crate::camera::wrap_degrees(yaw - self.yaw);
}
}
/// Put there at once, with nothing to blend from
/// (`Entity.snapTo`).
pub fn snap(&mut self, at: Vec3, yaw: f32) {
self.position = at;
self.previous = at;
self.yaw = yaw;
self.previous_yaw = yaw;
}
pub fn new(id: u32, position: Vec3, yaw: f32) -> Self {
Self {
fire_ticks: 0,
health: 20.0,
id,
position,
previous: position,
velocity: Vec3::ZERO,
on_ground: true,
yaw,
previous_yaw: yaw,
equipment: Default::default(),
small: false,
show_arms: false,
@@ -1005,8 +1151,29 @@ impl ArmorStand {
last_hit: i64::MIN / 2,
pose: Self::DEFAULT_POSE,
vehicle: None,
disabled_slots: 0,
}
}
/// `EquipmentSlot.getFilterFlag` of an `equipment` index (main hand, off
/// hand, feet, legs, chest, head).
fn filter_flag(slot: usize) -> u32 {
[0, 5, 1, 2, 3, 4][slot]
}
/// `ArmorStand.isDisabled`: the slot is locked, or it is a hand and the
/// stand has no arms.
pub fn is_disabled(&self, slot: usize) -> bool {
self.disabled_slots & (1 << Self::filter_flag(slot)) != 0 || (slot < 2 && !self.show_arms)
}
/// The `swapItem` locks: taking from a filled slot or putting into an
/// empty one can each be disabled.
pub fn swap_locked(&self, slot: usize) -> bool {
let offset = if self.equipment[slot].is_some() {
8
} else {
16
};
self.disabled_slots & (1 << (Self::filter_flag(slot) + offset)) != 0
}
pub fn environment_tick(
&mut self,
world: &World,
@@ -1296,6 +1463,53 @@ mod fire_environment_tests {
}
}
#[cfg(test)]
mod armor_stand_render_tests {
use super::*;
/// A falling stand is drawn between last tick's position and this one's,
/// so it slides down every frame instead of jumping once a tick (#4416).
#[test]
fn a_falling_stand_is_drawn_between_its_ticks() {
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 70.0, 0.5), 0.0);
// A new stand has no move behind it.
assert_eq!(stand.render_position(0.5), stand.position);
let fall = |s: &ArmorStand| Some((Vec3::new(0.5, s.position.y - 1.0, 0.5), 0.0));
stand.interpolate(fall(&stand));
assert_eq!(stand.render_position(0.0).y, 70.0);
assert_eq!(stand.render_position(0.25).y, 69.75);
assert_eq!(stand.render_position(1.0).y, 69.0);
stand.interpolate(fall(&stand));
assert_eq!(stand.render_position(0.5).y, 68.5);
// Landed: no step is due, so it stays put, and so does every frame.
stand.interpolate(None);
assert_eq!(stand.render_position(0.5).y, 68.0);
}
/// A turning stand's body turns between ticks too, the short way round.
#[test]
fn a_turning_stand_is_drawn_between_its_ticks() {
let mut stand = ArmorStand::new(1, Vec3::ZERO, 170.0);
assert_eq!(stand.render_yaw(0.5), 170.0);
// To -170 is 20 degrees on through 180, not 340 back through 0.
stand.interpolate(Some((Vec3::ZERO, -170.0)));
assert_eq!(stand.render_yaw(0.0), 170.0);
assert_eq!(stand.render_yaw(0.5), 180.0);
assert_eq!(stand.render_yaw(1.0), 190.0);
stand.interpolate(None);
assert_eq!(stand.render_yaw(0.5), 190.0);
}
/// A snap (a move too far to interpolate) leaves nothing to blend from.
#[test]
fn a_snapped_stand_is_drawn_where_it_landed() {
let mut stand = ArmorStand::new(1, Vec3::new(0.5, 70.0, 0.5), 0.0);
stand.interpolate(Some((Vec3::new(0.5, 69.0, 0.5), 0.0)));
stand.snap(Vec3::new(200.5, 64.0, 0.5), 90.0);
assert_eq!(stand.render_position(0.5), Vec3::new(200.5, 64.0, 0.5));
assert_eq!(stand.render_yaw(0.5), 90.0);
}
}
#[cfg(test)]
mod merge_tests {
use super::*;
+219 -12
View File
@@ -1141,6 +1141,25 @@ pub fn mob_to_nbt(
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
// A worn piece or held item that kept its damage, name or
// components is saved with them.
if slot != "mainhand"
&& item.is_some()
&& let Some(stack) = crate::mobs::finalize::stored_stack(
m,
[
crate::mobs::finalize::MAINHAND,
crate::mobs::finalize::OFFHAND,
crate::mobs::finalize::HEAD,
crate::mobs::finalize::CHEST,
crate::mobs::finalize::LEGS,
crate::mobs::finalize::FEET,
][index],
)
{
equipment.insert(slot.into(), crate::save::item_to_nbt(&stack));
continue;
}
// Enchanted or trimmed gear is saved with its components.
if let (Some(id), Some(g)) = (item, crate::mobs::gear::gear(m))
&& (!g.slots[index].is_empty() || g.trims[index].is_some())
@@ -1405,6 +1424,10 @@ pub fn mob_from_nbt(
crate::mobs::gear::set(&mut m, index, stack.enchantments().to_vec());
let trim = stack.data().and_then(|d| d.trim.clone());
crate::mobs::gear::set_trim(&mut m, index, trim);
// The damage, name and components stay with the slot.
if index != crate::mobs::gear::MAIN {
crate::mobs::gear::set_stack(&mut m, index, stack);
}
}
}
} else {
@@ -2155,6 +2178,7 @@ pub fn arrow_from_nbt(
p.velocity = motion(tag);
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.owner_uuid = owner;
let a = p.arrow.as_mut()?;
a.life = number(tag.get("life")).unwrap_or(0) as i16 as i32;
a.shake = (number(tag.get("shake")).unwrap_or(0) as i8 as u8).into();
@@ -2198,26 +2222,106 @@ pub fn fireball_to_nbt(
"acceleration_power",
Tag::Double(p.acceleration.to_bits()),
);
// Projectile: LeftOwner only when set; HasBeenShot once it has ticked
// (a fireball saved before its first tick keeps what it was loaded with).
if let Tag::Compound(map) = &mut tag {
map.remove(&"LeftOwner".into());
}
if p.left_owner {
put(&mut tag, "LeftOwner", Tag::Byte(1));
}
if p.age > 0 || tag.get("HasBeenShot").is_none() {
put(&mut tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
}
put_projectile(&mut tag, p, owner);
if let Some(power) = power {
put(&mut tag, "ExplosionPower", Tag::Byte(power as i8));
}
Some(tag)
}
/// `Projectile.addAdditionalSaveData`: Owner, LeftOwner only when set, and
/// HasBeenShot once it has ticked (one saved before its first tick keeps
/// what it was loaded with).
fn put_projectile(tag: &mut Tag, p: &Projectile, owner: Option<[i32; 4]>) {
if let Tag::Compound(map) = tag {
map.remove(&"LeftOwner".into());
}
if p.left_owner {
put(tag, "LeftOwner", Tag::Byte(1));
}
if p.age > 0 || tag.get("HasBeenShot").is_none() {
put(tag, "HasBeenShot", Tag::Byte(i8::from(p.age > 0)));
}
if let Some(owner) = owner {
put(&mut tag, "Owner", Tag::IntArray(owner.to_vec()));
put(tag, "Owner", Tag::IntArray(owner.to_vec()));
}
}
/// The thrown item projectiles Horizon saves: each entity type's id is also
/// the item it saves when none was loaded (`getDefaultItem`).
const THROWABLES: [(ProjectileKind, &str); 3] = [
(ProjectileKind::Snowball, "minecraft:snowball"),
(ProjectileKind::Egg, "minecraft:egg"),
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
];
fn throwable_item(kind: &ProjectileKind) -> Option<&'static str> {
THROWABLES
.iter()
.find(|(k, _)| k == kind)
.map(|&(_, id)| id)
}
/// Whether its chunk saves it: the kinds `arrow_to_nbt`, `fireball_to_nbt`
/// and [`throwable_to_nbt`] write.
pub fn saved_with_chunk(p: &Projectile) -> bool {
matches!(
p.kind,
ProjectileKind::LargeFireball { .. } | ProjectileKind::SmallFireball
) || (p.arrow.is_some() && matches!(p.kind, ProjectileKind::Arrow { .. }))
|| throwable_item(&p.kind).is_some()
}
/// `ThrowableItemProjectile.addAdditionalSaveData`: `Projectile`'s keys and
/// the `Item` it flies as.
pub fn throwable_to_nbt(
p: &Projectile,
uuid: [i32; 4],
owner: Option<[i32; 4]>,
raw: Option<&Tag>,
) -> Option<Tag> {
let item = throwable_item(&p.kind)?;
let base = Base {
id: p.kind.id(),
uuid,
position: p.position,
motion: p.velocity,
rotation: [p.yaw, p.pitch],
on_ground: false,
fall_distance: 0.0,
};
let mut tag = base.tag(raw);
put_projectile(&mut tag, p, owner);
// the loaded item stays (a blue egg's, a renamed one's).
if tag.get("Item").is_none() {
put(
&mut tag,
"Item",
item_to_nbt(&crate::items::ItemStack::new(item, 1)),
);
}
Some(tag)
}
/// `ThrowableItemProjectile.readAdditionalSaveData` for the kinds
/// [`throwable_to_nbt`] writes; the owner is the player when it is theirs.
pub fn throwable_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
let id = kind(tag)?;
let (kind, _) = THROWABLES.iter().find(|(_, i)| *i == id)?.clone();
let owner = owner_uuid(tag);
let from_player = player.is_some() && owner == player;
let mut p = Projectile::new(kind, position(tag)?, from_player.then_some(Who::Player));
p.velocity = motion(tag);
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.owner_uuid = owner;
Some(p)
}
/// `Projectile`'s `Owner`.
pub fn owner_uuid(tag: &Tag) -> Option<[i32; 4]> {
uuid_from_tag(tag.get("Owner"))
}
pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projectile> {
let kind = match kind(tag)?.as_str() {
"minecraft:fireball" => ProjectileKind::LargeFireball {
@@ -2234,6 +2338,7 @@ pub fn fireball_from_nbt(tag: &Tag, player: Option<[i32; 4]>) -> Option<Projecti
p.velocity = motion(tag);
[p.yaw, p.pitch] = rotation(tag);
p.left_owner = flag(tag, "LeftOwner");
p.owner_uuid = owner;
p.acceleration = float(tag.get("acceleration_power")).unwrap_or(0.1);
Some(p)
}
@@ -2498,6 +2603,7 @@ pub fn armor_stand_to_nbt(a: &ArmorStand, uuid: [i32; 4], raw: Option<&Tag>) ->
} else {
put(&mut tag, "equipment", Tag::compound(equipment));
}
put(&mut tag, "DisabledSlots", Tag::Int(a.disabled_slots));
for (key, value) in [
("Small", a.small),
("ShowArms", a.show_arms),
@@ -2572,6 +2678,7 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
}
a.small = flag(tag, "Small");
a.show_arms = flag(tag, "ShowArms");
a.disabled_slots = number(tag.get("DisabledSlots")).unwrap_or(0) as i32;
a.no_base_plate = flag(tag, "NoBasePlate");
a.invisible = flag(tag, "Invisible");
a.marker = flag(tag, "Marker");
@@ -4084,6 +4191,69 @@ mod fireball_nbt_tests {
assert_eq!(fireball_from_nbt(&tag, None).unwrap().kind, kind);
}
}
#[test]
fn thrown_item_projectiles_round_trip_their_flight_item_and_owner() {
let player = Some([5, 6, 7, 8]);
for (kind, id) in [
(ProjectileKind::Snowball, "minecraft:snowball"),
(ProjectileKind::Egg, "minecraft:egg"),
(ProjectileKind::EnderPearl, "minecraft:ender_pearl"),
] {
let mut p =
Projectile::new(kind.clone(), Vec3::new(1.5, 70.0, -2.5), Some(Who::Player));
(p.velocity, p.left_owner, p.age) = (Vec3::new(0.3, -0.1, 1.2), true, 4);
[p.yaw, p.pitch] = [30.0, -10.0];
let tag = throwable_to_nbt(&p, [1, 2, 3, 4], player, None).unwrap();
assert_eq!(super::kind(&tag).as_deref(), Some(id));
// ThrowableItemProjectile: the item it flies as, by its default.
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!((&*item.item, item.count), (id, 1));
assert_eq!(number(tag.get("HasBeenShot")), Some(1));
assert_eq!(uuid_from_tag(tag.get("Owner")), player);
let back = throwable_from_nbt(&tag, player).unwrap();
assert_eq!((back.kind, back.owner), (kind, Some(Who::Player)));
assert_eq!((back.position, back.velocity), (p.position, p.velocity));
assert_eq!(
([back.yaw, back.pitch], back.left_owner),
([30.0, -10.0], true)
);
// an owner who is not here keeps their UUID through a moved resave.
let mut away = throwable_from_nbt(&tag, Some([9; 4])).unwrap();
assert_eq!((away.owner, away.owner_uuid), (None, player));
away.position.y += 3.0;
let resaved = throwable_to_nbt(&away, [1; 4], away.owner_uuid, None).unwrap();
assert_eq!(uuid_from_tag(resaved.get("Owner")), player);
}
// the chunk saves arrows, fireballs and these, and so takes them with it.
let kinds = [
(ProjectileKind::SmallFireball, true),
(ProjectileKind::Snowball, true),
(ProjectileKind::WindCharge, false),
(ProjectileKind::Trident, false),
];
for (kind, saved) in kinds {
let p = Projectile::new(kind, Vec3::ZERO, None);
assert_eq!(saved_with_chunk(&p), saved, "{:?}", p.kind);
}
// a loaded item stays, and a new projectile leaves LeftOwner out.
let raw = Tag::compound([
("id", Tag::String("minecraft:egg".into())),
("Pos", doubles(&[0.0, 64.0, 0.0])),
(
"Item",
item_to_nbt(&crate::items::ItemStack::new("minecraft:blue_egg", 1)),
),
]);
let egg = throwable_from_nbt(&raw, None).unwrap();
let tag = throwable_to_nbt(&egg, [1; 4], None, Some(&raw)).unwrap();
let item = tag.get("Item").and_then(item_from_nbt).unwrap();
assert_eq!(&*item.item, "minecraft:blue_egg");
assert!(tag.get("LeftOwner").is_none() && tag.get("Owner").is_none());
assert_eq!(number(tag.get("HasBeenShot")), Some(0));
let wind = Projectile::new(ProjectileKind::WindCharge, Vec3::ZERO, None);
assert!(throwable_to_nbt(&wind, [1; 4], None, None).is_none());
}
}
#[cfg(test)]
@@ -4136,6 +4306,43 @@ mod neutral_anger_tests {
mod held_item_name_tests {
use super::*;
#[test]
fn a_worn_piece_keeps_its_damage_and_name_through_the_save() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../.local/horizon-client")
.join(horizon_core::VANILLA_VERSION);
if !root.join("items.json").exists() {
return;
}
let data = crate::mobs::MobTypes::load(&root).unwrap();
let types: HashMap<_, _> = data
.types
.into_iter()
.map(|(id, kind)| (id, Arc::new(kind)))
.collect();
let mut mob = Mob::new(1, Arc::clone(&types["minecraft:zombie"]), Vec3::ZERO, 0.0);
let mut helmet = crate::items::ItemStack::new("minecraft:iron_helmet", 1);
helmet.damage = 40;
helmet.data_mut().custom_name = Some(horizon_world::nbt::Tag::String("Hat".into()));
crate::mobs::finalize::set_item_stack(&mut mob, crate::mobs::finalize::HEAD, helmet);
let raw = mob_to_nbt(&mob, [1, 2, 3, 4], None, None);
let loaded = mob_from_nbt(&raw, &types, 2, None).unwrap();
let worn = crate::mobs::finalize::item_stack(&loaded, crate::mobs::finalize::HEAD).unwrap();
assert_eq!(worn.damage, 40);
assert_eq!(worn.custom_name().as_deref(), Some("Hat"));
// Replacing the item forgets the old stack.
let mut loaded = loaded;
crate::mobs::finalize::set_item(
&mut loaded,
crate::mobs::finalize::HEAD,
"minecraft:iron_helmet",
);
let fresh =
crate::mobs::finalize::item_stack(&loaded, crate::mobs::finalize::HEAD).unwrap();
assert_eq!(fresh.damage, 0);
assert!(fresh.custom_name().is_none());
}
#[test]
fn replacing_a_named_item_does_not_restore_its_old_name_on_save() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
+109
View File
@@ -150,6 +150,52 @@ impl FlatTerrain {
void_platform,
})
}
/// The settings of a superflat overworld that lets structure sets generate
/// (`FlatLevelSource.createState`): its layers, biome and `structure_overrides`
/// (no key means every set; `[]` none). `None` for anything else, a world with no
/// structure set to generate, the Void preset's biome (its platform is a feature
/// this generator places) or one that is not superflat.
pub fn structure_settings(
settings: &Tag,
) -> Option<horizon_world::worldgen::generator::FlatSettings> {
let generator = settings
.get("dimensions")?
.get("minecraft:overworld")?
.get("generator")?;
if crate::save::text(generator.get("type"))?.trim_start_matches("minecraft:") != "flat" {
return None;
}
let s = generator.get("settings")?;
let biome = crate::save::text(s.get("biome")).unwrap_or_else(|| "minecraft:plains".into());
if biome == "minecraft:the_void" {
return None;
}
let mut layers = Vec::new();
if let Some(Tag::List(list)) = s.get("layers") {
for layer in list {
let block = crate::save::text(layer.get("block"))?;
let height = crate::save::number(layer.get("height")).unwrap_or(0).max(0) as i32;
layers.push((block, height));
}
}
let structure_overrides = match s.get("structure_overrides") {
Some(Tag::List(sets)) => Some(
sets.iter()
.filter_map(|set| crate::save::text(Some(set)))
.collect::<Vec<_>>(),
),
Some(Tag::String(set)) => Some(vec![set.to_string_lossy()]),
_ => None,
};
if structure_overrides.as_ref().is_some_and(Vec::is_empty) {
return None;
}
Some(horizon_world::worldgen::generator::FlatSettings {
layers,
biome,
structure_overrides,
})
}
/// FlatLevelSource.getSpawnHeight: the top of the layers.
pub fn spawn_height(&self) -> i32 {
self.shape.min_y + self.layers.len().min(self.shape.height as usize) as i32
@@ -479,4 +525,67 @@ mod tests {
(None, vec!["Sum of layer heights is > 4064".into()])
);
}
/// `FlatLevelSource.createState`: the settings a flat world's structure sets run with.
#[test]
fn a_flat_world_names_the_structure_sets_that_may_generate() {
let world = |generator: Tag| {
Tag::compound([(
"dimensions",
Tag::compound([(
"minecraft:overworld",
Tag::compound([("generator", generator)]),
)]),
)])
};
let flat = |extra: Vec<(&str, Tag)>, biome: &str| {
let mut settings = vec![
("biome", Tag::String(biome.into())),
(
"layers",
Tag::List(vec![Tag::compound([
("block", Tag::String("minecraft:stone".into())),
("height", Tag::Int(3)),
])]),
),
];
settings.extend(extra);
world(Tag::compound([
("type", Tag::String("minecraft:flat".into())),
("settings", Tag::compound(settings)),
]))
};
// No key: every set. A list: those. An empty list: none. The void and a noise world: none.
let all = FlatTerrain::structure_settings(&flat(vec![], "minecraft:plains")).unwrap();
assert_eq!(all.structure_overrides, None);
assert_eq!(all.layers, [("minecraft:stone".to_owned(), 3)]);
let some = FlatTerrain::structure_settings(&flat(
vec![(
"structure_overrides",
Tag::List(vec![Tag::String("minecraft:villages".into())]),
)],
"minecraft:desert",
))
.unwrap();
assert_eq!(
some.structure_overrides,
Some(vec!["minecraft:villages".to_owned()])
);
assert_eq!(some.biome, "minecraft:desert");
assert!(
FlatTerrain::structure_settings(&flat(
vec![("structure_overrides", Tag::List(vec![]))],
"minecraft:plains"
))
.is_none()
);
assert!(FlatTerrain::structure_settings(&flat(vec![], "minecraft:the_void")).is_none());
assert!(
FlatTerrain::structure_settings(&world(Tag::compound([(
"type",
Tag::String("minecraft:noise".into())
)])))
.is_none()
);
}
}
File diff suppressed because it is too large. Load diff
@@ -457,6 +457,8 @@ impl Game<'_> {
EntityView {
kind: "minecraft:player".into(),
position: [p.position.x, p.position.y, p.position.z],
// Entity.isOnFire on a server: remainingFireTicks (players aren't fire immune).
on_fire: p.fire_ticks > 0,
sneaking: p.pose == horizon_client::pose::Pose::Crouching,
// Input.shift: the held key, as `BlockItem.place`'s sneaking reads it.
input_sneak: self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft),
+48 -9
View File
@@ -522,7 +522,9 @@ impl Game<'_> {
let slot = match &held {
Some(h) => {
let slot = self.equipment_slot(h);
if slot < 2 && !a.show_arms {
// ArmorStand.interactAt: a disabled slot, or a hand without
// arms, is FAIL.
if a.is_disabled(slot) {
return true;
}
slot
@@ -550,6 +552,18 @@ impl Game<'_> {
}
}
};
// An empty hand takes from the main hand when the clicked slot is
// disabled (`isDisabled(clickedSlot) ? slot : clickedSlot`).
let slot = if held.is_none() && a.is_disabled(slot) {
0
} else {
slot
};
// ArmorStand.swapItem: taking from a filled slot or putting into an
// empty one can be disabled; the click then passes.
if a.swap_locked(slot) {
return false;
}
let current = self.armor_stands[i].equipment[slot].clone();
if held.is_none() && current.is_none() {
return false;
@@ -722,6 +736,8 @@ impl Game<'_> {
// ServerLevel.tick: only entities in entity-ticking chunks tick.
let ticking = self.entity_ticking_area();
for (i, a) in self.armor_stands.iter_mut().enumerate() {
a.previous = a.position;
a.previous_yaw = a.yaw;
if !ticking(a.position.x.floor() as i32, a.position.z.floor() as i32) {
continue;
}
@@ -785,11 +801,10 @@ impl Game<'_> {
let dragon = a.equipment[5]
.as_ref()
.is_some_and(|i| &*i.item == "minecraft:dragon_head");
let position = super::entity_culling::at(a.position);
if !self.standing_entity_in_view(
camera,
position,
position,
super::entity_culling::at(a.previous),
super::entity_culling::at(a.position),
alpha,
width,
height,
@@ -798,7 +813,9 @@ impl Game<'_> {
) {
continue;
}
let p = glam::DVec3::new(a.position.x, a.position.y, a.position.z) - camera;
// EntityRenderer: drawn between last tick's position and this one's.
let at = a.render_position(alpha);
let p = super::entity_culling::at(at) - camera;
// setupRotations: 180 - body yaw, plus the wobble for 5 ticks after a hit.
let wiggle = (self.world.game_time - a.last_hit) as f32 + alpha as f32;
let wobble = if wiggle < 5.0 {
@@ -807,13 +824,15 @@ impl Game<'_> {
0.0
};
let base = glam::Mat4::from_translation(p.as_vec3())
* glam::Mat4::from_rotation_y((180.0 - a.yaw + wobble).to_radians())
* glam::Mat4::from_rotation_y(
(180.0 - a.render_yaw(alpha as f32) + wobble).to_radians(),
)
* glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.501, 0.0));
let light = self.world.light(
a.position.x.floor() as i32,
(a.position.y + 0.5).floor() as i32,
a.position.z.floor() as i32,
at.x.floor() as i32,
(at.y + 0.5).floor() as i32,
at.z.floor() as i32,
);
let packed = (u32::from(light >> 4) * 16) | ((u32::from(light & 15) * 16) << 8);
self.armor_stand_vertices(a, base, packed, out);
@@ -1322,6 +1341,26 @@ mod swap_tests {
ItemStack::new(item, count)
}
#[test]
fn disabled_slots_lock_by_filter_flag() {
let mut a = ArmorStand::new(1, horizon_core::geometry::Vec3::ZERO, 0.0);
// Hands need arms.
assert!(a.is_disabled(0) && a.is_disabled(1));
a.show_arms = true;
assert!(!a.is_disabled(0) && !a.is_disabled(1));
// Bit 4 locks the head (equipment index 5), bit 5 the off hand (1).
a.disabled_slots = (1 << 4) | (1 << 5);
assert!(a.is_disabled(5) && a.is_disabled(1));
assert!(!a.is_disabled(4) && !a.is_disabled(0));
// Taking (+8) from a filled slot and putting (+16) into an empty one.
a.disabled_slots = (1 << (3 + 8)) | (1 << (1 + 16));
assert!(!a.swap_locked(4), "an empty chest slot is not take-locked");
a.equipment[4] = Some(stack("minecraft:iron_chestplate", 1));
assert!(a.swap_locked(4));
assert!(a.swap_locked(2), "an empty feet slot is put-locked");
a.equipment[2] = Some(stack("minecraft:iron_boots", 1));
assert!(!a.swap_locked(2));
}
#[test]
fn creative_swap_gives_the_worn_piece_to_the_hand() {
let swap = swap_with_equipment_slot(
&stack("minecraft:diamond_chestplate", 1),
+360 -1
View File
@@ -14,7 +14,7 @@ use horizon_client::{
leash::{Holder, Pending},
riders,
},
motion::RiddenBoat,
motion::{BubbleColumn, RiddenBoat, bubble_column_at},
};
use horizon_core::{
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
@@ -48,6 +48,25 @@ pub(super) struct Boat {
water_level: f64,
land_friction: f32,
out_of_control: f32,
/// `AbstractBoat.DATA_ID_BUBBLE_TIME` (synched): the ticks of `BUBBLE_TIME`
/// left before a bubble column lifts or drags the boat; 0 when it is not
/// over one.
pub bubble_time: i32,
/// `AbstractBoat.isAboveBubbleColumn`, set by `onAboveBubbleColumn` and
/// cleared by `tickBubbleColumn` (the server's).
above_bubble_column: bool,
/// `AbstractBoat.bubbleColumnDirectionIsDown`.
bubble_down: bool,
/// A bubble column's drag asked a rider the server can't move itself (a
/// player) to get out: `guest_vehicle_tick` takes it (`ejectPassengers`).
pub eject_riders: bool,
/// `Entity.tickCount`, counted where the wobble is (a client's tick).
tick_count: i32,
/// `AbstractBoat.bubbleMultiplier`, `bubbleAngle` and `bubbleAngleO`
/// (a client's).
bubble_multiplier: f32,
bubble_angle: f32,
bubble_angle_old: f32,
pub hurt_time: i32,
pub hurt_dir: i32,
pub damage: f32,
@@ -72,6 +91,13 @@ pub(super) struct Boat {
}
/// `ChestBoat.CONTAINER_SIZE`.
pub(super) const CHEST_SLOTS: usize = 27;
/// `AbstractBoat.BUBBLE_TIME`: the ticks over a bubble column before it
/// lifts or drags the boat.
const BUBBLE_TIME: i32 = 60;
/// `EntityEvent` 71 and 72, which `AbstractBoat.tickBubbleColumn` broadcasts
/// for a down and an up column.
pub(super) const BUBBLE_DOWN_EVENT: i8 = 71;
pub(super) const BUBBLE_UP_EVENT: i8 = 72;
pub(super) const WIDTH: f64 = 1.375;
pub(super) const HEIGHT: f64 = 0.5625;
impl Boat {
@@ -90,6 +116,14 @@ impl Boat {
water_level: 0.0,
land_friction: 0.0,
out_of_control: 0.0,
bubble_time: 0,
above_bubble_column: false,
bubble_down: false,
eject_riders: false,
tick_count: 0,
bubble_multiplier: 0.0,
bubble_angle: 0.0,
bubble_angle_old: 0.0,
hurt_time: 0,
hurt_dir: 1,
damage: 0.0,
@@ -174,6 +208,42 @@ impl Boat {
yaw,
)
}
/// `AbstractBoat.getBubbleAngle`: the wobble, in degrees, `a` of the way
/// from the last tick's to this one's.
pub fn bubble_angle(&self, a: f32) -> f32 {
self.bubble_angle_old + a * (self.bubble_angle - self.bubble_angle_old)
}
/// `AbstractBoat.tickBubbleColumn`: on a client, the wobble grows by 0.05 a
/// tick while the synched bubble time runs and fades by 0.1 otherwise.
pub(super) fn tick_bubble_wobble(&mut self) {
self.tick_count += 1;
if self.bubble_time > 0 {
self.bubble_multiplier += 0.05;
} else {
self.bubble_multiplier -= 0.1;
}
self.bubble_multiplier = self.bubble_multiplier.clamp(0.0, 1.0);
self.bubble_angle_old = self.bubble_angle;
self.bubble_angle =
10.0 * (0.5 * f64::from(self.tick_count)).sin() as f32 * self.bubble_multiplier;
}
/// `AbstractBoat.tickBubbleColumn`'s countdown on the server: the bubble
/// time drops while the boat stays over a column and resets when it leaves.
/// `Some(down)` on the tick it runs out: the boat is thrown up (`false`) or
/// pulled down (`true`).
pub(super) fn tick_bubble_countdown(&mut self) -> Option<bool> {
if !self.above_bubble_column {
self.bubble_time = 0;
}
if self.bubble_time <= 0 {
return None;
}
self.bubble_time -= 1;
let diff = BUBBLE_TIME - self.bubble_time - 1;
let fired = (diff > 0 && self.bubble_time == 0).then_some(self.bubble_down);
self.above_bubble_column = false;
fired
}
/// `getRowingTime`.
pub fn rowing_time(&self, side: usize, a: f32) -> f32 {
if self.paddling[side] {
@@ -684,9 +754,194 @@ impl Game<'_> {
if self.boats[i].out_of_control >= 60.0 {
self.eject_boat_mobs(i, false);
}
self.boat_bubble_tick(i);
self.boat_contacts(i);
}
}
/// `Entity.canSimulateMovement`: for a boat (`AUTHORITATIVE_SIDE`) a
/// server leaves the boat its player drives to that player's client, and a
/// client moves only the boat its player drives.
fn boat_can_simulate_movement(&self, id: u32) -> bool {
if self.remote.is_some() {
self.riding == Some(id)
} else {
!self.guest_vehicles().contains(&id)
}
}
/// `AbstractBoat.tick`: `status = getStatus()` for a boat this window
/// does not tick (`tick_boat` sets it for the others): a client's boats
/// other than the one it drives, and a server's boats its players drive.
pub(super) fn refresh_boat_status(&mut self, i: usize) {
let b = &self.boats[i];
let mut probe = Boat::new(b.id, b.wood.clone(), b.position, b.yaw);
let status = self.boat_status(&mut probe);
self.boats[i].status = status;
}
/// `BubbleColumnBlock.entityInside`: the bubble columns boat `i` touches
/// (its box deflated by 1e-5 as `Entity.checkInsideBlocks` does), in x, y,
/// z order.
pub(super) fn boat_bubble_columns(&self, i: usize) -> Vec<BubbleColumn> {
let b = self.boats[i].bounds().deflate(Vec3::new(
f64::from(1.0e-5f32),
f64::from(1.0e-5f32),
f64::from(1.0e-5f32),
));
let (lo, hi) = (b.min_position(), b.max_position());
let mut columns = Vec::new();
for x in lo.x.floor() as i32..=(hi.x.ceil() as i32 - 1) {
for y in lo.y.floor() as i32..=(hi.y.ceil() as i32 - 1) {
for z in lo.z.floor() as i32..=(hi.z.ceil() as i32 - 1) {
if let Some(column) = bubble_column_at(
&self.world,
self.pack.assets.registry,
self.res.facts(),
self.collision,
BlockPos::new(x, y, z),
) {
columns.push(column);
}
}
}
}
columns
}
/// `Entity.handleOnInsideBubbleColumn`: a column under more water lifts
/// boat `i` up to 0.7 or drags it down to -0.3 (`AbstractBoat` does not
/// override `onInsideBubbleColumn`), applied by the column's own push below
/// the surface.
pub(super) fn boat_inside_bubble_column(&mut self, i: usize, column: BubbleColumn) {
if !self.boat_can_simulate_movement(self.boats[i].id) {
return;
}
column.push(&mut self.boats[i].velocity);
}
/// `AbstractBoat.onAboveBubbleColumn`: whether the splash comes up, one
/// time in a hundred for a boat that is not under water.
pub(super) fn boat_splash_roll(&mut self, i: usize) -> bool {
!self.boats[i].under_water() && self.world.random.next_int_bound(100).unwrap_or(1) == 0
}
/// `AbstractBoat.onAboveBubbleColumn`: the client's half after the roll,
/// the splash sound and particle at the boat.
pub(super) fn boat_bubble_splash(&mut self, i: usize) {
use horizon_client::particles::{ParticleOptions, ParticleType};
let p = self.boats[i].position;
let pitch = 0.8 + 0.4 * self.world.random.next_float();
self.audio.play(
"minecraft:entity.generic.splash",
Some([p.x, p.y, p.z]),
1.0,
pitch,
);
let x = p.x + f64::from(self.world.random.next_float());
let z = p.z + f64::from(self.world.random.next_float());
self.particles.add(
&ParticleOptions::simple(ParticleType::Splash),
[x, p.y + 0.7, z],
[0.0; 3],
);
}
/// `AbstractBoat.onAboveBubbleColumn`: on the server (this window
/// simulates the boat) the column is remembered, the countdown starts and
/// the splash's game event goes to the listeners (its sound and particle
/// are the client's).
fn boat_above_bubble_column(&mut self, i: usize, drag_down: bool) {
let b = &mut self.boats[i];
b.above_bubble_column = true;
b.bubble_down = drag_down;
if b.bubble_time == 0 {
b.bubble_time = BUBBLE_TIME;
}
if !self.boat_splash_roll(i) {
return;
}
// A window that is its own client hears and sees the splash too.
if self.options.execution_role.is_client() {
self.boat_bubble_splash(i);
}
let (id, p) = (self.boats[i].id, self.boats[i].position);
// Entity.gameEvent(SPLASH, getControllingPassenger()): the window's
// own player when it drives the boat.
let ctx = if self.riding == Some(id) && self.player.is_some() {
self.player_context(None)
} else {
self.who_context(None, None)
};
self.post_game_event("minecraft:splash", [p.x, p.y, p.z], ctx);
}
/// `AbstractBoat.handleBubbleColumnEffect`: an up column throws the boat
/// up at 0.6, a down column pulls it down by 0.7.
pub(super) fn handle_bubble_column_effect(&mut self, i: usize, drag_down: bool) {
if !self.boat_can_simulate_movement(self.boats[i].id) {
return;
}
let v = &mut self.boats[i].velocity;
if drag_down {
// movement.add(0.0, -0.7, 0.0)
v.y += -0.7;
} else {
v.y = 0.6;
}
}
/// `Entity.ejectPassengers`: for a down column the mobs get out here, this
/// window's player gets out, and a server's player is asked to
/// (`guest_vehicle_tick` lets it out through its client).
fn eject_boat_riders(&mut self, i: usize) {
self.eject_boat_mobs(i, false);
let id = self.boats[i].id;
if self.riding == Some(id) && self.player.is_some() {
self.dismount(i);
} else if self.guest_rides(id) {
self.boats[i].eject_riders = true;
}
}
/// `AbstractBoat.tickBubbleColumn`: on the server, a boat that stays over
/// a column for `BUBBLE_TIME` ticks is thrown up or pulled down (a down
/// column lets the riders out first), and every client is told.
fn tick_boat_bubble_column(&mut self, i: usize) {
if let Some(down) = self.boats[i].tick_bubble_countdown() {
let (id, p) = (self.boats[i].id, self.boats[i].position);
let event = if down {
BUBBLE_DOWN_EVENT
} else {
BUBBLE_UP_EVENT
};
if down {
self.eject_boat_riders(i);
}
self.lan_entity_event(id as i32, event, [p.x, p.y, p.z]);
self.handle_bubble_column_effect(i, down);
}
}
/// `AbstractBoat.tick`: the end of it on the server for boat `i`, the two
/// `applyEffectsFromBlocks` that reach bubble columns (the lava is
/// `boat_lava_tick`'s), then `tickBubbleColumn`; and the wobble when this
/// window is its own client.
fn boat_bubble_tick(&mut self, i: usize) {
let id = self.boats[i].id;
// The boat a server's player drives is not ticked here: its status is.
if self.guest_vehicles().contains(&id) {
self.refresh_boat_status(i);
}
// An ejection nobody took (its player left the boat first) is void.
if !self.guest_rides(id) {
self.boats[i].eject_riders = false;
}
let touches = self.boat_bubble_columns(i);
// AbstractBoat.tick calls applyEffectsFromBlocks twice.
for _ in 0..2 {
for &column in &touches {
if column.surface {
self.boat_above_bubble_column(i, column.drag);
} else {
self.boat_inside_bubble_column(i, column);
}
}
}
self.tick_boat_bubble_column(i);
if self.options.execution_role.is_client() {
self.boats[i].tick_bubble_wobble();
}
}
/// `LavaFluid.entityInside` (`lavaHurt`) from the two
/// `applyEffectsFromBlocks` calls in `AbstractBoat.tick`: twice 40
/// damage, so lava breaks a boat on its first tick inside, and the fire
@@ -1053,6 +1308,8 @@ impl Game<'_> {
let destroyed = creative || b.damage > 40.0;
if destroyed {
self.eject_boat_mobs(i, true);
let id = self.boats[i].id;
self.release_vehicle_container(id);
let b = self.boats.remove(i);
let at = [b.position.x, b.position.y, b.position.z];
// AbstractBoat.remove: a destroyed boat drops its lead.
@@ -1132,6 +1389,16 @@ impl Game<'_> {
(hurt.sin() * hurt * damage / 10.0 * b.hurt_dir as f32).to_radians(),
);
}
// AbstractBoatRenderer.extractRenderState: bubbleAngle.
let wobble = b.bubble_angle(a);
// AbstractBoatRenderer.submit: a boat above water wobbles in a
// bubble column, about the axis (1, 0, 1).
if !b.under_water() && wobble.abs() >= 1.0e-5 {
base *= glam::Mat4::from_axis_angle(
glam::Vec3::new(1.0, 0.0, 1.0).normalize(),
wobble * (std::f64::consts::PI / 180.0) as f32,
);
}
base *= glam::Mat4::from_scale(glam::Vec3::new(-1.0, -1.0, 1.0))
* glam::Mat4::from_rotation_y(90f32.to_radians());
let mut poses = Poses::new(model);
@@ -1186,3 +1453,95 @@ impl Game<'_> {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn boat() -> Boat {
Boat::new(1, Arc::from("oak"), Vec3::ZERO, 0.0)
}
/// `AbstractBoat.tickBubbleColumn` on a client: the wobble grows while the
/// bubble time runs and fades after.
#[test]
fn the_wobble_grows_while_the_bubble_time_runs_and_fades_after() {
let mut b = boat();
b.bubble_time = BUBBLE_TIME;
for _ in 0..30 {
b.tick_bubble_wobble();
}
// 0.05 a tick, capped at 1.
assert_eq!(b.bubble_multiplier, 1.0);
// 10 * sin(0.5 * tickCount) * multiplier.
let expected = 10.0 * (0.5 * 30.0f64).sin() as f32;
assert!((b.bubble_angle - expected).abs() < 1.0e-4);
// The render angle lerps between the last two ticks.
let mid = b.bubble_angle(0.5);
assert!((mid - (b.bubble_angle_old + b.bubble_angle) / 2.0).abs() < 1.0e-5);
// Once the bubble time is 0 the multiplier fades by 0.1 a tick.
b.bubble_time = 0;
for _ in 0..4 {
b.tick_bubble_wobble();
}
assert!((b.bubble_multiplier - 0.6).abs() < 1.0e-5);
for _ in 0..10 {
b.tick_bubble_wobble();
}
assert_eq!(b.bubble_multiplier, 0.0);
assert_eq!(b.bubble_angle, 0.0);
}
/// `AbstractBoat.tickBubbleColumn` on the server: a boat held over a
/// column fires on the 60th tick, with the column's direction, then the
/// time restarts.
#[test]
fn the_bubble_countdown_fires_every_sixty_ticks_over_a_column() {
for down in [false, true] {
let mut b = boat();
let mut fired = Vec::new();
for tick in 1..=130 {
// What boat_above_bubble_column does each tick.
b.above_bubble_column = true;
b.bubble_down = down;
if b.bubble_time == 0 {
b.bubble_time = BUBBLE_TIME;
}
if let Some(d) = b.tick_bubble_countdown() {
fired.push((tick, d));
}
assert!(!b.above_bubble_column);
}
assert_eq!(fired, vec![(60, down), (120, down)]);
}
}
/// Leaving the column resets the time: nothing fires, and a boat with no
/// time left does nothing over a column until the column sets it.
#[test]
fn leaving_the_column_resets_the_bubble_countdown() {
let mut b = boat();
b.bubble_time = BUBBLE_TIME;
for _ in 0..30 {
b.above_bubble_column = true;
assert_eq!(b.tick_bubble_countdown(), None);
}
assert_eq!(b.bubble_time, 30);
// A tick away from the column.
assert_eq!(b.tick_bubble_countdown(), None);
assert_eq!(b.bubble_time, 0);
b.above_bubble_column = true;
assert_eq!(b.tick_bubble_countdown(), None);
assert!(b.above_bubble_column);
}
#[test]
fn a_boat_that_never_saw_a_column_does_not_wobble() {
let mut b = boat();
for _ in 0..10 {
b.tick_bubble_wobble();
}
assert_eq!(b.bubble_angle, 0.0);
assert_eq!(b.bubble_angle(0.5), 0.0);
}
}
+1
View File
@@ -1432,6 +1432,7 @@ impl Game<'_> {
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.clone(),
size: None,
scaled: true,
});
self.ui.screenshot_message = Some(path);
}
+9 -15
View File
@@ -1228,21 +1228,11 @@ impl Game<'_> {
static RARITY: std::sync::OnceLock<std::collections::HashMap<String, String>> =
std::sync::OnceLock::new();
let map = RARITY.get_or_init(|| {
let v: serde_json::Value = serde_json::from_slice(
&std::fs::read(self.options.resources.join("items.json")).unwrap_or_default(),
)
.unwrap_or_default();
v.as_object()
.map(|o| {
o.iter()
.filter_map(|(k, c)| {
c["minecraft:rarity"]
.as_str()
.map(|r| (k.clone(), r.to_owned()))
})
.collect()
})
horizon_world::item_defaults::read(&self.options.resources)
.unwrap_or_default()
.into_iter()
.filter_map(|(k, c)| c["minecraft:rarity"].as_str().map(|r| (k, r.to_owned())))
.collect()
});
map.get(item).cloned().unwrap_or_else(|| "common".into())
}
@@ -1952,11 +1942,15 @@ impl Game<'_> {
Target::Boat(id) => {
// AbstractBoat.remove: a killed boat drops its lead.
if let Some(i) = self.boats.iter().position(|b| b.id == id) {
self.release_vehicle_container(id);
let b = self.boats.remove(i);
self.boat_removed(&b);
}
}
Target::Cart(id) => self.carts.retain(|c| c.id != id),
Target::Cart(id) => {
self.release_vehicle_container(id);
self.carts.retain(|c| c.id != id);
}
Target::Other(id) => self.others.retain(|o| o.id != id),
Target::Crystal(id) => self.kill_crystal(id),
Target::Dragon(id) => self.kill_dragon(id),
@@ -26,6 +26,8 @@ pub(in crate::game) enum Slot {
/// An armor stand's equipment (`ArmorStand::SLOTS` index).
Stand(u32, usize),
Frame(u32),
/// A chest or hopper minecart's slot (`ContainerEntity.getChestVehicleSlot`).
Cart(u32, usize),
Block([i32; 3], usize),
/// A slot of another player (`for_player`): reached with their state in place.
Guest(u32, Box<Slot>),
@@ -168,6 +170,19 @@ impl Game<'_> {
add(Slot::Frame(*id));
}
}
// `getChestVehicleSlot` takes any index below `getContainerSize`.
Owner::Entity(Target::Cart(id)) => {
let size = self
.carts
.iter()
.find(|c| c.id == *id)
.map_or(0, |c| c.kind.slots());
for i in 0..size {
if matches_name(name, &format!("container.{i}")) {
add(Slot::Cart(*id, i));
}
}
}
Owner::Entity(_) => {}
}
out
@@ -209,6 +224,7 @@ impl Game<'_> {
.cloned()
.flatten(),
Slot::Frame(id) => self.item_frames.iter().find(|f| f.id == *id)?.item.clone(),
Slot::Cart(id, i) => self.cart_slots(*id)?.get(*i).cloned().flatten(),
Slot::Block(p, i) => match self.world.block_entities.get(&(p[0], p[1], p[2])) {
Some(horizon_client::containers::BlockEntity::Chest(items)) => {
items.get(*i).cloned().flatten()
@@ -296,6 +312,13 @@ impl Game<'_> {
}
None => false,
},
Slot::Cart(id, i) => match self.cart_slots(*id).and_then(|c| c.get_mut(*i)) {
Some(slot) => {
*slot = stack;
true
}
None => false,
},
Slot::Block(p, i) => match self.world.block_entities.get_mut(&(p[0], p[1], p[2])) {
Some(horizon_client::containers::BlockEntity::Chest(items)) => {
match items.get_mut(*i) {
@@ -61,7 +61,11 @@ impl Game<'_> {
.unwrap_or(Target::Player)
}
/// `PlayerList.getPlayerByName`: names are not case sensitive.
/// `PlayerList.getPlayerByName`: names are not case sensitive. Horizon:
/// on a server where a mod gave players nicknames
/// (`native_set_nickname`), a name no player has finds the player going
/// by it, without its `~` or with it (quoted), so `/msg` and `/tell`
/// reach a player by the name chat shows. No nickname, no change.
pub(super) fn player_named(&self, name: &str) -> Option<Target> {
if self.player.is_some() && name.eq_ignore_ascii_case(self.acting_player_name()) {
return Some(Target::Player);
@@ -69,6 +73,21 @@ impl Game<'_> {
self.command_players()
.find(|g| g.name.eq_ignore_ascii_case(name))
.map(|g| Target::Guest(g.conn))
.or_else(|| self.player_nicknamed(name))
}
/// The player whose nickname is `name` (any case, a leading `~` either
/// side ignored).
fn player_nicknamed(&self, name: &str) -> Option<Target> {
let bare = |s: &str| s.strip_prefix('~').unwrap_or(s).to_ascii_lowercase();
let wanted = bare(name);
let hub = &self.lan.as_ref()?.hub;
let uuid = hub
.nicknames
.iter()
.find(|(_, nick)| bare(nick) == wanted)?
.0;
self.target_by_uuid(*uuid)
}
/// `EntitySelector.findEntities` with a UUID: the player with it, else
+10
View File
@@ -644,6 +644,7 @@ impl Game<'_> {
renderer.screenshot = Some(horizon_client::gfx::ScreenshotRequest {
path: path.trim().into(),
size: None,
scaled: false,
});
self.pending_screenshot = Some(reply.clone());
Ok(None)
@@ -895,6 +896,15 @@ impl Game<'_> {
);
ok()
}
// A splitscreen player leaves splitscreen; only P1's game ends the process.
"quit" if self.options.split_guest => {
if self.remote.is_some() {
self.leave_server(None)?;
} else if let Some(title) = self.title.as_mut() {
title.escape();
}
ok()
}
"quit" => {
self.begin_closing_save(true)?;
ok()
+137 -22
View File
@@ -89,6 +89,12 @@ pub(super) struct ProjWorld<'a, 'w> {
pub targets: Vec<(Who, Aabb)>,
}
impl ProjectileWorld for ProjWorld<'_, '_> {
fn block_contacts(&self, entity: i32, kind: &str, from: Vec3, to: Vec3, bounds: Aabb) {
horizon_client::block_contact::swept_inside(entity, kind, from, to, bounds, |p| {
let state = self.w.world.block(p.x, p.y, p.z);
(state, &self.w.facts.get(state).name)
});
}
fn clip(&self, from: Vec3, to: Vec3) -> Option<(Vec3, [i32; 3], [i32; 3])> {
let mut scene = SceneCollisions {
world: self.w.world,
@@ -1201,7 +1207,13 @@ impl Game<'_> {
self.villager_murdered(m, slain_by_player, killer.flatten());
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
let killer_entity = self.killer_loot_entity(killer.flatten());
self.drop_death_loot(&mut dying, by_player, killer_entity, consumed, pos);
// LAST_DAMAGE_PLAYER only while the player-hurt memory lasts
// (LivingEntity.dropFromLootTable's playerKilled): a mob that dies to
// the environment long after a player's hit names no player.
let player = killer
.filter(|_| m.ai.last_hurt_by_player > 0)
.and_then(|conn| self.killer_uuid(conn));
self.drop_death_loot(&mut dying, player, by_player, killer_entity, consumed, pos);
self.create_wither_rose(m);
if m.species() == Species::CopperGolem {
// CopperGolem.dropEquipment: the preserved equipment too.
@@ -1318,9 +1330,26 @@ impl Game<'_> {
}
own(self)
}
/// The UUID of the player `last_hurt_by_player` keeps: the guest on
/// connection `conn` (None once they left), else the player in place
/// (None on a server, which has none of its own).
fn killer_uuid(&mut self, conn: Option<u32>) -> Option<u128> {
match conn {
// During that guest's own turn, the hub is out of `lan`.
Some(conn) if self.lan_acting != Some(conn) => {
Some(self.lan.as_ref()?.hub.guest(conn)?.uuid)
}
Some(_) => Some(self.native_acting_id()),
None if self.lan_acting.is_none() && self.player.is_some() => {
Some(self.native_acting_id())
}
None => None,
}
}
fn drop_death_loot(
&mut self,
m: &mut Mob,
player_uuid: Option<u128>,
by_player: bool,
killer: horizon_world::loot::LootEntity,
consumed: bool,
@@ -1403,22 +1432,48 @@ impl Game<'_> {
}
}
let raider = horizon_client::mobs::raider::is_raider(m.species());
// The item drops come before the carried block and the orbs, so the
// mods get them all at once and they still spawn in that order.
let mut stacks = Vec::new();
let mut rest = Vec::new();
for d in items {
match d {
drops::Drop::Item(stack) => {
let item = if raider && stack.item == horizon_client::mobs::raider::BANNER {
drops::Drop::Item(stack) => stacks.push(
if raider && stack.item == horizon_client::mobs::raider::BANNER {
horizon_client::mobs::raider::ominous_banner()
} else {
from_loot(&stack)
};
// Every death drop is its own ItemEntity, and the drop
// entity's own random (not the level's) gives it the
// constructor's horizontal push (its motion otherwise
// stays [0, 0.2, 0] and the drops land stacked at the
// mob's centre, without disturbing the level random that
// the orbs' merge ids and block ticks read next).
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
}
},
),
drops::Drop::Stack(stack) => stacks.push(stack),
other => rest.push(other),
}
}
let kind = super::native_hooks::mob_kind(m);
let table = drops::death_loot_table(m, death.mob_drops).filter(|t| loot.has_table(t));
let context = super::native_hooks::NativeLootDrops {
table: table.as_deref().unwrap_or_default(),
dimension: self.dim.id(),
origin: [m.position.x, m.position.y, m.position.z],
block: None,
entity: Some((m.id, &kind)),
player: player_uuid,
tool: None,
explosion: None,
};
self.native_loot_drops(&context, &mut stacks);
stacks.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
for item in stacks {
// Every death drop is its own ItemEntity, and the drop entity's
// own random (not the level's) gives it the constructor's
// horizontal push (its motion otherwise stays [0, 0.2, 0] and
// the drops land stacked at the mob's centre, without disturbing
// the level random that the orbs' merge ids and block ticks read
// next).
self.spawn_item(item, pos, dispenser::new_item_motion(), 10);
}
for d in rest {
match d {
drops::Drop::CarriedBlock(state) => {
// Enderman.dropCustomDeathLoot: spawnAtLocation for each
// of the carried block's silk-touch drops.
@@ -1434,7 +1489,7 @@ impl Game<'_> {
}
}
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
drops::Drop::Orb(_) => {}
drops::Drop::Item(_) | drops::Drop::Stack(_) | drops::Drop::Orb(_) => {}
}
}
}
@@ -1599,6 +1654,15 @@ impl Game<'_> {
self.next_entity += 1;
p.id = self.next_entity;
}
// Projectile.setOwner: the acting player's or the mob's UUID, which
// its save keeps even once the owner is gone.
if p.owner_uuid.is_none() {
p.owner_uuid = match p.owner {
Some(Who::Player) => Some(self.acting_player_uuid()),
Some(Who::Mob(id)) => Some(self.uuid_for(id)),
None => None,
};
}
self.creatures.projectiles.push(p);
}
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
@@ -2218,13 +2282,14 @@ impl Game<'_> {
self.destroy_particles(pos, old);
}
if drop {
let drops = world_items!(self).block_drops_in(
let mut drops = world_items!(self).block_drops_in(
self.pack.assets.registry,
old,
None,
&mut self.world.random,
Some((&mut self.rand_sequences, self.level.seed)),
);
self.native_block_drops(pos, old, None, None, None, &mut drops);
for d in drops {
self.pop_resource(pos, d);
}
@@ -3096,7 +3161,7 @@ impl Game<'_> {
});
}
if raised {
self.after_block(outcome, amount, attacker, projectile);
self.after_block(outcome, attacker, projectile);
}
outcome.hurt
}
@@ -3411,9 +3476,10 @@ impl Game<'_> {
} = hit
&& let Some(j) = wind_charge_hit(id)
{
let (owner, guest) = (p.owner, p.guest);
let (owner, owner_uuid, guest) = (p.owner, p.owner_uuid, p.guest);
let look = self.owner_look(owner, guest);
super::deflect::aim_deflect(&mut projectiles[j], look, owner, guest);
let wind = &mut projectiles[j];
super::deflect::aim_deflect(wind, look, owner, owner_uuid, guest);
}
}
let mut idx = 0;
@@ -3564,6 +3630,7 @@ impl Game<'_> {
ProjectileKind::Arrow { .. }
| ProjectileKind::Trident
| ProjectileKind::Snowball
| ProjectileKind::Thrown(_)
| ProjectileKind::Egg
| ProjectileKind::SmallFireball
| ProjectileKind::LargeFireball { .. }
@@ -3860,6 +3927,7 @@ impl Game<'_> {
let amount = match p.kind {
ProjectileKind::SmallFireball => 5.0,
ProjectileKind::LargeFireball { .. } => 6.0,
ProjectileKind::Thrown(t) => t.damage,
_ => 0.0,
};
let kind = match p.kind {
@@ -3880,6 +3948,7 @@ impl Game<'_> {
ProjectileKind::EnderPearl
| ProjectileKind::Egg
| ProjectileKind::Snowball
| ProjectileKind::Thrown(_)
| ProjectileKind::LargeFireball { .. }
) {
return Ok(());
@@ -4133,6 +4202,47 @@ impl Game<'_> {
self.thrown_break_event(p.id, atp);
self.item_burst(atp, "minecraft:snowball");
}
// a mod's copy of Snowball: its damage to anything it hits.
(ProjectileKind::Thrown(t), hit) => {
match hit {
Hit::Entity {
who: Who::Mob(id), ..
} => {
let push = [p.velocity.x, p.velocity.z];
let hurt = self
.mobs
.iter_mut()
.find(|m| m.id == id)
.is_some_and(|m| m.damage_projectile(t.damage, source, aim, push));
if hurt {
self.player_hurt_mob(p, id);
}
// LivingEntity.hurtServer: PLAYER_HURT_ENTITY.
if hurt
&& p.owner == Some(Who::Player)
&& let Some(m) = self.mobs.iter().find(|m| m.id == id)
{
let victim = self.mob_entity(m);
let direct = horizon_client::advancements::EntityView {
kind: t.id.into(),
position: [p.position.x, p.position.y, p.position.z],
..Default::default()
};
self.player_hit(&victim, "minecraft:thrown", Some(direct), t.damage);
}
}
// Player.hurtServer takes no 0 damage.
Hit::Entity {
who: Who::Player, ..
} if t.damage > 0.0 => {
let from = [p.previous.x, p.previous.y, p.previous.z];
self.mob_hits_player(t.damage, from, "minecraft:thrown");
}
_ => {}
}
self.thrown_break_event(p.id, atp);
self.item_burst(atp, t.item);
}
(ProjectileKind::Egg, hit) => {
if let Hit::Entity {
who: Who::Mob(id), ..
@@ -4450,7 +4560,7 @@ impl Game<'_> {
/// client entity's random (the particle engine's here) for an egg.
pub(super) fn item_burst(&mut self, at: [f64; 3], item: &str) {
let options = self.item_particle(&horizon_client::items::ItemStack::new(item, 1));
let egg = item != "minecraft:snowball";
let egg = item == "minecraft:egg";
for _ in 0..8 {
let v = if egg {
let r = self.particles.random();
@@ -5450,7 +5560,10 @@ impl Game<'_> {
return Ok(thrown);
}
_ if item.ends_with("_spawn_egg") => return self.use_spawn_egg(stack),
_ => return Ok(false),
_ => match horizon_client::projectiles::thrown_by_item(item) {
Some(t) => ProjectileKind::Thrown(t),
None => return Ok(false),
},
};
let Some(eye) = self.eye_position() else {
return Ok(false);
@@ -5480,7 +5593,9 @@ impl Game<'_> {
// second is the `use_cooldown` component, as for any thrown item.
self.apply_use_cooldown(stack);
let sound = match kind {
ProjectileKind::Snowball => "minecraft:entity.snowball.throw",
ProjectileKind::Snowball | ProjectileKind::Thrown(_) => {
"minecraft:entity.snowball.throw"
}
ProjectileKind::Egg => "minecraft:entity.egg.throw",
_ => "minecraft:entity.ender_pearl.throw",
};
@@ -6080,8 +6195,8 @@ impl Game<'_> {
}
continue;
}
if let Some(stack) = a.potion_item() {
let owner = super::item_draw::owner_at(at, 0.0, "minecraft:splash_potion");
if let Some(stack) = a.drawn_item() {
let owner = super::item_draw::owner_at(at, 0.0, a.kind.id());
let state = self.resolve_item(&stack, DisplayContext::Ground, Some(&owner), 0);
let pose = glam::Mat4::from_translation(c)
* glam::Mat4::from_quat(self.camera_orientation());
+44 -1
View File
@@ -374,7 +374,7 @@ impl Game<'_> {
let p = c.position;
self.world.modified.insert((c.pos[0] >> 4, c.pos[2] >> 4));
if self.riding == Some(c.id) {
self.riding = None;
self.dismount_removed_vehicle(p.y);
}
self.audio.play(
"minecraft:entity.cushion.break",
@@ -468,6 +468,49 @@ impl Game<'_> {
);
}
/// `LivingEntity.dismountVehicle` from a removed vehicle (a broken
/// cushion) whose position was at `vehicle_y`: up to the higher of the
/// rider's and the vehicle's heights, then the free spot nearest there
/// (`findFreePosition` within the rider's own box). No get-up sound:
/// `Cushion.removePassenger` plays none once the cushion is removed.
pub(super) fn dismount_removed_vehicle(&mut self, vehicle_y: f64) {
self.riding = None;
let border = self.border_bounds();
let b = self.res.behaviours();
let Some(p) = self.player.as_ref() else {
return;
};
let (w, h) = (
f64::from(p.dimensions.width),
f64::from(p.dimensions.height),
);
let mut at = Vec3::new(p.position.x, p.position.y.max(vehicle_y), p.position.z);
// `getBbWidth() <= 4 && getBbHeight() <= 4`.
if w <= 4.0 && h <= 4.0 {
let center = at.add_vector(Vec3::new(0.0, h / 2.0, 0.0));
let size = Vec3::new(w, h, w);
let free = horizon_world::level::portal_travel::find_free_position(
Aabb::of_size(center, size),
center,
size,
&border,
|q| {
b.collision_shape(self.world.block(q.x, q.y, q.z))
.map(|s| s.moved(Vec3::new(f64::from(q.x), f64::from(q.y), f64::from(q.z))))
},
);
if let Some(free) = free {
at = free.add_vector(Vec3::new(0.0, -h / 2.0, 0.0));
}
}
if let Some(p) = self.player.as_mut() {
self.previous_position = p.position;
p.position = at;
p.velocity = Vec3::ZERO;
p.fall_distance = 0.0;
}
}
/// `Player.rideTick` on a cushion: hold the seat, and get up on sneak.
pub(super) fn tick_cushion_riding(&mut self, keys: super::Keys) -> bool {
let Some(c) = self
+27 -5
View File
@@ -954,6 +954,20 @@ impl Game<'_> {
let p = c.position;
g.entity_box(min, max, [p.x, p.y, p.z], 0.2125, false, (0.0, c.yaw));
}
// Interaction: EntityDimensions.scalable(width, height), so its eyes
// are at 0.85 of the height. Its rotation is not kept here.
for i in self.interactions.values() {
let b = i.bounds();
let p = i.position;
g.entity_box(
[b.min(Axis::X), b.min(Axis::Y), b.min(Axis::Z)],
[b.max(Axis::X), b.max(Axis::Y), b.max(Axis::Z)],
[p.x, p.y, p.z],
i.height.max(0.0) * 0.85,
false,
(0.0, 0.0),
);
}
for a in self.armor_stands.iter().filter(|a| !a.invisible) {
// ArmorStand.getDimensions: a marker has none, a small one is
// half scale with its eyes at 0.9875.
@@ -962,8 +976,13 @@ impl Game<'_> {
(_, true) => ((0.25, 0.9875), 0.9875),
_ => ((0.5, 1.975), 1.7775),
};
let p = a.position;
g.hitbox([p.x, p.y, p.z], size, eye, true, (0.0, a.yaw));
g.hitbox(
at(a.previous, a.position),
size,
eye,
true,
(0.0, a.render_yaw(alpha)),
);
}
for b in &self.boats {
let h = super::boats::HEIGHT as f32;
@@ -1023,9 +1042,12 @@ impl Game<'_> {
use horizon_client::projectiles::ProjectileKind as K;
let (size, eye) = match p.kind {
K::Arrow { .. } | K::Trident => (0.5, 0.13),
K::Snowball | K::Egg | K::EnderPearl | K::LlamaSpit | K::Potion { .. } => {
(0.25, 0.2125)
}
K::Snowball
| K::Egg
| K::EnderPearl
| K::LlamaSpit
| K::Potion { .. }
| K::Thrown(_) => (0.25, 0.2125),
K::SmallFireball | K::ShulkerBullet { .. } | K::WitherSkull { .. } => {
(0.3125, 0.265_625)
}
@@ -1720,6 +1720,55 @@ impl Game<'_> {
}
}
/// The anticheat bans a player (`HostEvent::Ban`): `banned-players.json` gets it as `/ban` adds it,
/// with the anticheat as its source, and the player is sent away with the ban's message.
pub(super) fn anticheat_ban(
&mut self,
hub: &mut horizon_client::lan::hub::Hub,
uuid: u128,
name: &str,
reason: &str,
) {
let Some(lists) = &self.server_lists else {
// No ban list to add to: the anticheat's kick, so a flagged cheater still doesn't stay.
log(
"WARN",
&format!("Can't ban {name} ({reason}): no ban list; kicked"),
);
let conns: Vec<u32> = hub
.guests
.iter()
.filter(|g| g.uuid == uuid)
.map(|g| g.conn)
.collect();
for conn in conns {
hub.anticheat_kick(conn);
}
return;
};
let user = NameAndId {
id: uuid,
name: name.to_owned(),
};
lists.lock().unwrap().bans.add(BanEntry::new(
user,
Some("Horizon anticheat".into()),
None,
Some(reason.to_owned()),
));
log("INFO", &format!("Banned {name}: {reason}"));
let conns: Vec<u32> = hub
.guests
.iter()
.filter(|g| g.uuid == uuid)
.map(|g| g.conn)
.collect();
for conn in conns {
hub.kick(conn, translatable("multiplayer.disconnect.banned", &[]));
}
events::record("ban", name, uuid, serde_json::json!({ "reason": reason }));
}
pub(super) fn kick_uuid(&mut self, uuid: u128, reason: Tag) {
if let Some(h) = self.lan.as_mut() {
let conns: Vec<u32> = h
@@ -1,6 +1,6 @@
//! The management protocol's list edits, which `horizon-server` forwards on the control endpoint as
//! `management <connection> {"method", "entries"}` (its management_methods.rs): vanilla 26.3's
//! `AllowlistService` and `BanlistService` on the running server's lists. Players are found as
//! `AllowlistService`, `BanlistService`, `IpBanlistService` and `OperatorService` on the running server's lists. Players are found as
//! `MinecraftPlayerListService.getUser` finds them, each change is logged as `JsonRpcLogger` logs it, and the
//! players a change shuts out are disconnected. The supervisor answers with the lists, from their files.
use super::{ServerState, log};
@@ -8,7 +8,7 @@ use crate::game::Game;
use horizon_client::{
server::{
profiles,
user_lists::{BanEntry, NameAndId, WhiteEntry},
user_lists::{BanEntry, NameAndId, OpEntry, WhiteEntry},
},
text::Text,
};
@@ -17,6 +17,16 @@ use serde_json::{Value, json};
/// A `UserBan`: the player, the reason, the source, the end.
type Ban = BanEntry<NameAndId>;
/// An `IpBan`: the address, the reason, the source, the end.
type IpBan = BanEntry<String>;
/// `OperatorService.Op`: the player and the level and bypass asked for, which a stored operator always has.
#[derive(PartialEq)]
struct Op {
user: NameAndId,
level: Option<i32>,
bypass: Option<bool>,
}
/// `NameAndId`'s record `toString`, as vanilla's log lines show it.
fn shown(user: &NameAndId) -> String {
@@ -122,6 +132,104 @@ impl Game<'_> {
}
// MinecraftBanListServiceImpl.clearUserBans logs nothing.
"bans/clear" => lists.lock().unwrap().bans.clear(),
"ip_bans/add" => {
for entry in &entries {
// An address, else the address of the player named, if online.
let ip = entry["ip"]
.as_str()
.filter(|ip| ip.parse::<std::net::IpAddr>().is_ok());
let ip = ip.map(str::to_owned).or_else(|| {
let player = entry.get("player")?;
self.management_online(player).map(|(_, ip)| ip)
});
if let Some(ip) = ip {
self.management_ip_ban(&rpc, state, management_ip_ban(entry, ip));
}
}
}
"ip_bans/remove" => {
for ip in entries.iter().filter_map(|e| e["ip"].as_str()) {
rpc(format!("Remove ip '{ip}' from ban list"));
lists.lock().unwrap().ip_bans.remove(&ip.to_owned());
}
}
"ip_bans/set" => {
let wanted: Vec<IpBan> = entries
.iter()
.filter_map(|e| {
let ip = e["ip"]
.as_str()
.filter(|ip| ip.parse::<std::net::IpAddr>().is_ok())?;
Some(management_ip_ban(e, ip.to_owned()))
})
.collect();
let current: Vec<IpBan> = lists
.lock()
.unwrap()
.ip_bans
.entries()
.into_iter()
.cloned()
.collect();
for ban in current.iter().filter(|b| !wanted.contains(b)) {
rpc(format!("Remove ip '{}' from ban list", ban.user));
lists.lock().unwrap().ip_bans.remove(&ban.user);
}
for ban in wanted.into_iter().filter(|b| !current.contains(b)) {
self.management_ip_ban(&rpc, state, ban);
}
}
"ip_bans/clear" => {
rpc("Clear ip ban list".into());
lists.lock().unwrap().ip_bans.clear();
}
"operators/add" => {
let ops: Vec<Op> = entries
.iter()
.filter_map(|e| self.management_op(e))
.collect();
for op in ops {
self.management_add_op(&rpc, state, op);
}
}
"operators/remove" => {
for user in entries.iter().filter_map(|e| self.management_user(e)) {
rpc(format!("Deop '{}'", shown(&user)));
lists.lock().unwrap().ops.remove(&user);
}
self.sync_ops(&lists);
}
"operators/set" => {
let wanted: Vec<Op> = entries
.iter()
.filter_map(|e| self.management_op(e))
.collect();
let current: Vec<Op> = lists
.lock()
.unwrap()
.ops
.entries()
.iter()
.map(|e| Op {
user: e.user.clone(),
level: Some(e.level),
bypass: Some(e.bypasses_player_limit),
})
.collect();
for op in current.iter().filter(|o| !wanted.contains(o)) {
rpc(format!("Deop '{}'", shown(&op.user)));
lists.lock().unwrap().ops.remove(&op.user);
}
for op in wanted.into_iter().filter(|o| !current.contains(o)) {
self.management_add_op(&rpc, state, op);
}
self.sync_ops(&lists);
}
"operators/clear" => {
rpc("Clear operator list".into());
lists.lock().unwrap().ops.clear();
self.sync_ops(&lists);
}
other => return json!({ "error": format!("unknown management method {other:?}") }),
}
json!({ "ok": true })
@@ -141,6 +249,72 @@ impl Game<'_> {
self.kick_uuid(id, translatable("multiplayer.disconnect.banned", &[]));
}
/// `addIpBan`, then whoever is online from that address is sent away.
fn management_ip_ban(&mut self, rpc: &impl Fn(String), state: &ServerState, ban: IpBan) {
rpc(format!("Add ip '{}' to ban list", ban.user));
let ip = ban.user.clone();
state.lists.lock().unwrap().ip_bans.add(ban);
let banned: Vec<u128> = self.lan.as_ref().map_or_else(Vec::new, |h| {
h.hub
.guests
.iter()
.filter(|g| g.address.ip().to_string() == ip)
.map(|g| g.uuid)
.collect()
});
for uuid in banned {
self.kick_uuid(uuid, translatable("multiplayer.disconnect.ip_banned", &[]));
}
}
/// `PlayerList.op`: the level asked for, else `op-permission-level`; the bypass asked for, else the one
/// the player had. The commands an online player may run follow (`sync_ops`, after the edit).
fn management_add_op(&mut self, rpc: &impl Fn(String), state: &ServerState, op: Op) {
rpc(format!("Op '{}'", shown(&op.user)));
let mut lists = state.lists.lock().unwrap();
let had = lists
.ops
.get(&op.user)
.is_some_and(|e| e.bypasses_player_limit);
let entry = OpEntry {
level: op.level.unwrap_or(lists.op_level),
bypasses_player_limit: op.bypass.unwrap_or(had),
user: op.user,
};
lists.ops.add(entry);
drop(lists);
self.sync_ops(&state.lists);
}
/// An `OperatorDto`: the player found, and the level and bypass when given.
fn management_op(&self, entry: &Value) -> Option<Op> {
Some(Op {
user: self.management_user(&entry["player"])?,
level: entry["permissionLevel"].as_i64().map(|l| l as i32),
bypass: entry["bypassesPlayerLimit"].as_bool(),
})
}
/// `PlayerListService.getPlayer(id, name)`: an online player, by id first; their address.
fn management_online(&self, player: &Value) -> Option<(u128, String)> {
let id = player["id"]
.as_str()
.and_then(|s| u128::from_str_radix(s, 16).ok());
let name = player["name"].as_str();
let guest = self
.lan
.as_ref()?
.hub
.guests
.iter()
.find(|g| match (id, name) {
(Some(id), _) => g.uuid == id,
(None, Some(name)) => g.name.eq_ignore_ascii_case(name),
(None, None) => false,
})?;
Some((guest.uuid, guest.address.ip().to_string()))
}
/// `UserBanDto.toUserBan`: the player found, the source "Management server" unless one is given; the ban
/// starts now.
fn management_ban(&self, entry: &Value) -> Option<Ban> {
@@ -172,3 +346,15 @@ impl Game<'_> {
self.resolve_name(player["name"].as_str()?)
}
}
/// `IpBan`: the reason, the source "Management server" unless one is given, the end; it starts now.
fn management_ip_ban(entry: &Value, ip: String) -> IpBan {
let source = entry["source"].as_str().unwrap_or("Management server");
let reason = entry["reason"].as_str().map(str::to_owned);
BanEntry::new(
ip,
Some(source.to_owned()),
entry["expires"].as_i64(),
reason,
)
}
+9 -8
View File
@@ -34,14 +34,14 @@ pub(super) fn aim_deflect(
p: &mut Projectile,
look: Option<Vec3>,
owner: Option<Who>,
owner_uuid: Option<[i32; 4]>,
guest: Option<u32>,
) {
if let Some(look) = look {
p.velocity = look;
p.needs_sync = true;
}
p.owner = owner;
p.guest = guest;
(p.owner, p.owner_uuid, p.guest) = (owner, owner_uuid, guest);
p.acceleration = 0.1;
}
@@ -196,14 +196,14 @@ impl Game<'_> {
/// `Projectile.deflect(AIM_DEFLECT, player, player, true)`.
pub(super) fn deflect(&mut self, i: usize) {
let dir = view_vector(self.yaw, self.pitch);
let guest = self.lan_acting;
let (guest, owner_uuid) = (self.lan_acting, Some(self.acting_player_uuid()));
let p = &mut self.creatures.projectiles[i];
p.velocity = Vec3::new(dir[0], dir[1], dir[2]);
p.yaw = dir[0].atan2(dir[2]).to_degrees() as f32;
p.pitch = dir[1].atan2(dir[0].hypot(dir[2])).to_degrees() as f32;
p.owner = Some(Who::Player);
// On a server, the guest who struck it (their projectile now).
p.guest = guest;
(p.guest, p.owner_uuid) = (guest, owner_uuid);
// Not into the one who turned it on its way out.
p.left_owner = false;
// `AbstractHurtingProjectile.onDeflection` by an attack, and the
@@ -274,9 +274,9 @@ mod tests {
fn a_redirected_wind_charge_flies_along_the_new_owners_look() {
let mut p = wind_charge();
let look = Vec3::new(0.0, -1.0, 0.0);
aim_deflect(&mut p, Some(look), Some(Who::Player), Some(3));
aim_deflect(&mut p, Some(look), Some(Who::Player), Some([4; 4]), Some(3));
assert_eq!(p.velocity, look);
assert_eq!(p.owner, Some(Who::Player));
assert_eq!((p.owner, p.owner_uuid), (Some(Who::Player), Some([4; 4])));
assert_eq!(p.guest, Some(3));
assert_eq!(p.acceleration, 0.1);
assert!(p.needs_sync);
@@ -285,9 +285,10 @@ mod tests {
#[test]
fn with_no_owner_here_it_keeps_its_movement_but_changes_hands() {
let mut p = wind_charge();
aim_deflect(&mut p, None, None, None);
p.owner_uuid = Some([7; 4]);
aim_deflect(&mut p, None, None, None, None);
assert_eq!(p.velocity, Vec3::new(0.0, 1.5, 0.0));
assert_eq!(p.owner, None);
assert_eq!((p.owner, p.owner_uuid), (None, None));
assert_eq!(p.acceleration, 0.1);
assert!(!p.needs_sync);
}
@@ -5,6 +5,10 @@ use horizon_client::discord_presence::{self as presence, Dimension, Play, Status
impl Game<'_> {
/// Each frame: only a change reaches the presence thread.
pub(super) fn update_discord_presence(&self) {
// One Discord status per computer: P1's, not a splitscreen player's.
if self.options.split_guest {
return;
}
presence::set(self.discord_status());
}
fn discord_status(&self) -> Status {
@@ -357,7 +357,7 @@ impl Game<'_> {
for p in containers {
let entity = &self.world.block_entities[&p];
let single = match entity {
BlockEntity::Jukebox(j) => std::slice::from_ref(&j.record),
BlockEntity::Jukebox(j) => &j.items,
_ => entity.slots(),
};
for (i, slot) in single.iter().enumerate() {
@@ -744,7 +744,7 @@ impl Game<'_> {
let jukebox =
&*self.res.facts().get(self.world.block(p.0, p.1, p.2)).name == "minecraft:jukebox";
match self.world.block_entities.get_mut(&p) {
Some(BlockEntity::Jukebox(j)) => j.record = stack,
Some(BlockEntity::Jukebox(j)) => j.items[0] = stack,
Some(BlockEntity::Lectern(l)) => l.book = stack,
Some(BlockEntity::Campfire(c)) => {
if let Some(cell) = c.items.get_mut(slot) {
@@ -761,7 +761,7 @@ impl Game<'_> {
self.world.block_entities.insert(
p,
BlockEntity::Jukebox(horizon_client::containers::Jukebox {
record: stack,
items: [stack],
ticks: 0,
playing: false,
}),
+85 -1
View File
@@ -67,6 +67,15 @@ fn y_rot(d: Direction) -> f32 {
_ => 270.0,
}
}
/// `DirectionalPlaceContext.getRotation`: `get2DDataValue() * 90`, so a
/// vertical direction (`data2d = -1`) gives -90 where `toYRot` gives 270: the
/// same horizontal facing, but the raw value a mod's placement hook reads.
fn place_rotation(d: Direction) -> f32 {
match d {
Direction::Up | Direction::Down => -90.0,
_ => y_rot(d),
}
}
fn block_box(p: Pos) -> Aabb {
Aabb::new(
Vec3::new(f64::from(p.0), f64::from(p.1), f64::from(p.2)),
@@ -90,6 +99,15 @@ fn shrink(mut stack: ItemStack) -> Option<ItemStack> {
/// A dispenser's answer: the slot's new content and the level event played.
type Dispensed = (Option<ItemStack>, i32);
/// A dispenser hook's answer: the slot's remainder (None empties it) and the
/// level event to play, `CLICK` (1000), `FAIL` (1001) or 0 for none.
pub(super) type NativeDispensed = (Option<ItemStack>, i32);
/// None keeps vanilla's behaviour; an explicit empty remainder consumes the stack.
fn mod_dispensed(answer: Option<NativeDispensed>) -> Option<Dispensed> {
answer.map(|(left, event)| (left.filter(|stack| stack.count > 0), event))
}
thread_local! {
/// `OptionalDispenseItemBehavior.success` of the registered behaviours
/// that leave it unset on some paths (brush, TNT, honeycomb, chest).
@@ -495,6 +513,29 @@ impl Game<'_> {
self.spawn_dispensed(pos, facing, one, 6);
(stack.count > 0).then_some(stack)
}
/// Drop one item as DefaultDispenseItemBehavior does. For a dispenser hook:
/// return this remainder in its answer with `CLICK`; the caller plays the
/// answer's event and smoke once. A client, another dimension or an unloaded
/// chunk keeps the input stack unchanged. API @API:dispenser_behaviour@.
#[allow(dead_code)]
pub(super) fn native_dispense_default(
&mut self,
dimension: &str,
pos: [i32; 3],
facing: Direction,
stack: ItemStack,
) -> Option<ItemStack> {
if !(self.serving.is_some() || self.options.dedicated.is_some())
|| dimension != self.dim.id()
|| !self.world.chunks.contains_key(&(pos[0] >> 4, pos[2] >> 4))
{
return Some(stack);
}
if stack.count == 0 {
return None;
}
self.default_execute((pos[0], pos[1], pos[2]), facing, stack)
}
/// `new DefaultDispenseItemBehavior().dispense(..)` including its events.
pub(super) fn dispense_default(
&mut self,
@@ -584,6 +625,25 @@ impl Game<'_> {
facing: Direction,
stack: ItemStack,
) -> Result<Option<ItemStack>> {
let mut answer = None;
if self.serving.is_some() || self.options.dedicated.is_some() {
self.native_dispense_item(
self.dim.id(),
[pos.0, pos.1, pos.2],
facing,
&stack,
&mut answer,
);
}
if let Some((left, event)) = mod_dispensed(answer) {
// DefaultDispenseItemBehavior.dispense: playSound, then playAnimation;
// a NOOP behaviour (event 0) plays neither.
if event != 0 {
self.dispenser_event(event, pos, 0);
self.dispenser_event(2000, pos, data_value(facing));
}
return Ok(left);
}
let item = stack.item.to_string();
let (left, event) = match item.as_str() {
"minecraft:arrow"
@@ -1530,7 +1590,7 @@ impl Game<'_> {
f64::from(front.2) + 0.5,
),
inside: false,
yaw: y_rot(facing),
yaw: place_rotation(facing),
pitch: 0.0,
sneaking: false,
item: stack.item.to_string(),
@@ -2148,3 +2208,27 @@ pub(super) fn tipped_effect(stack: &ItemStack) -> Option<(&'static str, i32)> {
})
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mod_dispenser_answers_preserve_fallback_and_own_consumption() {
assert!(
mod_dispensed(None).is_none(),
"no registered behaviour keeps vanilla"
);
assert_eq!(mod_dispensed(Some((None, CLICK))), Some((None, CLICK)));
let stack = ItemStack::new("minecraft:stone", 7);
let Some((Some(left), event)) = mod_dispensed(Some((Some(stack.clone()), FAIL))) else {
panic!("handled stack")
};
assert_eq!(left, stack, "the returned remainder is not consumed again");
assert_eq!(event, FAIL, "the answer chooses its sound");
assert_eq!(
mod_dispensed(Some((Some(ItemStack { count: 0, ..stack }), 0))),
Some((None, 0))
);
}
}
@@ -175,6 +175,7 @@ impl Game<'_> {
};
let Some(m) = self.mobs.get(mob) else { return };
let kind = m.kind.id.clone();
let victim = m.id;
let ctx = EffectContext {
this: Some(&kind),
attacker: Some("minecraft:player"),
@@ -187,6 +188,7 @@ impl Game<'_> {
// ignite (fire aspect) and apply_mob_effect (bane of arthropods).
let mut ignite = 0.0f32;
let mut mob_effects = Vec::new();
let mut hook_calls: Vec<(String, String, Value, u32)> = Vec::new();
for (id, level) in held.enchantments() {
let Some(def) = world_stations!(self).enchantments.get(id) else {
continue;
@@ -197,14 +199,17 @@ impl Game<'_> {
.flatten()
{
let e = &effect["effect"];
let kind_of = e["type"].as_str();
if effect["enchanted"] != "attacker"
|| effect["affected"] != "victim"
|| !matches!(
kind_of,
Some("minecraft:ignite" | "minecraft:apply_mob_effect")
)
{
if effect["enchanted"] != "attacker" || effect["affected"] != "victim" {
continue;
}
// A bare type name is minecraft's own, as a bare item id is.
let kind_of = match e["type"].as_str() {
Some(k) if k.contains(':') => k.to_owned(),
Some(k) => format!("minecraft:{k}"),
None => continue,
};
let own = !kind_of.starts_with("minecraft:");
if !own && !matches!(&*kind_of, "minecraft:ignite" | "minecraft:apply_mob_effect") {
continue;
}
let mut random = self.world.random.clone();
@@ -218,7 +223,11 @@ impl Game<'_> {
if !ok {
continue;
}
if kind_of == Some("minecraft:ignite") {
// A mod's own kind: its hook answers, after this loop (the
// stations borrow is held here).
if own {
hook_calls.push((id.to_string(), kind_of, e.clone(), *level));
} else if kind_of == "minecraft:ignite" {
ignite =
ignite.max(horizon_client::enchant::level_value(&e["duration"], *level));
} else {
@@ -235,6 +244,9 @@ impl Game<'_> {
for (effect, level) in mob_effects {
self.apply_mob_effect(mob, &effect, level);
}
for (id, kind_of, data, level) in hook_calls {
self.native_enchant_effect(&id, &kind_of, &data, level, victim);
}
self.post_attack_bursts(&held, &kind);
}
/// `EnchantmentHelper.doPostPiercingAttackEffects`: the
+3 -2
View File
@@ -435,7 +435,7 @@ impl Game<'_> {
}
}
if raised {
self.after_block(outcome, amount, None, false);
self.after_block(outcome, None, false);
}
pushed
}
@@ -941,7 +941,7 @@ impl Game<'_> {
// tables' survives_explosion and explosion_decay draw from their
// random sequence.
let decay = (interaction == BlockInteraction::DestroyWithDecay).then_some(radius);
let drops = world_items!(self).block_drops_exploded(
let mut drops = world_items!(self).block_drops_exploded(
self.pack.assets.registry,
state,
None,
@@ -951,6 +951,7 @@ impl Game<'_> {
decay,
false,
);
self.native_block_drops(pos, state, None, None, Some(radius), &mut drops);
for mut stack in drops {
if &*stack.item == "minecraft:air" || stack.count == 0 {
continue;
+7 -2
View File
@@ -93,13 +93,18 @@ impl Game<'_> {
}
/// Auto-reconnect's next attempt after a disconnect, when it is on and
/// the reason allows one.
pub(super) fn reconnect_after(&mut self, reason: &str, from_server: bool) -> Option<Retry> {
pub(super) fn reconnect_after(
&mut self,
reason: &str,
key: Option<&str>,
from_server: bool,
) -> Option<Retry> {
if !self.settings.extras.auto_reconnect {
self.extras.reconnect.reset();
return None;
}
let now = std::time::Instant::now();
self.extras.reconnect.ended(reason, from_server, now)
self.extras.reconnect.ended(reason, key, from_server, now)
}
/// Show the attempt's countdown on the Disconnected screen.
pub(super) fn offer_reconnect(&mut self, retry: Option<Retry>) {
+43 -1
View File
@@ -14,7 +14,7 @@ use horizon_client::{
pose::Pose,
};
use horizon_core::{
geometry::{Aabb, Vec3, trig},
geometry::{Aabb, Vec3, traverse_blocks, trig},
position::BlockPos,
random::RandomSource,
};
@@ -68,6 +68,12 @@ fn bounds(p: Vec3) -> Aabb {
fn triangle(r: &mut impl RandomSource, mode: f64, deviation: f64) -> f64 {
mode + deviation * (f64::from(r.next_float()) - f64::from(r.next_float()))
}
/// `FishingHook.onHitBlock`: `getDeltaMovement().normalize().scale(hit.distanceTo(this))`,
/// where `HitResult.distanceTo` is the *squared* distance to the hook: the bobber
/// springs back a little off a wall instead of stopping dead.
fn rebound(velocity: Vec3, hit: Vec3, at: Vec3) -> Vec3 {
velocity.normalize().scale(hit.distance_squared(at))
}
impl Game<'_> {
fn holds_rod(&self) -> bool {
self.held()
@@ -568,6 +574,28 @@ impl Game<'_> {
self.hook = Some(h);
return;
}
// checkCollision: the block the path hits before the move, then
// `onHitBlock`'s rebound.
let to = h.position.add_vector(h.velocity);
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
subject: None,
};
let hit = traverse_blocks(h.position, to, 1024, |p: BlockPos| {
let state = self.world.block(p.x, p.y, p.z);
if self.res.facts().is_air(state) {
return None;
}
let shape = self
.collision
.collision_shape(&mut scene, state, [p.x, p.y, p.z])
.ok()?;
shape.clip(h.position, to, p)
});
if let Some(hit) = hit.ok().flatten() {
h.velocity = rebound(h.velocity, hit.location, h.position);
}
}
State::Hooked(id) => {
// updateHookedEntity: ride the hooked mob, player or item at 0.8 of its height.
@@ -1291,3 +1319,17 @@ impl Game<'_> {
.collect()
}
}
#[cfg(test)]
mod tests {
use super::rebound;
use horizon_core::geometry::Vec3;
/// `HitResult.distanceTo` is squared: a hit 0.5 away rebounds at 0.25.
#[test]
fn a_block_hit_rebounds_by_the_squared_distance() {
let v = Vec3::new(0.0, 0.0, -2.0);
let hit = Vec3::new(0.0, 0.0, -0.5);
assert_eq!(rebound(v, hit, Vec3::ZERO), Vec3::new(0.0, 0.0, -0.25));
}
}
@@ -0,0 +1,98 @@
//! `--headless` (#4951): a client with no display, no window and no renderer.
//!
//! winit's event loop needs a display server even when no window is shown, so a headless game
//! runs this loop instead. Each step is what winit's `about_to_wait` does (`Game::idle`: stop
//! requests, the closing save, world entry, the control socket), and `idle` runs the frame
//! (the client tick, nothing drawn) through the same `RedrawRequested` path a window's game
//! takes. Input comes only from the control socket (`horizon-ctl`), as synthetic window events.
use super::Game;
use super::event_loop::EventLoopCtl;
use std::{
cell::Cell,
time::{Duration, Instant},
};
use winit::event_loop::ControlFlow;
/// Time between headless frames. The client ticks at 20 Hz whatever the frame rate (a frame
/// runs the ticks its elapsed time owes), so one frame per tick does all the work there is
/// without spending a core on frames nobody sees.
const FRAME: Duration = Duration::from_millis(50);
/// The loop's side of `EventLoopCtl`: what the game asked for during a step.
struct HeadlessLoop {
exit: Cell<bool>,
flow: Cell<ControlFlow>,
}
impl EventLoopCtl for HeadlessLoop {
fn exit(&self) {
self.exit.set(true);
}
fn set_control_flow(&self, flow: ControlFlow) {
self.flow.set(flow);
}
}
impl HeadlessLoop {
/// When the step that started at `started` wants the next one: at the time the game asked
/// for (`WaitUntil`, as the closing save's polls do), else one frame later.
fn next_step(&self, started: Instant) -> Instant {
match self.flow.get() {
ControlFlow::WaitUntil(at) => at,
ControlFlow::Poll | ControlFlow::Wait => started + FRAME,
}
}
}
impl Game<'_> {
/// Runs the game until it asks to stop (`horizon-ctl quit`, a signal, an error), then
/// saves as a window's game does when its loop ends.
pub(super) fn run_headless(&mut self) {
let ctl = HeadlessLoop {
exit: Cell::new(false),
flow: Cell::new(ControlFlow::Poll),
};
self.previous = Instant::now();
while !ctl.exit.get() {
let started = Instant::now();
ctl.flow.set(ControlFlow::Poll);
self.idle(&ctl);
if let Some(wait) = ctl
.next_step(started)
.checked_duration_since(Instant::now())
{
std::thread::sleep(wait);
}
}
self.shut_down();
}
}
#[cfg(test)]
mod tests {
use super::*;
fn ctl(flow: ControlFlow) -> HeadlessLoop {
HeadlessLoop {
exit: Cell::new(false),
flow: Cell::new(flow),
}
}
#[test]
fn steps_come_a_frame_apart_unless_the_game_asks_for_sooner() {
let started = Instant::now();
assert_eq!(ctl(ControlFlow::Poll).next_step(started), started + FRAME);
let soon = started + Duration::from_millis(4);
assert_eq!(ctl(ControlFlow::WaitUntil(soon)).next_step(started), soon);
}
#[test]
fn exit_is_remembered_for_the_loop() {
let c = ctl(ControlFlow::Poll);
assert!(!c.exit.get());
EventLoopCtl::exit(&c);
assert!(c.exit.get());
}
}
+88 -6
View File
@@ -60,6 +60,9 @@ struct Flags {
entity_distance: AtomicU32,
/// `MinecraftServer.isCurrentlySaving`: the server is autosaving.
saving: AtomicBool,
/// Splitscreen players joined through memory (offline uuids): let in although the world is
/// not open to the LAN, and the game no longer pauses while they play.
split_players: Mutex<Vec<u128>>,
/// The generators of the levels the server has run, by dimension: what
/// F3's chunk generation entry asks `ServerChunkCache.getGenerator`.
terrain: Mutex<
@@ -120,7 +123,42 @@ impl Integrated {
}
/// `IntegratedServer.tickServer` skips the world while the game is paused.
pub fn set_paused(&self, paused: bool) {
self.flags.paused.store(paused, Ordering::Relaxed);
let split = !self.flags.split_players.lock().unwrap().is_empty();
self.flags.paused.store(paused && !split, Ordering::Relaxed);
}
/// Splitscreen: `name` left; the game pauses again once every such player has. The owner's
/// permissions it was given go with it (a LAN player could otherwise join under that name).
pub fn leave_split(&self, name: &str) {
let uuid = horizon_protocol::crypto::offline_uuid(name);
self.flags
.split_players
.lock()
.unwrap()
.retain(|&u| u != uuid);
self.call(move |g| {
if let Some(host) = g.lan.as_mut() {
host.hub.ops.remove(&uuid);
}
});
}
/// Splitscreen: a connection through memory for another player on this machine, who gets
/// the owner's permissions. `None` once the server has stopped.
pub fn join_split(&self, name: &str) -> Option<horizon_protocol::connection::Pipe> {
let uuid = horizon_protocol::crypto::offline_uuid(name);
self.flags.split_players.lock().unwrap().push(uuid);
let pipe = self
.call(move |g| {
let host = g.lan.as_mut()?;
let owner = host.owner.as_ref()?.uuid;
let level = host.hub.ops.get(&owner).copied().unwrap_or(0);
host.hub.ops.insert(uuid, level);
host.hub.lan.connect_local().ok()
})
.flatten();
if pipe.is_none() {
self.leave_split(name);
}
pipe
}
/// The window's render and simulation distances and its Entity Distance
/// option, for the server to follow.
@@ -679,14 +717,25 @@ impl Game<'_> {
};
let owner = launch.owner;
let published = flags.clone();
let local_players = flags.clone();
let net = NetConfig {
// No network until it is opened to the LAN; the memory connection
// is not compressed (`Connection.isMemoryConnection`).
listen: false,
online: horizon_client::lan::OnlineMode::lan(),
gate: Some(Arc::new(move |_, uuid, _| {
(uuid != owner && published.published.load(Ordering::Relaxed) == 0)
.then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
// Registered local seats share this computer; the vanilla LAN capacity still
// applies to other connections when the world is published.
bypasses_limit: Some(Arc::new(move |uuid| {
local_players.split_players.lock().unwrap().contains(&uuid)
})),
gate: Some(Arc::new(move |address, uuid, _| {
refused(
uuid == owner,
published.published.load(Ordering::Relaxed) != 0,
published.split_players.lock().unwrap().contains(&uuid),
address == horizon_client::lan::conn::MEMORY_ADDRESS,
)
.then(|| translatable("multiplayer.disconnect.not_whitelisted", &[]))
})),
levels: self.level_shapes,
owner_profile: launch.owner_profile.clone(),
@@ -1033,12 +1082,17 @@ impl Game<'_> {
/// `IntegratedServer.setWorldAllowCommands`: the owner is an operator
/// while the world allows commands; everyone's level is sent again.
pub(super) fn owner_allow_commands(&mut self, allow: bool) {
let Some(owner) = self.serving.as_ref().map(|s| s.owner) else {
let Some((owner, flags)) = self.serving.as_ref().map(|s| (s.owner, s.flags.clone())) else {
return;
};
let published = self.published_port().is_some();
if let Some(host) = self.lan.as_mut() {
host.hub.ops.insert(owner, if allow { 4 } else { 0 });
let level = if allow { 4 } else { 0 };
host.hub.ops.insert(owner, level);
// Splitscreen seats have the owner's permissions (`join_split`): they follow it.
for &seat in flags.split_players.lock().unwrap().iter() {
host.hub.ops.insert(seat, level);
}
if published {
host.hub.config.guest_commands = allow && host.settings.guest_commands;
}
@@ -1144,10 +1198,38 @@ fn server_game(
Game::new(res, pack, gui, options, settings, audio, None)
}
/// Who the integrated server turns away: anyone but the owner until it is opened to the LAN,
/// except a splitscreen seat. A seat's name is let in only through memory, so a LAN player
/// logging in under it never gets its place or the owner's permissions it holds.
fn refused(owner: bool, published: bool, split_seat: bool, memory: bool) -> bool {
if split_seat {
!memory
} else {
!owner && !published
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_splitscreen_seat_is_let_in_only_through_memory() {
// (owner, published, seat, memory)
assert!(!refused(false, false, true, true), "a local seat joins");
assert!(
refused(false, true, true, false),
"a LAN player under a seat's name"
);
assert!(refused(false, false, true, false));
assert!(
!refused(false, true, false, false),
"anyone once open to the LAN"
);
assert!(refused(false, false, false, false), "nobody else before");
assert!(!refused(true, false, false, true), "the owner");
}
#[test]
fn shutdown_request_does_not_join_a_server_still_saving() {
let flags = Arc::new(Flags::default());
+46 -6
View File
@@ -53,6 +53,47 @@ impl Game<'_> {
self.container_changed((pos[0], pos[1], pos[2]));
self.world.modified.insert((pos[0] >> 4, pos[2] >> 4));
}
/// `JukeboxBlockEntity.setTheItem` from a container (a hopper or a dropper
/// feeding the slot): a disc that lands in a jukebox that is not playing
/// starts its song, as the player's own use does, with the block state,
/// the song's level event and the neighbour and comparator updates.
pub(super) fn jukebox_inserted(&mut self, pos: [i32; 3]) {
let Some(song) = self
.jukebox(pos)
.filter(|j| !j.playing)
.and_then(|j| j.record())
.and_then(|record| self.res.items().get(&record.item))
.and_then(|info| info.jukebox_song.clone())
else {
return;
};
if let Some(BlockEntity::Jukebox(j)) =
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
{
j.playing = true;
j.ticks = 0;
}
self.set_has_record(pos, true);
self.start_song(pos, &song);
self.jukebox_changed(pos);
}
/// `JukeboxBlockEntity.setTheItem(EMPTY)` from a container (a hopper
/// below taking the disc): the song stops, the block loses its record,
/// and the neighbours and comparators hear it.
pub(super) fn jukebox_taken(&mut self, pos: [i32; 3]) {
if let Some(BlockEntity::Jukebox(j)) =
self.world.block_entities.get_mut(&(pos[0], pos[1], pos[2]))
{
if j.record().is_some() {
return;
}
j.playing = false;
j.ticks = 0;
}
self.stop_song(pos);
self.set_has_record(pos, false);
self.jukebox_changed(pos);
}
/// `JukeboxBlock.useItemOn` / `useWithoutItem` on the jukebox at `pos`.
pub(super) fn use_jukebox(&mut self, pos: [i32; 3], held: Option<&ItemStack>) -> bool {
if &*self
@@ -64,7 +105,7 @@ impl Game<'_> {
{
return false;
}
if self.jukebox(pos).is_some_and(|j| j.record.is_some()) {
if self.jukebox(pos).is_some_and(|j| j.record().is_some()) {
// TRY_WITH_EMPTY_HAND: only the main hand goes on to useWithoutItem.
if self.hand != Hand::Main {
return false;
@@ -95,7 +136,7 @@ impl Game<'_> {
self.world.block_entities.insert(
(pos[0], pos[1], pos[2]),
BlockEntity::Jukebox(Jukebox {
record: Some(record),
items: [Some(record)],
ticks: 0,
playing: true,
}),
@@ -200,7 +241,7 @@ impl Game<'_> {
Some(BlockEntity::Jukebox(j)) => {
j.playing = false;
j.ticks = 0;
j.record.take()
j.items[0].take()
}
_ => None,
};
@@ -224,7 +265,7 @@ impl Game<'_> {
/// `JukeboxSongPlayer.tick` of the jukebox at `key`, while it plays.
pub(super) fn tick_jukebox(&mut self, key: (i32, i32, i32)) {
let playing = match self.world.block_entities.get(&key) {
Some(BlockEntity::Jukebox(j)) if j.playing => j.record.as_ref().and_then(|r| {
Some(BlockEntity::Jukebox(j)) if j.playing => j.record().and_then(|r| {
let song = self.items().get(&r.item)?.jukebox_song.clone()?;
Some((song, j.ticks))
}),
@@ -314,8 +355,7 @@ pub(super) fn jukebox_signals(
match world.block_entities.get(&(pos.x, pos.y, pos.z)) {
Some(BlockEntity::Jukebox(j)) => {
let comparator = j
.record
.as_ref()
.record()
.and_then(|r| items.get(&r.item)?.jukebox_song.as_ref())
.and_then(|s| items.songs.get(s))
.map_or(0, |s| s.comparator);
+42 -33
View File
@@ -1482,6 +1482,25 @@ impl Game<'_> {
/// `PlayerList.removeAll`: every guest leaves in turn, the vehicle it
/// alone rides with it (`PlayerList.remove`), and is saved.
pub(super) fn save_leaving_guests(&mut self, host: &mut LanHost) {
// Every open menu closes first, in its guest's level
// (`ServerPlayer.disconnect` -> `doCloseContainer`), so the cursor,
// the trade and the inputs are back in the file that is saved.
let back = self.dim;
let open: Vec<(u32, super::Dim)> = host
.hub
.guests
.iter()
.filter(|g| g.placed)
.map(|g| (g.conn, super::Dim::from_id(&g.dimension)))
.collect();
for (conn, dim) in open {
if self.enter_level(Some(host), dim).is_ok() {
self.guest_close_all(host, conn);
}
}
if let Err(e) = self.enter_level(Some(host), back) {
log::error!("could not reopen {}: {e}", back.id());
}
for i in 0..host.hub.guests.len() {
let mut g = host.hub.guests.remove(i);
self.take_parent_vehicle(host, &mut g);
@@ -1490,15 +1509,13 @@ impl Game<'_> {
}
}
/// Save a guest when they leave or the world closes (after
/// `take_parent_vehicle`): an integrated server's owner with the world
/// (`save_game`), anyone else in their file.
/// `take_parent_vehicle`, `PlayerList.save`): an integrated server's owner
/// into the level (`/data`) and both its files (`playerdata::save_owner`),
/// anyone else into their own file.
pub(super) fn save_guest(&mut self, host: &LanHost, guest: &horizon_client::lan::guest::Guest) {
if host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) && guest.placed {
// The world's own player state rides in the level (`/data`).
playerdata::store(guest, &mut self.level, None);
}
if guest.placed {
// PlayerList.save/remove: every player saves to their own file.
if guest.placed && host.owner.as_ref().is_some_and(|o| o.uuid == guest.uuid) {
playerdata::save_owner(guest, &mut self.level, &self.world_root);
} else if guest.placed {
playerdata::save(guest, &self.world_root);
}
self.save_guest_records(host, guest);
@@ -1900,6 +1917,9 @@ impl Game<'_> {
event @ (HostEvent::Chat { .. } | HostEvent::Warn(_) | HostEvent::Error(_)) => {
self.lan_log_event(event);
}
HostEvent::Ban { uuid, name, reason } => {
self.anticheat_ban(&mut host.hub, uuid, &name, &reason);
}
HostEvent::ChatFiltered {
sender,
name,
@@ -1976,15 +1996,17 @@ impl Game<'_> {
}
}
HostEvent::Left(mut guest) => {
// The container they had open loses an opener.
if let Some(pos) = guest.menu.as_ref().and_then(|m| m.pos)
&& !guest.spectator()
{
// In the level they were in.
// The menu they had open closes, in the level they were in
// (`ServerPlayer.disconnect` -> `doCloseContainer`).
if guest.placed {
let back = self.dim;
let theirs = super::Dim::from_id(&guest.dimension);
if self.enter_level(Some(&mut host), theirs).is_ok() {
self.container_openers_changed(pos, false);
let conn = guest.conn;
let at = host.hub.guests.len();
host.hub.guests.push(*guest);
self.guest_close_all(&mut host, conn);
*guest = host.hub.guests.remove(at);
}
if let Err(e) = self.enter_level(Some(&mut host), back) {
log::error!("could not reopen {}: {e}", back.id());
@@ -2349,28 +2371,15 @@ impl Game<'_> {
let force = host.hub.config.force_game_mode;
let root = self.world_root.clone();
let in_place = host.hub.config.dimension.clone();
let mut owner = host.owner.as_mut();
let owner = host.owner.as_mut();
let Some(g) = host.hub.guest_mut(conn) else {
return;
};
let is_owner = owner.as_ref().is_some_and(|o| o.uuid == g.uuid);
// PlayerDataStorage.load: the player's own file first; the world's
// own player state is the fallback for a first join.
let known = if is_owner && playerdata::load(g, &root) {
true
} else {
let saved = if is_owner {
owner.as_mut().and_then(|o| o.saved.take())
} else {
None
};
match saved {
Some(level) => {
playerdata::apply(g, &level);
level.position.is_some()
}
None => playerdata::load(g, &root),
}
// PlayerList.loadPlayerData: the owner gets the world's player file,
// everyone else their own.
let known = match owner.filter(|o| o.uuid == g.uuid) {
Some(o) => playerdata::load_owner(g, &root, o.saved.take().as_deref()),
None => playerdata::load(g, &root),
};
if !known || force {
g.game_mode = default_mode;
+126 -14
View File
@@ -295,7 +295,9 @@ impl Game<'_> {
f: impl FnOnce(&mut Self) -> R,
) -> Option<R> {
let swap = self.enter_guest(host, conn)?;
let result = f(self);
let dimension =
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(self.dim.id());
let result = horizon_client::block_contact::in_level(dimension, || f(self));
self.leave_guest(host, swap);
Some(result)
}
@@ -482,6 +484,9 @@ impl Game<'_> {
if let Some(p) = self.player.as_mut() {
p.is_passenger = g.vehicle.is_some();
}
// Entity.collide: the guest's body collides with the level's world
// border too, as it is this turn (it moves with /worldborder).
self.player_border();
let host_station = self.station.take();
// The guest's bed, sleep counter and respawn point; the host's
// respawn point, anchor and action bar stay theirs.
@@ -1179,8 +1184,7 @@ impl Game<'_> {
return false;
}
// ServerPlayer.openMenu closes the menu that was open.
self.guest_container_closed(host, conn);
self.lan_station_closed(host, conn, None);
self.guest_close_all(host, conn);
let hidden = self.native_hidden_slots(pos, items.len());
host.hub
.open_container(conn, menu, title, pos, slots, items);
@@ -1228,8 +1232,7 @@ impl Game<'_> {
return false;
};
// ServerPlayer.openMenu closes the menu that was open.
self.guest_container_closed(host, conn);
self.lan_station_closed(host, conn, None);
self.guest_close_all(host, conn);
host.hub
.open_container(conn, menu, title, pos, slots, items);
true
@@ -1250,6 +1253,38 @@ impl Game<'_> {
}
}
/// Closes whatever menu the guest has open, the way each `ContainerClose`
/// does (`ServerPlayer.doCloseContainer`, `AbstractContainerMenu.removed`):
/// the container's lid, a vehicle's stand-in (its items go back to the
/// vehicle), the trade (written back to the villager), the crafting table,
/// station and mount menus, and the cursor stack back to the inventory.
/// Nothing is sent to the client: this is for a guest who is leaving or
/// whose server is closing. The guest must be in `host.hub.guests` and the
/// level they are in must be the one in place.
pub(super) fn guest_close_all(&mut self, host: &mut LanHost, conn: u32) {
let Some(gi) = host.hub.guests.iter().position(|g| g.conn == conn) else {
return;
};
if let Some((window, slots)) = host.hub.guests[gi]
.menu
.as_ref()
.map(|m| (m.window, m.slots))
{
self.guest_container_closed(host, conn);
if let Slots::Block(key) = slots
&& key.1 == super::minecarts::CART_KEY_Y
{
self.close_guest_vehicle_menu(host, conn, key);
}
host.hub.container_closed(conn, window);
}
self.lan_merchant_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
self.lan_station_closed(host, conn, None);
self.lan_mount_closed(host, conn, None);
self.guest_carried_back(host, gi);
}
/// `ContainerEntity`'s `Container.stopOpen` when a vehicle menu closes
/// (`AbstractChestBoat.stopOpen` / `MinecartChest.stopOpen`): the menu's
/// slots go back to the vehicle, the shared placeholder leaves
@@ -1294,15 +1329,7 @@ impl Game<'_> {
.guests
.iter()
.filter(|g| host.hub.here(g))
.filter(|g| {
g.menu.as_ref().is_some_and(|m| {
(m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos))
|| m.pos.is_some_and(|k| {
k.1 == super::minecarts::CART_KEY_Y
&& !self.chest_vehicle_still_valid(k.2, g)
})
})
})
.filter(|g| !self.container_menu_valid(g))
.map(|g| {
let vehicle = g.menu.as_ref().and_then(|m| match m.slots {
Slots::Block(key) if key.1 == super::minecarts::CART_KEY_Y => Some(key),
@@ -1323,6 +1350,78 @@ impl Game<'_> {
}
}
/// `stillValid` of the guest's open container: its blocks are still there
/// and in reach, or its vehicle exists and is in reach. True with none open.
fn container_menu_valid(&self, g: &horizon_client::lan::guest::Guest) -> bool {
g.menu.as_ref().is_none_or(|m| {
!(m.slots.valid(&self.world) && !m.slots.still_valid(&self.world, g, m.pos))
&& !m.pos.is_some_and(|k| {
k.1 == super::minecarts::CART_KEY_Y && !self.chest_vehicle_still_valid(k.2, g)
})
})
}
/// `AbstractContainerMenu.stillValid` of whatever menu the guest has open
/// (a container or vehicle, a trade, the crafting table, a station, a
/// lectern, a mount's inventory). True when none is open.
fn guest_menu_still_valid(&self, host: &LanHost, conn: u32) -> bool {
let Some(g) = host.hub.guest(conn) else {
return true;
};
if !self.container_menu_valid(g) {
return false;
}
let Some(p) = host.play.get(&conn) else {
return true;
};
p.crafting
.table
.as_ref()
.is_none_or(|t| self.lan_table_valid(g, t.3))
&& p.station
.as_ref()
.is_none_or(|s| self.lan_station_valid(g, s))
&& p.lectern
.as_ref()
.is_none_or(|l| self.lan_lectern_valid(g, l.key))
&& p.merchant
.as_ref()
.and_then(|m| m.merchant.mob)
.is_none_or(|id| self.lan_merchant_valid(g, id))
&& p.mount_window
.as_ref()
.is_none_or(|m| self.lan_mount_valid(g, m.mob))
}
/// The check before a menu click: a menu that is no longer valid closes
/// (returning what it held, as `ContainerClose` does) and the client's
/// window with it, so the click lands on nothing
/// (`ServerGamePacketListenerImpl.handleContainerClick` and the others
/// ask `stillValid` first).
fn guest_validate_menu(&mut self, host: &mut LanHost, conn: u32) {
if self.guest_menu_still_valid(host, conn) {
return;
}
let mut windows: Vec<i32> = host
.hub
.guest(conn)
.and_then(|g| g.menu.as_ref().map(|m| m.window))
.into_iter()
.collect();
if let Some(p) = host.play.get(&conn) {
windows.extend(p.crafting.table.as_ref().map(|t| t.0));
windows.extend(p.station.as_ref().map(|s| s.window));
windows.extend(p.lectern.as_ref().map(|l| l.window));
windows.extend(p.merchant.as_ref().map(|m| m.window));
windows.extend(p.mount_window.as_ref().map(|m| m.window));
}
self.guest_close_all(host, conn);
for container in windows {
host.hub
.send_to(conn, &C::ContainerClose(play::ContainerClose { container }));
}
}
/// `ContainerEntity.isChestVehicleStillValid`: the chest minecart or chest
/// boat is still there and within the guest's entity interaction range
/// plus 4 of its box.
@@ -1434,6 +1533,19 @@ impl Game<'_> {
{
return;
}
if matches!(
packet,
S::ContainerClick(_)
| S::ContainerButtonClick(_)
| S::ContainerSlotStateChanged(_)
| S::RenameItem(_)
| S::SetBeacon(_)
| S::SelectTrade(_)
| S::SelectBundleItem(_)
| S::PlaceRecipe(_)
) {
self.guest_validate_menu(host, conn);
}
let spectator = host.hub.guests[gi].spectator();
match packet {
S::PlayerAction(a) => match a.action {
+21 -3
View File
@@ -186,9 +186,27 @@ impl Game<'_> {
let targets: Vec<usize> = match (raw, &selector.player_name, selector.uuid) {
(Some("@s"), ..) => vec![i],
(Some("@a"), ..) => (0..guests.len()).filter(placed).collect(),
(_, Some(name), _) => (0..guests.len())
.filter(|&k| placed(&k) && guests[k].name.eq_ignore_ascii_case(name))
.collect(),
(_, Some(name), _) => {
let named: Vec<usize> = (0..guests.len())
.filter(|&k| placed(&k) && guests[k].name.eq_ignore_ascii_case(name))
.collect();
if named.is_empty() {
// No player has that name: the one going by it as a nickname (`~` optional).
let bare = |s: &str| s.strip_prefix('~').unwrap_or(s).to_ascii_lowercase();
let wanted = bare(name);
let nicknamed = |k: &usize| {
host.hub
.nicknames
.get(&guests[*k].uuid)
.is_some_and(|nick| bare(nick) == wanted)
};
(0..guests.len())
.filter(|k| placed(k) && nicknamed(k))
.collect()
} else {
named
}
}
(_, None, Some(uuid)) => (0..guests.len())
.filter(|&k| placed(&k) && guests[k].uuid == uuid)
.collect(),
@@ -21,7 +21,7 @@ pub(super) struct GuestCrafting {
/// The inventory menu; its grid lives in the hub's guest.
inventory: Option<Box<Menu>>,
/// The open crafting table: its window, state id, menu and block.
table: Option<(i32, i32, Box<Menu>, [i32; 3])>,
pub(super) table: Option<(i32, i32, Box<Menu>, [i32; 3])>,
/// The open furnace's or brewing stand's window and menu (`AbstractFurnaceMenu`,
/// `BrewingStandMenu`); its slots live in the block entity and the hub's
/// container menu keeps the client in sync.
@@ -436,10 +436,8 @@ impl Game<'_> {
/// The guest opened a crafting table (`CraftingTableBlock.getMenuProvider`).
pub(super) fn lan_open_crafting(&mut self, host: &mut LanHost, conn: u32, pos: [i32; 3]) {
self.lan_crafting_closed(host, conn, None);
self.lan_station_closed(host, conn, None);
// The container it replaces loses its opener.
self.guest_container_closed(host, conn);
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable("container.crafting", &[]);
let Some(window) = host.hub.open_window(conn, "minecraft:crafting", title) else {
return;
@@ -509,7 +507,7 @@ impl Game<'_> {
/// `CraftingMenu.stillValid`: the block is still a crafting table and the
/// guest is within reach of it.
fn lan_table_valid(&self, g: &Guest, pos: [i32; 3]) -> bool {
pub(super) fn lan_table_valid(&self, g: &Guest, pos: [i32; 3]) -> bool {
let name = &self
.res
.facts()
+33 -4
View File
@@ -12,6 +12,15 @@ use horizon_client::lan::{
use horizon_protocol::entity_data::{Value, serializer};
use horizon_world::{nbt::Tag, registry::BlockStateId};
/// Allocate once: a native summon may need its id before the tracking tick.
pub(super) fn assign_entity_id(next: &mut u32, id: &mut u32) -> u32 {
if *id == 0 {
*next += 1;
*id = *next;
}
*id
}
fn state(id: u32, kind: &str, position: [f64; 3], yaw: f32, on_ground: bool) -> EntityState {
EntityState {
id: id as i32,
@@ -125,6 +134,13 @@ impl Game<'_> {
.push((11 + k as u8, serializer::BOOLEAN, Value::Bool(true)));
}
}
// AbstractBoat.DATA_ID_BUBBLE_TIME, back to 0 once the boat is off
// the column.
if b.bubble_time != 0 {
e.meta
.push((13, serializer::INT, Value::Int(b.bubble_time)));
}
e.defaults.push((13, serializer::INT, Value::Int(0)));
out.push(e);
}
for c in &self.carts {
@@ -443,10 +459,7 @@ impl Game<'_> {
}
let next = &mut self.next_entity;
let mut give = |id: &mut u32| {
if *id == 0 {
*next += 1;
*id = *next;
}
assign_entity_id(next, id);
};
self.tnts.iter_mut().for_each(|t| give(&mut t.id));
self.falling.iter_mut().for_each(|f| give(&mut f.id));
@@ -729,6 +742,22 @@ fn arrow_meta(
mod tests {
use super::*;
#[test]
fn summoned_ids_are_unique_and_survive_the_tracking_pass() {
let mut next = 40;
let mut roots = [0; 3];
let returned = roots.each_mut().map(|id| assign_entity_id(&mut next, id));
assert_eq!(returned, [41, 42, 43]);
// Tracking sees the same roots plus an unrelated deferred entity.
for id in &mut roots {
assign_entity_id(&mut next, id);
}
let mut later = 0;
assert_eq!(assign_entity_id(&mut next, &mut later), 44);
assert_eq!(roots, returned);
assert_eq!(next, 44);
}
#[test]
fn armor_stand_poses_send_only_what_differs() {
let mut stand = horizon_client::entities::ArmorStand::new(1, Default::default(), 0.0);
@@ -15,8 +15,8 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
/// A guest's open mount menu.
pub(super) struct GuestMount {
window: i32,
mob: u32,
pub(super) window: i32,
pub(super) mob: u32,
columns: usize,
state: i32,
/// The menu's slots and the carried stack the client was last sent.
@@ -31,10 +31,8 @@ impl Game<'_> {
return;
};
let columns = super::mount_menu::mount_columns(&self.mobs[index]);
self.lan_mount_closed(host, conn, None);
self.lan_station_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
self.guest_container_closed(host, conn);
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let Some(window) = host.hub.open_mount_window(conn, columns as i32, mob as i32) else {
return;
};
@@ -182,10 +180,28 @@ impl Game<'_> {
closed
}
/// `AbstractMountInventoryMenu.stillValid`: the mount is alive and within
/// the entity interaction range (3) plus 4 of the guest's eyes.
pub(super) fn lan_mount_valid(&self, g: &horizon_client::lan::guest::Guest, mob: u32) -> bool {
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
self.mobs.iter().find(|m| m.id == mob).is_some_and(|m| {
let (w, h) = (f64::from(m.width()) / 2.0, f64::from(m.height()));
let p = [m.position.x, m.position.y, m.position.z];
let lo = [p[0] - w, p[1], p[2] - w];
let hi = [p[0] + w, p[1] + h, p[2] + w];
let d: f64 = (0..3)
.map(|k| (eye[k] - eye[k].clamp(lo[k], hi[k])).powi(2))
.sum();
m.alive() && d < 7.0 * 7.0
})
}
/// Each tick: a mount menu whose mount died or is out of reach closes
/// (`AbstractMountInventoryMenu.stillValid`: alive, within the entity
/// interaction range plus 4 of the eyes), and the others follow the
/// mount's and the inventory's slots.
/// mount's and the inventory's slots. Only the level the player is in
/// checks its menu (`ServerPlayer.tick`): another level ticking has
/// none of its mobs (#5331).
pub(super) fn tick_lan_mount_menus(&mut self, host: &mut LanHost) {
let open: Vec<(u32, u32, i32)> = host
.play
@@ -196,19 +212,10 @@ impl Game<'_> {
let Some(g) = host.hub.guests.iter().find(|g| g.conn == conn) else {
continue;
};
let eye = [g.position[0], g.position[1] + g.eye_height(), g.position[2]];
let valid = self.mobs.iter().find(|m| m.id == mob).is_some_and(|m| {
let (w, h) = (f64::from(m.width()) / 2.0, f64::from(m.height()));
let p = [m.position.x, m.position.y, m.position.z];
let lo = [p[0] - w, p[1], p[2] - w];
let hi = [p[0] + w, p[1] + h, p[2] + w];
let d: f64 = (0..3)
.map(|k| (eye[k] - eye[k].clamp(lo[k], hi[k])).powi(2))
.sum();
// Player.ENTITY_INTERACTION_RANGE 3, plus 4.
m.alive() && d < 7.0 * 7.0
});
if valid {
if !host.hub.here(g) {
continue;
}
if self.lan_mount_valid(g, mob) {
self.lan_send_mount(host, conn);
} else {
self.lan_mount_closed(host, conn, Some(window));
@@ -437,14 +437,16 @@ impl Game<'_> {
});
}
/// Native sounds wait until the server host is available.
/// Native sounds wait until the server host is available. An unknown
/// `minecraft:` event is dropped; a mod's goes by name (`sound_packet`).
pub(super) fn flush_native_sounds(&mut self) {
let Some(host) = &self.lan else { return };
for sound in std::mem::take(&mut self.creatures.lan_sounds) {
if host
.hub
.registry_id("minecraft:sound_event", &sound.sound)
.is_none()
if sound.sound.starts_with("minecraft:")
&& host
.hub
.registry_id("minecraft:sound_event", &sound.sound)
.is_none()
{
continue;
}
+7 -3
View File
@@ -16,12 +16,16 @@ use horizon_client::{
use horizon_core::geometry::{Aabb, Vec3};
/// The player projectiles that meet other players: arrows (their hurt), and
/// snowballs and eggs, which break on them (`Player.hurtServer` takes no 0
/// damage). Pearls, potions and wind charges fly past players as before.
/// snowballs, eggs and mods' thrown types, which break on them
/// (`Player.hurtServer` takes no 0 damage). Pearls, potions and wind charges
/// fly past players as before.
pub(super) fn meets_players(kind: &ProjectileKind) -> bool {
matches!(
kind,
ProjectileKind::Arrow { .. } | ProjectileKind::Snowball | ProjectileKind::Egg
ProjectileKind::Arrow { .. }
| ProjectileKind::Snowball
| ProjectileKind::Egg
| ProjectileKind::Thrown(_)
)
}
+35 -21
View File
@@ -20,7 +20,7 @@ use horizon_protocol::packets::play::{self, Clientbound as C};
/// A guest's open workstation menu.
pub(super) struct GuestStation {
window: i32,
pub(super) window: i32,
state: i32,
kind: StationKind,
pos: [i32; 3],
@@ -34,9 +34,9 @@ pub(super) struct GuestStation {
/// A guest's open lectern: `LecternMenu` over the block entity, made on the
/// menu engine for each click (`Game::lectern_menu`).
pub(super) struct GuestLectern {
window: i32,
pub(super) window: i32,
state: i32,
key: (i32, i32, i32),
pub(super) key: (i32, i32, i32),
/// The book and page the client was last sent (`broadcastChanges`).
sent: Option<(Option<ItemStack>, i32)>,
}
@@ -69,10 +69,8 @@ impl Game<'_> {
let Some((menu_type, kind, _)) = menu_of(station.kind) else {
return false;
};
self.lan_station_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
// The container it replaces loses its opener.
self.guest_container_closed(host, conn);
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable(station.kind.title_key(), &[]);
let Some(window) = host.hub.open_window(conn, menu_type, title) else {
return true;
@@ -151,9 +149,8 @@ impl Game<'_> {
/// `LecternBlock.openScreen` for a guest: `minecraft:lectern` over the
/// block entity, with its book and page.
pub(super) fn lan_open_lectern(&mut self, host: &mut LanHost, conn: u32, key: (i32, i32, i32)) {
self.lan_station_closed(host, conn, None);
self.lan_crafting_closed(host, conn, None);
self.guest_container_closed(host, conn);
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let title = horizon_protocol::text::translatable("container.lectern", &[]);
let Some(window) = host.hub.open_window(conn, "minecraft:lectern", title) else {
return;
@@ -644,10 +641,7 @@ impl Game<'_> {
let Some(gs) = host.play.get(&conn).and_then(|p| p.station.as_ref()) else {
continue;
};
let [x, y, z] = gs.pos;
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
let block = StationKind::from_block(name) == Some(gs.kind);
if !(block && g.within_block_range(gs.pos, 4.0)) {
if !self.lan_station_valid(g, gs) {
let window = gs.window;
self.lan_station_closed(host, conn, Some(window));
host.hub.send_to(
@@ -669,6 +663,32 @@ impl Game<'_> {
}
impl Game<'_> {
/// A station menu's `stillValid`: its block is still that station and the
/// guest is within `isWithinBlockInteractionRange(pos, 4)`.
pub(super) fn lan_station_valid(
&self,
g: &horizon_client::lan::guest::Guest,
gs: &GuestStation,
) -> bool {
let [x, y, z] = gs.pos;
let name = &self.res.facts().get(self.world.block(x, y, z)).name;
StationKind::from_block(name) == Some(gs.kind) && g.within_block_range(gs.pos, 4.0)
}
/// `LecternMenu.stillValid` (`Container.stillValidBlockEntity`): the block
/// entity still holds a book and the guest is within the interaction
/// range and its buffer.
pub(super) fn lan_lectern_valid(
&self,
g: &horizon_client::lan::guest::Guest,
key: (i32, i32, i32),
) -> bool {
matches!(
self.world.block_entities.get(&key),
Some(BlockEntity::Lectern(l)) if l.book.is_some()
) && g.within_block_range([key.0, key.1, key.2], 4.0)
}
/// The guests' lecterns each tick: `stillValid` (the block entity, its
/// book, the reach) closes them, and `broadcastChanges` sends a book or
/// page another player changed.
@@ -685,13 +705,7 @@ impl Game<'_> {
if !host.hub.here(g) {
continue;
}
let has_book = matches!(
self.world.block_entities.get(&key),
Some(BlockEntity::Lectern(l)) if l.book.is_some()
);
// Container.stillValidBlockEntity: the interaction range and its buffer.
let near = g.within_block_range([key.0, key.1, key.2], 4.0);
if has_book && near {
if self.lan_lectern_valid(g, key) {
self.lan_send_lectern(host, conn, false);
} else {
self.lan_station_closed(host, conn, Some(window));
+28 -20
View File
@@ -57,9 +57,8 @@ impl Game<'_> {
state.session.update_special_prices();
}
let title = self.merchant_display_name(&state);
// The container or workstation the trading screen replaces is closed.
self.guest_container_closed(host, conn);
self.lan_station_closed(host, conn, None);
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(host, conn);
let Some(window) = host.hub.open_merchant_window(conn, title) else {
self.merchant_closed(&mut state);
return;
@@ -350,28 +349,37 @@ impl Game<'_> {
self.merchant_closed(&mut gm.merchant);
true
}
/// `Merchant.stillValid` for a trader: it is alive and the guest is within
/// the entity interaction range plus 4 of its box.
pub(super) fn lan_merchant_valid(
&self,
g: &horizon_client::lan::guest::Guest,
id: u32,
) -> bool {
self.mobs
.iter()
.find(|m| m.id == id)
.is_some_and(|m| m.alive() && guest_within_entity_range(g, &m.bounds(), 4.0))
}
/// `Villager.customServerAiStep` for the guests' trading villagers: the
/// TRADE reputation event, then the prices follow.
pub(super) fn lan_merchant_tick(&mut self, host: &mut LanHost) {
// `AbstractVillager.stillValid`: the trade screen closes once the trader
// is gone or dead, or the guest is more than 4 blocks past its reach.
let far: Vec<(u32, i32)> =
host.play
.iter()
.filter_map(|(conn, p)| {
let gm = p.merchant.as_ref()?;
let id = gm.merchant.mob?;
let g = host
.hub
.guests
.iter()
.find(|g| g.conn == *conn && host.hub.here(g))?;
let valid = self.mobs.iter().find(|m| m.id == id).is_some_and(|m| {
m.alive() && guest_within_entity_range(g, &m.bounds(), 4.0)
});
(!valid).then_some((*conn, gm.window))
})
.collect();
let far: Vec<(u32, i32)> = host
.play
.iter()
.filter_map(|(conn, p)| {
let gm = p.merchant.as_ref()?;
let id = gm.merchant.mob?;
let g = host
.hub
.guests
.iter()
.find(|g| g.conn == *conn && host.hub.here(g))?;
(!self.lan_merchant_valid(g, id)).then_some((*conn, gm.window))
})
.collect();
for (conn, window) in far {
if self.lan_merchant_closed(host, conn, Some(window)) {
host.hub.send_to(
@@ -126,12 +126,15 @@ impl Game<'_> {
(g.vehicle, g.vehicle_good) = (None, None);
return;
}
let mut ejected = false;
if let Some(i) = boat {
// AbstractBoat.tick on the server for a boat its rider drives:
// only the hurt animation and the damage wear off here.
let b = &mut self.boats[i];
b.hurt_time = (b.hurt_time - 1).max(0);
b.damage = (b.damage - 1.0).max(0.0);
// AbstractBoat.tickBubbleColumn: a down column ejects the rider.
ejected = std::mem::take(&mut b.eject_riders);
}
// ServerGamePacketListenerImpl.tick: the vehicle's last good position
// is where it is now, so a move the server made (a piston, a push)
@@ -161,7 +164,7 @@ impl Game<'_> {
return Some([p.x, p.y, p.z]);
}
if let Some(i) = game.boats.iter().position(|b| Some(b.id) == game.riding) {
if keys.shift {
if keys.shift || ejected {
game.dismount(i);
return None;
}
+31 -5
View File
@@ -246,7 +246,14 @@ impl Game<'_> {
return true;
}
}
if self.mobs[index].extra.leash == Some(Holder::Player) {
// Mob.checkAndHandleImportantInteractions: only the player the lead
// is tied to unties it (`getLeashHolder() == player`).
if self.mobs[index].extra.leash == Some(Holder::Player)
&& self.mobs[index]
.extra
.leash_player
.is_none_or(|p| p == self.acting_player_uuid())
{
let creative = self.creative;
self.break_leash(id, !creative, false);
self.lead_game_event("minecraft:entity_interact", at);
@@ -301,12 +308,31 @@ impl Game<'_> {
.filter(|&i| self.mobs[i].extra.leash == Some(Holder::Knot(knot_pos)))
.collect();
let existing_boats = self.boats_on(Holder::Knot(knot_pos));
// `LeashFenceKnotEntity.interact` runs only when the ray meets the knot
// before the post: the knot's box (`recalculateBoundingBox`, 0.375 wide,
// y + 0.375 to y + 0.875) is wider than the post, so a hit point on the
// post inside that box went through the knot first. Any other click on
// the fence is `FenceBlock.useWithoutItem` / `LeadItem.useOn`, which only
// ties the player's own mobs (`LeadItem.bindPlayerMobs`).
let knot_hit = !(existing.is_empty() && existing_boats.is_empty()) && {
let box_ = leash::knot_state(knot_pos);
let half = box_.width / 2.0 + 1.0e-6;
(hit.point[0] - box_.position.x).abs() <= half
&& (hit.point[2] - box_.position.z).abs() <= half
&& hit.point[1] >= box_.position.y - 1.0e-6
&& hit.point[1] <= box_.position.y + box_.height + 1.0e-6
// The knot is an entity: a hit on it counts only within the
// entity interaction range (`filterHitResult`), shorter than
// the block range the fence was hit at.
&& self.eye_position().is_some_and(|eye| {
let d = [0, 1, 2].map(|i| hit.point[i] - eye[i]);
d[0] * d[0] + d[1] * d[1] + d[2] * d[2] <= self.entity_reach * self.entity_reach
})
};
let held_item = self.held().map(|s| s.item.to_string());
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
// Shears snip every lead on the knot.
if held_item.as_deref() == Some("minecraft:shears")
&& !(existing.is_empty() && existing_boats.is_empty())
{
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);
@@ -335,7 +361,7 @@ impl Game<'_> {
attached = true;
}
}
if !attached && !sneaking {
if !attached && !sneaking && knot_hit {
let player = self.player_center();
let holder = self.acting_player_uuid();
for i in existing {
@@ -82,6 +82,13 @@ impl Game<'_> {
}
}
None if &*facts.get(state).name == "minecraft:water" => (p, count * 3, 3.0, 1.0, false),
// A mod block the level answered bone meal for: the port knows no
// `BonemealableBlock.Type` for it, and vanilla's `GROWER` is the
// one a block growing itself has, so the sparks stay inside it
// rather than spreading over its neighbours.
None if !facts.get(state).name.starts_with("minecraft:") => {
(p, count, 0.5, self.block_shape_top(p), true)
}
None => return,
};
self.spawn_particles(at, count, xz, height, floating, ParticleType::HappyVillager);
+17 -12
View File
@@ -411,18 +411,23 @@ impl Game<'_> {
}
self.update_dedicated_streaming();
self.update_world();
result = if paused {
self.tick_lan(true);
Ok(())
} else if k == 0 {
self.tick()
} else {
let running = running.unwrap_or_else(|| self.clock.world_runs());
if running {
self.tick_weather_effects();
}
self.tick_world(running)
};
result = horizon_client::block_contact::in_level(
(self.serving.is_some() || self.options.dedicated.is_some()).then_some(dim.id()),
|| {
if paused {
self.tick_lan(true);
Ok(())
} else if k == 0 {
self.tick()
} else {
let running = running.unwrap_or_else(|| self.clock.world_runs());
if running {
self.tick_weather_effects();
}
self.tick_world(running)
}
},
);
if result.is_err() {
break;
}
@@ -46,6 +46,8 @@ enum Kind {
Brewing,
/// `ChiseledBookShelfBlockEntity`: books only, one to a slot.
Bookshelf,
/// `JukeboxBlockEntity`'s single slot: a playable disc only.
Jukebox,
/// A mod block's block entity (`ModContainer`).
Mod,
/// `ShulkerBoxBlockEntity`: a shulker box doesn't go through a face into one.
@@ -209,6 +211,7 @@ impl Containers<'_> {
(BlockEntity::Crafter(_), _) => Kind::Crafter,
(BlockEntity::Brewing(_), _) => Kind::Brewing,
(BlockEntity::ChiseledBookshelf(_), _) => Kind::Bookshelf,
(BlockEntity::Jukebox(_), _) => Kind::Jukebox,
(_, "minecraft:hopper") => Kind::Hopper,
(_, n) if is_shulker_box(n) => Kind::Shulker,
_ => Kind::Generic,
@@ -302,6 +305,15 @@ impl Containers<'_> {
_ => false,
};
}
if view.kind == Kind::Jukebox {
// JukeboxBlockEntity.canPlaceItem: a playable disc, into the one
// slot, which has to be empty first (its maximum stack is 1).
return self
.items
.get(&stack.item)
.is_some_and(|i| i.jukebox_song.is_some())
&& self.get(view, slot).is_none();
}
if view.kind == Kind::Bookshelf {
// ListBackedContainer.canPlaceItem: `acceptsItemType` (the books)
// and a free slot, as `getMaxStackSize` is 1.
@@ -572,6 +584,12 @@ impl Game<'_> {
let (rest, moved) = self.move_in_item(from_hopper, view, stack, slot);
if moved {
self.view_changed(view);
// JukeboxBlockEntity.setTheItem through a hopper's or a dropper's
// transfer: the disc it lands on starts playing.
if view.kind == Kind::Jukebox {
let (x, y, z) = view.parts[0].0;
self.jukebox_inserted([x, y, z]);
}
}
rest
}
@@ -868,6 +886,12 @@ impl Game<'_> {
match self.add_item(source_hopper, hopper, one, None) {
None => {
self.view_changed(source);
// JukeboxBlockEntity.removeItem -> setTheItem(EMPTY): a
// disc a hopper takes out stops the song.
if source.kind == Kind::Jukebox {
let (x, y, z) = source.parts[0].0;
self.jukebox_taken([x, y, z]);
}
return true;
}
Some(rest) => self.put_back(source, slot, rest),
+39 -1
View File
@@ -56,7 +56,7 @@ impl CartKind {
Self::CommandBlock => "minecraft:command_block_minecart",
}
}
fn slots(self) -> usize {
pub(super) fn slots(self) -> usize {
match self {
Self::Chest => 27,
Self::Hopper => 5,
@@ -963,6 +963,7 @@ impl Game<'_> {
if let Some(i) = self.carts.iter().position(|x| x.id == c.id) {
self.eject_cart_mobs(i);
}
self.release_vehicle_container(c.id);
self.carts.retain(|x| x.id != c.id);
// ExplosionInteraction.TNT: the tnt_explosion_drop_decay rule.
let interaction = self.destroy_type("tnt_explosion_drop_decay");
@@ -1226,6 +1227,7 @@ impl Game<'_> {
}
} else {
self.eject_cart_mobs(i);
self.release_vehicle_container(id);
self.carts.remove(i);
}
}
@@ -1331,6 +1333,7 @@ impl Game<'_> {
self.push_from_cart(i);
} else {
self.eject_cart_mobs(i);
self.release_vehicle_container(c.id);
self.carts.remove(i);
self.riding = None;
}
@@ -1453,6 +1456,22 @@ impl Game<'_> {
}
best
}
/// The slots of the cart `id`, for a command: `getChestVehicleItem` and
/// `setChestVehicleItem` first unpack its loot table with no player, so
/// no `player_generates_container_loot` fires later. While its menu is
/// open the live slots are the placeholder copy (`open_cart_container`).
pub(super) fn cart_slots(&mut self, id: u32) -> Option<&mut Vec<Option<ItemStack>>> {
let c = self.carts.iter_mut().find(|c| c.id == id)?;
c.loot = None;
match self
.world
.block_entities
.get_mut(&(i32::MAX - 15, CART_KEY_Y, id as i32))
{
Some(BlockEntity::Chest(open)) => Some(open),
_ => Some(&mut c.items),
}
}
/// `ContainerEntity.unpackChestVehicleLootTable` for a player who opens a
/// chest minecart: a roll it still holds fires `player_generates_container_loot`
/// for its table, and the cart keeps its items from then on.
@@ -1561,6 +1580,19 @@ impl Game<'_> {
self.store_cart_items(key);
self.world.block_entities.remove(&key);
}
/// A chest or hopper minecart or a chest boat is about to be removed
/// (`Containers.dropContents` / `AbstractChestBoat.remove`): what an open
/// menu edited goes back to the vehicle, so it is what drops or travels
/// with it, and the menu's stand-in leaves the level. A menu that is
/// still open over it then has nothing left to take.
pub(super) fn release_vehicle_container(&mut self, id: u32) {
let key = (i32::MAX - 15, CART_KEY_Y, id as i32);
self.store_cart_items(key);
self.world.block_entities.remove(&key);
if self.container == Some(key) {
self.container = None;
}
}
/// The menu of a chest or hopper minecart or a chest boat works on the
/// vehicle's own items (`ContainerEntity`): the placeholder under `key`
/// that the menu edits is copied back to the vehicle.
@@ -1628,6 +1660,8 @@ impl Game<'_> {
}
if burning && self.carts[i].kind == CartKind::Tnt {
self.eject_cart_mobs(i);
let id = self.carts[i].id;
self.release_vehicle_container(id);
let c = self.carts.remove(i);
self.explode_cart(&c, arrow_velocity.length_squared());
return;
@@ -1653,6 +1687,10 @@ impl Game<'_> {
if !tnt_prime {
self.eject_cart_mobs(i);
}
let id = self.carts[i].id;
if !tnt_prime {
self.release_vehicle_container(id);
}
let mut c = self.carts.remove(i);
if c.kind == CartKind::Tnt && (fire || speed >= f64::from(0.01f32)) {
// MinecartTNT.destroy: prime instead of breaking.
+82 -10
View File
@@ -18,6 +18,28 @@ fn carried_block_matrix(base: glam::Mat4) -> glam::Mat4 {
* glam::Mat4::from_rotation_y(90.0f32.to_radians())
}
/// Drops the quads of `out[first..]` whose face normal points away from the eye, which
/// is the origin of camera-relative vertices. Each quad is four vertices carrying its
/// face normal in `Vertex::normal`, as `block_model_vertices` writes for diffuse faces.
fn cull_back_quads(out: &mut Vec<Vertex>, first: usize) {
let mut kept = first;
for quad in (first..out.len()).step_by(4) {
let vertices = &out[quad..quad + 4];
let n = vertices[0].normal;
let normal = glam::Vec3::new(f32::from(n[0]), f32::from(n[1]), f32::from(n[2]));
let centre = vertices.iter().fold(glam::Vec3::ZERO, |sum, v| {
sum + glam::Vec3::from_array(v.pos)
}) * 0.25;
// The eye is at the origin: the face is seen from its front when the eye is on the
// side its normal points to, that is when `normal . (eye - centre) > 0`.
if normal.dot(centre) < 0.0 {
out.copy_within(quad..quad + 4, kept);
kept += 4;
}
}
out.truncate(kept);
}
/// `CustomHeadLayer.translateToHead` and the banner's `head` display.
fn banner_matrix(head: glam::Mat4) -> glam::Mat4 {
head * glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0))
@@ -30,6 +52,24 @@ fn banner_matrix(head: glam::Mat4) -> glam::Mat4 {
* glam::Mat4::from_scale(glam::Vec3::new(2.0 / 3.0, -2.0 / 3.0, -2.0 / 3.0))
}
/// `BlockDecorationLayer.submit` and `CopperGolemModel.applyBlockOnAntennaTransform`, then the
/// block model's own display transformation, which `BlockModelRenderState.submitModel` post-multiplies.
fn antenna_block_matrix(head: glam::Mat4, flowerbed_shift: bool) -> glam::Mat4 {
// UNIT_CUBE_BOTTOM_CENTER_TO_ANTENNA_CENTER: translation(-0.5, 0, -0.5)
// then rotateAround(Z 180, 0.5, 0.5, 0.5), which JOML post-multiplies.
let unit = glam::Mat4::from_translation(glam::Vec3::new(-0.5, 0.0, -0.5))
* glam::Mat4::from_translation(glam::Vec3::splat(0.5))
* glam::Mat4::from_rotation_z(180.0f32.to_radians())
* glam::Mat4::from_translation(glam::Vec3::splat(-0.5));
let t = head * glam::Mat4::from_translation(glam::Vec3::new(0.0, -1.75, 0.0)) * unit;
if flowerbed_shift {
// `BuiltInBlockModels.createFlowerBedModel`'s Transformation(0.25, 0, 0.25).
t * glam::Mat4::from_translation(glam::Vec3::new(0.25, 0.0, 0.25))
} else {
t
}
}
/// `ElderGuardianParticle.lifetime`.
pub(super) const APPARITION_TICKS: i32 = 30;
@@ -206,7 +246,13 @@ impl Game<'_> {
let base = render::base_transform(mob, alpha as f32, camera);
let t = carried_block_matrix(base);
// Preserve cross models (flowers, fungi) and state properties (facing).
let first = out.len();
self.block_model_vertices(state, 42, t, light, true, out);
// The carried block uses the item cutout render type, which culls back faces
// (RenderPipelines.ITEM_CUTOUT). Without that, both coplanar sides of a cross
// model are drawn and flicker. CULL_BACK would drop them too, but it turns off
// the normal lighting this model needs.
cull_back_quads(out, first);
}
}
/// `BlockDecorationLayer.submit`: the block item in a copper golem's
@@ -222,7 +268,6 @@ impl Game<'_> {
light: u32,
out: &mut Vec<Vertex>,
) {
use glam::{Mat4, Vec3};
if mob.species() != Species::CopperGolem || horizon_client::mobs::trader::is_invisible(mob)
{
return;
@@ -260,13 +305,12 @@ impl Game<'_> {
else {
return;
};
// UNIT_CUBE_BOTTOM_CENTER_TO_ANTENNA_CENTER: translation(-0.5, 0, -0.5)
// then rotateAround(Z 180, 0.5, 0.5, 0.5), which JOML post-multiplies.
let unit = Mat4::from_translation(Vec3::new(-0.5, 0.0, -0.5))
* Mat4::from_translation(Vec3::splat(0.5))
* Mat4::from_rotation_z(180.0f32.to_radians())
* Mat4::from_translation(Vec3::splat(-0.5));
let t = head * Mat4::from_translation(Vec3::new(0.0, -1.75, 0.0)) * unit;
// Only wildflowers and pink petals carry the flowerbed display shift.
let flowerbeds = matches!(
&*stack.item,
"minecraft:wildflowers" | "minecraft:pink_petals"
);
let t = antenna_block_matrix(head, flowerbeds);
self.block_model_vertices(state, 42, t, light, true, out);
}
/// `IronGolemFlowerLayer`: the poppy in the right arm while the golem
@@ -658,12 +702,13 @@ mod tests {
(Vec3::ONE, Vec3::new(-0.3535534, 0.3938461, -0.85688126)),
(Vec3::splat(0.5), Vec3::new(0.0, 0.6287693, -0.77137625)),
];
for base in [
let bases = [
Mat4::IDENTITY,
Mat4::from_translation(Vec3::new(3.0, 2.0, -7.0))
* Mat4::from_rotation_y(1.2)
* Mat4::from_scale(Vec3::new(-1.0, -1.0, 1.0)),
] {
];
for base in bases {
let block = carried_block_matrix(base);
for (point, expected) in samples {
let actual = block.transform_point3(point);
@@ -674,5 +719,32 @@ mod tests {
);
}
}
// `antenna_block_matrix` with an identity head, from the 26.3 expressions:
// `UNIT_CUBE_BOTTOM_CENTER_TO_ANTENNA_CENTER` (`translate(-0.5, 0, -0.5)`
// then `rotateAround(Z 180, 0.5, 0.5, 0.5)`) sends (x, y, z) to
// (0.5 - x, 1 - y, z - 0.5), the antenna drops it 1.75, and
// `createFlowerBedModel`'s quarter block lands on (-0.25, 0, 0.25) because
// the Z-180 turn flips it.
let antenna = [
(Vec3::ZERO, Vec3::new(0.5, -0.75, -0.5)),
(Vec3::X, Vec3::new(-0.5, -0.75, -0.5)),
(Vec3::Y, Vec3::new(0.5, -1.75, -0.5)),
(Vec3::Z, Vec3::new(0.5, -0.75, 0.5)),
(Vec3::ONE, Vec3::new(-0.5, -1.75, 0.5)),
(Vec3::splat(0.5), Vec3::new(0.0, -1.25, 0.0)),
];
for (shifted, shift) in [(false, Vec3::ZERO), (true, Vec3::new(-0.25, 0.0, 0.25))] {
for base in bases {
let m = antenna_block_matrix(base, shifted);
for (point, expected) in antenna {
let actual = m.transform_point3(point);
let expected = base.transform_point3(expected + shift);
assert!(
actual.abs_diff_eq(expected, 1e-6),
"{actual:?} != {expected:?}"
);
}
}
}
}
}
+21 -27
View File
@@ -14,10 +14,7 @@ use horizon_client::{
mobs::{
Mob, Species,
equip::{self, Facts, ItemTags, Slot},
finalize::{
self, set_drop_chance, set_item, set_slot_enchantments, slot_enchantments,
stored_drop_chance,
},
finalize::{self, set_drop_chance, set_item_stack, stored_drop_chance},
},
};
use horizon_core::{geometry::Vec3, random::RandomSource};
@@ -92,27 +89,29 @@ impl Game<'_> {
if !replace {
return None;
}
// shortcut: a mob's equipment is one item per slot here, so a hand
// takes one item of a stack where vanilla takes the whole stack;
// upgrade with a per-slot count in `Extra`.
let taken = 1;
// `EquipmentSlot.limit`: an armour slot takes one item, a hand the
// whole stack.
let taken = if slot.is_armor() {
1
} else {
stack.count.max(1)
};
let m = &mut self.mobs[index];
let old = finalize::item(m, slot.index());
let old = finalize::item_stack(m, slot.index());
let chance = stored_drop_chance(m, slot.index());
let old_enchantments = slot_enchantments(m, slot.index());
// `DropChances.byEquipment` against the roll the equip makes.
let drop_old = old.is_some() && (m.random.next_float() - 0.1).max(0.0) < chance;
set_item(m, slot.index(), item);
// The stack's enchantments come with it: a Sharpness V sword stays sharp.
set_slot_enchantments(m, slot.index(), stack.enchantments().to_vec());
// The whole stack comes with it: its enchantments (a Sharpness V sword
// stays sharp), damage, name and components.
let mut equipped = stack.clone();
equipped.count = taken;
set_item_stack(m, slot.index(), equipped);
// `setItemSlotAndDropWhenKilled`, then `persistenceRequired`.
set_drop_chance(m, slot.index(), 2.0);
m.persistent = true;
let at = [m.position.x, m.position.y, m.position.z];
if drop_old && let Some(old) = old {
let mut dropped = ItemStack::new(&old, 1);
dropped.set_crafting_enchantments(old_enchantments);
self.spawn_item(dropped, at, super::dispenser::new_item_motion(), 10);
self.spawn_item(old, at, super::dispenser::new_item_motion(), 10);
}
Some(taken)
}
@@ -120,9 +119,10 @@ impl Game<'_> {
/// `Mob.canReplaceCurrentItem` for what `m` wears or holds in `slot`.
fn can_replace(&self, index: usize, stack: &ItemStack, slot: Slot) -> bool {
let m = &self.mobs[index];
let Some(current) = finalize::item(m, slot.index()) else {
let Some(worn_stack) = finalize::item_stack(m, slot.index()) else {
return true;
};
let current = worn_stack.item.clone();
let species = m.species();
// `Drowned.canReplaceCurrentItem` never parts with a nautilus shell.
if species == Species::Drowned && &*current == "minecraft:nautilus_shell" {
@@ -140,21 +140,15 @@ impl Game<'_> {
// Binding's, the only enchantment that carries one.
prevents_armor_change: stack.enchantment_level("minecraft:binding_curse") > 0,
};
let worn_enchantments = slot_enchantments(m, slot.index());
let (armor, toughness, attack) = self.attributes(m, &current, slot);
// shortcut: what a mob already wears keeps no damage value or name here
// (`Extra` records neither), so a worn piece always compares as fresh;
// upgrade with a stack per equipment slot.
let worn = Facts {
armor,
armor_toughness: toughness,
attack_damage: attack,
enchantments: worn_enchantments.len(),
damage: 0,
custom_name: false,
prevents_armor_change: worn_enchantments
.iter()
.any(|(id, level)| &**id == "minecraft:binding_curse" && *level > 0),
enchantments: worn_stack.enchantments().len(),
damage: worn_stack.damage as i32,
custom_name: worn_stack.custom_name().is_some(),
prevents_armor_change: worn_stack.enchantment_level("minecraft:binding_curse") > 0,
};
let preferred = equip::preferred_weapon_tag(species).map(|tag| {
(
+67 -16
View File
@@ -16,6 +16,27 @@ pub(super) struct ModsState {
stopped: Vec<String>,
}
/// Freeze native component IDs before reading content or opening a connection.
/// With no mod registering any, nothing is installed, which every reader
/// treats as no custom components, and `registries.json` isn't read.
pub(super) fn register_components(root: &std::path::Path) -> horizon_client::Result<()> {
let mut names = Vec::new();
Game::native_component_types(&mut names);
if names.is_empty() {
return Ok(());
}
let path =
horizon_protocol::registry::Builtin::find(root).ok_or("registries.json not found")?;
let builtin = horizon_protocol::registry::Builtin::load(&path)?;
let types = builtin
.get("minecraft:data_component_type")
.ok_or("missing data component registry")?;
let first = i32::try_from(types.names.len())?;
horizon_protocol::item::custom::install(first, names)
.map_err(|error| format!("invalid native component registration: {error}"))?;
Ok(())
}
/// `root` with the content mods that are on layered over it
/// (docs/modpacks.md): their blocks, items, data and assets. Read once at
/// start, before the resources load; a mod whose content is refused is left
@@ -31,6 +52,7 @@ pub(super) fn content_root(
let mut paths = horizon_mods::find(&here.join(mod_list::DIR));
paths.retain(|p| !dev.contains(p));
paths.extend(dev.iter().cloned());
let mut known = None;
let mut mods: Vec<ContentMod> = Vec::new();
for path in &paths {
let Ok(package) = Package::read(path) else {
@@ -44,23 +66,30 @@ pub(super) fn content_root(
{
continue;
}
let content = Content::read(path, m).and_then(|content| {
// The first of two mods owning one namespace stays on.
let own = (m.id.as_str(), content.namespaces.as_slice());
match mods
.iter()
.find_map(|c| horizon_mods::content::clash((&c.id, &c.content.namespaces), own))
{
Some(e) => Err(e),
None => Ok(content),
let content = match Content::read(path, m) {
Ok(content) => content,
Err(e) => {
log::warn!("Horizon: mod {} content not loaded: {e}", m.id);
continue;
}
});
match content {
Ok(content) => mods.push(ContentMod {
id: m.id.clone(),
content,
}),
Err(e) => log::warn!("Horizon: mod {} content not loaded: {e}", m.id),
};
let known = match &mut known {
Some(types) => types,
None => match mod_content::known_components(root) {
Ok(types) => known.insert(types),
Err(e) => {
log::warn!("Horizon: content mods not loaded: {e}");
register_mob_effects(&[]);
return root.to_owned();
}
},
};
let candidate = ContentMod {
id: m.id.clone(),
content,
};
if let Err(e) = mod_content::accept_mod(&mut mods, candidate, known) {
log::warn!("Horizon: mod {} content not loaded: {e}", m.id);
}
}
match mod_content::resource_root(root, &mods, &here.join(".horizon-cache")) {
@@ -73,11 +102,13 @@ pub(super) fn content_root(
);
}
register_mob_effects(&mods);
register_entity_types(&mods);
out
}
Err(e) => {
log::warn!("Horizon: content mods not loaded: {e}");
register_mob_effects(&[]);
register_entity_types(&[]);
root.to_owned()
}
}
@@ -110,6 +141,26 @@ fn register_mob_effects(mods: &[horizon_client::mod_content::ContentMod]) {
}
}
/// the mods' thrown entity types, as their mob effects: set once, from the
/// startup root.
fn register_entity_types(mods: &[horizon_client::mod_content::ContentMod]) {
use horizon_client::projectiles;
let types = horizon_client::mod_content::entity_types(mods);
let set = projectiles::register_thrown_types(
types
.iter()
.map(|t| (t.id.clone(), t.item.clone(), t.damage)),
);
if !set
&& projectiles::thrown_types()
.iter()
.map(|t| t.id)
.ne(types.iter().map(|t| t.id.as_str()))
{
log::warn!("Horizon: the mods' entity 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";
@@ -319,6 +319,16 @@ impl Game<'_> {
v.extend((0..open.columns * 3).map(|i| CHEST + i));
Some(v)
}
/// How many slots `mount_slots` lists, counted without building it
/// (`Game::slot_count` asks every frame).
pub(super) fn mount_slot_count(&self) -> Option<usize> {
let open = self
.mount_menu
.open
.as_ref()
.filter(|_| self.mount_active())?;
Some(36 + usize::from(open.show_saddle) + usize::from(open.show_armor) + open.columns * 3)
}
pub(super) fn mount_slot_position(&self, i: usize) -> Option<(f32, f32)> {
let open = self
.mount_menu
+25 -6
View File
@@ -17,6 +17,15 @@ use horizon_core::{
random::RandomSource,
};
fn steering_item(species: Species, main: Option<&str>, offhand: Option<&str>) -> bool {
let item = match species {
Species::Pig => "minecraft:carrot_on_a_stick",
Species::Strider => "minecraft:warped_fungus_on_a_stick",
_ => return false,
};
main == Some(item) || offhand == Some(item)
}
/// `passengerAttachments` minus the player's 0.6 vehicle attachment.
fn seat(s: Species) -> f64 {
let attach = match s {
@@ -367,12 +376,11 @@ impl Game<'_> {
let strafe = f32::from(u8::from(keys.left)) - f32::from(u8::from(keys.right));
let forward = f32::from(u8::from(keys.forward)) - f32::from(u8::from(keys.backward));
// LocalPlayer.aiStep: the riding jump charges while space is held.
let held = self.held().map(|s| s.item.clone());
let steer = match self.mobs[i].species() {
Species::Pig => held.as_deref() == Some("minecraft:carrot_on_a_stick"),
Species::Strider => held.as_deref() == Some("minecraft:warped_fungus_on_a_stick"),
_ => false,
};
let steer = steering_item(
self.mobs[i].species(),
self.held().map(|s| s.item.as_ref()),
self.offhand.as_ref().map(|s| s.item.as_ref()),
);
let jump = self.charge_mount_jump(i, keys);
let m = &mut self.mobs[i];
if jump > 0.0 {
@@ -839,6 +847,17 @@ mod tests {
use super::*;
use std::collections::HashMap;
#[test]
fn steering_accepts_the_correct_item_in_either_hand() {
let carrot = "minecraft:carrot_on_a_stick";
let fungus = "minecraft:warped_fungus_on_a_stick";
assert!(steering_item(Species::Pig, Some(carrot), None));
assert!(steering_item(Species::Pig, None, Some(carrot)));
assert!(steering_item(Species::Strider, None, Some(fungus)));
assert!(!steering_item(Species::Pig, Some(fungus), None));
assert!(!steering_item(Species::Horse, Some(carrot), Some(fungus)));
}
/// A lava sea up to y 30 with full blocks placed in it; air above.
struct Sea {
blocks: HashMap<[i32; 3], ()>,
+4
View File
@@ -10,6 +10,10 @@ use horizon_client::{
impl Game<'_> {
/// `Minecraft.tick` -> `MusicManager.tick`, once per client tick.
pub(super) fn tick_music(&mut self) {
// Splitscreen: one music, P1's.
if self.options.split_guest {
return;
}
let (music, volume) = self.situational_music();
// Options.musicFrequency's listener: MusicManager.setMinutesBetweenSongs.
let frequency = MusicFrequency::parse(&self.settings.file.name("musicFrequency"));
+6 -4
View File
@@ -287,13 +287,15 @@ impl Game<'_> {
lift = f64::from(9.0f32 * 1.15 * 0.025);
}
lines.push((shown, lift));
// The tag rides with the body, between last tick's position and this one's.
let at = a.render_position(alpha);
let l = self.world.light(
a.position.x.floor() as i32,
(a.position.y + height).floor() as i32,
a.position.z.floor() as i32,
at.x.floor() as i32,
(at.y + height).floor() as i32,
at.z.floor() as i32,
);
tags.push(NameTag {
at: glam::DVec3::new(a.position.x, a.position.y + height + 0.5, a.position.z),
at: glam::DVec3::new(at.x, at.y + height + 0.5, at.z),
lines,
light: l,
discrete: false,
@@ -0,0 +1,391 @@
//! Mod block entities drawn on the client: `native_block_entity_submit`
//! (vanilla's `BlockEntityRenderer.submit`) gets a [`BlockEntityDraw`], whose
//! pose stack (`PoseStack`) places what the helpers here add to the entity
//! batch, as vanilla's block entity renderers draw.
use super::{Dim, Game};
use glam::{DVec3, Mat4, Quat, Vec3};
use horizon_assets::{
bake::{bake_flat_face, default_uv},
model::{Direction, Material},
};
use horizon_client::{
block_entity_render::{diffuse, diffuse_lights, vertex_light},
containers::BlockEntity,
gfx::Cull,
hud::Rect,
mesher::Vertex,
};
use horizon_world::registry::BlockStateId;
/// `BlockEntityRenderer.getViewDistance`'s default, in blocks.
const VIEW_DISTANCE: f64 = 64.0;
/// A mod block entity's bounds until its mod says otherwise: the block with
/// the block of margin Horizon culls vanilla's block entities with.
const DEFAULT_BOUNDS: [f32; 6] = [-1.0, -1.0, -1.0, 2.0, 2.0, 2.0];
/// Whether `bounds` (min then max, in blocks from `origin`, the block's corner
/// relative to the camera) meet the frustum; an infinite bound always does
/// (NeoForge's `INFINITE_EXTENT_AABB`), as does a frame without a frustum.
fn in_frustum(frustum: Option<&impl Cull>, origin: Vec3, bounds: [f32; 6]) -> bool {
if bounds.iter().any(|b| !b.is_finite()) {
return true;
}
let min = origin + Vec3::new(bounds[0], bounds[1], bounds[2]);
let max = origin + Vec3::new(bounds[3], bounds[4], bounds[5]);
frustum.is_none_or(|f| f.intersects(min, max))
}
/// One mod block entity's frame, as `submit` gets it: the render state's
/// partial tick, game time and light, and the pose stack the helpers draw
/// under (`native_draw_quad`, `native_draw_cube`, `native_draw_block`).
// Native mods read and drive it; nothing in Horizon does.
#[allow(dead_code)]
pub struct BlockEntityDraw {
/// The frame's partial tick, 0 to 1 (`BlockEntityRenderState`).
pub partial: f32,
/// The level's game time, for animations.
pub game_time: i64,
/// The light the helpers draw with: the block's own
/// (`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,
poses: Vec<Mat4>,
lights: (Vec3, Vec3),
vertices: Vec<Vertex>,
}
#[allow(dead_code)]
impl BlockEntityDraw {
/// `PoseStack.pushPose`: `pop` comes back to the pose as it is now.
pub fn push(&mut self) {
let pose = self.pose();
self.poses.push(pose);
}
/// `PoseStack.popPose`; the block's own pose is never popped.
pub fn pop(&mut self) {
if self.poses.len() > 1 {
self.poses.pop();
}
}
/// Moves what is drawn next by `by`, in blocks.
pub fn translate(&mut self, by: [f32; 3]) {
self.apply(Mat4::from_translation(Vec3::from_array(by)));
}
/// Turns what is drawn next by `degrees` about `axis` (counterclockwise
/// seen from the axis' positive end, as `Axis.rotationDegrees`) through
/// `pivot`, in blocks from the pose's origin: `[0.5; 3]` is the block's centre.
pub fn rotate_about(&mut self, axis: [f32; 3], degrees: f32, pivot: [f32; 3]) {
let axis = Vec3::from_array(axis).normalize_or(Vec3::Y);
let pivot = Vec3::from_array(pivot);
self.apply(
Mat4::from_translation(pivot)
* Mat4::from_quat(Quat::from_axis_angle(axis, degrees.to_radians()))
* Mat4::from_translation(-pivot),
);
}
/// Scales what is drawn next by `by` about the pose's origin.
pub fn scale(&mut self, by: [f32; 3]) {
self.apply(Mat4::from_scale(Vec3::from_array(by)));
}
fn apply(&mut self, m: Mat4) {
if let Some(pose) = self.poses.last_mut() {
*pose *= m;
}
}
fn pose(&self) -> Mat4 {
self.poses.last().copied().unwrap_or(Mat4::IDENTITY)
}
/// A quad of `corners` (blocks, under the pose) with unit UVs, shaded as
/// vanilla's block entity models are (`Lighting`'s two lights).
fn quad(&mut self, corners: [[f32; 3]; 4], uv: [[f32; 2]; 4], sprite: Rect, tint: u32) {
let pose = self.pose();
let at = corners.map(|c| pose.transform_point3(Vec3::from_array(c)));
let normal = (at[1] - at[0]).cross(at[2] - at[0]).normalize_or_zero();
let normal = if normal == Vec3::ZERO {
(at[2] - at[1]).cross(at[3] - at[1]).normalize_or(Vec3::Y)
} else {
normal
};
let color = shaded(tint, diffuse(normal, self.lights));
for (p, uv) in at.into_iter().zip(uv) {
self.vertices.push(Vertex {
pos: p.to_array(),
uv: [
sprite.u0 + (sprite.u1 - sprite.u0) * uv[0],
sprite.v0 + (sprite.v1 - sprite.v0) * uv[1],
],
layer: 0,
color,
light: self.light,
normal: [0; 4],
glint_uv: [0.0, 0.0],
});
}
}
}
/// One face of a `native_draw_cube` box: its sprite (`mymod:entity/cog`,
/// `mymod:block/casing`) and its UVs in texels of 16 per side, as a model
/// face's `uv` (none takes the box's own, as a model's default).
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct CubeFace<'a> {
pub sprite: &'a str,
pub uv: Option<[f32; 4]>,
}
/// 0xRRGGBB `tint` times `brightness`, as an opaque vertex colour.
fn shaded(tint: u32, brightness: f32) -> u32 {
let c = |shift: u32| (((tint >> shift) & 255) as f32 * brightness) as u32;
0xFF00_0000 | c(0) << 16 | c(8) << 8 | c(16)
}
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
/// `BlockEntityRenderer.getViewDistance` to the camera (`shouldRender`)
/// and with its bounds (`native_block_entity_render_bounding_box`) in the
/// frustum.
pub(super) fn mod_block_entity_geometry<'a>(
&self,
blocks: impl Iterator<Item = &'a ([i32; 3], u32)>,
camera: DVec3,
partial: f32,
out: &mut Vec<Vertex>,
) {
let mut draw = BlockEntityDraw {
partial,
game_time: self.world.game_time,
light: 0,
poses: Vec::new(),
lights: diffuse_lights(self.dim == Dim::Nether),
vertices: std::mem::take(out),
};
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.
if (corner + 0.5).distance_squared(camera) >= VIEW_DISTANCE * VIEW_DISTANCE {
continue;
}
let Some(BlockEntity::Mod(entity)) =
self.world.block_entities.get(&(pos[0], pos[1], pos[2]))
else {
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) {
continue;
}
let start = draw.vertices.len();
self.mark_block_entity(start, state);
draw.poses.clear();
draw.poses.push(Mat4::from_translation(origin));
draw.light = vertex_light(self.world.light(pos[0], pos[1], pos[2]));
self.native_block_entity_submit(pos, block, id, entity, &mut draw);
self.cull_entity_vertices(&mut draw.vertices, start);
}
*out = draw.vertices;
}
}
/// Helpers for drawing mod block entities, kept as stable as the hook points.
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// Draws a quad with `corners` in blocks under `draw`'s pose, in a model
/// face's order (top left, bottom left, bottom right, top right as seen
/// from its front), textured with the `uv` rectangle (u0, v0, u1, v1 in
/// texels of 16 per side) of `sprite` (`mymod:entity/screen`,
/// `mymod:block/casing`, `block/stone`) times the 0xRRGGBB `tint`
/// (0xFFFFFF for none). False for an unknown sprite. API @API:block_entity_submit@.
pub(super) fn native_draw_quad(
&self,
draw: &mut BlockEntityDraw,
sprite: &str,
corners: [[f32; 3]; 4],
uv: [f32; 4],
tint: u32,
) -> bool {
let Some(rect) = self.gui.atlas.sprites.get(sprite).copied() else {
return false;
};
let [u0, v0, u1, v1] = uv.map(|t| t / 16.0);
draw.quad(
corners,
[[u0, v0], [u0, v1], [u1, v1], [u1, v0]],
rect,
tint,
);
true
}
/// Draws a box from `from` to `to` (texels of 16 per block, as a block
/// model's element) under `draw`'s pose, with the faces given, by
/// `Direction` (down, up, north, south, west, east), baked as a model
/// element's are (`FaceBakery`), times the 0xRRGGBB `tint`. A turned box
/// is drawn under a turned pose (`BlockEntityDraw::rotate_about`). False
/// when a face's sprite is unknown; that face is left out. API @API:block_entity_submit@.
pub(super) fn native_draw_cube(
&self,
draw: &mut BlockEntityDraw,
from: [f32; 3],
to: [f32; 3],
faces: [Option<CubeFace<'_>>; 6],
tint: u32,
) -> bool {
let mut all = true;
for (direction, face) in Direction::ALL.into_iter().zip(faces) {
let Some(face) = face else { continue };
let Some(rect) = self.gui.atlas.sprites.get(face.sprite).copied() else {
all = false;
continue;
};
let uv = face.uv.unwrap_or_else(|| default_uv(from, to, direction));
let material = Material {
sprite: String::new(),
force_translucent: false,
};
if let Ok(quad) = bake_flat_face(from, to, uv, direction, material, -1) {
draw.quad(quad.positions, quad.uv, rect, tint);
}
}
all
}
/// Draws block `block`'s model (`mymod:cog`, any block with a model) in
/// the state with `properties` (`[("axis", "x")]`, the rest default)
/// under `draw`'s pose, as vanilla's `submitBlockModel` draws a block in
/// an entity's place: a block's baked model, with its default tints. A
/// moving part is often a block of the mod's own, never placed. False for
/// an unknown block or property. API @API:block_entity_submit@.
pub(super) fn native_draw_block(
&self,
draw: &mut BlockEntityDraw,
block: &str,
properties: &[(&str, &str)],
) -> bool {
let registry = &self.pack.assets.registry;
let Some(mut state) = registry
.default_state(block)
.and_then(|id| registry.state(id))
.cloned()
else {
return false;
};
for (key, value) in properties {
match state.properties.get_mut(*key) {
Some(v) => *v = (*value).to_owned(),
None => return false,
}
}
let Some(id) = registry.id(&state) else {
return false;
};
let pose = draw.pose();
self.block_model_vertices(id, 42, pose, draw.light, true, &mut draw.vertices);
true
}
}
#[cfg(test)]
mod tests {
use super::*;
fn draw() -> BlockEntityDraw {
BlockEntityDraw {
partial: 0.0,
game_time: 0,
light: 0xF0F0,
poses: vec![Mat4::from_translation(Vec3::new(10.0, 0.0, 0.0))],
lights: diffuse_lights(false),
vertices: Vec::new(),
}
}
fn sprite() -> Rect {
Rect {
u0: 0.5,
v0: 0.25,
u1: 0.75,
v1: 0.5,
w: 16,
h: 16,
}
}
#[test]
fn the_pose_stack_turns_about_its_pivot_and_pops_back() {
let mut d = draw();
d.push();
d.rotate_about([0.0, 1.0, 0.0], 90.0, [0.5, 0.5, 0.5]);
// (1, 0, 0.5) turns a quarter about the block's centre to (0.5, 0, 0).
let p = d.pose().transform_point3(Vec3::new(1.0, 0.0, 0.5));
assert!(p.abs_diff_eq(Vec3::new(10.5, 0.0, 0.0), 1e-5), "{p}");
d.pop();
d.pop();
// Popping past the block's own pose keeps it.
let p = d.pose().transform_point3(Vec3::ZERO);
assert_eq!(p, Vec3::new(10.0, 0.0, 0.0));
d.translate([0.0, 2.0, 0.0]);
d.scale([2.0, 2.0, 2.0]);
let p = d.pose().transform_point3(Vec3::ONE);
assert_eq!(p, Vec3::new(12.0, 4.0, 2.0));
}
#[test]
fn a_quad_lands_in_its_sprite_lit_and_shaded() {
let mut d = draw();
// An upward face: full brightness under vanilla's level lights is
// below 1 (two lights), so the colour is shaded but opaque.
d.quad(
[
[0.0, 1.0, 0.0],
[0.0, 1.0, 1.0],
[1.0, 1.0, 1.0],
[1.0, 1.0, 0.0],
],
[[0.0, 0.0], [0.0, 1.0], [1.0, 1.0], [1.0, 0.0]],
sprite(),
0xFFFFFF,
);
assert_eq!(d.vertices.len(), 4);
let v = d.vertices[2];
assert_eq!(v.pos, [11.0, 1.0, 1.0]);
assert_eq!(v.uv, [0.75, 0.5]);
assert_eq!(v.light, 0xF0F0);
let up = diffuse(Vec3::Y, diffuse_lights(false));
assert_eq!(v.color, shaded(0xFFFFFF, up));
assert_eq!(v.color >> 24, 0xFF);
}
/// A frustum that sees nothing.
struct Blind;
impl Cull for Blind {
fn intersects(&self, _: Vec3, _: Vec3) -> bool {
false
}
fn contains(&self, _: Vec3, _: Vec3) -> bool {
false
}
}
#[test]
fn infinite_bounds_and_frames_without_a_frustum_are_never_culled() {
let origin = Vec3::new(1.0e6, 0.0, 0.0);
let infinite = [f32::NEG_INFINITY, 0.0, 0.0, f32::INFINITY, 1.0, 1.0];
assert!(!in_frustum(Some(&Blind), origin, DEFAULT_BOUNDS));
assert!(in_frustum(Some(&Blind), origin, infinite));
assert!(in_frustum(None::<&Blind>, origin, DEFAULT_BOUNDS));
}
#[test]
fn a_tint_keeps_its_channels_in_vertex_order() {
// 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);
}
}
@@ -0,0 +1,93 @@
//! Native item data component helpers.
use super::Game;
use horizon_client::items::ItemStack;
use horizon_world::nbt::Tag;
/// Failure to access a native item component.
#[derive(Debug)]
pub(crate) enum NativeComponentError {
UnknownType,
InvalidValue(horizon_protocol::Error),
}
impl std::fmt::Display for NativeComponentError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::UnknownType => f.write_str("unregistered component type"),
Self::InvalidValue(error) => write!(f, "invalid component value: {error}"),
}
}
}
impl std::error::Error for NativeComponentError {}
// Native mods use these helpers.
#[allow(dead_code)]
impl Game<'_> {
/// Read a native component, including the item's default when not patched.
pub(super) fn native_component<'a>(
&'a self,
stack: &'a ItemStack,
id: &str,
) -> Result<Option<&'a Tag>, NativeComponentError> {
if horizon_protocol::item::custom::id(id).is_none() {
return Err(NativeComponentError::UnknownType);
}
if let Some(data) = stack.data() {
if data.other(&format!("!{id}")).is_some() {
return Ok(None);
}
if let Some(value) = data.other(id) {
return Ok(Some(value));
}
}
Ok(self
.items()
.get(&stack.item)
.and_then(|item| item.custom_components.get(id)))
}
/// Set a registered native component after checking the network NBT limits.
pub(super) fn native_set_component(
&self,
stack: &mut ItemStack,
id: &str,
value: Tag,
) -> Result<(), NativeComponentError> {
if horizon_protocol::item::custom::id(id).is_none() {
return Err(NativeComponentError::UnknownType);
}
horizon_protocol::item::custom::validate(&value)
.map_err(NativeComponentError::InvalidValue)?;
let default = self
.items()
.get(&stack.item)
.and_then(|item| item.custom_components.get(id));
let value = (default != Some(&value)).then_some(value);
let removed = format!("!{id}");
let data = stack.data_mut();
data.set_other(&removed, None);
data.set_other(id, value);
stack.tidy();
Ok(())
}
/// Remove a native component, including its item default.
pub(super) fn native_remove_component(
&self,
stack: &mut ItemStack,
id: &str,
) -> Result<(), NativeComponentError> {
if horizon_protocol::item::custom::id(id).is_none() {
return Err(NativeComponentError::UnknownType);
}
let default = self
.items()
.get(&stack.item)
.is_some_and(|item| item.custom_components.contains_key(id));
let removed = format!("!{id}");
let data = stack.data_mut();
data.set_other(id, None);
data.set_other(&removed, default.then(|| Tag::Compound(Default::default())));
stack.tidy();
Ok(())
}
}
@@ -0,0 +1,215 @@
//! A mod's own advancement criteria triggers (docs/native-mods.md,
//! "Advancement triggers"). An advancement of a content mod or a world's data
//! pack names a trigger of the mod's (`mymod:pipe_connected`); the tree keeps
//! it as it keeps vanilla's, and the mod fires it where its event happens, as
//! a NeoForge mod calls its own `SimpleCriterionTrigger.trigger`.
use super::{Game, advancements::item_view, commands::Target};
use horizon_client::{
advancements::{self as adv, EntityView},
items::ItemStack,
};
use serde_json::Value;
/// One open criterion of a mod's trigger, as the mod's test sees it.
pub(crate) struct NativeCriterion<'a> {
/// The criterion's `conditions`, `Null` when it has none. Its `player`
/// predicate is the game's to test, as vanilla's `SimpleInstance.player`.
pub(crate) conditions: &'a Value,
world: &'a dyn adv::World,
}
#[allow(dead_code)]
impl NativeCriterion<'_> {
/// Whether `stack` passes the item predicate under `key` in the
/// conditions, as the game's own triggers read an `ItemPredicate`:
/// `items` (an id, a list or a `#tag`), `count`, the `potion_contents`
/// and `damage` components, and the `enchantments` and `damage`
/// predicates; other components and predicates aren't checked. A missing
/// key passes, as an absent `Optional<ItemPredicate>` does.
pub(crate) fn item(&self, key: &str, stack: &ItemStack) -> bool {
adv::item(&self.conditions[key], &item_view(stack), self.world)
}
}
/// Whether a mod may fire `trigger`: a namespaced id outside `minecraft`,
/// whose triggers the game fires from its own events.
fn mod_trigger(trigger: &str) -> bool {
trigger
.split_once(':')
.is_some_and(|(ns, path)| !ns.is_empty() && !path.is_empty() && ns != "minecraft")
}
/// Helpers for server mods, kept as stable as the hook points.
// Native mods use these; nothing in Horizon does.
#[allow(dead_code)]
impl Game<'_> {
/// Fires the mod's criteria trigger `trigger` (`mymod:pipe_connected`)
/// for the player with that UUID: vanilla's
/// `SimpleCriterionTrigger.trigger`. Each criterion of theirs with that
/// trigger, not yet granted, of an advancement not yet done, is tested
/// by `test` and then by its `player` predicate; those that pass are
/// granted, and an advancement they complete is awarded with its toast,
/// chat line and rewards, as the game's own triggers award.
///
/// The acting player is reached inside their hook; any player on the
/// server, in their own level, only where no player's state is swapped in
/// (`native_tick`, `native_menu_clicked`), as `native_run_command`. True
/// when the player was reached, whether or not anything was granted;
/// false off a server, when the player isn't on or can't be reached from
/// here, and for a `minecraft:` trigger or a name without a namespace.
/// API @API:native_criteria@.
pub(super) fn native_trigger_criterion(
&mut self,
uuid: u128,
trigger: &str,
test: impl Fn(&NativeCriterion<'_>) -> bool,
) -> bool {
if !mod_trigger(trigger) || self.remote.is_some() {
return false;
}
if self.lan_acting_uuid == Some(uuid) {
self.trigger_mod_criteria(trigger, &test);
return true;
}
if self.lan_acting.is_some() {
return false;
}
let Some(conn) = self.lan.as_ref().and_then(|host| {
host.hub
.guests
.iter()
.find(|g| g.placed && g.uuid == uuid)
.map(|g| g.conn)
}) else {
return false;
};
// In their level: the predicates read where they are, and a reward
// that doesn't fit drops there.
self.for_player(Target::Guest(conn), |g| {
g.trigger_mod_criteria(trigger, &test)
})
.is_some()
}
/// `trigger` for a mod's trigger, for the player in place.
fn trigger_mod_criteria(&mut self, trigger: &str, test: &dyn Fn(&NativeCriterion<'_>) -> bool) {
let me = self.player_entity();
self.trigger(trigger, |conditions, world| {
passes(conditions, &me, world, test)
});
}
}
/// `SimpleCriterionTrigger.trigger`'s test of one criterion for the player
/// `me`: the mod's matcher, then the `player` predicate, in the player's own
/// context (`EntityPredicate.createContext(player, player)`).
fn passes(
conditions: &Value,
me: &EntityView,
world: &dyn adv::World,
test: &dyn Fn(&NativeCriterion<'_>) -> bool,
) -> bool {
let player = adv::Entities {
this: Some(me),
origin: me.position,
tool: None,
block_pos: None,
};
test(&NativeCriterion { conditions, world })
&& adv::conditions(&conditions["player"], &player, world)
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::BTreeMap;
struct Plains;
impl adv::World for Plains {
fn dimension(&self) -> &str {
"minecraft:overworld"
}
fn biome(&self, _: [i32; 3]) -> String {
"minecraft:plains".into()
}
fn structures(&self, _: [i32; 3]) -> Vec<String> {
Vec::new()
}
fn block(&self, _: [i32; 3]) -> (String, BTreeMap<String, String>) {
("minecraft:air".into(), BTreeMap::new())
}
fn fluid(&self, _: [i32; 3]) -> Option<String> {
None
}
fn can_see_sky(&self, _: [i32; 3]) -> bool {
true
}
fn light(&self, _: [i32; 3]) -> i32 {
15
}
fn smokey(&self, _: [i32; 3]) -> bool {
false
}
fn in_tag(&self, registry: &str, tag: &str, id: &str) -> bool {
registry == "item" && tag == "c:ingots/copper" && id == "minecraft:copper_ingot"
}
}
/// A mod's `pipe_connected` criterion: the pipe's item by a convention
/// tag, and only for a player on fire (a loot condition list, as
/// vanilla's advancements write `player`).
fn criterion() -> Value {
json!({
"pipe": {"items": "#c:ingots/copper", "count": {"min": 2}},
"player": [{
"condition": "minecraft:entity_properties",
"entity": "this",
"predicate": {"flags": {"is_on_fire": true}}
}]
})
}
#[test]
fn a_mods_criterion_takes_its_own_test_and_the_player_predicate() {
let burning = EntityView {
kind: "minecraft:player".into(),
on_fire: true,
..EntityView::default()
};
let calm = EntityView {
on_fire: false,
..burning.clone()
};
let copper = ItemStack::new("minecraft:copper_ingot", 2);
let pipe = |stack: &ItemStack| {
let stack = stack.clone();
move |c: &NativeCriterion<'_>| c.item("pipe", &stack)
};
let c = criterion();
assert!(passes(&c, &burning, &Plains, &pipe(&copper)));
assert!(
!passes(&c, &calm, &Plains, &pipe(&copper)),
"the player predicate"
);
let one = ItemStack::new("minecraft:copper_ingot", 1);
assert!(
!passes(&c, &burning, &Plains, &pipe(&one)),
"the item's count"
);
let iron = ItemStack::new("minecraft:iron_ingot", 2);
assert!(!passes(&c, &burning, &Plains, &pipe(&iron)), "the tag");
// A criterion without the key or any conditions passes the item test.
let any = |c: &NativeCriterion<'_>| c.item("filter", &iron);
assert!(passes(&c, &burning, &Plains, &any));
assert!(passes(&Value::Null, &calm, &Plains, &any));
}
#[test]
fn mods_fire_only_their_own_namespaced_triggers() {
assert!(mod_trigger("mymod:pipe_connected"));
assert!(!mod_trigger("minecraft:inventory_changed"));
assert!(!mod_trigger("inventory_changed"));
assert!(!mod_trigger(":pipe_connected"));
}
}
@@ -130,7 +130,7 @@ impl Game<'_> {
/// client's copy of the world, for another dimension, a position outside
/// `/summon`'s bounds, a chunk that isn't loaded, a kind Horizon can't
/// load, or one a mod kept out (`native_entity_join_level`). API
/// @API:native_entities@.
/// 59.
pub(super) fn native_summon(
&mut self,
dimension: &str,
@@ -153,18 +153,29 @@ impl Game<'_> {
place_summoned(&mut tag, pos);
let from = self.mobs.len();
let first = self.next_entity + 1;
let deferred = (self.tnts.len(), self.falling.len(), self.rockets.len());
let target = self.spawn_entity_from_nbt(tag);
// These roots load with id 0 and have no command Target variant.
// Select the newly appended root, never an older waiting entity.
let root = match kind {
"minecraft:tnt" => self.tnts.get_mut(deferred.0).map(|e| &mut e.id),
"minecraft:falling_block" => self.falling.get_mut(deferred.1).map(|e| &mut e.id),
"minecraft:firework_rocket" => self.rockets.get_mut(deferred.2).map(|e| &mut e.id),
_ => None,
};
let root = root.map(|id| super::lan_entities::assign_entity_id(&mut self.next_entity, id));
self.native_mobs_joined(from, false);
// The root is the first entity spawned, also for kinds no command
// target stands for (experience orbs). An id handed out to an entity
// a mod then kept out is not given back, so the root must be there.
root_id(target, first, |id| self.orbs.iter().any(|o| o.id == id))
// Otherwise the root is the first entity spawned, also for kinds no
// command target stands for (experience orbs). An id handed out to an
// entity a mod then kept out is not given back, so the root must be
// there.
root.or_else(|| root_id(target, first, |id| self.orbs.iter().any(|o| o.id == id)))
}
/// Moves entity `id`, one Horizon keeps without simulating (a display, a
/// marker, an interaction), to `pos` facing `yaw` and `pitch` in degrees;
/// a display eases there over its `teleport` ticks. False for no such
/// entity, another dimension or a position outside `/summon`'s bounds.
/// Mobs move with `native_teleport_mob`. API @API:native_entities@.
/// Mobs move with `native_teleport_mob`. API 59.
pub(super) fn native_move_entity(
&mut self,
dimension: &str,
@@ -192,7 +203,7 @@ impl Game<'_> {
/// `block_display`, `item_display` or `text_display`): players see it
/// ease from what it showed over `interpolation` ticks. False for no such
/// display, another dimension, or a value that isn't finite, a zero
/// quaternion, or a duration out of range. API @API:native_entities@.
/// quaternion, or a duration out of range. API 59.
pub(super) fn native_set_display(
&mut self,
dimension: &str,
@@ -226,7 +237,7 @@ impl Game<'_> {
/// Removes entity `id`, one Horizon keeps without simulating, as `/kill`
/// does (it drops nothing and isn't saved). False for no such entity or
/// another dimension. Mobs go with `native_remove_mob`. API
/// @API:native_entities@.
/// 59.
pub(super) fn native_remove_entity(&mut self, dimension: &str, id: u32) -> bool {
if !self.native_entity_level(dimension) || !self.others.iter().any(|o| o.id == id) {
return false;
@@ -0,0 +1,138 @@
//! Explosions for native server mods (docs/native-mods.md, "Explosions"): a mod
//! sets one off as `Level.explode` does, in the level in place, which it names
//! by its `dimension` (`minecraft:overworld`) as `native_set_block` does.
use super::{Game, explosion::BlockInteraction};
/// The largest radius `native_explode` takes. A blast's cost grows with its
/// radius (its 1352 rays walk `radius * 1.3` blocks at most); vanilla's
/// largest is the wither's charged spawn, 7.
pub(super) const NATIVE_EXPLOSION_MAX_RADIUS: f32 = 32.0;
/// What a mod's blast does to blocks (`Level.ExplosionInteraction`). Built by
/// mods only.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) enum NativeBlast {
/// Blocks stay; entities are hurt and pushed (`ExplosionInteraction.NONE`).
Keep,
/// Blocks break and drop by the `block_explosion_drop_decay` rule
/// (`ExplosionInteraction.BLOCK`).
#[default]
Block,
/// Blocks break and drop by the `tnt_explosion_drop_decay` rule
/// (`ExplosionInteraction.TNT`).
Tnt,
}
/// Whether `native_explode` takes this centre and radius.
fn blast_ok(center: [f64; 3], radius: f32) -> bool {
center
.into_iter()
.all(|v| v.is_finite() && v.abs() <= 30_000_000.0)
&& radius.is_finite()
&& radius > 0.0
&& radius <= NATIVE_EXPLOSION_MAX_RADIUS
}
#[allow(dead_code)]
impl Game<'_> {
/// Explodes at `center` in the level in place, `dimension`, as
/// `Level.explode(null, x, y, z, radius, fire, interaction)` does: the
/// blocks it reaches break (`native_explosion_detonate` sees them first),
/// entities are hurt and pushed, `fire` lights a third of the air above
/// solid blocks, and everyone near hears and sees it. False, with nothing
/// done, on a client's copy of the world, for another dimension, a chunk
/// that isn't loaded, or a radius that isn't in `0 < radius <= 32`. Call
/// it from a server hook; a blast inside `native_explosion_detonate`
/// starts that hook again. API @API:native_explode@.
pub(super) fn native_explode(
&mut self,
dimension: &str,
center: [f64; 3],
radius: f32,
fire: bool,
blast: NativeBlast,
) -> bool {
if (self.serving.is_none() && self.options.dedicated.is_none())
|| dimension != self.dim.id()
|| !blast_ok(center, radius)
{
return false;
}
let chunk = (
(center[0].floor() as i32) >> 4,
(center[2].floor() as i32) >> 4,
);
if !self.world.chunks.contains_key(&chunk) {
return false;
}
let interaction = match blast {
NativeBlast::Keep => BlockInteraction::Keep,
NativeBlast::Block => self.destroy_type("block_explosion_drop_decay"),
NativeBlast::Tnt => self.destroy_type("tnt_explosion_drop_decay"),
};
// `Level.explode(null, ...)`: no source. Called from inside another
// blast (`native_explosion_detonate`), it must not take that blast's
// source, owner or flags, and that blast gets them back after.
let outer = self.take_blast_state();
let done = self
.server_explode(center, radius, interaction, fire)
.is_ok();
self.restore_blast_state(outer);
done
}
/// The blast going off now, cleared to a sourceless one.
fn take_blast_state(&mut self) -> BlastState {
let c = &mut self.combat;
BlastState {
source: c.explosion_source.take(),
cause: c.explosion_cause.take(),
source_named: std::mem::take(&mut c.explosion_source_named),
in_water: std::mem::take(&mut c.explosion_in_water),
hive_emptying: std::mem::take(&mut c.hive_emptying_blast),
rail_proof: std::mem::take(&mut c.rail_proof_blast),
owner: c.explosion_owner.take(),
}
}
/// Puts back what `take_blast_state` cleared.
fn restore_blast_state(&mut self, s: BlastState) {
let c = &mut self.combat;
c.explosion_source = s.source;
c.explosion_cause = s.cause;
c.explosion_source_named = s.source_named;
c.explosion_in_water = s.in_water;
c.hive_emptying_blast = s.hive_emptying;
c.rail_proof_blast = s.rail_proof;
c.explosion_owner = s.owner;
}
}
/// The fields of `CombatTracker` that describe the blast going off now.
struct BlastState {
source: Option<u32>,
cause: Option<horizon_client::advancements::EntityView>,
source_named: bool,
in_water: bool,
hive_emptying: bool,
rail_proof: bool,
owner: Option<u32>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_blast_needs_a_finite_centre_and_a_radius_in_range() {
assert!(blast_ok([0.5, 64.0, 0.5], 4.0));
assert!(blast_ok([0.5, 64.0, 0.5], NATIVE_EXPLOSION_MAX_RADIUS));
assert!(!blast_ok([0.5, 64.0, 0.5], 0.0));
assert!(!blast_ok([0.5, 64.0, 0.5], -1.0));
assert!(!blast_ok([0.5, 64.0, 0.5], 33.0));
assert!(!blast_ok([0.5, 64.0, 0.5], f32::NAN));
assert!(!blast_ok([f64::INFINITY, 64.0, 0.5], 4.0));
assert!(!blast_ok([0.5, 64.0, 4.0e7], 4.0));
}
}
+776 -12
View File
@@ -6,6 +6,19 @@
//! Horizon's own HUD extras do, and keep a mod's settings where the
//! WebAssembly mod of the same id kept them.
//!
//! API 62 adds nicknames for server mods: `native_set_nickname` shows a name in place of a player's in chat and
//! the Tab list (the real one on hover), and `native_text_allowed` asks the server's chat filter, in
//! `native_server.rs`.
//!
//! API 61 adds mods' own sound events in `native_play_sound`: another
//! namespace's event is sent by name, as vanilla sends an unregistered sound.
//!
//! API 60 adds the small bar under an item (`native_item_bar`): `GuiGraphicsExtractor.itemDecorations`
//! hands it vanilla's choice (the damage bar, or a bundle's fullness already applied), to replace or hide.
//!
//! API 59 adds entities for server mods (`native_summon`, `native_move_entity`,
//! `native_set_display`, `native_remove_entity`, in `native_entities.rs`).
//!
//! API 58 adds the renderer's hook points, in `horizon-client-render`'s
//! `gfx/native_render.rs`: `native_world_drawn` (draw with the world's colour,
//! depth, matrices and entity geometry before the hand and the HUD) and
@@ -205,6 +218,7 @@ use super::native_lifecycle::{NativeLifecycleContext, ServerTickPhase, WorldPhas
use super::{
Game, GameLevel, LevelDimension, Screen,
execution_role::ExecutionRole,
native_block_entity_draw::BlockEntityDraw,
native_panel_recipes::RecipePage,
native_server::{NativeCommand, NativeServerPlayer},
};
@@ -232,8 +246,8 @@ use horizon_core::{
ticks::{ScheduleResult, TickPriority},
};
use horizon_world::{
fluid::FluidKind,
level::{BlockId, LevelAccess, Ticks},
fluid::{FluidKind, FluidState},
level::{BlockId, LevelAccess, PlacementContext, Ticks},
nbt::Compound,
registry::{BlockRegistry, BlockStateId},
};
@@ -255,7 +269,7 @@ pub(super) use horizon_world::level::flags;
/// The hook points' API version. The launcher reads this line from the
/// source it builds; keep its form.
#[allow(dead_code)]
pub(crate) const NATIVE_HOOKS_API: u32 = 58;
pub(crate) const NATIVE_HOOKS_API: u32 = 62;
impl GameLevel<'_> {
/// An authoritative server committed a changed state through `Level.setBlock`.
@@ -351,6 +365,39 @@ impl GameLevel<'_> {
let _ = (state, source);
}
/// `BlockState.hasAnalogOutputSignal`: set `output` for a block of yours
/// that comparators read (how full or far along it is, as a composter's
/// level), so they ask `native_analog_output`. Asked from inside the
/// level's update: answer from `state` alone. Authoritative servers only.
/// API @API:analog_output@.
#[inline(always)]
pub(super) fn native_has_analog_output(&self, state: BlockStateId, output: &mut bool) {
let _ = (state, output);
}
/// `BlockState.getAnalogOutputSignal`: the signal a comparator reads from
/// the block `state` at `pos`, `face` the side that touches the comparator
/// (or the conductor in front of it). `output` holds vanilla's answer:
/// change it when the block is yours (0 to 15; anything else is clamped).
/// Asked only for blocks `native_has_analog_output` answered for, from
/// inside the level's update: don't change the world here. When the
/// answer changes without the block's state changing, call
/// `native_update_output_neighbors`; a change of state through
/// `native_set_block` or `native_set_block_property` (neighbour updates
/// on) tells the comparators itself, as vanilla's `Level.setBlock` does.
/// Authoritative servers only. API @API:analog_output@.
#[inline(always)]
pub(super) fn native_analog_output(
&self,
dimension: LevelDimension,
pos: BlockPos,
state: BlockStateId,
face: Direction,
output: &mut i32,
) {
let _ = (dimension, pos, state, face, output);
}
/// An authoritative server's `ServerLevel.tickChunk` is about to
/// random-tick the block `state` at `pos` (`BlockState.randomTick`):
/// only blocks that tick randomly, a mod's as its vanilla template block
@@ -410,6 +457,45 @@ impl GameLevel<'_> {
);
}
/// On an authoritative server, `BlockState.canSurvive` for `state` at
/// `pos` (`BlockBehaviour.canSurvive`), before the block answers it: as
/// it is placed (`BlockItem.canPlace`), from a shape update of a block
/// whose template checks it (a plant, a torch, a sign: false breaks the
/// block with its drops), and wherever else vanilla asks. `state` may not
/// be in the world yet, so decide from `state`. Answer only for your own
/// blocks: `Some(false)` refuses the spot, `Some(true)` allows it, and
/// the block's own rule doesn't run. The level is read-only. Asked on
/// every shape update of such a block: keep it short. API @API:can_survive@.
#[inline(always)]
pub(super) fn native_can_survive(
&self,
dimension: LevelDimension,
pos: BlockPos,
state: BlockStateId,
survives: &mut Option<bool>,
) {
let _ = (dimension, pos, state, survives);
}
/// On an authoritative server, `VegetationBlock.mayPlaceOn` for the
/// plant `state` on `ground` at `ground_pos`, before the plant answers
/// it: the blocks a mod plant (one whose template is a vanilla plant,
/// such as a crop) can stand on. Its template's `canSurvive` asks it, so
/// the plant is refused there and pops off when its ground changes to
/// one it can't stand on. Answer only for your own blocks. The level is
/// read-only. API @API:can_survive@.
#[inline(always)]
pub(super) fn native_may_place_on(
&self,
dimension: LevelDimension,
state: BlockStateId,
ground: BlockStateId,
ground_pos: BlockPos,
may: &mut Option<bool>,
) {
let _ = (dimension, state, ground, ground_pos, may);
}
/// An authoritative server's `ServerLevel.tickBlock` is about to run a
/// due scheduled tick of the block `state` at `pos` (`BlockState.tick`):
/// the block is still the one the tick was scheduled for. Change the
@@ -427,6 +513,121 @@ impl GameLevel<'_> {
) {
let _ = (ticks, dimension, pos, state, handled);
}
/// A block is about to take the state it is placed in
/// (`Block.getStateForPlacement(BlockPlaceContext)`), `state` the block's
/// own default state. Answer only for your blocks: put the state you want
/// in `answer` as `Some(Some(state))` — a state of that block, as vanilla
/// sets properties on its own `defaultBlockState` (another block's is
/// ignored) — or refuse the placement with `Some(None)`: the block goes
/// nowhere and the stack is kept, as vanilla's `null`. Left empty, the
/// block's own placement runs. `context` is
/// vanilla's `BlockPlaceContext` (the clicked face, the placer's rotation,
/// the clicked position, whether the clicked block is being replaced, the
/// item in hand) and `fluid` the fluid in the position the block would go
/// in, which a waterlogged block reads. Build the state from `state` with
/// `native_state_with_property`. It is asked for every placer that goes
/// through vanilla's placement (a player's `BlockItem.place`, a
/// dispenser's `DirectionalPlaceContext`), and the answer still takes its
/// survival and entity obstruction checks. Authoritative servers only.
/// @API:placement_state@.
#[inline(always)]
#[allow(clippy::option_option)]
pub(super) fn native_state_for_placement(
&self,
dimension: LevelDimension,
state: BlockStateId,
context: &PlacementContext,
fluid: FluidState,
answer: &mut Option<Option<BlockStateId>>,
) {
let _ = (dimension, state, context, fluid, answer);
}
/// A block nothing in Horizon knows a `BonemealableBlock` for is about to
/// take bone meal: vanilla's `BonemealableBlock.isValidBonemealTarget`,
/// `state` the block's own state at `pos`. Answer `valid` for your own
/// blocks: the meal is used up on one as vanilla uses it up (the growth
/// particles and the sound follow, whether or not the roll below grows
/// it), from a player's `BoneMealItem.useOn`, from a dispenser and, on a
/// crop (a villager bonemeals only a `CropBlock`), from a villager. Asked
/// on both sides, as vanilla asks it on both, so a client predicts the
/// use with your answer: say no where you can only grow on a server
/// (`is_client_side`). `Some(false)` refuses the meal, even on a block
/// whose template takes it (vanilla's `growCrop` then keeps the meal);
/// left `None`, the block keeps the `BonemealableBlock` its template gives
/// it, or takes no bone meal.
/// @API:bonemeal@.
#[inline(always)]
pub(super) fn native_is_valid_bonemeal_target(
&self,
dimension: LevelDimension,
pos: BlockPos,
state: BlockStateId,
valid: &mut Option<bool>,
) {
let _ = (dimension, pos, state, valid);
}
/// `BonemealableBlock.isBonemealSuccess` for a block that answered
/// `native_is_valid_bonemeal_target`, after it did: set `success` to grow
/// it this time. Vanilla draws the level's random here
/// (`isBonemealSuccess(Level, RandomSource, BlockPos, BlockState)`), so
/// draw from `native_level_random` to keep vanilla's order.
/// Authoritative servers only. @API:bonemeal@.
#[inline(always)]
pub(super) fn native_is_bonemeal_success(
&mut self,
dimension: LevelDimension,
pos: BlockPos,
state: BlockStateId,
success: &mut bool,
) {
let _ = (dimension, pos, state, success);
}
/// `BonemealableBlock.performBonemeal` for a block that answered
/// `native_is_valid_bonemeal_target` and whose `native_is_bonemeal_success`
/// said yes: grow it here, `state` its state at `pos`. The meal is used up
/// either way. Change the block with the level's
/// `native_set_block_property`, and schedule with `native_schedule_block_tick`
/// (`ticks` is the hook's). Authoritative servers only. @API:bonemeal@.
#[inline(always)]
pub(super) fn native_perform_bonemeal(
&mut self,
ticks: &mut Ticks,
dimension: LevelDimension,
pos: BlockPos,
state: BlockStateId,
) {
let _ = (ticks, dimension, pos, state);
}
}
/// What dropped the items [`Game::native_loot_drops`] hears.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub(crate) struct NativeLootDrops<'a> {
/// The block's or mob's own loot table (`minecraft:blocks/stone`, `minecraft:entities/zombie`);
/// empty when none rolled: the block or mob has none, the `mob_drops` rule is off, a baby
/// animal. A mob's drops may hold more: a charged creeper's head, a fox's mouth item.
pub(crate) table: &'a str,
/// The level they drop in (`minecraft:overworld`).
pub(crate) dimension: &'a str,
/// `LootContextParams.ORIGIN`: a broken block's centre, a mob's position.
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`).
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
/// that dies to lava long after names none.
pub(crate) player: Option<u128>,
/// What broke the block (`LootContextParams.TOOL`).
pub(crate) tool: Option<&'a ItemStack>,
/// The radius of the explosion that broke the block.
pub(crate) explosion: Option<f32>,
}
/// A depth-tested outline of the client's block selection shape.
@@ -488,6 +689,49 @@ 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.
#[allow(dead_code)]
#[derive(Clone, Copy, Debug)]
pub struct NativeScreenView<'a> {
/// One id per `Screen` (`Screen::id`): `inventory`, `creative`, `paused`,
/// `stats`, `options`, `win`… Stable across releases, so a mod can match
/// on it. Every container, station and mod window draws on the inventory
/// screen and so reports `inventory`: tell them apart by `menu` and
/// `title` (both None on a window the client opened itself) and by
/// `slots`.
pub id: &'static str,
/// The type id of the menu the server opened for this screen
/// (`minecraft:generic_9x3`, `minecraft:furnace`, a mod's own id; a mount's
/// inventory is `horizon:mount<n>`). None on a screen without one (the
/// client's own inventory, creative, the pause menu, stats…) and for a
/// menu type the client does not know.
pub menu: Option<&'a str>,
/// The title the server gave the open menu, if it has one: the component
/// itself, not a copy (`.plain()` it to draw, `chars()` for the styles).
/// None when the server gave none, on a screen the client opened, and
/// while playing.
pub title: Option<&'a Text>,
/// The screen's container panel in GUI pixels, `[x, y, width, height]`;
/// the whole window for a screen with none. The panel is where the game
/// draws the window: 176 wide for most, wider for the merchant and the
/// beacon, a mod screen's own size for a mod's.
pub rect: [f32; 4],
/// The slots the open menu has, as `Game::slots` counts them: the player
/// inventory's 36, then the container's, the station's, the mod screen's
/// or the crafting grid's (a screen with no menu of its own still counts
/// the inventory menu's). Counted, not listed.
pub slots: usize,
/// The pointer in GUI pixels (`Screen.mouseX`, `mouseY`, from the
/// window's top left), as `native_cursor` gives it: for hover highlights.
pub mouse: [f32; 2],
/// This frame's partial tick (`getGameTimeDeltaPartialTick(false)`, 0 to
/// 1): what the screen draws animation against.
pub partial_tick: f32,
}
/// The mod screen open on this client: the block it is for, the key its
/// slots are under, the window id of its menu, and the menu's data slots as
/// the server sent them.
@@ -889,6 +1133,10 @@ impl Game<'_> {
pub(super) fn native_minimap(&self, minimap: &mut Option<NativeMinimap>) {
let _ = minimap;
}
/// Register namespaced NBT component types before content loads.
pub(super) fn native_component_types(types: &mut Vec<String>) {
let _ = types;
}
/// Options > Mods lists the native mods: push yours.
pub(super) fn native_mods(&mut self, mods: &mut Vec<NativeMod>) {
let _ = mods;
@@ -926,6 +1174,25 @@ impl Game<'_> {
pub(super) fn native_block_entity_ticks(&self, block: &str, ticks: &mut bool) {
let _ = (block, ticks);
}
/// A `minecraft:post_attack` effect of a mod's own kind, on a mob a
/// player's weapon hit: the entry says `enchanted` attacker and `affected`
/// victim, its `requirements` passed, and its `effect` names a `type` in a
/// namespace outside `minecraft:`. Told the enchantment id, the kind, the
/// `effect` JSON, the level and the mob's id (it may have just died). The
/// acting player is the one whose weapon carries the enchantment. Each
/// such entry is told once a hit, and once for each mob a sweep lands on.
/// A hit on a player, and an entry inside `minecraft:all_of`, aren't told
/// yet. An unclaimed kind does nothing. API @API:enchant_effect@.
pub(super) fn native_enchant_effect(
&mut self,
enchantment: &str,
kind: &str,
data: &serde_json::Value,
level: u32,
victim: u32,
) {
let _ = (enchantment, kind, data, level, victim);
}
/// Block `block`'s block entity has item slots (a `WorldlyContainer`):
/// 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
@@ -968,6 +1235,15 @@ impl Game<'_> {
pub(super) fn native_block_entity_tick(&mut self, pos: [i32; 3], entity: &mut ModBlockEntity) {
let _ = (pos, entity);
}
/// A consumable's effect of a mod's own kind: the item's `on_consume_effects`
/// entry is of a type in a namespace of its own, and the loader kept it
/// for this call. The kind and the JSON the file wrote are here; the acting
/// player is the one consuming, and the game's own effects,
/// sounds, teleports and drops are open. An unclaimed kind does
/// nothing. API @API:consume_effect@.
pub(super) fn native_consume_effect(&mut self, kind: &str, data: &serde_json::Value) {
let _ = (kind, data);
}
/// What clients get of the block entity at `pos` when its `id` is
/// yours (`BlockEntity.getUpdateTag`): set `tag` to it, read from
/// `entity` alone. Asked on the server whenever a chunk holding it is
@@ -982,6 +1258,44 @@ impl Game<'_> {
) {
let _ = (pos, entity, tag);
}
/// Draw the mod block entity at `pos`, of block `block`
/// (`mymod:machine`), on this client: once a frame while it is in view
/// (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
/// (`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 @API:block_entity_submit@.
pub(super) fn native_block_entity_submit(
&self,
pos: [i32; 3],
block: &str,
state: BlockStateId,
entity: &ModBlockEntity,
draw: &mut BlockEntityDraw,
) {
let _ = (pos, block, state, entity, draw);
}
/// Where the mod block entity at `pos` draws, for culling
/// (NeoForge's `getRenderBoundingBox`): `bounds` is min x, y, z then max
/// x, y, z, in blocks from the block's lower north-west corner. It starts
/// as the block with a block of margin around it; widen it when your
/// `native_block_entity_submit` draws further (a tall shaft, a beam), or
/// set an infinite value for one never culled. Asked once a frame for each
/// within 64 blocks whose chunk is drawn. API @API:block_entity_submit@.
pub(super) fn native_block_entity_render_bounding_box(
&self,
pos: [i32; 3],
block: &str,
state: BlockStateId,
entity: &ModBlockEntity,
bounds: &mut [f32; 6],
) {
let _ = (pos, block, state, entity, bounds);
}
/// A menu opens on this client for block `block`, a mod's: set `screen`
/// when the block is yours, to lay its screen out (`native_screen_draw`
/// draws it). The server asks it too: a slot it hides is not in the menu
@@ -1005,6 +1319,136 @@ impl Game<'_> {
) {
let _ = (block, data, batch, origin);
}
/// The open screen has drawn itself and its tooltips (`Screen.render`,
/// the point of `ScreenEvent.Render.Post`): draw over it with
/// `native_fill`, `native_sprite` and `native_text`, `screen.rect` being
/// its container panel (or the whole window) and `screen.id` the screen
/// it is. Once a frame on the window, never while playing with no screen;
/// `&self`, so it reads and draws only. The Win and Options screens call
/// it on their own path, after their drawing.
///
/// It does not fire on the title screen, which returns before its point,
/// nor for the screens drawn over gameplay after it (a server dialog, the
/// server pack, link and code-of-conduct prompts, a book, a sign editor):
/// those keep `Screen::Playing`, which it skips, and draw over what it
/// drew, while vanilla's `ScreenEvent.Render.Post` fires for them.
/// API @API:screen_render_post@.
pub(super) fn native_screen_render_post(
&self,
batch: &mut HudBatch,
screen: &NativeScreenView<'_>,
) {
let _ = (batch, screen);
}
/// A mouse button went down over the open screen
/// (`ScreenEvent.MouseButtonPressed.Pre`, vanilla's
/// `Screen.mouseClicked`): `screen` is the screen it is, `screen.mouse`
/// where the pointer is (so there is no position of its own), `button` the
/// button's GLFW index (0 left, 1 right, 2 middle, 3 back, 4 forward; a
/// button past those is the platform's own number, which differs between
/// systems) and `modifiers` GLFW's bits (shift 1, control 2,
/// alt 4, super 8). Set `canceled` and the press goes no further: no slot,
/// no screen button, no drag, no hotbar key, no sort or header button.
///
/// It fires before anything the screen does with the press, on the window
/// only, 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
/// click there stays the game's own. A gamepad click or a click the
/// keyboard's focus sends is not the mouse and fires nothing.
/// API @API:screen_mouse_button@.
pub(super) fn native_screen_mouse_button_pressed_pre(
&self,
screen: &NativeScreenView<'_>,
button: i32,
modifiers: u32,
canceled: &mut bool,
) {
let _ = (screen, button, modifiers, canceled);
}
/// A mouse button went up over the open screen
/// (`ScreenEvent.MouseButtonReleased.Pre`, vanilla's
/// `Screen.mouseReleased`), with the same `screen`, `button` and
/// `modifiers` as `native_screen_mouse_button_pressed_pre` and fired at
/// the same point. Set `canceled` and the release goes no further: a
/// pressed slot keeps what it took and no drag, slider or scrollbar lets
/// go.
///
/// Cancel a release only when the press it ends was cancelled too: a
/// release the screen took ends the drag or the scroll it began, as it
/// does in vanilla, and no guard here un-cancels it. Same screens as the
/// press; a press that opened another screen ends on that one.
/// API @API:screen_mouse_button@.
pub(super) fn native_screen_mouse_button_released_pre(
&self,
screen: &NativeScreenView<'_>,
button: i32,
modifiers: u32,
canceled: &mut bool,
) {
let _ = (screen, button, 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
/// drawn this frame, which `NativeScreenView.id` comes from. Borrowed:
/// nothing is copied per frame.
pub(super) fn screen_view(&self, batch: &HudBatch, screen: Screen) -> NativeScreenView<'_> {
NativeScreenView {
id: screen.id(),
menu: self.remote_menu_type().filter(|m| !m.is_empty()),
title: self.remote.as_ref().and_then(|r| r.menu_title.as_ref()),
rect: self.screen_rect(batch, screen),
slots: self.slot_count(),
mouse: self.native_cursor(),
partial_tick: self.clock.fraction() as f32,
}
}
/// The open screen's container panel in GUI pixels (`screen`'s, the one
/// this frame draws), or the whole window for a screen with none
/// (`NativeScreenView.rect`).
pub(super) fn screen_rect(&self, batch: &HudBatch, screen: Screen) -> [f32; 4] {
if screen != Screen::Inventory && screen != Screen::Creative {
return [0.0, 0.0, batch.width, batch.height];
}
// The window the game itself draws (`container_image`): the merchant
// and the beacon are wider and shifted, creative is its own size.
let image = self.container_image(batch);
[
image.left as f32,
image.top as f32,
image.width as f32,
image.height as f32,
]
}
/// A screen the native mods' input hooks are for is open: the window has a
/// renderer, `Screen::Playing` is behind it (so not the title screen, nor a
/// book, a sign editor, a server dialog or a prompt drawn over gameplay)
/// and no connect or world load is in progress. The one gate they all ask
/// before they fire.
pub(super) fn screen_hooks_open(&self) -> bool {
self.renderer.is_some()
&& self.screen != Screen::Playing
&& !self.screen_over_playing()
&& !self.remote_loading()
}
/// The open screen as an input hook sees it (`NativeScreenView`), or None
/// when `screen_hooks_open` says no screen is. `screen.mouse` is the
/// pointer the hook's event is at, so a hook takes no position, and
/// `partial_tick` is the clock's fraction at this event, not a frame's.
/// The `HudBatch` built here is only what `screen.rect` needs: the
/// window's GUI scale, no allocation.
pub(super) fn screen_view_now(&self) -> Option<NativeScreenView<'_>> {
if !self.screen_hooks_open() {
return None;
}
let renderer = self.renderer.as_ref()?;
let batch = HudBatch::new([renderer.config.width, renderer.config.height]);
Some(self.screen_view(&batch, self.screen))
}
/// 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.
@@ -1110,11 +1554,30 @@ impl Game<'_> {
pub(super) fn native_tooltip(&self, stack: &ItemStack, lines: &mut Vec<(Text, u32)>) {
let _ = (stack, lines);
}
/// Client-side image component for this stack's tooltip. Answer its width/height
/// in GUI pixels; finite positive dimensions reserve space after text and any
/// vanilla bundle component. None keeps the vanilla tooltip unchanged.
pub(super) fn native_tooltip_component(&self, stack: &ItemStack, size: &mut Option<[f32; 2]>) {
let _ = (stack, size);
}
/// Render the measured component at origin, inside the tooltip background.
/// Stack is the component's data. Use native_sprite/native_fill/native_text;
/// size is the measurement returned by native_tooltip_component this frame.
pub(super) fn native_tooltip_component_draw(
&self,
stack: &ItemStack,
batch: &mut HudBatch,
origin: [f32; 2],
size: [f32; 2],
) {
let _ = (stack, batch, origin, size);
}
/// The small bar `GuiGraphicsExtractor.itemDecorations` draws under an
/// item, wherever the game shows one: `bar` arrives holding vanilla's value
/// (the damage bar, or a bundle's fullness already applied) and may be
/// replaced (width `0..=13`, colour `rgb`) or set to `None` to hide it.
/// Asked each frame a stack is drawn. Client-side. @API:item_bar@.
/// Asked each frame a stack is drawn. Client-side. 60.
pub(super) fn native_item_bar(&self, stack: &ItemStack, bar: &mut Option<(i32, u32)>) {
let _ = (stack, bar);
}
@@ -1134,6 +1597,25 @@ impl Game<'_> {
) {
let _ = (stack, pos, face, claimed);
}
/// `DispenserBlock.getDispenseMethod`: choose a behaviour for any item,
/// including vanilla items, on the authoritative server (not droppers).
/// Leave answer None for vanilla. Otherwise answer `(remainder, event)`:
/// a remainder of None empties the slot, `Some(stack)` replaces it (the hook
/// owns consumption: no extra item is removed afterwards). `event` is the
/// level event the dispenser plays with its smoke: 1000 the click, 1001 the
/// fail sound (`OptionalDispenseItemBehavior` without success), or 0 for
/// nothing at all (`DispenseItemBehavior.NOOP`). To drop one normally,
/// answer `(native_dispense_default(..), 1000)`. API @API:dispenser_behaviour@.
pub(super) fn native_dispense_item(
&mut self,
dimension: &str,
pos: [i32; 3],
facing: horizon_core::geometry::Direction,
stack: &ItemStack,
answer: &mut Option<super::dispenser::NativeDispensed>,
) {
let _ = (dimension, pos, facing, stack, answer);
}
/// A player uses `stack` (right click) with `self.hand`: in the air, or
/// on a block once `native_use_item_on` let it through, where `Item.use`
/// starts. Each click reaches it once. Set `claimed` to take the use: the
@@ -1338,6 +1820,29 @@ impl Game<'_> {
pub(super) fn native_mob_killed(&mut self, player: u128, entity: u32, kind: &str) {
let _ = (player, entity, kind);
}
/// A block broke or a mob died, and `drops` are the items about to drop, in
/// the order they drop: what the loot tables rolled (after
/// `native_loot_table_load`'s changes), and for a mob what it wore, held or
/// carried (a fox's mouth item, an allay's, piglin's or horse's inventory),
/// but not an enderman's carried block or a copper golem's preserved
/// equipment, which drop after. A charged creeper's head and a fox's mouth
/// item come before the mob's own table. A block's stacks get its block
/// 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
/// 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.
/// Server-side, for every player. API
/// @API:loot_tables@.
pub(super) fn native_loot_drops(
&mut self,
context: &NativeLootDrops<'_>,
drops: &mut Vec<ItemStack>,
) {
let _ = (context, drops);
}
/// `player` threw `count` of `item` (`Player.drop`: the drop key, a
/// menu's throw...), not what scatters when they die. It only hears.
/// Server-side. API 40.
@@ -1466,6 +1971,76 @@ impl Game<'_> {
) {
let _ = (entity, kind, damage_type, causing, direct, amount, canceled);
}
/// `Block.stepOn` for a mod block, on its authoritative server level.
/// No self: the simulation owns the entity during this callback.
pub(super) fn native_block_step_on(
dimension: &str,
entity: i32,
kind: &str,
state: BlockStateId,
position: [i32; 3],
) {
let _ = (dimension, entity, kind, state, position);
}
/// `Block.entityInside` for each mod block touched along the entity's movement.
/// Repeated sweep hits in one contact pass are suppressed; idle overlap still fires.
pub(super) fn native_block_entity_inside(
dimension: &str,
entity: i32,
kind: &str,
state: BlockStateId,
position: [i32; 3],
) {
let _ = (dimension, entity, kind, state, position);
}
/// `Block.fallOn` for a mod block. Change calculated damage before generic fall sounds
/// and hurt handling; zero prevents both. Creative and fall-immune entities
/// notify with zero damage and retain their immunity. Server-side only.
pub(super) fn native_block_fall_on(
dimension: &str,
entity: i32,
kind: &str,
state: BlockStateId,
position: [i32; 3],
distance: f64,
damage: &mut f32,
) {
let _ = (dimension, entity, kind, state, position, distance, damage);
}
}
pub(super) fn block_contact(
dimension: &str,
event: horizon_client::block_contact::Event<'_>,
damage: &mut f32,
) {
use horizon_client::block_contact::Event;
match event {
Event::StepOn(c) => Game::native_block_step_on(
dimension,
c.entity,
c.kind,
c.state,
[c.position.x, c.position.y, c.position.z],
),
Event::Inside(c) => Game::native_block_entity_inside(
dimension,
c.entity,
c.kind,
c.state,
[c.position.x, c.position.y, c.position.z],
),
Event::FallOn(c, distance) => Game::native_block_fall_on(
dimension,
c.entity,
c.kind,
c.state,
[c.position.x, c.position.y, c.position.z],
distance,
damage,
),
}
}
/// `native_living_incoming_damage` for the damage code (`health::HURT_SERVER`): whether
@@ -1512,6 +2087,21 @@ fn mob_source(damage: &Damage) -> HurtSource<'static> {
}
}
/// Whether `id` is a sound event's id, `namespace:path` as `Identifier`
/// allows: the namespace of `[a-z0-9_.-]`, the path also with `/`.
fn sound_id(id: &str) -> bool {
let Some((namespace, path)) = id.split_once(':') else {
return false;
};
let allowed = |c: char, slash: bool| {
c.is_ascii_lowercase() || c.is_ascii_digit() || "_.-".contains(c) || (slash && c == '/')
};
!namespace.is_empty()
&& !path.is_empty()
&& namespace.chars().all(|c| allowed(c, false))
&& path.chars().all(|c| allowed(c, true))
}
fn dimension_id(dimension: LevelDimension) -> &'static str {
match dimension {
LevelDimension::Overworld => "minecraft:overworld",
@@ -1523,9 +2113,13 @@ fn dimension_id(dimension: LevelDimension) -> &'static str {
/// Server-level helpers for native mods.
#[allow(dead_code)]
impl GameLevel<'_> {
/// Play a registered vanilla sound for nearby players in this server level.
/// The return value means queued, not heard. Unknown sound events are
/// rejected at dispatch when the server registry is available.
/// Play a sound event for nearby players in this server level. The return
/// value means queued, not heard. A `minecraft:` event must be registered:
/// an unknown one is dropped at dispatch. Another namespace's event (a
/// mod's, `mymod:machine_hum`) is sent by name, as vanilla sends a sound
/// that isn't in its registry (`Holder.direct`, a variable range): players
/// hear it if their resources define it (`assets/mymod/sounds.json`, a
/// content mod's). API 61.
pub(super) fn native_play_sound(
&mut self,
sound: &str,
@@ -1534,7 +2128,7 @@ impl GameLevel<'_> {
volume: f32,
pitch: f32,
) -> bool {
if !self.native_authoritative || !sound.starts_with("minecraft:") {
if !self.native_authoritative || !sound_id(sound) {
return false;
}
if source >= horizon_client::settings::SOUND_SOURCES.len() {
@@ -1683,6 +2277,31 @@ impl GameLevel<'_> {
let time = self.game_time();
ticks.schedule_fluid(time, at, kind, delay) != ScheduleResult::MissingChunk
}
/// The state `state` with its property `key` set to `value`, as a mod block
/// builds the state it is placed in: `native_state_with_property(cable,
/// "facing", "east")` (`BlockState.setValue`). A block's own declared
/// properties (`state_properties` in its block file) are set the same way.
/// `state` itself when the property already has that value; None when the
/// block has no such property, or the property no such value. API
/// @API:placement_state@.
pub(super) fn native_state_with_property(
&self,
state: BlockStateId,
key: &str,
value: &str,
) -> Option<BlockStateId> {
with_property(self.registry, state, key, value)
}
/// This level's own random (vanilla's `Level.random`): the stream
/// `native_level_random(dimension)` hands a server hook, for a world hook
/// that draws as a vanilla block does (`isBonemealSuccess`), so the mod's
/// draws shift vanilla's as a Java mod's would. `None` where this level
/// isn't the server's copy. API @API:bonemeal@.
pub(super) fn native_level_random(
&mut self,
) -> Option<&mut horizon_core::random::LegacyRandom> {
self.native_authoritative.then_some(&mut self.world.random)
}
}
/// Helpers for native mods, kept as stable as the hook points.
@@ -1891,6 +2510,26 @@ impl Game<'_> {
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.set_block(level, ticks, at, id, flags))
}
/// The block at `pos` changed its comparator output
/// (`native_analog_output`) without changing its state: the comparators
/// beside it, or behind a conductor there, read it again
/// (`Level.updateNeighbourForOutputSignal`). Acts on the level in place:
/// false (and nothing happens) when `dimension` is another, or this
/// window isn't a server. API @API:analog_output@.
pub(super) fn native_update_output_neighbors(
&mut self,
dimension: &str,
pos: [i32; 3],
) -> bool {
if (self.serving.is_none() && self.options.dedicated.is_none())
|| dimension != self.dim.id()
{
return false;
}
let at = BlockPos::new(pos[0], pos[1], pos[2]);
self.with_level(|b, level, ticks| b.update_neighbour_for_output_signal(level, ticks, at));
true
}
/// The block at `pos` changed the signal it gives: its neighbours, and
/// theirs (a block it powers passes the signal on), read it again, as
/// around a lever (`Level.updateNeighborsAt`). Call it when the answer
@@ -2325,6 +2964,34 @@ impl Game<'_> {
pub(super) fn native_acting_id(&mut self) -> u128 {
horizon_client::lan::playerdata::ints_uuid(self.acting_player_uuid())
}
/// The mods' say on a broken block's drops (`native_loot_drops`).
pub(super) fn native_block_drops(
&mut self,
pos: [i32; 3],
state: BlockStateId,
player: Option<u128>,
tool: Option<&ItemStack>,
explosion: Option<f32>,
drops: &mut Vec<ItemStack>,
) {
let items = match &self.world_tables {
Some(tables) => &tables.items,
None => self.res.items(),
};
let table = items.block_loot_table(self.pack.assets.registry, state);
let context = NativeLootDrops {
table: table.as_deref().unwrap_or_default(),
dimension: self.dim.id(),
origin: pos.map(|c| f64::from(c) + 0.5),
block: Some((pos, state)),
entity: None,
player,
tool,
explosion,
};
self.native_loot_drops(&context, drops);
drops.retain(|stack| stack.count > 0 && &*stack.item != "minecraft:air");
}
/// Whether a native mod keeps the acting player from taking `stack`.
pub(super) fn native_pickup_refused(&mut self, stack: &ItemStack) -> bool {
let player = self.native_acting_id();
@@ -2739,6 +3406,12 @@ impl Game<'_> {
.collect(),
)
}
/// How many of those slots `native_screen_slots` lists, without building
/// it (`Game::slot_count` asks every frame).
pub(super) fn native_screen_slot_count(&self) -> Option<usize> {
let s = self.shown_native_screen()?;
Some(s.screen.slots.iter().filter(|p| p.is_some()).count())
}
/// The first row of the player's inventory on the open mod screen.
pub(super) fn native_screen_inventory_y(&self) -> Option<f32> {
self.shown_native_screen().map(|s| s.screen.inventory_y)
@@ -3136,6 +3809,30 @@ fn box_meets(b: &horizon_core::geometry::Aabb, min: [f64; 3], max: [f64; 3]) ->
#[cfg(test)]
mod tests {
/// `native_play_sound` takes vanilla's and mods' sound ids, nothing else.
#[test]
fn sound_ids_are_identifiers() {
for id in [
"minecraft:block.note_block.harp",
"mymod:machine/hum",
"my_mod.x-1:a",
] {
assert!(super::sound_id(id), "{id}");
}
for id in [
"",
"hum",
":hum",
"mymod:",
"MyMod:hum",
"mymod:Hum",
"my/mod:hum",
"a:b c",
] {
assert!(!super::sound_id(id), "{id}");
}
}
/// A mod's property write: none for a key or value the block lacks, the
/// state itself when it already has the value, else the new state.
#[test]
@@ -3168,6 +3865,37 @@ mod tests {
assert_eq!(super::property_change(&registry, age0, "age", "9"), None);
assert_eq!(super::property_change(&registry, age0, "lit", "true"), None);
}
/// The state a mod block is placed in is built from its own default state
/// and one of its properties, its declared ones (`state_properties` in its
/// block file) included, and keeps the properties it already had.
#[test]
fn a_placement_state_is_built_from_a_blocks_own_properties() {
let facts = br#"{"defaults":{"minecraft:air":0,"test:pipe":1},"states":[
["air",true,false,"","minecraft:air"],
["north_gas",false,false,"",{"id":"test:pipe","properties":{"carries":"gas","facing":"north"}}],
["east_gas",false,false,"",{"id":"test:pipe","properties":{"carries":"gas","facing":"east"}}],
["north_items",false,false,"",{"id":"test:pipe","properties":{"carries":"items","facing":"north"}}],
["east_items",false,false,"",{"id":"test:pipe","properties":{"carries":"items","facing":"east"}}]
]}"#;
let registry = horizon_world::registry::BlockRegistry::from_facts_json(&facts[..]).unwrap();
use horizon_world::registry::BlockStateId;
let (north_gas, east_gas, east_items) = (BlockStateId(1), BlockStateId(2), BlockStateId(4));
// The declared property changes, and `carries` stays as it was.
assert_eq!(
super::with_property(&registry, north_gas, "facing", "east"),
Some(east_gas)
);
assert_eq!(
super::with_property(&registry, east_gas, "carries", "items"),
Some(east_items)
);
// A value the property does not have builds nothing.
assert_eq!(
super::with_property(&registry, east_gas, "facing", "up"),
None
);
}
/// Both property setters return false for a write that changes nothing:
/// each goes through `property_change`, none returns true on its own.
#[test]
@@ -3217,6 +3945,27 @@ mod tests {
}
}
#[test]
fn loot_table_load_stays_hookable() {
let source = include_str!("../../../horizon-world/src/loot.rs");
// From its doc comment's last line, so an attribute above it counts.
let hook = source.find("pub fn native_loot_table_load(").unwrap();
let start = source[..hook].rfind("///").unwrap();
let body = &source[start..start + source[start..].find("\n}\n").unwrap()];
for line in body.lines() {
assert!(
!line.contains("cfg"),
"a cfg in the loot hook point: {line}"
);
for mac in ["!(", "![", "! {", "!{"] {
assert!(
!line.contains(mac),
"a macro in the loot hook point: {line}"
);
}
}
}
#[test]
fn lifecycle_source_stays_hookable() {
let source = include_str!("native_lifecycle.rs");
@@ -3275,12 +4024,21 @@ mod tests {
.next()
})
.collect();
// The renderer's hook points (`horizon_client_render::gfx::native_render`).
let render_hooks: BTreeSet<_> =
include_str!("../../../horizon-client-render/src/gfx/native_render.rs")
// Hook points that are free functions in other crates.
let free_hooks = |source: &'static str| -> BTreeSet<&'static str> {
source
.lines()
.filter_map(|line| line.strip_prefix("pub fn native_")?.split('(').next())
.collect();
.collect()
};
// The renderer's (`horizon_client_render::gfx::native_render`).
let render_hooks = free_hooks(include_str!(
"../../../horizon-client-render/src/gfx/native_render.rs"
));
// Items' default components (`horizon_world::item_defaults`).
let item_hooks = free_hooks(include_str!("../../../horizon-world/src/item_defaults.rs"));
// The loot tables' hook point (`horizon_world::loot`).
let loot_hooks = free_hooks(include_str!("../../../horizon-world/src/loot.rs"));
let declared: BTreeSet<_> = section
.lines()
.chain(level_section.lines())
@@ -3291,6 +4049,8 @@ mod tests {
.next()
})
.chain(render_hooks.iter().copied())
.chain(item_hooks.iter().copied())
.chain(loot_hooks.iter().copied())
.collect();
let mut names = BTreeSet::new();
let mut contracts = BTreeSet::new();
@@ -3304,6 +4064,10 @@ mod tests {
names.insert(name);
let target = if render_hooks.contains(name) {
format!("horizon_client_render::gfx::native_render::native_{name}")
} else if item_hooks.contains(name) {
format!("horizon_world::item_defaults::native_{name}")
} else if loot_hooks.contains(name) {
format!("horizon_world::loot::native_{name}")
} else if level_hooks.contains(name) {
format!("horizon_client::game::native_hooks::GameLevel<'_>::native_{name}")
} else {
@@ -129,9 +129,14 @@ impl Game<'_> {
items: Vec<Option<ItemStack>>,
) -> Option<i32> {
let conn = self.native_conn(uuid)?;
let host = self.lan.as_mut()?;
host.hub
.open_native_menu(conn, rows, component_nbt(&title.component()), items)
let mut host = self.lan.take()?;
// ServerPlayer.openMenu closes the menu that was open.
self.guest_close_all(&mut host, conn);
let window =
host.hub
.open_native_menu(conn, rows, component_nbt(&title.component()), items);
self.lan = Some(host);
window
}
/// Replaces what the player's open native menu shows; sent with the
/// tick's container sync.
@@ -403,6 +403,37 @@ impl Game<'_> {
}
true
}
/// Shows `nickname` in place of that player's name in chat (their
/// messages, `/me`, `/msg`) and in the Tab list; None shows their name
/// again. Hovering the shown name gives the real one, which stays in the
/// log, commands, selectors and the server's events. Kept until a mod
/// sets it again or the server stops (also if they leave and come back).
/// Plain text: the mod checks it (`native_text_allowed`, its length,
/// other players' names). Only where no player's state is swapped in,
/// as `native_run_command`; false there and when they aren't on. API
/// 62.
pub(super) fn native_set_nickname(&mut self, uuid: u128, nickname: Option<&str>) -> bool {
let Some(host) = &mut self.lan else {
return false;
};
if !host.hub.guests.iter().any(|g| g.placed && g.uuid == uuid) {
return false;
}
host.hub.set_nickname(uuid, nickname.map(str::to_owned));
true
}
/// Whether the server's chat filter (`server.toml` `[chat_filter]`)
/// lets `text` through: true without a filter, None inside a player's
/// hook (where the server is out of reach). API 62.
pub(super) fn native_text_allowed(&self, text: &str) -> Option<bool> {
let host = self.lan.as_ref()?;
Some(
host.hub
.chat_filter
.as_ref()
.is_none_or(|g| !g.filter.matches(text)),
)
}
/// A system line in every player's chat and the server's log.
pub(super) fn native_broadcast(&self, line: Text) {
let plain = line.plain();
Loaded 100 of 334 files, more files were not shown because too many files have changed in this diff. Show more