Compare commits

...
Author SHA1 Message Date
Horizon agent bf125a1a69 Merge remote-tracking branch 'origin/main' into layers/window-icon-hook
# Conflicts:
#	crates/horizon-client/src/entry.rs
2026-11-09 19:09:55 +01:00
Ludovic Marie 7e6d2121b7 Merge pull request #14095 from vivescene/perf/client-mob-skips
🤖 Agent: both skips only move side-effect-free checks (box intersection, empty effect lists) ahead of the lookups; the set and order of mobs that push or swirl is unchanged.
2026-11-09 19:08:49 +01:00
Ludovic Marie a89865b1c5 Merge pull request #14094 from MSedgeMC/fix/book-title-centered
🤖 Agent: text_start_x is EditBox.updateTextPosition's centred textX ((width - text width) / 2 from getX), and position_at measures the click from the same start, as findClickedPositionInText does.
2026-11-09 19:08:44 +01:00
Ludovic Marie c8e48f9cea Merge pull request #14089 from Mik00000/perf/particle-collision-cache
🤖 Agent: ParticleLevel::block is World::block with a one-chunk Cell in front (same air fallback), and the other edits (lava smoke, bubble removal, lifetime_alpha) keep their random draws and results. Queue BEFORE 14097 (its Ctx impl sits beside this PR's Ctx<'a, 'l>).
2026-11-09 18:59:04 +01:00
Ludovic Marie bdb6452da9 Merge pull request #14073 from alexcatdad/nix-packages
🤖 Agent: packaging only; Cargo.lock has no git sources, so buildRustPackage needs no outputHashes, and nothing the source filter drops (launcher/, nix/, docs pages) is read by the build.
2026-11-09 18:58:58 +01:00
Ludovic Marie 2ddd9705d9 Merge pull request #14059 from MSedgeMC/fix/port-tooltip
🤖 Agent: PORT_INFO_TEXT is drawn after every widget so it sits on top, and a remote world (no port box) gets none.
2026-11-09 18:58:53 +01:00
Ludovic Marie 868d3b76a5 Merge pull request #14058 from MSedgeMC/fix/social-tooltip-delay
🤖 Agent: the delay is per button identity (row id + kind) with vanilla's strict elapsed > 500 ms, and costs one Instant::now() per frame on the social screen only.
2026-11-09 18:58:47 +01:00
Ludovic Marie b4f24319ac Merge pull request #14083 from horizon-rust/agent/remote-poll-move
🤖 Agent: pure move; poll_stationary_client, poll_remote and poll_remote_events go byte-identical from remote.rs to remote_poll.rs (a #[path] child of remote), only visibility and super::super::server_links change.
2026-11-09 18:53:19 +01:00
Ludovic Marie 962f5949ac Merge pull request #13613 from vnsmoreira/fix/command-block-last-output
🤖 Agent: LastOutput is now saved as a component, keeping the red, click-to-suggest feedback of a failed command (BaseCommandBlock / ComponentSerialization.CODEC). The case keeps to failed commands, so no new score holder changes the later /scoreboard players list order. command_exec: 19063/19065; the 2 left are the zombie PersistenceRequired pair, which fails the same way on main 8321f11360.
2026-11-09 18:46:17 +01:00
Ludovic Marie 5e9024e0a1 Merge pull request #14063 from TheDanikReal/main
🤖 Agent: all 1266 entries keep their {placeholders}; translation file only, approved by a Team member who reads it.
2026-11-09 18:46:13 +01:00
Ludovic Marie 559c2d4e81 Merge pull request #14062 from MSedgeMC/fix/sign-messages-codec
🤖 Agent: a non-list messages now empties the side as SignText.CODEC does; still open: 26.3 also fails a side whose messages are missing or not exactly 4 (listOf(4, 4)), a follow-up.
2026-11-09 18:46:09 +01:00
Ludovic Marie ee449e0ef2 Merge pull request #13734 from RINNECODER/up10/campfire-smoke-limit
🤖 Agent: campfire smoke now goes through the particle limiter like vanilla's; marker_particles 2187/2187 pass on a union with main.
2026-11-09 18:46:05 +01:00
Ludovic Marie dc3d97ce25 Merge pull request #13728 from DUzzL/fix/13673-cured-villager-gear
🤖 Agent: cure drops and cursed gear go to the inventory as in vanilla; zombie_cure_sound 804/804 pass on a union with main.
2026-11-09 18:46:00 +01:00
Ludovic Marie cab5f23234 Merge pull request #13724 from DUzzL/fix/13672-cushion-lava
🤖 Agent: the cushion now takes lava damage at the check interval, as vanilla does; cushion_placement (2852 cases), fall_damage and living_fall pass on a union with main.
2026-11-09 18:45:56 +01:00
Ludovic Marie 2c5d1990ce Merge pull request #13957 from horizon-rust/layers/packs-sky
🤖 Agent: OptiFine custom sky now reaches the engine only through pack_hooks::PackFormats::custom_sky, resolved once per pack load (the per-frame draw keeps the same layer list and shader). layer plan M6 step 1. verify green on this head; no vanilla behaviour changed (parity.py selects 633 harnesses only because game.rs is touched; none observes pack skies).
2026-11-09 18:45:52 +01:00
MSedgeMC ec3e4df6e3 port_map: EditBox.setCentered 2026-11-09 16:55:26 +00:00
MSedgeMC a0f02b61ea test: a centred edit box starts its text in the middle
The title box is set centred; the test pins the offset for an empty, a short
and an odd-width value, and that the sign screen's title box asks for it.
2026-11-09 16:55:26 +00:00
MSedgeMC 7f2e64b603 fix: the book signing title box centres its text
BookSignScreen sets its 15 character title box centred, so the title and the
caret sit in the middle of the 114 pixel box. EditBox had no centred mode: the
text (and a click on it) started at the box's left edge.
2026-11-09 16:55:26 +00:00
Horizon agentandClaude Opus 5.5 b77e4c4924 horizon_look.rs: the module the previous commit declares
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:54:56 +01:00
valuttaandClaude Opus 5.5 861b9e7b16 Client tick: skip far mobs before the pushes' lookups and effectless ones before their size
remote_pushes looked up every mob's player entry and scoreboard team
before testing whether its box meets the player's; effect_swirls worked
out every mob's width and height before seeing it had no effects. Both
skips come first now, for the same pushes and particles. At 10,000 pigs
the client tick takes about a quarter less of the main thread.

Fixes #14053

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:53:00 +01:00
MSedgeMC b75193985b port_map: WorldOptionsScreen.init's port box tooltip 2026-11-09 16:51:28 +00:00
MSedgeMC 1a07a02990 port_map: the social row buttons' tooltip delay and the PlayerEntry row 2026-11-09 16:51:22 +00:00
Mik00000 c221aa9ec9 perf(render): cache chunk lookups inside ParticleLevel
Maintainer feedback from PR #13906 requested keeping signatures identical and
placing the one-chunk lookup cache directly inside ParticleLevel via
Cell<Option<((i32, i32), Option<&'a Chunk>)>>, avoiding threading _cached twins
and preventing particles.rs from growing beyond base size.

- Store cached_chunk inside ParticleLevel and read through ParticleLevel::block.
- Decouple Ctx lifetime parameter in polytone.rs (Ctx<'a, 'l>) so ParticleLevel's
  invariant 'a does not constrain stack evaluation borrows.
- Keep particles.rs strictly under line size limits (5,195 non-test lines vs 5,197 base).
- Passes check_file_size.py, cargo fmt, clippy -D warnings, and particle unit tests.
2026-11-09 18:47:29 +02:00
Horizon agentandClaude Opus 5.5 3e77ec425f remote.rs: the packet poll loop moves to remote_poll.rs (pure move)
poll_stationary_client, poll_remote and poll_remote_events move unchanged
into game/remote_poll.rs, a module of remote.rs, so a change to the loop
(#12271's cancellable packets) no longer grows remote.rs, which is over the
3000-line limit. Only the visibility (pub(in crate::game)) and one path
(super::super::server_links) follow the new module.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 17:45:28 +01:00
Horizon agent 2a018699ac Merge remote-tracking branch 'origin/main' into layers/packs-sky
# Conflicts:
#	crates/horizon-client/src/client_lib.rs
2026-11-09 17:34:13 +01:00
Vinícius MoreiraandClaude Opus 5.5 5757de7e74 Merge main into #13613
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:28:51 -03:00
MSedgeMC 916ab6b8b1 A reopened social screen starts its tooltips' wait again
The screen's widgets are new on every open in vanilla, so the half-second
wait starts over rather than resuming a hover from the last time.
2026-11-09 16:26:28 +00:00
MSedgeMC b56e58fde3 Document the sign side codec fallback 2026-11-09 16:24:35 +00:00
alexcatdadandClaude Opus 5.5 0df07ea3cf Nix: build horizon-server, the engine, horizon-prepare and the client from the flake
The flake built only the launcher, for Linux. It now also builds the game's programs from this
workspace, with nixpkgs' rustPlatform and Cargo.lock, on x86_64-linux, aarch64-linux and
aarch64-darwin (nix/):

- horizon-engine: horizon-client --no-default-features, the headless game (no ALSA or udev);
- horizon-server, with horizon-engine linked beside it, where the server looks for the game;
- horizon-prepare, with a JDK 25 as its default JAVA_HOME and its root (research-lock.json, the
  exporters' sources) in share/: a wrapper copies that root somewhere writable unless --root is given;
- horizon-client with horizon-ctl, the libraries wgpu and winit open at run time on its rpath.

The launcher and the dev shell stay as they were, on Linux. docs/nix.md says how to use them. Nothing
of Mojang's is fetched or packaged. Part of #13841.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 18:23:29 +02:00
TheDanikReal e527784bc3 Fix more Belarusian translations 2026-11-09 16:23:03 +00:00
MSedgeMC 5dd06d27cb The port box explains itself on hover
WorldOptionsScreen gives portEdit the PORT_INFO_TEXT tooltip, so hovering the
LAN port box says what it is; Horizon drew no tooltip for it. The box stays
visible with LAN off (only its editing stops), so the tooltip shows there too.
2026-11-09 16:21:49 +00:00
MSedgeMC 3c5a95239d A social screen row button's tooltip waits half a second
PlayerEntry sets TOOLTIP_DELAY (500 ms) on its report, mute and show
buttons, so their tooltips appear only after the cursor has rested on the
same button for half a second (WidgetTooltipHolder). Horizon's social screen
drew the row button's tooltip on the first hovered frame.

social_screen::TooltipDelay now tracks the hovered Tip and its start time and
answers whether the tooltip shows; the screen's draw passes the current tip.
2026-11-09 16:21:43 +00:00
TheDanikReal af593b6559 Fix Belarusian translations 2026-11-09 16:08:47 +00:00
Horizon agentandClaude Opus 5.5 aa060c5fe1 Horizon's look is given from its own module
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 07ff96c50c rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 19c23a96c6 rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Horizon agentandClaude Opus 5.5 29d8c49bc2 Window icon: the engine takes it from the layer above
horizon-client-input's window code no longer embeds Horizon's icon: the
startup gives it (window::set_icon_png), and with_horizon_icon becomes
with_icon. horizon-client-input no longer depends on horizon-theme outside
its tests (layer plan M6).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 16:59:57 +01:00
Vinícius MoreiraandClaude Opus 5.5 ed3fabb7b4 command_exec: the command block case keeps to failed commands
A success's feedback names the player with an entity hover, a new score
holder (which changes a later scoreboard players list) or a bracketed
value, whose components the two engines build differently. The case
keeps the two failures, whose styled feedback is what the save must keep.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 12:35:40 -03:00
MSedgeMC 83c50cf720 A sign whose messages are not a list loads an empty side
SignText.CODEC reads a side's messages as a list; any other tag fails the side
and leaves SignText.EMPTY. Horizon ignored the bad field but still applied the
side's colour and glow.
2026-11-09 15:32:32 +00:00
Horizon agentandClaude Opus 5.5 15da067a21 fix: deref the sky layers; rustfmt
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:25:26 +01:00
Horizon agentandClaude Opus 5.5 382776e2ee Custom sky layers through the engine's pack-format hook
layer plan M6 (pack formats), step 1: the renderer's custom sky layers are
engine types (crate::custom_sky: Blend, Draw, Conditions, the SkyLayers
trait); OptiFine's sky format fills them through crate::pack_hooks, a hook
the platform installs at startup. The OptiFine glue moves to pack_formats/,
which only it names horizon_optifine and moves to a platform crate later.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 15:17:38 +01:00
Horizon agentandClaude Opus 5.5 9163335028 command_exec: the command block case scores the player, not a new holder
A fake holder (#cb) created here stays tracked after its objective is
removed, and the later /scoreboard players list then lists the holders
in another order than vanilla's hash map. Horizon's open-hash order for
that insertion is a separate bug; this case is about LastOutput.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 13:49:14 +01:00
Vinícius MoreiraandClaude Opus 5.5 d3392cb35b A command block saves its last output as a component
BaseCommandBlock.save writes LastOutput with ComponentSerialization.CODEC:
the time-stamped feedback component with its colours and click events.
Horizon wrote its plain characters as a string.

Fixes #13077

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 09:40:24 -03:00
RINNECODERandClaude Opus 5.5 26d55e5d39 Campfire smoke goes through the particle limiter
`make_campfire_smoke` made both of its particles with the engine's
`create`, which skips `ClientLevel.doAddParticle`'s limiter, so with
Particles on Minimal or Decreased, or a campfire more than 32 blocks
away, a smoking (doused) campfire still drew every plain `smoke` puff.

As `CampfireBlock.makeParticles` does, the cosy/signal smoke now goes
through `addAlwaysVisibleParticle(.., true, ..)` (`add_with(.., true,
true)`: always made, but it still rolls the Particles option) and the
plain smoke through `addParticle` (`add`).

Fixes #13663

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-11-09 01:59:31 -06:00
DUzzL f82a1bd4fb A cured zombie villager drops its picked-up gear instead of wearing it 2026-11-09 08:30:18 +01:00
DUzzL 60b7307b3f Break cushions in lava at the check interval 2026-11-09 08:19:30 +01:00
64 changed files with 1913 additions and 1006 deletions

No files matched your search

+4 -2
View File
@@ -9,11 +9,13 @@ description = "The client's input layer: key bindings and their names, mouse, co
[dependencies]
horizon-client-core = { path = "../horizon-client-core" }
winit = "0.30.13"
# The window icon.
horizon-theme = { path = "../horizon-theme" }
png = "0.18"
arboard = { version = "3.6.1", default-features = false, features = ["wayland-data-control"] }
[dev-dependencies]
# The window icon test decodes Horizon's icon.
horizon-theme = { path = "../horizon-theme" }
[features]
controllers = []
+32 -10
View File
@@ -638,10 +638,30 @@ pub fn fullscreen_request(
}
}
/// Horizon's icon for the window's title bar and the task bar (X11 and Windows; Wayland takes
/// it from the `horizon.desktop` entry, macOS from the app bundle). `None` if it can't be read.
/// The PNG the windows show as their icon, given once at startup by the layer that brands the
/// game (the engine has none of its own).
static ICON_PNG: std::sync::OnceLock<&'static [u8]> = std::sync::OnceLock::new();
/// Give the windows their icon, a PNG: call it before the first window opens. A second call
/// keeps the first icon.
pub fn set_icon_png(png: &'static [u8]) {
let _ = ICON_PNG.set(png);
}
/// The icon given to [`set_icon_png`], if any.
pub fn icon_png() -> Option<&'static [u8]> {
ICON_PNG.get().copied()
}
/// The icon for the window's title bar and the task bar (X11 and Windows; Wayland takes it from
/// the `.desktop` entry, macOS from the app bundle). `None` with no icon given, or one that
/// can't be read.
pub fn icon() -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(horizon_theme::ICON_PNG));
decode_icon(icon_png()?)
}
fn decode_icon(png: &[u8]) -> Option<winit::window::Icon> {
let mut decoder = png::Decoder::new(std::io::Cursor::new(png));
decoder.ignore_checksums(true);
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
@@ -654,14 +674,15 @@ pub fn icon() -> Option<winit::window::Icon> {
winit::window::Icon::from_rgba(bytes, info.width, info.height).ok()
}
/// Apply Horizon's icon to every window. Windows has a separate taskbar icon slot from the small
/// window icon; requiring the decoded icon there prevents a generic taskbar icon from appearing.
pub fn with_horizon_icon(
attributes: winit::window::WindowAttributes,
) -> winit::window::WindowAttributes {
/// Apply the icon given to [`set_icon_png`] to a window. Windows has a separate taskbar icon slot
/// from the small window icon; requiring a given icon to decode there prevents a generic taskbar
/// icon from appearing.
pub fn with_icon(attributes: winit::window::WindowAttributes) -> winit::window::WindowAttributes {
#[cfg(not(target_os = "windows"))]
let icon = icon();
#[cfg(target_os = "windows")]
let icon = Some(icon.expect("Horizon's icon must be available on Windows"));
let icon =
icon_png().map(|png| decode_icon(png).expect("the window icon must decode on Windows"));
let attributes = attributes.with_window_icon(icon.clone());
#[cfg(target_os = "windows")]
@@ -706,7 +727,8 @@ mod tests {
}
#[test]
fn the_window_icon_decodes() {
assert!(icon().is_some());
assert!(decode_icon(horizon_theme::ICON_PNG).is_some());
assert!(decode_icon(b"not a png").is_none());
}
#[test]
@@ -121,8 +121,8 @@ impl CommandBlock {
condition_met: flag("conditionMet", false),
}
}
/// `CommandBlockEntity.saveAdditional`. The last output is written as a
/// plain string component.
/// `CommandBlockEntity.saveAdditional`. The last output is the component
/// itself (`ComponentSerialization.CODEC`), styles and click events kept.
pub fn save(&self) -> Vec<(&'static str, Tag)> {
let mut out = vec![
("Command", Tag::String(self.command.as_str().into())),
@@ -135,7 +135,7 @@ impl CommandBlock {
if self.track_output
&& let Some(last) = &self.last_output
{
out.push(("LastOutput", Tag::String(last.plain().as_str().into())));
out.push(("LastOutput", scoreboard::component_tag(&last.component())));
}
out.push((
"UpdateLastExecution",
+76 -204
View File
@@ -1,27 +1,34 @@
//! OptiFine custom skies (`horizon_optifine::sky`) as the game draws them: a pack's layers with
//! their decoded textures, read once with the pack, and each frame's choice of layers, their
//! brightness and rotation ([`CustomSky::draws`]), which `gfx::custom_sky` draws over the
//! vanilla sky. A pack without `optifine/sky` gives an empty [`CustomSky`] and the sky stays
//! vanilla.
//! Custom sky layers: skyboxes a pack draws over the vanilla sky, in a format vanilla doesn't read
//! (OptiFine's), which the platform's pack formats fill ([`crate::pack_hooks`]). The layers'
//! textures are read once with the pack; each frame the game asks for the layers to draw, their
//! brightness and rotation ([`SkyLayers::draws`]), and `gfx::custom_sky` draws them over the
//! vanilla sky. A pack without layers keeps the vanilla sky.
use std::path::Path;
use std::sync::Arc;
use glam::{Mat4, Vec3};
use horizon_optifine::sky::{Blend, Conditions, Skies, SkyLayer, Transition};
use glam::Mat4;
/// A layer's texture, RGBA8.
/// A layer's texture, RGBA8: the skybox's six faces in a 3x2 grid, bottom, top and south in the
/// top row, west, north and east below (`gfx::custom_sky`'s faces).
pub struct Image {
pub width: u32,
pub height: u32,
pub rgba: Vec<u8>,
}
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
/// over all worlds, in `worlds` order).
#[derive(Default)]
pub struct CustomSky {
worlds: Vec<(i32, Vec<SkyLayer>)>,
pub images: Vec<Image>,
/// How a layer mixes with what is drawn below it.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Blend {
Add,
Subtract,
Multiply,
Dodge,
Burn,
Screen,
Replace,
Overlay,
Alpha,
}
/// One layer of a frame: which texture, with which blend, how bright and turned how.
@@ -34,62 +41,44 @@ pub struct Draw {
pub rotation: Mat4,
}
/// The weather of a frame: thunder is a storm's rain, so it is only `Thunder`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Weather {
Clear,
Rain,
Thunder,
}
/// What a frame's layers may depend on.
#[derive(Clone, Copy, Debug)]
pub struct Conditions<'a> {
/// Ticks of the day, counting the days.
pub day_time: i64,
pub weather: Weather,
/// The biome at the camera, namespaced.
pub biome: &'a str,
/// The camera's block height.
pub y: i32,
}
/// The eased brightness of each layer, kept by the game between frames.
#[derive(Default)]
pub struct State {
world: Option<i32>,
eased: Vec<Transition>,
/// The world the layers were last drawn for.
pub world: Option<i32>,
/// Each layer's eased visibility, 0 to 1.
pub eased: Vec<f32>,
}
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
const TEXEL_BUDGET: u64 = 64 << 20;
impl CustomSky {
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
/// dropped with a warning.
pub fn load(root: &Path) -> Self {
let skies = Skies::scan(root);
let mut out = Self::default();
let mut texels = 0u64;
for (world, sky) in skies.worlds {
let mut layers = Vec::new();
for layer in sky.layers {
match crate::hud::decode(&root.join(&layer.source)) {
Ok((width, height, rgba))
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
{
texels += u64::from(width) * u64::from(height);
out.images.push(Image {
width,
height,
rgba,
});
layers.push(layer);
}
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
Err(e) => log::warn!("{}: {e:#}", layer.source),
}
}
if !layers.is_empty() {
out.worlds.push((world, layers));
}
}
out
}
pub fn is_empty(&self) -> bool {
self.worlds.is_empty()
}
/// Whether `world` has layers (`0` the Overworld, `1` the End).
pub fn has(&self, world: i32) -> bool {
self.worlds.iter().any(|(w, _)| *w == world)
}
/// This frame's layers of `world` into `out` (cleared first), in drawing order: those whose
/// conditions hold, eased over their `transition` by `seconds` since the last frame, at the
/// brightness of their fade times. `sun_turns` is the sun's angle over 360° (`0` at noon).
pub fn draws(
/// A pack's custom sky layers, by world (`0` the Overworld, `1` the End).
pub trait SkyLayers: Send + Sync {
/// Every layer's texture: [`Draw::image`] indexes it.
fn images(&self) -> &[Image];
/// Whether `world` has layers.
fn has(&self, world: i32) -> bool;
/// This frame's layers of `world` into `out` (cleared first), in drawing order, eased by
/// `seconds` since the last frame. `sun_turns` is the sun's angle over 360° (`0` at noon).
fn draws(
&self,
world: i32,
conditions: &Conditions,
@@ -97,147 +86,30 @@ impl CustomSky {
seconds: f32,
state: &mut State,
out: &mut Vec<Draw>,
) {
);
}
/// No layers: the vanilla sky.
struct NoLayers;
impl SkyLayers for NoLayers {
fn images(&self) -> &[Image] {
&[]
}
fn has(&self, _: i32) -> bool {
false
}
fn draws(&self, _: i32, _: &Conditions, _: f32, _: f32, s: &mut State, out: &mut Vec<Draw>) {
out.clear();
let mut first = 0;
let mut found = None;
for (w, layers) in &self.worlds {
if *w == world {
found = Some(layers);
break;
}
first += layers.len();
}
let Some(layers) = found else {
state.world = None;
return;
};
// A new world (or pack) starts each layer where its conditions put it: no fade in.
let snap = state.world != Some(world) || state.eased.len() != layers.len();
if snap {
state.world = Some(world);
state.eased.clear();
state.eased.resize(layers.len(), Transition::default());
}
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
let shown = if snap {
eased.value = target;
target
} else {
eased.step(target, seconds, layer.transition)
};
let brightness = shown * layer.brightness(conditions.day_time);
if brightness <= 0.0 {
continue;
}
let turns = layer.turns(conditions.day_time, sun_turns);
let rotation = if turns == 0.0 {
Mat4::IDENTITY
} else {
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
};
out.push(Draw {
image: (first + i) as u16,
blend: layer.blend,
brightness,
rotation,
});
}
s.world = None;
}
}
#[cfg(test)]
mod tests {
use super::*;
use horizon_optifine::sky::Now;
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
let root = std::env::temp_dir().join(format!(
"horizon-custom-sky-{}-{}",
std::process::id(),
files.len()
));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("assets/minecraft/optifine/sky/world0");
std::fs::create_dir_all(&dir).unwrap();
let png = {
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[255, 0, 0, 255].repeat(24))
.unwrap();
writer.finish().unwrap();
bytes
};
for (name, text) in files {
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
}
let sky = CustomSky::load(&root);
(root, sky)
}
fn noon(biome: &str) -> Conditions<'_> {
Conditions {
day_time: 6000,
weather: Now::Clear,
biome,
y: 64,
}
}
#[test]
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
let (root, sky) = pack(&[
("sky1", "biomes=desert\ntransition=2"),
("sky2", "blend=alpha\nrotate=false"),
]);
let _ = std::fs::remove_dir_all(root);
assert!(sky.has(0) && !sky.has(1));
assert_eq!(sky.images[0].width, 6);
let (mut state, mut out) = (State::default(), Vec::new());
sky.draws(
0,
&noon("minecraft:desert"),
0.0,
0.05,
&mut state,
&mut out,
);
assert_eq!(
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
[1.0, 1.0]
);
// Out of the desert: the first layer fades over its two seconds.
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
assert_eq!(out[0].brightness, 0.75);
assert_eq!(out[1].blend, Blend::Alpha);
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
assert_eq!(out.len(), 1, "a dark layer is not drawn");
assert_eq!(out[0].image, 1);
// Into the End, which has no layers, and back: no fade.
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert!(out.is_empty());
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert_eq!(out.len(), 2);
}
#[test]
fn a_layer_turns_west_like_the_sun() {
let (root, sky) = pack(&[("sky1", "")]);
let _ = std::fs::remove_dir_all(root);
let (mut state, mut out) = (State::default(), Vec::new());
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
let c = Conditions {
day_time: 12000,
..noon("minecraft:plains")
};
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
let up = out[0].rotation.transform_vector3(Vec3::Y);
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
}
/// The custom sky layers of the overlay at `root`, from the installed pack formats.
pub fn load(root: &Path) -> Arc<dyn SkyLayers> {
crate::pack_hooks::formats()
.custom_sky(root)
.unwrap_or_else(|| Arc::new(NoLayers))
}
+4 -4
View File
@@ -1329,7 +1329,7 @@ pub struct Renderer {
celestial_buffer: wgpu::Buffer,
end_sky: end_sky::EndSky,
stars: stars::Stars,
/// OptiFine custom sky layers (`crate::custom_sky`), and the frame's from the game.
/// Custom sky layers (`crate::custom_sky`), and the frame's from the game.
custom_sky: custom_sky::CustomSky,
custom_sky_draws: Vec<crate::custom_sky::Draw>,
/// The glowing entities' outline (`LevelRenderer.entityOutlineTarget`).
@@ -5517,11 +5517,11 @@ impl Renderer {
))
});
}
/// The pack's OptiFine custom sky layer textures (`crate::custom_sky::CustomSky::load`).
pub fn set_custom_sky(&mut self, pack: &crate::custom_sky::CustomSky) {
/// The pack's custom sky layer textures (`crate::custom_sky::load`).
pub fn set_custom_sky(&mut self, pack: &dyn crate::custom_sky::SkyLayers) {
self.custom_sky.set_pack(&self.device, &self.queue, pack);
}
/// The custom sky layers the next frame draws (`CustomSky::draws`): the game refills it each
/// The custom sky layers the next frame draws (`SkyLayers::draws`): the game refills it each
/// frame, empty when the world's sky has none.
pub fn custom_sky_draws(&mut self) -> &mut Vec<crate::custom_sky::Draw> {
&mut self.custom_sky_draws
@@ -1,12 +1,11 @@
//! OptiFine custom sky layers on the GPU: one texture per layer, kept with the renderer (its pack),
//! Custom sky layers (`crate::custom_sky`) on the GPU: one texture per layer, kept with the renderer (its pack),
//! a static skybox mesh of the 3x2 grid's six faces, and one pipeline per blend mode. A frame
//! uploads one small uniform per drawn layer ([`crate::custom_sky::Draw`]) and draws 36 vertices
//! each, after the vanilla sky's disc and sunrise and before the sun, moon and stars.
use glam::Mat4;
use horizon_optifine::sky::{Blend, Face};
use wgpu::util::DeviceExt;
use crate::custom_sky::{CustomSky as Pack, Draw};
use crate::custom_sky::{Blend, Draw, SkyLayers};
/// The blend modes in the shader's `mode` order (custom_sky.wgsl).
const MODES: [Blend; 9] = [
@@ -85,6 +84,76 @@ fn blend(mode: Blend) -> wgpu::BlendComponent {
}
}
/// A face of the skybox, as the 3x2 grid of its texture holds them: `Bottom`, `Top`, `South` in
/// the top row, `West`, `North`, `East` below.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Face {
Bottom,
Top,
South,
West,
North,
East,
}
impl Face {
const ALL: [Self; 6] = [
Self::Bottom,
Self::Top,
Self::South,
Self::West,
Self::North,
Self::East,
];
/// The face's cell of the texture, `[u0, v0, u1, v1]` in 0..1.
fn cell(self) -> [f32; 4] {
let (col, row) = match self {
Self::Bottom => (0, 0),
Self::Top => (1, 0),
Self::South => (2, 0),
Self::West => (0, 1),
Self::North => (1, 1),
Self::East => (2, 1),
};
let (u, v) = (col as f32 / 3.0, row as f32 / 2.0);
[u, v, u + 1.0 / 3.0, v + 0.5]
}
/// The face's corners (on the unit cube around the camera; x east, y up, z south) in the
/// order of its cell's `(u0, v0)`, `(u1, v0)`, `(u1, v1)`, `(u0, v1)`. The side faces are
/// seen from inside with up at the top of their cell; the top face has north at the bottom of
/// its cell, the bottom face north at the top (the template's `N` marks).
fn corners(self) -> [[f32; 3]; 4] {
match self {
// Seen looking north: west on the left.
Self::North => [
[-1., 1., -1.],
[1., 1., -1.],
[1., -1., -1.],
[-1., -1., -1.],
],
Self::South => [[1., 1., 1.], [-1., 1., 1.], [-1., -1., 1.], [1., -1., 1.]],
Self::East => [[1., 1., -1.], [1., 1., 1.], [1., -1., 1.], [1., -1., -1.]],
Self::West => [
[-1., 1., 1.],
[-1., 1., -1.],
[-1., -1., -1.],
[-1., -1., 1.],
],
// Looking up with north toward the cell's bottom edge: east on the right.
Self::Top => [[-1., 1., 1.], [1., 1., 1.], [1., 1., -1.], [-1., 1., -1.]],
// Looking down with north toward the cell's top edge: east on the right.
Self::Bottom => [
[-1., -1., -1.],
[1., -1., -1.],
[1., -1., 1.],
[-1., -1., 1.],
],
}
}
}
/// The skybox's 36 vertices: two triangles per face, each corner with its cell's UV.
fn mesh() -> Vec<Vertex> {
let mut out = Vec::with_capacity(36);
@@ -256,10 +325,15 @@ impl CustomSky {
}
/// The pack's layer textures, replacing any before.
pub(super) fn set_pack(&mut self, device: &wgpu::Device, queue: &wgpu::Queue, pack: &Pack) {
pub(super) fn set_pack(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
pack: &dyn SkyLayers,
) {
let limit = device.limits().max_texture_dimension_2d;
self.groups = pack
.images
.images()
.iter()
.map(|image| {
// Too large for this GPU: a 1x1 transparent texel, so the layer draws nothing.
@@ -413,6 +487,38 @@ impl CustomSky {
mod tests {
use super::*;
#[test]
fn the_grid_holds_each_face_once_and_faces_point_out() {
let mut cells: Vec<_> = Face::ALL
.iter()
.map(|f| f.cell().map(|x| (x * 6.0) as i32))
.collect();
cells.dedup();
assert_eq!(cells.len(), 6);
assert_eq!(Face::East.cell(), [2.0 / 3.0, 0.5, 1.0, 1.0]);
for face in Face::ALL {
let c = face.corners();
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
let (a, b) = (sub(c[1], c[0]), sub(c[3], c[0]));
let n = [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
let centre: [f32; 3] =
std::array::from_fn(|i| c.iter().map(|p| p[i]).sum::<f32>() / 4.0);
// Seen from inside, u runs right and v down, so u x v points away from the camera.
let dot: f32 = (0..3).map(|i| n[i] * centre[i]).sum();
assert!(dot > 0.0, "{face:?} is mirrored");
}
// The top cell's bottom edge (v1) is north: its corners 2 and 3 have z = -1.
assert_eq!(Face::Top.corners()[2][2], -1.0);
assert_eq!(Face::Top.corners()[3][2], -1.0);
// The bottom cell's top edge (v0) is north.
assert_eq!(Face::Bottom.corners()[0][2], -1.0);
assert_eq!(Face::Bottom.corners()[1][2], -1.0);
}
/// What a blend leaves a sky texel of `dst` with when the shader outputs `src`
/// (custom_sky.wgsl) for a layer texel `t` at brightness `b`, per channel.
fn mix(mode: Blend, dst: f32, t: f32, a: f32, b: f32) -> f32 {
@@ -6,7 +6,7 @@ struct Layer {
// Projection * view (camera-relative) * the layer's rotation.
clip_from_sky: mat4x4<f32>,
brightness: f32,
// horizon_optifine::sky::Blend, in gfx/custom_sky.rs's MODES order.
// crate::custom_sky::Blend, in gfx/custom_sky.rs's MODES order.
mode: u32,
pad: vec2<u32>,
hdr_settings: vec4<f32>,
+2
View File
@@ -89,6 +89,8 @@ pub mod options_fixes;
pub mod options_ui;
pub mod overlay;
pub mod pack_browser;
pub mod pack_formats;
pub mod pack_hooks;
pub mod pack_options_ui;
pub mod pack_palette;
pub mod pack_screen;
@@ -0,0 +1,252 @@
//! OptiFine custom skies (`horizon_optifine::sky`) on the engine's sky layers
//! ([`crate::custom_sky`]): a pack's layers with their decoded textures, read once with the pack,
//! and each frame's choice of layers, their brightness and rotation. A pack without
//! `optifine/sky` has none, and the sky stays vanilla.
use std::path::Path;
use glam::{Mat4, Vec3};
use horizon_optifine::sky::{Blend, Conditions, Now, Skies, SkyLayer, Transition};
use crate::custom_sky::{self as engine, Draw, Image, SkyLayers, State};
/// The pack's layers, by world, with one texture per layer (`images[i]` for the `i`th layer
/// over all worlds, in `worlds` order).
#[derive(Default)]
pub struct CustomSky {
worlds: Vec<(i32, Vec<SkyLayer>)>,
pub images: Vec<Image>,
}
/// Textures past this many texels in all are left out (a skybox is a few megapixels at most).
const TEXEL_BUDGET: u64 = 64 << 20;
impl CustomSky {
/// The custom skies of the overlay at `root`. A layer whose texture does not decode is
/// dropped with a warning.
pub fn load(root: &Path) -> Self {
let skies = Skies::scan(root);
let mut out = Self::default();
let mut texels = 0u64;
for (world, sky) in skies.worlds {
let mut layers = Vec::new();
for layer in sky.layers {
match crate::hud::decode(&root.join(&layer.source)) {
Ok((width, height, rgba))
if texels + u64::from(width) * u64::from(height) <= TEXEL_BUDGET =>
{
texels += u64::from(width) * u64::from(height);
out.images.push(Image {
width,
height,
rgba,
});
layers.push(layer);
}
Ok(_) => log::warn!("{}: past the custom sky's texture budget", layer.source),
Err(e) => log::warn!("{}: {e:#}", layer.source),
}
}
if !layers.is_empty() {
out.worlds.push((world, layers));
}
}
out
}
pub fn is_empty(&self) -> bool {
self.worlds.is_empty()
}
}
/// The engine's sky blend for OptiFine's.
fn blend(blend: Blend) -> engine::Blend {
match blend {
Blend::Add => engine::Blend::Add,
Blend::Subtract => engine::Blend::Subtract,
Blend::Multiply => engine::Blend::Multiply,
Blend::Dodge => engine::Blend::Dodge,
Blend::Burn => engine::Blend::Burn,
Blend::Screen => engine::Blend::Screen,
Blend::Replace => engine::Blend::Replace,
Blend::Overlay => engine::Blend::Overlay,
Blend::Alpha => engine::Blend::Alpha,
}
}
impl SkyLayers for CustomSky {
fn images(&self) -> &[Image] {
&self.images
}
/// Whether `world` has layers (`0` the Overworld, `1` the End).
fn has(&self, world: i32) -> bool {
self.worlds.iter().any(|(w, _)| *w == world)
}
/// The layers whose conditions hold, eased over their `transition`, at the brightness of
/// their fade times.
fn draws(
&self,
world: i32,
conditions: &engine::Conditions,
sun_turns: f32,
seconds: f32,
state: &mut State,
out: &mut Vec<Draw>,
) {
let conditions = &Conditions {
day_time: conditions.day_time,
weather: match conditions.weather {
engine::Weather::Clear => Now::Clear,
engine::Weather::Rain => Now::Rain,
engine::Weather::Thunder => Now::Thunder,
},
biome: conditions.biome,
y: conditions.y,
};
out.clear();
let mut first = 0;
let mut found = None;
for (w, layers) in &self.worlds {
if *w == world {
found = Some(layers);
break;
}
first += layers.len();
}
let Some(layers) = found else {
state.world = None;
return;
};
// A new world (or pack) starts each layer where its conditions put it: no fade in.
let snap = state.world != Some(world) || state.eased.len() != layers.len();
if snap {
state.world = Some(world);
state.eased.clear();
state.eased.resize(layers.len(), 0.0);
}
for (i, (layer, eased)) in layers.iter().zip(&mut state.eased).enumerate() {
let target = if layer.shows(conditions) { 1.0 } else { 0.0 };
let shown = if snap {
*eased = target;
target
} else {
let mut t = Transition { value: *eased };
*eased = t.step(target, seconds, layer.transition);
*eased
};
let brightness = shown * layer.brightness(conditions.day_time);
if brightness <= 0.0 {
continue;
}
let turns = layer.turns(conditions.day_time, sun_turns);
let rotation = if turns == 0.0 {
Mat4::IDENTITY
} else {
Mat4::from_axis_angle(Vec3::from_array(layer.axis), turns * std::f32::consts::TAU)
};
out.push(Draw {
image: (first + i) as u16,
blend: blend(layer.blend),
brightness,
rotation,
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::custom_sky::{Conditions, Weather};
fn pack(files: &[(&str, &str)]) -> (std::path::PathBuf, CustomSky) {
let root = std::env::temp_dir().join(format!(
"horizon-custom-sky-{}-{}",
std::process::id(),
files.len()
));
let _ = std::fs::remove_dir_all(&root);
let dir = root.join("assets/minecraft/optifine/sky/world0");
std::fs::create_dir_all(&dir).unwrap();
let png = {
let mut bytes = Vec::new();
let mut encoder = png::Encoder::new(&mut bytes, 6, 4);
encoder.set_color(png::ColorType::Rgba);
encoder.set_depth(png::BitDepth::Eight);
let mut writer = encoder.write_header().unwrap();
writer
.write_image_data(&[255, 0, 0, 255].repeat(24))
.unwrap();
writer.finish().unwrap();
bytes
};
for (name, text) in files {
std::fs::write(dir.join(format!("{name}.properties")), text).unwrap();
std::fs::write(dir.join(format!("{name}.png")), &png).unwrap();
}
let sky = CustomSky::load(&root);
(root, sky)
}
fn noon(biome: &str) -> Conditions<'_> {
Conditions {
day_time: 6000,
weather: Weather::Clear,
biome,
y: 64,
}
}
#[test]
fn layers_ease_in_at_a_biome_border_but_not_on_arrival() {
let (root, sky) = pack(&[
("sky1", "biomes=desert\ntransition=2"),
("sky2", "blend=alpha\nrotate=false"),
]);
let _ = std::fs::remove_dir_all(root);
assert!(sky.has(0) && !sky.has(1));
assert_eq!(sky.images[0].width, 6);
let (mut state, mut out) = (State::default(), Vec::new());
sky.draws(
0,
&noon("minecraft:desert"),
0.0,
0.05,
&mut state,
&mut out,
);
assert_eq!(
out.iter().map(|d| d.brightness).collect::<Vec<_>>(),
[1.0, 1.0]
);
// Out of the desert: the first layer fades over its two seconds.
sky.draws(0, &noon("minecraft:plains"), 0.0, 0.5, &mut state, &mut out);
assert_eq!(out[0].brightness, 0.75);
assert_eq!(out[1].blend, engine::Blend::Alpha);
sky.draws(0, &noon("minecraft:plains"), 0.0, 2.0, &mut state, &mut out);
assert_eq!(out.len(), 1, "a dark layer is not drawn");
assert_eq!(out[0].image, 1);
// Into the End, which has no layers, and back: no fade.
sky.draws(1, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert!(out.is_empty());
sky.draws(0, &noon("minecraft:desert"), 0.0, 0.0, &mut state, &mut out);
assert_eq!(out.len(), 2);
}
#[test]
fn a_layer_turns_west_like_the_sun() {
let (root, sky) = pack(&[("sky1", "")]);
let _ = std::fs::remove_dir_all(root);
let (mut state, mut out) = (State::default(), Vec::new());
// A quarter turn after noon (sunset): the skybox's zenith has gone down to the west.
let c = Conditions {
day_time: 12000,
..noon("minecraft:plains")
};
sky.draws(0, &c, 0.25, 0.0, &mut state, &mut out);
let up = out[0].rotation.transform_vector3(Vec3::Y);
assert!((up - Vec3::NEG_X).length() < 1e-5, "{up}");
}
}
@@ -0,0 +1,26 @@
//! The resource pack formats vanilla doesn't read (OptiFine's, Polytone's) on the engine's
//! extension points ([`crate::pack_hooks`]). This is platform code (docs/layers.md) kept in this
//! crate until the platform's executable installs it: it uses only the crate's public API, so it
//! moves to a platform crate unchanged, and nothing else in the crate names it.
mod custom_sky;
use std::path::Path;
use std::sync::Arc;
use crate::pack_hooks::PackFormats;
/// OptiFine's and Polytone's formats.
pub struct Formats;
impl PackFormats for Formats {
fn custom_sky(&self, root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
let sky = custom_sky::CustomSky::load(root);
(!sky.is_empty()).then(|| Arc::new(sky) as _)
}
}
/// Installs these formats ([`crate::pack_hooks::install`]); the game does at startup.
pub fn install() {
crate::pack_hooks::install(&Formats);
}
@@ -0,0 +1,35 @@
//! The engine's extension points for resource pack formats vanilla doesn't read (OptiFine's,
//! Polytone's: platform code, docs/layers.md). The engine asks [`formats`] once per pack load and
//! gets engine types back (tables, or an object it calls at most once a frame), never a format's
//! own types. The platform installs its formats at startup ([`install`]); with none installed,
//! packs load as vanilla reads them.
use std::path::Path;
use std::sync::{Arc, OnceLock};
/// What the pack formats beyond vanilla's add to a pack. Each method reads one overlay of a pack
/// (its folder at `root`); the default is "nothing in this format".
pub trait PackFormats: Send + Sync {
/// The sky layers the overlay draws over the vanilla sky, if it has any.
fn custom_sky(&self, _root: &Path) -> Option<Arc<dyn crate::custom_sky::SkyLayers>> {
None
}
}
/// No format beyond vanilla's.
struct Vanilla;
impl PackFormats for Vanilla {}
static FORMATS: OnceLock<&'static dyn PackFormats> = OnceLock::new();
/// Installs the pack formats, before the first pack loads. The first call wins: later ones are
/// ignored, so each test or probe that needs the formats may install them.
pub fn install(formats: &'static dyn PackFormats) {
let _ = FORMATS.set(formats);
}
/// The installed pack formats, or vanilla's alone.
pub fn formats() -> &'static dyn PackFormats {
FORMATS.get().copied().unwrap_or(&Vanilla)
}
+44 -44
View File
@@ -1222,8 +1222,9 @@ pub struct ParticleLevel<'a> {
pub collision: &'a BlockCollisionRegistry<'a>,
/// Local player feet position and vertical velocity (`PlayerCloudParticle`).
pub player: Option<([f64; 3], f64)>,
pub cached_chunk: std::cell::Cell<Option<((i32, i32), Option<&'a crate::chunk::Chunk>)>>,
}
impl ParticleLevel<'_> {
impl<'a> ParticleLevel<'a> {
fn collide(&self, bb: Aabb, d: [f64; 3]) -> [f64; 3] {
let mut scene = SceneCollisions {
world: self.world,
@@ -1231,9 +1232,6 @@ impl ParticleLevel<'_> {
subject: None,
};
let movement = Vec3::new(d[0], d[1], d[2]);
// `Entity.collideBoundingBox(null, ...)`: no entity shapes, no border. Where no
// block of the swept box's cells can collide, it finds no shape and the
// movement stands, as the resolution below would return it.
if scene.cells_without_collision(bb.expand_towards(movement)) {
return d;
}
@@ -1249,38 +1247,46 @@ impl ParticleLevel<'_> {
p[2].floor() as i32,
]
}
#[inline]
pub fn block(&self, x: i32, y: i32, z: i32) -> horizon_world::registry::BlockStateId {
let (cx, cz) = (x >> 4, z >> 4);
let chunk = match self.cached_chunk.get() {
Some(((ccx, ccz), c)) if ccx == cx && ccz == cz => c,
_ => {
let c = self.world.chunks.get(&(cx, cz)).map(|c| c.as_ref());
self.cached_chunk.set(Some(((cx, cz), c)));
c
}
};
chunk.map_or(self.world.air, |c| c.block(x & 15, y, z & 15))
}
/// The fluid at a block and `FluidState.getHeight`.
fn fluid(&self, b: [i32; 3]) -> (Fluid, f64) {
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
let s = self.facts.get(self.block(b[0], b[1], b[2]));
if s.fluid == Fluid::None {
return (Fluid::None, 0.0);
}
let above = self.facts.get(self.world.block(b[0], b[1] + 1, b[2]));
let height = if above.fluid == s.fluid {
let above = self.facts.get(self.block(b[0], b[1] + 1, b[2]));
let h = if above.fluid == s.fluid {
1.0
} else {
f64::from(s.fluid_level) / 9.0
};
(s.fluid, height)
(s.fluid, h)
}
/// The fluid at a block and whether it is a source (`FluidState.isSource`).
pub(crate) fn fluid_source(&self, b: [i32; 3]) -> (Fluid, bool) {
let s = self.facts.get(self.world.block(b[0], b[1], b[2]));
let s = self.facts.get(self.block(b[0], b[1], b[2]));
(s.fluid, s.fluid_level == 8 && !s.fluid_falling)
}
/// `getCollisionShape(...).max(Y, x, z)` for cached shapes.
fn collision_top(&self, b: [i32; 3], fx: f64, fz: f64) -> f64 {
let state = self.world.block(b[0], b[1], b[2]);
let state = self.block(b[0], b[1], b[2]);
match self.collision.cached_shapes().cached_shape(state) {
Ok(Some(shape)) if !shape.is_empty() => shape.max_at(Axis::Y, fx, fz),
Ok(Some(_)) => 0.0,
_ => {
if self.facts.get(state).full_collision {
1.0
} else {
0.0
}
}
_ if self.facts.get(state).full_collision => 1.0,
_ => 0.0,
}
}
}
@@ -4052,15 +4058,12 @@ impl ParticleEngine {
}
Behaviour::Lava => {
p.base_tick(level);
if !p.removed {
let odds = p.age as f32 / p.lifetime as f32;
if p.random.next_float() > odds {
self.add(
&ParticleOptions::simple(ParticleType::Smoke),
p.position,
p.velocity,
);
}
if !p.removed && p.random.next_float() > p.age as f32 / p.lifetime as f32 {
self.add(
&ParticleOptions::simple(ParticleType::Smoke),
p.position,
p.velocity,
);
}
}
Behaviour::Crit => {
@@ -4144,9 +4147,7 @@ impl ParticleEngine {
*c *= f64::from(0.98f32);
}
if p.on_ground {
if p.random.next_float() < 0.5 {
p.removed = true;
}
p.removed |= p.random.next_float() < 0.5;
p.velocity[0] *= f64::from(0.7f32);
p.velocity[2] *= f64::from(0.7f32);
}
@@ -4186,13 +4187,10 @@ impl ParticleEngine {
} => {
p.base_tick(level);
let (fluid, source) = level.fluid_source(ParticleLevel::block_at(p.position));
if !(p.removed || (fluid == Fluid::Water && source)) {
p.removed = true;
}
if !p.removed && p.position[1] >= y_end {
p.removed = true;
}
if !p.removed && p.position[1] <= y_prev {
if !(fluid == Fluid::Water && source)
|| p.position[1] >= y_end
|| p.position[1] <= y_prev
{
p.removed = true;
}
// randomHorizontalWiggling, then a second, horizontal move.
@@ -4216,9 +4214,8 @@ impl ParticleEngine {
p.base_tick(level);
self.sprite_from_age(p);
if p.age as f32 > fade_start {
let frames_since_fade_out_start = p.age as f32 - fade_start;
p.rgba[3] =
(p.lifetime as f32 - frames_since_fade_out_start) / p.lifetime as f32;
(p.lifetime as f32 - (p.age as f32 - fade_start)) / p.lifetime as f32;
}
}
Behaviour::NoxiousGasCloud => {
@@ -4682,7 +4679,7 @@ impl ParticleEngine {
self.sprite_from_age(p);
half(p);
let b = ParticleLevel::block_at(p.position);
if level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
if level.facts.is_air(level.block(b[0], b[1], b[2])) {
p.velocity[1] -= f64::from(0.0074f32);
}
}
@@ -4690,7 +4687,7 @@ impl ParticleEngine {
Behaviour::Firefly => {
p.base_tick(level);
let b = ParticleLevel::block_at(p.position);
if !level.facts.is_air(level.world.block(b[0], b[1], b[2])) {
if !level.facts.is_air(level.block(b[0], b[1], b[2])) {
p.removed = true;
} else {
p.rgba[3] = fade_amount(p.lifetime_progress(p.age as f32), 0.3, 0.5);
@@ -5154,11 +5151,12 @@ fn hdr_tier(ty: ParticleType) -> u8 {
}
/// `Particle.LifetimeAlpha.currentAlphaForAge`.
fn lifetime_alpha(a: [f32; 4], age: i32, lifetime: i32, partial: f32) -> f32 {
if (a[0] - a[1]).abs() < 1.0e-5 {
return a[0];
}
let t = ((age as f32 + partial) / lifetime as f32 - a[2]) / (a[3] - a[2]);
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
if (a[0] - a[1]).abs() < 1.0e-5 {
a[0]
} else {
a[0] + (a[1] - a[0]) * t.clamp(0.0, 1.0)
}
}
struct Starter {
@@ -5589,6 +5587,7 @@ mod tests {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.set_sprites.push(vec![10, 11, 12]);
@@ -5680,6 +5679,7 @@ mod tests {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
let mut particle = Particle::blank(ParticleType::Smoke, [0.0; 3]);
@@ -124,6 +124,7 @@ mod tests {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut e = engine();
let mut p = e
@@ -490,14 +490,14 @@ enum Subject<'a> {
}
/// What one expression runs against: its subject, the frame and the level.
struct Ctx<'a> {
struct Ctx<'a, 'l> {
subject: Subject<'a>,
random: &'a mut LegacyRandom,
custom: &'a mut f64,
customs: &'a mut BTreeMap<String, f64>,
frame: &'a Frame,
registry: &'a Registry,
level: Option<&'a ParticleLevel<'a>>,
level: Option<&'a ParticleLevel<'l>>,
blocks: Vec<[i32; 3]>,
}
@@ -515,7 +515,7 @@ fn axis(name: &str) -> usize {
usize::from(name.as_bytes()[0] - b'x')
}
impl Ctx<'_> {
impl Ctx<'_, '_> {
fn position(&self) -> [f64; 3] {
match &self.subject {
Subject::Particle(p) => p.position,
@@ -683,7 +683,7 @@ impl Ctx<'_> {
}
}
impl Host for Ctx<'_> {
impl Host for Ctx<'_, '_> {
fn root(&mut self, root: Root) -> V {
match root {
Root::Object => V::Object(SELF),
@@ -1662,6 +1662,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
// Air has no emitters; the torch fires its first emitter only.
@@ -1701,6 +1702,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_animate(&level, [1, 64, 1]);
@@ -1722,6 +1724,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_animate(&level, [1, 64, 1]);
@@ -1767,6 +1770,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let registry = Registry::read(&root, Default::default(), blocks);
let frame = registry.frame.clone();
@@ -1820,6 +1824,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let registry = Registry::read(&root, Default::default(), blocks);
let mut engine = engine(registry);
@@ -1853,6 +1858,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let rect = crate::hud::Rect {
@@ -1905,6 +1911,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let rect = crate::hud::Rect {
@@ -1966,6 +1973,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_entity(&level, "minecraft:player", [1.0, 70.0, 1.0]);
@@ -2014,6 +2022,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
let rect = crate::hud::Rect {
@@ -2080,6 +2089,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let registry = Arc::new(Registry::read(&root, Default::default(), blocks));
// The chunk's biome is id 0: plains, whose foliage is #336699.
@@ -2198,6 +2208,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
engine.polytone_entity(&level, "minecraft:player", [1.5, 70.0, 1.5]);
@@ -2276,6 +2287,7 @@ mod tests {
facts: &scene.facts,
collision: &scene.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = engine(Registry::read(&root, Default::default(), blocks));
// The test chunk's biome is the table's first: a cold one first, then a warm one.
@@ -93,6 +93,7 @@ fn particle_tick_cost() {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let ty = |name: &str| ParticleType::from_name(name).unwrap();
let (fx, fy, fz) = (0.5, f64::from(FLOOR_Y + 5), 20.5);
+27 -1
View File
@@ -192,7 +192,12 @@ impl SignData {
let side = |t: Option<&Tag>| -> SignText {
let mut out = SignText::default();
let Some(t) = t else { return out };
if let Some(Tag::List(messages)) = t.get("messages") {
if let Some(messages) = t.get("messages") {
// `SignText.CODEC` reads a side all or nothing: a messages
// field that is not a list leaves the side empty.
let Tag::List(messages) = messages else {
return SignText::default();
};
for (i, m) in messages.iter().take(4).enumerate() {
out.lines[i] = component_text(m);
out.click[i] = click_event(m);
@@ -307,6 +312,27 @@ pub fn editor_sprite(folder: &str, wood: &str) -> String {
#[cfg(test)]
mod tests {
use super::*;
/// `SignText.CODEC` reads messages as a list: a string field fails the
/// whole side, taking its colour and glow with it.
#[test]
fn messages_that_are_not_a_list_load_an_empty_side() {
let tag = Tag::compound([(
"front_text",
Tag::compound([
("messages", Tag::String("not a list".into())),
("color", Tag::String("red".into())),
("has_glowing_text", Tag::Byte(1)),
]),
)]);
let mut decoded = SignData::default();
decoded.decode(&tag);
assert_eq!(
decoded.front,
SignText::default(),
"a failed message list takes the side's colour and glow with it"
);
}
#[test]
fn waxed_and_glowing_text_take_any_number() {
let tag = Tag::compound([
+41 -2
View File
@@ -1238,6 +1238,9 @@ pub struct EditBox {
/// Grey completion shown after the text (EditBox.setSuggestion).
pub suggestion: Option<String>,
pub text_color: u32,
/// `EditBox.setCentered`: the text starts in the middle of the box's
/// width, not at its left edge (`BookSignScreen`'s title).
pub centered: bool,
/// `EditBox.textColorUneditable`: the colour of a box `setEditable(false)`
/// made read-only, which still shows its value and caret.
pub text_color_uneditable: u32,
@@ -1536,6 +1539,7 @@ impl EditBox {
focused_at: Instant::now(),
suggestion: None,
text_color: rgba(224, 224, 224, 255),
centered: false,
text_color_uneditable: rgba(112, 112, 112, 255),
editable: true,
message: String::new(),
@@ -1559,6 +1563,16 @@ impl EditBox {
self.y
}
}
/// `EditBox.updateTextPosition`: where the text starts, `(width - text
/// width) / 2` in for a centred box (vanilla's `textX`), else at
/// `text_x` (4 in from a border).
pub fn text_start_x(&self, text_width: f32) -> f32 {
if self.centered {
self.x + ((self.width - text_width) / 2.0).floor()
} else {
self.text_x()
}
}
pub fn reset_blink(&mut self) {
self.focused_at = Instant::now();
}
@@ -1641,8 +1655,13 @@ impl EditBox {
/// EditBox.findClickedPositionInText: the character boundary under GUI `x`.
pub fn position_at(&mut self, atlas: &HudAtlas, x: f32) -> usize {
self.layout(atlas);
let room = (x.floor() - self.text_x()).min(self.inner_width());
let rest: String = self.value.chars().skip(self.display).collect();
let shown: String = rest
.chars()
.take(atlas.font.fit(&rest, self.inner_width()))
.collect();
let start = self.text_start_x(atlas.font.width_text(&Text::literal(shown.as_str())));
let room = (x.floor() - start).min(self.inner_width());
self.display + atlas.font.fit(&rest, room)
}
/// EditBox.onClick: the cursor to the pointer, extending the selection
@@ -1849,7 +1868,12 @@ impl EditBox {
),
}
};
let (tx, ty) = (self.text_x(), self.text_y());
let start = if self.centered {
self.text_start_x(atlas.font.width_text(&Text::literal(displayed.as_str())))
} else {
self.text_x()
};
let (tx, ty) = (start, self.text_y());
let mut draw_x = tx;
if !displayed.is_empty() {
let half = if on_screen {
@@ -2650,4 +2674,19 @@ mod tests {
assert!(edit.key(winit::keyboard::KeyCode::Backspace, false, false));
assert_eq!(edit.value, "name");
}
/// `EditBox.setCentered` (`BookSignScreen`'s title box): with the flag the
/// text starts `(width - text width) / 2` in, which is also where a click
/// maps; without it the text starts at the box's left edge.
#[test]
fn a_centred_box_starts_its_text_in_the_middle() {
let mut edit = EditBox::new(10.0, 0.0, 114.0, 9.0);
edit.bordered = false;
assert_eq!(edit.text_start_x(0.0), 10.0);
edit.centered = true;
assert_eq!(edit.text_start_x(0.0), 67.0);
assert_eq!(edit.text_start_x(18.0), 58.0);
// (114 - 5) / 2 = 54.5, rounded down.
assert_eq!(edit.text_start_x(5.0), 64.0);
}
}
@@ -97,6 +97,7 @@ pub fn main() -> Result<()> {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let mut engine = ParticleEngine::new(&root, &Default::default(), &Default::default());
let mut out = BufWriter::new(File::create(&args[2])?);
@@ -194,6 +194,7 @@ pub fn main() -> Result<()> {
facts: &facts,
collision: &collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let v = |key: &str| -> [f64; 3] {
std::array::from_fn(|i| case[key][i].as_f64().unwrap_or(0.0))
+49 -4
View File
@@ -612,6 +612,7 @@ fn do_animate_tick(
facts: ctx.facts,
collision: ctx.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
engine.polytone_animate(&level, pos);
let facts = ctx.facts.get(state);
@@ -1994,8 +1995,10 @@ pub fn campfire_particles<'a>(
}
}
}
/// `CampfireBlock.makeParticles` (always visible, overriding the limiter);
/// `smoking` adds the plain smoke puff at the logs.
/// `CampfireBlock.makeParticles`: the cosy or signal smoke through
/// `addAlwaysVisibleParticle(.., true, ..)` (it overrides the limiter but still
/// rolls the Particles option); `smoking` adds the plain smoke puff at the logs
/// through `addParticle`, so the limiter can drop it.
pub fn make_campfire_smoke(
engine: &mut ParticleEngine,
pos: [i32; 3],
@@ -2014,7 +2017,13 @@ pub fn make_campfire_smoke(
} else {
T::CampfireCosySmoke
};
engine.create(&ParticleOptions::simple(ty), [x, y, z], [0.0, 0.07, 0.0]);
engine.add_with(
&ParticleOptions::simple(ty),
[x, y, z],
[0.0, 0.07, 0.0],
true,
true,
);
if smoking {
let r = engine.random();
let dx = r.next_double() / 4.0 * sign(r);
@@ -2024,13 +2033,49 @@ pub fn make_campfire_smoke(
f64::from(pos[1]) + 0.4,
f64::from(pos[2]) + 0.5 + dz,
];
engine.create(&ParticleOptions::simple(T::Smoke), at, [0.0, 0.005, 0.0]);
add(engine, T::Smoke, at, [0.0, 0.005, 0.0]);
}
}
#[cfg(test)]
mod tests {
use super::*;
/// `CampfireBlock.makeParticles` through `ClientLevel.doAddParticle`: the
/// cosy smoke overrides the limiter, the plain smoke does not, so it is
/// dropped beyond 32 blocks and at Particles: Minimal, and at Decreased
/// each puff is dropped when `calculateParticleLevel`'s `nextInt(3)` is 0.
#[test]
fn doused_campfire_smoke_goes_through_the_particle_limiter() {
use horizon_core::random::RandomSource as _;
use std::collections::{HashMap, HashSet};
let run = |level: u8, camera: [f64; 3]| {
let mut engine = ParticleEngine::new(Path::new(""), &HashMap::new(), &HashSet::new());
engine.seed_randoms(5, 9, 0);
engine.created_requests = Some(Vec::new());
engine.particle_level = level;
engine.camera = camera;
for _ in 0..20 {
make_campfire_smoke(&mut engine, [0, 64, 0], false, true);
}
let made = engine.created_requests.take().unwrap();
let count = |ty| made.iter().filter(|(o, ..)| o.ty == ty).count();
(count(T::CampfireCosySmoke), count(T::Smoke))
};
let near = [0.5, 65.0, 0.5];
assert_eq!(run(0, near), (20, 20), "All");
assert_eq!(run(2, near), (20, 0), "Minimal");
assert_eq!(run(0, [100.5, 65.0, 0.5]), (20, 0), "100 blocks away");
// Decreased: per puff the cosy smoke's roll, then the plain smoke's.
let mut level = LegacyRandom::new(9);
let kept = (0..20)
.filter(|_| {
level.next_int_bound(3).unwrap();
level.next_int_bound(3).unwrap() != 0
})
.count();
assert!(0 < kept && kept < 20);
assert_eq!(run(1, near), (20, kept), "Decreased");
}
#[test]
fn classifies_animated_blocks() {
let none = |_: &str| None;
+7 -7
View File
@@ -19,13 +19,13 @@ pub use horizon_client_render::{
hdr_calibration, hud, hud_kit, hud_layout, hud_overlays, inventory_effects, java_text,
jigsaw_block, json_folder, level_loading, light, lod, logging, menu_blur, mesher, minimap,
mipmap, mod_browser, mod_list, model_library, models, multiline_edit, native_emissive,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_palette, pack_screen,
packs, pad_cursor, pad_glyph, par, particles, poi_sections, polytone_colors,
polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit, providers, pvp,
pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask, server_terrain,
settings, shader_options, shader_pack_list, signs, skins, social, sound_engine, sound_events,
sound_preview, spans, split_layout, split_map, split_profiles, sprites, streaming,
structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
options_file, options_fixes, options_ui, overlay, pack_browser, pack_formats, pack_hooks,
pack_palette, pack_screen, packs, pad_cursor, pad_glyph, par, particles, poi_sections,
polytone_colors, polytone_creative_tabs, polytone_post, post_chain, potent_sulfur, preedit,
providers, pvp, pvp_hud, random_source, screen_draw, screen_effects, sculk, section_mask,
server_terrain, settings, shader_options, shader_pack_list, signs, skins, social, sound_engine,
sound_events, sound_preview, spans, split_layout, split_map, split_profiles, sprites,
streaming, structure_block, tab_list, terrain_model, test_block, test_instance_block, testgen,
title_edition, trims, ui, vault, weather, widgets, world, worldborder, zip,
};
pub mod attack_strength;
+3
View File
@@ -13,6 +13,9 @@ pub fn main_with(mods: crate::mod_api::Mods) {
log::warn!("Trait mods already installed; ignoring the second list");
}
horizon_threads::configure_allocator();
crate::horizon_look::give();
// OptiFine's and Polytone's pack formats (platform, docs/layers.md), before any pack loads.
horizon_client::pack_formats::install();
// horizon-extras (docs/forking.md): without the feature, no Horizon Settings or Mods page.
if !cfg!(feature = "horizon-extras") {
horizon_client::options_ui::HORIZON_EXTRAS
+2 -2
View File
@@ -2018,8 +2018,8 @@ pub struct PackResources {
clouds: Arc<(u32, u32, Vec<bool>)>,
/// misc/forcefield.png: (width, height, RGBA) for the world border wall.
forcefield: Arc<(u32, u32, Vec<u8>)>,
/// OptiFine custom sky layers (`optifine/sky`) with their textures; empty without them.
custom_sky: Arc<horizon_client::custom_sky::CustomSky>,
/// Custom sky layers (OptiFine's `optifine/sky`) with their textures; none without them.
custom_sky: Arc<dyn horizon_client::custom_sky::SkyLayers>,
/// The overworld's, Nether's and End's attribute stacks with the packs' Polytone biome and
/// dimension modifiers (#11486), when a pack has some: what the frame draws.
environments: Option<Arc<[Environment; 3]>>,
+15
View File
@@ -33,6 +33,8 @@ fn book_title_box() -> ui::EditBox {
edit.bordered = false;
edit.max_length = 15;
edit.text_color = BLACK;
// BookSignScreen sets the title box centred.
edit.centered = true;
edit.focused = true;
edit
}
@@ -1618,6 +1620,19 @@ pub(super) fn lectern_signal(world: &horizon_client::world::World, pos: BlockPos
}
}
#[cfg(test)]
mod title_box_tests {
use super::book_title_box;
/// `BookSignScreen` sets its 15 character title box centred and unbordered.
#[test]
fn the_sign_title_box_is_centred() {
let edit = book_title_box();
assert!(edit.centered);
assert!(!edit.bordered);
assert_eq!(edit.max_length, 15);
}
}
#[cfg(test)]
mod lectern_books_tag_tests {
use super::super::native_machines::tests::server_game;
@@ -227,16 +227,16 @@ impl Game<'_> {
);
}
for m in self.mobs.iter() {
if !m.alive() {
// A mob with no effects swirls nothing: skipped before its size
// is worked out, which every mob in a crowded world would pay
// for (#14053).
if !m.alive() || (m.extra.effect_particles.is_empty() && m.extra.effects.is_empty()) {
continue;
}
let size = (f64::from(m.width()), f64::from(m.height()));
let invisible = m.invisible || m.has_effect("minecraft:invisibility");
if m.extra.effect_particles.is_empty() {
// A mob of this game: its own effects, visible and not ambient.
if m.extra.effects.is_empty() {
continue;
}
let list: Vec<_> = m
.extra
.effects
@@ -92,6 +92,7 @@ fn a_mods_fireworks_burst_and_bang_on_the_client_only() {
facts: game.res.facts(),
collision: game.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
game.particles.tick(&level);
};
+36 -5
View File
@@ -471,15 +471,46 @@ impl Game<'_> {
continue;
}
self.cushions[i].check_interval = 0;
let c = self.cushions[i].clone();
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
self.break_cushion(i, false);
continue;
if !self.check_cushion(i) {
i += 1;
}
i += 1;
}
}
/// `Cushion.tickAtCheckInterval` plus the survive check; true when the cushion broke.
fn check_cushion(&mut self, i: usize) -> bool {
let c = self.cushions[i].clone();
let p = c.position;
let (x, y, z) = (p.x.floor() as i32, p.y.floor() as i32, p.z.floor() as i32);
// collidedWithFluid: the fluid box of the cushion's own block against its box.
if let Some((height, _)) =
self.fluid_height_of(x, y, z, Some(horizon_client::blocks::Fluid::Lava))
{
let fluid = Aabb::new(
Vec3::new(f64::from(x), f64::from(y), f64::from(z)),
Vec3::new(
f64::from(x) + 1.0,
f64::from(y) + f64::from(height),
f64::from(z) + 1.0,
),
);
if aabb(c.bounds())
.deflate(Vec3::new(1.0e-7, 1.0e-7, 1.0e-7))
.intersects(fluid)
{
// LAVA_IGNITE's lavaHurt deals 4 damage, which breaks the cushion.
self.break_cushion(i, false);
self.lava_hurt_sound([p.x, p.y, p.z]);
return true;
}
}
if self.cushion_in_fire(&c) || !self.cushion_survives(&c) {
self.break_cushion(i, false);
return true;
}
false
}
/// `LivingEntity.stopRiding` off a cushion: `dismountVehicle` beside it,
/// and `Cushion.removePassenger`'s get-up sound for a rider that left
/// rather than one whose cushion was killed.
+7 -7
View File
@@ -1,8 +1,8 @@
//! OptiFine custom skies (`horizon_client::custom_sky`): each frame, the layers of the world's sky
//! whose day, weather, biome and height hold at the camera, handed to the renderer, which draws
//! them over the vanilla sky. A pack without `optifine/sky` hands it none.
//! Custom skies (`horizon_client::custom_sky`, OptiFine's format): each frame, the layers of the
//! world's sky whose day, weather, biome and height hold at the camera, handed to the renderer,
//! which draws them over the vanilla sky. A pack without layers hands it none.
use super::*;
use horizon_optifine::sky::{Conditions, Now};
use horizon_client::custom_sky::{Conditions, Weather};
/// The layers' eased brightness between frames, and when the last frame was.
#[derive(Default)]
@@ -46,11 +46,11 @@ impl Game<'_> {
let mut state = std::mem::take(&mut self.custom_sky.state);
// Level.isThundering / isRaining.
let weather = if thunder > 0.9 && rain > 0.2 {
Now::Thunder
Weather::Thunder
} else if rain > 0.2 {
Now::Rain
Weather::Rain
} else {
Now::Clear
Weather::Clear
};
let conditions = Conditions {
day_time: self.view_day_time(),
@@ -152,6 +152,14 @@ impl WorldOptions {
}
self.port.value.parse::<u16>().ok().filter(|p| *p >= 1024)
}
/// `portEdit.setTooltip(PORT_INFO_TEXT)`: the port box explains itself while
/// the cursor is over it. The box is drawn even with LAN off, so the tooltip
/// shows there too; a remote world has no such box.
fn port_tooltip(port: &EditBox, cursor: (f32, f32), remote: bool) -> Option<Text> {
(!remote && ui::hovered(cursor, port.x, port.y, port.width, port.height))
.then(|| Text::translate("lanServer.port", &[]))
}
fn snapshot(&self) -> Snapshot {
Snapshot {
default_mode: self.default_mode,
@@ -1003,6 +1011,10 @@ impl Game<'_> {
hover,
);
}
// The widgets are drawn; the port box's tooltip goes over them.
if let Some(tip) = WorldOptions::port_tooltip(&o.port, cursor, o.remote) {
ui::tooltip(batch, atlas, &[tip], cursor);
}
}
/// The mouse wheel over the screen (ScrollableLayout, 10 pixels a step).
@@ -1899,4 +1911,15 @@ mod tests {
assert!(o.locked);
assert!(!o.active(Button::Difficulty));
}
/// `portEdit.setTooltip(PORT_INFO_TEXT)`: hovering the port box shows its
/// label; a remote world (`o.remote`) has no port box to hover.
#[test]
fn the_port_box_explains_itself_on_hover() {
let port = EditBox::new(158.0, 100.0, 40.0, 20.0);
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), false).is_some());
assert!(WorldOptions::port_tooltip(&port, (157.0, 101.0), false).is_none());
assert!(WorldOptions::port_tooltip(&port, (159.0, 121.0), false).is_none());
assert!(WorldOptions::port_tooltip(&port, (159.0, 101.0), true).is_none());
}
}
@@ -98,9 +98,8 @@ impl Loading {
.map_or([1280, 720], |o| o.window_size)
.map(f64::from);
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
let attributes = horizon_client::window::with_horizon_icon(
Window::default_attributes().with_title("Horizon"),
);
let attributes =
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"));
// Wayland names the window by its app id, which the horizon.desktop entry matches.
#[cfg(target_os = "linux")]
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
@@ -91,7 +91,7 @@ pub(super) fn build_renderer(
.prepare_glint_atlas(renderer.device.limits().max_texture_dimension_2d, filtering)?;
renderer.set_lines_shader(atlas.lines_shader.as_deref());
renderer.set_sky_shaders(atlas.sky_shader.as_deref(), atlas.stars_shader.as_deref());
renderer.set_custom_sky(&pack.custom_sky);
renderer.set_custom_sky(&*pack.custom_sky);
renderer.set_shared_material_maps(&atlas.material_maps);
let cit_glints = pack.item_renderer.cit.as_ref().map(|c| &c.glint_textures);
renderer
@@ -25,6 +25,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
self.particles.tick(&level);
}
@@ -93,10 +93,9 @@ impl ApplicationHandler for Missing {
if self.window.is_some() {
return;
}
let attributes = horizon_client::window::with_horizon_icon(
Window::default_attributes().with_title("Horizon"),
)
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
let attributes =
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"))
.with_inner_size(winit::dpi::LogicalSize::new(1040.0, 700.0));
let made = event_loop
.create_window(attributes)
.map_err(Into::into)
@@ -143,6 +143,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
find_noxious_gas_source_block(&level, [pos.0, pos.1, pos.2])
}
@@ -198,6 +199,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
// The gas reads blocks only, so the mobs it reaches can be found
// before any gets its effect.
@@ -264,6 +266,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
can_be_reached_by_noxious_gas(&level, source, eye)
};
@@ -356,6 +359,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
let height = unobstructed_block_count(&level, [pos.0, pos.1 + 1, pos.2], water_blocks);
let area = launch_area(pos, height);
@@ -419,6 +423,7 @@ impl Game<'_> {
facts: self.res.facts(),
collision: self.collision,
player: None,
cached_chunk: std::cell::Cell::new(None),
};
unobstructed_block_count(&level, [pos.0, pos.1 + 1, pos.2], water_blocks)
};
+15 -294
View File
@@ -60,6 +60,9 @@ use std::{
};
use winit::{event::MouseButton, keyboard::KeyCode};
#[path = "remote_poll.rs"]
mod poll;
type BuiltinCache = Vec<(std::path::PathBuf, Arc<Builtin>)>;
static BUILTIN: std::sync::Mutex<BuiltinCache> = std::sync::Mutex::new(Vec::new());
@@ -2174,288 +2177,6 @@ impl Game<'_> {
pub(super) fn remote_backlog(&self) -> usize {
self.remote.as_ref().map_or(0, |r| r.backlog.len())
}
/// A headless command oracle has a real connection but no movement tick.
/// Acknowledge loaded terrain without predicting or sending movement.
pub(super) fn poll_stationary_client(&mut self) -> Result<()> {
self.poll_remote()?;
let loaded = self.player.as_ref().is_some_and(|p| {
self.streaming.is_lit(
(p.position.x.floor() as i32) >> 4,
(p.position.z.floor() as i32) >> 4,
)
});
if loaded && self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
self.send(S::PlayerLoaded(Empty));
let r = self.remote.as_mut().unwrap();
r.loaded_sent = true;
r.level_load.finish();
}
Ok(())
}
/// Process what the network thread received (once per frame, like the
/// client's packet processor), in order. Chunk packets get
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
/// the next chunk packet on wait for the next frame, unless they have
/// already waited `BACKLOG_LIMIT`. A chunk packet still decoding holds
/// back what follows it.
pub(super) fn poll_remote(&mut self) -> Result<()> {
let polled = self.poll_remote_events();
// The block events and block changes of a piston clock start and end
// thousands of moves at once: the collision view and the renderer
// take them all in one sync, before the tick or frame reads them.
if std::mem::take(&mut self.moving_pistons_stale) {
self.sync_moving_pistons();
}
polled
}
fn poll_remote_events(&mut self) -> Result<()> {
let Some(r) = self.remote.as_mut() else {
return Ok(());
};
let mut events = std::mem::take(&mut r.backlog);
// Chunk packets start decoding now; the frame that reaches one in
// order inserts its column.
for event in r.session.poll() {
events.push_back(match event {
Event::Packet(p) if matches!(*p, C::LevelChunkWithLight(_)) => {
match r.decoder.submit(r.chunk_format.clone(), p) {
None => Received::Chunk(r.chunk_format.clone()),
Some(p) => Received::Event(Event::Packet(p)),
}
}
event => Received::Event(event),
});
}
let overdue = r
.backlog_since
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
let mut chunks = Duration::ZERO;
// A run of ForgetLevelChunk (thousands when the server's view moves far: a
// teleport at 32 chunks) is forgotten at once, before whatever follows it.
let mut forgotten = horizon_client::fast_hash::FxHashSet::default();
while let Some(item) = events.pop_front() {
if let Received::Event(Event::Packet(packet)) = &item
&& let C::ForgetLevelChunk(p) = &**packet
{
self.native_client_packet(packet);
forgotten.insert((p.pos[0], p.pos[1]));
continue;
}
self.forget_remote_columns(&mut forgotten);
let chunk = matches!(item, Received::Chunk(_));
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
events.push_front(item);
break;
}
let started = chunk.then(Instant::now);
let event = match item {
Received::Event(event) => event,
Received::Chunk(format) => {
// A column still decoding waits for the next frame, with
// what came after it, overdue or not.
let Some(next) = self.remote.as_ref().map(|r| r.decoder.next()) else {
break;
};
let (packet, column) = match next {
Next::Ready(decoded) => decoded,
Next::Decoding => {
events.push_front(Received::Chunk(format));
break;
}
Next::Lost => {
log::warn!("Horizon: the chunk decoder stopped; a column is missing");
continue;
}
};
self.native_client_packet(&packet);
if let C::LevelChunkWithLight(p) = *packet {
self.remote_chunk(&p, Some((&format, column)));
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
continue;
}
};
match event {
Event::Status(s) => {
if let Some(r) = self.remote.as_mut() {
r.status = s;
}
}
Event::Joined {
registries,
name,
uuid,
} => {
self.set_registries(registries);
self.extras_joined();
// handleLogin: the level loading screen replaces any dialog.
self.close_dialogs();
if let Some(r) = self.remote.as_mut() {
r.joined = true;
// handleLogin: startWaitingForNewLevel with Reason.OTHER.
r.profile = uuid;
r.profile_name = name.clone();
// LevelLoadingScreen's line: no player name.
r.status = horizon_client::lang::get_or(
"multiplayer.downloadingTerrain",
"Loading terrain...",
);
}
}
Event::Features(features) => {
if let Some(remote) = &mut self.remote {
remote.features = features;
}
}
Event::Brand(brand) => {
if let Some(remote) = &mut self.remote {
remote.brand = brand;
}
}
Event::QuietCommands => {
if let Some(remote) = &mut self.remote {
remote.quiet_commands = true;
}
}
Event::ModNet => {
if let Some(remote) = &mut self.remote {
remote.mod_net = true;
}
}
Event::FarTerrain(offer) => {
if let Some(remote) = &mut self.remote {
remote.far_terrain = Some(offer);
}
}
Event::Reconfiguring => {
// handleConfigurationStart: the held-back chat shows, then
// clearClientLevel opens ServerReconfigScreen over no level.
self.flush_chat_queue();
self.send_chat_acknowledgement();
self.close_dialogs();
// The server configuring us now (a proxy's next one)
// offers its own far terrain, or none, and announces its
// own features again (a Horizon server's quiet commands).
if let Some(remote) = &mut self.remote {
remote.far_terrain = None;
remote.quiet_commands = false;
remote.mod_net = false;
}
self.clear_lod();
if let Some(remote) = &self.remote {
remote.session.reset_far_terrain();
}
self.player = None;
self.screen = Screen::Playing;
self.capture(false);
// clearClientLevel drops the client level now, so its
// unload comes before the configuration screen, not
// after the next login's load.
if let Some(old) = self.remote.as_mut().and_then(|r| r.heard_level.take()) {
self.native_client_level_unload(&old);
}
let ticks = self.ticks;
if let Some(r) = self.remote.as_mut() {
r.reconfiguring = Some(ticks);
r.chat_tree_received = false;
}
}
Event::Reconfigured(registries) => {
// handleConfigurationStart → clearClientLevel, and the next
// ClientPacketListener starts with no players or scoreboard,
// no waypoints (its own ClientWaypointManager: a proxy's next
// server never untracks the last one's), and its new
// ClientSuggestionProvider with no custom chat completions.
// clearClientLevel clears the narrator.
self.clear_narrator();
self.hud_disconnected();
self.score.board = Default::default();
self.clear_waypoints();
if let Some(r) = self.remote.as_mut() {
r.attachments.clear();
r.players.clear();
r.tab_players.clear();
r.listed.clear();
r.signatures = Default::default();
r.custom_completions.clear();
}
self.ui.registries.custom_completions.clear();
self.set_registries(registries);
}
// handleTransfer: "Cannot transfer to server from
// singleplayer" (no ServerData), so the world just closes.
Event::Transfer { host, port } => {
if let Some(r) = self.remote.as_mut()
&& r.session.address != "local"
{
r.transfer = Some(format!("{host}:{port}"));
}
}
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::ServerLinks(links) => {
if let Some(r) = self.remote.as_mut() {
r.server_links = super::server_links::trusted(links);
}
}
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
Event::ClearDialog => self.clear_dialog(),
Event::CodeOfConduct(text) => self.handle_code_of_conduct(text),
Event::DecodeError(e) => {
if let Some(r) = self.remote.as_mut() {
r.decode_errors += 1;
if r.decode_errors <= 20 {
log::warn!("Horizon: skipped undecodable packet {e}");
}
}
}
Event::Disconnected {
reason,
key,
from_server,
} => {
log::info!("Horizon: disconnected: {reason}");
// ClientboundTransferPacket left a target: connect there
// instead of showing the disconnect, the stored cookies
// carried over (ClientCommonPacketListenerImpl.
// handleTransfer → ConnectScreen.startConnecting).
let transfer = self.remote.as_mut().and_then(|r| {
r.transfer.take().map(|address| {
(address, r.session.name.clone(), r.session.cookies.clone())
})
});
if let Some((address, name, cookies)) = transfer {
self.leave_server(None)?;
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
return Ok(());
}
let retry = self.reconnect_after(&reason, key.as_deref(), from_server);
self.leave_server(Some(reason))?;
self.offer_reconnect(retry);
return Ok(());
}
Event::Packet(packet) => self.handle_packet(*packet)?,
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
if self.remote.is_none() {
break;
}
}
if self.remote.is_some() {
self.forget_remote_columns(&mut forgotten);
}
if let Some(r) = self.remote.as_mut() {
r.backlog_since = if events.is_empty() {
None
} else {
Some(r.backlog_since.unwrap_or_else(Instant::now))
};
r.backlog = events;
}
self.send_suggestion_request();
Ok(())
}
/// ClientSuggestionProvider.customSuggestion: the chat's completion
/// request for an `ask_server` node goes to the server.
fn send_suggestion_request(&mut self) {
@@ -7083,24 +6804,24 @@ impl Game<'_> {
let mut push = Vec3::ZERO;
let order = |id: u32| r.ticked_before_player.contains(&id) == before_player;
for m in self.mobs.iter().filter(|m| order(m.id)) {
let listed = r
.level_players
.get(&(m.id as i32))
.and_then(|uuid| r.tab_players.get(uuid));
let spectator = listed.is_some_and(|t| t.game_mode == SPECTATOR);
let mob_team = match listed {
Some(t) => board.players_team(&t.name),
None => teams.of(Who::Mob(m.id)),
};
// Bats never push; a spectator has no physics; passengers of the
// same vehicle never push each other.
// Bats never push, nor passengers of one vehicle, nor a spectator; the
// box test comes before the lookups that far mobs never need (#14053).
if !m.alive()
|| spectator
|| m.species() == horizon_client::mobs::Species::Bat
|| self
.riding
.is_some_and(|v| v == m.id || m.extra.mount == Some(v))
|| !m.bounds().intersects(bounds)
{
continue;
}
let listed = r
.level_players
.get(&(m.id as i32))
.and_then(|uuid| r.tab_players.get(uuid));
let mob_team =
listed.map_or_else(|| teams.of(Who::Mob(m.id)), |t| board.players_team(&t.name));
if listed.is_some_and(|t| t.game_mode == SPECTATOR)
|| !horizon_client::scoreboard::pushable_by(mob_team, player_team, true)
{
continue;
@@ -0,0 +1,288 @@
//! The server connection's received events, once a frame (`poll_remote`): chunk packets
//! within their per-frame budget, then every other packet in order (`handle_packet`).
use super::*;
impl Game<'_> {
/// A headless command oracle has a real connection but no movement tick.
/// Acknowledge loaded terrain without predicting or sending movement.
pub(in crate::game) fn poll_stationary_client(&mut self) -> Result<()> {
self.poll_remote()?;
let loaded = self.player.as_ref().is_some_and(|p| {
self.streaming.is_lit(
(p.position.x.floor() as i32) >> 4,
(p.position.z.floor() as i32) >> 4,
)
});
if loaded && self.remote.as_ref().is_some_and(|r| !r.loaded_sent) {
self.send(S::PlayerLoaded(Empty));
let r = self.remote.as_mut().unwrap();
r.loaded_sent = true;
r.level_load.finish();
}
Ok(())
}
/// Process what the network thread received (once per frame, like the
/// client's packet processor), in order. Chunk packets get
/// `CHUNK_PACKETS_PER_FRAME`: once a frame has spent it, the events from
/// the next chunk packet on wait for the next frame, unless they have
/// already waited `BACKLOG_LIMIT`. A chunk packet still decoding holds
/// back what follows it.
pub(in crate::game) fn poll_remote(&mut self) -> Result<()> {
let polled = self.poll_remote_events();
// The block events and block changes of a piston clock start and end
// thousands of moves at once: the collision view and the renderer
// take them all in one sync, before the tick or frame reads them.
if std::mem::take(&mut self.moving_pistons_stale) {
self.sync_moving_pistons();
}
polled
}
fn poll_remote_events(&mut self) -> Result<()> {
let Some(r) = self.remote.as_mut() else {
return Ok(());
};
let mut events = std::mem::take(&mut r.backlog);
// Chunk packets start decoding now; the frame that reaches one in
// order inserts its column.
for event in r.session.poll() {
events.push_back(match event {
Event::Packet(p) if matches!(*p, C::LevelChunkWithLight(_)) => {
match r.decoder.submit(r.chunk_format.clone(), p) {
None => Received::Chunk(r.chunk_format.clone()),
Some(p) => Received::Event(Event::Packet(p)),
}
}
event => Received::Event(event),
});
}
let overdue = r
.backlog_since
.is_some_and(|since| since.elapsed() >= BACKLOG_LIMIT);
let mut chunks = Duration::ZERO;
// A run of ForgetLevelChunk (thousands when the server's view moves far: a
// teleport at 32 chunks) is forgotten at once, before whatever follows it.
let mut forgotten = horizon_client::fast_hash::FxHashSet::default();
while let Some(item) = events.pop_front() {
if let Received::Event(Event::Packet(packet)) = &item
&& let C::ForgetLevelChunk(p) = &**packet
{
self.native_client_packet(packet);
forgotten.insert((p.pos[0], p.pos[1]));
continue;
}
self.forget_remote_columns(&mut forgotten);
let chunk = matches!(item, Received::Chunk(_));
if chunk && !overdue && chunks >= CHUNK_PACKETS_PER_FRAME {
events.push_front(item);
break;
}
let started = chunk.then(Instant::now);
let event = match item {
Received::Event(event) => event,
Received::Chunk(format) => {
// A column still decoding waits for the next frame, with
// what came after it, overdue or not.
let Some(next) = self.remote.as_ref().map(|r| r.decoder.next()) else {
break;
};
let (packet, column) = match next {
Next::Ready(decoded) => decoded,
Next::Decoding => {
events.push_front(Received::Chunk(format));
break;
}
Next::Lost => {
log::warn!("Horizon: the chunk decoder stopped; a column is missing");
continue;
}
};
self.native_client_packet(&packet);
if let C::LevelChunkWithLight(p) = *packet {
self.remote_chunk(&p, Some((&format, column)));
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
continue;
}
};
match event {
Event::Status(s) => {
if let Some(r) = self.remote.as_mut() {
r.status = s;
}
}
Event::Joined {
registries,
name,
uuid,
} => {
self.set_registries(registries);
self.extras_joined();
// handleLogin: the level loading screen replaces any dialog.
self.close_dialogs();
if let Some(r) = self.remote.as_mut() {
r.joined = true;
// handleLogin: startWaitingForNewLevel with Reason.OTHER.
r.profile = uuid;
r.profile_name = name.clone();
// LevelLoadingScreen's line: no player name.
r.status = horizon_client::lang::get_or(
"multiplayer.downloadingTerrain",
"Loading terrain...",
);
}
}
Event::Features(features) => {
if let Some(remote) = &mut self.remote {
remote.features = features;
}
}
Event::Brand(brand) => {
if let Some(remote) = &mut self.remote {
remote.brand = brand;
}
}
Event::QuietCommands => {
if let Some(remote) = &mut self.remote {
remote.quiet_commands = true;
}
}
Event::ModNet => {
if let Some(remote) = &mut self.remote {
remote.mod_net = true;
}
}
Event::FarTerrain(offer) => {
if let Some(remote) = &mut self.remote {
remote.far_terrain = Some(offer);
}
}
Event::Reconfiguring => {
// handleConfigurationStart: the held-back chat shows, then
// clearClientLevel opens ServerReconfigScreen over no level.
self.flush_chat_queue();
self.send_chat_acknowledgement();
self.close_dialogs();
// The server configuring us now (a proxy's next one)
// offers its own far terrain, or none, and announces its
// own features again (a Horizon server's quiet commands).
if let Some(remote) = &mut self.remote {
remote.far_terrain = None;
remote.quiet_commands = false;
remote.mod_net = false;
}
self.clear_lod();
if let Some(remote) = &self.remote {
remote.session.reset_far_terrain();
}
self.player = None;
self.screen = Screen::Playing;
self.capture(false);
// clearClientLevel drops the client level now, so its
// unload comes before the configuration screen, not
// after the next login's load.
if let Some(old) = self.remote.as_mut().and_then(|r| r.heard_level.take()) {
self.native_client_level_unload(&old);
}
let ticks = self.ticks;
if let Some(r) = self.remote.as_mut() {
r.reconfiguring = Some(ticks);
r.chat_tree_received = false;
}
}
Event::Reconfigured(registries) => {
// handleConfigurationStart → clearClientLevel, and the next
// ClientPacketListener starts with no players or scoreboard,
// no waypoints (its own ClientWaypointManager: a proxy's next
// server never untracks the last one's), and its new
// ClientSuggestionProvider with no custom chat completions.
// clearClientLevel clears the narrator.
self.clear_narrator();
self.hud_disconnected();
self.score.board = Default::default();
self.clear_waypoints();
if let Some(r) = self.remote.as_mut() {
r.attachments.clear();
r.players.clear();
r.tab_players.clear();
r.listed.clear();
r.signatures = Default::default();
r.custom_completions.clear();
}
self.ui.registries.custom_completions.clear();
self.set_registries(registries);
}
// handleTransfer: "Cannot transfer to server from
// singleplayer" (no ServerData), so the world just closes.
Event::Transfer { host, port } => {
if let Some(r) = self.remote.as_mut()
&& r.session.address != "local"
{
r.transfer = Some(format!("{host}:{port}"));
}
}
Event::PackPush(p) => self.handle_pack_push(p),
Event::PackPop(id) => self.handle_pack_pop(id),
Event::ServerLinks(links) => {
if let Some(r) = self.remote.as_mut() {
r.server_links = super::super::server_links::trusted(links);
}
}
Event::ShowDialog(dialog) => self.show_dialog(&dialog),
Event::ClearDialog => self.clear_dialog(),
Event::CodeOfConduct(text) => self.handle_code_of_conduct(text),
Event::DecodeError(e) => {
if let Some(r) = self.remote.as_mut() {
r.decode_errors += 1;
if r.decode_errors <= 20 {
log::warn!("Horizon: skipped undecodable packet {e}");
}
}
}
Event::Disconnected {
reason,
key,
from_server,
} => {
log::info!("Horizon: disconnected: {reason}");
// ClientboundTransferPacket left a target: connect there
// instead of showing the disconnect, the stored cookies
// carried over (ClientCommonPacketListenerImpl.
// handleTransfer → ConnectScreen.startConnecting).
let transfer = self.remote.as_mut().and_then(|r| {
r.transfer.take().map(|address| {
(address, r.session.name.clone(), r.session.cookies.clone())
})
});
if let Some((address, name, cookies)) = transfer {
self.leave_server(None)?;
self.connect_server_with_cookies(&address, &name, Some(cookies))?;
return Ok(());
}
let retry = self.reconnect_after(&reason, key.as_deref(), from_server);
self.leave_server(Some(reason))?;
self.offer_reconnect(retry);
return Ok(());
}
Event::Packet(packet) => self.handle_packet(*packet)?,
}
chunks += started.map_or(Duration::ZERO, |t| t.elapsed());
if self.remote.is_none() {
break;
}
}
if self.remote.is_some() {
self.forget_remote_columns(&mut forgotten);
}
if let Some(r) = self.remote.as_mut() {
r.backlog_since = if events.is_empty() {
None
} else {
Some(r.backlog_since.unwrap_or_else(Instant::now))
};
r.backlog = events;
}
self.send_suggestion_request();
Ok(())
}
}
@@ -679,6 +679,7 @@ impl Game<'_> {
.player
.as_ref()
.map(|p| ([p.position.x, p.position.y, p.position.z], p.velocity.y)),
cached_chunk: std::cell::Cell::new(None),
};
laps.lap("tp_level");
// Resource packs' Polytone entity emitters on the player (#11486): fog, dust and
@@ -190,7 +190,7 @@ impl PackResources {
let clouds = named_scope(scope, "pack.clouds", || load_clouds(root));
let forcefield = named_scope(scope, "pack.forcefield", || load_forcefield(root));
let custom_sky = named_scope(scope, "pack.custom_sky", || {
horizon_client::custom_sky::CustomSky::load(root)
horizon_client::custom_sky::load(root)
});
let environments =
named_scope(scope, "pack.environments", || pack_environments(root));
@@ -246,7 +246,7 @@ impl PackResources {
names: Arc::new(names),
clouds: Arc::new(clouds),
forcefield: Arc::new(forcefield),
custom_sky: Arc::new(custom_sky),
custom_sky,
environments,
polytone_particles,
polytone_post,
+5
View File
@@ -24,6 +24,8 @@ pub(super) struct Social {
names: HashMap<u128, String>,
page: Page,
pub(super) search: EditBox,
/// `PlayerEntry.TOOLTIP_DELAY`: the row buttons' tooltips wait 500 ms.
tooltip: social_screen::TooltipDelay,
pub(super) scroll: f32,
/// Opened from the pause menu: Done goes back there.
from_pause: bool,
@@ -38,6 +40,7 @@ impl Default for Social {
names: HashMap::new(),
page: Page::All,
search,
tooltip: Default::default(),
scroll: 0.0,
from_pause: false,
}
@@ -77,6 +80,7 @@ impl Game<'_> {
self.hide_social_tutorial();
self.social.page = Page::All;
self.social.search.set_value("");
self.social.tooltip = Default::default();
self.social.scroll = 0.0;
self.social.from_pause = from_pause;
self.screen = Screen::Social;
@@ -168,6 +172,7 @@ impl Game<'_> {
self.cursor,
&view,
&mut self.social.search,
&mut self.social.tooltip,
true,
);
}
@@ -516,10 +516,9 @@ impl<'a> Split<'a> {
continue;
}
// Opened without the focus: whoever types in the game's window keeps the keyboard.
let attributes =
horizon_client::window::with_horizon_icon(Window::default_attributes())
.with_active(false)
.with_visible(window_shown(true, self.offscreen));
let attributes = horizon_client::window::with_icon(Window::default_attributes())
.with_active(false)
.with_visible(window_shown(true, self.offscreen));
// A monitor's window covers it, borderless. On macOS not the system's fullscreen, a
// Space of its own that hides the other windows as it slides in.
let placement = self.placement(*key).cloned();
@@ -273,9 +273,8 @@ impl ApplicationHandler for Game<'_> {
let fullscreen = self.settings.fullscreen || self.options.fullscreen;
let size = self.options.window_size.map(f64::from);
self.fullscreen = horizon_client::window::FullscreenState::new(fullscreen);
let attributes = horizon_client::window::with_horizon_icon(
Window::default_attributes().with_title("Horizon"),
);
let attributes =
horizon_client::window::with_icon(Window::default_attributes().with_title("Horizon"));
// Wayland names the window by its app id, which the horizon.desktop entry matches.
#[cfg(target_os = "linux")]
let attributes = winit::platform::wayland::WindowAttributesExtWayland::with_name(
@@ -6,6 +6,7 @@ use super::{Game, commands::Target};
use horizon_client::{
advancements as adv,
gossip::GossipType,
items::ItemStack,
lan::playerdata,
mobs::{Mob, Species, villager, zombie_villager},
};
@@ -143,7 +144,10 @@ impl Game<'_> {
let Some(v) = self.mobs.iter_mut().find(|m| m.id == vid) else {
return;
};
convert_to_villager(&old, v);
let drops = convert_to_villager(&old, v);
for stack in drops {
self.spawn_at_location(old.position, 0.0, stack);
}
// `level.getPlayerByUUID(conversionStarter)`: the player who fed the
// apple, if still in this level (the host or a server's guest).
let starter = zombie_villager::cure(&old).and_then(|c| c.starter);
@@ -165,6 +169,10 @@ impl Game<'_> {
g.trigger_cured_zombie_villager(&zombie_view, &villager_view)
});
}
// The nausea comes after the advancement criterion (finishConversion).
if let Some(v) = self.mobs.iter_mut().find(|m| m.id == vid) {
v.add_effect("minecraft:nausea", 200, 0);
}
if !old.silent {
self.level_event_at(
1027,
@@ -278,11 +286,38 @@ pub(super) fn convert_to_zombie_villager(from: &Mob, to: &mut Mob) {
carry_over(from, to);
to.extra.age = if from.is_baby() { i32::MIN / 2 } else { 0 };
}
/// A cured zombie villager becomes a villager again, queasy for 10 seconds.
pub(super) fn convert_to_villager(from: &Mob, to: &mut Mob) {
/// A cured zombie villager becomes a villager again (the caller adds the
/// 10 second nausea after the cure criterion).
/// `convertTo(.., keepEquipment = false)` leaves the villager bare, then
/// `ZombieVillager.finishConversion` runs `Mob.dropPreservedEquipment` with
/// "no `PREVENT_ARMOR_CHANGE`": kept (cursed) stacks go to the villager's
/// slot `EquipmentSlot.getIndex() + 300` (its inventory), picked-up gear
/// (drop chance > 1) is returned for the caller to drop, the rest vanishes.
pub(super) fn convert_to_villager(from: &Mob, to: &mut Mob) -> Vec<ItemStack> {
use horizon_client::mobs::{drops, finalize};
carry_over(from, to);
let mut drops_out = Vec::new();
// `EquipmentSlot.VALUES` order: main hand, off hand, feet, legs, chest, head.
for (slot, name) in drops::SLOTS.iter().take(6).enumerate() {
let Some(stack) = finalize::item_stack(from, slot) else {
continue;
};
drops::clear_slot(to, name);
if stack.enchantment_level("minecraft:binding_curse") > 0 {
// `getIndex() + 300`: hands 0..1, then feet, legs, chest, head 0..3.
let index = if slot < 2 { slot } else { slot - 2 };
let inventory = &mut villager::ext_mut(to).inventory;
if index < inventory.len() {
inventory[index] = stack;
} else {
inventory.push(stack);
}
} else if drops::drop_chance(from, name) > 1.0 {
drops_out.push(stack);
}
}
to.extra.age = if from.is_baby() { -24000 } else { 0 };
to.add_effect("minecraft:nausea", 200, 0);
drops_out
}
#[cfg(test)]
@@ -384,11 +419,34 @@ mod tests {
assert!(zombie.invulnerable_flag);
assert!(zombie.extra.left_handed);
let mut cured = mob(3, Species::Villager, "minecraft:villager");
convert_to_villager(&zombie, &mut cured);
let drops = convert_to_villager(&zombie, &mut cured);
assert!(drops.is_empty());
assert!(cured.silent);
assert!(cured.no_ai);
assert!(cured.no_gravity);
assert!(cured.invulnerable_flag);
assert!(cured.extra.left_handed);
// The cure keeps no equipment: picked-up gear drops, cursed gear goes
// to the inventory, the rest vanishes.
use horizon_client::mobs::finalize::{
FEET, HEAD, MAINHAND, item_stack, set_drop_chance, set_item, set_item_stack,
};
let mut gear = mob(4, Species::ZombieVillager, "minecraft:zombie_villager");
set_item(&mut gear, MAINHAND, "minecraft:iron_sword");
set_drop_chance(&mut gear, MAINHAND, 2.0);
set_item(&mut gear, HEAD, "minecraft:iron_helmet");
let mut boots = ItemStack::new("minecraft:iron_boots", 1);
boots.set_crafting_enchantments(vec![("minecraft:binding_curse".into(), 1)]);
set_item_stack(&mut gear, FEET, boots);
let mut bare = mob(5, Species::Villager, "minecraft:villager");
let drops = convert_to_villager(&gear, &mut bare);
assert_eq!(drops.len(), 1);
assert_eq!(drops[0].item.as_ref(), "minecraft:iron_sword");
for slot in 0..=5 {
assert!(item_stack(&bare, slot).is_none(), "slot {slot}");
}
let kept = &villager::ext(&bare).unwrap().inventory;
assert_eq!(kept.len(), 1);
assert_eq!(kept[0].item.as_ref(), "minecraft:iron_boots");
}
}
@@ -0,0 +1,8 @@
//! Horizon's look, given to the engine's extension points at startup (docs/layers.md): the
//! windows' icon. Its own module so it can leave the engine crate as a pure file move, for the
//! official executable to call.
/// Give the engine Horizon's look; call it once, before the first window opens.
pub(crate) fn give() {
horizon_client::window::set_icon_png(horizon_theme::ICON_PNG);
}
+1
View File
@@ -6,6 +6,7 @@ extern crate self as horizon_client;
pub use horizon_client_lib::*;
pub mod entry;
pub mod game;
mod horizon_look;
pub mod mod_api;
#[cfg(test)]
+94 -14
View File
@@ -9,6 +9,7 @@ use crate::{
text::Text,
ui,
};
use std::time::{Duration, Instant};
pub const TITLE: &str = "gui.socialInteractions.title";
/// `SocialInteractionsScreen`'s page.
@@ -185,8 +186,35 @@ pub fn click(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<
}
None
}
/// The tooltip of what the cursor is over.
fn tooltip(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<Text> {
/// `PlayerEntry.TOOLTIP_DELAY`: a row button's tooltip shows once the cursor
/// has rested on that same button for half a second.
pub const TOOLTIP_DELAY: Duration = Duration::from_millis(500);
/// The row button whose tooltip the cursor is over.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Tip {
Report(u128),
Toggle { id: u128, hidden: bool },
}
impl Tip {
fn text(self) -> Text {
match self {
Tip::Report(_) => {
Text::translate("gui.socialInteractions.tooltip.report.disabled", &[])
}
Tip::Toggle { hidden: true, .. } => {
Text::translate("gui.socialInteractions.tooltip.show", &[])
}
Tip::Toggle { hidden: false, .. } => {
Text::translate("gui.socialInteractions.tooltip.hide", &[])
}
}
}
}
/// The tooltip of what the cursor is over: the row button it names.
fn tip_at(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<Tip> {
let (w, h) = size;
if cursor.1 < LIST_START || cursor.1 >= list_end(h) {
return None;
@@ -198,28 +226,49 @@ fn tooltip(size: (f32, f32), cursor: (f32, f32), view: &SocialView) -> Option<Te
}
let (hide, report) = row_buttons(w, y);
if ui::hovered(cursor, hide[0], hide[1], 20.0, 20.0) {
let key = if e.hidden {
"gui.socialInteractions.tooltip.show"
} else {
"gui.socialInteractions.tooltip.hide"
};
return Some(Text::translate(key, &[]));
return Some(Tip::Toggle {
id: e.id,
hidden: e.hidden,
});
}
if ui::hovered(cursor, report[0], report[1], 20.0, 20.0) {
return Some(Text::translate(
"gui.socialInteractions.tooltip.report.disabled",
&[],
));
return Some(Tip::Report(e.id));
}
}
None
}
/// `WidgetTooltipHolder`'s delay: the tooltip of the control the cursor has
/// rested on for `TOOLTIP_DELAY`, and nothing while it moves between them.
#[derive(Clone, Copy, Debug, Default)]
pub struct TooltipDelay {
hovered: Option<(Tip, Instant)>,
}
impl TooltipDelay {
/// The tip to draw at `now`, starting the wait when the cursor reaches a
/// new button (vanilla's `displayStartTime`).
pub fn shown(&mut self, hovered: Option<Tip>, now: Instant) -> Option<Tip> {
let Some(tip) = hovered else {
self.hovered = None;
return None;
};
match self.hovered {
Some((t, at)) if t == tip => (now.duration_since(at) > TOOLTIP_DELAY).then_some(tip),
_ => {
self.hovered = Some((tip, now));
None
}
}
}
}
pub fn draw(
batch: &mut HudBatch,
atlas: &HudAtlas,
cursor: (f32, f32),
view: &SocialView,
search: &mut ui::EditBox,
tooltip: &mut TooltipDelay,
in_world: bool,
) {
let (w, h) = (batch.width, batch.height);
@@ -419,8 +468,8 @@ pub fn draw(
);
}
}
if let Some(text) = tooltip((w, h), cursor, view) {
ui::tooltip(batch, atlas, &[text], cursor);
if let Some(tip) = tooltip.shown(tip_at((w, h), cursor, view), Instant::now()) {
ui::tooltip(batch, atlas, &[tip.text()], cursor);
}
}
@@ -517,4 +566,35 @@ mod tests {
let names: Vec<_> = rows.iter().map(|e| e.name.as_str()).collect();
assert_eq!(names, ["me", "_x", "Alpha", "zed", "§odd"]);
}
/// `PlayerEntry.TOOLTIP_DELAY` (500 ms): the row buttons' tooltips wait
/// while container item tooltips do not. Moving to another button, or
/// away and back, starts the wait again.
#[test]
fn a_row_buttons_tooltip_waits_half_a_second() {
let start = Instant::now();
let at = |ms| start + Duration::from_millis(ms);
let mut delay = TooltipDelay::default();
let tip = Tip::Toggle {
id: 7,
hidden: false,
};
assert_eq!(delay.shown(Some(tip), at(0)), None);
assert_eq!(delay.shown(Some(tip), at(499)), None);
assert_eq!(delay.shown(Some(tip), at(500)), None, "500 ms is not > 500");
assert_eq!(delay.shown(Some(tip), at(501)), Some(tip));
// Another row button is another widget: its own wait.
let other = Tip::Toggle {
id: 8,
hidden: true,
};
assert_eq!(delay.shown(Some(other), at(502)), None);
assert_eq!(delay.shown(Some(other), at(1002)), None);
assert_eq!(delay.shown(Some(other), at(1003)), Some(other));
// Leaving hides it at once; coming back starts over.
assert_eq!(delay.shown(None, at(1004)), None);
assert_eq!(delay.shown(Some(tip), at(1005)), None);
assert_eq!(delay.shown(Some(tip), at(1505)), None);
assert_eq!(delay.shown(Some(tip), at(1506)), Some(tip));
}
}
+5 -106
View File
@@ -2,10 +2,11 @@
//! `optifine/sky/world<N>/sky<M>.properties` (`world0` the Overworld, `world1` the End; the Nether
//! draws no sky), and the `sun` and `moon_phases` textures that replace the vanilla ones.
//!
//! A layer is a skybox (one texture, six faces in a 3x2 grid, [`Face`]) drawn on top of the
//! previous layer (or the vanilla sky) with a blend ([`Blend`]), at a brightness its fade times
//! give for the time of day ([`Fade`]), on the days, in the weather, biomes and heights it names
//! ([`SkyLayer::shows`]), and turning with the time of day around its axis ([`SkyLayer::turns`]).
//! A layer is a skybox (one texture, six faces in a 3x2 grid: bottom, top and south over west,
//! north and east, the renderer's custom sky layout) drawn on top of the previous layer (or the
//! vanilla sky) with a blend ([`Blend`]), at a brightness its fade times give for the time of day
//! ([`Fade`]), on the days, in the weather, biomes and heights it names ([`SkyLayer::shows`]), and
//! turning with the time of day around its axis ([`SkyLayer::turns`]).
//! Everything here is read once at load; a frame evaluates a few numbers per layer.
//!
//! Written from OptiFine's public documentation of the format (`OptiFineDoc/doc/sky.properties`
@@ -354,76 +355,6 @@ impl Transition {
}
}
/// A face of the skybox, as the 3x2 grid of its texture holds them: `Bottom`, `Top`, `South` in
/// the top row, `West`, `North`, `East` below.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Face {
Bottom,
Top,
South,
West,
North,
East,
}
impl Face {
pub const ALL: [Self; 6] = [
Self::Bottom,
Self::Top,
Self::South,
Self::West,
Self::North,
Self::East,
];
/// The face's cell of the texture, `[u0, v0, u1, v1]` in 0..1.
pub fn cell(self) -> [f32; 4] {
let (col, row) = match self {
Self::Bottom => (0, 0),
Self::Top => (1, 0),
Self::South => (2, 0),
Self::West => (0, 1),
Self::North => (1, 1),
Self::East => (2, 1),
};
let (u, v) = (col as f32 / 3.0, row as f32 / 2.0);
[u, v, u + 1.0 / 3.0, v + 0.5]
}
/// The face's corners (on the unit cube around the camera; x east, y up, z south) in the
/// order of its cell's `(u0, v0)`, `(u1, v0)`, `(u1, v1)`, `(u0, v1)`. The side faces are
/// seen from inside with up at the top of their cell; the top face has north at the bottom of
/// its cell, the bottom face north at the top (the template's `N` marks).
pub fn corners(self) -> [[f32; 3]; 4] {
match self {
// Seen looking north: west on the left.
Self::North => [
[-1., 1., -1.],
[1., 1., -1.],
[1., -1., -1.],
[-1., -1., -1.],
],
Self::South => [[1., 1., 1.], [-1., 1., 1.], [-1., -1., 1.], [1., -1., 1.]],
Self::East => [[1., 1., -1.], [1., 1., 1.], [1., -1., 1.], [1., -1., -1.]],
Self::West => [
[-1., 1., 1.],
[-1., 1., -1.],
[-1., -1., -1.],
[-1., -1., 1.],
],
// Looking up with north toward the cell's bottom edge: east on the right.
Self::Top => [[-1., 1., 1.], [1., 1., 1.], [1., 1., -1.], [-1., 1., -1.]],
// Looking down with north toward the cell's top edge: east on the right.
Self::Bottom => [
[-1., -1., -1.],
[1., -1., -1.],
[1., -1., 1.],
[-1., -1., 1.],
],
}
}
}
impl Skies {
/// Every `sky/world<N>/` of the pack under `root`. Layers draw in the order of their number
/// (`sky1` before `sky10`); a file that fails to parse is skipped with a warning.
@@ -673,38 +604,6 @@ mod tests {
assert_eq!(t.step(0.2, 0.0, 0.0), 0.2);
}
#[test]
fn the_grid_holds_each_face_once_and_faces_point_out() {
let mut cells: Vec<_> = Face::ALL
.iter()
.map(|f| f.cell().map(|x| (x * 6.0) as i32))
.collect();
cells.dedup();
assert_eq!(cells.len(), 6);
assert_eq!(Face::East.cell(), [2.0 / 3.0, 0.5, 1.0, 1.0]);
for face in Face::ALL {
let c = face.corners();
let sub = |a: [f32; 3], b: [f32; 3]| [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
let (a, b) = (sub(c[1], c[0]), sub(c[3], c[0]));
let n = [
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
];
let centre: [f32; 3] =
std::array::from_fn(|i| c.iter().map(|p| p[i]).sum::<f32>() / 4.0);
// Seen from inside, u runs right and v down, so u x v points away from the camera.
let dot: f32 = (0..3).map(|i| n[i] * centre[i]).sum();
assert!(dot > 0.0, "{face:?} is mirrored");
}
// The top cell's bottom edge (v1) is north: its corners 2 and 3 have z = -1.
assert_eq!(Face::Top.corners()[2][2], -1.0);
assert_eq!(Face::Top.corners()[3][2], -1.0);
// The bottom cell's top edge (v0) is north.
assert_eq!(Face::Bottom.corners()[0][2], -1.0);
assert_eq!(Face::Bottom.corners()[1][2], -1.0);
}
#[test]
fn scan_reads_worlds_in_number_order_with_bodies() {
let root = std::env::temp_dir().join(format!("horizon-sky-{}", std::process::id()));
+1
View File
@@ -16,6 +16,7 @@ To play, use [the launcher](../launcher/README.md); the project site is [playhor
- [Velocity forwarding](velocity-forwarding.md): the player-information exchange behind a Velocity proxy and how it was checked.
- [The anticheat](anticheat.md): the dedicated server's own checks, off unless `server.toml`'s `[anticheat]` turns them on: violation levels, setback, alerts, kicks, the log, and how to write a check.
- [The chat filter](chat-filter.md): the dedicated server refuses hate slurs in chat, message commands, signs, anvil names and book titles: the list and its normalising, mutes, temporary bans, `[chat_filter]`.
- [Nix and NixOS](nix.md): the flake's packages: server, engine, prepare, client and launcher.
- [Hosting a server: design](server.md): `horizon-server`, the design and the plan: install, `server.toml`, the CLI, the web panel, backups, services, Docker, and the bot benchmark against vanilla.
- [The next server: design](server-next.md): Horizon's dedicated server against vanilla and Pumpkin (measured), the targets, parallel ticking that keeps vanilla's order, the network front, the Horizon fast path, the plugin API and the plan.
- [Survival multiplayer](smp.md): the official server's `smp` native mod (economy, market, trades, games of chance, spawn, homes, teleport requests, warps, land claims) and the server hook points it needs; not part of Horizon's own server.
+2
View File
@@ -66,6 +66,8 @@ Options: `--version vX.Y.Z`, `--dir FOLDER`, `--bin FOLDER` (Windows: `-Version`
such as Linux on ARM, build from source: `cargo build --release -p horizon-client --bin horizon-client -p
horizon-server` and keep both programs in one folder.
On Nix and NixOS, the repository's flake builds `horizon-server` from source: [nix.md](nix.md).
The Horizon launcher also installs releases. Every release folder holds `horizon-server`, so you can run it
from there too.
+30
View File
@@ -0,0 +1,30 @@
# Nix and NixOS
The repository is a flake (`flake.nix`, and the game's packages in `nix/`). It builds Horizon from source with nixpkgs'
`rustPlatform` and holds none of Mojang's files: the game files still come from `horizon-prepare` and your own
copy of Minecraft 26.3, as everywhere else ([hosting.md](hosting.md), step 2).
| Package | What | Systems |
|---|---|---|
| `horizon-server` | `horizon-server`, with `horizon-engine` (the game without sound or controllers) beside it | Linux, macOS (Apple Silicon) |
| `horizon-engine` | The server build of the game alone | Linux, macOS |
| `horizon-prepare` | `horizon-prepare`, with a JDK 25 for the exporters it compiles | Linux, macOS |
| `horizon-client` | The game and `horizon-ctl` | Linux, macOS |
| `horizon-launcher` | The launcher in an FHS environment (it builds native mods with Cargo) | Linux |
```sh
nix run github:horizon-rust/horizon#horizon-server -- --version
nix profile install github:horizon-rust/horizon#horizon-server
```
`overlays.default` adds them to `pkgs`; on Linux, `nix develop` gives the build environment of the workspace.
## Preparing the game files
`horizon-prepare` works in a writable folder (its pinned hashes, the exporters' sources, scratch files). The
Nix wrapper copies one to `$HORIZON_PREPARE_ROOT`, by default `~/.local/share/horizon-nix/prepare`, unless you
pass `--root`. Write the files where you want them with `--output`:
```sh
horizon-prepare --prepare --server-jar ~/Downloads/server.jar --output ~/horizon/26.3
```
+25 -5
View File
@@ -7,10 +7,26 @@
outputs = { self, nixpkgs }:
let
systems = [ "x86_64-linux" "aarch64-linux" ];
linuxSystems = [ "x86_64-linux" "aarch64-linux" ];
systems = linuxSystems ++ [ "aarch64-darwin" ];
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
forLinuxSystems = f: nixpkgs.lib.genAttrs linuxSystems (system: f nixpkgs.legacyPackages.${system});
# The game's packages, built from this workspace (nix/, docs/nix.md). Releases are tagged by
# release.yml, which never uses this flake, so a build from it says which commit it is.
version = "0-unstable-${builtins.substring 0 8 (self.lastModifiedDate or "19700101")}";
gamePackages = pkgs:
let
call = pkgs.lib.callPackageWith (pkgs // packages // { inherit version; });
packages = {
horizon-engine = call ./nix/horizon-engine.nix { };
horizon-server = call ./nix/horizon-server.nix { };
horizon-prepare = call ./nix/horizon-prepare.nix { };
horizon-client = call ./nix/horizon-client.nix { };
};
in packages;
in {
packages = forAllSystems (pkgs:
packages = forAllSystems (pkgs: gamePackages pkgs // nixpkgs.lib.optionalAttrs pkgs.stdenv.hostPlatform.isLinux (
let
version = "0.2.0";
pname = "horizon-launcher";
@@ -127,14 +143,18 @@
in {
horizon-launcher = horizonLauncher;
default = horizonLauncher;
}
})
);
overlays.default = final: prev: {
horizon-launcher = self.packages.${final.stdenv.hostPlatform.system}.horizon-launcher;
};
} // gamePackages final;
devShells = forAllSystems (pkgs: {
checks = forAllSystems (pkgs: {
inherit (self.packages.${pkgs.stdenv.hostPlatform.system}) horizon-server horizon-prepare;
});
devShells = forLinuxSystems (pkgs: {
default = pkgs.mkShell {
nativeBuildInputs = with pkgs; [
coreutils
+252 -252
View File
File diff suppressed because it is too large. Load diff
+67
View File
@@ -0,0 +1,67 @@
# The game (horizon-client, with sound, controllers and Horizon's extras) and horizon-ctl, the tool
# that drives it (docs/agent-control.md). Start it with `horizon-client --resources DIR`, DIR being
# what horizon-prepare wrote.
{
lib,
stdenv,
rustPlatform,
pkg-config,
callPackage,
alsa-lib,
udev,
vulkan-loader,
wayland,
libxkbcommon,
libx11,
libxcursor,
libxi,
libxrandr,
libxcb,
version,
}:
let
# wgpu and winit open these with dlopen, so the linker never records them.
dlopened = [
vulkan-loader
wayland
libxkbcommon
libx11
libxcursor
libxi
libxrandr
libxcb
];
in
rustPlatform.buildRustPackage {
pname = "horizon-client";
inherit version;
src = callPackage ./source.nix { };
cargoLock.lockFile = ../Cargo.lock;
cargoBuildFlags = [
"--package=horizon-client"
"--bin=horizon-client"
"--package=horizon-client-lib"
"--bin=horizon-ctl"
];
nativeBuildInputs = [ pkg-config ];
buildInputs = lib.optionals stdenv.hostPlatform.isLinux [
alsa-lib
udev
];
doCheck = false;
postFixup = lib.optionalString stdenv.hostPlatform.isLinux ''
patchelf --add-rpath ${lib.makeLibraryPath dlopened} $out/bin/horizon-client
'';
meta = {
description = "Horizon, Minecraft: Java Edition 26.3 rewritten in Rust, and horizon-ctl";
homepage = "https://playhorizon.dev";
license = lib.licenses.mit;
mainProgram = "horizon-client";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
}
+39
View File
@@ -0,0 +1,39 @@
# The server build of the game (`horizon-client --no-default-features`): the game without sound,
# controllers or Horizon's online extras, so it links neither ALSA nor udev. horizon-server runs it
# headless as `horizon-engine` (docs/hosting.md, packaging/server/Dockerfile).
{
lib,
rustPlatform,
pkg-config,
callPackage,
version,
}:
rustPlatform.buildRustPackage {
pname = "horizon-engine";
inherit version;
src = callPackage ./source.nix { };
cargoLock.lockFile = ../Cargo.lock;
cargoBuildFlags = [
"--package=horizon-client"
"--bin=horizon-client"
];
buildNoDefaultFeatures = true;
nativeBuildInputs = [ pkg-config ];
# The game's tests read the prepared vanilla files in .local/, which a build never has.
doCheck = false;
postInstall = ''
mv $out/bin/horizon-client $out/bin/horizon-engine
'';
meta = {
description = "Horizon's headless game engine, run by horizon-server";
homepage = "https://playhorizon.dev";
license = lib.licenses.mit;
mainProgram = "horizon-engine";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
}
+57
View File
@@ -0,0 +1,57 @@
# horizon-prepare: builds the game files Horizon reads from the player's own Minecraft 26.3 and the
# server jar they downloaded themselves. Nothing of Mojang's is fetched or packaged here.
#
# It works in a "root": research-lock.json (the pinned jars' hashes), the exporters' Java sources it
# compiles against those jars, and a writable .local/ for its scratch files. The store is read-only, so
# the package keeps that root in share/horizon/prepare and bin/horizon-prepare (horizon-prepare.sh)
# copies it somewhere writable unless --root is given. The exporters need a JDK of the pinned Java
# version (25): the default JAVA_HOME.
{
lib,
stdenv,
rustPlatform,
callPackage,
coreutils,
jdk25_headless,
version,
}:
rustPlatform.buildRustPackage {
pname = "horizon-prepare";
inherit version;
src = callPackage ./source.nix { };
cargoLock.lockFile = ../Cargo.lock;
cargoBuildFlags = [
"--package=horizon-prepare"
"--bin=horizon-prepare"
];
doCheck = false;
postInstall = ''
root=$out/share/horizon/prepare
install -Dm444 research-lock.json -t $root
install -Dm444 crates/horizon-prepare/src/exporters.rs -t $root/crates/horizon-prepare/src
mkdir -p $root/mods/horizon/src/test
cp -r mods/horizon/src/test/java $root/mods/horizon/src/test/
mkdir -p $out/libexec
mv $out/bin/horizon-prepare $out/libexec/horizon-prepare
substitute ${./horizon-prepare.sh} $out/bin/horizon-prepare \
--subst-var out \
--subst-var-by shell ${stdenv.shell} \
--subst-var-by coreutils ${coreutils} \
--subst-var-by java ${jdk25_headless}
chmod +x $out/bin/horizon-prepare
'';
passthru.java = jdk25_headless;
meta = {
description = "Prepare Horizon's game files from your own copy of Minecraft: Java Edition 26.3";
homepage = "https://playhorizon.dev";
license = lib.licenses.mit;
mainProgram = "horizon-prepare";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
}
+15
View File
@@ -0,0 +1,15 @@
#!@shell@
# horizon-prepare from Nix: the program wants a writable root (research-lock.json, the exporters'
# sources, .local/ for its scratch files), which the store can't be. Unless --root is given, a copy of
# the packaged root goes to $HORIZON_PREPARE_ROOT, by default ${XDG_DATA_HOME:-~/.local/share}/horizon-nix/prepare.
set -eu
export JAVA_HOME="${JAVA_HOME:-@java@}"
for arg in "$@"; do
case "$arg" in
--root | --root=*) exec @out@/libexec/horizon-prepare "$@" ;;
esac
done
root="${HORIZON_PREPARE_ROOT:-${XDG_DATA_HOME:-$HOME/.local/share}/horizon-nix/prepare}"
@coreutils@/bin/mkdir -p "$root"
@coreutils@/bin/cp -R --no-preserve=mode @out@/share/horizon/prepare/. "$root/"
exec @out@/libexec/horizon-prepare --root "$root" "$@"
+43
View File
@@ -0,0 +1,43 @@
# horizon-server: server.toml, the CLI and the supervisor. It finds the game beside itself, so the
# engine is linked into the same bin/ (crates/horizon-server/src/engine.rs `program_in`).
{
lib,
rustPlatform,
pkg-config,
callPackage,
horizon-engine,
version,
}:
rustPlatform.buildRustPackage {
pname = "horizon-server";
inherit version;
src = callPackage ./source.nix { };
cargoLock.lockFile = ../Cargo.lock;
cargoBuildFlags = [
"--package=horizon-server"
"--bin=horizon-server"
];
nativeBuildInputs = [ pkg-config ];
doCheck = false;
postInstall = ''
ln -s ${lib.getExe horizon-engine} $out/bin/horizon-engine
'';
passthru.engine = horizon-engine;
meta = {
description = "Host a Minecraft: Java Edition 26.3 server with Horizon, the game rewritten in Rust";
longDescription = ''
Holds none of Mojang's files: the server reads the game files you prepare from your own copy of
Minecraft 26.3 with horizon-prepare (docs/hosting.md).
'';
homepage = "https://playhorizon.dev";
license = lib.licenses.mit;
mainProgram = "horizon-server";
platforms = lib.platforms.linux ++ lib.platforms.darwin;
};
}
+30
View File
@@ -0,0 +1,30 @@
# The game workspace's sources: everything a `cargo build` at the root reads, without the folders it never
# does (the launcher is its own workspace), so editing those rebuilds nothing.
{ lib }:
let
root = ../.;
skipped = [
".github"
".githooks"
".claude"
".local"
"benchmarks"
"launcher"
"nix"
"target"
"flake.nix"
"flake.lock"
];
in
lib.cleanSourceWith {
name = "horizon-source";
src = lib.cleanSource root;
filter =
path: _type:
let
relative = lib.removePrefix (toString root + "/") (toString path);
in
!(lib.any (top: relative == top || lib.hasPrefix (top + "/") relative) skipped)
# The pages of docs/ (the code reads only some of its data files).
&& !(lib.hasPrefix "docs/" relative && lib.hasSuffix ".md" relative);
}
+22
View File
@@ -6838,6 +6838,27 @@ def living_short_timers():
return ops + setup('/tick unfreeze', f'/kill @e[type=!player,x={x0},y={y0 - 8},z={z0},dx=16,dy=32,dz=16]',
f'/forceload remove {x0} {z0}')
def command_block_last_output():
"""#13077: `BaseCommandBlock` keeps the feedback it got as a component (stamped with the time:
`Component.literal("[HH:mm:ss] ").append(message)`) and saves it with
`ComponentSerialization.CODEC`: a failed command keeps its red, click-to-suggest error."""
x0, y0, z0 = 3904, 260, 3648
ops = [cmd(f'/forceload add {x0} {z0}', False), {'op': 'wait_chunks', 'chunks': [[x0 >> 4, z0 >> 4]]},
cmd('/scoreboard objectives add cb_out dummy', False)]
# Two failures, whose styled feedback (red, click to suggest the command) is what the save must keep.
# A success's feedback names an entity, a new score holder or a bracketed value, whose components
# the two engines build differently (or which change later groups' scoreboard lists).
commands = ['say', 'scoreboard players get #nobody cb_out']
at = [(x0 + 2 + 3 * n, y0, z0 + 4) for n in range(len(commands))]
for command, (x, y, z) in zip(commands, at):
ops.append(cmd(f'/setblock {x} {y} {z} minecraft:command_block{{Command:"{command}"}}', expect_result=1))
ops.append(cmd(f'/setblock {x} {y + 1} {z} minecraft:redstone_block', expect_result=1))
ops.append({'op': 'tick', 'n': 3})
for x, y, z in at:
ops.append(cmd(f'/data get block {x} {y} {z} LastOutput'))
return ops + setup(f'/fill {x0} {y0} {z0} {x0 + 15} {y0 + 1} {z0 + 15} air',
'/scoreboard objectives remove cb_out', f'/forceload remove {x0} {z0}')
def loaded_ticket_removal(blocks=False):
"""Entity-ticking command visibility after forced tickets leave; FULL is separate."""
targets = [(d, x, z) for d in range(2, 7)
@@ -7421,6 +7442,7 @@ def groups():
('piston entity tick order', piston_entity_tick_order()),
('primed tnt box', primed_tnt_box()),
('primed tnt fall', primed_tnt_fall()),
('command block last output', command_block_last_output()),
('living short timers', living_short_timers()),
('projectile fall distance', projectile_fall_distance()),
('item fall distance', item_fall_distance()),
-1
View File
@@ -95,7 +95,6 @@
"horizon-client -> horizon-shaders",
"horizon-client -> horizon-telemetry",
"horizon-client -> horizon-theme",
"horizon-client-input -> horizon-theme",
"horizon-client-lib -> horizon-anticheat",
"horizon-client-lib -> horizon-bench",
"horizon-client-lib -> horizon-chat-filter",
+1 -1
View File
@@ -142,7 +142,7 @@ AbstractTextAreaWidget.extractBackground skip:ui frame drawn by the book screen
AbstractTextAreaWidget.extractBorder skip:ui frame sprite; no behaviour
AbstractTextAreaWidget.withinContentAreaTopBottom skip:ui visibility cull while drawing lines
AbstractWidget.extractRenderState port crates/horizon-client-render/src/ui.rs::hovered gap: minor: the scissor test is not part of the hover check
AbstractWidget.extractTooltipForNextRenderPass port crates/horizon-client-render/src/ui.rs::tooltip gap: minor: unsure: widget hover delay not checked
AbstractWidget.extractTooltipForNextRenderPass port crates/horizon-client-render/src/ui.rs::tooltip widgets with a delay wait it out: the social rows' buttons use social_screen::TooltipDelay (PlayerEntry.TOOLTIP_DELAY, 500 ms); others have vanilla's zero delay
AbstractWidget.extractScrollingStringOverContents port crates/horizon-client-render/src/ui.rs::scrolling_text ui::button and ui::slider (margin 2) draw it for the HudBatch widgets; screen_draw.rs::scrolling_string_over_contents for the screen_draw ones
AbstractWidget.mouseClicked port crates/horizon-client/src/title.rs::Title::click gap: minor: click dispatch is per screen; the press sound is audio.rs Audio::play_button_click
AbstractWidget.mouseReleased port crates/horizon-client-render/src/ui.rs::Scroll::release gap: minor: only the scroll release; widget onRelease is per screen
1 ActiveTextCollector.accept inline crates/horizon-client-render/src/screen_draw.rs::text default overload: left-aligned text at x,y with default parameters
142 AbstractTextAreaWidget.extractBorder skip:ui frame sprite; no behaviour
143 AbstractTextAreaWidget.withinContentAreaTopBottom skip:ui visibility cull while drawing lines
144 AbstractWidget.extractRenderState port crates/horizon-client-render/src/ui.rs::hovered gap: minor: the scissor test is not part of the hover check
145 AbstractWidget.extractTooltipForNextRenderPass port crates/horizon-client-render/src/ui.rs::tooltip gap: minor: unsure: widget hover delay not checked widgets with a delay wait it out: the social rows' buttons use social_screen::TooltipDelay (PlayerEntry.TOOLTIP_DELAY, 500 ms); others have vanilla's zero delay
146 AbstractWidget.extractScrollingStringOverContents port crates/horizon-client-render/src/ui.rs::scrolling_text ui::button and ui::slider (margin 2) draw it for the HudBatch widgets; screen_draw.rs::scrolling_string_over_contents for the screen_draw ones
147 AbstractWidget.mouseClicked port crates/horizon-client/src/title.rs::Title::click gap: minor: click dispatch is per screen; the press sound is audio.rs Audio::play_button_click
148 AbstractWidget.mouseReleased port crates/horizon-client-render/src/ui.rs::Scroll::release gap: minor: only the scroll release; widget onRelease is per screen
+1
View File
@@ -105,6 +105,7 @@ EditBox.setMaxLength port crates/horizon-client-render/src/ui.rs::Caret::sync ga
EditBox.setFocused port crates/horizon-client-render/src/ui.rs::Caret::draw focus restarts the blink clock here; gap: minor: no IME or narration side effects
EditBox.setEditable missing minor: unsure: no read-only flag on the box in ui.rs; a box vanilla locks would accept typing
EditBox.setHighlightPos port crates/horizon-client-render/src/ui.rs::EditBox::set_cursor sets the highlight and scrolls
EditBox.setCentered port crates/horizon-client-render/src/ui.rs::EditBox::text_start_x the text and a click start (width - text width) / 2 in; BookSignScreen's title box sets it
EditBox.setHint inline crates/horizon-client/src/title/create_world.rs::Title::create_field the hint is passed when the box is made and drawn while it is empty and unfocused
FittingMultiLineTextWidget.setWidth skip:ui layout: wrap width
FittingMultiLineTextWidget.minimizeHeight skip:ui layout: height to content
1 BossHealthOverlay.extractBar port crates/horizon-client-render/src/hud_overlays.rs::boss_overlay background bar, then progress at the lerped width; the notch overlay only when not PROGRESS
105 EditBox.setFocused port crates/horizon-client-render/src/ui.rs::Caret::draw focus restarts the blink clock here; gap: minor: no IME or narration side effects
106 EditBox.setEditable missing minor: unsure: no read-only flag on the box in ui.rs; a box vanilla locks would accept typing
107 EditBox.setHighlightPos port crates/horizon-client-render/src/ui.rs::EditBox::set_cursor sets the highlight and scrolls
108 EditBox.setCentered port crates/horizon-client-render/src/ui.rs::EditBox::text_start_x the text and a click start (width - text width) / 2 in; BookSignScreen's title box sets it
109 EditBox.setHint inline crates/horizon-client/src/title/create_world.rs::Title::create_field the hint is passed when the box is made and drawn while it is empty and unfocused
110 FittingMultiLineTextWidget.setWidth skip:ui layout: wrap width
111 FittingMultiLineTextWidget.minimizeHeight skip:ui layout: height to content
+1 -1
View File
@@ -2,7 +2,7 @@ WinScreen.wrapCreditsIO port crates/horizon-client/src/win_screen.rs::WinScreen:
WinScreen.extractRenderState port crates/horizon-client/src/win_screen.rs::WinScreen::draw logo, lines scrolled up, last line stops mid-screen; background and vignette are drawn here too
WinScreen.extractVignette inline crates/horizon-client/src/win_screen.rs::WinScreen::draw credits_vignette sprite stretched over the screen
WinScreen.extractMenuBackground port crates/horizon-client/src/win_screen.rs::WinScreen::menu_background menu background tiled and scrolled at half the text speed
WorldOptionsScreen.init port crates/horizon-client/src/game/lan_screen.rs::Game::open_world_options reads difficulty, lock, LAN port and command/force settings when opened; layout is WorldOptions::buttons
WorldOptionsScreen.init port crates/horizon-client/src/game/lan_screen.rs::Game::open_world_options reads difficulty, lock, LAN port and command/force settings when opened; layout is WorldOptions::buttons; the port box's PORT_INFO_TEXT tooltip is WorldOptions::port_tooltip
WorldOptionsScreen.createGameRulesButton inline crates/horizon-client/src/game/lan_screen.rs::Game::world_options_click GameRules arm opens the rules view; enabled state is in WorldOptions::active
WorldOptionsScreen.createGameModeButton inline crates/horizon-client/src/game/lan_screen.rs::Game::world_options_click Default and Personal mode arms cycle the mode; enabled state is in WorldOptions::active
WorldOptionsScreen.createAllowCommandsButton inline crates/horizon-client/src/game/lan_screen.rs::Game::world_options_click Cheats arm toggles commands; the hardcore lock is in WorldOptions::active
1 WinScreen.wrapCreditsIO port crates/horizon-client/src/win_screen.rs::WinScreen::new reads the credits texts from the resource root; a failed read is logged and skipped
2 WinScreen.extractRenderState port crates/horizon-client/src/win_screen.rs::WinScreen::draw logo, lines scrolled up, last line stops mid-screen; background and vignette are drawn here too
3 WinScreen.extractVignette inline crates/horizon-client/src/win_screen.rs::WinScreen::draw credits_vignette sprite stretched over the screen
4 WinScreen.extractMenuBackground port crates/horizon-client/src/win_screen.rs::WinScreen::menu_background menu background tiled and scrolled at half the text speed
5 WorldOptionsScreen.init port crates/horizon-client/src/game/lan_screen.rs::Game::open_world_options reads difficulty, lock, LAN port and command/force settings when opened; layout is WorldOptions::buttons reads difficulty, lock, LAN port and command/force settings when opened; layout is WorldOptions::buttons; the port box's PORT_INFO_TEXT tooltip is WorldOptions::port_tooltip
6 WorldOptionsScreen.createGameRulesButton inline crates/horizon-client/src/game/lan_screen.rs::Game::world_options_click GameRules arm opens the rules view; enabled state is in WorldOptions::active
7 WorldOptionsScreen.createGameModeButton inline crates/horizon-client/src/game/lan_screen.rs::Game::world_options_click Default and Personal mode arms cycle the mode; enabled state is in WorldOptions::active
8 WorldOptionsScreen.createAllowCommandsButton inline crates/horizon-client/src/game/lan_screen.rs::Game::world_options_click Cheats arm toggles commands; the hardcore lock is in WorldOptions::active
+1 -1
View File
@@ -59,7 +59,7 @@ ReportReasonSelectionScreen skip:service abuse report reason screen; Horizon ha
ReportReasonSelectionScreen.ReasonSelectionList skip:service reason list of the report screen; Horizon has no reporting
ReportReasonSelectionScreen.ReasonSelectionList.Entry skip:service reason row of the report screen; Horizon has no reporting
SkinReportScreen skip:service skin abuse report screen; Horizon has no reporting
PlayerEntry skip:service social list row with report, hide and status; Horizon has no social list
PlayerEntry port crates/horizon-client/src/social_screen.rs::Entry the row and its report (disabled) and hide/show buttons; the list, pages and search are social_screen's SocialView/draw, the state is game/social.rs::Social; the buttons' 500 ms tooltip delay is social_screen::TooltipDelay
PlayerSocialManager skip:service Mojang social manager: block, friends and message hiding; Horizon has none of it
PresenceHandler skip:service online presence sharing with the service; Horizon has none
RemoteFriendListUpdateHandler skip:service friend list polling and toasts from the service; Horizon has no friend list (its relay is separate)
1 GhostSlots.extractTooltip port crates/horizon-client/src/game/recipe_book.rs::Game::ghost_stack the stack a ghost slot is showing, cycling like the ghost's own draw, becomes that slot's tooltip: draw_slot_tooltip asks ghost_stack first, so a ghost's tooltip wins over the slot's own
59 ReportReasonSelectionScreen.ReasonSelectionList skip:service reason list of the report screen; Horizon has no reporting
60 ReportReasonSelectionScreen.ReasonSelectionList.Entry skip:service reason row of the report screen; Horizon has no reporting
61 SkinReportScreen skip:service skin abuse report screen; Horizon has no reporting
62 PlayerEntry skip:service port crates/horizon-client/src/social_screen.rs::Entry social list row with report, hide and status; Horizon has no social list the row and its report (disabled) and hide/show buttons; the list, pages and search are social_screen's SocialView/draw, the state is game/social.rs::Social; the buttons' 500 ms tooltip delay is social_screen::TooltipDelay
63 PlayerSocialManager skip:service Mojang social manager: block, friends and message hiding; Horizon has none of it
64 PresenceHandler skip:service online presence sharing with the service; Horizon has none
65 RemoteFriendListUpdateHandler skip:service friend list polling and toasts from the service; Horizon has no friend list (its relay is separate)
+2
View File
@@ -727,6 +727,8 @@ MASKS = [
(re.compile(r'Average time per tick: [0-9.]+ms'), 'Average time per tick: <ms>'),
(re.compile(r'P50: [0-9.]+ms P95: [0-9.]+ms P99: [0-9.]+ms'), 'P50: <ms> P95: <ms> P99: <ms>'),
(re.compile(r'debug/client-[0-9_.-]+\.(jfr|json)'), 'debug/client-<time>'),
# BaseCommandBlock.sendSystemMessage stamps the last output with the wall clock.
(re.compile(r'\[\d\d:\d\d:\d\d\] '), '[<time>] '),
]